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.
- package/dist/lib/e2e/release.d.mts +2 -1
- package/dist/lib/e2e/release.mjs +70 -5
- package/dist/lib/e2e/testHarness.d.mts +1 -0
- package/dist/lib/e2e/testHarness.mjs +51 -2
- package/dist/runtime/client/client.d.ts +3 -2
- package/dist/runtime/client/client.js +5 -1
- package/dist/runtime/client/navigation.js +42 -61
- package/dist/runtime/client/navigation.test.js +166 -18
- package/dist/runtime/client/recovery.d.ts +17 -0
- package/dist/runtime/client/recovery.js +191 -0
- package/dist/runtime/client/scrollRestoration.d.ts +25 -0
- package/dist/runtime/client/scrollRestoration.js +157 -0
- package/dist/runtime/client/scrollRestoration.test.d.ts +1 -0
- package/dist/runtime/client/scrollRestoration.test.js +93 -0
- package/dist/runtime/imports/client.js +13 -1
- package/dist/runtime/lib/router.d.ts +1 -0
- package/dist/runtime/lib/router.js +27 -2
- package/dist/runtime/lib/router.test.js +96 -0
- package/dist/runtime/render/assembleDocument.js +1 -1
- package/dist/runtime/render/renderDocumentHtmlStream.js +1 -1
- package/dist/runtime/render/stylesheets.js +0 -22
- package/dist/use-synced-state/__tests__/SyncStateServer.test.mjs +14 -7
- package/dist/use-synced-state/__tests__/client-core.test.js +18 -223
- package/dist/use-synced-state/__tests__/worker.test.mjs +41 -2
- package/dist/use-synced-state/client-core.js +20 -10
- package/dist/use-synced-state/hibernation/__tests__/client-core.test.d.ts +1 -0
- package/dist/use-synced-state/hibernation/__tests__/client-core.test.js +244 -0
- package/dist/use-synced-state/hibernation/__tests__/server.test.d.mts +1 -0
- package/dist/use-synced-state/hibernation/__tests__/server.test.mjs +165 -0
- package/dist/use-synced-state/hibernation/client-core.d.ts +19 -0
- package/dist/use-synced-state/hibernation/client-core.js +38 -0
- package/dist/use-synced-state/hibernation/client.d.ts +3 -0
- package/dist/use-synced-state/hibernation/client.js +4 -0
- package/dist/use-synced-state/hibernation/connection/connection.d.ts +2 -0
- package/dist/use-synced-state/hibernation/connection/connection.js +72 -0
- package/dist/use-synced-state/hibernation/connection/messages.d.ts +6 -0
- package/dist/use-synced-state/hibernation/connection/messages.js +64 -0
- package/dist/use-synced-state/hibernation/connection/timer.d.ts +5 -0
- package/dist/use-synced-state/hibernation/connection/timer.js +33 -0
- package/dist/use-synced-state/hibernation/connection/types.d.ts +22 -0
- package/dist/use-synced-state/hibernation/connection/types.js +1 -0
- package/dist/use-synced-state/hibernation/identity.d.mts +4 -0
- package/dist/use-synced-state/hibernation/identity.mjs +25 -0
- package/dist/use-synced-state/hibernation/protocol.d.mts +53 -0
- package/dist/use-synced-state/hibernation/protocol.mjs +55 -0
- package/dist/use-synced-state/hibernation/reconnect/backoff.d.ts +1 -0
- package/dist/use-synced-state/hibernation/reconnect/backoff.js +7 -0
- package/dist/use-synced-state/hibernation/reconnect/reconnect.d.ts +8 -0
- package/dist/use-synced-state/hibernation/reconnect/reconnect.js +34 -0
- package/dist/use-synced-state/hibernation/server.d.mts +62 -0
- package/dist/use-synced-state/hibernation/server.mjs +385 -0
- package/dist/use-synced-state/hibernation/state/clientFactory.d.ts +2 -0
- package/dist/use-synced-state/hibernation/state/clientFactory.js +63 -0
- package/dist/use-synced-state/hibernation/state/clientManager.d.ts +46 -0
- package/dist/use-synced-state/hibernation/state/clientManager.js +151 -0
- package/dist/use-synced-state/hibernation/useSyncedState.d.ts +22 -0
- package/dist/use-synced-state/hibernation/useSyncedState.js +64 -0
- package/dist/use-synced-state/hibernation/worker.d.mts +8 -0
- package/dist/use-synced-state/hibernation/worker.mjs +50 -0
- package/dist/use-synced-state/shim/__tests__/worker.test.d.mts +1 -0
- package/dist/use-synced-state/shim/__tests__/worker.test.mjs +37 -0
- package/dist/use-synced-state/shim/client.d.ts +2 -0
- package/dist/use-synced-state/shim/client.js +2 -0
- package/dist/use-synced-state/shim/worker.d.mts +2 -0
- package/dist/use-synced-state/shim/worker.mjs +139 -0
- package/dist/use-synced-state/worker.mjs +34 -3
- package/dist/vite/buildApp.mjs +15 -54
- package/dist/vite/configPlugin.mjs +20 -27
- package/dist/vite/createDirectiveLookupPlugin.mjs +15 -11
- package/dist/vite/directiveModulesDevPlugin.mjs +44 -28
- package/dist/vite/directivesFilteringPlugin.mjs +7 -20
- package/dist/vite/directivesPlugin.mjs +87 -44
- package/dist/vite/knownDepsResolverPlugin.mjs +36 -29
- package/dist/vite/linkerPlugin.mjs +1 -1
- package/dist/vite/prismaPlugin.mjs +10 -7
- package/dist/vite/redwoodPlugin.mjs +2 -0
- package/dist/vite/runDirectivesScan.mjs +1 -1
- package/dist/vite/ssrBridgePlugin.mjs +21 -11
- package/dist/vite/statePlugin.mjs +15 -8
- package/dist/vite/transformJsxScriptTagsPlugin.mjs +3 -3
- package/dist/vite/transformJsxScriptTagsPlugin.test.mjs +4 -3
- package/dist/vite/vitePreamblePlugin.d.mts +153 -2
- package/package.json +35 -18
- package/dist/vite/addOptimizeDepsPlugin.d.mts +0 -10
- 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
|
-
|
|
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
|
-
|
|
558
|
-
|
|
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
|
});
|