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
|
@joelhawksley im not sure if thd primer failure is related but everything I feel is done, I would appreciate a review when ever you can |
joelhawksley
left a comment
There was a problem hiding this comment.
Getting closer! Thank you for your hard work ❤️
| Caching is experimental. | ||
|
|
||
| To enable caching, include `ViewComponent::ExperimentallyCacheable`. | ||
|
|
||
| Components implement caching by marking dependencies using `cache_on`: |
There was a problem hiding this comment.
| Caching is experimental. | |
| To enable caching, include `ViewComponent::ExperimentallyCacheable`. | |
| Components implement caching by marking dependencies using `cache_on`: | |
| To enable caching, include `ViewComponent::ExperimentallyCacheable` and declare dependencies by passing method names (private methods allowed) to `cache_on`: |
There was a problem hiding this comment.
Is it true that one must call cache_on? What if you don't?
| <p><%= "#{foo} #{bar}" %></p> | ||
| ``` | ||
|
|
||
| `cache_on` accepts method names. Returned values are expanded via `ActiveSupport::Cache.expand_cache_key`, so Active Record models, `GlobalID`, arrays, and plain strings work as expected. |
There was a problem hiding this comment.
| `cache_on` accepts method names. Returned values are expanded via `ActiveSupport::Cache.expand_cache_key`, so Active Record models, `GlobalID`, arrays, and plain strings work as expected. | |
| Returned values are expanded via `ActiveSupport::Cache.expand_cache_key`, so Active Record models, `GlobalID`, arrays, and plain strings work as expected. |
| Methods listed in `cache_on` may be private. | ||
|
|
There was a problem hiding this comment.
| Methods listed in `cache_on` may be private. |
|
|
||
| Methods listed in `cache_on` may be private. | ||
|
|
||
| The cache key includes a digest of component source (Ruby + templates + i18n sidecars) and rendered child ViewComponents. |
There was a problem hiding this comment.
| The cache key includes a digest of component source (Ruby + templates + i18n sidecars) and rendered child ViewComponents. | |
| The cache key includes a digest of the component, its sidecar files, and with ViewComponents rendered by the component. |
|
|
||
| The cache key includes a digest of component source (Ruby + templates + i18n sidecars) and rendered child ViewComponents. | ||
|
|
||
| Partial/layout string dependencies aren't currently included in the digest, to invalidate the cache on deploy modify `RAILS_CACHE_ID`/`RAILS_APP_VERSION`. |
There was a problem hiding this comment.
| Partial/layout string dependencies aren't currently included in the digest, to invalidate the cache on deploy modify `RAILS_CACHE_ID`/`RAILS_APP_VERSION`. | |
| _Warning: changes to partial and layout string dependencies will not invalidate the cache. Modify `RAILS_CACHE_ID` or `RAILS_APP_VERSION` instead._ |
| end | ||
| end | ||
|
|
||
| def file_contents(path) |
There was a problem hiding this comment.
| def file_contents(path) | |
| def cached_file_contents(path) |
| module CachingRegistry | ||
| extend self | ||
|
|
||
| def caching? |
There was a problem hiding this comment.
I don't see this method called or tested anywhere. Do we need it?
|
|
||
| def template_fragment(cache_key, safe_call) | ||
| if (content = read_fragment(cache_key)) | ||
| @view_renderer.cache_hits[@current_template&.virtual_path] = :hit if defined?(@view_renderer) |
There was a problem hiding this comment.
Should we always be recording hits/misses, or only when instrumentation is enabled?
| end | ||
|
|
||
| def combined_fragment_cache_key(key) | ||
| cache_key = [:view_component, ENV["RAILS_CACHE_ID"] || ENV["RAILS_APP_VERSION"], key] |
There was a problem hiding this comment.
I believe you've implied that changing either ENV would work:
| cache_key = [:view_component, ENV["RAILS_CACHE_ID"] || ENV["RAILS_APP_VERSION"], key] | |
| cache_key = [:view_component, ENV["RAILS_CACHE_ID"], ENV["RAILS_APP_VERSION"], key] |
Do we have a test for ensuring that changing each busts the cache?
| require "digest" | ||
| require "view_component/template_dependency_extractor" | ||
|
|
||
| module ViewComponent |
There was a problem hiding this comment.
I wonder how much of this work we could do at compile time, especially when eager loading is enabled. I believe everything except for the value(s) of cache_on should be stable at that point.
|
@joelhawksley and everyone who has reviewed or tested this so far, I have come back to this with a few fixes addressing the latest comments. The main changes are:
I think this now addresses the comments raised here, but I would really appreciate another pass before we try to land it. @JWShuff @tmaier if either of you have time to test this against real cached component use cases again, that would be especially helpful. Thanks again everyone for all the review and patience on this. |
…into rv_add_component_caching # Conflicts: # .tool-versions # Gemfile.lock # docs/CHANGELOG.md # gemfiles/rails_main.gemfile.lock # test/sandbox/test/rendering_test.rb
…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.
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)