Skip to content

Commit 57925f1

Browse files
prerenderConcurrency option (#14380)
1 parent 900636f commit 57925f1

File tree

8 files changed

+252
-64
lines changed

8 files changed

+252
-64
lines changed

.changeset/big-drinks-invite.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+
Introduce a `prerender.unstable_concurrency` option, to support running the prerendering concurrently, potentially speeding up the build.

contributors.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@
212212
- kigawas
213213
- kilavvy
214214
- kiliman
215+
- kirillgroshkov
215216
- kkirsche
216217
- kno-raziel
217218
- knownasilya

docs/how-to/pre-rendering.md

Lines changed: 84 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,43 +9,105 @@ title: Pre-Rendering
99
<br/>
1010
<br/>
1111

12-
Pre-Rendering allows you to speed up page loads for static content by rendering pages at build time instead of at runtime. Pre-rendering is enabled via the `prerender` config in `react-router.config.ts` and can be used in two ways based on the `ssr` config value:
12+
Pre-Rendering allows you to speed up page loads for static content by rendering pages at build time instead of at runtime.
1313

14-
- Alongside a runtime SSR server with `ssr:true` (the default value)
15-
- Deployed to a static file server with `ssr:false`
14+
## Configuration
15+
16+
Pre-rendering is enabled via the `prerender` config in `react-router.config.ts`.
17+
18+
The simplest configuration is a boolean `true` which will pre-render all off the applications static paths based on `routes.ts`:
19+
20+
```ts filename=react-router.config.ts
21+
import type { Config } from "@react-router/dev/config";
1622

17-
## Pre-rendering with `ssr:true`
23+
export default {
24+
prerender: true,
25+
} satisfies Config;
26+
```
1827

19-
### Configuration
28+
The boolean `true` will not include any dynamic paths (i.e., `/blog/:slug`) because the parameter values are unknown.
2029

21-
Add the `prerender` option to your config, there are three signatures:
30+
To configure specific paths including dynamic values, you can specify an array of paths:
2231

23-
```ts filename=react-router.config.ts lines=[7-8,10-11,13-21]
32+
```ts filename=react-router.config.ts
2433
import type { Config } from "@react-router/dev/config";
2534

35+
let slugs = getPostSlugs();
36+
2637
export default {
27-
// Can be omitted - defaults to true
28-
ssr: true,
38+
prerender: [
39+
"/",
40+
"/blog",
41+
...slugs.map((s) => `/blog/${s}`),
42+
],
43+
} satisfies Config;
44+
```
2945

30-
// all static paths (no dynamic segments like "/post/:slug")
31-
prerender: true,
46+
If you need to perform more complex and/or asynchronous logic to determine the paths, you can also provide a function that returns an array of paths. This function provides you with a `getStaticPaths` method you can use to avoid manually adding all of the static paths in your application:
3247

33-
// specific paths
34-
prerender: ["/", "/blog", "/blog/popular-post"],
48+
```ts filename=react-router.config.ts
49+
import type { Config } from "@react-router/dev/config";
3550

36-
// async function for dependencies like a CMS
51+
export default {
3752
async prerender({ getStaticPaths }) {
38-
let posts = await fakeGetPostsFromCMS();
53+
let slugs = await getPostSlugsFromCMS();
3954
return [
55+
...getStaticPaths(), // "/" and "/blog"
56+
...slugs.map((s) => `/blog/${s}`),
57+
];
58+
},
59+
} satisfies Config;
60+
```
61+
62+
### Concurrency (unstable)
63+
64+
<docs-warning>This API is experimental and subject to breaking changes in
65+
minor/patch releases. Please use with caution and pay **very** close attention
66+
to release notes for relevant changes.</docs-warning>
67+
68+
By default, pages are pre-rendered one path at a time. You can enable concurrency to pre-render multiple paths in parallel which can speed up build times in many cases. You should experiment with the value that provides the best performance for your app.
69+
70+
To specify concurrency, move your `prerender` config down into a `prerender.paths` field and you can specify the concurrency in `prerender.unstable_concurrency`:
71+
72+
```ts filename=react-router.config.ts
73+
import type { Config } from "@react-router/dev/config";
74+
75+
let slugs = getPostSlugs();
76+
77+
export default {
78+
prerender: {
79+
paths: [
4080
"/",
4181
"/blog",
42-
...posts.map((post) => post.href),
43-
];
82+
...slugs.map((s) => `/blog/${s}`),
83+
],
84+
unstable_concurrency: 4,
4485
},
4586
} satisfies Config;
4687
```
4788

48-
### Data Loading and Pre-rendering
89+
## Pre-Rendering with/without a Runtime Server
90+
91+
Pre-Rendering can be used in two ways based on the `ssr` config value:
92+
93+
- Alongside a runtime SSR server with `ssr:true` (the default value)
94+
- Deployed to a static file server with `ssr:false`
95+
96+
### Pre-rendering with `ssr:true`
97+
98+
When pre-rendering with `ssr:true`, you're indicating you will still have a runtime server but you are choosing to pre-render certain paths for quicker Response times.
99+
100+
```ts filename=react-router.config.ts
101+
import type { Config } from "@react-router/dev/config";
102+
103+
export default {
104+
// Can be omitted - defaults to true
105+
ssr: true,
106+
prerender: ["/", "/blog", "/blog/popular-post"],
107+
} satisfies Config;
108+
```
109+
110+
#### Data Loading and Pre-rendering
49111

50112
There is no extra application API for pre-rendering. Routes being pre-rendered use the same route `loader` functions as server rendering:
51113

@@ -64,7 +126,7 @@ Instead of a request coming to your route on a deployed server, the build create
64126

65127
When server rendering, requests to paths that have not been pre-rendered will be server rendered as usual.
66128

67-
### Static File Output
129+
#### Static File Output
68130

69131
The rendered result will be written out to your `build/client` directory. You'll notice two files for each path:
70132

@@ -89,7 +151,7 @@ Prerender: Generated build/client/blog/my-first-post/index.html
89151

90152
During development, pre-rendering doesn't save the rendered results to the public directory, this only happens for `react-router build`.
91153

92-
## Pre-rendering with `ssr:false`
154+
### Pre-rendering with `ssr:false`
93155

94156
The above examples assume you are deploying a runtime server but are pre-rendering some static pages to avoid hitting the server, resulting in faster loads.
95157

@@ -108,7 +170,7 @@ If you specify `ssr:false` without a `prerender` config, React Router refers to
108170

109171
If you want to pre-render paths with `ssr:false`, those matched routes _can_ have loaders because we'll pre-render all of the matched routes for those paths, not just the root. You cannot include `actions` or `headers` functions in any routes when `ssr:false` is set because there will be no runtime server to run them on.
110172

111-
### Pre-rendering with a SPA Fallback
173+
#### Pre-rendering with a SPA Fallback
112174

113175
If you want `ssr:false` but don't want to pre-render _all_ of your routes - that's fine too! You may have some paths where you need the performance/SEO benefits of pre-rendering, but other pages where a SPA would be fine.
114176

@@ -155,7 +217,7 @@ sirv-cli build/client --single index.html
155217
sirv-cli build/client --single __spa-fallback.html
156218
```
157219

158-
### Invalid Exports
220+
#### Invalid Exports
159221

160222
When pre-rendering with `ssr:false`, React Router will error at build time if you have invalid exports to help prevent some mistakes that can be easily overlooked.
161223

integration/vite-prerender-test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -577,6 +577,58 @@ test.describe("Prerendering", () => {
577577
expect(html).toMatch('<h2 data-route="true">About</h2>');
578578
expect(html).toMatch('<p data-loader-data="true">About Loader Data</p>');
579579
});
580+
581+
test("Permits a concurrency option", async () => {
582+
fixture = await createFixture({
583+
prerender: true,
584+
files: {
585+
...files,
586+
"react-router.config.ts": js`
587+
export default {
588+
prerender: {
589+
paths: ['/', '/about'],
590+
unstable_concurrency: 2,
591+
},
592+
}
593+
`,
594+
"vite.config.ts": js`
595+
import { defineConfig } from "vite";
596+
import { reactRouter } from "@react-router/dev/vite";
597+
598+
export default defineConfig({
599+
build: { manifest: true },
600+
plugins: [
601+
reactRouter()
602+
],
603+
});
604+
`,
605+
},
606+
});
607+
appFixture = await createAppFixture(fixture);
608+
609+
let clientDir = path.join(fixture.projectDir, "build", "client");
610+
expect(listAllFiles(clientDir).sort()).toEqual([
611+
"_root.data",
612+
"about.data",
613+
"about/index.html",
614+
"favicon.ico",
615+
"index.html",
616+
]);
617+
618+
let res = await fixture.requestDocument("/");
619+
let html = await res.text();
620+
expect(html).toMatch("<title>Index Title: Index Loader Data</title>");
621+
expect(html).toMatch("<h1>Root</h1>");
622+
expect(html).toMatch('<h2 data-route="true">Index</h2>');
623+
expect(html).toMatch('<p data-loader-data="true">Index Loader Data</p>');
624+
625+
res = await fixture.requestDocument("/about");
626+
html = await res.text();
627+
expect(html).toMatch("<title>About Title: About Loader Data</title>");
628+
expect(html).toMatch("<h1>Root</h1>");
629+
expect(html).toMatch('<h2 data-route="true">About</h2>');
630+
expect(html).toMatch('<p data-loader-data="true">About Loader Data</p>');
631+
});
580632
});
581633

582634
test.describe("ssr: true", () => {

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

Lines changed: 46 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,13 @@ type BuildEndHook = (args: {
110110
viteConfig: Vite.ResolvedConfig;
111111
}) => void | Promise<void>;
112112

113+
export type PrerenderPaths =
114+
| boolean
115+
| Array<string>
116+
| ((args: {
117+
getStaticPaths: () => string[];
118+
}) => Array<string> | Promise<Array<string>>);
119+
113120
/**
114121
* Config to be exported via the default export from `react-router.config.ts`.
115122
*/
@@ -149,13 +156,19 @@ export type ReactRouterConfig = {
149156
/**
150157
* An array of URLs to prerender to HTML files at build time. Can also be a
151158
* function returning an array to dynamically generate URLs.
159+
*
160+
* `unstable_concurrency` defaults to 1, which means "no concurrency" - fully serial execution.
161+
* Setting it to a value more than 1 enables concurrent prerendering.
162+
* Setting it to a value higher than one can increase the speed of the build,
163+
* but may consume more resources, and send more concurrent requests to the
164+
* server/CMS.
152165
*/
153166
prerender?:
154-
| boolean
155-
| Array<string>
156-
| ((args: {
157-
getStaticPaths: () => string[];
158-
}) => Array<string> | Promise<Array<string>>);
167+
| PrerenderPaths
168+
| {
169+
paths: PrerenderPaths;
170+
unstable_concurrency?: number;
171+
};
159172
/**
160173
* An array of React Router plugin config presets to ease integration with
161174
* other platforms and tools.
@@ -462,17 +475,35 @@ async function resolveConfig({
462475
serverBundles = undefined;
463476
}
464477

465-
let isValidPrerenderConfig =
466-
prerender == null ||
467-
typeof prerender === "boolean" ||
468-
Array.isArray(prerender) ||
469-
typeof prerender === "function";
478+
if (prerender) {
479+
let isValidPrerenderPathsConfig = (p: unknown) =>
480+
typeof p === "boolean" || typeof p === "function" || Array.isArray(p);
470481

471-
if (!isValidPrerenderConfig) {
472-
return err(
473-
"The `prerender` config must be a boolean, an array of string paths, " +
474-
"or a function returning a boolean or array of string paths",
475-
);
482+
let isValidPrerenderConfig =
483+
isValidPrerenderPathsConfig(prerender) ||
484+
(typeof prerender === "object" &&
485+
"paths" in prerender &&
486+
isValidPrerenderPathsConfig(prerender.paths));
487+
488+
if (!isValidPrerenderConfig) {
489+
return err(
490+
"The `prerender`/`prerender.paths` config must be a boolean, an array " +
491+
"of string paths, or a function returning a boolean or array of string paths.",
492+
);
493+
}
494+
495+
let isValidConcurrencyConfig =
496+
typeof prerender != "object" ||
497+
!("unstable_concurrency" in prerender) ||
498+
(typeof prerender.unstable_concurrency === "number" &&
499+
Number.isInteger(prerender.unstable_concurrency) &&
500+
prerender.unstable_concurrency > 0);
501+
502+
if (!isValidConcurrencyConfig) {
503+
return err(
504+
"The `prerender.unstable_concurrency` config must be a positive integer if specified.",
505+
);
506+
}
476507
}
477508

478509
let routeDiscovery: ResolvedReactRouterConfig["routeDiscovery"];

packages/react-router-dev/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@
8383
"isbot": "^5.1.11",
8484
"jsesc": "3.0.2",
8585
"lodash": "^4.17.21",
86+
"p-map": "^7.0.3",
8687
"pathe": "^1.1.2",
8788
"picocolors": "^1.1.1",
8889
"prettier": "^3.6.2",

0 commit comments

Comments
 (0)