ruact 0.0.15 → 0.0.17

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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -1
  3. data/lib/generators/ruact/install/install_generator.rb +2 -1
  4. data/lib/generators/ruact/install/templates/AGENTS.md.tt +2 -2
  5. data/lib/ruact/client_manifest.rb +11 -2
  6. data/lib/ruact/component_attributes.rb +91 -0
  7. data/lib/ruact/configuration.rb +28 -0
  8. data/lib/ruact/controller.rb +3 -1
  9. data/lib/ruact/doctor.rb +64 -4
  10. data/lib/ruact/erb_preprocessor.rb +1 -47
  11. data/lib/ruact/flight/renderer.rb +23 -9
  12. data/lib/ruact/flight/request.rb +31 -2
  13. data/lib/ruact/flight/row_emitter.rb +9 -0
  14. data/lib/ruact/flight/serializer.rb +22 -7
  15. data/lib/ruact/render_pipeline.rb +10 -3
  16. data/lib/ruact/server.rb +11 -7
  17. data/lib/ruact/server_functions/action_assigns.rb +52 -0
  18. data/lib/ruact/server_functions/bucket_two_payload.rb +1 -1
  19. data/lib/ruact/testing/component_query.rb +11 -4
  20. data/lib/ruact/testing/flight_wire_parser.rb +2 -2
  21. data/lib/ruact/version.rb +1 -1
  22. data/lib/ruact.rb +1 -0
  23. data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +44 -2
  24. data/vendor/javascript/vite-plugin-ruact/flight-conformance.test.mjs +286 -0
  25. data/vendor/javascript/vite-plugin-ruact/index.js +89 -14
  26. data/vendor/javascript/vite-plugin-ruact/package-lock.json +804 -3
  27. data/vendor/javascript/vite-plugin-ruact/package.json +5 -1
  28. data/vendor/javascript/vite-plugin-ruact/ruact-router.test.mjs +53 -0
  29. data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +46 -17
  30. data/vendor/javascript/vite-plugin-ruact/runtime/flight-modules.js +40 -0
  31. data/vendor/javascript/vite-plugin-ruact/runtime/flight-webpack-globals.js +15 -0
  32. data/vendor/javascript/vite-plugin-ruact/runtime/root-boundary.js +41 -0
  33. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +75 -88
  34. data/vendor/javascript/vite-plugin-ruact/runtime/suspense-boundary.js +83 -0
  35. data/vendor/javascript/vite-plugin-ruact/runtime/transport.js +41 -0
  36. data/vendor/javascript/vite-plugin-ruact/runtime/vendor/react-server-dom-webpack/LICENSE +21 -0
  37. data/vendor/javascript/vite-plugin-ruact/runtime/vendor/react-server-dom-webpack/VERSION.json +5 -0
  38. data/vendor/javascript/vite-plugin-ruact/runtime/vendor/react-server-dom-webpack/client.browser.development.js +5473 -0
  39. data/vendor/javascript/vite-plugin-ruact/runtime/vendor/react-server-dom-webpack/client.browser.production.js +2126 -0
  40. data/vendor/javascript/vite-plugin-ruact/scripts/vendor-flight-client.mjs +124 -0
  41. data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +14 -0
  42. metadata +15 -4
  43. data/vendor/javascript/vite-plugin-ruact/flight-client.test.mjs +0 -452
  44. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +0 -408
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ab61a580bf64e964d0356fae822cc80a83d31eaaa38fdfb5398e6534158772f0
4
- data.tar.gz: c66021ed18cba60a650a6f1f4e77fc60b848079e2c125aa4f34f2868f6af2101
3
+ metadata.gz: 78c8550850bcded6e5585918c33d4520131a019520f4def020e9e5701a47c7ae
4
+ data.tar.gz: ab49c696c75137128062c6f9c7fa8b2569b8f918818bcf50f209d3939675af59
5
5
  SHA512:
6
- metadata.gz: 053037bfb91205f625613499e5b98c14da4e886be7a94ef60239a1da934d44a35dbf104208494cc951eb7048c9bedbf1b8792c46ed9428e2a455f18d6141f729
7
- data.tar.gz: '0800f0c8d28ea88c91b7d40b488ac6931b7548811cbfa0bd56458dbc9d0d4a09ab8f2db5a0884217045072b9a3889fa168cd83d969cb9f778bf22aab0ddc1cc8'
6
+ metadata.gz: a4dda8e8c229d941a6d8e852704837259a45653002b2c7465764ae219c58fbaa4188d4490745efe88327ceb736094ccc265b6e38f2cc38d008388bf7cb5d1e88
7
+ data.tar.gz: 976492c9e23c34d777071ec0fa313ae146d942ba1ade21d939de380e012a0cedc4028c9e997dabe96518ad9b7b00bbd48a4c162eef9270969ba9e2bf5ce4e3a6
data/CHANGELOG.md CHANGED
@@ -7,6 +7,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.0.17] - 2026-10-06
11
+
12
+ ### Changed
13
+
14
+ - **The browser reads the page with React's own Flight client.** ruact had its own decoder, and most of the transport bugs fixed in 0.0.12–0.0.16 lived in it. The runtime now uses React's `react-server-dom-webpack` client (`client.browser`), which ships inside the gem: version 19.3.0, MIT, regenerated by `vendor/javascript/vite-plugin-ruact/scripts/vendor-flight-client.mjs`. Nothing is added to the app's `package.json`. The npm package would also install `webpack`, which this avoids. An app that needs a different version adds `react-server-dom-webpack` to its own `package.json`, and the Vite plugin uses that copy instead; a copy that is only installed as another package's dependency is not used. Vite's startup and build output and `rails ruact:doctor` say which copy is in use, and the doctor warns when the app's copy is built for another React minor than the app's `react`. Supported React: `^19.2.8`, checked in CI against 19.2.8 and 19.3.0. The bundle grows: the nav-islands example went from 74.35 kB to 80.82 kB gzipped (+6.47 kB), and the `.gem` from 373 KB to 427 KB.
15
+
16
+ - **The Flight wire changed to the format React's client reads.** The server and the browser runtime change together; a page served by this version needs this version's runtime. **When you deploy it,** a tab still open on the previous version cannot read the new responses: its next in-place navigation fails and the user has to reload. Code that parses ruact's wire itself, rather than through `Ruact::Testing`, needs updating:
17
+ - import rows are `[id, chunks, name]`, with `chunks` always `[]` (the runtime registers every component up front);
18
+ - a string of 1024 bytes or more is referenced as `"$<hex>"`, not `"$T<hex>"`;
19
+ - a `<Suspense>` boundary's type is a `"$Sreact.suspense"` symbol row, and its deferred content is a lazy child, `"children":"$L<hex>"`, inside a boundary the runtime registers as `ruact:boundary`;
20
+ - an error row is an object, `{"digest","name","message","stack","env"}`, whose `digest` is a stable code (`ruact:suspense-timeout`): React's production client keeps only the digest, and the runtime turns it back into the message;
21
+ - in development only, elements carry React's three development slots, so React does not warn about keys on ERB siblings and loops. The test environment keeps the production wire.
22
+
23
+ `Ruact::Testing`'s matchers read the new wire, and `have_ruact_component` ignores the runtime's own boundary.
24
+
25
+ - **An error in a `<Suspense>` child no longer risks the whole page.** React unmounts the page on a render error no component catches. Each Suspense child now sits inside a boundary that keeps the fallback on screen and passes the error to the router's `onError` as `[ruact] Server error: …`, in production too, as before. The same goes for a response that ends before the Suspense row arrives (a server error mid-stream, a dropped connection): `[ruact] Server error: the response ended before the whole page arrived`. The boundary handles only what the server or the stream produced; a crash in your own component still reaches your error boundaries. The next response for the page (a navigation, `revalidate()`) renders normally.
26
+
27
+ - **A render error shows on the page instead of emptying it.** React's client looks a client component up when it renders, so a component missing from the registry (no `"use client"`, a stale build) fails then, and React would remove the whole page. The page now shows `[ruact] Error: client component not registered: …` in its place, as the first load did before; the next navigation replaces it.
28
+
29
+ ## [0.0.16] - 2026-10-03
30
+
31
+ ### Fixed
32
+
33
+ - **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}`.
34
+
35
+ - **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.
36
+
37
+ - **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.
38
+
39
+ - **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.
40
+
41
+ ### Changed
42
+
43
+ - **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:
44
+ - **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).
45
+ - **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"]`.
46
+
47
+ A page render's view still sees every instance variable.
48
+
10
49
  ## [0.0.15] - 2026-10-03
11
50
 
12
51
  ### Security
@@ -416,7 +455,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
416
455
 
417
456
  - **E2E test app** — `e2e/` Rails app (no DB, in-memory Post model) with full CRUD system tests validating the complete request cycle.
418
457
 
419
- [Unreleased]: https://github.com/luizcg/ruact/compare/v0.0.15...HEAD
458
+ [Unreleased]: https://github.com/luizcg/ruact/compare/v0.0.17...HEAD
459
+ [0.0.17]: https://github.com/luizcg/ruact/releases/tag/v0.0.17
460
+ [0.0.16]: https://github.com/luizcg/ruact/releases/tag/v0.0.16
420
461
  [0.0.15]: https://github.com/luizcg/ruact/releases/tag/v0.0.15
421
462
  [0.0.14]: https://github.com/luizcg/ruact/releases/tag/v0.0.14
422
463
  [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
@@ -38,13 +38,22 @@ module Ruact
38
38
  # Story 13.5 preprocess-time call-site validator; +nil+ means "no contract →
39
39
  # no validation" (fail open).
40
40
  class ClientManifest
41
+ # The +chunks+ of every import row (see {#resolve}). One shared frozen
42
+ # array: a render resolves each component once, and the allocation guard
43
+ # counts every array.
44
+ NO_CHUNKS = [].freeze
45
+
41
46
  # Used by Flight::Serializer to produce I rows.
42
- # Returns the metadata array the client expects: [id, name, chunks]
47
+ # Returns the metadata array React's Flight client reads: [id, chunks, name]
48
+ # (Story 18-1). +chunks+ is always empty: the browser runtime registers
49
+ # every client component eagerly (`virtual:ruact/registry`), so there is
50
+ # nothing to load — and React reads +chunks+ as `[chunkId, filename]` pairs,
51
+ # which the manifest's one-URL list is not.
43
52
  def resolve(module_id, _export_name)
44
53
  entry = by_module_id(module_id)
45
54
  raise "ClientManifest: no entry for module_id=#{module_id.inspect}" unless entry
46
55
 
47
- [entry["id"], entry["name"], entry["chunks"]]
56
+ [entry["id"], NO_CHUNKS, entry["name"]]
48
57
  end
49
58
 
50
59
  # Returns true if +name+ is a top-level key in the manifest data.
@@ -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.
@@ -168,7 +168,9 @@ module Ruact
168
168
  # its headers on the first row.
169
169
  self.status = status if status
170
170
 
171
- pipeline = RenderPipeline.new(ruact_manifest, controller_path: controller_path, logger: logger)
171
+ pipeline = RenderPipeline.new(ruact_manifest,
172
+ controller_path: controller_path, logger: logger,
173
+ development: Flight::Renderer.development_default)
172
174
  streaming = ruact_request? && self.class.ancestors.include?(ActionController::Live)
173
175
 
174
176
  # Allocate a per-render context and expose it to the view via a normal
data/lib/ruact/doctor.rb CHANGED
@@ -8,8 +8,8 @@ module Ruact
8
8
  # Runs a suite of installation health checks and prints ✓/✗ per check.
9
9
  # Extracted from the ruact:doctor Rake task for direct testability (FR27).
10
10
  class Doctor # rubocop:disable Metrics/ClassLength
11
- CHECKS = %i[manifest vite controller layout head_assets streaming legacy_constant serialize_only
12
- flight_middleware].freeze
11
+ CHECKS = %i[manifest vite flight_client controller layout head_assets streaming legacy_constant
12
+ serialize_only flight_middleware].freeze
13
13
  # Built via Array#join so the gem-CI `name-propagation` guard does not
14
14
  # match these literals against itself (Story 5.1 review F4 — the doctor
15
15
  # file participates in the guard with no exclusion).
@@ -42,8 +42,8 @@ module Ruact
42
42
  /\b#{%w[from flight].join('_')}\b/,
43
43
  /\b#{%w[parse flight].join('_')}\b/,
44
44
  /\b#{%w[decode flight].join('_')}\b/,
45
- # React Flight reader entry points invoked from Ruby (NOT createFromFlightPayload,
46
- # which is the client/browser deserializing the server's own trusted payload)
45
+ # React Flight reader entry points invoked from Ruby (NOT the browser
46
+ # runtime's own calls, which read the server's own trusted payload)
47
47
  /\b#{%w[create From].join}(?:NodeStream|ReadableStream|Fetch)\b/
48
48
  ].freeze
49
49
  DESERIALIZE_SIGNAL_RE = Regexp.union(DESERIALIZE_SIGNALS)
@@ -163,6 +163,66 @@ module Ruact
163
163
  "Run npm run dev (or bin/dev) to start the Vite dev server."]
164
164
  end
165
165
 
166
+ # Story 18-1 — which copy of React's Flight client the browser runtime
167
+ # uses, decided as the Vite plugin decides it (`resolveFlightClient`): the
168
+ # app's `react-server-dom-webpack` when the app's package.json declares it
169
+ # and Node finds it from the app (a workspace may hoist it to a parent
170
+ # directory), otherwise the one vendored in the gem. The plugin prints the
171
+ # same line when Vite starts. The client is built for one React minor; the
172
+ # app's copy is checked against the app's `react`.
173
+ def check_flight_client
174
+ vendored = [:pass, "Flight client: react-server-dom-webpack #{vendored_flight_client_version} vendored in ruact"]
175
+ return vendored unless app_declares?("react-server-dom-webpack")
176
+
177
+ app_package = node_package_json("react-server-dom-webpack")
178
+ return vendored unless app_package
179
+
180
+ version = JSON.parse(app_package.read)["version"]
181
+ react_package = node_package_json("react")
182
+ react = react_package && JSON.parse(react_package.read)["version"]
183
+ message = "Flight client: react-server-dom-webpack #{version} from the app (replaces the copy in ruact)"
184
+ return [:pass, message] if react.nil? || minor(react) == minor(version)
185
+
186
+ [:warn, "#{message}, but the app's react is #{react}",
187
+ "Install the react-server-dom-webpack matching your react (npm install react-server-dom-webpack@#{react}), " \
188
+ "or uninstall it to use the copy vendored in ruact."]
189
+ rescue JSON::ParserError => e
190
+ [:warn, "Flight client: a package.json under node_modules is not valid JSON (#{e.message})",
191
+ "Reinstall it (npm install), or remove react-server-dom-webpack to use the copy vendored in ruact."]
192
+ end
193
+
194
+ # As the Vite plugin reads it: a non-empty entry in dependencies or
195
+ # devDependencies; an unreadable package.json declares nothing.
196
+ def app_declares?(name)
197
+ manifest = Rails.root.join("package.json")
198
+ return false unless manifest.exist?
199
+
200
+ json = JSON.parse(manifest.read)
201
+ [json["dependencies"], json["devDependencies"]].any? { |deps| deps.is_a?(Hash) && !deps[name].to_s.empty? }
202
+ rescue JSON::ParserError
203
+ false
204
+ end
205
+
206
+ # The first `node_modules/<name>/package.json` from Rails.root upward, the
207
+ # way Node resolves a package.
208
+ def node_package_json(name)
209
+ Pathname(Rails.root).expand_path.ascend do |dir|
210
+ candidate = dir.join("node_modules", name, "package.json")
211
+ return candidate if candidate.exist?
212
+ end
213
+ nil
214
+ end
215
+
216
+ def minor(version)
217
+ version.to_s.split(".").first(2).join(".")
218
+ end
219
+
220
+ def vendored_flight_client_version
221
+ path = File.join(File.dirname(Ruact.vite_plugin_path), "runtime", "vendor", "react-server-dom-webpack",
222
+ "VERSION.json")
223
+ JSON.parse(File.read(path))["version"]
224
+ end
225
+
166
226
  # Story 17.0g (FR116) — reports the ADOPTION MODE instead of demanding one.
167
227
  #
168
228
  # Whole-app (`ruact:install --app`): ApplicationController includes the
@@ -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
@@ -14,17 +14,30 @@ module Ruact
14
14
  each(model, bundler_config, streaming: false, **).to_a.join
15
15
  end
16
16
 
17
- def self.each(model, bundler_config, strict_serialization: false, on_as_json_warning: nil,
18
- streaming: true, &)
19
- new(model, bundler_config,
20
- strict_serialization: strict_serialization,
21
- on_as_json_warning: on_as_json_warning).each(streaming: streaming, &)
17
+ # Keyword options other than +streaming:+ go to {#initialize}
18
+ # (strict_serialization:, on_as_json_warning:, development:).
19
+ def self.each(model, bundler_config, streaming: true, **, &)
20
+ new(model, bundler_config, **).each(streaming: streaming, &)
22
21
  end
23
22
 
24
- def initialize(model, bundler_config, strict_serialization: false, on_as_json_warning: nil)
23
+ # Whether rows carry the slots React's development client reads (see
24
+ # Serializer#element_tuple): in Rails' development environment only. The
25
+ # test environment keeps the production wire, so what a request spec
26
+ # asserts is what production sends.
27
+ def self.development_default
28
+ return false unless defined?(Rails) && Rails.respond_to?(:env)
29
+
30
+ Rails.env.development?
31
+ rescue StandardError
32
+ false
33
+ end
34
+
35
+ def initialize(model, bundler_config, strict_serialization: false, on_as_json_warning: nil,
36
+ development: false)
25
37
  @request = Request.new(model, bundler_config,
26
38
  strict_serialization: strict_serialization,
27
- on_as_json_warning: on_as_json_warning)
39
+ on_as_json_warning: on_as_json_warning,
40
+ development: development)
28
41
  end
29
42
 
30
43
  # Yields Flight rows one at a time.
@@ -50,7 +63,8 @@ module Ruact
50
63
  if streaming && deferred[:delay]&.positive?
51
64
  timeout = Ruact.config.suspense_timeout
52
65
  if timeout&.positive? && deferred[:delay] > timeout
53
- yield RowEmitter.error(deferred[:id], JSON.generate("Suspense timeout exceeded"))
66
+ error = RowEmitter.error_payload("Suspense timeout exceeded", digest: "ruact:suspense-timeout")
67
+ yield RowEmitter.error(deferred[:id], JSON.generate(error))
54
68
  next
55
69
  end
56
70
  sleep(deferred[:delay])
@@ -58,7 +72,7 @@ module Ruact
58
72
 
59
73
  # Serialize deferred content — may produce new import rows, and new
60
74
  # regular rows (a string of 1024+ bytes becomes a `T` row the deferred
61
- # model row references as `$T<id>`).
75
+ # model row references as `$<id>`).
62
76
  import_count_before = @request.completed_import_chunks.length
63
77
  regular_count_before = @request.completed_regular_chunks.length
64
78
  deferred_value = serializer.serialize_model(deferred[:element])
@@ -16,11 +16,15 @@ module Ruact
16
16
  # object_id => "$L<hex>" reference (dedup)
17
17
  attr_reader :written_objects
18
18
  attr_reader :next_chunk_id, :pending_chunks, :bundler_config, :root_model,
19
- :strict_serialization, :on_as_json_warning
19
+ :strict_serialization, :on_as_json_warning, :development
20
20
 
21
- def initialize(model, bundler_config, strict_serialization: false, on_as_json_warning: nil)
21
+ def initialize(model, bundler_config, strict_serialization: false, on_as_json_warning: nil,
22
+ development: false)
22
23
  @strict_serialization = strict_serialization
23
24
  @on_as_json_warning = on_as_json_warning
25
+ @development = development
26
+ @suspense_symbol_ref = nil
27
+ @boundary_ref = nil
24
28
  @next_chunk_id = 0
25
29
  @pending_chunks = 0
26
30
  @bundler_config = bundler_config
@@ -42,6 +46,31 @@ module Ruact
42
46
  id
43
47
  end
44
48
 
49
+ # The `"$<hex>"` reference to this response's `"$Sreact.suspense"` symbol
50
+ # row, emitted once, before the first row that uses it.
51
+ def suspense_symbol_ref
52
+ @suspense_symbol_ref ||= begin
53
+ id = allocate_id
54
+ @completed_regular_chunks << RowEmitter.model(id, JSON.generate("$Sreact.suspense"))
55
+ "$#{id.to_s(16)}"
56
+ end
57
+ end
58
+
59
+ # The runtime's own client component that catches an error in a Suspense
60
+ # child (runtime/suspense-boundary.js). Not in the app's manifest: the
61
+ # runtime registers it under this id.
62
+ BOUNDARY_METADATA = ["ruact:boundary", [], "SuspenseBoundary"].freeze
63
+
64
+ # The `"$L<hex>"` reference to {BOUNDARY_METADATA}'s import row, emitted
65
+ # once per response.
66
+ def boundary_ref
67
+ @boundary_ref ||= begin
68
+ id = allocate_id
69
+ @completed_import_chunks << RowEmitter.import(id, JSON.generate(BOUNDARY_METADATA))
70
+ "$L#{id.to_s(16)}"
71
+ end
72
+ end
73
+
45
74
  def increment_pending
46
75
  @pending_chunks += 1
47
76
  end
@@ -22,6 +22,15 @@ module Ruact
22
22
  "#{id.to_s(16)}:E#{error_json}\n"
23
23
  end
24
24
 
25
+ # The error object React's Flight client reads from an E row. Its
26
+ # development build reads `stack` and `env`; a bare string crashes it.
27
+ # Its production build drops the message and keeps `digest`, so the
28
+ # digest is a stable code the runtime turns back into the message
29
+ # (runtime/suspense-boundary.js, DIGEST_MESSAGES).
30
+ def self.error_payload(message, digest:)
31
+ { "digest" => digest, "name" => "Error", "message" => message, "stack" => [], "env" => "Server" }
32
+ end
33
+
25
34
  # A large text row (binary framing, no trailing newline)
26
35
  def self.text(id, text)
27
36
  byte_length = text.bytesize
@@ -64,7 +64,9 @@ module Ruact
64
64
  @request.increment_pending
65
65
  row = RowEmitter.text(id, value)
66
66
  @request.completed_regular_chunks << row
67
- return "$T#{id.to_s(16)}"
67
+ # A plain "$<hex>" reference: React reads "$T" as a temporary
68
+ # reference (Story 18-1).
69
+ return "$#{id.to_s(16)}"
68
70
  end
69
71
 
70
72
  # Escape leading $ so the client doesn't misinterpret it.
@@ -131,7 +133,16 @@ module Ruact
131
133
  key = element.key
132
134
  props = serialize_hash(element.props)
133
135
 
134
- ["$", resolved_type, key, props]
136
+ element_tuple(resolved_type, key, props)
137
+ end
138
+
139
+ # In development, React's client reads three more slots — owner, debug
140
+ # stack, and whether the children were validated. ERB has no `key`, so
141
+ # without the last one every element with sibling children or rendered in
142
+ # a loop logs React's "unique key" warning. Production reads four slots.
143
+ def element_tuple(type, key, props)
144
+ tuple = ["$", type, key, props]
145
+ @request.development ? tuple.push(nil, nil, 1) : tuple
135
146
  end
136
147
 
137
148
  # --- Suspense Boundary ---
@@ -143,11 +154,15 @@ module Ruact
143
154
 
144
155
  fallback_value = element.fallback ? serialize_model(element.fallback) : nil
145
156
 
146
- # Children is an element tuple using the lazy ref as its type
147
- lazy_ref = "$L#{deferred_id.to_s(16)}"
148
- children_el = ["$", lazy_ref, nil, {}]
149
-
150
- ["$", "$SS", nil, { "fallback" => fallback_value, "children" => children_el }]
157
+ # React's shape (Story 18-1): the type is a reference to a
158
+ # `"$Sreact.suspense"` symbol row, and the deferred content is a lazy
159
+ # child the client resolves when its row arrives. The lazy child sits
160
+ # inside the runtime's error boundary: an error row for it (a Suspense
161
+ # timeout) keeps the fallback on screen and reaches the router's
162
+ # `onError`, where an unhandled render error would unmount the page.
163
+ boundary = element_tuple(@request.boundary_ref, nil,
164
+ { "fallback" => fallback_value, "children" => "$L#{deferred_id.to_s(16)}" })
165
+ element_tuple(@request.suspense_symbol_ref, nil, { "fallback" => fallback_value, "children" => boundary })
151
166
  end
152
167
 
153
168
  # --- Unknown type fallback ---