Skip to content

Digest cache blocks written inside a component template - #2714

Open
erikaxel wants to merge 3 commits into
ViewComponent:mainfrom
erikaxel:vc-digest-cache-block-in-component-template
Open

Digest cache blocks written inside a component template#2714
erikaxel wants to merge 3 commits into
ViewComponent:mainfrom
erikaxel:vc-digest-cache-block-in-component-template

Conversation

@erikaxel

@erikaxel erikaxel commented Sep 3, 2026

Copy link
Copy Markdown

Problem

4.15.0 frames the win as: a <% cache %> block wrapping the component in a view is invalidated when the component changes. That works. A cache block inside a component's template does not — it gets no digest at all, silently.

ActionView::Helpers::CacheHelper#digest_path_from_template calls Digestor.digest(name: template.virtual_path, ...). Inside a component render, @current_template is the component's own template, so the name is e.g. post_component. Nothing resolves it: component templates aren't in view_paths, and CacheDigest::Resolver#find_templates only answers paths prefixed view_component/cache_digest/. The Digestor returns "", digest_path_from_template falls back to the bare virtual path, and the fragment is never invalidated. The only signal is a log line:

Couldn't find template for digesting: post_component

Measured before this change:

cache block lives in… digest
an app/views template bdc19872c00fd6a096130a056c3b3c89
a component template, top level ""
a component template, nested ""
a nested component that has opted in ""

The same components digest fine through the synthetic path (PostComponent.cache_digestcb23e1bd…), so the digest exists; it's just unreachable from where cache looks.

This isn't specific to apps that use components as their view layer, and nesting is irrelevant. The trigger is purely where the cache block sits lexically — a vanilla app with app/components/foo_component.html.erb hits it too, because app/components isn't in view_paths either.

Fix

No Rails change needed. ActionView::Helpers::CacheHelper is mixed into the component's own ancestor chain, and ExperimentallyCacheable sits ahead of it:

ExperimentallyCacheable          at ancestor index 1
ViewComponent::Base              at 16
ActionView::Helpers::CacheHelper at 51

So the narrowest seam, digest_path_from_template, can be overridden in the module. When the template being digested is the component's own, the digest ViewComponent already computes is substituted for the empty one the Digestor returns. Anything else — a partial rendered from within the component, for instance — falls through to super unchanged.

The alternative is overriding cache itself, since its own cache_fragment_name already accepts digest_path: and cache drops it via options.slice(:skip_digest). That means duplicating the helper body (including the CachingRegistry.track_caching wrapper) to widen one slice, which looked like the worse maintenance trade.

The digest comes from the component being rendered, not from whichever class owns the template file, so a subclass that inherits its parent's template doesn't share the parent's fragment.

Two things worth settling

  1. digest_path_from_template is # :nodoc:. Its signature and body are identical on 7.1 through main, and the tests here would catch a change, but I'm happy to take this to Rails as a request to make the seam public (or to make cache forward digest_path:) if you'd rather not depend on it.
  2. Should this apply to every component, or only to those that opted in? As written it's opt-in, matching the boundary the rest of the design uses — the digest it substitutes only exists for registered components. Applying it everywhere would make component-hosted cache blocks correct by default, which is probably what users expect, but it widens the feature's reach. It also interacts with Track components that haven't opted into cacheability #2713, which argues for tracking components that never opted in. Happy to rework either way.

Docs

The guide didn't say component-hosted cache blocks were digest-aware, but it didn't say they weren't either, and "that's all that's needed for the <% cache %> block above to work" reads as covering both. Added a section that says explicitly what including the module does and doesn't do for blocks inside a component template.

Tests

Fail on main, pass here:

  • test_a_cache_block_in_a_component_template_is_digested — asserts the exact fragment key, read from read_fragment.action_controller instrumentation rather than recomputed.
  • test_a_subclass_rendering_an_inherited_template_uses_its_own_digest — the digest follows the component being rendered, not the template's owner.
  • test_fragment_inside_a_component_template_is_invalidated_when_its_template_changes / ..._when_an_ancestor_changes / ..._when_a_rendered_component_changes (integration) — the three invalidation dimensions, end to end through a request.

Plus test_digest_path_for_anything_else_is_left_to_rails and test_fragment_inside_a_component_template_is_unaffected_by_unrelated_components, which pass either way and pin the fall-through.

bundle exec rake passes on the default Gemfile (Rails 8.1); the cacheable suites pass on gemfiles/rails_7.1.gemfile. The only failure seen was a Ferrum::PendingConnectionsError flake in ViewComponentSystemTest, which passes when run on its own and is unrelated.

Related

Independent of, but in the same area as, #2709, #2711, #2712 and #2713. Only the docs/ files overlap.

- Override digest_path_from_template in ExperimentallyCacheable so a
  cache block in a component's own template gets the component's digest
  instead of the empty one the Digestor returns for a path that resolves
  to no template
- Leave every other template the component renders to Rails
- Add regression tests for the block busting on the component's template,
  an ancestor, and a component it renders
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant