view_component 3.14.0 → 3.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 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