Skip to content

Commit 91e547e

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 2e6e02d commit 91e547e

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
## 4.15.0
1418

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

‎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.
@@ -223,6 +231,13 @@ def install!
223231

224232
private
225233

234+
# Drop Action View's memoized template digests, leaving its resolver
235+
# caches alone: no template changed, only the set of dependencies the
236+
# Digestor can see.
237+
def expire_digests
238+
ActionView::LookupContext::DetailsKey.digest_caches.each(&:clear)
239+
end
240+
226241
# Resolve a constant name to a component that opted into caching.
227242
#
228243
# 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
@@ -75,6 +75,28 @@ def test_cache_block_digest_is_unaffected_by_unrelated_components
7575
end
7676
end
7777

78+
# Components register as they're autoloaded, so under lazy loading a template
79+
# can be digested before the components it renders have loaded. Action View
80+
# memoizes digests for the life of the process, so that first digest sticks:
81+
# without invalidation the same source digests differently depending on the
82+
# order things happened to load in.
83+
def test_digest_does_not_depend_on_when_the_component_registered
84+
registered_first = with_registry("cacheable_component" => "CacheableComponent") do
85+
clear_digest_cache
86+
fragment_digest_for("integration_examples/cached_component")
87+
end
88+
89+
registered_late = with_registry({}) do
90+
clear_digest_cache
91+
fragment_digest_for("integration_examples/cached_component")
92+
ViewComponent::CacheDigest.register(CacheableComponent)
93+
94+
fragment_digest_for("integration_examples/cached_component")
95+
end
96+
97+
assert_equal registered_first, registered_late
98+
end
99+
78100
def test_component_output_is_cached_between_requests
79101
get "/cached_component"
80102
assert_select(".cacheable", text: "cached")
@@ -98,6 +120,15 @@ def fragment_digest_for(virtual_path)
98120
)
99121
end
100122

123+
def with_registry(entries)
124+
saved = ViewComponent::CacheDigest.registry.dup
125+
ViewComponent::CacheDigest.registry.replace(entries)
126+
yield
127+
ensure
128+
ViewComponent::CacheDigest.registry.replace(saved)
129+
clear_digest_cache
130+
end
131+
101132
def view_context
102133
ApplicationController.new.tap { |c| c.request = ActionDispatch::TestRequest.create }.view_context
103134
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?
@@ -487,6 +513,11 @@ def recompile(component)
487513
component.__vc_compile(force: true)
488514
end
489515

516+
# Every digest Action View has memoized, across all details keys.
517+
def digest_cache_size
518+
ActionView::LookupContext::DetailsKey.digest_caches.sum(&:size)
519+
end
520+
490521
def build_template(source)
491522
ActionView::Template.new(
492523
source,

0 commit comments

Comments
 (0)