Skip to content

Commit e86cfda

Browse files
committed
Raise when a caller sets a slot on a self-caching component
Slot content set by the caller is no more part of the cache key than a block is, so it carries the same risk of serving one caller's content to another. Checked before rendering via @__vc_set_slots, which callers populate through `with_*` setters. Slots a component fills in for itself with a `default_*` method resolve lazily during the render, so they aren't counted and are cached normally.
1 parent 66dcc78 commit e86cfda

7 files changed

Lines changed: 88 additions & 8 deletions

File tree

‎docs/api.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -467,9 +467,9 @@ Content for slot SLOT_NAME has already been provided.
467467

468468
### `ContentPassedToCachedComponentError`
469469

470-
Content was passed to COMPONENT, which caches its own output because it declares `cache_on`.
470+
COMPONENT declares `cache_on`, so it caches its own output, but its caller passed it content.
471471

472-
Content provided by the caller isn't part of the cache key, so caching it would risk serving one caller's content to another.
472+
Content and slots set by the caller aren't part of the cache key, so caching them would risk serving one caller's content to another.
473473

474474
To fix this issue, either remove `cache_on` from COMPONENT, or move the content into the component and derive it from the values declared in `cache_on`.
475475

‎docs/guide/caching.md‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -158,20 +158,39 @@ Declared components must include `ViewComponent::ExperimentallyCacheable` themse
158158

159159
## Caveats
160160

161-
**Self-caching components can't take content.** Content passed by the caller isn't part of the cache key, so caching it would risk serving one caller's content to another. Passing a block or `with_content` to a component that declares `cache_on` raises:
161+
**Self-caching components can't take content from their callers.** Content passed by the caller isn't part of the cache key, so caching it would risk serving one caller's content to another. Passing a block, `with_content`, or a slot to a component that declares `cache_on` raises:
162162

163163
```erb
164164
<%# Raises ContentPassedToCachedComponentError %>
165165
<%= render PostComponent.new(post: @post) do %>
166166
Hello
167167
<% end %>
168+
169+
<%# Also raises %>
170+
<%= render PostComponent.new(post: @post) do |component| %>
171+
<% component.with_header { "Hello" } %>
172+
<% end %>
168173
```
169174

170175
The error is raised whether or not caching is enabled, so the conflict surfaces in development and test rather than only in production.
171176

172-
To cache a component that takes content, move the content into the component and derive it from values declared in `cache_on`. Components that don't declare `cache_on` are unaffected: they still accept content, and a `<% cache %>` block around them still invalidates correctly.
177+
Slots a component fills in for itself with a `default_*` method are part of its own output, not the caller's, so those are cached normally:
178+
179+
```ruby
180+
class PostComponent < ViewComponent::Base
181+
include ViewComponent::ExperimentallyCacheable
182+
183+
renders_one :header
184+
185+
cache_on :post
186+
187+
def default_header
188+
post.title # cached, because the component decides it
189+
end
190+
end
191+
```
173192

174-
**Slot content set by the caller isn't part of the key either**, and isn't currently detected. Declare the values it depends on in `cache_on`.
193+
To cache a component that takes content, move the content into the component and derive it from values declared in `cache_on`. Components that don't declare `cache_on` are unaffected: they still accept content and slots, and a `<% cache %>` block around them still invalidates correctly.
175194

176195
**`cache_on` methods run before the component renders**, so they can only depend on the component's own state, not on `helpers` or the view context. A cache key that depends on the view context is usually a sign the value should be passed to the component instead.
177196

‎lib/view_component/errors.rb‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -244,8 +244,8 @@ def initialize(component_name, method_name)
244244

245245
class ContentPassedToCachedComponentError < StandardError
246246
MESSAGE =
247-
"Content was passed to COMPONENT, which caches its own output because it declares `cache_on`.\n\n" \
248-
"Content provided by the caller isn't part of the cache key, so caching it would risk " \
247+
"COMPONENT declares `cache_on`, so it caches its own output, but its caller passed it content.\n\n" \
248+
"Content and slots set by the caller aren't part of the cache key, so caching them would risk " \
249249
"serving one caller's content to another.\n\n" \
250250
"To fix this issue, either remove `cache_on` from COMPONENT, or move the content into the " \
251251
"component and derive it from the values declared in `cache_on`.".freeze

‎lib/view_component/experimentally_cacheable.rb‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ def render_in(view_context, **, &block)
122122
# it would serve one caller's content to another. Raised whether or not
123123
# caching is currently enabled, so the conflict surfaces in development
124124
# and test rather than only in production.
125-
if block || __vc_content_set_by_with_content_defined?
125+
if block || __vc_content_set_by_with_content_defined? || __vc_slots_set_by_caller?
126126
raise ContentPassedToCachedComponentError.new(self.class.name)
127127
end
128128

@@ -167,6 +167,13 @@ def cache_key(view_context = nil)
167167

168168
private
169169

170+
# Slots set by the caller via `with_*`. Checked before rendering, so slots
171+
# a component fills in for itself with a `default_*` method — which resolve
172+
# lazily during the render — aren't counted.
173+
def __vc_slots_set_by_caller?
174+
defined?(@__vc_set_slots) && @__vc_set_slots.present?
175+
end
176+
170177
def __vc_cache_enabled?(view_context)
171178
return false unless defined?(Rails) && Rails.respond_to?(:cache) && Rails.cache
172179

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
<div class="cacheable-slot">
2+
<span class="header"><%= header %></span>
3+
<span class="title"><%= title %></span>
4+
</div>
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# frozen_string_literal: true
2+
3+
# Declares both a slot and `cache_on`, so callers setting the slot must be
4+
# rejected while a default-filled slot must not be.
5+
class CacheableSlotComponent < ViewComponent::Base
6+
include ViewComponent::ExperimentallyCacheable
7+
8+
renders_one :header
9+
10+
cache_on :title
11+
12+
def initialize(title:)
13+
@title = title
14+
end
15+
16+
def default_header
17+
"default header"
18+
end
19+
20+
private
21+
22+
attr_reader :title
23+
end

‎test/sandbox/test/experimentally_cacheable_test.rb‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,33 @@ def test_passing_with_content_to_a_cached_component_raises
342342
end
343343
end
344344

345+
def test_setting_a_slot_on_a_cached_component_raises
346+
error = assert_raises(ViewComponent::ContentPassedToCachedComponentError) do
347+
render_inline(CacheableSlotComponent.new(title: "a").tap { |c| c.with_header { "set" } })
348+
end
349+
350+
assert_includes error.message, "CacheableSlotComponent"
351+
end
352+
353+
# A slot the component fills in for itself isn't caller-provided, so it's
354+
# part of the component's own output and safe to cache.
355+
def test_a_default_filled_slot_does_not_raise
356+
render_inline(CacheableSlotComponent.new(title: "a"))
357+
358+
assert_selector(".header", text: "default header")
359+
assert_selector(".title", text: "a")
360+
end
361+
362+
def test_default_filled_slots_are_cached
363+
with_caching do
364+
render_inline(CacheableSlotComponent.new(title: "cached"))
365+
366+
refute_nil Rails.cache.read(
367+
CacheableSlotComponent.new(title: "cached").cache_key(vc_test_controller.view_context)
368+
)
369+
end
370+
end
371+
345372
# Raised regardless of whether caching is on, so the conflict is caught in
346373
# development and test rather than only in production.
347374
def test_content_raises_even_when_caching_is_enabled

0 commit comments

Comments
 (0)