rwsdk 1.2.9 → 1.2.11

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,45 @@ 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;
37
+ let currentPathKey = null;
38
+ function getLocationPathKey() {
39
+ return `${window.location.pathname ?? ""}${window.location.search ?? ""}`;
40
+ }
41
+ function getUrlPathKey(url) {
42
+ return `${url.pathname ?? ""}${url.search ?? ""}` || getLocationPathKey();
43
+ }
39
44
  export async function navigate(href, options = { history: "push" }) {
40
45
  if (!IS_CLIENT_NAVIGATION) {
41
46
  window.location.href = href;
42
47
  return;
43
48
  }
44
- saveScrollPosition(window.scrollX, window.scrollY);
45
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 };
46
56
  if (options.history === "push") {
47
- window.history.pushState({ path: href }, "", url);
57
+ scrollRestoration?.pushEntry(href, url, nextScrollPosition);
48
58
  }
49
59
  else {
50
- window.history.replaceState({ path: href }, "", url);
60
+ scrollRestoration?.replaceEntry(href, url, nextScrollPosition);
51
61
  }
52
- const scrollToTop = options.info?.scrollToTop ?? true;
53
- const scrollBehavior = (options.info?.scrollBehavior ??
54
- "instant");
62
+ currentPathKey = getUrlPathKey(url);
55
63
  if (scrollToTop) {
56
- 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);
57
71
  }
58
72
  await options.onNavigate?.();
59
73
  await globalThis.__rsc_callServer(null, null, "navigation");
60
74
  }
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
75
  /**
77
76
  * Initializes client-side navigation for Single Page App (SPA) behavior.
78
77
  *
@@ -116,23 +115,9 @@ function applyPendingScroll() {
116
115
  */
117
116
  export function initClientNavigation(opts = {}) {
118
117
  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
- }
118
+ scrollRestoration = createScrollRestoration();
119
+ scrollRestoration.initialize();
120
+ currentPathKey = getLocationPathKey();
136
121
  document.addEventListener("click", async function handleClickEvent(event) {
137
122
  if (!validateClickEvent(event, event.target)) {
138
123
  return;
@@ -144,28 +129,39 @@ export function initClientNavigation(opts = {}) {
144
129
  await navigate(href, { history: "push", onNavigate: opts.onNavigate });
145
130
  }, true);
146
131
  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
- };
132
+ const nextPathKey = getLocationPathKey();
133
+ const isHashOnlyChange = nextPathKey === currentPathKey;
134
+ currentPathKey = nextPathKey;
135
+ if (isHashOnlyChange) {
136
+ return;
137
+ }
138
+ scrollRestoration?.restorePopStateScroll();
153
139
  await opts.onNavigate?.();
154
140
  await globalThis.__rsc_callServer(null, null, "navigation");
155
141
  });
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;
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.
160
145
  window.addEventListener("scroll", () => {
161
- if (scrollSaveScheduled)
162
- return;
163
- scrollSaveScheduled = true;
164
- requestAnimationFrame(() => {
165
- scrollSaveScheduled = false;
166
- saveScrollPosition(window.scrollX, window.scrollY);
167
- });
146
+ scrollRestoration?.recordCurrentPosition(window.scrollX, window.scrollY);
168
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
+ });
169
165
  function handleResponse(response) {
170
166
  if (response.status >= 300 && response.status < 400) {
171
167
  const location = response.headers.get("Location");
@@ -190,7 +186,7 @@ export function initClientNavigation(opts = {}) {
190
186
  // Apply any pending scroll intent now that React has committed the new
191
187
  // DOM — this is what prevents the scroll flash on both link-click and
192
188
  // popstate navigations.
193
- applyPendingScroll();
189
+ scrollRestoration?.applyPendingScroll();
194
190
  // After each RSC hydration/update, increment generation and evict old caches,
195
191
  // then warm the navigation cache based on any <link rel="x-prefetch"> tags
196
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,25 +92,36 @@ 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;
92
103
  }),
93
104
  });
94
105
  vi.stubGlobal("window", {
95
- location: { href: "http://localhost/" },
106
+ location: { href: "http://localhost/", pathname: "/", search: "" },
96
107
  addEventListener: vi.fn((event, handler) => {
97
108
  if (event === "popstate")
98
109
  capturedPopstateHandler = handler;
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", {
@@ -148,12 +167,15 @@ describe("onNavigate callback (issue #1123 regression)", () => {
148
167
  const onNavigate = vi.fn();
149
168
  initClientNavigation({ onNavigate });
150
169
  expect(capturedPopstateHandler).not.toBeNull();
170
+ window.location.pathname = "/about";
151
171
  await capturedPopstateHandler();
152
172
  expect(onNavigate).toHaveBeenCalled();
153
173
  });
154
174
  it("onNavigate fires after pushState but before RSC fetch", async () => {
155
175
  const callOrder = [];
156
- const onNavigate = vi.fn(() => { callOrder.push("onNavigate"); });
176
+ const onNavigate = vi.fn(() => {
177
+ callOrder.push("onNavigate");
178
+ });
157
179
  globalThis.__rsc_callServer = vi.fn(() => {
158
180
  callOrder.push("rscCallServer");
159
181
  return Promise.resolve();
@@ -183,9 +205,59 @@ describe("onNavigate callback (issue #1123 regression)", () => {
183
205
  });
184
206
  });
185
207
  describe("initClientNavigation", () => {
208
+ let historyState;
209
+ let capturedScrollHandler = null;
210
+ let capturedPagehideHandler = null;
211
+ let capturedVisibilityChangeHandler = null;
212
+ let capturedPopstateHandler = null;
186
213
  beforeEach(() => {
187
- window.location.href = "http://localhost/";
214
+ historyState = {};
215
+ capturedScrollHandler = null;
216
+ capturedPagehideHandler = null;
217
+ capturedVisibilityChangeHandler = null;
218
+ capturedPopstateHandler = null;
188
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);
189
261
  });
190
262
  it("handleResponse should follow redirects", () => {
191
263
  const { handleResponse } = initClientNavigation();
@@ -213,4 +285,91 @@ describe("initClientNavigation", () => {
213
285
  initClientNavigation();
214
286
  expect(history.scrollRestoration).toBe("manual");
215
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
+ });
216
375
  });
@@ -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);
@@ -94,10 +94,8 @@ export const transformServerFunctions = (code, normalizedId, environment, server
94
94
  process.env.VERBOSE &&
95
95
  log(`Transforming for ${environment} environment: normalizedId=%s`, normalizedId);
96
96
  const exportInfo = findExportInfo(code, normalizedId);
97
- const allExports = new Set([
98
- ...exportInfo.localFunctions,
99
- ...exportInfo.reExports.map((r) => r.localName),
100
- ]);
97
+ const reExportNames = new Set(exportInfo.reExports.map((r) => r.localName));
98
+ const allExports = new Set(Array.from(exportInfo.localFunctions).filter((name) => !reExportNames.has(name)));
101
99
  // Check for default function exports that should also be named exports
102
100
  const defaultFunctionName = findDefaultFunctionName(code, normalizedId);
103
101
  if (defaultFunctionName) {
@@ -105,11 +103,26 @@ export const transformServerFunctions = (code, normalizedId, environment, server
105
103
  }
106
104
  // Generate completely new code for SSR
107
105
  const s = new MagicString("");
108
- if (environment === "ssr") {
109
- s.append('import { createServerReference } from "rwsdk/__ssr";\n\n');
106
+ const hasDefExport = hasDefaultExport(code, normalizedId);
107
+ if (allExports.size > 0 || hasDefExport) {
108
+ if (environment === "ssr") {
109
+ s.append('import { createServerReference } from "rwsdk/__ssr";\n\n');
110
+ }
111
+ else {
112
+ s.append('import { createServerReference } from "rwsdk/client";\n\n');
113
+ }
110
114
  }
111
- else {
112
- s.append('import { createServerReference } from "rwsdk/client";\n\n');
115
+ for (const reExport of exportInfo.reExports) {
116
+ const reExportStatement = reExport.originalName === "default"
117
+ ? `export { default as ${reExport.localName} } from ${JSON.stringify(reExport.moduleSpecifier)};\n`
118
+ : reExport.originalName === reExport.localName
119
+ ? `export { ${reExport.originalName} } from ${JSON.stringify(reExport.moduleSpecifier)};\n`
120
+ : `export { ${reExport.originalName} as ${reExport.localName} } from ${JSON.stringify(reExport.moduleSpecifier)};\n`;
121
+ s.append(reExportStatement);
122
+ log(`Preserved ${environment} re-export for function: %s (original: %s) from %s in normalizedId=%s`, reExport.localName, reExport.originalName, reExport.moduleSpecifier, normalizedId);
123
+ }
124
+ if (exportInfo.reExports.length > 0 && allExports.size > 0) {
125
+ s.append("\n");
113
126
  }
114
127
  const ext = path.extname(normalizedId).toLowerCase();
115
128
  const lang = ext === ".tsx" || ext === ".jsx" ? Lang.Tsx : SgLang.TypeScript;
@@ -151,7 +164,7 @@ export const transformServerFunctions = (code, normalizedId, environment, server
151
164
  }
152
165
  }
153
166
  // Check for default export in the actual module (not re-exports)
154
- if (hasDefaultExport(code, normalizedId)) {
167
+ if (hasDefExport) {
155
168
  let method;
156
169
  let source = "action";
157
170
  const patterns = [
@@ -169,6 +169,17 @@ export const getProject = serverQuery([
169
169
  SERVER_QUERY_ARRAY_POST_CODE,
170
170
  };
171
171
  describe("TRANSFORMS", () => {
172
+ it("preserves client re-exports so serverQuery metadata comes from the defining module", () => {
173
+ const barrelResult = transformServerFunctions(`
174
+ "use server";
175
+
176
+ export { getProject } from "./queries";
177
+ `, "/actions.ts", "client", new Set());
178
+ expect(barrelResult?.code).toContain(`export { getProject } from "./queries";`);
179
+ expect(barrelResult?.code).not.toContain(`createServerReference("/actions.ts", "getProject")`);
180
+ const queryResult = transformServerFunctions(SERVER_QUERY_GET_CODE, "/queries.ts", "client", new Set());
181
+ expect(queryResult?.code).toContain(`createServerReference("/queries.ts", "getProject", "GET", "query")`);
182
+ });
172
183
  for (const [key, CODE] of Object.entries(TEST_CASES)) {
173
184
  describe(key, () => {
174
185
  it(`CLIENT`, () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rwsdk",
3
- "version": "1.2.9",
3
+ "version": "1.2.11",
4
4
  "description": "Build fast, server-driven webapps on Cloudflare with SSR, RSC, and realtime",
5
5
  "type": "module",
6
6
  "bin": {