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
2 changes: 1 addition & 1 deletion .audition-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"class-level-state|lib/view_component/cache_digest.rb": 1,
"class-level-state|lib/view_component/configurable.rb": 1,
"class-level-state|lib/view_component/engine.rb": 1,
"class-level-state|lib/view_component/experimentally_cacheable.rb": 4,
"class-level-state|lib/view_component/cacheable.rb": 4,
"class-level-state|lib/view_component/inline_template.rb": 3,
"class-level-state|lib/view_component/instrumentation.rb": 1,
"class-level-state|lib/view_component/preview.rb": 2,
Expand Down
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

* Enable caching support for all components by default, and rename `ViewComponent::ExperimentallyCacheable` to `ViewComponent::Cacheable`. Including the module is no longer necessary. Breaking: remove existing `include ViewComponent::ExperimentallyCacheable` lines. Caching is no longer experimental.

Every component's source digest is tracked automatically. 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` and `cache_on`. Components cache their own output only when they declare `cache_on`, 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
39 changes: 13 additions & 26 deletions docs/guide/caching.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,45 +6,42 @@ 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.
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 declare `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,18 +50,16 @@ 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, use `cache_on` to declare methods used for the component's cache key. `cache_on` is available on every component; there's no module to include. Components that don't call it aren't cached.

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

cache_on :post

def initialize(post:)
Expand Down Expand Up @@ -105,8 +100,6 @@ To cache only some renders, pass `if:` or `unless:`. Both accept a method name o

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

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

def initialize(post:)
Expand Down Expand Up @@ -168,8 +161,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 +169,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 +191,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.
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 All @@ -223,8 +212,6 @@ Slots a component fills in for itself with a `default_*` method are part of its

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

renders_one :header

cache_on :post
Expand Down
2 changes: 1 addition & 1 deletion lib/view_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ module ViewComponent

autoload :Base
autoload :CacheDigest
autoload :Cacheable
autoload :Compiler
autoload :CompileCache
autoload :Config
autoload :Deprecation
autoload :ExperimentallyCacheable
autoload :InlineTemplate
autoload :Instrumentation
autoload :Preview
Expand Down
35 changes: 35 additions & 0 deletions lib/view_component/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
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"
require "view_component/errors"
require "view_component/cacheable"
require "view_component/inline_template"
require "view_component/preview"
require "view_component/request_details"
Expand All @@ -31,6 +33,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 +439,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 +699,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 Expand Up @@ -857,6 +889,9 @@ def __vc_initialize_signatures
attr_reader :__vc_provided_collection_parameter
end

# Prepended so its `render_in` wraps the one defined above.
prepend ViewComponent::Cacheable

ActiveSupport.run_load_hooks(:view_component, self)
end
end
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
Loading
Loading