Skip to content

Commit e8b3c3a

Browse files
authored
feat: RSC framework mode prerender (#14907)
1 parent 44c3d6b commit e8b3c3a

File tree

23 files changed

+905
-306
lines changed

23 files changed

+905
-306
lines changed

.changeset/olive-suns-rest.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@react-router/dev": patch
3+
---
4+
5+
rsc framework mode prerender / spa mode support

integration/client-data-test.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,12 +149,31 @@ test.describe("Client Data", () => {
149149
templateName,
150150
files: {
151151
"react-router.config.ts": reactRouterConfig({
152-
future: { v8_splitRouteModules },
152+
future: { v8_splitRouteModules, v8_middleware: true },
153153
}),
154154
"app/root.tsx": js`
155155
import { Form, Outlet, Scripts } from "react-router"
156156
157-
export default function Root({ loaderData }) {
157+
export const middleware = [
158+
async ({ request }, next) => {
159+
let response = await next();
160+
161+
if (
162+
request.method === "GET" &&
163+
response instanceof Response &&
164+
response.status === 200 &&
165+
request.headers.get("sec-purpose") === "prefetch" &&
166+
!response.headers.has("Cache-Control")
167+
) {
168+
let cachedResponse = new Response(response.body, response);
169+
cachedResponse.headers.set("Cache-Control", "max-age=5");
170+
return cachedResponse;
171+
}
172+
return response;
173+
}
174+
];
175+
176+
export default function Root() {
158177
return (
159178
<html>
160179
<head></head>
@@ -1541,7 +1560,9 @@ test.describe("Client Data", () => {
15411560
expect(html).toMatch("Child Server Action (mutated by client)");
15421561
});
15431562

1544-
test("child.clientAction/parent.childLoader", async ({ page }) => {
1563+
test("child.clientAction/parent.childLoader", async ({
1564+
page,
1565+
}) => {
15451566
let app = new PlaywrightFixture(appFixture, page);
15461567
await app.goto("/");
15471568
await app.clickLink(

integration/helpers/create-fixture.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,9 @@ export async function createFixture(init: FixtureInit, mode?: ServerMode) {
157157
isSpaMode: init.spaMode,
158158
prerender: init.prerender,
159159
requestDocument(href: string) {
160-
let file = new URL(href, "test://test").pathname + "/index.html";
160+
let pathname = new URL(href, "test://test").pathname;
161+
let file =
162+
(pathname.endsWith("/") ? pathname : pathname + "/") + "index.html";
161163
let clientDir = path.join(projectDir, "build", "client");
162164
let mainPath = path.join(clientDir, file);
163165
let fallbackPath = path.join(clientDir, "__spa-fallback.html");

integration/link-test.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -104,9 +104,9 @@ test.describe("route module link export", () => {
104104
105105
export function links() {
106106
return [
107-
{ rel: "stylesheet", href: resetHref },
108-
{ rel: "stylesheet", href: stylesHref },
109-
{ rel: "stylesheet", href: "/resources/theme-css" },
107+
{ rel: "stylesheet", href: resetHref, presidence: "medium" },
108+
{ rel: "stylesheet", href: stylesHref, presidence: "medium" },
109+
{ rel: "stylesheet", href: "/resources/theme-css", presidence: "medium" },
110110
{ rel: "shortcut icon", href: favicon },
111111
];
112112
}
@@ -247,11 +247,12 @@ test.describe("route module link export", () => {
247247
}
248248
export function links() {
249249
return [
250-
{ rel: "stylesheet", href: redTextHref },
250+
{ rel: "stylesheet", href: redTextHref, presidence: "medium" },
251251
{
252252
rel: "stylesheet",
253253
href: blueTextHref,
254254
media: "(prefers-color-scheme: beef)",
255+
presidence: "medium"
255256
},
256257
{ page: "/gists/mjackson" },
257258
{
@@ -320,7 +321,7 @@ test.describe("route module link export", () => {
320321
import { data, Link, Outlet, useLoaderData, useNavigation } from "react-router";
321322
import stylesHref from "~/gists.css?url";
322323
export function links() {
323-
return [{ rel: "stylesheet", href: stylesHref }];
324+
return [{ rel: "stylesheet", href: stylesHref, presidence: "medium" }];
324325
}
325326
export async function loader() {
326327
return data({

integration/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"react": "catalog:",
3838
"react-dom": "catalog:",
3939
"react-router": "workspace:*",
40+
"react-server-dom-webpack": "catalog:",
4041
"semver": "^7.7.2",
4142
"serialize-javascript": "^6.0.1",
4243
"shelljs": "^0.8.5",
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import type { Page } from "@playwright/test";
2+
import { expect } from "@playwright/test";
3+
4+
import { js } from "../helpers/create-fixture";
5+
import type { TemplateName, Files } from "../helpers/vite";
6+
import { reactRouterConfig, test } from "../helpers/vite";
7+
8+
type PrerenderPaths =
9+
| boolean
10+
| Array<string>
11+
| ((args: {
12+
getStaticPaths: () => string[];
13+
}) => Array<string> | Promise<Array<string>>);
14+
15+
function simplePage(name: string) {
16+
return js`
17+
export default function Route({ params: { slug } }) {
18+
return (
19+
<div>
20+
<h1 data-testid="${name}">{${JSON.stringify(name)} + (slug ? " " + slug : "")}</h1>
21+
</div>
22+
);
23+
}
24+
`;
25+
}
26+
27+
function prerender({
28+
files,
29+
links,
30+
prerender = true,
31+
ssr,
32+
vitePreview,
33+
}: {
34+
files?: Files;
35+
links?: string[];
36+
prerender?:
37+
| PrerenderPaths
38+
| {
39+
paths: PrerenderPaths;
40+
unstable_concurrency?: number;
41+
};
42+
ssr?: boolean;
43+
vitePreview: (
44+
files: Files,
45+
templateName?: TemplateName | undefined,
46+
) => Promise<{
47+
port: number;
48+
cwd: string;
49+
}>;
50+
}) {
51+
return vitePreview(async (args) => {
52+
return {
53+
"react-router.config.ts": reactRouterConfig({ prerender, ssr }),
54+
"app/root.tsx": js`
55+
import { Link, Links, Meta, Outlet, ScrollRestoration, useRouteLoaderData } from "react-router";
56+
57+
export function loader() {
58+
return {
59+
IS_RR_BUILD_REQUEST: process.env.IS_RR_BUILD_REQUEST
60+
}
61+
}
62+
63+
export default function Route() {
64+
return (
65+
<Outlet />
66+
)
67+
}
68+
69+
export function ErrorBoundary() {
70+
return <p data-testid="root-error-boundary">Root Error Boundary</p>
71+
}
72+
73+
export function Layout({ children }) {
74+
const { IS_RR_BUILD_REQUEST } = useRouteLoaderData("root") ?? {};
75+
76+
return (
77+
<html lang="en">
78+
<head>
79+
<meta charSet="utf-8" />
80+
<meta name="viewport" content="width=device-width, initial-scale=1" />
81+
<Meta />
82+
<Links />
83+
</head>
84+
<body>
85+
<p data-testid="IS_RR_BUILD_REQUEST">{IS_RR_BUILD_REQUEST}</p>
86+
${links ? links.map((link) => js`<Link to="${link}">${link}</Link>`).join("\n") : ``}
87+
{children}
88+
<ScrollRestoration />
89+
</body>
90+
</html>
91+
);
92+
}
93+
`,
94+
"app/routes/_index.tsx": simplePage("index"),
95+
...(await files?.(args)),
96+
};
97+
}, "rsc-vite-framework");
98+
}
99+
100+
async function assertPrerendered(page: Page, expectedPrerender = true) {
101+
const isBuildRequest = await page
102+
.getByTestId("IS_RR_BUILD_REQUEST")
103+
.textContent();
104+
expect(isBuildRequest).toBe(expectedPrerender ? "yes" : "");
105+
}
106+
107+
test.describe("rsc prerender", () => {
108+
test("prerenders single route", async ({ page, vitePreview }) => {
109+
const { port } = await prerender({
110+
links: ["/", "/404/not-found"],
111+
vitePreview,
112+
});
113+
const baseUrl = `http://localhost:${port}`;
114+
115+
await page.goto(baseUrl);
116+
await assertPrerendered(page);
117+
const index = await page.getByTestId("index").textContent();
118+
expect(index).toBe("index");
119+
120+
await page.click('text="/404/not-found"');
121+
await page.waitForURL(`${baseUrl}/404/not-found`);
122+
const errorBoundary = await page
123+
.getByTestId("root-error-boundary")
124+
.textContent();
125+
expect(errorBoundary).toBe("Root Error Boundary");
126+
127+
await page.click('text="/"');
128+
await page.waitForURL(baseUrl + "/");
129+
const index2 = await page.getByTestId("index").textContent();
130+
expect(index2).toBe("index");
131+
});
132+
133+
test("prerenders dynamic route", async ({ page, vitePreview }) => {
134+
const { port } = await prerender({
135+
files: async () => ({
136+
"app/routes/products.$slug.tsx": simplePage("product"),
137+
}),
138+
links: ["/", "/products/1", "/products/2"],
139+
prerender: ["/", "/products/1"],
140+
vitePreview,
141+
});
142+
const baseUrl = `http://localhost:${port}`;
143+
144+
await page.goto(baseUrl, { waitUntil: "networkidle" });
145+
const index = await page.getByTestId("index").textContent();
146+
expect(index).toBe("index");
147+
await assertPrerendered(page);
148+
149+
await page.click('text="/products/1"');
150+
await page.waitForURL(`${baseUrl}/products/1`);
151+
const product1 = await page.getByTestId("product").textContent();
152+
expect(product1).toBe("product 1");
153+
154+
await page.click('text="/products/2"');
155+
await page.waitForURL(`${baseUrl}/products/2`);
156+
const product2 = await page.getByTestId("product").textContent();
157+
expect(product2).toBe("product 2");
158+
159+
await page.click('text="/"');
160+
await page.waitForURL(baseUrl + "/");
161+
const index2 = await page.getByTestId("index").textContent();
162+
expect(index2).toBe("index");
163+
});
164+
165+
test("prerenders single page app", async ({ page, vitePreview }) => {
166+
const { port } = await prerender({
167+
links: ["/", "/404/not-found"],
168+
ssr: false,
169+
vitePreview,
170+
});
171+
const baseUrl = `http://localhost:${port}`;
172+
173+
await page.goto(baseUrl);
174+
const index = await page.getByTestId("index").textContent();
175+
expect(index).toBe("index");
176+
await assertPrerendered(page);
177+
178+
await page.click('text="/404/not-found"');
179+
await page.waitForURL(`${baseUrl}/404/not-found`);
180+
const errorBoundary = await page
181+
.getByTestId("root-error-boundary")
182+
.textContent();
183+
expect(errorBoundary).toBe("Root Error Boundary");
184+
});
185+
});

integration/vite-prerender-test.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,25 @@ import { test, expect } from "@playwright/test";
66

77
import {
88
createAppFixture,
9-
createFixture,
9+
createFixture as _createFixture,
1010
js,
1111
} from "./helpers/create-fixture.js";
1212
import type { Fixture, AppFixture } from "./helpers/create-fixture.js";
1313
import { PlaywrightFixture } from "./helpers/playwright-fixture.js";
1414
import { build, createProject, reactRouterConfig } from "./helpers/vite.js";
1515

1616
for (let previewServerPrerendering of [false, true]) {
17+
let createFixture = (...args: Parameters<typeof _createFixture>) =>
18+
_createFixture(
19+
{
20+
templateName:
21+
args[0].templateName ??
22+
(previewServerPrerendering ? "vite-6-template" : "vite-5-template"),
23+
...args[0],
24+
},
25+
args[1],
26+
);
27+
1728
let files = {
1829
"react-router.config.ts": reactRouterConfig({
1930
prerender: true,

integration/vite-preview-test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,9 @@ test.describe("Vite preview", () => {
193193
<div id="content">
194194
<Outlet />
195195
</div>
196+
{Array.from({ length: 100 }).map((_, i) => (
197+
<p key={i}>Filler content {i + 1}</p>
198+
))}
196199
<Scripts />
197200
</body>
198201
</html>

packages/react-router-dev/config/config.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -698,7 +698,9 @@ async function resolveConfig({
698698
v8_splitRouteModules:
699699
userAndPresetConfigs.future?.v8_splitRouteModules ?? false,
700700
v8_viteEnvironmentApi:
701-
userAndPresetConfigs.future?.v8_viteEnvironmentApi ?? false,
701+
(userAndPresetConfigs.future?.v8_viteEnvironmentApi ||
702+
userAndPresetConfigs.future?.unstable_previewServerPrerendering) ??
703+
false,
702704
};
703705

704706
let allowedActionOrigins = userAndPresetConfigs.allowedActionOrigins ?? false;

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ createFromReadableStream<RSCPayload>(getRSCStream()).then((payload) => {
3232
document,
3333
<StrictMode>
3434
<RSCHydratedRouter
35-
payload={payload}
3635
createFromReadableStream={createFromReadableStream}
36+
payload={payload}
3737
/>
3838
</StrictMode>,
3939
{

0 commit comments

Comments
 (0)