Skip to content

fix(init): scaffold ssr.noExternal for React Router v8 projects#374

Open
manovotny wants to merge 1 commit into
mainfrom
manovotny/react-router-v8-noexternal
Open

fix(init): scaffold ssr.noExternal for React Router v8 projects#374
manovotny wants to merge 1 commit into
mainfrom
manovotny/react-router-v8-noexternal

Conversation

@manovotny

Copy link
Copy Markdown
Contributor

Summary

clerk init on a React Router 8 project scaffolds an app that is broken in dev out of the box: every request fails during SSR with useNavigate() may be used only in the context of a <Router> component. This came out of root-causing a public field report where an agent couldn't one-shot a Clerk + React Router app.

RR8 ships development/production conditional exports. react-router dev externalizes @clerk/react-router for SSR, so Node resolves react-router's production build for Clerk while the app code gets the development build through Vite — two module instances, two Router contexts, and Clerk's useNavigate() call inside ClerkProvider throws. Upstream: remix-run/react-router#15232. The scaffolder already handles the v7/v8 split for the v8_middleware flag but never touched the vite config.

Changes

  • Scaffold ssr: { noExternal: ["@clerk/react-router"] } into vite.config.{ts,js,mts,mjs} when react-router is v8+ (or unparseable, matching how the v8_middleware gate already treats unknown versions). v7 has no conditional exports and is left alone.
  • Uses magicast with a string-replace fallback, mirroring the astro scaffolder; appends to an existing ssr.noExternal array instead of clobbering it; skips when the config already references @clerk/react-router.
  • Emits a manual post-instruction (with the verbatim error and upstream link) when the vite config is missing or uses a function form that can't be safely modified.
  • 6 new unit tests covering: fresh add, append-to-existing-array, already-present skip, v7 no-op, missing-config instruction, and function-form instruction.

Verification

  • Reproduced in a scratch app (create-react-router@latest → RR 8.0.0 + @clerk/react-router@3.5.5): dev SSR 500s without the entry, HTTP 200 with it; react-router build unaffected.
  • bun run format, lint, typecheck clean; react-router.test.ts 21/21 pass. (Full bun run test has 363 pre-existing failures in credential-store/host-execution on clean main in my sandbox — identical count with and without this change.)

Not planned (follow-up candidates)

  • The e2e fixture (test/e2e/fixtures/react-router) pins react-router@7.15.0, so CI never exercises v8 — worth adding an RR8 fixture so the next regression of this class is caught.

References

🤖 Generated with Claude Code

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 <Router>" (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 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Jul 6, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e96261a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
clerk Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@manovotny

manovotny commented Jul 6, 2026

Copy link
Copy Markdown
Contributor Author

Companion PRs, now all open:

@manovotny manovotny marked this pull request as ready for review July 6, 2026 22:23
@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds scaffolding logic to the React Router v8 CLI initializer that automatically updates a project's vite.config.ts to include @clerk/react-router in ssr.noExternal, using AST-based modification with a regex fallback. When automatic modification isn't possible (missing or function-form config), a post-instruction is emitted advising manual changes. The scaffold flow is updated to run this new logic in parallel with existing steps. Tests and a changeset entry are included.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant ReactRouterScaffold as reactRouter.scaffold
  participant ViteScaffolder as scaffoldViteConfig
  participant FileSystem

  User->>ReactRouterScaffold: run init for React Router v8
  ReactRouterScaffold->>ViteScaffolder: scaffoldViteConfig(ctx)
  ViteScaffolder->>FileSystem: locate vite.config.ts
  alt config found and `@clerk/react-router` absent
    ViteScaffolder->>ViteScaffolder: addClerkNoExternal (AST or regex fallback)
    ViteScaffolder-->>ReactRouterScaffold: modify action
  else config missing or function-form
    ViteScaffolder-->>ReactRouterScaffold: needsManualViteWire=true
  end
  ReactRouterScaffold->>ReactRouterScaffold: assemble actions list
  alt needsManualViteWire
    ReactRouterScaffold->>User: postInstructions with manual noExternal note
  end
Loading

Related PRs
No related PRs identified in the provided information.

Suggested labels
None

Suggested reviewers
None

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: scaffolding Vite ssr.noExternal for React Router v8 projects.
Description check ✅ Passed The description directly explains the React Router v8 SSR fix and the related scaffolding and tests.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Warning

Review ran into problems

🔥 Problems

Linked repositories: Your configuration references 7 linked repositories, but your current plan allows 1. Analyzed clerk/clerk_go, skipped clerk/dashboard, clerk/accounts, clerk/backoffice, clerk/clerk, clerk/clerk-docs, clerk/cloudflare-workers.


Repository analysis: Couldn't refresh clerk/cli clone - clone failed: Clone operation failed: Stream initialization permanently failed: 13 INTERNAL: Received RST_STREAM with code 2 (Internal server error)


Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
packages/cli-core/src/commands/init/frameworks/react-router.test.ts (1)

461-483: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Strengthen assertions to catch duplicate ssr keys, not just substring presence.

This test only checks that the output contains "some-other-pkg" and "@clerk/react-router" as substrings. It wouldn't catch a regression where the fix ends up in a duplicate ssr: block that gets shadowed by the original one at runtime (see the related comment on addClerkNoExternal in react-router.ts). Consider asserting the actual parsed array (e.g., via a second parseModule on viteAction.content and checking ssr.noExternal contains exactly one @clerk/react-router alongside some-other-pkg), or at least asserting there's only a single ssr: occurrence in the output.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli-core/src/commands/init/frameworks/react-router.test.ts` around
lines 461 - 483, Strengthen the React Router 8 scaffold test so it verifies the
final Vite config structure, not just raw substrings. In react-router.test.ts,
update the assertion around reactRouter.scaffold and viteAction.content to parse
the generated config (or otherwise inspect structure) and confirm ssr.noExternal
contains both some-other-pkg and exactly one `@clerk/react-router` entry, with
only a single ssr block present. Use the existing addClerkNoExternal /
reactRouter.scaffold / viteAction checks to locate the test and make the
assertion resistant to duplicate ssr keys.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/cli-core/src/commands/init/frameworks/react-router.ts`:
- Around line 434-444: The early skip in react-router init is using a naive
content.includes("`@clerk/react-router`") check that can falsely treat unrelated
mentions as already configured. Update the logic in the config inspection path
around the existing skip branch so it only skips after confirming
`@clerk/react-router` is actually present in the ssr.noExternal configuration
(preferably via the AST parse/inspection already used in this flow, or a much
narrower pattern tied to noExternal). If the match is only incidental, continue
with the normal wiring path and emit the manual Vite instruction instead of
returning skip from the current check.
- Around line 399-420: The addClerkNoExternal helper is mutating
mod.exports.default too early and the defineConfig fallback can create a
duplicate ssr block. Unwrap the defineConfig(...) argument first, then update
the existing ssr.noExternal array on that config object before generating code;
if ssr already exists, merge into it instead of injecting a second ssr key. Use
addClerkNoExternal, parseModule, and mod.exports.default to locate the fix.

---

Nitpick comments:
In `@packages/cli-core/src/commands/init/frameworks/react-router.test.ts`:
- Around line 461-483: Strengthen the React Router 8 scaffold test so it
verifies the final Vite config structure, not just raw substrings. In
react-router.test.ts, update the assertion around reactRouter.scaffold and
viteAction.content to parse the generated config (or otherwise inspect
structure) and confirm ssr.noExternal contains both some-other-pkg and exactly
one `@clerk/react-router` entry, with only a single ssr block present. Use the
existing addClerkNoExternal / reactRouter.scaffold / viteAction checks to locate
the test and make the assertion resistant to duplicate ssr keys.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 5107ccbd-34ba-4b06-99d2-2a373394222c

📥 Commits

Reviewing files that changed from the base of the PR and between 1494d63 and e96261a.

📒 Files selected for processing (3)
  • .changeset/react-router-v8-noexternal.md
  • packages/cli-core/src/commands/init/frameworks/react-router.test.ts
  • packages/cli-core/src/commands/init/frameworks/react-router.ts
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • clerk/clerk_go (manual)

Comment on lines +399 to +420
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;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '== relevant files ==\n'
git ls-files | rg 'packages/cli-core/src/commands/init/frameworks/react-router\.ts|packages/cli-core.*test|packages/cli-core.*spec|package\.json|bun\.lock|pnpm-lock\.yaml|package-lock\.json|yarn.lock'

printf '\n== locate addClerkNoExternal ==\n'
rg -n "function addClerkNoExternal|addClerkNoExternal|defineConfig|noExternal" packages/cli-core/src/commands/init/frameworks/react-router.ts packages/cli-core -g '!**/node_modules/**'

printf '\n== package dependency for magicast ==\n'
rg -n '"magicast"|magicast' package.json packages/**/package.json bun.lock pnpm-lock.yaml package-lock.json yarn.lock -g '!**/node_modules/**'

printf '\n== tests mentioning react-router init ==\n'
rg -n "react-router|addClerkNoExternal|noExternal" packages/cli-core -g '*.{test,spec}.{ts,tsx,js,jsx}' -g '!**/node_modules/**' || true

Repository: clerk/cli

Length of output: 1931


🌐 Web query:

magicast defineConfig function-call export default defaultExport $type $args docs changelog

💡 Result:

Magicast is a tool for programmatically modifying JavaScript and TypeScript source code while preserving formatting [1][2]. When working with exports like defineConfig, Magicast provides mechanisms to inspect and manipulate function calls using special proxy-based properties [1][3]. Key concepts for working with function calls: $type: This property allows you to check if an export (or any node) is a function call [1][4]. For example, when interacting with export default defineConfig(...), you can verify the node type using mod.exports.default.$type === "function-call" [1]. $args: When a node is identified as a function call, its arguments are accessible via the .$args property [1][3]. This is an array where .$args[0] typically holds the configuration object passed to the function [1][4]. Usage Pattern: A common pattern when dealing with configuration files is to handle both raw object exports and function-wrapped exports (like defineConfig) by checking the type [1][4]: import { parseModule } from "magicast"; const mod = parseModule(export default defineConfig({ foo: 'bar' })); // Access the configuration object regardless of whether it is wrapped in a function call const options = mod.exports.default.$type === "function-call"? mod.exports.default.$args: mod.exports.default; console.log(options.foo); // "bar" Creating Function Calls: To create new function calls, you can use the builders utility provided by Magicast [1][5]. This allows you to programmatically define function calls and assign them to module exports [1][4]: import { builders } from "magicast"; // Create a function call 'create([1][6][7])' const call = builders.functionCall("create", [1][6][7]); mod.exports.default.list = call;

Citations:


🏁 Script executed (no clone):

#!/bin/bash
set -euo pipefail
python3 - <<'PY'
import sys, json, urllib.parse, urllib.request
q = urllib.parse.quote('magicast defineConfig function-call export default $type $args')
url = f'https://api.github.com/search/issues?q={q}+repo:unjs/magicast'
req = urllib.request.Request(url, headers={'Accept':'application/vnd.github+json','User-Agent':'Mozilla/5.0'})
try:
    with urllib.request.urlopen(req, timeout=20) as r:
        data = json.load(r)
    print(json.dumps({
        "total_count": data.get("total_count"),
        "items": [
            {"title": i["title"], "url": i["html_url"]}
            for i in data.get("items", [])[:5]
        ]
    }, indent=2))
except Exception as e:
    print(type(e).__name__, e)
PY

Length of output: 146


🌐 Web query:

site:github.com/unjs/magicast README function-call $type $args defineConfig property assignment

💡 Result:

In the context of the Magicast library, $type and $args are internal properties used to represent and manipulate code structures, such as function calls like defineConfig, at an Abstract Syntax Tree (AST) level [1][2]. When Magicast parses code, it identifies certain structures as function calls. If a node is identified as a function call, its $type property is set to "function-call" [1]. The $args property is an array that contains the arguments passed to that function [1][2]. For example, when dealing with a configuration export like export default defineConfig({ foo: 'bar' }), you can programmatically interact with it by checking the $type and accessing the $args array [1]: import { parseModule } from "magicast"; const mod = parseModule(export default defineConfig({ foo: 'bar' })); // Check if the default export is a function call if (mod.exports.default.$type === "function-call") { // Access the first argument passed to the function const options = mod.exports.default.$args; console.log(options.foo); // 'bar' // Modify the property options.foo = 'baz'; } The use of the $ prefix (e.g., $type, $args) serves to distinguish these library-provided metadata properties from standard properties of the user's JavaScript objects, preventing naming collisions [2]. You can also use the builders utility to programmatically construct new function calls for modification or injection [1][3].

Citations:


🏁 Script executed (no clone):

#!/bin/bash
set -euo pipefail
python3 - <<'PY'
import json, urllib.parse, urllib.request
queries = [
    "magicast function-call proxy property assignment defineConfig",
    "unjs magicast $type function-call $args defineConfig",
]
for q in queries:
    print("QUERY:", q)
    url = "https://api.github.com/search/code?q=" + urllib.parse.quote(q + " repo:unjs/magicast")
    req = urllib.request.Request(url, headers={'Accept':'application/vnd.github+json','User-Agent':'Mozilla/5.0'})
    try:
        with urllib.request.urlopen(req, timeout=20) as r:
            data = json.load(r)
        print(json.dumps({"total_count": data.get("total_count"), "items": [i.get("html_url") for i in data.get("items", [])[:5]]}, indent=2))
    except Exception as e:
        print(type(e).__name__, e)
PY

Length of output: 146


🏁 Script executed (no clone):

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import urllib.request, re

url = "https://raw.githubusercontent.com/unjs/magicast/main/README.md"
text = urllib.request.urlopen(url, timeout=20).read().decode()

for needle in ["defineConfig", "function-call", "$args", "exports.default"]:
    print(f"\n== {needle} ==")
    for m in re.finditer(needle, text, re.I):
        start = max(0, m.start() - 220)
        end = min(len(text), m.end() + 420)
        print(text[start:end].replace("\n", " "))
        print("---")
        break
PY

Length of output: 146


Handle defineConfig(...) exports before mutating ssr.noExternal
mod.exports.default needs to unwrap the defineConfig argument before appending @clerk/react-router. As written, the fallback path can inject a second ssr key, and a later existing ssr block will win, dropping the new noExternal entry.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli-core/src/commands/init/frameworks/react-router.ts` around lines
399 - 420, The addClerkNoExternal helper is mutating mod.exports.default too
early and the defineConfig fallback can create a duplicate ssr block. Unwrap the
defineConfig(...) argument first, then update the existing ssr.noExternal array
on that config object before generating code; if ssr already exists, merge into
it instead of injecting a second ssr key. Use addClerkNoExternal, parseModule,
and mod.exports.default to locate the fix.

Comment on lines +434 to +444
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,
};
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Naive substring check can cause a silent false-positive skip.

content.includes("@clerk/react-router") matches anywhere in the file (imports, comments, other config sections), not specifically inside ssr.noExternal. If it matches for an unrelated reason, the scaffold returns { action: skip, needsManualViteWire: false } — no automatic fix is applied and no manual post-instruction is emitted, so the SSR bug goes unresolved without any indication to the user.

Consider deferring the "already present" check to after attempting the AST parse (checking membership inside the actual ssr.noExternal array), or narrowing the regex to something like /noExternal\s*:\s*\[[^\]]*@clerk\/react-router/.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli-core/src/commands/init/frameworks/react-router.ts` around lines
434 - 444, The early skip in react-router init is using a naive
content.includes("`@clerk/react-router`") check that can falsely treat unrelated
mentions as already configured. Update the logic in the config inspection path
around the existing skip branch so it only skips after confirming
`@clerk/react-router` is actually present in the ssr.noExternal configuration
(preferably via the AST parse/inspection already used in this flow, or a much
narrower pattern tied to noExternal). If the match is only incidental, continue
with the normal wiring path and emit the manual Vite instruction instead of
returning skip from the current check.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant