Repository navigation
router-plugin: a split route option that is also a shared binding exports an undeclared Split… when two @babel/traverse copies are installed #8715
Description
Which project does this relate to?
Router (@tanstack/router-plugin, code splitting)
Describe the bug
A route option is broken when it meets both of these conditions:
- it is a split target (
notFoundComponent,errorComponent, …); - its value is a local identifier that another split group also uses, which makes it a shared binding.
In that case the option's virtual module exports a name it never declares, and the bundler rejects it:
[PARSE_ERROR] Export 'SplitNotFoundComponent' is not defined
╭─[ src/routes/posts.$id.tsx?tsr-split=notFoundComponent:3:10 ]
3 │ export { SplitNotFoundComponent as notFoundComponent };
It happens only when the install holds two copies of @babel/traverse, which pnpm produces easily:
babel-dead-code-elimination(pulled in by@tanstack/router-utils) resolves one copy;@babel/coreresolves another.
Our lockfile reached that state from a plain pnpm up of the TanStack Start packages: @babel/core@7.29.7 → @babel/traverse@7.29.8, and babel-dead-code-elimination@1.0.12 → @babel/traverse@7.29.10.
Minimal route
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/posts/$id')({
component: PostRoute,
notFoundComponent: PostNotFound,
})
function PostNotFound() {
return <p>Not found</p>
}
function PostRoute() {
const post = usePost()
if (!post) return <PostNotFound />
return <p>{post.title}</p>
}
declare function usePost(): { title: string } | undefinedSteps to reproduce
package.json:{ "private": true, "dependencies": { "@tanstack/router-plugin": "1.168.46", "@tanstack/react-router": "1.170.44", "react": "19.2.8", "react-dom": "19.2.8" } }pnpm-workspace.yaml, to get the two-copy layout on purpose:packages: [] overrides: "@babel/core>@babel/traverse": 7.29.8
pnpm install. Then either run a Vite build with the router plugin andautoCodeSplitting, or call the compiler directly:import { readFileSync } from 'node:fs' import { computeSharedBindings, compileCodeSplitVirtualRoute } from './node_modules/@tanstack/router-plugin/dist/esm/core/code-splitter/compilers.js' const filename = 'route.tsx' const code = readFileSync(filename, 'utf8') const codeSplitGroupings = [['component'], ['errorComponent'], ['notFoundComponent']] const sharedBindings = computeSharedBindings({ code, filename, codeSplitGroupings }) console.log(sharedBindings) // Set { 'PostNotFound' } console.log(compileCodeSplitVirtualRoute({ code, filename, splitTargets: ['notFoundComponent'], sharedBindings }).code)
Actual
import { PostNotFound } from "route.tsx?tsr-shared=1";
export { SplitNotFoundComponent as notFoundComponent };Expected
What the same code produces with a single @babel/traverse copy:
import { PostNotFound } from "route.tsx?tsr-shared=1";
export { PostNotFound as notFoundComponent };Cause
In compileCodeSplitVirtualRoute:
findReferencedIdentifiers(ast)(router-utils →babel-dead-code-elimination→ traverse copy A) traverses the program and caches its scope, including thePostNotFoundbinding.removeSharedDeclarations(ast, sharedBindings)filtersast.program.bodydirectly.PostNotFound's declaration is gone, but no scope is updated.babel.traverse(ast, …)(@babel/core→ traverse copy B) resolves the split node withprogramPath.scope.getBinding('PostNotFound').- One copy: B reuses the scope A cached in step 1 and gets the stale binding to the removed
FunctionDeclaration. TheisFunctionDeclarationbranch setslocalExporterIdent = 'PostNotFound', which the shared import added later happens to satisfy. It works only because of the stale cache. - Two copies: B crawls fresh, the declaration is already gone, and
getBindingreturnsundefined.splitNodeisundefined, so noconst SplitNotFoundComponent = …is ever emitted, but the export is still written.
- One copy: B reuses the scope A cached in step 1 and gets the stale binding to the removed
To prove it with a single copy, add babel.traverse.cache.clear() right after removeSharedDeclarations(...). The single-copy install then fails exactly like the two-copy one.
Scope
- Reproduced on
@tanstack/router-plugin1.168.46 and 1.168.35, so it is not a recent regression. errorComponentbreaks the same way (SplitErrorComponent). I expect any split key whose value is a shared identifier to be affected.
Possible fixes
- When a split target's identifier is in
sharedBindings, export it straight from the shared import (localExporterIdent = originalIdentName) instead of resolving it through a scope that step 2 made stale. - Or resolve split nodes before
removeSharedDeclarationsmutates the body. - Separately: router-utils and
babel-dead-code-eliminationusing a different@babel/traverseinstance from@babel/coremakes any cross-pass scope reuse depend on how the package manager dedupes.
Workarounds
- Wrap the option in an arrow,
notFoundComponent: () => <PostNotFound />. The split node is then an arrow expression and no binding lookup happens. - Or dedupe
@babel/traverseto a single copy. That only brings back the stale-cache behaviour that happens to work.
Your environment
@tanstack/router-plugin1.168.46 (also 1.168.35),@tanstack/router-utils1.162.4,babel-dead-code-elimination1.0.12@babel/core7.29.7,@babel/traverse7.29.8 + 7.29.10- pnpm 10.24.0, Vite 8.2.2 (rolldown), Node 22.23.3
- Found through
@tanstack/react-start1.169.2
Activity
schiller-manuel commentedon Oct 15, 2026
please provide an actual git repo that reproduces this
Zeifri commentedon Oct 16, 2026
Repro repo: https://github.com/Zeifri/tanstack-router-8715-repro
A Vite 8.2.2 + React 19 app with
@tanstack/react-router@1.170.44,@tanstack/router-plugin@1.168.46(autoCodeSplitting: true) and the single route from the issue (src/routes/posts.$id.tsx).pnpm-workspace.yamloverrides@babel/core>@babel/traverseto7.29.8, and the committed lockfile holds both copies (7.29.8 via@babel/core, 7.29.10 viababel-dead-code-elimination).Fails:
To see it pass with a single
@babel/traversecopy, delete theoverridesblock frompnpm-workspace.yaml, then reinstall from scratch (the lockfile has to go too, otherwise pnpm keeps the locked 7.29.8 resolution for@babel/core):That builds fine. Verified both ways from a fresh clone (Node 22.23.3, pnpm 10.12.1).
Sheraff commentedon Oct 17, 2026
this seems like a valid error. However you could also
throw notFound()orthrow new Error()from your route component to trigger thenotFoundComponentor theerrorComponent. (or use stuff that suspends to trigger thependingComponent). In case that helps while we fix this.