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.
- checksums.yaml +4 -4
- data/docs/CHANGELOG.md +94 -0
- data/lib/generators/view_component/abstract_generator.rb +24 -4
- data/lib/generators/view_component/component/component_generator.rb +8 -6
- data/lib/generators/view_component/component/templates/component.rb.tt +1 -1
- data/lib/generators/view_component/erb/erb_generator.rb +2 -2
- data/lib/generators/view_component/haml/haml_generator.rb +1 -1
- data/lib/generators/view_component/locale/locale_generator.rb +2 -6
- data/lib/generators/view_component/preview/preview_generator.rb +9 -6
- data/lib/generators/view_component/preview/templates/component_preview.rb.tt +2 -2
- data/lib/generators/view_component/rspec/rspec_generator.rb +1 -1
- data/lib/generators/view_component/rspec/templates/component_spec.rb.tt +1 -1
- data/lib/generators/view_component/slim/slim_generator.rb +1 -1
- data/lib/generators/view_component/stimulus/stimulus_generator.rb +3 -7
- data/lib/generators/view_component/test_unit/templates/component_test.rb.tt +2 -2
- data/lib/generators/view_component/test_unit/test_unit_generator.rb +8 -7
- data/lib/view_component/base.rb +106 -98
- data/lib/view_component/cache_digest/dependency_tracking.rb +6 -6
- data/lib/view_component/cache_digest/digestor_lookup.rb +3 -1
- data/lib/view_component/cache_digest/resolver.rb +59 -38
- data/lib/view_component/cache_digest.rb +161 -53
- data/lib/view_component/cacheable.rb +16 -18
- data/lib/view_component/collection.rb +4 -9
- data/lib/view_component/compile_cache.rb +44 -5
- data/lib/view_component/compiler.rb +50 -2
- data/lib/view_component/config.rb +114 -8
- data/lib/view_component/engine.rb +1 -2
- data/lib/view_component/errors.rb +16 -0
- data/lib/view_component/slot.rb +12 -0
- data/lib/view_component/slotable.rb +28 -4
- data/lib/view_component/template.rb +16 -3
- data/lib/view_component/test_helpers.rb +38 -16
- data/lib/view_component/translatable.rb +34 -0
- data/lib/view_component/version.rb +1 -1
- 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
|
-
|
|
133
|
-
|
|
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
|
|
148
|
-
# View
|
|
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
|
|
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
|
|
164
|
-
partials_rendered_by(
|
|
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:
|
|
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
|
-
#
|
|
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(
|
|
299
|
-
def partition_render_calls(program)
|
|
300
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
|
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
|
-
#
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
231
|
-
Array(lookup_context&.
|
|
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
|
|
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
|
-
#
|
|
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
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
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)
|
|
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 =
|
|
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,
|