From 5f2182795bb5c52036279a854c07530af03e3830 Mon Sep 17 00:00:00 2001 From: robobun Date: Wed, 2 Sep 2026 04:22:31 +0000 Subject: [PATCH 01/11] minify: port esbuild's statement-level syntax passes Port mangleIf, the jump merging in mangleStmts, mangleFor, MangleIfExpr, ValuesLookTheSame, the optional catch binding drop, unused label removal, and the try statement trimming from esbuild. Add the strict-to-loose equality change for known primitives, the null-or-undefined comparison merge, and the right-associative rotation for ||, && and ??. Statement restructuring runs for bun build with and without --no-bundle through Options::full_minify_syntax(). The runtime transpiler keeps its statement layout. Inside a function body the React Compiler may compile, the restructuring passes stay off because the compiler does not lower for(;;) loops or the && form of a lazy ref initialization. The for loop visitor now clears a var initializer that was relocated to the top level instead of keeping both copies. --- src/ast/expr.rs | 13 +- src/js_parser/p.rs | 50 +- src/js_parser/parse/parse_entry.rs | 14 +- src/js_parser/scan/scan_side_effects.rs | 211 ++- src/js_parser/visit/mangle.rs | 1571 +++++++++++++++++ src/js_parser/visit/mod.rs | 381 +--- src/js_parser/visit/visit_binary.rs | 114 ++ src/js_parser/visit/visit_expr.rs | 29 +- src/js_parser/visit/visit_stmt.rs | 210 ++- test/bundler/bundler_edgecase.test.ts | 2 +- test/bundler/bundler_minify.test.ts | 180 +- test/bundler/bundler_npm.test.ts | 14 +- test/bundler/esbuild/js_parser_mangle.test.ts | 641 +++++++ test/bundler/transpiler/transpiler.test.js | 7 +- 14 files changed, 2862 insertions(+), 575 deletions(-) create mode 100644 src/js_parser/visit/mangle.rs create mode 100644 test/bundler/esbuild/js_parser_mangle.test.ts diff --git a/src/ast/expr.rs b/src/ast/expr.rs index cfedeb4f9bf8..59cfea86efcf 100644 --- a/src/ast/expr.rs +++ b/src/ast/expr.rs @@ -1247,7 +1247,7 @@ impl Expr { // will potentially be simplified to avoid generating unnecessary extra "!" // operators. For example, calling this with "!!x" will return "!x" instead // of returning "!!!x". - pub(crate) fn not(&self, bump: &Bump) -> Expr { + pub fn not(&self, bump: &Bump) -> Expr { self.maybe_simplify_not(bump).unwrap_or_else(|| { Expr::init( E::Unary { @@ -2491,6 +2491,15 @@ impl Data { break 'brk PrimitiveType::Mixed; // Can be number or bigint or string (or an exception) } + crate::OpCode::BinAddAssign => { + if binary.right.data.known_primitive_with_check(stack_check) + == PrimitiveType::String + { + break 'brk PrimitiveType::String; + } + break 'brk PrimitiveType::Mixed; // Can be number or bigint or string (or an exception) + } + crate::OpCode::BinSub | crate::OpCode::BinSubAssign | crate::OpCode::BinMul @@ -2629,7 +2638,7 @@ impl Data { } } - pub(crate) fn extract_numeric_value(&self) -> Option { + pub fn extract_numeric_value(&self) -> Option { match self { Data::ENumber(n) => Some(n.value()), Data::EInlinedEnum(inlined) => match &inlined.value.data { diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index a96df3d43031..a73729339ccd 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -442,6 +442,9 @@ pub struct P<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> { /// See [`bun_react_compiler::PendingCompile`]. Set by `visit_func` / /// arrow-visit, consumed inside `visit_stmts` between visit and mangle. pub(crate) react_compiler_pending: Option, + /// True while visiting the body of a function the React Compiler may + /// compile. See `full_minify_syntax`. + pub(crate) in_react_compiler_candidate: bool, /// Compiled args/flags written by the `visit_stmts` hook for `visit_func` / /// arrow-visit to apply to the original `G::Fn` / `E::Arrow`. pub(crate) react_compiler_result: Option, @@ -5727,6 +5730,15 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O true } + /// `Options::full_minify_syntax()` for this point of the visit. It is off + /// inside a function body the React Compiler may compile: the compiler sees + /// the body after its statements were visited, and it does not lower some + /// shapes the statement mangler produces (a `for (;;)` loop made from a + /// `while`, `ref.current == null && (ref.current = x)` made from an `if`). + pub(crate) fn full_minify_syntax(&self) -> bool { + self.options.full_minify_syntax() && !self.in_react_compiler_candidate + } + // TODO: // When React Fast Refresh is enabled, anything that's a JSX component should not be removable // This is to improve the reliability of fast refresh between page loads. @@ -6346,43 +6358,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } - pub(crate) fn append_if_body_preserving_scope( - &mut self, - stmts: &mut ListManaged<'a, Stmt>, - body: Stmt, - ) -> Result<(), crate::Error> { - if let js_ast::StmtData::SBlock(block) = &body.data { - // `S::Block.stmts` is `StoreSlice` arena-owned for parser 'a; no aliasing &mut. - let block_stmts: &[Stmt] = block.stmts.slice(); - let mut keep_block = false; - for stmt in block_stmts { - if statement_cares_about_scope(stmt) { - keep_block = true; - break; - } - } - if !keep_block && !block_stmts.is_empty() { - stmts.extend_from_slice(block_stmts); - return Ok(()); - } - } - - if statement_cares_about_scope(&body) { - let block_stmts = self.arena.alloc_slice_copy(&[body]); - stmts.push(self.s( - S::Block { - stmts: block_stmts.into(), - close_brace_loc: bun_ast::Loc::EMPTY, - }, - body.loc, - )); - return Ok(()); - } - - stmts.push(body); - Ok(()) - } - #[cold] #[inline(never)] fn mark_exported_binding_inside_namespace(&mut self, r#ref: Ref, binding: BindingNodeIndex) { @@ -9265,6 +9240,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O react_compiler_candidate_name: None, react_compiler_in_react_hoc: false, react_compiler_pending: None, + in_react_compiler_candidate: false, react_compiler_result: None, server_components_wrap_ref: Ref::NONE, jest: Jest::default(), diff --git a/src/js_parser/parse/parse_entry.rs b/src/js_parser/parse/parse_entry.rs index d0edbf4f2846..7ef2a02b934c 100644 --- a/src/js_parser/parse/parse_entry.rs +++ b/src/js_parser/parse/parse_entry.rs @@ -268,10 +268,16 @@ impl<'a> Options<'a> { self.features.hash_for_runtime_transpiler(hasher); } - // Used to determine if `joinWithComma` should be called in `visitStmts`. We do this - // to avoid changing line numbers too much to make source mapping more readable - pub(crate) fn runtime_merge_adjacent_expression_statements(&self) -> bool { - self.bundle + /// `minify_syntax` for a build: `bun build`, bundled or with `--no-bundle`. + /// + /// The runtime transpiler (`bun run`) also turns `minify_syntax` on, for + /// constant inlining, but it keeps one output statement per source statement + /// so that line numbers and `Function.prototype.toString()` stay close to + /// the source. Every pass that merges or restructures statements (comma + /// joins, `if` to `&&`/`?:`, return and throw chains, `while` to `for`, + /// arrow bodies to expressions) checks this instead of `minify_syntax`. + pub(crate) fn full_minify_syntax(&self) -> bool { + self.features.minify_syntax && (self.bundle || self.transform_only) } pub fn init(jsx: options::JSX::Pragma, loader: options::Loader) -> Options<'static> { diff --git a/src/js_parser/scan/scan_side_effects.rs b/src/js_parser/scan/scan_side_effects.rs index f541f38bad57..b863b834668f 100644 --- a/src/js_parser/scan/scan_side_effects.rs +++ b/src/js_parser/scan/scan_side_effects.rs @@ -35,62 +35,130 @@ impl SideEffects { && left != bun_ast::expr::PrimitiveType::Mixed } + /// Simplify an expression that is only tested for truthiness, such as the + /// test of an `if`. Port of esbuild's `SimplifyBooleanExpr`. pub(crate) fn simplify_boolean<'a, const TS: bool, const SCAN: bool>( - p: &P<'a, TS, SCAN>, + p: &mut P<'a, TS, SCAN>, expr: Expr, ) -> Expr { - if !p.options.features.dead_code_elimination { + if !p.options.features.dead_code_elimination || !p.stack_check.is_safe_to_recurse() { return expr; } - let mut result: Expr = expr; - Self::_simplify_boolean(p, &mut result); - result - } - fn _simplify_boolean<'a, const TS: bool, const SCAN: bool>( - p: &P<'a, TS, SCAN>, - expr: &mut Expr, - ) { - loop { - match &mut expr.data { - ExprData::EUnary(e) => { - if e.op == Op::Code::UnNot { - // "!!a" => "a" - if let ExprData::EUnary(inner) = &e.value.data { - if inner.op == Op::Code::UnNot { - *expr = inner.value; - continue; - } + match expr.data { + ExprData::EUnary(mut e) if e.op == Op::Code::UnNot => { + // "!!a" => "a" + if let ExprData::EUnary(inner) = e.value.data + && inner.op == Op::Code::UnNot + { + return Self::simplify_boolean(p, inner.value); + } + + // "!!!a" => "!a" + e.value = Self::simplify_boolean(p, e.value); + } + + ExprData::EBinary(mut e) => match e.op { + Op::Code::BinStrictEq + | Op::Code::BinStrictNe + | Op::Code::BinLooseEq + | Op::Code::BinLooseNe => { + if let Some(r) = e.right.data.extract_numeric_value() + && r == 0.0 + && is_int32_or_uint32(&e.left.data) + { + // If the left is guaranteed to be an integer (e.g. not NaN, + // Infinity, or a non-numeric value) then a test against zero + // in a boolean context is unnecessary because the value is + // only truthy if it's not zero. + if e.op == Op::Code::BinStrictNe || e.op == Op::Code::BinLooseNe { + // "if ((a >>> b) !== 0)" => "if (a >>> b)" + return e.left; } - Self::_simplify_boolean(p, &mut e.value); + // "if ((a >>> b) === 0)" => "if (!(a >>> b))" + return e.left.not(p.arena); } } - ExprData::EBinary(e) => match e.op { - Op::Code::BinLogicalAnd => { - if let Some(effects) = SideEffects::to_boolean(p, &e.right.data) { - if effects.value && effects.side_effects == SideEffects::NoSideEffects { - // "if (anything && truthyNoSideEffects)" => "if (anything)" - *expr = e.left; - continue; - } - } + + Op::Code::BinLogicalAnd => { + // "if (!!a && !!b)" => "if (a && b)" + e.left = Self::simplify_boolean(p, e.left); + e.right = Self::simplify_boolean(p, e.right); + + if let Some(effects) = SideEffects::to_boolean(p, &e.right.data) + && effects.value + && effects.side_effects == SideEffects::NoSideEffects + { + // "if (anything && truthyNoSideEffects)" => "if (anything)" + return e.left; } - Op::Code::BinLogicalOr => { - if let Some(effects) = SideEffects::to_boolean(p, &e.right.data) { - if !effects.value && effects.side_effects == SideEffects::NoSideEffects - { - // "if (anything || falsyNoSideEffects)" => "if (anything)" - *expr = e.left; - continue; - } - } + } + + Op::Code::BinLogicalOr => { + // "if (!!a || !!b)" => "if (a || b)" + e.left = Self::simplify_boolean(p, e.left); + e.right = Self::simplify_boolean(p, e.right); + + if let Some(effects) = SideEffects::to_boolean(p, &e.right.data) + && !effects.value + && effects.side_effects == SideEffects::NoSideEffects + { + // "if (anything || falsyNoSideEffects)" => "if (anything)" + return e.left; } - _ => {} - }, + } + _ => {} + }, + + ExprData::EIf(mut e) => { + // "if (a ? !!b : !!c)" => "if (a ? b : c)" + e.yes = Self::simplify_boolean(p, e.yes); + e.no = Self::simplify_boolean(p, e.no); + + if let Some(effects) = SideEffects::to_boolean(p, &e.yes.data) + && effects.side_effects == SideEffects::NoSideEffects + { + if effects.value { + // "if (anything1 ? truthyNoSideEffects : anything2)" => "if (anything1 || anything2)" + return Expr::join_with_left_associative_op( + Op::Code::BinLogicalOr, + e.test, + e.no, + ); + } + // "if (anything1 ? falsyNoSideEffects : anything2)" => "if (!anything1 && anything2)" + return Expr::join_with_left_associative_op( + Op::Code::BinLogicalAnd, + e.test.not(p.arena), + e.no, + ); + } + + if let Some(effects) = SideEffects::to_boolean(p, &e.no.data) + && effects.side_effects == SideEffects::NoSideEffects + { + if effects.value { + // "if (anything1 ? anything2 : truthyNoSideEffects)" => "if (!anything1 || anything2)" + return Expr::join_with_left_associative_op( + Op::Code::BinLogicalOr, + e.test.not(p.arena), + e.yes, + ); + } + // "if (anything1 ? anything2 : falsyNoSideEffects)" => "if (anything1 && anything2)" + return Expr::join_with_left_associative_op( + Op::Code::BinLogicalAnd, + e.test, + e.yes, + ); + } } - break; + + _ => {} } + + expr } // Re-exports of ExprData methods. @@ -327,6 +395,15 @@ impl SideEffects { Op::Code::BinLogicalAnd | Op::Code::BinLogicalOr | Op::Code::BinNullishCoalescing => { + // If this is a boolean logical operation and the result is unused, then + // we know the left operand will only be used for its boolean value and + // can be simplified under that assumption + if bin.op != Op::Code::BinNullishCoalescing + && p.options.features.minify_syntax + { + bin.left = Self::simplify_boolean(p, bin.left); + } + let right = bin.right; bin.right = Self::simplify_unused_expr(p, right) .unwrap_or_else(|| right.to_empty()); @@ -337,6 +414,39 @@ impl SideEffects { if bin.right.is_empty() { return Self::simplify_unused_expr(p, bin.left); } + + // Try to take advantage of the optional chain operator to shorten code + if p.options.features.minify_syntax + && let ExprData::EBinary(binary) = bin.left.data + // "a != null && a.b()" => "a?.b()" + // "a == null || a.b()" => "a?.b()" + && ((binary.op == Op::Code::BinLooseNe + && bin.op == Op::Code::BinLogicalAnd) + || (binary.op == Op::Code::BinLooseEq + && bin.op == Op::Code::BinLogicalOr)) + { + let test = if matches!(binary.right.data, ExprData::ENull(_)) { + Some(binary.left) + } else if matches!(binary.left.data, ExprData::ENull(_)) { + Some(binary.right) + } else { + None + }; + + // Note: Technically unbound identifiers can refer to a getter on + // the global object and that getter can have side effects that can + // be observed if we run that getter once instead of twice. But this + // seems like terrible coding practice and very unlikely to come up + // in real software, so we deliberately ignore this possibility and + // optimize for size instead of for this obscure edge case. + if let Some(test) = test + && let ExprData::EIdentifier(id) = test.data + && !id.must_keep_due_to_with_stmt() + && p.try_to_insert_optional_chain(test, bin.right) + { + return Some(bin.right); + } + } } _ => {} @@ -1001,3 +1111,20 @@ impl SideEffects { } } } + +/// True when the expression always evaluates to an int32 or uint32, so that it +/// is falsy exactly when it is zero. +fn is_int32_or_uint32(data: &ExprData) -> bool { + match data { + ExprData::EBinary(e) => match e.op { + // This is the only bitwise operator that can't return a bigint (because it throws instead) + Op::Code::BinUShr => true, + Op::Code::BinLogicalOr | Op::Code::BinLogicalAnd => { + is_int32_or_uint32(&e.left.data) && is_int32_or_uint32(&e.right.data) + } + _ => false, + }, + ExprData::EIf(e) => is_int32_or_uint32(&e.yes.data) && is_int32_or_uint32(&e.no.data), + _ => false, + } +} diff --git a/src/js_parser/visit/mangle.rs b/src/js_parser/visit/mangle.rs new file mode 100644 index 000000000000..5444778c9c6d --- /dev/null +++ b/src/js_parser/visit/mangle.rs @@ -0,0 +1,1571 @@ +//! Statement-level syntax minification. Ports of esbuild's `mangleStmts`, +//! `mangleIf`, `mangleFor`, and of the conditional-expression folding +//! (`MangleIfExpr`) the statement passes build on. +//! +//! `mangle_stmts` is the tail of `visit_stmts`: it runs once every statement in +//! a list has been visited, so every branch and loop body it looks at has +//! already been mangled. The passes that merge or restructure statements check +//! `Options::full_minify_syntax()`; the ones that only fold constants run +//! whenever `minify_syntax` is on. + +use crate::p::P; +use crate::parser::{StmtsKind, statement_cares_about_scope}; +use crate::scan::scan_side_effects::SideEffects; +use bun_alloc::{Arena as Bump, ArenaVec as BumpVec, ArenaVecExt as _}; +use bun_ast::b::B as BData; +use bun_ast::expr::{Equality, StrictEql}; +use bun_ast::s::{Kind as LocalKind, LocalOrigin}; +use bun_ast::{ + E, Expr, ExprData, G, Loc, OpCode, OptionalChain, Ref, S, Stmt, StmtData, StoreRef, StoreSlice, + Symbol, +}; +use bun_collections::VecExt; + +type ListManaged<'bump, T> = BumpVec<'bump, T>; + +impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_ONLY> { + /// Merge and simplify a list of visited statements. Port of esbuild's + /// `mangleStmts`. The caller checks `minify_syntax` and + /// `dead_code_elimination`. + pub(crate) fn mangle_stmts( + &mut self, + mut stmts: ListManaged<'a, Stmt>, + kind: StmtsKind, + ) -> ListManaged<'a, Stmt> { + let p = self; + + // SAFETY: current_scope is a valid arena ptr for the parse. + if p.current_scope().parent.is_some() && !p.current_scope().contains_direct_eval { + // Remove inlined constants now that we know whether any of these statements + // contained a direct eval() or not. This can't be done earlier when we + // encounter the constant because we haven't encountered the eval() yet. + // Inlined constants are not removed if they are in a top-level scope or + // if they are exported (which could be in a nested TypeScript namespace). + if p.const_values.count() > 0 { + let items: &mut [Stmt] = stmts.as_mut_slice(); + for stmt in items.iter_mut() { + match stmt.data { + StmtData::SEmpty(_) + | StmtData::SComment(_) + | StmtData::SDirective(_) + | StmtData::SDebugger(_) + | StmtData::STypeScript(_) => continue, + StmtData::SLocal(mut local) => { + // "using" / "await using" declarations have disposal + // side-effects on scope exit. Their refs can end up in + // `const_values` via the macro path in `visitDecl` + // (`could_be_macro`), so skip them here to avoid + // silently dropping the declaration. + if local.kind.is_using() { + continue; + } + if !local.is_export && !local.origin.is_commonjs_export() { + let mut any_decl_in_const_values = local.kind == LocalKind::KConst; + let decls: &mut [G::Decl] = local.decls.slice_mut(); + let mut end: usize = 0; + for idx in 0..decls.len() { + if let BData::BIdentifier(id_ptr) = decls[idx].binding.data { + let id_ref = id_ptr.r#ref; + if p.const_values.contains(&id_ref) { + any_decl_in_const_values = true; + let symbol = &p.symbols[id_ref.inner_index() as usize]; + if symbol.use_count_estimate == 0 { + // Skip declarations that are constants with zero usage + continue; + } + } + } + // `Decl` is field-wise `Copy` but lacks the + // derive; `swap` compacts in place (idx >= end always). + decls.swap(end, idx); + end += 1; + } + local.decls.truncate(end); + if any_decl_in_const_values { + if end == 0 { + *stmt = stmt.to_empty(); + } + continue; + } + } + } + _ => {} + } + + // Break after processing relevant statements + break; + } + } + } + + let full = p.full_minify_syntax(); + let mut is_control_flow_dead = false; + + let mut output: ListManaged<'a, Stmt> = ListManaged::with_capacity_in(stmts.len(), p.arena); + + for i in 0..stmts.len() { + let stmt = stmts[i]; + if is_control_flow_dead + && !SideEffects::should_keep_stmt_in_dead_control_flow(stmt, p.arena) + { + // Strip unnecessary statements if the control flow is dead here + continue; + } + + // Inline single-use variable declarations where possible: + // + // // Before + // let x = fn(); + // return x.y(); + // + // // After + // return fn().y(); + // + // The declaration must not be exported. We can't just check for the + // "export" keyword because something might do "export {id};" later on. + // Instead we just ignore all top-level declarations for now. That means + // this optimization currently only applies in nested scopes. + // + // Ignore declarations if the scope is shadowed by a direct "eval" call. + // The eval'd code may indirectly reference this symbol and the actual + // use count may be greater than 1. + // SAFETY: current_scope is a valid arena ptr for the parse. + if p.current_scope != p.module_scope && !p.current_scope().contains_direct_eval { + // Keep inlining variables until a failure or until there are none left. + // That handles cases like this: + // + // // Before + // let x = fn(); + // let y = x.prop; + // return y; + // + // // After + // return fn().prop; + // + 'inner: while output.len() > 0 { + // Ignore "var" declarations since those have function-level scope and + // we may not have visited all of their uses yet by this point. We + // should have visited all the uses of "let" and "const" declarations + // by now since they are scoped to this block which we just finished + // visiting. + let prev_idx = output.len() - 1; + // borrowck: read the `StoreRef` (Copy) first, then re-borrow + // `output` only when truncating. + let StmtData::SLocal(mut local) = output[prev_idx].data else { + break; + }; + // "using" / "await using" declarations have disposal + // side-effects on scope exit, so they must not be + // removed by inlining their initializer into the use. + if local.decls.len_u32() == 0 + || local.kind == LocalKind::KVar + || local.kind.is_using() + || local.is_export + { + break; + } + + // The variable must be initialized, since we will be substituting + // the value into the usage. + let last_idx = (local.decls.len_u32() - 1) as usize; + let last: &mut G::Decl = &mut local.decls.slice_mut()[last_idx]; + let Some(replacement) = last.value else { break }; + + // The binding must be an identifier that is only used once. + // Ignore destructuring bindings since that's not the simple case. + // Destructuring bindings could potentially execute side-effecting + // code which would invalidate reordering. + let BData::BIdentifier(ident_ptr) = last.binding.data else { + break; + }; + let id = ident_ptr.r#ref; + + let symbol: &Symbol = &p.symbols[id.inner_index() as usize]; + + // Try to substitute the identifier with the initializer. This will + // fail if something with side effects is in between the declaration + // and the usage. + if symbol.use_count_estimate == 1 + && p.substitute_single_use_symbol_in_stmt(stmt, id, replacement) + { + // `const ns = await import(x); return ns` — the single use just + // moved into `replacement`; unless it was an accounted-for read + // (`f(ns.a)`), the namespace escapes there. + if p.dynamic_import_namespace_locals.contains_key(&id) + && p.namespace_tracked_uses.get(&id).copied().unwrap_or(0) == 0 + { + // Read as "more uses than accounted for" when finalizing. + p.namespace_tracked_uses.insert(id, u32::MAX); + } + match local.decls.len_u32() { + 1 => { + local.decls.clear(); + let new_len = output.len() - 1; + output.truncate(new_len); + continue 'inner; + } + _ => { + let n = local.decls.len() - 1; + local.decls.truncate(n); + continue 'inner; + } + } + } + break; + } + } + + // don't merge super calls to ensure they are called before "this" is accessed + if stmt.is_super_call() { + output.push(stmt); + continue; + } + + match stmt.data { + StmtData::SEmpty(_) => continue, + + // skip directives for now + StmtData::SDirective(_) => continue, + + StmtData::SLocal(local) => { + // Merge adjacent local statements + if output.len() > 0 { + let prev_idx = output.len() - 1; + let prev_stmt = &mut output[prev_idx]; + if let StmtData::SLocal(mut prev_local) = prev_stmt.data { + if local.can_merge_with(&prev_local) { + // `Vec::append_slice` requires `T: Clone` + // but `G::Decl` lacks the derive (its fields are all + // `Copy`). Per-element bitwise copy instead. + // + // The parse pass allocates `decls` in the bump arena + // (`from_bump_slice` → `Origin::Borrowed`); promote to a + // global-heap buffer before growing it. + for d in local.decls.slice() { + // SAFETY: Decl is field-wise Copy (Binding, Option). + prev_local.decls.push(unsafe { core::ptr::read(d) }); + } + continue; + } + } + } + } + + StmtData::SExpr(s_expr) => { + // Merge adjacent expression statements + if output.len() > 0 { + let prev_idx = output.len() - 1; + let prev_stmt = &mut output[prev_idx]; + if let StmtData::SExpr(mut prev_expr) = prev_stmt.data { + if !prev_stmt.is_super_call() && full { + prev_expr.does_not_affect_tree_shaking = prev_expr + .does_not_affect_tree_shaking + && s_expr.does_not_affect_tree_shaking; + prev_expr.value = + Expr::join_with_comma(prev_expr.value, s_expr.value); + continue; + } + } else if let StmtData::SLocal(prev_local) = prev_stmt.data { + // + // Input: + // var f; + // f = 123; + // Output: + // var f = 123; + // + // This doesn't handle every case. Only the very simple one. + if let ExprData::EBinary(bin_assign) = s_expr.value.data { + if prev_local.decls.len_u32() == 1 + && bin_assign.op == OpCode::BinAssign + // we can only do this with var because var is hoisted + // the statement we are merging into may use the statement before its defined. + && prev_local.kind == LocalKind::KVar + { + if let ExprData::EIdentifier(left_id) = bin_assign.left.data { + // `prev_local` is a `StoreRef` (Copy) so + // re-slicing here writes through to the arena slot. + let mut prev_local = prev_local; + let decl = &mut prev_local.decls.slice_mut()[0]; + if let BData::BIdentifier(bid_ptr) = decl.binding.data { + let bid_ref = bid_ptr.r#ref; + if bid_ref.eql(left_id.ref_) + // If the value was assigned, we shouldn't merge it incase it was used in the current statement + // https://github.com/oven-sh/bun/issues/2948 + // We don't have a more granular way to check symbol usage so this is the best we can do + && decl.value.is_none() + { + decl.value = Some(bin_assign.right); + p.ignore_usage(left_id.ref_); + continue; + } + } + } + } + } + } + } + } + StmtData::SSwitch(mut s_switch) => { + // Absorb a previous expression statement + if output.len() > 0 && full { + let prev_idx = output.len() - 1; + let prev_stmt = output[prev_idx]; + if let StmtData::SExpr(prev_expr) = prev_stmt.data { + if !prev_stmt.is_super_call() { + s_switch.test = + Expr::join_with_comma(prev_expr.value, s_switch.test); + output.truncate(prev_idx); + } + } + } + } + StmtData::SIf(mut s_if) => { + // Absorb a previous expression statement + if output.len() > 0 && full { + let prev_idx = output.len() - 1; + let prev_stmt = output[prev_idx]; + if let StmtData::SExpr(prev_expr) = prev_stmt.data { + if !prev_stmt.is_super_call() { + s_if.test = Expr::join_with_comma(prev_expr.value, s_if.test); + output.truncate(prev_idx); + } + } + } + + if full && is_jump_statement(s_if.yes.data) { + // Absorb a previous if statement + if let Some(&prev_stmt) = output.last() { + if let StmtData::SIf(prev_if) = prev_stmt.data { + if prev_if.no.is_none() + && p.jump_stmts_look_the_same(prev_if.yes.data, s_if.yes.data) + { + // "if (a) break c; if (b) break c;" => "if (a || b) break c;" + // "if (a) continue c; if (b) continue c;" => "if (a || b) continue c;" + // "if (a) return c; if (b) return c;" => "if (a || b) return c;" + // "if (a) throw c; if (b) throw c;" => "if (a || b) throw c;" + s_if.test = Expr::join_with_left_associative_op( + OpCode::BinLogicalOr, + prev_if.test, + s_if.test, + ); + let new_len = output.len() - 1; + output.truncate(new_len); + } + } + } + + // "while (x) { if (y) continue; z(); }" => "while (x) { if (!y) z(); }" + // "while (x) { if (y) continue; else z(); w(); }" => "while (x) { if (!y) { z(); w(); } }" => "for (; x;) !y && (z(), w());" + // + // "let x = () => { if (y) return; z(); };" => "let x = () => { if (!y) z(); };" + // "let x = () => { if (y) return; else z(); w(); };" => "let x = () => { if (!y) { z(); w(); } };" => "let x = () => { !y && (z(), w()); };" + let optimize_implicit_jump = match (kind, s_if.yes.data) { + (StmtsKind::LoopBody, StmtData::SContinue(c)) => c.label.is_none(), + (StmtsKind::FnBody, StmtData::SReturn(r)) => r.value.is_none(), + _ => false, + }; + + if optimize_implicit_jump && p.stack_check.is_safe_to_recurse() { + let mut body: ListManaged<'a, Stmt> = + ListManaged::with_capacity_in(1 + stmts.len() - i, p.arena); + if let Some(no) = s_if.no { + body.push(no); + } + body.extend_from_slice(&stmts[i + 1..]); + + // Don't do this transformation if the branch condition could + // potentially access symbols declared later on this scope below. + // If so, inverting the branch condition and nesting statements after + // this in a block would break that access which is a behavior change. + // + // // This transformation is incorrect + // if (a()) return; function a() {} + // if (!a()) { function a() {} } + // + // // This transformation is incorrect + // if (a(() => b)) return; let b; + // if (a(() => b)) { let b; } + // + if !stmts_care_about_scope(&body) { + let body = p.mangle_stmts(body, kind); + let body_loc = body.first().map_or(s_if.yes.loc, |s| s.loc); + let test = SideEffects::simplify_boolean(p, s_if.test.not(p.arena)); + let yes = + p.stmts_to_single_stmt(body_loc, body.into_bump_slice_mut()); + let new_if = p.s( + S::If { + test, + yes, + no: None, + }, + stmt.loc, + ); + let StmtData::SIf(new_if_ref) = new_if.data else { + unreachable!() + }; + p.mangle_if(&mut output, stmt.loc, new_if_ref); + return output; + } + } + + if s_if.no.is_some() { + // "if (a) return b; else if (c) return d; else return e;" => "if (a) return b; if (c) return d; return e;" + let mut cur_stmt = stmt; + let mut cur_if = s_if; + loop { + output.push(cur_stmt); + cur_stmt = cur_if.no.take().expect("checked above"); + let StmtData::SIf(next_if) = cur_stmt.data else { + break; + }; + if !is_jump_statement(next_if.yes.data) || next_if.no.is_none() { + break; + } + cur_if = next_if; + } + p.append_if_body_preserving_scope(&mut output, cur_stmt); + if is_jump_statement(cur_stmt.data) { + is_control_flow_dead = true; + } + continue; + } + } + } + + StmtData::SReturn(mut ret) => { + // Merge return statements with the previous expression statement + if output.len() > 0 && ret.value.is_some() && full { + let prev_idx = output.len() - 1; + let prev_stmt = output[prev_idx]; + if let StmtData::SExpr(prev_expr) = prev_stmt.data { + if !prev_stmt.is_super_call() { + ret.value = Some(Expr::join_with_comma( + prev_expr.value, + ret.value.unwrap(), + )); + output[prev_idx] = stmt; + continue; + } + } + } + + is_control_flow_dead = true; + } + + StmtData::SBreak(_) | StmtData::SContinue(_) => { + is_control_flow_dead = true; + } + + StmtData::SThrow(s_throw) => { + // Merge throw statements with the previous expression statement + if output.len() > 0 && full { + let prev_idx = output.len() - 1; + let prev_stmt = output[prev_idx]; + if let StmtData::SExpr(prev_expr) = prev_stmt.data { + if !prev_stmt.is_super_call() { + output[prev_idx] = p.s( + S::Throw { + value: Expr::join_with_comma( + prev_expr.value, + s_throw.value, + ), + }, + stmt.loc, + ); + continue; + } + } + } + + is_control_flow_dead = true; + } + + StmtData::SFor(mut s_for) if full => { + if let Some(&prev_stmt) = output.last() { + let prev_idx = output.len() - 1; + if let StmtData::SExpr(prev_expr) = prev_stmt.data { + if !prev_stmt.is_super_call() { + // Insert the previous expression into the for loop initializer + match s_for.init { + None => { + s_for.init = Some(p.s( + S::SExpr { + value: prev_expr.value, + ..Default::default() + }, + prev_stmt.loc, + )); + output[prev_idx] = stmt; + continue; + } + Some(init) => { + if let StmtData::SExpr(init_expr) = init.data { + s_for.init = Some(p.s( + S::SExpr { + value: Expr::join_with_comma( + prev_expr.value, + init_expr.value, + ), + ..Default::default() + }, + prev_stmt.loc, + )); + output[prev_idx] = stmt; + continue; + } + } + } + } + } else if let StmtData::SLocal(mut prev_local) = prev_stmt.data { + // Insert the previous variable declaration into the for loop + // initializer if it's a "var" declaration, since the scope + // doesn't matter due to scope hoisting + if prev_local.kind == LocalKind::KVar + && !prev_local.is_export + && prev_local.origin == LocalOrigin::Normal + { + match s_for.init { + None => { + s_for.init = Some(prev_stmt); + output[prev_idx] = stmt; + continue; + } + Some(init) => { + if let StmtData::SLocal(init_local) = init.data + && init_local.kind == LocalKind::KVar + && init_local.origin == LocalOrigin::Normal + { + for d in init_local.decls.slice() { + // SAFETY: Decl is field-wise Copy (Binding, Option). + prev_local + .decls + .push(unsafe { core::ptr::read(d) }); + } + s_for.init = Some(prev_stmt); + output[prev_idx] = stmt; + continue; + } + } + } + } + } + } + } + + StmtData::STry(mut s_try) if full => { + // Drop an unused identifier binding: "try { x() } catch (y) {}" => "try { x() } catch {}" + if let Some(catch) = &mut s_try.catch + && let Some(binding) = catch.binding + && let BData::BIdentifier(id) = binding.data + { + let symbol = &p.symbols[id.r#ref.inner_index() as usize]; + // We cannot transform "try { x() } catch (y) { var y = 1 }" into + // "try { x() } catch { var y = 1 }" even though "y" is never used + // because the hoisted variable "y" would have different values + // after the statement ends due to a strange JavaScript quirk: + // + // try { x() } catch (y) { var y = 1 } + // console.log(y) // undefined + // + // try { x() } catch { var y = 1 } + // console.log(y) // 1 + // + // We also cannot transform "try { x() } catch (y) { eval('z = y') }" + // into "try { x() } catch { eval('z = y') }" because the variable + // "y" is actually still used. + // SAFETY: current_scope is a valid arena ptr for the parse. + if symbol.use_count_estimate == 0 + && symbol.link.get() == Ref::NONE + && !p.current_scope().contains_direct_eval + { + catch.binding = None; + } + } + } + + _ => {} + } + + output.push(stmt); + } + + if !full { + return output; + } + + // Drop a trailing unconditional jump statement if applicable + if let Some(&last) = output.last() { + let last_idx = output.len() - 1; + match kind { + StmtsKind::LoopBody => { + // "while (x) { y(); continue; }" => "while (x) { y(); }" + if let StmtData::SContinue(c) = last.data + && c.label.is_none() + { + output.truncate(last_idx); + } + } + StmtsKind::FnBody => { + if let StmtData::SReturn(ret) = last.data { + match ret.value { + // "function f() { x(); return; }" => "function f() { x(); }" + None => output.truncate(last_idx), + // "function f() { return void x(); }" => "function f() { x(); }" + Some(value) => { + if let ExprData::EUnary(unary) = value.data + && unary.op == OpCode::UnVoid + { + output[last_idx] = p.s( + S::SExpr { + value: unary.value, + ..Default::default() + }, + last.loc, + ); + } + } + } + } + } + _ => {} + } + } + + // Merge certain statements in reverse order + if output.len() >= 2 { + let last_stmt = output[output.len() - 1]; + if let StmtData::SReturn(last_return) = last_stmt.data { + // "if (a) return b; if (c) return d; return e;" => "return a ? b : c ? d : e;" + let mut last_loc = last_stmt.loc; + let mut last_value = last_return.value; + while output.len() >= 2 { + let prev_index = output.len() - 2; + let prev_stmt = output[prev_index]; + match prev_stmt.data { + StmtData::SExpr(prev_expr) => { + // This return statement must have a value + let Some(value) = last_value else { break }; + if prev_stmt.is_super_call() { + break; + } + // "a(); return b;" => "return a(), b;" + last_value = Some(Expr::join_with_comma(prev_expr.value, value)); + } + StmtData::SIf(mut prev_if) => { + // The previous statement must be an if statement with no else clause + if prev_if.no.is_some() { + break; + } + // The then clause must be a return + let StmtData::SReturn(prev_return) = prev_if.yes.data else { + break; + }; + + // Handle some or all of the values being undefined + // "if (a) return; return b;" => "return a ? void 0 : b;" + let mut left = prev_return + .value + .unwrap_or_else(|| p.new_expr(E::Undefined {}, prev_if.yes.loc)); + // "if (a) return a; return;" => "return a ? b : void 0;" + let mut right = + last_value.unwrap_or_else(|| p.new_expr(E::Undefined {}, last_loc)); + + // "if (!a) return b; return c;" => "return a ? c : b;" + if let ExprData::EUnary(not) = prev_if.test.data + && not.op == OpCode::UnNot + { + prev_if.test = not.value; + core::mem::swap(&mut left, &mut right); + } + + last_value = Some(p.mangle_test_to_if_expr(prev_if.test, left, right)); + } + _ => break, + } + + // Merge the last two statements + last_loc = prev_stmt.loc; + output[prev_index] = p.s(S::Return { value: last_value }, last_loc); + let new_len = output.len() - 1; + output.truncate(new_len); + } + } else if let StmtData::SThrow(last_throw) = last_stmt.data { + // "if (a) throw b; if (c) throw d; throw e;" => "throw a ? b : c ? d : e;" + let mut last_value = last_throw.value; + while output.len() >= 2 { + let prev_index = output.len() - 2; + let prev_stmt = output[prev_index]; + match prev_stmt.data { + StmtData::SExpr(prev_expr) => { + if prev_stmt.is_super_call() { + break; + } + // "a(); throw b;" => "throw a(), b;" + last_value = Expr::join_with_comma(prev_expr.value, last_value); + } + StmtData::SIf(mut prev_if) => { + // The previous statement must be an if statement with no else clause + if prev_if.no.is_some() { + break; + } + // The then clause must be a throw + let StmtData::SThrow(prev_throw) = prev_if.yes.data else { + break; + }; + + let mut left = prev_throw.value; + let mut right = last_value; + + // "if (!a) throw b; throw c;" => "throw a ? c : b;" + if let ExprData::EUnary(not) = prev_if.test.data + && not.op == OpCode::UnNot + { + prev_if.test = not.value; + core::mem::swap(&mut left, &mut right); + } + + last_value = p.mangle_test_to_if_expr(prev_if.test, left, right); + } + _ => break, + } + + // Merge the last two statements + output[prev_index] = p.s(S::Throw { value: last_value }, prev_stmt.loc); + let new_len = output.len() - 1; + output.truncate(new_len); + } + } + } + + output + } + + /// `test ? yes : no` for the return and throw chains above. + /// "if (a, b) return c; return d;" => "return a, b ? c : d;" + fn mangle_test_to_if_expr(&mut self, test: Expr, yes: Expr, no: Expr) -> Expr { + if let ExprData::EBinary(comma) = test.data + && comma.op == OpCode::BinComma + { + let e_if = self.new_expr( + E::If { + test: comma.right, + yes, + no, + }, + comma.right.loc, + ); + let ExprData::EIf(e_if_ref) = e_if.data else { + unreachable!() + }; + let mangled = self.mangle_if_expr(comma.right.loc, e_if_ref); + return Expr::join_with_comma(comma.left, mangled); + } + let e_if = self.new_expr(E::If { test, yes, no }, test.loc); + let ExprData::EIf(e_if_ref) = e_if.data else { + unreachable!() + }; + self.mangle_if_expr(test.loc, e_if_ref) + } + + /// Append the body of an `if` branch or a label without changing what the + /// block scopes. Port of esbuild's `appendIfOrLabelBodyPreservingScope`. + pub(crate) fn append_if_body_preserving_scope( + &mut self, + stmts: &mut ListManaged<'a, Stmt>, + body: Stmt, + ) { + if let StmtData::SBlock(block) = body.data { + let block_stmts: &[Stmt] = block.stmts.slice(); + if !stmts_care_about_scope(block_stmts) { + stmts.extend_from_slice(block_stmts); + return; + } + } + + if statement_cares_about_scope(&body) { + let block_stmts = self.arena.alloc_slice_copy(&[body]); + stmts.push(self.s( + S::Block { + stmts: block_stmts.into(), + close_brace_loc: Loc::EMPTY, + }, + body.loc, + )); + return; + } + + stmts.push(body); + } + + /// Simplify a visited `if` statement and append the result. Port of + /// esbuild's `mangleIf`. The caller checks `minify_syntax`. + pub(crate) fn mangle_if( + &mut self, + stmts: &mut ListManaged<'a, Stmt>, + loc: Loc, + mut s: StoreRef, + ) { + let p = self; + let full = p.full_minify_syntax(); + + // Constant folding using the test expression + if let Some(known) = SideEffects::to_boolean(p, &s.test.data) { + if known.value { + // The test is truthy + if s.no.is_none_or(|no| { + !SideEffects::should_keep_stmt_in_dead_control_flow(no, p.arena) + }) { + // We can drop the "no" branch + if known.side_effects == SideEffects::CouldHaveSideEffects { + // Keep the condition if it could have side effects (but is still known to be truthy) + if let Some(test) = SideEffects::simplify_unused_expr(p, s.test) { + stmts.push(p.s( + S::SExpr { + value: test, + ..Default::default() + }, + test.loc, + )); + } + } + p.append_if_body_preserving_scope(stmts, s.yes); + return; + } + // We have to keep the "no" branch + } else { + // The test is falsy + if !SideEffects::should_keep_stmt_in_dead_control_flow(s.yes, p.arena) { + // We can drop the "yes" branch + if known.side_effects == SideEffects::CouldHaveSideEffects { + // Keep the condition if it could have side effects (but is still known to be falsy) + if let Some(test) = SideEffects::simplify_unused_expr(p, s.test) { + stmts.push(p.s( + S::SExpr { + value: test, + ..Default::default() + }, + test.loc, + )); + } + } + if let Some(no) = s.no { + p.append_if_body_preserving_scope(stmts, no); + } + return; + } + // We have to keep the "yes" branch + } + + // Use "1" and "0" instead of "true" and "false" to be shorter + if full && known.side_effects == SideEffects::NoSideEffects { + s.test = p.new_expr( + E::Number::new(if known.value { 1.0 } else { 0.0 }), + s.test.loc, + ); + } + } + + // `s_expr` drops an expression statement without side effects, so an + // emptied branch is an `SEmpty`. An `SExpr` with a missing value is the + // same thing to the passes below. + let yes_is_empty = matches!(s.yes.data, StmtData::SEmpty(_)) || s.yes.is_missing_expr(); + + if !full { + // "if (a) {}" => "" and "if (a) {} else {}" => "" when the test has no side effects + if yes_is_empty + && s.no.is_none_or(|no| no.is_missing_expr()) + && p.expr_can_be_removed_if_unused(&s.test) + { + return; + } + stmts.push(Stmt { + loc, + data: StmtData::SIf(s), + }); + return; + } + + let mut expr: Option = None; + + if let StmtData::SExpr(yes) = s.yes.data + && !yes_is_empty + { + // "yes" is an expression + match s.no { + None => { + if let ExprData::EUnary(not) = s.test.data + && not.op == OpCode::UnNot + { + // "if (!a) b();" => "a || b();" + expr = Some(Expr::join_with_left_associative_op( + OpCode::BinLogicalOr, + not.value, + yes.value, + )); + } else { + // "if (a) b();" => "a && b();" + expr = Some(Expr::join_with_left_associative_op( + OpCode::BinLogicalAnd, + s.test, + yes.value, + )); + } + } + Some(no) => { + if let StmtData::SExpr(no_expr) = no.data { + // "if (a) b(); else c();" => "a ? b() : c();" + let e_if = p.new_expr( + E::If { + test: s.test, + yes: yes.value, + no: no_expr.value, + }, + loc, + ); + let ExprData::EIf(e_if_ref) = e_if.data else { + unreachable!() + }; + expr = Some(p.mangle_if_expr(loc, e_if_ref)); + } + } + } + } else if yes_is_empty { + // "yes" is missing + match s.no { + None => { + // "yes" and "no" are both missing + if p.expr_can_be_removed_if_unused(&s.test) { + // "if (1) {}" => "" + return; + } + // "if (a) {}" => "a;" + expr = Some(s.test); + } + Some(no) => { + if let StmtData::SExpr(no_expr) = no.data { + if let ExprData::EUnary(not) = s.test.data + && not.op == OpCode::UnNot + { + // "if (!a) {} else b();" => "a && b();" + expr = Some(Expr::join_with_left_associative_op( + OpCode::BinLogicalAnd, + not.value, + no_expr.value, + )); + } else { + // "if (a) {} else b();" => "a || b();" + expr = Some(Expr::join_with_left_associative_op( + OpCode::BinLogicalOr, + s.test, + no_expr.value, + )); + } + } else { + // "yes" is missing and "no" is not missing (and is not an expression) + if let ExprData::EUnary(not) = s.test.data + && not.op == OpCode::UnNot + { + // "if (!a) {} else throw b;" => "if (a) throw b;" + s.test = not.value; + } else { + // "if (a) {} else throw b;" => "if (!a) throw b;" + s.test = s.test.not(p.arena); + } + s.yes = no; + s.no = None; + } + } + } + } else { + // "yes" is not missing (and is not an expression) + match s.no { + Some(no) => { + // "yes" is not missing (and is not an expression) and "no" is not missing + if let ExprData::EUnary(not) = s.test.data + && not.op == OpCode::UnNot + { + // "if (!a) return b; else return c;" => "if (a) return c; else return b;" + s.test = not.value; + s.no = Some(s.yes); + s.yes = no; + } + } + None => { + // "no" is missing + if let StmtData::SIf(s2) = s.yes.data + && s2.no.is_none() + { + // "if (a) if (b) return c;" => "if (a && b) return c;" + s.test = Expr::join_with_left_associative_op( + OpCode::BinLogicalAnd, + s.test, + s2.test, + ); + s.yes = s2.yes; + } + } + } + } + + // Return an expression if we replaced the if statement with an expression above + if let Some(expr) = expr { + if let Some(expr) = SideEffects::simplify_unused_expr(p, expr) { + stmts.push(p.s( + S::SExpr { + value: expr, + ..Default::default() + }, + loc, + )); + } + return; + } + + stmts.push(Stmt { + loc, + data: StmtData::SIf(s), + }); + } + + /// Rewrite a conditional expression into a shorter equivalent. Port of + /// esbuild's `MangleIfExpr`. `e` is updated in place when the result is + /// still a conditional. + pub(crate) fn mangle_if_expr(&mut self, loc: Loc, mut e: StoreRef) -> Expr { + if !self.stack_check.is_safe_to_recurse() { + return Expr { + loc, + data: ExprData::EIf(e), + }; + } + + let mut test = e.test; + let mut yes = e.yes; + let mut no = e.no; + + // "(a, b) ? c : d" => "a, b ? c : d" + if let ExprData::EBinary(comma) = test.data + && comma.op == OpCode::BinComma + { + e.test = comma.right; + let inner = self.mangle_if_expr(comma.right.loc, e); + return Expr::join_with_comma(comma.left, inner); + } + + // "!a ? b : c" => "a ? c : b" + if let ExprData::EUnary(not) = test.data + && not.op == OpCode::UnNot + { + test = not.value; + core::mem::swap(&mut yes, &mut no); + } + + if self.values_look_the_same(&yes.data, &no.data) { + // "/* @__PURE__ */ a() ? b : b" => "b" + if self.expr_can_be_removed_if_unused(&test) { + return yes; + } + + // "a ? b : b" => "a, b" + return Expr::join_with_comma(test, yes); + } + + // "a ? true : false" => "!!a" + // "a ? false : true" => "!a" + if let ( + ExprData::EBoolean(y) | ExprData::EBranchBoolean(y), + ExprData::EBoolean(n) | ExprData::EBranchBoolean(n), + ) = (yes.data, no.data) + { + if y.value && !n.value { + return test.not(self.arena).not(self.arena); + } + if !y.value && n.value { + return test.not(self.arena); + } + } + + if let ExprData::EIdentifier(id) = test.data { + // "a ? a : b" => "a || b" + if let ExprData::EIdentifier(id2) = yes.data + && id.ref_ == id2.ref_ + { + return Expr::join_with_left_associative_op(OpCode::BinLogicalOr, test, no); + } + + // "a ? b : a" => "a && b" + if let ExprData::EIdentifier(id2) = no.data + && id.ref_ == id2.ref_ + { + return Expr::join_with_left_associative_op(OpCode::BinLogicalAnd, test, yes); + } + } + + // "a ? b ? c : d : d" => "a && b ? c : d" + if let ExprData::EIf(yes_if) = yes.data + && self.values_look_the_same(&yes_if.no.data, &no.data) + { + e.test = Expr::join_with_left_associative_op(OpCode::BinLogicalAnd, test, yes_if.test); + e.yes = yes_if.yes; + e.no = no; + return Expr { + loc, + data: ExprData::EIf(e), + }; + } + + // "a ? b : c ? b : d" => "a || c ? b : d" + if let ExprData::EIf(no_if) = no.data + && self.values_look_the_same(&yes.data, &no_if.yes.data) + { + e.test = Expr::join_with_left_associative_op(OpCode::BinLogicalOr, test, no_if.test); + e.yes = yes; + e.no = no_if.no; + return Expr { + loc, + data: ExprData::EIf(e), + }; + } + + // "a ? c : (b, c)" => "(a || b), c" + if let ExprData::EBinary(comma) = no.data + && comma.op == OpCode::BinComma + && self.values_look_the_same(&yes.data, &comma.right.data) + { + return Expr::join_with_comma( + Expr::join_with_left_associative_op(OpCode::BinLogicalOr, test, comma.left), + comma.right, + ); + } + + // "a ? (b, c) : c" => "(a && b), c" + if let ExprData::EBinary(comma) = yes.data + && comma.op == OpCode::BinComma + && self.values_look_the_same(&comma.right.data, &no.data) + { + return Expr::join_with_comma( + Expr::join_with_left_associative_op(OpCode::BinLogicalAnd, test, comma.left), + comma.right, + ); + } + + // "a ? b || c : c" => "(a && b) || c" + if let ExprData::EBinary(binary) = yes.data + && binary.op == OpCode::BinLogicalOr + && self.values_look_the_same(&binary.right.data, &no.data) + { + return Expr::init( + E::Binary { + op: OpCode::BinLogicalOr, + left: Expr::join_with_left_associative_op( + OpCode::BinLogicalAnd, + test, + binary.left, + ), + right: binary.right, + }, + loc, + ); + } + + // "a ? c : b && c" => "(a || b) && c" + if let ExprData::EBinary(binary) = no.data + && binary.op == OpCode::BinLogicalAnd + && self.values_look_the_same(&yes.data, &binary.right.data) + { + return Expr::init( + E::Binary { + op: OpCode::BinLogicalAnd, + left: Expr::join_with_left_associative_op( + OpCode::BinLogicalOr, + test, + binary.left, + ), + right: binary.right, + }, + loc, + ); + } + + // "a ? b(c, d) : b(e, d)" => "b(a ? c : e, d)" + if let ExprData::ECall(mut y) = yes.data + && !y.args.is_empty() + && let ExprData::ECall(n) = no.data + && n.args.len() == y.args.len() + && y.optional_chain == n.optional_chain + && y.is_direct_eval == n.is_direct_eval + && y.can_be_unwrapped_if_unused == n.can_be_unwrapped_if_unused + && self.values_look_the_same(&y.target.data, &n.target.data) + // Only do this if the condition can be reordered past the call target + // without side effects. For example, if the test or the call target is + // an unbound identifier, reordering could potentially mean evaluating + // the code could throw a different ReferenceError. + && self.expr_can_be_removed_if_unused(&test) + && self.expr_can_be_removed_if_unused(&y.target) + { + let same_tail_args = (1..y.args.len()) + .all(|i| self.values_look_the_same(&y.args[i].data, &n.args[i].data)); + if same_tail_args { + let y0 = y.args[0]; + let n0 = n.args[0]; + match (y0.data, n0.data) { + // "a ? b(...c) : b(...e)" => "b(...a ? c : e)" + (ExprData::ESpread(yes_spread), ExprData::ESpread(no_spread)) => { + e.test = test; + e.yes = yes_spread.value; + e.no = no_spread.value; + let inner = self.mangle_if_expr(loc, e); + y.args[0] = self.new_expr(E::Spread { value: inner }, loc); + return Expr { + loc, + data: ExprData::ECall(y), + }; + } + (ExprData::ESpread(_), _) | (_, ExprData::ESpread(_)) => {} + // "a ? b(c) : b(e)" => "b(a ? c : e)" + _ => { + e.test = test; + e.yes = y0; + e.no = n0; + y.args[0] = self.mangle_if_expr(loc, e); + return Expr { + loc, + data: ExprData::ECall(y), + }; + } + } + } + } + + // Try using the "??" or "?." operators + if let ExprData::EBinary(binary) = test.data { + let (check, when_null, when_non_null) = match binary.op { + OpCode::BinLooseEq => { + if matches!(binary.right.data, ExprData::ENull(_)) { + // "a == null ? _ : _" + (Some(binary.left), yes, no) + } else if matches!(binary.left.data, ExprData::ENull(_)) { + // "null == a ? _ : _" + (Some(binary.right), yes, no) + } else { + (None, yes, no) + } + } + OpCode::BinLooseNe => { + if matches!(binary.right.data, ExprData::ENull(_)) { + // "a != null ? _ : _" + (Some(binary.left), no, yes) + } else if matches!(binary.left.data, ExprData::ENull(_)) { + // "null != a ? _ : _" + (Some(binary.right), no, yes) + } else { + (None, yes, no) + } + } + _ => (None, yes, no), + }; + + if let Some(check) = check + && self.expr_can_be_removed_if_unused(&check) + { + // "a != null ? a : b" => "a ?? b" + if self.values_look_the_same(&check.data, &when_non_null.data) { + return Expr::join_with_left_associative_op( + OpCode::BinNullishCoalescing, + check, + when_null, + ); + } + + // "a != null ? a.b.c[d](e) : undefined" => "a?.b.c[d](e)" + if matches!(when_null.data, ExprData::EUndefined(_)) + && self.try_to_insert_optional_chain(check, when_non_null) + { + return when_non_null; + } + } + } + + e.test = test; + e.yes = yes; + e.no = no; + Expr { + loc, + data: ExprData::EIf(e), + } + } + + /// Make `expr` an optional chain rooted at `test`: "a.b.c" => "a?.b.c" + /// when `test` is "a". Port of esbuild's `TryToInsertOptionalChain`. + pub(crate) fn try_to_insert_optional_chain(&mut self, test: Expr, expr: Expr) -> bool { + if !self.stack_check.is_safe_to_recurse() { + return false; + } + match expr.data { + ExprData::EDot(mut e) => { + if self.values_look_the_same(&test.data, &e.target.data) { + e.optional_chain = Some(OptionalChain::Start); + return true; + } + if self.try_to_insert_optional_chain(test, e.target) { + if e.optional_chain.is_none() { + e.optional_chain = Some(OptionalChain::Continuation); + } + return true; + } + } + ExprData::EIndex(mut e) => { + if self.values_look_the_same(&test.data, &e.target.data) { + e.optional_chain = Some(OptionalChain::Start); + return true; + } + if self.try_to_insert_optional_chain(test, e.target) { + if e.optional_chain.is_none() { + e.optional_chain = Some(OptionalChain::Continuation); + } + return true; + } + } + ExprData::ECall(mut e) => { + if self.values_look_the_same(&test.data, &e.target.data) { + e.optional_chain = Some(OptionalChain::Start); + return true; + } + if self.try_to_insert_optional_chain(test, e.target) { + if e.optional_chain.is_none() { + e.optional_chain = Some(OptionalChain::Continuation); + } + return true; + } + } + _ => {} + } + false + } + + /// True when two expressions are written the same way and evaluating + /// either one gives the same value. Port of esbuild's `ValuesLookTheSame`. + pub(crate) fn values_look_the_same(&mut self, left: &ExprData, right: &ExprData) -> bool { + if !self.stack_check.is_safe_to_recurse() { + return false; + } + + if let ExprData::EInlinedEnum(b) = right { + return self.values_look_the_same(left, &b.value.data); + } + + match left { + ExprData::EInlinedEnum(a) => return self.values_look_the_same(&a.value.data, right), + + ExprData::EIdentifier(a) => { + if let ExprData::EIdentifier(b) = right + && a.ref_ == b.ref_ + { + return true; + } + } + + ExprData::EDot(a) => { + if let ExprData::EDot(b) = right + && a.optional_chain == b.optional_chain + && a.can_be_removed_if_unused == b.can_be_removed_if_unused + && a.call_can_be_unwrapped_if_unused == b.call_can_be_unwrapped_if_unused + && a.name.slice() == b.name.slice() + && self.values_look_the_same(&a.target.data, &b.target.data) + { + return true; + } + } + + ExprData::EIndex(a) => { + if let ExprData::EIndex(b) = right + && a.optional_chain == b.optional_chain + && self.values_look_the_same(&a.target.data, &b.target.data) + && self.values_look_the_same(&a.index.data, &b.index.data) + { + return true; + } + } + + ExprData::EIf(a) => { + if let ExprData::EIf(b) = right + && self.values_look_the_same(&a.test.data, &b.test.data) + && self.values_look_the_same(&a.yes.data, &b.yes.data) + && self.values_look_the_same(&a.no.data, &b.no.data) + { + return true; + } + } + + ExprData::EUnary(a) => { + if let ExprData::EUnary(b) = right + && a.op == b.op + && self.values_look_the_same(&a.value.data, &b.value.data) + { + return true; + } + } + + ExprData::EBinary(a) => { + if let ExprData::EBinary(b) = right + && a.op == b.op + && self.values_look_the_same(&a.left.data, &b.left.data) + && self.values_look_the_same(&a.right.data, &b.right.data) + { + return true; + } + } + + ExprData::ECall(a) => { + if let ExprData::ECall(b) = right + && a.optional_chain == b.optional_chain + && a.is_direct_eval == b.is_direct_eval + && a.can_be_unwrapped_if_unused == b.can_be_unwrapped_if_unused + && a.args.len() == b.args.len() + && self.values_look_the_same(&a.target.data, &b.target.data) + { + return (0..a.args.len()) + .all(|i| self.values_look_the_same(&a.args[i].data, &b.args[i].data)); + } + } + + // Special-case to distinguish between negative an non-negative zero when mangling + // "a ? -0 : 0" => "a ? -0 : 0" + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Equality_comparisons_and_sameness + ExprData::ENumber(a) => { + if let ExprData::ENumber(b) = right + && a.value() == 0.0 + && b.value() == 0.0 + && a.value().is_sign_negative() != b.value().is_sign_negative() + { + return false; + } + } + + _ => {} + } + + matches!( + ExprData::eql::<_, StrictEql>(left, right, self), + Equality::Equal + ) + } + + /// True when two jump statements do the same thing. Port of esbuild's + /// `jumpStmtsLookTheSame`. + fn jump_stmts_look_the_same(&mut self, left: StmtData, right: StmtData) -> bool { + match (left, right) { + (StmtData::SBreak(a), StmtData::SBreak(b)) => match (a.label, b.label) { + (None, None) => true, + (Some(a), Some(b)) => a.ref_ == b.ref_, + _ => false, + }, + (StmtData::SContinue(a), StmtData::SContinue(b)) => match (a.label, b.label) { + (None, None) => true, + (Some(a), Some(b)) => a.ref_ == b.ref_, + _ => false, + }, + (StmtData::SReturn(a), StmtData::SReturn(b)) => match (a.value, b.value) { + (None, None) => true, + (Some(a), Some(b)) => self.values_look_the_same(&a.data, &b.data), + _ => false, + }, + (StmtData::SThrow(a), StmtData::SThrow(b)) => { + self.values_look_the_same(&a.value.data, &b.value.data) + } + _ => false, + } + } +} + +pub(crate) fn is_jump_statement(data: StmtData) -> bool { + matches!( + data, + StmtData::SBreak(_) | StmtData::SContinue(_) | StmtData::SReturn(_) | StmtData::SThrow(_) + ) +} + +pub(crate) fn stmts_care_about_scope(stmts: &[Stmt]) -> bool { + stmts.iter().any(statement_cares_about_scope) +} + +/// Fold a leading `if (x) break;` of a loop body into the loop test. Port of +/// esbuild's `mangleFor`. `while` loops are converted to `for` first so that +/// both shapes get this. +pub(crate) fn mangle_for(s: &mut S::For, bump: &Bump) { + // Get the first statement in the loop + let mut first = s.body; + if let StmtData::SBlock(block) = first.data + && !block.stmts.is_empty() + { + first = block.stmts[0]; + } + + let StmtData::SIf(if_s) = first.data else { + return; + }; + + // "for (;;) if (x) break;" => "for (; !x;) ;" + // "for (; a;) if (x) break;" => "for (; a && !x;) ;" + // "for (;;) if (x) break; else y();" => "for (; !x;) y();" + // "for (; a;) if (x) break; else y();" => "for (; a && !x;) y();" + if let StmtData::SBreak(break_s) = if_s.yes.data + && break_s.label.is_none() + { + let not = match if_s.test.data { + ExprData::EUnary(unary) if unary.op == OpCode::UnNot => unary.value, + _ => if_s.test.not(bump), + }; + s.test = Some(match s.test { + Some(test) => Expr::init( + E::Binary { + op: OpCode::BinLogicalAnd, + left: test, + right: not, + }, + test.loc, + ), + None => not, + }); + s.body = drop_first_statement(s.body, if_s.no); + return; + } + + // "for (;;) if (x) y(); else break;" => "for (; x;) y();" + // "for (; a;) if (x) y(); else break;" => "for (; a && x;) y();" + if let Some(no) = if_s.no + && let StmtData::SBreak(break_s) = no.data + && break_s.label.is_none() + { + s.test = Some(match s.test { + Some(test) => Expr::init( + E::Binary { + op: OpCode::BinLogicalAnd, + left: test, + right: if_s.test, + }, + test.loc, + ), + None => if_s.test, + }); + s.body = drop_first_statement(s.body, Some(if_s.yes)); + } +} + +fn drop_first_statement(body: Stmt, replace: Option) -> Stmt { + if let StmtData::SBlock(mut block) = body.data + && !block.stmts.is_empty() + { + if let Some(replace) = replace { + block.stmts.slice_mut()[0] = replace; + } else if block.stmts.len() == 2 && !statement_cares_about_scope(&block.stmts[1]) { + return block.stmts[1]; + } else { + block.stmts = StoreSlice::new(&block.stmts.slice()[1..]); + } + return body; + } + if let Some(replace) = replace { + return replace; + } + body.to_empty() +} diff --git a/src/js_parser/visit/mod.rs b/src/js_parser/visit/mod.rs index f5bdf9467cc4..7e768ea1eec0 100644 --- a/src/js_parser/visit/mod.rs +++ b/src/js_parser/visit/mod.rs @@ -2,6 +2,7 @@ //! AST visitor pass: visits statements, expressions, bindings, function bodies, //! classes, and declarations. This is the second pass after parsing. +pub(crate) mod mangle; pub mod visit_binary; pub(crate) mod visit_expr; pub(crate) mod visit_stmt; @@ -15,8 +16,7 @@ use crate::parser::{ }; use bun_alloc::{ArenaVec as BumpVec, ArenaVecExt as _}; use bun_ast as js_ast; -use bun_ast::G::{Decl, PropertyKind}; -use bun_ast::OpCode; +use bun_ast::G::PropertyKind; use bun_ast::b::B as BData; use bun_ast::flags; use bun_ast::s::Kind as LocalKind; @@ -24,7 +24,7 @@ use bun_ast::scope::{Kind as ScopeKind, Member as ScopeMember}; use bun_ast::symbol::Kind as SymbolKind; use bun_ast::{ AssignTarget, B, Binding, BindingNodeIndex, E, Expr, ExprData, ExprNodeList, G, LocRef, S, - Stmt, StmtData, Symbol, + Stmt, StmtData, }; use bun_collections::VecExt; // `parser::SideEffects` is a stub enum without the assoc fns; the real @@ -1449,6 +1449,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } else { None }; + let old_in_react_compiler_candidate = p.in_react_compiler_candidate; + if rc_pending.is_some() { + p.in_react_compiler_candidate = true; + } #[cfg(debug_assertions)] let initial_scope: js_ast::StoreRef = p.current_scope; @@ -1742,379 +1746,14 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O p.react_compiler_result = Some(result); } } + p.in_react_compiler_candidate = old_in_react_compiler_candidate; if !p.options.features.minify_syntax || !p.options.features.dead_code_elimination { return Ok(()); } - // SAFETY: current_scope is a valid arena ptr for the parse. - if p.current_scope().parent.is_some() && !p.current_scope().contains_direct_eval { - // Remove inlined constants now that we know whether any of these statements - // contained a direct eval() or not. This can't be done earlier when we - // encounter the constant because we haven't encountered the eval() yet. - // Inlined constants are not removed if they are in a top-level scope or - // if they are exported (which could be in a nested TypeScript namespace). - if p.const_values.count() > 0 { - let items: &mut [Stmt] = stmts.as_mut_slice(); - for stmt in items.iter_mut() { - match stmt.data { - StmtData::SEmpty(_) - | StmtData::SComment(_) - | StmtData::SDirective(_) - | StmtData::SDebugger(_) - | StmtData::STypeScript(_) => continue, - StmtData::SLocal(mut local) => { - // "using" / "await using" declarations have disposal - // side-effects on scope exit. Their refs can end up in - // `const_values` via the macro path in `visitDecl` - // (`could_be_macro`), so skip them here to avoid - // silently dropping the declaration. - if local.kind.is_using() { - continue; - } - if !local.is_export && !local.origin.is_commonjs_export() { - let mut any_decl_in_const_values = local.kind == LocalKind::KConst; - let decls: &mut [Decl] = local.decls.slice_mut(); - let mut end: usize = 0; - for idx in 0..decls.len() { - if let BData::BIdentifier(id_ptr) = decls[idx].binding.data { - let id_ref = id_ptr.r#ref; - if p.const_values.contains(&id_ref) { - any_decl_in_const_values = true; - let symbol = &p.symbols[id_ref.inner_index() as usize]; - if symbol.use_count_estimate == 0 { - // Skip declarations that are constants with zero usage - continue; - } - } - } - // `Decl` is field-wise `Copy` but lacks the - // derive; `swap` compacts in place (idx >= end always). - decls.swap(end, idx); - end += 1; - } - local.decls.truncate(end); - if any_decl_in_const_values { - if end == 0 { - *stmt = stmt.to_empty(); - } - continue; - } - } - } - _ => {} - } - - // Break after processing relevant statements - break; - } - } - } - - let mut is_control_flow_dead = false; - - let mut output: ListManaged<'a, Stmt> = ListManaged::with_capacity_in(stmts.len(), p.arena); - - let dead_code_elimination = p.options.features.dead_code_elimination; - for stmt in stmts.iter().copied() { - if is_control_flow_dead - && dead_code_elimination - && !SideEffects::should_keep_stmt_in_dead_control_flow(stmt, p.arena) - { - // Strip unnecessary statements if the control flow is dead here - continue; - } - - // Inline single-use variable declarations where possible: - // - // // Before - // let x = fn(); - // return x.y(); - // - // // After - // return fn().y(); - // - // The declaration must not be exported. We can't just check for the - // "export" keyword because something might do "export {id};" later on. - // Instead we just ignore all top-level declarations for now. That means - // this optimization currently only applies in nested scopes. - // - // Ignore declarations if the scope is shadowed by a direct "eval" call. - // The eval'd code may indirectly reference this symbol and the actual - // use count may be greater than 1. - // SAFETY: current_scope is a valid arena ptr for the parse. - if p.current_scope != p.module_scope && !p.current_scope().contains_direct_eval { - // Keep inlining variables until a failure or until there are none left. - // That handles cases like this: - // - // // Before - // let x = fn(); - // let y = x.prop; - // return y; - // - // // After - // return fn().prop; - // - 'inner: while output.len() > 0 { - // Ignore "var" declarations since those have function-level scope and - // we may not have visited all of their uses yet by this point. We - // should have visited all the uses of "let" and "const" declarations - // by now since they are scoped to this block which we just finished - // visiting. - let prev_idx = output.len() - 1; - // borrowck: read the `StoreRef` (Copy) first, then re-borrow - // `output` only when truncating. - let StmtData::SLocal(mut local) = output[prev_idx].data else { - break; - }; - // "using" / "await using" declarations have disposal - // side-effects on scope exit, so they must not be - // removed by inlining their initializer into the use. - if local.decls.len_u32() == 0 - || local.kind == LocalKind::KVar - || local.kind.is_using() - || local.is_export - { - break; - } - - // The variable must be initialized, since we will be substituting - // the value into the usage. - let last_idx = (local.decls.len_u32() - 1) as usize; - let last: &mut Decl = &mut local.decls.slice_mut()[last_idx]; - let Some(replacement) = last.value else { break }; - - // The binding must be an identifier that is only used once. - // Ignore destructuring bindings since that's not the simple case. - // Destructuring bindings could potentially execute side-effecting - // code which would invalidate reordering. - let BData::BIdentifier(ident_ptr) = last.binding.data else { - break; - }; - let id = ident_ptr.r#ref; - - let symbol: &Symbol = &p.symbols[id.inner_index() as usize]; - - // Try to substitute the identifier with the initializer. This will - // fail if something with side effects is in between the declaration - // and the usage. - if symbol.use_count_estimate == 1 - && p.substitute_single_use_symbol_in_stmt(stmt, id, replacement) - { - // `const ns = await import(x); return ns` — the single use just - // moved into `replacement`; unless it was an accounted-for read - // (`f(ns.a)`), the namespace escapes there. - if p.dynamic_import_namespace_locals.contains_key(&id) - && p.namespace_tracked_uses.get(&id).copied().unwrap_or(0) == 0 - { - // Read as "more uses than accounted for" when finalizing. - p.namespace_tracked_uses.insert(id, u32::MAX); - } - match local.decls.len_u32() { - 1 => { - local.decls.clear(); - let new_len = output.len() - 1; - output.truncate(new_len); - continue 'inner; - } - _ => { - let n = local.decls.len() - 1; - local.decls.truncate(n); - continue 'inner; - } - } - } - break; - } - } - - // don't merge super calls to ensure they are called before "this" is accessed - if stmt.is_super_call() { - output.push(stmt); - continue; - } - - // The following calls to `joinWithComma` are only enabled during bundling. We do this - // to avoid changing line numbers too much for source maps - - match stmt.data { - StmtData::SEmpty(_) => continue, - - // skip directives for now - StmtData::SDirective(_) => continue, - - StmtData::SLocal(local) => { - // Merge adjacent local statements - if output.len() > 0 { - let prev_idx = output.len() - 1; - let prev_stmt = &mut output[prev_idx]; - if let StmtData::SLocal(mut prev_local) = prev_stmt.data { - if local.can_merge_with(&prev_local) { - // `Vec::append_slice` requires `T: Clone` - // but `G::Decl` lacks the derive (its fields are all - // `Copy`). Per-element bitwise copy instead. - // - // The parse pass allocates `decls` in the bump arena - // (`from_bump_slice` → `Origin::Borrowed`); promote to a - // global-heap buffer before growing it. - for d in local.decls.slice() { - // SAFETY: Decl is field-wise Copy (Binding, Option). - prev_local.decls.push(unsafe { core::ptr::read(d) }); - } - continue; - } - } - } - } - - StmtData::SExpr(s_expr) => { - // Merge adjacent expression statements - if output.len() > 0 { - let prev_idx = output.len() - 1; - let prev_stmt = &mut output[prev_idx]; - if let StmtData::SExpr(mut prev_expr) = prev_stmt.data { - if !prev_stmt.is_super_call() - && p.options.runtime_merge_adjacent_expression_statements() - { - prev_expr.does_not_affect_tree_shaking = prev_expr - .does_not_affect_tree_shaking - && s_expr.does_not_affect_tree_shaking; - prev_expr.value = - Expr::join_with_comma(prev_expr.value, s_expr.value); - continue; - } - } else if let StmtData::SLocal(prev_local) = prev_stmt.data { - // - // Input: - // var f; - // f = 123; - // Output: - // var f = 123; - // - // This doesn't handle every case. Only the very simple one. - if let ExprData::EBinary(bin_assign) = s_expr.value.data { - if prev_local.decls.len_u32() == 1 - && bin_assign.op == OpCode::BinAssign - // we can only do this with var because var is hoisted - // the statement we are merging into may use the statement before its defined. - && prev_local.kind == LocalKind::KVar - { - if let ExprData::EIdentifier(left_id) = bin_assign.left.data { - // `prev_local` is a `StoreRef` (Copy) so - // re-slicing here writes through to the arena slot. - let mut prev_local = prev_local; - let decl = &mut prev_local.decls.slice_mut()[0]; - if let BData::BIdentifier(bid_ptr) = decl.binding.data { - let bid_ref = bid_ptr.r#ref; - if bid_ref.eql(left_id.ref_) - // If the value was assigned, we shouldn't merge it incase it was used in the current statement - // https://github.com/oven-sh/bun/issues/2948 - // We don't have a more granular way to check symbol usage so this is the best we can do - && decl.value.is_none() - { - decl.value = Some(bin_assign.right); - p.ignore_usage(left_id.ref_); - continue; - } - } - } - } - } - } - } - } - StmtData::SSwitch(mut s_switch) => { - // Absorb a previous expression statement - if output.len() > 0 && p.options.runtime_merge_adjacent_expression_statements() - { - let prev_idx = output.len() - 1; - let prev_stmt = output[prev_idx]; - if let StmtData::SExpr(prev_expr) = prev_stmt.data { - if !prev_stmt.is_super_call() { - s_switch.test = - Expr::join_with_comma(prev_expr.value, s_switch.test); - output.truncate(prev_idx); - } - } - } - } - StmtData::SIf(mut s_if) => { - // Absorb a previous expression statement - if output.len() > 0 && p.options.runtime_merge_adjacent_expression_statements() - { - let prev_idx = output.len() - 1; - let prev_stmt = output[prev_idx]; - if let StmtData::SExpr(prev_expr) = prev_stmt.data { - if !prev_stmt.is_super_call() { - s_if.test = Expr::join_with_comma(prev_expr.value, s_if.test); - output.truncate(prev_idx); - } - } - } - - // TODO: optimize jump - } - - StmtData::SReturn(mut ret) => { - // Merge return statements with the previous expression statement - if output.len() > 0 - && ret.value.is_some() - && p.options.runtime_merge_adjacent_expression_statements() - { - let prev_idx = output.len() - 1; - let prev_stmt = output[prev_idx]; - if let StmtData::SExpr(prev_expr) = prev_stmt.data { - if !prev_stmt.is_super_call() { - ret.value = Some(Expr::join_with_comma( - prev_expr.value, - ret.value.unwrap(), - )); - output[prev_idx] = stmt; - continue; - } - } - } - - is_control_flow_dead = true; - } - - StmtData::SBreak(_) | StmtData::SContinue(_) => { - is_control_flow_dead = true; - } - - StmtData::SThrow(s_throw) => { - // Merge throw statements with the previous expression statement - if output.len() > 0 && p.options.runtime_merge_adjacent_expression_statements() - { - let prev_idx = output.len() - 1; - let prev_stmt = output[prev_idx]; - if let StmtData::SExpr(prev_expr) = prev_stmt.data { - if !prev_stmt.is_super_call() { - output[prev_idx] = p.s( - S::Throw { - value: Expr::join_with_comma( - prev_expr.value, - s_throw.value, - ), - }, - stmt.loc, - ); - continue; - } - } - } - - is_control_flow_dead = true; - } - - _ => {} - } - - output.push(stmt); - } - - // stmts.deinit(); — Drop handles freeing the old buffer (BumpVec is arena-backed). - *stmts = output; + let input = core::mem::replace(stmts, ListManaged::new_in(p.arena)); + *stmts = p.mangle_stmts(input, kind); Ok(()) } } diff --git a/src/js_parser/visit/visit_binary.rs b/src/js_parser/visit/visit_binary.rs index 5c1ebed71e9d..39146099808f 100644 --- a/src/js_parser/visit/visit_binary.rs +++ b/src/js_parser/visit/visit_binary.rs @@ -72,6 +72,50 @@ fn try_optimize_typeof_undefined<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bo )) } +/// "a === null || a === void 0" (or "a !== null && a !== void 0" with +/// `BinStrictNe`): the two operands of the `== null` that replaces it. +fn is_binary_null_and_undefined(left: Expr, right: Expr, op: Op::Code) -> Option<(Expr, Expr)> { + let ExprData::EBinary(a) = left.data else { + return None; + }; + let ExprData::EBinary(b) = right.data else { + return None; + }; + if a.op != op || b.op != op { + return None; + } + + let (mut id_a, mut eq_a) = (a.left, a.right); + let (mut id_b, mut eq_b) = (b.left, b.right); + + // Detect when the identifier comes second and flip the order of our checks + if matches!(eq_a.data, ExprData::EIdentifier(_)) { + core::mem::swap(&mut id_a, &mut eq_a); + } + if matches!(eq_b.data, ExprData::EIdentifier(_)) { + core::mem::swap(&mut id_b, &mut eq_b); + } + + let (ExprData::EIdentifier(id_a), ExprData::EIdentifier(id_b)) = (id_a.data, id_b.data) else { + return None; + }; + if id_a.ref_ != id_b.ref_ { + return None; + } + + // "a === null || a === void 0" + if matches!(eq_a.data, ExprData::ENull(_)) && matches!(eq_b.data, ExprData::EUndefined(_)) { + return Some((a.left, a.right)); + } + + // "a === void 0 || a === null" + if matches!(eq_a.data, ExprData::EUndefined(_)) && matches!(eq_b.data, ExprData::ENull(_)) { + return Some((b.left, b.right)); + } + + None +} + // `Expr.Data.eql(left, right, p, .{loose,strict})` — thin adapter // from the `const STRICT: bool` shape used at the four call sites below to the // canonical `ExprData::eql` (Expr.rs). Kept as a free fn so @@ -298,6 +342,11 @@ impl BinaryExpressionVisitor { if let Some(optimized) = try_optimize_typeof_undefined(e_, p, Op::Code::BinGt) { return optimized; } + + // "typeof x === 'string'" => "typeof x == 'string'" + if SideEffects::can_change_strict_to_loose(&e_.left.data, &e_.right.data) { + e_.op = Op::Code::BinLooseEq; + } } // const after_op_loc = locAfterOp(e_.); @@ -348,6 +397,11 @@ impl BinaryExpressionVisitor { if let Some(optimized) = try_optimize_typeof_undefined(e_, p, Op::Code::BinLt) { return optimized; } + + // "typeof x !== 'string'" => "typeof x != 'string'" + if SideEffects::can_change_strict_to_loose(&e_.left.data, &e_.right.data) { + e_.op = Op::Code::BinLooseNe; + } } } Op::Code::BinNullishCoalescing => { @@ -372,6 +426,20 @@ impl BinaryExpressionVisitor { return e_.right; } } + + if p.options.features.minify_syntax { + // "a ?? (b ?? c)" => "a ?? b ?? c" + if let ExprData::EBinary(right) = e_.right.data + && right.op == Op::Code::BinNullishCoalescing + { + e_.left = Expr::join_with_left_associative_op( + Op::Code::BinNullishCoalescing, + e_.left, + right.left, + ); + e_.right = right.right; + } + } } Op::Code::BinLogicalOr => { if let Some(side_effects) = SideEffects::to_boolean(p, &e_.left.data) { @@ -394,6 +462,29 @@ impl BinaryExpressionVisitor { return e_.right; } } + + if p.options.features.minify_syntax { + // "a || (b || c)" => "a || b || c" + if let ExprData::EBinary(right) = e_.right.data + && right.op == Op::Code::BinLogicalOr + { + e_.left = Expr::join_with_left_associative_op( + Op::Code::BinLogicalOr, + e_.left, + right.left, + ); + e_.right = right.right; + } + + // "a === null || a === undefined" => "a == null" + if let Some((left, right)) = + is_binary_null_and_undefined(e_.left, e_.right, Op::Code::BinStrictEq) + { + e_.op = Op::Code::BinLooseEq; + e_.left = left; + e_.right = right; + } + } } Op::Code::BinLogicalAnd => { if let Some(side_effects) = SideEffects::to_boolean(p, &e_.left.data) { @@ -416,6 +507,29 @@ impl BinaryExpressionVisitor { return e_.right; } } + + if p.options.features.minify_syntax { + // "a && (b && c)" => "a && b && c" + if let ExprData::EBinary(right) = e_.right.data + && right.op == Op::Code::BinLogicalAnd + { + e_.left = Expr::join_with_left_associative_op( + Op::Code::BinLogicalAnd, + e_.left, + right.left, + ); + e_.right = right.right; + } + + // "a !== null && a !== undefined" => "a != null" + if let Some((left, right)) = + is_binary_null_and_undefined(e_.left, e_.right, Op::Code::BinStrictNe) + { + e_.op = Op::Code::BinLooseNe; + e_.left = left; + e_.right = right; + } + } } Op::Code::BinAdd => { if p.should_fold_typescript_constant_expressions { diff --git a/src/js_parser/visit/visit_expr.rs b/src/js_parser/visit/visit_expr.rs index d096d19750ac..3828fd0b9f13 100644 --- a/src/js_parser/visit/visit_expr.rs +++ b/src/js_parser/visit/visit_expr.rs @@ -1507,6 +1507,16 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let Some(side_effects) = SideEffects::to_boolean(p, &e_.test.data) else { p.visit_expr(&mut e_.yes); p.visit_expr(&mut e_.no); + if p.full_minify_syntax() { + let result = p.mangle_if_expr(e.loc, e_); + // "(a ? b.c : b.c)()" => "(0, b.c)()", not "b.c()" + *e = if is_call_target && result.has_value_for_this_in_call() { + p.new_expr(E::Number::new(0.0), result.loc) + .join_with_comma(result) + } else { + result + }; + } return; }; @@ -2675,19 +2685,12 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } - // Collapse a single-`return` body to a shorthand expression body when - // minifying syntax while bundling: `(a) => { return a; }` becomes - // `(a) => a`. The printer (see js_printer EArrow) emits the shorthand - // when `prefer_expr` is set and the lone statement is a `return` with a - // value. A bare `return;` (no value) keeps the block body. - // - // Gated on `bundle` like the `e_function` name-drop below: the runtime - // transpiler forces `minify_syntax` on for `target.is_bun()` - // (see bundler/options.rs), so without this guard the collapse would - // also run for `bun run`/`bun test` and change an arrow's - // `Function.prototype.toString()` output. - if p.options.features.minify_syntax - && p.options.bundle + // Collapse a single-`return` body to a shorthand expression body: + // `(a) => { return a; }` becomes `(a) => a`. The printer (see + // js_printer EArrow) emits the shorthand when `prefer_expr` is set and + // the lone statement is a `return` with a value. A bare `return;` (no + // value) keeps the block body. + if p.full_minify_syntax() && stmts_list.len() == 1 && matches!( stmts_list[0].data, diff --git a/src/js_parser/visit/visit_stmt.rs b/src/js_parser/visit/visit_stmt.rs index 5c90a2df04ab..8526169b1e8e 100644 --- a/src/js_parser/visit/visit_stmt.rs +++ b/src/js_parser/visit/visit_stmt.rs @@ -1,4 +1,5 @@ #![warn(unused_must_use)] +use super::mangle::{mangle_for, stmts_care_about_scope}; use crate::Error; use crate::lexer as js_lexer; use crate::p::{P, ReactRefreshExportKind}; @@ -1346,6 +1347,22 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O data.stmt = p.visit_single_stmt(data.stmt, StmtsKind::None); p.pop_scope(); + if p.full_minify_syntax() { + // Optimize "x: break x" which some people apparently write by hand + if let StmtData::SBreak(child) = data.stmt.data + && let Some(label) = child.label + && label.ref_ == ref_ + { + return Ok(()); + } + + // Remove the label if it's not necessary + if p.symbols[ref_.inner_index() as usize].use_count_estimate == 0 { + p.append_if_body_preserving_scope(stmts, data.stmt); + return Ok(()); + } + } + stmts.push(*stmt); Ok(()) } @@ -1678,7 +1695,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O data.body = p.visit_loop_body(data.body); data.test = SideEffects::simplify_boolean(p, data.test); - if let Some(result) = SideEffects::to_boolean(p, &data.test.data) { + let known = SideEffects::to_boolean(p, &data.test.data); + if let Some(result) = known { if result.side_effects == SideEffects::NoSideEffects { data.test = p.new_expr( E::Boolean { @@ -1689,6 +1707,34 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } + if p.full_minify_syntax() { + // "while (a) {}" => "for (;a;) {}" + // A true value is implied + let test = match known { + Some(result) + if result.value && result.side_effects == SideEffects::NoSideEffects => + { + None + } + _ => Some(data.test), + }; + let for_stmt = p.s( + S::For { + init: None, + test, + update: None, + body: data.body, + }, + stmt.loc, + ); + let StmtData::SFor(mut for_ref) = for_stmt.data else { + unreachable!() + }; + mangle_for(&mut for_ref, p.arena); + stmts.push(for_stmt); + return Ok(()); + } + stmts.push(*stmt); Ok(()) } @@ -1766,81 +1812,11 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } if p.options.features.minify_syntax { - if let Some(effects) = effects { - if effects.value { - if data.no.is_none() - || !SideEffects::should_keep_stmt_in_dead_control_flow( - data.no.unwrap(), - p.arena, - ) - { - if effects.side_effects == SideEffects::CouldHaveSideEffects { - // Keep the condition if it could have side effects (but is still known to be truthy) - if let Some(test) = SideEffects::simplify_unused_expr(p, data.test) { - stmts.push(p.s( - S::SExpr { - value: test, - ..Default::default() - }, - test.loc, - )); - } - } - - return p.append_if_body_preserving_scope(stmts, data.yes); - } else { - // We have to keep the "no" branch - } - } else { - // The test is falsy - if !SideEffects::should_keep_stmt_in_dead_control_flow(data.yes, p.arena) { - if effects.side_effects == SideEffects::CouldHaveSideEffects { - // Keep the condition if it could have side effects (but is still known to be truthy) - if let Some(test) = SideEffects::simplify_unused_expr(p, data.test) { - stmts.push(p.s( - S::SExpr { - value: test, - ..Default::default() - }, - test.loc, - )); - } - } - - if data.no.is_none() { - return Ok(()); - } - - return p.append_if_body_preserving_scope(stmts, data.no.unwrap()); - } - } - } - - // TODO: more if statement syntax minification - let can_remove_test = p.expr_can_be_removed_if_unused(&data.test); - match data.yes.data { - StmtData::SExpr(yes_expr) => { - if yes_expr.value.is_missing() { - if let Some(no) = data.no { - if no.is_missing_expr() && can_remove_test { - return Ok(()); - } - } else if can_remove_test { - return Ok(()); - } - } - } - StmtData::SEmpty(_) => { - if let Some(no) = data.no { - if no.is_missing_expr() && can_remove_test { - return Ok(()); - } - } else if can_remove_test { - return Ok(()); - } - } - _ => {} - } + let StmtData::SIf(s_ref) = stmt.data else { + unreachable!() + }; + p.mangle_if(stmts, stmt.loc, s_ref); + return Ok(()); } stmts.push(*stmt); @@ -1886,8 +1862,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O local.decls.slice(), RelocateVarsMode::Normal, ); - if let Some(relocated) = relocate.stmt { - data.init = Some(relocated); + if relocate.ok { + // `stmt` is the assignments that replace the declaration. + // Without initializers there is nothing left to run here. + data.init = relocate.stmt; } } } @@ -1895,6 +1873,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O p.pop_scope(); + if p.full_minify_syntax() { + mangle_for(data, p.arena); + } + stmts.push(*stmt); Ok(()) } @@ -2072,6 +2054,15 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O p.pop_scope(); if let Some(catch) = &mut data.catch { + let old_is_control_flow_dead = p.is_control_flow_dead; + + // If the try body is empty, then the catch body is dead. Dead code is + // stripped whenever dead_code_elimination is on, so only mark it when + // minifying to leave plain transpiler output alone. + if data.body.is_empty() && p.options.features.minify_syntax { + p.is_control_flow_dead = true; + } + p.push_scope_for_visit_pass(js_ast::scope::Kind::CatchBinding, catch.loc) .expect("unreachable"); { @@ -2089,6 +2080,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O catch.body = list_to_stmts(_stmts); } p.pop_scope(); + + p.is_control_flow_dead = old_is_control_flow_dead; } if let Some(finally) = &mut data.finally { @@ -2103,6 +2096,67 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O p.pop_scope(); } + if p.full_minify_syntax() { + if data.body.is_empty() { + // Try to drop the whole thing if the try body is empty + let mut keep_catch = false; + + // Certain "catch" blocks need to be preserved: + // + // try {} catch { let foo } // Can be removed + // try {} catch { var foo } // Must be kept + // + if let Some(catch) = &data.catch { + for stmt2 in catch.body.slice() { + if SideEffects::should_keep_stmt_in_dead_control_flow(*stmt2, p.arena) { + keep_catch = true; + break; + } + } + } + + // Make sure to preserve the "finally" block if present + if !keep_catch { + let Some(finally) = &data.finally else { + return Ok(()); + }; + if !stmts_care_about_scope(finally.stmts.slice()) { + stmts.extend_from_slice(finally.stmts.slice()); + return Ok(()); + } + stmts.push(p.s( + S::Block { + stmts: finally.stmts, + close_brace_loc: js_ast::Loc::EMPTY, + }, + finally.loc, + )); + return Ok(()); + } + } else if let Some(finally) = &data.finally + && finally.stmts.is_empty() + { + if data.catch.is_some() { + // Just remove the "finally" block if there's a "catch" + data.finally = None; + } else { + // Otherwise, try to unwrap the whole "try" statement + if !stmts_care_about_scope(data.body.slice()) { + stmts.extend_from_slice(data.body.slice()); + return Ok(()); + } + stmts.push(p.s( + S::Block { + stmts: data.body, + close_brace_loc: js_ast::Loc::EMPTY, + }, + stmt.loc, + )); + return Ok(()); + } + } + } + stmts.push(*stmt); Ok(()) } diff --git a/test/bundler/bundler_edgecase.test.ts b/test/bundler/bundler_edgecase.test.ts index 762453475b4d..c75c4e35fa3e 100644 --- a/test/bundler/bundler_edgecase.test.ts +++ b/test/bundler/bundler_edgecase.test.ts @@ -1302,7 +1302,7 @@ describe("bundler", () => { snapshotSourceMap: { "entry.js.map": { files: ["../node_modules/react/index.js", "../entry.js"], - mappingsExactMatch: "2lBACA,WAAW,IAAQ,EAAE,ICDrB,aACA,QAAQ,IAAI,CAAK", + mappingsExactMatch: "klBACA,WAAW,IAAQ,EAAE,ICDrB,eACA,QAAQ,IAAI,CAAK", }, }, }); diff --git a/test/bundler/bundler_minify.test.ts b/test/bundler/bundler_minify.test.ts index 4c8ea49b3bc0..5d97a313a60e 100644 --- a/test/bundler/bundler_minify.test.ts +++ b/test/bundler/bundler_minify.test.ts @@ -1179,8 +1179,8 @@ describe("bundler", () => { "e2 instanceof Error", 'e1.message === "with new"', 'e2.message === "without new"', - 'typeof e1.stack === "string"', - 'typeof e2.stack === "string"', + 'typeof e1.stack == "string"', + 'typeof e2.stack == "string"', "withNew.constructor === withoutNew.constructor", ], minifySyntax: true, @@ -1354,12 +1354,12 @@ describe("bundler", () => { "JSON.stringify(a1) === JSON.stringify(a2)", "a1.constructor === a2.constructor", "sparse.length === 5", - "0 in sparse === !1", + "0 in sparse == !1", 'JSON.stringify(sparse) === "[null,null,null,null,null]"', "a3.length === a4.length && a3.length === 3 && a3[0] === void 0", - "typeof o1 === typeof o2", + "typeof o1 == typeof o2", "o1.constructor === o2.constructor", - "typeof f1 === typeof f2", + "typeof f1 == typeof f2", "f1() === f2()", "r1.source === r2.source", "r1.flags === r2.flags", @@ -1398,7 +1398,7 @@ describe("bundler", () => { onAfterBundle(api) { const file = api.readFile("out.js"); expect(normalizeBunSnapshot(file)).toMatchInlineSnapshot( - `"console.log(typeof x<"u");console.log(typeof x<"u");console.log(typeof x<"u");console.log(typeof x<"u");console.log(typeof x>"u");console.log(typeof x>"u");console.log(typeof x>"u");console.log(typeof x>"u");console.log(typeof x==="string");console.log(x==="undefined");console.log(y==="undefined");console.log(typeof x==="undefinedx");"`, + `"console.log(typeof x<"u");console.log(typeof x<"u");console.log(typeof x<"u");console.log(typeof x<"u");console.log(typeof x>"u");console.log(typeof x>"u");console.log(typeof x>"u");console.log(typeof x>"u");console.log(typeof x=="string");console.log(x==="undefined");console.log(y==="undefined");console.log(typeof x=="undefinedx");"`, ); }, }); @@ -1485,13 +1485,14 @@ describe("bundler", () => { }, }); - // A bare `return;` (no value) cannot be expressed as a shorthand arrow body, - // so the block body must be preserved even when minifying. + // A bare `return;` (no value) cannot be expressed as a shorthand arrow body. + // A trailing `return;` is dropped, and the empty block body stays. itBundled("minify/ArrowBareReturnKeepsBlock", { files: { "/entry.js": /* js */ ` export const bare = (x) => { return; }; export const undef = (x) => { return undefined; }; + export const tail = (x) => { x(); return; }; export function fn(a) { return a + 1; } `, }, @@ -1499,9 +1500,10 @@ describe("bundler", () => { minifyIdentifiers: false, onAfterBundle(api) { const code = api.readFile("/out.js"); - // Bare `return;` and `return undefined;` (normalized to `return;`) stay as blocks. - expect(code).toMatch(/bare = \(x\) => \{\s*return;?\s*\}/); - expect(code).toMatch(/undef = \(x\) => \{\s*return;?\s*\}/); + // Bare `return;` and `return undefined;` (normalized to `return;`) are dropped. + expect(code).toMatch(/bare = \(x\) => \{\s*\}/); + expect(code).toMatch(/undef = \(x\) => \{\s*\}/); + expect(code).toMatch(/tail = \(x\) => \{\s*x\(\);?\s*\}/); // Function declarations are never rewritten into arrows. expect(code).toContain("function fn(a)"); }, @@ -1589,14 +1591,13 @@ describe("bundler", () => { }, }); - // The collapse is gated on bundling, so minify-syntax alone (no bundle) must - // keep the block body. The runtime transpiler (`bun run`/`bun test`) forces - // minify-syntax on for bun targets but never bundles, so collapsing here - // would change `Function.prototype.toString()` output at runtime. - itBundled("minify/ArrowReturnNotCollapsedWhenNotBundling", { + // `bun build --no-bundle --minify-syntax` runs the same statement-level + // passes as the bundler, including the arrow body collapse. + itBundled("minify/ArrowReturnCollapsedWhenNotBundling", { files: { "/entry.js": /* js */ ` export const foo = (a) => { return a + 1; }; + export function bar(a, b) { if (a) return b(); return 0; } `, }, bundling: false, @@ -1604,7 +1605,152 @@ describe("bundler", () => { minifyIdentifiers: false, onAfterBundle(api) { const code = api.readFile("/out.js"); - expect(code).toMatch(/=>\s*\{\s*return a \+ 1;?\s*\}/); + expect(code).toMatch(/foo = \(a\) => a \+ 1/); + expect(code).toMatch(/return a \? b\(\) : 0/); + }, + }); + + // The runtime transpiler (`bun run`/`bun test`) forces minify-syntax on for + // bun targets but never bundles. It must not restructure statements, so that + // `Function.prototype.toString()` and line numbers stay close to the source. + test("runtime transpiler keeps statement structure", async () => { + using dir = tempDir("minify-runtime-structure", { + "entry.js": /* js */ ` + const foo = (a) => { return a + 1; }; + function bar(a, b) { + if (a) return b(); + if (!a) b(); else b(); + while (a) if (b()) break; + try { b() } catch (e) { b() } + return 0; + } + console.log(foo.toString()); + console.log(bar.toString()); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "entry.js"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(normalizeBunSnapshot(stdout)).toMatchInlineSnapshot(` + "(a) => { + return a + 1; + } + function bar(a, b) { + if (a) + return b(); + if (!a) + b(); + else + b(); + while (a) + if (b()) + break; + try { + b(); + } catch (e) { + b(); + } + return 0; + }" + `); + expect(exitCode).toBe(0); + }); + + // Statement-level syntax minification (esbuild's mangleIf, mangleStmts, + // mangleFor, MangleIfExpr). Each function is checked for shape, then the + // bundle runs to show the rewrites keep the original behavior. + itBundled("minify/StatementLevelMangling", { + files: { + "/entry.js": /* js */ ` + export function ifElseToTernary(a) { if (a) return 1; else return 2 } + export function ifChain(r) { if (!r) return 1; if (r.x) return 2; return 3 } + export function ifAnd(f, i, g) { if (f) { if (i) g() } } + export function ifOr(e, g) { if (e); else g() } + export function throwChain(a, b) { if (a) throw b; throw 1 } + export function whileBreak(g) { let n = 0; while (true) { if (g(n)) break; n++ } return n } + export function catchUnused(g, h) { try { g() } catch (t) { h() } } + export function catchVarQuirk() { try { throw 1 } catch (x) { var x = 2 } return x } + export function usedLabel(b) { x: { if (b) break x; return 1 } return 2 } + export function unusedLabel(b) { x: { return b } } + export function negZero(b) { return b ? -0 : 0 } + export function optChain(a) { return a != null ? a.b : undefined } + export function nullish(a, b) { return a != null ? a : b } + export function callTarget(o, a) { return (a ? o.m : o.m)() } + export function implicitReturn(y, z) { if (y) return; z() } + export function loopContinue(xs, f) { for (const x of xs) { if (x > 2) continue; f(x) } } + export function typeofEq(t) { return typeof t === "string" } + export function nullUndef(n) { return n === void 0 || n === null } + export function ternaryBool(a) { return a ? true : false } + export function sameArgs(a, b, c, d) { return a ? b(c) : b(d) } + export function exprStmts(a, b) { a(); b(); return 1 } + + const out = []; + const log = v => out.push(v); + log(ifElseToTernary(1), ifElseToTernary(0)); + log(ifChain(null), ifChain({ x: 1 }), ifChain({})); + { let n = 0; ifAnd(1, 1, () => n++); ifAnd(1, 0, () => n++); ifAnd(0, 1, () => n++); log(n); } + { let n = 0; ifOr(0, () => n++); ifOr(1, () => n++); log(n); } + try { throwChain(1, "b") } catch (e) { log(e) } + try { throwChain(0, "b") } catch (e) { log(e) } + log(whileBreak(n => n >= 3)); + { let n = 0; catchUnused(() => { throw 0 }, () => n++); log(n); } + log(catchVarQuirk()); + log(usedLabel(true), usedLabel(false), unusedLabel(7)); + log(Object.is(negZero(true), -0), Object.is(negZero(false), 0)); + log(optChain(null), optChain({ b: 5 })); + log(nullish(null, 2), nullish(0, 2)); + log(callTarget({ m() { return this === undefined } }, true)); + { let n = 0; implicitReturn(true, () => n++); implicitReturn(false, () => n++); log(n); } + { const seen = []; loopContinue([1, 2, 3, 4], x => seen.push(x)); log(seen.join()); } + log(typeofEq("s"), typeofEq(1)); + log(nullUndef(undefined), nullUndef(null), nullUndef(0)); + log(ternaryBool(1), ternaryBool("")); + log(sameArgs(true, x => x * 2, 1, 2), sameArgs(false, x => x * 2, 1, 2)); + { const seen = []; log(exprStmts(() => seen.push("a"), () => seen.push("b")), seen.join()); } + console.log(JSON.stringify(out)); + `, + }, + minifySyntax: true, + minifyWhitespace: true, + minifyIdentifiers: false, + onAfterBundle(api) { + const code = api.readFile("/out.js"); + const fns = [...code.matchAll(/function (\w+)\([^)]*\)\{(.*?)\}(?=function |var out)/g)].map(m => [m[1], m[2]]); + expect(Object.fromEntries(fns)).toEqual({ + ifElseToTernary: "return a?1:2", + ifChain: "return r?r.x?2:3:1", + ifAnd: "f&&i&&g()", + ifOr: "e||g()", + throwChain: "throw a?b:1", + whileBreak: "let n=0;for(;!g(n);)n++;return n", + catchUnused: "try{g()}catch{h()}", + // The hoisted `var x` shares its name with the catch binding, so the binding stays. + catchVarQuirk: "try{throw 1}catch(x){var x=2}return x", + usedLabel: "x:{if(b)break x;return 1}return 2", + unusedLabel: "return b", + // `-0` and `0` are not the same value, so the branches are not merged. + negZero: "return b?-0:0", + optChain: "return a?.b", + nullish: "return a??b", + // The comma keeps `this` unbound, as it was for the conditional. + callTarget: "return(0,o.m)()", + implicitReturn: "y||z()", + loopContinue: "for(let x of xs)x>2||f(x)", + typeofEq: 'return typeof t=="string"', + nullUndef: "return n==null", + ternaryBool: "return!!a", + sameArgs: "return b(a?c:d)", + exprStmts: "return a(),b(),1", + }); + }, + run: { + stdout: '[1,1,1,1,"b",1,3,1,null,2,true,null,2,true,1,"1,2",true,true,true,2,1]', }, }); diff --git a/test/bundler/bundler_npm.test.ts b/test/bundler/bundler_npm.test.ts index 2c5c29a375b8..9e3e4d25e58f 100644 --- a/test/bundler/bundler_npm.test.ts +++ b/test/bundler/bundler_npm.test.ts @@ -55,17 +55,17 @@ describe("bundler", () => { "../entry.tsx", ], mappings: [ - ["react.development.js:524:'getContextName'", "1:5623:at"], - ["react.development.js:2495:'actScopeDepth'", "23:4082:or++"], - ["react.development.js:696:''Component'", '1:7685:\'Component "%s"'], - ["entry.tsx:6:'\"Content-Type\"'", '100:18811:"Content-Type"'], - ["entry.tsx:11:''", "100:19065:void"], - ["entry.tsx:23:'await'", "100:19164:await"], + ["react.development.js:524:'getContextName'", "1:5578:at"], + ["react.development.js:2495:'actScopeDepth'", "23:3981:or++"], + ["react.development.js:696:''Component'", '1:7616:\'Component "%s"'], + ["entry.tsx:6:'\"Content-Type\"'", '100:18570:"Content-Type"'], + ["entry.tsx:11:''", "100:18824:void"], + ["entry.tsx:23:'await'", "100:18923:await"], ], }, }, expectExactFilesize: { - "out/entry.js": 221989, + "out/entry.js": 218955, }, run: { stdout: "

Hello World

This is an example.

", diff --git a/test/bundler/esbuild/js_parser_mangle.test.ts b/test/bundler/esbuild/js_parser_mangle.test.ts new file mode 100644 index 000000000000..ed2e365ff86c --- /dev/null +++ b/test/bundler/esbuild/js_parser_mangle.test.ts @@ -0,0 +1,641 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +// Ported from esbuild's internal/js_parser/js_parser_test.go. Each case is a +// program and the output esbuild prints for it with `--minify-syntax`. Bun +// prints the same tokens with a different layout (indentation, `!0` for +// `true`), so both sides are compared with the layout normalized away. +// +// Two kinds of cases keep Bun's own output instead of esbuild's: +// - `// bun: relocates var`: Bun hoists a nested `var` to the top level of a +// transform-only build (esbuild only does that when bundling), so the `var` +// statement ends up after the loop or try instead of inside it. +// - `// bun: strict mode`: Bun parses the file as a module, so a function +// declaration in a block does not get the sloppy-mode `var` alias. +// +// Cases that depend on esbuild's `MaybeSimplifyEqualityComparison` +// (`!a === false` => `!!a`, `(a, b) === c` => `a, b === c`) are not ported. + +type Case = [input: string, expected: string]; + +function normalize(code: string): string { + return code + .replaceAll("!0", "true") + .replaceAll("!1", "false") + .replace(/\s+/g, " ") + .trim() + .replace(/ ?([^\w$ ]) ?/g, "$1"); +} + +// One `bun build --no-bundle --minify-syntax` run per group, every case as its own entry point. +async function mangle(cases: Case[]): Promise { + const files: Record = {}; + cases.forEach(([input], i) => { + files[`case${i}.js`] = input; + }); + using dir = tempDir("esbuild-mangle", files); + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "--no-bundle", "--minify-syntax", "--outdir=out", ...Object.keys(files)], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + return cases.map((_, i) => readFileSync(join(String(dir), "out", `case${i}.js`), "utf8")); +} + +function check(name: string, cases: Case[]) { + test.concurrent(name, async () => { + const outputs = await mangle(cases); + const got: Record = {}; + const want: Record = {}; + cases.forEach(([input, expected], i) => { + got[`${i}: ${input}`] = normalize(outputs[i]); + want[`${i}: ${input}`] = normalize(expected); + }); + expect(got).toEqual(want); + }); +} + +check("TestMangleFor", [ + ["var a; while (1) ;", "for (var a; ; ) ;\n"], + ["let a; while (1) ;", "let a;\nfor (; ; ) ;\n"], + ["const a=0; while (1) ;", "const a = 0;\nfor (; ; ) ;\n"], + ["var a; for (var b;;) ;", "for (var a;; )\n ;\nvar b;\n"], // bun: relocates var + ["let a; for (let b;;) ;", "let a;\nfor (let b; ; ) ;\n"], + ["const a=0; for (const b = 1;;) ;", "const a = 0;\nfor (const b = 1; ; ) ;\n"], + ["export var a; while (1) ;", "export var a;\nfor (; ; ) ;\n"], + ["export let a; while (1) ;", "export let a;\nfor (; ; ) ;\n"], + ["export const a=0; while (1) ;", "export const a = 0;\nfor (; ; ) ;\n"], + ["export var a; for (var b;;) ;", "export var a;\nfor (;; )\n ;\nvar b;\n"], // bun: relocates var + ["export let a; for (let b;;) ;", "export let a;\nfor (let b; ; ) ;\n"], + ["export const a=0; for (const b = 1;;) ;", "export const a = 0;\nfor (const b = 1; ; ) ;\n"], + ["var a; for (let b;;) ;", "var a;\nfor (let b; ; ) ;\n"], + ["let a; for (const b=0;;) ;", "let a;\nfor (const b = 0; ; ) ;\n"], + ["const a=0; for (var b;;) ;", "const a = 0;\nfor (;; )\n ;\nvar b;\n"], // bun: relocates var + ["a(); while (1) ;", "for (a(); ; ) ;\n"], + ["a(); for (b();;) ;", "for (a(), b(); ; ) ;\n"], + ["for (; ;) if (x) break;", "for (; !x; ) ;\n"], + ["for (; ;) if (!x) break;", "for (; x; ) ;\n"], + ["for (; a;) if (x) break;", "for (; a && !x; ) ;\n"], + ["for (; a;) if (!x) break;", "for (; a && x; ) ;\n"], + ["for (; ;) { if (x) break; y(); }", "for (; !x; )\n y();\n"], + ["for (; a;) { if (x) break; y(); }", "for (; a && !x; )\n y();\n"], + ["for (; ;) if (x) break; else y();", "for (; !x; ) y();\n"], + ["for (; a;) if (x) break; else y();", "for (; a && !x; ) y();\n"], + ["for (; ;) { if (x) break; else y(); z(); }", "for (; !x; )\n y(), z();\n"], + ["for (; a;) { if (x) break; else y(); z(); }", "for (; a && !x; )\n y(), z();\n"], + ["for (; ;) if (x) y(); else break;", "for (; x; ) y();\n"], + ["for (; ;) if (!x) y(); else break;", "for (; !x; ) y();\n"], + ["for (; a;) if (x) y(); else break;", "for (; a && x; ) y();\n"], + ["for (; a;) if (!x) y(); else break;", "for (; a && !x; ) y();\n"], + ["for (; ;) { if (x) y(); else break; z(); }", "for (; x; ) {\n y();\n z();\n}\n"], + ["for (; a;) { if (x) y(); else break; z(); }", "for (; a && x; ) {\n y();\n z();\n}\n"], +]); + +check("TestMangleLoopJump", [ + ["while (x) { if (1) break; z(); }", "for (; x; )\n break;\n"], + ["while (x) { if (1) continue; z(); }", "for (; x; )\n ;\n"], + ["foo: while (a) while (x) { if (1) continue foo; z(); }", "foo: for (; a; ) for (; x; )\n continue foo;\n"], + ["while (x) { y(); if (1) break; z(); }", "for (; x; ) {\n y();\n break;\n}\n"], + ["while (x) { y(); if (1) continue; z(); }", "for (; x; )\n y();\n"], + ["while (x) { y(); debugger; if (1) continue; z(); }", "for (; x; ) {\n y();\n debugger;\n}\n"], + ["while (x) { let y = z(); if (1) continue; z(); }", "for (; x; ) {\n let y = z();\n}\n"], + ["while (x) { debugger; if (y) { if (1) break; z() } }", "for (; x; ) {\n debugger;\n if (y)\n break;\n}\n"], + ["while (x) { debugger; if (y) { if (1) continue; z() } }", "for (; x; ) {\n debugger;\n y;\n}\n"], + ["while (x) { debugger; if (1) { if (1) break; z() } }", "for (; x; ) {\n debugger;\n break;\n}\n"], + ["while (x) { debugger; if (1) { if (1) continue; z() } }", "for (; x; )\n debugger;\n"], + ["while (x()) continue", "for (; x(); ) ;\n"], + ["while (x) { y(); continue }", "for (; x; )\n y();\n"], + ["while (x) { if (y) { z(); continue } }", "for (; x; )\n if (y) {\n z();\n continue;\n }\n"], + [ + "label: while (x) while (y) { z(); continue label }", + "label: for (; x; ) for (; y; ) {\n z();\n continue label;\n}\n", + ], + ["while (x) { if (y) continue; z(); }", "for (; x; )\n y || z();\n"], + ["while (x) { if (y) continue; else z(); w(); }", "for (; x; )\n y || (z(), w());\n"], + ["while (x) { t(); if (y) continue; z(); }", "for (; x; )\n t(), !y && z();\n"], + ["while (x) { t(); if (y) continue; else z(); w(); }", "for (; x; )\n t(), !y && (z(), w());\n"], + ["while (x) { debugger; if (y) continue; z(); }", "for (; x; ) {\n debugger;\n y || z();\n}\n"], + ["while (x) { debugger; if (y) continue; else z(); w(); }", "for (; x; ) {\n debugger;\n y || (z(), w());\n}\n"], + ["while (x) { if (y) continue; function y() {} }", "for (;x; )\n ;\n"], // bun: strict mode + ["while (x) { if (y) continue; let y }", "for (; x; ) {\n if (y) continue;\n let y;\n}\n"], + ["while (x) { if (y) continue; var y }", "for (;x; )\n ;\nvar y;\n"], // bun: relocates var +]); + +check("TestMangleBlock", [ + ["while(1) { while (1) {} }", "for (; ; )\n for (; ; )\n ;\n"], + ["while(1) { const x = y; }", "for (; ; ) {\n const x = y;\n}\n"], + ["while(1) { let x; }", "for (; ; ) {\n let x;\n}\n"], + ["while(1) { var x; }", "for (;; )\n ;\nvar x;\n"], // bun: relocates var + ["while(1) { class X {} }", "for (; ; ) {\n class X {\n }\n}\n"], + ["while(1) { function x() {} }", "for (;; ) {\n let x = function() {};\n}\n"], // bun: strict mode + ["while(1) { function* x() {} }", "for (; ; ) {\n function* x() {\n }\n}\n"], + ["while(1) { async function x() {} }", "for (; ; ) {\n async function x() {\n }\n}\n"], + ["while(1) { async function* x() {} }", "for (; ; ) {\n async function* x() {\n }\n}\n"], +]); + +check("TestMangleIf", [ + ["1 ? a() : b()", "a();\n"], + ["0 ? a() : b()", "b();\n"], + ["a ? a : b", "a || b;\n"], + ["a ? b : a", "a && b;\n"], + ["a.x ? a.x : b", "a.x ? a.x : b;\n"], + ["a.x ? b : a.x", "a.x ? b : a.x;\n"], + ["a ? b() : c()", "a ? b() : c();\n"], + ["!a ? b() : c()", "a ? c() : b();\n"], + ["!!a ? b() : c()", "a ? b() : c();\n"], + ["!!!a ? b() : c()", "a ? c() : b();\n"], + ["if (1) a(); else b()", "a();\n"], + ["if (0) a(); else b()", "b();\n"], + ["if (a) b(); else c()", "a ? b() : c();\n"], + ["if (!a) b(); else c()", "a ? c() : b();\n"], + ["if (!!a) b(); else c()", "a ? b() : c();\n"], + ["if (!!!a) b(); else c()", "a ? c() : b();\n"], + ["if (1) a()", "a();\n"], + ["if (0) a()", ""], + ["if (a) b()", "a && b();\n"], + ["if (!a) b()", "a || b();\n"], + ["if (!!a) b()", "a && b();\n"], + ["if (!!!a) b()", "a || b();\n"], + ["if (1) {} else a()", ""], + ["if (0) {} else a()", "a();\n"], + ["if (a) {} else b()", "a || b();\n"], + ["if (!a) {} else b()", "a && b();\n"], + ["if (!!a) {} else b()", "a || b();\n"], + ["if (!!!a) {} else b()", "a && b();\n"], + ["if (a) {} else throw b", "if (!a)\n throw b;\n"], + ["if (!a) {} else throw b", "if (a)\n throw b;\n"], + ["a(); if (b) throw c", "if (a(), b) throw c;\n"], + ["if (a) if (b) throw c", "if (a && b) throw c;\n"], + ["if (true) { let a = b; if (c) throw d }", "{\n let a = b;\n if (c) throw d;\n}\n"], + ["if (true) { if (a) throw b; if (c) throw d }", "if (a) throw b;\nif (c) throw d;\n"], + ["if (false) throw a; else { let b = c; if (d) throw e }", "{\n let b = c;\n if (d) throw e;\n}\n"], + ["if (false) throw a; else { if (b) throw c; if (d) throw e }", "if (b) throw c;\nif (d) throw e;\n"], + [ + "if (a) { if (b) throw c; else { let d = e; if (f) throw g } }", + "if (a) {\n if (b) throw c;\n {\n let d = e;\n if (f) throw g;\n }\n}\n", + ], + [ + "if (a) { if (b) throw c; else if (d) throw e; else if (f) throw g }", + "if (a) {\n if (b) throw c;\n if (d) throw e;\n if (f) throw g;\n}\n", + ], + ["a = b ? true : false", "a = !!b;\n"], + ["a = b ? false : true", "a = !b;\n"], + ["a = !b ? true : false", "a = !b;\n"], + ["a = !b ? false : true", "a = !!b;\n"], + ["a = b == c ? true : false", "a = b == c;\n"], + ["a = b != c ? true : false", "a = b != c;\n"], + ["a = b === c ? true : false", "a = b === c;\n"], + ["a = b !== c ? true : false", "a = b !== c;\n"], + ["a ? b(c) : b(d)", "a ? b(c) : b(d);\n"], + ["let a; a ? b(c) : b(d)", "let a;\na ? b(c) : b(d);\n"], + ["let a, b; a ? b(c) : b(d)", "let a, b;\nb(a ? c : d);\n"], + ["let a, b; a ? b(c, 0) : b(d)", "let a, b;\na ? b(c, 0) : b(d);\n"], + ["let a, b; a ? b(c) : b(d, 0)", "let a, b;\na ? b(c) : b(d, 0);\n"], + ["let a, b; a ? b(c, 0) : b(d, 1)", "let a, b;\na ? b(c, 0) : b(d, 1);\n"], + ["let a, b; a ? b(c, 0) : b(d, 0)", "let a, b;\nb(a ? c : d, 0);\n"], + ["let a, b; a ? b(...c) : b(d)", "let a, b;\na ? b(...c) : b(d);\n"], + ["let a, b; a ? b(c) : b(...d)", "let a, b;\na ? b(c) : b(...d);\n"], + ["let a, b; a ? b(...c) : b(...d)", "let a, b;\nb(...a ? c : d);\n"], + ["let a, b; a ? b(a) : b(c)", "let a, b;\nb(a || c);\n"], + ["let a, b; a ? b(c) : b(a)", "let a, b;\nb(a && c);\n"], + ["let a, b; a ? b(...a) : b(...c)", "let a, b;\nb(...a || c);\n"], + ["let a, b; a ? b(...c) : b(...a)", "let a, b;\nb(...a && c);\n"], + ["let a; a.x ? b(c) : b(d)", "let a;\na.x ? b(c) : b(d);\n"], + ["let a, b; a.x ? b(c) : b(d)", "let a, b;\na.x ? b(c) : b(d);\n"], + ["let a, b; a ? b.y(c) : b.y(d)", "let a, b;\na ? b.y(c) : b.y(d);\n"], + ["let a, b; a.x ? b.y(c) : b.y(d)", "let a, b;\na.x ? b.y(c) : b.y(d);\n"], + ["a ? b : c ? b : d", "a || c ? b : d;\n"], + ["a ? b ? c : d : d", "a && b ? c : d;\n"], + ["a ? c : (b, c)", "a || b, c;\n"], + ["a ? (b, c) : c", "a && b, c;\n"], + ["a ? c : (b, d)", "a ? c : (b, d);\n"], + ["a ? (b, c) : d", "a ? (b, c) : d;\n"], + ["a ? b || c : c", "a && b || c;\n"], + ["a ? b || c : d", "a ? b || c : d;\n"], + ["a ? b && c : c", "a ? b && c : c;\n"], + ["a ? c : b && c", "(a || b) && c;\n"], + ["a ? c : b && d", "a ? c : b && d;\n"], + ["a ? c : b || c", "a ? c : b || c;\n"], + ["a = b == null ? c : b", "a = b == null ? c : b;\n"], + ["a = b != null ? b : c", "a = b != null ? b : c;\n"], + ["let b; a = b == null ? c : b", "let b;\na = b ?? c;\n"], + ["let b; a = b != null ? b : c", "let b;\na = b ?? c;\n"], + ["let b; a = b == null ? b : c", "let b;\na = b == null ? b : c;\n"], + ["let b; a = b != null ? c : b", "let b;\na = b != null ? c : b;\n"], + ["let b; a = null == b ? c : b", "let b;\na = b ?? c;\n"], + ["let b; a = null != b ? b : c", "let b;\na = b ?? c;\n"], + ["let b; a = null == b ? b : c", "let b;\na = b == null ? b : c;\n"], + ["let b; a = null != b ? c : b", "let b;\na = b != null ? c : b;\n"], + ["let b; a = b.x == null ? c : b.x", "let b;\na = b.x == null ? c : b.x;\n"], + ["let b; a = b.x != null ? b.x : c", "let b;\na = b.x != null ? b.x : c;\n"], + ["let b; a = null == b.x ? c : b.x", "let b;\na = b.x == null ? c : b.x;\n"], + ["let b; a = null != b.x ? b.x : c", "let b;\na = b.x != null ? b.x : c;\n"], + ["let b; a = b === null ? c : b", "let b;\na = b === null ? c : b;\n"], + ["let b; a = b !== null ? b : c", "let b;\na = b !== null ? b : c;\n"], + ["let b; a = null === b ? c : b", "let b;\na = b === null ? c : b;\n"], + ["let b; a = null !== b ? b : c", "let b;\na = b !== null ? b : c;\n"], + ["let b; a = null === b || b === undefined ? c : b", "let b;\na = b ?? c;\n"], + ["let b; a = b !== undefined && b !== null ? b : c", "let b;\na = b ?? c;\n"], + ["a(b ? 0 : 0)", "a((b, 0));\n"], + ["a(b ? +0 : -0)", "a(b ? 0 : -0);\n"], + ["a(b ? +0 : 0)", "a((b, 0));\n"], + ["a(b ? -0 : 0)", "a(b ? -0 : 0);\n"], + ["a ? b : b", "a, b;\n"], + ["let a; a ? b : b", "let a;\nb;\n"], + ["a ? -b : -b", "a, -b;\n"], + ["a ? b.c : b.c", "a, b.c;\n"], + ["a ? b?.c : b?.c", "a, b?.c;\n"], + ["a ? b[c] : b[c]", "a, b[c];\n"], + ["a ? b() : b()", "a, b();\n"], + ["a ? b?.() : b?.()", "a, b?.();\n"], + ["a ? b?.[c] : b?.[c]", "a, b?.[c];\n"], + ["a ? b == c : b == c", "a, b == c;\n"], + ["a ? b.c(d + e[f]) : b.c(d + e[f])", "a, b.c(d + e[f]);\n"], + ["a ? -b : !b", "a ? -b : b;\n"], + ["a ? b() : b(c)", "a ? b() : b(c);\n"], + ["a ? b(c) : b(d)", "a ? b(c) : b(d);\n"], + ["a ? b?.c : b.c", "a ? b?.c : b.c;\n"], + ["a ? b?.() : b()", "a ? b?.() : b();\n"], + ["a ? b?.[c] : b[c]", "a ? b?.[c] : b[c];\n"], + ["a ? b == c : b != c", "a ? b == c : b != c;\n"], + ["a ? b.c(d + e[f]) : b.c(d + e[g])", "a ? b.c(d + e[f]) : b.c(d + e[g]);\n"], + ["(a, b) ? c : d", "a, b ? c : d;\n"], + ["return a && ((b && c) && (d && e))", "return a && b && c && d && e;\n"], + ["return a || ((b || c) || (d || e))", "return a || b || c || d || e;\n"], + ["return a ?? ((b ?? c) ?? (d ?? e))", "return a ?? b ?? c ?? d ?? e;\n"], + ["if (a) if (b) if (c) d", "a && b && c && d;\n"], + ["if (!a) if (!b) if (!c) d", "a || b || c || d;\n"], + ["let a, b, c; return a != null ? a : b != null ? b : c", "let a, b, c;\nreturn a ?? b ?? c;\n"], + ["if (a) return c; if (b) return d;", "if (a) return c;\nif (b) return d;\n"], + ["if (a) return c; if (b) return c;", "if (a || b) return c;\n"], + ["if (a) return c; if (b) return;", "if (a) return c;\nif (b) return;\n"], + ["if (a) return; if (b) return c;", "if (a) return;\nif (b) return c;\n"], + ["if (a) return; if (b) return;", "if (a || b) return;\n"], + ["if (a) throw c; if (b) throw d;", "if (a) throw c;\nif (b) throw d;\n"], + ["if (a) throw c; if (b) throw c;", "if (a || b) throw c;\n"], + ["while (x) { if (a) break; if (b) break; }", "for (; x && !(a || b); )\n ;\n"], + ["while (x) { if (a) continue; if (b) continue; }", "for (; x; )\n a || b;\n"], + ["while (x) { debugger; if (a) break; if (b) break; }", "for (; x; ) {\n debugger;\n if (a || b) break;\n}\n"], + ["while (x) { debugger; if (a) continue; if (b) continue; }", "for (; x; ) {\n debugger;\n a || b;\n}\n"], + [ + "x: while (x) y: while (y) { if (a) break x; if (b) break y; }", + "x: for (; x; ) y: for (; y; ) {\n if (a) break x;\n if (b) break y;\n}\n", + ], + [ + "x: while (x) y: while (y) { if (a) continue x; if (b) continue y; }", + "x: for (; x; ) y: for (; y; ) {\n if (a) continue x;\n if (b) continue y;\n}\n", + ], + [ + "x: while (x) y: while (y) { if (a) break x; if (b) break x; }", + "x: for (; x; ) for (; y; )\n if (a || b) break x;\n", + ], + [ + "x: while (x) y: while (y) { if (a) continue x; if (b) continue x; }", + "x: for (; x; ) for (; y; )\n if (a || b) continue x;\n", + ], + [ + "x: while (x) y: while (y) { if (a) break y; if (b) break y; }", + "for (; x; ) y: for (; y; )\n if (a || b) break y;\n", + ], + [ + "x: while (x) y: while (y) { if (a) continue y; if (b) continue y; }", + "for (; x; ) y: for (; y; )\n if (a || b) continue y;\n", + ], + ["if (x ? y : 0) foo()", "x && y && foo();\n"], + ["if (x ? y : 1) foo()", "(!x || y) && foo();\n"], + ["if (x ? 0 : y) foo()", "!x && y && foo();\n"], + ["if (x ? 1 : y) foo()", "(x || y) && foo();\n"], + ["if (x ? y : 0) ; else foo()", "x && y || foo();\n"], + ["if (x ? y : 1) ; else foo()", "!x || y || foo();\n"], + ["if (x ? 0 : y) ; else foo()", "!x && y || foo();\n"], + ["if (x ? 1 : y) ; else foo()", "x || y || foo();\n"], + ["(x ? y : 0) && foo();", "x && y && foo();\n"], + ["(x ? y : 1) && foo();", "(!x || y) && foo();\n"], + ["(x ? 0 : y) && foo();", "!x && y && foo();\n"], + ["(x ? 1 : y) && foo();", "(x || y) && foo();\n"], + ["(x ? y : 0) || foo();", "x && y || foo();\n"], + ["(x ? y : 1) || foo();", "!x || y || foo();\n"], + ["(x ? 0 : y) || foo();", "!x && y || foo();\n"], + ["(x ? 1 : y) || foo();", "x || y || foo();\n"], + ["if (!!a || !!b) throw 0", "if (a || b) throw 0;\n"], + ["if (!!a && !!b) throw 0", "if (a && b) throw 0;\n"], + ["if (!!a ? !!b : !!c) throw 0", "if (a ? b : c) throw 0;\n"], + ["if ((a + b) !== 0) throw 0", "if (a + b !== 0) throw 0;\n"], + ["if ((a | b) !== 0) throw 0", "if ((a | b) !== 0) throw 0;\n"], + ["if ((a & b) !== 0) throw 0", "if ((a & b) !== 0) throw 0;\n"], + ["if ((a ^ b) !== 0) throw 0", "if ((a ^ b) !== 0) throw 0;\n"], + ["if ((a << b) !== 0) throw 0", "if (a << b !== 0) throw 0;\n"], + ["if ((a >> b) !== 0) throw 0", "if (a >> b !== 0) throw 0;\n"], + ["if ((a >>> b) !== 0) throw 0", "if (a >>> b) throw 0;\n"], + ["if (+a !== 0) throw 0", "if (+a != 0) throw 0;\n"], + ["if (~a !== 0) throw 0", "if (~a !== 0) throw 0;\n"], + ["if (0 != (a + b)) throw 0", "if (a + b != 0) throw 0;\n"], + ["if (0 != (a | b)) throw 0", "if ((a | b) != 0) throw 0;\n"], + ["if (0 != (a & b)) throw 0", "if ((a & b) != 0) throw 0;\n"], + ["if (0 != (a ^ b)) throw 0", "if ((a ^ b) != 0) throw 0;\n"], + ["if (0 != (a << b)) throw 0", "if (a << b != 0) throw 0;\n"], + ["if (0 != (a >> b)) throw 0", "if (a >> b != 0) throw 0;\n"], + ["if (0 != (a >>> b)) throw 0", "if (a >>> b) throw 0;\n"], + ["if (0 != +a) throw 0", "if (+a != 0) throw 0;\n"], + ["if (0 != ~a) throw 0", "if (~a != 0) throw 0;\n"], + ["if ((a + b) === 0) throw 0", "if (a + b === 0) throw 0;\n"], + ["if ((a | b) === 0) throw 0", "if ((a | b) === 0) throw 0;\n"], + ["if ((a & b) === 0) throw 0", "if ((a & b) === 0) throw 0;\n"], + ["if ((a ^ b) === 0) throw 0", "if ((a ^ b) === 0) throw 0;\n"], + ["if ((a << b) === 0) throw 0", "if (a << b === 0) throw 0;\n"], + ["if ((a >> b) === 0) throw 0", "if (a >> b === 0) throw 0;\n"], + ["if ((a >>> b) === 0) throw 0", "if (!(a >>> b)) throw 0;\n"], + ["if (+a === 0) throw 0", "if (+a == 0) throw 0;\n"], + ["if (~a === 0) throw 0", "if (~a === 0) throw 0;\n"], + ["if (0 == (a + b)) throw 0", "if (a + b == 0) throw 0;\n"], + ["if (0 == (a | b)) throw 0", "if ((a | b) == 0) throw 0;\n"], + ["if (0 == (a & b)) throw 0", "if ((a & b) == 0) throw 0;\n"], + ["if (0 == (a ^ b)) throw 0", "if ((a ^ b) == 0) throw 0;\n"], + ["if (0 == (a << b)) throw 0", "if (a << b == 0) throw 0;\n"], + ["if (0 == (a >> b)) throw 0", "if (a >> b == 0) throw 0;\n"], + ["if (0 == (a >>> b)) throw 0", "if (!(a >>> b)) throw 0;\n"], + ["if (0 == +a) throw 0", "if (+a == 0) throw 0;\n"], + ["if (0 == ~a) throw 0", "if (~a == 0) throw 0;\n"], +]); + +check("TestMangleWrapToAvoidAmbiguousElse", [ + ["if (a) { if (b) return c } else return d", "if (a) {\n if (b) return c;\n} else return d;\n"], + [ + "if (a) while (1) { if (b) return c } else return d", + "if (a) {\n for (; ; )\n if (b) return c;\n} else return d;\n", + ], + [ + "if (a) for (;;) { if (b) return c } else return d", + "if (a) {\n for (; ; )\n if (b) return c;\n} else return d;\n", + ], + [ + "if (a) for (x in y) { if (b) return c } else return d", + "if (a) {\n for (x in y)\n if (b) return c;\n} else return d;\n", + ], + [ + "if (a) for (x of y) { if (b) return c } else return d", + "if (a) {\n for (x of y)\n if (b) return c;\n} else return d;\n", + ], + [ + "if (a) with (x) { if (b) return c } else return d", + "if (a) {\n with (x)\n if (b) return c;\n} else return d;\n", + ], + ["if (a) x: { if (b) break x } else return c", "if (a) {\n x:\n if (b) break x;\n} else return c;\n"], +]); + +check("TestMangleOptionalChain", [ + ["let a; return a != null ? a.b : undefined", "let a;\nreturn a?.b;\n"], + ["let a; return a != null ? a[b] : undefined", "let a;\nreturn a?.[b];\n"], + ["let a; return a != null ? a(b) : undefined", "let a;\nreturn a?.(b);\n"], + ["let a; return a == null ? undefined : a.b", "let a;\nreturn a?.b;\n"], + ["let a; return a == null ? undefined : a[b]", "let a;\nreturn a?.[b];\n"], + ["let a; return a == null ? undefined : a(b)", "let a;\nreturn a?.(b);\n"], + ["let a; return null != a ? a.b : undefined", "let a;\nreturn a?.b;\n"], + ["let a; return null != a ? a[b] : undefined", "let a;\nreturn a?.[b];\n"], + ["let a; return null != a ? a(b) : undefined", "let a;\nreturn a?.(b);\n"], + ["let a; return null == a ? undefined : a.b", "let a;\nreturn a?.b;\n"], + ["let a; return null == a ? undefined : a[b]", "let a;\nreturn a?.[b];\n"], + ["let a; return null == a ? undefined : a(b)", "let a;\nreturn a?.(b);\n"], + ["return a != null ? a.b : undefined", "return a != null ? a.b : void 0;\n"], + ["let a; return a != null ? a.b : null", "let a;\nreturn a != null ? a.b : null;\n"], + ["let a; return a != null ? b.a : undefined", "let a;\nreturn a != null ? b.a : void 0;\n"], + ["let a; return a != 0 ? a.b : undefined", "let a;\nreturn a != 0 ? a.b : void 0;\n"], + ["let a; return a !== null ? a.b : undefined", "let a;\nreturn a !== null ? a.b : void 0;\n"], + ["let a; return a != undefined ? a.b : undefined", "let a;\nreturn a?.b;\n"], + ["let a; return a != null ? a?.b : undefined", "let a;\nreturn a?.b;\n"], + ["let a; return a != null ? a.b.c[d](e) : undefined", "let a;\nreturn a?.b.c[d](e);\n"], + ["let a; return a != null ? a?.b.c[d](e) : undefined", "let a;\nreturn a?.b.c[d](e);\n"], + ["let a; return a != null ? a.b.c?.[d](e) : undefined", "let a;\nreturn a?.b.c?.[d](e);\n"], + ["let a; return a != null ? a?.b.c?.[d](e) : undefined", "let a;\nreturn a?.b.c?.[d](e);\n"], + ["a != null && a.b()", "a?.b();\n"], + ["a == null || a.b()", "a?.b();\n"], + ["null != a && a.b()", "a?.b();\n"], + ["null == a || a.b()", "a?.b();\n"], + ["a == null && a.b()", "a == null && a.b();\n"], + ["a != null || a.b()", "a != null || a.b();\n"], + ["null == a && a.b()", "a == null && a.b();\n"], + ["null != a || a.b()", "a != null || a.b();\n"], + ["x = a != null && a.b()", "x = a != null && a.b();\n"], + ["x = a == null || a.b()", "x = a == null || a.b();\n"], + ["if (a != null) a.b()", "a?.b();\n"], + ["if (a == null) ; else a.b()", "a?.b();\n"], + ["if (a == null) a.b()", "a == null && a.b();\n"], + ["if (a != null) ; else a.b()", "a != null || a.b();\n"], +]); + +check("TestMangleReturn", [ + ["function foo() { x(); return; }", "function foo() {\n x();\n}\n"], + ["let foo = function() { x(); return; }", "let foo = function() {\n x();\n};\n"], + ["let foo = () => { x(); return; }", "let foo = () => {\n x();\n};\n"], + ["function foo() { x(); return y; }", "function foo() {\n return x(), y;\n}\n"], + ["let foo = function() { x(); return y; }", "let foo = function() {\n return x(), y;\n};\n"], + ["let foo = () => { x(); return y; }", "let foo = () => (x(), y);\n"], + ["x(); return;", "x();\nreturn;\n"], + [ + "function foo() { a = b; if (a) return a; if (b) c = b; return c; }", + "function foo() {\n return a = b, a || (b && (c = b), c);\n}\n", + ], + [ + "function foo() { a = b; if (a) return; if (b) c = b; return c; }", + "function foo() {\n if (a = b, !a)\n return b && (c = b), c;\n}\n", + ], + ["function foo() { if (!a) return b; return c; }", "function foo() {\n return a ? c : b;\n}\n"], + ["if (1) return a(); else return b()", "return a();\n"], + ["if (0) return a(); else return b()", "return b();\n"], + ["if (a) return b(); else return c()", "return a ? b() : c();\n"], + ["if (!a) return b(); else return c()", "return a ? c() : b();\n"], + ["if (!!a) return b(); else return c()", "return a ? b() : c();\n"], + ["if (!!!a) return b(); else return c()", "return a ? c() : b();\n"], + ["if (1) return a(); return b()", "return a();\n"], + ["if (0) return a(); return b()", "return b();\n"], + ["if (a) return b(); return c()", "return a ? b() : c();\n"], + ["if (!a) return b(); return c()", "return a ? c() : b();\n"], + ["if (!!a) return b(); return c()", "return a ? b() : c();\n"], + ["if (!!!a) return b(); return c()", "return a ? c() : b();\n"], + ["if (a) return b; else return c; return d;\n", "return a ? b : c;\n"], + ["function x() { if (y) return; z(); }", "function x() {\n y || z();\n}\n"], + ["function x() { if (y) return; else z(); w(); }", "function x() {\n y || (z(), w());\n}\n"], + ["function x() { t(); if (y) return; z(); }", "function x() {\n t(), !y && z();\n}\n"], + ["function x() { t(); if (y) return; else z(); w(); }", "function x() {\n t(), !y && (z(), w());\n}\n"], + ["function x() { debugger; if (y) return; z(); }", "function x() {\n debugger;\n y || z();\n}\n"], + ["function x() { debugger; if (y) return; else z(); w(); }", "function x() {\n debugger;\n y || (z(), w());\n}\n"], + ["function x() { if (y) { if (z) return; } }", "function x() {\n y && z;\n}\n"], + [ + "function x() { if (y) { if (z) return; w(); } }", + "function x() {\n if (y) {\n if (z) return;\n w();\n }\n}\n", + ], + ["function foo(x) { if (!x.y) {} else return x }", "function foo(x) {\n if (x.y)\n return x;\n}\n"], + ["function foo(x) { if (!x.y) return undefined; return x }", "function foo(x) {\n if (x.y)\n return x;\n}\n"], + ["function x() { if (y) return; function y() {} }", "function x() {\n if (y) return;\n function y() {\n }\n}\n"], + ["function x() { if (y) return; let y }", "function x() {\n if (y) return;\n let y;\n}\n"], + ["function x() { if (y) return; var y }", "function x() {\n if (!y)\n var y;\n}\n"], +]); + +check("TestMangleThrow", [ + [ + "function foo() { a = b; if (a) throw a; if (b) c = b; throw c; }", + "function foo() {\n throw a = b, a || (b && (c = b), c);\n}\n", + ], + ["function foo() { if (!a) throw b; throw c; }", "function foo() {\n throw a ? c : b;\n}\n"], + ["if (1) throw a(); else throw b()", "throw a();\n"], + ["if (0) throw a(); else throw b()", "throw b();\n"], + ["if (a) throw b(); else throw c()", "throw a ? b() : c();\n"], + ["if (!a) throw b(); else throw c()", "throw a ? c() : b();\n"], + ["if (!!a) throw b(); else throw c()", "throw a ? b() : c();\n"], + ["if (!!!a) throw b(); else throw c()", "throw a ? c() : b();\n"], + ["if (1) throw a(); throw b()", "throw a();\n"], + ["if (0) throw a(); throw b()", "throw b();\n"], + ["if (a) throw b(); throw c()", "throw a ? b() : c();\n"], + ["if (!a) throw b(); throw c()", "throw a ? c() : b();\n"], + ["if (!!a) throw b(); throw c()", "throw a ? b() : c();\n"], + ["if (!!!a) throw b(); throw c()", "throw a ? c() : b();\n"], +]); + +check("TestMangleNestedLogical", [ + ["(a && b) && c", "a && b && c;\n"], + ["a && (b && c)", "a && b && c;\n"], + ["(a || b) && c", "(a || b) && c;\n"], + ["a && (b || c)", "a && (b || c);\n"], + ["(a || b) || c", "a || b || c;\n"], + ["a || (b || c)", "a || b || c;\n"], + ["(a && b) || c", "a && b || c;\n"], + ["a || (b && c)", "a || b && c;\n"], +]); + +check("TestMangleEquals", [ + ["return typeof x === y", "return typeof x === y;\n"], + ["return typeof x !== y", "return typeof x !== y;\n"], + ["return y === typeof x", "return y === typeof x;\n"], + ["return y !== typeof x", "return y !== typeof x;\n"], + ["return typeof x === 'string'", 'return typeof x == "string";\n'], + ["return typeof x !== 'string'", 'return typeof x != "string";\n'], + ["return 'string' === typeof x", 'return typeof x == "string";\n'], + ["return 'string' !== typeof x", 'return typeof x != "string";\n'], + ["return a === 0", "return a === 0;\n"], + ["return a !== 0", "return a !== 0;\n"], + ["return +a === 0", "return +a == 0;\n"], + ["return +a !== 0", "return +a != 0;\n"], + ["return -a === 0", "return -a === 0;\n"], + ["return -a !== 0", "return -a !== 0;\n"], + ["return a === ''", 'return a === "";\n'], + ["return a !== ''", 'return a !== "";\n'], + ["return (a + '!') === 'a!'", 'return a + "!" == "a!";\n'], + ["return (a + '!') !== 'a!'", 'return a + "!" != "a!";\n'], + ["return (a += '!') === 'a!'", 'return (a += "!") == "a!";\n'], + ["return (a += '!') !== 'a!'", 'return (a += "!") != "a!";\n'], + ["return a === false", "return a === false;\n"], + ["return a === true", "return a === true;\n"], + ["return a !== false", "return a !== false;\n"], + ["return a !== true", "return a !== true;\n"], + ["return a === !b", "return a === !b;\n"], + ["return a === !b", "return a === !b;\n"], + ["return a !== !b", "return a !== !b;\n"], + ["return a !== !b", "return a !== !b;\n"], + ["return !a === !b", "return !a == !b;\n"], + ["return !a === !b", "return !a == !b;\n"], + ["return !a !== !b", "return !a != !b;\n"], + ["return !a !== !b", "return !a != !b;\n"], + ["return (a -= 1n) !== -1", "return (a -= 1n) !== -1;\n"], + ["return (a *= 1n) !== -1", "return (a *= 1n) !== -1;\n"], + ["return (a **= 1n) !== -1", "return (a **= 1n) !== -1;\n"], + ["return (a /= 1n) !== -1", "return (a /= 1n) !== -1;\n"], + ["return (a %= 1n) !== -1", "return (a %= 1n) !== -1;\n"], + ["return (a &= 1n) !== -1", "return (a &= 1n) !== -1;\n"], + ["return (a |= 1n) !== -1", "return (a |= 1n) !== -1;\n"], + ["return (a ^= 1n) !== -1", "return (a ^= 1n) !== -1;\n"], +]); + +check("TestMangleEqualsUndefined", [ + ["return a === void 0", "return a === void 0;\n"], + ["return a !== void 0", "return a !== void 0;\n"], + ["return void 0 === a", "return a === void 0;\n"], + ["return void 0 !== a", "return a !== void 0;\n"], + ["return a == void 0", "return a == null;\n"], + ["return a != void 0", "return a != null;\n"], + ["return void 0 == a", "return a == null;\n"], + ["return void 0 != a", "return a != null;\n"], + ["return a === null || a === undefined", "return a == null;\n"], + ["return a === null || a !== undefined", "return a === null || a !== void 0;\n"], + ["return a !== null || a === undefined", "return a !== null || a === void 0;\n"], + ["return a === null && a === undefined", "return a === null && a === void 0;\n"], + ["return a.x === null || a.x === undefined", "return a.x === null || a.x === void 0;\n"], + ["return a === undefined || a === null", "return a == null;\n"], + ["return a === undefined || a !== null", "return a === void 0 || a !== null;\n"], + ["return a !== undefined || a === null", "return a !== void 0 || a === null;\n"], + ["return a === undefined && a === null", "return a === void 0 && a === null;\n"], + ["return a.x === undefined || a.x === null", "return a.x === void 0 || a.x === null;\n"], + ["return a !== null && a !== undefined", "return a != null;\n"], + ["return a !== null && a === undefined", "return a !== null && a === void 0;\n"], + ["return a === null && a !== undefined", "return a === null && a !== void 0;\n"], + ["return a !== null || a !== undefined", "return a !== null || a !== void 0;\n"], + ["return a.x !== null && a.x !== undefined", "return a.x !== null && a.x !== void 0;\n"], + ["return a !== undefined && a !== null", "return a != null;\n"], + ["return a !== undefined && a === null", "return a !== void 0 && a === null;\n"], + ["return a === undefined && a !== null", "return a === void 0 && a !== null;\n"], + ["return a !== undefined || a !== null", "return a !== void 0 || a !== null;\n"], + ["return a.x !== undefined && a.x !== null", "return a.x !== void 0 && a.x !== null;\n"], +]); + +check("TestMangleTypeofEqualsUndefined", [ + ["return typeof x !== 'undefined'", 'return typeof x < "u";\n'], + ["return typeof x != 'undefined'", 'return typeof x < "u";\n'], + ["return 'undefined' !== typeof x", 'return typeof x < "u";\n'], + ["return 'undefined' != typeof x", 'return typeof x < "u";\n'], + ["return typeof x === 'undefined'", 'return typeof x > "u";\n'], + ["return typeof x == 'undefined'", 'return typeof x > "u";\n'], + ["return 'undefined' === typeof x", 'return typeof x > "u";\n'], + ["return 'undefined' == typeof x", 'return typeof x > "u";\n'], +]); + +check("TestMangleCatch", [ + ["try { throw 0 } catch (e) { console.log(0) }", "try {\n throw 0;\n} catch {\n console.log(0);\n}\n"], + ["try { throw 0 } catch (e) { console.log(0, e) }", "try {\n throw 0;\n} catch (e) {\n console.log(0, e);\n}\n"], + ["try { throw 0 } catch (e) { 0 && console.log(0, e) }", "try {\n throw 0;\n} catch {\n}\n"], + ["try { thrower() } catch ([a]) { console.log(0) }", "try {\n thrower();\n} catch ([a]) {\n console.log(0);\n}\n"], + [ + "try { thrower() } catch ({ a }) { console.log(0) }", + "try {\n thrower();\n} catch ({ a }) {\n console.log(0);\n}\n", + ], + [ + "try { throw 1 } catch (x) { y(x); var x = 2; y(x) }", + "try {\n throw 1;\n} catch (x) {\n y(x), x = 2, y(x);\n}\nvar x;\n", + ], // bun: relocates var + ["try { throw 1 } catch (x) { var x = 2; y(x) }", "try {\n throw 1;\n} catch (x) {\n x = 2, y(x);\n}\nvar x;\n"], // bun: relocates var + ["try { throw 1 } catch (x) { var x = 2 }", "try {\n throw 1;\n} catch (x) {\n x = 2;\n}\nvar x;\n"], // bun: relocates var + ["try { throw 1 } catch (x) { eval('x') }", 'try {\n throw 1;\n} catch (x) {\n eval("x");\n}\n'], + ["if (y) try { throw 1 } catch (x) {} else eval('x')", 'if (y) try {\n throw 1;\n} catch {\n}\nelse eval("x");\n'], +]); + +check("TestMangleTry", [ + ["try { throw 0 } catch (e) { foo() }", "try {\n throw 0;\n} catch {\n foo();\n}\n"], + ["try {} catch (e) { var foo }", "var foo;\n"], // bun: relocates var + ["try {} catch (e) { foo() }", ""], + ["try {} catch (e) { foo() } finally {}", ""], + ["try {} finally { foo() }", "foo();\n"], + ["try {} catch (e) { foo() } finally { bar() }", "bar();\n"], + ["try {} finally { var x = foo() }", "x = foo();\nvar x;\n"], // bun: relocates var + ["try {} catch (e) { foo() } finally { var x = bar() }", "x = bar();\nvar x;\n"], // bun: relocates var + ["try {} finally { let x = foo() }", "{\n let x = foo();\n}\n"], + ["try {} catch (e) { foo() } finally { let x = bar() }", "{\n let x = bar();\n}\n"], + ["try { foo() } catch {}", "try {\n foo();\n} catch {\n}\n"], + ["try { foo() } catch {} finally {}", "try {\n foo();\n} catch {\n}\n"], + ["try { foo() } finally {}", "foo();\n"], + ["try { var x = foo() } catch {}", "try {\n x = foo();\n} catch {}\nvar x;\n"], // bun: relocates var + ["try { var x = foo() } catch {} finally {}", "try {\n x = foo();\n} catch {}\nvar x;\n"], // bun: relocates var + ["try { var x = foo() } finally {}", "x = foo();\nvar x;\n"], // bun: relocates var + ["try { let x = foo() } catch {}", "try {\n let x = foo();\n} catch {\n}\n"], + ["try { let x = foo() } catch {} finally {}", "try {\n let x = foo();\n} catch {\n}\n"], + ["try { let x = foo() } finally {}", "{\n let x = foo();\n}\n"], + ["x: try { while (true) ; break x } catch {}", "x: try {\n for (; ; ) ;\n break x;\n} catch {\n}\n"], + [ + "d: { e: { try { while (1) { break d } } catch { break e } } }", + "d:\n e:\n try {\n for (; ; )\n break d;\n } catch {\n break e;\n }\n", + ], +]); diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 743f883e7023..5fe057b8aa3e 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -4355,9 +4355,10 @@ console.log(foo, array); expectPrinted("typeof {x: sideEffect()}", "typeof { x: sideEffect() }"); expectPrinted("typeof class { static x = sideEffect(); }", "typeof class {\n static x = sideEffect();\n}"); - expectPrinted('typeof [] === "object"', 'typeof [] === "object"'); - expectPrinted("typeof {foo: 123} === typeof {bar: 123}", "typeof { foo: 123 } === typeof { bar: 123 }"); - expectPrinted("typeof {foo: 123} !== typeof 123", 'typeof { foo: 123 } !== "number"'); + // Both sides are known strings, so strict equality becomes loose equality. + expectPrinted('typeof [] === "object"', 'typeof [] == "object"'); + expectPrinted("typeof {foo: 123} === typeof {bar: 123}", "typeof { foo: 123 } == typeof { bar: 123 }"); + expectPrinted("typeof {foo: 123} !== typeof 123", 'typeof { foo: 123 } != "number"'); // `!` folds to a boolean only when the operand has no side effects or // can be proven removable. Side-effecting operands are left intact. From 36afccb3257799b502bf7d2336cdb0416f60fac4 Mon Sep 17 00:00:00 2001 From: robobun Date: Wed, 2 Sep 2026 05:09:10 +0000 Subject: [PATCH 02/11] minify: address review, refresh snapshots after rebase Fold Options::full_minify_syntax into P::full_minify_syntax so the React Compiler guard cannot be bypassed. Keep the new simplify_boolean rules and the dead catch marking to minified builds so plain transpiler output does not change. Leave a conditional alone when it is the operand of delete. Share one append_decls helper. Update the bytecode, source map and React SSR snapshots, and record every value in the StatementLevelMangling run. --- src/js_parser/p.rs | 24 +++++++++---- src/js_parser/parse/parse_entry.rs | 12 ------- src/js_parser/scan/scan_side_effects.rs | 22 ++++++++---- src/js_parser/visit/mangle.rs | 35 +++++++++---------- src/js_parser/visit/visit_expr.rs | 4 ++- src/js_parser/visit/visit_stmt.rs | 10 +++--- .../bundler/bundler_bytecode_portable.test.ts | 18 +++++----- test/bundler/bundler_edgecase.test.ts | 2 +- test/bundler/bundler_minify.test.ts | 9 +++-- test/bundler/bundler_npm.test.ts | 8 ++--- test/bundler/esbuild/js_parser_mangle.test.ts | 2 +- 11 files changed, 81 insertions(+), 65 deletions(-) diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index a73729339ccd..60b650ba7e2d 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -5730,13 +5730,25 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O true } - /// `Options::full_minify_syntax()` for this point of the visit. It is off - /// inside a function body the React Compiler may compile: the compiler sees - /// the body after its statements were visited, and it does not lower some - /// shapes the statement mangler produces (a `for (;;)` loop made from a - /// `while`, `ref.current == null && (ref.current = x)` made from an `if`). + /// `minify_syntax` for a build: `bun build`, bundled or with `--no-bundle`. + /// Every pass that merges or restructures statements (comma joins, `if` to + /// `&&`/`?:`, return and throw chains, `while` to `for`, arrow bodies to + /// expressions) checks this instead of `minify_syntax`. + /// + /// The runtime transpiler (`bun run`) also turns `minify_syntax` on, for + /// constant inlining, but it keeps one output statement per source statement + /// so that line numbers and `Function.prototype.toString()` stay close to + /// the source. + /// + /// It is also off inside a function body the React Compiler may compile: + /// the compiler sees the body after its statements were visited, and it + /// does not lower some shapes the statement mangler produces (a `for (;;)` + /// loop made from a `while`, `ref.current == null && (ref.current = x)` + /// made from an `if`). pub(crate) fn full_minify_syntax(&self) -> bool { - self.options.full_minify_syntax() && !self.in_react_compiler_candidate + self.options.features.minify_syntax + && (self.options.bundle || self.options.transform_only) + && !self.in_react_compiler_candidate } // TODO: diff --git a/src/js_parser/parse/parse_entry.rs b/src/js_parser/parse/parse_entry.rs index 7ef2a02b934c..7bdc46495349 100644 --- a/src/js_parser/parse/parse_entry.rs +++ b/src/js_parser/parse/parse_entry.rs @@ -268,18 +268,6 @@ impl<'a> Options<'a> { self.features.hash_for_runtime_transpiler(hasher); } - /// `minify_syntax` for a build: `bun build`, bundled or with `--no-bundle`. - /// - /// The runtime transpiler (`bun run`) also turns `minify_syntax` on, for - /// constant inlining, but it keeps one output statement per source statement - /// so that line numbers and `Function.prototype.toString()` stay close to - /// the source. Every pass that merges or restructures statements (comma - /// joins, `if` to `&&`/`?:`, return and throw chains, `while` to `for`, - /// arrow bodies to expressions) checks this instead of `minify_syntax`. - pub(crate) fn full_minify_syntax(&self) -> bool { - self.features.minify_syntax && (self.bundle || self.transform_only) - } - pub fn init(jsx: options::JSX::Pragma, loader: options::Loader) -> Options<'static> { // `macro_context` is `None` // (see field comment); caller overwrites before use. diff --git a/src/js_parser/scan/scan_side_effects.rs b/src/js_parser/scan/scan_side_effects.rs index b863b834668f..a51640b45ead 100644 --- a/src/js_parser/scan/scan_side_effects.rs +++ b/src/js_parser/scan/scan_side_effects.rs @@ -37,6 +37,10 @@ impl SideEffects { /// Simplify an expression that is only tested for truthiness, such as the /// test of an `if`. Port of esbuild's `SimplifyBooleanExpr`. + /// + /// Some callers run this without `minify_syntax`, so the rules that + /// rewrite operands or comparisons only run when minifying. The rest is + /// the behavior those callers had before. pub(crate) fn simplify_boolean<'a, const TS: bool, const SCAN: bool>( p: &mut P<'a, TS, SCAN>, expr: Expr, @@ -44,6 +48,7 @@ impl SideEffects { if !p.options.features.dead_code_elimination || !p.stack_check.is_safe_to_recurse() { return expr; } + let minify = p.options.features.minify_syntax; match expr.data { ExprData::EUnary(mut e) if e.op == Op::Code::UnNot => { @@ -63,7 +68,8 @@ impl SideEffects { | Op::Code::BinStrictNe | Op::Code::BinLooseEq | Op::Code::BinLooseNe => { - if let Some(r) = e.right.data.extract_numeric_value() + if minify + && let Some(r) = e.right.data.extract_numeric_value() && r == 0.0 && is_int32_or_uint32(&e.left.data) { @@ -82,8 +88,10 @@ impl SideEffects { Op::Code::BinLogicalAnd => { // "if (!!a && !!b)" => "if (a && b)" - e.left = Self::simplify_boolean(p, e.left); - e.right = Self::simplify_boolean(p, e.right); + if minify { + e.left = Self::simplify_boolean(p, e.left); + e.right = Self::simplify_boolean(p, e.right); + } if let Some(effects) = SideEffects::to_boolean(p, &e.right.data) && effects.value @@ -96,8 +104,10 @@ impl SideEffects { Op::Code::BinLogicalOr => { // "if (!!a || !!b)" => "if (a || b)" - e.left = Self::simplify_boolean(p, e.left); - e.right = Self::simplify_boolean(p, e.right); + if minify { + e.left = Self::simplify_boolean(p, e.left); + e.right = Self::simplify_boolean(p, e.right); + } if let Some(effects) = SideEffects::to_boolean(p, &e.right.data) && !effects.value @@ -111,7 +121,7 @@ impl SideEffects { _ => {} }, - ExprData::EIf(mut e) => { + ExprData::EIf(mut e) if minify => { // "if (a ? !!b : !!c)" => "if (a ? b : c)" e.yes = Self::simplify_boolean(p, e.yes); e.no = Self::simplify_boolean(p, e.no); diff --git a/src/js_parser/visit/mangle.rs b/src/js_parser/visit/mangle.rs index 5444778c9c6d..22105ab8986e 100644 --- a/src/js_parser/visit/mangle.rs +++ b/src/js_parser/visit/mangle.rs @@ -5,8 +5,8 @@ //! `mangle_stmts` is the tail of `visit_stmts`: it runs once every statement in //! a list has been visited, so every branch and loop body it looks at has //! already been mangled. The passes that merge or restructure statements check -//! `Options::full_minify_syntax()`; the ones that only fold constants run -//! whenever `minify_syntax` is on. +//! `P::full_minify_syntax()`; the ones that only fold constants run whenever +//! `minify_syntax` is on. use crate::p::P; use crate::parser::{StmtsKind, statement_cares_about_scope}; @@ -234,17 +234,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let prev_stmt = &mut output[prev_idx]; if let StmtData::SLocal(mut prev_local) = prev_stmt.data { if local.can_merge_with(&prev_local) { - // `Vec::append_slice` requires `T: Clone` - // but `G::Decl` lacks the derive (its fields are all - // `Copy`). Per-element bitwise copy instead. - // - // The parse pass allocates `decls` in the bump arena - // (`from_bump_slice` → `Origin::Borrowed`); promote to a - // global-heap buffer before growing it. - for d in local.decls.slice() { - // SAFETY: Decl is field-wise Copy (Binding, Option). - prev_local.decls.push(unsafe { core::ptr::read(d) }); - } + append_decls(&mut prev_local.decls, local.decls.slice()); continue; } } @@ -535,12 +525,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O && init_local.kind == LocalKind::KVar && init_local.origin == LocalOrigin::Normal { - for d in init_local.decls.slice() { - // SAFETY: Decl is field-wise Copy (Binding, Option). - prev_local - .decls - .push(unsafe { core::ptr::read(d) }); - } + append_decls( + &mut prev_local.decls, + init_local.decls.slice(), + ); s_for.init = Some(prev_stmt); output[prev_idx] = stmt; continue; @@ -1477,6 +1465,15 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } +/// `Vec::extend_from_slice` needs `T: Clone`, and `G::Decl` is field-wise `Copy` +/// without the derive, so each declaration is copied bitwise. +fn append_decls(dst: &mut G::DeclList, src: &[G::Decl]) { + for d in src { + // SAFETY: Decl is field-wise Copy (Binding, Option). + dst.push(unsafe { core::ptr::read(d) }); + } +} + pub(crate) fn is_jump_statement(data: StmtData) -> bool { matches!( data, diff --git a/src/js_parser/visit/visit_expr.rs b/src/js_parser/visit/visit_expr.rs index 3828fd0b9f13..36a2330b1cba 100644 --- a/src/js_parser/visit/visit_expr.rs +++ b/src/js_parser/visit/visit_expr.rs @@ -1495,6 +1495,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_delete_target = matches!(p.delete_target, Data::EIf(dt) if core::ptr::eq(&raw const *e_, &raw const *dt)); let prev_in_branch = p.in_branch_condition; p.in_branch_condition = true; @@ -1507,7 +1508,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let Some(side_effects) = SideEffects::to_boolean(p, &e_.test.data) else { p.visit_expr(&mut e_.yes); p.visit_expr(&mut e_.no); - if p.full_minify_syntax() { + // "delete (a ? b.c : b.c)" deletes nothing, "delete b.c" would. + if p.full_minify_syntax() && !is_delete_target { let result = p.mangle_if_expr(e.loc, e_); // "(a ? b.c : b.c)()" => "(0, b.c)()", not "b.c()" *e = if is_call_target && result.has_value_for_this_in_call() { diff --git a/src/js_parser/visit/visit_stmt.rs b/src/js_parser/visit/visit_stmt.rs index 8526169b1e8e..434897912cca 100644 --- a/src/js_parser/visit/visit_stmt.rs +++ b/src/js_parser/visit/visit_stmt.rs @@ -2056,10 +2056,12 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O if let Some(catch) = &mut data.catch { let old_is_control_flow_dead = p.is_control_flow_dead; - // If the try body is empty, then the catch body is dead. Dead code is - // stripped whenever dead_code_elimination is on, so only mark it when - // minifying to leave plain transpiler output alone. - if data.body.is_empty() && p.options.features.minify_syntax { + // If the try body is empty, then the catch body is dead. Only the + // build paths strip it, so plain transpiler output stays as written. + if data.body.is_empty() + && p.full_minify_syntax() + && p.options.features.dead_code_elimination + { p.is_control_flow_dead = true; } diff --git a/test/bundler/bundler_bytecode_portable.test.ts b/test/bundler/bundler_bytecode_portable.test.ts index a3debaf55203..ea2fcc3b0b73 100644 --- a/test/bundler/bundler_bytecode_portable.test.ts +++ b/test/bundler/bundler_bytecode_portable.test.ts @@ -474,24 +474,24 @@ describe("bytecode cache portability", () => { "sha256": "2a5d62fb4ca9d107e3a5bb2abe6a73f3c859a6f1a91c6c3d77653cb8c2053361", }, "bun build --bytecode --minify all.js": { - "js": "721d67b0c2135aad554c9962b4e4505a4d6856b50da537fee7566d43f6cfdbda", + "js": "84c5bbb4d0c2ab2c2663dbf749488d140e5087d29d1fd0bd08a0d4711e0cbb5d", "jsc": { - "bytes": 1998992, - "sha256": "f2631b3ba2b532e52183d5e6a44a7dbf5c0515b368d95fc692bf0e5ad15eb8f6", + "bytes": 2001536, + "sha256": "70815d92e6fb90269a845cdde7f9d98a388d4b3bc15aeb821c6f3c64ea2e1d29", }, }, "bun build --bytecode --minify features.js": { - "js": "d49da0aa39824bf9eba2af5d3a010525ad14ca5c1cedc0d8adcfdd5f4984a0d0", + "js": "c68a6bb1464d0cd4d51865b2fb477f5c6ed454d58461f5feb0c2273799cc27b6", "jsc": { - "bytes": 46152, - "sha256": "db36543ec2430a8cbdce13a6a980148f502e2c6b135cf95f62b6743c3fa30968", + "bytes": 46128, + "sha256": "82d1bd2621d2aaa23c0d80d8755608a3f4a44ed02c4ca7678f4d9d3031acc3a8", }, }, "bun build --bytecode --minify records.js": { - "js": "889cbb2c9525ff69a2676a6e81d97bb87760bdee65178b836c9c1d6808ac7c6e", + "js": "7dee3ebff15236bf392c940b63932d088b469049ac86aa91bb6833e44e9480d6", "jsc": { - "bytes": 89144, - "sha256": "3c3e86151fe78e6d56d7e7033dfa0e97b062af3b86d7ad75b723d91c77b11cba", + "bytes": 89128, + "sha256": "41851070b6000fb8cc321ebd5f0d2d96e9661c17e017553e73a23e565cb26fb2", }, }, "bun build --bytecode acorn/dist/acorn.mjs": { diff --git a/test/bundler/bundler_edgecase.test.ts b/test/bundler/bundler_edgecase.test.ts index c75c4e35fa3e..a8e4ca6230c9 100644 --- a/test/bundler/bundler_edgecase.test.ts +++ b/test/bundler/bundler_edgecase.test.ts @@ -1302,7 +1302,7 @@ describe("bundler", () => { snapshotSourceMap: { "entry.js.map": { files: ["../node_modules/react/index.js", "../entry.js"], - mappingsExactMatch: "klBACA,WAAW,IAAQ,EAAE,ICDrB,eACA,QAAQ,IAAI,CAAK", + mappingsExactMatch: "klBACA,WAAW,IAAQ,EAAE,ICDrB,aACA,QAAQ,IAAI,CAAK", }, }, }); diff --git a/test/bundler/bundler_minify.test.ts b/test/bundler/bundler_minify.test.ts index 5d97a313a60e..64e4713deefc 100644 --- a/test/bundler/bundler_minify.test.ts +++ b/test/bundler/bundler_minify.test.ts @@ -1689,9 +1689,10 @@ describe("bundler", () => { export function ternaryBool(a) { return a ? true : false } export function sameArgs(a, b, c, d) { return a ? b(c) : b(d) } export function exprStmts(a, b) { a(); b(); return 1 } + export function deleteCond(o, t) { delete (t ? o.b : o.b); return "b" in o } const out = []; - const log = v => out.push(v); + const log = (...v) => out.push(...v); log(ifElseToTernary(1), ifElseToTernary(0)); log(ifChain(null), ifChain({ x: 1 }), ifChain({})); { let n = 0; ifAnd(1, 1, () => n++); ifAnd(1, 0, () => n++); ifAnd(0, 1, () => n++); log(n); } @@ -1713,6 +1714,7 @@ describe("bundler", () => { log(ternaryBool(1), ternaryBool("")); log(sameArgs(true, x => x * 2, 1, 2), sameArgs(false, x => x * 2, 1, 2)); { const seen = []; log(exprStmts(() => seen.push("a"), () => seen.push("b")), seen.join()); } + log(deleteCond({ b: 1 }, true), deleteCond({ b: 1 }, false)); console.log(JSON.stringify(out)); `, }, @@ -1747,10 +1749,13 @@ describe("bundler", () => { ternaryBool: "return!!a", sameArgs: "return b(a?c:d)", exprStmts: "return a(),b(),1", + // `delete` of a conditional deletes nothing, so the conditional stays. + deleteCond: 'return delete(t?o.b:o.b),"b"in o', }); }, run: { - stdout: '[1,1,1,1,"b",1,3,1,null,2,true,null,2,true,1,"1,2",true,true,true,2,1]', + stdout: + '[1,2,1,2,3,1,1,"b",1,3,1,null,2,1,7,true,true,null,5,2,0,true,1,"1,2",true,false,true,true,false,true,false,2,4,1,"a,b",true,true]', }, }); diff --git a/test/bundler/bundler_npm.test.ts b/test/bundler/bundler_npm.test.ts index 9e3e4d25e58f..577558f1ce44 100644 --- a/test/bundler/bundler_npm.test.ts +++ b/test/bundler/bundler_npm.test.ts @@ -58,14 +58,14 @@ describe("bundler", () => { ["react.development.js:524:'getContextName'", "1:5578:at"], ["react.development.js:2495:'actScopeDepth'", "23:3981:or++"], ["react.development.js:696:''Component'", '1:7616:\'Component "%s"'], - ["entry.tsx:6:'\"Content-Type\"'", '100:18570:"Content-Type"'], - ["entry.tsx:11:''", "100:18824:void"], - ["entry.tsx:23:'await'", "100:18923:await"], + ["entry.tsx:6:'\"Content-Type\"'", '100:18564:"Content-Type"'], + ["entry.tsx:11:''", "100:18818:void"], + ["entry.tsx:23:'await'", "100:18917:await"], ], }, }, expectExactFilesize: { - "out/entry.js": 218955, + "out/entry.js": 218949, }, run: { stdout: "

Hello World

This is an example.

", diff --git a/test/bundler/esbuild/js_parser_mangle.test.ts b/test/bundler/esbuild/js_parser_mangle.test.ts index ed2e365ff86c..a4d9b34a8696 100644 --- a/test/bundler/esbuild/js_parser_mangle.test.ts +++ b/test/bundler/esbuild/js_parser_mangle.test.ts @@ -40,7 +40,7 @@ async function mangle(cases: Case[]): Promise { cmd: [bunExe(), "build", "--no-bundle", "--minify-syntax", "--outdir=out", ...Object.keys(files)], cwd: String(dir), env: bunEnv, - stdout: "pipe", + stdout: "ignore", stderr: "pipe", }); const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); From e54debc3a7345f8deeec0075b5cc0b8b1ed176db Mon Sep 17 00:00:00 2001 From: robobun Date: Wed, 2 Sep 2026 05:23:11 +0000 Subject: [PATCH 03/11] minify: join the loop test left-associatively, run the runtime test with -e --- src/js_parser/visit/mangle.rs | 27 ++++++++------------------- test/bundler/bundler_minify.test.ts | 12 ++++++------ 2 files changed, 14 insertions(+), 25 deletions(-) diff --git a/src/js_parser/visit/mangle.rs b/src/js_parser/visit/mangle.rs index 22105ab8986e..d077e34fcd61 100644 --- a/src/js_parser/visit/mangle.rs +++ b/src/js_parser/visit/mangle.rs @@ -4,9 +4,10 @@ //! //! `mangle_stmts` is the tail of `visit_stmts`: it runs once every statement in //! a list has been visited, so every branch and loop body it looks at has -//! already been mangled. The passes that merge or restructure statements check -//! `P::full_minify_syntax()`; the ones that only fold constants run whenever -//! `minify_syntax` is on. +//! already been mangled. The passes ported here check +//! `P::full_minify_syntax()`. The merges that predate the port (adjacent +//! declarations, `var f; f = 1`, single-use `let` inlining) and the constant +//! folding keep running whenever `minify_syntax` is on, as they did before. use crate::p::P; use crate::parser::{StmtsKind, statement_cares_about_scope}; @@ -1513,14 +1514,7 @@ pub(crate) fn mangle_for(s: &mut S::For, bump: &Bump) { _ => if_s.test.not(bump), }; s.test = Some(match s.test { - Some(test) => Expr::init( - E::Binary { - op: OpCode::BinLogicalAnd, - left: test, - right: not, - }, - test.loc, - ), + Some(test) => Expr::join_with_left_associative_op(OpCode::BinLogicalAnd, test, not), None => not, }); s.body = drop_first_statement(s.body, if_s.no); @@ -1534,14 +1528,9 @@ pub(crate) fn mangle_for(s: &mut S::For, bump: &Bump) { && break_s.label.is_none() { s.test = Some(match s.test { - Some(test) => Expr::init( - E::Binary { - op: OpCode::BinLogicalAnd, - left: test, - right: if_s.test, - }, - test.loc, - ), + Some(test) => { + Expr::join_with_left_associative_op(OpCode::BinLogicalAnd, test, if_s.test) + } None => if_s.test, }); s.body = drop_first_statement(s.body, Some(if_s.yes)); diff --git a/test/bundler/bundler_minify.test.ts b/test/bundler/bundler_minify.test.ts index 64e4713deefc..d60f6b0fdbae 100644 --- a/test/bundler/bundler_minify.test.ts +++ b/test/bundler/bundler_minify.test.ts @@ -1614,8 +1614,11 @@ describe("bundler", () => { // bun targets but never bundles. It must not restructure statements, so that // `Function.prototype.toString()` and line numbers stay close to the source. test("runtime transpiler keeps statement structure", async () => { - using dir = tempDir("minify-runtime-structure", { - "entry.js": /* js */ ` + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + /* js */ ` const foo = (a) => { return a + 1; }; function bar(a, b) { if (a) return b(); @@ -1627,10 +1630,7 @@ describe("bundler", () => { console.log(foo.toString()); console.log(bar.toString()); `, - }); - await using proc = Bun.spawn({ - cmd: [bunExe(), "entry.js"], - cwd: String(dir), + ], env: bunEnv, stdout: "pipe", stderr: "pipe", From 812da475df59b4d49bb76588636be62ff03cbf71 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 2 Sep 2026 06:20:00 +0000 Subject: [PATCH 04/11] minify: guard the int32 operand walk with the stack check --- src/js_parser/scan/scan_side_effects.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/js_parser/scan/scan_side_effects.rs b/src/js_parser/scan/scan_side_effects.rs index a51640b45ead..13f45f16d885 100644 --- a/src/js_parser/scan/scan_side_effects.rs +++ b/src/js_parser/scan/scan_side_effects.rs @@ -71,7 +71,7 @@ impl SideEffects { if minify && let Some(r) = e.right.data.extract_numeric_value() && r == 0.0 - && is_int32_or_uint32(&e.left.data) + && is_int32_or_uint32(&e.left.data, p.stack_check) { // If the left is guaranteed to be an integer (e.g. not NaN, // Infinity, or a non-numeric value) then a test against zero @@ -1124,17 +1124,24 @@ impl SideEffects { /// True when the expression always evaluates to an int32 or uint32, so that it /// is falsy exactly when it is zero. -fn is_int32_or_uint32(data: &ExprData) -> bool { +fn is_int32_or_uint32(data: &ExprData, stack_check: bun_core::StackCheck) -> bool { + if !stack_check.is_safe_to_recurse() { + return false; + } match data { ExprData::EBinary(e) => match e.op { // This is the only bitwise operator that can't return a bigint (because it throws instead) Op::Code::BinUShr => true, Op::Code::BinLogicalOr | Op::Code::BinLogicalAnd => { - is_int32_or_uint32(&e.left.data) && is_int32_or_uint32(&e.right.data) + is_int32_or_uint32(&e.left.data, stack_check) + && is_int32_or_uint32(&e.right.data, stack_check) } _ => false, }, - ExprData::EIf(e) => is_int32_or_uint32(&e.yes.data) && is_int32_or_uint32(&e.no.data), + ExprData::EIf(e) => { + is_int32_or_uint32(&e.yes.data, stack_check) + && is_int32_or_uint32(&e.no.data, stack_check) + } _ => false, } } From 76a69024a160620e88ca4c0d088394a98df4f3e6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 2 Sep 2026 06:46:38 +0000 Subject: [PATCH 05/11] ci: retrigger From 9d68e14307329566c540e73ee2e78cf1963c5b52 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 2 Sep 2026 06:50:39 +0000 Subject: [PATCH 06/11] test: run every mangleIf rewrite with a truthy and a falsy test Ported from the test added in #34826, which this PR supersedes. Each rewritten `if` form is checked for shape, then the bundle runs with both inputs and the observed side effects are compared with the original statement. `blocks` covers comma-joined branches, `retMixed` covers `if (a) return; else return x` at the end of a function body, and `emptyBoth` keeps the side effect of the test when both branches are empty. --- test/bundler/bundler_minify.test.ts | 50 +++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/test/bundler/bundler_minify.test.ts b/test/bundler/bundler_minify.test.ts index d60f6b0fdbae..a32c63009ab0 100644 --- a/test/bundler/bundler_minify.test.ts +++ b/test/bundler/bundler_minify.test.ts @@ -1759,6 +1759,56 @@ describe("bundler", () => { }, }); + // Every `if` rewrite runs with a truthy and a falsy test. The side effects + // of the test and of each branch must match the original `if`, and + // comma-joined branches stay grouped inside the conditional. + itBundled("minify/IfStatementMangling", { + files: { + "/entry.js": /* js */ ` + const out = []; + function both(a){if(a)out.push("y"+a);else out.push("n"+a);} + function yes(a){if(a)out.push("Y"+a);} + function notYes(a){if(!a)out.push("N"+a);} + function notBoth(a){if(!a)out.push("b"+a);else out.push("c"+a);} + function blocks(a){if(a){out.push("x"+a);out.push("y"+a);}else{out.push("z"+a);out.push("w"+a);}} + function retBoth(a){if(a)return "r1";else return "r2";} + function retMixed(a){if(a)return;else return "m";} + function throwBoth(a){try{if(a)throw "t1";else throw "t2";}catch(e){return e;}} + function emptyYes(a){if(a){}else out.push("E"+a);} + function emptyBoth(a){let n=0;if((n++,a)){}else{};out.push("eb"+n);} + + for (const v of [0, 1]) { + both(v); yes(v); notYes(v); notBoth(v); blocks(v); + out.push(retBoth(v), String(retMixed(v)), throwBoth(v)); + emptyYes(v); emptyBoth(v); + } + console.log(out.join(",")); + `, + }, + minifySyntax: true, + minifyWhitespace: true, + onAfterBundle(api) { + const code = api.readFile("/out.js"); + const fns = [...code.matchAll(/function (\w+)\([^)]*\)\{(.*?)\}(?=function |for\()/g)].map(m => [m[1], m[2]]); + expect(Object.fromEntries(fns)).toEqual({ + both: 'a?out.push("y"+a):out.push("n"+a)', + yes: 'a&&out.push("Y"+a)', + notYes: 'a||out.push("N"+a)', + notBoth: 'a?out.push("c"+a):out.push("b"+a)', + blocks: 'a?(out.push("x"+a),out.push("y"+a)):(out.push("z"+a),out.push("w"+a))', + retBoth: 'return a?"r1":"r2"', + // `return a?void 0:"m"` at the end of a function body becomes an `if`. + retMixed: 'if(!a)return"m"', + throwBoth: 'try{throw a?"t1":"t2"}catch(e){return e}', + emptyYes: 'a||out.push("E"+a)', + emptyBoth: 'let n=0;n++,out.push("eb"+n)', + }); + }, + run: { + stdout: "n0,N0,b0,z0,w0,r2,m,t2,E0,eb1,y1,Y1,c1,x1,y1,r1,undefined,t1,eb1", + }, + }); + // A single-use `let` is substituted into whichever child of the next // expression reads it. `a` is used where the substitution must happen; // `keep` where a side effect in between must block it. From d16cdc83ff1c7bd970a836e964f32e0c4dbc016f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:44:08 +0000 Subject: [PATCH 07/11] minify: gate the statement passes on an explicit build-only flag `RuntimeFeatures.minify_syntax_statements` replaces the `minify_syntax && (bundle || transform_only)` predicate. The bundler's parse task derives it from `minify_syntax`, and `bun build --no-bundle` sets it through the transpiler options. `Bun.Transpiler`, `bun pm diff` and the runtime transpiler leave it off, so their output is unchanged from main. The `===` to `==`, `a === null || a === void 0`, logical chain and `simplify_boolean` rewrites move behind the same flag. The switch case bodies are mangled from `s_switch` once every case is visited, so the single-use inliner sees the uses in later cases (same fix as #40791). The runtime transpiler cache version goes to 29 for it. A tagged template whose tag was a conditional keeps `this` unbound: `(a ? o.m : o.m)\`x\`` prints as `(0, o.m)\`x\``. --- src/bundler/ParseTask.rs | 2 + src/bundler/options.rs | 6 ++ src/bundler/transpiler.rs | 1 + src/js_parser/p.rs | 31 +++--- src/js_parser/parse/parse_entry.rs | 1 + src/js_parser/parser.rs | 12 ++- src/js_parser/scan/scan_side_effects.rs | 12 +-- src/js_parser/visit/mod.rs | 8 ++ src/js_parser/visit/visit_binary.rs | 14 ++- src/js_parser/visit/visit_expr.rs | 23 ++-- src/js_parser/visit/visit_stmt.rs | 10 ++ src/jsc/RuntimeTranspilerCache.rs | 4 +- src/runtime/cli/build_command.rs | 3 + test/bundler/bundler_minify.test.ts | 101 +++++++++++++++++- .../react-compiler-fixtures.test.ts | 9 -- test/bundler/transpiler/transpiler.test.js | 7 +- 16 files changed, 187 insertions(+), 57 deletions(-) diff --git a/src/bundler/ParseTask.rs b/src/bundler/ParseTask.rs index bf59458d4fd6..6b4ad9243bc3 100644 --- a/src/bundler/ParseTask.rs +++ b/src/bundler/ParseTask.rs @@ -2517,6 +2517,8 @@ pub mod parse_worker { options::Format::InternalBakeDev => js_parser::options::Format::InternalBakeDev, }; opts.features.minify_syntax = topts.minify_syntax; + // A bundle is a build: it gets every syntax pass. + opts.features.minify_syntax_statements = topts.minify_syntax; opts.features.minify_identifiers = topts.minify_identifiers; opts.features.minify_keep_names = topts.keep_names; opts.features.minify_whitespace = topts.minify_whitespace; diff --git a/src/bundler/options.rs b/src/bundler/options.rs index 0197ab08af0d..51af96544ef2 100644 --- a/src/bundler/options.rs +++ b/src/bundler/options.rs @@ -1308,6 +1308,10 @@ pub struct BundleOptions<'a> { pub inline_entrypoint_import_meta_main: bool, pub minify_whitespace: bool, pub minify_syntax: bool, + /// Statement restructuring on top of `minify_syntax`. Set by `bun build` + /// only (the bundler's parse task derives it from `minify_syntax` itself). + /// See `bun_js_parser::RuntimeFeatures::minify_syntax_statements`. + pub minify_syntax_statements: bool, pub minify_identifiers: bool, pub keep_names: bool, pub dead_code_elimination: bool, @@ -1519,6 +1523,7 @@ impl<'a> BundleOptions<'a> { inline_entrypoint_import_meta_main: self.inline_entrypoint_import_meta_main, minify_whitespace: self.minify_whitespace, minify_syntax: self.minify_syntax, + minify_syntax_statements: self.minify_syntax_statements, minify_identifiers: self.minify_identifiers, keep_names: self.keep_names, dead_code_elimination: self.dead_code_elimination, @@ -1768,6 +1773,7 @@ impl<'a> BundleOptions<'a> { inline_entrypoint_import_meta_main: false, minify_whitespace: false, minify_syntax: false, + minify_syntax_statements: false, minify_identifiers: false, keep_names: false, dead_code_elimination: true, diff --git a/src/bundler/transpiler.rs b/src/bundler/transpiler.rs index 9c1b3090ee64..9c3713fd9d40 100644 --- a/src/bundler/transpiler.rs +++ b/src/bundler/transpiler.rs @@ -1644,6 +1644,7 @@ impl<'a> Transpiler<'a> { opts.features.inject_jest_globals = this_parse.inject_jest_globals; opts.features.minify_syntax = self.options.minify_syntax; + opts.features.minify_syntax_statements = self.options.minify_syntax_statements; opts.features.minify_identifiers = self.options.minify_identifiers; opts.features.dead_code_elimination = self.options.dead_code_elimination; opts.features.remove_cjs_module_wrapper = this_parse.remove_cjs_module_wrapper; diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 60b650ba7e2d..002338ad47e1 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -511,6 +511,10 @@ pub struct P<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> { pub(crate) stmt_expr_value: js_ast::ExprData, pub(crate) call_target: js_ast::ExprData, pub(crate) delete_target: js_ast::ExprData, + /// The tag of the tagged template being visited. A tag call binds `this` + /// like a method call, so a rewrite that turns a tag into a property access + /// must keep `this` undefined, as `call_target` does for a call. + pub(crate) template_tag: js_ast::ExprData, pub(crate) loop_body: js_ast::StmtData, pub(crate) module_scope: js_ast::StoreRef, pub(crate) module_scope_directive_loc: bun_ast::Loc, @@ -5730,24 +5734,20 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O true } - /// `minify_syntax` for a build: `bun build`, bundled or with `--no-bundle`. - /// Every pass that merges or restructures statements (comma joins, `if` to - /// `&&`/`?:`, return and throw chains, `while` to `for`, arrow bodies to - /// expressions) checks this instead of `minify_syntax`. + /// `minify_syntax_statements` (see `RuntimeFeatures`): every pass that + /// merges or restructures statements (comma joins, `if` to `&&`/`?:`, + /// return and throw chains, `while` to `for`, arrow bodies to expressions) + /// and every expression rewrite that changes how a function prints checks + /// this instead of `minify_syntax`. /// - /// The runtime transpiler (`bun run`) also turns `minify_syntax` on, for - /// constant inlining, but it keeps one output statement per source statement - /// so that line numbers and `Function.prototype.toString()` stay close to - /// the source. - /// - /// It is also off inside a function body the React Compiler may compile: - /// the compiler sees the body after its statements were visited, and it - /// does not lower some shapes the statement mangler produces (a `for (;;)` - /// loop made from a `while`, `ref.current == null && (ref.current = x)` - /// made from an `if`). + /// It is off inside a function body the React Compiler may compile: the + /// compiler sees the body after its statements were visited, and it does + /// not lower some shapes the statement mangler produces (a `for (;;)` loop + /// made from a `while`, `ref.current == null && (ref.current = x)` made + /// from an `if`). pub(crate) fn full_minify_syntax(&self) -> bool { self.options.features.minify_syntax - && (self.options.bundle || self.options.transform_only) + && self.options.features.minify_syntax_statements && !self.in_react_compiler_candidate } @@ -9161,6 +9161,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O call_target: null_expr_data(), delete_target: null_expr_data(), + template_tag: null_expr_data(), stmt_expr_value: null_expr_data(), loop_body: null_stmt_data(), define, diff --git a/src/js_parser/parse/parse_entry.rs b/src/js_parser/parse/parse_entry.rs index 7bdc46495349..a08192e7bb68 100644 --- a/src/js_parser/parse/parse_entry.rs +++ b/src/js_parser/parse/parse_entry.rs @@ -194,6 +194,7 @@ impl<'a> Options<'a> { no_macros: f.no_macros, commonjs_named_exports: f.commonjs_named_exports, minify_syntax: f.minify_syntax, + minify_syntax_statements: f.minify_syntax_statements, minify_identifiers: f.minify_identifiers, minify_keep_names: f.minify_keep_names, minify_whitespace: f.minify_whitespace, diff --git a/src/js_parser/parser.rs b/src/js_parser/parser.rs index 3937affd4b9f..2cd2242d6488 100644 --- a/src/js_parser/parser.rs +++ b/src/js_parser/parser.rs @@ -199,6 +199,14 @@ pub mod Runtime { pub commonjs_named_exports: bool, pub minify_syntax: bool, + /// The rest of esbuild's `--minify-syntax`: statement restructuring + /// (`if` chains to `?:`/`&&`/`||`, `while` to `for`, merged returns) + /// and the expression rewrites that change how a function prints + /// (`===` to `==`, `a === null || a === void 0` to `a == null`). + /// Only `bun build` sets it. `minify_syntax` alone keeps one output + /// statement per source statement, which `bun run` relies on for + /// source maps, coverage and `Function.prototype.toString()`. + pub minify_syntax_statements: bool, pub minify_identifiers: bool, /// Preserve function/class names during minification (CLI: --keep-names) pub minify_keep_names: bool, @@ -289,6 +297,7 @@ pub mod Runtime { no_macros: false, commonjs_named_exports: true, minify_syntax: false, + minify_syntax_statements: false, minify_identifiers: false, minify_keep_names: false, minify_whitespace: false, @@ -359,13 +368,14 @@ pub mod Runtime { pub(crate) fn hash_for_runtime_transpiler(&self, hasher: &mut Wyhash) { debug_assert!(self.runtime_transpiler_cache.is_some()); - let bools: [bool; 17] = [ + let bools: [bool; 18] = [ self.top_level_await, self.auto_import_jsx, self.allow_runtime, self.inlining, self.commonjs_named_exports, self.minify_syntax, + self.minify_syntax_statements, self.minify_identifiers, self.minify_keep_names, self.dead_code_elimination, diff --git a/src/js_parser/scan/scan_side_effects.rs b/src/js_parser/scan/scan_side_effects.rs index 13f45f16d885..802a7d3cec25 100644 --- a/src/js_parser/scan/scan_side_effects.rs +++ b/src/js_parser/scan/scan_side_effects.rs @@ -39,8 +39,8 @@ impl SideEffects { /// test of an `if`. Port of esbuild's `SimplifyBooleanExpr`. /// /// Some callers run this without `minify_syntax`, so the rules that - /// rewrite operands or comparisons only run when minifying. The rest is - /// the behavior those callers had before. + /// rewrite operands or comparisons only run for a minified build. The + /// rest is the behavior those callers had before. pub(crate) fn simplify_boolean<'a, const TS: bool, const SCAN: bool>( p: &mut P<'a, TS, SCAN>, expr: Expr, @@ -48,7 +48,7 @@ impl SideEffects { if !p.options.features.dead_code_elimination || !p.stack_check.is_safe_to_recurse() { return expr; } - let minify = p.options.features.minify_syntax; + let minify = p.full_minify_syntax(); match expr.data { ExprData::EUnary(mut e) if e.op == Op::Code::UnNot => { @@ -408,9 +408,7 @@ impl SideEffects { // If this is a boolean logical operation and the result is unused, then // we know the left operand will only be used for its boolean value and // can be simplified under that assumption - if bin.op != Op::Code::BinNullishCoalescing - && p.options.features.minify_syntax - { + if bin.op != Op::Code::BinNullishCoalescing && p.full_minify_syntax() { bin.left = Self::simplify_boolean(p, bin.left); } @@ -426,7 +424,7 @@ impl SideEffects { } // Try to take advantage of the optional chain operator to shorten code - if p.options.features.minify_syntax + if p.full_minify_syntax() && let ExprData::EBinary(binary) = bin.left.data // "a != null && a.b()" => "a?.b()" // "a == null || a.b()" => "a?.b()" diff --git a/src/js_parser/visit/mod.rs b/src/js_parser/visit/mod.rs index 7e768ea1eec0..e3047ce39050 100644 --- a/src/js_parser/visit/mod.rs +++ b/src/js_parser/visit/mod.rs @@ -1748,6 +1748,14 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } p.in_react_compiler_candidate = old_in_react_compiler_candidate; + // A case body is one slice of the switch block scope, so a `let` or + // `const` declared here can still be used by a later case that has not + // been visited yet. `s_switch` mangles every case body once all of them + // have been visited and the use counts are final. + if kind == StmtsKind::SwitchStmt { + return Ok(()); + } + if !p.options.features.minify_syntax || !p.options.features.dead_code_elimination { return Ok(()); } diff --git a/src/js_parser/visit/visit_binary.rs b/src/js_parser/visit/visit_binary.rs index 39146099808f..2a2999fa460f 100644 --- a/src/js_parser/visit/visit_binary.rs +++ b/src/js_parser/visit/visit_binary.rs @@ -344,7 +344,9 @@ impl BinaryExpressionVisitor { } // "typeof x === 'string'" => "typeof x == 'string'" - if SideEffects::can_change_strict_to_loose(&e_.left.data, &e_.right.data) { + if p.full_minify_syntax() + && SideEffects::can_change_strict_to_loose(&e_.left.data, &e_.right.data) + { e_.op = Op::Code::BinLooseEq; } } @@ -399,7 +401,9 @@ impl BinaryExpressionVisitor { } // "typeof x !== 'string'" => "typeof x != 'string'" - if SideEffects::can_change_strict_to_loose(&e_.left.data, &e_.right.data) { + if p.full_minify_syntax() + && SideEffects::can_change_strict_to_loose(&e_.left.data, &e_.right.data) + { e_.op = Op::Code::BinLooseNe; } } @@ -427,7 +431,7 @@ impl BinaryExpressionVisitor { } } - if p.options.features.minify_syntax { + if p.full_minify_syntax() { // "a ?? (b ?? c)" => "a ?? b ?? c" if let ExprData::EBinary(right) = e_.right.data && right.op == Op::Code::BinNullishCoalescing @@ -463,7 +467,7 @@ impl BinaryExpressionVisitor { } } - if p.options.features.minify_syntax { + if p.full_minify_syntax() { // "a || (b || c)" => "a || b || c" if let ExprData::EBinary(right) = e_.right.data && right.op == Op::Code::BinLogicalOr @@ -508,7 +512,7 @@ impl BinaryExpressionVisitor { } } - if p.options.features.minify_syntax { + if p.full_minify_syntax() { // "a && (b && c)" => "a && b && c" if let ExprData::EBinary(right) = e_.right.data && right.op == Op::Code::BinLogicalAnd diff --git a/src/js_parser/visit/visit_expr.rs b/src/js_parser/visit/visit_expr.rs index 36a2330b1cba..e538dc613861 100644 --- a/src/js_parser/visit/visit_expr.rs +++ b/src/js_parser/visit/visit_expr.rs @@ -686,8 +686,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 @@ -1496,6 +1497,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let is_call_target = matches!(p.call_target, Data::EIf(ct) if core::ptr::eq(&raw const *e_, &raw const *ct)); let is_delete_target = matches!(p.delete_target, Data::EIf(dt) if core::ptr::eq(&raw const *e_, &raw const *dt)); + 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; @@ -1511,8 +1513,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // "delete (a ? b.c : b.c)" deletes nothing, "delete b.c" would. if p.full_minify_syntax() && !is_delete_target { let result = p.mangle_if_expr(e.loc, e_); - // "(a ? b.c : b.c)()" => "(0, b.c)()", not "b.c()" - *e = if is_call_target && result.has_value_for_this_in_call() { + // "(a ? b.c : b.c)()" => "(0, b.c)()", not "b.c()". A tagged + // template binds `this` the same way. + *e = if (is_call_target || is_template_tag) && result.has_value_for_this_in_call() { p.new_expr(E::Number::new(0.0), result.loc) .join_with_comma(result) } else { @@ -2725,10 +2728,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // Restore now so the stack-local pointer never escapes this frame. p.react_refresh.hook_ctx_storage = prev_hook_ctx; - // Remove unused function names when minifying (only when bundling is enabled) - // unless --keep-names is specified - if p.options.features.minify_syntax - && p.options.bundle + // Remove unused function names when minifying unless --keep-names is specified + if p.full_minify_syntax() && !p.options.features.minify_keep_names // SAFETY: current_scope is a live arena ptr while the parser exists. && !p.current_scope().contains_direct_eval @@ -2794,10 +2795,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O return; } - // Remove unused class names when minifying (only when bundling is enabled) - // unless --keep-names is specified - if p.options.features.minify_syntax - && p.options.bundle + // Remove unused class names when minifying unless --keep-names is specified + if p.full_minify_syntax() && !p.options.features.minify_keep_names // SAFETY: current_scope is a live arena ptr while the parser exists. && !p.current_scope().contains_direct_eval diff --git a/src/js_parser/visit/visit_stmt.rs b/src/js_parser/visit/visit_stmt.rs index 434897912cca..7dfb808299b2 100644 --- a/src/js_parser/visit/visit_stmt.rs +++ b/src/js_parser/visit/visit_stmt.rs @@ -2191,6 +2191,16 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } p.fn_or_arrow_data_visit.is_inside_switch = old_is_inside_switch; + // All case bodies share this block scope, so a `let`/`const` + // declared in one case can be used by a later case. Only now, with + // every case visited, are the use counts final. + if p.options.features.minify_syntax && p.options.features.dead_code_elimination { + for i in 0..cases.len() { + let body = stmts_to_list(p.arena, cases[i].body); + cases[i].body = list_to_stmts(p.mangle_stmts(body, StmtsKind::SwitchStmt)); + } + } + for i in 0..cases.len() { if p.should_lower_using_declarations(cases[i].body.slice()) { lowered_using = true; diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index 6ad49e143f1a..5bbf9827aca5 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -56,7 +56,9 @@ bun_core::declare_scope!(cache, visible); /// 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: the define table and `--drop` entries participate in the features hash. -const EXPECTED_VERSION: u32 = 28; +/// Version 29: the single-use `let`/`const` inliner no longer deletes a declaration +/// in one switch case clause that a later clause still uses. +const EXPECTED_VERSION: u32 = 29; /// 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/runtime/cli/build_command.rs b/src/runtime/cli/build_command.rs index f7ae8991b9ea..a8acc85a3f75 100644 --- a/src/runtime/cli/build_command.rs +++ b/src/runtime/cli/build_command.rs @@ -210,6 +210,9 @@ impl BuildCommand { this_transpiler.options.code_splitting = ctx.bundler_options.code_splitting; this_transpiler.options.split_require = ctx.bundler_options.split_require; this_transpiler.options.minify_syntax = ctx.bundler_options.minify_syntax; + // `bun build --no-bundle` parses through the transpiler, not the + // bundler's parse task, so the statement passes are turned on here. + this_transpiler.options.minify_syntax_statements = ctx.bundler_options.minify_syntax; this_transpiler.options.minify_whitespace = ctx.bundler_options.minify_whitespace; this_transpiler.options.minify_identifiers = ctx.bundler_options.minify_identifiers; this_transpiler.options.keep_names = ctx.bundler_options.keep_names; diff --git a/test/bundler/bundler_minify.test.ts b/test/bundler/bundler_minify.test.ts index a32c63009ab0..f3d61cbc1f81 100644 --- a/test/bundler/bundler_minify.test.ts +++ b/test/bundler/bundler_minify.test.ts @@ -1611,8 +1611,9 @@ describe("bundler", () => { }); // The runtime transpiler (`bun run`/`bun test`) forces minify-syntax on for - // bun targets but never bundles. It must not restructure statements, so that - // `Function.prototype.toString()` and line numbers stay close to the source. + // bun targets but never bundles. It must not restructure statements or + // rewrite comparisons, so that `Function.prototype.toString()` and line + // numbers stay close to the source. test("runtime transpiler keeps statement structure", async () => { await using proc = Bun.spawn({ cmd: [ @@ -1625,6 +1626,9 @@ describe("bundler", () => { if (!a) b(); else b(); while (a) if (b()) break; try { b() } catch (e) { b() } + if (typeof a === "string" || a === null || a === void 0) b(); + if ((a >>> 0) !== 0) b(); + a != null && a.b(); return 0; } console.log(foo.toString()); @@ -1656,6 +1660,11 @@ describe("bundler", () => { } catch (e) { b(); } + if (typeof a === "string" || a === null || a === void 0) + b(); + if (a >>> 0 !== 0) + b(); + a != null && a.b(); return 0; }" `); @@ -1682,6 +1691,7 @@ describe("bundler", () => { export function optChain(a) { return a != null ? a.b : undefined } export function nullish(a, b) { return a != null ? a : b } export function callTarget(o, a) { return (a ? o.m : o.m)() } + export function tagTarget(o, a) { return (a ? o.m : o.m)\`x\` } export function implicitReturn(y, z) { if (y) return; z() } export function loopContinue(xs, f) { for (const x of xs) { if (x > 2) continue; f(x) } } export function typeofEq(t) { return typeof t === "string" } @@ -1740,8 +1750,11 @@ describe("bundler", () => { negZero: "return b?-0:0", optChain: "return a?.b", nullish: "return a??b", - // The comma keeps `this` unbound, as it was for the conditional. + // The comma keeps `this` unbound, as it was for the conditional. The + // bundle is right for the tag too, but the runtime transpiler drops + // that comma today (#40829), so only the shape is checked for it. callTarget: "return(0,o.m)()", + tagTarget: "return(0,o.m)`x`", implicitReturn: "y||z()", loopContinue: "for(let x of xs)x>2||f(x)", typeofEq: 'return typeof t=="string"', @@ -1882,6 +1895,88 @@ describe("bundler", () => { expect(code.match(/let keep = /g)).toHaveLength(10); }, }); + + // A `let`/`const` declared in one case clause is scoped to the whole switch + // block, so a later clause can still read or assign it. The single-use + // inliner used to run per clause and deleted `tag`, `s` and `n` after it + // had only seen the uses in the declaring clause. `only` has its single use + // in the same clause and must still be inlined. + itBundled("minify/SwitchCaseDeclUsedInLaterCase", { + files: { + "/entry.js": /* js */ ` + function capture(v) { return v; } + function fallthrough(k) { + switch (k) { + case 1: const tag = { id: 1 }; capture(tag); + case 2: return typeof tag; + } + } + function defaultClause() { + switch (1) { + case 1: let s = "s1"; capture(s.length); + default: { return s + "!"; } + } + } + function reassigned(k) { + switch (k) { + case 1: let n = k; capture(n); + case 2: n = 5; return n; + } + } + function sameClause(v) { + switch (v) { + case 1: const only = v; return capture(only + 1); + } + } + console.log(JSON.stringify([fallthrough(1), defaultClause(), reassigned(1), sameClause(1)])); + `, + }, + capture: ["v", "tag", "s.length", "n", "v + 1"], + minifySyntax: true, + minifyIdentifiers: false, + target: "bun", + run: { + stdout: '["object","s1!",5,2]', + }, + }); +}); + +// The runtime transpiler runs the same single-use inliner. A `const` declared +// in one case clause and read after a fall-through into the next clause must +// keep its declaration. +test("runtime transpiler keeps a switch case declaration that a later case reads", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + /* js */ ` + function f(k) { + switch (k) { + case 1: const tag = { id: 1 }; use(tag); + case 2: return typeof tag; + } + } + function g() { + switch (1) { + case 1: let s = "s1"; use(s.length); + default: { return s + "!"; } + } + } + function use(v) { return v; } + let r; + try { r = g(); } catch (e) { r = e.constructor.name; } + console.log(JSON.stringify([f(1), r])); + `, + ], + env: bunEnv, + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toBe('["object","s1!"]\n'); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); }); // The runtime transpiler (`bun run`/`bun test`) implicitly enables diff --git a/test/bundler/transpiler/react-compiler-fixtures.test.ts b/test/bundler/transpiler/react-compiler-fixtures.test.ts index a90f5d3ea775..38f9623460cf 100644 --- a/test/bundler/transpiler/react-compiler-fixtures.test.ts +++ b/test/bundler/transpiler/react-compiler-fixtures.test.ts @@ -222,15 +222,6 @@ const TODO: Record = Object.assign(Object.create(null), {}); // `__proto__: null`: a fixture named "constructor" exists. const MINIFY_SYNTAX_DIVERGENCE: Record = Object.assign(Object.create(null), { - // minify.syntax drops the unreferenced name from `function formatWithUnit() {}`, - // making it eligible for the compiler's function-outlining pass (upstream only - // outlines anonymous function expressions). The outlined module-level function - // is referentially stable with no memo slot, so `_c(1)` is legitimately gone. - // This is better output, not a bailout. - "new-mutability/repro-destructure-from-prop-with-default-value": { - compiledFunctions: 0, - reason: "anonymized function expression becomes outlineable; 0 slots needed", - }, // minify.syntax constant-folds `const x = 0` into the `useMemo` dependency // array, turning `[x]` into `[0]`; the manual-memoization parser only accepts // identifier / member expressions as dependencies, so it errors. This is a diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 5fe057b8aa3e..743f883e7023 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -4355,10 +4355,9 @@ console.log(foo, array); expectPrinted("typeof {x: sideEffect()}", "typeof { x: sideEffect() }"); expectPrinted("typeof class { static x = sideEffect(); }", "typeof class {\n static x = sideEffect();\n}"); - // Both sides are known strings, so strict equality becomes loose equality. - expectPrinted('typeof [] === "object"', 'typeof [] == "object"'); - expectPrinted("typeof {foo: 123} === typeof {bar: 123}", "typeof { foo: 123 } == typeof { bar: 123 }"); - expectPrinted("typeof {foo: 123} !== typeof 123", 'typeof { foo: 123 } != "number"'); + expectPrinted('typeof [] === "object"', 'typeof [] === "object"'); + expectPrinted("typeof {foo: 123} === typeof {bar: 123}", "typeof { foo: 123 } === typeof { bar: 123 }"); + expectPrinted("typeof {foo: 123} !== typeof 123", 'typeof { foo: 123 } !== "number"'); // `!` folds to a boolean only when the operand has no side effects or // can be proven removable. Side-effecting operands are left intact. From 5e68c7da05edd425f54881f8a14f067df4053027 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:08:52 +0000 Subject: [PATCH 08/11] minify: bound the nested jump and return chains at 128 levels A run of `if (x) return;` statements nests into one `&&` chain and a run of `if (x) return y;` into one `?:` chain, one level per statement. esbuild has no bound and goes quadratic on long runs, and the chains it builds are as deep as the run, which the recursive walkers and the printer here cannot take. Past `MAX_MANGLE_NESTING` the rest of the list stays as written. --- src/js_parser/p.rs | 4 +++ src/js_parser/visit/mangle.rs | 23 ++++++++++++-- test/bundler/bundler_minify.test.ts | 49 +++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 3 deletions(-) diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 002338ad47e1..df565f4a2131 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -445,6 +445,9 @@ pub struct P<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> { /// True while visiting the body of a function the React Compiler may /// compile. See `full_minify_syntax`. pub(crate) in_react_compiler_candidate: bool, + /// Nesting of the `if (x) return; rest` to `if (!x) { rest }` rewrite in + /// `mangle_stmts`, bounded by `MAX_MANGLE_NESTING`. + pub(crate) mangle_jump_depth: u32, /// Compiled args/flags written by the `visit_stmts` hook for `visit_func` / /// arrow-visit to apply to the original `G::Fn` / `E::Arrow`. pub(crate) react_compiler_result: Option, @@ -9254,6 +9257,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O react_compiler_in_react_hoc: false, react_compiler_pending: None, in_react_compiler_candidate: false, + mangle_jump_depth: 0, react_compiler_result: None, server_components_wrap_ref: Ref::NONE, jest: Jest::default(), diff --git a/src/js_parser/visit/mangle.rs b/src/js_parser/visit/mangle.rs index d077e34fcd61..da4146e7429a 100644 --- a/src/js_parser/visit/mangle.rs +++ b/src/js_parser/visit/mangle.rs @@ -24,6 +24,14 @@ use bun_collections::VecExt; type ListManaged<'bump, T> = BumpVec<'bump, T>; +/// How many `if (x) return;` statements in one list become one nested `&&` +/// chain, and how many `if (a) return b;` statements become one `?:` chain. +/// Past this the rest of the list stays as it is. esbuild has no bound and +/// goes quadratic on such lists (11 s and 11 GB for 16,000 statements), and +/// the chains it builds are as deep as the list, which the recursive walkers +/// and the printer here cannot take. +const MAX_MANGLE_NESTING: u32 = 128; + impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_ONLY> { /// Merge and simplify a list of visited statements. Port of esbuild's /// `mangleStmts`. The caller checks `minify_syntax` and @@ -356,7 +364,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O _ => false, }; - if optimize_implicit_jump && p.stack_check.is_safe_to_recurse() { + if optimize_implicit_jump + && p.mangle_jump_depth < MAX_MANGLE_NESTING + && p.stack_check.is_safe_to_recurse() + { let mut body: ListManaged<'a, Stmt> = ListManaged::with_capacity_in(1 + stmts.len() - i, p.arena); if let Some(no) = s_if.no { @@ -378,7 +389,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // if (a(() => b)) { let b; } // if !stmts_care_about_scope(&body) { + p.mangle_jump_depth += 1; let body = p.mangle_stmts(body, kind); + p.mangle_jump_depth -= 1; let body_loc = body.first().map_or(s_if.yes.loc, |s| s.loc); let test = SideEffects::simplify_boolean(p, s_if.test.not(p.arena)); let yes = @@ -627,6 +640,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // "if (a) return b; if (c) return d; return e;" => "return a ? b : c ? d : e;" let mut last_loc = last_stmt.loc; let mut last_value = last_return.value; + let mut nesting = 0; while output.len() >= 2 { let prev_index = output.len() - 2; let prev_stmt = output[prev_index]; @@ -642,9 +656,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } StmtData::SIf(mut prev_if) => { // The previous statement must be an if statement with no else clause - if prev_if.no.is_some() { + if prev_if.no.is_some() || nesting >= MAX_MANGLE_NESTING { break; } + nesting += 1; // The then clause must be a return let StmtData::SReturn(prev_return) = prev_if.yes.data else { break; @@ -681,6 +696,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } else if let StmtData::SThrow(last_throw) = last_stmt.data { // "if (a) throw b; if (c) throw d; throw e;" => "throw a ? b : c ? d : e;" let mut last_value = last_throw.value; + let mut nesting = 0; while output.len() >= 2 { let prev_index = output.len() - 2; let prev_stmt = output[prev_index]; @@ -694,9 +710,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } StmtData::SIf(mut prev_if) => { // The previous statement must be an if statement with no else clause - if prev_if.no.is_some() { + if prev_if.no.is_some() || nesting >= MAX_MANGLE_NESTING { break; } + nesting += 1; // The then clause must be a throw let StmtData::SThrow(prev_throw) = prev_if.yes.data else { break; diff --git a/test/bundler/bundler_minify.test.ts b/test/bundler/bundler_minify.test.ts index f3d61cbc1f81..5610f3a4ddd6 100644 --- a/test/bundler/bundler_minify.test.ts +++ b/test/bundler/bundler_minify.test.ts @@ -1939,6 +1939,55 @@ describe("bundler", () => { stdout: '["object","s1!",5,2]', }, }); + + // A long run of `if (x) return;` nests into one `&&` chain and a long run + // of `if (x) return y;` into one `?:` chain. Both stop at 128 levels so the + // recursive walkers and the printer stay within the stack. The rest of the + // list stays as written (in `early` it joins the flat `||` chain that the + // same-jump merge builds), the build succeeds, and the output runs. + const longIfs = (n: number, body: (i: number) => string) => + Array.from({ length: n }, (_, i) => `if (a[${i}]) ${body(i)};`).join("\n"); + itBundled("minify/LongJumpChainsStayBounded", { + files: { + "/entry.js": /* js */ ` + function early(a) { ${longIfs(600, () => "return")} return "end"; } + function lookup(a) { ${longIfs(600, i => `return ${i}`)} return -1; } + function raise(a) { ${longIfs(600, i => `throw ${i}`)} throw -1; } + const hit = i => { const a = []; a[i] = 1; return a; }; + let thrown; + try { raise(hit(599)); } catch (e) { thrown = e; } + console.log(JSON.stringify([early(hit(599)), early([]), lookup(hit(0)), lookup(hit(599)), lookup([]), thrown])); + `, + }, + minifySyntax: true, + minifyWhitespace: true, + minifyIdentifiers: false, + onAfterBundle(api) { + const code = api.readFile("/out.js"); + const fn = (name: string) => code.match(new RegExp(`function ${name}\\(a\\)\\{(.*?)\\}(?:function |var )`))![1]; + const count = (s: string, re: RegExp) => (s.match(re) ?? []).length; + expect({ + earlyAnd: count(fn("early"), /&&/g), + earlyOr: count(fn("early"), /\|\|/g), + earlyTernary: count(fn("early"), /\?/g), + lookupIfs: count(fn("lookup"), /if\(a\[\d+\]\)/g), + lookupTernary: count(fn("lookup"), /\?/g), + raiseIfs: count(fn("raise"), /if\(a\[\d+\]\)/g), + raiseTernary: count(fn("raise"), /\?/g), + }).toEqual({ + earlyAnd: 127, + earlyOr: 471, + earlyTernary: 1, + lookupIfs: 472, + lookupTernary: 128, + raiseIfs: 472, + raiseTernary: 128, + }); + }, + run: { + stdout: '[null,"end",0,599,-1,599]', + }, + }); }); // The runtime transpiler runs the same single-use inliner. A `const` declared From cfd7fd381d2a923f1b64965ee6ef493cd6a2f71d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:24:27 +0000 Subject: [PATCH 09/11] minify: keep this unbound for a constant-folded conditional template tag --- src/js_parser/visit/visit_expr.rs | 4 ++-- test/bundler/bundler_minify.test.ts | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/js_parser/visit/visit_expr.rs b/src/js_parser/visit/visit_expr.rs index e538dc613861..9c72f4856fa6 100644 --- a/src/js_parser/visit/visit_expr.rs +++ b/src/js_parser/visit/visit_expr.rs @@ -1544,7 +1544,7 @@ 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() { + 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); @@ -1571,7 +1571,7 @@ 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() { + 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/test/bundler/bundler_minify.test.ts b/test/bundler/bundler_minify.test.ts index 5610f3a4ddd6..0792cfa6ee99 100644 --- a/test/bundler/bundler_minify.test.ts +++ b/test/bundler/bundler_minify.test.ts @@ -1692,6 +1692,7 @@ describe("bundler", () => { export function nullish(a, b) { return a != null ? a : b } export function callTarget(o, a) { return (a ? o.m : o.m)() } export function tagTarget(o, a) { return (a ? o.m : o.m)\`x\` } + export function tagTargetFolded(o) { return (1 ? o.m : 2)\`x\` } export function implicitReturn(y, z) { if (y) return; z() } export function loopContinue(xs, f) { for (const x of xs) { if (x > 2) continue; f(x) } } export function typeofEq(t) { return typeof t === "string" } @@ -1755,6 +1756,7 @@ describe("bundler", () => { // that comma today (#40829), so only the shape is checked for it. callTarget: "return(0,o.m)()", tagTarget: "return(0,o.m)`x`", + tagTargetFolded: "return(0,o.m)`x`", implicitReturn: "y||z()", loopContinue: "for(let x of xs)x>2||f(x)", typeofEq: 'return typeof t=="string"', From 0adfc04e0088a893bb79854db22fd817897d58df Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:58:53 +0000 Subject: [PATCH 10/11] minify: re-simplify the kept operand of a boolean test when not minifying --- src/js_parser/scan/scan_side_effects.rs | 12 ++++++++++-- test/bundler/bundler_minify.test.ts | 6 ++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/js_parser/scan/scan_side_effects.rs b/src/js_parser/scan/scan_side_effects.rs index 802a7d3cec25..ffbb16dc886b 100644 --- a/src/js_parser/scan/scan_side_effects.rs +++ b/src/js_parser/scan/scan_side_effects.rs @@ -98,7 +98,11 @@ impl SideEffects { && effects.side_effects == SideEffects::NoSideEffects { // "if (anything && truthyNoSideEffects)" => "if (anything)" - return e.left; + return if minify { + e.left + } else { + Self::simplify_boolean(p, e.left) + }; } } @@ -114,7 +118,11 @@ impl SideEffects { && effects.side_effects == SideEffects::NoSideEffects { // "if (anything || falsyNoSideEffects)" => "if (anything)" - return e.left; + return if minify { + e.left + } else { + Self::simplify_boolean(p, e.left) + }; } } diff --git a/test/bundler/bundler_minify.test.ts b/test/bundler/bundler_minify.test.ts index 0792cfa6ee99..592d974d472e 100644 --- a/test/bundler/bundler_minify.test.ts +++ b/test/bundler/bundler_minify.test.ts @@ -1628,6 +1628,8 @@ describe("bundler", () => { try { b() } catch (e) { b() } if (typeof a === "string" || a === null || a === void 0) b(); if ((a >>> 0) !== 0) b(); + if (!!a && true) b(); + if ((a || false) || false) b(); a != null && a.b(); return 0; } @@ -1664,6 +1666,10 @@ describe("bundler", () => { b(); if (a >>> 0 !== 0) b(); + if (a) + b(); + if (a) + b(); a != null && a.b(); return 0; }" From 079f1085de74909f7094ef61bd5fb8fc0c0c4811 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:40:56 +0000 Subject: [PATCH 11/11] minify: parenthesize an optional chain used as a template tag, keep this unbound after a side-effect test folds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The printer checked the template expression itself for an optional chain instead of its tag, so the parenthesized form was never printed. `(a != null ? a.b() : void 0)\`x\`` now prints as `(a?.b())\`x\``. A constant-test conditional whose test folds to nothing (`typeof o`) goes through the same `(0, …)` wrap as one with no side effects when it is a call or tag target. --- src/js_parser/visit/visit_expr.rs | 79 ++++++++++++++++------------- src/js_printer/lib.rs | 2 +- test/bundler/bundler_minify.test.ts | 11 +++- 3 files changed, 56 insertions(+), 36 deletions(-) diff --git a/src/js_parser/visit/visit_expr.rs b/src/js_parser/visit/visit_expr.rs index 9c72f4856fa6..67f48169d00d 100644 --- a/src/js_parser/visit/visit_expr.rs +++ b/src/js_parser/visit/visit_expr.rs @@ -1534,24 +1534,14 @@ 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)()" - 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; - } - - *e = e_.yes; + // "(a, true) ? b : c" => "a, b" + *e = Self::fold_if_branch( + p, + side_effects.side_effects, + e_.test, + e_.yes, + is_call_target || is_template_tag, + ); } else { // "false ? dead : live" let old = p.is_control_flow_dead; @@ -1561,24 +1551,45 @@ 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; - } + *e = Self::fold_if_branch( + p, + side_effects.side_effects, + e_.test, + e_.no, + is_call_target || is_template_tag, + ); + } + } - // "(1 ? fn : 2)()" => "fn()" - // "(1 ? this.fn : 2)" => "this.fn" - // "(1 ? this.fn : 2)()" => "(0, this.fn)()" - 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; - } - *e = e_.no; + /// The branch a constant-test conditional folds to. The test is kept in + /// front of it when it may have side effects. A call or template tag + /// target that turns into a property access gets `(0, …)` in front so it + /// still binds `this` to `undefined`: + /// + /// "(a, true) ? b : c" => "a, b" + /// "(1 ? fn : 2)()" => "fn()" + /// "(1 ? this.fn : 2)" => "this.fn" + /// "(1 ? this.fn : 2)()" => "(0, this.fn)()" + /// "(typeof x ? this.fn : 2)()" => "(0, this.fn)()" + fn fold_if_branch( + p: &mut Self, + test_side_effects: SideEffects, + test: Expr, + branch: Expr, + binds_this: bool, + ) -> Expr { + if test_side_effects == SideEffects::CouldHaveSideEffects + && let Some(test) = SideEffects::simplify_unused_expr(p, test) + && !test.is_missing() + { + return test.join_with_comma(branch); + } + if binds_this && branch.has_value_for_this_in_call() { + return p + .new_expr(E::Number::new(0.0), test.loc) + .join_with_comma(branch); } + branch } #[inline(never)] // PERF(port:frame): see e_jsx_element. diff --git a/src/js_printer/lib.rs b/src/js_printer/lib.rs index 7eeda62a814a..e7ab20d1fcc5 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -4207,7 +4207,7 @@ pub(crate) mod __gated_printer { 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 { + 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(), diff --git a/test/bundler/bundler_minify.test.ts b/test/bundler/bundler_minify.test.ts index 592d974d472e..c71a8081d741 100644 --- a/test/bundler/bundler_minify.test.ts +++ b/test/bundler/bundler_minify.test.ts @@ -1699,6 +1699,9 @@ describe("bundler", () => { export function callTarget(o, a) { return (a ? o.m : o.m)() } export function tagTarget(o, a) { return (a ? o.m : o.m)\`x\` } export function tagTargetFolded(o) { return (1 ? o.m : 2)\`x\` } + export function tagTargetTypeof(o) { return (typeof o ? o.m : 2)\`x\` } + export function callTargetTypeof(o) { return (typeof o ? o.m : 2)() } + export function tagOptionalChain(a) { return (a != null ? a.b() : void 0)\`x\` } export function implicitReturn(y, z) { if (y) return; z() } export function loopContinue(xs, f) { for (const x of xs) { if (x > 2) continue; f(x) } } export function typeofEq(t) { return typeof t === "string" } @@ -1724,6 +1727,8 @@ describe("bundler", () => { log(optChain(null), optChain({ b: 5 })); log(nullish(null, 2), nullish(0, 2)); log(callTarget({ m() { return this === undefined } }, true)); + log(callTargetTypeof({ m() { return this === undefined } })); + log(tagOptionalChain({ b() { return s => s[0] } })); { let n = 0; implicitReturn(true, () => n++); implicitReturn(false, () => n++); log(n); } { const seen = []; loopContinue([1, 2, 3, 4], x => seen.push(x)); log(seen.join()); } log(typeofEq("s"), typeofEq(1)); @@ -1763,6 +1768,10 @@ describe("bundler", () => { callTarget: "return(0,o.m)()", tagTarget: "return(0,o.m)`x`", tagTargetFolded: "return(0,o.m)`x`", + tagTargetTypeof: "return(0,o.m)`x`", + callTargetTypeof: "return(0,o.m)()", + // An optional chain cannot be a template tag, so the tag is parenthesized. + tagOptionalChain: "return(a?.b())`x`", implicitReturn: "y||z()", loopContinue: "for(let x of xs)x>2||f(x)", typeofEq: 'return typeof t=="string"', @@ -1776,7 +1785,7 @@ describe("bundler", () => { }, run: { stdout: - '[1,2,1,2,3,1,1,"b",1,3,1,null,2,1,7,true,true,null,5,2,0,true,1,"1,2",true,false,true,true,false,true,false,2,4,1,"a,b",true,true]', + '[1,2,1,2,3,1,1,"b",1,3,1,null,2,1,7,true,true,null,5,2,0,true,true,"x",1,"1,2",true,false,true,true,false,true,false,2,4,1,"a,b",true,true]', }, });