diff --git a/src/js_parser/lower/lower_decorators.rs b/src/js_parser/lower/lower_decorators.rs index 77c2620dac5e..92c272e62c5b 100644 --- a/src/js_parser/lower/lower_decorators.rs +++ b/src/js_parser/lower/lower_decorators.rs @@ -5,9 +5,8 @@ use bun_alloc::ArenaVecExt as _; use bun_collections::{HashMap, VecExt}; -use crate::lexer as js_lexer; use crate::p::P; -use crate::parser::{ARGUMENTS_STR as arguments_str, Ref, is_eval_or_arguments}; +use crate::parser::{ARGUMENTS_STR as arguments_str, Ref}; use bun_ast::g::{DeclList, Property, PropertyKind}; use bun_ast::{self as js_ast, B, E, Expr, ExprNodeList, Flags, G, S, Stmt}; @@ -124,19 +123,21 @@ fn class_copy(c: &G::Class) -> G::Class { } } -/// Whether a context-inferred name (`export default` → "default", object -/// property keys, assignment targets) can be attached to a lowered anonymous -/// class expression as its syntactic binding name. Class bodies are always -/// strict mode code and the output may be a module, so reserved words -/// ("default", "let", "await", …), `eval`/`arguments`, and non-identifier -/// strings would turn `_class = class {}` into a syntax error. -#[inline] -fn can_be_class_binding_name(name: &[u8]) -> bool { - js_lexer::is_identifier(name) - && js_lexer::keyword(name).is_none() - && !js_lexer::is_strict_mode_reserved_word(name) - && name != b"await" - && !is_eval_or_arguments(name) +/// Installed before static blocks run; a `static name` field instead wins on its own. +fn defines_static_name_method(props: &[Property]) -> bool { + props.iter().any(|prop| { + prop.flags.contains(Flags::Property::IsStatic) + && (prop.flags.contains(Flags::Property::IsMethod) + // A decorated accessor is installed from the suffix instead. + || (prop.kind == PropertyKind::AutoAccessor && prop.ts_decorators.len_u32() == 0)) + && match prop.key { + Some(key) => matches!( + key.unwrap_inlined().data, + js_ast::ExprData::EString(s) if s.eql_comptime(b"name") + ), + None => false, + } + }) } // ── impl P ─────────────────────────────────────────────────────────────────── @@ -1142,19 +1143,14 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O class_name_ref = ecr; class_name_loc = loc; expr_class_is_anonymous = true; - if let Some(name) = name_from_context - && can_be_class_binding_name(name) - { - class.class_name = Some(js_ast::LocRef { - ref_: p.new_sym(js_ast::symbol::Kind::Other, name), - loc, - }); - } } } else { class_name_ref = class.class_name.as_ref().unwrap().ref_; class_name_loc = class.class_name.as_ref().unwrap().loc; } + // Decided before Phase 2 replaces decorated computed keys with temporaries. + let restore_inferred_name = + expr_class_is_anonymous && !defines_static_name_method(class.properties.slice()); let mut inner_class_ref: Ref = class_name_ref; if !is_expr { @@ -2422,6 +2418,21 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O new_properties = merged; } + // A string literal, unlike a class binding, survives the bundler's renaming. + if restore_inferred_name { + let this_e = p.new_expr(E::This {}, loc); + let name_e = p.new_expr( + E::EString { + data: name_from_context.unwrap_or(b"").into(), + ..Default::default() + }, + loc, + ); + let set_name = p.call_rt(loc, b"__name", &[this_e, name_e]); + let block = p.make_static_block(set_name, loc); + new_properties.insert(0, block); + } + class.properties = bun_ast::StoreSlice::new_mut(new_properties.into_bump_slice_mut()); class.has_decorators = false; class.should_lower_standard_decorators = false; diff --git a/test/bundler/bundler_edgecase.test.ts b/test/bundler/bundler_edgecase.test.ts index 7fe388d3a623..67013a72a011 100644 --- a/test/bundler/bundler_edgecase.test.ts +++ b/test/bundler/bundler_edgecase.test.ts @@ -3170,6 +3170,51 @@ describe("bundler", () => { }, run: { stdout: "try:false" }, }); + // Standard-decorator lowering rewrites `const Bar = class { ... }` into + // `_class = class { ... }`, so the class no longer infers its name from the + // binding. The name the lowering attaches instead has to survive the + // bundler's renaming of symbols that collide in the enclosing scope (the + // function-local `Bar` here, the CommonJS-wrapped module scope below) and + // identifier minification. + const decoratedAnonymousClassNames = /* js */ ` + function dec() {} + function f() { + const Bar = class { @dec m() {} }; + const Baz = class { accessor x; }; + let Qux; Qux = class { @dec static s() {} }; + const obj = { "not-an-identifier": class { @dec m() {} } }; + return [Bar.name, Baz.name, Qux.name, obj["not-an-identifier"].name]; + } + console.log(JSON.stringify(f())); + `; + itBundled("edgecase/DecoratedAnonymousClassExprKeepsInferredName", { + files: { + "/entry.js": decoratedAnonymousClassNames, + }, + run: { stdout: '["Bar","Baz","Qux","not-an-identifier"]' }, + }); + itBundled("edgecase/DecoratedAnonymousClassExprKeepsInferredNameMinified", { + files: { + "/entry.js": decoratedAnonymousClassNames, + }, + minifyIdentifiers: true, + minifySyntax: true, + minifyWhitespace: true, + run: { stdout: '["Bar","Baz","Qux","not-an-identifier"]' }, + }); + itBundled("edgecase/DecoratedAnonymousClassExprKeepsInferredNameInCJSWrapper", { + files: { + "/entry.js": /* js */ ` + console.log(require("./mod.cjs").name); + `, + "/mod.cjs": /* js */ ` + function dec() {} + const Bar = class { @dec m() {} }; + module.exports = Bar; + `, + }, + run: { stdout: "Bar" }, + }); }); for (const backend of ["api", "cli"] as const) { diff --git a/test/bundler/transpiler/es-decorators.test.ts b/test/bundler/transpiler/es-decorators.test.ts index bdc61e68c48d..41845a031c45 100644 --- a/test/bundler/transpiler/es-decorators.test.ts +++ b/test/bundler/transpiler/es-decorators.test.ts @@ -845,6 +845,7 @@ describe("ES Decorators", () => { import Cls from "./mod.js"; const c = new Cls(); console.log(c.foo()); + console.log(Cls.name); `, "mod.js": ` function dec(fn, ctx) { console.log("decorated", ctx.name); return fn; } @@ -863,7 +864,7 @@ describe("ES Decorators", () => { const [stdout, rawStderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(filterStderr(rawStderr)).toBe(""); - expect(stdout).toBe("decorated foo\n42\n"); + expect(stdout).toBe("decorated foo\n42\ndefault\n"); expect(exitCode).toBe(0); }); @@ -982,6 +983,191 @@ describe("ES Decorators", () => { }); }); + describe("inferred names of lowered anonymous class expressions", () => { + // Lowering turns `const Bar = class { ... }` into `_class = class { ... }`, + // which would otherwise make the class infer the name "_class". The name + // the source position would have inferred must be restored without adding + // a class binding: a binding can only hold identifier names, shadows the + // outer variable inside the class body, and is renamed by the bundler. + test.concurrent("names that are not valid identifiers", async () => { + const { stdout, stderr, exitCode } = await runDecorator(` + function dec() {} + const obj = { + "foo-bar": class { @dec m() {} }, + "": class { @dec m() {} }, + default: class { @dec m() {} }, + "with space": class { accessor x; }, + }; + console.log(JSON.stringify([obj["foo-bar"].name, obj[""].name, obj.default.name, obj["with space"].name])); + `); + expect(stderr).toBe(""); + expect(stdout).toBe('["foo-bar","","default","with space"]\n'); + expect(exitCode).toBe(0); + }); + + test.concurrent("console.log and Bun.inspect show the inferred name", async () => { + const { stdout, stderr, exitCode } = await runDecorator(` + function dec() {} + const Item = class { accessor id; }; + const Decorated = class { @dec m() {} }; + class Child extends Item {} + console.log(Item, Bun.inspect(new Item(), { compact: true }), Bun.inspect({ item: new Item() }, { compact: true })); + console.log(Decorated, Bun.inspect(new Decorated(), { compact: true }), Child); + const id = x => x; + const Nameless = id(class { @dec m() {} }); + console.log(Nameless, Bun.inspect(new Nameless(), { compact: true })); + `); + expect(stderr).toBe(""); + expect(stdout).toBe( + [ + "[class Item] Item { id: [Getter/Setter] } { item: Item { id: [Getter/Setter] } }", + "[class Decorated] Decorated { m: [Function: m] } [class Child extends Item]", + "[class (anonymous)] { m: [Function: m] }", + ].join("\n") + "\n", + ); + expect(exitCode).toBe(0); + }); + + test.concurrent("every naming context", async () => { + const { stdout, stderr, exitCode } = await runDecorator(` + function dec() {} + const A = class { @dec m() {} }; + let B; B = class { @dec m() {} }; + const { C = class { @dec m() {} } } = {}; + const [D = class { @dec m() {} }] = []; + const obj = { E: class { @dec m() {} } }; + class Holder { + static F = class { @dec m() {} }; + G = class { @dec m() {} }; + } + const H = @dec class {}; + console.log(JSON.stringify([A.name, B.name, C.name, D.name, obj.E.name, Holder.F.name, new Holder().G.name, H.name])); + `); + expect(stderr).toBe(""); + expect(stdout).toBe('["A","B","C","D","E","F","G","H"]\n'); + expect(exitCode).toBe(0); + }); + + test.concurrent("no naming context leaves the name empty", async () => { + const { stdout, stderr, exitCode } = await runDecorator(` + function dec() {} + const id = x => x; + console.log(JSON.stringify([id(class { @dec m() {} }).name, id(@dec class {}).name, id(class { accessor x; }).name])); + `); + expect(stderr).toBe(""); + expect(stdout).toBe('["","",""]\n'); + expect(exitCode).toBe(0); + }); + + test.concurrent("class body still refers to the outer binding", async () => { + const { stdout, stderr, exitCode } = await runDecorator(` + function dec() {} + let Bar = class { + @dec m() { return Bar; } + static s() { return Bar; } + }; + const Original = Bar; + Bar = "reassigned"; + console.log(Original.name, new Original().m(), Original.s()); + `); + expect(stderr).toBe(""); + expect(stdout).toBe("Bar reassigned reassigned\n"); + expect(exitCode).toBe(0); + }); + + test.concurrent("name is set before static initializers run", async () => { + const { stdout, stderr, exitCode } = await runDecorator(` + function dec() {} + const Bar = class { + static field = this.name; + static #priv = this.name; + static priv() { return this.#priv; } + static { console.log("static block:", this.name); } + @dec m() {} + }; + console.log(Bar.name, Bar.field, Bar.priv()); + const Baz = @dec class { + static field = this.name; + }; + console.log(Baz.name, Baz.field); + `); + expect(stderr).toBe(""); + expect(stdout).toBe("static block: Bar\nBar Bar Bar\nBaz Baz\n"); + expect(exitCode).toBe(0); + }); + + test.concurrent("a static member named `name` declared by the class wins", async () => { + const { stdout, stderr, exitCode } = await runDecorator(` + function dec() {} + const Getter = class { static get name() { return "from getter"; } @dec m() {} }; + const Setter = class { static set name(v) {} @dec m() {} }; + const Method = class { static name() {} @dec m() {} }; + const DecoratedMethod = class { @dec static name() {} }; + const DecoratedComputed = class { @dec static ["name"]() {} }; + const Accessor = class { static accessor name = "from accessor"; @dec m() {} }; + const Field = class { static seenBefore = this.name; static name = "from field"; @dec m() {} }; + const Uninitialized = class { static name; @dec m() {} }; + const Instance = class { name = "instance"; @dec m() {} }; + console.log(JSON.stringify([ + Getter.name, + Setter.name, + typeof Method.name, + typeof DecoratedMethod.name, + typeof DecoratedComputed.name, + Accessor.name, + Field.seenBefore, + Field.name, + Uninitialized.name, + Instance.name, + ])); + `); + expect(stderr).toBe(""); + expect(stdout).toBe( + '["from getter",null,"function","function","function","from accessor","Field","from field",null,"Instance"]\n', + ); + expect(exitCode).toBe(0); + }); + + test.concurrent("a decorated static accessor named `name` is installed after the body runs", async () => { + const { stdout, stderr, exitCode } = await runDecorator(` + function dec() {} + const Foo = class { + static seenBefore = this.name; + @dec static accessor name = "from accessor"; + }; + console.log(JSON.stringify([Foo.seenBefore, Foo.name])); + `); + expect(stderr).toBe(""); + expect(stdout).toBe('["Foo","from accessor"]\n'); + expect(exitCode).toBe(0); + }); + + test.concurrent("a static `name` method keyed by an inlined TypeScript enum member wins too", async () => { + using dir = tempDir("es-dec-enum-name-key", { + "tsconfig.json": JSON.stringify({ compilerOptions: {} }), + "test.ts": ` + enum Key { Name = "name" } + function dec() {} + const Foo = class { + static [Key.Name]() { return "method"; } + @dec m() {} + }; + console.log(typeof Foo.name); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test.ts"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + }); + const [stdout, rawStderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(filterStderr(rawStderr)).toBe(""); + expect(stdout).toBe("function\n"); + expect(exitCode).toBe(0); + }); + }); + describe("private member calls in lowered classes", () => { // When a class is lowered for standard decorators, `recv.#m(...)` becomes // `__privateGet(recv, _m).call(recv, ...)`. The receiver must be evaluated