view_component 4.0.2 → 4.12.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.
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "action_view"
4
- require "active_support/configurable"
4
+ require "action_view/base"
5
5
  require "view_component/collection"
6
6
  require "view_component/compile_cache"
7
7
  require "view_component/compiler"
@@ -47,7 +47,7 @@ module ViewComponent
47
47
  end
48
48
 
49
49
  include ActionView::Helpers
50
- include Rails.application.routes.url_helpers if defined?(Rails) && Rails.application
50
+ include Rails.application.routes.url_helpers if defined?(Rails.application.routes)
51
51
  include ERB::Escape
52
52
  include ActiveSupport::CoreExt::ERBUtil
53
53
 
@@ -82,6 +82,7 @@ module ViewComponent
82
82
  # so helpers, etc work as expected.
83
83
  #
84
84
  # @param view_context [ActionView::Base] The original view context.
85
+ #
85
86
  # @return [void]
86
87
  def set_original_view_context(view_context)
87
88
  # noop
@@ -102,25 +103,27 @@ module ViewComponent
102
103
  # Returns HTML that has been escaped by the respective template handler.
103
104
  #
104
105
  # @return [String]
105
- def render_in(view_context, &block)
106
+ def render_in(view_context, **_, &block)
106
107
  self.class.__vc_compile(raise_errors: true)
107
108
 
109
+ __vc_reset_render_state!
110
+
108
111
  @view_context = view_context
109
112
  @old_virtual_path = view_context.instance_variable_get(:@virtual_path)
110
- self.__vc_original_view_context ||= view_context
113
+ self.__vc_original_view_context = view_context
111
114
 
112
115
  @output_buffer = view_context.output_buffer
113
116
 
114
- @lookup_context ||= view_context.lookup_context
117
+ @lookup_context = view_context.lookup_context
115
118
 
116
119
  # For content_for
117
- @view_flow ||= view_context.view_flow
120
+ @view_flow = view_context.view_flow
118
121
 
119
122
  # For i18n
120
123
  @virtual_path ||= virtual_path
121
124
 
122
125
  # Describes the inferred request constraints (locales, formats, variants)
123
- @__vc_requested_details ||= @lookup_context.vc_requested_details
126
+ @__vc_requested_details = @lookup_context.vc_requested_details
124
127
 
125
128
  # For caching, such as #cache_if
126
129
  @current_template = nil unless defined?(@current_template)
@@ -131,7 +134,9 @@ module ViewComponent
131
134
  end
132
135
 
133
136
  @__vc_content_evaluated = false
137
+ remove_instance_variable(:@__vc_content) if defined?(@__vc_content)
134
138
  @__vc_render_in_block = block
139
+ @view_context.instance_variable_set(:@virtual_path, virtual_path)
135
140
 
136
141
  before_render
137
142
 
@@ -139,12 +144,21 @@ module ViewComponent
139
144
  value = nil
140
145
 
141
146
  @output_buffer.with_buffer do
142
- @view_context.instance_variable_set(:@virtual_path, virtual_path)
147
+ inner_rendered_template = nil
148
+ around_rendered_template = around_render do
149
+ inner_rendered_template = render_template_for(@__vc_requested_details).to_s
150
+ end
143
151
 
144
- rendered_template =
145
- around_render do
146
- render_template_for(@__vc_requested_details).to_s
147
- end
152
+ # If `around_render` returned the same object the block yielded, the inner
153
+ # template's escaping is authoritative and we can trust the result. If the
154
+ # user replaced/wrapped the value, re-check HTML safety to prevent
155
+ # bypassing the escaping applied to normal `#call` return values
156
+ # (GHSA-97jw-64cj-jc58).
157
+ rendered_template = if around_rendered_template.equal?(inner_rendered_template)
158
+ around_rendered_template
159
+ else
160
+ __vc_safe_around_render_output(around_rendered_template)
161
+ end
148
162
 
149
163
  # Avoid allocating new string when output_preamble and output_postamble are blank
150
164
  value = if output_preamble.blank? && output_postamble.blank?
@@ -161,7 +175,7 @@ module ViewComponent
161
175
 
162
176
  value
163
177
  else
164
- ""
178
+ "".html_safe
165
179
  end
166
180
  ensure
167
181
  view_context.instance_variable_set(:@virtual_path, @old_virtual_path)
@@ -254,13 +268,16 @@ module ViewComponent
254
268
  # @private
255
269
  def render(options = {}, args = {}, &block)
256
270
  if options.respond_to?(:set_original_view_context)
257
- options.set_original_view_context(self.__vc_original_view_context)
271
+ options.set_original_view_context(__vc_original_view_context)
258
272
 
259
273
  # We assume options is a component, so there's no need to evaluate the
260
274
  # block in the view context as we do below.
261
275
  @view_context.render(options, args, &block)
262
276
  elsif block
263
277
  __vc_original_view_context.render(options, args) do
278
+ # capture the block output in the view context of the component
279
+ output = capture(&block)
280
+
264
281
  # Partials are rendered to their own buffer and do not append to the
265
282
  # original @output_buffer we retain a reference to in #render_in. This
266
283
  # is a problem since the block passed to us here in the #render method
@@ -268,13 +285,29 @@ module ViewComponent
268
285
  # appends to the original @output_buffer. To avoid this, we evaluate the
269
286
  # block in the view context instead, which will append to the output buffer
270
287
  # created for the partial.
271
- __vc_original_view_context.instance_exec(&block)
288
+ __vc_original_view_context.capture { output }
272
289
  end
273
290
  else
274
291
  __vc_original_view_context.render(options, args)
275
292
  end
276
293
  end
277
294
 
295
+ # Sync @output_buffer with the view context's current output buffer before
296
+ # capturing. Form helpers create builders with @template_object pointing to
297
+ # the component. When those builders later call capture inside a partial
298
+ # (whose _run allocated a fresh OutputBuffer on the view context), the
299
+ # component's stale @output_buffer would capture from the wrong buffer.
300
+ # Temporarily switching to the view context's buffer keeps both in sync.
301
+ #
302
+ # @private
303
+ def capture(...)
304
+ old_output_buffer = @output_buffer
305
+ @output_buffer = view_context.output_buffer if view_context
306
+ super
307
+ ensure
308
+ @output_buffer = old_output_buffer
309
+ end
310
+
278
311
  # The current controller. Use sparingly as doing so introduces coupling
279
312
  # that inhibits encapsulation & reuse, often making testing difficult.
280
313
  #
@@ -302,7 +335,7 @@ module ViewComponent
302
335
  @__vc_helpers ||= __vc_original_view_context || controller.view_context
303
336
  end
304
337
 
305
- if ::Rails.env.development? || ::Rails.env.test?
338
+ if defined?(Rails.env) && (::Rails.env.development? || ::Rails.env.test?)
306
339
  # @private
307
340
  def method_missing(method_name, *args) # rubocop:disable Style/MissingRespondToMissing
308
341
  super
@@ -331,7 +364,7 @@ module ViewComponent
331
364
  []
332
365
  end
333
366
 
334
- if Rails::VERSION::MAJOR == 7 && Rails::VERSION::MINOR == 1
367
+ if defined?(Rails::VERSION) && Rails::VERSION::MAJOR == 7 && Rails::VERSION::MINOR == 1
335
368
  # Rails expects us to define `format` on all renderables,
336
369
  # but we do not know the `format` of a ViewComponent until runtime.
337
370
  def format
@@ -362,7 +395,7 @@ module ViewComponent
362
395
 
363
396
  @__vc_content =
364
397
  if __vc_render_in_block_provided?
365
- with_original_virtual_path do
398
+ with_captured_virtual_path(@old_virtual_path) do
366
399
  view_context.capture(self, &@__vc_render_in_block)
367
400
  end
368
401
  elsif __vc_content_set_by_with_content_defined?
@@ -378,11 +411,14 @@ module ViewComponent
378
411
  end
379
412
 
380
413
  # @private
381
- def with_original_virtual_path
382
- @view_context.instance_variable_set(:@virtual_path, @old_virtual_path)
414
+ # Temporarily sets the virtual path to the captured value, then restores it.
415
+ # This ensures translations and other path-dependent code execute with the correct scope.
416
+ def with_captured_virtual_path(captured_path)
417
+ old_virtual_path = @view_context.instance_variable_get(:@virtual_path)
418
+ @view_context.instance_variable_set(:@virtual_path, captured_path)
383
419
  yield
384
420
  ensure
385
- @view_context.instance_variable_set(:@virtual_path, virtual_path)
421
+ @view_context.instance_variable_set(:@virtual_path, old_virtual_path)
386
422
  end
387
423
 
388
424
  private
@@ -421,6 +457,28 @@ module ViewComponent
421
457
  end
422
458
  end
423
459
 
460
+ def __vc_safe_around_render_output(output)
461
+ __vc_maybe_escape_html(output) do
462
+ Kernel.warn("WARNING: The #{self.class} component's around_render returned an HTML-unsafe string. The output will be automatically escaped, but you may want to investigate.")
463
+ end
464
+ end
465
+
466
+ # Resets every render-scoped instance variable derived from the calling view
467
+ # context so a reused instance cannot leak controller/helper/request/format
468
+ # state from a previous render. Slot state (`@__vc_set_slots`,
469
+ # `@__vc_content_set_by_with_content`) is intentionally preserved because it
470
+ # is populated by callers _before_ `render_in` runs (e.g. via `with_*`
471
+ # slot setters or `with_content`).
472
+ def __vc_reset_render_state!
473
+ %i[
474
+ @__vc_controller
475
+ @__vc_helpers
476
+ @__vc_request
477
+ ].each do |ivar|
478
+ remove_instance_variable(ivar) if instance_variable_defined?(ivar)
479
+ end
480
+ end
481
+
424
482
  # Configuration for generators.
425
483
  #
426
484
  # All options under this namespace default to `false` unless otherwise
@@ -512,6 +570,7 @@ module ViewComponent
512
570
  # @param extensions [Array<String>] Extensions of which to return matching sidecar files.
513
571
  def sidecar_files(extensions)
514
572
  return [] unless identifier
573
+ return [] unless name
515
574
 
516
575
  extensions = extensions.join(",")
517
576
 
@@ -573,7 +632,7 @@ module ViewComponent
573
632
  # eager loading is disabled and the parent component is rendered before the child. In
574
633
  # such a scenario, the parent will override ViewComponent::Base#render_template_for,
575
634
  # meaning it will not be called for any children and thus not compile their templates.
576
- if !child.instance_methods(false).include?(:render_template_for) && !child.__vc_compiled?
635
+ if !child.method_defined?(:render_template_for, false) && !child.__vc_compiled?
577
636
  child.class_eval <<~RUBY, __FILE__, __LINE__ + 1
578
637
  def render_template_for(requested_details)
579
638
  # Force compilation here so the compiler always redefines render_template_for.
@@ -596,7 +655,7 @@ module ViewComponent
596
655
  # Set collection parameter to the extended component
597
656
  child.with_collection_parameter(__vc_provided_collection_parameter)
598
657
 
599
- if instance_methods(false).include?(:render_template_for)
658
+ if method_defined?(:render_template_for, false)
600
659
  vc_ancestor_calls = defined?(@__vc_ancestor_calls) ? @__vc_ancestor_calls.dup : []
601
660
 
602
661
  vc_ancestor_calls.unshift(instance_method(:render_template_for))
@@ -611,6 +670,16 @@ module ViewComponent
611
670
  __vc_compiler.compiled?
612
671
  end
613
672
 
673
+ # Hook called by the compiler after a component is compiled.
674
+ #
675
+ # Extensions can override this class method to run logic after
676
+ # compilation (e.g., generate helpers, register metadata, etc.).
677
+ #
678
+ # By default, this is a no-op.
679
+ def after_compile
680
+ # no-op by default
681
+ end
682
+
614
683
  # @private
615
684
  def __vc_ensure_compiled
616
685
  __vc_compile unless __vc_compiled?
@@ -1,25 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "action_view/renderer/collection_renderer"
4
+ require "action_view/helpers/output_safety_helper"
4
5
 
5
6
  module ViewComponent
6
7
  class Collection
7
8
  include Enumerable
9
+ include ActionView::Helpers::OutputSafetyHelper
10
+
8
11
  attr_reader :component
9
12
 
10
13
  delegate :size, to: :@collection
11
14
 
12
- def render_in(view_context, &block)
13
- components.map do |component|
15
+ def render_in(view_context, **_, &block)
16
+ rendered = components.map do |component|
14
17
  component.render_in(view_context, &block)
15
- end.join(rendered_spacer(view_context)).html_safe
18
+ end
19
+ safe_join(rendered, rendered_spacer(view_context))
16
20
  end
17
21
 
18
22
  def each(&block)
19
23
  components.each(&block)
20
24
  end
21
25
 
22
- if Rails::VERSION::MAJOR == 7 && Rails::VERSION::MINOR == 1
26
+ if defined?(Rails::VERSION) && Rails::VERSION::MAJOR == 7 && Rails::VERSION::MINOR == 1
23
27
  # Rails expects us to define `format` on all renderables,
24
28
  # but we do not know the `format` of a ViewComponent until runtime.
25
29
  def format
@@ -29,15 +33,15 @@ module ViewComponent
29
33
 
30
34
  private
31
35
 
36
+ # Always rebuild child component instances per render to avoid leaking
37
+ # request-scoped state from a previous render into a later one (GHSA).
32
38
  def components
33
- return @components if defined? @components
34
-
35
39
  iterator = ActionView::PartialIteration.new(@collection.size)
36
40
 
37
41
  component.__vc_validate_collection_parameter!(validate_default: true)
38
42
 
39
- @components = @collection.map do |item|
40
- component.new(**component_options(item, iterator)).tap do |component|
43
+ @collection.map do |item|
44
+ component.new(**component_options(item, iterator)).tap do |_|
41
45
  iterator.iterate!
42
46
  end
43
47
  end
@@ -66,12 +70,12 @@ module ViewComponent
66
70
  @options.merge(item_options)
67
71
  end
68
72
 
73
+ # Render the spacer through a fresh `dup` so a collection rendered multiple
74
+ # times always gets a clean spacer instance.
69
75
  def rendered_spacer(view_context)
70
- if @spacer_component
71
- @spacer_component.render_in(view_context)
72
- else
73
- ""
74
- end
76
+ return "" unless @spacer_component
77
+
78
+ @spacer_component.dup.render_in(view_context)
75
79
  end
76
80
  end
77
81
  end
@@ -52,12 +52,14 @@ module ViewComponent
52
52
  @component.__vc_build_i18n_backend
53
53
 
54
54
  CompileCache.register(@component)
55
+
56
+ @component.after_compile
55
57
  end
56
58
  end
57
59
 
58
- # @return all matching compiled templates, in priority order based on the requested details from LookupContext
60
+ # @param requested_details [ActionView::TemplateDetails::Requested] i.e. locales, formats, variants
59
61
  #
60
- # @param [ActionView::TemplateDetails::Requested] requested_details i.e. locales, formats, variants
62
+ # @return all matching compiled templates, in priority order based on the requested details from LookupContext
61
63
  def find_templates_for(requested_details)
62
64
  filtered_templates = @templates.select do |template|
63
65
  template.details.matches?(requested_details)
@@ -113,11 +115,11 @@ module ViewComponent
113
115
  .tally
114
116
  .select { |_, count| count > 1 }
115
117
  .each do |tally|
116
- variant, this_format = tally.first
118
+ variant, this_format = tally.first
117
119
 
118
- variant_string = " for variant `#{variant}`" if variant.present?
120
+ variant_string = " for variant `#{variant}`" if variant.present?
119
121
 
120
- 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}. "
121
123
  end
122
124
 
123
125
  default_template_types = @templates.each_with_object(Set.new) do |template, memo|
@@ -188,11 +190,11 @@ module ViewComponent
188
190
  ).flat_map { |ancestor| ancestor.instance_methods(false).grep(/^call(_|$)/) }
189
191
  .uniq
190
192
  .each do |method_name|
191
- templates << Template::InlineCall.new(
192
- component: @component,
193
- method_name: method_name,
194
- defined_on_self: component_instance_methods_on_self.include?(method_name)
195
- )
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
+ )
196
198
  end
197
199
 
198
200
  templates
@@ -19,6 +19,7 @@ module ViewComponent
19
19
  end
20
20
 
21
21
  # @!attribute generate
22
+ #
22
23
  # @return [ActiveSupport::OrderedOptions]
23
24
  # The subset of configuration options relating to generators.
24
25
  #
@@ -95,6 +96,7 @@ module ViewComponent
95
96
  # in `spec/views/components/` rather than the default `spec/components/`.
96
97
 
97
98
  # @!attribute previews
99
+ #
98
100
  # @return [ActiveSupport::OrderedOptions]
99
101
  # The subset of configuration options relating to previews.
100
102
  #
@@ -124,6 +126,7 @@ module ViewComponent
124
126
  #
125
127
 
126
128
  # @!attribute instrumentation_enabled
129
+ #
127
130
  # @return [Boolean]
128
131
  # Whether ActiveSupport notifications are enabled.
129
132
  # Defaults to `false`.
@@ -163,7 +166,7 @@ module ViewComponent
163
166
  options = ActiveSupport::OrderedOptions.new
164
167
  options.controller = "ViewComponentsController"
165
168
  options.route = "/rails/view_components"
166
- options.enabled = Rails.env.development? || Rails.env.test?
169
+ options.enabled = defined?(Rails.env) && (Rails.env.development? || Rails.env.test?)
167
170
  options.default_layout = nil
168
171
  options.paths = default_preview_paths
169
172
  options
@@ -171,6 +174,7 @@ module ViewComponent
171
174
  end
172
175
 
173
176
  # @!attribute current
177
+ #
174
178
  # @return [ViewComponent::Config]
175
179
  # Returns the current ViewComponent::Config. This is persisted against this
176
180
  # class so that config options remain accessible before the rest of
@@ -4,14 +4,30 @@ module ViewComponent
4
4
  module Configurable
5
5
  extend ActiveSupport::Concern
6
6
 
7
+ class_methods do
8
+ def config
9
+ @_config ||= if respond_to?(:superclass) && superclass.respond_to?(:config)
10
+ superclass.config.inheritable_copy
11
+ else
12
+ ActiveSupport::OrderedOptions.new
13
+ end
14
+ end
15
+
16
+ def configure
17
+ yield config
18
+ end
19
+ end
20
+
7
21
  included do
8
22
  next if respond_to?(:config) && config.respond_to?(:view_component) && config.respond_to_missing?(:instrumentation_enabled)
9
23
 
10
- include ActiveSupport::Configurable
11
-
12
24
  configure do |config|
13
25
  config.view_component ||= ActiveSupport::InheritableOptions.new
14
26
  end
27
+
28
+ def config
29
+ self.class.config
30
+ end
15
31
  end
16
32
  end
17
33
  end
@@ -50,11 +50,13 @@ module ViewComponent
50
50
  end
51
51
  end
52
52
 
53
- config.after_initialize do |app|
53
+ config.after_routes_loaded do
54
54
  ActiveSupport.on_load(:view_component) do
55
55
  if defined?(Sprockets::Rails)
56
56
  include Sprockets::Rails::Helper
57
57
 
58
+ app = Rails.application
59
+
58
60
  # Copy relevant config to VC context
59
61
  # See: https://github.com/rails/sprockets-rails/blob/266ec49f3c7c96018dd75f9dc4f9b62fe3f7eecf/lib/sprockets/railtie.rb#L245
60
62
  self.debug_assets = app.config.assets.debug
@@ -77,7 +79,15 @@ module ViewComponent
77
79
 
78
80
  initializer "view_component.eager_load_actions" do
79
81
  ActiveSupport.on_load(:after_initialize) do
80
- ViewComponent::Base.descendants.each(&:__vc_compile) if Rails.application.config.eager_load
82
+ if Rails.application.config.eager_load
83
+ ActiveSupport::Notifications.instrument("compile.view_component") do
84
+ ViewComponent::Base.descendants.each do |component|
85
+ next if component.anonymous?
86
+
87
+ component.__vc_compile
88
+ end
89
+ end
90
+ end
81
91
  end
82
92
  end
83
93
 
@@ -3,6 +3,7 @@
3
3
  module ViewComponent # :nodoc:
4
4
  module InlineTemplate
5
5
  extend ActiveSupport::Concern
6
+
6
7
  Template = Struct.new(:source, :language, :path, :lineno)
7
8
 
8
9
  class_methods do
@@ -8,18 +8,31 @@ 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(view_context, **_, &block)
12
12
  return super if !Rails.application.config.view_component.instrumentation_enabled.present?
13
13
 
14
+ payload = {
15
+ name: self.class.name,
16
+ identifier: self.class.identifier,
17
+ view_identifier: nil
18
+ }
19
+
14
20
  ActiveSupport::Notifications.instrument(
15
21
  "render.view_component",
16
- {
17
- name: self.class.name,
18
- identifier: self.class.identifier
19
- }
22
+ payload
20
23
  ) do
21
- super
24
+ result = super
25
+ payload[:view_identifier] = @__vc_instrumentation_view_identifier
26
+ result
22
27
  end
28
+ ensure
29
+ @__vc_instrumentation_view_identifier = nil
30
+ end
31
+
32
+ def around_render
33
+ result = super
34
+ @__vc_instrumentation_view_identifier = current_template&.path
35
+ result
23
36
  end
24
37
  end
25
38
  end
@@ -40,6 +40,8 @@ module ViewComponent # :nodoc:
40
40
 
41
41
  # Returns the arguments for rendering of the component in its layout
42
42
  def render_args(example, params: {})
43
+ raise AbstractController::ActionNotFound, "#{example} is not a valid preview example" unless examples.include?(example.to_s)
44
+
43
45
  example_params_names = instance_method(example).parameters.map(&:last)
44
46
  provided_params = params.slice(*example_params_names).to_h.symbolize_keys
45
47
  result = provided_params.empty? ? new.public_send(example) : new.public_send(example, **provided_params)
@@ -95,7 +97,7 @@ module ViewComponent # :nodoc:
95
97
  # @private
96
98
  def __vc_load_previews
97
99
  Array(preview_paths).each do |preview_path|
98
- Dir["#{preview_path}/**/*preview.rb"].sort.each { |file| require_dependency file }
100
+ Dir["#{preview_path}/**/*preview.rb"].sort.each { |file| require file }
99
101
  end
100
102
  end
101
103
 
@@ -6,7 +6,7 @@ module ViewComponent
6
6
  class Slot
7
7
  include ViewComponent::WithContentHelper
8
8
 
9
- attr_writer :__vc_component_instance, :__vc_content_block, :__vc_content
9
+ attr_writer :__vc_component_instance, :__vc_content_block, :__vc_content, :__vc_content_block_virtual_path
10
10
 
11
11
  def initialize(parent)
12
12
  @parent = parent
@@ -58,7 +58,7 @@ module ViewComponent
58
58
  if defined?(@__vc_content_block)
59
59
  # render_in is faster than `parent.render`
60
60
  @__vc_component_instance.render_in(view_context) do |*args|
61
- @parent.with_original_virtual_path do
61
+ @parent.with_captured_virtual_path(@__vc_content_block_virtual_path) do
62
62
  @__vc_content_block.call(*args)
63
63
  end
64
64
  end
@@ -68,7 +68,7 @@ module ViewComponent
68
68
  elsif defined?(@__vc_content)
69
69
  @__vc_content
70
70
  elsif defined?(@__vc_content_block)
71
- @parent.with_original_virtual_path do
71
+ @parent.with_captured_virtual_path(@__vc_content_block_virtual_path) do
72
72
  view_context.capture(&@__vc_content_block)
73
73
  end
74
74
  elsif defined?(@__vc_content_set_by_with_content)
@@ -96,7 +96,6 @@ module ViewComponent
96
96
  # end
97
97
  # end
98
98
  # end
99
- #
100
99
  def method_missing(symbol, *args, **kwargs, &block)
101
100
  @__vc_component_instance.public_send(symbol, *args, **kwargs, &block)
102
101
  end
@@ -259,7 +259,7 @@ module ViewComponent
259
259
 
260
260
  setter_method_name = :"with_#{poly_slot_name}"
261
261
 
262
- if instance_methods.include?(setter_method_name)
262
+ if method_defined?(setter_method_name)
263
263
  raise AlreadyDefinedPolymorphicSlotSetterError.new(setter_method_name, poly_slot_name)
264
264
  end
265
265
 
@@ -380,6 +380,7 @@ module ViewComponent
380
380
  def __vc_set_slot(slot_name, slot_definition = nil, *args, **kwargs, &block)
381
381
  slot_definition ||= self.class.registered_slots[slot_name]
382
382
  slot = Slot.new(self)
383
+ captured_block_virtual_path = nil
383
384
 
384
385
  # Passing the block to the sub-component wrapper like this has two
385
386
  # benefits:
@@ -390,7 +391,13 @@ module ViewComponent
390
391
  # 2. Since we have to pass block content to components when calling
391
392
  # `render`, evaluating the block here would require us to call
392
393
  # `view_context.capture` twice, which is slower
393
- slot.__vc_content_block = block if block
394
+ if block
395
+ slot.__vc_content_block = block
396
+ # Capture the virtual path at the time the block is defined, so that
397
+ # translations resolve relative to where the block was created, not where it's rendered
398
+ captured_block_virtual_path = view_context.instance_variable_get(:@virtual_path)
399
+ slot.__vc_content_block_virtual_path = captured_block_virtual_path
400
+ end
394
401
 
395
402
  # If class
396
403
  if slot_definition[:renderable]
@@ -408,7 +415,9 @@ module ViewComponent
408
415
  renderable_value =
409
416
  if block
410
417
  renderable_function.call(*args, **kwargs) do |*rargs|
411
- view_context.capture(*rargs, &block)
418
+ with_captured_virtual_path(captured_block_virtual_path) do
419
+ view_context.capture(*rargs, &block)
420
+ end
412
421
  end
413
422
  else
414
423
  renderable_function.call(*args, **kwargs)
@@ -7,6 +7,7 @@ module ViewComponent
7
7
  # Returns a block that can be used to visit the path of the inline rendered component.
8
8
  # @param fragment [Nokogiri::Fragment] The fragment returned from `render_inline`.
9
9
  # @param layout [String] The (optional) layout to use.
10
+ #
10
11
  # @return [Proc] A block that can be used to visit the path of the inline rendered component.
11
12
  def with_rendered_component_path(fragment, layout: false, &block)
12
13
  file = Tempfile.new(