Skip to content

Commit

Permalink
Enable prerendering for resource routes (#12200)
Browse files Browse the repository at this point in the history
* Enable prerendering for resource routes

* Remove unused param
  • Loading branch information
brophdawg11 authored Oct 29, 2024
1 parent 509a96d commit cae658e
Show file tree
Hide file tree
Showing 5 changed files with 191 additions and 43 deletions.
5 changes: 5 additions & 0 deletions .changeset/eighty-dolls-juggle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@react-router/dev": patch
---

Enable prerendering for resource routes
86 changes: 57 additions & 29 deletions integration/helpers/create-fixture.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";

Expand Down Expand Up @@ -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: () => {
Expand All @@ -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);

Expand Down
66 changes: 66 additions & 0 deletions integration/vite-prerender-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,72 @@ test.describe("Prerendering", () => {
expect(html).toMatch('<p data-loader-data="true">About Loader Data</p>');
});

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,
Expand Down
75 changes: 63 additions & 12 deletions packages/react-router-dev/vite/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
};
}
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down Expand Up @@ -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<ReturnType<typeof resolveReactRouterConfig>>,
viteConfig: Vite.ResolvedConfig,
requestInit: RequestInit
) {
let normalizedPath = `${reactRouterConfig.basename}${prerenderPath}/`
.replace(/\/\/+/g, "/")
.replace(/\/$/g, "");
let request = new Request(`http://localhost${normalizedPath}`, requestInit);
let response = await handler(request);
let text = await response.text();

validatePrerenderedResponse(response, text, "Prerender", normalizedPath);

// Write out the resource route file
let outdir = path.relative(process.cwd(), clientBuildDirectory);
let outfile = path.join(outdir, ...normalizedPath.split("/"));
await fse.ensureDir(path.dirname(outfile));
await fse.outputFile(outfile, text);
viteConfig.logger.info(`Prerender: Generated ${colors.bold(outfile)}`);
}

async function prerenderManifest(
build: ServerBuild,
clientBuildDirectory: string,
Expand Down
2 changes: 0 additions & 2 deletions packages/react-router/lib/server-runtime/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,6 @@ export const createRequestHandler: CreateRequestHandlerFunction = (
) {
response = await handleResourceRequest(
serverMode,
_build,
staticHandler,
matches.slice(-1)[0].route.id,
request,
Expand Down Expand Up @@ -479,7 +478,6 @@ async function handleDocumentRequest(

async function handleResourceRequest(
serverMode: ServerMode,
build: ServerBuild,
staticHandler: StaticHandler,
routeId: string,
request: Request,
Expand Down

0 comments on commit cae658e

Please sign in to comment.