Skip to content

Commit 0071477

Browse files
authored
Merge pull request #2685 from ViewComponent/experimentally-cacheable
Add experimental, opt-in component caching
2 parents efc3085 + 1948a8e commit 0071477

38 files changed

Lines changed: 2114 additions & 0 deletions

‎.audition-baseline.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
"class-level-state|lib/view_component/base.rb": 9,
3+
"class-level-state|lib/view_component/cache_digest.rb": 1,
34
"class-level-state|lib/view_component/preview.rb": 2,
45
"class-variables|lib/view_component/base.rb": 2,
56
"runtime-class-state|/Users/joelhawksley/.local/share/mise/installs/ruby/4.0.5/lib/ruby/4.0.0/delegate.rb": 1,

‎.github/workflows/lint.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ jobs:
3131
persist-credentials: false
3232
- name: Vale
3333
uses: errata-ai/vale-action@d89dee975228ae261d22c15adcd03578634d429c
34+
with:
35+
fail_on_error: true
3436
env:
3537
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
3638
markdown:

‎app/controllers/view_components_system_test_controller.rb‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ def self.temp_dir
1111
end
1212

1313
rescue_from ViewComponent::SystemTestControllerNefariousPathError, with: :render_not_found
14+
rescue_from Errno::ENOENT, with: :render_not_found
1415

1516
def system_test_entrypoint
1617
render file: @path

‎docs/CHANGELOG.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,30 @@ nav_order: 6
1010

1111
## main
1212

13+
* Add experimental caching support, opt-in per component via `include ViewComponent::ExperimentallyCacheable`.
14+
15+
Components have never participated in Rails' template digests, so a `<% cache %>` block wrapping `render MyComponent.new` was never invalidated when the component changed ([#234](https://github.com/ViewComponent/view_component/issues/234), open since 2020).
16+
17+
Including the module registers the component with Rails' own `ActionView::Digestor`, so fragment caches are invalidated when the component's template, Ruby class, sidecar files, superclasses, child components, or rendered partials change. This includes components and partials rendered from inline templates and `#call` methods. Adding `cache_on` caches the component's own rendered output, optionally guarded by `if:`/`unless:`, and `.cache_digest` exposes the digest for use outside a request.
18+
19+
```ruby
20+
class MessageComponent < ViewComponent::Base
21+
include ViewComponent::ExperimentallyCacheable
22+
23+
cache_on :message, unless: -> { message.draft? }
24+
25+
def initialize(message:)
26+
@message = message
27+
end
28+
end
29+
```
30+
31+
**This API is experimental and may change or be removed in a non-major release.** It's shipping opt-in and per-component precisely so we can iterate on it in response to real-world use. **Please try it and tell us what breaks, what's missing, and what feels wrong in [#234](https://github.com/ViewComponent/view_component/issues/234).** We're especially interested in feedback on: whether `cache_on` is the right shape for declaring cache keys, how the feature behaves with slots and content blocks, and whether the `# Template Dependency:` escape hatch is sufficient for dynamic renders. See [the caching guide](https://viewcomponent.org/guide/caching.html) for details and known caveats.
32+
33+
This work builds directly on prior art from the community. The `cache_on` API and the case for component-local caching come from [#2126](https://github.com/ViewComponent/view_component/pull/2126) by *Reegan Viljoen*. The approach of integrating with Rails' digest tree rather than reimplementing it comes from [`view_component-cache_digest`](https://github.com/tildeio/view_component-cache_digest) by *Godfrey Chan*. The invalidation cases it's tested against were contributed by *JWShuff* and *timburgan*, drawing on [`view_component-fragment_caching`](https://github.com/patrickarnett/view_component-fragment_caching) by *Patrick Arnett*. The issue was opened and researched by *ozzyaaron*, *pinzonjulian*, and *Derek Kniffin*, and the digest workaround that surfaced the superclass gap came from *cannikin* and *rnestler*. Cache-key correctness issues (formats sharing an entry, positional `nil` collisions, conditional caching, and ignored `cache_on` blocks) were found and reported by *Reegan Viljoen*.
34+
35+
*Reegan Viljoen*, *Godfrey Chan*, *JWShuff*, *timburgan*, *Patrick Arnett*, *ozzyaaron*, *pinzonjulian*, *Derek Kniffin*, *cannikin*, *rnestler*, *Joel Hawksley*
36+
1337
## 4.14.0
1438

1539
* Freeze `ReusedInstanceError::MESSAGE` and update `test_renders_component_with_asset_url` to build a fresh `AssetComponent` per render, fixing CI regressions introduced by the GHSA-8qw7-6phv-7q6p remediation.

‎docs/api.md‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,10 +455,24 @@ A method called 'SETTER_METHOD_NAME' already exists and would be overwritten by
455455

456456
Please choose a different setter name.
457457

458+
### `CacheDigestTemplateError`
459+
460+
The synthetic cache digest template for COMPONENT was rendered.
461+
462+
This template exists only so Rails can compute a cache digest for the component and is never meant to be rendered. Render the component itself instead.
463+
458464
### `ContentAlreadySetForPolymorphicSlotError`
459465

460466
Content for slot SLOT_NAME has already been provided.
461467

468+
### `ContentPassedToCachedComponentError`
469+
470+
COMPONENT declares `cache_on`, so it caches its own output, but its caller passed it content.
471+
472+
Content and slots set by the caller aren't part of the cache key, so caching them would risk serving one caller's content to another.
473+
474+
To fix this issue, either remove `cache_on` from COMPONENT, or move the content into the component and derive it from the values declared in `cache_on`.
475+
462476
### `ContentSlotNameError`
463477

464478
COMPONENT declares a slot named content, which is a reserved word in ViewComponent.
@@ -582,3 +596,9 @@ It's sometimes possible to fix this issue by moving code dependent on `#translat
582596
COMPONENT declares a slot named SLOT_NAME, which is an uncountable word
583597

584598
To fix this issue, choose a different name.
599+
600+
### `UndefinedCacheKeyMethodError`
601+
602+
`cache_on` declared `METHOD` on COMPONENT, but no such method is defined.
603+
604+
To fix this issue, define `METHOD` or remove it from `cache_on`.

‎docs/guide/caching.md‎

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
---
2+
layout: default
3+
title: Caching
4+
parent: How-to guide
5+
---
6+
7+
# Caching
8+
9+
Experimental
10+
{: .label .label-yellow }
11+
12+
Since 4.14.0
13+
{: .label }
14+
15+
**This API is experimental.** It may change or be removed in a non-major release. Please share feedback in [#234](https://github.com/ViewComponent/view_component/issues/234).
16+
17+
Rails computes a digest for every template from its source and from the templates it renders. That digest is mixed into the key of every `<% cache %>` block in the template, so editing a partial invalidates the caches of everything that renders it.
18+
19+
Components are invisible to that mechanism.
20+
21+
```erb
22+
<% cache @post do %>
23+
<%= render PostComponent.new(post: @post) %>
24+
<% end %>
25+
```
26+
27+
Editing `PostComponent`'s template, Ruby class, or sidecar files doesn't invalidate the fragment, so the stale markup is served until the cache is cleared by hand.
28+
29+
## Opting in
30+
31+
Include `ViewComponent::ExperimentallyCacheable` in each component that should participate in caching:
32+
33+
```ruby
34+
class PostComponent < ViewComponent::Base
35+
include ViewComponent::ExperimentallyCacheable
36+
37+
def initialize(post:)
38+
@post = post
39+
end
40+
end
41+
```
42+
43+
That's all that's needed for the `<% cache %>` block above to work. The component is registered with Rails' digest tree, and the fragment is invalidated when the component's template, Ruby class, sidecar files, superclasses, child components, or rendered partials change, including components and partials rendered from an inline template or a `#call` method.
44+
45+
## Self-caching
46+
47+
To have a component cache its own output without needing a `cache` block, use `cache_on` to declare methods used for the component's cache key.
48+
49+
```ruby
50+
class PostComponent < ViewComponent::Base
51+
include ViewComponent::ExperimentallyCacheable
52+
53+
cache_on :post
54+
55+
def initialize(post:)
56+
@post = post
57+
end
58+
59+
private
60+
61+
attr_reader :post
62+
end
63+
```
64+
65+
Every call site is now cached automatically:
66+
67+
```erb
68+
<%= render PostComponent.new(post: @post) %>
69+
```
70+
71+
Which is equivalent to writing:
72+
73+
```erb
74+
<% cache [@post, PostComponent.cache_digest] do %>
75+
<%= render PostComponent.new(post: @post) %>
76+
<% end %>
77+
```
78+
79+
The cache key combines:
80+
81+
- the component's virtual path
82+
- its digest, computed by Rails' `ActionView::Digestor`
83+
- the requested format and variant
84+
- the current `I18n.locale`
85+
- the values returned by the `cache_on` methods
86+
87+
Caching is skipped unless `perform_caching` is enabled on the controller, matching the behavior of Rails' `cache` helper.
88+
89+
To cache only some renders, pass `if:` or `unless:`. Both accept a method name or a proc evaluated on the component:
90+
91+
```ruby
92+
class PostComponent < ViewComponent::Base
93+
include ViewComponent::ExperimentallyCacheable
94+
95+
cache_on :post, unless: -> { post.draft? }
96+
97+
def initialize(post:)
98+
@post = post
99+
end
100+
101+
private
102+
103+
attr_reader :post
104+
end
105+
```
106+
107+
Drafts now render every time, while published posts are cached. Note that this controls whether the cache is *used*, not what goes into the key: a `cache_on` method returning `nil` or `false` still contributes that value to the key rather than disabling caching.
108+
109+
A component that declares `cache_on` can't accept content from its callers. A block, `with_content`, or a slot set by the caller isn't part of the cache key, so passing one raises `ContentPassedToCachedComponentError`:
110+
111+
```erb
112+
<%# Raises: the block's content isn't in the cache key %>
113+
<%= render PostComponent.new(post: @post) do %>
114+
Hello
115+
<% end %>
116+
```
117+
118+
The error is raised even when caching is disabled, so the conflict surfaces in development and test rather than only in production. See [Caveats](#caveats) for how to restructure a component that needs to take content.
119+
120+
`cache_on` is inherited, so declaring it on a base class opts every subclass into self-caching, and into that restriction. Declare it on the components that should cache themselves rather than on `ApplicationComponent`.
121+
122+
## Reading a component's digest
123+
124+
`.cache_digest` returns the digest of everything the component renders from. It works outside a request, where no view context exists:
125+
126+
```ruby
127+
PostComponent.cache_digest # => "a1b2c3..."
128+
```
129+
130+
Use it when a cache needs to be tied to a component's source but is written somewhere the component isn't rendered, such as a background job:
131+
132+
```ruby
133+
Rails.cache.fetch(["post-summary", post, PostComponent.cache_digest]) do
134+
expensive_summary_for(post)
135+
end
136+
```
137+
138+
## Declaring dependencies static analysis can't see
139+
140+
Dependencies are discovered by scanning template and Ruby source for literal references, so renders resolved at runtime are invisible:
141+
142+
```erb
143+
<%= render @component %>
144+
```
145+
146+
```ruby
147+
def call
148+
render "posts/#{@post.style}" # interpolated, so not tracked
149+
end
150+
```
151+
152+
Declare these with Rails' `# Template Dependency:` comment, in either the Ruby file or the template. Partials are named by path:
153+
154+
```ruby
155+
class PostComponent < ViewComponent::Base
156+
include ViewComponent::ExperimentallyCacheable
157+
158+
# Template Dependency: posts/byline
159+
end
160+
```
161+
162+
Components are named by class, listing each one the component might render:
163+
164+
```ruby
165+
class PostComponent < ViewComponent::Base
166+
include ViewComponent::ExperimentallyCacheable
167+
168+
# Template Dependency: PostSummaryComponent
169+
# Template Dependency: PostDetailComponent
170+
171+
def call
172+
render(@detailed ? PostDetailComponent : PostSummaryComponent).new(post: @post)
173+
end
174+
end
175+
```
176+
177+
The same works in a template, where the branch is often the more natural place for it:
178+
179+
```erb
180+
<% if params[:style] == "summary" %>
181+
<%# Template Dependency: PostSummaryComponent %>
182+
<% component = PostSummaryComponent %>
183+
<% else %>
184+
<%# Template Dependency: PostDetailComponent %>
185+
<% component = PostDetailComponent %>
186+
<% end %>
187+
<%= render component.new(post: @post) %>
188+
```
189+
190+
Declared components must include `ViewComponent::ExperimentallyCacheable` themselves, since a component that hasn't opted in has no digest to depend on.
191+
192+
## Caveats
193+
194+
**Self-caching components can't take content from their callers.** Besides a block, this covers `with_content` and slots set by the caller:
195+
196+
```erb
197+
<%# Also raises ContentPassedToCachedComponentError %>
198+
<%= render PostComponent.new(post: @post) do |component| %>
199+
<% component.with_header { "Hello" } %>
200+
<% end %>
201+
```
202+
203+
Slots a component fills in for itself with a `default_*` method are part of its own output, not the caller's, so those are cached normally:
204+
205+
```ruby
206+
class PostComponent < ViewComponent::Base
207+
include ViewComponent::ExperimentallyCacheable
208+
209+
renders_one :header
210+
211+
cache_on :post
212+
213+
def default_header
214+
post.title # cached, because the component decides it
215+
end
216+
end
217+
```
218+
219+
To cache a component that takes content, move the content into the component and derive it from values declared in `cache_on`. Components that don't declare `cache_on` are unaffected: they still accept content and slots, and a `<% cache %>` block around them still invalidates correctly.
220+
221+
**`cache_on` methods run before the component renders**, so they can only depend on the component's own state, not on `helpers` or the view context. A cache key that depends on the view context is usually a sign the value should be passed to the component instead.
222+
223+
**Included modules aren't tracked.** A component's superclasses are, but a module included into a component isn't, since a module has no template or source file of its own to hash. Use `# Template Dependency:` for those.

‎lib/view_component.rb‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@ module ViewComponent
88
extend ActiveSupport::Autoload
99

1010
autoload :Base
11+
autoload :CacheDigest
1112
autoload :Compiler
1213
autoload :CompileCache
1314
autoload :Config
1415
autoload :Deprecation
16+
autoload :ExperimentallyCacheable
1517
autoload :InlineTemplate
1618
autoload :Instrumentation
1719
autoload :Preview

0 commit comments

Comments
 (0)