From 3758bcc420f96a774e7973bf49db99bf939bde00 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 07:54:51 +0000 Subject: [PATCH 1/4] js_parser: don't fold wrappers that expose anonymous class/fn to NamedEvaluation The runtime transpiler (target: bun) enables minify_syntax, which folds expressions like (0, class {}), true && class {}, [class {}][0], ({k: class {}}).k, and (true ? class {} : 0) down to a bare class {}. Printing that into a binding position (const A = class {}) triggers NamedEvaluation, so the class/function picks up the binding name instead of staying anonymous per spec. Guard each fold site with is_anonymous_named() the same way we already guard for has_value_for_this_in_call(): emit (0, expr) instead of a bare anonymous function/class/arrow. For the object-literal property case the property key is the spec name, so skip the fold instead of wrapping. --- src/js_parser/fold.rs | 4 ++ src/js_parser/visit/visit_binary.rs | 38 +++++++--- src/js_parser/visit/visit_expr.rs | 31 ++++++-- .../transpiler/runtime-transpiler.test.ts | 71 +++++++++++++++++++ 4 files changed, 131 insertions(+), 13 deletions(-) diff --git a/src/js_parser/fold.rs b/src/js_parser/fold.rs index 08e2f4378ae0..9ae8f8775774 100644 --- a/src/js_parser/fold.rs +++ b/src/js_parser/fold.rs @@ -483,6 +483,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O name, ) && name != b"__proto__" + // "({ f: class {} }).f" assigns name "f"; inlining + // would drop that and expose the class to the outer + // NamedEvaluation position instead. + && !value.is_anonymous_named() { return Some(value); } diff --git a/src/js_parser/visit/visit_binary.rs b/src/js_parser/visit/visit_binary.rs index 36ae53852428..44a05ee83f03 100644 --- a/src/js_parser/visit/visit_binary.rs +++ b/src/js_parser/visit/visit_binary.rs @@ -203,6 +203,7 @@ impl BinaryExpressionVisitor { // "(sideEffects(), 2)" => "(sideEffects(), 2)" // "(0, this.fn)" => "this.fn" // "(0, this.fn)()" => "(0, this.fn)()" + // "(0, class {})" => "(0, class {})" if p.options.features.minify_syntax { // If e_.left is itself a comma, its .left was already simplified // by the previous unwind step; only simplify its .right to avoid @@ -221,14 +222,24 @@ impl BinaryExpressionVisitor { }; if let Some(simplified_left) = simplified_left { if simplified_left.is_empty() { - return e_.right; + if e_.right.is_anonymous_named() { + e_.left = Expr { + data: prefill::data::ZERO, + loc: e_.left.loc, + }; + } else { + return e_.right; + } + } else { + e_.left = simplified_left; } - e_.left = simplified_left; } 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() { - // Keep the comma expression to strip "this" binding + // the comma operator semantics when used as a call target, and + // avoid exposing an anonymous function/class to NamedEvaluation. + if (is_call_target && e_.right.has_value_for_this_in_call()) + || e_.right.is_anonymous_named() + { e_.left = Expr { data: prefill::data::ZERO, loc: e_.left.loc, @@ -371,7 +382,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 ?? class {})" => "(0, class {})" + if (is_call_target && e_.right.has_value_for_this_in_call()) + || e_.right.is_anonymous_named() + { return Expr::join_with_comma( Expr { data: ExprData::ENumber(E::Number::new(0.0)), @@ -394,7 +408,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 || class {})" => "(0, class {})" + if (is_call_target && e_.right.has_value_for_this_in_call()) + || e_.right.is_anonymous_named() + { return Expr::join_with_comma( Expr { data: prefill::data::ZERO, @@ -416,7 +433,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 && class {})" => "(0, class {})" + if (is_call_target && e_.right.has_value_for_this_in_call()) + || e_.right.is_anonymous_named() + { return Expr::join_with_comma( Expr { data: prefill::data::ZERO, @@ -712,7 +732,7 @@ impl BinaryExpressionVisitor { if let Some(dot) = e_.left.data.e_dot() { if let Some(obj) = dot.target.data.e_object() { if obj.properties.len_u32() == 0 { - if dot.name != b"__proto__" { + if dot.name != b"__proto__" && !e_.right.is_anonymous_named() { return e_.right; } } diff --git a/src/js_parser/visit/visit_expr.rs b/src/js_parser/visit/visit_expr.rs index fa3efcb7c061..23f632a058c3 100644 --- a/src/js_parser/visit/visit_expr.rs +++ b/src/js_parser/visit/visit_expr.rs @@ -1091,7 +1091,16 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O if array.items.len_u32() == 1 && number.value() == 0.0 { let inlined = *array.items.at(0); if inlined.can_be_inlined_from_property_access() { - *e = inlined; + // [class {}][0] -> (0, class {}) + *e = if inlined.is_anonymous_named() { + Expr { + data: prefill::data::ZERO, + loc: expr.loc, + } + .join_with_comma(inlined) + } else { + inlined + }; return; } } @@ -1108,7 +1117,15 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O return; } debug_assert!(inlined.can_be_inlined_from_property_access()); - *e = inlined; + *e = if inlined.is_anonymous_named() { + Expr { + data: prefill::data::ZERO, + loc: expr.loc, + } + .join_with_comma(inlined) + } else { + inlined + }; return; } } @@ -1494,7 +1511,10 @@ 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 ? class {} : 2)" => "(0, class {})" + if (is_call_target && e_.yes.has_value_for_this_in_call()) + || e_.yes.is_anonymous_named() + { *e = p .new_expr(E::Number::new(0.0), e_.test_.loc) .join_with_comma(e_.yes); @@ -1522,7 +1542,10 @@ 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_.no.has_value_for_this_in_call() { + // "(0 ? 1 : class {})" => "(0, class {})" + if (is_call_target && e_.no.has_value_for_this_in_call()) + || e_.no.is_anonymous_named() + { *e = p .new_expr(E::Number::new(0.0), e_.test_.loc) .join_with_comma(e_.no); diff --git a/test/bundler/transpiler/runtime-transpiler.test.ts b/test/bundler/transpiler/runtime-transpiler.test.ts index 5c05e408c92a..ad3697e30d80 100644 --- a/test/bundler/transpiler/runtime-transpiler.test.ts +++ b/test/bundler/transpiler/runtime-transpiler.test.ts @@ -252,3 +252,74 @@ describe("unterminated string literals in large files", () => { expect(exitCode).toBe(1); }); }); + +describe("constant folding preserves NamedEvaluation semantics", () => { + // Folding a wrapper expression down to a bare anonymous class/function must + // not expose it to NamedEvaluation at the assignment/binding site. + test.concurrent("wrapped anonymous class/function .name stays empty", async () => { + const src = ` + const A = (0, class {}); + const B = true && class {}; + const C = false || class {}; + const D = null ?? class {}; + const E = true ? class {} : 0; + const F = false ? 0 : class {}; + const G = [class {}][0]; + const fn = (0, function () {}); + const ar = (0, () => {}); + function param(P = (0, class {})) { return P.name; } + class Holder { field = (0, class {}); } + console.log(JSON.stringify({ + A: A.name, B: B.name, C: C.name, D: D.name, E: E.name, F: F.name, G: G.name, + fn: fn.name, ar: ar.name, param: param(), field: new Holder().field.name, + })); + `; + 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(JSON.parse(stdout)).toEqual({ + A: "", + B: "", + C: "", + D: "", + E: "", + F: "", + G: "", + fn: "", + ar: "", + param: "", + field: "", + }); + expect(exitCode).toBe(0); + }); + + test.concurrent("({ prop: class {} }).prop keeps the property key as .name", async () => { + const src = ` + const K = ({ prop: class {} }).prop; + const M = ({ prop: function () {} }).prop; + const N = ({ prop: () => {} }).prop; + const X = ({}).x ??= class {}; + console.log(JSON.stringify({ K: K.name, M: M.name, N: N.name, X: X.name })); + `; + 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(JSON.parse(stdout)).toEqual({ K: "prop", M: "prop", N: "prop", X: "" }); + expect(exitCode).toBe(0); + }); + + test.concurrent("direct anonymous class/function still gets the binding name", async () => { + const src = ` + const A = class {}; + const B = function () {}; + const C = () => {}; + const D = (0, class Named {}); + console.log(JSON.stringify({ A: A.name, B: B.name, C: C.name, D: D.name })); + `; + 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(JSON.parse(stdout)).toEqual({ A: "A", B: "B", C: "C", D: "Named" }); + expect(exitCode).toBe(0); + }); +}); From ce4639e0bfe61f94cf604fa4c861d8161ec66d14 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 09:02:55 +0000 Subject: [PATCH 2/4] Also guard left-operand folds and ternary CouldHaveSideEffects path (class {}) || 0, (class {}) ?? 0, and [] ? class {} : 0 all fold the anonymous class into a NamedEvaluation position via the return e_.left arms and the CouldHaveSideEffects ternary branch (where simplify_unused_expr returning None seeds join_with_comma with E::Missing, which collapses to the bare branch). --- src/js_parser/visit/visit_binary.rs | 20 +++++++++++ src/js_parser/visit/visit_expr.rs | 36 +++++++++++-------- .../transpiler/runtime-transpiler.test.ts | 9 +++++ 3 files changed, 51 insertions(+), 14 deletions(-) diff --git a/src/js_parser/visit/visit_binary.rs b/src/js_parser/visit/visit_binary.rs index 44a05ee83f03..bc9cb88570e9 100644 --- a/src/js_parser/visit/visit_binary.rs +++ b/src/js_parser/visit/visit_binary.rs @@ -377,6 +377,16 @@ impl BinaryExpressionVisitor { let null_or_undefined = SideEffects::to_null_or_undefined(p, &e_.left.data); if null_or_undefined.ok { if !null_or_undefined.value { + // "(class {} ?? 0)" => "(0, class {})" + if e_.left.is_anonymous_named() { + return Expr::join_with_comma( + Expr { + data: prefill::data::ZERO, + loc: e_.left.loc, + }, + e_.left, + ); + } return e_.left; } else if null_or_undefined.side_effects == SideEffects::NoSideEffects { // "(null ?? fn)()" => "fn()" @@ -402,6 +412,16 @@ impl BinaryExpressionVisitor { Op::Code::BinLogicalOr => { let side_effects = SideEffects::to_boolean(p, &e_.left.data); if side_effects.ok && side_effects.value { + // "(class {} || 0)" => "(0, class {})" + if e_.left.is_anonymous_named() { + return Expr::join_with_comma( + Expr { + data: prefill::data::ZERO, + loc: e_.left.loc, + }, + e_.left, + ); + } return e_.left; } else if side_effects.ok && side_effects.side_effects == SideEffects::NoSideEffects { diff --git a/src/js_parser/visit/visit_expr.rs b/src/js_parser/visit/visit_expr.rs index 23f632a058c3..d5157d4ad3e9 100644 --- a/src/js_parser/visit/visit_expr.rs +++ b/src/js_parser/visit/visit_expr.rs @@ -1501,20 +1501,24 @@ 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; + // "(1 ? fn : 2)()" => "fn()" + // "(1 ? this.fn : 2)" => "this.fn" + // "(1 ? this.fn : 2)()" => "(0, this.fn)()" + // "(1 ? class {} : 2)" => "(0, class {})" + let needs_comma_wrap = (is_call_target && e_.yes.has_value_for_this_in_call()) + || e_.yes.is_anonymous_named(); + if side_effects.side_effects == SideEffects::CouldHaveSideEffects { *e = SideEffects::simplify_unused_expr(p, e_.test_) + .or_else(|| { + needs_comma_wrap.then(|| p.new_expr(E::Number::new(0.0), e_.test_.loc)) + }) .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 ? class {} : 2)" => "(0, class {})" - if (is_call_target && e_.yes.has_value_for_this_in_call()) - || e_.yes.is_anonymous_named() - { + if needs_comma_wrap { *e = p .new_expr(E::Number::new(0.0), e_.test_.loc) .join_with_comma(e_.yes); @@ -1531,21 +1535,25 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O p.is_control_flow_dead = old; p.visit_expr(&mut e_.no); + // "(1 ? fn : 2)()" => "fn()" + // "(1 ? this.fn : 2)" => "this.fn" + // "(1 ? this.fn : 2)()" => "(0, this.fn)()" + // "(0 ? 1 : class {})" => "(0, class {})" + let needs_comma_wrap = (is_call_target && e_.no.has_value_for_this_in_call()) + || e_.no.is_anonymous_named(); + // "(a, false) ? b : c" => "a, c" if side_effects.side_effects == SideEffects::CouldHaveSideEffects { *e = SideEffects::simplify_unused_expr(p, e_.test_) + .or_else(|| { + needs_comma_wrap.then(|| p.new_expr(E::Number::new(0.0), e_.test_.loc)) + }) .unwrap_or_else(|| p.new_expr(E::Missing {}, e_.test_.loc)) .join_with_comma(e_.no); return; } - // "(1 ? fn : 2)()" => "fn()" - // "(1 ? this.fn : 2)" => "this.fn" - // "(1 ? this.fn : 2)()" => "(0, this.fn)()" - // "(0 ? 1 : class {})" => "(0, class {})" - if (is_call_target && e_.no.has_value_for_this_in_call()) - || e_.no.is_anonymous_named() - { + if needs_comma_wrap { *e = p .new_expr(E::Number::new(0.0), e_.test_.loc) .join_with_comma(e_.no); diff --git a/test/bundler/transpiler/runtime-transpiler.test.ts b/test/bundler/transpiler/runtime-transpiler.test.ts index ad3697e30d80..547ef0fe7df8 100644 --- a/test/bundler/transpiler/runtime-transpiler.test.ts +++ b/test/bundler/transpiler/runtime-transpiler.test.ts @@ -265,12 +265,17 @@ describe("constant folding preserves NamedEvaluation semantics", () => { const E = true ? class {} : 0; const F = false ? 0 : class {}; const G = [class {}][0]; + const H = [] ? class {} : 0; + const I = (function () {}) || 0; + const J = (() => {}) ?? 0; + const L = (class {}) ?? 0; const fn = (0, function () {}); const ar = (0, () => {}); function param(P = (0, class {})) { return P.name; } class Holder { field = (0, class {}); } console.log(JSON.stringify({ A: A.name, B: B.name, C: C.name, D: D.name, E: E.name, F: F.name, G: G.name, + H: H.name, I: I.name, J: J.name, L: L.name, fn: fn.name, ar: ar.name, param: param(), field: new Holder().field.name, })); `; @@ -285,6 +290,10 @@ describe("constant folding preserves NamedEvaluation semantics", () => { E: "", F: "", G: "", + H: "", + I: "", + J: "", + L: "", fn: "", ar: "", param: "", From 99bed96f8b7685ba0eccb17af807c42af7b9ba34 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 09:47:45 +0000 Subject: [PATCH 3/4] Trim inline comments per comment-cop --- src/js_parser/fold.rs | 4 +--- src/js_parser/visit/visit_binary.rs | 3 --- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/src/js_parser/fold.rs b/src/js_parser/fold.rs index 9ae8f8775774..8f43819bd647 100644 --- a/src/js_parser/fold.rs +++ b/src/js_parser/fold.rs @@ -483,9 +483,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O name, ) && name != b"__proto__" - // "({ f: class {} }).f" assigns name "f"; inlining - // would drop that and expose the class to the outer - // NamedEvaluation position instead. + // "({ f: class {} }).f" => keep; name is "f" per spec && !value.is_anonymous_named() { return Some(value); diff --git a/src/js_parser/visit/visit_binary.rs b/src/js_parser/visit/visit_binary.rs index bc9cb88570e9..384b1670ffd8 100644 --- a/src/js_parser/visit/visit_binary.rs +++ b/src/js_parser/visit/visit_binary.rs @@ -234,9 +234,6 @@ impl BinaryExpressionVisitor { e_.left = simplified_left; } } else { - // The left operand has no side effects, but we need to preserve - // the comma operator semantics when used as a call target, and - // avoid exposing an anonymous function/class to NamedEvaluation. if (is_call_target && e_.right.has_value_for_this_in_call()) || e_.right.is_anonymous_named() { From aadf9542b13ed4b5835800388c082a53cdbe4bfc Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 13:01:31 +0000 Subject: [PATCH 4/4] ci: retrigger