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
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
//
|
|
3
|
+
// Story 18-1 — React's Flight client (the copy vendored in the gem) reads what
|
|
4
|
+
// the Ruby side writes. Every payload here is the serializer's own output,
|
|
5
|
+
// written and guarded against drift by spec/ruact/flight/*_fixtures_spec.rb.
|
|
6
|
+
//
|
|
7
|
+
// The suite runs once per client build: FLIGHT_CLIENT_MODE=development (the
|
|
8
|
+
// default) and =production (`npm test` runs both). CI also runs it against the
|
|
9
|
+
// lowest React the gem supports.
|
|
10
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { act, createElement as h, Suspense } from "react";
|
|
14
|
+
import { createRoot } from "react-dom/client";
|
|
15
|
+
import { createFromReadableStream } from "virtual:ruact/flight-client";
|
|
16
|
+
import { setModuleRegistry } from "./runtime/flight-modules.js";
|
|
17
|
+
import { setBoundaryErrorHandler } from "./runtime/suspense-boundary.js";
|
|
18
|
+
import { RootBoundary } from "./runtime/root-boundary.js";
|
|
19
|
+
import { labelledStream } from "./runtime/transport.js";
|
|
20
|
+
|
|
21
|
+
const MODE = process.env.FLIGHT_CLIENT_MODE || "development";
|
|
22
|
+
const FIXTURES = path.join(import.meta.dirname, "../../../spec/fixtures/flight");
|
|
23
|
+
const read = (name) => new Uint8Array(fs.readFileSync(path.join(FIXTURES, name)));
|
|
24
|
+
const readJSON = (name) => JSON.parse(fs.readFileSync(path.join(FIXTURES, name), "utf8"));
|
|
25
|
+
|
|
26
|
+
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
27
|
+
|
|
28
|
+
function Bad() {
|
|
29
|
+
throw new Error("boom");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function LikeButton({ likes, tags }) {
|
|
33
|
+
return h("button", null, `${likes}:${Array.isArray(tags) ? `[${tags.join(",")}]` : String(tags)}`);
|
|
34
|
+
}
|
|
35
|
+
setModuleRegistry({ "/LikeButton.jsx": { LikeButton }, "/Bad.jsx": { Bad } });
|
|
36
|
+
|
|
37
|
+
function streamOf(chunks) {
|
|
38
|
+
return new ReadableStream({
|
|
39
|
+
start(controller) {
|
|
40
|
+
for (const chunk of chunks) controller.enqueue(chunk);
|
|
41
|
+
controller.close();
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// As the runtime reads a response: through ./runtime/transport.js. React's
|
|
47
|
+
// client returns a thenable, not a Promise; wrap it so `rejects` really checks.
|
|
48
|
+
const decode = (chunks) => Promise.resolve(createFromReadableStream(labelledStream(streamOf(chunks))));
|
|
49
|
+
|
|
50
|
+
async function mount(value) {
|
|
51
|
+
const container = document.createElement("div");
|
|
52
|
+
const root = createRoot(container);
|
|
53
|
+
await act(async () => {
|
|
54
|
+
root.render(h(Suspense, { fallback: "…" }, value));
|
|
55
|
+
});
|
|
56
|
+
return { container, root };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let consoleError;
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
62
|
+
});
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
consoleError.mockRestore();
|
|
65
|
+
setBoundaryErrorHandler(null);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe(`React's Flight client (${MODE}) reads a page the Ruby side rendered`, () => {
|
|
69
|
+
// The development wire carries the slots React's development client reads.
|
|
70
|
+
const fixture = MODE === "development" ? "conformance_tree_dev.txt" : "conformance_tree.txt";
|
|
71
|
+
const html =
|
|
72
|
+
"<main><h1>Posts</h1>" +
|
|
73
|
+
"<article><button>1:[only]</button></article>" +
|
|
74
|
+
"<article><button>2:[only]</button></article>" +
|
|
75
|
+
"<article><button>3:[only]</button></article>" +
|
|
76
|
+
"<p>late</p><p>after</p></main>";
|
|
77
|
+
|
|
78
|
+
// FIRST render in this file on purpose: React reports a missing key once per
|
|
79
|
+
// location, so a later test would see nothing even on the wrong wire.
|
|
80
|
+
it("renders host elements, an ERB loop of client components and a Suspense child — and logs nothing", async () => {
|
|
81
|
+
const { container, root } = await mount(decode([read(fixture)]));
|
|
82
|
+
expect(container.innerHTML).toBe(html);
|
|
83
|
+
// The development build reports a missing key after the commit, on a later
|
|
84
|
+
// task; wait for it before concluding there is none.
|
|
85
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
86
|
+
expect(consoleError).not.toHaveBeenCalled();
|
|
87
|
+
act(() => root.unmount());
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("keeps a one-element array prop a one-element array (Story 17.0a)", async () => {
|
|
91
|
+
const tree = await decode([read(fixture)]);
|
|
92
|
+
const article = tree.props.children[1];
|
|
93
|
+
expect(article.props.children.props.tags).toEqual(["only"]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("does not depend on where the network splits the bytes", async () => {
|
|
97
|
+
const bytes = read(fixture);
|
|
98
|
+
for (let cut = 1; cut < bytes.length; cut += 37) {
|
|
99
|
+
const { container, root } = await mount(decode([bytes.subarray(0, cut), bytes.subarray(cut)]));
|
|
100
|
+
expect(container.innerHTML).toBe(html);
|
|
101
|
+
act(() => root.unmount());
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe(`text rows (${MODE}) — Story 17-0d`, () => {
|
|
107
|
+
const expected = readJSON("text_framing_expected.json");
|
|
108
|
+
|
|
109
|
+
it("decodes every value the serializer framed, byte for byte", async () => {
|
|
110
|
+
expect(await decode([read("text_framing.txt")])).toEqual(expected);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("decodes the same split at every byte, including inside lengths and code points", async () => {
|
|
114
|
+
const bytes = read("text_framing.txt");
|
|
115
|
+
for (let cut = 1; cut < bytes.length; cut += 1) {
|
|
116
|
+
expect(await decode([bytes.subarray(0, cut), bytes.subarray(cut)])).toEqual(expected);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("decodes one byte at a time", async () => {
|
|
121
|
+
const bytes = read("text_framing.txt");
|
|
122
|
+
expect(await decode([...bytes].map((b) => Uint8Array.of(b)))).toEqual(expected);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("puts long text inside Suspense content where the deferred row points", async () => {
|
|
126
|
+
const { container, root } = await mount(decode([read("text_framing_suspense.txt")]));
|
|
127
|
+
expect(container.querySelector("p").textContent).toBe(expected.multibyte);
|
|
128
|
+
act(() => root.unmount());
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("rejects a stream cut inside a text row instead of rendering part of it", async () => {
|
|
132
|
+
const bytes = read("text_framing.txt");
|
|
133
|
+
const cut = Buffer.from(bytes).indexOf(":T") + 10;
|
|
134
|
+
await expect(decode([bytes.subarray(0, cut)])).rejects.toThrow(/the response ended before the whole page arrived/);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe(`dates and big integers (${MODE}) — Story 17-0c`, () => {
|
|
139
|
+
// Expected JSON tags what JSON cannot hold: { date: ISO } and { bigint: "…" }.
|
|
140
|
+
const revive = (value) => {
|
|
141
|
+
if (Array.isArray(value)) return value.map(revive);
|
|
142
|
+
if (value && typeof value === "object") {
|
|
143
|
+
if ("date" in value) return new Date(value.date);
|
|
144
|
+
if ("bigint" in value) return BigInt(value.bigint);
|
|
145
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, revive(v)]));
|
|
146
|
+
}
|
|
147
|
+
return value;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
it("rebuilds every value with its JS type, nested ones included", async () => {
|
|
151
|
+
expect(await decode([read("scalar_round_trip.txt")])).toEqual(revive(readJSON("scalar_round_trip_expected.json")));
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("keeps a literal string that starts with $D a string", async () => {
|
|
155
|
+
expect((await decode([read("scalar_round_trip.txt")])).literal).toBe("$D2026-01-01 is a string");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("reads NaN, ±Infinity, undefined and an escaped $", async () => {
|
|
159
|
+
const value = await decode([new TextEncoder().encode(
|
|
160
|
+
'0:{"nan":"$NaN","inf":"$Infinity","ninf":"$-Infinity","negz":"$-0","und":"$undefined","cash":"$$5"}\n',
|
|
161
|
+
)]);
|
|
162
|
+
expect(Number.isNaN(value.nan)).toBe(true);
|
|
163
|
+
expect(value.inf).toBe(Infinity);
|
|
164
|
+
expect(value.ninf).toBe(-Infinity);
|
|
165
|
+
expect(Object.is(value.negz, -0)).toBe(true);
|
|
166
|
+
expect(value.und).toBeUndefined();
|
|
167
|
+
expect(value.cash).toBe("$5");
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe(`a Suspense child whose row is an error (${MODE})`, () => {
|
|
172
|
+
const timeout = () => read("conformance_suspense_timeout.txt");
|
|
173
|
+
// The same page, its Suspense row now content: the timeout fixture with the
|
|
174
|
+
// error row replaced.
|
|
175
|
+
const recovered = () => new TextEncoder().encode(
|
|
176
|
+
new TextDecoder().decode(timeout()).replace(/^1:E.*$/m, '1:["$","p",null,{"children":"late"}]'),
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
it("keeps the fallback on screen and reports the server's message, in production too", async () => {
|
|
180
|
+
const reported = [];
|
|
181
|
+
setBoundaryErrorHandler((error) => reported.push(error));
|
|
182
|
+
const { container, root } = await mount(decode([timeout()]));
|
|
183
|
+
|
|
184
|
+
expect(container.innerHTML).toBe("<span>loading</span>");
|
|
185
|
+
expect(reported.map((e) => e.message)).toEqual(["[ruact] Server error: Suspense timeout exceeded"]);
|
|
186
|
+
act(() => root.unmount());
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("keeps the fallback when the response ends before the Suspense row, in production too", async () => {
|
|
190
|
+
const reported = [];
|
|
191
|
+
setBoundaryErrorHandler((error) => reported.push(error));
|
|
192
|
+
const cut = new TextDecoder().decode(timeout()).replace(/^1:E.*\n?/m, "");
|
|
193
|
+
const tree = await decode([new TextEncoder().encode(cut)]);
|
|
194
|
+
const container = document.createElement("div");
|
|
195
|
+
const root = createRoot(container);
|
|
196
|
+
await act(async () => {
|
|
197
|
+
root.render(h(RootBoundary, { tree }, h(Suspense, { fallback: "…" }, tree)));
|
|
198
|
+
});
|
|
199
|
+
expect(container.innerHTML).toBe("<span>loading</span>");
|
|
200
|
+
expect(reported.map((e) => e.message)).toEqual([
|
|
201
|
+
"[ruact] Server error: the response ended before the whole page arrived",
|
|
202
|
+
]);
|
|
203
|
+
act(() => root.unmount());
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("keeps the fallback when the network fails mid-response", async () => {
|
|
207
|
+
const reported = [];
|
|
208
|
+
setBoundaryErrorHandler((error) => reported.push(error));
|
|
209
|
+
const head = new TextDecoder().decode(timeout()).replace(/^1:E.*\n?/m, "");
|
|
210
|
+
const failing = new ReadableStream({
|
|
211
|
+
start(controller) {
|
|
212
|
+
controller.enqueue(new TextEncoder().encode(head));
|
|
213
|
+
setTimeout(() => controller.error(new TypeError("network error")), 0);
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
const tree = await createFromReadableStream(labelledStream(failing));
|
|
217
|
+
const container = document.createElement("div");
|
|
218
|
+
const root = createRoot(container);
|
|
219
|
+
await act(async () => {
|
|
220
|
+
root.render(h(RootBoundary, { tree }, h(Suspense, { fallback: "…" }, tree)));
|
|
221
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
222
|
+
});
|
|
223
|
+
expect(container.innerHTML).toBe("<span>loading</span>");
|
|
224
|
+
expect(reported).toHaveLength(1);
|
|
225
|
+
act(() => root.unmount());
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("does not stay failed: the next response for the same page renders", async () => {
|
|
229
|
+
setBoundaryErrorHandler(() => {});
|
|
230
|
+
const { container, root } = await mount(decode([timeout()]));
|
|
231
|
+
expect(container.innerHTML).toBe("<span>loading</span>");
|
|
232
|
+
|
|
233
|
+
// What revalidate() or a navigation to a page of the same shape does.
|
|
234
|
+
const next = decode([recovered()]);
|
|
235
|
+
await act(async () => {
|
|
236
|
+
root.render(h(Suspense, { fallback: "…" }, next));
|
|
237
|
+
});
|
|
238
|
+
expect(container.innerHTML).toBe("<p>late</p>");
|
|
239
|
+
act(() => root.unmount());
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("lets a crash in the app's own component through, to the root's boundary", async () => {
|
|
243
|
+
const reported = [];
|
|
244
|
+
setBoundaryErrorHandler((error) => reported.push(error));
|
|
245
|
+
const wire = new TextDecoder().decode(timeout())
|
|
246
|
+
.replace(/^1:E.*$/m, '1:["$","$L5",null,{}]')
|
|
247
|
+
.replace(/^/, '5:I["/Bad.jsx",[],"Bad"]\n');
|
|
248
|
+
const tree = await decode([new TextEncoder().encode(wire)]);
|
|
249
|
+
const container = document.createElement("div");
|
|
250
|
+
const root = createRoot(container);
|
|
251
|
+
await act(async () => {
|
|
252
|
+
root.render(h(RootBoundary, { tree }, tree));
|
|
253
|
+
});
|
|
254
|
+
expect(container.textContent).toBe("[ruact] Error: boom");
|
|
255
|
+
expect(reported).toEqual([]);
|
|
256
|
+
act(() => root.unmount());
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe(`a component missing from the registry (${MODE})`, () => {
|
|
261
|
+
it("shows an error in place of the page instead of removing it", async () => {
|
|
262
|
+
const tree = await decode([new TextEncoder().encode('1:I["/Gone.jsx",[],"Gone"]\n0:["$","$L1",null,{}]\n')]);
|
|
263
|
+
const container = document.createElement("div");
|
|
264
|
+
const root = createRoot(container);
|
|
265
|
+
await act(async () => {
|
|
266
|
+
root.render(h(RootBoundary, { tree }, tree));
|
|
267
|
+
});
|
|
268
|
+
expect(container.textContent).toBe(
|
|
269
|
+
'[ruact] Error: client component not registered: /Gone.jsx — is it under app/javascript/components/ with "use client" at the top?',
|
|
270
|
+
);
|
|
271
|
+
act(() => root.unmount());
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
describe(`responses the router reads (${MODE})`, () => {
|
|
276
|
+
it("decodes a redirect body to the object the router checks", async () => {
|
|
277
|
+
// redirect_row.txt is what Ruact::Controller#redirect_to writes (controller_spec guards it).
|
|
278
|
+
expect(await decode([read("redirect_row.txt")])).toEqual({ redirectUrl: "/posts/1", redirectType: "push" });
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("rejects a stream that ends before its root row", async () => {
|
|
282
|
+
await expect(decode([new TextEncoder().encode('1:["$","p",null,{}]\n')])).rejects.toThrow(
|
|
283
|
+
/the response ended before the whole page arrived/,
|
|
284
|
+
);
|
|
285
|
+
});
|
|
286
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
4
5
|
import { installServerFunctionsHooks } from "./server-functions-codegen.mjs";
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -56,6 +57,11 @@ const RESOLVED_REGISTRY_ID = "\0" + REGISTRY_VIRTUAL_ID;
|
|
|
56
57
|
// `/@id/__x00__virtual:ruact/bootstrap`; prod Vite-manifest key
|
|
57
58
|
// `virtual:ruact/bootstrap`). Mirrors REGISTRY_VIRTUAL_ID exactly.
|
|
58
59
|
export const BOOTSTRAP_VIRTUAL_ID = "virtual:ruact/bootstrap";
|
|
60
|
+
|
|
61
|
+
// Story 18-1 — React's Flight client, as the runtime imports it. The plugin
|
|
62
|
+
// decides which copy answers (see `flightClientSource`).
|
|
63
|
+
export const FLIGHT_CLIENT_VIRTUAL_ID = "virtual:ruact/flight-client";
|
|
64
|
+
const RESOLVED_FLIGHT_CLIENT_ID = "\0" + FLIGHT_CLIENT_VIRTUAL_ID;
|
|
59
65
|
const RESOLVED_BOOTSTRAP_ID = "\0" + BOOTSTRAP_VIRTUAL_ID;
|
|
60
66
|
|
|
61
67
|
// The gem-shipped runtime sources the virtual bootstrap pulls in. Resolved
|
|
@@ -71,18 +77,30 @@ export default function ruact(options = {}) {
|
|
|
71
77
|
|
|
72
78
|
let root;
|
|
73
79
|
let manifest = {};
|
|
80
|
+
let flightClient; // { source: "app" | "vendored", version, mode }
|
|
74
81
|
|
|
75
82
|
return installServerFunctionsHooks({
|
|
76
83
|
name: "vite-plugin-ruact",
|
|
77
84
|
|
|
85
|
+
// Story 18-1 — the runtime (and React's Flight client vendored with it) is
|
|
86
|
+
// served from the gem's directory, not the app's. Its `react` and
|
|
87
|
+
// `react-dom` imports must still be the app's: one React instance.
|
|
88
|
+
config() {
|
|
89
|
+
return { resolve: { dedupe: ["react", "react-dom"] } };
|
|
90
|
+
},
|
|
91
|
+
|
|
78
92
|
configResolved(config) {
|
|
79
93
|
root = config.root;
|
|
94
|
+
flightClient = resolveFlightClient(root, config.isProduction ? "production" : "development");
|
|
95
|
+
// Dev server and build alike, so a build log shows it too.
|
|
96
|
+
config.logger?.info?.(`[ruact] Flight client: ${describeFlightClient(flightClient)}`);
|
|
80
97
|
},
|
|
81
98
|
|
|
82
99
|
// Story 10.1b / 14.2 — resolve ruact's virtual module ids.
|
|
83
100
|
resolveId(id) {
|
|
84
101
|
if (id === REGISTRY_VIRTUAL_ID) return RESOLVED_REGISTRY_ID;
|
|
85
102
|
if (id === BOOTSTRAP_VIRTUAL_ID) return RESOLVED_BOOTSTRAP_ID;
|
|
103
|
+
if (id === FLIGHT_CLIENT_VIRTUAL_ID) return RESOLVED_FLIGHT_CLIENT_ID;
|
|
86
104
|
return null;
|
|
87
105
|
},
|
|
88
106
|
|
|
@@ -96,6 +114,7 @@ export default function ruact(options = {}) {
|
|
|
96
114
|
// Story 14.2 — serve the gem-shipped bootstrap source, with its relative
|
|
97
115
|
// runtime imports rewritten to absolute fs specifiers (see below).
|
|
98
116
|
if (id === RESOLVED_BOOTSTRAP_ID) return generateBootstrapSource();
|
|
117
|
+
if (id === RESOLVED_FLIGHT_CLIENT_ID) return flightClientSource(flightClient);
|
|
99
118
|
return null;
|
|
100
119
|
},
|
|
101
120
|
|
|
@@ -303,28 +322,84 @@ export function generateRegistrySource(manifest) {
|
|
|
303
322
|
// Story 14.2 (FR104) — render the virtual bootstrap source from the gem-shipped
|
|
304
323
|
// `runtime/bootstrap.jsx`. A `load` hook returns module TEXT whose relative
|
|
305
324
|
// imports resolve against the resolved id (`\0virtual:ruact/bootstrap`), which
|
|
306
|
-
// is NOT a filesystem path — so `./flight-
|
|
307
|
-
// would fail. We rewrite those
|
|
325
|
+
// is NOT a filesystem path — so `./flight-modules.js` / `./ruact-router.js`
|
|
326
|
+
// would fail. We rewrite those specifiers to ABSOLUTE fs specifiers into the
|
|
308
327
|
// gem `runtime/` dir (the same technique `generateRegistrySource` uses via
|
|
309
328
|
// `toImportSpecifier`). The bare `react` / `react-dom/client` specifiers and the
|
|
310
329
|
// `virtual:ruact/registry` id are left untouched — Vite resolves them from the
|
|
311
330
|
// app root (so React comes from the USER's node_modules: one React instance).
|
|
312
331
|
export function generateBootstrapSource(runtimeDir = RUNTIME_DIR) {
|
|
313
332
|
const src = fs.readFileSync(path.join(runtimeDir, "bootstrap.jsx"), "utf8");
|
|
314
|
-
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
333
|
+
// Rewrite EVERY relative `from './<runtime>.js'` and side-effect
|
|
334
|
+
// `import './<runtime>.js'` specifier to its absolute fs path. Matching the
|
|
335
|
+
// quoted specifier after `from`/`import` (not the bare filename) leaves prose
|
|
336
|
+
// mentions of a file in comments alone, and either quote style is accepted.
|
|
337
|
+
// The replacement is a function so a `$` in the absolute path is never read
|
|
338
|
+
// as a replacement pattern.
|
|
339
|
+
return src.replace(
|
|
340
|
+
/\b(from|import)(\s+)(['"])\.\/([\w.-]+\.js)\3/g,
|
|
341
|
+
(_match, keyword, space, _quote, name) =>
|
|
342
|
+
`${keyword}${space}'${toImportSpecifier(path.join(runtimeDir, name))}'`,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Story 18-1 (packaging option C, docs/internal/decisions/flight-client-compatibility.md)
|
|
347
|
+
// — which copy of React's Flight client the runtime uses. The app's own
|
|
348
|
+
// `react-server-dom-webpack` when the app declares it in its package.json (an
|
|
349
|
+
// app that needs a different React version installs it); otherwise the copy
|
|
350
|
+
// vendored in the gem, which brings no `webpack` into the app's install. A copy
|
|
351
|
+
// that only resolves — another package's dependency, hoisted — is not chosen:
|
|
352
|
+
// it was not picked for this app's React.
|
|
353
|
+
export function resolveFlightClient(appRoot, mode, runtimeDir = RUNTIME_DIR) {
|
|
354
|
+
const vendored = () => {
|
|
355
|
+
const { version } = JSON.parse(
|
|
356
|
+
fs.readFileSync(path.join(runtimeDir, "vendor", "react-server-dom-webpack", "VERSION.json"), "utf8"),
|
|
325
357
|
);
|
|
358
|
+
return { source: "vendored", version, mode };
|
|
326
359
|
};
|
|
327
|
-
|
|
360
|
+
let declared = false;
|
|
361
|
+
try {
|
|
362
|
+
const app = JSON.parse(fs.readFileSync(path.join(appRoot, "package.json"), "utf8"));
|
|
363
|
+
declared = Boolean(app.dependencies?.["react-server-dom-webpack"] ?? app.devDependencies?.["react-server-dom-webpack"]);
|
|
364
|
+
} catch {
|
|
365
|
+
return vendored();
|
|
366
|
+
}
|
|
367
|
+
if (!declared) return vendored();
|
|
368
|
+
try {
|
|
369
|
+
const require = createRequire(path.join(appRoot, "package.json"));
|
|
370
|
+
const pkg = require("react-server-dom-webpack/package.json");
|
|
371
|
+
return { source: "app", version: pkg.version, mode };
|
|
372
|
+
} catch {
|
|
373
|
+
// Declared but not installed: npm install has not run. The vendored copy
|
|
374
|
+
// keeps the page working meanwhile.
|
|
375
|
+
return vendored();
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export function describeFlightClient({ source, version, mode }) {
|
|
380
|
+
return source === "app"
|
|
381
|
+
? `react-server-dom-webpack ${version} from the app (${mode})`
|
|
382
|
+
: `react-server-dom-webpack ${version} vendored in ruact (${mode})`;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// The `virtual:ruact/flight-client` module. The vendored copy is an ES module
|
|
386
|
+
// whose webpack hooks are module-local. The app's copy is CommonJS reading
|
|
387
|
+
// webpack's globals, which must exist before it evaluates — hence the
|
|
388
|
+
// side-effect import first.
|
|
389
|
+
export function flightClientSource({ source, mode }, runtimeDir = RUNTIME_DIR) {
|
|
390
|
+
const names = "createFromReadableStream, createFromFetch";
|
|
391
|
+
if (source === "app") {
|
|
392
|
+
const globals = toImportSpecifier(path.join(runtimeDir, "flight-webpack-globals.js"));
|
|
393
|
+
return [
|
|
394
|
+
`import '${globals}';`,
|
|
395
|
+
`export { ${names} } from 'react-server-dom-webpack/client.browser';`,
|
|
396
|
+
"",
|
|
397
|
+
].join("\n");
|
|
398
|
+
}
|
|
399
|
+
const vendored = toImportSpecifier(
|
|
400
|
+
path.join(runtimeDir, "vendor", "react-server-dom-webpack", `client.browser.${mode}.js`),
|
|
401
|
+
);
|
|
402
|
+
return `export { ${names} } from '${vendored}';\n`;
|
|
328
403
|
}
|
|
329
404
|
|
|
330
405
|
// A bundler import specifier for an absolute fs path. Vite/Rollup resolve
|