Skip to content

Add first class component cache - #2126

Open
reeganviljoen wants to merge 134 commits into
ViewComponent:mainfrom
reeganviljoen:rv_add_component_caching
Open

Add first class component cache#2126
reeganviljoen wants to merge 134 commits into
ViewComponent:mainfrom
reeganviljoen:rv_add_component_caching

Conversation

@reeganviljoen

@reeganviljoen reeganviljoen commented Oct 8, 2024

Copy link
Copy Markdown
Collaborator

closes #234

What are you trying to accomplish?

Add experimental fragment caching for ViewComponent that is true to the ViewComponent ethos, namely:

  • caching remains component-local (no hidden global state required to work)
  • caching that can easily be debugged with standard debugging tools like breakpoints

What approach did you choose and why?

I chose to add an opt-in caching API:

  • ViewComponent::Cacheable + a cache_on macro that declares which instance methods contribute to the cache key
  • cache keys include component identity + template identity (variant/format) + declared dependencies (via ActiveSupport::Cache.expand_cache_key) + a component digest
  • the component digest invalidates cached output when the component’s source changes (Ruby file, templates, i18n sidecars) and when rendered child ViewComponents change
  • template dependency extraction compiles templates to Ruby and uses Prism to find render dependencies
    • ERB uses stdlib ERB::Compiler (no Temple)
    • Slim/Haml are loaded lazily and only when those handlers are used

Anything you want to highlight for special attention from reviewers?

  • This is intentionally opt-in (global opt-in via require "view_component/fragment_caching" or per-component include ViewComponent::Cacheable)
  • Added integration coverage proving parent cache invalidates when a child component template changes
  • cache_on supports private methods and uses Rails cache key expansion for dependency values
  • Known limitation: partial/layout string dependencies are not currently included in the digest (workaround: bump RAILS_CACHE_ID/RAILS_APP_VERSION on deploy)
  • Follow-ups can extend the API (procs, conditionals/options, broader dependency coverage)

@reeganviljoen reeganviljoen changed the title add component controlled cache Add First class component cache Oct 8, 2024
@reeganviljoen reeganviljoen changed the title Add First class component cache Add first class component cache Oct 8, 2024
@reeganviljoen
reeganviljoen force-pushed the rv_add_component_caching branch from 7544079 to 7533a9c Compare October 10, 2024 15:21
@reeganviljoen

Copy link
Copy Markdown
Collaborator Author

@joelhawksley @Spone @camertron I would appreciate any thoughts on this.

@joelhawksley joelhawksley left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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.

@reeganviljoen

Copy link
Copy Markdown
Collaborator Author

@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 ❤️

@boardfish

Copy link
Copy Markdown
Collaborator

@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 ✨

Comment thread lib/view_component/base.rb Outdated
Comment thread lib/view_component/base.rb Outdated
Comment thread lib/view_component/base.rb Outdated
Comment thread lib/view_component/base.rb Outdated
Comment thread lib/view_component/base.rb Outdated
Comment thread lib/view_component/base.rb Outdated
Comment thread lib/view_component/base.rb Outdated
Comment thread lib/view_component/base.rb Outdated
Comment thread lib/view_component/base.rb Outdated
Comment thread lib/view_component/base.rb Outdated
Comment thread lib/view_component/cacheable.rb Outdated
Comment thread lib/view_component/cacheable.rb Outdated

@JWShuff JWShuff left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread lib/view_component/base.rb Outdated
include ViewComponent::Slotable
include ViewComponent::Translatable
include ViewComponent::WithContentHelper
include ViewComponent::Cacheable

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@JWShuff like touch: true in rails ?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also big thanks for testing it, I can look at adding the touch true thing later this week

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@reeganviljoen I'd expect us to test this with changes to child partials.

@JWShuff JWShuff left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a hypothetical approach to an integration test for the behavior.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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.

JWShuff#1

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.

@reeganviljoen

Copy link
Copy Markdown
Collaborator Author

@JWShuff I investigated a few of your suggestions, added a few, thanks, and others I am struggling with
luckily I think these aren't required for the initial feature and are more nice to haves that can be implemented in another pr(child cache busting)

@reeganviljoen

Copy link
Copy Markdown
Collaborator Author

@joelhawksley with a live use case tested how do you feel about this now

@joelhawksley

Copy link
Copy Markdown
Member

@reeganviljoen when you have some time, I'd be curious about your thoughts on @timburgan's PR ❤️

@reeganviljoen

Copy link
Copy Markdown
Collaborator Author

@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

timburgan and others added 12 commits July 24, 2026 17:24
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.
@joelhawksley
joelhawksley requested a balanced review from Copilot and removed request for JWShuff, ekampp, mikodagatan and tmaier August 20, 2026 16:19

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_precompiler as 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

Comment on lines +41 to +43
ruby_source = cached_file_contents(component_class.identifier)
update_digest(digest, ruby_source)
update_ruby_dependency_digests(digest, ruby_source, component_class.identifier)
Comment thread lib/view_component.rb
autoload :CompileCache
autoload :Config
autoload :Deprecation
autoload :ExperimentallyCacheable
Comment on lines +60 to +62
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/
Comment on lines +1431 to +1441
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Components should play nicely with Rails caching mechanisms