From 2f3c65cf9638698189d1a3bd96774bf59cf07aa1 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 7 May 2024 12:05:15 -0400 Subject: [PATCH 01/11] Add support for prerendering --- .../react-router/lib/dom/ssr/single-fetch.tsx | 13 +- packages/remix-dev/config.ts | 37 +++- packages/remix-dev/vite/plugin.ts | 194 +++++++++++++++--- packages/remix-server-runtime/routes.ts | 4 +- 4 files changed, 197 insertions(+), 51 deletions(-) diff --git a/packages/react-router/lib/dom/ssr/single-fetch.tsx b/packages/react-router/lib/dom/ssr/single-fetch.tsx index 17f8f5f5b9..8a15ce1da0 100644 --- a/packages/react-router/lib/dom/ssr/single-fetch.tsx +++ b/packages/react-router/lib/dom/ssr/single-fetch.tsx @@ -294,16 +294,9 @@ export function singleFetchUrl(reqUrl: URL | string) { async function fetchAndDecode(url: URL, init?: RequestInit) { let res = await fetch(url, init); - if (res.headers.get("Content-Type")?.includes("text/x-turbo")) { - invariant(res.body, "No response body to decode"); - let decoded = await decodeViaTurboStream(res.body, window); - return { status: res.status, data: decoded.value }; - } - - // If we didn't get back a turbo-stream response, then we never reached the - // Remix server and likely this is a network error - just expose up the - // response body as an Error - throw new Error(await res.text()); + invariant(res.body, "No response body to decode"); + let decoded = await decodeViaTurboStream(res.body, window); + return { status: res.status, data: decoded.value }; } // Note: If you change this function please change the corresponding diff --git a/packages/remix-dev/config.ts b/packages/remix-dev/config.ts index ba110cb0c6..1161a498f3 100644 --- a/packages/remix-dev/config.ts +++ b/packages/remix-dev/config.ts @@ -143,6 +143,11 @@ export type VitePluginConfig = { * Defaults to `false`. */ manifest?: boolean; + /** + * An array of URLs to prerender to HTML files at build time. Can also be a + * function returning an array to dynamically generate URLs. + */ + prerender?: Array | (() => Array | Promise>); /** * An array of React Router plugin config presets to ease integration with * other platforms and tools. @@ -195,6 +200,10 @@ export type ResolvedVitePluginConfig = Readonly<{ * Defaults to `false`. */ manifest: boolean; + /** + * An array of URLs to prerender to HTML files at build time. + */ + prerender: Array | null; /** * Derived from Vite's `base` config * */ @@ -370,6 +379,7 @@ export async function resolveReactRouterConfig({ ignoredRouteFiles, manifest, routes: userRoutesFunction, + prerender: prerenderConfig, serverBuildFile, serverBundles, serverModuleFormat, @@ -379,10 +389,8 @@ export async function resolveReactRouterConfig({ ...mergeReactRouterConfig(...presets, reactRouterUserConfig), }; - let isSpaMode = !ssr; - // Log warning for incompatible vite config flags - if (isSpaMode && serverBundles) { + if (!ssr && serverBundles) { console.warn( colors.yellow( colors.bold("⚠️ SPA Mode: ") + @@ -393,6 +401,25 @@ export async function resolveReactRouterConfig({ serverBundles = undefined; } + let prerender: Array | null = null; + if (prerenderConfig) { + if (ssr) { + throw new Error( + "Prerendering is only supported when `ssr:false` is set." + ); + } + if (Array.isArray(prerenderConfig)) { + prerender = prerenderConfig; + } else if (typeof prerenderConfig === "function") { + prerender = await prerenderConfig(); + } else { + throw new Error( + "The `prerender` config must be an array of string paths, or a function " + + "returning an array of string paths" + ); + } + } + let appDirectory = path.resolve(rootDirectory, userAppDirectory || "app"); let buildDirectory = path.resolve(rootDirectory, userBuildDirectory); let publicPath = viteUserConfig.base ?? "/"; @@ -445,6 +472,7 @@ export async function resolveReactRouterConfig({ buildEnd, future, manifest, + prerender, publicPath, routes, serverBuildFile, @@ -468,7 +496,6 @@ export async function resolveEntryFiles({ reactRouterConfig: ResolvedVitePluginConfig; }) { let { appDirectory, future } = reactRouterConfig; - let isSpaMode = !reactRouterConfig.ssr; let defaultsDirectory = path.resolve(__dirname, "config", "defaults"); @@ -481,7 +508,7 @@ export async function resolveEntryFiles({ let pkgJson = await PackageJson.load(rootDirectory); let deps = pkgJson.content.dependencies ?? {}; - if (isSpaMode && future?.unstable_singleFetch != true) { + if (!reactRouterConfig.ssr && future?.unstable_singleFetch !== true) { // This is a super-simple default since we don't need streaming in SPA Mode. // We can include this in a remix-spa template, but right now `npx remix reveal` // will still expose the streaming template since that command doesn't have diff --git a/packages/remix-dev/vite/plugin.ts b/packages/remix-dev/vite/plugin.ts index 0576916476..6a09a556a3 100644 --- a/packages/remix-dev/vite/plugin.ts +++ b/packages/remix-dev/vite/plugin.ts @@ -6,11 +6,13 @@ import * as path from "node:path"; import * as url from "node:url"; import * as fse from "fs-extra"; import babel from "@babel/core"; +import type { RequestHandler, ServerBuild } from "@react-router/server-runtime"; import { - type ServerBuild, unstable_setDevServerHooks as setDevServerHooks, createRequestHandler, } from "@react-router/server-runtime"; +import type { DataRouteObject } from "react-router"; +import { matchRoutes } from "react-router"; import { init as initEsModuleLexer, parse as esModuleLexer, @@ -504,7 +506,9 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => { )}; export const basename = ${JSON.stringify(ctx.reactRouterConfig.basename)}; export const future = ${JSON.stringify(ctx.reactRouterConfig.future)}; - export const isSpaMode = ${!ctx.reactRouterConfig.ssr}; + export const isSpaMode = ${ + !ctx.reactRouterConfig.ssr && ctx.reactRouterConfig.prerender == null + }; export const publicPath = ${JSON.stringify( ctx.reactRouterConfig.publicPath )}; @@ -1172,11 +1176,10 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => { if (!ctx.reactRouterConfig.ssr) { await handleSpaMode( - serverBuildDirectory, - ctx.reactRouterConfig.serverBuildFile, - clientBuildDirectory, viteConfig, - ctx.reactRouterConfig.basename + ctx.reactRouterConfig, + serverBuildDirectory, + clientBuildDirectory ); } }, @@ -1315,16 +1318,27 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => { if (!route) return; if (!ctx.reactRouterConfig.ssr) { - let serverOnlyExports = esModuleLexer(code)[1] - .map((exp) => exp.n) - .filter((exp) => SERVER_ONLY_ROUTE_EXPORTS.includes(exp)); - if (serverOnlyExports.length > 0) { - let str = serverOnlyExports.map((e) => `\`${e}\``).join(", "); - let message = - `SPA Mode: ${serverOnlyExports.length} invalid route export(s) in ` + - `\`${route.file}\`: ${str}. See https://remix.run/guides/spa-mode ` + - `for more information.`; - throw Error(message); + // TODO: Should we drop this in v7? SPA Mode was originally envisioned + // as a guardrails-enabled way to do what you can basically do with your + // own pre-rendering. Server loaders "work" but are an easy footgun + // if you aren't properly handling `clientLoader` logic on subsequent + // client-side navs to those pages. + // Pre-rendering is a more advanced usage of SPA mode and therefore we + // allow server loaders for more complete pre-rendered HTML. + // So we could keep ssr:false by itself as guard-rails enabled, and + // then let folks specify prerender to opt-into full capabilities + if (ctx.reactRouterConfig.prerender == null) { + let serverOnlyExports = esModuleLexer(code)[1] + .map((exp) => exp.n) + .filter((exp) => SERVER_ONLY_ROUTE_EXPORTS.includes(exp)); + if (serverOnlyExports.length > 0) { + let str = serverOnlyExports.map((e) => `\`${e}\``).join(", "); + let message = + `SPA Mode: ${serverOnlyExports.length} invalid route export(s) in ` + + `\`${route.file}\`: ${str}. See https://remix.run/guides/spa-mode ` + + `for more information.`; + throw Error(message); + } } if (route.id !== "root") { @@ -1630,27 +1644,59 @@ async function getRouteMetadata( } async function handleSpaMode( - serverBuildDirectoryPath: string, - serverBuildFile: string, - clientBuildDirectory: string, viteConfig: Vite.ResolvedConfig, - basename: string + reactRouterConfig: Awaited>, + serverBuildDirectoryPath: string, + clientBuildDirectory: string ) { - // Create a handler and call it for the `/` path - rendering down to the + let { basename, serverBuildFile, prerender } = reactRouterConfig; + let routesToPrerender = prerender || ["/"]; + + // Create a handler and call it for all prerender paths - rendering down to the // proper HydrateFallback ... or not! Maybe they have a static landing page // generated from routes/_index.tsx. let serverBuildPath = path.join(serverBuildDirectoryPath, serverBuildFile); - let build = await import(url.pathToFileURL(serverBuildPath).toString()); + let build = (await import( + url.pathToFileURL(serverBuildPath).toString() + )) as ServerBuild; + let routes = createPrerenderRoutes(build.routes); let { createRequestHandler: createHandler } = await import( "@react-router/node" ); let handler = createHandler(build, viteConfig.mode); - let response = await handler(new Request(`http://localhost${basename}`)); + + for (let path of routesToPrerender) { + await prerenderRoute( + handler, + routes, + basename, + path, + clientBuildDirectory, + viteConfig + ); + } + + // Cleanup - we no longer need the server build assets + fse.removeSync(serverBuildDirectoryPath); +} + +async function prerenderRoute( + handler: RequestHandler, + routes: DataRouteObject[], + basename: string, + prerenderPath: string, + clientBuildDirectory: string, + viteConfig: Vite.ResolvedConfig +) { + let normalizedPath = `${basename}${prerenderPath}/`.replace(/\/\/+/g, "/"); + let request = new Request(`http://localhost${normalizedPath}`); + let response = await handler(request); let html = await response.text(); if (response.status !== 200) { throw new Error( `SPA Mode: Received a ${response.status} status code from ` + - `\`entry.server.tsx\` while generating the \`index.html\` file.\n${html}` + `\`entry.server.tsx\` while prerendering the \`${normalizedPath}\` ` + + `path.\n${html}` ); } @@ -1660,20 +1706,100 @@ async function handleSpaMode( ) { throw new Error( "SPA Mode: Did you forget to include in your `root.tsx` " + - "`HydrateFallback` component? Your `index.html` file cannot hydrate " + - "into a SPA without ``." + "`HydrateFallback` component? Your prerendered HTML files cannot " + + "hydrate into a SPA without ``." ); } - // Write out the index.html file for the SPA - await fse.writeFile(path.join(clientBuildDirectory, "index.html"), html); + // Write out the HTML file for the SPA + await fse.ensureDir(path.join(clientBuildDirectory, normalizedPath)); + await fse.writeFile( + path.join(clientBuildDirectory, normalizedPath, "index.html"), + html + ); - viteConfig.logger.info( - "SPA Mode: index.html has been written to your " + - colors.bold(path.relative(process.cwd(), clientBuildDirectory)) + - " directory" + let outfile = path.join( + path.relative(process.cwd(), clientBuildDirectory), + normalizedPath.split("/").join(path.sep), + "index.html" ); + viteConfig.logger.info(`SPA Mode: Prerendered ${colors.bold(outfile)}`); + + let matches = matchRoutes(routes, prerenderPath); + if (matches?.some((m) => m.route.loader)) { + let normalizedDataPath = `${basename}${ + prerenderPath === "/" + ? "/_root.data" + : `${prerenderPath.replace(/\/$/, "")}.data` + }`.replace(/\/\/+/g, "/"); + let dataRequest = new Request(`http://localhost${normalizedDataPath}`); + let dataResponse = await handler(dataRequest); + if (!dataResponse.ok) { + throw new Error( + `Caught error when prerendering ${normalizedDataPath}: ${ + dataResponse.status + } ${await dataResponse.text()}}` + ); + } + let data = await dataResponse.text(); + let dataOutfile = path.join( + path.relative(process.cwd(), clientBuildDirectory), + normalizedDataPath.split("/").join(path.sep) + ); + await fse.outputFile(dataOutfile, data); + viteConfig.logger.info(`SPA Mode: Prerendered ${colors.bold(dataOutfile)}`); + } +} - // Cleanup - we no longer need the server build assets - fse.removeSync(serverBuildDirectoryPath); +type ServerRoute = ServerBuild["routes"][string] & { + children: ServerRoute[]; +}; + +// Note: Duplicated from remix-server-runtime +function groupRoutesByParentId(manifest: ServerBuild["routes"]) { + let routes: Record[]> = {}; + + Object.values(manifest).forEach((route) => { + let parentId = route.parentId || ""; + if (!routes[parentId]) { + routes[parentId] = []; + } + routes[parentId].push(route); + }); + + return routes; +} + +// Note: Duplicated from remix-server-runtime +function createPrerenderRoutes( + manifest: ServerBuild["routes"], + parentId: string = "", + routesByParentId: Record< + string, + Omit[] + > = groupRoutesByParentId(manifest) +): DataRouteObject[] { + return (routesByParentId[parentId] || []).map((route) => { + let commonRoute = { + // Always include root due to default boundaries + hasErrorBoundary: + route.id === "root" || route.module.ErrorBoundary != null, + id: route.id, + path: route.path, + loader: route.module.loader ? () => null : undefined, + action: undefined, + handle: route.module.handle, + }; + + return route.index + ? { + index: true, + ...commonRoute, + } + : { + caseSensitive: route.caseSensitive, + children: createPrerenderRoutes(manifest, route.id, routesByParentId), + ...commonRoute, + }; + }); } diff --git a/packages/remix-server-runtime/routes.ts b/packages/remix-server-runtime/routes.ts index 44f6cf8500..3892fc6ab0 100644 --- a/packages/remix-server-runtime/routes.ts +++ b/packages/remix-server-runtime/routes.ts @@ -15,7 +15,7 @@ export interface RouteManifest { export type ServerRouteManifest = RouteManifest>; -// NOTE: make sure to change the Route in remix-react if you change this +// NOTE: make sure to change the Route in remix-react/remix-dev if you change this export interface Route { index?: boolean; caseSensitive?: boolean; @@ -24,7 +24,7 @@ export interface Route { path?: string; } -// NOTE: make sure to change the EntryRoute in remix-react if you change this +// NOTE: make sure to change the EntryRoute in remix-react/remix-dev if you change this export interface EntryRoute extends Route { hasAction: boolean; hasLoader: boolean; From d8071402841a744236b4559370f8af37eba4db64 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 7 May 2024 12:11:45 -0400 Subject: [PATCH 02/11] Add changeset --- .changeset/prerendering.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .changeset/prerendering.md diff --git a/.changeset/prerendering.md b/.changeset/prerendering.md new file mode 100644 index 0000000000..04690ae392 --- /dev/null +++ b/.changeset/prerendering.md @@ -0,0 +1,35 @@ +--- +"react-router": minor +--- + +- Add support for `prerender` config in the React Router vite plugin, to support existing SSG use-cases + +To use "Prerender Mode" you set `ssr:false` in your plugin config, since you intend to prerenderr your HTML files instead of rendering them on a server at runtime, and then tell React Router which paths you would like to prerender via the `prerender` config. + +`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. + +```ts +export default defineConfig({ + plugins: [ + reactRouter({ + // Single fetch is required for prerendering (which will be the default in v7) + future: { + unstable_singleFetch: true, + }, + ssr: false, + async prerender() { + let slugs = await fakeGetSlugsFromCms(); + // PRerender these paths into `.html` files at build time, and `.data` + // files if they have loaders + return ["/", "/about", ...slugs.map((slug) => `/product/${slug}`)]; + }, + }), + tsconfigPaths(), + ], +}); + +async function fakeGetSlugsFromCms() { + await new Promise((r) => setTimeout(r, 1000)); + return ["shirt", "hat"]; +} +``` From c3d26d2cead1b4958c739ea99d9772686a1655a3 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 7 May 2024 13:15:07 -0400 Subject: [PATCH 03/11] Fix a few integration tests --- integration/error-boundary-v2-test.ts | 7 ++++++- integration/vite-presets-test.ts | 2 +- integration/vite-spa-mode-test.ts | 6 +++--- packages/react-router/lib/dom/ssr/single-fetch.tsx | 11 +++++++++-- 4 files changed, 19 insertions(+), 7 deletions(-) diff --git a/integration/error-boundary-v2-test.ts b/integration/error-boundary-v2-test.ts index e9fad82de3..25b1c8bc49 100644 --- a/integration/error-boundary-v2-test.ts +++ b/integration/error-boundary-v2-test.ts @@ -409,7 +409,12 @@ test.describe("single fetch", () => { let app = new PlaywrightFixture(appFixture, page); await app.goto("/parent"); await app.clickLink("/parent/child-with-boundary"); - await waitForAndAssert(page, app, "#child-error", "CDN Error"); + await waitForAndAssert( + page, + app, + "#child-error", + "Unable to decode turbo-stream response" + ); }); }); diff --git a/integration/vite-presets-test.ts b/integration/vite-presets-test.ts index 5c61d304e9..74df72fbb3 100644 --- a/integration/vite-presets-test.ts +++ b/integration/vite-presets-test.ts @@ -49,7 +49,7 @@ const files = { return {}; }, }, - + // Ensure preset config takes lower precedence than user config { name: "test-preset", diff --git a/integration/vite-spa-mode-test.ts b/integration/vite-spa-mode-test.ts index 1e4444b483..5512733638 100644 --- a/integration/vite-spa-mode-test.ts +++ b/integration/vite-spa-mode-test.ts @@ -145,7 +145,7 @@ test.describe("SPA Mode", () => { let stderr = result.stderr.toString("utf8"); expect(stderr).toMatch( "SPA Mode: Received a 500 status code from `entry.server.tsx` while " + - "generating the `index.html` file." + "prerendering the `/` path." ); expect(stderr).toMatch("

Loading...

"); }); @@ -170,8 +170,8 @@ test.describe("SPA Mode", () => { let stderr = result.stderr.toString("utf8"); expect(stderr).toMatch( "SPA Mode: Did you forget to include in your `root.tsx` " + - "`HydrateFallback` component? Your `index.html` file cannot hydrate " + - "into a SPA without ``." + "`HydrateFallback` component? Your prerendered HTML files cannot " + + "hydrate into a SPA without ``." ); }); }); diff --git a/packages/react-router/lib/dom/ssr/single-fetch.tsx b/packages/react-router/lib/dom/ssr/single-fetch.tsx index 8a15ce1da0..e6a7eaf9b0 100644 --- a/packages/react-router/lib/dom/ssr/single-fetch.tsx +++ b/packages/react-router/lib/dom/ssr/single-fetch.tsx @@ -295,8 +295,15 @@ export function singleFetchUrl(reqUrl: URL | string) { async function fetchAndDecode(url: URL, init?: RequestInit) { let res = await fetch(url, init); invariant(res.body, "No response body to decode"); - let decoded = await decodeViaTurboStream(res.body, window); - return { status: res.status, data: decoded.value }; + try { + let decoded = await decodeViaTurboStream(res.body, window); + return { status: res.status, data: decoded.value }; + } catch (e) { + // Unfortunately I don't think we can get access to the response text here. + // Pre-emptively cloning seems incorrect and we can't clone after consuming + // the body via turbo-stream + throw new Error("Unable to decode turbo-stream response"); + } } // Note: If you change this function please change the corresponding From 4fcd0298569c661ffda6d5af35650a1ddf3fbaec Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 7 May 2024 15:06:33 -0400 Subject: [PATCH 04/11] Decouple ssr and prerender configs --- .changeset/prerendering.md | 7 +- packages/remix-dev/config.ts | 5 - packages/remix-dev/vite/plugin.ts | 233 +++++++++++++++++++----------- packages/remix-serve/cli.ts | 5 + 4 files changed, 159 insertions(+), 91 deletions(-) diff --git a/.changeset/prerendering.md b/.changeset/prerendering.md index 04690ae392..9fbaef2777 100644 --- a/.changeset/prerendering.md +++ b/.changeset/prerendering.md @@ -4,9 +4,7 @@ - Add support for `prerender` config in the React Router vite plugin, to support existing SSG use-cases -To use "Prerender Mode" you set `ssr:false` in your plugin config, since you intend to prerenderr your HTML files instead of rendering them on a server at runtime, and then tell React Router which paths you would like to prerender via the `prerender` config. - -`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. +You can use the `prerender`config to pre-render your `.html` and `.data` files at build time and then serve them statically at runtime. `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. ```ts export default defineConfig({ @@ -16,10 +14,9 @@ export default defineConfig({ future: { unstable_singleFetch: true, }, - ssr: false, async prerender() { let slugs = await fakeGetSlugsFromCms(); - // PRerender these paths into `.html` files at build time, and `.data` + // Prerender these paths into `.html` files at build time, and `.data` // files if they have loaders return ["/", "/about", ...slugs.map((slug) => `/product/${slug}`)]; }, diff --git a/packages/remix-dev/config.ts b/packages/remix-dev/config.ts index 1161a498f3..eeebc726e6 100644 --- a/packages/remix-dev/config.ts +++ b/packages/remix-dev/config.ts @@ -403,11 +403,6 @@ export async function resolveReactRouterConfig({ let prerender: Array | null = null; if (prerenderConfig) { - if (ssr) { - throw new Error( - "Prerendering is only supported when `ssr:false` is set." - ); - } if (Array.isArray(prerenderConfig)) { prerender = prerenderConfig; } else if (typeof prerenderConfig === "function") { diff --git a/packages/remix-dev/vite/plugin.ts b/packages/remix-dev/vite/plugin.ts index 6a09a556a3..2279f35e28 100644 --- a/packages/remix-dev/vite/plugin.ts +++ b/packages/remix-dev/vite/plugin.ts @@ -1181,6 +1181,13 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => { serverBuildDirectory, clientBuildDirectory ); + } else if (ctx.reactRouterConfig.prerender != null) { + await handlePrerender( + viteConfig, + ctx.reactRouterConfig, + serverBuildDirectory, + clientBuildDirectory + ); } }, }, @@ -1318,27 +1325,16 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => { if (!route) return; if (!ctx.reactRouterConfig.ssr) { - // TODO: Should we drop this in v7? SPA Mode was originally envisioned - // as a guardrails-enabled way to do what you can basically do with your - // own pre-rendering. Server loaders "work" but are an easy footgun - // if you aren't properly handling `clientLoader` logic on subsequent - // client-side navs to those pages. - // Pre-rendering is a more advanced usage of SPA mode and therefore we - // allow server loaders for more complete pre-rendered HTML. - // So we could keep ssr:false by itself as guard-rails enabled, and - // then let folks specify prerender to opt-into full capabilities - if (ctx.reactRouterConfig.prerender == null) { - let serverOnlyExports = esModuleLexer(code)[1] - .map((exp) => exp.n) - .filter((exp) => SERVER_ONLY_ROUTE_EXPORTS.includes(exp)); - if (serverOnlyExports.length > 0) { - let str = serverOnlyExports.map((e) => `\`${e}\``).join(", "); - let message = - `SPA Mode: ${serverOnlyExports.length} invalid route export(s) in ` + - `\`${route.file}\`: ${str}. See https://remix.run/guides/spa-mode ` + - `for more information.`; - throw Error(message); - } + let serverOnlyExports = esModuleLexer(code)[1] + .map((exp) => exp.n) + .filter((exp) => SERVER_ONLY_ROUTE_EXPORTS.includes(exp)); + if (serverOnlyExports.length > 0) { + let str = serverOnlyExports.map((e) => `\`${e}\``).join(", "); + let message = + `SPA Mode: ${serverOnlyExports.length} invalid route export(s) in ` + + `\`${route.file}\`: ${str}. See https://remix.run/guides/spa-mode ` + + `for more information.`; + throw Error(message); } if (route.id !== "root") { @@ -1643,41 +1639,146 @@ async function getRouteMetadata( return info; } +async function getPrerenderBuildAndHandler( + viteConfig: Vite.ResolvedConfig, + reactRouterConfig: Awaited>, + serverBuildDirectoryPath: string +) { + let serverBuildPath = path.join( + serverBuildDirectoryPath, + reactRouterConfig.serverBuildFile + ); + let build = await import(url.pathToFileURL(serverBuildPath).toString()); + let { createRequestHandler: createHandler } = await import( + "@react-router/node" + ); + return { + build, + handler: createHandler(build, viteConfig.mode), + }; +} + async function handleSpaMode( viteConfig: Vite.ResolvedConfig, reactRouterConfig: Awaited>, serverBuildDirectoryPath: string, clientBuildDirectory: string ) { - let { basename, serverBuildFile, prerender } = reactRouterConfig; - let routesToPrerender = prerender || ["/"]; - - // Create a handler and call it for all prerender paths - rendering down to the - // proper HydrateFallback ... or not! Maybe they have a static landing page - // generated from routes/_index.tsx. - let serverBuildPath = path.join(serverBuildDirectoryPath, serverBuildFile); - let build = (await import( - url.pathToFileURL(serverBuildPath).toString() - )) as ServerBuild; - let routes = createPrerenderRoutes(build.routes); - let { createRequestHandler: createHandler } = await import( - "@react-router/node" + let { handler } = await getPrerenderBuildAndHandler( + viteConfig, + reactRouterConfig, + serverBuildDirectoryPath + ); + let request = new Request(`http://localhost${reactRouterConfig.basename}`); + let response = await handler(request); + let html = await response.text(); + + if (response.status !== 200) { + throw new Error( + `SPA Mode: Received a ${response.status} status code from ` + + `\`entry.server.tsx\` while generating the \`index.html\` file.\n${html}` + ); + } + + if ( + !html.includes("window.__remixContext =") || + !html.includes("window.__remixRouteModules =") + ) { + throw new Error( + "SPA Mode: Did you forget to include in your `root.tsx` " + + "`HydrateFallback` component? Your `index.html` file cannot hydrate " + + "into a SPA without ``." + ); + } + + // Write out the index.html file for the SPA + await fse.writeFile(path.join(clientBuildDirectory, "index.html"), html); + + viteConfig.logger.info( + "SPA Mode: index.html has been written to your " + + colors.bold(path.relative(process.cwd(), clientBuildDirectory)) + + " directory" ); - let handler = createHandler(build, viteConfig.mode); + // Cleanup - we no longer need the server build assets + fse.removeSync(serverBuildDirectoryPath); +} + +async function handlePrerender( + viteConfig: Vite.ResolvedConfig, + reactRouterConfig: Awaited>, + serverBuildDirectoryPath: string, + clientBuildDirectory: string +) { + let { build, handler } = await getPrerenderBuildAndHandler( + viteConfig, + reactRouterConfig, + serverBuildDirectoryPath + ); + + let routes = createPrerenderRoutes(build.routes); + let routesToPrerender = reactRouterConfig.prerender || ["/"]; + let requestInit = { + headers: { + "X-React-router-Prerender": "yes", + }, + }; for (let path of routesToPrerender) { + let matches = matchRoutes(routes, path); + if (matches?.some((m) => m.route.loader)) { + await prerenderData( + handler, + routes, + reactRouterConfig.basename, + path, + clientBuildDirectory, + viteConfig, + requestInit + ); + } await prerenderRoute( handler, routes, - basename, + reactRouterConfig.basename, path, clientBuildDirectory, - viteConfig + viteConfig, + requestInit ); } - // Cleanup - we no longer need the server build assets - fse.removeSync(serverBuildDirectoryPath); + async function prerenderData( + handler: RequestHandler, + routes: DataRouteObject[], + basename: string, + prerenderPath: string, + clientBuildDirectory: string, + viteConfig: Vite.ResolvedConfig, + requestInit: RequestInit + ) { + let normalizedPath = `${basename}${ + prerenderPath === "/" + ? "/_root.data" + : `${prerenderPath.replace(/\/$/, "")}.data` + }`.replace(/\/\/+/g, "/"); + let request = new Request(`http://localhost${normalizedPath}`, requestInit); + let response = await handler(request); + let data = await response.text(); + if (response.status !== 200) { + throw new Error( + `Prerender: Received a ${response.status} status code from ` + + `\`entry.server.tsx\` while prerendering the \`${normalizedPath}\` ` + + `path.\n${data}` + ); + } + + // Write out the .data file + let outdir = path.relative(process.cwd(), clientBuildDirectory); + let outfile = path.join(outdir, normalizedPath.split("/").join(path.sep)); + await fse.ensureDir(path.dirname(outfile)); + await fse.outputFile(outfile, data); + viteConfig.logger.info(`Prerender: Generated ${colors.bold(outfile)}`); + } } async function prerenderRoute( @@ -1686,15 +1787,17 @@ async function prerenderRoute( basename: string, prerenderPath: string, clientBuildDirectory: string, - viteConfig: Vite.ResolvedConfig + viteConfig: Vite.ResolvedConfig, + requestInit: RequestInit ) { let normalizedPath = `${basename}${prerenderPath}/`.replace(/\/\/+/g, "/"); - let request = new Request(`http://localhost${normalizedPath}`); + let request = new Request(`http://localhost${normalizedPath}`, requestInit); let response = await handler(request); let html = await response.text(); + if (response.status !== 200) { throw new Error( - `SPA Mode: Received a ${response.status} status code from ` + + `Prerender: Received a ${response.status} status code from ` + `\`entry.server.tsx\` while prerendering the \`${normalizedPath}\` ` + `path.\n${html}` ); @@ -1705,50 +1808,18 @@ async function prerenderRoute( !html.includes("window.__remixRouteModules =") ) { throw new Error( - "SPA Mode: Did you forget to include in your `root.tsx` " + + "Prerender: Did you forget to include in your `root.tsx` " + "`HydrateFallback` component? Your prerendered HTML files cannot " + "hydrate into a SPA without ``." ); } - // Write out the HTML file for the SPA - await fse.ensureDir(path.join(clientBuildDirectory, normalizedPath)); - await fse.writeFile( - path.join(clientBuildDirectory, normalizedPath, "index.html"), - html - ); - - let outfile = path.join( - path.relative(process.cwd(), clientBuildDirectory), - normalizedPath.split("/").join(path.sep), - "index.html" - ); - viteConfig.logger.info(`SPA Mode: Prerendered ${colors.bold(outfile)}`); - - let matches = matchRoutes(routes, prerenderPath); - if (matches?.some((m) => m.route.loader)) { - let normalizedDataPath = `${basename}${ - prerenderPath === "/" - ? "/_root.data" - : `${prerenderPath.replace(/\/$/, "")}.data` - }`.replace(/\/\/+/g, "/"); - let dataRequest = new Request(`http://localhost${normalizedDataPath}`); - let dataResponse = await handler(dataRequest); - if (!dataResponse.ok) { - throw new Error( - `Caught error when prerendering ${normalizedDataPath}: ${ - dataResponse.status - } ${await dataResponse.text()}}` - ); - } - let data = await dataResponse.text(); - let dataOutfile = path.join( - path.relative(process.cwd(), clientBuildDirectory), - normalizedDataPath.split("/").join(path.sep) - ); - await fse.outputFile(dataOutfile, data); - viteConfig.logger.info(`SPA Mode: Prerendered ${colors.bold(dataOutfile)}`); - } + // Write out the HTML file + let outdir = path.relative(process.cwd(), clientBuildDirectory); + let outfile = path.join(outdir, ...normalizedPath.split("/"), "index.html"); + await fse.ensureDir(path.dirname(outfile)); + await fse.outputFile(outfile, html); + viteConfig.logger.info(`Prerender: Generated ${colors.bold(outfile)}`); } type ServerRoute = ServerBuild["routes"][string] & { diff --git a/packages/remix-serve/cli.ts b/packages/remix-serve/cli.ts index 538166ec8e..bcd4b93f4e 100644 --- a/packages/remix-serve/cli.ts +++ b/packages/remix-serve/cli.ts @@ -81,6 +81,11 @@ async function run() { express.static(build.assetsBuildDirectory, { immutable: true, maxAge: "1y", + setHeaders: function (res, path, stat) { + if (path.endsWith(".data")) { + res.set("Content-Type", "text/x-turbo"); + } + }, }) ); app.use(express.static("public", { maxAge: "1h" })); From 722bb807057305e3803f5c58fc6a7e5e7f84b908 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 7 May 2024 15:08:44 -0400 Subject: [PATCH 05/11] Integration tests --- integration/vite-spa-mode-test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/integration/vite-spa-mode-test.ts b/integration/vite-spa-mode-test.ts index 5512733638..1e4444b483 100644 --- a/integration/vite-spa-mode-test.ts +++ b/integration/vite-spa-mode-test.ts @@ -145,7 +145,7 @@ test.describe("SPA Mode", () => { let stderr = result.stderr.toString("utf8"); expect(stderr).toMatch( "SPA Mode: Received a 500 status code from `entry.server.tsx` while " + - "prerendering the `/` path." + "generating the `index.html` file." ); expect(stderr).toMatch("

Loading...

"); }); @@ -170,8 +170,8 @@ test.describe("SPA Mode", () => { let stderr = result.stderr.toString("utf8"); expect(stderr).toMatch( "SPA Mode: Did you forget to include in your `root.tsx` " + - "`HydrateFallback` component? Your prerendered HTML files cannot " + - "hydrate into a SPA without ``." + "`HydrateFallback` component? Your `index.html` file cannot hydrate " + + "into a SPA without ``." ); }); }); From 99dab4d035e6f298815cc2ea0d77a2199aab3a9f Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 7 May 2024 15:12:41 -0400 Subject: [PATCH 06/11] Updates --- .changeset/prerendering.md | 4 ++-- packages/remix-dev/vite/plugin.ts | 6 ++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/.changeset/prerendering.md b/.changeset/prerendering.md index 9fbaef2777..21f4438e82 100644 --- a/.changeset/prerendering.md +++ b/.changeset/prerendering.md @@ -3,8 +3,8 @@ --- - Add support for `prerender` config in the React Router vite plugin, to support existing SSG use-cases - -You can use the `prerender`config to pre-render your `.html` and `.data` files at build time and then serve them statically at runtime. `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. + - 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) + - `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. ```ts export default defineConfig({ diff --git a/packages/remix-dev/vite/plugin.ts b/packages/remix-dev/vite/plugin.ts index 2279f35e28..6950ab2319 100644 --- a/packages/remix-dev/vite/plugin.ts +++ b/packages/remix-dev/vite/plugin.ts @@ -1724,11 +1724,10 @@ async function handlePrerender( }, }; for (let path of routesToPrerender) { - let matches = matchRoutes(routes, path); - if (matches?.some((m) => m.route.loader)) { + let hasLoaders = matchRoutes(routes, path)?.some((m) => m.route.loader); + if (hasLoaders) { await prerenderData( handler, - routes, reactRouterConfig.basename, path, clientBuildDirectory, @@ -1749,7 +1748,6 @@ async function handlePrerender( async function prerenderData( handler: RequestHandler, - routes: DataRouteObject[], basename: string, prerenderPath: string, clientBuildDirectory: string, From 4030a697c993f1bced9aaaf298596af6b1aff1e8 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 8 May 2024 11:21:51 -0400 Subject: [PATCH 07/11] Fix isDeepFrozen integration test check to handle null values --- integration/vite-presets-test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/integration/vite-presets-test.ts b/integration/vite-presets-test.ts index 74df72fbb3..47b9be2b53 100644 --- a/integration/vite-presets-test.ts +++ b/integration/vite-presets-test.ts @@ -18,7 +18,7 @@ const files = { let isDeepFrozen = (obj: any) => Object.isFrozen(obj) && Object.keys(obj).every( - prop => typeof obj[prop] !== 'object' || isDeepFrozen(obj[prop]) + prop => typeof obj[prop] !== 'object' || obj[prop] === null || isDeepFrozen(obj[prop]) ); export default { @@ -214,6 +214,7 @@ test("Vite / presets", async () => { "buildEnd", "future", "manifest", + "prerender", "publicPath", "routes", "serverBuildFile", From 4ed3f5da97a44c3f74eb8973d66c0d918017d68f Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 8 May 2024 12:00:57 -0400 Subject: [PATCH 08/11] Minor updates --- .../react-router/lib/dom/ssr/single-fetch.tsx | 9 ++-- packages/remix-dev/vite/plugin.ts | 45 ++++++++++++------- 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/packages/react-router/lib/dom/ssr/single-fetch.tsx b/packages/react-router/lib/dom/ssr/single-fetch.tsx index e6a7eaf9b0..952fd035b1 100644 --- a/packages/react-router/lib/dom/ssr/single-fetch.tsx +++ b/packages/react-router/lib/dom/ssr/single-fetch.tsx @@ -299,9 +299,12 @@ async function fetchAndDecode(url: URL, init?: RequestInit) { let decoded = await decodeViaTurboStream(res.body, window); return { status: res.status, data: decoded.value }; } catch (e) { - // Unfortunately I don't think we can get access to the response text here. - // Pre-emptively cloning seems incorrect and we can't clone after consuming - // the body via turbo-stream + // Can't clone after consuming the body via turbo-stream so we can't + // include the body here. In an ideal world we'd look for a turbo-stream + // content type here, or even X-Remix-Response but then folks can't + // statically deploy their prerendered .data files to a CDN unless they can + // tell that CDN to add special headers to those certain files - which is a + // bit restrictive. throw new Error("Unable to decode turbo-stream response"); } } diff --git a/packages/remix-dev/vite/plugin.ts b/packages/remix-dev/vite/plugin.ts index 6950ab2319..837c13412e 100644 --- a/packages/remix-dev/vite/plugin.ts +++ b/packages/remix-dev/vite/plugin.ts @@ -1174,21 +1174,37 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => { ); } - if (!ctx.reactRouterConfig.ssr) { - await handleSpaMode( + if (ctx.reactRouterConfig.prerender != null) { + // If we have prerender routes, that takes precedence over SPA mode + // which is ssr:false and only the rot route being rendered + await handlePrerender( viteConfig, ctx.reactRouterConfig, serverBuildDirectory, clientBuildDirectory ); - } else if (ctx.reactRouterConfig.prerender != null) { - await handlePrerender( + } else if (!ctx.reactRouterConfig.ssr) { + await handleSpaMode( viteConfig, ctx.reactRouterConfig, serverBuildDirectory, clientBuildDirectory ); } + + // For both SPA mode and prerendering, we can remove the server builds + // if ssr:false is set + if (!ctx.reactRouterConfig.ssr) { + // Cleanup - we no longer need the server build assets + viteConfig.logger.info( + [ + "Removing the server build in", + colors.green(serverBuildDirectory), + "due to ssr:false", + ].join(" ") + ); + fse.removeSync(serverBuildDirectory); + } }, }, async buildEnd() { @@ -1642,10 +1658,10 @@ async function getRouteMetadata( async function getPrerenderBuildAndHandler( viteConfig: Vite.ResolvedConfig, reactRouterConfig: Awaited>, - serverBuildDirectoryPath: string + serverBuildDirectory: string ) { let serverBuildPath = path.join( - serverBuildDirectoryPath, + serverBuildDirectory, reactRouterConfig.serverBuildFile ); let build = await import(url.pathToFileURL(serverBuildPath).toString()); @@ -1661,13 +1677,13 @@ async function getPrerenderBuildAndHandler( async function handleSpaMode( viteConfig: Vite.ResolvedConfig, reactRouterConfig: Awaited>, - serverBuildDirectoryPath: string, + serverBuildDirectory: string, clientBuildDirectory: string ) { let { handler } = await getPrerenderBuildAndHandler( viteConfig, reactRouterConfig, - serverBuildDirectoryPath + serverBuildDirectory ); let request = new Request(`http://localhost${reactRouterConfig.basename}`); let response = await handler(request); @@ -1699,28 +1715,27 @@ async function handleSpaMode( colors.bold(path.relative(process.cwd(), clientBuildDirectory)) + " directory" ); - - // Cleanup - we no longer need the server build assets - fse.removeSync(serverBuildDirectoryPath); } async function handlePrerender( viteConfig: Vite.ResolvedConfig, reactRouterConfig: Awaited>, - serverBuildDirectoryPath: string, + serverBuildDirectory: string, clientBuildDirectory: string ) { let { build, handler } = await getPrerenderBuildAndHandler( viteConfig, reactRouterConfig, - serverBuildDirectoryPath + serverBuildDirectory ); let routes = createPrerenderRoutes(build.routes); let routesToPrerender = reactRouterConfig.prerender || ["/"]; let requestInit = { headers: { - "X-React-router-Prerender": "yes", + // Header that can be used in the loader to know if you're running at + // build time or runtime + "X-React-Router-Prerender": "yes", }, }; for (let path of routesToPrerender) { @@ -1737,7 +1752,6 @@ async function handlePrerender( } await prerenderRoute( handler, - routes, reactRouterConfig.basename, path, clientBuildDirectory, @@ -1781,7 +1795,6 @@ async function handlePrerender( async function prerenderRoute( handler: RequestHandler, - routes: DataRouteObject[], basename: string, prerenderPath: string, clientBuildDirectory: string, From 5bf9019767e49f139c95a3fc4cc5cc996c7b9157 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 8 May 2024 12:29:14 -0400 Subject: [PATCH 09/11] Update remix-serve to put proper headers on pre-rendered .data files --- packages/remix-serve/cli.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/remix-serve/cli.ts b/packages/remix-serve/cli.ts index bcd4b93f4e..5532ef96cf 100644 --- a/packages/remix-serve/cli.ts +++ b/packages/remix-serve/cli.ts @@ -79,11 +79,15 @@ async function run() { app.use( build.publicPath, express.static(build.assetsBuildDirectory, { - immutable: true, - maxAge: "1y", setHeaders: function (res, path, stat) { if (path.endsWith(".data")) { res.set("Content-Type", "text/x-turbo"); + } else { + // Cache as an immutable asset for 1 year + // Do this here instead of via the immutable/maxAge headers so we can + // conditionally apply it to assets (which are hashed), and not + // pre-rendered .data files (not hashed) + res.set("Cache-Control", "public, max-age=31536000, immutable"); } }, }) From db09881849699f0c65a1708fd8383ca1b5584986 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 9 May 2024 10:48:04 -0400 Subject: [PATCH 10/11] Add a few basic integration tests --- integration/vite-prerender-test.ts | 211 +++++++++++++++++++++++++++++ packages/remix-dev/vite/plugin.ts | 81 ++++++----- 2 files changed, 250 insertions(+), 42 deletions(-) create mode 100644 integration/vite-prerender-test.ts diff --git a/integration/vite-prerender-test.ts b/integration/vite-prerender-test.ts new file mode 100644 index 0000000000..cf3907b70c --- /dev/null +++ b/integration/vite-prerender-test.ts @@ -0,0 +1,211 @@ +import fs from "node:fs"; +import path from "node:path"; +import { test, expect } from "@playwright/test"; + +import { + createAppFixture, + createFixture, + js, +} from "./helpers/create-fixture.js"; +import type { Fixture, AppFixture } from "./helpers/create-fixture.js"; +import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; +import { createProject, build } from "./helpers/vite.js"; + +let files = { + "vite.config.ts": js` + import { defineConfig } from "vite"; + import { vitePlugin as reactRouter } from "@react-router/dev"; + + export default defineConfig({ + build: { manifest: true }, + plugins: [ + reactRouter({ + future: { + unstable_singleFetch: true, + }, + prerender: ['/', '/about'], + }) + ], + }); + `, + "app/root.tsx": js` + import * as React from "react"; + import { Form, Link, Links, Meta, Outlet, Scripts } from "react-router"; + + export function meta({ data }) { + return [{ + title: "Root Title" + }]; + } + + export default function Root() { + return ( + + + + + + +

Root

+ + + + + + ); + } + `, + "app/routes/_index.tsx": js` + import * as React from "react"; + import { useLoaderData } from "react-router-dom"; + + export function meta({ data }) { + return [{ + title: "Index Title: " + data + }]; + } + + export async function loader() { + return "Index Loader Data"; + } + + export default function Component() { + let data = useLoaderData(); + const [mounted, setMounted] = React.useState(false); + React.useEffect(() => setMounted(true), []); + + return ( + <> +

Index

+

{data}

+ {!mounted ?

Unmounted

:

Mounted

} + + ); + } + `, + "app/routes/about.tsx": js` + import { useActionData, useLoaderData } from "react-router-dom"; + + export function meta({ data }) { + return [{ + title: "About Title: " + data + }]; + } + + export async function loader() { + return "About Loader Data"; + } + + export default function Component() { + let data = useLoaderData(); + + return ( + <> +

About

+

{data}

+ + ); + } + `, +}; + +test.describe("Prerendering", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.afterAll(() => { + appFixture.close(); + }); + + test("Prerenders a static array of routes", async () => { + fixture = await createFixture({ + files, + }); + appFixture = await createAppFixture(fixture); + + let clientDir = path.join(fixture.projectDir, "build", "client"); + expect(fs.readdirSync(clientDir)).toEqual([ + "_root.data", + "about", + "about.data", + "assets", + "favicon.ico", + "index.html", + ]); + expect(fs.readdirSync(path.join(clientDir, "about"))).toEqual([ + "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("Prerenders a dynamic array of routes", async () => { + fixture = await createFixture({ + files: { + ...files, + "vite.config.ts": js` + import { defineConfig } from "vite"; + import { vitePlugin as reactRouter } from "@react-router/dev"; + + export default defineConfig({ + build: { manifest: true }, + plugins: [ + reactRouter({ + future: { + unstable_singleFetch: true, + }, + async prerender() { + await new Promise(r => setTimeout(r, 1)); + return ['/', '/about']; + }, + }) + ], + }); + `, + }, + }); + appFixture = await createAppFixture(fixture); + + let clientDir = path.join(fixture.projectDir, "build", "client"); + expect(fs.readdirSync(clientDir)).toEqual([ + "_root.data", + "about", + "about.data", + "assets", + "favicon.ico", + "index.html", + ]); + expect(fs.readdirSync(path.join(clientDir, "about"))).toEqual([ + "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

'); + }); +}); diff --git a/packages/remix-dev/vite/plugin.ts b/packages/remix-dev/vite/plugin.ts index 837c13412e..0019947e4f 100644 --- a/packages/remix-dev/vite/plugin.ts +++ b/packages/remix-dev/vite/plugin.ts @@ -1689,23 +1689,8 @@ async function handleSpaMode( let response = await handler(request); let html = await response.text(); - if (response.status !== 200) { - throw new Error( - `SPA Mode: Received a ${response.status} status code from ` + - `\`entry.server.tsx\` while generating the \`index.html\` file.\n${html}` - ); - } - - if ( - !html.includes("window.__remixContext =") || - !html.includes("window.__remixRouteModules =") - ) { - throw new Error( - "SPA Mode: Did you forget to include in your `root.tsx` " + - "`HydrateFallback` component? Your `index.html` file cannot hydrate " + - "into a SPA without ``." - ); - } + validatePrerenderedResponse(response, html, "SPA Mode", "/"); + validatePrerenderedHtml(html, "SPA Mode"); // Write out the index.html file for the SPA await fse.writeFile(path.join(clientBuildDirectory, "index.html"), html); @@ -1743,18 +1728,18 @@ async function handlePrerender( if (hasLoaders) { await prerenderData( handler, - reactRouterConfig.basename, path, clientBuildDirectory, + reactRouterConfig, viteConfig, requestInit ); } await prerenderRoute( handler, - reactRouterConfig.basename, path, clientBuildDirectory, + reactRouterConfig, viteConfig, requestInit ); @@ -1762,13 +1747,13 @@ async function handlePrerender( async function prerenderData( handler: RequestHandler, - basename: string, prerenderPath: string, clientBuildDirectory: string, + reactRouterConfig: Awaited>, viteConfig: Vite.ResolvedConfig, requestInit: RequestInit ) { - let normalizedPath = `${basename}${ + let normalizedPath = `${reactRouterConfig.basename}${ prerenderPath === "/" ? "/_root.data" : `${prerenderPath.replace(/\/$/, "")}.data` @@ -1776,13 +1761,8 @@ async function handlePrerender( let request = new Request(`http://localhost${normalizedPath}`, requestInit); let response = await handler(request); let data = await response.text(); - if (response.status !== 200) { - throw new Error( - `Prerender: Received a ${response.status} status code from ` + - `\`entry.server.tsx\` while prerendering the \`${normalizedPath}\` ` + - `path.\n${data}` - ); - } + + validatePrerenderedResponse(response, data, "Prerender", normalizedPath); // Write out the .data file let outdir = path.relative(process.cwd(), clientBuildDirectory); @@ -1795,42 +1775,59 @@ async function handlePrerender( async function prerenderRoute( handler: RequestHandler, - basename: string, prerenderPath: string, clientBuildDirectory: string, + reactRouterConfig: Awaited>, viteConfig: Vite.ResolvedConfig, requestInit: RequestInit ) { - let normalizedPath = `${basename}${prerenderPath}/`.replace(/\/\/+/g, "/"); + let normalizedPath = `${reactRouterConfig.basename}${prerenderPath}/`.replace( + /\/\/+/g, + "/" + ); let request = new Request(`http://localhost${normalizedPath}`, requestInit); let response = await handler(request); let html = await response.text(); + validatePrerenderedResponse(response, html, "Prerender", normalizedPath); + + if (!reactRouterConfig.ssr) { + validatePrerenderedHtml(html, "Prerender"); + } + + // Write out the HTML file + let outdir = path.relative(process.cwd(), clientBuildDirectory); + let outfile = path.join(outdir, ...normalizedPath.split("/"), "index.html"); + await fse.ensureDir(path.dirname(outfile)); + await fse.outputFile(outfile, html); + viteConfig.logger.info(`Prerender: Generated ${colors.bold(outfile)}`); +} + +function validatePrerenderedResponse( + response: Response, + html: string, + prefix: string, + path: string +) { if (response.status !== 200) { throw new Error( - `Prerender: Received a ${response.status} status code from ` + - `\`entry.server.tsx\` while prerendering the \`${normalizedPath}\` ` + + `${prefix}: Received a ${response.status} status code from ` + + `\`entry.server.tsx\` while prerendering the \`${path}\` ` + `path.\n${html}` ); } +} +function validatePrerenderedHtml(html: string, prefix: string) { if ( !html.includes("window.__remixContext =") || !html.includes("window.__remixRouteModules =") ) { throw new Error( - "Prerender: Did you forget to include in your `root.tsx` " + - "`HydrateFallback` component? Your prerendered HTML files cannot " + - "hydrate into a SPA without ``." + `${prefix}: Did you forget to include in your route route? ` + + "Your pre-rendered HTML files cannot hydrate without ``." ); } - - // Write out the HTML file - let outdir = path.relative(process.cwd(), clientBuildDirectory); - let outfile = path.join(outdir, ...normalizedPath.split("/"), "index.html"); - await fse.ensureDir(path.dirname(outfile)); - await fse.outputFile(outfile, html); - viteConfig.logger.info(`Prerender: Generated ${colors.bold(outfile)}`); } type ServerRoute = ServerBuild["routes"][string] & { From ce4c8b10d6d3cee54064931e91d4e4c473f5e5b1 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 9 May 2024 14:45:42 -0400 Subject: [PATCH 11/11] Add a few more prerender e2e tests --- integration/vite-prerender-test.ts | 123 +++++++++++++++++++++++++++-- integration/vite-spa-mode-test.ts | 37 +++++---- packages/remix-dev/vite/plugin.ts | 2 +- 3 files changed, 137 insertions(+), 25 deletions(-) diff --git a/integration/vite-prerender-test.ts b/integration/vite-prerender-test.ts index cf3907b70c..aac8175fa1 100644 --- a/integration/vite-prerender-test.ts +++ b/integration/vite-prerender-test.ts @@ -39,6 +39,8 @@ let files = { } export default function Root() { + const [mounted, setMounted] = React.useState(false); + React.useEffect(() => setMounted(true), []); return ( @@ -47,6 +49,7 @@ let files = {

Root

+ {!mounted ?

Unmounted

:

Mounted

}