From b662f7504f089297394223aeb89fa669f17d5f13 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Fri, 9 Oct 2026 11:53:14 -0600 Subject: [PATCH 1/7] 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/7] 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/7] 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 From e2926225f086a2d034bcdde1df2e4ac96b9a324e Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Mon, 19 Oct 2026 13:50:55 -0600 Subject: [PATCH 4/7] Enable caching support for all components by default Prepend ViewComponent::ExperimentallyCacheable into ViewComponent::Base so every component can declare cache_on without including the module. Explicit includes remain harmless no-ops. Components still only cache their own output when they declare cache_on. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c5ebc5d1-5db1-4685-b58b-8efb26828c46 --- docs/CHANGELOG.md | 4 +-- docs/guide/caching.md | 12 ++------ lib/view_component/base.rb | 4 +++ .../experimentally_cacheable.rb | 16 ++++------- .../components/cacheable_call_component.rb | 2 -- .../cacheable_call_partial_component.rb | 2 -- .../components/cacheable_child_component.rb | 1 - .../app/components/cacheable_component.rb | 2 -- .../cacheable_dynamic_constant_component.rb | 2 -- ...cacheable_explicit_dependency_component.rb | 2 -- .../cacheable_inline_partial_component.rb | 2 -- .../cacheable_inline_template_component.rb | 2 -- .../components/cacheable_parent_component.rb | 1 - .../cacheable_plain_dependency_component.rb | 2 -- ...e_raising_explicit_dependency_component.rb | 2 -- ...eable_raising_ruby_dependency_component.rb | 2 -- ...e_raising_template_dependency_component.rb | 1 - .../components/cacheable_slot_component.rb | 2 -- ...able_unreadable_digest_source_component.rb | 2 -- .../cacheable_untracked_parent_component.rb | 1 - .../conditional_cache_probe_component.rb | 2 -- .../format_sensitive_cacheable_component.rb | 2 -- .../positional_cache_key_component.rb | 2 -- .../experimentally_cacheable_review_test.rb | 28 +++++++++++++++---- .../test/experimentally_cacheable_test.rb | 24 ++++++++-------- 25 files changed, 48 insertions(+), 74 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 414a44d21..56365cd2a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -10,9 +10,9 @@ nav_order: 6 ## main -* Automatically track source digests for all components, without requiring `ViewComponent::ExperimentallyCacheable`. +* Enable caching support for all components by default. Including `ViewComponent::ExperimentallyCacheable` is no longer necessary, and existing includes are harmless. - 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. + 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* diff --git a/docs/guide/caching.md b/docs/guide/caching.md index 6d11349f4..fcca8c069 100644 --- a/docs/guide/caching.md +++ b/docs/guide/caching.md @@ -42,7 +42,7 @@ The fragment is invalidated when the component's template, Ruby class, sidecar f 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. +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 @@ -61,12 +61,10 @@ The fragment is invalidated by the same set of changes listed above. No module i ## Self-caching -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. +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:) @@ -107,8 +105,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:) @@ -200,7 +196,7 @@ The same works in a template, where the branch is often the more natural place f <%= render component.new(post: @post) %> ``` -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. +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 @@ -221,8 +217,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 diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index ea02e6bdf..2d7a20cdf 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -8,6 +8,7 @@ require "view_component/compiler" require "view_component/config" require "view_component/errors" +require "view_component/experimentally_cacheable" require "view_component/inline_template" require "view_component/preview" require "view_component/request_details" @@ -888,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::ExperimentallyCacheable + ActiveSupport.run_load_hooks(:view_component, self) end end diff --git a/lib/view_component/experimentally_cacheable.rb b/lib/view_component/experimentally_cacheable.rb index 126c6cb2b..d396534b9 100644 --- a/lib/view_component/experimentally_cacheable.rb +++ b/lib/view_component/experimentally_cacheable.rb @@ -9,14 +9,13 @@ module ViewComponent # release. Please share feedback in # https://github.com/ViewComponent/view_component/issues/234. # - # 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. + # Provides the `cache_on` macro, which caches the component's own rendered + # output. Included in every component by `ViewComponent::Base`, so there's + # no need to include it. Components don't cache their output until they + # call `cache_on`. # # ```ruby # class MessageComponent < ViewComponent::Base - # include ViewComponent::ExperimentallyCacheable - # # cache_on :message # # def initialize(message:) @@ -44,7 +43,7 @@ module ExperimentallyCacheable # ``` # # Calling `cache_on` opts the component into caching its own output. - # Without it, including this module does not cache rendered output. + # Components that don't call it aren't cached. # # Pass `if:` or `unless:` to cache only some renders. Both accept a method # name or a proc evaluated on the component: @@ -107,11 +106,6 @@ def __vc_cache_unless superclass.__vc_cache_unless if superclass.respond_to?(:__vc_cache_unless) end - # @private - def __vc_cacheable? - true - end - # Whether this component caches its own rendered output. # # @return [Boolean] diff --git a/test/sandbox/app/components/cacheable_call_component.rb b/test/sandbox/app/components/cacheable_call_component.rb index ea1602814..c96b354fb 100644 --- a/test/sandbox/app/components/cacheable_call_component.rb +++ b/test/sandbox/app/components/cacheable_call_component.rb @@ -2,8 +2,6 @@ # Renders a child component from a `call` method rather than a template. class CacheableCallComponent < ViewComponent::Base - include ViewComponent::ExperimentallyCacheable - def call render CacheableChildComponent.new end diff --git a/test/sandbox/app/components/cacheable_call_partial_component.rb b/test/sandbox/app/components/cacheable_call_partial_component.rb index 819f34439..bac3d7b83 100644 --- a/test/sandbox/app/components/cacheable_call_partial_component.rb +++ b/test/sandbox/app/components/cacheable_call_partial_component.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true class CacheableCallPartialComponent < ViewComponent::Base - include ViewComponent::ExperimentallyCacheable - def call render "integration_examples/erb_partial" end diff --git a/test/sandbox/app/components/cacheable_child_component.rb b/test/sandbox/app/components/cacheable_child_component.rb index d0af57230..90a902b1d 100644 --- a/test/sandbox/app/components/cacheable_child_component.rb +++ b/test/sandbox/app/components/cacheable_child_component.rb @@ -1,5 +1,4 @@ # frozen_string_literal: true class CacheableChildComponent < ViewComponent::Base - include ViewComponent::ExperimentallyCacheable end diff --git a/test/sandbox/app/components/cacheable_component.rb b/test/sandbox/app/components/cacheable_component.rb index 27b40e079..c683d7e6d 100644 --- a/test/sandbox/app/components/cacheable_component.rb +++ b/test/sandbox/app/components/cacheable_component.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true class CacheableComponent < ViewComponent::Base - include ViewComponent::ExperimentallyCacheable - cache_on :title def initialize(title:) diff --git a/test/sandbox/app/components/cacheable_dynamic_constant_component.rb b/test/sandbox/app/components/cacheable_dynamic_constant_component.rb index 246c04fd1..c2abfc505 100644 --- a/test/sandbox/app/components/cacheable_dynamic_constant_component.rb +++ b/test/sandbox/app/components/cacheable_dynamic_constant_component.rb @@ -3,8 +3,6 @@ # Same as CacheableDynamicComponent, but naming the component class directly # rather than its internal digest path. class CacheableDynamicConstantComponent < ViewComponent::Base - include ViewComponent::ExperimentallyCacheable - # Template Dependency: CacheableChildComponent def initialize(component: CacheableChildComponent) diff --git a/test/sandbox/app/components/cacheable_explicit_dependency_component.rb b/test/sandbox/app/components/cacheable_explicit_dependency_component.rb index 8ece5e067..340d0e5ab 100644 --- a/test/sandbox/app/components/cacheable_explicit_dependency_component.rb +++ b/test/sandbox/app/components/cacheable_explicit_dependency_component.rb @@ -3,8 +3,6 @@ # Renders a partial that static analysis can't see, declared with the # `# Template Dependency:` escape hatch. class CacheableExplicitDependencyComponent < ViewComponent::Base - include ViewComponent::ExperimentallyCacheable - # Template Dependency: integration_examples/erb_partial def initialize(partial: "integration_examples/erb_partial") diff --git a/test/sandbox/app/components/cacheable_inline_partial_component.rb b/test/sandbox/app/components/cacheable_inline_partial_component.rb index db0759fc9..4ed0356c2 100644 --- a/test/sandbox/app/components/cacheable_inline_partial_component.rb +++ b/test/sandbox/app/components/cacheable_inline_partial_component.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true class CacheableInlinePartialComponent < ViewComponent::Base - include ViewComponent::ExperimentallyCacheable - erb_template <<~ERB
<%= render "integration_examples/erb_partial" %>
ERB diff --git a/test/sandbox/app/components/cacheable_inline_template_component.rb b/test/sandbox/app/components/cacheable_inline_template_component.rb index 91bad5925..5ae31be2b 100644 --- a/test/sandbox/app/components/cacheable_inline_template_component.rb +++ b/test/sandbox/app/components/cacheable_inline_template_component.rb @@ -2,8 +2,6 @@ # Renders a child component from an inline template rather than a sidecar file. class CacheableInlineTemplateComponent < ViewComponent::Base - include ViewComponent::ExperimentallyCacheable - erb_template <<~ERB
<%= render CacheableChildComponent.new %>
ERB diff --git a/test/sandbox/app/components/cacheable_parent_component.rb b/test/sandbox/app/components/cacheable_parent_component.rb index 190a87402..85a176e1b 100644 --- a/test/sandbox/app/components/cacheable_parent_component.rb +++ b/test/sandbox/app/components/cacheable_parent_component.rb @@ -2,5 +2,4 @@ # Renders a child component, so changes to the child must invalidate the parent. class CacheableParentComponent < ViewComponent::Base - include ViewComponent::ExperimentallyCacheable end diff --git a/test/sandbox/app/components/cacheable_plain_dependency_component.rb b/test/sandbox/app/components/cacheable_plain_dependency_component.rb index f5f482276..32f5182fd 100644 --- a/test/sandbox/app/components/cacheable_plain_dependency_component.rb +++ b/test/sandbox/app/components/cacheable_plain_dependency_component.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true class CacheablePlainDependencyComponent < ViewComponent::Base - include ViewComponent::ExperimentallyCacheable - # Template Dependency: ErbComponent def initialize(component: ErbComponent) diff --git a/test/sandbox/app/components/cacheable_raising_explicit_dependency_component.rb b/test/sandbox/app/components/cacheable_raising_explicit_dependency_component.rb index 9dd0ae832..6eab5d5d8 100644 --- a/test/sandbox/app/components/cacheable_raising_explicit_dependency_component.rb +++ b/test/sandbox/app/components/cacheable_raising_explicit_dependency_component.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true class CacheableRaisingExplicitDependencyComponent < ViewComponent::Base - include ViewComponent::ExperimentallyCacheable - # Template Dependency: CacheDigestFixtures::RaisingRubyDependency def call diff --git a/test/sandbox/app/components/cacheable_raising_ruby_dependency_component.rb b/test/sandbox/app/components/cacheable_raising_ruby_dependency_component.rb index bf5445e59..4d9d6096b 100644 --- a/test/sandbox/app/components/cacheable_raising_ruby_dependency_component.rb +++ b/test/sandbox/app/components/cacheable_raising_ruby_dependency_component.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true class CacheableRaisingRubyDependencyComponent < ViewComponent::Base - include ViewComponent::ExperimentallyCacheable - def call render CacheDigestFixtures::RaisingRubyDependency.new end diff --git a/test/sandbox/app/components/cacheable_raising_template_dependency_component.rb b/test/sandbox/app/components/cacheable_raising_template_dependency_component.rb index 29e3f2b08..5785a9629 100644 --- a/test/sandbox/app/components/cacheable_raising_template_dependency_component.rb +++ b/test/sandbox/app/components/cacheable_raising_template_dependency_component.rb @@ -1,5 +1,4 @@ # frozen_string_literal: true class CacheableRaisingTemplateDependencyComponent < ViewComponent::Base - include ViewComponent::ExperimentallyCacheable end diff --git a/test/sandbox/app/components/cacheable_slot_component.rb b/test/sandbox/app/components/cacheable_slot_component.rb index 8949cad35..de0cdf2cc 100644 --- a/test/sandbox/app/components/cacheable_slot_component.rb +++ b/test/sandbox/app/components/cacheable_slot_component.rb @@ -3,8 +3,6 @@ # Declares both a slot and `cache_on`, so callers setting the slot must be # rejected while a default-filled slot must not be. class CacheableSlotComponent < ViewComponent::Base - include ViewComponent::ExperimentallyCacheable - renders_one :header cache_on :title diff --git a/test/sandbox/app/components/cacheable_unreadable_digest_source_component.rb b/test/sandbox/app/components/cacheable_unreadable_digest_source_component.rb index 19db7d5dd..c59e88275 100644 --- a/test/sandbox/app/components/cacheable_unreadable_digest_source_component.rb +++ b/test/sandbox/app/components/cacheable_unreadable_digest_source_component.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true class CacheableUnreadableDigestSourceComponent < ViewComponent::Base - include ViewComponent::ExperimentallyCacheable - def call "unreadable digest source" end diff --git a/test/sandbox/app/components/cacheable_untracked_parent_component.rb b/test/sandbox/app/components/cacheable_untracked_parent_component.rb index a3c800012..ed3f59568 100644 --- a/test/sandbox/app/components/cacheable_untracked_parent_component.rb +++ b/test/sandbox/app/components/cacheable_untracked_parent_component.rb @@ -1,5 +1,4 @@ # frozen_string_literal: true class CacheableUntrackedParentComponent < ViewComponent::Base - include ViewComponent::ExperimentallyCacheable end diff --git a/test/sandbox/app/components/conditional_cache_probe_component.rb b/test/sandbox/app/components/conditional_cache_probe_component.rb index edab74b29..fabe6bbff 100644 --- a/test/sandbox/app/components/conditional_cache_probe_component.rb +++ b/test/sandbox/app/components/conditional_cache_probe_component.rb @@ -2,8 +2,6 @@ # Counts renders so cache hits are observable. class ConditionalCacheProbeComponent < ViewComponent::Base - include ViewComponent::ExperimentallyCacheable - cache_on :identity, if: :cacheable? class_attribute :render_count, default: 0 diff --git a/test/sandbox/app/components/format_sensitive_cacheable_component.rb b/test/sandbox/app/components/format_sensitive_cacheable_component.rb index f3337fbb3..be9d9f7c8 100644 --- a/test/sandbox/app/components/format_sensitive_cacheable_component.rb +++ b/test/sandbox/app/components/format_sensitive_cacheable_component.rb @@ -3,8 +3,6 @@ # Renders the requested format, so a cache entry shared across formats is # visible in the output. class FormatSensitiveCacheableComponent < ViewComponent::Base - include ViewComponent::ExperimentallyCacheable - cache_on :identity def call diff --git a/test/sandbox/app/components/positional_cache_key_component.rb b/test/sandbox/app/components/positional_cache_key_component.rb index 629d25744..7c562f996 100644 --- a/test/sandbox/app/components/positional_cache_key_component.rb +++ b/test/sandbox/app/components/positional_cache_key_component.rb @@ -3,8 +3,6 @@ # Two `cache_on` values where either may be nil, so positional collisions are # visible in the key. class PositionalCacheKeyComponent < ViewComponent::Base - include ViewComponent::ExperimentallyCacheable - cache_on :first, :second def initialize(first:, second:) diff --git a/test/sandbox/test/experimentally_cacheable_review_test.rb b/test/sandbox/test/experimentally_cacheable_review_test.rb index cfdcbde13..9cb89cf8b 100644 --- a/test/sandbox/test/experimentally_cacheable_review_test.rb +++ b/test/sandbox/test/experimentally_cacheable_review_test.rb @@ -126,11 +126,31 @@ def self.name end end + def test_cache_on_is_available_without_including_the_module + component = Class.new(ViewComponent::Base) + + refute_predicate component, :__vc_caches_output? + + component.cache_on :identity + + assert_predicate component, :__vc_caches_output? + refute_predicate ViewComponent::Base, :__vc_caches_output? + end + + def test_including_the_module_explicitly_is_harmless + component = Class.new(ViewComponent::Base) do + include ViewComponent::ExperimentallyCacheable + + cache_on :identity + end + + assert_equal 1, component.ancestors.count(ViewComponent::ExperimentallyCacheable) + assert_equal [:identity], component.__vc_cache_on + end + def test_cache_on_rejects_unknown_options error = assert_raises(ArgumentError) do Class.new(ViewComponent::Base) do - include ViewComponent::ExperimentallyCacheable - cache_on :identity, when: :something end end @@ -161,8 +181,6 @@ def self.name def test_cache_on_rejects_a_block assert_raises(ArgumentError) do Class.new(ViewComponent::Base) do - include ViewComponent::ExperimentallyCacheable - cache_on { :identity } end end @@ -173,8 +191,6 @@ def test_cache_on_rejects_a_block def test_cache_on_rejects_a_proc_with_a_useful_error error = assert_raises(ArgumentError) do Class.new(ViewComponent::Base) do - include ViewComponent::ExperimentallyCacheable - cache_on -> { :identity } end end diff --git a/test/sandbox/test/experimentally_cacheable_test.rb b/test/sandbox/test/experimentally_cacheable_test.rb index 5583bd523..418d6c33d 100644 --- a/test/sandbox/test/experimentally_cacheable_test.rb +++ b/test/sandbox/test/experimentally_cacheable_test.rb @@ -46,9 +46,9 @@ def test_registering_a_new_component_expires_memoized_digests assert_equal 0, digest_cache_size end - def test_component_is_marked_cacheable - assert_predicate CacheableComponent, :__vc_cacheable? - refute_respond_to ErbComponent, :__vc_cacheable? + def test_only_components_declaring_cache_on_cache_their_output + assert_predicate CacheableComponent, :__vc_caches_output? + refute_predicate ErbComponent, :__vc_caches_output? end def test_cache_on_declares_key_methods @@ -140,8 +140,8 @@ def test_cache_digest_changes_when_an_untracked_child_ruby_file_changes end def test_cache_digest_changes_when_a_transitive_untracked_dependency_changes - refute_respond_to UntrackedChildComponent, :__vc_cacheable? - refute_respond_to HTTPUntrackedComponent, :__vc_cacheable? + refute_predicate UntrackedChildComponent, :__vc_caches_output? + refute_predicate HTTPUntrackedComponent, :__vc_caches_output? assert_digest_changes( "app/components/http_untracked_component.html.erb", @@ -208,7 +208,7 @@ def test_declared_names_that_are_not_components_are_left_alone end def test_declared_components_resolve_without_opting_into_caching - refute_respond_to ErbComponent, :__vc_cacheable? + refute_predicate ErbComponent, :__vc_caches_output? with_registry("cacheable_component" => "CacheableComponent") do template = build_template("<%# Template Dependency: ErbComponent %>") @@ -373,7 +373,7 @@ 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 + refute_predicate CacheBlockComponent, :__vc_caches_output? with_caching do key = capture_fragment_key { render_inline(CacheBlockComponent.new) } @@ -605,8 +605,8 @@ def test_discovered_components_register_acronym_names_and_custom_virtual_paths 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 + refute_predicate HTTPUntrackedComponent, :__vc_caches_output? + refute_predicate UntrackedChildComponent, :__vc_caches_output? end end @@ -621,14 +621,14 @@ def test_dependency_discovery_works_without_any_registered_components 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 + refute_predicate HTTPUntrackedComponent, :__vc_caches_output? + refute_predicate UntrackedChildComponent, :__vc_caches_output? end end def test_unopted_component_digest_is_computable_without_registration with_registry({}) do - refute_includes UntrackedChildComponent.ancestors, ViewComponent::ExperimentallyCacheable + refute_predicate UntrackedChildComponent, :__vc_caches_output? refute_empty UntrackedChildComponent.cache_digest assert_equal "UntrackedChildComponent", ViewComponent::CacheDigest.registry["custom/untracked_child"] From f746d34a5ef65d6e0cd0148779a912a94fdfea05 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Mon, 19 Oct 2026 13:53:38 -0600 Subject: [PATCH 5/7] Rename ExperimentallyCacheable to Cacheable Keep ViewComponent::ExperimentallyCacheable as a deprecated alias until v5.0.0. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c5ebc5d1-5db1-4685-b58b-8efb26828c46 --- .audition-baseline.json | 2 +- docs/CHANGELOG.md | 2 +- lib/view_component.rb | 8 +++++++- lib/view_component/base.rb | 4 ++-- .../{experimentally_cacheable.rb => cacheable.rb} | 2 +- ...gration_test.rb => cacheable_integration_test.rb} | 2 +- ...eable_review_test.rb => cacheable_review_test.rb} | 12 +++++++++--- ...imentally_cacheable_test.rb => cacheable_test.rb} | 2 +- 8 files changed, 23 insertions(+), 11 deletions(-) rename lib/view_component/{experimentally_cacheable.rb => cacheable.rb} (99%) rename test/sandbox/test/{experimentally_cacheable_integration_test.rb => cacheable_integration_test.rb} (99%) rename test/sandbox/test/{experimentally_cacheable_review_test.rb => cacheable_review_test.rb} (93%) rename test/sandbox/test/{experimentally_cacheable_test.rb => cacheable_test.rb} (99%) diff --git a/.audition-baseline.json b/.audition-baseline.json index 1c19fba37..b525d92af 100644 --- a/.audition-baseline.json +++ b/.audition-baseline.json @@ -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, diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 56365cd2a..03dfa7ca9 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -10,7 +10,7 @@ nav_order: 6 ## main -* Enable caching support for all components by default. Including `ViewComponent::ExperimentallyCacheable` is no longer necessary, and existing includes are harmless. +* Enable caching support for all components by default, and rename `ViewComponent::ExperimentallyCacheable` to `ViewComponent::Cacheable`. Including the module is no longer necessary, and existing includes are harmless. `ViewComponent::ExperimentallyCacheable` remains as a deprecated alias, to be removed in v5.0.0. 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. diff --git a/lib/view_component.rb b/lib/view_component.rb index bb7ba1d42..de17f6e74 100644 --- a/lib/view_component.rb +++ b/lib/view_component.rb @@ -2,6 +2,7 @@ require "action_view" require "active_support/dependencies/autoload" +require "active_support/deprecation" require "view_component/version" module ViewComponent @@ -9,11 +10,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 @@ -27,6 +28,11 @@ module ViewComponent autoload :TestCase autoload :SystemTestCase end + + include ActiveSupport::Deprecation::DeprecatedConstantAccessor + + deprecate_constant :ExperimentallyCacheable, "ViewComponent::Cacheable", + deprecator: ViewComponent::Deprecation end require "view_component/engine" if defined?(Rails::Engine) diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index 2d7a20cdf..c7a2589f5 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -8,7 +8,7 @@ require "view_component/compiler" require "view_component/config" require "view_component/errors" -require "view_component/experimentally_cacheable" +require "view_component/cacheable" require "view_component/inline_template" require "view_component/preview" require "view_component/request_details" @@ -890,7 +890,7 @@ def __vc_initialize_signatures end # Prepended so its `render_in` wraps the one defined above. - prepend ViewComponent::ExperimentallyCacheable + prepend ViewComponent::Cacheable ActiveSupport.run_load_hooks(:view_component, self) end diff --git a/lib/view_component/experimentally_cacheable.rb b/lib/view_component/cacheable.rb similarity index 99% rename from lib/view_component/experimentally_cacheable.rb rename to lib/view_component/cacheable.rb index d396534b9..96097a2e4 100644 --- a/lib/view_component/experimentally_cacheable.rb +++ b/lib/view_component/cacheable.rb @@ -23,7 +23,7 @@ module ViewComponent # end # end # ``` - module ExperimentallyCacheable + module Cacheable extend ActiveSupport::Concern # Stands in for `nil` in the cache key. Without it, `expand_cache_key` diff --git a/test/sandbox/test/experimentally_cacheable_integration_test.rb b/test/sandbox/test/cacheable_integration_test.rb similarity index 99% rename from test/sandbox/test/experimentally_cacheable_integration_test.rb rename to test/sandbox/test/cacheable_integration_test.rb index 81c24545c..332763097 100644 --- a/test/sandbox/test/experimentally_cacheable_integration_test.rb +++ b/test/sandbox/test/cacheable_integration_test.rb @@ -6,7 +6,7 @@ # a `<% cache %>` block is invalidated when the component changes, whether the # block sits in a view that renders the component or in the component's own # template. -class ExperimentallyCacheableIntegrationTest < ActionDispatch::IntegrationTest +class CacheableIntegrationTest < ActionDispatch::IntegrationTest def setup Rails.cache.clear clear_digest_cache diff --git a/test/sandbox/test/experimentally_cacheable_review_test.rb b/test/sandbox/test/cacheable_review_test.rb similarity index 93% rename from test/sandbox/test/experimentally_cacheable_review_test.rb rename to test/sandbox/test/cacheable_review_test.rb index 9cb89cf8b..2427558be 100644 --- a/test/sandbox/test/experimentally_cacheable_review_test.rb +++ b/test/sandbox/test/cacheable_review_test.rb @@ -4,7 +4,7 @@ # Failing tests for the issues @reeganviljoen reported reviewing #2685. # https://github.com/ViewComponent/view_component/pull/2685 -class ExperimentallyCacheableReviewTest < ViewComponent::TestCase +class CacheableReviewTest < ViewComponent::TestCase def setup super Rails.cache.clear @@ -139,15 +139,21 @@ def test_cache_on_is_available_without_including_the_module def test_including_the_module_explicitly_is_harmless component = Class.new(ViewComponent::Base) do - include ViewComponent::ExperimentallyCacheable + include ViewComponent::Cacheable cache_on :identity end - assert_equal 1, component.ancestors.count(ViewComponent::ExperimentallyCacheable) + assert_equal 1, component.ancestors.count(ViewComponent::Cacheable) assert_equal [:identity], component.__vc_cache_on end + def test_experimentally_cacheable_is_a_deprecated_alias + assert_deprecated(/ViewComponent::ExperimentallyCacheable.*ViewComponent::Cacheable/, ViewComponent::Deprecation) do + assert_same ViewComponent::Cacheable, ViewComponent::ExperimentallyCacheable + end + end + def test_cache_on_rejects_unknown_options error = assert_raises(ArgumentError) do Class.new(ViewComponent::Base) do diff --git a/test/sandbox/test/experimentally_cacheable_test.rb b/test/sandbox/test/cacheable_test.rb similarity index 99% rename from test/sandbox/test/experimentally_cacheable_test.rb rename to test/sandbox/test/cacheable_test.rb index 418d6c33d..0f6a13253 100644 --- a/test/sandbox/test/experimentally_cacheable_test.rb +++ b/test/sandbox/test/cacheable_test.rb @@ -2,7 +2,7 @@ require "test_helper" -class ExperimentallyCacheableTest < ViewComponent::TestCase +class CacheableTest < ViewComponent::TestCase def setup super Rails.cache.clear From d8fa2ade7feef926bf8c966e99289803f6d7c115 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Mon, 19 Oct 2026 13:55:06 -0600 Subject: [PATCH 6/7] Remove ExperimentallyCacheable alias Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c5ebc5d1-5db1-4685-b58b-8efb26828c46 --- docs/CHANGELOG.md | 2 +- lib/view_component.rb | 6 ------ test/sandbox/test/cacheable_review_test.rb | 6 ------ 3 files changed, 1 insertion(+), 13 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 03dfa7ca9..b24402a9d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -10,7 +10,7 @@ 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, and existing includes are harmless. `ViewComponent::ExperimentallyCacheable` remains as a deprecated alias, to be removed in v5.0.0. +* 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. 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. diff --git a/lib/view_component.rb b/lib/view_component.rb index de17f6e74..c279e7492 100644 --- a/lib/view_component.rb +++ b/lib/view_component.rb @@ -2,7 +2,6 @@ require "action_view" require "active_support/dependencies/autoload" -require "active_support/deprecation" require "view_component/version" module ViewComponent @@ -28,11 +27,6 @@ module ViewComponent autoload :TestCase autoload :SystemTestCase end - - include ActiveSupport::Deprecation::DeprecatedConstantAccessor - - deprecate_constant :ExperimentallyCacheable, "ViewComponent::Cacheable", - deprecator: ViewComponent::Deprecation end require "view_component/engine" if defined?(Rails::Engine) diff --git a/test/sandbox/test/cacheable_review_test.rb b/test/sandbox/test/cacheable_review_test.rb index 2427558be..6eafc9570 100644 --- a/test/sandbox/test/cacheable_review_test.rb +++ b/test/sandbox/test/cacheable_review_test.rb @@ -148,12 +148,6 @@ def test_including_the_module_explicitly_is_harmless assert_equal [:identity], component.__vc_cache_on end - def test_experimentally_cacheable_is_a_deprecated_alias - assert_deprecated(/ViewComponent::ExperimentallyCacheable.*ViewComponent::Cacheable/, ViewComponent::Deprecation) do - assert_same ViewComponent::Cacheable, ViewComponent::ExperimentallyCacheable - end - end - def test_cache_on_rejects_unknown_options error = assert_raises(ArgumentError) do Class.new(ViewComponent::Base) do From eafd4f7be304585257384f4585ff2d86f9b16dd6 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Mon, 19 Oct 2026 13:55:49 -0600 Subject: [PATCH 7/7] Mark caching as no longer experimental Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c5ebc5d1-5db1-4685-b58b-8efb26828c46 --- docs/CHANGELOG.md | 2 +- docs/guide/caching.md | 5 ----- lib/view_component/cacheable.rb | 6 +----- 3 files changed, 2 insertions(+), 11 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b24402a9d..59b7f73c5 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -10,7 +10,7 @@ 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. +* 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. diff --git a/docs/guide/caching.md b/docs/guide/caching.md index fcca8c069..96d0d0e4a 100644 --- a/docs/guide/caching.md +++ b/docs/guide/caching.md @@ -6,14 +6,9 @@ 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. ViewComponent automatically integrates every component into that mechanism: diff --git a/lib/view_component/cacheable.rb b/lib/view_component/cacheable.rb index 96097a2e4..e76e823ba 100644 --- a/lib/view_component/cacheable.rb +++ b/lib/view_component/cacheable.rb @@ -3,11 +3,7 @@ require "view_component/cache_digest" module ViewComponent - # Experimental caching support for ViewComponents. - # - # **This API is experimental.** It may change or be removed in a non-major - # release. Please share feedback in - # https://github.com/ViewComponent/view_component/issues/234. + # Caching support for ViewComponents. # # Provides the `cache_on` macro, which caches the component's own rendered # output. Included in every component by `ViewComponent::Base`, so there's