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
1 change: 1 addition & 0 deletions .audition-baseline.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"class-level-state|lib/view_component/base.rb": 9,
"class-level-state|lib/view_component/cache_digest.rb": 1,
"class-level-state|lib/view_component/preview.rb": 2,
"class-variables|lib/view_component/base.rb": 2,
"runtime-class-state|/Users/joelhawksley/.local/share/mise/installs/ruby/4.0.5/lib/ruby/4.0.0/delegate.rb": 1,
Expand Down
24 changes: 24 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,30 @@

## main

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

Components have never participated in Rails' template digests, so a `<% cache %>` block wrapping `render MyComponent.new` was never invalidated when the component changed ([#234](https://github.com/ViewComponent/view_component/issues/234), open since 2020).

Including the module registers the component with Rails' own `ActionView::Digestor`, so fragment caches are invalidated when the component's template, Ruby class, sidecar files, superclasses, child components, or rendered partials change — including components and partials rendered from inline templates and `#call` methods. Adding `cache_on` caches the component's own rendered output, and `.cache_digest` exposes the digest for use outside a request.

Check failure on line 17 in docs/CHANGELOG.md

View workflow job for this annotation

GitHub Actions / prose

[vale] reported by reviewdog 🐶 [Microsoft.Dashes] Remove the spaces around ' —'. Raw Output: {"message": "[Microsoft.Dashes] Remove the spaces around ' —'.", "location": {"path": "docs/CHANGELOG.md", "range": {"start": {"line": 17, "column": 242}}}, "severity": "ERROR"}

```ruby
class MessageComponent < ViewComponent::Base
include ViewComponent::ExperimentallyCacheable

cache_on :message

def initialize(message:)
@message = message
end
end
```

**This API is experimental and may change or be removed in a non-major release.** It's shipping opt-in and per-component precisely so we can iterate on it in response to real-world use. **Please try it and tell us what breaks, what's missing, and what feels wrong in [#234](https://github.com/ViewComponent/view_component/issues/234).** We're especially interested in feedback on: whether `cache_on` is the right shape for declaring cache keys, how the feature behaves with slots and content blocks, and whether the `# Template Dependency:` escape hatch is sufficient for dynamic renders. See [the caching guide](https://viewcomponent.org/guide/caching.html) for details and known caveats.

This work builds directly on prior art from the community: the `cache_on` API and the case for component-local caching come from [#2126](https://github.com/ViewComponent/view_component/pull/2126) by *Reegan Viljoen*; the approach of integrating with Rails' digest tree rather than reimplementing it comes from [`view_component-cache_digest`](https://github.com/tildeio/view_component-cache_digest) by *Godfrey Chan*; the invalidation cases it's tested against were contributed by *JWShuff* and *timburgan*, drawing on [`view_component-fragment_caching`](https://github.com/patrickarnett/view_component-fragment_caching) by *Patrick Arnett*. The issue was opened and researched by *ozzyaaron*, *pinzonjulian*, and *Derek Kniffin*, and the digest workaround that surfaced the superclass gap came from *cannikin* and *rnestler*.

Check warning on line 33 in docs/CHANGELOG.md

View workflow job for this annotation

GitHub Actions / prose

[vale] reported by reviewdog 🐶 [Microsoft.Semicolon] Try to simplify this sentence. Raw Output: {"message": "[Microsoft.Semicolon] Try to simplify this sentence.", "location": {"path": "docs/CHANGELOG.md", "range": {"start": {"line": 33, "column": 420}}}, "severity": "INFO"}

Check warning on line 33 in docs/CHANGELOG.md

View workflow job for this annotation

GitHub Actions / prose

[vale] reported by reviewdog 🐶 [Microsoft.Semicolon] Try to simplify this sentence. Raw Output: {"message": "[Microsoft.Semicolon] Try to simplify this sentence.", "location": {"path": "docs/CHANGELOG.md", "range": {"start": {"line": 33, "column": 220}}}, "severity": "INFO"}

*Reegan Viljoen*, *Godfrey Chan*, *JWShuff*, *timburgan*, *Patrick Arnett*, *ozzyaaron*, *pinzonjulian*, *Derek Kniffin*, *cannikin*, *rnestler*, *Joel Hawksley*

* Update GitHub Actions workflows to use `actions/checkout` v7.

*Richard Macklin*
Expand Down
20 changes: 20 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -455,10 +455,24 @@ A method called 'SETTER_METHOD_NAME' already exists and would be overwritten by

Please choose a different setter name.

### `CacheDigestTemplateError`

The synthetic cache digest template for COMPONENT was rendered.

This template exists only so Rails can compute a cache digest for the component and is never meant to be rendered. Render the component itself instead.

### `ContentAlreadySetForPolymorphicSlotError`

Content for slot SLOT_NAME has already been provided.

### `ContentPassedToCachedComponentError`

COMPONENT declares `cache_on`, so it caches its own output, but its caller passed it content.

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.

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`.

### `ContentSlotNameError`

COMPONENT declares a slot named content, which is a reserved word in ViewComponent.
Expand Down Expand Up @@ -574,3 +588,9 @@ It's sometimes possible to fix this issue by moving code dependent on `#translat
COMPONENT declares a slot named SLOT_NAME, which is an uncountable word

To fix this issue, choose a different name.

### `UndefinedCacheKeyMethodError`

`cache_on` declared `METHOD` on COMPONENT, but no such method is defined.

To fix this issue, define `METHOD` or remove it from `cache_on`.
203 changes: 203 additions & 0 deletions docs/guide/caching.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
---
layout: default
title: Caching
parent: How-to guide
---

# Caching

Experimental
{: .label .label-yellow }

Since 4.14.0
{: .label }

**This API is experimental.** It may change or be removed in a non-major release. Please share feedback in [#234](https://github.com/ViewComponent/view_component/issues/234).

Rails computes a digest for every template from its source and from the templates it renders. That digest is mixed into the key of every `<% cache %>` block in the template, so editing a partial invalidates the caches of everything that renders it.

Components are invisible to that mechanism.

```erb
<% cache @post do %>
<%= render PostComponent.new(post: @post) %>
<% end %>
```

Editing `PostComponent`'s template, Ruby class, or sidecar files doesn't invalidate the fragment, so the stale markup is served until the cache is cleared by hand.

## Opting in

Include `ViewComponent::ExperimentallyCacheable` in each component that should participate in caching:

```ruby
class PostComponent < ViewComponent::Base
include ViewComponent::ExperimentallyCacheable

def initialize(post:)
@post = post
end
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.

## 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.

```ruby
class PostComponent < ViewComponent::Base
include ViewComponent::ExperimentallyCacheable

cache_on :post

def initialize(post:)
@post = post
end

private

attr_reader :post
end
```

Every call site is now cached automatically:

```erb
<%= render PostComponent.new(post: @post) %>
```

Which is equivalent to writing:

```erb
<% cache [@post, PostComponent.cache_digest] do %>
<%= render PostComponent.new(post: @post) %>
<% end %>
```

The cache key combines:

- the component's virtual path
- its digest, computed by Rails' `ActionView::Digestor`
- the requested format and variant
- the current `I18n.locale`
- the values returned by the `cache_on` methods

Caching is skipped unless `perform_caching` is enabled on the controller, matching the behavior of Rails' `cache` helper.

A component that declares `cache_on` can't accept content from its callers. A block, `with_content`, or a slot set by the caller isn't part of the cache key, so passing one raises `ContentPassedToCachedComponentError`:

```erb
<%# Raises: the block's content isn't in the cache key %>
<%= render PostComponent.new(post: @post) do %>
Hello
<% end %>
```

The error is raised whether or not caching is enabled, so the conflict surfaces in development and test rather than only in production. See [Caveats](#caveats) for how to restructure a component that needs to take content.

Check warning on line 98 in docs/guide/caching.md

View workflow job for this annotation

GitHub Actions / prose

[vale] reported by reviewdog 🐶 [Microsoft.Wordiness] Consider using 'whether' instead of 'whether or not'. Raw Output: {"message": "[Microsoft.Wordiness] Consider using 'whether' instead of 'whether or not'.", "location": {"path": "docs/guide/caching.md", "range": {"start": {"line": 98, "column": 21}}}, "severity": "WARNING"}

`cache_on` is inherited, so declaring it on a base class opts every subclass into self-caching, and into that restriction. Declare it on the components that should cache themselves rather than on `ApplicationComponent`.

## Reading a component's digest

`.cache_digest` returns the digest of everything the component renders from. It works outside a request, where no view context exists:

```ruby
PostComponent.cache_digest # => "a1b2c3..."
```

Use it when a cache needs to be tied to a component's source but is written somewhere the component isn't rendered, such as a background job:

```ruby
Rails.cache.fetch(["post-summary", post, PostComponent.cache_digest]) do
expensive_summary_for(post)
end
```

## Declaring dependencies static analysis can't see

Dependencies are discovered by scanning template and Ruby source for literal references, so renders resolved at runtime are invisible:

```erb
<%= render @component %>
```

```ruby
def call
render "posts/#{@post.style}" # interpolated, so not tracked
end
```

Declare these with Rails' `# Template Dependency:` comment, in either the Ruby file or the template. Partials are named by path:

```ruby
class PostComponent < ViewComponent::Base
include ViewComponent::ExperimentallyCacheable

# Template Dependency: posts/byline
end
```

Components are named by class, listing each one the component might render:

```ruby
class PostComponent < ViewComponent::Base
include ViewComponent::ExperimentallyCacheable

# Template Dependency: PostSummaryComponent
# Template Dependency: PostDetailComponent

def call
render(@detailed ? PostDetailComponent : PostSummaryComponent).new(post: @post)
end
end
```

The same works in a template, where the branch is often the more natural place for it:

```erb
<% if params[:style] == "summary" %>
<%# Template Dependency: PostSummaryComponent %>
<% component = PostSummaryComponent %>
<% else %>
<%# Template Dependency: PostDetailComponent %>
<% component = PostDetailComponent %>
<% end %>
<%= render component.new(post: @post) %>
```

Declared components must include `ViewComponent::ExperimentallyCacheable` themselves, since a component that hasn't opted in has no digest to depend on.

## Caveats

**Self-caching components can't take content from their callers.** Besides a block, this covers `with_content` and slots set by the caller:

```erb
<%# Also raises ContentPassedToCachedComponentError %>
<%= render PostComponent.new(post: @post) do |component| %>
<% component.with_header { "Hello" } %>
<% end %>
```

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:

```ruby
class PostComponent < ViewComponent::Base
include ViewComponent::ExperimentallyCacheable

renders_one :header

cache_on :post

def default_header
post.title # cached, because the component decides it
end
end
```

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.

**`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.

**Included modules aren't tracked.** A component's superclasses are, but a module included into a component isn't, since a module has no template or source file of its own to hash. Use `# Template Dependency:` for those.
2 changes: 2 additions & 0 deletions lib/view_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ module ViewComponent
extend ActiveSupport::Autoload

autoload :Base
autoload :CacheDigest
autoload :Compiler
autoload :CompileCache
autoload :Config
autoload :Deprecation
autoload :ExperimentallyCacheable
autoload :InlineTemplate
autoload :Instrumentation
autoload :Preview
Expand Down
Loading
Loading