diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e3902c099..414a44d21 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -10,6 +10,12 @@ nav_order: 6 ## main +* 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 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* + * Fix subclasses defined after their parent renders a collection using the parent's default collection parameter name instead of their own. Fix recompiling a component not refreshing its collection parameter, counter, and iteration detection. Compute that detection when a component compiles, and store the results frozen so they're shareable across Ractors. *Ben Sheldon* 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 5cbcfe3f0..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,27 +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 each component that should participate in caching: +All components participate in Rails' digest tree without including a module or configuring caching: ```ruby class PostComponent < ViewComponent::Base - include ViewComponent::ExperimentallyCacheable - def initialize(post:) @post = post end end ``` -That's all that's needed for the `<% cache %>` block above to work. The component is registered with Rails' digest tree, and the fragment is invalidated when the component's template, Ruby class, sidecar files, superclasses, child components, or rendered partials change, including components and partials rendered from an inline template or a `#call` method. +The fragment is invalidated when the component's template, Ruby class, sidecar files, superclasses, child components, or rendered partials change, including components and partials rendered from an inline template or a `#call` method. + +Dependency tracking discovers components throughout digested views and their render trees. This includes transitively rendered components, components with acronym names, and components that override `virtual_path`. + +Tracking source dependencies doesn't cache a component's output by itself. Use a Rails `cache` block or 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 %> @@ -53,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 @@ -168,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 ``` @@ -178,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 @@ -202,7 +200,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. +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 81947954a..ea02e6bdf 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 4db6bc48c..e1e28b567 100644 --- a/lib/view_component/cache_digest.rb +++ b/lib/view_component/cache_digest.rb @@ -21,9 +21,8 @@ module ViewComponent # not just its template. # # This module fixes both, reusing Rails' own `ActionView::Digestor` rather than - # reimplementing static analysis. Components opt in individually by including - # `ViewComponent::ExperimentallyCacheable`; until at least one component does, - # every hook here short-circuits. + # reimplementing static analysis. Every discovered component is tracked + # automatically, independently of output caching. # # @private module CacheDigest @@ -56,8 +55,7 @@ module CacheDigest # Rails' escape hatch for dependencies static analysis can't see. EXPLICIT_DEPENDENCY = /#\s*Template Dependency:\s*(\S+)/ class << self - # Virtual paths of components that have opted into caching, mapped to - # their class names. + # Virtual paths of components, mapped to their class names. # # Class *names* rather than class objects so the registry survives # autoloader reloads without pinning stale constants in memory. @@ -67,11 +65,6 @@ def registry @registry ||= {} end - # @return [Boolean] whether any component has opted in. - def enabled? - !registry.empty? - end - # @private def register(component) return unless component.virtual_path && component.name @@ -108,28 +101,22 @@ def component_for(virtual_path) constantize_component(name) end - # Scan a template's source for renders of cacheable components. - # - # Called for every template Rails digests, so it exits early when the - # feature is unused. + # Scan a template's source for renders of components. # # @return [Array] synthetic virtual paths def dependencies_in(template) - return [] unless enabled? - component_paths_in(template.source) end # Scan arbitrary source (a template or a component's Ruby file) for - # renders of cacheable components. + # renders of components. # # @return [Array] synthetic virtual paths def component_paths_in(source) return [] unless source.is_a?(String) && source.include?("render") source.scan(RENDER_CALL).flatten.uniq.filter_map do |constant_name| - component = constantize_component(constant_name) - virtual_path_for(component) if component + registered_path_for(constantize_component(constant_name)) end end @@ -182,17 +169,19 @@ def explicit_component_dependencies(source) next unless /\A(?:::)?[A-Z]/.match?(declared) component = constantize_component(declared) - [declared, virtual_path_for(component)] if component + virtual_path = registered_path_for(component) + [declared, virtual_path] if virtual_path end end # Compute the digest of a component using Rails' digest tree. # - # @param component [Class] a component that includes `ExperimentallyCacheable` + # @param component [Class] a component class # @param finder [ActionView::LookupContext] # @param format [Symbol] # @return [String] def digest(component, finder: default_finder, format: :html) + register(component) virtual_path = virtual_path_for(component) return "" unless virtual_path @@ -210,10 +199,7 @@ def default_finder # Wire the tracker and resolver into Action View. # - # Called each time a component includes `ExperimentallyCacheable`. Both - # steps below are individually idempotent, so no "already installed" flag - # is kept. Both hooks short-circuit while the registry is empty, so - # applications that never opt in are unaffected. + # Installed during engine initialization. Both steps are idempotent. # # @private def install! @@ -228,6 +214,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 +230,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/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 50efcf9a2..126c6cb2b 100644 --- a/lib/view_component/experimentally_cacheable.rb +++ b/lib/view_component/experimentally_cacheable.rb @@ -9,15 +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. - # 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 @@ -38,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. # @@ -55,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: @@ -130,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 @@ -226,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/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..edd20e8ba --- /dev/null +++ b/test/sandbox/app/views/integration_examples/cached_untracked_component.html.erb @@ -0,0 +1,3 @@ +<% cache "cached-untracked-component-fragment" do %> + <%= 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..5583bd523 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: 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 + 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 @@ -298,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) } @@ -505,8 +582,57 @@ 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_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 + + 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 def test_resolver_is_identified_by_class @@ -530,6 +656,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