Skip to content

Commit 488431a

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 226dbbd commit 488431a

File tree

2 files changed

+179
-44
lines changed

2 files changed

+179
-44
lines changed

integration/vite-spa-mode-test.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -913,4 +913,100 @@ test.describe("SPA Mode", () => {
913913
expect(viteManifestFiles).toEqual(["manifest.json"]);
914914
});
915915
});
916+
917+
test("only imports top-level route modules when SSRing index.html", async ({ page }) => {
918+
let fixture = await createFixture({
919+
spaMode: true,
920+
files: {
921+
"react-router.config.ts": reactRouterConfig({
922+
ssr: false,
923+
}),
924+
"vite.config.ts": js`
925+
import { defineConfig } from "vite";
926+
import { reactRouter } from "@react-router/dev/vite";
927+
928+
export default defineConfig({
929+
build: { manifest: true },
930+
plugins: [reactRouter()],
931+
});
932+
`,
933+
"app/routeImportTracker.ts": js`
934+
// this is kinda silly, but this way we can track imports
935+
// that happen during SSR and during CSR
936+
export async function logImport(url: string) {
937+
try {
938+
const fs = await import("node:fs");
939+
const path = await import("node:path");
940+
fs.appendFileSync(path.join(process.cwd(), "ssr-route-imports.txt"), url + "\n");
941+
}
942+
catch (e) {
943+
(window.csrRouteImports ??= []).push(url);
944+
}
945+
}
946+
`,
947+
"app/root.tsx": js`
948+
import { Links, Meta, Outlet, Scripts } from "react-router";
949+
import { logImport } from "./routeImportTracker";
950+
logImport("app/root.tsx");
951+
952+
export default function Root() {
953+
return (
954+
<html lang="en">
955+
<head>
956+
<Meta />
957+
<Links />
958+
</head>
959+
<body>
960+
hello world
961+
<Outlet />
962+
<Scripts />
963+
</body>
964+
</html>
965+
);
966+
}
967+
`,
968+
"app/routes/_index.tsx": js`
969+
import { logImport } from "../routeImportTracker";
970+
logImport("app/routes/_index.tsx");
971+
972+
export default function Component() {
973+
return "index";
974+
}
975+
`,
976+
"app/routes/about.tsx": js`
977+
import * as React from "react";
978+
import { logImport } from "../routeImportTracker";
979+
logImport("app/routes/about.tsx");
980+
981+
export default function Component() {
982+
const [mounted, setMounted] = React.useState(false);
983+
React.useEffect(() => setMounted(true), []);
984+
985+
return (
986+
<>
987+
{!mounted ? <span>Unmounted</span> : <span data-mounted>Mounted</span>}
988+
</>
989+
);
990+
}
991+
`,
992+
},
993+
});
994+
995+
let importedRoutes = (await fs.promises.readFile(path.join(fixture.projectDir, "ssr-route-imports.txt"), "utf-8")).trim().split("\n");
996+
expect(importedRoutes).toStrictEqual([
997+
"app/root.tsx",
998+
"app/routes/_index.tsx"
999+
// we should NOT have imported app/routes/about.tsx
1000+
]);
1001+
1002+
appFixture = await createAppFixture(fixture);
1003+
let app = new PlaywrightFixture(appFixture, page);
1004+
await app.goto("/about");
1005+
await page.waitForSelector("[data-mounted]");
1006+
// @ts-expect-error
1007+
expect(await page.evaluate(() => window.csrRouteImports)).toStrictEqual([
1008+
"app/root.tsx",
1009+
"app/routes/about.tsx"
1010+
]);
1011+
});
9161012
});

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

Lines changed: 83 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -456,19 +456,37 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
456456
: // Otherwise, all routes are imported as usual
457457
ctx.reactRouterConfig.routes;
458458

459+
let isSpaMode =
460+
!ctx.reactRouterConfig.ssr && ctx.reactRouterConfig.prerender == null;
461+
462+
let routeIdsToImport = new Set(Object.keys(routes));
463+
if (isSpaMode) {
464+
// In SPA mode, we only pre-render the top-level index route; for all
465+
// other routes we stub out their imports, as they (and their deps) may
466+
// not be compatible with server-side rendering. This also helps keep
467+
// the build fast
468+
routeIdsToImport = getRootRouteIds(routes);
469+
}
470+
459471
return `
460472
import * as entryServer from ${JSON.stringify(
461473
resolveFileUrl(ctx, ctx.entryServerFilePath)
462474
)};
463475
${Object.keys(routes)
464476
.map((key, index) => {
465477
let route = routes[key]!;
466-
return `import * as route${index} from ${JSON.stringify(
467-
resolveFileUrl(
468-
ctx,
469-
resolveRelativeRouteFilePath(route, ctx.reactRouterConfig)
470-
)
471-
)};`;
478+
if (routeIdsToImport.has(key)) {
479+
return `import * as route${index} from ${JSON.stringify(
480+
resolveFileUrl(
481+
ctx,
482+
resolveRelativeRouteFilePath(route, ctx.reactRouterConfig)
483+
)
484+
)};`;
485+
} else {
486+
// we're not importing the route since we won't be rendering
487+
// it via SSR; just stub it out
488+
return `const route${index} = { default: () => null };`;
489+
}
472490
})
473491
.join("\n")}
474492
export { default as assets } from ${JSON.stringify(
@@ -482,9 +500,7 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
482500
)};
483501
export const basename = ${JSON.stringify(ctx.reactRouterConfig.basename)};
484502
export const future = ${JSON.stringify(ctx.reactRouterConfig.future)};
485-
export const isSpaMode = ${
486-
!ctx.reactRouterConfig.ssr && ctx.reactRouterConfig.prerender == null
487-
};
503+
export const isSpaMode = ${isSpaMode};
488504
export const publicPath = ${JSON.stringify(ctx.publicPath)};
489505
export const entry = { module: entryServer };
490506
export const routes = {
@@ -1198,7 +1214,7 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
11981214
ctx.reactRouterConfig.prerender !== false
11991215
) {
12001216
// If we have prerender routes, that takes precedence over SPA mode
1201-
// which is ssr:false and only the rot route being rendered
1217+
// which is ssr:false and only the root route being rendered
12021218
await handlePrerender(
12031219
viteConfig,
12041220
ctx.reactRouterConfig,
@@ -2087,13 +2103,20 @@ function validatePrerenderedHtml(html: string, prefix: string) {
20872103
}
20882104
}
20892105

2090-
type ServerRoute = ServerBuild["routes"][string] & {
2091-
children: ServerRoute[];
2106+
// Just the minimum necessary to unflatten manifests generically (e.g. RouteManifest vs. ServerBuild["routes"])
2107+
type ServerRoute = {
2108+
id: string;
2109+
parentId?: string;
2110+
path?: string;
2111+
caseSensitive?: boolean;
2112+
index?: boolean;
20922113
};
20932114

2094-
// Note: Duplicated from react-router/lib/server-runtime
2095-
function groupRoutesByParentId(manifest: ServerBuild["routes"]) {
2096-
let routes: Record<string, Omit<ServerRoute, "children">[]> = {};
2115+
// Note: Adapted from react-router/lib/server-runtime
2116+
function groupRoutesByParentId<T extends ServerRoute>(
2117+
manifest: Record<string, T | undefined>
2118+
) {
2119+
let routes: Record<string, T[]> = {};
20972120

20982121
Object.values(manifest).forEach((route) => {
20992122
if (route) {
@@ -2108,36 +2131,52 @@ function groupRoutesByParentId(manifest: ServerBuild["routes"]) {
21082131
return routes;
21092132
}
21102133

2111-
// Note: Duplicated from react-router/lib/server-runtime
2112-
function createPrerenderRoutes(
2113-
manifest: ServerBuild["routes"],
2134+
// Note: Adapted from react-router/lib/server-runtime
2135+
function createRoutes<T extends ServerRoute, T2 extends Partial<T> = T>(
2136+
manifest: Record<string, T | undefined>,
2137+
mapRoute: (route: T) => T2 = (route) => route as unknown as T2,
21142138
parentId: string = "",
2115-
routesByParentId: Record<
2116-
string,
2117-
Omit<ServerRoute, "children">[]
2118-
> = groupRoutesByParentId(manifest)
2119-
): DataRouteObject[] {
2120-
return (routesByParentId[parentId] || []).map((route) => {
2121-
let commonRoute = {
2122-
// Always include root due to default boundaries
2123-
hasErrorBoundary:
2124-
route.id === "root" || route.module.ErrorBoundary != null,
2125-
id: route.id,
2126-
path: route.path,
2127-
loader: route.module.loader ? () => null : undefined,
2128-
action: undefined,
2129-
handle: route.module.handle,
2139+
routesByParentId = groupRoutesByParentId(manifest),
2140+
) {
2141+
return (routesByParentId[parentId] || []).map((route): T2 => {
2142+
return {
2143+
...(route.index
2144+
? { index: true }
2145+
: {
2146+
caseSensitive: route.caseSensitive,
2147+
children: createRoutes(
2148+
manifest,
2149+
mapRoute,
2150+
route.id,
2151+
routesByParentId,
2152+
),
2153+
}),
2154+
...mapRoute(route),
21302155
};
2131-
2132-
return route.index
2133-
? {
2134-
index: true,
2135-
...commonRoute,
2136-
}
2137-
: {
2138-
caseSensitive: route.caseSensitive,
2139-
children: createPrerenderRoutes(manifest, route.id, routesByParentId),
2140-
...commonRoute,
2141-
};
21422156
});
21432157
}
2158+
2159+
function createPrerenderRoutes(
2160+
manifest: ServerBuild["routes"]
2161+
): DataRouteObject[] {
2162+
return createRoutes(manifest, (route) => ({
2163+
// Always include root due to default boundaries
2164+
hasErrorBoundary: route.id === "root" || route.module.ErrorBoundary != null,
2165+
id: route.id,
2166+
path: route.path,
2167+
loader: route.module.loader ? () => null : undefined,
2168+
action: undefined,
2169+
handle: route.module.handle,
2170+
}));
2171+
}
2172+
2173+
/**
2174+
* Return the route ids associated with the top-level index route
2175+
*
2176+
* i.e. "root", the top-level index route's id, and (if applicable) the ids of
2177+
* any top-level layout/path-less routes in between
2178+
*/
2179+
function getRootRouteIds(manifest: RouteManifest): Set<string> {
2180+
const matches = matchRoutes(createRoutes(manifest), "/");
2181+
return new Set(matches?.filter(Boolean).map((m) => m.route.id) || []);
2182+
}

0 commit comments

Comments
 (0)