From 41f07479c897271fd22f4456834bea0e8321feda Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:34:57 +0000 Subject: [PATCH 1/7] js_parser: track template_tag so wrapper folds don't rebind `this` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tagged template `expr`...`` binds `this` for `expr` the same way a call `expr()` does. The visit pass already tracks `p.call_target` so that folds like `(0, obj.m)()` → `obj.m()` are suppressed (they would change the receiver from undefined to `obj`), but it had no equivalent for tagged templates: the `.is_template_tag = ...` TODOs were left over from the port. That meant every fold that checks `is_call_target && has_value_for_this_in_call()` was wrong in tag position. Under `minify_syntax`: var obj = { m() { return this === obj } }; (1 ? obj.m : 0)`x` // node: false, bun: true (folded to obj.m`x`) (0, obj.m)`x` // node: false, bun: true (null ?? obj.m)`x` // node: false, bun: true (true && obj.m)`x` // node: false, bun: true (false || obj.m)`x` // node: false, bun: true [obj.m][0]`x` // node: false, bun: true ({m: obj.m}).m`x` // node: false, bun: true Add `p.template_tag` (mirroring `p.call_target`), set it in `e_template` before visiting the tag, and compute `is_template_tag` alongside `is_call_target` in `e_index`/`e_dot`/`e_if`/`e_binary`. Every `(0, x)` emission now fires for `is_call_target || is_template_tag`, and the `[x][0]` / `{f:x}.f` folds bail out in tag position (matching esbuild). The `IdentifierOpts` bit is wired through so `maybe_rewrite_property_access` sees it. Bump the runtime transpiler cache version. --- src/js_parser/fold.rs | 1 + src/js_parser/p.rs | 2 + src/js_parser/parser.rs | 16 ++++-- src/js_parser/visit/visit_binary.rs | 21 ++++++-- src/js_parser/visit/visit_expr.rs | 35 +++++++++---- src/jsc/RuntimeTranspilerCache.rs | 5 +- test/bundler/transpiler/transpiler.test.js | 58 ++++++++++++++++++++++ 7 files changed, 120 insertions(+), 18 deletions(-) diff --git a/src/js_parser/fold.rs b/src/js_parser/fold.rs index 085318aaa987..8f2f272bb2c5 100644 --- a/src/js_parser/fold.rs +++ b/src/js_parser/fold.rs @@ -471,6 +471,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O && !identifier_opts.is_delete_target() && identifier_opts.assign_target() == js_ast::AssignTarget::None && !identifier_opts.is_call_target() + && !identifier_opts.is_template_tag() { let prop: &G::Property = &obj.properties.slice()[0]; if let (Some(value), Some(key)) = (prop.value, prop.key) { diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index ae8c8539a8db..eca8f36db985 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -444,6 +444,7 @@ pub struct P<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> { // syntactic constructs as appropriate. pub(crate) stmt_expr_value: js_ast::ExprData, pub(crate) call_target: js_ast::ExprData, + pub(crate) template_tag: js_ast::ExprData, pub(crate) delete_target: js_ast::ExprData, pub(crate) loop_body: js_ast::StmtData, pub(crate) module_scope: js_ast::StoreRef, @@ -8710,6 +8711,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O allow_in: true, call_target: null_expr_data(), + template_tag: null_expr_data(), delete_target: null_expr_data(), stmt_expr_value: null_expr_data(), loop_body: null_stmt_data(), diff --git a/src/js_parser/parser.rs b/src/js_parser/parser.rs index 0d47bad98750..a47529b98e99 100644 --- a/src/js_parser/parser.rs +++ b/src/js_parser/parser.rs @@ -993,9 +993,9 @@ impl AsyncPrefixExpression { } // Packed u8 — assign_target:u2, is_delete_target:b1, -// was_originally_identifier:b1, is_call_target:b1, _padding:u3 (LSB-first). -// Not all-bool (assign_target is a 2-bit enum), so per PORTING.md we use a -// transparent u8 with manual shift accessors. +// was_originally_identifier:b1, is_call_target:b1, is_template_tag:b1, +// _padding:u2 (LSB-first). Not all-bool (assign_target is a 2-bit enum), +// so per PORTING.md we use a transparent u8 with manual shift accessors. #[repr(transparent)] #[derive(Clone, Copy, Default, PartialEq, Eq)] pub struct IdentifierOpts(u8); @@ -1005,6 +1005,7 @@ impl IdentifierOpts { const IS_DELETE_TARGET: u8 = 1 << 2; const WAS_ORIGINALLY_IDENTIFIER: u8 = 1 << 3; const IS_CALL_TARGET: u8 = 1 << 4; + const IS_TEMPLATE_TAG: u8 = 1 << 5; #[inline] pub(crate) const fn assign_target(self) -> js_ast::AssignTarget { @@ -1032,6 +1033,10 @@ impl IdentifierOpts { pub(crate) const fn is_call_target(self) -> bool { self.0 & Self::IS_CALL_TARGET != 0 } + #[inline] + pub(crate) const fn is_template_tag(self) -> bool { + self.0 & Self::IS_TEMPLATE_TAG != 0 + } // Builder-style helpers (this stays a packed u8 rather than a // named-field struct). @@ -1059,6 +1064,11 @@ impl IdentifierOpts { self.0 = (self.0 & !Self::IS_CALL_TARGET) | ((v as u8) << 4); self } + #[inline] + pub(crate) const fn with_is_template_tag(mut self, v: bool) -> Self { + self.0 = (self.0 & !Self::IS_TEMPLATE_TAG) | ((v as u8) << 5); + self + } } pub(crate) fn statement_cares_about_scope(stmt: &Stmt) -> bool { diff --git a/src/js_parser/visit/visit_binary.rs b/src/js_parser/visit/visit_binary.rs index 0b7d11afe789..13e567222ecd 100644 --- a/src/js_parser/visit/visit_binary.rs +++ b/src/js_parser/visit/visit_binary.rs @@ -117,6 +117,8 @@ impl BinaryExpressionVisitor { let is_call_target = matches!(p.call_target, ExprData::EBinary(ptr) if core::ptr::eq(ptr.as_ptr(), e_ptr)); + let is_template_tag = + matches!(p.template_tag, ExprData::EBinary(ptr) if core::ptr::eq(ptr.as_ptr(), e_ptr)); let was_anonymous_named_expr = e_.right.is_anonymous_named(); let prev_decorator_class_name = p.decorator_class_name; @@ -225,7 +227,9 @@ impl BinaryExpressionVisitor { } else { // The left operand has no side effects, but we need to preserve // the comma operator semantics when used as a call target - if is_call_target && e_.right.has_value_for_this_in_call() { + if (is_call_target || is_template_tag) + && e_.right.has_value_for_this_in_call() + { // Keep the comma expression to strip "this" binding e_.left = Expr { data: prefill::data::ZERO, @@ -369,7 +373,10 @@ impl BinaryExpressionVisitor { // "(null ?? fn)()" => "fn()" // "(null ?? this.fn)" => "this.fn" // "(null ?? this.fn)()" => "(0, this.fn)()" - if is_call_target && e_.right.has_value_for_this_in_call() { + // "(null ?? this.fn)`x`" => "(0, this.fn)`x`" + if (is_call_target || is_template_tag) + && e_.right.has_value_for_this_in_call() + { return Expr::join_with_comma( Expr { data: ExprData::ENumber(E::Number::new(0.0)), @@ -392,7 +399,10 @@ impl BinaryExpressionVisitor { // "(0 || fn)()" => "fn()" // "(0 || this.fn)" => "this.fn" // "(0 || this.fn)()" => "(0, this.fn)()" - if is_call_target && e_.right.has_value_for_this_in_call() { + // "(0 || this.fn)`x`" => "(0, this.fn)`x`" + if (is_call_target || is_template_tag) + && e_.right.has_value_for_this_in_call() + { return Expr::join_with_comma( Expr { data: prefill::data::ZERO, @@ -414,7 +424,10 @@ impl BinaryExpressionVisitor { // "(1 && fn)()" => "fn()" // "(1 && this.fn)" => "this.fn" // "(1 && this.fn)()" => "(0, this.fn)()" - if is_call_target && e_.right.has_value_for_this_in_call() { + // "(1 && this.fn)`x`" => "(0, this.fn)`x`" + if (is_call_target || is_template_tag) + && e_.right.has_value_for_this_in_call() + { return Expr::join_with_comma( Expr { data: prefill::data::ZERO, diff --git a/src/js_parser/visit/visit_expr.rs b/src/js_parser/visit/visit_expr.rs index 0d5c834fde53..30abbea2deff 100644 --- a/src/js_parser/visit/visit_expr.rs +++ b/src/js_parser/visit/visit_expr.rs @@ -692,8 +692,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let expr = *e; let _ = in_; let mut e_ = expr.data.e_template().expect("infallible: variant checked"); - if e_.tag.is_some() { - p.visit_expr(e_.tag.as_mut().unwrap()); + if let Some(tag) = e_.tag.as_mut() { + p.template_tag = tag.data; + p.visit_expr(tag); } // Visit the interpolation values before the macro dispatch below: its @@ -885,6 +886,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let expr = *e; let mut e_ = expr.data.e_index().expect("infallible: variant checked"); let is_call_target = matches!(p.call_target, Data::EIndex(ct) if core::ptr::eq(&raw const *e_, &raw const *ct)); + let is_template_tag = matches!(p.template_tag, Data::EIndex(tt) if core::ptr::eq(&raw const *e_, &raw const *tt)); let is_delete_target = matches!(p.delete_target, Data::EIndex(dt) if core::ptr::eq(&raw const *e_, &raw const *dt)); // "a['b']" => "a.b" @@ -905,6 +907,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O if is_call_target { p.call_target = dot.data; } + if is_template_tag { + p.template_tag = dot.data; + } if is_delete_target { p.delete_target = dot.data; } @@ -1020,6 +1025,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O if is_call_target { p.call_target = dot.data; } + if is_template_tag { + p.template_tag = dot.data; + } if is_delete_target { p.delete_target = dot.data; } @@ -1041,7 +1049,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O unwrapped.loc, IdentifierOpts::default() .with_is_call_target(is_call_target) - // .is_template_tag = is_template_tag, + .with_is_template_tag(is_template_tag) .with_is_delete_target(is_delete_target) .with_assign_target(in_.assign_target), ) { @@ -1057,7 +1065,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let target = e_.target.unwrap_inlined(); let index = e_.index.unwrap_inlined(); - if p.options.features.minify_syntax { + // `[obj.m][0]` / `"s"[n]` are property references into a temporary; + // folding them to a value in tag position would rebind `this`. + if p.options.features.minify_syntax && !is_template_tag { if let Some(number) = index.data.as_e_number() { if number.value() >= 0.0 && number.value() < (usize::MAX as f64) @@ -1335,6 +1345,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let mut e_ = expr.data.e_dot().expect("infallible: variant checked"); let is_delete_target = matches!(p.delete_target, Data::EDot(dt) if core::ptr::eq(&raw const *e_, &raw const *dt)); let is_call_target = matches!(p.call_target, Data::EDot(ct) if core::ptr::eq(&raw const *e_, &raw const *ct)); + let is_template_tag = matches!(p.template_tag, Data::EDot(tt) if core::ptr::eq(&raw const *e_, &raw const *tt)); // `p.define: &'a Define` is `Copy`; hoist so the `dots.get` borrow is // tied to `'a`, not `&*p`, and `&mut self` helpers below can be called @@ -1427,9 +1438,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O e_.name_loc, IdentifierOpts::default() .with_is_call_target(is_call_target) + .with_is_template_tag(is_template_tag) .with_assign_target(in_.assign_target) .with_is_delete_target(is_delete_target), - // .is_template_tag = p.template_tag != null, ) { *e = _expr; return; @@ -1460,6 +1471,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let mut e_ = e.data.e_if().expect("infallible: variant checked"); let is_call_target = matches!(p.call_target, Data::EIf(ct) if core::ptr::eq(&raw const *e_, &raw const *ct)); + let is_template_tag = + matches!(p.template_tag, Data::EIf(tt) if core::ptr::eq(&raw const *e_, &raw const *tt)); let prev_in_branch = p.in_branch_condition; p.in_branch_condition = true; @@ -1493,7 +1506,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // "(1 ? fn : 2)()" => "fn()" // "(1 ? this.fn : 2)" => "this.fn" // "(1 ? this.fn : 2)()" => "(0, this.fn)()" - if is_call_target && e_.yes.has_value_for_this_in_call() { + // "(1 ? this.fn : 2)`x`" => "(0, this.fn)`x`" + if (is_call_target || is_template_tag) && e_.yes.has_value_for_this_in_call() { *e = p .new_expr(E::Number::new(0.0), e_.test.loc) .join_with_comma(e_.yes); @@ -1518,10 +1532,11 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O return; } - // "(1 ? fn : 2)()" => "fn()" - // "(1 ? this.fn : 2)" => "this.fn" - // "(1 ? this.fn : 2)()" => "(0, this.fn)()" - if is_call_target && e_.no.has_value_for_this_in_call() { + // "(0 ? 1 : fn)()" => "fn()" + // "(0 ? 1 : this.fn)" => "this.fn" + // "(0 ? 1 : this.fn)()" => "(0, this.fn)()" + // "(0 ? 1 : this.fn)`x`" => "(0, this.fn)`x`" + if (is_call_target || is_template_tag) && e_.no.has_value_for_this_in_call() { *e = p .new_expr(E::Number::new(0.0), e_.test.loc) .join_with_comma(e_.no); diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index ab204c146078..a0d046eca8b7 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -48,7 +48,10 @@ bun_core::declare_scope!(cache, visible); /// bindings from the compiled bytecode after the module-loader rewrite, so the /// record no longer carries them; blobs written in the old numbering must not /// be read back. -const EXPECTED_VERSION: u32 = 24; +/// Version 25: `p.template_tag` is tracked during the visit pass, so wrapper +/// folds (`(0, a.b)`, `cond ? a.b : x`, `a.b ?? x`, `[a.b][0]`, `{f:a.b}.f`) +/// no longer strip the indirection when the result is a tagged-template tag. +const EXPECTED_VERSION: u32 = 25; /// Source files smaller than this are not written to / read from the on-disk /// transpiler cache. Originally 50 KiB, which excluded almost every file in a diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index d72bcc6fddcd..f4d589e5ecac 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -3919,6 +3919,64 @@ console.log(foo, array); expectPrinted("(0, func())", "func()"); }); + it("tagged-template tag folds preserve `this`", () => { + const expectPrinted = (code, out) => { + expect(parsed(code, true, true, transpilerMinifySyntax)).toBe(out); + }; + + // A tagged template binds `this` the same way a call does: folding a + // wrapper away so that a member expression lands directly in tag + // position would change the receiver. These match the call-target + // cases above, emitting `(0, obj.m)` to strip `this`. + expectPrinted("(0, obj.m)`x`", "(0, obj.m)`x`"); + expectPrinted("(0, obj[k])`x`", "(0, obj[k])`x`"); + expectPrinted("(1 ? obj.m : 0)`x`", "(0, obj.m)`x`"); + expectPrinted("(0 ? 0 : obj.m)`x`", "(0, obj.m)`x`"); + expectPrinted("(null ?? obj.m)`x`", "(0, obj.m)`x`"); + expectPrinted("(1 && obj.m)`x`", "(0, obj.m)`x`"); + expectPrinted("(0 || obj.m)`x`", "(0, obj.m)`x`"); + // Property-access folds that replace a reference with its value bail + // out entirely in tag position. + expectPrinted("[obj.m][0]`x`", "[obj.m][0]`x`"); + expectPrinted("({ m: obj.m }).m`x`", "{ m: obj.m }.m`x`"); + expectPrinted('({ m: obj.m })["m"]`x`', "{ m: obj.m }.m`x`"); + + // Still folded when the wrapped value carries no `this`. + expectPrinted("(0, fn)`x`", "fn`x`"); + expectPrinted("(1 ? fn : 0)`x`", "fn`x`"); + expectPrinted("(null ?? fn)`x`", "fn`x`"); + expectPrinted("(1 && fn)`x`", "fn`x`"); + expectPrinted("(0 || fn)`x`", "fn`x`"); + + // Still folded outside call/tag position. + expectPrinted("(0, obj.m)", "obj.m"); + expectPrinted("[obj.m][0]", "obj.m"); + expectPrinted("({ m: obj.m }).m", "obj.m"); + }); + + it("tagged-template tag `this` matches node at runtime", async () => { + const src = ` + var obj = { m() { return this === obj; } }; + console.log(JSON.stringify([ + (0, obj.m)\`x\`, + (1 ? obj.m : 0)\`x\`, + (0 ? 0 : obj.m)\`x\`, + (null ?? obj.m)\`x\`, + (true && obj.m)\`x\`, + (false || obj.m)\`x\`, + [obj.m][0]\`x\`, + ({ m: obj.m }).m\`x\`, + ({ m: obj.m })["m"]\`x\`, + obj.m\`x\`, + ])); + `; + await using proc = Bun.spawn({ cmd: [bunExe(), "-e", src], env: bunEnv, stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(stdout).toBe("[false,false,false,false,false,false,false,false,false,true]\n"); + expect(exitCode).toBe(0); + }); + it("constant folding", () => { const expectPrinted = (code, out) => { expect(parsed(code, true, true, transpilerMinifySyntax)).toBe(out); From 4135c52f47e04db44c6e649d3738d2d91f3a376f Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:38:18 +0000 Subject: [PATCH 2/7] [autofix.ci] apply automated fixes --- src/js_parser/visit/visit_binary.rs | 3 +-- src/js_parser/visit/visit_expr.rs | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/js_parser/visit/visit_binary.rs b/src/js_parser/visit/visit_binary.rs index 13e567222ecd..32e517472614 100644 --- a/src/js_parser/visit/visit_binary.rs +++ b/src/js_parser/visit/visit_binary.rs @@ -400,8 +400,7 @@ impl BinaryExpressionVisitor { // "(0 || this.fn)" => "this.fn" // "(0 || this.fn)()" => "(0, this.fn)()" // "(0 || this.fn)`x`" => "(0, this.fn)`x`" - if (is_call_target || is_template_tag) - && e_.right.has_value_for_this_in_call() + if (is_call_target || is_template_tag) && e_.right.has_value_for_this_in_call() { return Expr::join_with_comma( Expr { diff --git a/src/js_parser/visit/visit_expr.rs b/src/js_parser/visit/visit_expr.rs index 30abbea2deff..e7b5037a6b9b 100644 --- a/src/js_parser/visit/visit_expr.rs +++ b/src/js_parser/visit/visit_expr.rs @@ -1471,8 +1471,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let mut e_ = e.data.e_if().expect("infallible: variant checked"); let is_call_target = matches!(p.call_target, Data::EIf(ct) if core::ptr::eq(&raw const *e_, &raw const *ct)); - let is_template_tag = - matches!(p.template_tag, Data::EIf(tt) if core::ptr::eq(&raw const *e_, &raw const *tt)); + let is_template_tag = matches!(p.template_tag, Data::EIf(tt) if core::ptr::eq(&raw const *e_, &raw const *tt)); let prev_in_branch = p.in_branch_condition; p.in_branch_condition = true; From 17ef1c48481bbb54e6fad0ec672af728223830c6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:06:09 +0000 Subject: [PATCH 3/7] e_if: guard CouldHaveSideEffects fold against bare-member arm in call/tag position When the ternary test is statically known but conservatively classed as CouldHaveSideEffects (e.g. `typeof x`), `simplify_unused_expr` can drop it entirely, so `EMissing.join_with_comma(arm)` returned the bare arm and the `(0, arm)` guard below never ran. Unify both paths so the guard applies regardless of the test's side-effect class; when a real side effect survives, the resulting `(side, arm)` comma already strips `this`. Fixes both `(typeof x ? obj.m : 0)`t`` and the pre-existing call-target case `(typeof x ? obj.m : 0)()`. --- src/js_parser/visit/visit_expr.rs | 51 ++++++++++------------ test/bundler/transpiler/transpiler.test.js | 12 ++++- 2 files changed, 35 insertions(+), 28 deletions(-) diff --git a/src/js_parser/visit/visit_expr.rs b/src/js_parser/visit/visit_expr.rs index e7b5037a6b9b..c1c26413f33b 100644 --- a/src/js_parser/visit/visit_expr.rs +++ b/src/js_parser/visit/visit_expr.rs @@ -1495,25 +1495,23 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O p.visit_expr(&mut e_.no); p.is_control_flow_dead = old; - if side_effects.side_effects == SideEffects::CouldHaveSideEffects { - *e = SideEffects::simplify_unused_expr(p, e_.test) - .unwrap_or_else(|| p.new_expr(E::Missing {}, e_.test.loc)) - .join_with_comma(e_.yes); - return; - } - // "(1 ? fn : 2)()" => "fn()" // "(1 ? this.fn : 2)" => "this.fn" // "(1 ? this.fn : 2)()" => "(0, this.fn)()" // "(1 ? this.fn : 2)`x`" => "(0, this.fn)`x`" - if (is_call_target || is_template_tag) && e_.yes.has_value_for_this_in_call() { - *e = p - .new_expr(E::Number::new(0.0), e_.test.loc) - .join_with_comma(e_.yes); - return; + let mut left = if side_effects.side_effects == SideEffects::CouldHaveSideEffects { + SideEffects::simplify_unused_expr(p, e_.test) + .unwrap_or_else(|| p.new_expr(E::Missing {}, e_.test.loc)) + } else { + p.new_expr(E::Missing {}, e_.test.loc) + }; + if left.is_missing() + && (is_call_target || is_template_tag) + && e_.yes.has_value_for_this_in_call() + { + left = p.new_expr(E::Number::new(0.0), e_.test.loc); } - - *e = e_.yes; + *e = left.join_with_comma(e_.yes); return; } else { // "false ? dead : live" @@ -1524,24 +1522,23 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O p.visit_expr(&mut e_.no); // "(a, false) ? b : c" => "a, c" - if side_effects.side_effects == SideEffects::CouldHaveSideEffects { - *e = SideEffects::simplify_unused_expr(p, e_.test) - .unwrap_or_else(|| p.new_expr(E::Missing {}, e_.test.loc)) - .join_with_comma(e_.no); - return; - } - // "(0 ? 1 : fn)()" => "fn()" // "(0 ? 1 : this.fn)" => "this.fn" // "(0 ? 1 : this.fn)()" => "(0, this.fn)()" // "(0 ? 1 : this.fn)`x`" => "(0, this.fn)`x`" - if (is_call_target || is_template_tag) && e_.no.has_value_for_this_in_call() { - *e = p - .new_expr(E::Number::new(0.0), e_.test.loc) - .join_with_comma(e_.no); - return; + let mut left = if side_effects.side_effects == SideEffects::CouldHaveSideEffects { + SideEffects::simplify_unused_expr(p, e_.test) + .unwrap_or_else(|| p.new_expr(E::Missing {}, e_.test.loc)) + } else { + p.new_expr(E::Missing {}, e_.test.loc) + }; + if left.is_missing() + && (is_call_target || is_template_tag) + && e_.no.has_value_for_this_in_call() + { + left = p.new_expr(E::Number::new(0.0), e_.test.loc); } - *e = e_.no; + *e = left.join_with_comma(e_.no); return; } } diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index f4d589e5ecac..dc7b799c4d82 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -3932,6 +3932,13 @@ console.log(foo, array); expectPrinted("(0, obj[k])`x`", "(0, obj[k])`x`"); expectPrinted("(1 ? obj.m : 0)`x`", "(0, obj.m)`x`"); expectPrinted("(0 ? 0 : obj.m)`x`", "(0, obj.m)`x`"); + // Statically-known test whose side-effect class is `CouldHaveSideEffects` + // but simplifies away entirely: the fold must still keep `(0, obj.m)`. + expectPrinted("(typeof x ? obj.m : 0)`x`", "(0, obj.m)`x`"); + expectPrinted("(typeof x ? obj.m : 0)()", "(0, obj.m)()"); + // When the test leaves a real side effect behind, the resulting comma + // already strips `this`; no extra `0,` is needed. + expectPrinted("(f() || 1 ? obj.m : 0)`x`", "(f(), obj.m)`x`"); expectPrinted("(null ?? obj.m)`x`", "(0, obj.m)`x`"); expectPrinted("(1 && obj.m)`x`", "(0, obj.m)`x`"); expectPrinted("(0 || obj.m)`x`", "(0, obj.m)`x`"); @@ -3957,10 +3964,13 @@ console.log(foo, array); it("tagged-template tag `this` matches node at runtime", async () => { const src = ` var obj = { m() { return this === obj; } }; + var x = 1; console.log(JSON.stringify([ (0, obj.m)\`x\`, (1 ? obj.m : 0)\`x\`, (0 ? 0 : obj.m)\`x\`, + (typeof x ? obj.m : 0)\`x\`, + (typeof x ? obj.m : 0)(), (null ?? obj.m)\`x\`, (true && obj.m)\`x\`, (false || obj.m)\`x\`, @@ -3973,7 +3983,7 @@ console.log(foo, array); await using proc = Bun.spawn({ cmd: [bunExe(), "-e", src], env: bunEnv, stderr: "pipe" }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).toBe(""); - expect(stdout).toBe("[false,false,false,false,false,false,false,false,false,true]\n"); + expect(stdout).toBe("[false,false,false,false,false,false,false,false,false,false,false,true]\n"); expect(exitCode).toBe(0); }); From 43d530bbef4c77aab20bfb83191790c0abbe3e54 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:45:30 +0000 Subject: [PATCH 4/7] e_binary: capture is_call_target/is_template_tag before visiting left MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `visit_right_and_finish` read `p.call_target`/`p.template_tag` after the left operand had already been visited, so any nested call/tagged-template in left overwrote the slot and the outer binary's identity check came up false. Move the capture into `check_and_prepare` (runs before any child visit) and stash it on `BinaryExpressionVisitor`, matching the early-capture shape of `e_index`/`e_dot`/`e_if`. Fixes `((() => f`a`), obj.m)`x`` folding to `obj.m`x``, and the pre-existing call-target twin `((() => f()), obj.m)()` → `obj.m()`. Also adds the falsy-arm `CouldHaveSideEffects` mirror (`typeof x && 0`) to the ternary coverage. --- src/js_parser/visit/visit_binary.rs | 18 +++++++++++++----- src/js_parser/visit/visit_expr.rs | 4 ++++ test/bundler/transpiler/transpiler.test.js | 14 ++++++++++++-- 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/js_parser/visit/visit_binary.rs b/src/js_parser/visit/visit_binary.rs index 32e517472614..6c46482c8d46 100644 --- a/src/js_parser/visit/visit_binary.rs +++ b/src/js_parser/visit/visit_binary.rs @@ -99,6 +99,11 @@ pub struct BinaryExpressionVisitor { /// Input for visiting the left child pub(crate) left_in: ExprIn, + + /// Captured in `check_and_prepare` (before visiting `left`) so a nested + /// call/tagged-template inside `left` can't clobber the pointer match. + pub(crate) is_call_target: bool, + pub(crate) is_template_tag: bool, } impl BinaryExpressionVisitor { @@ -112,13 +117,10 @@ impl BinaryExpressionVisitor { // invariant is encapsulated there. The borrow is on the `v.e` field // only, so `v.loc` reads below split-borrow cleanly. let e_handle: StoreRef = v.e; - let e_ptr: *mut E::Binary = e_handle.as_ptr(); + let is_call_target = v.is_call_target; + let is_template_tag = v.is_template_tag; let e_ = &mut *v.e; - let is_call_target = - matches!(p.call_target, ExprData::EBinary(ptr) if core::ptr::eq(ptr.as_ptr(), e_ptr)); - let is_template_tag = - matches!(p.template_tag, ExprData::EBinary(ptr) if core::ptr::eq(ptr.as_ptr(), e_ptr)); let was_anonymous_named_expr = e_.right.is_anonymous_named(); let prev_decorator_class_name = p.decorator_class_name; @@ -789,6 +791,12 @@ impl BinaryExpressionVisitor { _ => {} } + let e_ptr: *mut E::Binary = e_handle.as_ptr(); + v.is_call_target = + matches!(p.call_target, ExprData::EBinary(ptr) if core::ptr::eq(ptr.as_ptr(), e_ptr)); + v.is_template_tag = + matches!(p.template_tag, ExprData::EBinary(ptr) if core::ptr::eq(ptr.as_ptr(), e_ptr)); + v.left_in = ExprIn { assign_target: Op::Code::binary_assign_target(e_.op), ..ExprIn::default() diff --git a/src/js_parser/visit/visit_expr.rs b/src/js_parser/visit/visit_expr.rs index c1c26413f33b..4197c943a45c 100644 --- a/src/js_parser/visit/visit_expr.rs +++ b/src/js_parser/visit/visit_expr.rs @@ -821,6 +821,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O e: e_, loc: expr.loc, left_in: ExprIn::default(), + is_call_target: false, + is_template_tag: false, }; // Everything uses a single stack to reduce allocation overhead. This stack @@ -868,6 +870,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O e: left_binary.unwrap(), loc: left.loc, left_in: ExprIn::default(), + is_call_target: false, + is_template_tag: false, }; } diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index dc7b799c4d82..c6c12a5f16fa 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -3936,12 +3936,19 @@ console.log(foo, array); // but simplifies away entirely: the fold must still keep `(0, obj.m)`. expectPrinted("(typeof x ? obj.m : 0)`x`", "(0, obj.m)`x`"); expectPrinted("(typeof x ? obj.m : 0)()", "(0, obj.m)()"); + expectPrinted("(typeof x && 0 ? 0 : obj.m)`x`", "(0, obj.m)`x`"); + expectPrinted("(typeof x && 0 ? 0 : obj.m)()", "(0, obj.m)()"); // When the test leaves a real side effect behind, the resulting comma // already strips `this`; no extra `0,` is needed. expectPrinted("(f() || 1 ? obj.m : 0)`x`", "(f(), obj.m)`x`"); expectPrinted("(null ?? obj.m)`x`", "(0, obj.m)`x`"); expectPrinted("(1 && obj.m)`x`", "(0, obj.m)`x`"); expectPrinted("(0 || obj.m)`x`", "(0, obj.m)`x`"); + // A call/tagged-template nested inside the comma's left operand must not + // clobber the outer binary's call/tag-position capture. + expectPrinted("((() => f`a`), obj.m)`x`", "(0, obj.m)`x`"); + expectPrinted("((true ? 0 : f`a`), obj.m)`x`", "(0, obj.m)`x`"); + expectPrinted("((() => f()), obj.m)()", "(0, obj.m)()"); // Property-access folds that replace a reference with its value bail // out entirely in tag position. expectPrinted("[obj.m][0]`x`", "[obj.m][0]`x`"); @@ -3964,16 +3971,19 @@ console.log(foo, array); it("tagged-template tag `this` matches node at runtime", async () => { const src = ` var obj = { m() { return this === obj; } }; - var x = 1; + var x = 1, f = () => 0; console.log(JSON.stringify([ (0, obj.m)\`x\`, (1 ? obj.m : 0)\`x\`, (0 ? 0 : obj.m)\`x\`, (typeof x ? obj.m : 0)\`x\`, (typeof x ? obj.m : 0)(), + (typeof x && 0 ? 0 : obj.m)\`x\`, (null ?? obj.m)\`x\`, (true && obj.m)\`x\`, (false || obj.m)\`x\`, + ((() => f\`a\`), obj.m)\`x\`, + ((() => f()), obj.m)(), [obj.m][0]\`x\`, ({ m: obj.m }).m\`x\`, ({ m: obj.m })["m"]\`x\`, @@ -3983,7 +3993,7 @@ console.log(foo, array); await using proc = Bun.spawn({ cmd: [bunExe(), "-e", src], env: bunEnv, stderr: "pipe" }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).toBe(""); - expect(stdout).toBe("[false,false,false,false,false,false,false,false,false,false,false,true]\n"); + expect(stdout).toBe("[false,false,false,false,false,false,false,false,false,false,false,false,false,false,true]\n"); expect(exitCode).toBe(0); }); From d2b13d3db9da38b413c653db0ef088c6648ad36f Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:47:49 +0000 Subject: [PATCH 5/7] [autofix.ci] apply automated fixes --- test/bundler/transpiler/transpiler.test.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index c6c12a5f16fa..777e56ffa9f5 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -3993,7 +3993,9 @@ console.log(foo, array); await using proc = Bun.spawn({ cmd: [bunExe(), "-e", src], env: bunEnv, stderr: "pipe" }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).toBe(""); - expect(stdout).toBe("[false,false,false,false,false,false,false,false,false,false,false,false,false,false,true]\n"); + expect(stdout).toBe( + "[false,false,false,false,false,false,false,false,false,false,false,false,false,false,true]\n", + ); expect(exitCode).toBe(0); }); From fa8034f395fede57831fd99e1ce532d378df8c7f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:06:25 +0000 Subject: [PATCH 6/7] ci: retrigger From 99c6d51eaba151e83c99638d7fee8be47de51086 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:37:16 +0000 Subject: [PATCH 7/7] guard the remaining tag-position folds that can emit a bare member Two more parallel-arm sites in the same bug class: - `BinNullishCoalescingAssign | BinLogicalOrAssign` in `visit_right_and_finish` (the `{}.x ??= v` HMR fold) returned `e_.right` bare with no `has_value_for_this_in_call()` guard. Fires without `minify_syntax`. `({}.x ??= obj.m)()` / `({}.x ??= obj.m)\`t\`` now emit `(0, obj.m)`. - `substitute_single_use_symbol_in_expr`'s `ETemplate` arm lacked the 'don't substitute into a target that could change this' check the parallel `ECall` arm has. `let x = obj.m; x\`t\`` no longer inlines to `obj.m\`t\``. Both were pre-existing for `is_call_target` too and neither is covered by #36730/#36734. --- src/js_parser/p.rs | 10 ++++++++++ src/js_parser/visit/visit_binary.rs | 11 +++++++++++ test/bundler/transpiler/transpiler.test.js | 20 +++++++++++++++++++- 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index eca8f36db985..ddce1ebf651e 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -2647,6 +2647,16 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } js_ast::ExprData::ETemplate(mut e) => { if let Some(tag) = e.tag.as_mut() { + // Don't substitute something into a template tag that could change "this" + match replacement.data { + js_ast::ExprData::EDot(_) | js_ast::ExprData::EIndex(_) => { + if matches!(tag.data, js_ast::ExprData::EIdentifier(id) if id.ref_.eql(r#ref)) + { + break 'outer; + } + } + _ => {} + } match self.substitute_single_use_symbol_in_expr( *tag, r#ref, diff --git a/src/js_parser/visit/visit_binary.rs b/src/js_parser/visit/visit_binary.rs index 6c46482c8d46..6d854cd354d4 100644 --- a/src/js_parser/visit/visit_binary.rs +++ b/src/js_parser/visit/visit_binary.rs @@ -725,6 +725,17 @@ impl BinaryExpressionVisitor { if let Some(obj) = dot.target.data.e_object() { if obj.properties.len_u32() == 0 { if dot.name != b"__proto__" { + if (is_call_target || is_template_tag) + && e_.right.has_value_for_this_in_call() + { + return Expr::join_with_comma( + Expr { + data: prefill::data::ZERO, + loc: e_.left.loc, + }, + e_.right, + ); + } return e_.right; } } diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 777e56ffa9f5..53e920cdfdb8 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -3949,6 +3949,21 @@ console.log(foo, array); expectPrinted("((() => f`a`), obj.m)`x`", "(0, obj.m)`x`"); expectPrinted("((true ? 0 : f`a`), obj.m)`x`", "(0, obj.m)`x`"); expectPrinted("((() => f()), obj.m)()", "(0, obj.m)()"); + // The `{}.x ??= v` / `{}.x ||= v` HMR fold also needs the guard. + expectPrinted("({}.x ??= obj.m)`x`", "(0, obj.m)`x`"); + expectPrinted("({}.x ??= obj.m)()", "(0, obj.m)()"); + expectPrinted("({}.x ||= obj.m)`x`", "(0, obj.m)`x`"); + expectPrinted("({}.x ??= fn)`x`", "fn`x`"); + // Single-use-symbol inlining must not move a member expression into + // identifier tag position (mirrors the existing call-target guard). + const subst = src => parsed(src, true, false, transpilerMinifySyntax); + expect(subst("function f(obj) { let x = obj.m; return x`t`; }")).toBe( + "function f(obj) {\n let x = obj.m;\n return x`t`;\n}", + ); + expect(subst("function f(obj) { let x = obj[k]; return x`t`; }")).toBe( + "function f(obj) {\n let x = obj[k];\n return x`t`;\n}", + ); + expect(subst("function f(obj) { let x = obj.m; return x; }")).toBe("function f(obj) {\n return obj.m;\n}"); // Property-access folds that replace a reference with its value bail // out entirely in tag position. expectPrinted("[obj.m][0]`x`", "[obj.m][0]`x`"); @@ -3984,6 +3999,9 @@ console.log(foo, array); (false || obj.m)\`x\`, ((() => f\`a\`), obj.m)\`x\`, ((() => f()), obj.m)(), + ({}.x ??= obj.m)\`x\`, + ({}.x ??= obj.m)(), + (function(){ let y = obj.m; return y\`t\`; })(), [obj.m][0]\`x\`, ({ m: obj.m }).m\`x\`, ({ m: obj.m })["m"]\`x\`, @@ -3994,7 +4012,7 @@ console.log(foo, array); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).toBe(""); expect(stdout).toBe( - "[false,false,false,false,false,false,false,false,false,false,false,false,false,false,true]\n", + "[false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,true]\n", ); expect(exitCode).toBe(0); });