Files
next.js/test/e2e/app-dir/dynamic-import-tree-shaking/dynamic-import-tree-shaking.test.ts
Tobias KoppersandGitHub c979cefa4a Turbopack: Enable tree-shaking for dynamic imports with destructuring (#89683)
## What?

This PR enables tree-shaking for dynamic imports when the imported module's exports are destructured or accessed via member expressions. Previously, dynamic imports were treated as importing all exports, preventing unused exports from being removed.

## Why?

Dynamic imports with destructuring patterns like `const { cat } = await import('./lib')` or member access like `(await import('./lib')).cat` now properly communicate which specific exports are actually used. This allows bundlers (particularly Turbopack) to tree-shake unused exports from dynamically imported modules, reducing bundle size.

## How?

### Core Changes:

1. **AST Analysis Enhancement** (`turbopack-ecmascript/src/analyzer/graph.rs`):
   - Added new `Effect::DynamicImport` variant that captures destructuring patterns and member access
   - Implemented `extract_dynamic_import_export_names()` to statically analyze destructuring patterns and extract used export names
   - Supports: const/let/var destructuring, renamed imports, nested destructuring, default exports, member access, and empty destructuring

2. **Export Usage Tracking** (`turbopack-core/src/resolve/mod.rs`):
   - Added `ExportUsage::NamedSet` variant to track multiple named exports being used
   - Updated `ModuleExportUsageInfo` to handle merging of multiple named exports

3. **Dynamic Import Reference** (`turbopack-ecmascript/src/references/esm/dynamic.rs`):
   - Extended `EsmAsyncAssetReference` with `export_names` field
   - Implemented `binding_usage()` to communicate which exports are used to the module graph

4. **Effect Processing** (`turbopack-ecmascript/src/references/mod.rs`):
   - Refactored dynamic import handling into dedicated `handle_dynamic_import()` function
   - Passes export usage information through the reference system

### Test Coverage:

- **E2E Tests** (`test/e2e/app-dir/dynamic-import-tree-shaking/`): Comprehensive tests for Next.js App Router covering various destructuring patterns
- **Snapshot Tests** (`turbopack-tests/tests/snapshot/basic-tree-shake/`): Turbopack-specific tests validating tree-shaking behavior

### Supported Patterns:

- ✅ Named destructuring: `const { cat } = await import('./lib')`
- ✅ Variable declarations: `var`, `let`, `const`
- ✅ Renamed imports: `const { cat: myCat } = await import('./lib')`
- ✅ Nested destructuring: `const { dogRef: { get } } = await import('./lib')`
- ✅ Default exports: `const { default: val } = await import('./lib')`
- ✅ Empty destructuring: `const {} = await import('./lib')` (side effects only)
- ✅ Member access: `(await import('./lib')).cat`

### Limitations:

- Rest patterns (`const { cat, ...rest }`) conservatively import all exports
- Computed property keys are not statically analyzed
- Member access tree-shaking is Turbopack-only (webpack limitation)

https://claude.ai/code/session_01QWi3udq2LX4GFqJEDaqFFU
2026-02-18 18:55:19 +01:00

213 lines
8.4 KiB
TypeScript

import { nextTestSetup } from 'e2e-utils'
import fs from 'fs'
import path from 'path'
describe('dynamic-import-tree-shaking', () => {
const { next, skipped, isNextStart, isTurbopack } = nextTestSetup({
files: __dirname,
skipDeployment: true,
})
if (skipped) return
// Recursively read all .js files in a directory
function getAllServerFiles(dir: string): string[] {
const results: string[] = []
try {
const entries = fs.readdirSync(dir, { withFileTypes: true })
for (const entry of entries) {
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory()) {
results.push(...getAllServerFiles(fullPath))
} else if (entry.name.endsWith('.js')) {
results.push(fullPath)
}
}
} catch {
// directory doesn't exist
}
return results
}
async function getAllServerContent(): Promise<string> {
const serverDir = path.join(next.testDir, '.next/server')
const files = getAllServerFiles(serverDir)
const contents = await Promise.all(
files.map((f) => fs.promises.readFile(f, 'utf8'))
)
return contents.join('\n')
}
// Verify that each page renders correctly (these should always pass in both dev and production)
it('should render const destructure page', async () => {
const $ = await next.render$('/const-destructure')
expect($('div').text()).toContain('TREESHAKE_CONST_USED')
})
it('should render var destructure page', async () => {
const $ = await next.render$('/var-destructure')
expect($('div').text()).toContain('TREESHAKE_VAR_USED')
})
it('should render let destructure page', async () => {
const $ = await next.render$('/let-destructure')
expect($('div').text()).toContain('TREESHAKE_LET_USED')
})
it('should render rename destructure page', async () => {
const $ = await next.render$('/rename-destructure')
expect($('div').text()).toContain('TREESHAKE_RENAME_USED')
})
it('should render nested destructure page', async () => {
const $ = await next.render$('/nested-destructure')
expect($('div').text()).toContain('TREESHAKE_NESTED_USED')
})
it('should render default destructure page', async () => {
const $ = await next.render$('/default-destructure')
expect($('div').text()).toContain('TREESHAKE_DEFAULT_USED')
})
it('should render empty destructure page', async () => {
const $ = await next.render$('/empty-destructure')
expect($('div').text()).toContain('TREESHAKE_EMPTY_PAGE')
})
it('should render member access page', async () => {
const $ = await next.render$('/member-access')
expect($('div').text()).toContain('TREESHAKE_MEMBER_USED')
})
it('should render webpack-exports-comment page', async () => {
const $ = await next.render$('/webpack-exports-comment')
expect($('div').text()).toContain('TREESHAKE_COMMENT_USED')
})
it('should render rest destructure page', async () => {
const $ = await next.render$('/rest-destructure')
expect($('div').text()).toContain('TREESHAKE_REST_USED')
})
it('should render multiple imports page', async () => {
const $ = await next.render$('/multiple-imports')
expect($('div').text()).toContain('TREESHAKE_MULTI_A_USED')
expect($('div').text()).toContain('TREESHAKE_MULTI_B_USED')
})
it('should render reassign page', async () => {
const $ = await next.render$('/reassign')
expect($('div').text()).toContain('TREESHAKE_REASSIGN_USED')
})
it('should render then-arrow-destructure page', async () => {
const $ = await next.render$('/then-arrow-destructure')
expect($('div').text()).toContain('TREESHAKE_THEN_ARROW_USED')
})
it('should render then-function-destructure page', async () => {
const $ = await next.render$('/then-function-destructure')
expect($('div').text()).toContain('TREESHAKE_THEN_FUNC_USED')
})
// Tree shaking assertions: unused exports should NOT be in the server bundle
// Tree shaking is only enabled in production builds, so skip these in dev mode
if (isNextStart) {
it('should tree-shake unused export with const destructured dynamic import', async () => {
const content = await getAllServerContent()
expect(content).toContain('TREESHAKE_CONST_USED')
expect(content).not.toContain('TREESHAKE_CONST_UNUSED')
})
it('should tree-shake unused export with var destructured dynamic import', async () => {
const content = await getAllServerContent()
expect(content).toContain('TREESHAKE_VAR_USED')
expect(content).not.toContain('TREESHAKE_VAR_UNUSED')
})
it('should tree-shake unused export with let destructured dynamic import', async () => {
const content = await getAllServerContent()
expect(content).toContain('TREESHAKE_LET_USED')
expect(content).not.toContain('TREESHAKE_LET_UNUSED')
})
it('should tree-shake unused export with renamed destructured dynamic import', async () => {
const content = await getAllServerContent()
expect(content).toContain('TREESHAKE_RENAME_USED')
expect(content).not.toContain('TREESHAKE_RENAME_UNUSED')
})
it('should tree-shake unused export with nested destructured dynamic import', async () => {
const content = await getAllServerContent()
expect(content).toContain('TREESHAKE_NESTED_USED')
expect(content).not.toContain('TREESHAKE_NESTED_UNUSED')
})
it('should tree-shake unused export with default destructured dynamic import', async () => {
const content = await getAllServerContent()
expect(content).toContain('TREESHAKE_DEFAULT_USED')
expect(content).not.toContain('TREESHAKE_DEFAULT_UNUSED')
})
it('should tree-shake all exports with empty destructured dynamic import', async () => {
const content = await getAllServerContent()
// Side effects should still be included
expect(content).toContain('TREESHAKE_EMPTY_SIDE_EFFECT')
// But no exports should be included
expect(content).not.toContain('TREESHAKE_EMPTY_USED')
expect(content).not.toContain('TREESHAKE_EMPTY_UNUSED')
})
it('should tree-shake unused export with webpackExports comment', async () => {
const content = await getAllServerContent()
expect(content).toContain('TREESHAKE_COMMENT_USED')
expect(content).not.toContain('TREESHAKE_COMMENT_UNUSED')
})
// Member access on dynamic import is only tree-shaken by Turbopack, not webpack
if (isTurbopack) {
it('should tree-shake unused export with member access on dynamic import', async () => {
const content = await getAllServerContent()
expect(content).toContain('TREESHAKE_MEMBER_USED')
expect(content).not.toContain('TREESHAKE_MEMBER_UNUSED')
})
}
it('should NOT tree-shake with rest destructured dynamic import', async () => {
const content = await getAllServerContent()
expect(content).toContain('TREESHAKE_REST_USED')
// rest elements prevent tree-shaking, so unused exports should still be present
expect(content).toContain('TREESHAKE_REST_UNUSED')
})
it('should tree-shake unused exports with multiple dynamic imports in one file', async () => {
const content = await getAllServerContent()
expect(content).toContain('TREESHAKE_MULTI_A_USED')
expect(content).not.toContain('TREESHAKE_MULTI_A_UNUSED')
expect(content).toContain('TREESHAKE_MULTI_B_USED')
expect(content).not.toContain('TREESHAKE_MULTI_B_UNUSED')
})
it('should NOT tree-shake with reassigned dynamic import', async () => {
const content = await getAllServerContent()
expect(content).toContain('TREESHAKE_REASSIGN_USED')
// re-assignment prevents destructuring analysis, so unused exports should remain
expect(content).toContain('TREESHAKE_REASSIGN_UNUSED')
})
// .then() callback destructuring is only tree-shaken by Turbopack, not webpack
if (isTurbopack) {
it('should tree-shake unused export with .then() arrow destructured dynamic import', async () => {
const content = await getAllServerContent()
expect(content).toContain('TREESHAKE_THEN_ARROW_USED')
expect(content).not.toContain('TREESHAKE_THEN_ARROW_UNUSED')
})
it('should tree-shake unused export with .then() function destructured dynamic import', async () => {
const content = await getAllServerContent()
expect(content).toContain('TREESHAKE_THEN_FUNC_USED')
expect(content).not.toContain('TREESHAKE_THEN_FUNC_UNUSED')
})
}
}
})