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
|
@@ -13,7 +13,7 @@ module ViewComponent
|
|
|
13
13
|
delegate :size, to: :@collection
|
|
14
14
|
|
|
15
15
|
def render_in(view_context, **_, &block)
|
|
16
|
-
rendered = components.map do |component|
|
|
16
|
+
rendered = components.map! do |component|
|
|
17
17
|
component.render_in(view_context, &block)
|
|
18
18
|
end
|
|
19
19
|
safe_join(rendered, rendered_spacer(view_context))
|
|
@@ -63,19 +63,25 @@ module ViewComponent
|
|
|
63
63
|
end
|
|
64
64
|
|
|
65
65
|
def component_options(item, iterator)
|
|
66
|
-
item_options =
|
|
66
|
+
item_options = @options.dup
|
|
67
|
+
item_options[component.__vc_collection_parameter] = item
|
|
67
68
|
item_options[component.__vc_collection_counter_parameter] = iterator.index if component.__vc_counter_argument_present?
|
|
68
69
|
item_options[component.__vc_collection_iteration_parameter] = iterator.dup if component.__vc_iteration_argument_present?
|
|
69
70
|
|
|
70
|
-
|
|
71
|
+
item_options
|
|
71
72
|
end
|
|
72
73
|
|
|
73
74
|
# Render the spacer through a fresh `dup` so a collection rendered multiple
|
|
74
|
-
# times
|
|
75
|
+
# times does not reuse (and trip the single-render guard on) the spacer
|
|
76
|
+
# instance passed by the caller.
|
|
75
77
|
def rendered_spacer(view_context)
|
|
76
78
|
return "" unless @spacer_component
|
|
77
79
|
|
|
78
|
-
@spacer_component.dup
|
|
80
|
+
spacer = @spacer_component.dup
|
|
81
|
+
if spacer.instance_variable_defined?(:@__vc_rendered)
|
|
82
|
+
spacer.remove_instance_variable(:@__vc_rendered)
|
|
83
|
+
end
|
|
84
|
+
spacer.render_in(view_context)
|
|
79
85
|
end
|
|
80
86
|
end
|
|
81
87
|
end
|
|
@@ -115,11 +115,11 @@ module ViewComponent
|
|
|
115
115
|
.tally
|
|
116
116
|
.select { |_, count| count > 1 }
|
|
117
117
|
.each do |tally|
|
|
118
|
-
|
|
118
|
+
variant, this_format = tally.first
|
|
119
119
|
|
|
120
|
-
|
|
120
|
+
variant_string = " for variant `#{variant}`" if variant.present?
|
|
121
121
|
|
|
122
|
-
|
|
122
|
+
errors << "More than one #{this_format.upcase} template found#{variant_string} for #{@component}. "
|
|
123
123
|
end
|
|
124
124
|
|
|
125
125
|
default_template_types = @templates.each_with_object(Set.new) do |template, memo|
|
|
@@ -177,7 +177,7 @@ module ViewComponent
|
|
|
177
177
|
else
|
|
178
178
|
path_parser = ActionView::Resolver::PathParser.new
|
|
179
179
|
templates = @component.sidecar_files(
|
|
180
|
-
ActionView::Template.
|
|
180
|
+
ActionView::Template::Handlers.extensions
|
|
181
181
|
).map do |path|
|
|
182
182
|
details = path_parser.parse(path).details
|
|
183
183
|
Template::File.new(component: @component, path: path, details: details)
|
|
@@ -190,11 +190,11 @@ module ViewComponent
|
|
|
190
190
|
).flat_map { |ancestor| ancestor.instance_methods(false).grep(/^call(_|$)/) }
|
|
191
191
|
.uniq
|
|
192
192
|
.each do |method_name|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
193
|
+
templates << Template::InlineCall.new(
|
|
194
|
+
component: @component,
|
|
195
|
+
method_name: method_name,
|
|
196
|
+
defined_on_self: component_instance_methods_on_self.include?(method_name)
|
|
197
|
+
)
|
|
198
198
|
end
|
|
199
199
|
|
|
200
200
|
templates
|
|
@@ -77,6 +77,13 @@ module ViewComponent
|
|
|
77
77
|
end
|
|
78
78
|
end
|
|
79
79
|
|
|
80
|
+
initializer "view_component.serializable" do
|
|
81
|
+
ActiveSupport.on_load(:active_job) do
|
|
82
|
+
require "view_component/active_job_serializer"
|
|
83
|
+
ActiveJob::Serializers.add_serializers(ViewComponent::ActiveJobSerializer)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
80
87
|
initializer "view_component.eager_load_actions" do
|
|
81
88
|
ActiveSupport.on_load(:after_initialize) do
|
|
82
89
|
if Rails.application.config.eager_load
|
|
@@ -9,7 +9,7 @@ module ViewComponent
|
|
|
9
9
|
MESSAGE =
|
|
10
10
|
"It looks like a block was provided after calling `with_content` on COMPONENT, " \
|
|
11
11
|
"which means that ViewComponent doesn't know which content to use.\n\n" \
|
|
12
|
-
"To fix this issue, use either `with_content` or a block."
|
|
12
|
+
"To fix this issue, use either `with_content` or a block.".freeze
|
|
13
13
|
|
|
14
14
|
def initialize(klass_name)
|
|
15
15
|
super(MESSAGE.gsub("COMPONENT", klass_name.to_s))
|
|
@@ -25,13 +25,13 @@ module ViewComponent
|
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
class MultipleInlineTemplatesError < BaseError
|
|
28
|
-
MESSAGE = "Inline templates can only be defined once per-component."
|
|
28
|
+
MESSAGE = "Inline templates can only be defined once per-component.".freeze
|
|
29
29
|
end
|
|
30
30
|
|
|
31
31
|
class MissingPreviewTemplateError < StandardError
|
|
32
32
|
MESSAGE =
|
|
33
33
|
"A preview template for example EXAMPLE doesn't exist.\n\n" \
|
|
34
|
-
"To fix this issue, create a template for the example."
|
|
34
|
+
"To fix this issue, create a template for the example.".freeze
|
|
35
35
|
|
|
36
36
|
def initialize(example)
|
|
37
37
|
super(MESSAGE.gsub("EXAMPLE", example))
|
|
@@ -41,7 +41,7 @@ module ViewComponent
|
|
|
41
41
|
class MissingTemplateError < StandardError
|
|
42
42
|
MESSAGE =
|
|
43
43
|
"No templates for COMPONENT match the request DETAIL.\n\n" \
|
|
44
|
-
"To fix this issue, provide a suitable template."
|
|
44
|
+
"To fix this issue, provide a suitable template.".freeze
|
|
45
45
|
|
|
46
46
|
def initialize(component, request_detail)
|
|
47
47
|
detail = {
|
|
@@ -58,7 +58,7 @@ module ViewComponent
|
|
|
58
58
|
MESSAGE =
|
|
59
59
|
"It looks like a block was provided after calling `with_content` on COMPONENT, " \
|
|
60
60
|
"which means that ViewComponent doesn't know which content to use.\n\n" \
|
|
61
|
-
"To fix this issue, use either `with_content` or a block."
|
|
61
|
+
"To fix this issue, use either `with_content` or a block.".freeze
|
|
62
62
|
|
|
63
63
|
def initialize(klass_name)
|
|
64
64
|
super(MESSAGE.gsub("COMPONENT", klass_name.to_s))
|
|
@@ -70,7 +70,7 @@ module ViewComponent
|
|
|
70
70
|
"The initializer for COMPONENT doesn't accept the parameter `PARAMETER`, " \
|
|
71
71
|
"which is required to render it as a collection.\n\n" \
|
|
72
72
|
"To fix this issue, update the initializer to accept `PARAMETER`.\n\n" \
|
|
73
|
-
"See [the collections docs](https://viewcomponent.org/guide/collections.html) for more information on rendering collections."
|
|
73
|
+
"See [the collections docs](https://viewcomponent.org/guide/collections.html) for more information on rendering collections.".freeze
|
|
74
74
|
|
|
75
75
|
def initialize(klass_name, parameter)
|
|
76
76
|
super(MESSAGE.gsub("COMPONENT", klass_name.to_s).gsub("PARAMETER", parameter.to_s))
|
|
@@ -80,7 +80,7 @@ module ViewComponent
|
|
|
80
80
|
class ReservedParameterError < StandardError
|
|
81
81
|
MESSAGE =
|
|
82
82
|
"COMPONENT initializer can't accept the parameter `PARAMETER`, as it will override a " \
|
|
83
|
-
"public ViewComponent method. To fix this issue, rename the parameter."
|
|
83
|
+
"public ViewComponent method. To fix this issue, rename the parameter.".freeze
|
|
84
84
|
|
|
85
85
|
def initialize(klass_name, parameter)
|
|
86
86
|
super(MESSAGE.gsub("COMPONENT", klass_name.to_s).gsub("PARAMETER", parameter.to_s))
|
|
@@ -90,14 +90,14 @@ module ViewComponent
|
|
|
90
90
|
class InvalidCollectionArgumentError < BaseError
|
|
91
91
|
MESSAGE =
|
|
92
92
|
"The value of the first argument passed to `with_collection` isn't a valid collection. " \
|
|
93
|
-
"Make sure it responds to `to_ary`."
|
|
93
|
+
"Make sure it responds to `to_ary`.".freeze
|
|
94
94
|
end
|
|
95
95
|
|
|
96
96
|
class ContentSlotNameError < StandardError
|
|
97
97
|
MESSAGE =
|
|
98
98
|
"COMPONENT declares a slot named content, which is a reserved word in ViewComponent.\n\n" \
|
|
99
99
|
"Content passed to a ViewComponent as a block is captured and assigned to the `content` accessor without having to create an explicit slot.\n\n" \
|
|
100
|
-
"To fix this issue, either use the `content` accessor directly or choose a different slot name."
|
|
100
|
+
"To fix this issue, either use the `content` accessor directly or choose a different slot name.".freeze
|
|
101
101
|
|
|
102
102
|
def initialize(klass_name)
|
|
103
103
|
super(MESSAGE.gsub("COMPONENT", klass_name.to_s))
|
|
@@ -107,7 +107,7 @@ module ViewComponent
|
|
|
107
107
|
class InvalidSlotDefinitionError < BaseError
|
|
108
108
|
MESSAGE =
|
|
109
109
|
"Invalid slot definition. Please pass a class, " \
|
|
110
|
-
"string, or callable (that is proc, lambda, etc)"
|
|
110
|
+
"string, or callable (that is proc, lambda, etc)".freeze
|
|
111
111
|
end
|
|
112
112
|
|
|
113
113
|
class InvalidSlotNameError < StandardError
|
|
@@ -118,7 +118,7 @@ module ViewComponent
|
|
|
118
118
|
"COMPONENT declares a slot named SLOT_NAME, which ends with a question mark.\n\n" \
|
|
119
119
|
"This isn't allowed because the ViewComponent framework already provides predicate " \
|
|
120
120
|
"methods ending in `?`.\n\n" \
|
|
121
|
-
"To fix this issue, choose a different name."
|
|
121
|
+
"To fix this issue, choose a different name.".freeze
|
|
122
122
|
|
|
123
123
|
def initialize(klass_name, slot_name)
|
|
124
124
|
super(MESSAGE.gsub("COMPONENT", klass_name.to_s).gsub("SLOT_NAME", slot_name.to_s))
|
|
@@ -128,7 +128,7 @@ module ViewComponent
|
|
|
128
128
|
class RedefinedSlotError < StandardError
|
|
129
129
|
MESSAGE =
|
|
130
130
|
"COMPONENT declares the SLOT_NAME slot multiple times.\n\n" \
|
|
131
|
-
"To fix this issue, choose a different slot name."
|
|
131
|
+
"To fix this issue, choose a different slot name.".freeze
|
|
132
132
|
|
|
133
133
|
def initialize(klass_name, slot_name)
|
|
134
134
|
super(MESSAGE.gsub("COMPONENT", klass_name.to_s).gsub("SLOT_NAME", slot_name.to_s))
|
|
@@ -138,7 +138,7 @@ module ViewComponent
|
|
|
138
138
|
class ReservedSingularSlotNameError < InvalidSlotNameError
|
|
139
139
|
MESSAGE =
|
|
140
140
|
"COMPONENT declares a slot named SLOT_NAME, which is a reserved word in the ViewComponent framework.\n\n" \
|
|
141
|
-
"To fix this issue, choose a different name."
|
|
141
|
+
"To fix this issue, choose a different name.".freeze
|
|
142
142
|
|
|
143
143
|
def initialize(klass_name, slot_name)
|
|
144
144
|
super(MESSAGE.gsub("COMPONENT", klass_name.to_s).gsub("SLOT_NAME", slot_name.to_s))
|
|
@@ -148,7 +148,7 @@ module ViewComponent
|
|
|
148
148
|
class ReservedPluralSlotNameError < InvalidSlotNameError
|
|
149
149
|
MESSAGE =
|
|
150
150
|
"COMPONENT declares a slot named SLOT_NAME, which is a reserved word in the ViewComponent framework.\n\n" \
|
|
151
|
-
"To fix this issue, choose a different name."
|
|
151
|
+
"To fix this issue, choose a different name.".freeze
|
|
152
152
|
|
|
153
153
|
def initialize(klass_name, slot_name)
|
|
154
154
|
super(MESSAGE.gsub("COMPONENT", klass_name.to_s).gsub("SLOT_NAME", slot_name.to_s))
|
|
@@ -158,7 +158,7 @@ module ViewComponent
|
|
|
158
158
|
class UncountableSlotNameError < InvalidSlotNameError
|
|
159
159
|
MESSAGE =
|
|
160
160
|
"COMPONENT declares a slot named SLOT_NAME, which is an uncountable word\n\n" \
|
|
161
|
-
"To fix this issue, choose a different name."
|
|
161
|
+
"To fix this issue, choose a different name.".freeze
|
|
162
162
|
|
|
163
163
|
def initialize(klass_name, slot_name)
|
|
164
164
|
super(MESSAGE.gsub("COMPONENT", klass_name.to_s).gsub("SLOT_NAME", slot_name.to_s))
|
|
@@ -166,7 +166,7 @@ module ViewComponent
|
|
|
166
166
|
end
|
|
167
167
|
|
|
168
168
|
class ContentAlreadySetForPolymorphicSlotError < StandardError
|
|
169
|
-
MESSAGE = "Content for slot SLOT_NAME has already been provided."
|
|
169
|
+
MESSAGE = "Content for slot SLOT_NAME has already been provided.".freeze
|
|
170
170
|
|
|
171
171
|
def initialize(slot_name)
|
|
172
172
|
super(MESSAGE.gsub("SLOT_NAME", slot_name.to_s))
|
|
@@ -176,7 +176,20 @@ module ViewComponent
|
|
|
176
176
|
class NilWithContentError < BaseError
|
|
177
177
|
MESSAGE =
|
|
178
178
|
"No content provided to `#with_content` for #{self}.\n\n" \
|
|
179
|
-
"To fix this issue, pass a value."
|
|
179
|
+
"To fix this issue, pass a value.".freeze
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
class ReusedInstanceError < StandardError
|
|
183
|
+
MESSAGE =
|
|
184
|
+
"ViewComponent instances are single-use; a COMPONENT instance was rendered more than once.\n\n" \
|
|
185
|
+
"Reusing a component instance across renders can leak request-scoped state " \
|
|
186
|
+
"(controller, helpers, request, view_flow, slot content, `with_content`) from an earlier render " \
|
|
187
|
+
"into a later one, which has security and correctness implications (GHSA-8qw7-6phv-7q6p).\n\n" \
|
|
188
|
+
"To fix this issue, create a new component instance per render.".freeze
|
|
189
|
+
|
|
190
|
+
def initialize(klass_name)
|
|
191
|
+
super(MESSAGE.gsub("COMPONENT", klass_name.to_s))
|
|
192
|
+
end
|
|
180
193
|
end
|
|
181
194
|
|
|
182
195
|
class TranslateCalledBeforeRenderError < BaseError
|
|
@@ -185,7 +198,7 @@ module ViewComponent
|
|
|
185
198
|
"on the view context that only exists once a ViewComponent is passed to " \
|
|
186
199
|
"the Rails render pipeline.\n\n" \
|
|
187
200
|
"It's sometimes possible to fix this issue by moving code dependent on " \
|
|
188
|
-
"`#translate` to a [`#before_render` method](https://viewcomponent.org/api.html#before_render--void)."
|
|
201
|
+
"`#translate` to a [`#before_render` method](https://viewcomponent.org/api.html#before_render--void).".freeze
|
|
189
202
|
end
|
|
190
203
|
|
|
191
204
|
class HelpersCalledBeforeRenderError < BaseError
|
|
@@ -194,7 +207,7 @@ module ViewComponent
|
|
|
194
207
|
"on the view context that only exists once a ViewComponent is passed to " \
|
|
195
208
|
"the Rails render pipeline.\n\n" \
|
|
196
209
|
"It's sometimes possible to fix this issue by moving code dependent on " \
|
|
197
|
-
"`#helpers` to a [`#before_render` method](https://viewcomponent.org/api.html#before_render--void)."
|
|
210
|
+
"`#helpers` to a [`#before_render` method](https://viewcomponent.org/api.html#before_render--void).".freeze
|
|
198
211
|
end
|
|
199
212
|
|
|
200
213
|
class ControllerCalledBeforeRenderError < BaseError
|
|
@@ -203,20 +216,55 @@ module ViewComponent
|
|
|
203
216
|
"on the view context that only exists once a ViewComponent is passed to " \
|
|
204
217
|
"the Rails render pipeline.\n\n" \
|
|
205
218
|
"It's sometimes possible to fix this issue by moving code dependent on " \
|
|
206
|
-
"`#controller` to a [`#before_render` method](https://viewcomponent.org/api.html#before_render--void)."
|
|
219
|
+
"`#controller` to a [`#before_render` method](https://viewcomponent.org/api.html#before_render--void).".freeze
|
|
207
220
|
end
|
|
208
221
|
|
|
209
222
|
class SystemTestControllerNefariousPathError < BaseError
|
|
210
|
-
MESSAGE = "ViewComponent SystemTest controller attempted to load a file outside of the expected directory."
|
|
223
|
+
MESSAGE = "ViewComponent SystemTest controller attempted to load a file outside of the expected directory.".freeze
|
|
211
224
|
end
|
|
212
225
|
|
|
213
226
|
class AlreadyDefinedPolymorphicSlotSetterError < StandardError
|
|
214
227
|
MESSAGE =
|
|
215
228
|
"A method called 'SETTER_METHOD_NAME' already exists and would be overwritten by the 'SETTER_NAME' polymorphic " \
|
|
216
|
-
"slot setter.\n\nPlease choose a different setter name."
|
|
229
|
+
"slot setter.\n\nPlease choose a different setter name.".freeze
|
|
217
230
|
|
|
218
231
|
def initialize(setter_method_name, setter_name)
|
|
219
232
|
super(MESSAGE.gsub("SETTER_METHOD_NAME", setter_method_name.to_s).gsub("SETTER_NAME", setter_name.to_s))
|
|
220
233
|
end
|
|
221
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
|
|
222
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
|
|
@@ -8,7 +8,7 @@ module ViewComponent # :nodoc:
|
|
|
8
8
|
mod.prepend(self) unless self <= ViewComponent::Instrumentation
|
|
9
9
|
end
|
|
10
10
|
|
|
11
|
-
def render_in(
|
|
11
|
+
def render_in(...)
|
|
12
12
|
return super if !Rails.application.config.view_component.instrumentation_enabled.present?
|
|
13
13
|
|
|
14
14
|
payload = {
|
|
@@ -11,6 +11,8 @@ module ViewComponent
|
|
|
11
11
|
# We're exposing it to the compiler via `refine` so that ViewComponent
|
|
12
12
|
# can match Rails' template picking logic.
|
|
13
13
|
module RequestDetails
|
|
14
|
+
EMPTY_DETAILS = {}.freeze
|
|
15
|
+
|
|
14
16
|
refine ActionView::LookupContext do
|
|
15
17
|
# Return an abstraction for matching and sorting available templates
|
|
16
18
|
# based on the current lookup context details.
|
|
@@ -18,7 +20,7 @@ module ViewComponent
|
|
|
18
20
|
# @return ActionView::TemplateDetails::Requested
|
|
19
21
|
# @see ActionView::LookupContext#detail_args_for
|
|
20
22
|
# @see ActionView::FileSystemResolver#_find_all
|
|
21
|
-
def vc_requested_details(user_details =
|
|
23
|
+
def vc_requested_details(user_details = EMPTY_DETAILS)
|
|
22
24
|
# The hash `user_details` would normally be the standard arguments that
|
|
23
25
|
# `render` accepts, but there's currently no mechanism for users to
|
|
24
26
|
# provide these when calling render on a ViewComponent.
|