view_component 4.21.0 → 4.22.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 829de474217e06e2148d92de68ef3d93dd8a31e314b40073fc292440508731b8
4
- data.tar.gz: 1dfb4a41b58c47814b115b72e6cfc736e3239886376632e7177cadfa024c8582
3
+ metadata.gz: 347185365d72f04ae47e10751bdaafc79ca1b92a5cc60659464cd91ea3fe5b1d
4
+ data.tar.gz: 8cc35ca70babcf15504c6cf3329357045b92cf5bce0b7059a376b465b9d82b3a
5
5
  SHA512:
6
- metadata.gz: 644364f5c6e4375b130074078de4e692dfdc4fca34ceca1bfedd307a135cfe9bd8ed81b111f08e1222af4d334afcb81fee48ee50a9b92bc21df591f783230314
7
- data.tar.gz: d7902711e70321bc27f0b4b9bbf5dbd7f75864444dcaacf4bdfc762c765c87f688463b9f65edcf01656aea723fc82baf421edcf6f35989ce60c9875ebb91c699
6
+ metadata.gz: 1f65ef1db7168f46ff4c0e41bcd7d329e30f5da9cfb05b06500c67686eaef0705b9b26433f644b39138c79ecf5d904176451c511f8dc48438cae9d9d6cbe81f8
7
+ data.tar.gz: 5b8df5a1f324a0fb481da6c822333e08e0bd424535310d851634da151702cb50a6a92d56be23f49f573c66aa5b9f9b8137a288969d85f4d5cec47ea59c114cca
data/docs/CHANGELOG.md CHANGED
@@ -10,6 +10,32 @@ nav_order: 6
10
10
 
11
11
  ## main
12
12
 
13
+ ## 4.22.0
14
+
15
+ * Comment on release pull requests with benchmark results compared to the previous release.
16
+
17
+ *Joel Hawksley*
18
+
19
+ * Reduce source-file reads and dependency-scanning allocations during cold cache digests and boot-time precomputation.
20
+
21
+ *Joel Hawksley*
22
+
23
+ * Share sidecar directory listings and source reads during cold cache digests, and hash shared Ruby and sidecar files once per digest computation or boot-time precomputation.
24
+
25
+ *Joel Hawksley*
26
+
27
+ * Avoid missing-template digest errors for unresolvable interpolated render paths in components and included helpers.
28
+
29
+ *Joel Hawksley*
30
+
31
+ * Cover shared helper dependencies in the boot benchmark and add a cold cache digest benchmark to the CI performance check.
32
+
33
+ *Joel Hawksley*
34
+
35
+ * Speed up cache digests of components that render shared dependencies, such as a helper module included by most components. The Digestor looks a template up once per edge of the tree rather than once per component, so each synthetic template was rebuilt, and its sources re-parsed, for every place it was rendered from, and every component digested at boot rebuilt the tree of everything it renders. Templates, dependencies and parsed sources are now shared within one digest computation and across boot-time precomputation, and dependencies whose digest is already known are no longer walked again. On an app with 1,547 components, one cold digest drops from 70 s to 0.5 s and boot-time precomputation from 144 s to 13 s, with identical digests.
36
+
37
+ *Erik Axel Nielsen*
38
+
13
39
  ## 4.21.0
14
40
 
15
41
  * Avoid `NameError` during eager loading when components include modules with temporary names.
@@ -667,10 +667,10 @@ module ViewComponent
667
667
  (sidecar_files - [identifier] + sidecar_directory_files + nested_component_files).uniq
668
668
  end
669
669
 
670
- # `sidecar_files`, looked up once per component while eager loading:
671
- # every component looks up its superclasses' templates and translations
672
- # too, when it compiles, loads its translations, and computes its cache
673
- # digest.
670
+ # `sidecar_files`, looked up once per component during eager loading or a
671
+ # digest computation: every component looks up its superclasses' templates
672
+ # and translations too, when it compiles, loads its translations, and
673
+ # computes its cache digest.
674
674
  #
675
675
  # @private
676
676
  def __vc_sidecar_files(extensions)
@@ -14,27 +14,33 @@ module ViewComponent
14
14
  #
15
15
  # @private
16
16
  module DependencyTracking
17
+ # Templates are memoized during a digest, so a template reached along
18
+ # many edges of the tree is scanned once.
17
19
  def find_dependencies(name, template, view_paths = nil)
18
- dependencies = super
19
- source = template.source
20
-
21
- # `# Template Dependency: SomeComponent` names a class, which Rails
22
- # would resolve as a template path and never find. Swap it for the path
23
- # the component is digested under, so the declaration resolves instead
24
- # of becoming a missing node.
25
- CacheDigest.explicit_component_dependencies(source).each do |declared, virtual_path|
26
- dependencies = dependencies - [declared] + [virtual_path]
20
+ CacheDigest.memoize([:dependencies, name, template, view_paths]) do
21
+ dependencies = super
22
+ source = template.source
23
+
24
+ # `# Template Dependency: SomeComponent` names a class, which Rails
25
+ # would resolve as a template path and never find. Swap it for the path
26
+ # the component is digested under, so the declaration resolves instead
27
+ # of becoming a missing node.
28
+ CacheDigest.explicit_component_dependencies(source).each do |declared, virtual_path|
29
+ dependencies = dependencies - [declared] + [virtual_path]
30
+ end
31
+
32
+ # Synthetic templates already declare every dependency found in their sources.
33
+ next dependencies if name.start_with?("#{CacheDigest::VIRTUAL_PATH_PREFIX}/")
34
+ next dependencies unless CacheDigest.template_dependencies?(source, template.handler)
35
+
36
+ component_paths, inferred_partials =
37
+ CacheDigest.template_renders_in(CacheDigest.compiled_source(template), name, view_paths: view_paths)
38
+ # Older ERB trackers omit the underscore on unqualified partial paths.
39
+ inferred_partials |= inferred_partials.map { |path| path.delete_prefix("_") }
40
+ declared = source.scan(CacheDigest::EXPLICIT_DEPENDENCY).flatten
41
+
42
+ (dependencies - (inferred_partials - declared) + component_paths).uniq
27
43
  end
28
-
29
- # Synthetic templates already declare every dependency found in their sources.
30
- return dependencies if name.start_with?("#{CacheDigest::VIRTUAL_PATH_PREFIX}/")
31
- return dependencies unless CacheDigest.template_dependencies?(source, template.handler)
32
-
33
- component_paths, inferred_partials =
34
- CacheDigest.template_renders_in(CacheDigest.compiled_source(template), name, view_paths: view_paths)
35
- declared = source.scan(CacheDigest::EXPLICIT_DEPENDENCY).flatten
36
-
37
- (dependencies - (inferred_partials - declared) + component_paths).uniq
38
44
  end
39
45
 
40
46
  # @private
@@ -21,12 +21,56 @@ module ViewComponent
21
21
  digestor.prepend(self)
22
22
  end
23
23
 
24
+ def digest(finder:, **)
25
+ CacheDigest.memoizing do
26
+ previous = CacheDigest.memo[:pruned]
27
+ loop do
28
+ pruned = CacheDigest.memo[:pruned] = {}
29
+ digest = super
30
+
31
+ # A component that registered while the tree was built expired the
32
+ # digests the pruned nodes stood in for, so this one, and the
33
+ # digests computed for them since, are missing their dependencies.
34
+ break digest if pruned.all? { |name, cached| finder.digest_cache[name].equal?(cached) }
35
+
36
+ finder.digest_cache.clear
37
+ end
38
+ ensure
39
+ CacheDigest.memo[:pruned] = previous
40
+ end
41
+ end
42
+
43
+ # A dependency whose digest is cached already becomes a leaf:
44
+ # `Node#dependency_digest` reads the cache and never walks its children.
45
+ # Without this, every component digested at boot rebuilds the tree of
46
+ # everything it renders, which for components rendering a shared helper
47
+ # module is most of the application.
48
+ def tree(name, finder, partial = false, seen = {})
49
+ CompileCache.with_sidecar_files_cache do
50
+ pruned = CacheDigest.memo&.fetch(:pruned, nil)
51
+ cached = finder.digest_cache[name] if pruned && partial
52
+ next super unless cached
53
+
54
+ path = ActionView::TemplatePath.parse(name)
55
+ template = find_template(finder, path.name, [path.prefix], partial, [])
56
+ next super unless template
57
+
58
+ pruned[name] = cached
59
+ seen[template.identifier] ||= ActionView::Digestor::Node.create(name, name.gsub(%r{/_}, "/"), template, partial)
60
+ end
61
+ end
62
+
24
63
  private
25
64
 
26
65
  def find_template(finder, name, prefixes, partial, keys)
27
- Resolver.instance.find_templates(
28
- name, prefixes.first, partial, {formats: finder.formats}, view_paths: finder.view_paths
29
- ).first || super
66
+ key = [:template, name, prefixes.first, partial, finder.formats, finder.view_paths]
67
+ template = CacheDigest.memoize(key) do
68
+ Resolver.instance.find_templates(
69
+ name, prefixes.first, partial, {formats: finder.formats}, view_paths: finder.view_paths
70
+ ).first
71
+ end
72
+
73
+ template || super
30
74
  end
31
75
  end
32
76
  end
@@ -160,8 +160,10 @@ module ViewComponent
160
160
  end
161
161
 
162
162
  def ruby_files(component)
163
- component_ancestors(component).map(&:identifier).concat(module_files(component))
164
- .compact.uniq.select { |path| ::File.exist?(path) }
163
+ CacheDigest.memoize([:ruby_files, component]) do
164
+ component_ancestors(component).map(&:identifier).concat(module_files(component))
165
+ .compact.uniq.select { |path| ::File.exist?(path) }
166
+ end
165
167
  end
166
168
 
167
169
  # Sidecar template files plus inline templates, which live in the Ruby
@@ -243,7 +245,16 @@ module ViewComponent
243
245
  end
244
246
 
245
247
  def file_digest(path)
246
- ActiveSupport::Digest.hexdigest(::File.binread(path))
248
+ CacheDigest.memoize([:file_digest, path]) do
249
+ # Reuse source reads unless text mode can transcode or translate newlines.
250
+ source = if ::File::ALT_SEPARATOR.nil? &&
251
+ (Encoding.default_internal.nil? || Encoding.default_internal == Encoding.default_external)
252
+ CompileCache.read_file(path)
253
+ else
254
+ ::File.binread(path)
255
+ end
256
+ ActiveSupport::Digest.hexdigest(source)
257
+ end
247
258
  end
248
259
 
249
260
  # Built once at load time rather than memoized, so no class-level state
@@ -43,7 +43,8 @@ module ViewComponent
43
43
  RENDER_METHODS = %i[render render_to_string].freeze
44
44
 
45
45
  COMPILED_SOURCES = :view_component_cache_digest_compiled_sources
46
- private_constant :COMPILED_SOURCES
46
+ MEMO = :view_component_cache_digest_memo
47
+ private_constant :COMPILED_SOURCES, :MEMO
47
48
 
48
49
  # Rails' escape hatch for dependencies static analysis can't see.
49
50
  EXPLICIT_DEPENDENCY = /#\s*Template Dependency:\s*(\S+)/
@@ -157,9 +158,13 @@ module ViewComponent
157
158
  # @return [Array(Array<String>, Array<String>)] synthetic virtual paths of
158
159
  # components and partial virtual paths
159
160
  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]
161
+ # Shared by every component that includes a module or inherits from
162
+ # the same superclass, so it's scanned once per digest computation.
163
+ memoize([:renders_in, source, name, view_paths, nesting]) do
164
+ components, _component_calls, other_calls = partition_render_calls(Prism.parse(source).value, nesting)
165
+ partials = partials_rendered_by(other_calls, name) - unresolved_partials_rendered_by(other_calls, name, view_paths)
166
+ [component_paths(components), partials]
167
+ end
163
168
  end
164
169
 
165
170
  # Find the components Ruby source renders, along with partial guesses
@@ -280,6 +285,40 @@ module ViewComponent
280
285
  Thread.current[COMPILED_SOURCES] = nil
281
286
  end
282
287
 
288
+ # Share synthetic templates between every lookup in the block.
289
+ #
290
+ # The Digestor looks a template up once per edge of the tree, before it
291
+ # checks whether it has seen the template already. Action View's own
292
+ # resolvers cache their templates, but building a synthetic one reads and
293
+ # parses the component's sources, so a component rendered from many
294
+ # places was rebuilt once per place. Kept for one digest computation
295
+ # only, so a source edited between requests is still seen.
296
+ #
297
+ # @private
298
+ def memoizing
299
+ return yield if Thread.current[MEMO]
300
+
301
+ begin
302
+ Thread.current[MEMO] = {}
303
+ yield
304
+ ensure
305
+ Thread.current[MEMO] = nil
306
+ end
307
+ end
308
+
309
+ # @private
310
+ def memo
311
+ Thread.current[MEMO]
312
+ end
313
+
314
+ # @private
315
+ def memoize(key)
316
+ memo = Thread.current[MEMO]
317
+ return yield unless memo
318
+
319
+ memo.fetch(key) { memo[key] = yield }
320
+ end
321
+
283
322
  # @private
284
323
  def compiled(handler, source, compiled_source)
285
324
  sources = Thread.current[COMPILED_SOURCES]
@@ -300,16 +339,18 @@ module ViewComponent
300
339
  # digest, so registering midway would discard digests already warmed.
301
340
  components.each { |component| register(component) }
302
341
 
303
- components.each do |component|
304
- component.__vc_cache_digest_source = Resolver.instance.source_for(component).freeze
305
- end
342
+ memoizing do
343
+ components.each do |component|
344
+ component.__vc_cache_digest_source = Resolver.instance.source_for(component).freeze
345
+ end
306
346
 
307
- # Warms the same digest cache requests read, since a lookup context's
308
- # cache is shared by every context with the same details.
309
- components.each do |component|
310
- component.__vc_template_formats.each do |format|
311
- finder = ActionView::LookupContext.new(ActionController::Base.view_paths, formats: [format])
312
- component.cache_digest(finder: finder, format: format)
347
+ # Warms the same digest cache requests read, since a lookup context's
348
+ # cache is shared by every context with the same details.
349
+ components.each do |component|
350
+ component.__vc_template_formats.each do |format|
351
+ finder = ActionView::LookupContext.new(ActionController::Base.view_paths, formats: [format])
352
+ component.cache_digest(finder: finder, format: format)
353
+ end
313
354
  end
314
355
  end
315
356
  end
@@ -327,7 +368,8 @@ module ViewComponent
327
368
 
328
369
  each_node(program, nesting) do |node, scope|
329
370
  if node.is_a?(Prism::ConstantReadNode) || node.is_a?(Prism::ConstantPathNode)
330
- names << [node.slice, scope] if CONSTANT_NAME.match?(node.slice)
371
+ name = node.slice
372
+ names << [name, scope] if CONSTANT_NAME.match?(name)
331
373
  elsif render_call?(node)
332
374
  if rendered_component(node, scope)
333
375
  component_calls << node
@@ -348,9 +390,10 @@ module ViewComponent
348
390
  # Class/module bodies add one lexical scope, even for qualified names:
349
391
  # `class UI::Card` adds Card, not UI, to Module.nesting.
350
392
  def each_node(program, nesting = [])
351
- nodes = [[program, nesting]]
352
- while (entry = nodes.pop)
353
- node, scope = entry
393
+ # Keep scopes and nodes in one stack rather than allocating a pair per node.
394
+ nodes = [nesting, program]
395
+ while (node = nodes.pop)
396
+ scope = nodes.pop
354
397
  yield node, scope
355
398
 
356
399
  body = nil
@@ -362,7 +405,7 @@ module ViewComponent
362
405
  end
363
406
 
364
407
  node.compact_child_nodes.reverse_each do |child|
365
- nodes << [child, child.equal?(body) ? body_scope : scope]
408
+ nodes.push(child.equal?(body) ? body_scope : scope, child)
366
409
  end
367
410
  end
368
411
  end
@@ -416,20 +459,29 @@ module ViewComponent
416
459
  # Dynamic renders can return components without naming their classes,
417
460
  # such as `render UI.button`. Keep Action View's guess only if it resolves,
418
461
  # without executing application methods to discover what they return.
462
+ # Interpolated paths are discarded if Rails cannot expand their wildcards.
419
463
  def unresolved_partials_rendered_by(calls, name, view_paths)
420
- return [] unless view_paths
421
-
422
464
  targets = calls.flat_map { |call| render_targets(call) }
423
465
  literal, dynamic = targets.partition do |target|
424
466
  target.is_a?(Prism::StringNode) || target.is_a?(Prism::InterpolatedStringNode)
425
467
  end
426
- return [] if dynamic.empty?
468
+ interpolated = literal.grep(Prism::InterpolatedStringNode)
469
+ unresolved = []
470
+ if interpolated.any?
471
+ paths = partial_paths_in(interpolated.map { |target| "render(#{target.slice})" }.join("\n"), name)
472
+ # Rails expands only directory wildcards with a literal prefix.
473
+ # Older parsers can return truncated paths without any wildcard.
474
+ unresolved = paths.reject { |path| path.match?(%r{\A[^*]+/\*\z}) }
475
+ static = literal.grep(Prism::StringNode)
476
+ unresolved -= partial_paths_in(static.map { |target| "render(#{target.slice})" }.join("\n"), name)
477
+ end
478
+ return unresolved unless view_paths && dynamic.any?
427
479
 
428
480
  inferred = partial_paths_in(dynamic.map { |target| "render(#{target.slice})" }.join("\n"), name)
429
481
  declared = partial_paths_in(literal.map { |target| "render(#{target.slice})" }.join("\n"), name)
430
482
  finder = ActionView::LookupContext.new(view_paths, variants: :any, locale: I18n.available_locales)
431
483
 
432
- inferred.reject do |path|
484
+ unresolved | inferred.reject do |path|
433
485
  declared.include?(path) || finder.exists?(path, [], true)
434
486
  end
435
487
  end
@@ -26,25 +26,28 @@ module ViewComponent
26
26
  cache.clear
27
27
  end
28
28
 
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.
29
+ # Share directory listings, sidecar lookups, and file contents within one
30
+ # eager load or digest computation, but never across reloads.
32
31
  def with_sidecar_files_cache
33
- Fiber[:__vc_sidecar_files] = {}
34
- Fiber[:__vc_sidecar_directories] = {}
35
- Fiber[:__vc_source_files] = {}
36
- Fiber[:__vc_path_parser] = ActionView::Resolver::PathParser.new
37
- yield
38
- ensure
39
- Fiber[:__vc_sidecar_files] = nil
40
- Fiber[:__vc_sidecar_directories] = nil
41
- Fiber[:__vc_source_files] = nil
42
- Fiber[:__vc_path_parser] = nil
32
+ return yield if Fiber[:__vc_sidecar_files]
33
+
34
+ begin
35
+ Fiber[:__vc_sidecar_files] = {}
36
+ Fiber[:__vc_sidecar_directories] = {}
37
+ Fiber[:__vc_source_files] = {}
38
+ Fiber[:__vc_path_parser] = ActionView::Resolver::PathParser.new
39
+ yield
40
+ ensure
41
+ Fiber[:__vc_sidecar_files] = nil
42
+ Fiber[:__vc_sidecar_directories] = nil
43
+ Fiber[:__vc_source_files] = nil
44
+ Fiber[:__vc_path_parser] = nil
45
+ end
43
46
  end
44
47
 
45
48
  def path_parser
46
49
  # Its regular expression depends on registered handlers, formats and locales.
47
- # Reuse it during eager loading, but not across reloads.
50
+ # Reuse it during eager loading or a digest computation, but not across reloads.
48
51
  Fiber[:__vc_path_parser] || ActionView::Resolver::PathParser.new
49
52
  end
50
53
 
@@ -3,7 +3,7 @@
3
3
  module ViewComponent
4
4
  module VERSION
5
5
  MAJOR = 4
6
- MINOR = 21
6
+ MINOR = 22
7
7
  PATCH = 0
8
8
  PRE = nil
9
9
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: view_component
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.21.0
4
+ version: 4.22.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ViewComponent Team