From e581726490f1164369ff00ecd373de8f327f34ec Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:03:37 +0000 Subject: [PATCH 1/9] js_parser: let a decorated class body observe the class its decorators return With standard decorators, a class declaration's body resolved its own name to the class's immutable inner binding, so after `Foo = __decorateElement(...)` installed a replacement class, methods, getters and field initializers inside Foo still used the undecorated class (`static create() { return new Foo() }` built undecorated instances). Undecorated static fields also stayed in the class body, so they were initialized on the undecorated class before the class decorators ran. For class statements with class decorators, visit_class now resolves the body's references to a separate binding. The lowering declares it (`let _Foo;`) after the pre-evaluated element decorators and computed keys, so those still observe the TDZ, captures the class in a leading static block and reassigns it together with the class name (`Foo = _Foo = __decorateElement(...)`). When the body never names the class the binding is merged into the class name and nothing extra is emitted; this also drops the unconditional `let _Foo = Foo` the lowering used to append. The binding is recorded as a declared symbol so the bundler renames it per file. Classes with private static members keep the old resolution, since those members stay installed on the class as written. Undecorated public static fields of a class with class decorators are moved out of the body and defined with __publicField after the class decorators have been applied, in source order with the static blocks and decorated fields, with `this` (and, for class expressions, the class's own name) rewritten to the decorated class. Classes with private members keep their static fields in place because relocated initializers cannot use private names. --- src/js_parser/lower/lower_decorators.rs | 234 +++++++++-- src/js_parser/p.rs | 9 +- src/js_parser/visit/mod.rs | 40 +- src/js_parser/visit/visit_expr.rs | 2 +- src/js_parser/visit/visit_stmt.rs | 10 +- test/bundler/transpiler/es-decorators.test.ts | 364 ++++++++++++++++++ 6 files changed, 607 insertions(+), 52 deletions(-) diff --git a/src/js_parser/lower/lower_decorators.rs b/src/js_parser/lower/lower_decorators.rs index 77c2620dac5e..d50accc68a4d 100644 --- a/src/js_parser/lower/lower_decorators.rs +++ b/src/js_parser/lower/lower_decorators.rs @@ -51,6 +51,9 @@ struct FieldInitEntry { enum StaticElementKind { Block, FieldOrAccessor, + /// Undecorated public static field moved out of the body of a class that + /// has class decorators (index into `relocated_static_fields`). + PlainField, } #[derive(Clone, Copy)] @@ -139,6 +142,41 @@ fn can_be_class_binding_name(name: &[u8]) -> bool { && !is_eval_or_arguments(name) } +/// `static x = 1` / `static x;` with no decorators. When the class itself is +/// decorated these are initialized after the class decorators run (on whatever +/// class they returned), so they cannot stay in the class body. +#[inline] +fn is_plain_static_field(prop: &Property) -> bool { + prop.kind == PropertyKind::Normal + && prop.flags.contains(Flags::Property::IsStatic) + && !prop.flags.contains(Flags::Property::IsMethod) + && prop.ts_decorators.len_u32() == 0 + && prop.key.is_some() + && !has_private_key(prop) +} + +#[inline] +fn has_private_key(prop: &Property) -> bool { + matches!(prop.key, Some(key) if matches!(key.data, js_ast::ExprData::EPrivateIdentifier(_))) +} + +/// Whether a class statement with class decorators gets a binding of its own +/// for the body's references to its name (see `declare_inner_class_binding`). +/// +/// Private static members stay installed on the class as written, so a body +/// that reaches them through the class name (`Foo.#count`) has to keep naming +/// that class; pointing the name at a replacement returned by a decorator would +/// turn every such access into a brand-check TypeError. +pub(crate) fn wants_inner_class_binding(class: &G::Class) -> bool { + class.should_lower_standard_decorators + && class.ts_decorators.len_u32() > 0 + && !class + .properties + .slice() + .iter() + .any(|prop| prop.flags.contains(Flags::Property::IsStatic) && has_private_key(prop)) +} + // ── impl P ─────────────────────────────────────────────────────────────────── impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_ONLY> { @@ -172,6 +210,22 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O ref_ } + /// `_Foo`: what `class Foo`'s body refers to by its own name once lowered. + /// The name binding a class declaration creates for its body is immutable, + /// so after `Foo = __decorateElement(...)` it would still hold the + /// undecorated class; `lower_impl` instead declares this symbol (`let _Foo;`) + /// in the scope the class statement is in, which must be the current scope + /// here, and reassigns it along with `Foo`. Element decorator and computed + /// key expressions are evaluated before that declaration and so hit the TDZ + /// the spec gives them. + pub(crate) fn declare_inner_class_binding(&mut self, class_name_ref: Ref) -> Ref { + let class_name: &'a [u8] = self.symbols[class_name_ref.inner_index() as usize] + .original_name + .slice(); + let name = self.bump_name2(b"_", class_name); + self.new_sym(js_ast::symbol::Kind::Other, name) + } + /// Single var declaration statement. fn var_decl(&mut self, ref_: Ref, value: Option, l: bun_ast::Loc) -> Stmt { let binding = self.b(B::Identifier { r#ref: ref_ }, l); @@ -1064,9 +1118,13 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // ── Public API ─────────────────────────────────────── + /// `inner_class_ref` is the binding `visit_class` resolved the body's + /// references to the class name to (see `declare_inner_class_binding`), or + /// `Ref::NONE` when it left them on the class name itself. pub(crate) fn lower_standard_decorators_stmt( &mut self, stmt: Stmt, + inner_class_ref: Ref, out: &mut BumpVec<'a, Stmt>, ) { // Every call site is the visitStmt `s_class` branch. `Stmt` and the @@ -1078,7 +1136,15 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O js_ast::StmtData::SClass(c) => c, _ => unreachable!(), }; - self.lower_impl(&mut s_class.class, stmt.loc, None, false, Some(stmt), out); + self.lower_impl( + &mut s_class.class, + stmt.loc, + None, + false, + Some(stmt), + inner_class_ref, + out, + ); } pub(crate) fn lower_standard_decorators_expr( @@ -1089,7 +1155,15 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O ) -> Expr { let bump = self.arena; let mut out = BumpVec::::new_in(bump); - self.lower_impl(class, loc, name_from_context, true, None, &mut out); + self.lower_impl( + class, + loc, + name_from_context, + true, + None, + Ref::NONE, + &mut out, + ); if out.is_empty() { return self.new_expr(E::Missing {}, loc); } @@ -1109,6 +1183,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O name_from_context: Option<&'a [u8]>, is_expr: bool, original_stmt: Option, + visited_inner_class_ref: Ref, out: &mut BumpVec<'a, Stmt>, ) { let p = self; @@ -1156,15 +1231,15 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O class_name_loc = class.class_name.as_ref().unwrap().loc; } - let mut inner_class_ref: Ref = class_name_ref; - if !is_expr { - // SAFETY: original_name is arena-owned for 'a. - let cns: &'a [u8] = p.symbols[class_name_ref.inner_index() as usize] - .original_name - .slice(); - let name = p.bump_name2(b"_", cns); - inner_class_ref = p.new_sym(js_ast::symbol::Kind::Other, name); - } + // Statement mode only; class expressions keep their own (per-evaluation) + // name binding and redirect relocated code to `_class` instead. + let inner_class_ref: Ref = if is_expr { + class_name_ref + } else if visited_inner_class_ref.is_symbol() { + visited_inner_class_ref + } else { + p.declare_inner_class_binding(class_name_ref) + }; // `ExprNodeList = Vec` owns its // buffer, so this MUST be a real ownership transfer; the previous @@ -1175,6 +1250,12 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O bun_alloc::AstAlloc::take(&mut class.ts_decorators); let class_decorators_len = class_decorators.len_u32() as usize; + // Relocated initializers are printed outside the class body, where a + // `#name` access would be a syntax error, so a class with private + // members keeps its static fields in place. + let has_private_members = class.properties.slice().iter().any(has_private_key); + let relocate_static_fields = class_decorators_len > 0 && !has_private_members; + let init_ref = p.new_sym(js_ast::symbol::Kind::Other, b"_init"); if is_expr { let binding = p.b(B::Identifier { r#ref: init_ref }, loc); @@ -1268,7 +1349,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } if prop.flags.contains(Flags::Property::IsComputed) && prop.key.is_some() - && prop.ts_decorators.len_u32() > 0 + && (prop.ts_decorators.len_u32() > 0 + || (relocate_static_fields && is_plain_static_field(prop))) { computed_key_counter += 1; let key_name: &'a [u8] = if computed_key_counter == 1 { @@ -1315,8 +1397,22 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } - // For named class expressions: swap to expr_class_ref for suffix ops + // Must be read before the lowering itself starts referencing the binding. + let inner_binding_used = + !is_expr && p.symbols[inner_class_ref.inner_index() as usize].use_count_estimate > 0; + if !is_expr && !inner_binding_used { + // Either declared below or merged into the class name, so the symbol + // never prints as an undeclared `_Foo`. + p.symbols[inner_class_ref.inner_index() as usize] + .link + .set(class_name_ref); + } + + // For named class expressions: swap to expr_class_ref for suffix ops. + // Code relocated out of the body can no longer see the expression's own + // name binding, so it is redirected to `_class` as well. let mut original_class_name_for_decorator: Option<&'a [u8]> = None; + let mut relocated_name_rewrite: Option = None; if is_expr && !expr_class_is_anonymous && let Some(ecr) = expr_class_ref @@ -1327,6 +1423,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O .original_name .slice(), ); + relocated_name_rewrite = Some(RewriteKind::ReplaceRef { + old: class_name_ref, + new: ecr, + }); class_name_ref = ecr; class_name_loc = loc; } @@ -1385,6 +1485,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let mut static_init_entries = BumpVec::::new_in(bump); let mut instance_init_entries = BumpVec::::new_in(bump); let mut static_element_order = BumpVec::::new_in(bump); + let mut relocated_static_fields = BumpVec::::new_in(bump); let mut extracted_static_blocks = BumpVec::>::new_in(bump); let mut prefix_stmts = BumpVec::::new_in(bump); @@ -1661,6 +1762,14 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } continue; } + if relocate_static_fields && is_plain_static_field(prop) { + static_element_order.push(StaticElement { + kind: StaticElementKind::PlainField, + index: relocated_static_fields.len(), + }); + relocated_static_fields.push(prop_copy(prop)); + continue; + } new_properties.push(prop_full_copy(prop)); continue; } @@ -2086,7 +2195,13 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let cls_dec_list = ExprNodeList::from_bump_vec(cls_dec_args); let dec_call = p.call_runtime(loc, b"__decorateElement", cls_dec_list); - suffix_exprs.push(p.assign_to(class_name_ref, dec_call, class_name_loc)); + // `Foo = _Foo = __decorateElement(...)` + let decorated = if inner_binding_used { + p.assign_to(inner_class_ref, dec_call, class_name_loc) + } else { + dec_call + }; + suffix_exprs.push(p.assign_to(class_name_ref, decorated, class_name_loc)); } // 6: Static method extra initializers @@ -2197,6 +2312,41 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let c_e = p.use_ref(class_name_ref, class_name_loc); suffix_exprs.push(p.call_rt(loc, b"__runInitializers", &[i_e, n_e, c_e])); } + StaticElementKind::PlainField => { + // `__publicField(Foo, "x", init)` keeps the field syntax's + // [[Define]] semantics; a plain assignment would invoke + // inherited setters. + let field = &mut relocated_static_fields[elem.index]; + let key = field.key.expect("infallible: prop has key"); + let target = p.use_ref(class_name_ref, class_name_loc); + match field.initializer.as_mut() { + Some(init) => { + p.rewrite_expr( + init, + RewriteKind::ReplaceThis { + ref_: class_name_ref, + loc: class_name_loc, + }, + ); + if let Some(rewrite) = relocated_name_rewrite { + p.rewrite_expr(init, rewrite); + } + let init = *init; + suffix_exprs.push(p.call_rt( + key.loc, + b"__publicField", + &[target, key, init], + )); + } + None => { + suffix_exprs.push(p.call_rt( + key.loc, + b"__publicField", + &[target, key], + )); + } + } + } } } } @@ -2422,6 +2572,16 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O new_properties = merged; } + // `static { _Foo = this }`: static initializers left in the body (see + // `relocate_static_fields`) read the binding while the class is still + // being defined, before the suffix can assign it. + if inner_binding_used { + let this_e = p.new_expr(E::This {}, class_name_loc); + let capture = p.assign_to(inner_class_ref, this_e, class_name_loc); + let block = p.make_static_block(capture, class_name_loc); + new_properties.insert(0, block); + } + class.properties = bun_ast::StoreSlice::new_mut(new_properties.into_bump_slice_mut()); class.has_decorators = false; class.should_lower_standard_decorators = false; @@ -2552,34 +2712,23 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } out.extend_from_slice(&pre_eval_stmts); out.extend_from_slice(&prefix_stmts); - out.push(init_decl_stmt); - out.push(original_stmt.unwrap()); - for expr in suffix_exprs.iter() { - out.push(p.s( - S::SExpr { - value: *expr, - ..Default::default() - }, - expr.loc, - )); - } - // Inner class binding: let _Foo = Foo - if !inner_class_ref.eql(class_name_ref) { - p.record_usage(class_name_ref); + // `let _Foo;` comes after the pre-evaluated element decorators and + // computed keys (which must still hit its TDZ) and before the class + // statement whose leading static block assigns it. `let` rather than + // `var` so that a class statement in a loop body or function gets a + // fresh binding per evaluation, like the class binding itself. + if inner_binding_used { + p.record_declared_symbol(inner_class_ref); let binding = p.b( B::Identifier { r#ref: inner_class_ref, }, - loc, - ); - let value = Some(p.new_expr( - E::Identifier { - ref_: class_name_ref, - ..Default::default() - }, class_name_loc, - )); - let decls = DeclList::from_slice(&[G::Decl { binding, value }]); + ); + let decls = DeclList::from_slice(&[G::Decl { + binding, + value: None, + }]); out.push(p.s( S::Local { kind: S::Kind::KLet, @@ -2589,5 +2738,16 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O loc, )); } + out.push(init_decl_stmt); + out.push(original_stmt.unwrap()); + for expr in suffix_exprs.iter() { + out.push(p.s( + S::SExpr { + value: *expr, + ..Default::default() + }, + expr.loc, + )); + } } } diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index e14684e6a2f2..98e5d42d5b8d 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -6470,7 +6470,12 @@ fn path_package_name<'a>(path: &fs::Path<'a>) -> Option<&'a [u8]> { } impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_ONLY> { - pub(crate) fn lower_class(&mut self, stmtorexpr: js_ast::StmtOrExpr) -> &'a mut [Stmt] { + /// `inner_class_ref` is what `visit_class` returned for this class. + pub(crate) fn lower_class( + &mut self, + stmtorexpr: js_ast::StmtOrExpr, + inner_class_ref: Ref, + ) -> &'a mut [Stmt] { use js_ast::g::PropertyKind; match stmtorexpr { js_ast::StmtOrExpr::Stmt(stmt) => { @@ -6485,7 +6490,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // `lower_standard_decorators_stmt` takes an out-param Vec; wrap to // keep this function's slice contract. let mut out = BumpVec::::new_in(self.arena); - self.lower_standard_decorators_stmt(stmt, &mut out); + self.lower_standard_decorators_stmt(stmt, inner_class_ref, &mut out); return out.into_bump_slice_mut(); } diff --git a/src/js_parser/visit/mod.rs b/src/js_parser/visit/mod.rs index dd62b2fc2ed2..da3992fb2aa6 100644 --- a/src/js_parser/visit/mod.rs +++ b/src/js_parser/visit/mod.rs @@ -7,6 +7,7 @@ pub(crate) mod visit_expr; pub(crate) mod visit_stmt; use crate::lexer as js_lexer; +use crate::lower::lower_decorators::wants_inner_class_binding; use crate::p::{LowerUsingDeclarationsContext, P}; use crate::parser::{ ExprIn, FnOnlyDataVisit, FnOrArrowDataVisit, ImportItemForNamespaceMap, PrependTempRefsOpts, @@ -771,11 +772,16 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O self.stmts_to_single_stmt(stmt.loc, stmts.into_bump_slice_mut()) } + /// Returns the binding the class body's references to the class name were + /// resolved to when standard decorator lowering needs one of its own (see + /// `declare_inner_class_binding`); the caller hands it to `lower_class`. + /// `Ref::NONE` otherwise. pub(crate) fn visit_class( &mut self, name_scope_loc: bun_ast::Loc, class: &mut G::Class, default_name_ref: Ref, + is_stmt: bool, ) -> Ref { debug_assert!( !SCAN_ONLY, @@ -788,6 +794,15 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O self.record_declared_symbol(name.ref_); } + // Before the class name scope is pushed: the lowering declares this + // symbol in the scope containing the class statement. + let inner_class_ref = match class.class_name { + Some(name) if is_stmt && wants_inner_class_binding(class) => { + self.declare_inner_class_binding(name.ref_) + } + _ => Ref::NONE, + }; + self.push_scope_for_visit_pass(ScopeKind::ClassName, name_scope_loc) .expect("unreachable"); let old_enclosing_class_keyword = self.enclosing_class_keyword; @@ -808,18 +823,27 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // must be the original value of the name, not the re-assigned value. // Use "const" for this symbol to match JavaScript run-time semantics. You // are not allowed to assign to this symbol (it throws a TypeError). + // + // Unless class decorators are being lowered: they may replace the class, + // so the body then resolves to `inner_class_ref`, which the lowering + // keeps pointed at the decorated class (`declare_inner_class_binding`). if let Some(name) = class.class_name { let name_ref = name.ref_; shadow_ref.set(name_ref); let original_name: &'a [u8] = self.symbols[name_ref.inner_index() as usize] .original_name .slice(); + let body_ref = if inner_class_ref.is_symbol() { + inner_class_ref + } else { + name_ref + }; self.vis_scope() .members .put( original_name, ScopeMember { - ref_: name.ref_, + ref_: body_ref, loc: name.loc, }, ) @@ -1253,12 +1277,12 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O self.enclosing_class_keyword = old_enclosing_class_keyword; } - if self.symbols[shadow_ref.get().inner_index() as usize].use_count_estimate == 0 { - // If there was originally no class name but something inside needed one - // (e.g. there was a static property initializer that referenced "this"), - // store our generated name so the class expression ends up with a name. - shadow_ref.set(Ref::NONE); - } else if class.class_name.is_none() { + // If there was originally no class name but something inside needed one + // (e.g. there was a static property initializer that referenced "this"), + // store our generated name so the class expression ends up with a name. + if class.class_name.is_none() + && self.symbols[shadow_ref.get().inner_index() as usize].use_count_estimate > 0 + { let sr = shadow_ref.get(); class.class_name = Some(LocRef { ref_: sr, @@ -1270,7 +1294,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // class name scope self.pop_scope(); - shadow_ref.get() + inner_class_ref } // Try separating the list for appending, so that it's not a pointer. diff --git a/src/js_parser/visit/visit_expr.rs b/src/js_parser/visit/visit_expr.rs index 260a937e154b..a739c5992c10 100644 --- a/src/js_parser/visit/visit_expr.rs +++ b/src/js_parser/visit/visit_expr.rs @@ -2631,7 +2631,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let decorator_name_from_context = p.decorator_class_name; p.decorator_class_name = None; - let _ = p.visit_class(expr.loc, &mut e_, Ref::NONE); + let _ = p.visit_class(expr.loc, &mut e_, Ref::NONE, false); // Lower standard decorators for class expressions if e_.should_lower_standard_decorators { diff --git a/src/js_parser/visit/visit_stmt.rs b/src/js_parser/visit/visit_stmt.rs index dfce8bec1923..4691b3df404b 100644 --- a/src/js_parser/visit/visit_stmt.rs +++ b/src/js_parser/visit/visit_stmt.rs @@ -773,7 +773,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } StmtData::SClass(mut class_ref) => { let class: &mut S::Class = &mut *class_ref; - let _ = p.visit_class(s2_loc, &mut class.class, data.default_name.ref_); + let inner_class_ref = + p.visit_class(s2_loc, &mut class.class, data.default_name.ref_, true); if p.is_control_flow_dead { restore_dead!(); @@ -828,7 +829,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // Lower the class (handles both TS legacy and standard decorators). // Standard decorator lowering may produce prefix statements // (variable declarations) before the class statement. - let class_stmts = p.lower_class(js_ast::StmtOrExpr::Stmt(s2_copy)); + let class_stmts = + p.lower_class(js_ast::StmtOrExpr::Stmt(s2_copy), inner_class_ref); // Find the s_class statement in the returned list let mut class_stmt_idx: usize = 0; @@ -1054,7 +1056,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O p.is_control_flow_dead = true; } - let _ = p.visit_class(stmt.loc, &mut data.class, Ref::NONE); + let inner_class_ref = p.visit_class(stmt.loc, &mut data.class, Ref::NONE, true); // Remove the export flag inside a namespace let was_export_inside_namespace = data.is_export && p.enclosing_namespace_arg_ref.is_some(); @@ -1063,7 +1065,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } // Lower class field syntax for browsers that don't support it - let lowered = p.lower_class(js_ast::StmtOrExpr::Stmt(*stmt)); + let lowered = p.lower_class(js_ast::StmtOrExpr::Stmt(*stmt), inner_class_ref); if !mark_as_dead || was_export_inside_namespace { // Lower class field syntax for browsers that don't support it diff --git a/test/bundler/transpiler/es-decorators.test.ts b/test/bundler/transpiler/es-decorators.test.ts index bdc61e68c48d..cc452e7adc40 100644 --- a/test/bundler/transpiler/es-decorators.test.ts +++ b/test/bundler/transpiler/es-decorators.test.ts @@ -98,6 +98,370 @@ describe("ES Decorators", () => { }); }); + // A class decorator that returns a replacement class rebinds the class name + // inside the class body too (tsc: `_classThis`), and the class's static + // fields are initialized afterwards, on the replacement. + describe.concurrent("class body observes the class returned by a class decorator", () => { + const wrap = ` + function wrap(cls, ctx) { + return class Wrapped extends cls { static tag = "wrapped"; }; + } + const tag = x => x.tag ?? "original"; + `; + + test("methods, getters, instance fields and static fields see the replacement", async () => { + const { stdout, stderr, exitCode } = await runDecorator(` + ${wrap} + @wrap class Foo { + static create() { return new Foo(); } + static self = Foo; + static viaThis = this; + static tagWhenInitialized = String(Foo.tag); + whoAmI() { return Foo; } + get ctor() { return Foo; } + field = Foo; + } + console.log(JSON.stringify({ + outer: tag(Foo), + create: tag(Foo.create().constructor), + whoAmI: tag(new Foo().whoAmI()), + getter: tag(new Foo().ctor), + field: tag(new Foo().field), + self: tag(Foo.self), + viaThis: tag(Foo.viaThis), + tagWhenInitialized: Foo.tagWhenInitialized, + })); + `); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + outer: "wrapped", + create: "wrapped", + whoAmI: "wrapped", + getter: "wrapped", + field: "wrapped", + self: "wrapped", + viaThis: "wrapped", + tagWhenInitialized: "wrapped", + }); + expect(exitCode).toBe(0); + }); + + test("static fields are initialized after the class decorator, in order, on the replacement", async () => { + const { stdout, stderr, exitCode } = await runDecorator(` + const log = []; + let original; + function wrap(cls, ctx) { + log.push("decorator"); + original = cls; + return class Wrapped extends cls {}; + } + const receivers = []; + @wrap class Foo { + static a = (log.push("a"), 1); + static { log.push("static block"); } + static b = (receivers.push(this), 2); + static c; + } + log.push("after class"); + console.log(JSON.stringify({ + log, + ownKeys: Object.keys(Foo), + originalKeys: Object.keys(original), + receiverIsReplacement: receivers[0] === Foo && receivers[0] !== original, + values: [Foo.a, Foo.b, Foo.c], + })); + `); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + log: ["decorator", "a", "static block", "after class"], + ownKeys: ["a", "b", "c"], + originalKeys: [], + receiverIsReplacement: true, + values: [1, 2, null], + }); + expect(exitCode).toBe(0); + }); + + test("relocated static fields keep their keys, evaluated once and in source order", async () => { + const { stdout, stderr, exitCode } = await runDecorator(` + ${wrap} + const log = []; + const key = name => (log.push(name), name); + const methodDec = () => { + log.push("decorator expression"); + return (fn, ctx) => { log.push("decorator applied"); }; + }; + @wrap class Foo { + static [key("first")] = 1; + @methodDec() method() {} + static [key("second")] = 2; + static 0 = "zero"; + static "quoted key" = "quoted"; + } + console.log(JSON.stringify([log, Object.keys(Foo), Foo.first, Foo.second, Foo[0], Foo["quoted key"]])); + `); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual([ + ["first", "decorator expression", "second", "decorator applied"], + // "tag" is Wrapped's own static field; the relocated fields follow it. + ["0", "tag", "first", "second", "quoted key"], + 1, + 2, + "zero", + "quoted", + ]); + expect(exitCode).toBe(0); + }); + + test("relocated static fields define properties instead of invoking inherited setters", async () => { + const { stdout, stderr, exitCode } = await runDecorator(` + ${wrap} + const setterCalls = []; + class Base { + static set limit(value) { setterCalls.push(value); } + } + @wrap class Foo extends Base { + static limit = 10; + } + console.log(JSON.stringify([setterCalls, Object.getOwnPropertyDescriptor(Foo, "limit")])); + `); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual([[], { value: 10, writable: true, enumerable: true, configurable: true }]); + expect(exitCode).toBe(0); + }); + + test("each evaluation of a class statement gets its own binding", async () => { + const { stdout, stderr, exitCode } = await runDecorator(` + ${wrap} + function make() { + @wrap class Foo { + static create() { return new Foo(); } + } + return Foo; + } + const first = make(); + const second = make(); + const fromLoop = []; + for (let i = 0; i < 2; i++) { + @wrap class Bar { + static create() { return new Bar(); } + } + fromLoop.push(Bar); + } + console.log(JSON.stringify([ + first.create() instanceof first, + second.create() instanceof second, + first.create() instanceof second, + fromLoop[0].create() instanceof fromLoop[0], + fromLoop[0].create() instanceof fromLoop[1], + ])); + `); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual([true, true, false, true, false]); + expect(exitCode).toBe(0); + }); + + test("element decorator expressions see the TDZ, then the decorated class", async () => { + const { stdout, stderr, exitCode } = await runDecorator(` + ${wrap} + const captured = []; + function capture(fn) { + let threw = "did not throw"; + try { fn(); } catch (e) { threw = e.constructor.name; } + captured.push({ fn, threw }); + return () => {}; + } + @wrap class Foo { + @(capture(() => Foo)) method() {} + @(capture(() => Foo)) static [(capture(() => Foo), "computed")]() {} + } + console.log(JSON.stringify(captured.map(({ fn, threw }) => [threw, tag(fn())]))); + `); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual([ + ["ReferenceError", "wrapped"], + ["ReferenceError", "wrapped"], + ["ReferenceError", "wrapped"], + ]); + expect(exitCode).toBe(0); + }); + + test("class with instance private members: body sees the replacement, static fields stay in the body", async () => { + // Static initializers may use the private names, which only exist inside + // the body, so they are not relocated; they still observe the class + // (as written) through its name. + const { stdout, stderr, exitCode } = await runDecorator(` + ${wrap} + @wrap class Foo { + #secret = 42; + static create() { return new Foo(); } + static peek = foo => foo.#secret; + static self = Foo; + } + console.log(JSON.stringify([ + tag(Foo), + tag(Foo.create().constructor), + Foo.peek(Foo.create()), + typeof Foo.self, + ])); + `); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual(["wrapped", "wrapped", 42, "function"]); + expect(exitCode).toBe(0); + }); + + test("class with private static members keeps name-based private access working", async () => { + // Private statics are installed on the class as written, so in this case + // the name keeps referring to that class (esbuild's behavior): pointing + // it at the replacement would make `Foo.#instances` throw. + const { stdout, stderr, exitCode } = await runDecorator(` + ${wrap} + function keep(cls, ctx) {} + @wrap class Foo { + static #instances = 0; + static create() { Foo.#instances++; return new Foo(); } + static get instances() { return Foo.#instances; } + } + @keep class Bar { + static #count = 0; + #id = ++Bar.#count; + static made = Bar.#count; + static make() { return new Bar(); } + id() { return this.#id; } + } + Foo.create(); + Foo.create(); + console.log(JSON.stringify([tag(Foo), Foo.instances, Bar.make().id(), Bar.make().id(), Bar.made])); + `); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual(["wrapped", 2, 1, 2, 0]); + expect(exitCode).toBe(0); + }); + + test("class expression: static fields are initialized on the replacement", async () => { + const { stdout, stderr, exitCode } = await runDecorator(` + ${wrap} + const receivers = []; + const Foo = @wrap class Inner { + static self = Inner; + static viaThis = (receivers.push(this), 1); + static plain; + }; + console.log(JSON.stringify([tag(Foo), tag(Foo.self), receivers[0] === Foo, Object.keys(Foo)])); + `); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual(["wrapped", "wrapped", true, ["tag", "self", "viaThis", "plain"]]); + expect(exitCode).toBe(0); + }); + + test("exported and default-exported classes", async () => { + using dir = tempDir("es-dec-replacement-exports", { + "wrap.js": ` + export function wrap(cls, ctx) { + return class Wrapped extends cls {}; + } + `, + "named.js": ` + import { wrap } from "./wrap.js"; + @wrap export class Named { + static create() { return new Named(); } + static self = Named; + } + `, + "default.js": ` + import { wrap } from "./wrap.js"; + @wrap export default class Default { + static create() { return new Default(); } + static self = Default; + } + `, + "entry.js": ` + import { Named } from "./named.js"; + import Default from "./default.js"; + console.log(JSON.stringify([ + Named.create() instanceof Named, + Named.self === Named, + Default.create() instanceof Default, + Default.self === Default, + ])); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "entry.js"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + }); + + const [stdout, rawStderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(filterStderr(rawStderr)).toBe(""); + expect(JSON.parse(stdout)).toEqual([true, true, true, true]); + expect(exitCode).toBe(0); + }); + + test("Bun.build keeps the bindings of same-named classes from different files apart", async () => { + using dir = tempDir("es-dec-replacement-bundle", { + "wrap.js": ` + export const wrap = label => (cls, ctx) => class Wrapped extends cls { static label = label; }; + `, + "a.js": ` + import { wrap } from "./wrap.js"; + @wrap("a") export class Service { + static create() { return new Service(); } + } + `, + "b.js": ` + import { wrap } from "./wrap.js"; + @wrap("b") export class Service { + static create() { return new Service(); } + } + `, + "entry.js": ` + import { Service as A } from "./a.js"; + import { Service as B } from "./b.js"; + console.log(JSON.stringify([A.create().constructor.label, B.create().constructor.label])); + `, + "build.js": ` + for (const minify of [false, true]) { + const result = await Bun.build({ + entrypoints: ["./entry.js"], + outdir: "./out-" + minify, + target: "bun", + minify, + }); + if (!result.success) throw new AggregateError(result.logs, "build failed"); + await import(result.outputs[0].path); + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "build.js"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + }); + + const [stdout, rawStderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(filterStderr(rawStderr)).toBe(""); + expect(stdout).toBe('["a","b"]\n["a","b"]\n'); + expect(exitCode).toBe(0); + }); + + test("a class that never names itself gets no extra binding", () => { + const transpiler = new Bun.Transpiler({ loader: "js", target: "bun" }); + const output = transpiler.transformSync(` + @dec class Foo { + static create() { return new this(); } + method() {} + } + `); + expect(output).toContain("__decorateElement"); + expect(output).not.toContain("_Foo"); + }); + }); + describe("method decorators", () => { test("instance method decorator", async () => { const { stdout, stderr, exitCode } = await runDecorator(` From 56659dfa7ac579d64dcf89bc179012368cf31d3c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:12:17 +0000 Subject: [PATCH 2/9] js_parser: only relocate static initializers the rewriter fully covers Relocating an undecorated static field printed its initializer outside the class body verbatim, so `super.x`, `new.target`, private names, and `this` in positions rewrite_expr does not visit (arrow parameter defaults, computed object keys) either became syntax errors or lost their receiver. Relocation is now limited to expression shapes the rewriter walks completely (can_leave_class_body); anything else stays in the body as before. Relocating a field also hoists its computed key ahead of the class, which reordered it relative to the computed keys of members left in the body. When static fields are relocated, every computed key in the class is now pre-evaluated in source order (and relocation is skipped if one of them cannot be), which also keeps those keys observing the class binding's TDZ. --- src/js_parser/lower/lower_decorators.rs | 89 +++++++++-- test/bundler/transpiler/es-decorators.test.ts | 140 +++++++++++++++--- 2 files changed, 198 insertions(+), 31 deletions(-) diff --git a/src/js_parser/lower/lower_decorators.rs b/src/js_parser/lower/lower_decorators.rs index d50accc68a4d..6176b09e5114 100644 --- a/src/js_parser/lower/lower_decorators.rs +++ b/src/js_parser/lower/lower_decorators.rs @@ -52,7 +52,8 @@ enum StaticElementKind { Block, FieldOrAccessor, /// Undecorated public static field moved out of the body of a class that - /// has class decorators (index into `relocated_static_fields`). + /// has class decorators (index into `relocated_static_fields`); see + /// `can_leave_class_body`. PlainField, } @@ -144,7 +145,8 @@ fn can_be_class_binding_name(name: &[u8]) -> bool { /// `static x = 1` / `static x;` with no decorators. When the class itself is /// decorated these are initialized after the class decorators run (on whatever -/// class they returned), so they cannot stay in the class body. +/// class they returned), so they are moved out of the class body, provided +/// `can_leave_class_body` holds for the initializer. #[inline] fn is_plain_static_field(prop: &Property) -> bool { prop.kind == PropertyKind::Normal @@ -160,6 +162,63 @@ fn has_private_key(prop: &Property) -> bool { matches!(prop.key, Some(key) if matches!(key.data, js_ast::ExprData::EPrivateIdentifier(_))) } +/// Whether an undecorated static field initializer or computed key may be +/// printed outside the class body. Accepts only the shapes `rewrite_expr` +/// walks completely, so every `this` in them gets redirected, and none of +/// the syntax that only means something inside the body: `super`, +/// `new.target`, `#names`, and anything creating a function or class, whose +/// parameters, keys and heritage the rewriter does not visit. Everything else +/// stays in the class body and keeps behaving as it did before. +fn can_leave_class_body(expr: &Expr) -> bool { + use js_ast::ExprData as D; + match &expr.data { + D::EThis(_) + | D::EIdentifier(_) + | D::EImportIdentifier(_) + | D::EString(_) + | D::ENumber(_) + | D::EBigInt(_) + | D::EBoolean(_) + | D::ENull(_) + | D::EUndefined(_) + | D::ERegExp(_) + | D::EInlinedEnum(_) + | D::ERequireString(_) + | D::ERequireResolveString(_) + | D::EImportMeta(_) + | D::EMissing(_) => true, + D::EBinary(e) => can_leave_class_body(&e.left) && can_leave_class_body(&e.right), + D::ECall(e) => { + can_leave_class_body(&e.target) && e.args.slice().iter().all(can_leave_class_body) + } + D::ENew(e) => { + can_leave_class_body(&e.target) && e.args.slice().iter().all(can_leave_class_body) + } + D::EIndex(e) => can_leave_class_body(&e.target) && can_leave_class_body(&e.index), + D::EDot(e) => can_leave_class_body(&e.target), + D::ESpread(e) => can_leave_class_body(&e.value), + D::EUnary(e) => can_leave_class_body(&e.value), + D::EIf(e) => { + can_leave_class_body(&e.test) + && can_leave_class_body(&e.yes) + && can_leave_class_body(&e.no) + } + D::EArray(e) => e.items.slice().iter().all(can_leave_class_body), + D::EObject(e) => e.properties.slice().iter().all(|prop| { + !prop.flags.contains(Flags::Property::IsComputed) + && prop.initializer.is_none() + && prop.value.is_some_and(|value| can_leave_class_body(&value)) + }), + D::ETemplate(e) => { + e.tag.is_none_or(|tag| can_leave_class_body(&tag)) + && e.parts() + .iter() + .all(|part| can_leave_class_body(&part.value)) + } + _ => false, + } +} + /// Whether a class statement with class decorators gets a binding of its own /// for the body's references to its name (see `declare_inner_class_binding`). /// @@ -1250,11 +1309,15 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O bun_alloc::AstAlloc::take(&mut class.ts_decorators); let class_decorators_len = class_decorators.len_u32() as usize; - // Relocated initializers are printed outside the class body, where a - // `#name` access would be a syntax error, so a class with private - // members keeps its static fields in place. - let has_private_members = class.properties.slice().iter().any(has_private_key); - let relocate_static_fields = class_decorators_len > 0 && !has_private_members; + // Relocating a static field moves its key's evaluation ahead of the + // class, so to keep the keys in source order every computed key in the + // class is pre-evaluated along with it (Phase 2), which all of them + // must be able to take. + let relocate_static_fields = class_decorators_len > 0 + && class.properties.slice().iter().all(|prop| { + !prop.flags.contains(Flags::Property::IsComputed) + || prop.key.is_none_or(|key| can_leave_class_body(&key)) + }); let init_ref = p.new_sym(js_ast::symbol::Kind::Other, b"_init"); if is_expr { @@ -1349,8 +1412,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } if prop.flags.contains(Flags::Property::IsComputed) && prop.key.is_some() - && (prop.ts_decorators.len_u32() > 0 - || (relocate_static_fields && is_plain_static_field(prop))) + && (prop.ts_decorators.len_u32() > 0 || relocate_static_fields) { computed_key_counter += 1; let key_name: &'a [u8] = if computed_key_counter == 1 { @@ -1762,7 +1824,12 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } continue; } - if relocate_static_fields && is_plain_static_field(prop) { + if relocate_static_fields + && is_plain_static_field(prop) + && prop + .initializer + .is_none_or(|init| can_leave_class_body(&init)) + { static_element_order.push(StaticElement { kind: StaticElementKind::PlainField, index: relocated_static_fields.len(), @@ -2573,7 +2640,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } // `static { _Foo = this }`: static initializers left in the body (see - // `relocate_static_fields`) read the binding while the class is still + // `can_leave_class_body`) read the binding while the class is still // being defined, before the suffix can assign it. if inner_binding_used { let this_e = p.new_expr(E::This {}, class_name_loc); diff --git a/test/bundler/transpiler/es-decorators.test.ts b/test/bundler/transpiler/es-decorators.test.ts index cc452e7adc40..a5d60d0b1bfe 100644 --- a/test/bundler/transpiler/es-decorators.test.ts +++ b/test/bundler/transpiler/es-decorators.test.ts @@ -194,17 +194,30 @@ describe("ES Decorators", () => { @wrap class Foo { static [key("first")] = 1; @methodDec() method() {} + [key("instance field")] = 0; static [key("second")] = 2; + [key("instance method")]() {} static 0 = "zero"; static "quoted key" = "quoted"; } - console.log(JSON.stringify([log, Object.keys(Foo), Foo.first, Foo.second, Foo[0], Foo["quoted key"]])); + console.log(JSON.stringify([ + log, + Object.keys(Foo), + Object.keys(new Foo()), + typeof Foo.prototype["instance method"], + Foo.first, + Foo.second, + Foo[0], + Foo["quoted key"], + ])); `); expect(stderr).toBe(""); expect(JSON.parse(stdout)).toEqual([ - ["first", "decorator expression", "second", "decorator applied"], + ["first", "decorator expression", "instance field", "second", "instance method", "decorator applied"], // "tag" is Wrapped's own static field; the relocated fields follow it. ["0", "tag", "first", "second", "quoted key"], + ["instance field"], + "function", 1, 2, "zero", @@ -213,6 +226,67 @@ describe("ES Decorators", () => { expect(exitCode).toBe(0); }); + test("initializers that only work inside the class body stay there", async () => { + const { stdout, stderr, exitCode } = await runDecorator(` + let original; + function wrap(cls, ctx) { + original = cls; + return class Wrapped extends cls {}; + } + class Base { + static defaults = { limit: 10 }; + } + @wrap class Foo extends Base { + static fromSuper = { ...super.defaults, limit: 20 }; + static newTarget = new.target; + static thisInParameter = (value = this) => value; + static thisInComputedKey = { [this.name]: true }; + static relocated = { limit: 30, tags: ["a", \`b\${1}\`], self: Foo }; + static instance = new Foo(); + } + const own = name => (Object.hasOwn(Foo, name) ? "replacement" : Object.hasOwn(original, name) ? "original" : "none"); + console.log(JSON.stringify({ + fromSuper: [own("fromSuper"), Foo.fromSuper], + newTarget: [own("newTarget"), typeof Foo.newTarget], + thisInParameter: [own("thisInParameter"), Foo.thisInParameter() === original], + thisInComputedKey: [own("thisInComputedKey"), Foo.thisInComputedKey], + relocated: [own("relocated"), Foo.relocated.limit, Foo.relocated.tags, Foo.relocated.self === Foo], + instance: [own("instance"), Foo.instance instanceof Foo], + })); + `); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + fromSuper: ["original", { limit: 20 }], + newTarget: ["original", "undefined"], + thisInParameter: ["original", true], + thisInComputedKey: ["original", { Foo: true }], + relocated: ["replacement", 30, ["a", "b1"], true], + instance: ["replacement", true], + }); + expect(exitCode).toBe(0); + }); + + test("a computed key that cannot be pre-evaluated keeps every static field in place", async () => { + const { stdout, stderr, exitCode } = await runDecorator(` + let original; + function wrap(cls, ctx) { + original = cls; + return class Wrapped extends cls {}; + } + const log = []; + const key = name => (log.push(name), name); + @wrap class Foo { + static [key("first")] = 1; + [key("instance")] = 2; + static [(() => key("second"))()] = 3; + } + console.log(JSON.stringify([log, Object.keys(Foo), Object.keys(original)])); + `); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual([["first", "instance", "second"], [], ["first", "second"]]); + expect(exitCode).toBe(0); + }); + test("relocated static fields define properties instead of invoking inherited setters", async () => { const { stdout, stderr, exitCode } = await runDecorator(` ${wrap} @@ -261,7 +335,7 @@ describe("ES Decorators", () => { expect(exitCode).toBe(0); }); - test("element decorator expressions see the TDZ, then the decorated class", async () => { + test("heritage, element decorator and computed key expressions see the TDZ, then the decorated class", async () => { const { stdout, stderr, exitCode } = await runDecorator(` ${wrap} const captured = []; @@ -271,42 +345,66 @@ describe("ES Decorators", () => { captured.push({ fn, threw }); return () => {}; } - @wrap class Foo { + class Base {} + @wrap class Foo extends (capture(() => Foo), Base) { @(capture(() => Foo)) method() {} @(capture(() => Foo)) static [(capture(() => Foo), "computed")]() {} } - console.log(JSON.stringify(captured.map(({ fn, threw }) => [threw, tag(fn())]))); + // Relocating Bar's static field pre-evaluates every computed key, so + // the undecorated method's key still observes the TDZ too. + let undecoratedKey = "did not throw"; + try { + @wrap class Bar { + [String(Bar)]() {} + static field = 1; + } + } catch (e) { + undecoratedKey = e.constructor.name; + } + console.log(JSON.stringify({ + captured: captured.map(({ fn, threw }) => [threw, tag(fn())]), + undecoratedKey, + })); `); expect(stderr).toBe(""); - expect(JSON.parse(stdout)).toEqual([ - ["ReferenceError", "wrapped"], - ["ReferenceError", "wrapped"], - ["ReferenceError", "wrapped"], - ]); + expect(JSON.parse(stdout)).toEqual({ + captured: [ + ["ReferenceError", "wrapped"], + ["ReferenceError", "wrapped"], + ["ReferenceError", "wrapped"], + ["ReferenceError", "wrapped"], + ], + undecoratedKey: "ReferenceError", + }); expect(exitCode).toBe(0); }); - test("class with instance private members: body sees the replacement, static fields stay in the body", async () => { - // Static initializers may use the private names, which only exist inside - // the body, so they are not relocated; they still observe the class - // (as written) through its name. + test("class with instance private members: initializers using them stay in the body", async () => { const { stdout, stderr, exitCode } = await runDecorator(` - ${wrap} + let original; + function wrap(cls, ctx) { + original = cls; + return class Wrapped extends cls {}; + } @wrap class Foo { #secret = 42; static create() { return new Foo(); } static peek = foo => foo.#secret; + static hasSecret = #secret in Foo; static self = Foo; } console.log(JSON.stringify([ - tag(Foo), - tag(Foo.create().constructor), + Foo.create() instanceof Foo, Foo.peek(Foo.create()), - typeof Foo.self, + Foo.hasSecret, + Object.hasOwn(original, "peek"), + Object.hasOwn(original, "hasSecret"), + Object.hasOwn(Foo, "self"), + Foo.self === Foo, ])); `); expect(stderr).toBe(""); - expect(JSON.parse(stdout)).toEqual(["wrapped", "wrapped", 42, "function"]); + expect(JSON.parse(stdout)).toEqual([true, 42, false, true, true, true, true]); expect(exitCode).toBe(0); }); @@ -360,12 +458,14 @@ describe("ES Decorators", () => { export function wrap(cls, ctx) { return class Wrapped extends cls {}; } + export const marker = Symbol("marker"); `, "named.js": ` - import { wrap } from "./wrap.js"; + import { wrap, marker } from "./wrap.js"; @wrap export class Named { static create() { return new Named(); } static self = Named; + static fromImport = [marker]; } `, "default.js": ` From 0ee9820c318238a8023fa949c54f10ff44c218b4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:21:26 +0000 Subject: [PATCH 3/9] js_parser: shorten the comments added by the decorator lowering change --- src/js_parser/lower/lower_decorators.rs | 68 +++++-------------------- src/js_parser/visit/mod.rs | 17 ++----- 2 files changed, 17 insertions(+), 68 deletions(-) diff --git a/src/js_parser/lower/lower_decorators.rs b/src/js_parser/lower/lower_decorators.rs index 6176b09e5114..16de373d2482 100644 --- a/src/js_parser/lower/lower_decorators.rs +++ b/src/js_parser/lower/lower_decorators.rs @@ -51,9 +51,7 @@ struct FieldInitEntry { enum StaticElementKind { Block, FieldOrAccessor, - /// Undecorated public static field moved out of the body of a class that - /// has class decorators (index into `relocated_static_fields`); see - /// `can_leave_class_body`. + /// Undecorated static field (`relocated_static_fields`). PlainField, } @@ -143,10 +141,6 @@ fn can_be_class_binding_name(name: &[u8]) -> bool { && !is_eval_or_arguments(name) } -/// `static x = 1` / `static x;` with no decorators. When the class itself is -/// decorated these are initialized after the class decorators run (on whatever -/// class they returned), so they are moved out of the class body, provided -/// `can_leave_class_body` holds for the initializer. #[inline] fn is_plain_static_field(prop: &Property) -> bool { prop.kind == PropertyKind::Normal @@ -162,13 +156,7 @@ fn has_private_key(prop: &Property) -> bool { matches!(prop.key, Some(key) if matches!(key.data, js_ast::ExprData::EPrivateIdentifier(_))) } -/// Whether an undecorated static field initializer or computed key may be -/// printed outside the class body. Accepts only the shapes `rewrite_expr` -/// walks completely, so every `this` in them gets redirected, and none of -/// the syntax that only means something inside the body: `super`, -/// `new.target`, `#names`, and anything creating a function or class, whose -/// parameters, keys and heritage the rewriter does not visit. Everything else -/// stays in the class body and keeps behaving as it did before. +/// Shapes `rewrite_expr` walks fully; never `super`, `new.target`, `#names`, functions or classes. fn can_leave_class_body(expr: &Expr) -> bool { use js_ast::ExprData as D; match &expr.data { @@ -219,13 +207,7 @@ fn can_leave_class_body(expr: &Expr) -> bool { } } -/// Whether a class statement with class decorators gets a binding of its own -/// for the body's references to its name (see `declare_inner_class_binding`). -/// -/// Private static members stay installed on the class as written, so a body -/// that reaches them through the class name (`Foo.#count`) has to keep naming -/// that class; pointing the name at a replacement returned by a decorator would -/// turn every such access into a brand-check TypeError. +/// Private statics stay on the class as written, so `Foo.#x` in the body must keep naming it. pub(crate) fn wants_inner_class_binding(class: &G::Class) -> bool { class.should_lower_standard_decorators && class.ts_decorators.len_u32() > 0 @@ -269,14 +251,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O ref_ } - /// `_Foo`: what `class Foo`'s body refers to by its own name once lowered. - /// The name binding a class declaration creates for its body is immutable, - /// so after `Foo = __decorateElement(...)` it would still hold the - /// undecorated class; `lower_impl` instead declares this symbol (`let _Foo;`) - /// in the scope the class statement is in, which must be the current scope - /// here, and reassigns it along with `Foo`. Element decorator and computed - /// key expressions are evaluated before that declaration and so hit the TDZ - /// the spec gives them. + /// `_Foo`, the body's name for `class Foo`: the binding the class itself provides is immutable. pub(crate) fn declare_inner_class_binding(&mut self, class_name_ref: Ref) -> Ref { let class_name: &'a [u8] = self.symbols[class_name_ref.inner_index() as usize] .original_name @@ -1177,9 +1152,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // ── Public API ─────────────────────────────────────── - /// `inner_class_ref` is the binding `visit_class` resolved the body's - /// references to the class name to (see `declare_inner_class_binding`), or - /// `Ref::NONE` when it left them on the class name itself. + /// `inner_class_ref`: what `visit_class` returned for this class. pub(crate) fn lower_standard_decorators_stmt( &mut self, stmt: Stmt, @@ -1290,8 +1263,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O class_name_loc = class.class_name.as_ref().unwrap().loc; } - // Statement mode only; class expressions keep their own (per-evaluation) - // name binding and redirect relocated code to `_class` instead. + // Class expressions keep their per-evaluation name binding; see `relocated_name_rewrite`. let inner_class_ref: Ref = if is_expr { class_name_ref } else if visited_inner_class_ref.is_symbol() { @@ -1309,10 +1281,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O bun_alloc::AstAlloc::take(&mut class.ts_decorators); let class_decorators_len = class_decorators.len_u32() as usize; - // Relocating a static field moves its key's evaluation ahead of the - // class, so to keep the keys in source order every computed key in the - // class is pre-evaluated along with it (Phase 2), which all of them - // must be able to take. + // Relocating pre-evaluates every computed key (Phase 2) to keep them in source order. let relocate_static_fields = class_decorators_len > 0 && class.properties.slice().iter().all(|prop| { !prop.flags.contains(Flags::Property::IsComputed) @@ -1459,20 +1428,17 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } - // Must be read before the lowering itself starts referencing the binding. + // Read before the lowering itself references the binding. let inner_binding_used = !is_expr && p.symbols[inner_class_ref.inner_index() as usize].use_count_estimate > 0; if !is_expr && !inner_binding_used { - // Either declared below or merged into the class name, so the symbol - // never prints as an undeclared `_Foo`. + // Not declared below, so merge it into the class name instead. p.symbols[inner_class_ref.inner_index() as usize] .link .set(class_name_ref); } - // For named class expressions: swap to expr_class_ref for suffix ops. - // Code relocated out of the body can no longer see the expression's own - // name binding, so it is redirected to `_class` as well. + // For named class expressions: swap to expr_class_ref for suffix ops and relocated code let mut original_class_name_for_decorator: Option<&'a [u8]> = None; let mut relocated_name_rewrite: Option = None; if is_expr @@ -2380,9 +2346,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O suffix_exprs.push(p.call_rt(loc, b"__runInitializers", &[i_e, n_e, c_e])); } StaticElementKind::PlainField => { - // `__publicField(Foo, "x", init)` keeps the field syntax's - // [[Define]] semantics; a plain assignment would invoke - // inherited setters. + // __publicField defines; an assignment would run inherited setters. let field = &mut relocated_static_fields[elem.index]; let key = field.key.expect("infallible: prop has key"); let target = p.use_ref(class_name_ref, class_name_loc); @@ -2639,9 +2603,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O new_properties = merged; } - // `static { _Foo = this }`: static initializers left in the body (see - // `can_leave_class_body`) read the binding while the class is still - // being defined, before the suffix can assign it. + // `static { _Foo = this }`: initializers left in the body run before the suffix assigns it. if inner_binding_used { let this_e = p.new_expr(E::This {}, class_name_loc); let capture = p.assign_to(inner_class_ref, this_e, class_name_loc); @@ -2779,11 +2741,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } out.extend_from_slice(&pre_eval_stmts); out.extend_from_slice(&prefix_stmts); - // `let _Foo;` comes after the pre-evaluated element decorators and - // computed keys (which must still hit its TDZ) and before the class - // statement whose leading static block assigns it. `let` rather than - // `var` so that a class statement in a loop body or function gets a - // fresh binding per evaluation, like the class binding itself. + // After the pre-evaluated keys and decorators (TDZ); `let` so loops get fresh bindings. if inner_binding_used { p.record_declared_symbol(inner_class_ref); let binding = p.b( diff --git a/src/js_parser/visit/mod.rs b/src/js_parser/visit/mod.rs index da3992fb2aa6..243cd0f4d98a 100644 --- a/src/js_parser/visit/mod.rs +++ b/src/js_parser/visit/mod.rs @@ -772,10 +772,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O self.stmts_to_single_stmt(stmt.loc, stmts.into_bump_slice_mut()) } - /// Returns the binding the class body's references to the class name were - /// resolved to when standard decorator lowering needs one of its own (see - /// `declare_inner_class_binding`); the caller hands it to `lower_class`. - /// `Ref::NONE` otherwise. + /// Returns the `declare_inner_class_binding` the body resolved to (for `lower_class`) or NONE. pub(crate) fn visit_class( &mut self, name_scope_loc: bun_ast::Loc, @@ -794,8 +791,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O self.record_declared_symbol(name.ref_); } - // Before the class name scope is pushed: the lowering declares this - // symbol in the scope containing the class statement. + // Created in the enclosing scope, where the lowering declares it. let inner_class_ref = match class.class_name { Some(name) if is_stmt && wants_inner_class_binding(class) => { self.declare_inner_class_binding(name.ref_) @@ -823,10 +819,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // must be the original value of the name, not the re-assigned value. // Use "const" for this symbol to match JavaScript run-time semantics. You // are not allowed to assign to this symbol (it throws a TypeError). - // - // Unless class decorators are being lowered: they may replace the class, - // so the body then resolves to `inner_class_ref`, which the lowering - // keeps pointed at the decorated class (`declare_inner_class_binding`). + // With lowered class decorators the body resolves to `inner_class_ref` instead. if let Some(name) = class.class_name { let name_ref = name.ref_; shadow_ref.set(name_ref); @@ -1277,9 +1270,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O self.enclosing_class_keyword = old_enclosing_class_keyword; } - // If there was originally no class name but something inside needed one - // (e.g. there was a static property initializer that referenced "this"), - // store our generated name so the class expression ends up with a name. + // An anonymous class whose body used the generated name keeps it as its name. if class.class_name.is_none() && self.symbols[shadow_ref.get().inner_index() as usize].use_count_estimate > 0 { From 333c9d1cff2dd4ecb12a97062212ebc4cf94a844 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:56:53 +0000 Subject: [PATCH 4/9] js_parser: relocate a decorated class's static fields all or nothing Deciding per field split the static fields of one class between the body (initialized before the class decorators, on the class as written) and the suffix (after them, on the decorated class), which reordered them relative to each other and put them on different objects. If any static field initializer has to stay in the body, all of them now stay, as before. --- src/js_parser/lower/lower_decorators.rs | 17 ++- test/bundler/transpiler/es-decorators.test.ts | 132 +++++++++++++----- 2 files changed, 106 insertions(+), 43 deletions(-) diff --git a/src/js_parser/lower/lower_decorators.rs b/src/js_parser/lower/lower_decorators.rs index 16de373d2482..0f07015ecba8 100644 --- a/src/js_parser/lower/lower_decorators.rs +++ b/src/js_parser/lower/lower_decorators.rs @@ -1281,11 +1281,15 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O bun_alloc::AstAlloc::take(&mut class.ts_decorators); let class_decorators_len = class_decorators.len_u32() as usize; - // Relocating pre-evaluates every computed key (Phase 2) to keep them in source order. + // All or nothing (keys are pre-evaluated in Phase 2), so static members keep their order. let relocate_static_fields = class_decorators_len > 0 && class.properties.slice().iter().all(|prop| { - !prop.flags.contains(Flags::Property::IsComputed) - || prop.key.is_none_or(|key| can_leave_class_body(&key)) + (!prop.flags.contains(Flags::Property::IsComputed) + || prop.key.is_none_or(|key| can_leave_class_body(&key))) + && (!is_plain_static_field(prop) + || prop + .initializer + .is_none_or(|init| can_leave_class_body(&init))) }); let init_ref = p.new_sym(js_ast::symbol::Kind::Other, b"_init"); @@ -1790,12 +1794,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } continue; } - if relocate_static_fields - && is_plain_static_field(prop) - && prop - .initializer - .is_none_or(|init| can_leave_class_body(&init)) - { + if relocate_static_fields && is_plain_static_field(prop) { static_element_order.push(StaticElement { kind: StaticElementKind::PlainField, index: relocated_static_fields.len(), diff --git a/test/bundler/transpiler/es-decorators.test.ts b/test/bundler/transpiler/es-decorators.test.ts index a5d60d0b1bfe..825fe819d59a 100644 --- a/test/bundler/transpiler/es-decorators.test.ts +++ b/test/bundler/transpiler/es-decorators.test.ts @@ -226,10 +226,12 @@ describe("ES Decorators", () => { expect(exitCode).toBe(0); }); - test("initializers that only work inside the class body stay there", async () => { + test("an initializer that only works inside the class body keeps every static field there, in order", async () => { const { stdout, stderr, exitCode } = await runDecorator(` + const log = []; let original; function wrap(cls, ctx) { + log.push("decorator"); original = cls; return class Wrapped extends cls {}; } @@ -237,31 +239,76 @@ describe("ES Decorators", () => { static defaults = { limit: 10 }; } @wrap class Foo extends Base { - static fromSuper = { ...super.defaults, limit: 20 }; + static first = (log.push("first"), 1); + static fromSuper = (log.push("fromSuper"), { ...super.defaults, limit: 20 }); + static second = (log.push("second"), Foo.first + 1); static newTarget = new.target; static thisInParameter = (value = this) => value; static thisInComputedKey = { [this.name]: true }; - static relocated = { limit: 30, tags: ["a", \`b\${1}\`], self: Foo }; - static instance = new Foo(); + static create = () => new Foo(); + } + console.log(JSON.stringify({ + log, + originalKeys: Object.keys(original), + replacementKeys: Object.keys(Foo), + fromSuper: Foo.fromSuper, + second: Foo.second, + newTarget: typeof Foo.newTarget, + thisInParameter: Foo.thisInParameter() === original, + thisInComputedKey: Foo.thisInComputedKey, + created: Foo.create() instanceof Foo, + })); + `); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + log: ["first", "fromSuper", "second", "decorator"], + originalKeys: ["first", "fromSuper", "second", "newTarget", "thisInParameter", "thisInComputedKey", "create"], + replacementKeys: [], + fromSuper: { limit: 20 }, + second: 2, + newTarget: "undefined", + thisInParameter: true, + thisInComputedKey: { Foo: true }, + // Lazy initializers still pick up the decorated class through the body's binding. + created: true, + }); + expect(exitCode).toBe(0); + }); + + test("eagerly evaluated initializers of every common shape are relocated together", async () => { + const { stdout, stderr, exitCode } = await runDecorator(` + let original; + function wrap(cls, ctx) { + original = cls; + return class Wrapped extends cls {}; + } + const label = "label"; + @wrap class Foo { + static singleton = new Foo(); + static registry = new Map([[label, Foo]]); + static config = { self: Foo, owner: this, nested: [1, ...[2], \`\${label}:\${Foo.name}\`], ...{ extra: true } }; + static flag = typeof Foo === "function" && this === Foo ? "decorated" : "undecorated"; + static declaredOnly; } - const own = name => (Object.hasOwn(Foo, name) ? "replacement" : Object.hasOwn(original, name) ? "original" : "none"); console.log(JSON.stringify({ - fromSuper: [own("fromSuper"), Foo.fromSuper], - newTarget: [own("newTarget"), typeof Foo.newTarget], - thisInParameter: [own("thisInParameter"), Foo.thisInParameter() === original], - thisInComputedKey: [own("thisInComputedKey"), Foo.thisInComputedKey], - relocated: [own("relocated"), Foo.relocated.limit, Foo.relocated.tags, Foo.relocated.self === Foo], - instance: [own("instance"), Foo.instance instanceof Foo], + originalKeys: Object.keys(original), + replacementKeys: Object.keys(Foo), + singleton: Foo.singleton instanceof Foo, + registry: Foo.registry.get("label") === Foo, + config: [Foo.config.self === Foo, Foo.config.owner === Foo, Foo.config.nested, Foo.config.extra], + flag: Foo.flag, + declaredOnly: Object.hasOwn(Foo, "declaredOnly") && Foo.declaredOnly === undefined, })); `); expect(stderr).toBe(""); expect(JSON.parse(stdout)).toEqual({ - fromSuper: ["original", { limit: 20 }], - newTarget: ["original", "undefined"], - thisInParameter: ["original", true], - thisInComputedKey: ["original", { Foo: true }], - relocated: ["replacement", 30, ["a", "b1"], true], - instance: ["replacement", true], + originalKeys: [], + replacementKeys: ["singleton", "registry", "config", "flag", "declaredOnly"], + singleton: true, + registry: true, + config: [true, true, [1, 2, "label:Wrapped"], true], + flag: "decorated", + declaredOnly: true, }); expect(exitCode).toBe(0); }); @@ -379,32 +426,49 @@ describe("ES Decorators", () => { expect(exitCode).toBe(0); }); - test("class with instance private members: initializers using them stay in the body", async () => { + test("instance private members: the body sees the replacement; initializers naming them stay put", async () => { const { stdout, stderr, exitCode } = await runDecorator(` - let original; + const originals = []; function wrap(cls, ctx) { - original = cls; + originals.push(cls); return class Wrapped extends cls {}; } - @wrap class Foo { + @wrap class UsesPrivateNames { #secret = 42; - static create() { return new Foo(); } + static create() { return new UsesPrivateNames(); } static peek = foo => foo.#secret; - static hasSecret = #secret in Foo; - static self = Foo; + static hasSecret = #secret in UsesPrivateNames; + static self = UsesPrivateNames; } - console.log(JSON.stringify([ - Foo.create() instanceof Foo, - Foo.peek(Foo.create()), - Foo.hasSecret, - Object.hasOwn(original, "peek"), - Object.hasOwn(original, "hasSecret"), - Object.hasOwn(Foo, "self"), - Foo.self === Foo, - ])); + @wrap class PlainInitializers { + #secret = 42; + static create() { return new PlainInitializers(); } + static self = PlainInitializers; + reveal() { return this.#secret; } + } + console.log(JSON.stringify({ + usesPrivateNames: [ + UsesPrivateNames.create() instanceof UsesPrivateNames, + UsesPrivateNames.peek(UsesPrivateNames.create()), + UsesPrivateNames.hasSecret, + Object.keys(originals[0]), + Object.keys(UsesPrivateNames), + UsesPrivateNames.self === originals[0], + ], + plainInitializers: [ + PlainInitializers.create() instanceof PlainInitializers, + PlainInitializers.create().reveal(), + Object.keys(originals[1]), + Object.keys(PlainInitializers), + PlainInitializers.self === PlainInitializers, + ], + })); `); expect(stderr).toBe(""); - expect(JSON.parse(stdout)).toEqual([true, 42, false, true, true, true, true]); + expect(JSON.parse(stdout)).toEqual({ + usesPrivateNames: [true, 42, false, ["peek", "hasSecret", "self"], [], true], + plainInitializers: [true, 42, [], ["self"], true], + }); expect(exitCode).toBe(0); }); From fa148513fa2526ffca8c63cea96e4c0b0f075f75 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 02:09:40 +0000 Subject: [PATCH 5/9] js_parser: do not relocate static fields past private static fields or static accessors Undecorated private static fields stay in the class body and undecorated static auto-accessors are initialized before the class decorator runs, so neither goes through the source-ordered part of the suffix. Relocating the public static fields around them moved those out from under them; a class containing either now keeps all of its static fields in place. --- src/js_parser/lower/lower_decorators.rs | 14 ++++- test/bundler/transpiler/es-decorators.test.ts | 55 +++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/src/js_parser/lower/lower_decorators.rs b/src/js_parser/lower/lower_decorators.rs index 0f07015ecba8..cdfb56ef58f2 100644 --- a/src/js_parser/lower/lower_decorators.rs +++ b/src/js_parser/lower/lower_decorators.rs @@ -156,6 +156,15 @@ fn has_private_key(prop: &Property) -> bool { matches!(prop.key, Some(key) if matches!(key.data, js_ast::ExprData::EPrivateIdentifier(_))) } +/// Undecorated private static field or static auto-accessor: initialized outside step 7's order. +#[inline] +fn is_unordered_static_initializer(prop: &Property) -> bool { + prop.flags.contains(Flags::Property::IsStatic) + && !prop.flags.contains(Flags::Property::IsMethod) + && prop.ts_decorators.len_u32() == 0 + && (has_private_key(prop) || prop.kind == PropertyKind::AutoAccessor) +} + /// Shapes `rewrite_expr` walks fully; never `super`, `new.target`, `#names`, functions or classes. fn can_leave_class_body(expr: &Expr) -> bool { use js_ast::ExprData as D; @@ -1284,8 +1293,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // All or nothing (keys are pre-evaluated in Phase 2), so static members keep their order. let relocate_static_fields = class_decorators_len > 0 && class.properties.slice().iter().all(|prop| { - (!prop.flags.contains(Flags::Property::IsComputed) - || prop.key.is_none_or(|key| can_leave_class_body(&key))) + !is_unordered_static_initializer(prop) + && (!prop.flags.contains(Flags::Property::IsComputed) + || prop.key.is_none_or(|key| can_leave_class_body(&key))) && (!is_plain_static_field(prop) || prop .initializer diff --git a/test/bundler/transpiler/es-decorators.test.ts b/test/bundler/transpiler/es-decorators.test.ts index 825fe819d59a..4aa64e5a33b4 100644 --- a/test/bundler/transpiler/es-decorators.test.ts +++ b/test/bundler/transpiler/es-decorators.test.ts @@ -334,6 +334,61 @@ describe("ES Decorators", () => { expect(exitCode).toBe(0); }); + test("a private static field or a static accessor keeps every static field in place", async () => { + // Neither is initialized through the ordered part of the lowering, so + // relocating their siblings would move those out from under them. + const { stdout, stderr, exitCode } = await runDecorator(` + const log = []; + let original; + function wrap(cls, ctx) { + log.push("decorator"); + original = cls; + return class Wrapped extends cls {}; + } + function keep(cls, ctx) { + log.push("decorator"); + } + @wrap class WithPrivate { + static first = (log.push("first"), 1); + static #second = (log.push("second"), WithPrivate.first + 1); + static get second() { return WithPrivate.#second; } + } + const withPrivate = { + log: log.splice(0), + second: WithPrivate.second, + originalKeys: Object.keys(original), + replacementKeys: Object.keys(WithPrivate), + }; + @keep class WithAccessor { + static first = (log.push("first"), 1); + static accessor second = (log.push("second"), 2); + static third = (log.push("third"), WithAccessor.first + 2); + } + const withAccessor = { + log: log.splice(0), + values: [WithAccessor.first, WithAccessor.second, WithAccessor.third], + keys: Object.keys(WithAccessor), + }; + console.log(JSON.stringify({ withPrivate, withAccessor })); + `); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + withPrivate: { + log: ["first", "second", "decorator"], + second: 2, + originalKeys: ["first"], + replacementKeys: [], + }, + withAccessor: { + // The accessor's storage is set up by the lowering after the body runs; unchanged by this change. + log: ["first", "third", "second", "decorator"], + values: [1, 2, 3], + keys: ["first", "third"], + }, + }); + expect(exitCode).toBe(0); + }); + test("relocated static fields define properties instead of invoking inherited setters", async () => { const { stdout, stderr, exitCode } = await runDecorator(` ${wrap} From 216124bcd1d75001507226b126e5da990f919b02 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 02:37:29 +0000 Subject: [PATCH 6/9] ci: retrigger From 63662452f30be24cf29789fcf5fdc8ab0a48067e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 02:54:39 +0000 Subject: [PATCH 7/9] js_parser: relocate static fields only when the body names the decorated class A private static method (not just a field) already keeps the body's references on the class as written, and a named class expression's body always refers to the expression's own binding, yet static fields were still moved onto the decorated class in both cases, so a body reading them by name found nothing. The relocation gate now uses the same private-static check as the inner binding and, for class expressions, requires the body not to use the expression's name; the rewrite of that name in relocated initializers is therefore gone. --- src/js_parser/lower/lower_decorators.rs | 45 +++++++++-------- test/bundler/transpiler/es-decorators.test.ts | 50 ++++++++++++++++--- 2 files changed, 67 insertions(+), 28 deletions(-) diff --git a/src/js_parser/lower/lower_decorators.rs b/src/js_parser/lower/lower_decorators.rs index cdfb56ef58f2..80618020f520 100644 --- a/src/js_parser/lower/lower_decorators.rs +++ b/src/js_parser/lower/lower_decorators.rs @@ -156,13 +156,20 @@ fn has_private_key(prop: &Property) -> bool { matches!(prop.key, Some(key) if matches!(key.data, js_ast::ExprData::EPrivateIdentifier(_))) } -/// Undecorated private static field or static auto-accessor: initialized outside step 7's order. +/// Its storage is initialized before the class decorator runs, outside step 7's source order. #[inline] -fn is_unordered_static_initializer(prop: &Property) -> bool { - prop.flags.contains(Flags::Property::IsStatic) - && !prop.flags.contains(Flags::Property::IsMethod) +fn is_undecorated_static_accessor(prop: &Property) -> bool { + prop.kind == PropertyKind::AutoAccessor + && prop.flags.contains(Flags::Property::IsStatic) && prop.ts_decorators.len_u32() == 0 - && (has_private_key(prop) || prop.kind == PropertyKind::AutoAccessor) +} + +fn has_private_static_member(class: &G::Class) -> bool { + class + .properties + .slice() + .iter() + .any(|prop| prop.flags.contains(Flags::Property::IsStatic) && has_private_key(prop)) } /// Shapes `rewrite_expr` walks fully; never `super`, `new.target`, `#names`, functions or classes. @@ -220,11 +227,7 @@ fn can_leave_class_body(expr: &Expr) -> bool { pub(crate) fn wants_inner_class_binding(class: &G::Class) -> bool { class.should_lower_standard_decorators && class.ts_decorators.len_u32() > 0 - && !class - .properties - .slice() - .iter() - .any(|prop| prop.flags.contains(Flags::Property::IsStatic) && has_private_key(prop)) + && !has_private_static_member(class) } // ── impl P ─────────────────────────────────────────────────────────────────── @@ -1272,7 +1275,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O class_name_loc = class.class_name.as_ref().unwrap().loc; } - // Class expressions keep their per-evaluation name binding; see `relocated_name_rewrite`. + // Class expressions keep their own name binding (see `body_names_class_as_written`). let inner_class_ref: Ref = if is_expr { class_name_ref } else if visited_inner_class_ref.is_symbol() { @@ -1290,10 +1293,18 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O bun_alloc::AstAlloc::take(&mut class.ts_decorators); let class_decorators_len = class_decorators.len_u32() as usize; + // Relocated fields land on the decorated class; a body naming the original would miss them. + let body_names_class_as_written = if is_expr { + !expr_class_is_anonymous + && p.symbols[class_name_ref.inner_index() as usize].use_count_estimate > 0 + } else { + has_private_static_member(class) + }; // All or nothing (keys are pre-evaluated in Phase 2), so static members keep their order. let relocate_static_fields = class_decorators_len > 0 + && !body_names_class_as_written && class.properties.slice().iter().all(|prop| { - !is_unordered_static_initializer(prop) + !is_undecorated_static_accessor(prop) && (!prop.flags.contains(Flags::Property::IsComputed) || prop.key.is_none_or(|key| can_leave_class_body(&key))) && (!is_plain_static_field(prop) @@ -1452,9 +1463,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O .set(class_name_ref); } - // For named class expressions: swap to expr_class_ref for suffix ops and relocated code + // For named class expressions: swap to expr_class_ref for suffix ops let mut original_class_name_for_decorator: Option<&'a [u8]> = None; - let mut relocated_name_rewrite: Option = None; if is_expr && !expr_class_is_anonymous && let Some(ecr) = expr_class_ref @@ -1465,10 +1475,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O .original_name .slice(), ); - relocated_name_rewrite = Some(RewriteKind::ReplaceRef { - old: class_name_ref, - new: ecr, - }); class_name_ref = ecr; class_name_loc = loc; } @@ -2368,9 +2374,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O loc: class_name_loc, }, ); - if let Some(rewrite) = relocated_name_rewrite { - p.rewrite_expr(init, rewrite); - } let init = *init; suffix_exprs.push(p.call_rt( key.loc, diff --git a/test/bundler/transpiler/es-decorators.test.ts b/test/bundler/transpiler/es-decorators.test.ts index 4aa64e5a33b4..f7e45298285c 100644 --- a/test/bundler/transpiler/es-decorators.test.ts +++ b/test/bundler/transpiler/es-decorators.test.ts @@ -546,28 +546,64 @@ describe("ES Decorators", () => { static make() { return new Bar(); } id() { return this.#id; } } + // A private static method is enough to keep the name on the class as + // written, so the static fields that name reaches must stay there too. + @wrap class Store { + static instances = []; + static #register(instance) { Store.instances.push(instance); } + constructor() { Store.#register(this); } + } + const store = new Store(); Foo.create(); Foo.create(); - console.log(JSON.stringify([tag(Foo), Foo.instances, Bar.make().id(), Bar.make().id(), Bar.made])); + console.log(JSON.stringify([ + tag(Foo), + Foo.instances, + Bar.make().id(), + Bar.make().id(), + Bar.made, + Store.instances.length === 1 && Store.instances[0] === store, + Object.hasOwn(Store, "instances"), + ])); `); expect(stderr).toBe(""); - expect(JSON.parse(stdout)).toEqual(["wrapped", 2, 1, 2, 0]); + expect(JSON.parse(stdout)).toEqual(["wrapped", 2, 1, 2, 0, true, false]); expect(exitCode).toBe(0); }); - test("class expression: static fields are initialized on the replacement", async () => { + test("class expressions: static fields move unless the body uses the expression's own name", async () => { const { stdout, stderr, exitCode } = await runDecorator(` ${wrap} const receivers = []; - const Foo = @wrap class Inner { - static self = Inner; + const Anonymous = @wrap class { static viaThis = (receivers.push(this), 1); static plain; }; - console.log(JSON.stringify([tag(Foo), tag(Foo.self), receivers[0] === Foo, Object.keys(Foo)])); + const Registry = @wrap class Inner { + static entries = []; + static add(entry) { Inner.entries.push(entry); return Inner.entries.length; } + }; + console.log(JSON.stringify([ + tag(Anonymous), + receivers[0] === Anonymous, + Object.keys(Anonymous), + tag(Registry), + Registry.add("a"), + Object.keys(Registry), + Registry.entries, + ])); `); expect(stderr).toBe(""); - expect(JSON.parse(stdout)).toEqual(["wrapped", "wrapped", true, ["tag", "self", "viaThis", "plain"]]); + expect(JSON.parse(stdout)).toEqual([ + "wrapped", + true, + ["tag", "viaThis", "plain"], + "wrapped", + 1, + // Inner's body means the class as written, so entries stays there and is inherited. + ["tag"], + ["a"], + ]); expect(exitCode).toBe(0); }); From 7e0342c5183f2059fea1fb73e1742ff35d76495e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 03:09:28 +0000 Subject: [PATCH 8/9] test: cover a decorated private static member next to a relocatable static field --- test/bundler/transpiler/es-decorators.test.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/test/bundler/transpiler/es-decorators.test.ts b/test/bundler/transpiler/es-decorators.test.ts index f7e45298285c..1b78f32dac05 100644 --- a/test/bundler/transpiler/es-decorators.test.ts +++ b/test/bundler/transpiler/es-decorators.test.ts @@ -546,13 +546,19 @@ describe("ES Decorators", () => { static make() { return new Bar(); } id() { return this.#id; } } - // A private static method is enough to keep the name on the class as - // written, so the static fields that name reaches must stay there too. + // Any private static member (a method, or a decorated one) is enough to + // keep the name on the class as written, so the static fields that name + // reaches must stay there too. @wrap class Store { static instances = []; static #register(instance) { Store.instances.push(instance); } constructor() { Store.#register(this); } } + @wrap class Keyed { + static registry = []; + @keep static #key = "k"; + lookup() { return Keyed.registry; } + } const store = new Store(); Foo.create(); Foo.create(); @@ -564,10 +570,12 @@ describe("ES Decorators", () => { Bar.made, Store.instances.length === 1 && Store.instances[0] === store, Object.hasOwn(Store, "instances"), + new Keyed().lookup(), + Object.hasOwn(Keyed, "registry"), ])); `); expect(stderr).toBe(""); - expect(JSON.parse(stdout)).toEqual(["wrapped", 2, 1, 2, 0, true, false]); + expect(JSON.parse(stdout)).toEqual(["wrapped", 2, 1, 2, 0, true, false, [], false]); expect(exitCode).toBe(0); }); From 72086c74561a92d97e5ed5bd8161e94344898f2d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 03:54:19 +0000 Subject: [PATCH 9/9] js_parser: keep class expressions' static fields in place around private statics too The private static check only applied to class statements; a class expression with a private static field still had its public static fields relocated around it, reordering the initializers. Both forms now share it. --- src/js_parser/lower/lower_decorators.rs | 16 +++++++--------- test/bundler/transpiler/es-decorators.test.ts | 19 ++++++++++++++++++- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/src/js_parser/lower/lower_decorators.rs b/src/js_parser/lower/lower_decorators.rs index 80618020f520..7808d86d51ee 100644 --- a/src/js_parser/lower/lower_decorators.rs +++ b/src/js_parser/lower/lower_decorators.rs @@ -1275,7 +1275,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O class_name_loc = class.class_name.as_ref().unwrap().loc; } - // Class expressions keep their own name binding (see `body_names_class_as_written`). + // Class expressions keep their own name binding (see `keep_static_fields_in_body`). let inner_class_ref: Ref = if is_expr { class_name_ref } else if visited_inner_class_ref.is_symbol() { @@ -1293,16 +1293,14 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O bun_alloc::AstAlloc::take(&mut class.ts_decorators); let class_decorators_len = class_decorators.len_u32() as usize; - // Relocated fields land on the decorated class; a body naming the original would miss them. - let body_names_class_as_written = if is_expr { - !expr_class_is_anonymous - && p.symbols[class_name_ref.inner_index() as usize].use_count_estimate > 0 - } else { - has_private_static_member(class) - }; + // Private statics stay in the body; an expression body using its name means the original. + let keep_static_fields_in_body = has_private_static_member(class) + || (is_expr + && !expr_class_is_anonymous + && p.symbols[class_name_ref.inner_index() as usize].use_count_estimate > 0); // All or nothing (keys are pre-evaluated in Phase 2), so static members keep their order. let relocate_static_fields = class_decorators_len > 0 - && !body_names_class_as_written + && !keep_static_fields_in_body && class.properties.slice().iter().all(|prop| { !is_undecorated_static_accessor(prop) && (!prop.flags.contains(Flags::Property::IsComputed) diff --git a/test/bundler/transpiler/es-decorators.test.ts b/test/bundler/transpiler/es-decorators.test.ts index 1b78f32dac05..e6c300fee31c 100644 --- a/test/bundler/transpiler/es-decorators.test.ts +++ b/test/bundler/transpiler/es-decorators.test.ts @@ -369,7 +369,18 @@ describe("ES Decorators", () => { values: [WithAccessor.first, WithAccessor.second, WithAccessor.third], keys: Object.keys(WithAccessor), }; - console.log(JSON.stringify({ withPrivate, withAccessor })); + const Expression = @wrap class { + static first = (log.push("first"), 1); + static #second = (log.push("second"), this.first + 1); + static get second() { return original.#second; } + }; + const expression = { + log: log.splice(0), + second: Expression.second, + originalKeys: Object.keys(original), + replacementKeys: Object.keys(Expression), + }; + console.log(JSON.stringify({ withPrivate, withAccessor, expression })); `); expect(stderr).toBe(""); expect(JSON.parse(stdout)).toEqual({ @@ -385,6 +396,12 @@ describe("ES Decorators", () => { values: [1, 2, 3], keys: ["first", "third"], }, + expression: { + log: ["first", "second", "decorator"], + second: 2, + originalKeys: ["first"], + replacementKeys: [], + }, }); expect(exitCode).toBe(0); });