view_component 4.12.0 → 4.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/controllers/view_components_system_test_controller.rb +1 -0
- data/docs/CHANGELOG.md +86 -0
- data/lib/view_component/active_job_serializer.rb +23 -0
- data/lib/view_component/base.rb +25 -8
- data/lib/view_component/cache_digest/dependency_tracking.rb +46 -0
- data/lib/view_component/cache_digest/resolver.rb +178 -0
- data/lib/view_component/cache_digest.rb +247 -0
- data/lib/view_component/collection.rb +11 -5
- data/lib/view_component/compiler.rb +9 -9
- data/lib/view_component/engine.rb +7 -0
- data/lib/view_component/errors.rb +70 -22
- data/lib/view_component/experimentally_cacheable.rb +285 -0
- data/lib/view_component/instrumentation.rb +1 -1
- data/lib/view_component/request_details.rb +3 -1
- data/lib/view_component/serializable/proxy.rb +110 -0
- data/lib/view_component/serializable.rb +27 -0
- data/lib/view_component/template.rb +33 -32
- data/lib/view_component/version.rb +2 -4
- data/lib/view_component.rb +3 -0
- metadata +9 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9da4df780e2f53937b1593ef29fbbf9ce09662b48de7bc56ceae812c43880d2f
|
|
4
|
+
data.tar.gz: 94c6f23684dc139431a5c912a9e7fab5f70e2e794ba6bb72f5b66211c97f0e8c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 50b36d70558457271f43deb0b67473209af190979ca48c68ce1b3116b27abf06255b0d9312c53382600be23d75aa2304b58926d9e52f314923c4968182b10e53
|
|
7
|
+
data.tar.gz: c687e6eec0d97bd5ea53b3f5b51012e94ca75bd3d659bd1ac2ea03d3343e4d88e695aa5b822d5fe8f55a4a239163cd3df7048a33195b95132ac95e186a491b7b
|
|
@@ -11,6 +11,7 @@ class ViewComponentsSystemTestController < ActionController::Base # :nodoc:
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
rescue_from ViewComponent::SystemTestControllerNefariousPathError, with: :render_not_found
|
|
14
|
+
rescue_from Errno::ENOENT, with: :render_not_found
|
|
14
15
|
|
|
15
16
|
def system_test_entrypoint
|
|
16
17
|
render file: @path
|
data/docs/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,92 @@ nav_order: 6
|
|
|
10
10
|
|
|
11
11
|
## main
|
|
12
12
|
|
|
13
|
+
## 4.15.0
|
|
14
|
+
|
|
15
|
+
* Add experimental caching support, opt-in per component via `include ViewComponent::ExperimentallyCacheable`.
|
|
16
|
+
|
|
17
|
+
Components have never participated in Rails' template digests, so a `<% cache %>` block wrapping `render MyComponent.new` was never invalidated when the component changed ([#234](https://github.com/ViewComponent/view_component/issues/234), open since 2020).
|
|
18
|
+
|
|
19
|
+
Including the module registers the component with Rails' own `ActionView::Digestor`, so fragment caches are invalidated when the component's template, Ruby class, sidecar files, superclasses, child components, or rendered partials change. This includes components and partials rendered from inline templates and `#call` methods. Adding `cache_on` caches the component's own rendered output, optionally guarded by `if:`/`unless:`, and `.cache_digest` exposes the digest for use outside a request.
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
class MessageComponent < ViewComponent::Base
|
|
23
|
+
include ViewComponent::ExperimentallyCacheable
|
|
24
|
+
|
|
25
|
+
cache_on :message, unless: -> { message.draft? }
|
|
26
|
+
|
|
27
|
+
def initialize(message:)
|
|
28
|
+
@message = message
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
**This API is experimental and may change or be removed in a non-major release.** It's shipping opt-in and per-component precisely so we can iterate on it in response to real-world use. **Please try it and tell us what breaks, what's missing, and what feels wrong in [#234](https://github.com/ViewComponent/view_component/issues/234).** We're especially interested in feedback on: whether `cache_on` is the right shape for declaring cache keys, how the feature behaves with slots and content blocks, and whether the `# Template Dependency:` escape hatch is sufficient for dynamic renders. See [the caching guide](https://viewcomponent.org/guide/caching.html) for details and known caveats.
|
|
34
|
+
|
|
35
|
+
This work builds directly on prior art from the community. The `cache_on` API and the case for component-local caching come from [#2126](https://github.com/ViewComponent/view_component/pull/2126) by *Reegan Viljoen*. The approach of integrating with Rails' digest tree rather than reimplementing it comes from [`view_component-cache_digest`](https://github.com/tildeio/view_component-cache_digest) by *Godfrey Chan*. The invalidation cases it's tested against were contributed by *JWShuff* and *timburgan*, drawing on [`view_component-fragment_caching`](https://github.com/patrickarnett/view_component-fragment_caching) by *Patrick Arnett*. The issue was opened and researched by *ozzyaaron*, *pinzonjulian*, and *Derek Kniffin*, and the digest workaround that surfaced the superclass gap came from *cannikin* and *rnestler*. Cache-key correctness issues (formats sharing an entry, positional `nil` collisions, conditional caching, and ignored `cache_on` blocks) were found and reported by *Reegan Viljoen*.
|
|
36
|
+
|
|
37
|
+
*Reegan Viljoen*, *Godfrey Chan*, *JWShuff*, *timburgan*, *Patrick Arnett*, *ozzyaaron*, *pinzonjulian*, *Derek Kniffin*, *cannikin*, *rnestler*, *Joel Hawksley*
|
|
38
|
+
|
|
39
|
+
## 4.14.0
|
|
40
|
+
|
|
41
|
+
* Freeze `ReusedInstanceError::MESSAGE` and update `test_renders_component_with_asset_url` to build a fresh `AssetComponent` per render, fixing CI regressions introduced by the GHSA-8qw7-6phv-7q6p remediation.
|
|
42
|
+
|
|
43
|
+
*Joel Hawksley*
|
|
44
|
+
|
|
45
|
+
* [Security] Fix incomplete remediation for CVE-2026-54497 (GHSA-8qw7-6phv-7q6p): reused ViewComponent instances could still leak `with_content` and `renders_one`/`renders_many` slot content from an earlier render into a later render because slot state and content set via `with_content` are populated by the caller before `render_in` runs and were not cleared by the previous per-render reset. Reinstate the `ViewComponent::ReusedInstanceError` guard that raises when a component instance is rendered more than once. Rebuild collection child components per render and dup collection spacer components before each render so that legitimate re-rendering of `Collection`/spacer objects continues to work.
|
|
46
|
+
|
|
47
|
+
*Yazan Balawneh, Cystack.ps*
|
|
48
|
+
|
|
49
|
+
* Update GitHub Actions workflows to use `actions/checkout` v7.
|
|
50
|
+
|
|
51
|
+
*Richard Macklin*
|
|
52
|
+
|
|
53
|
+
## 4.13.0
|
|
54
|
+
|
|
55
|
+
* Add support for Turbo-streaming ViewComponents.
|
|
56
|
+
|
|
57
|
+
*Ben Sheldon*, *Joel Hawksley*
|
|
58
|
+
|
|
59
|
+
* Reduce allocations and avoid redundant compiler work when rendering components and collections.
|
|
60
|
+
|
|
61
|
+
*Joel Hawksley*
|
|
62
|
+
|
|
63
|
+
* Stabilize rendering allocation tests with explicit warmups and exact expectations by Rails and Ruby.
|
|
64
|
+
|
|
65
|
+
*Joel Hawksley*
|
|
66
|
+
|
|
67
|
+
* Replace the custom memory allocation test helper with `minitest-memory`, preserving Ruby-version-specific allocation thresholds while improving failure diagnostics.
|
|
68
|
+
|
|
69
|
+
*Joel Hawksley*
|
|
70
|
+
|
|
71
|
+
* Add zizmor security analysis for GitHub Actions workflows to CI.
|
|
72
|
+
|
|
73
|
+
*Joel Hawksley*
|
|
74
|
+
|
|
75
|
+
* Remove the `$PROGRAM_NAME` version-printing line from `version.rb` so the file no longer reads a global variable (unshareable across Ractors). The version is still available via `ViewComponent::VERSION::STRING`.
|
|
76
|
+
|
|
77
|
+
*Joel Hawksley*
|
|
78
|
+
|
|
79
|
+
* Fix intermittent template compilation failures where line-number offsets and annotation stripping were decided when a template object was created instead of when it was compiled, so later changes to coverage or annotation settings produced off-by-one backtraces or blank output.
|
|
80
|
+
|
|
81
|
+
*Joel Hawksley*
|
|
82
|
+
|
|
83
|
+
* Freeze `ViewComponent::VERSION::STRING` so the version constant is immutable and Ractor-shareable.
|
|
84
|
+
|
|
85
|
+
*Joel Hawksley*
|
|
86
|
+
|
|
87
|
+
* Add [audition](https://github.com/yaroslav/audition) Ractor-readiness checks to CI. Applied safe `.freeze` auto-fixes to string constants in `ViewComponent::Errors` and baselined existing findings so the gate fails only on new Ractor-isolation violations.
|
|
88
|
+
|
|
89
|
+
*Joel Hawksley*
|
|
90
|
+
|
|
91
|
+
* Update link to GOV.UK Components library in resources list to govuk-components.x-govuk.org
|
|
92
|
+
|
|
93
|
+
*Peter Yates*
|
|
94
|
+
|
|
95
|
+
* Fix `NoMethodError: undefined method 'template_handler_extensions'` when gathering sidecar templates on Rails main. Action View removed the `ActionView::Template.template_handler_extensions` method in newer versions; the compiler now reads the registered extensions through `ActionView::Template::Handlers.extensions`, which is the supported read-path API across all supported Rails versions (7.1+).
|
|
96
|
+
|
|
97
|
+
*Luiz Kowalski*
|
|
98
|
+
|
|
13
99
|
## 4.12.0
|
|
14
100
|
|
|
15
101
|
* Fix stale render context on reused component instances. A `ViewComponent::Base` instance memoized its controller, helpers, request, view context, lookup context, view flow, and requested format details on first render via `||=`. Rendering the same instance a second time (intentionally or via aliasing) reused that stale context, which could leak data across requests, sessions, or users. `#render_in` now resets these ivars on every call so each render derives its context from the current view.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_job/serializers"
|
|
4
|
+
|
|
5
|
+
module ViewComponent
|
|
6
|
+
class ActiveJobSerializer < ActiveJob::Serializers::ObjectSerializer
|
|
7
|
+
def klass
|
|
8
|
+
ViewComponent::Serializable::Proxy
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def serialize?(argument)
|
|
12
|
+
argument.is_a?(ViewComponent::Serializable::Proxy)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def serialize(proxy)
|
|
16
|
+
super(proxy.serialize)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def deserialize(hash)
|
|
20
|
+
ViewComponent::Serializable::Proxy.deserialize(hash)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
data/lib/view_component/base.rb
CHANGED
|
@@ -103,9 +103,10 @@ module ViewComponent
|
|
|
103
103
|
# Returns HTML that has been escaped by the respective template handler.
|
|
104
104
|
#
|
|
105
105
|
# @return [String]
|
|
106
|
-
def render_in(view_context,
|
|
107
|
-
self.class.__vc_compile(raise_errors: true)
|
|
106
|
+
def render_in(view_context, **, &block)
|
|
107
|
+
self.class.__vc_compile(raise_errors: true) unless self.class.__vc_compiled?
|
|
108
108
|
|
|
109
|
+
__vc_check_reused_instance!
|
|
109
110
|
__vc_reset_render_state!
|
|
110
111
|
|
|
111
112
|
@view_context = view_context
|
|
@@ -180,6 +181,7 @@ module ViewComponent
|
|
|
180
181
|
ensure
|
|
181
182
|
view_context.instance_variable_set(:@virtual_path, @old_virtual_path)
|
|
182
183
|
@current_template = old_current_template
|
|
184
|
+
@__vc_rendered = true
|
|
183
185
|
end
|
|
184
186
|
|
|
185
187
|
# Subclass components that call `super` inside their template code will cause a
|
|
@@ -468,17 +470,32 @@ module ViewComponent
|
|
|
468
470
|
# state from a previous render. Slot state (`@__vc_set_slots`,
|
|
469
471
|
# `@__vc_content_set_by_with_content`) is intentionally preserved because it
|
|
470
472
|
# is populated by callers _before_ `render_in` runs (e.g. via `with_*`
|
|
471
|
-
# slot setters or `with_content`)
|
|
473
|
+
# slot setters or `with_content`); reuse of an instance that has slot or
|
|
474
|
+
# `with_content` state is guarded against separately by
|
|
475
|
+
# `__vc_check_reused_instance!`.
|
|
476
|
+
RENDER_STATE_IVARS = %i[
|
|
477
|
+
@__vc_controller
|
|
478
|
+
@__vc_helpers
|
|
479
|
+
@__vc_request
|
|
480
|
+
].freeze
|
|
481
|
+
|
|
472
482
|
def __vc_reset_render_state!
|
|
473
|
-
|
|
474
|
-
@__vc_controller
|
|
475
|
-
@__vc_helpers
|
|
476
|
-
@__vc_request
|
|
477
|
-
].each do |ivar|
|
|
483
|
+
RENDER_STATE_IVARS.each do |ivar|
|
|
478
484
|
remove_instance_variable(ivar) if instance_variable_defined?(ivar)
|
|
479
485
|
end
|
|
480
486
|
end
|
|
481
487
|
|
|
488
|
+
# Raises when a component instance is rendered more than once.
|
|
489
|
+
# Reusing a component instance across renders can leak request-scoped state
|
|
490
|
+
# (controller, helpers, request, view_flow, slot content, `with_content`)
|
|
491
|
+
# from an earlier render into a later one. See
|
|
492
|
+
# `ViewComponent::ReusedInstanceError` and GHSA-8qw7-6phv-7q6p.
|
|
493
|
+
def __vc_check_reused_instance!
|
|
494
|
+
return unless defined?(@__vc_rendered) && @__vc_rendered
|
|
495
|
+
|
|
496
|
+
raise ReusedInstanceError.new(self.class.name)
|
|
497
|
+
end
|
|
498
|
+
|
|
482
499
|
# Configuration for generators.
|
|
483
500
|
#
|
|
484
501
|
# All options under this namespace default to `false` unless otherwise
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "action_view/dependency_tracker"
|
|
4
|
+
|
|
5
|
+
module ViewComponent
|
|
6
|
+
module CacheDigest
|
|
7
|
+
# Teaches `ActionView::DependencyTracker` to see components.
|
|
8
|
+
#
|
|
9
|
+
# Prepended to the tracker's singleton class rather than to a specific
|
|
10
|
+
# tracker implementation (`ERBTracker`, `RubyTracker`, or the trackers
|
|
11
|
+
# registered by the Haml and Slim gems). `find_dependencies` is the single
|
|
12
|
+
# seam every tracker flows through, so hooking it here works regardless of
|
|
13
|
+
# which handler a template uses and doesn't depend on tracker internals.
|
|
14
|
+
#
|
|
15
|
+
# @private
|
|
16
|
+
module DependencyTracking
|
|
17
|
+
def find_dependencies(name, template, view_paths = nil)
|
|
18
|
+
dependencies = super
|
|
19
|
+
source = template.source
|
|
20
|
+
|
|
21
|
+
# `# Template Dependency: SomeComponent` names a class, which Rails
|
|
22
|
+
# would resolve as a template path and never find. Swap it for the path
|
|
23
|
+
# the component is digested under, so the declaration resolves instead
|
|
24
|
+
# of becoming a missing node.
|
|
25
|
+
CacheDigest.explicit_component_dependencies(source).each do |declared, virtual_path|
|
|
26
|
+
dependencies = dependencies - [declared] + [virtual_path]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
dependencies + CacheDigest.dependencies_in(template)
|
|
30
|
+
rescue
|
|
31
|
+
# A broken digest is preferable to a broken render. Falling back to the
|
|
32
|
+
# dependencies Rails found on its own means the component simply isn't
|
|
33
|
+
# tracked, which is the pre-existing behavior.
|
|
34
|
+
super
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @private
|
|
38
|
+
def self.install!
|
|
39
|
+
tracker = ActionView::DependencyTracker.singleton_class
|
|
40
|
+
return if tracker.include?(self)
|
|
41
|
+
|
|
42
|
+
tracker.prepend(self)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ViewComponent
|
|
4
|
+
module CacheDigest
|
|
5
|
+
# Synthesizes the templates Rails' `ActionView::Digestor` digests components from.
|
|
6
|
+
#
|
|
7
|
+
# Once `DependencyTracking` reports `view_component/cache_digest/foo_component`
|
|
8
|
+
# as a dependency, the Digestor tries to find a template at that path. No such
|
|
9
|
+
# file exists: a component's rendered output depends on its template *and* its
|
|
10
|
+
# Ruby class, its sidecar files, and its superclasses.
|
|
11
|
+
#
|
|
12
|
+
# This resolver answers with a synthetic template whose source encodes all of
|
|
13
|
+
# those inputs. The template is never compiled or rendered; the Digestor only
|
|
14
|
+
# reads `#source` to hash it and to scan it for further dependencies.
|
|
15
|
+
#
|
|
16
|
+
# @private
|
|
17
|
+
class Resolver < ActionView::Resolver
|
|
18
|
+
# Extensions whose contents are hashed into the synthetic source.
|
|
19
|
+
SIDECAR_EXTENSIONS = %w[yml yaml].freeze
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
def instance
|
|
23
|
+
INSTANCE
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def find_templates(name, prefix, partial, details, locals = [])
|
|
28
|
+
virtual_path = [prefix.presence, name].compact.join("/")
|
|
29
|
+
component = CacheDigest.component_for(virtual_path)
|
|
30
|
+
return [] unless component
|
|
31
|
+
|
|
32
|
+
[build_template(component, virtual_path, details)]
|
|
33
|
+
rescue
|
|
34
|
+
# Never let digest resolution break rendering. Returning no template
|
|
35
|
+
# makes the Digestor treat this as a missing node, which degrades to
|
|
36
|
+
# the behavior components have without this feature.
|
|
37
|
+
[]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def to_s
|
|
41
|
+
"ViewComponent::CacheDigest::Resolver"
|
|
42
|
+
end
|
|
43
|
+
alias_method :to_path, :to_s
|
|
44
|
+
|
|
45
|
+
def eql?(other)
|
|
46
|
+
self.class.equal?(other.class)
|
|
47
|
+
end
|
|
48
|
+
alias_method :==, :eql?
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def build_template(component, virtual_path, details)
|
|
53
|
+
ActionView::Template.new(
|
|
54
|
+
source_for(component),
|
|
55
|
+
"view_component cache digest for #{component.name}",
|
|
56
|
+
ActionView::Template.handler_for_extension(:erb),
|
|
57
|
+
locals: [],
|
|
58
|
+
format: Array(details[:formats]).first || :html,
|
|
59
|
+
virtual_path: virtual_path
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The synthetic source. Every section exists to change this string when
|
|
64
|
+
# something the component renders from changes.
|
|
65
|
+
def source_for(component)
|
|
66
|
+
parts = []
|
|
67
|
+
|
|
68
|
+
# Safety net: this template should never be rendered, only digested.
|
|
69
|
+
parts << "<% raise ViewComponent::CacheDigestTemplateError.new(#{component.name.inspect}) %>"
|
|
70
|
+
|
|
71
|
+
# Content hashes of the Ruby files and sidecar files backing the
|
|
72
|
+
# component and its component superclasses. Hashing rather than
|
|
73
|
+
# inlining keeps the source small and avoids embedding Ruby that a
|
|
74
|
+
# tracker might misread as a render call.
|
|
75
|
+
source_files(component).each do |path|
|
|
76
|
+
parts << "<%# Resolved Dependency: #{path} #{file_digest(path)} %>"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Everything the component renders from Ruby rather than from a
|
|
80
|
+
# template: `# Template Dependency:` declarations, components rendered
|
|
81
|
+
# from `#call` methods, and partials referenced by string path.
|
|
82
|
+
# Re-emitted so the Digestor resolves them as tree nodes.
|
|
83
|
+
ruby_dependencies(component).each do |dependency|
|
|
84
|
+
parts << "<%# Template Dependency: #{dependency} %>"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Template sources verbatim, so trackers can discover the partials and
|
|
88
|
+
# components they render.
|
|
89
|
+
template_sources(component).each do |source|
|
|
90
|
+
parts << source
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
parts.join("\n")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# The component and any component superclasses, nearest first. Including
|
|
97
|
+
# ancestors means editing `ApplicationComponent` invalidates every
|
|
98
|
+
# component that inherits from it.
|
|
99
|
+
def component_ancestors(component)
|
|
100
|
+
component.ancestors.select do |ancestor|
|
|
101
|
+
ancestor.is_a?(Class) &&
|
|
102
|
+
ancestor <= ViewComponent::Base &&
|
|
103
|
+
ancestor != ViewComponent::Base
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def source_files(component)
|
|
108
|
+
component_ancestors(component).flat_map { |ancestor|
|
|
109
|
+
[ancestor.identifier, *ancestor.sidecar_files(SIDECAR_EXTENSIONS)]
|
|
110
|
+
}.compact.uniq.select { |path| ::File.exist?(path) }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def template_files(component)
|
|
114
|
+
component_ancestors(component)
|
|
115
|
+
.flat_map { |ancestor| ancestor.sidecar_files(ActionView::Template::Handlers.extensions) }
|
|
116
|
+
.uniq
|
|
117
|
+
.select { |path| ::File.exist?(path) }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Sidecar template files plus inline templates, which live in the Ruby
|
|
121
|
+
# file and so are invisible to Action View's trackers.
|
|
122
|
+
def template_sources(component)
|
|
123
|
+
sources = template_files(component).map { |path| ::File.read(path) }
|
|
124
|
+
|
|
125
|
+
component_ancestors(component).each do |ancestor|
|
|
126
|
+
inline_template = ancestor.__vc_inline_template
|
|
127
|
+
sources << inline_template.source if inline_template
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
sources.uniq
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def explicit_dependencies(component)
|
|
134
|
+
ruby_sources(component).flat_map { |source|
|
|
135
|
+
declared = source.scan(CacheDigest::EXPLICIT_DEPENDENCY).flatten
|
|
136
|
+
|
|
137
|
+
# Component class names are translated to the path they're digested
|
|
138
|
+
# under; anything else is a template path already.
|
|
139
|
+
CacheDigest.explicit_component_dependencies(source).each do |name, virtual_path|
|
|
140
|
+
declared = declared - [name] + [virtual_path]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
declared
|
|
144
|
+
}.uniq
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Everything a component renders from Ruby code rather than from a
|
|
148
|
+
# template. Action View's trackers only read templates, so a `#call`
|
|
149
|
+
# method that renders another component or a partial would otherwise go
|
|
150
|
+
# unnoticed.
|
|
151
|
+
def ruby_dependencies(component)
|
|
152
|
+
virtual_path = CacheDigest.virtual_path_for(component)
|
|
153
|
+
|
|
154
|
+
explicit_dependencies(component) |
|
|
155
|
+
ruby_sources(component).flat_map { |source|
|
|
156
|
+
CacheDigest.component_paths_in(source) |
|
|
157
|
+
CacheDigest.partial_paths_in(source, virtual_path)
|
|
158
|
+
}.uniq
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def ruby_sources(component)
|
|
162
|
+
component_ancestors(component).filter_map { |ancestor|
|
|
163
|
+
path = ancestor.identifier
|
|
164
|
+
::File.read(path) if path && ::File.exist?(path)
|
|
165
|
+
}
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def file_digest(path)
|
|
169
|
+
ActiveSupport::Digest.hexdigest(::File.read(path))
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Built once at load time rather than memoized, so no class-level state
|
|
173
|
+
# is written after boot. The resolver is stateless: it reads from disk on
|
|
174
|
+
# every call so it can't go stale when a component changes.
|
|
175
|
+
INSTANCE = new
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/dependencies/autoload"
|
|
4
|
+
require "action_view/digestor"
|
|
5
|
+
require "action_view/render_parser"
|
|
6
|
+
|
|
7
|
+
module ViewComponent
|
|
8
|
+
# Integrates ViewComponents into Rails' template digest tree.
|
|
9
|
+
#
|
|
10
|
+
# Rails computes a digest for every template from its source and the templates
|
|
11
|
+
# it renders. That digest is mixed into the key of every `<% cache %>` block in
|
|
12
|
+
# the template, so editing a partial busts the caches of everything that
|
|
13
|
+
# renders it.
|
|
14
|
+
#
|
|
15
|
+
# Components are invisible to that mechanism for two reasons:
|
|
16
|
+
#
|
|
17
|
+
# 1. **Discovery** — `ActionView::DependencyTracker` doesn't recognize
|
|
18
|
+
# `render SomeComponent.new(...)` as a dependency.
|
|
19
|
+
# 2. **Resolution** — component templates live outside the view paths, and a
|
|
20
|
+
# component's rendered output depends on its Ruby class and sidecar files,
|
|
21
|
+
# not just its template.
|
|
22
|
+
#
|
|
23
|
+
# This module fixes both, reusing Rails' own `ActionView::Digestor` rather than
|
|
24
|
+
# reimplementing static analysis. Components opt in individually by including
|
|
25
|
+
# `ViewComponent::ExperimentallyCacheable`; until at least one component does,
|
|
26
|
+
# every hook here short-circuits.
|
|
27
|
+
#
|
|
28
|
+
# @private
|
|
29
|
+
module CacheDigest
|
|
30
|
+
extend ActiveSupport::Autoload
|
|
31
|
+
|
|
32
|
+
autoload :DependencyTracking
|
|
33
|
+
autoload :Resolver
|
|
34
|
+
|
|
35
|
+
# Prefix for the synthetic virtual paths components are digested under.
|
|
36
|
+
#
|
|
37
|
+
# Namespaced under `view_component/` so it can't collide with an
|
|
38
|
+
# application partial.
|
|
39
|
+
VIRTUAL_PATH_PREFIX = "view_component/cache_digest"
|
|
40
|
+
|
|
41
|
+
# Matches `render FooComponent`, `render(Foo::BarComponent.new(...))`,
|
|
42
|
+
# `render FooComponent.with_collection(...)`, etc.
|
|
43
|
+
#
|
|
44
|
+
# Deliberately a plain source scan rather than a tracker-specific hook: it
|
|
45
|
+
# behaves identically for the ERB tracker, the Prism-based Ruby tracker, and
|
|
46
|
+
# third-party Haml/Slim trackers.
|
|
47
|
+
RENDER_CALL = /
|
|
48
|
+
\brender(?:_to_string)?\b # render or render_to_string
|
|
49
|
+
\s*\(?\s* # optional opening paren
|
|
50
|
+
(?<const>
|
|
51
|
+
(?:::)?[A-Z]\w* # a constant
|
|
52
|
+
(?:::[A-Z]\w*)* # optionally namespaced
|
|
53
|
+
)
|
|
54
|
+
/x
|
|
55
|
+
|
|
56
|
+
# Rails' escape hatch for dependencies static analysis can't see.
|
|
57
|
+
EXPLICIT_DEPENDENCY = /#\s*Template Dependency:\s*(\S+)/
|
|
58
|
+
class << self
|
|
59
|
+
# Virtual paths of components that have opted into caching, mapped to
|
|
60
|
+
# their class names.
|
|
61
|
+
#
|
|
62
|
+
# Class *names* rather than class objects so the registry survives
|
|
63
|
+
# autoloader reloads without pinning stale constants in memory.
|
|
64
|
+
#
|
|
65
|
+
# @return [Hash{String => String}]
|
|
66
|
+
def registry
|
|
67
|
+
@registry ||= {}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @return [Boolean] whether any component has opted in.
|
|
71
|
+
def enabled?
|
|
72
|
+
!registry.empty?
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @private
|
|
76
|
+
def register(component)
|
|
77
|
+
return unless component.virtual_path && component.name
|
|
78
|
+
|
|
79
|
+
registry[component.virtual_path] = component.name
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The synthetic virtual path a component is digested under.
|
|
83
|
+
#
|
|
84
|
+
# @return [String, nil]
|
|
85
|
+
def virtual_path_for(component)
|
|
86
|
+
return unless component.respond_to?(:virtual_path) && component.virtual_path
|
|
87
|
+
|
|
88
|
+
"#{VIRTUAL_PATH_PREFIX}/#{component.virtual_path}"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Resolve a synthetic virtual path back to the component that owns it.
|
|
92
|
+
#
|
|
93
|
+
# @return [Class, nil]
|
|
94
|
+
def component_for(virtual_path)
|
|
95
|
+
return unless virtual_path.start_with?("#{VIRTUAL_PATH_PREFIX}/")
|
|
96
|
+
|
|
97
|
+
name = registry[virtual_path.delete_prefix("#{VIRTUAL_PATH_PREFIX}/")]
|
|
98
|
+
return unless name
|
|
99
|
+
|
|
100
|
+
constantize_component(name)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Scan a template's source for renders of cacheable components.
|
|
104
|
+
#
|
|
105
|
+
# Called for every template Rails digests, so it exits early when the
|
|
106
|
+
# feature is unused.
|
|
107
|
+
#
|
|
108
|
+
# @return [Array<String>] synthetic virtual paths
|
|
109
|
+
def dependencies_in(template)
|
|
110
|
+
return [] unless enabled?
|
|
111
|
+
|
|
112
|
+
component_paths_in(template.source)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Scan arbitrary source (a template or a component's Ruby file) for
|
|
116
|
+
# renders of cacheable components.
|
|
117
|
+
#
|
|
118
|
+
# @return [Array<String>] synthetic virtual paths
|
|
119
|
+
def component_paths_in(source)
|
|
120
|
+
return [] unless source.is_a?(String) && source.include?("render")
|
|
121
|
+
|
|
122
|
+
source.scan(RENDER_CALL).flatten.uniq.filter_map do |constant_name|
|
|
123
|
+
component = constantize_component(constant_name)
|
|
124
|
+
virtual_path_for(component) if component
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Scan a component's Ruby source for partials referenced by string path,
|
|
129
|
+
# such as `render "posts/byline"` inside a `#call` method.
|
|
130
|
+
#
|
|
131
|
+
# Uses Rails' own render parser — the same one `RubyTracker` runs over
|
|
132
|
+
# compiled templates — rather than a second implementation of the same
|
|
133
|
+
# analysis. Its results are then narrowed to paths that appear verbatim in
|
|
134
|
+
# the source, which keeps string literals and discards the speculative
|
|
135
|
+
# `things/_thing` entries the parser infers from dynamic renders like
|
|
136
|
+
# `render @thing` or `render FooComponent.new`. Those would resolve to
|
|
137
|
+
# nothing and only add log noise; components rendered from Ruby are
|
|
138
|
+
# already found precisely by `component_paths_in`.
|
|
139
|
+
#
|
|
140
|
+
# @param source [String] Ruby source
|
|
141
|
+
# @param name [String] virtual path the source is being digested under
|
|
142
|
+
# @return [Array<String>] partial virtual paths
|
|
143
|
+
def partial_paths_in(source, name)
|
|
144
|
+
return [] unless source.is_a?(String) && source.include?("render")
|
|
145
|
+
|
|
146
|
+
RENDER_PARSER.new(name, source).render_calls.uniq.select do |path|
|
|
147
|
+
source.include?(path) || source.include?(path.sub(%r{(\A|/)_}, '\1'))
|
|
148
|
+
end
|
|
149
|
+
rescue
|
|
150
|
+
# Never let digest computation break rendering.
|
|
151
|
+
[]
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Action View has shipped its render parser as a class (Rails 7.1, and
|
|
155
|
+
# again on main) and as a module holding a `Default` implementation
|
|
156
|
+
# chosen from Prism or Ripper (Rails 7.2 through 8.1).
|
|
157
|
+
#
|
|
158
|
+
# @param parser [Class, Module] `ActionView::RenderParser`
|
|
159
|
+
# @return [Class]
|
|
160
|
+
def resolve_render_parser(parser)
|
|
161
|
+
parser.is_a?(Class) ? parser : parser::Default
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Resolve `# Template Dependency: SomeComponent` declarations.
|
|
165
|
+
#
|
|
166
|
+
# Rails' escape hatch takes a template path, but the path a component is
|
|
167
|
+
# digested under is an internal detail. Naming the class instead keeps
|
|
168
|
+
# that detail out of application code, so `SomeComponent` is translated
|
|
169
|
+
# to the path the Digestor can resolve.
|
|
170
|
+
#
|
|
171
|
+
# @return [Array<Array(String, String)>] pairs of declared name and
|
|
172
|
+
# synthetic virtual path
|
|
173
|
+
def explicit_component_dependencies(source)
|
|
174
|
+
return [] unless source.is_a?(String) && source.include?("Template Dependency:")
|
|
175
|
+
|
|
176
|
+
source.scan(EXPLICIT_DEPENDENCY).flatten.uniq.filter_map do |declared|
|
|
177
|
+
next unless /\A(?:::)?[A-Z]/.match?(declared)
|
|
178
|
+
|
|
179
|
+
component = constantize_component(declared)
|
|
180
|
+
[declared, virtual_path_for(component)] if component
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Compute the digest of a component using Rails' digest tree.
|
|
185
|
+
#
|
|
186
|
+
# @param component [Class] a component that includes `ExperimentallyCacheable`
|
|
187
|
+
# @param finder [ActionView::LookupContext]
|
|
188
|
+
# @param format [Symbol]
|
|
189
|
+
# @return [String]
|
|
190
|
+
def digest(component, finder: default_finder, format: :html)
|
|
191
|
+
virtual_path = virtual_path_for(component)
|
|
192
|
+
return "" unless virtual_path
|
|
193
|
+
|
|
194
|
+
ActionView::Digestor.digest(name: virtual_path, format: format, finder: finder)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# A lookup context for digesting components outside a request, where no
|
|
198
|
+
# view context (and therefore no finder) exists.
|
|
199
|
+
#
|
|
200
|
+
# @return [ActionView::LookupContext]
|
|
201
|
+
def default_finder
|
|
202
|
+
# Not memoized across reloads: view paths change when the app reloads.
|
|
203
|
+
ActionView::LookupContext.new(ActionController::Base.view_paths)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Wire the tracker and resolver into Action View.
|
|
207
|
+
#
|
|
208
|
+
# Called each time a component includes `ExperimentallyCacheable`. Both
|
|
209
|
+
# steps below are individually idempotent, so no "already installed" flag
|
|
210
|
+
# is kept. Both hooks short-circuit while the registry is empty, so
|
|
211
|
+
# applications that never opt in are unaffected.
|
|
212
|
+
#
|
|
213
|
+
# @private
|
|
214
|
+
def install!
|
|
215
|
+
DependencyTracking.install!
|
|
216
|
+
|
|
217
|
+
ActiveSupport.on_load(:action_controller_base) do
|
|
218
|
+
resolver = ViewComponent::CacheDigest::Resolver.instance
|
|
219
|
+
|
|
220
|
+
append_view_path(resolver) unless view_paths.include?(resolver)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
private
|
|
225
|
+
|
|
226
|
+
# Resolve a constant name to a component that opted into caching.
|
|
227
|
+
#
|
|
228
|
+
# Returns nil for anything else, including constants that don't exist.
|
|
229
|
+
# Autoloading here is safe: the template is about to render this constant
|
|
230
|
+
# anyway.
|
|
231
|
+
def constantize_component(constant_name)
|
|
232
|
+
component = constant_name.safe_constantize
|
|
233
|
+
return unless component.is_a?(Class)
|
|
234
|
+
return unless component.respond_to?(:__vc_cacheable?) && component.__vc_cacheable?
|
|
235
|
+
|
|
236
|
+
component
|
|
237
|
+
rescue
|
|
238
|
+
# Never let digest computation break rendering.
|
|
239
|
+
nil
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Resolved once at load time rather than memoized, so no class-level state
|
|
244
|
+
# is written after boot.
|
|
245
|
+
RENDER_PARSER = resolve_render_parser(ActionView::RenderParser)
|
|
246
|
+
end
|
|
247
|
+
end
|