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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -1
  3. data/lib/ruact/client_manifest.rb +11 -2
  4. data/lib/ruact/controller.rb +3 -1
  5. data/lib/ruact/doctor.rb +64 -4
  6. data/lib/ruact/flight/renderer.rb +23 -9
  7. data/lib/ruact/flight/request.rb +31 -2
  8. data/lib/ruact/flight/row_emitter.rb +9 -0
  9. data/lib/ruact/flight/serializer.rb +22 -7
  10. data/lib/ruact/render_pipeline.rb +10 -3
  11. data/lib/ruact/testing/component_query.rb +11 -4
  12. data/lib/ruact/testing/flight_wire_parser.rb +2 -2
  13. data/lib/ruact/version.rb +1 -1
  14. data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +44 -2
  15. data/vendor/javascript/vite-plugin-ruact/flight-conformance.test.mjs +286 -0
  16. data/vendor/javascript/vite-plugin-ruact/index.js +89 -14
  17. data/vendor/javascript/vite-plugin-ruact/package-lock.json +804 -3
  18. data/vendor/javascript/vite-plugin-ruact/package.json +5 -1
  19. data/vendor/javascript/vite-plugin-ruact/ruact-router.test.mjs +53 -0
  20. data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +46 -17
  21. data/vendor/javascript/vite-plugin-ruact/runtime/flight-modules.js +40 -0
  22. data/vendor/javascript/vite-plugin-ruact/runtime/flight-webpack-globals.js +15 -0
  23. data/vendor/javascript/vite-plugin-ruact/runtime/root-boundary.js +41 -0
  24. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +65 -83
  25. data/vendor/javascript/vite-plugin-ruact/runtime/suspense-boundary.js +83 -0
  26. data/vendor/javascript/vite-plugin-ruact/runtime/transport.js +41 -0
  27. data/vendor/javascript/vite-plugin-ruact/runtime/vendor/react-server-dom-webpack/LICENSE +21 -0
  28. data/vendor/javascript/vite-plugin-ruact/runtime/vendor/react-server-dom-webpack/VERSION.json +5 -0
  29. data/vendor/javascript/vite-plugin-ruact/runtime/vendor/react-server-dom-webpack/client.browser.development.js +5473 -0
  30. data/vendor/javascript/vite-plugin-ruact/runtime/vendor/react-server-dom-webpack/client.browser.production.js +2126 -0
  31. data/vendor/javascript/vite-plugin-ruact/scripts/vendor-flight-client.mjs +124 -0
  32. data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +14 -0
  33. metadata +13 -4
  34. data/vendor/javascript/vite-plugin-ruact/flight-client.test.mjs +0 -499
  35. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +0 -436
@@ -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 (`./flight-client.js`) do NOT resolve from a `\0virtual:`
9
- // id. The plugin's `load` hook rewrites the two runtime imports below to
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
- import { createFromFlightPayload } from './flight-client.js';
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
- const payload = flightData.join('');
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 = createFromFlightPayload(payload, MODULE_REGISTRY);
71
+ initialTree = createFromReadableStream(labelledStream(stream));
49
72
  } catch (err) {
50
- console.error('[ruact] Failed to parse Flight payload:', err);
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
- function App() {
57
- const [tree, setTree] = useState(() => initialTree);
77
+ initialTree.then(
78
+ (tree) => {
79
+ function App() {
80
+ const [current, setCurrent] = useState(() => tree);
58
81
 
59
- useEffect(() => {
60
- setupRouter({ onNavigate: setTree, moduleRegistry: MODULE_REGISTRY });
61
- return () => teardownRouter();
62
- }, []);
82
+ useEffect(() => {
83
+ setupRouter({ onNavigate: setCurrent });
84
+ return () => teardownRouter();
85
+ }, []);
63
86
 
64
- return tree;
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
- createRoot(document.getElementById('root')).render(createElement(App));
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,36 +2,39 @@
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 via ReadableStream (incremental), and:
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. Resolves deferred Suspense rows as they stream in (swaps in actual content)
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
- createRowParser,
14
- buildTreeFromRows,
15
- buildTree,
16
- resolvePendingChunk,
17
- clearPendingChunks,
18
- } from "./flight-client.js";
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 {object} opts.moduleRegistry - passed through to flight-client
29
- * @param {function} [opts.onError] - called with an Error when navigation fails
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, moduleRegistry, onError = null }) {
31
+ export function setupRouter({ onNavigate, onError = null }) {
32
32
  _onNavigate = onNavigate;
33
- _moduleRegistry = moduleRegistry;
34
33
  _onError = onError;
34
+ setBoundaryErrorHandler((error) => {
35
+ console.error(error.message);
36
+ _onError?.(error);
37
+ });
35
38
 
36
39
  // Bubble phase, on the document: the app's own handlers (React's, which
37
40
  // listen on the root container, and any other) run first, so an
@@ -65,9 +68,9 @@ export function teardownRouter() {
65
68
  document.removeEventListener("submit", handleSubmit);
66
69
  window.removeEventListener("popstate", handlePopstate);
67
70
  _onNavigate = null;
68
- _moduleRegistry = null;
69
71
  _onError = null;
70
72
  _currentAbort = null;
73
+ setBoundaryErrorHandler(null);
71
74
  // Story 8.2 — tear down the revalidate handle so a subsequent
72
75
  // setupRouter()-less code path (e.g., SSR) fails LOUDLY at the first
73
76
  // revalidate() call instead of using a stale handle.
@@ -376,8 +379,6 @@ function _hasField(form, name) {
376
379
  }
377
380
 
378
381
  async function _submitForm(form, submitter = null) {
379
- clearPendingChunks();
380
-
381
382
  // R11: resolve the effective action and method from the submitter's
382
383
  // overrides before falling back to the form's attributes. The
383
384
  // previous implementation always read `form.action` and the form's
@@ -428,7 +429,9 @@ async function _submitForm(form, submitter = null) {
428
429
  else _nativeSubmit(form, submitter);
429
430
  return;
430
431
  }
431
- await _processFlightResponse(response, { push: true, targetUrl: action, method: htmlMethod });
432
+ await _processFlightResponse(response, {
433
+ push: true, targetUrl: action, method: htmlMethod, signal: controller.signal,
434
+ });
432
435
  } catch (err) {
433
436
  if (err.name === "AbortError") return;
434
437
  console.error("[ruact-router] Form submission error:", err);
@@ -460,9 +463,6 @@ function handlePopstate() {
460
463
  * branch on success vs. failure of a programmatic refetch.
461
464
  */
462
465
  async function navigate(url, { push = true, scroll = true, throwOnError = false, replace = false } = {}) {
463
- // Clear stale lazy refs from any previous streaming navigation
464
- clearPendingChunks();
465
-
466
466
  // Abort any in-flight fetch so we don't apply a stale response
467
467
  _currentAbort?.abort();
468
468
  const controller = (_currentAbort = new AbortController());
@@ -487,12 +487,17 @@ async function navigate(url, { push = true, scroll = true, throwOnError = false,
487
487
  scroll,
488
488
  throwOnError,
489
489
  replace,
490
+ signal: controller.signal,
490
491
  });
491
492
  } catch (err) {
492
493
  if (err.name === "AbortError") {
493
494
  if (throwOnError) throw err;
494
495
  return;
495
496
  }
497
+ if (_alreadyReported.has(err)) {
498
+ if (throwOnError) throw err;
499
+ return;
500
+ }
496
501
  console.error("[ruact-router] Navigation error:", err);
497
502
  _onError?.(err);
498
503
  if (throwOnError) throw err;
@@ -504,7 +509,7 @@ async function navigate(url, { push = true, scroll = true, throwOnError = false,
504
509
  // ---------------------------------------------------------------------------
505
510
 
506
511
  async function _processFlightResponse(response, {
507
- push, targetUrl, scroll = true, throwOnError = false, replace = false, method = "GET",
512
+ push, targetUrl, scroll = true, throwOnError = false, replace = false, method = "GET", signal = null,
508
513
  }) {
509
514
  // Story 17.0f — a response that is not Flight escaped the server's boundary
510
515
  // classifier. Feeding it to the line parser was the old dead click: every HTML
@@ -565,68 +570,45 @@ async function _processFlightResponse(response, {
565
570
  ? (() => { const u = new URL(response.url); return u.pathname + u.search + u.hash; })()
566
571
  : targetUrl;
567
572
 
568
- const rows = new Map();
569
- let initialTreeSet = false;
570
- let redirected = false;
571
-
572
- const processRow = (parsed) => {
573
- rows.set(parsed.id, parsed.row);
574
- // A text row is data a model row points at; nothing renders on its own.
575
- if (parsed.row.kind === "text") return;
576
-
577
- if (!initialTreeSet && rows.has(0)) {
578
- const rootRow = rows.get(0);
579
- // Flight redirect instruction — delegate to navigate() and bail out.
580
- if (rootRow.kind === "model" && rootRow.value != null && rootRow.value.redirectUrl) {
581
- // Validate redirect is same-origin before following
582
- try {
583
- const rurl = new URL(rootRow.value.redirectUrl, location.href);
584
- if (rurl.origin !== location.origin) return;
585
- } catch { return; }
586
- redirected = true;
587
- navigate(rootRow.value.redirectUrl, { push: rootRow.value.redirectType !== "replace" });
588
- return;
589
- }
590
- // Normal case: build tree and render immediately.
591
- const tree = buildTreeFromRows(rows, _moduleRegistry);
592
- if (pushEntry) history.pushState(null, "", finalPath);
593
- _onNavigate(tree);
594
- if (scroll) window.scrollTo(0, 0);
595
- initialTreeSet = true;
596
- return;
597
- }
598
-
599
- if (initialTreeSet && parsed.id !== 0) {
600
- if (parsed.row.kind === "error") {
601
- // Deferred error row — propagate via onError callback
602
- _onError?.(new Error(`[ruact] Server error: ${parsed.row.message}`));
603
- return;
604
- }
605
- if (parsed.row.kind === "model") {
606
- // Deferred content row — resolve pending lazy chunk.
607
- const element = buildTree(parsed.row.value, rows, _moduleRegistry);
608
- resolvePendingChunk(parsed.id, element);
609
- }
610
- }
611
- };
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
+ }
612
596
 
613
- // The same byte parser the initial load uses: text rows are framed by byte
614
- // length, so the stream is never decoded line by line.
615
- const parser = createRowParser(processRow);
616
- const reader = response.body.getReader();
597
+ if (pushEntry) history.pushState(null, "", finalPath);
598
+ _onNavigate(root);
599
+ if (scroll) window.scrollTo(0, 0);
617
600
 
618
- while (true) {
619
- const { done, value } = await reader.read();
620
- if (done) break;
621
- parser.push(value);
622
- }
623
- parser.end();
624
-
625
- // Fallback: if row 0 never triggered (shouldn't happen with valid server)
626
- if (!initialTreeSet && !redirected && rows.has(0)) {
627
- const tree = buildTreeFromRows(rows, _moduleRegistry);
628
- if (pushEntry) history.pushState(null, "", finalPath);
629
- _onNavigate(tree);
630
- 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;
631
608
  }
632
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
+ }
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) Meta Platforms, Inc. and affiliates.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,5 @@
1
+ {
2
+ "package": "react-server-dom-webpack",
3
+ "version": "19.3.0",
4
+ "integrity": "sha512-ymt9RkvWtW4OpIhWf1wj4u5CxnBaVbDCkz/bw3/QFmOE7+/wg8C30uiJ/QqkEdWn/UqT5c7/rOUW/eWYMwceCg=="
5
+ }