ruact 0.0.16 → 0.0.18

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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -1
  3. data/README.md +1 -1
  4. data/lib/generators/ruact/install/templates/AGENTS.md.tt +6 -6
  5. data/lib/ruact/client_manifest.rb +29 -2
  6. data/lib/ruact/component_contract.rb +4 -2
  7. data/lib/ruact/controller.rb +3 -1
  8. data/lib/ruact/doctor.rb +64 -4
  9. data/lib/ruact/erb_preprocessor.rb +240 -137
  10. data/lib/ruact/errors.rb +15 -15
  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/html_converter.rb +18 -0
  16. data/lib/ruact/render_pipeline.rb +16 -8
  17. data/lib/ruact/testing/component_query.rb +11 -4
  18. data/lib/ruact/testing/flight_wire_parser.rb +2 -2
  19. data/lib/ruact/version.rb +1 -1
  20. data/lib/ruact/view_helper.rb +13 -0
  21. data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +44 -2
  22. data/vendor/javascript/vite-plugin-ruact/flight-conformance.test.mjs +306 -0
  23. data/vendor/javascript/vite-plugin-ruact/index.js +89 -14
  24. data/vendor/javascript/vite-plugin-ruact/package-lock.json +804 -3
  25. data/vendor/javascript/vite-plugin-ruact/package.json +5 -1
  26. data/vendor/javascript/vite-plugin-ruact/ruact-router.test.mjs +53 -0
  27. data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +46 -17
  28. data/vendor/javascript/vite-plugin-ruact/runtime/flight-modules.js +40 -0
  29. data/vendor/javascript/vite-plugin-ruact/runtime/flight-webpack-globals.js +15 -0
  30. data/vendor/javascript/vite-plugin-ruact/runtime/root-boundary.js +41 -0
  31. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +65 -83
  32. data/vendor/javascript/vite-plugin-ruact/runtime/suspense-boundary.js +83 -0
  33. data/vendor/javascript/vite-plugin-ruact/runtime/transport.js +41 -0
  34. data/vendor/javascript/vite-plugin-ruact/runtime/vendor/react-server-dom-webpack/LICENSE +21 -0
  35. data/vendor/javascript/vite-plugin-ruact/runtime/vendor/react-server-dom-webpack/VERSION.json +5 -0
  36. data/vendor/javascript/vite-plugin-ruact/runtime/vendor/react-server-dom-webpack/client.browser.development.js +5473 -0
  37. data/vendor/javascript/vite-plugin-ruact/runtime/vendor/react-server-dom-webpack/client.browser.production.js +2126 -0
  38. data/vendor/javascript/vite-plugin-ruact/scripts/vendor-flight-client.mjs +124 -0
  39. data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +14 -0
  40. metadata +13 -4
  41. data/vendor/javascript/vite-plugin-ruact/flight-client.test.mjs +0 -499
  42. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +0 -436
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cc962c661c39c1c9905f3aee2ddb2c956dc196218f283ef5532bf8c9826fe98f
4
- data.tar.gz: c3211a4a820d0ad77daebcd5ee071f91b5f10c2be9153f0bb6e62cc8153c61c9
3
+ metadata.gz: afeffed3bf00af43d750b220dbffbdd62758269d578bb649912d7fbee157df86
4
+ data.tar.gz: 2bae60da180b2a5b6c85fe49c3adc42d3431c1cfee7666c9fdbf79b92815049b
5
5
  SHA512:
6
- metadata.gz: 8dcd8f0f4988c3a3e0f9950e6210d68986d980256a2ff9463a2847107513ad8c3090c9949f728aaebc810ccf1937d7c52589f267dd60b5a12ae6a70f9e1ce95a
7
- data.tar.gz: 4c7c1bf1221798c93d183456f07cb4d4549b8ec4bfa56ff091133edff176c77bd9709f303fcbf372ffea994530aaefcb92c069d66abe9e861578578b64f68c20
6
+ metadata.gz: 79f5785e73237b512b1fce27e9b085538c57954921bf92544fc8d4f80c2646f27e5e46baa23b1a565beb567b8e4762a108a0f2885b3ec85f9b822216889afadd
7
+ data.tar.gz: 3421862773a8f0669533bd70e39aed531b9ae2c4ce42205bc6939bc6311d76f5c45e4527d39752793bebc343af29ba96ebffd3fd58a6f414bb16fba879cfcbeb
data/CHANGELOG.md CHANGED
@@ -7,6 +7,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.0.18] - 2026-10-07
11
+
12
+ ### Added
13
+
14
+ - **Client components take children from ERB.** Content between a component's opening and closing tags reaches it as `children`, evaluated on the server like the rest of the template: HTML, `<%= %>` output (escaped as usual), loops, conditionals, partials, and other components, the same one included.
15
+
16
+ ```erb
17
+ <Card title={@post.title}>
18
+ <p><%= @post.body %></p>
19
+ <LikeButton postId={@post.id} />
20
+ </Card>
21
+ ```
22
+
23
+ A component that declares a contract (`__ruactContract`) has to list `children` among its `props` or `slots` to take them. `<Suspense>` works inside a component and around one.
24
+
25
+ - **A misspelled component name is caught where it is written.** When a manifest is available, a template naming a component that does not exist now fails while it compiles, with `Ruact::UnknownComponentError` naming the tag, the template's file and line, and the closest component (`Did you mean "LikeButton"?`). It used to fail at render with no location. Without a manifest the render reports it, as before.
26
+
27
+ ### Changed
28
+
29
+ - **Every component tag must close.** `<Dialog open={true}>` with no closing tag used to render as a component without children. It now raises `Ruact::ComponentTagError` at file:line: write `<Dialog open={true} />`, or close it with `</Dialog>`. The same error names a closing tag that closes nothing, and a tag given both content and a `children={...}` prop.
30
+
31
+ ### Removed
32
+
33
+ - **`Ruact::ChildrenNotSupportedError`.** It was raised for content between a component's tags, which is now supported. Rescue `Ruact::ComponentTagError` for tags that do not pair, or `Ruact::PreprocessorError` for any template error.
34
+
35
+ ## [0.0.17] - 2026-10-06
36
+
37
+ ### Changed
38
+
39
+ - **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.
40
+
41
+ - **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:
42
+ - import rows are `[id, chunks, name]`, with `chunks` always `[]` (the runtime registers every component up front);
43
+ - a string of 1024 bytes or more is referenced as `"$<hex>"`, not `"$T<hex>"`;
44
+ - 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`;
45
+ - 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;
46
+ - 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.
47
+
48
+ `Ruact::Testing`'s matchers read the new wire, and `have_ruact_component` ignores the runtime's own boundary.
49
+
50
+ - **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.
51
+
52
+ - **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.
53
+
10
54
  ## [0.0.16] - 2026-10-03
11
55
 
12
56
  ### Fixed
@@ -436,7 +480,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
436
480
 
437
481
  - **E2E test app** — `e2e/` Rails app (no DB, in-memory Post model) with full CRUD system tests validating the complete request cycle.
438
482
 
439
- [Unreleased]: https://github.com/luizcg/ruact/compare/v0.0.16...HEAD
483
+ [Unreleased]: https://github.com/luizcg/ruact/compare/v0.0.18...HEAD
484
+ [0.0.18]: https://github.com/luizcg/ruact/releases/tag/v0.0.18
485
+ [0.0.17]: https://github.com/luizcg/ruact/releases/tag/v0.0.17
440
486
  [0.0.16]: https://github.com/luizcg/ruact/releases/tag/v0.0.16
441
487
  [0.0.15]: https://github.com/luizcg/ruact/releases/tag/v0.0.15
442
488
  [0.0.14]: https://github.com/luizcg/ruact/releases/tag/v0.0.14
data/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
  scripted and live with the project's other capture harnesses, and the message the demo
12
12
  shows is pinned here by `spec/readme_demo_message_spec.rb`. -->
13
13
  <img src="https://ruact.dev/readme-write-verify.gif" width="800"
14
- alt="An ERB template holding a &lt;LikeButton likes=&#123;@likes&#125; /&gt; tag, and the &quot;use client&quot; React component that tag resolves to. The component renders in a browser and its count changes when it is clicked. Children are then put inside the tag — the JSX habit — and the next request stops server-side with Ruact::ChildrenNotSupportedError, which names the component, the template file and line, and the fix. The children come out again and the page renders." />
14
+ alt="An ERB template holding a &lt;LikeButton likes=&#123;@likes&#125; /&gt; tag, and the &quot;use client&quot; React component that tag resolves to. The component renders in a browser and its count changes when it is clicked. The tag&#39;s name is then misspelled, and the next request stops server-side with Ruact::UnknownComponentError, which names the tag, the template file and line, and the component it was probably meant to be. The name is corrected and the page renders." />
15
15
 
16
16
  ## Quick start
17
17
 
@@ -77,14 +77,14 @@ READ IT instead of simulating the name generation. Regenerate after routes/queri
77
77
 
78
78
  ## Five traps
79
79
 
80
- 1. **Component tags take no children.** Client component tags are self-closing
81
- ONLY: `<Card content={@body} />`, never `<Card>...</Card>`. Children (a
82
- matching closing tag) fail LOUDLY with a `PreprocessorError` at preprocess
83
- time — pass content as a prop instead. Sole exception: the built-in
84
- `<Suspense fallback="...">...</Suspense>` pair.
80
+ 1. **Every component tag closes.** `<Badge label={@x} />` has no children;
81
+ `<Card title={@t}>...</Card>` passes the ERB between its tags — HTML, loops,
82
+ partials, other components — to the component as `children`. An opening tag
83
+ that never closes, or a closing tag with no opening, fails LOUDLY with a
84
+ `PreprocessorError` naming file:line. `<Suspense fallback="...">` pairs too.
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. A quoted value (`title="Hi"`) is a
87
+ no `{...spread}`. A quoted value (`title="Hi"`) is a
88
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}` —
@@ -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.
@@ -80,6 +89,24 @@ module Ruact
80
89
  end
81
90
  end
82
91
 
92
+ # Story 18-2 — whether +name+ resolves for SOME controller: the shared key,
93
+ # or a co-located one in any folder (`posts/_like_button`). The ERB
94
+ # preprocessor asks while it compiles a template, before it knows which
95
+ # controller will render it, so a co-located component is never refused
96
+ # for being looked up from a shared partial.
97
+ def component?(name)
98
+ return true if include?(name)
99
+
100
+ suffix = "/_#{pascal_to_snake_case(name)}"
101
+ entries_by_name.keys.any? { |key| key.end_with?(suffix) }
102
+ end
103
+
104
+ # Story 18-2 — the "not found" message {#reference_for} raises, for the
105
+ # preprocessor to put the template's file:line in front of.
106
+ def unknown_component_message(name, controller_path: nil)
107
+ build_unknown_component_message(name, controller_path)
108
+ end
109
+
83
110
  # Story 13.5 (FR100) — return the optional component +contract+ Hash for
84
111
  # +name+, or +nil+ when the component declared none (or is absent from the
85
112
  # manifest). Honors the same co-located/shared +resolve_key+ precedence as
@@ -103,8 +103,10 @@ module Ruact
103
103
  # @param detail [String] the violation clause
104
104
  # @param where [Hash] keys +component+, +file+, +line+, +snippet+
105
105
  def self.raise_error(detail, where)
106
- location = [where[:file], where[:line]].compact.join(":")
107
- location = "(unknown location)" if location.empty?
106
+ location = if where[:file] then [where[:file], where[:line]].compact.join(":")
107
+ elsif where[:line] then "line #{where[:line]}"
108
+ else "(unknown location)"
109
+ end
108
110
  message = "ruact: <#{where[:component]}> at #{location} #{detail}."
109
111
  message += "\n in: #{where[:snippet]}" if where[:snippet] && !where[:snippet].empty?
110
112
  raise ComponentContractError, message
@@ -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