feat(minifier): fold boolean-literal ternary branches in value contexts (#24110)

## What

Fold a ternary with one boolean literal branch into `&&` / `||`, in value positions (call arguments, returns, assignments).

| Input | Output | Condition |
|---|---|---|
| `c ? false : x` | `!c && x` | always safe |
| `c ? x : true` | `!c \|\| x` | always safe |
| `c ? true : x` | `c \|\| x` | only when `c` is a boolean |
| `c ? x : false` | `c && x` | only when `c` is a boolean |

We already do this inside `if` tests. This PR does it everywhere else. The first two rows produce the exact same value for any `c`. The last two rows would return `c` itself instead of `true`/`false`, so they need a boolean-typed `c`.

## How

New match arms in `minimize_conditional_expression`, next to the existing `a ? true : false` => `!!a` block. The dropped branch must be side-effect free (same check the existing block uses). The fold is skipped when it would make the output longer, e.g. `a || b ? false : c` would need `!(a || b)`, so it stays as is. Equality tests invert for free: `a === b ? false : x` => `a !== b && x`.

## Example

```js
// input
use(foo() ? false : bar());
use(a === b ? false : compute());
```

```js
// before
use(foo()?!1:bar()),use(a===b?!1:compute());

// after
use(!foo()&&bar()),use(a!==b&&compute());
```

Found by re-minifying oxc's minsize output with `terser -c`: typescript.js alone has ~290 of these. All minsize fixtures stay equal or smaller.

Implemented with Claude Code, reviewed and verified by me.
This commit is contained in:
Dunqing committed 2026-07-06 08:26:10 +00:00
1 parent e8b50ee716
commit 2d9b0b3c76
4 files changed
+153 -12

No files matched your search

@@ -431,6 +431,84 @@ impl<'a> PeepholeOptimizations {
let test = Self::minimize_not(expr.span, test, ctx);
return Some(test);
}
// "c ? false : x" => "!c && x" (exact for any `c`)
(Some(false), None)
if Self::can_fold_negated_test(&expr.test)
&& !Self::logical_operand_needs_parens(
&expr.alternate,
LogicalOperator::And,
) =>
{
let test = expr.test.take_in(ctx);
let test = Self::minimize_not(expr.span, test, ctx);
let right = expr.alternate.take_in(ctx);
return Some(Self::join_with_left_associative_op(
expr.span,
LogicalOperator::And,
test,
right,
ctx,
));
}
// "c ? x : true" => "!c || x" (exact for any `c`)
(None, Some(true))
if Self::can_fold_negated_test(&expr.test)
&& !Self::logical_operand_needs_parens(
&expr.consequent,
LogicalOperator::Or,
) =>
{
let test = expr.test.take_in(ctx);
let test = Self::minimize_not(expr.span, test, ctx);
let right = expr.consequent.take_in(ctx);
return Some(Self::join_with_left_associative_op(
expr.span,
LogicalOperator::Or,
test,
right,
ctx,
));
}
// "c ? true : x" => "c || x" (only when `c` is boolean-typed; a
// non-boolean truthy `c` would be returned instead of `true`)
(Some(true), None)
if expr.test.value_type(ctx).is_boolean()
&& !Self::logical_operand_needs_parens(&expr.test, LogicalOperator::Or)
&& !Self::logical_operand_needs_parens(
&expr.alternate,
LogicalOperator::Or,
) =>
{
let test = expr.test.take_in(ctx);
let right = expr.alternate.take_in(ctx);
return Some(Self::join_with_left_associative_op(
expr.span,
LogicalOperator::Or,
test,
right,
ctx,
));
}
// "c ? x : false" => "c && x" (only when `c` is boolean-typed; a
// non-boolean falsy `c` would be returned instead of `false`)
(None, Some(false))
if expr.test.value_type(ctx).is_boolean()
&& !Self::logical_operand_needs_parens(&expr.test, LogicalOperator::And)
&& !Self::logical_operand_needs_parens(
&expr.consequent,
LogicalOperator::And,
) =>
{
let test = expr.test.take_in(ctx);
let right = expr.consequent.take_in(ctx);
return Some(Self::join_with_left_associative_op(
expr.span,
LogicalOperator::And,
test,
right,
ctx,
));
}
_ => {}
}
@@ -687,6 +765,39 @@ impl<'a> PeepholeOptimizations {
false
}
/// Returns `true` when `minimize_not(test)` negates `test` without wrapping
/// it in a `!(...)`, so folding a conditional into `!test && x` / `!test || x`
/// does not grow the output. Equality comparisons invert their operator in
/// place (`a === b` -> `a !== b`); anything that is not otherwise
/// parenthesized as a unary operand negates to a bare `!test`.
fn can_fold_negated_test(test: &Expression<'_>) -> bool {
if let Expression::BinaryExpression(e) = test {
return e.operator.is_equality();
}
!Self::test_needs_parens(test)
}
/// Returns `true` if `expr` would need parentheses when printed as an operand
/// of the logical operator `op` (`&&` or `||`). Used as a size guard before
/// folding a conditional into a logical expression.
fn logical_operand_needs_parens(expr: &Expression<'_>, op: LogicalOperator) -> bool {
match expr {
Expression::SequenceExpression(_)
| Expression::AssignmentExpression(_)
| Expression::YieldExpression(_)
| Expression::ArrowFunctionExpression(_)
| Expression::ConditionalExpression(_) => true,
// `??` cannot be mixed with `&&`/`||` without parens, and `||` needs
// parens as an operand of `&&`. `&&` under `||`, and same-operator
// nesting (flattened by `join_with_left_associative_op`), do not.
Expression::LogicalExpression(e) => {
matches!(e.operator, LogicalOperator::Coalesce)
|| matches!((e.operator, op), (LogicalOperator::Or, LogicalOperator::And))
}
_ => false,
}
}
/// Returns `true` if the expression would need parentheses when used as a unary operand.
/// Binary and conditional expressions need wrapping; identifiers, calls, unary, etc. do not.
fn test_needs_parens(expr: &Expression<'_>) -> bool {
@@ -105,3 +105,33 @@ fn test_minimize_conditional_numeric() {
// "a ? 0 : 1" stays when parens would make it same or longer
test("let x = a + b ? 0 : 1", "let x = a + b ? 0 : 1");
}
#[test]
fn test_minimize_conditional_boolean_value_context() {
// Form 1: "c ? false : x" => "!c && x" (exact for any `c`)
test("let x = foo() ? false : bar()", "let x = !foo() && bar()");
test("let x = num ? false : y", "let x = !num && y");
test("foo(a ? false : b)", "foo(!a && b)");
// equality tests invert their operator in place via `minimize_not`
test("function f() { return a === b ? false : x }", "function f() { return a !== b && x }");
// Form 2: "c ? x : true" => "!c || x" (exact for any `c`)
test("let x = foo() ? bar() : true", "let x = !foo() || bar()");
test("let x = a === b ? c : true", "let x = a !== b || c");
// "!a ? true : x" first flips to "a ? x : true", then folds via form 2
test("let x = !a ? true : b", "let x = !a || b");
// Form 3: "c ? true : x" => "c || x" only when `c` is boolean-typed
test("let x = a === b ? true : c", "let x = a === b || c");
// Form 4: "c ? x : false" => "c && x" only when `c` is boolean-typed
test("let x = a === b ? c : false", "let x = a === b && c");
// Negative: forms 3/4 require a boolean-typed test, else the value changes.
test("let x = num ? true : y", "let x = num ? !0 : y");
test("let x = num ? y : false", "let x = num ? y : !1");
// Negative: size guard. `!(a || b) && c` is longer than `a || b ? !1 : c`.
test("let x = a || b ? false : c", "let x = a || b ? !1 : c");
// Negative: `x` needing parens as a logical operand would not save bytes.
test("let x = a ? false : b ? c : d", "let x = a ? !1 : b ? c : d");
}
+9 -9
View File
@@ -5,23 +5,23 @@ Original | minified | minified | gzip | gzip | Iterations | Fi
173.90 kB | 59.40 kB | 59.82 kB | 19.15 kB | 19.33 kB | 2 | moment.js
287.63 kB | 89.25 kB | 90.07 kB | 30.91 kB | 31.95 kB | 2 | jquery.js
287.63 kB | 89.25 kB | 90.07 kB | 30.90 kB | 31.95 kB | 2 | jquery.js
342.15 kB | 116.97 kB | 118.14 kB | 43.18 kB | 44.37 kB | 2 | vue.js
342.15 kB | 116.97 kB | 118.14 kB | 43.17 kB | 44.37 kB | 2 | vue.js
544.10 kB | 70.92 kB | 72.48 kB | 25.74 kB | 26.20 kB | 2 | lodash.js
544.10 kB | 70.92 kB | 72.48 kB | 25.73 kB | 26.20 kB | 2 | lodash.js
555.77 kB | 267.40 kB | 270.13 kB | 88.01 kB | 90.80 kB | 2 | d3.js
1.01 MB | 439.36 kB | 458.89 kB | 122.05 kB | 126.71 kB | 2 | bundle.min.js
1.01 MB | 439.34 kB | 458.89 kB | 122.04 kB | 126.71 kB | 2 | bundle.min.js
1.25 MB | 642.65 kB | 646.76 kB | 159.41 kB | 163.73 kB | 2 | three.js
1.25 MB | 642.62 kB | 646.76 kB | 159.40 kB | 163.73 kB | 2 | three.js
2.14 MB | 711.07 kB | 724.14 kB | 160.41 kB | 181.07 kB | 2 | victory.js
2.14 MB | 711.06 kB | 724.14 kB | 160.41 kB | 181.07 kB | 2 | victory.js
3.20 MB | 1.00 MB | 1.01 MB | 322.61 kB | 331.56 kB | 2 | echarts.js
3.20 MB | 1.00 MB | 1.01 MB | 322.60 kB | 331.56 kB | 2 | echarts.js
6.69 MB | 2.22 MB | 2.31 MB | 458.44 kB | 488.28 kB | 4 | antd.js
6.69 MB | 2.22 MB | 2.31 MB | 458.42 kB | 488.28 kB | 4 | antd.js
10.95 MB | 3.33 MB | 3.49 MB | 853.11 kB | 915.50 kB | 4 | typescript.js
10.95 MB | 3.33 MB | 3.49 MB | 853.07 kB | 915.50 kB | 4 | typescript.js
@@ -1,14 +1,14 @@
File | File size || Sys allocs | Sys reallocs || Arena allocs | Arena reallocs
---------------------------------------------------------------------------------------------------------------------------
checker.ts | 2.92 MB || 162 | 5 || 152751 | 28253
checker.ts | 2.92 MB || 162 | 5 || 152811 | 28253
App.tsx | 415.34 kB || 72 | 8 || 17010 | 2702
App.tsx | 415.34 kB || 72 | 8 || 17028 | 2702
RadixUIAdoptionSection.jsx | 2.52 kB || 24 | 0 || 32 | 6
pdf.mjs | 567.30 kB || 2476 | 205 || 47484 | 7742
antd.js | 6.69 MB || 870 | 56 || 337232 | 69333
antd.js | 6.69 MB || 870 | 56 || 337676 | 69333
binder.ts | 193.08 kB || 41 | 1 || 7083 | 824