Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
15 changes: 15 additions & 0 deletions docs/guide/caching.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
31 changes: 28 additions & 3 deletions lib/view_component/experimentally_cacheable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
#
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions test/sandbox/app/components/cache_block_base_component.rb
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions test/sandbox/app/components/cache_block_component.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<% cache "cache-block-fragment" do %>
<div class="cache-block"><%= render CacheableChildComponent.new %></div>
<% end %>
6 changes: 6 additions & 0 deletions test/sandbox/app/components/cache_block_component.rb
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions test/sandbox/app/components/cache_block_subclass_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# frozen_string_literal: true

# Inherits CacheBlockComponent's template, and with it the `cache` block in it.
class CacheBlockSubclassComponent < CacheBlockComponent
end
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<%= render CacheBlockComponent.new %>
1 change: 1 addition & 0 deletions test/sandbox/config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
64 changes: 62 additions & 2 deletions test/sandbox/test/experimentally_cacheable_integration_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <div class=\"cache-block\">changed</div>\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", "<span class=\"cacheable-child\">changed</span>\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", "<div>unrelated change</div>\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")
Expand All @@ -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
Expand Down
57 changes: 55 additions & 2 deletions test/sandbox/test/experimentally_cacheable_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
15 changes: 15 additions & 0 deletions test/test_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading