Skip to content

Commit 6286f90

Browse files
authored
feat(rsc): reload stale clients after new deployments (#15318)
1 parent 95c7c8b commit 6286f90

11 files changed

Lines changed: 554 additions & 70 deletions

File tree

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
import path from "node:path";
2+
import { pathToFileURL } from "node:url";
3+
import { expect, type Page } from "@playwright/test";
4+
5+
import {
6+
reactRouterConfig,
7+
test,
8+
type Files,
9+
type TemplateName,
10+
} from "./helpers/vite.js";
11+
12+
const js = String.raw;
13+
const templateName = "rsc-vite-framework" as const satisfies TemplateName;
14+
15+
function getFiles() {
16+
return {
17+
"app/root.tsx": js`
18+
import { Link, Outlet } from "react-router";
19+
20+
export default function Root() {
21+
return (
22+
<html lang="en">
23+
<body>
24+
<Link to="/other?token=abc123&ref=campaign#section1">
25+
Go to Other
26+
</Link>
27+
<Outlet />
28+
</body>
29+
</html>
30+
);
31+
}
32+
`,
33+
"app/routes/_index.tsx": js`
34+
export default function Index() {
35+
return <h1>Home</h1>;
36+
}
37+
`,
38+
"app/routes/other.tsx": js`
39+
import { useLocation } from "react-router";
40+
41+
export default function Other() {
42+
let location = useLocation();
43+
return (
44+
<h1 data-location>
45+
{location.pathname + location.search + location.hash}
46+
</h1>
47+
);
48+
}
49+
`,
50+
};
51+
}
52+
53+
function trackDocumentRequests(page: Page) {
54+
let requests: string[] = [];
55+
page.on("request", (request) => {
56+
if (request.resourceType() === "document") {
57+
requests.push(request.url());
58+
}
59+
});
60+
return requests;
61+
}
62+
63+
async function interceptWithStaleClientVersion(page: Page) {
64+
let manifestRequests: string[] = [];
65+
await page.route(/\.manifest(?:\?|$)/, async (route) => {
66+
let url = new URL(route.request().url());
67+
manifestRequests.push(url.href);
68+
url.searchParams.set("version", "stale");
69+
await route.continue({ url: url.href });
70+
});
71+
return manifestRequests;
72+
}
73+
74+
test.describe("RSC client versions", () => {
75+
test("derives the client version from the Vite RSC assets manifest", async ({
76+
page,
77+
request,
78+
vitePreview,
79+
}) => {
80+
let files: Files = async () => getFiles();
81+
let { cwd, port } = await vitePreview(files, templateName);
82+
let baseUrl = `http://localhost:${port}`;
83+
let documentRequests = trackDocumentRequests(page);
84+
let manifestRequests: string[] = [];
85+
page.on("request", (request) => {
86+
if (/\.manifest(?:\?|$)/.test(request.url())) {
87+
manifestRequests.push(request.url());
88+
}
89+
});
90+
91+
await page.goto(`${baseUrl}/`);
92+
await expect.poll(() => manifestRequests.length).toBeGreaterThan(0);
93+
expect(manifestRequests[0]).toMatch(
94+
/\/other\.manifest\?version=[a-f0-9]{8}$/,
95+
);
96+
97+
let clientVersion = new URL(manifestRequests[0]).searchParams.get(
98+
"version",
99+
);
100+
let { default: assetsManifest } = await import(
101+
pathToFileURL(
102+
path.join(cwd, "build/server/__vite_rsc_assets_manifest.js"),
103+
).href
104+
);
105+
let { clientVersion: manifestClientVersion, ...versionedManifestValues } =
106+
assetsManifest;
107+
expect(manifestClientVersion).toBe(clientVersion);
108+
expect(clientVersion).toBe(
109+
await getClientVersion(JSON.stringify(versionedManifestValues)),
110+
);
111+
112+
let currentResponse = await request.get(
113+
`${baseUrl}/other.manifest?version=${clientVersion}`,
114+
);
115+
expect(currentResponse.status()).toBe(200);
116+
117+
await page.getByRole("link", { name: "Go to Other" }).click();
118+
await expect(page.locator("[data-location]")).toHaveText(
119+
"/other?token=abc123&ref=campaign#section1",
120+
);
121+
expect(documentRequests).toHaveLength(1);
122+
});
123+
124+
test("defers an eager discovery mismatch until navigation, then reloads the destination", async ({
125+
page,
126+
vitePreview,
127+
}) => {
128+
let files: Files = async () => getFiles();
129+
let { port } = await vitePreview(files, templateName);
130+
let baseUrl = `http://localhost:${port}`;
131+
let documentRequests = trackDocumentRequests(page);
132+
let manifestRequests = await interceptWithStaleClientVersion(page);
133+
let eagerMismatch = page.waitForResponse(
134+
(response) =>
135+
response.url().includes(".manifest") && response.status() === 204,
136+
);
137+
138+
await page.goto(`${baseUrl}/`);
139+
await eagerMismatch;
140+
await expect.poll(() => manifestRequests.length).toBeGreaterThan(0);
141+
142+
// An eager discovery mismatch should not disrupt the current document.
143+
await expect(page.getByRole("heading", { name: "Home" })).toBeVisible();
144+
expect(documentRequests).toHaveLength(1);
145+
146+
await page.getByRole("link", { name: "Go to Other" }).click();
147+
148+
await expect(page.locator("[data-location]")).toHaveText(
149+
"/other?token=abc123&ref=campaign#section1",
150+
);
151+
expect(page.url()).toBe(
152+
`${baseUrl}/other?token=abc123&ref=campaign#section1`,
153+
);
154+
expect(documentRequests).toHaveLength(2);
155+
expect(documentRequests[1]).toBe(
156+
`${baseUrl}/other?token=abc123&ref=campaign`,
157+
);
158+
});
159+
160+
test("reloads when a prerendered RSC payload has a new client version", async ({
161+
page,
162+
vitePreview,
163+
}) => {
164+
let files: Files = async () => ({
165+
...getFiles(),
166+
"react-router.config.ts": reactRouterConfig({
167+
ssr: false,
168+
prerender: ["/", "/other"],
169+
}),
170+
"app/root.tsx": js`
171+
import { Link, Outlet } from "react-router";
172+
173+
export default function Root() {
174+
return (
175+
<html lang="en">
176+
<body>
177+
<Link to="/other">Go to Other</Link>
178+
<Outlet />
179+
</body>
180+
</html>
181+
);
182+
}
183+
`,
184+
});
185+
let { cwd, port } = await vitePreview(files, templateName);
186+
let baseUrl = `http://localhost:${port}`;
187+
let documentRequests = trackDocumentRequests(page);
188+
189+
let { default: assetsManifest } = await import(
190+
pathToFileURL(
191+
path.join(cwd, "build/server/__vite_rsc_assets_manifest.js"),
192+
).href
193+
);
194+
let clientVersion = assetsManifest.clientVersion as string;
195+
let newVersion = clientVersion === "deadbeef" ? "feedface" : "deadbeef";
196+
197+
let replacedVersion = false;
198+
await page.route(/\/other\.rsc(?:\?|$)/, async (route) => {
199+
if (replacedVersion) {
200+
await route.continue();
201+
return;
202+
}
203+
204+
replacedVersion = true;
205+
let response = await route.fetch();
206+
let source = await response.text();
207+
expect(source).toContain(clientVersion);
208+
await route.fulfill({
209+
response,
210+
body: source.replaceAll(clientVersion, newVersion),
211+
});
212+
});
213+
214+
await page.goto(`${baseUrl}/`);
215+
let rscResponse = page.waitForResponse(
216+
(response) =>
217+
response.url().includes("/other.rsc") && response.status() === 200,
218+
);
219+
220+
await page.getByRole("link", { name: "Go to Other" }).click();
221+
await rscResponse;
222+
223+
await expect(page.locator("[data-location]")).toHaveText("/other");
224+
expect(documentRequests).toHaveLength(2);
225+
expect(documentRequests[1]).toBe(`${baseUrl}/other`);
226+
});
227+
228+
test("does not reload repeatedly for the same stale client version", async ({
229+
page,
230+
vitePreview,
231+
}) => {
232+
let files: Files = async () => getFiles();
233+
let { port } = await vitePreview(files, templateName);
234+
let baseUrl = `http://localhost:${port}`;
235+
let documentRequests = trackDocumentRequests(page);
236+
let manifestRequests = await interceptWithStaleClientVersion(page);
237+
let consoleErrors: string[] = [];
238+
page.on("console", (message) => {
239+
if (message.type() === "error") consoleErrors.push(message.text());
240+
});
241+
let eagerMismatch = page.waitForResponse(
242+
(response) =>
243+
response.url().includes(".manifest") && response.status() === 204,
244+
);
245+
246+
await page.goto(`${baseUrl}/`);
247+
await eagerMismatch;
248+
await expect.poll(() => manifestRequests.length).toBeGreaterThan(0);
249+
250+
let clientVersion = new URL(manifestRequests[0]).searchParams.get(
251+
"version",
252+
);
253+
await page.evaluate((version) => {
254+
sessionStorage.setItem("react-router-manifest-version", version!);
255+
}, clientVersion);
256+
257+
await page.getByRole("link", { name: "Go to Other" }).click();
258+
await expect.poll(() => manifestRequests.length).toBeGreaterThan(1);
259+
await expect
260+
.poll(() => consoleErrors)
261+
.toContain("Unable to discover routes due to manifest version mismatch.");
262+
263+
expect(documentRequests).toHaveLength(1);
264+
});
265+
});
266+
267+
async function getClientVersion(source: string) {
268+
let digest = await globalThis.crypto.subtle.digest(
269+
"SHA-256",
270+
new TextEncoder().encode(source),
271+
);
272+
return Array.from(new Uint8Array(digest))
273+
.map((byte) => byte.toString(16).padStart(2, "0"))
274+
.join("")
275+
.slice(0, 8);
276+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add `unstable_rsc/client-version` client build version virtual module

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
import routes from "virtual:react-router/unstable_rsc/routes";
1616
import routeDiscovery from "virtual:react-router/unstable_rsc/route-discovery";
1717
import basename from "virtual:react-router/unstable_rsc/basename";
18+
import clientVersion from "virtual:react-router/unstable_rsc/client-version";
1819
import unstable_reactRouterServeConfig from "virtual:react-router/unstable_rsc/react-router-serve-config";
1920

2021
export { unstable_reactRouterServeConfig };
@@ -31,6 +32,8 @@ export function fetchServer(
3132
decodeFormState,
3233
decodeReply,
3334
loadServerAction,
35+
// Detect stale clients after a new deployment.
36+
clientVersion,
3437
// The incoming request.
3538
request,
3639
requestContext,

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/client-version" {
39+
const clientVersion: string;
40+
export default clientVersion;
41+
}
42+
3843
declare module "virtual:react-router/unstable_rsc/inject-hmr-runtime" {}

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

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,17 @@ export function reactRouterRSCVitePlugin(): Vite.PluginOption[] {
134134
).code;
135135
}
136136

137+
async function getClientVersion(source: string) {
138+
const digest = await globalThis.crypto.subtle.digest(
139+
"SHA-256",
140+
new TextEncoder().encode(source),
141+
);
142+
return Array.from(new Uint8Array(digest))
143+
.map((byte) => byte.toString(16).padStart(2, "0"))
144+
.join("")
145+
.slice(0, 8);
146+
}
147+
137148
return [
138149
{
139150
name: "react-router/rsc",
@@ -572,6 +583,52 @@ export function reactRouterRSCVitePlugin(): Vite.PluginOption[] {
572583
}
573584
},
574585
},
586+
{
587+
name: "react-router/rsc/virtual-client-version",
588+
resolveId(id) {
589+
if (id === virtual.clientVersion.id) {
590+
return virtual.clientVersion.resolvedId;
591+
}
592+
},
593+
load(id) {
594+
if (id === virtual.clientVersion.resolvedId) {
595+
return viteCommand === "build"
596+
? `
597+
import assetsManifest from "virtual:vite-rsc/assets-manifest";
598+
export default assetsManifest.clientVersion;
599+
`
600+
: `export default "development";`;
601+
}
602+
},
603+
generateBundle: {
604+
order: "post",
605+
async handler() {
606+
if (this.environment.name !== "client") return;
607+
608+
const viteRscPlugin = resolvedViteConfig.plugins.find(
609+
(plugin) => plugin.name === "rsc:minimal",
610+
) as
611+
| (Vite.Plugin & {
612+
api?: {
613+
manager?: {
614+
buildAssetsManifest?: Record<string, unknown>;
615+
};
616+
};
617+
})
618+
| undefined;
619+
const assetsManifest =
620+
viteRscPlugin?.api?.manager?.buildAssetsManifest;
621+
invariant(assetsManifest, "Vite RSC assets manifest not found");
622+
623+
// Add the version before the Vite RSC plugin serializes this manifest
624+
// into the RSC and SSR builds. This lets the virtual module expose a
625+
// build-time value without rewriting files after Vite writes them.
626+
assetsManifest.clientVersion = await getClientVersion(
627+
JSON.stringify(assetsManifest),
628+
);
629+
},
630+
},
631+
},
575632
{
576633
name: "react-router/rsc/hmr/inject-runtime",
577634
enforce: "pre",
@@ -782,6 +839,7 @@ export function reactRouterRSCVitePlugin(): Vite.PluginOption[] {
782839
const virtual = {
783840
routeConfig: create("unstable_rsc/routes"),
784841
routeDiscovery: create("unstable_rsc/route-discovery"),
842+
clientVersion: create("unstable_rsc/client-version"),
785843
injectHmrRuntime: create("unstable_rsc/inject-hmr-runtime"),
786844
basename: create("unstable_rsc/basename"),
787845
reactRouterServeConfig: create("unstable_rsc/react-router-serve-config"),
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
Detect stale RSC clients during lazy route discovery and reload the destination document
2+
3+
### Migration
4+
5+
Apps using the default RSC Framework entry do not need to make any changes. Apps with a custom `entry.rsc.tsx` should import the generated client version and pass it to `unstable_matchRSCServerRequest`:
6+
7+
```tsx
8+
import clientVersion from "virtual:react-router/unstable_rsc/client-version";
9+
10+
return unstable_matchRSCServerRequest({
11+
// ...
12+
clientVersion,
13+
});
14+
```

0 commit comments

Comments
 (0)