Skip to content

router-plugin: a split route option that is also a shared binding exports an undeclared Split… when two @babel/traverse copies are installed #8715

Open

Description

@Zeifri

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/core resolves 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 } | undefined

Steps to reproduce

  1. 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" } }
  2. pnpm-workspace.yaml, to get the two-copy layout on purpose:
    packages: []
    overrides:
      "@babel/core>@babel/traverse": 7.29.8
  3. pnpm install. Then either run a Vite build with the router plugin and autoCodeSplitting, 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:

  1. findReferencedIdentifiers(ast) (router-utils → babel-dead-code-elimination → traverse copy A) traverses the program and caches its scope, including the PostNotFound binding.
  2. removeSharedDeclarations(ast, sharedBindings) filters ast.program.body directly. PostNotFound's declaration is gone, but no scope is updated.
  3. babel.traverse(ast, …) (@babel/core → traverse copy B) resolves the split node with programPath.scope.getBinding('PostNotFound').
    • One copy: B reuses the scope A cached in step 1 and gets the stale binding to the removed FunctionDeclaration. The isFunctionDeclaration branch sets localExporterIdent = '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 getBinding returns undefined. splitNode is undefined, so no const SplitNotFoundComponent = … is ever emitted, but the export is still written.

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-plugin 1.168.46 and 1.168.35, so it is not a recent regression.
  • errorComponent breaks 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 removeSharedDeclarations mutates the body.
  • Separately: router-utils and babel-dead-code-elimination using a different @babel/traverse instance from @babel/core makes 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/traverse to a single copy. That only brings back the stale-cache behaviour that happens to work.

Your environment

  • @tanstack/router-plugin 1.168.46 (also 1.168.35), @tanstack/router-utils 1.162.4, babel-dead-code-elimination 1.0.12
  • @babel/core 7.29.7, @babel/traverse 7.29.8 + 7.29.10
  • pnpm 10.24.0, Vite 8.2.2 (rolldown), Node 22.23.3
  • Found through @tanstack/react-start 1.169.2

Activity

  1. schiller-manuel commented on Oct 15, 2026

    @schiller-manuel
    Collaborator

    please provide an actual git repo that reproduces this

  2. Zeifri commented on Oct 16, 2026

    @Zeifri
    Author

    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.yaml overrides @babel/core>@babel/traverse to 7.29.8, and the committed lockfile holds both copies (7.29.8 via @babel/core, 7.29.10 via babel-dead-code-elimination).

    git clone https://github.com/Zeifri/tanstack-router-8715-repro && cd tanstack-router-8715-repro
    pnpm install && pnpm build

    Fails:

    [PARSE_ERROR] Export 'SplitNotFoundComponent' is not defined
       ╭─[ src/routes/posts.$id.tsx?tsr-split=notFoundComponent:1:10 ]
     1 │ export { SplitNotFoundComponent as notFoundComponent };
    

    To see it pass with a single @babel/traverse copy, delete the overrides block from pnpm-workspace.yaml, then reinstall from scratch (the lockfile has to go too, otherwise pnpm keeps the locked 7.29.8 resolution for @babel/core):

    rm -rf node_modules pnpm-lock.yaml && pnpm install && pnpm build

    That builds fine. Verified both ways from a fresh clone (Node 22.23.3, pnpm 10.12.1).

  3. Sheraff commented on Oct 17, 2026

    @Sheraff
    Collaborator

    this seems like a valid error. However you could also throw notFound() or throw new Error() from your route component to trigger the notFoundComponent or the errorComponent. (or use stuff that suspends to trigger the pendingComponent). In case that helps while we fix this.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions