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
|
@@ -39,9 +39,10 @@ export declare function runRelease(cwd: string, projectDir: string, resourceUniq
|
|
|
39
39
|
/**
|
|
40
40
|
* Run a local production preview server (build + preview) and return the URL.
|
|
41
41
|
*/
|
|
42
|
-
export declare function runPreviewServer(packageManager?: string, cwd?: string): Promise<{
|
|
42
|
+
export declare function runPreviewServer(packageManager?: string, cwd?: string, preferredPort?: number): Promise<{
|
|
43
43
|
url: string;
|
|
44
44
|
stopPreview: () => Promise<void>;
|
|
45
|
+
port: number;
|
|
45
46
|
}>;
|
|
46
47
|
/**
|
|
47
48
|
* Check if a resource name includes a specific resource unique key
|
package/dist/lib/e2e/release.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { execaCommand } from "execa";
|
|
|
3
3
|
import { existsSync, readFileSync } from "fs";
|
|
4
4
|
import { pathExists } from "fs-extra";
|
|
5
5
|
import * as fs from "fs/promises";
|
|
6
|
+
import { createConnection } from "node:net";
|
|
6
7
|
import { parse as parseJsonc } from "jsonc-parser";
|
|
7
8
|
import { setTimeout } from "node:timers/promises";
|
|
8
9
|
import { basename, dirname, join, relative, resolve } from "path";
|
|
@@ -11,6 +12,42 @@ import { checkServerUp } from "./browser.mjs";
|
|
|
11
12
|
import { extractLastJson, parseJson } from "../../lib/jsonUtils.mjs";
|
|
12
13
|
import { IS_DEBUG_MODE, PREVIEW_SERVER_TIMEOUT } from "./constants.mjs";
|
|
13
14
|
const log = debug("rwsdk:e2e:release");
|
|
15
|
+
function isPortFree(port, host = "localhost") {
|
|
16
|
+
return new Promise((resolve) => {
|
|
17
|
+
const socket = createConnection(port, host);
|
|
18
|
+
let settled = false;
|
|
19
|
+
const finish = (value) => {
|
|
20
|
+
if (settled)
|
|
21
|
+
return;
|
|
22
|
+
settled = true;
|
|
23
|
+
socket.destroy();
|
|
24
|
+
resolve(value);
|
|
25
|
+
};
|
|
26
|
+
socket.once("connect", () => finish(false));
|
|
27
|
+
socket.once("error", (err) => {
|
|
28
|
+
finish(err.code === "ECONNREFUSED");
|
|
29
|
+
});
|
|
30
|
+
socket.setTimeout(300, () => finish(true));
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
async function findFreePort(startingAt = 4173) {
|
|
34
|
+
for (let port = startingAt; port < startingAt + 100; port++) {
|
|
35
|
+
if (await isPortFree(port)) {
|
|
36
|
+
return port;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
throw new Error(`Could not find a free port between ${startingAt} and ${startingAt + 99}`);
|
|
40
|
+
}
|
|
41
|
+
async function waitForPortFree(port, timeoutMs = 10000) {
|
|
42
|
+
const start = Date.now();
|
|
43
|
+
while (Date.now() - start < timeoutMs) {
|
|
44
|
+
if (await isPortFree(port)) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
await setTimeout(200);
|
|
48
|
+
}
|
|
49
|
+
throw new Error(`Port ${port} did not become free within ${timeoutMs}ms`);
|
|
50
|
+
}
|
|
14
51
|
/**
|
|
15
52
|
* Find wrangler cache by searching up the directory tree for node_modules/.cache/wrangler
|
|
16
53
|
*/
|
|
@@ -404,7 +441,7 @@ export async function runRelease(cwd, projectDir, resourceUniqueKey) {
|
|
|
404
441
|
/**
|
|
405
442
|
* Run a local production preview server (build + preview) and return the URL.
|
|
406
443
|
*/
|
|
407
|
-
export async function runPreviewServer(packageManager = "pnpm", cwd) {
|
|
444
|
+
export async function runPreviewServer(packageManager = "pnpm", cwd, preferredPort) {
|
|
408
445
|
console.log("🚀 Building for production preview...");
|
|
409
446
|
const pm = packageManager === "yarn-classic" ? "yarn" : packageManager;
|
|
410
447
|
await $(pm, ["run", "build"], {
|
|
@@ -413,6 +450,29 @@ export async function runPreviewServer(packageManager = "pnpm", cwd) {
|
|
|
413
450
|
env: { ...process.env, NODE_ENV: "production" },
|
|
414
451
|
});
|
|
415
452
|
console.log("✅ Build complete. Starting preview server...");
|
|
453
|
+
// Pick a port. If the caller asked for a specific port (e.g. a redeploy
|
|
454
|
+
// wants to reuse the previous port), try that first and wait briefly for
|
|
455
|
+
// the old process to release it. If it is still occupied, fall back to a
|
|
456
|
+
// free port rather than failing — deterministic port assignment can collide
|
|
457
|
+
// between different playground test files.
|
|
458
|
+
let port;
|
|
459
|
+
if (preferredPort != null) {
|
|
460
|
+
if (await isPortFree(preferredPort)) {
|
|
461
|
+
port = preferredPort;
|
|
462
|
+
}
|
|
463
|
+
else {
|
|
464
|
+
try {
|
|
465
|
+
await waitForPortFree(preferredPort, 3000);
|
|
466
|
+
port = preferredPort;
|
|
467
|
+
}
|
|
468
|
+
catch {
|
|
469
|
+
port = await findFreePort(preferredPort + 1);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
else {
|
|
474
|
+
port = await findFreePort(4173);
|
|
475
|
+
}
|
|
416
476
|
let previewProcess = null;
|
|
417
477
|
let isErrorExpected = false;
|
|
418
478
|
const stopPreview = async () => {
|
|
@@ -430,7 +490,7 @@ export async function runPreviewServer(packageManager = "pnpm", cwd) {
|
|
|
430
490
|
await previewProcess.catch(() => { });
|
|
431
491
|
console.log("Preview server stopped");
|
|
432
492
|
};
|
|
433
|
-
previewProcess = $(pm, ["run", "preview", "--",
|
|
493
|
+
previewProcess = $(pm, ["run", "preview", "--port", String(port), "--strictPort"], {
|
|
434
494
|
all: true,
|
|
435
495
|
detached: process.platform !== "win32",
|
|
436
496
|
cleanup: true,
|
|
@@ -439,6 +499,11 @@ export async function runPreviewServer(packageManager = "pnpm", cwd) {
|
|
|
439
499
|
env: { ...process.env, NODE_ENV: "production" },
|
|
440
500
|
stdio: "pipe",
|
|
441
501
|
});
|
|
502
|
+
previewProcess.all?.on("data", (data) => {
|
|
503
|
+
if (IS_DEBUG_MODE) {
|
|
504
|
+
process.stdout.write(data.toString());
|
|
505
|
+
}
|
|
506
|
+
});
|
|
442
507
|
previewProcess.catch((error) => {
|
|
443
508
|
if (!isErrorExpected) {
|
|
444
509
|
log("Preview server process exited unexpectedly: %O", error);
|
|
@@ -471,10 +536,10 @@ export async function runPreviewServer(packageManager = "pnpm", cwd) {
|
|
|
471
536
|
// context(justinvdm, 2026-05-13): Give the CI preview path the same
|
|
472
537
|
// readiness budget as the dev server so local agent-ci runs can absorb build
|
|
473
538
|
// and startup latency without falling back to Cloudflare.
|
|
474
|
-
const
|
|
475
|
-
|
|
539
|
+
const serverUrl = `http://localhost:${port}`;
|
|
540
|
+
await checkServerUp(serverUrl, "/__debug", Math.max(1, Math.ceil(PREVIEW_SERVER_TIMEOUT / 2000)), false);
|
|
476
541
|
console.log(`✅ Preview server started at ${serverUrl}`);
|
|
477
|
-
return { url: serverUrl, stopPreview };
|
|
542
|
+
return { url: serverUrl, stopPreview, port };
|
|
478
543
|
}
|
|
479
544
|
/**
|
|
480
545
|
* Check if a resource name includes a specific resource unique key
|
|
@@ -14,6 +14,7 @@ interface DeploymentInstance {
|
|
|
14
14
|
resourceUniqueKey: string;
|
|
15
15
|
projectDir: string;
|
|
16
16
|
cleanup: () => Promise<void>;
|
|
17
|
+
redeploy: () => Promise<void>;
|
|
17
18
|
}
|
|
18
19
|
export declare const SKIP_DEV_SERVER_TESTS: boolean;
|
|
19
20
|
export declare const SKIP_DEPLOYMENT_TESTS: boolean;
|
|
@@ -11,6 +11,17 @@ import { deleteD1Database, deleteWorker, isRelatedToTest, runPreviewServer, runR
|
|
|
11
11
|
import { setupTarballEnvironment } from "./tarball.mjs";
|
|
12
12
|
import { ensureTmpDir } from "./utils.mjs";
|
|
13
13
|
export { DEPLOYMENT_CHECK_TIMEOUT, DEPLOYMENT_MIN_TRIES, DEPLOYMENT_TIMEOUT, DEV_SERVER_MIN_TRIES, DEV_SERVER_TIMEOUT, HYDRATION_TIMEOUT, INSTALL_DEPENDENCIES_RETRIES, PUPPETEER_TIMEOUT, SETUP_PLAYGROUND_ENV_TIMEOUT, SETUP_WAIT_TIMEOUT, TEST_MAX_RETRIES, TEST_MAX_RETRIES_PER_CODE, };
|
|
14
|
+
function getProjectBasePort(projectDir) {
|
|
15
|
+
// Spread concurrent playground previews across a wide port range so
|
|
16
|
+
// different playgrounds are unlikely to collide. Each playground still
|
|
17
|
+
// reuses the same port for redeploys within a single test file.
|
|
18
|
+
const name = basename(projectDir);
|
|
19
|
+
let hash = 0;
|
|
20
|
+
for (let i = 0; i < name.length; i++) {
|
|
21
|
+
hash = (hash * 31 + name.charCodeAt(i)) >>> 0;
|
|
22
|
+
}
|
|
23
|
+
return 4173 + (hash % 1000);
|
|
24
|
+
}
|
|
14
25
|
// Environment variable flags for skipping tests
|
|
15
26
|
export const SKIP_DEV_SERVER_TESTS = process.env.RWSDK_SKIP_DEV === "1";
|
|
16
27
|
export const SKIP_DEPLOYMENT_TESTS = process.env.RWSDK_SKIP_DEPLOY === "1";
|
|
@@ -212,7 +223,41 @@ export function createDeployment() {
|
|
|
212
223
|
throw new Error("Deploy playground environment not initialized. Enable `deploy: true` in setupPlaygroundEnvironment.");
|
|
213
224
|
}
|
|
214
225
|
const { projectDir } = globalDeployPlaygroundEnv;
|
|
226
|
+
const packageManager = process.env.PACKAGE_MANAGER || "pnpm";
|
|
215
227
|
let instance = null;
|
|
228
|
+
let previewStop = null;
|
|
229
|
+
let previewPort = null;
|
|
230
|
+
const makeRedeploy = () => async () => {
|
|
231
|
+
if (!instance) {
|
|
232
|
+
throw new Error("Deployment not started");
|
|
233
|
+
}
|
|
234
|
+
if (IS_PULL_REQUEST) {
|
|
235
|
+
if (!previewStop || previewPort == null) {
|
|
236
|
+
throw new Error("No preview server to redeploy");
|
|
237
|
+
}
|
|
238
|
+
await previewStop();
|
|
239
|
+
const newPreview = await runPreviewServer(packageManager, projectDir, previewPort);
|
|
240
|
+
previewStop = newPreview.stopPreview;
|
|
241
|
+
previewPort = newPreview.port;
|
|
242
|
+
instance.url = newPreview.url;
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const deployResult = await runRelease(projectDir, projectDir, instance.resourceUniqueKey);
|
|
246
|
+
await poll(async () => {
|
|
247
|
+
try {
|
|
248
|
+
const response = await fetch(deployResult.url);
|
|
249
|
+
const body = await response.text();
|
|
250
|
+
return body.includes("__RWSDK_CONTEXT");
|
|
251
|
+
}
|
|
252
|
+
catch (e) {
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
}, {
|
|
256
|
+
timeout: DEPLOYMENT_CHECK_TIMEOUT,
|
|
257
|
+
});
|
|
258
|
+
instance.url = deployResult.url;
|
|
259
|
+
instance.workerName = deployResult.workerName;
|
|
260
|
+
};
|
|
216
261
|
return {
|
|
217
262
|
projectDir,
|
|
218
263
|
start: async () => {
|
|
@@ -231,8 +276,10 @@ export function createDeployment() {
|
|
|
231
276
|
: Math.random().toString(36).substring(2, 15);
|
|
232
277
|
if (IS_PULL_REQUEST) {
|
|
233
278
|
console.log("PR mode detected — using local preview instead of deploy");
|
|
234
|
-
const
|
|
235
|
-
|
|
279
|
+
const basePort = getProjectBasePort(projectDir);
|
|
280
|
+
const previewResult = await runPreviewServer(packageManager, projectDir, basePort);
|
|
281
|
+
previewStop = previewResult.stopPreview;
|
|
282
|
+
previewPort = previewResult.port;
|
|
236
283
|
instance = {
|
|
237
284
|
url: previewResult.url,
|
|
238
285
|
workerName: `preview-${resourceUniqueKey}`,
|
|
@@ -244,6 +291,7 @@ export function createDeployment() {
|
|
|
244
291
|
});
|
|
245
292
|
return Promise.resolve();
|
|
246
293
|
},
|
|
294
|
+
redeploy: makeRedeploy(),
|
|
247
295
|
};
|
|
248
296
|
deploymentInstances.push(instance);
|
|
249
297
|
return instance;
|
|
@@ -284,6 +332,7 @@ export function createDeployment() {
|
|
|
284
332
|
resourceUniqueKey,
|
|
285
333
|
projectDir: projectDir,
|
|
286
334
|
cleanup,
|
|
335
|
+
redeploy: makeRedeploy(),
|
|
287
336
|
};
|
|
288
337
|
}, {
|
|
289
338
|
timeout: DEPLOYMENT_TIMEOUT,
|
|
@@ -4,6 +4,7 @@ export type { Dispatch, MutableRefObject, SetStateAction } from "react";
|
|
|
4
4
|
export { ClientOnly } from "./ClientOnly.js";
|
|
5
5
|
export { initClientNavigation, navigate } from "./navigation.js";
|
|
6
6
|
export type { ActionResponseData } from "./types";
|
|
7
|
+
import { type RecoveryOptions } from "./recovery.js";
|
|
7
8
|
import type { ActionResponseData, HydrationOptions, Transport } from "./types";
|
|
8
9
|
export declare const fetchTransport: Transport;
|
|
9
10
|
/**
|
|
@@ -60,10 +61,10 @@ export declare const fetchTransport: Transport;
|
|
|
60
61
|
* },
|
|
61
62
|
* });
|
|
62
63
|
*/
|
|
63
|
-
export declare const initClient: ({ transport, hydrateRootOptions, handleResponse, onHydrated, onActionResponse, }?: {
|
|
64
|
+
export declare const initClient: ({ transport, hydrateRootOptions, handleResponse, onHydrated, onActionResponse, onModuleNotFound, }?: {
|
|
64
65
|
transport?: Transport;
|
|
65
66
|
hydrateRootOptions?: HydrationOptions;
|
|
66
67
|
handleResponse?: (response: Response) => boolean;
|
|
67
68
|
onHydrated?: () => void;
|
|
68
69
|
onActionResponse?: (actionResponse: ActionResponseData) => boolean | void;
|
|
69
|
-
}) => Promise<void>;
|
|
70
|
+
} & RecoveryOptions) => Promise<void>;
|
|
@@ -14,6 +14,7 @@ export { default as React } from "react";
|
|
|
14
14
|
export { ClientOnly } from "./ClientOnly.js";
|
|
15
15
|
export { initClientNavigation, navigate } from "./navigation.js";
|
|
16
16
|
import { getCachedNavigationResponse } from "./navigationCache.js";
|
|
17
|
+
import { configureRecovery } from "./recovery.js";
|
|
17
18
|
import { isActionResponse } from "./types";
|
|
18
19
|
export const fetchTransport = (transportContext) => {
|
|
19
20
|
const fetchCallServer = async (id, args, source = "action", method = "POST") => {
|
|
@@ -172,7 +173,10 @@ export const fetchTransport = (transportContext) => {
|
|
|
172
173
|
* },
|
|
173
174
|
* });
|
|
174
175
|
*/
|
|
175
|
-
export const initClient = async ({ transport = fetchTransport, hydrateRootOptions, handleResponse, onHydrated, onActionResponse, } = {}) => {
|
|
176
|
+
export const initClient = async ({ transport = fetchTransport, hydrateRootOptions, handleResponse, onHydrated, onActionResponse, onModuleNotFound, } = {}) => {
|
|
177
|
+
if (onModuleNotFound) {
|
|
178
|
+
configureRecovery({ onModuleNotFound });
|
|
179
|
+
}
|
|
176
180
|
const transportContext = {
|
|
177
181
|
setRscPayload: () => { },
|
|
178
182
|
handleResponse,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { onNavigationCommit, preloadFromLinkTags, } from "./navigationCache.js";
|
|
2
|
+
import { createScrollRestoration, } from "./scrollRestoration.js";
|
|
2
3
|
export function validateClickEvent(event, target) {
|
|
3
4
|
// should this only work for left click?
|
|
4
5
|
if (event.button !== 0) {
|
|
@@ -32,10 +33,7 @@ export function validateClickEvent(event, target) {
|
|
|
32
33
|
return true;
|
|
33
34
|
}
|
|
34
35
|
let IS_CLIENT_NAVIGATION = false;
|
|
35
|
-
|
|
36
|
-
// onHydrated, so the new scroll position aligns with the new DOM rather
|
|
37
|
-
// than flashing on top of the old one.
|
|
38
|
-
let pendingScroll = null;
|
|
36
|
+
let scrollRestoration = null;
|
|
39
37
|
let currentPathKey = null;
|
|
40
38
|
function getLocationPathKey() {
|
|
41
39
|
return `${window.location.pathname ?? ""}${window.location.search ?? ""}`;
|
|
@@ -48,39 +46,32 @@ export async function navigate(href, options = { history: "push" }) {
|
|
|
48
46
|
window.location.href = href;
|
|
49
47
|
return;
|
|
50
48
|
}
|
|
51
|
-
saveScrollPosition(window.scrollX, window.scrollY);
|
|
52
49
|
const url = new URL(href, window.location.href);
|
|
50
|
+
const scrollToTop = options.info?.scrollToTop ?? true;
|
|
51
|
+
const scrollBehavior = (options.info?.scrollBehavior ??
|
|
52
|
+
"instant");
|
|
53
|
+
const nextScrollPosition = scrollToTop
|
|
54
|
+
? { x: 0, y: 0 }
|
|
55
|
+
: { x: window.scrollX, y: window.scrollY };
|
|
53
56
|
if (options.history === "push") {
|
|
54
|
-
|
|
57
|
+
scrollRestoration?.pushEntry(href, url, nextScrollPosition);
|
|
55
58
|
}
|
|
56
59
|
else {
|
|
57
|
-
|
|
60
|
+
scrollRestoration?.replaceEntry(href, url, nextScrollPosition);
|
|
58
61
|
}
|
|
59
62
|
currentPathKey = getUrlPathKey(url);
|
|
60
|
-
const scrollToTop = options.info?.scrollToTop ?? true;
|
|
61
|
-
const scrollBehavior = (options.info?.scrollBehavior ??
|
|
62
|
-
"instant");
|
|
63
63
|
if (scrollToTop) {
|
|
64
|
-
|
|
64
|
+
scrollRestoration?.setPendingScroll({
|
|
65
|
+
...nextScrollPosition,
|
|
66
|
+
behavior: scrollBehavior,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
scrollRestoration?.recordCurrentPosition(window.scrollX, window.scrollY);
|
|
65
71
|
}
|
|
66
72
|
await options.onNavigate?.();
|
|
67
73
|
await globalThis.__rsc_callServer(null, null, "navigation");
|
|
68
74
|
}
|
|
69
|
-
function saveScrollPosition(x, y) {
|
|
70
|
-
window.history.replaceState({
|
|
71
|
-
...window.history.state,
|
|
72
|
-
scrollX: x,
|
|
73
|
-
scrollY: y,
|
|
74
|
-
}, "", window.location.href);
|
|
75
|
-
}
|
|
76
|
-
function applyPendingScroll() {
|
|
77
|
-
if (!pendingScroll)
|
|
78
|
-
return;
|
|
79
|
-
const { x, y, behavior } = pendingScroll;
|
|
80
|
-
pendingScroll = null;
|
|
81
|
-
window.scrollTo({ top: y, left: x, behavior });
|
|
82
|
-
saveScrollPosition(x, y);
|
|
83
|
-
}
|
|
84
75
|
/**
|
|
85
76
|
* Initializes client-side navigation for Single Page App (SPA) behavior.
|
|
86
77
|
*
|
|
@@ -124,23 +115,8 @@ function applyPendingScroll() {
|
|
|
124
115
|
*/
|
|
125
116
|
export function initClientNavigation(opts = {}) {
|
|
126
117
|
IS_CLIENT_NAVIGATION = true;
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
// committed — which causes the old DOM to flash at the new scroll offset.
|
|
130
|
-
history.scrollRestoration = "manual";
|
|
131
|
-
// If we're booting onto an entry that already has a saved scroll (e.g.
|
|
132
|
-
// a reload after scrolling, or a back-forward cache restore), queue that
|
|
133
|
-
// position so the first commit lands us where the user left off.
|
|
134
|
-
const bootState = window.history.state;
|
|
135
|
-
if (bootState &&
|
|
136
|
-
(typeof bootState.scrollX === "number" ||
|
|
137
|
-
typeof bootState.scrollY === "number")) {
|
|
138
|
-
pendingScroll = {
|
|
139
|
-
x: bootState.scrollX ?? 0,
|
|
140
|
-
y: bootState.scrollY ?? 0,
|
|
141
|
-
behavior: "instant",
|
|
142
|
-
};
|
|
143
|
-
}
|
|
118
|
+
scrollRestoration = createScrollRestoration();
|
|
119
|
+
scrollRestoration.initialize();
|
|
144
120
|
currentPathKey = getLocationPathKey();
|
|
145
121
|
document.addEventListener("click", async function handleClickEvent(event) {
|
|
146
122
|
if (!validateClickEvent(event, event.target)) {
|
|
@@ -159,28 +135,33 @@ export function initClientNavigation(opts = {}) {
|
|
|
159
135
|
if (isHashOnlyChange) {
|
|
160
136
|
return;
|
|
161
137
|
}
|
|
162
|
-
|
|
163
|
-
pendingScroll = {
|
|
164
|
-
x: typeof state.scrollX === "number" ? state.scrollX : 0,
|
|
165
|
-
y: typeof state.scrollY === "number" ? state.scrollY : 0,
|
|
166
|
-
behavior: "instant",
|
|
167
|
-
};
|
|
138
|
+
scrollRestoration?.restorePopStateScroll();
|
|
168
139
|
await opts.onNavigate?.();
|
|
169
140
|
await globalThis.__rsc_callServer(null, null, "navigation");
|
|
170
141
|
});
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
let scrollSaveScheduled = false;
|
|
142
|
+
// Track the user's scroll position in memory so back/forward navigation can
|
|
143
|
+
// restore it after the RSC payload commits. Avoid writing on every scroll via
|
|
144
|
+
// history.replaceState because browsers throttle frequent history updates.
|
|
175
145
|
window.addEventListener("scroll", () => {
|
|
176
|
-
|
|
177
|
-
return;
|
|
178
|
-
scrollSaveScheduled = true;
|
|
179
|
-
requestAnimationFrame(() => {
|
|
180
|
-
scrollSaveScheduled = false;
|
|
181
|
-
saveScrollPosition(window.scrollX, window.scrollY);
|
|
182
|
-
});
|
|
146
|
+
scrollRestoration?.recordCurrentPosition(window.scrollX, window.scrollY);
|
|
183
147
|
}, { passive: true });
|
|
148
|
+
let didFlushForHiddenPage = false;
|
|
149
|
+
function flushForPageLifecycle() {
|
|
150
|
+
if (didFlushForHiddenPage) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
didFlushForHiddenPage = true;
|
|
154
|
+
scrollRestoration?.flushCurrentPositionToHistoryState();
|
|
155
|
+
}
|
|
156
|
+
window.addEventListener("pagehide", flushForPageLifecycle);
|
|
157
|
+
document.addEventListener("visibilitychange", () => {
|
|
158
|
+
if (document.visibilityState === "hidden") {
|
|
159
|
+
flushForPageLifecycle();
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
didFlushForHiddenPage = false;
|
|
163
|
+
}
|
|
164
|
+
});
|
|
184
165
|
function handleResponse(response) {
|
|
185
166
|
if (response.status >= 300 && response.status < 400) {
|
|
186
167
|
const location = response.headers.get("Location");
|
|
@@ -205,7 +186,7 @@ export function initClientNavigation(opts = {}) {
|
|
|
205
186
|
// Apply any pending scroll intent now that React has committed the new
|
|
206
187
|
// DOM — this is what prevents the scroll flash on both link-click and
|
|
207
188
|
// popstate navigations.
|
|
208
|
-
applyPendingScroll();
|
|
189
|
+
scrollRestoration?.applyPendingScroll();
|
|
209
190
|
// After each RSC hydration/update, increment generation and evict old caches,
|
|
210
191
|
// then warm the navigation cache based on any <link rel="x-prefetch"> tags
|
|
211
192
|
// rendered for the current location.
|
|
@@ -1,10 +1,18 @@
|
|
|
1
|
-
import { describe, expect, it, vi
|
|
2
|
-
import {
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { initClientNavigation, validateClickEvent } from "./navigation";
|
|
3
|
+
import { HISTORY_STATE_SCROLL_KEY } from "./scrollRestoration";
|
|
3
4
|
// Mocking browser globals
|
|
4
5
|
vi.stubGlobal("window", {
|
|
5
6
|
location: { href: "http://localhost/" },
|
|
6
7
|
addEventListener: vi.fn(),
|
|
7
|
-
history: {
|
|
8
|
+
history: {
|
|
9
|
+
scrollRestoration: "auto",
|
|
10
|
+
pushState: vi.fn(),
|
|
11
|
+
replaceState: vi.fn(),
|
|
12
|
+
state: {},
|
|
13
|
+
},
|
|
14
|
+
scrollX: 0,
|
|
15
|
+
scrollY: 0,
|
|
8
16
|
});
|
|
9
17
|
vi.stubGlobal("document", {
|
|
10
18
|
addEventListener: vi.fn(),
|
|
@@ -84,8 +92,11 @@ describe("onNavigate callback (issue #1123 regression)", () => {
|
|
|
84
92
|
capturedClickHandler = null;
|
|
85
93
|
capturedPopstateHandler = null;
|
|
86
94
|
vi.clearAllMocks();
|
|
95
|
+
let historyState = {};
|
|
87
96
|
// Capture registered event listeners so we can invoke them manually
|
|
88
97
|
vi.stubGlobal("document", {
|
|
98
|
+
visibilityState: "visible",
|
|
99
|
+
querySelectorAll: vi.fn().mockReturnValue([]),
|
|
89
100
|
addEventListener: vi.fn((event, handler) => {
|
|
90
101
|
if (event === "click")
|
|
91
102
|
capturedClickHandler = handler;
|
|
@@ -99,10 +110,18 @@ describe("onNavigate callback (issue #1123 regression)", () => {
|
|
|
99
110
|
}),
|
|
100
111
|
history: {
|
|
101
112
|
scrollRestoration: "auto",
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
113
|
+
get state() {
|
|
114
|
+
return historyState;
|
|
115
|
+
},
|
|
116
|
+
pushState: vi.fn((state) => {
|
|
117
|
+
historyState = state;
|
|
118
|
+
}),
|
|
119
|
+
replaceState: vi.fn((state) => {
|
|
120
|
+
historyState = state;
|
|
121
|
+
}),
|
|
105
122
|
},
|
|
123
|
+
scrollX: 0,
|
|
124
|
+
scrollY: 0,
|
|
106
125
|
scrollTo: vi.fn(),
|
|
107
126
|
});
|
|
108
127
|
vi.stubGlobal("history", {
|
|
@@ -154,7 +173,9 @@ describe("onNavigate callback (issue #1123 regression)", () => {
|
|
|
154
173
|
});
|
|
155
174
|
it("onNavigate fires after pushState but before RSC fetch", async () => {
|
|
156
175
|
const callOrder = [];
|
|
157
|
-
const onNavigate = vi.fn(() => {
|
|
176
|
+
const onNavigate = vi.fn(() => {
|
|
177
|
+
callOrder.push("onNavigate");
|
|
178
|
+
});
|
|
158
179
|
globalThis.__rsc_callServer = vi.fn(() => {
|
|
159
180
|
callOrder.push("rscCallServer");
|
|
160
181
|
return Promise.resolve();
|
|
@@ -182,21 +203,61 @@ describe("onNavigate callback (issue #1123 regression)", () => {
|
|
|
182
203
|
expect(callOrder).toEqual(["onNavigate", "rscCallServer"]);
|
|
183
204
|
expect(window.history.pushState).toHaveBeenCalled();
|
|
184
205
|
});
|
|
185
|
-
it("ignores hash-only popstate events so anchor links keep their native scroll", async () => {
|
|
186
|
-
const onNavigate = vi.fn();
|
|
187
|
-
globalThis.__rsc_callServer = vi.fn().mockResolvedValue(undefined);
|
|
188
|
-
initClientNavigation({ onNavigate });
|
|
189
|
-
expect(capturedPopstateHandler).not.toBeNull();
|
|
190
|
-
window.location.hash = "#heading";
|
|
191
|
-
await capturedPopstateHandler();
|
|
192
|
-
expect(onNavigate).not.toHaveBeenCalled();
|
|
193
|
-
expect(globalThis.__rsc_callServer).not.toHaveBeenCalled();
|
|
194
|
-
});
|
|
195
206
|
});
|
|
196
207
|
describe("initClientNavigation", () => {
|
|
208
|
+
let historyState;
|
|
209
|
+
let capturedScrollHandler = null;
|
|
210
|
+
let capturedPagehideHandler = null;
|
|
211
|
+
let capturedVisibilityChangeHandler = null;
|
|
212
|
+
let capturedPopstateHandler = null;
|
|
197
213
|
beforeEach(() => {
|
|
198
|
-
|
|
214
|
+
historyState = {};
|
|
215
|
+
capturedScrollHandler = null;
|
|
216
|
+
capturedPagehideHandler = null;
|
|
217
|
+
capturedVisibilityChangeHandler = null;
|
|
218
|
+
capturedPopstateHandler = null;
|
|
199
219
|
vi.clearAllMocks();
|
|
220
|
+
const mockHistory = {
|
|
221
|
+
scrollRestoration: "auto",
|
|
222
|
+
get state() {
|
|
223
|
+
return historyState;
|
|
224
|
+
},
|
|
225
|
+
pushState: vi.fn((state) => {
|
|
226
|
+
historyState = state;
|
|
227
|
+
}),
|
|
228
|
+
replaceState: vi.fn((state) => {
|
|
229
|
+
historyState = state;
|
|
230
|
+
}),
|
|
231
|
+
};
|
|
232
|
+
vi.stubGlobal("document", {
|
|
233
|
+
visibilityState: "visible",
|
|
234
|
+
querySelectorAll: vi.fn().mockReturnValue([]),
|
|
235
|
+
addEventListener: vi.fn((event, handler) => {
|
|
236
|
+
if (event === "visibilitychange") {
|
|
237
|
+
capturedVisibilityChangeHandler = handler;
|
|
238
|
+
}
|
|
239
|
+
}),
|
|
240
|
+
});
|
|
241
|
+
vi.stubGlobal("window", {
|
|
242
|
+
location: { href: "http://localhost/", pathname: "/", search: "" },
|
|
243
|
+
addEventListener: vi.fn((event, handler) => {
|
|
244
|
+
if (event === "scroll") {
|
|
245
|
+
capturedScrollHandler = handler;
|
|
246
|
+
}
|
|
247
|
+
if (event === "pagehide") {
|
|
248
|
+
capturedPagehideHandler = handler;
|
|
249
|
+
}
|
|
250
|
+
if (event === "popstate") {
|
|
251
|
+
capturedPopstateHandler = handler;
|
|
252
|
+
}
|
|
253
|
+
}),
|
|
254
|
+
history: mockHistory,
|
|
255
|
+
fetch: vi.fn(),
|
|
256
|
+
scrollX: 0,
|
|
257
|
+
scrollY: 0,
|
|
258
|
+
scrollTo: vi.fn(),
|
|
259
|
+
});
|
|
260
|
+
vi.stubGlobal("history", mockHistory);
|
|
200
261
|
});
|
|
201
262
|
it("handleResponse should follow redirects", () => {
|
|
202
263
|
const { handleResponse } = initClientNavigation();
|
|
@@ -224,4 +285,91 @@ describe("initClientNavigation", () => {
|
|
|
224
285
|
initClientNavigation();
|
|
225
286
|
expect(history.scrollRestoration).toBe("manual");
|
|
226
287
|
});
|
|
288
|
+
it("ignores hash-only popstate events so anchor links keep their native scroll", async () => {
|
|
289
|
+
const onNavigate = vi.fn();
|
|
290
|
+
globalThis.__rsc_callServer = vi.fn().mockResolvedValue(undefined);
|
|
291
|
+
const { onHydrated } = initClientNavigation({ onNavigate });
|
|
292
|
+
expect(capturedPopstateHandler).not.toBeNull();
|
|
293
|
+
window.location.hash =
|
|
294
|
+
"#heading";
|
|
295
|
+
window.location.href =
|
|
296
|
+
"http://localhost/#heading";
|
|
297
|
+
window.scrollY = 500;
|
|
298
|
+
await capturedPopstateHandler();
|
|
299
|
+
onHydrated();
|
|
300
|
+
expect(onNavigate).not.toHaveBeenCalled();
|
|
301
|
+
expect(globalThis.__rsc_callServer).not.toHaveBeenCalled();
|
|
302
|
+
expect(window.scrollTo).not.toHaveBeenCalled();
|
|
303
|
+
});
|
|
304
|
+
it("does not write to history state on scroll", () => {
|
|
305
|
+
initClientNavigation();
|
|
306
|
+
expect(capturedScrollHandler).not.toBeNull();
|
|
307
|
+
vi.mocked(window.history.replaceState).mockClear();
|
|
308
|
+
window.scrollY = 100;
|
|
309
|
+
capturedScrollHandler();
|
|
310
|
+
window.scrollY = 200;
|
|
311
|
+
capturedScrollHandler();
|
|
312
|
+
expect(window.history.replaceState).not.toHaveBeenCalled();
|
|
313
|
+
});
|
|
314
|
+
it("restores scroll from persisted history state after reload", () => {
|
|
315
|
+
historyState = {
|
|
316
|
+
[HISTORY_STATE_SCROLL_KEY]: "entry:1",
|
|
317
|
+
scrollX: 9,
|
|
318
|
+
scrollY: 321,
|
|
319
|
+
};
|
|
320
|
+
const { onHydrated } = initClientNavigation();
|
|
321
|
+
onHydrated();
|
|
322
|
+
expect(window.scrollTo).toHaveBeenCalledWith({
|
|
323
|
+
left: 9,
|
|
324
|
+
top: 321,
|
|
325
|
+
behavior: "instant",
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
it("migrates legacy scrollX/scrollY state on boot", () => {
|
|
329
|
+
historyState = { scrollX: 11, scrollY: 432 };
|
|
330
|
+
const { onHydrated } = initClientNavigation();
|
|
331
|
+
onHydrated();
|
|
332
|
+
expect(window.history.replaceState).toHaveBeenCalledWith(expect.objectContaining({
|
|
333
|
+
[HISTORY_STATE_SCROLL_KEY]: expect.any(String),
|
|
334
|
+
scrollX: 11,
|
|
335
|
+
scrollY: 432,
|
|
336
|
+
}), "", "http://localhost/");
|
|
337
|
+
expect(window.scrollTo).toHaveBeenCalledWith({
|
|
338
|
+
left: 11,
|
|
339
|
+
top: 432,
|
|
340
|
+
behavior: "instant",
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
it("flushes the latest scroll position on pagehide for reload restoration", () => {
|
|
344
|
+
initClientNavigation();
|
|
345
|
+
expect(capturedScrollHandler).not.toBeNull();
|
|
346
|
+
expect(capturedPagehideHandler).not.toBeNull();
|
|
347
|
+
vi.mocked(window.history.replaceState).mockClear();
|
|
348
|
+
window.scrollX = 3;
|
|
349
|
+
window.scrollY = 250;
|
|
350
|
+
capturedScrollHandler();
|
|
351
|
+
capturedPagehideHandler();
|
|
352
|
+
expect(window.history.replaceState).toHaveBeenCalledWith(expect.objectContaining({
|
|
353
|
+
[HISTORY_STATE_SCROLL_KEY]: expect.any(String),
|
|
354
|
+
scrollX: 3,
|
|
355
|
+
scrollY: 250,
|
|
356
|
+
}), "", "http://localhost/");
|
|
357
|
+
});
|
|
358
|
+
it("flushes the latest scroll position when the page is hidden", () => {
|
|
359
|
+
initClientNavigation();
|
|
360
|
+
expect(capturedVisibilityChangeHandler).not.toBeNull();
|
|
361
|
+
vi.mocked(window.history.replaceState).mockClear();
|
|
362
|
+
window.scrollX = 5;
|
|
363
|
+
window.scrollY = 275;
|
|
364
|
+
Object.defineProperty(document, "visibilityState", {
|
|
365
|
+
value: "hidden",
|
|
366
|
+
configurable: true,
|
|
367
|
+
});
|
|
368
|
+
capturedVisibilityChangeHandler();
|
|
369
|
+
expect(window.history.replaceState).toHaveBeenCalledWith(expect.objectContaining({
|
|
370
|
+
[HISTORY_STATE_SCROLL_KEY]: expect.any(String),
|
|
371
|
+
scrollX: 5,
|
|
372
|
+
scrollY: 275,
|
|
373
|
+
}), "", "http://localhost/");
|
|
374
|
+
});
|
|
227
375
|
});
|