Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion packages/router-plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,6 @@
"@tanstack/router-generator": "workspace:*",
"@tanstack/router-utils": "workspace:*",
"@tanstack/virtual-file-routes": "workspace:*",
"babel-dead-code-elimination": "^1.0.11",
"chokidar": "^3.6.0",
"unplugin": "^2.1.2",
"zod": "^3.24.2"
Expand Down
6 changes: 4 additions & 2 deletions packages/router-plugin/src/core/code-splitter/compilers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import * as template from '@babel/template'
import {
deadCodeElimination,
findReferencedIdentifiers,
} from 'babel-dead-code-elimination'
import { generateFromAst, parseAst } from '@tanstack/router-utils'
generateFromAst,
parseAst,
} from '@tanstack/router-utils'
import { tsrSplit } from '../constants'
import { routeHmrStatement } from '../route-hmr-statement'
import { createIdentifier } from './path-ids'
Expand Down Expand Up @@ -430,6 +431,7 @@ export function compileCodeSplitReferenceRoute(
if (!modified) {
return null
}

deadCodeElimination(ast, refIdents)

// if there are exported identifiers, then we need to add a warning
Expand Down
3 changes: 2 additions & 1 deletion packages/router-utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,14 @@
"@babel/core": "^7.28.5",
"@babel/generator": "^7.28.5",
"@babel/parser": "^7.28.5",
"@babel/types": "^7.28.5",
"ansis": "^4.1.0",
"babel-dead-code-elimination": "^1.0.12",
"diff": "^8.0.2",
"pathe": "^2.0.3",
"tinyglobby": "^0.2.15"
},
"devDependencies": {
"@babel/types": "^7.28.5",
"@types/babel__core": "^7.20.5",
"@types/babel__generator": "^7.27.0",
"@types/diff": "^7.0.2"
Expand Down
121 changes: 121 additions & 0 deletions packages/router-utils/src/ast.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { parse } from '@babel/parser'
import _generate from '@babel/generator'
import * as t from '@babel/types'
import {
deadCodeElimination as _deadCodeElimination,
findReferencedIdentifiers,
} from 'babel-dead-code-elimination'
import type { GeneratorOptions, GeneratorResult } from '@babel/generator'
import type { ParseResult, ParserOptions } from '@babel/parser'
import type * as _babel_types from '@babel/types'
Expand Down Expand Up @@ -36,3 +41,119 @@ export function generateFromAst(
)
}
export type { GeneratorResult } from '@babel/generator'

/**
* Strips TypeScript type-only exports and imports from an AST.
*
* This is necessary because babel-dead-code-elimination doesn't handle
* TypeScript type exports/imports. When a type export references an import
* that pulls in server-only code, the dead code elimination won't remove
* that import because it sees the type as still referencing it.
*
* This function removes:
* - `export type Foo = ...`
* - `export interface Foo { ... }`
* - `export type { Foo } from './module'`
* - `export type * from './module'`
* - Type specifiers in mixed exports: `export { value, type Foo }` -> `export { value }`
* - `import type { Foo } from './module'`
* - Type specifiers in mixed imports: `import { value, type Foo } from './module'` -> `import { value }`
*
* Note: Non-exported type/interface declarations are preserved as they may be
* used as type annotations within the code.
*
* @param ast - The Babel AST (or ParseResult) to mutate
*/
export function stripTypeExports(ast: ParseResult<_babel_types.File>): void {
// Filter the program body to remove type-only nodes
ast.program.body = ast.program.body.filter((node) => {
// Handle export declarations
if (t.isExportNamedDeclaration(node)) {
// Remove entire export if it's a type-only export
// e.g., `export type Foo = string`, `export interface Bar {}`, `export type { X } from './y'`
if (node.exportKind === 'type') {
return false
}

// For value exports with mixed specifiers, filter out type-only specifiers
// e.g., `export { value, type TypeOnly }` -> `export { value }`
if (node.specifiers.length > 0) {
node.specifiers = node.specifiers.filter((specifier) => {
if (t.isExportSpecifier(specifier)) {
return specifier.exportKind !== 'type'
}
return true
})

// If all specifiers were removed, remove the entire export declaration
// (unless it has a declaration like `export const x = 1`)
if (node.specifiers.length === 0 && !node.declaration) {
return false
}
}
}

// Handle type-only export-all declarations
// e.g., `export type * from './module'`
if (t.isExportAllDeclaration(node)) {
if (node.exportKind === 'type') {
return false
}
}

// Handle import declarations
if (t.isImportDeclaration(node)) {
// Remove entire import if it's a type-only import
// e.g., `import type { Foo } from './module'`
if (node.importKind === 'type') {
return false
}

// For value imports with mixed specifiers, filter out type-only specifiers
// e.g., `import { value, type TypeOnly } from './module'` -> `import { value }`
if (node.specifiers.length > 0) {
node.specifiers = node.specifiers.filter((specifier) => {
if (t.isImportSpecifier(specifier)) {
return specifier.importKind !== 'type'
}
return true
})

// If all specifiers were removed, remove the entire import declaration
if (node.specifiers.length === 0) {
return false
}
}
}

return true
})
}
Comment on lines +45 to +131
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if these TypeScript syntaxes exist in the repo
echo "=== Checking for 'export default interface/type' syntax ==="
rg -nP "export\s+default\s+(interface|type)\b" --type=ts --type=tsx

echo ""
echo "=== Checking for 'import type Foo = require' syntax ==="
rg -nP "import\s+type\s+\w+\s*=\s*require" --type=ts --type=tsx

Repository: TanStack/router

Length of output: 248


🏁 Script executed:

#!/bin/bash
# Check if these TypeScript syntaxes exist in the repo
echo "=== Checking for 'export default interface/type' syntax ==="
rg -nP "export\s+default\s+(interface|type)\b" packages/router-utils/src/

echo ""
echo "=== Checking for 'import type.*require' syntax ==="
rg -nP "import\s+type\s+\w+\s*=\s*require" packages/router-utils/src/

echo ""
echo "=== Broader search for TSImportEqualsDeclaration usage in entire codebase ==="
rg -n "import\s+type\s+\w+\s*=" --type=ts

echo ""
echo "=== Check for export default with type/interface in whole repo ==="
rg -n "export\s+default\s+(type|interface)" --type=ts

Repository: TanStack/router

Length of output: 317


🏁 Script executed:

#!/bin/bash
# Check where stripTypeExports is used
echo "=== Usages of stripTypeExports ==="
rg -n "stripTypeExports" packages/router-utils/

echo ""
echo "=== Check test files for stripTypeExports ==="
fd -e test.ts -e test.tsx -e spec.ts packages/router-utils/ | head -20

echo ""
echo "=== Look for test files in router-utils ==="
find packages/router-utils -name "*.test.*" -o -name "*.spec.*" | head -20

echo ""
echo "=== Examine the ast.ts file context ==="
head -50 packages/router-utils/src/ast.ts

Repository: TanStack/router

Length of output: 2804


🏁 Script executed:

#!/bin/bash
# Read the test file to see what cases are tested
echo "=== stripTypeExports test file ==="
cat packages/router-utils/tests/stripTypeExports.test.ts

Repository: TanStack/router

Length of output: 9056


Handle edge cases for export default type declarations and TypeScript import-equals syntax.

stripTypeExports currently covers ExportNamedDeclaration, ExportAllDeclaration, and ImportDeclaration only. While export default interface/type and import type Foo = require(...) syntaxes don't currently appear in the codebase, they are valid TypeScript patterns that would not be stripped if introduced, potentially allowing server-only imports to remain.

Proposed patch
   ast.program.body = ast.program.body.filter((node) => {
+    // Handle default type-only exports: `export default interface Foo {}` / `export default type Foo = ...`
+    if (t.isExportDefaultDeclaration(node)) {
+      const decl = node.declaration
+      if (t.isTSInterfaceDeclaration(decl) || t.isTSTypeAliasDeclaration(decl)) {
+        return false
+      }
+    }
+
+    // Handle TS import-equals type-only: `import type Foo = require('./module')`
+    if (t.isTSImportEqualsDeclaration(node) && node.isTypeOnly) {
+      return false
+    }
🤖 Prompt for AI Agents
In `@packages/router-utils/src/ast.ts` around lines 45 - 131, The stripTypeExports
function currently misses TypeScript's "export default type/interface" and the
import-equals type-only form; update stripTypeExports to also filter out
t.isExportDefaultDeclaration nodes whose declaration is a TSTypeAliasDeclaration
or TSInterfaceDeclaration (or whose exportKind === 'type') and to filter out
t.isTSImportEqualsDeclaration nodes when node.isTypeOnly is true so type-only
import-equals are removed; modify the AST filtering logic in stripTypeExports to
check for t.isExportDefaultDeclaration and t.isTSImportEqualsDeclaration and
return false for those type-only cases (and preserve other behavior for
mixed/value declarations).


// Re-export findReferencedIdentifiers from babel-dead-code-elimination
export { findReferencedIdentifiers }

/**
* Performs dead code elimination on the AST, with TypeScript type stripping.
*
* This is a wrapper around babel-dead-code-elimination that first strips
* TypeScript type-only exports and imports. This is necessary because
* babel-dead-code-elimination doesn't handle type exports, which can cause
* imports to be retained when they're only referenced by type exports.
*
* @param ast - The Babel AST to mutate
* @param candidates - Optional set of identifier paths to consider for removal.
* If provided, only these identifiers will be candidates for removal.
* This should be the result of `findReferencedIdentifiers(ast)` called
* before any AST transformations.
*/
export function deadCodeElimination(
ast: ParseResult<_babel_types.File>,
candidates?: ReturnType<typeof findReferencedIdentifiers>,
): void {
// First strip TypeScript type-only exports and imports
stripTypeExports(ast)

// Then run the original dead code elimination
_deadCodeElimination(ast, candidates)
}
8 changes: 7 additions & 1 deletion packages/router-utils/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
export { parseAst, generateFromAst } from './ast'
export {
parseAst,
generateFromAst,
deadCodeElimination,
findReferencedIdentifiers,
stripTypeExports,
} from './ast'
export type { ParseAstOptions, ParseAstResult, GeneratorResult } from './ast'
export { logDiff } from './logger'

Expand Down
Loading
Loading