rwsdk 1.3.0-canary.7 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/dist/lib/e2e/release.d.mts +2 -1
  2. package/dist/lib/e2e/release.mjs +70 -5
  3. package/dist/lib/e2e/testHarness.d.mts +1 -0
  4. package/dist/lib/e2e/testHarness.mjs +51 -2
  5. package/dist/runtime/client/client.d.ts +3 -2
  6. package/dist/runtime/client/client.js +5 -1
  7. package/dist/runtime/client/navigation.js +42 -61
  8. package/dist/runtime/client/navigation.test.js +166 -18
  9. package/dist/runtime/client/recovery.d.ts +17 -0
  10. package/dist/runtime/client/recovery.js +191 -0
  11. package/dist/runtime/client/scrollRestoration.d.ts +25 -0
  12. package/dist/runtime/client/scrollRestoration.js +157 -0
  13. package/dist/runtime/client/scrollRestoration.test.d.ts +1 -0
  14. package/dist/runtime/client/scrollRestoration.test.js +93 -0
  15. package/dist/runtime/imports/client.js +13 -1
  16. package/dist/runtime/lib/router.d.ts +1 -0
  17. package/dist/runtime/lib/router.js +27 -2
  18. package/dist/runtime/lib/router.test.js +96 -0
  19. package/dist/runtime/render/assembleDocument.js +1 -1
  20. package/dist/runtime/render/renderDocumentHtmlStream.js +1 -1
  21. package/dist/runtime/render/stylesheets.js +0 -22
  22. package/dist/use-synced-state/__tests__/SyncStateServer.test.mjs +14 -7
  23. package/dist/use-synced-state/__tests__/client-core.test.js +18 -223
  24. package/dist/use-synced-state/__tests__/worker.test.mjs +41 -2
  25. package/dist/use-synced-state/client-core.js +20 -10
  26. package/dist/use-synced-state/hibernation/__tests__/client-core.test.d.ts +1 -0
  27. package/dist/use-synced-state/hibernation/__tests__/client-core.test.js +244 -0
  28. package/dist/use-synced-state/hibernation/__tests__/server.test.d.mts +1 -0
  29. package/dist/use-synced-state/hibernation/__tests__/server.test.mjs +165 -0
  30. package/dist/use-synced-state/hibernation/client-core.d.ts +19 -0
  31. package/dist/use-synced-state/hibernation/client-core.js +38 -0
  32. package/dist/use-synced-state/hibernation/client.d.ts +3 -0
  33. package/dist/use-synced-state/hibernation/client.js +4 -0
  34. package/dist/use-synced-state/hibernation/connection/connection.d.ts +2 -0
  35. package/dist/use-synced-state/hibernation/connection/connection.js +72 -0
  36. package/dist/use-synced-state/hibernation/connection/messages.d.ts +6 -0
  37. package/dist/use-synced-state/hibernation/connection/messages.js +64 -0
  38. package/dist/use-synced-state/hibernation/connection/timer.d.ts +5 -0
  39. package/dist/use-synced-state/hibernation/connection/timer.js +33 -0
  40. package/dist/use-synced-state/hibernation/connection/types.d.ts +22 -0
  41. package/dist/use-synced-state/hibernation/connection/types.js +1 -0
  42. package/dist/use-synced-state/hibernation/identity.d.mts +4 -0
  43. package/dist/use-synced-state/hibernation/identity.mjs +25 -0
  44. package/dist/use-synced-state/hibernation/protocol.d.mts +53 -0
  45. package/dist/use-synced-state/hibernation/protocol.mjs +55 -0
  46. package/dist/use-synced-state/hibernation/reconnect/backoff.d.ts +1 -0
  47. package/dist/use-synced-state/hibernation/reconnect/backoff.js +7 -0
  48. package/dist/use-synced-state/hibernation/reconnect/reconnect.d.ts +8 -0
  49. package/dist/use-synced-state/hibernation/reconnect/reconnect.js +34 -0
  50. package/dist/use-synced-state/hibernation/server.d.mts +62 -0
  51. package/dist/use-synced-state/hibernation/server.mjs +385 -0
  52. package/dist/use-synced-state/hibernation/state/clientFactory.d.ts +2 -0
  53. package/dist/use-synced-state/hibernation/state/clientFactory.js +63 -0
  54. package/dist/use-synced-state/hibernation/state/clientManager.d.ts +46 -0
  55. package/dist/use-synced-state/hibernation/state/clientManager.js +151 -0
  56. package/dist/use-synced-state/hibernation/useSyncedState.d.ts +22 -0
  57. package/dist/use-synced-state/hibernation/useSyncedState.js +64 -0
  58. package/dist/use-synced-state/hibernation/worker.d.mts +8 -0
  59. package/dist/use-synced-state/hibernation/worker.mjs +50 -0
  60. package/dist/use-synced-state/shim/__tests__/worker.test.d.mts +1 -0
  61. package/dist/use-synced-state/shim/__tests__/worker.test.mjs +37 -0
  62. package/dist/use-synced-state/shim/client.d.ts +2 -0
  63. package/dist/use-synced-state/shim/client.js +2 -0
  64. package/dist/use-synced-state/shim/worker.d.mts +2 -0
  65. package/dist/use-synced-state/shim/worker.mjs +139 -0
  66. package/dist/use-synced-state/worker.mjs +34 -3
  67. package/dist/vite/buildApp.mjs +15 -54
  68. package/dist/vite/configPlugin.mjs +20 -27
  69. package/dist/vite/createDirectiveLookupPlugin.mjs +15 -11
  70. package/dist/vite/directiveModulesDevPlugin.mjs +44 -28
  71. package/dist/vite/directivesFilteringPlugin.mjs +7 -20
  72. package/dist/vite/directivesPlugin.mjs +87 -44
  73. package/dist/vite/knownDepsResolverPlugin.mjs +36 -29
  74. package/dist/vite/linkerPlugin.mjs +1 -1
  75. package/dist/vite/prismaPlugin.mjs +10 -7
  76. package/dist/vite/redwoodPlugin.mjs +2 -0
  77. package/dist/vite/runDirectivesScan.mjs +1 -1
  78. package/dist/vite/ssrBridgePlugin.mjs +21 -11
  79. package/dist/vite/statePlugin.mjs +15 -8
  80. package/dist/vite/transformJsxScriptTagsPlugin.mjs +3 -3
  81. package/dist/vite/transformJsxScriptTagsPlugin.test.mjs +4 -3
  82. package/dist/vite/vitePreamblePlugin.d.mts +153 -2
  83. package/package.json +35 -18
  84. package/dist/vite/addOptimizeDepsPlugin.d.mts +0 -10
  85. package/dist/vite/addOptimizeDepsPlugin.mjs +0 -6
@@ -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
@@ -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", "--", "--port", "4173", "--strictPort"], {
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 reachableDebugUrl = await checkServerUp("http://localhost:4173", "/__debug", Math.max(1, Math.ceil(PREVIEW_SERVER_TIMEOUT / 2000)), false);
475
- const serverUrl = new URL(reachableDebugUrl).origin;
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 previewResult = await runPreviewServer(process.env.PACKAGE_MANAGER ||
235
- "pnpm", projectDir);
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
- // Scroll intent recorded at navigation time and applied post-commit in
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
- window.history.pushState({ path: href }, "", url);
57
+ scrollRestoration?.pushEntry(href, url, nextScrollPosition);
55
58
  }
56
59
  else {
57
- window.history.replaceState({ path: href }, "", url);
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
- pendingScroll = { x: 0, y: 0, behavior: scrollBehavior };
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
- // Take manual control of scroll restoration. With "auto", the browser
128
- // restores scroll immediately on popstate — before the RSC payload has
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
- const state = window.history.state ?? {};
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
- // Persist the user's scroll position on the current history entry so
172
- // that back/forward navigation can restore it accurately once the new
173
- // RSC payload commits. Coalesced via rAF to avoid thrashing replaceState.
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
- if (scrollSaveScheduled)
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, beforeEach } from "vitest";
2
- import { validateClickEvent, initClientNavigation } from "./navigation";
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: { scrollRestoration: "auto" },
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
- pushState: vi.fn(),
103
- replaceState: vi.fn(),
104
- state: {},
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(() => { callOrder.push("onNavigate"); });
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
- window.location.href = "http://localhost/";
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
  });