Skip to content

Commit 187f292

Browse files
committed
Expire memoized template digests when a component registers
- Return early from CacheDigest.register when the entry is unchanged, so reloads and re-registrations don't churn - Clear ActionView's digest caches on a new registration, leaving resolver caches alone - Add a regression test asserting a fragment digest is the same whether the component registered before or after the template was digested
1 parent 0eb9e0e commit 187f292

4 files changed

Lines changed: 81 additions & 0 deletions

File tree

‎docs/CHANGELOG.md‎

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

1111
## main
1212

13+
* 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.
14+
15+
*Erik Axel Nielsen*
16+
1317
* Raise errors encountered while computing a component's cache digest instead of degrading to an untracked component that can serve stale fragments.
1418

1519
*Erik Axel Nielsen*

‎lib/view_component/cache_digest.rb‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,16 @@ def enabled?
7575
# @private
7676
def register(component)
7777
return unless component.virtual_path && component.name
78+
return if registry[component.virtual_path] == component.name
7879

7980
registry[component.virtual_path] = component.name
81+
82+
# Components register as they load, and under lazy loading that happens
83+
# after Action View has already digested and memoized some templates.
84+
# Those digests were computed without this component's dependencies and
85+
# would otherwise be served for the rest of the process, making the
86+
# digest a function of load order rather than of source.
87+
expire_digests
8088
end
8189

8290
# The synthetic virtual path a component is digested under.
@@ -220,6 +228,13 @@ def install!
220228

221229
private
222230

231+
# Drop Action View's memoized template digests, leaving its resolver
232+
# caches alone: no template changed, only the set of dependencies the
233+
# Digestor can see.
234+
def expire_digests
235+
ActionView::LookupContext::DetailsKey.digest_caches.each(&:clear)
236+
end
237+
223238
# Resolve a constant name to a component that opted into caching.
224239
#
225240
# Returns nil for anything else, including constants that don't exist.

‎test/sandbox/test/experimentally_cacheable_integration_test.rb‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,28 @@ def test_fragment_inside_a_component_template_is_unaffected_by_unrelated_compone
129129
end
130130
end
131131

132+
# Components register as they're autoloaded, so under lazy loading a template
133+
# can be digested before the components it renders have loaded. Action View
134+
# memoizes digests for the life of the process, so that first digest sticks:
135+
# without invalidation the same source digests differently depending on the
136+
# order things happened to load in.
137+
def test_digest_does_not_depend_on_when_the_component_registered
138+
registered_first = with_registry("cacheable_component" => "CacheableComponent") do
139+
clear_digest_cache
140+
fragment_digest_for("integration_examples/cached_component")
141+
end
142+
143+
registered_late = with_registry({}) do
144+
clear_digest_cache
145+
fragment_digest_for("integration_examples/cached_component")
146+
ViewComponent::CacheDigest.register(CacheableComponent)
147+
148+
fragment_digest_for("integration_examples/cached_component")
149+
end
150+
151+
assert_equal registered_first, registered_late
152+
end
153+
132154
def test_component_output_is_cached_between_requests
133155
get "/cached_component"
134156
assert_select(".cacheable", text: "cached")
@@ -158,6 +180,15 @@ def fragment_key_for(path)
158180
capture_fragment_key { with_new_cache { get path } }
159181
end
160182

183+
def with_registry(entries)
184+
saved = ViewComponent::CacheDigest.registry.dup
185+
ViewComponent::CacheDigest.registry.replace(entries)
186+
yield
187+
ensure
188+
ViewComponent::CacheDigest.registry.replace(saved)
189+
clear_digest_cache
190+
end
191+
161192
def view_context
162193
ApplicationController.new.tap { |c| c.request = ActionDispatch::TestRequest.create }.view_context
163194
end

‎test/sandbox/test/experimentally_cacheable_test.rb‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,32 @@ def test_registers_component_with_the_digest_registry
2020
)
2121
end
2222

23+
# Registration runs on every class load, so an unchanged component must not
24+
# throw away digests other templates are still using.
25+
def test_registering_an_unchanged_component_leaves_memoized_digests_alone
26+
clear_digest_cache
27+
CacheableComponent.cache_digest
28+
memoized = digest_cache_size
29+
30+
assert_operator memoized, :>, 0
31+
32+
ViewComponent::CacheDigest.register(CacheableComponent)
33+
34+
assert_equal memoized, digest_cache_size
35+
end
36+
37+
def test_registering_a_new_component_expires_memoized_digests
38+
clear_digest_cache
39+
CacheableComponent.cache_digest
40+
41+
assert_operator digest_cache_size, :>, 0
42+
43+
ViewComponent::CacheDigest.registry.delete("cacheable_component")
44+
ViewComponent::CacheDigest.register(CacheableComponent)
45+
46+
assert_equal 0, digest_cache_size
47+
end
48+
2349
def test_component_is_marked_cacheable
2450
assert_predicate CacheableComponent, :__vc_cacheable?
2551
refute_respond_to ErbComponent, :__vc_cacheable?
@@ -513,6 +539,11 @@ def recompile(component)
513539
component.__vc_compile(force: true)
514540
end
515541

542+
# Every digest Action View has memoized, across all details keys.
543+
def digest_cache_size
544+
ActionView::LookupContext::DetailsKey.digest_caches.sum(&:size)
545+
end
546+
516547
def build_template(source, virtual_path: "test/template")
517548
ActionView::Template.new(
518549
source,

0 commit comments

Comments
 (0)