Commit Graph

377 Commits

Author SHA1 Message Date
Armano a17de58e30 feat(minifier): process of return/throw in non last position and fix async * inlining (#26214)
This Pr brings couple optimizations and refactor,

- Disable merging: `if (a) return x; return;` and `if (a) return; return
x;` when is descendant of `async* function`
- Handle empty chains: `if (a) return; return;` → `a; return;`
- Process returns/throw anywhere in block via handler merge `if (a)
return; return; var b` → `a; return; var b`

fixes #26255
ref #24331
2026-09-03 23:05:40 +08:00
Armano 1e902cc1ad feat(minifier): expand fold leading assignments into the var decl (#26142)
expands leading assignment inlining into variable declarations for
`return`, `switch`, `if`, `throw`, `for`, `for in`, `for of`
```js
// return
var a; return a = b(), c;
var a = b(); return c;
// throw
var a; throw a = b(), c;
var a = b(); throw c;
// switch
var a; switch (a = b(), c) {}
var a = b(); switch (c) {}
// for
var a; for (a = b(), c; ;) d;
var a = b(); for (c; ;) d;
// if
var a; if (a = b(), c) d;
var a = b(); if (c) d;
```

fixes #25949
2026-08-29 04:27:01 +08:00
Armano 3ed4f6ad90 feat(minifier): expand de morgan's optimization to allow move of ! (#25930)
Expand negation to include move of `!` operator to inner children

```js
!(a == b && c == 3 && a)
a != b || c != 3 | !a
```

delta calculation is dependent on boolean context
- if we expect truthly/falsly value negation of `!a` will be converted
to `a` (-1 delta)
- in ambiguous context `!a` will be converted to `!!a` (+1 delta)

```js
a = !!(!b || !c);
//   ^ ^     ^ -3
a = !(b || c)
//  ^ ^    ^ +1 - if we could detect if parens can be removed it would be -1
//  ^^^    ^^ -1 - if we would know that we can remove parens
```
2026-08-29 02:08:57 +08:00
Armano b6e9e5efda feat(minifier): fold switch stmt whose parent is not block stmt (#25403)
Move removal of dead code from switch stmts to remove_dead_code, this
change enables processing of switch stmts whose parent is not list of
stmts

current minification processes stmts whose parents are block stmts and
they are last stmt of set block, this change also allows us to remove
empty blocks and last break from switch cases whose parent is not block
stmt

```js
if (a) { b(); if (c) switch (d) { case 1: case 2: break; } c(); }
//            ^~~~~~ due to body not being a block we where not able to process this

// Current:
if (a) { if (b(), c) switch (d) { case 1: case 2: break } c() }
//                   ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ this switch case is not being processed

// This branch:
a && (b(), c && d, c());
//              ^ switch cases can be correctly processed
```

note: this change applies only dce, and dosn't substitute switch stmts
with if'stmts

#### changes in stack:
- https://github.com/oxc-project/oxc/pull/25405 
- https://github.com/oxc-project/oxc/pull/25403 👈
- `main`
2026-08-23 22:08:10 +08:00
Kotaro Chikuba af82a0707a fix(minifier): keep unary + when the other operand has side effects (#25645)
`substitute_unary_plus` removes a `+` when the enclosing binary operator
will do the `ToNumber` conversion anyway. The existing doc comment
reasons carefully about `ToNumber` vs `ToNumeric`, but not about *when*
each runs.

`+a` converts `a` while evaluating `+a`, before the right operand is
evaluated at all. `a - n` converts it in
`ApplyStringOrNumericBinaryOperator`, after `n` has been evaluated.
Dropping the `+` therefore moves the conversion across `n`, and a right
operand with a side effect can change what the conversion sees:

```js
var xs = [];
console.log((+xs) - (xs.push(1), 0)); // 0, converted while `xs` was still empty

// oxc emitted:
console.log(xs - (xs.push(1), 0));    // 1, converted after `xs` grew
```

This PR requires the right operand to be free of side effects before
dropping a `+` in the left position. It cannot observe the conversion
either: under the "Coercion Methods Are Pure" assumption the conversion
runs no user code, so there is nothing to observe.

The right position (`n - +a`) is left as is: `n` is evaluated first
either way, and its `ToNumeric` is a no-op because the existing guard
already established that `n` is a number, so nothing runs between
evaluating `a` and converting it.

`cargo minsize` moves the typescript.js gzip figure down by 10 bytes;
every minified size is identical.

The reproduction was found by the minifier fuzzer (#25594) at seed
12397.

### Differential testing

Every case below was run through oxc and then executed in Node against
the unminified source, comparing the result, the mutated state, and the
error constructor *and message*.

| shape | cases | value worlds | runs |
| --- | ---: | ---: | ---: |
| operator × 37 hand-written right operands | 814 | 41 | 33,374 |
| right operand reading a closure variable the conversion may write |
374 | 41 | 15,334 |
| argument shape (`+a.v`, `+f()`, `+(seq(), a)`, `+(+a)`, getter) ×
nesting | 3,168 | 41 | 129,888 |
| random expressions, 3 seeds, depth 2-3 | 7,500 | 41 | 307,500 |

The worlds cover `[]`, `{ valueOf: () => 10n }`, `Symbol()`,
`Object.create(null)`, objects with getters, `Proxy`, and the numeric
edge cases.

No difference survives, other than one that predates this pass and is
discussed in the doc comment: for a `BigInt` argument both forms throw a
`TypeError`, but `+a` says "Cannot convert a BigInt value to a number"
while `a - 1` says "Cannot mix BigInt and other types". The error kind
never changes.

---

Written with AI assistance (Claude Code). Reviewed by me, and the
before/after behaviour was checked against Node.js.

---------

Signed-off-by: Kotaro Chikuba <miz404@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 18:38:27 +09:00
Dunqing 2f5cdb1231 feat(minifier): add property name mangling (#24740)
Oxc can shorten variable names and private class names, but it cannot shorten property names such as `obj._field`. This change adds optional property mangling. It replaces #23772 and gives bundlers a way to use the same property names across several files.

### Feature

- `include` selects names to mangle. `exclude` and `reserved` keep names unchanged.
- The cache can give a property a fixed output name or keep it unchanged.
- The mangler supports member access, object and class keys, destructuring, optional chains, the left side of `in`, and JSX property names.
- `mangle_quoted` also handles strings and template literals used directly as property keys. `/* @__KEY__ */` and `/* #__KEY__ */` can mark one other string or template literal as a property key.
- Short names are assigned in a stable order. More common properties get shorter names. Existing and reserved names are avoided.
- Bundlers can collect names from several programs, create one shared map, and apply it to every program.
- Numeric keys, `__proto__`, `constructor`, `prototype`, TypeScript-only keys, and private names are never changed by this pass.

### Design

Property names are part of JavaScript syntax, not local scope. For this reason, the code lives in `oxc_minifier`. It only reuses the short-name generator from `oxc_mangler`.

For one program, the order is:

```text
collect names → assign names → rewrite once → compress → mangle variables and private names
```

Property names are rewritten before compression because compression can remove information about quotes and comments.

For several programs, a bundler collects names from each program, merges the results, assigns one shared map, and rewrites each program once. Collection can run in parallel. All programs must use the same options.

The shared map keeps property names consistent inside one build. The cache has a different job: it keeps fixed names and reservations across separate minify calls.

### Example

With `include: "^_"` and `mangle_quoted: true`:

```js
obj[`_field`] = 1;
obj._field = 2;
```

becomes:

```js
obj[`e`] = 1;
obj.e = 2;
```

### Limitation

- Property mangling is unsafe when other code still uses the old property name. Public APIs and names shared with external code, host APIs, imported namespace objects, or separate builds must be excluded or reserved.
- Quote handling is per use. Without `mangle_quoted`, `obj._field` may change while `obj["_field"]` does not.
- The mangler cannot find names built at runtime or stored in arbitrary strings. Code that uses `eval`, `Function`, or `with` must reserve any affected names.
- Compression may create a matching property after this pass has finished. For example, `'f' + 'oo_'` may later become `foo_` and will not be mangled.
- TypeScript enum reverse mappings are not supported because the transform creates quoted strings.
- Multi-program support is a caller API. The caller must collect every program that shares mangled properties before assigning names.

The public `oxc-minify` API is stacked in #24741. The downstream multi-chunk integration is shown in rolldown/rolldown#10374.

Supersedes #23772.

AI assistance: OpenAI Codex and Claude were used for research, implementation, review, and testing. The contributor is responsible for reviewing and understanding the changes before submission.
2026-08-17 07:50:51 +00:00
sapphi-red 771d79a9e3 fix(mangler): exclude non-manglable symbols from slot assignment (#25539)
Slots were assigned to non-manglable symbols as well. This made the slot re-use non-optimal.

This PR fixes that.

I used AI to find out this bug and to generate the code and then reviewed the code.
2026-08-13 06:39:16 +00:00
Armano 6c9ee14ad7 fix(minifier): preserve if-chain idempotency and correct side effects when alternate should not be moved (#25041)
do not rewrite if else where `alternate` could produce invalid code and
change behavior

```js
function g(a,b){if(a){}else if(b){return()=>typeof f}else function f(){}}
```

https://github.com/oxc-project/oxc/pull/24997/changes#r3659223987

> **<sub><sub>![P1
Badge](https://img.shields.io/badge/P1-orange?style=flat)</sub></sub>
Preserve Annex B branch scoping when wrapping terminated blocks**
> 
> In sloppy scripts, broadening this condition from direct jumps to any
terminated statement lets a block-returning branch trigger the synthetic
wrapper. For `function g(a,b){if(a){}else if(b){return()=>typeof f}else
function f(){}}`, the subsequent `handle_if_statement` pass detaches the
`else` into that wrapper, emitting the equivalent of
`if(!a){if(b)return()=>typeof f;function f(){}}`. The Annex B function
is then instantiated when the block is entered, so `g(false,true)()`
changes from `"undefined"` to `"function"`; avoid flattening
scope-sensitive alternates or otherwise preserve their original branch
scope.


based on changes from #24997

---------

Co-authored-by: Cameron <cameron.clark@hey.com>
2026-07-31 10:34:15 +08:00
Dunqing fb402b010c perf(minifier): stop re-deriving printed numeric spellings (#25103)
The parser cannot produce negative or non-finite NumericLiterals: `-1` parses as unary negation, and codegen prints `Infinity` as `1/0`, which parses as a division. The loop's constant folds re-derive the literal from either spelling and record a change for it, one phantom change per such number on every run over printed output.

Collapse `-` into a negative literal in the Normalize pass — the same treatment `NaN` and `Infinity` identifiers already get — and keep `1/0` and `-1/0` unfolded like the existing `!0` and `void 0` guards: they are the canonical printed spellings of the non-finite numbers.

The guard shifts an inlining decision in lodash.js, making it 20 bytes smaller; all other minsize outputs are unchanged.
2026-07-30 10:52:55 +00:00
Dunqing 82c5806081 feat(minifier): fold sequence branches in boolean ternaries (#24943)
Boolean-literal ternaries with a sequence in the non-literal branch are left uncompressed because the logical-operand size guard counts sequence parentheses as newly added. A sequence already requires parentheses in a conditional branch, so this misses shorter `&&` / `||` forms.

Treat sequence branches as having no additional parenthesis cost while keeping the existing guard for assignments, nested conditionals, arrows, `yield`, and incompatible logical operators. The change is confined to full expression compression and does not affect statement or tree-shaking passes.

## Example

```js
// input
use(flag ? false : (touch(), true));

// before
use(flag?!1:(touch(),!0));

// after
use(!flag&&(touch(),!0));
```

The minsize corpus improves GZIP by 0.01 kB each for Victory, ECharts, Ant Design, and TypeScript, with no regressions or iteration changes. The additional folds add 3–42 arena allocations per tracked fixture (1,152 arena bytes at most) and no system allocations.

Verified with `cargo test -p oxc_minifier`, `just minsize`, Clippy with warnings denied, formatting checks, and `just ready`.

Implemented with AI assistance (OpenAI Codex). The contributor remains responsible for reviewing, understanding, and submitting the changes under the repository AI usage policy.
2026-07-27 09:01:04 +00:00
Armano b31713a693 feat(minifier): add new IsTerminated trait and use it in exit-point optimization (#24950)
Added a new IsTerminated trait and wired it into handle_if_statement in
minimize-exit-points.

before this, we only treated direct jump statements as terminating. Now
we also handle nested terminated branches, so cases like nested if/else
termination optimize correctly.

for now this is only used in this one optimization path, but we should
reuse IsTerminated in more places (e.g. switch-case and other
exit-point/termination optimizations).
2026-07-27 16:03:47 +08:00
Dunqing 46183007c2 fix(minifier): avoid longer numeric constant folds (#24875)
Constant evaluation can replace a short numeric expression with a longer exact floating-point literal, increasing minified output.

Using the serializer shared by #24876 and optimized by #24889, the minifier compares the folded literal with a conservative lower bound for the original numeric expression before accepting numeric addition, subtraction, and multiplication folds. Parentheses are omitted from the estimate, so an accepted fold cannot make output longer. Measuring the original tree instead of evaluated operand values also preserves profitable outer folds such as `.8` without regressing nested expressions such as `0+(.1+.05)`.

## Example

Input:

```js
console.log(0.1 + 0.05);
console.log(0.1 + 0.2 + 0.1 + 0.4);
console.log(0.1 * 1 + 0.05);
console.log(0.1 * 1 + 0.7);
console.log(0.3 - 0.1);
```

Before:

```js
console.log(.15000000000000002),console.log(.7999999999999999),console.log(.15000000000000002),console.log(.8),console.log(.19999999999999998);
```

After:

```js
console.log(.1+.05),console.log(.7+.1),console.log(.1+.05),console.log(.8),console.log(.3-.1);
```

Verified with the full `oxc_minifier` test suite, minsize and allocation snapshots, Clippy, and formatting.

Closes #24863.

AI assistance: Codex was used to implement and verify this change.
2026-07-26 14:12:00 +00:00
Dunqing e9e0f24dca feat(minifier): shorten integer zero comparisons (#24933)
Integer-valued comparisons with zero remain verbose in value contexts, even when the minifier can prove that the left-hand side is an `int32` or `uint32`. Applying the same rewrite to every Number-valued expression would be incorrect for `NaN`, while bitwise expressions with unknown operands may still evaluate as BigInt.

Reuse the existing `IsInt32OrUint32` proof to lower equality with zero to logical negation and inequality with zero to double negation. The optimization remains inside binary-expression compression and does not change statement transforms or DCE.

```js
// before
f((a & 1) != 0);
f((a ^ 2) === 0);

// after
f(!!(a & 1));
f(!(a ^ 2));
```

Verified with `just ready`, `just minsize`, and `just allocs`. On the latest `main`, the minsize snapshot improves Three.js raw output from 642.46 kB to 642.45 kB and TypeScript GZIP output from 852.94 kB to 852.81 kB; allocations and compression-pass iterations are unchanged.

AI disclosure: Codex was used to research, implement, and verify this change.
2026-07-26 08:41:45 +00:00
Armano 34f555945c feat(minifier): convert switch with one case to if statement (#24611)
Inline switch with 1 case to if statement or in case of default to
statement expressions + block statement


https://github.com/oxc-project/oxc/actions/runs/29759622291#summary-88410679091

change extracted from #17523
ref #17544
2026-07-26 08:01:40 +08:00
Boshen baf022d74d refactor(ast)!: remove old AstBuilder methods (#24563)
Closes the migration tracked in #23043.

The new builder API (methods on the AST types themselves) has been in place for a while, all Oxc crates are migrated, and every crate which uses `AstBuilder` already enables `disable_old_builder` in its tests. The module docs said "after a few weeks, we will remove `AstBuilder`'s own methods entirely" — this does that.

## What's removed

- `crates/oxc_ast/src/generated/ast_builder.rs` and `builder/methods.rs` — `AstBuilder`'s own node and primitive constructors.
- `AstBuilderGenerator`, which produced the former.
- The `disable_old_builder` Cargo feature, plus the dev-dependency hacks it required across 33 manifests. Eight crates depended on `oxc_ast` *purely* to turn the feature on; those deps and their `cargo-shear` ignore entries are gone too.

## One thing worth reviewing

`AstBuilderGenerator` owned the `#[builder(default)]` / `#[builder(skip)]` attr registration that `BuilderMethodsGenerator` only *reads* — its doc comment said as much. Deleting it naively would have silently dropped `skip`/`default` handling for the 12 AST types using those attrs, so `attrs()` and `parse_attr()` move to `BuilderMethodsGenerator`.

The evidence this was faithful: after regenerating, `builder_methods.rs` and every other generated file are **byte-identical**. The only generated change in the diff is the deletion.

## Breaking changes

- `AstBuilder`'s own methods are gone. Use the AST type methods: `Statement::new_expression_statement(span, expr, &builder)` instead of `builder.statement_expression(span, expr)`.
- `AstBuilder` is no longer `Copy`/`Clone`, and its `allocator` field is private — use the `allocator` method from `GetAllocator`.
- `AstBuilder` and `NONE` are no longer re-exported from the crate root; import them from `oxc_ast::builder`. (The `disable_old_builder` feature already gated these off, so nothing in-tree used the root path.)
- The `disable_old_builder` feature no longer exists — remove it from any manifest that sets it.

A migration note covering all of the above is in `crates/oxc_ast/src/builder/mod.rs`.

---

Implemented with AI assistance (Claude Code); design, review, and verification driven by the author per the repo AI-usage policy.
2026-07-21 15:22:46 +00:00
sapphi-red d85276c894 fix(minifier): avoid incorrect logical assignment transformation when base object may be mutated (#24718)
Similar to #16802 but for cases without the returned value.
2026-07-21 03:31:04 +00:00
sapphi-red 87823d0d64 feat(minifier): merge combined assign expression in conditional expression (#24707)
Compress `x ? a += 0 : a += 1` to `a += x ? 0 : 1`.

refs #8345
2026-07-21 03:31:04 +00:00
sapphi-red 03a54c9e40 feat(minifier): merge assign expression against member expression in conditional expression (#24706)
Compress `x ? a.b = 0 : a.b = 1` to `a.b = x ? 0 : 1`.

refs #8345
2026-07-21 03:31:03 +00:00
Dunqing f145d73b6a fix(minifier): guard reordered identifier reads (#24698)
A locally read-only identifier is not necessarily stable across a side-effecting call. ESM imports are live bindings, Script-root bindings can be changed by another script, and computed assignment keys bypassed the existing closed-over-TDZ guard. Single-use substitution could therefore move a binding read before a call and change program behavior.

The same incomplete stability query affected logical-assignment folding. A live imported member base could be cached before a call that reassigns the export, making `||=` or `??=` update the old object instead of the current binding.

Build on the shared reorder predicates from #24699. Imports, Script-root bindings, unresolved references, syntactically mutated symbols, and closed-over block-scoped reads are treated conservatively across ordinary reads, assignment-target objects, computed keys, and member-base folding. Stable module bindings and pure replacements remain optimizable.

## Example

```js
var key = "x";
function init(obj) {
  let value = mutate();
  obj[key] = value;
}
```

The previous substitution could produce:

```js
var key = "x";
function init(obj) {
  obj[key] = mutate();
}
```

That captures `key` before `mutate()` runs, although another script can change the global binding during the call. The declaration is now retained so the read remains after `mutate()`.

For imported member bases, main can rewrite `x.y || (mutate(), x.y = 3)` to `x.y ||= (mutate(), 3)`. When `mutate` reassigns the exported `x`, the original two-module runtime repro produces `3`, while the rewritten program produces `0`. The shared stability guard now prevents that fold.

Plain computed assignments still inline stable identifiers and literals. ECMAScript evaluates the key expression and `GetValue` before the right-hand side, but defers `ToPropertyKey` until the final `PutValue`, so arbitrary key expressions remain conservative without losing safe simple-key substitutions.

This improves the existing reorder guard; it does not add a general TDZ model. Sloppy mapped `arguments` mutation remains a pre-existing unsupported case and is outside this stack.

Verified with the full `oxc_minifier` suite (622 passed, 42 ignored), clippy with warnings denied, formatting, minsize, allocation snapshots, runtime ordering probes, and repeat-compression checks. `cargo minsize` measured 18 additional raw bytes across `echarts.js` and `typescript.js`, with no iteration-count changes. System-allocation metrics are unchanged; arena changes are dominated by retained code with minor fold-path shifts.

## Stack

1. #24699 — centralize reorder stability checks
2. **#24698 — guard reordered identifier reads**

AI assistance: Codex and Claude were used for implementation, adversarial review, and verification. The contributor remains responsible for reviewing, understanding, and submitting the changes under the repository AI usage policy.
2026-07-20 09:07:50 +00:00
Armano 7b045cd417 feat(minfier): drop last break from last switch case (#24673)
After many trial and errors, this seem to be the best way to remove
break stmts in switch cases,

This change targets only last break in last case of switch smts if its
unlabelled

this change differs from #23914 as its no longer targets all branches as
that should not be no longer necessary if all cases are correctly
rewritten

https://github.com/oxc-project/oxc/issues/24672

-----

cases like `switch (a) { case 3: if (b) break; }` are not changed

-----

ref: #17544
2026-07-20 13:21:03 +08:00
Dunqing 7d3c178756 feat(minifier): remove unreachable recursive functions (#24125)
Reference counts cannot remove unreachable recursive functions because their self- and mutual references keep the counts nonzero even when no executable code can reach them.

This change computes function reachability after scoping is flushed. Normalize registers function declarations and bindings with implicit runtime observers. References outside registered functions make their targets live; references owned by a live registered function propagate liveness to their targets.

Graph deadness is used only to remove function declarations. Other transformations continue to use ordinary reference counts together with implicit-observability checks. Direct `eval` disables the analysis for the whole program while present. Exports, Script-root bindings, Annex B aliases, and `using` disposal remain observable even when they have no resolved AST reference.

## Example

Input:

```js
function self() { self() }
function a() { b() }
function b() { a() }
export {};
```

Output:

```js
export {};
```

## Supported

- Self-recursive and mutually recursive function declarations.
- References nested in blocks, arrows, function expressions, classes, and parameter defaults. The nearest registered function declaration owns each reference.
- ES modules, CommonJS, and local functions in Scripts. Script-root bindings remain visible to later scripts.
- Full compression and DCE-only mode.
- Reads, writes, exports, `using` disposal, and direct `eval` preserve live functions.
- Exact self-recursive variable initializers whose value is a `FunctionExpression` or `ArrowFunctionExpression`, including classic `for` initializers.

## Conservatively retained

- Mutual cycles between variable declarators, such as `const a = () => b(); const b = () => a()`.
- Recursive class cycles and mixed function/declarator/class cycles.
- Wrapped or arbitrary function-valued initializers, such as `const f = (effect(), () => f())`.
- Recursive declarators in `for-in` and `for-of` heads.
- Sloppy unhoisted Annex B block functions, whose runtime alias write is not represented by their block-scoped symbol.

## Performance

[CodSpeed's report for `e71c392`](https://github.com/oxc-project/oxc/pull/24125#issuecomment-4877260138) measured a 3.74% regression on `binder.ts`, where the analysis finds no dead graph. On `kitchen-sink.tsx`, removing 39 function declarations covering 25.3 kB of source early improved the minifier benchmark by 21.01% and the full pipeline by 8.76%. A separate 15-run Script/CommonJS corpus measured a 1.67% median increase overall, below the 2% acceptance threshold.

Verified with all-feature workspace tests, lint, rustdoc, formatting, AST generation, minsize and allocation snapshots, and the repeat-compression corpus.

Closes #13105
Closes #17364

Implemented with AI assistance (OpenAI Codex). The contributor remains responsible for reviewing, understanding, and submitting the changes under the repository's AI usage policy.
2026-07-18 03:23:27 +00:00
Armano 3acf4c1789 feat(minifier): expand switch optimiation to remove empty cases (#24520)
remove empty cases:
- before default or at end (if no default).
- only if they don't affect behavior.

refactored implementation from #17523 

ref #17544

---------

Co-authored-by: Dunqing <dengqing0821@gmail.com>
2026-07-16 10:36:03 +08:00
Armano 5b208289db feat(minifier): extract sequence expression from expressions (#22003)
fixes: #18258

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-13 22:39:24 +08:00
Dunqing 616bfa24df feat(minifier): remove unreachable code after terminating statements (#24441)
Folding a define-replaced branch can leave `if (true) { ... return fn; }` as a bare block: the lexical declaration inside pins the block (flattening it could collide with sibling bindings), and the statements after it survived even though the block always returns. rolldown's per-module tree-shake hits this shape and ships the dead branch (rolldown/rolldown#10184).

`minimize_statements` only flagged dead control flow on direct jump statements, each arm setting the flag by hand. The driver now flips the flag whenever the statement it just emitted never completes normally: a direct jump, a block ending in a jump, an if/else where both branches jump, or a try where every path jumps. That single check subsumes the per-arm writes, so the `&mut bool` threaded through the return/throw handlers is gone. The removal reuses the existing dead-zone machinery, so `var` hoisting and kept function declarations behave as before, and both DCE and full-minify modes are covered.

A minimized dead zone keeps hoisting survivors after the jump — `function` declarations and the initializer-less `var` stub KeepVar re-emits — so the block predicate skips those from the back instead of testing only the literal last statement; nothing after an aborting statement ever runs, so they cannot change how the block completes.

The helper is conservative on purpose: labeled statements never count (`a: { break a; }` completes normally), and loops and switches are left to their specific folds. Terser removes the same tails; esbuild keeps them. The leftover `{ ... }` block wrapper is also kept — flattening a block whose lexical declarations may collide with siblings is a separate rewrite, and terser and esbuild keep the braces too.

## Example

`__SSR__` defined to `true` (rolldown treeshake, `minify: false`):

```js
export function test1() {
  if (true) {
    const fn = () => console.log("ssr");
    fn.stop = fn;
    return fn;
  }
  const fn = () => console.log("client");
  fn.stop = fn;
  return fn;
}
```

Before, the unreachable client tail stayed in the output. After:

```js
export function test1() {
	{
		const fn = () => console.log("ssr");
		fn.stop = fn;
		return fn;
	}
}
```

Verified with the minifier unit suite, test262/babel minifier conformance (no changes), DCE `--twice` idempotency, and `just minsize` (antd gzip 455.51 kB -> 455.50 kB, iteration counts unchanged).

Fixes rolldown/rolldown#10184.

Implemented with AI assistance (Claude Code); design, review, and verification driven by the author per the repo AI-usage policy.
2026-07-13 08:43:59 +00:00
camc314 87841eebfd chore(all): fix clippy::useless_borrows_in_formatting (#24347) 2026-07-10 09:01:12 +00:00
Dunqing b79eef7a15 feat(minifier): apply De Morgan's law to negated comparison chains in jump guards and loop tests (#24279)
### What

Terser rewrites `!(a==b||c==d)` into `a!=b&&c!=d`. oxc only removed the `!` when a later pass could consume it for free, such as swapping if-else branches. In guards that end in a jump (`throw`, `return`, `break`) and in loop tests there is nothing to swap, so the negation stayed in the output.

### How

`minimize_unary` gets a `LogicalExpression` arm that distributes the `!` over `&&`/`||` chains. It only fires when every comparison in the chain can invert its operator in place (`==`, `!=`, `===`, `!==`; relational operators are excluded because `!(a<b)` is not `a>=b` under NaN) and when flipping `&&`/`||` does not add parentheses. Under those rules the fold always saves bytes and is its own inverse, so shapes that re-negate the test later (branch swaps, `!!` collapses) still reach their old output.

DCE mode is untouched; `minimize_unary` only runs in full minify.

### Example

Before:

```js
export function f(e,t,n,r){if(!(e==t||n==r))throw Error(`x`)}export function g2(e,t,n,r){for(;!(e==t||n==r);)h()}
```

After:

```js
export function f(e,t,n,r){if(e!=t&&n!=r)throw Error(`x`)}export function g2(e,t,n,r){for(;e!=t&&n!=r;)h()}
```

### Size

jquery, d3, three, victory, echarts, typescript, react and bundle.min each shrink by 0.01–0.02 kB minified; no fixture grows. jquery's gzip column ticks up 0.01 kB even though its raw output shrank — a compression artifact of an equal-or-smaller input. Arena allocation counts rise slightly (+1 to +11 per fixture) from a transient `!` wrapper that re-negation builds and immediately unwraps.
2026-07-08 02:58:34 +00:00
Dunqing 34ff7b4ee8 feat(minifier): drop write-only property assignments to unused local bindings by default (#24112)
## What

Drop `o.x = value` when `o` is a local variable that is never read — by default. Until now this only happened with the `treeshake.property_write_side_effects: false` opt-in. terser does this by default; esbuild does not do it at all.

This matters because one leftover write keeps a lot of code alive. antd bundles 820 icon modules that each end with `o.displayName = 'UserOutlined'`; that single write kept each module's whole require-chain alive. **antd.js: 2.22 MB -> 2.13 MB minified** (458 kB -> 455 kB gzip). oxc now beats esbuild on antd by ~180 kB.

## How

The drop itself uses machinery that already exists for the opt-in path (`is_member_assign_to_unused_binding`: the object must be a fresh value — function/class/object/array literal — not exported, never written as a whole, and every read of it must be the target of a property write).

What is new is making it safe without the opt-in:

- A program-wide fact table (`PersistentSymbolFacts`, bit `SymbolFact::MEMBER_WRITE_HAZARD`) is filled once by `Normalize`, before the compress loop. The container is generic on purpose: monotone symbol facts, seeded pre-loop, never cleared — a stale set bit only forgoes an optimization. A follow-up migrates `proto_write_symbols` into it as `PROTO_WRITTEN`, fixing that field's execution-order TODO. A symbol goes in when any of its property writes could be observed: read-modify ops (`o.x += 1` reads the property), chained writes (`a.b.c = 1` reads `a.b`, so dropping `a.b = {}` would throw), and `__proto__` or computed keys (may install setters). A hazarded symbol never gets its writes dropped.
- The set also grows mid-loop when a pass rewrites `o.x = o.x + 1` into `o.x += 1`.
- Only plain `=` with a single-level, non-`__proto__` key qualifies. `o[k()] = v` bails.
- A side-effectful right side is kept: `o.x = impure()` becomes `impure()`.

Once the write is gone, the variable is unused and the existing passes delete the declaration and everything only it referenced.

## Behavior change

The only assumption this makes (documented in `docs/ASSUMPTIONS.md`): setters installed on `Object.prototype` / `Function.prototype` / `Array.prototype` are not observed by these writes — the same assumption the existing opt-in flag makes, and terser's default.

A 98-case audit (node ground truth vs oxc vs terser, strict and sloppy) hardened everything else. Writes that throw or have exotic effects are now kept even though the binding is unused, via a kind-aware key denylist: `caller`/`arguments`/`name`/`length` on function- and class-valued bindings, `prototype` on class-valued (class prototypes are non-writable; plain function `prototype` writes still drop — they are writable and a real size win), `length` on array-valued (the array length setter coerces its RHS and can throw RangeError), private-field writes (brand-check TypeError), and classes with static blocks, decorators, or static accessors are not treated as fresh. Post-audit the matrix shows zero unsound cases beyond the documented prototype-setter class — stricter than terser, which for example still drops `a.length = -1` (losing the RangeError) where oxc now keeps it. No size cost: the minsize corpus is byte-identical, antd keeps the full 90 kB win.

## Example

```js
// input
(function() {
  var r = require('react');
  var i = require('./asn/UserOutlined');
  var o = function(e, t) { return r.createElement(i.a, e, t); };
  o.displayName = 'UserOutlined';
})();
```

```js
// before (everything kept)
(function(){var e=require("react"),t=require("./asn/UserOutlined"),n=function(n,r){return e.createElement(t.a,n,r)};n.displayName=`UserOutlined`})();

// after (only the side effects remain)
(function(){require("react"),require("./asn/UserOutlined")})();
```

Found by re-minifying oxc's minsize output with `terser -c`; this was the single largest gap between oxc and terser. Full conformance suite passes with zero snapshot changes; minsize stays a fixed point (antd takes one extra compress iteration).

Implemented with Claude Code, reviewed and verified by me.
2026-07-08 02:28:23 +00:00
Dunqing 2d9b0b3c76 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.
2026-07-06 08:26:10 +00:00
Dunqing da0e5bf668 fix(minifier): don't reorder a closed-over TDZ read when inlining a var (#23771)
## Summary

- The single-use-variable inliner could merge `let num = await f(); g(v, num)` into `g(v, await f())`, moving the read of a closed-over lexical `v` ahead of the `await`. When the enclosing function runs while `v` is still in its Temporal Dead Zone (called before `let v` executes), the merged form throws `ReferenceError: Cannot access 'v' before initialization` while the original does not — a Svelte production-only crash surfaced via rolldown ([rolldown/rolldown#9959](https://github.com/rolldown/rolldown/issues/9959), [sveltejs/svelte#18454](https://github.com/sveltejs/svelte/issues/18454)).
- The fix adds a guard, `is_tdz_closed_over_read`, that blocks the reorder only for a **block-scoped binding closed over from an enclosing function** — the one shape that can observe the TDZ across an `await`/`yield`. Same-function lexicals, `var`, and parameters still inline; the member assignment-target path (`v.x = await f()`) gets the same guard.
- Covered by `inline_single_use_variable.rs::test_inline_read_before_await_tdz` (closed-over `let`/`const`/`using`/`class`, generator `yield`, member targets, two-level nesting, plus the safe `var` / parameter / same-function cases). Full `oxc_minifier` suite passes; `minsize` unchanged except `bundle.min.js` (+~10 bytes).

## The bug

The crash needs `init()` to run *before* `let v` initializes — here it is called in `p`'s initializer (circular imports cause the same shape), so `v` is in its TDZ while `init` runs:

```js
let p = init(), v = ext();
async function init() {
  let num = await foo();
  bar(v, num);          // reads `v` AFTER the await: the await suspends, module
                        // eval runs `v = ext()`, then `init` resumes → `v` is set
}
export { p };
```

Inlining merges the temp into the argument, moving the read *before* the await:

```js
// before this PR (incorrect)
async function init() {
  bar(v, await foo());  // reads `v` before the await → still in TDZ →
}                       // ReferenceError: Cannot access 'v' before initialization

// after (correct): the temp is preserved, the read stays after the await
async function init() {
  let num = await foo();
  bar(v, num);
}
```

(With a *normal* late call — `init` imported and invoked after this module finishes evaluating — `v` is already initialized and there is no crash. The inliner still keeps the temp because it cannot prove the call is late.)

## How it works

A reorder turns a **working** program into a **crashing** one in exactly one shape: the moved read is a **block-scoped binding** (`let`/`const`/`using`/`class`/`enum`) that is **closed over from an enclosing function**. The mechanism:

1. The enclosing `async`/generator function is called *before* the binding's declaration has run, so the binding is in its TDZ.
2. The original reads it *after* an `await`/`yield`. During that suspension, outer code runs the declaration and initializes the binding — so the read succeeds.
3. Inlining moves the read *before* the `await` (before the suspension), so it now observes the TDZ and throws.

A binding declared in the **same** function can't trigger this: the body runs top-to-bottom on every call, so the binding is always initialized before the read — it can't be initialized "mid-suspension" by the function's own later code. `var` / parameters have no TDZ at all.

So the guard blocks the reorder exactly when the read is **block-scoped and closed over**, and detects "closed over" structurally — walking from the read's scope out to the binding's declaration scope and checking whether a **function boundary** is crossed first (suspensions only live inside function scopes):

```rust
// unsafe to reorder iff: block-scoped binding AND the read crosses a
// function boundary before reaching the binding's declaration scope
is_block_scoped(symbol) && read_crosses_function_boundary(read_scope, decl_scope)
```

### Blocked — closed over (kept)

Each is kept because `v` is a closed-over block-scoped binding and the inliner can't prove `init` is never called early (see **The bug** above). For the call-argument case, with that early call spelled out:

```js
let p = init(), v = ext();   // init() runs before `v = ext()` → `v` in TDZ
async function init() {
  let num = await foo();
  bar(v, num);               // kept: merging would read `v` before the await → ReferenceError
}
```

The same guard covers a member assignment target (the object is read before the write) and generators (`yield` is a suspension point too):

```js
// member target
let v = ext();
export async function init() { let num = await foo(); v.x = num; }   // kept

// generator
let v = ext();
export function* init() { let num = yield foo(); bar(v, num); }      // kept
```

### Still inlined — no TDZ hazard

```js
// parameter: not block-scoped, no TDZ
export async function init(v) {
  let num = await foo();
  bar(v, num);            // → bar(v, await foo())
}

// `var`: function-scoped, no TDZ
export async function init() {
  var v = ext();
  let num = await foo();
  bar(v, num);            // → bar(ext(), await foo())
}

// same-function lexical: initialized before the read
export function outer() {
  const v = ext();
  return bar(v, ext2());  // → return bar(ext(), ext2())
}
```

## Comparison with other minifiers

Verified by minifying each case and running the output (early-call repro where `init()` runs before `let v` initializes):

| Case | Sound to fold? | this PR | esbuild | terser | SWC |
|---|---|---|---|---|---|
| closed-over `let v` as call arg `g(v, num)` | no (TDZ) | keep ✓ | keep ✓ | keep ✓ | fold ✗ |
| closed-over `let v` as member target `v.x = num` | no (TDZ) | keep ✓ | keep ✓ | keep ✓ | fold ✗ |
| plain assign `v = num` | yes | fold | fold | keep | fold |
| same-function `const` skipped past | yes | inline | keep | inline | inline |
| parameter `v` as call arg | yes | fold | keep | keep | fold |

esbuild and terser already decline the unsound reorder; this PR aligns oxc with them on the two TDZ cases while keeping oxc's existing (sound) folds on the rest. SWC currently performs the same unsound reorder and reproduces the `ReferenceError`.
2026-06-29 09:34:27 +00:00
overlookmotel 85a6853e0b fix(tasks): enable disable_old_builder Cargo feature for oxc_ast crate for all tasks (#23885)
Part of #23043.

We don't enable `oxc_ast`'s `disable_old_builder` Cargo feature in any of our published crates, or crates that are depended on by published crates.

Tasks are private to the repo, so enable this feature on them, to ensure no usage of legacy `AstBuilder` methods sneak in during the transition period before we delete these methods.
2026-06-28 12:13:17 +00:00
Dunqing 6883fcf158 feat(minifier): fold write-once falsy var to false in boolean context (#23540)
## What

A `var` initialized to a falsy constant and never reassigned is always falsy. oxc already folds such a `let`/`const` everywhere, but a `var` is held back by a hoisting check (a read before the declarator runs sees the hoisted `undefined`), so a multi-read `var` flag past a non-trivial declarative prelude was never folded.

In **boolean context** that check is unnecessary: a pre-init `var` read yields `undefined`, which is indistinguishable from the falsy init inside `if (x)` / `x ? … : …` / `!x`. So fold such reads to `false` there; the existing `if (false)` dead-code pass removes the branch.

This is the bundled-flag shape behind this issue. Svelte's `var hydrating = false` is read by `if (hydrating)` throughout the runtime; once a client-only bundle tree-shakes the `set_hydrating` setter, `hydrating` is a write-once falsy `var` and the whole hydration path becomes dead and is eliminated.

## Why it is sound

- `SymbolValue::boolean_falsy` is set only for a write-once binding with a falsy constant initializer.
- It folds to `false` **only** in boolean context; value-context reads keep their hoisting-correct behavior (never folded here).
- Gated off for a script's top-level `var` (a global another script can reassign, so an in-module write count of 0 doesn't prove write-once) and for `eval` scopes.

## Verification

- `oxc_minifier` tests pass; idempotent.
- On a real client-only Svelte (Vite/Rollup) bundle, every `if (hydrating)` check is eliminated.
- `minsize`: react 23.14 → 23.12 kB, lodash 70.96 → 70.92 kB (gzip down too). `allocs_minifier.snap`: +20 sys allocs on antd (from evaluating `var` initializers; arena allocs unchanged).
- Cross-validated for soundness: on the Svelte bundle, terser `--module` and esbuild `--format=esm` independently fold the same write-once flag (16 → 0), agreeing with oxc. On a Vue 3 client bundle the runtime's flags are multi-write (e.g. `isInSSRComponentSetup` has two assignments, so it can be truthy) and oxc correctly leaves them untouched — the write-once gate is the right line.

## Scope (partial fix)

This handles the real-world **bundled** shape — a write-once falsy `var` flag read in boolean context (the Svelte/Vue hydration case, where the bundler has already tree-shaken the setter). It does **not** fold the issue's literal source snippet:

```js
let foo = false;
function bar(val) { foo = val; }
if (foo) { bar(true) }
console.log(foo);
```

There `foo` is still reassigned via `bar` (so it isn't write-once), and proving the write dead requires self-referential / flow-sensitive analysis — out of scope here (see #22753 for that direction). So this is a partial step.

Refs #14001.

Prepared with AI assistance.
2026-06-23 05:48:45 +00:00
Dunqing 53509a85df feat(minifier): treeshake pure typed arrays and Set/Map array literals (#23469)
## Summary

Mark side-effect-free `new` constructions pure so they are dropped when unused (and annotated for downstream tree-shaking):

- **TypedArrays** with a non-negative numeric-literal length (`new Int8Array(8)`, `new Uint8Array(1024)`). The buffer allocation runs no user code; a valid length is pure and a too-large length only throws a maximum-length `RangeError`, which the minifier is allowed to drop (see `docs/ASSUMPTIONS.md`). `new Int8Array(-1)` (negative-length `RangeError`), `new Int8Array(0n)` (BigInt `TypeError`), object, and non-literal arguments are kept.
- **`new Set([...])` / `new Map([[..], ..])`** with array-literal arguments; element side effects are preserved when the construction is dropped. `WeakSet`/`WeakMap` are excluded (their keys must be objects, so `new WeakSet([1])` throws), and `Map` entries must be array literals (`new Map([1])` throws).

This matches esbuild's per-constructor handling — the only other minifier that keeps every throwing form; Rollup, Terser (`unsafe`), and SWC drop some of these incorrectly. Cross-verified against the ES spec, esbuild, Terser, Rollup, and SWC.

Minified-size impact: react.development.js −0.04 kB; size-neutral elsewhere (real code uses dynamic buffer lengths, not numeric literals).
2026-06-16 07:46:28 +00:00
Dunqing 09762d9224 feat(minifier): inline const value for read-only vars (#22593)
## Summary

The minifier skipped constant-inlining for **every** `var`, blaming TDZ. The real constraint is narrower — `var` *hoisting* — and most `var`s are still safe to inline. This lifts the blanket skip and inlines a `var`'s constant whenever no read can observe its hoisted `undefined`. Closes #13051.

## Why

A `var x = <literal>;` binds `x` to `undefined` at the top of its scope and assigns the literal only in source order. A read in that window sees `undefined`, so inlining the literal everywhere would change behavior there — which is why the old code skipped all `var`s. But that also blocked a common, perfectly safe pattern from optimizing in DCE mode:

```js
var DEBUG = false;
function check() { if (DEBUG) console.log('debug'); }
console.log(check());
```

vite 8's `topLevelVar: true` rewrites top-level `let`/`const` to `var`, so users' flag patterns silently stopped tree-shaking — vitejs/vite#22363 → rolldown/rolldown#9279.

## The safety principle

Inline only when the value at the read is provably the assigned literal, never the hoisted `undefined`. Concretely, no user code may run between scope entry and the assignment that could read `x`. A `var x = <literal>;` qualifies when all of:

- **It is in a still-declarative body.** It sits directly in the current function/program body, and every preceding statement is *declarative* — runs no user code (`is_declarative_body_statement`: function/empty/type-only declarations, module re-exports, and literal-initialised simple `var`s). The first non-declarative statement ends the body's "declarative prelude"; this is also enforced per-declarator for multi-declarator statements.
- **Its initializer is a `ConstantValue`.**
- **It is not a top-level script var** (those alias the global object).
- **At program scope, the module loads no other module** (`import` / `export … from` / `export * from`) — a cyclic importer could call into our exports and observe a captured var before assignment, regardless of export status.
- **It has exactly one read, crossing a function boundary** — the gap that `substitute_single_use_symbol` (same call frame) and the small-value inliner don't fill. This also keeps the change byte-positive: dropping `var x = L;` and replacing one read with `L` always saves bytes.

The prelude flag is a per-body stack pushed/popped at function boundaries, so the analysis runs independently for nested functions. Reassignment is handled by the existing `write_references_count` guard, and direct-eval scopes are excluded.

**Verification.** A battery of hoisting / closure / reassignment programs was run through the minifier and then *executed*, checking every output reproduces the original's observable behavior. All pass — output is semantically equal to source on each, matching Terser.

## Comparison with other minifiers

Behaviour checked against Terser 5.48, esbuild 0.24, and SWC 1.15:

- **esbuild** only propagates `const` primitives — it never inlines a `var` cross-scope. So `const DEBUG = false; …` folds to `console.log(void 0)` but `var DEBUG = false; …` stays. That is exactly the regression path: `topLevelVar: true` rewrites `const`→`var`, defeating const-only propagation. oxc had the same blanket-`var` skip; this PR lifts it for the provably-safe subset.
- **Terser / SWC** reach the same result (and more — flow-sensitive reassignment, call inlining) via multi-pass `reduce_vars`/`collapse_vars`. This PR recovers the common flag pattern with one syntactic prelude check instead of that machinery, staying single-pass.

## Results

`minsize` shows small gzip improvements on lodash / victory / antd / typescript; jquery is byte-identical.

Closes #13051
2026-06-16 02:09:14 +00:00
Boshen 777f02ae10 refactor(diagnostics)!: return a Diagnostics type instead of Vec<OxcDiagnostic> (#23217)
## Summary

Introduces `oxc_diagnostics::Diagnostics` — a `Vec<OxcDiagnostic>` newtype that knows the difference between errors and warnings — and makes it the standard diagnostics return value across oxc.

- **New type**: `Diagnostics` with `has_errors()`, `has_warnings()`, `errors()`, `warnings()`, plus `Vec`-like ergonomics (Deref, `IntoIterator`, `Extend`, `From`/`Into<Vec>`) so most call sites are unchanged.
- **Return types**: `ParserReturn`, `SemanticBuilderReturn`, `IsolatedDeclarationsReturn`, the React Compiler `TransformResult`/`LintResult`, and `TransformerReturn` now expose `diagnostics: Diagnostics` instead of `errors: Vec<OxcDiagnostic>` (the React Compiler's separate `errors`/`warnings` are merged into one severity-tagged list).
- **Behaviour**: a React Compiler **error** now stops the transform (no output), while **warnings** flow through and the transform continues. `compile()` aborts codegen only when `diagnostics.has_errors()` — warnings alone no longer abort (previously any diagnostic did).

## Breaking change

Consumers reading `*.errors` on these return values must read `*.diagnostics`. React Compiler consumers that read `.errors`/`.warnings` separately should use `diagnostics.has_errors()` / `diagnostics.errors()` / `diagnostics.warnings()`.

Parser/semantic/isolated-declarations fatality is otherwise unchanged. Builds on the React Compiler transform feature (#23201).

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-10 14:46:52 +00:00
Boshen 37169ffa75 fix(codegen): don't emit space between postfix -- and > when minifying (#23036)
## What

`a-- > b` was minified as `a-- >b` (a space before `>`) to avoid producing the `-->` token sequence. But `-->` is only an HTML close comment (`SingleLineHTMLCloseComment`) at the **start of a line**, and a postfix `--` always has an operand before it — so `a-->b` is never a comment and always parses as `(a--) > b`.

| Input | Before | After |
| --- | --- | --- |
| `while (i-- > 0) f()` | `while(i-- >0)f()` | `while(i-->0)f()` |
| `x = a-- > b` | `x=a-- >b` | `x=a-->b` |

Verified in node (`while(i-->0)` yields `[2,1,0]`). This goes one step beyond esbuild, which keeps the conservative space.

The `< ! --` guard (avoiding `<!--`, which *can* appear mid-line) is left untouched.
2026-06-07 02:52:51 +00:00
Boshen 28467cea77 fix(codegen): don't emit space before a postfix update operand when minifying (#23027)
## What

Found with the whitespace analyzer. The postfix branch of `UpdateExpression::gen_expr` called `print_space_before_operator(operator)` **before** printing the operand. For a postfix `x++` the `++` comes *after* the operand, so this treated the trailing `++` as if it immediately followed a preceding `+` (the `+`/`++` guard exists to avoid `+++` in prefix `x + ++y`).

Result, when minifying:

| Input | Before | After |
| --- | --- | --- |
| `x + y++` | `x+ y++` | `x+y++` |
| ``a + `-` + i[1]++`` | ``a+`-`+ i[1]++`` | ``a+`-`+i[1]++`` |

## Fix

Matches esbuild's `EUnary` printer: for postfix, print the operand first, then call `printSpaceBeforeOperator` immediately before the operator text. The guard `prev_op_end == code.len()` then makes it a no-op (the operand was printed since the last operator), so `x+y++` stays tight. Required spaces from the prefix/binary guard are unaffected:

- `x + ++y` → `x+ ++y`, `x - --y` → `x- --y`, `b + +c` → `b+ +c`

Output matches esbuild exactly. Codegen (116) + minifier (506) pass; minsize updated.
2026-06-06 16:45:37 +00:00
Boshen 62965ae72c fix(codegen): drop redundant space after else when minifying (#23025)
## What

Found with the whitespace analyzer (remove each space in minified output, check it still canonicalizes to the same AST). `print_body` printed an unconditional hard space before a single-statement body, so an `else` body kept a space even before non-identifier tokens:

| Input | Before | After |
| --- | --- | --- |
| ``if(x)a();else`t` `` | ``else `t` `` | ``else`t` `` |
| `if(x)a();else++b` | `else ++b` | `else++b` |
| `if(x)a();else[b]` | `else [b]` | `else[b]` |

esbuild emits these tight too.

## Fix

The body statement's first token already inserts a hard space via `print_space_before_identifier` whenever it would merge with a preceding identifier char — this is how `for`/`while`/`do` bodies (which pass `need_space=false`) already work; they're safe because they end in `)`. `else` ends in `e`, so the unconditional space was added as a blunt guard.

Removing it and relying on the body's own guard keeps the required spaces (`else x`, `else return 5`, `else debugger`, `else var k`, `else 5`) while dropping the unnecessary ones. The `need_space` parameter is now unused and removed.

Verified: every keyword-statement else-body (`return`/`throw`/`debugger`/`var`/…) self-guards; full codegen (116) + minifier (506) suites pass, and a 150-file real-world minify round-trip is idempotent.
2026-06-06 15:59:34 +00:00
Dunqing a069b1c631 fix(codegen): preserve quotes for cjs-module-lexer equality strings (#22551)
## Summary

- Force plain string quotes for `"default"` / `"__esModule"` operands of `==`/`===`/`!=`/`!==` during minification, so `cjs-module-lexer` can recognise the `Object.keys(mod).forEach(key => key === "default" || ...)` re-export filter emitted by Babel / TypeScript / Rollup.
- Follow-up to #22475 (which fixed `require("…")`). Same root cause: `calculate_quote_maybe_backtick`'s equal-cost tie-breaker prefers backtick, which the lexer's heuristic parser doesn't accept.
- Caught by monitor-oxc's `whitespace` runner: minifying `@babel/types/lib/index.js` previously broke `import { isIdentifier } from "@babel/types"` under `--experimental-require-module`. Raw minified size is unchanged across all `minsize` fixtures.

## Example

Input:

```js
Object.keys(mod).forEach(function (key) {
  if (key === "default" || key === "__esModule") return;
});
```

Before (broken — `cjs-module-lexer` skips the re-export):

```js
Object.keys(t).forEach(function(e){if(e===`default`||e===`__esModule`)return});
```

After:

```js
Object.keys(t).forEach(function(e){if(e==="default"||e==="__esModule")return});
```

AI disclosure: drafted with Claude Code, reviewed manually.
2026-05-19 08:43:14 +00:00
Alexander Lichter 0f26de6dd2 fix(ecmascript): resolve identifier value type via tracked constants (#22234)
`value_type` for `Expression::Identifier` returned `Undetermined` for
any non-global identifier, **even when the binding had zero writes and a
known constant initializer**. This causes code like `slot?.()` on a `let
slot;` with all writers tree-shaken away not being folded to `void 0`,
leaving dead code in the output.

This PR changes that by checking
`GlobalContext::get_constant_value_for_reference_id` for non-global
identifier references and mapping the resulting `ConstantValue` to a
`ValueType` via a new private helper.

Once the identifier resolves to a tracked nullish value, the existing
nullish branch in `fold_chain_expr` collapses the chain to `void 0`. The
same lookup also works for the opposite case (#21923).

The lookup is gated by making sure there are no write references, so
anything that is being written anywhere keeps its `Undetermined` type.

Closes rolldown/rolldown#9281
Related (but does not resolve) #21923

---------

Co-authored-by: Dunqing <dengqing0821@gmail.com>
2026-05-18 16:59:34 +08:00
zennnnnnn11 b9615b2ed9 fix(codegen): preserve string quotes in require() calls during minification (#22475)
Closes #22463

## Summary

- Add `try_print_require_call` to force plain string literals (no
backticks) for `require("...")` calls during minification
- Prevents `cjs-module-lexer` from failing to detect CJS reexport
sources

### Problem

When minifying, `StringLiteral::gen` passes `allow_backtick: true`,
which can convert:
```js
__exportStar(require("./decorators"), exports);
```
into:
```js
__exportStar(require(`./decorators`),exports);
```

Node.js uses `cjs-module-lexer` for CJS/ESM interop. This lexer uses a
fast heuristic parser that does **not** recognize template literals as
`require()` arguments, causing named reexports to silently disappear
from the module's export list.

### Fix

Add `try_print_require_call` (following the existing
`try_print_cjs_define_property_call` pattern) that detects
`require("...")` calls via the existing
`CallExpression::common_js_require()` helper and prints the string
argument with `allow_backtick: false`.

## Test plan

- [x] `require("./decorators")` stays quoted when minified
- [x] `require('./foo')` normalizes to double quotes (not backticks)
- [x] `require(foo)` (dynamic) is unaffected
- [x] Non-require calls like `foo("./bar")` still get backtick
optimization
- [x] `cargo test -p oxc_codegen` passes
- [x] Follows existing CJS special-case pattern in codegen

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

---------

Co-authored-by: zennnnq <ennanzhou@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Dunqing <dengqing0821@gmail.com>
2026-05-18 08:42:42 +08:00
Dunqing 18edc2ce8f fix(codegen): keep Object.defineProperty property name as plain string in minify (#22400)
Fix `cjs-module-lexer` failing to detect `Object.defineProperty(exports, "name", ...)` named exports after oxc minify.

`calculate_quote_maybe_backtick` favored backtick when all three quote costs tied at zero, so a plain ASCII property name like `"getInclusionReasons"` was emitted as a backtick template. [`cjs-module-lexer`](https://github.com/nodejs/cjs-module-lexer) (used by Node for CJS/ESM interop) only matches plain string literals there, so the affected exports silently dropped out of its export set — downstream ESM consumers then failed with `Named export 'X' not found`.

In `CallExpression::gen_expr`, detect `Object.defineProperty` / `Reflect.defineProperty` and print the 2nd argument via `print_string_literal(name, false)` (the existing `allow_backtick: false` path). Other arguments — and nested strings inside the descriptor — still benefit from backtick optimization. Minify-only: outside minify, `print_string_literal` uses `self.quote` (never backtick), and `print_arguments` is needed there to preserve argument comments.

### Before / after

```js
// input
Object.defineProperty(exports, "getInclusionReasons", { ... });
```

```js
// before
Object.defineProperty(exports,`getInclusionReasons`,{...});

// after
Object.defineProperty(exports,"getInclusionReasons",{...});
```

Closes #22342

AI assisted.
2026-05-14 10:28:48 +00:00
sapphi-red 2338e28f59 fix(ecmascript): treat this as potentially having side effects (#21297)
fixes https://github.com/oxc-project/oxc/issues/21296 by treating `this` as potentially having side effects.
We can reduce the case that is bailed out by checking whether `this` is written inside `constructor` or not in the future.
2026-04-11 14:25:36 +00:00
Ulrich Stark 1a370a65c8 fix(minifier): inline single-use vars past non-computed object keys (#20810)
Fixes #20802

This fixes a bug in single-use variable inlining where object literals
with non-computed string keys like `"-a"` were incorrectly treated like
computed properties and stopped further substitution.
2026-03-28 13:29:00 +08:00
John Costa 2917bb2d49 feat(minifier): minify x ? 1 : 0 to +x or +!!x (#20594)
Closes #20288

Adds numeric conditional expression optimization to the minifier:

- `x ? 1 : 0` → `+x` when `x` is known boolean (saves 3 chars)
- `x ? 1 : 0` → `+!!x` when `x` is unknown type and no parens needed
(saves 1 char)
- `x ? 0 : 1` → `+!x` when no parens needed (saves 2 chars)
- Skips when parentheses would make the result the same length or longer
(e.g., `a+b?1:0`)

Profitability checks are based on the lessons from swc-project/swc#9920.

Implementation note: uses `DetermineValueType` to check if the test
expression is boolean, and a simple `test_needs_parens` helper (matching
`BinaryExpression`, `LogicalExpression`, etc.) to determine if wrapping
would negate the savings.

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-23 23:58:11 +08:00
Dunqing e7163b6720 feat(ecmascript): add known-globals to side-effect-free property reads (#20212)
## Summary

close: #13067

Part of https://github.com/oxc-project/oxc/issues/19673

Depends on #20217.

Port known-globals lists from Rolldown to Oxc's side-effect detection:

- **Global identifiers** (Math, Array, console, DOM classes, etc.) are now considered side-effect-free to access via `is_known_global_identifier`, matching Rolldown's `GLOBAL_IDENT` set
- **Property reads** like `Math.PI`, `console.log`, `Object.keys` are considered side-effect-free via `is_known_global_property`
- **3-level chains** like `Object.prototype.hasOwnProperty` are supported via `is_known_global_property_deep`

## Test plan

- [x] Added tests for known global identifiers (Math, Array, Object, console, etc.)
- [x] Added tests for known global property reads (Math.PI, Object.keys, etc.)
- [x] Added tests for 3-level chains (Object.prototype.hasOwnProperty, etc.)
- [x] Existing minifier test expectations updated for improved side-effect detection
- [x] All existing tests pass

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-03-12 09:22:57 +00:00
sapphi-red af0ca46d70 feat(span)!: use ModuleKind::CommonJS for SourceType::cjs() (#18276)
Follow-up to #18089.

Maybe we should add `SourceType::script()` so that it's easier to create a SourceType with `ModuleType::Script`.
2026-01-20 06:09:26 +00:00
Armano d1016b9e60 feat(minifier): do not flip if/else when it would produce longer result (#17556)
Disable flipping of negated `if` statement when `alternate` is `if`
statement

when `if else if` is encountered, flipping always produces at-least one
extra character (in many cases 5) as new `consequent` has to be wrapped
in `{}`, this also prevents `remove_dead_code` and `minimize_statements`
to be run to covert if statement's to ternary expression
2026-01-15 08:56:32 +08:00
Boshen 9c6e344f9f feat(minifier): prune empty case before trailing default (#17994)
## Summary

- Adds minification optimization to remove empty `case` clauses that precede a trailing `default` clause when those cases contain only primitive literals
- Port of https://github.com/evanw/esbuild/commit/add452ed51333953dd38a26f28a775bb220ea2e9

### Example

```javascript
// Before:
switch (x) { case 0: foo(); break; case 1: default: bar() }

// After:
switch (x) { case 0: foo(); break; default: bar() }
```

## Test plan

- [x] Added tests for basic case with empty literal before default
- [x] Added tests for multiple empty literal cases
- [x] Added tests for non-literal identifiers (should NOT be removed)
- [x] Added tests for default not at end (preserved)
- [x] Added tests for string, null, and BigInt literals
- [x] All existing minifier tests pass

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-01-14 12:56:25 +00:00
Boshen 423c27e260 test(rust): reenable lib tests that only run for code coverage 2026-01-01 11:12:14 +08:00
Boshen e031056b2f feat(codegen): add sourcemap feature flag (#17305)
Oxlint uses `oxc_codegen` for fixes, but do not require sourcemap.
2025-12-23 10:28:49 +00:00