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

* Automatically track source digests for all components, without requiring `ViewComponent::ExperimentallyCacheable`.

Literal renders and `# Template Dependency:` declarations register the component's actual class name and virtual path, including acronym names and custom paths. Changes to dependencies invalidate enclosing fragments, including `cache` blocks inside any component template. Every component exposes `.cache_digest`. Only component output caching through `cache_on` remains opt-in, and unexpected digest errors continue to raise.

*Erik Axel Nielsen, Joel Hawksley*

* Fix subclasses defined after their parent renders a collection using the parent's default collection parameter name instead of their own. Fix recompiling a component not refreshing its collection parameter, counter, and iteration detection. Compute that detection when a component compiles, and store the results frozen so they're shareable across Ractors.

*Ben Sheldon*
Expand Down
4 changes: 4 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ compilation (e.g., generate helpers, register metadata, etc.).

By default, this is a no-op.

### `.cache_digest(finder: nil, format: :html)` → [String]

A digest of the component's source, ancestors, and rendered dependencies.

### `.config` → [ActiveSupport::OrderedOptions]

Returns the current config.
Expand Down
28 changes: 13 additions & 15 deletions docs/guide/caching.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,35 +16,37 @@ Since 4.14.0

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.
ViewComponent automatically integrates every component into 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.
Editing `PostComponent`'s template, Ruby class, or sidecar files invalidates the fragment.

## Opting in
## Automatic digest tracking

Include `ViewComponent::ExperimentallyCacheable` in each component that should participate in caching:
All components participate in Rails' digest tree without including a module or configuring 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.
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.

Dependency tracking discovers components throughout digested views and their render trees. This includes transitively rendered components, components with acronym names, and components that override `virtual_path`.

Tracking source dependencies doesn't cache a component's output by itself. Use a Rails `cache` block or opt into `cache_on` as described below. Dynamic renders still need explicit dependency declarations.

## 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.
A `<% cache %>` block written inside a component's own template also uses the component's source digest automatically:

```erb
<%# app/components/post_component.html.erb %>
Expand All @@ -53,13 +55,13 @@ A `<% cache %>` block written inside a component's own template has the same pro
<% 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.
The fragment is invalidated by the same set of changes listed above. No module include is required.

`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.
To have a component cache its own output without needing a `cache` block, include `ViewComponent::ExperimentallyCacheable` and use `cache_on` to declare methods used for the component's cache key.

```ruby
class PostComponent < ViewComponent::Base
Expand Down Expand Up @@ -168,8 +170,6 @@ Declare these with Rails' `# Template Dependency:` comment, in either the Ruby f

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

# Template Dependency: posts/byline
end
```
Expand All @@ -178,8 +178,6 @@ 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

Expand All @@ -202,7 +200,7 @@ The same works in a template, where the branch is often the more natural place f
<%= 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.
Declared components don't need to include `ViewComponent::ExperimentallyCacheable`. The declaration registers the component's actual class name and virtual path, so its template, Ruby class, sidecar files, superclasses, and discoverable dependencies are digested too.

## When a digest can't be computed

Expand Down
31 changes: 31 additions & 0 deletions lib/view_component/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
require "action_view"
require "action_view/base"
require "view_component/collection"
require "view_component/cache_digest"
require "view_component/compile_cache"
require "view_component/compiler"
require "view_component/config"
Expand Down Expand Up @@ -31,6 +32,20 @@ def with_buffer(buf = nil)
module ViewComponent
class Base
class << self
# A digest of the component's source, ancestors, and rendered dependencies.
#
# @param finder [ActionView::LookupContext, nil]
# @param format [Symbol]
#
# @return [String]
def cache_digest(finder: nil, format: :html)
ViewComponent::CacheDigest.digest(
self,
finder: finder || ViewComponent::CacheDigest.default_finder,
format: format
)
end

delegate(*ViewComponent::Config.defaults.keys, to: :config)

# Returns the current config.
Expand Down Expand Up @@ -423,6 +438,21 @@ def with_captured_virtual_path(captured_path)
@view_context.instance_variable_set(:@virtual_path, old_virtual_path)
end

# Use the component's source digest for fragments inside its own template.
#
# @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 || lookup_context.formats.first || :html
)

digest.present? ? "#{component_path}:#{digest}" : component_path
end

private

attr_reader :view_context
Expand Down Expand Up @@ -668,6 +698,7 @@ def render_template_for(requested_details)
# owner is included in a prefix like `ApplicationComponent.inherited`.
child.identifier = caller_locations(1, 10).reject { |l| l.base_label == "inherited" }[0].path
child.virtual_path = child.name&.underscore
ViewComponent::CacheDigest.register(child)

# Set collection parameter to the extended component
child.with_collection_parameter(__vc_provided_collection_parameter)
Expand Down
50 changes: 22 additions & 28 deletions lib/view_component/cache_digest.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,8 @@ module ViewComponent
# not just its template.
#
# This module fixes both, reusing Rails' own `ActionView::Digestor` rather than
# reimplementing static analysis. Components opt in individually by including
# `ViewComponent::ExperimentallyCacheable`; until at least one component does,
# every hook here short-circuits.
# reimplementing static analysis. Every discovered component is tracked
# automatically, independently of output caching.
#
# @private
module CacheDigest
Expand Down Expand Up @@ -56,8 +55,7 @@ module CacheDigest
# Rails' escape hatch for dependencies static analysis can't see.
EXPLICIT_DEPENDENCY = /#\s*Template Dependency:\s*(\S+)/
class << self
# Virtual paths of components that have opted into caching, mapped to
# their class names.
# Virtual paths of components, mapped to their class names.
#
# Class *names* rather than class objects so the registry survives
# autoloader reloads without pinning stale constants in memory.
Expand All @@ -67,11 +65,6 @@ def registry
@registry ||= {}
end

# @return [Boolean] whether any component has opted in.
def enabled?
!registry.empty?
end

# @private
def register(component)
return unless component.virtual_path && component.name
Expand Down Expand Up @@ -108,28 +101,22 @@ def component_for(virtual_path)
constantize_component(name)
end

# Scan a template's source for renders of cacheable components.
#
# Called for every template Rails digests, so it exits early when the
# feature is unused.
# Scan a template's source for renders of components.
#
# @return [Array<String>] synthetic virtual paths
def dependencies_in(template)
return [] unless enabled?

component_paths_in(template.source)
end

# Scan arbitrary source (a template or a component's Ruby file) for
# renders of cacheable components.
# renders of components.
#
# @return [Array<String>] synthetic virtual paths
def component_paths_in(source)
return [] unless source.is_a?(String) && source.include?("render")

source.scan(RENDER_CALL).flatten.uniq.filter_map do |constant_name|
component = constantize_component(constant_name)
virtual_path_for(component) if component
registered_path_for(constantize_component(constant_name))
end
end

Expand Down Expand Up @@ -182,17 +169,19 @@ def explicit_component_dependencies(source)
next unless /\A(?:::)?[A-Z]/.match?(declared)

component = constantize_component(declared)
[declared, virtual_path_for(component)] if component
virtual_path = registered_path_for(component)
[declared, virtual_path] if virtual_path
end
end

# Compute the digest of a component using Rails' digest tree.
#
# @param component [Class] a component that includes `ExperimentallyCacheable`
# @param component [Class] a component class
# @param finder [ActionView::LookupContext]
# @param format [Symbol]
# @return [String]
def digest(component, finder: default_finder, format: :html)
register(component)
virtual_path = virtual_path_for(component)
return "" unless virtual_path

Expand All @@ -210,10 +199,7 @@ def default_finder

# Wire the tracker and resolver into Action View.
#
# Called each time a component includes `ExperimentallyCacheable`. Both
# steps below are individually idempotent, so no "already installed" flag
# is kept. Both hooks short-circuit while the registry is empty, so
# applications that never opt in are unaffected.
# Installed during engine initialization. Both steps are idempotent.
#
# @private
def install!
Expand All @@ -228,22 +214,30 @@ def install!

private

# Record the actual class name rather than trying to reverse a virtual
# path, which may contain acronyms or be overridden by the component.
def registered_path_for(component)
return unless component

register(component)
virtual_path_for(component)
end

# Drop Action View's memoized template digests, leaving its resolver
# caches alone: no template changed, only the set of dependencies the
# Digestor can see.
def expire_digests
ActionView::LookupContext::DetailsKey.digest_caches.each(&:clear)
end

# Resolve a constant name to a component that opted into caching.
# Resolve a constant name to any ViewComponent.
#
# Returns nil for anything else, including constants that don't exist.
# Autoloading here is safe: the template is about to render this constant
# anyway.
def constantize_component(constant_name)
component = constant_name.safe_constantize
return unless component.is_a?(Class)
return unless component.respond_to?(:__vc_cacheable?) && component.__vc_cacheable?
return unless component.is_a?(Class) && component < ViewComponent::Base

component
end
Expand Down
5 changes: 5 additions & 0 deletions lib/view_component/engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,16 @@
require "rails"
require "view_component/config"
require "view_component/deprecation"
require "view_component/cache_digest"

module ViewComponent
class Engine < Rails::Engine # :nodoc:
config.view_component = ViewComponent::Config.current

initializer "view_component.cache_digest" do
ViewComponent::CacheDigest.install!
end

initializer "view_component.set_configs" do |app|
options = app.config.view_component

Expand Down
Loading
Loading