rwsdk 1.3.0-canary.7 → 1.3.1

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 (85) hide show
  1. package/dist/lib/e2e/release.d.mts +2 -1
  2. package/dist/lib/e2e/release.mjs +70 -5
  3. package/dist/lib/e2e/testHarness.d.mts +1 -0
  4. package/dist/lib/e2e/testHarness.mjs +51 -2
  5. package/dist/runtime/client/client.d.ts +3 -2
  6. package/dist/runtime/client/client.js +5 -1
  7. package/dist/runtime/client/navigation.js +42 -61
  8. package/dist/runtime/client/navigation.test.js +166 -18
  9. package/dist/runtime/client/recovery.d.ts +17 -0
  10. package/dist/runtime/client/recovery.js +191 -0
  11. package/dist/runtime/client/scrollRestoration.d.ts +25 -0
  12. package/dist/runtime/client/scrollRestoration.js +157 -0
  13. package/dist/runtime/client/scrollRestoration.test.d.ts +1 -0
  14. package/dist/runtime/client/scrollRestoration.test.js +93 -0
  15. package/dist/runtime/imports/client.js +13 -1
  16. package/dist/runtime/lib/router.d.ts +1 -0
  17. package/dist/runtime/lib/router.js +27 -2
  18. package/dist/runtime/lib/router.test.js +96 -0
  19. package/dist/runtime/render/assembleDocument.js +1 -1
  20. package/dist/runtime/render/renderDocumentHtmlStream.js +1 -1
  21. package/dist/runtime/render/stylesheets.js +0 -22
  22. package/dist/use-synced-state/__tests__/SyncStateServer.test.mjs +14 -7
  23. package/dist/use-synced-state/__tests__/client-core.test.js +18 -223
  24. package/dist/use-synced-state/__tests__/worker.test.mjs +41 -2
  25. package/dist/use-synced-state/client-core.js +20 -10
  26. package/dist/use-synced-state/hibernation/__tests__/client-core.test.d.ts +1 -0
  27. package/dist/use-synced-state/hibernation/__tests__/client-core.test.js +244 -0
  28. package/dist/use-synced-state/hibernation/__tests__/server.test.d.mts +1 -0
  29. package/dist/use-synced-state/hibernation/__tests__/server.test.mjs +165 -0
  30. package/dist/use-synced-state/hibernation/client-core.d.ts +19 -0
  31. package/dist/use-synced-state/hibernation/client-core.js +38 -0
  32. package/dist/use-synced-state/hibernation/client.d.ts +3 -0
  33. package/dist/use-synced-state/hibernation/client.js +4 -0
  34. package/dist/use-synced-state/hibernation/connection/connection.d.ts +2 -0
  35. package/dist/use-synced-state/hibernation/connection/connection.js +72 -0
  36. package/dist/use-synced-state/hibernation/connection/messages.d.ts +6 -0
  37. package/dist/use-synced-state/hibernation/connection/messages.js +64 -0
  38. package/dist/use-synced-state/hibernation/connection/timer.d.ts +5 -0
  39. package/dist/use-synced-state/hibernation/connection/timer.js +33 -0
  40. package/dist/use-synced-state/hibernation/connection/types.d.ts +22 -0
  41. package/dist/use-synced-state/hibernation/connection/types.js +1 -0
  42. package/dist/use-synced-state/hibernation/identity.d.mts +4 -0
  43. package/dist/use-synced-state/hibernation/identity.mjs +25 -0
  44. package/dist/use-synced-state/hibernation/protocol.d.mts +53 -0
  45. package/dist/use-synced-state/hibernation/protocol.mjs +55 -0
  46. package/dist/use-synced-state/hibernation/reconnect/backoff.d.ts +1 -0
  47. package/dist/use-synced-state/hibernation/reconnect/backoff.js +7 -0
  48. package/dist/use-synced-state/hibernation/reconnect/reconnect.d.ts +8 -0
  49. package/dist/use-synced-state/hibernation/reconnect/reconnect.js +34 -0
  50. package/dist/use-synced-state/hibernation/server.d.mts +62 -0
  51. package/dist/use-synced-state/hibernation/server.mjs +385 -0
  52. package/dist/use-synced-state/hibernation/state/clientFactory.d.ts +2 -0
  53. package/dist/use-synced-state/hibernation/state/clientFactory.js +63 -0
  54. package/dist/use-synced-state/hibernation/state/clientManager.d.ts +46 -0
  55. package/dist/use-synced-state/hibernation/state/clientManager.js +151 -0
  56. package/dist/use-synced-state/hibernation/useSyncedState.d.ts +22 -0
  57. package/dist/use-synced-state/hibernation/useSyncedState.js +64 -0
  58. package/dist/use-synced-state/hibernation/worker.d.mts +8 -0
  59. package/dist/use-synced-state/hibernation/worker.mjs +50 -0
  60. package/dist/use-synced-state/shim/__tests__/worker.test.d.mts +1 -0
  61. package/dist/use-synced-state/shim/__tests__/worker.test.mjs +37 -0
  62. package/dist/use-synced-state/shim/client.d.ts +2 -0
  63. package/dist/use-synced-state/shim/client.js +2 -0
  64. package/dist/use-synced-state/shim/worker.d.mts +2 -0
  65. package/dist/use-synced-state/shim/worker.mjs +139 -0
  66. package/dist/use-synced-state/worker.mjs +34 -3
  67. package/dist/vite/buildApp.mjs +15 -54
  68. package/dist/vite/configPlugin.mjs +20 -27
  69. package/dist/vite/createDirectiveLookupPlugin.mjs +15 -11
  70. package/dist/vite/directiveModulesDevPlugin.mjs +44 -28
  71. package/dist/vite/directivesFilteringPlugin.mjs +7 -20
  72. package/dist/vite/directivesPlugin.mjs +87 -44
  73. package/dist/vite/knownDepsResolverPlugin.mjs +36 -29
  74. package/dist/vite/linkerPlugin.mjs +1 -1
  75. package/dist/vite/prismaPlugin.mjs +10 -7
  76. package/dist/vite/redwoodPlugin.mjs +2 -0
  77. package/dist/vite/runDirectivesScan.mjs +1 -1
  78. package/dist/vite/ssrBridgePlugin.mjs +21 -11
  79. package/dist/vite/statePlugin.mjs +15 -8
  80. package/dist/vite/transformJsxScriptTagsPlugin.mjs +3 -3
  81. package/dist/vite/transformJsxScriptTagsPlugin.test.mjs +4 -3
  82. package/dist/vite/vitePreamblePlugin.d.mts +153 -2
  83. package/package.json +35 -18
  84. package/dist/vite/addOptimizeDepsPlugin.d.mts +0 -10
  85. package/dist/vite/addOptimizeDepsPlugin.mjs +0 -6
@@ -0,0 +1,17 @@
1
+ export type RecoveryState = "idle" | "waiting" | "checking" | "reload";
2
+ export type RecoveryController = {
3
+ readonly state: RecoveryState;
4
+ readonly attempts: number;
5
+ readonly elapsedMs: number;
6
+ retry(): void;
7
+ reload(): void;
8
+ };
9
+ export type RecoveryCallback = (controller: RecoveryController) => void | Promise<void>;
10
+ export type RecoveryHandler = "reloadWhenReady" | RecoveryCallback;
11
+ export type RecoveryOptions = {
12
+ onModuleNotFound?: RecoveryHandler;
13
+ };
14
+ export declare function configureRecovery(options: RecoveryOptions): void;
15
+ export declare function isRecoveryConfigured(): boolean;
16
+ export declare function startRecovery(reason: "module-not-found"): void;
17
+ export declare function isDynamicImportFailure(error: unknown): boolean;
@@ -0,0 +1,191 @@
1
+ const DEFAULT_BACKOFF_MS = 1000;
2
+ const MAX_BACKOFF_MS = 30000;
3
+ const FALLBACK_TIMEOUT_MS = 30000;
4
+ const FALLBACK_TIMEOUT_JITTER_MS = 10000;
5
+ const STARTUP_JITTER_MS = 1000;
6
+ let configuredOptions = {};
7
+ let activeController = null;
8
+ function debugLog(...args) {
9
+ if (typeof window !== "undefined" &&
10
+ (window.__RWSDK_DEBUG__ ||
11
+ window.__RWSDK_DEBUG_RECOVERY__)) {
12
+ console.log("[rwsdk:recovery]", ...args);
13
+ }
14
+ }
15
+ function getBackoffMs(attempt) {
16
+ const base = Math.min(DEFAULT_BACKOFF_MS * 2 ** attempt, MAX_BACKOFF_MS);
17
+ const jittered = base * (0.75 + Math.random() * 0.5);
18
+ return Math.round(Math.min(jittered, MAX_BACKOFF_MS));
19
+ }
20
+ function getJitteredFallbackTimeoutMs() {
21
+ return (FALLBACK_TIMEOUT_MS + Math.round(Math.random() * FALLBACK_TIMEOUT_JITTER_MS));
22
+ }
23
+ function normalizeRecoveryUrl(url) {
24
+ const parsed = new URL(url);
25
+ // A trailing dot on the hostname (e.g. example.com.) is a DNS root
26
+ // indicator that some Cloudflare Workers routes treat differently. Strip
27
+ // it so the health check hits the same origin the app uses normally.
28
+ parsed.hostname = parsed.hostname.replace(/\.$/, "");
29
+ // Drop query string and hash: we only care whether the document route
30
+ // itself is ready.
31
+ return `${parsed.origin}${parsed.pathname}`;
32
+ }
33
+ function getCurrentHydrateRootId() {
34
+ if (typeof document === "undefined") {
35
+ return null;
36
+ }
37
+ const root = document.getElementById("hydrate-root");
38
+ return root?.tagName.toLowerCase() ?? null;
39
+ }
40
+ async function checkUrl(url, signal) {
41
+ try {
42
+ const response = await fetch(url, {
43
+ method: "GET",
44
+ cache: "no-store",
45
+ headers: { Accept: "text/html" },
46
+ signal,
47
+ });
48
+ if (response.status !== 200) {
49
+ debugLog("checked", url, "status", response.status, "ok", false);
50
+ return false;
51
+ }
52
+ const contentType = response.headers.get("content-type") ?? "";
53
+ if (!contentType.includes("text/html")) {
54
+ debugLog("checked", url, "content-type", contentType, "ok", false);
55
+ return false;
56
+ }
57
+ const text = await response.text();
58
+ const isHtml = /<!doctype html|<html/i.test(text);
59
+ if (!isHtml) {
60
+ debugLog("checked", url, "not html", "ok", false);
61
+ return false;
62
+ }
63
+ const currentRootId = getCurrentHydrateRootId();
64
+ if (currentRootId) {
65
+ // Match the current hydrate root element. We look for id="hydrate-root"
66
+ // or id='hydrate-root' followed by the same tag name.
67
+ const rootPattern = new RegExp(`id=["']hydrate-root["'][^>]*>\\s*<${currentRootId}\\b`, "i");
68
+ const rootMatches = rootPattern.test(text);
69
+ debugLog("checked", url, "status", 200, "rootId", currentRootId, "rootMatches", rootMatches, "ok", rootMatches);
70
+ return rootMatches;
71
+ }
72
+ debugLog("checked", url, "status", 200, "html", true, "ok", true);
73
+ return true;
74
+ }
75
+ catch (error) {
76
+ debugLog("check failed", url, error);
77
+ return false;
78
+ }
79
+ }
80
+ function createController() {
81
+ let state = "idle";
82
+ let attempts = 0;
83
+ const startedAt = Date.now();
84
+ let wakeResolver = null;
85
+ let abortController = new AbortController();
86
+ const controller = {
87
+ get state() {
88
+ return state;
89
+ },
90
+ get attempts() {
91
+ return attempts;
92
+ },
93
+ get elapsedMs() {
94
+ return Date.now() - startedAt;
95
+ },
96
+ _setState(next) {
97
+ state = next;
98
+ },
99
+ _incAttempts() {
100
+ attempts++;
101
+ },
102
+ retry() {
103
+ wakeResolver?.();
104
+ },
105
+ reload() {
106
+ abortController.abort();
107
+ state = "reload";
108
+ wakeResolver?.();
109
+ if (typeof window !== "undefined") {
110
+ window.location.reload();
111
+ }
112
+ },
113
+ _wait(ms) {
114
+ return new Promise((resolve) => {
115
+ const timeout = setTimeout(resolve, ms);
116
+ wakeResolver = () => {
117
+ clearTimeout(timeout);
118
+ resolve();
119
+ };
120
+ });
121
+ },
122
+ };
123
+ return controller;
124
+ }
125
+ export function configureRecovery(options) {
126
+ configuredOptions = options;
127
+ }
128
+ export function isRecoveryConfigured() {
129
+ return configuredOptions.onModuleNotFound != null;
130
+ }
131
+ export function startRecovery(reason) {
132
+ if (typeof window === "undefined") {
133
+ return;
134
+ }
135
+ debugLog("start", reason);
136
+ if (activeController) {
137
+ debugLog("already recovering, ignoring duplicate");
138
+ return;
139
+ }
140
+ const handler = configuredOptions.onModuleNotFound;
141
+ const controller = createController();
142
+ activeController = controller;
143
+ const run = async () => {
144
+ controller._setState("waiting");
145
+ debugLog("waiting, handler", typeof handler === "function" ? "custom" : handler);
146
+ if (typeof handler === "function") {
147
+ try {
148
+ await handler(controller);
149
+ }
150
+ catch (error) {
151
+ console.error("[rwsdk] recovery callback threw", error);
152
+ }
153
+ }
154
+ const currentUrl = normalizeRecoveryUrl(window.location.href);
155
+ const fallbackTimeoutMs = getJitteredFallbackTimeoutMs();
156
+ const startupJitter = Math.round(Math.random() * STARTUP_JITTER_MS);
157
+ debugLog("startup jitter", startupJitter, "ms");
158
+ await controller._wait(startupJitter);
159
+ while (activeController === controller) {
160
+ controller._setState("checking");
161
+ controller._incAttempts();
162
+ debugLog("checking", currentUrl, "attempt", controller.attempts, "elapsed", controller.elapsedMs);
163
+ const ok = await checkUrl(currentUrl, new AbortController().signal);
164
+ if (ok) {
165
+ debugLog("current route ready, reloading");
166
+ controller.reload();
167
+ return;
168
+ }
169
+ if (controller.elapsedMs >= fallbackTimeoutMs) {
170
+ debugLog("fallback timeout reached, checking /");
171
+ const indexUrl = `${new URL(window.location.href).origin}/`;
172
+ const indexOk = await checkUrl(indexUrl, new AbortController().signal);
173
+ if (indexOk) {
174
+ debugLog("/ ready, navigating to /");
175
+ window.location.href = "/";
176
+ return;
177
+ }
178
+ }
179
+ const backoff = getBackoffMs(controller.attempts);
180
+ debugLog("not ready, backing off", backoff, "ms");
181
+ controller._setState("waiting");
182
+ await controller._wait(backoff);
183
+ }
184
+ };
185
+ void run();
186
+ }
187
+ export function isDynamicImportFailure(error) {
188
+ return (error instanceof TypeError &&
189
+ typeof error.message === "string" &&
190
+ error.message.includes("dynamically imported module"));
191
+ }
@@ -0,0 +1,25 @@
1
+ export declare const HISTORY_STATE_SCROLL_KEY = "__rwsdk_scroll_key";
2
+ export type ScrollPosition = {
3
+ x: number;
4
+ y: number;
5
+ };
6
+ export type PendingScroll = ScrollPosition & {
7
+ behavior: ScrollBehavior;
8
+ };
9
+ export interface NavigationHistoryState extends Record<string, unknown> {
10
+ path?: string;
11
+ scrollX?: number;
12
+ scrollY?: number;
13
+ [HISTORY_STATE_SCROLL_KEY]?: string;
14
+ }
15
+ export interface ScrollRestorationController {
16
+ initialize(): void;
17
+ recordCurrentPosition(x: number, y: number): void;
18
+ flushCurrentPositionToHistoryState(x?: number, y?: number): void;
19
+ pushEntry(href: string, url: URL, initialPosition: ScrollPosition): void;
20
+ replaceEntry(href: string, url: URL, initialPosition: ScrollPosition): void;
21
+ restorePopStateScroll(): void;
22
+ setPendingScroll(pendingScroll: PendingScroll): void;
23
+ applyPendingScroll(): void;
24
+ }
25
+ export declare function createScrollRestoration(): ScrollRestorationController;
@@ -0,0 +1,157 @@
1
+ export const HISTORY_STATE_SCROLL_KEY = "__rwsdk_scroll_key";
2
+ export function createScrollRestoration() {
3
+ const historyEntryKeyPrefix = Math.random().toString(36).slice(2);
4
+ const scrollPositions = new Map();
5
+ let currentHistoryEntryKey = null;
6
+ let nextHistoryEntryKey = 0;
7
+ let pendingScroll = null;
8
+ function createHistoryEntryKey() {
9
+ nextHistoryEntryKey += 1;
10
+ return `${historyEntryKeyPrefix}:${nextHistoryEntryKey}`;
11
+ }
12
+ function readHistoryState() {
13
+ const state = window.history.state;
14
+ return state && typeof state === "object"
15
+ ? { ...state }
16
+ : {};
17
+ }
18
+ function getHistoryEntryKey(state) {
19
+ const key = state[HISTORY_STATE_SCROLL_KEY];
20
+ return typeof key === "string" ? key : null;
21
+ }
22
+ function getScrollPositionFromState(state) {
23
+ if (typeof state.scrollX === "number" ||
24
+ typeof state.scrollY === "number") {
25
+ return {
26
+ x: typeof state.scrollX === "number" ? state.scrollX : 0,
27
+ y: typeof state.scrollY === "number" ? state.scrollY : 0,
28
+ };
29
+ }
30
+ return null;
31
+ }
32
+ function ensureCurrentHistoryEntryKey(state = readHistoryState()) {
33
+ const existingKey = getHistoryEntryKey(state);
34
+ if (existingKey) {
35
+ currentHistoryEntryKey = existingKey;
36
+ return existingKey;
37
+ }
38
+ const historyEntryKey = createHistoryEntryKey();
39
+ currentHistoryEntryKey = historyEntryKey;
40
+ window.history.replaceState({ ...state, [HISTORY_STATE_SCROLL_KEY]: historyEntryKey }, "", window.location.href);
41
+ return historyEntryKey;
42
+ }
43
+ function getCurrentHistoryEntryKeyForReplace(state) {
44
+ const existingKey = getHistoryEntryKey(state) ?? currentHistoryEntryKey;
45
+ if (existingKey) {
46
+ currentHistoryEntryKey = existingKey;
47
+ return existingKey;
48
+ }
49
+ const historyEntryKey = createHistoryEntryKey();
50
+ currentHistoryEntryKey = historyEntryKey;
51
+ return historyEntryKey;
52
+ }
53
+ function getSavedScrollPosition(state) {
54
+ const historyEntryKey = getHistoryEntryKey(state) ?? currentHistoryEntryKey;
55
+ const savedPosition = historyEntryKey
56
+ ? scrollPositions.get(historyEntryKey)
57
+ : undefined;
58
+ return savedPosition ?? getScrollPositionFromState(state);
59
+ }
60
+ function writeHistoryState(state, historyEntryKey, position) {
61
+ window.history.replaceState({
62
+ ...state,
63
+ [HISTORY_STATE_SCROLL_KEY]: historyEntryKey,
64
+ scrollX: position.x,
65
+ scrollY: position.y,
66
+ }, "", window.location.href);
67
+ }
68
+ return {
69
+ initialize() {
70
+ // Take manual control of scroll restoration. With "auto", the browser
71
+ // restores scroll immediately on popstate — before the RSC payload has
72
+ // committed — which causes the old DOM to flash at the new scroll offset.
73
+ if ("scrollRestoration" in window.history) {
74
+ window.history.scrollRestoration = "manual";
75
+ }
76
+ // Boot can happen after a reload, or after an older runtime wrote only
77
+ // scrollX/scrollY. Seed the in-memory store from history.state so the
78
+ // first commit can restore the saved position without per-scroll writes.
79
+ const bootState = readHistoryState();
80
+ const bootHistoryEntryKey = ensureCurrentHistoryEntryKey(bootState);
81
+ const bootScrollPosition = getSavedScrollPosition(bootState);
82
+ if (bootScrollPosition) {
83
+ scrollPositions.set(bootHistoryEntryKey, bootScrollPosition);
84
+ pendingScroll = {
85
+ x: bootScrollPosition.x,
86
+ y: bootScrollPosition.y,
87
+ behavior: "instant",
88
+ };
89
+ }
90
+ },
91
+ recordCurrentPosition(x, y) {
92
+ if (!currentHistoryEntryKey) {
93
+ return;
94
+ }
95
+ scrollPositions.set(currentHistoryEntryKey, { x, y });
96
+ },
97
+ flushCurrentPositionToHistoryState(x = window.scrollX, y = window.scrollY) {
98
+ const state = readHistoryState();
99
+ const historyEntryKey = ensureCurrentHistoryEntryKey(state);
100
+ const position = { x, y };
101
+ scrollPositions.set(historyEntryKey, position);
102
+ writeHistoryState(state, historyEntryKey, position);
103
+ },
104
+ pushEntry(href, url, initialPosition) {
105
+ this.flushCurrentPositionToHistoryState();
106
+ const historyEntryKey = createHistoryEntryKey();
107
+ currentHistoryEntryKey = historyEntryKey;
108
+ scrollPositions.set(historyEntryKey, initialPosition);
109
+ window.history.pushState({
110
+ path: href,
111
+ [HISTORY_STATE_SCROLL_KEY]: historyEntryKey,
112
+ scrollX: initialPosition.x,
113
+ scrollY: initialPosition.y,
114
+ }, "", url);
115
+ },
116
+ replaceEntry(href, url, initialPosition) {
117
+ const state = readHistoryState();
118
+ const historyEntryKey = getCurrentHistoryEntryKeyForReplace(state);
119
+ scrollPositions.set(historyEntryKey, initialPosition);
120
+ window.history.replaceState({
121
+ ...state,
122
+ path: href,
123
+ [HISTORY_STATE_SCROLL_KEY]: historyEntryKey,
124
+ scrollX: initialPosition.x,
125
+ scrollY: initialPosition.y,
126
+ }, "", url);
127
+ },
128
+ restorePopStateScroll() {
129
+ const state = readHistoryState();
130
+ const historyEntryKey = ensureCurrentHistoryEntryKey(state);
131
+ const savedScrollPosition = getSavedScrollPosition(state) ?? {
132
+ x: 0,
133
+ y: 0,
134
+ };
135
+ if (!scrollPositions.has(historyEntryKey)) {
136
+ scrollPositions.set(historyEntryKey, savedScrollPosition);
137
+ }
138
+ pendingScroll = {
139
+ x: savedScrollPosition.x,
140
+ y: savedScrollPosition.y,
141
+ behavior: "instant",
142
+ };
143
+ },
144
+ setPendingScroll(nextPendingScroll) {
145
+ pendingScroll = nextPendingScroll;
146
+ this.recordCurrentPosition(nextPendingScroll.x, nextPendingScroll.y);
147
+ },
148
+ applyPendingScroll() {
149
+ if (!pendingScroll)
150
+ return;
151
+ const { x, y, behavior } = pendingScroll;
152
+ pendingScroll = null;
153
+ window.scrollTo({ top: y, left: x, behavior });
154
+ this.recordCurrentPosition(x, y);
155
+ },
156
+ };
157
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,93 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { createScrollRestoration, HISTORY_STATE_SCROLL_KEY, } from "./scrollRestoration";
3
+ describe("scrollRestoration", () => {
4
+ let historyState;
5
+ beforeEach(() => {
6
+ historyState = {};
7
+ vi.stubGlobal("window", {
8
+ location: { href: "http://localhost/" },
9
+ scrollX: 0,
10
+ scrollY: 0,
11
+ scrollTo: vi.fn(),
12
+ history: {
13
+ scrollRestoration: "auto",
14
+ get state() {
15
+ return historyState;
16
+ },
17
+ pushState: vi.fn((state) => {
18
+ historyState = state;
19
+ }),
20
+ replaceState: vi.fn((state) => {
21
+ historyState = state;
22
+ }),
23
+ },
24
+ });
25
+ });
26
+ it("restores scroll from history state after a reload", () => {
27
+ historyState = {
28
+ [HISTORY_STATE_SCROLL_KEY]: "entry:1",
29
+ scrollX: 12,
30
+ scrollY: 345,
31
+ };
32
+ const scrollRestoration = createScrollRestoration();
33
+ scrollRestoration.initialize();
34
+ scrollRestoration.applyPendingScroll();
35
+ expect(window.scrollTo).toHaveBeenCalledWith({
36
+ left: 12,
37
+ top: 345,
38
+ behavior: "instant",
39
+ });
40
+ });
41
+ it("migrates legacy scrollX/scrollY history state on boot", () => {
42
+ historyState = { scrollX: 7, scrollY: 222 };
43
+ const scrollRestoration = createScrollRestoration();
44
+ scrollRestoration.initialize();
45
+ scrollRestoration.applyPendingScroll();
46
+ expect(window.history.replaceState).toHaveBeenCalledWith(expect.objectContaining({
47
+ [HISTORY_STATE_SCROLL_KEY]: expect.any(String),
48
+ scrollX: 7,
49
+ scrollY: 222,
50
+ }), "", "http://localhost/");
51
+ expect(window.scrollTo).toHaveBeenCalledWith({
52
+ left: 7,
53
+ top: 222,
54
+ behavior: "instant",
55
+ });
56
+ });
57
+ it("restores back/forward positions from the in-memory key map", () => {
58
+ const scrollRestoration = createScrollRestoration();
59
+ scrollRestoration.initialize();
60
+ const firstEntryState = historyState;
61
+ window.scrollY = 500;
62
+ scrollRestoration.recordCurrentPosition(0, 500);
63
+ scrollRestoration.pushEntry("/next", new URL("/next", "http://localhost/"), { x: 0, y: 0 });
64
+ historyState = firstEntryState;
65
+ scrollRestoration.restorePopStateScroll();
66
+ scrollRestoration.applyPendingScroll();
67
+ expect(window.scrollTo).toHaveBeenCalledWith({
68
+ left: 0,
69
+ top: 500,
70
+ behavior: "instant",
71
+ });
72
+ });
73
+ it("does not write to history state while recording scroll", () => {
74
+ const scrollRestoration = createScrollRestoration();
75
+ scrollRestoration.initialize();
76
+ vi.mocked(window.history.replaceState).mockClear();
77
+ scrollRestoration.recordCurrentPosition(0, 100);
78
+ scrollRestoration.recordCurrentPosition(0, 200);
79
+ expect(window.history.replaceState).not.toHaveBeenCalled();
80
+ });
81
+ it("flushes the latest scroll position to history state on lifecycle boundaries", () => {
82
+ const scrollRestoration = createScrollRestoration();
83
+ scrollRestoration.initialize();
84
+ vi.mocked(window.history.replaceState).mockClear();
85
+ scrollRestoration.recordCurrentPosition(4, 400);
86
+ scrollRestoration.flushCurrentPositionToHistoryState(4, 400);
87
+ expect(window.history.replaceState).toHaveBeenCalledWith(expect.objectContaining({
88
+ [HISTORY_STATE_SCROLL_KEY]: expect.any(String),
89
+ scrollX: 4,
90
+ scrollY: 400,
91
+ }), "", "http://localhost/");
92
+ });
93
+ });
@@ -1,5 +1,6 @@
1
1
  import React from "react";
2
2
  import { ClientOnly } from "../client/client";
3
+ import { isDynamicImportFailure, isRecoveryConfigured, startRecovery, } from "../client/recovery.js";
3
4
  import { memoizeOnId } from "../lib/memoizeOnId";
4
5
  // @ts-ignore
5
6
  import { useClientLookup } from "virtual:use-client-lookup.js";
@@ -8,7 +9,18 @@ export const loadModule = memoizeOnId(async (id) => {
8
9
  if (!moduleFn) {
9
10
  throw new Error(`(client) No module found for '${id}' in module lookup for "use client" directive`);
10
11
  }
11
- return await moduleFn();
12
+ try {
13
+ return await moduleFn();
14
+ }
15
+ catch (error) {
16
+ if (isDynamicImportFailure(error) && isRecoveryConfigured()) {
17
+ startRecovery("module-not-found");
18
+ // Stall this import forever so React doesn't crash before the recovery
19
+ // flow reloads the page.
20
+ return new Promise(() => { });
21
+ }
22
+ throw error;
23
+ }
12
24
  });
13
25
  // context(justinvdm, 2 Dec 2024): re memoize(): React relies on the same promise instance being returned for the same id
14
26
  export const clientWebpackRequire = memoizeOnId(async (id) => {
@@ -9,6 +9,7 @@ export type RouteMiddleware<T extends RequestInfo = RequestInfo> = BivariantRout
9
9
  export type ExceptHandler<T extends RequestInfo = RequestInfo> = {
10
10
  __rwExcept: true;
11
11
  handler: (error: unknown, requestInfo: T) => MaybePromise<React.JSX.Element | Response | void>;
12
+ pathPattern?: string;
12
13
  };
13
14
  type RouteFunction<T extends RequestInfo = RequestInfo> = BivariantRouteHandler<T, MaybePromise<Response>>;
14
15
  type RouteComponent<T extends RequestInfo = RequestInfo> = BivariantRouteHandler<T, MaybePromise<React.JSX.Element | Response | void>>;
@@ -185,11 +185,28 @@ export function defineRoutes(routes) {
185
185
  function isExceptHandler(route) {
186
186
  return route.type === "except";
187
187
  }
188
+ function isPathInExceptScope(pathPattern, requestPath) {
189
+ const normalized = pathPattern.endsWith("/")
190
+ ? pathPattern
191
+ : pathPattern + "/";
192
+ const hasParams = normalized.includes(":") || normalized.includes("*");
193
+ if (hasParams) {
194
+ const wildcardPattern = normalized.slice(0, -1) + "/*";
195
+ return (matchPath(wildcardPattern, requestPath) !== null ||
196
+ matchPath(normalized.slice(0, -1), requestPath) !== null);
197
+ }
198
+ return (requestPath === normalized || requestPath.startsWith(normalized));
199
+ }
188
200
  async function executeExceptHandlers(error, startIndex) {
189
201
  // Search backwards from startIndex to find the most recent except handler
190
202
  for (let i = startIndex; i >= 0; i--) {
191
203
  const route = compiledRoutes[i];
192
204
  if (isExceptHandler(route)) {
205
+ const pattern = route.handler.pathPattern;
206
+ if (pattern &&
207
+ !isPathInExceptScope(pattern, getRequestInfo().path)) {
208
+ continue;
209
+ }
193
210
  try {
194
211
  const result = await route.handler.handler(error, getRequestInfo());
195
212
  const handled = await handleMiddlewareResult(result);
@@ -554,8 +571,16 @@ export function prefix(prefixPath, routes) {
554
571
  r !== null &&
555
572
  "__rwExcept" in r &&
556
573
  r.__rwExcept === true) {
557
- // Pass through ExceptHandler as-is
558
- return r;
574
+ const existing = r.pathPattern;
575
+ const combined = existing
576
+ ? joinPaths(normalizedPrefix, existing)
577
+ : normalizedPrefix;
578
+ const scoped = {
579
+ __rwExcept: true,
580
+ handler: r.handler,
581
+ pathPattern: combined,
582
+ };
583
+ return scoped;
559
584
  }
560
585
  if (Array.isArray(r)) {
561
586
  // Recursively process nested route arrays
@@ -1535,5 +1535,101 @@ describe("defineRoutes - Request Handling Behavior", () => {
1535
1535
  expect(response.status).toBe(500);
1536
1536
  expect(await response.text()).toBe(`Caught: ${errorMessage}`);
1537
1537
  });
1538
+ describe("prefix scoping", () => {
1539
+ const makePrefixApp = () => {
1540
+ const globalHandler = except(() => new Response("[GLOBAL]", {
1541
+ status: 500,
1542
+ }));
1543
+ const adminHandler = except(() => new Response("[ADMIN]", {
1544
+ status: 500,
1545
+ }));
1546
+ const router = defineRoutes([
1547
+ globalHandler,
1548
+ ...prefix("/admin", [
1549
+ adminHandler,
1550
+ route("/dashboard/", () => {
1551
+ throw new Error("admin dashboard error");
1552
+ }),
1553
+ ]),
1554
+ route("/", () => {
1555
+ throw new Error("home route error");
1556
+ }),
1557
+ ]);
1558
+ return router;
1559
+ };
1560
+ const handle = async (router, path) => {
1561
+ const deps = createMockDependencies();
1562
+ deps.mockRequestInfo.request = new Request(`http://localhost:3000${path}`);
1563
+ return router.handle({
1564
+ request: new Request(`http://localhost:3000${path}`),
1565
+ renderPage: deps.mockRenderPage,
1566
+ getRequestInfo: deps.getRequestInfo,
1567
+ onError: deps.onError,
1568
+ runWithRequestInfoOverrides: deps.mockRunWithRequestInfoOverrides,
1569
+ rscActionHandler: deps.mockRscActionHandler,
1570
+ });
1571
+ };
1572
+ it("an except inside prefix should NOT catch errors from routes outside the prefix", async () => {
1573
+ const router = makePrefixApp();
1574
+ const response = await handle(router, "/");
1575
+ expect(await response.text()).toBe("[GLOBAL]");
1576
+ });
1577
+ it("an except inside prefix should catch errors from routes inside the prefix", async () => {
1578
+ const router = makePrefixApp();
1579
+ const response = await handle(router, "/admin/dashboard/");
1580
+ expect(await response.text()).toBe("[ADMIN]");
1581
+ });
1582
+ it("an except inside prefix that returns void should bubble to the global handler", async () => {
1583
+ const router = defineRoutes([
1584
+ except(() => new Response("[GLOBAL]", { status: 500 })),
1585
+ ...prefix("/admin", [
1586
+ except(() => undefined),
1587
+ route("/dashboard/", () => {
1588
+ throw new Error("boom");
1589
+ }),
1590
+ ]),
1591
+ ]);
1592
+ const response = await handle(router, "/admin/dashboard/");
1593
+ expect(await response.text()).toBe("[GLOBAL]");
1594
+ });
1595
+ it("nested prefixes compose the scope path", async () => {
1596
+ const router = defineRoutes([
1597
+ except(() => new Response("[GLOBAL]", { status: 500 })),
1598
+ ...prefix("/a", [
1599
+ ...prefix("/b", [
1600
+ except(() => new Response("[A/B]", { status: 500 })),
1601
+ route("/c/", () => {
1602
+ throw new Error("a/b/c error");
1603
+ }),
1604
+ ]),
1605
+ ]),
1606
+ route("/d/", () => {
1607
+ throw new Error("d error");
1608
+ }),
1609
+ ]);
1610
+ const inside = await handle(router, "/a/b/c/");
1611
+ expect(await inside.text()).toBe("[A/B]");
1612
+ const outside = await handle(router, "/d/");
1613
+ expect(await outside.text()).toBe("[GLOBAL]");
1614
+ });
1615
+ it("parameterized prefixes scope the except handler", async () => {
1616
+ const router = defineRoutes([
1617
+ except(() => new Response("[GLOBAL]", { status: 500 })),
1618
+ ...prefix("/users/:id", [
1619
+ except(() => new Response("[USER]", { status: 500 })),
1620
+ route("/profile/", () => {
1621
+ throw new Error("profile error");
1622
+ }),
1623
+ ]),
1624
+ route("/about/", () => {
1625
+ throw new Error("about error");
1626
+ }),
1627
+ ]);
1628
+ const inside = await handle(router, "/users/42/profile/");
1629
+ expect(await inside.text()).toBe("[USER]");
1630
+ const outside = await handle(router, "/about/");
1631
+ expect(await outside.text()).toBe("[GLOBAL]");
1632
+ });
1633
+ });
1538
1634
  });
1539
1635
  });