rwsdk 1.2.9 → 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.
@@ -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
+ });
@@ -7,7 +7,7 @@ import { normalizeModulePath } from "../lib/normalizeModulePath.mjs";
7
7
  import { hasDirective as sourceHasDirective } from "./hasDirective.mjs";
8
8
  import { invalidateModule } from "./invalidateModule.mjs";
9
9
  import { isJsFile } from "./isJsFile.mjs";
10
- import { VIRTUAL_SSR_PREFIX } from "./ssrBridgePlugin.mjs";
10
+ import { VIRTUAL_SSR_PREFIX } from "./ssrVirtualModule.mjs";
11
11
  const log = debug("rwsdk:vite:hmr-plugin");
12
12
  let hasErrored = false;
13
13
  const hasDirective = async (filepath, directive) => {
@@ -1,5 +1,4 @@
1
1
  import type { Plugin } from "vite";
2
- export declare const VIRTUAL_SSR_PREFIX = "virtual:rwsdk:ssr:";
3
2
  export declare const ssrBridgePlugin: ({ clientFiles, serverFiles, }: {
4
3
  clientFiles: Set<string>;
5
4
  serverFiles: Set<string>;
@@ -3,8 +3,8 @@ import MagicString from "magic-string";
3
3
  import { INTERMEDIATE_SSR_BRIDGE_PATH } from "../lib/constants.mjs";
4
4
  import { externalModulesSet } from "./constants.mjs";
5
5
  import { findSsrImportCallSites } from "./findSsrSpecifiers.mjs";
6
+ import { isVirtualSsrModuleId, normalizeVirtualSsrModuleId, VIRTUAL_SSR_PREFIX, } from "./ssrVirtualModule.mjs";
6
7
  const log = debug("rwsdk:vite:ssr-bridge-plugin");
7
- export const VIRTUAL_SSR_PREFIX = "virtual:rwsdk:ssr:";
8
8
  export const ssrBridgePlugin = ({ clientFiles, serverFiles, }) => {
9
9
  let devServer;
10
10
  let isDev = false;
@@ -63,10 +63,11 @@ export const ssrBridgePlugin = ({ clientFiles, serverFiles, }) => {
63
63
  process.env.VERBOSE &&
64
64
  log("Esbuild onResolve called for path=%s, args=%O", args.path, args);
65
65
  if (args.path === "rwsdk/__ssr_bridge" ||
66
- args.path.startsWith(VIRTUAL_SSR_PREFIX)) {
67
- log("Marking as external: %s", args.path);
66
+ isVirtualSsrModuleId(args.path)) {
67
+ const path = normalizeVirtualSsrModuleId(args.path) ?? args.path;
68
+ log("Marking as external: %s", path);
68
69
  return {
69
- path: args.path,
70
+ path,
70
71
  external: true,
71
72
  };
72
73
  }
@@ -98,14 +99,15 @@ export const ssrBridgePlugin = ({ clientFiles, serverFiles, }) => {
98
99
  // context(justinvdm, 27 May 2025): In dev, we need to dynamically load
99
100
  // SSR modules, so we return the virtual id so that the dynamic loading
100
101
  // can happen in load()
101
- if (id.startsWith(VIRTUAL_SSR_PREFIX)) {
102
- if (id.endsWith(".css")) {
103
- const newId = id + ".js";
102
+ const virtualSsrId = normalizeVirtualSsrModuleId(id);
103
+ if (virtualSsrId) {
104
+ if (virtualSsrId.endsWith(".css")) {
105
+ const newId = virtualSsrId + ".js";
104
106
  log("Virtual CSS module, adding .js suffix. old: %s, new: %s", id, newId);
105
107
  return newId;
106
108
  }
107
- log("Returning virtual SSR id for dev: %s", id);
108
- return id;
109
+ log("Returning virtual SSR id for dev: %s", virtualSsrId);
110
+ return virtualSsrId;
109
111
  }
110
112
  // context(justinvdm, 28 May 2025): The SSR bridge module is a special case -
111
113
  // it is the entry point for all SSR modules, so to trigger the
@@ -119,10 +121,11 @@ export const ssrBridgePlugin = ({ clientFiles, serverFiles, }) => {
119
121
  }
120
122
  else {
121
123
  // In build mode, the behavior depends on the build pass
122
- if (id.startsWith(VIRTUAL_SSR_PREFIX)) {
124
+ const virtualSsrId = normalizeVirtualSsrModuleId(id);
125
+ if (virtualSsrId) {
123
126
  if (this.environment.name === "worker") {
124
127
  log("Virtual SSR module case (build-worker pass): resolving to external");
125
- return { id, external: true };
128
+ return { id: virtualSsrId, external: true };
126
129
  }
127
130
  }
128
131
  if (id === "rwsdk/__ssr_bridge" && this.environment.name === "worker") {
@@ -141,9 +144,9 @@ export const ssrBridgePlugin = ({ clientFiles, serverFiles, }) => {
141
144
  }
142
145
  },
143
146
  async load(id) {
144
- if (id.startsWith(VIRTUAL_SSR_PREFIX) &&
145
- this.environment.name === "worker") {
146
- const realId = id.slice(VIRTUAL_SSR_PREFIX.length);
147
+ const virtualSsrId = normalizeVirtualSsrModuleId(id);
148
+ if (virtualSsrId && this.environment.name === "worker") {
149
+ const realId = virtualSsrId.slice(VIRTUAL_SSR_PREFIX.length);
147
150
  let idForFetch = realId.endsWith(".css.js")
148
151
  ? realId.slice(0, -3)
149
152
  : realId;
@@ -0,0 +1,3 @@
1
+ export declare const VIRTUAL_SSR_PREFIX = "virtual:rwsdk:ssr:";
2
+ export declare function normalizeVirtualSsrModuleId(id: string): string | undefined;
3
+ export declare function isVirtualSsrModuleId(id: string): boolean;
@@ -0,0 +1,11 @@
1
+ const VITE_ID_PREFIX = "/@id/";
2
+ export const VIRTUAL_SSR_PREFIX = "virtual:rwsdk:ssr:";
3
+ export function normalizeVirtualSsrModuleId(id) {
4
+ const normalizedId = id.startsWith(VITE_ID_PREFIX)
5
+ ? id.slice(VITE_ID_PREFIX.length)
6
+ : id;
7
+ return normalizedId.startsWith(VIRTUAL_SSR_PREFIX) ? normalizedId : undefined;
8
+ }
9
+ export function isVirtualSsrModuleId(id) {
10
+ return normalizeVirtualSsrModuleId(id) !== undefined;
11
+ }
@@ -0,0 +1,73 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { isVirtualSsrModuleId, normalizeVirtualSsrModuleId, VIRTUAL_SSR_PREFIX, } from "./ssrVirtualModule.mjs";
3
+ import { transformJsxScriptTagsPlugin } from "./transformJsxScriptTagsPlugin.mjs";
4
+ describe("isVirtualSsrModuleId", () => {
5
+ it.each([
6
+ [`${VIRTUAL_SSR_PREFIX}/src/Preview.tsx`, true],
7
+ [`${VIRTUAL_SSR_PREFIX}rwsdk/__ssr_bridge`, true],
8
+ [`/@id/${VIRTUAL_SSR_PREFIX}/src/Preview.tsx`, true],
9
+ [`/src/${VIRTUAL_SSR_PREFIX}Preview.tsx`, false],
10
+ ["/src/Preview.tsx", false],
11
+ ])("identifies %s as virtual SSR module id: %s", (id, expected) => {
12
+ expect(isVirtualSsrModuleId(id)).toBe(expected);
13
+ });
14
+ it("normalizes Vite /@id/ virtual SSR module URLs to bare module ids", () => {
15
+ expect(normalizeVirtualSsrModuleId(`/@id/${VIRTUAL_SSR_PREFIX}/src/Preview.tsx`)).toBe(`${VIRTUAL_SSR_PREFIX}/src/Preview.tsx`);
16
+ });
17
+ });
18
+ describe("transformJsxScriptTagsPlugin transform hook", () => {
19
+ function createPlugin() {
20
+ const clientEntryPoints = new Set();
21
+ const plugin = transformJsxScriptTagsPlugin({
22
+ clientEntryPoints,
23
+ projectRootDir: "/project/root/dir",
24
+ });
25
+ const configResolved = plugin.configResolved;
26
+ if (typeof configResolved === "function") {
27
+ configResolved.call({}, { command: "serve", base: "/" });
28
+ }
29
+ else {
30
+ configResolved?.handler.call({}, { command: "serve", base: "/" });
31
+ }
32
+ const transform = typeof plugin.transform === "function"
33
+ ? plugin.transform
34
+ : plugin.transform?.handler;
35
+ if (!transform) {
36
+ throw new Error("Expected transform hook to be defined");
37
+ }
38
+ return { clientEntryPoints, transform: transform };
39
+ }
40
+ it.each([
41
+ `${VIRTUAL_SSR_PREFIX}/src/Preview.tsx`,
42
+ `/@id/${VIRTUAL_SSR_PREFIX}/src/Preview.tsx`,
43
+ ])("skips virtual SSR bridge module ids in worker: %s", async (id) => {
44
+ // Regression for #1210: virtual SSR bridge modules have .tsx ids but are
45
+ // already transformed by the ssr environment, so the worker transform must
46
+ // not run script-tag discovery on them again.
47
+ const { clientEntryPoints, transform } = createPlugin();
48
+ const ssrBridgeCode = `
49
+ import { jsx } from "react/jsx-runtime";
50
+ const __vite_ssr_import_0__ = await __vite_ssr_import__("react/jsx-runtime", { importedNames: ["jsx"] });
51
+ export function Preview() {
52
+ return jsx("script", { async: true, src: "https://example.com/a.js" });
53
+ }
54
+ `;
55
+ const result = await transform.call({ environment: { name: "worker" } }, ssrBridgeCode, id);
56
+ expect(result).toBeNull();
57
+ expect(clientEntryPoints.size).toBe(0);
58
+ });
59
+ it("still transforms non-bridge worker .tsx modules", async () => {
60
+ const { transform } = createPlugin();
61
+ const code = `
62
+ import { jsx } from "react/jsx-runtime";
63
+ export function Preview() {
64
+ return jsx("script", { async: true, src: "https://example.com/a.js" });
65
+ }
66
+ `;
67
+ const result = await transform.call({ environment: { name: "worker" } }, code, "/src/Preview.tsx");
68
+ expect(result).toBeTruthy();
69
+ expect(typeof result === "object" && result !== null && "code" in result
70
+ ? result.code
71
+ : "").toContain("nonce: requestInfo.rw.nonce");
72
+ });
73
+ });
@@ -2,6 +2,7 @@ import debug from "debug";
2
2
  import { Node, Project, SyntaxKind, } from "ts-morph";
3
3
  import { normalizeModulePath } from "../lib/normalizeModulePath.mjs";
4
4
  import { stripBase } from "../lib/stripBase.mjs";
5
+ import { isVirtualSsrModuleId } from "./ssrVirtualModule.mjs";
5
6
  const log = debug("rwsdk:vite:transform-jsx-script-tags");
6
7
  function transformAssetPath(importPath, projectRootDir, base) {
7
8
  if (process.env.VITE_IS_DEV_SERVER === "1") {
@@ -336,6 +337,10 @@ export const transformJsxScriptTagsPlugin = ({ clientEntryPoints, projectRootDir
336
337
  return null;
337
338
  }
338
339
  if (this.environment?.name === "worker" &&
340
+ // context(peterp, 29 May 2026): SSR bridge modules are already
341
+ // transformed by the `ssr` environment; rerunning script-tag discovery
342
+ // in the worker injects duplicate imports into virtual SSR modules.
343
+ !isVirtualSsrModuleId(id) &&
339
344
  id.endsWith(".tsx") &&
340
345
  hasJsxFunctions(code)) {
341
346
  log("Transforming JSX script tags in %s", id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rwsdk",
3
- "version": "1.2.9",
3
+ "version": "1.2.10",
4
4
  "description": "Build fast, server-driven webapps on Cloudflare with SSR, RSC, and realtime",
5
5
  "type": "module",
6
6
  "bin": {