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).
This commit is contained in:
Dunqing committed 2026-06-16 07:46:28 +00:00
1 parent 3fa8051d15
commit 53509a85df
9 files changed
+99 -24

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@
// To edit this generated file you have to edit `tasks/ast_tools/src/generators/estree_visit.rs`.
/** Mapping from node type name to node type ID */
export const NODE_TYPE_IDS_MAP = new Map([
export const NODE_TYPE_IDS_MAP = /* @__PURE__ */ new Map([
["DebuggerStatement", 0],
["EmptyStatement", 1],
["Literal", 2],
@@ -101,7 +101,7 @@ pub(super) fn is_error_constructor(name: &str) -> bool {
}
#[rustfmt::skip]
pub(super) fn is_typed_array_constructor(name: &str) -> bool {
pub fn is_typed_array_constructor(name: &str) -> bool {
matches!(name, "Int8Array" | "Uint8Array" | "Uint8ClampedArray"
| "Int16Array" | "Uint16Array" | "Int32Array" | "Uint32Array"
| "Float32Array" | "Float64Array" | "BigInt64Array" | "BigUint64Array")
@@ -5,7 +5,7 @@ mod pure_function;
mod statements;
pub use context::{MayHaveSideEffectsContext, PropertyReadSideEffects};
pub use known_globals::is_valid_regexp;
pub use known_globals::{is_typed_array_constructor, is_valid_regexp};
pub use pure_function::is_pure_function;
/// Returns true if subtree changes application state.
+4 -2
View File
@@ -66,9 +66,9 @@ console.log(x); // TDZ violation
let x = 1;
```
### No errors from Array/String Maximum Length
### No errors from Array/String/TypedArray Maximum Length
Creating strings or arrays that exceed maximum length can be moved or removed.
Creating strings, arrays, or typed arrays that exceed maximum length can be moved or removed.
```javascript
// The minifier may change when this error occurs:
@@ -79,6 +79,8 @@ try {
}
```
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.
### No side effects from extending a class
Extending a class does not have a side effect.
+47 -2
View File
@@ -3,7 +3,7 @@ use oxc_allocator::{TakeIn, Vec};
use oxc_ast::ast::*;
use oxc_ecmascript::{
constant_evaluation::{DetermineValueType, ValueType},
side_effects::is_valid_regexp,
side_effects::{is_typed_array_constructor, is_valid_regexp},
};
use oxc_semantic::IsGlobalReference;
use oxc_syntax::scope::ScopeFlags;
@@ -355,6 +355,30 @@ impl<'a> Normalize {
}
return;
}
// Typed arrays allocate a zeroed buffer and run no user code when the
// length argument is a non-negative numeric literal: it is either a valid
// length (pure) or too large, which throws a maximum-length `RangeError`
// that the minifier is allowed to drop (see `docs/ASSUMPTIONS.md`). The
// value must be checked explicitly because constant folding can turn `-1`
// into a negative-valued `NumericLiteral`. Other arguments are kept:
// `new Int8Array(-1)` throws a negative-length `RangeError`,
// `new Int8Array(0n)` throws a `TypeError` (BigInt), and an object argument
// can run user code via `Symbol.iterator` / `valueOf`. The 0-arg and
// `0`-literal forms (the latter folded to 0-arg by
// `substitute_typed_array_constructor`) are both covered, so the result is
// idempotent regardless of fold order.
name if is_typed_array_constructor(name) => {
let safe_length = new_expr.arguments.is_empty()
|| (new_expr.arguments.len() == 1
&& matches!(
new_expr.arguments[0].as_expression(),
Some(Expression::NumericLiteral(lit)) if lit.value >= 0.0
));
if safe_length && Self::can_set_pure(ident, ctx) {
new_expr.pure = true;
}
return;
}
_ => return,
};
@@ -366,7 +390,28 @@ impl<'a> Normalize {
0 if !zero_arg_throws_error => true,
1 => match new_expr.arguments[0].as_expression() {
Some(Expression::ArrayExpression(array_expr)) => {
array_expr.elements.is_empty() && !one_arg_array_throws_error
if one_arg_array_throws_error {
false
} else if array_expr.elements.is_empty() {
true
} else {
// A non-empty array literal is iterated via the built-in
// array iterator (side-effect-free; element side effects are
// preserved when the call is dropped). Only `Set`/`Map` accept
// arbitrary entries: `Set` takes any values; `Map` requires
// every entry to be an array literal (`new Map([1])` throws —
// `1` is not iterable). `WeakSet`/`WeakMap` are excluded —
// their keys must be objects, so `new WeakSet([1])` /
// `new WeakMap([[1, 2]])` throw.
match ident.name.as_str() {
"Set" => true,
"Map" => array_expr
.elements
.iter()
.all(|el| matches!(el, ArrayExpressionElement::ArrayExpression(_))),
_ => false,
}
}
}
Some(e) => {
if let Expression::NewExpression(new_expr) = e {
@@ -119,18 +119,40 @@ fn test_new_constructor_side_effect() {
test("new Date(undefined)", "");
test_same("new Date(x)");
test("new Set()", "");
// test("new Set([a, b, c])", "");
test("new Set([1, 2, 3])", "");
// Element side effects are preserved when the pure construction is dropped.
test("new Set([foo(), bar()])", "foo(), bar();");
test("new Map([[foo(), bar()]])", "foo(), bar();");
test("new Set(null)", "");
test("new Set(undefined)", "");
test("new Set(void 0)", "");
test_same("new Set(x)");
test("new Map()", "");
test("new Map([[1, 2], [3, 4]])", "");
test("new Map(null)", "");
test("new Map(undefined)", "");
test("new Map(void 0)", "");
// test_same("new Map([x])");
test_same("new Map(x)");
// test("new Map([[a, b], [c, d]])", "");
// Map entries must be array literals, otherwise they are not iterable and throw.
test_same("new Map([x])");
test_same("new Map([1, 2])");
// WeakSet/WeakMap keys must be objects, so array-literal args throw.
test_same("new WeakSet([1])");
test_same("new WeakMap([[1, 2]])");
// Typed arrays allocate a zeroed buffer with no user code for a numeric-literal
// length: a valid length is pure, and a too-large length is a max-length
// RangeError the minifier is allowed to drop (see docs/ASSUMPTIONS.md).
test("new Int8Array()", "");
test("new Uint8Array()", "");
test("new Int8Array(8)", "");
test("new Int8Array(1024)", "");
// Kept: `-1` throws a negative-length RangeError, `0n` throws a TypeError, an
// object arg can run user code, and a shadowed `Int8Array` is not the builtin.
test_same("new Int8Array(-1)");
test_same("new Int8Array(0n)");
test_same("new Int8Array(x)");
test_same("new Int8Array([1, 2])");
test_same("var Int8Array; new Int8Array()");
}
#[test]
@@ -262,20 +262,26 @@ fn test_fold_new_expressions() {
#[test]
fn test_compress_typed_array_constructor() {
test("new Int8Array(0)", "new Int8Array()");
test("new Uint8Array(0)", "new Uint8Array()");
test("new Uint8ClampedArray(0)", "new Uint8ClampedArray()");
test("new Int16Array(0)", "new Int16Array()");
test("new Uint16Array(0)", "new Uint16Array()");
test("new Int32Array(0)", "new Int32Array()");
test("new Uint32Array(0)", "new Uint32Array()");
test("new Float32Array(0)", "new Float32Array()");
test("new Float64Array(0)", "new Float64Array()");
test("new BigInt64Array(0)", "new BigInt64Array()");
test("new BigUint64Array(0)", "new BigUint64Array()");
// `new Int8Array(0)` is equivalent to `new Int8Array()`: the `0` arg is folded
// away and the pure empty construction is dropped when its result is unused.
test("new Int8Array(0)", "");
test("new Uint8Array(0)", "");
test("new Uint8ClampedArray(0)", "");
test("new Int16Array(0)", "");
test("new Uint16Array(0)", "");
test("new Int32Array(0)", "");
test("new Uint32Array(0)", "");
test("new Float32Array(0)", "");
test("new Float64Array(0)", "");
test("new BigInt64Array(0)", "");
test("new BigUint64Array(0)", "");
test("new Int8Array()", "");
// A numeric-literal length is pure (or a droppable max-length RangeError).
test("new Int8Array(8)", "");
// Not optimized when shadowed, or with a negative / non-literal / extra length.
test_same("var Int8Array; new Int8Array(0)");
test_same("new Int8Array(1)");
test_same("new Int8Array(-1)");
test_same("new Int8Array(a)");
test_same("new Int8Array(0, a)");
}
@@ -2,7 +2,7 @@
// To edit this generated file you have to edit `tasks/ast_tools/src/generators/estree_visit.rs`.
/** Mapping from node type name to node type ID */
export const NODE_TYPE_IDS_MAP = new Map([
export const NODE_TYPE_IDS_MAP = /* @__PURE__ */ new Map([
["DebuggerStatement", 0],
["EmptyStatement", 1],
["Literal", 2],
+1 -1
View File
@@ -1,7 +1,7 @@
| Oxc | ESBuild | Oxc | ESBuild |
Original | minified | minified | gzip | gzip | Iterations | File
-------------------------------------------------------------------------------------
72.14 kB | 23.18 kB | 23.70 kB | 8.38 kB | 8.54 kB | 2 | react.development.js
72.14 kB | 23.14 kB | 23.70 kB | 8.36 kB | 8.54 kB | 2 | react.development.js
173.90 kB | 59.40 kB | 59.82 kB | 19.15 kB | 19.33 kB | 2 | moment.js