Skip to content

Enable prerendering for resource routes #12200

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Oct 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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