Skip to content

Commit d7c707a

Browse files
erikaxelCopilot
authored andcommitted
Track components that haven't opted into cacheability
- Resolve any ViewComponent::Base descendant as a dependency rather than only registered ones, so a digest can't be silently partial - Resolve unregistered components back from their virtual path, requiring the path to round-trip to the same class - Add regression tests for an untracked child changing without moving the parent's digest or the fragment digest Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 23ceae2 commit d7c707a

11 files changed

Lines changed: 145 additions & 17 deletions

‎docs/CHANGELOG.md‎

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

1111
## main
1212

13+
* Track every component in a fragment's render tree when the experimental caching feature is enabled, not only the components that included `ViewComponent::ExperimentallyCacheable`.
14+
15+
Dependency tracking used to be transitively opt-in: a parent that included the module got a digest covering only the children that also included it. The digest looked complete regardless, and the gap surfaced as stale HTML at an arbitrary later time, whenever an unrelated tracked component happened to change.
16+
17+
Only the component wrapped in the `<% cache %>` block needs the include now. Applications that never opt in are unaffected, since dependency tracking still short-circuits until the first component registers.
18+
19+
*Erik Axel Nielsen*
20+
1321
* Invalidate Action View's memoized template digests when a component registers with `ViewComponent::CacheDigest`, so a digest computed before the component loaded isn't served for the rest of the process.
1422

1523
*Erik Axel Nielsen*

‎docs/guide/caching.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ Editing `PostComponent`'s template, Ruby class, or sidecar files doesn't invalid
2828

2929
## Opting in
3030

31-
Include `ViewComponent::ExperimentallyCacheable` in each component that should participate in caching:
31+
Include `ViewComponent::ExperimentallyCacheable` in the component rendered inside the `cache` block:
3232

3333
```ruby
3434
class PostComponent < ViewComponent::Base
@@ -42,6 +42,8 @@ end
4242

4343
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.
4444

45+
Once any component in the application has opted in, the whole render tree is tracked: the child components `PostComponent` renders, and the components *they* render, invalidate the fragment even when they don't include the module.
46+
4547
## Caching inside a component template
4648

4749
A `<% cache %>` block written inside a component's own template has the same problem, for the same reason: Rails digests the template that's rendering, and a component's template isn't in the view paths, so there's nothing to digest.
@@ -202,7 +204,7 @@ The same works in a template, where the branch is often the more natural place f
202204
<%= render component.new(post: @post) %>
203205
```
204206

205-
Declared components must include `ViewComponent::ExperimentallyCacheable` themselves, since a component that hasn't opted in has no digest to depend on.
207+
Declared components don't need to include `ViewComponent::ExperimentallyCacheable` themselves. A component that overrides `virtual_path` does, since it's otherwise digested under a path that doesn't lead back to it.
206208

207209
## When a digest can't be computed
208210

‎lib/view_component/cache_digest.rb‎

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,11 @@ module ViewComponent
2121
# not just its template.
2222
#
2323
# This module fixes both, reusing Rails' own `ActionView::Digestor` rather than
24-
# reimplementing static analysis. Components opt in individually by including
25-
# `ViewComponent::ExperimentallyCacheable`; until at least one component does,
26-
# every hook here short-circuits.
24+
# reimplementing static analysis. Until at least one component opts in by
25+
# including `ViewComponent::ExperimentallyCacheable`, every hook here
26+
# short-circuits. Once one has, every component reachable from a digested
27+
# template is tracked, whether or not it included the module: a digest that
28+
# covered only part of the render tree would look exactly like a complete one.
2729
#
2830
# @private
2931
module CacheDigest
@@ -98,17 +100,26 @@ def virtual_path_for(component)
98100

99101
# Resolve a synthetic virtual path back to the component that owns it.
100102
#
103+
# Components that opted in are looked up in the registry. Everything else
104+
# is derived from the path, which `ViewComponent::Base` builds by
105+
# underscoring the class name. The derived constant has to underscore back
106+
# to the same path, so a component that overrides `virtual_path` — and
107+
# would therefore be digested under a path that isn't its own — is left
108+
# unresolved rather than confused with another component.
109+
#
101110
# @return [Class, nil]
102111
def component_for(virtual_path)
103112
return unless virtual_path.start_with?("#{VIRTUAL_PATH_PREFIX}/")
104113

105-
name = registry[virtual_path.delete_prefix("#{VIRTUAL_PATH_PREFIX}/")]
106-
return unless name
114+
path = virtual_path.delete_prefix("#{VIRTUAL_PATH_PREFIX}/")
115+
name = registry[path]
116+
return constantize_component(name) if name
107117

108-
constantize_component(name)
118+
component = constantize_component(path.camelize)
119+
component if component&.virtual_path == path
109120
end
110121

111-
# Scan a template's source for renders of cacheable components.
122+
# Scan a template's source for renders of components.
112123
#
113124
# Called for every template Rails digests, so it exits early when the
114125
# feature is unused.
@@ -121,7 +132,7 @@ def dependencies_in(template)
121132
end
122133

123134
# Scan arbitrary source (a template or a component's Ruby file) for
124-
# renders of cacheable components.
135+
# renders of components.
125136
#
126137
# @return [Array<String>] synthetic virtual paths
127138
def component_paths_in(source)
@@ -235,15 +246,24 @@ def expire_digests
235246
ActionView::LookupContext::DetailsKey.digest_caches.each(&:clear)
236247
end
237248

238-
# Resolve a constant name to a component that opted into caching.
249+
# Resolve a constant name to a component.
250+
#
251+
# Any component counts, not only those that included
252+
# `ExperimentallyCacheable`. Tracking only the ones that opted in makes
253+
# dependency tracking transitive: a parent's digest covers the children
254+
# that happen to have included the module and silently omits the rest,
255+
# which is indistinguishable from a complete digest until the untracked
256+
# child changes and stale HTML stays on. Applications that never opt in
257+
# are unaffected either way, because every hook here short-circuits while
258+
# the registry is empty.
239259
#
240260
# Returns nil for anything else, including constants that don't exist.
241261
# Autoloading here is safe: the template is about to render this constant
242262
# anyway.
243263
def constantize_component(constant_name)
244264
component = constant_name.safe_constantize
245265
return unless component.is_a?(Class)
246-
return unless component.respond_to?(:__vc_cacheable?) && component.__vc_cacheable?
266+
return unless component < ViewComponent::Base
247267

248268
component
249269
end
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<div class="cacheable-untracked-parent"><%= render UntrackedChildComponent.new %></div>
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# frozen_string_literal: true
2+
3+
# Renders a child that never opted in, so changes to the child must still
4+
# invalidate the parent.
5+
class CacheableUntrackedParentComponent < ViewComponent::Base
6+
include ViewComponent::ExperimentallyCacheable
7+
end
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<span class="untracked-child">untracked</span>
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# frozen_string_literal: true
2+
3+
# Deliberately does not include `ViewComponent::ExperimentallyCacheable`, so
4+
# nothing registers it with the digest tree.
5+
class UntrackedChildComponent < ViewComponent::Base
6+
end
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
<% cache "cached-untracked-component-fragment" do %>
2+
<%= render CacheableComponent.new(title: "cached") %>
3+
<%= render UntrackedChildComponent.new %>
4+
<% end %>

‎test/sandbox/config/routes.rb‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
get :cached_component, to: "integration_examples#cached_component"
3030
get :cached_nested_component, to: "integration_examples#cached_nested_component"
3131
get :cache_block_component, to: "integration_examples#cache_block_component"
32+
get :cached_untracked_component, to: "integration_examples#cached_untracked_component"
3233
get :inherited_sidecar, to: "integration_examples#inherited_sidecar"
3334
get :inherited_from_uncompilable_component, to: "integration_examples#inherited_from_uncompilable_component"
3435
get :unsafe_component, to: "integration_examples#unsafe_component"

‎test/sandbox/test/experimentally_cacheable_integration_test.rb‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,36 @@ def test_cache_block_is_invalidated_when_a_nested_component_changes
6666
end
6767
end
6868

69+
# The failure this guards against is silent: the digest looks complete while
70+
# covering only the children that happened to opt in, so an edit to an
71+
# untracked one sits invisible until an unrelated tracked component changes.
72+
def test_cache_block_is_invalidated_when_an_untracked_component_changes
73+
get "/cached_untracked_component"
74+
assert_select(".untracked-child", text: "untracked")
75+
76+
before = fragment_digest_for("integration_examples/cached_untracked_component")
77+
78+
modify_file "app/components/untracked_child_component.html.erb", "<span class=\"untracked-child\">changed</span>\n" do
79+
clear_digest_cache
80+
81+
refute_equal before, fragment_digest_for("integration_examples/cached_untracked_component")
82+
end
83+
end
84+
85+
def test_cached_markup_of_an_untracked_component_is_not_served_stale
86+
get "/cached_untracked_component"
87+
assert_select(".untracked-child", text: "untracked")
88+
89+
modify_file "app/components/untracked_child_component.html.erb", "<span class=\"untracked-child\">changed</span>\n" do
90+
clear_digest_cache
91+
with_new_cache do
92+
get "/cached_untracked_component"
93+
94+
assert_select(".untracked-child", text: "changed")
95+
end
96+
end
97+
end
98+
6999
def test_cache_block_digest_is_unaffected_by_unrelated_components
70100
before = fragment_digest_for("integration_examples/cached_component")
71101

0 commit comments

Comments
 (0)