view_component 4.12.0 → 4.13.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: b2f1a7d34956fc9ae629c0f0e710f4a7335b33ebc99f1c3eccafc41d19713524
4
- data.tar.gz: efd4bec7d2425da171f89f28fbc1bfaf9f2cba94fd0f2a9cbce674830281938e
3
+ metadata.gz: aaf81704d1bb151178b288ef446a657e2d8ac0158768ffee8db3c3e2c530aa29
4
+ data.tar.gz: 4e2192552574c18ff0dfc4613d797204abe98cd26b6343712c7edb7a4cba65bd
5
5
  SHA512:
6
- metadata.gz: 5d0f5024d419a56b5e178f0cce3d77ac4a1ae2d4d3efc0ac5334e7b55a4dcb6cc7460aa4d60a47bce63918b0a452b53c5425297ef03da2ce074793407acef570
7
- data.tar.gz: be1d649efec64995de234eb70998001d42cd9b0197bdce00ec5011d68cb2a2784925abf283b3f51c3986a55a446db3ba0c17cedf406c919e31b178ece2be6b51
6
+ metadata.gz: c06fd1d045b7c78a32cc66d1db448df80b1c2543d6cf9e44ab5bfa4e92c5cfe3d6151f80b24b345168e62dde6b8b400dfb7c28006716494b9e33366376f8cb9d
7
+ data.tar.gz: ddf8a61600fb66a0e2ae8ce4095e1d9398355ff2c5b19a16e79d8adeaff3da896dc4ecd948c4a13eaac8683df811fb0b578147486e7bfc609773f41532964466
data/docs/CHANGELOG.md CHANGED
@@ -10,6 +10,52 @@ nav_order: 6
10
10
 
11
11
  ## main
12
12
 
13
+ ## 4.13.0
14
+
15
+ * Add support for Turbo-streaming ViewComponents.
16
+
17
+ *Ben Sheldon*, *Joel Hawksley*
18
+
19
+ * Reduce allocations and avoid redundant compiler work when rendering components and collections.
20
+
21
+ *Joel Hawksley*
22
+
23
+ * Stabilize rendering allocation tests with explicit warmups and exact expectations by Rails and Ruby.
24
+
25
+ *Joel Hawksley*
26
+
27
+ * Replace the custom memory allocation test helper with `minitest-memory`, preserving Ruby-version-specific allocation thresholds while improving failure diagnostics.
28
+
29
+ *Joel Hawksley*
30
+
31
+ * Add zizmor security analysis for GitHub Actions workflows to CI.
32
+
33
+ *Joel Hawksley*
34
+
35
+ * Remove the `$PROGRAM_NAME` version-printing line from `version.rb` so the file no longer reads a global variable (unshareable across Ractors). The version is still available via `ViewComponent::VERSION::STRING`.
36
+
37
+ *Joel Hawksley*
38
+
39
+ * Fix intermittent template compilation failures where line-number offsets and annotation stripping were decided when a template object was created instead of when it was compiled, so later changes to coverage or annotation settings produced off-by-one backtraces or blank output.
40
+
41
+ *Joel Hawksley*
42
+
43
+ * Freeze `ViewComponent::VERSION::STRING` so the version constant is immutable and Ractor-shareable.
44
+
45
+ *Joel Hawksley*
46
+
47
+ * Add [audition](https://github.com/yaroslav/audition) Ractor-readiness checks to CI. Applied safe `.freeze` auto-fixes to string constants in `ViewComponent::Errors` and baselined existing findings so the gate fails only on new Ractor-isolation violations.
48
+
49
+ *Joel Hawksley*
50
+
51
+ * Update link to GOV.UK Components library in resources list to govuk-components.x-govuk.org
52
+
53
+ *Peter Yates*
54
+
55
+ * Fix `NoMethodError: undefined method 'template_handler_extensions'` when gathering sidecar templates on Rails main. Action View removed the `ActionView::Template.template_handler_extensions` method in newer versions; the compiler now reads the registered extensions through `ActionView::Template::Handlers.extensions`, which is the supported read-path API across all supported Rails versions (7.1+).
56
+
57
+ *Luiz Kowalski*
58
+
13
59
  ## 4.12.0
14
60
 
15
61
  * Fix stale render context on reused component instances. A `ViewComponent::Base` instance memoized its controller, helpers, request, view context, lookup context, view flow, and requested format details on first render via `||=`. Rendering the same instance a second time (intentionally or via aliasing) reused that stale context, which could leak data across requests, sessions, or users. `#render_in` now resets these ivars on every call so each render derives its context from the current view.
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job/serializers"
4
+
5
+ module ViewComponent
6
+ class ActiveJobSerializer < ActiveJob::Serializers::ObjectSerializer
7
+ def klass
8
+ ViewComponent::Serializable::Proxy
9
+ end
10
+
11
+ def serialize?(argument)
12
+ argument.is_a?(ViewComponent::Serializable::Proxy)
13
+ end
14
+
15
+ def serialize(proxy)
16
+ super(proxy.serialize)
17
+ end
18
+
19
+ def deserialize(hash)
20
+ ViewComponent::Serializable::Proxy.deserialize(hash)
21
+ end
22
+ end
23
+ end
@@ -103,8 +103,8 @@ module ViewComponent
103
103
  # Returns HTML that has been escaped by the respective template handler.
104
104
  #
105
105
  # @return [String]
106
- def render_in(view_context, **_, &block)
107
- self.class.__vc_compile(raise_errors: true)
106
+ def render_in(view_context, **, &block)
107
+ self.class.__vc_compile(raise_errors: true) unless self.class.__vc_compiled?
108
108
 
109
109
  __vc_reset_render_state!
110
110
 
@@ -469,12 +469,14 @@ module ViewComponent
469
469
  # `@__vc_content_set_by_with_content`) is intentionally preserved because it
470
470
  # is populated by callers _before_ `render_in` runs (e.g. via `with_*`
471
471
  # slot setters or `with_content`).
472
+ RENDER_STATE_IVARS = %i[
473
+ @__vc_controller
474
+ @__vc_helpers
475
+ @__vc_request
476
+ ].freeze
477
+
472
478
  def __vc_reset_render_state!
473
- %i[
474
- @__vc_controller
475
- @__vc_helpers
476
- @__vc_request
477
- ].each do |ivar|
479
+ RENDER_STATE_IVARS.each do |ivar|
478
480
  remove_instance_variable(ivar) if instance_variable_defined?(ivar)
479
481
  end
480
482
  end
@@ -13,7 +13,7 @@ module ViewComponent
13
13
  delegate :size, to: :@collection
14
14
 
15
15
  def render_in(view_context, **_, &block)
16
- rendered = components.map do |component|
16
+ rendered = components.map! do |component|
17
17
  component.render_in(view_context, &block)
18
18
  end
19
19
  safe_join(rendered, rendered_spacer(view_context))
@@ -63,11 +63,12 @@ module ViewComponent
63
63
  end
64
64
 
65
65
  def component_options(item, iterator)
66
- item_options = {component.__vc_collection_parameter => item}
66
+ item_options = @options.dup
67
+ item_options[component.__vc_collection_parameter] = item
67
68
  item_options[component.__vc_collection_counter_parameter] = iterator.index if component.__vc_counter_argument_present?
68
69
  item_options[component.__vc_collection_iteration_parameter] = iterator.dup if component.__vc_iteration_argument_present?
69
70
 
70
- @options.merge(item_options)
71
+ item_options
71
72
  end
72
73
 
73
74
  # Render the spacer through a fresh `dup` so a collection rendered multiple
@@ -115,11 +115,11 @@ module ViewComponent
115
115
  .tally
116
116
  .select { |_, count| count > 1 }
117
117
  .each do |tally|
118
- variant, this_format = tally.first
118
+ variant, this_format = tally.first
119
119
 
120
- variant_string = " for variant `#{variant}`" if variant.present?
120
+ variant_string = " for variant `#{variant}`" if variant.present?
121
121
 
122
- errors << "More than one #{this_format.upcase} template found#{variant_string} for #{@component}. "
122
+ errors << "More than one #{this_format.upcase} template found#{variant_string} for #{@component}. "
123
123
  end
124
124
 
125
125
  default_template_types = @templates.each_with_object(Set.new) do |template, memo|
@@ -177,7 +177,7 @@ module ViewComponent
177
177
  else
178
178
  path_parser = ActionView::Resolver::PathParser.new
179
179
  templates = @component.sidecar_files(
180
- ActionView::Template.template_handler_extensions
180
+ ActionView::Template::Handlers.extensions
181
181
  ).map do |path|
182
182
  details = path_parser.parse(path).details
183
183
  Template::File.new(component: @component, path: path, details: details)
@@ -190,11 +190,11 @@ module ViewComponent
190
190
  ).flat_map { |ancestor| ancestor.instance_methods(false).grep(/^call(_|$)/) }
191
191
  .uniq
192
192
  .each do |method_name|
193
- templates << Template::InlineCall.new(
194
- component: @component,
195
- method_name: method_name,
196
- defined_on_self: component_instance_methods_on_self.include?(method_name)
197
- )
193
+ templates << Template::InlineCall.new(
194
+ component: @component,
195
+ method_name: method_name,
196
+ defined_on_self: component_instance_methods_on_self.include?(method_name)
197
+ )
198
198
  end
199
199
 
200
200
  templates
@@ -77,6 +77,13 @@ module ViewComponent
77
77
  end
78
78
  end
79
79
 
80
+ initializer "view_component.serializable" do
81
+ ActiveSupport.on_load(:active_job) do
82
+ require "view_component/active_job_serializer"
83
+ ActiveJob::Serializers.add_serializers(ViewComponent::ActiveJobSerializer)
84
+ end
85
+ end
86
+
80
87
  initializer "view_component.eager_load_actions" do
81
88
  ActiveSupport.on_load(:after_initialize) do
82
89
  if Rails.application.config.eager_load
@@ -9,7 +9,7 @@ module ViewComponent
9
9
  MESSAGE =
10
10
  "It looks like a block was provided after calling `with_content` on COMPONENT, " \
11
11
  "which means that ViewComponent doesn't know which content to use.\n\n" \
12
- "To fix this issue, use either `with_content` or a block."
12
+ "To fix this issue, use either `with_content` or a block.".freeze
13
13
 
14
14
  def initialize(klass_name)
15
15
  super(MESSAGE.gsub("COMPONENT", klass_name.to_s))
@@ -25,13 +25,13 @@ module ViewComponent
25
25
  end
26
26
 
27
27
  class MultipleInlineTemplatesError < BaseError
28
- MESSAGE = "Inline templates can only be defined once per-component."
28
+ MESSAGE = "Inline templates can only be defined once per-component.".freeze
29
29
  end
30
30
 
31
31
  class MissingPreviewTemplateError < StandardError
32
32
  MESSAGE =
33
33
  "A preview template for example EXAMPLE doesn't exist.\n\n" \
34
- "To fix this issue, create a template for the example."
34
+ "To fix this issue, create a template for the example.".freeze
35
35
 
36
36
  def initialize(example)
37
37
  super(MESSAGE.gsub("EXAMPLE", example))
@@ -41,7 +41,7 @@ module ViewComponent
41
41
  class MissingTemplateError < StandardError
42
42
  MESSAGE =
43
43
  "No templates for COMPONENT match the request DETAIL.\n\n" \
44
- "To fix this issue, provide a suitable template."
44
+ "To fix this issue, provide a suitable template.".freeze
45
45
 
46
46
  def initialize(component, request_detail)
47
47
  detail = {
@@ -58,7 +58,7 @@ module ViewComponent
58
58
  MESSAGE =
59
59
  "It looks like a block was provided after calling `with_content` on COMPONENT, " \
60
60
  "which means that ViewComponent doesn't know which content to use.\n\n" \
61
- "To fix this issue, use either `with_content` or a block."
61
+ "To fix this issue, use either `with_content` or a block.".freeze
62
62
 
63
63
  def initialize(klass_name)
64
64
  super(MESSAGE.gsub("COMPONENT", klass_name.to_s))
@@ -70,7 +70,7 @@ module ViewComponent
70
70
  "The initializer for COMPONENT doesn't accept the parameter `PARAMETER`, " \
71
71
  "which is required to render it as a collection.\n\n" \
72
72
  "To fix this issue, update the initializer to accept `PARAMETER`.\n\n" \
73
- "See [the collections docs](https://viewcomponent.org/guide/collections.html) for more information on rendering collections."
73
+ "See [the collections docs](https://viewcomponent.org/guide/collections.html) for more information on rendering collections.".freeze
74
74
 
75
75
  def initialize(klass_name, parameter)
76
76
  super(MESSAGE.gsub("COMPONENT", klass_name.to_s).gsub("PARAMETER", parameter.to_s))
@@ -80,7 +80,7 @@ module ViewComponent
80
80
  class ReservedParameterError < StandardError
81
81
  MESSAGE =
82
82
  "COMPONENT initializer can't accept the parameter `PARAMETER`, as it will override a " \
83
- "public ViewComponent method. To fix this issue, rename the parameter."
83
+ "public ViewComponent method. To fix this issue, rename the parameter.".freeze
84
84
 
85
85
  def initialize(klass_name, parameter)
86
86
  super(MESSAGE.gsub("COMPONENT", klass_name.to_s).gsub("PARAMETER", parameter.to_s))
@@ -90,14 +90,14 @@ module ViewComponent
90
90
  class InvalidCollectionArgumentError < BaseError
91
91
  MESSAGE =
92
92
  "The value of the first argument passed to `with_collection` isn't a valid collection. " \
93
- "Make sure it responds to `to_ary`."
93
+ "Make sure it responds to `to_ary`.".freeze
94
94
  end
95
95
 
96
96
  class ContentSlotNameError < StandardError
97
97
  MESSAGE =
98
98
  "COMPONENT declares a slot named content, which is a reserved word in ViewComponent.\n\n" \
99
99
  "Content passed to a ViewComponent as a block is captured and assigned to the `content` accessor without having to create an explicit slot.\n\n" \
100
- "To fix this issue, either use the `content` accessor directly or choose a different slot name."
100
+ "To fix this issue, either use the `content` accessor directly or choose a different slot name.".freeze
101
101
 
102
102
  def initialize(klass_name)
103
103
  super(MESSAGE.gsub("COMPONENT", klass_name.to_s))
@@ -107,7 +107,7 @@ module ViewComponent
107
107
  class InvalidSlotDefinitionError < BaseError
108
108
  MESSAGE =
109
109
  "Invalid slot definition. Please pass a class, " \
110
- "string, or callable (that is proc, lambda, etc)"
110
+ "string, or callable (that is proc, lambda, etc)".freeze
111
111
  end
112
112
 
113
113
  class InvalidSlotNameError < StandardError
@@ -118,7 +118,7 @@ module ViewComponent
118
118
  "COMPONENT declares a slot named SLOT_NAME, which ends with a question mark.\n\n" \
119
119
  "This isn't allowed because the ViewComponent framework already provides predicate " \
120
120
  "methods ending in `?`.\n\n" \
121
- "To fix this issue, choose a different name."
121
+ "To fix this issue, choose a different name.".freeze
122
122
 
123
123
  def initialize(klass_name, slot_name)
124
124
  super(MESSAGE.gsub("COMPONENT", klass_name.to_s).gsub("SLOT_NAME", slot_name.to_s))
@@ -128,7 +128,7 @@ module ViewComponent
128
128
  class RedefinedSlotError < StandardError
129
129
  MESSAGE =
130
130
  "COMPONENT declares the SLOT_NAME slot multiple times.\n\n" \
131
- "To fix this issue, choose a different slot name."
131
+ "To fix this issue, choose a different slot name.".freeze
132
132
 
133
133
  def initialize(klass_name, slot_name)
134
134
  super(MESSAGE.gsub("COMPONENT", klass_name.to_s).gsub("SLOT_NAME", slot_name.to_s))
@@ -138,7 +138,7 @@ module ViewComponent
138
138
  class ReservedSingularSlotNameError < InvalidSlotNameError
139
139
  MESSAGE =
140
140
  "COMPONENT declares a slot named SLOT_NAME, which is a reserved word in the ViewComponent framework.\n\n" \
141
- "To fix this issue, choose a different name."
141
+ "To fix this issue, choose a different name.".freeze
142
142
 
143
143
  def initialize(klass_name, slot_name)
144
144
  super(MESSAGE.gsub("COMPONENT", klass_name.to_s).gsub("SLOT_NAME", slot_name.to_s))
@@ -148,7 +148,7 @@ module ViewComponent
148
148
  class ReservedPluralSlotNameError < InvalidSlotNameError
149
149
  MESSAGE =
150
150
  "COMPONENT declares a slot named SLOT_NAME, which is a reserved word in the ViewComponent framework.\n\n" \
151
- "To fix this issue, choose a different name."
151
+ "To fix this issue, choose a different name.".freeze
152
152
 
153
153
  def initialize(klass_name, slot_name)
154
154
  super(MESSAGE.gsub("COMPONENT", klass_name.to_s).gsub("SLOT_NAME", slot_name.to_s))
@@ -158,7 +158,7 @@ module ViewComponent
158
158
  class UncountableSlotNameError < InvalidSlotNameError
159
159
  MESSAGE =
160
160
  "COMPONENT declares a slot named SLOT_NAME, which is an uncountable word\n\n" \
161
- "To fix this issue, choose a different name."
161
+ "To fix this issue, choose a different name.".freeze
162
162
 
163
163
  def initialize(klass_name, slot_name)
164
164
  super(MESSAGE.gsub("COMPONENT", klass_name.to_s).gsub("SLOT_NAME", slot_name.to_s))
@@ -166,7 +166,7 @@ module ViewComponent
166
166
  end
167
167
 
168
168
  class ContentAlreadySetForPolymorphicSlotError < StandardError
169
- MESSAGE = "Content for slot SLOT_NAME has already been provided."
169
+ MESSAGE = "Content for slot SLOT_NAME has already been provided.".freeze
170
170
 
171
171
  def initialize(slot_name)
172
172
  super(MESSAGE.gsub("SLOT_NAME", slot_name.to_s))
@@ -176,7 +176,7 @@ module ViewComponent
176
176
  class NilWithContentError < BaseError
177
177
  MESSAGE =
178
178
  "No content provided to `#with_content` for #{self}.\n\n" \
179
- "To fix this issue, pass a value."
179
+ "To fix this issue, pass a value.".freeze
180
180
  end
181
181
 
182
182
  class TranslateCalledBeforeRenderError < BaseError
@@ -185,7 +185,7 @@ module ViewComponent
185
185
  "on the view context that only exists once a ViewComponent is passed to " \
186
186
  "the Rails render pipeline.\n\n" \
187
187
  "It's sometimes possible to fix this issue by moving code dependent on " \
188
- "`#translate` to a [`#before_render` method](https://viewcomponent.org/api.html#before_render--void)."
188
+ "`#translate` to a [`#before_render` method](https://viewcomponent.org/api.html#before_render--void).".freeze
189
189
  end
190
190
 
191
191
  class HelpersCalledBeforeRenderError < BaseError
@@ -194,7 +194,7 @@ module ViewComponent
194
194
  "on the view context that only exists once a ViewComponent is passed to " \
195
195
  "the Rails render pipeline.\n\n" \
196
196
  "It's sometimes possible to fix this issue by moving code dependent on " \
197
- "`#helpers` to a [`#before_render` method](https://viewcomponent.org/api.html#before_render--void)."
197
+ "`#helpers` to a [`#before_render` method](https://viewcomponent.org/api.html#before_render--void).".freeze
198
198
  end
199
199
 
200
200
  class ControllerCalledBeforeRenderError < BaseError
@@ -203,17 +203,17 @@ module ViewComponent
203
203
  "on the view context that only exists once a ViewComponent is passed to " \
204
204
  "the Rails render pipeline.\n\n" \
205
205
  "It's sometimes possible to fix this issue by moving code dependent on " \
206
- "`#controller` to a [`#before_render` method](https://viewcomponent.org/api.html#before_render--void)."
206
+ "`#controller` to a [`#before_render` method](https://viewcomponent.org/api.html#before_render--void).".freeze
207
207
  end
208
208
 
209
209
  class SystemTestControllerNefariousPathError < BaseError
210
- MESSAGE = "ViewComponent SystemTest controller attempted to load a file outside of the expected directory."
210
+ MESSAGE = "ViewComponent SystemTest controller attempted to load a file outside of the expected directory.".freeze
211
211
  end
212
212
 
213
213
  class AlreadyDefinedPolymorphicSlotSetterError < StandardError
214
214
  MESSAGE =
215
215
  "A method called 'SETTER_METHOD_NAME' already exists and would be overwritten by the 'SETTER_NAME' polymorphic " \
216
- "slot setter.\n\nPlease choose a different setter name."
216
+ "slot setter.\n\nPlease choose a different setter name.".freeze
217
217
 
218
218
  def initialize(setter_method_name, setter_name)
219
219
  super(MESSAGE.gsub("SETTER_METHOD_NAME", setter_method_name.to_s).gsub("SETTER_NAME", setter_name.to_s))
@@ -8,7 +8,7 @@ module ViewComponent # :nodoc:
8
8
  mod.prepend(self) unless self <= ViewComponent::Instrumentation
9
9
  end
10
10
 
11
- def render_in(view_context, **_, &block)
11
+ def render_in(...)
12
12
  return super if !Rails.application.config.view_component.instrumentation_enabled.present?
13
13
 
14
14
  payload = {
@@ -11,6 +11,8 @@ module ViewComponent
11
11
  # We're exposing it to the compiler via `refine` so that ViewComponent
12
12
  # can match Rails' template picking logic.
13
13
  module RequestDetails
14
+ EMPTY_DETAILS = {}.freeze
15
+
14
16
  refine ActionView::LookupContext do
15
17
  # Return an abstraction for matching and sorting available templates
16
18
  # based on the current lookup context details.
@@ -18,7 +20,7 @@ module ViewComponent
18
20
  # @return ActionView::TemplateDetails::Requested
19
21
  # @see ActionView::LookupContext#detail_args_for
20
22
  # @see ActionView::FileSystemResolver#_find_all
21
- def vc_requested_details(user_details = {})
23
+ def vc_requested_details(user_details = EMPTY_DETAILS)
22
24
  # The hash `user_details` would normally be the standard arguments that
23
25
  # `render` accepts, but there's currently no mechanism for users to
24
26
  # provide these when calling render on a ViewComponent.
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponent
4
+ module Serializable
5
+ # A proxy that wraps a component class and its initialization arguments, deferring
6
+ # component instantiation until render time. This allows slot calls and other
7
+ # post-initialize configuration to be captured and replayed, and enables the
8
+ # proxy itself (rather than a live component instance) to be serialized for
9
+ # background jobs (e.g. ActiveJob / Turbo Streams).
10
+ class Proxy
11
+ # Rebuilds a Proxy from a serialized hash produced by +serialize+
12
+ def self.deserialize(hash)
13
+ klass = hash["component_class"].safe_constantize
14
+ raise ArgumentError, "Cannot deserialize unknown component: #{hash["component_class"]}" unless klass
15
+
16
+ args = ActiveJob::Arguments.deserialize(hash["initialize_args"] || [])
17
+ proxy = new(klass, *args)
18
+
19
+ Array(hash["slot_calls"]).each do |call|
20
+ method_name = call["method"].to_sym
21
+ slot_args = ActiveJob::Arguments.deserialize(call["args"])
22
+ proxy.public_send(method_name, *slot_args)
23
+ end
24
+
25
+ proxy
26
+ end
27
+
28
+ attr_reader :component_class, :initialize_args, :slot_calls
29
+
30
+ def initialize(component_class, *args)
31
+ if component_class.name.nil?
32
+ raise UnserializableError, "Cannot serialize anonymous component class #{component_class.inspect}"
33
+ end
34
+
35
+ @component_class = component_class
36
+ @initialize_args = args
37
+ @slot_calls = []
38
+ end
39
+ ruby2_keywords :initialize
40
+
41
+ # Implements the Rails renderable interface
42
+ def render_in(view_context, &block)
43
+ if block
44
+ raise UnserializableError, "Cannot serialize render_in with a block"
45
+ end
46
+ build_component.render_in(view_context)
47
+ end
48
+
49
+ def method_missing(method_name, *args, &block)
50
+ if slot_method?(method_name)
51
+ capture_slot_call(method_name, args, &block)
52
+ else
53
+ super
54
+ end
55
+ end
56
+ ruby2_keywords :method_missing
57
+
58
+ def respond_to_missing?(method_name, include_private = false)
59
+ slot_method?(method_name) || super
60
+ end
61
+
62
+ # Returns a hash that can be rebuilt into a Proxy instance using +deserialize+
63
+ def serialize
64
+ serialized_slot_calls = @slot_calls.map do |call|
65
+ {
66
+ "method" => call[:method].to_s,
67
+ "args" => ActiveJob::Arguments.serialize(call[:args])
68
+ }
69
+ end
70
+
71
+ {
72
+ "component_class" => @component_class.name,
73
+ "initialize_args" => ActiveJob::Arguments.serialize(@initialize_args),
74
+ "slot_calls" => serialized_slot_calls
75
+ }
76
+ end
77
+
78
+ if defined?(Rails::VERSION) && Rails::VERSION::MAJOR == 7 && Rails::VERSION::MINOR == 1
79
+ # Rails expects us to define `format` on all renderables,
80
+ # but we do not know the `format` of a ViewComponent until runtime.
81
+ def format
82
+ nil
83
+ end
84
+ end
85
+
86
+ private
87
+
88
+ def slot_method?(method_name)
89
+ method_name.to_s.start_with?("with_") && @component_class.method_defined?(method_name)
90
+ end
91
+
92
+ def capture_slot_call(method_name, args, &block)
93
+ if block
94
+ raise UnserializableError, "Cannot serialize slot call '#{method_name}' with a block"
95
+ end
96
+
97
+ @slot_calls << {method: method_name, args: args}
98
+ self
99
+ end
100
+
101
+ def build_component
102
+ @component_class.new(*@initialize_args).tap do |instance|
103
+ @slot_calls.each do |call|
104
+ instance.public_send(call[:method], *call[:args])
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require "view_component/serializable/proxy"
5
+
6
+ module ViewComponent
7
+ module Serializable
8
+ extend ActiveSupport::Concern
9
+
10
+ class UnserializableError < ArgumentError; end
11
+
12
+ class_methods do
13
+ # Returns a Proxy that captures this class and its initialization arguments,
14
+ # deferring instantiation until render time. The proxy is renderable and
15
+ # serializable for ActiveJob (e.g. Turbo Streams). Slot calls made on the
16
+ # proxy are captured and replayed at render time. Blocks are not supported.
17
+ #
18
+ # MyComponent.render_later("title", size: :large).with_item(label: "One")
19
+ def render_later(*args, &block)
20
+ raise UnserializableError, "Cannot serialize render_later with a block" if block
21
+
22
+ ViewComponent::Serializable::Proxy.new(self, *args)
23
+ end
24
+ ruby2_keywords :render_later
25
+ end
26
+ end
27
+ end
@@ -5,7 +5,7 @@ module ViewComponent
5
5
  DEFAULT_FORMAT = :html
6
6
  private_constant :DEFAULT_FORMAT
7
7
 
8
- DataWithSource = Struct.new(:format, :identifier, :short_identifier, :type, keyword_init: true)
8
+ DataWithSource = Struct.new(:format, :identifier, :short_identifier, :type)
9
9
 
10
10
  attr_reader :details, :path
11
11
 
@@ -28,33 +28,7 @@ module ViewComponent
28
28
  details = ActionView::TemplateDetails.new(details.locale, details.handler, DEFAULT_FORMAT, details.variant)
29
29
  end
30
30
 
31
- @strip_annotation_line = false
32
-
33
- # Rails 8.1 added a newline to compiled ERB output (rails/rails#53731).
34
- # Use -1 to compensate for correct line numbers in stack traces.
35
- # However, negative line numbers cause segfaults when Ruby's coverage
36
- # is enabled (bugs.ruby-lang.org/issues/19363). In that case, strip the
37
- # annotation line from compiled source instead.
38
- lineno =
39
- if Rails::VERSION::MAJOR >= 8 && Rails::VERSION::MINOR > 0 && details.handler == :erb
40
- if coverage_running?
41
- # Can't use negative lineno with coverage (causes segfault on Linux).
42
- # Strip annotation line if enabled to preserve correct line numbers.
43
- @strip_annotation_line = ActionView::Base.annotate_rendered_view_with_filenames
44
- 0
45
- else
46
- -1
47
- end
48
- else
49
- 0
50
- end
51
-
52
- super(
53
- component: component,
54
- details: details,
55
- path: path,
56
- lineno: lineno
57
- )
31
+ super
58
32
  end
59
33
 
60
34
  def type
@@ -68,11 +42,36 @@ module ViewComponent
68
42
 
69
43
  private
70
44
 
45
+ # Rails 8.1 added a newline to compiled ERB output (rails/rails#53731).
46
+ # Use -1 to compensate for correct line numbers in stack traces.
47
+ # However, negative line numbers cause segfaults when Ruby's coverage
48
+ # is enabled (bugs.ruby-lang.org/issues/19363). In that case, strip the
49
+ # annotation line from compiled source instead.
50
+ #
51
+ # Computed at compile time rather than at initialization because both
52
+ # coverage and annotation settings can change between the two.
53
+ def lineno
54
+ return 0 unless erb_newline_compensation_needed?
55
+
56
+ # Can't use negative lineno with coverage (causes segfault on Linux).
57
+ coverage_running? ? 0 : -1
58
+ end
59
+
60
+ # Strip the annotation line to maintain correct line numbers when coverage
61
+ # is running (avoids segfault from negative lineno)
62
+ def strip_annotation_line?
63
+ erb_newline_compensation_needed? &&
64
+ coverage_running? &&
65
+ ActionView::Base.annotate_rendered_view_with_filenames
66
+ end
67
+
68
+ def erb_newline_compensation_needed?
69
+ Rails::VERSION::MAJOR >= 8 && Rails::VERSION::MINOR > 0 && details.handler == :erb
70
+ end
71
+
71
72
  def compiled_source
72
73
  result = super
73
- # Strip the annotation line to maintain correct line numbers when coverage
74
- # is running (avoids segfault from negative lineno)
75
- result = result.partition(";").last if @strip_annotation_line
74
+ result = result.partition(";").last if strip_annotation_line?
76
75
  result
77
76
  end
78
77
  end
@@ -147,7 +146,7 @@ module ViewComponent
147
146
  @component.silence_redefinition_of_method(call_method_name)
148
147
 
149
148
  # rubocop:disable Style/EvalWithLocation
150
- @component.class_eval <<~RUBY, @path, @lineno
149
+ @component.class_eval <<~RUBY, @path, lineno
151
150
  def #{call_method_name}
152
151
  #{compiled_source}
153
152
  end
@@ -195,6 +194,8 @@ module ViewComponent
195
194
 
196
195
  private
197
196
 
197
+ attr_reader :lineno
198
+
198
199
  def compiled_source
199
200
  handler = details.handler_class
200
201
  this_source = source
@@ -3,12 +3,10 @@
3
3
  module ViewComponent
4
4
  module VERSION
5
5
  MAJOR = 4
6
- MINOR = 12
6
+ MINOR = 13
7
7
  PATCH = 0
8
8
  PRE = nil
9
9
 
10
- STRING = [MAJOR, MINOR, PATCH, PRE].compact.join(".")
10
+ STRING = [MAJOR, MINOR, PATCH, PRE].compact.join(".").freeze
11
11
  end
12
12
  end
13
-
14
- puts ViewComponent::VERSION::STRING if __FILE__ == $PROGRAM_NAME
@@ -15,6 +15,7 @@ module ViewComponent
15
15
  autoload :InlineTemplate
16
16
  autoload :Instrumentation
17
17
  autoload :Preview
18
+ autoload :Serializable
18
19
  autoload :Translatable
19
20
 
20
21
  if defined?(Rails.env) && Rails.env.test?
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: view_component
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.12.0
4
+ version: 4.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ViewComponent Team
@@ -89,6 +89,7 @@ files:
89
89
  - lib/generators/view_component/test_unit/templates/component_test.rb.tt
90
90
  - lib/generators/view_component/test_unit/test_unit_generator.rb
91
91
  - lib/view_component.rb
92
+ - lib/view_component/active_job_serializer.rb
92
93
  - lib/view_component/base.rb
93
94
  - lib/view_component/collection.rb
94
95
  - lib/view_component/compile_cache.rb
@@ -102,6 +103,8 @@ files:
102
103
  - lib/view_component/instrumentation.rb
103
104
  - lib/view_component/preview.rb
104
105
  - lib/view_component/request_details.rb
106
+ - lib/view_component/serializable.rb
107
+ - lib/view_component/serializable/proxy.rb
105
108
  - lib/view_component/slot.rb
106
109
  - lib/view_component/slotable.rb
107
110
  - lib/view_component/system_spec_helpers.rb
@@ -135,7 +138,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
135
138
  - !ruby/object:Gem::Version
136
139
  version: '0'
137
140
  requirements: []
138
- rubygems_version: 4.0.10
141
+ rubygems_version: 4.0.16
139
142
  specification_version: 4
140
143
  summary: A framework for building reusable, testable & encapsulated view components
141
144
  in Ruby on Rails.