diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 9834fc583..294042f1d 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -10,6 +10,12 @@ nav_order: 6
## main
+* Give `<% cache %>` blocks inside a component's own template a digest, for components that `include ViewComponent::ExperimentallyCacheable`.
+
+ Rails digests the virtual path of whichever template is rendering. Inside a component that path resolves to no template, because component templates aren't in the view paths, so the Digestor returned an empty digest and the fragment was never invalidated. The only signal was a `Couldn't find template for digesting` line in the log. 4.15.0 fixed the case where the `cache` block wraps the component in a view. This fixes the case where the block sits in the component's template.
+
+ *Erik Axel Nielsen*
+
## 4.15.0
* Add experimental caching support, opt-in per component via `include ViewComponent::ExperimentallyCacheable`.
diff --git a/docs/guide/caching.md b/docs/guide/caching.md
index 183de010d..0af337a16 100644
--- a/docs/guide/caching.md
+++ b/docs/guide/caching.md
@@ -42,6 +42,21 @@ end
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.
+## Caching inside a component template
+
+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.
+
+```erb
+<%# app/components/post_component.html.erb %>
+<% cache @post do %>
+ <%= render CommentComponent.new(post: @post) %>
+<% end %>
+```
+
+Including the module fixes this too. The component's own digest is substituted for the empty one Rails computes, so the fragment is invalidated by the same set of changes listed above. A component that hasn't opted in gets no digest at all, and the fragment is never invalidated.
+
+`cache` blocks in partials the component renders are unaffected: those templates resolve through the view paths like any other, so Rails digests them itself.
+
## Self-caching
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.
diff --git a/lib/view_component/experimentally_cacheable.rb b/lib/view_component/experimentally_cacheable.rb
index 804d23873..af73f4d4a 100644
--- a/lib/view_component/experimentally_cacheable.rb
+++ b/lib/view_component/experimentally_cacheable.rb
@@ -12,9 +12,10 @@ module ViewComponent
# Including this module does two things:
#
# 1. Registers the component with Rails' template digest tree, so a
- # `<% cache %>` block wrapping the component in a view is invalidated when
- # the component's template, Ruby class, sidecar files, or child components
- # change.
+ # `<% cache %>` block is invalidated when the component's template, Ruby
+ # class, sidecar files, or child components change. This covers blocks
+ # wrapping the component in a view and blocks inside the component's own
+ # template.
# 2. Enables the `cache_on` macro, which caches the component's own rendered
# output.
#
@@ -225,6 +226,30 @@ def cache_key(view_context = nil)
)
end
+ # The digest Rails mixes into the key of a `<% cache %>` block.
+ #
+ # `ActionView::Helpers::CacheHelper` digests the virtual path of whichever
+ # template is rendering. Inside a component that path is the component's
+ # own, which resolves to nothing: component templates aren't in the view
+ # paths. The Digestor returns an empty digest, `CacheHelper` falls back to
+ # the bare virtual path, and the fragment never invalidates.
+ #
+ # Substituting the digest the component already computes makes a `cache`
+ # block in a component template behave like one in a view. Everything else
+ # the component renders — a partial, say — keeps Rails' behavior.
+ #
+ # @private
+ def digest_path_from_template(template)
+ component_path = self.class.virtual_path
+ return super unless component_path && template.virtual_path == component_path
+
+ digest = self.class.cache_digest(finder: lookup_context, format: template.format || :html)
+
+ # An empty digest means the component couldn't be resolved. Falling back
+ # to the bare virtual path matches what `CacheHelper` does with one.
+ digest.present? ? "#{component_path}:#{digest}" : component_path
+ end
+
private
# Slots set by the caller via `with_*`. Checked before rendering, so slots
diff --git a/test/sandbox/app/components/cache_block_base_component.rb b/test/sandbox/app/components/cache_block_base_component.rb
new file mode 100644
index 000000000..0d3c3798d
--- /dev/null
+++ b/test/sandbox/app/components/cache_block_base_component.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+# Ancestor of CacheBlockComponent, so changes here must invalidate the fragment
+# that component's template caches.
+class CacheBlockBaseComponent < ViewComponent::Base
+ include ViewComponent::ExperimentallyCacheable
+end
diff --git a/test/sandbox/app/components/cache_block_component.html.erb b/test/sandbox/app/components/cache_block_component.html.erb
new file mode 100644
index 000000000..1703749a7
--- /dev/null
+++ b/test/sandbox/app/components/cache_block_component.html.erb
@@ -0,0 +1,3 @@
+<% cache "cache-block-fragment" do %>
+
<%= render CacheableChildComponent.new %>
+<% end %>
diff --git a/test/sandbox/app/components/cache_block_component.rb b/test/sandbox/app/components/cache_block_component.rb
new file mode 100644
index 000000000..7acc3f4bc
--- /dev/null
+++ b/test/sandbox/app/components/cache_block_component.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+# Holds a `<% cache %>` block in its own template, rather than being wrapped in
+# one by a view.
+class CacheBlockComponent < CacheBlockBaseComponent
+end
diff --git a/test/sandbox/app/components/cache_block_subclass_component.rb b/test/sandbox/app/components/cache_block_subclass_component.rb
new file mode 100644
index 000000000..8409a0982
--- /dev/null
+++ b/test/sandbox/app/components/cache_block_subclass_component.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+# Inherits CacheBlockComponent's template, and with it the `cache` block in it.
+class CacheBlockSubclassComponent < CacheBlockComponent
+end
diff --git a/test/sandbox/app/views/integration_examples/cache_block_component.html.erb b/test/sandbox/app/views/integration_examples/cache_block_component.html.erb
new file mode 100644
index 000000000..1b3befaea
--- /dev/null
+++ b/test/sandbox/app/views/integration_examples/cache_block_component.html.erb
@@ -0,0 +1 @@
+<%= render CacheBlockComponent.new %>
diff --git a/test/sandbox/config/routes.rb b/test/sandbox/config/routes.rb
index 71b76dd95..c29f6e4b3 100644
--- a/test/sandbox/config/routes.rb
+++ b/test/sandbox/config/routes.rb
@@ -28,6 +28,7 @@
get :cached_partial, to: "integration_examples#cached_partial"
get :cached_component, to: "integration_examples#cached_component"
get :cached_nested_component, to: "integration_examples#cached_nested_component"
+ get :cache_block_component, to: "integration_examples#cache_block_component"
get :inherited_sidecar, to: "integration_examples#inherited_sidecar"
get :inherited_from_uncompilable_component, to: "integration_examples#inherited_from_uncompilable_component"
get :unsafe_component, to: "integration_examples#unsafe_component"
diff --git a/test/sandbox/test/experimentally_cacheable_integration_test.rb b/test/sandbox/test/experimentally_cacheable_integration_test.rb
index 2c08015de..fbecabd6a 100644
--- a/test/sandbox/test/experimentally_cacheable_integration_test.rb
+++ b/test/sandbox/test/experimentally_cacheable_integration_test.rb
@@ -3,8 +3,9 @@
require "test_helper"
# Proves the scenario from https://github.com/ViewComponent/view_component/issues/234:
-# a `<% cache %>` block in a view that renders a component is invalidated when
-# the component changes.
+# a `<% cache %>` block is invalidated when the component changes, whether the
+# block sits in a view that renders the component or in the component's own
+# template.
class ExperimentallyCacheableIntegrationTest < ActionDispatch::IntegrationTest
def setup
Rails.cache.clear
@@ -75,6 +76,59 @@ def test_cache_block_digest_is_unaffected_by_unrelated_components
end
end
+ def test_renders_a_cache_block_held_by_a_component_template
+ get "/cache_block_component"
+
+ assert_response :success
+ assert_select(".cache-block .cacheable-child", text: "child")
+ end
+
+ def test_fragment_inside_a_component_template_is_invalidated_when_its_template_changes
+ get "/cache_block_component"
+ assert_select(".cache-block .cacheable-child", text: "child")
+
+ template = "<% cache \"cache-block-fragment\" do %>\n changed
\n<% end %>\n"
+ modify_file "app/components/cache_block_component.html.erb", template do
+ clear_digest_cache
+ with_new_cache do
+ get "/cache_block_component"
+
+ assert_select(".cache-block", text: "changed")
+ end
+ end
+ end
+
+ def test_fragment_inside_a_component_template_is_invalidated_when_an_ancestor_changes
+ before = fragment_key_for("/cache_block_component")
+
+ original = File.read(Rails.root.join("app/components/cache_block_base_component.rb"))
+ modify_file "app/components/cache_block_base_component.rb", original + "\n# a comment\n" do
+ clear_digest_cache
+
+ refute_equal before, fragment_key_for("/cache_block_component")
+ end
+ end
+
+ def test_fragment_inside_a_component_template_is_invalidated_when_a_rendered_component_changes
+ before = fragment_key_for("/cache_block_component")
+
+ modify_file "app/components/cacheable_child_component.html.erb", "changed\n" do
+ clear_digest_cache
+
+ refute_equal before, fragment_key_for("/cache_block_component")
+ end
+ end
+
+ def test_fragment_inside_a_component_template_is_unaffected_by_unrelated_components
+ before = fragment_key_for("/cache_block_component")
+
+ modify_file "app/components/erb_component.html.erb", "unrelated change
\n" do
+ clear_digest_cache
+
+ assert_equal before, fragment_key_for("/cache_block_component")
+ end
+ end
+
def test_component_output_is_cached_between_requests
get "/cached_component"
assert_select(".cacheable", text: "cached")
@@ -98,6 +152,12 @@ def fragment_digest_for(virtual_path)
)
end
+ # The key of the fragment the component's own template caches during the
+ # given request.
+ def fragment_key_for(path)
+ capture_fragment_key { with_new_cache { get path } }
+ end
+
def view_context
ApplicationController.new.tap { |c| c.request = ActionDispatch::TestRequest.create }.view_context
end
diff --git a/test/sandbox/test/experimentally_cacheable_test.rb b/test/sandbox/test/experimentally_cacheable_test.rb
index bb0566163..cf77f4d42 100644
--- a/test/sandbox/test/experimentally_cacheable_test.rb
+++ b/test/sandbox/test/experimentally_cacheable_test.rb
@@ -256,6 +256,51 @@ def test_cache_key_includes_the_digest
assert_includes key, CacheableComponent.cache_digest
end
+ # A `cache` block in a component template is digested from the component's
+ # own path, which resolves to no template: component templates aren't in the
+ # view paths. Without a digest of its own the fragment never invalidates.
+ def test_a_cache_block_in_a_component_template_is_digested
+ with_caching do
+ key = capture_fragment_key { render_inline(CacheBlockComponent.new) }
+
+ assert_equal(
+ [:views, "cache_block_component:#{CacheBlockComponent.cache_digest}", "cache-block-fragment"],
+ key
+ )
+ end
+ end
+
+ # A subclass renders its parent's template, so the digest has to come from the
+ # component being rendered rather than from whichever class owns the file.
+ # Otherwise the two share a fragment despite having different digests.
+ def test_a_subclass_rendering_an_inherited_template_uses_its_own_digest
+ with_caching do
+ key = capture_fragment_key { render_inline(CacheBlockSubclassComponent.new) }
+
+ assert_equal(
+ [
+ :views,
+ "cache_block_subclass_component:#{CacheBlockSubclassComponent.cache_digest}",
+ "cache-block-fragment"
+ ],
+ key
+ )
+ end
+ end
+
+ # A `cache` block in a partial the component renders is Rails' business, not
+ # ours: the partial resolves through the view paths like any other template.
+ def test_digest_path_for_anything_else_is_left_to_rails
+ component = CacheBlockComponent.new
+ render_inline(component)
+ template = build_template("", virtual_path: "integration_examples/_erb_partial")
+
+ assert_equal(
+ "integration_examples/_erb_partial:#{digest_of("integration_examples/_erb_partial")}",
+ component.digest_path_from_template(template)
+ )
+ end
+
def test_undefined_cache_on_method_raises
component = Class.new(CacheableComponent) do
cache_on :nonexistent
@@ -487,14 +532,22 @@ def recompile(component)
component.__vc_compile(force: true)
end
- def build_template(source)
+ def build_template(source, virtual_path: "test/template")
ActionView::Template.new(
source,
"test template",
ActionView::Template.handler_for_extension(:erb),
locals: [],
format: :html,
- virtual_path: "test/template"
+ virtual_path: virtual_path
+ )
+ end
+
+ def digest_of(virtual_path)
+ ActionView::Digestor.digest(
+ name: virtual_path,
+ format: :html,
+ finder: ActionView::LookupContext.new(ActionController::Base.view_paths)
)
end
diff --git a/test/test_helper.rb b/test/test_helper.rb
index 05a514fab..567e117fc 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -207,6 +207,21 @@ def with_compiler_development_mode(mode)
ViewComponent::Compiler.__vc_development_mode = previous_mode
end
+# The key of the first fragment read while the block runs. Taken from the
+# instrumentation rather than recomputed, so assertions cover the key rendering
+# actually used.
+def capture_fragment_key
+ key = nil
+ subscriber = ActiveSupport::Notifications.subscribe("read_fragment.action_controller") do |*, payload|
+ key ||= payload[:key]
+ end
+
+ yield
+ key
+ensure
+ ActiveSupport::Notifications.unsubscribe(subscriber)
+end
+
def capture_warnings(&block)
[].tap do |warnings|
Kernel.stub(:warn, ->(msg) { warnings << msg }) do