view_component 4.14.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 83cf2f7914c64ec4ca156f19d975d79d311619024fb550fde59c0e6f807ca02c
4
- data.tar.gz: 918cc12667440e6a50793addc531431050e06bda525f348c8522487ea0208439
3
+ metadata.gz: 9da4df780e2f53937b1593ef29fbbf9ce09662b48de7bc56ceae812c43880d2f
4
+ data.tar.gz: 94c6f23684dc139431a5c912a9e7fab5f70e2e794ba6bb72f5b66211c97f0e8c
5
5
  SHA512:
6
- metadata.gz: fa5696a0ca7c945fa3ed682fb8f679fd75c462ddb0651b8926e64448392507a5616dcee97218e0a74c3eb4f4d384a89ff2f26fd08d89c9ff86ca961fd6cd410d
7
- data.tar.gz: f1ced2760c93c93d46e2ed508ec234abbe498100ccb80d1ef83f6d7c1b17e79b7ecbd44f26745e36a7fef74426da49867bbdef038885b954b04982d788e4c322
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,32 @@ 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
+
13
39
  ## 4.14.0
14
40
 
15
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.
@@ -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
@@ -232,4 +232,39 @@ module ViewComponent
232
232
  super(MESSAGE.gsub("SETTER_METHOD_NAME", setter_method_name.to_s).gsub("SETTER_NAME", setter_name.to_s))
233
233
  end
234
234
  end
235
+
236
+ class CacheDigestTemplateError < StandardError
237
+ MESSAGE =
238
+ "The synthetic cache digest template for COMPONENT was rendered.\n\n" \
239
+ "This template exists only so Rails can compute a cache digest for the " \
240
+ "component and is never meant to be rendered. Render the component " \
241
+ "itself instead.".freeze
242
+
243
+ def initialize(component_name)
244
+ super(MESSAGE.gsub("COMPONENT", component_name.to_s))
245
+ end
246
+ end
247
+
248
+ class UndefinedCacheKeyMethodError < StandardError
249
+ MESSAGE =
250
+ "`cache_on` declared `METHOD` on COMPONENT, but no such method is defined.\n\n" \
251
+ "To fix this issue, define `METHOD` or remove it from `cache_on`.".freeze
252
+
253
+ def initialize(component_name, method_name)
254
+ super(MESSAGE.gsub("COMPONENT", component_name.to_s).gsub("METHOD", method_name.to_s))
255
+ end
256
+ end
257
+
258
+ class ContentPassedToCachedComponentError < StandardError
259
+ MESSAGE =
260
+ "COMPONENT declares `cache_on`, so it caches its own output, but its caller passed it content.\n\n" \
261
+ "Content and slots set by the caller aren't part of the cache key, so caching them would risk " \
262
+ "serving one caller's content to another.\n\n" \
263
+ "To fix this issue, either remove `cache_on` from COMPONENT, or move the content into the " \
264
+ "component and derive it from the values declared in `cache_on`.".freeze
265
+
266
+ def initialize(component_name)
267
+ super(MESSAGE.gsub("COMPONENT", component_name.to_s))
268
+ end
269
+ end
235
270
  end
@@ -0,0 +1,285 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "view_component/cache_digest"
4
+
5
+ module ViewComponent
6
+ # Experimental caching support for ViewComponents.
7
+ #
8
+ # **This API is experimental.** It may change or be removed in a non-major
9
+ # release. Please share feedback in
10
+ # https://github.com/ViewComponent/view_component/issues/234.
11
+ #
12
+ # Including this module does two things:
13
+ #
14
+ # 1. Registers the component with Rails' template digest tree, so a
15
+ # `<% cache %>` block wrapping the component in a view is invalidated when
16
+ # the component's template, Ruby class, sidecar files, or child components
17
+ # change.
18
+ # 2. Enables the `cache_on` macro, which caches the component's own rendered
19
+ # output.
20
+ #
21
+ # ```ruby
22
+ # class MessageComponent < ViewComponent::Base
23
+ # include ViewComponent::ExperimentallyCacheable
24
+ #
25
+ # cache_on :message
26
+ #
27
+ # def initialize(message:)
28
+ # @message = message
29
+ # end
30
+ # end
31
+ # ```
32
+ module ExperimentallyCacheable
33
+ extend ActiveSupport::Concern
34
+
35
+ # Stands in for `nil` in the cache key. Without it, `expand_cache_key`
36
+ # renders `nil` and `""` identically, so two components differing only in
37
+ # that respect would share an entry.
38
+ NIL_CACHE_VALUE = :__vc_nil
39
+
40
+ included do
41
+ ViewComponent::CacheDigest.install!
42
+ ViewComponent::CacheDigest.register(self)
43
+ end
44
+
45
+ class_methods do
46
+ # Declare the values that identify a rendering of this component.
47
+ #
48
+ # Each argument names a method on the component whose value is mixed into
49
+ # the cache key, alongside a digest of the component's source. Private
50
+ # methods are allowed.
51
+ #
52
+ # ```ruby
53
+ # cache_on :message, :current_user
54
+ # ```
55
+ #
56
+ # Calling `cache_on` opts the component into caching its own output.
57
+ # Without it, including this module only registers the component with
58
+ # Rails' digest tree.
59
+ #
60
+ # Pass `if:` or `unless:` to cache only some renders. Both accept a method
61
+ # name or a proc evaluated on the component:
62
+ #
63
+ # ```ruby
64
+ # cache_on :message, if: :persisted?
65
+ # cache_on :message, unless: -> { message.draft? }
66
+ # ```
67
+ #
68
+ # These methods are called before the component renders, so they can only
69
+ # depend on the component's own state, not on `helpers` or the view
70
+ # context.
71
+ #
72
+ # @param methods [Array<Symbol>] Methods whose values form the cache key.
73
+ # @param options [Hash] `:if` and/or `:unless` conditions.
74
+ # @return [void]
75
+ def cache_on(*methods, **options, &block)
76
+ if block
77
+ raise ArgumentError,
78
+ "`cache_on` doesn't accept a block. Name the methods whose values form the cache key, " \
79
+ "such as `cache_on :message`."
80
+ end
81
+
82
+ methods.each do |method|
83
+ next if method.is_a?(Symbol) || method.is_a?(String)
84
+
85
+ raise ArgumentError,
86
+ "`cache_on` expects method names as symbols, got #{method.class}. " \
87
+ "Define a method for the value and name it, such as `cache_on :message`."
88
+ end
89
+
90
+ unknown = options.keys - %i[if unless]
91
+ if unknown.any?
92
+ raise ArgumentError,
93
+ "`cache_on` received unknown #{"option".pluralize(unknown.count)} " \
94
+ "#{unknown.map(&:inspect).to_sentence}. Supported options are `:if` and `:unless`."
95
+ end
96
+
97
+ @__vc_cache_on = __vc_cache_on | methods.map(&:to_sym)
98
+ @__vc_cache_if = options[:if] if options.key?(:if)
99
+ @__vc_cache_unless = options[:unless] if options.key?(:unless)
100
+ end
101
+
102
+ # @private
103
+ def __vc_cache_on
104
+ @__vc_cache_on ||= superclass.respond_to?(:__vc_cache_on) ? superclass.__vc_cache_on : []
105
+ end
106
+
107
+ # @private
108
+ def __vc_cache_if
109
+ return @__vc_cache_if if defined?(@__vc_cache_if)
110
+
111
+ superclass.__vc_cache_if if superclass.respond_to?(:__vc_cache_if)
112
+ end
113
+
114
+ # @private
115
+ def __vc_cache_unless
116
+ return @__vc_cache_unless if defined?(@__vc_cache_unless)
117
+
118
+ superclass.__vc_cache_unless if superclass.respond_to?(:__vc_cache_unless)
119
+ end
120
+
121
+ # @private
122
+ def __vc_cacheable?
123
+ true
124
+ end
125
+
126
+ # Whether this component caches its own rendered output.
127
+ #
128
+ # @return [Boolean]
129
+ def __vc_caches_output?
130
+ __vc_cache_on.any?
131
+ end
132
+
133
+ # A digest of everything this component renders from: its template, its
134
+ # Ruby class, its sidecar files, its superclasses, and the components and
135
+ # partials it renders.
136
+ #
137
+ # Computed with Rails' own `ActionView::Digestor`, so it's the same digest
138
+ # used to invalidate `<% cache %>` blocks.
139
+ #
140
+ # Usable outside a request, where no view context exists:
141
+ #
142
+ # ```ruby
143
+ # MessageComponent.cache_digest
144
+ # ```
145
+ #
146
+ # @param finder [ActionView::LookupContext] Defaults to a lookup context
147
+ # built from `ActionController::Base.view_paths`.
148
+ # @param format [Symbol]
149
+ # @return [String]
150
+ def cache_digest(finder: nil, format: :html)
151
+ ViewComponent::CacheDigest.digest(
152
+ self,
153
+ finder: finder || ViewComponent::CacheDigest.default_finder,
154
+ format: format
155
+ )
156
+ end
157
+
158
+ # @private
159
+ def inherited(child)
160
+ super
161
+ ViewComponent::CacheDigest.register(child)
162
+ end
163
+ end
164
+
165
+ # Renders the component, reading from and writing to the Rails cache when
166
+ # `cache_on` has been declared.
167
+ #
168
+ # @private
169
+ def render_in(view_context, **, &block)
170
+ return super unless self.class.__vc_caches_output?
171
+
172
+ # Content provided by the caller isn't part of the cache key, so caching
173
+ # it would serve one caller's content to another. Raised whether or not
174
+ # caching is currently enabled, so the conflict surfaces in development
175
+ # and test rather than only in production.
176
+ if block || __vc_content_set_by_with_content_defined? || __vc_slots_set_by_caller?
177
+ raise ContentPassedToCachedComponentError.new(self.class.name)
178
+ end
179
+
180
+ return super unless __vc_cache_enabled?(view_context)
181
+
182
+ store = Rails.cache
183
+ key = cache_key(view_context)
184
+
185
+ if (cached = store.read(key))
186
+ # Safe to mark as HTML-safe: the cached string was produced by this same
187
+ # rendering pipeline, which escapes output before it's written.
188
+ return cached.html_safe # rubocop:disable Rails/OutputSafety
189
+ end
190
+
191
+ super.tap do |output|
192
+ store.write(key, output.to_s)
193
+ end
194
+ end
195
+
196
+ # The cache key for this rendering of the component.
197
+ #
198
+ # Combines the component's identity, its source digest, the requested
199
+ # format and variant, the current locale, and the values declared with
200
+ # `cache_on`. Override for full control.
201
+ #
202
+ # @param view_context [ActionView::Base]
203
+ # @return [String]
204
+ def cache_key(view_context = nil)
205
+ lookup_context = view_context&.lookup_context
206
+ format = __vc_cache_format(lookup_context)
207
+
208
+ parts = [
209
+ "view_component",
210
+ self.class.virtual_path,
211
+ self.class.cache_digest(finder: lookup_context, format: format),
212
+ # Included in its own right, not just as a digest input: components that
213
+ # render every format from one template have the same digest for each.
214
+ format,
215
+ __vc_cache_variant(lookup_context),
216
+ I18n.locale,
217
+ *__vc_cache_on_values
218
+ ]
219
+
220
+ # Positions are significant, so nils are substituted rather than removed.
221
+ # Compacting the array would let a nil in one position collapse into a nil
222
+ # in another.
223
+ ActiveSupport::Cache.expand_cache_key(
224
+ parts.map { |part| part.nil? ? NIL_CACHE_VALUE : part }
225
+ )
226
+ end
227
+
228
+ private
229
+
230
+ # Slots set by the caller via `with_*`. Checked before rendering, so slots
231
+ # a component fills in for itself with a `default_*` method — which resolve
232
+ # lazily during the render — aren't counted.
233
+ def __vc_slots_set_by_caller?
234
+ defined?(@__vc_set_slots) && @__vc_set_slots.present?
235
+ end
236
+
237
+ def __vc_cache_enabled?(view_context)
238
+ return false unless defined?(Rails) && Rails.respond_to?(:cache) && Rails.cache
239
+ return false unless __vc_cache_conditions_met?
240
+
241
+ controller = view_context.try(:controller)
242
+ controller.respond_to?(:perform_caching) && controller.perform_caching
243
+ end
244
+
245
+ def __vc_cache_conditions_met?
246
+ if (condition = self.class.__vc_cache_if)
247
+ return false unless __vc_evaluate_cache_condition(condition)
248
+ end
249
+
250
+ if (condition = self.class.__vc_cache_unless)
251
+ return false if __vc_evaluate_cache_condition(condition)
252
+ end
253
+
254
+ true
255
+ end
256
+
257
+ def __vc_evaluate_cache_condition(condition)
258
+ return instance_exec(&condition) if condition.respond_to?(:to_proc) && !condition.is_a?(Symbol)
259
+
260
+ unless respond_to?(condition, true)
261
+ raise UndefinedCacheKeyMethodError.new(self.class.name, condition)
262
+ end
263
+
264
+ send(condition)
265
+ end
266
+
267
+ def __vc_cache_on_values
268
+ self.class.__vc_cache_on.map do |method_name|
269
+ unless respond_to?(method_name, true)
270
+ raise UndefinedCacheKeyMethodError.new(self.class.name, method_name)
271
+ end
272
+
273
+ send(method_name)
274
+ end
275
+ end
276
+
277
+ def __vc_cache_format(lookup_context)
278
+ Array(lookup_context&.formats).first || :html
279
+ end
280
+
281
+ def __vc_cache_variant(lookup_context)
282
+ Array(lookup_context&.variants).first
283
+ end
284
+ end
285
+ end
@@ -3,7 +3,7 @@
3
3
  module ViewComponent
4
4
  module VERSION
5
5
  MAJOR = 4
6
- MINOR = 14
6
+ MINOR = 15
7
7
  PATCH = 0
8
8
  PRE = nil
9
9
 
@@ -8,10 +8,12 @@ module ViewComponent
8
8
  extend ActiveSupport::Autoload
9
9
 
10
10
  autoload :Base
11
+ autoload :CacheDigest
11
12
  autoload :Compiler
12
13
  autoload :CompileCache
13
14
  autoload :Config
14
15
  autoload :Deprecation
16
+ autoload :ExperimentallyCacheable
15
17
  autoload :InlineTemplate
16
18
  autoload :Instrumentation
17
19
  autoload :Preview
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: view_component
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.14.0
4
+ version: 4.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ViewComponent Team
@@ -91,6 +91,9 @@ files:
91
91
  - lib/view_component.rb
92
92
  - lib/view_component/active_job_serializer.rb
93
93
  - lib/view_component/base.rb
94
+ - lib/view_component/cache_digest.rb
95
+ - lib/view_component/cache_digest/dependency_tracking.rb
96
+ - lib/view_component/cache_digest/resolver.rb
94
97
  - lib/view_component/collection.rb
95
98
  - lib/view_component/compile_cache.rb
96
99
  - lib/view_component/compiler.rb
@@ -99,6 +102,7 @@ files:
99
102
  - lib/view_component/deprecation.rb
100
103
  - lib/view_component/engine.rb
101
104
  - lib/view_component/errors.rb
105
+ - lib/view_component/experimentally_cacheable.rb
102
106
  - lib/view_component/inline_template.rb
103
107
  - lib/view_component/instrumentation.rb
104
108
  - lib/view_component/preview.rb