Skip to content

Add experimental, opt-in component caching - #2685

Open
joelhawksley wants to merge 12 commits into
mainfrom
experimentally-cacheable
Open

Add experimental, opt-in component caching#2685
joelhawksley wants to merge 12 commits into
mainfrom
experimentally-cacheable

Conversation

@joelhawksley

@joelhawksley joelhawksley commented Aug 20, 2026

Copy link
Copy Markdown
Member

Closes #234.

What are you trying to accomplish?

Make ViewComponents work with Rails fragment caching. Today this silently serves stale markup:

<% cache @post do %>
  <%= render PostComponent.new(post: @post) %>
<% end %>

Editing PostComponent's template, Ruby class, or sidecar files doesn't invalidate the fragment. #234 has been open since February 2020.

What approach did you choose and why?

This reconciles the two approaches in the thread. They turned out to solve different sub-problems, and the disagreement dissolves once they're separated:

Sub-problem #2126 tildeio/view_component-cache_digest
Invalidation — detect when a component's sources change Reimplements the digest: compiles templates via ERB::Compiler, walks render deps with Prism, hashes sources itself Plugs into Rails' existing Digestor/DependencyTracker so the same tree Rails uses for partials sees components
Ergonomics — declare a cache key on the component cache_on macro Out of scope

@chancancode's point in this comment is correct: for invalidation, reimplementing Rails' cache-digest static analysis works against the grain. Rails already moved that analysis to a Prism-based RubyTracker, handles the render-graph cascade, and injects the digest into every <% cache %> key. The known limitations in #2126 — partial/layout string deps, the RAILS_CACHE_ID workaround, the child-invalidation gaps @JWShuff and @timburgan kept hitting — are exactly the cases Rails' Digestor already handles.

But #2126's cache_on is genuinely better ergonomics than hand-writing <% cache [key, Component.digest] %> at every call site, and @chancancode said so himself: "A Ruby-level DSL to express a cache key for the component can be a nice separate addition."

So: adopt the Digestor integration as the invalidation engine, keep cache_on as an ergonomic layer on top, drop the parallel digest machinery.

The three phases

Phase 1 — Rails-integrated invalidation. Two hooks:

  • ViewComponent::CacheDigest::DependencyTracking prepends ActionView::DependencyTracker.find_dependencies so component renders are reported as dependencies. Hooking the tracker registry rather than a specific tracker means this works identically for ERBTracker, the Prism RubyTracker, and the Haml/Slim gems' trackers, without depending on any of their internals.
  • ViewComponent::CacheDigest::Resolver synthesizes the template the Digestor asks for, encoding content hashes of the .rb file, sidecar files, and superclasses, plus template and inline-template source verbatim so nested partials and components are discovered.

Phase 2 — cache_on rebased on Phase 1. Supplies the value half of the key; the digest half comes from Rails' Digestor. No bespoke digest engine.

Phase 3 — standalone digests. Component.cache_digest works outside a request, reusing the same Digestor tree rather than a second implementation.

Both of view_component-cache_digest's documented limitations are resolved

That gem discloses two limitations. Because our resolver holds the component class (it resolves through a registry populated by the include) rather than only a file path, it can do things the standalone gem couldn't.

Inheritance/superclass are not tracked as it would require loading the Ruby class/evaluating the component Ruby file and can be hairy/unreliable.

Resolved. component_ancestors walks .ancestors up to ViewComponent::Base and hashes each one's Ruby file and sidecar files. This also closes the gap @rnestler raised against @cannikin's workaround: editing ApplicationComponent now invalidates everything that inherits from it.

Ruby files are not scanned by ERBTracker so inline templates and call method components do not get their implicit dependencies synthesized.

Resolved. Inline template sources are embedded in the synthetic template, so the standard tracker discovers what they render. Components rendered from Ruby are matched against the opt-in registry. And partials referenced by string path from Ruby are extracted with Rails' own render parserActionView::RenderParser, the same one RubyTracker runs over compiled templates — rather than a second implementation of the same analysis.

That parser is speculative by design: it turns render @thing into things/_thing and render FooComponent.new into news/_new, on the theory that a template might have such a partial. For a Ruby file those resolve to nothing and would only add Couldn't find template for digesting noise to production logs, so results are narrowed to paths appearing verbatim in the source. String literals survive; inferred paths don't.

Measured, not assumed — each row is a test:

Dependency Before Now
Inline template → child component
Inline template → partial
#call method → child component
#call method → child component's Ruby file
#call method → partial by string path
Superclass template / Ruby file

# Template Dependency: is still supported and still needed for genuinely dynamic renders (render @component, interpolated paths) — the same cases Rails itself can't resolve statically in a template.

Opt-in

Per-component:

class PostComponent < ViewComponent::Base
  include ViewComponent::ExperimentallyCacheable

  cache_on :post
end

Nothing is installed until a component includes the module, and both hooks short-circuit while the registry is empty. Applications that never opt in are unaffected — verified by the existing suite passing unchanged.

⚠️ This API is experimental

It may change or be removed in a non-major release. That's disclosed in the module docs, the guide, and the CHANGELOG. Shipping it opt-in and per-component is what makes iterating on it safe.

We're asking for feedback in the release note, specifically on:

  • whether cache_on is the right shape for declaring cache keys
  • behavior with slots and content blocks
  • whether # Template Dependency: is a sufficient escape hatch for dynamic renders

What's tested

Full invalidation matrix (each input, plus negative tests that unrelated components and inferred partial paths don't invalidate), end-to-end integration tests driving real requests through <% cache %>, output-caching behavior, and HTML-safety of cached output. 597 minitests + 5 engine tests + 8 specs green; 100% line coverage on all four new files.

Safety notes

  • Content blocks are not cached. A block's content isn't part of the cache key, so caching is skipped rather than risk serving one caller's content to another. Documented, with guidance.
  • Both hooks rescue and degrade to pre-existing behavior. A broken digest never breaks a render.
  • Cached output is marked html_safe only because it was produced by the same rendering pipeline that escaped it — same contract as Rails' cache helper.

Anything you want to highlight for special attention from reviewers?

  1. docs/api.md has unrelated churn. It's auto-generated by rake docs:build and had already drifted from main; regenerating picks up those pre-existing deltas alongside the two new error classes. Happy to split that out.
  2. Since 4.14.0 in the guide is a guess at the next minor — adjust as needed.
  3. Rails 7.1 wasn't testable locally (it pins Ruby ~> 3.2; this machine runs 4.1-dev). Two version differences are handled: ActionView::RenderParser is a class in 7.1 and a module with Default in 7.2+, covered by a test that exercises both shapes rather than relying on matrix collation. Everything else is identical across 8.0/8.1 and stable since Rails 6, but CI's matrix is the real check.
  4. Constantizing during static analysis. component_paths_in calls safe_constantize on constants matched in source to check opt-in status. This autoloads a class that's about to be rendered anyway, and is wrapped in a rescue — but it's the design decision I'd most like a second opinion on. It's also what buys the resolved limitations above, so it's a real trade rather than an incidental choice.

Credit

This is assembled from the community's work, not written from scratch:

@reeganviljoen — you've carried this for two years and this PR wouldn't exist without #2126. Would very much like your review, especially on whether cache_on survived the rebase onto the Digestor with its ergonomics intact.

@chancancode — thank you for open-sourcing the patch and documenting the reasoning so thoroughly. No obligation given you said you can't shepherd it, but if you have the time, a sanity check on the tracker/resolver seams would be invaluable — particularly the constantize-during-analysis trade in note 4, and whether narrowing RenderParser output to verbatim matches is a sound way to reuse it against Ruby files.

joelhawksley and others added 8 commits August 20, 2026 11:06
Closes #234.

Integrates components into Rails' template digest tree rather than
reimplementing static analysis, so a `<% cache %>` block wrapping a
component is invalidated when the component changes.

Opt in per component with `include ViewComponent::ExperimentallyCacheable`.
Adding `cache_on` caches the component's own rendered output, and
`.cache_digest` exposes the digest outside a request.

The API is experimental and may change or be removed in a non-major
release.

Co-authored-by: Reegan Viljoen <reeganviljoen@users.noreply.github.com>
Co-authored-by: Godfrey Chan <chancancode@users.noreply.github.com>
Co-authored-by: JWShuff <JWShuff@users.noreply.github.com>
Co-authored-by: timburgan <timburgan@users.noreply.github.com>
Co-authored-by: Patrick Arnett <patrickarnett@users.noreply.github.com>
Resolves both limitations documented by view_component-cache_digest.

Inheritance: superclass templates and Ruby files were already tracked,
since the resolver holds the component class and can walk its ancestors.

Ruby files: inline template sources are now embedded in the synthetic
digest template, and component renders in Ruby (`#call` methods, helper
methods) are emitted as explicit dependencies. Previously these were
invisible because Action View's trackers only read template files.

The one remaining case is a partial referenced by string path from a
`#call` method, which still needs `# Template Dependency:`. Discovering
it would mean reimplementing Rails' partial static analysis for Ruby.
Removes the last dependency kind that needed the escape hatch.

Runs Rails' own render parser -- the one RubyTracker uses on compiled
templates -- over the component's Ruby source, rather than adding a
second implementation of the same analysis.

Results are narrowed to paths that appear verbatim in the source. That
keeps string literals and discards the speculative entries the parser
infers from dynamic renders (`render @thing` becomes `things/_thing`,
`render FooComponent.new` becomes `news/_new`), which would resolve to
nothing and only add "Couldn't find template for digesting" log noise.
Components rendered from Ruby are already found precisely elsewhere.
Rails' escape hatch takes a template path, but the path a component is
digested under is an internal detail. Requiring
`# Template Dependency: view_component/cache_digest/post_component` in
application code would freeze that path as public API for an
experimental feature.

Component class names are now translated to the path the Digestor can
resolve, so a dynamically rendered component is declared as
`# Template Dependency: PostComponent`. The raw class name is replaced
rather than added, since Rails would otherwise resolve it as a template
path, find nothing, and log about it.

Works in both Ruby files and templates. Document with examples for
runtime-chosen components.
Caller-provided content isn't part of the cache key, so caching it risks
serving one caller's content to another. Previously the cache was
silently skipped, which is hard to notice: the component just quietly
stops being cached.

Passing a block or `with_content` to a component that declares
`cache_on` now raises ContentPassedToCachedComponentError. Raised
whether or not caching is enabled, so the conflict surfaces in
development and test rather than only in production.

Components without `cache_on` are unaffected and still accept content.
Slot content set by the caller is no more part of the cache key than a
block is, so it carries the same risk of serving one caller's content to
another.

Checked before rendering via @__vc_set_slots, which callers populate
through `with_*` setters. Slots a component fills in for itself with a
`default_*` method resolve lazily during the render, so they aren't
counted and are cached normally.
@reeganviljoen

Copy link
Copy Markdown
Collaborator

Im so hyped and happy about this @joelhawksley , Im going through some testing on my side, I hope we can get this shipped soon ❤️ 🚀

Four issues, all surfaced by jobs that can't run on this machine:

- The render parser shape test hardcoded ActionView::RenderParser::Default,
  which exists only on Rails 7.2-8.1. Both 7.1 and main ship the parser as
  a class. The selection logic is now a pure function tested with doubles,
  so both shapes are covered on every version, and the result is resolved
  once into RENDER_PARSER at load time.

- A test-ordering leak: `with_new_cache` compiles components against
  whatever is on disk, then restores the previous compile cache, so a
  component compiled while its template was modified kept rendering the
  modified output after the file was restored. Force a recompile.

- Ractor readiness: replaced the memoized @render_parser and @instance with
  load-time constants, and dropped the @installed flag since both steps of
  `install!` are already indi  `install!` are already indi  `install!` are already indi  `install!` are already indi  `install!` are already indi ing whitespace in the caching guide.
@reeganviljoen

Copy link
Copy Markdown
Collaborator

Hey @joelhawksley I tested this locally and the overall and I like the include ExperimentallyCacheable / cache_on split. I did however find two cache-key issues that look worth addressing before merging.

So here they are:

1. Different formats(html, json, etc) can share cached output

Format is passed to cache_digest, but it is not included explicitly in cache_key. When the digest is identical across formats, HTML and JSON(or any other format) share a cache entry.

I reproduced it with:

class FormatSensitiveCacheableComponent < ViewComponent::Base
  include ViewComponent::ExperimentallyCacheable

  cache_on :identity

  def call
    view_context.lookup_context.formats.first.to_s
  end

  private

  def identity  
    "same-component"
   end
end

test:

with_caching do
  with_format(:html) do
    render_inline(FormatSensitiveCacheableComponent.new)
    assert_text "html"
  end

  with_format(:json) do
    render_inline(FormatSensitiveCacheableComponent.new)

    # Fails: returns the cached "html"
    assert_text "json"
  end
end

2. Positional nil values can collide

Because the complete key array is compacted, distinct cache_on values can generate the same key:

class PositionalCacheKeyComponent < ViewComponent::Base
  include ViewComponent::ExperimentallyCacheable

  cache_on :first, :second

  def initialize(first:, second:)
    @first = first
    @second = second
  end

  private

  attr_reader :first, :second
end

test:

left = PositionalCacheKeyComponent.new(
  first: nil,
  second: "same"
)

right = PositionalCacheKeyComponent.new(
  first: "same",
  second: nil
)

# Fails: the keys are identical
refute_equal left.cache_key, right.cache_key

I think these are easy to fix and wouldn't even mind adding them myself if you want, I would just feel bad shipping his to users with these two issues

@reeganviljoen

Copy link
Copy Markdown
Collaborator

I found some further issues, I hope Im not spamming, Im hoping to help here 😄

I am going to skip the formalities of an introduction for the next few items for brevity so here we go

the conditional caching stuff has the following issue:

class ConditionalCacheProbeComponent < ViewComponent::Base
  include ViewComponent::ExperimentallyCacheable

  cache_on :cacheable?

  class_attribute :render_count, default: 0

  def initialize(cacheable:)
    @cacheable = cacheable
  end

  def call
    self.class.render_count += 1
    "render-#{self.class.render_count}".html_safe
  end

  private

  def cacheable?
    @cacheable
  end
end

test:

ConditionalCacheProbeComponent.render_count = 0

with_caching do
  render_inline(
    ConditionalCacheProbeComponent.new(cacheable: false)
  )
  first = rendered_content

  render_inline(
    ConditionalCacheProbeComponent.new(cacheable: false)
  )
  second = rendered_content

  assert_equal first, second
  assert_equal 1, ConditionalCacheProbeComponent.render_count
end

Even though cacheable? returns false, the component renders only once, its because false becomes an ordinary cache-key value so it does not disable caching.

Returning nil also does not disable caching, it is instead removed from the key.

@reeganviljoen

Copy link
Copy Markdown
Collaborator

A block is silently ignored in these examples:

class BlockCacheProbeComponent < ViewComponent::Base
  include ViewComponent::ExperimentallyCacheable

  cache_on { :identity }
end

test:

BlockCacheProbeComponent.__vc_cache_on
# => []

BlockCacheProbeComponent.__vc_caches_output?
# => false

Passing a proc explicitly raises:

class ProcCacheProbeComponent < ViewComponent::Base
  include ViewComponent::ExperimentallyCacheable

  cache_on -> { :identity }
end
NoMethodError: undefined method `to_sym` for an instance of Proc

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Components should play nicely with Rails caching mechanisms

2 participants