Add first class component cache - #2126
Conversation
7544079 to
7533a9c
Compare
|
@joelhawksley @Spone @camertron I would appreciate any thoughts on this. |
There was a problem hiding this comment.
@reeganviljoen THANK YOU for taking a crack at building something functional to address the long-overdue need for caching in ViewComponent.
To be honest with you, I have not used caching in Rails nearly enough to feel like I can properly assess this PR in terms of how it would integrate with the existing mental model of caching in Rails. I'm happy to learn that context, but I won't have the space to do so for at least a couple of weeks.
@Spone @boardfish have y'all done much with Rails' caching? @reeganviljoen if you know of anyone else in the community who would be a good reviewer here, I'd be happy to have them.
@joelhawksley I really aprreciate the honesty, I will see if I can find anyone with cache experience to take a crack at reviewing this ❤️ |
|
@joelhawksley Afraid it's not something I've really interacted with directly until now, sorry! I'd be coming from a similar place. Great to see @reeganviljoen working at it, though ✨ |
JWShuff
left a comment
There was a problem hiding this comment.
Reegan, really excited to see this. I'm going to take a shot at adapting our gem spec suite to your fork and open a PR at your fork with those specs by way of giving us a place to start that gives us the most cache functionality we can get.
| include ViewComponent::Slotable | ||
| include ViewComponent::Translatable | ||
| include ViewComponent::WithContentHelper | ||
| include ViewComponent::Cacheable |
There was a problem hiding this comment.
Suggestion, nitpick and nonblocking: Can we slide this in alphabetically above? I'd think it'd be ~ line 32
| __vc_cache_dependencies.push(*args) | ||
| end | ||
|
|
||
| def inherited(child) |
There was a problem hiding this comment.
We took this for a spin in a few cases of our cached partials and view components that we are familiar with and largely it works for the base cases. This is working to correctly bust a cached VC when it changes or things in the render path 'above' (partials or VCs) it change.
What we aren't seeing is when a VC renders another VC or partial as a child that downstream changes of the cached VC pick up changes and handle it. I'm going to fork your branch and offer some test cases based on an approach we use in the vc fragment caching gem (what we're currently solving this with)
Effectively we want some way for a child partial or VC change (in either the .rb or template) to bust the cached parent.
There was a problem hiding this comment.
also big thanks for testing it, I can look at adding the touch true thing later this week
There was a problem hiding this comment.
@reeganviljoen I'd expect us to test this with changes to child partials.
JWShuff
left a comment
There was a problem hiding this comment.
Added a hypothetical approach to an integration test for the behavior.
There was a problem hiding this comment.
@reeganviljoen I am not a git-wizard, much to my eternal shame, but I set up a fork and adjusted the spec approach here to use the integration examples/controllers to assert the behavior. Feel free to take, leave, or otherwise.
There's a wider challenge around partial/template digesting, and on a quiet day I'll port our spec suite over to the VC style and get it implemented so we have all the permutations we know of that need to cache appropriately.
|
@JWShuff I investigated a few of your suggestions, added a few, thanks, and others I am struggling with |
|
@joelhawksley with a live use case tested how do you feel about this now |
cb357ca to
06bc05a
Compare
…v_add_component_caching
|
@reeganviljoen when you have some time, I'd be curious about your thoughts on @timburgan's PR ❤️ |
|
@joelhawksley I have left a small comment on @timburgen's pr, the preliminary review I did leads me to believe it would be a good course of action to bring his commits in here |
The cache digestor reached into ActionView::Template.template_handler_extensions and hardcoded %w[yml yaml], the latter duplicating Translatable's TRANSLATION_EXTENSIONS. Expose sidecar_templates (Base) and sidecar_translations (Translatable) so the handler-extension coupling lives in one auditable place and the translation list can't drift from i18n loading. Addresses review feedback on the PR (sidecar_templates/sidecar_translations).
cache_hits is :nodoc: and only annotates the render log; feature-detect it with respond_to? so a future Rails change can't raise during caching. The supported hit/miss signal remains the read_fragment/write_fragment ActiveSupport notifications, which fire regardless.
The bare rescue turned any ActionView::Template/handler-call signature break into a silent nil, which degrades to empty dependencies and stale caches. Capture the error and emit template_ast_build_failed.view_component instrumentation, and add per-handler canary tests (erb/slim/haml) so an upgrade break fails loudly in CI.
cache_if evaluated Symbol/String conditions with public_send, so the documented example (cache_if :cacheable? with a private cacheable?) crashed at render with NoMethodError. Use send, consistent with how the cache block itself is evaluated via instance_exec. The existing cache_if test now uses a private method, and a positive test covers a private condition that enables caching.
The cache key was composed only of the component digest and the cache block's dependencies, so a component with sidecar translations served the cached fragment from whichever locale rendered first (e.g. English output for a French request). Add I18n.locale to the key and cover it with a translatable, cacheable component that must render the correct language per locale.
Dependency extraction ran only over template source, so a child rendered from a Ruby method (e.g. a helper called from the template) was not a tracked dependency and changes to it did not bust the parent digest. Also scan the component's own Ruby source for component renders (render Foo.new) and recurse, so indirect children invalidate the parent like template-level renders do.
__vc_cache_key_block/__vc_cache_if are class_attributes, which already inherit. The extra inherited hook eagerly copied the parent's then-current value into each subclass, so declaring cache on a parent AFTER a subclass was defined left the subclass with a stale nil and it silently would not cache. Rely on class_attribute inheritance instead.
Cover def-call components, ActiveRecord-like models (hit + invalidate on update), child .rb changes, indirectly-rendered children, cyclic/self-referential digest termination, variant isolation, HTML-safety round-trip, and cache-block inheritance ordering.
Condense the rationale comments on the AST-build rescue, cache_hits guard, and extract_component_renders to match the terse style of the surrounding internal classes. Mark sidecar_templates/sidecar_translations @Private: both are only called cross-class by the digestor and translation loader, so they stay Ruby-public but out of the documented API surface.
…ching # Conflicts: # docs/CHANGELOG.md
There was a problem hiding this comment.
Pull request overview
Adds experimental component-local fragment caching with dependency-aware cache keys and component/source digests.
Changes:
- Adds the caching concern, digest generation, and template dependency extraction.
- Adds caching documentation, integration coverage, fixtures, and benchmarks.
- Adds
actionview_precompileras a runtime dependency.
Show a summary per file
| File | Description |
|---|---|
view_component.gemspec |
Adds precompiler dependency. |
test/sandbox/test/template_ast_builder_test.rb |
Tests handler compilation. |
test/sandbox/test/rendering_test.rb |
Tests caching behavior and invalidation. |
test/sandbox/test/integration_test.rb |
Tests controller rendering with caching. |
test/sandbox/config/routes.rb |
Adds cached-component route. |
test/sandbox/app/views/shared/_cache_digestor_partial.html.erb |
Adds partial fixture. |
test/sandbox/app/views/shared/_cache_digestor_nested_partial.html.erb |
Adds nested partial fixture. |
test/sandbox/app/views/shared/_cache_digestor_layout.html.erb |
Adds layout fixture. |
test/sandbox/app/models/cacheable_test_record.rb |
Adds versioned cache-key fixture. |
test/sandbox/app/controllers/integration_examples_controller.rb |
Renders cached component. |
test/sandbox/app/components/no_cache_component.rb |
Adds uncached opt-in fixture. |
test/sandbox/app/components/no_cache_component.html.erb |
Adds uncached template. |
test/sandbox/app/components/inline_cache_component.rb |
Adds inline cached component. |
test/sandbox/app/components/inherited_cache_component.rb |
Adds inherited cache fixture. |
test/sandbox/app/components/inherited_cache_component.html.erb |
Adds inherited template. |
test/sandbox/app/components/cache_variant_component.rb |
Adds variant cache fixture. |
test/sandbox/app/components/cache_variant_component.html+phone.erb |
Adds phone variant. |
test/sandbox/app/components/cache_variant_component.html.erb |
Adds default variant. |
test/sandbox/app/components/cache_self_referential_component.rb |
Adds recursive digest fixture. |
test/sandbox/app/components/cache_self_referential_component.html.erb |
Adds self-rendering template. |
test/sandbox/app/components/cache_record_component.rb |
Caches by record version. |
test/sandbox/app/components/cache_record_component.html.erb |
Adds record template. |
test/sandbox/app/components/cache_locale_component.yml |
Adds localized messages. |
test/sandbox/app/components/cache_locale_component.rb |
Adds locale cache fixture. |
test/sandbox/app/components/cache_locale_component.html.erb |
Adds localized template. |
test/sandbox/app/components/cache_indirect_parent_component.rb |
Adds Ruby-render dependency. |
test/sandbox/app/components/cache_indirect_parent_component.html.erb |
Invokes indirect child rendering. |
test/sandbox/app/components/cache_if_private_component.rb |
Tests private cache condition. |
test/sandbox/app/components/cache_if_private_component.html.erb |
Adds condition template. |
test/sandbox/app/components/cache_html_safety_component.rb |
Adds HTML-safety fixture. |
test/sandbox/app/components/cache_html_safety_component.html.erb |
Adds escaped-content template. |
test/sandbox/app/components/cache_digestor_slim_parent_component.rb |
Adds Slim digest fixture. |
test/sandbox/app/components/cache_digestor_slim_parent_component.html.slim |
Renders child from Slim. |
test/sandbox/app/components/cache_digestor_partial_parent_component.rb |
Adds partial dependency fixture. |
test/sandbox/app/components/cache_digestor_partial_parent_component.html.erb |
Renders a partial. |
test/sandbox/app/components/cache_digestor_parent_component.rb |
Adds parent digest fixture. |
test/sandbox/app/components/cache_digestor_parent_component.html.erb |
Renders child component. |
test/sandbox/app/components/cache_digestor_nested_partial_parent_component.rb |
Adds nested partial fixture. |
test/sandbox/app/components/cache_digestor_nested_partial_parent_component.html.erb |
Renders partial child component. |
test/sandbox/app/components/cache_digestor_layout_parent_component.rb |
Adds layout dependency fixture. |
test/sandbox/app/components/cache_digestor_layout_parent_component.html.erb |
Renders with a layout. |
test/sandbox/app/components/cache_digestor_jbuilder_parent_component.rb |
Adds Jbuilder digest fixture. |
test/sandbox/app/components/cache_digestor_jbuilder_parent_component.json.jbuilder |
Renders child from Jbuilder. |
test/sandbox/app/components/cache_digestor_haml_parent_component.rb |
Adds Haml digest fixture. |
test/sandbox/app/components/cache_digestor_haml_parent_component.html.haml |
Renders child from Haml. |
test/sandbox/app/components/cache_digestor_child_partial_component.rb |
Adds partial-rendering child. |
test/sandbox/app/components/cache_digestor_child_partial_component.html.erb |
Renders nested partial. |
test/sandbox/app/components/cache_digestor_child_component.rb |
Adds digest child fixture. |
test/sandbox/app/components/cache_digestor_child_component.html.erb |
Adds mutable child template. |
test/sandbox/app/components/cache_dependency_types_component.rb |
Exercises dependency value types. |
test/sandbox/app/components/cache_dependency_types_component.html.erb |
Displays dependency data. |
test/sandbox/app/components/cache_cycle_b_component.rb |
Adds cyclic dependency fixture. |
test/sandbox/app/components/cache_cycle_b_component.html.erb |
Completes component cycle. |
test/sandbox/app/components/cache_cycle_a_component.rb |
Adds cached cycle root. |
test/sandbox/app/components/cache_cycle_a_component.html.erb |
Starts component cycle. |
test/sandbox/app/components/cache_condition_component.rb |
Adds disabled-cache condition. |
test/sandbox/app/components/cache_condition_component.html.erb |
Adds timestamped condition template. |
test/sandbox/app/components/cache_component.rb |
Adds standard cached component. |
test/sandbox/app/components/cache_component.html.erb |
Adds cached component template. |
test/sandbox/app/components/cache_call_component.rb |
Tests cached call rendering. |
performance/template_handler_cache_benchmark.rb |
Benchmarks cache behavior by handler. |
performance/components/slim_uncached_benchmark_component.rb |
Adds uncached Slim benchmark. |
performance/components/slim_uncached_benchmark_component.html.slim |
Adds Slim benchmark template. |
performance/components/slim_cached_benchmark_component.rb |
Adds cached Slim benchmark. |
performance/components/slim_cached_benchmark_component.html.slim |
Adds cached Slim template. |
performance/components/non_cacheable_benchmark_component.rb |
Adds uncached baseline component. |
performance/components/non_cacheable_benchmark_component.html.erb |
Adds baseline template. |
performance/components/jbuilder_uncached_benchmark_component.rb |
Adds uncached Jbuilder benchmark. |
performance/components/jbuilder_uncached_benchmark_component.json.jbuilder |
Adds Jbuilder benchmark template. |
performance/components/jbuilder_cached_benchmark_component.rb |
Adds cached Jbuilder benchmark. |
performance/components/jbuilder_cached_benchmark_component.json.jbuilder |
Adds cached Jbuilder template. |
performance/components/handler_benchmark_component.rb |
Adds shared benchmark workload. |
performance/components/haml_uncached_benchmark_component.rb |
Adds uncached Haml benchmark. |
performance/components/haml_uncached_benchmark_component.html.haml |
Adds Haml benchmark template. |
performance/components/haml_cached_benchmark_component.rb |
Adds cached Haml benchmark. |
performance/components/haml_cached_benchmark_component.html.haml |
Adds cached Haml template. |
performance/components/erb_uncached_benchmark_component.rb |
Adds uncached ERB benchmark. |
performance/components/erb_uncached_benchmark_component.html.erb |
Adds ERB benchmark template. |
performance/components/erb_cached_benchmark_component.rb |
Adds cached ERB benchmark. |
performance/components/erb_cached_benchmark_component.html.erb |
Adds cached ERB template. |
performance/components/cacheable_benchmark_component.rb |
Adds conditional cache benchmark. |
performance/components/cacheable_benchmark_component.html.erb |
Adds cache benchmark template. |
performance/cache_benchmark.rb |
Benchmarks hits, misses, and baseline. |
lib/view_component/translatable.rb |
Exposes translation sidecars. |
lib/view_component/template.rb |
Adjusts compiled-source line handling. |
lib/view_component/template_dependency_extractor.rb |
Extracts rendered dependencies. |
lib/view_component/template_ast_builder.rb |
Compiles templates to Ruby. |
lib/view_component/experimentally_cacheable.rb |
Implements component fragment caching. |
lib/view_component/compiler.rb |
Uses the sidecar template helper. |
lib/view_component/cache_registry.rb |
Tracks cache execution state. |
lib/view_component/cache_digestor.rb |
Computes component dependency digests. |
lib/view_component/base.rb |
Resets cache state and exposes sidecars. |
lib/view_component.rb |
Autoloads caching support. |
gemfiles/rails_main.gemfile.lock |
Locks precompiler for Rails main. |
gemfiles/rails_main_head.gemfile.lock |
Locks precompiler for Rails head. |
gemfiles/rails_8.1.gemfile.lock |
Locks precompiler for Rails 8.1. |
gemfiles/rails_8.0.gemfile.lock |
Locks precompiler for Rails 8.0. |
gemfiles/rails_7.2.gemfile.lock |
Locks precompiler for Rails 7.2. |
gemfiles/rails_7.1.gemfile.lock |
Locks precompiler for Rails 7.1. |
Gemfile.lock |
Locks the new dependency. |
docs/guide/caching.md |
Documents experimental caching. |
docs/CHANGELOG.md |
Announces caching support. |
.audition-baseline.json |
Updates runtime audit baseline. |
Review details
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
- Files reviewed: 96/103 changed files
- Comments generated: 7
- Review effort level: Balanced
| ruby_source = cached_file_contents(component_class.identifier) | ||
| update_digest(digest, ruby_source) | ||
| update_ruby_dependency_digests(digest, ruby_source, component_class.identifier) |
| autoload :CompileCache | ||
| autoload :Config | ||
| autoload :Deprecation | ||
| autoload :ExperimentallyCacheable |
| if coverage_running? && ActionView::Base.annotate_rendered_view_with_filenames | ||
| result = result.partition(";").last | ||
| end |
| end | ||
| end | ||
|
|
||
| COMPONENT_RENDER = /(?:render|render_to_string)\s*\(?\s*([A-Z]\w*(?:::[A-Z]\w*)*)\.new\b/ |
| def test_cache_if_false_skips_caching | ||
| component = CacheConditionComponent.new(foo: "foo") | ||
|
|
||
| render_inline(component) | ||
| first_time = page.find(".cache-condition-component__message")["data-time"] | ||
|
|
||
| render_inline(component) | ||
| second_time = page.find(".cache-condition-component__message")["data-time"] | ||
|
|
||
| refute_equal(first_time, second_time) | ||
| end |
| end | ||
| end | ||
|
|
||
| def test_no_cache_compoennt |
| "template_ast_build_failed.view_component", | ||
| handler: handler_name, identifier: identifier, error: error | ||
| ) | ||
| nil |
closes #234
What are you trying to accomplish?
Add experimental fragment caching for ViewComponent that is true to the ViewComponent ethos, namely:
What approach did you choose and why?
I chose to add an opt-in caching API:
ViewComponent::Cacheable+ acache_onmacro that declares which instance methods contribute to the cache keyActiveSupport::Cache.expand_cache_key) + a component digestrenderdependenciesERB::Compiler(no Temple)Anything you want to highlight for special attention from reviewers?
require "view_component/fragment_caching"or per-componentinclude ViewComponent::Cacheable)cache_onsupports private methods and uses Rails cache key expansion for dependency valuesRAILS_CACHE_ID/RAILS_APP_VERSIONon deploy)