Skip to content

Only import the root route when SSRing SPA mode's index.html #13023

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Feb 20, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/modern-forks-ring.md
Original file line number Diff line number Diff line change
@@ -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
111 changes: 111 additions & 0 deletions integration/vite-spa-mode-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<html lang="en">
<head>
<Meta />
<Links />
</head>
<body>
hello world
<Outlet />
<Scripts />
</body>
</html>
);
}
`,
"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 ? <span>Unmounted</span> : <span data-mounted>Mounted</span>}
</>
);
}
`,
},
});

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",
]);
});
});
26 changes: 18 additions & 8 deletions packages/react-router-dev/vite/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -621,19 +621,29 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
routes
);

let isSpaMode = isSpaModeEnabled(ctx.reactRouterConfig);

return `
import * as entryServer from ${JSON.stringify(
resolveFileUrl(ctx, ctx.entryServerFilePath)
)};
${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(
Expand All @@ -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 };
Expand Down Expand Up @@ -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,
Expand Down
Loading