universal_renderer 0.5.2 → 0.7.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.
@@ -1,19 +1,23 @@
1
1
  module UniversalRenderer
2
+ # Controller-side entry point for server-side rendering. Drive it either
3
+ # declaratively with {ClassMethods#enable_ssr}, or imperatively by calling
4
+ # {#render_ssr} from the action. A failed or unconfigured render is a no-op:
5
+ # {#ssr?} returns false and the layout falls back to client-side rendering.
2
6
  module Renderable
3
7
  extend ActiveSupport::Concern
4
8
 
5
9
  included do
6
10
  helper UniversalRenderer::SSR::Helpers
7
11
 
8
- # Distinct from the `enable_ssr` DSL method: reading a class_attribute
9
- # named `enable_ssr` on a class that never opted in would invoke the DSL
10
- # (arming SSR as a side effect) instead of returning false.
12
+ # Not named `enable_ssr`: reading a class_attribute by that name would
13
+ # invoke the DSL method and arm SSR as a side effect.
11
14
  class_attribute :ssr_enabled, instance_writer: false, default: false
12
15
  class_attribute :ssr_streaming_preference,
13
16
  instance_writer: false,
14
17
  default: nil
18
+ class_attribute :ssr_conditions, instance_writer: false, default: {}
15
19
 
16
- before_action :initialize_props, unless: :skip_universal_renderer?
20
+ helper_method :ssr?, :ssr_response, :ssr_streaming?
17
21
  end
18
22
 
19
23
  module Streaming
@@ -22,98 +26,117 @@ module UniversalRenderer
22
26
  included { include ActionController::Live }
23
27
  end
24
28
 
25
- class_methods do
29
+ module ClassMethods
30
+ # Arms the automatic render path for this controller.
31
+ #
32
+ # @param options [Hash]
33
+ # @option options [Boolean] :streaming Stream the response instead of
34
+ # fetching it in one blocking request. Mixes in ActionController::Live.
35
+ # @option options [Symbol, Array<Symbol>] :only Restrict to these actions.
36
+ # @option options [Symbol, Array<Symbol>] :except Skip these actions.
37
+ # @option options [Symbol, Proc] :if Render only when this evaluates
38
+ # truthy. A Symbol names a controller method; a Proc is instance_exec'd
39
+ # against the controller.
40
+ # @option options [Symbol, Proc] :unless Skip when this evaluates truthy.
41
+ #
42
+ # @example Public pages only
43
+ # enable_ssr only: :show, unless: -> { current_user.present? }
44
+ # @return [void]
26
45
  def enable_ssr(options = {})
27
46
  self.ssr_enabled = true
28
47
  self.ssr_streaming_preference = options[:streaming]
48
+ self.ssr_conditions = options.slice(:only, :except, :if, :unless).freeze
29
49
 
30
50
  include UniversalRenderer::Renderable::Streaming if options[:streaming]
31
51
  end
32
52
  end
33
53
 
34
- # Fetches Server-Side Rendered (SSR) content for the current request.
35
- # This method makes a blocking call to the SSR service and stores the
36
- # result in the `@ssr` instance variable.
54
+ # The props accumulated for this request. Mutating the returned hash is
55
+ # supported, but prefer {#add_prop} / {#push_prop} / {#add_query_data}.
37
56
  #
38
- # The SSR content is fetched based on the `request.original_url` and the
39
- # `@universal_renderer_props` accumulated for the request.
40
- #
41
- # @return [Hash, nil] The fetched SSR data (typically a hash with keys like `:head`, `:body_html`, `:body_attrs`),
42
- # or `nil` if the fetch fails or SSR is not configured.
43
- def fetch_ssr
44
- props = @universal_renderer_props || {}
45
- @ssr =
46
- UniversalRenderer::Client::Base.call(
47
- request.original_url,
48
- props
49
- )
57
+ # @return [Hash]
58
+ # rubocop:disable Naming/MemoizedInstanceVariableName -- the ivar name is
59
+ # part of the pre-0.6 surface; layouts and specs in the wild read it.
60
+ def ssr_props
61
+ @universal_renderer_props ||= {}
50
62
  end
63
+ # rubocop:enable Naming/MemoizedInstanceVariableName
51
64
 
52
- def ssr_streaming?
53
- self.class.ssr_streaming_preference
54
- end
65
+ # Fetches the SSR payload for the current request and remembers it, so the
66
+ # view helpers and {#ssr?} can see it. Idempotent.
67
+ #
68
+ # @param props [Hash, nil] Props to merge first. Ignored once a render has
69
+ # happened, since merging then would change `ssr_props` without affecting
70
+ # the response.
71
+ # @return [UniversalRenderer::SSR::Response, nil] `nil` when SSR is not
72
+ # configured or the render failed.
73
+ def render_ssr(props = nil)
74
+ return @_ssr_response if defined?(@_ssr_response)
55
75
 
56
- def render(*, **)
57
- return super unless self.class.ssr_enabled
58
- return super unless request.format.html?
76
+ add_prop(props) if props.present?
59
77
 
60
- # Allow Warden and other authentication mechanisms to complete first
61
- # This prevents interference with authentication throws like :warden
62
- if ssr_streaming?
63
- success = render_ssr_stream(*, **)
64
- super unless success
65
- else
66
- fetch_ssr
67
- super
68
- end
69
- end
78
+ @_ssr_response =
79
+ UniversalRenderer::Client::Base.call(request.original_url, ssr_props)
70
80
 
71
- private
81
+ @ssr = @_ssr_response # pre-0.6 layouts read this ivar
72
82
 
73
- def render_ssr_stream(*, **)
74
- full_layout = render_to_string(*, **)
75
- current_props = (@universal_renderer_props || {}).dup
83
+ @_ssr_response
84
+ end
76
85
 
77
- streaming_succeeded =
78
- UniversalRenderer::Client::Stream.call(
79
- request.original_url,
80
- current_props,
81
- full_layout,
82
- response
83
- )
86
+ alias fetch_ssr render_ssr
84
87
 
85
- if streaming_succeeded
86
- response.stream.close unless response.stream.closed?
87
- true
88
- else
89
- UniversalRenderer.log do |log|
90
- log.error(
91
- "SSR stream fallback: " \
92
- "Streaming failed, proceeding with standard rendering."
93
- )
94
- end
95
- false
96
- end
88
+ # @return [UniversalRenderer::SSR::Response, nil] The payload from the most
89
+ # recent {#render_ssr}, or nil if none succeeded.
90
+ def ssr_response
91
+ return @_ssr_response if defined?(@_ssr_response)
92
+
93
+ # Tolerate layouts and controllers that assigned @ssr by hand.
94
+ @ssr
97
95
  end
98
96
 
99
- def initialize_props
100
- @universal_renderer_props = {}
97
+ # Whether this request has server-rendered content to emit. Use it to pick
98
+ # between a hydration entry point and a client-render entry point.
99
+ #
100
+ # True while streaming too, where the HTML arrives after the layout renders.
101
+ #
102
+ # @return [Boolean]
103
+ def ssr?
104
+ ssr_streaming? || ssr_response.present?
101
105
  end
102
106
 
103
- # Determines whether to skip universal renderer initialization
104
- # This helps prevent interference with authentication flows like Warden
105
- def skip_universal_renderer?
106
- # Skip if SSR is not enabled for this controller
107
- return true unless self.class.ssr_enabled
107
+ # Whether this *request* is being streamed.
108
+ #
109
+ # False unless the request would also stream, so the layout never emits the
110
+ # `<!-- SSR_HEAD -->` / `<!-- SSR_BODY -->` markers into a page no renderer
111
+ # will see. A failed stream downgrades this for the same reason.
112
+ #
113
+ # @return [Boolean]
114
+ def ssr_streaming?
115
+ return @_ssr_streaming if defined?(@_ssr_streaming)
116
+
117
+ self.class.ssr_streaming_preference.present? && ssr_enabled_for_request?
118
+ end
108
119
 
109
- # Skip for non-HTML requests
110
- return true unless request.format.html?
120
+ # Render options that mean "this is not a page". The request format is still
121
+ # HTML for a `render json:` inside a form post, or for a Turbo Frame, so the
122
+ # format check alone does not catch them.
123
+ NON_PAGE_RENDER_OPTIONS = %i[
124
+ body file inline js json nothing partial plain xml
125
+ ].freeze
111
126
 
112
- # Only skip if we're in the middle of an active Warden throw/catch mechanism
113
- # This is more specific and allows SSR for public pages with unauthenticated users
114
- return true if defined?(Warden) && request.env["warden"]&.message.present?
127
+ def render(*, **options)
128
+ return super unless ssr_enabled_for_request?
129
+ return super if options.keys.intersect?(NON_PAGE_RENDER_OPTIONS)
130
+ # No layout, so nothing calls the helpers that would emit the payload.
131
+ return super if options[:layout] == false
115
132
 
116
- false
133
+ if ssr_streaming?
134
+ success = render_ssr_stream(*, **options)
135
+ super unless success
136
+ else
137
+ render_ssr
138
+ super
139
+ end
117
140
  end
118
141
 
119
142
  # Adds a prop or a hash of props to be sent to the SSR service.
@@ -128,16 +151,15 @@ module UniversalRenderer
128
151
  # add_prop({theme: "dark", locale: "en"})
129
152
  # @return [void]
130
153
  def add_prop(key_or_hash, data_value = nil)
131
- @universal_renderer_props ||= {}
132
154
  if data_value.nil? && key_or_hash.is_a?(Hash)
133
- @universal_renderer_props.merge!(key_or_hash.deep_stringify_keys)
155
+ ssr_props.merge!(key_or_hash.deep_stringify_keys)
134
156
  else
135
- @universal_renderer_props[key_or_hash.to_s] = data_value
157
+ ssr_props[key_or_hash.to_s] = data_value
136
158
  end
137
159
  end
138
160
 
139
161
  # Allows a prop to be treated as an array, pushing new values to it.
140
- # If the prop does not exist or is `nil`, it\'s initialized as an empty array.
162
+ # If the prop does not exist or is `nil`, it's initialized as an empty array.
141
163
  # If the prop exists but is not an array (e.g., set as a scalar by `add_prop`),
142
164
  # its current value will be converted into the first element of the new array.
143
165
  # If `value_to_add` is an array, its elements are concatenated to the existing array.
@@ -151,38 +173,150 @@ module UniversalRenderer
151
173
  # push_prop(:tags, ["rails", "ruby"])
152
174
  # @example Appending to an existing scalar value (converts to array)
153
175
  # add_prop(:item, "first")
154
- # push_prop(:item, "second") # @universal_renderer_props becomes { "item" => ["first", "second"] }
176
+ # push_prop(:item, "second") # ssr_props becomes { "item" => ["first", "second"] }
155
177
  # @return [void]
156
178
  def push_prop(key, value_to_add)
157
- @universal_renderer_props ||= {}
179
+ props = ssr_props
158
180
  prop_key = key.to_s
159
- current_value = @universal_renderer_props[prop_key]
181
+ current_value = props[prop_key]
160
182
 
161
183
  if current_value.nil?
162
- @universal_renderer_props[prop_key] = []
184
+ props[prop_key] = []
163
185
  elsif !current_value.is_a?(Array)
164
- @universal_renderer_props[prop_key] = [current_value]
186
+ props[prop_key] = [current_value]
165
187
  end
166
- # At this point, @universal_renderer_props[prop_key] is guaranteed to be an array.
188
+ # At this point, props[prop_key] is guaranteed to be an array.
167
189
 
168
190
  if value_to_add.is_a?(Array)
169
- @universal_renderer_props[prop_key].concat(value_to_add)
191
+ props[prop_key].concat(value_to_add)
170
192
  else
171
- @universal_renderer_props[prop_key] << value_to_add
193
+ props[prop_key] << value_to_add
172
194
  end
173
195
  end
174
196
 
175
197
  # Adds a React Query cache entry that can be hydrated on SSR/client boot.
176
198
  #
199
+ # Entries accumulate under the `react_query` prop as
200
+ # `{ "query_key" => [...], "data" => ... }`. The NPM package's
201
+ # `hydrateReactQuery(props, queryClient)` consumes exactly this shape — use
202
+ # it in `setup` rather than reimplementing the loop.
203
+ #
177
204
  # @param query_key [Array, String, Symbol] The React Query key.
178
205
  # @param data [Object] The cached query data.
179
206
  # @return [void]
180
207
  def add_query_data(query_key, data)
181
- normalized_query_key = query_key.is_a?(Array) ? query_key : [query_key]
208
+ parts = query_key.is_a?(Array) ? query_key : [query_key]
209
+
210
+ # React Query compares keys structurally, so numeric parts have to stay
211
+ # numeric. `deep_stringify_keys` below only touches hash keys.
212
+ normalized = parts.map { |part| part.is_a?(Symbol) ? part.to_s : part }
213
+
182
214
  push_prop(
183
215
  :react_query,
184
- { query_key: normalized_query_key, data: data }.deep_stringify_keys
216
+ { query_key: normalized, data: data }.deep_stringify_keys
185
217
  )
186
218
  end
219
+
220
+ private
221
+
222
+ # Whether the automatic (`enable_ssr`) path should run for this request.
223
+ # {#render_ssr} deliberately does not consult this: an explicit call is the
224
+ # caller stating intent.
225
+ #
226
+ # Memoized so a caller's `if:` / `unless:` predicate runs once, however many
227
+ # times the layout asks through `ssr_streaming?`.
228
+ # rubocop:disable Naming/MemoizedInstanceVariableName -- ivars this concern
229
+ # sets into a host controller are `_`-prefixed to avoid collisions.
230
+ def ssr_enabled_for_request?
231
+ return @_ssr_enabled_for_request if defined?(@_ssr_enabled_for_request)
232
+
233
+ @_ssr_enabled_for_request = compute_ssr_enabled_for_request?
234
+ end
235
+ # rubocop:enable Naming/MemoizedInstanceVariableName
236
+
237
+ def compute_ssr_enabled_for_request?
238
+ return false unless self.class.ssr_enabled
239
+ return false unless request.format.html?
240
+
241
+ # Only while a Warden throw/catch is in flight, so SSR still runs for
242
+ # public pages viewed by unauthenticated visitors.
243
+ return false if defined?(Warden) && request.env["warden"]&.message.present?
244
+
245
+ ssr_conditions_met?
246
+ end
247
+
248
+ def ssr_conditions_met?
249
+ conditions = self.class.ssr_conditions
250
+ return true if conditions.blank?
251
+
252
+ ssr_action_allowed?(conditions) && ssr_guards_pass?(conditions)
253
+ end
254
+
255
+ def ssr_action_allowed?(conditions)
256
+ action = action_name.to_s
257
+
258
+ only = conditions[:only]
259
+ return false if only && Array(only).map(&:to_s).exclude?(action)
260
+
261
+ except = conditions[:except]
262
+ return false if except && Array(except).map(&:to_s).include?(action)
263
+
264
+ true
265
+ end
266
+
267
+ def ssr_guards_pass?(conditions)
268
+ if_condition = conditions[:if]
269
+ return false if if_condition && !evaluate_ssr_condition(if_condition)
270
+
271
+ unless_condition = conditions[:unless]
272
+ return false if unless_condition &&
273
+ evaluate_ssr_condition(unless_condition)
274
+
275
+ true
276
+ end
277
+
278
+ def evaluate_ssr_condition(condition)
279
+ case condition
280
+ when Symbol, String
281
+ send(condition)
282
+ when Proc
283
+ condition.arity.zero? ? instance_exec(&condition) : condition.call(self)
284
+ else
285
+ raise ArgumentError,
286
+ "enable_ssr conditions must be a Symbol, String, or Proc, " \
287
+ "got #{condition.class}"
288
+ end
289
+ end
290
+
291
+ def render_ssr_stream(*, **)
292
+ # Rendered while `ssr_streaming?` is still true, so the layout carries the
293
+ # markers the SSR service splices into.
294
+ full_layout = render_to_string(*, **)
295
+
296
+ streaming_succeeded =
297
+ UniversalRenderer::Client::Stream.call(
298
+ request.original_url,
299
+ ssr_props.dup,
300
+ full_layout,
301
+ response
302
+ )
303
+
304
+ if streaming_succeeded
305
+ response.stream.close unless response.stream.closed?
306
+ true
307
+ else
308
+ # Nothing was written upstream, so the caller re-renders normally.
309
+ # Downgrade first, or the fallback page ships bare markers and no content.
310
+ @_ssr_streaming = false
311
+
312
+ UniversalRenderer.log do |log|
313
+ log.error(
314
+ "SSR stream fallback: " \
315
+ "Streaming failed, proceeding with standard rendering."
316
+ )
317
+ end
318
+ false
319
+ end
320
+ end
187
321
  end
188
322
  end
@@ -1,79 +1,104 @@
1
1
  module UniversalRenderer
2
2
  module SSR
3
+ # View helpers for emitting what the SSR service returned. `ssr?`,
4
+ # `ssr_response`, and `ssr_streaming?` come from the controller via
5
+ # `helper_method` (see {UniversalRenderer::Renderable}).
3
6
  module Helpers
4
- # @!method ssr_head
5
- # Outputs a head placeholder for SSR content.
6
- # This placeholder is used by the rendering process to inject SSR metadata.
7
- # @return [String] The HTML-safe string "<!-- SSR_HEAD -->".
7
+ SAFE_BODY_ATTRIBUTE_NAME = /\A[a-z_:][a-z0-9:._-]*\z/i
8
+
9
+ # Server-rendered <head> content, or the streaming placeholder.
10
+ #
11
+ # @return [String] Sanitized head HTML, the `<!-- SSR_HEAD -->` marker
12
+ # when streaming, or an empty string when there is nothing to emit.
8
13
  def ssr_head
9
- if ssr_streaming?
10
- Placeholders::HEAD
11
- elsif @ssr && @ssr.head.present?
12
- sanitize_ssr(@ssr.head)
13
- else
14
- ""
15
- end
14
+ return Placeholders::HEAD if ssr_streaming?
15
+
16
+ html = ssr_response&.head
17
+ html.present? ? sanitize_ssr(html) : ""
16
18
  end
17
19
 
18
- # @!method ssr_body
19
- # Outputs a body placeholder for SSR content.
20
- # This placeholder is used by the rendering process to inject the main SSR body.
21
- # @return [String] The HTML-safe string "<!-- SSR_BODY -->".
20
+ # Server-rendered body content, or the streaming placeholder.
21
+ #
22
+ # @return [String] Sanitized body HTML, the `<!-- SSR_BODY -->` marker
23
+ # when streaming, or an empty string when there is nothing to emit.
22
24
  def ssr_body
23
- if ssr_streaming?
24
- Placeholders::BODY
25
- elsif @ssr && @ssr.body.present?
26
- sanitize_ssr(@ssr.body)
27
- else
28
- ""
29
- end
30
- end
25
+ return Placeholders::BODY if ssr_streaming?
31
26
 
32
- # @!method sanitize_ssr(html)
33
- # Sanitizes HTML content rendered by the SSR service.
34
- # Uses a custom scrubber ({UniversalRenderer::SSR::Scrubber}) to remove potentially
35
- # harmful elements like scripts and event handlers, while allowing safe tags
36
- # like stylesheets and meta tags.
37
- # @param html [String] The HTML string to sanitize.
38
- # @return [String] The sanitized HTML string.
39
- def sanitize_ssr(html)
40
- sanitize(html, scrubber: Scrubber.new)
27
+ html = ssr_response&.body
28
+ html.present? ? sanitize_ssr(html) : ""
41
29
  end
42
30
 
43
- # @!method ssr_props_json(props = nil)
44
- # Serializes and JSON-escapes SSR props for safe embedding in HTML attributes.
45
- # Defaults to `@universal_renderer_props` from the current controller/view context.
46
- # @param props [Hash, nil] Optional props hash to serialize.
47
- # @return [String] JSON string escaped for safe HTML embedding.
48
- def ssr_props_json(props = nil)
49
- raw_props = props || @universal_renderer_props || {}
50
- ERB::Util.json_escape(raw_props.to_json)
31
+ # Attributes the renderer asked to be applied to the `<body>` tag:
32
+ #
33
+ # <body class="app" <%= ssr_body_attributes %>>
34
+ #
35
+ # A non-Hash is dropped rather than raised on, so a renderer answering 200
36
+ # with the wrong shape degrades the page instead of breaking the layout.
37
+ #
38
+ # @return [ActiveSupport::SafeBuffer] Escaped `name="value"` pairs, or an
39
+ # empty buffer when the renderer sent none.
40
+ def ssr_body_attributes
41
+ attrs = ssr_response&.body_attrs
42
+ return "".html_safe unless attrs.is_a?(Hash)
43
+ return "".html_safe if attrs.empty?
44
+
45
+ attrs = sanitize_ssr_body_attributes(attrs) if UniversalRenderer.config.sanitize
46
+ tag.attributes(attrs)
51
47
  end
52
48
 
53
- # @!method ssr_props(id: "ssr-props", props: nil)
54
- # Renders a JSON script tag containing SSR props for client hydration.
55
- # @param id [String] DOM id for the script element.
56
- # @param props [Hash, nil] Optional props hash to serialize.
57
- # @return [ActiveSupport::SafeBuffer] Script tag with serialized JSON payload.
58
- def ssr_props(id: "ssr-props", props: nil)
49
+ # Renders the renderer's hydration payload as an inert JSON script tag.
50
+ #
51
+ # Return it as `payload` from your `render` callback and read it on the
52
+ # client with `JSON.parse(el.textContent)`.
53
+ #
54
+ # @param id [String] DOM id for the script element.
55
+ # @return [ActiveSupport::SafeBuffer, nil] The script tag, or nil when the
56
+ # renderer sent no payload.
57
+ def ssr_payload(id: "ssr-payload")
58
+ payload = ssr_response&.payload
59
+ return if payload.nil?
60
+
59
61
  content_tag(
60
62
  :script,
61
- ssr_props_json(props),
63
+ ERB::Util.json_escape(payload.to_json),
62
64
  { id: id, type: "application/json" },
63
65
  false
64
66
  )
65
67
  end
66
68
 
67
- # @!method ssr_streaming?
68
- # Determines if SSR streaming should be used for the current request.
69
- # The decision is based solely on the `ssr_streaming_preference` class attribute
70
- # set on the controller.
71
- # - If `ssr_streaming_preference` is `true`, streaming is enabled.
72
- # - If `ssr_streaming_preference` is `false`, streaming is disabled.
73
- # - If `ssr_streaming_preference` is `nil` (not set), streaming is disabled.
74
- # @return [Boolean, nil] The value of `ssr_streaming_preference` (true, false, or nil).
75
- # In conditional contexts, `nil` will behave as `false`.
76
- delegate :ssr_streaming?, to: :controller
69
+ # These back the helpers above; the module is included into the view
70
+ # context, so a public method here would be callable from any template.
71
+ private
72
+
73
+ # Sanitizes HTML returned by the SSR service, honouring `config.sanitize`
74
+ # and `config.scrubber`.
75
+ #
76
+ # @param html [String] The HTML string to sanitize.
77
+ # @return [String] The sanitized HTML, or the input marked html_safe when
78
+ # sanitization is disabled.
79
+ def sanitize_ssr(html)
80
+ config = UniversalRenderer.config
81
+ # rubocop:disable Rails/OutputSafety -- opting out of sanitization is the
82
+ # documented meaning of config.sanitize = false.
83
+ return html.to_s.html_safe unless config.sanitize
84
+ # rubocop:enable Rails/OutputSafety
85
+
86
+ sanitize(html, scrubber: config.scrubber || Scrubber.new)
87
+ end
88
+
89
+ # `tag.attributes` escapes values but does not reject executable names such
90
+ # as `onload`, so the body-attribute channel needs the same boundary as
91
+ # `ssr_head` and `ssr_body`.
92
+ def sanitize_ssr_body_attributes(attrs)
93
+ attrs.each_with_object({}) do |(name, value), safe|
94
+ normalized = name.to_s.downcase
95
+ next unless normalized.match?(SAFE_BODY_ATTRIBUTE_NAME)
96
+ next if normalized.start_with?("on") || normalized == "srcdoc"
97
+
98
+ safe[name] = value
99
+ end
100
+ end
101
+
77
102
  end
78
103
  end
79
104
  end
@@ -14,6 +14,11 @@ module UniversalRenderer
14
14
  # @return [String, nil] Raw body HTML snippet produced by the renderer.
15
15
  # @!attribute body_attrs
16
16
  # @return [Hash, nil] A hash of attributes that should be applied to the <body> tag.
17
- Response = Struct.new(:head, :body, :body_attrs, keyword_init: true)
17
+ # @!attribute payload
18
+ # @return [Object, nil] Arbitrary JSON-serializable hydration state the
19
+ # renderer produced, such as a dehydrated query cache. Emit it with the
20
+ # `ssr_payload` helper.
21
+ Response =
22
+ Struct.new(:head, :body, :body_attrs, :payload, keyword_init: true)
18
23
  end
19
24
  end