Add experimental, opt-in component caching - #2685
Conversation
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.
|
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.
…xperimentally-cacheable
|
Hey @joelhawksley I tested this locally and the overall and I like the So here they are: 1. Different formats(html, json, etc) can share cached outputFormat is passed to 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
endtest: 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
end2. Positional
|
|
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
endtest: 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
endEven though Returning |
|
A block is silently ignored in these examples: class BlockCacheProbeComponent < ViewComponent::Base
include ViewComponent::ExperimentallyCacheable
cache_on { :identity }
endtest: BlockCacheProbeComponent.__vc_cache_on
# => []
BlockCacheProbeComponent.__vc_caches_output?
# => falsePassing a proc explicitly raises: class ProcCacheProbeComponent < ViewComponent::Base
include ViewComponent::ExperimentallyCacheable
cache_on -> { :identity }
end |
Closes #234.
What are you trying to accomplish?
Make ViewComponents work with Rails fragment caching. Today this silently serves stale markup:
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:
ERB::Compiler, walksrenderdeps with Prism, hashes sources itselfDigestor/DependencyTrackerso the same tree Rails uses for partials sees componentscache_onmacro@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, theRAILS_CACHE_IDworkaround, the child-invalidation gaps @JWShuff and @timburgan kept hitting — are exactly the cases Rails' Digestor already handles.But #2126's
cache_onis 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_onas an ergonomic layer on top, drop the parallel digest machinery.The three phases
Phase 1 — Rails-integrated invalidation. Two hooks:
ViewComponent::CacheDigest::DependencyTrackingprependsActionView::DependencyTracker.find_dependenciesso component renders are reported as dependencies. Hooking the tracker registry rather than a specific tracker means this works identically forERBTracker, the PrismRubyTracker, and the Haml/Slim gems' trackers, without depending on any of their internals.ViewComponent::CacheDigest::Resolversynthesizes the template the Digestor asks for, encoding content hashes of the.rbfile, sidecar files, and superclasses, plus template and inline-template source verbatim so nested partials and components are discovered.Phase 2 —
cache_onrebased 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_digestworks outside a request, reusing the same Digestor tree rather than a second implementation.Both of
view_component-cache_digest's documented limitations are resolvedThat 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.Resolved.
component_ancestorswalks.ancestorsup toViewComponent::Baseand hashes each one's Ruby file and sidecar files. This also closes the gap @rnestler raised against @cannikin's workaround: editingApplicationComponentnow invalidates everything that inherits from it.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 parser —
ActionView::RenderParser, the same oneRubyTrackerruns over compiled templates — rather than a second implementation of the same analysis.That parser is speculative by design: it turns
render @thingintothings/_thingandrender FooComponent.newintonews/_new, on the theory that a template might have such a partial. For a Ruby file those resolve to nothing and would only addCouldn't find template for digestingnoise 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:
#callmethod → child component#callmethod → child component's Ruby file#callmethod → partial by string path# 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:
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.
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:
cache_onis the right shape for declaring cache keys# Template Dependency:is a sufficient escape hatch for dynamic rendersWhat'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
html_safeonly because it was produced by the same rendering pipeline that escaped it — same contract as Rails'cachehelper.Anything you want to highlight for special attention from reviewers?
docs/api.mdhas unrelated churn. It's auto-generated byrake docs:buildand had already drifted frommain; regenerating picks up those pre-existing deltas alongside the two new error classes. Happy to split that out.Since 4.14.0in the guide is a guess at the next minor — adjust as needed.~> 3.2; this machine runs 4.1-dev). Two version differences are handled:ActionView::RenderParseris a class in 7.1 and a module withDefaultin 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.component_paths_incallssafe_constantizeon 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:
cache_onAPI and the case for component-local caching (Add first class component cache #2126)view_component-cache_digest) and the comment that reframed the problemview_component-fragment_caching, which those cases came from@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_onsurvived 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
RenderParseroutput to verbatim matches is a sound way to reuse it against Ruby files.