Skip to content

Commit b33bc9c

Browse files
jensengbrophdawg11
andauthored
Only import the root route when SSRing SPA mode's index.html (#13023)
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. Closes #12360 --------- Co-authored-by: Matt Brophy <[email protected]>
1 parent b0756d2 commit b33bc9c

File tree

3 files changed

+134
-8
lines changed

3 files changed

+134
-8
lines changed

.changeset/modern-forks-ring.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+
Stub all routes except root in "SPA Mode" server builds to avoid issues when route modules or their dependencies import non-SSR-friendly modules

integration/vite-spa-mode-test.ts

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

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

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -621,19 +621,29 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
621621
routes
622622
);
623623

624+
let isSpaMode = isSpaModeEnabled(ctx.reactRouterConfig);
625+
624626
return `
625627
import * as entryServer from ${JSON.stringify(
626628
resolveFileUrl(ctx, ctx.entryServerFilePath)
627629
)};
628630
${Object.keys(routes)
629631
.map((key, index) => {
630632
let route = routes[key]!;
631-
return `import * as route${index} from ${JSON.stringify(
632-
resolveFileUrl(
633-
ctx,
634-
resolveRelativeRouteFilePath(route, ctx.reactRouterConfig)
635-
)
636-
)};`;
633+
if (isSpaMode && key !== "root") {
634+
// In SPA mode, we only pre-render the root route and its `HydrateFallback`.
635+
// Therefore, we can stub all other routes with an empty module as they
636+
// (and their deps) may not be compatible with server-side rendering.
637+
// This also helps keep the build fast.
638+
return `const route${index} = { default: () => null };`;
639+
} else {
640+
return `import * as route${index} from ${JSON.stringify(
641+
resolveFileUrl(
642+
ctx,
643+
resolveRelativeRouteFilePath(route, ctx.reactRouterConfig)
644+
)
645+
)};`;
646+
}
637647
})
638648
.join("\n")}
639649
export { default as assets } from ${JSON.stringify(
@@ -650,7 +660,7 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
650660
export const basename = ${JSON.stringify(ctx.reactRouterConfig.basename)};
651661
export const future = ${JSON.stringify(ctx.reactRouterConfig.future)};
652662
export const ssr = ${ctx.reactRouterConfig.ssr};
653-
export const isSpaMode = ${isSpaModeEnabled(ctx.reactRouterConfig)};
663+
export const isSpaMode = ${isSpaMode};
654664
export const prerender = ${JSON.stringify(prerenderPaths)};
655665
export const publicPath = ${JSON.stringify(ctx.publicPath)};
656666
export const entry = { module: entryServer };
@@ -1503,7 +1513,7 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
15031513

15041514
if (isPrerenderingEnabled(ctx.reactRouterConfig)) {
15051515
// If we have prerender routes, that takes precedence over SPA mode
1506-
// which is ssr:false and only the rot route being rendered
1516+
// which is ssr:false and only the root route being rendered
15071517
await handlePrerender(
15081518
viteConfig,
15091519
ctx.reactRouterConfig,

0 commit comments

Comments
 (0)