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 +4 -4
- data/app/views/test_mailer/test_multipart_email.html.erb +1 -0
- data/app/views/test_mailer/test_multipart_email.text.erb +1 -0
- data/docs/CHANGELOG.md +50 -0
- data/lib/view_component/base.rb +22 -4
- data/lib/view_component/cache_digest/dependency_tracking.rb +9 -1
- data/lib/view_component/cache_digest/resolver.rb +92 -34
- data/lib/view_component/cache_digest.rb +172 -42
- data/lib/view_component/compile_cache.rb +18 -0
- data/lib/view_component/compiler.rb +27 -12
- data/lib/view_component/engine.rb +9 -4
- data/lib/view_component/request_details.rb +5 -10
- data/lib/view_component/slotable.rb +4 -4
- data/lib/view_component/template.rb +17 -12
- data/lib/view_component/translatable.rb +1 -1
- data/lib/view_component/version.rb +1 -1
- metadata +17 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 04ae333990353bff3c4671c78db517fbe53ddcb15d9bfeb4448bc2d52c710f5b
|
|
4
|
+
data.tar.gz: ef958403f0326485a5f2035a9d744c878197c041247df3f76e68cf2712b9a652
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
data/lib/view_component/base.rb
CHANGED
|
@@ -624,7 +624,10 @@ module ViewComponent
|
|
|
624
624
|
return [] unless identifier
|
|
625
625
|
return [] unless name
|
|
626
626
|
|
|
627
|
-
|
|
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
|
-
|
|
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 =
|
|
655
|
+
sidecar_files = __vc_files_ending_with(extensions, "#{directory}/#{component_name}.*")
|
|
653
656
|
|
|
654
|
-
sidecar_directory_files =
|
|
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
|
-
|
|
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
|
|
58
|
-
# inlining keeps the source small and avoids
|
|
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
|
|
65
|
-
#
|
|
66
|
-
#
|
|
67
|
-
#
|
|
68
|
-
|
|
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
|
-
#
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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.
|
|
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(
|
|
132
|
-
|
|
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
|
|
146
|
-
#
|
|
147
|
-
|
|
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(
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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.
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
#
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
#
|
|
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 [
|
|
108
|
-
def
|
|
109
|
-
|
|
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
|
-
#
|
|
113
|
-
# renders
|
|
115
|
+
# Find the components Ruby source references and the partials it
|
|
116
|
+
# renders.
|
|
114
117
|
#
|
|
115
|
-
#
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
#
|
|
125
|
-
#
|
|
147
|
+
# Find the components Ruby source renders, along with the partials Action
|
|
148
|
+
# View infers from them.
|
|
126
149
|
#
|
|
127
|
-
#
|
|
128
|
-
#
|
|
129
|
-
#
|
|
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>]
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
end
|
|
105
|
+
body = <<~RUBY
|
|
106
|
+
@current_template = TEMPLATE_0
|
|
107
|
+
#{template.safe_method_call}
|
|
108
|
+
RUBY
|
|
105
109
|
else
|
|
106
|
-
|
|
107
|
-
@
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
98
|
-
|
|
97
|
+
CompileCache.with_sidecar_files_cache do
|
|
98
|
+
components = ViewComponent::Base.descendants.reject(&:anonymous?)
|
|
99
99
|
|
|
100
|
-
|
|
101
|
-
|
|
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:
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
|
|
32
|
-
|
|
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 `
|
|
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]
|
|
414
|
+
renderable_function = slot_definition[:renderable_function]
|
|
415
415
|
renderable_value =
|
|
416
416
|
if block
|
|
417
|
-
renderable_function.
|
|
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.
|
|
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
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
212
|
-
this_source
|
|
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.
|
|
33
|
+
files.concat(ancestor.__vc_sidecar_files(TRANSLATION_EXTENSIONS))
|
|
34
34
|
end
|
|
35
35
|
end
|
|
36
36
|
|
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.
|
|
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
|