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.
@@ -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 = {component.__vc_collection_parameter => item}
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
- @options.merge(item_options)
71
+ item_options
71
72
  end
72
73
 
73
74
  # Render the spacer through a fresh `dup` so a collection rendered multiple
74
- # times always gets a clean spacer instance.
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.render_in(view_context)
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
- variant, this_format = tally.first
118
+ variant, this_format = tally.first
119
119
 
120
- variant_string = " for variant `#{variant}`" if variant.present?
120
+ variant_string = " for variant `#{variant}`" if variant.present?
121
121
 
122
- errors << "More than one #{this_format.upcase} template found#{variant_string} for #{@component}. "
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.template_handler_extensions
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
- 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
- )
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(view_context, **_, &block)
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.