Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/js_parser/lower/lower_decorators.rs
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,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 {
Expand Down
21 changes: 13 additions & 8 deletions src/js_parser/p.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6642,20 +6642,23 @@
continue;
};

let key = prop.key.expect("infallible: prop has key");
let is_static = prop.flags.contains(Flags::Property::IsStatic);
// A non-literal computed key must still evaluate in the enclosing scope.
let use_static_block = is_static
&& (!prop.flags.contains(Flags::Property::IsComputed)
|| key.unwrap_inlined().is_primitive_literal());

Check warning on line 6650 in src/js_parser/p.rs

View check run for this annotation

Claude / Claude Code Review

Non-literal computed-key static fields still emitted after the class: super/this in initializer still broken, and relative order vs literal-key decorated siblings now flips

The non-literal computed-key carve-out (commit 1b78564) leaves `static_members` in place, so `@dec static [k] = super.f()` still emits `A[k] = super.f();` after the class and hits the same `SyntaxError` this PR fixes for identifier/literal keys — and it now flips relative order between decorated statics: `@dec static [k] = g(); @dec static a = f();` runs `f()` before `g()` (previously both were in `static_members` and stayed in source order). Both are blocked on #38142's key-hoisting, so probabl
Comment thread
robobun marked this conversation as resolved.

let mut target: Expr;
if prop.flags.contains(Flags::Property::IsStatic) {
if is_static && !use_static_block {
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);
} else {
target = self.new_expr(
E::This {},
prop.key.expect("infallible: prop has key").loc,
);
target = self.new_expr(E::This {}, key.loc);
}

let key = prop.key.expect("infallible: prop has key");
target = match &key.data {
js_ast::ExprData::EString(s)
if s.is_utf8()
Expand All @@ -6681,8 +6684,10 @@
),
};

// remove fields with decorators from class body. Move static members outside of class.
if prop.flags.contains(Flags::Property::IsStatic) {
if use_static_block {
let assign = Expr::assign(target, initializer);
class_properties.push(self.make_static_block(assign, key.loc));
} else if is_static {
static_members.push(Stmt::assign(target, initializer));
} else {
instance_members.push(Stmt::assign(target, initializer));
Expand Down
251 changes: 250 additions & 1 deletion test/bundler/transpiler/decorators.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// @ts-nocheck
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
import { bunEnv, bunExe, tempDir } from "harness";
import DecoratedClass from "./decorator-export-default-class-fixture";
import DecoratedAnonClass from "./decorator-export-default-class-fixture-anon";

Expand Down Expand Up @@ -1105,3 +1105,252 @@ 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);

describe("decorated static field initializers", () => {
function dec(_target: any, _key?: any) {}

test("evaluate `this` as the class", () => {
class Base {
static tag = "base-tag";
}
class A extends Base {
static own = "own";
@dec static self = this;
@dec static inheritedTag = this.tag;
@dec static ownViaThis = this.own;
@dec static ["literal key"] = this;
@dec static 123 = this;
@dec static arrow = () => this;
@dec static fn = function (this: unknown) {
return this;
};
}

const receiver = {};
expect({
self: A.self === A,
inheritedTag: A.inheritedTag,
ownViaThis: A.ownViaThis,
literalKey: A["literal key"] === A,
numericKey: A[123] === A,
arrow: A.arrow() === A,
fn: A.fn.call(receiver) === receiver,
}).toEqual({
self: true,
inheritedTag: "base-tag",
ownViaThis: "own",
literalKey: true,
numericKey: true,
arrow: true,
fn: true,
});
});

test("run before the class decorator, against the class it decorates", () => {
const events: string[] = [];
let decorated: any;
function replace(target: any) {
events.push(`class:${target.name}`);
return class Replacement extends target {};
}
function member(target: any, key: string) {
decorated = target;
events.push(`member:${key}=${target[key] === target ? "class" : String(target[key])}`);
}

@replace
class A {
@member static self = this;
@member static n = events.push("init:n");
}

expect({
events,
binding: A.name,
decoratedIsOriginal: decorated === Object.getPrototypeOf(A),
selfIsOriginal: A.self === Object.getPrototypeOf(A),
n: A.n,
}).toEqual({
events: ["init:n", "member:self=class", "member:n=1", "class:A"],
binding: "Replacement",
decoratedIsOriginal: true,
selfIsOriginal: true,
n: 1,
});
});

test("keep a non-literal computed key in the enclosing scope", () => {
const decoratedKeys: string[] = [];
function record(_target: any, key: string) {
decoratedKeys.push(key);
}
function define(this: { name: string }, _first: string) {
class A {
@record static [this.name] = "from this";
@record static [arguments[0]] = "from arguments";
}
return A;
}

const A = define.call({ name: "thisKey" }, "argumentsKey");
expect({ decoratedKeys, thisKey: A.thisKey, argumentsKey: A.argumentsKey }).toEqual({
decoratedKeys: ["thisKey", "argumentsKey"],
thisKey: "from this",
argumentsKey: "from arguments",
});
});

test("run in source order with the other static members", () => {
const order: string[] = [];
function log(name: string) {
order.push(name);
return name;
}
function logDec(_target: any, key: string) {
order.push(`dec:${key}`);
}

class A {
static a = log("a");
@logDec static b = log("b");
static {
log("block");
}
@logDec static c = log("c");
static d = log("d");
@logDec e = log("e");
}

// Same order as tsc; `e` is an instance field, so only its decorator runs.
expect(order).toEqual(["a", "b", "block", "c", "d", "dec:e", "dec:b", "dec:c"]);
});

test.concurrent("can use `super`", async () => {
using dir = tempDir("legacy-decorator-static-super", {
"tsconfig.json": JSON.stringify({ compilerOptions: { experimentalDecorators: true } }),
"base.ts": `
export function dec(_target: any, _key?: any) {}
export class Base {
static x = 1;
static receiver() {
return this;
}
static get tagged() {
return "base:" + this.label;
}
}
`,
"anon.ts": `
import { Base, dec } from "./base";
export default class extends Base {
@dec static me = this;
@dec static viaSuper = super.receiver();
}
`,
"main.ts": `
import Anon from "./anon";
import { Base, dec } from "./base";
class A extends Base {
static label = "A";
@dec static call = super.receiver() === this;
@dec static getter = super.tagged;
@dec static arrow = (() => super.receiver())() === this;
@dec static computedMember = super["receiver"]() === this;
@dec static assigned = (super.x = 42);
@dec static ["computedKey"] = super.tagged;
}
console.log(
JSON.stringify({
call: A.call,
getter: A.getter,
arrow: A.arrow,
computedMember: A.computedMember,
assigned: A.assigned,
ownX: Object.hasOwn(A, "x") && A.x,
baseX: Base.x,
computedKey: A.computedKey,
anonMe: Anon.me === Anon,
anonViaSuper: Anon.viaSuper === Anon,
}),
);
`,
});

await using proc = Bun.spawn({
cmd: [bunExe(), "main.ts"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect({ stderr, result: stdout.trim() && JSON.parse(stdout), exitCode }).toEqual({
stderr: "",
result: {
call: true,
getter: "base:A",
arrow: true,
computedMember: true,
assigned: 42,
ownX: 42,
baseX: 1,
computedKey: "base:A",
anonMe: true,
anonViaSuper: true,
},
exitCode: 0,
});
});

test("stay in the class body as static blocks", () => {
const transpiler = new Bun.Transpiler({
loader: "ts",
tsconfig: { compilerOptions: { experimentalDecorators: true } },
});
const out = transpiler.transformSync(`
class A extends Base {
@dec static a = super.f();
static b = 1;
@dec static ["c"] = this.b;
@dec static [k] = 2;
@dec static d: number;
@dec e = this.f;
}
`);

// Everything after the import of the runtime helper.
const body = out.slice(out.indexOf("class A")).replace(/__legacyDecorateClassTS_\w+/g, "__legacyDecorateClassTS");
expect(body).toMatchInlineSnapshot(`
"class A extends Base {
constructor() {
super(...arguments);
this.e = this.f;
}
static {
this.a = super.f();
}
static b = 1;
static {
this["c"] = this.b;
}
}
A[k] = 2;
__legacyDecorateClassTS([
dec
], A.prototype, "e", undefined);
__legacyDecorateClassTS([
dec
], A, "a", undefined);
__legacyDecorateClassTS([
dec
], A, "c", undefined);
__legacyDecorateClassTS([
dec
], A, k, undefined);
__legacyDecorateClassTS([
dec
], A, "d", undefined);
"
`);
});
});
Loading