Skip to content

Runtime correctness bugs found by monitor-oxc's UglifyJS corpus oracle #24331

Description

@Dunqing

Found by running UglifyJS's test/compress corpus differentially against oxc main: each case is executed with node before and after oxc-minify and the stdout compared. Every item below reproduces on main, verified in plain node. Areas span the minifier, mangler, parser, transformer, and codegen.

delete of a constant-folded NaN changes true to false

console.log(delete (0 / 0));
console.log(delete NaN);

Original prints true, minified prints false. 0 / 0 is folded to the reference NaN, and delete NaN deletes a non-configurable global. (compressor)

dropping a strict-mode IIFE loses the use strict directive

console.log((() => { "use strict"; return function() { return this; }(); })());
console.log(function(){return this}());

Original prints undefined, minified prints the global object. The dropped arrow carried "use strict", which made the inner plain call's this undefined. (compressor)

for-init no-in restriction leaks into an arrow block body

for (a => { a in a; }; b(););

oxc's parser rejects this with "Expected a semicolon"; node --check and V8 accept it. The for-init [~In] restriction is propagated into the arrow's block body, where in is legal. Arrow expression bodies and function-expression bodies are unaffected. (parser)

BigInt compound exponentiation assignment is mis-lowered

var a = 8n; a **= a; console.log(a);
var a = 8n; a = Math.pow(a, a); console.log(a);

Original prints 16777216n, transformed throws TypeError: Cannot convert a BigInt value to a number. **= is lowered to Math.pow, which rejects BigInt operands. (transformer)

async generator return void 0 defers a finally block

var a = "FAIL";
async function* f(b) { try { if (b) return; else return; } finally { a = "PASS"; } }
f().next();
console.log(a);
var a=`FAIL`;async function*f(e){try{return void 0}finally{a=`PASS`}}f().next(),console.log(a);

Original prints PASS, minified prints FAIL. In an async generator return void 0 awaits its operand before running finally; a bare return does not, so the rewrite reorders the side effect. (compressor)

with-scope substitution of Infinity ignores the with object

var o = { Infinity: "PASS" };
var v = "Infinity";
with (o) { v = Infinity; }
console.log(v);
var o={Infinity:`PASS`},v=`Infinity`;with(o)v=1/0;console.log(v);

Original prints PASS, minified prints Infinity. Infinity is substituted with 1/0, but inside with (o) the name resolves to o.Infinity. (compressor)

mangling collapses catch and let bindings into a TDZ

console.log(function() {
  var a = 1;
  try { throw ["FAIL", "PASS"]; }
  catch ({ [a]: b }) { let a = 0; return b; }
}());
console.log(function(){var e=1;try{throw[`FAIL`,`PASS`]}catch({[e]:e}){return e}}());

Original prints PASS, minified throws ReferenceError: Cannot access 'e' before initialization. The outer a, the catch param b, and the inner let a are all mangled to e, so the computed key reads e in its own TDZ. (mangler)

compressor breaks sloppy arguments-parameter linkage

function f(a) { var a = function() {}; return [arguments, a]; }
console.log(typeof f(42)[0][0]);
function f(e){return[arguments,function(){}]}console.log(typeof f(42)[0][0]);

Original prints function, minified prints number. In sloppy mode arguments[0] is linked to param a; reassigning a updates arguments[0]. Dropping the assignment breaks that link. (compressor)

mangling breaks Annex B block-function hoisting

var x = 42;
{ x(); function x() { console.log("foo"); } }
console.log(typeof x);
var x=42;{e();function e(){console.log(`foo`)}}console.log(typeof x);

Original prints foo then function, minified prints foo then number. Annex B copies the block-scoped function to the outer var x; renaming the block function to e severs that copy. (mangler)

rest-parameter destructuring side effects are dropped

try { (function f(...[ {} ]) {})(); } catch (e) { console.log("PASS"); }

Minified output is empty — the whole IIFE is removed. Original prints PASS, minified prints nothing. Calling f() makes ...[ {} ] destructure undefined, which throws; the IIFE is dropped as dead. (compressor)

optional-chain call loses its detached this binding

(function(o) { console.log((0, o?.f)(42)); })({ a: "PASS", f(b) { return this.a || b; } });
(function(e){console.log((e?.f)(42))})({a:`PASS`,f(e){return this.a||e}});

Original prints 42, minified prints PASS. (0, o?.f)(42) detaches the method so this is not o; dropping the sequence restores the this binding. (compressor)

dropping a unary plus changes coercion timing

console.log(+(A = []) * (A[0] = 1));
console.log((A=[])*(A[0]=1));

Original prints 0, minified prints 1. The + coerces the empty array to 0 before the right operand assigns A[0] = 1; without it the array is coerced after the mutation. (compressor)

DCE drops a throwing call before an explicit throw

function f() {
  g();
  throw new Error("foo");
  if (true) { function g() {} }
}
f();
function f(){throw Error(`foo`)}f();

Original throws TypeError: g is not a function, minified throws Error: foo. g is Annex-B hoisted as undefined (the block function is not yet assigned when throw runs), so g() throws first; DCE removes the call. (compressor)

Metadata

Metadata

Assignees

Labels

Type

No type

Fields

Priority

None yet

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions