From aaa3425ea56176238de696449e9125428ca8b825 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 13 Sep 2026 23:44:23 +0000 Subject: [PATCH 1/4] js_parser: run lowered decorator effects after a `super()` that is not a statement The standard decorator lowering puts what follows the last instance field (accessor storage, extra initializers, brands) in the constructor, after a top-level `super();` statement. A derived constructor with no such statement (`const made = super();`, `if (x) super(); else super();`, an arrow that calls it, or a constructor that returns an object) got the code at the top, before `this` exists, and threw a ReferenceError on every construction. For that shape the lowering now adds one `#private` field after the last member and puts the code in its initializer. A field runs where `super()` returns. The name is one that no enclosing class declares, and it is registered in the class body scope so the minifier renames it. --- src/js_parser/lower/lower_decorators.rs | 92 ++++++++++++++++--- src/js_parser/p.rs | 4 + src/js_parser/visit/mod.rs | 2 + src/jsc/RuntimeTranspilerCache.rs | 3 +- test/bundler/transpiler/es-decorators.test.ts | 83 +++++++++++++++++ 5 files changed, 170 insertions(+), 14 deletions(-) diff --git a/src/js_parser/lower/lower_decorators.rs b/src/js_parser/lower/lower_decorators.rs index c285fbfb3a8e..41686609ee2d 100644 --- a/src/js_parser/lower/lower_decorators.rs +++ b/src/js_parser/lower/lower_decorators.rs @@ -101,27 +101,34 @@ fn initializer_group(prop: &Property) -> Option { } } -/// Puts `inserted` in `constructor` right after a top-level `super()` -/// statement returns, which is after the last instance field. -fn insert_after_super<'a>( +/// Puts `inserted` in `constructor` where the last instance field is defined: +/// at the top of a base class, after the `super()` statement of a derived one. +/// Returns false when a derived constructor has no such statement. +fn insert_after_fields<'a>( constructor: &mut Property, + is_derived: bool, inserted: &[Stmt], arena: &'a bun_alloc::Arena, -) { +) -> bool { let func = match &mut constructor.value.as_mut().unwrap().data { js_ast::ExprData::EFunction(f) => &mut **f, _ => unreachable!(), }; let body: &[Stmt] = func.func.body.stmts.slice(); - let after_super = body - .iter() - .position(|stmt| stmt.is_super_call()) - .map_or(0, |i| i + 1); + let after_fields = if is_derived { + match body.iter().position(|stmt| stmt.is_super_call()) { + Some(super_call) => super_call + 1, + None => return false, + } + } else { + 0 + }; let mut stmts = BumpVec::<'a, Stmt>::with_capacity_in(body.len() + inserted.len(), arena); - stmts.extend_from_slice(&body[..after_super]); + stmts.extend_from_slice(&body[..after_fields]); stmts.extend_from_slice(inserted); - stmts.extend_from_slice(&body[after_super..]); + stmts.extend_from_slice(&body[after_fields..]); func.func.body.stmts = bun_ast::StoreSlice::from_bump(stmts); + true } // ── impl P ─────────────────────────────────────────────────────────────────── @@ -211,6 +218,42 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O b"_accessor_storage" } + /// A `#private` name for `class_body` that no class around it declares. + fn new_private_name( + &mut self, + mut class_body: js_ast::StoreRef, + base: &'a [u8], + kind: js_ast::symbol::Kind, + ) -> Ref { + let mut name = base; + let mut count = 1u32; + 'unique: loop { + let mut scope = Some(class_body); + while let Some(current) = scope { + let declares = current.kind == js_ast::scope::Kind::ClassBody + && (current.members.contains_key(name) + || current.generated.slice().iter().any(|generated| { + self.symbols[generated.inner_index() as usize] + .original_name + .slice() + == name + })); + if declares { + count += 1; + name = bun_alloc::arena_format!(in self.arena, "{}{}", bstr::BStr::new(base), count) + .into_bump_str() + .as_bytes(); + continue 'unique; + } + scope = current.parent; + } + break; + } + let ref_ = self.new_symbol(kind, name); + VecExt::append(&mut class_body.generated, ref_); + ref_ + } + // ── Private access rewriting ───────────────────────── fn private_get_expr(&mut self, obj: Expr, info: &PrivateLoweredInfo, l: bun_ast::Loc) -> Expr { @@ -776,7 +819,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O /// native behavior; what the runtime helpers need is added around them. /// Decorator lists are evaluated in the key of their member and applied by /// a leading `static {}`. What has to run between two instance fields rides - /// in the initializer of the next one, or the constructor after the last. + /// in the initializer of the next one. After the last one it goes in the + /// constructor, or in one more `#private` field where a derived + /// constructor has no `super()` statement to put it after. /// A `#private` name with a decorated member becomes a WeakMap or WeakSet, /// which is how the helpers reach it; its methods become function expressions. #[allow(clippy::too_many_lines)] @@ -789,6 +834,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let p = self; let bump = p.arena; let temps_before = p.temp_refs_to_declare.len(); + let class_body = p + .visited_class_body + .take() + .expect("visit_class ran for this class"); let class_decorators: ExprNodeList = bun_alloc::AstAlloc::take(&mut class.ts_decorators); let has_class_decorators = class_decorators.len_u32() > 0; @@ -1247,14 +1296,31 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let mut new_constructor = None; if !constructor_effects.is_empty() { + let is_derived = class.extends.is_some(); match constructor { Some(constructor) => { let stmts = p.effect_stmts(&constructor_effects, loc); - insert_after_super(&mut members[constructor], &stmts, p.arena); + if !insert_after_fields(&mut members[constructor], is_derived, &stmts, p.arena) + { + // `super()` is inside an expression or a block, or missing: + // one more field runs where it returns. + let host = p.new_private_name( + class_body, + b"#_", + js_ast::symbol::Kind::PrivateField, + ); + p.record_usage(host); + constructor_effects.push(p.new_expr(E::Undefined {}, loc)); + members.push(Property { + key: Some(p.new_expr(E::PrivateIdentifier { ref_: host }, loc)), + initializer: Some(Expr::join_all_with_comma(&constructor_effects)), + ..Default::default() + }); + } } None => { new_constructor = - Some(p.new_constructor(class.extends.is_some(), &constructor_effects, loc)); + Some(p.new_constructor(is_derived, &constructor_effects, loc)); } } } diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 205bc7b0ca69..d1a12e3bfa28 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -711,6 +711,9 @@ pub struct P<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> { /// Name from assignment context for anonymous decorated class expressions. /// Set before visitExpr, consumed by lowerStandardDecoratorsImpl. pub(crate) decorator_class_name: Option<&'a [u8]>, + /// The body scope of the class `visit_class` just returned from. Its + /// lowering declares the `#private` names it adds to the class there. + pub(crate) visited_class_body: Option>, } // `binding::ToExprWrapper` type-erases `*P` (which is generic over @@ -9893,6 +9896,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O will_wrap_module_in_try_catch_for_using: false, nearest_stmt_list: None, decorator_class_name: None, + visited_class_body: None, jsx_transform, diff --git a/src/js_parser/visit/mod.rs b/src/js_parser/visit/mod.rs index 182b67f06695..30da8d875898 100644 --- a/src/js_parser/visit/mod.rs +++ b/src/js_parser/visit/mod.rs @@ -1457,6 +1457,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } + self.visited_class_body = Some(self.current_scope); + // manual restore for the block-level `defer` self.pop_scope(); self.enclosing_class_keyword = old_enclosing_class_keyword; diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index b56455b6e00f..54643e63c55c 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: Standard decorator lowering does not write before a `super()` that is not a statement. +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 diff --git a/test/bundler/transpiler/es-decorators.test.ts b/test/bundler/transpiler/es-decorators.test.ts index f597e47a1334..2b0b74246aac 100644 --- a/test/bundler/transpiler/es-decorators.test.ts +++ b/test/bundler/transpiler/es-decorators.test.ts @@ -1393,6 +1393,41 @@ describe("ES Decorators", () => { expect(exitCode).toBe(0); }); + test.concurrent("the field added after the last one gets a #private name of its own", async () => { + // A derived constructor without a `super();` statement: what follows the + // last field rides in one more `#private` field. The class, and a class + // around it that the body refers to, already use the names it would take. + using dir = tempDir("es-dec-added-private-name", { + "entry.js": ` + const dec = (v, ctx) => { ctx.addInitializer(function () { this.ran = true; }); }; + class Base {} + class Outer { + #_ = "outer"; + static make() { + return class extends Base { + #_2 = "own"; + @dec x = "x"; + constructor() { const made = super(); } + read(o) { return [o.#_, this.#_2, this.x, this.ran]; } + }; + } + } + console.log(JSON.stringify(new (Outer.make())().read(new Outer()))); + `, + }); + const expected = '["outer","own","x",true]\n'; + const ran = await runIn(String(dir), ["entry.js"]); + expect(filterStderr(ran.stderr)).toBe(""); + expect(ran.stdout).toBe(expected); + expect(ran.exitCode).toBe(0); + const build = await runIn(String(dir), ["build", "--target=bun", "--minify", "entry.js", "--outfile=out.js"]); + expect({ stderr: filterStderr(build.stderr), exitCode: build.exitCode }).toEqual({ stderr: "", exitCode: 0 }); + const minified = await runIn(String(dir), ["out.js"]); + expect(filterStderr(minified.stderr)).toBe(""); + expect(minified.stdout).toBe(expected); + expect(minified.exitCode).toBe(0); + }); + test.concurrent("super resolves from the class as written when a class decorator replaces it", async () => { const { stdout, stderr, exitCode } = await runDecorator(` const dec = (v, ctx) => {}; @@ -2391,6 +2426,30 @@ const extraSections = ` const desc = Object.getOwnPropertyDescriptor(N.prototype, "k"); out.accessorStorage = [nn.a, N.a, nn.priv(), nn.k, mm.k, n, typeof desc.get, typeof desc.set, Outer.make().a]; } + +// What follows the last instance field runs when \`super()\` returns, +// wherever that call is written, and not at all when it is never called. +{ + const seen = []; + const see = (tag) => (seen.push(tag), tag); + const extra = (value, ctx) => { ctx.addInitializer(function () { seen.push("extra " + String(ctx.name) + ":" + (this instanceof Base3)); }); }; + class Base3 {} + const classes = [ + class extends Base3 { @extra accessor p = see("accessor"); constructor() { const made = super(); seen.push("body"); } }, + class extends Base3 { accessor p = see("plain accessor"); constructor(x) { if (x) { super(); } else { super(); } seen.push("body"); } }, + class extends Base3 { @extra #p = see("#field"); constructor() { (() => super())(); seen.push("body"); } }, + class extends Base3 { @extra accessor #p = see("#accessor"); constructor() { void super(); seen.push("body"); } }, + class extends Base3 { @extra p = see("field"); constructor() { const made = super(); seen.push("body"); } }, + class extends Base3 { @extra m() {} constructor() { const made = super(); seen.push("body"); } }, + class extends Base3 { @extra #m() {} constructor() { const made = super(); seen.push("body"); } }, + class extends Base3 { @extra p = see("never"); constructor() { return { made: true }; } }, + class extends Base3 { #_ = see("own #_"); @extra p = see("next to #_"); constructor() { const made = super(); seen.push(this.#_); } }, + ]; + for (const C of classes) { + try { new C(1); } catch (e) { seen.push(e.constructor.name); } + } + out.superNotAStatement = seen; +} `; const extraExpected = { @@ -2434,6 +2493,30 @@ const extraExpected = { accessorKeys: [11, 2, 3, 4], issue31921: ["a", 1, "a", "b", [5, "a", "block", "b"]], accessorStorage: [12, 13, 1, 14, 15, 2, "function", "function", 6], + superNotAStatement: [ + "accessor", + "extra p:true", + "body", + "plain accessor", + "body", + "#field", + "extra #p:true", + "body", + "#accessor", + "extra #p:true", + "body", + "field", + "extra p:true", + "body", + "extra m:true", + "body", + "extra #m:true", + "body", + "own #_", + "next to #_", + "extra p:true", + "own #_", + ], }; function buildFixture() { From a7f44b27db23ce992c6535d038d4b9f5ede0eca7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 14 Sep 2026 00:26:18 +0000 Subject: [PATCH 2/4] ci: retrigger From 74b3c1a7adaec6574238662fc9c1927f74dc11ad Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 14 Sep 2026 00:29:41 +0000 Subject: [PATCH 3/4] js_parser: shorten the comments on the new lowering code --- src/js_parser/lower/lower_decorators.rs | 12 ++++-------- src/js_parser/p.rs | 3 +-- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/src/js_parser/lower/lower_decorators.rs b/src/js_parser/lower/lower_decorators.rs index 41686609ee2d..3d90a00eb103 100644 --- a/src/js_parser/lower/lower_decorators.rs +++ b/src/js_parser/lower/lower_decorators.rs @@ -101,9 +101,8 @@ fn initializer_group(prop: &Property) -> Option { } } -/// Puts `inserted` in `constructor` where the last instance field is defined: -/// at the top of a base class, after the `super()` statement of a derived one. -/// Returns false when a derived constructor has no such statement. +/// Puts `inserted` after the last instance field: at the top of a base +/// constructor, after the `super();` statement of a derived one, if it has one. fn insert_after_fields<'a>( constructor: &mut Property, is_derived: bool, @@ -819,9 +818,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O /// native behavior; what the runtime helpers need is added around them. /// Decorator lists are evaluated in the key of their member and applied by /// a leading `static {}`. What has to run between two instance fields rides - /// in the initializer of the next one. After the last one it goes in the - /// constructor, or in one more `#private` field where a derived - /// constructor has no `super()` statement to put it after. + /// in the initializer of the next one, or the constructor after the last. /// A `#private` name with a decorated member becomes a WeakMap or WeakSet, /// which is how the helpers reach it; its methods become function expressions. #[allow(clippy::too_many_lines)] @@ -1302,8 +1299,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let stmts = p.effect_stmts(&constructor_effects, loc); if !insert_after_fields(&mut members[constructor], is_derived, &stmts, p.arena) { - // `super()` is inside an expression or a block, or missing: - // one more field runs where it returns. + // No `super();` statement: a last field runs where `super()` returns. let host = p.new_private_name( class_body, b"#_", diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index d1a12e3bfa28..f06f2733535c 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -711,8 +711,7 @@ pub struct P<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> { /// Name from assignment context for anonymous decorated class expressions. /// Set before visitExpr, consumed by lowerStandardDecoratorsImpl. pub(crate) decorator_class_name: Option<&'a [u8]>, - /// The body scope of the class `visit_class` just returned from. Its - /// lowering declares the `#private` names it adds to the class there. + /// Set by `visit_class`, consumed by `lower_class_body` to declare `#private` names. pub(crate) visited_class_body: Option>, } From b90a7e0fc90748ae9d4ebcd37327d8b38ecb49c1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 14 Sep 2026 00:31:14 +0000 Subject: [PATCH 4/4] js_parser: one-line doc for insert_after_fields --- src/js_parser/lower/lower_decorators.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/js_parser/lower/lower_decorators.rs b/src/js_parser/lower/lower_decorators.rs index 3d90a00eb103..1282b61500a1 100644 --- a/src/js_parser/lower/lower_decorators.rs +++ b/src/js_parser/lower/lower_decorators.rs @@ -101,8 +101,7 @@ fn initializer_group(prop: &Property) -> Option { } } -/// Puts `inserted` after the last instance field: at the top of a base -/// constructor, after the `super();` statement of a derived one, if it has one. +/// Inserts after the last instance field, or returns false: derived with no `super();` statement. fn insert_after_fields<'a>( constructor: &mut Property, is_derived: bool,