From f518f397b70b268dd9bb61f768b3d98f2a366074 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:12:02 +0000 Subject: [PATCH 01/10] bundler: tree-shake top-level member assignments with the binding they mutate A top-level `X.y = v`, `X.prototype.y = v`, or `X.a.b = 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 parser marks the part removable and registers it as one of `X`'s declaring parts in `top_level_symbols_to_parts`. The linker then keeps the assignment exactly when a live part reads or exports `X`, instead of treating it as an unconditional side effect that pins `X` into every bundle. The statement stays a side effect when the class or literal declares an accessor with that name (walking a local `extends` chain), when the parent class is not local, when a prefix of the path may alias an object `X` does not own, when the key is not a plain identifier or string, or when `v` has side effects. --- src/js_parser/p.rs | 13 +- src/js_parser/scan/mod.rs | 1 + src/js_parser/scan/scan_member_assignments.rs | 499 ++++++++++++++++++ test/bundler/bundler_barrel.test.ts | 6 +- test/bundler/bundler_edgecase.test.ts | 236 +++++++++ 5 files changed, 750 insertions(+), 5 deletions(-) create mode 100644 src/js_parser/scan/scan_member_assignments.rs diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index e80633c743d0..8dd3e1ab7926 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; @@ -8774,6 +8777,12 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O ); } + // `X.y = v` parts belong to `X`: removable, and pulled in with `X` + self.claim_member_assignments_for_owners( + parts.as_mut_slice(), + &mut top_level_symbols_to_parts, + ); + // Pulling in the exports of this module always pulls in the export part top_level_symbols_to_parts .entry(self.exports_ref) 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..c2777cb40cb8 --- /dev/null +++ b/src/js_parser/scan/scan_member_assignments.rs @@ -0,0 +1,499 @@ +//! Top-level member assignments owned by the binding they mutate. +//! +//! `X.y = v`, `X.prototype.y = v`, and `X.a.b = v` at the top level of a module +//! only change 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 is observable only through `X`. Its part is marked +//! `can_be_removed_if_unused` and registered as one of `X`'s declaring parts in +//! `top_level_symbols_to_parts`, so the linker keeps it exactly when a live +//! part reads or exports `X`. A setter the class or literal declares, a parent +//! class that is not local, a key that is not a plain identifier or string, or +//! a value with side effects keeps the statement as an unconditional side +//! effect, as before. +//! +//! Like Rollup, this ignores accessors inherited from `Object.prototype` and +//! `Function.prototype`, and a TypeError the assignment could throw (a frozen +//! object, a non-writable `name`, a TDZ read). + +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` statement found in pass 1. +#[derive(Clone, Copy)] +struct Candidate<'a> { + part_index: u32, + /// `X`, with symbol links followed. + owner: Ref, + /// The property names of the target, outermost first: `[a, b]`. + keys: &'a [&'a [u8]], + /// `v` is a literal, function, or class expression, so it cannot alias an + /// object that lives outside `X`. + value_is_fresh: bool, +} + +#[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, + } + } +} + +/// How the owner binding is declared. +#[derive(Clone, Copy)] +enum OwnerShape { + Class(ClassSource), + Function { + is_arrow: bool, + }, + /// The `EObject` initializer. + Object(Expr), +} + +/// Cache of `owner_shape` results. `None` means the binding does not qualify. +type ShapeCache = 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) +} + +fn value_is_fresh(value: &Expr) -> bool { + value.is_primitive_literal() + || matches!( + value.data, + ExprData::EObject(_) + | ExprData::EArray(_) + | ExprData::EFunction(_) + | ExprData::EArrow(_) + | ExprData::EClass(_) + | ExprData::ERegExp(_) + ) +} + +impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_ONLY> { + /// Marks each part whose only side effect is a member assignment on a + /// qualifying top-level binding as removable, and registers it as a + /// declaring part of that binding. Runs once per file from `to_ast`, after + /// `top_level_symbols_to_parts` holds the real declarations. + pub(crate) fn claim_member_assignments_for_owners( + &mut self, + parts: &mut [js_ast::Part], + top_level_symbols_to_parts: &mut TopLevelSymbolToParts, + ) { + if !self.options.features.dead_code_elimination || !self.options.tree_shaking { + return; + } + let arena = self.arena; + + let mut candidates: BumpVec<'a, Candidate<'a>> = BumpVec::new_in(arena); + for (part_index, part) in parts.iter().enumerate() { + if part.can_be_removed_if_unused || part.tag != js_ast::PartTag::None { + continue; + } + let mut found: Option> = None; + let mut ok = true; + 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_candidate(s_expr.value, part_index as u32) + { + if found.is_some() { + ok = false; + break; + } + found = Some(candidate); + continue; + } + if !self + .stmts_can_be_removed_if_unused_without_dce_check(core::slice::from_ref(stmt)) + { + ok = false; + break; + } + } + if ok && let Some(candidate) = found { + candidates.push(candidate); + } + } + if candidates.is_empty() { + return; + } + + let mut shapes = ShapeCache::new(); + let mut claimed: BumpVec<'a, Candidate<'a>> = BumpVec::new_in(arena); + for candidate in candidates.iter() { + let Some(shape) = self.owner_shape( + candidate.owner, + parts, + top_level_symbols_to_parts, + &mut shapes, + ) else { + continue; + }; + let keys = candidate.keys; + if keys.len() >= 2 { + // `X.a = other; X.a.b = v` writes through to `other`. Keep the + // deeper write if any sibling assignment may have aliased a + // prefix of its path to an object `X` does not own. + let aliased = candidates.iter().any(|other| { + other.owner == candidate.owner + && !other.value_is_fresh + && is_proper_prefix(other.keys, keys) + }); + if aliased { + continue; + } + } + if !self.chain_is_owned(shape, keys, parts, top_level_symbols_to_parts, &mut shapes) { + 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_candidate( + &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) = Self::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, + value_is_fresh: value_is_fresh(&bin.right), + }) + } + + /// Splits `X.a['b'].c` into the root identifier `X` and the property names + /// `[a, b, c]`. `None` if a link is optional, a key is not a plain + /// identifier or string, or the root is not an identifier. + fn member_chain(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 { + match cur.data { + ExprData::EDot(dot) => { + if dot.optional_chain.is_some() { + return None; + } + keys.push(dot.name.slice()); + cur = dot.target; + } + ExprData::EIndex(index) => { + if index.optional_chain.is_some() { + return None; + } + let ExprData::EString(s) = index.index.data else { + return None; + }; + keys.push(key_bytes(&s, arena)); + cur = index.target; + } + ExprData::EIdentifier(id) => { + if keys.is_empty() { + return None; + } + keys.reverse(); + return Some((id, keys.into_bump_slice())); + } + _ => return None, + } + } + } + + /// How `owner` is declared, if it is a never-reassigned class, function, or + /// object literal declared exactly once at the top level of this file. + 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 mut shape: Option = None; + for stmt in parts[*declaring_part as usize].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; + } + let value = decl.value?; + return 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, + }; + } + None + } + StmtData::SExportDefault(export_default) => match &export_default.value { + StmtOrExpr::Stmt(inner) => self.declaration_shape(inner, owner), + StmtOrExpr::Expr(_) => None, + }, + _ => None, + } + } + + /// Whether writing `keys` on an owner of this shape only touches objects + /// the owner created, and runs no accessor the owner declares. + fn chain_is_owned( + &self, + shape: OwnerShape, + keys: &[&[u8]], + parts: &[js_ast::Part], + top_level_symbols_to_parts: &TopLevelSymbolToParts, + shapes: &mut ShapeCache, + ) -> bool { + match shape { + OwnerShape::Function { is_arrow } => match keys { + [_] => true, + [b"prototype", _] => !is_arrow, + _ => false, + }, + OwnerShape::Class(source) => { + let name = match keys { + [name] => *name, + [b"prototype", name] => *name, + _ => return false, + }; + self.class_chain_declares_no_accessor( + source, + name, + parts, + top_level_symbols_to_parts, + shapes, + ) + } + OwnerShape::Object(object) => Self::object_literal_owns_path(object, keys, self.arena), + } + } + + /// No getter or setter named `name`, static or not, on the class or on any + /// class it extends. Every class in the `extends` chain must be a local + /// class that `owner_shape` accepts. + fn class_chain_declares_no_accessor( + &self, + source: ClassSource, + name: &[u8], + parts: &[js_ast::Part], + top_level_symbols_to_parts: &TopLevelSymbolToParts, + shapes: &mut ShapeCache, + ) -> 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 Some(OwnerShape::Class(parent_source)) = + self.owner_shape(parent.ref_, parts, top_level_symbols_to_parts, shapes) + else { + return false; + }; + current = parent_source; + } + false + } + + /// `keys[..n-1]` name nested object literals inside `object`, and the last + /// key is not an accessor on the innermost one. A spread or `__proto__` + /// key makes the literal's shape unknown. + fn object_literal_owns_path(object: Expr, keys: &[&[u8]], arena: &'a 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) + { + // Spread copies data properties, never accessors, so it + // cannot add a setter for the final key. It can replace a + // nested literal with an object `X` does not own. + 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 + } +} 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..cf461ebeb36e 100644 --- a/test/bundler/bundler_edgecase.test.ts +++ b/test/bundler/bundler_edgecase.test.ts @@ -2233,6 +2233,242 @@ 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; + `, + }, + run: { + stdout: "static,proto,parent,computed,literal,unknown-parent", + }, + }); + // 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 */ ` From b582b0e13b5ce55f6d035f09438777885e36f68c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:48:45 +0000 Subject: [PATCH 02/10] bundler: detect owned member assignments in the top_level_symbols_to_parts loop The loop in `to_ast` that records which parts declare each top-level symbol now also records which parts are member assignments on one. Only the per-candidate claim runs after the loop, because it needs the finished map. --- src/js_parser/p.rs | 16 +++- src/js_parser/scan/scan_member_assignments.rs | 91 +++++++++---------- 2 files changed, 55 insertions(+), 52 deletions(-) diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 8dd3e1ab7926..392aaef169c5 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -8749,6 +8749,11 @@ 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`: removable, and pulled in with `X`. + // Detected here, claimed once the map holds every declaration. + 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, @@ -8775,12 +8780,17 @@ 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); + } } - - // `X.y = v` parts belong to `X`: removable, and pulled in with `X` - self.claim_member_assignments_for_owners( + 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 diff --git a/src/js_parser/scan/scan_member_assignments.rs b/src/js_parser/scan/scan_member_assignments.rs index c2777cb40cb8..6937130eba41 100644 --- a/src/js_parser/scan/scan_member_assignments.rs +++ b/src/js_parser/scan/scan_member_assignments.rs @@ -11,6 +11,10 @@ //! a value with side effects keeps the statement as an unconditional side //! effect, as before. //! +//! `to_ast` detects candidates in the loop that builds +//! `top_level_symbols_to_parts` and claims them once the map is complete: a +//! later `X = ...` or a second declaration of `X` vetoes every candidate on `X`. +//! //! Like Rollup, this ignores accessors inherited from `Object.prototype` and //! `Function.prototype`, and a TypeError the assignment could throw (a frozen //! object, a non-writable `name`, a TDZ read). @@ -26,9 +30,10 @@ use bun_collections::{HashMap, VecExt as _}; use crate::p::P; -/// One `X.a.b = v` statement found in pass 1. +/// A part that is one `X.a.b = v` statement, plus statements that are +/// removable on their own. #[derive(Clone, Copy)] -struct Candidate<'a> { +pub(crate) struct Candidate<'a> { part_index: u32, /// `X`, with symbol links followed. owner: Ref, @@ -92,58 +97,50 @@ fn value_is_fresh(value: &Expr) -> bool { } impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_ONLY> { - /// Marks each part whose only side effect is a member assignment on a - /// qualifying top-level binding as removable, and registers it as a - /// declaring part of that binding. Runs once per file from `to_ast`, after - /// `top_level_symbols_to_parts` holds the real declarations. - pub(crate) fn claim_member_assignments_for_owners( + /// The member assignment that keeps `part` from being removable, if that + /// is the only thing that does. + pub(crate) fn member_assignment_candidate( &mut self, - parts: &mut [js_ast::Part], - top_level_symbols_to_parts: &mut TopLevelSymbolToParts, - ) { - if !self.options.features.dead_code_elimination || !self.options.tree_shaking { - return; + part: &js_ast::Part, + part_index: u32, + ) -> Option> { + if part.can_be_removed_if_unused || part.tag != js_ast::PartTag::None { + return None; } - let arena = self.arena; - - let mut candidates: BumpVec<'a, Candidate<'a>> = BumpVec::new_in(arena); - for (part_index, part) in parts.iter().enumerate() { - if part.can_be_removed_if_unused || part.tag != js_ast::PartTag::None { - continue; - } - let mut found: Option> = None; - let mut ok = true; - 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_candidate(s_expr.value, part_index as u32) - { - if found.is_some() { - ok = false; - break; - } - found = Some(candidate); - continue; - } - if !self - .stmts_can_be_removed_if_unused_without_dce_check(core::slice::from_ref(stmt)) - { - ok = false; - break; + 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 ok && let Some(candidate) = found { - candidates.push(candidate); + if !self.stmts_can_be_removed_if_unused_without_dce_check(core::slice::from_ref(stmt)) { + return None; } } + found + } + + /// Marks each candidate whose owner qualifies as removable, and registers + /// it as a declaring part of the owner. `top_level_symbols_to_parts` must + /// hold every real declaration of the file. + 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 claimed: BumpVec<'a, Candidate<'a>> = BumpVec::new_in(arena); - for candidate in candidates.iter() { + 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, @@ -194,11 +191,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } /// `X.a.b = v` with a side-effect-free `v`, rooted at a local identifier. - fn member_assignment_candidate( - &mut self, - value: Expr, - part_index: u32, - ) -> Option> { + fn member_assignment(&mut self, value: Expr, part_index: u32) -> Option> { let ExprData::EBinary(bin) = value.data else { return None; }; From 9cf1b4b3883e9122efe0a8702fba4ad36d51290a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:51:17 +0000 Subject: [PATCH 03/10] bundler: trim comments in the member assignment scan --- src/js_parser/p.rs | 3 +- src/js_parser/scan/scan_member_assignments.rs | 67 +++++-------------- 2 files changed, 16 insertions(+), 54 deletions(-) diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 392aaef169c5..cf72230da34a 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -8749,8 +8749,7 @@ 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`: removable, and pulled in with `X`. - // Detected here, claimed once the map holds every declaration. + // `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); diff --git a/src/js_parser/scan/scan_member_assignments.rs b/src/js_parser/scan/scan_member_assignments.rs index 6937130eba41..d09b3f7c5e42 100644 --- a/src/js_parser/scan/scan_member_assignments.rs +++ b/src/js_parser/scan/scan_member_assignments.rs @@ -1,23 +1,6 @@ -//! Top-level member assignments owned by the binding they mutate. -//! -//! `X.y = v`, `X.prototype.y = v`, and `X.a.b = v` at the top level of a module -//! only change 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 is observable only through `X`. Its part is marked -//! `can_be_removed_if_unused` and registered as one of `X`'s declaring parts in -//! `top_level_symbols_to_parts`, so the linker keeps it exactly when a live -//! part reads or exports `X`. A setter the class or literal declares, a parent -//! class that is not local, a key that is not a plain identifier or string, or -//! a value with side effects keeps the statement as an unconditional side -//! effect, as before. -//! -//! `to_ast` detects candidates in the loop that builds -//! `top_level_symbols_to_parts` and claims them once the map is complete: a -//! later `X = ...` or a second declaration of `X` vetoes every candidate on `X`. -//! -//! Like Rollup, this ignores accessors inherited from `Object.prototype` and -//! `Function.prototype`, and a TypeError the assignment could throw (a frozen -//! object, a non-writable `name`, a TDZ read). +//! A top-level `X.y = v` on a local, never reassigned class, function, or +//! object literal is observable only through `X`: its part becomes removable +//! and a declaring part of `X`, so it lives exactly when `X` does. use bun_alloc::Arena as Bump; use bun_alloc::{ArenaVec as BumpVec, ArenaVecExt as _}; @@ -30,17 +13,14 @@ use bun_collections::{HashMap, VecExt as _}; use crate::p::P; -/// A part that is one `X.a.b = v` statement, plus statements that are -/// removable on their own. +/// One `X.a.b = v` part. #[derive(Clone, Copy)] pub(crate) struct Candidate<'a> { part_index: u32, - /// `X`, with symbol links followed. owner: Ref, - /// The property names of the target, outermost first: `[a, b]`. + /// Outermost first: `[a, b]`. keys: &'a [&'a [u8]], - /// `v` is a literal, function, or class expression, so it cannot alias an - /// object that lives outside `X`. + /// `v` is a literal, function, or class, so it aliases nothing outside `X`. value_is_fresh: bool, } @@ -59,7 +39,6 @@ impl ClassSource { } } -/// How the owner binding is declared. #[derive(Clone, Copy)] enum OwnerShape { Class(ClassSource), @@ -70,7 +49,7 @@ enum OwnerShape { Object(Expr), } -/// Cache of `owner_shape` results. `None` means the binding does not qualify. +/// `None` caches "does not qualify". type ShapeCache = HashMap>; const MAX_EXTENDS_DEPTH: usize = 32; @@ -97,8 +76,7 @@ fn value_is_fresh(value: &Expr) -> bool { } impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_ONLY> { - /// The member assignment that keeps `part` from being removable, if that - /// is the only thing that does. + /// The member assignment that alone keeps `part` from being removable. pub(crate) fn member_assignment_candidate( &mut self, part: &js_ast::Part, @@ -126,9 +104,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O found } - /// Marks each candidate whose owner qualifies as removable, and registers - /// it as a declaring part of the owner. `top_level_symbols_to_parts` must - /// hold every real declaration of the file. + /// Needs every real declaration in `top_level_symbols_to_parts` first. pub(crate) fn claim_member_assignments( &self, parts: &mut [js_ast::Part], @@ -151,9 +127,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O }; let keys = candidate.keys; if keys.len() >= 2 { - // `X.a = other; X.a.b = v` writes through to `other`. Keep the - // deeper write if any sibling assignment may have aliased a - // prefix of its path to an object `X` does not own. + // `X.a = other; X.a.b = v` writes through to `other`. let aliased = candidates.iter().any(|other| { other.owner == candidate.owner && !other.value_is_fresh @@ -213,9 +187,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O }) } - /// Splits `X.a['b'].c` into the root identifier `X` and the property names - /// `[a, b, c]`. `None` if a link is optional, a key is not a plain - /// identifier or string, or the root is not an identifier. + /// `X.a['b'].c` to `X` and `[a, b, c]`. fn member_chain(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; @@ -250,8 +222,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } - /// How `owner` is declared, if it is a never-reassigned class, function, or - /// object literal declared exactly once at the top level of this file. fn owner_shape( &self, owner: Ref, @@ -346,8 +316,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } - /// Whether writing `keys` on an owner of this shape only touches objects - /// the owner created, and runs no accessor the owner declares. + /// The write reaches only objects the owner created and runs no accessor. fn chain_is_owned( &self, shape: OwnerShape, @@ -380,9 +349,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } - /// No getter or setter named `name`, static or not, on the class or on any - /// class it extends. Every class in the `extends` chain must be a local - /// class that `owner_shape` accepts. + /// Static or not, on the class or any local class it extends. fn class_chain_declares_no_accessor( &self, source: ClassSource, @@ -432,9 +399,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O false } - /// `keys[..n-1]` name nested object literals inside `object`, and the last - /// key is not an accessor on the innermost one. A spread or `__proto__` - /// key makes the literal's shape unknown. + /// Each key but the last names a nested literal; the last is no accessor. fn object_literal_owns_path(object: Expr, keys: &[&[u8]], arena: &'a Bump) -> bool { let mut current = object; for (i, key) in keys.iter().enumerate() { @@ -447,9 +412,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O if property.kind == PropertyKind::Spread || property.flags.contains(Flags::Property::IsSpread) { - // Spread copies data properties, never accessors, so it - // cannot add a setter for the final key. It can replace a - // nested literal with an object `X` does not own. + // A spread copies data properties, never accessors. if is_last { continue; } From 64fe7e756f33168fd39bceae9124ebd970cbd3b9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:51:49 +0000 Subject: [PATCH 04/10] bundler: one-line module doc for the member assignment scan --- src/js_parser/scan/scan_member_assignments.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/js_parser/scan/scan_member_assignments.rs b/src/js_parser/scan/scan_member_assignments.rs index d09b3f7c5e42..786037b35537 100644 --- a/src/js_parser/scan/scan_member_assignments.rs +++ b/src/js_parser/scan/scan_member_assignments.rs @@ -1,6 +1,4 @@ -//! A top-level `X.y = v` on a local, never reassigned class, function, or -//! object literal is observable only through `X`: its part becomes removable -//! and a declaring part of `X`, so it lives exactly when `X` does. +//! 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 _}; From 5e7e4f18ee25c9ebc39469fb88ae71e44773bc76 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 2 Sep 2026 04:21:25 +0000 Subject: [PATCH 05/10] bundler: keep member assignments that can reach an installed setter Four writes were claimed for a binding although a setter could run: - A sibling write rewrote a prefix of the path with a fresh literal that declares a setter. Any sibling write to a prefix now keeps the deeper write. - The target path contained `__proto__`, which changes the prototype chain. Such a path is never owned. - A static block or static initializer with side effects installed an accessor on the class. The owner's declaring part must be removable. - Live code mutated a parent class (`Object.defineProperty`, a static method call). A parent qualifies only when every part that uses it is an `extends` clause, an owned write, or an export clause. --- src/js_parser/scan/scan_member_assignments.rs | 156 ++++++++++++++---- test/bundler/bundler_edgecase.test.ts | 66 +++++++- 2 files changed, 188 insertions(+), 34 deletions(-) diff --git a/src/js_parser/scan/scan_member_assignments.rs b/src/js_parser/scan/scan_member_assignments.rs index 786037b35537..0dba455db892 100644 --- a/src/js_parser/scan/scan_member_assignments.rs +++ b/src/js_parser/scan/scan_member_assignments.rs @@ -18,8 +18,6 @@ pub(crate) struct Candidate<'a> { owner: Ref, /// Outermost first: `[a, b]`. keys: &'a [&'a [u8]], - /// `v` is a literal, function, or class, so it aliases nothing outside `X`. - value_is_fresh: bool, } #[derive(Clone, Copy)] @@ -50,6 +48,9 @@ enum OwnerShape { /// `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] { @@ -60,19 +61,6 @@ fn is_proper_prefix(short: &[&[u8]], long: &[&[u8]]) -> bool { short.len() < long.len() && short.iter().zip(long).all(|(a, b)| a == b) } -fn value_is_fresh(value: &Expr) -> bool { - value.is_primitive_literal() - || matches!( - value.data, - ExprData::EObject(_) - | ExprData::EArray(_) - | ExprData::EFunction(_) - | ExprData::EArrow(_) - | ExprData::EClass(_) - | ExprData::ERegExp(_) - ) -} - 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( @@ -113,6 +101,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O 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( @@ -124,18 +113,22 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O continue; }; let keys = candidate.keys; - if keys.len() >= 2 { - // `X.a = other; X.a.b = v` writes through to `other`. - let aliased = candidates.iter().any(|other| { - other.owner == candidate.owner - && !other.value_is_fresh - && is_proper_prefix(other.keys, keys) - }); - if aliased { - continue; - } + // `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, &mut shapes) { + if !self.chain_is_owned( + shape, + keys, + parts, + top_level_symbols_to_parts, + candidates, + &mut shapes, + &mut parents, + ) { continue; } claimed.push(*candidate); @@ -181,22 +174,22 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O part_index, owner: self.follow_symbol_link(root.ref_), keys, - value_is_fresh: value_is_fresh(&bin.right), }) } - /// `X.a['b'].c` to `X` and `[a, b, c]`. + /// `X.a['b'].c` to `X` and `[a, b, c]`. A `__proto__` key changes the + /// prototype chain, so it is never owned. fn member_chain(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 { - match cur.data { + let key: &'a [u8] = match cur.data { ExprData::EDot(dot) => { if dot.optional_chain.is_some() { return None; } - keys.push(dot.name.slice()); cur = dot.target; + dot.name.slice() } ExprData::EIndex(index) => { if index.optional_chain.is_some() { @@ -205,8 +198,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let ExprData::EString(s) = index.index.data else { return None; }; - keys.push(key_bytes(&s, arena)); cur = index.target; + key_bytes(&s, arena) } ExprData::EIdentifier(id) => { if keys.is_empty() { @@ -216,7 +209,11 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O return Some((id, keys.into_bump_slice())); } _ => return None, + }; + if key == b"__proto__" { + return None; } + keys.push(key); } } @@ -259,8 +256,13 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O 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 parts[*declaring_part as usize].stmts.slice() { + for stmt in declaring_part.stmts.slice() { let Some(found) = self.declaration_shape(stmt, owner) else { continue; }; @@ -315,13 +317,16 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } /// 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 } => match keys { @@ -340,7 +345,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O name, parts, top_level_symbols_to_parts, + candidates, shapes, + parents, ) } OwnerShape::Object(object) => Self::object_literal_owns_path(object, keys, self.arena), @@ -348,13 +355,16 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } /// 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; @@ -387,16 +397,96 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O 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.ref_, parts, top_level_symbols_to_parts, shapes) + self.owner_shape(parent, parts, top_level_symbols_to_parts, shapes) else { return false; }; + if !self.class_is_only_extended(parent, parts, candidates, parents) { + return false; + } current = parent_source; } false } + /// Every use of `class_ref` is an `extends` clause, an owned member + /// assignment, or an export. Any other use (a call argument, its own + /// methods) may install an accessor that a subclass inherits. + fn class_is_only_extended( + &self, + class_ref: Ref, + parts: &[js_ast::Part], + candidates: &[Candidate<'a>], + parents: &mut ParentCache, + ) -> bool { + if let Some(&known) = parents.get(&class_ref) { + return known; + } + let only_extended = parts.iter().enumerate().all(|(part_index, part)| { + let Some(use_) = part.symbol_uses.get(&class_ref) else { + return true; + }; + if candidates + .iter() + .any(|c| c.part_index as usize == part_index && c.owner == class_ref) + { + return true; + } + if 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 + } + /// Each key but the last names a nested literal; the last is no accessor. fn object_literal_owns_path(object: Expr, keys: &[&[u8]], arena: &'a Bump) -> bool { let mut current = object; diff --git a/test/bundler/bundler_edgecase.test.ts b/test/bundler/bundler_edgecase.test.ts index cf461ebeb36e..1a303edb99fa 100644 --- a/test/bundler/bundler_edgecase.test.ts +++ b/test/bundler/bundler_edgecase.test.ts @@ -2378,10 +2378,74 @@ describe("bundler", () => { 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; + `, + }, + run: { + stdout: [ + "static", + "proto", + "parent", + "computed", + "literal", + "unknown-parent", + "rewritten-prefix", + "rewritten-prototype", + "dunder-proto", + "parent-defineProperty", + "parent-method", + "static-block", + "static-initializer", + ].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: "static,proto,parent,computed,literal,unknown-parent", + stdout: "decorator:1", }, }); // A write that can reach an object the binding does not own is not owned by From 98ba238d642099bcaa282d8dfaa016528fac275f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 2 Sep 2026 04:22:14 +0000 Subject: [PATCH 06/10] bundler: one-line docs in the member assignment scan --- src/js_parser/scan/scan_member_assignments.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/js_parser/scan/scan_member_assignments.rs b/src/js_parser/scan/scan_member_assignments.rs index 0dba455db892..785a67fb36db 100644 --- a/src/js_parser/scan/scan_member_assignments.rs +++ b/src/js_parser/scan/scan_member_assignments.rs @@ -177,8 +177,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O }) } - /// `X.a['b'].c` to `X` and `[a, b, c]`. A `__proto__` key changes the - /// prototype chain, so it is never owned. + /// `X.a['b'].c` to `X` and `[a, b, c]`; a `__proto__` key is never owned. fn member_chain(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; @@ -411,9 +410,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O false } - /// Every use of `class_ref` is an `extends` clause, an owned member - /// assignment, or an export. Any other use (a call argument, its own - /// methods) may install an accessor that a subclass inherits. + /// Any other use (a call argument, its own methods) may install an accessor a subclass inherits. fn class_is_only_extended( &self, class_ref: Ref, From bae91ac2be83690d6aab8276fc3423c083e83864 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 2 Sep 2026 04:47:02 +0000 Subject: [PATCH 07/10] bundler: keep non-generic member assignment helpers out of the generic parser impl --- src/js_parser/scan/scan_member_assignments.rs | 238 +++++++++--------- 1 file changed, 124 insertions(+), 114 deletions(-) diff --git a/src/js_parser/scan/scan_member_assignments.rs b/src/js_parser/scan/scan_member_assignments.rs index 785a67fb36db..64a068a16c40 100644 --- a/src/js_parser/scan/scan_member_assignments.rs +++ b/src/js_parser/scan/scan_member_assignments.rs @@ -61,6 +61,124 @@ 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( @@ -163,7 +281,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O if bin.op != js_ast::op::Code::BinAssign { return None; } - let (root, keys) = Self::member_chain(bin.left, self.arena)?; + let (root, keys) = member_chain(bin.left, self.arena)?; if root.must_keep_due_to_with_stmt() { return None; } @@ -177,45 +295,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O }) } - /// `X.a['b'].c` to `X` and `[a, b, c]`; a `__proto__` key is never owned. - fn member_chain(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 owner_shape( &self, owner: Ref, @@ -294,16 +373,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O if self.follow_symbol_link(id.r#ref) != owner { continue; } - let value = decl.value?; - return 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, - }; + return initializer_shape(decl.value?); } None } @@ -328,16 +398,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O parents: &mut ParentCache, ) -> bool { match shape { - OwnerShape::Function { is_arrow } => match keys { - [_] => true, - [b"prototype", _] => !is_arrow, - _ => false, - }, + OwnerShape::Function { is_arrow } => function_owns_path(is_arrow, keys), OwnerShape::Class(source) => { - let name = match keys { - [name] => *name, - [b"prototype", name] => *name, - _ => return false, + let Some(name) = class_member_name(keys) else { + return false; }; self.class_chain_declares_no_accessor( source, @@ -349,7 +413,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O parents, ) } - OwnerShape::Object(object) => Self::object_literal_owns_path(object, keys, self.arena), + OwnerShape::Object(object) => object_literal_owns_path(object, keys, self.arena), } } @@ -483,58 +547,4 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } parent } - - /// Each key but the last names a nested literal; the last is no accessor. - fn object_literal_owns_path(object: Expr, keys: &[&[u8]], arena: &'a 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 - } } From 9b08518a99a38b9be8e885f85de2c4b09dac651a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 2 Sep 2026 05:36:19 +0000 Subject: [PATCH 08/10] bundler: a parent class must not be reachable from a sibling's definition or a setter write A subclass with a static block or a static initializer can reach the parent through `this`, so the `extends` exemption now requires the subclass's part to be removable. A write on the parent that runs a setter the parent declares is live code that can mutate it, so such a write no longer counts as owned. --- src/js_parser/scan/scan_member_assignments.rs | 39 ++++++++++++++++--- test/bundler/bundler_edgecase.test.ts | 12 ++++++ 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/src/js_parser/scan/scan_member_assignments.rs b/src/js_parser/scan/scan_member_assignments.rs index 64a068a16c40..c55ab0691513 100644 --- a/src/js_parser/scan/scan_member_assignments.rs +++ b/src/js_parser/scan/scan_member_assignments.rs @@ -466,7 +466,15 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O else { return false; }; - if !self.class_is_only_extended(parent, parts, candidates, parents) { + if !self.class_is_only_extended( + parent, + parent_source, + parts, + top_level_symbols_to_parts, + candidates, + shapes, + parents, + ) { return false; } current = parent_source; @@ -475,27 +483,48 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } /// 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 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 candidates + if let Some(candidate) = candidates .iter() - .any(|c| c.part_index as usize == part_index && c.owner == class_ref) + .find(|c| c.part_index as usize == part_index && c.owner == class_ref) { - return true; + // 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, + ) + }); } - if use_.count_estimate == 1 && self.class_declaration_parent(part) == Some(class_ref) { + // 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 diff --git a/test/bundler/bundler_edgecase.test.ts b/test/bundler/bundler_edgecase.test.ts index 1a303edb99fa..100917cd35c1 100644 --- a/test/bundler/bundler_edgecase.test.ts +++ b/test/bundler/bundler_edgecase.test.ts @@ -2406,6 +2406,16 @@ describe("bundler", () => { 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; `, }, run: { @@ -2423,6 +2433,8 @@ describe("bundler", () => { "parent-method", "static-block", "static-initializer", + "sibling-static-block", + "parent-setter-write", ].join(","), }, }); From 4e80f67f3119f788a81c992d88107065883dfd66 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 2 Sep 2026 06:14:26 +0000 Subject: [PATCH 09/10] bundler: bound the parent-class walk by the stack check --- src/js_parser/scan/scan_member_assignments.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/js_parser/scan/scan_member_assignments.rs b/src/js_parser/scan/scan_member_assignments.rs index c55ab0691513..1f4649d51133 100644 --- a/src/js_parser/scan/scan_member_assignments.rs +++ b/src/js_parser/scan/scan_member_assignments.rs @@ -497,6 +497,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O 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)| { From 79d971df1ce4ba937838226a6c58221d5d6600e8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:27:36 +0000 Subject: [PATCH 10/10] bundler: document owned member assignments and cover `this` escapes The esbuild comparison records that a top-level `X.y = v` on an unused local binding is removed, as Rollup does. The `can_be_removed_if_unused` comments name the second way a part earns the flag. Two cases cover a static block and a static initializer that hand `this` to code that installs a setter later. --- docs/bundler/esbuild.mdx | 2 +- src/ast/nodes.rs | 6 ++++-- src/bundler/linker_context/mergeSmallChunks.rs | 3 ++- test/bundler/bundler_edgecase.test.ts | 11 +++++++++++ 4 files changed, 18 insertions(+), 4 deletions(-) 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/test/bundler/bundler_edgecase.test.ts b/test/bundler/bundler_edgecase.test.ts index 100917cd35c1..b86b0a21254a 100644 --- a/test/bundler/bundler_edgecase.test.ts +++ b/test/bundler/bundler_edgecase.test.ts @@ -2416,6 +2416,15 @@ describe("bundler", () => { 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: { @@ -2435,6 +2444,8 @@ describe("bundler", () => { "static-initializer", "sibling-static-block", "parent-setter-write", + "static-block-escape", + "static-initializer-escape", ].join(","), }, });