From 287751dbc52e660534511c731c79dbfea3beff8a Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Wed, 4 Feb 2026 09:39:15 -0800 Subject: [PATCH 01/13] feat(rsc): support prerender / ssr options --- .changeset/red-trains-yell.md | 6 + .../config/default-rsc-entries/entry.rsc.tsx | 2 + .../config/default-rsc-entries/entry.ssr.tsx | 4 +- packages/react-router-dev/package.json | 2 +- packages/react-router-dev/rsc-types.d.ts | 5 + packages/react-router-dev/vite/plugin.ts | 26 +- .../vite/plugins/prerender.ts | 47 +- packages/react-router-dev/vite/rsc/plugin.ts | 102 +++- packages/react-router/lib/rsc/browser.tsx | 3 +- packages/react-router/lib/rsc/server.rsc.ts | 9 + playground/rsc-vite-framework/package.json | 3 +- .../rsc-vite-framework/react-router.config.ts | 5 +- .../vite-plugin-cloudflare/package.json | 2 +- pnpm-lock.yaml | 526 ++++++++++++++++-- pnpm-workspace.yaml | 1 + 15 files changed, 647 insertions(+), 96 deletions(-) create mode 100644 .changeset/red-trains-yell.md diff --git a/.changeset/red-trains-yell.md b/.changeset/red-trains-yell.md new file mode 100644 index 0000000000..22cf6fe0ca --- /dev/null +++ b/.changeset/red-trains-yell.md @@ -0,0 +1,6 @@ +--- +"@react-router/dev": patch +"react-router": patch +--- + +support prerender / ssr options for RSC and gate unstable_previewServerPrerendering behind Vite v7 diff --git a/packages/react-router-dev/config/default-rsc-entries/entry.rsc.tsx b/packages/react-router-dev/config/default-rsc-entries/entry.rsc.tsx index 520eea2c18..7644fc9ed7 100644 --- a/packages/react-router-dev/config/default-rsc-entries/entry.rsc.tsx +++ b/packages/react-router-dev/config/default-rsc-entries/entry.rsc.tsx @@ -14,6 +14,7 @@ import { // Import the routes generated by routes.ts import routes from "virtual:react-router/unstable_rsc/routes"; import basename from "virtual:react-router/unstable_rsc/basename"; +import ssr from "virtual:react-router/unstable_rsc/ssr"; import unstable_reactRouterServeConfig from "virtual:react-router/unstable_rsc/react-router-serve-config"; export { unstable_reactRouterServeConfig }; @@ -35,6 +36,7 @@ export function fetchServer( requestContext, // The app routes. routes, + prerender: !ssr, // Encode the match with the React Server implementation. generateResponse(match, options) { return new Response(renderToReadableStream(match.payload, options), { diff --git a/packages/react-router-dev/config/default-rsc-entries/entry.ssr.tsx b/packages/react-router-dev/config/default-rsc-entries/entry.ssr.tsx index fe518ff4b8..0f0ff6d560 100644 --- a/packages/react-router-dev/config/default-rsc-entries/entry.ssr.tsx +++ b/packages/react-router-dev/config/default-rsc-entries/entry.ssr.tsx @@ -1,6 +1,5 @@ import { createFromReadableStream } from "@vitejs/plugin-rsc/ssr"; -// @ts-expect-error - no types for this, can import from root once on latest 19 -import { renderToReadableStream } from "react-dom/server.edge"; +import { renderToReadableStream } from "react-dom/server"; import { unstable_routeRSCServerRequest as routeRSCServerRequest, unstable_RSCStaticRouter as RSCStaticRouter, @@ -31,6 +30,7 @@ export async function generateHTML( { ...options, bootstrapScriptContent, + // @ts-expect-error - no types for this yet formState, signal: request.signal, }, diff --git a/packages/react-router-dev/package.json b/packages/react-router-dev/package.json index 84c4fd010c..e8a68fb2de 100644 --- a/packages/react-router-dev/package.json +++ b/packages/react-router-dev/package.json @@ -119,7 +119,7 @@ "react-router": "workspace:^", "tsup": "catalog:", "typescript": "catalog:", - "vite": "^6.3.0", + "vite": "catalog:", "wireit": "catalog:", "wrangler": "^4.23.0" }, diff --git a/packages/react-router-dev/rsc-types.d.ts b/packages/react-router-dev/rsc-types.d.ts index 954c6a7410..322e6ceb1b 100644 --- a/packages/react-router-dev/rsc-types.d.ts +++ b/packages/react-router-dev/rsc-types.d.ts @@ -10,6 +10,11 @@ declare module "virtual:react-router/unstable_rsc/basename" { export default basename; } +declare module "virtual:react-router/unstable_rsc/ssr" { + const ssr: boolean; + export default ssr; +} + declare module "virtual:react-router/unstable_rsc/react-router-serve-config" { const unstable_reactRouterServeConfig: { publicPath: string; diff --git a/packages/react-router-dev/vite/plugin.ts b/packages/react-router-dev/vite/plugin.ts index 6eaf1297a3..2d680b6e5a 100644 --- a/packages/react-router-dev/vite/plugin.ts +++ b/packages/react-router-dev/vite/plugin.ts @@ -1,6 +1,6 @@ // We can only import types from Vite at the top level since we're in a CJS // context but want to use Vite's ESM build since Vite 7+ is ESM only -import type * as Vite from "vite"; +import * as Vite from "vite"; import { type BinaryLike, createHash } from "node:crypto"; import { existsSync, readFileSync, readdirSync, rmSync } from "node:fs"; import { @@ -727,6 +727,19 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => { return; } + if ( + reactRouterConfig.future.unstable_previewServerPrerendering && + Number(Vite.version.split(".")[0]) < 7 + ) { + logger.error( + colors.red( + "Vite 7 or higher is required for preview server prerendering, got version " + + Vite.version, + ), + ); + process.exit(1); + } + // This `injectedPluginContext` logic is so we can support injecting an // already-resolved plugin context into the build in case we want to re-use // any resolved values. Currently, this is used so that we can re-use the @@ -2555,15 +2568,6 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => { let { future } = ctx.reactRouterConfig; - // Prerender during SSR build only - if ( - future.v8_viteEnvironmentApi - ? this.environment.name === "client" - : !viteConfigEnv.isSsrBuild - ) { - return []; - } - // Skip prerendering if the future flag is disabled if (!future.unstable_previewServerPrerendering) { return []; @@ -2824,7 +2828,7 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => { } let serverBuildDirectory = future.v8_viteEnvironmentApi - ? this.environment.config?.build?.outDir + ? viteConfig.environments.ssr?.build?.outDir : (ctx.environmentBuildContext?.options.build?.outDir ?? getServerBuildDirectory(ctx.reactRouterConfig)); diff --git a/packages/react-router-dev/vite/plugins/prerender.ts b/packages/react-router-dev/vite/plugins/prerender.ts index d72938eae9..1575056cfc 100644 --- a/packages/react-router-dev/vite/plugins/prerender.ts +++ b/packages/react-router-dev/vite/plugins/prerender.ts @@ -82,11 +82,7 @@ export interface PrerenderPluginOptions< /** * Prerender configuration */ - config?: - | PrerenderConfig - | (( - this: Vite.Rollup.PluginContext, - ) => PrerenderConfig | Promise); + config?: PrerenderConfig | (() => PrerenderConfig | Promise); /** * Requests to prerender @@ -109,9 +105,7 @@ export interface PrerenderPluginOptions< */ requests: | PrerenderRequest[] - | (( - this: Vite.Rollup.PluginContext, - ) => + | (() => | PrerenderRequest[] | Promise[]>); @@ -142,7 +136,6 @@ export interface PrerenderPluginOptions< * ``` */ postProcess?: ( - this: Vite.Rollup.PluginContext, request: Request, response: Response, metadata: Metadata | undefined, @@ -155,7 +148,6 @@ export interface PrerenderPluginOptions< * If it throws, the build fails. */ handleError?: ( - this: Vite.Rollup.PluginContext, request: Request, error: Error, metadata: Metadata | undefined, @@ -167,21 +159,14 @@ export interface PrerenderPluginOptions< * Use for custom logging with access to request metadata. * If not provided, uses default logging. */ - logFile?: ( - this: Vite.Rollup.PluginContext, - outputPath: string, - metadata: Metadata | undefined, - ) => void; + logFile?: (outputPath: string, metadata: Metadata | undefined) => void; /** * Called after all prerendering is complete * * Use for cleanup or post-processing of output files. */ - finalize?: ( - this: Vite.Rollup.PluginContext, - buildDirectory: string, - ) => void | Promise; + finalize?: (buildDirectory: string) => void | Promise; } function normalizePrerenderRequest>( @@ -232,13 +217,13 @@ export function prerender>( configResolved(resolvedConfig) { viteConfig = resolvedConfig; }, - writeBundle: { + sharedDuringBuild: true, + // @ts-expect-error - needs newer types + buildApp: { + order: "post", async handler() { - const pluginContext = this; const rawRequests = - typeof requests === "function" - ? await requests.call(pluginContext) - : requests; + typeof requests === "function" ? await requests.call(null) : requests; const prerenderRequests = rawRequests.map(normalizePrerenderRequest); @@ -247,9 +232,7 @@ export function prerender>( } const prerenderConfig = - typeof config === "function" - ? await config.call(pluginContext) - : config; + typeof config === "function" ? await config.call(null) : config; const { buildDirectory = viteConfig.environments.client.build.outDir, concurrency = 1, @@ -304,7 +287,7 @@ export function prerender>( // External redirect: pass to postProcess if (responseURL.origin !== locationUrl.origin) { return await postProcess.call( - pluginContext, + null, request, response, metadata, @@ -324,7 +307,7 @@ export function prerender>( } return await postProcess.call( - pluginContext, + null, request, response, metadata, @@ -339,7 +322,7 @@ export function prerender>( // If handleError does not throw, return empty array and continue handleError.call( - pluginContext, + null, request, error instanceof Error ? error @@ -390,7 +373,7 @@ export function prerender>( const relativePath = path.relative(viteConfig.root, outputPath); if (logFile) { - logFile.call(pluginContext, relativePath, metadata); + logFile.call(null, relativePath, metadata); } return relativePath; @@ -406,7 +389,7 @@ export function prerender>( ); if (finalize) { - await finalize.call(pluginContext, buildDirectory); + await finalize.call(null, buildDirectory); } } finally { await new Promise((resolve, reject) => { diff --git a/packages/react-router-dev/vite/rsc/plugin.ts b/packages/react-router-dev/vite/rsc/plugin.ts index 2c002ff86f..d7b6c789ec 100644 --- a/packages/react-router-dev/vite/rsc/plugin.ts +++ b/packages/react-router-dev/vite/rsc/plugin.ts @@ -28,6 +28,8 @@ import { import { loadDotenv } from "../load-dotenv"; import { validatePluginOrder } from "../plugins/validate-plugin-order"; import { warnOnClientSourceMaps } from "../plugins/warn-on-client-source-maps"; +import { prerender } from "../plugins/prerender"; +import { getPrerenderPaths } from "../plugin"; export function reactRouterRSCVitePlugin(): Vite.PluginOption[] { let runningWithinTheReactRouterMonoRepo = Boolean( @@ -80,11 +82,9 @@ export function reactRouterRSCVitePlugin(): Vite.PluginOption[] { validateConfig: (userConfig) => { let errors: string[] = []; if (userConfig.buildEnd) errors.push("buildEnd"); - if (userConfig.prerender) errors.push("prerender"); if (userConfig.presets?.length) errors.push("presets"); if (userConfig.routeDiscovery) errors.push("routeDiscovery"); if (userConfig.serverBundles) errors.push("serverBundles"); - if (userConfig.ssr === false) errors.push("ssr: false"); if (userConfig.future?.v8_middleware === false) errors.push("future.v8_middleware: false"); if (userConfig.future?.v8_splitRouteModules) @@ -411,7 +411,7 @@ export function reactRouterRSCVitePlugin(): Vite.PluginOption[] { viteCommand, routeIdByFile, rootRouteFile, - viteEnvironment: this.environment, + viteEnvironment: this.environment as unknown as Vite.Environment, }); }, }, @@ -428,6 +428,19 @@ export function reactRouterRSCVitePlugin(): Vite.PluginOption[] { } }, }, + { + name: "react-router/rsc/virtual-ssr", + resolveId(id) { + if (id === virtual.ssr.id) { + return virtual.ssr.resolvedId; + } + }, + load(id) { + if (id === virtual.ssr.resolvedId) { + return `export default ${JSON.stringify(config.ssr)};`; + } + }, + }, { name: "react-router/rsc/hmr/inject-runtime", enforce: "pre", @@ -603,14 +616,97 @@ export function reactRouterRSCVitePlugin(): Vite.PluginOption[] { }, validatePluginOrder(), warnOnClientSourceMaps(), + prerender({ + config() { + return { + buildDirectory: getClientBuildDirectory(config), + concurrency: getPrerenderConcurrencyConfig(config), + }; + }, + async requests() { + const prerenderPaths = await getPrerenderPaths( + config.prerender, + config.ssr, + config.routes, + true, + ); + + return prerenderPaths.flatMap((prerenderPath) => + prerenderPath === "/" + ? `http://localhost${config.basename}${prerenderPath.slice(1)}` + : [ + `http://localhost${config.basename}${prerenderPath.slice(1)}`, + { + request: `http://localhost${config.basename}${prerenderPath.slice(1)}.manifest`, + metadata: { manifest: true }, + }, + ], + ); + }, + async postProcess(request, response, metadata) { + const url = new URL(request.url); + + if (metadata?.manifest) { + return [ + { + path: url.pathname, + contents: await response.text(), + }, + ]; + } + + const html = await response.text(); + + let files = [ + { + path: url.pathname + "/index.html", + contents: html, + }, + ]; + + let matches = Array.from( + html.matchAll( + /