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.
- package/dist/lib/e2e/browser.d.mts +4 -0
- package/dist/lib/e2e/browser.mjs +58 -0
- package/dist/lib/e2e/constants.d.mts +2 -0
- package/dist/lib/e2e/constants.mjs +6 -0
- package/dist/lib/e2e/dev.mjs +7 -18
- package/dist/lib/e2e/release.d.mts +7 -0
- package/dist/lib/e2e/release.mjs +78 -2
- package/dist/lib/e2e/tarball.mjs +4 -1
- package/dist/lib/e2e/testHarness.d.mts +1 -7
- package/dist/lib/e2e/testHarness.mjs +30 -10
- package/dist/lib/smokeTests/browser.d.mts +1 -1
- package/dist/lib/smokeTests/browser.mjs +36 -30
- package/dist/lib/smokeTests/release.d.mts +8 -1
- package/dist/lib/smokeTests/release.mjs +54 -29
- package/dist/lib/smokeTests/runSmokeTests.mjs +1 -1
- package/dist/runtime/client/navigation.js +42 -61
- package/dist/runtime/client/navigation.test.js +145 -8
- 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/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/scripts/worker-run.mjs +42 -8
- package/dist/use-synced-state/__tests__/SyncStateServer.test.mjs +14 -7
- package/dist/use-synced-state/__tests__/worker.test.mjs +41 -2
- package/dist/use-synced-state/worker.mjs +34 -3
- package/dist/vite/miniflareHMRPlugin.mjs +1 -1
- package/dist/vite/ssrBridgePlugin.d.mts +0 -1
- package/dist/vite/ssrBridgePlugin.mjs +17 -14
- package/dist/vite/ssrVirtualModule.d.mts +3 -0
- package/dist/vite/ssrVirtualModule.mjs +11 -0
- package/dist/vite/transformJsxScriptTagsPlugin.hook.test.d.mts +1 -0
- package/dist/vite/transformJsxScriptTagsPlugin.hook.test.mjs +73 -0
- package/dist/vite/transformJsxScriptTagsPlugin.mjs +5 -0
- package/dist/vite/transformJsxScriptTagsPlugin.test.mjs +4 -3
- 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
|
-
|
|
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
|
-
|
|
50
|
+
scrollRestoration?.pushEntry(href, url, nextScrollPosition);
|
|
48
51
|
}
|
|
49
52
|
else {
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
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
|
-
|
|
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
|
|
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", {
|
|
@@ -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(() => {
|
|
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
|
-
|
|
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>>;
|