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
@@ -0,0 +1,124 @@
1
+ #!/usr/bin/env node
2
+ // Story 18-1 — vendor React's Flight client into the gem.
3
+ //
4
+ // ruact decodes Flight with `react-server-dom-webpack/client.browser`, the
5
+ // client React publishes (docs/internal/decisions/flight-client-compatibility.md).
6
+ // The npm package declares `webpack` as a required peer, so the gem ships its
7
+ // own copy instead (packaging option C); an app that installs
8
+ // `react-server-dom-webpack` itself gets that one (see `flightClientSource` in
9
+ // ../index.js).
10
+ //
11
+ // The published files are CommonJS. The runtime is served from the gem's
12
+ // directory, which Vite does not pre-bundle, so this script wraps each one as
13
+ // an ES module:
14
+ //
15
+ // - `require("react")` / `require("react-dom")` become imports of the app's
16
+ // React (one React instance);
17
+ // - `__webpack_require__` / `__webpack_chunk_load__` become module-local
18
+ // functions over ruact's component registry (../runtime/flight-modules.js),
19
+ // so nothing named after webpack lands on `globalThis`;
20
+ // - `process.env.NODE_ENV` is fixed per variant.
21
+ //
22
+ // Usage: node scripts/vendor-flight-client.mjs (writes runtime/vendor/…)
23
+ // Re-running with the same VERSION/INTEGRITY writes identical bytes.
24
+
25
+ import { execFileSync } from "node:child_process";
26
+ import { createHash } from "node:crypto";
27
+ import fs from "node:fs";
28
+ import os from "node:os";
29
+ import path from "node:path";
30
+ import { fileURLToPath } from "node:url";
31
+
32
+ export const PACKAGE = "react-server-dom-webpack";
33
+ export const VERSION = "19.3.0";
34
+ const INTEGRITY =
35
+ "sha512-ymt9RkvWtW4OpIhWf1wj4u5CxnBaVbDCkz/bw3/QFmOE7+/wg8C30uiJ/QqkEdWn/UqT5c7/rOUW/eWYMwceCg==";
36
+
37
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
38
+ const OUT_DIR = path.join(HERE, "..", "runtime", "vendor", PACKAGE);
39
+
40
+ // What the client exports (both variants export the same names).
41
+ const EXPORTS = [
42
+ "createFromFetch",
43
+ "createFromReadableStream",
44
+ "createServerReference",
45
+ "createTemporaryReferenceSet",
46
+ "encodeReply",
47
+ "registerServerReference",
48
+ ];
49
+
50
+ const VARIANTS = {
51
+ production: "cjs/react-server-dom-webpack-client.browser.production.js",
52
+ development: "cjs/react-server-dom-webpack-client.browser.development.js",
53
+ };
54
+
55
+ function fetchTarball(workDir) {
56
+ const out = execFileSync("npm", ["pack", `${PACKAGE}@${VERSION}`, "--silent", "--pack-destination", workDir], {
57
+ encoding: "utf8",
58
+ });
59
+ const file = path.join(workDir, out.trim().split("\n").pop());
60
+ const digest = `sha512-${createHash("sha512").update(fs.readFileSync(file)).digest("base64")}`;
61
+ if (digest !== INTEGRITY) {
62
+ throw new Error(`${PACKAGE}@${VERSION}: integrity mismatch\n expected ${INTEGRITY}\n got ${digest}`);
63
+ }
64
+ execFileSync("tar", ["-xzf", file, "-C", workDir]);
65
+ return path.join(workDir, "package");
66
+ }
67
+
68
+ export function wrap(cjsSource, mode) {
69
+ const usesReact = cjsSource.includes('require("react")');
70
+ const header = [
71
+ `// GENERATED by scripts/vendor-flight-client.mjs from ${PACKAGE}@${VERSION}`,
72
+ `// (${VARIANTS[mode]}, MIT — see LICENSE). Do not edit; re-run the script.`,
73
+ usesReact ? 'import React from "react";' : null,
74
+ 'import ReactDOM from "react-dom";',
75
+ 'import { requireModule, loadChunk } from "../../flight-modules.js";',
76
+ "",
77
+ "const module = { exports: {} };",
78
+ "const exports = module.exports;",
79
+ `const process = { env: { NODE_ENV: ${JSON.stringify(mode)} } };`,
80
+ "function require(id) {",
81
+ usesReact ? ' if (id === "react") return React;' : null,
82
+ ' if (id === "react-dom") return ReactDOM;',
83
+ ' throw new Error("[ruact] vendored Flight client required an unexpected module: " + id);',
84
+ "}",
85
+ "// What webpack's runtime would provide, over ruact's component registry.",
86
+ "function __webpack_require__(id) { return requireModule(id); }",
87
+ "function __webpack_chunk_load__(chunkId) { return loadChunk(chunkId); }",
88
+ "function __webpack_get_script_filename__(chunkId) { return chunkId; }",
89
+ "",
90
+ "(function () {",
91
+ ].filter((line) => line !== null);
92
+ const footer = [
93
+ "})();",
94
+ "",
95
+ ...EXPORTS.map((name) => `export const ${name} = module.exports.${name};`),
96
+ "",
97
+ ];
98
+ return `${header.join("\n")}\n${cjsSource.trimEnd()}\n${footer.join("\n")}`;
99
+ }
100
+
101
+ function main() {
102
+ const workDir = fs.mkdtempSync(path.join(os.tmpdir(), "ruact-vendor-"));
103
+ try {
104
+ const pkg = fetchTarball(workDir);
105
+ fs.mkdirSync(OUT_DIR, { recursive: true });
106
+ for (const [mode, rel] of Object.entries(VARIANTS)) {
107
+ const source = fs.readFileSync(path.join(pkg, rel), "utf8");
108
+ for (const name of EXPORTS) {
109
+ if (!source.includes(`exports.${name} =`)) throw new Error(`${rel}: export ${name} not found`);
110
+ }
111
+ fs.writeFileSync(path.join(OUT_DIR, `client.browser.${mode}.js`), wrap(source, mode));
112
+ }
113
+ fs.copyFileSync(path.join(pkg, "LICENSE"), path.join(OUT_DIR, "LICENSE"));
114
+ fs.writeFileSync(
115
+ path.join(OUT_DIR, "VERSION.json"),
116
+ `${JSON.stringify({ package: PACKAGE, version: VERSION, integrity: INTEGRITY }, null, 2)}\n`,
117
+ );
118
+ console.log(`vendored ${PACKAGE}@${VERSION} → ${path.relative(process.cwd(), OUT_DIR)}`);
119
+ } finally {
120
+ fs.rmSync(workDir, { recursive: true, force: true });
121
+ }
122
+ }
123
+
124
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main();
@@ -4,9 +4,23 @@
4
4
  // src/ + dist/ layout. Until then, run tests against the in-tree `.mjs` source
5
5
  // directly.
6
6
 
7
+ import { fileURLToPath } from "node:url";
7
8
  import { defineConfig } from "vitest/config";
8
9
 
10
+ // Story 18-1 — the runtime imports React's Flight client through a virtual id
11
+ // the plugin answers; under vitest it is the vendored copy. FLIGHT_CLIENT_MODE
12
+ // picks the build (the conformance suite runs both).
13
+ const flightClient = fileURLToPath(
14
+ new URL(
15
+ `./runtime/vendor/react-server-dom-webpack/client.browser.${process.env.FLIGHT_CLIENT_MODE || "development"}.js`,
16
+ import.meta.url,
17
+ ),
18
+ );
19
+
9
20
  export default defineConfig({
21
+ resolve: {
22
+ alias: { "virtual:ruact/flight-client": flightClient },
23
+ },
10
24
  test: {
11
25
  environment: "node",
12
26
  // The runtime's `index.test.mjs` is node-environment + dependency-free, so
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.17
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-06 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
@@ -146,7 +148,7 @@ files:
146
148
  - vendor/javascript/ruact-server-functions-runtime/package.json
147
149
  - vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs
148
150
  - vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs
149
- - vendor/javascript/vite-plugin-ruact/flight-client.test.mjs
151
+ - vendor/javascript/vite-plugin-ruact/flight-conformance.test.mjs
150
152
  - vendor/javascript/vite-plugin-ruact/index.js
151
153
  - vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs
152
154
  - vendor/javascript/vite-plugin-ruact/manifest-http.test.mjs
@@ -155,8 +157,17 @@ files:
155
157
  - vendor/javascript/vite-plugin-ruact/registry.test.mjs
156
158
  - vendor/javascript/vite-plugin-ruact/ruact-router.test.mjs
157
159
  - vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx
158
- - vendor/javascript/vite-plugin-ruact/runtime/flight-client.js
160
+ - vendor/javascript/vite-plugin-ruact/runtime/flight-modules.js
161
+ - vendor/javascript/vite-plugin-ruact/runtime/flight-webpack-globals.js
162
+ - vendor/javascript/vite-plugin-ruact/runtime/root-boundary.js
159
163
  - vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js
164
+ - vendor/javascript/vite-plugin-ruact/runtime/suspense-boundary.js
165
+ - vendor/javascript/vite-plugin-ruact/runtime/transport.js
166
+ - vendor/javascript/vite-plugin-ruact/runtime/vendor/react-server-dom-webpack/LICENSE
167
+ - vendor/javascript/vite-plugin-ruact/runtime/vendor/react-server-dom-webpack/VERSION.json
168
+ - vendor/javascript/vite-plugin-ruact/runtime/vendor/react-server-dom-webpack/client.browser.development.js
169
+ - vendor/javascript/vite-plugin-ruact/runtime/vendor/react-server-dom-webpack/client.browser.production.js
170
+ - vendor/javascript/vite-plugin-ruact/scripts/vendor-flight-client.mjs
160
171
  - vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs
161
172
  - vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs
162
173
  - vendor/javascript/vite-plugin-ruact/tsconfig.json
@@ -1,452 +0,0 @@
1
- // Story 17.0a (issue #62) — the Flight client must not collapse arrays.
2
- //
3
- // `flight-client.js` was the only runtime module in this package without a unit
4
- // test, and the defect this file pins is what that gap allowed: `_buildTree`
5
- // applied a single-element unwrap (`items.length === 1 ? items[0] : items`) to
6
- // EVERY array it walked. `_buildTree` is a generic value walker with no
7
- // positional information, so it could not tell a `children` array from a data
8
- // array sitting in a prop — a list prop holding exactly one row arrived at the
9
- // client component as the row itself.
10
- //
11
- // What this file covers:
12
- //
13
- // 1. Data props — 0 / 1 / 2 elements, at top level and nested, arrive with
14
- // their arity intact. The n=1 cases are the regression; n=0 and n=2 are
15
- // guards that the fix does not over-correct in the other direction.
16
- // 2. Children — 0 / 1 / 2, asserting the REBUILT content (type, key, nested
17
- // children), not just arity, so a rebuild returning nulls cannot pass.
18
- // Children keep their arity too: the server collapses a lone TAG-NESTED
19
- // child before the wire (`html_converter.rb:188`, Suspense at `:202`), but
20
- // an explicit `children={[...]}` prop reaches the client as an array and
21
- // now stays one. See the contract note on that describe block.
22
- // 3. The gem-produced fixture — the same assertion driven by wire bytes the
23
- // Ruby serializer wrote, not by a literal typed here.
24
- //
25
- // Isolation: `flight-client.js` keeps `pendingChunks` and `lazyCache` as
26
- // module-level Maps that survive between tests in one process. `lazyCache` is
27
- // memoized by rowId, so two cases reusing an id would leak into each other.
28
- // `clearPendingChunks()` in `beforeEach` is what keeps this file order-
29
- // independent. Vitest is not configured to shuffle, so nothing here depends on
30
- // that guard today — it is insurance against a case that reuses a row id, and
31
- // against shuffling being turned on later.
32
-
33
- import { describe, it, expect, beforeEach } from "vitest";
34
- import fs from "node:fs";
35
- import path from "node:path";
36
- import {
37
- buildTree,
38
- createFromFlightPayload,
39
- createRowParser,
40
- clearPendingChunks,
41
- } from "./runtime/flight-client.js";
42
-
43
- // A stand-in client component. Never rendered — these tests assert the props
44
- // the client BUILDS, which is the contract the component then consumes.
45
- const TaskList = () => null;
46
- const PostList = () => null; // the fixture's component — see the last describe
47
- const MODULE_REGISTRY = {
48
- "/TaskList.jsx": { TaskList },
49
- "/PostList.jsx": { PostList },
50
- };
51
-
52
- const EMPTY_ROWS = new Map();
53
-
54
- /** Wire payload: one import row + a root element carrying `props`. */
55
- function payloadWithProps(props) {
56
- return [
57
- '1:I["/TaskList.jsx","TaskList"]',
58
- `0:["$","$L1",null,${JSON.stringify(props)}]`,
59
- "",
60
- ].join("\n");
61
- }
62
-
63
- beforeEach(() => {
64
- clearPendingChunks();
65
- });
66
-
67
- describe("Story 17.0a — data props keep their arity", () => {
68
- it("a prop holding ONE item arrives as a one-element array", () => {
69
- const tree = createFromFlightPayload(
70
- payloadWithProps({ tasks: [{ id: 6, title: "Buy coffee" }] }),
71
- MODULE_REGISTRY,
72
- );
73
-
74
- expect(Array.isArray(tree.props.tasks)).toBe(true);
75
- expect(tree.props.tasks).toEqual([{ id: 6, title: "Buy coffee" }]);
76
- });
77
-
78
- it("a prop holding TWO items is unchanged", () => {
79
- const tree = createFromFlightPayload(
80
- payloadWithProps({ tasks: [{ id: 6 }, { id: 7 }] }),
81
- MODULE_REGISTRY,
82
- );
83
-
84
- expect(tree.props.tasks).toEqual([{ id: 6 }, { id: 7 }]);
85
- });
86
-
87
- it("a prop holding an EMPTY array stays an empty array", () => {
88
- const tree = createFromFlightPayload(
89
- payloadWithProps({ tasks: [] }),
90
- MODULE_REGISTRY,
91
- );
92
-
93
- expect(Array.isArray(tree.props.tasks)).toBe(true);
94
- expect(tree.props.tasks).toEqual([]);
95
- });
96
-
97
- it("a one-element array NESTED inside an object prop keeps its arity", () => {
98
- const tree = createFromFlightPayload(
99
- payloadWithProps({ page: { rows: [{ id: 6 }], total: 1 } }),
100
- MODULE_REGISTRY,
101
- );
102
-
103
- expect(Array.isArray(tree.props.page.rows)).toBe(true);
104
- expect(tree.props.page.rows).toEqual([{ id: 6 }]);
105
- });
106
-
107
- it("a one-element array of PRIMITIVES keeps its arity", () => {
108
- const tree = createFromFlightPayload(
109
- payloadWithProps({ tags: ["$$ruby"] }),
110
- MODULE_REGISTRY,
111
- );
112
-
113
- // Decoded, not merely carried: "$$ruby" on the wire is "$ruby" in the prop.
114
- expect(tree.props.tags).toEqual(["$ruby"]);
115
- });
116
-
117
- it("the scaffold's own prop shape survives (issue #62 reproduction)", () => {
118
- // `<PostList posts={rows} />` with exactly one record. The generated
119
- // scaffold gates its table on `sortedRows.length > 0`, so an object here
120
- // renders neither the table nor the empty state — a blank list, no error.
121
- // The throw needs a specific sequence, because the sort controls live
122
- // inside the table that did not render: search (which works, since results
123
- // arrive as JSON), sort, then clear the search — that puts the object back
124
- // through `[...rows]`.
125
- const tree = createFromFlightPayload(
126
- payloadWithProps({ posts: [{ id: 1, title: "First post" }] }),
127
- MODULE_REGISTRY,
128
- );
129
-
130
- // The component itself, not just its props — a rebuild that resolved the
131
- // `$L` reference to a plain "div" would keep every prop assertion green
132
- // while never running PostList at all.
133
- expect(tree.type).toBe(TaskList);
134
-
135
- const { posts } = tree.props;
136
- expect(Array.isArray(posts)).toBe(true);
137
- expect(posts).toHaveLength(1);
138
- expect(() => [...posts]).not.toThrow();
139
- });
140
-
141
- it("an array INSIDE an array keeps both dimensions", () => {
142
- // `.flat()` in the walker would collapse this and still satisfy every
143
- // one-dimensional assertion above.
144
- const tree = createFromFlightPayload(
145
- payloadWithProps({ grid: [[1, 2], [3]] }),
146
- MODULE_REGISTRY,
147
- );
148
-
149
- expect(tree.props.grid).toEqual([[1, 2], [3]]);
150
- });
151
-
152
- it("a ONE-element outer array wrapping an array keeps both levels", () => {
153
- // Distinct from the grid case above, whose outer array has two members: a
154
- // collapse conditioned on `length === 1 && Array.isArray(items[0])` would
155
- // pass that one and corrupt this one.
156
- const tree = createFromFlightPayload(
157
- payloadWithProps({ matrix: [[1, 2]] }),
158
- MODULE_REGISTRY,
159
- );
160
-
161
- expect(tree.props.matrix).toEqual([[1, 2]]);
162
- });
163
-
164
- it("an `undefined` MEMBER is kept — Flight distinguishes it from absence", () => {
165
- // `$undefined` is a wire sentinel (project-context §7), so `[undefined]` is
166
- // a one-member array, not an empty one. A `.filter(v => v !== undefined)`
167
- // in the walker would silently make it empty.
168
- const tree = createFromFlightPayload(
169
- payloadWithProps({ slots: ["$undefined"] }),
170
- MODULE_REGISTRY,
171
- );
172
-
173
- expect(tree.props.slots).toHaveLength(1);
174
- expect(tree.props.slots[0]).toBeUndefined();
175
- });
176
-
177
- it("falsy items survive — they are values, not absences", () => {
178
- // `.filter(Boolean)` in the walker would drop these and still satisfy
179
- // every length assertion that uses truthy items.
180
- const tree = createFromFlightPayload(
181
- payloadWithProps({ flags: [false, 0, null, "", 1] }),
182
- MODULE_REGISTRY,
183
- );
184
-
185
- expect(tree.props.flags).toEqual([false, 0, null, "", 1]);
186
- });
187
- });
188
-
189
- describe("Story 17.0a — children keep their arity too (contract, decided 2026-09-09)", () => {
190
- // Decision: arity is preserved for `children` as well as for data props.
191
- //
192
- // The server collapses a lone child produced by TAG NESTING before the wire
193
- // (`html_converter.rb:188`, Suspense at `:202`), so that path is unaffected.
194
- // But `children` passed as an EXPLICIT PROP on a self-closing tag —
195
- // `<Label children={["hi"]} />` — reaches the wire as an array, and Story
196
- // 15.2's loud error does not cover it (`erb_preprocessor.rb:174` exempts
197
- // self-closing tags). Before this story the client collapsed that to `"hi"`.
198
- // It no longer does: you passed an array, you get an array.
199
- //
200
- // Taken deliberately while the library has no external adopters — the only
201
- // moment where making the contract consistent costs nothing.
202
-
203
- it("a single child NESTED IN A TAG arrives collapsed, because the server collapsed it", () => {
204
- const tree = buildTree(
205
- ["$", "div", null, { children: ["$", "em", null, { children: "hi" }] }],
206
- EMPTY_ROWS,
207
- MODULE_REGISTRY,
208
- );
209
-
210
- // Not an array: the server sent the element itself, and it rebuilds as one.
211
- expect(Array.isArray(tree.props.children)).toBe(false);
212
- expect(tree.props.children.type).toBe("em");
213
- expect(tree.props.children.props.children).toBe("hi");
214
- });
215
-
216
- it("a single TEXT child nested in a tag arrives as the string", () => {
217
- const tree = buildTree(
218
- ["$", "div", null, { children: "hi" }],
219
- EMPTY_ROWS,
220
- MODULE_REGISTRY,
221
- );
222
-
223
- expect(tree.props.children).toBe("hi");
224
- });
225
-
226
- it("multiple children arrive as an array, each one rebuilt", () => {
227
- const tree = buildTree(
228
- ["$", "ul", null, {
229
- children: [
230
- ["$", "li", "a", { children: "one" }],
231
- ["$", "li", "b", { children: "two" }],
232
- ],
233
- }],
234
- EMPTY_ROWS,
235
- MODULE_REGISTRY,
236
- );
237
-
238
- const kids = tree.props.children;
239
- expect(Array.isArray(kids)).toBe(true);
240
- expect(kids).toHaveLength(2);
241
- // Content, not just arity — a rebuild that returned nulls would pass a
242
- // length check and fail this one.
243
- expect(kids.map((k) => k.type)).toEqual(["li", "li"]);
244
- expect(kids.map((k) => k.key)).toEqual(["a", "b"]);
245
- expect(kids.map((k) => k.props.children)).toEqual(["one", "two"]);
246
- });
247
-
248
- it("an EXPLICIT one-element children prop stays a one-element array", () => {
249
- // The contract change. `<Label children={["hi"]} />` — verified against the
250
- // Ruby pipeline to reach the wire as `{"children":["hi"]}`.
251
- const tree = buildTree(
252
- ["$", "span", null, { children: ["hi"] }],
253
- EMPTY_ROWS,
254
- MODULE_REGISTRY,
255
- );
256
-
257
- expect(tree.props.children).toEqual(["hi"]);
258
- });
259
-
260
- it("a one-element children array of ELEMENTS stays an array, rebuilt", () => {
261
- const tree = buildTree(
262
- ["$", "ul", null, { children: [["$", "li", "only", { children: "just one" }]] }],
263
- EMPTY_ROWS,
264
- MODULE_REGISTRY,
265
- );
266
-
267
- const kids = tree.props.children;
268
- expect(Array.isArray(kids)).toBe(true);
269
- expect(kids).toHaveLength(1);
270
- expect(kids[0].type).toBe("li");
271
- expect(kids[0].key).toBe("only");
272
- expect(kids[0].props.children).toBe("just one");
273
- });
274
-
275
- it("an EMPTY children array stays an empty array", () => {
276
- // AC3's n=0 for children. Distinct from "no children prop at all" below:
277
- // this one is present and empty, and must not become visible content.
278
- const tree = buildTree(
279
- ["$", "ul", null, { children: [] }],
280
- EMPTY_ROWS,
281
- MODULE_REGISTRY,
282
- );
283
-
284
- expect(Array.isArray(tree.props.children)).toBe(true);
285
- expect(tree.props.children).toHaveLength(0);
286
- });
287
-
288
- it("an element with no children prop has none after rebuilding", () => {
289
- const tree = buildTree(["$", "br", null, {}], EMPTY_ROWS, MODULE_REGISTRY);
290
-
291
- expect("children" in tree.props).toBe(false);
292
- });
293
- });
294
-
295
- describe("Story 17.0a — the contract, driven by a gem-produced fixture", () => {
296
- // The bytes were written by the Ruby serializer, not typed here.
297
- //
298
- // The two sides guarantee DIFFERENT things, and neither reddens for the
299
- // other: `spec/ruact/flight/serializer_spec.rb` ("single-element array prop
300
- // survives the wire (Story 17.0a)") asserts that what the serializer emits
301
- // still equals this file, so a server-side change reddens THERE; this test
302
- // asserts that these bytes rebuild into a one-element array, so a client-side
303
- // change reddens HERE. What the pair buys is that the client is exercised
304
- // against real server output rather than a literal someone typed — the
305
- // Story 5.2 pattern. It does not buy simultaneous failure.
306
- const FIXTURE = path.join(
307
- import.meta.dirname,
308
- "../../../spec/fixtures/flight/single_element_array_prop.txt",
309
- );
310
-
311
- it("the fixture the gem wrote rebuilds as a one-element array", () => {
312
- const payload = fs.readFileSync(FIXTURE, "utf8");
313
- const tree = createFromFlightPayload(payload, MODULE_REGISTRY);
314
-
315
- expect(tree.type).toBe(PostList);
316
- expect(Array.isArray(tree.props.posts)).toBe(true);
317
- // The title is "$$5 plan" ON THE WIRE (§7 prepends one "$"). Asserting the
318
- // DECODED value means a walker that preserved arity but stopped rebuilding
319
- // scalar members would fail here rather than pass.
320
- expect(tree.props.posts).toEqual([{ id: 1, title: "$5 plan" }]);
321
- });
322
- });
323
-
324
- // Story 17-0d — every string of 1024+ bytes travels as a `T` row framed by its
325
- // BYTE length, with no trailing newline. The decoder split on "\n" and so lost
326
- // the root row: one long post body blanked the whole page. The fixtures are
327
- // what the Ruby serializer emits (spec/ruact/flight/text_framing_fixtures_spec.rb
328
- // writes and guards them).
329
- describe("text rows (Story 17-0d)", () => {
330
- const FIXTURES = path.join(import.meta.dirname, "../../../spec/fixtures/flight");
331
- const read = (name) => fs.readFileSync(path.join(FIXTURES, name));
332
- const expected = JSON.parse(read("text_framing_expected.json").toString("utf8"));
333
-
334
- const rowsOf = (chunks) => {
335
- const rows = new Map();
336
- const parser = createRowParser(({ id, row }) => rows.set(id, row));
337
- for (const chunk of chunks) parser.push(chunk);
338
- parser.end();
339
- return rows;
340
- };
341
-
342
- it("decodes every value the serializer framed, byte for byte", () => {
343
- const tree = createFromFlightPayload(read("text_framing.txt").toString("utf8"), MODULE_REGISTRY);
344
- expect(tree).toEqual(expected);
345
- });
346
-
347
- it("puts long text inside Suspense content where the deferred row points", () => {
348
- const tree = createFromFlightPayload(read("text_framing_suspense.txt").toString("utf8"), MODULE_REGISTRY);
349
- const deferred = tree.props.children.type; // the already-arrived deferred row, wrapped
350
- expect(deferred().props.children).toBe(expected.multibyte);
351
- });
352
-
353
- it("does not depend on where the network splits the bytes", () => {
354
- const bytes = new Uint8Array(read("text_framing.txt"));
355
- const whole = rowsOf([bytes]);
356
-
357
- // Every single split point: inside ids, `T` lengths, text bodies and
358
- // multibyte code points, and between rows.
359
- for (let cut = 1; cut < bytes.length; cut += 1) {
360
- const split = rowsOf([bytes.subarray(0, cut), bytes.subarray(cut)]);
361
- expect(split).toEqual(whole);
362
- }
363
-
364
- // And one byte at a time.
365
- expect(rowsOf([...bytes].map((b) => Uint8Array.of(b)))).toEqual(whole);
366
- });
367
-
368
- it("refuses a truncated text row instead of rendering part of the page", () => {
369
- const bytes = new Uint8Array(read("text_framing.txt"));
370
- const firstText = Buffer.from(bytes).indexOf(":T") + 10;
371
- expect(() => rowsOf([bytes.subarray(0, firstText)])).toThrow(/Truncated Flight payload/);
372
- });
373
-
374
- it("refuses a malformed text length", () => {
375
- const enc = new TextEncoder();
376
- expect(() => rowsOf([enc.encode("1:Tzz,abc0:\"$T1\"\n")])).toThrow(/Malformed text row length/);
377
- });
378
-
379
- it("names a text reference whose row never arrived", () => {
380
- expect(() => createFromFlightPayload('0:{"body":"$T7"}\n', MODULE_REGISTRY))
381
- .toThrow(/Text row 7 is referenced but was never received/);
382
- });
383
-
384
- it("skips a stray line without a colon instead of losing the row after it", () => {
385
- const rows = rowsOf([new TextEncoder().encode('garbage\n0:{"a":1}\n')]);
386
- expect(rows.get(0)).toEqual({ kind: "model", value: { a: 1 } });
387
- });
388
-
389
- it("reads a large row arriving in small chunks in linear time", () => {
390
- const big = JSON.stringify({ items: Array.from({ length: 60000 }, (_, i) => ({ id: i, name: `item ${i}` })) });
391
- const bytes = new TextEncoder().encode(`0:${big}\n`);
392
- const chunks = [];
393
- for (let i = 0; i < bytes.length; i += 4096) chunks.push(bytes.subarray(i, i + 4096));
394
- const started = performance.now();
395
- const rows = rowsOf(chunks);
396
- expect(rows.get(0).value.items).toHaveLength(60000);
397
- // ~2.3 MB in 4 KB chunks; quadratic re-copying took seconds.
398
- expect(performance.now() - started).toBeLessThan(1000);
399
- });
400
-
401
- it("still reads a last row that lost its trailing newline", () => {
402
- const rows = rowsOf([new TextEncoder().encode('0:{"a":1}')]);
403
- expect(rows.get(0)).toEqual({ kind: "model", value: { a: 1 } });
404
- });
405
- });
406
-
407
- // Story 17-0c — the serializer emits Ruby Time/DateTime as "$D<ISO>" and
408
- // integers beyond ±(2^53 − 1) as "$n<decimal>". Components got the marker
409
- // strings. The fixture is the Ruby serializer's own output
410
- // (spec/ruact/flight/scalar_fixtures_spec.rb writes and guards it).
411
- describe("dates and big integers (Story 17-0c)", () => {
412
- const FIXTURES = path.join(import.meta.dirname, "../../../spec/fixtures/flight");
413
- const payload = fs.readFileSync(path.join(FIXTURES, "scalar_round_trip.txt"), "utf8");
414
- const expected = JSON.parse(fs.readFileSync(path.join(FIXTURES, "scalar_round_trip_expected.json"), "utf8"));
415
-
416
- // Expected JSON tags what JSON cannot hold: { date: ISO } and { bigint: "…" }.
417
- const revive = (value) => {
418
- if (Array.isArray(value)) return value.map(revive);
419
- if (value && typeof value === "object") {
420
- if ("date" in value) return new Date(value.date);
421
- if ("bigint" in value) return BigInt(value.bigint);
422
- return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, revive(v)]));
423
- }
424
- return value;
425
- };
426
-
427
- it("rebuilds every value with its JS type, nested ones included", () => {
428
- const tree = createFromFlightPayload(payload, MODULE_REGISTRY);
429
- expect(tree).toEqual(revive(expected));
430
- });
431
-
432
- it("gives a Date the same instant, offset applied, to the millisecond", () => {
433
- const tree = createFromFlightPayload(payload, MODULE_REGISTRY);
434
- expect(tree.time).toBeInstanceOf(Date);
435
- expect(tree.time.toISOString()).toBe("2026-09-08T12:30:45.123Z");
436
- expect(tree.zoned.toISOString()).toBe("2026-09-08T12:30:00.000Z");
437
- });
438
-
439
- it("keeps safe integers as numbers and only the unsafe ones as BigInt", () => {
440
- const tree = createFromFlightPayload(payload, MODULE_REGISTRY);
441
- expect(typeof tree.max_safe).toBe("number");
442
- expect(typeof tree.min_safe).toBe("number");
443
- expect(typeof tree.big).toBe("bigint");
444
- expect(tree.big).toBe(9007199254740993n);
445
- expect(tree.negative_big).toBe(-18446744073709551616n);
446
- });
447
-
448
- it("leaves a literal string that starts with $D a string", () => {
449
- const tree = createFromFlightPayload(payload, MODULE_REGISTRY);
450
- expect(tree.literal).toBe("$D2026-01-01 is a string");
451
- });
452
- });