diff --git a/src/ast/KnownGlobal.zig b/src/ast/KnownGlobal.zig index 76eaf465c0e4..f6959749e1b6 100644 --- a/src/ast/KnownGlobal.zig +++ b/src/ast/KnownGlobal.zig @@ -8,18 +8,158 @@ pub const KnownGlobal = enum { Response, TextEncoder, TextDecoder, + Error, + TypeError, + SyntaxError, + RangeError, + ReferenceError, + EvalError, + URIError, + AggregateError, + Array, + Object, + Function, + RegExp, pub const map = bun.ComptimeEnumMap(KnownGlobal); - pub noinline fn maybeMarkConstructorAsPure(noalias e: *E.New, symbols: []const Symbol) void { - const id = if (e.target.data == .e_identifier) e.target.data.e_identifier.ref else return; + inline fn callFromNew(e: *E.New, loc: logger.Loc) js_ast.Expr { + const call = E.Call{ + .target = e.target, + .args = e.args, + .close_paren_loc = e.close_parens_loc, + .can_be_unwrapped_if_unused = e.can_be_unwrapped_if_unused, + }; + return js_ast.Expr.init(E.Call, call, loc); + } + + pub noinline fn minifyGlobalConstructor(allocator: std.mem.Allocator, noalias e: *E.New, symbols: []const Symbol, loc: logger.Loc, minify_whitespace: bool) ?js_ast.Expr { + const id = if (e.target.data == .e_identifier) e.target.data.e_identifier.ref else return null; const symbol = &symbols[id.innerIndex()]; if (symbol.kind != .unbound) - return; + return null; + + const constructor = map.get(symbol.original_name) orelse return null; + + return switch (constructor) { + // Error constructors can be called without 'new' with identical behavior + .Error, .TypeError, .SyntaxError, .RangeError, .ReferenceError, .EvalError, .URIError, .AggregateError => { + // Convert `new Error(...)` to `Error(...)` to save bytes + return callFromNew(e, loc); + }, - const constructor = map.get(symbol.original_name) orelse return; + .Object => { + const n = e.args.len; - switch (constructor) { + if (n == 0) { + // new Object() -> {} + return js_ast.Expr.init(E.Object, E.Object{}, loc); + } + + if (n == 1) { + const arg = e.args.ptr[0]; + switch (arg.data) { + .e_object, .e_array => { + // new Object({a: 1}) -> {a: 1} + // new Object([1, 2]) -> [1, 2] + return arg; + }, + .e_null, .e_undefined => { + // new Object(null) -> {} + // new Object(undefined) -> {} + return js_ast.Expr.init(E.Object, E.Object{}, loc); + }, + else => {}, + } + } + + // For other cases, just remove 'new' + return callFromNew(e, loc); + }, + + .Array => { + const n = e.args.len; + + return switch (n) { + 0 => { + // new Array() -> [] + return js_ast.Expr.init(E.Array, E.Array{}, loc); + }, + 1 => { + // For single argument, only convert to literal if we're SURE it's not a number + const arg = e.args.ptr[0]; + + // Check if it's an object or array literal first + switch (arg.data) { + .e_object, .e_array => { + // new Array({}) -> [{}], new Array([1]) -> [[1]] + // These are definitely not numbers, safe to convert + return js_ast.Expr.init(E.Array, .{ .items = e.args }, loc); + }, + else => {}, + } + + // For other types, check via knownPrimitive + const primitive = arg.knownPrimitive(); + // Only convert if we know for certain it's not a number + // unknown could be a number at runtime, so we must preserve Array() call + switch (primitive) { + .null, .undefined, .boolean, .string, .bigint => { + // These are definitely not numbers, safe to convert + return js_ast.Expr.init(E.Array, .{ .items = e.args }, loc); + }, + .number => { + const val = arg.data.e_number.value; + if ( + // only want this with whitespace minification + minify_whitespace and + (val == 0 or + val == 1 or + val == 2 or + val == 3 or + val == 4 or + val == 5 or + val == 6 or + val == 7 or + val == 8 or + val == 9 or + val == 10)) + { + const arg_loc = arg.loc; + var list = e.args.listManaged(allocator); + list.clearRetainingCapacity(); + bun.handleOom(list.appendNTimes(js_ast.Expr{ .data = js_parser.Prefill.Data.EMissing, .loc = arg_loc }, @intFromFloat(val))); + return js_ast.Expr.init(E.Array, .{ .items = .fromList(list) }, loc); + } + return callFromNew(e, loc); + }, + .unknown, .mixed => { + // Could be a number, preserve Array() call + return callFromNew(e, loc); + }, + } + }, + // > 1 + else => { + // new Array(1, 2, 3) -> [1, 2, 3] + // But NOT new Array(3) which creates an array with 3 empty slots + return js_ast.Expr.init(E.Array, .{ .items = e.args }, loc); + }, + }; + }, + + .Function => { + // Just remove 'new' for Function + return callFromNew(e, loc); + }, + .RegExp => { + // Don't optimize RegExp - the semantics are too complex: + // - new RegExp(re) creates a copy, but RegExp(re) returns the same instance + // - This affects object identity and lastIndex behavior + // - The difference only applies when flags are undefined + // Keep the original new RegExp() call to preserve correct semantics + return null; + }, .WeakSet, .WeakMap => { const n = e.args.len; @@ -27,7 +167,7 @@ pub const KnownGlobal = enum { // "new WeakSet()" is pure e.can_be_unwrapped_if_unused = .if_unused; - return; + return null; } if (n == 1) { @@ -50,6 +190,7 @@ pub const KnownGlobal = enum { }, } } + return null; }, .Date => { const n = e.args.len; @@ -58,7 +199,7 @@ pub const KnownGlobal = enum { // "new Date()" is pure e.can_be_unwrapped_if_unused = .if_unused; - return; + return null; } if (n == 1) { @@ -78,6 +219,7 @@ pub const KnownGlobal = enum { }, } } + return null; }, .Set => { @@ -86,7 +228,7 @@ pub const KnownGlobal = enum { if (n == 0) { // "new Set()" is pure e.can_be_unwrapped_if_unused = .if_unused; - return; + return null; } if (n == 1) { @@ -102,6 +244,7 @@ pub const KnownGlobal = enum { }, } } + return null; }, .Headers => { @@ -111,8 +254,9 @@ pub const KnownGlobal = enum { // "new Headers()" is pure e.can_be_unwrapped_if_unused = .if_unused; - return; + return null; } + return null; }, .Response => { @@ -122,7 +266,7 @@ pub const KnownGlobal = enum { // "new Response()" is pure e.can_be_unwrapped_if_unused = .if_unused; - return; + return null; } if (n == 1) { @@ -142,6 +286,7 @@ pub const KnownGlobal = enum { }, } } + return null; }, .TextDecoder, .TextEncoder => { const n = e.args.len; @@ -151,11 +296,12 @@ pub const KnownGlobal = enum { // "new TextDecoder()" is pure e.can_be_unwrapped_if_unused = .if_unused; - return; + return null; } // We _could_ validate the encoding argument // But let's not bother + return null; }, .Map => { @@ -164,7 +310,7 @@ pub const KnownGlobal = enum { if (n == 0) { // "new Map()" is pure e.can_be_unwrapped_if_unused = .if_unused; - return; + return null; } if (n == 1) { @@ -193,18 +339,20 @@ pub const KnownGlobal = enum { }, } } + return null; }, - } + }; } }; const string = []const u8; +const std = @import("std"); + const bun = @import("bun"); +const js_parser = bun.js_parser; +const logger = bun.logger; const js_ast = bun.ast; const E = js_ast.E; const Symbol = js_ast.Symbol; - -const std = @import("std"); -const Map = std.AutoHashMapUnmanaged; diff --git a/src/ast/visitExpr.zig b/src/ast/visitExpr.zig index 6d48342f4a17..3e2343485693 100644 --- a/src/ast/visitExpr.zig +++ b/src/ast/visitExpr.zig @@ -1492,7 +1492,9 @@ pub fn VisitExpr( } if (p.options.features.minify_syntax) { - KnownGlobal.maybeMarkConstructorAsPure(e_, p.symbols.items); + if (KnownGlobal.minifyGlobalConstructor(p.allocator, e_, p.symbols.items, expr.loc, p.options.features.minify_whitespace)) |minified| { + return minified; + } } return expr; } diff --git a/src/bundler/ParseTask.zig b/src/bundler/ParseTask.zig index caa9d18f3990..9f9cc1417300 100644 --- a/src/bundler/ParseTask.zig +++ b/src/bundler/ParseTask.zig @@ -1175,6 +1175,7 @@ fn runWithSourceCode( opts.output_format = output_format; opts.features.minify_syntax = transpiler.options.minify_syntax; opts.features.minify_identifiers = transpiler.options.minify_identifiers; + opts.features.minify_whitespace = transpiler.options.minify_whitespace; opts.features.emit_decorator_metadata = transpiler.options.emit_decorator_metadata; opts.features.unwrap_commonjs_packages = transpiler.options.unwrap_commonjs_packages; opts.features.hot_module_reloading = output_format == .internal_bake_dev and !source.index.isRuntime(); diff --git a/src/runtime.zig b/src/runtime.zig index 2d8ab1e1c021..fd6de9fa073f 100644 --- a/src/runtime.zig +++ b/src/runtime.zig @@ -168,6 +168,7 @@ pub const Runtime = struct { minify_syntax: bool = false, minify_identifiers: bool = false, + minify_whitespace: bool = false, dead_code_elimination: bool = true, set_breakpoint_on_first_line: bool = false, diff --git a/test/bundler/bundler_minify.test.ts b/test/bundler/bundler_minify.test.ts index 96a34f96c56a..0317c49f7dbf 100644 --- a/test/bundler/bundler_minify.test.ts +++ b/test/bundler/bundler_minify.test.ts @@ -692,6 +692,349 @@ describe("bundler", () => { }, }); + itBundled("minify/ErrorConstructorOptimization", { + files: { + "/entry.js": /* js */ ` + // Test all Error constructors + capture(new Error()); + capture(new Error("message")); + capture(new Error("message", { cause: "cause" })); + + capture(new TypeError()); + capture(new TypeError("type error")); + + capture(new SyntaxError()); + capture(new SyntaxError("syntax error")); + + capture(new RangeError()); + capture(new RangeError("range error")); + + capture(new ReferenceError()); + capture(new ReferenceError("ref error")); + + capture(new EvalError()); + capture(new EvalError("eval error")); + + capture(new URIError()); + capture(new URIError("uri error")); + + capture(new AggregateError([], "aggregate error")); + capture(new AggregateError([new Error("e1")], "multiple")); + + // Test with complex arguments + const msg = "dynamic"; + capture(new Error(msg)); + capture(new TypeError(getErrorMessage())); + + // Test that other constructors are not affected + capture(new Date()); + capture(new Map()); + capture(new Set()); + + function getErrorMessage() { return "computed"; } + `, + }, + capture: [ + "Error()", + 'Error("message")', + 'Error("message", { cause: "cause" })', + "TypeError()", + 'TypeError("type error")', + "SyntaxError()", + 'SyntaxError("syntax error")', + "RangeError()", + 'RangeError("range error")', + "ReferenceError()", + 'ReferenceError("ref error")', + "EvalError()", + 'EvalError("eval error")', + "URIError()", + 'URIError("uri error")', + 'AggregateError([], "aggregate error")', + 'AggregateError([Error("e1")], "multiple")', + "Error(msg)", + "TypeError(getErrorMessage())", + "/* @__PURE__ */ new Date", + "/* @__PURE__ */ new Map", + "/* @__PURE__ */ new Set", + ], + minifySyntax: true, + target: "bun", + }); + + itBundled("minify/ErrorConstructorWithVariables", { + files: { + "/entry.js": /* js */ ` + function capture(val) { console.log(val); return val; } + // Test that Error constructors work with variables and expressions + const e1 = new Error("test1"); + const e2 = new TypeError("test2"); + const e3 = new SyntaxError("test3"); + + capture(e1.message); + capture(e2.message); + capture(e3.message); + + // Test that they're still Error instances + capture(e1 instanceof Error); + capture(e2 instanceof TypeError); + capture(e3 instanceof SyntaxError); + + // Test with try-catch + try { + throw new RangeError("out of range"); + } catch (e) { + capture(e.message); + } + `, + }, + capture: [ + "val", + "e1.message", + "e2.message", + "e3.message", + "e1 instanceof Error", + "e2 instanceof TypeError", + "e3 instanceof SyntaxError", + "e.message", + ], + minifySyntax: true, + target: "bun", + run: { + stdout: "test1\ntest2\ntest3\ntrue\ntrue\ntrue\nout of range", + }, + }); + + itBundled("minify/ErrorConstructorPreservesSemantics", { + files: { + "/entry.js": /* js */ ` + function capture(val) { console.log(val); return val; } + // Verify that Error() and new Error() have identical behavior + const e1 = new Error("with new"); + const e2 = Error("without new"); + + // Both should be Error instances + capture(e1 instanceof Error); + capture(e2 instanceof Error); + + // Both should have the same message + capture(e1.message === "with new"); + capture(e2.message === "without new"); + + // Both should have stack traces + capture(typeof e1.stack === "string"); + capture(typeof e2.stack === "string"); + + // Test all error types + const errors = [ + [new TypeError("t1"), TypeError("t2")], + [new SyntaxError("s1"), SyntaxError("s2")], + [new RangeError("r1"), RangeError("r2")], + ]; + + for (const [withNew, withoutNew] of errors) { + capture(withNew.constructor === withoutNew.constructor); + } + `, + }, + capture: [ + "val", + "e1 instanceof Error", + "e2 instanceof Error", + 'e1.message === "with new"', + 'e2.message === "without new"', + 'typeof e1.stack === "string"', + 'typeof e2.stack === "string"', + "withNew.constructor === withoutNew.constructor", + ], + minifySyntax: true, + target: "bun", + run: { + stdout: "true\ntrue\ntrue\ntrue\ntrue\ntrue\ntrue\ntrue\ntrue", + }, + }); + + itBundled("minify/AdditionalGlobalConstructorOptimization", { + files: { + "/entry.js": /* js */ ` + // Test Array constructor + capture(new Array()); + capture(new Array(3)); + capture(new Array(1, 2, 3)); + + // Test Array with non-numeric single arguments (should convert to literal) + capture(new Array("string")); + capture(new Array(true)); + capture(new Array(null)); + capture(new Array(undefined)); + capture(new Array({})); + + // Test Object constructor + capture(new Object()); + capture(new Object(null)); + capture(new Object({ a: 1 })); + + // Test Function constructor + capture(new Function("return 42")); + capture(new Function("a", "b", "return a + b")); + + // Test RegExp constructor + capture(new RegExp("test")); + capture(new RegExp("test", "gi")); + capture(new RegExp(/abc/)); + + // Test with variables + const pattern = "\\d+"; + capture(new RegExp(pattern)); + + // Test that other constructors are preserved + capture(new Date()); + capture(new Map()); + capture(new Set()); + `, + }, + capture: [ + "[]", // new Array() -> [] + "Array(3)", // new Array(3) stays as Array(3) because it creates sparse array + `[ + 1, + 2, + 3 +]`, // new Array(1, 2, 3) -> [1, 2, 3] + `[ + "string" +]`, // new Array("string") -> ["string"] + `[ + !0 +]`, // new Array(true) -> [true] (minified to !0) + `[ + null +]`, // new Array(null) -> [null] + `[ + void 0 +]`, // new Array(undefined) -> [void 0] + `[ + {} +]`, // new Array({}) -> [{}] + "{}", // new Object() -> {} + "{}", // new Object(null) -> {} + "{ a: 1 }", // new Object({ a: 1 }) -> { a: 1 } + 'Function("return 42")', + 'Function("a", "b", "return a + b")', + 'new RegExp("test")', + 'new RegExp("test", "gi")', + "new RegExp(/abc/)", + "new RegExp(pattern)", + "/* @__PURE__ */ new Date", + "/* @__PURE__ */ new Map", + "/* @__PURE__ */ new Set", + ], + minifySyntax: true, + target: "bun", + }); + + itBundled("minify/ArrayConstructorWithNumberAndMinifyWhitespace", { + files: { + "/entry.js": /* js */ ` + capture(new Array(0)); + capture(new Array(1)); + capture(new Array(2)); + capture(new Array(3)); + capture(new Array(4)); + capture(new Array(5)); + capture(new Array(6)); + capture(new Array(7)); + capture(new Array(8)); + capture(new Array(9)); + capture(new Array(10)); + capture(new Array(11)); + capture(new Array(4.5)); + `, + }, + capture: [ + "[]", // new Array() -> [] + "[,]", // new Array(1) -> [undefined] + "[,,]", // new Array(2) -> [undefined, undefined] + "[,,,]", // new Array(3) -> [undefined, undefined, undefined] + "[,,,,]", // new Array(4) -> [undefined, undefined, undefined, undefined] + "[,,,,,]", // new Array(5) -> [undefined x 5] + "[,,,,,,]", // new Array(6) -> [undefined x 6] + "[,,,,,,,]", // new Array(7) -> [undefined x 7] + "[,,,,,,,,]", // new Array(8) -> [undefined x 8] + "[,,,,,,,,,]", // new Array(9) -> [undefined x 9] + "[,,,,,,,,,,]", // new Array(10) -> [undefined x 10] + "Array(11)", // new Array(11) -> Array(11) + "Array(4.5)", // new Array(4.5) is Array(4.5) because it's not an integer + ], + minifySyntax: true, + minifyWhitespace: true, + target: "bun", + }); + + itBundled("minify/GlobalConstructorSemanticsPreserved", { + files: { + "/entry.js": /* js */ ` + function capture(val) { console.log(val); return val; } + + // Test Array semantics + const a1 = new Array(1, 2, 3); + const a2 = Array(1, 2, 3); + capture(JSON.stringify(a1) === JSON.stringify(a2)); + capture(a1.constructor === a2.constructor); + + // Test sparse array semantics - new Array(5) creates sparse array + const sparse = new Array(5); + capture(sparse.length === 5); + capture(0 in sparse === false); // No element at index 0 + capture(JSON.stringify(sparse) === "[null,null,null,null,null]"); + + // Single-arg variable case: must preserve sparse semantics + const n = 3; + const a3 = new Array(n); + const a4 = Array(n); + capture(a3.length === a4.length && a3.length === 3 && a3[0] === undefined); + + // Test Object semantics + const o1 = new Object(); + const o2 = Object(); + capture(typeof o1 === typeof o2); + capture(o1.constructor === o2.constructor); + + // Test Function semantics + const f1 = new Function("return 1"); + const f2 = Function("return 1"); + capture(typeof f1 === typeof f2); + capture(f1() === f2()); + + // Test RegExp semantics + const r1 = new RegExp("test", "g"); + const r2 = RegExp("test", "g"); + capture(r1.source === r2.source); + capture(r1.flags === r2.flags); + `, + }, + capture: [ + "val", + "JSON.stringify(a1) === JSON.stringify(a2)", + "a1.constructor === a2.constructor", + "sparse.length === 5", + "0 in sparse === !1", + 'JSON.stringify(sparse) === "[null,null,null,null,null]"', + "a3.length === a4.length && a3.length === 3 && a3[0] === void 0", + "typeof o1 === typeof o2", + "o1.constructor === o2.constructor", + "typeof f1 === typeof f2", + "f1() === f2()", + "r1.source === r2.source", + "r1.flags === r2.flags", + ], + minifySyntax: true, + target: "bun", + run: { + stdout: "true\ntrue\ntrue\ntrue\ntrue\ntrue\ntrue\ntrue\ntrue\ntrue\ntrue\ntrue", + }, + }); + itBundled("minify/TypeofUndefinedOptimization", { files: { "/entry.js": /* js */ ` diff --git a/test/bundler/bundler_npm.test.ts b/test/bundler/bundler_npm.test.ts index d841e6b3e719..55fa25fb20c8 100644 --- a/test/bundler/bundler_npm.test.ts +++ b/test/bundler/bundler_npm.test.ts @@ -57,17 +57,17 @@ describe("bundler", () => { "../entry.tsx", ], mappings: [ - ["react.development.js:524:'getContextName'", "1:5426:Y1"], + ["react.development.js:524:'getContextName'", "1:5412:Y1"], ["react.development.js:2495:'actScopeDepth'", "23:4082:GJ++"], - ["react.development.js:696:''Component'", '1:7488:\'Component "%s"'], - ["entry.tsx:6:'\"Content-Type\"'", '100:18849:"Content-Type"'], - ["entry.tsx:11:''", "100:19103:void"], - ["entry.tsx:23:'await'", "100:19203:await"], + ["react.development.js:696:''Component'", '1:7474:\'Component "%s"'], + ["entry.tsx:6:'\"Content-Type\"'", '100:18809:"Content-Type"'], + ["entry.tsx:11:''", "100:19063:void"], + ["entry.tsx:23:'await'", "100:19163:await"], ], }, }, expectExactFilesize: { - "out/entry.js": 222114, + "out/entry.js": 221726, }, run: { stdout: "
This is an example.
", diff --git a/test/cli/hot/hot.test.ts b/test/cli/hot/hot.test.ts index d0fb0de6a65a..1a4ad85951a3 100644 --- a/test/cli/hot/hot.test.ts +++ b/test/cli/hot/hot.test.ts @@ -470,7 +470,7 @@ ${" ".repeat(reloadCounter * 2)}throw new Error(${reloadCounter});`, const match = next.match(/\s*at.*?:1003:(\d+)$/); if (!match) throw new Error("invalid string: " + next); const col = match[1]; - expect(Number(col)).toBe(1 + "throw ".length + (reloadCounter - 1) * 2); + expect(Number(col)).toBe(1 + "throw new ".length + (reloadCounter - 1) * 2); any = true; } diff --git a/test/js/bun/test/stack.test.ts b/test/js/bun/test/stack.test.ts index db623a38ad12..782215e12378 100644 --- a/test/js/bun/test/stack.test.ts +++ b/test/js/bun/test/stack.test.ts @@ -78,7 +78,7 @@ test("err.line and err.column are set", () => { line: 3, column: 17, originalLine: 1, - originalColumn: 22, + originalColumn: 18, }, null, 2, diff --git a/test/js/bun/test/test-error-code-done-callback.test.ts b/test/js/bun/test/test-error-code-done-callback.test.ts index 0d4e9eab0e17..4e6fac4adb78 100644 --- a/test/js/bun/test/test-error-code-done-callback.test.ts +++ b/test/js/bun/test/test-error-code-done-callback.test.ts @@ -49,7 +49,7 @@ test("verify we print error messages passed to done callbacks", () => { 27 | done(new Error(msg + "(sync)")); ^ error: you should see this(sync) - at