diff --git a/.changeset/big-drinks-invite.md b/.changeset/big-drinks-invite.md new file mode 100644 index 0000000000..89c0ab635e --- /dev/null +++ b/.changeset/big-drinks-invite.md @@ -0,0 +1,5 @@ +--- +"@react-router/dev": patch +--- + +Introduce a `prerender.unstable_concurrency` option, to support running the prerendering concurrently, potentially speeding up the build. diff --git a/contributors.yml b/contributors.yml index 7b0c1b6f4b..f63e094e67 100644 --- a/contributors.yml +++ b/contributors.yml @@ -212,6 +212,7 @@ - kigawas - kilavvy - kiliman +- kirillgroshkov - kkirsche - kno-raziel - knownasilya diff --git a/docs/how-to/pre-rendering.md b/docs/how-to/pre-rendering.md index 71e182c423..e0cbc6a85d 100644 --- a/docs/how-to/pre-rendering.md +++ b/docs/how-to/pre-rendering.md @@ -9,43 +9,105 @@ title: Pre-Rendering

-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: +Pre-Rendering allows you to speed up page loads for static content by rendering pages at build time instead of at runtime. -- Alongside a runtime SSR server with `ssr:true` (the default value) -- Deployed to a static file server with `ssr:false` +## Configuration + +Pre-rendering is enabled via the `prerender` config in `react-router.config.ts`. + +The simplest configuration is a boolean `true` which will pre-render all off the applications static paths based on `routes.ts`: + +```ts filename=react-router.config.ts +import type { Config } from "@react-router/dev/config"; -## Pre-rendering with `ssr:true` +export default { + prerender: true, +} satisfies Config; +``` -### Configuration +The boolean `true` will not include any dynamic paths (i.e., `/blog/:slug`) because the parameter values are unknown. -Add the `prerender` option to your config, there are three signatures: +To configure specific paths including dynamic values, you can specify an array of paths: -```ts filename=react-router.config.ts lines=[7-8,10-11,13-21] +```ts filename=react-router.config.ts import type { Config } from "@react-router/dev/config"; +let slugs = getPostSlugs(); + export default { - // Can be omitted - defaults to true - ssr: true, + prerender: [ + "/", + "/blog", + ...slugs.map((s) => `/blog/${s}`), + ], +} satisfies Config; +``` - // all static paths (no dynamic segments like "/post/:slug") - prerender: true, +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: - // specific paths - prerender: ["/", "/blog", "/blog/popular-post"], +```ts filename=react-router.config.ts +import type { Config } from "@react-router/dev/config"; - // async function for dependencies like a CMS +export default { async prerender({ getStaticPaths }) { - let posts = await fakeGetPostsFromCMS(); + let slugs = await getPostSlugsFromCMS(); return [ + ...getStaticPaths(), // "/" and "/blog" + ...slugs.map((s) => `/blog/${s}`), + ]; + }, +} satisfies Config; +``` + +### Concurrency (unstable) + +This API is experimental and subject to breaking changes in +minor/patch releases. Please use with caution and pay **very** close attention +to release notes for relevant changes. + +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. + +To specify concurrency, move your `prerender` config down into a `prerender.paths` field and you can specify the concurrency in `prerender.unstable_concurrency`: + +```ts filename=react-router.config.ts +import type { Config } from "@react-router/dev/config"; + +let slugs = getPostSlugs(); + +export default { + prerender: { + paths: [ "/", "/blog", - ...posts.map((post) => post.href), - ]; + ...slugs.map((s) => `/blog/${s}`), + ], + unstable_concurrency: 4, }, } satisfies Config; ``` -### Data Loading and Pre-rendering +## Pre-Rendering with/without a Runtime Server + +Pre-Rendering can be used in two ways based on the `ssr` config value: + +- Alongside a runtime SSR server with `ssr:true` (the default value) +- Deployed to a static file server with `ssr:false` + +### Pre-rendering with `ssr:true` + +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. + +```ts filename=react-router.config.ts +import type { Config } from "@react-router/dev/config"; + +export default { + // Can be omitted - defaults to true + ssr: true, + prerender: ["/", "/blog", "/blog/popular-post"], +} satisfies Config; +``` + +#### Data Loading and Pre-rendering There is no extra application API for pre-rendering. Routes being pre-rendered use the same route `loader` functions as server rendering: @@ -64,7 +126,7 @@ Instead of a request coming to your route on a deployed server, the build create When server rendering, requests to paths that have not been pre-rendered will be server rendered as usual. -### Static File Output +#### Static File Output The rendered result will be written out to your `build/client` directory. You'll notice two files for each path: @@ -89,7 +151,7 @@ Prerender: Generated build/client/blog/my-first-post/index.html During development, pre-rendering doesn't save the rendered results to the public directory, this only happens for `react-router build`. -## Pre-rendering with `ssr:false` +### Pre-rendering with `ssr:false` 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. @@ -108,7 +170,7 @@ If you specify `ssr:false` without a `prerender` config, React Router refers to 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. -### Pre-rendering with a SPA Fallback +#### Pre-rendering with a SPA Fallback 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. @@ -155,7 +217,7 @@ sirv-cli build/client --single index.html sirv-cli build/client --single __spa-fallback.html ``` -### Invalid Exports +#### Invalid Exports 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. diff --git a/integration/vite-prerender-test.ts b/integration/vite-prerender-test.ts index ebbf8f62f9..2cd0c95bbe 100644 --- a/integration/vite-prerender-test.ts +++ b/integration/vite-prerender-test.ts @@ -577,6 +577,58 @@ test.describe("Prerendering", () => { expect(html).toMatch('

About

'); expect(html).toMatch('

About Loader Data

'); }); + + test("Permits a concurrency option", async () => { + fixture = await createFixture({ + prerender: true, + files: { + ...files, + "react-router.config.ts": js` + export default { + prerender: { + paths: ['/', '/about'], + unstable_concurrency: 2, + }, + } + `, + "vite.config.ts": js` + import { defineConfig } from "vite"; + import { reactRouter } from "@react-router/dev/vite"; + + export default defineConfig({ + build: { manifest: true }, + plugins: [ + reactRouter() + ], + }); + `, + }, + }); + appFixture = await createAppFixture(fixture); + + let clientDir = path.join(fixture.projectDir, "build", "client"); + expect(listAllFiles(clientDir).sort()).toEqual([ + "_root.data", + "about.data", + "about/index.html", + "favicon.ico", + "index.html", + ]); + + let res = await fixture.requestDocument("/"); + let html = await res.text(); + expect(html).toMatch("Index Title: Index Loader Data"); + expect(html).toMatch("

Root

"); + expect(html).toMatch('

Index

'); + expect(html).toMatch('

Index Loader Data

'); + + res = await fixture.requestDocument("/about"); + html = await res.text(); + expect(html).toMatch("About Title: About Loader Data"); + expect(html).toMatch("

Root

"); + expect(html).toMatch('

About

'); + expect(html).toMatch('

About Loader Data

'); + }); }); test.describe("ssr: true", () => { diff --git a/packages/react-router-dev/config/config.ts b/packages/react-router-dev/config/config.ts index 44f63824a0..3e0df29fc4 100644 --- a/packages/react-router-dev/config/config.ts +++ b/packages/react-router-dev/config/config.ts @@ -110,6 +110,13 @@ type BuildEndHook = (args: { viteConfig: Vite.ResolvedConfig; }) => void | Promise; +export type PrerenderPaths = + | boolean + | Array + | ((args: { + getStaticPaths: () => string[]; + }) => Array | Promise>); + /** * Config to be exported via the default export from `react-router.config.ts`. */ @@ -149,13 +156,19 @@ export type ReactRouterConfig = { /** * An array of URLs to prerender to HTML files at build time. Can also be a * function returning an array to dynamically generate URLs. + * + * `unstable_concurrency` defaults to 1, which means "no concurrency" - fully serial execution. + * Setting it to a value more than 1 enables concurrent prerendering. + * Setting it to a value higher than one can increase the speed of the build, + * but may consume more resources, and send more concurrent requests to the + * server/CMS. */ prerender?: - | boolean - | Array - | ((args: { - getStaticPaths: () => string[]; - }) => Array | Promise>); + | PrerenderPaths + | { + paths: PrerenderPaths; + unstable_concurrency?: number; + }; /** * An array of React Router plugin config presets to ease integration with * other platforms and tools. @@ -462,17 +475,35 @@ async function resolveConfig({ serverBundles = undefined; } - let isValidPrerenderConfig = - prerender == null || - typeof prerender === "boolean" || - Array.isArray(prerender) || - typeof prerender === "function"; + if (prerender) { + let isValidPrerenderPathsConfig = (p: unknown) => + typeof p === "boolean" || typeof p === "function" || Array.isArray(p); - if (!isValidPrerenderConfig) { - return err( - "The `prerender` config must be a boolean, an array of string paths, " + - "or a function returning a boolean or array of string paths", - ); + let isValidPrerenderConfig = + isValidPrerenderPathsConfig(prerender) || + (typeof prerender === "object" && + "paths" in prerender && + isValidPrerenderPathsConfig(prerender.paths)); + + if (!isValidPrerenderConfig) { + return err( + "The `prerender`/`prerender.paths` config must be a boolean, an array " + + "of string paths, or a function returning a boolean or array of string paths.", + ); + } + + let isValidConcurrencyConfig = + typeof prerender != "object" || + !("unstable_concurrency" in prerender) || + (typeof prerender.unstable_concurrency === "number" && + Number.isInteger(prerender.unstable_concurrency) && + prerender.unstable_concurrency > 0); + + if (!isValidConcurrencyConfig) { + return err( + "The `prerender.unstable_concurrency` config must be a positive integer if specified.", + ); + } } let routeDiscovery: ResolvedReactRouterConfig["routeDiscovery"]; diff --git a/packages/react-router-dev/package.json b/packages/react-router-dev/package.json index 2722b58c1a..2e37951c2e 100644 --- a/packages/react-router-dev/package.json +++ b/packages/react-router-dev/package.json @@ -83,6 +83,7 @@ "isbot": "^5.1.11", "jsesc": "3.0.2", "lodash": "^4.17.21", + "p-map": "^7.0.3", "pathe": "^1.1.2", "picocolors": "^1.1.1", "prettier": "^3.6.2", diff --git a/packages/react-router-dev/vite/plugin.ts b/packages/react-router-dev/vite/plugin.ts index d409f808c0..683feba361 100644 --- a/packages/react-router-dev/vite/plugin.ts +++ b/packages/react-router-dev/vite/plugin.ts @@ -36,6 +36,7 @@ import pick from "lodash/pick"; import jsesc from "jsesc"; import colors from "picocolors"; import kebabCase from "lodash/kebabCase"; +import pMap from "p-map"; import * as Typegen from "../typegen"; import type { RouteManifestEntry, RouteManifest } from "../config/routes"; @@ -79,6 +80,7 @@ import { createConfigLoader, resolveEntryFiles, configRouteToBranchRoute, + type PrerenderPaths, } from "../config/config"; import { getOptimizeDepsEntries } from "./optimize-deps-entries"; import { decorateComponentExportsWithProps } from "./with-props"; @@ -2658,11 +2660,12 @@ async function handlePrerender( } let buildRoutes = createPrerenderRoutes(build.routes); - for (let path of build.prerender) { + + let prerenderSinglePath = async (path: string) => { // Ensure we have a leading slash for matching let matches = matchRoutes(buildRoutes, `/${path}/`.replace(/^\/\/+/, "/")); if (!matches) { - continue; + return; } // When prerendering a resource route, we don't want to pass along the // `.data` file since we want to prerender the raw Response returned from @@ -2731,7 +2734,15 @@ async function handlePrerender( : undefined, ); } + }; + + let concurrency = 1; + let { prerender } = reactRouterConfig; + if (typeof prerender === "object" && "unstable_concurrency" in prerender) { + concurrency = prerender.unstable_concurrency ?? 1; } + + await pMap(build.prerender, prerenderSinglePath, { concurrency }); } function getStaticPrerenderPaths(routes: DataRouteObject[]) { @@ -2916,33 +2927,49 @@ export async function getPrerenderPaths( routes: GenericRouteManifest, logWarning = false, ): Promise { - let prerenderPaths: string[] = []; - if (prerender != null && prerender !== false) { - let prerenderRoutes = createPrerenderRoutes(routes); - if (prerender === true) { - let { paths, paramRoutes } = getStaticPrerenderPaths(prerenderRoutes); - if (logWarning && !ssr && paramRoutes.length > 0) { - console.warn( - colors.yellow( - [ - "⚠️ Paths with dynamic/splat params cannot be prerendered when " + - "using `prerender: true`. You may want to use the `prerender()` " + - "API to prerender the following paths:", - ...paramRoutes.map((p) => " - " + p), - ].join("\n"), - ), - ); - } - prerenderPaths = paths; - } else if (typeof prerender === "function") { - prerenderPaths = await prerender({ - getStaticPaths: () => getStaticPrerenderPaths(prerenderRoutes).paths, - }); - } else { - prerenderPaths = prerender || ["/"]; + if (prerender == null || prerender === false) { + return []; + } + + let pathsConfig: PrerenderPaths; + + if (typeof prerender === "object" && "paths" in prerender) { + pathsConfig = prerender.paths; + } else { + pathsConfig = prerender; + } + + if (pathsConfig === false) { + return []; + } + + let prerenderRoutes = createPrerenderRoutes(routes); + + if (pathsConfig === true) { + let { paths, paramRoutes } = getStaticPrerenderPaths(prerenderRoutes); + if (logWarning && !ssr && paramRoutes.length > 0) { + console.warn( + colors.yellow( + [ + "⚠️ Paths with dynamic/splat params cannot be prerendered when " + + "using `prerender: true`. You may want to use the `prerender()` " + + "API to prerender the following paths:", + ...paramRoutes.map((p) => " - " + p), + ].join("\n"), + ), + ); } + return paths; } - return prerenderPaths; + + if (typeof pathsConfig === "function") { + let paths = await pathsConfig({ + getStaticPaths: () => getStaticPrerenderPaths(prerenderRoutes).paths, + }); + return paths; + } + + return pathsConfig; } // Note: Duplicated from react-router/lib/server-runtime diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c3e43eaea6..44cd3ad065 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1098,6 +1098,9 @@ importers: lodash: specifier: ^4.17.21 version: 4.17.21 + p-map: + specifier: ^7.0.3 + version: 7.0.3 pathe: specifier: ^1.1.2 version: 1.1.2 @@ -8147,6 +8150,10 @@ packages: resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==} engines: {node: '>=6'} + p-map@7.0.3: + resolution: {integrity: sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==} + engines: {node: '>=18'} + p-try@2.2.0: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} @@ -17759,6 +17766,8 @@ snapshots: p-map@2.1.0: {} + p-map@7.0.3: {} + p-try@2.2.0: {} pac-proxy-agent@7.0.2: