view_component 2.49.1 → 3.23.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.txt +1 -1
  3. data/app/assets/vendor/prism.css +3 -195
  4. data/app/assets/vendor/prism.min.js +11 -11
  5. data/app/controllers/concerns/view_component/preview_actions.rb +108 -0
  6. data/app/controllers/view_components_controller.rb +1 -87
  7. data/app/controllers/view_components_system_test_controller.rb +30 -0
  8. data/app/helpers/preview_helper.rb +30 -12
  9. data/app/views/view_components/_preview_source.html.erb +3 -3
  10. data/app/views/view_components/preview.html.erb +2 -2
  11. data/docs/CHANGELOG.md +1653 -24
  12. data/lib/rails/generators/abstract_generator.rb +16 -10
  13. data/lib/rails/generators/component/component_generator.rb +8 -4
  14. data/lib/rails/generators/component/templates/component.rb.tt +3 -2
  15. data/lib/rails/generators/erb/component_generator.rb +1 -1
  16. data/lib/rails/generators/locale/component_generator.rb +4 -4
  17. data/lib/rails/generators/preview/component_generator.rb +17 -3
  18. data/lib/rails/generators/preview/templates/component_preview.rb.tt +5 -1
  19. data/lib/rails/generators/rspec/component_generator.rb +15 -3
  20. data/lib/rails/generators/rspec/templates/component_spec.rb.tt +3 -1
  21. data/lib/rails/generators/stimulus/component_generator.rb +8 -3
  22. data/lib/rails/generators/stimulus/templates/component_controller.ts.tt +9 -0
  23. data/lib/rails/generators/test_unit/templates/component_test.rb.tt +3 -1
  24. data/lib/view_component/base.rb +352 -196
  25. data/lib/view_component/capture_compatibility.rb +44 -0
  26. data/lib/view_component/collection.rb +28 -9
  27. data/lib/view_component/compiler.rb +162 -193
  28. data/lib/view_component/config.rb +225 -0
  29. data/lib/view_component/configurable.rb +17 -0
  30. data/lib/view_component/deprecation.rb +8 -0
  31. data/lib/view_component/engine.rb +74 -47
  32. data/lib/view_component/errors.rb +240 -0
  33. data/lib/view_component/inline_template.rb +55 -0
  34. data/lib/view_component/instrumentation.rb +10 -2
  35. data/lib/view_component/preview.rb +21 -19
  36. data/lib/view_component/rails/tasks/view_component.rake +11 -2
  37. data/lib/view_component/render_component_helper.rb +1 -0
  38. data/lib/view_component/render_component_to_string_helper.rb +1 -1
  39. data/lib/view_component/render_to_string_monkey_patch.rb +1 -1
  40. data/lib/view_component/rendering_component_helper.rb +1 -1
  41. data/lib/view_component/rendering_monkey_patch.rb +1 -1
  42. data/lib/view_component/slot.rb +119 -1
  43. data/lib/view_component/slotable.rb +393 -96
  44. data/lib/view_component/slotable_default.rb +20 -0
  45. data/lib/view_component/system_test_case.rb +13 -0
  46. data/lib/view_component/system_test_helpers.rb +27 -0
  47. data/lib/view_component/template.rb +134 -0
  48. data/lib/view_component/test_helpers.rb +208 -47
  49. data/lib/view_component/translatable.rb +51 -33
  50. data/lib/view_component/use_helpers.rb +42 -0
  51. data/lib/view_component/version.rb +5 -4
  52. data/lib/view_component/with_content_helper.rb +3 -8
  53. data/lib/view_component.rb +7 -12
  54. metadata +339 -57
  55. data/lib/rails/generators/component/USAGE +0 -13
  56. data/lib/view_component/content_areas.rb +0 -57
  57. data/lib/view_component/polymorphic_slots.rb +0 -73
  58. data/lib/view_component/preview_template_error.rb +0 -6
  59. data/lib/view_component/previewable.rb +0 -62
  60. data/lib/view_component/slot_v2.rb +0 -104
  61. data/lib/view_component/slotable_v2.rb +0 -307
  62. data/lib/view_component/template_error.rb +0 -9
  63. data/lib/yard/mattr_accessor_handler.rb +0 -19
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponent
4
+ # CaptureCompatibility is a module that patches #capture to fix issues
5
+ # related to ViewComponent and functionality that relies on `capture`
6
+ # like forms, capture itself, turbo frames, etc.
7
+ #
8
+ # This underlying incompatibility with ViewComponent and capture is
9
+ # that several features like forms keep a reference to the primary
10
+ # `ActionView::Base` instance which has its own @output_buffer. When
11
+ # `#capture` is called on the original `ActionView::Base` instance while
12
+ # evaluating a block from a ViewComponent the @output_buffer is overridden
13
+ # in the ActionView::Base instance, and *not* the component. This results
14
+ # in a double render due to `#capture` implementation details.
15
+ #
16
+ # To resolve the issue, we override `#capture` so that we can delegate
17
+ # the `capture` logic to the ViewComponent that created the block.
18
+ module CaptureCompatibility
19
+ def self.included(base)
20
+ return if base < InstanceMethods
21
+
22
+ base.class_eval do
23
+ alias_method :original_capture, :capture
24
+ end
25
+
26
+ base.prepend(InstanceMethods)
27
+ end
28
+
29
+ module InstanceMethods
30
+ def capture(*args, &block)
31
+ # Handle blocks that originate from C code and raise, such as `&:method`
32
+ return original_capture(*args, &block) if block.source_location.nil?
33
+
34
+ block_context = block.binding.receiver
35
+
36
+ if block_context != self && block_context.class < ActionView::Base
37
+ block_context.original_capture(*args, &block)
38
+ else
39
+ original_capture(*args, &block)
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -7,13 +7,19 @@ module ViewComponent
7
7
  include Enumerable
8
8
  attr_reader :component
9
9
 
10
- delegate :format, to: :component
11
10
  delegate :size, to: :@collection
12
11
 
12
+ attr_accessor :__vc_original_view_context
13
+
14
+ def set_original_view_context(view_context)
15
+ self.__vc_original_view_context = view_context
16
+ end
17
+
13
18
  def render_in(view_context, &block)
14
19
  components.map do |component|
20
+ component.set_original_view_context(__vc_original_view_context)
15
21
  component.render_in(view_context, &block)
16
- end.join.html_safe # rubocop:disable Rails/OutputSafety
22
+ end.join(rendered_spacer(view_context)).html_safe
17
23
  end
18
24
 
19
25
  def components
@@ -34,11 +40,18 @@ module ViewComponent
34
40
  components.each(&block)
35
41
  end
36
42
 
43
+ # Rails expects us to define `format` on all renderables,
44
+ # but we do not know the `format` of a ViewComponent until runtime.
45
+ def format
46
+ nil
47
+ end
48
+
37
49
  private
38
50
 
39
- def initialize(component, object, **options)
51
+ def initialize(component, object, spacer_component, **options)
40
52
  @component = component
41
53
  @collection = collection_variable(object || [])
54
+ @spacer_component = spacer_component
42
55
  @options = options
43
56
  end
44
57
 
@@ -46,19 +59,25 @@ module ViewComponent
46
59
  if object.respond_to?(:to_ary)
47
60
  object.to_ary
48
61
  else
49
- raise ArgumentError.new(
50
- "The value of the first argument passed to `with_collection` isn't a valid collection. " \
51
- "Make sure it responds to `to_ary`."
52
- )
62
+ raise InvalidCollectionArgumentError
53
63
  end
54
64
  end
55
65
 
56
66
  def component_options(item, iterator)
57
- item_options = { component.collection_parameter => item }
58
- item_options[component.collection_counter_parameter] = iterator.index + 1 if component.counter_argument_present?
67
+ item_options = {component.collection_parameter => item}
68
+ item_options[component.collection_counter_parameter] = iterator.index if component.counter_argument_present?
59
69
  item_options[component.collection_iteration_parameter] = iterator.dup if component.iteration_argument_present?
60
70
 
61
71
  @options.merge(item_options)
62
72
  end
73
+
74
+ def rendered_spacer(view_context)
75
+ if @spacer_component
76
+ @spacer_component.set_original_view_context(__vc_original_view_context)
77
+ @spacer_component.render_in(view_context)
78
+ else
79
+ ""
80
+ end
81
+ end
63
82
  end
64
83
  end
@@ -1,260 +1,229 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "concurrent-ruby"
4
+
3
5
  module ViewComponent
4
6
  class Compiler
5
- # Lock required to be obtained before compiling the component
6
- attr_reader :__vc_compiler_lock
7
-
8
- # Compiler mode. Can be either:
9
- # * development (a blocking mode which ensures thread safety when redefining the `call` method for components,
7
+ # Compiler development mode. Can be either:
8
+ # * true (a blocking mode which ensures thread safety when redefining the `call` method for components,
10
9
  # default in Rails development and test mode)
11
- # * production (a non-blocking mode, default in Rails production mode)
12
- DEVELOPMENT_MODE = :development
13
- PRODUCTION_MODE = :production
10
+ # * false(a non-blocking mode, default in Rails production mode)
11
+ class_attribute :development_mode, default: false
14
12
 
15
- class_attribute :mode, default: PRODUCTION_MODE
16
-
17
- def initialize(component_class)
18
- @component_class = component_class
19
- @__vc_compiler_lock = Monitor.new
13
+ def initialize(component)
14
+ @component = component
15
+ @lock = Mutex.new
20
16
  end
21
17
 
22
18
  def compiled?
23
- CompileCache.compiled?(component_class)
19
+ CompileCache.compiled?(@component)
24
20
  end
25
21
 
26
- def development?
27
- self.class.mode == DEVELOPMENT_MODE
28
- end
22
+ def compile(raise_errors: false, force: false)
23
+ return if compiled? && !force
24
+ return if @component == ViewComponent::Base
29
25
 
30
- def compile(raise_errors: false)
31
- return if compiled?
26
+ @lock.synchronize do
27
+ # this check is duplicated so that concurrent compile calls can still
28
+ # early exit
29
+ return if compiled? && !force
32
30
 
33
- with_lock do
34
- CompileCache.invalidate_class!(component_class)
31
+ gather_templates
35
32
 
36
- subclass_instance_methods = component_class.instance_methods(false)
37
-
38
- if subclass_instance_methods.include?(:with_content) && raise_errors
39
- raise ViewComponent::ComponentError.new(
40
- "#{component_class} implements a reserved method, `#with_content`.\n\n" \
41
- "To fix this issue, change the name of the method."
42
- )
33
+ if self.class.development_mode && @templates.any?(&:requires_compiled_superclass?)
34
+ @component.superclass.compile(raise_errors: raise_errors)
43
35
  end
44
36
 
45
37
  if template_errors.present?
46
- raise ViewComponent::TemplateError.new(template_errors) if raise_errors
38
+ raise TemplateError.new(template_errors) if raise_errors
47
39
 
40
+ # this return is load bearing, and prevents the component from being considered "compiled?"
48
41
  return false
49
42
  end
50
43
 
51
- if subclass_instance_methods.include?(:before_render_check)
52
- ActiveSupport::Deprecation.warn(
53
- "`#before_render_check` will be removed in v3.0.0.\n\n" \
54
- "To fix this issue, use `#before_render` instead."
55
- )
56
- end
57
-
58
44
  if raise_errors
59
- component_class.validate_initialization_parameters!
60
- component_class.validate_collection_parameter!
61
- end
62
-
63
- templates.each do |template|
64
- # Remove existing compiled template methods,
65
- # as Ruby warns when redefining a method.
66
- method_name = call_method_name(template[:variant])
67
-
68
- if component_class.instance_methods.include?(method_name.to_sym)
69
- component_class.send(:undef_method, method_name.to_sym)
70
- end
71
-
72
- component_class.class_eval <<-RUBY, template[:path], -1
73
- def #{method_name}
74
- @output_buffer = ActionView::OutputBuffer.new
75
- #{compiled_template(template[:path])}
76
- end
77
- RUBY
45
+ @component.validate_initialization_parameters!
46
+ @component.validate_collection_parameter!
78
47
  end
79
48
 
80
49
  define_render_template_for
81
50
 
82
- component_class._after_compile
83
-
84
- CompileCache.register(component_class)
85
- end
86
- end
51
+ @component.register_default_slots
52
+ @component.build_i18n_backend
87
53
 
88
- def with_lock(&block)
89
- if development?
90
- __vc_compiler_lock.synchronize(&block)
91
- else
92
- block.call
54
+ CompileCache.register(@component)
93
55
  end
94
56
  end
95
57
 
96
58
  private
97
59
 
98
- attr_reader :component_class
60
+ attr_reader :templates
99
61
 
100
62
  def define_render_template_for
101
- if component_class.instance_methods.include?(:render_template_for)
102
- component_class.send(:undef_method, :render_template_for)
63
+ @templates.each do |template|
64
+ template.compile_to_component
103
65
  end
104
66
 
105
- variant_elsifs = variants.compact.uniq.map do |variant|
106
- "elsif variant.to_sym == :#{variant}\n #{call_method_name(variant)}"
107
- end.join("\n")
108
-
109
- body = <<-RUBY
110
- if variant.nil?
111
- call
112
- #{variant_elsifs}
67
+ method_body =
68
+ if @templates.one?
69
+ @templates.first.safe_method_name_call
70
+ elsif (template = @templates.find(&:inline?))
71
+ template.safe_method_name_call
113
72
  else
114
- call
115
- end
116
- RUBY
73
+ branches = []
74
+
75
+ @templates.each do |template|
76
+ conditional =
77
+ if template.inline_call?
78
+ "variant&.to_sym == #{template.variant.inspect}"
79
+ else
80
+ [
81
+ template.default_format? ? "(format == #{ViewComponent::Base::VC_INTERNAL_DEFAULT_FORMAT.inspect} || format.nil?)" : "format == #{template.format.inspect}",
82
+ template.variant.nil? ? "variant.nil?" : "variant&.to_sym == #{template.variant.inspect}"
83
+ ].join(" && ")
84
+ end
85
+
86
+ branches << [conditional, template.safe_method_name_call]
87
+ end
117
88
 
118
- if development?
119
- component_class.class_eval <<-RUBY, __FILE__, __LINE__ + 1
120
- def render_template_for(variant = nil)
121
- self.class.compiler.with_lock do
122
- #{body}
89
+ out = branches.each_with_object(+"") do |(conditional, branch_body), memo|
90
+ memo << "#{(!memo.present?) ? "if" : "elsif"} #{conditional}\n #{branch_body}\n"
123
91
  end
92
+ out << "else\n #{templates.find { _1.variant.nil? && _1.default_format? }.safe_method_name_call}\nend"
124
93
  end
125
- RUBY
126
- else
127
- component_class.class_eval <<-RUBY, __FILE__, __LINE__ + 1
128
- def render_template_for(variant = nil)
129
- #{body}
130
- end
131
- RUBY
94
+
95
+ @component.silence_redefinition_of_method(:render_template_for)
96
+ @component.class_eval <<-RUBY, __FILE__, __LINE__ + 1
97
+ def render_template_for(variant = nil, format = nil)
98
+ #{method_body}
132
99
  end
100
+ RUBY
133
101
  end
134
102
 
135
103
  def template_errors
136
- @__vc_template_errors ||=
137
- begin
138
- errors = []
104
+ @_template_errors ||= begin
105
+ errors = []
139
106
 
140
- if (templates + inline_calls).empty?
141
- errors << "Couldn't find a template file or inline render method for #{component_class}."
142
- end
107
+ errors << "Couldn't find a template file or inline render method for #{@component}." if @templates.empty?
143
108
 
144
- if templates.count { |template| template[:variant].nil? } > 1
145
- errors <<
146
- "More than one template found for #{component_class}. " \
147
- "There can only be one default template file per component."
148
- end
149
-
150
- invalid_variants =
151
- templates.
152
- group_by { |template| template[:variant] }.
153
- map { |variant, grouped| variant if grouped.length > 1 }.
154
- compact.
155
- sort
156
-
157
- unless invalid_variants.empty?
158
- errors <<
159
- "More than one template found for #{'variant'.pluralize(invalid_variants.count)} " \
160
- "#{invalid_variants.map { |v| "'#{v}'" }.to_sentence} in #{component_class}. " \
161
- "There can only be one template file per variant."
162
- end
109
+ # We currently allow components to have both an inline call method and a template for a variant, with the
110
+ # inline call method overriding the template. We should aim to change this in v4 to instead
111
+ # raise an error.
112
+ @templates.reject(&:inline_call?)
113
+ .map { |template| [template.variant, template.format] }
114
+ .tally
115
+ .select { |_, count| count > 1 }
116
+ .each do |tally|
117
+ variant, this_format = tally.first
163
118
 
164
- if templates.find { |template| template[:variant].nil? } && inline_calls_defined_on_self.include?(:call)
165
- errors <<
166
- "Template file and inline render method found for #{component_class}. " \
167
- "There can only be a template file or inline render method per component."
168
- end
119
+ variant_string = " for variant `#{variant}`" if variant.present?
169
120
 
170
- duplicate_template_file_and_inline_variant_calls =
171
- templates.pluck(:variant) & variants_from_inline_calls(inline_calls_defined_on_self)
121
+ errors << "More than one #{this_format.upcase} template found#{variant_string} for #{@component}. "
122
+ end
172
123
 
173
- unless duplicate_template_file_and_inline_variant_calls.empty?
174
- count = duplicate_template_file_and_inline_variant_calls.count
124
+ default_template_types = @templates.each_with_object(Set.new) do |template, memo|
125
+ next if template.variant
175
126
 
176
- errors <<
177
- "Template #{'file'.pluralize(count)} and inline render #{'method'.pluralize(count)} " \
178
- "found for #{'variant'.pluralize(count)} " \
179
- "#{duplicate_template_file_and_inline_variant_calls.map { |v| "'#{v}'" }.to_sentence} " \
180
- "in #{component_class}. " \
181
- "There can only be a template file or inline render method per variant."
182
- end
127
+ memo << :template_file if !template.inline_call?
128
+ memo << :inline_render if template.inline_call? && template.defined_on_self?
183
129
 
184
- errors
130
+ memo
185
131
  end
186
- end
187
132
 
188
- def templates
189
- @templates ||=
190
- begin
191
- extensions = ActionView::Template.template_handler_extensions
133
+ if default_template_types.length > 1
134
+ errors <<
135
+ "Template file and inline render method found for #{@component}. " \
136
+ "There can only be a template file or inline render method per component."
137
+ end
192
138
 
193
- component_class._sidecar_files(extensions).each_with_object([]) do |path, memo|
194
- pieces = File.basename(path).split(".")
195
- memo << {
196
- path: path,
197
- variant: pieces.second.split("+").second&.to_sym,
198
- handler: pieces.last
199
- }
200
- end
139
+ # If a template has inline calls, they can conflict with template files the component may use
140
+ # to render. This attempts to catch and raise that issue before run time. For example,
141
+ # `def render_mobile` would conflict with a sidecar template of `component.html+mobile.erb`
142
+ duplicate_template_file_and_inline_call_variants =
143
+ @templates.reject(&:inline_call?).map(&:variant) &
144
+ @templates.select { _1.inline_call? && _1.defined_on_self? }.map(&:variant)
145
+
146
+ unless duplicate_template_file_and_inline_call_variants.empty?
147
+ count = duplicate_template_file_and_inline_call_variants.count
148
+
149
+ errors <<
150
+ "Template #{"file".pluralize(count)} and inline render #{"method".pluralize(count)} " \
151
+ "found for #{"variant".pluralize(count)} " \
152
+ "#{duplicate_template_file_and_inline_call_variants.map { |v| "'#{v}'" }.to_sentence} " \
153
+ "in #{@component}. There can only be a template file or inline render method per variant."
201
154
  end
202
- end
203
155
 
204
- def inline_calls
205
- @inline_calls ||=
206
- begin
207
- # Fetch only ViewComponent ancestor classes to limit the scope of
208
- # finding inline calls
209
- view_component_ancestors =
210
- (
211
- component_class.ancestors.take_while { |ancestor| ancestor != ViewComponent::Base } -
212
- component_class.included_modules
213
- )
156
+ @templates.select(&:variant).each_with_object(Hash.new { |h, k| h[k] = Set.new }) do |template, memo|
157
+ memo[template.normalized_variant_name] << template.variant
158
+ memo
159
+ end.each do |_, variant_names|
160
+ next unless variant_names.length > 1
214
161
 
215
- view_component_ancestors.flat_map { |ancestor| ancestor.instance_methods(false).grep(/^call/) }.uniq
162
+ errors << "Colliding templates #{variant_names.sort.map { |v| "'#{v}'" }.to_sentence} found in #{@component}."
216
163
  end
217
- end
218
164
 
219
- def inline_calls_defined_on_self
220
- @inline_calls_defined_on_self ||= component_class.instance_methods(false).grep(/^call/)
165
+ errors
166
+ end
221
167
  end
222
168
 
223
- def variants
224
- @__vc_variants = (
225
- templates.map { |template| template[:variant] } + variants_from_inline_calls(inline_calls)
226
- ).compact.uniq
227
- end
169
+ def gather_templates
170
+ @templates ||=
171
+ begin
172
+ templates = @component.sidecar_files(
173
+ ActionView::Template.template_handler_extensions
174
+ ).map do |path|
175
+ # Extract format and variant from template filename
176
+ this_format, variant =
177
+ File
178
+ .basename(path) # "variants_component.html+mini.watch.erb"
179
+ .split(".")[1..-2] # ["html+mini", "watch"]
180
+ .join(".") # "html+mini.watch"
181
+ .split("+") # ["html", "mini.watch"]
182
+ .map(&:to_sym) # [:html, :"mini.watch"]
183
+
184
+ out = Template.new(
185
+ component: @component,
186
+ type: :file,
187
+ path: path,
188
+ lineno: 0,
189
+ extension: path.split(".").last,
190
+ this_format: this_format.to_s.split(".").last&.to_sym, # strip locale from this_format, see #2113
191
+ variant: variant
192
+ )
228
193
 
229
- def variants_from_inline_calls(calls)
230
- calls.reject { |call| call == :call }.map do |variant_call|
231
- variant_call.to_s.sub("call_", "").to_sym
232
- end
233
- end
194
+ out
195
+ end
234
196
 
235
- def compiled_template(file_path)
236
- handler = ActionView::Template.handler_for_extension(File.extname(file_path).gsub(".", ""))
237
- template = File.read(file_path)
238
-
239
- if handler.method(:call).parameters.length > 1
240
- handler.call(component_class, template)
241
- else
242
- handler.call(
243
- OpenStruct.new(
244
- source: template,
245
- identifier: component_class.identifier,
246
- type: component_class.type
247
- )
248
- )
249
- end
250
- end
197
+ component_instance_methods_on_self = @component.instance_methods(false)
198
+
199
+ (
200
+ @component.ancestors.take_while { |ancestor| ancestor != ViewComponent::Base } - @component.included_modules
201
+ ).flat_map { |ancestor| ancestor.instance_methods(false).grep(/^call(_|$)/) }
202
+ .uniq
203
+ .each do |method_name|
204
+ templates << Template.new(
205
+ component: @component,
206
+ type: :inline_call,
207
+ this_format: ViewComponent::Base::VC_INTERNAL_DEFAULT_FORMAT,
208
+ variant: method_name.to_s.include?("call_") ? method_name.to_s.sub("call_", "").to_sym : nil,
209
+ method_name: method_name,
210
+ defined_on_self: component_instance_methods_on_self.include?(method_name)
211
+ )
212
+ end
213
+
214
+ if @component.inline_template.present?
215
+ templates << Template.new(
216
+ component: @component,
217
+ type: :inline,
218
+ path: @component.inline_template.path,
219
+ lineno: @component.inline_template.lineno,
220
+ source: @component.inline_template.source.dup,
221
+ extension: @component.inline_template.language
222
+ )
223
+ end
251
224
 
252
- def call_method_name(variant)
253
- if variant.present? && variants.include?(variant)
254
- "call_#{variant}"
255
- else
256
- "call"
257
- end
225
+ templates
226
+ end
258
227
  end
259
228
  end
260
229
  end