Skip to content

Code-splitter breaks every route when the project path contains a single quote/apostrophe (autoCodeSplitting: false does not avoid it) #7754

Description

@aneurycasado

Which project does this relate to?

Router (@tanstack/router-plugin) — with a secondary defect in Start (@tanstack/start-plugin-core)

Describe the bug

When a TanStack Start (or plain TanStack Router + Vite) project lives under an absolute path that contains a single quote / apostrophe (e.g. a macOS username directory, an external contributor's "it's" folder, etc.), every route request 500s.

@tanstack/router-plugin's code-splitter interpolates each route file's absolute path directly into a single-quoted JS string template without escaping it:

// node_modules/@tanstack/router-plugin/dist/esm/core/code-splitter/compilers.js
`const ${splitNodeMeta.localImporterIdent} = () => import('${splitUrl}')`

If splitUrl contains a ', the generated code is syntactically invalid — the apostrophe terminates the string literal early — and Babel throws a parse error, which surfaces to the user as an unhandled 500 on every route.

This is not avoidable via configuration. Setting router: { autoCodeSplitting: false } in the tanstackStart() / tanstackRouter() plugin options does not prevent the crash, because @tanstack/start-plugin-core wires the code-splitter Vite plugins unconditionally — it never reads autoCodeSplitting before deciding whether to include them in the plugin list. See Root Cause below.

Minimal repro steps

  1. Create a project directory whose absolute path contains a single quote, e.g.:
    mkdir -p "/Users/dev/it's a repro/app/src/routes"
    cd "/Users/dev/it's a repro/app"
    
  2. Minimal package.json deps: @tanstack/react-start, @tanstack/react-router, react, react-dom, vite, @vitejs/plugin-react.
  3. vite.config.ts:
    import { defineConfig } from 'vite'
    import viteReact from '@vitejs/plugin-react'
    import { tanstackStart } from '@tanstack/react-start/plugin/vite'
    
    export default defineConfig({
      plugins: [tanstackStart(), viteReact()],
    })
  4. src/routes/__root.tsx — standard root route with <Outlet /> and <Scripts />.
  5. src/routes/index.tsx:
    import { createFileRoute } from '@tanstack/react-router'
    
    export const Route = createFileRoute('/')({
      component: () => <div>Hello</div>,
    })
  6. npm install && npx vite dev, then curl http://localhost:<port>/.
  7. To confirm the config flag does not help, set router: { autoCodeSplitting: false } in tanstackStart({...}) and repeat step 6 — the failure is identical.

Expected vs Actual

Expected: the dev server compiles the route and returns the rendered page (HTTP 200), regardless of what characters appear in the absolute project path.

Actual: every request 500s. The dev server logs a Babel template parse error caused by the unescaped apostrophe splitting the generated import(...) string literal:

SyntaxError: Unexpected token, expected "," (2:220)
  1 | /* @babel/template */;
> 2 | const $$splitComponentImporter = () => import('/Users/dev/it's a repro/app/src/routes/index.tsx?tsr-split=component')
    |                                                                                                                    ^
    code: 'BABEL_TEMPLATE_PARSE_ERROR',
    reasonCode: 'UnexpectedToken',
    plugin: 'tanstack-router:code-splitter:compile-reference-file',
    id: "/Users/dev/it's a repro/app/src/routes/index.tsx",

with the stack trace bottoming out at:

at node_modules/@tanstack/router-plugin/dist/esm/core/code-splitter/compilers.js:299:128
at Array.forEach (<anonymous>)
at babelHandleReference (.../compilers.js:274:30)
at CallExpression (.../compilers.js:345:50)

HTTP response: 500 Internal Server Error, body {"status":500,"unhandled":true,"message":"HTTPError"}.

Setting router: { autoCodeSplitting: false } produces the exact same error and stack trace — the flag has no effect on this failure.

Root cause

Two separate defects compound:

1. Unescaped path interpolation in the code-splitter (@tanstack/router-plugin 1.168.19)

node_modules/@tanstack/router-plugin/dist/esm/core/code-splitter/compilers.js, inside compileCodeSplitReferenceRoute, two call sites build the dynamic-import statement by interpolating splitUrl (an absolute filesystem path + ?tsr-split=... query) into a single-quoted template string, with no escaping:

  • Line 299:
    if (!hasImportedOrDefinedIdentifier(splitNodeMeta.localImporterIdent)) programPath.unshiftContainer("body", [template.statement(`const ${splitNodeMeta.localImporterIdent} = () => import('${splitUrl}')`)()]);
  • Line 335 (same pattern, second branch of the same function):
    if (!hasImportedOrDefinedIdentifier(splitNodeMeta.localImporterIdent)) programPath.unshiftContainer("body", [template.statement(`const ${splitNodeMeta.localImporterIdent} = () => import('${splitUrl}')`)()]);

Any character in splitUrl that terminates a single-quoted JS string (') — or otherwise isn't valid inside one — breaks the generated source.

2. autoCodeSplitting: false is not honored by @tanstack/start-plugin-core

node_modules/@tanstack/start-plugin-core/dist/esm/vite/start-router-plugin/plugin.js, function tanStackStartRouter (around lines 91–133), unconditionally returns an array that includes two tanStackRouterCodeSplitter(...) plugin instances (one for the client environment, one for the server environment):

return [
	clientTreePlugin,
	tanstackRouterGenerator(() => { ... }, routerPluginContext),
	tanStackRouterCodeSplitter(() => {
		const routerConfig = getConfig().startConfig.router;
		return { ...routerConfig, codeSplittingOptions: { ... }, plugin: { vite: { environmentName: VITE_ENVIRONMENT_NAMES.client } } };
	}, routerPluginContext),
	tanStackRouterCodeSplitter(() => {
		const routerConfig = getConfig().startConfig.router;
		return { ...routerConfig, codeSplittingOptions: { ... }, plugin: { vite: { environmentName: VITE_ENVIRONMENT_NAMES.server } } };
	}, routerPluginContext)
]

routerConfig.autoCodeSplitting is spread into the returned options object but is never read to decide whether to push the tanStackRouterCodeSplitter(...) plugins into the array in the first place. So even with router: { autoCodeSplitting: false }, both code-splitter plugins are still instantiated and wired into the Vite pipeline, and defect 1 still fires.

Suggested fix

Escape the interpolated path with JSON.stringify() at both sites in compilers.js (verified locally — patching this eliminates the syntax error entirely; a route in an apostrophe-path project then compiles and progresses past this failure):

--- a/node_modules/@tanstack/router-plugin/dist/esm/core/code-splitter/compilers.js
+++ b/node_modules/@tanstack/router-plugin/dist/esm/core/code-splitter/compilers.js
@@ -296,7 +296,7 @@ function compileCodeSplitReferenceRoute(opts) {
 									if (!shouldSplit) return;
 									modified = true;
 									if (!hasImportedOrDefinedIdentifier(LAZY_ROUTE_COMPONENT_IDENT)) programPath.unshiftContainer("body", [template.statement(`import { ${LAZY_ROUTE_COMPONENT_IDENT} } from '${PACKAGE}'`)()]);
-									if (!hasImportedOrDefinedIdentifier(splitNodeMeta.localImporterIdent)) programPath.unshiftContainer("body", [template.statement(`const ${splitNodeMeta.localImporterIdent} = () => import('${splitUrl}')`)()]);
+									if (!hasImportedOrDefinedIdentifier(splitNodeMeta.localImporterIdent)) programPath.unshiftContainer("body", [template.statement(`const ${splitNodeMeta.localImporterIdent} = () => import(${JSON.stringify(splitUrl)})`)()]);
 									const insertionPath = path.getStatementParent() ?? path;
 									let splitPropValue;
 									for (const plugin of opts.compilerPlugins ?? []) {
@@ -332,7 +332,7 @@ function compileCodeSplitReferenceRoute(opts) {
 									if (!shouldSplit) return;
 									modified = true;
 									if (!hasImportedOrDefinedIdentifier(LAZY_FN_IDENT)) programPath.unshiftContainer("body", template.smart(`import { ${LAZY_FN_IDENT} } from '${PACKAGE}'`)());
-									if (!hasImportedOrDefinedIdentifier(splitNodeMeta.localImporterIdent)) programPath.unshiftContainer("body", [template.statement(`const ${splitNodeMeta.localImporterIdent} = () => import('${splitUrl}')`)()]);
+									if (!hasImportedOrDefinedIdentifier(splitNodeMeta.localImporterIdent)) programPath.unshiftContainer("body", [template.statement(`const ${splitNodeMeta.localImporterIdent} = () => import(${JSON.stringify(splitUrl)})`)()]);
 									prop.value = template.expression(`${LAZY_FN_IDENT}(${splitNodeMeta.localImporterIdent}, '${splitNodeMeta.exporterIdent}')`)();
 								}
 							}

It's worth auditing the rest of compilers.js (and any sibling generator/compiler files that build source via string templates) for the same pattern — other interpolated identifiers/paths built from filesystem input are equally at risk.

Separately, for defect 2: tanStackStartRouter in start-plugin-core's start-router-plugin/plugin.js should check routerConfig.autoCodeSplitting before pushing the tanStackRouterCodeSplitter(...) entries into the returned plugin array, so the documented flag actually disables the code-splitter instead of only affecting its internal options.

Environment

  • @tanstack/react-start: 1.168.27 (latest at time of testing)
  • @tanstack/router-plugin: 1.168.19 (latest at time of testing — also the version pinned by the latest @tanstack/start-plugin-core 1.171.19)
  • @tanstack/react-router: 1.170.17
  • @tanstack/start-plugin-core: 1.171.19
  • Vite: 7.3.6
  • Node: v24.14.0
  • OS: macOS (Darwin 25.5.0)
  • Bundler: esbuild/Vite dev server (module runner), also expected to affect production builds since the same compiler path is shared

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions