Skip to content
Open
57 changes: 34 additions & 23 deletions src/js_parser/lower/lower_decorators.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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 <name> {}` 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,
}
})
Comment thread
robobun marked this conversation as resolved.
}

// ── impl P ───────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
45 changes: 45 additions & 0 deletions test/bundler/bundler_edgecase.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
188 changes: 187 additions & 1 deletion test/bundler/transpiler/es-decorators.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand All @@ -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);
});

Expand Down Expand Up @@ -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
Expand Down
Loading