diff --git a/.changeset/modern-forks-ring.md b/.changeset/modern-forks-ring.md
new file mode 100644
index 0000000000..1c85944d34
--- /dev/null
+++ b/.changeset/modern-forks-ring.md
@@ -0,0 +1,5 @@
+---
+"@react-router/dev": patch
+---
+
+Stub all routes except root in "SPA Mode" server builds to avoid issues when route modules or their dependencies import non-SSR-friendly modules
diff --git a/integration/vite-spa-mode-test.ts b/integration/vite-spa-mode-test.ts
index bffa57caf1..2253ac9bf5 100644
--- a/integration/vite-spa-mode-test.ts
+++ b/integration/vite-spa-mode-test.ts
@@ -1067,4 +1067,115 @@ test.describe("SPA Mode", () => {
});
});
});
+
+ test("only imports the root route in the server build when SSRing index.html", async ({
+ page,
+ }) => {
+ let fixture = await createFixture({
+ spaMode: true,
+ files: {
+ "react-router.config.ts": reactRouterConfig({
+ ssr: false,
+ }),
+ "vite.config.ts": js`
+ import { defineConfig } from "vite";
+ import { reactRouter } from "@react-router/dev/vite";
+
+ export default defineConfig({
+ build: { manifest: true },
+ plugins: [reactRouter()],
+ });
+ `,
+ "app/routeImportTracker.ts": js`
+ // this is kinda silly, but this way we can track imports
+ // that happen during SSR and during CSR
+ export async function logImport(url: string) {
+ try {
+ const fs = await import("node:fs");
+ const path = await import("node:path");
+ fs.appendFileSync(path.join(process.cwd(), "ssr-route-imports.txt"), url + "\n");
+ }
+ catch (e) {
+ (window.csrRouteImports ??= []).push(url);
+ }
+ }
+ `,
+ "app/root.tsx": js`
+ import { Links, Meta, Outlet, Scripts } from "react-router";
+ import { logImport } from "./routeImportTracker";
+ logImport("app/root.tsx");
+
+ export default function Root() {
+ return (
+
+
+
+
+
+
+ hello world
+
+
+
+
+ );
+ }
+ `,
+ "app/routes/_index.tsx": js`
+ import { logImport } from "../routeImportTracker";
+ logImport("app/routes/_index.tsx");
+
+ // This should not cause an error on SSR because the module is not loaded
+ console.log(window);
+
+ export default function Component() {
+ return "index";
+ }
+ `,
+ "app/routes/about.tsx": js`
+ import * as React from "react";
+ import { logImport } from "../routeImportTracker";
+ logImport("app/routes/about.tsx");
+
+ // This should not cause an error on SSR because the module is not loaded
+ console.log(window);
+
+ export default function Component() {
+ const [mounted, setMounted] = React.useState(false);
+ React.useEffect(() => setMounted(true), []);
+
+ return (
+ <>
+ {!mounted ? Unmounted : Mounted}
+ >
+ );
+ }
+ `,
+ },
+ });
+
+ let importedRoutes = (
+ await fs.promises.readFile(
+ path.join(fixture.projectDir, "ssr-route-imports.txt"),
+ "utf-8"
+ )
+ )
+ .trim()
+ .split("\n");
+ expect(importedRoutes).toStrictEqual([
+ "app/root.tsx",
+ // we should not have imported app/routes/_index.tsx
+ // we should not have imported app/routes/about.tsx
+ ]);
+
+ appFixture = await createAppFixture(fixture);
+ let app = new PlaywrightFixture(appFixture, page);
+ await app.goto("/about");
+ await page.waitForSelector("[data-mounted]");
+ // @ts-expect-error
+ expect(await page.evaluate(() => window.csrRouteImports)).toStrictEqual([
+ "app/root.tsx",
+ "app/routes/about.tsx",
+ ]);
+ });
});
diff --git a/packages/react-router-dev/vite/plugin.ts b/packages/react-router-dev/vite/plugin.ts
index c54e10fd38..8d04232580 100644
--- a/packages/react-router-dev/vite/plugin.ts
+++ b/packages/react-router-dev/vite/plugin.ts
@@ -621,6 +621,8 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
routes
);
+ let isSpaMode = isSpaModeEnabled(ctx.reactRouterConfig);
+
return `
import * as entryServer from ${JSON.stringify(
resolveFileUrl(ctx, ctx.entryServerFilePath)
@@ -628,12 +630,20 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
${Object.keys(routes)
.map((key, index) => {
let route = routes[key]!;
- return `import * as route${index} from ${JSON.stringify(
- resolveFileUrl(
- ctx,
- resolveRelativeRouteFilePath(route, ctx.reactRouterConfig)
- )
- )};`;
+ if (isSpaMode && key !== "root") {
+ // In SPA mode, we only pre-render the root route and its `HydrateFallback`.
+ // Therefore, we can stub all other routes with an empty module as they
+ // (and their deps) may not be compatible with server-side rendering.
+ // This also helps keep the build fast.
+ return `const route${index} = { default: () => null };`;
+ } else {
+ return `import * as route${index} from ${JSON.stringify(
+ resolveFileUrl(
+ ctx,
+ resolveRelativeRouteFilePath(route, ctx.reactRouterConfig)
+ )
+ )};`;
+ }
})
.join("\n")}
export { default as assets } from ${JSON.stringify(
@@ -650,7 +660,7 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
export const basename = ${JSON.stringify(ctx.reactRouterConfig.basename)};
export const future = ${JSON.stringify(ctx.reactRouterConfig.future)};
export const ssr = ${ctx.reactRouterConfig.ssr};
- export const isSpaMode = ${isSpaModeEnabled(ctx.reactRouterConfig)};
+ export const isSpaMode = ${isSpaMode};
export const prerender = ${JSON.stringify(prerenderPaths)};
export const publicPath = ${JSON.stringify(ctx.publicPath)};
export const entry = { module: entryServer };
@@ -1503,7 +1513,7 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
if (isPrerenderingEnabled(ctx.reactRouterConfig)) {
// If we have prerender routes, that takes precedence over SPA mode
- // which is ssr:false and only the rot route being rendered
+ // which is ssr:false and only the root route being rendered
await handlePrerender(
viteConfig,
ctx.reactRouterConfig,