From f60bca975a0cb1b42ccff33c3b55a76a946168ef Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 13 Sep 2026 11:48:51 +0000 Subject: [PATCH 1/5] js_parser: substitute undefined for new.target in class field initializers and static blocks A class field initializer and a class static block run as a method call, so new.target is always undefined in them. An arrow function in them takes the same value. JavaScriptCore throws "ReferenceError: Can't find private variable: PrivateSymbol.newTargetLocal" on entry to an arrow function that has no function around it when a class inside it has new.target in a field initializer or a static block. The bundler's __esm wrapper is such an arrow function, so a module that runs unbundled throws once bundled. With a function around the arrow function, a static block throws too. Lowering also moves the initializer of an auto-accessor into the constructor, where new.target is the class. --- src/js_parser/parser.rs | 4 + src/js_parser/visit/mod.rs | 6 +- src/js_parser/visit/visit_expr.rs | 12 ++- test/bundler/bundler_edgecase.test.ts | 26 ++++++ .../transpiler/runtime-transpiler.test.ts | 80 +++++++++++++++++++ test/bundler/transpiler/transpiler.test.js | 45 +++++++++++ 6 files changed, 171 insertions(+), 2 deletions(-) diff --git a/src/js_parser/parser.rs b/src/js_parser/parser.rs index 2614231907cf..de59f9d26ef0 100644 --- a/src/js_parser/parser.rs +++ b/src/js_parser/parser.rs @@ -1312,6 +1312,10 @@ pub struct FnOnlyDataVisit { /// or a class declaration). That means the top-level module scope "this" value /// has been shadowed and is now inaccessible. pub(crate) is_this_nested: bool, + + /// True inside a class field initializer or a class static block. Both run + /// as a method call, so "new.target" is always undefined there. + pub(crate) is_new_target_undefined: bool, } /// Due to ES6 destructuring patterns, there are many cases where it's diff --git a/src/js_parser/visit/mod.rs b/src/js_parser/visit/mod.rs index 182b67f06695..dde379660545 100644 --- a/src/js_parser/visit/mod.rs +++ b/src/js_parser/visit/mod.rs @@ -1062,7 +1062,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O self.fn_or_arrow_data_visit = FnOrArrowDataVisit::default(); self.fn_only_data_visit = FnOnlyDataVisit { is_this_nested: true, - ..Default::default() + is_new_target_undefined: true, }; // PropertyKind::ClassStaticBlock guarantees `Some`; arena-owned for 'a. let csb = property.class_static_block_mut().unwrap(); @@ -1193,6 +1193,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O if let Some(val) = property.initializer { let class_body = self.current_scope; self.field_init_class_bodies.push(class_body); + let old_is_new_target_undefined = + self.fn_only_data_visit.is_new_target_undefined; + self.fn_only_data_visit.is_new_target_undefined = true; if let Some(name) = name_to_keep { let was_anon = val.is_anonymous_named(); let prev_dcn2 = self.decorator_class_name; @@ -1211,6 +1214,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } else { self.visit_expr(property.initializer.as_mut().unwrap()); } + self.fn_only_data_visit.is_new_target_undefined = old_is_new_target_undefined; self.field_init_class_bodies.pop(); } diff --git a/src/js_parser/visit/visit_expr.rs b/src/js_parser/visit/visit_expr.rs index 702d6768eecf..9d630a3e2e36 100644 --- a/src/js_parser/visit/visit_expr.rs +++ b/src/js_parser/visit/visit_expr.rs @@ -87,9 +87,19 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // Private associated fns on this impl so they can see the const-generic // feature params. - fn e_new_target(_: &mut Self, _e: &mut Expr, _: ExprIn) { + fn e_new_target(p: &mut Self, e: &mut Expr, _: ExprIn) { // The "Cannot use \"new.target\" here" range error is intentionally // not emitted: it is not necessary and it was causing breakages. + + // Substitute the value where it is always undefined. Lowering can move + // a field initializer into the constructor, where "new.target" is the + // class. JavaScriptCore also throws a ReferenceError on entry to an + // arrow function with no function around it (the bundler's "__esm" + // wrapper) when a class in it has "new.target" in a field initializer + // or a static block. + if p.fn_only_data_visit.is_new_target_undefined { + *e = p.new_expr(E::Undefined {}, e.loc); + } } fn e_string(_: &mut Self, _e: &mut Expr, _: ExprIn) { diff --git a/test/bundler/bundler_edgecase.test.ts b/test/bundler/bundler_edgecase.test.ts index 0ffec5a9950b..182614048c75 100644 --- a/test/bundler/bundler_edgecase.test.ts +++ b/test/bundler/bundler_edgecase.test.ts @@ -2914,6 +2914,32 @@ describe("bundler", () => { target: "bun", run: { stdout: "before\nEFFECT1\n2\nEFFECT2\n3" }, }); + // The `__esm` wrapper is an arrow function with no function around it. On + // entry to such an arrow function JavaScriptCore throws "ReferenceError: + // Can't find private variable: PrivateSymbol.newTargetLocal" when a class in + // it has `new.target` in a field initializer or a static block. The value + // there is always undefined, and the unbundled module runs. + itBundled("edgecase/EsmWrapNewTargetInClassFieldInitializer", { + files: { + "/lazy.js": ` + export class A { + x = typeof new.target; + static y = typeof new.target; + static { A.z = typeof new.target; } + } + `, + "/entry.js": ` + const { A } = await import("./lazy.js"); + console.log(new A().x, A.y, A.z); + `, + }, + entryPoints: ["/entry.js"], + target: "bun", + run: { stdout: "undefined undefined undefined" }, + onAfterBundle(api) { + expect(api.readFile("/out.js")).toContain("__esm"); + }, + }); // https://github.com/oven-sh/bun/issues/30269 // Same bug for a nested `let` binding instead of a function parameter. itBundled("identifiers/NestedLocalDoesNotShadowLaterHoistedFunction", { diff --git a/test/bundler/transpiler/runtime-transpiler.test.ts b/test/bundler/transpiler/runtime-transpiler.test.ts index 5c05e408c92a..97b5b15de17b 100644 --- a/test/bundler/transpiler/runtime-transpiler.test.ts +++ b/test/bundler/transpiler/runtime-transpiler.test.ts @@ -210,6 +210,86 @@ test("math.pow", () => { expect(20.4 ** -0.5 + "").toEqual("0.22140372138502384"); }); +// A class field initializer and a class static block run as a method call, so +// `new.target` is undefined in them. JavaScriptCore throws +// "ReferenceError: Can't find private variable: PrivateSymbol.newTargetLocal" +// on entry to an arrow function that contains such a class and has no function +// around it. +test("new.target in a class field initializer or a class static block is undefined", async () => { + using dir = tempDir("transpiler-new-target-class-field", { + "index.mjs": ` + const results = {}; + async function t(name, f) { + try { + results[name] = await f(); + } catch (e) { + results[name] = "throws " + e; + } + } + + // The class is inside an arrow function with no function around it. + await t("instance field", () => { class A { x = typeof new.target; } return new A().x; }); + await t("static field", () => { class A { static x = typeof new.target; } return A.x; }); + await t("static block", () => { let r; class A { static { r = typeof new.target; } } return r; }); + await t("private field", () => { class A { #x = typeof new.target; get x() { return this.#x; } } return new A().x; }); + await t("class expression", () => new (class { x = typeof new.target; })().x); + await t("derived class", () => { class B {} class A extends B { x = typeof new.target; } return new A().x; }); + await t("arrow in arrow", () => (() => { class A { x = typeof new.target; } return new A().x; })()); + await t("arrow in field", () => { class A { x = (() => typeof new.target)(); } return new A().x; }); + await t("key of a class in a field", () => { class A { x = class { static [typeof new.target] = 1; }; } return Object.keys(new A().x)[0]; }); + await t("async arrow", async () => { class A { x = typeof new.target; } return new A().x; }); + + // A static block in an arrow function throws with a function around the arrow function too. + await t("static block, arrow in function", function () { return (() => { let r; class A { static { r = typeof new.target; } } return r; })(); }); + + // The transpiler moves the initializer of an auto-accessor into the constructor. + await t("auto-accessor", function () { class A { accessor x = typeof new.target; } return new A().x; }); + + // These see the new.target of a function, and keep it. + await t("function in field", () => { class A { f = function () { return new.target; }; } const f = new A().f; return new f() === f; }); + await t("function in static block", () => { let f; class A { static { f = function () { return new.target; }; } } return new f() === f; }); + await t("constructor", () => { class A { constructor() { this.x = new.target; } } return new A().x === A; }); + await t("field key", () => { let k; function F() { class A { [new.target.name] = 1; } k = Object.keys(new A())[0]; } new F(); return k; }); + await t("extends clause", () => { let ok; function F() { class A extends new.target.Base {} ok = new A() instanceof F.Base; } F.Base = class {}; new F(); return ok; }); + + console.log(JSON.stringify(results)); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.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]); + + // Debug builds print an ASAN warning here. + expect(stderr.split("\n").filter(line => line && !line.startsWith("WARNING: ASAN"))).toEqual([]); + expect(JSON.parse(stdout)).toEqual({ + "instance field": "undefined", + "static field": "undefined", + "static block": "undefined", + "private field": "undefined", + "class expression": "undefined", + "derived class": "undefined", + "arrow in arrow": "undefined", + "arrow in field": "undefined", + "key of a class in a field": "undefined", + "async arrow": "undefined", + "static block, arrow in function": "undefined", + "auto-accessor": "undefined", + "function in field": true, + "function in static block": true, + "constructor": true, + "field key": "F", + "extends clause": true, + }); + expect(exitCode).toBe(0); +}); + describe("unterminated string literals in large files", () => { test("reports an unterminated string literal at the end of a large JavaScript file", async () => { using dir = tempDir("transpiler-long-unterminated-js", { diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 78baaf353d75..9bde2a14d39b 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -3817,6 +3817,51 @@ class Foo { ); }); + it("new.target in a class field initializer or a class static block is undefined", () => { + // Both run as a method call. An arrow function takes the value from them. + expectPrinted_("class Foo { x = new.target }", "class Foo {\n x = undefined;\n}"); + expectPrinted_("class Foo { static x = new.target }", "class Foo {\n static x = undefined;\n}"); + expectPrinted_("class Foo { #x = new.target }", "class Foo {\n #x = undefined;\n}"); + expectPrinted_("class Foo { static { foo(new.target) } }", "class Foo {\n static {\n foo(undefined);\n }\n}"); + expectPrinted_("class Foo { x = () => foo(new.target) }", "class Foo {\n x = () => foo(undefined);\n}"); + expectPrinted_( + "class Foo { static { (() => foo(new.target))() } }", + "class Foo {\n static {\n (() => foo(undefined))();\n }\n}", + ); + // The key of a class in a field initializer is part of the field initializer. + expectPrinted_( + "class Foo { x = class { [new.target] = new.target } }", + "class Foo {\n x = class {\n [undefined] = undefined;\n };\n}", + ); + + // A function has its own new.target. + expectPrinted_( + "class Foo { x = function() { return new.target } }", + "class Foo {\n x = function() {\n return new.target;\n };\n}", + ); + expectPrinted_( + "class Foo { x = { m() { return new.target } } }", + "class Foo {\n x = { m() {\n return new.target;\n } };\n}", + ); + expectPrinted_( + "class Foo { static { function f() { return new.target } } }", + "class Foo {\n static {\n function f() {\n return new.target;\n }\n }\n}", + ); + expectPrinted_( + "class Foo { constructor() { foo(new.target) } m() { foo(new.target) } }", + "class Foo {\n constructor() {\n foo(new.target);\n }\n m() {\n foo(new.target);\n }\n}", + ); + // A key and an extends clause belong to the code around the class. + expectPrinted_( + "function f() { class Foo extends new.target { [new.target] = 1; static [new.target] = 2; [new.target]() {} } }", + "function f() {\n class Foo extends new.target {\n [new.target] = 1;\n static [new.target] = 2;\n [new.target]() {}\n }\n}", + ); + expectPrinted_( + "function f() { class Foo { x = function() { class Bar { [new.target] = new.target } } } }", + "function f() {\n class Foo {\n x = function() {\n class Bar {\n [new.target] = undefined;\n }\n };\n }\n}", + ); + }); + it("declarations named eval or arguments, and reserved words, in strict mode", () => { expectParseError( '"use strict"; var arguments = 1', From 5b0dc0f372d2b9ec49994c0ba249dad33e2e2532 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 13 Sep 2026 18:42:05 +0000 Subject: [PATCH 2/5] Bump the transpiler cache version and link the engine fix A cached file that was transpiled before this change still has new.target in a class field initializer or a class static block. --- src/js_parser/visit/visit_expr.rs | 2 +- src/jsc/RuntimeTranspilerCache.rs | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/js_parser/visit/visit_expr.rs b/src/js_parser/visit/visit_expr.rs index 9d630a3e2e36..bb2b48bd1e09 100644 --- a/src/js_parser/visit/visit_expr.rs +++ b/src/js_parser/visit/visit_expr.rs @@ -96,7 +96,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // class. JavaScriptCore also throws a ReferenceError on entry to an // arrow function with no function around it (the bundler's "__esm" // wrapper) when a class in it has "new.target" in a field initializer - // or a static block. + // or a static block: https://github.com/oven-sh/WebKit/pull/647 if p.fn_only_data_visit.is_new_target_undefined { *e = p.new_expr(E::Undefined {}, e.loc); } diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index b56455b6e00f..b9af7376d175 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -60,7 +60,8 @@ bun_core::declare_scope!(cache, visible); /// Version 30: String enum members are stored flat, so folds no longer append onto an inlined member. /// Version 31: Standard decorator lowering temporaries have a per-file counter in their name (`_init$1`). /// Version 32: Standard decorator lowering keeps class members in place. -const EXPECTED_VERSION: u32 = 32; +/// Version 33: `new.target` in a class field initializer or a class static block is printed as `undefined`. +const EXPECTED_VERSION: u32 = 33; /// 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 From 6ea45f47eedefa6cb01f4208f41212455aa0f99e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:04:06 +0000 Subject: [PATCH 3/5] Test new.target in a field initializer that TypeScript lowering moves into the constructor --- .../ts-use-define-for-class-fields.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/bundler/transpiler/ts-use-define-for-class-fields.test.ts b/test/bundler/transpiler/ts-use-define-for-class-fields.test.ts index 076d09d338d8..50e31ee81f70 100644 --- a/test/bundler/transpiler/ts-use-define-for-class-fields.test.ts +++ b/test/bundler/transpiler/ts-use-define-for-class-fields.test.ts @@ -97,6 +97,22 @@ describe("tsconfig compilerOptions.useDefineForClassFields", () => { expect(exitCode).toBe(0); }); + // `new.target` is the class in the constructor and undefined in a field initializer. + test.concurrent("false: new.target in a field initializer stays undefined", async () => { + using dir = tempDir("udfcf-new-target", { + "tsconfig.json": JSON.stringify({ compilerOptions: { useDefineForClassFields: false } }), + "index.ts": ` + class C { x = typeof new.target; y = () => typeof new.target; constructor(public z = typeof new.target) {} } + const c = new C(); + process.stdout.write(JSON.stringify({ x: c.x, y: c.y(), z: c.z })); + `, + }); + const { stdout, stderr, exitCode } = await run(String(dir)); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ x: "undefined", y: "undefined", z: "function" }); + expect(exitCode).toBe(0); + }); + test.concurrent("false: computed literal keys use [[Set]] semantics", async () => { using dir = tempDir("udfcf-computed", { "tsconfig.json": JSON.stringify({ compilerOptions: { useDefineForClassFields: false } }), From 09ef9e980c30d01242e1d696430c6db44d5d7c37 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:16:59 +0000 Subject: [PATCH 4/5] ci: retrigger From 5a92b150bacd989195e80bc1ad7455a87f4fb9bc Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:20:46 +0000 Subject: [PATCH 5/5] Shorten the two new comments to one line each --- src/js_parser/parser.rs | 3 +-- src/js_parser/visit/visit_expr.rs | 7 +------ 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/src/js_parser/parser.rs b/src/js_parser/parser.rs index de59f9d26ef0..6ac19f1b0ac3 100644 --- a/src/js_parser/parser.rs +++ b/src/js_parser/parser.rs @@ -1313,8 +1313,7 @@ pub struct FnOnlyDataVisit { /// has been shadowed and is now inaccessible. pub(crate) is_this_nested: bool, - /// True inside a class field initializer or a class static block. Both run - /// as a method call, so "new.target" is always undefined there. + /// True in a class field initializer or a class static block, which run as a method call. pub(crate) is_new_target_undefined: bool, } diff --git a/src/js_parser/visit/visit_expr.rs b/src/js_parser/visit/visit_expr.rs index bb2b48bd1e09..8ac351014e71 100644 --- a/src/js_parser/visit/visit_expr.rs +++ b/src/js_parser/visit/visit_expr.rs @@ -91,12 +91,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // The "Cannot use \"new.target\" here" range error is intentionally // not emitted: it is not necessary and it was causing breakages. - // Substitute the value where it is always undefined. Lowering can move - // a field initializer into the constructor, where "new.target" is the - // class. JavaScriptCore also throws a ReferenceError on entry to an - // arrow function with no function around it (the bundler's "__esm" - // wrapper) when a class in it has "new.target" in a field initializer - // or a static block: https://github.com/oven-sh/WebKit/pull/647 + // JavaScriptCore can throw for it here: https://github.com/oven-sh/WebKit/pull/647 if p.fn_only_data_visit.is_new_target_undefined { *e = p.new_expr(E::Undefined {}, e.loc); }