Skip to content

Commit dcd79e6

Browse files
fix: strip type exports upon DCE
fixes #6480
1 parent 46fc1e9 commit dcd79e6

9 files changed

Lines changed: 370 additions & 18 deletions

File tree

packages/router-plugin/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,6 @@
114114
"@tanstack/router-generator": "workspace:*",
115115
"@tanstack/router-utils": "workspace:*",
116116
"@tanstack/virtual-file-routes": "workspace:*",
117-
"babel-dead-code-elimination": "^1.0.11",
118117
"chokidar": "^3.6.0",
119118
"unplugin": "^2.1.2",
120119
"zod": "^3.24.2"

packages/router-plugin/src/core/code-splitter/compilers.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@ import * as t from '@babel/types'
22
import babel from '@babel/core'
33
import * as template from '@babel/template'
44
import {
5+
generateFromAst,
6+
parseAst,
57
deadCodeElimination,
68
findReferencedIdentifiers,
7-
} from 'babel-dead-code-elimination'
8-
import { generateFromAst, parseAst } from '@tanstack/router-utils'
9+
} from '@tanstack/router-utils'
910
import { tsrSplit } from '../constants'
1011
import { routeHmrStatement } from '../route-hmr-statement'
1112
import { createIdentifier } from './path-ids'
@@ -430,6 +431,7 @@ export function compileCodeSplitReferenceRoute(
430431
if (!modified) {
431432
return null
432433
}
434+
433435
deadCodeElimination(ast, refIdents)
434436

435437
// if there are exported identifiers, then we need to add a warning

packages/router-utils/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,13 +66,14 @@
6666
"@babel/core": "^7.28.5",
6767
"@babel/generator": "^7.28.5",
6868
"@babel/parser": "^7.28.5",
69+
"@babel/types": "^7.28.5",
6970
"ansis": "^4.1.0",
71+
"babel-dead-code-elimination": "^1.0.11",
7072
"diff": "^8.0.2",
7173
"pathe": "^2.0.3",
7274
"tinyglobby": "^0.2.15"
7375
},
7476
"devDependencies": {
75-
"@babel/types": "^7.28.5",
7677
"@types/babel__core": "^7.20.5",
7778
"@types/babel__generator": "^7.27.0",
7879
"@types/diff": "^7.0.2"

packages/router-utils/src/ast.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { parse } from '@babel/parser'
22
import _generate from '@babel/generator'
3+
import * as t from '@babel/types'
4+
import {
5+
deadCodeElimination as _deadCodeElimination,
6+
findReferencedIdentifiers,
7+
} from 'babel-dead-code-elimination'
38
import type { GeneratorOptions, GeneratorResult } from '@babel/generator'
49
import type { ParseResult, ParserOptions } from '@babel/parser'
510
import type * as _babel_types from '@babel/types'
@@ -36,3 +41,119 @@ export function generateFromAst(
3641
)
3742
}
3843
export type { GeneratorResult } from '@babel/generator'
44+
45+
/**
46+
* Strips TypeScript type-only exports and imports from an AST.
47+
*
48+
* This is necessary because babel-dead-code-elimination doesn't handle
49+
* TypeScript type exports/imports. When a type export references an import
50+
* that pulls in server-only code, the dead code elimination won't remove
51+
* that import because it sees the type as still referencing it.
52+
*
53+
* This function removes:
54+
* - `export type Foo = ...`
55+
* - `export interface Foo { ... }`
56+
* - `export type { Foo } from './module'`
57+
* - Type specifiers in mixed exports: `export { value, type Foo }` -> `export { value }`
58+
* - `import type { Foo } from './module'`
59+
* - Type specifiers in mixed imports: `import { value, type Foo } from './module'` -> `import { value }`
60+
* - Top-level `type Foo = ...` declarations (non-exported)
61+
* - Top-level `interface Foo { ... }` declarations (non-exported)
62+
*
63+
* @param ast - The Babel AST (or ParseResult) to mutate
64+
*/
65+
export function stripTypeExports(ast: ParseResult<_babel_types.File>): void {
66+
// Filter the program body to remove type-only nodes
67+
ast.program.body = ast.program.body.filter((node) => {
68+
// Remove top-level type alias declarations: `type Foo = string`
69+
if (t.isTSTypeAliasDeclaration(node)) {
70+
return false
71+
}
72+
73+
// Remove top-level interface declarations: `interface Foo { ... }`
74+
if (t.isTSInterfaceDeclaration(node)) {
75+
return false
76+
}
77+
78+
// Handle export declarations
79+
if (t.isExportNamedDeclaration(node)) {
80+
// Remove entire export if it's a type-only export
81+
// e.g., `export type Foo = string`, `export interface Bar {}`, `export type { X } from './y'`
82+
if (node.exportKind === 'type') {
83+
return false
84+
}
85+
86+
// For value exports with mixed specifiers, filter out type-only specifiers
87+
// e.g., `export { value, type TypeOnly }` -> `export { value }`
88+
if (node.specifiers.length > 0) {
89+
node.specifiers = node.specifiers.filter((specifier) => {
90+
if (t.isExportSpecifier(specifier)) {
91+
return specifier.exportKind !== 'type'
92+
}
93+
return true
94+
})
95+
96+
// If all specifiers were removed, remove the entire export declaration
97+
// (unless it has a declaration like `export const x = 1`)
98+
if (node.specifiers.length === 0 && !node.declaration) {
99+
return false
100+
}
101+
}
102+
}
103+
104+
// Handle import declarations
105+
if (t.isImportDeclaration(node)) {
106+
// Remove entire import if it's a type-only import
107+
// e.g., `import type { Foo } from './module'`
108+
if (node.importKind === 'type') {
109+
return false
110+
}
111+
112+
// For value imports with mixed specifiers, filter out type-only specifiers
113+
// e.g., `import { value, type TypeOnly } from './module'` -> `import { value }`
114+
if (node.specifiers.length > 0) {
115+
node.specifiers = node.specifiers.filter((specifier) => {
116+
if (t.isImportSpecifier(specifier)) {
117+
return specifier.importKind !== 'type'
118+
}
119+
return true
120+
})
121+
122+
// If all specifiers were removed, remove the entire import declaration
123+
if (node.specifiers.length === 0) {
124+
return false
125+
}
126+
}
127+
}
128+
129+
return true
130+
})
131+
}
132+
133+
// Re-export findReferencedIdentifiers from babel-dead-code-elimination
134+
export { findReferencedIdentifiers }
135+
136+
/**
137+
* Performs dead code elimination on the AST, with TypeScript type stripping.
138+
*
139+
* This is a wrapper around babel-dead-code-elimination that first strips
140+
* TypeScript type-only exports and imports. This is necessary because
141+
* babel-dead-code-elimination doesn't handle type exports, which can cause
142+
* imports to be retained when they're only referenced by type exports.
143+
*
144+
* @param ast - The Babel AST to mutate
145+
* @param candidates - Optional set of identifier paths to consider for removal.
146+
* If provided, only these identifiers will be candidates for removal.
147+
* This should be the result of `findReferencedIdentifiers(ast)` called
148+
* before any AST transformations.
149+
*/
150+
export function deadCodeElimination(
151+
ast: ParseResult<_babel_types.File>,
152+
candidates?: Set<any>,
153+
): void {
154+
// First strip TypeScript type-only exports and imports
155+
stripTypeExports(ast)
156+
157+
// Then run the original dead code elimination
158+
_deadCodeElimination(ast, candidates)
159+
}

packages/router-utils/src/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
export { parseAst, generateFromAst } from './ast'
1+
export {
2+
parseAst,
3+
generateFromAst,
4+
deadCodeElimination,
5+
findReferencedIdentifiers,
6+
stripTypeExports,
7+
} from './ast'
28
export type { ParseAstOptions, ParseAstResult, GeneratorResult } from './ast'
39
export { logDiff } from './logger'
410

0 commit comments

Comments
 (0)