ruact 0.0.20 → 0.0.22
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 +46 -1
- data/CONTRIBUTING.md +2 -2
- data/lib/generators/ruact/install/install_generator.rb +121 -19
- data/lib/generators/ruact/install/legacy/vite.config.pinned.js +28 -0
- data/lib/generators/ruact/install/templates/AGENTS.md.tt +9 -9
- data/lib/generators/ruact/install/templates/vite.config.js.tt +56 -21
- data/lib/ruact/build_skew.rb +164 -0
- data/lib/ruact/cached_blocks.rb +30 -0
- data/lib/ruact/client_manifest.rb +27 -1
- data/lib/ruact/controller/delivery.rb +24 -9
- data/lib/ruact/controller/streaming.rb +64 -0
- data/lib/ruact/controller.rb +21 -5
- data/lib/ruact/declared_content.rb +33 -0
- data/lib/ruact/deferred_render.rb +55 -9
- data/lib/ruact/doctor.rb +40 -6
- data/lib/ruact/erb_preprocessor.rb +8 -2
- data/lib/ruact/flight/react_element.rb +9 -4
- data/lib/ruact/flight/renderer.rb +4 -1
- data/lib/ruact/html_converter.rb +21 -3
- data/lib/ruact/markers.rb +11 -8
- data/lib/ruact/query_watch.rb +106 -0
- data/lib/ruact/railtie.rb +14 -0
- data/lib/ruact/render_context.rb +23 -11
- data/lib/ruact/render_pipeline.rb +3 -1
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper/suspense.rb +10 -2
- data/lib/ruact/view_helper.rb +56 -30
- data/lib/ruact.rb +8 -2
- data/vendor/javascript/vite-plugin-ruact/build-version.test.mjs +101 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +39 -4
- data/vendor/javascript/vite-plugin-ruact/page-mount.test.mjs +10 -2
- data/vendor/javascript/vite-plugin-ruact/page-stream.test.mjs +29 -4
- data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +7 -1
- data/vendor/javascript/vite-plugin-ruact/ruact-router.test.mjs +185 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/dev-overlay.js +20 -6
- data/vendor/javascript/vite-plugin-ruact/runtime/page-stream.js +23 -3
- data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +64 -4
- metadata +8 -2
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The two ways a page is opened read the same way: the rows a document inlined
|
|
2
2
|
// in `__FLIGHT_DATA`, and a fetch body that may end early.
|
|
3
3
|
import { describe, it, expect } from "vitest";
|
|
4
|
-
import { documentRows, openPage } from "./runtime/page-stream.js";
|
|
4
|
+
import { LEAVE_EVENT, documentRows, openPage } from "./runtime/page-stream.js";
|
|
5
5
|
|
|
6
6
|
const encoder = new TextEncoder();
|
|
7
7
|
|
|
@@ -57,9 +57,14 @@ describe("documentRows", () => {
|
|
|
57
57
|
return {
|
|
58
58
|
readyState: "loading",
|
|
59
59
|
addEventListener(type, listener) { listeners[type] = listener; },
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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");
|
|
63
68
|
},
|
|
64
69
|
};
|
|
65
70
|
}
|
|
@@ -93,6 +98,26 @@ describe("documentRows", () => {
|
|
|
93
98
|
expect(closed).toBe(true);
|
|
94
99
|
});
|
|
95
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
|
+
|
|
96
121
|
it("closes at once when the document has already ended", async () => {
|
|
97
122
|
const reader = documentRows(['0:"x"\n'], { readyState: "complete" }).getReader();
|
|
98
123
|
await reader.read();
|
|
@@ -20,6 +20,8 @@ import path from "node:path";
|
|
|
20
20
|
import { pathToFileURL } from "node:url";
|
|
21
21
|
import { build } from "vite";
|
|
22
22
|
import {
|
|
23
|
+
BUILD_KEY,
|
|
24
|
+
readRuactVersion,
|
|
23
25
|
buildManifest,
|
|
24
26
|
generateRegistrySource,
|
|
25
27
|
REGISTRY_VIRTUAL_ID,
|
|
@@ -143,9 +145,13 @@ describe("prod id-match invariant — real vite build (Story 10.1b AC2, LOAD-BEA
|
|
|
143
145
|
});
|
|
144
146
|
|
|
145
147
|
// The manifest the gem will read (ids === Flight moduleIds).
|
|
146
|
-
const
|
|
148
|
+
const written = JSON.parse(
|
|
147
149
|
fs.readFileSync(path.join(dir, "public/react-client-manifest.json"), "utf8")
|
|
148
150
|
);
|
|
151
|
+
// Story 19-3 — the build's record comes first, and is not a component.
|
|
152
|
+
const { [BUILD_KEY]: build19_3, ...manifest } = written;
|
|
153
|
+
expect(Object.keys(written)[0]).toBe(BUILD_KEY);
|
|
154
|
+
expect(build19_3).toEqual({ version: readRuactVersion() });
|
|
149
155
|
const manifestIds = new Set(Object.values(manifest).map((e) => e.id));
|
|
150
156
|
|
|
151
157
|
// Prod ids stay source-relative (eager-inlined components → no facade chunk
|
|
@@ -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
|
+
});
|
|
@@ -9,7 +9,11 @@
|
|
|
9
9
|
|
|
10
10
|
const OVERLAY_ID = "ruact-dev-overlay";
|
|
11
11
|
|
|
12
|
-
/**
|
|
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
|
+
*/
|
|
13
17
|
export function showDevOverlay(error, doc = globalThis.document) {
|
|
14
18
|
const cause = error?.cause ?? error;
|
|
15
19
|
let overlay = doc.getElementById(OVERLAY_ID);
|
|
@@ -23,17 +27,27 @@ export function showDevOverlay(error, doc = globalThis.document) {
|
|
|
23
27
|
"font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;padding:32px;overflow:auto;" +
|
|
24
28
|
"white-space:pre-wrap;cursor:pointer";
|
|
25
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);
|
|
26
36
|
(doc.body ?? doc.documentElement).appendChild(overlay);
|
|
27
37
|
}
|
|
28
|
-
|
|
29
|
-
|
|
38
|
+
|
|
39
|
+
const entry = doc.createElement("section");
|
|
40
|
+
entry.dataset.overlayEntry = "";
|
|
30
41
|
const message = doc.createElement("p");
|
|
31
42
|
message.dataset.overlayMessage = "";
|
|
32
43
|
message.textContent = cause?.message ?? String(cause);
|
|
33
44
|
const stack = doc.createElement("pre");
|
|
34
45
|
stack.dataset.overlayStack = "";
|
|
35
46
|
stack.textContent = cause?.stack ?? "";
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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`;
|
|
39
53
|
}
|
|
@@ -8,12 +8,21 @@
|
|
|
8
8
|
import { createFromReadableStream } from "virtual:ruact/flight-client";
|
|
9
9
|
import { labelledStream } from "./transport.js";
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* What the router dispatches on the document before it stops one it leaves
|
|
13
|
+
* while it still loads (Story 19-2).
|
|
14
|
+
*/
|
|
15
|
+
export const LEAVE_EVENT = "ruact:leave";
|
|
16
|
+
|
|
11
17
|
/**
|
|
12
18
|
* The rows the document pushes onto `__FLIGHT_DATA`, as a byte stream that
|
|
13
19
|
* stays open while the document does (Story 19-1): the rows already in the
|
|
14
20
|
* queue, then each one pushed later — a page that streams pushes one script per
|
|
15
21
|
* `<Suspense>` — until the document has finished loading. A row pushed before
|
|
16
22
|
* this runs is in the queue, and read first.
|
|
23
|
+
*
|
|
24
|
+
* A document the router leaves ({@link LEAVE_EVENT}) ends the stream as an
|
|
25
|
+
* aborted navigation does: the page being left reports no response cut short.
|
|
17
26
|
*/
|
|
18
27
|
export function documentRows(queue, doc = globalThis.document) {
|
|
19
28
|
const encoder = new TextEncoder();
|
|
@@ -27,12 +36,23 @@ export function documentRows(queue, doc = globalThis.document) {
|
|
|
27
36
|
if (open) rows.forEach(read);
|
|
28
37
|
return 0;
|
|
29
38
|
};
|
|
30
|
-
const close = () => {
|
|
39
|
+
const close = (error) => {
|
|
40
|
+
if (!open) return;
|
|
31
41
|
open = false;
|
|
32
|
-
|
|
42
|
+
doc?.removeEventListener?.("readystatechange", atEnd);
|
|
43
|
+
doc?.removeEventListener?.(LEAVE_EVENT, left);
|
|
44
|
+
if (error) controller.error(error);
|
|
45
|
+
else controller.close();
|
|
46
|
+
};
|
|
47
|
+
// Closed when the document stops loading: its end (`interactive`, just
|
|
48
|
+
// before DOMContentLoaded), or the router stopping it (Story 19-2).
|
|
49
|
+
const atEnd = () => {
|
|
50
|
+
if (doc.readyState !== "loading") close();
|
|
33
51
|
};
|
|
52
|
+
const left = () => close(new DOMException("the page was left while it loaded", "AbortError"));
|
|
34
53
|
if (doc?.readyState === "loading") {
|
|
35
|
-
doc.addEventListener("
|
|
54
|
+
doc.addEventListener("readystatechange", atEnd);
|
|
55
|
+
doc.addEventListener(LEAVE_EVENT, left);
|
|
36
56
|
} else {
|
|
37
57
|
close();
|
|
38
58
|
}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { setBoundaryErrorHandler } from "./suspense-boundary.js";
|
|
14
|
-
import { openPage } from "./page-stream.js";
|
|
14
|
+
import { LEAVE_EVENT, openPage } from "./page-stream.js";
|
|
15
15
|
|
|
16
16
|
// Errors already reported once (by a Suspense boundary) that navigate() must
|
|
17
17
|
// not report again on their way out to a revalidate() caller.
|
|
@@ -21,6 +21,12 @@ let _onNavigate = null;
|
|
|
21
21
|
let _onError = null;
|
|
22
22
|
let _currentAbort = null;
|
|
23
23
|
|
|
24
|
+
// Story 19-2 — whether the page on screen is a document the router stopped
|
|
25
|
+
// and has not replaced yet (a new tree, a full load, a native submit); and the
|
|
26
|
+
// path of the page on screen, which a `#anchor` does not leave.
|
|
27
|
+
let _pageStopped = false;
|
|
28
|
+
let _renderedPath = null;
|
|
29
|
+
|
|
24
30
|
/**
|
|
25
31
|
* @param {object} opts
|
|
26
32
|
* @param {function} opts.onNavigate - called with the new React element tree
|
|
@@ -33,6 +39,8 @@ let _currentAbort = null;
|
|
|
33
39
|
export function setupRouter({ onNavigate, onError = null, onBoundaryError = null }) {
|
|
34
40
|
_onNavigate = onNavigate;
|
|
35
41
|
_onError = onError;
|
|
42
|
+
_pageStopped = false;
|
|
43
|
+
_renderedPath = typeof location === "undefined" ? null : location.pathname + location.search;
|
|
36
44
|
setBoundaryErrorHandler((error) => {
|
|
37
45
|
console.error(error.message);
|
|
38
46
|
onBoundaryError?.(error);
|
|
@@ -58,11 +66,15 @@ export function setupRouter({ onNavigate, onError = null, onBoundaryError = null
|
|
|
58
66
|
// what link clicks and form submits want, but a programmatic
|
|
59
67
|
// `revalidate()` caller HAS to be able to branch on success vs.
|
|
60
68
|
// failure.
|
|
69
|
+
// `leave: false` (Story 19-2): revalidate() stays on the page, so a
|
|
70
|
+
// document still streaming is not stopped — stopping it would also fail the
|
|
71
|
+
// page's other requests in flight (a server function, a useQuery).
|
|
61
72
|
globalThis.__ruact_revalidate = (target) =>
|
|
62
73
|
navigate(target ?? location.pathname + location.search, {
|
|
63
74
|
push: false,
|
|
64
75
|
scroll: false,
|
|
65
76
|
throwOnError: true,
|
|
77
|
+
leave: false,
|
|
66
78
|
});
|
|
67
79
|
}
|
|
68
80
|
|
|
@@ -112,9 +124,12 @@ function handleClick(event) {
|
|
|
112
124
|
const anchor = event.target.closest("a[href]");
|
|
113
125
|
if (!shouldIntercept(anchor)) return;
|
|
114
126
|
|
|
115
|
-
event.preventDefault();
|
|
116
|
-
|
|
117
127
|
const url = new URL(anchor.getAttribute("href"), location.href);
|
|
128
|
+
// Story 19-2 — this page with a `#anchor`: the browser scrolls; nothing to
|
|
129
|
+
// fetch, and a page still streaming is not left.
|
|
130
|
+
if (url.hash && url.pathname + url.search === location.pathname + location.search) return;
|
|
131
|
+
|
|
132
|
+
event.preventDefault();
|
|
118
133
|
navigate(url.pathname + url.search + url.hash);
|
|
119
134
|
}
|
|
120
135
|
|
|
@@ -304,6 +319,7 @@ function _isFlight(response) {
|
|
|
304
319
|
// `response.url` never carries the #fragment (Fetch strips it), so the one
|
|
305
320
|
// the link asked for is put back.
|
|
306
321
|
function _fullLoad(response, targetUrl, { replace = false } = {}) {
|
|
322
|
+
_pageStopped = false;
|
|
307
323
|
const requested = new URL(targetUrl, location.href);
|
|
308
324
|
const destination = new URL(response.url || requested.href);
|
|
309
325
|
if (!destination.hash && requested.hash) destination.hash = requested.hash;
|
|
@@ -340,6 +356,7 @@ const _SUBMITTER_OVERRIDES = [
|
|
|
340
356
|
];
|
|
341
357
|
|
|
342
358
|
function _nativeSubmit(form, submitter) {
|
|
359
|
+
_pageStopped = false;
|
|
343
360
|
const restore = [];
|
|
344
361
|
const added = [];
|
|
345
362
|
|
|
@@ -405,6 +422,7 @@ async function _submitForm(form, submitter = null) {
|
|
|
405
422
|
// Rails _method hidden field is included in FormData automatically;
|
|
406
423
|
// Rack::MethodOverride reads it and routes to the correct action.
|
|
407
424
|
_currentAbort?.abort();
|
|
425
|
+
_leaveOpenDocument();
|
|
408
426
|
const controller = (_currentAbort = new AbortController());
|
|
409
427
|
|
|
410
428
|
const headers = { Accept: "text/x-component", "Ruact-Request": "1" };
|
|
@@ -439,6 +457,8 @@ async function _submitForm(form, submitter = null) {
|
|
|
439
457
|
if (err.name === "AbortError") return;
|
|
440
458
|
console.error("[ruact-router] Form submission error:", err);
|
|
441
459
|
_onError?.(err);
|
|
460
|
+
} finally {
|
|
461
|
+
_recoverStoppedPage(controller);
|
|
442
462
|
}
|
|
443
463
|
}
|
|
444
464
|
|
|
@@ -449,6 +469,9 @@ async function _submitForm(form, submitter = null) {
|
|
|
449
469
|
function handlePopstate() {
|
|
450
470
|
// Story 17.0f — `replace`: if this entry turns out not to be a ruact page, the
|
|
451
471
|
// full load must not push a NEW entry into the history being walked.
|
|
472
|
+
// Story 19-2 — only the hash changed (a `#anchor`, Back over one): the page
|
|
473
|
+
// on screen is this one, and the browser scrolls.
|
|
474
|
+
if (location.pathname + location.search === _renderedPath) return;
|
|
452
475
|
navigate(location.pathname + location.search + location.hash, { push: false, scroll: false, replace: true });
|
|
453
476
|
}
|
|
454
477
|
|
|
@@ -456,6 +479,38 @@ function handlePopstate() {
|
|
|
456
479
|
// Navigation
|
|
457
480
|
// ---------------------------------------------------------------------------
|
|
458
481
|
|
|
482
|
+
/**
|
|
483
|
+
* Story 19-2 — the page being left may be a document still arriving: a page
|
|
484
|
+
* that streams, whose <Suspense> rows are still to come. Nobody will see them,
|
|
485
|
+
* so stop loading it: the connection closes, and the server, which asks before
|
|
486
|
+
* each block, starts no further block for it. Only a document that streams
|
|
487
|
+
* and has rows still to come (the server marks it, `__FLIGHT_DATA.streaming`):
|
|
488
|
+
* any other document still being parsed — a slow network, a long layout — is
|
|
489
|
+
* left to load, the part of its layout after `#root` included.
|
|
490
|
+
*/
|
|
491
|
+
function _leaveOpenDocument() {
|
|
492
|
+
if (typeof document === "undefined" || document.readyState !== "loading") return;
|
|
493
|
+
if (globalThis.__FLIGHT_DATA?.streaming !== true) return;
|
|
494
|
+
// First its rows' stream ends as an aborted navigation: stopped, it would
|
|
495
|
+
// read as a response cut short, which the page being left reports.
|
|
496
|
+
if (typeof document.dispatchEvent === "function") document.dispatchEvent(new Event(LEAVE_EVENT));
|
|
497
|
+
if (typeof window !== "undefined" && typeof window.stop === "function") window.stop();
|
|
498
|
+
_pageStopped = true;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Story 19-2 — a navigation ended without replacing a page the router had
|
|
503
|
+
* stopped (a failed fetch, a 403 or a 500 to a form — this navigation's, or a
|
|
504
|
+
* later one's that superseded the one that stopped it): the user is still on
|
|
505
|
+
* that page, whose boundaries would keep their fallback. Fetch it again,
|
|
506
|
+
* without stopping anything, once. Not when a newer navigation took over.
|
|
507
|
+
*/
|
|
508
|
+
function _recoverStoppedPage(controller) {
|
|
509
|
+
if (!_pageStopped || controller.signal.aborted) return;
|
|
510
|
+
_pageStopped = false;
|
|
511
|
+
navigate(location.pathname + location.search, { push: false, scroll: false, leave: false });
|
|
512
|
+
}
|
|
513
|
+
|
|
459
514
|
/**
|
|
460
515
|
* Story 8.2 review patch R7 (2026-05-17) — `throwOnError` opts a caller
|
|
461
516
|
* out of the swallow-and-forward error mode. The default (false) is the
|
|
@@ -465,9 +520,10 @@ function handlePopstate() {
|
|
|
465
520
|
* REJECTS on a failed Flight fetch — without this, callers cannot
|
|
466
521
|
* branch on success vs. failure of a programmatic refetch.
|
|
467
522
|
*/
|
|
468
|
-
async function navigate(url, { push = true, scroll = true, throwOnError = false, replace = false } = {}) {
|
|
523
|
+
async function navigate(url, { push = true, scroll = true, throwOnError = false, replace = false, leave = true } = {}) {
|
|
469
524
|
// Abort any in-flight fetch so we don't apply a stale response
|
|
470
525
|
_currentAbort?.abort();
|
|
526
|
+
if (leave) _leaveOpenDocument();
|
|
471
527
|
const controller = (_currentAbort = new AbortController());
|
|
472
528
|
|
|
473
529
|
try {
|
|
@@ -504,6 +560,8 @@ async function navigate(url, { push = true, scroll = true, throwOnError = false,
|
|
|
504
560
|
console.error("[ruact-router] Navigation error:", err);
|
|
505
561
|
_onError?.(err);
|
|
506
562
|
if (throwOnError) throw err;
|
|
563
|
+
} finally {
|
|
564
|
+
_recoverStoppedPage(controller);
|
|
507
565
|
}
|
|
508
566
|
}
|
|
509
567
|
|
|
@@ -594,6 +652,8 @@ async function _processFlightResponse(response, {
|
|
|
594
652
|
}
|
|
595
653
|
|
|
596
654
|
if (pushEntry) history.pushState(null, "", finalPath);
|
|
655
|
+
_pageStopped = false;
|
|
656
|
+
_renderedPath = location.pathname + location.search;
|
|
597
657
|
_onNavigate(root);
|
|
598
658
|
if (scroll) window.scrollTo(0, 0);
|
|
599
659
|
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ruact
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0.
|
|
4
|
+
version: 0.0.22
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Luiz Garcia
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-16 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: nokogiri
|
|
@@ -43,6 +43,7 @@ files:
|
|
|
43
43
|
- RELEASING.md
|
|
44
44
|
- SECURITY.md
|
|
45
45
|
- lib/generators/ruact/install/install_generator.rb
|
|
46
|
+
- lib/generators/ruact/install/legacy/vite.config.pinned.js
|
|
46
47
|
- lib/generators/ruact/install/templates/AGENTS.md.tt
|
|
47
48
|
- lib/generators/ruact/install/templates/Procfile.dev.tt
|
|
48
49
|
- lib/generators/ruact/install/templates/dev.tt
|
|
@@ -71,6 +72,8 @@ files:
|
|
|
71
72
|
- lib/generators/ruact/scaffold/templates/views/new.html.erb.tt
|
|
72
73
|
- lib/generators/ruact/scaffold/templates/views/show.html.erb.tt
|
|
73
74
|
- lib/ruact.rb
|
|
75
|
+
- lib/ruact/build_skew.rb
|
|
76
|
+
- lib/ruact/cached_blocks.rb
|
|
74
77
|
- lib/ruact/client_manifest.rb
|
|
75
78
|
- lib/ruact/component_attributes.rb
|
|
76
79
|
- lib/ruact/component_contract.rb
|
|
@@ -82,6 +85,7 @@ files:
|
|
|
82
85
|
- lib/ruact/controller/page_rendering.rb
|
|
83
86
|
- lib/ruact/controller/pages.rb
|
|
84
87
|
- lib/ruact/controller/streaming.rb
|
|
88
|
+
- lib/ruact/declared_content.rb
|
|
85
89
|
- lib/ruact/deferred_render.rb
|
|
86
90
|
- lib/ruact/doctor.rb
|
|
87
91
|
- lib/ruact/erb_preprocessor.rb
|
|
@@ -104,6 +108,7 @@ files:
|
|
|
104
108
|
- lib/ruact/navigation_boundary.rb
|
|
105
109
|
- lib/ruact/packaging.rb
|
|
106
110
|
- lib/ruact/query.rb
|
|
111
|
+
- lib/ruact/query_watch.rb
|
|
107
112
|
- lib/ruact/railtie.rb
|
|
108
113
|
- lib/ruact/redirect_path.rb
|
|
109
114
|
- lib/ruact/render_context.rb
|
|
@@ -156,6 +161,7 @@ files:
|
|
|
156
161
|
- vendor/javascript/ruact-server-functions-runtime/package.json
|
|
157
162
|
- vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs
|
|
158
163
|
- vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs
|
|
164
|
+
- vendor/javascript/vite-plugin-ruact/build-version.test.mjs
|
|
159
165
|
- vendor/javascript/vite-plugin-ruact/flight-conformance.test.mjs
|
|
160
166
|
- vendor/javascript/vite-plugin-ruact/index.js
|
|
161
167
|
- vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs
|