rwsdk 1.2.8 → 1.2.10

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 (38) hide show
  1. package/dist/lib/e2e/browser.d.mts +4 -0
  2. package/dist/lib/e2e/browser.mjs +58 -0
  3. package/dist/lib/e2e/constants.d.mts +2 -0
  4. package/dist/lib/e2e/constants.mjs +6 -0
  5. package/dist/lib/e2e/dev.mjs +7 -18
  6. package/dist/lib/e2e/release.d.mts +7 -0
  7. package/dist/lib/e2e/release.mjs +78 -2
  8. package/dist/lib/e2e/tarball.mjs +4 -1
  9. package/dist/lib/e2e/testHarness.d.mts +1 -7
  10. package/dist/lib/e2e/testHarness.mjs +30 -10
  11. package/dist/lib/smokeTests/browser.d.mts +1 -1
  12. package/dist/lib/smokeTests/browser.mjs +36 -30
  13. package/dist/lib/smokeTests/release.d.mts +8 -1
  14. package/dist/lib/smokeTests/release.mjs +54 -29
  15. package/dist/lib/smokeTests/runSmokeTests.mjs +1 -1
  16. package/dist/runtime/client/navigation.js +42 -61
  17. package/dist/runtime/client/navigation.test.js +145 -8
  18. package/dist/runtime/client/scrollRestoration.d.ts +25 -0
  19. package/dist/runtime/client/scrollRestoration.js +157 -0
  20. package/dist/runtime/client/scrollRestoration.test.d.ts +1 -0
  21. package/dist/runtime/client/scrollRestoration.test.js +93 -0
  22. package/dist/runtime/lib/router.d.ts +1 -0
  23. package/dist/runtime/lib/router.js +27 -2
  24. package/dist/runtime/lib/router.test.js +96 -0
  25. package/dist/scripts/worker-run.mjs +42 -8
  26. package/dist/use-synced-state/__tests__/SyncStateServer.test.mjs +14 -7
  27. package/dist/use-synced-state/__tests__/worker.test.mjs +41 -2
  28. package/dist/use-synced-state/worker.mjs +34 -3
  29. package/dist/vite/miniflareHMRPlugin.mjs +1 -1
  30. package/dist/vite/ssrBridgePlugin.d.mts +0 -1
  31. package/dist/vite/ssrBridgePlugin.mjs +17 -14
  32. package/dist/vite/ssrVirtualModule.d.mts +3 -0
  33. package/dist/vite/ssrVirtualModule.mjs +11 -0
  34. package/dist/vite/transformJsxScriptTagsPlugin.hook.test.d.mts +1 -0
  35. package/dist/vite/transformJsxScriptTagsPlugin.hook.test.mjs +73 -0
  36. package/dist/vite/transformJsxScriptTagsPlugin.mjs +5 -0
  37. package/dist/vite/transformJsxScriptTagsPlugin.test.mjs +4 -3
  38. package/package.json +2 -3
@@ -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,47 +33,37 @@ 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
  export async function navigate(href, options = { history: "push" }) {
40
38
  if (!IS_CLIENT_NAVIGATION) {
41
39
  window.location.href = href;
42
40
  return;
43
41
  }
44
- saveScrollPosition(window.scrollX, window.scrollY);
45
42
  const url = new URL(href, window.location.href);
43
+ const scrollToTop = options.info?.scrollToTop ?? true;
44
+ const scrollBehavior = (options.info?.scrollBehavior ??
45
+ "instant");
46
+ const nextScrollPosition = scrollToTop
47
+ ? { x: 0, y: 0 }
48
+ : { x: window.scrollX, y: window.scrollY };
46
49
  if (options.history === "push") {
47
- window.history.pushState({ path: href }, "", url);
50
+ scrollRestoration?.pushEntry(href, url, nextScrollPosition);
48
51
  }
49
52
  else {
50
- window.history.replaceState({ path: href }, "", url);
53
+ scrollRestoration?.replaceEntry(href, url, nextScrollPosition);
51
54
  }
52
- const scrollToTop = options.info?.scrollToTop ?? true;
53
- const scrollBehavior = (options.info?.scrollBehavior ??
54
- "instant");
55
55
  if (scrollToTop) {
56
- pendingScroll = { x: 0, y: 0, behavior: scrollBehavior };
56
+ scrollRestoration?.setPendingScroll({
57
+ ...nextScrollPosition,
58
+ behavior: scrollBehavior,
59
+ });
60
+ }
61
+ else {
62
+ scrollRestoration?.recordCurrentPosition(window.scrollX, window.scrollY);
57
63
  }
58
64
  await options.onNavigate?.();
59
65
  await globalThis.__rsc_callServer(null, null, "navigation");
60
66
  }
61
- function saveScrollPosition(x, y) {
62
- window.history.replaceState({
63
- ...window.history.state,
64
- scrollX: x,
65
- scrollY: y,
66
- }, "", window.location.href);
67
- }
68
- function applyPendingScroll() {
69
- if (!pendingScroll)
70
- return;
71
- const { x, y, behavior } = pendingScroll;
72
- pendingScroll = null;
73
- window.scrollTo({ top: y, left: x, behavior });
74
- saveScrollPosition(x, y);
75
- }
76
67
  /**
77
68
  * Initializes client-side navigation for Single Page App (SPA) behavior.
78
69
  *
@@ -116,23 +107,8 @@ function applyPendingScroll() {
116
107
  */
117
108
  export function initClientNavigation(opts = {}) {
118
109
  IS_CLIENT_NAVIGATION = true;
119
- // Take manual control of scroll restoration. With "auto", the browser
120
- // restores scroll immediately on popstate — before the RSC payload has
121
- // committed — which causes the old DOM to flash at the new scroll offset.
122
- history.scrollRestoration = "manual";
123
- // If we're booting onto an entry that already has a saved scroll (e.g.
124
- // a reload after scrolling, or a back-forward cache restore), queue that
125
- // position so the first commit lands us where the user left off.
126
- const bootState = window.history.state;
127
- if (bootState &&
128
- (typeof bootState.scrollX === "number" ||
129
- typeof bootState.scrollY === "number")) {
130
- pendingScroll = {
131
- x: bootState.scrollX ?? 0,
132
- y: bootState.scrollY ?? 0,
133
- behavior: "instant",
134
- };
135
- }
110
+ scrollRestoration = createScrollRestoration();
111
+ scrollRestoration.initialize();
136
112
  document.addEventListener("click", async function handleClickEvent(event) {
137
113
  if (!validateClickEvent(event, event.target)) {
138
114
  return;
@@ -144,28 +120,33 @@ export function initClientNavigation(opts = {}) {
144
120
  await navigate(href, { history: "push", onNavigate: opts.onNavigate });
145
121
  }, true);
146
122
  window.addEventListener("popstate", async function handlePopState() {
147
- const state = window.history.state ?? {};
148
- pendingScroll = {
149
- x: typeof state.scrollX === "number" ? state.scrollX : 0,
150
- y: typeof state.scrollY === "number" ? state.scrollY : 0,
151
- behavior: "instant",
152
- };
123
+ scrollRestoration?.restorePopStateScroll();
153
124
  await opts.onNavigate?.();
154
125
  await globalThis.__rsc_callServer(null, null, "navigation");
155
126
  });
156
- // Persist the user's scroll position on the current history entry so
157
- // that back/forward navigation can restore it accurately once the new
158
- // RSC payload commits. Coalesced via rAF to avoid thrashing replaceState.
159
- let scrollSaveScheduled = false;
127
+ // Track the user's scroll position in memory so back/forward navigation can
128
+ // restore it after the RSC payload commits. Avoid writing on every scroll via
129
+ // history.replaceState because browsers throttle frequent history updates.
160
130
  window.addEventListener("scroll", () => {
161
- if (scrollSaveScheduled)
162
- return;
163
- scrollSaveScheduled = true;
164
- requestAnimationFrame(() => {
165
- scrollSaveScheduled = false;
166
- saveScrollPosition(window.scrollX, window.scrollY);
167
- });
131
+ scrollRestoration?.recordCurrentPosition(window.scrollX, window.scrollY);
168
132
  }, { passive: true });
133
+ let didFlushForHiddenPage = false;
134
+ function flushForPageLifecycle() {
135
+ if (didFlushForHiddenPage) {
136
+ return;
137
+ }
138
+ didFlushForHiddenPage = true;
139
+ scrollRestoration?.flushCurrentPositionToHistoryState();
140
+ }
141
+ window.addEventListener("pagehide", flushForPageLifecycle);
142
+ document.addEventListener("visibilitychange", () => {
143
+ if (document.visibilityState === "hidden") {
144
+ flushForPageLifecycle();
145
+ }
146
+ else {
147
+ didFlushForHiddenPage = false;
148
+ }
149
+ });
169
150
  function handleResponse(response) {
170
151
  if (response.status >= 300 && response.status < 400) {
171
152
  const location = response.headers.get("Location");
@@ -190,7 +171,7 @@ export function initClientNavigation(opts = {}) {
190
171
  // Apply any pending scroll intent now that React has committed the new
191
172
  // DOM — this is what prevents the scroll flash on both link-click and
192
173
  // popstate navigations.
193
- applyPendingScroll();
174
+ scrollRestoration?.applyPendingScroll();
194
175
  // After each RSC hydration/update, increment generation and evict old caches,
195
176
  // then warm the navigation cache based on any <link rel="x-prefetch"> tags
196
177
  // 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", {
@@ -153,7 +172,9 @@ describe("onNavigate callback (issue #1123 regression)", () => {
153
172
  });
154
173
  it("onNavigate fires after pushState but before RSC fetch", async () => {
155
174
  const callOrder = [];
156
- const onNavigate = vi.fn(() => { callOrder.push("onNavigate"); });
175
+ const onNavigate = vi.fn(() => {
176
+ callOrder.push("onNavigate");
177
+ });
157
178
  globalThis.__rsc_callServer = vi.fn(() => {
158
179
  callOrder.push("rscCallServer");
159
180
  return Promise.resolve();
@@ -183,9 +204,54 @@ describe("onNavigate callback (issue #1123 regression)", () => {
183
204
  });
184
205
  });
185
206
  describe("initClientNavigation", () => {
207
+ let historyState;
208
+ let capturedScrollHandler = null;
209
+ let capturedPagehideHandler = null;
210
+ let capturedVisibilityChangeHandler = null;
186
211
  beforeEach(() => {
187
- window.location.href = "http://localhost/";
212
+ historyState = {};
213
+ capturedScrollHandler = null;
214
+ capturedPagehideHandler = null;
215
+ capturedVisibilityChangeHandler = null;
188
216
  vi.clearAllMocks();
217
+ const mockHistory = {
218
+ scrollRestoration: "auto",
219
+ get state() {
220
+ return historyState;
221
+ },
222
+ pushState: vi.fn((state) => {
223
+ historyState = state;
224
+ }),
225
+ replaceState: vi.fn((state) => {
226
+ historyState = state;
227
+ }),
228
+ };
229
+ vi.stubGlobal("document", {
230
+ visibilityState: "visible",
231
+ querySelectorAll: vi.fn().mockReturnValue([]),
232
+ addEventListener: vi.fn((event, handler) => {
233
+ if (event === "visibilitychange") {
234
+ capturedVisibilityChangeHandler = handler;
235
+ }
236
+ }),
237
+ });
238
+ vi.stubGlobal("window", {
239
+ location: { href: "http://localhost/" },
240
+ addEventListener: vi.fn((event, handler) => {
241
+ if (event === "scroll") {
242
+ capturedScrollHandler = handler;
243
+ }
244
+ if (event === "pagehide") {
245
+ capturedPagehideHandler = handler;
246
+ }
247
+ }),
248
+ history: mockHistory,
249
+ fetch: vi.fn(),
250
+ scrollX: 0,
251
+ scrollY: 0,
252
+ scrollTo: vi.fn(),
253
+ });
254
+ vi.stubGlobal("history", mockHistory);
189
255
  });
190
256
  it("handleResponse should follow redirects", () => {
191
257
  const { handleResponse } = initClientNavigation();
@@ -213,4 +279,75 @@ describe("initClientNavigation", () => {
213
279
  initClientNavigation();
214
280
  expect(history.scrollRestoration).toBe("manual");
215
281
  });
282
+ it("does not write to history state on scroll", () => {
283
+ initClientNavigation();
284
+ expect(capturedScrollHandler).not.toBeNull();
285
+ vi.mocked(window.history.replaceState).mockClear();
286
+ window.scrollY = 100;
287
+ capturedScrollHandler();
288
+ window.scrollY = 200;
289
+ capturedScrollHandler();
290
+ expect(window.history.replaceState).not.toHaveBeenCalled();
291
+ });
292
+ it("restores scroll from persisted history state after reload", () => {
293
+ historyState = {
294
+ [HISTORY_STATE_SCROLL_KEY]: "entry:1",
295
+ scrollX: 9,
296
+ scrollY: 321,
297
+ };
298
+ const { onHydrated } = initClientNavigation();
299
+ onHydrated();
300
+ expect(window.scrollTo).toHaveBeenCalledWith({
301
+ left: 9,
302
+ top: 321,
303
+ behavior: "instant",
304
+ });
305
+ });
306
+ it("migrates legacy scrollX/scrollY state on boot", () => {
307
+ historyState = { scrollX: 11, scrollY: 432 };
308
+ const { onHydrated } = initClientNavigation();
309
+ onHydrated();
310
+ expect(window.history.replaceState).toHaveBeenCalledWith(expect.objectContaining({
311
+ [HISTORY_STATE_SCROLL_KEY]: expect.any(String),
312
+ scrollX: 11,
313
+ scrollY: 432,
314
+ }), "", "http://localhost/");
315
+ expect(window.scrollTo).toHaveBeenCalledWith({
316
+ left: 11,
317
+ top: 432,
318
+ behavior: "instant",
319
+ });
320
+ });
321
+ it("flushes the latest scroll position on pagehide for reload restoration", () => {
322
+ initClientNavigation();
323
+ expect(capturedScrollHandler).not.toBeNull();
324
+ expect(capturedPagehideHandler).not.toBeNull();
325
+ vi.mocked(window.history.replaceState).mockClear();
326
+ window.scrollX = 3;
327
+ window.scrollY = 250;
328
+ capturedScrollHandler();
329
+ capturedPagehideHandler();
330
+ expect(window.history.replaceState).toHaveBeenCalledWith(expect.objectContaining({
331
+ [HISTORY_STATE_SCROLL_KEY]: expect.any(String),
332
+ scrollX: 3,
333
+ scrollY: 250,
334
+ }), "", "http://localhost/");
335
+ });
336
+ it("flushes the latest scroll position when the page is hidden", () => {
337
+ initClientNavigation();
338
+ expect(capturedVisibilityChangeHandler).not.toBeNull();
339
+ vi.mocked(window.history.replaceState).mockClear();
340
+ window.scrollX = 5;
341
+ window.scrollY = 275;
342
+ Object.defineProperty(document, "visibilityState", {
343
+ value: "hidden",
344
+ configurable: true,
345
+ });
346
+ capturedVisibilityChangeHandler();
347
+ expect(window.history.replaceState).toHaveBeenCalledWith(expect.objectContaining({
348
+ [HISTORY_STATE_SCROLL_KEY]: expect.any(String),
349
+ scrollX: 5,
350
+ scrollY: 275,
351
+ }), "", "http://localhost/");
352
+ });
216
353
  });
@@ -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
+ });
@@ -9,6 +9,7 @@ export type RouteMiddleware<T extends RequestInfo = RequestInfo> = BivariantRout
9
9
  export type ExceptHandler<T extends RequestInfo = RequestInfo> = {
10
10
  __rwExcept: true;
11
11
  handler: (error: unknown, requestInfo: T) => MaybePromise<React.JSX.Element | Response | void>;
12
+ pathPattern?: string;
12
13
  };
13
14
  type RouteFunction<T extends RequestInfo = RequestInfo> = BivariantRouteHandler<T, MaybePromise<Response>>;
14
15
  type RouteComponent<T extends RequestInfo = RequestInfo> = BivariantRouteHandler<T, MaybePromise<React.JSX.Element | Response | void>>;