From b085ece8921493250f42e11c772082faf01cb725 Mon Sep 17 00:00:00 2001 From: Erik Axel Nielsen Date: Thu, 3 Sep 2026 13:53:55 +0200 Subject: [PATCH] Resolve declared component dependencies that never opted in `# Template Dependency: SomeComponent` is the escape hatch for component renders static analysis can't see -- a class held in a local variable, or reached through a helper module. `explicit_component_dependencies` translated the declared class name into the synthetic path the component is digested under, but only for classes `constantize_component` accepts, which means only ones that already include `ExperimentallyCacheable`. For anything else the raw constant name stayed in the dependency list and was handed to the Digestor as a template path. Nothing resolves there, so the declaration silently did nothing and the only signal was `Couldn't find template for digesting: SomeComponent` in the log. That defeats the point of the hatch: the dependencies you need it for are exactly the ones whose target you may not control or want to modify. Resolve any `ViewComponent::Base` descendant instead, and register it so the Resolver can synthesize a template for it -- being named by a declaration is itself the opt-in. `component_for` resolves through the same path, since the registry can now hold components that never included the module. Co-Authored-By: Claude Opus 5 (1M context) --- docs/CHANGELOG.md | 6 ++++ docs/guide/caching.md | 2 +- lib/view_component/cache_digest.rb | 34 +++++++++++++++++-- .../cacheable_plain_dependency_component.rb | 17 ++++++++++ .../test/experimentally_cacheable_test.rb | 26 ++++++++++++-- 5 files changed, 79 insertions(+), 6 deletions(-) create mode 100644 test/sandbox/app/components/cacheable_plain_dependency_component.rb diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 9834fc583..4edf73834 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -10,6 +10,12 @@ nav_order: 6 ## main +* Resolve `# Template Dependency: SomeComponent` declarations naming a component that hasn't included `ViewComponent::ExperimentallyCacheable`. + + The declaration was previously dropped: the class name was emitted to the Digestor verbatim, which looked for a template at that path, found none, and logged `Couldn't find template for digesting: SomeComponent`. That made the escape hatch unusable for exactly the dependencies it exists for, since a component reached through a local variable or a helper module is often one you don't control and can't add the module to. Naming a component in a declaration now digests it, opted in or not. + + *Erik Axel Nielsen* + ## 4.15.0 * Add experimental caching support, opt-in per component via `include ViewComponent::ExperimentallyCacheable`. diff --git a/docs/guide/caching.md b/docs/guide/caching.md index 183de010d..1035093f5 100644 --- a/docs/guide/caching.md +++ b/docs/guide/caching.md @@ -187,7 +187,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. +A declared component doesn't have to include `ViewComponent::ExperimentallyCacheable` itself. The declaration alone gets its template, Ruby class, sidecar files, and superclasses digested, which matters because the components this escape hatch exists for are often ones you don't control. ## Caveats diff --git a/lib/view_component/cache_digest.rb b/lib/view_component/cache_digest.rb index 98694c8dc..4dab43e7e 100644 --- a/lib/view_component/cache_digest.rb +++ b/lib/view_component/cache_digest.rb @@ -97,7 +97,7 @@ def component_for(virtual_path) name = registry[virtual_path.delete_prefix("#{VIRTUAL_PATH_PREFIX}/")] return unless name - constantize_component(name) + constantize_view_component(name) end # Scan a template's source for renders of cacheable components. @@ -168,6 +168,10 @@ def resolve_render_parser(parser) # that detail out of application code, so `SomeComponent` is translated # to the path the Digestor can resolve. # + # Any component resolves here, not just ones that opted into caching. The + # hatch exists for dependencies the source scanner can't see, which are + # exactly the ones whose target the application may not control. + # # @return [Array] pairs of declared name and # synthetic virtual path def explicit_component_dependencies(source) @@ -176,8 +180,18 @@ def explicit_component_dependencies(source) 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 + component = constantize_view_component(declared) + next unless component + + virtual_path = virtual_path_for(component) + next unless virtual_path + + # Being named by a declaration is what makes a component resolvable: + # the Resolver synthesizes templates from the registry, so a component + # that never opted in has to be added to it before the Digestor asks. + register(component) + + [declared, virtual_path] end end @@ -238,6 +252,20 @@ def constantize_component(constant_name) # Never let digest computation break rendering. nil end + + # Resolve a constant name to a component, whether or not it opted into + # caching. + # + # @return [Class, nil] + def constantize_view_component(constant_name) + component = constant_name.safe_constantize + return unless component.is_a?(Class) && component < ViewComponent::Base + + component + rescue + # Never let digest computation break rendering. + nil + end end # Resolved once at load time rather than memoized, so no class-level state 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..9f663b570 --- /dev/null +++ b/test/sandbox/app/components/cacheable_plain_dependency_component.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +# Renders a component that static analysis can't see and that hasn't opted into +# caching, declared with the `# Template Dependency:` escape hatch. +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/test/experimentally_cacheable_test.rb b/test/sandbox/test/experimentally_cacheable_test.rb index bb0566163..e12385678 100644 --- a/test/sandbox/test/experimentally_cacheable_test.rb +++ b/test/sandbox/test/experimentally_cacheable_test.rb @@ -121,13 +121,35 @@ 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: ErbComponent" + "# Template Dependency: NotAConstantAnywhere" + ) + assert_empty ViewComponent::CacheDigest.explicit_component_dependencies( + "# Template Dependency: ActiveSupport::Digest" ) assert_empty ViewComponent::CacheDigest.explicit_component_dependencies("no declarations here") end + # The hatch exists for dependencies static analysis can't see, which are + # exactly the ones whose target an application may not control. Declaring a + # component is the opt-in, so the target needn't include the module itself. + def test_declared_components_resolve_without_opting_into_caching + refute_respond_to ErbComponent, :__vc_cacheable? + + assert_equal( + [["ErbComponent", "view_component/cache_digest/erb_component"]], + ViewComponent::CacheDigest.explicit_component_dependencies("# Template Dependency: ErbComponent") + ) + end + + def test_cache_digest_changes_when_a_component_declared_without_opting_in_changes + assert_digest_changes( + "app/components/erb_component.html.erb", + "
changed
\n" + ) { CacheablePlainDependencyComponent.cache_digest } + 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