From 6681719cc2f1a7feb7bb5a2c54522b70a47f971a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:05:49 +0000 Subject: [PATCH 1/8] Keep `this` undefined for call targets and template tags that a rewrite turned into a property access A call target or template tag that is a property access binds `this` to the object. When a fold, a single-use inlining, a dotted define, or the bundler's namespace rewrite turned some other expression into a property access in that position, the printer emitted it bare and `this` changed: `import { fn } from "./cjs"; fn()` bundled to `import_cjs.fn()`, `(0, o.f)\`x\`` printed as `o.f\`x\``, and `const t = a?.b; t\`x\`` printed as `a?.b\`x\``, which is a SyntaxError. Mirror esbuild: `E::Call` gets `target_was_originally_property_access` and `E::Template` gets `tag_was_originally_property_access`, set by the parser when the callee or tag is an `EDot` or `EIndex`. The printer wraps the target in `(0, ...)` when it is a property access now but was not one originally. An import item in that position is wrapped by the `EImportIdentifier` arm through the same flag. The parser tracks `template_tag` next to `call_target` so the `{f}.f` and `[o.f][0]` inlining and dotted defines treat a tag like a call target. The optional-chain check for tags now inspects the tag node. Synthesized calls with a property-access target set the flag. The runtime transpiler cache version is bumped because printed output changes. --- src/ast/e.rs | 14 ++ src/ast/expr.rs | 2 + src/ast/fold_string_addition.rs | 1 + src/bundler/ParseTask.rs | 1 + .../convertStmtsForChunkForDevServer.rs | 1 + .../generateCodeForLazyExport.rs | 1 + src/js_parser/fold.rs | 2 + src/js_parser/lower/lower_decorators.rs | 1 + src/js_parser/lower/lower_esm_exports_hmr.rs | 1 + src/js_parser/p.rs | 2 + src/js_parser/parse/parse_entry.rs | 1 + src/js_parser/parse/parse_prefix.rs | 1 + src/js_parser/parse/parse_suffix.rs | 5 + src/js_parser/parse/parse_typescript.rs | 2 + src/js_parser/parser.rs | 13 +- src/js_parser/visit/visit_expr.rs | 64 +++++--- src/js_printer/lib.rs | 80 +++++++--- src/jsc/RuntimeTranspilerCache.rs | 4 +- src/react_compiler/codegen.rs | 3 + test/bundler/bundler_cjs.test.ts | 129 ++++++++++++++- test/bundler/bundler_npm.test.ts | 6 +- .../__snapshots__/react-compiler.test.ts.snap | 6 +- .../transpiler/runtime-transpiler.test.ts | 127 +++++++++++++++ test/bundler/transpiler/transpiler.test.js | 151 ++++++++++++++++++ 24 files changed, 563 insertions(+), 55 deletions(-) diff --git a/src/ast/e.rs b/src/ast/e.rs index 4d36f17bbeeb..7cee920b205f 100644 --- a/src/ast/e.rs +++ b/src/ast/e.rs @@ -273,6 +273,13 @@ pub struct Call { /// Used when printing to generate the source prop on the fly pub was_jsx_element: bool, + + /// True if the call target was a property access (`a.b()` or `a[b]()`) in + /// the source, so the call binds `this` to `a`. When a later rewrite turns + /// some other target into a property access (`(0, a.b)()` folded to + /// `a.b()`, or `fn()` bound to `import_ns.fn()`), this stays false and the + /// printer emits `(0, a.b)()` to keep `this` undefined. + pub target_was_originally_property_access: bool, } impl Default for Call { fn default() -> Self { @@ -284,6 +291,7 @@ impl Default for Call { close_paren_loc: crate::Loc::EMPTY, can_be_unwrapped_if_unused: CallUnwrap::Never, was_jsx_element: false, + target_was_originally_property_access: false, } } } @@ -2118,6 +2126,11 @@ pub struct Template { /// `parts()` / `parts_mut()` for ergonomic access; never null. pub parts: crate::StoreSlice, pub head: TemplateContents, + /// True if the tag was a property access (`a.b\`\`` or `a[b]\`\``) in the + /// source. Same role as `Call::target_was_originally_property_access`: a + /// tag that became a property access through a rewrite prints as + /// `(0, a.b)\`\`` so `this` stays undefined. + pub tag_was_originally_property_access: bool, } impl Template { @@ -2340,6 +2353,7 @@ impl Template { .expect("infallible: variant checked") .shallow_clone(), ), + tag_was_originally_property_access: false, }, loc, ) diff --git a/src/ast/expr.rs b/src/ast/expr.rs index 3beff11a4014..88fc4da769cc 100644 --- a/src/ast/expr.rs +++ b/src/ast/expr.rs @@ -2001,6 +2001,7 @@ impl Data { close_paren_loc: el.close_paren_loc, can_be_unwrapped_if_unused: el.can_be_unwrapped_if_unused, was_jsx_element: el.was_jsx_element, + target_was_originally_property_access: el.target_was_originally_property_access, }); Ok(Data::ECall(StoreRef::from_bump(item))) } @@ -2084,6 +2085,7 @@ impl Data { // `TemplateContents` is POD-shaped; `shallow_clone` is the // safe field-wise copy. head: el.head.shallow_clone(), + tag_was_originally_property_access: el.tag_was_originally_property_access, }); Ok(Data::ETemplate(StoreRef::from_bump(item))) } diff --git a/src/ast/fold_string_addition.rs b/src/ast/fold_string_addition.rs index be74632ff71c..ab4efd1c6fed 100644 --- a/src/ast/fold_string_addition.rs +++ b/src/ast/fold_string_addition.rs @@ -200,6 +200,7 @@ pub fn fold_string_addition( right.head.cooked(), matches!(l.data, Data::EInlinedEnum(_)), )), + tag_was_originally_property_access: false, }, l.loc, )); diff --git a/src/bundler/ParseTask.rs b/src/bundler/ParseTask.rs index 20f6ea89b3b1..3f01e2ccc030 100644 --- a/src/bundler/ParseTask.rs +++ b/src/bundler/ParseTask.rs @@ -1093,6 +1093,7 @@ pub mod parse_worker { target: require_property, // SAFETY: bump-owned slice; never grown via this Vec. args: unsafe { bun_ast::ExprNodeList::from_bump_slice(require_args) }, + target_was_originally_property_access: true, ..Default::default() }, Loc { start: 0 }, diff --git a/src/bundler/linker_context/convertStmtsForChunkForDevServer.rs b/src/bundler/linker_context/convertStmtsForChunkForDevServer.rs index 17dfac19f76a..4c44c01f4a92 100644 --- a/src/bundler/linker_context/convertStmtsForChunkForDevServer.rs +++ b/src/bundler/linker_context/convertStmtsForChunkForDevServer.rs @@ -147,6 +147,7 @@ pub(crate) fn convert_stmts_for_chunk_for_dev_server<'bump>( }, record.range.loc, )]), + target_was_originally_property_access: true, ..Default::default() }, stmt.loc, diff --git a/src/bundler/linker_context/generateCodeForLazyExport.rs b/src/bundler/linker_context/generateCodeForLazyExport.rs index 44c9a36142c8..4ab827519b79 100644 --- a/src/bundler/linker_context/generateCodeForLazyExport.rs +++ b/src/bundler/linker_context/generateCodeForLazyExport.rs @@ -347,6 +347,7 @@ pub(crate) fn generate_code_for_lazy_export( tag: None, parts: parts_slice, head: E::TemplateContents::Cooked(E::String::init(b"")), + tag_was_originally_property_access: false, }, stmt.loc, ); diff --git a/src/js_parser/fold.rs b/src/js_parser/fold.rs index 74125b583e17..b1b0be271cb2 100644 --- a/src/js_parser/fold.rs +++ b/src/js_parser/fold.rs @@ -467,10 +467,12 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // To avoid thinking too much about edgecases, only do this for: // 1) Objects with a single property // 2) Not a method, not a computed property + // 3) Not a call target or template tag ("this" would change) if obj.properties.len_u32() == 1 && !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/lower/lower_decorators.rs b/src/js_parser/lower/lower_decorators.rs index 77c2620dac5e..4ab84a9e74cd 100644 --- a/src/js_parser/lower/lower_decorators.rs +++ b/src/js_parser/lower/lower_decorators.rs @@ -801,6 +801,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O new_args.push(*arg); } e.target = call_target; + e.target_was_originally_property_access = true; e.args = ExprNodeList::from_bump_vec(new_args); return; } diff --git a/src/js_parser/lower/lower_esm_exports_hmr.rs b/src/js_parser/lower/lower_esm_exports_hmr.rs index 1299e8b030ba..abb5922c5b94 100644 --- a/src/js_parser/lower/lower_esm_exports_hmr.rs +++ b/src/js_parser/lower/lower_esm_exports_hmr.rs @@ -711,6 +711,7 @@ impl<'a> ConvertESMExportsForHmr<'a> { bun_ast::Loc::EMPTY, ), args: bun_alloc::AstAlloc::vec(), + target_was_originally_property_access: true, ..Default::default() }, bun_ast::Loc::EMPTY, diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 12d485079d93..7a517908590c 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, @@ -8626,6 +8627,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/parse/parse_entry.rs b/src/js_parser/parse/parse_entry.rs index cdb4d149ceec..1a42c4c655f8 100644 --- a/src/js_parser/parse/parse_entry.rs +++ b/src/js_parser/parse/parse_entry.rs @@ -721,6 +721,7 @@ fn lower_one_date_time_literal<'a>( target: from_dot, args: Vec::from_arena_slice(args_slice), can_be_unwrapped_if_unused: E::CallUnwrap::IfUnused, + target_was_originally_property_access: true, ..Default::default() }, loc, diff --git a/src/js_parser/parse/parse_prefix.rs b/src/js_parser/parse/parse_prefix.rs index bc5d24b92166..1070110a0e6a 100644 --- a/src/js_parser/parse/parse_prefix.rs +++ b/src/js_parser/parse/parse_prefix.rs @@ -292,6 +292,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O tag: None, head: E::TemplateContents::Cooked(head), parts, + tag_was_originally_property_access: false, }, loc, )) diff --git a/src/js_parser/parse/parse_suffix.rs b/src/js_parser/parse/parse_suffix.rs index 36825b386133..16d720056b0b 100644 --- a/src/js_parser/parse/parse_suffix.rs +++ b/src/js_parser/parse/parse_suffix.rs @@ -174,6 +174,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let target = *left; *left = p.new_expr( E::Call { + target_was_originally_property_access: target.has_value_for_this_in_call(), target, args: list_loc.list, close_paren_loc: list_loc.loc, @@ -204,6 +205,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let target = *left; *left = p.new_expr( E::Call { + target_was_originally_property_access: target.has_value_for_this_in_call(), target, args: list_loc.list, close_paren_loc: list_loc.loc, @@ -286,6 +288,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let tag = *left; *left = p.new_expr( E::Template { + tag_was_originally_property_access: tag.has_value_for_this_in_call(), tag: Some(tag), head: E::TemplateContents::Raw(head), parts: E::Template::empty_parts(), @@ -316,6 +319,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let loc = left.loc; *left = p.new_expr( E::Template { + tag_was_originally_property_access: tag.has_value_for_this_in_call(), tag: Some(tag), head: E::TemplateContents::Raw(head), parts, @@ -386,6 +390,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let target = *left; *left = p.new_expr( E::Call { + target_was_originally_property_access: target.has_value_for_this_in_call(), target, args: list_loc.list, close_paren_loc: list_loc.loc, diff --git a/src/js_parser/parse/parse_typescript.rs b/src/js_parser/parse/parse_typescript.rs index 62bc1c41df2d..b4fa5ab3ef6d 100644 --- a/src/js_parser/parse/parse_typescript.rs +++ b/src/js_parser/parse/parse_typescript.rs @@ -166,6 +166,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let args = p.parse_call_args()?; expr = p.new_expr( E::Call { + target_was_originally_property_access: expr + .has_value_for_this_in_call(), target: expr, args: args.list, close_paren_loc: args.loc, diff --git a/src/js_parser/parser.rs b/src/js_parser/parser.rs index 408deeeb45c0..b69a3f44d789 100644 --- a/src/js_parser/parser.rs +++ b/src/js_parser/parser.rs @@ -883,7 +883,8 @@ impl AsyncPrefixExpression { } // Packed u8 — assign_target:u2, is_delete_target:b1, -// was_originally_identifier:b1, is_call_target:b1, _padding:u3 (LSB-first). +// 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)] @@ -895,6 +896,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 { @@ -922,6 +924,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). @@ -949,6 +955,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_expr.rs b/src/js_parser/visit/visit_expr.rs index b843756bb2e5..23919a4ff604 100644 --- a/src/js_parser/visit/visit_expr.rs +++ b/src/js_parser/visit/visit_expr.rs @@ -175,6 +175,11 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O .expect("infallible: variant checked") .ref_, ); + // Parse-time refs: `p.call_target` and `p.template_tag` hold the node + // as it was before this visit resolved `e_.ref_`. + let is_call_target = matches!(p.call_target, Data::EIdentifier(ct) if ct.ref_.eql(e_.ref_)); + let is_template_tag = + matches!(p.template_tag, Data::EIdentifier(tt) if tt.ref_.eql(e_.ref_)); let name = p.load_name_from_ref(e_.ref_); if p.is_strict_mode() && js_lexer::is_strict_mode_reserved_word(name) { @@ -271,7 +276,19 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O || in_.assign_target == js_ast::AssignTarget::None { p.ignore_usage(e_.ref_); - *e = newvalue; + // A dotted define value ("--define foo=a.b") is one + // identifier named "a.b" that prints as a property + // access. "foo()" => "(0, a.b)()" keeps `this` undefined. + let is_dotted_name = matches!(newvalue.data.tag(), Tag::EIdentifier) + && def + .original_name() + .is_some_and(|n| strings::contains_char(n, b'.')); + *e = if (is_call_target || is_template_tag) && is_dotted_name { + p.new_expr(E::Number::new(0.0), expr.loc) + .join_with_comma(newvalue) + } else { + newvalue + }; return; } @@ -300,15 +317,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // Substitute uncalled "require" for the require target if p.require_ref.eql(e_.ref_) && !p.is_source_runtime() { // mark a reference to __require only if this is not about to be used for a call target - if !(matches!(p.call_target.tag(), Tag::EIdentifier) - && expr.data.e_identifier().unwrap().ref_.eql( - p.call_target - .e_identifier() - .expect("infallible: variant checked") - .ref_, - )) - && p.options.features.allow_runtime - { + if !is_call_target && p.options.features.allow_runtime { p.record_usage_of_runtime_require(); } @@ -324,15 +333,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O IdentifierOpts::default() .with_assign_target(in_.assign_target) .with_is_delete_target(is_delete_target) - .with_is_call_target( - matches!(p.call_target.tag(), Tag::EIdentifier) - && expr.data.e_identifier().unwrap().ref_.eql( - p.call_target - .e_identifier() - .expect("infallible: variant checked") - .ref_, - ), - ) + .with_is_call_target(is_call_target) + .with_is_template_tag(is_template_tag) .with_was_originally_identifier(true), ); } @@ -470,6 +472,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // Call createElement() *e = p.new_expr( E::Call { + target_was_originally_property_access: target + .has_value_for_this_in_call(), target, args, // Enable tree shaking @@ -686,8 +690,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 @@ -879,6 +884,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" @@ -899,6 +905,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; } @@ -1014,6 +1023,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; } @@ -1035,7 +1047,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), ) { @@ -1100,7 +1112,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } if inlined.can_be_inlined_from_property_access() { // "[obj.m][0]()" => "(0, obj.m)()" - *e = if is_call_target && inlined.has_value_for_this_in_call() { + // "[obj.m][0]``" => "(0, obj.m)``" + *e = if (is_call_target || is_template_tag) + && inlined.has_value_for_this_in_call() + { p.new_expr(E::Number::new(0.0), expr.loc) .join_with_comma(inlined) } else { @@ -1335,6 +1350,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 +1443,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; diff --git a/src/js_printer/lib.rs b/src/js_printer/lib.rs index f6aed3a8f7bf..849fafc295bb 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -1652,7 +1652,11 @@ pub(crate) mod __gated_printer { pub(crate) prev_op_end: i32, pub(crate) prev_num_end: i32, pub(crate) prev_reg_exp_end: i32, - pub(crate) call_target: Option, + /// The call target or template tag that is being printed right now, + /// when the source did not write it as a property access. An import + /// item in this position prints as `(0, import_ns.fn)` so that `this` + /// stays undefined. `None` outside of a target or tag. + pub(crate) unbound_call_target: Option, pub(crate) writer: W, pub(crate) renamer: rename::Renamer<'a, 'a>, @@ -2615,6 +2619,21 @@ pub(crate) mod __gated_printer { } } + /// True when `e` is the call target or template tag being printed and + /// the source did not write that position as a property access. The + /// import item now prints as `import_ns.fn`, so it needs the + /// `(0, import_ns.fn)` wrap to keep `this` undefined. + /// + /// `unbound_call_target` holds the target's data only while the target + /// itself prints (the call and template arms clear it before the + /// arguments), so comparing refs here can only match the target node. + fn is_unbound_call_target(&self, e: &E::ImportIdentifier) -> bool { + matches!( + self.unbound_call_target, + Some(ExprData::EImportIdentifier(target)) if target.ref_.eql(e.ref_) + ) + } + #[inline] fn symbols(&self) -> &js_ast::symbol::Map { self.renamer.symbols() @@ -3457,13 +3476,24 @@ pub(crate) mod __gated_printer { } } // We only want to generate an unbound eval() in CommonJS - self.call_target = Some(e.target.data); - let is_unbound_eval = !e.is_direct_eval && self.is_unbound_eval_identifier(e.target) && e.optional_chain.is_none(); - if is_unbound_eval { + // "(0, a.b)()" must not become "a.b()": a property access + // that only became the call target through a rewrite + // (comma, logical, or conditional folding, single-use + // inlining, defines) prints as "(0, a.b)()" to keep + // `this` undefined. An import item in that position is + // wrapped the same way by the EImportIdentifier arm. + let target_was_originally_property_access = + e.target_was_originally_property_access; + let must_preserve_this = !target_was_originally_property_access + && e.target.has_value_for_this_in_call(); + self.unbound_call_target = + (!target_was_originally_property_access).then_some(e.target.data); + + if is_unbound_eval || must_preserve_this { self.print(b"(0,"); self.print_space(); self.print_expr(e.target, Level::Postfix, ExprFlag::none()); @@ -3471,6 +3501,7 @@ pub(crate) mod __gated_printer { } else { self.print_expr(e.target, Level::Postfix, target_flags); } + self.unbound_call_target = None; if e.optional_chain == Some(js_ast::OptionalChain::Start) { self.print(b"?."); @@ -4005,6 +4036,7 @@ pub(crate) mod __gated_printer { // template back through the arena pointer // would be a cross-thread data race. Re-prints recompute // the identical fold, so emitted output is unchanged. + let tag_was_originally_property_access = e.tag_was_originally_property_access; let mut e = E::Template { tag: e.tag, parts: e.parts, @@ -4014,6 +4046,7 @@ pub(crate) mod __gated_printer { } E::TemplateContents::Raw(r) => E::TemplateContents::Raw(*r), }, + tag_was_originally_property_access, }; if e.tag.is_none() && (self.options.minify_syntax || self.was_lazy_export) { // `TemplatePart` is structurally @@ -4079,6 +4112,7 @@ pub(crate) mod __gated_printer { } E::TemplateContents::Raw(r) => E::TemplateContents::Raw(*r), }, + tag_was_originally_property_access, }; let e2 = copy.fold(self.bump, expr.loc); match &e2.data { @@ -4110,6 +4144,8 @@ pub(crate) mod __gated_printer { E::TemplateContents::Raw(*r) } }, + tag_was_originally_property_access: t + .tag_was_originally_property_access, }; } _ => {} @@ -4126,21 +4162,32 @@ pub(crate) mod __gated_printer { if let Some(tag) = &e.tag { self.add_source_mapping(expr.loc); - // Optional chains are forbidden in template tags - // `Expr::is_optional_chain` is gated upstream; inline its body. - let is_optional_chain = match &expr.data { + // The tag binds `this` like a call target does. See + // the ECall arm for the two wrap paths. + self.unbound_call_target = + (!e.tag_was_originally_property_access).then_some(tag.data); + let is_optional_chain = match &tag.data { ExprData::EDot(d) => d.optional_chain.is_some(), ExprData::EIndex(i) => i.optional_chain.is_some(), ExprData::ECall(c) => c.optional_chain.is_some(), _ => false, }; - if is_optional_chain { + if !e.tag_was_originally_property_access && tag.has_value_for_this_in_call() + { + // Prevent "x``" from becoming "y.z``" + self.print(b"(0,"); + self.print_space(); + self.print_expr(*tag, Level::Lowest, ExprFlag::none()); + self.print(b")"); + } else if is_optional_chain { + // Optional chains are forbidden in template tags self.print(b"("); self.print_expr(*tag, Level::Lowest, ExprFlag::none()); self.print(b")"); } else { self.print_expr(*tag, Level::Postfix, ExprFlag::none()); } + self.unbound_call_target = None; } else { self.add_source_mapping(expr.loc); } @@ -4230,13 +4277,8 @@ pub(crate) mod __gated_printer { let import_record = self.import_record(namespace.import_record_index as usize); if namespace.was_originally_property_access { - let mut wrap = false; did_print = true; - - if let Some(target) = &self.call_target { - wrap = e.was_originally_identifier() - && matches!(target, ExprData::EIdentifier(id) if id.ref_.eql(e.ref_)); - } + let wrap = self.is_unbound_call_target(e); if wrap { self.print_whitespacer(ws!(b"(0, ")); @@ -4269,13 +4311,7 @@ pub(crate) mod __gated_printer { if !did_print { did_print = true; - - let wrap = if let Some(target) = &self.call_target { - e.was_originally_identifier() - && matches!(target, ExprData::EIdentifier(id) if id.ref_.eql(e.ref_)) - } else { - false - }; + let wrap = self.is_unbound_call_target(e); if wrap { self.print_whitespacer(ws!(b"(0, ")); @@ -6763,7 +6799,7 @@ pub(crate) mod __gated_printer { prev_op_end: -1, prev_num_end: -1, prev_reg_exp_end: -1, - call_target: None, + unbound_call_target: None, writer, renamer, prev_stmt_tag: StmtTag::SEmpty, diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index 597bcd6952aa..229f73811841 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -55,7 +55,9 @@ bun_core::declare_scope!(cache, visible); /// offsets picked by a header byte) plus a body of tagged records with /// u8/u16/u32 ids and implied slots dropped, instead of fixed u32 arrays. /// Version 27: ModuleInfo string table holds Latin-1 / UTF-16 bodies, not WTF-8. -const EXPECTED_VERSION: u32 = 27; +/// Version 28: Call targets and template tags that a fold turned into a +/// property access print as `(0, a.b)` so `this` stays undefined. +const EXPECTED_VERSION: u32 = 28; /// 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/src/react_compiler/codegen.rs b/src/react_compiler/codegen.rs index 97d1ec121219..256239224bab 100644 --- a/src/react_compiler/codegen.rs +++ b/src/react_compiler/codegen.rs @@ -1915,6 +1915,7 @@ fn codegen_base_instruction_value( E::Call { target: member_expr, args: arguments, + target_was_originally_property_access: true, ..Default::default() }, loc, @@ -2173,6 +2174,7 @@ fn codegen_base_instruction_value( let tag_expr = codegen_place_to_expression(cx, tag)?; Ok(Expr::init( E::Template { + tag_was_originally_property_access: tag_expr.has_value_for_this_in_call(), tag: Some(tag_expr), head: E::TemplateContents::Raw(value.raw), parts: StoreSlice::EMPTY, @@ -2201,6 +2203,7 @@ fn codegen_base_instruction_value( tag: None, head, parts: StoreSlice::new_mut(parts.leak()), + tag_was_originally_property_access: false, }, loc, )) diff --git a/test/bundler/bundler_cjs.test.ts b/test/bundler/bundler_cjs.test.ts index aee7fb02d9fd..24bb9389d96b 100644 --- a/test/bundler/bundler_cjs.test.ts +++ b/test/bundler/bundler_cjs.test.ts @@ -1,4 +1,4 @@ -import { describe } from "bun:test"; +import { describe, expect } from "bun:test"; import { itBundled } from "./expectBundled"; // Tests for CommonJS <> ESM interop, specifically the __toESM helper behavior. @@ -597,4 +597,131 @@ describe("bundler", () => { stdout: "loaded ok", }, }); + + // ============================================================================ + // `this` for CommonJS exports called through ESM imports + // + // The linker rewrites `who` (a named import of a CJS module) to the namespace + // member `import_lib.who`. Calling that as `import_lib.who()` would bind + // `this` to the namespace object, while Node (and unbundled Bun) call the + // export with `this === undefined`. The printer emits `(0, import_lib.who)()` + // and `(0, import_lib.who)\`\`` instead. `ns.who()` was a property access in + // the source, so it keeps the namespace as `this`. + // + // The probe reports "unbound" for both `undefined` and `globalThis` so the + // result does not depend on the strictness of the output format. + // ============================================================================ + const thisProbe = /* js */ ` + function probe(self, exports) { + return self === exports ? "exports" : self === undefined || self === globalThis ? "unbound" : "other"; + } + `; + const thisForImportCallsFiles = { + "/entry.js": /* js */ ` + import def from "./default.cjs"; + import { who } from "./lib.cjs"; + import * as ns from "./lib.cjs"; + console.log( + def(), def\`\`, + who(), who\`\`, + ns.who(), ns.who\`\`, + (0, ns.who)(), (0, ns.who)\`\`, + (true && who)(), (1 ? who : 0)\`\`, + (0, who)(), (null ?? who)\`\`, + ); + `, + "/default.cjs": /* js */ ` + ${thisProbe} + module.exports = function () { return probe(this, module.exports); }; + `, + "/lib.cjs": /* js */ ` + ${thisProbe} + exports.who = function () { return probe(this, exports); }; + `, + }; + const thisForImportCallsStdout = + "unbound unbound unbound unbound other other unbound unbound unbound unbound unbound unbound"; + + itBundled("cjs/ThisForImportCalls", { + files: thisForImportCallsFiles, + onAfterBundle(api) { + const out = api.readFile("/out.js"); + expect(out).toContain("(0, import_default.default)()"); + expect(out).toContain("(0, import_default.default)``"); + expect(out).toContain("(0, import_lib.who)()"); + expect(out).toContain("(0, import_lib.who)``"); + expect(out).toContain("ns.who(), ns.who``"); + expect(out).toContain("(0, ns.who)(), (0, ns.who)``"); + }, + run: { stdout: thisForImportCallsStdout }, + }); + itBundled("cjs/ThisForImportCallsMinifySyntax", { + files: thisForImportCallsFiles, + minifySyntax: true, + run: { stdout: thisForImportCallsStdout }, + }); + itBundled("cjs/ThisForImportCallsMinifyAll", { + files: thisForImportCallsFiles, + minifySyntax: true, + minifyWhitespace: true, + minifyIdentifiers: true, + run: { stdout: thisForImportCallsStdout }, + }); + itBundled("cjs/ThisForImportCallsFormatCJS", { + files: thisForImportCallsFiles, + format: "cjs", + run: { stdout: thisForImportCallsStdout }, + }); + itBundled("cjs/ThisForImportCallsFormatIIFE", { + files: thisForImportCallsFiles, + format: "iife", + run: { stdout: thisForImportCallsStdout }, + }); + itBundled("cjs/ThisForImportCallsTargetNode", { + files: thisForImportCallsFiles, + target: "node", + run: { stdout: thisForImportCallsStdout }, + }); + itBundled("cjs/ThisForImportCallsTargetBun", { + files: thisForImportCallsFiles, + target: "bun", + run: { stdout: thisForImportCallsStdout }, + }); + + // External CJS packages in a cjs or iife bundle become `var import_ext = require("ext")`. + const thisForExternalImportCallsFiles = { + "/entry.js": /* js */ ` + import { html } from "ext"; + import * as ns from "ext"; + console.log(html(), html\`\`, ns.html(), ns.html\`\`, (0, ns.html)(), (true && html)(), (1 ? html : 0)\`\`); + `, + }; + const thisForExternalImportCallsRuntimeFiles = { + "/node_modules/ext/index.js": /* js */ ` + ${thisProbe} + exports.html = function () { return probe(this, exports); }; + `, + }; + const thisForExternalImportCallsStdout = "unbound unbound other other unbound unbound unbound"; + itBundled("cjs/ThisForExternalImportCallsFormatCJS", { + files: thisForExternalImportCallsFiles, + runtimeFiles: thisForExternalImportCallsRuntimeFiles, + external: ["ext"], + target: "node", + format: "cjs", + onAfterBundle(api) { + const out = api.readFile("/out.js"); + expect(out).toContain("(0, import_ext.html)()"); + expect(out).toContain("(0, import_ext.html)``"); + expect(out).toContain("ns.html(), ns.html``"); + }, + run: { stdout: thisForExternalImportCallsStdout }, + }); + itBundled("cjs/ThisForExternalImportCallsFormatIIFE", { + files: thisForExternalImportCallsFiles, + runtimeFiles: thisForExternalImportCallsRuntimeFiles, + external: ["ext"], + format: "iife", + run: { stdout: thisForExternalImportCallsStdout }, + }); }); diff --git a/test/bundler/bundler_npm.test.ts b/test/bundler/bundler_npm.test.ts index 0aa8b729971a..b9d6ad9afa0a 100644 --- a/test/bundler/bundler_npm.test.ts +++ b/test/bundler/bundler_npm.test.ts @@ -61,13 +61,13 @@ describe("bundler", () => { ["react.development.js:2495:'actScopeDepth'", "23:4082:or++"], ["react.development.js:696:''Component'", '1:7685:\'Component "%s"'], ["entry.tsx:6:'\"Content-Type\"'", '100:18806:"Content-Type"'], - ["entry.tsx:11:''", "100:19060:void"], - ["entry.tsx:23:'await'", "100:19159:await"], + ["entry.tsx:11:''", "100:19076:void"], + ["entry.tsx:23:'await'", "100:19175:await"], ], }, }, expectExactFilesize: { - "out/entry.js": 221984, + "out/entry.js": 222004, }, run: { stdout: "

Hello World

This is an example.

", diff --git a/test/bundler/transpiler/__snapshots__/react-compiler.test.ts.snap b/test/bundler/transpiler/__snapshots__/react-compiler.test.ts.snap index c774a9e5fd74..cb938ef571b5 100644 --- a/test/bundler/transpiler/__snapshots__/react-compiler.test.ts.snap +++ b/test/bundler/transpiler/__snapshots__/react-compiler.test.ts.snap @@ -143,13 +143,13 @@ var $jsxDEV = () => null; var $c = () => []; // entry.tsx -var Ctx = import_react.createContext(0); +var Ctx = (0, import_react.createContext)(0); var sub = () => () => {}; var get = () => 1; function Comp() { let $ = $c(3); - let v = import_react.useSyncExternalStore(sub, get); - let c = import_react.useContext(Ctx); + let v = (0, import_react.useSyncExternalStore)(sub, get); + let c = (0, import_react.useContext)(Ctx); let t0; if ($[0] !== c || $[1] !== v) t0 = /* @__PURE__ */ $jsxDEV("div", { diff --git a/test/bundler/transpiler/runtime-transpiler.test.ts b/test/bundler/transpiler/runtime-transpiler.test.ts index 5c05e408c92a..24e0948d554d 100644 --- a/test/bundler/transpiler/runtime-transpiler.test.ts +++ b/test/bundler/transpiler/runtime-transpiler.test.ts @@ -252,3 +252,130 @@ describe("unterminated string literals in large files", () => { expect(exitCode).toBe(1); }); }); + +// The runtime transpiler folds constant expressions, inlines single-use +// constants and substitutes defines. A fold that turns a call target or a +// template tag into a property access must not change `this`, and an optional +// chain that lands in tag position must stay parenthesized. Node prints the +// same values for every fixture here. +describe("this binding for call targets and template tags", () => { + async function run(files: Record, entry: string, bunArgs: string[] = []) { + using dir = tempDir("transpiler-this-binding", files); + await using proc = Bun.spawn({ + cmd: [bunExe(), ...bunArgs, entry], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + return stdout.trim().split("\n"); + } + + // `this` is undefined for an unbound call in a module and the object for a + // method call. + const describeThis = ` + const o = { f() { return this === o ? "o" : this === undefined ? "undefined" : typeof this } }; + function f() { return this === undefined ? "undefined" : typeof this } + `; + + test.concurrent("template tag folds", async () => { + const lines = await run( + { + "entry.mjs": ` + ${describeThis} + function single() { const t = o.f; return t\`x\`; } + function singleIndex() { const t = o["f"]; return t\`x\`; } + console.log(o.f\`x\`, (o.f)\`x\`, o["f"]\`x\`, { foo: f }.foo\`\`, ({ foo: f }).foo\`\`); + console.log((0, o.f)\`x\`, (true && o.f)\`x\`, (false || o.f)\`x\`, (null ?? o.f)\`x\`, (1 ? o.f : 0)\`x\`, (0 ? 1 : o.f)\`x\`); + console.log(single(), singleIndex(), (0, f)\`\`, (true && f)\`\`); + `, + }, + "entry.mjs", + ); + expect(lines).toEqual([ + "o o o object object", + "undefined undefined undefined undefined undefined undefined", + "undefined undefined undefined undefined", + ]); + }); + + test.concurrent("call target folds", async () => { + const lines = await run( + { + "entry.mjs": ` + ${describeThis} + function single() { const t = o.f; return t(); } + function singleIndex() { const t = o["f"]; return t(); } + console.log(o.f(), (o.f)(), o["f"](), o.f?.(), o?.f(), { foo: f }.foo(), ({ foo: f }).foo()); + console.log((0, o.f)(), (true && o.f)(), (false || o.f)(), (null ?? o.f)(), (1 ? o.f : 0)(), (0 ? 1 : o.f)()); + console.log(single(), singleIndex(), (0, f)(), (true && f)()); + `, + }, + "entry.mjs", + ); + expect(lines).toEqual([ + "o o o o o object object", + "undefined undefined undefined undefined undefined undefined", + "undefined undefined undefined undefined", + ]); + }); + + test.concurrent("optional chains in tag position", async () => { + const lines = await run( + { + "entry.mjs": ` + function tag(a) { const t = a?.b; return t\`x\`; } + function tagIndex(a) { const t = a?.["b"]; return t\`x\`; } + function tagDeep(a) { const t = a?.b.c; return t\`x\`; } + const y = { z() { return "y.z" } }; + const w = { v: { z() { return this === w.v ? "w.v" : String(this) } } }; + console.log(tag({ b: () => "ok" }), tagIndex({ b: () => "ok" }), tagDeep({ b: { c: () => "ok" } })); + console.log((y?.z)\`\`, (y?.["z"])\`\`, (w?.v).z\`\`, (true && y?.z)\`\`); + `, + }, + "entry.mjs", + ); + expect(lines).toEqual(["ok ok ok", "y.z y.z w.v y.z"]); + }); + + test.concurrent("defines", async () => { + const lines = await run( + { + "entry.mjs": ` + ${describeThis} + o.C = class { kind = "ctor" }; + console.log(callee(), callee\`x\`, plain(), plain\`x\`, new ctor().kind, callee.name); + `, + }, + "entry.mjs", + ["--define", "callee=o.f", "--define", "plain=f", "--define", "ctor=o.C"], + ); + expect(lines).toEqual(["undefined undefined undefined undefined ctor f"]); + }); + + test.concurrent("CommonJS imports", async () => { + const lines = await run( + { + "entry.mjs": ` + import def from "./default.cjs"; + import { who } from "./lib.cjs"; + import * as ns from "./lib.cjs"; + console.log(def(), def\`\`, who(), who\`\`, ns.who(), ns.who\`\`, (0, ns.who)(), (0, ns.who)\`\`); + `, + "default.cjs": ` + "use strict"; + module.exports = function () { return this === undefined ? "undefined" : typeof this; }; + `, + "lib.cjs": ` + "use strict"; + exports.who = function () { return this === undefined ? "undefined" : this === exports ? "exports" : typeof this; }; + `, + }, + "entry.mjs", + ); + expect(lines).toEqual(["undefined undefined undefined undefined object object undefined undefined"]); + }); +}); diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 590362557892..58a10fa4af9a 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -269,6 +269,157 @@ describe("Bun.Transpiler", () => { }); }); + // A call target or template tag that is a property access binds `this` to the + // object. When a rewrite turns some other expression into a property access in + // that position, the printer must emit `(0, a.b)` so `this` stays undefined. + describe("this preservation for call targets and template tags", () => { + // Both the plain and the minify-syntax transpiler must agree on these. + const both = (code, out) => { + ts.expectPrinted_(code, out); + ts.expectPrintedMin_(code, out); + }; + + it("keeps `this` of a property access that was written as one", () => { + both("x.y();\n", "x.y();\n"); + both("x[y]();\n", "x[y]();\n"); + both("(x.y)();\n", "x.y();\n"); + both("(x[y])();\n", "x[y]();\n"); + both("x.y``;\n", "x.y``;\n"); + both("x[y]``;\n", "x[y]``;\n"); + both("(x.y)``;\n", "x.y``;\n"); + both("x?.y();\n", "x?.y();\n"); + both("x.y?.();\n", "x.y?.();\n"); + both("(x?.y)();\n", "(x?.y)();\n"); + both("(x?.[y])();\n", "(x?.[y])();\n"); + both("new x.y();\n", "new x.y;\n"); + both( + "class A extends B { m() { super.x(); super[y](); (super.x)(); } }", + "class A extends B {\n m() {\n super.x();\n super[y]();\n super.x();\n }\n}", + ); + }); + + it("call target folds", () => { + // Comma folding only happens with minify-syntax. + ts.expectPrinted_("let x = (2, y.z)();\n", "let x = (2, y.z)();\n"); + ts.expectPrintedMin_("let x = (2, y.z)();\n", "let x = (0, y.z)();\n"); + ts.expectPrinted_("let x = (2, y[z])();\n", "let x = (2, y[z])();\n"); + ts.expectPrintedMin_("let x = (2, y[z])();\n", "let x = (0, y[z])();\n"); + ts.expectPrinted_("let x = (2, y)();\n", "let x = (2, y)();\n"); + ts.expectPrintedMin_("let x = (2, y)();\n", "let x = y();\n"); + both("let x = (0, y.z)();\n", "let x = (0, y.z)();\n"); + both("let x = (sideEffect(), y.z)();\n", "let x = (sideEffect(), y.z)();\n"); + + // Logical and conditional folds on constants run in both modes. + both("let x = (true && y.z)();\n", "let x = (0, y.z)();\n"); + both("let x = (false || y.z)();\n", "let x = (0, y.z)();\n"); + both("let x = (null ?? y.z)();\n", "let x = (0, y.z)();\n"); + both("let x = (1 ? y.z : 2)();\n", "let x = (0, y.z)();\n"); + both("let x = (0 ? 1 : y.z)();\n", "let x = (0, y.z)();\n"); + both("let x = (true && y[z])();\n", "let x = (0, y[z])();\n"); + both("let x = (false || y[z])();\n", "let x = (0, y[z])();\n"); + both("let x = (null ?? y[z])();\n", "let x = (0, y[z])();\n"); + both("let x = (1 ? y[z] : 2)();\n", "let x = (0, y[z])();\n"); + both("let x = (0 ? 1 : y[z])();\n", "let x = (0, y[z])();\n"); + both("let x = (true && y)();\n", "let x = y();\n"); + both("let x = (1 ? y : 2)();\n", "let x = y();\n"); + + // Object and array literal inlining + ts.expectPrintedMin_("({ f: y }).f();\n", "({ f: y }).f();\n"); + ts.expectPrintedMin_("({ f: y.z }).f();\n", "({ f: y.z }).f();\n"); + ts.expectPrintedMin_("[y.z][0]();\n", "(0, y.z)();\n"); + ts.expectPrintedMin_("[y][0]();\n", "y();\n"); + + // Single-use substitution does not move a property access into a call target + ts.expectPrintedMin_( + "function f(a) { const t = a.b; return t() }", + "function f(a) {\n const t = a.b;\n return t();\n}", + ); + ts.expectPrintedMin_( + "function f(a) { const t = a?.b; return t() }", + "function f(a) {\n const t = a?.b;\n return t();\n}", + ); + ts.expectPrintedMin_("function f(a) { const t = a.b; return t.c() }\n", "function f(a) {\n return a.b.c();\n}"); + ts.expectPrintedMin_( + "function f(a) { const t = a.b; return new t() }\n", + "function f(a) {\n return new a.b;\n}", + ); + }); + + it("template tag folds", () => { + ts.expectPrinted_("(2, y.z)``;\n", "(2, y.z)``;\n"); + ts.expectPrintedMin_("(2, y.z)``;\n", "(0, y.z)``;\n"); + ts.expectPrinted_("(2, y[z])``;\n", "(2, y[z])``;\n"); + ts.expectPrintedMin_("(2, y[z])``;\n", "(0, y[z])``;\n"); + ts.expectPrinted_("(2, y)``;\n", "(2, y)``;\n"); + ts.expectPrintedMin_("(2, y)``;\n", "y``;\n"); + both("(0, y.z)``;\n", "(0, y.z)``;\n"); + both("(0, y.z)`a${b}c`;\n", "(0, y.z)`a${b}c`;\n"); + both("(sideEffect(), y.z)``;\n", "(sideEffect(), y.z)``;\n"); + + both("(true && y.z)``;\n", "(0, y.z)``;\n"); + both("(false || y.z)``;\n", "(0, y.z)``;\n"); + both("(null ?? y.z)``;\n", "(0, y.z)``;\n"); + both("(1 ? y.z : 2)``;\n", "(0, y.z)``;\n"); + both("(0 ? 1 : y.z)``;\n", "(0, y.z)``;\n"); + both("(true && y[z])``;\n", "(0, y[z])``;\n"); + both("(false || y[z])``;\n", "(0, y[z])``;\n"); + both("(null ?? y[z])``;\n", "(0, y[z])``;\n"); + both("(1 ? y[z] : 2)``;\n", "(0, y[z])``;\n"); + both("(true && y)``;\n", "y``;\n"); + both("(1 ? y : 2)``;\n", "y``;\n"); + + // Object and array literal inlining + ts.expectPrintedMin_("({ f: y }).f``;\n", "({ f: y }).f``;\n"); + ts.expectPrintedMin_("({ f: y.z }).f``;\n", "({ f: y.z }).f``;\n"); + ts.expectPrintedMin_("[y.z][0]``;\n", "(0, y.z)``;\n"); + ts.expectPrintedMin_("[y][0]``;\n", "y``;\n"); + + // Single-use substitution relies on the printer for the wrap + ts.expectPrintedMin_("function f(a) { const t = a.b; return t`x` }", "function f(a) {\n return (0, a.b)`x`;\n}"); + ts.expectPrintedMin_( + "function f(a) { const t = a.b; return t`${x}` }", + "function f(a) {\n return (0, a.b)`${x}`;\n}", + ); + ts.expectPrintedMin_( + "function f(a) { const t = a[b]; return t`x` }", + "function f(a) {\n return (0, a[b])`x`;\n}", + ); + ts.expectPrintedMin_("function f(a) { const t = a; return t`x` }\n", "function f(a) {\n return a`x`;\n}"); + }); + + it("optional chains as template tags stay parenthesized", () => { + both("(a?.b)``;\n", "(a?.b)``;\n"); + both("(a?.[b])``;\n", "(a?.[b])``;\n"); + both("(a?.b.c)``;\n", "(a?.b.c)``;\n"); + both("(a?.b())``;\n", "(a?.b())``;\n"); + both("(a?.b).c``;\n", "(a?.b).c``;\n"); + both("(a?.b)`x${y}z`;\n", "(a?.b)`x${y}z`;\n"); + ts.expectPrintedMin_( + "function f(a) { const t = a?.b; return t`x` }", + "function f(a) {\n return (0, a?.b)`x`;\n}", + ); + ts.expectPrintedMin_( + "function f(a) { const t = a?.[b]; return t`x` }", + "function f(a) {\n return (0, a?.[b])`x`;\n}", + ); + both("(true && a?.b)``;\n", "(0, a?.b)``;\n"); + ts.expectParseError("a?.b``", "Template literals cannot have an optional chain as a tag"); + }); + + it("defines", () => { + // user_nested is defined as "location.origin" (see the transpiler options above) + both("user_nested();\n", "(0, location.origin)();\n"); + both("user_nested``;\n", "(0, location.origin)``;\n"); + both("user_nested(1)(2);\n", "(0, location.origin)(1)(2);\n"); + both("user_nested.x();\n", "location.origin.x();\n"); + both("new user_nested();\n", "new location.origin;\n"); + // A property access replaced by another property access keeps `this` + both("hello.earth();\n", "hello.mars();\n"); + both("hello.earth``;\n", "hello.mars``;\n"); + both("Math.log(1);\n", "console.error(1);\n"); + }); + }); + describe("TypeScript", () => { it("import Foo = Baz.Bar", () => { ts.expectPrinted_("import Foo = Baz.Bar;\nexport default Foo;", "const Foo = Baz.Bar;\nexport default Foo"); From b086423789dc8676d57fae48742660e8993a64e2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:34:08 +0000 Subject: [PATCH 2/8] Update the bytecode portability snapshot for the (0, import_x.fn)() wraps The happy-dom and libraries.js corpus bundles call named imports of node builtins (net.isIP, fs.readFileSync, url.fileURLToPath). In CommonJS output those are namespace members, so they now print as (0, import_net.isIP)(...) and the JS and bytecode hashes move on every platform at once. --- test/bundler/bundler_bytecode_portable.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/bundler/bundler_bytecode_portable.test.ts b/test/bundler/bundler_bytecode_portable.test.ts index 6765d3141050..cc8bba70fff7 100644 --- a/test/bundler/bundler_bytecode_portable.test.ts +++ b/test/bundler/bundler_bytecode_portable.test.ts @@ -509,10 +509,10 @@ describe("bytecode cache portability", () => { }, }, "bun build --bytecode happy-dom/lib/index.js": { - "js": "148f0d3e4baf485281725f859deb3e717a6da25a4a08de9af288d5ef54b6414b", + "js": "d8317565f2c8cfb22a79f27c7c7bacaf3bea7f15418033182fd41927be01f737", "jsc": { - "bytes": 2528840, - "sha256": "d68b260282a74f545c15b5a3b812b69cb4e828972f85c5de60f8307b73c43e57", + "bytes": 2528848, + "sha256": "0474ee53640f9a7f245597ed6954349d127cb46241c8afcd55604fdbedd8f438", }, }, "bun build --bytecode immutable/dist/immutable.es.js": { @@ -523,10 +523,10 @@ describe("bytecode cache portability", () => { }, }, "bun build --bytecode libraries.js": { - "js": "e685164a8316f60b665bc3d47e42b4623cfe7385f87310e655478a50ecd8f959", + "js": "8eea62dec4206aec6d22d22de842000d2adfcb9ca9dced8f4ed00e946a4cd818", "jsc": { - "bytes": 23806352, - "sha256": "5f23309532d868e1c985d568207c92b77a7f3974e5c6c3f37b21accf7d429327", + "bytes": 23806368, + "sha256": "c5c0839658c5ed75f53b811426b4b2f579ef99013879f9d16a6a249ad5ecaa11", }, }, "bun build --bytecode lodash/lodash.js": { From c3f3c50b06e983f0839f32478a7974be1d9c2b7b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:37:34 +0000 Subject: [PATCH 3/8] Shorten the comments on the this-preservation flags --- src/ast/e.rs | 12 +++--------- src/js_parser/visit/visit_expr.rs | 7 ++----- src/js_printer/lib.rs | 29 +++++++++-------------------- src/jsc/RuntimeTranspilerCache.rs | 3 +-- 4 files changed, 15 insertions(+), 36 deletions(-) diff --git a/src/ast/e.rs b/src/ast/e.rs index 7cee920b205f..f9249316a062 100644 --- a/src/ast/e.rs +++ b/src/ast/e.rs @@ -274,11 +274,8 @@ pub struct Call { /// Used when printing to generate the source prop on the fly pub was_jsx_element: bool, - /// True if the call target was a property access (`a.b()` or `a[b]()`) in - /// the source, so the call binds `this` to `a`. When a later rewrite turns - /// some other target into a property access (`(0, a.b)()` folded to - /// `a.b()`, or `fn()` bound to `import_ns.fn()`), this stays false and the - /// printer emits `(0, a.b)()` to keep `this` undefined. + /// The source wrote the target as `a.b()` or `a[b]()`. Any other target + /// that is a property access by print time gets the `(0, a.b)()` wrap. pub target_was_originally_property_access: bool, } impl Default for Call { @@ -2126,10 +2123,7 @@ pub struct Template { /// `parts()` / `parts_mut()` for ergonomic access; never null. pub parts: crate::StoreSlice, pub head: TemplateContents, - /// True if the tag was a property access (`a.b\`\`` or `a[b]\`\``) in the - /// source. Same role as `Call::target_was_originally_property_access`: a - /// tag that became a property access through a rewrite prints as - /// `(0, a.b)\`\`` so `this` stays undefined. + /// Same as `Call::target_was_originally_property_access`, for the tag. pub tag_was_originally_property_access: bool, } diff --git a/src/js_parser/visit/visit_expr.rs b/src/js_parser/visit/visit_expr.rs index 23919a4ff604..7b9c661b0024 100644 --- a/src/js_parser/visit/visit_expr.rs +++ b/src/js_parser/visit/visit_expr.rs @@ -175,8 +175,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O .expect("infallible: variant checked") .ref_, ); - // Parse-time refs: `p.call_target` and `p.template_tag` hold the node - // as it was before this visit resolved `e_.ref_`. + // Both hold the parse-time ref, so compare before `find_symbol` resolves `e_.ref_`. let is_call_target = matches!(p.call_target, Data::EIdentifier(ct) if ct.ref_.eql(e_.ref_)); let is_template_tag = matches!(p.template_tag, Data::EIdentifier(tt) if tt.ref_.eql(e_.ref_)); @@ -276,9 +275,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O || in_.assign_target == js_ast::AssignTarget::None { p.ignore_usage(e_.ref_); - // A dotted define value ("--define foo=a.b") is one - // identifier named "a.b" that prints as a property - // access. "foo()" => "(0, a.b)()" keeps `this` undefined. + // "--define foo=a.b" is one identifier named "a.b": "foo()" => "(0, a.b)()" let is_dotted_name = matches!(newvalue.data.tag(), Tag::EIdentifier) && def .original_name() diff --git a/src/js_printer/lib.rs b/src/js_printer/lib.rs index 849fafc295bb..5932d5cd5a82 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -1652,10 +1652,8 @@ pub(crate) mod __gated_printer { pub(crate) prev_op_end: i32, pub(crate) prev_num_end: i32, pub(crate) prev_reg_exp_end: i32, - /// The call target or template tag that is being printed right now, - /// when the source did not write it as a property access. An import - /// item in this position prints as `(0, import_ns.fn)` so that `this` - /// stays undefined. `None` outside of a target or tag. + /// The call target or template tag being printed, while the source did + /// not write it as a property access. See `is_unbound_call_target`. pub(crate) unbound_call_target: Option, pub(crate) writer: W, @@ -2619,14 +2617,10 @@ pub(crate) mod __gated_printer { } } - /// True when `e` is the call target or template tag being printed and - /// the source did not write that position as a property access. The - /// import item now prints as `import_ns.fn`, so it needs the - /// `(0, import_ns.fn)` wrap to keep `this` undefined. - /// - /// `unbound_call_target` holds the target's data only while the target - /// itself prints (the call and template arms clear it before the - /// arguments), so comparing refs here can only match the target node. + /// `e` is a call target or template tag that the source did not write + /// as a property access, so `import_ns.fn` needs the `(0, ...)` wrap. + /// The ref compare is exact because the call and template arms clear + /// `unbound_call_target` before they print the arguments. fn is_unbound_call_target(&self, e: &E::ImportIdentifier) -> bool { matches!( self.unbound_call_target, @@ -3480,12 +3474,8 @@ pub(crate) mod __gated_printer { && self.is_unbound_eval_identifier(e.target) && e.optional_chain.is_none(); - // "(0, a.b)()" must not become "a.b()": a property access - // that only became the call target through a rewrite - // (comma, logical, or conditional folding, single-use - // inlining, defines) prints as "(0, a.b)()" to keep - // `this` undefined. An import item in that position is - // wrapped the same way by the EImportIdentifier arm. + // A target that a fold or inlining turned into "a.b" keeps + // its undefined `this`: "(0, a.b)()", never "a.b()". let target_was_originally_property_access = e.target_was_originally_property_access; let must_preserve_this = !target_was_originally_property_access @@ -4162,8 +4152,7 @@ pub(crate) mod __gated_printer { if let Some(tag) = &e.tag { self.add_source_mapping(expr.loc); - // The tag binds `this` like a call target does. See - // the ECall arm for the two wrap paths. + // A tag binds `this` like a call target; same two wraps as ECall. self.unbound_call_target = (!e.tag_was_originally_property_access).then_some(tag.data); let is_optional_chain = match &tag.data { diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index 229f73811841..9dc46ad95dd9 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -55,8 +55,7 @@ bun_core::declare_scope!(cache, visible); /// offsets picked by a header byte) plus a body of tagged records with /// u8/u16/u32 ids and implied slots dropped, instead of fixed u32 arrays. /// Version 27: ModuleInfo string table holds Latin-1 / UTF-16 bodies, not WTF-8. -/// Version 28: Call targets and template tags that a fold turned into a -/// property access print as `(0, a.b)` so `this` stays undefined. +/// Version 28: Folded call targets and template tags print as `(0, a.b)`. const EXPECTED_VERSION: u32 = 28; /// Source files smaller than this are not written to / read from the on-disk From f61cb4207d2561f491e8db2203b083c5b2ba7ea7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:39:58 +0000 Subject: [PATCH 4/8] Make the this-preservation comments one line each --- src/ast/e.rs | 3 +-- src/js_printer/lib.rs | 12 ++++-------- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/src/ast/e.rs b/src/ast/e.rs index f9249316a062..2768bfb1fd7d 100644 --- a/src/ast/e.rs +++ b/src/ast/e.rs @@ -274,8 +274,7 @@ pub struct Call { /// Used when printing to generate the source prop on the fly pub was_jsx_element: bool, - /// The source wrote the target as `a.b()` or `a[b]()`. Any other target - /// that is a property access by print time gets the `(0, a.b)()` wrap. + /// Set for `a.b()` / `a[b]()` in the source. Other property-access targets print as `(0, a.b)()`. pub target_was_originally_property_access: bool, } impl Default for Call { diff --git a/src/js_printer/lib.rs b/src/js_printer/lib.rs index 5932d5cd5a82..34b79cd0db66 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -1652,8 +1652,7 @@ pub(crate) mod __gated_printer { pub(crate) prev_op_end: i32, pub(crate) prev_num_end: i32, pub(crate) prev_reg_exp_end: i32, - /// The call target or template tag being printed, while the source did - /// not write it as a property access. See `is_unbound_call_target`. + /// The call target or template tag being printed, unless the source wrote it as `a.b`. pub(crate) unbound_call_target: Option, pub(crate) writer: W, @@ -2617,10 +2616,8 @@ pub(crate) mod __gated_printer { } } - /// `e` is a call target or template tag that the source did not write - /// as a property access, so `import_ns.fn` needs the `(0, ...)` wrap. - /// The ref compare is exact because the call and template arms clear - /// `unbound_call_target` before they print the arguments. + /// Whether `import_ns.fn` for `e` needs the `(0, ...)` wrap. Exact by ref: the + /// call and template arms clear `unbound_call_target` before the arguments print. fn is_unbound_call_target(&self, e: &E::ImportIdentifier) -> bool { matches!( self.unbound_call_target, @@ -3474,8 +3471,7 @@ pub(crate) mod __gated_printer { && self.is_unbound_eval_identifier(e.target) && e.optional_chain.is_none(); - // A target that a fold or inlining turned into "a.b" keeps - // its undefined `this`: "(0, a.b)()", never "a.b()". + // "(0, a.b)()" folded to "a.b()" would bind `this` to "a"; keep the wrap. let target_was_originally_property_access = e.target_was_originally_property_access; let must_preserve_this = !target_was_originally_property_access From b3549b64b6479c0c822fe0dc67aa079659e237d7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:58:49 +0000 Subject: [PATCH 5/8] Pass the import identifier by value and add the template tag cases from #36735 The clippy lint trivially_copy_pass_by_ref rejects a reference to the 8-byte E::ImportIdentifier in is_unbound_call_target. The new test cases cover the folds that PR #36735 guarded one by one: a ternary whose test is typeof or a dropped ||, a call or template tag nested in the left operand of a comma, the {}.x ??= v fold, and an index into an object literal. The printer wraps every one of them. --- src/js_printer/lib.rs | 6 ++-- .../transpiler/runtime-transpiler.test.ts | 12 ++++--- test/bundler/transpiler/transpiler.test.js | 32 +++++++++++++++++++ 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/src/js_printer/lib.rs b/src/js_printer/lib.rs index 34b79cd0db66..6060ec108299 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -2618,7 +2618,7 @@ pub(crate) mod __gated_printer { /// Whether `import_ns.fn` for `e` needs the `(0, ...)` wrap. Exact by ref: the /// call and template arms clear `unbound_call_target` before the arguments print. - fn is_unbound_call_target(&self, e: &E::ImportIdentifier) -> bool { + fn is_unbound_call_target(&self, e: E::ImportIdentifier) -> bool { matches!( self.unbound_call_target, Some(ExprData::EImportIdentifier(target)) if target.ref_.eql(e.ref_) @@ -4263,7 +4263,7 @@ pub(crate) mod __gated_printer { self.import_record(namespace.import_record_index as usize); if namespace.was_originally_property_access { did_print = true; - let wrap = self.is_unbound_call_target(e); + let wrap = self.is_unbound_call_target(*e); if wrap { self.print_whitespacer(ws!(b"(0, ")); @@ -4296,7 +4296,7 @@ pub(crate) mod __gated_printer { if !did_print { did_print = true; - let wrap = self.is_unbound_call_target(e); + let wrap = self.is_unbound_call_target(*e); if wrap { self.print_whitespacer(ws!(b"(0, ")); diff --git a/test/bundler/transpiler/runtime-transpiler.test.ts b/test/bundler/transpiler/runtime-transpiler.test.ts index 24e0948d554d..216384e93c9c 100644 --- a/test/bundler/transpiler/runtime-transpiler.test.ts +++ b/test/bundler/transpiler/runtime-transpiler.test.ts @@ -288,16 +288,18 @@ describe("this binding for call targets and template tags", () => { ${describeThis} function single() { const t = o.f; return t\`x\`; } function singleIndex() { const t = o["f"]; return t\`x\`; } - console.log(o.f\`x\`, (o.f)\`x\`, o["f"]\`x\`, { foo: f }.foo\`\`, ({ foo: f }).foo\`\`); + console.log(o.f\`x\`, (o.f)\`x\`, o["f"]\`x\`, { foo: f }.foo\`\`, ({ foo: f }).foo\`\`, ({ foo: f })["foo"]\`\`); console.log((0, o.f)\`x\`, (true && o.f)\`x\`, (false || o.f)\`x\`, (null ?? o.f)\`x\`, (1 ? o.f : 0)\`x\`, (0 ? 1 : o.f)\`x\`); + console.log((typeof o ? o.f : 0)\`x\`, (typeof o && 0 ? 0 : o.f)\`x\`, ((() => f\`\`), o.f)\`x\`, ({}.x ??= o.f)\`x\`, ({}.x ||= o.f)\`x\`); console.log(single(), singleIndex(), (0, f)\`\`, (true && f)\`\`); `, }, "entry.mjs", ); expect(lines).toEqual([ - "o o o object object", + "o o o object object object", "undefined undefined undefined undefined undefined undefined", + "undefined undefined undefined undefined undefined", "undefined undefined undefined undefined", ]); }); @@ -309,16 +311,18 @@ describe("this binding for call targets and template tags", () => { ${describeThis} function single() { const t = o.f; return t(); } function singleIndex() { const t = o["f"]; return t(); } - console.log(o.f(), (o.f)(), o["f"](), o.f?.(), o?.f(), { foo: f }.foo(), ({ foo: f }).foo()); + console.log(o.f(), (o.f)(), o["f"](), o.f?.(), o?.f(), { foo: f }.foo(), ({ foo: f }).foo(), ({ foo: f })["foo"]()); console.log((0, o.f)(), (true && o.f)(), (false || o.f)(), (null ?? o.f)(), (1 ? o.f : 0)(), (0 ? 1 : o.f)()); + console.log((typeof o ? o.f : 0)(), (typeof o && 0 ? 0 : o.f)(), ((() => f()), o.f)(), ({}.x ??= o.f)(), ({}.x ||= o.f)()); console.log(single(), singleIndex(), (0, f)(), (true && f)()); `, }, "entry.mjs", ); expect(lines).toEqual([ - "o o o o o object object", + "o o o o o object object object", "undefined undefined undefined undefined undefined undefined", + "undefined undefined undefined undefined undefined", "undefined undefined undefined undefined", ]); }); diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 58a10fa4af9a..0f6012179ebf 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -323,9 +323,24 @@ describe("Bun.Transpiler", () => { both("let x = (true && y)();\n", "let x = y();\n"); both("let x = (1 ? y : 2)();\n", "let x = y();\n"); + // A test that is statically known but classed as side-effecting (typeof, a + // dropped `||`) folds through a different path than a plain constant. + both("let x = (typeof y ? y.z : 2)();\n", "let x = (0, y.z)();\n"); + both("let x = (typeof y && 0 ? 2 : y.z)();\n", "let x = (0, y.z)();\n"); + both("let x = (sideEffect() || 1 ? y.z : 2)();\n", "let x = (sideEffect(), y.z)();\n"); + + // A call nested in the left operand of the comma + ts.expectPrintedMin_("let x = ((() => f()), y.z)();\n", "let x = (0, y.z)();\n"); + + // The `{}.x ??= v` fold + both("let x = ({}.x ??= y.z)();\n", "let x = (0, y.z)();\n"); + both("let x = ({}.x ||= y.z)();\n", "let x = (0, y.z)();\n"); + both("let x = ({}.x ??= y)();\n", "let x = y();\n"); + // Object and array literal inlining ts.expectPrintedMin_("({ f: y }).f();\n", "({ f: y }).f();\n"); ts.expectPrintedMin_("({ f: y.z }).f();\n", "({ f: y.z }).f();\n"); + ts.expectPrintedMin_('({ f: y.z })["f"]();\n', "({ f: y.z }).f();\n"); ts.expectPrintedMin_("[y.z][0]();\n", "(0, y.z)();\n"); ts.expectPrintedMin_("[y][0]();\n", "y();\n"); @@ -367,10 +382,27 @@ describe("Bun.Transpiler", () => { both("(1 ? y[z] : 2)``;\n", "(0, y[z])``;\n"); both("(true && y)``;\n", "y``;\n"); both("(1 ? y : 2)``;\n", "y``;\n"); + both("(0 ? 1 : y[z])``;\n", "(0, y[z])``;\n"); + + // A test that is statically known but classed as side-effecting + both("(typeof y ? y.z : 2)``;\n", "(0, y.z)``;\n"); + both("(typeof y && 0 ? 2 : y.z)``;\n", "(0, y.z)``;\n"); + both("(sideEffect() || 1 ? y.z : 2)``;\n", "(sideEffect(), y.z)``;\n"); + + // A template tag nested in the left operand of the comma + ts.expectPrintedMin_("((() => f``), y.z)``;\n", "(0, y.z)``;\n"); + both("((true ? 0 : f``), y.z)``;\n", "(0, y.z)``;\n"); + + // The `{}.x ??= v` fold + both("({}.x ??= y.z)``;\n", "(0, y.z)``;\n"); + both("({}.x ||= y.z)``;\n", "(0, y.z)``;\n"); + both("({}.x ??= y)``;\n", "y``;\n"); // Object and array literal inlining ts.expectPrintedMin_("({ f: y }).f``;\n", "({ f: y }).f``;\n"); ts.expectPrintedMin_("({ f: y.z }).f``;\n", "({ f: y.z }).f``;\n"); + ts.expectPrintedMin_('({ f: y.z })["f"]``;\n', "({ f: y.z }).f``;\n"); + ts.expectPrintedMin_('({ f: y })["f"]``;\n', "({ f: y }).f``;\n"); ts.expectPrintedMin_("[y.z][0]``;\n", "(0, y.z)``;\n"); ts.expectPrintedMin_("[y][0]``;\n", "y``;\n"); From 5d98550a8681b1e1bfea6573b7f4051242e3d7b5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:18:43 +0000 Subject: [PATCH 6/8] React compiler: keep the receiver of a props method call optimize_props_method_calls turns props.foo(1) into a CallExpression whose callee is the props.foo property load. The printer now wraps a property access call target in (0, ...) unless the call was originally a property access, so the compiled output became (0, props.foo)(1) and this changed from props to undefined. Babel prints the callee as written. Set the flag from the callee that codegen emits. Also lock in the output for a bare call of a namespace export: f() inside namespace NS prints (0, NS.f)(), as esbuild does. --- src/react_compiler/codegen.rs | 4 ++ .../bundler/transpiler/react-compiler.test.ts | 39 +++++++++++++++++++ test/bundler/transpiler/transpiler.test.js | 10 +++++ 3 files changed, 53 insertions(+) diff --git a/src/react_compiler/codegen.rs b/src/react_compiler/codegen.rs index 256239224bab..bdcb11edd50e 100644 --- a/src/react_compiler/codegen.rs +++ b/src/react_compiler/codegen.rs @@ -1892,6 +1892,10 @@ fn codegen_base_instruction_value( } let call_expr = Expr::init( E::Call { + // Babel prints the callee as written, so `props.foo(...)` from + // optimize_props_method_calls stays a method call. + target_was_originally_property_access: callee_expr + .has_value_for_this_in_call(), target: callee_expr, args: arguments, ..Default::default() diff --git a/test/bundler/transpiler/react-compiler.test.ts b/test/bundler/transpiler/react-compiler.test.ts index 8b1da356fb4e..eb64d9fe66d1 100644 --- a/test/bundler/transpiler/react-compiler.test.ts +++ b/test/bundler/transpiler/react-compiler.test.ts @@ -697,6 +697,45 @@ describe("bundler", () => { }, }); + // optimize_props_method_calls turns `props.render(1)` into a CallExpression + // whose callee is the `props.render` property load. Babel prints that callee + // as written, so the receiver stays `props`. The printer wraps a property + // access call target in `(0, ...)` unless the call was originally a property + // access, so codegen must set that flag from the callee it emits. + itBundled("react-compiler/PropsMethodCallKeepsReceiver", { + files: { + "/entry.jsx": /* jsx */ ` + export function Comp(props) { + const a = props.render(1); + const b = props.api.compute(2); + return
{a}{b}
; + } + const props = { + render(n) { return (this === props ? "props" : String(this)) + n; }, + api: { compute(n) { return (this === props.api ? "api" : String(this)) + n; } }, + }; + const el = Comp(props); + console.log(el.props.children.join("")); + `, + "/node_modules/react/index.js": `module.exports = {};`, + "/node_modules/react/jsx-runtime.js": `exports.jsx = exports.jsxs = (t, p) => ({ t, props: p });`, + "/node_modules/react/jsx-dev-runtime.js": `exports.jsxDEV = (t, p) => ({ t, props: p });`, + "/node_modules/react/compiler-runtime.js": `exports.c = n => new Array(n).fill(Symbol.for("react.memo_cache_sentinel"));`, + "/node_modules/react/package.json": `{"name":"react","main":"./index.js"}`, + }, + reactCompiler: true, + target: "browser", + backend: "cli", + run: { stdout: "props1api2" }, + onAfterBundle(api) { + const out = api.readFile("/out.js"); + // The component must be compiled (a bailout would leave the source as is). + expect(out).toContain("react.memo_cache_sentinel"); + expect(out).toMatch(/\bprops\.render\(1\)/); + expect(out).not.toMatch(/\(\s*0\s*,\s*props\./); + }, + }); + // `delete (true ? o.a : o.b)` is a no-op per spec (operand is a value, not a // Reference). The visitor folds the conditional to a bare EDot with the // delete-flag unset; lowering must not turn that into a real PropertyDelete. diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 0f6012179ebf..ac34fb49a65d 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -438,6 +438,16 @@ describe("Bun.Transpiler", () => { ts.expectParseError("a?.b``", "Template literals cannot have an optional chain as a tag"); }); + it("TypeScript namespace exports", () => { + // A bare `f()` inside the namespace becomes `NS.f()` in the output. The + // source call had no receiver, so the wrap keeps `this` undefined, as + // esbuild does. A call written as `NS.f()` keeps `NS`. + both( + "namespace NS { export const f = () => 1; export function g() {} export let r = [f(), g(), f``, NS.f()]; }", + "var NS;\n((NS) => {\n NS.f = () => 1;\n function g() {}\n NS.g = g;\n NS.r = [(0, NS.f)(), g(), (0, NS.f)``, NS.f()];\n})(NS ||= {});\n", + ); + }); + it("defines", () => { // user_nested is defined as "location.origin" (see the transpiler options above) both("user_nested();\n", "(0, location.origin)();\n"); From 312458d3c2e24fcff7ec512bc58a68907a22d058 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:18:59 +0000 Subject: [PATCH 7/8] Format codegen.rs --- src/react_compiler/codegen.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/react_compiler/codegen.rs b/src/react_compiler/codegen.rs index bdcb11edd50e..164ab5fe7930 100644 --- a/src/react_compiler/codegen.rs +++ b/src/react_compiler/codegen.rs @@ -1894,8 +1894,7 @@ fn codegen_base_instruction_value( E::Call { // Babel prints the callee as written, so `props.foo(...)` from // optimize_props_method_calls stays a method call. - target_was_originally_property_access: callee_expr - .has_value_for_this_in_call(), + target_was_originally_property_access: callee_expr.has_value_for_this_in_call(), target: callee_expr, args: arguments, ..Default::default() From c7fe42e7c628b9d0f2ed62ff29f589e81c437e2b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:20:11 +0000 Subject: [PATCH 8/8] Make the React compiler callee comment one line --- src/react_compiler/codegen.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/react_compiler/codegen.rs b/src/react_compiler/codegen.rs index 164ab5fe7930..de8c5fb4f482 100644 --- a/src/react_compiler/codegen.rs +++ b/src/react_compiler/codegen.rs @@ -1892,8 +1892,7 @@ fn codegen_base_instruction_value( } let call_expr = Expr::init( E::Call { - // Babel prints the callee as written, so `props.foo(...)` from - // optimize_props_method_calls stays a method call. + // Babel keeps `props.foo(...)` from optimize_props_method_calls a method call. target_was_originally_property_access: callee_expr.has_value_for_this_in_call(), target: callee_expr, args: arguments,