From d9b2d49cf39201bb08b297d1750b2e86429ffddf Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 13 Sep 2026 10:38:57 +0000 Subject: [PATCH 1/4] js_parser: keep the names a class has without decorators through standard decorator lowering A decorated field or an accessor passes its initializer to a runtime helper as a call argument, where an anonymous function or class is no longer named by the field. It is now wrapped as `{ key: value }[key]`, like the initializer of a field that carries the effects of its neighbors. The class a decorated class extends was kept in a `_base` temporary for `__decoratorStart`, so an anonymous class or function in the extends clause was named after the temporary. `__decoratorStart` now reads the prototype of the class itself and the extends clause stays as written. `__decorateElement` set the name of a class with class decorators after its static methods and accessors were defined, which replaced a static member called `name`. It now only replaces the string every class starts with. scripts/build/codegen.ts: runtime.out.js is bundled from runtime.bun.js, which re-exports runtime.js. Only the former was an input of the build edge, so an edit to runtime.js did not rebuild it. --- scripts/build/codegen.ts | 3 +- src/js_parser/lower/lower_decorators.rs | 77 +++++++----- src/jsc/RuntimeTranspilerCache.rs | 3 +- src/runtime.js | 12 +- test/bundler/transpiler/es-decorators.test.ts | 118 ++++++++++++++++++ 5 files changed, 181 insertions(+), 32 deletions(-) diff --git a/scripts/build/codegen.ts b/scripts/build/codegen.ts index 36566f1e9ca8..04cb1874735d 100644 --- a/scripts/build/codegen.ts +++ b/scripts/build/codegen.ts @@ -497,7 +497,8 @@ function emitRuntimeJs({ n, cfg, o, dirStamp }: Ctx): void { n.build({ outputs: [out], rule: "esbuild", - inputs: [src], + // runtime.bun.js re-exports runtime.js, which holds most of the helpers. + inputs: [src, resolve(cfg.cwd, "src", "runtime.js")], implicitInputs: [o.rootInstall], orderOnlyInputs: [dirStamp], vars: { diff --git a/src/js_parser/lower/lower_decorators.rs b/src/js_parser/lower/lower_decorators.rs index c285fbfb3a8e..a75145de80fe 100644 --- a/src/js_parser/lower/lower_decorators.rs +++ b/src/js_parser/lower/lower_decorators.rs @@ -821,12 +821,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } else { Ref::NONE }; - let mut base_ref: Option = None; - if has_decorators && let Some(extends) = class.extends { - let base = p.declared_temp(b"_base"); - class.extends = Some(p.assign_to(base, extends, extends.loc)); - base_ref = Some(base); - } let mut members = BumpVec::::with_capacity_in(class.properties.len() + 4, bump); let mut key_effects = BumpVec::::new_in(bump); @@ -977,8 +971,13 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O next_initializer[group] += 1; } private_lowered_map.insert(private_index, info); - let effects = - p.storage_init_effects(storage, prop.initializer, initializer_index, loc); + let effects = p.storage_init_effects( + storage, + prop.initializer, + FieldName::Key(name_expr), + initializer_index, + loc, + ); if is_static { members.push(p.make_static_block(&effects, loc)); } else { @@ -1039,8 +1038,18 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O initializer_index = Some((init_ref, next_initializer[group])); next_initializer[group] += 1; } - let effects = - p.storage_init_effects(storage, prop.initializer, initializer_index, loc); + let field_name = if is_constant_key(&name_expr) { + FieldName::Key(name_expr) + } else { + FieldName::Computed(name_expr) + }; + let effects = p.storage_init_effects( + storage, + prop.initializer, + field_name, + initializer_index, + loc, + ); if is_static { members.push(p.make_static_block(&effects, loc)); } else { @@ -1058,10 +1067,12 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let name: &'a [u8] = p.symbols[private_index as usize].original_name.slice(); field_name = FieldName::Key(p.new_expr(E::EString::init(name), loc)); } else { - // Only a field that may carry effects has to name its function itself. + // Only a field that is decorated or may carry effects has to + // name its function itself. let names_function = !is_method - && !is_static - && (!instance_effects.is_empty() || first_instance_host.is_none()) + && (dec_ref.is_some() + || !is_static + && (!instance_effects.is_empty() || first_instance_host.is_none())) && prop .initializer .is_some_and(|value| value.is_anonymous_named()); @@ -1089,6 +1100,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O init_ref, next_initializer[group], prop.initializer, + field_name, loc, ); next_initializer[group] += 1; @@ -1166,11 +1178,15 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O leading.extend_from_slice(&static_brands); let mut class_decorators_result: Option = None; if has_decorators { - let base = match base_ref { - Some(base) => p.use_ref(base, loc), - None => p.new_expr(E::Undefined {}, loc), + // The metadata of a derived class inherits from that of the class it + // extends, which `__decoratorStart` reads off the prototype of `this`. + let undefined = p.new_expr(E::Undefined {}, loc); + let start = if class.extends.is_some() { + let this = p.new_expr(E::This {}, loc); + p.call_rt(loc, b"__decoratorStart", &[undefined, this]) + } else { + p.call_rt(loc, b"__decoratorStart", &[undefined]) }; - let start = p.call_rt(loc, b"__decoratorStart", &[base]); leading.push(p.assign_to(init_ref, start, loc)); let has_static_method_decorators = !decorate[0].is_empty(); for group in &decorate { @@ -1394,17 +1410,22 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O prop.flags.insert(Flags::Property::IsComputed); } - /// The initializer of a field that is about to go behind a comma. There an - /// anonymous function or class is not named by the field any more. + /// The initializer of a field that is about to go behind a comma. fn hosted_initializer( &mut self, initializer: Option, field_name: FieldName, loc: bun_ast::Loc, ) -> Expr { - let Some(value) = initializer else { - return self.new_expr(E::Undefined {}, loc); - }; + match initializer { + Some(value) => self.named_by_field(value, field_name), + None => self.new_expr(E::Undefined {}, loc), + } + } + + /// `value` for a place where the field no longer names it: behind a comma + /// or as a call argument, an anonymous function or class stays anonymous. + fn named_by_field(&mut self, value: Expr, field_name: FieldName) -> Expr { if !value.is_anonymous_named() { return value; } @@ -1457,13 +1478,14 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O init_ref: Ref, index: usize, initializer: Option, + field_name: FieldName, loc: bun_ast::Loc, ) -> DecoratedInitializer { let mut args = BumpVec::::with_capacity_in(4, self.arena); args.push(self.use_ref(init_ref, loc)); args.push(self.new_expr(E::Number::new(((4 + 2 * index) << 1) as f64), loc)); args.push(self.new_expr(E::This {}, loc)); - args.extend(initializer); + args.extend(initializer.map(|value| self.named_by_field(value, field_name))); let value = self.call_runtime(loc, b"__runInitializers", ExprNodeList::from_bump_vec(args)); let this = self.new_expr(E::This {}, loc); let extra = self.run_initializers(init_ref, (((5 + 2 * index) << 1) | 1) as f64, this, loc); @@ -1476,19 +1498,18 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O &mut self, storage: Ref, initializer: Option, + field_name: FieldName, decorated: Option<(Ref, usize)>, loc: bun_ast::Loc, ) -> BumpVec<'a, Expr> { let mut effects = BumpVec::::with_capacity_in(2, self.arena); let (value, extra) = match decorated { Some((init_ref, index)) => { - let decorated = self.decorated_initializer(init_ref, index, initializer, loc); + let decorated = + self.decorated_initializer(init_ref, index, initializer, field_name, loc); (decorated.value, Some(decorated.extra)) } - None => ( - initializer.unwrap_or_else(|| self.new_expr(E::Undefined {}, loc)), - None, - ), + None => (self.hosted_initializer(initializer, field_name, loc), None), }; let this = self.new_expr(E::This {}, loc); let storage = self.use_ref(storage, loc); diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index b56455b6e00f..2960a07b77a9 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 names the function a decorated field or accessor is initialized with, and no longer names an anonymous base class. +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/src/runtime.js b/src/runtime.js index 5260178f1723..8270685ce621 100644 --- a/src/runtime.js +++ b/src/runtime.js @@ -240,7 +240,12 @@ export var __privateSet = (obj, member, value, setter) => ( ); export var __privateMethod = (obj, member, method) => (__accessCheck(obj, member, "access private method"), method); -export var __decoratorStart = base => [, , , __create(base?.[__knownSymbol("metadata")] ?? null)]; +// `target` is a derived class: its metadata inherits from that of its prototype, +// the class it extends. Output of older versions passes that class as `base`. +export var __decoratorStart = (base, target) => ( + target && (base = __getProtoOf(target)), + [, , , __create(base?.[__knownSymbol("metadata")] ?? null)] +); var __decoratorStrings = ["class", "method", "getter", "setter", "accessor", "field", "value", "get", "set"]; var __expectFn = fn => (fn !== void 0 && typeof fn !== "function" ? __typeError("Function expected") : fn); var __decoratorContext = (kind, name, done, metadata, fns) => ({ @@ -286,7 +291,10 @@ export var __decorateElement = (array, flags, name, decorators, target, extra) = }, name, )); - k ? p && k < 4 && __name(extra, (k > 2 ? "set " : k > 1 ? "get " : "") + name) : __name(target, name); + // A static member called `name` has already replaced the string every class starts with. + k + ? p && k < 4 && __name(extra, (k > 2 ? "set " : k > 1 ? "get " : "") + name) + : typeof __getOwnPropDesc(target, "name")?.value == "string" && __name(target, name); for (var i = decorators.length - 1; i >= 0; i--) { ctx = __decoratorContext(k, name, (done = {}), array[3], extraInitializers); diff --git a/test/bundler/transpiler/es-decorators.test.ts b/test/bundler/transpiler/es-decorators.test.ts index f597e47a1334..10933375f389 100644 --- a/test/bundler/transpiler/es-decorators.test.ts +++ b/test/bundler/transpiler/es-decorators.test.ts @@ -1584,6 +1584,94 @@ describe("ES Decorators", () => { expect(exitCode).toBe(0); }); + test.concurrent("a decorated field or an accessor names the function it is initialized with", async () => { + const { stdout, stderr, exitCode } = await runDecorator(` + const dec = (v, ctx) => {}; + const k = "dyn", sym = Symbol("desc"); + class A { + @dec f = () => {}; @dec static g = function () {}; @dec h = class {}; + @dec static [k] = () => {}; @dec [sym] = function () {}; @dec 123 = class {}; @dec "q r" = () => {}; @dec ["__proto__"] = function () {}; + @dec own = class { static name() {} }; @dec named = function keep() {}; + @dec #p = () => {}; @dec static #sp = class {}; + accessor a1 = () => {}; @dec accessor a2 = function () {}; static accessor a3 = class {}; @dec static accessor [k + "2"] = () => {}; + accessor #a4 = () => {}; @dec accessor #a5 = () => {}; @dec static accessor #a6 = class {}; + privateNames() { return [this.#p.name, A.#sp.name, this.#a4.name, this.#a5.name, A.#a6.name] } + } + const a = new A(); + console.log(JSON.stringify([a.f.name, A.g.name, a.h.name, A[k].name, a[sym].name, a[123].name, a["q r"].name, a["__proto__"].name, typeof a.own.name, a.named.name])); + console.log(JSON.stringify([a.privateNames(), a.a1.name, a.a2.name, A.a3.name, A[k + "2"].name, Object.getPrototypeOf(a) === A.prototype])); + // The \`init\` functions of the decorators get the function already named. + const seen = []; + const init = (v, ctx) => { const f = (x) => (seen.push(x.name), x); return ctx.kind === "accessor" ? { init: f } : f }; + class B { @init f = () => {}; @init static g = class {}; @init accessor h = function () {}; @init #p = () => {}; } + new B(); + console.log(JSON.stringify(seen)); + `); + expect(stderr).toBe(""); + expect(stdout).toBe( + '["f","g","h","dyn","[desc]","123","q r","__proto__","function","keep"]\n' + + '[["#p","#sp","#a4","#a5","#a6"],"a1","a2","a3","dyn2",true]\n' + + '["g","f","h","#p"]\n', + ); + expect(exitCode).toBe(0); + }); + + test.concurrent("an anonymous class or function in the extends clause stays anonymous", async () => { + const { stdout, stderr, exitCode } = await runDecorator(` + const dec = (v, ctx) => { if (ctx.kind !== "class") ctx.metadata[ctx.name] = true }; + const base = (C) => Object.getPrototypeOf(C); + const metadata = Symbol.metadata ?? Symbol.for("Symbol.metadata"); + class A extends class {} { @dec m() {} } + class B extends (function () {}) { @dec m() {} } + class C extends class Named {} { @dec m() {} } + class D extends class { static name() {} } { @dec m() {} } + class E extends class { @dec x() {} } { @dec m() {} } + const F = class extends class {} { @dec m() {} }; + @dec class G extends class {} {} + class H extends class { accessor y = 1 } { accessor x = 2 } + console.log(JSON.stringify([A, B, C, D, E, F, G, H].map(K => base(K).name))); + // The metadata of the class still inherits from the metadata of what it extends. + class Nul extends null { @dec static s() {} } + class Top { @dec t() {} } + console.log(JSON.stringify([ + Object.getPrototypeOf(E[metadata]) === base(E)[metadata], Object.keys(E[metadata]), E[metadata].x, + Object.getPrototypeOf(Nul[metadata]), Object.getPrototypeOf(Top[metadata]), + ])); + `); + expect(stderr).toBe(""); + expect(stdout).toBe('["","","Named",null,"","","",""]\n[true,["m"],true,null,null]\n'); + expect(exitCode).toBe(0); + }); + + test.concurrent("a class decorator leaves a static member called name alone", async () => { + const { stdout, stderr, exitCode } = await runDecorator(` + const seen = []; + const dec = (v, ctx) => {}; + const see = (v, ctx) => { seen.push(typeof v.name + ":" + ctx.name) }; + @see class A { static name() { return "method" } } + const B = @see class { static name() { return "method" } }; + @see class C { static get name() { return "getter" } } + @dec class D { static accessor name = "accessor" } + @dec class E { @dec static accessor name = "decorated accessor" } + @dec class F { static ["na" + "me"]() { return "computed" } } + @dec class G { static name = 1 } + // Without such a member the class keeps its name, or gets the one its context gives it. + @see class H {} + const I = @see class {}; + const J = @see class Inner {}; + console.log(JSON.stringify([A.name(), B.name(), C.name, D.name, E.name, F.name(), G.name, H.name, I.name, J.name])); + console.log(JSON.stringify([A, C, H, I].map(K => Object.getOwnPropertyDescriptor(K, "name")).map(d => [typeof (d.value ?? d.get), d.writable, d.enumerable, d.configurable]))); + console.log(seen.join(" ")); + `); + expect(stderr).toBe(""); + expect(stdout).toBe( + '["method","method","getter","accessor","decorated accessor","computed",1,"H","I","Inner"]\n' + + '[["function",true,false,true],["function",null,false,true],["string",false,false,true],["string",false,false,true]]\n' + + "function:A function:B string:C string:H string:I string:Inner\n", + ); + expect(exitCode).toBe(0); + }); + test.concurrent( "a class with no key for its decorator lists: private names, extends and the inner name", async () => { @@ -2391,6 +2479,31 @@ 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]; } + +// Names are the ones the class would have without decorators: of the function +// a decorated field or an accessor is initialized with, of an anonymous class +// in the extends clause, and of a class with a static member called \`name\`. +{ + const none = (value, ctx) => {}; + class N1 { + @none f = () => {}; + @none static g = function () {}; + @none accessor h = class {}; + accessor i = () => {}; + @none #p = () => {}; + p() { return this.#p.name; } + } + class N2 extends class {} { @none m() {} } + class N3 extends (function () {}) { accessor a = 1; } + @none class N4 { static name() { return "N4.name"; } } + const N5 = @none class { static get name() { return "N5.name"; } }; + const n1 = new N1(); + out.names = [ + [n1.f.name, N1.g.name, n1.h.name, n1.i.name, n1.p()], + [Object.getPrototypeOf(N2).name, Object.getPrototypeOf(N3).name], + [N4.name(), N5.name], + ]; +} `; const extraExpected = { @@ -2434,6 +2547,11 @@ 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], + names: [ + ["f", "g", "h", "i", "#p"], + ["", ""], + ["N4.name", "N5.name"], + ], }; function buildFixture() { From ab642e5f10bf0daa2370fdd13edb6ed64e230934 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 13 Sep 2026 10:46:18 +0000 Subject: [PATCH 2/4] test: keep the names fixture section from throwing on a build without the fix --- test/bundler/transpiler/es-decorators.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/bundler/transpiler/es-decorators.test.ts b/test/bundler/transpiler/es-decorators.test.ts index 10933375f389..5f0ed66357cf 100644 --- a/test/bundler/transpiler/es-decorators.test.ts +++ b/test/bundler/transpiler/es-decorators.test.ts @@ -2495,13 +2495,13 @@ const extraSections = ` } class N2 extends class {} { @none m() {} } class N3 extends (function () {}) { accessor a = 1; } - @none class N4 { static name() { return "N4.name"; } } + @none class N4 { static name() {} } const N5 = @none class { static get name() { return "N5.name"; } }; const n1 = new N1(); out.names = [ [n1.f.name, N1.g.name, n1.h.name, n1.i.name, n1.p()], [Object.getPrototypeOf(N2).name, Object.getPrototypeOf(N3).name], - [N4.name(), N5.name], + [typeof N4.name, N5.name], ]; } `; @@ -2550,7 +2550,7 @@ const extraExpected = { names: [ ["f", "g", "h", "i", "#p"], ["", ""], - ["N4.name", "N5.name"], + ["function", "N5.name"], ], }; From 2d7d2ab00e5095c1a823ca41e9111cffd517e146 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:24:51 +0000 Subject: [PATCH 3/4] ci: retrigger From 3d2466135600742927e3ee2771bad5ce6ee8d18c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:26:17 +0000 Subject: [PATCH 4/4] Shorten comments --- src/js_parser/lower/lower_decorators.rs | 9 +++------ src/runtime.js | 3 +-- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/js_parser/lower/lower_decorators.rs b/src/js_parser/lower/lower_decorators.rs index a75145de80fe..bab546a5a079 100644 --- a/src/js_parser/lower/lower_decorators.rs +++ b/src/js_parser/lower/lower_decorators.rs @@ -1067,8 +1067,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let name: &'a [u8] = p.symbols[private_index as usize].original_name.slice(); field_name = FieldName::Key(p.new_expr(E::EString::init(name), loc)); } else { - // Only a field that is decorated or may carry effects has to - // name its function itself. + // Only a decorated field, or one that may carry effects, names its function itself. let names_function = !is_method && (dec_ref.is_some() || !is_static @@ -1178,8 +1177,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O leading.extend_from_slice(&static_brands); let mut class_decorators_result: Option = None; if has_decorators { - // The metadata of a derived class inherits from that of the class it - // extends, which `__decoratorStart` reads off the prototype of `this`. + // `__decoratorStart` reads the class that `this` extends off its prototype. let undefined = p.new_expr(E::Undefined {}, loc); let start = if class.extends.is_some() { let this = p.new_expr(E::This {}, loc); @@ -1423,8 +1421,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } - /// `value` for a place where the field no longer names it: behind a comma - /// or as a call argument, an anonymous function or class stays anonymous. + /// `value` with the name `field_name` gives it, for behind a comma or in a call argument. fn named_by_field(&mut self, value: Expr, field_name: FieldName) -> Expr { if !value.is_anonymous_named() { return value; diff --git a/src/runtime.js b/src/runtime.js index 8270685ce621..7c69be80d3f1 100644 --- a/src/runtime.js +++ b/src/runtime.js @@ -240,8 +240,7 @@ export var __privateSet = (obj, member, value, setter) => ( ); export var __privateMethod = (obj, member, method) => (__accessCheck(obj, member, "access private method"), method); -// `target` is a derived class: its metadata inherits from that of its prototype, -// the class it extends. Output of older versions passes that class as `base`. +// `target` is a derived class. Older output passes the class it extends as `base`. export var __decoratorStart = (base, target) => ( target && (base = __getProtoOf(target)), [, , , __create(base?.[__knownSymbol("metadata")] ?? null)]