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 e38c4527a0..f9373e4ae5 100644 --- a/contributors.yml +++ b/contributors.yml @@ -210,6 +210,7 @@ - kigawas - kilavvy - kiliman +- kirillgroshkov - kkirsche - kno-raziel - knownasilya 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 c0d92b2714..95ea805fe9 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/vite/plugin.ts b/packages/react-router-dev/vite/plugin.ts index d409f808c0..65744c4033 100644 --- a/packages/react-router-dev/vite/plugin.ts +++ b/packages/react-router-dev/vite/plugin.ts @@ -79,12 +79,14 @@ import { createConfigLoader, resolveEntryFiles, configRouteToBranchRoute, + type PrerenderPaths, } from "../config/config"; import { getOptimizeDepsEntries } from "./optimize-deps-entries"; import { decorateComponentExportsWithProps } from "./with-props"; import { loadDotenv } from "./load-dotenv"; import { validatePluginOrder } from "./plugins/validate-plugin-order"; import { warnOnClientSourceMaps } from "./plugins/warn-on-client-source-maps"; +import { pMap } from "./pmap"; export type LoadCssContents = ( viteDevServer: Vite.ViteDevServer, @@ -2658,80 +2660,93 @@ async function handlePrerender( } let buildRoutes = createPrerenderRoutes(build.routes); - for (let path of build.prerender) { - // Ensure we have a leading slash for matching - let matches = matchRoutes(buildRoutes, `/${path}/`.replace(/^\/\/+/, "/")); - if (!matches) { - continue; - } - // 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 - // the loader. Presumably this is for routes where a file extension is - // already included, such as `app/routes/items[.json].tsx` that will - // render into `/items.json` - let leafRoute = matches ? matches[matches.length - 1].route : null; - let manifestRoute = leafRoute ? build.routes[leafRoute.id]?.module : null; - let isResourceRoute = - manifestRoute && !manifestRoute.default && !manifestRoute.ErrorBoundary; - - if (isResourceRoute) { - invariant(leafRoute); - invariant(manifestRoute); - if (manifestRoute.loader) { - // Prerender a .data file for turbo-stream consumption - await prerenderData( - handler, - path, - [leafRoute.id], - clientBuildDirectory, - reactRouterConfig, - viteConfig, - ); - // Prerender a raw file for external consumption - await prerenderResourceRoute( - handler, - path, - clientBuildDirectory, - reactRouterConfig, - viteConfig, - ); + await pMap( + build.prerender, + async (path) => { + // Ensure we have a leading slash for matching + let matches = matchRoutes( + buildRoutes, + `/${path}/`.replace(/^\/\/+/, "/"), + ); + if (!matches) { + 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 + // the loader. Presumably this is for routes where a file extension is + // already included, such as `app/routes/items[.json].tsx` that will + // render into `/items.json` + let leafRoute = matches ? matches[matches.length - 1].route : null; + let manifestRoute = leafRoute ? build.routes[leafRoute.id]?.module : null; + let isResourceRoute = + manifestRoute && !manifestRoute.default && !manifestRoute.ErrorBoundary; + + if (isResourceRoute) { + invariant(leafRoute); + invariant(manifestRoute); + if (manifestRoute.loader) { + // Prerender a .data file for turbo-stream consumption + await prerenderData( + handler, + path, + [leafRoute.id], + clientBuildDirectory, + reactRouterConfig, + viteConfig, + ); + // Prerender a raw file for external consumption + await prerenderResourceRoute( + handler, + path, + clientBuildDirectory, + reactRouterConfig, + viteConfig, + ); + } else { + viteConfig.logger.warn( + `⚠️ Skipping prerendering for resource route without a loader: ${leafRoute?.id}`, + ); + } } else { - viteConfig.logger.warn( - `⚠️ Skipping prerendering for resource route without a loader: ${leafRoute?.id}`, + let hasLoaders = matches.some( + (m) => build.assets.routes[m.route.id]?.hasLoader, ); - } - } else { - let hasLoaders = matches.some( - (m) => build.assets.routes[m.route.id]?.hasLoader, - ); - let data: string | undefined; - if (!isResourceRoute && hasLoaders) { - data = await prerenderData( + let data: string | undefined; + if (!isResourceRoute && hasLoaders) { + data = await prerenderData( + handler, + path, + null, + clientBuildDirectory, + reactRouterConfig, + viteConfig, + ); + } + + await prerenderRoute( handler, path, - null, clientBuildDirectory, reactRouterConfig, viteConfig, + data + ? { + headers: { + "X-React-Router-Prerender-Data": encodeURI(data), + }, + } + : undefined, ); } - - await prerenderRoute( - handler, - path, - clientBuildDirectory, - reactRouterConfig, - viteConfig, - data - ? { - headers: { - "X-React-Router-Prerender-Data": encodeURI(data), - }, - } - : undefined, - ); - } - } + }, + { + concurrency: + typeof reactRouterConfig.prerender === "object" && + "paths" in reactRouterConfig.prerender + ? reactRouterConfig.prerender.unstable_concurrency || 1 + : 1, + }, + ); } function getStaticPrerenderPaths(routes: DataRouteObject[]) { @@ -2916,33 +2931,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/packages/react-router-dev/vite/pmap.d.ts b/packages/react-router-dev/vite/pmap.d.ts new file mode 100644 index 0000000000..352922854b --- /dev/null +++ b/packages/react-router-dev/vite/pmap.d.ts @@ -0,0 +1,155 @@ +type BaseOptions = { + /** + Number of concurrently pending promises returned by `mapper`. + + Must be an integer from 1 and up or `Infinity`. + + @default Infinity + */ + readonly concurrency?: number; +}; + +export type Options = BaseOptions & { + /** + When `true`, the first mapper rejection will be rejected back to the consumer. + + When `false`, instead of stopping when a promise rejects, it will wait for all the promises to settle and then reject with an [`AggregateError`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AggregateError) containing all the errors from the rejected promises. + + Caveat: When `true`, any already-started async mappers will continue to run until they resolve or reject. In the case of infinite concurrency with sync iterables, *all* mappers are invoked on startup and will continue after the first rejection. [Issue #51](https://github.com/sindresorhus/p-map/issues/51) can be implemented for abort control. + + @default true + */ + readonly stopOnError?: boolean; + + /** + You can abort the promises using [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController). + + @example + ``` + import pMap from 'p-map'; + import delay from 'delay'; + + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, 500); + + const mapper = async value => value; + + await pMap([delay(1000), delay(1000)], mapper, {signal: abortController.signal}); + // Throws AbortError (DOMException) after 500 ms. + ``` + */ + readonly signal?: AbortSignal; +}; + +export type IterableOptions = BaseOptions & { + /** + Maximum number of promises returned by `mapper` that have resolved but not yet collected by the consumer of the async iterable. Calls to `mapper` will be limited so that there is never too much backpressure. + + Useful whenever you are consuming the iterable slower than what the mapper function can produce concurrently. For example, to avoid making an overwhelming number of HTTP requests if you are saving each of the results to a database. + + Default: `options.concurrency` + */ + readonly backpressure?: number; +}; + +type MaybePromise = T | Promise; + +/** + Function which is called for every item in `input`. Expected to return a `Promise` or value. + + @param element - Iterated element. + @param index - Index of the element in the source array. + */ +export type Mapper = ( + element: Element, + index: number +) => MaybePromise; + +/** + @param input - Synchronous or asynchronous iterable that is iterated over concurrently, calling the `mapper` function for each element. Each iterated item is `await`'d before the `mapper` is invoked so the iterable may return a `Promise` that resolves to an item. Asynchronous iterables (different from synchronous iterables that return `Promise` that resolves to an item) can be used when the next item may not be ready without waiting for an asynchronous process to complete and/or the end of the iterable may be reached after the asynchronous process completes. For example, reading from a remote queue when the queue has reached empty, or reading lines from a stream. + @param mapper - Function which is called for every item in `input`. Expected to return a `Promise` or value. + @returns A `Promise` that is fulfilled when all promises in `input` and ones returned from `mapper` are fulfilled, or rejects if any of the promises reject. The fulfilled value is an `Array` of the fulfilled values returned from `mapper` in `input` order. + + @example + ``` + import pMap from 'p-map'; + import got from 'got'; + + const sites = [ + getWebsiteFromUsername('sindresorhus'), //=> Promise + 'https://avajs.dev', + 'https://github.com' + ]; + + const mapper = async site => { + const {requestUrl} = await got.head(site); + return requestUrl; + }; + + const result = await pMap(sites, mapper, {concurrency: 2}); + + console.log(result); + //=> ['https://sindresorhus.com/', 'https://avajs.dev/', 'https://github.com/'] + ``` + */ +export function pMap( + input: AsyncIterable> | Iterable>, + mapper: Mapper, + options?: Options +): Promise>>; + +/** + @param input - Synchronous or asynchronous iterable that is iterated over concurrently, calling the `mapper` function for each element. Each iterated item is `await`'d before the `mapper` is invoked so the iterable may return a `Promise` that resolves to an item. Asynchronous iterables (different from synchronous iterables that return `Promise` that resolves to an item) can be used when the next item may not be ready without waiting for an asynchronous process to complete and/or the end of the iterable may be reached after the asynchronous process completes. For example, reading from a remote queue when the queue has reached empty, or reading lines from a stream. + @param mapper - Function which is called for every item in `input`. Expected to return a `Promise` or value. + @returns An async iterable that streams each return value from `mapper` in order. + + @example + ``` + import {pMapIterable} from 'p-map'; + + // Multiple posts are fetched concurrently, with limited concurrency and backpressure + for await (const post of pMapIterable(postIds, getPostMetadata, {concurrency: 8})) { + console.log(post); + }; + ``` + */ +export function pMapIterable( + input: AsyncIterable> | Iterable>, + mapper: Mapper, + options?: IterableOptions +): AsyncIterable>; + +/** + Return this value from a `mapper` function to skip including the value in the returned array. + + @example + ``` + import pMap, {pMapSkip} from 'p-map'; + import got from 'got'; + + const sites = [ + getWebsiteFromUsername('sindresorhus'), //=> Promise + 'https://avajs.dev', + 'https://example.invalid', + 'https://github.com' + ]; + + const mapper = async site => { + try { + const {requestUrl} = await got.head(site); + return requestUrl; + } catch { + return pMapSkip; + } + }; + + const result = await pMap(sites, mapper, {concurrency: 2}); + + console.log(result); + //=> ['https://sindresorhus.com/', 'https://avajs.dev/', 'https://github.com/'] + ``` + */ +export const pMapSkip: unique symbol; diff --git a/packages/react-router-dev/vite/pmap.js b/packages/react-router-dev/vite/pmap.js new file mode 100644 index 0000000000..f9e4f2c30e --- /dev/null +++ b/packages/react-router-dev/vite/pmap.js @@ -0,0 +1,284 @@ +// vendored from: https://github.com/sindresorhus/p-map/ + +/* eslint-disable */ + +export async function pMap( + iterable, + mapper, + { + concurrency = Number.POSITIVE_INFINITY, + stopOnError = true, + signal, + } = {}, +) { + return new Promise((resolve_, reject_) => { + if (iterable[Symbol.iterator] === undefined && iterable[Symbol.asyncIterator] === undefined) { + throw new TypeError(`Expected \`input\` to be either an \`Iterable\` or \`AsyncIterable\`, got (${typeof iterable})`); + } + + if (typeof mapper !== 'function') { + throw new TypeError('Mapper function is required'); + } + + if (!((Number.isSafeInteger(concurrency) && concurrency >= 1) || concurrency === Number.POSITIVE_INFINITY)) { + throw new TypeError(`Expected \`concurrency\` to be an integer from 1 and up or \`Infinity\`, got \`${concurrency}\` (${typeof concurrency})`); + } + + const result = []; + const errors = []; + const skippedIndexesMap = new Map(); + let isRejected = false; + let isResolved = false; + let isIterableDone = false; + let resolvingCount = 0; + let currentIndex = 0; + const iterator = iterable[Symbol.iterator] === undefined ? iterable[Symbol.asyncIterator]() : iterable[Symbol.iterator](); + + const signalListener = () => { + reject(signal.reason); + }; + + const cleanup = () => { + signal?.removeEventListener('abort', signalListener); + }; + + const resolve = value => { + resolve_(value); + cleanup(); + }; + + const reject = reason => { + isRejected = true; + isResolved = true; + reject_(reason); + cleanup(); + }; + + if (signal) { + if (signal.aborted) { + reject(signal.reason); + } + + signal.addEventListener('abort', signalListener, {once: true}); + } + + const next = async () => { + if (isResolved) { + return; + } + + const nextItem = await iterator.next(); + + const index = currentIndex; + currentIndex++; + + // Note: `iterator.next()` can be called many times in parallel. + // This can cause multiple calls to this `next()` function to + // receive a `nextItem` with `done === true`. + // The shutdown logic that rejects/resolves must be protected + // so it runs only one time as the `skippedIndex` logic is + // non-idempotent. + if (nextItem.done) { + isIterableDone = true; + + if (resolvingCount === 0 && !isResolved) { + if (!stopOnError && errors.length > 0) { + reject(new AggregateError(errors)); + return; + } + + isResolved = true; + + if (skippedIndexesMap.size === 0) { + resolve(result); + return; + } + + const pureResult = []; + + // Support multiple `pMapSkip`'s. + for (const [index, value] of result.entries()) { + if (skippedIndexesMap.get(index) === pMapSkip) { + continue; + } + + pureResult.push(value); + } + + resolve(pureResult); + } + + return; + } + + resolvingCount++; + + // Intentionally detached + (async () => { + try { + const element = await nextItem.value; + + if (isResolved) { + return; + } + + const value = await mapper(element, index); + + // Use Map to stage the index of the element. + if (value === pMapSkip) { + skippedIndexesMap.set(index, value); + } + + result[index] = value; + + resolvingCount--; + await next(); + } catch (error) { + if (stopOnError) { + reject(error); + } else { + errors.push(error); + resolvingCount--; + + // In that case we can't really continue regardless of `stopOnError` state + // since an iterable is likely to continue throwing after it throws once. + // If we continue calling `next()` indefinitely we will likely end up + // in an infinite loop of failed iteration. + try { + await next(); + } catch (error) { + reject(error); + } + } + } + })(); + }; + + // Create the concurrent runners in a detached (non-awaited) + // promise. We need this so we can await the `next()` calls + // to stop creating runners before hitting the concurrency limit + // if the iterable has already been marked as done. + // NOTE: We *must* do this for async iterators otherwise we'll spin up + // infinite `next()` calls by default and never start the event loop. + (async () => { + for (let index = 0; index < concurrency; index++) { + try { + await next(); + } catch (error) { + reject(error); + break; + } + + if (isIterableDone || isRejected) { + break; + } + } + })(); + }); +} + +export function pMapIterable( + iterable, + mapper, + { + concurrency = Number.POSITIVE_INFINITY, + backpressure = concurrency, + } = {}, +) { + if (iterable[Symbol.iterator] === undefined && iterable[Symbol.asyncIterator] === undefined) { + throw new TypeError(`Expected \`input\` to be either an \`Iterable\` or \`AsyncIterable\`, got (${typeof iterable})`); + } + + if (typeof mapper !== 'function') { + throw new TypeError('Mapper function is required'); + } + + if (!((Number.isSafeInteger(concurrency) && concurrency >= 1) || concurrency === Number.POSITIVE_INFINITY)) { + throw new TypeError(`Expected \`concurrency\` to be an integer from 1 and up or \`Infinity\`, got \`${concurrency}\` (${typeof concurrency})`); + } + + if (!((Number.isSafeInteger(backpressure) && backpressure >= concurrency) || backpressure === Number.POSITIVE_INFINITY)) { + throw new TypeError(`Expected \`backpressure\` to be an integer from \`concurrency\` (${concurrency}) and up or \`Infinity\`, got \`${backpressure}\` (${typeof backpressure})`); + } + + return { + async * [Symbol.asyncIterator]() { + const iterator = iterable[Symbol.asyncIterator] === undefined ? iterable[Symbol.iterator]() : iterable[Symbol.asyncIterator](); + + const promises = []; + let runningMappersCount = 0; + let isDone = false; + let index = 0; + + function trySpawn() { + if (isDone || !(runningMappersCount < concurrency && promises.length < backpressure)) { + return; + } + + const promise = (async () => { + const {done, value} = await iterator.next(); + + if (done) { + return {done: true}; + } + + runningMappersCount++; + + // Spawn if still below concurrency and backpressure limit + trySpawn(); + + try { + const returnValue = await mapper(await value, index++); + + runningMappersCount--; + + if (returnValue === pMapSkip) { + const index = promises.indexOf(promise); + + if (index > 0) { + promises.splice(index, 1); + } + } + + // Spawn if still below backpressure limit and just dropped below concurrency limit + trySpawn(); + + return {done: false, value: returnValue}; + } catch (error) { + isDone = true; + return {error}; + } + })(); + + promises.push(promise); + } + + trySpawn(); + + while (promises.length > 0) { + const {error, done, value} = await promises[0]; + + promises.shift(); + + if (error) { + throw error; + } + + if (done) { + return; + } + + // Spawn if just dropped below backpressure limit and below the concurrency limit + trySpawn(); + + if (value === pMapSkip) { + continue; + } + + yield value; + } + }, + }; +} + +export const pMapSkip = Symbol('skip');