diff --git a/.changeset/react-router-v8-noexternal.md b/.changeset/react-router-v8-noexternal.md new file mode 100644 index 00000000..1480cac9 --- /dev/null +++ b/.changeset/react-router-v8-noexternal.md @@ -0,0 +1,5 @@ +--- +"clerk": patch +--- + +Fix `clerk init` for React Router v8 projects by adding `@clerk/react-router` to `ssr.noExternal` in the Vite config, preventing dev-mode SSR from failing with "useNavigate() may be used only in the context of a ". diff --git a/packages/cli-core/src/commands/init/frameworks/react-router.test.ts b/packages/cli-core/src/commands/init/frameworks/react-router.test.ts index 65a656d9..f76e831e 100644 --- a/packages/cli-core/src/commands/init/frameworks/react-router.test.ts +++ b/packages/cli-core/src/commands/init/frameworks/react-router.test.ts @@ -434,3 +434,118 @@ export default [index("routes/home.tsx")] satisfies RouteConfig; 'route("($locale)/sign-up/*", "routes/($locale).sign-up.tsx")', ); }); + +test("adds ssr.noExternal to vite.config.ts for React Router 8", async () => { + await mkdir(join(tempDir, "app"), { recursive: true }); + await Bun.write( + join(tempDir, "vite.config.ts"), + `import { reactRouter } from "@react-router/dev/vite"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [reactRouter()], +}); +`, + ); + + const plan = await reactRouter.scaffold(makeCtx({ deps: { "react-router": "8.0.0" } })); + const viteAction = plan.actions.find((action) => action.path === "vite.config.ts"); + + expect(viteAction?.type).toBe("modify"); + if (viteAction?.type !== "modify") throw new Error("Expected vite config modify action"); + + expect(viteAction.content).toContain("noExternal"); + expect(viteAction.content).toContain("@clerk/react-router"); + expect(viteAction.content.match(/ssr\s*:/g)?.length).toBe(1); +}); + +test("appends to an existing ssr.noExternal array for React Router 8", async () => { + await mkdir(join(tempDir, "app"), { recursive: true }); + await Bun.write( + join(tempDir, "vite.config.ts"), + `import { defineConfig } from "vite"; + +export default defineConfig({ + ssr: { + noExternal: ["some-other-pkg"], + }, +}); +`, + ); + + const plan = await reactRouter.scaffold(makeCtx({ deps: { "react-router": "8.0.0" } })); + const viteAction = plan.actions.find((action) => action.path === "vite.config.ts"); + + expect(viteAction?.type).toBe("modify"); + if (viteAction?.type !== "modify") throw new Error("Expected vite config modify action"); + + expect(viteAction.content).toContain("some-other-pkg"); + expect(viteAction.content).toContain("@clerk/react-router"); + expect(viteAction.content.match(/ssr\s*:/g)?.length).toBe(1); +}); + +test("skips vite config when @clerk/react-router is already in ssr.noExternal", async () => { + await mkdir(join(tempDir, "app"), { recursive: true }); + await Bun.write( + join(tempDir, "vite.config.ts"), + `import { defineConfig } from "vite"; + +export default defineConfig({ + ssr: { + noExternal: ["@clerk/react-router"], + }, +}); +`, + ); + + const plan = await reactRouter.scaffold(makeCtx({ deps: { "react-router": "8.0.0" } })); + const viteAction = plan.actions.find((action) => action.path === "vite.config.ts"); + + expect(viteAction?.type).toBe("skip"); +}); + +test("does not touch vite config for React Router 7", async () => { + await mkdir(join(tempDir, "app"), { recursive: true }); + await Bun.write( + join(tempDir, "vite.config.ts"), + `import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [], +}); +`, + ); + + const plan = await reactRouter.scaffold(makeCtx({ deps: { "react-router": "7.9.0" } })); + const viteAction = plan.actions.find((action) => action.path === "vite.config.ts"); + + expect(viteAction).toBeUndefined(); + expect(plan.postInstructions.join(" ")).not.toContain("noExternal"); +}); + +test("emits manual noExternal instruction when vite config is missing on React Router 8", async () => { + await mkdir(join(tempDir, "app"), { recursive: true }); + + const plan = await reactRouter.scaffold(makeCtx({ deps: { "react-router": "8.0.0" } })); + + expect(plan.postInstructions.join(" ")).toContain("noExternal"); +}); + +test("emits manual noExternal instruction when vite config is a function form", async () => { + await mkdir(join(tempDir, "app"), { recursive: true }); + await Bun.write( + join(tempDir, "vite.config.ts"), + `import { defineConfig } from "vite"; + +export default defineConfig(({ command }) => ({ + plugins: [], +})); +`, + ); + + const plan = await reactRouter.scaffold(makeCtx({ deps: { "react-router": "8.0.0" } })); + const viteAction = plan.actions.find((action) => action.path === "vite.config.ts"); + + expect(viteAction).toBeUndefined(); + expect(plan.postInstructions.join(" ")).toContain("noExternal"); +}); diff --git a/packages/cli-core/src/commands/init/frameworks/react-router.ts b/packages/cli-core/src/commands/init/frameworks/react-router.ts index b219a2d6..31bd8e5a 100644 --- a/packages/cli-core/src/commands/init/frameworks/react-router.ts +++ b/packages/cli-core/src/commands/init/frameworks/react-router.ts @@ -382,6 +382,83 @@ function scaffoldConfig(ctx: ProjectContext): Promise { }); } +type ViteScaffoldResult = { + action: FileAction | null; + /** True when the noExternal entry is needed but couldn't be applied — user must wire manually. */ + needsManualViteWire: boolean; +}; + +/** + * Add `ssr.noExternal: ["@clerk/react-router"]` to the vite config. + * React Router v8 ships development/production conditional exports; `react-router dev` + * externalizes @clerk/react-router for SSR, so it resolves react-router's production + * build while app code gets the development build — two Router contexts, and every + * SSR render throws "useNavigate() may be used only in the context of a ". + * https://github.com/remix-run/react-router/issues/15232 + */ +function addClerkNoExternal(content: string): string { + try { + const mod = parseModule(content); + const defaultExport = mod.exports.default; + if (!defaultExport || typeof defaultExport !== "object") return content; + + // `export default defineConfig({...})` proxies the call expression; property writes on it + // throw, so operate on the call's first argument (the config object) instead. + const config = defaultExport.$type === "function-call" ? defaultExport.$args[0] : defaultExport; + if (!config || typeof config !== "object") return content; + + if (!config.ssr) config.ssr = {}; + if (config.ssr.noExternal === undefined) config.ssr.noExternal = []; + // boolean/string/regex noExternal: leave it and let the caller emit the manual instruction + if (!Array.isArray(config.ssr.noExternal)) return content; + + if (!config.ssr.noExternal.includes("@clerk/react-router")) { + config.ssr.noExternal.push("@clerk/react-router"); + } + return mod.generate().code; + } catch { + return content; + } +} + +async function scaffoldViteConfig(ctx: ProjectContext): Promise { + // The dual-instance issue only exists in v8's conditional exports; v7 doesn't need this. + if (shouldEnableV8MiddlewareFlag(ctx)) return { action: null, needsManualViteWire: false }; + + const configPath = await findFirstFile(ctx.cwd, [ + "vite.config.ts", + "vite.config.js", + "vite.config.mts", + "vite.config.mjs", + ]); + if (!configPath) return { action: null, needsManualViteWire: true }; + + const content = await Bun.file(join(ctx.cwd, configPath)).text(); + if (content.includes("@clerk/react-router")) { + return { + action: { + type: "skip", + path: configPath, + skipReason: "Already references @clerk/react-router in vite config", + }, + needsManualViteWire: false, + }; + } + + const updated = addClerkNoExternal(content); + if (updated === content) return { action: null, needsManualViteWire: true }; + + return { + action: { + path: configPath, + type: "modify", + content: updated, + description: "Add @clerk/react-router to ssr.noExternal (React Router v8 dev SSR fix)", + }, + needsManualViteWire: false, + }; +} + export const reactRouter: FrameworkScaffold = { name: "React Router", dep: "react-router", @@ -390,8 +467,9 @@ export const reactRouter: FrameworkScaffold = { matches: (ctx) => ctx.framework.dep === "react-router", async scaffold(ctx: ProjectContext): Promise { - const [configAction, rootResult, localePrefix, envAction] = await Promise.all([ + const [configAction, viteResult, rootResult, localePrefix, envAction] = await Promise.all([ scaffoldConfig(ctx), + scaffoldViteConfig(ctx), scaffoldRoot(ctx), detectLocalePrefix(ctx.cwd), scaffoldEnvVars(ctx, SIGN_ROUTE_ENV_VARS.vite), @@ -404,6 +482,7 @@ export const reactRouter: FrameworkScaffold = { const rootAction = rootResult.action; const actions = [ configAction, + viteResult.action, rootAction, ...authActions, routesResult.action, @@ -424,6 +503,12 @@ export const reactRouter: FrameworkScaffold = { ); } + if (viteResult.needsManualViteWire) { + postInstructions.push( + "React Router v8 needs `ssr: { noExternal: ['@clerk/react-router'] }` in your vite config — without it, dev SSR fails with \"useNavigate() may be used only in the context of a \". See: https://github.com/remix-run/react-router/issues/15232", + ); + } + if (rootAction?.type === "modify" && rootResult.needsManualLoaderMerge) { postInstructions.push( "Update your existing app/root.tsx loader to import and call rootAuthLoader(args), then pass that loaderData to .",