rwsdk 1.3.3 → 1.4.0
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/client.d.ts +6 -3
- package/dist/runtime/client/client.js +49 -12
- package/dist/runtime/client/navigation.d.ts +9 -8
- package/dist/runtime/client/navigation.js +43 -16
- package/dist/runtime/client/navigation.test.js +21 -5
- package/dist/runtime/client/navigationPending.d.ts +47 -0
- package/dist/runtime/client/navigationPending.js +115 -0
- package/dist/runtime/client/navigationPending.test.d.ts +1 -0
- package/dist/runtime/client/navigationPending.test.js +122 -0
- package/dist/runtime/client/navigationState.d.ts +28 -0
- package/dist/runtime/client/navigationState.js +124 -0
- package/dist/runtime/client/types.d.ts +7 -2
- package/dist/runtime/lib/realtime/client.js +18 -5
- package/package.json +1 -1
|
@@ -3,9 +3,12 @@ export { default as React } from "react";
|
|
|
3
3
|
export type { Dispatch, MutableRefObject, SetStateAction } from "react";
|
|
4
4
|
export { ClientOnly } from "./ClientOnly.js";
|
|
5
5
|
export { initClientNavigation, navigate } from "./navigation.js";
|
|
6
|
-
export
|
|
6
|
+
export { NavigationPending, useNavigationPending, } from "./navigationPending.js";
|
|
7
|
+
export type { NavigationPendingOptions, NavigationPendingProps, NavigationPendingWatch, NavigationPendingWhenArgs, NavigationSearchParamsWatch, } from "./navigationPending.js";
|
|
8
|
+
export type { NavigationSnapshot, PendingNavigationSnapshot, } from "./navigationState.js";
|
|
9
|
+
export type { ActionResponseData, RscPayloadMeta } from "./types";
|
|
7
10
|
import { type RecoveryOptions } from "./recovery.js";
|
|
8
|
-
import type { ActionResponseData, HydrationOptions, Transport } from "./types";
|
|
11
|
+
import type { ActionResponseData, HydrationOptions, RscPayloadMeta, Transport } from "./types";
|
|
9
12
|
export declare const fetchTransport: Transport;
|
|
10
13
|
/**
|
|
11
14
|
* Initializes the React client and hydrates the RSC payload.
|
|
@@ -65,6 +68,6 @@ export declare const initClient: ({ transport, hydrateRootOptions, handleRespons
|
|
|
65
68
|
transport?: Transport;
|
|
66
69
|
hydrateRootOptions?: HydrationOptions;
|
|
67
70
|
handleResponse?: (response: Response) => boolean;
|
|
68
|
-
onHydrated?: () => void;
|
|
71
|
+
onHydrated?: (meta?: RscPayloadMeta) => void;
|
|
69
72
|
onActionResponse?: (actionResponse: ActionResponseData) => boolean | void;
|
|
70
73
|
} & RecoveryOptions) => Promise<void>;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
// note(justinvdm, 14 Aug 2025): Rendering related imports and logic go here.
|
|
3
3
|
// See client.tsx for the actual client entrypoint.
|
|
4
4
|
// context(justinvdm, 14 Aug 2025): `react-server-dom-webpack` uses this global
|
|
@@ -13,12 +13,16 @@ import { rscStream } from "rsc-html-stream/client";
|
|
|
13
13
|
export { default as React } from "react";
|
|
14
14
|
export { ClientOnly } from "./ClientOnly.js";
|
|
15
15
|
export { initClientNavigation, navigate } from "./navigation.js";
|
|
16
|
+
export { NavigationPending, useNavigationPending, } from "./navigationPending.js";
|
|
16
17
|
import { getCachedNavigationResponse } from "./navigationCache.js";
|
|
18
|
+
import { NavigationPayloadProvider } from "./navigationPending.js";
|
|
19
|
+
import { abortPendingNavigation, isPendingNavigationCommit, isSameNavigationDocumentUrl, } from "./navigationState.js";
|
|
17
20
|
import { configureRecovery } from "./recovery.js";
|
|
18
21
|
import { isActionResponse } from "./types";
|
|
19
22
|
export const fetchTransport = (transportContext) => {
|
|
20
23
|
const fetchCallServer = async (id, args, source = "action", method = "POST") => {
|
|
21
|
-
const
|
|
24
|
+
const pageUrl = new URL(window.location.href);
|
|
25
|
+
const url = new URL(pageUrl.href);
|
|
22
26
|
url.searchParams.set("__rsc", "");
|
|
23
27
|
const isAction = id != null;
|
|
24
28
|
if (isAction) {
|
|
@@ -65,6 +69,16 @@ export const fetchTransport = (transportContext) => {
|
|
|
65
69
|
});
|
|
66
70
|
}
|
|
67
71
|
}
|
|
72
|
+
const discardStaleNavigationResponse = () => {
|
|
73
|
+
if (source !== "navigation" ||
|
|
74
|
+
isSameNavigationDocumentUrl(pageUrl, window.location.href)) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
if (isPendingNavigationCommit(pageUrl)) {
|
|
78
|
+
abortPendingNavigation();
|
|
79
|
+
}
|
|
80
|
+
return true;
|
|
81
|
+
};
|
|
68
82
|
const processActionResponse = (rawActionResult) => {
|
|
69
83
|
if (isActionResponse(rawActionResult)) {
|
|
70
84
|
const actionResponse = rawActionResult.__rw_action_response;
|
|
@@ -87,6 +101,9 @@ export const fetchTransport = (transportContext) => {
|
|
|
87
101
|
// If there's a response handler, check the response first
|
|
88
102
|
if (transportContext.handleResponse) {
|
|
89
103
|
const response = await fetchPromise;
|
|
104
|
+
if (discardStaleNavigationResponse()) {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
90
107
|
const shouldContinue = transportContext.handleResponse(response);
|
|
91
108
|
if (!shouldContinue) {
|
|
92
109
|
return undefined;
|
|
@@ -96,13 +113,22 @@ export const fetchTransport = (transportContext) => {
|
|
|
96
113
|
callServer: fetchCallServer,
|
|
97
114
|
});
|
|
98
115
|
if (source === "navigation" || source === "action") {
|
|
99
|
-
|
|
116
|
+
if (discardStaleNavigationResponse()) {
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
transportContext.setRscPayload(streamData, {
|
|
120
|
+
source,
|
|
121
|
+
href: pageUrl.href,
|
|
122
|
+
});
|
|
100
123
|
}
|
|
101
124
|
const result = await streamData;
|
|
102
125
|
return processActionResponse(result.actionResult);
|
|
103
126
|
}
|
|
104
127
|
// Original behavior when no handler is present
|
|
105
128
|
const response = await fetchPromise;
|
|
129
|
+
if (discardStaleNavigationResponse()) {
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
106
132
|
const location = response.headers.get("Location");
|
|
107
133
|
if (response.status >= 300 && response.status < 400 && location) {
|
|
108
134
|
window.location.href = location;
|
|
@@ -112,7 +138,13 @@ export const fetchTransport = (transportContext) => {
|
|
|
112
138
|
callServer: fetchCallServer,
|
|
113
139
|
});
|
|
114
140
|
if (source === "navigation" || source === "action") {
|
|
115
|
-
|
|
141
|
+
if (discardStaleNavigationResponse()) {
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
transportContext.setRscPayload(streamData, {
|
|
145
|
+
source,
|
|
146
|
+
href: pageUrl.href,
|
|
147
|
+
});
|
|
116
148
|
}
|
|
117
149
|
const result = await streamData;
|
|
118
150
|
return processActionResponse(result.actionResult);
|
|
@@ -210,18 +242,23 @@ export const initClient = async ({ transport = fetchTransport, hydrateRootOption
|
|
|
210
242
|
});
|
|
211
243
|
}
|
|
212
244
|
function Content() {
|
|
213
|
-
const [
|
|
245
|
+
const [streamState, setStreamState] = React.useState(rscPayload
|
|
246
|
+
? {
|
|
247
|
+
data: rscPayload,
|
|
248
|
+
meta: { source: "initial", href: window.location.href },
|
|
249
|
+
}
|
|
250
|
+
: null);
|
|
214
251
|
const [_isPending, startTransition] = React.useTransition();
|
|
215
|
-
transportContext.setRscPayload = (v) => startTransition(() => {
|
|
216
|
-
|
|
252
|
+
transportContext.setRscPayload = (v, meta) => startTransition(() => {
|
|
253
|
+
setStreamState({ data: v, meta });
|
|
217
254
|
});
|
|
218
255
|
React.useEffect(() => {
|
|
219
|
-
if (!
|
|
256
|
+
if (!streamState)
|
|
220
257
|
return;
|
|
221
|
-
transportContext.onHydrated?.();
|
|
222
|
-
}, [
|
|
223
|
-
return (_jsx(
|
|
224
|
-
? React.use(
|
|
258
|
+
transportContext.onHydrated?.(streamState.meta);
|
|
259
|
+
}, [streamState]);
|
|
260
|
+
return (_jsx(NavigationPayloadProvider, { meta: streamState?.meta, children: streamState
|
|
261
|
+
? React.use(streamState.data).node
|
|
225
262
|
: null }));
|
|
226
263
|
}
|
|
227
264
|
hydrateRoot(rootEl, _jsx(Content, {}), {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type NavigationCache, type NavigationCacheStorage } from "./navigationCache.js";
|
|
2
|
+
import type { RscPayloadMeta } from "./types.js";
|
|
2
3
|
export type { NavigationCache, NavigationCacheStorage };
|
|
3
4
|
export interface ClientNavigationOptions {
|
|
4
5
|
onNavigate?: () => Promise<void> | void;
|
|
@@ -20,7 +21,7 @@ export declare function navigate(href: string, options?: NavigateOptions): Promi
|
|
|
20
21
|
* Initializes client-side navigation for Single Page App (SPA) behavior.
|
|
21
22
|
*
|
|
22
23
|
* Intercepts clicks on internal links and fetches page content without full-page reloads.
|
|
23
|
-
* Returns
|
|
24
|
+
* Returns handleResponse and onHydrated functions to pass to initClient.
|
|
24
25
|
*
|
|
25
26
|
* @param opts.scrollToTop - Scroll to top after navigation (default: true)
|
|
26
27
|
* @param opts.scrollBehavior - How to scroll: 'instant', 'smooth', or 'auto' (default: 'instant')
|
|
@@ -35,29 +36,29 @@ export declare function navigate(href: string, options?: NavigateOptions): Promi
|
|
|
35
36
|
*
|
|
36
37
|
* @example
|
|
37
38
|
* // With custom scroll behavior
|
|
38
|
-
* const { handleResponse } = initClientNavigation({
|
|
39
|
+
* const { handleResponse, onHydrated } = initClientNavigation({
|
|
39
40
|
* scrollBehavior: "smooth",
|
|
40
41
|
* scrollToTop: true,
|
|
41
42
|
* });
|
|
42
|
-
* initClient({ handleResponse });
|
|
43
|
+
* initClient({ handleResponse, onHydrated });
|
|
43
44
|
*
|
|
44
45
|
* @example
|
|
45
46
|
* // Preserve scroll position (e.g., for infinite scroll)
|
|
46
|
-
* const { handleResponse } = initClientNavigation({
|
|
47
|
+
* const { handleResponse, onHydrated } = initClientNavigation({
|
|
47
48
|
* scrollToTop: false,
|
|
48
49
|
* });
|
|
49
|
-
* initClient({ handleResponse });
|
|
50
|
+
* initClient({ handleResponse, onHydrated });
|
|
50
51
|
*
|
|
51
52
|
* @example
|
|
52
53
|
* // With navigation callback
|
|
53
|
-
* const { handleResponse } = initClientNavigation({
|
|
54
|
+
* const { handleResponse, onHydrated } = initClientNavigation({
|
|
54
55
|
* onNavigate: () => {
|
|
55
56
|
* console.log("Navigating to:", window.location.href);
|
|
56
57
|
* },
|
|
57
58
|
* });
|
|
58
|
-
* initClient({ handleResponse });
|
|
59
|
+
* initClient({ handleResponse, onHydrated });
|
|
59
60
|
*/
|
|
60
61
|
export declare function initClientNavigation(opts?: ClientNavigationOptions): {
|
|
61
62
|
handleResponse: (response: Response) => boolean;
|
|
62
|
-
onHydrated: () => void;
|
|
63
|
+
onHydrated: (meta?: RscPayloadMeta) => void;
|
|
63
64
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { onNavigationCommit, preloadFromLinkTags, } from "./navigationCache.js";
|
|
2
|
+
import { abortPendingNavigation, beginPendingNavigation, commitPendingNavigation, isPendingNavigationCommit, } from "./navigationState.js";
|
|
2
3
|
import { createScrollRestoration, } from "./scrollRestoration.js";
|
|
3
4
|
export function validateClickEvent(event, target) {
|
|
4
5
|
// should this only work for left click?
|
|
@@ -69,14 +70,21 @@ export async function navigate(href, options = { history: "push" }) {
|
|
|
69
70
|
else {
|
|
70
71
|
scrollRestoration?.recordCurrentPosition(window.scrollX, window.scrollY);
|
|
71
72
|
}
|
|
72
|
-
|
|
73
|
-
|
|
73
|
+
const pendingNavigation = beginPendingNavigation(url);
|
|
74
|
+
try {
|
|
75
|
+
await options.onNavigate?.();
|
|
76
|
+
await globalThis.__rsc_callServer(null, null, "navigation");
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
abortPendingNavigation(pendingNavigation.id);
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
74
82
|
}
|
|
75
83
|
/**
|
|
76
84
|
* Initializes client-side navigation for Single Page App (SPA) behavior.
|
|
77
85
|
*
|
|
78
86
|
* Intercepts clicks on internal links and fetches page content without full-page reloads.
|
|
79
|
-
* Returns
|
|
87
|
+
* Returns handleResponse and onHydrated functions to pass to initClient.
|
|
80
88
|
*
|
|
81
89
|
* @param opts.scrollToTop - Scroll to top after navigation (default: true)
|
|
82
90
|
* @param opts.scrollBehavior - How to scroll: 'instant', 'smooth', or 'auto' (default: 'instant')
|
|
@@ -91,27 +99,27 @@ export async function navigate(href, options = { history: "push" }) {
|
|
|
91
99
|
*
|
|
92
100
|
* @example
|
|
93
101
|
* // With custom scroll behavior
|
|
94
|
-
* const { handleResponse } = initClientNavigation({
|
|
102
|
+
* const { handleResponse, onHydrated } = initClientNavigation({
|
|
95
103
|
* scrollBehavior: "smooth",
|
|
96
104
|
* scrollToTop: true,
|
|
97
105
|
* });
|
|
98
|
-
* initClient({ handleResponse });
|
|
106
|
+
* initClient({ handleResponse, onHydrated });
|
|
99
107
|
*
|
|
100
108
|
* @example
|
|
101
109
|
* // Preserve scroll position (e.g., for infinite scroll)
|
|
102
|
-
* const { handleResponse } = initClientNavigation({
|
|
110
|
+
* const { handleResponse, onHydrated } = initClientNavigation({
|
|
103
111
|
* scrollToTop: false,
|
|
104
112
|
* });
|
|
105
|
-
* initClient({ handleResponse });
|
|
113
|
+
* initClient({ handleResponse, onHydrated });
|
|
106
114
|
*
|
|
107
115
|
* @example
|
|
108
116
|
* // With navigation callback
|
|
109
|
-
* const { handleResponse } = initClientNavigation({
|
|
117
|
+
* const { handleResponse, onHydrated } = initClientNavigation({
|
|
110
118
|
* onNavigate: () => {
|
|
111
119
|
* console.log("Navigating to:", window.location.href);
|
|
112
120
|
* },
|
|
113
121
|
* });
|
|
114
|
-
* initClient({ handleResponse });
|
|
122
|
+
* initClient({ handleResponse, onHydrated });
|
|
115
123
|
*/
|
|
116
124
|
export function initClientNavigation(opts = {}) {
|
|
117
125
|
IS_CLIENT_NAVIGATION = true;
|
|
@@ -136,8 +144,15 @@ export function initClientNavigation(opts = {}) {
|
|
|
136
144
|
return;
|
|
137
145
|
}
|
|
138
146
|
scrollRestoration?.restorePopStateScroll();
|
|
139
|
-
|
|
140
|
-
|
|
147
|
+
const pendingNavigation = beginPendingNavigation(window.location.href);
|
|
148
|
+
try {
|
|
149
|
+
await opts.onNavigate?.();
|
|
150
|
+
await globalThis.__rsc_callServer(null, null, "navigation");
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
abortPendingNavigation(pendingNavigation.id);
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
141
156
|
});
|
|
142
157
|
// Track the user's scroll position in memory so back/forward navigation can
|
|
143
158
|
// restore it after the RSC payload commits. Avoid writing on every scroll via
|
|
@@ -166,6 +181,7 @@ export function initClientNavigation(opts = {}) {
|
|
|
166
181
|
if (response.status >= 300 && response.status < 400) {
|
|
167
182
|
const location = response.headers.get("Location");
|
|
168
183
|
if (location) {
|
|
184
|
+
abortPendingNavigation();
|
|
169
185
|
window.location.href = location;
|
|
170
186
|
return false;
|
|
171
187
|
}
|
|
@@ -173,6 +189,7 @@ export function initClientNavigation(opts = {}) {
|
|
|
173
189
|
if (!response.ok) {
|
|
174
190
|
// Redirect to the current page (window.location) to show the error
|
|
175
191
|
// This means the page that produced the error is called twice.
|
|
192
|
+
abortPendingNavigation();
|
|
176
193
|
window.location.href = window.location.href;
|
|
177
194
|
return false;
|
|
178
195
|
}
|
|
@@ -182,11 +199,21 @@ export function initClientNavigation(opts = {}) {
|
|
|
182
199
|
if (opts.cacheStorage && typeof globalThis !== "undefined") {
|
|
183
200
|
globalThis.__rsc_cacheStorage = opts.cacheStorage;
|
|
184
201
|
}
|
|
185
|
-
function onHydrated() {
|
|
186
|
-
// Apply any pending scroll intent
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
|
|
202
|
+
function onHydrated(meta) {
|
|
203
|
+
// Apply any pending scroll intent once the relevant DOM has committed. For
|
|
204
|
+
// navigation payloads, ignore stale/superseded commits whose href no longer
|
|
205
|
+
// matches the pending navigation target.
|
|
206
|
+
const shouldCommitPendingNavigation = !meta || meta.source === "navigation";
|
|
207
|
+
const isMatchingNavigationCommit = shouldCommitPendingNavigation && isPendingNavigationCommit(meta?.href);
|
|
208
|
+
if (!meta || meta.source === "initial" || isMatchingNavigationCommit) {
|
|
209
|
+
scrollRestoration?.applyPendingScroll();
|
|
210
|
+
}
|
|
211
|
+
// Resolve NavigationPending boundaries only for navigation payloads. If a
|
|
212
|
+
// custom transport does not provide metadata, fall back to the historical
|
|
213
|
+
// onHydrated behavior and treat the commit as the pending navigation.
|
|
214
|
+
if (isMatchingNavigationCommit) {
|
|
215
|
+
commitPendingNavigation(meta?.href);
|
|
216
|
+
}
|
|
190
217
|
// After each RSC hydration/update, increment generation and evict old caches,
|
|
191
218
|
// then warm the navigation cache based on any <link rel="x-prefetch"> tags
|
|
192
219
|
// rendered for the current location.
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { initClientNavigation, validateClickEvent } from "./navigation";
|
|
3
|
+
import { getNavigationSnapshot, resetNavigationStateForTests, } from "./navigationState";
|
|
3
4
|
import { HISTORY_STATE_SCROLL_KEY } from "./scrollRestoration";
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
resetNavigationStateForTests();
|
|
7
|
+
});
|
|
4
8
|
// Mocking browser globals
|
|
5
9
|
vi.stubGlobal("window", {
|
|
6
10
|
location: { href: "http://localhost/" },
|
|
@@ -130,11 +134,6 @@ describe("onNavigate callback (issue #1123 regression)", () => {
|
|
|
130
134
|
replaceState: vi.fn(),
|
|
131
135
|
state: {},
|
|
132
136
|
});
|
|
133
|
-
vi.stubGlobal("URL", class {
|
|
134
|
-
constructor(path, base) {
|
|
135
|
-
this.href = base.replace(/\/$/, "") + path;
|
|
136
|
-
}
|
|
137
|
-
});
|
|
138
137
|
// Assign directly to globalThis without replacing it (avoids breaking Vitest internals)
|
|
139
138
|
globalThis.__rsc_callServer = vi.fn().mockResolvedValue(undefined);
|
|
140
139
|
});
|
|
@@ -301,6 +300,23 @@ describe("initClientNavigation", () => {
|
|
|
301
300
|
expect(globalThis.__rsc_callServer).not.toHaveBeenCalled();
|
|
302
301
|
expect(window.scrollTo).not.toHaveBeenCalled();
|
|
303
302
|
});
|
|
303
|
+
it("keeps navigation pending until the matching navigation payload hydrates", async () => {
|
|
304
|
+
globalThis.__rsc_callServer = vi.fn().mockResolvedValue(undefined);
|
|
305
|
+
const { onHydrated } = initClientNavigation();
|
|
306
|
+
expect(capturedPopstateHandler).not.toBeNull();
|
|
307
|
+
window.location.href =
|
|
308
|
+
"http://localhost/about";
|
|
309
|
+
window.location.pathname = "/about";
|
|
310
|
+
await capturedPopstateHandler();
|
|
311
|
+
const pending = getNavigationSnapshot().pending;
|
|
312
|
+
expect(pending?.pendingUrl.href).toBe("http://localhost/about");
|
|
313
|
+
onHydrated({ source: "action", href: pending.pendingUrl.href });
|
|
314
|
+
expect(getNavigationSnapshot().pending?.id).toBe(pending.id);
|
|
315
|
+
onHydrated({ source: "navigation", href: "http://localhost/other" });
|
|
316
|
+
expect(getNavigationSnapshot().pending?.id).toBe(pending.id);
|
|
317
|
+
onHydrated({ source: "navigation", href: pending.pendingUrl.href });
|
|
318
|
+
expect(getNavigationSnapshot().pending).toBeNull();
|
|
319
|
+
});
|
|
304
320
|
it("does not write to history state on scroll", () => {
|
|
305
321
|
initClientNavigation();
|
|
306
322
|
expect(capturedScrollHandler).not.toBeNull();
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { type NavigationSnapshot } from "./navigationState.js";
|
|
3
|
+
import type { RscPayloadMeta } from "./types.js";
|
|
4
|
+
export type NavigationSearchParamsWatch = boolean | readonly string[];
|
|
5
|
+
export interface NavigationPendingWatch {
|
|
6
|
+
/** Watch pathname changes. Defaults to true when using a watch config. */
|
|
7
|
+
pathname?: boolean;
|
|
8
|
+
/** Watch all search params, no search params, or a specific list of params. */
|
|
9
|
+
searchParams?: NavigationSearchParamsWatch;
|
|
10
|
+
/** Watch hash changes. Defaults to false when using a watch config. */
|
|
11
|
+
hash?: boolean;
|
|
12
|
+
}
|
|
13
|
+
export interface NavigationPendingWhenArgs {
|
|
14
|
+
currentUrl: URL;
|
|
15
|
+
pendingUrl: URL;
|
|
16
|
+
}
|
|
17
|
+
export interface NavigationPendingOptions {
|
|
18
|
+
/** Shorthand for watching only these search params. */
|
|
19
|
+
searchParams?: readonly string[];
|
|
20
|
+
/** Explicit URL parts to watch. */
|
|
21
|
+
watch?: NavigationPendingWatch;
|
|
22
|
+
/** Advanced predicate for deciding whether the pending navigation is relevant. */
|
|
23
|
+
when?: (args: NavigationPendingWhenArgs) => boolean;
|
|
24
|
+
}
|
|
25
|
+
export interface NavigationPendingProps extends NavigationPendingOptions {
|
|
26
|
+
children?: React.ReactNode;
|
|
27
|
+
}
|
|
28
|
+
export declare function NavigationPayloadProvider({ children, meta, }: {
|
|
29
|
+
children?: React.ReactNode;
|
|
30
|
+
meta?: RscPayloadMeta;
|
|
31
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
32
|
+
export declare function shouldSuspendForPendingNavigation(snapshot: NavigationSnapshot, options?: NavigationPendingOptions): boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Suspends while a matching client-side RSC navigation is pending.
|
|
35
|
+
*
|
|
36
|
+
* Use this hook inside a React <Suspense> boundary. When the pending navigation
|
|
37
|
+
* commits to the visible React tree, the thrown promise resolves and React
|
|
38
|
+
* retries the render with the newly committed tree.
|
|
39
|
+
*/
|
|
40
|
+
export declare function useNavigationPending(options?: NavigationPendingOptions): NavigationSnapshot;
|
|
41
|
+
/**
|
|
42
|
+
* Suspense-aware boundary for client-side RSC navigations.
|
|
43
|
+
*
|
|
44
|
+
* Wrap server-backed UI with this component inside your own <Suspense> fallback
|
|
45
|
+
* to avoid showing stale props while a relevant navigation is in flight.
|
|
46
|
+
*/
|
|
47
|
+
export declare function NavigationPending({ children, searchParams, watch, when, }: NavigationPendingProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import React, { useContext, useSyncExternalStore } from "react";
|
|
3
|
+
import { getNavigationSnapshot, isSameNavigationDocumentUrl, subscribeNavigationState, } from "./navigationState.js";
|
|
4
|
+
const NO_NAVIGATION_PAYLOAD_META = Symbol("NO_NAVIGATION_PAYLOAD_META");
|
|
5
|
+
const NavigationPayloadMetaContext = React.createContext(NO_NAVIGATION_PAYLOAD_META);
|
|
6
|
+
export function NavigationPayloadProvider({ children, meta, }) {
|
|
7
|
+
return (_jsx(NavigationPayloadMetaContext.Provider, { value: meta, children: children }));
|
|
8
|
+
}
|
|
9
|
+
function cloneUrl(url) {
|
|
10
|
+
return new URL(url.href);
|
|
11
|
+
}
|
|
12
|
+
function arraysEqual(left, right) {
|
|
13
|
+
if (left.length !== right.length) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
return left.every((value, index) => value === right[index]);
|
|
17
|
+
}
|
|
18
|
+
function searchParamChanged(currentUrl, pendingUrl, name) {
|
|
19
|
+
return !arraysEqual(currentUrl.searchParams.getAll(name), pendingUrl.searchParams.getAll(name));
|
|
20
|
+
}
|
|
21
|
+
function searchParamsChanged(currentUrl, pendingUrl, watch) {
|
|
22
|
+
if (watch === false) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
if (watch === true) {
|
|
26
|
+
return currentUrl.search !== pendingUrl.search;
|
|
27
|
+
}
|
|
28
|
+
return watch.some((name) => searchParamChanged(currentUrl, pendingUrl, name));
|
|
29
|
+
}
|
|
30
|
+
function watchedUrlChanged(currentUrl, pendingUrl, watch) {
|
|
31
|
+
const watchPathname = watch.pathname ?? true;
|
|
32
|
+
const watchSearchParams = watch.searchParams ?? true;
|
|
33
|
+
const watchHash = watch.hash ?? false;
|
|
34
|
+
if (watchPathname && currentUrl.pathname !== pendingUrl.pathname) {
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
if (searchParamsChanged(currentUrl, pendingUrl, watchSearchParams)) {
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
if (watchHash && currentUrl.hash !== pendingUrl.hash) {
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
function isRenderingMatchingNavigationPayload(snapshot, meta) {
|
|
46
|
+
const pending = snapshot.pending;
|
|
47
|
+
if (!pending) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
if (meta === NO_NAVIGATION_PAYLOAD_META) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
if (meta === undefined) {
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
if (meta.source !== "navigation") {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
if (!meta.href) {
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
return isSameNavigationDocumentUrl(new URL(meta.href, pending.currentUrl), pending.pendingUrl);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export function shouldSuspendForPendingNavigation(snapshot, options = {}) {
|
|
70
|
+
const pending = snapshot.pending;
|
|
71
|
+
if (!pending) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
const currentUrl = pending.currentUrl;
|
|
75
|
+
const pendingUrl = pending.pendingUrl;
|
|
76
|
+
if (options.when) {
|
|
77
|
+
return options.when({
|
|
78
|
+
currentUrl: cloneUrl(currentUrl),
|
|
79
|
+
pendingUrl: cloneUrl(pendingUrl),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
if (options.watch) {
|
|
83
|
+
return watchedUrlChanged(currentUrl, pendingUrl, options.watch);
|
|
84
|
+
}
|
|
85
|
+
if (options.searchParams) {
|
|
86
|
+
return searchParamsChanged(currentUrl, pendingUrl, options.searchParams);
|
|
87
|
+
}
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Suspends while a matching client-side RSC navigation is pending.
|
|
92
|
+
*
|
|
93
|
+
* Use this hook inside a React <Suspense> boundary. When the pending navigation
|
|
94
|
+
* commits to the visible React tree, the thrown promise resolves and React
|
|
95
|
+
* retries the render with the newly committed tree.
|
|
96
|
+
*/
|
|
97
|
+
export function useNavigationPending(options = {}) {
|
|
98
|
+
const snapshot = useSyncExternalStore(subscribeNavigationState, getNavigationSnapshot, getNavigationSnapshot);
|
|
99
|
+
const payloadMeta = useContext(NavigationPayloadMetaContext);
|
|
100
|
+
if (shouldSuspendForPendingNavigation(snapshot, options) &&
|
|
101
|
+
!isRenderingMatchingNavigationPayload(snapshot, payloadMeta)) {
|
|
102
|
+
throw snapshot.pending.promise;
|
|
103
|
+
}
|
|
104
|
+
return snapshot;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Suspense-aware boundary for client-side RSC navigations.
|
|
108
|
+
*
|
|
109
|
+
* Wrap server-backed UI with this component inside your own <Suspense> fallback
|
|
110
|
+
* to avoid showing stale props while a relevant navigation is in flight.
|
|
111
|
+
*/
|
|
112
|
+
export function NavigationPending({ children, searchParams, watch, when, }) {
|
|
113
|
+
useNavigationPending({ searchParams, watch, when });
|
|
114
|
+
return _jsx(_Fragment, { children: children });
|
|
115
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import React, { Suspense } from "react";
|
|
2
|
+
import { renderToString } from "react-dom/server";
|
|
3
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
4
|
+
import { NavigationPayloadProvider, NavigationPending, shouldSuspendForPendingNavigation, } from "./navigationPending";
|
|
5
|
+
import { abortPendingNavigation, beginPendingNavigation, commitPendingNavigation, getNavigationSnapshot, isSameNavigationDocumentUrl, resetNavigationStateForTests, } from "./navigationState";
|
|
6
|
+
function renderPendingBoundary(meta) {
|
|
7
|
+
return renderToString(React.createElement(NavigationPayloadProvider, { meta }, React.createElement(Suspense, { fallback: React.createElement("span", null, "loading") }, React.createElement(NavigationPending, null, React.createElement("span", null, "ready")))));
|
|
8
|
+
}
|
|
9
|
+
async function expectPromiseResolved(promise) {
|
|
10
|
+
let resolved = false;
|
|
11
|
+
promise.then(() => {
|
|
12
|
+
resolved = true;
|
|
13
|
+
});
|
|
14
|
+
await Promise.resolve();
|
|
15
|
+
expect(resolved).toBe(true);
|
|
16
|
+
}
|
|
17
|
+
describe("navigation pending state", () => {
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
resetNavigationStateForTests("http://localhost/results?search=old&page=1");
|
|
20
|
+
});
|
|
21
|
+
it("tracks a pending navigation until the matching navigation commit", async () => {
|
|
22
|
+
const pending = beginPendingNavigation("http://localhost/results?search=new&page=1");
|
|
23
|
+
expect(getNavigationSnapshot().pending?.id).toBe(pending.id);
|
|
24
|
+
expect(getNavigationSnapshot().pending?.currentUrl.href).toBe("http://localhost/results?search=old&page=1");
|
|
25
|
+
expect(getNavigationSnapshot().pending?.pendingUrl.href).toBe("http://localhost/results?search=new&page=1");
|
|
26
|
+
expect(commitPendingNavigation("http://localhost/results?search=old&page=1")).toBe(false);
|
|
27
|
+
expect(getNavigationSnapshot().pending?.id).toBe(pending.id);
|
|
28
|
+
expect(commitPendingNavigation("http://localhost/results?search=new&page=1")).toBe(true);
|
|
29
|
+
expect(getNavigationSnapshot().pending).toBeNull();
|
|
30
|
+
expect(getNavigationSnapshot().currentUrl.href).toBe("http://localhost/results?search=new&page=1");
|
|
31
|
+
await expectPromiseResolved(pending.promise);
|
|
32
|
+
});
|
|
33
|
+
it("resolves a superseded navigation promise while keeping the newest navigation pending", async () => {
|
|
34
|
+
const first = beginPendingNavigation("/results?search=first&page=1");
|
|
35
|
+
const second = beginPendingNavigation("/results?search=second&page=1");
|
|
36
|
+
expect(getNavigationSnapshot().pending?.id).toBe(second.id);
|
|
37
|
+
await expectPromiseResolved(first.promise);
|
|
38
|
+
expect(getNavigationSnapshot().pending?.id).toBe(second.id);
|
|
39
|
+
});
|
|
40
|
+
it("treats hash-only differences as the same navigation document", async () => {
|
|
41
|
+
const pending = beginPendingNavigation("/results?search=new&page=1");
|
|
42
|
+
expect(isSameNavigationDocumentUrl("http://localhost/results?search=new&page=1", "http://localhost/results?search=new&page=1#details")).toBe(true);
|
|
43
|
+
expect(isSameNavigationDocumentUrl("http://localhost/results?search=new&page=1", "http://localhost/results?search=new&page=2#details")).toBe(false);
|
|
44
|
+
expect(commitPendingNavigation("/results?search=new&page=1#details")).toBe(true);
|
|
45
|
+
expect(getNavigationSnapshot().pending).toBeNull();
|
|
46
|
+
await expectPromiseResolved(pending.promise);
|
|
47
|
+
});
|
|
48
|
+
it("can abort only the matching pending navigation", async () => {
|
|
49
|
+
const pending = beginPendingNavigation("/results?search=new&page=1");
|
|
50
|
+
expect(abortPendingNavigation(pending.id + 1)).toBe(false);
|
|
51
|
+
expect(getNavigationSnapshot().pending?.id).toBe(pending.id);
|
|
52
|
+
expect(abortPendingNavigation(pending.id)).toBe(true);
|
|
53
|
+
expect(getNavigationSnapshot().pending).toBeNull();
|
|
54
|
+
await expectPromiseResolved(pending.promise);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
describe("shouldSuspendForPendingNavigation", () => {
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
resetNavigationStateForTests("http://localhost/results?search=old&page=1");
|
|
60
|
+
});
|
|
61
|
+
it("suspends for any pending navigation by default", () => {
|
|
62
|
+
beginPendingNavigation("/results?search=new&page=1");
|
|
63
|
+
expect(shouldSuspendForPendingNavigation(getNavigationSnapshot())).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
it("can watch a shorthand list of search params", () => {
|
|
66
|
+
beginPendingNavigation("/results?search=new&page=1&sort=asc");
|
|
67
|
+
expect(shouldSuspendForPendingNavigation(getNavigationSnapshot(), {
|
|
68
|
+
searchParams: ["search"],
|
|
69
|
+
})).toBe(true);
|
|
70
|
+
expect(shouldSuspendForPendingNavigation(getNavigationSnapshot(), {
|
|
71
|
+
searchParams: ["page"],
|
|
72
|
+
})).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
it("does not let the searchParams shorthand suspend for pathname-only changes", () => {
|
|
75
|
+
beginPendingNavigation("/other?search=old&page=1");
|
|
76
|
+
expect(shouldSuspendForPendingNavigation(getNavigationSnapshot(), {
|
|
77
|
+
searchParams: ["search", "page"],
|
|
78
|
+
})).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
it("supports explicit watch configuration", () => {
|
|
81
|
+
beginPendingNavigation("/other?search=old&page=2#details");
|
|
82
|
+
expect(shouldSuspendForPendingNavigation(getNavigationSnapshot(), {
|
|
83
|
+
watch: { pathname: false, searchParams: ["page"], hash: false },
|
|
84
|
+
})).toBe(true);
|
|
85
|
+
expect(shouldSuspendForPendingNavigation(getNavigationSnapshot(), {
|
|
86
|
+
watch: { pathname: false, searchParams: ["search"], hash: false },
|
|
87
|
+
})).toBe(false);
|
|
88
|
+
expect(shouldSuspendForPendingNavigation(getNavigationSnapshot(), {
|
|
89
|
+
watch: { pathname: false, searchParams: false, hash: true },
|
|
90
|
+
})).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
it("supports a custom predicate", () => {
|
|
93
|
+
beginPendingNavigation("/results?tab=details&page=1");
|
|
94
|
+
expect(shouldSuspendForPendingNavigation(getNavigationSnapshot(), {
|
|
95
|
+
when: ({ currentUrl, pendingUrl }) => currentUrl.searchParams.get("tab") !==
|
|
96
|
+
pendingUrl.searchParams.get("tab"),
|
|
97
|
+
})).toBe(true);
|
|
98
|
+
expect(shouldSuspendForPendingNavigation(getNavigationSnapshot(), {
|
|
99
|
+
when: ({ currentUrl, pendingUrl }) => currentUrl.searchParams.get("page") !==
|
|
100
|
+
pendingUrl.searchParams.get("page"),
|
|
101
|
+
})).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
it("keeps current payloads suspended but lets the matching navigation payload render", () => {
|
|
104
|
+
beginPendingNavigation("/results?search=new&page=1");
|
|
105
|
+
expect(renderPendingBoundary({
|
|
106
|
+
source: "initial",
|
|
107
|
+
href: "http://localhost/results?search=old&page=1",
|
|
108
|
+
})).toContain("loading");
|
|
109
|
+
expect(renderPendingBoundary({
|
|
110
|
+
source: "navigation",
|
|
111
|
+
href: "http://localhost/results?search=other&page=1",
|
|
112
|
+
})).toContain("loading");
|
|
113
|
+
expect(renderPendingBoundary({
|
|
114
|
+
source: "navigation",
|
|
115
|
+
href: "http://localhost/results?search=new&page=1",
|
|
116
|
+
})).toContain("ready");
|
|
117
|
+
expect(renderPendingBoundary({
|
|
118
|
+
source: "navigation",
|
|
119
|
+
href: "http://localhost/results?search=new&page=1#details",
|
|
120
|
+
})).toContain("ready");
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface NavigationSnapshot {
|
|
2
|
+
/** URL represented by the React tree that has committed to the screen. */
|
|
3
|
+
currentUrl: URL;
|
|
4
|
+
/** The latest client navigation that has updated history but not committed. */
|
|
5
|
+
pending: PendingNavigationSnapshot | null;
|
|
6
|
+
}
|
|
7
|
+
export interface PendingNavigationSnapshot {
|
|
8
|
+
id: number;
|
|
9
|
+
/** URL represented by the currently visible React tree when navigation began. */
|
|
10
|
+
currentUrl: URL;
|
|
11
|
+
/** URL that the pending RSC navigation is rendering. */
|
|
12
|
+
pendingUrl: URL;
|
|
13
|
+
/** Resolves when this pending navigation commits, is superseded, or is aborted. */
|
|
14
|
+
promise: Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
interface PendingNavigationInternal extends PendingNavigationSnapshot {
|
|
17
|
+
resolve: () => void;
|
|
18
|
+
}
|
|
19
|
+
type NavigationListener = () => void;
|
|
20
|
+
export declare function isSameNavigationDocumentUrl(left: string | URL, right: string | URL): boolean;
|
|
21
|
+
export declare function subscribeNavigationState(listener: NavigationListener): () => void;
|
|
22
|
+
export declare function getNavigationSnapshot(): NavigationSnapshot;
|
|
23
|
+
export declare function beginPendingNavigation(url: string | URL): PendingNavigationInternal;
|
|
24
|
+
export declare function isPendingNavigationCommit(href?: string | URL): boolean;
|
|
25
|
+
export declare function commitPendingNavigation(href?: string | URL): boolean;
|
|
26
|
+
export declare function abortPendingNavigation(id?: number): boolean;
|
|
27
|
+
export declare function resetNavigationStateForTests(href?: string): void;
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
const listeners = new Set();
|
|
2
|
+
let nextNavigationId = 0;
|
|
3
|
+
let currentUrl = readWindowUrl();
|
|
4
|
+
let pendingNavigation = null;
|
|
5
|
+
let snapshot = createSnapshot();
|
|
6
|
+
function readWindowUrl() {
|
|
7
|
+
if (typeof window !== "undefined" && window.location?.href) {
|
|
8
|
+
return new URL(window.location.href);
|
|
9
|
+
}
|
|
10
|
+
return new URL("http://localhost/");
|
|
11
|
+
}
|
|
12
|
+
function cloneUrl(url) {
|
|
13
|
+
return new URL(url.href);
|
|
14
|
+
}
|
|
15
|
+
function createSnapshot() {
|
|
16
|
+
return {
|
|
17
|
+
currentUrl: cloneUrl(currentUrl),
|
|
18
|
+
pending: pendingNavigation
|
|
19
|
+
? {
|
|
20
|
+
id: pendingNavigation.id,
|
|
21
|
+
currentUrl: cloneUrl(pendingNavigation.currentUrl),
|
|
22
|
+
pendingUrl: cloneUrl(pendingNavigation.pendingUrl),
|
|
23
|
+
promise: pendingNavigation.promise,
|
|
24
|
+
}
|
|
25
|
+
: null,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function updateSnapshot() {
|
|
29
|
+
snapshot = createSnapshot();
|
|
30
|
+
}
|
|
31
|
+
function notifyListeners() {
|
|
32
|
+
for (const listener of listeners) {
|
|
33
|
+
listener();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function toUrl(url) {
|
|
37
|
+
if (url instanceof URL) {
|
|
38
|
+
return cloneUrl(url);
|
|
39
|
+
}
|
|
40
|
+
return new URL(url, readWindowUrl());
|
|
41
|
+
}
|
|
42
|
+
function toNavigationDocumentHref(url) {
|
|
43
|
+
const nextUrl = toUrl(url);
|
|
44
|
+
nextUrl.hash = "";
|
|
45
|
+
return nextUrl.href;
|
|
46
|
+
}
|
|
47
|
+
export function isSameNavigationDocumentUrl(left, right) {
|
|
48
|
+
return toNavigationDocumentHref(left) === toNavigationDocumentHref(right);
|
|
49
|
+
}
|
|
50
|
+
function createDeferred() {
|
|
51
|
+
let resolve;
|
|
52
|
+
const promise = new Promise((resolvePromise) => {
|
|
53
|
+
resolve = resolvePromise;
|
|
54
|
+
});
|
|
55
|
+
return { promise, resolve };
|
|
56
|
+
}
|
|
57
|
+
export function subscribeNavigationState(listener) {
|
|
58
|
+
listeners.add(listener);
|
|
59
|
+
return () => {
|
|
60
|
+
listeners.delete(listener);
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
export function getNavigationSnapshot() {
|
|
64
|
+
return snapshot;
|
|
65
|
+
}
|
|
66
|
+
export function beginPendingNavigation(url) {
|
|
67
|
+
const supersededNavigation = pendingNavigation;
|
|
68
|
+
const targetUrl = toUrl(url);
|
|
69
|
+
const { promise, resolve } = createDeferred();
|
|
70
|
+
pendingNavigation = {
|
|
71
|
+
id: ++nextNavigationId,
|
|
72
|
+
currentUrl: cloneUrl(currentUrl),
|
|
73
|
+
pendingUrl: targetUrl,
|
|
74
|
+
promise,
|
|
75
|
+
resolve,
|
|
76
|
+
};
|
|
77
|
+
updateSnapshot();
|
|
78
|
+
notifyListeners();
|
|
79
|
+
supersededNavigation?.resolve();
|
|
80
|
+
return pendingNavigation;
|
|
81
|
+
}
|
|
82
|
+
export function isPendingNavigationCommit(href) {
|
|
83
|
+
if (!pendingNavigation) {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
if (href === undefined) {
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
return isSameNavigationDocumentUrl(href, pendingNavigation.pendingUrl);
|
|
90
|
+
}
|
|
91
|
+
export function commitPendingNavigation(href) {
|
|
92
|
+
if (!isPendingNavigationCommit(href)) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
const committedNavigation = pendingNavigation;
|
|
96
|
+
pendingNavigation = null;
|
|
97
|
+
currentUrl = cloneUrl(committedNavigation.pendingUrl);
|
|
98
|
+
updateSnapshot();
|
|
99
|
+
notifyListeners();
|
|
100
|
+
committedNavigation.resolve();
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
export function abortPendingNavigation(id) {
|
|
104
|
+
if (!pendingNavigation) {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
if (id !== undefined && pendingNavigation.id !== id) {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
const abortedNavigation = pendingNavigation;
|
|
111
|
+
pendingNavigation = null;
|
|
112
|
+
updateSnapshot();
|
|
113
|
+
notifyListeners();
|
|
114
|
+
abortedNavigation.resolve();
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
export function resetNavigationStateForTests(href = "http://localhost/") {
|
|
118
|
+
pendingNavigation?.resolve();
|
|
119
|
+
pendingNavigation = null;
|
|
120
|
+
nextNavigationId = 0;
|
|
121
|
+
currentUrl = new URL(href);
|
|
122
|
+
updateSnapshot();
|
|
123
|
+
notifyListeners();
|
|
124
|
+
}
|
|
@@ -4,6 +4,11 @@ export type RscActionResponse<Result> = {
|
|
|
4
4
|
node: React.ReactNode;
|
|
5
5
|
actionResult: Result;
|
|
6
6
|
};
|
|
7
|
+
export type RscPayloadMeta = {
|
|
8
|
+
source: "initial" | "action" | "navigation" | "query";
|
|
9
|
+
/** Browser URL represented by this RSC payload, without the internal __rsc marker. */
|
|
10
|
+
href?: string;
|
|
11
|
+
};
|
|
7
12
|
export type ActionResponseData = {
|
|
8
13
|
status: number;
|
|
9
14
|
headers: {
|
|
@@ -15,14 +20,14 @@ export type ActionResponseMeta = {
|
|
|
15
20
|
};
|
|
16
21
|
export declare function isActionResponse(value: unknown): value is ActionResponseMeta;
|
|
17
22
|
export type TransportContext = {
|
|
18
|
-
setRscPayload: <Result>(v: Promise<RscActionResponse<Result
|
|
23
|
+
setRscPayload: <Result>(v: Promise<RscActionResponse<Result>>, meta?: RscPayloadMeta) => void;
|
|
19
24
|
handleResponse?: (response: Response) => boolean;
|
|
20
25
|
/**
|
|
21
26
|
* Optional callback invoked after a new RSC payload has been committed on the client.
|
|
22
27
|
* This is useful for features like client-side navigation that want to run logic
|
|
23
28
|
* after hydration/updates, e.g. warming navigation caches.
|
|
24
29
|
*/
|
|
25
|
-
onHydrated?: () => void;
|
|
30
|
+
onHydrated?: (meta?: RscPayloadMeta) => void;
|
|
26
31
|
/**
|
|
27
32
|
* Optional callback invoked when an action returns a Response.
|
|
28
33
|
* Return true to signal that the response has been handled and
|
|
@@ -46,7 +46,10 @@ export const realtimeTransport = ({ key = DEFAULT_KEY, handleResponse, }) => (tr
|
|
|
46
46
|
setTimeout(setupWebSocket, 5000);
|
|
47
47
|
});
|
|
48
48
|
listenForUpdates(ws, (response) => {
|
|
49
|
-
processResponse(response
|
|
49
|
+
processResponse(response, {
|
|
50
|
+
source: "action",
|
|
51
|
+
href: window.location.href,
|
|
52
|
+
});
|
|
50
53
|
});
|
|
51
54
|
};
|
|
52
55
|
const ensureWs = () => {
|
|
@@ -58,7 +61,7 @@ export const realtimeTransport = ({ key = DEFAULT_KEY, handleResponse, }) => (tr
|
|
|
58
61
|
}
|
|
59
62
|
return ws;
|
|
60
63
|
};
|
|
61
|
-
const realtimeCallServer = async (id, args,
|
|
64
|
+
const realtimeCallServer = async (id, args, source = "action", _method) => {
|
|
62
65
|
try {
|
|
63
66
|
const socket = ensureWs();
|
|
64
67
|
const { encodeReply } = await import("react-server-dom-webpack/client.browser");
|
|
@@ -78,17 +81,24 @@ export const realtimeTransport = ({ key = DEFAULT_KEY, handleResponse, }) => (tr
|
|
|
78
81
|
});
|
|
79
82
|
const promisedResponse = respondToRequest(requestId, socket);
|
|
80
83
|
socket.send(message);
|
|
81
|
-
return await processResponse(await promisedResponse
|
|
84
|
+
return await processResponse(await promisedResponse, {
|
|
85
|
+
source,
|
|
86
|
+
href: window.location.href,
|
|
87
|
+
});
|
|
82
88
|
}
|
|
83
89
|
catch (e) {
|
|
84
90
|
console.error("[Realtime] Error calling server", e);
|
|
85
91
|
return undefined;
|
|
86
92
|
}
|
|
87
93
|
};
|
|
88
|
-
const processResponse = async (response) => {
|
|
94
|
+
const processResponse = async (response, meta) => {
|
|
89
95
|
try {
|
|
90
96
|
let streamForRsc;
|
|
91
97
|
let shouldContinue = true;
|
|
98
|
+
const isStaleNavigationResponse = () => meta?.source === "navigation" && meta.href !== window.location.href;
|
|
99
|
+
if (isStaleNavigationResponse()) {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
92
102
|
if (transportContext.handleResponse) {
|
|
93
103
|
const [stream1, stream2] = response.body.tee();
|
|
94
104
|
const clonedResponse = new Response(stream1, response);
|
|
@@ -104,7 +114,10 @@ export const realtimeTransport = ({ key = DEFAULT_KEY, handleResponse, }) => (tr
|
|
|
104
114
|
const rscPayload = createFromReadableStream(streamForRsc, {
|
|
105
115
|
callServer: realtimeCallServer,
|
|
106
116
|
});
|
|
107
|
-
|
|
117
|
+
if (isStaleNavigationResponse()) {
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
transportContext.setRscPayload(rscPayload, meta);
|
|
108
121
|
const rawActionResult = (await rscPayload).actionResult;
|
|
109
122
|
if (isActionResponse(rawActionResult)) {
|
|
110
123
|
const actionResponse = rawActionResult.__rw_action_response;
|