Compare commits

...

2 Commits

Author SHA1 Message Date
Claude Bot
d0cb334703 Add documentation for dynamic import tree-shaking work
This commit documents the research and partial implementation of tree-shaking
for dynamic imports with static property access.

Parser-side changes are complete:
- Tracks bindings from dynamic imports
- Converts property accesses to ImportIdentifier nodes
- Records accessed properties for potential tree-shaking

Bundler-side changes are still needed to actually perform the tree-shaking.
See DYNAMIC_IMPORT_TREESHAKING_NOTES.md for detailed analysis and next steps.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-27 01:29:40 +00:00
Claude Bot
754f9f96ba WIP: Add parser support for tree-shaking dynamic imports with static property access
This commit adds the foundation for tree-shaking unused exports from dynamic imports
when only specific properties are accessed statically. For example:

  const foo = await import("./bar");
  console.log(foo.baz);  // Only 'baz' should be included, not other exports

Changes made:
- Add dynamic_import_ref field to Symbol to track bindings from dynamic imports
- Detect `const foo = await import(...)` patterns and mark the symbol
- Convert static property accesses (foo.baz) to ImportIdentifier nodes
- Add tests for the expected behavior

Current status:
- Parser correctly identifies and converts property accesses to ImportIdentifiers
- Bundler still includes all exports (tree-shaking not yet working end-to-end)

Next steps needed:
- Modify bundler/linker to use ImportIdentifier info for dynamic imports
- Handle destructuring patterns: const { a, b } = await import(...)
- Handle computed property access (should keep all exports)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-27 01:17:38 +00:00
5 changed files with 334 additions and 1 deletions

View File

@@ -0,0 +1,118 @@
# Dynamic Import Tree-Shaking Implementation Notes
## Summary
This document describes the work done to implement tree-shaking for dynamic imports with static property access in Bun's bundler.
## Problem Statement
When code uses dynamic imports with static property access patterns like:
```javascript
const foo = await import("./bar");
console.log(foo.baz); // Only 'baz' should be included, not other exports
```
Currently, the bundler includes ALL exports from the dynamically imported module, even though we can statically determine that only specific exports are used.
## Implementation Progress
### ✅ Completed: Parser-Side Changes
1. **Symbol Tracking for Dynamic Imports** (`src/ast/Symbol.zig`)
- Added `dynamic_import_ref` field to track when a binding comes from a dynamic import
- Allows the parser to identify which symbols are dynamic import results
2. **Detection of Dynamic Import Bindings** (`src/ast/visit.zig`)
- Detects patterns like `const foo = await import("./bar")`
- Marks the binding symbol with the import record index
3. **Property Access Transformation** (`src/ast/visitExpr.zig`)
- When accessing properties on dynamic import bindings (e.g., `foo.baz`)
- Converts these to `ImportIdentifier` nodes
- Tracks accessed properties in `import_items_for_namespace` map
- Properly sets up namespace aliases for the import items
4. **Test Suite** (`test/bundler/bundler_treeshake_dynamic.test.ts`)
- Comprehensive tests for expected behavior
- Tests multiple access patterns and edge cases
### ❌ Remaining Work: Bundler-Side Changes
The parser correctly identifies and transforms the AST, but the bundler still includes all exports from dynamic imports. The core issue is in how the bundler's linker handles dynamic imports differently from static imports.
#### Key Findings:
1. **Dynamic Import Processing** (`src/bundler/linker_context/scanImportsAndExports.zig`)
- Line 737-744: Dynamic imports always generate a dependency on the entire exports object
- Static imports (`kind == .stmt`) can use specific imports via `import_items_for_namespace`
- Dynamic imports (`kind == .dynamic`) don't check this map
2. **Fundamental Challenge**:
- Dynamic imports are resolved at runtime, not bundle time
- The bundler must be conservative and include the entire module
- Even with our ImportIdentifier tracking, the bundler doesn't know if the dynamic import will actually execute
3. **Potential Solutions**:
**Option A: Limited Tree-Shaking for Guaranteed Dynamic Imports**
- Only optimize patterns where we KNOW the import will execute
- Example: Top-level `const foo = await import("./bar")`
- Requires flow analysis to ensure the import isn't conditional
**Option B: Create Separate Chunks with Lazy Loading**
- Split dynamically imported modules into separate chunks
- Each chunk only includes the exports that are statically known to be used
- Requires significant bundler architecture changes
**Option C: Transform to Static Imports**
- During bundling, convert guaranteed dynamic imports to static imports
- Only works for top-level await patterns
- Loses the lazy-loading benefit of dynamic imports
## Next Steps
1. **Decide on Approach**: The Bun team needs to decide which approach aligns with the bundler's architecture and goals.
2. **Implement Bundler Changes**: Based on the chosen approach, modify:
- `scanImportsAndExports.zig` to check `import_items_for_namespace` for dynamic imports
- Potentially add flow analysis to detect guaranteed dynamic imports
- Update how parts/dependencies are created for dynamic imports
3. **Handle Edge Cases**:
- Destructuring: `const { a, b } = await import("./lib")`
- Computed property access: `mod[key]` (should keep all exports)
- Conditional imports: `if (condition) { await import("./lib") }`
- Re-exports from dynamic imports
4. **Performance Testing**: Ensure tree-shaking doesn't significantly impact bundle time.
## Technical Details
### How Static Imports Work
1. Parser creates `ImportIdentifier` nodes for named imports
2. `import_items_for_namespace` maps namespace symbols to accessed properties
3. Bundler's `scanImportsAndExports` only imports used symbols via `generateSymbolImportAndUse`
4. Unused exports are never marked as live during tree-shaking
### How Dynamic Imports Currently Work
1. Parser creates `E.Import` expression nodes
2. Bundler always imports the entire exports object for dynamic imports
3. All exports from the module are included in the bundle
4. No tree-shaking occurs even if usage is statically analyzable
### What Our Changes Do
1. Track when a binding comes from a dynamic import
2. Convert property accesses to `ImportIdentifier` nodes
3. Record accessed properties in `import_items_for_namespace`
4. The bundler infrastructure is ready but not yet using this information
## Testing
Run the test suite with:
```bash
bun bd test test/bundler/bundler_treeshake_dynamic.test.ts
```
Currently, tests fail because the bundler doesn't tree-shake dynamic imports yet.
## References
- Original feature request: [Issue/Discussion needed]
- Related ESBuild issue: https://github.com/evanw/esbuild/issues/1591
- Dynamic import spec: https://tc39.es/ecma262/#sec-import-calls

View File

@@ -138,8 +138,14 @@ remove_overwritten_function_declaration: bool = false,
/// Used in HMR to decide when live binding code is needed.
has_been_assigned_to: bool = false,
/// Tracks if this symbol is a binding from a dynamic import.
/// Used for tree-shaking: when accessing properties on this symbol,
/// they should be converted to ImportIdentifier for better tree-shaking.
/// e.g., `const foo = await import("bar"); foo.baz` -> ImportIdentifier for baz
dynamic_import_ref: Ref = Ref.None,
comptime {
bun.assert_eql(@sizeOf(Symbol), 88);
bun.assert_eql(@sizeOf(Symbol), 96);
bun.assert_eql(@alignOf(Symbol), @alignOf([]const u8));
}

View File

@@ -190,6 +190,27 @@ pub fn Visit(
.is_immediately_assigned_to_decl = true,
});
// Track if this binding comes from a dynamic import for tree-shaking
// e.g., const foo = await import("./bar")
if (decl.value) |value| {
if (value.data == .e_await) {
if (value.data.e_await.value.data == .e_import) {
// This is `await import(...)`, mark the binding symbol
if (decl.binding.data == .b_identifier) {
const symbol_ref = decl.binding.data.b_identifier.ref;
const import_expr = value.data.e_await.value.data.e_import;
// Store the import record index in the symbol
// We'll use a special ref to indicate this is from a dynamic import
p.symbols.items[symbol_ref.innerIndex()].dynamic_import_ref = Ref.init(
@as(u31, @truncate(import_expr.import_record_index)),
0,
true,
);
}
}
}
}
if (p.options.features.react_fast_refresh) {
// When hooks are immediately assigned to something, we need to hash the binding.
if (p.react_refresh.last_hook_seen) |last_hook| {

View File

@@ -876,6 +876,64 @@ pub fn VisitExpr(
.property_access_for_method_call_maybe_should_replace_with_undefined = in.property_access_for_method_call_maybe_should_replace_with_undefined,
});
// Check if the target is a binding from a dynamic import
// If so, convert property access to ImportIdentifier for tree-shaking
if (e_.target.data == .e_identifier) {
const target_ref = e_.target.data.e_identifier.ref;
const symbol = &p.symbols.items[target_ref.innerIndex()];
// Check if this symbol comes from a dynamic import
if (symbol.dynamic_import_ref.tag != .invalid) {
// The dynamic_import_ref we stored is actually the import record index encoded as a Ref
// We use the target_ref itself as the namespace ref since it represents the imported module
const namespace_ref = target_ref;
// Get or create the import items map for this namespace
const items_map = p.import_items_for_namespace.getPtr(namespace_ref) orelse brk: {
p.import_items_for_namespace.put(
p.allocator,
namespace_ref,
ImportItemForNamespaceMap.init(p.allocator)
) catch unreachable;
break :brk p.import_items_for_namespace.getPtr(namespace_ref).?;
};
// Check if we already have a ref for this property
const existing_ref = items_map.get(e_.name);
if (existing_ref) |ref| {
// Use the existing ref
return p.newExpr(E.ImportIdentifier{
.ref = ref.ref.?,
.was_originally_identifier = false,
}, expr.loc);
}
// Create a new ref for the imported member
const member_ref = p.newSymbol(.import, e_.name) catch unreachable;
// Track the import item in the map
items_map.put(e_.name, LocRef{
.loc = expr.loc,
.ref = member_ref,
}) catch unreachable;
// Mark the member as an import item
p.symbols.items[member_ref.innerIndex()].namespace_alias = G.NamespaceAlias{
.namespace_ref = namespace_ref,
.alias = e_.name,
.was_originally_property_access = true,
.import_record_index = symbol.dynamic_import_ref.innerIndex(),
};
p.symbols.items[member_ref.innerIndex()].import_item_status = .generated;
// Return an ImportIdentifier instead of a Dot expression
return p.newExpr(E.ImportIdentifier{
.ref = member_ref,
.was_originally_identifier = false,
}, expr.loc);
}
}
// 'require.resolve' -> .e_require_resolve_call_target
if (e_.target.data == .e_require_call_target and
strings.eqlComptime(e_.name, "resolve"))
@@ -1652,8 +1710,10 @@ const js_parser = bun.js_parser;
const ExprIn = js_parser.ExprIn;
const FnOrArrowDataVisit = js_parser.FnOrArrowDataVisit;
const IdentifierOpts = js_parser.IdentifierOpts;
const ImportItemForNamespaceMap = js_parser.ImportItemForNamespaceMap;
const JSXTransformType = js_parser.JSXTransformType;
const KnownGlobal = js_parser.KnownGlobal;
const LocRef = js_ast.LocRef;
const Prefill = js_parser.Prefill;
const PrependTempRefsOpts = js_parser.PrependTempRefsOpts;
const ReactRefresh = js_parser.ReactRefresh;

View File

@@ -0,0 +1,128 @@
import { describe } from "bun:test";
import { itBundled } from "./expectBundled";
// Tests for tree-shaking dynamic imports with static property access
// The goal is to convert `const foo = await import("bar"); foo.baz` to ImportIdentifier
// so that unused exports from "bar" can be tree-shaken out
describe("bundler", () => {
itBundled("dce/DynamicImportWithStaticPropertyAccess", {
files: {
"/entry.ts": /* js */ `
const foo = await import("./bar");
console.log(foo.baz);
`,
"/bar.ts": /* js */ `
export const baz = "used";
export const qux = "unused";
export const quux = "also_unused";
export function unusedFn() {
return "this_should_be_removed";
}
`,
},
dce: true,
run: {
stdout: "used",
},
});
itBundled("dce/DynamicImportWithMultipleStaticProperties", {
files: {
"/entry.ts": /* js */ `
const mod = await import("./lib");
console.log(mod.foo);
console.log(mod.bar);
`,
"/lib.ts": /* js */ `
export const foo = "kept1";
export const bar = "kept2";
export const baz = "removed1";
export const qux = "removed2";
`,
},
dce: true,
run: {
stdout: "kept1\nkept2",
},
});
itBundled("dce/DynamicImportWithDestructuring", {
files: {
"/entry.ts": /* js */ `
const mod = await import("./lib");
const { a, b } = mod;
console.log(a);
console.log(b);
`,
"/lib.ts": /* js */ `
export const a = "used_a";
export const b = "used_b";
export const c = "unused_c";
export const d = "unused_d";
`,
},
dce: true,
run: {
stdout: "used_a\nused_b",
},
});
itBundled("dce/DynamicImportKeepAllWithDynamicAccess", {
files: {
"/entry.ts": /* js */ `
const mod = await import("./lib");
const key = "foo";
console.log(mod[key]);
`,
"/lib.ts": /* js */ `
export const foo = "might_be_used";
export const bar = "might_be_used_too";
`,
},
// When using dynamic property access, all exports should be kept
// This test documents the expected behavior - no tree-shaking
run: {
stdout: "might_be_used",
},
});
itBundled("dce/DynamicImportWithDefaultAndNamed", {
files: {
"/entry.ts": /* js */ `
const mod = await import("./lib");
console.log(mod.default);
console.log(mod.named1);
`,
"/lib.ts": /* js */ `
export default "default_export";
export const named1 = "used_named";
export const named2 = "unused_named";
`,
},
dce: true,
run: {
stdout: "default_export\nused_named",
},
});
itBundled("dce/DynamicImportInFunction", {
files: {
"/entry.ts": /* js */ `
async function test() {
const lib = await import("./lib");
return lib.kept;
}
test().then(console.log);
`,
"/lib.ts": /* js */ `
export const kept = "kept_export";
export const removed = "removed_export";
`,
},
dce: true,
run: {
stdout: "kept_export",
},
});
});