Skip to content

Commit cb25a21

Browse files
authored
Add support for prerendering (#11539)
1 parent 361eb79 commit cb25a21

File tree

10 files changed

+677
-78
lines changed

10 files changed

+677
-78
lines changed

.changeset/prerendering.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
"react-router": minor
3+
---
4+
5+
- Add support for `prerender` config in the React Router vite plugin, to support existing SSG use-cases
6+
- You can use the `prerender` config to pre-render your `.html` and `.data` files at build time and then serve them statically at runtime (either from a running server or a CDN)
7+
- `prerender` can either be an array of string paths, or a function (sync or async) that returns an array of strings so that you can dynamically generate the paths by talking to your CMS, etc.
8+
9+
```ts
10+
export default defineConfig({
11+
plugins: [
12+
reactRouter({
13+
// Single fetch is required for prerendering (which will be the default in v7)
14+
future: {
15+
unstable_singleFetch: true,
16+
},
17+
async prerender() {
18+
let slugs = await fakeGetSlugsFromCms();
19+
// Prerender these paths into `.html` files at build time, and `.data`
20+
// files if they have loaders
21+
return ["/", "/about", ...slugs.map((slug) => `/product/${slug}`)];
22+
},
23+
}),
24+
tsconfigPaths(),
25+
],
26+
});
27+
28+
async function fakeGetSlugsFromCms() {
29+
await new Promise((r) => setTimeout(r, 1000));
30+
return ["shirt", "hat"];
31+
}
32+
```

integration/error-boundary-v2-test.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ test.describe("ErrorBoundary", () => {
1717

1818
test.beforeAll(async () => {
1919
fixture = await createFixture({
20-
singleFetch: true,
2120
files: {
2221
"app/root.tsx": js`
2322
import { Links, Meta, Outlet, Scripts } from "react-router-dom";
@@ -175,7 +174,12 @@ test.describe("ErrorBoundary", () => {
175174
let app = new PlaywrightFixture(appFixture, page);
176175
await app.goto("/parent");
177176
await app.clickLink("/parent/child-with-boundary");
178-
await waitForAndAssert(page, app, "#child-error", "CDN Error");
177+
await waitForAndAssert(
178+
page,
179+
app,
180+
"#child-error",
181+
"Unable to decode turbo-stream response"
182+
);
179183
});
180184
});
181185

integration/vite-prerender-test.ts

Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
import fs from "node:fs";
2+
import path from "node:path";
3+
import { test, expect } from "@playwright/test";
4+
5+
import {
6+
createAppFixture,
7+
createFixture,
8+
js,
9+
} from "./helpers/create-fixture.js";
10+
import type { Fixture, AppFixture } from "./helpers/create-fixture.js";
11+
import { PlaywrightFixture } from "./helpers/playwright-fixture.js";
12+
import { createProject, build } from "./helpers/vite.js";
13+
14+
let files = {
15+
"vite.config.ts": js`
16+
import { defineConfig } from "vite";
17+
import { vitePlugin as reactRouter } from "@react-router/dev";
18+
19+
export default defineConfig({
20+
build: { manifest: true },
21+
plugins: [
22+
reactRouter({
23+
future: {
24+
unstable_singleFetch: true,
25+
},
26+
prerender: ['/', '/about'],
27+
})
28+
],
29+
});
30+
`,
31+
"app/root.tsx": js`
32+
import * as React from "react";
33+
import { Form, Link, Links, Meta, Outlet, Scripts } from "react-router";
34+
35+
export function meta({ data }) {
36+
return [{
37+
title: "Root Title"
38+
}];
39+
}
40+
41+
export default function Root() {
42+
const [mounted, setMounted] = React.useState(false);
43+
React.useEffect(() => setMounted(true), []);
44+
return (
45+
<html lang="en">
46+
<head>
47+
<Meta />
48+
<Links />
49+
</head>
50+
<body>
51+
<h1>Root</h1>
52+
{!mounted ? <h3>Unmounted</h3> : <h3 data-mounted>Mounted</h3>}
53+
<nav>
54+
<Link to="/">Home</Link><br/>
55+
<Link to="/about">About</Link><br/>
56+
</nav>
57+
<Outlet />
58+
<Scripts />
59+
</body>
60+
</html>
61+
);
62+
}
63+
`,
64+
"app/routes/_index.tsx": js`
65+
import * as React from "react";
66+
import { useLoaderData } from "react-router";
67+
68+
export function meta({ data }) {
69+
return [{
70+
title: "Index Title: " + data
71+
}];
72+
}
73+
74+
export async function loader() {
75+
return "Index Loader Data";
76+
}
77+
78+
export default function Component() {
79+
let data = useLoaderData();
80+
81+
return (
82+
<>
83+
<h2 data-route>Index</h2>
84+
<p data-loader-data>{data}</p>
85+
</>
86+
);
87+
}
88+
`,
89+
"app/routes/about.tsx": js`
90+
import { useActionData, useLoaderData } from "react-router";
91+
92+
export function meta({ data }) {
93+
return [{
94+
title: "About Title: " + data
95+
}];
96+
}
97+
98+
export async function loader() {
99+
return "About Loader Data";
100+
}
101+
102+
export default function Component() {
103+
let data = useLoaderData();
104+
105+
return (
106+
<>
107+
<h2 data-route>About</h2>
108+
<p data-loader-data>{data}</p>
109+
</>
110+
);
111+
}
112+
`,
113+
};
114+
115+
test.describe("Prerendering", () => {
116+
let fixture: Fixture;
117+
let appFixture: AppFixture;
118+
119+
test.afterAll(() => {
120+
appFixture.close();
121+
});
122+
123+
test("Prerenders a static array of routes", async () => {
124+
fixture = await createFixture({
125+
files,
126+
});
127+
appFixture = await createAppFixture(fixture);
128+
129+
let clientDir = path.join(fixture.projectDir, "build", "client");
130+
expect(fs.readdirSync(clientDir)).toEqual([
131+
"_root.data",
132+
"about",
133+
"about.data",
134+
"assets",
135+
"favicon.ico",
136+
"index.html",
137+
]);
138+
expect(fs.readdirSync(path.join(clientDir, "about"))).toEqual([
139+
"index.html",
140+
]);
141+
142+
let res = await fixture.requestDocument("/");
143+
let html = await res.text();
144+
expect(html).toMatch("<title>Index Title: Index Loader Data</title>");
145+
expect(html).toMatch("<h1>Root</h1>");
146+
expect(html).toMatch('<h2 data-route="true">Index</h2>');
147+
expect(html).toMatch('<p data-loader-data="true">Index Loader Data</p>');
148+
149+
res = await fixture.requestDocument("/about");
150+
html = await res.text();
151+
expect(html).toMatch("<title>About Title: About Loader Data</title>");
152+
expect(html).toMatch("<h1>Root</h1>");
153+
expect(html).toMatch('<h2 data-route="true">About</h2>');
154+
expect(html).toMatch('<p data-loader-data="true">About Loader Data</p>');
155+
});
156+
157+
test("Prerenders a dynamic array of routes", async () => {
158+
fixture = await createFixture({
159+
files: {
160+
...files,
161+
"vite.config.ts": js`
162+
import { defineConfig } from "vite";
163+
import { vitePlugin as reactRouter } from "@react-router/dev";
164+
165+
export default defineConfig({
166+
build: { manifest: true },
167+
plugins: [
168+
reactRouter({
169+
future: {
170+
unstable_singleFetch: true,
171+
},
172+
async prerender() {
173+
await new Promise(r => setTimeout(r, 1));
174+
return ['/', '/about'];
175+
},
176+
})
177+
],
178+
});
179+
`,
180+
},
181+
});
182+
appFixture = await createAppFixture(fixture);
183+
184+
let clientDir = path.join(fixture.projectDir, "build", "client");
185+
expect(fs.readdirSync(clientDir)).toEqual([
186+
"_root.data",
187+
"about",
188+
"about.data",
189+
"assets",
190+
"favicon.ico",
191+
"index.html",
192+
]);
193+
expect(fs.readdirSync(path.join(clientDir, "about"))).toEqual([
194+
"index.html",
195+
]);
196+
197+
let res = await fixture.requestDocument("/");
198+
let html = await res.text();
199+
expect(html).toMatch("<title>Index Title: Index Loader Data</title>");
200+
expect(html).toMatch("<h1>Root</h1>");
201+
expect(html).toMatch('<h2 data-route="true">Index</h2>');
202+
expect(html).toMatch('<p data-loader-data="true">Index Loader Data</p>');
203+
204+
res = await fixture.requestDocument("/about");
205+
html = await res.text();
206+
expect(html).toMatch("<title>About Title: About Loader Data</title>");
207+
expect(html).toMatch("<h1>Root</h1>");
208+
expect(html).toMatch('<h2 data-route="true">About</h2>');
209+
expect(html).toMatch('<p data-loader-data="true">About Loader Data</p>');
210+
});
211+
212+
test("Hydrates into a navigable app", async ({ page }) => {
213+
fixture = await createFixture({
214+
files,
215+
});
216+
appFixture = await createAppFixture(fixture);
217+
218+
let requests: string[] = [];
219+
page.on("request", (request) => {
220+
if (request.url().endsWith(".data")) {
221+
requests.push(request.url());
222+
}
223+
});
224+
225+
let app = new PlaywrightFixture(appFixture, page);
226+
await app.goto("/");
227+
await page.waitForSelector("[data-mounted]");
228+
await app.clickLink("/about");
229+
await page.waitForSelector("[data-route]:has-text('About')");
230+
expect(requests.length).toBe(1);
231+
expect(requests[0]).toMatch(/\/about.data$/);
232+
});
233+
234+
test("Serves the prerendered HTML file", async ({ page }) => {
235+
fixture = await createFixture({
236+
files: {
237+
...files,
238+
"app/routes/about.tsx": js`
239+
import { useLoaderData } from 'react-router';
240+
export function loader({ request }) {
241+
return "ABOUT-" + request.headers.has('X-React-Router-Prerender');
242+
}
243+
244+
export default function Comp() {
245+
let data = useLoaderData();
246+
return <h1>About: <span>{data}</span></h1>
247+
}
248+
`,
249+
"app/routes/not-prerendered.tsx": js`
250+
import { useLoaderData } from 'react-router';
251+
export function loader({ request }) {
252+
return "NOT-PRERENDERED-" + request.headers.has('X-React-Router-Prerender');
253+
}
254+
255+
export default function Comp() {
256+
let data = useLoaderData();
257+
return <h1>Not-Prerendered: <span>{data}</span></h1>
258+
}
259+
`,
260+
},
261+
});
262+
appFixture = await createAppFixture(fixture);
263+
264+
let app = new PlaywrightFixture(appFixture, page);
265+
await app.goto("/about");
266+
await page.waitForSelector("[data-mounted]");
267+
expect(await app.getHtml()).toContain("<span>ABOUT-true</span>");
268+
269+
await app.goto("/not-prerendered");
270+
await page.waitForSelector("[data-mounted]");
271+
expect(await app.getHtml()).toContain("<span>NOT-PRERENDERED-false</span>");
272+
});
273+
274+
test("Renders/ down to the proper HydrateFallback", async ({ page }) => {
275+
fixture = await createFixture({
276+
files: {
277+
...files,
278+
"app/routes/parent.tsx": js`
279+
import { Outlet, useLoaderData } from 'react-router';
280+
export function loader() {
281+
return "PARENT";
282+
}
283+
export default function Comp() {
284+
let data = useLoaderData();
285+
return <><p>Parent: {data}</p><Outlet/></>
286+
}
287+
`,
288+
"app/routes/parent.child.tsx": js`
289+
import { Outlet, useLoaderData } from 'react-router';
290+
export function loader() {
291+
return "CHILD";
292+
}
293+
export function HydrateFallback() {
294+
return <p>Child loading...</p>
295+
}
296+
export default function Comp() {
297+
let data = useLoaderData();
298+
return <><p>Child: {data}</p><Outlet/></>
299+
}
300+
`,
301+
"app/routes/parent.child._index.tsx": js`
302+
import { Outlet, useLoaderData } from 'react-router';
303+
export function clientLoader() {
304+
return "INDEX";
305+
}
306+
export default function Comp() {
307+
let data = useLoaderData();
308+
return <><p>Index: {data}</p><Outlet/></>
309+
}
310+
`,
311+
},
312+
});
313+
appFixture = await createAppFixture(fixture);
314+
315+
let res = await fixture.requestDocument("/parent/child");
316+
let html = await res.text();
317+
expect(html).toContain("<p>Child loading...</p>");
318+
319+
let app = new PlaywrightFixture(appFixture, page);
320+
await app.goto("/parent/child");
321+
await page.waitForSelector("[data-mounted]");
322+
expect(await app.getHtml()).toMatch("Index: INDEX");
323+
});
324+
});

0 commit comments

Comments
 (0)