view_component 4.19.0 → 4.20.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: d29069cd53a93e2e3b1e2285612ecd25985bae4458a842227922a1a5bdb18b96
4
- data.tar.gz: 6063a6b520fc62961f61e36b30bbbf47eaf5728580a61a5af68231b11c5f7dc4
3
+ metadata.gz: 04ae333990353bff3c4671c78db517fbe53ddcb15d9bfeb4448bc2d52c710f5b
4
+ data.tar.gz: ef958403f0326485a5f2035a9d744c878197c041247df3f76e68cf2712b9a652
5
5
  SHA512:
6
- metadata.gz: 2741959ff497e91a4fd792959667ad0e6ca57f3378e460a8ec6db74c013b09acf4eeb2bfbcbfbfab9b81f92613c29107562c838502b61b5a3b8503e4c75978ed
7
- data.tar.gz: 8b54025874d993b7fe3b61f565bff74c463ac683f66b0427b05cc3e5791ec1d4b526fd9807010669e641203505c0d0e58376ffab3c6d96e2a1e68bc746e1e4cc
6
+ metadata.gz: 68e6ae32b57a9c1323ed64eb82c866e9595f2aeca16009b532f4922e85abe72536b06dc61d75328a202faef438f57e4d9deebef8ace06c96a3c7d609ad946a38
7
+ data.tar.gz: 52ef898174f4270dbc4bdcc91c0d841d35fe12c04c8b46e0639b0b1cb1a43b2789d24174cf70f2bc4ee3718eae536448b24833247cdd9a0f891c33678186060f
@@ -0,0 +1 @@
1
+ <%= render MultipartMailerComponent.new %>
@@ -0,0 +1 @@
1
+ <%= render MultipartMailerComponent.new %>
data/docs/CHANGELOG.md CHANGED
@@ -10,6 +10,56 @@ nav_order: 6
10
10
 
11
11
  ## main
12
12
 
13
+ ## 4.20.0
14
+
15
+ * Fix `invalid byte sequence in UTF-8` errors when precomputing cache digests for components loaded from encrypted or binary source files.
16
+
17
+ *Joel Junström*
18
+
19
+ * Fix cache digests missing components rendered through a slot declared with the component's class, through a method or variable that holds the component, or through a module the component includes. Any constant naming a component in a component's Ruby or templates now counts as a dependency, and included modules are digested like superclasses.
20
+
21
+ *Erik Axel Nielsen*
22
+
23
+ * Add an informational CI check that runs added and modified tests without a pull request's implementation changes to identify regression coverage.
24
+
25
+ *Joel Hawksley*
26
+
27
+ * Speed up rendering with direct compiled-template calls and avoid intermediate method allocations when invoking lambda-backed slots.
28
+
29
+ *Joel Hawksley*
30
+
31
+ * Fix `Couldn't find template for digesting` errors when computing cache digests for templates that render components. Action View inferred a partial from `render SomeComponent.new`, so partials inferred from component renders are now discarded. Component renders are found with Prism rather than a regular expression, adding a dependency on the `prism` gem.
32
+
33
+ *Joel Hawksley*
34
+
35
+ * Fix components rendering the template for the first format a lookup context rendered in, instead of the current one. An Action Mailer email that declares `format.text` before `format.html` rendered the text template of each component into its HTML part. Components now reuse the details key Action View memoizes, which Action View resets whenever a detail such as `formats` changes. Regression from 4.16.0.
36
+
37
+ *Jan Schilbach*
38
+
39
+ * Look up each component's sidecar templates and translations once while eager loading, instead of again when it compiles, loads its translations, and computes its cache digest, and for each of its subclasses. Time spent in ViewComponent during the boot benchmark drops by about a quarter.
40
+
41
+ *Clément Joubert*
42
+
43
+ * Measure cache digest precomputation in the boot benchmark by disabling class reloading in the benchmark app and having each generated component render another.
44
+
45
+ *Joel Hawksley*
46
+
47
+ * Add a CI performance check that fails a pull request when any benchmark scenario's component/partial render-time ratio, or ViewComponent's share of app boot time, regresses by more than 5% against the base branch on Rails 8.1 or Rails main.
48
+
49
+ *Joel Hawksley*
50
+
51
+ * Fail the test suite when a gem in the Gemfile or gemspec isn't loaded.
52
+
53
+ *Joel Hawksley*
54
+
55
+ * Remove the unused `selenium-webdriver` and `tailwindcss-rails` development dependencies.
56
+
57
+ *Joel Hawksley*
58
+
59
+ * Speed up `sidecar_files`, which 4.18's cache digest precomputation calls for every component and superclass at boot, by globbing each directory once instead of once per extension. On an app with 302 components, `compile.view_component` drops from 2.3 s to 1.2 s.
60
+
61
+ *Clément Joubert*
62
+
13
63
  ## 4.19.0
14
64
 
15
65
  * Remove the unused `concurrent-ruby` dependency. `require "concurrent-ruby"` loaded every concurrent-ruby class at boot, a leftover from the `Concurrent::ReadWriteLock` the compiler no longer uses.
@@ -624,7 +624,10 @@ module ViewComponent
624
624
  return [] unless identifier
625
625
  return [] unless name
626
626
 
627
- extensions = extensions.join(",")
627
+ # One glob per directory, filtered by extension, rather than a brace
628
+ # pattern per extension: Dir expands braces into one directory listing
629
+ # per extension.
630
+ extensions = extensions.map(&:to_s)
628
631
 
629
632
  # view files in a directory named like the component
630
633
  directory = File.dirname(identifier)
@@ -643,19 +646,29 @@ module ViewComponent
643
646
  # Without this, `MyOtherComponent` will not look for `my_component/my_other_component.html.erb`
644
647
  nested_component_files =
645
648
  if name.include?("::") && component_name != filename
646
- Dir["#{directory}/#{filename}/#{component_name}.*{#{extensions}}"]
649
+ __vc_files_ending_with(extensions, "#{directory}/#{filename}/#{component_name}.*")
647
650
  else
648
651
  []
649
652
  end
650
653
 
651
654
  # view files in the same directory as the component
652
- sidecar_files = Dir["#{directory}/#{component_name}.*{#{extensions}}"]
655
+ sidecar_files = __vc_files_ending_with(extensions, "#{directory}/#{component_name}.*")
653
656
 
654
- sidecar_directory_files = Dir["#{directory}/#{component_name}/#{filename}.*{#{extensions}}"]
657
+ sidecar_directory_files = __vc_files_ending_with(extensions, "#{directory}/#{component_name}/#{filename}.*")
655
658
 
656
659
  (sidecar_files - [identifier] + sidecar_directory_files + nested_component_files).uniq
657
660
  end
658
661
 
662
+ # `sidecar_files`, looked up once per component while eager loading:
663
+ # every component looks up its superclasses' templates and translations
664
+ # too, when it compiles, loads its translations, and computes its cache
665
+ # digest.
666
+ #
667
+ # @private
668
+ def __vc_sidecar_files(extensions)
669
+ CompileCache.sidecar_files_for(self, extensions) { sidecar_files(extensions) }
670
+ end
671
+
659
672
  # Render a component for each element in a collection ([documentation](/guide/collections)):
660
673
  #
661
674
  # ```ruby
@@ -901,6 +914,11 @@ module ViewComponent
901
914
  signatures
902
915
  end
903
916
 
917
+ def __vc_files_ending_with(extensions, pattern)
918
+ paths = Dir[pattern]
919
+ extensions.flat_map { |extension| paths.select { |path| path.end_with?(extension) } }
920
+ end
921
+
904
922
  attr_reader :__vc_provided_collection_parameter
905
923
  end
906
924
 
@@ -26,7 +26,15 @@ module ViewComponent
26
26
  dependencies = dependencies - [declared] + [virtual_path]
27
27
  end
28
28
 
29
- dependencies + CacheDigest.dependencies_in(template)
29
+ # Swap the partials Action View wrongly infers from component renders,
30
+ # such as `news/new` from `render ChildComponent.new`, for the
31
+ # components themselves.
32
+ return dependencies unless CacheDigest::CONSTANT_RENDER.match?(source)
33
+
34
+ component_paths, inferred_partials =
35
+ CacheDigest.component_renders_in(CacheDigest.compiled_source(template), name)
36
+
37
+ (dependencies - inferred_partials + component_paths).uniq
30
38
  end
31
39
 
32
40
  # @private
@@ -18,6 +18,10 @@ module ViewComponent
18
18
  # Extensions whose contents are hashed into the synthetic source.
19
19
  SIDECAR_EXTENSIONS = %w[yml yaml].freeze
20
20
 
21
+ # Where ViewComponent's own modules, such as the ones it includes into
22
+ # every component, are defined.
23
+ VIEW_COMPONENT_LIB = "#{::File.expand_path("..", __dir__)}/".freeze
24
+
21
25
  class << self
22
26
  def instance
23
27
  INSTANCE
@@ -49,30 +53,33 @@ module ViewComponent
49
53
  # @return [String]
50
54
  def source_for(component)
51
55
  parts = []
56
+ templates = template_sources(component)
52
57
 
53
58
  # Safety net: this template should never be rendered, only digested.
54
59
  parts << "<% raise ViewComponent::CacheDigestTemplateError.new(#{component.name.inspect}) %>"
55
60
 
56
61
  # Content hashes of the Ruby files and sidecar files backing the
57
- # component and its component superclasses. Hashing rather than
58
- # inlining keeps the source small and avoids embedding Ruby that a
59
- # tracker might misread as a render call.
62
+ # component, its component superclasses, and the modules they include.
63
+ # Hashing rather than inlining keeps the source small and avoids
64
+ # embedding Ruby that a tracker might misread as a render call.
60
65
  source_files(component).each do |path|
61
66
  parts << "<%# Resolved Dependency: #{path} #{file_digest(path)} %>"
62
67
  end
63
68
 
64
- # Everything the component renders from Ruby rather than from a
65
- # template: `# Template Dependency:` declarations, components rendered
66
- # from `#call` methods, and partials referenced by string path.
67
- # Re-emitted so the Digestor resolves them as tree nodes.
68
- ruby_dependencies(component).each do |dependency|
69
+ # Everything the component renders: `# Template Dependency:`
70
+ # declarations, the components its slots are declared with, and the
71
+ # components and partials referenced from its templates, in any
72
+ # language, and from its Ruby. Emitted as declarations so the Digestor
73
+ # resolves them as tree nodes without trackers having to find them in
74
+ # the template sources, where they'd also infer partials from component
75
+ # renders.
76
+ dependencies(component, templates).each do |dependency|
69
77
  parts << "<%# Template Dependency: #{dependency} %>"
70
78
  end
71
79
 
72
- # Template sources verbatim, so trackers can discover the partials and
73
- # components they render.
74
- template_sources(component).each do |source|
75
- parts << source
80
+ # Content hashes of the templates, including inline ones.
81
+ templates.each do |source, _extension|
82
+ parts << "<%# Template Digest: #{ActiveSupport::Digest.hexdigest(source)} %>"
76
83
  end
77
84
 
78
85
  parts.join("\n")
@@ -102,34 +109,55 @@ module ViewComponent
102
109
  end
103
110
  end
104
111
 
112
+ # Modules the component and its component superclasses include, such as
113
+ # a module of helpers that render other components. Modules included
114
+ # into `ViewComponent::Base` and above are left out, as superclasses
115
+ # are, and so are the modules ViewComponent adds to every component.
116
+ #
117
+ # @return [Array<String>] the files the modules are defined in
118
+ def module_files(component)
119
+ component.ancestors.take_while { |ancestor| ancestor != ViewComponent::Base }.filter_map { |ancestor|
120
+ next if ancestor.is_a?(Class) || ancestor.name.nil?
121
+
122
+ path, _line = Object.const_source_location(ancestor.name)
123
+ path unless path.nil? || path.start_with?(VIEW_COMPONENT_LIB)
124
+ }.uniq
125
+ end
126
+
105
127
  def source_files(component)
106
- component_ancestors(component).flat_map { |ancestor|
107
- [ancestor.identifier, *ancestor.sidecar_files(SIDECAR_EXTENSIONS)]
108
- }.compact.uniq.select { |path| ::File.exist?(path) }
128
+ sidecars = component_ancestors(component).flat_map { |ancestor| ancestor.__vc_sidecar_files(SIDECAR_EXTENSIONS) }
129
+ (ruby_files(component) + sidecars).uniq.select { |path| ::File.exist?(path) }
130
+ end
131
+
132
+ def ruby_files(component)
133
+ component_ancestors(component).map(&:identifier).concat(module_files(component))
134
+ .compact.uniq.select { |path| ::File.exist?(path) }
109
135
  end
110
136
 
111
137
  def template_files(component)
112
138
  component_ancestors(component)
113
- .flat_map { |ancestor| ancestor.sidecar_files(ActionView::Template::Handlers.extensions) }
139
+ .flat_map { |ancestor| ancestor.__vc_sidecar_files(ActionView::Template::Handlers.extensions) }
114
140
  .uniq
115
141
  .select { |path| ::File.exist?(path) }
116
142
  end
117
143
 
118
144
  # Sidecar template files plus inline templates, which live in the Ruby
119
145
  # file and so are invisible to Action View's trackers.
146
+ #
147
+ # @return [Array<Array(String, String)>] pairs of source and extension
120
148
  def template_sources(component)
121
- sources = template_files(component).map { |path| ::File.read(path) }
149
+ sources = template_files(component).map { |path| [::File.read(path), ::File.extname(path).delete_prefix(".")] }
122
150
 
123
151
  component_ancestors(component).each do |ancestor|
124
152
  inline_template = ancestor.__vc_inline_template
125
- sources << inline_template.source if inline_template
153
+ sources << [inline_template.source, inline_template.language.to_s] if inline_template
126
154
  end
127
155
 
128
156
  sources.uniq
129
157
  end
130
158
 
131
- def explicit_dependencies(component)
132
- ruby_sources(component).flat_map { |source|
159
+ def explicit_dependencies(sources)
160
+ sources.flat_map { |source|
133
161
  declared = source.scan(CacheDigest::EXPLICIT_DEPENDENCY).flatten
134
162
 
135
163
  # Component class names are translated to the path they're digested
@@ -142,29 +170,59 @@ module ViewComponent
142
170
  }.uniq
143
171
  end
144
172
 
145
- # Everything a component renders from Ruby code rather than from a
146
- # template. Action View's trackers only read templates, so a `#call`
147
- # method that renders another component or a partial would otherwise go
148
- # unnoticed.
149
- def ruby_dependencies(component)
173
+ # Everything but the component and its superclasses, which a component
174
+ # references in its own source but are digested as part of it already.
175
+ def dependencies(component, templates)
150
176
  virtual_path = CacheDigest.virtual_path_for(component)
177
+ ruby = ruby_sources(component)
178
+ own_paths = component_ancestors(component).filter_map { |ancestor| CacheDigest.virtual_path_for(ancestor) }
151
179
 
152
- explicit_dependencies(component) |
153
- ruby_sources(component).flat_map { |source|
154
- CacheDigest.component_paths_in(source) |
155
- CacheDigest.partial_paths_in(source, virtual_path)
156
- }.uniq
180
+ dependencies = explicit_dependencies(ruby + templates.map(&:first)) |
181
+ ruby.flat_map { |source| ruby_dependencies(source, virtual_path) } |
182
+ CacheDigest.slot_renders_of(component) |
183
+ templates.flat_map { |source, extension| template_dependencies(component, source, extension, virtual_path) }
184
+
185
+ dependencies - own_paths
186
+ end
187
+
188
+ # Components and partials rendered from Ruby, such as a `#call` method.
189
+ # Partials are narrowed to paths that appear in the source, discarding
190
+ # the speculative `things/thing` Action View infers from dynamic renders
191
+ # like `render @thing`, which would resolve to nothing.
192
+ def ruby_dependencies(source, virtual_path)
193
+ components, partials = CacheDigest.renders_in(source, virtual_path)
194
+
195
+ components | partials.select { |path|
196
+ source.include?(path) || source.include?(path.sub(%r{[^/]+\z}, '_\0'))
197
+ }
198
+ end
199
+
200
+ def template_dependencies(component, source, extension, virtual_path)
201
+ return [] unless source.include?("render")
202
+
203
+ template = ActionView::Template.new(
204
+ source,
205
+ "view_component cache digest for #{component.name}",
206
+ ActionView::Template.handler_for_extension(extension),
207
+ locals: [],
208
+ format: nil,
209
+ virtual_path: virtual_path
210
+ )
211
+ CacheDigest.renders_in(CacheDigest.compiled_source(template), virtual_path).flatten
157
212
  end
158
213
 
214
+ # Components loaded from encrypted files, as some commercial gems ship
215
+ # them, have binary identifiers that can't be scanned. They're still
216
+ # hashed by `file_digest`.
159
217
  def ruby_sources(component)
160
- component_ancestors(component).filter_map { |ancestor|
161
- path = ancestor.identifier
162
- ::File.read(path) if path && ::File.exist?(path)
218
+ ruby_files(component).filter_map { |path|
219
+ source = ::File.read(path)
220
+ source if source.valid_encoding?
163
221
  }
164
222
  end
165
223
 
166
224
  def file_digest(path)
167
- ActiveSupport::Digest.hexdigest(::File.read(path))
225
+ ActiveSupport::Digest.hexdigest(::File.binread(path))
168
226
  end
169
227
 
170
228
  # Built once at load time rather than memoized, so no class-level state
@@ -3,6 +3,7 @@
3
3
  require "active_support/dependencies/autoload"
4
4
  require "action_view/digestor"
5
5
  require "action_view/render_parser"
6
+ require "prism"
6
7
 
7
8
  module ViewComponent
8
9
  # Integrates ViewComponents into Rails' template digest tree.
@@ -38,23 +39,22 @@ module ViewComponent
38
39
  # application partial.
39
40
  VIRTUAL_PATH_PREFIX = "view_component/cache_digest"
40
41
 
41
- # Matches `render FooComponent`, `render(Foo::BarComponent.new(...))`,
42
- # `render FooComponent.with_collection(...)`, etc.
43
- #
44
- # Deliberately a plain source scan rather than a tracker-specific hook: it
45
- # behaves identically for the ERB tracker, the Prism-based Ruby tracker, and
46
- # third-party Haml/Slim trackers.
47
- RENDER_CALL = /
48
- \brender(?:_to_string)?\b # render or render_to_string
49
- \s*\(?\s* # optional opening paren
50
- (?<const>
51
- (?:::)?[A-Z]\w* # a constant
52
- (?:::[A-Z]\w*)* # optionally namespaced
53
- )
54
- /x
42
+ RENDER_METHODS = %i[render render_to_string].freeze
43
+
44
+ COMPILED_SOURCES = :view_component_cache_digest_compiled_sources
45
+ private_constant :COMPILED_SOURCES
46
+
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]/
55
50
 
56
51
  # Rails' escape hatch for dependencies static analysis can't see.
57
52
  EXPLICIT_DEPENDENCY = /#\s*Template Dependency:\s*(\S+)/
53
+
54
+ # A constant path made only of constants, such as `Foo` or `::Foo::Bar`,
55
+ # rather than one hanging off an expression, such as `self.class::Bar`.
56
+ CONSTANT_NAME = /\A(?:::)?[A-Z]\w*(?:::[A-Z]\w*)*\z/
57
+
58
58
  class << self
59
59
  # Virtual paths of components, mapped to their class names.
60
60
  #
@@ -102,46 +102,81 @@ module ViewComponent
102
102
  constantize_component(name)
103
103
  end
104
104
 
105
- # Scan a template's source for renders of components.
105
+ # Compile a template to Ruby with its own handler, so ERB, Haml, Slim and
106
+ # any other template language can be analyzed the same way. Reuses the
107
+ # Ruby compiled at boot, when there is some.
106
108
  #
107
- # @return [Array<String>] synthetic virtual paths
108
- def dependencies_in(template)
109
- component_paths_in(template.source)
109
+ # @return [String] Ruby source
110
+ def compiled_source(template)
111
+ source = template.source
112
+ Thread.current[COMPILED_SOURCES]&.dig(template.handler, source) || template.handler.call(template, source)
110
113
  end
111
114
 
112
- # Scan arbitrary source (a template or a component's Ruby file) for
113
- # renders of components.
115
+ # Find the components Ruby source references and the partials it
116
+ # renders.
114
117
  #
115
- # @return [Array<String>] synthetic virtual paths
116
- def component_paths_in(source)
117
- return [] unless source.is_a?(String) && source.include?("render")
118
-
119
- source.scan(RENDER_CALL).flatten.uniq.filter_map do |constant_name|
120
- registered_path_for(constantize_component(constant_name))
118
+ # Every constant that names a component counts, not only one passed
119
+ # straight to `render`: a component can also be rendered from a method
120
+ # that builds it, from a local variable, or from a slot declared with its
121
+ # class. A component referenced without being rendered only makes the
122
+ # digest change more often than it needs to.
123
+ #
124
+ # Action View's render parser only sees the calls that don't render a
125
+ # component, so it can't infer partials from them.
126
+ #
127
+ # @param source [String] Ruby source
128
+ # @param name [String] virtual path the source is being digested under
129
+ # @return [Array(Array<String>, Array<String>)] synthetic virtual paths of
130
+ # 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
121
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)]
122
145
  end
123
146
 
124
- # Scan a component's Ruby source for partials referenced by string path,
125
- # such as `render "posts/byline"` inside a `#call` method.
147
+ # Find the components Ruby source renders, along with the partials Action
148
+ # View infers from them.
126
149
  #
127
- # Uses Rails' own render parser — the same one `RubyTracker` runs over
128
- # compiled templates — rather than a second implementation of the same
129
- # analysis. Its results are then narrowed to paths that appear verbatim in
130
- # the source, which keeps string literals and discards the speculative
131
- # `things/_thing` entries the parser infers from dynamic renders like
132
- # `render @thing` or `render FooComponent.new`. Those would resolve to
133
- # nothing and only add log noise; components rendered from Ruby are
134
- # already found precisely by `component_paths_in`.
150
+ # Action View reads `render FooComponent.new` as a dynamic render of a
151
+ # partial named after the last method, inferring `news/new`. Partials also
152
+ # rendered some other way aren't returned.
135
153
  #
136
154
  # @param source [String] Ruby source
137
155
  # @param name [String] virtual path the source is being digested under
138
- # @return [Array<String>] partial virtual paths
139
- def partial_paths_in(source, name)
140
- return [] unless source.is_a?(String) && source.include?("render")
156
+ # @return [Array(Array<String>, Array<String>)] synthetic virtual paths of
157
+ # 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?
161
+
162
+ [
163
+ component_paths(components.values),
164
+ partials_rendered_by(components.keys, name) - partials_rendered_by(other_calls, name)
165
+ ]
166
+ end
141
167
 
142
- RENDER_PARSER.new(name, source).render_calls.uniq.select do |path|
143
- source.include?(path) || source.include?(path.sub(%r{(\A|/)_}, '\1'))
144
- end
168
+ # Find the components a component's slots render, from the class each
169
+ # slot was declared with: `renders_one :header, HeaderComponent` or
170
+ # `renders_many :items, "ItemComponent"`. Read from the slot definitions
171
+ # rather than from source, since a class named by a string, or by a name
172
+ # relative to the component, resolves against the component at runtime.
173
+ #
174
+ # @param component [Class]
175
+ # @return [Array<String>] synthetic virtual paths of components
176
+ def slot_renders_of(component)
177
+ definitions = component.registered_slots.values.flat_map { |slot| slot[:renderable_hash]&.values || [slot] }
178
+
179
+ component_paths(definitions.filter_map { |slot| slot_component(component, slot) })
145
180
  end
146
181
 
147
182
  # Action View has shipped its render parser as a class (Rails 7.1, and
@@ -208,6 +243,25 @@ module ViewComponent
208
243
  DigestorLookup.install!
209
244
  end
210
245
 
246
+ # Keep the Ruby that templates compile to while the block runs, so
247
+ # `precompute!` can analyze templates components just compiled without
248
+ # compiling them again. Kept in a thread local rather than on the module,
249
+ # which isn't writable from other Ractors.
250
+ #
251
+ # @private
252
+ def retaining_compiled_sources
253
+ Thread.current[COMPILED_SOURCES] = {}
254
+ yield
255
+ ensure
256
+ Thread.current[COMPILED_SOURCES] = nil
257
+ end
258
+
259
+ # @private
260
+ def compiled(handler, source, compiled_source)
261
+ sources = Thread.current[COMPILED_SOURCES]
262
+ (sources[handler] ||= {})[source] = compiled_source if sources
263
+ end
264
+
211
265
  # Compute component digests at boot, so requests read them from memory
212
266
  # instead of reading and scanning source files.
213
267
  #
@@ -238,6 +292,82 @@ module ViewComponent
238
292
 
239
293
  private
240
294
 
295
+ # Receiverless `render` and `render_to_string` calls, split into those
296
+ # that render a component and the rest.
297
+ #
298
+ # @return [Array(Hash{Prism::CallNode => Class}, Array<Prism::CallNode>)]
299
+ def partition_render_calls(program)
300
+ components = {}
301
+ other_calls = []
302
+
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
310
+ end
311
+ end
312
+
313
+ [components, other_calls]
314
+ end
315
+
316
+ def render_call?(node)
317
+ node.is_a?(Prism::CallNode) && node.receiver.nil? && RENDER_METHODS.include?(node.name)
318
+ end
319
+
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!)
326
+ end
327
+ end
328
+
329
+ # The component a render call renders, from the constant at the root of
330
+ # its first argument: `FooComponent`, `FooComponent.new(...)`,
331
+ # `FooComponent.with_collection(...)`, and so on.
332
+ #
333
+ # @return [Class, nil]
334
+ def rendered_component(call)
335
+ node = call.arguments&.arguments&.first
336
+ node = node.receiver while node.is_a?(Prism::CallNode) && node.receiver
337
+
338
+ case node
339
+ when Prism::ConstantReadNode, Prism::ConstantPathNode
340
+ constantize_component(node.slice)
341
+ end
342
+ end
343
+
344
+ def component_paths(components)
345
+ components.uniq.filter_map { |component| registered_path_for(component) }
346
+ end
347
+
348
+ # The component a slot renders, resolving a class name the way the slot
349
+ # does when it's set. A name that doesn't resolve is left to fail there.
350
+ def slot_component(component, slot)
351
+ renderable = slot[:renderable]
352
+ name = slot[:renderable_class_name]
353
+ renderable ||= component.const_get(name) if name && component.const_defined?(name)
354
+
355
+ renderable if renderable.is_a?(Class) && renderable < ViewComponent::Base
356
+ end
357
+
358
+ # Partials Action View's render parser finds in the given calls,
359
+ # normalized to the form its trackers report. The calls are parsed
360
+ # together, so the parser runs once rather than once per call. Blocks are
361
+ # left out, as any renders inside them are calls of their own.
362
+ def partials_rendered_by(calls, name)
363
+ return [] if calls.empty?
364
+
365
+ source = calls.map { |call| "#{call.name}(#{call.arguments&.slice})" }.join("\n")
366
+ RENDER_PARSER.new(name, source).render_calls
367
+ .map { |path| path.gsub(%r{/_}, "/") }
368
+ .uniq
369
+ end
370
+
241
371
  # Record the actual class name rather than trying to reverse a virtual
242
372
  # path, which may contain acronyms or be overridden by the component.
243
373
  def registered_path_for(component)
@@ -26,6 +26,24 @@ 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.
32
+ def with_sidecar_files_cache
33
+ Fiber[:__vc_sidecar_files] = {}
34
+ yield
35
+ ensure
36
+ Fiber[:__vc_sidecar_files] = nil
37
+ end
38
+
39
+ def sidecar_files_for(klass, extensions)
40
+ sidecar_files_by_component = Fiber[:__vc_sidecar_files]
41
+ return yield unless sidecar_files_by_component
42
+
43
+ sidecar_files = sidecar_files_by_component[klass] ||= {}
44
+ sidecar_files[extensions] ||= yield.freeze
45
+ end
46
+
29
47
  # Invalidates the cache when a template or a translation file is added,
30
48
  # changed, or removed. Changes to Ruby files reload the application instead.
31
49
  def template_watcher(file_watcher)
@@ -95,23 +95,38 @@ module ViewComponent
95
95
 
96
96
  @component.silence_redefinition_of_method(:render_template_for)
97
97
 
98
+ # Keep template references local to this method's owner, including when
99
+ # render_parent invokes it on an instance of a descendant component.
100
+ methods = Module.new
101
+ @templates.each_with_index { |template, index| methods.const_set(:"TEMPLATE_#{index}", template) }
102
+
98
103
  if @templates.one?
99
104
  template = @templates.first
100
- safe_call = template.safe_method_name_call
101
- @component.define_method(:render_template_for) do |_|
102
- @current_template = template
103
- instance_exec(&safe_call)
104
- end
105
+ body = <<~RUBY
106
+ @current_template = TEMPLATE_0
107
+ #{template.safe_method_call}
108
+ RUBY
105
109
  else
106
- compiler = self
107
- @component.define_method(:render_template_for) do |details|
108
- if (@current_template = compiler.find_templates_for(details).first)
109
- instance_exec(&@current_template.safe_method_name_call)
110
+ methods.const_set(:COMPILER, self)
111
+ calls = @templates.each_with_index.map do |template, index|
112
+ "when TEMPLATE_#{index}\n#{template.safe_method_call}"
113
+ end.join("\n")
114
+ body = <<~RUBY
115
+ @current_template = COMPILER.find_templates_for(details).first
116
+ case @current_template
117
+ #{calls}
110
118
  else
111
- raise MissingTemplateError.new(self.class.name, details)
119
+ raise ViewComponent::MissingTemplateError.new(self.class.name, details)
112
120
  end
113
- end
121
+ RUBY
114
122
  end
123
+
124
+ methods.module_eval <<~RUBY, __FILE__, __LINE__ + 1
125
+ def render_template_for(details)
126
+ #{body}
127
+ end
128
+ RUBY
129
+ @component.define_method(:render_template_for, methods.instance_method(:render_template_for))
115
130
  end
116
131
 
117
132
  def template_errors
@@ -186,7 +201,7 @@ module ViewComponent
186
201
  )]
187
202
  else
188
203
  path_parser = ActionView::Resolver::PathParser.new
189
- templates = @component.sidecar_files(
204
+ templates = @component.__vc_sidecar_files(
190
205
  ActionView::Template::Handlers.extensions
191
206
  ).map do |path|
192
207
  details = path_parser.parse(path).details
@@ -94,11 +94,16 @@ module ViewComponent
94
94
  ActiveSupport.on_load(:after_initialize) do
95
95
  if Rails.application.config.eager_load
96
96
  ActiveSupport::Notifications.instrument("compile.view_component") do
97
- components = ViewComponent::Base.descendants.reject(&:anonymous?)
98
- components.each(&:__vc_compile)
97
+ CompileCache.with_sidecar_files_cache do
98
+ components = ViewComponent::Base.descendants.reject(&:anonymous?)
99
99
 
100
- if ActionView::Base.cache_template_loading
101
- ViewComponent::CacheDigest.precompute!(components.select(&:__vc_compiled?))
100
+ ViewComponent::CacheDigest.retaining_compiled_sources do
101
+ components.each(&:__vc_compile)
102
+
103
+ if ActionView::Base.cache_template_loading
104
+ ViewComponent::CacheDigest.precompute!(components.select(&:__vc_compiled?))
105
+ end
106
+ end
102
107
  end
103
108
  end
104
109
  end
@@ -25,16 +25,11 @@ module ViewComponent
25
25
  # `render` accepts, but there's currently no mechanism for users to
26
26
  # provide these when calling render on a ViewComponent.
27
27
  if user_details.equal?(EMPTY_DETAILS)
28
- # Fast path: memoize the empty-details Requested per LookupContext.
29
- # Rendered many times with the same context, the tuple/Requested
30
- # allocations from ActionView are then paid at most once.
31
- cached = instance_variable_defined?(:@__vc_requested_details_cache) &&
32
- @__vc_requested_details_cache
33
- return cached if cached
34
-
35
- details, from_cache = detail_args_for(EMPTY_DETAILS)
36
- @__vc_requested_details_cache =
37
- from_cache || ActionView::TemplateDetails::Requested.new(**details)
28
+ # Fast path: with template caching on, `details_key` is the
29
+ # Requested for the current details, memoized by ActionView and
30
+ # reset whenever a detail such as `formats` changes. Using it
31
+ # directly skips the tuple `detail_args_for` allocates.
32
+ details_key || ActionView::TemplateDetails::Requested.new(**@details)
38
33
  else
39
34
  details, from_cache = detail_args_for(user_details)
40
35
  from_cache || ActionView::TemplateDetails::Requested.new(**details)
@@ -408,19 +408,19 @@ module ViewComponent
408
408
  self.class.const_get(slot_definition[:renderable_class_name]).new(*args, **kwargs)
409
409
  # If passed a lambda
410
410
  elsif slot_definition[:renderable_function]
411
- # Use `bind(self)` to ensure lambda is executed in the context of the
411
+ # Use `bind_call(self)` to ensure lambda is executed in the context of the
412
412
  # current component. This is necessary to allow the lambda to access helper
413
413
  # methods like `content_tag` as well as parent component state.
414
- renderable_function = slot_definition[:renderable_function].bind(self)
414
+ renderable_function = slot_definition[:renderable_function]
415
415
  renderable_value =
416
416
  if block
417
- renderable_function.call(*args, **kwargs) do |*rargs|
417
+ renderable_function.bind_call(self, *args, **kwargs) do |*rargs|
418
418
  with_captured_virtual_path(captured_block_virtual_path) do
419
419
  view_context.capture(*rargs, &block)
420
420
  end
421
421
  end
422
422
  else
423
- renderable_function.call(*args, **kwargs)
423
+ renderable_function.bind_call(self, *args, **kwargs)
424
424
  end
425
425
 
426
426
  # Function calls can return components, so if it's a component handle it specially
@@ -123,14 +123,13 @@ module ViewComponent
123
123
  @component.define_method(safe_method_name, @component.instance_method(@call_method_name))
124
124
  end
125
125
 
126
- def safe_method_name_call
127
- m = safe_method_name
128
- proc do
129
- __vc_maybe_escape_html(send(m)) do
130
- Kernel.warn("WARNING: The #{self.class} component rendered HTML-unsafe output. " \
126
+ def safe_method_call
127
+ <<~RUBY
128
+ __vc_maybe_escape_html(#{super}) do
129
+ Kernel.warn("WARNING: The \#{self.class} component rendered HTML-unsafe output. " \
131
130
  "The output will be automatically escaped, but you may want to investigate.")
132
131
  end
133
- end
132
+ RUBY
134
133
  end
135
134
 
136
135
  def defined_on_self?
@@ -156,9 +155,13 @@ module ViewComponent
156
155
  defined?(Coverage) && Coverage.running?
157
156
  end
158
157
 
159
- def safe_method_name_call
160
- m = safe_method_name
161
- proc { send(m) }
158
+ def safe_method_call
159
+ method_name = safe_method_name
160
+ if /\A[a-zA-Z_]\w*[!?]?\z/.match?(method_name)
161
+ method_name
162
+ else
163
+ "send(#{method_name.to_sym.inspect})"
164
+ end
162
165
  end
163
166
 
164
167
  def requires_compiled_superclass?
@@ -208,14 +211,16 @@ module ViewComponent
208
211
 
209
212
  def compiled_source
210
213
  handler = details.handler_class
211
- this_source = source
212
- this_source.rstrip! if @component.strip_trailing_whitespace?
214
+ original_source = source
215
+ this_source = @component.strip_trailing_whitespace? ? original_source.rstrip : original_source
213
216
 
214
217
  short_identifier = defined?(Rails.root) ? @path.sub("#{Rails.root}/", "") : @path
215
218
  format = self.format || DEFAULT_FORMAT
216
219
  type = ActionView::Template::Types[format]
217
220
 
218
- handler.call(DataWithSource.new(format:, identifier: @path, short_identifier:, type:), this_source)
221
+ handler.call(DataWithSource.new(format:, identifier: @path, short_identifier:, type:), this_source).tap do |compiled|
222
+ CacheDigest.compiled(handler, original_source, compiled)
223
+ end
219
224
  end
220
225
  end
221
226
  end
@@ -30,7 +30,7 @@ module ViewComponent
30
30
  # can inherit translations from its parent and is able to overwrite them.
31
31
  translation_files = ancestors.reverse_each.with_object([]) do |ancestor, files|
32
32
  if ancestor.is_a?(Class) && ancestor < ViewComponent::Base
33
- files.concat(ancestor.sidecar_files(TRANSLATION_EXTENSIONS))
33
+ files.concat(ancestor.__vc_sidecar_files(TRANSLATION_EXTENSIONS))
34
34
  end
35
35
  end
36
36
 
@@ -3,7 +3,7 @@
3
3
  module ViewComponent
4
4
  module VERSION
5
5
  MAJOR = 4
6
- MINOR = 19
6
+ MINOR = 20
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.19.0
4
+ version: 4.20.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ViewComponent Team
@@ -37,6 +37,20 @@ dependencies:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
39
  version: 7.1.0
40
+ - !ruby/object:Gem::Dependency
41
+ name: prism
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '1.0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '1.0'
40
54
  executables: []
41
55
  extensions: []
42
56
  extra_rdoc_files: []
@@ -48,6 +62,8 @@ files:
48
62
  - app/controllers/view_components_system_test_controller.rb
49
63
  - app/views/test_mailer/test_asset_email.html.erb
50
64
  - app/views/test_mailer/test_email.html.erb
65
+ - app/views/test_mailer/test_multipart_email.html.erb
66
+ - app/views/test_mailer/test_multipart_email.text.erb
51
67
  - app/views/test_mailer/test_url_email.html.erb
52
68
  - app/views/view_components/index.html.erb
53
69
  - app/views/view_components/preview.html.erb