diff --git a/.changeset/eighty-dolls-juggle.md b/.changeset/eighty-dolls-juggle.md new file mode 100644 index 0000000000..80416d4a76 --- /dev/null +++ b/.changeset/eighty-dolls-juggle.md @@ -0,0 +1,5 @@ +--- +"@react-router/dev": patch +--- + +Enable prerendering for resource routes diff --git a/integration/helpers/create-fixture.ts b/integration/helpers/create-fixture.ts index ee4d9fe648..8e22c35232 100644 --- a/integration/helpers/create-fixture.ts +++ b/integration/helpers/create-fixture.ts @@ -1,4 +1,5 @@ import type { Writable } from "node:stream"; +import { Readable } from "node:stream"; import path from "node:path"; import url from "node:url"; import fse from "fs-extra"; @@ -15,6 +16,7 @@ import { UNSAFE_decodeViaTurboStream as decodeViaTurboStream, } from "react-router"; import { createRequestHandler as createExpressHandler } from "@react-router/express"; +import { createReadableStreamFromReadable } from "@react-router/node"; import { viteConfig } from "./vite.js"; @@ -54,40 +56,23 @@ export async function createFixture(init: FixtureInit, mode?: ServerMode) { ); }; - if (init.spaMode || init.prerender) { - let requestDocument = init.spaMode - ? () => { - let html = fse.readFileSync( - path.join(projectDir, "build/client/index.html") - ); - return new Response(html, { - headers: { - "Content-Type": "text/html", - }, - }); - } - : (href: string) => { - let pathname = new URL(href, "test://test").pathname; - let file = pathname.endsWith(".data") - ? pathname - : pathname + "/index.html"; - let html = fse.readFileSync( - path.join(projectDir, "build/client" + file) - ); - return new Response(html, { - headers: { - "Content-Type": "text/html", - }, - }); - }; - + if (init.spaMode) { return { projectDir, build: null, isSpaMode: init.spaMode, prerender: init.prerender, - requestDocument, - requestResource: () => { + requestDocument() { + let html = fse.readFileSync( + path.join(projectDir, "build/client/index.html") + ); + return new Response(html, { + headers: { + "Content-Type": "text/html", + }, + }); + }, + requestResource() { throw new Error("Cannot requestResource in SPA Mode tests"); }, requestSingleFetchData: () => { @@ -101,6 +86,49 @@ export async function createFixture(init: FixtureInit, mode?: ServerMode) { }; } + if (init.prerender) { + return { + projectDir, + build: null, + isSpaMode: init.spaMode, + prerender: init.prerender, + requestDocument(href: string) { + let file = new URL(href, "test://test").pathname + "/index.html"; + let html = fse.readFileSync( + path.join(projectDir, "build/client" + file) + ); + return new Response(html, { + headers: { + "Content-Type": "text/html", + }, + }); + }, + requestResource(href: string) { + let data = fse.readFileSync( + path.join(projectDir, "build/client", href) + ); + return new Response(data); + }, + async requestSingleFetchData(href: string) { + let data = fse.readFileSync( + path.join(projectDir, "build/client", href) + ); + let stream = createReadableStreamFromReadable(Readable.from(data)); + return { + status: 200, + statusText: "OK", + headers: new Headers(), + data: (await decodeViaTurboStream(stream, global)).value, + }; + }, + postDocument: () => { + throw new Error("Cannot postDocument in Prerender tests"); + }, + getBrowserAsset, + useReactRouterServe: init.useReactRouterServe, + }; + } + let app: ServerBuild = await import(buildPath); let handler = createRequestHandler(app, mode || ServerMode.Production); diff --git a/integration/vite-prerender-test.ts b/integration/vite-prerender-test.ts index 7e4e7381b6..de77dac736 100644 --- a/integration/vite-prerender-test.ts +++ b/integration/vite-prerender-test.ts @@ -355,6 +355,72 @@ test.describe("Prerendering", () => { expect(html).toMatch('
About Loader Data
'); }); + test("Pre-renders resource routes with file extensions", async () => { + fixture = await createFixture({ + prerender: true, + files: { + ...files, + "app/routes/text[.txt].tsx": js` + export function loader() { + return new Response("Hello, world"); + } + `, + "app/routes/json[.json].tsx": js` + export function loader() { + return new Response(JSON.stringify({ hello: 'world' }), { + headers: { + 'Content-Type': 'application/json', + } + }); + } + `, + }, + }); + appFixture = await createAppFixture(fixture); + + let clientDir = path.join(fixture.projectDir, "build", "client"); + expect(listAllFiles(clientDir).sort()).toEqual([ + "__manifest", + "_root.data", + "about.data", + "about/index.html", + "favicon.ico", + "index.html", + "json.json", + "json.json.data", + "text.txt", + "text.txt.data", + ]); + + let res = await fixture.requestResource("/json.json"); + expect(await res.json()).toEqual({ hello: "world" }); + + let dataRes = await fixture.requestSingleFetchData("/json.json.data"); + expect(dataRes.data).toEqual({ + root: { + data: null, + }, + "routes/json[.json]": { + data: { + hello: "world", + }, + }, + }); + + res = await fixture.requestResource("/text.txt"); + expect(await res.text()).toBe("Hello, world"); + + dataRes = await fixture.requestSingleFetchData("/text.txt.data"); + expect(dataRes.data).toEqual({ + root: { + data: null, + }, + "routes/text[.txt]": { + data: "Hello, world", + }, + }); + }); + test("Hydrates into a navigable app", 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 7a4f1a75d2..201d143677 100644 --- a/packages/react-router-dev/vite/plugin.ts +++ b/packages/react-router-dev/vite/plugin.ts @@ -1776,7 +1776,7 @@ async function getPrerenderBuildAndHandler( let build = await import(url.pathToFileURL(serverBuildPath).toString()); let { createRequestHandler: createHandler } = await import("react-router"); return { - build, + build: build as ServerBuild, handler: createHandler(build, viteConfig.mode), }; } @@ -1844,7 +1844,8 @@ async function handlePrerender( "X-React-Router-Prerender": "yes", }; for (let path of routesToPrerender) { - let hasLoaders = matchRoutes(routes, path)?.some((m) => m.route.loader); + let matches = matchRoutes(routes, path); + let hasLoaders = matches?.some((m) => m.route.loader); let data: string | undefined; if (hasLoaders) { data = await prerenderData( @@ -1856,16 +1857,41 @@ async function handlePrerender( { headers } ); } - await prerenderRoute( - handler, - path, - clientBuildDirectory, - reactRouterConfig, - viteConfig, - data - ? { headers: { ...headers, "X-React-Router-Prerender-Data": data } } - : { headers } - ); + + // 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 && + manifestRoute.loader; + + if (isResourceRoute) { + await prerenderResourceRoute( + handler, + path, + clientBuildDirectory, + reactRouterConfig, + viteConfig, + { headers } + ); + } else { + await prerenderRoute( + handler, + path, + clientBuildDirectory, + reactRouterConfig, + viteConfig, + data + ? { headers: { ...headers, "X-React-Router-Prerender-Data": data } } + : { headers } + ); + } } await prerenderManifest( @@ -1976,6 +2002,31 @@ async function prerenderRoute( viteConfig.logger.info(`Prerender: Generated ${colors.bold(outfile)}`); } +async function prerenderResourceRoute( + handler: RequestHandler, + prerenderPath: string, + clientBuildDirectory: string, + reactRouterConfig: Awaited