view_component 3.14.0 → 3.15.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7ef8c603bedd61511ed3eb2da02cd7aaccc5b890ad20badac0cfc9a3e7d4af1e
4
- data.tar.gz: f756c5bd11f44e7e8984d6cb6b29ec409dc082d83864ee4bd82c50adc784310c
3
+ metadata.gz: 25e2163752aa77abea2905cb28191358f69e1f8f473bbfa05db10d89d3fe4543
4
+ data.tar.gz: 4641471ff3338d4132c409d28a9f4ce5d77677525be98737550288b8b22f3693
5
5
  SHA512:
6
- metadata.gz: 8815314944335c3b69196bf5ba8779e10ec78a3146e8cc51d4d278d5e444035c8bc50858315c45e5556337e925ab94f4afee6b3283839079db6892292bec3581
7
- data.tar.gz: 42c9159369779c50a9904525239d5bf3daa62ed5373104cb11f80251b9481a8225290421d0d9a5663e9f7c7e0bd4d73c63a53bc44845dc4b5bc23a0419869301
6
+ metadata.gz: f780668314e90a1f3584e77785d6f514c30996fa6f94dc994c670d96c75df681f368c50a3cb1615cfa0e86224a5f7524afd03e323f4c0a9ed32394c6cd04022a
7
+ data.tar.gz: 89fa4c33d4fd03f1f63ac1e0dac274379d37cc11ee9fdb250d12ebb377c6aadfeae1d87edc69c1fcf65e800401b4f195d129f4ef20d937c62245064b4ac927be
data/docs/CHANGELOG.md CHANGED
@@ -10,6 +10,48 @@ nav_order: 5
10
10
 
11
11
  ## main
12
12
 
13
+ ## 3.15.0
14
+
15
+ * Add basic internal testing for memory allocations.
16
+
17
+ *Joel Hawksley*
18
+
19
+ * Add support for request formats.
20
+
21
+ *Joel Hawksley*
22
+
23
+ * Add `rendered_json` test helper.
24
+
25
+ *Joel Hawksley*
26
+
27
+ * Add `with_format` test helper.
28
+
29
+ *Joel Hawksley*
30
+
31
+ * Warn if using Ruby < 3.2 or Rails < 7.1, which won't be supported by ViewComponent v4, to be released no earlier than April 1, 2025.
32
+
33
+ *Joel Hawksley*
34
+
35
+ * Add Kicksite to list of companies using ViewComponent.
36
+
37
+ *Adil Lari*
38
+
39
+ * Allow overridden slot methods to use `super`.
40
+
41
+ *Andrew Schwartz*
42
+
43
+ * Add Rails engine support to generators.
44
+
45
+ *Tomasz Kowalewski*
46
+
47
+ * Register stats directories with `Rails::CodeStatistics.register_directory` to support `rails stats` in Rails 8.
48
+
49
+ *Petrik de Heus*
50
+
51
+ * Fixed type declaration for `ViewComponent::TestHelpers.with_controller_class` parameter.
52
+
53
+ *Graham Rogers*
54
+
13
55
  ## 3.14.0
14
56
 
15
57
  * Defer to built-in caching for language environment setup, rather than manually using `actions/cache` in CI.
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ <% module_namespacing do -%>
3
4
  class <%= class_name %>Component < <%= parent_class %>
4
5
  <%- if initialize_signature -%>
5
6
  def initialize(<%= initialize_signature %>)
@@ -11,5 +12,5 @@ class <%= class_name %>Component < <%= parent_class %>
11
12
  content_tag :h1, "Hello world!"<%= ", data: { controller: \"#{stimulus_controller}\" }" if options["stimulus"] %>
12
13
  end
13
14
  <%- end -%>
14
-
15
15
  end
16
+ <% end -%>
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ <% module_namespacing do -%>
3
4
  class <%= class_name %>ComponentPreview < ViewComponent::Preview
4
5
  def default
5
6
  render(<%= class_name %>Component.new<%= "(#{render_signature})" if render_signature %>)
6
7
  end
7
8
  end
9
+ <% end -%>
@@ -2,7 +2,7 @@
2
2
 
3
3
  require "rails_helper"
4
4
 
5
- RSpec.describe <%= class_name %>Component, type: :component do
5
+ RSpec.describe <%= namespaced? ? "#{namespace.name}::" : '' %><%= class_name %>Component, type: :component do
6
6
  pending "add some examples to (or delete) #{__FILE__}"
7
7
 
8
8
  # it "renders something useful" do
@@ -2,7 +2,7 @@
2
2
 
3
3
  require "test_helper"
4
4
 
5
- class <%= class_name %>ComponentTest < ViewComponent::TestCase
5
+ class <%= namespaced? ? "#{namespace.name}::" : '' %><%= class_name %>ComponentTest < ViewComponent::TestCase
6
6
  def test_component_renders_something_useful
7
7
  # assert_equal(
8
8
  # %(<span>Hello, components!</span>),
@@ -11,6 +11,7 @@ require "view_component/inline_template"
11
11
  require "view_component/preview"
12
12
  require "view_component/slotable"
13
13
  require "view_component/slotable_default"
14
+ require "view_component/template"
14
15
  require "view_component/translatable"
15
16
  require "view_component/with_content_helper"
16
17
  require "view_component/use_helpers"
@@ -35,6 +36,7 @@ module ViewComponent
35
36
  include ViewComponent::WithContentHelper
36
37
 
37
38
  RESERVED_PARAMETER = :content
39
+ VC_INTERNAL_DEFAULT_FORMAT = :html
38
40
 
39
41
  # For CSRF authenticity tokens in forms
40
42
  delegate :form_authenticity_token, :protect_against_forgery?, :config, to: :helpers
@@ -106,9 +108,16 @@ module ViewComponent
106
108
  before_render
107
109
 
108
110
  if render?
109
- # Avoid allocating new string when output_preamble and output_postamble are blank
110
- rendered_template = safe_render_template_for(@__vc_variant).to_s
111
+ rendered_template =
112
+ if compiler.renders_template_for?(@__vc_variant, request&.format&.to_sym)
113
+ render_template_for(@__vc_variant, request&.format&.to_sym)
114
+ else
115
+ maybe_escape_html(render_template_for(@__vc_variant, request&.format&.to_sym)) do
116
+ Kernel.warn("WARNING: The #{self.class} component rendered HTML-unsafe output. The output will be automatically escaped, but you may want to investigate.")
117
+ end
118
+ end.to_s
111
119
 
120
+ # Avoid allocating new string when output_preamble and output_postamble are blank
112
121
  if output_preamble.blank? && output_postamble.blank?
113
122
  rendered_template
114
123
  else
@@ -330,16 +339,6 @@ module ViewComponent
330
339
  end
331
340
  end
332
341
 
333
- def safe_render_template_for(variant)
334
- if compiler.renders_template_for_variant?(variant)
335
- render_template_for(variant)
336
- else
337
- maybe_escape_html(render_template_for(variant)) do
338
- Kernel.warn("WARNING: The #{self.class} component rendered HTML-unsafe output. The output will be automatically escaped, but you may want to investigate.")
339
- end
340
- end
341
- end
342
-
343
342
  def safe_output_preamble
344
343
  maybe_escape_html(output_preamble) do
345
344
  Kernel.warn("WARNING: The #{self.class} component was provided an HTML-unsafe preamble. The preamble will be automatically escaped, but you may want to investigate.")
@@ -500,13 +499,6 @@ module ViewComponent
500
499
  Collection.new(self, collection, **args)
501
500
  end
502
501
 
503
- # Provide identifier for ActionView template annotations
504
- #
505
- # @private
506
- def short_identifier
507
- @short_identifier ||= defined?(Rails.root) ? source_location.sub("#{Rails.root}/", "") : source_location
508
- end
509
-
510
502
  # @private
511
503
  def inherited(child)
512
504
  # Compile so child will inherit compiled `call_*` template methods that
@@ -519,12 +511,12 @@ module ViewComponent
519
511
  # meaning it will not be called for any children and thus not compile their templates.
520
512
  if !child.instance_methods(false).include?(:render_template_for) && !child.compiled?
521
513
  child.class_eval <<~RUBY, __FILE__, __LINE__ + 1
522
- def render_template_for(variant = nil)
514
+ def render_template_for(variant = nil, format = nil)
523
515
  # Force compilation here so the compiler always redefines render_template_for.
524
516
  # This is mostly a safeguard to prevent infinite recursion.
525
517
  self.class.compile(raise_errors: true, force: true)
526
518
  # .compile replaces this method; call the new one
527
- render_template_for(variant)
519
+ render_template_for(variant, format)
528
520
  end
529
521
  RUBY
530
522
  end
@@ -572,10 +564,6 @@ module ViewComponent
572
564
  compile unless compiled?
573
565
  end
574
566
 
575
- # Compile templates to instance methods, assuming they haven't been compiled already.
576
- #
577
- # Do as much work as possible in this step, as doing so reduces the amount
578
- # of work done each time a component is rendered.
579
567
  # @private
580
568
  def compile(raise_errors: false, force: false)
581
569
  compiler.compile(raise_errors: raise_errors, force: force)
@@ -586,22 +574,6 @@ module ViewComponent
586
574
  @__vc_compiler ||= Compiler.new(self)
587
575
  end
588
576
 
589
- # we'll eventually want to update this to support other types
590
- # @private
591
- def type
592
- "text/html"
593
- end
594
-
595
- # @private
596
- def format
597
- :html
598
- end
599
-
600
- # @private
601
- def identifier
602
- source_location
603
- end
604
-
605
577
  # Set the parameter name used when rendering elements of a collection ([documentation](/guide/collections)):
606
578
  #
607
579
  # ```ruby
@@ -7,7 +7,6 @@ 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
 
13
12
  attr_accessor :__vc_original_view_context
@@ -41,6 +40,12 @@ module ViewComponent
41
40
  components.each(&block)
42
41
  end
43
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
+
44
49
  private
45
50
 
46
51
  def initialize(component, object, **options)
@@ -4,308 +4,228 @@ require "concurrent-ruby"
4
4
 
5
5
  module ViewComponent
6
6
  class Compiler
7
- # Compiler mode. Can be either:
8
- # * 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,
9
9
  # default in Rails development and test mode)
10
- # * production (a non-blocking mode, default in Rails production mode)
11
- DEVELOPMENT_MODE = :development
12
- PRODUCTION_MODE = :production
10
+ # * false(a non-blocking mode, default in Rails production mode)
11
+ class_attribute :development_mode, default: false
13
12
 
14
- class_attribute :mode, default: PRODUCTION_MODE
15
-
16
- def initialize(component_class)
17
- @component_class = component_class
13
+ def initialize(component)
14
+ @component = component
18
15
  @redefinition_lock = Mutex.new
19
- @variants_rendering_templates = Set.new
16
+ @rendered_templates = Set.new
20
17
  end
21
18
 
22
19
  def compiled?
23
- CompileCache.compiled?(component_class)
24
- end
25
-
26
- def development?
27
- self.class.mode == DEVELOPMENT_MODE
20
+ CompileCache.compiled?(@component)
28
21
  end
29
22
 
30
23
  def compile(raise_errors: false, force: false)
31
24
  return if compiled? && !force
32
- return if component_class == ViewComponent::Base
33
-
34
- component_class.superclass.compile(raise_errors: raise_errors) if should_compile_superclass?
35
-
36
- if template_errors.present?
37
- raise TemplateError.new(template_errors) if raise_errors
25
+ return if @component == ViewComponent::Base
38
26
 
39
- return false
40
- end
27
+ gather_templates
41
28
 
42
- if raise_errors
43
- component_class.validate_initialization_parameters!
44
- component_class.validate_collection_parameter!
29
+ if self.class.development_mode && @templates.any?(&:requires_compiled_superclass?)
30
+ @component.superclass.compile(raise_errors: raise_errors)
45
31
  end
46
32
 
47
- if has_inline_template?
48
- template = component_class.inline_template
49
-
50
- redefinition_lock.synchronize do
51
- component_class.silence_redefinition_of_method("call")
52
- # rubocop:disable Style/EvalWithLocation
53
- component_class.class_eval <<-RUBY, template.path, template.lineno
54
- def call
55
- #{compiled_inline_template(template)}
56
- end
57
- RUBY
58
- # rubocop:enable Style/EvalWithLocation
59
-
60
- component_class.define_method(:"_call_#{safe_class_name}", component_class.instance_method(:call))
33
+ return if gather_template_errors(raise_errors).any?
61
34
 
62
- component_class.silence_redefinition_of_method("render_template_for")
63
- component_class.class_eval <<-RUBY, __FILE__, __LINE__ + 1
64
- def render_template_for(variant = nil)
65
- _call_#{safe_class_name}
66
- end
67
- RUBY
68
- end
69
- else
70
- templates.each do |template|
71
- method_name = call_method_name(template[:variant])
72
- @variants_rendering_templates << template[:variant]
73
-
74
- redefinition_lock.synchronize do
75
- component_class.silence_redefinition_of_method(method_name)
76
- # rubocop:disable Style/EvalWithLocation
77
- component_class.class_eval <<-RUBY, template[:path], 0
78
- def #{method_name}
79
- #{compiled_template(template[:path])}
80
- end
81
- RUBY
82
- # rubocop:enable Style/EvalWithLocation
83
- end
84
- end
85
-
86
- define_render_template_for
35
+ if raise_errors
36
+ @component.validate_initialization_parameters!
37
+ @component.validate_collection_parameter!
87
38
  end
88
39
 
89
- component_class.registered_slots.each do |slot_name, config|
90
- config[:default_method] = component_class.instance_methods.find { |method_name| method_name == :"default_#{slot_name}" }
40
+ define_render_template_for
91
41
 
92
- component_class.registered_slots[slot_name] = config
93
- end
42
+ @component.register_default_slots
43
+ @component.build_i18n_backend
94
44
 
95
- component_class.build_i18n_backend
96
-
97
- CompileCache.register(component_class)
45
+ CompileCache.register(@component)
98
46
  end
99
47
 
100
- def renders_template_for_variant?(variant)
101
- @variants_rendering_templates.include?(variant)
48
+ def renders_template_for?(variant, format)
49
+ @rendered_templates.include?([variant, format])
102
50
  end
103
51
 
104
52
  private
105
53
 
106
- attr_reader :component_class, :redefinition_lock
54
+ attr_reader :templates
107
55
 
108
56
  def define_render_template_for
109
- variant_elsifs = variants.compact.uniq.map do |variant|
110
- safe_name = "_call_variant_#{normalized_variant_name(variant)}_#{safe_class_name}"
111
- component_class.define_method(safe_name, component_class.instance_method(call_method_name(variant)))
112
-
113
- "elsif variant.to_sym == :'#{variant}'\n #{safe_name}"
114
- end.join("\n")
115
-
116
- component_class.define_method(:"_call_#{safe_class_name}", component_class.instance_method(:call))
57
+ @templates.each do |template|
58
+ @redefinition_lock.synchronize do
59
+ template.compile_to_component
60
+ end
61
+ end
117
62
 
118
- body = <<-RUBY
119
- if variant.nil?
120
- _call_#{safe_class_name}
121
- #{variant_elsifs}
63
+ method_body =
64
+ if @templates.one?
65
+ @templates.first.safe_method_name
66
+ elsif (template = @templates.find(&:inline?))
67
+ template.safe_method_name
122
68
  else
123
- _call_#{safe_class_name}
69
+ branches = []
70
+
71
+ @templates.each do |template|
72
+ conditional =
73
+ if template.inline_call?
74
+ "variant&.to_sym == #{template.variant.inspect}"
75
+ else
76
+ [
77
+ template.default_format? ? "(format == #{ViewComponent::Base::VC_INTERNAL_DEFAULT_FORMAT.inspect} || format.nil?)" : "format == #{template.format.inspect}",
78
+ template.variant.nil? ? "variant.nil?" : "variant&.to_sym == #{template.variant.inspect}"
79
+ ].join(" && ")
80
+ end
81
+
82
+ branches << [conditional, template.safe_method_name]
83
+ end
84
+
85
+ out = branches.each_with_object(+"") do |(conditional, branch_body), memo|
86
+ memo << "#{(!memo.present?) ? "if" : "elsif"} #{conditional}\n #{branch_body}\n"
87
+ end
88
+ out << "else\n #{templates.find { _1.variant.nil? && _1.default_format? }.safe_method_name}\nend"
124
89
  end
125
- RUBY
126
90
 
127
- redefinition_lock.synchronize do
128
- component_class.silence_redefinition_of_method(:render_template_for)
129
- component_class.class_eval <<-RUBY, __FILE__, __LINE__ + 1
130
- def render_template_for(variant = nil)
131
- #{body}
91
+ @redefinition_lock.synchronize do
92
+ @component.silence_redefinition_of_method(:render_template_for)
93
+ @component.class_eval <<-RUBY, __FILE__, __LINE__ + 1
94
+ def render_template_for(variant = nil, format = nil)
95
+ #{method_body}
132
96
  end
133
97
  RUBY
134
98
  end
135
99
  end
136
100
 
137
- def has_inline_template?
138
- component_class.respond_to?(:inline_template) && component_class.inline_template.present?
139
- end
101
+ def gather_template_errors(raise_errors)
102
+ errors = []
140
103
 
141
- def template_errors
142
- @__vc_template_errors ||=
143
- begin
144
- errors = []
104
+ errors << "Couldn't find a template file or inline render method for #{@component}." if @templates.empty?
145
105
 
146
- if (templates + inline_calls).empty? && !has_inline_template?
147
- errors << "Couldn't find a template file or inline render method for #{component_class}."
148
- end
106
+ # We currently allow components to have both an inline call method and a template for a variant, with the
107
+ # inline call method overriding the template. We should aim to change this in v4 to instead
108
+ # raise an error.
109
+ @templates.reject(&:inline_call?)
110
+ .map { |template| [template.variant, template.format] }
111
+ .tally
112
+ .select { |_, count| count > 1 }
113
+ .each do |tally|
114
+ variant, this_format = tally.first
149
115
 
150
- if templates.count { |template| template[:variant].nil? } > 1
151
- errors <<
152
- "More than one template found for #{component_class}. " \
153
- "There can only be one default template file per component."
154
- end
116
+ variant_string = " for variant `#{variant}`" if variant.present?
155
117
 
156
- invalid_variants =
157
- templates
158
- .group_by { |template| template[:variant] }
159
- .map { |variant, grouped| variant if grouped.length > 1 }
160
- .compact
161
- .sort
162
-
163
- unless invalid_variants.empty?
164
- errors <<
165
- "More than one template found for #{"variant".pluralize(invalid_variants.count)} " \
166
- "#{invalid_variants.map { |v| "'#{v}'" }.to_sentence} in #{component_class}. " \
167
- "There can only be one template file per variant."
168
- end
118
+ errors << "More than one #{this_format.upcase} template found#{variant_string} for #{@component}. "
119
+ end
169
120
 
170
- if templates.find { |template| template[:variant].nil? } && inline_calls_defined_on_self.include?(:call)
171
- errors <<
172
- "Template file and inline render method found for #{component_class}. " \
173
- "There can only be a template file or inline render method per component."
174
- end
121
+ default_template_types = @templates.each_with_object(Set.new) do |template, memo|
122
+ next if template.variant
175
123
 
176
- duplicate_template_file_and_inline_variant_calls =
177
- templates.pluck(:variant) & variants_from_inline_calls(inline_calls_defined_on_self)
124
+ memo << :template_file if !template.inline_call?
125
+ memo << :inline_render if template.inline_call? && template.defined_on_self?
178
126
 
179
- unless duplicate_template_file_and_inline_variant_calls.empty?
180
- count = duplicate_template_file_and_inline_variant_calls.count
127
+ memo
128
+ end
181
129
 
182
- errors <<
183
- "Template #{"file".pluralize(count)} and inline render #{"method".pluralize(count)} " \
184
- "found for #{"variant".pluralize(count)} " \
185
- "#{duplicate_template_file_and_inline_variant_calls.map { |v| "'#{v}'" }.to_sentence} " \
186
- "in #{component_class}. " \
187
- "There can only be a template file or inline render method per variant."
188
- end
130
+ if default_template_types.length > 1
131
+ errors <<
132
+ "Template file and inline render method found for #{@component}. " \
133
+ "There can only be a template file or inline render method per component."
134
+ end
189
135
 
190
- uniq_variants = variants.compact.uniq
191
- normalized_variants = uniq_variants.map { |variant| normalized_variant_name(variant) }
136
+ # If a template has inline calls, they can conflict with template files the component may use
137
+ # to render. This attempts to catch and raise that issue before run time. For example,
138
+ # `def render_mobile` would conflict with a sidecar template of `component.html+mobile.erb`
139
+ duplicate_template_file_and_inline_call_variants =
140
+ @templates.reject(&:inline_call?).map(&:variant) &
141
+ @templates.select { _1.inline_call? && _1.defined_on_self? }.map(&:variant)
142
+
143
+ unless duplicate_template_file_and_inline_call_variants.empty?
144
+ count = duplicate_template_file_and_inline_call_variants.count
145
+
146
+ errors <<
147
+ "Template #{"file".pluralize(count)} and inline render #{"method".pluralize(count)} " \
148
+ "found for #{"variant".pluralize(count)} " \
149
+ "#{duplicate_template_file_and_inline_call_variants.map { |v| "'#{v}'" }.to_sentence} " \
150
+ "in #{@component}. There can only be a template file or inline render method per variant."
151
+ end
192
152
 
193
- colliding_variants = uniq_variants.select do |variant|
194
- normalized_variants.count(normalized_variant_name(variant)) > 1
195
- end
153
+ @templates.select(&:variant).each_with_object(Hash.new { |h, k| h[k] = Set.new }) do |template, memo|
154
+ memo[template.normalized_variant_name] << template.variant
155
+ memo
156
+ end.each do |_, variant_names|
157
+ next unless variant_names.length > 1
196
158
 
197
- unless colliding_variants.empty?
198
- errors <<
199
- "Colliding templates #{colliding_variants.sort.map { |v| "'#{v}'" }.to_sentence} " \
200
- "found in #{component_class}."
201
- end
159
+ errors << "Colliding templates #{variant_names.sort.map { |v| "'#{v}'" }.to_sentence} found in #{@component}."
160
+ end
202
161
 
203
- errors
204
- end
162
+ raise TemplateError.new(errors) if errors.any? && raise_errors
163
+
164
+ errors
205
165
  end
206
166
 
207
- def templates
167
+ def gather_templates
208
168
  @templates ||=
209
169
  begin
210
- extensions = ActionView::Template.template_handler_extensions
211
-
212
- component_class.sidecar_files(extensions).each_with_object([]) do |path, memo|
213
- pieces = File.basename(path).split(".")
214
- memo << {
170
+ templates = @component.sidecar_files(
171
+ ActionView::Template.template_handler_extensions
172
+ ).map do |path|
173
+ # Extract format and variant from template filename
174
+ this_format, variant =
175
+ File
176
+ .basename(path) # "variants_component.html+mini.watch.erb"
177
+ .split(".")[1..-2] # ["html+mini", "watch"]
178
+ .join(".") # "html+mini.watch"
179
+ .split("+") # ["html", "mini.watch"]
180
+ .map(&:to_sym) # [:html, :"mini.watch"]
181
+
182
+ out = Template.new(
183
+ component: @component,
184
+ type: :file,
215
185
  path: path,
216
- variant: pieces[1..-2].join(".").split("+").second&.to_sym,
217
- handler: pieces.last
218
- }
219
- end
220
- end
221
- end
222
-
223
- def inline_calls
224
- @inline_calls ||=
225
- begin
226
- # Fetch only ViewComponent ancestor classes to limit the scope of
227
- # finding inline calls
228
- view_component_ancestors =
229
- (
230
- component_class.ancestors.take_while { |ancestor| ancestor != ViewComponent::Base } -
231
- component_class.included_modules
186
+ lineno: 0,
187
+ extension: path.split(".").last,
188
+ this_format: this_format,
189
+ variant: variant
232
190
  )
233
191
 
234
- view_component_ancestors.flat_map { |ancestor| ancestor.instance_methods(false).grep(/^call(_|$)/) }.uniq
235
- end
236
- end
237
-
238
- def inline_calls_defined_on_self
239
- @inline_calls_defined_on_self ||= component_class.instance_methods(false).grep(/^call(_|$)/)
240
- end
241
-
242
- def variants
243
- @__vc_variants = (
244
- templates.map { |template| template[:variant] } + variants_from_inline_calls(inline_calls)
245
- ).compact.uniq
246
- end
247
-
248
- def variants_from_inline_calls(calls)
249
- calls.reject { |call| call == :call }.map do |variant_call|
250
- variant_call.to_s.sub("call_", "").to_sym
251
- end
252
- end
253
-
254
- def compiled_inline_template(template)
255
- handler = ActionView::Template.handler_for_extension(template.language)
256
- template = template.source.dup
192
+ # TODO: We should consider inlining the HTML output safety logic into the compiled render_template_for
193
+ # instead of this indirect approach
194
+ @rendered_templates << [out.variant, out.this_format]
257
195
 
258
- compile_template(template, handler)
259
- end
260
-
261
- def compiled_template(file_path)
262
- handler = ActionView::Template.handler_for_extension(File.extname(file_path).delete("."))
263
- template = File.read(file_path)
264
-
265
- compile_template(template, handler)
266
- end
267
-
268
- def compile_template(template, handler)
269
- template.rstrip! if component_class.strip_trailing_whitespace?
270
-
271
- if handler.method(:call).parameters.length > 1
272
- handler.call(component_class, template)
273
- # :nocov:
274
- else
275
- handler.call(
276
- OpenStruct.new(
277
- source: template,
278
- identifier: component_class.identifier,
279
- type: component_class.type
280
- )
281
- )
282
- end
283
- # :nocov:
284
- end
285
-
286
- def call_method_name(variant)
287
- if variant.present? && variants.include?(variant)
288
- "call_#{normalized_variant_name(variant)}"
289
- else
290
- "call"
291
- end
292
- end
293
-
294
- def normalized_variant_name(variant)
295
- variant.to_s.gsub("-", "__").gsub(".", "___")
296
- end
196
+ out
197
+ end
297
198
 
298
- def safe_class_name
299
- @safe_class_name ||= component_class.name.underscore.gsub("/", "__")
300
- end
199
+ component_instance_methods_on_self = @component.instance_methods(false)
200
+
201
+ (
202
+ @component.ancestors.take_while { |ancestor| ancestor != ViewComponent::Base } - @component.included_modules
203
+ ).flat_map { |ancestor| ancestor.instance_methods(false).grep(/^call(_|$)/) }
204
+ .uniq
205
+ .each do |method_name|
206
+ templates << Template.new(
207
+ component: @component,
208
+ type: :inline_call,
209
+ this_format: ViewComponent::Base::VC_INTERNAL_DEFAULT_FORMAT,
210
+ variant: method_name.to_s.include?("call_") ? method_name.to_s.sub("call_", "").to_sym : nil,
211
+ method_name: method_name,
212
+ defined_on_self: component_instance_methods_on_self.include?(method_name)
213
+ )
214
+ end
301
215
 
302
- def should_compile_superclass?
303
- development? && templates.empty? && !has_inline_template? && !call_defined?
304
- end
216
+ if @component.inline_template.present?
217
+ templates << Template.new(
218
+ component: @component,
219
+ type: :inline,
220
+ path: @component.inline_template.path,
221
+ lineno: @component.inline_template.lineno,
222
+ source: @component.inline_template.source.dup,
223
+ extension: @component.inline_template.language
224
+ )
225
+ end
305
226
 
306
- def call_defined?
307
- component_class.instance_methods(false).include?(:call) ||
308
- component_class.private_instance_methods(false).include?(:call)
227
+ templates
228
+ end
309
229
  end
310
230
  end
311
231
  end
@@ -168,11 +168,29 @@ module ViewComponent
168
168
  # Defaults to `false`.
169
169
 
170
170
  def default_preview_paths
171
+ (default_rails_preview_paths + default_rails_engines_preview_paths).uniq
172
+ end
173
+
174
+ def default_rails_preview_paths
171
175
  return [] unless defined?(Rails.root) && Dir.exist?("#{Rails.root}/test/components/previews")
172
176
 
173
177
  ["#{Rails.root}/test/components/previews"]
174
178
  end
175
179
 
180
+ def default_rails_engines_preview_paths
181
+ return [] unless defined?(Rails::Engine)
182
+
183
+ registered_rails_engines_with_previews.map do |descendant|
184
+ "#{descendant.root}/test/components/previews"
185
+ end
186
+ end
187
+
188
+ def registered_rails_engines_with_previews
189
+ Rails::Engine.descendants.select do |descendant|
190
+ defined?(descendant.root) && Dir.exist?("#{descendant.root}/test/components/previews")
191
+ end
192
+ end
193
+
176
194
  def default_generate_options
177
195
  options = ActiveSupport::OrderedOptions.new(false)
178
196
  options.preview_path = ""
@@ -8,8 +8,16 @@ module ViewComponent
8
8
  class Engine < Rails::Engine # :nodoc:
9
9
  config.view_component = ViewComponent::Config.current
10
10
 
11
- rake_tasks do
12
- load "view_component/rails/tasks/view_component.rake"
11
+ if Rails.version.to_f < 8.0
12
+ rake_tasks do
13
+ load "view_component/rails/tasks/view_component.rake"
14
+ end
15
+ else
16
+ initializer "view_component.stats_directories" do |app|
17
+ require "rails/code_statistics"
18
+ dir = ViewComponent::Base.view_component_path
19
+ Rails::CodeStatistics.register_directory("ViewComponents", dir)
20
+ end
13
21
  end
14
22
 
15
23
  initializer "view_component.set_configs" do |app|
@@ -128,11 +136,7 @@ module ViewComponent
128
136
  end
129
137
 
130
138
  initializer "compiler mode" do |_app|
131
- ViewComponent::Compiler.mode = if Rails.env.development? || Rails.env.test?
132
- ViewComponent::Compiler::DEVELOPMENT_MODE
133
- else
134
- ViewComponent::Compiler::PRODUCTION_MODE
135
- end
139
+ ViewComponent::Compiler.development_mode = (Rails.env.development? || Rails.env.test?)
136
140
  end
137
141
 
138
142
  config.after_initialize do |app|
@@ -165,12 +169,12 @@ module ViewComponent
165
169
  end
166
170
 
167
171
  # :nocov:
168
- if RUBY_VERSION < "3.0.0"
169
- ViewComponent::Deprecation.deprecation_warning("Support for Ruby versions < 3.0.0", "ViewComponent 4.0 will remove support for Ruby versions < 3.0.0 ")
172
+ if RUBY_VERSION < "3.2.0"
173
+ ViewComponent::Deprecation.deprecation_warning("Support for Ruby versions < 3.2.0", "ViewComponent v4 will remove support for Ruby versions < 3.2.0 no earlier than April 1, 2025")
170
174
  end
171
175
 
172
- if Rails.version.to_f < 6.1
173
- ViewComponent::Deprecation.deprecation_warning("Support for Rails versions < 6.1", "ViewComponent 4.0 will remove support for Rails versions < 6.1 ")
176
+ if Rails.version.to_f < 7.1
177
+ ViewComponent::Deprecation.deprecation_warning("Support for Rails versions < 7.1", "ViewComponent v4 will remove support for Rails versions < 7.1 no earlier than April 1, 2025")
174
178
  end
175
179
  # :nocov:
176
180
 
@@ -18,7 +18,7 @@ module ViewComponent
18
18
 
19
19
  class TemplateError < StandardError
20
20
  def initialize(errors)
21
- super(errors.join(", "))
21
+ super(errors.join("\n"))
22
22
  end
23
23
  end
24
24
 
@@ -13,7 +13,7 @@ module ViewComponent # :nodoc:
13
13
  notification_name,
14
14
  {
15
15
  name: self.class.name,
16
- identifier: self.class.identifier
16
+ identifier: self.class.source_location
17
17
  }
18
18
  ) do
19
19
  super
@@ -89,11 +89,11 @@ module ViewComponent
89
89
  end
90
90
  ruby2_keywords(setter_method_name) if respond_to?(:ruby2_keywords, true)
91
91
 
92
- define_method slot_name do
92
+ self::GeneratedSlotMethods.define_method slot_name do
93
93
  get_slot(slot_name)
94
94
  end
95
95
 
96
- define_method :"#{slot_name}?" do
96
+ self::GeneratedSlotMethods.define_method :"#{slot_name}?" do
97
97
  get_slot(slot_name).present?
98
98
  end
99
99
 
@@ -176,11 +176,11 @@ module ViewComponent
176
176
  end
177
177
  end
178
178
 
179
- define_method slot_name do
179
+ self::GeneratedSlotMethods.define_method slot_name do
180
180
  get_slot(slot_name)
181
181
  end
182
182
 
183
- define_method :"#{slot_name}?" do
183
+ self::GeneratedSlotMethods.define_method :"#{slot_name}?" do
184
184
  get_slot(slot_name).present?
185
185
  end
186
186
 
@@ -199,19 +199,28 @@ module ViewComponent
199
199
  end
200
200
  end
201
201
 
202
- # Clone slot configuration into child class
203
- # see #test_slots_pollution
204
202
  def inherited(child)
203
+ # Clone slot configuration into child class
204
+ # see #test_slots_pollution
205
205
  child.registered_slots = registered_slots.clone
206
+
207
+ # Add a module for slot methods, allowing them to be overriden by the component class
208
+ # see #test_slot_name_can_be_overriden
209
+ unless child.const_defined?(:GeneratedSlotMethods, false)
210
+ generated_slot_methods = Module.new
211
+ child.const_set(:GeneratedSlotMethods, generated_slot_methods)
212
+ child.include generated_slot_methods
213
+ end
214
+
206
215
  super
207
216
  end
208
217
 
209
218
  def register_polymorphic_slot(slot_name, types, collection:)
210
- define_method(slot_name) do
219
+ self::GeneratedSlotMethods.define_method(slot_name) do
211
220
  get_slot(slot_name)
212
221
  end
213
222
 
214
- define_method(:"#{slot_name}?") do
223
+ self::GeneratedSlotMethods.define_method(:"#{slot_name}?") do
215
224
  get_slot(slot_name).present?
216
225
  end
217
226
 
@@ -259,6 +268,15 @@ module ViewComponent
259
268
  }
260
269
  end
261
270
 
271
+ # Called by the compiler, as instance methods are not defined when slots are first registered
272
+ def register_default_slots
273
+ registered_slots.each do |slot_name, config|
274
+ config[:default_method] = instance_methods.find { |method_name| method_name == :"default_#{slot_name}" }
275
+
276
+ registered_slots[slot_name] = config
277
+ end
278
+ end
279
+
262
280
  private
263
281
 
264
282
  def register_slot(slot_name, **kwargs)
@@ -0,0 +1,123 @@
1
+ require "ostruct"
2
+
3
+ module ViewComponent
4
+ class Template
5
+ attr_reader :variant, :this_format, :type
6
+
7
+ def initialize(
8
+ component:,
9
+ type:,
10
+ this_format: nil,
11
+ variant: nil,
12
+ lineno: nil,
13
+ path: nil,
14
+ extension: nil,
15
+ source: nil,
16
+ method_name: nil,
17
+ defined_on_self: true
18
+ )
19
+ @component = component
20
+ @type = type
21
+ @this_format = this_format
22
+ @variant = variant&.to_sym
23
+ @lineno = lineno
24
+ @path = path
25
+ @extension = extension
26
+ @source = source
27
+ @method_name = method_name
28
+ @defined_on_self = defined_on_self
29
+
30
+ @source_originally_nil = @source.nil?
31
+
32
+ @call_method_name =
33
+ if @method_name
34
+ @method_name
35
+ else
36
+ out = +"call"
37
+ out << "_#{normalized_variant_name}" if @variant.present?
38
+ out << "_#{@this_format}" if @this_format.present? && @this_format != ViewComponent::Base::VC_INTERNAL_DEFAULT_FORMAT
39
+ out
40
+ end
41
+ end
42
+
43
+ def compile_to_component
44
+ if !inline_call?
45
+ @component.silence_redefinition_of_method(@call_method_name)
46
+
47
+ # rubocop:disable Style/EvalWithLocation
48
+ @component.class_eval <<-RUBY, @path, @lineno
49
+ def #{@call_method_name}
50
+ #{compiled_source}
51
+ end
52
+ RUBY
53
+ # rubocop:enable Style/EvalWithLocation
54
+ end
55
+
56
+ @component.define_method(safe_method_name, @component.instance_method(@call_method_name))
57
+ end
58
+
59
+ def requires_compiled_superclass?
60
+ inline_call? && !defined_on_self?
61
+ end
62
+
63
+ def inline_call?
64
+ @type == :inline_call
65
+ end
66
+
67
+ def inline?
68
+ @type == :inline
69
+ end
70
+
71
+ def default_format?
72
+ @this_format == ViewComponent::Base::VC_INTERNAL_DEFAULT_FORMAT
73
+ end
74
+
75
+ def format
76
+ @this_format
77
+ end
78
+
79
+ def safe_method_name
80
+ "_#{@call_method_name}_#{@component.name.underscore.gsub("/", "__")}"
81
+ end
82
+
83
+ def normalized_variant_name
84
+ @variant.to_s.gsub("-", "__").gsub(".", "___")
85
+ end
86
+
87
+ def defined_on_self?
88
+ @defined_on_self
89
+ end
90
+
91
+ private
92
+
93
+ def source
94
+ if @source_originally_nil
95
+ # Load file each time we look up #source in case the file has been modified
96
+ File.read(@path)
97
+ else
98
+ @source
99
+ end
100
+ end
101
+
102
+ def compiled_source
103
+ handler = ActionView::Template.handler_for_extension(@extension)
104
+ this_source = source
105
+ this_source.rstrip! if @component.strip_trailing_whitespace?
106
+
107
+ short_identifier = defined?(Rails.root) ? @path.sub("#{Rails.root}/", "") : @path
108
+ type = ActionView::Template::Types[@this_format]
109
+
110
+ if handler.method(:call).parameters.length > 1
111
+ handler.call(
112
+ OpenStruct.new(format: @this_format, identifier: @path, short_identifier: short_identifier, type: type),
113
+ this_source
114
+ )
115
+ # :nocov:
116
+ # TODO: Remove in v4
117
+ else
118
+ handler.call(OpenStruct.new(source: this_source, identifier: @path, type: type))
119
+ end
120
+ # :nocov:
121
+ end
122
+ end
123
+ end
@@ -63,6 +63,16 @@ module ViewComponent
63
63
  Nokogiri::HTML.fragment(@rendered_content)
64
64
  end
65
65
 
66
+ # `JSON.parse`-d component output.
67
+ #
68
+ # ```ruby
69
+ # render_inline(MyJsonComponent.new)
70
+ # assert_equal(rendered_json["hello"], "world")
71
+ # ```
72
+ def rendered_json
73
+ JSON.parse(rendered_content)
74
+ end
75
+
66
76
  # Render a preview inline. Internally sets `page` to be a `Capybara::Node::Simple`,
67
77
  # allowing for Capybara assertions to be used:
68
78
  #
@@ -145,7 +155,7 @@ module ViewComponent
145
155
  # end
146
156
  # ```
147
157
  #
148
- # @param klass [ActionController::Base] The controller to be used.
158
+ # @param klass [Class<ActionController::Base>] The controller to be used.
149
159
  def with_controller_class(klass)
150
160
  old_controller = defined?(@vc_test_controller) && @vc_test_controller
151
161
 
@@ -155,6 +165,19 @@ module ViewComponent
155
165
  @vc_test_controller = old_controller
156
166
  end
157
167
 
168
+ # Set format of the current request
169
+ #
170
+ # ```ruby
171
+ # with_format(:json) do
172
+ # render_inline(MyComponent.new)
173
+ # end
174
+ # ```
175
+ #
176
+ # @param format [Symbol] The format to be set for the provided block.
177
+ def with_format(format)
178
+ with_request_url("/", format: format) { yield }
179
+ end
180
+
158
181
  # Set the URL of the current request (such as when using request-dependent path helpers):
159
182
  #
160
183
  # ```ruby
@@ -182,7 +205,7 @@ module ViewComponent
182
205
  # @param full_path [String] The path to set for the current request.
183
206
  # @param host [String] The host to set for the current request.
184
207
  # @param method [String] The request method to set for the current request.
185
- def with_request_url(full_path, host: nil, method: nil, format: :html)
208
+ def with_request_url(full_path, host: nil, method: nil, format: ViewComponent::Base::VC_INTERNAL_DEFAULT_FORMAT)
186
209
  old_request_host = vc_test_request.host
187
210
  old_request_method = vc_test_request.request_method
188
211
  old_request_path_info = vc_test_request.path_info
@@ -3,7 +3,7 @@
3
3
  module ViewComponent
4
4
  module VERSION
5
5
  MAJOR = 3
6
- MINOR = 14
6
+ MINOR = 15
7
7
  PATCH = 0
8
8
  PRE = nil
9
9
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: view_component
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.14.0
4
+ version: 3.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ViewComponent Team
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-08-26 00:00:00.000000000 Z
11
+ date: 2024-09-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -58,6 +58,20 @@ dependencies:
58
58
  - - "~>"
59
59
  - !ruby/object:Gem::Version
60
60
  version: '1.0'
61
+ - !ruby/object:Gem::Dependency
62
+ name: allocation_stats
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: 0.1.5
68
+ type: :development
69
+ prerelease: false
70
+ version_requirements: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: 0.1.5
61
75
  - !ruby/object:Gem::Dependency
62
76
  name: appraisal
63
77
  requirement: !ruby/object:Gem::Requirement
@@ -547,6 +561,7 @@ files:
547
561
  - lib/view_component/slotable_default.rb
548
562
  - lib/view_component/system_test_case.rb
549
563
  - lib/view_component/system_test_helpers.rb
564
+ - lib/view_component/template.rb
550
565
  - lib/view_component/test_case.rb
551
566
  - lib/view_component/test_helpers.rb
552
567
  - lib/view_component/translatable.rb