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
105 changes: 105 additions & 0 deletions src/bundler/LinkerContext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3049,6 +3049,15 @@ impl<'a> LinkerContext<'a> {
}
}

// A destructuring of an import namespace reads like a member
// access, which is side-effect free. The parser cannot see that
// the initializer is a namespace, so refine its verdict here.
if !can_be_removed_if_unused
&& self.part_is_removable_namespace_destructuring(source_index, part)
{
can_be_removed_if_unused = true;
}
Comment thread
robobun marked this conversation as resolved.

// The automatic JSX runtime import is synthesized by the parser; it
// exists only so lowered JSX can reference `jsx`/`jsxDEV`/etc. If no
// live part references those symbols the import must not be kept
Expand Down Expand Up @@ -4299,6 +4308,102 @@ impl<'a> LinkerContext<'a> {
&& exports.contains(b"default")
}

/// `const { a } = ns` where `ns` is an import namespace. The parser
/// keeps such a part because a pattern over an arbitrary object can run
/// getters, but the linker knows `ns` is a module namespace, so every
/// key reads like `ns.a` and is side-effect free. True when each
/// declaration destructures plain string keys into identifiers (no
/// computed key, no rest, no default, no nested pattern) out of an
/// import namespace.
fn part_is_removable_namespace_destructuring(
&self,
source_index: crate::IndexInt,
part: &Part,
) -> bool {
// With a direct eval() in the file, the parser pins every
// symbol-declaring part: eval'd code can reference the bindings.
if self.graph.ast.items_module_scope()[source_index as usize].contains_direct_eval {
return false;
}
let stmts = part.stmts.slice();
if stmts.is_empty() {
return false;
}
stmts.iter().all(|stmt| {
let bun_ast::StmtData::SLocal(local) = &stmt.data else {
return false;
};
if matches!(
local.kind,
bun_ast::s::Kind::KUsing | bun_ast::s::Kind::KAwaitUsing
) {
return false;
}
local.decls.slice().iter().all(|decl| {
let bun_ast::b::B::BObject(pattern) = decl.binding.data else {
return false;
};
let Some(value) = &decl.value else {
return false;
};
if !self.value_is_import_namespace(source_index, value) {
return false;
}
pattern.properties().iter().all(|property| {
!property.flags.contains(bun_ast::flags::Property::IsSpread)
&& !property
.flags
.contains(bun_ast::flags::Property::IsComputed)
&& property.default_value.is_none()
&& matches!(property.key.data, bun_ast::ExprData::EString(_))
&& matches!(property.value.data, bun_ast::b::B::BIdentifier(_))
})
})
})
}

/// Does `value` evaluate to a module namespace: a star import's binding,
/// an import that resolved to another module's namespace (`export * as`),
/// or a `require()` that `unwrap_commonjs_to_esm` turned into an import?
fn value_is_import_namespace(&self, source_index: crate::IndexInt, value: &Expr) -> bool {
let id = source_index as usize;
let ref_ = match &value.data {
bun_ast::ExprData::EIdentifier(identifier) => identifier.ref_,
// A named import that holds a namespace (`export * as`) prints as
// an import identifier.
bun_ast::ExprData::EImportIdentifier(identifier) => identifier.ref_,
bun_ast::ExprData::ERequireString(require) => {
return require.unwrapped_id.get().is_some();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
_ => return false,
};
// A require() lifted into an import binds an ordinary local, so user
// code can rebind it to an object with getters. Only a binding that
// is never assigned still holds the namespace. A `var` can also be
// re-initialized by a duplicate declaration or a `for (var ns of ..)`
// head, which the parser does not record as an assignment, so a
// hoisted symbol is never trusted.
match self.graph.symbols.get_const(ref_) {
Some(symbol)
if !symbol.has_been_assigned_to()
&& !matches!(
symbol.kind,
bun_ast::symbol::Kind::Hoisted | bun_ast::symbol::Kind::HoistedFunction
) => {}
_ => return false,
}
Comment thread
robobun marked this conversation as resolved.
if let Some(named_import) = self.graph.ast.items_named_imports()[id].get(&ref_) {
if named_import.alias_is_star {
return true;
}
}
if let Some(import_data) = self.graph.meta.items_imports_to_bind()[id].get(&ref_) {
let target = import_data.data;
return self.is_esm_namespace_ref(target.source_index.get(), target.import_ref);
}
false
}

/// Resolves every named import in one file to its matching export,
/// recording the bindings in `imports_to_bind`.
pub(crate) fn match_imports_with_exports_for_file(
Expand Down
132 changes: 132 additions & 0 deletions test/bundler/esbuild/dce.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2199,6 +2199,138 @@ describe("bundler", () => {
stdout: "EFFECT1\nEFFECT2\nEFFECT3\nEFFECT3\nEFFECT1",
},
});
itBundled("dce/DCEOfDestructuringOfImportNamespace", {
files: {
"/entry.js": /* js */ `
import * as ns from './lib.js'
import * as cjs from './cjs.js'
import { sub } from './reexport.js'
import * as ns2 from './lib3.js'

// An unused destructuring of a module namespace reads like a member
// access and is side-effect free
const { removeMe1 } = ns
const { shorthand: removeMe2, other: removeMe3 } = ns
var { x: removeMe4 } = cjs
let { inner: removeMe5 } = sub

// Anything that can run user code must stay
const { [KEEP0]: KEEP1 } = ns2
const { ...KEEP2 } = ns2
const { nested: { KEEP3 } } = ns2
const { withDefault: KEEP4 = keep4() } = ns2
const plain = keep5()
const { KEEP6 } = plain
`,
"/lib.js": /* js */ `
export const removeMe1 = 1
export const shorthand = 2, other = 3
`,
"/cjs.js": /* js */ `
exports.x = 1
`,
"/reexport.js": /* js */ `
export * as sub from './lib2.js'
`,
"/lib2.js": /* js */ `
export const inner = 7
`,
"/lib3.js": /* js */ `
export const nested = { a: 6 }, withDefault = 5
`,
},
dce: true,
});
itBundled("dce/DestructuringOfImportNamespaceRuntime", {
files: {
"/entry.js": /* js */ `
import * as ns from './lib.js'
const { one, two: renamed } = ns
const { unused } = ns
console.log(one(), renamed)
`,
"/lib.js": /* js */ `
export function one() { return 1 }
export const two = 2
export const unused = 3
`,
},
run: {
stdout: "1 2",
},
});
itBundled("dce/DCEOfDestructuringOfUnwrappedRequire", {
files: {
"/entry.js": /* js */ `
export {}
const { x: removeMe1 } = require('react')
KEEP1()
`,
"/node_modules/react/index.js": /* js */ `
exports.x = 1
`,
"/node_modules/react/package.json": `{ "name": "react", "version": "19.0.0" }`,
},
dce: true,
});
// A direct eval() pins every symbol-declaring part: the eval'd code can
// reference the destructured binding by name.
itBundled("dce/DestructuringOfImportNamespaceDirectEval", {
files: {
"/entry.js": /* js */ `
import * as ns from './lib.js'
const { helper } = ns
eval('helper()')
`,
"/lib.js": /* js */ `
export function helper() { console.log("HELPER RAN") }
`,
},
run: {
stdout: "HELPER RAN",
},
});
// A `for (var ns of ...)` head re-initializes the lifted namespace local
// without an assignment the parser records, so a hoisted symbol is never
// treated as a namespace and the destructuring stays.
itBundled("dce/DestructuringOfForOfReboundUnwrappedRequire", {
files: {
"/entry.js": /* js */ `
export {}
var ns = require('react')
for (var ns of [{ get x() { console.log("EFFECT") } }]) {}
const { x } = ns
console.log("entry")
`,
"/node_modules/react/index.js": /* js */ `
exports.x = 1
`,
"/node_modules/react/package.json": `{ "name": "react", "version": "19.0.0" }`,
},
run: {
stdout: "EFFECT\nentry",
},
});
// A lifted require() namespace is an ordinary local. When user code
// rebinds it, the value can hold getters, so the destructuring must stay.
itBundled("dce/DestructuringOfReboundUnwrappedRequire", {
files: {
"/entry.js": /* js */ `
export {}
var ns = require('react')
ns = { get x() { console.log("EFFECT"); return 2 } }
const { x } = ns
console.log("entry")
`,
"/node_modules/react/index.js": /* js */ `
exports.x = 1
`,
"/node_modules/react/package.json": `{ "name": "react", "version": "19.0.0" }`,
},
run: {
stdout: "EFFECT\nentry",
},
});
itBundled("dce/TreeShakingLoweredClassStaticField", {
files: {
"/entry.js": /* js */ `
Expand Down
Loading