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.
- package/dist/runtime/client/navigation.js +57 -61
- package/dist/runtime/client/navigation.test.js +168 -9
- 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/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/transformServerFunctions.mjs +22 -9
- package/dist/vite/transformServerFunctions.test.mjs +11 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
57
|
+
scrollRestoration?.pushEntry(href, url, nextScrollPosition);
|
|
48
58
|
}
|
|
49
59
|
else {
|
|
50
|
-
|
|
60
|
+
scrollRestoration?.replaceEntry(href, url, nextScrollPosition);
|
|
51
61
|
}
|
|
52
|
-
|
|
53
|
-
const scrollBehavior = (options.info?.scrollBehavior ??
|
|
54
|
-
"instant");
|
|
62
|
+
currentPathKey = getUrlPathKey(url);
|
|
55
63
|
if (scrollToTop) {
|
|
56
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
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
|
-
|
|
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
|
|
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,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
|
-
|
|
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", {
|
|
@@ -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(() => {
|
|
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
|
-
|
|
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 "./
|
|
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) => {
|
|
@@ -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
|
|
67
|
-
|
|
66
|
+
isVirtualSsrModuleId(args.path)) {
|
|
67
|
+
const path = normalizeVirtualSsrModuleId(args.path) ?? args.path;
|
|
68
|
+
log("Marking as external: %s", path);
|
|
68
69
|
return {
|
|
69
|
-
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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",
|
|
108
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
const realId =
|
|
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,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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
|
98
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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
|
-
|
|
112
|
-
|
|
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 (
|
|
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`, () => {
|