From 2176b35a60ec41101758841d28af502656ecd893 Mon Sep 17 00:00:00 2001 From: Evan Wallace Date: Fri, 3 Mar 2023 16:04:40 -0500 Subject: [PATCH] fix #2962: minify global primitive constructors --- CHANGELOG.md | 20 ++++++ internal/js_parser/js_parser.go | 63 +++++++++++++++++++ internal/js_parser/js_parser_test.go | 54 ++++++++++++++++ scripts/end-to-end-tests.js | 92 ++++++++++++++++++++++++++++ 4 files changed, 229 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95409c77339..a8e605baa30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ ## Unreleased +* Minify calls to some global primitive constructors ([#2962](https://github.com/evanw/esbuild/issues/2962)) + + With this release, esbuild's minifier now replaces calls to `Boolean`/`Number`/`String`/`BigInt` with equivalent shorter code when relevant: + + ```js + // Original code + console.log( + Boolean(a ? (b | c) !== 0 : (c & d) !== 0), + Number(e ? '1' : '2'), + String(e ? '1' : '2'), + BigInt(e ? 1n : 2n), + ) + + // Old output (with --minify) + console.log(Boolean(a?(b|c)!==0:(c&d)!==0),Number(e?"1":"2"),String(e?"1":"2"),BigInt(e?1n:2n)); + + // New output (with --minify) + console.log(!!(a?b|c:c&d),+(e?"1":"2"),e?"1":"2",e?1n:2n); + ``` + * Adjust some feature compatibility tables for node ([#2940](https://github.com/evanw/esbuild/issues/2940)) This release makes the following adjustments to esbuild's internal feature compatibility tables for node, which tell esbuild which versions of node are known to support all aspects of that feature: diff --git a/internal/js_parser/js_parser.go b/internal/js_parser/js_parser.go index 10c8c9cb7e4..70c7e33b892 100644 --- a/internal/js_parser/js_parser.go +++ b/internal/js_parser/js_parser.go @@ -13715,6 +13715,69 @@ func (p *parser) visitExprInOut(expr js_ast.Expr, in exprIn) (js_ast.Expr, exprO } } + // Optimize references to global constructors + if p.options.minifySyntax && t.CanBeRemovedIfUnused && len(e.Args) <= 1 && !hasSpread { + if symbol := &p.symbols[t.Ref.InnerIndex]; symbol.Kind == js_ast.SymbolUnbound { + // Note: We construct expressions by assigning to "expr.Data" so + // that the source map position for the constructor is preserved + switch symbol.OriginalName { + case "Boolean": + if len(e.Args) == 0 { + return js_ast.Expr{Loc: expr.Loc, Data: &js_ast.EBoolean{Value: false}}, exprOut{} + } else { + expr.Data = &js_ast.EUnary{Value: js_ast.SimplifyBooleanExpr(e.Args[0]), Op: js_ast.UnOpNot} + return js_ast.Not(expr), exprOut{} + } + + case "Number": + if len(e.Args) == 0 { + return js_ast.Expr{Loc: expr.Loc, Data: &js_ast.ENumber{Value: 0}}, exprOut{} + } else { + arg := e.Args[0] + + switch js_ast.KnownPrimitiveType(arg.Data) { + case js_ast.PrimitiveNumber: + return arg, exprOut{} + + case + js_ast.PrimitiveUndefined, // NaN + js_ast.PrimitiveNull, // 0 + js_ast.PrimitiveBoolean, // 0 or 1 + js_ast.PrimitiveString: // StringToNumber + if number, ok := js_ast.ToNumberWithoutSideEffects(arg.Data); ok { + expr.Data = &js_ast.ENumber{Value: number} + } else { + expr.Data = &js_ast.EUnary{Value: arg, Op: js_ast.UnOpPos} + } + return expr, exprOut{} + } + } + + case "String": + if len(e.Args) == 0 { + return js_ast.Expr{Loc: expr.Loc, Data: &js_ast.EString{Value: nil}}, exprOut{} + } else { + arg := e.Args[0] + + switch js_ast.KnownPrimitiveType(arg.Data) { + case js_ast.PrimitiveString: + return arg, exprOut{} + } + } + + case "BigInt": + if len(e.Args) == 1 { + arg := e.Args[0] + + switch js_ast.KnownPrimitiveType(arg.Data) { + case js_ast.PrimitiveBigInt: + return arg, exprOut{} + } + } + } + } + } + // Copy the call side effect flag over if this is a known target if t.CallCanBeUnwrappedIfUnused { e.CanBeUnwrappedIfUnused = true diff --git a/internal/js_parser/js_parser_test.go b/internal/js_parser/js_parser_test.go index 65815b57d70..354cd2d5000 100644 --- a/internal/js_parser/js_parser_test.go +++ b/internal/js_parser/js_parser_test.go @@ -3277,6 +3277,60 @@ func TestMangleDoubleNot(t *testing.T) { expectPrintedNormalAndMangle(t, "a = !!(b, c)", "a = !!(b, c);\n", "a = (b, !!c);\n") } +func TestMangleBooleanConstructor(t *testing.T) { + expectPrintedNormalAndMangle(t, "a = Boolean(b); var Boolean", "a = Boolean(b);\nvar Boolean;\n", "a = Boolean(b);\nvar Boolean;\n") + + expectPrintedNormalAndMangle(t, "a = Boolean()", "a = Boolean();\n", "a = false;\n") + expectPrintedNormalAndMangle(t, "a = Boolean(b)", "a = Boolean(b);\n", "a = !!b;\n") + expectPrintedNormalAndMangle(t, "a = Boolean(!b)", "a = Boolean(!b);\n", "a = !b;\n") + expectPrintedNormalAndMangle(t, "a = Boolean(!!b)", "a = Boolean(!!b);\n", "a = !!b;\n") + expectPrintedNormalAndMangle(t, "a = Boolean(b ? true : false)", "a = Boolean(b ? true : false);\n", "a = !!b;\n") + expectPrintedNormalAndMangle(t, "a = Boolean(b ? false : true)", "a = Boolean(b ? false : true);\n", "a = !b;\n") + expectPrintedNormalAndMangle(t, "a = Boolean(b ? c > 0 : c < 0)", "a = Boolean(b ? c > 0 : c < 0);\n", "a = b ? c > 0 : c < 0;\n") + + // Check for calling "SimplifyBooleanExpr" on the argument + expectPrintedNormalAndMangle(t, "a = Boolean((b | c) !== 0)", "a = Boolean((b | c) !== 0);\n", "a = !!(b | c);\n") + expectPrintedNormalAndMangle(t, "a = Boolean(b ? (c | d) !== 0 : (d | e) !== 0)", "a = Boolean(b ? (c | d) !== 0 : (d | e) !== 0);\n", "a = !!(b ? c | d : d | e);\n") +} + +func TestMangleNumberConstructor(t *testing.T) { + expectPrintedNormalAndMangle(t, "a = Number(x)", "a = Number(x);\n", "a = Number(x);\n") + expectPrintedNormalAndMangle(t, "a = Number(0n)", "a = Number(0n);\n", "a = Number(0n);\n") + expectPrintedNormalAndMangle(t, "a = Number(false); var Number", "a = Number(false);\nvar Number;\n", "a = Number(false);\nvar Number;\n") + expectPrintedNormalAndMangle(t, "a = Number(0xFFFF_FFFF_FFFF_FFFFn)", "a = Number(0xFFFFFFFFFFFFFFFFn);\n", "a = Number(0xFFFFFFFFFFFFFFFFn);\n") + + expectPrintedNormalAndMangle(t, "a = Number()", "a = Number();\n", "a = 0;\n") + expectPrintedNormalAndMangle(t, "a = Number(-123)", "a = Number(-123);\n", "a = -123;\n") + expectPrintedNormalAndMangle(t, "a = Number(false)", "a = Number(false);\n", "a = 0;\n") + expectPrintedNormalAndMangle(t, "a = Number(true)", "a = Number(true);\n", "a = 1;\n") + expectPrintedNormalAndMangle(t, "a = Number(undefined)", "a = Number(void 0);\n", "a = NaN;\n") + expectPrintedNormalAndMangle(t, "a = Number(null)", "a = Number(null);\n", "a = 0;\n") + expectPrintedNormalAndMangle(t, "a = Number(b ? !c : !d)", "a = Number(b ? !c : !d);\n", "a = +(b ? !c : !d);\n") +} + +func TestMangleStringConstructor(t *testing.T) { + expectPrintedNormalAndMangle(t, "a = String(x)", "a = String(x);\n", "a = String(x);\n") + expectPrintedNormalAndMangle(t, "a = String('x'); var String", "a = String(\"x\");\nvar String;\n", "a = String(\"x\");\nvar String;\n") + + expectPrintedNormalAndMangle(t, "a = String()", "a = String();\n", "a = \"\";\n") + expectPrintedNormalAndMangle(t, "a = String('x')", "a = String(\"x\");\n", "a = \"x\";\n") + expectPrintedNormalAndMangle(t, "a = String(b ? 'x' : 'y')", "a = String(b ? \"x\" : \"y\");\n", "a = b ? \"x\" : \"y\";\n") +} + +func TestMangleBigIntConstructor(t *testing.T) { + expectPrintedNormalAndMangle(t, "a = BigInt(x)", "a = BigInt(x);\n", "a = BigInt(x);\n") + expectPrintedNormalAndMangle(t, "a = BigInt(0n); var BigInt", "a = BigInt(0n);\nvar BigInt;\n", "a = BigInt(0n);\nvar BigInt;\n") + + // Note: This throws instead of returning "0n" + expectPrintedNormalAndMangle(t, "a = BigInt()", "a = BigInt();\n", "a = BigInt();\n") + + // Note: Transforming this into "0n" is unsafe because that syntax may not be supported + expectPrintedNormalAndMangle(t, "a = BigInt('0')", "a = BigInt(\"0\");\n", "a = BigInt(\"0\");\n") + + expectPrintedNormalAndMangle(t, "a = BigInt(0n)", "a = BigInt(0n);\n", "a = 0n;\n") + expectPrintedNormalAndMangle(t, "a = BigInt(b ? 0n : 1n)", "a = BigInt(b ? 0n : 1n);\n", "a = b ? 0n : 1n;\n") +} + func TestMangleIf(t *testing.T) { expectPrintedNormalAndMangle(t, "1 ? a() : b()", "1 ? a() : b();\n", "a();\n") expectPrintedNormalAndMangle(t, "0 ? a() : b()", "0 ? a() : b();\n", "b();\n") diff --git a/scripts/end-to-end-tests.js b/scripts/end-to-end-tests.js index 2a7e18bb74c..4001a3002c8 100644 --- a/scripts/end-to-end-tests.js +++ b/scripts/end-to-end-tests.js @@ -2800,6 +2800,98 @@ for (const minify of [[], ['--minify-syntax']]) { `, }), ); + + // Check global constructor behavior + tests.push( + test(['in.js', '--outfile=node.js'].concat(minify), { + 'in.js': ` + const check = (before, after) => { + if (Boolean(before) !== after) throw 'fail: Boolean(' + before + ') should not be ' + Boolean(before) + if (new Boolean(before) === after) throw 'fail: new Boolean(' + before + ') should not be ' + new Boolean(before) + if (new Boolean(before).valueOf() !== after) throw 'fail: new Boolean(' + before + ').valueOf() should not be ' + new Boolean(before).valueOf() + } + check(false, false); check(0, false); check(0n, false) + check(true, true); check(1, true); check(1n, true) + check(null, false); check(undefined, false) + check('', false); check('x', true) + + const checkSpread = (before, after) => { + if (Boolean(...before) !== after) throw 'fail: Boolean(...' + before + ') should not be ' + Boolean(...before) + if (new Boolean(...before) === after) throw 'fail: new Boolean(...' + before + ') should not be ' + new Boolean(...before) + if (new Boolean(...before).valueOf() !== after) throw 'fail: new Boolean(...' + before + ').valueOf() should not be ' + new Boolean(...before).valueOf() + } + checkSpread([0], false); check([1], true) + checkSpread([], false) + `, + }), + test(['in.js', '--outfile=node.js'].concat(minify), { + 'in.js': ` + class ToPrimitive { [Symbol.toPrimitive]() { return '100.001' } } + const someObject = { toString: () => 123, valueOf: () => 321 } + + const check = (before, after) => { + if (Number(before) !== after) throw 'fail: Number(' + before + ') should not be ' + Number(before) + if (new Number(before) === after) throw 'fail: new Number(' + before + ') should not be ' + new Number(before) + if (new Number(before).valueOf() !== after) throw 'fail: new Number(' + before + ').valueOf() should not be ' + new Number(before).valueOf() + } + check(-1.23, -1.23) + check('-1.23', -1.23) + check(123n, 123) + check(null, 0) + check(false, 0) + check(true, 1) + check(someObject, 321) + check(new ToPrimitive(), 100.001) + + const checkSpread = (before, after) => { + if (Number(...before) !== after) throw 'fail: Number(...' + before + ') should not be ' + Number(...before) + if (new Number(...before) === after) throw 'fail: new Number(...' + before + ') should not be ' + new Number(...before) + if (new Number(...before).valueOf() !== after) throw 'fail: new Number(...' + before + ').valueOf() should not be ' + new Number(...before).valueOf() + } + checkSpread(['123'], 123) + checkSpread([], 0) + `, + }), + test(['in.js', '--outfile=node.js'].concat(minify), { + 'in.js': ` + class ToPrimitive { [Symbol.toPrimitive]() { return 100.001 } } + const someObject = { toString: () => 123, valueOf: () => 321 } + + const check = (before, after) => { + if (String(before) !== after) throw 'fail: String(' + before + ') should not be ' + String(before) + if (new String(before) === after) throw 'fail: new String(' + before + ') should not be ' + new String(before) + if (new String(before).valueOf() !== after) throw 'fail: new String(' + before + ').valueOf() should not be ' + new String(before).valueOf() + } + check('', '') + check('x', 'x') + check(null, 'null') + check(false, 'false') + check(1.23, '1.23') + check(-123n, '-123') + check(someObject, '123') + check(new ToPrimitive(), '100.001') + + const checkSpread = (before, after) => { + if (String(...before) !== after) throw 'fail: String(...' + before + ') should not be ' + String(...before) + if (new String(...before) === after) throw 'fail: new String(...' + before + ') should not be ' + new String(...before) + if (new String(...before).valueOf() !== after) throw 'fail: new String(...' + before + ').valueOf() should not be ' + new String(...before).valueOf() + } + checkSpread([123], '123') + checkSpread([], '') + + const checkAndExpectNewToThrow = (before, after) => { + if (String(before) !== after) throw 'fail: String(...) should not be ' + String(before) + try { + new String(before) + } catch (e) { + return + } + throw 'fail: new String(...) should not succeed' + } + checkAndExpectNewToThrow(Symbol('abc'), 'Symbol(abc)') + `, + }), + ); } // Test minification of top-level symbols