ruact 0.0.19 → 0.0.21

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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +57 -1
  3. data/CONTRIBUTING.md +10 -3
  4. data/README.md +1 -1
  5. data/lib/generators/ruact/install/templates/AGENTS.md.tt +13 -13
  6. data/lib/generators/ruact/install/templates/initializer.rb.tt +4 -1
  7. data/lib/ruact/cached_blocks.rb +30 -0
  8. data/lib/ruact/configuration.rb +6 -1
  9. data/lib/ruact/controller/delivery.rb +262 -0
  10. data/lib/ruact/controller/document_rendering.rb +15 -12
  11. data/lib/ruact/controller/streaming.rb +305 -0
  12. data/lib/ruact/controller.rb +34 -51
  13. data/lib/ruact/declared_content.rb +33 -0
  14. data/lib/ruact/deferred_render.rb +254 -0
  15. data/lib/ruact/doctor.rb +42 -2
  16. data/lib/ruact/erb_preprocessor.rb +211 -24
  17. data/lib/ruact/flight/react_element.rb +22 -2
  18. data/lib/ruact/flight/renderer.rb +94 -41
  19. data/lib/ruact/flight/request.rb +71 -27
  20. data/lib/ruact/flight/serializer.rb +3 -10
  21. data/lib/ruact/html_converter.rb +44 -34
  22. data/lib/ruact/inline_script.rb +24 -0
  23. data/lib/ruact/load_async_note.rb +51 -0
  24. data/lib/ruact/markers.rb +98 -0
  25. data/lib/ruact/navigation_boundary.rb +2 -2
  26. data/lib/ruact/query_watch.rb +106 -0
  27. data/lib/ruact/railtie.rb +12 -0
  28. data/lib/ruact/render_context.rb +73 -6
  29. data/lib/ruact/render_pipeline.rb +32 -22
  30. data/lib/ruact/request_kind.rb +36 -0
  31. data/lib/ruact/server.rb +3 -7
  32. data/lib/ruact/testing/flight_extractor.rb +21 -4
  33. data/lib/ruact/version.rb +1 -1
  34. data/lib/ruact/view_helper/suspense.rb +70 -0
  35. data/lib/ruact/view_helper.rb +35 -33
  36. data/lib/ruact.rb +7 -0
  37. data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +16 -3
  38. data/vendor/javascript/vite-plugin-ruact/page-mount.test.mjs +75 -0
  39. data/vendor/javascript/vite-plugin-ruact/page-stream.test.mjs +132 -0
  40. data/vendor/javascript/vite-plugin-ruact/ruact-router.test.mjs +185 -0
  41. data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +23 -18
  42. data/vendor/javascript/vite-plugin-ruact/runtime/dev-overlay.js +53 -0
  43. data/vendor/javascript/vite-plugin-ruact/runtime/page-stream.js +108 -0
  44. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +74 -20
  45. data/vendor/javascript/vite-plugin-ruact/runtime/suspense-boundary.js +1 -0
  46. metadata +17 -2
data/lib/ruact.rb CHANGED
@@ -10,12 +10,19 @@ require_relative "ruact/string_distance"
10
10
  require_relative "ruact/layout_source"
11
11
  require_relative "ruact/component_contract"
12
12
  require_relative "ruact/component_attributes"
13
+ require_relative "ruact/markers"
14
+ require_relative "ruact/request_kind"
15
+ require_relative "ruact/inline_script"
13
16
  require_relative "ruact/erb_preprocessor"
14
17
  require_relative "ruact/render_context"
15
18
  require_relative "ruact/html_converter"
16
19
  require_relative "ruact/client_manifest"
17
20
  require_relative "ruact/manifest_resolver"
18
21
  require_relative "ruact/render_pipeline"
22
+ require_relative "ruact/deferred_render"
23
+ require_relative "ruact/cached_blocks"
24
+ require_relative "ruact/query_watch"
25
+ require_relative "ruact/declared_content"
19
26
  require_relative "ruact/view_helper"
20
27
  require_relative "ruact/erb_preprocessor_hook"
21
28
  require_relative "ruact/server_functions"
@@ -56,15 +56,28 @@ describe("virtual:ruact/bootstrap — load (Story 14.2)", () => {
56
56
 
57
57
  it("imports the runtime modules via ABSOLUTE fs specifiers, not relative", () => {
58
58
  expect(src).toMatch(/from '\/.*\/runtime\/flight-modules\.js'/);
59
- expect(src).toMatch(/import '\/.*\/runtime\/suspense-boundary\.js'/);
59
+ expect(src).toMatch(/from '\/.*\/runtime\/suspense-boundary\.js'/);
60
60
  expect(src).toMatch(/from '\/.*\/runtime\/ruact-router\.js'/);
61
- // Story 18-1 — React's Flight client, through the plugin's virtual id.
62
- expect(src).toContain("from 'virtual:ruact/flight-client'");
61
+ // The page is opened through page-stream.js, which reads React's Flight
62
+ // client through the plugin's virtual id (Story 18-1).
63
+ expect(src).toMatch(/from '\/.*\/runtime\/page-stream\.js'/);
63
64
  // No `\0virtual:`-unresolvable relative import survives.
64
65
  expect(src).not.toMatch(/(from|import)\s+['"]\.\/[\w.-]+\.js['"]/);
65
66
  expect(src).not.toMatch(/from\s+['"]\.\/ruact-router\.js['"]/);
66
67
  });
67
68
 
69
+ // Story 19-1 — the boundary handler is in place before the first render: a
70
+ // row that is already an error when the page mounts is caught during that
71
+ // first commit, before the router's effect runs.
72
+ it("installs the boundary error handler before it mounts, and mounts into the root once it exists", () => {
73
+ expect(src).toMatch(/from '\/.*\/runtime\/dev-overlay\.js'/);
74
+ expect(src.indexOf("setBoundaryErrorHandler((error)")).toBeGreaterThan(-1);
75
+ expect(src.indexOf("setBoundaryErrorHandler((error)")).toBeLessThan(src.indexOf("createRoot(container"));
76
+ expect(src).toContain("Promise.all([initialTree, whenRoot()])");
77
+ expect(src).toContain("openPage(documentRows(flightData))");
78
+ expect(src).toContain("setupRouter({ onNavigate: setCurrent, onBoundaryError })");
79
+ });
80
+
68
81
  it("contains no JSX syntax (loads via the default loader like the registry)", () => {
69
82
  expect(src).not.toContain("<App");
70
83
  expect(src).toContain("createElement(App)");
@@ -0,0 +1,75 @@
1
+ // @vitest-environment jsdom
2
+ //
3
+ // Story 19-1 — the entry is `async`: it can run before the parser reaches the
4
+ // page's `<div id="root">`, and a <Suspense> that failed on the server is shown
5
+ // over the page in development.
6
+ import { describe, it, expect } from "vitest";
7
+ import { whenRoot } from "./runtime/page-stream.js";
8
+ import { showDevOverlay } from "./runtime/dev-overlay.js";
9
+
10
+ function loadingDocument() {
11
+ const doc = document.implementation.createHTMLDocument("page");
12
+ Object.defineProperty(doc, "readyState", { value: "loading", configurable: true });
13
+ return doc;
14
+ }
15
+
16
+ describe("whenRoot", () => {
17
+ it("gives the root at once when it is there", async () => {
18
+ const doc = document.implementation.createHTMLDocument("page");
19
+ doc.body.innerHTML = '<div id="root"></div>';
20
+ await expect(whenRoot(doc)).resolves.toBe(doc.getElementById("root"));
21
+ });
22
+
23
+ it("waits for a root the parser has not reached yet", async () => {
24
+ const doc = loadingDocument();
25
+ let found = null;
26
+ const waiting = whenRoot(doc).then((root) => { found = root; });
27
+ await new Promise((resolve) => setTimeout(resolve, 5));
28
+ expect(found).toBeNull();
29
+
30
+ const root = doc.createElement("div");
31
+ root.id = "root";
32
+ doc.body.appendChild(root);
33
+ await waiting;
34
+ expect(found).toBe(root);
35
+ });
36
+
37
+ it("gives up with null when the document ends without one", async () => {
38
+ const doc = loadingDocument();
39
+ const waiting = whenRoot(doc);
40
+ doc.dispatchEvent(new Event("DOMContentLoaded"));
41
+ await expect(waiting).resolves.toBeNull();
42
+ });
43
+ });
44
+
45
+ describe("showDevOverlay", () => {
46
+ it("shows the server's error over the page, and closes on a click", () => {
47
+ const cause = new Error("ArgumentError: boom in a block");
48
+ cause.stack = "ArgumentError: boom in a block\n at app/views/posts/show.html.erb:4";
49
+ showDevOverlay(new Error("[ruact] Server error: ArgumentError: boom in a block", { cause }));
50
+
51
+ const overlay = document.getElementById("ruact-dev-overlay");
52
+ expect(overlay.querySelector("[data-overlay-message]").textContent).toBe("ArgumentError: boom in a block");
53
+ expect(overlay.querySelector("[data-overlay-stack]").textContent).toContain("show.html.erb:4");
54
+
55
+ overlay.click();
56
+ expect(document.getElementById("ruact-dev-overlay")).toBeNull();
57
+ });
58
+
59
+ // Story 19-2 (AC7) — every failure, in the one overlay.
60
+ it("shows each failure in the one overlay, and opens again after it was closed", () => {
61
+ showDevOverlay(new Error("first"));
62
+ showDevOverlay(new Error("second"));
63
+ const overlays = document.querySelectorAll("#ruact-dev-overlay");
64
+ expect(overlays).toHaveLength(1);
65
+ const messages = [...overlays[0].querySelectorAll("[data-overlay-message]")].map((el) => el.textContent);
66
+ expect(messages).toEqual(["first", "second"]);
67
+ expect(overlays[0].querySelector("[data-overlay-title]").textContent).toBe("[ruact] 2 <Suspense> failed on the server");
68
+ overlays[0].click();
69
+
70
+ showDevOverlay(new Error("third"));
71
+ const reopened = document.getElementById("ruact-dev-overlay");
72
+ expect([...reopened.querySelectorAll("[data-overlay-message]")].map((el) => el.textContent)).toEqual(["third"]);
73
+ reopened.click();
74
+ });
75
+ });
@@ -0,0 +1,132 @@
1
+ // The two ways a page is opened read the same way: the rows a document inlined
2
+ // in `__FLIGHT_DATA`, and a fetch body that may end early.
3
+ import { describe, it, expect } from "vitest";
4
+ import { LEAVE_EVENT, documentRows, openPage } from "./runtime/page-stream.js";
5
+
6
+ const encoder = new TextEncoder();
7
+
8
+ function bodyOf(chunks, { error } = {}) {
9
+ return new ReadableStream({
10
+ start(controller) {
11
+ for (const chunk of chunks) controller.enqueue(encoder.encode(chunk));
12
+ if (error) controller.error(error);
13
+ else controller.close();
14
+ },
15
+ });
16
+ }
17
+
18
+ describe("openPage", () => {
19
+ it("opens the rows a document inlined, joined in the order they were pushed", async () => {
20
+ const page = openPage(documentRows(['1:"wor', 'ld"\n0:["hello","$1"]\n'], { readyState: "complete" }));
21
+ expect(await page.root).toEqual(["hello", "world"]);
22
+ await expect(page.end).resolves.toBeUndefined();
23
+ });
24
+
25
+ it("resolves the root at row 0, before a deferred row has arrived", async () => {
26
+ let push;
27
+ const body = new ReadableStream({
28
+ start(controller) {
29
+ controller.enqueue(encoder.encode('0:{"later":"$@1"}\n'));
30
+ push = (row) => {
31
+ controller.enqueue(encoder.encode(row));
32
+ controller.close();
33
+ };
34
+ },
35
+ });
36
+ const page = openPage(body);
37
+ const root = await page.root;
38
+ expect(root.later).toBeInstanceOf(Promise);
39
+
40
+ push('1:"arrived"\n');
41
+ await expect(root.later).resolves.toBe("arrived");
42
+ await expect(page.end).resolves.toBeUndefined();
43
+ });
44
+
45
+ it("labels a body that ends before the root as the transport's failure, and rejects the end", async () => {
46
+ const page = openPage(bodyOf([], { error: new TypeError("network down") }));
47
+ await expect(page.root).rejects.toMatchObject({ digest: "ruact:connection-closed" });
48
+ await expect(page.end).rejects.toThrow("network down");
49
+ });
50
+ });
51
+
52
+ // Story 19-1 — a page that streams: the document is still open when the
53
+ // bootstrap runs, and pushes each <Suspense>'s rows as they are ready.
54
+ describe("documentRows", () => {
55
+ function openDocument() {
56
+ const listeners = {};
57
+ return {
58
+ readyState: "loading",
59
+ addEventListener(type, listener) { listeners[type] = listener; },
60
+ removeEventListener(type) { delete listeners[type]; },
61
+ finish(state = "interactive") {
62
+ this.readyState = state;
63
+ listeners.readystatechange?.();
64
+ },
65
+ leave() {
66
+ listeners[LEAVE_EVENT]?.();
67
+ this.finish("complete");
68
+ },
69
+ };
70
+ }
71
+
72
+ it("reads what the queue holds, then each row pushed later, until the document ends", async () => {
73
+ const doc = openDocument();
74
+ const queue = ['0:{"later":"$@1"}\n'];
75
+ const page = openPage(documentRows(queue, doc));
76
+ const root = await page.root;
77
+ expect(root.later).toBeInstanceOf(Promise);
78
+
79
+ queue.push('1:"arrived"\n');
80
+ await expect(root.later).resolves.toBe("arrived");
81
+ expect(queue).toHaveLength(0);
82
+
83
+ doc.finish();
84
+ await expect(page.end).resolves.toBeUndefined();
85
+ });
86
+
87
+ it("keeps the stream open while the document loads, and closes it when the document ends", async () => {
88
+ const doc = openDocument();
89
+ const stream = documentRows(['0:"shell"\n'], doc);
90
+ const reader = stream.getReader();
91
+ await reader.read();
92
+ let closed = false;
93
+ const next = reader.read().then((result) => { closed = result.done; });
94
+ await new Promise((resolve) => setTimeout(resolve, 10));
95
+ expect(closed).toBe(false);
96
+ doc.finish();
97
+ await next;
98
+ expect(closed).toBe(true);
99
+ });
100
+
101
+ // Story 19-2 — the router stops a document it leaves while it still loads.
102
+ it("closes when the document is stopped while loading", async () => {
103
+ const doc = openDocument();
104
+ const reader = documentRows(['0:"x"\n'], doc).getReader();
105
+ await reader.read();
106
+ doc.finish("complete");
107
+ await expect(reader.read()).resolves.toMatchObject({ done: true });
108
+ });
109
+
110
+ // Story 19-2 — the router leaving it: the page being left reports nothing,
111
+ // as for a superseded navigation.
112
+ it("ends as an aborted navigation when the router leaves the document", async () => {
113
+ const doc = openDocument();
114
+ const page = openPage(documentRows(['0:{"later":"$@1"}\n'], doc));
115
+ const root = await page.root;
116
+ doc.leave();
117
+ await expect(root.later).rejects.toMatchObject({ name: "AbortError" });
118
+ await expect(page.end).rejects.toMatchObject({ name: "AbortError" });
119
+ });
120
+
121
+ it("closes at once when the document has already ended", async () => {
122
+ const reader = documentRows(['0:"x"\n'], { readyState: "complete" }).getReader();
123
+ await reader.read();
124
+ await expect(reader.read()).resolves.toMatchObject({ done: true });
125
+ });
126
+
127
+ it("ignores a row pushed after the document ended instead of throwing", () => {
128
+ const queue = [];
129
+ documentRows(queue, { readyState: "complete" });
130
+ expect(() => queue.push('9:"late"\n')).not.toThrow();
131
+ });
132
+ });
@@ -14,6 +14,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
14
14
  import fs from "node:fs";
15
15
  import path from "node:path";
16
16
  import { setupRouter, teardownRouter } from "./runtime/ruact-router.js";
17
+ import { LEAVE_EVENT } from "./runtime/page-stream.js";
17
18
 
18
19
  const ORIGIN = "http://localhost:3000";
19
20
 
@@ -568,3 +569,187 @@ describe("ruact-router — dates and big integers in a navigation (Story 17-0c)"
568
569
  });
569
570
  });
570
571
 
572
+
573
+ // Story 19-2 — leaving a page whose document is still arriving (a page that
574
+ // streams) stops loading it, so the server starts no further block for it.
575
+ describe("leaving a document that is still loading", () => {
576
+ let dom;
577
+
578
+ beforeEach(() => {
579
+ dom = installDom();
580
+ globalThis.fetch = vi.fn().mockResolvedValue(respond({ body: '0:"next"\n' }));
581
+ globalThis.window.stop = vi.fn();
582
+ globalThis.__FLIGHT_DATA = Object.assign([], { streaming: true });
583
+ vi.spyOn(console, "error").mockImplementation(() => {});
584
+ setupRouter({ onNavigate: vi.fn(), moduleRegistry: {}, onError: vi.fn() });
585
+ });
586
+
587
+ afterEach(() => {
588
+ teardownRouter();
589
+ delete globalThis.__FLIGHT_DATA;
590
+ vi.restoreAllMocks();
591
+ });
592
+
593
+ // A slow network, a long layout: the rest of the document after `#root`
594
+ // (which a navigation does not replace) still has to arrive.
595
+ it("leaves a loading document alone when it does not stream, or its rows are all in", async () => {
596
+ globalThis.document.readyState = "loading";
597
+ globalThis.__FLIGHT_DATA = [];
598
+ click(dom.listeners, "/products/2");
599
+ await settle();
600
+ globalThis.__FLIGHT_DATA = Object.assign([], { streaming: false });
601
+ click(dom.listeners, "/products/3");
602
+ await settle();
603
+ expect(window.stop).not.toHaveBeenCalled();
604
+ expect(fetch).toHaveBeenCalledTimes(2);
605
+ });
606
+
607
+ it("stops the document before it fetches the next page", async () => {
608
+ globalThis.document.readyState = "loading";
609
+ let stoppedBeforeFetch = null;
610
+ globalThis.fetch.mockImplementation(async () => {
611
+ stoppedBeforeFetch = window.stop.mock.calls.length === 1;
612
+ return respond({ body: '0:"next"\n' });
613
+ });
614
+ click(dom.listeners, "/products/2");
615
+ await settle();
616
+ expect(stoppedBeforeFetch).toBe(true);
617
+ });
618
+
619
+ it("leaves a document that has finished alone", async () => {
620
+ globalThis.document.readyState = "complete";
621
+ click(dom.listeners, "/products/2");
622
+ await settle();
623
+ expect(window.stop).not.toHaveBeenCalled();
624
+ expect(fetch).toHaveBeenCalled();
625
+ });
626
+
627
+ it("stops it for a form the router submits", async () => {
628
+ globalThis.document.readyState = "loading";
629
+ let stoppedBeforeFetch = null;
630
+ globalThis.fetch.mockImplementation(async () => {
631
+ stoppedBeforeFetch = window.stop.mock.calls.length === 1;
632
+ return respond({ body: '0:"next"\n' });
633
+ });
634
+ submit(dom.listeners, new dom.FakeForm({ action: "/people", method: "post" }));
635
+ await settle();
636
+ expect(stoppedBeforeFetch).toBe(true);
637
+ });
638
+
639
+ // Review 19-2a R3/R4 — a `#anchor` on the page on screen: the browser
640
+ // scrolls; nothing is fetched, and the document is not left.
641
+ it("leaves a popstate that only changes the hash to the browser", async () => {
642
+ globalThis.document.readyState = "loading";
643
+ location._set("/products/1#reviews");
644
+ dom.listeners.window.popstate.forEach((fn) => fn({}));
645
+ await settle();
646
+ expect(window.stop).not.toHaveBeenCalled();
647
+ expect(fetch).not.toHaveBeenCalled();
648
+ });
649
+
650
+ it("leaves a link to this page's #anchor to the browser", async () => {
651
+ globalThis.document.readyState = "loading";
652
+ const event = click(dom.listeners, "/products/1#reviews");
653
+ await settle();
654
+ expect(event.defaultPrevented).toBe(false);
655
+ expect(window.stop).not.toHaveBeenCalled();
656
+ expect(fetch).not.toHaveBeenCalled();
657
+ });
658
+
659
+ // Review 19-2a R4 — as a browser does: once stopped, the document no longer
660
+ // loads, so a later navigation does not stop it again.
661
+ function stopForReal() {
662
+ globalThis.document.readyState = "loading";
663
+ window.stop.mockImplementation(() => { globalThis.document.readyState = "complete"; });
664
+ }
665
+
666
+ it("fetches the page again when the navigation that superseded the one that stopped it fails", async () => {
667
+ stopForReal();
668
+ const html500 = respond({ status: 500, contentType: "text/html", body: "<p>boom</p>" });
669
+ globalThis.fetch
670
+ .mockImplementationOnce((_url, { signal }) => new Promise((_resolve, reject) => {
671
+ signal.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")));
672
+ }))
673
+ .mockResolvedValueOnce(html500)
674
+ .mockResolvedValue(respond({ body: '0:"again"\n' }));
675
+ const form = new dom.FakeForm({ action: "/people", method: "post" });
676
+ submit(dom.listeners, form);
677
+ submit(dom.listeners, form);
678
+ await settle();
679
+ await settle();
680
+ expect(window.stop).toHaveBeenCalledTimes(1);
681
+ expect(fetch.mock.calls.map((call) => call[0])).toEqual(["/people", "/people", "/products/1"]);
682
+ });
683
+
684
+ it("fetches the page again when the redirect a form was answered with fails", async () => {
685
+ stopForReal();
686
+ globalThis.fetch
687
+ .mockResolvedValueOnce(respond({ body: '0:{"redirectUrl":"/people/9","redirectType":"push"}\n' }))
688
+ .mockRejectedValueOnce(new TypeError("Failed to fetch"))
689
+ .mockResolvedValue(respond({ body: '0:"again"\n' }));
690
+ submit(dom.listeners, new dom.FakeForm({ action: "/people", method: "post" }));
691
+ await settle();
692
+ await settle();
693
+ expect(fetch.mock.calls.map((call) => call[0])).toEqual(["/people", "/people/9", "/products/1"]);
694
+ });
695
+
696
+ it("does not fetch the page again once a navigation replaced it", async () => {
697
+ stopForReal();
698
+ globalThis.fetch.mockResolvedValue(respond({ body: '0:"next"\n' }));
699
+ click(dom.listeners, "/products/2");
700
+ await settle();
701
+ globalThis.fetch.mockRejectedValue(new TypeError("Failed to fetch"));
702
+ click(dom.listeners, "/products/3");
703
+ await settle();
704
+ await settle();
705
+ expect(fetch.mock.calls.map((call) => call[0])).toEqual(["/products/2", "/products/3"]);
706
+ });
707
+
708
+ // Review 19-2a R3 — the navigation that stopped the document failed: the user
709
+ // is still on that page, whose boundaries would keep their fallback.
710
+ it("fetches the page again when the navigation that stopped it fails", async () => {
711
+ globalThis.document.readyState = "loading";
712
+ globalThis.fetch
713
+ .mockRejectedValueOnce(new TypeError("Failed to fetch"))
714
+ .mockResolvedValue(respond({ body: '0:"again"\n' }));
715
+ submit(dom.listeners, new dom.FakeForm({ action: "/people", method: "post" }));
716
+ await settle();
717
+ await settle();
718
+ expect(window.stop).toHaveBeenCalledTimes(1);
719
+ expect(fetch).toHaveBeenCalledTimes(2);
720
+ expect(fetch.mock.calls[1][0]).toBe("/products/1");
721
+ });
722
+
723
+ it("does not fetch the page again after a failure when nothing was stopped", async () => {
724
+ globalThis.document.readyState = "complete";
725
+ globalThis.fetch.mockRejectedValue(new TypeError("Failed to fetch"));
726
+ submit(dom.listeners, new dom.FakeForm({ action: "/people", method: "post" }));
727
+ await settle();
728
+ await settle();
729
+ expect(fetch).toHaveBeenCalledTimes(1);
730
+ });
731
+
732
+ // revalidate() stays on the page: stopping the document would also fail the
733
+ // page's other requests in flight (a server function, a useQuery).
734
+ it("leaves the document loading for revalidate()", async () => {
735
+ globalThis.document.readyState = "loading";
736
+ await globalThis.__ruact_revalidate();
737
+ expect(window.stop).not.toHaveBeenCalled();
738
+ expect(fetch).toHaveBeenCalledTimes(1);
739
+ });
740
+
741
+ // Stopped, the document's rows would read as a response cut short, which
742
+ // the page being left reports: they are told first.
743
+ it("tells the document's rows it is left before it stops it", async () => {
744
+ globalThis.document.readyState = "loading";
745
+ const told = [];
746
+ globalThis.document.dispatchEvent = vi.fn((event) => {
747
+ told.push([event.type, window.stop.mock.calls.length]);
748
+ return true;
749
+ });
750
+ click(dom.listeners, "/products/2");
751
+ await settle();
752
+ expect(told).toEqual([[LEAVE_EVENT, 0]]);
753
+ expect(window.stop).toHaveBeenCalledTimes(1);
754
+ });
755
+ });
@@ -18,15 +18,16 @@
18
18
  // transforming a `\0virtual:` id.
19
19
  import { createRoot } from 'react-dom/client';
20
20
  import { createElement, useState, useEffect } from 'react';
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
21
  import { setModuleRegistry } from './flight-modules.js';
25
22
  // Registers the boundary the server wraps each Suspense child in.
26
- import './suspense-boundary.js';
23
+ import { setBoundaryErrorHandler } from './suspense-boundary.js';
27
24
  import { RootBoundary, onCaughtError } from './root-boundary.js';
28
- import { labelledStream } from './transport.js';
25
+ // The page, opened from its Flight bytes with React's own Flight client (the
26
+ // copy vendored in the gem, or the app's `react-server-dom-webpack` when it
27
+ // declares one: the plugin decides).
28
+ import { documentRows, openPage, whenRoot } from './page-stream.js';
29
29
  import { setupRouter, teardownRouter } from './ruact-router.js';
30
+ import { showDevOverlay } from './dev-overlay.js';
30
31
 
31
32
  // MODULE_REGISTRY maps react-client-manifest "id" values to component exports.
32
33
  // It is auto-derived by vite-plugin-ruact from the SAME scan that emits
@@ -56,31 +57,35 @@ if (!flightData || flightData.length === 0) {
56
57
  root.textContent = '[ruact] No Flight data found — is the Rails server running?';
57
58
  }
58
59
  } else {
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
- },
60
+ // Story 19-1 — in development a <Suspense> that failed on the server is shown
61
+ // over the page. The handler is in place before anything renders: a row that
62
+ // is already an error when the page mounts is caught during that first
63
+ // commit, before the router's effect would install one.
64
+ const onBoundaryError = import.meta.env?.DEV ? (error) => showDevOverlay(error) : null;
65
+ setBoundaryErrorHandler((error) => {
66
+ console.error(error.message);
67
+ onBoundaryError?.(error);
67
68
  });
68
69
 
70
+ // The document's rows, as the stream the client reads — open while the
71
+ // document is: a page that streams pushes each <Suspense>'s rows as they are
72
+ // ready. The root is mounted once the tree resolves, so the first paint is
73
+ // the page with its fallbacks.
69
74
  let initialTree;
70
75
  try {
71
- initialTree = createFromReadableStream(labelledStream(stream));
76
+ initialTree = openPage(documentRows(flightData)).root;
72
77
  } catch (err) {
73
78
  showBootError(err);
74
79
  throw err;
75
80
  }
76
81
 
77
- initialTree.then(
78
- (tree) => {
82
+ Promise.all([initialTree, whenRoot()]).then(
83
+ ([tree, container]) => {
79
84
  function App() {
80
85
  const [current, setCurrent] = useState(() => tree);
81
86
 
82
87
  useEffect(() => {
83
- setupRouter({ onNavigate: setCurrent });
88
+ setupRouter({ onNavigate: setCurrent, onBoundaryError });
84
89
  return () => teardownRouter();
85
90
  }, []);
86
91
 
@@ -88,7 +93,7 @@ if (!flightData || flightData.length === 0) {
88
93
  return createElement(RootBoundary, { tree: current }, current);
89
94
  }
90
95
 
91
- createRoot(document.getElementById('root'), { onCaughtError }).render(createElement(App));
96
+ createRoot(container, { onCaughtError }).render(createElement(App));
92
97
  },
93
98
  (err) => {
94
99
  showBootError(err);
@@ -0,0 +1,53 @@
1
+ // Story 19-1 (ADR D5) — in development, a `<Suspense>` whose content failed on
2
+ // the server is shown over the page: what failed, and where in the templates.
3
+ // Production shows nothing of the error; the boundary keeps its fallback.
4
+ //
5
+ // The boundary hands the error to the handler ./suspense-boundary.js was given
6
+ // (the bootstrap installs one before the first render, the router keeps it).
7
+ // React's development client rebuilds the server's backtrace into the error's
8
+ // `stack`.
9
+
10
+ const OVERLAY_ID = "ruact-dev-overlay";
11
+
12
+ /**
13
+ * Shows +error+ over the page, under the ones already shown (Story 19-2: one
14
+ * entry per failed <Suspense>); a click closes the overlay, and the next
15
+ * failure opens it again.
16
+ */
17
+ export function showDevOverlay(error, doc = globalThis.document) {
18
+ const cause = error?.cause ?? error;
19
+ let overlay = doc.getElementById(OVERLAY_ID);
20
+ if (!overlay) {
21
+ overlay = doc.createElement("div");
22
+ overlay.id = OVERLAY_ID;
23
+ overlay.setAttribute("role", "alertdialog");
24
+ overlay.setAttribute("aria-label", "A <Suspense> failed on the server");
25
+ overlay.style.cssText =
26
+ "position:fixed;inset:0;z-index:2147483647;background:rgba(24,6,6,.94);color:#fff;" +
27
+ "font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;padding:32px;overflow:auto;" +
28
+ "white-space:pre-wrap;cursor:pointer";
29
+ overlay.addEventListener("click", () => overlay.remove());
30
+ const title = doc.createElement("h2");
31
+ title.dataset.overlayTitle = "";
32
+ const hint = doc.createElement("p");
33
+ hint.dataset.overlayHint = "";
34
+ hint.textContent = "Development only. Click to close.";
35
+ overlay.append(title, hint);
36
+ (doc.body ?? doc.documentElement).appendChild(overlay);
37
+ }
38
+
39
+ const entry = doc.createElement("section");
40
+ entry.dataset.overlayEntry = "";
41
+ const message = doc.createElement("p");
42
+ message.dataset.overlayMessage = "";
43
+ message.textContent = cause?.message ?? String(cause);
44
+ const stack = doc.createElement("pre");
45
+ stack.dataset.overlayStack = "";
46
+ stack.textContent = cause?.stack ?? "";
47
+ entry.append(message, stack);
48
+ overlay.insertBefore(entry, overlay.querySelector("[data-overlay-hint]"));
49
+
50
+ const count = overlay.querySelectorAll("[data-overlay-entry]").length;
51
+ overlay.querySelector("[data-overlay-title]").textContent =
52
+ count === 1 ? "[ruact] A <Suspense> failed on the server" : `[ruact] ${count} <Suspense> failed on the server`;
53
+ }