From f121874d229d3f3e181b423fafca6e1d7e122818 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:13:18 +0000 Subject: [PATCH 01/16] js_parser: fix TypeScript experimental decorator lowering With `experimentalDecorators`, `lower_class` moved every decorated field out of the class body (`Class.key = init` after the class, `this.key = init` in the constructor). That changed the initialization order, gave decorated fields [[Set]] semantics under `useDefineForClassFields: true`, dropped decorated fields that have no initializer, and left `this` and `super` in hoisted static initializers pointing at the wrong thing. Decorated fields now stay where TypeScript's own field lowering puts them and only the `__legacyDecorateClassTS` calls follow the class. Other fixes in the same lowering: - A computed key of a decorated member is captured in a temporary (`[_key = expr]`) so it is evaluated once and the decorator call uses the same key. - Parameter decorators are parsed with the await context and scope that enclose the class, and visited in the class body scope, so a name in them is not bound to a parameter and `await` works inside an async function. - When a member or parameter decorator reads a `#private` name, the decorator calls run in a static block at the end of the class body, where the name is in scope (the shape tsc emits). - `export default @dec class Foo {}` is parsed as a class declaration, so the `Foo` binding exists and the decorator is applied. With standard decorators an anonymous class on that path keeps the name "default". - Experimental decorators on a class expression, or on its members and parameters, report an error instead of being dropped. - `accessor` fields are parsed in experimentalDecorators projects and lowered to a `#x_accessor_storage` field with a getter/setter pair; decorated accessors receive the descriptor like a method. - Decorators on both sides of `export` report "Decorators are not valid here". --- src/ast/expr.rs | 1 + src/ast/g.rs | 5 + src/js_parser/lower/lower_decorators.rs | 7 +- src/js_parser/p.rs | 471 +++++++++++------- src/js_parser/parse/mod.rs | 42 ++ src/js_parser/parse/parse_fn.rs | 33 +- src/js_parser/parse/parse_prefix.rs | 13 + src/js_parser/parse/parse_property.rs | 6 +- src/js_parser/parse/parse_stmt.rs | 44 ++ src/js_parser/parser.rs | 11 + src/js_parser/visit/mod.rs | 27 +- src/js_parser/visit/visit_binary.rs | 1 + src/js_parser/visit/visit_expr.rs | 1 + src/jsc/RuntimeTranspilerCache.rs | 6 +- .../decorators-legacy-lowering.test.ts | 345 +++++++++++++ .../set-semantics-fixture.ts | 177 +++++++ .../decorators-set-semantics/tsconfig.json | 6 + test/bundler/transpiler/decorators.test.ts | 189 ++----- test/bundler/transpiler/es-decorators.test.ts | 2 +- 19 files changed, 1032 insertions(+), 355 deletions(-) create mode 100644 test/bundler/transpiler/decorators-legacy-lowering.test.ts create mode 100644 test/bundler/transpiler/decorators-set-semantics/set-semantics-fixture.ts create mode 100644 test/bundler/transpiler/decorators-set-semantics/tsconfig.json diff --git a/src/ast/expr.rs b/src/ast/expr.rs index 3beff11a4014..375f6839b631 100644 --- a/src/ast/expr.rs +++ b/src/ast/expr.rs @@ -1970,6 +1970,7 @@ impl Data { properties, has_decorators: el.has_decorators, should_lower_standard_decorators: el.should_lower_standard_decorators, + ts_decorators_use_private_names: el.ts_decorators_use_private_names, }); Ok(Data::EClass(StoreRef::from_bump(item))) } diff --git a/src/ast/g.rs b/src/ast/g.rs index 96f0a28d6390..2ff8b1fbc2b4 100644 --- a/src/ast/g.rs +++ b/src/ast/g.rs @@ -63,6 +63,10 @@ pub struct Class { pub properties: StoreSlice, pub has_decorators: bool, pub should_lower_standard_decorators: bool, + /// A member or parameter decorator reads a `#private` name of this class. + /// The TypeScript experimental decorator calls then have to run inside the + /// class body (a trailing static block), where the name is in scope. + pub ts_decorators_use_private_names: bool, } impl Default for Class { @@ -77,6 +81,7 @@ impl Default for Class { properties: StoreSlice::EMPTY, has_decorators: false, should_lower_standard_decorators: false, + ts_decorators_use_private_names: false, } } } diff --git a/src/js_parser/lower/lower_decorators.rs b/src/js_parser/lower/lower_decorators.rs index 77c2620dac5e..dfde7ab3c807 100644 --- a/src/js_parser/lower/lower_decorators.rs +++ b/src/js_parser/lower/lower_decorators.rs @@ -121,6 +121,7 @@ fn class_copy(c: &G::Class) -> G::Class { properties: c.properties, has_decorators: c.has_decorators, should_lower_standard_decorators: c.should_lower_standard_decorators, + ts_decorators_use_private_names: c.ts_decorators_use_private_names, } } @@ -890,7 +891,11 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O /// Drain receiver-capture temporaries created past `baseline` into a /// single `var` declaration statement; `None` if none were created. - fn drain_capture_temp_decls(&mut self, baseline: usize, loc: bun_ast::Loc) -> Option { + pub(crate) fn drain_capture_temp_decls( + &mut self, + baseline: usize, + loc: bun_ast::Loc, + ) -> Option { let total = self.temp_refs_to_declare.len(); if total == baseline { return None; diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 12d485079d93..581faaf2fcb2 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -536,6 +536,12 @@ pub struct P<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> { pub(crate) temp_refs_to_declare: List<'a, TempRef>, pub(crate) temp_ref_count: i32, + /// Counts every `obj.#name` and `#name in obj` the visit pass resolves. + /// `visit_ts_decorators` compares it before and after the visit and sets + /// `ts_decorators_use_private_names` when a decorator read a private name. + pub(crate) private_name_use_count: u32, + pub(crate) ts_decorators_use_private_names: bool, + // When bundling, hoisted top-level local variables declared with "var" in // nested scopes are moved up to be declared in the top-level scope instead. // The old "var" statements are turned into regular assignments instead. This @@ -6376,17 +6382,31 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O return self.arena.alloc_slice_copy(&[stmt]); } } + let class_loc = stmt.loc; + let use_define = self.options.use_define_for_class_fields; let mut constructor_function: Option> = None; let mut static_decorators = BumpVec::::new_in(self.arena); let mut instance_decorators = BumpVec::::new_in(self.arena); - let mut instance_members = BumpVec::::new_in(self.arena); - let mut static_members = BumpVec::::new_in(self.arena); let mut class_properties = BumpVec::::new_in(self.arena); + // Temporaries that capture computed keys. They are declared in a + // `var` statement right before the class. + let temp_refs_before = self.temp_refs_to_declare.len(); + let mut accessor_storage_counter: usize = 0; + for prop in s_class.class.properties.slice_mut().iter_mut() { + if prop.kind == PropertyKind::ClassStaticBlock { + class_properties.push(core::mem::take(prop)); + continue; + } + + let is_method = prop.flags.contains(Flags::Property::IsMethod); + let is_static = prop.flags.contains(Flags::Property::IsStatic); + let is_auto_accessor = prop.kind == PropertyKind::AutoAccessor; + // merge parameter decorators with method decorators - if prop.flags.contains(Flags::Property::IsMethod) { + if is_method { if let Some(prop_value) = prop.value { match prop_value.data { js_ast::ExprData::EFunction(func) => { @@ -6429,44 +6449,73 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } - // TODO: prop.kind == .declare and prop.value == null + let is_decorated = prop.ts_decorators.len_u32() > 0; + if !is_decorated && !is_auto_accessor { + class_properties.push(core::mem::take(prop)); + continue; + } - if prop.ts_decorators.len_u32() > 0 { - let descriptor_key = prop.key.expect("infallible: prop has key"); - let loc = descriptor_key.loc; + let key = prop.key.expect("infallible: prop has key"); + let key_loc = key.loc; + let is_private_key = + matches!(key.data, js_ast::ExprData::EPrivateIdentifier(_)); + + // A computed key is evaluated once, where the member is defined. + // The decorator call (and the setter of an auto-accessor) then + // read it back from a temporary: + // + // class Foo { + // [_a = key()]() {} + // } + // __legacyDecorateClassTS([dec], Foo.prototype, _a, null); + let mut key_for_reuse = key; + if prop.flags.contains(Flags::Property::IsComputed) + && !matches!( + key.data, + js_ast::ExprData::EString(_) | js_ast::ExprData::ENumber(_) + ) + { + let temp_ref = self.generate_temp_ref(Some(b"_key")); + self.record_declared_symbol(temp_ref); + self.record_usage(temp_ref); + let temp_ident = self.new_expr(E::Identifier::init(temp_ref), key_loc); + prop.key = Some(Expr::assign(temp_ident, key)); + self.record_usage(temp_ref); + key_for_reuse = self.new_expr(E::Identifier::init(temp_ref), key_loc); + } - // TODO: when we have the `accessor` modifier, add `and !prop.flags.contains(.has_accessor_modifier)` to - // the if statement. - let descriptor_kind: Expr = - if !prop.flags.contains(Flags::Property::IsMethod) { - self.new_expr(E::Undefined {}, loc) - } else { - self.new_expr(E::Null {}, loc) - }; + if is_decorated { + // A method, getter, setter or auto-accessor has a property + // descriptor for the decorator to receive. A field does not. + let descriptor_kind: Expr = if is_method || is_auto_accessor { + self.new_expr(E::Null {}, key_loc) + } else { + self.new_expr(E::Undefined {}, key_loc) + }; let class_name = s_class.class.class_name.unwrap(); let class_ref = class_name.ref_; - let target: Expr = if prop.flags.contains(Flags::Property::IsStatic) { - self.record_usage(class_ref); - self.new_expr(E::Identifier::init(class_ref), class_name.loc) + self.record_usage(class_ref); + let class_ident = + self.new_expr(E::Identifier::init(class_ref), class_name.loc); + let target: Expr = if is_static { + class_ident } else { - let inner = - self.new_expr(E::Identifier::init(class_ref), class_name.loc); self.new_expr( E::Dot { - target: inner, + target: class_ident, name: b"prototype".into(), - name_loc: loc, + name_loc: key_loc, ..Default::default() }, - loc, + key_loc, ) }; let mut array = BumpVec::::new_in(self.arena); if self.options.features.emit_decorator_metadata { - self.emit_decorator_metadata_for_prop(prop, &mut array, loc); + self.emit_decorator_metadata_for_prop(prop, &mut array, key_loc); } let mut full = BumpVec::::with_capacity_in( @@ -6481,21 +6530,18 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O items: full_items, ..Default::default() }, - loc, + key_loc, ); let args_slice = self.arena.alloc_slice_copy(&[ array_expr, target, - descriptor_key, + key_for_reuse, descriptor_kind, ]); let args = ExprNodeList::from_arena_slice(args_slice); - let decorator = self.call_runtime( - prop.key.expect("infallible: prop has key").loc, - b"__legacyDecorateClassTS", - args, - ); + let decorator = + self.call_runtime(key_loc, b"__legacyDecorateClassTS", args); let decorator_stmt = self.s( S::SExpr { value: decorator, @@ -6504,200 +6550,236 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O decorator.loc, ); - if prop.flags.contains(Flags::Property::IsStatic) { + if is_static { static_decorators.push(decorator_stmt); } else { instance_decorators.push(decorator_stmt); } } - if prop.kind != PropertyKind::ClassStaticBlock - && !prop.flags.contains(Flags::Property::IsMethod) - && !matches!( - prop.key.map(|k| k.data), - Some(js_ast::ExprData::EPrivateIdentifier(_)) - ) - && prop.ts_decorators.len_u32() > 0 - { - // remove decorated fields without initializers to avoid assigning undefined. - let Some(initializer) = prop.initializer else { - continue; + if is_auto_accessor { + // `accessor x = init` becomes a private storage field plus a + // getter/setter pair: + // + // #x_accessor_storage = init; + // get x() { return this.#x_accessor_storage; } + // set x(v) { this.#x_accessor_storage = v; } + let storage_name: &'a [u8] = 'storage_name: { + let base: &'a [u8] = match key.data { + js_ast::ExprData::EString(s) + if s.is_utf8() + && !prop.flags.contains(Flags::Property::IsComputed) + && js_lexer::is_identifier(s.slice8()) => + { + s.string(self.arena).expect("oom") + } + js_ast::ExprData::EPrivateIdentifier(private) => { + &self.load_name_from_ref(private.ref_)[1..] + } + _ => { + let name = bun_alloc::arena_format!( + in self.arena, + "#accessor_storage_{}", + accessor_storage_counter + ) + .into_bump_str() + .as_bytes(); + accessor_storage_counter += 1; + break 'storage_name name; + } + }; + bun_alloc::arena_format!( + in self.arena, + "#{}_accessor_storage", + bstr::BStr::new(base) + ) + .into_bump_str() + .as_bytes() }; - - let mut target: Expr; - if prop.flags.contains(Flags::Property::IsStatic) { - let class_name = s_class.class.class_name.unwrap(); - let class_ref = class_name.ref_; - self.record_usage(class_ref); - target = self.new_expr(E::Identifier::init(class_ref), class_name.loc); + let storage_kind = if is_static { + js_ast::symbol::Kind::PrivateStaticField } else { - target = self.new_expr( - E::This {}, - prop.key.expect("infallible: prop has key").loc, - ); - } - - let key = prop.key.expect("infallible: prop has key"); - target = match &key.data { - js_ast::ExprData::EString(s) - if s.is_utf8() - && !prop.flags.contains(Flags::Property::IsComputed) => - { - self.new_expr( - E::Dot { - target, - name: s.data, - name_loc: key.loc, - ..Default::default() - }, - key.loc, - ) - } - _ => self.new_expr( - E::Index { - target, - index: key, - optional_chain: None, - }, - key.loc, - ), + js_ast::symbol::Kind::PrivateField }; + let storage_ref = self.new_symbol(storage_kind, storage_name); + let storage_key = + self.new_expr(E::PrivateIdentifier { ref_: storage_ref }, key_loc); - // remove fields with decorators from class body. Move static members outside of class. - if prop.flags.contains(Flags::Property::IsStatic) { - static_members.push(Stmt::assign(target, initializer)); - } else { - instance_members.push(Stmt::assign(target, initializer)); - } - continue; - } - - // The old backing slice is overwritten right after this loop, so - // `take` is fine here (Property: Default). - class_properties.push(core::mem::take(prop)); - } - - s_class.class.properties = - bun_ast::StoreSlice::new_mut(class_properties.into_bump_slice_mut()); - - if !instance_members.is_empty() { - if let Some(mut cf) = constructor_function { - // `body.stmts` is an arena-owned `StoreSlice`. - let old_stmts: &[Stmt] = cf.func.body.stmts.slice(); - // statements coming from class body inserted after super call or beginning of constructor. - let mut super_index: Option = None; - for (index, item) in old_stmts.iter().enumerate() { - if !matches!(item.data, js_ast::StmtData::SExpr(se) if matches!(se.value.data, js_ast::ExprData::ECall(c) if matches!(c.target.data, js_ast::ExprData::ESuper(_)))) - { - continue; - } - super_index = Some(index); - break; + let mut storage_flags = Flags::PropertySet::empty(); + if is_static { + storage_flags.insert(Flags::Property::IsStatic); } + class_properties.push(G::Property { + kind: PropertyKind::Normal, + flags: storage_flags, + key: Some(storage_key), + initializer: prop.initializer.take(), + ..Default::default() + }); - let i = super_index.map(|j| j + 1).unwrap_or(0); - let mut constructor_stmts = BumpVec::::with_capacity_in( - old_stmts.len() + instance_members.len(), - self.arena, + // get x() { return this.#x_accessor_storage; } + let this_expr = self.new_expr(E::This {}, key_loc); + let storage_index = + self.new_expr(E::PrivateIdentifier { ref_: storage_ref }, key_loc); + let get_value = self.new_expr( + E::Index { + target: this_expr, + index: storage_index, + optional_chain: None, + }, + key_loc, ); - constructor_stmts.extend_from_slice(&old_stmts[..i]); - constructor_stmts.extend_from_slice(&instance_members); - constructor_stmts.extend_from_slice(&old_stmts[i..]); - - cf.func.body.stmts = - bun_ast::StoreSlice::new_mut(constructor_stmts.into_bump_slice_mut()); - } else { - // Rebuild the property list with the new entry at index 0 - // (Property is not Clone). - let old_props: bun_ast::StoreSlice = s_class.class.properties; - let old_len = old_props.len(); - let mut properties = - BumpVec::::with_capacity_in(old_len + 1, self.arena); - let mut constructor_stmts = BumpVec::::new_in(self.arena); - - if s_class.class.extends.is_some() { - let target = self.new_expr(E::Super {}, stmt.loc); - let arguments_ref = - self.new_symbol(js_ast::symbol::Kind::Unbound, arguments_str); - VecExt::append(&mut self.current_scope_mut().generated, arguments_ref); - - let spread_inner = - self.new_expr(E::Identifier::init(arguments_ref), stmt.loc); - let super_ = self.new_expr( - E::Spread { - value: spread_inner, - }, - stmt.loc, - ); - let args = ExprNodeList::init_one(super_); - - let call_value = self.new_expr( - E::Call { - target, - args, - ..Default::default() - }, - stmt.loc, - ); - constructor_stmts.push(self.s( - S::SExpr { - value: call_value, + let get_body = self.arena.alloc_slice_copy(&[self.s( + S::Return { + value: Some(get_value), + }, + key_loc, + )]); + let get_fn = self.new_expr( + E::Function { + func: G::Fn { + body: G::FnBody { + stmts: bun_ast::StoreSlice::new_mut(get_body), + loc: key_loc, + }, + open_parens_loc: key_loc, ..Default::default() }, - stmt.loc, - )); - } - - constructor_stmts.extend_from_slice(&instance_members); + }, + key_loc, + ); - let key_expr = - self.new_expr(E::EString::from_static(b"constructor"), stmt.loc); - let value_expr = self.new_expr( + // set x(v) { this.#x_accessor_storage = v; } + let setter_arg_ref = self.new_symbol(js_ast::symbol::Kind::Other, b"v"); + let this_expr = self.new_expr(E::This {}, key_loc); + let storage_index = + self.new_expr(E::PrivateIdentifier { ref_: storage_ref }, key_loc); + let set_target = self.new_expr( + E::Index { + target: this_expr, + index: storage_index, + optional_chain: None, + }, + key_loc, + ); + self.record_usage(setter_arg_ref); + let set_value = self.new_expr(E::Identifier::init(setter_arg_ref), key_loc); + let set_body = self + .arena + .alloc_slice_copy(&[Stmt::assign(set_target, set_value)]); + let setter_binding = self.b( + B::Identifier { + r#ref: setter_arg_ref, + }, + key_loc, + ); + let setter_args = self.arena.alloc(G::Arg { + binding: setter_binding, + ..Default::default() + }); + let set_fn = self.new_expr( E::Function { func: G::Fn { - name: None, - open_parens_loc: bun_ast::Loc::EMPTY, - args: bun_ast::StoreSlice::EMPTY, + args: bun_ast::StoreSlice::new_mut(core::slice::from_mut( + setter_args, + )), body: G::FnBody { - loc: stmt.loc, - stmts: bun_ast::StoreSlice::new_mut( - constructor_stmts.into_bump_slice_mut(), - ), + stmts: bun_ast::StoreSlice::new_mut(set_body), + loc: key_loc, }, - flags: Flags::FUNCTION_NONE, + open_parens_loc: key_loc, ..Default::default() }, }, - stmt.loc, + key_loc, ); - properties.push(G::Property { - flags: Flags::Property::IsMethod.into(), - key: Some(key_expr), - value: Some(value_expr), + + let mut accessor_flags = prop.flags; + accessor_flags.insert(Flags::Property::IsMethod); + class_properties.push(G::Property { + kind: PropertyKind::Get, + flags: accessor_flags, + key: prop.key, + value: Some(get_fn), ..Default::default() }); - for old in old_props.slice_mut().iter_mut() { - properties.push(core::mem::take(old)); - } + class_properties.push(G::Property { + kind: PropertyKind::Set, + flags: accessor_flags, + key: Some(key_for_reuse), + value: Some(set_fn), + ..Default::default() + }); + continue; + } - s_class.class.properties = - bun_ast::StoreSlice::new_mut(properties.into_bump_slice_mut()); + // A "declare" or "abstract" member emits nothing. It is only in + // the list because of its decorators, which ran above. + if matches!(prop.kind, PropertyKind::Declare | PropertyKind::Abstract) { + continue; } - // TODO: make sure "super()" comes before instance field initializers - // https://github.com/evanw/esbuild/blob/e9413cc4f7ab87263ea244a999c6fa1f1e34dc65/internal/js_parser/js_parser_lower.go#L2742 + // With "useDefineForClassFields: false", `visit_class` moved the + // instance field initializers into the constructor and TypeScript + // omits a field without an initializer entirely. The property + // only stayed in the list for the decorator call above. + if !use_define + && !is_method + && !is_static + && !is_private_key + && prop.kind == PropertyKind::Normal + && prop.initializer.is_none() + { + continue; + } + + // The old backing slice is overwritten right after this loop, so + // `take` is fine here (Property: Default). + class_properties.push(core::mem::take(prop)); } + // The decorator calls run after the class. When one of them reads a + // `#private` name, they have to run inside the class body instead, so + // they go into a static block at the end of the body. Static blocks run + // in order, so this one runs after every field initializer. + let decorators_in_static_block = s_class.class.ts_decorators_use_private_names + && (!instance_decorators.is_empty() || !static_decorators.is_empty()); + if decorators_in_static_block { + let mut block_stmts = BumpVec::::with_capacity_in( + instance_decorators.len() + static_decorators.len(), + self.arena, + ); + block_stmts.extend_from_slice(&instance_decorators); + block_stmts.extend_from_slice(&static_decorators); + let static_block = self.arena.alloc(G::ClassStaticBlock { + loc: s_class.class.close_brace_loc, + stmts: bun_alloc::AstVec::::from_bump_vec(block_stmts), + }); + class_properties.push(G::Property { + kind: PropertyKind::ClassStaticBlock, + class_static_block: Some(bun_ast::StoreRef::from_bump(static_block)), + ..Default::default() + }); + } + + s_class.class.properties = + bun_ast::StoreSlice::new_mut(class_properties.into_bump_slice_mut()); + let mut stmts_count: usize = - 1 + static_members.len() + instance_decorators.len() + static_decorators.len(); + 2 + instance_decorators.len() + static_decorators.len(); if s_class.class.ts_decorators.len_u32() > 0 { stmts_count += 1; } let mut stmts = BumpVec::::with_capacity_in(stmts_count, self.arena); + if let Some(temp_decls) = self.drain_capture_temp_decls(temp_refs_before, class_loc) + { + stmts.push(temp_decls); + } stmts.push(stmt); - stmts.extend_from_slice(&static_members); - stmts.extend_from_slice(&instance_decorators); - stmts.extend_from_slice(&static_decorators); + if !decorators_in_static_block { + stmts.extend_from_slice(&instance_decorators); + stmts.extend_from_slice(&static_decorators); + } if s_class.class.ts_decorators.len_u32() > 0 { let mut array: Vec = s_class.class.ts_decorators.move_to_list_managed(); @@ -6911,8 +6993,15 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } } - PropertyKind::Spread | PropertyKind::Declare | PropertyKind::AutoAccessor => {} // not allowed in a class (auto_accessor is standard decorators only) - PropertyKind::ClassStaticBlock => {} // not allowed to decorate this + PropertyKind::AutoAccessor => { + // typescript only sets design:type for an auto-accessor. + let v = self + .serialize_metadata(prop.ts_metadata.clone()) + .expect("unreachable"); + push_metadata!(b"design:type", v); + } + PropertyKind::Spread | PropertyKind::Declare => {} // not allowed in a class + PropertyKind::ClassStaticBlock => {} // not allowed to decorate this } } @@ -8733,6 +8822,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O await_target: None, temp_refs_to_declare: BumpVec::new_in(arena), temp_ref_count: 0, + private_name_use_count: 0, + ts_decorators_use_private_names: false, relocated_top_level_vars: BumpVec::new_in(arena), after_arrow_body_loc: bun_ast::Loc::EMPTY, const_values: Default::default(), diff --git a/src/js_parser/parse/mod.rs b/src/js_parser/parse/mod.rs index 7b3519a9ba8a..1b69873a20de 100644 --- a/src/js_parser/parse/mod.rs +++ b/src/js_parser/parse/mod.rs @@ -202,11 +202,13 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O allow_ts_decorators: class_opts.allow_ts_decorators, class_has_extends: extends.is_some(), has_argument_decorators: false, + decorator_scope: Some(p.current_scope), ..Default::default() }; // Parse decorators for this property let first_decorator_loc = p.lexer.loc(); + let first_decorator_range = p.lexer.range(); let property_scope_index = p.scopes_in_order.len(); if opts.allow_ts_decorators { opts.ts_decorators = p.parse_type_script_decorators()?; @@ -221,6 +223,28 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // read fields before move (G::Property is not Copy). let prop_kind = property.kind; let prop_key = property.key; + + // TypeScript's experimental decorators are lowered to statements + // after the class declaration. There is no place to put them for a + // class expression, so reject them like tsc and esbuild do. + if Self::IS_TYPESCRIPT_ENABLED + && !p.options.features.standard_decorators + && class_opts.is_class_expr + { + let decorator_range = if opts.ts_decorators.len() > 0 { + Some(first_decorator_range) + } else { + Self::first_argument_decorator_range(&property) + }; + if let Some(range) = decorator_range { + p.log().add_range_error( + Some(p.source), + range, + b"TypeScript experimental decorators can only be used with class declarations", + ); + } + } + properties.push(property); has_auto_accessor = has_auto_accessor || prop_kind == js_ast::g::PropertyKind::AutoAccessor; @@ -275,9 +299,27 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O has_decorators: has_any_decorators, should_lower_standard_decorators: p.options.features.standard_decorators && (has_any_decorators || has_auto_accessor), + ts_decorators_use_private_names: false, }) } + /// The location of the first parameter decorator of a method property, if any. + fn first_argument_decorator_range(property: &G::Property) -> Option { + let value = property.value?; + let js_ast::expr::Data::EFunction(func) = value.data else { + return None; + }; + func.func + .args + .iter() + .flat_map(|arg| arg.ts_decorators.slice().iter()) + .next() + .map(|decorator| bun_ast::Range { + loc: decorator.loc, + len: 0, + }) + } + pub(crate) fn parse_template_parts( &mut self, include_raw: bool, diff --git a/src/js_parser/parse/parse_fn.rs b/src/js_parser/parse/parse_fn.rs index 664f4eaf94fb..7b02eb267b2f 100644 --- a/src/js_parser/parse/parse_fn.rs +++ b/src/js_parser/parse/parse_fn.rs @@ -228,7 +228,38 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let mut ts_decorators = bun_alloc::AstAlloc::vec(); if opts.allow_ts_decorators { - ts_decorators = p.parse_type_script_decorators()?; + // TypeScript parameter decorators are not evaluated where they are + // written. The lowering moves them after the class declaration: + // + // class Foo { + // foo(@bar() baz) {} + // } + // + // becomes + // + // class Foo { + // foo(baz) {} + // } + // __legacyDecorateClassTS([ + // __legacyDecorateParamTS(0, bar()) + // ], Foo.prototype, "foo", null); + // + // so "await" inside a parameter decorator follows the context that + // encloses the class, not the method, and a name in it is looked up + // from the class body scope, not from the argument scope. + let inner_allow_await = p.fn_or_arrow_data_parse.allow_await; + let inner_needs_async_loc = p.fn_or_arrow_data_parse.needs_async_loc; + let inner_scope = p.current_scope; + p.fn_or_arrow_data_parse.allow_await = old_fn_or_arrow_data.allow_await; + p.fn_or_arrow_data_parse.needs_async_loc = old_fn_or_arrow_data.needs_async_loc; + if let Some(decorator_scope) = opts.decorator_scope { + p.current_scope = decorator_scope; + } + let decorators = p.parse_type_script_decorators(); + p.current_scope = inner_scope; + p.fn_or_arrow_data_parse.allow_await = inner_allow_await; + p.fn_or_arrow_data_parse.needs_async_loc = inner_needs_async_loc; + ts_decorators = decorators?; if ts_decorators.len_u32() > 0 { arg_has_decorators = true; } diff --git a/src/js_parser/parse/parse_prefix.rs b/src/js_parser/parse/parse_prefix.rs index bc5d24b92166..bb4c0c0727cf 100644 --- a/src/js_parser/parse/parse_prefix.rs +++ b/src/js_parser/parse/parse_prefix.rs @@ -579,6 +579,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O &ParseClassOptions { allow_ts_decorators: Self::IS_TYPESCRIPT_ENABLED || p.options.features.standard_decorators, + is_class_expr: true, ..Default::default() }, )?; @@ -589,6 +590,17 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O fn pfx_t_at(p: &mut Self) -> PResult { // Parse decorators before a class expression: @dec class { ... } + // + // TypeScript's experimental decorators only work on class declarations. + // The lowering emits the decorator calls as statements after the class, + // which has no equivalent for an expression. + if Self::IS_TYPESCRIPT_ENABLED && !p.options.features.standard_decorators { + p.log().add_range_error( + Some(p.source), + p.lexer.range(), + b"TypeScript experimental decorators cannot be used in expression position", + ); + } let ts_decorators = p.parse_type_script_decorators()?; // Expect class keyword after decorators @@ -650,6 +662,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O &ParseClassOptions { ts_decorators: ts_decorators_slice, allow_ts_decorators: true, + is_class_expr: true, ..Default::default() }, )?; diff --git a/src/js_parser/parse/parse_property.rs b/src/js_parser/parse/parse_property.rs index 81fa6a8f0224..1385c1cd51e8 100644 --- a/src/js_parser/parse/parse_property.rs +++ b/src/js_parser/parse/parse_property.rs @@ -104,6 +104,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O allow_super_call: opts.class_has_extends && is_constructor, allow_super_property: true, allow_ts_decorators: opts.allow_ts_decorators, + decorator_scope: opts.decorator_scope, is_constructor, has_decorators: opts.ts_decorators.len() > 0 || (opts.has_class_decorators && is_constructor), @@ -463,10 +464,11 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } PropertyModifierKeyword::PAccessor => { - // "accessor" keyword for auto-accessor fields (TC39 standard decorators) + // "accessor" keyword for auto-accessor fields. Part of the + // TC39 decorators proposal, but TypeScript accepts it with + // "experimentalDecorators" too. if opts.is_class && !p.lexer.has_newline_before - && p.options.features.standard_decorators && PropertyModifierKeyword::find(raw) == Some(PropertyModifierKeyword::PAccessor) { diff --git a/src/js_parser/parse/parse_stmt.rs b/src/js_parser/parse/parse_stmt.rs index 67fb248ab74f..1d90e3c60cb0 100644 --- a/src/js_parser/parse/parse_stmt.rs +++ b/src/js_parser/parse/parse_stmt.rs @@ -79,8 +79,24 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // Parse decorators before class statements, which are potentially exported if Self::IS_TYPESCRIPT_ENABLED || p.options.features.standard_decorators { let scope_index = p.scopes_in_order.len(); + let at_loc = p.lexer.loc(); let ts_decorators = p.parse_type_script_decorators()?; + // "@x export @y class Foo {}" + // "@x export default @y class Foo {}" + if opts.ts_decorators.is_some() { + p.log().add_range_error( + Some(p.source), + bun_ast::Range { + loc: at_loc, + len: p.lexer.range().end().start - at_loc.start, + }, + b"Decorators are not valid here", + ); + p.discard_scopes_up_to(scope_index); + return p.parse_stmt(opts); + } + // If this turns out to be a "declare class" statement, we need to undo the // scopes that were potentially pushed while parsing the decorator arguments. // That can look like any one of the following: @@ -848,6 +864,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O if opts.ts_decorators.is_some() && p.lexer.token != T::TClass && p.lexer.token != T::TDefault + && p.lexer.token != T::TAt && !p.lexer.is_contextual_keyword(b"abstract") && !p.lexer.is_contextual_keyword(b"declare") { @@ -978,6 +995,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // "@decorator export default abstract class Foo {}" if opts.ts_decorators.is_some() && p.lexer.token != T::TClass + && p.lexer.token != T::TAt && !p.lexer.is_contextual_keyword(b"abstract") { p.lexer.expected(T::TClass)?; @@ -1037,8 +1055,16 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O )); } + // "export default class {}" + // "export default class Foo {}" + // "export default @x class {}" + // "export default @x class Foo {}" + // "export default function() {}" + // "export default function foo() {}" + // "export default interface Foo {}" if p.lexer.token == T::TFunction || p.lexer.token == T::TClass + || p.lexer.token == T::TAt || p.lexer.is_contextual_keyword(b"interface") { let mut _opts = ParseStatementOptions { @@ -1071,6 +1097,24 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O ref_: name.ref_, }; } + + // "export default @x class {}" with standard decorators: + // the class keeps the name "default", as the spec says, + // when it is lowered as an anonymous class expression. + if class.class.should_lower_standard_decorators { + let mut class_ref = *class; + let class_value = core::mem::take(&mut class_ref.class); + let expr = p.new_expr(class_value, stmt.loc); + let default_name = p.create_default_name(default_loc); + p.has_es_module_syntax = true; + return Ok(p.s( + S::ExportDefault { + default_name, + value: js_ast::StmtOrExpr::Expr(expr), + }, + loc, + )); + } } // "interface" turned out not to start an interface // declaration: the nested statement came back as an diff --git a/src/js_parser/parser.rs b/src/js_parser/parser.rs index 408deeeb45c0..ec7a490003a4 100644 --- a/src/js_parser/parser.rs +++ b/src/js_parser/parser.rs @@ -1216,6 +1216,11 @@ pub struct FnOrArrowDataParse { /// Allow TypeScript decorators in function arguments pub(crate) allow_ts_decorators: bool, + + /// The class body scope. TypeScript parameter decorators are parsed in it, + /// not in the argument scope, because they are evaluated with the other + /// decorators of the class. + pub(crate) decorator_scope: Option>, } impl Default for FnOrArrowDataParse { @@ -1237,6 +1242,7 @@ impl Default for FnOrArrowDataParse { track_arrow_arg_errors: false, allow_missing_body_for_type_script: false, allow_ts_decorators: false, + decorator_scope: None, } } } @@ -1321,6 +1327,8 @@ pub struct PropertyOpts { pub(crate) ts_decorators: ExprNodeList, pub(crate) has_argument_decorators: bool, pub(crate) has_class_decorators: bool, + /// See `FnOrArrowDataParse::decorator_scope`. + pub(crate) decorator_scope: Option>, } impl Default for PropertyOpts { @@ -1338,6 +1346,7 @@ impl Default for PropertyOpts { ts_decorators: bun_alloc::AstAlloc::vec(), has_argument_decorators: false, has_class_decorators: false, + decorator_scope: None, } } } @@ -1421,6 +1430,8 @@ pub struct ParseClassOptions<'a> { pub(crate) ts_decorators: &'a [Expr], pub(crate) allow_ts_decorators: bool, pub(crate) is_type_script_declare: bool, + /// TypeScript experimental decorators are rejected inside class expressions. + pub(crate) is_class_expr: bool, } #[repr(u8)] diff --git a/src/js_parser/visit/mod.rs b/src/js_parser/visit/mod.rs index 53786e4db37d..1a17e197112c 100644 --- a/src/js_parser/visit/mod.rs +++ b/src/js_parser/visit/mod.rs @@ -218,7 +218,18 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O for arg in args.iter_mut() { if arg.ts_decorators.len_u32() > 0 { + // A TypeScript parameter decorator is evaluated with the other + // decorators of the class, not inside the method, so a name in it + // refers to the class body scope. `parse_fn` parsed it in that scope + // and it is the parent of this function's argument scope. + let args_scope = self.current_scope; + let class_body_scope = args_scope + .parent + .expect("a method with parameter decorators is inside a class body"); + debug_assert!(class_body_scope.kind == ScopeKind::ClassBody); + self.current_scope = class_body_scope; self.visit_ts_decorators(&mut arg.ts_decorators); + self.current_scope = args_scope; } // reborrow per-iter. @@ -232,9 +243,13 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // `Vec` is not `Copy`; mutate in place. pub(crate) fn visit_ts_decorators(&mut self, decs: &mut ExprNodeList) { + let private_name_uses_before = self.private_name_use_count; for dec in decs.slice_mut() { self.visit_expr(dec); } + if self.private_name_use_count != private_name_uses_before { + self.ts_decorators_use_private_names = true; + } } pub(crate) fn visit_decls( @@ -842,6 +857,12 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O self.push_scope_for_visit_pass(ScopeKind::ClassBody, class.body_loc) .expect("unreachable"); + // Set by `visit_ts_decorators` when a member or parameter decorator of + // this class reads a `#private` name. Class decorators were visited + // above and do not count: they run outside the class either way. + let outer_ts_decorators_use_private_names = + core::mem::replace(&mut self.ts_decorators_use_private_names, false); + let mut constructor_function: Option> = None; let properties: &mut [G::Property] = class.properties.slice_mut(); for property in properties.iter_mut() { @@ -970,7 +991,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } if let Some(val) = property.initializer { - // if (property.flags.is_static and ) if let Some(name) = name_to_keep { let was_anon = val.is_anonymous_named(); let prev_dcn2 = self.decorator_class_name; @@ -996,6 +1016,11 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O self.fn_only_data_visit.is_this_nested = old_is_this_captured; } + class.ts_decorators_use_private_names = core::mem::replace( + &mut self.ts_decorators_use_private_names, + outer_ts_decorators_use_private_names, + ); + if Self::IS_TYPESCRIPT_ENABLED { // `lower_standard_decorators_stmt` owns field placement for such classes. let use_define = self.options.use_define_for_class_fields diff --git a/src/js_parser/visit/visit_binary.rs b/src/js_parser/visit/visit_binary.rs index e86ba8da5398..2c04aa7bcd18 100644 --- a/src/js_parser/visit/visit_binary.rs +++ b/src/js_parser/visit/visit_binary.rs @@ -715,6 +715,7 @@ impl BinaryExpressionVisitor { let name = p.load_name_from_ref(private.ref_); let result = p.find_symbol(e_.left.loc, name).expect("unreachable"); private.ref_ = result.r#ref; + p.private_name_use_count += 1; // Unlike regular identifiers, there are no unbound private identifiers let kind = p.symbols[result.r#ref.inner_index() as usize].kind; diff --git a/src/js_parser/visit/visit_expr.rs b/src/js_parser/visit/visit_expr.rs index b843756bb2e5..02662c5463ca 100644 --- a/src/js_parser/visit/visit_expr.rs +++ b/src/js_parser/visit/visit_expr.rs @@ -917,6 +917,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let name = p.load_name_from_ref(private.ref_); let result = p.find_symbol(e_.index.loc, name).expect("unreachable"); private.ref_ = result.r#ref; + p.private_name_use_count += 1; // Unlike regular identifiers, there are no unbound private identifiers let kind: js_ast::symbol::Kind = diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index 597bcd6952aa..aae5c3386363 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -55,7 +55,11 @@ bun_core::declare_scope!(cache, visible); /// offsets picked by a header byte) plus a body of tagged records with /// u8/u16/u32 ids and implied slots dropped, instead of fixed u32 arrays. /// Version 27: ModuleInfo string table holds Latin-1 / UTF-16 bodies, not WTF-8. -const EXPECTED_VERSION: u32 = 27; +/// Version 28: TypeScript experimental decorator lowering keeps decorated +/// fields in the class body, captures computed keys once, lowers `accessor` +/// members and moves decorator calls that read a `#private` name into a +/// static block. +const EXPECTED_VERSION: u32 = 28; /// Source files smaller than this are not written to / read from the on-disk /// transpiler cache. Originally 50 KiB, which excluded almost every file in a diff --git a/test/bundler/transpiler/decorators-legacy-lowering.test.ts b/test/bundler/transpiler/decorators-legacy-lowering.test.ts new file mode 100644 index 000000000000..6d4d3abd55fe --- /dev/null +++ b/test/bundler/transpiler/decorators-legacy-lowering.test.ts @@ -0,0 +1,345 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, nodeExe, tempDir } from "harness"; +import { join } from "node:path"; +import ts from "typescript"; + +// TypeScript "experimentalDecorators" lowering. Every fixture runs three ways: +// with `bun `, bundled and minified with `bun build --minify`, and as the +// JavaScript tsc emits for it (run with node when available). All three must +// print the same thing, so the expected output is tsc's behavior. + +interface Fixture { + source: string; + expected: string; + useDefineForClassFields?: boolean; + /** Extra files next to index.ts. */ + files?: Record; +} + +const fixtures: Record = { + "decorated fields stay in place: init order, this, super, no initializer": { + source: ` + function dec(t: any, k?: any, d?: any) {} + class Base { static x = 5 } + class A extends Base { + static a = (console.log("a"), 1); @dec static b = (console.log("b"), 2); static c = (console.log("c"), A.b); + @dec static t = this.a; + @dec static s = super.x; + x = (console.log("x"), 1); @dec y = (console.log("y"), 2); z = (console.log("z"), 3); + @dec w; + } + new A(); + const a: any = new A(); + console.log(A.c, A.t, A.s, "w" in a, Object.getOwnPropertyDescriptor(a, "y")?.value); + `, + expected: "a\nb\nc\nx\ny\nz\nx\ny\nz\n2 1 5 true 2\n", + }, + "useDefineForClassFields false: decorated fields follow [[Set]] semantics too": { + useDefineForClassFields: false, + source: ` + function dec(t: any, k?: any, d?: any) {} + class Base { static x = 5; set y(v: any) { console.log("setter", v) } get y() { return "getter" } } + class A extends Base { + static a = (console.log("a"), 1); @dec static b = (console.log("b"), 2); static c = (console.log("c"), A.b); + x = (console.log("x"), 1); @dec y = (console.log("y"), 2); z = (console.log("z"), 3); + @dec w; + } + const a: any = new A(); + console.log(A.c, "w" in a, a.y, Object.hasOwn(a, "y")); + `, + expected: "a\nb\nc\nx\ny\nsetter 2\nz\n2 false getter false\n", + }, + "computed keys are evaluated once and the decorator sees the same key": { + source: ` + let n = 0; const key = () => "k" + ++n; + function d2(t: any, p: string) { console.log("decorating", p) } + class F { @d2 [key()]() {} @d2 [key()] = 1 } + new F(); new F(); + console.log(n, JSON.stringify(Object.getOwnPropertyNames(F.prototype)), JSON.stringify(Object.keys(new F()))); + `, + expected: 'decorating k1\ndecorating k2\n2 ["constructor","k1"] ["k2"]\n', + }, + "a parameter decorator is evaluated in the scope around the class": { + source: ` + function pd(v: any) { console.log("dec arg =", v); return (...a: any[]) => {} } + let arg = 1; + class P { m(@pd(arg) arg = 2) { return arg } } + console.log(new P().m()); + `, + expected: "dec arg = 1\n2\n", + }, + "a parameter decorator can await in an enclosing async function": { + source: ` + function pd(v: any) { console.log("dec arg =", v); return (...a: any[]) => {} } + async function f(foo: Promise) { class C { m(@pd(await foo) a: any) {} } return new C() } + await f(Promise.resolve(42)); + console.log("done"); + `, + expected: "dec arg = 42\ndone\n", + }, + "decorators that read a static #private name run inside the class": { + source: ` + function pd(v: any) { console.log("dec arg =", v); return (...a: any[]) => {} } + class Q { + static #p = 1; + static { console.log("static block") } + m(@pd(Q.#p) a: any) {} + @pd(Q.#p + 1) b: any; + static #q = (console.log("static field"), 2); + } + console.log(Object.getOwnPropertyNames(Q.prototype).join(",")); + `, + expected: "static block\nstatic field\ndec arg = 1\ndec arg = 2\nconstructor,m\n", + }, + "export default @dec class keeps the binding and applies the decorator": { + files: { + "mod.ts": ` + function cd(cls: any) { console.log("decorated", cls.x); return class extends cls { static y = 2 } } + export default @cd class Bar { static x = 1 } + console.log(Bar.x, Bar.y); + `, + }, + source: ` + import Bar from "./mod"; + console.log(Bar.x, Bar.y); + `, + expected: "decorated 1\n1 2\n1 2\n", + }, + "accessor fields are lowered and can be decorated": { + source: ` + function dec(t: any, k: any, d: any) { console.log("dec", String(k), typeof d.get, typeof d.set) } + const sym = Symbol("s"); + class Acc { + accessor y = 2; + @dec accessor z = 3; + @dec static accessor s: string; + @dec accessor [sym] = 4; + accessor #p = 5; + p() { return this.#p } + } + const a = new Acc(); + console.log(a.y, a.z, Acc.s, a[sym], a.p()); + a.y = 20; a.z = 30; Acc.s = "s"; a[sym] = 40; + console.log(a.y, a.z, Acc.s, a[sym]); + console.log(JSON.stringify(Object.getOwnPropertyNames(a)), JSON.stringify(Object.getOwnPropertyNames(Acc.prototype))); + `, + expected: + 'dec z function function\ndec Symbol(s) function function\ndec s function function\n2 3 undefined 4 5\n20 30 s 40\n[] ["constructor","y","z","p"]\n', + }, +}; + +function tsconfig(useDefineForClassFields: boolean | undefined) { + return JSON.stringify({ + compilerOptions: { + experimentalDecorators: true, + ...(useDefineForClassFields === undefined ? {} : { useDefineForClassFields }), + }, + }); +} + +async function run(cmd: string[], cwd: string) { + await using proc = Bun.spawn({ cmd, cwd, env: bunEnv, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; +} + +function tscEmit(source: string, useDefineForClassFields: boolean | undefined) { + return ts.transpileModule(source, { + compilerOptions: { + experimentalDecorators: true, + useDefineForClassFields, + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + }, + }).outputText; +} + +describe("experimentalDecorators lowering", () => { + for (const [name, fixture] of Object.entries(fixtures)) { + const files = { + "tsconfig.json": tsconfig(fixture.useDefineForClassFields), + "index.ts": fixture.source, + ...fixture.files, + }; + + // One test per fixture runs the three variants one after the other, so + // the file does not start every subprocess at once. + test.concurrent( + name, + async () => { + { + using dir = tempDir("legacy-dec-run", files); + const { stdout, stderr, exitCode } = await run([bunExe(), "index.ts"], String(dir)); + expect({ mode: "bun", stdout, stderr, exitCode }).toEqual({ + mode: "bun", + stdout: fixture.expected, + stderr: "", + exitCode: 0, + }); + } + + { + using dir = tempDir("legacy-dec-build", files); + const build = await Bun.build({ + entrypoints: [join(String(dir), "index.ts")], + outdir: join(String(dir), "out"), + minify: true, + }); + expect(build.logs).toEqual([]); + const { stdout, stderr, exitCode } = await run([bunExe(), "out/index.js"], String(dir)); + expect({ mode: "bundled and minified", stdout, stderr, exitCode }).toEqual({ + mode: "bundled and minified", + stdout: fixture.expected, + stderr: "", + exitCode: 0, + }); + } + + { + // The reference: tsc's own output for the same source, run as plain JavaScript. + const emitted: Record = {}; + for (const [file, source] of Object.entries(files)) { + if (file.endsWith(".ts")) { + emitted[file.replace(/\.ts$/, ".mjs")] = tscEmit(source, fixture.useDefineForClassFields).replaceAll( + /from "(\.\/[^"]+)"/g, + 'from "$1.mjs"', + ); + } + } + using dir = tempDir("legacy-dec-tsc", emitted); + const { stdout, stderr, exitCode } = await run([nodeExe() ?? bunExe(), "index.mjs"], String(dir)); + expect({ mode: "tsc emit", stdout, stderr, exitCode }).toEqual({ + mode: "tsc emit", + stdout: fixture.expected, + stderr: "", + exitCode: 0, + }); + } + }, + 30_000, + ); + } + + test.concurrent("decorated auto-accessor gets design:type metadata", async () => { + using dir = tempDir("legacy-dec-metadata", { + "tsconfig.json": JSON.stringify({ + compilerOptions: { experimentalDecorators: true, emitDecoratorMetadata: true }, + }), + "index.ts": ` + (Reflect as any).metadata = (key: string, value: any) => (target: any, prop: string) => { + console.log(prop, key, value.name); + }; + function dec(t: any, k: any, d: any) {} + class Acc { + @dec accessor n: number = 1; + @dec accessor s: string; + } + console.log(new Acc().n); + `, + }); + const { stdout, stderr, exitCode } = await run([bunExe(), "index.ts"], String(dir)); + expect(stderr).toBe(""); + expect(stdout).toBe("n design:type Number\ns design:type String\n1\n"); + expect(exitCode).toBe(0); + }); + + test.concurrent("decorators on a class expression are a syntax error", async () => { + using dir = tempDir("legacy-dec-class-expr", { + "tsconfig.json": tsconfig(undefined), + "before.ts": ` + function cd(cls: any) {} + const X = @cd class Y {}; + `, + "member.ts": ` + function dec(t: any, k?: any) {} + const X = class Y { @dec x = 1 }; + `, + "param.ts": ` + function dec(t: any, k?: any, i?: any) {} + const X = class { m(@dec a: any) {} }; + `, + }); + const [before, member, param] = await Promise.all([ + run([bunExe(), "before.ts"], String(dir)), + run([bunExe(), "member.ts"], String(dir)), + run([bunExe(), "param.ts"], String(dir)), + ]); + expect(before.stderr).toContain("TypeScript experimental decorators cannot be used in expression position"); + expect(before.exitCode).not.toBe(0); + expect(member.stderr).toContain("TypeScript experimental decorators can only be used with class declarations"); + expect(member.exitCode).not.toBe(0); + expect(param.stderr).toContain("TypeScript experimental decorators can only be used with class declarations"); + expect(param.exitCode).not.toBe(0); + }); + + test.concurrent("decorators on both sides of export default are rejected", async () => { + using dir = tempDir("legacy-dec-double", { + "tsconfig.json": tsconfig(undefined), + "index.ts": ` + function cd(cls: any) {} + @cd export default @cd class Foo {} + `, + }); + const { stderr, exitCode } = await run([bunExe(), "index.ts"], String(dir)); + expect(stderr).toContain("Decorators are not valid here"); + expect(exitCode).not.toBe(0); + }); + + test.concurrent("Bun.Transpiler output shape matches tsc", async () => { + const transpiler = new Bun.Transpiler({ + loader: "ts", + tsconfig: { compilerOptions: { experimentalDecorators: true } }, + }); + const out = transpiler.transformSync(` + function dec(t: any, k?: any, d?: any) {} + let n = 0; const key = () => "k" + ++n; + class A { + static #p = 1; + @dec static a = this.x; + @dec b; + @dec [key()] = 2; + accessor c = 3; + m(@dec(A.#p) arg = 1) {} + } + `); + // Fields stay in the body, computed keys are captured once, and the + // decorator calls run in a static block because one reads `A.#p`. + expect(out).toMatchInlineSnapshot(` + "import { __legacyDecorateClassTS as __legacyDecorateClassTS_3r173x8m, __legacyDecorateParamTS as __legacyDecorateParamTS_1ycx8dha } from "bun:wrap"; + function dec(t, k, d) {} + let n = 0; + const key = () => "k" + ++n; + var __bun_temp_ref_1$; + + class A { + static #p = 1; + static a = this.x; + b; + [__bun_temp_ref_1$ = key()] = 2; + #c_accessor_storage = 3; + get c() { + return this.#c_accessor_storage; + } + set c(v) { + this.#c_accessor_storage = v; + } + m(arg = 1) {} + static { + __legacyDecorateClassTS_3r173x8m([ + dec + ], A.prototype, "b", undefined); + __legacyDecorateClassTS_3r173x8m([ + dec + ], A.prototype, __bun_temp_ref_1$, undefined); + __legacyDecorateClassTS_3r173x8m([ + __legacyDecorateParamTS_1ycx8dha(0, dec(A.#p)) + ], A.prototype, "m", null); + __legacyDecorateClassTS_3r173x8m([ + dec + ], A, "a", undefined); + } + } + " + `); + }); +}); diff --git a/test/bundler/transpiler/decorators-set-semantics/set-semantics-fixture.ts b/test/bundler/transpiler/decorators-set-semantics/set-semantics-fixture.ts new file mode 100644 index 000000000000..a65f4bcedf6d --- /dev/null +++ b/test/bundler/transpiler/decorators-set-semantics/set-semantics-fixture.ts @@ -0,0 +1,177 @@ +// @ts-nocheck +// Run by decorators.test.ts with this directory as the working directory, so +// this directory's tsconfig.json applies: experimentalDecorators with +// useDefineForClassFields: false. Decorated field initializers then use +// [[Set]] semantics and run the accessors the decorators define. +function expect(actual: unknown) { + return { + toBe(expected: unknown) { + if (actual !== expected) { + throw new Error(`Expected ${String(expected)} but received ${String(actual)}`); + } + }, + }; +} + +class IceCreamComponent { + @Emoji() + flavor = "vanilla"; +} + +// Property Decorator +function Emoji() { + return function (target: Object, key: string | symbol) { + let val = target[key]; + + const getter = () => { + return val; + }; + const setter = next => { + val = `🍦 ${next} 🍦`; + }; + + Object.defineProperty(target, key, { + get: getter, + set: setter, + enumerable: true, + configurable: true, + }); + }; +} + +const iceCream = new IceCreamComponent(); +expect(iceCream.flavor === "🍦 vanilla 🍦").toBe(true); +iceCream.flavor = "chocolate"; +expect(iceCream.flavor === "🍦 chocolate 🍦").toBe(true); + +// No instance field below has a computed key: with useDefineForClassFields +// false, one such key keeps every instance field of the class native. + +const h: unique symbol = Symbol.for("h"); +const q: unique symbol = Symbol.for("q"); +const u3: unique symbol = Symbol.for("u3"); +const u8: unique symbol = Symbol.for("u8"); + +class S { + @StringAppender("😛") k = 35; + @StringAppender("🤠") static j = 4; + @StringAppender("😵‍💫") private static [h] = 30; + @StringAppender("🤯") private static u = 60; + @StringAppender("🎃") private e = 10; + @StringAppender("👻") static [q] = 202; + @StringAppender("😇") r = S[h]; + _y: number; + @StringAppender("🤡") get y() { + return this._y; + } + set y(next) { + this._y = next; + } + #o = 100; + + @StringAppender("😍") u1: number; + @StringAppender("🥳") static u2: number; + @StringAppender("🤓") private static [u3]: number; + @StringAppender("🥺") private static u4: number; + @StringAppender("☹️") private u7: number; + @StringAppender("🙃") static [u8]: number; + + @StringAppender("🤔") u9 = this.u1; + @StringAppender("🤨") u10 = this.u2; + @StringAppender("🙂") u11 = S[u3]; + @StringAppender("🙁") u12 = S.u4; + @StringAppender("😶") u15 = this.u7; + @StringAppender("😏") u16 = S[u8]; + + constructor() { + this.k = 3; + expect(this.k).toBe("3 😛"); + expect(S.j).toBe(4); + expect(this.e).toBe("10 🎃"); + expect(S[h]).toBe(30); + expect(S.u).toBe(60); + expect(S[q]).toBe(202); + expect(this.#o).toBe(100); + expect(this.r).toBe("30 😇"); + expect(this.y).toBe(undefined); + this.y = 100; + expect(this.y).toBe(100); + + expect(this.u1).toBe(undefined); + expect(S.u2).toBe(undefined); + expect(S[u3]).toBe(undefined); + expect(S.u4).toBe(undefined); + expect(this.u7).toBe(undefined); + expect(S[u8]).toBe(undefined); + + expect(this.u9).toBe("undefined 🤔"); + expect(this.u10).toBe("undefined 🤨"); + expect(this.u11).toBe("undefined 🙂"); + expect(this.u12).toBe("undefined 🙁"); + expect(this.u15).toBe("undefined 😶"); + expect(this.u16).toBe("undefined 😏"); + + this.u1 = 100; + expect(this.u1).toBe("100 😍"); + S.u2 = 100; + expect(S.u2).toBe("100 🥳"); + S[u3] = 100; + expect(S[u3]).toBe("100 🤓"); + S.u4 = 100; + expect(S.u4).toBe("100 🥺"); + this.u7 = 100; + expect(this.u7).toBe("100 ☹️"); + S[u8] = 100; + expect(S[u8]).toBe("100 🙃"); + + expect(this.u9).toBe("undefined 🤔"); + expect(this.u10).toBe("undefined 🤨"); + expect(this.u11).toBe("undefined 🙂"); + expect(this.u12).toBe("undefined 🙁"); + expect(this.u15).toBe("undefined 😶"); + expect(this.u16).toBe("undefined 😏"); + } +} + +let s = new S(); +expect(s.u9).toBe("undefined 🤔"); +expect(s.u10).toBe("undefined 🤨"); +expect(s.u11).toBe("undefined 🙂"); +expect(s.u12).toBe("undefined 🙁"); +expect(s.u15).toBe("undefined 😶"); +expect(s.u16).toBe("undefined 😏"); + +s.u9 = 35; +expect(s.u9).toBe("35 🤔"); +s.u10 = 36; +expect(s.u10).toBe("36 🤨"); +s.u11 = 37; +expect(s.u11).toBe("37 🙂"); +s.u12 = 38; +expect(s.u12).toBe("38 🙁"); +s.u15 = 41; +expect(s.u15).toBe("41 😶"); +s.u16 = 42; +expect(s.u16).toBe("42 😏"); + +function StringAppender(emoji: string) { + return function (target: Object, key: string | symbol) { + let val = target[key]; + + const getter = () => { + return val; + }; + const setter = value => { + val = `${value} ${emoji}`; + }; + + Object.defineProperty(target, key, { + get: getter, + set: setter, + enumerable: true, + configurable: true, + }); + }; +} + +console.log("ok"); diff --git a/test/bundler/transpiler/decorators-set-semantics/tsconfig.json b/test/bundler/transpiler/decorators-set-semantics/tsconfig.json new file mode 100644 index 000000000000..00b8a14afab3 --- /dev/null +++ b/test/bundler/transpiler/decorators-set-semantics/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "useDefineForClassFields": false + } +} diff --git a/test/bundler/transpiler/decorators.test.ts b/test/bundler/transpiler/decorators.test.ts index 80800f17c415..13a4a58d5844 100644 --- a/test/bundler/transpiler/decorators.test.ts +++ b/test/bundler/transpiler/decorators.test.ts @@ -1,6 +1,7 @@ // @ts-nocheck import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe } from "harness"; +import { join } from "node:path"; import DecoratedClass from "./decorator-export-default-class-fixture"; import DecoratedAnonClass from "./decorator-export-default-class-fixture-anon"; @@ -368,6 +369,10 @@ test("decorators random", () => { expect(Object.isFrozen(IceCream)).toBe(true); + // Decorated fields keep [[Define]] semantics, like tsc and esbuild with + // useDefineForClassFields unset (the default for this test's tsconfig). The + // own property created by `flavor = "vanilla"` shadows the accessor the + // decorator put on the prototype. class IceCreamComponent { @Emoji() flavor = "vanilla"; @@ -395,165 +400,32 @@ test("decorators random", () => { } const iceCream = new IceCreamComponent(); - expect(iceCream.flavor === "🍦 vanilla 🍦").toBe(true); + expect(Object.getOwnPropertyDescriptor(iceCream, "flavor")).toEqual({ + value: "vanilla", + writable: true, + enumerable: true, + configurable: true, + }); + expect(iceCream.flavor).toBe("vanilla"); iceCream.flavor = "chocolate"; - expect(iceCream.flavor === "🍦 chocolate 🍦").toBe(true); - - const i: unique symbol = Symbol.for("i"); - const h: unique symbol = Symbol.for("h"); - const t: unique symbol = Symbol.for("t"); - const q: unique symbol = Symbol.for("q"); - const p: unique symbol = Symbol.for("p"); - const u3: unique symbol = Symbol.for("u3"); - const u5: unique symbol = Symbol.for("u5"); - const u6: unique symbol = Symbol.for("u6"); - const u8: unique symbol = Symbol.for("u8"); - - class S { - @StringAppender("😛") k = 35; - @StringAppender("🤠") static j = 4; - @StringAppender("😵‍💫") private static [h] = 30; - @StringAppender("🤯") private static u = 60; - @StringAppender("🤪") private [t] = 32; - @StringAppender("🤑") [i] = 8; - @StringAppender("🎃") private e = 10; - @StringAppender("👻") static [q] = 202; - @StringAppender("😇") r = S[h]; - _y: number; - @StringAppender("🤡") get y() { - return this._y; - } - set y(next) { - this._y = next; - } - #o = 100; - - @StringAppender("😍") u1: number; - @StringAppender("🥳") static u2: number; - @StringAppender("🤓") private static [u3]: number; - @StringAppender("🥺") private static u4: number; - @StringAppender("🤯") private [u5]: number; - @StringAppender("🤩") [u6]: number; - @StringAppender("☹️") private u7: number; - @StringAppender("🙃") static [u8]: number; - - @StringAppender("🤔") u9 = this.u1; - @StringAppender("🤨") u10 = this.u2; - @StringAppender("🙂") u11 = S[u3]; - @StringAppender("🙁") u12 = S.u4; - @StringAppender("😐") u13 = this[u5]; - @StringAppender("😑") u14 = this[u6]; - @StringAppender("😶") u15 = this.u7; - @StringAppender("😏") u16 = S[u8]; - - constructor() { - this.k = 3; - expect(this.k).toBe("3 😛"); - expect(S.j).toBe(4); - expect(this[i]).toBe("8 🤑"); - expect(this.e).toBe("10 🎃"); - expect(S[h]).toBe(30); - expect(S.u).toBe(60); - expect(this[t]).toBe("32 🤪"); - expect(S[q]).toBe(202); - expect(this.#o).toBe(100); - expect(this.r).toBe("30 😇"); - expect(this.y).toBe(undefined); - this.y = 100; - expect(this.y).toBe(100); - - expect(this.u1).toBe(undefined); - expect(S.u2).toBe(undefined); - expect(S[u3]).toBe(undefined); - expect(S.u4).toBe(undefined); - expect(this[u5]).toBe(undefined); - expect(this[u6]).toBe(undefined); - expect(this.u7).toBe(undefined); - expect(S[u8]).toBe(undefined); - - expect(this.u9).toBe("undefined 🤔"); - expect(this.u10).toBe("undefined 🤨"); - expect(this.u11).toBe("undefined 🙂"); - expect(this.u12).toBe("undefined 🙁"); - expect(this.u13).toBe("undefined 😐"); - expect(this.u14).toBe("undefined 😑"); - expect(this.u15).toBe("undefined 😶"); - expect(this.u16).toBe("undefined 😏"); - - this.u1 = 100; - expect(this.u1).toBe("100 😍"); - S.u2 = 100; - expect(S.u2).toBe("100 🥳"); - S[u3] = 100; - expect(S[u3]).toBe("100 🤓"); - S.u4 = 100; - expect(S.u4).toBe("100 🥺"); - this[u5] = 100; - expect(this[u5]).toBe("100 🤯"); - this[u6] = 100; - expect(this[u6]).toBe("100 🤩"); - this.u7 = 100; - expect(this.u7).toBe("100 ☹️"); - S[u8] = 100; - expect(S[u8]).toBe("100 🙃"); - - expect(this.u9).toBe("undefined 🤔"); - expect(this.u10).toBe("undefined 🤨"); - expect(this.u11).toBe("undefined 🙂"); - expect(this.u12).toBe("undefined 🙁"); - expect(this.u13).toBe("undefined 😐"); - expect(this.u14).toBe("undefined 😑"); - expect(this.u15).toBe("undefined 😶"); - expect(this.u16).toBe("undefined 😏"); - } - } - - let s = new S(); - expect(s.u9).toBe("undefined 🤔"); - expect(s.u10).toBe("undefined 🤨"); - expect(s.u11).toBe("undefined 🙂"); - expect(s.u12).toBe("undefined 🙁"); - expect(s.u13).toBe("undefined 😐"); - expect(s.u14).toBe("undefined 😑"); - expect(s.u15).toBe("undefined 😶"); - expect(s.u16).toBe("undefined 😏"); - - s.u9 = 35; - expect(s.u9).toBe("35 🤔"); - s.u10 = 36; - expect(s.u10).toBe("36 🤨"); - s.u11 = 37; - expect(s.u11).toBe("37 🙂"); - s.u12 = 38; - expect(s.u12).toBe("38 🙁"); - s.u13 = 39; - expect(s.u13).toBe("39 😐"); - s.u14 = 40; - expect(s.u14).toBe("40 😑"); - s.u15 = 41; - expect(s.u15).toBe("41 😶"); - s.u16 = 42; - expect(s.u16).toBe("42 😏"); - - function StringAppender(emoji: string) { - return function (target: Object, key: string | symbol) { - let val = target[key]; - - const getter = () => { - return val; - }; - const setter = value => { - val = `${value} ${emoji}`; - }; + expect(iceCream.flavor).toBe("chocolate"); + expect(Object.getOwnPropertyDescriptor(IceCreamComponent.prototype, "flavor").get()).toBe(undefined); +}); - Object.defineProperty(target, key, { - get: getter, - set: setter, - enumerable: true, - configurable: true, - }); - }; - } +test("decorators random (useDefineForClassFields: false)", async () => { + // The same decorators with [[Set]] semantics: every field initializer runs + // through the accessor the decorator defined on the prototype. The fixture + // directory has its own tsconfig.json with useDefineForClassFields: false. + await using proc = Bun.spawn({ + cmd: [bunExe(), "set-semantics-fixture.ts"], + env: bunEnv, + cwd: join(import.meta.dir, "decorators-set-semantics"), + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(stdout).toBe("ok\n"); + expect(exitCode).toBe(0); }); test("class field order", () => { @@ -1059,6 +931,7 @@ test("decorator and declare", () => { test("lowering many decorated instance fields into a large constructor body stays linear", async () => { // Hold N fixed; compare M=100 vs M=50000. If the splice-after-super() were O(M*N) // instead of O(M+N), tLarge/tSmall would be ~5x (debug) / ~90x (release) here, not ~2x. + // Only useDefineForClassFields: false moves field initializers into the constructor. await using proc = Bun.spawn({ cmd: [ bunExe(), @@ -1075,7 +948,7 @@ test("lowering many decorated instance fields into a large constructor body stay } const t = new Bun.Transpiler({ loader: "ts", - tsconfig: { compilerOptions: { experimentalDecorators: true } }, + tsconfig: { compilerOptions: { experimentalDecorators: true, useDefineForClassFields: false } }, }); function time(M) { const src = gen(M); diff --git a/test/bundler/transpiler/es-decorators.test.ts b/test/bundler/transpiler/es-decorators.test.ts index bdc61e68c48d..82fd7a6af0ef 100644 --- a/test/bundler/transpiler/es-decorators.test.ts +++ b/test/bundler/transpiler/es-decorators.test.ts @@ -527,7 +527,7 @@ describe("ES Decorators", () => { const { stderr, exitCode } = await runDecoratorTS(` @x export @y class Foo {} `); - expect(stderr).toContain('Expected "class" but found "@"'); + expect(stderr).toContain("Decorators are not valid here"); expect(exitCode).not.toBe(0); }); From 8adbbeaac7d11d3727b8b1e7b2deec17f121eeb3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:21:27 +0000 Subject: [PATCH 02/16] js_parser: keep the captured key of a dropped decorated field A decorated "declare" field with a computed key, or a decorated field that "useDefineForClassFields: false" omits, is removed from the class body. Its key expression still runs once, in a static block, because the decorator call reads the temporary. The omission of fields without an initializer only applies when visit_class actually lowered the instance fields, which it does not do when one of them has a computed key. --- src/js_parser/lower/lower_decorators.rs | 2 +- src/js_parser/p.rs | 53 +++++++++++++------ .../decorators-legacy-lowering.test.ts | 23 ++++++++ 3 files changed, 61 insertions(+), 17 deletions(-) diff --git a/src/js_parser/lower/lower_decorators.rs b/src/js_parser/lower/lower_decorators.rs index dfde7ab3c807..6f6c7dec8a1e 100644 --- a/src/js_parser/lower/lower_decorators.rs +++ b/src/js_parser/lower/lower_decorators.rs @@ -274,7 +274,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } /// Create a static block property from a single expression. - fn make_static_block(&mut self, expr: Expr, l: bun_ast::Loc) -> Property { + pub(crate) fn make_static_block(&mut self, expr: Expr, l: bun_ast::Loc) -> Property { let bump = self.arena; let stmt = self.s( S::SExpr { diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 581faaf2fcb2..448515fdf32a 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -6395,6 +6395,24 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let temp_refs_before = self.temp_refs_to_declare.len(); let mut accessor_storage_counter: usize = 0; + // With "useDefineForClassFields: false", `visit_class` moved the + // instance field initializers into the constructor and left a + // decorated field in the list, without its initializer, only for the + // decorator call. It skips that lowering when an instance field has a + // computed key it cannot hoist. Mirror the decision here. + let instance_fields_lowered = !use_define + && !s_class.class.properties.iter().any(|p| { + p.kind == PropertyKind::Normal + && !p.flags.contains(Flags::Property::IsMethod) + && !p.flags.contains(Flags::Property::IsStatic) + && p.flags.contains(Flags::Property::IsComputed) + && p.value.is_none() + && !matches!( + p.key.map(|k| k.data), + Some(js_ast::ExprData::EString(_) | js_ast::ExprData::ENumber(_)) + ) + }); + for prop in s_class.class.properties.slice_mut().iter_mut() { if prop.kind == PropertyKind::ClassStaticBlock { class_properties.push(core::mem::take(prop)); @@ -6469,6 +6487,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // } // __legacyDecorateClassTS([dec], Foo.prototype, _a, null); let mut key_for_reuse = key; + let mut captured_key: Option = None; if prop.flags.contains(Flags::Property::IsComputed) && !matches!( key.data, @@ -6479,7 +6498,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O self.record_declared_symbol(temp_ref); self.record_usage(temp_ref); let temp_ident = self.new_expr(E::Identifier::init(temp_ref), key_loc); - prop.key = Some(Expr::assign(temp_ident, key)); + let assign = Expr::assign(temp_ident, key); + prop.key = Some(assign); + captured_key = Some(assign); self.record_usage(temp_ref); key_for_reuse = self.new_expr(E::Identifier::init(temp_ref), key_loc); } @@ -6714,22 +6735,22 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } // A "declare" or "abstract" member emits nothing. It is only in - // the list because of its decorators, which ran above. - if matches!(prop.kind, PropertyKind::Declare | PropertyKind::Abstract) { - continue; - } - - // With "useDefineForClassFields: false", `visit_class` moved the - // instance field initializers into the constructor and TypeScript - // omits a field without an initializer entirely. The property - // only stayed in the list for the decorator call above. - if !use_define - && !is_method - && !is_static - && !is_private_key - && prop.kind == PropertyKind::Normal - && prop.initializer.is_none() + // the list because of its decorators, which ran above. A field + // without an initializer is omitted the same way when + // `visit_class` applied "useDefineForClassFields: false". + if matches!(prop.kind, PropertyKind::Declare | PropertyKind::Abstract) + || (instance_fields_lowered + && !is_method + && !is_static + && !is_private_key + && prop.kind == PropertyKind::Normal + && prop.initializer.is_none()) { + // The key expression still runs, for its side effects and + // because the decorator call reads the temporary. + if let Some(captured_key) = captured_key { + class_properties.push(self.make_static_block(captured_key, key_loc)); + } continue; } diff --git a/test/bundler/transpiler/decorators-legacy-lowering.test.ts b/test/bundler/transpiler/decorators-legacy-lowering.test.ts index 6d4d3abd55fe..9c7e1cef56d3 100644 --- a/test/bundler/transpiler/decorators-legacy-lowering.test.ts +++ b/test/bundler/transpiler/decorators-legacy-lowering.test.ts @@ -59,6 +59,29 @@ const fixtures: Record = { `, expected: 'decorating k1\ndecorating k2\n2 ["constructor","k1"] ["k2"]\n', }, + "a decorated declare field is dropped but its computed key still runs once": { + source: ` + function dec(t: any, k: any) { console.log("dec", String(k)) } + let n = 0; const k = () => "k" + ++n; + class A { @dec declare [k()]: number; @dec declare d: number; x = 1 } + console.log(n, JSON.stringify(Object.getOwnPropertyNames(new A()))); + `, + expected: 'dec k1\ndec d\n1 ["x"]\n', + }, + "useDefineForClassFields false: decorated fields next to a computed key": { + useDefineForClassFields: false, + source: ` + function dec(t: any, k: any) { console.log("dec", String(k)) } + const s = Symbol("s"); + class A { @dec w: number; [s] = 1; @dec [Symbol.iterator]: any; y = 2 } + const a: any = new A(); + console.log(JSON.stringify([a.y, a[s], a.w, a[Symbol.iterator]])); + `, + // Bun keeps a class with a computed instance key native instead of hoisting + // the key like tsc, so "w" is an own property here and not with tsc. The + // values and the decorator calls agree. + expected: "dec w\ndec Symbol(Symbol.iterator)\n[2,1,null,null]\n", + }, "a parameter decorator is evaluated in the scope around the class": { source: ` function pd(v: any) { console.log("dec arg =", v); return (...a: any[]) => {} } From 376088cb4a20b34219bb8244d625bae30f854f0e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:26:55 +0000 Subject: [PATCH 03/16] js_parser: mark lowered decorated fields as declare, trim comments With useDefineForClassFields false, visit_class keeps a decorated field in the class body only for its decorator call. Give that placeholder the "declare" kind instead of mirroring the lowering decision again in lower_class. A decorated declare field also gets design:type metadata, as tsc emits it. --- src/ast/g.rs | 2 - src/js_parser/p.rs | 76 +++---------------- src/js_parser/parse/mod.rs | 4 +- src/js_parser/parse/parse_fn.rs | 21 +---- src/js_parser/parse/parse_prefix.rs | 4 - src/js_parser/parse/parse_property.rs | 4 +- src/js_parser/parse/parse_stmt.rs | 13 +--- src/js_parser/parser.rs | 4 +- src/js_parser/visit/mod.rs | 20 ++--- src/jsc/RuntimeTranspilerCache.rs | 5 +- .../decorators-legacy-lowering.test.ts | 7 +- 11 files changed, 35 insertions(+), 125 deletions(-) diff --git a/src/ast/g.rs b/src/ast/g.rs index 2ff8b1fbc2b4..4ccce4fcfec8 100644 --- a/src/ast/g.rs +++ b/src/ast/g.rs @@ -64,8 +64,6 @@ pub struct Class { pub has_decorators: bool, pub should_lower_standard_decorators: bool, /// A member or parameter decorator reads a `#private` name of this class. - /// The TypeScript experimental decorator calls then have to run inside the - /// class body (a trailing static block), where the name is in scope. pub ts_decorators_use_private_names: bool, } diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 448515fdf32a..ee2dbc193a65 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -536,10 +536,9 @@ pub struct P<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> { pub(crate) temp_refs_to_declare: List<'a, TempRef>, pub(crate) temp_ref_count: i32, - /// Counts every `obj.#name` and `#name in obj` the visit pass resolves. - /// `visit_ts_decorators` compares it before and after the visit and sets - /// `ts_decorators_use_private_names` when a decorator read a private name. + /// Every `obj.#name` and `#name in obj` the visit pass resolves. pub(crate) private_name_use_count: u32, + /// Set by `visit_ts_decorators` when a decorator read a `#private` name. pub(crate) ts_decorators_use_private_names: bool, // When bundling, hoisted top-level local variables declared with "var" in @@ -6383,36 +6382,15 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } let class_loc = stmt.loc; - let use_define = self.options.use_define_for_class_fields; let mut constructor_function: Option> = None; let mut static_decorators = BumpVec::::new_in(self.arena); let mut instance_decorators = BumpVec::::new_in(self.arena); let mut class_properties = BumpVec::::new_in(self.arena); - // Temporaries that capture computed keys. They are declared in a - // `var` statement right before the class. let temp_refs_before = self.temp_refs_to_declare.len(); let mut accessor_storage_counter: usize = 0; - // With "useDefineForClassFields: false", `visit_class` moved the - // instance field initializers into the constructor and left a - // decorated field in the list, without its initializer, only for the - // decorator call. It skips that lowering when an instance field has a - // computed key it cannot hoist. Mirror the decision here. - let instance_fields_lowered = !use_define - && !s_class.class.properties.iter().any(|p| { - p.kind == PropertyKind::Normal - && !p.flags.contains(Flags::Property::IsMethod) - && !p.flags.contains(Flags::Property::IsStatic) - && p.flags.contains(Flags::Property::IsComputed) - && p.value.is_none() - && !matches!( - p.key.map(|k| k.data), - Some(js_ast::ExprData::EString(_) | js_ast::ExprData::ENumber(_)) - ) - }); - for prop in s_class.class.properties.slice_mut().iter_mut() { if prop.kind == PropertyKind::ClassStaticBlock { class_properties.push(core::mem::take(prop)); @@ -6475,17 +6453,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let key = prop.key.expect("infallible: prop has key"); let key_loc = key.loc; - let is_private_key = - matches!(key.data, js_ast::ExprData::EPrivateIdentifier(_)); - - // A computed key is evaluated once, where the member is defined. - // The decorator call (and the setter of an auto-accessor) then - // read it back from a temporary: - // - // class Foo { - // [_a = key()]() {} - // } - // __legacyDecorateClassTS([dec], Foo.prototype, _a, null); + + // `[_key = expr]() {}` so the decorator call can reuse `_key`. let mut key_for_reuse = key; let mut captured_key: Option = None; if prop.flags.contains(Flags::Property::IsComputed) @@ -6506,8 +6475,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } if is_decorated { - // A method, getter, setter or auto-accessor has a property - // descriptor for the decorator to receive. A field does not. + // Only a field has no property descriptor to pass along. let descriptor_kind: Expr = if is_method || is_auto_accessor { self.new_expr(E::Null {}, key_loc) } else { @@ -6579,12 +6547,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } if is_auto_accessor { - // `accessor x = init` becomes a private storage field plus a - // getter/setter pair: - // - // #x_accessor_storage = init; - // get x() { return this.#x_accessor_storage; } - // set x(v) { this.#x_accessor_storage = v; } + // `#x_accessor_storage = init; get x() {...} set x(v) {...}` let storage_name: &'a [u8] = 'storage_name: { let base: &'a [u8] = match key.data { js_ast::ExprData::EString(s) @@ -6734,20 +6697,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O continue; } - // A "declare" or "abstract" member emits nothing. It is only in - // the list because of its decorators, which ran above. A field - // without an initializer is omitted the same way when - // `visit_class` applied "useDefineForClassFields: false". - if matches!(prop.kind, PropertyKind::Declare | PropertyKind::Abstract) - || (instance_fields_lowered - && !is_method - && !is_static - && !is_private_key - && prop.kind == PropertyKind::Normal - && prop.initializer.is_none()) - { - // The key expression still runs, for its side effects and - // because the decorator call reads the temporary. + // A "declare" or "abstract" member emits nothing but its key. + if matches!(prop.kind, PropertyKind::Declare | PropertyKind::Abstract) { if let Some(captured_key) = captured_key { class_properties.push(self.make_static_block(captured_key, key_loc)); } @@ -6759,10 +6710,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O class_properties.push(core::mem::take(prop)); } - // The decorator calls run after the class. When one of them reads a - // `#private` name, they have to run inside the class body instead, so - // they go into a static block at the end of the body. Static blocks run - // in order, so this one runs after every field initializer. + // A decorator that reads a `#private` name only works inside the body. let decorators_in_static_block = s_class.class.ts_decorators_use_private_names && (!instance_decorators.is_empty() || !static_decorators.is_empty()); if decorators_in_static_block { @@ -6902,7 +6850,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } match prop.kind { - PropertyKind::Normal | PropertyKind::Abstract => { + PropertyKind::Normal | PropertyKind::Abstract | PropertyKind::Declare => { { // design:type let v = self @@ -7021,8 +6969,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O .expect("unreachable"); push_metadata!(b"design:type", v); } - PropertyKind::Spread | PropertyKind::Declare => {} // not allowed in a class - PropertyKind::ClassStaticBlock => {} // not allowed to decorate this + PropertyKind::Spread => {} // not allowed in a class + PropertyKind::ClassStaticBlock => {} // not allowed to decorate this } } diff --git a/src/js_parser/parse/mod.rs b/src/js_parser/parse/mod.rs index 1b69873a20de..9108494bd0c3 100644 --- a/src/js_parser/parse/mod.rs +++ b/src/js_parser/parse/mod.rs @@ -224,9 +224,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let prop_kind = property.kind; let prop_key = property.key; - // TypeScript's experimental decorators are lowered to statements - // after the class declaration. There is no place to put them for a - // class expression, so reject them like tsc and esbuild do. + // tsc and esbuild reject them too: the lowering needs a statement. if Self::IS_TYPESCRIPT_ENABLED && !p.options.features.standard_decorators && class_opts.is_class_expr diff --git a/src/js_parser/parse/parse_fn.rs b/src/js_parser/parse/parse_fn.rs index 7b02eb267b2f..c98bda0c2fe1 100644 --- a/src/js_parser/parse/parse_fn.rs +++ b/src/js_parser/parse/parse_fn.rs @@ -228,25 +228,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let mut ts_decorators = bun_alloc::AstAlloc::vec(); if opts.allow_ts_decorators { - // TypeScript parameter decorators are not evaluated where they are - // written. The lowering moves them after the class declaration: - // - // class Foo { - // foo(@bar() baz) {} - // } - // - // becomes - // - // class Foo { - // foo(baz) {} - // } - // __legacyDecorateClassTS([ - // __legacyDecorateParamTS(0, bar()) - // ], Foo.prototype, "foo", null); - // - // so "await" inside a parameter decorator follows the context that - // encloses the class, not the method, and a name in it is looked up - // from the class body scope, not from the argument scope. + // The lowering evaluates a parameter decorator with the class's + // decorators, outside the method: parse it in that context. let inner_allow_await = p.fn_or_arrow_data_parse.allow_await; let inner_needs_async_loc = p.fn_or_arrow_data_parse.needs_async_loc; let inner_scope = p.current_scope; diff --git a/src/js_parser/parse/parse_prefix.rs b/src/js_parser/parse/parse_prefix.rs index bb4c0c0727cf..7ca86af43127 100644 --- a/src/js_parser/parse/parse_prefix.rs +++ b/src/js_parser/parse/parse_prefix.rs @@ -590,10 +590,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O fn pfx_t_at(p: &mut Self) -> PResult { // Parse decorators before a class expression: @dec class { ... } - // - // TypeScript's experimental decorators only work on class declarations. - // The lowering emits the decorator calls as statements after the class, - // which has no equivalent for an expression. if Self::IS_TYPESCRIPT_ENABLED && !p.options.features.standard_decorators { p.log().add_range_error( Some(p.source), diff --git a/src/js_parser/parse/parse_property.rs b/src/js_parser/parse/parse_property.rs index 1385c1cd51e8..e80448c6a4ea 100644 --- a/src/js_parser/parse/parse_property.rs +++ b/src/js_parser/parse/parse_property.rs @@ -464,9 +464,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } PropertyModifierKeyword::PAccessor => { - // "accessor" keyword for auto-accessor fields. Part of the - // TC39 decorators proposal, but TypeScript accepts it with - // "experimentalDecorators" too. + // "accessor" keyword for auto-accessor fields if opts.is_class && !p.lexer.has_newline_before && PropertyModifierKeyword::find(raw) diff --git a/src/js_parser/parse/parse_stmt.rs b/src/js_parser/parse/parse_stmt.rs index 1d90e3c60cb0..6190799e6f57 100644 --- a/src/js_parser/parse/parse_stmt.rs +++ b/src/js_parser/parse/parse_stmt.rs @@ -83,7 +83,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let ts_decorators = p.parse_type_script_decorators()?; // "@x export @y class Foo {}" - // "@x export default @y class Foo {}" if opts.ts_decorators.is_some() { p.log().add_range_error( Some(p.source), @@ -1055,13 +1054,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O )); } - // "export default class {}" - // "export default class Foo {}" - // "export default @x class {}" - // "export default @x class Foo {}" - // "export default function() {}" - // "export default function foo() {}" - // "export default interface Foo {}" + // "export default @x class Foo {}" is a class declaration too if p.lexer.token == T::TFunction || p.lexer.token == T::TClass || p.lexer.token == T::TAt @@ -1098,9 +1091,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O }; } - // "export default @x class {}" with standard decorators: - // the class keeps the name "default", as the spec says, - // when it is lowered as an anonymous class expression. + // Lowered as an expression the class is named "default". if class.class.should_lower_standard_decorators { let mut class_ref = *class; let class_value = core::mem::take(&mut class_ref.class); diff --git a/src/js_parser/parser.rs b/src/js_parser/parser.rs index ec7a490003a4..ab0f0f51d9a4 100644 --- a/src/js_parser/parser.rs +++ b/src/js_parser/parser.rs @@ -1217,9 +1217,7 @@ pub struct FnOrArrowDataParse { /// Allow TypeScript decorators in function arguments pub(crate) allow_ts_decorators: bool, - /// The class body scope. TypeScript parameter decorators are parsed in it, - /// not in the argument scope, because they are evaluated with the other - /// decorators of the class. + /// The class body scope, where TypeScript parameter decorators are parsed. pub(crate) decorator_scope: Option>, } diff --git a/src/js_parser/visit/mod.rs b/src/js_parser/visit/mod.rs index 1a17e197112c..68cff287f096 100644 --- a/src/js_parser/visit/mod.rs +++ b/src/js_parser/visit/mod.rs @@ -218,10 +218,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O for arg in args.iter_mut() { if arg.ts_decorators.len_u32() > 0 { - // A TypeScript parameter decorator is evaluated with the other - // decorators of the class, not inside the method, so a name in it - // refers to the class body scope. `parse_fn` parsed it in that scope - // and it is the parent of this function's argument scope. + // Parsed in the class body scope (see `parse_fn`), so visit it there. let args_scope = self.current_scope; let class_body_scope = args_scope .parent @@ -857,9 +854,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O self.push_scope_for_visit_pass(ScopeKind::ClassBody, class.body_loc) .expect("unreachable"); - // Set by `visit_ts_decorators` when a member or parameter decorator of - // this class reads a `#private` name. Class decorators were visited - // above and do not count: they run outside the class either way. + // Class decorators were visited above and do not count. let outer_ts_decorators_use_private_names = core::mem::replace(&mut self.ts_decorators_use_private_names, false); @@ -1158,12 +1153,19 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O }; injected.push(Stmt::assign(target, init)); } - // Keep `#x;` for brand; keep decorated props for `lower_class`. - if is_private || prop.ts_decorators.len_u32() > 0 { + // Keep `#x;` for brand. A decorated field stays only for its + // decorator call and emits nothing itself, like `declare`. + if is_private { class_body.push(G::Property { initializer: None, ..prop }); + } else if prop.ts_decorators.len_u32() > 0 { + class_body.push(G::Property { + initializer: None, + kind: PropertyKind::Declare, + ..prop + }); } } diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index aae5c3386363..b60a351d5341 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -55,10 +55,7 @@ bun_core::declare_scope!(cache, visible); /// offsets picked by a header byte) plus a body of tagged records with /// u8/u16/u32 ids and implied slots dropped, instead of fixed u32 arrays. /// Version 27: ModuleInfo string table holds Latin-1 / UTF-16 bodies, not WTF-8. -/// Version 28: TypeScript experimental decorator lowering keeps decorated -/// fields in the class body, captures computed keys once, lowers `accessor` -/// members and moves decorator calls that read a `#private` name into a -/// static block. +/// Version 28: TypeScript experimental decorator lowering output changed. const EXPECTED_VERSION: u32 = 28; /// Source files smaller than this are not written to / read from the on-disk diff --git a/test/bundler/transpiler/decorators-legacy-lowering.test.ts b/test/bundler/transpiler/decorators-legacy-lowering.test.ts index 9c7e1cef56d3..0dec6b32807f 100644 --- a/test/bundler/transpiler/decorators-legacy-lowering.test.ts +++ b/test/bundler/transpiler/decorators-legacy-lowering.test.ts @@ -243,7 +243,7 @@ describe("experimentalDecorators lowering", () => { ); } - test.concurrent("decorated auto-accessor gets design:type metadata", async () => { + test.concurrent("decorated auto-accessor and declare fields get design:type metadata", async () => { using dir = tempDir("legacy-dec-metadata", { "tsconfig.json": JSON.stringify({ compilerOptions: { experimentalDecorators: true, emitDecoratorMetadata: true }, @@ -256,13 +256,14 @@ describe("experimentalDecorators lowering", () => { class Acc { @dec accessor n: number = 1; @dec accessor s: string; + @dec declare d: boolean; } - console.log(new Acc().n); + console.log(new Acc().n, "d" in new Acc()); `, }); const { stdout, stderr, exitCode } = await run([bunExe(), "index.ts"], String(dir)); expect(stderr).toBe(""); - expect(stdout).toBe("n design:type Number\ns design:type String\n1\n"); + expect(stdout).toBe("n design:type Number\ns design:type String\nd design:type Boolean\n1 false\n"); expect(exitCode).toBe(0); }); From beb35c799ee3957491f99b5b2e4bd5cdb6afd5d3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:27:44 +0000 Subject: [PATCH 04/16] js_parser: shorten comments in the decorator lowering --- src/js_parser/p.rs | 2 -- src/js_parser/parse/parse_fn.rs | 3 +-- src/js_parser/visit/mod.rs | 3 +-- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index ee2dbc193a65..7d5a281a027d 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -6705,8 +6705,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O continue; } - // The old backing slice is overwritten right after this loop, so - // `take` is fine here (Property: Default). class_properties.push(core::mem::take(prop)); } diff --git a/src/js_parser/parse/parse_fn.rs b/src/js_parser/parse/parse_fn.rs index c98bda0c2fe1..09037f5fa271 100644 --- a/src/js_parser/parse/parse_fn.rs +++ b/src/js_parser/parse/parse_fn.rs @@ -228,8 +228,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let mut ts_decorators = bun_alloc::AstAlloc::vec(); if opts.allow_ts_decorators { - // The lowering evaluates a parameter decorator with the class's - // decorators, outside the method: parse it in that context. + // Parameter decorators are evaluated outside the method, with the class. let inner_allow_await = p.fn_or_arrow_data_parse.allow_await; let inner_needs_async_loc = p.fn_or_arrow_data_parse.needs_async_loc; let inner_scope = p.current_scope; diff --git a/src/js_parser/visit/mod.rs b/src/js_parser/visit/mod.rs index 68cff287f096..1aa2c8b9bc60 100644 --- a/src/js_parser/visit/mod.rs +++ b/src/js_parser/visit/mod.rs @@ -1153,8 +1153,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O }; injected.push(Stmt::assign(target, init)); } - // Keep `#x;` for brand. A decorated field stays only for its - // decorator call and emits nothing itself, like `declare`. + // Keep `#x;` for brand. A decorated field stays only for its decorator call. if is_private { class_body.push(G::Property { initializer: None, From c029c67b900c64d12a33ff00124490a727630767 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:01:18 +0000 Subject: [PATCH 05/16] js_parser: lower accessor in class expressions, unique storage names The accessor rewrite moves into a helper that both lower_class and the class expression visit use, so a class expression with an accessor member is lowered too instead of reaching the printer. Storage names get a numeric suffix when the class already declares the private name, like tsc. The tsc comparison in the test runs typescript in node: loading the package in a debug build of bun takes longer than a test may. --- src/js_parser/p.rs | 404 ++++++++++-------- src/js_parser/visit/visit_expr.rs | 4 + .../decorators-legacy-lowering.test.ts | 144 +++---- 3 files changed, 311 insertions(+), 241 deletions(-) diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 7d5a281a027d..daa346b928c3 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -6389,7 +6389,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let mut class_properties = BumpVec::::new_in(self.arena); let temp_refs_before = self.temp_refs_to_declare.len(); - let mut accessor_storage_counter: usize = 0; + let mut accessor_storage_names = self.private_names_in_class(&s_class.class); for prop in s_class.class.properties.slice_mut().iter_mut() { if prop.kind == PropertyKind::ClassStaticBlock { @@ -6451,28 +6451,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O continue; } - let key = prop.key.expect("infallible: prop has key"); - let key_loc = key.loc; - - // `[_key = expr]() {}` so the decorator call can reuse `_key`. - let mut key_for_reuse = key; - let mut captured_key: Option = None; - if prop.flags.contains(Flags::Property::IsComputed) - && !matches!( - key.data, - js_ast::ExprData::EString(_) | js_ast::ExprData::ENumber(_) - ) - { - let temp_ref = self.generate_temp_ref(Some(b"_key")); - self.record_declared_symbol(temp_ref); - self.record_usage(temp_ref); - let temp_ident = self.new_expr(E::Identifier::init(temp_ref), key_loc); - let assign = Expr::assign(temp_ident, key); - prop.key = Some(assign); - captured_key = Some(assign); - self.record_usage(temp_ref); - key_for_reuse = self.new_expr(E::Identifier::init(temp_ref), key_loc); - } + let key_loc = prop.key.expect("infallible: prop has key").loc; + let (key_for_reuse, captured_key) = self.capture_computed_key(prop); if is_decorated { // Only a field has no property descriptor to pass along. @@ -6547,153 +6527,12 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } if is_auto_accessor { - // `#x_accessor_storage = init; get x() {...} set x(v) {...}` - let storage_name: &'a [u8] = 'storage_name: { - let base: &'a [u8] = match key.data { - js_ast::ExprData::EString(s) - if s.is_utf8() - && !prop.flags.contains(Flags::Property::IsComputed) - && js_lexer::is_identifier(s.slice8()) => - { - s.string(self.arena).expect("oom") - } - js_ast::ExprData::EPrivateIdentifier(private) => { - &self.load_name_from_ref(private.ref_)[1..] - } - _ => { - let name = bun_alloc::arena_format!( - in self.arena, - "#accessor_storage_{}", - accessor_storage_counter - ) - .into_bump_str() - .as_bytes(); - accessor_storage_counter += 1; - break 'storage_name name; - } - }; - bun_alloc::arena_format!( - in self.arena, - "#{}_accessor_storage", - bstr::BStr::new(base) - ) - .into_bump_str() - .as_bytes() - }; - let storage_kind = if is_static { - js_ast::symbol::Kind::PrivateStaticField - } else { - js_ast::symbol::Kind::PrivateField - }; - let storage_ref = self.new_symbol(storage_kind, storage_name); - let storage_key = - self.new_expr(E::PrivateIdentifier { ref_: storage_ref }, key_loc); - - let mut storage_flags = Flags::PropertySet::empty(); - if is_static { - storage_flags.insert(Flags::Property::IsStatic); - } - class_properties.push(G::Property { - kind: PropertyKind::Normal, - flags: storage_flags, - key: Some(storage_key), - initializer: prop.initializer.take(), - ..Default::default() - }); - - // get x() { return this.#x_accessor_storage; } - let this_expr = self.new_expr(E::This {}, key_loc); - let storage_index = - self.new_expr(E::PrivateIdentifier { ref_: storage_ref }, key_loc); - let get_value = self.new_expr( - E::Index { - target: this_expr, - index: storage_index, - optional_chain: None, - }, - key_loc, - ); - let get_body = self.arena.alloc_slice_copy(&[self.s( - S::Return { - value: Some(get_value), - }, - key_loc, - )]); - let get_fn = self.new_expr( - E::Function { - func: G::Fn { - body: G::FnBody { - stmts: bun_ast::StoreSlice::new_mut(get_body), - loc: key_loc, - }, - open_parens_loc: key_loc, - ..Default::default() - }, - }, - key_loc, - ); - - // set x(v) { this.#x_accessor_storage = v; } - let setter_arg_ref = self.new_symbol(js_ast::symbol::Kind::Other, b"v"); - let this_expr = self.new_expr(E::This {}, key_loc); - let storage_index = - self.new_expr(E::PrivateIdentifier { ref_: storage_ref }, key_loc); - let set_target = self.new_expr( - E::Index { - target: this_expr, - index: storage_index, - optional_chain: None, - }, - key_loc, - ); - self.record_usage(setter_arg_ref); - let set_value = self.new_expr(E::Identifier::init(setter_arg_ref), key_loc); - let set_body = self - .arena - .alloc_slice_copy(&[Stmt::assign(set_target, set_value)]); - let setter_binding = self.b( - B::Identifier { - r#ref: setter_arg_ref, - }, - key_loc, - ); - let setter_args = self.arena.alloc(G::Arg { - binding: setter_binding, - ..Default::default() - }); - let set_fn = self.new_expr( - E::Function { - func: G::Fn { - args: bun_ast::StoreSlice::new_mut(core::slice::from_mut( - setter_args, - )), - body: G::FnBody { - stmts: bun_ast::StoreSlice::new_mut(set_body), - loc: key_loc, - }, - open_parens_loc: key_loc, - ..Default::default() - }, - }, - key_loc, + self.lower_ts_auto_accessor( + prop, + key_for_reuse, + &mut accessor_storage_names, + &mut class_properties, ); - - let mut accessor_flags = prop.flags; - accessor_flags.insert(Flags::Property::IsMethod); - class_properties.push(G::Property { - kind: PropertyKind::Get, - flags: accessor_flags, - key: prop.key, - value: Some(get_fn), - ..Default::default() - }); - class_properties.push(G::Property { - kind: PropertyKind::Set, - flags: accessor_flags, - key: Some(key_for_reuse), - value: Some(set_fn), - ..Default::default() - }); continue; } @@ -6823,6 +6662,233 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } + /// `accessor` members of a TypeScript class expression under + /// `experimentalDecorators`. A class statement goes through `lower_class`. + pub(crate) fn lower_class_expr_auto_accessors(&mut self, class: &mut G::Class) { + use js_ast::g::PropertyKind; + if !class + .properties + .iter() + .any(|p| p.kind == PropertyKind::AutoAccessor) + { + return; + } + + let temp_refs_before = self.temp_refs_to_declare.len(); + let mut storage_names = self.private_names_in_class(class); + let mut properties = + BumpVec::::with_capacity_in(class.properties.len() + 2, self.arena); + for prop in class.properties.slice_mut().iter_mut() { + if prop.kind != PropertyKind::AutoAccessor { + properties.push(core::mem::take(prop)); + continue; + } + let (key_for_reuse, _) = self.capture_computed_key(prop); + self.lower_ts_auto_accessor(prop, key_for_reuse, &mut storage_names, &mut properties); + } + class.properties = bun_ast::StoreSlice::new_mut(properties.into_bump_slice_mut()); + + if let Some(temp_decls) = self.drain_capture_temp_decls(temp_refs_before, class.body_loc) + && let Some(stmt_list) = self.nearest_stmt_list_mut() + { + stmt_list.push(temp_decls); + } + } + + /// The `#names` a class body declares, so generated storage names stay unique. + fn private_names_in_class(&self, class: &G::Class) -> BumpVec<'a, &'a [u8]> { + let mut names = BumpVec::<&'a [u8]>::new_in(self.arena); + for prop in class.properties.iter() { + if let Some(js_ast::ExprData::EPrivateIdentifier(private)) = prop.key.map(|k| k.data) { + names.push(self.load_name_from_ref(private.ref_)); + } + } + names + } + + /// Rewrites a computed key to `[_key = expr]` and returns `_key` for the + /// other uses of the key, plus the assignment itself. A key that is a + /// literal, or not computed, is returned as is. + fn capture_computed_key(&mut self, prop: &mut G::Property) -> (Expr, Option) { + let key = prop.key.expect("infallible: prop has key"); + if !prop.flags.contains(Flags::Property::IsComputed) + || matches!( + key.data, + js_ast::ExprData::EString(_) | js_ast::ExprData::ENumber(_) + ) + { + return (key, None); + } + let temp_ref = self.generate_temp_ref(Some(b"_key")); + self.record_declared_symbol(temp_ref); + self.record_usage(temp_ref); + self.record_usage(temp_ref); + let temp_ident = self.new_expr(E::Identifier::init(temp_ref), key.loc); + let assign = Expr::assign(temp_ident, key); + prop.key = Some(assign); + ( + self.new_expr(E::Identifier::init(temp_ref), key.loc), + Some(assign), + ) + } + + /// `accessor x = init` becomes `#x_accessor_storage = init` plus a getter + /// and a setter. `key_for_reuse` is the key the setter uses. + fn lower_ts_auto_accessor( + &mut self, + prop: &mut G::Property, + key_for_reuse: Expr, + storage_names: &mut BumpVec<'a, &'a [u8]>, + out: &mut BumpVec<'a, G::Property>, + ) { + use js_ast::g::PropertyKind; + + let key = prop.key.expect("infallible: prop has key"); + let key_loc = key.loc; + let is_static = prop.flags.contains(Flags::Property::IsStatic); + + let base: &'a [u8] = match key.data { + js_ast::ExprData::EString(s) + if s.is_utf8() + && !prop.flags.contains(Flags::Property::IsComputed) + && js_lexer::is_identifier(s.slice8()) => + { + s.string(self.arena).expect("oom") + } + js_ast::ExprData::EPrivateIdentifier(private) => { + &self.load_name_from_ref(private.ref_)[1..] + } + _ => b"", + }; + let mut storage_name: &'a [u8] = + bun_alloc::arena_format!(in self.arena, "#{}_accessor_storage", bstr::BStr::new(base)) + .into_bump_str() + .as_bytes(); + let mut suffix = 1; + while storage_names.iter().any(|name| *name == storage_name) { + storage_name = bun_alloc::arena_format!( + in self.arena, + "#{}_accessor_storage_{}", + bstr::BStr::new(base), + suffix + ) + .into_bump_str() + .as_bytes(); + suffix += 1; + } + storage_names.push(storage_name); + + let storage_kind = if is_static { + js_ast::symbol::Kind::PrivateStaticField + } else { + js_ast::symbol::Kind::PrivateField + }; + let storage_ref = self.new_symbol(storage_kind, storage_name); + let storage_key = self.new_expr(E::PrivateIdentifier { ref_: storage_ref }, key_loc); + + let mut storage_flags = Flags::PropertySet::empty(); + if is_static { + storage_flags.insert(Flags::Property::IsStatic); + } + out.push(G::Property { + kind: PropertyKind::Normal, + flags: storage_flags, + key: Some(storage_key), + initializer: prop.initializer.take(), + ..Default::default() + }); + + // get x() { return this.#x_accessor_storage; } + let this_expr = self.new_expr(E::This {}, key_loc); + let storage_index = self.new_expr(E::PrivateIdentifier { ref_: storage_ref }, key_loc); + let get_value = self.new_expr( + E::Index { + target: this_expr, + index: storage_index, + optional_chain: None, + }, + key_loc, + ); + let get_body = self.arena.alloc_slice_copy(&[self.s( + S::Return { + value: Some(get_value), + }, + key_loc, + )]); + let get_fn = self.new_expr( + E::Function { + func: G::Fn { + body: G::FnBody { + stmts: bun_ast::StoreSlice::new_mut(get_body), + loc: key_loc, + }, + open_parens_loc: key_loc, + ..Default::default() + }, + }, + key_loc, + ); + + // set x(v) { this.#x_accessor_storage = v; } + let setter_arg_ref = self.new_symbol(js_ast::symbol::Kind::Other, b"v"); + let this_expr = self.new_expr(E::This {}, key_loc); + let storage_index = self.new_expr(E::PrivateIdentifier { ref_: storage_ref }, key_loc); + let set_target = self.new_expr( + E::Index { + target: this_expr, + index: storage_index, + optional_chain: None, + }, + key_loc, + ); + self.record_usage(setter_arg_ref); + let set_value = self.new_expr(E::Identifier::init(setter_arg_ref), key_loc); + let set_body = self + .arena + .alloc_slice_copy(&[Stmt::assign(set_target, set_value)]); + let setter_binding = self.b( + B::Identifier { + r#ref: setter_arg_ref, + }, + key_loc, + ); + let setter_args = self.arena.alloc(G::Arg { + binding: setter_binding, + ..Default::default() + }); + let set_fn = self.new_expr( + E::Function { + func: G::Fn { + args: bun_ast::StoreSlice::new_mut(core::slice::from_mut(setter_args)), + body: G::FnBody { + stmts: bun_ast::StoreSlice::new_mut(set_body), + loc: key_loc, + }, + open_parens_loc: key_loc, + ..Default::default() + }, + }, + key_loc, + ); + + let mut accessor_flags = prop.flags; + accessor_flags.insert(Flags::Property::IsMethod); + out.push(G::Property { + kind: PropertyKind::Get, + flags: accessor_flags, + key: prop.key, + value: Some(get_fn), + ..Default::default() + }); + out.push(G::Property { + kind: PropertyKind::Set, + flags: accessor_flags, + key: Some(key_for_reuse), + value: Some(set_fn), + ..Default::default() + }); + } + // Helper extracted from lower_class to keep that fn readable; condenses // the per-kind metadata switch. #[cold] diff --git a/src/js_parser/visit/visit_expr.rs b/src/js_parser/visit/visit_expr.rs index 02662c5463ca..b8022c1d2f3b 100644 --- a/src/js_parser/visit/visit_expr.rs +++ b/src/js_parser/visit/visit_expr.rs @@ -2625,6 +2625,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O return; } + if TYPESCRIPT { + p.lower_class_expr_auto_accessors(&mut e_); + } + // Remove unused class names when minifying (only when bundling is enabled) // unless --keep-names is specified if p.options.features.minify_syntax diff --git a/test/bundler/transpiler/decorators-legacy-lowering.test.ts b/test/bundler/transpiler/decorators-legacy-lowering.test.ts index 0dec6b32807f..d7ead3df4d3d 100644 --- a/test/bundler/transpiler/decorators-legacy-lowering.test.ts +++ b/test/bundler/transpiler/decorators-legacy-lowering.test.ts @@ -1,12 +1,11 @@ import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, nodeExe, tempDir } from "harness"; import { join } from "node:path"; -import ts from "typescript"; // TypeScript "experimentalDecorators" lowering. Every fixture runs three ways: -// with `bun `, bundled and minified with `bun build --minify`, and as the -// JavaScript tsc emits for it (run with node when available). All three must -// print the same thing, so the expected output is tsc's behavior. +// with `bun `, bundled and minified with `Bun.build`, and as the +// JavaScript tsc emits for it, run with node. All three must print the same +// thing, so the expected output is tsc's behavior. interface Fixture { source: string; @@ -149,6 +148,23 @@ const fixtures: Record = { expected: 'dec z function function\ndec Symbol(s) function function\ndec s function function\n2 3 undefined 4 5\n20 30 s 40\n[] ["constructor","y","z","p"]\n', }, + "accessor storage names stay unique and class expressions are lowered too": { + source: ` + class A { + accessor x = 1; + static accessor x = 2; + accessor #x = 3; + #x_accessor_storage = 4; + get(): number[] { return [this.x, A.x, this.#x, this.#x_accessor_storage] } + } + const k = () => "k"; + const B = class { accessor y = 5; accessor [k()] = 7 }; + const b: any = new B(); + b.y += 1; + console.log(JSON.stringify([new A().get(), b.y, b.k, Object.getOwnPropertyNames(b).length])); + `, + expected: "[[1,2,3,4],6,7,0]\n", + }, }; function tsconfig(useDefineForClassFields: boolean | undefined) { @@ -166,16 +182,29 @@ async function run(cmd: string[], cwd: string) { return { stdout, stderr, exitCode }; } -function tscEmit(source: string, useDefineForClassFields: boolean | undefined) { - return ts.transpileModule(source, { - compilerOptions: { - experimentalDecorators: true, - useDefineForClassFields, - target: ts.ScriptTarget.ES2022, - module: ts.ModuleKind.ESNext, - }, - }).outputText; -} +// Runs in node: transpiles every .ts file in the directory with the typescript +// package, then imports index.mjs. Loading typescript in a debug build of bun +// takes far longer than a test may. +const tscEmitAndRun = ` + const fs = require("node:fs"); + const path = require("node:path"); + const ts = require(process.argv[2]); + const useDefineForClassFields = process.argv[3] === "" ? undefined : process.argv[3] === "true"; + for (const file of fs.readdirSync(".")) { + if (!file.endsWith(".ts")) continue; + const out = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { + experimentalDecorators: true, + useDefineForClassFields, + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + }, + }).outputText; + fs.writeFileSync(file.replace(/\\.ts$/, ".mjs"), out.replaceAll(/from "(\\.\\/[^"]+)"/g, 'from "$1.mjs"')); + } + import("file://" + path.resolve("index.mjs")); +`; +const typescriptPath = require.resolve("typescript"); describe("experimentalDecorators lowering", () => { for (const [name, fixture] of Object.entries(fixtures)) { @@ -185,65 +214,36 @@ describe("experimentalDecorators lowering", () => { ...fixture.files, }; - // One test per fixture runs the three variants one after the other, so - // the file does not start every subprocess at once. - test.concurrent( - name, - async () => { - { - using dir = tempDir("legacy-dec-run", files); - const { stdout, stderr, exitCode } = await run([bunExe(), "index.ts"], String(dir)); - expect({ mode: "bun", stdout, stderr, exitCode }).toEqual({ - mode: "bun", - stdout: fixture.expected, - stderr: "", - exitCode: 0, - }); - } + test(`${name} (bun)`, async () => { + using dir = tempDir("legacy-dec-run", files); + const { stdout, stderr, exitCode } = await run([bunExe(), "index.ts"], String(dir)); + expect({ stdout, stderr, exitCode }).toEqual({ stdout: fixture.expected, stderr: "", exitCode: 0 }); + }); - { - using dir = tempDir("legacy-dec-build", files); - const build = await Bun.build({ - entrypoints: [join(String(dir), "index.ts")], - outdir: join(String(dir), "out"), - minify: true, - }); - expect(build.logs).toEqual([]); - const { stdout, stderr, exitCode } = await run([bunExe(), "out/index.js"], String(dir)); - expect({ mode: "bundled and minified", stdout, stderr, exitCode }).toEqual({ - mode: "bundled and minified", - stdout: fixture.expected, - stderr: "", - exitCode: 0, - }); - } + test(`${name} (bundled and minified)`, async () => { + using dir = tempDir("legacy-dec-build", files); + const build = await Bun.build({ + entrypoints: [join(String(dir), "index.ts")], + outdir: join(String(dir), "out"), + minify: true, + }); + expect(build.logs).toEqual([]); + const { stdout, stderr, exitCode } = await run([bunExe(), "out/index.js"], String(dir)); + expect({ stdout, stderr, exitCode }).toEqual({ stdout: fixture.expected, stderr: "", exitCode: 0 }); + }); - { - // The reference: tsc's own output for the same source, run as plain JavaScript. - const emitted: Record = {}; - for (const [file, source] of Object.entries(files)) { - if (file.endsWith(".ts")) { - emitted[file.replace(/\.ts$/, ".mjs")] = tscEmit(source, fixture.useDefineForClassFields).replaceAll( - /from "(\.\/[^"]+)"/g, - 'from "$1.mjs"', - ); - } - } - using dir = tempDir("legacy-dec-tsc", emitted); - const { stdout, stderr, exitCode } = await run([nodeExe() ?? bunExe(), "index.mjs"], String(dir)); - expect({ mode: "tsc emit", stdout, stderr, exitCode }).toEqual({ - mode: "tsc emit", - stdout: fixture.expected, - stderr: "", - exitCode: 0, - }); - } - }, - 30_000, - ); + test.skipIf(!nodeExe())(`${name} (tsc emit)`, async () => { + // The reference: tsc's own output for the same source, run as plain JavaScript. + using dir = tempDir("legacy-dec-tsc", { ...files, "tsc-emit-and-run.cjs": tscEmitAndRun }); + const { stdout, stderr, exitCode } = await run( + [nodeExe()!, "tsc-emit-and-run.cjs", typescriptPath, String(fixture.useDefineForClassFields ?? "")], + String(dir), + ); + expect({ stdout, stderr, exitCode }).toEqual({ stdout: fixture.expected, stderr: "", exitCode: 0 }); + }); } - test.concurrent("decorated auto-accessor and declare fields get design:type metadata", async () => { + test("decorated auto-accessor and declare fields get design:type metadata", async () => { using dir = tempDir("legacy-dec-metadata", { "tsconfig.json": JSON.stringify({ compilerOptions: { experimentalDecorators: true, emitDecoratorMetadata: true }, @@ -267,7 +267,7 @@ describe("experimentalDecorators lowering", () => { expect(exitCode).toBe(0); }); - test.concurrent("decorators on a class expression are a syntax error", async () => { + test("decorators on a class expression are a syntax error", async () => { using dir = tempDir("legacy-dec-class-expr", { "tsconfig.json": tsconfig(undefined), "before.ts": ` @@ -296,7 +296,7 @@ describe("experimentalDecorators lowering", () => { expect(param.exitCode).not.toBe(0); }); - test.concurrent("decorators on both sides of export default are rejected", async () => { + test("decorators on both sides of export default are rejected", async () => { using dir = tempDir("legacy-dec-double", { "tsconfig.json": tsconfig(undefined), "index.ts": ` @@ -309,7 +309,7 @@ describe("experimentalDecorators lowering", () => { expect(exitCode).not.toBe(0); }); - test.concurrent("Bun.Transpiler output shape matches tsc", async () => { + test("Bun.Transpiler output shape matches tsc", async () => { const transpiler = new Bun.Transpiler({ loader: "ts", tsconfig: { compilerOptions: { experimentalDecorators: true } }, From e225ad310205cf09cd4730be2b60c8f78cd26d95 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:02:14 +0000 Subject: [PATCH 06/16] js_parser: one-line doc comments on the accessor helpers --- src/js_parser/p.rs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index daa346b928c3..db4ccd8a0e93 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -6662,8 +6662,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } - /// `accessor` members of a TypeScript class expression under - /// `experimentalDecorators`. A class statement goes through `lower_class`. + /// `accessor` members of a class expression (a class statement uses `lower_class`). pub(crate) fn lower_class_expr_auto_accessors(&mut self, class: &mut G::Class) { use js_ast::g::PropertyKind; if !class @@ -6706,9 +6705,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O names } - /// Rewrites a computed key to `[_key = expr]` and returns `_key` for the - /// other uses of the key, plus the assignment itself. A key that is a - /// literal, or not computed, is returned as is. + /// `[expr]` becomes `[_key = expr]`. Returns the key for other uses and the assignment. fn capture_computed_key(&mut self, prop: &mut G::Property) -> (Expr, Option) { let key = prop.key.expect("infallible: prop has key"); if !prop.flags.contains(Flags::Property::IsComputed) @@ -6732,8 +6729,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O ) } - /// `accessor x = init` becomes `#x_accessor_storage = init` plus a getter - /// and a setter. `key_for_reuse` is the key the setter uses. + /// `accessor x = init` becomes `#x_accessor_storage = init` plus a getter and a setter. fn lower_ts_auto_accessor( &mut self, prop: &mut G::Property, From 23465fca5c078da240b942782519fbef3ae7ad5f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:21:12 +0000 Subject: [PATCH 07/16] js_parser: parameter decorators use the enclosing yield context too --- src/js_parser/parse/parse_fn.rs | 3 +++ test/bundler/transpiler/decorators-legacy-lowering.test.ts | 6 ++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/js_parser/parse/parse_fn.rs b/src/js_parser/parse/parse_fn.rs index 09037f5fa271..efecaf3941fe 100644 --- a/src/js_parser/parse/parse_fn.rs +++ b/src/js_parser/parse/parse_fn.rs @@ -230,9 +230,11 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O if opts.allow_ts_decorators { // Parameter decorators are evaluated outside the method, with the class. let inner_allow_await = p.fn_or_arrow_data_parse.allow_await; + let inner_allow_yield = p.fn_or_arrow_data_parse.allow_yield; let inner_needs_async_loc = p.fn_or_arrow_data_parse.needs_async_loc; let inner_scope = p.current_scope; p.fn_or_arrow_data_parse.allow_await = old_fn_or_arrow_data.allow_await; + p.fn_or_arrow_data_parse.allow_yield = old_fn_or_arrow_data.allow_yield; p.fn_or_arrow_data_parse.needs_async_loc = old_fn_or_arrow_data.needs_async_loc; if let Some(decorator_scope) = opts.decorator_scope { p.current_scope = decorator_scope; @@ -240,6 +242,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let decorators = p.parse_type_script_decorators(); p.current_scope = inner_scope; p.fn_or_arrow_data_parse.allow_await = inner_allow_await; + p.fn_or_arrow_data_parse.allow_yield = inner_allow_yield; p.fn_or_arrow_data_parse.needs_async_loc = inner_needs_async_loc; ts_decorators = decorators?; if ts_decorators.len_u32() > 0 { diff --git a/test/bundler/transpiler/decorators-legacy-lowering.test.ts b/test/bundler/transpiler/decorators-legacy-lowering.test.ts index d7ead3df4d3d..38d198e7fe15 100644 --- a/test/bundler/transpiler/decorators-legacy-lowering.test.ts +++ b/test/bundler/transpiler/decorators-legacy-lowering.test.ts @@ -90,14 +90,16 @@ const fixtures: Record = { `, expected: "dec arg = 1\n2\n", }, - "a parameter decorator can await in an enclosing async function": { + "a parameter decorator can await or yield in the enclosing function": { source: ` function pd(v: any) { console.log("dec arg =", v); return (...a: any[]) => {} } async function f(foo: Promise) { class C { m(@pd(await foo) a: any) {} } return new C() } await f(Promise.resolve(42)); + function* g() { class D { *m(@pd(yield 1) a: any) {} } return new D() } + const it = g(); console.log(it.next().value, it.next(43).done); console.log("done"); `, - expected: "dec arg = 42\ndone\n", + expected: "dec arg = 42\ndec arg = 43\n1 true\ndone\n", }, "decorators that read a static #private name run inside the class": { source: ` From 77e04a9d08f49502d6231843dc38d34975bafee1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:08:42 +0000 Subject: [PATCH 08/16] js_parser: only a private name of the decorated class moves its decorators A class expression inside a decorator that uses its own #name no longer sends the outer class's decorator calls into a static block. The check now compares the resolved private name against the members of the class body scope whose decorators are being visited. --- src/js_parser/p.rs | 20 +++++++++++++---- src/js_parser/visit/mod.rs | 6 ++--- src/js_parser/visit/visit_binary.rs | 2 +- src/js_parser/visit/visit_expr.rs | 2 +- .../decorators-legacy-lowering.test.ts | 22 +++++++++++++++++++ 5 files changed, 42 insertions(+), 10 deletions(-) diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index db4ccd8a0e93..862d0319f090 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -536,9 +536,9 @@ pub struct P<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> { pub(crate) temp_refs_to_declare: List<'a, TempRef>, pub(crate) temp_ref_count: i32, - /// Every `obj.#name` and `#name in obj` the visit pass resolves. - pub(crate) private_name_use_count: u32, - /// Set by `visit_ts_decorators` when a decorator read a `#private` name. + /// The class body scope while its TypeScript decorators are visited. + pub(crate) ts_decorator_class_scope: Option>, + /// Set when a decorator read a `#private` name of `ts_decorator_class_scope`. pub(crate) ts_decorators_use_private_names: bool, // When bundling, hoisted top-level local variables declared with "var" in @@ -6662,6 +6662,18 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } + /// Called for every resolved `obj.#name` and `#name in obj`. + pub(crate) fn note_private_name_use(&mut self, name: &[u8], resolved: Ref) { + if let Some(class_scope) = self.ts_decorator_class_scope + && class_scope + .members + .get(name) + .is_some_and(|member| member.ref_ == resolved) + { + self.ts_decorators_use_private_names = true; + } + } + /// `accessor` members of a class expression (a class statement uses `lower_class`). pub(crate) fn lower_class_expr_auto_accessors(&mut self, class: &mut G::Class) { use js_ast::g::PropertyKind; @@ -8851,7 +8863,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O await_target: None, temp_refs_to_declare: BumpVec::new_in(arena), temp_ref_count: 0, - private_name_use_count: 0, + ts_decorator_class_scope: None, ts_decorators_use_private_names: false, relocated_top_level_vars: BumpVec::new_in(arena), after_arrow_body_loc: bun_ast::Loc::EMPTY, diff --git a/src/js_parser/visit/mod.rs b/src/js_parser/visit/mod.rs index 1aa2c8b9bc60..092be6985f7d 100644 --- a/src/js_parser/visit/mod.rs +++ b/src/js_parser/visit/mod.rs @@ -240,13 +240,11 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // `Vec` is not `Copy`; mutate in place. pub(crate) fn visit_ts_decorators(&mut self, decs: &mut ExprNodeList) { - let private_name_uses_before = self.private_name_use_count; + let outer_class_scope = self.ts_decorator_class_scope.replace(self.current_scope); for dec in decs.slice_mut() { self.visit_expr(dec); } - if self.private_name_use_count != private_name_uses_before { - self.ts_decorators_use_private_names = true; - } + self.ts_decorator_class_scope = outer_class_scope; } pub(crate) fn visit_decls( diff --git a/src/js_parser/visit/visit_binary.rs b/src/js_parser/visit/visit_binary.rs index 2c04aa7bcd18..61a0d2cbe36f 100644 --- a/src/js_parser/visit/visit_binary.rs +++ b/src/js_parser/visit/visit_binary.rs @@ -715,7 +715,7 @@ impl BinaryExpressionVisitor { let name = p.load_name_from_ref(private.ref_); let result = p.find_symbol(e_.left.loc, name).expect("unreachable"); private.ref_ = result.r#ref; - p.private_name_use_count += 1; + p.note_private_name_use(name, result.r#ref); // Unlike regular identifiers, there are no unbound private identifiers let kind = p.symbols[result.r#ref.inner_index() as usize].kind; diff --git a/src/js_parser/visit/visit_expr.rs b/src/js_parser/visit/visit_expr.rs index b8022c1d2f3b..0c59d7b9a7a6 100644 --- a/src/js_parser/visit/visit_expr.rs +++ b/src/js_parser/visit/visit_expr.rs @@ -917,7 +917,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let name = p.load_name_from_ref(private.ref_); let result = p.find_symbol(e_.index.loc, name).expect("unreachable"); private.ref_ = result.r#ref; - p.private_name_use_count += 1; + p.note_private_name_use(name, result.r#ref); // Unlike regular identifiers, there are no unbound private identifiers let kind: js_ast::symbol::Kind = diff --git a/test/bundler/transpiler/decorators-legacy-lowering.test.ts b/test/bundler/transpiler/decorators-legacy-lowering.test.ts index 38d198e7fe15..858e8530612f 100644 --- a/test/bundler/transpiler/decorators-legacy-lowering.test.ts +++ b/test/bundler/transpiler/decorators-legacy-lowering.test.ts @@ -115,6 +115,18 @@ const fixtures: Record = { `, expected: "static block\nstatic field\ndec arg = 1\ndec arg = 2\nconstructor,m\n", }, + "a private name of another class in a decorator keeps the calls outside": { + source: ` + function dec(...a: any[]) { return (...b: any[]) => {} } + class Other { static #p = 1; static read() { return Other.#p } } + class C { + @dec(class { #id = 7; get() { return this.#id } }) x = 1; + @dec(Other.read()) y = 2; + } + console.log(Object.keys(new C()).join(",")); + `, + expected: "x,y\n", + }, "export default @dec class keeps the binding and applies the decorator": { files: { "mod.ts": ` @@ -316,6 +328,16 @@ describe("experimentalDecorators lowering", () => { loader: "ts", tsconfig: { compilerOptions: { experimentalDecorators: true } }, }); + // A private name of another class does not move the calls into a static + // block, so `await` in a sibling decorator keeps working. + expect( + transpiler.transformSync(` + class C { + @dec(class { #id = 7; get() { return this.#id } }) x = 1; + @dec(await p) y = 2; + } + `), + ).not.toContain("static {"); const out = transpiler.transformSync(` function dec(t: any, k?: any, d?: any) {} let n = 0; const key = () => "k" + ++n; From 7e04ac479ec3e8c92bad3d64ae24a2dbb1b0d365 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 28 Aug 2026 20:07:08 +0000 Subject: [PATCH 09/16] js_parser: record private-name use per class body scope A class expression nested in a decorator could discard the flag the outer class needs. The parser now keeps the list of class body scopes whose decorators read one of their private names, and each class looks itself up when its visit ends. --- src/js_parser/p.rs | 12 ++++++++---- src/js_parser/visit/mod.rs | 17 +++++++++-------- .../decorators-legacy-lowering.test.ts | 3 ++- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 862d0319f090..7f24113a92f9 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -538,8 +538,8 @@ pub struct P<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> { /// The class body scope while its TypeScript decorators are visited. pub(crate) ts_decorator_class_scope: Option>, - /// Set when a decorator read a `#private` name of `ts_decorator_class_scope`. - pub(crate) ts_decorators_use_private_names: bool, + /// Class body scopes with a decorator that read one of their `#private` names. + pub(crate) ts_decorator_scopes_using_private_names: List<'a, js_ast::StoreRef>, // When bundling, hoisted top-level local variables declared with "var" in // nested scopes are moved up to be declared in the top-level scope instead. @@ -6669,8 +6669,12 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O .members .get(name) .is_some_and(|member| member.ref_ == resolved) + && !self + .ts_decorator_scopes_using_private_names + .contains(&class_scope) { - self.ts_decorators_use_private_names = true; + self.ts_decorator_scopes_using_private_names + .push(class_scope); } } @@ -8864,7 +8868,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O temp_refs_to_declare: BumpVec::new_in(arena), temp_ref_count: 0, ts_decorator_class_scope: None, - ts_decorators_use_private_names: false, + ts_decorator_scopes_using_private_names: BumpVec::new_in(arena), relocated_top_level_vars: BumpVec::new_in(arena), after_arrow_body_loc: bun_ast::Loc::EMPTY, const_values: Default::default(), diff --git a/src/js_parser/visit/mod.rs b/src/js_parser/visit/mod.rs index 092be6985f7d..3efe641027a1 100644 --- a/src/js_parser/visit/mod.rs +++ b/src/js_parser/visit/mod.rs @@ -851,10 +851,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O { self.push_scope_for_visit_pass(ScopeKind::ClassBody, class.body_loc) .expect("unreachable"); - - // Class decorators were visited above and do not count. - let outer_ts_decorators_use_private_names = - core::mem::replace(&mut self.ts_decorators_use_private_names, false); + let class_body_scope = self.current_scope; let mut constructor_function: Option> = None; let properties: &mut [G::Property] = class.properties.slice_mut(); @@ -1009,10 +1006,14 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O self.fn_only_data_visit.is_this_nested = old_is_this_captured; } - class.ts_decorators_use_private_names = core::mem::replace( - &mut self.ts_decorators_use_private_names, - outer_ts_decorators_use_private_names, - ); + class.ts_decorators_use_private_names = self + .ts_decorator_scopes_using_private_names + .iter() + .position(|scope| *scope == class_body_scope) + .inspect(|&i| { + self.ts_decorator_scopes_using_private_names.swap_remove(i); + }) + .is_some(); if Self::IS_TYPESCRIPT_ENABLED { // `lower_standard_decorators_stmt` owns field placement for such classes. diff --git a/test/bundler/transpiler/decorators-legacy-lowering.test.ts b/test/bundler/transpiler/decorators-legacy-lowering.test.ts index 858e8530612f..d8a239a5e4c0 100644 --- a/test/bundler/transpiler/decorators-legacy-lowering.test.ts +++ b/test/bundler/transpiler/decorators-legacy-lowering.test.ts @@ -109,11 +109,12 @@ const fixtures: Record = { static { console.log("static block") } m(@pd(Q.#p) a: any) {} @pd(Q.#p + 1) b: any; + @pd(new (class { read() { return Q.#p + 2 } })().read()) c: any; static #q = (console.log("static field"), 2); } console.log(Object.getOwnPropertyNames(Q.prototype).join(",")); `, - expected: "static block\nstatic field\ndec arg = 1\ndec arg = 2\nconstructor,m\n", + expected: "static block\nstatic field\ndec arg = 1\ndec arg = 2\ndec arg = 3\nconstructor,m\n", }, "a private name of another class in a decorator keeps the calls outside": { source: ` From 054ee9336bc2d2c28ec9acc8cfab0dbd9a99318d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 28 Aug 2026 20:23:38 +0000 Subject: [PATCH 10/16] js_parser: keep decorated abstract accessors, restore super flags for parameter decorators A decorated abstract accessor keeps its decorator call like an abstract field. Parameter decorators also parse with the enclosing super flags. A class expression only captures a computed accessor key when there is a statement list to declare the temporary in. --- src/js_parser/p.rs | 10 +++++++-- src/js_parser/parse/parse_fn.rs | 11 +++++----- src/js_parser/parse/parse_property.rs | 7 ++++-- .../decorators-legacy-lowering.test.ts | 22 ++++++++++--------- .../set-semantics-fixture.ts | 4 ++-- 5 files changed, 32 insertions(+), 22 deletions(-) diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 7f24113a92f9..12f83dcc5e2a 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -6689,6 +6689,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O return; } + // A computed key temporary needs a statement list to be declared in. + let can_declare_temps = self.nearest_stmt_list.is_some(); let temp_refs_before = self.temp_refs_to_declare.len(); let mut storage_names = self.private_names_in_class(class); let mut properties = @@ -6698,7 +6700,11 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O properties.push(core::mem::take(prop)); continue; } - let (key_for_reuse, _) = self.capture_computed_key(prop); + let key_for_reuse = if can_declare_temps { + self.capture_computed_key(prop).0 + } else { + prop.key.expect("infallible: prop has key") + }; self.lower_ts_auto_accessor(prop, key_for_reuse, &mut storage_names, &mut properties); } class.properties = bun_ast::StoreSlice::new_mut(properties.into_bump_slice_mut()); @@ -6777,7 +6783,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O .into_bump_str() .as_bytes(); let mut suffix = 1; - while storage_names.iter().any(|name| *name == storage_name) { + while storage_names.contains(&storage_name) { storage_name = bun_alloc::arena_format!( in self.arena, "#{}_accessor_storage_{}", diff --git a/src/js_parser/parse/parse_fn.rs b/src/js_parser/parse/parse_fn.rs index efecaf3941fe..65350401cdbe 100644 --- a/src/js_parser/parse/parse_fn.rs +++ b/src/js_parser/parse/parse_fn.rs @@ -229,21 +229,20 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let mut ts_decorators = bun_alloc::AstAlloc::vec(); if opts.allow_ts_decorators { // Parameter decorators are evaluated outside the method, with the class. - let inner_allow_await = p.fn_or_arrow_data_parse.allow_await; - let inner_allow_yield = p.fn_or_arrow_data_parse.allow_yield; - let inner_needs_async_loc = p.fn_or_arrow_data_parse.needs_async_loc; + let inner_fn_or_arrow_data = p.fn_or_arrow_data_parse.clone(); let inner_scope = p.current_scope; p.fn_or_arrow_data_parse.allow_await = old_fn_or_arrow_data.allow_await; p.fn_or_arrow_data_parse.allow_yield = old_fn_or_arrow_data.allow_yield; p.fn_or_arrow_data_parse.needs_async_loc = old_fn_or_arrow_data.needs_async_loc; + p.fn_or_arrow_data_parse.allow_super_call = old_fn_or_arrow_data.allow_super_call; + p.fn_or_arrow_data_parse.allow_super_property = + old_fn_or_arrow_data.allow_super_property; if let Some(decorator_scope) = opts.decorator_scope { p.current_scope = decorator_scope; } let decorators = p.parse_type_script_decorators(); p.current_scope = inner_scope; - p.fn_or_arrow_data_parse.allow_await = inner_allow_await; - p.fn_or_arrow_data_parse.allow_yield = inner_allow_yield; - p.fn_or_arrow_data_parse.needs_async_loc = inner_needs_async_loc; + p.fn_or_arrow_data_parse = inner_fn_or_arrow_data; ts_decorators = decorators?; if ts_decorators.len_u32() > 0 { arg_has_decorators = true; diff --git a/src/js_parser/parse/parse_property.rs b/src/js_parser/parse/parse_property.rs index e80448c6a4ea..8cd44f7dbbae 100644 --- a/src/js_parser/parse/parse_property.rs +++ b/src/js_parser/parse/parse_property.rs @@ -450,8 +450,11 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O if let Some(prop) = p.parse_property(kind, opts, None)? { - if prop.kind == PropertyKind::Normal - && prop.value.is_none() + if matches!( + prop.kind, + PropertyKind::Normal + | PropertyKind::AutoAccessor + ) && prop.value.is_none() && opts.ts_decorators.len() > 0 { let mut prop_ = prop; diff --git a/test/bundler/transpiler/decorators-legacy-lowering.test.ts b/test/bundler/transpiler/decorators-legacy-lowering.test.ts index d8a239a5e4c0..a58300a46cc6 100644 --- a/test/bundler/transpiler/decorators-legacy-lowering.test.ts +++ b/test/bundler/transpiler/decorators-legacy-lowering.test.ts @@ -144,24 +144,26 @@ const fixtures: Record = { }, "accessor fields are lowered and can be decorated": { source: ` - function dec(t: any, k: any, d: any) { console.log("dec", String(k), typeof d.get, typeof d.set) } + function dec(t: any, k: any, d: any) { console.log("dec", String(k), d === undefined ? "undefined" : typeof d.get) } const sym = Symbol("s"); - class Acc { + abstract class Acc { accessor y = 2; @dec accessor z = 3; @dec static accessor s: string; @dec accessor [sym] = 4; accessor #p = 5; + @dec abstract accessor q: number; p() { return this.#p } } - const a = new Acc(); - console.log(a.y, a.z, Acc.s, a[sym], a.p()); + class Impl extends Acc { accessor q = 6 } + const a = new Impl(); + console.log(a.y, a.z, Acc.s, a[sym], a.p(), a.q); a.y = 20; a.z = 30; Acc.s = "s"; a[sym] = 40; console.log(a.y, a.z, Acc.s, a[sym]); console.log(JSON.stringify(Object.getOwnPropertyNames(a)), JSON.stringify(Object.getOwnPropertyNames(Acc.prototype))); `, expected: - 'dec z function function\ndec Symbol(s) function function\ndec s function function\n2 3 undefined 4 5\n20 30 s 40\n[] ["constructor","y","z","p"]\n', + 'dec z function\ndec Symbol(s) function\ndec q undefined\ndec s function\n2 3 undefined 4 5 6\n20 30 s 40\n[] ["constructor","y","z","p"]\n', }, "accessor storage names stay unique and class expressions are lowered too": { source: ` @@ -222,20 +224,20 @@ const tscEmitAndRun = ` const typescriptPath = require.resolve("typescript"); describe("experimentalDecorators lowering", () => { - for (const [name, fixture] of Object.entries(fixtures)) { + describe.each(Object.entries(fixtures))("%s", (_name, fixture) => { const files = { "tsconfig.json": tsconfig(fixture.useDefineForClassFields), "index.ts": fixture.source, ...fixture.files, }; - test(`${name} (bun)`, async () => { + test("bun", async () => { using dir = tempDir("legacy-dec-run", files); const { stdout, stderr, exitCode } = await run([bunExe(), "index.ts"], String(dir)); expect({ stdout, stderr, exitCode }).toEqual({ stdout: fixture.expected, stderr: "", exitCode: 0 }); }); - test(`${name} (bundled and minified)`, async () => { + test("bundled and minified", async () => { using dir = tempDir("legacy-dec-build", files); const build = await Bun.build({ entrypoints: [join(String(dir), "index.ts")], @@ -247,7 +249,7 @@ describe("experimentalDecorators lowering", () => { expect({ stdout, stderr, exitCode }).toEqual({ stdout: fixture.expected, stderr: "", exitCode: 0 }); }); - test.skipIf(!nodeExe())(`${name} (tsc emit)`, async () => { + test.skipIf(!nodeExe())("tsc emit", async () => { // The reference: tsc's own output for the same source, run as plain JavaScript. using dir = tempDir("legacy-dec-tsc", { ...files, "tsc-emit-and-run.cjs": tscEmitAndRun }); const { stdout, stderr, exitCode } = await run( @@ -256,7 +258,7 @@ describe("experimentalDecorators lowering", () => { ); expect({ stdout, stderr, exitCode }).toEqual({ stdout: fixture.expected, stderr: "", exitCode: 0 }); }); - } + }); test("decorated auto-accessor and declare fields get design:type metadata", async () => { using dir = tempDir("legacy-dec-metadata", { diff --git a/test/bundler/transpiler/decorators-set-semantics/set-semantics-fixture.ts b/test/bundler/transpiler/decorators-set-semantics/set-semantics-fixture.ts index a65f4bcedf6d..d3c4597fb9b3 100644 --- a/test/bundler/transpiler/decorators-set-semantics/set-semantics-fixture.ts +++ b/test/bundler/transpiler/decorators-set-semantics/set-semantics-fixture.ts @@ -40,9 +40,9 @@ function Emoji() { } const iceCream = new IceCreamComponent(); -expect(iceCream.flavor === "🍦 vanilla 🍦").toBe(true); +expect(iceCream.flavor).toBe("🍦 vanilla 🍦"); iceCream.flavor = "chocolate"; -expect(iceCream.flavor === "🍦 chocolate 🍦").toBe(true); +expect(iceCream.flavor).toBe("🍦 chocolate 🍦"); // No instance field below has a computed key: with useDefineForClassFields // false, one such key keeps every instance field of the class native. From 1c405764478147ce426f17ba7426145e136a3e32 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 28 Aug 2026 21:12:51 +0000 Subject: [PATCH 11/16] js_parser: lower accessor members in visit_class The accessor rewrite now runs in visit_class before the useDefineForClassFields: false pass, so the storage field's initializer moves into the constructor in source order like any other field. The getter keeps the member's decorators and type metadata, marked with a property flag, so lower_class treats it like a decorated method and emits design:type only. One rewrite covers class statements and class expressions. --- src/ast/lib.rs | 3 + src/js_parser/p.rs | 74 +++++++++++-------- src/js_parser/visit/mod.rs | 4 + src/js_parser/visit/visit_expr.rs | 4 - .../decorators-legacy-lowering.test.ts | 17 +++++ 5 files changed, 68 insertions(+), 34 deletions(-) diff --git a/src/ast/lib.rs b/src/ast/lib.rs index 3456860e472a..431871cacddb 100644 --- a/src/ast/lib.rs +++ b/src/ast/lib.rs @@ -2945,6 +2945,9 @@ pub mod flags { IsStatic, WasShorthand, IsSpread, + /// The getter that a TypeScript `accessor` member was lowered to. It + /// carries the member's decorators and type metadata. + IsAutoAccessorGetter, } pub type PropertySet = EnumSet; pub const PROPERTY_NONE: PropertySet = EnumSet::empty(); diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 12f83dcc5e2a..b1cbe099b62a 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -6389,7 +6389,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let mut class_properties = BumpVec::::new_in(self.arena); let temp_refs_before = self.temp_refs_to_declare.len(); - let mut accessor_storage_names = self.private_names_in_class(&s_class.class); for prop in s_class.class.properties.slice_mut().iter_mut() { if prop.kind == PropertyKind::ClassStaticBlock { @@ -6399,7 +6398,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let is_method = prop.flags.contains(Flags::Property::IsMethod); let is_static = prop.flags.contains(Flags::Property::IsStatic); - let is_auto_accessor = prop.kind == PropertyKind::AutoAccessor; // merge parameter decorators with method decorators if is_method { @@ -6445,8 +6443,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } - let is_decorated = prop.ts_decorators.len_u32() > 0; - if !is_decorated && !is_auto_accessor { + if prop.ts_decorators.len_u32() == 0 { class_properties.push(core::mem::take(prop)); continue; } @@ -6454,9 +6451,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let key_loc = prop.key.expect("infallible: prop has key").loc; let (key_for_reuse, captured_key) = self.capture_computed_key(prop); - if is_decorated { + { // Only a field has no property descriptor to pass along. - let descriptor_kind: Expr = if is_method || is_auto_accessor { + let descriptor_kind: Expr = if is_method { self.new_expr(E::Null {}, key_loc) } else { self.new_expr(E::Undefined {}, key_loc) @@ -6526,16 +6523,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } - if is_auto_accessor { - self.lower_ts_auto_accessor( - prop, - key_for_reuse, - &mut accessor_storage_names, - &mut class_properties, - ); - continue; - } - // A "declare" or "abstract" member emits nothing but its key. if matches!(prop.kind, PropertyKind::Declare | PropertyKind::Abstract) { if let Some(captured_key) = captured_key { @@ -6678,8 +6665,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } - /// `accessor` members of a class expression (a class statement uses `lower_class`). - pub(crate) fn lower_class_expr_auto_accessors(&mut self, class: &mut G::Class) { + /// Lowers the `accessor` members of a TypeScript class under + /// `experimentalDecorators`. Runs in `visit_class`, before the + /// `useDefineForClassFields: false` pass moves field initializers. + pub(crate) fn lower_ts_auto_accessors(&mut self, class: &mut G::Class) { use js_ast::g::PropertyKind; if !class .properties @@ -6689,8 +6678,18 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O return; } - // A computed key temporary needs a statement list to be declared in. + // A computed key temporary is a `var` of the scope around the class, and + // needs a statement list to be declared in. let can_declare_temps = self.nearest_stmt_list.is_some(); + let class_body_scope = self.current_scope; + let mut enclosing_scope = class_body_scope; + while matches!( + enclosing_scope.kind, + js_ast::scope::Kind::ClassBody | js_ast::scope::Kind::ClassName + ) && let Some(parent) = enclosing_scope.parent + { + enclosing_scope = parent; + } let temp_refs_before = self.temp_refs_to_declare.len(); let mut storage_names = self.private_names_in_class(class); let mut properties = @@ -6701,7 +6700,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O continue; } let key_for_reuse = if can_declare_temps { - self.capture_computed_key(prop).0 + self.current_scope = enclosing_scope; + let (key, _) = self.capture_computed_key(prop); + self.current_scope = class_body_scope; + key } else { prop.key.expect("infallible: prop has key") }; @@ -6738,6 +6740,14 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O { return (key, None); } + // Already captured, by the `accessor` lowering in `visit_class`. + if let js_ast::ExprData::EBinary(binary) = key.data + && binary.op == js_ast::OpCode::BinAssign + && let js_ast::ExprData::EIdentifier(temp) = binary.left.data + { + self.record_usage(temp.ref_); + return (binary.left, None); + } let temp_ref = self.generate_temp_ref(Some(b"_key")); self.record_declared_symbol(temp_ref); self.record_usage(temp_ref); @@ -6891,11 +6901,15 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let mut accessor_flags = prop.flags; accessor_flags.insert(Flags::Property::IsMethod); + let mut getter_flags = accessor_flags; + getter_flags.insert(Flags::Property::IsAutoAccessorGetter); out.push(G::Property { kind: PropertyKind::Get, - flags: accessor_flags, + flags: getter_flags, key: prop.key, value: Some(get_fn), + ts_decorators: bun_alloc::AstAlloc::take(&mut prop.ts_decorators), + ts_metadata: prop.ts_metadata.clone(), ..Default::default() }); out.push(G::Property { @@ -6977,6 +6991,13 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } } + PropertyKind::Get if prop.flags.contains(Flags::Property::IsAutoAccessorGetter) => { + // typescript only sets design:type for an auto-accessor. + let v = self + .serialize_metadata(prop.ts_metadata.clone()) + .expect("unreachable"); + push_metadata!(b"design:type", v); + } PropertyKind::Get => { if prop.flags.contains(Flags::Property::IsMethod) { // typescript sets design:type to the return value & design:paramtypes to []. @@ -7044,15 +7065,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } } - PropertyKind::AutoAccessor => { - // typescript only sets design:type for an auto-accessor. - let v = self - .serialize_metadata(prop.ts_metadata.clone()) - .expect("unreachable"); - push_metadata!(b"design:type", v); - } - PropertyKind::Spread => {} // not allowed in a class - PropertyKind::ClassStaticBlock => {} // not allowed to decorate this + PropertyKind::Spread | PropertyKind::AutoAccessor => {} // lowered before this runs + PropertyKind::ClassStaticBlock => {} // not allowed to decorate this } } diff --git a/src/js_parser/visit/mod.rs b/src/js_parser/visit/mod.rs index 3efe641027a1..66df2847f4b6 100644 --- a/src/js_parser/visit/mod.rs +++ b/src/js_parser/visit/mod.rs @@ -1016,6 +1016,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O .is_some(); if Self::IS_TYPESCRIPT_ENABLED { + if !class.should_lower_standard_decorators { + self.lower_ts_auto_accessors(class); + } + // `lower_standard_decorators_stmt` owns field placement for such classes. let use_define = self.options.use_define_for_class_fields || class.should_lower_standard_decorators; diff --git a/src/js_parser/visit/visit_expr.rs b/src/js_parser/visit/visit_expr.rs index 0c59d7b9a7a6..1846873f72a8 100644 --- a/src/js_parser/visit/visit_expr.rs +++ b/src/js_parser/visit/visit_expr.rs @@ -2625,10 +2625,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O return; } - if TYPESCRIPT { - p.lower_class_expr_auto_accessors(&mut e_); - } - // Remove unused class names when minifying (only when bundling is enabled) // unless --keep-names is specified if p.options.features.minify_syntax diff --git a/test/bundler/transpiler/decorators-legacy-lowering.test.ts b/test/bundler/transpiler/decorators-legacy-lowering.test.ts index a58300a46cc6..0192e424a42c 100644 --- a/test/bundler/transpiler/decorators-legacy-lowering.test.ts +++ b/test/bundler/transpiler/decorators-legacy-lowering.test.ts @@ -67,6 +67,23 @@ const fixtures: Record = { `, expected: 'dec k1\ndec d\n1 ["x"]\n', }, + "useDefineForClassFields false: accessor initializers keep their place": { + useDefineForClassFields: false, + source: ` + function dec(t: any, k: any, d: any) { console.log("dec", k, typeof d.get) } + const log: string[] = []; + class C { + a = (log.push("a"), 1); + @dec accessor x = (log.push("x"), 2); + b = (log.push("b"), 3); + static accessor s = (log.push("s"), 4); + } + const c = new C(); + c.x = 5; + console.log(log.join(","), c.x, C.s, JSON.stringify(Object.getOwnPropertyNames(c))); + `, + expected: 'dec x function\ns,a,x,b 5 4 ["a","b"]\n', + }, "useDefineForClassFields false: decorated fields next to a computed key": { useDefineForClassFields: false, source: ` From cc4679d2dc1a5ef2c381ebb5e9b7ee5b38aed3ef Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 28 Aug 2026 21:13:50 +0000 Subject: [PATCH 12/16] js_parser: shorten accessor lowering comments --- src/ast/lib.rs | 3 +-- src/js_parser/p.rs | 7 ++----- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/ast/lib.rs b/src/ast/lib.rs index 431871cacddb..8e34ab3656c5 100644 --- a/src/ast/lib.rs +++ b/src/ast/lib.rs @@ -2945,8 +2945,7 @@ pub mod flags { IsStatic, WasShorthand, IsSpread, - /// The getter that a TypeScript `accessor` member was lowered to. It - /// carries the member's decorators and type metadata. + /// The getter a TypeScript `accessor` member was lowered to (keeps its decorators). IsAutoAccessorGetter, } pub type PropertySet = EnumSet; diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index b1cbe099b62a..dda563fb8993 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -6665,9 +6665,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } - /// Lowers the `accessor` members of a TypeScript class under - /// `experimentalDecorators`. Runs in `visit_class`, before the - /// `useDefineForClassFields: false` pass moves field initializers. + /// `accessor` members under `experimentalDecorators`, before the useDefineForClassFields pass. pub(crate) fn lower_ts_auto_accessors(&mut self, class: &mut G::Class) { use js_ast::g::PropertyKind; if !class @@ -6678,8 +6676,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O return; } - // A computed key temporary is a `var` of the scope around the class, and - // needs a statement list to be declared in. + // A computed key temporary is a `var` in the scope around the class. let can_declare_temps = self.nearest_stmt_list.is_some(); let class_body_scope = self.current_scope; let mut enclosing_scope = class_body_scope; From d3b349d6c01d4877878b4555cf1d3b8dd4da0c2c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 28 Aug 2026 21:28:59 +0000 Subject: [PATCH 13/16] js_parser: declare class expression temporaries during the enum pre-pass too visit_stmts now points nearest_stmt_list at its prefix list before it pre-visits TypeScript enums, so a class expression in an enum initializer can declare its computed accessor key temporary. The accessor lowering always captures a computed key once. --- src/js_parser/p.rs | 20 +++++++------------ src/js_parser/visit/mod.rs | 16 ++++++++------- .../decorators-legacy-lowering.test.ts | 8 +++++--- 3 files changed, 21 insertions(+), 23 deletions(-) diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index dda563fb8993..e2c1536f4d37 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -6677,7 +6677,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } // A computed key temporary is a `var` in the scope around the class. - let can_declare_temps = self.nearest_stmt_list.is_some(); let class_body_scope = self.current_scope; let mut enclosing_scope = class_body_scope; while matches!( @@ -6696,22 +6695,17 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O properties.push(core::mem::take(prop)); continue; } - let key_for_reuse = if can_declare_temps { - self.current_scope = enclosing_scope; - let (key, _) = self.capture_computed_key(prop); - self.current_scope = class_body_scope; - key - } else { - prop.key.expect("infallible: prop has key") - }; + self.current_scope = enclosing_scope; + let (key_for_reuse, _) = self.capture_computed_key(prop); + self.current_scope = class_body_scope; self.lower_ts_auto_accessor(prop, key_for_reuse, &mut storage_names, &mut properties); } class.properties = bun_ast::StoreSlice::new_mut(properties.into_bump_slice_mut()); - if let Some(temp_decls) = self.drain_capture_temp_decls(temp_refs_before, class.body_loc) - && let Some(stmt_list) = self.nearest_stmt_list_mut() - { - stmt_list.push(temp_decls); + if let Some(temp_decls) = self.drain_capture_temp_decls(temp_refs_before, class.body_loc) { + self.nearest_stmt_list_mut() + .expect("visit_class runs while a statement list is visited") + .push(temp_decls); } } diff --git a/src/js_parser/visit/mod.rs b/src/js_parser/visit/mod.rs index 66df2847f4b6..b5a3b5ad3301 100644 --- a/src/js_parser/visit/mod.rs +++ b/src/js_parser/visit/mod.rs @@ -1321,6 +1321,15 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let mut before: ListManaged<'a, Stmt> = ListManaged::new_in(p.arena); let mut after: ListManaged<'a, Stmt> = ListManaged::new_in(p.arena); + let prev_nearest_stmt_list = p.nearest_stmt_list; + // BACKREF — `before` outlives this block; raw NonNull avoids + // the `&'a mut` borrow conflict. Derive via `addr_of_mut!` (no intermediate + // `&mut`) so the pointer shares the local's base tag and survives the + // direct `&mut before` reborrows in the loop below (Stacked Borrows). + // Set before the enum pre-pass, so a class expression in an enum + // initializer declares its temporaries in this list too. + p.nearest_stmt_list = NonNull::new(core::ptr::addr_of_mut!(before)); + // Preprocess TypeScript enums to improve code generation. Otherwise // uses of an enum before that enum has been declared won't be inlined: // @@ -1353,13 +1362,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let mut visited: ListManaged<'a, Stmt> = ListManaged::with_capacity_in(stmts.len(), p.arena); - let prev_nearest_stmt_list = p.nearest_stmt_list; - // BACKREF — `before` outlives this block; raw NonNull avoids - // the `&'a mut` borrow conflict. Derive via `addr_of_mut!` (no intermediate - // `&mut`) so the pointer shares the local's base tag and survives the - // direct `&mut before` reborrows in the loop below (Stacked Borrows). - p.nearest_stmt_list = NonNull::new(core::ptr::addr_of_mut!(before)); - let mut preprocessed_enum_i: usize = 0; 'stmt_loop: for stmt in stmts.iter_mut() { diff --git a/test/bundler/transpiler/decorators-legacy-lowering.test.ts b/test/bundler/transpiler/decorators-legacy-lowering.test.ts index 0192e424a42c..7c2cc7553b8b 100644 --- a/test/bundler/transpiler/decorators-legacy-lowering.test.ts +++ b/test/bundler/transpiler/decorators-legacy-lowering.test.ts @@ -191,13 +191,15 @@ const fixtures: Record = { #x_accessor_storage = 4; get(): number[] { return [this.x, A.x, this.#x, this.#x_accessor_storage] } } - const k = () => "k"; + let n = 0; + const k = () => "k" + ++n; const B = class { accessor y = 5; accessor [k()] = 7 }; const b: any = new B(); b.y += 1; - console.log(JSON.stringify([new A().get(), b.y, b.k, Object.getOwnPropertyNames(b).length])); + enum E { A = (new (class { accessor [k()] = 8 })() as any).k2 } + console.log(JSON.stringify([new A().get(), b.y, b.k1, Object.getOwnPropertyNames(b).length, E.A, n])); `, - expected: "[[1,2,3,4],6,7,0]\n", + expected: "[[1,2,3,4],6,7,0,8,2]\n", }, }; From 101c663c6c23d2f2f03284bc40e13407c5042e0e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 28 Aug 2026 21:30:05 +0000 Subject: [PATCH 14/16] js_parser: shorten the nearest_stmt_list comment --- src/js_parser/visit/mod.rs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/js_parser/visit/mod.rs b/src/js_parser/visit/mod.rs index b5a3b5ad3301..e2190440a836 100644 --- a/src/js_parser/visit/mod.rs +++ b/src/js_parser/visit/mod.rs @@ -1322,12 +1322,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let mut after: ListManaged<'a, Stmt> = ListManaged::new_in(p.arena); let prev_nearest_stmt_list = p.nearest_stmt_list; - // BACKREF — `before` outlives this block; raw NonNull avoids - // the `&'a mut` borrow conflict. Derive via `addr_of_mut!` (no intermediate - // `&mut`) so the pointer shares the local's base tag and survives the - // direct `&mut before` reborrows in the loop below (Stacked Borrows). - // Set before the enum pre-pass, so a class expression in an enum - // initializer declares its temporaries in this list too. + // BACKREF via `addr_of_mut!` (Stacked Borrows); set before the enum pre-pass too. p.nearest_stmt_list = NonNull::new(core::ptr::addr_of_mut!(before)); // Preprocess TypeScript enums to improve code generation. Otherwise From 0508d419c4e0af80f0ec0d9d99b557f8891c2500 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 28 Aug 2026 21:49:27 +0000 Subject: [PATCH 15/16] js_parser: only reuse a captured key on an accessor getter A user-written `[k = expr]` computed key on a decorated member gets its own temporary again. The linear-lowering test compares M=25000 with M=50000 after a warm-up instead of M=100, so fixed costs do not decide the ratio. --- src/js_parser/p.rs | 5 +++-- .../decorators-legacy-lowering.test.ts | 6 +++++- test/bundler/transpiler/decorators.test.ts | 17 +++++++++-------- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index e2c1536f4d37..3bab2fe87d05 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -6731,8 +6731,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O { return (key, None); } - // Already captured, by the `accessor` lowering in `visit_class`. - if let js_ast::ExprData::EBinary(binary) = key.data + // The `accessor` lowering in `visit_class` captured this key already. + if prop.flags.contains(Flags::Property::IsAutoAccessorGetter) + && let js_ast::ExprData::EBinary(binary) = key.data && binary.op == js_ast::OpCode::BinAssign && let js_ast::ExprData::EIdentifier(temp) = binary.left.data { diff --git a/test/bundler/transpiler/decorators-legacy-lowering.test.ts b/test/bundler/transpiler/decorators-legacy-lowering.test.ts index 7c2cc7553b8b..ac41f35f2847 100644 --- a/test/bundler/transpiler/decorators-legacy-lowering.test.ts +++ b/test/bundler/transpiler/decorators-legacy-lowering.test.ts @@ -55,8 +55,12 @@ const fixtures: Record = { class F { @d2 [key()]() {} @d2 [key()] = 1 } new F(); new F(); console.log(n, JSON.stringify(Object.getOwnPropertyNames(F.prototype)), JSON.stringify(Object.keys(new F()))); + let k: any; + class G { @d2 [k = "a"]() {} @d2 [k = "b"]() {} @d2 accessor [k = "c"] = 1 } + console.log(k, Object.getOwnPropertyNames(G.prototype).join(",")); `, - expected: 'decorating k1\ndecorating k2\n2 ["constructor","k1"] ["k2"]\n', + expected: + 'decorating k1\ndecorating k2\n2 ["constructor","k1"] ["k2"]\ndecorating a\ndecorating b\ndecorating c\nc constructor,a,b,c\n', }, "a decorated declare field is dropped but its computed key still runs once": { source: ` diff --git a/test/bundler/transpiler/decorators.test.ts b/test/bundler/transpiler/decorators.test.ts index 13a4a58d5844..8acd50204940 100644 --- a/test/bundler/transpiler/decorators.test.ts +++ b/test/bundler/transpiler/decorators.test.ts @@ -929,15 +929,15 @@ test("decorator and declare", () => { }); test("lowering many decorated instance fields into a large constructor body stays linear", async () => { - // Hold N fixed; compare M=100 vs M=50000. If the splice-after-super() were O(M*N) - // instead of O(M+N), tLarge/tSmall would be ~5x (debug) / ~90x (release) here, not ~2x. + // Hold N fixed; compare M=25000 vs M=50000 after a warm-up. If the splice-after-super() + // were O(M*N) instead of O(M+N), tLarge/tMid would be ~4x here, not ~1.2x to 2x. // Only useDefineForClassFields: false moves field initializers into the constructor. await using proc = Bun.spawn({ cmd: [ bunExe(), "-e", ` - const N = 500000; + const N = 200000; function gen(M) { let src = "function d(t,k){}\\nclass Base {}\\nclass Foo extends Base {\\n"; for (let i = 0; i < M; i++) src += "@d f" + i + " = " + i + ";\\n"; @@ -959,9 +959,10 @@ test("lowering many decorated instance fields into a large constructor body stay throw new Error("instance-field initializers missing from lowered constructor at M=" + M); return ms; } - const tSmall = time(100); + time(100); + const tMid = time(25000); const tLarge = time(50000); - console.log(JSON.stringify({ tSmall, tLarge, ratio: tLarge / tSmall })); + console.log(JSON.stringify({ tMid, tLarge, ratio: tLarge / tMid })); `, ], env: bunEnv, @@ -972,9 +973,9 @@ test("lowering many decorated instance fields into a large constructor body stay }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect({ stdout, stderr, exitCode }).toMatchObject({ - stdout: expect.stringMatching(/^\{"tSmall":[\d.]+,"tLarge":[\d.]+,"ratio":[\d.]+\}\n$/), + stdout: expect.stringMatching(/^\{"tMid":[\d.]+,"tLarge":[\d.]+,"ratio":[\d.]+\}\n$/), exitCode: 0, }); - const { tSmall, tLarge } = JSON.parse(stdout); - expect(tLarge).toBeLessThan(tSmall * 3); + const { tMid, tLarge } = JSON.parse(stdout); + expect(tLarge).toBeLessThan(tMid * 3); }, 90_000); From 2fe2ee31e704d270b1c0f6c84fb3f84ddb6de581 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 28 Aug 2026 21:57:14 +0000 Subject: [PATCH 16/16] test: compare M=100 with M=20000 in the linear lowering check Doubling M with N fixed cannot tell O(M*N) from O(M+N). A small M against a large one can: parsing the body dominates the linear case, while a quadratic splice copies the body once per field. --- test/bundler/transpiler/decorators.test.ts | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/test/bundler/transpiler/decorators.test.ts b/test/bundler/transpiler/decorators.test.ts index 8acd50204940..84222e7fa80f 100644 --- a/test/bundler/transpiler/decorators.test.ts +++ b/test/bundler/transpiler/decorators.test.ts @@ -929,15 +929,17 @@ test("decorator and declare", () => { }); test("lowering many decorated instance fields into a large constructor body stays linear", async () => { - // Hold N fixed; compare M=25000 vs M=50000 after a warm-up. If the splice-after-super() - // were O(M*N) instead of O(M+N), tLarge/tMid would be ~4x here, not ~1.2x to 2x. + // Hold N fixed; compare M=100 vs M=20000 after a warm-up. Parsing the N-sized body + // dominates both runs when the lowering is O(M+N), so tLarge/tSmall stays under ~2x. + // If the splice-after-super() were O(M*N), every field would copy the body again + // and the ratio would be far above 3x in both debug and release builds. // Only useDefineForClassFields: false moves field initializers into the constructor. await using proc = Bun.spawn({ cmd: [ bunExe(), "-e", ` - const N = 200000; + const N = 500000; function gen(M) { let src = "function d(t,k){}\\nclass Base {}\\nclass Foo extends Base {\\n"; for (let i = 0; i < M; i++) src += "@d f" + i + " = " + i + ";\\n"; @@ -960,9 +962,9 @@ test("lowering many decorated instance fields into a large constructor body stay return ms; } time(100); - const tMid = time(25000); - const tLarge = time(50000); - console.log(JSON.stringify({ tMid, tLarge, ratio: tLarge / tMid })); + const tSmall = time(100); + const tLarge = time(20000); + console.log(JSON.stringify({ tSmall, tLarge, ratio: tLarge / tSmall })); `, ], env: bunEnv, @@ -973,9 +975,9 @@ test("lowering many decorated instance fields into a large constructor body stay }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect({ stdout, stderr, exitCode }).toMatchObject({ - stdout: expect.stringMatching(/^\{"tMid":[\d.]+,"tLarge":[\d.]+,"ratio":[\d.]+\}\n$/), + stdout: expect.stringMatching(/^\{"tSmall":[\d.]+,"tLarge":[\d.]+,"ratio":[\d.]+\}\n$/), exitCode: 0, }); - const { tMid, tLarge } = JSON.parse(stdout); - expect(tLarge).toBeLessThan(tMid * 3); + const { tSmall, tLarge } = JSON.parse(stdout); + expect(tLarge).toBeLessThan(tSmall * 3); }, 90_000);