From 4b9d5c82c5cd6d0faf8a474dd0af98573f20648d Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Fri, 4 Oct 2024 16:36:14 -0400 Subject: [PATCH 1/7] Fix react-router-serve handling of prerendered files --- docs/misc/pre-rendering.md | 6 ------ packages/react-router-serve/cli.ts | 13 +------------ 2 files changed, 1 insertion(+), 18 deletions(-) diff --git a/docs/misc/pre-rendering.md b/docs/misc/pre-rendering.md index a115a6b94a..2ca42c7dc3 100644 --- a/docs/misc/pre-rendering.md +++ b/docs/misc/pre-rendering.md @@ -116,12 +116,6 @@ 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"); - } - }, }) ); 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")); From 2cca60e4af9e54b3baca1e7404a8853e2db07e60 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Fri, 4 Oct 2024 16:36:47 -0400 Subject: [PATCH 2/7] Reuse prerendered data from loader when prerendering HTML pages --- packages/react-router-dev/vite/plugin.ts | 20 ++++++------ .../react-router/lib/server-runtime/routes.ts | 32 +++++++++++++++++-- 2 files changed, 41 insertions(+), 11 deletions(-) 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/lib/server-runtime/routes.ts b/packages/react-router/lib/server-runtime/routes.ts index 136121a5ee..3787231b9a 100644 --- a/packages/react-router/lib/server-runtime/routes.ts +++ b/packages/react-router/lib/server-runtime/routes.ts @@ -2,6 +2,7 @@ import type { AgnosticDataRouteObject, LoaderFunctionArgs as RRLoaderFunctionArgs, ActionFunctionArgs as RRActionFunctionArgs, + RouteData, } from "../router/utils"; import { callRouteHandler } from "./data"; import type { FutureConfig } from "../dom/ssr/entry"; @@ -10,6 +11,11 @@ import type { LoaderFunctionArgs, ServerRouteModule, } from "./routeModules"; +import { + SingleFetchResults, + decodeViaTurboStream, +} from "../dom/ssr/single-fetch"; +import invariant from "./invariant"; export interface RouteManifest { [routeId: string]: Route; @@ -95,8 +101,30 @@ 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 (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(route.id in data, "Unable to decode prerendered data"); + return data[route.id]; + } + let val = await callRouteHandler( + route.module.loader!, + args as LoaderFunctionArgs + ); + return val; + } : undefined, action: route.module.action ? (args: RRActionFunctionArgs) => From 1f43028139dcae0518bc65ce35457c8bd195600e Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Fri, 4 Oct 2024 16:39:25 -0400 Subject: [PATCH 3/7] Add changeset --- .changeset/rare-plums-chew.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/rare-plums-chew.md 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 From 3861eca7713408847e2edce256b0ed486fb4c92d Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Mon, 7 Oct 2024 10:21:58 -0400 Subject: [PATCH 4/7] Fix lint issue --- packages/react-router/lib/server-runtime/routes.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/react-router/lib/server-runtime/routes.ts b/packages/react-router/lib/server-runtime/routes.ts index 3787231b9a..77bd2f6e5a 100644 --- a/packages/react-router/lib/server-runtime/routes.ts +++ b/packages/react-router/lib/server-runtime/routes.ts @@ -2,7 +2,6 @@ import type { AgnosticDataRouteObject, LoaderFunctionArgs as RRLoaderFunctionArgs, ActionFunctionArgs as RRActionFunctionArgs, - RouteData, } from "../router/utils"; import { callRouteHandler } from "./data"; import type { FutureConfig } from "../dom/ssr/entry"; @@ -11,10 +10,8 @@ import type { LoaderFunctionArgs, ServerRouteModule, } from "./routeModules"; -import { - SingleFetchResults, - decodeViaTurboStream, -} from "../dom/ssr/single-fetch"; +import type { SingleFetchResults } from "../dom/ssr/single-fetch"; +import { decodeViaTurboStream } from "../dom/ssr/single-fetch"; import invariant from "./invariant"; export interface RouteManifest { From d3021f4750d1edcbae069fba3a08cfc8dd796503 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Mon, 7 Oct 2024 10:40:37 -0400 Subject: [PATCH 5/7] Fix loaders --- .../react-router/lib/server-runtime/routes.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/react-router/lib/server-runtime/routes.ts b/packages/react-router/lib/server-runtime/routes.ts index 77bd2f6e5a..26fdd34a64 100644 --- a/packages/react-router/lib/server-runtime/routes.ts +++ b/packages/react-router/lib/server-runtime/routes.ts @@ -10,7 +10,10 @@ import type { LoaderFunctionArgs, ServerRouteModule, } from "./routeModules"; -import type { SingleFetchResults } from "../dom/ssr/single-fetch"; +import type { + SingleFetchResult, + SingleFetchResults, +} from "../dom/ssr/single-fetch"; import { decodeViaTurboStream } from "../dom/ssr/single-fetch"; import invariant from "./invariant"; @@ -99,6 +102,8 @@ export function createStaticHandlerDataRoutes( // context even though we know it'll always be provided in remix loader: route.module.loader ? 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" @@ -113,8 +118,13 @@ export function createStaticHandlerDataRoutes( }); let decoded = await decodeViaTurboStream(stream, global); let data = decoded.value as SingleFetchResults; - invariant(route.id in data, "Unable to decode prerendered data"); - return data[route.id]; + 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!, From 1e9b7c3d5e14727055fd35e141cf6d8c8b1a7c6f Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Mon, 7 Oct 2024 11:25:12 -0400 Subject: [PATCH 6/7] Sync up docs --- docs/misc/pre-rendering.md | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/docs/misc/pre-rendering.md b/docs/misc/pre-rendering.md index 2ca42c7dc3..bed931cd99 100644 --- a/docs/misc/pre-rendering.md +++ b/docs/misc/pre-rendering.md @@ -111,13 +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, - }) -); +app.use("/", express.static("build/client")); // Serve remaining unhandled requests via your React Router handler app.all( From 0dd6108b3f0d1b00575f4ccc96ebfb71cac66ef5 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Mon, 7 Oct 2024 11:55:40 -0400 Subject: [PATCH 7/7] Add test for large prerendered data --- integration/vite-prerender-test.ts | 57 ++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) 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,