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

* Track discovered components without requiring each dependency to include `ViewComponent::ExperimentallyCacheable`, once experimental caching is enabled.

Literal renders and `# Template Dependency:` declarations register the component's actual class name and virtual path, including acronym names and custom paths. Changes to unopted-in dependencies now invalidate enclosing fragments. Component output caching and digest-aware `cache` blocks inside component templates remain opt-in, and unexpected digest errors continue to raise.

*Erik Axel Nielsen, Joel Hawksley*

* Invalidate Action View's memoized template digests when a component registers with `ViewComponent::CacheDigest`, so a digest computed before the component loaded isn't served for the rest of the process.

*Erik Axel Nielsen*
Expand Down
8 changes: 6 additions & 2 deletions docs/guide/caching.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Editing `PostComponent`'s template, Ruby class, or sidecar files doesn't invalid

## Opting in

Include `ViewComponent::ExperimentallyCacheable` in each component that should participate in caching:
Include `ViewComponent::ExperimentallyCacheable` in a component to enable experimental caching:

```ruby
class PostComponent < ViewComponent::Base
Expand All @@ -42,6 +42,10 @@ 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.

Once any component in the application includes the module, dependency tracking discovers components throughout digested views and their render trees, even when those components don't include the module. This includes transitively rendered components, components with acronym names, and components that override `virtual_path`. Applications that never include the module are unaffected.

Discovery only tracks source dependencies. It doesn't cache a component's output or make `cache` blocks inside its own template digest-aware. Those still require the opt-ins 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.
Expand Down Expand Up @@ -202,7 +206,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.
Once experimental caching is enabled, declared components don't need to include `ViewComponent::ExperimentallyCacheable` themselves. 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
37 changes: 24 additions & 13 deletions lib/view_component/cache_digest.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,10 @@ 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. Until at least one component includes
# `ViewComponent::ExperimentallyCacheable`, dependency discovery short-circuits.
# Once enabled, every discovered component is tracked, whether or not it
# included the module.
#
# @private
module CacheDigest
Expand Down Expand Up @@ -56,8 +57,8 @@ 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 opted-in and discovered 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 Down Expand Up @@ -108,7 +109,7 @@ def component_for(virtual_path)
constantize_component(name)
end

# Scan a template's source for renders of cacheable components.
# Scan a template's source for renders of components.
#
# Called for every template Rails digests, so it exits early when the
# feature is unused.
Expand All @@ -121,15 +122,15 @@ def dependencies_in(template)
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 enabled?
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 @@ -176,13 +177,15 @@ def resolve_render_parser(parser)
# @return [Array<Array(String, String)>] pairs of declared name and
# synthetic virtual path
def explicit_component_dependencies(source)
return [] unless enabled?
return [] unless source.is_a?(String) && source.include?("Template Dependency:")

source.scan(EXPLICIT_DEPENDENCY).flatten.uniq.filter_map do |declared|
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

Expand Down Expand Up @@ -228,22 +231,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: 3 additions & 2 deletions lib/view_component/experimentally_cacheable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ module ViewComponent
# `<% cache %>` block is invalidated when the component's template, Ruby
# class, sidecar files, or child components change. This covers blocks
# wrapping the component in a view and blocks inside the component's own
# template.
# template. Once enabled, dependency tracking also discovers components
# that haven't included this module.
# 2. Enables the `cache_on` macro, which caches the component's own rendered
# output.
#
Expand Down Expand Up @@ -186,7 +187,7 @@ def render_in(view_context, **, &block)
if (cached = store.read(key))
# Safe to mark as HTML-safe: the cached string was produced by this same
# rendering pipeline, which escapes output before it's written.
return cached.html_safe # rubocop:disable Rails/OutputSafety
return cached.html_safe
end

super.tap do |output|
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# frozen_string_literal: true

class CacheablePlainDependencyComponent < ViewComponent::Base
include ViewComponent::ExperimentallyCacheable

# Template Dependency: ErbComponent

def initialize(component: ErbComponent)
@component = component
end

def call
render @component.new(message: "plain")
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# frozen_string_literal: true

class CacheableRaisingExplicitDependencyComponent < ViewComponent::Base
include ViewComponent::ExperimentallyCacheable

# Template Dependency: CacheDigestFixtures::RaisingRubyDependency

def call
"unreachable"
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<div class="cacheable-untracked-parent"><%= render UntrackedChildComponent.new %></div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# frozen_string_literal: true

class CacheableUntrackedParentComponent < ViewComponent::Base
include ViewComponent::ExperimentallyCacheable
end
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ class FormatSensitiveCacheableComponent < ViewComponent::Base
cache_on :identity

def call
view_context.lookup_context.formats.first.to_s.html_safe # rubocop:disable Rails/OutputSafety
view_context.lookup_context.formats.first.to_s.html_safe
end

private
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<span class="http-untracked">HTTP</span>
4 changes: 4 additions & 0 deletions test/sandbox/app/components/http_untracked_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# frozen_string_literal: true

class HTTPUntrackedComponent < ViewComponent::Base
end
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ def initialize(first:, second:)
end

def call
"#{first}-#{second}".html_safe # rubocop:disable Rails/OutputSafety
"#{first}-#{second}".html_safe
end

private
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<span class="untracked-child">untracked</span><%= render HTTPUntrackedComponent.new %>
5 changes: 5 additions & 0 deletions test/sandbox/app/components/untracked_child_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# frozen_string_literal: true

class UntrackedChildComponent < ViewComponent::Base
self.virtual_path = "custom/untracked_child"
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<% cache "cached-untracked-component-fragment" do %>
<%= render CacheableComponent.new(title: "cached") %>
<%= render UntrackedChildComponent.new %>
<% end %>
2 changes: 2 additions & 0 deletions test/sandbox/config/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ class Application < Rails::Application
end
end

Rails.autoloaders.main.inflector.inflect("http_untracked_component" => "HTTPUntrackedComponent")

Sandbox::Application.config.secret_key_base = "foo"

# Don't silence library backtraces in test reports
Expand Down
1 change: 1 addition & 0 deletions test/sandbox/config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
get :cached_partial, to: "integration_examples#cached_partial"
get :cached_component, to: "integration_examples#cached_component"
get :cached_nested_component, to: "integration_examples#cached_nested_component"
get :cached_untracked_component, to: "integration_examples#cached_untracked_component"
get :cache_block_component, to: "integration_examples#cache_block_component"
get :inherited_sidecar, to: "integration_examples#inherited_sidecar"
get :inherited_from_uncompilable_component, to: "integration_examples#inherited_from_uncompilable_component"
Expand Down
32 changes: 32 additions & 0 deletions test/sandbox/test/experimentally_cacheable_integration_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,38 @@ def test_cache_block_digest_is_unaffected_by_unrelated_components
end
end

def test_cache_block_is_invalidated_when_an_untracked_component_changes
get "/cached_untracked_component"
assert_select(".untracked-child", text: "untracked")

before = fragment_digest_for("integration_examples/cached_untracked_component")

modify_file "app/components/untracked_child_component.html.erb", "<span class=\"untracked-child\">changed</span>\n" do
clear_digest_cache

refute_equal before, fragment_digest_for("integration_examples/cached_untracked_component")
with_new_cache do
get "/cached_untracked_component"

assert_select(".untracked-child", text: "changed")
end
end
end

def test_cache_block_is_invalidated_when_a_transitive_untracked_component_changes
get "/cached_untracked_component"
assert_select(".http-untracked", text: "HTTP")

modify_file "app/components/http_untracked_component.html.erb", "<span class=\"http-untracked\">changed</span>\n" do
clear_digest_cache
with_new_cache do
get "/cached_untracked_component"

assert_select(".http-untracked", text: "changed")
end
end
end

def test_renders_a_cache_block_held_by_a_component_template
get "/cache_block_component"

Expand Down
Loading
Loading