Skip to content

Commit cb3cfec

Browse files
committed
Only import the top-level index route when SSRing SPA mode's index.html
Since we only actually render the top-level index route in SPA mode, we don't need to import any other routes in the server bundle; the bundle is solely used to generate the index.html file, and is subsequently discarded. This should speed up SPA mode builds and make them less error prone; often times apps use libraries that are not SSR-capable (e.g. referencing window in the module scope). By excluding all other routes (and their deps), we make the index.html render more likely to be successful and less likely to require a bunch of externals/shimming shenanigans in vite.config.js.
1 parent fbea1ea commit cb3cfec

File tree

2 files changed

+133
-8
lines changed

2 files changed

+133
-8
lines changed

integration/vite-spa-mode-test.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1067,4 +1067,100 @@ test.describe("SPA Mode", () => {
10671067
});
10681068
});
10691069
});
1070+
1071+
test("only imports top-level route modules when SSRing index.html", async ({ page }) => {
1072+
let fixture = await createFixture({
1073+
spaMode: true,
1074+
files: {
1075+
"react-router.config.ts": reactRouterConfig({
1076+
ssr: false,
1077+
}),
1078+
"vite.config.ts": js`
1079+
import { defineConfig } from "vite";
1080+
import { reactRouter } from "@react-router/dev/vite";
1081+
1082+
export default defineConfig({
1083+
build: { manifest: true },
1084+
plugins: [reactRouter()],
1085+
});
1086+
`,
1087+
"app/routeImportTracker.ts": js`
1088+
// this is kinda silly, but this way we can track imports
1089+
// that happen during SSR and during CSR
1090+
export async function logImport(url: string) {
1091+
try {
1092+
const fs = await import("node:fs");
1093+
const path = await import("node:path");
1094+
fs.appendFileSync(path.join(process.cwd(), "ssr-route-imports.txt"), url + "\n");
1095+
}
1096+
catch (e) {
1097+
(window.csrRouteImports ??= []).push(url);
1098+
}
1099+
}
1100+
`,
1101+
"app/root.tsx": js`
1102+
import { Links, Meta, Outlet, Scripts } from "react-router";
1103+
import { logImport } from "./routeImportTracker";
1104+
logImport("app/root.tsx");
1105+
1106+
export default function Root() {
1107+
return (
1108+
<html lang="en">
1109+
<head>
1110+
<Meta />
1111+
<Links />
1112+
</head>
1113+
<body>
1114+
hello world
1115+
<Outlet />
1116+
<Scripts />
1117+
</body>
1118+
</html>
1119+
);
1120+
}
1121+
`,
1122+
"app/routes/_index.tsx": js`
1123+
import { logImport } from "../routeImportTracker";
1124+
logImport("app/routes/_index.tsx");
1125+
1126+
export default function Component() {
1127+
return "index";
1128+
}
1129+
`,
1130+
"app/routes/about.tsx": js`
1131+
import * as React from "react";
1132+
import { logImport } from "../routeImportTracker";
1133+
logImport("app/routes/about.tsx");
1134+
1135+
export default function Component() {
1136+
const [mounted, setMounted] = React.useState(false);
1137+
React.useEffect(() => setMounted(true), []);
1138+
1139+
return (
1140+
<>
1141+
{!mounted ? <span>Unmounted</span> : <span data-mounted>Mounted</span>}
1142+
</>
1143+
);
1144+
}
1145+
`,
1146+
},
1147+
});
1148+
1149+
let importedRoutes = (await fs.promises.readFile(path.join(fixture.projectDir, "ssr-route-imports.txt"), "utf-8")).trim().split("\n");
1150+
expect(importedRoutes).toStrictEqual([
1151+
"app/root.tsx",
1152+
"app/routes/_index.tsx"
1153+
// we should NOT have imported app/routes/about.tsx
1154+
]);
1155+
1156+
appFixture = await createAppFixture(fixture);
1157+
let app = new PlaywrightFixture(appFixture, page);
1158+
await app.goto("/about");
1159+
await page.waitForSelector("[data-mounted]");
1160+
// @ts-expect-error
1161+
expect(await page.evaluate(() => window.csrRouteImports)).toStrictEqual([
1162+
"app/root.tsx",
1163+
"app/routes/about.tsx"
1164+
]);
1165+
});
10701166
});

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

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -597,19 +597,37 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
597597
routes
598598
);
599599

600+
let isSpaMode =
601+
!ctx.reactRouterConfig.ssr && ctx.reactRouterConfig.prerender == null;
602+
603+
let routeIdsToImport = new Set(Object.keys(routes));
604+
if (isSpaMode) {
605+
// In SPA mode, we only pre-render the top-level index route; for all
606+
// other routes we stub out their imports, as they (and their deps) may
607+
// not be compatible with server-side rendering. This also helps keep
608+
// the build fast
609+
routeIdsToImport = getRootRouteIds(routes);
610+
}
611+
600612
return `
601613
import * as entryServer from ${JSON.stringify(
602614
resolveFileUrl(ctx, ctx.entryServerFilePath)
603615
)};
604616
${Object.keys(routes)
605617
.map((key, index) => {
606618
let route = routes[key]!;
607-
return `import * as route${index} from ${JSON.stringify(
608-
resolveFileUrl(
609-
ctx,
610-
resolveRelativeRouteFilePath(route, ctx.reactRouterConfig)
611-
)
612-
)};`;
619+
if (routeIdsToImport.has(key)) {
620+
return `import * as route${index} from ${JSON.stringify(
621+
resolveFileUrl(
622+
ctx,
623+
resolveRelativeRouteFilePath(route, ctx.reactRouterConfig)
624+
)
625+
)};`;
626+
} else {
627+
// we're not importing the route since we won't be rendering
628+
// it via SSR; just stub it out
629+
return `const route${index} = { default: () => null };`;
630+
}
613631
})
614632
.join("\n")}
615633
export { default as assets } from ${JSON.stringify(
@@ -626,7 +644,7 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
626644
export const basename = ${JSON.stringify(ctx.reactRouterConfig.basename)};
627645
export const future = ${JSON.stringify(ctx.reactRouterConfig.future)};
628646
export const ssr = ${ctx.reactRouterConfig.ssr};
629-
export const isSpaMode = ${isSpaModeEnabled(ctx.reactRouterConfig)};
647+
export const isSpaMode = ${isSpaMode};
630648
export const prerender = ${JSON.stringify(prerenderPaths)};
631649
export const publicPath = ${JSON.stringify(ctx.publicPath)};
632650
export const entry = { module: entryServer };
@@ -1394,7 +1412,7 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
13941412

13951413
if (isPrerenderingEnabled(ctx.reactRouterConfig)) {
13961414
// If we have prerender routes, that takes precedence over SPA mode
1397-
// which is ssr:false and only the rot route being rendered
1415+
// which is ssr:false and only the root route being rendered
13981416
await handlePrerender(
13991417
viteConfig,
14001418
ctx.reactRouterConfig,
@@ -2587,6 +2605,17 @@ function groupRoutesByParentId(manifest: GenericRouteManifest) {
25872605
return routes;
25882606
}
25892607

2608+
/**
2609+
* Return the route ids associated with the top-level index route
2610+
*
2611+
* i.e. "root", the top-level index route's id, and (if applicable) the ids of
2612+
* any top-level layout/path-less routes in between
2613+
*/
2614+
function getRootRouteIds(manifest: GenericRouteManifest): Set<string> {
2615+
const matches = matchRoutes(createPrerenderRoutes(manifest), "/");
2616+
return new Set(matches?.filter(Boolean).map((m) => m.route.id) || []);
2617+
}
2618+
25902619
// Create a skeleton route tree of paths
25912620
function createPrerenderRoutes(
25922621
manifest: GenericRouteManifest,

0 commit comments

Comments
 (0)