From 5037e8403841c456bcbf40eb793884613f9111b5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 13:16:17 +0000 Subject: [PATCH 01/17] js_parser: lower undecorated auto-accessors to native private-field storage Instead of a module-scope WeakMap with __privateAdd injected into the constructor, emit a class-body '#_ = ' private field plus a get/set pair that read and write it directly. The storage initializer stays at the accessor's source position, so it runs in spec order relative to neighbouring fields, and the per-class private name needs no module-scope binding so a subclass with a same-named accessor no longer trips __privateAdd's duplicate-member check. Storage names are checked against the class's existing private names (and earlier generated storage) and suffixed on collision. --- src/js_parser/lower/lower_decorators.rs | 139 +++++++++++------- test/bundler/transpiler/es-decorators.test.ts | 122 +++++++++++++++ 2 files changed, 207 insertions(+), 54 deletions(-) diff --git a/src/js_parser/lower/lower_decorators.rs b/src/js_parser/lower/lower_decorators.rs index 77c2620dac5e..f17e8cd9ebbe 100644 --- a/src/js_parser/lower/lower_decorators.rs +++ b/src/js_parser/lower/lower_decorators.rs @@ -1393,8 +1393,11 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let mut emitted_private_adds: HashMap = HashMap::default(); let mut static_private_add_blocks = BumpVec::::new_in(bump); - // Pre-scan: determine if all private members need lowering + // Pre-scan: determine if all private members need lowering, and collect + // the class's private-name set so generated accessor-storage privates + // can pick an unused name. let mut lower_all_private = false; + let mut class_private_names: HashMap<&'a [u8], ()> = HashMap::default(); { let mut has_any_private = false; let mut has_any_decorated = false; @@ -1412,16 +1415,15 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O ) { lower_all_private = true; - break; } } - if cprop.key.is_some() - && matches!( - cprop.key.unwrap().data, - js_ast::ExprData::EPrivateIdentifier(_) - ) + if let Some(k) = cprop.key + && let js_ast::ExprData::EPrivateIdentifier(pi) = &k.data { has_any_private = true; + let name: &'a [u8] = + p.symbols[pi.ref_.inner_index() as usize].original_name.slice(); + class_private_names.insert(name, ()); } } if !lower_all_private && has_any_private && has_any_decorated { @@ -1539,29 +1541,80 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O continue; } } - // Undecorated auto-accessor → WeakMap + getter/setter + // Undecorated auto-accessor → native `#`-storage field + get/set. + // The storage initializer stays at the accessor's class-body + // position so it runs in source order with neighbouring fields, + // and the per-class private name needs no module-scope binding. if prop.kind == PropertyKind::AutoAccessor { - let accessor_name: &'a [u8] = 'brk: { + let storage_base: &'a [u8] = 'brk: { if let Some(k) = prop.key { - if let js_ast::ExprData::EString(s) = &k.data - && s.is_utf8() - { - break 'brk p.bump_name2(b"_", &s.data); + match &k.data { + js_ast::ExprData::EString(s) + if s.is_utf8() + && js_lexer::is_identifier(&s.data) => + { + break 'brk p.bump_name2(b"#_", &s.data); + } + js_ast::ExprData::EPrivateIdentifier(pi) => { + let orig: &'a [u8] = p.symbols + [pi.ref_.inner_index() as usize] + .original_name + .slice(); + break 'brk p.bump_name2(b"#_", &orig[1..]); + } + _ => {} } } - let name = - p.bump_name(b"_accessor_storage", Some(accessor_storage_counter)); + let name = p.bump_name( + b"#_accessor_storage", + Some(accessor_storage_counter), + ); accessor_storage_counter += 1; name }; - let wm_ref = p.new_sym(js_ast::symbol::Kind::Other, accessor_name); - let wme = p.new_weak_map_expr(loc); - prefix_stmts.push(p.var_decl(wm_ref, Some(wme), loc)); + let mut storage_name: &'a [u8] = storage_base; + let mut tries: usize = 2; + while class_private_names.contains_key(storage_name) { + storage_name = p.bump_name(storage_base, Some(tries)); + tries += 1; + } + class_private_names.insert(storage_name, ()); + + let is_static = prop.flags.contains(Flags::Property::IsStatic); + let storage_kind = if is_static { + js_ast::symbol::Kind::PrivateStaticField + } else { + js_ast::symbol::Kind::PrivateField + }; + let storage_ref = p.new_sym(storage_kind, storage_name); + let storage_key = + p.new_expr(E::PrivateIdentifier { ref_: storage_ref }, loc); - // Getter: get foo() { return __privateGet(this, _foo); } + let storage_flags = if is_static { + Flags::Property::IsStatic.into() + } else { + js_ast::flags::PROPERTY_NONE + }; + new_properties.push(Property { + key: Some(storage_key), + initializer: prop.initializer, + kind: PropertyKind::Normal, + flags: storage_flags, + ..Default::default() + }); + + // get foo() { return this.#_foo; } let this_e = p.new_expr(E::This {}, loc); - let wm_e = p.use_ref(wm_ref, loc); - let get_ret = p.call_rt(loc, b"__privateGet", &[this_e, wm_e]); + let idx_e = + p.new_expr(E::PrivateIdentifier { ref_: storage_ref }, loc); + let get_ret = p.new_expr( + E::Index { + target: this_e, + index: idx_e, + optional_chain: None, + }, + loc, + ); let get_body = bump.alloc_slice_copy(&[p.s( S::Return { value: Some(get_ret), @@ -1576,19 +1629,21 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O ..Default::default() }; - // Setter: set foo(v) { __privateSet(this, _foo, v); } + // set foo(v) { this.#_foo = v; } let setter_param_ref = p.new_sym(js_ast::symbol::Kind::Other, b"v"); let this_e2 = p.new_expr(E::This {}, loc); - let wm_e2 = p.use_ref(wm_ref, loc); - let v_e = p.use_ref(setter_param_ref, loc); - let set_call = p.call_rt(loc, b"__privateSet", &[this_e2, wm_e2, v_e]); - let set_body = bump.alloc_slice_copy(&[p.s( - S::SExpr { - value: set_call, - ..Default::default() + let idx_e2 = + p.new_expr(E::PrivateIdentifier { ref_: storage_ref }, loc); + let lhs = p.new_expr( + E::Index { + target: this_e2, + index: idx_e2, + optional_chain: None, }, loc, - )]); + ); + let v_e = p.use_ref(setter_param_ref, loc); + let set_body = bump.alloc_slice_copy(&[Stmt::assign(lhs, v_e)]); let setter_binding = p.b( B::Identifier { r#ref: setter_param_ref, @@ -1624,30 +1679,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O flags: getter_flags, ..Default::default() }); - - let init_val = prop - .initializer - .unwrap_or_else(|| p.new_expr(E::Undefined {}, loc)); - if !prop.flags.contains(Flags::Property::IsStatic) { - let this_e3 = p.new_expr(E::This {}, loc); - let wm_e3 = p.use_ref(wm_ref, loc); - let call = p.call_rt(loc, b"__privateAdd", &[this_e3, wm_e3, init_val]); - constructor_inject_stmts.push(p.s( - S::SExpr { - value: call, - ..Default::default() - }, - loc, - )); - } else { - let cn_e = p.use_ref(class_name_ref, class_name_loc); - let wm_e3 = p.use_ref(wm_ref, loc); - suffix_exprs.push(p.call_rt( - loc, - b"__privateAdd", - &[cn_e, wm_e3, init_val], - )); - } continue; } // Static blocks → extract to suffix diff --git a/test/bundler/transpiler/es-decorators.test.ts b/test/bundler/transpiler/es-decorators.test.ts index bdc61e68c48d..423ab258d589 100644 --- a/test/bundler/transpiler/es-decorators.test.ts +++ b/test/bundler/transpiler/es-decorators.test.ts @@ -1210,4 +1210,126 @@ describe("ES Decorators", () => { expect(exitCode).toBe(0); }); }); + + describe("undecorated auto-accessor lowering", () => { + test("initializer runs in source order with sibling fields", async () => { + const { stdout, stderr, exitCode } = await runDecorator(` + const log = []; + const mk = (name, v) => (log.push(name), v); + class C { + a = mk("a", 1); + accessor b = mk("b", 2); + c = mk("c", this.b); + } + const c = new C(); + console.log(log.join(",")); + console.log(c.a, c.b, c.c); + `); + expect(stderr).toBe(""); + expect(stdout).toBe("a,b,c\n1 2 2\n"); + expect(exitCode).toBe(0); + }); + + test("static accessor initializer runs in source order with sibling static fields", async () => { + const { stdout, stderr, exitCode } = await runDecorator(` + const log = []; + const mk = (name, v) => (log.push(name), v); + class C { + static a = mk("a", 1); + static accessor b = mk("b", 2); + static c = mk("c", C.b); + } + console.log(log.join(",")); + console.log(C.a, C.b, C.c); + `); + expect(stderr).toBe(""); + expect(stdout).toBe("a,b,c\n1 2 2\n"); + expect(exitCode).toBe(0); + }); + + test("subclass may declare an accessor with the same name as its base", async () => { + const { stdout, stderr, exitCode } = await runDecorator(` + class Base { accessor v = 1; } + class Sub extends Base { accessor v = 2; } + const s = new Sub(); + console.log(s.v); + s.v = 99; + console.log(s.v); + `); + expect(stderr).toBe(""); + expect(stdout).toBe("2\n99\n"); + expect(exitCode).toBe(0); + }); + + test("two classes in a file may each declare a same-named accessor", async () => { + const { stdout, stderr, exitCode } = await runDecorator(` + class A { accessor x = 1; } + class B { accessor x = 2; } + const a = new A(), b = new B(); + a.x = 10; + console.log(a.x, b.x); + `); + expect(stderr).toBe(""); + expect(stdout).toBe("10 2\n"); + expect(exitCode).toBe(0); + }); + + test("instance and static accessors may share a name", async () => { + const { stdout, stderr, exitCode } = await runDecorator(` + class C { + accessor x = 1; + static accessor x = 2; + } + const c = new C(); + c.x = 10; + C.x = 20; + console.log(c.x, C.x); + `); + expect(stderr).toBe(""); + expect(stdout).toBe("10 20\n"); + expect(exitCode).toBe(0); + }); + + test("generated storage name avoids existing private names", async () => { + const { stdout, stderr, exitCode } = await runDecorator(` + class C { + #_y = "user"; + accessor y = "acc"; + read() { return this.#_y; } + } + const c = new C(); + console.log(c.y, c.read()); + c.y = "changed"; + console.log(c.y, c.read()); + `); + expect(stderr).toBe(""); + expect(stdout).toBe("acc user\nchanged user\n"); + expect(exitCode).toBe(0); + }); + + test("accessor with a private-name key round-trips", async () => { + const { stdout, stderr, exitCode } = await runDecorator(` + class C { + accessor #z = 5; + get z() { return this.#z; } + set z(v) { this.#z = v; } + } + const c = new C(); + console.log(c.z); + c.z = 50; + console.log(c.z); + `); + expect(stderr).toBe(""); + expect(stdout).toBe("5\n50\n"); + expect(exitCode).toBe(0); + }); + + test("storage is a class-body private field, not a module-scope WeakMap", () => { + const transpiler = new Bun.Transpiler({ loader: "js", target: "bun" }); + const out = transpiler.transformSync(`class C { accessor x = 1; }`); + expect(out).toContain("#_x"); + expect(out).not.toContain("WeakMap"); + expect(out).not.toContain("__privateAdd"); + }); + }); }); From 6f92a8ced3fd0769f421ca0a5f07f0d16dc9e4a4 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 13:18:52 +0000 Subject: [PATCH 02/17] [autofix.ci] apply automated fixes --- src/js_parser/lower/lower_decorators.rs | 26 ++++++++++--------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/src/js_parser/lower/lower_decorators.rs b/src/js_parser/lower/lower_decorators.rs index f17e8cd9ebbe..c462b40642d2 100644 --- a/src/js_parser/lower/lower_decorators.rs +++ b/src/js_parser/lower/lower_decorators.rs @@ -1421,8 +1421,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O && let js_ast::ExprData::EPrivateIdentifier(pi) = &k.data { has_any_private = true; - let name: &'a [u8] = - p.symbols[pi.ref_.inner_index() as usize].original_name.slice(); + let name: &'a [u8] = p.symbols[pi.ref_.inner_index() as usize] + .original_name + .slice(); class_private_names.insert(name, ()); } } @@ -1550,14 +1551,12 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O if let Some(k) = prop.key { match &k.data { js_ast::ExprData::EString(s) - if s.is_utf8() - && js_lexer::is_identifier(&s.data) => + if s.is_utf8() && js_lexer::is_identifier(&s.data) => { break 'brk p.bump_name2(b"#_", &s.data); } js_ast::ExprData::EPrivateIdentifier(pi) => { - let orig: &'a [u8] = p.symbols - [pi.ref_.inner_index() as usize] + let orig: &'a [u8] = p.symbols[pi.ref_.inner_index() as usize] .original_name .slice(); break 'brk p.bump_name2(b"#_", &orig[1..]); @@ -1565,10 +1564,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O _ => {} } } - let name = p.bump_name( - b"#_accessor_storage", - Some(accessor_storage_counter), - ); + let name = + p.bump_name(b"#_accessor_storage", Some(accessor_storage_counter)); accessor_storage_counter += 1; name }; @@ -1587,8 +1584,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O js_ast::symbol::Kind::PrivateField }; let storage_ref = p.new_sym(storage_kind, storage_name); - let storage_key = - p.new_expr(E::PrivateIdentifier { ref_: storage_ref }, loc); + let storage_key = p.new_expr(E::PrivateIdentifier { ref_: storage_ref }, loc); let storage_flags = if is_static { Flags::Property::IsStatic.into() @@ -1605,8 +1601,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // get foo() { return this.#_foo; } let this_e = p.new_expr(E::This {}, loc); - let idx_e = - p.new_expr(E::PrivateIdentifier { ref_: storage_ref }, loc); + let idx_e = p.new_expr(E::PrivateIdentifier { ref_: storage_ref }, loc); let get_ret = p.new_expr( E::Index { target: this_e, @@ -1632,8 +1627,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // set foo(v) { this.#_foo = v; } let setter_param_ref = p.new_sym(js_ast::symbol::Kind::Other, b"v"); let this_e2 = p.new_expr(E::This {}, loc); - let idx_e2 = - p.new_expr(E::PrivateIdentifier { ref_: storage_ref }, loc); + let idx_e2 = p.new_expr(E::PrivateIdentifier { ref_: storage_ref }, loc); let lhs = p.new_expr( E::Index { target: this_e2, From e9283d645e950ffac8c2b380c0954185ec249bf4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 19:58:05 +0000 Subject: [PATCH 03/17] Keep lowered instance #-private brand-adds on the class-body timeline When every instance #-private in a class is lowered to WeakMap/WeakSet storage, emit each __privateAdd call as the initializer of a synthetic class-body private field at the member's source position instead of splicing it into the constructor body. Sibling public fields and native-# accessor storage then evaluate on the same class-body timeline in source order, so a following field can read a preceding lowered private without racing the constructor splice, and NamedEvaluation, new.target and computed-key semantics of untouched fields are left to the engine. Accessor storage is named '#_accessor_storage' (matching tsc) and checked against every private-kind symbol already parsed, so it cannot textually shadow a private declared in an enclosing class. --- src/js_parser/lower/lower_decorators.rs | 129 ++++++++++++------ test/bundler/transpiler/es-decorators.test.ts | 63 ++++++++- 2 files changed, 151 insertions(+), 41 deletions(-) diff --git a/src/js_parser/lower/lower_decorators.rs b/src/js_parser/lower/lower_decorators.rs index c462b40642d2..adfda2680642 100644 --- a/src/js_parser/lower/lower_decorators.rs +++ b/src/js_parser/lower/lower_decorators.rs @@ -344,6 +344,50 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O (((5 + 2 * idx) << 1) | 1) as f64 } + /// Pick an unused `#`-private name (suffixing on collision) and record it. + fn fresh_private_name(&self, used: &mut HashMap<&'a [u8], ()>, base: &'a [u8]) -> &'a [u8] { + let mut name = base; + let mut tries: usize = 2; + while used.contains_key(name) { + name = self.bump_name(base, Some(tries)); + tries += 1; + } + used.insert(name, ()); + name + } + + /// A synthetic `#__N = void ` class-body property. Placed at a + /// lowered private's source position so its brand-add runs on the + /// class-body initializer timeline alongside native fields. + fn inline_brand_field( + &mut self, + used: &mut HashMap<&'a [u8], ()>, + counter: &mut usize, + expr: Expr, + loc: bun_ast::Loc, + ) -> Property { + let base = self.bump_name(b"#__lowered_", Some(*counter)); + *counter += 1; + let name = self.fresh_private_name(used, base); + let ref_ = self.new_sym(js_ast::symbol::Kind::PrivateField, name); + let key = self.new_expr(E::PrivateIdentifier { ref_ }, loc); + let init = self.new_expr( + E::Unary { + op: js_ast::OpCode::UnVoid, + value: expr, + flags: js_ast::e::UnaryFlags::empty(), + }, + loc, + ); + Property { + key: Some(key), + initializer: Some(init), + kind: PropertyKind::Normal, + flags: js_ast::flags::PROPERTY_NONE, + ..Default::default() + } + } + /// Emit __privateAdd for a given storage ref. fn emit_private_add( &mut self, @@ -1393,11 +1437,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let mut emitted_private_adds: HashMap = HashMap::default(); let mut static_private_add_blocks = BumpVec::::new_in(bump); - // Pre-scan: determine if all private members need lowering, and collect - // the class's private-name set so generated accessor-storage privates - // can pick an unused name. + // Pre-scan: determine if all private members need lowering. let mut lower_all_private = false; - let mut class_private_names: HashMap<&'a [u8], ()> = HashMap::default(); { let mut has_any_private = false; let mut has_any_decorated = false; @@ -1417,14 +1458,13 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O lower_all_private = true; } } - if let Some(k) = cprop.key - && let js_ast::ExprData::EPrivateIdentifier(pi) = &k.data + if cprop.key.is_some() + && matches!( + cprop.key.unwrap().data, + js_ast::ExprData::EPrivateIdentifier(_) + ) { has_any_private = true; - let name: &'a [u8] = p.symbols[pi.ref_.inner_index() as usize] - .original_name - .slice(); - class_private_names.insert(name, ()); } } if !lower_all_private && has_any_private && has_any_decorated { @@ -1432,6 +1472,17 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } + // Generated `#`-privates must not textually shadow any private name + // already parsed (this class's declarations and any enclosing class's), + // because `NoOpRenamer` prints symbol names verbatim. + let mut class_private_names: HashMap<&'a [u8], ()> = HashMap::default(); + for sym in p.symbols.iter() { + if sym.kind.is_private() { + class_private_names.insert(sym.original_name.slice(), ()); + } + } + let mut brand_field_counter: usize = 0; + let props_slice2: &mut [Property] = class.properties.slice_mut(); for (prop_idx, prop) in props_slice2.iter_mut().enumerate() { if prop.ts_decorators.len_u32() == 0 { @@ -1504,14 +1555,22 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // __privateAdd (once per name) if !emitted_private_adds.contains_key(&npriv_inner) { emitted_private_adds.insert(npriv_inner, ()); - p.emit_private_add( - prop.flags.contains(Flags::Property::IsStatic), - ws_ref, - None, - loc, - &mut constructor_inject_stmts, - &mut static_private_add_blocks, - ); + if prop.flags.contains(Flags::Property::IsStatic) { + let t = p.new_expr(E::This {}, loc); + let s = p.use_ref(ws_ref, loc); + let call = p.call_rt(loc, b"__privateAdd", &[t, s]); + static_private_add_blocks.push(p.make_static_block(call, loc)); + } else { + let t = p.new_expr(E::This {}, loc); + let s = p.use_ref(ws_ref, loc); + let call = p.call_rt(loc, b"__privateAdd", &[t, s]); + new_properties.push(p.inline_brand_field( + &mut class_private_names, + &mut brand_field_counter, + call, + loc, + )); + } } continue; } else { @@ -1528,24 +1587,20 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let this_e = p.new_expr(E::This {}, loc); let wm_e = p.use_ref(wm_ref, loc); let call = p.call_rt(loc, b"__privateAdd", &[this_e, wm_e, init_val]); - if !prop.flags.contains(Flags::Property::IsStatic) { - constructor_inject_stmts.push(p.s( - S::SExpr { - value: call, - ..Default::default() - }, + if prop.flags.contains(Flags::Property::IsStatic) { + static_private_add_blocks.push(p.make_static_block(call, loc)); + } else { + new_properties.push(p.inline_brand_field( + &mut class_private_names, + &mut brand_field_counter, + call, loc, )); - } else { - static_private_add_blocks.push(p.make_static_block(call, loc)); } continue; } } // Undecorated auto-accessor → native `#`-storage field + get/set. - // The storage initializer stays at the accessor's class-body - // position so it runs in source order with neighbouring fields, - // and the per-class private name needs no module-scope binding. if prop.kind == PropertyKind::AutoAccessor { let storage_base: &'a [u8] = 'brk: { if let Some(k) = prop.key { @@ -1553,13 +1608,17 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O js_ast::ExprData::EString(s) if s.is_utf8() && js_lexer::is_identifier(&s.data) => { - break 'brk p.bump_name2(b"#_", &s.data); + let mut v = BumpVec::::new_in(bump); + v.push(b'#'); + v.extend_from_slice(&s.data); + v.extend_from_slice(b"_accessor_storage"); + break 'brk v.into_bump_slice(); } js_ast::ExprData::EPrivateIdentifier(pi) => { let orig: &'a [u8] = p.symbols[pi.ref_.inner_index() as usize] .original_name .slice(); - break 'brk p.bump_name2(b"#_", &orig[1..]); + break 'brk p.bump_name2(orig, b"_accessor_storage"); } _ => {} } @@ -1569,13 +1628,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O accessor_storage_counter += 1; name }; - let mut storage_name: &'a [u8] = storage_base; - let mut tries: usize = 2; - while class_private_names.contains_key(storage_name) { - storage_name = p.bump_name(storage_base, Some(tries)); - tries += 1; - } - class_private_names.insert(storage_name, ()); + let storage_name = p.fresh_private_name(&mut class_private_names, storage_base); let is_static = prop.flags.contains(Flags::Property::IsStatic); let storage_kind = if is_static { diff --git a/test/bundler/transpiler/es-decorators.test.ts b/test/bundler/transpiler/es-decorators.test.ts index 423ab258d589..f4519cc75548 100644 --- a/test/bundler/transpiler/es-decorators.test.ts +++ b/test/bundler/transpiler/es-decorators.test.ts @@ -1293,9 +1293,9 @@ describe("ES Decorators", () => { test("generated storage name avoids existing private names", async () => { const { stdout, stderr, exitCode } = await runDecorator(` class C { - #_y = "user"; + #y_accessor_storage = "user"; accessor y = "acc"; - read() { return this.#_y; } + read() { return this.#y_accessor_storage; } } const c = new C(); console.log(c.y, c.read()); @@ -1307,6 +1307,27 @@ describe("ES Decorators", () => { expect(exitCode).toBe(0); }); + test("generated storage name does not shadow a private referenced from an enclosing class", async () => { + const { stdout, stderr, exitCode } = await runDecorator(` + class Outer { + #x_accessor_storage = 42; + make() { + const outer = this; + return class Inner { + accessor x = 1; + readOuter() { return outer.#x_accessor_storage; } + }; + } + } + const Inner = new Outer().make(); + const i = new Inner(); + console.log(i.readOuter(), i.x); + `); + expect(stderr).toBe(""); + expect(stdout).toBe("42 1\n"); + expect(exitCode).toBe(0); + }); + test("accessor with a private-name key round-trips", async () => { const { stdout, stderr, exitCode } = await runDecorator(` class C { @@ -1324,10 +1345,46 @@ describe("ES Decorators", () => { expect(exitCode).toBe(0); }); + test("instance fields run in source order when sibling #-privates are WeakMap-lowered", async () => { + const { stdout, stderr, exitCode } = await runDecorator(` + function dec(v) { return v; } + const log = []; + class C { + @dec m() {} + #a = (log.push("a"), 1); + accessor b = (log.push("b"), this.readA()); + c = (log.push("c"), this.b); + readA() { return this.#a; } + } + const o = new C(); + console.log(log.join(","), o.b, o.c, o.readA()); + `); + expect(stderr).toBe(""); + expect(stdout).toBe("a,b,c 1 1 1\n"); + expect(exitCode).toBe(0); + }); + + test("WeakMap-lowered #-private does not strip NamedEvaluation or new.target from sibling fields", async () => { + const { stdout, stderr, exitCode } = await runDecorator(` + function dec(v) { return v; } + class C { + @dec m() {} + #p; + onClick = () => {}; + nt = new.target; + } + const c = new C(); + console.log(c.onClick.name, c.nt === undefined); + `); + expect(stderr).toBe(""); + expect(stdout).toBe("onClick true\n"); + expect(exitCode).toBe(0); + }); + test("storage is a class-body private field, not a module-scope WeakMap", () => { const transpiler = new Bun.Transpiler({ loader: "js", target: "bun" }); const out = transpiler.transformSync(`class C { accessor x = 1; }`); - expect(out).toContain("#_x"); + expect(out).toContain("#x_accessor_storage"); expect(out).not.toContain("WeakMap"); expect(out).not.toContain("__privateAdd"); }); From 8d57f56a545635c074746f5af4d6c840a9871d62 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 19:59:16 +0000 Subject: [PATCH 04/17] Trim doc comments flagged by comment-cop --- src/js_parser/lower/lower_decorators.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/js_parser/lower/lower_decorators.rs b/src/js_parser/lower/lower_decorators.rs index adfda2680642..15e736338c73 100644 --- a/src/js_parser/lower/lower_decorators.rs +++ b/src/js_parser/lower/lower_decorators.rs @@ -356,9 +356,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O name } - /// A synthetic `#__N = void ` class-body property. Placed at a - /// lowered private's source position so its brand-add runs on the - /// class-body initializer timeline alongside native fields. + /// Synthetic `#__N = void ` class-body field at a lowered private's position. fn inline_brand_field( &mut self, used: &mut HashMap<&'a [u8], ()>, @@ -1472,9 +1470,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } - // Generated `#`-privates must not textually shadow any private name - // already parsed (this class's declarations and any enclosing class's), - // because `NoOpRenamer` prints symbol names verbatim. + // Every `#`-name already parsed (this class or an enclosing one). let mut class_private_names: HashMap<&'a [u8], ()> = HashMap::default(); for sym in p.symbols.iter() { if sym.kind.is_private() { From 6c532fbac6051a68ad4fe80ed11f47f191753600 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:05:09 +0000 Subject: [PATCH 05/17] Route decorated instance #-private method brand-adds through inline_brand_field The parallel arm for decorated instance private methods/getters/setters at the old emit_private_add call site was still pushing to constructor_inject_stmts, so a following undecorated #-private field whose class-body initializer called the method ran before the brand was installed. Use inline_brand_field for the instance case there too and drop emit_private_add, which is now unused. --- src/js_parser/lower/lower_decorators.rs | 51 +++++-------------- test/bundler/transpiler/es-decorators.test.ts | 15 ++++++ 2 files changed, 28 insertions(+), 38 deletions(-) diff --git a/src/js_parser/lower/lower_decorators.rs b/src/js_parser/lower/lower_decorators.rs index 15e736338c73..e237a997c26c 100644 --- a/src/js_parser/lower/lower_decorators.rs +++ b/src/js_parser/lower/lower_decorators.rs @@ -386,36 +386,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } - /// Emit __privateAdd for a given storage ref. - fn emit_private_add( - &mut self, - is_static: bool, - storage_ref: Ref, - value: Option, - loc: bun_ast::Loc, - constructor_inject: &mut BumpVec<'_, Stmt>, - static_blocks: &mut BumpVec<'_, Property>, - ) { - let target = self.new_expr(E::This {}, loc); - let storage = self.use_ref(storage_ref, loc); - let call = if let Some(v) = value { - self.call_rt(loc, b"__privateAdd", &[target, storage, v]) - } else { - self.call_rt(loc, b"__privateAdd", &[target, storage]) - }; - if is_static { - static_blocks.push(self.make_static_block(call, loc)); - } else { - constructor_inject.push(self.s( - S::SExpr { - value: call, - ..Default::default() - }, - loc, - )); - } - } - /// Get the method kind code (1=method, 2=getter, 3=setter). fn method_kind(prop: &Property) -> u8 { match prop.kind { @@ -2019,14 +1989,19 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O }; if !emitted_private_adds.contains_key(&priv_inner2) { emitted_private_adds.insert(priv_inner2, ()); - p.emit_private_add( - prop.flags.contains(Flags::Property::IsStatic), - private_storage_ref.unwrap(), - None, - loc, - &mut constructor_inject_stmts, - &mut static_private_add_blocks, - ); + let t = p.new_expr(E::This {}, loc); + let s = p.use_ref(private_storage_ref.unwrap(), loc); + let call = p.call_rt(loc, b"__privateAdd", &[t, s]); + if prop.flags.contains(Flags::Property::IsStatic) { + static_private_add_blocks.push(p.make_static_block(call, loc)); + } else { + new_properties.push(p.inline_brand_field( + &mut class_private_names, + &mut brand_field_counter, + call, + loc, + )); + } } if prop.flags.contains(Flags::Property::IsStatic) { static_non_field_elements.push(element); diff --git a/test/bundler/transpiler/es-decorators.test.ts b/test/bundler/transpiler/es-decorators.test.ts index f4519cc75548..6b15f8012853 100644 --- a/test/bundler/transpiler/es-decorators.test.ts +++ b/test/bundler/transpiler/es-decorators.test.ts @@ -1364,6 +1364,21 @@ describe("ES Decorators", () => { expect(exitCode).toBe(0); }); + test("decorated instance #-private method's brand is installed before a following undecorated #-private field reads it", async () => { + const { stdout, stderr, exitCode } = await runDecorator(` + function dec(v) { return v; } + class C { + @dec #m() { return 1; } + #a = this.readM(); + readM() { return this.#m(); } + } + console.log(new C().readM()); + `); + expect(stderr).toBe(""); + expect(stdout).toBe("1\n"); + expect(exitCode).toBe(0); + }); + test("WeakMap-lowered #-private does not strip NamedEvaluation or new.target from sibling fields", async () => { const { stdout, stderr, exitCode } = await runDecorator(` function dec(v) { return v; } From 757cb309294f54ba20a86dbb61e4d3f30439a1f4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 13 Aug 2026 03:26:42 +0000 Subject: [PATCH 06/17] Make symbol::Kind::is_private pub again for the storage-name scan --- src/ast/symbol.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ast/symbol.rs b/src/ast/symbol.rs index 699462bae261..400f50f441e3 100644 --- a/src/ast/symbol.rs +++ b/src/ast/symbol.rs @@ -317,7 +317,7 @@ pub enum Kind { impl Kind { #[inline] - pub(crate) fn is_private(self) -> bool { + pub fn is_private(self) -> bool { (self as u8) >= (Kind::PrivateField as u8) && (self as u8) <= (Kind::PrivateStaticGetSetPair as u8) } From 7dca5a5bd7bc4ed2ba181343c46055cd3f80785e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 13 Aug 2026 05:49:37 +0000 Subject: [PATCH 07/17] Hoist the __privateAdd construction above the static/instance branch --- src/js_parser/lower/lower_decorators.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/js_parser/lower/lower_decorators.rs b/src/js_parser/lower/lower_decorators.rs index e237a997c26c..a34cf8405b89 100644 --- a/src/js_parser/lower/lower_decorators.rs +++ b/src/js_parser/lower/lower_decorators.rs @@ -1521,15 +1521,12 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // __privateAdd (once per name) if !emitted_private_adds.contains_key(&npriv_inner) { emitted_private_adds.insert(npriv_inner, ()); + let t = p.new_expr(E::This {}, loc); + let s = p.use_ref(ws_ref, loc); + let call = p.call_rt(loc, b"__privateAdd", &[t, s]); if prop.flags.contains(Flags::Property::IsStatic) { - let t = p.new_expr(E::This {}, loc); - let s = p.use_ref(ws_ref, loc); - let call = p.call_rt(loc, b"__privateAdd", &[t, s]); static_private_add_blocks.push(p.make_static_block(call, loc)); } else { - let t = p.new_expr(E::This {}, loc); - let s = p.use_ref(ws_ref, loc); - let call = p.call_rt(loc, b"__privateAdd", &[t, s]); new_properties.push(p.inline_brand_field( &mut class_private_names, &mut brand_field_counter, From 36c340b2337c5d3c0a022acdfe9ef4ec0771ec77 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 13 Aug 2026 07:03:56 +0000 Subject: [PATCH 08/17] Rewrite lowered private accesses inside undecorated initializers Phase 5 visited method bodies and static blocks in new_properties but not field initializers, so a direct this.#a in an undecorated field, accessor storage or synthetic brand field (and C.#a inside a static brand block, which is spliced in after Phase 5) printed an undeclared private name. With the brand-adds now on the class-body timeline the rewritten __privateGet evaluates after its __privateAdd, so visit those too. --- src/js_parser/lower/lower_decorators.rs | 8 ++++- test/bundler/transpiler/es-decorators.test.ts | 35 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/js_parser/lower/lower_decorators.rs b/src/js_parser/lower/lower_decorators.rs index a34cf8405b89..8457f2baa7c4 100644 --- a/src/js_parser/lower/lower_decorators.rs +++ b/src/js_parser/lower/lower_decorators.rs @@ -2020,10 +2020,16 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // ── Phase 5: Rewrite private accesses ──────────── if !private_lowered_map.is_empty() { - for nprop in new_properties.iter_mut() { + for nprop in new_properties + .iter_mut() + .chain(static_private_add_blocks.iter_mut()) + { if let Some(v) = &mut nprop.value { p.rewrite_private_accesses_in_expr(v, &private_lowered_map); } + if let Some(ini) = &mut nprop.initializer { + p.rewrite_private_accesses_in_expr(ini, &private_lowered_map); + } if let Some(sb) = nprop.class_static_block_mut() { p.rewrite_private_accesses_in_stmts(sb.stmts.slice_mut(), &private_lowered_map); } diff --git a/test/bundler/transpiler/es-decorators.test.ts b/test/bundler/transpiler/es-decorators.test.ts index 6b15f8012853..9a140d6ab65c 100644 --- a/test/bundler/transpiler/es-decorators.test.ts +++ b/test/bundler/transpiler/es-decorators.test.ts @@ -1379,6 +1379,41 @@ describe("ES Decorators", () => { expect(exitCode).toBe(0); }); + test("undecorated field, accessor and #-field initializers may read a WeakMap-lowered #-private directly", async () => { + const { stdout, stderr, exitCode } = await runDecorator(` + function dec(v) { return v; } + class C { + @dec m() {} + #a = 1; + b = this.#a; + accessor c = this.#a + 10; + #d = this.#a + 100; + readD() { return this.#d; } + } + const o = new C(); + console.log(o.b, o.c, o.readD()); + `); + expect(stderr).toBe(""); + expect(stdout).toBe("1 11 101\n"); + expect(exitCode).toBe(0); + }); + + test("undecorated static #-field initializer may read a WeakMap-lowered static #-private directly", async () => { + const { stdout, stderr, exitCode } = await runDecorator(` + function dec(v) { return v; } + class C { + @dec m() {} + static #a = 1; + static #b = C.#a + 1; + static readB() { return C.#b; } + } + console.log(C.readB()); + `); + expect(stderr).toBe(""); + expect(stdout).toBe("2\n"); + expect(exitCode).toBe(0); + }); + test("WeakMap-lowered #-private does not strip NamedEvaluation or new.target from sibling fields", async () => { const { stdout, stderr, exitCode } = await runDecorator(` function dec(v) { return v; } From 6b1619085189f8ba1388c6019f5529c6fde6f28e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 13 Aug 2026 07:49:31 +0000 Subject: [PATCH 09/17] Evaluate computed accessor keys once and scope the private-name scan The synthesized get/set pair for an undecorated accessor each carried the original computed key, so it was evaluated twice. Capture it in a temp at the getter's key position (get [_k = expr] / set [_k]) so it runs once, in class-body order, and both members share the result. Seed the generated-name collision set from this class's own private keys plus the members of enclosing ClassBody scopes instead of walking the whole symbol table, which also drops the symbol::Kind::is_private visibility change. Tests for the remaining key shapes: static private-name accessors, non-identifier string keys (the old lowering emitted an invalid identifier for these) and computed keys. --- src/ast/symbol.rs | 2 +- src/js_parser/lower/lower_decorators.rs | 44 ++++++++++++--- test/bundler/transpiler/es-decorators.test.ts | 53 +++++++++++++++++++ 3 files changed, 92 insertions(+), 7 deletions(-) diff --git a/src/ast/symbol.rs b/src/ast/symbol.rs index 400f50f441e3..699462bae261 100644 --- a/src/ast/symbol.rs +++ b/src/ast/symbol.rs @@ -317,7 +317,7 @@ pub enum Kind { impl Kind { #[inline] - pub fn is_private(self) -> bool { + pub(crate) fn is_private(self) -> bool { (self as u8) >= (Kind::PrivateField as u8) && (self as u8) <= (Kind::PrivateStaticGetSetPair as u8) } diff --git a/src/js_parser/lower/lower_decorators.rs b/src/js_parser/lower/lower_decorators.rs index 8457f2baa7c4..6076d569ef0f 100644 --- a/src/js_parser/lower/lower_decorators.rs +++ b/src/js_parser/lower/lower_decorators.rs @@ -1440,12 +1440,33 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } - // Every `#`-name already parsed (this class or an enclosing one). + // `#`-names a generated private must not shadow: this class's own and + // those of every lexically enclosing class. let mut class_private_names: HashMap<&'a [u8], ()> = HashMap::default(); - for sym in p.symbols.iter() { - if sym.kind.is_private() { - class_private_names.insert(sym.original_name.slice(), ()); + for cprop in class.properties.slice() { + if let Some(k) = cprop.key + && let js_ast::ExprData::EPrivateIdentifier(pi) = &k.data + { + let name: &'a [u8] = p.symbols[pi.ref_.inner_index() as usize] + .original_name + .slice(); + class_private_names.insert(name, ()); + } + } + let mut scope = p.current_scope; + loop { + if scope.kind == js_ast::scope::Kind::ClassBody { + for member in scope.members.values() { + let name: &'a [u8] = p.symbols[member.ref_.inner_index() as usize] + .original_name + .slice(); + if name.first() == Some(&b'#') { + class_private_names.insert(name, ()); + } + } } + let Some(parent) = scope.parent else { break }; + scope = parent; } let mut brand_field_counter: usize = 0; @@ -1673,17 +1694,28 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O ..Default::default() }; + // A computed key is evaluated once, by the getter's key + // position: `get [_k = expr]() {}` / `set [_k](v) {}`. + let (getter_key, setter_key) = match prop.key { + Some(key) if prop.flags.contains(Flags::Property::IsComputed) => { + let key_ref = p.generate_temp_ref(Some(b"_computedKey")); + let captured = p.assign_to(key_ref, key, key.loc); + (Some(captured), Some(p.use_ref(key_ref, key.loc))) + } + key => (key, key), + }; + let mut getter_flags = prop.flags; getter_flags.insert(Flags::Property::IsMethod); new_properties.push(Property { - key: prop.key, + key: getter_key, value: Some(p.new_expr(E::Function { func: get_fn }, loc)), kind: PropertyKind::Get, flags: getter_flags, ..Default::default() }); new_properties.push(Property { - key: prop.key, + key: setter_key, value: Some(p.new_expr(E::Function { func: set_fn }, loc)), kind: PropertyKind::Set, flags: getter_flags, diff --git a/test/bundler/transpiler/es-decorators.test.ts b/test/bundler/transpiler/es-decorators.test.ts index 9a140d6ab65c..bf816788b5e9 100644 --- a/test/bundler/transpiler/es-decorators.test.ts +++ b/test/bundler/transpiler/es-decorators.test.ts @@ -1345,6 +1345,59 @@ describe("ES Decorators", () => { expect(exitCode).toBe(0); }); + test("static accessor with a private-name key round-trips", async () => { + const { stdout, stderr, exitCode } = await runDecorator(` + class C { + static accessor #z = 5; + static get z() { return C.#z; } + static set z(v) { C.#z = v; } + } + console.log(C.z); + C.z = 50; + console.log(C.z); + `); + expect(stderr).toBe(""); + expect(stdout).toBe("5\n50\n"); + expect(exitCode).toBe(0); + }); + + test("accessor with a non-identifier string key round-trips", async () => { + const { stdout, stderr, exitCode } = await runDecorator(` + class C { + accessor "a-b" = 1; + static accessor "c d" = 2; + } + const c = new C(); + c["a-b"] = 10; + C["c d"] = 20; + console.log(c["a-b"], C["c d"]); + `); + expect(stderr).toBe(""); + expect(stdout).toBe("10 20\n"); + expect(exitCode).toBe(0); + }); + + test("computed accessor key is evaluated once, in source order, and shared by get and set", async () => { + const { stdout, stderr, exitCode } = await runDecorator(` + const order = []; + const k = (name) => (order.push(name), name); + class C { + [k("m")]() {} + accessor [k("a")] = 1; + static accessor [k("s")] = 2; + } + console.log(order.join(",")); + const c = new C(); + c.a = 10; + C.s = 20; + const d = Object.getOwnPropertyDescriptor(C.prototype, "a"); + console.log(c.a, C.s, typeof d.get, typeof d.set); + `); + expect(stderr).toBe(""); + expect(stdout).toBe("m,a,s\n10 20 function function\n"); + expect(exitCode).toBe(0); + }); + test("instance fields run in source order when sibling #-privates are WeakMap-lowered", async () => { const { stdout, stderr, exitCode } = await runDecorator(` function dec(v) { return v; } From 47688a50076457b69eb138a994e4f2107a5c03e7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 13 Aug 2026 07:53:41 +0000 Subject: [PATCH 10/17] Shorten two comments --- src/js_parser/lower/lower_decorators.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/js_parser/lower/lower_decorators.rs b/src/js_parser/lower/lower_decorators.rs index 6076d569ef0f..21b1644d71eb 100644 --- a/src/js_parser/lower/lower_decorators.rs +++ b/src/js_parser/lower/lower_decorators.rs @@ -1440,8 +1440,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } - // `#`-names a generated private must not shadow: this class's own and - // those of every lexically enclosing class. + // `#`-names in scope here, which a generated private must not shadow. let mut class_private_names: HashMap<&'a [u8], ()> = HashMap::default(); for cprop in class.properties.slice() { if let Some(k) = cprop.key @@ -1694,8 +1693,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O ..Default::default() }; - // A computed key is evaluated once, by the getter's key - // position: `get [_k = expr]() {}` / `set [_k](v) {}`. + // `get [_k = expr]()` / `set [_k](v)`: the key runs once. let (getter_key, setter_key) = match prop.key { Some(key) if prop.flags.contains(Flags::Property::IsComputed) => { let key_ref = p.generate_temp_ref(Some(b"_computedKey")); From dce30b2f285ea9d03774ee4417d90a4917bae792 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 08:02:40 +0000 Subject: [PATCH 11/17] Guard the decorated accessor storage name against non-identifier keys Same fix as the undecorated arm: a string key like "a-b" otherwise becomes an invalid `var _a-b` binding and the output fails to parse. --- src/js_parser/lower/lower_decorators.rs | 1 + test/bundler/transpiler/es-decorators.test.ts | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/js_parser/lower/lower_decorators.rs b/src/js_parser/lower/lower_decorators.rs index 21b1644d71eb..5e13fe436fff 100644 --- a/src/js_parser/lower/lower_decorators.rs +++ b/src/js_parser/lower/lower_decorators.rs @@ -1865,6 +1865,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let accessor_name: &'a [u8] = 'brk: { if let js_ast::ExprData::EString(s) = &key_expr.data && s.is_utf8() + && js_lexer::is_identifier(&s.data) { break 'brk p.bump_name2(b"_", &s.data); } diff --git a/test/bundler/transpiler/es-decorators.test.ts b/test/bundler/transpiler/es-decorators.test.ts index bf816788b5e9..c4d9b8214a3c 100644 --- a/test/bundler/transpiler/es-decorators.test.ts +++ b/test/bundler/transpiler/es-decorators.test.ts @@ -1377,6 +1377,24 @@ describe("ES Decorators", () => { expect(exitCode).toBe(0); }); + test("decorated accessor with a non-identifier string key round-trips", async () => { + const { stdout, stderr, exitCode } = await runDecorator(` + const seen = []; + function dec(target, ctx) { seen.push(ctx.name); } + class C { + @dec accessor "a-b" = 1; + @dec static accessor "c d" = 2; + } + const c = new C(); + c["a-b"] = 10; + C["c d"] = 20; + console.log(seen.sort().join(","), c["a-b"], C["c d"]); + `); + expect(stderr).toBe(""); + expect(stdout).toBe("a-b,c d 10 20\n"); + expect(exitCode).toBe(0); + }); + test("computed accessor key is evaluated once, in source order, and shared by get and set", async () => { const { stdout, stderr, exitCode } = await runDecorator(` const order = []; From 59eb4a07f355d9f1135e3f213c86221543771c56 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 08:35:28 +0000 Subject: [PATCH 12/17] Install lowered instance method brands before any field initializer InitializeInstanceElements adds every private method and accessor brand before running field initializers, so a field that textually precedes the #-method it calls must still work. Route all three brand-add sites back through one emit_private_add helper: static brands go to a static block as before, instance method/getter/setter brands are collected and placed ahead of the class body's fields, and instance field brands keep their source position because they carry the initializer. --- src/js_parser/lower/lower_decorators.rs | 113 +++++++++++------- test/bundler/transpiler/es-decorators.test.ts | 22 ++++ 2 files changed, 95 insertions(+), 40 deletions(-) diff --git a/src/js_parser/lower/lower_decorators.rs b/src/js_parser/lower/lower_decorators.rs index 5e13fe436fff..50359c5ade27 100644 --- a/src/js_parser/lower/lower_decorators.rs +++ b/src/js_parser/lower/lower_decorators.rs @@ -356,7 +356,40 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O name } - /// Synthetic `#__N = void ` class-body field at a lowered private's position. + /// Emit the `__privateAdd(this, storage[, init])` that installs a lowered + /// private. Static ones run from a static block. Instance method brands + /// (`init` is `None`) are collected in `method_brands`, which the caller + /// places ahead of every field, as InitializeInstanceElements does; + /// instance field brands carry their initializer and stay at their source + /// position in `fields`. + fn emit_private_add( + &mut self, + is_static: bool, + storage_ref: Ref, + init: Option, + loc: bun_ast::Loc, + used: &mut HashMap<&'a [u8], ()>, + counter: &mut usize, + static_blocks: &mut BumpVec<'a, Property>, + method_brands: &mut BumpVec<'a, Property>, + fields: &mut BumpVec<'a, Property>, + ) { + let target = self.new_expr(E::This {}, loc); + let storage = self.use_ref(storage_ref, loc); + let is_field = init.is_some(); + let call = match init { + Some(v) => self.call_rt(loc, b"__privateAdd", &[target, storage, v]), + None => self.call_rt(loc, b"__privateAdd", &[target, storage]), + }; + if is_static { + static_blocks.push(self.make_static_block(call, loc)); + } else { + let field = self.inline_brand_field(used, counter, call, loc); + if is_field { fields } else { method_brands }.push(field); + } + } + + /// Synthetic `#__N = void ` class-body field. fn inline_brand_field( &mut self, used: &mut HashMap<&'a [u8], ()>, @@ -1404,6 +1437,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let mut accessor_storage_counter: usize = 0; let mut emitted_private_adds: HashMap = HashMap::default(); let mut static_private_add_blocks = BumpVec::::new_in(bump); + let mut instance_method_brands = BumpVec::::new_in(bump); // Pre-scan: determine if all private members need lowering. let mut lower_all_private = false; @@ -1541,19 +1575,17 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // __privateAdd (once per name) if !emitted_private_adds.contains_key(&npriv_inner) { emitted_private_adds.insert(npriv_inner, ()); - let t = p.new_expr(E::This {}, loc); - let s = p.use_ref(ws_ref, loc); - let call = p.call_rt(loc, b"__privateAdd", &[t, s]); - if prop.flags.contains(Flags::Property::IsStatic) { - static_private_add_blocks.push(p.make_static_block(call, loc)); - } else { - new_properties.push(p.inline_brand_field( - &mut class_private_names, - &mut brand_field_counter, - call, - loc, - )); - } + p.emit_private_add( + prop.flags.contains(Flags::Property::IsStatic), + ws_ref, + None, + loc, + &mut class_private_names, + &mut brand_field_counter, + &mut static_private_add_blocks, + &mut instance_method_brands, + &mut new_properties, + ); } continue; } else { @@ -1567,19 +1599,17 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let init_val = prop .initializer .unwrap_or_else(|| p.new_expr(E::Undefined {}, loc)); - let this_e = p.new_expr(E::This {}, loc); - let wm_e = p.use_ref(wm_ref, loc); - let call = p.call_rt(loc, b"__privateAdd", &[this_e, wm_e, init_val]); - if prop.flags.contains(Flags::Property::IsStatic) { - static_private_add_blocks.push(p.make_static_block(call, loc)); - } else { - new_properties.push(p.inline_brand_field( - &mut class_private_names, - &mut brand_field_counter, - call, - loc, - )); - } + p.emit_private_add( + prop.flags.contains(Flags::Property::IsStatic), + wm_ref, + Some(init_val), + loc, + &mut class_private_names, + &mut brand_field_counter, + &mut static_private_add_blocks, + &mut instance_method_brands, + &mut new_properties, + ); continue; } } @@ -2017,19 +2047,17 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O }; if !emitted_private_adds.contains_key(&priv_inner2) { emitted_private_adds.insert(priv_inner2, ()); - let t = p.new_expr(E::This {}, loc); - let s = p.use_ref(private_storage_ref.unwrap(), loc); - let call = p.call_rt(loc, b"__privateAdd", &[t, s]); - if prop.flags.contains(Flags::Property::IsStatic) { - static_private_add_blocks.push(p.make_static_block(call, loc)); - } else { - new_properties.push(p.inline_brand_field( - &mut class_private_names, - &mut brand_field_counter, - call, - loc, - )); - } + p.emit_private_add( + prop.flags.contains(Flags::Property::IsStatic), + private_storage_ref.unwrap(), + None, + loc, + &mut class_private_names, + &mut brand_field_counter, + &mut static_private_add_blocks, + &mut instance_method_brands, + &mut new_properties, + ); } if prop.flags.contains(Flags::Property::IsStatic) { static_non_field_elements.push(element); @@ -2049,6 +2077,11 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } + if !instance_method_brands.is_empty() { + instance_method_brands.extend(new_properties.drain(..)); + new_properties = instance_method_brands; + } + // ── Phase 5: Rewrite private accesses ──────────── if !private_lowered_map.is_empty() { for nprop in new_properties diff --git a/test/bundler/transpiler/es-decorators.test.ts b/test/bundler/transpiler/es-decorators.test.ts index c4d9b8214a3c..e91e66f8ad2d 100644 --- a/test/bundler/transpiler/es-decorators.test.ts +++ b/test/bundler/transpiler/es-decorators.test.ts @@ -1450,6 +1450,28 @@ describe("ES Decorators", () => { expect(exitCode).toBe(0); }); + test("lowered #-method and #-getter brands are installed before any field, while #-field brands keep source order", async () => { + const { stdout, stderr, exitCode } = await runDecorator(` + function dec(v) { return v; } + const log = []; + class C { + @dec y; + a = (log.push("a"), this.#m() + this.#g); + #f = (log.push("f"), 10); + b = (log.push("b"), this.#f); + #m() { return 1; } + get #g() { return 100; } + @dec #dm() { return 1000; } + c = this.#dm(); + } + const o = new C(); + console.log(log.join(","), o.a, o.b, o.c); + `); + expect(stderr).toBe(""); + expect(stdout).toBe("a,f,b 101 10 1000\n"); + expect(exitCode).toBe(0); + }); + test("undecorated field, accessor and #-field initializers may read a WeakMap-lowered #-private directly", async () => { const { stdout, stderr, exitCode } = await runDecorator(` function dec(v) { return v; } From 80b1915f468776045df32010f5610f15708aebf2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 08:37:46 +0000 Subject: [PATCH 13/17] Shorten emit_private_add doc comment --- src/js_parser/lower/lower_decorators.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/js_parser/lower/lower_decorators.rs b/src/js_parser/lower/lower_decorators.rs index 50359c5ade27..e6fcd079554d 100644 --- a/src/js_parser/lower/lower_decorators.rs +++ b/src/js_parser/lower/lower_decorators.rs @@ -356,12 +356,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O name } - /// Emit the `__privateAdd(this, storage[, init])` that installs a lowered - /// private. Static ones run from a static block. Instance method brands - /// (`init` is `None`) are collected in `method_brands`, which the caller - /// places ahead of every field, as InitializeInstanceElements does; - /// instance field brands carry their initializer and stay at their source - /// position in `fields`. + /// `__privateAdd(this, storage[, init])` for a lowered private. Instance + /// method brands (`init == None`) go to `method_brands`, which the caller + /// puts before every field per InitializeInstanceElements. fn emit_private_add( &mut self, is_static: bool, From ed4c6f32dc90a0422cef89992ee286a4df3d02db Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 08:38:53 +0000 Subject: [PATCH 14/17] One-line emit_private_add doc comment --- src/js_parser/lower/lower_decorators.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/js_parser/lower/lower_decorators.rs b/src/js_parser/lower/lower_decorators.rs index e6fcd079554d..9c054f1af83b 100644 --- a/src/js_parser/lower/lower_decorators.rs +++ b/src/js_parser/lower/lower_decorators.rs @@ -356,9 +356,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O name } - /// `__privateAdd(this, storage[, init])` for a lowered private. Instance - /// method brands (`init == None`) go to `method_brands`, which the caller - /// puts before every field per InitializeInstanceElements. + /// Method brands (`init == None`) go to `method_brands`, which run before every field. fn emit_private_add( &mut self, is_static: bool, From e41c6ab67d65fe4db060096e8235f7cd204cbc28 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 09:55:19 +0000 Subject: [PATCH 15/17] Expand undecorated accessors into field + get/set before lowering An undecorated accessor is now rewritten up front into the three class elements it denotes (a #_accessor_storage field and a getter/setter pair over it) and those flow through the ordinary field and method arms. Outside lower_all_private this emits exactly what the dedicated arm did; inside it, a private-keyed accessor is now WeakMap/WeakSet-lowered and registered like every other private, so a sibling #-method body hoisted out of the class can still reference it. The expansion runs before Phase 2 because later phases index class.properties by position, and after the lower_all_private scan so synthesized storage fields do not put a class into lowering mode by themselves. --- src/js_parser/lower/lower_decorators.rs | 438 ++++++++++-------- test/bundler/transpiler/es-decorators.test.ts | 21 + 2 files changed, 256 insertions(+), 203 deletions(-) diff --git a/src/js_parser/lower/lower_decorators.rs b/src/js_parser/lower/lower_decorators.rs index 9c054f1af83b..da47b9bec35f 100644 --- a/src/js_parser/lower/lower_decorators.rs +++ b/src/js_parser/lower/lower_decorators.rs @@ -414,6 +414,151 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } + /// Expand an undecorated `accessor k = v` into the three class elements it + /// stands for: a `#k_accessor_storage = v` field plus a `get k`/`set k` + /// pair over it. They are then lowered (or kept native) like any other + /// field, getter and setter of the class. + fn expand_auto_accessor( + &mut self, + prop: &Property, + used: &mut HashMap<&'a [u8], ()>, + storage_counter: &mut usize, + loc: bun_ast::Loc, + out: &mut BumpVec<'a, Property>, + ) { + let bump = self.arena; + let is_static = prop.flags.contains(Flags::Property::IsStatic); + + let storage_base: &'a [u8] = 'brk: { + if let Some(k) = prop.key { + match &k.data { + js_ast::ExprData::EString(s) + if s.is_utf8() && js_lexer::is_identifier(&s.data) => + { + let mut v = BumpVec::::new_in(bump); + v.push(b'#'); + v.extend_from_slice(&s.data); + v.extend_from_slice(b"_accessor_storage"); + break 'brk v.into_bump_slice(); + } + js_ast::ExprData::EPrivateIdentifier(pi) => { + let orig: &'a [u8] = self.symbols[pi.ref_.inner_index() as usize] + .original_name + .slice(); + break 'brk self.bump_name2(orig, b"_accessor_storage"); + } + _ => {} + } + } + let name = self.bump_name(b"#_accessor_storage", Some(*storage_counter)); + *storage_counter += 1; + name + }; + let storage_name = self.fresh_private_name(used, storage_base); + let storage_kind = if is_static { + js_ast::symbol::Kind::PrivateStaticField + } else { + js_ast::symbol::Kind::PrivateField + }; + let storage_ref = self.new_sym(storage_kind, storage_name); + let storage_key = self.new_expr(E::PrivateIdentifier { ref_: storage_ref }, loc); + out.push(Property { + key: Some(storage_key), + initializer: prop.initializer, + kind: PropertyKind::Normal, + flags: if is_static { + Flags::Property::IsStatic.into() + } else { + js_ast::flags::PROPERTY_NONE + }, + ..Default::default() + }); + + // get k() { return this.#storage; } + let this_e = self.new_expr(E::This {}, loc); + let idx_e = self.new_expr(E::PrivateIdentifier { ref_: storage_ref }, loc); + let get_ret = self.new_expr( + E::Index { + target: this_e, + index: idx_e, + optional_chain: None, + }, + loc, + ); + let get_body = bump.alloc_slice_copy(&[self.s( + S::Return { + value: Some(get_ret), + }, + loc, + )]); + let get_fn = G::Fn { + body: G::FnBody { + stmts: bun_ast::StoreSlice::new_mut(get_body), + loc, + }, + ..Default::default() + }; + + // set k(v) { this.#storage = v; } + let setter_param_ref = self.new_sym(js_ast::symbol::Kind::Other, b"v"); + let this_e2 = self.new_expr(E::This {}, loc); + let idx_e2 = self.new_expr(E::PrivateIdentifier { ref_: storage_ref }, loc); + let lhs = self.new_expr( + E::Index { + target: this_e2, + index: idx_e2, + optional_chain: None, + }, + loc, + ); + let v_e = self.use_ref(setter_param_ref, loc); + let set_body = bump.alloc_slice_copy(&[Stmt::assign(lhs, v_e)]); + let setter_binding = self.b( + B::Identifier { + r#ref: setter_param_ref, + }, + loc, + ); + let setter_fn_args = bump.alloc(G::Arg { + binding: setter_binding, + ..Default::default() + }); + let set_fn = G::Fn { + args: bun_ast::StoreSlice::new_mut(core::slice::from_mut(setter_fn_args)), + body: G::FnBody { + stmts: bun_ast::StoreSlice::new_mut(set_body), + loc, + }, + ..Default::default() + }; + + // `get [_k = expr]()` / `set [_k](v)`: the key runs once. + let (getter_key, setter_key) = match prop.key { + Some(key) if prop.flags.contains(Flags::Property::IsComputed) => { + let key_ref = self.generate_temp_ref(Some(b"_computedKey")); + let captured = self.assign_to(key_ref, key, key.loc); + (Some(captured), Some(self.use_ref(key_ref, key.loc))) + } + key => (key, key), + }; + let mut method_flags = prop.flags; + method_flags.insert(Flags::Property::IsMethod); + out.push(Property { + key: getter_key, + value: Some(self.new_expr(E::Function { func: get_fn }, loc)), + kind: PropertyKind::Get, + flags: method_flags, + ..Default::default() + }); + out.push(Property { + key: setter_key, + value: Some(self.new_expr(E::Function { func: set_fn }, loc)), + kind: PropertyKind::Set, + flags: method_flags, + ..Default::default() + }); + } + /// Get the method kind code (1=method, 2=getter, 3=setter). fn method_kind(prop: &Property) -> u8 { match prop.kind { @@ -1237,6 +1382,96 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } + // Pre-scan: determine if all private members need lowering. + let mut lower_all_private = false; + { + let mut has_any_private = false; + let mut has_any_decorated = false; + let cprops: &[Property] = class.properties.slice(); + for cprop in cprops.iter() { + if cprop.kind == PropertyKind::ClassStaticBlock { + continue; + } + if cprop.ts_decorators.len_u32() > 0 { + has_any_decorated = true; + if cprop.key.is_some() + && matches!( + cprop.key.unwrap().data, + js_ast::ExprData::EPrivateIdentifier(_) + ) + { + lower_all_private = true; + } + } + if cprop.key.is_some() + && matches!( + cprop.key.unwrap().data, + js_ast::ExprData::EPrivateIdentifier(_) + ) + { + has_any_private = true; + } + } + if !lower_all_private && has_any_private && has_any_decorated { + lower_all_private = true; + } + } + + // `#`-names in scope here, which a generated private must not shadow. + let mut class_private_names: HashMap<&'a [u8], ()> = HashMap::default(); + for cprop in class.properties.slice() { + if let Some(k) = cprop.key + && let js_ast::ExprData::EPrivateIdentifier(pi) = &k.data + { + let name: &'a [u8] = p.symbols[pi.ref_.inner_index() as usize] + .original_name + .slice(); + class_private_names.insert(name, ()); + } + } + let mut scope = p.current_scope; + loop { + if scope.kind == js_ast::scope::Kind::ClassBody { + for member in scope.members.values() { + let name: &'a [u8] = p.symbols[member.ref_.inner_index() as usize] + .original_name + .slice(); + if name.first() == Some(&b'#') { + class_private_names.insert(name, ()); + } + } + } + let Some(parent) = scope.parent else { break }; + scope = parent; + } + + // Undecorated accessors become ordinary field + getter + setter + // elements before anything below indexes `class.properties`. + let mut accessor_storage_counter: usize = 0; + if class + .properties + .slice() + .iter() + .any(|cp| cp.kind == PropertyKind::AutoAccessor && cp.ts_decorators.len_u32() == 0) + { + let old: &[Property] = class.properties.slice(); + let mut expanded = BumpVec::::with_capacity_in(old.len() + 2, bump); + for cprop in old.iter() { + if cprop.kind == PropertyKind::AutoAccessor && cprop.ts_decorators.len_u32() == 0 { + p.expand_auto_accessor( + cprop, + &mut class_private_names, + &mut accessor_storage_counter, + loc, + &mut expanded, + ); + } else { + expanded.push(prop_full_copy(cprop)); + } + } + class.properties = bun_ast::StoreSlice::new_mut(expanded.into_bump_slice_mut()); + } + // ── Phase 2: Pre-evaluate decorators/keys ──────── let mut dec_counter: usize = 0; let mut class_dec_ref: Option = None; @@ -1429,73 +1664,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O BumpVec::>::new_in(bump); let mut prefix_stmts = BumpVec::::new_in(bump); let mut private_lowered_map: PrivateLoweredMap = PrivateLoweredMap::default(); - let mut accessor_storage_counter: usize = 0; let mut emitted_private_adds: HashMap = HashMap::default(); let mut static_private_add_blocks = BumpVec::::new_in(bump); let mut instance_method_brands = BumpVec::::new_in(bump); - - // Pre-scan: determine if all private members need lowering. - let mut lower_all_private = false; - { - let mut has_any_private = false; - let mut has_any_decorated = false; - let cprops: &[Property] = class.properties.slice(); - for cprop in cprops.iter() { - if cprop.kind == PropertyKind::ClassStaticBlock { - continue; - } - if cprop.ts_decorators.len_u32() > 0 { - has_any_decorated = true; - if cprop.key.is_some() - && matches!( - cprop.key.unwrap().data, - js_ast::ExprData::EPrivateIdentifier(_) - ) - { - lower_all_private = true; - } - } - if cprop.key.is_some() - && matches!( - cprop.key.unwrap().data, - js_ast::ExprData::EPrivateIdentifier(_) - ) - { - has_any_private = true; - } - } - if !lower_all_private && has_any_private && has_any_decorated { - lower_all_private = true; - } - } - - // `#`-names in scope here, which a generated private must not shadow. - let mut class_private_names: HashMap<&'a [u8], ()> = HashMap::default(); - for cprop in class.properties.slice() { - if let Some(k) = cprop.key - && let js_ast::ExprData::EPrivateIdentifier(pi) = &k.data - { - let name: &'a [u8] = p.symbols[pi.ref_.inner_index() as usize] - .original_name - .slice(); - class_private_names.insert(name, ()); - } - } - let mut scope = p.current_scope; - loop { - if scope.kind == js_ast::scope::Kind::ClassBody { - for member in scope.members.values() { - let name: &'a [u8] = p.symbols[member.ref_.inner_index() as usize] - .original_name - .slice(); - if name.first() == Some(&b'#') { - class_private_names.insert(name, ()); - } - } - } - let Some(parent) = scope.parent else { break }; - scope = parent; - } let mut brand_field_counter: usize = 0; let props_slice2: &mut [Property] = class.properties.slice_mut(); @@ -1506,7 +1677,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O && let Some(nk_expr) = prop.key && matches!(nk_expr.data, js_ast::ExprData::EPrivateIdentifier(_)) && prop.kind != PropertyKind::ClassStaticBlock - && prop.kind != PropertyKind::AutoAccessor { let npriv_ref = match &nk_expr.data { js_ast::ExprData::EPrivateIdentifier(pi) => pi.ref_, @@ -1608,144 +1778,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O continue; } } - // Undecorated auto-accessor → native `#`-storage field + get/set. - if prop.kind == PropertyKind::AutoAccessor { - let storage_base: &'a [u8] = 'brk: { - if let Some(k) = prop.key { - match &k.data { - js_ast::ExprData::EString(s) - if s.is_utf8() && js_lexer::is_identifier(&s.data) => - { - let mut v = BumpVec::::new_in(bump); - v.push(b'#'); - v.extend_from_slice(&s.data); - v.extend_from_slice(b"_accessor_storage"); - break 'brk v.into_bump_slice(); - } - js_ast::ExprData::EPrivateIdentifier(pi) => { - let orig: &'a [u8] = p.symbols[pi.ref_.inner_index() as usize] - .original_name - .slice(); - break 'brk p.bump_name2(orig, b"_accessor_storage"); - } - _ => {} - } - } - let name = - p.bump_name(b"#_accessor_storage", Some(accessor_storage_counter)); - accessor_storage_counter += 1; - name - }; - let storage_name = p.fresh_private_name(&mut class_private_names, storage_base); - - let is_static = prop.flags.contains(Flags::Property::IsStatic); - let storage_kind = if is_static { - js_ast::symbol::Kind::PrivateStaticField - } else { - js_ast::symbol::Kind::PrivateField - }; - let storage_ref = p.new_sym(storage_kind, storage_name); - let storage_key = p.new_expr(E::PrivateIdentifier { ref_: storage_ref }, loc); - - let storage_flags = if is_static { - Flags::Property::IsStatic.into() - } else { - js_ast::flags::PROPERTY_NONE - }; - new_properties.push(Property { - key: Some(storage_key), - initializer: prop.initializer, - kind: PropertyKind::Normal, - flags: storage_flags, - ..Default::default() - }); - - // get foo() { return this.#_foo; } - let this_e = p.new_expr(E::This {}, loc); - let idx_e = p.new_expr(E::PrivateIdentifier { ref_: storage_ref }, loc); - let get_ret = p.new_expr( - E::Index { - target: this_e, - index: idx_e, - optional_chain: None, - }, - loc, - ); - let get_body = bump.alloc_slice_copy(&[p.s( - S::Return { - value: Some(get_ret), - }, - loc, - )]); - let get_fn = G::Fn { - body: G::FnBody { - stmts: bun_ast::StoreSlice::new_mut(get_body), - loc, - }, - ..Default::default() - }; - - // set foo(v) { this.#_foo = v; } - let setter_param_ref = p.new_sym(js_ast::symbol::Kind::Other, b"v"); - let this_e2 = p.new_expr(E::This {}, loc); - let idx_e2 = p.new_expr(E::PrivateIdentifier { ref_: storage_ref }, loc); - let lhs = p.new_expr( - E::Index { - target: this_e2, - index: idx_e2, - optional_chain: None, - }, - loc, - ); - let v_e = p.use_ref(setter_param_ref, loc); - let set_body = bump.alloc_slice_copy(&[Stmt::assign(lhs, v_e)]); - let setter_binding = p.b( - B::Identifier { - r#ref: setter_param_ref, - }, - loc, - ); - let setter_fn_args = bump.alloc(G::Arg { - binding: setter_binding, - ..Default::default() - }); - let set_fn = G::Fn { - args: bun_ast::StoreSlice::new_mut(core::slice::from_mut(setter_fn_args)), - body: G::FnBody { - stmts: bun_ast::StoreSlice::new_mut(set_body), - loc, - }, - ..Default::default() - }; - - // `get [_k = expr]()` / `set [_k](v)`: the key runs once. - let (getter_key, setter_key) = match prop.key { - Some(key) if prop.flags.contains(Flags::Property::IsComputed) => { - let key_ref = p.generate_temp_ref(Some(b"_computedKey")); - let captured = p.assign_to(key_ref, key, key.loc); - (Some(captured), Some(p.use_ref(key_ref, key.loc))) - } - key => (key, key), - }; - - let mut getter_flags = prop.flags; - getter_flags.insert(Flags::Property::IsMethod); - new_properties.push(Property { - key: getter_key, - value: Some(p.new_expr(E::Function { func: get_fn }, loc)), - kind: PropertyKind::Get, - flags: getter_flags, - ..Default::default() - }); - new_properties.push(Property { - key: setter_key, - value: Some(p.new_expr(E::Function { func: set_fn }, loc)), - kind: PropertyKind::Set, - flags: getter_flags, - ..Default::default() - }); - continue; - } // Static blocks → extract to suffix if prop.kind == PropertyKind::ClassStaticBlock { if let Some(sb) = prop.class_static_block { diff --git a/test/bundler/transpiler/es-decorators.test.ts b/test/bundler/transpiler/es-decorators.test.ts index e91e66f8ad2d..e679f24100e5 100644 --- a/test/bundler/transpiler/es-decorators.test.ts +++ b/test/bundler/transpiler/es-decorators.test.ts @@ -1361,6 +1361,27 @@ describe("ES Decorators", () => { expect(exitCode).toBe(0); }); + test("private-name accessor is lowered with its siblings when the class WeakMap-lowers its privates", async () => { + // #helper's body is hoisted out of the class, so its this.#z must be + // rewritten along with every other lowered private. + const { stdout, stderr, exitCode } = await runDecorator(` + function dec(v) { return v; } + class C { + @dec m() {} + accessor #z = 5; + #helper() { return this.#z; } + bump() { this.#z = this.#z + 1; return this.#helper(); } + static accessor #s = 10; + static #sh() { return C.#s; } + static readS() { C.#s = C.#s * 2; return C.#sh(); } + } + console.log(new C().bump(), C.readS()); + `); + expect(stderr).toBe(""); + expect(stdout).toBe("6 20\n"); + expect(exitCode).toBe(0); + }); + test("accessor with a non-identifier string key round-trips", async () => { const { stdout, stderr, exitCode } = await runDecorator(` class C { From 240a8daf024ebbc0ca2beface1505c731cdcc520 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 09:57:29 +0000 Subject: [PATCH 16/17] Shorten two comments --- src/js_parser/lower/lower_decorators.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/js_parser/lower/lower_decorators.rs b/src/js_parser/lower/lower_decorators.rs index da47b9bec35f..08652ce1462e 100644 --- a/src/js_parser/lower/lower_decorators.rs +++ b/src/js_parser/lower/lower_decorators.rs @@ -414,10 +414,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } - /// Expand an undecorated `accessor k = v` into the three class elements it - /// stands for: a `#k_accessor_storage = v` field plus a `get k`/`set k` - /// pair over it. They are then lowered (or kept native) like any other - /// field, getter and setter of the class. + /// `accessor k = v` → a `#k_accessor_storage = v` field plus a `get k`/`set k` pair over it. fn expand_auto_accessor( &mut self, prop: &Property, @@ -1445,8 +1442,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O scope = parent; } - // Undecorated accessors become ordinary field + getter + setter - // elements before anything below indexes `class.properties`. + // Must precede Phase 2, which indexes `class.properties` by position. let mut accessor_storage_counter: usize = 0; if class .properties From 7db08ef496c6d753ae9e684ce689efcf9dad0f76 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:06:49 +0000 Subject: [PATCH 17/17] Keep lowered static field brand-adds at their source position Static field brands (accessor storage and #-fields) were collected and prepended to the class body, so under lowering a static accessor or #-field initializer ran before the static fields declared above it. Route them like instance field brands: a static block at the member's source position. Only method brands, static or instance, are hoisted, so the two brand lists collapse into one. --- src/js_parser/lower/lower_decorators.rs | 53 ++++++------------- test/bundler/transpiler/es-decorators.test.ts | 22 ++++++++ 2 files changed, 37 insertions(+), 38 deletions(-) diff --git a/src/js_parser/lower/lower_decorators.rs b/src/js_parser/lower/lower_decorators.rs index 08652ce1462e..e678d3ab5fce 100644 --- a/src/js_parser/lower/lower_decorators.rs +++ b/src/js_parser/lower/lower_decorators.rs @@ -356,7 +356,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O name } - /// Method brands (`init == None`) go to `method_brands`, which run before every field. + /// Method brands (`init == None`) go to `hoisted`, which runs before every field. fn emit_private_add( &mut self, is_static: bool, @@ -365,8 +365,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O loc: bun_ast::Loc, used: &mut HashMap<&'a [u8], ()>, counter: &mut usize, - static_blocks: &mut BumpVec<'a, Property>, - method_brands: &mut BumpVec<'a, Property>, + hoisted: &mut BumpVec<'a, Property>, fields: &mut BumpVec<'a, Property>, ) { let target = self.new_expr(E::This {}, loc); @@ -376,12 +375,12 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O Some(v) => self.call_rt(loc, b"__privateAdd", &[target, storage, v]), None => self.call_rt(loc, b"__privateAdd", &[target, storage]), }; - if is_static { - static_blocks.push(self.make_static_block(call, loc)); + let element = if is_static { + self.make_static_block(call, loc) } else { - let field = self.inline_brand_field(used, counter, call, loc); - if is_field { fields } else { method_brands }.push(field); - } + self.inline_brand_field(used, counter, call, loc) + }; + if is_field { fields } else { hoisted }.push(element); } /// Synthetic `#__N = void ` class-body field. @@ -1661,8 +1660,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let mut prefix_stmts = BumpVec::::new_in(bump); let mut private_lowered_map: PrivateLoweredMap = PrivateLoweredMap::default(); let mut emitted_private_adds: HashMap = HashMap::default(); - let mut static_private_add_blocks = BumpVec::::new_in(bump); - let mut instance_method_brands = BumpVec::::new_in(bump); + let mut hoisted_brands = BumpVec::::new_in(bump); let mut brand_field_counter: usize = 0; let props_slice2: &mut [Property] = class.properties.slice_mut(); @@ -1743,8 +1741,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O loc, &mut class_private_names, &mut brand_field_counter, - &mut static_private_add_blocks, - &mut instance_method_brands, + &mut hoisted_brands, &mut new_properties, ); } @@ -1767,8 +1764,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O loc, &mut class_private_names, &mut brand_field_counter, - &mut static_private_add_blocks, - &mut instance_method_brands, + &mut hoisted_brands, &mut new_properties, ); continue; @@ -2077,8 +2073,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O loc, &mut class_private_names, &mut brand_field_counter, - &mut static_private_add_blocks, - &mut instance_method_brands, + &mut hoisted_brands, &mut new_properties, ); } @@ -2100,17 +2095,14 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } - if !instance_method_brands.is_empty() { - instance_method_brands.extend(new_properties.drain(..)); - new_properties = instance_method_brands; + if !hoisted_brands.is_empty() { + hoisted_brands.extend(new_properties.drain(..)); + new_properties = hoisted_brands; } // ── Phase 5: Rewrite private accesses ──────────── if !private_lowered_map.is_empty() { - for nprop in new_properties - .iter_mut() - .chain(static_private_add_blocks.iter_mut()) - { + for nprop in new_properties.iter_mut() { if let Some(v) = &mut nprop.value { p.rewrite_private_accesses_in_expr(v, &private_lowered_map); } @@ -2546,21 +2538,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } - // Static private __privateAdd blocks at beginning - if !static_private_add_blocks.is_empty() { - let mut merged = BumpVec::::with_capacity_in( - static_private_add_blocks.len() + new_properties.len(), - bump, - ); - for sp in static_private_add_blocks.drain(..) { - merged.push(sp); - } - for np in new_properties.drain(..) { - merged.push(np); - } - new_properties = merged; - } - class.properties = bun_ast::StoreSlice::new_mut(new_properties.into_bump_slice_mut()); class.has_decorators = false; class.should_lower_standard_decorators = false; diff --git a/test/bundler/transpiler/es-decorators.test.ts b/test/bundler/transpiler/es-decorators.test.ts index e679f24100e5..099766b7f036 100644 --- a/test/bundler/transpiler/es-decorators.test.ts +++ b/test/bundler/transpiler/es-decorators.test.ts @@ -1493,6 +1493,28 @@ describe("ES Decorators", () => { expect(exitCode).toBe(0); }); + test("the same holds for static members: lowered static accessor storage and #-fields keep source order", async () => { + const { stdout, stderr, exitCode } = await runDecorator(` + function dec(v) { return v; } + const log = []; + const mk = (n, v) => (log.push(n), v); + class C { + @dec m() {} + #p; + static a = mk("a", 1); + static accessor b = mk("b", C.a); + static #c = mk("c", C.b + 1); + static d = mk("d", C.#c); + static #sm() { return 1; } + static e = mk("e", C.#sm()); + } + console.log(log.join(","), C.b, C.d, C.e); + `); + expect(stderr).toBe(""); + expect(stdout).toBe("a,b,c,d,e 1 2 1\n"); + expect(exitCode).toBe(0); + }); + test("undecorated field, accessor and #-field initializers may read a WeakMap-lowered #-private directly", async () => { const { stdout, stderr, exitCode } = await runDecorator(` function dec(v) { return v; }