Files
bun.sh/src/ast/visitBinaryExpression.zig
robobun 7a199276fb implement typeof undefined minification optimization (#22278)
## Summary

Implements the `typeof undefined === 'u'` minification optimization from
esbuild in Bun's minifier, and fixes dead code elimination (DCE) for
typeof comparisons with string literals.

### Part 1: Minification Optimization
This optimization transforms:
- `typeof x === "undefined"` → `typeof x > "u"`
- `typeof x !== "undefined"` → `typeof x < "u"`
- `typeof x == "undefined"` → `typeof x > "u"`
- `typeof x != "undefined"` → `typeof x < "u"`

Also handles flipped operands (`"undefined" === typeof x`).

### Part 2: DCE Fix for Typeof Comparisons
Fixed dead code elimination to properly handle typeof comparisons with
strings (e.g., `typeof x <= 'u'`). These patterns can now be correctly
eliminated when they reference unbound identifiers that would throw
ReferenceErrors.

## Before/After

### Minification
Before:
```javascript
console.log(typeof x === "undefined");
```

After:
```javascript
console.log(typeof x > "u");
```

### Dead Code Elimination
Before (incorrectly kept):
```javascript
var REMOVE_1 = typeof x <= 'u' ? x : null;
```

After (correctly eliminated):
```javascript
// removed
```

## Implementation

### Minification
- Added `tryOptimizeTypeofUndefined` function in
`src/ast/visitBinaryExpression.zig`
- Handles all 4 equality operators and both operand orders
- Only optimizes when both sides match the expected pattern (typeof
expression + "undefined" string)
- Replaces "undefined" with "u" and changes operators to `>` (for
equality) or `<` (for inequality)

### DCE Improvements
- Extended `isSideEffectFreeUnboundIdentifierRef` in `src/ast/P.zig` to
handle comparison operators (`<`, `>`, `<=`, `>=`)
- Added comparison operators to `simplifyUnusedExpr` in
`src/ast/SideEffects.zig`
- Now correctly identifies when typeof comparisons guard against
undefined references

## Test Plan

 Added comprehensive test in `test/bundler/bundler_minify.test.ts` that
verifies:
- All 8 variations work correctly (4 operators × 2 operand orders)
- Cases that shouldn't be optimized are left unchanged
- Matches esbuild's behavior exactly using inline snapshots

 DCE test `dce/DCETypeOfCompareStringGuardCondition` now passes:
- Correctly eliminates dead code with typeof comparison patterns
- Maintains compatibility with esbuild's DCE behavior

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
Co-authored-by: Dylan Conway <dylan.conway567@gmail.com>
2025-09-08 01:15:59 -07:00

586 lines
29 KiB
Zig

pub fn CreateBinaryExpressionVisitor(
comptime parser_feature__typescript: bool,
comptime parser_feature__jsx: JSXTransformType,
comptime parser_feature__scan_only: bool,
) type {
return struct {
const P = js_parser.NewParser_(parser_feature__typescript, parser_feature__jsx, parser_feature__scan_only);
/// Try to optimize "typeof x === 'undefined'" to "typeof x > 'u'" or similar
/// Returns the optimized expression if successful, null otherwise
fn tryOptimizeTypeofUndefined(e_: *E.Binary, p: *P, replacement_op: js_ast.Op.Code) ?Expr {
// Check if this is a typeof comparison with "undefined"
const typeof_expr, const string_expr, const flip_comparison = exprs: {
// Try left side as typeof, right side as string
if (e_.left.data == .e_unary and e_.left.data.e_unary.op == .un_typeof) {
if (e_.right.data == .e_string and
e_.right.data.e_string.eqlComptime("undefined"))
{
break :exprs .{ e_.left, e_.right, false };
}
return null;
}
// Try right side as typeof, left side as string
if (e_.right.data == .e_unary and e_.right.data.e_unary.op == .un_typeof) {
if (e_.left.data == .e_string and
e_.left.data.e_string.eqlComptime("undefined"))
{
break :exprs .{ e_.right, e_.left, true };
}
return null;
}
return null;
};
// Create new string with "u"
const u_string = p.newExpr(E.String{ .data = "u" }, string_expr.loc);
// Create the optimized comparison
const left = if (flip_comparison) u_string else typeof_expr;
const right = if (flip_comparison) typeof_expr else u_string;
return p.newExpr(E.Binary{
.left = left,
.right = right,
.op = replacement_op,
}, e_.left.loc);
}
pub const BinaryExpressionVisitor = struct {
e: *E.Binary,
loc: logger.Loc,
in: ExprIn,
/// Input for visiting the left child
left_in: ExprIn,
/// "Local variables" passed from "checkAndPrepare" to "visitRightAndFinish"
is_stmt_expr: bool = false,
pub fn visitRightAndFinish(
v: *BinaryExpressionVisitor,
p: *P,
) Expr {
var e_ = v.e;
const is_call_target = @as(Expr.Tag, p.call_target) == .e_binary and e_ == p.call_target.e_binary;
// const is_stmt_expr = @as(Expr.Tag, p.stmt_expr_value) == .e_binary and expr.data.e_binary == p.stmt_expr_value.e_binary;
const was_anonymous_named_expr = e_.right.isAnonymousNamed();
// Mark the control flow as dead if the branch is never taken
switch (e_.op) {
.bin_logical_or => {
const side_effects = SideEffects.toBoolean(p, e_.left.data);
if (side_effects.ok and side_effects.value) {
// "true || dead"
const old = p.is_control_flow_dead;
p.is_control_flow_dead = true;
e_.right = p.visitExpr(e_.right);
p.is_control_flow_dead = old;
} else {
e_.right = p.visitExpr(e_.right);
}
},
.bin_logical_and => {
const side_effects = SideEffects.toBoolean(p, e_.left.data);
if (side_effects.ok and !side_effects.value) {
// "false && dead"
const old = p.is_control_flow_dead;
p.is_control_flow_dead = true;
e_.right = p.visitExpr(e_.right);
p.is_control_flow_dead = old;
} else {
e_.right = p.visitExpr(e_.right);
}
},
.bin_nullish_coalescing => {
const side_effects = SideEffects.toNullOrUndefined(p, e_.left.data);
if (side_effects.ok and !side_effects.value) {
// "notNullOrUndefined ?? dead"
const old = p.is_control_flow_dead;
p.is_control_flow_dead = true;
e_.right = p.visitExpr(e_.right);
p.is_control_flow_dead = old;
} else {
e_.right = p.visitExpr(e_.right);
}
},
else => {
e_.right = p.visitExpr(e_.right);
},
}
// Always put constants on the right for equality comparisons to help
// reduce the number of cases we have to check during pattern matching. We
// can only reorder expressions that do not have any side effects.
switch (e_.op) {
.bin_loose_eq, .bin_loose_ne, .bin_strict_eq, .bin_strict_ne => {
if (SideEffects.isPrimitiveToReorder(e_.left.data) and !SideEffects.isPrimitiveToReorder(e_.right.data)) {
const _left = e_.left;
const _right = e_.right;
e_.left = _right;
e_.right = _left;
}
},
else => {},
}
switch (e_.op) {
.bin_comma => {
// "(1, 2)" => "2"
// "(sideEffects(), 2)" => "(sideEffects(), 2)"
// "(0, this.fn)" => "this.fn"
// "(0, this.fn)()" => "(0, this.fn)()"
if (p.options.features.minify_syntax) {
if (SideEffects.simplifyUnusedExpr(p, e_.left)) |simplified_left| {
e_.left = simplified_left;
} else {
// The left operand has no side effects, but we need to preserve
// the comma operator semantics when used as a call target
if (is_call_target and e_.right.hasValueForThisInCall()) {
// Keep the comma expression to strip "this" binding
e_.left = Expr{ .data = Prefill.Data.Zero, .loc = e_.left.loc };
} else {
return e_.right;
}
}
}
},
.bin_loose_eq => {
const equality = e_.left.data.eql(e_.right.data, p, .loose);
if (equality.ok) {
if (equality.is_require_main_and_module) {
p.ignoreUsageOfRuntimeRequire();
p.ignoreUsage(p.module_ref);
return p.valueForImportMetaMain(false, v.loc);
}
return p.newExpr(
E.Boolean{ .value = equality.equal },
v.loc,
);
}
if (p.options.features.minify_syntax) {
// "typeof x == 'undefined'" => "typeof x > 'u'"
if (tryOptimizeTypeofUndefined(e_, p, .bin_gt)) |optimized| {
return optimized;
}
// "x == void 0" => "x == null"
if (e_.left.data == .e_undefined) {
e_.left.data = .{ .e_null = E.Null{} };
} else if (e_.right.data == .e_undefined) {
e_.right.data = .{ .e_null = E.Null{} };
}
}
// const after_op_loc = locAfterOp(e_.);
// TODO: warn about equality check
// TODO: warn about typeof string
},
.bin_strict_eq => {
const equality = e_.left.data.eql(e_.right.data, p, .strict);
if (equality.ok) {
if (equality.is_require_main_and_module) {
p.ignoreUsage(p.module_ref);
p.ignoreUsageOfRuntimeRequire();
return p.valueForImportMetaMain(false, v.loc);
}
return p.newExpr(E.Boolean{ .value = equality.equal }, v.loc);
}
if (p.options.features.minify_syntax) {
// "typeof x === 'undefined'" => "typeof x > 'u'"
if (tryOptimizeTypeofUndefined(e_, p, .bin_gt)) |optimized| {
return optimized;
}
}
// const after_op_loc = locAfterOp(e_.);
// TODO: warn about equality check
// TODO: warn about typeof string
},
.bin_loose_ne => {
const equality = e_.left.data.eql(e_.right.data, p, .loose);
if (equality.ok) {
if (equality.is_require_main_and_module) {
p.ignoreUsage(p.module_ref);
p.ignoreUsageOfRuntimeRequire();
return p.valueForImportMetaMain(true, v.loc);
}
return p.newExpr(E.Boolean{ .value = !equality.equal }, v.loc);
}
if (p.options.features.minify_syntax) {
// "typeof x != 'undefined'" => "typeof x < 'u'"
if (tryOptimizeTypeofUndefined(e_, p, .bin_lt)) |optimized| {
return optimized;
}
}
// const after_op_loc = locAfterOp(e_.);
// TODO: warn about equality check
// TODO: warn about typeof string
// "x != void 0" => "x != null"
if (@as(Expr.Tag, e_.right.data) == .e_undefined) {
e_.right = p.newExpr(E.Null{}, e_.right.loc);
}
},
.bin_strict_ne => {
const equality = e_.left.data.eql(e_.right.data, p, .strict);
if (equality.ok) {
if (equality.is_require_main_and_module) {
p.ignoreUsage(p.module_ref);
p.ignoreUsageOfRuntimeRequire();
return p.valueForImportMetaMain(true, v.loc);
}
return p.newExpr(E.Boolean{ .value = !equality.equal }, v.loc);
}
if (p.options.features.minify_syntax) {
// "typeof x !== 'undefined'" => "typeof x < 'u'"
if (tryOptimizeTypeofUndefined(e_, p, .bin_lt)) |optimized| {
return optimized;
}
}
},
.bin_nullish_coalescing => {
const nullorUndefined = SideEffects.toNullOrUndefined(p, e_.left.data);
if (nullorUndefined.ok) {
if (!nullorUndefined.value) {
return e_.left;
} else if (nullorUndefined.side_effects == .no_side_effects) {
// "(null ?? fn)()" => "fn()"
// "(null ?? this.fn)" => "this.fn"
// "(null ?? this.fn)()" => "(0, this.fn)()"
if (is_call_target and e_.right.hasValueForThisInCall()) {
return Expr.joinWithComma(Expr{ .data = .{ .e_number = .{ .value = 0.0 } }, .loc = e_.left.loc }, e_.right, p.allocator);
}
return e_.right;
}
}
},
.bin_logical_or => {
const side_effects = SideEffects.toBoolean(p, e_.left.data);
if (side_effects.ok and side_effects.value) {
return e_.left;
} else if (side_effects.ok and side_effects.side_effects == .no_side_effects) {
// "(0 || fn)()" => "fn()"
// "(0 || this.fn)" => "this.fn"
// "(0 || this.fn)()" => "(0, this.fn)()"
if (is_call_target and e_.right.hasValueForThisInCall()) {
return Expr.joinWithComma(Expr{ .data = Prefill.Data.Zero, .loc = e_.left.loc }, e_.right, p.allocator);
}
return e_.right;
}
},
.bin_logical_and => {
const side_effects = SideEffects.toBoolean(p, e_.left.data);
if (side_effects.ok) {
if (!side_effects.value) {
return e_.left;
} else if (side_effects.side_effects == .no_side_effects) {
// "(1 && fn)()" => "fn()"
// "(1 && this.fn)" => "this.fn"
// "(1 && this.fn)()" => "(0, this.fn)()"
if (is_call_target and e_.right.hasValueForThisInCall()) {
return Expr.joinWithComma(Expr{ .data = Prefill.Data.Zero, .loc = e_.left.loc }, e_.right, p.allocator);
}
return e_.right;
}
}
},
.bin_add => {
if (p.should_fold_typescript_constant_expressions) {
if (Expr.extractNumericValues(e_.left.data, e_.right.data)) |vals| {
return p.newExpr(E.Number{ .value = vals[0] + vals[1] }, v.loc);
}
// "'abc' + 'xyz'" => "'abcxyz'"
if (foldStringAddition(e_.left, e_.right, p.allocator, .normal)) |res| {
return res;
}
// "(x + 'abc') + 'xyz'" => "'abcxyz'"
if (e_.left.data.as(.e_binary)) |left| {
if (left.op == .bin_add) {
if (foldStringAddition(left.right, e_.right, p.allocator, .nested_left)) |result| {
return p.newExpr(E.Binary{
.left = left.left,
.right = result,
.op = .bin_add,
}, e_.left.loc);
}
}
}
}
},
.bin_sub => {
if (p.should_fold_typescript_constant_expressions) {
if (Expr.extractNumericValues(e_.left.data, e_.right.data)) |vals| {
return p.newExpr(E.Number{ .value = vals[0] - vals[1] }, v.loc);
}
}
},
.bin_mul => {
if (p.should_fold_typescript_constant_expressions) {
if (Expr.extractNumericValues(e_.left.data, e_.right.data)) |vals| {
return p.newExpr(E.Number{ .value = vals[0] * vals[1] }, v.loc);
}
}
},
.bin_div => {
if (p.should_fold_typescript_constant_expressions) {
if (Expr.extractNumericValues(e_.left.data, e_.right.data)) |vals| {
return p.newExpr(E.Number{ .value = vals[0] / vals[1] }, v.loc);
}
}
},
.bin_rem => {
if (p.should_fold_typescript_constant_expressions) {
if (Expr.extractNumericValues(e_.left.data, e_.right.data)) |vals| {
const fmod = @extern(*const fn (f64, f64) callconv(.C) f64, .{ .name = "fmod" });
return p.newExpr(
// Use libc fmod here to be consistent with what JavaScriptCore does
// https://github.com/oven-sh/WebKit/blob/7a0b13626e5db69aa5a32d037431d381df5dfb61/Source/JavaScriptCore/runtime/MathCommon.cpp#L574-L597
E.Number{ .value = if (comptime Environment.isNative) fmod(vals[0], vals[1]) else std.math.mod(f64, vals[0], vals[1]) catch 0 },
v.loc,
);
}
}
},
.bin_pow => {
if (p.should_fold_typescript_constant_expressions) {
if (Expr.extractNumericValues(e_.left.data, e_.right.data)) |vals| {
return p.newExpr(E.Number{ .value = jsc.math.pow(vals[0], vals[1]) }, v.loc);
}
}
},
.bin_shl => {
if (p.should_fold_typescript_constant_expressions) {
if (Expr.extractNumericValues(e_.left.data, e_.right.data)) |vals| {
const left = floatToInt32(vals[0]);
const right: u8 = @intCast(@as(u32, @bitCast(floatToInt32(vals[1]))) % 32);
const result: i32 = @bitCast(std.math.shl(i32, left, right));
return p.newExpr(E.Number{
.value = @floatFromInt(result),
}, v.loc);
}
}
},
.bin_shr => {
if (p.should_fold_typescript_constant_expressions) {
if (Expr.extractNumericValues(e_.left.data, e_.right.data)) |vals| {
const left = floatToInt32(vals[0]);
const right: u8 = @intCast(@as(u32, @bitCast(floatToInt32(vals[1]))) % 32);
const result: i32 = @bitCast(std.math.shr(i32, left, right));
return p.newExpr(E.Number{
.value = @floatFromInt(result),
}, v.loc);
}
}
},
.bin_u_shr => {
if (p.should_fold_typescript_constant_expressions) {
if (Expr.extractNumericValues(e_.left.data, e_.right.data)) |vals| {
const left: u32 = @bitCast(floatToInt32(vals[0]));
const right: u8 = @intCast(@as(u32, @bitCast(floatToInt32(vals[1]))) % 32);
const result: u32 = std.math.shr(u32, left, right);
return p.newExpr(E.Number{
.value = @floatFromInt(result),
}, v.loc);
}
}
},
.bin_bitwise_and => {
if (p.should_fold_typescript_constant_expressions) {
if (Expr.extractNumericValues(e_.left.data, e_.right.data)) |vals| {
return p.newExpr(E.Number{
.value = @floatFromInt((floatToInt32(vals[0]) & floatToInt32(vals[1]))),
}, v.loc);
}
}
},
.bin_bitwise_or => {
if (p.should_fold_typescript_constant_expressions) {
if (Expr.extractNumericValues(e_.left.data, e_.right.data)) |vals| {
return p.newExpr(E.Number{
.value = @floatFromInt((floatToInt32(vals[0]) | floatToInt32(vals[1]))),
}, v.loc);
}
}
},
.bin_bitwise_xor => {
if (p.should_fold_typescript_constant_expressions) {
if (Expr.extractNumericValues(e_.left.data, e_.right.data)) |vals| {
return p.newExpr(E.Number{
.value = @floatFromInt((floatToInt32(vals[0]) ^ floatToInt32(vals[1]))),
}, v.loc);
}
}
},
.bin_lt => {
if (p.should_fold_typescript_constant_expressions) {
if (Expr.extractNumericValuesInSafeRange(e_.left.data, e_.right.data)) |vals| {
return p.newExpr(E.Boolean{
.value = vals[0] < vals[1],
}, v.loc);
}
if (Expr.extractStringValues(e_.left.data, e_.right.data, p.allocator)) |vals| {
return p.newExpr(E.Boolean{
.value = vals[0].order(vals[1]) == .lt,
}, v.loc);
}
}
},
.bin_gt => {
if (p.should_fold_typescript_constant_expressions) {
if (Expr.extractNumericValuesInSafeRange(e_.left.data, e_.right.data)) |vals| {
return p.newExpr(E.Boolean{
.value = vals[0] > vals[1],
}, v.loc);
}
if (Expr.extractStringValues(e_.left.data, e_.right.data, p.allocator)) |vals| {
return p.newExpr(E.Boolean{
.value = vals[0].order(vals[1]) == .gt,
}, v.loc);
}
}
},
.bin_le => {
if (p.should_fold_typescript_constant_expressions) {
if (Expr.extractNumericValuesInSafeRange(e_.left.data, e_.right.data)) |vals| {
return p.newExpr(E.Boolean{
.value = vals[0] <= vals[1],
}, v.loc);
}
if (Expr.extractStringValues(e_.left.data, e_.right.data, p.allocator)) |vals| {
return p.newExpr(E.Boolean{
.value = switch (vals[0].order(vals[1])) {
.eq, .lt => true,
.gt => false,
},
}, v.loc);
}
}
},
.bin_ge => {
if (p.should_fold_typescript_constant_expressions) {
if (Expr.extractNumericValuesInSafeRange(e_.left.data, e_.right.data)) |vals| {
return p.newExpr(E.Boolean{
.value = vals[0] >= vals[1],
}, v.loc);
}
if (Expr.extractStringValues(e_.left.data, e_.right.data, p.allocator)) |vals| {
return p.newExpr(E.Boolean{
.value = switch (vals[0].order(vals[1])) {
.eq, .gt => true,
.lt => false,
},
}, v.loc);
}
}
},
// ---------------------------------------------------------------------------------------------------
.bin_assign => {
// Optionally preserve the name
if (e_.left.data == .e_identifier) {
e_.right = p.maybeKeepExprSymbolName(e_.right, p.symbols.items[e_.left.data.e_identifier.ref.innerIndex()].original_name, was_anonymous_named_expr);
}
},
.bin_nullish_coalescing_assign, .bin_logical_or_assign => {
// Special case `{}.field ??= value` to minify to `value`
// This optimization is specifically to target this pattern in HMR:
// `import.meta.hot.data.etc ??= init()`
if (e_.left.data.as(.e_dot)) |dot| {
if (dot.target.data.as(.e_object)) |obj| {
if (obj.properties.len == 0) {
if (!bun.strings.eqlComptime(dot.name, "__proto__"))
return e_.right;
}
}
}
},
else => {},
}
return Expr{ .loc = v.loc, .data = .{ .e_binary = e_ } };
}
pub fn checkAndPrepare(v: *BinaryExpressionVisitor, p: *P) ?Expr {
var e_ = v.e;
switch (e_.left.data) {
// Special-case private identifiers
.e_private_identifier => |_private| {
if (e_.op == .bin_in) {
var private = _private;
const name = p.loadNameFromRef(private.ref);
const result = p.findSymbol(e_.left.loc, name) catch unreachable;
private.ref = result.ref;
// Unlike regular identifiers, there are no unbound private identifiers
const kind: Symbol.Kind = p.symbols.items[result.ref.innerIndex()].kind;
if (!Symbol.isKindPrivate(kind)) {
const r = logger.Range{ .loc = e_.left.loc, .len = @as(i32, @intCast(name.len)) };
p.log.addRangeErrorFmt(p.source, r, p.allocator, "Private name \"{s}\" must be declared in an enclosing class", .{name}) catch unreachable;
}
e_.right = p.visitExpr(e_.right);
e_.left = .{ .data = .{ .e_private_identifier = private }, .loc = e_.left.loc };
// privateSymbolNeedsToBeLowered
return Expr{ .loc = v.loc, .data = .{ .e_binary = e_ } };
}
},
else => {},
}
v.is_stmt_expr = p.stmt_expr_value == .e_binary and p.stmt_expr_value.e_binary == e_;
v.left_in = ExprIn{
.assign_target = e_.op.binaryAssignTarget(),
};
return null;
}
};
};
}
const string = []const u8;
const std = @import("std");
const bun = @import("bun");
const Environment = bun.Environment;
const jsc = bun.jsc;
const logger = bun.logger;
const strings = bun.strings;
const js_ast = bun.ast;
const E = js_ast.E;
const Expr = js_ast.Expr;
const Symbol = js_ast.Symbol;
const js_parser = bun.js_parser;
const ExprIn = js_parser.ExprIn;
const JSXTransformType = js_parser.JSXTransformType;
const Prefill = js_parser.Prefill;
const SideEffects = js_parser.SideEffects;
const floatToInt32 = js_parser.floatToInt32;
const foldStringAddition = js_parser.foldStringAddition;
const options = js_parser.options;