Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
6f6902a
Add experimental, opt-in component caching
joelhawksley Aug 20, 2026
9787698
Track dependencies expressed in Ruby, not just in templates
joelhawksley Aug 20, 2026
116ee51
Unwrap caching guide prose and replace invalidation table with a sent…
joelhawksley Aug 20, 2026
f0c5fea
Clarify that cache_on removes the need for a wrapping cache block
joelhawksley Aug 20, 2026
ae85e83
Track partials rendered by string path from Ruby
joelhawksley Aug 20, 2026
f12c6d1
Let Template Dependency name a component class
joelhawksley Aug 20, 2026
66dcc78
Raise when content is passed to a self-caching component
joelhawksley Aug 20, 2026
e86cfda
Raise when a caller sets a slot on a self-caching component
joelhawksley Aug 20, 2026
1892d6f
Document the content restriction and inheritance gotcha with cache_on
joelhawksley Aug 20, 2026
5bb730e
Merge branch 'main' into experimentally-cacheable
joelhawksley Aug 20, 2026
c0acadf
Fix CI failures on Rails 7.1 and main
joelhawksley Aug 20, 2026
24b84b6
Merge remote-tracking branch 'origin/experimentally-cacheable' into e…
joelhawksley Aug 20, 2026
1681063
Add failing tests for review feedback on #2685
joelhawksley Aug 21, 2026
c6b64df
Fix cache key correctness issues found in review
joelhawksley Aug 21, 2026
f8f987c
Merge branch 'main' into experimentally-cacheable
joelhawksley Aug 24, 2026
510e36c
Recompile components after modify_file to prevent test pollution
joelhawksley Aug 24, 2026
449bd26
Fix vale warnings on PR-added lines and make vale fail CI
joelhawksley Aug 25, 2026
46d5b6c
Disable Microsoft.Dashes; keep spaced em-dashes as house style
joelhawksley Aug 25, 2026
f1da3e3
Restore Microsoft.Dashes rule; unspace em-dashes in CHANGELOG
joelhawksley Aug 25, 2026
6d92d61
Rewrite CHANGELOG em-dash clauses as separate sentence and parenthetical
joelhawksley Aug 25, 2026
1948a8e
Rescue Errno::ENOENT in system-test entrypoint controller
joelhawksley Aug 25, 2026
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
2 changes: 2 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ jobs:
persist-credentials: false
- name: Vale
uses: errata-ai/vale-action@d89dee975228ae261d22c15adcd03578634d429c
with:
fail_on_error: true
env:
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
markdown:
Expand Down
1 change: 1 addition & 0 deletions app/controllers/view_components_system_test_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ def self.temp_dir
end

rescue_from ViewComponent::SystemTestControllerNefariousPathError, with: :render_not_found
rescue_from Errno::ENOENT, with: :render_not_found

def system_test_entrypoint
render file: @path
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 @@ nav_order: 6

## 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. This includes components and partials rendered from inline templates and `#call` methods. Adding `cache_on` caches the component's own rendered output, optionally guarded by `if:`/`unless:`, and `.cache_digest` exposes the digest for use outside a request.

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

cache_on :message, unless: -> { message.draft? }

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*. Cache-key correctness issues (formats sharing an entry, positional `nil` collisions, conditional caching, and ignored `cache_on` blocks) were found and reported by *Reegan Viljoen*.

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

## 4.14.0

* Freeze `ReusedInstanceError::MESSAGE` and update `test_renders_component_with_asset_url` to build a fresh `AssetComponent` per render, fixing CI regressions introduced by the GHSA-8qw7-6phv-7q6p remediation.
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 @@ -582,3 +596,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`.
223 changes: 223 additions & 0 deletions docs/guide/caching.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
---
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.

To cache only some renders, pass `if:` or `unless:`. Both accept a method name or a proc evaluated on the component:

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

cache_on :post, unless: -> { post.draft? }

def initialize(post:)
@post = post
end

private

attr_reader :post
end
```

Drafts now render every time, while published posts are cached. Note that this controls whether the cache is *used*, not what goes into the key: a `cache_on` method returning `nil` or `false` still contributes that value to the key rather than disabling caching.

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 even when caching is disabled, 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.

`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