view_component 4.20.0 → 4.21.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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/docs/CHANGELOG.md +94 -0
  3. data/lib/generators/view_component/abstract_generator.rb +24 -4
  4. data/lib/generators/view_component/component/component_generator.rb +8 -6
  5. data/lib/generators/view_component/component/templates/component.rb.tt +1 -1
  6. data/lib/generators/view_component/erb/erb_generator.rb +2 -2
  7. data/lib/generators/view_component/haml/haml_generator.rb +1 -1
  8. data/lib/generators/view_component/locale/locale_generator.rb +2 -6
  9. data/lib/generators/view_component/preview/preview_generator.rb +9 -6
  10. data/lib/generators/view_component/preview/templates/component_preview.rb.tt +2 -2
  11. data/lib/generators/view_component/rspec/rspec_generator.rb +1 -1
  12. data/lib/generators/view_component/rspec/templates/component_spec.rb.tt +1 -1
  13. data/lib/generators/view_component/slim/slim_generator.rb +1 -1
  14. data/lib/generators/view_component/stimulus/stimulus_generator.rb +3 -7
  15. data/lib/generators/view_component/test_unit/templates/component_test.rb.tt +2 -2
  16. data/lib/generators/view_component/test_unit/test_unit_generator.rb +8 -7
  17. data/lib/view_component/base.rb +106 -98
  18. data/lib/view_component/cache_digest/dependency_tracking.rb +6 -6
  19. data/lib/view_component/cache_digest/digestor_lookup.rb +3 -1
  20. data/lib/view_component/cache_digest/resolver.rb +59 -38
  21. data/lib/view_component/cache_digest.rb +161 -53
  22. data/lib/view_component/cacheable.rb +16 -18
  23. data/lib/view_component/collection.rb +4 -9
  24. data/lib/view_component/compile_cache.rb +44 -5
  25. data/lib/view_component/compiler.rb +50 -2
  26. data/lib/view_component/config.rb +114 -8
  27. data/lib/view_component/engine.rb +1 -2
  28. data/lib/view_component/errors.rb +16 -0
  29. data/lib/view_component/slot.rb +12 -0
  30. data/lib/view_component/slotable.rb +28 -4
  31. data/lib/view_component/template.rb +16 -3
  32. data/lib/view_component/test_helpers.rb +38 -16
  33. data/lib/view_component/translatable.rb +34 -0
  34. data/lib/view_component/version.rb +1 -1
  35. metadata +1 -1
@@ -4,6 +4,7 @@ require "active_support/dependencies/autoload"
4
4
  require "action_view/digestor"
5
5
  require "action_view/render_parser"
6
6
  require "prism"
7
+ require "strscan"
7
8
 
8
9
  module ViewComponent
9
10
  # Integrates ViewComponents into Rails' template digest tree.
@@ -44,10 +45,6 @@ module ViewComponent
44
45
  COMPILED_SOURCES = :view_component_cache_digest_compiled_sources
45
46
  private_constant :COMPILED_SOURCES
46
47
 
47
- # A render call whose first argument starts with a constant, such as
48
- # `render FooComponent.new` or `render(Foo::Bar.with_collection(...))`.
49
- CONSTANT_RENDER = /\brender(?:_to_string)?[\s(]*(?:::)?[A-Z]/
50
-
51
48
  # Rails' escape hatch for dependencies static analysis can't see.
52
49
  EXPLICIT_DEPENDENCY = /#\s*Template Dependency:\s*(\S+)/
53
50
 
@@ -112,6 +109,35 @@ module ViewComponent
112
109
  Thread.current[COMPILED_SOURCES]&.dig(template.handler, source) || template.handler.call(template, source)
113
110
  end
114
111
 
112
+ # Skip literal templates and ERB markup before compiling Ruby for analysis.
113
+ # Other handlers keep a conservative check because their code syntax varies.
114
+ #
115
+ # @param source [String]
116
+ # @param handler [#call]
117
+ #
118
+ # @return [Boolean]
119
+ def template_dependencies?(source, handler)
120
+ return false unless source.match?(/render|[A-Z]/)
121
+
122
+ case handler
123
+ when ActionView::Template::Handlers::Raw, ActionView::Template::Handlers::Html
124
+ false
125
+ when ActionView::Template::Handlers::ERB
126
+ # Never restart the search at another opener after an unterminated tag.
127
+ scanner = StringScanner.new(source)
128
+ while scanner.skip_until(/<%/)
129
+ code = scanner.scan_until(/%>/)
130
+ return false unless code
131
+ next if code.start_with?("#", "%")
132
+
133
+ return true if code.match?(/render|[A-Z]/)
134
+ end
135
+ false
136
+ else
137
+ true
138
+ end
139
+ end
140
+
115
141
  # Find the components Ruby source references and the partials it
116
142
  # renders.
117
143
  #
@@ -126,42 +152,36 @@ module ViewComponent
126
152
  #
127
153
  # @param source [String] Ruby source
128
154
  # @param name [String] virtual path the source is being digested under
155
+ # @param view_paths [ActionView::PathSet, nil] paths to check dynamic partial guesses against
156
+ # @param nesting [Array<Module>] lexical scope of compiled template source
129
157
  # @return [Array(Array<String>, Array<String>)] synthetic virtual paths of
130
158
  # components and partial virtual paths
131
- def renders_in(source, name)
132
- names = []
133
- other_calls = []
134
-
135
- each_node(Prism.parse(source).value) do |node|
136
- if node.is_a?(Prism::ConstantReadNode) || node.is_a?(Prism::ConstantPathNode)
137
- names << node.slice if CONSTANT_NAME.match?(node.slice)
138
- elsif render_call?(node) && !rendered_component(node)
139
- other_calls << node
140
- end
141
- end
142
-
143
- components = names.uniq.filter_map { |constant_name| constantize_component(constant_name) }
144
- [component_paths(components), partials_rendered_by(other_calls, name)]
159
+ def renders_in(source, name, view_paths: nil, nesting: [])
160
+ components, _component_calls, other_calls = partition_render_calls(Prism.parse(source).value, nesting)
161
+ partials = partials_rendered_by(other_calls, name) - unresolved_partials_rendered_by(other_calls, name, view_paths)
162
+ [component_paths(components), partials]
145
163
  end
146
164
 
147
- # Find the components Ruby source renders, along with the partials Action
148
- # View infers from them.
165
+ # Find the components Ruby source renders, along with partial guesses
166
+ # Action View should discard.
149
167
  #
150
168
  # Action View reads `render FooComponent.new` as a dynamic render of a
151
169
  # partial named after the last method, inferring `news/new`. Partials also
152
- # rendered some other way aren't returned.
170
+ # rendered some other way aren't returned. Unresolved guesses from other
171
+ # dynamic renders are discarded when view paths are available.
153
172
  #
154
173
  # @param source [String] Ruby source
155
174
  # @param name [String] virtual path the source is being digested under
175
+ # @param view_paths [ActionView::PathSet, nil] paths to check dynamic partial guesses against
156
176
  # @return [Array(Array<String>, Array<String>)] synthetic virtual paths of
157
177
  # components and inferred partial virtual paths
158
- def component_renders_in(source, name)
159
- components, other_calls = partition_render_calls(Prism.parse(source).value)
160
- return [[], []] if components.empty?
178
+ def template_renders_in(source, name, view_paths: nil)
179
+ components, component_calls, other_calls = partition_render_calls(Prism.parse(source).value)
161
180
 
162
181
  [
163
- component_paths(components.values),
164
- partials_rendered_by(components.keys, name) - partials_rendered_by(other_calls, name)
182
+ component_paths(components),
183
+ (partials_rendered_by(component_calls, name) - partials_rendered_by(other_calls, name)) |
184
+ unresolved_partials_rendered_by(other_calls, name, view_paths)
165
185
  ]
166
186
  end
167
187
 
@@ -213,24 +233,28 @@ module ViewComponent
213
233
  # Compute the digest of a component using Rails' digest tree.
214
234
  #
215
235
  # @param component [Class] a component class
216
- # @param finder [ActionView::LookupContext]
236
+ # @param finder [ActionView::LookupContext, nil]
217
237
  # @param format [Symbol]
238
+ #
218
239
  # @return [String]
219
- def digest(component, finder: default_finder, format: :html)
240
+ def digest(component, finder: nil, format: :html)
220
241
  register(component)
221
242
  virtual_path = virtual_path_for(component)
222
243
  return "" unless virtual_path
223
244
 
245
+ finder ||= default_finder(format: format)
224
246
  ActionView::Digestor.digest(name: virtual_path, format: format, finder: finder)
225
247
  end
226
248
 
227
249
  # A lookup context for digesting components outside a request, where no
228
250
  # view context (and therefore no finder) exists.
229
251
  #
252
+ # @param format [Symbol]
253
+ #
230
254
  # @return [ActionView::LookupContext]
231
- def default_finder
255
+ def default_finder(format: :html)
232
256
  # Not memoized across reloads: view paths change when the app reloads.
233
- ActionView::LookupContext.new(ActionController::Base.view_paths)
257
+ ActionView::LookupContext.new(ActionController::Base.view_paths, formats: [format])
234
258
  end
235
259
 
236
260
  # Wire the tracker and resolver into Action View.
@@ -292,37 +316,54 @@ module ViewComponent
292
316
 
293
317
  private
294
318
 
295
- # Receiverless `render` and `render_to_string` calls, split into those
296
- # that render a component and the rest.
319
+ # Component references and receiverless render calls, split into those
320
+ # that render a component directly and the rest.
297
321
  #
298
- # @return [Array(Hash{Prism::CallNode => Class}, Array<Prism::CallNode>)]
299
- def partition_render_calls(program)
300
- components = {}
322
+ # @return [Array(Array<Class>, Array<Prism::CallNode>, Array<Prism::CallNode>)]
323
+ def partition_render_calls(program, nesting = [])
324
+ names = []
325
+ component_calls = []
301
326
  other_calls = []
302
327
 
303
- each_node(program) do |node|
304
- next unless render_call?(node)
305
-
306
- if (component = rendered_component(node))
307
- components[node] = component
308
- else
309
- other_calls << node
328
+ each_node(program, nesting) do |node, scope|
329
+ if node.is_a?(Prism::ConstantReadNode) || node.is_a?(Prism::ConstantPathNode)
330
+ names << [node.slice, scope] if CONSTANT_NAME.match?(node.slice)
331
+ elsif render_call?(node)
332
+ if rendered_component(node, scope)
333
+ component_calls << node
334
+ else
335
+ other_calls << node
336
+ end
310
337
  end
311
338
  end
312
339
 
313
- [components, other_calls]
340
+ components = names.uniq.filter_map { |constant_name, scope| constantize_component(constant_name, scope) }
341
+ [components, component_calls, other_calls]
314
342
  end
315
343
 
316
344
  def render_call?(node)
317
345
  node.is_a?(Prism::CallNode) && node.receiver.nil? && RENDER_METHODS.include?(node.name)
318
346
  end
319
347
 
320
- # Visit every node of a Prism tree, in source order.
321
- def each_node(program)
322
- nodes = [program]
323
- while (node = nodes.pop)
324
- yield node
325
- nodes.concat(node.compact_child_nodes.reverse!)
348
+ # Class/module bodies add one lexical scope, even for qualified names:
349
+ # `class UI::Card` adds Card, not UI, to Module.nesting.
350
+ def each_node(program, nesting = [])
351
+ nodes = [[program, nesting]]
352
+ while (entry = nodes.pop)
353
+ node, scope = entry
354
+ yield node, scope
355
+
356
+ body = nil
357
+ body_scope = scope
358
+ if node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode)
359
+ body = node.body
360
+ namespace = resolve_constant(node.constant_path.slice, scope)
361
+ body_scope = [namespace, *scope] if namespace.is_a?(Module)
362
+ end
363
+
364
+ node.compact_child_nodes.reverse_each do |child|
365
+ nodes << [child, child.equal?(body) ? body_scope : scope]
366
+ end
326
367
  end
327
368
  end
328
369
 
@@ -331,13 +372,13 @@ module ViewComponent
331
372
  # `FooComponent.with_collection(...)`, and so on.
332
373
  #
333
374
  # @return [Class, nil]
334
- def rendered_component(call)
375
+ def rendered_component(call, nesting = [])
335
376
  node = call.arguments&.arguments&.first
336
377
  node = node.receiver while node.is_a?(Prism::CallNode) && node.receiver
337
378
 
338
379
  case node
339
380
  when Prism::ConstantReadNode, Prism::ConstantPathNode
340
- constantize_component(node.slice)
381
+ constantize_component(node.slice, nesting)
341
382
  end
342
383
  end
343
384
 
@@ -363,11 +404,59 @@ module ViewComponent
363
404
  return [] if calls.empty?
364
405
 
365
406
  source = calls.map { |call| "#{call.name}(#{call.arguments&.slice})" }.join("\n")
407
+ partial_paths_in(source, name)
408
+ end
409
+
410
+ def partial_paths_in(source, name)
366
411
  RENDER_PARSER.new(name, source).render_calls
367
412
  .map { |path| path.gsub(%r{/_}, "/") }
368
413
  .uniq
369
414
  end
370
415
 
416
+ # Dynamic renders can return components without naming their classes,
417
+ # such as `render UI.button`. Keep Action View's guess only if it resolves,
418
+ # without executing application methods to discover what they return.
419
+ def unresolved_partials_rendered_by(calls, name, view_paths)
420
+ return [] unless view_paths
421
+
422
+ targets = calls.flat_map { |call| render_targets(call) }
423
+ literal, dynamic = targets.partition do |target|
424
+ target.is_a?(Prism::StringNode) || target.is_a?(Prism::InterpolatedStringNode)
425
+ end
426
+ return [] if dynamic.empty?
427
+
428
+ inferred = partial_paths_in(dynamic.map { |target| "render(#{target.slice})" }.join("\n"), name)
429
+ declared = partial_paths_in(literal.map { |target| "render(#{target.slice})" }.join("\n"), name)
430
+ finder = ActionView::LookupContext.new(view_paths, variants: :any, locale: I18n.available_locales)
431
+
432
+ inferred.reject do |path|
433
+ declared.include?(path) || finder.exists?(path, [], true)
434
+ end
435
+ end
436
+
437
+ def render_targets(call)
438
+ arguments = call.arguments&.arguments || []
439
+ targets = arguments.flat_map do |argument|
440
+ if argument.is_a?(Prism::KeywordHashNode) || argument.is_a?(Prism::HashNode)
441
+ argument.elements.filter_map do |element|
442
+ next unless element.is_a?(Prism::AssocNode) && element.key.is_a?(Prism::SymbolNode)
443
+ next unless %w[partial template layout spacer_template].include?(element.key.unescaped)
444
+
445
+ element.value
446
+ end
447
+ elsif argument.equal?(arguments.first)
448
+ argument
449
+ end
450
+ end.compact
451
+
452
+ targets.map do |target|
453
+ while target.is_a?(Prism::ParenthesesNode) && target.body.is_a?(Prism::StatementsNode) && target.body.body.one?
454
+ target = target.body.body.first
455
+ end
456
+ target
457
+ end
458
+ end
459
+
371
460
  # Record the actual class name rather than trying to reverse a virtual
372
461
  # path, which may contain acronyms or be overridden by the component.
373
462
  def registered_path_for(component)
@@ -389,12 +478,31 @@ module ViewComponent
389
478
  # Returns nil for anything else, including constants that don't exist.
390
479
  # Autoloading here is safe: the template is about to render this constant
391
480
  # anyway.
392
- def constantize_component(constant_name)
393
- component = constant_name.safe_constantize
481
+ def constantize_component(constant_name, nesting = [])
482
+ component = resolve_constant(constant_name, nesting)
394
483
  return unless component.is_a?(Class) && component < ViewComponent::Base
395
484
 
396
485
  component
397
486
  end
487
+
488
+ def resolve_constant(name, nesting)
489
+ return unless CONSTANT_NAME.match?(name)
490
+ return name.safe_constantize if nesting.empty? || name.start_with?("::")
491
+
492
+ first, *rest = name.split("::")
493
+ # Lexical constants precede inherited constants. Once a name resolves,
494
+ # do not fall back to a top-level component if it names something else.
495
+ owner = nesting.find { |namespace| namespace.const_defined?(first, false) }
496
+ owner ||= nesting.first if nesting.first.const_defined?(first)
497
+ owner ||= Object if Object.const_defined?(first)
498
+ return unless owner
499
+
500
+ constant = owner.const_get(first)
501
+ return constant if rest.empty?
502
+ return unless constant.is_a?(Module) && constant.name
503
+
504
+ "#{constant.name}::#{rest.join("::")}".safe_constantize
505
+ end
398
506
  end
399
507
 
400
508
  # Resolved once at load time rather than memoized, so no class-level state
@@ -117,25 +117,32 @@ module ViewComponent
117
117
  # The cache key for this rendering of the component.
118
118
  #
119
119
  # Combines the component's identity, its source digest, the requested
120
- # format and variant, the current locale, and the values declared with
121
- # `cache_on`. Override for full control.
120
+ # ordered formats, variants and partial lookup prefixes, the current locale,
121
+ # and the values declared with `cache_on`. Override for full control.
122
122
  #
123
123
  # @param view_context [ActionView::Base]
124
124
  # @return [String]
125
125
  def cache_key(view_context = nil)
126
126
  lookup_context = view_context&.lookup_context
127
- format = __vc_cache_format(lookup_context)
127
+ formats = Array(lookup_context&.formats).presence || [:html]
128
+ values = __vc_cache_on_values.map do |value|
129
+ value.nil? ? nil : ActiveSupport::Cache.expand_cache_key(value)
130
+ end
128
131
 
129
132
  parts = [
130
133
  "view_component",
131
134
  self.class.virtual_path,
132
- self.class.cache_digest(finder: lookup_context, format: format),
135
+ self.class.cache_digest(finder: lookup_context, format: formats.first),
133
136
  # Included in its own right, not just as a digest input: components that
134
137
  # render every format from one template have the same digest for each.
135
- format,
136
- __vc_cache_variant(lookup_context),
138
+ # Encode each list to keep format and variant boundaries distinct.
139
+ formats.to_json,
140
+ __vc_cache_variants(lookup_context).to_json,
141
+ Array(lookup_context&.prefixes).to_json,
137
142
  I18n.locale,
138
- *__vc_cache_on_values
143
+ # Expand records using Rails' versioned keys, then preserve the boundaries
144
+ # between declared values, including nils and strings containing slashes.
145
+ values.to_json
139
146
  ]
140
147
 
141
148
  # Positions are significant, so nils are substituted rather than removed.
@@ -175,11 +182,6 @@ module ViewComponent
175
182
  end
176
183
  end
177
184
 
178
- def __vc_render_uncached(view_context)
179
- @__vc_cache_bypassed = true
180
- render_in(view_context)
181
- end
182
-
183
185
  # Slots set by the caller via `with_*`. Checked before rendering, so slots
184
186
  # a component fills in for itself with a `default_*` method — which resolve
185
187
  # lazily during the render — aren't counted.
@@ -227,12 +229,8 @@ module ViewComponent
227
229
  end
228
230
  end
229
231
 
230
- def __vc_cache_format(lookup_context)
231
- Array(lookup_context&.formats).first || :html
232
- end
233
-
234
- def __vc_cache_variant(lookup_context)
235
- Array(lookup_context&.variants).first
232
+ def __vc_cache_variants(lookup_context)
233
+ Array(lookup_context&.variants)
236
234
  end
237
235
  end
238
236
  end
@@ -41,12 +41,12 @@ module ViewComponent
41
41
  def components
42
42
  component.__vc_validate_collection_parameter!(validate_default: true)
43
43
 
44
- iterator = ActionView::PartialIteration.new(@collection.size)
45
44
  collection_param = component.__vc_collection_parameter
46
45
  counter_present = component.__vc_counter_argument_present?
47
46
  counter_param = component.__vc_collection_counter_parameter if counter_present
48
47
  iteration_present = component.__vc_iteration_argument_present?
49
48
  iteration_param = component.__vc_collection_iteration_parameter if iteration_present
49
+ iterator = ActionView::PartialIteration.new(@collection.size) if counter_present || iteration_present
50
50
  item_options = @options.dup
51
51
 
52
52
  @collection.map do |item|
@@ -54,7 +54,7 @@ module ViewComponent
54
54
  item_options[counter_param] = iterator.index if counter_present
55
55
  item_options[iteration_param] = iterator.dup if iteration_present
56
56
  instance = component.new(**item_options)
57
- iterator.iterate!
57
+ iterator&.iterate!
58
58
  instance
59
59
  end
60
60
  end
@@ -74,16 +74,11 @@ module ViewComponent
74
74
  end
75
75
  end
76
76
 
77
- # Render the spacer through a fresh `dup` so a collection rendered multiple
78
- # times does not reuse (and trip the single-render guard on) the spacer
79
- # instance passed by the caller.
77
+ # Keep the caller's spacer unrendered, including its nested slot components.
80
78
  def rendered_spacer(view_context)
81
79
  return EMPTY_SPACER unless @spacer_component
82
80
 
83
- spacer = @spacer_component.dup
84
- if spacer.instance_variable_defined?(:@__vc_rendered)
85
- spacer.remove_instance_variable(:@__vc_rendered)
86
- end
81
+ spacer = @spacer_component.is_a?(Base) ? @spacer_component.__vc_dup_for_render : @spacer_component.dup
87
82
  spacer.render_in(view_context)
88
83
  end
89
84
  end
@@ -15,7 +15,7 @@ module ViewComponent
15
15
  end
16
16
 
17
17
  def compiled?(klass)
18
- cache.include? klass
18
+ @@cache.include? klass
19
19
  end
20
20
 
21
21
  def invalidate_class!(klass)
@@ -26,14 +26,33 @@ module ViewComponent
26
26
  cache.clear
27
27
  end
28
28
 
29
- # Sidecar files can't change while the application eager loads: within the
30
- # block, each component's are looked up once. Fiber storage rather than
31
- # module state, so the cache is gone once boot is done.
29
+ # Source files can't change while the application eager loads: share
30
+ # directory listings, sidecar lookups, and file contents within the block.
31
+ # Fiber storage keeps the caches from surviving boot.
32
32
  def with_sidecar_files_cache
33
33
  Fiber[:__vc_sidecar_files] = {}
34
+ Fiber[:__vc_sidecar_directories] = {}
35
+ Fiber[:__vc_source_files] = {}
36
+ Fiber[:__vc_path_parser] = ActionView::Resolver::PathParser.new
34
37
  yield
35
38
  ensure
36
39
  Fiber[:__vc_sidecar_files] = nil
40
+ Fiber[:__vc_sidecar_directories] = nil
41
+ Fiber[:__vc_source_files] = nil
42
+ Fiber[:__vc_path_parser] = nil
43
+ end
44
+
45
+ def path_parser
46
+ # Its regular expression depends on registered handlers, formats and locales.
47
+ # Reuse it during eager loading, but not across reloads.
48
+ Fiber[:__vc_path_parser] || ActionView::Resolver::PathParser.new
49
+ end
50
+
51
+ def read_file(path)
52
+ files = Fiber[:__vc_source_files]
53
+ return File.read(path) unless files
54
+
55
+ (files[path] ||= File.read(path).freeze).dup
37
56
  end
38
57
 
39
58
  def sidecar_files_for(klass, extensions)
@@ -44,13 +63,33 @@ module ViewComponent
44
63
  sidecar_files[extensions] ||= yield.freeze
45
64
  end
46
65
 
66
+ def sidecar_files_matching(pattern)
67
+ directories = Fiber[:__vc_sidecar_directories]
68
+ return Dir[pattern] unless directories
69
+
70
+ filename = File.basename(pattern)
71
+ prefix = filename.delete_suffix(".*")
72
+ return Dir[pattern] if prefix.match?(/[*?{}\[\]\\]/)
73
+
74
+ directory = File.dirname(pattern)
75
+ # Index the shared directory by component name instead of scanning all
76
+ # its entries again for every component and extension.
77
+ entries = directories[directory] ||= Dir.glob("#{directory}/*", File::FNM_DOTMATCH)
78
+ .group_by { |path| File.basename(path).split(".", 2).first }
79
+ candidates = entries.fetch(prefix.split(".", 2).first, [])
80
+ candidates.select { |path| File.fnmatch?(filename, File.basename(path)) }
81
+ end
82
+
47
83
  # Invalidates the cache when a template or a translation file is added,
48
84
  # changed, or removed. Changes to Ruby files reload the application instead.
49
85
  def template_watcher(file_watcher)
50
86
  extensions = ActionView::Template::Handlers.extensions.map(&:to_s) + %w[yml yaml]
51
87
  directories = ActiveSupport::Dependencies.autoload_paths.map(&:to_s).index_with(extensions)
52
88
 
53
- file_watcher.new([], directories) { invalidate! }
89
+ file_watcher.new([], directories) do
90
+ invalidate!
91
+ ActionView::LookupContext::DetailsKey.clear
92
+ end
54
93
  end
55
94
  end
56
95
  end
@@ -26,10 +26,14 @@ module ViewComponent
26
26
  # early exit
27
27
  return if compiled? && !force
28
28
 
29
+ reset_templates
30
+
29
31
  gather_templates
30
32
 
31
- if self.class.__vc_development_mode && @templates.any?(&:requires_compiled_superclass?)
33
+ if (self.class.__vc_development_mode || !@component.superclass.__vc_compiled?) && @templates.any?(&:requires_compiled_superclass?)
32
34
  @component.superclass.__vc_compile(raise_errors: raise_errors)
35
+ @templates = nil
36
+ gather_templates
33
37
  end
34
38
 
35
39
  if template_errors.present?
@@ -84,10 +88,31 @@ module ViewComponent
84
88
  gather_templates.map { |template| template.format || :html }.uniq
85
89
  end
86
90
 
91
+ # @private
92
+ def templates_for_call(method_name)
93
+ gather_templates.select { |template| template.call_method_name == method_name }
94
+ end
95
+
87
96
  private
88
97
 
89
98
  attr_reader :templates
90
99
 
100
+ def reset_templates
101
+ @templates&.each do |template|
102
+ next if template.inline_call?
103
+
104
+ call_method = template.call_method_name
105
+ safe_method = template.safe_method_name
106
+ next unless @component.method_defined?(call_method, false) && @component.method_defined?(safe_method, false)
107
+ next unless @component.instance_method(call_method) == @component.instance_method(safe_method)
108
+
109
+ @component.remove_method(call_method)
110
+ end
111
+
112
+ @templates = nil
113
+ @_template_errors = nil
114
+ end
115
+
91
116
  def define_render_template_for
92
117
  @templates.each do |template|
93
118
  template.compile_to_component
@@ -127,9 +152,18 @@ module ViewComponent
127
152
  end
128
153
  RUBY
129
154
  @component.define_method(:render_template_for, methods.instance_method(:render_template_for))
155
+
156
+ # Descendants retain these unbound methods for render_parent.
157
+ render_method = @component.instance_method(:render_template_for)
158
+ @component.descendants.each do |descendant|
159
+ ancestor_calls = descendant.instance_variable_get(:@__vc_ancestor_calls)
160
+ ancestor_calls&.map! { |call| (call.owner == @component) ? render_method : call }
161
+ end
130
162
  end
131
163
 
132
164
  def template_errors
165
+ return [] if @templates.one?
166
+
133
167
  @_template_errors ||= begin
134
168
  errors = []
135
169
 
@@ -200,7 +234,7 @@ module ViewComponent
200
234
  inline_template: @component.__vc_inline_template
201
235
  )]
202
236
  else
203
- path_parser = ActionView::Resolver::PathParser.new
237
+ path_parser = CompileCache.path_parser
204
238
  templates = @component.__vc_sidecar_files(
205
239
  ActionView::Template::Handlers.extensions
206
240
  ).map do |path|
@@ -215,6 +249,20 @@ module ViewComponent
215
249
  ).flat_map { |ancestor| ancestor.instance_methods(false).grep(/^call(_|$)/) }
216
250
  .uniq
217
251
  .each do |method_name|
252
+ unless component_instance_methods_on_self.include?(method_name)
253
+ owner = @component.instance_method(method_name).owner
254
+ inherited = owner.respond_to?(:__vc_compiler) ? owner.__vc_compiler.templates_for_call(method_name) : []
255
+ if inherited.any?
256
+ # A format and a variant can share a public call_* alias.
257
+ inherited.each do |template|
258
+ next if templates.any? { |own| own.variant == template.variant && own.format == template.format }
259
+
260
+ templates << Template::Inherited.new(component: @component, template: template)
261
+ end
262
+ next
263
+ end
264
+ end
265
+
218
266
  templates << Template::InlineCall.new(
219
267
  component: @component,
220
268
  method_name: method_name,