From b662f7504f089297394223aeb89fa669f17d5f13 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Fri, 9 Oct 2026 11:53:14 -0600 Subject: [PATCH 1/3] Track discovered component dependencies without per-component opt-in Consolidate the fixes proposed in #2711 and #2713. Register literal and declared dependencies by their actual class names and virtual paths, preserving acronym names, custom paths, and transitive invalidation without enabling output caching or suppressing digest errors. Co-authored-by: Erik Axel Nielsen Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 62a65b38-1933-46c3-aa8b-de40801e2c7f --- docs/CHANGELOG.md | 6 + docs/guide/caching.md | 8 +- lib/view_component/cache_digest.rb | 37 +++-- .../experimentally_cacheable.rb | 3 +- .../cacheable_plain_dependency_component.rb | 15 ++ ...e_raising_explicit_dependency_component.rb | 11 ++ ...heable_untracked_parent_component.html.erb | 1 + .../cacheable_untracked_parent_component.rb | 5 + .../http_untracked_component.html.erb | 1 + .../components/http_untracked_component.rb | 4 + .../untracked_child_component.html.erb | 1 + .../components/untracked_child_component.rb | 5 + .../cached_untracked_component.html.erb | 4 + test/sandbox/config/application.rb | 2 + test/sandbox/config/routes.rb | 1 + ...perimentally_cacheable_integration_test.rb | 32 +++++ .../test/experimentally_cacheable_test.rb | 131 +++++++++++++++++- 17 files changed, 247 insertions(+), 20 deletions(-) create mode 100644 test/sandbox/app/components/cacheable_plain_dependency_component.rb create mode 100644 test/sandbox/app/components/cacheable_raising_explicit_dependency_component.rb create mode 100644 test/sandbox/app/components/cacheable_untracked_parent_component.html.erb create mode 100644 test/sandbox/app/components/cacheable_untracked_parent_component.rb create mode 100644 test/sandbox/app/components/http_untracked_component.html.erb create mode 100644 test/sandbox/app/components/http_untracked_component.rb create mode 100644 test/sandbox/app/components/untracked_child_component.html.erb create mode 100644 test/sandbox/app/components/untracked_child_component.rb create mode 100644 test/sandbox/app/views/integration_examples/cached_untracked_component.html.erb diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 36f2da188..0d321d2eb 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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* diff --git a/docs/guide/caching.md b/docs/guide/caching.md index 5cbcfe3f0..4caa580d2 100644 --- a/docs/guide/caching.md +++ b/docs/guide/caching.md @@ -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 @@ -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. @@ -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 diff --git a/lib/view_component/cache_digest.rb b/lib/view_component/cache_digest.rb index 4db6bc48c..4557ffaeb 100644 --- a/lib/view_component/cache_digest.rb +++ b/lib/view_component/cache_digest.rb @@ -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 @@ -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. @@ -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. @@ -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] 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 @@ -176,13 +177,15 @@ def resolve_render_parser(parser) # @return [Array] 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 @@ -228,6 +231,15 @@ 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. @@ -235,15 +247,14 @@ 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 diff --git a/lib/view_component/experimentally_cacheable.rb b/lib/view_component/experimentally_cacheable.rb index 3a87be6a7..4a0bd2d9b 100644 --- a/lib/view_component/experimentally_cacheable.rb +++ b/lib/view_component/experimentally_cacheable.rb @@ -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. # diff --git a/test/sandbox/app/components/cacheable_plain_dependency_component.rb b/test/sandbox/app/components/cacheable_plain_dependency_component.rb new file mode 100644 index 000000000..f5f482276 --- /dev/null +++ b/test/sandbox/app/components/cacheable_plain_dependency_component.rb @@ -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 diff --git a/test/sandbox/app/components/cacheable_raising_explicit_dependency_component.rb b/test/sandbox/app/components/cacheable_raising_explicit_dependency_component.rb new file mode 100644 index 000000000..9dd0ae832 --- /dev/null +++ b/test/sandbox/app/components/cacheable_raising_explicit_dependency_component.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class CacheableRaisingExplicitDependencyComponent < ViewComponent::Base + include ViewComponent::ExperimentallyCacheable + + # Template Dependency: CacheDigestFixtures::RaisingRubyDependency + + def call + "unreachable" + end +end diff --git a/test/sandbox/app/components/cacheable_untracked_parent_component.html.erb b/test/sandbox/app/components/cacheable_untracked_parent_component.html.erb new file mode 100644 index 000000000..6898f8e50 --- /dev/null +++ b/test/sandbox/app/components/cacheable_untracked_parent_component.html.erb @@ -0,0 +1 @@ +
<%= render UntrackedChildComponent.new %>
diff --git a/test/sandbox/app/components/cacheable_untracked_parent_component.rb b/test/sandbox/app/components/cacheable_untracked_parent_component.rb new file mode 100644 index 000000000..a3c800012 --- /dev/null +++ b/test/sandbox/app/components/cacheable_untracked_parent_component.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class CacheableUntrackedParentComponent < ViewComponent::Base + include ViewComponent::ExperimentallyCacheable +end diff --git a/test/sandbox/app/components/http_untracked_component.html.erb b/test/sandbox/app/components/http_untracked_component.html.erb new file mode 100644 index 000000000..a0f6a85b3 --- /dev/null +++ b/test/sandbox/app/components/http_untracked_component.html.erb @@ -0,0 +1 @@ +HTTP diff --git a/test/sandbox/app/components/http_untracked_component.rb b/test/sandbox/app/components/http_untracked_component.rb new file mode 100644 index 000000000..a259067b5 --- /dev/null +++ b/test/sandbox/app/components/http_untracked_component.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class HTTPUntrackedComponent < ViewComponent::Base +end diff --git a/test/sandbox/app/components/untracked_child_component.html.erb b/test/sandbox/app/components/untracked_child_component.html.erb new file mode 100644 index 000000000..7c6719c9e --- /dev/null +++ b/test/sandbox/app/components/untracked_child_component.html.erb @@ -0,0 +1 @@ +untracked<%= render HTTPUntrackedComponent.new %> diff --git a/test/sandbox/app/components/untracked_child_component.rb b/test/sandbox/app/components/untracked_child_component.rb new file mode 100644 index 000000000..d07a10665 --- /dev/null +++ b/test/sandbox/app/components/untracked_child_component.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class UntrackedChildComponent < ViewComponent::Base + self.virtual_path = "custom/untracked_child" +end diff --git a/test/sandbox/app/views/integration_examples/cached_untracked_component.html.erb b/test/sandbox/app/views/integration_examples/cached_untracked_component.html.erb new file mode 100644 index 000000000..eacbad1b0 --- /dev/null +++ b/test/sandbox/app/views/integration_examples/cached_untracked_component.html.erb @@ -0,0 +1,4 @@ +<% cache "cached-untracked-component-fragment" do %> + <%= render CacheableComponent.new(title: "cached") %> + <%= render UntrackedChildComponent.new %> +<% end %> diff --git a/test/sandbox/config/application.rb b/test/sandbox/config/application.rb index 65c9082fd..d01622174 100644 --- a/test/sandbox/config/application.rb +++ b/test/sandbox/config/application.rb @@ -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 diff --git a/test/sandbox/config/routes.rb b/test/sandbox/config/routes.rb index c29f6e4b3..d0d7c5073 100644 --- a/test/sandbox/config/routes.rb +++ b/test/sandbox/config/routes.rb @@ -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" diff --git a/test/sandbox/test/experimentally_cacheable_integration_test.rb b/test/sandbox/test/experimentally_cacheable_integration_test.rb index b1d152248..81c24545c 100644 --- a/test/sandbox/test/experimentally_cacheable_integration_test.rb +++ b/test/sandbox/test/experimentally_cacheable_integration_test.rb @@ -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", "changed\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", "changed\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" diff --git a/test/sandbox/test/experimentally_cacheable_test.rb b/test/sandbox/test/experimentally_cacheable_test.rb index ebf6cd10c..fd7bc3fd0 100644 --- a/test/sandbox/test/experimentally_cacheable_test.rb +++ b/test/sandbox/test/experimentally_cacheable_test.rb @@ -79,6 +79,12 @@ def test_cache_digest_raises_when_a_template_dependency_fails_to_load assert_equal "raising template dependency", error.message end + def test_cache_digest_raises_when_an_explicit_dependency_fails_to_load + error = assert_raises(RuntimeError) { CacheableRaisingExplicitDependencyComponent.cache_digest } + + assert_equal "raising Ruby dependency", error.message + end + def test_cache_digest_raises_when_a_digest_source_cannot_be_read error = assert_raises(Errno::EISDIR) { CacheableUnreadableDigestSourceComponent.cache_digest } @@ -117,6 +123,32 @@ def test_cache_digest_changes_when_a_child_component_ruby_file_changes ) { CacheableParentComponent.cache_digest } end + def test_cache_digest_changes_when_an_untracked_child_template_changes + assert_digest_changes( + "app/components/untracked_child_component.html.erb", + "changed\n" + ) { CacheableUntrackedParentComponent.cache_digest } + end + + def test_cache_digest_changes_when_an_untracked_child_ruby_file_changes + original = File.read(Rails.root.join("app/components/untracked_child_component.rb")) + + assert_digest_changes( + "app/components/untracked_child_component.rb", + original + "\n# a comment\n" + ) { CacheableUntrackedParentComponent.cache_digest } + end + + def test_cache_digest_changes_when_a_transitive_untracked_dependency_changes + refute_respond_to UntrackedChildComponent, :__vc_cacheable? + refute_respond_to HTTPUntrackedComponent, :__vc_cacheable? + + assert_digest_changes( + "app/components/http_untracked_component.html.erb", + "changed\n" + ) { CacheableUntrackedParentComponent.cache_digest } + end + def test_cache_digest_changes_when_a_superclass_template_changes assert_digest_changes( "app/components/cacheable_component.html.erb", @@ -165,13 +197,56 @@ def test_declared_template_paths_are_left_alone assert_includes dependencies, "integration_examples/erb_partial" end - def test_declared_names_that_are_not_cacheable_components_are_left_alone + def test_declared_names_that_are_not_components_are_left_alone + assert_empty ViewComponent::CacheDigest.explicit_component_dependencies( + "# Template Dependency: NotAConstantAnywhere" + ) assert_empty ViewComponent::CacheDigest.explicit_component_dependencies( - "# Template Dependency: ErbComponent" + "# Template Dependency: ActiveSupport::Digest" ) assert_empty ViewComponent::CacheDigest.explicit_component_dependencies("no declarations here") end + def test_declared_components_resolve_without_opting_into_caching + refute_respond_to ErbComponent, :__vc_cacheable? + + with_registry("cacheable_component" => "CacheableComponent") do + template = build_template("<%# Template Dependency: ErbComponent %>") + dependencies = ActionView::DependencyTracker.find_dependencies("test/template", template, []) + + assert_includes dependencies, "view_component/cache_digest/erb_component" + refute_includes dependencies, "ErbComponent" + assert_equal ErbComponent, ViewComponent::CacheDigest.component_for("view_component/cache_digest/erb_component") + end + end + + def test_cache_digest_changes_when_a_declared_untracked_component_changes + assert_digest_changes( + "app/components/erb_component.html.erb", + "
changed
\n" + ) { CacheablePlainDependencyComponent.cache_digest } + end + + def test_declared_components_register_acronym_names_and_custom_virtual_paths + with_registry("cacheable_component" => "CacheableComponent") do + dependencies = ViewComponent::CacheDigest.explicit_component_dependencies( + "# Template Dependency: HTTPUntrackedComponent\n# Template Dependency: UntrackedChildComponent" + ) + + assert_equal( + [ + ["HTTPUntrackedComponent", "view_component/cache_digest/http_untracked_component"], + ["UntrackedChildComponent", "view_component/cache_digest/custom/untracked_child"] + ], + dependencies + ) + assert_equal HTTPUntrackedComponent, + ViewComponent::CacheDigest.component_for("view_component/cache_digest/http_untracked_component") + assert_equal UntrackedChildComponent, + ViewComponent::CacheDigest.component_for("view_component/cache_digest/custom/untracked_child") + end + end + # Components rendered from an inline template are invisible to Action View's # trackers, which only read template files. def test_cache_digest_changes_when_a_child_of_an_inline_template_changes @@ -505,8 +580,47 @@ def test_dependencies_are_found_for_component_renders ) end - def test_dependencies_ignore_components_that_did_not_opt_in - assert_empty ViewComponent::CacheDigest.dependencies_in(build_template("<%= render ErbComponent.new(message: 'a') %>")) + def test_dependencies_are_found_for_components_that_did_not_opt_in + assert_equal( + ["view_component/cache_digest/erb_component"], + ViewComponent::CacheDigest.dependencies_in(build_template("<%= render ErbComponent.new(message: 'a') %>")) + ) + end + + def test_discovered_components_register_acronym_names_and_custom_virtual_paths + with_registry("cacheable_component" => "CacheableComponent") do + dependencies = ViewComponent::CacheDigest.dependencies_in( + build_template("<%= render HTTPUntrackedComponent.new %><%= render UntrackedChildComponent.new %>") + ) + + assert_equal( + ["view_component/cache_digest/http_untracked_component", "view_component/cache_digest/custom/untracked_child"], + dependencies + ) + assert_equal "HTTPUntrackedComponent", ViewComponent::CacheDigest.registry["http_untracked_component"] + assert_equal "UntrackedChildComponent", ViewComponent::CacheDigest.registry["custom/untracked_child"] + assert_equal HTTPUntrackedComponent, + ViewComponent::CacheDigest.component_for("view_component/cache_digest/http_untracked_component") + assert_equal UntrackedChildComponent, + ViewComponent::CacheDigest.component_for("view_component/cache_digest/custom/untracked_child") + refute_respond_to HTTPUntrackedComponent, :__vc_caches_output? + refute_includes UntrackedChildComponent.ancestors, ViewComponent::ExperimentallyCacheable + end + end + + def test_dependencies_ignore_constants_that_are_not_components + assert_empty ViewComponent::CacheDigest.dependencies_in(build_template("<%= render IntegrationExamplesController %>")) + end + + def test_dependency_discovery_does_not_enable_caching_without_opt_in + with_registry({}) do + source = "<%# Template Dependency: UntrackedChildComponent %><%= render HTTPUntrackedComponent.new %>" + + assert_empty ViewComponent::CacheDigest.dependencies_in(build_template(source)) + assert_empty ViewComponent::CacheDigest.component_paths_in(source) + assert_empty ViewComponent::CacheDigest.explicit_component_dependencies(source) + assert_empty ViewComponent::CacheDigest.registry + end end def test_resolver_is_identified_by_class @@ -530,6 +644,15 @@ def test_install_is_idempotent private + def with_registry(entries) + saved = ViewComponent::CacheDigest.registry.dup + ViewComponent::CacheDigest.registry.replace(entries) + yield + ensure + ViewComponent::CacheDigest.registry.replace(saved) + clear_digest_cache + end + # `with_new_cache` compiles components against whatever is on disk, then # restores the previous compile cache on exit. A component compiled while its # template was modified is therefore still registered as compiled, and keeps From 549f6bb864c8ce25274d2890de4c26b6d729ce06 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Fri, 9 Oct 2026 12:16:16 -0600 Subject: [PATCH 2/3] Fix prose and RuboCop failures in caching PR Split the guide sentence flagged by Vale, remove directives for a Rails cop not loaded by StandardRB, and remove a redundant line continuation rejected by current RuboCop. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 62a65b38-1933-46c3-aa8b-de40801e2c7f --- docs/guide/caching.md | 2 +- lib/view_component/experimentally_cacheable.rb | 2 +- .../app/components/format_sensitive_cacheable_component.rb | 2 +- test/sandbox/app/components/positional_cache_key_component.rb | 2 +- test/sandbox/test/rendering_test.rb | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/guide/caching.md b/docs/guide/caching.md index 4caa580d2..e8575e1e4 100644 --- a/docs/guide/caching.md +++ b/docs/guide/caching.md @@ -44,7 +44,7 @@ That's all that's needed for the `<% cache %>` block above to work. The componen 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. +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 diff --git a/lib/view_component/experimentally_cacheable.rb b/lib/view_component/experimentally_cacheable.rb index 4a0bd2d9b..062d0a30d 100644 --- a/lib/view_component/experimentally_cacheable.rb +++ b/lib/view_component/experimentally_cacheable.rb @@ -187,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| diff --git a/test/sandbox/app/components/format_sensitive_cacheable_component.rb b/test/sandbox/app/components/format_sensitive_cacheable_component.rb index f37bb016a..f3337fbb3 100644 --- a/test/sandbox/app/components/format_sensitive_cacheable_component.rb +++ b/test/sandbox/app/components/format_sensitive_cacheable_component.rb @@ -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 diff --git a/test/sandbox/app/components/positional_cache_key_component.rb b/test/sandbox/app/components/positional_cache_key_component.rb index 7ef549109..629d25744 100644 --- a/test/sandbox/app/components/positional_cache_key_component.rb +++ b/test/sandbox/app/components/positional_cache_key_component.rb @@ -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 diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index 6e53e8749..9a119688a 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -581,7 +581,7 @@ def test_raise_error_when_variant_template_files_collide assert_includes( error.message, - "Colliding templates 'mini-watch' and 'mini__watch' found in VariantTemplatesCollisionComponent." \ + "Colliding templates 'mini-watch' and 'mini__watch' found in VariantTemplatesCollisionComponent." ) end From 63d60c834be0aab559cc38d7e47b11deef17bd2a Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Thu, 15 Oct 2026 12:00:34 -0600 Subject: [PATCH 3/3] Enable source digest tracking for all components Install digest discovery at engine initialization and move component digest APIs and fragment key integration to Base. Track every component automatically while keeping cache_on output caching opt-in. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 62a65b38-1933-46c3-aa8b-de40801e2c7f --- docs/CHANGELOG.md | 4 +- docs/api.md | 4 + docs/guide/caching.md | 28 +++---- lib/view_component/base.rb | 31 ++++++++ lib/view_component/cache_digest.rb | 29 ++----- lib/view_component/engine.rb | 5 ++ .../experimentally_cacheable.rb | 79 +------------------ .../components/cache_block_base_component.rb | 1 - .../cached_untracked_component.html.erb | 1 - .../test/experimentally_cacheable_test.rb | 22 ++++-- 10 files changed, 80 insertions(+), 124 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c73da2ae5..a79d3fa18 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -10,9 +10,9 @@ nav_order: 6 ## main -* Track discovered components without requiring each dependency to include `ViewComponent::ExperimentallyCacheable`, once experimental caching is enabled. +* 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 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. + 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* diff --git a/docs/api.md b/docs/api.md index b615b2d54..f1341dc44 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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. diff --git a/docs/guide/caching.md b/docs/guide/caching.md index e8575e1e4..6d11349f4 100644 --- a/docs/guide/caching.md +++ b/docs/guide/caching.md @@ -16,7 +16,7 @@ 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 %> @@ -24,31 +24,29 @@ Components are invisible to that mechanism. <% 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 a component to enable experimental 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. -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. +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`. -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. +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 %> @@ -57,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 @@ -172,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 ``` @@ -182,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 @@ -206,7 +200,7 @@ The same works in a template, where the branch is often the more natural place f <%= render component.new(post: @post) %> ``` -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. +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 diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index 21b0d5460..541c80a42 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -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" @@ -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. @@ -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 @@ -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) diff --git a/lib/view_component/cache_digest.rb b/lib/view_component/cache_digest.rb index 4557ffaeb..e1e28b567 100644 --- a/lib/view_component/cache_digest.rb +++ b/lib/view_component/cache_digest.rb @@ -21,10 +21,8 @@ module ViewComponent # not just its template. # # This module fixes both, reusing Rails' own `ActionView::Digestor` rather than - # 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. + # reimplementing static analysis. Every discovered component is tracked + # automatically, independently of output caching. # # @private module CacheDigest @@ -57,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 opted-in and discovered components, 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. @@ -68,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 @@ -111,13 +103,8 @@ def component_for(virtual_path) # Scan a template's source for renders of components. # - # Called for every template Rails digests, so it exits early when the - # feature is unused. - # # @return [Array] synthetic virtual paths def dependencies_in(template) - return [] unless enabled? - component_paths_in(template.source) end @@ -126,7 +113,6 @@ def dependencies_in(template) # # @return [Array] 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| @@ -177,7 +163,6 @@ def resolve_render_parser(parser) # @return [Array] 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| @@ -191,11 +176,12 @@ def explicit_component_dependencies(source) # 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 @@ -213,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! diff --git a/lib/view_component/engine.rb b/lib/view_component/engine.rb index c6d31b13c..9b3177a2c 100644 --- a/lib/view_component/engine.rb +++ b/lib/view_component/engine.rb @@ -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 diff --git a/lib/view_component/experimentally_cacheable.rb b/lib/view_component/experimentally_cacheable.rb index 062d0a30d..126c6cb2b 100644 --- a/lib/view_component/experimentally_cacheable.rb +++ b/lib/view_component/experimentally_cacheable.rb @@ -9,16 +9,9 @@ module ViewComponent # release. Please share feedback in # https://github.com/ViewComponent/view_component/issues/234. # - # Including this module does two things: - # - # 1. Registers the component with Rails' template digest tree, so a - # `<% 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. 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. + # Enables the `cache_on` macro, which caches the component's own rendered + # output. Source digest tracking is automatic for all components and does + # not require this module. # # ```ruby # class MessageComponent < ViewComponent::Base @@ -39,11 +32,6 @@ module ExperimentallyCacheable # that respect would share an entry. NIL_CACHE_VALUE = :__vc_nil - included do - ViewComponent::CacheDigest.install! - ViewComponent::CacheDigest.register(self) - end - class_methods do # Declare the values that identify a rendering of this component. # @@ -56,8 +44,7 @@ module ExperimentallyCacheable # ``` # # Calling `cache_on` opts the component into caching its own output. - # Without it, including this module only registers the component with - # Rails' digest tree. + # Without it, including this module does not cache rendered output. # # Pass `if:` or `unless:` to cache only some renders. Both accept a method # name or a proc evaluated on the component: @@ -131,37 +118,6 @@ def __vc_cacheable? def __vc_caches_output? __vc_cache_on.any? end - - # A digest of everything this component renders from: its template, its - # Ruby class, its sidecar files, its superclasses, and the components and - # partials it renders. - # - # Computed with Rails' own `ActionView::Digestor`, so it's the same digest - # used to invalidate `<% cache %>` blocks. - # - # Usable outside a request, where no view context exists: - # - # ```ruby - # MessageComponent.cache_digest - # ``` - # - # @param finder [ActionView::LookupContext] Defaults to a lookup context - # built from `ActionController::Base.view_paths`. - # @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 - - # @private - def inherited(child) - super - ViewComponent::CacheDigest.register(child) - end end # Renders the component, reading from and writing to the Rails cache when @@ -227,33 +183,6 @@ def cache_key(view_context = nil) ) end - # The digest Rails mixes into the key of a `<% cache %>` block. - # - # `ActionView::Helpers::CacheHelper` digests the virtual path of whichever - # template is rendering. Inside a component that path is the component's - # own, which resolves to nothing: component templates aren't in the view - # paths. The Digestor returns an empty digest, `CacheHelper` falls back to - # the bare virtual path, and the fragment never invalidates. - # - # Substituting the digest the component already computes makes a `cache` - # block in a component template behave like one in a view. Everything else - # the component renders — a partial, say — keeps Rails' behavior. - # - # @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 || __vc_cache_format(lookup_context) - ) - - # An empty digest means the component couldn't be resolved. Falling back - # to the bare virtual path matches what `CacheHelper` does with one. - digest.present? ? "#{component_path}:#{digest}" : component_path - end - private # Slots set by the caller via `with_*`. Checked before rendering, so slots diff --git a/test/sandbox/app/components/cache_block_base_component.rb b/test/sandbox/app/components/cache_block_base_component.rb index 0d3c3798d..5e1bacb47 100644 --- a/test/sandbox/app/components/cache_block_base_component.rb +++ b/test/sandbox/app/components/cache_block_base_component.rb @@ -3,5 +3,4 @@ # Ancestor of CacheBlockComponent, so changes here must invalidate the fragment # that component's template caches. class CacheBlockBaseComponent < ViewComponent::Base - include ViewComponent::ExperimentallyCacheable end diff --git a/test/sandbox/app/views/integration_examples/cached_untracked_component.html.erb b/test/sandbox/app/views/integration_examples/cached_untracked_component.html.erb index eacbad1b0..edd20e8ba 100644 --- a/test/sandbox/app/views/integration_examples/cached_untracked_component.html.erb +++ b/test/sandbox/app/views/integration_examples/cached_untracked_component.html.erb @@ -1,4 +1,3 @@ <% cache "cached-untracked-component-fragment" do %> - <%= render CacheableComponent.new(title: "cached") %> <%= render UntrackedChildComponent.new %> <% end %> diff --git a/test/sandbox/test/experimentally_cacheable_test.rb b/test/sandbox/test/experimentally_cacheable_test.rb index fd7bc3fd0..5583bd523 100644 --- a/test/sandbox/test/experimentally_cacheable_test.rb +++ b/test/sandbox/test/experimentally_cacheable_test.rb @@ -373,6 +373,8 @@ def test_cache_key_includes_the_digest # own path, which resolves to no template: component templates aren't in the # view paths. Without a digest of its own the fragment never invalidates. def test_a_cache_block_in_a_component_template_is_digested + refute_includes CacheBlockComponent.ancestors, ViewComponent::ExperimentallyCacheable + with_caching do key = capture_fragment_key { render_inline(CacheBlockComponent.new) } @@ -612,14 +614,24 @@ def test_dependencies_ignore_constants_that_are_not_components assert_empty ViewComponent::CacheDigest.dependencies_in(build_template("<%= render IntegrationExamplesController %>")) end - def test_dependency_discovery_does_not_enable_caching_without_opt_in + def test_dependency_discovery_works_without_any_registered_components with_registry({}) do source = "<%# Template Dependency: UntrackedChildComponent %><%= render HTTPUntrackedComponent.new %>" + dependencies = ActionView::DependencyTracker.find_dependencies("test/template", build_template(source), []) + + assert_includes dependencies, "view_component/cache_digest/http_untracked_component" + assert_includes dependencies, "view_component/cache_digest/custom/untracked_child" + refute_respond_to HTTPUntrackedComponent, :__vc_caches_output? + refute_includes UntrackedChildComponent.ancestors, ViewComponent::ExperimentallyCacheable + end + end - assert_empty ViewComponent::CacheDigest.dependencies_in(build_template(source)) - assert_empty ViewComponent::CacheDigest.component_paths_in(source) - assert_empty ViewComponent::CacheDigest.explicit_component_dependencies(source) - assert_empty ViewComponent::CacheDigest.registry + def test_unopted_component_digest_is_computable_without_registration + with_registry({}) do + refute_includes UntrackedChildComponent.ancestors, ViewComponent::ExperimentallyCacheable + + refute_empty UntrackedChildComponent.cache_digest + assert_equal "UntrackedChildComponent", ViewComponent::CacheDigest.registry["custom/untracked_child"] end end