rwsdk 1.3.0-canary.6 → 1.3.0
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/db/DOSqliteIntrospector.d.ts +8 -0
- package/dist/runtime/lib/db/DOSqliteIntrospector.js +129 -0
- package/dist/runtime/lib/db/DOWorkerDialect.d.ts +3 -2
- package/dist/runtime/lib/db/DOWorkerDialect.js +3 -2
- package/dist/runtime/lib/db/SqliteDurableObject.js +10 -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/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 +27 -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) => {
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type DatabaseMetadata, type DatabaseMetadataOptions, type Kysely, type SchemaMetadata, type TableMetadata } from "kysely";
|
|
2
|
+
export declare class DOSqliteIntrospector {
|
|
3
|
+
#private;
|
|
4
|
+
constructor(db: Kysely<any>);
|
|
5
|
+
getSchemas(): Promise<SchemaMetadata[]>;
|
|
6
|
+
getTables(options?: DatabaseMetadataOptions): Promise<TableMetadata[]>;
|
|
7
|
+
getMetadata(options: DatabaseMetadataOptions): Promise<DatabaseMetadata>;
|
|
8
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// NOTE(justinvdm, 9 Jun 2026): This file copies Kysely's SqliteIntrospector.
|
|
2
|
+
// Before trying to simplify it, read this comment.
|
|
3
|
+
//
|
|
4
|
+
// Problem: Cloudflare's DO SQLite adds internal tables (_cf_KV, _cf_METADATA)
|
|
5
|
+
// when storage.put/setAlarm is used. Kysely's SqliteIntrospector discovers
|
|
6
|
+
// these via sqlite_master, then runs PRAGMA table_info on each. Cloudflare's
|
|
7
|
+
// authorizer rejects PRAGMA on _cf_* tables with SQLITE_AUTH, breaking
|
|
8
|
+
// migrations and rendering the DO unusable.
|
|
9
|
+
//
|
|
10
|
+
// Why we copied instead of composed:
|
|
11
|
+
// - Kysely's SqliteIntrospector uses JS private fields (#db, #tablesQuery,
|
|
12
|
+
// #getTableMetadata). Private fields are truly private — subclasses cannot
|
|
13
|
+
// override them, and wrappers cannot intercept internal calls.
|
|
14
|
+
// - We considered a lighter approach: query sqlite_master with Kysely's
|
|
15
|
+
// builder, then run PRAGMA table_info per-table. This is lighter but
|
|
16
|
+
// requires inlining the table name into raw SQL. Even with escaping,
|
|
17
|
+
// any injection risk is unacceptable. The CTE approach below uses
|
|
18
|
+
// `pragma_table_info(tl.name)` where `tl.name` is a column reference,
|
|
19
|
+
// not an inlined value — zero injection surface.
|
|
20
|
+
// - We also considered Kysely plugins (AST rewriting, SQL string rewriting)
|
|
21
|
+
// but these are fragile: they depend on Kysely's internal AST shape and
|
|
22
|
+
// SQL formatting, both of which can change between releases.
|
|
23
|
+
//
|
|
24
|
+
// This is a copy of Kysely's SqliteIntrospector (MIT licensed) with one
|
|
25
|
+
// change: `.where('name', 'not like', '_cf_%')` to exclude Cloudflare tables.
|
|
26
|
+
//
|
|
27
|
+
// See: https://github.com/redwoodjs/sdk/issues/1219
|
|
28
|
+
var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
|
|
29
|
+
if (kind === "m") throw new TypeError("Private method is not writable");
|
|
30
|
+
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
|
|
31
|
+
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
|
|
32
|
+
return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
|
|
33
|
+
};
|
|
34
|
+
var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
|
|
35
|
+
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
|
|
36
|
+
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
|
|
37
|
+
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
|
|
38
|
+
};
|
|
39
|
+
var _DOSqliteIntrospector_instances, _DOSqliteIntrospector_db, _DOSqliteIntrospector_tablesQuery, _DOSqliteIntrospector_getTableMetadata;
|
|
40
|
+
import { DEFAULT_MIGRATION_LOCK_TABLE, DEFAULT_MIGRATION_TABLE, sql, } from "kysely";
|
|
41
|
+
export class DOSqliteIntrospector {
|
|
42
|
+
constructor(db) {
|
|
43
|
+
_DOSqliteIntrospector_instances.add(this);
|
|
44
|
+
_DOSqliteIntrospector_db.set(this, void 0);
|
|
45
|
+
__classPrivateFieldSet(this, _DOSqliteIntrospector_db, db, "f");
|
|
46
|
+
}
|
|
47
|
+
async getSchemas() {
|
|
48
|
+
// Sqlite doesn't support schemas.
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
async getTables(options = { withInternalKyselyTables: false }) {
|
|
52
|
+
return await __classPrivateFieldGet(this, _DOSqliteIntrospector_instances, "m", _DOSqliteIntrospector_getTableMetadata).call(this, options);
|
|
53
|
+
}
|
|
54
|
+
async getMetadata(options) {
|
|
55
|
+
return {
|
|
56
|
+
tables: await this.getTables(options),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
_DOSqliteIntrospector_db = new WeakMap(), _DOSqliteIntrospector_instances = new WeakSet(), _DOSqliteIntrospector_tablesQuery = function _DOSqliteIntrospector_tablesQuery(qb, options) {
|
|
61
|
+
let tablesQuery = qb
|
|
62
|
+
.selectFrom("sqlite_master")
|
|
63
|
+
.where("type", "in", ["table", "view"])
|
|
64
|
+
.where("name", "not like", "sqlite_%")
|
|
65
|
+
// context(justinvdm, 9 Jun 2026): Exclude Cloudflare internal tables.
|
|
66
|
+
// These are added by the DO runtime and cannot be introspected.
|
|
67
|
+
.where("name", "not like", "_cf_%")
|
|
68
|
+
.select(["name", "sql", "type"])
|
|
69
|
+
.orderBy("name");
|
|
70
|
+
if (!options.withInternalKyselyTables) {
|
|
71
|
+
tablesQuery = tablesQuery
|
|
72
|
+
.where("name", "!=", DEFAULT_MIGRATION_TABLE)
|
|
73
|
+
.where("name", "!=", DEFAULT_MIGRATION_LOCK_TABLE);
|
|
74
|
+
}
|
|
75
|
+
return tablesQuery;
|
|
76
|
+
}, _DOSqliteIntrospector_getTableMetadata = async function _DOSqliteIntrospector_getTableMetadata(options) {
|
|
77
|
+
const tablesResult = await __classPrivateFieldGet(this, _DOSqliteIntrospector_instances, "m", _DOSqliteIntrospector_tablesQuery).call(this, __classPrivateFieldGet(this, _DOSqliteIntrospector_db, "f"), options).execute();
|
|
78
|
+
const tableMetadata = await __classPrivateFieldGet(this, _DOSqliteIntrospector_db, "f")
|
|
79
|
+
.with("table_list", (qb) => __classPrivateFieldGet(this, _DOSqliteIntrospector_instances, "m", _DOSqliteIntrospector_tablesQuery).call(this, qb, options))
|
|
80
|
+
.selectFrom([
|
|
81
|
+
"table_list as tl",
|
|
82
|
+
sql `pragma_table_info(tl.name)`.as("p"),
|
|
83
|
+
])
|
|
84
|
+
.select([
|
|
85
|
+
"tl.name as table",
|
|
86
|
+
"p.cid",
|
|
87
|
+
"p.name",
|
|
88
|
+
"p.type",
|
|
89
|
+
"p.notnull",
|
|
90
|
+
"p.dflt_value",
|
|
91
|
+
"p.pk",
|
|
92
|
+
])
|
|
93
|
+
.orderBy("tl.name")
|
|
94
|
+
.orderBy("p.cid")
|
|
95
|
+
.execute();
|
|
96
|
+
const columnsByTable = {};
|
|
97
|
+
for (const row of tableMetadata) {
|
|
98
|
+
columnsByTable[row.table] ??= [];
|
|
99
|
+
columnsByTable[row.table].push(row);
|
|
100
|
+
}
|
|
101
|
+
return tablesResult.map(({ name, sql: tableSql, type }) => {
|
|
102
|
+
let autoIncrementCol = tableSql
|
|
103
|
+
?.split(/[\(\),]/)
|
|
104
|
+
?.find((it) => it.toLowerCase().includes("autoincrement"))
|
|
105
|
+
?.trimStart()
|
|
106
|
+
?.split(/\s+/)?.[0]
|
|
107
|
+
?.replace(/["`]/g, "");
|
|
108
|
+
const columns = columnsByTable[name] ?? [];
|
|
109
|
+
if (!autoIncrementCol) {
|
|
110
|
+
const pkCols = columns.filter((r) => r.pk > 0);
|
|
111
|
+
if (pkCols.length === 1 &&
|
|
112
|
+
pkCols[0].type.toLowerCase() === "integer") {
|
|
113
|
+
autoIncrementCol = pkCols[0].name;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
name: name,
|
|
118
|
+
isView: type === "view",
|
|
119
|
+
columns: columns.map((col) => ({
|
|
120
|
+
name: col.name,
|
|
121
|
+
dataType: col.type,
|
|
122
|
+
isNullable: !col.notnull,
|
|
123
|
+
isAutoIncrementing: col.name === autoIncrementCol,
|
|
124
|
+
hasDefaultValue: col.dflt_value != null,
|
|
125
|
+
comment: undefined,
|
|
126
|
+
})),
|
|
127
|
+
};
|
|
128
|
+
});
|
|
129
|
+
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { DatabaseConnection, Driver, QueryResult, SqliteAdapter,
|
|
1
|
+
import { DatabaseConnection, Driver, QueryResult, SqliteAdapter, SqliteQueryCompiler } from "kysely";
|
|
2
|
+
import { DOSqliteIntrospector } from "./DOSqliteIntrospector.js";
|
|
2
3
|
type DOWorkerDialectConfig = {
|
|
3
4
|
kyselyExecuteQuery: (compiledQuery: {
|
|
4
5
|
sql: string;
|
|
@@ -11,7 +12,7 @@ export declare class DOWorkerDialect {
|
|
|
11
12
|
createAdapter(): SqliteAdapter;
|
|
12
13
|
createDriver(): DOWorkerDriver;
|
|
13
14
|
createQueryCompiler(): SqliteQueryCompiler;
|
|
14
|
-
createIntrospector(db: any):
|
|
15
|
+
createIntrospector(db: any): DOSqliteIntrospector;
|
|
15
16
|
}
|
|
16
17
|
declare class DOWorkerDriver implements Driver {
|
|
17
18
|
config: DOWorkerDialectConfig;
|