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.
@@ -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 type { ActionResponseData } from "./types";
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 { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
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 url = new URL(window.location.href);
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
- transportContext.setRscPayload(streamData);
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
- transportContext.setRscPayload(streamData);
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 [streamData, setStreamData] = React.useState(rscPayload);
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
- setStreamData(v);
252
+ transportContext.setRscPayload = (v, meta) => startTransition(() => {
253
+ setStreamState({ data: v, meta });
217
254
  });
218
255
  React.useEffect(() => {
219
- if (!streamData)
256
+ if (!streamState)
220
257
  return;
221
- transportContext.onHydrated?.();
222
- }, [streamData]);
223
- return (_jsx(_Fragment, { children: streamData
224
- ? React.use(streamData).node
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 a handleResponse function to pass to initClient.
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
- await options.onNavigate?.();
73
- await globalThis.__rsc_callServer(null, null, "navigation");
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 a handleResponse function to pass to initClient.
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
- await opts.onNavigate?.();
140
- await globalThis.__rsc_callServer(null, null, "navigation");
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 now that React has committed the new
187
- // DOM this is what prevents the scroll flash on both link-click and
188
- // popstate navigations.
189
- scrollRestoration?.applyPendingScroll();
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>>) => void;
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, _source, _method) => {
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
- transportContext.setRscPayload(rscPayload);
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rwsdk",
3
- "version": "1.3.3",
3
+ "version": "1.4.0",
4
4
  "description": "Build fast, server-driven webapps on Cloudflare with SSR, RSC, and realtime",
5
5
  "type": "module",
6
6
  "bin": {