ruact 0.0.16 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -1
- data/lib/ruact/client_manifest.rb +11 -2
- data/lib/ruact/controller.rb +3 -1
- data/lib/ruact/doctor.rb +64 -4
- data/lib/ruact/flight/renderer.rb +23 -9
- data/lib/ruact/flight/request.rb +31 -2
- data/lib/ruact/flight/row_emitter.rb +9 -0
- data/lib/ruact/flight/serializer.rb +22 -7
- data/lib/ruact/render_pipeline.rb +10 -3
- data/lib/ruact/testing/component_query.rb +11 -4
- data/lib/ruact/testing/flight_wire_parser.rb +2 -2
- data/lib/ruact/version.rb +1 -1
- data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +44 -2
- data/vendor/javascript/vite-plugin-ruact/flight-conformance.test.mjs +286 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +89 -14
- data/vendor/javascript/vite-plugin-ruact/package-lock.json +804 -3
- data/vendor/javascript/vite-plugin-ruact/package.json +5 -1
- data/vendor/javascript/vite-plugin-ruact/ruact-router.test.mjs +53 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +46 -17
- data/vendor/javascript/vite-plugin-ruact/runtime/flight-modules.js +40 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/flight-webpack-globals.js +15 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/root-boundary.js +41 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +65 -83
- data/vendor/javascript/vite-plugin-ruact/runtime/suspense-boundary.js +83 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/transport.js +41 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/vendor/react-server-dom-webpack/LICENSE +21 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/vendor/react-server-dom-webpack/VERSION.json +5 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/vendor/react-server-dom-webpack/client.browser.development.js +5473 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/vendor/react-server-dom-webpack/client.browser.production.js +2126 -0
- data/vendor/javascript/vite-plugin-ruact/scripts/vendor-flight-client.mjs +124 -0
- data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +14 -0
- metadata +13 -4
- data/vendor/javascript/vite-plugin-ruact/flight-client.test.mjs +0 -499
- 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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 78c8550850bcded6e5585918c33d4520131a019520f4def020e9e5701a47c7ae
|
|
4
|
+
data.tar.gz: ab49c696c75137128062c6f9c7fa8b2569b8f918818bcf50f209d3939675af59
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a4dda8e8c229d941a6d8e852704837259a45653002b2c7465764ae219c58fbaa4188d4490745efe88327ceb736094ccc265b6e38f2cc38d008388bf7cb5d1e88
|
|
7
|
+
data.tar.gz: 976492c9e23c34d777071ec0fa313ae146d942ba1ade21d939de380e012a0cedc4028c9e997dabe96518ad9b7b00bbd48a4c162eef9270969ba9e2bf5ce4e3a6
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,25 @@ 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
|
+
|
|
10
29
|
## [0.0.16] - 2026-10-03
|
|
11
30
|
|
|
12
31
|
### Fixed
|
|
@@ -436,7 +455,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
436
455
|
|
|
437
456
|
- **E2E test app** — `e2e/` Rails app (no DB, in-memory Post model) with full CRUD system tests validating the complete request cycle.
|
|
438
457
|
|
|
439
|
-
[Unreleased]: https://github.com/luizcg/ruact/compare/v0.0.
|
|
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
|
|
440
460
|
[0.0.16]: https://github.com/luizcg/ruact/releases/tag/v0.0.16
|
|
441
461
|
[0.0.15]: https://github.com/luizcg/ruact/releases/tag/v0.0.15
|
|
442
462
|
[0.0.14]: https://github.com/luizcg/ruact/releases/tag/v0.0.14
|
|
@@ -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
|
|
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"],
|
|
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.
|
data/lib/ruact/controller.rb
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
|
46
|
-
#
|
|
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
|
|
@@ -14,17 +14,30 @@ module Ruact
|
|
|
14
14
|
each(model, bundler_config, streaming: false, **).to_a.join
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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])
|
data/lib/ruact/flight/request.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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 ---
|
|
@@ -15,10 +15,15 @@ module Ruact
|
|
|
15
15
|
class RenderPipeline
|
|
16
16
|
VALID_MODES = %i[string stream].freeze
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
# @param development [Boolean] emit the element slots React's development
|
|
19
|
+
# client reads (see Flight::Serializer#element_tuple). The controller
|
|
20
|
+
# passes {Flight::Renderer.development_default}; everything else gets the
|
|
21
|
+
# production wire.
|
|
22
|
+
def initialize(manifest, controller_path: nil, logger: nil, development: false)
|
|
19
23
|
@manifest = manifest
|
|
20
24
|
@controller_path = controller_path
|
|
21
25
|
@logger = logger
|
|
26
|
+
@development = development
|
|
22
27
|
end
|
|
23
28
|
|
|
24
29
|
# Render a server component tree to Flight wire format.
|
|
@@ -194,7 +199,8 @@ module Ruact
|
|
|
194
199
|
Flight::Renderer.each(root_element, @manifest,
|
|
195
200
|
strict_serialization: Ruact.config.strict_serialization,
|
|
196
201
|
on_as_json_warning: as_json_warning_callback,
|
|
197
|
-
streaming: streaming
|
|
202
|
+
streaming: streaming,
|
|
203
|
+
development: @development) { |row| y << row }
|
|
198
204
|
end
|
|
199
205
|
end
|
|
200
206
|
|
|
@@ -215,7 +221,8 @@ module Ruact
|
|
|
215
221
|
Flight::Renderer.each(root_element, @manifest,
|
|
216
222
|
strict_serialization: strict,
|
|
217
223
|
on_as_json_warning: warning_cb,
|
|
218
|
-
streaming: streaming
|
|
224
|
+
streaming: streaming,
|
|
225
|
+
development: @development) { |row| y << row }
|
|
219
226
|
end
|
|
220
227
|
end
|
|
221
228
|
|
|
@@ -10,7 +10,9 @@ module Ruact
|
|
|
10
10
|
#
|
|
11
11
|
# A component is TWO wire rows (Story 7.5 / FR108 semantics):
|
|
12
12
|
#
|
|
13
|
-
# * an `:import` row — `["<module path>", "<export name>"
|
|
13
|
+
# * an `:import` row — `["<module path>", [chunks…], "<export name>"]`
|
|
14
|
+
# (React's order since Story 18-1; the older `[path, name, chunks]` is
|
|
15
|
+
# still read) —
|
|
14
16
|
# sourced from the client manifest (`reference_for`); its `:id` is the
|
|
15
17
|
# integer the model rows reference; AND
|
|
16
18
|
# * one or more `:model` rows carrying a React element
|
|
@@ -27,8 +29,10 @@ module Ruact
|
|
|
27
29
|
# A client-component element's type is "$L" followed by the import's hex id.
|
|
28
30
|
CLIENT_REF = /\A\$L(?<hex>\h+)\z/
|
|
29
31
|
# A string of 1024+ bytes travels as a `T` row, and the props hold a
|
|
30
|
-
# `"
|
|
31
|
-
|
|
32
|
+
# `"$<hex>"` reference to it (Story 17-0d; `$T<hex>` before 18-1). A
|
|
33
|
+
# string that really starts with `$` travels escaped as `$$`, so this
|
|
34
|
+
# cannot match one.
|
|
35
|
+
TEXT_REF = /\A\$T?(?<hex>\h+)\z/
|
|
32
36
|
|
|
33
37
|
# @param wire [String] the raw Flight wire byte string.
|
|
34
38
|
def initialize(wire)
|
|
@@ -69,7 +73,10 @@ module Ruact
|
|
|
69
73
|
next unless payload.is_a?(Array)
|
|
70
74
|
|
|
71
75
|
module_path = payload[0]
|
|
72
|
-
|
|
76
|
+
# The runtime's own components (`ruact:boundary`) are not the app's.
|
|
77
|
+
next if module_path.is_a?(String) && module_path.start_with?("ruact:")
|
|
78
|
+
|
|
79
|
+
export_name = payload[1].is_a?(Array) ? payload[2] : payload[1]
|
|
73
80
|
acc[row[:id]] = {
|
|
74
81
|
module_path: module_path,
|
|
75
82
|
export_name: export_name,
|
|
@@ -24,10 +24,10 @@ module Ruact
|
|
|
24
24
|
# `Thread.current`.
|
|
25
25
|
#
|
|
26
26
|
# @example
|
|
27
|
-
# wire = "1:I[\"/L.jsx\"
|
|
27
|
+
# wire = "1:I[\"/L.jsx\",[],\"L\"]\n0:[\"$\",\"$L1\",null,{}]\n"
|
|
28
28
|
# Ruact::Testing::FlightWireParser.parse(wire)
|
|
29
29
|
# # => [
|
|
30
|
-
# # { id: 1, class: :import, payload: ["/L.jsx",
|
|
30
|
+
# # { id: 1, class: :import, payload: ["/L.jsx", [], "L"], raw: "1:I...\n" },
|
|
31
31
|
# # { id: 0, class: :model, payload: ["$", "$L1", nil, {}], raw: "0:[\"$\"...\n" }
|
|
32
32
|
# # ]
|
|
33
33
|
class FlightWireParser
|
data/lib/ruact/version.rb
CHANGED
|
@@ -21,6 +21,8 @@ import ruact, {
|
|
|
21
21
|
BOOTSTRAP_VIRTUAL_ID,
|
|
22
22
|
REGISTRY_VIRTUAL_ID,
|
|
23
23
|
generateBootstrapSource,
|
|
24
|
+
resolveFlightClient,
|
|
25
|
+
flightClientSource,
|
|
24
26
|
} from "./index.js";
|
|
25
27
|
|
|
26
28
|
const RESOLVED = "\0" + BOOTSTRAP_VIRTUAL_ID;
|
|
@@ -53,10 +55,13 @@ describe("virtual:ruact/bootstrap — load (Story 14.2)", () => {
|
|
|
53
55
|
});
|
|
54
56
|
|
|
55
57
|
it("imports the runtime modules via ABSOLUTE fs specifiers, not relative", () => {
|
|
56
|
-
expect(src).toMatch(/from '\/.*\/runtime\/flight-
|
|
58
|
+
expect(src).toMatch(/from '\/.*\/runtime\/flight-modules\.js'/);
|
|
59
|
+
expect(src).toMatch(/import '\/.*\/runtime\/suspense-boundary\.js'/);
|
|
57
60
|
expect(src).toMatch(/from '\/.*\/runtime\/ruact-router\.js'/);
|
|
61
|
+
// Story 18-1 — React's Flight client, through the plugin's virtual id.
|
|
62
|
+
expect(src).toContain("from 'virtual:ruact/flight-client'");
|
|
58
63
|
// No `\0virtual:`-unresolvable relative import survives.
|
|
59
|
-
expect(src).not.toMatch(/from\s+['"]\.\/
|
|
64
|
+
expect(src).not.toMatch(/(from|import)\s+['"]\.\/[\w.-]+\.js['"]/);
|
|
60
65
|
expect(src).not.toMatch(/from\s+['"]\.\/ruact-router\.js['"]/);
|
|
61
66
|
});
|
|
62
67
|
|
|
@@ -94,3 +99,40 @@ describe("generateBootstrapSource — rewrites imports only (Story 14.2)", () =>
|
|
|
94
99
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
95
100
|
});
|
|
96
101
|
});
|
|
102
|
+
|
|
103
|
+
// Story 18-1 — option C: which copy of React's Flight client answers.
|
|
104
|
+
describe("resolveFlightClient", () => {
|
|
105
|
+
const make = (pkg, installed) => {
|
|
106
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ruact-rfc-"));
|
|
107
|
+
if (pkg) fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify(pkg));
|
|
108
|
+
if (installed) {
|
|
109
|
+
const mod = path.join(dir, "node_modules", "react-server-dom-webpack");
|
|
110
|
+
fs.mkdirSync(mod, { recursive: true });
|
|
111
|
+
fs.writeFileSync(path.join(mod, "package.json"), JSON.stringify({ name: "react-server-dom-webpack", version: installed }));
|
|
112
|
+
}
|
|
113
|
+
return dir;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
it("uses the copy vendored in the gem when the app declares none", () => {
|
|
117
|
+
expect(resolveFlightClient(make({ dependencies: {} }), "development")).toMatchObject({ source: "vendored" });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("uses the app's copy when the app declares and installs it", () => {
|
|
121
|
+
const dir = make({ dependencies: { "react-server-dom-webpack": "19.2.8" } }, "19.2.8");
|
|
122
|
+
expect(resolveFlightClient(dir, "production")).toEqual({ source: "app", version: "19.2.8", mode: "production" });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("ignores a copy that is installed but not declared (another package's, hoisted)", () => {
|
|
126
|
+
expect(resolveFlightClient(make({ dependencies: {} }, "19.0.0"), "development")).toMatchObject({ source: "vendored" });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("falls back to the vendored copy when declared but not installed yet", () => {
|
|
130
|
+
const dir = make({ devDependencies: { "react-server-dom-webpack": "19.2.8" } });
|
|
131
|
+
expect(resolveFlightClient(dir, "development")).toMatchObject({ source: "vendored" });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("serves the app's copy behind the webpack globals, imported first", () => {
|
|
135
|
+
const src = flightClientSource({ source: "app", mode: "development" });
|
|
136
|
+
expect(src.indexOf("flight-webpack-globals.js")).toBeLessThan(src.indexOf("react-server-dom-webpack/client.browser"));
|
|
137
|
+
});
|
|
138
|
+
});
|