diff --git a/.changeset/rare-plums-chew.md b/.changeset/rare-plums-chew.md new file mode 100644 index 0000000000..6624883cd6 --- /dev/null +++ b/.changeset/rare-plums-chew.md @@ -0,0 +1,8 @@ +--- +"@react-router/serve": patch +"@react-router/dev": patch +"react-router": patch +--- + +- Fix `react-router-serve` handling of prerendered HTML files by removing the `redirect: false` option so it now falls back on the default `redirect: true` behavior of redirecting from `/folder` -> `/folder/` which will then pick up `/folder/index.html` from disk. See https://expressjs.com/en/resources/middleware/serve-static.html +- Proxy prerendered loader data into prerender pass for HTML files to avoid double-invocations of the loader at build time diff --git a/docs/misc/pre-rendering.md b/docs/misc/pre-rendering.md index a115a6b94a..bed931cd99 100644 --- a/docs/misc/pre-rendering.md +++ b/docs/misc/pre-rendering.md @@ -111,19 +111,7 @@ app.use( ); // Serve static HTML and .data requests without Cache-Control -app.use( - "/", - express.static("build/client", { - // Don't redirect directory index.html requests to include a trailing slash - redirect: false, - setHeaders: function (res, path) { - // Add the proper Content-Type for turbo-stream data responses - if (path.endsWith(".data")) { - res.set("Content-Type", "text/x-turbo"); - } - }, - }) -); +app.use("/", express.static("build/client")); // Serve remaining unhandled requests via your React Router handler app.all( diff --git a/integration/vite-prerender-test.ts b/integration/vite-prerender-test.ts index ca08a359f7..4b50eb57df 100644 --- a/integration/vite-prerender-test.ts +++ b/integration/vite-prerender-test.ts @@ -414,6 +414,63 @@ test.describe("Prerendering", () => { expect(await app.getHtml()).toContain("NOT-PRERENDERED-false"); }); + test("Does not encounter header limits on large prerendered data", async ({ + page, + }) => { + fixture = await createFixture({ + // Even thogh we are prerendering, we want a running server so we can + // hit the pre-rendered HTML file and a non-prerendered route + prerender: false, + files: { + ...files, + "vite.config.ts": js` + import { defineConfig } from "vite"; + import { reactRouter } from "@react-router/dev/vite"; + + export default defineConfig({ + build: { manifest: true }, + plugins: [ + reactRouter({ + prerender: ["/", "/about"], + }) + ], + }); + `, + "app/routes/about.tsx": js` + import { useLoaderData } from 'react-router'; + export function loader({ request }) { + return { + prerendered: request.headers.has('X-React-Router-Prerender') ? 'yes' : 'no', + // 24999 characters + data: new Array(5000).fill('test').join('-'), + }; + } + + export default function Comp() { + let data = useLoaderData(); + return ( + <> +

Large loader

+

{data.prerendered}

+

{data.data.length}

+ + ); + } + `, + }, + }); + appFixture = await createAppFixture(fixture); + + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/about"); + await page.waitForSelector("[data-mounted]"); + expect(await app.getHtml("[data-title]")).toContain("Large loader"); + expect(await app.getHtml("[data-prerendered]")).toContain("yes"); + expect(await app.getHtml("[data-length]")).toBe( + '

24999

' + ); + }); + test("Renders down to the proper HydrateFallback", async ({ page }) => { fixture = await createFixture({ prerender: true, diff --git a/packages/react-router-dev/vite/plugin.ts b/packages/react-router-dev/vite/plugin.ts index ceda0905a4..07593480a3 100644 --- a/packages/react-router-dev/vite/plugin.ts +++ b/packages/react-router-dev/vite/plugin.ts @@ -1831,23 +1831,22 @@ async function handlePrerender( } else { routesToPrerender = reactRouterConfig.prerender || ["/"]; } - let requestInit = { - headers: { - // Header that can be used in the loader to know if you're running at - // build time or runtime - "X-React-Router-Prerender": "yes", - }, + let headers = { + // 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) { let hasLoaders = matchRoutes(routes, path)?.some((m) => m.route.loader); + let data: string | undefined; if (hasLoaders) { - await prerenderData( + data = await prerenderData( handler, path, clientBuildDirectory, reactRouterConfig, viteConfig, - requestInit + { headers } ); } await prerenderRoute( @@ -1856,7 +1855,9 @@ async function handlePrerender( clientBuildDirectory, reactRouterConfig, viteConfig, - requestInit + data + ? { headers: { ...headers, "X-React-Router-Prerender-Data": data } } + : { headers } ); } @@ -1934,6 +1935,7 @@ async function prerenderData( await fse.ensureDir(path.dirname(outfile)); await fse.outputFile(outfile, data); viteConfig.logger.info(`Prerender: Generated ${colors.bold(outfile)}`); + return data; } async function prerenderRoute( diff --git a/packages/react-router-serve/cli.ts b/packages/react-router-serve/cli.ts index ab7a0feff7..8929f8624e 100644 --- a/packages/react-router-serve/cli.ts +++ b/packages/react-router-serve/cli.ts @@ -83,18 +83,7 @@ async function run() { maxAge: "1y", }) ); - app.use( - build.publicPath, - express.static(build.assetsBuildDirectory, { - // Don't redirect directory index.html request to include a trailing slash - redirect: false, - setHeaders: function (res, path) { - if (path.endsWith(".data")) { - res.set("Content-Type", "text/x-turbo"); - } - }, - }) - ); + app.use(build.publicPath, express.static(build.assetsBuildDirectory)); app.use(express.static("public", { maxAge: "1h" })); app.use(morgan("tiny")); diff --git a/packages/react-router/lib/server-runtime/routes.ts b/packages/react-router/lib/server-runtime/routes.ts index 136121a5ee..26fdd34a64 100644 --- a/packages/react-router/lib/server-runtime/routes.ts +++ b/packages/react-router/lib/server-runtime/routes.ts @@ -10,6 +10,12 @@ import type { LoaderFunctionArgs, ServerRouteModule, } from "./routeModules"; +import type { + SingleFetchResult, + SingleFetchResults, +} from "../dom/ssr/single-fetch"; +import { decodeViaTurboStream } from "../dom/ssr/single-fetch"; +import invariant from "./invariant"; export interface RouteManifest { [routeId: string]: Route; @@ -95,8 +101,37 @@ export function createStaticHandlerDataRoutes( // Need to use RR's version in the param typed here to permit the optional // context even though we know it'll always be provided in remix loader: route.module.loader - ? (args: RRLoaderFunctionArgs) => - callRouteHandler(route.module.loader!, args as LoaderFunctionArgs) + ? async (args: RRLoaderFunctionArgs) => { + // If we're prerendering, use the data passed in from prerendering + // the .data route so we dom't call loaders twice + if (args.request.headers.has("X-React-Router-Prerender-Data")) { + let encoded = args.request.headers.get( + "X-React-Router-Prerender-Data" + ); + invariant(encoded, "Missing prerendered data for route"); + let uint8array = new TextEncoder().encode(encoded); + let stream = new ReadableStream({ + start(controller) { + controller.enqueue(uint8array); + controller.close(); + }, + }); + let decoded = await decodeViaTurboStream(stream, global); + let data = decoded.value as SingleFetchResults; + invariant( + data && route.id in data, + "Unable to decode prerendered data" + ); + let result = data[route.id] as SingleFetchResult; + invariant("data" in result, "Unable to process prerendered data"); + return result.data; + } + let val = await callRouteHandler( + route.module.loader!, + args as LoaderFunctionArgs + ); + return val; + } : undefined, action: route.module.action ? (args: RRActionFunctionArgs) =>