Skip to content

Commit d7a7a5a

Browse files
committed
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
1 parent 2e6e02d commit d7a7a5a

11 files changed

Lines changed: 146 additions & 18 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 while it was silently partial, 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
## 4.15.0
1422

1523
* Add experimental caching support, opt-in per component via `include ViewComponent::ExperimentallyCacheable`.

‎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 whether or not they include the module.
46+
4547
## Self-caching
4648

4749
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.
@@ -187,7 +189,7 @@ The same works in a template, where the branch is often the more natural place f
187189
<%= render component.new(post: @post) %>
188190
```
189191

190-
Declared components must include `ViewComponent::ExperimentallyCacheable` themselves, since a component that hasn't opted in has no digest to depend on.
192+
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.
191193

192194
## Caveats
193195

‎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
@@ -90,17 +92,26 @@ def virtual_path_for(component)
9092

9193
# Resolve a synthetic virtual path back to the component that owns it.
9294
#
95+
# Components that opted in are looked up in the registry. Everything else
96+
# is derived from the path, which `ViewComponent::Base` builds by
97+
# underscoring the class name. The derived constant has to underscore back
98+
# to the same path, so a component that overrides `virtual_path` — and
99+
# would therefore be digested under a path that isn't its own — is left
100+
# unresolved rather than confused with another component.
101+
#
93102
# @return [Class, nil]
94103
def component_for(virtual_path)
95104
return unless virtual_path.start_with?("#{VIRTUAL_PATH_PREFIX}/")
96105

97-
name = registry[virtual_path.delete_prefix("#{VIRTUAL_PATH_PREFIX}/")]
98-
return unless name
106+
path = virtual_path.delete_prefix("#{VIRTUAL_PATH_PREFIX}/")
107+
name = registry[path]
108+
return constantize_component(name) if name
99109

100-
constantize_component(name)
110+
component = constantize_component(path.camelize)
111+
component if component&.virtual_path == path
101112
end
102113

103-
# Scan a template's source for renders of cacheable components.
114+
# Scan a template's source for renders of components.
104115
#
105116
# Called for every template Rails digests, so it exits early when the
106117
# feature is unused.
@@ -113,7 +124,7 @@ def dependencies_in(template)
113124
end
114125

115126
# Scan arbitrary source (a template or a component's Ruby file) for
116-
# renders of cacheable components.
127+
# renders of components.
117128
#
118129
# @return [Array<String>] synthetic virtual paths
119130
def component_paths_in(source)
@@ -223,15 +234,24 @@ def install!
223234

224235
private
225236

226-
# Resolve a constant name to a component that opted into caching.
237+
# Resolve a constant name to a component.
238+
#
239+
# Any component counts, not only those that included
240+
# `ExperimentallyCacheable`. Tracking only the ones that opted in makes
241+
# dependency tracking transitive: a parent's digest covers the children
242+
# that happen to have included the module and silently omits the rest,
243+
# which is indistinguishable from a complete digest until the untracked
244+
# child changes and stale HTML stays on. Applications that never opt in
245+
# are unaffected either way, because every hook here short-circuits while
246+
# the registry is empty.
227247
#
228248
# Returns nil for anything else, including constants that don't exist.
229249
# Autoloading here is safe: the template is about to render this constant
230250
# anyway.
231251
def constantize_component(constant_name)
232252
component = constant_name.safe_constantize
233253
return unless component.is_a?(Class)
234-
return unless component.respond_to?(:__vc_cacheable?) && component.__vc_cacheable?
254+
return unless component < ViewComponent::Base
235255

236256
component
237257
rescue
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
@@ -28,6 +28,7 @@
2828
get :cached_partial, to: "integration_examples#cached_partial"
2929
get :cached_component, to: "integration_examples#cached_component"
3030
get :cached_nested_component, to: "integration_examples#cached_nested_component"
31+
get :cached_untracked_component, to: "integration_examples#cached_untracked_component"
3132
get :inherited_sidecar, to: "integration_examples#inherited_sidecar"
3233
get :inherited_from_uncompilable_component, to: "integration_examples#inherited_from_uncompilable_component"
3334
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
@@ -65,6 +65,36 @@ def test_cache_block_is_invalidated_when_a_nested_component_changes
6565
end
6666
end
6767

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

0 commit comments

Comments
 (0)