ruact 0.0.15 → 0.0.16

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: ab61a580bf64e964d0356fae822cc80a83d31eaaa38fdfb5398e6534158772f0
4
- data.tar.gz: c66021ed18cba60a650a6f1f4e77fc60b848079e2c125aa4f34f2868f6af2101
3
+ metadata.gz: cc962c661c39c1c9905f3aee2ddb2c956dc196218f283ef5532bf8c9826fe98f
4
+ data.tar.gz: c3211a4a820d0ad77daebcd5ee071f91b5f10c2be9153f0bb6e62cc8153c61c9
5
5
  SHA512:
6
- metadata.gz: 053037bfb91205f625613499e5b98c14da4e886be7a94ef60239a1da934d44a35dbf104208494cc951eb7048c9bedbf1b8792c46ed9428e2a455f18d6141f729
7
- data.tar.gz: '0800f0c8d28ea88c91b7d40b488ac6931b7548811cbfa0bd56458dbc9d0d4a09ab8f2db5a0884217045072b9a3889fa168cd83d969cb9f778bf22aab0ddc1cc8'
6
+ metadata.gz: 8dcd8f0f4988c3a3e0f9950e6210d68986d980256a2ff9463a2847107513ad8c3090c9949f728aaebc810ccf1937d7c52589f267dd60b5a12ae6a70f9e1ce95a
7
+ data.tar.gz: 4c7c1bf1221798c93d183456f07cb4d4549b8ec4bfa56ff091133edff176c77bd9709f303fcbf372ffea994530aaefcb92c069d66abe9e861578578b64f68c20
data/CHANGELOG.md CHANGED
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.0.16] - 2026-10-03
11
+
12
+ ### Fixed
13
+
14
+ - **A quoted attribute on a component was dropped without a word.** `<PostCard title="Hello" />` reached the component with no `title`: only `title={…}` was read, and the quoted form is the first thing a JSX hand writes. Component tags now take the three JSX forms. `name={ruby}` is a Ruby expression, as before — except that a hyphenated name keeps its hyphen: `aria-label={…}` used to arrive as `label`. `name="text"` is the string; a quoted value never runs Ruby. A bare `name` is `true`. Anything else raises a template error that names the attribute, including ERB inside a quoted value (`title="<%= @t %>"`), which a component attribute cannot run: write `title={@t}`.
15
+
16
+ - **The Getting Started page logged a React error.** Server-rendered siblings, such as an `<h1>` next to a component or the rows of an ERB loop, reached React as an array of elements with no keys, and React logged "Each child in a list should have a unique key" on the guide's own example. Siblings are now handed to React as separate children, the way JSX compiles them, so React does not check them for keys. That covers the page itself, any element's children and the content a `<Suspense>` boundary streams in. No key is invented, and a key the template gives still wins. A data array passed as a `children` prop is left as it is.
17
+
18
+ - **A form React handles itself also got a router request.** The client router decided on clicks and form submits before the app's own handlers ran, so a form whose `onSubmit` calls `preventDefault()` was also fetched by the router: the scaffold's form sent a stray `GET` of its own page on every save. The router now decides after them, and an event the app prevented is left alone. The same holds for a link whose `onClick` prevents the default. A handler that stops the event's propagation now also keeps it from the router, which then leaves the browser to follow the link or submit the form, as Turbo does.
19
+
20
+ - **The install message said a component works in any ERB view.** In island mode, the default, it works in the views of a controller that has `include Ruact::Controller`; the message now says so.
21
+
22
+ ### Changed
23
+
24
+ - **A server function no longer returns what callbacks and auth libraries put on the controller, or answers 500 because of it.** A function call's JSON was the controller's whole `view_assigns`. An authentication library that memoizes the user in an instance variable (Devise's `current_user` sets `@current_user` on its first call) put that user in every response. Under `strict_serialization`, on by default in production, a user model without `ruact_props` then made every function call answer 500. Two rules now decide what the JSON holds:
25
+ - **What callbacks set stays out, unless the action assigns it a different object.** `@post = Post.find(...)` in the action is returned. A `before_action :set_post` whose `@post` the action only updates in place (`@post.update(...)`) is not: reassign it (`@post = @post.reload`) or `render json:`. This covers plain values too: a callback's `@current_user_id = 5` is not returned, and neither is a value the action sets to the very same object the callback stored (`@count = 0` after a callback did the same, or a memoized helper's result).
26
+ - **Authentication and authorization memos are never returned,** even when the action fills them by calling `current_user` or `authorize`. The names are in the new `config.server_function_hidden_ivars`, default `["current_user", "current_ability", "pundit"]` for Devise's `:user` scope, CanCanCan and Pundit. Every name starting with `_` is hidden too. Add the memo of any other scope or helper, such as Devise's `@current_admin` for `devise_for :admins`: `config.server_function_hidden_ivars += ["current_admin"]`.
27
+
28
+ A page render's view still sees every instance variable.
29
+
10
30
  ## [0.0.15] - 2026-10-03
11
31
 
12
32
  ### Security
@@ -416,7 +436,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
416
436
 
417
437
  - **E2E test app** — `e2e/` Rails app (no DB, in-memory Post model) with full CRUD system tests validating the complete request cycle.
418
438
 
419
- [Unreleased]: https://github.com/luizcg/ruact/compare/v0.0.15...HEAD
439
+ [Unreleased]: https://github.com/luizcg/ruact/compare/v0.0.16...HEAD
440
+ [0.0.16]: https://github.com/luizcg/ruact/releases/tag/v0.0.16
420
441
  [0.0.15]: https://github.com/luizcg/ruact/releases/tag/v0.0.15
421
442
  [0.0.14]: https://github.com/luizcg/ruact/releases/tag/v0.0.14
422
443
  [0.0.13]: https://github.com/luizcg/ruact/releases/tag/v0.0.13
@@ -425,7 +425,8 @@ module Ruact
425
425
 
426
426
  show_shadcn_next_steps if shadcn?
427
427
 
428
- say "\nThen add <MyComponent /> to any ERB view."
428
+ say "\nThen add <MyComponent /> to an ERB view of a ruact page:" unless whole_app?
429
+ say "\nThen add <MyComponent /> to any ERB view." if whole_app?
429
430
  say layout_summary
430
431
  say ""
431
432
  say "Note: re-run this generator after updating the ruact gem to refresh"
@@ -84,8 +84,8 @@ READ IT instead of simulating the name generation. Regenerate after routes/queri
84
84
  `<Suspense fallback="...">...</Suspense>` pair.
85
85
  2. **`{}` props are Ruby, not JavaScript.** `<Badge label={@post.title} />`
86
86
  evaluates `@post.title` as a Ruby expression in the ERB. No JS ternaries,
87
- no `{...spread}`, no JSX children. Plain unbraced string props are not
88
- supported either — always use braces.
87
+ no `{...spread}`, no JSX children. A quoted value (`title="Hi"`) is a
88
+ plain string and a bare attribute is `true`, as in JSX.
89
89
  3. **One action, two response shapes.** A `Ruact::Server` non-GET action
90
90
  answers JSON — its instance variables, or `204`, or `{"$redirect": path}` —
91
91
  when the request's `Accept` header is exactly `application/json`, which is
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruact
4
+ # The attributes of a PascalCase component tag in ERB, read the way JSX
5
+ # reads them (see {ErbPreprocessor}). Returns ordered
6
+ # +[name, ruby_expr]+ pairs for the props Hash the template renders.
7
+ module ComponentAttributes
8
+ ATTR_NAME_RE = /\A[a-zA-Z_][\w-]*/
9
+
10
+ # Parses the attributes string of a component tag into ordered
11
+ # +[name, ruby_expr]+ pairs, e.g. [["postId", "@post.id"], ["title", "\"Hi\""]].
12
+ # The names feed the Story 13.5 contract check; the pairs render the props
13
+ # Hash. The three JSX attribute forms:
14
+ #
15
+ # name={ruby} — the Ruby expression (nested braces honored)
16
+ # name="text" — the string, as JSX passes it (single quotes too)
17
+ # name — +true+, as JSX passes a bare attribute
18
+ #
19
+ # Anything else raises instead of being dropped: an unbraced attribute used
20
+ # to vanish without a word, and it is the first thing a JSX hand writes.
21
+ def self.parse(attrs_string)
22
+ attrs = attrs_string.sub(%r{\s*/\z}, "")
23
+ pairs = []
24
+ i = 0
25
+ while i < attrs.length
26
+ if attrs[i].match?(/\s/)
27
+ i += 1
28
+ next
29
+ end
30
+ name = attrs[i..][ATTR_NAME_RE] ||
31
+ raise(PreprocessorError, "unexpected #{attrs[i, 20].inspect} in the component's attributes")
32
+ i += name.length
33
+ i += 1 while attrs[i]&.match?(/\s/)
34
+ if attrs[i] == "="
35
+ i += 1
36
+ i += 1 while attrs[i]&.match?(/\s/)
37
+ value, i = parse_value(attrs, i, name)
38
+ pairs << [name, value]
39
+ else
40
+ pairs << [name, "true"]
41
+ end
42
+ end
43
+ pairs
44
+ end
45
+
46
+ # One attribute value at +attrs[i]+ → [ruby_expr, index after it].
47
+ def self.parse_value(attrs, pos, name)
48
+ case attrs[pos]
49
+ when "{"
50
+ expr = extract_braced_expr(attrs, pos + 1)
51
+ raise PreprocessorError, "#{name}={} is empty: put a Ruby expression in the braces" if expr.strip.empty?
52
+
53
+ [expr, pos + expr.length + 2]
54
+ when '"', "'"
55
+ close = attrs.index(attrs[pos], pos + 1)
56
+ text = attrs[(pos + 1)...(close || attrs.length)]
57
+ if text.include?("<%")
58
+ raise PreprocessorError, "#{name}= holds ERB, which a component attribute cannot run. " \
59
+ "Pass the Ruby in braces instead: #{name}={...}"
60
+ end
61
+ unless close
62
+ raise PreprocessorError, "unclosed quote in #{name}= (a component tag cannot contain `>`, " \
63
+ "even inside a quoted value: pass the text from Ruby instead)"
64
+ end
65
+
66
+ [text.inspect, close + 1]
67
+ else
68
+ raise PreprocessorError, "#{name}= needs a value: #{name}={ruby} or #{name}=\"text\""
69
+ end
70
+ end
71
+
72
+ # Given a string and a start position (just after the opening '{'),
73
+ # returns the content up to the matching '}'.
74
+ def self.extract_braced_expr(str, start)
75
+ depth = 1
76
+ i = start
77
+ while i < str.length && depth.positive?
78
+ case str[i]
79
+ when "{" then depth += 1
80
+ when "}" then depth -= 1
81
+ end
82
+ i += 1
83
+ end
84
+ raise PreprocessorError, "unclosed brace in prop expression" if depth.positive?
85
+
86
+ str[start...(i - 1)]
87
+ end
88
+
89
+ private_class_method :parse_value, :extract_braced_expr
90
+ end
91
+ end
@@ -26,6 +26,7 @@ module Ruact
26
26
  shadcn_compatible_versions
27
27
  layout
28
28
  layout_stylesheets
29
+ server_function_hidden_ivars
29
30
  ].freeze
30
31
 
31
32
  # @!attribute [r] manifest_path
@@ -125,6 +126,19 @@ module Ruact
125
126
  # @example Set an app-wide default expiry
126
127
  # Ruact.configure { |c| c.signed_global_id_default_expires_in = 1.hour }
127
128
  #
129
+ # @!attribute [r] server_function_hidden_ivars
130
+ # @return [Array<String>] Instance-variable names (no `@`) a server
131
+ # function's JSON never returns, even when the action set them: the
132
+ # memoization ivars of authentication and authorization libraries,
133
+ # which an action fills just by calling `current_user` or `authorize`.
134
+ # Names starting with `_` are always hidden. Default
135
+ # `["current_user", "current_ability", "pundit"]` (Devise's `:user`
136
+ # scope, CanCanCan, Pundit); another Devise scope memoizes its own
137
+ # `@current_<scope>` — add it. Assign to replace the list; to keep the
138
+ # defaults, add to them.
139
+ # @example Also hide an app's own memoized account
140
+ # Ruact.configure { |c| c.server_function_hidden_ivars += ["current_account"] }
141
+ #
128
142
  # @!attribute [r] shadcn_compatible_versions
129
143
  # @return [Array<Integer>] Story 10.5 — the shadcn/ui MAJOR versions the
130
144
  # `ruact:scaffold` generator is regression-tested against. When the
@@ -255,6 +269,7 @@ module Ruact
255
269
  @shadcn_compatible_versions = [1, 2, 4]
256
270
  @layout = false
257
271
  @layout_stylesheets = [:app]
272
+ @server_function_hidden_ivars = %w[current_user current_ability pundit]
258
273
  end
259
274
  end
260
275
 
@@ -278,6 +293,9 @@ module Ruact
278
293
  # @api private
279
294
  # @return [Ruact::Configuration] self, frozen
280
295
  def seal!
296
+ # An Array attribute mutated in place inside the block (`list << x`)
297
+ # never passed through its writer; validate it here, before publication.
298
+ validate_attribute_value!(:server_function_hidden_ivars, server_function_hidden_ivars)
281
299
  ATTRIBUTES.each do |attr|
282
300
  value = public_send(attr)
283
301
  next if value.nil? || value.frozen?
@@ -322,6 +340,7 @@ module Ruact
322
340
  when :shadcn_compatible_versions then validate_shadcn_compatible_versions!(value)
323
341
  when :layout then validate_layout!(value)
324
342
  when :layout_stylesheets then validate_layout_stylesheets!(value)
343
+ when :server_function_hidden_ivars then validate_server_function_hidden_ivars!(value)
325
344
  end
326
345
  end
327
346
 
@@ -440,6 +459,15 @@ module Ruact
440
459
  "major versions (e.g. [1, 2]); got #{value.inspect}."
441
460
  end
442
461
 
462
+ # An Array of ivar names; a leading `@` is a typo that would hide nothing.
463
+ def validate_server_function_hidden_ivars!(value)
464
+ return if value.is_a?(Array) && value.all? { |name| name.is_a?(String) && !name.start_with?("@") }
465
+
466
+ raise Ruact::ConfigurationError,
467
+ "Ruact::Configuration#server_function_hidden_ivars must be an Array of instance-variable " \
468
+ "names without the @ (e.g. [\"current_user\"]); got #{value.inspect}."
469
+ end
470
+
443
471
  def build_error_message(attr, location)
444
472
  <<~MSG.strip
445
473
  ruact: cannot mutate Ruact::Configuration##{attr} after initialization.
@@ -51,11 +51,6 @@ module Ruact
51
51
  # unrelated `<% "</Dialog>" %>` appears later).
52
52
  ERB_ISLAND_RE = /<%.*?%>/m
53
53
 
54
- # Matches a +{ruby_expr}+ attribute value — captures everything between the braces.
55
- # We use a simple bracket-depth counter approach during scanning instead of regex
56
- # because expressions can contain nested braces: {foo.bar({ a: 1 })}.
57
- PROP_RE = /\b([a-zA-Z_][a-zA-Z0-9_]*)=\{/
58
-
59
54
  # Transform ERB source, replacing component tags with ERB placeholders.
60
55
  # Returns the transformed source string.
61
56
  #
@@ -114,7 +109,7 @@ module Ruact
114
109
  # the render path surfaces the clear error). In prod this is the
115
110
  # boot-loaded Ruact.manifest, unchanged.
116
111
  registry = ManifestResolver.resolve_soft if registry == :default
117
- pairs = parse_prop_pairs(attrs_string)
112
+ pairs = ComponentAttributes.parse(attrs_string)
118
113
  validate_contract(registry, component_name, pairs.map(&:first),
119
114
  at: { file: identifier, line: line, snippet: match.strip })
120
115
  props_ruby = pairs.map { |name, expr| "#{name.inspect} => #{expr}" }.join(", ")
@@ -247,46 +242,5 @@ module Ruact
247
242
  m = identifier.to_s.match(%r{app/views/(.+)/[^/]+\z})
248
243
  m && m[1]
249
244
  end
250
-
251
- # Parses the attributes string of a component tag into ordered
252
- # +[name, ruby_expr]+ pairs, e.g. [["postId", "@post.id"], ["count", "5"]].
253
- # Honors nested braces in values (via {#extract_braced_expr}). The names
254
- # feed the Story 13.5 contract check; the pairs render the props Hash.
255
- def parse_prop_pairs(attrs_string)
256
- return [] if attrs_string.empty?
257
-
258
- pairs = []
259
- remaining = attrs_string.dup
260
-
261
- while (m = PROP_RE.match(remaining))
262
- prop_name = m[1]
263
- # Find the matching closing brace, respecting nesting
264
- value_start = m.end(0)
265
- value_expr = extract_braced_expr(remaining, value_start)
266
- pairs << [prop_name, value_expr]
267
- # Advance past this prop
268
- remaining = remaining[(value_start + value_expr.length + 1)..] # +1 for closing }
269
- break if remaining.nil?
270
- end
271
-
272
- pairs
273
- end
274
-
275
- # Given a string and a start position (just after the opening '{'),
276
- # returns the content up to the matching '}'.
277
- def extract_braced_expr(str, start)
278
- depth = 1
279
- i = start
280
- while i < str.length && depth.positive?
281
- case str[i]
282
- when "{" then depth += 1
283
- when "}" then depth -= 1
284
- end
285
- i += 1
286
- end
287
- raise PreprocessorError, "unclosed brace in prop expression" if depth.positive?
288
-
289
- str[start...(i - 1)]
290
- end
291
245
  end
292
246
  end
data/lib/ruact/server.rb CHANGED
@@ -14,6 +14,7 @@ require_relative "../ruact"
14
14
  require_relative "server_functions/error_rendering"
15
15
  require_relative "server_functions/bucket_two_payload"
16
16
  require_relative "server_functions/name_bridge"
17
+ require_relative "server_functions/action_assigns"
17
18
  require_relative "redirect_path"
18
19
  require_relative "validation_errors_collector"
19
20
 
@@ -69,6 +70,9 @@ module Ruact
69
70
  module Server
70
71
  extend ActiveSupport::Concern
71
72
 
73
+ # A function call returns the action's own ivars, not its callbacks'.
74
+ include Ruact::ServerFunctions::ActionAssigns
75
+
72
76
  include Ruact::ServerFunctions::ErrorRendering
73
77
  include Ruact::ValidationErrorsCollector
74
78
 
@@ -202,16 +206,16 @@ module Ruact
202
206
  # Story 9.2 AC2/AC4 (D1) — Bucket-2 success-path negotiation. When the
203
207
  # action finished without an explicit render on a function-call request
204
208
  # ({#__ruact_function_call?} — `Accept: application/json`, non-GET), serialize
205
- # the action's exposed instance variables (Rails `view_assigns`, verbatim —
206
- # the same set a view would see) as a JSON object keyed by ivar name, or
209
+ # the instance variables the ACTION assigned (Rails `view_assigns` minus
210
+ # what callbacks set and the action left alone — see
211
+ # {Ruact::ServerFunctions::ActionAssigns}) as a JSON object keyed by ivar name, or
207
212
  # `204 No Content` when none were set. Any other request shape falls through
208
213
  # to `super` so Bucket-1 rendering — the host's `Ruact::Controller` Flight
209
214
  # re-render, then Rails — is byte-for-byte unchanged (AC1).
210
215
  #
211
- # The exposed-ivar set is Rails' own `view_assigns` with no custom filtering:
212
- # Rails already excludes its protected `@_`-prefixed internals (including the
213
- # CSRF `@_marked_for_same_origin_verification` flag), so what remains is
214
- # exactly what the action assigned. Each value is serialized through the
216
+ # Rails' `view_assigns` already excludes its protected `@_`-prefixed
217
+ # internals (including the CSRF `@_marked_for_same_origin_verification`
218
+ # flag); ActionAssigns then drops what callbacks set. Each value is serialized through the
215
219
  # `ruact_props` / `Ruact::Serializable` / `strict_serialization` rules
216
220
  # ({Ruact::ServerFunctions::BucketTwoPayload}); a single ivar stays keyed
217
221
  # (no magic unwrap).
@@ -222,7 +226,7 @@ module Ruact
222
226
  # dev `@errors`) are dropped from the serialized assigns; Rails' own
223
227
  # `view_assigns` only filters a fixed set of `@_` ivars, not every `@__`
224
228
  # one (see {Ruact::ValidationErrorsCollector::RESERVED_ASSIGN_KEYS}).
225
- assigns = view_assigns.except(*ValidationErrorsCollector::RESERVED_ASSIGN_KEYS)
229
+ assigns = __ruact_action_assigns(view_assigns.except(*ValidationErrorsCollector::RESERVED_ASSIGN_KEYS))
226
230
 
227
231
  # Story 15.0 (F6) — reaching this branch means ruact itself is producing
228
232
  # the function-call response (fall-through: injection, plain JSON, or
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruact
4
+ module ServerFunctions
5
+ # What a server function's JSON is made of (decision, 2026-10-03): the
6
+ # ivars the ACTION assigned, not everything a view would see.
7
+ #
8
+ # Two filters. Callbacks run before `send_action`, so what they put on the
9
+ # controller (a `set_post`, a `set_current_user`) is recorded there and
10
+ # left out of the JSON unless the action assigns it again. And some ivars
11
+ # are filled DURING the action without the action naming them — Devise's
12
+ # `current_user` memoizes `@current_user` on first call, Pundit's
13
+ # `authorize` sets `@pundit` — so names in
14
+ # `Ruact.config.server_function_hidden_ivars`, and every name starting
15
+ # with `_`, are never returned. Without these, a function call carried the
16
+ # signed-in user, and under `strict_serialization` (production's default)
17
+ # a user model without `ruact_props` made every call a 500. A page
18
+ # render's view still sees every ivar; only the function-call JSON
19
+ # narrows.
20
+ module ActionAssigns
21
+ # The snapshot's own name, kept out of what it filters.
22
+ CALLBACK_IVARS_KEY = "__ruact_callback_ivars"
23
+
24
+ private
25
+
26
+ def send_action(...)
27
+ if __ruact_function_call?
28
+ @__ruact_callback_ivars = instance_variables.to_h do |name|
29
+ [name.to_s.delete_prefix("@"), instance_variable_get(name)]
30
+ end
31
+ end
32
+ super
33
+ end
34
+
35
+ # +assigns+ minus what callbacks set and the action left holding the
36
+ # same object. Only a DIFFERENT object makes it the action's: setting
37
+ # the same value again (`@ok = false` after a callback did, a memoized
38
+ # helper returning what the callback already stored) leaves it out.
39
+ # Security first — a callback's `@current_user_id = 5` must not ride
40
+ # every response — at the price of a key that is absent when callback
41
+ # and action happen to agree.
42
+ def __ruact_action_assigns(assigns)
43
+ before = @__ruact_callback_ivars || {}
44
+ hidden = Ruact.config.server_function_hidden_ivars
45
+ assigns.reject do |name, value|
46
+ name == CALLBACK_IVARS_KEY || name.start_with?("_") || hidden.include?(name) ||
47
+ (before.key?(name) && before[name].equal?(value))
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -7,7 +7,7 @@ require_relative "../errors"
7
7
  module Ruact
8
8
  module ServerFunctions
9
9
  # Story 9.2 — pure serializer for the Bucket-2 (imperative `await fn()`)
10
- # response body. Takes the host action's exposed instance variables (Rails
10
+ # response body. Takes the instance variables the action returns (Rails
11
11
  # `view_assigns`, resolved by the caller) and produces a JSON-ready Ruby
12
12
  # Hash, keyed by ivar name, applying the SAME prop-exposure policy as the
13
13
  # Flight serializer ({Ruact::Flight::Serializer#serialize_unknown}):
data/lib/ruact/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ruact
4
- VERSION = "0.0.15"
4
+ VERSION = "0.0.16"
5
5
  end
data/lib/ruact.rb CHANGED
@@ -9,6 +9,7 @@ require_relative "ruact/flight"
9
9
  require_relative "ruact/string_distance"
10
10
  require_relative "ruact/layout_source"
11
11
  require_relative "ruact/component_contract"
12
+ require_relative "ruact/component_attributes"
12
13
  require_relative "ruact/erb_preprocessor"
13
14
  require_relative "ruact/render_context"
14
15
  require_relative "ruact/html_converter"
@@ -31,6 +31,7 @@
31
31
  // against shuffling being turned on later.
32
32
 
33
33
  import { describe, it, expect, beforeEach } from "vitest";
34
+ import { Fragment } from "react";
34
35
  import fs from "node:fs";
35
36
  import path from "node:path";
36
37
  import {
@@ -450,3 +451,49 @@ describe("dates and big integers (Story 17-0c)", () => {
450
451
  expect(tree.literal).toBe("$D2026-01-01 is a string");
451
452
  });
452
453
  });
454
+
455
+ // A server-rendered tree has unkeyed siblings (an <h1> beside a component, the
456
+ // rows of an ERB loop). Handed to React as an array they made it log "Each
457
+ // child in a list should have a unique key" on the Getting Started page. They
458
+ // are passed as separate children instead — static, like JSX — and no key is
459
+ // invented (an invented index key can collide with a template's own key and
460
+ // duplicate a row, or reset state when a sibling comes or goes).
461
+ describe("sibling children", () => {
462
+ const payload = [
463
+ '1:I["/TaskList.jsx","TaskList"]',
464
+ '0:[["$","h1",null,{"children":"Hello"}],["$","$L1",null,{"tasks":[]}],["$","ul",null,{"children":[["$","li",null,{"children":"a"}],["$","li","explicit",{"children":"b"}]]}]]',
465
+ "",
466
+ ].join("\n");
467
+
468
+ it("wraps several root siblings in a Fragment, without inventing keys", () => {
469
+ const tree = createFromFlightPayload(payload, MODULE_REGISTRY);
470
+ expect(tree.type).toBe(Fragment);
471
+ expect(tree.props.children.map((el) => el.key)).toEqual([null, null, null]);
472
+ });
473
+
474
+ it("keeps the template's own key and invents none for the others", () => {
475
+ const tree = createFromFlightPayload(payload, MODULE_REGISTRY);
476
+ const ul = tree.props.children[2];
477
+ expect(ul.props.children.map((el) => el.key)).toEqual([null, "explicit"]);
478
+ expect(ul.props.children).toHaveLength(2);
479
+ });
480
+
481
+ it("wraps a Suspense boundary's deferred siblings the same way", () => {
482
+ const deferred = buildTree([["$", "h2", null, { children: "A" }], ["$", "p", null, { children: "B" }]], EMPTY_ROWS, MODULE_REGISTRY);
483
+ expect(deferred.type).toBe(Fragment);
484
+ expect(deferred.props.children).toHaveLength(2);
485
+ });
486
+
487
+ it("does not spread a data array passed as children (it stays mutable)", () => {
488
+ const tree = createFromFlightPayload(payloadWithProps({ children: ["a", "b", "c"] }), MODULE_REGISTRY);
489
+ expect(tree.props.children).toEqual(["a", "b", "c"]);
490
+ expect(Object.isFrozen(tree.props.children)).toBe(false);
491
+ });
492
+
493
+ it("leaves a single root, data arrays and one-element children as they were", () => {
494
+ const tree = createFromFlightPayload(payloadWithProps({ tasks: [{ id: 1 }, { id: 2 }] }), MODULE_REGISTRY);
495
+ expect(tree.key).toBeNull();
496
+ expect(tree.props.tasks).toEqual([{ id: 1 }, { id: 2 }]);
497
+ expect(createFromFlightPayload('0:[1,2]\n', MODULE_REGISTRY)).toEqual([1, 2]);
498
+ });
499
+ });
@@ -26,7 +26,7 @@
26
26
  * - "$L{hex}" referencing a missing row → React.lazy() that resolves when the row arrives
27
27
  */
28
28
 
29
- import { createElement, Fragment, lazy, Suspense } from "react";
29
+ import { createElement, Fragment, isValidElement, lazy, Suspense } from "react";
30
30
 
31
31
  // ---------------------------------------------------------------------------
32
32
  // Pending chunk registry — used for streaming Suspense deferred rows
@@ -273,7 +273,7 @@ export function buildTreeFromRows(rows, moduleRegistry) {
273
273
  * @param {Object} moduleRegistry
274
274
  */
275
275
  export function buildTree(value, rows, moduleRegistry) {
276
- return _buildTree(value, rows, moduleRegistry);
276
+ return rootTree(_buildTree(value, rows, moduleRegistry));
277
277
  }
278
278
 
279
279
  /**
@@ -341,7 +341,7 @@ function _buildTree(value, rows, moduleRegistry) {
341
341
  }
342
342
  // Model row — deferred content already arrived (non-streaming path).
343
343
  // Wrap in a function component so it can be used as a type in createElement.
344
- const content = _buildTree(row.value, rows, moduleRegistry);
344
+ const content = rootTree(_buildTree(row.value, rows, moduleRegistry));
345
345
  return () => content;
346
346
  }
347
347
  return value;
@@ -355,7 +355,7 @@ function _buildTree(value, rows, moduleRegistry) {
355
355
  const type = resolveType(rawType, rows, moduleRegistry);
356
356
  const props = buildProps(rawProps, rows, moduleRegistry);
357
357
  if (key != null) props.key = key;
358
- return createElement(type, props);
358
+ return createWithChildren(type, props);
359
359
  }
360
360
 
361
361
  // Plain array — fragment children OR a data array sitting in a prop.
@@ -384,6 +384,34 @@ function _buildTree(value, rows, moduleRegistry) {
384
384
  return value;
385
385
  }
386
386
 
387
+ // A server-rendered tree is static: siblings (an <h1> beside a component, the
388
+ // rows of an ERB loop) carry no key on the wire. Handed to React as an ARRAY,
389
+ // they made it log "Each child in a list should have a unique key" — on the
390
+ // Getting Started page itself. Passed as separate arguments, the way JSX
391
+ // compiles `<div><h1/><p/></div>`, they are static children React does not
392
+ // key-check, and nothing is invented: a key the template gave still wins, and
393
+ // reconciliation is the one React does for JSX. Two or more only — a single
394
+ // child or an explicit one-element array keeps its shape (Story 17-0a).
395
+ function createWithChildren(type, props) {
396
+ const { children } = props;
397
+ if (!Array.isArray(children) || children.length < 2 || !children.some(isValidElement)) {
398
+ return createElement(type, props); // a data array passed as `children` stays a plain array
399
+ }
400
+ const rest = { ...props };
401
+ delete rest.children;
402
+ return createElement(type, rest, ...children);
403
+ }
404
+
405
+ // The page's root — or a Suspense boundary's deferred content — when it is
406
+ // several siblings: a Fragment of them, for the same reason as above (React
407
+ // logged the warning against <App>).
408
+ function rootTree(tree) {
409
+ if (Array.isArray(tree) && tree.length >= 2 && tree.some(isValidElement)) {
410
+ return createElement(Fragment, null, ...tree);
411
+ }
412
+ return tree;
413
+ }
414
+
387
415
  function resolveType(rawType, rows, moduleRegistry) {
388
416
  if (typeof rawType === "string") {
389
417
  if (rawType === "$SS") return Suspense; // React.Suspense
@@ -33,8 +33,13 @@ export function setupRouter({ onNavigate, moduleRegistry, onError = null }) {
33
33
  _moduleRegistry = moduleRegistry;
34
34
  _onError = onError;
35
35
 
36
- document.addEventListener("click", handleClick, { capture: true });
37
- document.addEventListener("submit", handleSubmit, { capture: true });
36
+ // Bubble phase, on the document: the app's own handlers (React's, which
37
+ // listen on the root container, and any other) run first, so an
38
+ // `onSubmit`/`onClick` that calls preventDefault() keeps the event. In the
39
+ // capture phase the router decided before them: a React form that handles
40
+ // its own submit (the scaffold's) also got a router fetch of its URL.
41
+ document.addEventListener("click", handleClick);
42
+ document.addEventListener("submit", handleSubmit);
38
43
  window.addEventListener("popstate", handlePopstate);
39
44
 
40
45
  // Story 8.2 — publish the revalidate handle the runtime helper reads.
@@ -56,8 +61,8 @@ export function setupRouter({ onNavigate, moduleRegistry, onError = null }) {
56
61
  }
57
62
 
58
63
  export function teardownRouter() {
59
- document.removeEventListener("click", handleClick, { capture: true });
60
- document.removeEventListener("submit", handleSubmit, { capture: true });
64
+ document.removeEventListener("click", handleClick);
65
+ document.removeEventListener("submit", handleSubmit);
61
66
  window.removeEventListener("popstate", handlePopstate);
62
67
  _onNavigate = null;
63
68
  _moduleRegistry = null;
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruact
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.15
4
+ version: 0.0.16
5
5
  platform: ruby
6
6
  authors:
7
7
  - Luiz Garcia
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-03 00:00:00.000000000 Z
11
+ date: 2026-10-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: nokogiri
@@ -72,6 +72,7 @@ files:
72
72
  - lib/generators/ruact/scaffold/templates/views/show.html.erb.tt
73
73
  - lib/ruact.rb
74
74
  - lib/ruact/client_manifest.rb
75
+ - lib/ruact/component_attributes.rb
75
76
  - lib/ruact/component_contract.rb
76
77
  - lib/ruact/configuration.rb
77
78
  - lib/ruact/controller.rb
@@ -105,6 +106,7 @@ files:
105
106
  - lib/ruact/serializable.rb
106
107
  - lib/ruact/server.rb
107
108
  - lib/ruact/server_functions.rb
109
+ - lib/ruact/server_functions/action_assigns.rb
108
110
  - lib/ruact/server_functions/backtrace_cleaner.rb
109
111
  - lib/ruact/server_functions/bucket_two_payload.rb
110
112
  - lib/ruact/server_functions/codegen.rb