Skip to content

Commit 59e1713

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

9 files changed

Lines changed: 520 additions & 46 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+
Add versioned document recovery for RSC navigations, route discovery, and server actions.

packages/react-router-dev/config/default-rsc-entries/entry.rsc.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
// Import the routes generated by routes.ts
1515
import routes from "virtual:react-router/unstable_rsc/routes";
1616
import routeDiscovery from "virtual:react-router/unstable_rsc/route-discovery";
17+
import version from "virtual:react-router/unstable_rsc/version";
1718
import basename from "virtual:react-router/unstable_rsc/basename";
1819
import unstable_reactRouterServeConfig from "virtual:react-router/unstable_rsc/react-router-serve-config";
1920

@@ -38,6 +39,7 @@ export function fetchServer(
3839
routes,
3940
// The route discovery configuration.
4041
routeDiscovery,
42+
version,
4143
// Encode the match with the React Server implementation.
4244
generateResponse(match, options) {
4345
return new Response(renderToReadableStream(match.payload, options), {

packages/react-router-dev/rsc-types.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,9 @@ declare module "virtual:react-router/unstable_rsc/route-discovery" {
3535
export default routeDiscovery;
3636
}
3737

38+
declare module "virtual:react-router/unstable_rsc/version" {
39+
const version: string;
40+
export default version;
41+
}
42+
3843
declare module "virtual:react-router/unstable_rsc/inject-hmr-runtime" {}

packages/react-router-dev/vite/rsc/plugin.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ export function reactRouterRSCVitePlugin(): Vite.PluginOption[] {
5454

5555
let config: ResolvedReactRouterConfig;
5656
let rootRouteFile: string;
57+
let rscVersion = `${Date.now().toString(36)}-${Math.random()
58+
.toString(36)
59+
.slice(2)}`;
5760
function updateConfig(newConfig: ResolvedReactRouterConfig) {
5861
config = newConfig;
5962
rootRouteFile = Path.resolve(
@@ -566,6 +569,19 @@ export function reactRouterRSCVitePlugin(): Vite.PluginOption[] {
566569
}
567570
},
568571
},
572+
{
573+
name: "react-router/rsc/virtual-version",
574+
resolveId(id) {
575+
if (id === virtual.version.id) {
576+
return virtual.version.resolvedId;
577+
}
578+
},
579+
load(id) {
580+
if (id === virtual.version.resolvedId) {
581+
return `export default ${JSON.stringify(rscVersion)};`;
582+
}
583+
},
584+
},
569585
{
570586
name: "react-router/rsc/hmr/inject-runtime",
571587
enforce: "pre",
@@ -776,6 +792,7 @@ export function reactRouterRSCVitePlugin(): Vite.PluginOption[] {
776792
const virtual = {
777793
routeConfig: create("unstable_rsc/routes"),
778794
routeDiscovery: create("unstable_rsc/route-discovery"),
795+
version: create("unstable_rsc/version"),
779796
injectHmrRuntime: create("unstable_rsc/inject-hmr-runtime"),
780797
basename: create("unstable_rsc/basename"),
781798
reactRouterServeConfig: create("unstable_rsc/react-router-serve-config"),
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
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+
// @ts-expect-error test-only router globals
13+
window.__reactRouterDataRouter = undefined;
14+
// @ts-expect-error test-only router globals
15+
window.__reactRouterRSCVersion = undefined;
16+
clearRSCReloadDocumentAttempt();
17+
});
18+
19+
it("recognizes reload-document responses", () => {
20+
expect(
21+
isRSCReloadDocumentResponse(
22+
new Response(null, {
23+
status: 204,
24+
headers: { "X-Remix-Reload-Document": "true" },
25+
}),
26+
),
27+
).toBe(true);
28+
29+
expect(
30+
isRSCReloadDocumentResponse(
31+
new Response(null, {
32+
status: 200,
33+
headers: { "X-Remix-Reload-Document": "true" },
34+
}),
35+
),
36+
).toBe(false);
37+
});
38+
39+
it("reloads GET RSC responses at the document request target", () => {
40+
// @ts-expect-error partial router global for this focused unit test
41+
window.__reactRouterDataRouter = {
42+
state: {
43+
navigation: {
44+
location: {
45+
pathname: "/target",
46+
search: "?panel=notes",
47+
hash: "#source",
48+
},
49+
},
50+
},
51+
};
52+
53+
expect(
54+
getRSCReloadDocumentTarget(
55+
new Request("http://localhost/target?panel=notes"),
56+
),
57+
).toBe("/target?panel=notes#source");
58+
});
59+
60+
it("reloads GET fetcher RSC responses at the current document", () => {
61+
expect(
62+
getRSCReloadDocumentTarget(
63+
new Request("http://localhost/target?panel=notes"),
64+
"fetcher",
65+
),
66+
).toBe("http://localhost/current?tab=one#section");
67+
});
68+
69+
it("reloads mutation RSC responses at the current document", () => {
70+
expect(
71+
getRSCReloadDocumentTarget(
72+
new Request("http://localhost/target", { method: "POST" }),
73+
),
74+
).toBe("http://localhost/current?tab=one#section");
75+
});
76+
77+
it("reloads manifest mismatches at the pending navigation target", () => {
78+
// @ts-expect-error partial router global for this focused unit test
79+
window.__reactRouterDataRouter = {
80+
state: {
81+
navigation: {
82+
location: {
83+
pathname: "/target",
84+
search: "?panel=notes",
85+
hash: "#source",
86+
},
87+
},
88+
},
89+
};
90+
91+
expect(getRSCManifestReloadDocumentTarget("/target")).toBe(
92+
"/target?panel=notes#source",
93+
);
94+
});
95+
96+
it("reloads fetcher manifest mismatches at the current document", () => {
97+
expect(getRSCManifestReloadDocumentTarget("/target", "fetcher")).toBe(
98+
"http://localhost/current?tab=one#section",
99+
);
100+
});
101+
102+
it("bounds reload attempts by target URL and client version", () => {
103+
// @ts-expect-error test-only router globals
104+
window.__reactRouterRSCVersion = "version-a";
105+
expect(claimRSCReloadDocumentAttempt("/target")).toBe(true);
106+
expect(claimRSCReloadDocumentAttempt("/target")).toBe(false);
107+
108+
// @ts-expect-error test-only router globals
109+
window.__reactRouterRSCVersion = "version-b";
110+
expect(claimRSCReloadDocumentAttempt("/target")).toBe(true);
111+
expect(claimRSCReloadDocumentAttempt("/other")).toBe(true);
112+
113+
clearRSCReloadDocumentAttempt();
114+
expect(claimRSCReloadDocumentAttempt("/target")).toBe(true);
115+
});
116+
});
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import {
2+
unstable_matchRSCServerRequest as matchRSCServerRequest,
3+
type unstable_RSCMatch as RSCMatch,
4+
} from "../../index-react-server";
5+
import { unstable_routeRSCServerRequest as routeRSCServerRequest } from "../../index";
6+
7+
describe("RSC server version recovery", () => {
8+
it("returns a reload document response for stale manifest requests", async () => {
9+
let response = await matchRSCServerRequest({
10+
createTemporaryReferenceSet: () => undefined,
11+
request: new Request("http://localhost/target.manifest?version=old"),
12+
routes: [],
13+
version: "new",
14+
generateResponse,
15+
});
16+
17+
expect(response.status).toBe(204);
18+
expect(response.headers.get("X-Remix-Reload-Document")).toBe("true");
19+
});
20+
21+
it("returns a reload document response for stale configured manifest requests", async () => {
22+
let response = await matchRSCServerRequest({
23+
createTemporaryReferenceSet: () => undefined,
24+
request: new Request(
25+
"http://localhost/__routes?paths=/target&version=old",
26+
),
27+
routeDiscovery: { mode: "lazy", manifestPath: "/__routes" },
28+
routes: [],
29+
version: "new",
30+
generateResponse,
31+
});
32+
33+
expect(response.status).toBe(204);
34+
expect(response.headers.get("X-Remix-Reload-Document")).toBe("true");
35+
});
36+
37+
it("returns a reload document response for stale RSC data requests", async () => {
38+
let response = await matchRSCServerRequest({
39+
createTemporaryReferenceSet: () => undefined,
40+
request: new Request("http://localhost/target.rsc", {
41+
headers: { "X-React-Router-RSC-Version": "old" },
42+
}),
43+
routes: [],
44+
version: "new",
45+
generateResponse,
46+
});
47+
48+
expect(response.status).toBe(204);
49+
expect(response.headers.get("X-Remix-Reload-Document")).toBe("true");
50+
});
51+
52+
it("returns a reload document response for stale server action requests", async () => {
53+
let response = await matchRSCServerRequest({
54+
createTemporaryReferenceSet: () => undefined,
55+
request: new Request("http://localhost/current", {
56+
method: "POST",
57+
headers: {
58+
"rsc-action-id": "action",
59+
"X-React-Router-RSC-Version": "old",
60+
},
61+
}),
62+
routes: [],
63+
version: "new",
64+
generateResponse,
65+
});
66+
67+
expect(response.status).toBe(204);
68+
expect(response.headers.get("X-Remix-Reload-Document")).toBe("true");
69+
});
70+
71+
it("passes through configured manifest responses from the SSR router", async () => {
72+
let response = await routeRSCServerRequest({
73+
request: new Request(
74+
"http://localhost/__routes?paths=/target&version=new",
75+
),
76+
serverResponse: new Response("manifest", {
77+
headers: { "React-Router-RSC-Manifest": "true" },
78+
}),
79+
createFromReadableStream: async () => {
80+
throw new Error("Should not decode manifest responses");
81+
},
82+
renderHTML() {
83+
throw new Error("Should not render manifest responses");
84+
},
85+
});
86+
87+
expect(await response.text()).toBe("manifest");
88+
});
89+
});
90+
91+
function generateResponse(match: RSCMatch) {
92+
return new Response(JSON.stringify(match.payload), {
93+
status: match.statusCode,
94+
headers: match.headers,
95+
});
96+
}

0 commit comments

Comments
 (0)