Skip to content

Commit e62d585

Browse files
authored
Fix react-router-serve for prerendered files and avoid dup loader invocation (#12071)
1 parent 2e59ea1 commit e62d585

File tree

6 files changed

+115
-36
lines changed

6 files changed

+115
-36
lines changed

.changeset/rare-plums-chew.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@react-router/serve": patch
3+
"@react-router/dev": patch
4+
"react-router": patch
5+
---
6+
7+
- 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
8+
- Proxy prerendered loader data into prerender pass for HTML files to avoid double-invocations of the loader at build time

docs/misc/pre-rendering.md

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -111,19 +111,7 @@ app.use(
111111
);
112112

113113
// Serve static HTML and .data requests without Cache-Control
114-
app.use(
115-
"/",
116-
express.static("build/client", {
117-
// Don't redirect directory index.html requests to include a trailing slash
118-
redirect: false,
119-
setHeaders: function (res, path) {
120-
// Add the proper Content-Type for turbo-stream data responses
121-
if (path.endsWith(".data")) {
122-
res.set("Content-Type", "text/x-turbo");
123-
}
124-
},
125-
})
126-
);
114+
app.use("/", express.static("build/client"));
127115

128116
// Serve remaining unhandled requests via your React Router handler
129117
app.all(

integration/vite-prerender-test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,63 @@ test.describe("Prerendering", () => {
414414
expect(await app.getHtml()).toContain("<span>NOT-PRERENDERED-false</span>");
415415
});
416416

417+
test("Does not encounter header limits on large prerendered data", async ({
418+
page,
419+
}) => {
420+
fixture = await createFixture({
421+
// Even thogh we are prerendering, we want a running server so we can
422+
// hit the pre-rendered HTML file and a non-prerendered route
423+
prerender: false,
424+
files: {
425+
...files,
426+
"vite.config.ts": js`
427+
import { defineConfig } from "vite";
428+
import { reactRouter } from "@react-router/dev/vite";
429+
430+
export default defineConfig({
431+
build: { manifest: true },
432+
plugins: [
433+
reactRouter({
434+
prerender: ["/", "/about"],
435+
})
436+
],
437+
});
438+
`,
439+
"app/routes/about.tsx": js`
440+
import { useLoaderData } from 'react-router';
441+
export function loader({ request }) {
442+
return {
443+
prerendered: request.headers.has('X-React-Router-Prerender') ? 'yes' : 'no',
444+
// 24999 characters
445+
data: new Array(5000).fill('test').join('-'),
446+
};
447+
}
448+
449+
export default function Comp() {
450+
let data = useLoaderData();
451+
return (
452+
<>
453+
<h1 data-title>Large loader</h1>
454+
<p data-prerendered>{data.prerendered}</p>
455+
<p data-length>{data.data.length}</p>
456+
</>
457+
);
458+
}
459+
`,
460+
},
461+
});
462+
appFixture = await createAppFixture(fixture);
463+
464+
let app = new PlaywrightFixture(appFixture, page);
465+
await app.goto("/about");
466+
await page.waitForSelector("[data-mounted]");
467+
expect(await app.getHtml("[data-title]")).toContain("Large loader");
468+
expect(await app.getHtml("[data-prerendered]")).toContain("yes");
469+
expect(await app.getHtml("[data-length]")).toBe(
470+
'<p data-length="true">24999</p>'
471+
);
472+
});
473+
417474
test("Renders down to the proper HydrateFallback", async ({ page }) => {
418475
fixture = await createFixture({
419476
prerender: true,

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

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1831,23 +1831,22 @@ async function handlePrerender(
18311831
} else {
18321832
routesToPrerender = reactRouterConfig.prerender || ["/"];
18331833
}
1834-
let requestInit = {
1835-
headers: {
1836-
// Header that can be used in the loader to know if you're running at
1837-
// build time or runtime
1838-
"X-React-Router-Prerender": "yes",
1839-
},
1834+
let headers = {
1835+
// Header that can be used in the loader to know if you're running at
1836+
// build time or runtime
1837+
"X-React-Router-Prerender": "yes",
18401838
};
18411839
for (let path of routesToPrerender) {
18421840
let hasLoaders = matchRoutes(routes, path)?.some((m) => m.route.loader);
1841+
let data: string | undefined;
18431842
if (hasLoaders) {
1844-
await prerenderData(
1843+
data = await prerenderData(
18451844
handler,
18461845
path,
18471846
clientBuildDirectory,
18481847
reactRouterConfig,
18491848
viteConfig,
1850-
requestInit
1849+
{ headers }
18511850
);
18521851
}
18531852
await prerenderRoute(
@@ -1856,7 +1855,9 @@ async function handlePrerender(
18561855
clientBuildDirectory,
18571856
reactRouterConfig,
18581857
viteConfig,
1859-
requestInit
1858+
data
1859+
? { headers: { ...headers, "X-React-Router-Prerender-Data": data } }
1860+
: { headers }
18601861
);
18611862
}
18621863

@@ -1934,6 +1935,7 @@ async function prerenderData(
19341935
await fse.ensureDir(path.dirname(outfile));
19351936
await fse.outputFile(outfile, data);
19361937
viteConfig.logger.info(`Prerender: Generated ${colors.bold(outfile)}`);
1938+
return data;
19371939
}
19381940

19391941
async function prerenderRoute(

packages/react-router-serve/cli.ts

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -83,18 +83,7 @@ async function run() {
8383
maxAge: "1y",
8484
})
8585
);
86-
app.use(
87-
build.publicPath,
88-
express.static(build.assetsBuildDirectory, {
89-
// Don't redirect directory index.html request to include a trailing slash
90-
redirect: false,
91-
setHeaders: function (res, path) {
92-
if (path.endsWith(".data")) {
93-
res.set("Content-Type", "text/x-turbo");
94-
}
95-
},
96-
})
97-
);
86+
app.use(build.publicPath, express.static(build.assetsBuildDirectory));
9887
app.use(express.static("public", { maxAge: "1h" }));
9988
app.use(morgan("tiny"));
10089

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

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ import type {
1010
LoaderFunctionArgs,
1111
ServerRouteModule,
1212
} from "./routeModules";
13+
import type {
14+
SingleFetchResult,
15+
SingleFetchResults,
16+
} from "../dom/ssr/single-fetch";
17+
import { decodeViaTurboStream } from "../dom/ssr/single-fetch";
18+
import invariant from "./invariant";
1319

1420
export interface RouteManifest<Route> {
1521
[routeId: string]: Route;
@@ -95,8 +101,37 @@ export function createStaticHandlerDataRoutes(
95101
// Need to use RR's version in the param typed here to permit the optional
96102
// context even though we know it'll always be provided in remix
97103
loader: route.module.loader
98-
? (args: RRLoaderFunctionArgs) =>
99-
callRouteHandler(route.module.loader!, args as LoaderFunctionArgs)
104+
? async (args: RRLoaderFunctionArgs) => {
105+
// If we're prerendering, use the data passed in from prerendering
106+
// the .data route so we dom't call loaders twice
107+
if (args.request.headers.has("X-React-Router-Prerender-Data")) {
108+
let encoded = args.request.headers.get(
109+
"X-React-Router-Prerender-Data"
110+
);
111+
invariant(encoded, "Missing prerendered data for route");
112+
let uint8array = new TextEncoder().encode(encoded);
113+
let stream = new ReadableStream({
114+
start(controller) {
115+
controller.enqueue(uint8array);
116+
controller.close();
117+
},
118+
});
119+
let decoded = await decodeViaTurboStream(stream, global);
120+
let data = decoded.value as SingleFetchResults;
121+
invariant(
122+
data && route.id in data,
123+
"Unable to decode prerendered data"
124+
);
125+
let result = data[route.id] as SingleFetchResult;
126+
invariant("data" in result, "Unable to process prerendered data");
127+
return result.data;
128+
}
129+
let val = await callRouteHandler(
130+
route.module.loader!,
131+
args as LoaderFunctionArgs
132+
);
133+
return val;
134+
}
100135
: undefined,
101136
action: route.module.action
102137
? (args: RRActionFunctionArgs) =>

0 commit comments

Comments
 (0)