Skip to content

Commit cae658e

Browse files
authored
Enable prerendering for resource routes (#12200)
* Enable prerendering for resource routes * Remove unused param
1 parent 509a96d commit cae658e

File tree

5 files changed

+191
-43
lines changed

5 files changed

+191
-43
lines changed

.changeset/eighty-dolls-juggle.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@react-router/dev": patch
3+
---
4+
5+
Enable prerendering for resource routes

integration/helpers/create-fixture.ts

Lines changed: 57 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Writable } from "node:stream";
2+
import { Readable } from "node:stream";
23
import path from "node:path";
34
import url from "node:url";
45
import fse from "fs-extra";
@@ -15,6 +16,7 @@ import {
1516
UNSAFE_decodeViaTurboStream as decodeViaTurboStream,
1617
} from "react-router";
1718
import { createRequestHandler as createExpressHandler } from "@react-router/express";
19+
import { createReadableStreamFromReadable } from "@react-router/node";
1820

1921
import { viteConfig } from "./vite.js";
2022

@@ -54,40 +56,23 @@ export async function createFixture(init: FixtureInit, mode?: ServerMode) {
5456
);
5557
};
5658

57-
if (init.spaMode || init.prerender) {
58-
let requestDocument = init.spaMode
59-
? () => {
60-
let html = fse.readFileSync(
61-
path.join(projectDir, "build/client/index.html")
62-
);
63-
return new Response(html, {
64-
headers: {
65-
"Content-Type": "text/html",
66-
},
67-
});
68-
}
69-
: (href: string) => {
70-
let pathname = new URL(href, "test://test").pathname;
71-
let file = pathname.endsWith(".data")
72-
? pathname
73-
: pathname + "/index.html";
74-
let html = fse.readFileSync(
75-
path.join(projectDir, "build/client" + file)
76-
);
77-
return new Response(html, {
78-
headers: {
79-
"Content-Type": "text/html",
80-
},
81-
});
82-
};
83-
59+
if (init.spaMode) {
8460
return {
8561
projectDir,
8662
build: null,
8763
isSpaMode: init.spaMode,
8864
prerender: init.prerender,
89-
requestDocument,
90-
requestResource: () => {
65+
requestDocument() {
66+
let html = fse.readFileSync(
67+
path.join(projectDir, "build/client/index.html")
68+
);
69+
return new Response(html, {
70+
headers: {
71+
"Content-Type": "text/html",
72+
},
73+
});
74+
},
75+
requestResource() {
9176
throw new Error("Cannot requestResource in SPA Mode tests");
9277
},
9378
requestSingleFetchData: () => {
@@ -101,6 +86,49 @@ export async function createFixture(init: FixtureInit, mode?: ServerMode) {
10186
};
10287
}
10388

89+
if (init.prerender) {
90+
return {
91+
projectDir,
92+
build: null,
93+
isSpaMode: init.spaMode,
94+
prerender: init.prerender,
95+
requestDocument(href: string) {
96+
let file = new URL(href, "test://test").pathname + "/index.html";
97+
let html = fse.readFileSync(
98+
path.join(projectDir, "build/client" + file)
99+
);
100+
return new Response(html, {
101+
headers: {
102+
"Content-Type": "text/html",
103+
},
104+
});
105+
},
106+
requestResource(href: string) {
107+
let data = fse.readFileSync(
108+
path.join(projectDir, "build/client", href)
109+
);
110+
return new Response(data);
111+
},
112+
async requestSingleFetchData(href: string) {
113+
let data = fse.readFileSync(
114+
path.join(projectDir, "build/client", href)
115+
);
116+
let stream = createReadableStreamFromReadable(Readable.from(data));
117+
return {
118+
status: 200,
119+
statusText: "OK",
120+
headers: new Headers(),
121+
data: (await decodeViaTurboStream(stream, global)).value,
122+
};
123+
},
124+
postDocument: () => {
125+
throw new Error("Cannot postDocument in Prerender tests");
126+
},
127+
getBrowserAsset,
128+
useReactRouterServe: init.useReactRouterServe,
129+
};
130+
}
131+
104132
let app: ServerBuild = await import(buildPath);
105133
let handler = createRequestHandler(app, mode || ServerMode.Production);
106134

integration/vite-prerender-test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,72 @@ test.describe("Prerendering", () => {
355355
expect(html).toMatch('<p data-loader-data="true">About Loader Data</p>');
356356
});
357357

358+
test("Pre-renders resource routes with file extensions", async () => {
359+
fixture = await createFixture({
360+
prerender: true,
361+
files: {
362+
...files,
363+
"app/routes/text[.txt].tsx": js`
364+
export function loader() {
365+
return new Response("Hello, world");
366+
}
367+
`,
368+
"app/routes/json[.json].tsx": js`
369+
export function loader() {
370+
return new Response(JSON.stringify({ hello: 'world' }), {
371+
headers: {
372+
'Content-Type': 'application/json',
373+
}
374+
});
375+
}
376+
`,
377+
},
378+
});
379+
appFixture = await createAppFixture(fixture);
380+
381+
let clientDir = path.join(fixture.projectDir, "build", "client");
382+
expect(listAllFiles(clientDir).sort()).toEqual([
383+
"__manifest",
384+
"_root.data",
385+
"about.data",
386+
"about/index.html",
387+
"favicon.ico",
388+
"index.html",
389+
"json.json",
390+
"json.json.data",
391+
"text.txt",
392+
"text.txt.data",
393+
]);
394+
395+
let res = await fixture.requestResource("/json.json");
396+
expect(await res.json()).toEqual({ hello: "world" });
397+
398+
let dataRes = await fixture.requestSingleFetchData("/json.json.data");
399+
expect(dataRes.data).toEqual({
400+
root: {
401+
data: null,
402+
},
403+
"routes/json[.json]": {
404+
data: {
405+
hello: "world",
406+
},
407+
},
408+
});
409+
410+
res = await fixture.requestResource("/text.txt");
411+
expect(await res.text()).toBe("Hello, world");
412+
413+
dataRes = await fixture.requestSingleFetchData("/text.txt.data");
414+
expect(dataRes.data).toEqual({
415+
root: {
416+
data: null,
417+
},
418+
"routes/text[.txt]": {
419+
data: "Hello, world",
420+
},
421+
});
422+
});
423+
358424
test("Hydrates into a navigable app", async ({ page }) => {
359425
fixture = await createFixture({
360426
prerender: true,

packages/react-router-dev/vite/plugin.ts

Lines changed: 63 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1776,7 +1776,7 @@ async function getPrerenderBuildAndHandler(
17761776
let build = await import(url.pathToFileURL(serverBuildPath).toString());
17771777
let { createRequestHandler: createHandler } = await import("react-router");
17781778
return {
1779-
build,
1779+
build: build as ServerBuild,
17801780
handler: createHandler(build, viteConfig.mode),
17811781
};
17821782
}
@@ -1844,7 +1844,8 @@ async function handlePrerender(
18441844
"X-React-Router-Prerender": "yes",
18451845
};
18461846
for (let path of routesToPrerender) {
1847-
let hasLoaders = matchRoutes(routes, path)?.some((m) => m.route.loader);
1847+
let matches = matchRoutes(routes, path);
1848+
let hasLoaders = matches?.some((m) => m.route.loader);
18481849
let data: string | undefined;
18491850
if (hasLoaders) {
18501851
data = await prerenderData(
@@ -1856,16 +1857,41 @@ async function handlePrerender(
18561857
{ headers }
18571858
);
18581859
}
1859-
await prerenderRoute(
1860-
handler,
1861-
path,
1862-
clientBuildDirectory,
1863-
reactRouterConfig,
1864-
viteConfig,
1865-
data
1866-
? { headers: { ...headers, "X-React-Router-Prerender-Data": data } }
1867-
: { headers }
1868-
);
1860+
1861+
// When prerendering a resource route, we don't want to pass along the
1862+
// `.data` file since we want to prerender the raw Response returned from
1863+
// the loader. Presumably this is for routes where a file extension is
1864+
// already included, such as `app/routes/items[.json].tsx` that will
1865+
// render into `/items.json`
1866+
let leafRoute = matches ? matches[matches.length - 1].route : null;
1867+
let manifestRoute = leafRoute ? build.routes[leafRoute.id]?.module : null;
1868+
let isResourceRoute =
1869+
manifestRoute &&
1870+
!manifestRoute.default &&
1871+
!manifestRoute.ErrorBoundary &&
1872+
manifestRoute.loader;
1873+
1874+
if (isResourceRoute) {
1875+
await prerenderResourceRoute(
1876+
handler,
1877+
path,
1878+
clientBuildDirectory,
1879+
reactRouterConfig,
1880+
viteConfig,
1881+
{ headers }
1882+
);
1883+
} else {
1884+
await prerenderRoute(
1885+
handler,
1886+
path,
1887+
clientBuildDirectory,
1888+
reactRouterConfig,
1889+
viteConfig,
1890+
data
1891+
? { headers: { ...headers, "X-React-Router-Prerender-Data": data } }
1892+
: { headers }
1893+
);
1894+
}
18691895
}
18701896

18711897
await prerenderManifest(
@@ -1976,6 +2002,31 @@ async function prerenderRoute(
19762002
viteConfig.logger.info(`Prerender: Generated ${colors.bold(outfile)}`);
19772003
}
19782004

2005+
async function prerenderResourceRoute(
2006+
handler: RequestHandler,
2007+
prerenderPath: string,
2008+
clientBuildDirectory: string,
2009+
reactRouterConfig: Awaited<ReturnType<typeof resolveReactRouterConfig>>,
2010+
viteConfig: Vite.ResolvedConfig,
2011+
requestInit: RequestInit
2012+
) {
2013+
let normalizedPath = `${reactRouterConfig.basename}${prerenderPath}/`
2014+
.replace(/\/\/+/g, "/")
2015+
.replace(/\/$/g, "");
2016+
let request = new Request(`http://localhost${normalizedPath}`, requestInit);
2017+
let response = await handler(request);
2018+
let text = await response.text();
2019+
2020+
validatePrerenderedResponse(response, text, "Prerender", normalizedPath);
2021+
2022+
// Write out the resource route file
2023+
let outdir = path.relative(process.cwd(), clientBuildDirectory);
2024+
let outfile = path.join(outdir, ...normalizedPath.split("/"));
2025+
await fse.ensureDir(path.dirname(outfile));
2026+
await fse.outputFile(outfile, text);
2027+
viteConfig.logger.info(`Prerender: Generated ${colors.bold(outfile)}`);
2028+
}
2029+
19792030
async function prerenderManifest(
19802031
build: ServerBuild,
19812032
clientBuildDirectory: string,

packages/react-router/lib/server-runtime/server.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,6 @@ export const createRequestHandler: CreateRequestHandlerFunction = (
196196
) {
197197
response = await handleResourceRequest(
198198
serverMode,
199-
_build,
200199
staticHandler,
201200
matches.slice(-1)[0].route.id,
202201
request,
@@ -479,7 +478,6 @@ async function handleDocumentRequest(
479478

480479
async function handleResourceRequest(
481480
serverMode: ServerMode,
482-
build: ServerBuild,
483481
staticHandler: StaticHandler,
484482
routeId: string,
485483
request: Request,

0 commit comments

Comments
 (0)