Files
oxc-project__oxc/crates/oxc_minifier/docs/ASSUMPTIONS.md
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

9.9 KiB

Minifier Assumptions

The Oxc minifier makes certain assumptions about JavaScript code to achieve optimal compression. These assumptions are standard for typical JavaScript but may not hold for unusual code patterns.

These assumptions are validated using ECMAScript operations from oxc_ecmascript, which implements spec-compliant behavior for type conversions, side effect analysis, and constant evaluation.

Core Assumptions

These assumptions are held regardless of the options.

No Monkey-Patching Built-ins

Built-in objects and their methods and properties behave as specified in ECMAScript.

// The minifier assumes this never happens:
Array.prototype.push = function () {
  console.log("hijacked!");
};
Object.defineProperty(Number.prototype, "toString", { value: () => "hijacked!" });

No document.all Usage

The deprecated document.all with its special typeof behavior is not used.

// The minifier assumes this never happens:
typeof document.all === "undefined"; // true in browsers
document.all && console.log("exists but falsy");

No with Statement

Code does not use with statements which create ambiguous scope.

// The minifier assumes this never happens:
with (obj) {
  x = 1; // Is this obj.x or a global x?
}

Coercion Methods Are Pure

.toString(), .valueOf(), and [Symbol.toPrimitive]() have no side effects. The minifier uses oxc_ecmascript type conversion utilities that assume standard coercion behavior.

// The minifier assumes this never happens:
const obj = {
  toString() {
    console.log("side effect!");
    return "";
  },
};
String(obj); // Would trigger side effect

No TDZ Violation

Code doesn't violate Temporal Dead Zones.

// The minifier assumes this never happens:
console.log(x); // TDZ violation
let x = 1;

No errors from Array/String/TypedArray Maximum Length

Creating strings, arrays, or typed arrays that exceed maximum length can be moved or removed.

// The minifier may change when this error occurs:
try {
  new Array(2 ** 32); // RangeError
} catch {
  console.log("caught");
}

This also lets an unused new Int8Array(n) (or any TypedArray) with a numeric-literal n be dropped: a valid length allocates a zeroed buffer with no observable effect, and a too-large literal only throws a maximum-length RangeError. A non-literal, negative (new Int8Array(-1)), BigInt (new Int8Array(0n)), or object argument is kept.

Coercion to a primitive does not throw for non-Symbol arguments

Pure global functions and methods that coerce their arguments (parseInt, isNaN, Math.*, Date.UTC, String.fromCharCode, …) are treated as side-effect-free, so an unused call is dropped. A Symbol argument would make ToString / ToNumber throw a TypeError, but the minifier assumes a non-Symbol argument when it cannot prove the type — so Math.abs(x) / parseInt(x) stay droppable.

// The minifier assumes this never happens:
Math.abs(Symbol.iterator); // TypeError: Cannot convert a Symbol value to a number

A call is still kept when an argument provably throws:

  • ToNumber on a BigInt (Math.abs(10n), isNaN(10n), Date.UTC(10n)).
  • String.fromCodePoint(cp) with a code point outside [0, 0x10FFFF] (String.fromCodePoint(-1)) — an "invalid code point" RangeError, distinct from the droppable maximum-length errors above.
  • A missing or invalid required argument (String.raw(), Symbol.keyFor(), URL.canParse()).

No side effects from extending a class

Extending a class does not have a side effect.

const v = [];
class A extends v {} // TypeError

Variables declared in direct eval are not referenced outside the eval

Variables declared in direct eval are not referenced outside the eval, which is only allowed in non-strict mode.

// The minifier assumes this never happens:
eval("var x = 1");
console.log(x); // 1

Known globals exist and are side-effect-free to access

Accessing known global identifiers (e.g. Math, Array, Object, console, document, window, DOM classes, etc.) does not throw a ReferenceError and has no side effects. Reading their properties (e.g. Math.PI, Object.keys) and select 3-level chains (e.g. Object.prototype.hasOwnProperty) are also side-effect-free. This list is ported from Rolldown's GLOBAL_IDENT set, which mirrors Rollup's knownGlobals, and includes browser/host-specific APIs intentionally.

// The minifier assumes these are always available:
Math.PI; // side-effect-free
Array.isArray; // side-effect-free
console; // side-effect-free to access (calling methods may still have side effects)

No side effects from accessing to a global variable named arguments

Accessing a global variable named arguments does not have a side effect. We intend to change this assumption to optional in the future.

// The minifier assumes this never happens:
console.log(arguments); // ReferenceError: arguments is not defined

Property writes to provably-unused local bindings are effect-free

In full-minify mode, a plain = write to a property of a local binding is dropped when the binding is provably unused: it holds a fresh value (object / array / function / class literal with no getters, setters, or __proto__), is not exported, and every reference to it is itself a property-write target (terser parity — function A() {} A.from = () => {}; is removed entirely).

This assumes Object.prototype / Function.prototype / Array.prototype properties are not setters with side effects that such writes would trigger (the same class of assumption as the property_write_side_effects: false tree-shaking option, but applied only to this narrow provably-unused case). This is the only unsoundness in this optimization.

Writes that would themselves throw a strict-mode TypeError or have an observable value-domain effect are kept via a kind-aware key denylist: the non-writable own name / length and the caller / arguments poison of a function or class, a class's non-writable prototype, an instance-private brand-check write (o.#x = 1), and an Array length write (which can throw a RangeError or run a valueOf coercion). A plain function's prototype is writable, so f.prototype = {...} still drops.

Writes are NOT dropped when any other operation on the binding could observe them: compound / logical assignments and updates (o.x += 1 reads the property), chained writes (a.b.c = 1 reads a.b), delete of a nested member, or writes through __proto__ / non-literal computed keys (which may install setters).

// The minifier assumes this never happens:
Object.defineProperty(Object.prototype, "x", {
  set() {
    console.log("side effect!");
  },
});
var o = {};
o.x = 1; // dropped — would have logged

Function.prototype.toString is not relied on

Code does not depend on Function.prototype.toString() returning specific source text. Minification renames variables and parameters, simplifies expressions (true → !0), restructures statements (fusing with the comma operator, converting while to for), removes whitespace, and may eliminate function bodies entirely (e.g. IIFE inlining). All of these change the string returned by .toString().

const serialize = (fn) => fn.toString();
serialize((x) => {
  return x * 2;
}); // Relied on to contain "return"

Optional Assumptions

No Reliance on Function.prototype.name

Code doesn't depend on function names being preserved. This assumption is held by default. This can be changed by settings keepNames option.

// The minifier assumes this never happens:
function myFunc() {}
if (myFunc.name !== "myFunc") throw Error();

Configuration

Optional assumptions can be configured in the minifier options if your code requires different behavior.

pub struct CompressOptions {
    // Control optimization behavior
    pub drop_console: bool,
    pub drop_debugger: bool,
    pub join_vars: bool,
    pub sequences: bool,
    pub unused: CompressOptionsUnused,
    pub keep_names: CompressOptionsKeepNames,

    // Tree-shaking options affect side effect analysis
    pub treeshake: TreeShakeOptions,
}

pub struct TreeShakeOptions {
    // Whether property reads have side effects
    pub property_read_side_effects: PropertyReadSideEffects,
    // Whether property writes have side effects
    pub property_write_side_effects: bool,
    // Whether accessing unknown globals has side effects
    pub unknown_global_side_effects: bool,
    // Respect pure annotations like /* @__PURE__ */
    pub annotations: bool,
}

Validation

To ensure your code works with these assumptions:

  1. Test thoroughly: Run your test suite on minified code
  2. Use conformance tests: cargo coverage runs test262, Babel, TypeScript tests
  3. Node.js compatibility: The minifier is validated against Node.js compatibility tables
  4. Try real-world code: Test with your actual application
  5. Report issues: If valid code breaks, file an issue

Node.js Compatibility Testing

The minifier includes comprehensive validation against Node.js compatibility tables to ensure optimizations work correctly across different Node.js versions and environments.

When Assumptions Don't Hold

If your code violates these assumptions, you may:

  1. Refactor code to follow standard patterns
  2. Disable specific optimizations
  3. Use a different minifier configuration
  4. Report the issue for consideration