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,