Skip to content

Commit c3a52d7

Browse files
committed
fix: handle RSC reload document responses
1 parent 79c5b65 commit c3a52d7

3 files changed

Lines changed: 205 additions & 18 deletions

File tree

.changeset/rsc-reload-document.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"react-router": patch
3+
---
4+
5+
Handle reload-document responses in RSC browser fetches without attempting to decode an empty RSC payload.
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import {
2+
claimRSCReloadDocumentAttempt,
3+
clearRSCReloadDocumentAttempt,
4+
getRSCManifestReloadDocumentTarget,
5+
getRSCReloadDocumentTarget,
6+
isRSCReloadDocumentResponse,
7+
} from "../../lib/rsc/browser";
8+
9+
describe("RSC document reload recovery", () => {
10+
beforeEach(() => {
11+
window.history.replaceState(null, "", "/current?tab=one#section");
12+
clearRSCReloadDocumentAttempt();
13+
});
14+
15+
it("recognizes reload-document responses", () => {
16+
expect(
17+
isRSCReloadDocumentResponse(
18+
new Response(null, {
19+
status: 204,
20+
headers: { "X-Remix-Reload-Document": "true" },
21+
}),
22+
),
23+
).toBe(true);
24+
25+
expect(
26+
isRSCReloadDocumentResponse(
27+
new Response(null, {
28+
status: 200,
29+
headers: { "X-Remix-Reload-Document": "true" },
30+
}),
31+
),
32+
).toBe(false);
33+
});
34+
35+
it("reloads GET RSC responses at the document request target", () => {
36+
expect(
37+
getRSCReloadDocumentTarget(
38+
new Request("http://localhost/target?panel=notes#source"),
39+
),
40+
).toBe("http://localhost/target?panel=notes#source");
41+
});
42+
43+
it("reloads mutation RSC responses at the current document", () => {
44+
expect(
45+
getRSCReloadDocumentTarget(
46+
new Request("http://localhost/target", { method: "POST" }),
47+
),
48+
).toBe("http://localhost/current?tab=one#section");
49+
});
50+
51+
it("reloads manifest mismatches at the pending navigation target", () => {
52+
// @ts-expect-error partial router global for this focused unit test
53+
window.__reactRouterDataRouter = {
54+
state: {
55+
navigation: {
56+
location: {
57+
pathname: "/target",
58+
search: "?panel=notes",
59+
hash: "#source",
60+
},
61+
},
62+
},
63+
};
64+
65+
expect(getRSCManifestReloadDocumentTarget("/target")).toBe(
66+
"/target?panel=notes#source",
67+
);
68+
});
69+
70+
it("reloads fetcher manifest mismatches at the current document", () => {
71+
expect(getRSCManifestReloadDocumentTarget("/target", "fetcher")).toBe(
72+
"http://localhost/current?tab=one#section",
73+
);
74+
});
75+
76+
it("bounds reload attempts by target URL", () => {
77+
expect(claimRSCReloadDocumentAttempt("/target")).toBe(true);
78+
expect(claimRSCReloadDocumentAttempt("/target")).toBe(false);
79+
expect(claimRSCReloadDocumentAttempt("/other")).toBe(true);
80+
81+
clearRSCReloadDocumentAttempt();
82+
expect(claimRSCReloadDocumentAttempt("/target")).toBe(true);
83+
});
84+
});

packages/react-router/lib/rsc/browser.tsx

Lines changed: 116 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { RouterProvider } from "../components";
55
import { RSCRouterContext } from "../context";
66
import { FrameworkContext, setIsHydrated } from "../dom/ssr/components";
77
import type { FrameworkContextObject } from "../dom/ssr/entry";
8-
import { createBrowserHistory, invariant } from "../router/history";
8+
import { createBrowserHistory, createPath, invariant } from "../router/history";
99
import type { Router as DataRouter, RouterInit } from "../router/router";
1010
import {
1111
createRouter,
@@ -68,6 +68,8 @@ type WindowWithRouterGlobals = Window &
6868
__routerActionID: number;
6969
};
7070

71+
const RSC_RELOAD_DOCUMENT_STORAGE_KEY = "react-router-rsc-reload-document";
72+
7173
/**
7274
* Create a React `callServer` implementation for React Router.
7375
*
@@ -123,23 +125,30 @@ export function createCallServer({
123125
(globalVar.__routerActionID ??= 0) + 1);
124126

125127
const temporaryReferences = createTemporaryReferenceSet();
126-
const payloadPromise = fetchImplementation(
127-
new Request(location.href, {
128-
body: await encodeReply(args, { temporaryReferences }),
129-
method: "POST",
130-
headers: {
131-
Accept: "text/x-component",
132-
"rsc-action-id": id,
133-
},
134-
}),
135-
).then((response) => {
136-
if (!response.body) {
137-
throw new Error("No response body");
138-
}
139-
return createFromReadableStream(response.body, {
140-
temporaryReferences,
141-
}) as Promise<RSCPayload>;
128+
const request = new Request(location.href, {
129+
body: await encodeReply(args, { temporaryReferences }),
130+
method: "POST",
131+
headers: {
132+
Accept: "text/x-component",
133+
"rsc-action-id": id,
134+
},
142135
});
136+
const payloadPromise = fetchImplementation(request).then(
137+
async (response) => {
138+
if (isRSCReloadDocumentResponse(response)) {
139+
await reloadRSCResponseDocument(getRSCReloadDocumentTarget(request));
140+
}
141+
142+
clearRSCReloadDocumentAttempt();
143+
144+
if (!response.body) {
145+
throw new Error("No response body");
146+
}
147+
return createFromReadableStream(response.body, {
148+
temporaryReferences,
149+
}) as Promise<RSCPayload>;
150+
},
151+
);
143152

144153
React.startTransition(() =>
145154
// @ts-expect-error - Needs React 19 types
@@ -301,7 +310,7 @@ function createRouterFromPayload({
301310
basename: payload.basename,
302311
isSpaMode: false,
303312
}),
304-
async patchRoutesOnNavigation({ path, signal }) {
313+
async patchRoutesOnNavigation({ path, signal, fetcherKey }) {
305314
if (payload.routeDiscovery.mode === "initial") {
306315
if (!applyPatchesPromise) {
307316
applyPatchesPromise = (async () => {
@@ -334,6 +343,7 @@ function createRouterFromPayload({
334343
createFromReadableStream,
335344
fetchImplementation,
336345
signal,
346+
getRSCManifestReloadDocumentTarget(path, fetcherKey),
337347
);
338348
},
339349
// FIXME: Pass `build.ssr` into this function
@@ -560,6 +570,12 @@ function getFetchAndDecodeViaRSC(
560570
new Request(url, await createRequestInit(request)),
561571
);
562572

573+
if (isRSCReloadDocumentResponse(res)) {
574+
await reloadRSCResponseDocument(getRSCReloadDocumentTarget(request));
575+
}
576+
577+
clearRSCReloadDocumentAttempt();
578+
563579
// If this error'd without hitting the running server, then bubble a normal
564580
// `ErrorResponse` and don't try to decode the body with `turbo-stream`.
565581
//
@@ -1039,6 +1055,7 @@ async function fetchAndApplyManifestPatches(
10391055
createFromReadableStream: BrowserCreateFromReadableStreamFunction,
10401056
fetchImplementation: (request: Request) => Promise<Response>,
10411057
signal?: AbortSignal,
1058+
reloadDocumentPath?: string,
10421059
) {
10431060
let url = getManifestUrl(paths);
10441061
if (url == null) {
@@ -1054,6 +1071,19 @@ async function fetchAndApplyManifestPatches(
10541071
}
10551072

10561073
let response = await fetchImplementation(new Request(url, { signal }));
1074+
if (isRSCReloadDocumentResponse(response)) {
1075+
if (reloadDocumentPath) {
1076+
await reloadRSCResponseDocument(reloadDocumentPath);
1077+
}
1078+
console.warn(
1079+
"Detected an RSC manifest version mismatch during eager route discovery. " +
1080+
"The next navigation to an undiscovered route will reload the document.",
1081+
);
1082+
return;
1083+
}
1084+
1085+
clearRSCReloadDocumentAttempt();
1086+
10571087
if (!response.body || response.status < 200 || response.status >= 300) {
10581088
throw new Error("Unable to fetch new route matches from the server");
10591089
}
@@ -1090,6 +1120,74 @@ function addToFifoQueue(path: string, queue: Set<string>) {
10901120
queue.add(path);
10911121
}
10921122

1123+
export function isRSCReloadDocumentResponse(response: Response): boolean {
1124+
return (
1125+
response.status === 204 && response.headers.has("X-Remix-Reload-Document")
1126+
);
1127+
}
1128+
1129+
export function getRSCReloadDocumentTarget(request: Request): string {
1130+
if (request.method === "GET") {
1131+
return new URL(request.url, window.location.href).toString();
1132+
}
1133+
1134+
return window.location.href;
1135+
}
1136+
1137+
export function getRSCManifestReloadDocumentTarget(
1138+
path: string,
1139+
fetcherKey?: string,
1140+
): string {
1141+
if (fetcherKey) {
1142+
return window.location.href;
1143+
}
1144+
1145+
let navigation = (window as WindowWithRouterGlobals).__reactRouterDataRouter
1146+
?.state.navigation;
1147+
if (navigation?.location) {
1148+
return createPath(navigation.location);
1149+
}
1150+
1151+
return path;
1152+
}
1153+
1154+
export function claimRSCReloadDocumentAttempt(target: string): boolean {
1155+
let href = new URL(target, window.location.href).toString();
1156+
try {
1157+
if (sessionStorage.getItem(RSC_RELOAD_DOCUMENT_STORAGE_KEY) === href) {
1158+
return false;
1159+
}
1160+
sessionStorage.setItem(RSC_RELOAD_DOCUMENT_STORAGE_KEY, href);
1161+
} catch {
1162+
// If sessionStorage is unavailable, still attempt the reload.
1163+
}
1164+
return true;
1165+
}
1166+
1167+
export function clearRSCReloadDocumentAttempt(): void {
1168+
try {
1169+
sessionStorage.removeItem(RSC_RELOAD_DOCUMENT_STORAGE_KEY);
1170+
} catch {
1171+
// Session storage unavailable
1172+
}
1173+
}
1174+
1175+
async function reloadRSCResponseDocument(target: string): Promise<never> {
1176+
let href = new URL(target, window.location.href).toString();
1177+
if (!claimRSCReloadDocumentAttempt(href)) {
1178+
console.error("Unable to reload document due to RSC version mismatch.");
1179+
throw new Error("Unable to reload document due to RSC version mismatch.");
1180+
}
1181+
1182+
window.location.assign(href);
1183+
console.warn("Detected RSC version mismatch, reloading...");
1184+
1185+
// Stall here and let the browser reload, avoiding an ErrorBoundary flash.
1186+
return await new Promise<never>(() => {
1187+
// Intentionally never resolves.
1188+
});
1189+
}
1190+
10931191
// Thanks Josh!
10941192
// https://www.joshwcomeau.com/snippets/javascript/debounce/
10951193
function debounce(callback: (...args: unknown[]) => unknown, wait: number) {

0 commit comments

Comments
 (0)