Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ nav_order: 6

## main

* 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`.
Expand Down
2 changes: 1 addition & 1 deletion docs/guide/caching.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
34 changes: 31 additions & 3 deletions lib/view_component/cache_digest.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<Array(String, String)>] pairs of declared name and
# synthetic virtual path
def explicit_component_dependencies(source)
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
26 changes: 24 additions & 2 deletions test/sandbox/test/experimentally_cacheable_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
"<div>changed</div>\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
Expand Down
Loading