From e96261a9998708950c539527849fa7b7c54e39e1 Mon Sep 17 00:00:00 2001 From: Michael Novotny Date: Mon, 6 Jul 2026 10:53:06 -0500 Subject: [PATCH 1/2] fix(init): scaffold ssr.noExternal for React Router v8 projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit React Router 8 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 module instances, two Router contexts — and every SSR render throws "useNavigate() may be used only in the context of a " (remix-run/react-router#15232). A clerk init'd RR8 app was broken in dev out of the box. Scaffold ssr: { noExternal: ["@clerk/react-router"] } into the vite config for react-router >= 8 (v7 has no conditional exports and needs nothing). Emits a manual post-instruction when the config is missing or uses a function form magicast can't safely modify. Verified against a live repro: create-react-router@latest (RR 8.0.0) + @clerk/react-router@3.5.5 fails in dev SSR without the entry and renders cleanly with it; production build unaffected. Co-Authored-By: Claude Fable 5 --- .changeset/react-router-v8-noexternal.md | 5 + .../init/frameworks/react-router.test.ts | 113 ++++++++++++++++++ .../commands/init/frameworks/react-router.ts | 85 ++++++++++++- 3 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 .changeset/react-router-v8-noexternal.md 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..5aa5d9d5 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,116 @@ 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"); +}); + +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"); +}); + +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..28c4601b 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,81 @@ 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; + + if (!defaultExport.ssr) defaultExport.ssr = {}; + if (!defaultExport.ssr.noExternal) defaultExport.ssr.noExternal = []; + if (!Array.isArray(defaultExport.ssr.noExternal)) return content; + + defaultExport.ssr.noExternal.push("@clerk/react-router"); + return mod.generate().code; + } catch { + if (content.includes("defineConfig")) { + return content.replace( + /(defineConfig\s*\(\s*\{)/, + '$1\n ssr: {\n noExternal: ["@clerk/react-router"],\n },', + ); + } + 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 +465,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 +480,7 @@ export const reactRouter: FrameworkScaffold = { const rootAction = rootResult.action; const actions = [ configAction, + viteResult.action, rootAction, ...authActions, routesResult.action, @@ -424,6 +501,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 .", From 4d247bcacf7c325f684e8ac09d37df85e3dfc9dd Mon Sep 17 00:00:00 2001 From: Michael Novotny Date: Tue, 7 Jul 2026 16:46:29 -0500 Subject: [PATCH 2/2] fix(init): update the existing ssr block via magicast instead of duplicating it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit magicast proxies `export default defineConfig({...})` as a function-call expression, and property writes on that proxy throw — so the AST branch never ran and every defineConfig config went through the regex fallback. With a pre-existing ssr block the fallback injected a second ssr key, and since the later key wins, the @clerk/react-router entry was silently dropped. Operate on the call's first argument instead and drop the string fallback entirely: unparseable or non-array shapes now surface the manual post-instruction rather than a maybe-broken patch. Tests now assert a single ssr block so a duplicate-key regression fails. Co-Authored-By: Claude Fable 5 --- .../init/frameworks/react-router.test.ts | 2 ++ .../commands/init/frameworks/react-router.ts | 24 ++++++++++--------- 2 files changed, 15 insertions(+), 11 deletions(-) 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 5aa5d9d5..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 @@ -456,6 +456,7 @@ export default defineConfig({ 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 () => { @@ -480,6 +481,7 @@ export default defineConfig({ 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 () => { 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 28c4601b..31bd8e5a 100644 --- a/packages/cli-core/src/commands/init/frameworks/react-router.ts +++ b/packages/cli-core/src/commands/init/frameworks/react-router.ts @@ -402,19 +402,21 @@ function addClerkNoExternal(content: string): string { const defaultExport = mod.exports.default; if (!defaultExport || typeof defaultExport !== "object") return content; - if (!defaultExport.ssr) defaultExport.ssr = {}; - if (!defaultExport.ssr.noExternal) defaultExport.ssr.noExternal = []; - if (!Array.isArray(defaultExport.ssr.noExternal)) return content; - - defaultExport.ssr.noExternal.push("@clerk/react-router"); + // `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 { - if (content.includes("defineConfig")) { - return content.replace( - /(defineConfig\s*\(\s*\{)/, - '$1\n ssr: {\n noExternal: ["@clerk/react-router"],\n },', - ); - } return content; } }