diff --git a/src/bundler/defines.rs b/src/bundler/defines.rs index e1564fc32cac..9e26e970d2ba 100644 --- a/src/bundler/defines.rs +++ b/src/bundler/defines.rs @@ -232,6 +232,7 @@ impl DefineExt for Define { let mut define = Box::new(Define { identifiers: StringHashMap::default(), dots: StringHashMap::default(), + injected: Vec::new(), drop_debugger, }); define.dots.reserve(124); @@ -448,6 +449,7 @@ impl DefineDataExt for DefineData { /* method_call_must_be_replaced_with_undefined: */ method_call_must_be_replaced_with_undefined_, ), + injected_define_index: None, }); } @@ -476,6 +478,7 @@ impl DefineDataExt for DefineData { /* method_call_must_be_replaced_with_undefined: */ method_call_must_be_replaced_with_undefined_, ), + injected_define_index: None, }); } @@ -522,6 +525,7 @@ impl DefineDataExt for DefineData { /* method_call_must_be_replaced_with_undefined: */ method_call_must_be_replaced_with_undefined_, ), + injected_define_index: None, }) } diff --git a/src/bundler/options.rs b/src/bundler/options.rs index e709614c53eb..31b7618e1307 100644 --- a/src/bundler/options.rs +++ b/src/bundler/options.rs @@ -1366,6 +1366,7 @@ impl<'a> BundleOptions<'a> { define: Box::new(defines::Define { identifiers: self.define.identifiers.clone(), dots: self.define.dots.clone(), + injected: self.define.injected.clone(), drop_debugger: self.define.drop_debugger, }), drop: self.drop.clone(), @@ -1633,6 +1634,7 @@ impl<'a> BundleOptions<'a> { define: Box::new(defines::Define { identifiers: Default::default(), dots: Default::default(), + injected: Vec::new(), drop_debugger: false, }), loaders, diff --git a/src/js_parser/lib.rs b/src/js_parser/lib.rs index 57bde22e0fc2..86287f1a565f 100644 --- a/src/js_parser/lib.rs +++ b/src/js_parser/lib.rs @@ -373,6 +373,8 @@ pub mod defines { // tier up for json-parser access) can construct directly. pub original_name: Option>, pub flags: Flags, + /// Index into `Define.injected` when `value` is an object/array literal. + pub injected_define_index: Option, } // SAFETY: `ExprData` contains `StoreRef` raw pointers into immutable, @@ -389,6 +391,7 @@ pub mod defines { value: ExprData::EMissing(E::Missing), original_name: None, flags: Flags::default(), + injected_define_index: None, } } } @@ -427,6 +430,7 @@ pub mod defines { options.method_call_must_be_replaced_with_undefined, ), original_name: options.original_name.map(Box::<[u8]>::from), + injected_define_index: None, } } @@ -490,14 +494,29 @@ pub mod defines { || b.method_call_must_be_replaced_with_undefined(), ), original_name: b.original_name, + injected_define_index: b.injected_define_index, } } } + /// An object/array `--define` value the parser hoists to one shared `var`. + #[derive(Clone)] + pub struct InjectedDefine { + pub name: Box<[u8]>, + pub value: ExprData, + } + + // SAFETY: see `Send` for DefineData — `value` points at immutable + // process-lifetime AST stores and is only read after `Define::init`. + unsafe impl Send for InjectedDefine {} + // SAFETY: see `Send` impl above. + unsafe impl Sync for InjectedDefine {} + #[derive(Default)] pub struct Define { pub identifiers: StringHashMap, pub dots: StringHashMap>, + pub injected: Vec, pub drop_debugger: bool, } @@ -522,8 +541,22 @@ pub mod defines { pub fn insert( &mut self, key: &[u8], - value: DefineData, + mut value: DefineData, ) -> Result<(), bun_alloc::AllocError> { + // `deep_clone` normalises JSON-parsed values to EObject/EArray. + if !value.valueless() + && matches!( + value.value.tag(), + bun_ast::expr::Tag::EObject | bun_ast::expr::Tag::EArray + ) + { + value.injected_define_index = Some(self.injected.len() as u32); + self.injected.push(InjectedDefine { + name: Box::from(key), + value: value.value, + }); + } + // If it has a dot, then it's a DotDefine. e.g. process.env.NODE_ENV if let Some(last_dot) = strings::last_index_of_char(key, b'.') { let tail = &key[last_dot + 1..key.len()]; diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 2a4761ab6141..2cc3cb848696 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -536,6 +536,9 @@ pub struct P<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> { pub temp_refs_to_declare: List<'a, TempRef>, pub temp_ref_count: i32, + // Indexed by `DefineData.injected_define_index`. + pub injected_define_refs: List<'a, Ref>, + // When bundling, hoisted top-level local variables declared with "var" in // nested scopes are moved up to be declared in the top-level scope instead. // The old "var" statements are turned into regular assignments instead. This @@ -2835,6 +2838,35 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O self.filename_ref = self.declare_common_js_symbol(js_ast::symbol::Kind::Unbound, b"__filename")?; + if !self.define.injected.is_empty() { + self.injected_define_refs + .reserve(self.define.injected.len()); + let will_use_renamer = self.will_use_renamer(); + for injected in self.define.injected.iter() { + let sanitized = bun_core::MutableString::ensure_valid_identifier(&injected.name)?; + // No renamer => printed verbatim, so suffix a hash for collision safety. + let name: &'a [u8] = if will_use_renamer { + bun_alloc::arena_format!( + in self.arena, + "define_{}_default", + bstr::BStr::new(&sanitized) + ) + } else { + bun_alloc::arena_format!( + in self.arena, + "define_{}_default_{}", + bstr::BStr::new(&sanitized), + bun_core::fmt::truncated_hash32(bun_wyhash::hash(&injected.name)) + ) + } + .into_bump_str() + .as_bytes(); + let ref_ = self.new_symbol(js_ast::symbol::Kind::Other, name); + VecExt::append(&mut self.module_scope_mut().generated, ref_); + self.injected_define_refs.push(ref_); + } + } + if self.options.features.inject_jest_globals { self.jest.test = self.declare_common_js_symbol(js_ast::symbol::Kind::Unbound, b"test")?; @@ -6303,6 +6335,18 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O is_delete_target: bool, define_data: &DefineData, ) -> Expr { + if let Some(idx) = define_data.injected_define_index { + if let Some(&ref_) = self.injected_define_refs.get(idx as usize) { + self.record_usage(ref_); + return Expr { + data: js_ast::ExprData::EIdentifier( + E::Identifier::init(ref_).with_can_be_removed_if_unused(true), + ), + loc, + }; + } + } + // Callers gate on `!valueless()` before reaching here, so `value` is a // real Expr.Data by contract. let value = define_data.value; @@ -8794,6 +8838,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O await_target: None, temp_refs_to_declare: BumpVec::new_in(arena), temp_ref_count: 0, + injected_define_refs: BumpVec::new_in(arena), relocated_top_level_vars: BumpVec::new_in(arena), after_arrow_body_loc: bun_ast::Loc::EMPTY, const_values: Default::default(), diff --git a/src/js_parser/parse/parse_entry.rs b/src/js_parser/parse/parse_entry.rs index 9227cd63a118..f3bbb9dc0088 100644 --- a/src/js_parser/parse/parse_entry.rs +++ b/src/js_parser/parse/parse_entry.rs @@ -1091,6 +1091,46 @@ impl<'a> Parser<'a> { } } + // `var define_X_default = {...}` for each referenced injected define. + for i in 0..p.injected_define_refs.len() { + let ref_ = p.injected_define_refs[i]; + if p.symbols.as_slice()[ref_.inner_index() as usize].use_count_estimate == 0 { + continue; + } + let value = p.define.injected[i].value; + let mut declared_symbols = + bun_ast::DeclaredSymbolList::init_capacity(1).expect("unreachable"); + declared_symbols.append_assume_capacity(DeclaredSymbol { + ref_, + is_top_level: true, + }); + let binding = p.b(B::Identifier { r#ref: ref_ }, bun_ast::Loc::EMPTY); + let part_stmts = p.arena.alloc_slice_fill_with(1, |_| { + let mut decls = G::DeclList::init_capacity(1); + decls.append_assume_capacity(G::Decl { + binding, + value: Some(Expr { + data: value, + loc: bun_ast::Loc::EMPTY, + }), + }); + p.s( + S::Local { + kind: js_ast::LocalKind::KVar, + decls, + ..Default::default() + }, + bun_ast::Loc::EMPTY, + ) + }); + before.push(js_ast::Part { + stmts: part_stmts.into(), + declared_symbols, + can_be_removed_if_unused: true, + ..Default::default() + }); + } + // This is a workaround for broken module environment checks in packages like lodash-es // https://github.com/lodash/lodash/issues/5660 let mut force_esm = false; diff --git a/src/js_parser/visit/visit_expr.rs b/src/js_parser/visit/visit_expr.rs index fa3efcb7c061..b67f8ef4ee4a 100644 --- a/src/js_parser/visit/visit_expr.rs +++ b/src/js_parser/visit/visit_expr.rs @@ -1219,7 +1219,24 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } Op::UnDelete => { + let name_before = match e_.value.data { + Data::EIdentifier(id) => Some(p.load_name_from_ref(id.ref_)), + _ => None, + }; p.visit_expr_in_out(&mut e_.value, ExprIn::default()); + // `delete ` is a strict-mode early error; wrap substituted operands. + if let Data::EIdentifier(id) = e_.value.data { + let name_after = p.symbols[id.ref_.inner_index() as usize] + .original_name + .slice(); + if name_before != Some(name_after) { + e_.value = Expr { + loc: e_.value.loc, + data: prefill::data::ZERO, + } + .join_with_comma(e_.value); + } + } } _ => { let assign_target = Op::unary_assign_target(e_.op); diff --git a/test/bundler/esbuild/extra.test.ts b/test/bundler/esbuild/extra.test.ts index 9354073c7229..347fe2e0d4a3 100644 --- a/test/bundler/esbuild/extra.test.ts +++ b/test/bundler/esbuild/extra.test.ts @@ -671,6 +671,98 @@ describe("bundler", () => { run: true, }); + // Object/array define values should be hoisted to a single shared binding so + // identity holds and mutations are visible across references (esbuild parity). + itBundled("extra/DefineObjectIdentity", { + files: { + "in.js": /* js */ ` + const a = CFG, b = CFG; + if (a !== b) throw "identity: a !== b"; + if (a.nested !== b.nested) throw "identity: a.nested !== b.nested"; + a.k = 99; + if (b.k !== 99) throw "mutation: b.k !== 99"; + console.log("ok"); + `, + }, + define: { CFG: '{"k":1,"nested":{"x":2}}' }, + run: { stdout: "ok" }, + onAfterBundle(api) { + const out = api.readFile("out.js"); + const literals = out.match(/\{\s*k:\s*1/g); + if ((literals?.length ?? 0) !== 1) { + throw new Error("expected exactly one { k: 1 ... } literal in output, got " + (literals?.length ?? 0)); + } + }, + }); + itBundled("extra/DefineObjectIdentityDot", { + files: { + "in.js": /* js */ ` + const a = process.env.CFG, b = process.env.CFG; + if (a !== b) throw "identity: a !== b"; + if (ARR !== ARR) throw "identity: ARR !== ARR"; + const arr = ARR; + arr.push(4); + if (ARR.length !== 4) throw "mutation: ARR.length !== 4"; + console.log("ok"); + `, + }, + define: { "process.env.CFG": '{"k":1}', "ARR": "[1,2,3]" }, + run: { stdout: "ok" }, + }); + itBundled("extra/DefineObjectIdentityNoBundle", { + files: { + "in.js": /* js */ ` + const a = CFG, b = CFG; + if (a !== b) throw "identity: a !== b"; + console.log("ok"); + `, + }, + bundling: false, + define: { CFG: '{"k":1}' }, + run: { stdout: "ok" }, + }); + // `delete` on a hoisted-object define must not print `delete ` + // (strict-mode early error); the operand has to be wrapped. + itBundled("extra/DefineObjectDeleteTarget", { + files: { + "in.mjs": /* js */ ` + export {}; + console.log(delete process.env.CFG, delete import.meta.CFG, process.env.CFG.x); + `, + }, + define: { "process.env.CFG": '{"x":1}', "import.meta.CFG": "[1,2]" }, + run: { stdout: "true true 1" }, + onAfterBundle(api) { + const out = api.readFile("out.js"); + if (/\bdelete\s+[A-Za-z_$][\w$]*\s*[,;]/.test(out)) { + throw new Error("output contains `delete ` (strict-mode SyntaxError):\n" + out); + } + }, + }); + itBundled("extra/DefineObjectDeleteBareIdentifier", { + files: { + "in.js": /* js */ ` + // sloppy-mode source: delete is valid here + var keep = 1; + console.log(delete CFG, delete ARR, delete keep); + `, + }, + format: "iife", + define: { CFG: '{"k":1}', ARR: "[1,2,3]" }, + // iife + node keeps the bundle sloppy so `delete keep` stays valid syntax at runtime. + run: { runtime: "node", stdout: "true true false" }, + onAfterBundle(api) { + const out = api.readFile("out.js"); + if (/\bdelete\s+define_[\w$]*\s*[,;)]/.test(out)) { + throw new Error("hoisted define emitted as bare `delete `:\n" + out); + } + // Source-written `delete keep` must stay a bare identifier reference. + if (!/\bdelete\s+keep\b/.test(out)) { + throw new Error("source-written `delete keep` was wrapped:\n" + out); + } + }, + }); + // Various ESM cases itBundled("extra/CatchScope1", { files: {