diff --git a/src/ast/g.rs b/src/ast/g.rs index 96f0a28d6390..e7e56774da00 100644 --- a/src/ast/g.rs +++ b/src/ast/g.rs @@ -101,7 +101,11 @@ impl Class { return false; } - if property.kind == PropertyKind::Normal && f.contains(flags::Property::IsStatic) { + if matches!( + property.kind, + PropertyKind::Normal | PropertyKind::AutoAccessor + ) && f.contains(flags::Property::IsStatic) + { for val in [property.value, property.initializer].into_iter().flatten() { match val.data { ExprData::EArrow(..) | ExprData::EFunction(..) => {} diff --git a/src/ast/lib.rs b/src/ast/lib.rs index 3456860e472a..99f46c86b1eb 100644 --- a/src/ast/lib.rs +++ b/src/ast/lib.rs @@ -2945,6 +2945,9 @@ pub mod flags { IsStatic, WasShorthand, IsSpread, + /// Getter/setter pair generated for an `accessor` member; `ts_metadata` + /// on the one carrying the decorators is the member's declared type. + IsLoweredAutoAccessor, } pub type PropertySet = EnumSet; pub const PROPERTY_NONE: PropertySet = EnumSet::empty(); diff --git a/src/js_parser/lower/lower_auto_accessors.rs b/src/js_parser/lower/lower_auto_accessors.rs new file mode 100644 index 000000000000..fee32dfb9777 --- /dev/null +++ b/src/js_parser/lower/lower_auto_accessors.rs @@ -0,0 +1,266 @@ +//! Lowers `accessor` members of classes that skip the standard-decorator +//! lowering (TypeScript with `experimentalDecorators`), in place: +//! +//! ```js +//! accessor x = 1; // -> #x = 1; get x() { return this.#x; } set x(v) { this.#x = v; } +//! ``` + +use bun_collections::VecExt; + +use crate::lexer as js_lexer; +use crate::p::P; +use crate::parser::Ref; +use bun_ast::g::{DeclList, Property, PropertyKind}; +use bun_ast::{self as js_ast, B, DeclaredSymbol, E, Expr, Flags, G, S}; + +type BumpVec<'a, T> = bun_alloc::ArenaVec<'a, T>; + +impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_ONLY> { + /// Call with the visited class body scope still current: the backing + /// fields are declared in it. + pub(crate) fn lower_auto_accessors_in_place(&mut self, class: &mut G::Class) { + let accessor_count = class + .properties + .iter() + .filter(|prop| prop.kind == PropertyKind::AutoAccessor) + .count(); + if accessor_count == 0 { + return; + } + + let bump = self.arena; + let mut properties = BumpVec::::with_capacity_in( + class.properties.len() + 2 * accessor_count, + bump, + ); + let mut generated_private_names = + BumpVec::<&'a [u8]>::with_capacity_in(accessor_count, bump); + let mut computed_key_decls = BumpVec::::new_in(bump); + + for slot in class.properties.slice_mut() { + let prop = core::mem::take(slot); + if prop.kind == PropertyKind::AutoAccessor { + self.lower_auto_accessor( + prop, + &mut properties, + &mut generated_private_names, + &mut computed_key_decls, + ); + } else { + properties.push(prop); + } + } + class.properties = bun_ast::StoreSlice::from_bump(properties); + + if !computed_key_decls.is_empty() { + let decl = self.s( + S::Local { + decls: DeclList::from_bump_vec(computed_key_decls), + ..Default::default() + }, + class.body_loc, + ); + self.nearest_stmt_list_mut() + .expect("classes are only visited from within a statement list") + .push(decl); + } + } + + fn lower_auto_accessor( + &mut self, + mut accessor: Property, + out: &mut BumpVec<'a, Property>, + generated_private_names: &mut BumpVec<'a, &'a [u8]>, + computed_key_decls: &mut BumpVec<'a, G::Decl>, + ) { + let key = accessor + .key + .expect("infallible: auto-accessors always have a key"); + let loc = key.loc; + let is_computed = accessor.flags.contains(Flags::Property::IsComputed); + let is_static = accessor.flags.contains(Flags::Property::IsStatic); + + let preferred_name: &'a [u8] = match &key.data { + js_ast::ExprData::EString(name) + if !is_computed + && name.is_utf8() + && js_lexer::is_identifier(&name.data) + && !name.eql_comptime(b"constructor") => + { + self.bump_name2(b"#", &name.data) + } + // `accessor #p` keeps `#p` for its getter/setter pair. + js_ast::ExprData::EPrivateIdentifier(private) => { + let name = self.load_name_from_ref(private.ref_); + self.bump_name2(b"#_", &name[1..]) + } + _ => b"#_accessor_storage", + }; + let backing_name = self.unused_private_name(preferred_name, generated_private_names); + generated_private_names.push(backing_name); + + let backing_kind = if is_static { + js_ast::symbol::Kind::PrivateStaticField + } else { + js_ast::symbol::Kind::PrivateField + }; + let backing_ref = self.new_sym(backing_kind, backing_name); + self.record_declared_symbol(backing_ref); + + // #x = ; + let mut backing_flags = accessor.flags; + backing_flags.remove(Flags::Property::IsComputed); + let backing_key = self.new_expr(E::PrivateIdentifier { ref_: backing_ref }, loc); + out.push(Property { + kind: PropertyKind::Normal, + flags: backing_flags, + key: Some(backing_key), + initializer: accessor.initializer.take(), + ..Default::default() + }); + + // `get [_computedKey = expr]() {} set [_computedKey]() {}` + let needs_key_temp = is_computed + && !matches!( + key.data, + js_ast::ExprData::EString(_) | js_ast::ExprData::ENumber(_) + ); + let (getter_key, setter_key) = if needs_key_temp { + let temp_ref = self.declare_var_temp_ref(b"_computedKey"); + let binding = self.b(B::Identifier { r#ref: temp_ref }, loc); + computed_key_decls.push(G::Decl { + binding, + value: None, + }); + let temp_for_getter = self.use_ref(temp_ref, loc); + let temp_for_setter = self.use_ref(temp_ref, loc); + (Expr::assign(temp_for_getter, key), temp_for_setter) + } else { + (key, key) + }; + + let mut pair_flags = accessor.flags; + pair_flags.insert(Flags::Property::IsMethod); + pair_flags.insert(Flags::Property::IsLoweredAutoAccessor); + + // get x() { return this.#x; } + let getter_value = self.backing_field_access(backing_ref, loc); + let getter_body = self.s( + S::Return { + value: Some(getter_value), + }, + loc, + ); + let getter = G::Fn { + body: G::FnBody { + stmts: bun_ast::StoreSlice::new_mut(self.arena.alloc_slice_copy(&[getter_body])), + loc, + }, + ..Default::default() + }; + let getter_fn = self.new_expr(E::Function { func: getter }, loc); + out.push(Property { + kind: PropertyKind::Get, + flags: pair_flags, + key: Some(getter_key), + value: Some(getter_fn), + ..Default::default() + }); + + // set x(v) { this.#x = v; } + // The decorators go on the setter because `lower_class` reuses the decorated + // member's key, and the setter's is the one without the assignment. + let value_ref = self.new_sym(js_ast::symbol::Kind::Other, b"v"); + let value_binding = self.b(B::Identifier { r#ref: value_ref }, loc); + let setter_arg = self.arena.alloc(G::Arg { + binding: value_binding, + ..Default::default() + }); + let assignment_target = self.backing_field_access(backing_ref, loc); + let assigned_value = self.use_ref(value_ref, loc); + let setter_body = self.s( + S::SExpr { + value: Expr::assign(assignment_target, assigned_value), + ..Default::default() + }, + loc, + ); + let setter = G::Fn { + args: bun_ast::StoreSlice::new_mut(core::slice::from_mut(setter_arg)), + body: G::FnBody { + stmts: bun_ast::StoreSlice::new_mut(self.arena.alloc_slice_copy(&[setter_body])), + loc, + }, + ..Default::default() + }; + let setter_fn = self.new_expr(E::Function { func: setter }, loc); + out.push(Property { + kind: PropertyKind::Set, + flags: pair_flags, + key: Some(setter_key), + value: Some(setter_fn), + ts_decorators: bun_alloc::AstAlloc::take(&mut accessor.ts_decorators), + ts_metadata: core::mem::take(&mut accessor.ts_metadata), + ..Default::default() + }); + } + + /// Private names are printed as written (only the minifier renames them), so + /// the name must not be visible from this class body already. + fn unused_private_name(&self, preferred: &'a [u8], generated: &[&'a [u8]]) -> &'a [u8] { + let mut candidate = preferred; + let mut suffix: usize = 2; + while generated.contains(&candidate) || self.is_declared_in_enclosing_scopes(candidate) { + candidate = self.bump_name(preferred, Some(suffix)); + suffix += 1; + } + candidate + } + + /// Symbol for a `var` emitted next to the class: it lives in the scope the + /// `var` hoists to, and the bundler's renamer only sees module-level + /// symbols through `declared_symbols`. + fn declare_var_temp_ref(&mut self, name: &'a [u8]) -> Ref { + let mut scope = self.current_scope; + while !scope.kind_stops_hoisting() { + scope = scope + .parent + .expect("infallible: the module scope stops hoisting"); + } + let ref_ = self.generate_temp_ref_with_scope(Some(name), scope); + self.declared_symbols + .append(DeclaredSymbol { + ref_, + is_top_level: scope == self.module_scope, + }) + .expect("oom"); + ref_ + } + + fn is_declared_in_enclosing_scopes(&self, name: &[u8]) -> bool { + let hash = js_ast::Scope::get_member_hash(name); + let mut scope = Some(self.current_scope); + while let Some(current) = scope { + if current.get_member_with_hash(name, hash).is_some() { + return true; + } + scope = current.parent; + } + false + } + + /// `this.#x` + fn backing_field_access(&mut self, backing_ref: Ref, loc: bun_ast::Loc) -> Expr { + self.record_usage(backing_ref); + let target = self.new_expr(E::This {}, loc); + let index = self.new_expr(E::PrivateIdentifier { ref_: backing_ref }, loc); + self.new_expr( + E::Index { + target, + index, + optional_chain: None, + }, + loc, + ) + } +} diff --git a/src/js_parser/lower/lower_decorators.rs b/src/js_parser/lower/lower_decorators.rs index 77c2620dac5e..0e399a66a4a4 100644 --- a/src/js_parser/lower/lower_decorators.rs +++ b/src/js_parser/lower/lower_decorators.rs @@ -146,7 +146,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O /// recordUsage + E.Identifier in one call. #[inline] - fn use_ref(&mut self, ref_: Ref, l: bun_ast::Loc) -> Expr { + pub(crate) fn use_ref(&mut self, ref_: Ref, l: bun_ast::Loc) -> Expr { self.record_usage(ref_); self.new_expr( E::Identifier { @@ -166,7 +166,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } /// newSymbol + scope.generated.append in one call. - fn new_sym(&mut self, kind: js_ast::symbol::Kind, name: &'a [u8]) -> Ref { + pub(crate) fn new_sym(&mut self, kind: js_ast::symbol::Kind, name: &'a [u8]) -> Ref { let ref_ = self.new_symbol(kind, name); VecExt::append(&mut self.current_scope_mut().generated, ref_); ref_ @@ -394,8 +394,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } - /// Bump-format `_{prefix}{n}` (or just `_{prefix}` when n is omitted). - fn bump_name(&self, prefix: &[u8], n: Option) -> &'a [u8] { + /// Bump-format `{prefix}{n}` (or just `{prefix}` when n is omitted). + pub(crate) fn bump_name(&self, prefix: &[u8], n: Option) -> &'a [u8] { let mut v = BumpVec::::new_in(self.arena); v.extend_from_slice(prefix); if let Some(n) = n { @@ -407,7 +407,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O v.into_bump_slice() } - fn bump_name2(&self, a: &[u8], b: &[u8]) -> &'a [u8] { + pub(crate) fn bump_name2(&self, a: &[u8], b: &[u8]) -> &'a [u8] { let mut v = BumpVec::::new_in(self.arena); v.extend_from_slice(a); v.extend_from_slice(b); diff --git a/src/js_parser/lower/mod.rs b/src/js_parser/lower/mod.rs index f3447aef653a..42731293c91d 100644 --- a/src/js_parser/lower/mod.rs +++ b/src/js_parser/lower/mod.rs @@ -1,2 +1,3 @@ +pub mod lower_auto_accessors; pub mod lower_decorators; pub mod lower_esm_exports_hmr; diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 12d485079d93..ad418d2582e7 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -6435,8 +6435,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let descriptor_key = prop.key.expect("infallible: prop has key"); let loc = descriptor_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) @@ -6844,6 +6842,15 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } } + PropertyKind::Get | PropertyKind::Set + if prop.flags.contains(Flags::Property::IsLoweredAutoAccessor) => + { + // typescript sets only design:type (the declared type) for an `accessor` member. + 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 []. @@ -6911,7 +6918,8 @@ 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::Spread | PropertyKind::Declare => {} + PropertyKind::AutoAccessor => {} // lowered to a getter/setter pair before this runs PropertyKind::ClassStaticBlock => {} // not allowed to decorate this } } diff --git a/src/js_parser/parse/parse_property.rs b/src/js_parser/parse/parse_property.rs index 81fa6a8f0224..200277be0fa1 100644 --- a/src/js_parser/parse/parse_property.rs +++ b/src/js_parser/parse/parse_property.rs @@ -449,7 +449,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 + // Legacy mode decorates an abstract accessor + // like an abstract field (tsc parity). + if (prop.kind == PropertyKind::Normal + || (prop.kind == PropertyKind::AutoAccessor + && !p.options.features.standard_decorators)) && prop.value.is_none() && opts.ts_decorators.len() > 0 { @@ -463,10 +467,10 @@ 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 (valid in + // both decorator modes, like in TypeScript) 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/visit/mod.rs b/src/js_parser/visit/mod.rs index 53786e4db37d..77619c3fae3b 100644 --- a/src/js_parser/visit/mod.rs +++ b/src/js_parser/visit/mod.rs @@ -996,6 +996,12 @@ 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; } + // Before the useDefineForClassFields lowering below, which has to see + // the generated backing fields. + if !class.should_lower_standard_decorators { + self.lower_auto_accessors_in_place(class); + } + 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/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index 597bcd6952aa..a7462850e4fe 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -55,7 +55,10 @@ 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: `Class::can_be_moved` counts a `static accessor` initializer as a +/// side effect, so such classes are no longer hoisted above the bindings they +/// read; older entries still hold the hoisted output. +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.test.ts b/test/bundler/transpiler/decorators.test.ts index 80800f17c415..e1383faf7b64 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 { bunEnv, bunExe, tempDir } from "harness"; +import { join } from "node:path"; import DecoratedClass from "./decorator-export-default-class-fixture"; import DecoratedAnonClass from "./decorator-export-default-class-fixture-anon"; @@ -1105,3 +1106,598 @@ test("lowering many decorated instance fields into a large constructor body stay const { tSmall, tLarge } = JSON.parse(stdout); expect(tLarge).toBeLessThan(tSmall * 3); }, 90_000); + +// With experimentalDecorators the standard-decorator lowering (which also lowers +// `accessor` members) is not used, so `accessor` is desugared in place instead: +// accessor x = 1 -> #x = 1; get x() { return this.#x } set x(v) { this.#x = v } +// tsc accepts the keyword in this mode too, and decorates such a member like a +// getter/setter pair. +describe("accessor keyword with experimentalDecorators", () => { + function transpile(code: string, compilerOptions: Record = {}) { + const transpiler = new Bun.Transpiler({ + loader: "ts", + tsconfig: { compilerOptions: { experimentalDecorators: true, ...compilerOptions } }, + }); + return transpiler + .transformSync(code) + .replace(/^import \{[\s\S]*?\} from "bun:wrap";\n/m, "") + .replace(/__legacy(\w+?)TS_\w+/g, "__legacy$1TS") + .trim(); + } + + async function run(name: string, files: Record, compilerOptions: Record = {}) { + using dir = tempDir(name, { + "tsconfig.json": JSON.stringify({ compilerOptions: { experimentalDecorators: true, ...compilerOptions } }), + ...files, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.ts"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + } + + test("is lowered to a private field plus a getter/setter pair", () => { + expect(transpile(`class A { accessor x = 1 }`)).toMatchInlineSnapshot(` + "class A { + #x = 1; + get x() { + return this.#x; + } + set x(v) { + this.#x = v; + } + }" + `); + }); + + test("static, private, literal and computed keys", () => { + expect( + transpile(` + const k = () => "dyn"; + class A { + static accessor s = 1; + accessor #p = 2; + accessor [k()] = 3; + accessor ["lit"] = 4; + accessor 42 = 5; + accessor noInit: string; + getP() { return this.#p; } + } + `), + ).toMatchInlineSnapshot(` + "var __bun_temp_ref_1$; + const k = () => "dyn"; + + class A { + static #s = 1; + static get s() { + return this.#s; + } + static set s(v) { + this.#s = v; + } + #_p = 2; + get #p() { + return this.#_p; + } + set #p(v) { + this.#_p = v; + } + #_accessor_storage = 3; + get [__bun_temp_ref_1$ = k()]() { + return this.#_accessor_storage; + } + set [__bun_temp_ref_1$](v) { + this.#_accessor_storage = v; + } + #_accessor_storage2 = 4; + get ["lit"]() { + return this.#_accessor_storage2; + } + set ["lit"](v) { + this.#_accessor_storage2 = v; + } + #_accessor_storage3 = 5; + get 42() { + return this.#_accessor_storage3; + } + set 42(v) { + this.#_accessor_storage3 = v; + } + #noInit; + get noInit() { + return this.#noInit; + } + set noInit(v) { + this.#noInit = v; + } + getP() { + return this.#p; + } + }" + `); + }); + + test("backing fields avoid the private names of the class and of enclosing classes", () => { + expect( + transpile(` + class Outer { + #x = "outer"; + accessor x = 1; + static accessor x = 2; + inner() { + return class Inner { + accessor x = 3; + outerX(o: Outer) { return o.#x; } + }; + } + } + `), + ).toMatchInlineSnapshot(` + "class Outer { + #x = "outer"; + #x2 = 1; + get x() { + return this.#x2; + } + set x(v) { + this.#x2 = v; + } + static #x3 = 2; + static get x() { + return this.#x3; + } + static set x(v) { + this.#x3 = v; + } + inner() { + return class Inner { + #x2 = 3; + get x() { + return this.#x2; + } + set x(v) { + this.#x2 = v; + } + outerX(o) { + return o.#x; + } + }; + } + }" + `); + }); + + test("a decorated accessor is decorated like a getter/setter pair", () => { + expect( + transpile(` + declare const dec: any; + declare const key: () => string; + class A { + @dec accessor x = 1; + @dec static accessor s = 2; + @dec accessor [key()] = 3; + } + `), + ).toMatchInlineSnapshot(` + "var __bun_temp_ref_1$; + + class A { + #x = 1; + get x() { + return this.#x; + } + set x(v) { + this.#x = v; + } + static #s = 2; + static get s() { + return this.#s; + } + static set s(v) { + this.#s = v; + } + #_accessor_storage = 3; + get [__bun_temp_ref_1$ = key()]() { + return this.#_accessor_storage; + } + set [__bun_temp_ref_1$](v) { + this.#_accessor_storage = v; + } + } + __legacyDecorateClassTS([ + dec + ], A.prototype, "x", null); + __legacyDecorateClassTS([ + dec + ], A.prototype, __bun_temp_ref_1$, null); + __legacyDecorateClassTS([ + dec + ], A, "s", null);" + `); + }); + + test("TypeScript modifiers are stripped and decorated siblings are lowered as usual", () => { + // tsc accepts a class that mixes legacy decorators with accessor members, so + // the accessors must not make the rest of the class an error. A decorated + // abstract accessor is decorated like an abstract field (no body member). + expect( + transpile(` + declare const dec: any; + abstract class Entity { + @dec id = 0; + accessor name = ""; + private accessor b = 2; + protected readonly accessor c = 3; + public static accessor d = 4; + protected static override accessor e = 5; + abstract accessor f: number; + @dec abstract accessor g: number; + @dec save() {} + } + `), + ).toMatchInlineSnapshot(` + "class Entity { + constructor() { + this.id = 0; + } + #name = ""; + get name() { + return this.#name; + } + set name(v) { + this.#name = v; + } + #b = 2; + get b() { + return this.#b; + } + set b(v) { + this.#b = v; + } + #c = 3; + get c() { + return this.#c; + } + set c(v) { + this.#c = v; + } + static #d = 4; + static get d() { + return this.#d; + } + static set d(v) { + this.#d = v; + } + static #e = 5; + static get e() { + return this.#e; + } + static set e(v) { + this.#e = v; + } + save() {} + } + __legacyDecorateClassTS([ + dec + ], Entity.prototype, "id", undefined); + __legacyDecorateClassTS([ + dec + ], Entity.prototype, "g", undefined); + __legacyDecorateClassTS([ + dec + ], Entity.prototype, "save", null);" + `); + }); + + test("emitDecoratorMetadata describes the accessor's declared type, unlike a getter's signature", () => { + expect( + transpile( + ` + declare const dec: any; + class A { + @dec accessor x: number = 1; + @dec get y(): string { return ""; } + } + `, + { emitDecoratorMetadata: true }, + ), + ).toMatchInlineSnapshot(` + "class A { + #x = 1; + get x() { + return this.#x; + } + set x(v) { + this.#x = v; + } + get y() { + return ""; + } + } + __legacyDecorateClassTS([ + dec, + __legacyMetadataTS("design:type", Number) + ], A.prototype, "x", null); + __legacyDecorateClassTS([ + dec, + __legacyMetadataTS("design:type", String), + __legacyMetadataTS("design:paramtypes", []) + ], A.prototype, "y", null);" + `); + }); + + test("useDefineForClassFields: false moves the backing field's initializer into the constructor", () => { + expect( + transpile( + ` + class A { + a = 1; + accessor x = this.a + 1; + b = 2; + } + `, + { useDefineForClassFields: false }, + ), + ).toMatchInlineSnapshot(` + "class A { + constructor() { + this.a = 1; + this.#x = this.a + 1; + this.b = 2; + } + #x; + get x() { + return this.#x; + } + set x(v) { + this.#x = v; + } + }" + `); + }); + + test("accessor is still an ordinary member name", () => { + expect( + transpile(` + declare const computed: string; + class A { + accessor = 1; + static accessor: string = "s"; + accessor + afterNewline = 2; + accessor + [computed] = 3; + accessor?: number; + accessor(): void {} + } + const o = { accessor: 1 }; + `), + ).toMatchInlineSnapshot(` + "class A { + accessor = 1; + static accessor = "s"; + accessor; + afterNewline = 2; + accessor; + [computed] = 3; + accessor; + accessor() {} + } + const o = { accessor: 1 };" + `); + }); + + test.concurrent("accessors behave like the native keyword at runtime", async () => { + const { stdout, stderr, exitCode } = await run("legacy-accessor-runtime", { + "index.ts": ` + import Anon from "./anon"; + const keyCalls: string[] = []; + const key = () => { keyCalls.push("key"); return "dyn"; }; + class Base { + accessor name = "base"; + static accessor count = 1; + accessor #secret = "s"; + accessor [key()] = "d"; + get secret() { return this.#secret; } + set secret(v: string) { this.#secret = v; } + } + class Derived extends Base { + accessor name = "derived"; + superName() { return super.name; } + } + const Expr = class { accessor e = 1 }; + const d: any = new Derived(); + d.name = "changed"; + d.dyn = "d2"; + d.secret = "s2"; + Base.count++; + console.log(JSON.stringify({ + name: d.name, + superName: d.superName(), + baseName: new Base().name, + count: Base.count, + dyn: d.dyn, + keyCalls, + secret: d.secret, + protoKeys: Object.getOwnPropertyNames(Base.prototype), + ownKeys: Object.keys(d), + descriptor: typeof Object.getOwnPropertyDescriptor(Base.prototype, "name").set, + expr: new Expr().e, + anonName: Anon.name, + anonValue: new Anon().z, + })); + `, + "anon.ts": `export default class { accessor z = "z" }`, + }); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + name: "changed", + superName: "base", + baseName: "base", + count: 2, + dyn: "d2", + keyCalls: ["key"], + secret: "s2", + protoKeys: ["constructor", "name", "dyn", "secret"], + ownKeys: [], + descriptor: "function", + expr: 1, + anonName: "default", + anonValue: "z", + }); + expect(exitCode).toBe(0); + }); + + test.concurrent("legacy decorators on accessors receive the property descriptor", async () => { + const { stdout, stderr, exitCode } = await run( + "legacy-accessor-decorated", + { + "index.ts": ` + const seen: unknown[] = []; + // Stand-in for reflect-metadata: __legacyMetadataTS calls Reflect.metadata when it exists. + (Reflect as any).metadata = (k: string, v: any) => (_target: object, key: string) => { + seen.push({ metadata: k, type: v.name, key }); + }; + function record(target: object, key: string, desc?: PropertyDescriptor) { + seen.push({ + key, + onPrototype: target === A.prototype, + onClass: target === A, + get: typeof desc?.get, + set: typeof desc?.set, + args: arguments.length, + }); + } + function double(_target: object, _key: string, desc: PropertyDescriptor): PropertyDescriptor { + const { get } = desc; + return { ...desc, get() { return get!.call(this) * 2; } }; + } + let keyEvaluations = 0; + const key = () => "k" + keyEvaluations++; + class A { + @record accessor x = 1; + @double accessor n: number = 21; + @record accessor [key()] = 3; + @record static accessor s = 2; + } + const a: any = new A(); + const nBefore = a.n; + a.n = 5; + console.log(JSON.stringify({ seen, keyEvaluations, k0: a.k0, nBefore, nAfter: a.n, x: a.x, s: A.s })); + `, + }, + { emitDecoratorMetadata: true }, + ); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + seen: [ + { metadata: "design:type", type: "Object", key: "x" }, + { key: "x", onPrototype: true, onClass: false, get: "function", set: "function", args: 3 }, + { metadata: "design:type", type: "Number", key: "n" }, + { metadata: "design:type", type: "Object", key: "k0" }, + { key: "k0", onPrototype: true, onClass: false, get: "function", set: "function", args: 3 }, + { metadata: "design:type", type: "Object", key: "s" }, + { key: "s", onPrototype: false, onClass: true, get: "function", set: "function", args: 3 }, + ], + keyEvaluations: 1, + k0: 3, + nBefore: 42, + nAfter: 10, + x: 1, + s: 2, + }); + expect(exitCode).toBe(0); + }); + + test.concurrent( + "useDefineForClassFields: false initializes accessors in source order with the other fields", + async () => { + const { stdout, stderr, exitCode } = await run( + "legacy-accessor-use-define", + { + "index.ts": ` + const order: string[] = []; + class A { + a = (order.push("a"), 1); + accessor x = (order.push("x"), this.a + 1); + b = (order.push("b"), 2); + static accessor s = (order.push("s"), 3); + constructor() { order.push("ctor"); } + } + const a = new A(); + console.log(JSON.stringify({ order, x: a.x, s: A.s, ownKeys: Object.keys(a) })); + `, + }, + { useDefineForClassFields: false }, + ); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ order: ["s", "a", "x", "b", "ctor"], x: 2, s: 3, ownKeys: ["a", "b"] }); + expect(exitCode).toBe(0); + }, + ); + + test.concurrent("bundling keeps the generated bindings of different files apart", async () => { + using dir = tempDir("legacy-accessor-bundle", { + "tsconfig.json": JSON.stringify({ compilerOptions: { experimentalDecorators: true } }), + // b.ts is bundled first and its export keeps the name _computedKey, so both + // the temporary generated for B's computed key and the function declared + // below need fresh names. If those two ended up sharing a name, defining B + // would overwrite the function. + "index.ts": ` + import { B, _computedKey as exported } from "./b"; + function _computedKey() { return "function"; } + const key = () => "ka"; + class A { + #x = "private"; + accessor x = 1; + accessor [key()] = 2; + privateX() { return this.#x; } + } + const a: any = new A(); + const b: any = new B(); + console.log(JSON.stringify({ + ax: a.x, aka: a.ka, privateX: a.privateX(), bx: b.x, bkb: b.kb, fn: _computedKey(), exported, + })); + `, + "b.ts": ` + export const _computedKey = "exported"; + const key = () => "kb"; + export class B { + accessor x = 3; + accessor [key()] = 4; + } + `, + }); + const build = await Bun.build({ + entrypoints: [join(String(dir), "index.ts")], + outdir: join(String(dir), "out"), + target: "bun", + }); + expect(build.logs).toEqual([]); + expect(build.success).toBe(true); + + await using proc = Bun.spawn({ + cmd: [bunExe(), join("out", "index.js")], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + ax: 1, + aka: 2, + privateX: "private", + bx: 3, + bkb: 4, + fn: "function", + exported: "exported", + }); + expect(exitCode).toBe(0); + }); +}); diff --git a/test/bundler/transpiler/es-decorators.test.ts b/test/bundler/transpiler/es-decorators.test.ts index bdc61e68c48d..9701359b4be8 100644 --- a/test/bundler/transpiler/es-decorators.test.ts +++ b/test/bundler/transpiler/es-decorators.test.ts @@ -958,6 +958,25 @@ describe("ES Decorators", () => { expect(stdout).toBe("true\n"); expect(exitCode).toBe(0); }); + + // Classes without side effects are hoisted to the top of the module at + // runtime; a static accessor's initializer is a side effect like a static + // field's, so these classes must stay below the binding they read. + test("classes with static accessor initializers are not hoisted above the bindings they use", async () => { + const { stdout, stderr, exitCode } = await runDecorator(` + const names = ["p"]; + class Stmt { + static accessor s = names[0] + "1"; + } + export default class Def { + static accessor d = names[0] + "2"; + } + console.log(Stmt.s, Def.d); + `); + expect(stderr).toBe(""); + expect(stdout).toBe("p1 p2\n"); + expect(exitCode).toBe(0); + }); }); describe("anonymous class expressions with reserved-word inferred names", () => { @@ -1209,5 +1228,23 @@ describe("ES Decorators", () => { expect(stdout).toBe("undefined\nworld\n"); expect(exitCode).toBe(0); }); + + // tsc rejects standard decorators on abstract members (TS1270), so like a + // decorated abstract field the member is dropped from the output. Only the + // legacy-decorator path keeps its decorators. + test("decorated abstract accessor stays dropped in standard mode", () => { + const t = new Bun.Transpiler({ loader: "ts" }); + const out = t.transformSync(` + declare const dec: any; + abstract class A { + @dec abstract accessor g: number; + x = 1; + } + `); + expect(out).toContain("x = 1"); + expect(out).not.toContain('"g"'); + expect(out).not.toContain("accessor g"); + expect(out).not.toContain("__decorateElement"); + }); }); });