From 5faaf4b40bf789367ff9a6498fecc8072aa8d648 Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Tue, 11 Aug 2026 02:34:56 +0000
Subject: [PATCH 1/7] transpiler: keep `new` on Error constructors so the
creating frame stays in the stack
With minify_syntax on (always the case for the runtime transpiler, and for
bun build --minify) `new Error(...)` and the other seven native error
constructors were rewritten to plain calls. Modules are strict mode code
and JSC implements proper tail calls there, so `return Error(...)` pops
the calling function's frame before the error captures its stack. Any
`return new Error(...)` helper, including `const e = new Error(); return e`
after the single-use binding is inlined, produced a stack without the
function that created the error. Constructs are never tail calls, so keep
the `new`.
Error positions in code frames and call sites move back from the
constructor name to the `new` keyword, which is where V8 reports them;
the affected snapshots are updated accordingly.
---
src/ast/known_global.rs | 8 ++-
test/bundler/bundler_minify.test.ts | 64 +++++++++++++------
test/bundler/bundler_npm.test.ts | 12 ++--
test/cli/hot/hot.test.ts | 4 +-
test/js/bun/test/stack.test.ts | 46 ++++++++++++-
.../test-error-code-done-callback.test.ts | 18 +++---
test/js/bun/test/test-test.test.ts | 4 +-
test/js/bun/util/inspect-error.test.js | 44 +++++--------
test/js/bun/util/inspect.test.js | 2 +-
test/js/bun/util/reportError.test.ts | 4 +-
test/js/web/console/console-log.test.ts | 2 +-
11 files changed, 134 insertions(+), 74 deletions(-)
diff --git a/src/ast/known_global.rs b/src/ast/known_global.rs
index 47fe7ff9dbaf..489870df10a2 100644
--- a/src/ast/known_global.rs
+++ b/src/ast/known_global.rs
@@ -103,7 +103,6 @@ impl KnownGlobal {
let constructor = lookup(original_name)?;
match constructor {
- // Error constructors can be called without 'new' with identical behavior
KnownGlobal::Error
| KnownGlobal::TypeError
| KnownGlobal::SyntaxError
@@ -112,8 +111,11 @@ impl KnownGlobal {
| KnownGlobal::EvalError
| KnownGlobal::URIError
| KnownGlobal::AggregateError => {
- // Convert `new Error(...)` to `Error(...)` to save bytes
- Some(Self::call_from_new(e, loc))
+ // `Error(...)` builds the same object as `new Error(...)`, but not the
+ // same `.stack`: in strict mode JSC turns `return Error(...)` into a
+ // proper tail call, so the function creating the error is already gone
+ // when the stack is captured. `new` is never a tail call. Keep it.
+ None
}
KnownGlobal::Object => {
diff --git a/test/bundler/bundler_minify.test.ts b/test/bundler/bundler_minify.test.ts
index f6220592d6d9..79bd0de00f75 100644
--- a/test/bundler/bundler_minify.test.ts
+++ b/test/bundler/bundler_minify.test.ts
@@ -819,7 +819,10 @@ describe("bundler", () => {
},
});
- itBundled("minify/ErrorConstructorOptimization", {
+ // `Error(...)` creates the same object as `new Error(...)`, but in strict mode JSC compiles
+ // `return Error(...)` to a tail call, which removes the creating function from the stack trace.
+ // `new` is never a tail call, so it has to stay.
+ itBundled("minify/ErrorConstructorKeepsNew", {
files: {
"/entry.js": /* js */ `
// Test all Error constructors
@@ -862,25 +865,25 @@ describe("bundler", () => {
`,
},
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())",
+ "new Error",
+ 'new Error("message")',
+ 'new Error("message", { cause: "cause" })',
+ "new TypeError",
+ 'new TypeError("type error")',
+ "new SyntaxError",
+ 'new SyntaxError("syntax error")',
+ "new RangeError",
+ 'new RangeError("range error")',
+ "new ReferenceError",
+ 'new ReferenceError("ref error")',
+ "new EvalError",
+ 'new EvalError("eval error")',
+ "new URIError",
+ 'new URIError("uri error")',
+ 'new AggregateError([], "aggregate error")',
+ 'new AggregateError([new Error("e1")], "multiple")',
+ "new Error(msg)",
+ "new TypeError(getErrorMessage())",
"/* @__PURE__ */ new Date",
"/* @__PURE__ */ new Map",
"/* @__PURE__ */ new Set",
@@ -889,6 +892,27 @@ describe("bundler", () => {
target: "bun",
});
+ itBundled("minify/ErrorReturnedFromFunctionKeepsItsFrame", {
+ files: {
+ "/entry.js": /* js */ `
+ function makeError() {
+ return new Error("made");
+ }
+ function makeTypeError() {
+ const err = new TypeError("made");
+ return err;
+ }
+ const frames = [makeError, makeTypeError].map(make => make().stack.includes("at " + make.name + " "));
+ console.log(JSON.stringify(frames));
+ `,
+ },
+ minifySyntax: true,
+ target: "bun",
+ run: {
+ stdout: "[true,true]",
+ },
+ });
+
itBundled("minify/ErrorConstructorWithVariables", {
files: {
"/entry.js": /* js */ `
diff --git a/test/bundler/bundler_npm.test.ts b/test/bundler/bundler_npm.test.ts
index 09807ab33ac5..01e8345c1b20 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:5623:at"],
+ ["react.development.js:524:'getContextName'", "1:5629:at"],
["react.development.js:2495:'actScopeDepth'", "23:4082:or++"],
- ["react.development.js:696:''Component'", '1:7685:\'Component "%s"'],
- ["entry.tsx:6:'\"Content-Type\"'", '100:18808:"Content-Type"'],
- ["entry.tsx:11:''", "100:19062:void"],
- ["entry.tsx:23:'await'", "100:19161:await"],
+ ["react.development.js:696:''Component'", '1:7691:\'Component "%s"'],
+ ["entry.tsx:6:'\"Content-Type\"'", '100:18848:"Content-Type"'],
+ ["entry.tsx:11:''", "100:19102:void"],
+ ["entry.tsx:23:'await'", "100:19201:await"],
],
},
},
expectExactFilesize: {
- "out/entry.js": 222000,
+ "out/entry.js": 222360,
},
run: {
stdout: "
Hello World
This is an example.
",
diff --git a/test/cli/hot/hot.test.ts b/test/cli/hot/hot.test.ts
index 8ab6f31dd9e6..e5713017df3d 100644
--- a/test/cli/hot/hot.test.ts
+++ b/test/cli/hot/hot.test.ts
@@ -567,7 +567,7 @@ ${Buffer.alloc(counter * 2, " ").toString()}throw new Error(${counter});`,
const match = nextLine.match(/\s*at.*?:1003:(\d+)$/);
if (!match) throw new Error("invalid string: " + nextLine);
const col = match[1];
- expect(Number(col)).toBe(1 + "throw new ".length + counter * 2);
+ expect(Number(col)).toBe(1 + "throw ".length + counter * 2);
},
});
await runner.exited;
@@ -614,7 +614,7 @@ ${Buffer.alloc(counter * 2, " ").toString()}throw new Error('${counter}');`,
const match = nextLine.match(/\s*at.*?:(\d+):(\d+)\)?$/);
if (!match) throw new Error("no :line:col in: " + JSON.stringify(nextLine));
if (match[1] !== "1003") throw new Error("expected :1003: but got: " + JSON.stringify(nextLine));
- expect(Number(match[2])).toBe(1 + "throw new ".length + counter * 2);
+ expect(Number(match[2])).toBe(1 + "throw ".length + counter * 2);
},
});
await runner.exited;
diff --git a/test/js/bun/test/stack.test.ts b/test/js/bun/test/stack.test.ts
index 63b28630a3f6..079ffff791fb 100644
--- a/test/js/bun/test/stack.test.ts
+++ b/test/js/bun/test/stack.test.ts
@@ -77,7 +77,7 @@ test("err.line and err.column are set", async () => {
line: 3,
column: 17,
originalLine: 1,
- originalColumn: 18,
+ originalColumn: 22,
},
null,
2,
@@ -149,3 +149,47 @@ test("Async functions frame should be included in stack trace", async () => {
at async (file:NN:NN)"
`);
});
+
+// Modules are strict mode code, and in strict mode JSC turns `return f()` into a proper tail
+// call: the returning function's frame is gone by the time `f` captures the stack. `new f()` is
+// never a tail call, so the transpiler must not rewrite `new Error()` into `Error()` to save bytes.
+test("a function returning `new Error()` is in the error's stack", () => {
+ const factories: Record Error> = {
+ error() {
+ return new Error("error");
+ },
+ typeError() {
+ return new TypeError("typeError");
+ },
+ syntaxError() {
+ return new SyntaxError("syntaxError");
+ },
+ rangeError() {
+ return new RangeError("rangeError");
+ },
+ referenceError() {
+ return new ReferenceError("referenceError");
+ },
+ evalError() {
+ return new EvalError("evalError");
+ },
+ uriError() {
+ return new URIError("uriError");
+ },
+ aggregateError() {
+ return new AggregateError([], "aggregateError");
+ },
+ viaConst() {
+ // the single-use binding gets inlined into the return statement
+ const e = new Error("viaConst");
+ return e;
+ },
+ arrow: () => new Error("arrow"),
+ };
+
+ const missingOwnFrame = Object.keys(factories).filter(
+ name => !factories[name]().stack!.includes(`\n at ${name} (`),
+ );
+
+ expect(missingOwnFrame).toEqual([]);
+});
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 610c4d85e7ec..da45086f2a67 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 (/test-error-done-callback-fixture.ts:27:12)
+ at (/test-error-done-callback-fixture.ts:27:8)
(fail) error done callback (sync)
27 | done(new Error(msg + "(sync)"));
28 | });
@@ -59,7 +59,7 @@ test("verify we print error messages passed to done callbacks", () => {
32 | done(new Error(msg + "(async with await)"));
^
error: you should see this(async with await)
- at (/test-error-done-callback-fixture.ts:32:12)
+ at (/test-error-done-callback-fixture.ts:32:8)
(fail) error done callback (async with await)
32 | done(new Error(msg + "(async with await)"));
33 | });
@@ -69,7 +69,7 @@ test("verify we print error messages passed to done callbacks", () => {
37 | done(new Error(msg + "(async with Bun.sleep)"));
^
error: you should see this(async with Bun.sleep)
- at (/test-error-done-callback-fixture.ts:37:12)
+ at (/test-error-done-callback-fixture.ts:37:8)
(fail) error done callback (async with Bun.sleep)
37 | done(new Error(msg + "(async with Bun.sleep)"));
38 | });
@@ -79,7 +79,7 @@ test("verify we print error messages passed to done callbacks", () => {
42 | done(new Error(msg + "(async)"));
^
error: you should see this(async)
- at (/test-error-done-callback-fixture.ts:42:14)
+ at (/test-error-done-callback-fixture.ts:42:10)
at (/test-error-done-callback-fixture.ts:37:3)
(fail) error done callback (async)
43 | });
@@ -90,7 +90,7 @@ test("verify we print error messages passed to done callbacks", () => {
48 | done(new Error(msg + "(async, setTimeout)"));
^
error: you should see this(async, setTimeout)
- at (/test-error-done-callback-fixture.ts:48:14)
+ at (/test-error-done-callback-fixture.ts:48:10)
(fail) error done callback (async, setTimeout)
49 | }, 0);
50 | });
@@ -100,7 +100,7 @@ test("verify we print error messages passed to done callbacks", () => {
54 | done(new Error(msg + "(async, setImmediate)"));
^
error: you should see this(async, setImmediate)
- at (/test-error-done-callback-fixture.ts:54:14)
+ at (/test-error-done-callback-fixture.ts:54:10)
(fail) error done callback (async, setImmediate)
55 | });
56 | });
@@ -110,7 +110,7 @@ test("verify we print error messages passed to done callbacks", () => {
60 | done(new Error(msg + "(async, nextTick)"));
^
error: you should see this(async, nextTick)
- at (/test-error-done-callback-fixture.ts:60:14)
+ at (/test-error-done-callback-fixture.ts:60:10)
at (/test-error-done-callback-fixture.ts:54:5)
(fail) error done callback (async, nextTick)
62 | });
@@ -121,7 +121,7 @@ test("verify we print error messages passed to done callbacks", () => {
67 | done(new Error(msg + "(async, setTimeout, Promise.resolve)"));
^
error: you should see this(async, setTimeout, Promise.resolve)
- at (/test-error-done-callback-fixture.ts:67:16)
+ at (/test-error-done-callback-fixture.ts:67:12)
(fail) error done callback (async, setTimeout, Promise.resolve)
70 | });
71 |
@@ -131,7 +131,7 @@ test("verify we print error messages passed to done callbacks", () => {
75 | done(new Error(msg + "(async, setImmediate, Promise.resolve)"));
^
error: you should see this(async, setImmediate, Promise.resolve)
- at (/test-error-done-callback-fixture.ts:75:16)
+ at (/test-error-done-callback-fixture.ts:75:12)
(fail) error done callback (async, setImmediate, Promise.resolve)
0 pass
diff --git a/test/js/bun/test/test-test.test.ts b/test/js/bun/test/test-test.test.ts
index 1fad55319b9b..d1e9d19f1471 100644
--- a/test/js/bun/test/test-test.test.ts
+++ b/test/js/bun/test/test-test.test.ts
@@ -735,10 +735,10 @@ test("my-test", () => {
const stackLines = output.split("\n").filter(line => line.trim().startsWith("at "));
expect(stackLines.length).toBeGreaterThan(0);
if (process.platform === "win32") {
- expect(stackLines[0]).toContain(`\\my-test.test.js:5:15`.replace("", test_dir));
+ expect(stackLines[0]).toContain(`\\my-test.test.js:5:11`.replace("", test_dir));
}
if (process.platform !== "win32") {
- expect(stackLines[0]).toContain(`/my-test.test.js:5:15`.replace("", test_dir));
+ expect(stackLines[0]).toContain(`/my-test.test.js:5:11`.replace("", test_dir));
}
expect(output).toContain("1 pass"); // since the error is unhandled and in a hook, the error does not get attributed to the hook and the test is still allowed to run
diff --git a/test/js/bun/util/inspect-error.test.js b/test/js/bun/util/inspect-error.test.js
index 4d3f488dac81..0c0290323986 100644
--- a/test/js/bun/util/inspect-error.test.js
+++ b/test/js/bun/util/inspect-error.test.js
@@ -13,17 +13,17 @@ test("error.cause", () => {
3 | test("error.cause", () => {
4 | const err = new Error("error 1");
5 | const err2 = new Error("error 2", { cause: err });
- ^
+ ^
error: error 2
- at ([dir]/inspect-error.test.js:5:20)
+ at ([dir]/inspect-error.test.js:5:16)
1 | import { describe, expect, jest, test } from "bun:test";
2 |
3 | test("error.cause", () => {
4 | const err = new Error("error 1");
- ^
+ ^
error: error 1
- at ([dir]/inspect-error.test.js:4:19)
+ at ([dir]/inspect-error.test.js:4:15)
"
`);
});
@@ -41,9 +41,9 @@ test("Error", () => {
30 |
31 | test("Error", () => {
32 | const err = new Error("my message");
- ^
+ ^
error: my message
- at ([dir]/inspect-error.test.js:32:19)
+ at ([dir]/inspect-error.test.js:32:15)
"
`);
});
@@ -71,22 +71,12 @@ note: "duplicateConstDecl" was originally declared here
}
});
-const normalizeError = str => {
- // remove debug-only stack trace frames
- // like "at require (:1:21)"
- if (str.includes(" (:")) {
- const splits = str.split("\n");
- for (let i = 0; i < splits.length; i++) {
- if (splits[i].includes(" (:")) {
- splits.splice(i, 1);
- i--;
- }
- }
- return splits.join("\n");
- }
-
- return str;
-};
+// Debug builds show frames of Bun's internal JS, which have no file: "at require (51:24)".
+const normalizeError = str =>
+ str
+ .split("\n")
+ .filter(line => !/^\s+at .* \(:?\d+:\d+\)$/.test(line))
+ .join("\n");
test("Error inside minified file (no color) ", () => {
try {
@@ -109,9 +99,9 @@ test("Error inside minified file (no color) ", () => {
26 | exports.forwardRef=function(a){return{$$typeof:v,render:a}};exports.forwardRef=function(a){return{$$typeof:v,render:a}};exports.forwardRef=function(a){return{$$typeof:v,render:a}};exports.forwardRef=function(a){return{$$typeof:v,render:a}};exports.forwardRef=function(a){return{$$typeof:v,render:a}};exports.forwardRef=function(a){return{$$typeof:v,render:a}};exports.forwardRef=function(a){return{$$typeof:v,render:a}};exports.forwardRef=function(a){return{$$typeof:v,render:a}};exports.forwardRef=function(a){return{$$typeof:v,render:a}};exports.forwardRef=function(a){return{$$typeof:v,render:a}};exports.forwardRef=function(a){return{$$typeof:v,render:a}};exports.forwardRef=function(a){return{$$typeof:v,render:a}};exports.forwardRef=function(a){return{$$typeof:v,render:a}};exports.forwardRef=function(a){return{$$typeof:v,render:a}};exports.forwardRef=function(a){return{$$typeof:v,render:a}};exports.forwardRef=function(a){return{$$typeof:v,render:a}};exports.forwardRef=function(a){return{$$typeof:v,render:a}};expo
error: error inside long minified file!
- at ([dir]/inspect-error-fixture.min.js:26:2850)
+ at ([dir]/inspect-error-fixture.min.js:26:2846)
at ([dir]/inspect-error-fixture.min.js:26:2890)
- at ([dir]/inspect-error.test.js:92:7)"
+ at ([dir]/inspect-error.test.js:82:7)"
`);
}
});
@@ -138,9 +128,9 @@ test("Error inside minified file (color) ", () => {
26 | exports.forwardRef=function(a){return{$$typeof:v,render:a}};exports.forwardRef=function(a){return{$$typeof:v,render:a}};exports.forwardRef=function(a){return{$$typeof:v,render:a}};exports.forwardRef=function(a){return{$$typeof:v,render:a}};exports.forwardRef=function(a){return{$$typeof:v,render:a}};exports.forwardRef=function(a){return{$$typeof:v,render:a}};exports.forwardRef=function(a){return{$$typeof:v,render:a}};exports.forwardRef=function(a){return{$$typeof:v,render:a}};exports.forwardRef=function(a){return{$$typeof:v,render:a}};exports.forwardRef=function(a){return{$$typeof:v,render:a}};exports.forwardRef=function(a){return{$$typeof:v,render:a}};exports.forwardRef=function(a){return{$$typeof:v,render:a}};exports.forwardRef=function(a){return{$$typeof:v,render:a}};exports.forwardRef=function(a){return{$$typeof:v,render:a}};exports.forwardRef=function(a){return{$$typeof:v,render:a}};exports.forwardRef=function(a){return{$$typeof:v,render:a}};exports.forwardRef=function(a){return{$$typeof:v,render:a}};expo | ... truncated
error: error inside long minified file!
- at ([dir]/inspect-error-fixture.min.js:26:2850)
+ at ([dir]/inspect-error-fixture.min.js:26:2846)
at ([dir]/inspect-error-fixture.min.js:26:2890)
- at ([dir]/inspect-error.test.js:120:7)"
+ at ([dir]/inspect-error.test.js:110:7)"
`);
}
});
@@ -154,7 +144,7 @@ test("Inserted originalLine and originalColumn do not appear in node:util.inspec
.replaceAll(import.meta.path.replaceAll("\\", "/"), "[file]"),
).toMatchInlineSnapshot(`
"Error: my message
- at ([file]:149:19)"
+ at ([file]:139:19)"
`);
});
diff --git a/test/js/bun/util/inspect.test.js b/test/js/bun/util/inspect.test.js
index 5c91f3dbe05f..514100f4dfb0 100644
--- a/test/js/bun/util/inspect.test.js
+++ b/test/js/bun/util/inspect.test.js
@@ -741,7 +741,7 @@ it("ErrorEvent", () => {
NNN | lineno: 42,
NNN | colno: 10,
NNN | error: new Error("Test error"),
- ^
+ ^
error: Test error
at (file:NN:NN)
,
diff --git a/test/js/bun/util/reportError.test.ts b/test/js/bun/util/reportError.test.ts
index 3075af55f04f..b45f5ab43db8 100644
--- a/test/js/bun/util/reportError.test.ts
+++ b/test/js/bun/util/reportError.test.ts
@@ -21,9 +21,9 @@ test("reportError", () => {
expect(output.replaceAll("\\", "/").replaceAll("/reportError.ts", "[file]")).toMatchInlineSnapshot(
`
"1 | reportError(new Error("reportError Test!"));
- ^
+ ^
error: reportError Test!
- at [file]:1:17
+ at [file]:1:13
error: true
true
error: false
diff --git a/test/js/web/console/console-log.test.ts b/test/js/web/console/console-log.test.ts
index 4356ae993262..5bb2b0438762 100644
--- a/test/js/web/console/console-log.test.ts
+++ b/test/js/web/console/console-log.test.ts
@@ -122,7 +122,7 @@ Quote"Backslash
55 | console.warn("Warning log");
56 | console.warn(new Error("console.warn an error"));
57 | console.error(new Error("console.error an error"));
- ^
+ ^
error: console.error an error
at :NN:NN
From 5be042b324b63d6e0fa73b70f40d050e5d0deb62 Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Tue, 11 Aug 2026 03:27:52 +0000
Subject: [PATCH 2/7] transpiler: keep `new` on Function too; its body's source
origin comes from the calling frame
`return Function(...)` is the same tail call as `return Error(...)`: the
Function constructor then takes the source origin for the new body from the
caller's caller, so an import() inside the body resolves relative to the
wrong file.
---
src/ast/known_global.rs | 16 ++++-----
test/bundler/bundler_minify.test.ts | 5 +--
.../transpiler/runtime-transpiler.test.ts | 34 +++++++++++++++++++
3 files changed, 44 insertions(+), 11 deletions(-)
diff --git a/src/ast/known_global.rs b/src/ast/known_global.rs
index 489870df10a2..340771e12773 100644
--- a/src/ast/known_global.rs
+++ b/src/ast/known_global.rs
@@ -110,11 +110,13 @@ impl KnownGlobal {
| KnownGlobal::ReferenceError
| KnownGlobal::EvalError
| KnownGlobal::URIError
- | KnownGlobal::AggregateError => {
- // `Error(...)` builds the same object as `new Error(...)`, but not the
- // same `.stack`: in strict mode JSC turns `return Error(...)` into a
- // proper tail call, so the function creating the error is already gone
- // when the stack is captured. `new` is never a tail call. Keep it.
+ | KnownGlobal::AggregateError
+ | KnownGlobal::Function => {
+ // These build the same object with or without `new`, but they read the calling
+ // frame, and in strict mode JSC compiles `return Error(...)` to a proper tail
+ // call that has already popped it: the error's `.stack` loses the function that
+ // created it, and a `Function(...)` body takes its source origin (the base for
+ // `import()` inside it) from the caller's caller. `new` is never a tail call.
None
}
@@ -257,10 +259,6 @@ impl KnownGlobal {
}
}
- KnownGlobal::Function => {
- // Just remove 'new' for Function
- Some(Self::call_from_new(e, loc))
- }
KnownGlobal::RegExp => {
// Don't optimize RegExp - the semantics are too complex:
// - new RegExp(re) creates a copy, but RegExp(re) returns the same instance
diff --git a/test/bundler/bundler_minify.test.ts b/test/bundler/bundler_minify.test.ts
index 79bd0de00f75..36c2bd4af2e8 100644
--- a/test/bundler/bundler_minify.test.ts
+++ b/test/bundler/bundler_minify.test.ts
@@ -1070,8 +1070,9 @@ describe("bundler", () => {
"{}", // new Object() -> {}
"{}", // new Object(null) -> {}
"{ a: 1 }", // new Object({ a: 1 }) -> { a: 1 }
- 'Function("return 42")',
- 'Function("a", "b", "return a + b")',
+ // kept for the same reason as the Error constructors: the body's source origin comes from the calling frame
+ 'new Function("return 42")',
+ 'new Function("a", "b", "return a + b")',
'new RegExp("test")',
'new RegExp("test", "gi")',
"new RegExp(/abc/)",
diff --git a/test/bundler/transpiler/runtime-transpiler.test.ts b/test/bundler/transpiler/runtime-transpiler.test.ts
index 5c05e408c92a..d6192b13c40f 100644
--- a/test/bundler/transpiler/runtime-transpiler.test.ts
+++ b/test/bundler/transpiler/runtime-transpiler.test.ts
@@ -252,3 +252,37 @@ describe("unterminated string literals in large files", () => {
expect(exitCode).toBe(1);
});
});
+
+// Rewriting `return new Function(...)` into `return Function(...)` makes it a proper tail call in
+// strict mode code, and the Function constructor then takes the source origin for the new body
+// from whichever frame is left: the caller's caller, in another file.
+test("a function body built with `return new Function()` resolves import() relative to the returning module", async () => {
+ using dir = tempDir("transpiler-new-function-origin", {
+ "main.mjs": /* js */ `
+ import { make } from "./lib/make.mjs";
+ const { which } = await make()();
+ console.log(which);
+ `,
+ "lib/make.mjs": /* js */ `
+ export function make() {
+ return new Function("return import('./which.mjs')");
+ }
+ `,
+ "lib/which.mjs": `export const which = "lib/which.mjs";`,
+ "which.mjs": `export const which = "which.mjs";`,
+ });
+
+ await using proc = Bun.spawn({
+ cmd: [bunExe(), "main.mjs"],
+ env: bunEnv,
+ cwd: String(dir),
+ stdout: "pipe",
+ stderr: "pipe",
+ });
+
+ const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
+
+ expect(stderr).toBe("");
+ expect(stdout).toBe("lib/which.mjs\n");
+ expect(exitCode).toBe(0);
+});
From 48434e66d860ec8a5f13b9d319504203c1337450 Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Tue, 11 Aug 2026 03:31:37 +0000
Subject: [PATCH 3/7] known_global: shorten the rationale comment
---
src/ast/known_global.rs | 6 +-----
1 file changed, 1 insertion(+), 5 deletions(-)
diff --git a/src/ast/known_global.rs b/src/ast/known_global.rs
index 340771e12773..5189b6ff6847 100644
--- a/src/ast/known_global.rs
+++ b/src/ast/known_global.rs
@@ -112,11 +112,7 @@ impl KnownGlobal {
| KnownGlobal::URIError
| KnownGlobal::AggregateError
| KnownGlobal::Function => {
- // These build the same object with or without `new`, but they read the calling
- // frame, and in strict mode JSC compiles `return Error(...)` to a proper tail
- // call that has already popped it: the error's `.stack` loses the function that
- // created it, and a `Function(...)` body takes its source origin (the base for
- // `import()` inside it) from the caller's caller. `new` is never a tail call.
+ // Kept: these read the calling frame, which a strict-mode `return Error(...)` tail call has already popped.
None
}
From fdfae0bccf12bf3b19884c029a4587335c9675cb Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Tue, 11 Aug 2026 04:02:25 +0000
Subject: [PATCH 4/7] test: dev error page frame position now points at `new`
---
test/js/bun/http/serve.test.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/test/js/bun/http/serve.test.ts b/test/js/bun/http/serve.test.ts
index 3800c8dadd34..4d8181f77132 100644
--- a/test/js/bun/http/serve.test.ts
+++ b/test/js/bun/http/serve.test.ts
@@ -1950,8 +1950,8 @@ it.concurrent("dev error page embeds the thrown error, its stack, and build/reso
expect(exception.stack.frames[0]).toEqual({
function_name: "inner",
file: join(String(dir), "server.ts"),
- // 1-based, pointing at `TypeError` — the same position `bun` prints to the terminal
- position: { line: 3, column: 19 },
+ // 1-based, pointing at `new`, the same position `bun` prints to the terminal
+ position: { line: 3, column: 15 },
scope: 3, // function
});
expect(exception.stack.source_lines).toContainEqual({
From 967a6e81e76994df94e11d77535512e18689a76b Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Tue, 11 Aug 2026 08:45:11 +0000
Subject: [PATCH 5/7] transpiler: never rewrite a known-global construct into a
call; bump the transpiler cache version
The remaining call rewrites (`new Object(x)` and `new Array(x)` with a
non-literal or length argument) have the same problem as the error
constructors: `return Array(...lengths)` is a varargs tail call, so the
RangeError thrown for an invalid length has no frame for the function that
asked for the array. Only the literal foldings remain, which makes the rule
a single sentence.
Cached transpiler output written by earlier versions still holds the call
form, so the runtime transpiler cache version moves to 26.
---
src/ast/known_global.rs | 44 ++++++++---------------------
src/jsc/RuntimeTranspilerCache.rs | 5 +++-
test/bundler/bundler_minify.test.ts | 35 ++++++++++++++++++-----
test/bundler/bundler_npm.test.ts | 6 ++--
test/js/bun/test/stack.test.ts | 15 ++++++++++
5 files changed, 61 insertions(+), 44 deletions(-)
diff --git a/src/ast/known_global.rs b/src/ast/known_global.rs
index 5189b6ff6847..423802f9033c 100644
--- a/src/ast/known_global.rs
+++ b/src/ast/known_global.rs
@@ -65,18 +65,6 @@ fn lookup(name: &[u8]) -> Option {
}
impl KnownGlobal {
- #[inline(always)]
- fn call_from_new(e: &mut E::New, loc: crate::Loc) -> js_ast::Expr {
- let call = E::Call {
- target: e.target,
- args: bun_alloc::AstAlloc::take(&mut e.args),
- close_paren_loc: e.close_parens_loc,
- can_be_unwrapped_if_unused: e.can_be_unwrapped_if_unused,
- ..Default::default()
- };
- js_ast::Expr::init(call, loc)
- }
-
// `_bump` is unused; the `Vec` uses the global arena.
#[inline(never)]
pub fn minify_global_constructor(
@@ -102,6 +90,9 @@ impl KnownGlobal {
let original_name = symbol.original_name.slice();
let constructor = lookup(original_name)?;
+ // `new X(...)` is only ever folded into a literal, never rewritten into the call `X(...)`:
+ // in strict mode `return X(...)` is a proper tail call, so the frame creating the value is
+ // gone by the time `X` captures a stack trace, reads its source origin (`Function`) or throws.
match constructor {
KnownGlobal::Error
| KnownGlobal::TypeError
@@ -111,10 +102,9 @@ impl KnownGlobal {
| KnownGlobal::EvalError
| KnownGlobal::URIError
| KnownGlobal::AggregateError
- | KnownGlobal::Function => {
- // Kept: these read the calling frame, which a strict-mode `return Error(...)` tail call has already popped.
- None
- }
+ | KnownGlobal::Function
+ // `RegExp(re)` would also return `re` itself where `new RegExp(re)` copies it.
+ | KnownGlobal::RegExp => None,
KnownGlobal::Object => {
let n = e.args.len_u32();
@@ -141,8 +131,7 @@ impl KnownGlobal {
}
}
- // For other cases, just remove 'new'
- Some(Self::call_from_new(e, loc))
+ None
}
KnownGlobal::Array => {
@@ -176,7 +165,6 @@ impl KnownGlobal {
// For other types, check via knownPrimitive
let primitive = arg.known_primitive();
// 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
match primitive {
js_ast::expr::PrimitiveType::Null
| js_ast::expr::PrimitiveType::Undefined
@@ -195,7 +183,7 @@ impl KnownGlobal {
js_ast::expr::PrimitiveType::Number => {
let val = match arg.data {
js_ast::ExprData::ENumber(num) => num.value(),
- _ => return Some(Self::call_from_new(e, loc)),
+ _ => return None,
};
if
// only want this with whitespace minification
@@ -231,13 +219,11 @@ impl KnownGlobal {
loc,
));
}
- Some(Self::call_from_new(e, loc))
+ None
}
+ // Could be a number, so `new Array(x)` may mean a length.
js_ast::expr::PrimitiveType::Unknown
- | js_ast::expr::PrimitiveType::Mixed => {
- // Could be a number, preserve Array() call
- Some(Self::call_from_new(e, loc))
- }
+ | js_ast::expr::PrimitiveType::Mixed => None,
}
}
// > 1
@@ -255,14 +241,6 @@ impl KnownGlobal {
}
}
- KnownGlobal::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
- None
- }
KnownGlobal::WeakSet | KnownGlobal::WeakMap => {
let n = e.args.len_u32();
diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs
index 3373c1402e99..2de2aaec36b9 100644
--- a/src/jsc/RuntimeTranspilerCache.rs
+++ b/src/jsc/RuntimeTranspilerCache.rs
@@ -51,7 +51,10 @@ bun_core::declare_scope!(cache, visible);
/// Version 25: Every ModuleInfo record carries a trailing FetchParameters slot
/// so ImportEntry/ExportEntry/StarExportEntry moduleRequestType matches JSC's
/// after WebKit 90b2ecf79ae3 keyed m_loadedModules on (specifier, type).
-const EXPECTED_VERSION: u32 = 25;
+/// Version 26: `new Error(...)` (and the other native error constructors, `Function`,
+/// `Object`, `Array`) is no longer rewritten into a plain call; older entries hold the
+/// call form, which JSC tail-calls and so drops the creating function from the stack.
+const EXPECTED_VERSION: u32 = 26;
/// Source files smaller than this are not written to / read from the on-disk
/// transpiler cache. Originally 50 KiB, which excluded almost every file in a
diff --git a/test/bundler/bundler_minify.test.ts b/test/bundler/bundler_minify.test.ts
index 36c2bd4af2e8..104b1f6929e3 100644
--- a/test/bundler/bundler_minify.test.ts
+++ b/test/bundler/bundler_minify.test.ts
@@ -892,7 +892,7 @@ describe("bundler", () => {
target: "bun",
});
- itBundled("minify/ErrorReturnedFromFunctionKeepsItsFrame", {
+ itBundled("minify/ReturnedConstructorKeepsItsFrame", {
files: {
"/entry.js": /* js */ `
function makeError() {
@@ -902,14 +902,28 @@ describe("bundler", () => {
const err = new TypeError("made");
return err;
}
- const frames = [makeError, makeTypeError].map(make => make().stack.includes("at " + make.name + " "));
+ function makeArray(...lengths) {
+ return new Array(...lengths);
+ }
+ function thrownBy(make) {
+ try {
+ make(-1);
+ } catch (err) {
+ return err;
+ }
+ }
+ const frames = [
+ [makeError, makeError()],
+ [makeTypeError, makeTypeError()],
+ [makeArray, thrownBy(makeArray)],
+ ].map(([fn, err]) => err.stack.includes("at " + fn.name + " "));
console.log(JSON.stringify(frames));
`,
},
minifySyntax: true,
target: "bun",
run: {
- stdout: "[true,true]",
+ stdout: "[true,true,true]",
},
});
@@ -1011,6 +1025,8 @@ describe("bundler", () => {
// Test Array constructor
capture(new Array());
capture(new Array(3));
+ capture(new Array(unknownValue));
+ capture(new Array(...unknownValue));
capture(new Array(1, 2, 3));
// Test Array with non-numeric single arguments (should convert to literal)
@@ -1024,6 +1040,7 @@ describe("bundler", () => {
capture(new Object());
capture(new Object(null));
capture(new Object({ a: 1 }));
+ capture(new Object(unknownValue));
// Test Function constructor
capture(new Function("return 42"));
@@ -1046,7 +1063,11 @@ describe("bundler", () => {
},
capture: [
"[]", // new Array() -> []
- "Array(3)", // new Array(3) stays as Array(3) because it creates sparse array
+ // A single argument may be a length, so these cannot become literals. They are not turned
+ // into `Array(...)` calls either (see ErrorConstructorKeepsNew); `new` stays as written.
+ "new Array(3)",
+ "new Array(unknownValue)",
+ "new Array(...unknownValue)",
`[
1,
2,
@@ -1070,7 +1091,7 @@ describe("bundler", () => {
"{}", // new Object() -> {}
"{}", // new Object(null) -> {}
"{ a: 1 }", // new Object({ a: 1 }) -> { a: 1 }
- // kept for the same reason as the Error constructors: the body's source origin comes from the calling frame
+ "new Object(unknownValue)", // nothing to fold into a literal; kept as written
'new Function("return 42")',
'new Function("a", "b", "return a + b")',
'new RegExp("test")',
@@ -1115,8 +1136,8 @@ describe("bundler", () => {
"[,,,,,,,,]", // 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
+ "new Array(11)", // too long to spell out as a literal; kept as written
+ "new Array(4.5)", // not an integer; kept as written
],
minifySyntax: true,
minifyWhitespace: true,
diff --git a/test/bundler/bundler_npm.test.ts b/test/bundler/bundler_npm.test.ts
index 01e8345c1b20..0e13ba67989d 100644
--- a/test/bundler/bundler_npm.test.ts
+++ b/test/bundler/bundler_npm.test.ts
@@ -57,9 +57,9 @@ describe("bundler", () => {
"../entry.tsx",
],
mappings: [
- ["react.development.js:524:'getContextName'", "1:5629:at"],
+ ["react.development.js:524:'getContextName'", "1:5637:at"],
["react.development.js:2495:'actScopeDepth'", "23:4082:or++"],
- ["react.development.js:696:''Component'", '1:7691:\'Component "%s"'],
+ ["react.development.js:696:''Component'", '1:7699:\'Component "%s"'],
["entry.tsx:6:'\"Content-Type\"'", '100:18848:"Content-Type"'],
["entry.tsx:11:''", "100:19102:void"],
["entry.tsx:23:'await'", "100:19201:await"],
@@ -67,7 +67,7 @@ describe("bundler", () => {
},
},
expectExactFilesize: {
- "out/entry.js": 222360,
+ "out/entry.js": 222388,
},
run: {
stdout: "Hello World
This is an example.
",
diff --git a/test/js/bun/test/stack.test.ts b/test/js/bun/test/stack.test.ts
index 079ffff791fb..1aa222b8a08c 100644
--- a/test/js/bun/test/stack.test.ts
+++ b/test/js/bun/test/stack.test.ts
@@ -193,3 +193,18 @@ test("a function returning `new Error()` is in the error's stack", () => {
expect(missingOwnFrame).toEqual([]);
});
+
+test("a function whose returned `new Array()` throws is in the error's stack", () => {
+ function makeArray(...lengths: number[]) {
+ return new Array(...lengths);
+ }
+
+ let thrown: RangeError | undefined;
+ try {
+ makeArray(-1);
+ } catch (e) {
+ thrown = e as RangeError;
+ }
+
+ expect(thrown!.stack).toContain("\n at makeArray (");
+});
From dd76e488b4e801fd15fd33eb46593a0ed5e8ff55 Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Tue, 11 Aug 2026 09:02:38 +0000
Subject: [PATCH 6/7] test: minified new Array(cond ? a : b) keeps its new
---
test/regression/issue/minify-new-array-with-if.test.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/test/regression/issue/minify-new-array-with-if.test.ts b/test/regression/issue/minify-new-array-with-if.test.ts
index 4d55201f1a6d..15d696c2e165 100644
--- a/test/regression/issue/minify-new-array-with-if.test.ts
+++ b/test/regression/issue/minify-new-array-with-if.test.ts
@@ -15,7 +15,7 @@ test("minifying new Array(if (0) 1 else 2) works", async () => {
});
expect(await file(join(testDir, "outdir/entry.js")).text()).toMatchInlineSnapshot(`
- "console.log(Array(Math.random()>-1?1:2));
+ "console.log(new Array(Math.random()>-1?1:2));
"
`);
});
From 5d87dd89675932e3312e1b67d8161a0332ee36eb Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Tue, 11 Aug 2026 10:08:28 +0000
Subject: [PATCH 7/7] transpiler: don't fold new Array(x, ...rest) into a
literal
With a spread in the argument list the runtime argument count is unknown;
when the spread is empty, new Array(5, ...rest) is new Array(5), a length,
while [5, ...rest] is a one element array.
---
src/ast/known_global.rs | 10 ++++++++++
test/bundler/bundler_minify.test.ts | 17 ++++++++++++++---
2 files changed, 24 insertions(+), 3 deletions(-)
diff --git a/src/ast/known_global.rs b/src/ast/known_global.rs
index 423802f9033c..d03e2533aca2 100644
--- a/src/ast/known_global.rs
+++ b/src/ast/known_global.rs
@@ -135,6 +135,16 @@ impl KnownGlobal {
}
KnownGlobal::Array => {
+ // `new Array(5, ...rest)` is `new Array(5)`, a length, when `rest` is empty.
+ if e
+ .args
+ .slice()
+ .iter()
+ .any(|arg| matches!(arg.data, js_ast::ExprData::ESpread(_)))
+ {
+ return None;
+ }
+
let n = e.args.len_u32();
match n {
diff --git a/test/bundler/bundler_minify.test.ts b/test/bundler/bundler_minify.test.ts
index 104b1f6929e3..bc9af725f8cf 100644
--- a/test/bundler/bundler_minify.test.ts
+++ b/test/bundler/bundler_minify.test.ts
@@ -1027,6 +1027,7 @@ describe("bundler", () => {
capture(new Array(3));
capture(new Array(unknownValue));
capture(new Array(...unknownValue));
+ capture(new Array(5, ...unknownValue));
capture(new Array(1, 2, 3));
// Test Array with non-numeric single arguments (should convert to literal)
@@ -1063,11 +1064,13 @@ describe("bundler", () => {
},
capture: [
"[]", // new Array() -> []
- // A single argument may be a length, so these cannot become literals. They are not turned
- // into `Array(...)` calls either (see ErrorConstructorKeepsNew); `new` stays as written.
+ // A single argument (possibly what a spread leaves behind at runtime) may be a length, so these
+ // cannot become literals. They are not turned into `Array(...)` calls either (see
+ // ErrorConstructorKeepsNew); `new` stays as written.
"new Array(3)",
"new Array(unknownValue)",
"new Array(...unknownValue)",
+ "new Array(5, ...unknownValue)",
`[
1,
2,
@@ -1166,6 +1169,12 @@ describe("bundler", () => {
const a3 = new Array(n);
const a4 = Array(n);
capture(a3.length === a4.length && a3.length === 3 && a3[0] === undefined);
+
+ // A spread can leave a single number behind at runtime, and then it is a length
+ const none = [];
+ const a5 = new Array(5, ...none);
+ capture(a5.length === 5);
+ capture(0 in a5 === false);
// Test Object semantics
const o1 = new Object();
@@ -1194,6 +1203,8 @@ describe("bundler", () => {
"0 in sparse === !1",
'JSON.stringify(sparse) === "[null,null,null,null,null]"',
"a3.length === a4.length && a3.length === 3 && a3[0] === void 0",
+ "a5.length === 5",
+ "0 in a5 === !1",
"typeof o1 === typeof o2",
"o1.constructor === o2.constructor",
"typeof f1 === typeof f2",
@@ -1204,7 +1215,7 @@ describe("bundler", () => {
minifySyntax: true,
target: "bun",
run: {
- stdout: "true\ntrue\ntrue\ntrue\ntrue\ntrue\ntrue\ntrue\ntrue\ntrue\ntrue\ntrue",
+ stdout: "true\ntrue\ntrue\ntrue\ntrue\ntrue\ntrue\ntrue\ntrue\ntrue\ntrue\ntrue\ntrue\ntrue",
},
});