diff --git a/docs/bundler/esbuild.mdx b/docs/bundler/esbuild.mdx index 716ae790bd34..8fdd27791b6a 100644 --- a/docs/bundler/esbuild.mdx +++ b/docs/bundler/esbuild.mdx @@ -168,7 +168,7 @@ In Bun's CLI, boolean flags like `--minify` take no argument. Flags that take on | `stdin` | n/a | Not supported | | `supported` | n/a | Not supported | | `target` | n/a | No support for syntax downleveling | -| `treeShaking` | `treeShaking` | Defaults to `true` | +| `treeShaking` | `treeShaking` | Defaults to `true`. A top-level `X.y = v` or `X.prototype.y = v` on a class, function, or object literal that is local to its module and never reassigned is removed with an unused `X` when `v` has no side effects, as Rollup does; esbuild keeps it. A write that would have thrown (a frozen object, a non-writable property) is removed too. | | `tsconfig` | `tsconfig` | | | `write` | n/a | Set to true if `outdir`/`outfile` is set, otherwise false | diff --git a/src/ast/nodes.rs b/src/ast/nodes.rs index 2621bc172b7d..5010f3d453ef 100644 --- a/src/ast/nodes.rs +++ b/src/ast/nodes.rs @@ -1059,8 +1059,10 @@ pub struct Part { pub dependencies: DependencyList, /// If true, this part can be removed if none of the declared symbols are - /// used. If the file containing this part is imported, then all parts that - /// don't have this flag enabled must be included. + /// used, or, for a `X.y = v` part registered under `X` in + /// `top_level_symbols_to_parts`, if `X` is unused. If the file containing + /// this part is imported, then all parts that don't have this flag enabled + /// must be included. pub can_be_removed_if_unused: bool, /// This is used for generated parts that we don't want to be present if they diff --git a/src/bundler/linker_context/mergeSmallChunks.rs b/src/bundler/linker_context/mergeSmallChunks.rs index 2507aa135832..7e1d05d48bd7 100644 --- a/src/bundler/linker_context/mergeSmallChunks.rs +++ b/src/bundler/linker_context/mergeSmallChunks.rs @@ -9,7 +9,8 @@ use crate::{EntryPoint, Index, LinkerContext, WrapKind}; bun_core::define_scoped_log!(debug_merge, MergeChunks, hidden); -/// `Part::can_be_removed_if_unused` (`Parser::stmts_can_be_removed_if_unused`) +/// `Part::can_be_removed_if_unused` (`Parser::stmts_can_be_removed_if_unused`, +/// or `claim_member_assignments` for a `X.y = v` part owned by `X`) /// is the parser's tree-shaking verdict, which also says "keep" for statements /// that do nothing at the top level but must survive for other reasons: /// `export * from` / `export {} from` (the re-exports are tracked separately), diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index e80633c743d0..cf72230da34a 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -5405,7 +5405,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O self.stmts_can_be_removed_if_unused_without_dce_check(stmts) } - fn stmts_can_be_removed_if_unused_without_dce_check(&mut self, stmts: &[Stmt]) -> bool { + pub(crate) fn stmts_can_be_removed_if_unused_without_dce_check( + &mut self, + stmts: &[Stmt], + ) -> bool { for stmt in stmts { match &stmt.data { // These never have side effects @@ -5737,7 +5740,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O self.expr_can_be_removed_if_unused_without_dce_check(expr) } - fn expr_can_be_removed_if_unused_without_dce_check(&mut self, expr: &Expr) -> bool { + pub(crate) fn expr_can_be_removed_if_unused_without_dce_check(&mut self, expr: &Expr) -> bool { if !self.stack_check.is_safe_to_recurse() || self.reported_stack_overflow.get() { self.report_stack_overflow(expr.loc); return false; @@ -8746,6 +8749,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O symbols: &'s [Symbol], part_index: u32, } + // `X.y = v` parts belong to `X`; claimed once the map is complete. + let claim_member_assignments = + self.options.features.dead_code_elimination && self.options.tree_shaking; + let mut member_assignments = BumpVec::new_in(arena); for (part_index, part) in parts.iter_mut().enumerate() { let mut ctx = Ctx { top_level: &mut top_level_symbols_to_parts, @@ -8772,7 +8779,18 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O .push(ctx.part_index); }, ); + if claim_member_assignments + && let Some(candidate) = + self.member_assignment_candidate(part, part_index as u32) + { + member_assignments.push(candidate); + } } + self.claim_member_assignments( + parts.as_mut_slice(), + &mut top_level_symbols_to_parts, + &member_assignments, + ); // Pulling in the exports of this module always pulls in the export part top_level_symbols_to_parts diff --git a/src/js_parser/scan/mod.rs b/src/js_parser/scan/mod.rs index 6730bf35e291..cdcb8cb45ecd 100644 --- a/src/js_parser/scan/mod.rs +++ b/src/js_parser/scan/mod.rs @@ -1,3 +1,4 @@ pub mod scan_imports; +pub(crate) mod scan_member_assignments; pub mod scan_side_effects; pub(crate) mod scan_symbols; diff --git a/src/js_parser/scan/scan_member_assignments.rs b/src/js_parser/scan/scan_member_assignments.rs new file mode 100644 index 000000000000..1f4649d51133 --- /dev/null +++ b/src/js_parser/scan/scan_member_assignments.rs @@ -0,0 +1,583 @@ +//! A top-level `X.y = v` on a local, never reassigned class, function, or object literal lives exactly when `X` does. + +use bun_alloc::Arena as Bump; +use bun_alloc::{ArenaVec as BumpVec, ArenaVecExt as _}; +use bun_ast::ast_result::TopLevelSymbolToParts; +use bun_ast::g::PropertyKind; +use bun_ast::{ + self as js_ast, E, Expr, ExprData, Flags, G, Ref, S, Stmt, StmtData, StmtOrExpr, StoreRef, +}; +use bun_collections::{HashMap, VecExt as _}; + +use crate::p::P; + +/// One `X.a.b = v` part. +#[derive(Clone, Copy)] +pub(crate) struct Candidate<'a> { + part_index: u32, + owner: Ref, + /// Outermost first: `[a, b]`. + keys: &'a [&'a [u8]], +} + +#[derive(Clone, Copy)] +enum ClassSource { + Stmt(StoreRef), + Expr(StoreRef), +} + +impl ClassSource { + fn class(&self) -> &G::Class { + match self { + Self::Stmt(stmt) => &stmt.class, + Self::Expr(class) => class, + } + } +} + +#[derive(Clone, Copy)] +enum OwnerShape { + Class(ClassSource), + Function { + is_arrow: bool, + }, + /// The `EObject` initializer. + Object(Expr), +} + +/// `None` caches "does not qualify". +type ShapeCache = HashMap>; + +/// Per parent class, whether no part can mutate it at run time. +type ParentCache = HashMap; + +const MAX_EXTENDS_DEPTH: usize = 32; + +fn key_bytes<'b>(s: &E::EString, arena: &'b Bump) -> &'b [u8] { + bun_core::handle_oom(s.flattened(arena).string(arena)) +} + +fn is_proper_prefix(short: &[&[u8]], long: &[&[u8]]) -> bool { + short.len() < long.len() && short.iter().zip(long).all(|(a, b)| a == b) +} + +/// `X.a['b'].c` to `X` and `[a, b, c]`; a `__proto__` key is never owned. +fn member_chain<'a>(target: Expr, arena: &'a Bump) -> Option<(E::Identifier, &'a [&'a [u8]])> { + let mut keys: BumpVec<'a, &'a [u8]> = BumpVec::new_in(arena); + let mut cur = target; + loop { + let key: &'a [u8] = match cur.data { + ExprData::EDot(dot) => { + if dot.optional_chain.is_some() { + return None; + } + cur = dot.target; + dot.name.slice() + } + ExprData::EIndex(index) => { + if index.optional_chain.is_some() { + return None; + } + let ExprData::EString(s) = index.index.data else { + return None; + }; + cur = index.target; + key_bytes(&s, arena) + } + ExprData::EIdentifier(id) => { + if keys.is_empty() { + return None; + } + keys.reverse(); + return Some((id, keys.into_bump_slice())); + } + _ => return None, + }; + if key == b"__proto__" { + return None; + } + keys.push(key); + } +} + +fn initializer_shape(value: Expr) -> Option { + match value.data { + ExprData::EObject(_) => Some(OwnerShape::Object(value)), + ExprData::EClass(class) => Some(OwnerShape::Class(ClassSource::Expr(class))), + ExprData::EFunction(_) => Some(OwnerShape::Function { is_arrow: false }), + ExprData::EArrow(_) => Some(OwnerShape::Function { is_arrow: true }), + _ => None, + } +} + +fn function_owns_path(is_arrow: bool, keys: &[&[u8]]) -> bool { + match keys { + [_] => true, + [b"prototype", _] => !is_arrow, + _ => false, + } +} + +/// The static or prototype member a class write names. +fn class_member_name<'k>(keys: &[&'k [u8]]) -> Option<&'k [u8]> { + match keys { + [name] | [b"prototype", name] => Some(name), + _ => None, + } +} + +/// Each key but the last names a nested literal; the last is no accessor. +fn object_literal_owns_path(object: Expr, keys: &[&[u8]], arena: &Bump) -> bool { + let mut current = object; + for (i, key) in keys.iter().enumerate() { + let ExprData::EObject(literal) = current.data else { + return false; + }; + let is_last = i + 1 == keys.len(); + let mut next: Option = None; + for property in literal.properties.slice() { + if property.kind == PropertyKind::Spread + || property.flags.contains(Flags::Property::IsSpread) + { + // A spread copies data properties, never accessors. + if is_last { + continue; + } + return false; + } + let Some(property_key) = property.key else { + return false; + }; + let ExprData::EString(s) = property_key.data else { + if is_last && property.kind == PropertyKind::Normal { + continue; + } + return false; + }; + let bytes = key_bytes(&s, arena); + if bytes == b"__proto__" { + return false; + } + if bytes != *key { + continue; + } + match property.kind { + PropertyKind::Normal => { + if !is_last { + next = property.value; + } + } + _ => return false, + } + } + if !is_last { + let Some(n) = next else { + return false; + }; + current = n; + } + } + true +} +impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_ONLY> { + /// The member assignment that alone keeps `part` from being removable. + pub(crate) fn member_assignment_candidate( + &mut self, + part: &js_ast::Part, + part_index: u32, + ) -> Option> { + if part.can_be_removed_if_unused || part.tag != js_ast::PartTag::None { + return None; + } + let mut found: Option> = None; + for stmt in part.stmts.slice() { + if let StmtData::SExpr(s_expr) = &stmt.data + && !s_expr.does_not_affect_tree_shaking + && let Some(candidate) = self.member_assignment(s_expr.value, part_index) + { + if found.is_some() { + return None; + } + found = Some(candidate); + continue; + } + if !self.stmts_can_be_removed_if_unused_without_dce_check(core::slice::from_ref(stmt)) { + return None; + } + } + found + } + + /// Needs every real declaration in `top_level_symbols_to_parts` first. + pub(crate) fn claim_member_assignments( + &self, + parts: &mut [js_ast::Part], + top_level_symbols_to_parts: &mut TopLevelSymbolToParts, + candidates: &[Candidate<'a>], + ) { + if candidates.is_empty() { + return; + } + let mut shapes = ShapeCache::new(); + let mut parents = ParentCache::new(); + let mut claimed: BumpVec<'a, Candidate<'a>> = BumpVec::new_in(self.arena); + for candidate in candidates { + let Some(shape) = self.owner_shape( + candidate.owner, + parts, + top_level_symbols_to_parts, + &mut shapes, + ) else { + continue; + }; + let keys = candidate.keys; + // `X.a = other; X.a.b = v` writes through to `other`. + let prefix_rewritten = candidates + .iter() + .any(|other| other.owner == candidate.owner && is_proper_prefix(other.keys, keys)); + if prefix_rewritten { + continue; + } + if !self.chain_is_owned( + shape, + keys, + parts, + top_level_symbols_to_parts, + candidates, + &mut shapes, + &mut parents, + ) { + continue; + } + claimed.push(*candidate); + } + + for candidate in claimed.iter() { + parts[candidate.part_index as usize].can_be_removed_if_unused = true; + let list = top_level_symbols_to_parts + .entry(candidate.owner) + .or_insert_with(bun_alloc::AstAlloc::vec); + if !list.contains(&candidate.part_index) { + list.push(candidate.part_index); + } + } + } + + fn follow_symbol_link(&self, mut ref_: Ref) -> Ref { + loop { + let symbol = &self.symbols[ref_.inner_index() as usize]; + if !symbol.has_link() { + return ref_; + } + ref_ = symbol.link.get(); + } + } + + /// `X.a.b = v` with a side-effect-free `v`, rooted at a local identifier. + fn member_assignment(&mut self, value: Expr, part_index: u32) -> Option> { + let ExprData::EBinary(bin) = value.data else { + return None; + }; + if bin.op != js_ast::op::Code::BinAssign { + return None; + } + let (root, keys) = member_chain(bin.left, self.arena)?; + if root.must_keep_due_to_with_stmt() { + return None; + } + if !self.expr_can_be_removed_if_unused_without_dce_check(&bin.right) { + return None; + } + Some(Candidate { + part_index, + owner: self.follow_symbol_link(root.ref_), + keys, + }) + } + + fn owner_shape( + &self, + owner: Ref, + parts: &[js_ast::Part], + top_level_symbols_to_parts: &TopLevelSymbolToParts, + shapes: &mut ShapeCache, + ) -> Option { + let owner = self.follow_symbol_link(owner); + if let Some(cached) = shapes.get(&owner) { + return *cached; + } + let shape = self.compute_owner_shape(owner, parts, top_level_symbols_to_parts); + shapes.insert(owner, shape); + shape + } + + fn compute_owner_shape( + &self, + owner: Ref, + parts: &[js_ast::Part], + top_level_symbols_to_parts: &TopLevelSymbolToParts, + ) -> Option { + let symbol = &self.symbols[owner.inner_index() as usize]; + if symbol.has_been_assigned_to() { + return None; + } + match symbol.kind { + js_ast::symbol::Kind::Hoisted + | js_ast::symbol::Kind::HoistedFunction + | js_ast::symbol::Kind::GeneratorOrAsyncFunction + | js_ast::symbol::Kind::Class + | js_ast::symbol::Kind::Constant + | js_ast::symbol::Kind::Other => {} + _ => return None, + } + let declaring_parts = top_level_symbols_to_parts.get(&owner)?; + let [declaring_part] = declaring_parts.as_slice() else { + return None; + }; + let declaring_part = &parts[*declaring_part as usize]; + // A static block or initializer with side effects can install accessors. + if !declaring_part.can_be_removed_if_unused { + return None; + } + let mut shape: Option = None; + for stmt in declaring_part.stmts.slice() { + let Some(found) = self.declaration_shape(stmt, owner) else { + continue; + }; + if shape.is_some() { + return None; + } + shape = Some(found); + } + shape + } + + /// The shape `stmt` gives `owner`, if `stmt` declares it. + fn declaration_shape(&self, stmt: &Stmt, owner: Ref) -> Option { + match &stmt.data { + StmtData::SFunction(func) => { + let name = func.func.name?; + (self.follow_symbol_link(name.ref_) == owner) + .then_some(OwnerShape::Function { is_arrow: false }) + } + StmtData::SClass(class) => { + let name = class.class.class_name?; + (self.follow_symbol_link(name.ref_) == owner) + .then_some(OwnerShape::Class(ClassSource::Stmt(*class))) + } + StmtData::SLocal(local) => { + for decl in local.decls.slice() { + let js_ast::b::B::BIdentifier(id) = decl.binding.data else { + continue; + }; + if self.follow_symbol_link(id.r#ref) != owner { + continue; + } + return initializer_shape(decl.value?); + } + None + } + StmtData::SExportDefault(export_default) => match &export_default.value { + StmtOrExpr::Stmt(inner) => self.declaration_shape(inner, owner), + StmtOrExpr::Expr(_) => None, + }, + _ => None, + } + } + + /// The write reaches only objects the owner created and runs no accessor. + #[allow(clippy::too_many_arguments)] + fn chain_is_owned( + &self, + shape: OwnerShape, + keys: &[&[u8]], + parts: &[js_ast::Part], + top_level_symbols_to_parts: &TopLevelSymbolToParts, + candidates: &[Candidate<'a>], + shapes: &mut ShapeCache, + parents: &mut ParentCache, + ) -> bool { + match shape { + OwnerShape::Function { is_arrow } => function_owns_path(is_arrow, keys), + OwnerShape::Class(source) => { + let Some(name) = class_member_name(keys) else { + return false; + }; + self.class_chain_declares_no_accessor( + source, + name, + parts, + top_level_symbols_to_parts, + candidates, + shapes, + parents, + ) + } + OwnerShape::Object(object) => object_literal_owns_path(object, keys, self.arena), + } + } + + /// Static or not, on the class or any local class it extends. + #[allow(clippy::too_many_arguments)] + fn class_chain_declares_no_accessor( + &self, + source: ClassSource, + name: &[u8], + parts: &[js_ast::Part], + top_level_symbols_to_parts: &TopLevelSymbolToParts, + candidates: &[Candidate<'a>], + shapes: &mut ShapeCache, + parents: &mut ParentCache, + ) -> bool { + let arena = self.arena; + let mut current = source; + for _ in 0..MAX_EXTENDS_DEPTH { + let class = current.class(); + for property in class.properties.iter() { + if !matches!( + property.kind, + PropertyKind::Get | PropertyKind::Set | PropertyKind::AutoAccessor + ) { + continue; + } + let Some(key) = property.key else { + return false; + }; + match key.data { + ExprData::EString(s) => { + if key_bytes(&s, arena) == name { + return false; + } + } + // `#name` never collides with a public key. + ExprData::EPrivateIdentifier(_) => {} + _ => return false, + } + } + let Some(extends) = class.extends else { + return true; + }; + let ExprData::EIdentifier(parent) = extends.data else { + return false; + }; + let parent = self.follow_symbol_link(parent.ref_); + let Some(OwnerShape::Class(parent_source)) = + self.owner_shape(parent, parts, top_level_symbols_to_parts, shapes) + else { + return false; + }; + if !self.class_is_only_extended( + parent, + parent_source, + parts, + top_level_symbols_to_parts, + candidates, + shapes, + parents, + ) { + return false; + } + current = parent_source; + } + false + } + + /// Any other use (a call argument, its own methods) may install an accessor a subclass inherits. + #[allow(clippy::too_many_arguments)] + fn class_is_only_extended( + &self, + class_ref: Ref, + source: ClassSource, + parts: &[js_ast::Part], + top_level_symbols_to_parts: &TopLevelSymbolToParts, + candidates: &[Candidate<'a>], + shapes: &mut ShapeCache, + parents: &mut ParentCache, + ) -> bool { + if let Some(&known) = parents.get(&class_ref) { + return known; + } + // A long `extends` chain with a write at each level recurses once per class. + if !self.stack_check.is_safe_to_recurse() { + return false; + } + // A cyclic `extends` chain re-enters here and reads this placeholder. + parents.insert(class_ref, false); + let only_extended = parts.iter().enumerate().all(|(part_index, part)| { + let Some(use_) = part.symbol_uses.get(&class_ref) else { + return true; + }; + if let Some(candidate) = candidates + .iter() + .find(|c| c.part_index as usize == part_index && c.owner == class_ref) + { + // A write that runs a setter is live code that can mutate the class. + return class_member_name(candidate.keys).is_some_and(|name| { + self.class_chain_declares_no_accessor( + source, + name, + parts, + top_level_symbols_to_parts, + candidates, + shapes, + parents, + ) + }); + } + // A subclass with a static block or initializer can reach this class through `this`. + if part.can_be_removed_if_unused + && use_.count_estimate == 1 + && self.class_declaration_parent(part) == Some(class_ref) + { + return true; + } + part.stmts + .slice() + .iter() + .all(|stmt| matches!(stmt.data, StmtData::SExportClause(_))) + }); + parents.insert(class_ref, only_extended); + only_extended + } + + /// The local class a class declaration in `part` extends, if any. + fn class_declaration_parent(&self, part: &js_ast::Part) -> Option { + let mut parent: Option = None; + for stmt in part.stmts.slice() { + let extends: Option = match &stmt.data { + StmtData::SClass(class) => class.class.extends, + StmtData::SLocal(local) => { + let [decl] = local.decls.slice() else { + continue; + }; + let Some(value) = decl.value else { + continue; + }; + let ExprData::EClass(class) = value.data else { + continue; + }; + class.extends + } + StmtData::SExportDefault(export_default) => match &export_default.value { + StmtOrExpr::Stmt(inner) => match &inner.data { + StmtData::SClass(class) => class.class.extends, + _ => continue, + }, + StmtOrExpr::Expr(_) => continue, + }, + _ => continue, + }; + let Some(extends) = extends else { + continue; + }; + let ExprData::EIdentifier(id) = extends.data else { + continue; + }; + if parent.is_some() { + return None; + } + parent = Some(self.follow_symbol_link(id.ref_)); + } + parent + } +} diff --git a/test/bundler/bundler_barrel.test.ts b/test/bundler/bundler_barrel.test.ts index f9032f0ac3c4..9e413be1424f 100644 --- a/test/bundler/bundler_barrel.test.ts +++ b/test/bundler/bundler_barrel.test.ts @@ -112,7 +112,7 @@ describe("bundler", () => { `, "/node_modules/effectful/src/effect.js": /* js */ ` export const TABLE = {}; - TABLE.marker = "THE_SIDE_EFFECT_RAN"; + globalThis.marker = "THE_SIDE_EFFECT_RAN"; `, }, outdir: "/out", @@ -141,7 +141,7 @@ describe("bundler", () => { `, "/node_modules/effectful/src/effect.js": /* js */ ` export const TABLE = {}; - TABLE.marker = "THE_SIDE_EFFECT_RAN"; + globalThis.marker = "THE_SIDE_EFFECT_RAN"; `, }, outdir: "/out", @@ -171,7 +171,7 @@ describe("bundler", () => { `, "/node_modules/effectful/src/effect.js": /* js */ ` export const TABLE = {}; - TABLE.marker = "THE_SIDE_EFFECT_RAN"; + globalThis.marker = "THE_SIDE_EFFECT_RAN"; `, }, outdir: "/out", diff --git a/test/bundler/bundler_edgecase.test.ts b/test/bundler/bundler_edgecase.test.ts index 6ef2119ad0bf..b86b0a21254a 100644 --- a/test/bundler/bundler_edgecase.test.ts +++ b/test/bundler/bundler_edgecase.test.ts @@ -2233,6 +2233,329 @@ describe("bundler", () => { stdout: `side-effect-ran`, }, }); + // A top-level `X.y = v` only changes an object that `X` owns. When `X` is a + // module-local class, function, or object literal that is never reassigned, + // and `v` has no side effects, the statement belongs to `X`: it is removed + // with `X` instead of pinning `X` (and everything `v` references) into the + // bundle. Patterns from three.js (`Track.prototype.ValueTypeName = ''`, + // `Texture.DEFAULT_IMAGE = null`, `ShaderLib.physical = {...}`), tiptap + // (`ExtensionManager.resolve = resolveExtensions`), tanstack + // (`includesString.autoRemove = ...`) and prosemirror + // (`Selection.prototype.visible = true`). + itBundled("edgecase/MemberAssignmentOwnedByUnusedBinding", { + files: { + "/entry.ts": ` + import { used } from './lib'; + console.log(used()); + `, + "/lib.ts": ` + function FnREMOVE() {} + FnREMOVE.prototype.bigREMOVE = "x"; + FnREMOVE.resolveREMOVE = helperREMOVE; + FnREMOVE['indexedREMOVE'] = null; + function helperREMOVE() {} + + HoistedREMOVE.beforeDeclarationREMOVE = 1; + function HoistedREMOVE() {} + + class TrackREMOVE { get other() { return 1 } } + TrackREMOVE.prototype.ValueTypeNameREMOVE = ''; + class NumberTrackREMOVE extends TrackREMOVE {} + NumberTrackREMOVE.prototype.ValueTypeNameREMOVE = 'number'; + NumberTrackREMOVE.DEFAULT_IMAGE_REMOVE = null; + NumberTrackREMOVE.prototype.constructor = NumberTrackREMOVE; + + const ShaderLibREMOVE = { basic: { uniforms: {} } }; + ShaderLibREMOVE.physicalREMOVE = { uniforms: {}, vertexShader: 'vREMOVE' }; + ShaderLibREMOVE.basic.fragmentShaderREMOVE = 'fREMOVE'; + + const includesStringREMOVE = (row: unknown) => row; + includesStringREMOVE.autoRemoveREMOVE = (val: unknown) => !val; + + const ClassExprREMOVE = class {}; + ClassExprREMOVE.staticREMOVE = true; + + export { FnREMOVE, NumberTrackREMOVE, ShaderLibREMOVE }; + export function used() { return 'used' } + `, + }, + dce: true, + dceKeepMarkerCount: false, + run: { + stdout: "used", + }, + }); + // The same statements stay, in order, when the binding is read, re-exported, + // or reached through a namespace import. + itBundled("edgecase/MemberAssignmentKeptWithBinding", { + files: { + "/entry.js": ` + import { Track, NumberTrack, ShaderLib, includesString, Fn } from './lib'; + import * as ns from './lib'; + console.log( + new Track().ValueTypeName, + new NumberTrack().ValueTypeName, + NumberTrack.DEFAULT_IMAGE, + ShaderLib.physical.vertexShader, + ShaderLib.basic.fragmentShader, + includesString.autoRemove(0), + Fn.prototype.big, + Fn.resolve === ns.helper, + ns.Selection.prototype.visible, + ); + `, + "/lib.js": ` + export class Track {} + Track.prototype.ValueTypeName = 'base'; + export class NumberTrack extends Track {} + NumberTrack.prototype.ValueTypeName = 'number'; + NumberTrack.DEFAULT_IMAGE = null; + export const ShaderLib = { basic: { uniforms: {} } }; + ShaderLib.physical = { uniforms: {}, vertexShader: 'v' }; + ShaderLib.basic.fragmentShader = 'f'; + export const includesString = (row) => row; + includesString.autoRemove = (val) => !val; + export function Fn() {} + Fn.prototype.big = 'big'; + Fn.resolve = helper; + export function helper() {} + export { Selection } from './selection'; + `, + "/selection.js": ` + export class Selection { get empty() { return true } } + Selection.prototype.visible = true; + `, + }, + run: { + stdout: "base number null v f true big true true", + }, + }); + // An entry point's own exports pin the assignments on the exported binding. + itBundled("edgecase/MemberAssignmentKeptForEntryExport", { + files: { + "/entry.js": ` + export class Selection { get empty() { return true } } + Selection.prototype.visible = true; + export default function Fn() {} + Fn.displayName = 'Fn'; + `, + }, + onAfterBundle(api) { + api.expectFile("/out.js").toContain("Selection.prototype.visible = true"); + api.expectFile("/out.js").toContain('Fn.displayName = "Fn"'); + }, + }); + // A setter that the class or literal declares (or inherits from a local + // parent class) runs user code, and a parent that is not a local class may + // declare one too. Each of these assignments must run. + itBundled("edgecase/MemberAssignmentSetterKept", { + files: { + "/entry.js": ` + import './lib'; + console.log(globalThis.hits.join(',')); + `, + "/lib.js": ` + globalThis.hits = []; + const hit = (name) => globalThis.hits.push(name); + + class StaticSetter { static set y(v) { hit('static') } } + StaticSetter.y = 1; + + class ProtoSetter { set y(v) { hit('proto') } } + ProtoSetter.prototype.y = 1; + + class Parent { static set y(v) { hit('parent') } } + class Child extends Parent {} + Child.y = 1; + + const key = 'y'; + class Computed { static set [key](v) { hit('computed') } } + Computed.y = 1; + + const literal = { set y(v) { hit('literal') } }; + literal.y = 1; + + globalThis.Base = class { static set y(v) { hit('unknown-parent') } }; + class Unknown extends globalThis.Base {} + Unknown.y = 1; + + const holder = { a: {} }; + holder.a = { set b(v) { hit('rewritten-prefix') } }; + holder.a.b = 1; + + function Proto() {} + Proto.prototype = { set x(v) { hit('rewritten-prototype') } }; + Proto.prototype.x = 1; + + function Dunder() {} + Dunder.__proto__ = { set x(v) { hit('dunder-proto') } }; + Dunder.x = 1; + + class Defined {} + Object.defineProperty(Defined.prototype, 'x', { set(v) { hit('parent-defineProperty') } }); + class DefinedChild extends Defined {} + DefinedChild.prototype.x = 1; + + class Installer { static install() { Object.defineProperty(Installer.prototype, 'x', { set(v) { hit('parent-method') } }) } } + Installer.install(); + class InstallerChild extends Installer {} + InstallerChild.prototype.x = 1; + + class Blocky { static { Object.defineProperty(this, 'y', { set(v) { hit('static-block') } }) } } + Blocky.y = 1; + + class Inited { static z = Object.defineProperty(this, 'y', { set(v) { hit('static-initializer') } }) } + Inited.y = 1; + + class Reached {} + class Sibling extends Reached { static { Object.defineProperty(Object.getPrototypeOf(this.prototype), 'x', { set(v) { hit('sibling-static-block') } }) } } + class ReachedChild extends Reached {} + ReachedChild.prototype.x = 1; + + class Armed { static set install(v) { Object.defineProperty(this.prototype, 'x', { set(w) { hit('parent-setter-write') } }) } } + Armed.install = 1; + class ArmedChild extends Armed {} + ArmedChild.prototype.x = 1; + + class Stored { static { globalThis.stored = this } } + Object.defineProperty(globalThis.stored, 'y', { set(v) { hit('static-block-escape') } }); + Stored.y = 1; + + const register = (klass) => { globalThis.registered = klass; return klass }; + class Registered { static self = register(this) } + Object.defineProperty(globalThis.registered, 'y', { set(v) { hit('static-initializer-escape') } }); + Registered.y = 1; + `, + }, + run: { + stdout: [ + "static", + "proto", + "parent", + "computed", + "literal", + "unknown-parent", + "rewritten-prefix", + "rewritten-prototype", + "dunder-proto", + "parent-defineProperty", + "parent-method", + "static-block", + "static-initializer", + "sibling-static-block", + "parent-setter-write", + "static-block-escape", + "static-initializer-escape", + ].join(","), + }, + }); + // Decorators run arbitrary code against the class, and lowering reassigns + // the binding, so the write stays. + itBundled("edgecase/MemberAssignmentDecoratedClassKept", { + files: { + "/entry.ts": ` + import './lib'; + console.log(globalThis.hit); + `, + "/lib.ts": ` + function installSetter(target: any) { + Object.defineProperty(target, 'y', { set(v: number) { globalThis.hit = 'decorator:' + v } }); + return target; + } + @installSetter + class Decorated {} + Decorated.y = 1; + `, + }, + run: { + stdout: "decorator:1", + }, + }); + // A write that can reach an object the binding does not own is not owned by + // the binding: a reassigned binding, a value with side effects, a compound + // assignment, a global or imported root, or a path whose prefix was aliased + // to another object. + itBundled("edgecase/MemberAssignmentNotOwnedKept", { + files: { + "/entry.js": ` + import { other, shared, proto } from './lib'; + import { imported } from './imported'; + console.log(other.y, globalThis.counter, globalThis.fromGlobal, imported.y, shared.b, proto.x); + `, + "/lib.js": ` + import { imported } from './imported'; + + let target = { a: 1 }; + export const other = {}; + target = other; + target.y = 'reassigned'; + + function Fn() {} + Fn.y = (globalThis.counter = (globalThis.counter ?? 0) + 1); + + function Counter() {} + Counter.n = 0; + Counter.n += 1; + + globalThis.fromGlobal = 'global'; + imported.y = 'imported'; + + export const shared = {}; + const holder = { a: {} }; + holder.a = shared; + holder.a.b = 'aliased'; + + function F() {} + export const proto = {}; + F.prototype = proto; + F.prototype.x = 'proto'; + `, + "/imported.js": ` + export const imported = {}; + `, + }, + onAfterBundle(api) { + api.expectFile("/out.js").toContain("Counter.n += 1"); + }, + run: { + stdout: "reassigned 1 global imported aliased proto", + }, + }); + itBundled("edgecase/MemberAssignmentSplitting", { + files: { + "/a.js": ` + import { Track } from './shared'; + console.log(new Track().ValueTypeName); + `, + "/b.js": ` + import { used } from './shared'; + console.log(used()); + `, + "/shared.js": ` + export class Track {} + Track.prototype.ValueTypeName = 'track'; + export class UnusedREMOVE {} + UnusedREMOVE.prototype.ValueTypeNameREMOVE = 'unused'; + export function used() { return 'used' } + `, + }, + entryPoints: ["/a.js", "/b.js"], + splitting: true, + outdir: "/out", + onAfterBundle(api) { + // The `dce` scan only reads the entry outputs. The shared chunk is where + // `shared.js` lands, so check every emitted file. + const chunks = readdirSync(api.outdir).filter(file => file.endsWith(".js")); + expect(chunks).toHaveLength(3); + for (const chunk of chunks) { + expect(api.readFile(join("out", chunk))).not.toContain("REMOVE"); + } + }, + run: [ + { file: "/out/a.js", stdout: "track" }, + { file: "/out/b.js", stdout: "used" }, + ], + }); itBundled("edgecase/ImportMetaMain", { files: { "/entry.ts": /* js */ `