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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +42 -1
- data/lib/generators/ruact/install/install_generator.rb +2 -1
- data/lib/generators/ruact/install/templates/AGENTS.md.tt +2 -2
- data/lib/ruact/client_manifest.rb +11 -2
- data/lib/ruact/component_attributes.rb +91 -0
- data/lib/ruact/configuration.rb +28 -0
- data/lib/ruact/controller.rb +3 -1
- data/lib/ruact/doctor.rb +64 -4
- data/lib/ruact/erb_preprocessor.rb +1 -47
- 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/server.rb +11 -7
- data/lib/ruact/server_functions/action_assigns.rb +52 -0
- data/lib/ruact/server_functions/bucket_two_payload.rb +1 -1
- 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/lib/ruact.rb +1 -0
- 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 +75 -88
- 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 +15 -4
- data/vendor/javascript/vite-plugin-ruact/flight-client.test.mjs +0 -452
- data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +0 -408
|
@@ -5,11 +5,15 @@
|
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"scripts": {
|
|
8
|
-
"test": "vitest run",
|
|
8
|
+
"test": "vitest run && FLIGHT_CLIENT_MODE=production vitest run flight-conformance",
|
|
9
|
+
"test:react-floor": "npm install --no-save --no-audit --no-fund react@19.2.8 react-dom@19.2.8 && vitest run flight-conformance && FLIGHT_CLIENT_MODE=production vitest run flight-conformance",
|
|
10
|
+
"vendor:flight-client": "node scripts/vendor-flight-client.mjs",
|
|
9
11
|
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.scaffold.json && tsc --noEmit -p tsconfig.scaffold-agnostic.json"
|
|
10
12
|
},
|
|
11
13
|
"devDependencies": {
|
|
14
|
+
"jsdom": "^25.0.1",
|
|
12
15
|
"react": "^19.2.8",
|
|
16
|
+
"react-dom": "^19.2.8",
|
|
13
17
|
"typescript": "^5.5.0",
|
|
14
18
|
"vitest": "^2.0.0"
|
|
15
19
|
},
|
|
@@ -481,6 +481,59 @@ describe("ruact-router — long text in a streamed navigation (Story 17-0d)", ()
|
|
|
481
481
|
});
|
|
482
482
|
});
|
|
483
483
|
|
|
484
|
+
// Story 18-1 review round 3 — a connection that drops after the page is on
|
|
485
|
+
// screen is reported once (by the Suspense boundary that is missing its row),
|
|
486
|
+
// not again by the router; revalidate() still rejects.
|
|
487
|
+
describe("ruact-router — a response cut after the page is on screen (Story 18-1)", () => {
|
|
488
|
+
const FIXTURES = path.join(import.meta.dirname, "../../../spec/fixtures/flight");
|
|
489
|
+
let dom;
|
|
490
|
+
let onNavigate;
|
|
491
|
+
let onError;
|
|
492
|
+
|
|
493
|
+
beforeEach(() => {
|
|
494
|
+
dom = installDom();
|
|
495
|
+
onNavigate = vi.fn();
|
|
496
|
+
onError = vi.fn();
|
|
497
|
+
globalThis.fetch = vi.fn();
|
|
498
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
499
|
+
setupRouter({ onNavigate, moduleRegistry: {}, onError });
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
afterEach(() => {
|
|
503
|
+
teardownRouter();
|
|
504
|
+
vi.restoreAllMocks();
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
const cutResponse = () => {
|
|
508
|
+
const head = fs.readFileSync(path.join(FIXTURES, "conformance_suspense_timeout.txt"), "utf8")
|
|
509
|
+
.replace(/^1:E.*\n?/m, "");
|
|
510
|
+
const body = new ReadableStream({
|
|
511
|
+
start(controller) {
|
|
512
|
+
controller.enqueue(new TextEncoder().encode(head));
|
|
513
|
+
setTimeout(() => controller.error(new TypeError("Failed to fetch")), 5);
|
|
514
|
+
},
|
|
515
|
+
});
|
|
516
|
+
const headers = new Headers({ "content-type": "text/x-component" });
|
|
517
|
+
return { ok: true, status: 200, statusText: "200", url: `${ORIGIN}/posts`, headers, body };
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
it("renders the page and leaves the report to the Suspense boundary", async () => {
|
|
521
|
+
fetch.mockResolvedValue(cutResponse());
|
|
522
|
+
click(dom.listeners, "/posts");
|
|
523
|
+
await settle();
|
|
524
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
525
|
+
|
|
526
|
+
expect(onNavigate).toHaveBeenCalledTimes(1);
|
|
527
|
+
expect(onError).not.toHaveBeenCalled();
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it("still rejects revalidate()", async () => {
|
|
531
|
+
fetch.mockResolvedValue(cutResponse());
|
|
532
|
+
await expect(globalThis.__ruact_revalidate("/posts")).rejects.toThrow(/Failed to fetch/);
|
|
533
|
+
expect(onError).not.toHaveBeenCalled();
|
|
534
|
+
});
|
|
535
|
+
});
|
|
536
|
+
|
|
484
537
|
// Story 17-0c — a navigation decodes dates and big integers like the first load.
|
|
485
538
|
describe("ruact-router — dates and big integers in a navigation (Story 17-0c)", () => {
|
|
486
539
|
const FIXTURES = path.join(import.meta.dirname, "../../../spec/fixtures/flight");
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
//
|
|
6
6
|
// IMPORTANT — this file is loaded as VIRTUAL-MODULE TEXT, not from disk, so its
|
|
7
7
|
// resolved id is `\0virtual:ruact/bootstrap`, which is NOT a filesystem path:
|
|
8
|
-
// • Relative imports (`./
|
|
9
|
-
// id. The plugin's `load` hook rewrites the
|
|
8
|
+
// • Relative imports (`./ruact-router.js`) do NOT resolve from a `\0virtual:`
|
|
9
|
+
// id. The plugin's `load` hook rewrites the runtime imports below to
|
|
10
10
|
// ABSOLUTE fs specifiers into the gem `runtime/` dir before serving them
|
|
11
11
|
// (see `generateBootstrapSource` in index.js).
|
|
12
12
|
// • Bare specifiers (`react`, `react-dom/client`) and the `virtual:` id DO
|
|
@@ -18,7 +18,14 @@
|
|
|
18
18
|
// transforming a `\0virtual:` id.
|
|
19
19
|
import { createRoot } from 'react-dom/client';
|
|
20
20
|
import { createElement, useState, useEffect } from 'react';
|
|
21
|
-
|
|
21
|
+
// Story 18-1 — React's own Flight client: the copy vendored in the gem, or the
|
|
22
|
+
// app's `react-server-dom-webpack` when installed (the plugin decides).
|
|
23
|
+
import { createFromReadableStream } from 'virtual:ruact/flight-client';
|
|
24
|
+
import { setModuleRegistry } from './flight-modules.js';
|
|
25
|
+
// Registers the boundary the server wraps each Suspense child in.
|
|
26
|
+
import './suspense-boundary.js';
|
|
27
|
+
import { RootBoundary, onCaughtError } from './root-boundary.js';
|
|
28
|
+
import { labelledStream } from './transport.js';
|
|
22
29
|
import { setupRouter, teardownRouter } from './ruact-router.js';
|
|
23
30
|
|
|
24
31
|
// MODULE_REGISTRY maps react-client-manifest "id" values to component exports.
|
|
@@ -29,11 +36,19 @@ import { setupRouter, teardownRouter } from './ruact-router.js';
|
|
|
29
36
|
// app/javascript/components/ or drop its "use client" directive.)
|
|
30
37
|
import MODULE_REGISTRY from 'virtual:ruact/registry';
|
|
31
38
|
|
|
39
|
+
setModuleRegistry(MODULE_REGISTRY);
|
|
40
|
+
|
|
32
41
|
// ---------------------------------------------------------------------------
|
|
33
42
|
// Boot
|
|
34
43
|
// ---------------------------------------------------------------------------
|
|
35
44
|
const flightData = globalThis.__FLIGHT_DATA;
|
|
36
45
|
|
|
46
|
+
function showBootError(err) {
|
|
47
|
+
console.error('[ruact] Failed to parse Flight payload:', err);
|
|
48
|
+
const root = document.getElementById('root');
|
|
49
|
+
if (root) root.textContent = '[ruact] Error: ' + err.message;
|
|
50
|
+
}
|
|
51
|
+
|
|
37
52
|
if (!flightData || flightData.length === 0) {
|
|
38
53
|
// Non-RSC page or Rails server not running — skip hydration.
|
|
39
54
|
const root = document.getElementById('root');
|
|
@@ -41,28 +56,42 @@ if (!flightData || flightData.length === 0) {
|
|
|
41
56
|
root.textContent = '[ruact] No Flight data found — is the Rails server running?';
|
|
42
57
|
}
|
|
43
58
|
} else {
|
|
44
|
-
|
|
59
|
+
// The inline payload, as the stream the client reads. The root is mounted
|
|
60
|
+
// once the tree resolves, so the first paint is the page, not a fallback.
|
|
61
|
+
const bytes = new TextEncoder().encode(flightData.join(''));
|
|
62
|
+
const stream = new ReadableStream({
|
|
63
|
+
start(controller) {
|
|
64
|
+
controller.enqueue(bytes);
|
|
65
|
+
controller.close();
|
|
66
|
+
},
|
|
67
|
+
});
|
|
45
68
|
|
|
46
69
|
let initialTree;
|
|
47
70
|
try {
|
|
48
|
-
initialTree =
|
|
71
|
+
initialTree = createFromReadableStream(labelledStream(stream));
|
|
49
72
|
} catch (err) {
|
|
50
|
-
|
|
51
|
-
const root = document.getElementById('root');
|
|
52
|
-
if (root) root.textContent = '[ruact] Error: ' + err.message;
|
|
73
|
+
showBootError(err);
|
|
53
74
|
throw err;
|
|
54
75
|
}
|
|
55
76
|
|
|
56
|
-
|
|
57
|
-
|
|
77
|
+
initialTree.then(
|
|
78
|
+
(tree) => {
|
|
79
|
+
function App() {
|
|
80
|
+
const [current, setCurrent] = useState(() => tree);
|
|
58
81
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
setupRouter({ onNavigate: setCurrent });
|
|
84
|
+
return () => teardownRouter();
|
|
85
|
+
}, []);
|
|
63
86
|
|
|
64
|
-
|
|
65
|
-
|
|
87
|
+
// A render error shows in place of the page instead of removing it.
|
|
88
|
+
return createElement(RootBoundary, { tree: current }, current);
|
|
89
|
+
}
|
|
66
90
|
|
|
67
|
-
|
|
91
|
+
createRoot(document.getElementById('root'), { onCaughtError }).render(createElement(App));
|
|
92
|
+
},
|
|
93
|
+
(err) => {
|
|
94
|
+
showBootError(err);
|
|
95
|
+
},
|
|
96
|
+
);
|
|
68
97
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Story 18-1 — how React's Flight client finds ruact's client components.
|
|
2
|
+
//
|
|
3
|
+
// The client asks for a module by the id in an import row (`I` row) and, under
|
|
4
|
+
// webpack, would load that module's chunks first. ruact registers every client
|
|
5
|
+
// component eagerly (`virtual:ruact/registry`, generated from the same scan as
|
|
6
|
+
// the manifest), so there is nothing to load: the server sends `chunks: []`
|
|
7
|
+
// and the lookup is a map read.
|
|
8
|
+
//
|
|
9
|
+
// The vendored client (./vendor/react-server-dom-webpack/) calls these
|
|
10
|
+
// directly. An app that installs `react-server-dom-webpack` itself gets that
|
|
11
|
+
// client instead, which reads webpack's globals — ./flight-webpack-globals.js
|
|
12
|
+
// defines them over the same functions.
|
|
13
|
+
|
|
14
|
+
let registry = Object.create(null);
|
|
15
|
+
|
|
16
|
+
// The runtime's own client components, always present: the boundary the server
|
|
17
|
+
// wraps each Suspense child in (Flight::Serializer#serialize_suspense).
|
|
18
|
+
const BUILTINS = Object.create(null);
|
|
19
|
+
|
|
20
|
+
export function registerBuiltin(id, moduleExports) {
|
|
21
|
+
BUILTINS[id] = moduleExports;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function setModuleRegistry(moduleRegistry) {
|
|
25
|
+
registry = moduleRegistry ?? Object.create(null);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function requireModule(id) {
|
|
29
|
+
const mod = BUILTINS[id] ?? registry[id];
|
|
30
|
+
if (!mod) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`client component not registered: ${id} — is it under app/javascript/components/ with "use client" at the top?`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
return mod;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function loadChunk() {
|
|
39
|
+
return Promise.resolve();
|
|
40
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Story 18-1 — only when the app installs `react-server-dom-webpack` itself.
|
|
2
|
+
//
|
|
3
|
+
// That copy of React's Flight client reads `__webpack_require__` and
|
|
4
|
+
// `__webpack_chunk_load__` as globals, and assigns `__webpack_require__.u`
|
|
5
|
+
// while it evaluates — so this module must run before it (the
|
|
6
|
+
// `virtual:ruact/flight-client` module imports it first). The vendored client
|
|
7
|
+
// needs none of this: its copies are module-local.
|
|
8
|
+
import { requireModule, loadChunk } from "./flight-modules.js";
|
|
9
|
+
|
|
10
|
+
if (typeof globalThis.__webpack_require__ !== "function") {
|
|
11
|
+
globalThis.__webpack_require__ = (id) => requireModule(id);
|
|
12
|
+
}
|
|
13
|
+
if (typeof globalThis.__webpack_chunk_load__ !== "function") {
|
|
14
|
+
globalThis.__webpack_chunk_load__ = (chunkId) => loadChunk(chunkId);
|
|
15
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Story 18-1 — what the page shows when rendering the tree throws.
|
|
2
|
+
//
|
|
3
|
+
// React's Flight client looks a client component up when React renders it, so
|
|
4
|
+
// a component missing from the registry (no "use client", a stale build) throws
|
|
5
|
+
// during render. With nothing to catch it, React 19 removes the whole page. The
|
|
6
|
+
// old decoder failed while parsing, and the bootstrap printed the error in
|
|
7
|
+
// #root; this keeps that: the error, visibly, in place of the page. The next
|
|
8
|
+
// navigation brings a new tree and clears it.
|
|
9
|
+
import { Component, createElement } from "react";
|
|
10
|
+
import { isAbort, SuspenseBoundary } from "./suspense-boundary.js";
|
|
11
|
+
|
|
12
|
+
export class RootBoundary extends Component {
|
|
13
|
+
constructor(props) {
|
|
14
|
+
super(props);
|
|
15
|
+
this.state = { error: null, tree: props.tree };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
static getDerivedStateFromProps(props, state) {
|
|
19
|
+
if (props.tree === state.tree) return null;
|
|
20
|
+
return { error: null, tree: props.tree };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
static getDerivedStateFromError(error) {
|
|
24
|
+
return { error };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
render() {
|
|
28
|
+
const { error } = this.state;
|
|
29
|
+
if (!error) return this.props.children;
|
|
30
|
+
return createElement("div", { role: "alert", "data-ruact-error": "" }, `[ruact] Error: ${error.message}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// createRoot's onCaughtError, for every boundary on the page. The Suspense
|
|
35
|
+
// boundary reports what it catches itself (to the router's onError), and a
|
|
36
|
+
// superseded navigation's AbortError is not an error; everything else is
|
|
37
|
+
// logged as React would, with its component stack.
|
|
38
|
+
export function onCaughtError(error, errorInfo) {
|
|
39
|
+
if (isAbort(error) || errorInfo?.errorBoundary instanceof SuspenseBoundary) return;
|
|
40
|
+
console.error(error, errorInfo?.componentStack ?? "");
|
|
41
|
+
}
|
|
@@ -2,39 +2,47 @@
|
|
|
2
2
|
* RSC client-side router.
|
|
3
3
|
*
|
|
4
4
|
* Intercepts same-origin <a> clicks and <form> submits, fetches the Flight
|
|
5
|
-
* payload for the new URL
|
|
5
|
+
* payload for the new URL, decodes it with React's Flight client (Story 18-1),
|
|
6
|
+
* and:
|
|
6
7
|
* 1. Calls onNavigate(tree) as soon as row 0 arrives (shows Suspense fallback)
|
|
7
|
-
* 2.
|
|
8
|
+
* 2. Lets the client resolve deferred Suspense rows as they stream in
|
|
8
9
|
*
|
|
9
10
|
* Also handles popstate for browser back/forward.
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
13
|
+
import { createFromReadableStream } from "virtual:ruact/flight-client";
|
|
14
|
+
import { setBoundaryErrorHandler } from "./suspense-boundary.js";
|
|
15
|
+
import { labelledStream } from "./transport.js";
|
|
16
|
+
|
|
17
|
+
// Errors already reported once (by a Suspense boundary) that navigate() must
|
|
18
|
+
// not report again on their way out to a revalidate() caller.
|
|
19
|
+
const _alreadyReported = new WeakSet();
|
|
19
20
|
|
|
20
21
|
let _onNavigate = null;
|
|
21
|
-
let _moduleRegistry = null;
|
|
22
22
|
let _onError = null;
|
|
23
23
|
let _currentAbort = null;
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* @param {object} opts
|
|
27
27
|
* @param {function} opts.onNavigate - called with the new React element tree
|
|
28
|
-
* @param {
|
|
29
|
-
*
|
|
28
|
+
* @param {function} [opts.onError] - called with an Error when navigation fails,
|
|
29
|
+
* or when a Suspense child's row is an error
|
|
30
30
|
*/
|
|
31
|
-
export function setupRouter({ onNavigate,
|
|
31
|
+
export function setupRouter({ onNavigate, onError = null }) {
|
|
32
32
|
_onNavigate = onNavigate;
|
|
33
|
-
_moduleRegistry = moduleRegistry;
|
|
34
33
|
_onError = onError;
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
setBoundaryErrorHandler((error) => {
|
|
35
|
+
console.error(error.message);
|
|
36
|
+
_onError?.(error);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Bubble phase, on the document: the app's own handlers (React's, which
|
|
40
|
+
// listen on the root container, and any other) run first, so an
|
|
41
|
+
// `onSubmit`/`onClick` that calls preventDefault() keeps the event. In the
|
|
42
|
+
// capture phase the router decided before them: a React form that handles
|
|
43
|
+
// its own submit (the scaffold's) also got a router fetch of its URL.
|
|
44
|
+
document.addEventListener("click", handleClick);
|
|
45
|
+
document.addEventListener("submit", handleSubmit);
|
|
38
46
|
window.addEventListener("popstate", handlePopstate);
|
|
39
47
|
|
|
40
48
|
// Story 8.2 — publish the revalidate handle the runtime helper reads.
|
|
@@ -56,13 +64,13 @@ export function setupRouter({ onNavigate, moduleRegistry, onError = null }) {
|
|
|
56
64
|
}
|
|
57
65
|
|
|
58
66
|
export function teardownRouter() {
|
|
59
|
-
document.removeEventListener("click", handleClick
|
|
60
|
-
document.removeEventListener("submit", handleSubmit
|
|
67
|
+
document.removeEventListener("click", handleClick);
|
|
68
|
+
document.removeEventListener("submit", handleSubmit);
|
|
61
69
|
window.removeEventListener("popstate", handlePopstate);
|
|
62
70
|
_onNavigate = null;
|
|
63
|
-
_moduleRegistry = null;
|
|
64
71
|
_onError = null;
|
|
65
72
|
_currentAbort = null;
|
|
73
|
+
setBoundaryErrorHandler(null);
|
|
66
74
|
// Story 8.2 — tear down the revalidate handle so a subsequent
|
|
67
75
|
// setupRouter()-less code path (e.g., SSR) fails LOUDLY at the first
|
|
68
76
|
// revalidate() call instead of using a stale handle.
|
|
@@ -371,8 +379,6 @@ function _hasField(form, name) {
|
|
|
371
379
|
}
|
|
372
380
|
|
|
373
381
|
async function _submitForm(form, submitter = null) {
|
|
374
|
-
clearPendingChunks();
|
|
375
|
-
|
|
376
382
|
// R11: resolve the effective action and method from the submitter's
|
|
377
383
|
// overrides before falling back to the form's attributes. The
|
|
378
384
|
// previous implementation always read `form.action` and the form's
|
|
@@ -423,7 +429,9 @@ async function _submitForm(form, submitter = null) {
|
|
|
423
429
|
else _nativeSubmit(form, submitter);
|
|
424
430
|
return;
|
|
425
431
|
}
|
|
426
|
-
await _processFlightResponse(response, {
|
|
432
|
+
await _processFlightResponse(response, {
|
|
433
|
+
push: true, targetUrl: action, method: htmlMethod, signal: controller.signal,
|
|
434
|
+
});
|
|
427
435
|
} catch (err) {
|
|
428
436
|
if (err.name === "AbortError") return;
|
|
429
437
|
console.error("[ruact-router] Form submission error:", err);
|
|
@@ -455,9 +463,6 @@ function handlePopstate() {
|
|
|
455
463
|
* branch on success vs. failure of a programmatic refetch.
|
|
456
464
|
*/
|
|
457
465
|
async function navigate(url, { push = true, scroll = true, throwOnError = false, replace = false } = {}) {
|
|
458
|
-
// Clear stale lazy refs from any previous streaming navigation
|
|
459
|
-
clearPendingChunks();
|
|
460
|
-
|
|
461
466
|
// Abort any in-flight fetch so we don't apply a stale response
|
|
462
467
|
_currentAbort?.abort();
|
|
463
468
|
const controller = (_currentAbort = new AbortController());
|
|
@@ -482,12 +487,17 @@ async function navigate(url, { push = true, scroll = true, throwOnError = false,
|
|
|
482
487
|
scroll,
|
|
483
488
|
throwOnError,
|
|
484
489
|
replace,
|
|
490
|
+
signal: controller.signal,
|
|
485
491
|
});
|
|
486
492
|
} catch (err) {
|
|
487
493
|
if (err.name === "AbortError") {
|
|
488
494
|
if (throwOnError) throw err;
|
|
489
495
|
return;
|
|
490
496
|
}
|
|
497
|
+
if (_alreadyReported.has(err)) {
|
|
498
|
+
if (throwOnError) throw err;
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
491
501
|
console.error("[ruact-router] Navigation error:", err);
|
|
492
502
|
_onError?.(err);
|
|
493
503
|
if (throwOnError) throw err;
|
|
@@ -499,7 +509,7 @@ async function navigate(url, { push = true, scroll = true, throwOnError = false,
|
|
|
499
509
|
// ---------------------------------------------------------------------------
|
|
500
510
|
|
|
501
511
|
async function _processFlightResponse(response, {
|
|
502
|
-
push, targetUrl, scroll = true, throwOnError = false, replace = false, method = "GET",
|
|
512
|
+
push, targetUrl, scroll = true, throwOnError = false, replace = false, method = "GET", signal = null,
|
|
503
513
|
}) {
|
|
504
514
|
// Story 17.0f — a response that is not Flight escaped the server's boundary
|
|
505
515
|
// classifier. Feeding it to the line parser was the old dead click: every HTML
|
|
@@ -560,68 +570,45 @@ async function _processFlightResponse(response, {
|
|
|
560
570
|
? (() => { const u = new URL(response.url); return u.pathname + u.search + u.hash; })()
|
|
561
571
|
: targetUrl;
|
|
562
572
|
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
const tree = buildTreeFromRows(rows, _moduleRegistry);
|
|
587
|
-
if (pushEntry) history.pushState(null, "", finalPath);
|
|
588
|
-
_onNavigate(tree);
|
|
589
|
-
if (scroll) window.scrollTo(0, 0);
|
|
590
|
-
initialTreeSet = true;
|
|
591
|
-
return;
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
if (initialTreeSet && parsed.id !== 0) {
|
|
595
|
-
if (parsed.row.kind === "error") {
|
|
596
|
-
// Deferred error row — propagate via onError callback
|
|
597
|
-
_onError?.(new Error(`[ruact] Server error: ${parsed.row.message}`));
|
|
598
|
-
return;
|
|
599
|
-
}
|
|
600
|
-
if (parsed.row.kind === "model") {
|
|
601
|
-
// Deferred content row — resolve pending lazy chunk.
|
|
602
|
-
const element = buildTree(parsed.row.value, rows, _moduleRegistry);
|
|
603
|
-
resolvePendingChunk(parsed.id, element);
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
};
|
|
573
|
+
// Two readers of one body: React's client, and a drain that settles when the
|
|
574
|
+
// last row is in — `revalidate()` resolves then, as it always has.
|
|
575
|
+
const [forClient, forEnd] = response.body.tee();
|
|
576
|
+
const streamEnd = _drain(forEnd);
|
|
577
|
+
streamEnd.catch(() => {}); // awaited below; an abort must not be "unhandled" first
|
|
578
|
+
|
|
579
|
+
// Resolves when row 0 arrives; deferred Suspense rows keep streaming into it.
|
|
580
|
+
// A stream that ends without a root rejects (runtime/transport.js labels it).
|
|
581
|
+
const root = await createFromReadableStream(labelledStream(forClient));
|
|
582
|
+
|
|
583
|
+
// A newer navigation started while this one was in flight: it owns the page.
|
|
584
|
+
if (signal?.aborted) return;
|
|
585
|
+
|
|
586
|
+
// Flight redirect instruction — delegate to navigate() and bail out.
|
|
587
|
+
if (root != null && typeof root === "object" && root.redirectUrl) {
|
|
588
|
+
// Validate redirect is same-origin before following
|
|
589
|
+
try {
|
|
590
|
+
const rurl = new URL(root.redirectUrl, location.href);
|
|
591
|
+
if (rurl.origin !== location.origin) return;
|
|
592
|
+
} catch { return; }
|
|
593
|
+
navigate(root.redirectUrl, { push: root.redirectType !== "replace" });
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
607
596
|
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
const reader = response.body.getReader();
|
|
597
|
+
if (pushEntry) history.pushState(null, "", finalPath);
|
|
598
|
+
_onNavigate(root);
|
|
599
|
+
if (scroll) window.scrollTo(0, 0);
|
|
612
600
|
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
// Fallback: if row 0 never triggered (shouldn't happen with valid server)
|
|
621
|
-
if (!initialTreeSet && !redirected && rows.has(0)) {
|
|
622
|
-
const tree = buildTreeFromRows(rows, _moduleRegistry);
|
|
623
|
-
if (pushEntry) history.pushState(null, "", finalPath);
|
|
624
|
-
_onNavigate(tree);
|
|
625
|
-
if (scroll) window.scrollTo(0, 0);
|
|
601
|
+
try {
|
|
602
|
+
await streamEnd;
|
|
603
|
+
} catch (err) {
|
|
604
|
+
// The page is on screen. A row that never arrived was reported by its
|
|
605
|
+
// Suspense boundary; a revalidate() caller still learns it failed.
|
|
606
|
+
if (err?.name !== "AbortError") _alreadyReported.add(err);
|
|
607
|
+
throw err;
|
|
626
608
|
}
|
|
627
609
|
}
|
|
610
|
+
|
|
611
|
+
async function _drain(stream) {
|
|
612
|
+
const reader = stream.getReader();
|
|
613
|
+
while (!(await reader.read()).done) { /* the client has its own copy */ }
|
|
614
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Story 18-1 — the runtime's own client component around every Suspense child.
|
|
2
|
+
//
|
|
3
|
+
// The server wraps the deferred child of each <Suspense> in this boundary
|
|
4
|
+
// (Flight::Serializer#serialize_suspense, import id `ruact:boundary`). When the
|
|
5
|
+
// child's row is an error — a Suspense that ran past `suspense_timeout` — React's
|
|
6
|
+
// Flight client throws it during render; without a boundary React 19 unmounts
|
|
7
|
+
// the whole root. Here the fallback stays on screen and the error goes to the
|
|
8
|
+
// router's `onError`, as it did before the official client.
|
|
9
|
+
//
|
|
10
|
+
// It catches only what the transport produced: an error row (React's client
|
|
11
|
+
// gives those a `digest`), a stream cut short (labelled by ./transport.js), a
|
|
12
|
+
// navigation that was superseded. A crash in the app's own components passes through to the app's
|
|
13
|
+
// error boundaries, or to the root's.
|
|
14
|
+
import { Component } from "react";
|
|
15
|
+
import { registerBuiltin } from "./flight-modules.js";
|
|
16
|
+
|
|
17
|
+
const defaultReport = (error) => console.error(error);
|
|
18
|
+
let reportError = defaultReport;
|
|
19
|
+
|
|
20
|
+
/** The router points this at its `onError`. */
|
|
21
|
+
export function setBoundaryErrorHandler(handler) {
|
|
22
|
+
reportError = handler ?? defaultReport;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// What the server writes in an error row's `digest`, which React's production
|
|
26
|
+
// client keeps (it drops the message). Mirrors Ruact::Flight::RowEmitter.
|
|
27
|
+
// runtime/transport.js labels a response cut short the same way.
|
|
28
|
+
const DIGEST_MESSAGES = {
|
|
29
|
+
"ruact:suspense-timeout": "Suspense timeout exceeded",
|
|
30
|
+
"ruact:connection-closed": "the response ended before the whole page arrived",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** A superseded navigation aborts its stream: not something to report. */
|
|
34
|
+
export function isAbort(error) {
|
|
35
|
+
return error?.name === "AbortError";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// An error row (React's client gives it a `digest`), a response cut short
|
|
39
|
+
// (runtime/transport.js gives it one too), or a superseded navigation. An
|
|
40
|
+
// AbortError thrown by the app's own code in a deferred child (an aborted fetch
|
|
41
|
+
// handed to `use()`) is treated as a navigation's too: the fallback stays.
|
|
42
|
+
function isTransportError(error) {
|
|
43
|
+
if (error == null || typeof error !== "object") return false;
|
|
44
|
+
return "digest" in error || isAbort(error);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The error to hand the app: the server's message, in production too. */
|
|
48
|
+
export function serverError(error) {
|
|
49
|
+
const message = DIGEST_MESSAGES[error?.digest] ?? error?.message ?? String(error);
|
|
50
|
+
return new Error(`[ruact] Server error: ${message}`, { cause: error });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class SuspenseBoundary extends Component {
|
|
54
|
+
constructor(props) {
|
|
55
|
+
super(props);
|
|
56
|
+
this.state = { failed: false, appError: null, children: props.children };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// A new response brings a new lazy child (the router, revalidate()). React
|
|
60
|
+
// keeps this instance when the page keeps its shape, so a failure must not
|
|
61
|
+
// outlive the child that failed.
|
|
62
|
+
static getDerivedStateFromProps(props, state) {
|
|
63
|
+
if (props.children === state.children) return null;
|
|
64
|
+
return { failed: false, appError: null, children: props.children };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
static getDerivedStateFromError(error) {
|
|
68
|
+
return isTransportError(error) ? { failed: true } : { appError: error };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
componentDidCatch(error) {
|
|
72
|
+
if (!isTransportError(error) || isAbort(error)) return;
|
|
73
|
+
reportError(serverError(error));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
render() {
|
|
77
|
+
// Not ours to handle: rethrown to the boundaries above.
|
|
78
|
+
if (this.state.appError) throw this.state.appError;
|
|
79
|
+
return this.state.failed ? (this.props.fallback ?? null) : this.props.children;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
registerBuiltin("ruact:boundary", { SuspenseBoundary });
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Story 18-1 — the stream React's Flight client reads, with its failures
|
|
2
|
+
// labelled.
|
|
3
|
+
//
|
|
4
|
+
// When a response ends before every row arrived (the server raised while
|
|
5
|
+
// streaming a deferred child, a proxy timed out, the network dropped), React's
|
|
6
|
+
// client fails the rows still pending — with "Connection closed." in its
|
|
7
|
+
// development build and a minified "#412" in production, or with whatever the
|
|
8
|
+
// network threw. The Suspense boundary must recognise all of these as the
|
|
9
|
+
// transport's, not as a crash in the app's components. So the stream ends with
|
|
10
|
+
// an error carrying a digest the boundary knows; for a complete response that
|
|
11
|
+
// changes nothing, since the client only fails rows still pending. A
|
|
12
|
+
// superseded navigation's AbortError passes through as it is.
|
|
13
|
+
import { isAbort } from "./suspense-boundary.js";
|
|
14
|
+
|
|
15
|
+
export const CONNECTION_CLOSED_DIGEST = "ruact:connection-closed";
|
|
16
|
+
|
|
17
|
+
function closed(cause) {
|
|
18
|
+
const error = new Error("the response ended before the whole page arrived", cause ? { cause } : undefined);
|
|
19
|
+
error.digest = CONNECTION_CLOSED_DIGEST;
|
|
20
|
+
return error;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function labelledStream(stream) {
|
|
24
|
+
const reader = stream.getReader();
|
|
25
|
+
return new ReadableStream({
|
|
26
|
+
async pull(controller) {
|
|
27
|
+
let result;
|
|
28
|
+
try {
|
|
29
|
+
result = await reader.read();
|
|
30
|
+
} catch (error) {
|
|
31
|
+
controller.error(isAbort(error) ? error : closed(error));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (result.done) controller.error(closed());
|
|
35
|
+
else controller.enqueue(result.value);
|
|
36
|
+
},
|
|
37
|
+
cancel(reason) {
|
|
38
|
+
return reader.cancel(reason);
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
}
|