Skip to content

Let a decorated class body observe the class its decorators return - #38731

Open
robobun wants to merge 9 commits into
mainfrom
farm/e0367c7d/decorator-inner-class-binding
Open

Let a decorated class body observe the class its decorators return#38731
robobun wants to merge 9 commits into
mainfrom
farm/e0367c7d/decorator-inner-class-binding

Conversation

@robobun

@robobun robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • With standard (TC39) decorators, references to the class name inside a decorated class declaration still see the undecorated class after a class decorator returns a replacement:
    function wrap(value, ctx) { return class W extends value { static tag = "W" } }
    @wrap class C {
      static create() { return new C() }
      whoAmI() { return C }
      static selfRef = C;
    }
    // bun 1.4.0:  [t(C), t(C.create().constructor), t(new C().whoAmI()), t(C.selfRef)] -> ["W","orig","orig","orig"]
    // tsc / spec:                                                                       -> ["W","W","W","W"]
    So a factory such as static create() under a class-replacing decorator (DI / mixin decorators) builds undecorated instances.
  • Cause, part 1: visit_class (src/js_parser/visit/mod.rs) resolves the body's C to the same symbol as the outer C, so the printed class C { ... new C ... } binds to the class's own immutable inner binding. The lowering (src/js_parser/lower/lower_decorators.rs, step 5) only reassigns the outer binding: C = __decorateElement(...). The let _C = C it appended after everything only served the TDZ emulation for element decorator expressions.
  • Cause, part 2: undecorated static fields were left in the class body, so static selfRef = C ran while the class was being defined, i.e. before the class decorator was even called, and landed on the undecorated class. Per spec (and tsc's emit) static fields are initialized after the class decorators, on the class they returned, and this in their initializers is that class.
  • esbuild 0.25 has the same two deviations; tsc is the reference here.

Fix

  • visit_class: for a class statement that has class decorators, the body's references to the class name now resolve to a separate symbol (_C), created in the scope containing the statement and handed to lower_class. Ordinary name resolution does the rewriting, so every position in the body (nested functions, shadowing, etc.) is covered.
  • lower_impl, statement mode, when that symbol is referenced:
    • emits let _C; after the pre-evaluated element decorators / computed keys (they still hit the TDZ the spec gives them; the esbuild conformance test "Class binding" keeps passing) and before the class statement; let, so a class statement in a loop body or function gets a fresh binding per evaluation;
    • injects static { _C = this } as the first element, so anything still evaluated inside the body sees the class while it is being defined;
    • step 5 becomes C = _C = __decorateElement(...);
    • records the symbol as declared so the bundler renames it per file (previously two files declaring a decorated class C produced two top-level let _C in one bundle, a SyntaxError).
  • When the body never names the class, the symbol is merged into the class name and nothing extra is emitted. This also removes the unconditional let _C = C; that every lowered class statement used to end with.
  • Classes with private static members keep the old resolution (wants_inner_class_binding): those members stay installed on the class as written, so C.#count inside the body has to keep naming that class; pointing it at the replacement would turn such accesses into brand-check TypeErrors. esbuild behaves the same way for these.
  • Static fields: undecorated public static fields of a class with class decorators are now moved out of the body like decorated ones already were, and emitted in source order with the static blocks and decorated fields as __publicField(C, key, init) after the class decorators are applied (__publicField keeps [[Define]] semantics). this in the initializer is rewritten to the decorated class and, for class expressions, the class's own name to _class, with the existing rewrite_expr walker.
    • This happens all or nothing per class, and only when every static field initializer and every computed key in the class is made of shapes the walker covers completely (can_leave_class_body: literals, identifiers, this, calls, new, member access, operators, arrays, objects without computed keys, templates). If one of them is not, in particular super.x, new.target, private names, or an arrow / function / class (whose parameter defaults, keys and heritage the walker does not visit), or if the class has any private static member (for statements the body then keeps naming the class as written, see above; and a private static field is initialized in the body, so moving its siblings would reorder them) or an undecorated static auto-accessor (its storage is initialized outside the source-ordered part of the suffix), every static field stays in the class body, in source order, and behaves exactly as before; arrows are lazy anyway, so static create = () => new C() still picks up the decorated class through _C.
    • When fields are relocated, every computed key in the class is pre-evaluated in source order (members that stay in the body get the _computedKey temp, as decorated members already did), so key evaluation keeps its source order and undecorated members' keys observe the class binding's TDZ as well.
  • Not changed: the inner name binding of decorated class expressions. Their lowering has no per-evaluation binding to reassign (_class is a hoisted temp shared by every evaluation), so a named class expression's body still sees the undecorated class, same as esbuild. For the same reason their static fields are only relocated when the body never uses the expression's name (anonymous expressions being the common case); otherwise they stay with the class the body names, as before.
  • Verification:
    • test/bundler/transpiler/es-decorators.test.ts, new class body observes the class returned by a class decorator block (16 tests): the report's repro plus getters / instance fields / this, initialization order and ownership of relocated statics, computed keys interleaved with in-body members (plus the bail-out), numeric / quoted keys, a class with one super / new.target / arrow initializer keeping all of its fields in the body in order (and its arrow still seeing the decorated class), the same for a class statement or expression with a private static field, or a static accessor, the common eager shapes all relocating together, [[Define]] vs inherited setter, per-evaluation bindings (function and loop), TDZ for heritage / element decorators / computed keys, instance-private and private-static classes (including a private static method next to a public static field the body reads), anonymous and self-referencing class expressions, export / export default (with an imported value in an initializer), a two-file Bun.build (plain and minified), and that a class which never names itself gets no extra binding. 12 fail on the released bun (the TDZ, private-static and the two bail-out ones are regression guards), all pass with this build.
    • es-decorators-esbuild.test.ts (esbuild's 147 conformance tests), es-decorators.test.ts, decorators.test.ts, decorator-metadata.test.ts, bundler_decorator_metadata.test.ts, ts-use-define-for-class-fields.test.ts, regression/issue/27575.test.ts, transpiler.test.js, esbuild/ts.test.ts, runtime-transpiler.test.ts: all pass.
    • cargo clippy -p bun_js_parser clean.

Background

  • Standard decorator lowering (lower_decorators.rs) is modeled on esbuild's: the class is printed mostly as written, followed by a suffix of __decorateElement(...) calls that apply the decorators; a class decorator's result is assigned back to the class binding. Static blocks and decorated static fields are moved into that suffix so they run after decoration, with this rewritten to the class binding by rewrite_expr.
  • A class declaration creates two bindings for its name: a mutable one in the enclosing scope, and an immutable one visible only inside the class body (class C { m() { return C } }; C = 1; new ...m() still returns the class). A class decorator that returns a replacement only ever updates the outer one, which is why the body needs its own reassignable binding. tsc solves the same problem by printing the class as an anonymous expression and rewriting body references to a _classThis variable.
  • The TC39 proposal initializes the body's binding to the decorated class right after applying class decorators and runs static field initializers / static blocks afterwards with this being that class; heritage, element decorator expressions and computed keys are evaluated before that, while the binding is in its TDZ.
  • __publicField(obj, key, value) (runtime.js) defines a data property the way field syntax does, instead of a [[Set]] that would run inherited setters.
  • Related open work on the same lowering, not overlapping with this change: Substitute this and inner class name in relocated static initializers during decorator lowering #31922 (this / class name in the already-relocated decorated initializers of class expressions, and widening rewrite_expr; can_leave_class_body can grow with it), Give decorator lowering temporaries file-unique names #31930 (collisions between the generated _init / _dec / _C names and user identifiers; this PR keeps the existing naming scheme), js_parser: keep [[Define]] semantics for decorated class fields #35537 (__publicField for decorated fields), Lower super in static blocks and static initializers relocated by decorator lowering (stacked on #38769) #38730 (super.x in already-relocated static code).
Emitted code for the repro, before and after

Before:

var _dec = [wrap];
var _init = __decoratorStart(undefined);
class C {
  static create() { return new C; }
  whoAmI() { return C; }
  static selfRef = C;
}
C = __decorateElement(_init, 0, "C", _dec, C);
__runInitializers(_init, 1, C);
__decoratorMetadata(_init, C);
let _C = C;

After:

var _dec = [wrap];
let _C;
var _init = __decoratorStart(undefined);
class C {
  static { _C = this; }
  static create() { return new _C; }
  whoAmI() { return _C; }
}
C = _C = __decorateElement(_init, 0, "C", _dec, C);
__publicField(C, "selfRef", _C);
__runInitializers(_init, 1, C);
__decoratorMetadata(_init, C);
Behavior matrix (this build vs bun 1.4.0)
case 1.4.0 this PR
methods / getters / instance fields naming the class undecorated class replacement
static fields with plain initializers (C, this.x, new C(), literals, objects, templates...) initialized before the decorator runs, on the undecorated class initialized after decoration, own properties of the replacement, this is the replacement
a class with one static field whose initializer uses super, new.target, #names, an arrow, function or class, a computed key that cannot be pre-evaluated, a private static member, or a static accessor all static fields stay in the body unchanged (arrow / function bodies still see the replacement through _C)
private static members (fields or methods) undecorated class unchanged
member decorators only, no class decorator unchanged unchanged (minus the trailing unused let _C = C)
class expression undecorated class anonymous, or name unused in the body: static fields on the replacement; body using the name: unchanged
heritage / element decorator / computed key expressions naming the class ReferenceError during evaluation same (computed keys of undecorated members included when fields are relocated)
two files each declaring a decorated, self-referencing class C, bundled SyntaxError (let _C twice) renamed _C / _C2

[review] gate passed · iteration 0 · 6 files touched

fails on main (without fix)
ASAN without fix: 12 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/transpiler/es-decorators.test.ts
bun test v1.4.0 (333c9d1cf)

test/bundler/transpiler/es-decorators.test.ts:
(pass) ES Decorators > class decorators > basic class decorator [539.77ms]
(pass) ES Decorators > class decorators > class decorator receives correct context [531.58ms]
(pass) ES Decorators > class decorators > class decorator can replace class [514.40ms]
(pass) ES Decorators > class decorators > multiple class decorators apply in reverse order [407.42ms]
131 |           viaThis: tag(Foo.viaThis),
132 |           tagWhenInitialized: Foo.tagWhenInitialized,
133 |         }));
134 |       `);
135 |       expect(stderr).toBe("");
136 |       expect(JSON.parse(stdout)).toEqual({
                                       ^
error: expect(received).toEqual(expected)

  {
-   "create": "wrapped",
-   "field": "wrapped",
-   "getter": "wrapped",
+   "create": "original",
+   "field": "original",
+   "getter": "original",
    "outer": "wrapped",
-   "self": "wrapped",
-   "tagWhenInitialized": "wrapped",
-   "viaThis": "wrapped"
... (truncated)

release without fix: 2 FAILED
bun test v1.4.0-canary.1 (0ee9820c3)

test/bundler/transpiler/es-decorators.test.ts:
(pass) ES Decorators > class decorators > basic class decorator [61.72ms]
(pass) ES Decorators > class decorators > class decorator receives correct context [14.17ms]
(pass) ES Decorators > class decorators > class decorator can replace class [10.01ms]
(pass) ES Decorators > class decorators > multiple class decorators apply in reverse order [12.79ms]
(pass) ES Decorators > class body observes the class returned by a class decorator > a class that never names itself gets no extra binding [1.93ms]
258 |           thisInComputedKey: Foo.thisInComputedKey,
259 |           created: Foo.create() instanceof Foo,
260 |         }));
261 |       `);
262 |       expect(stderr).toBe("");
263 |       expect(JSON.parse(stdout)).toEqual({
                                       ^
error: expect(received).toEqual(expected)

  {
    "created": true,
    "fromSuper": {
      "limit": 20,
    },
    "log": [
-     "first",
      "fromSuper",
-     "second",
      "decorator",
+     "first",
+     "second",
    ],
    "newTarget": "undefined",
    "originalKeys": [
-     "first",
      "fromSuper",
-   
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/transpiler/es-decorators.test.ts
bun test v1.4.0 (333c9d1cf)

test/bundler/transpiler/es-decorators.test.ts:
(pass) ES Decorators > class decorators > basic class decorator [378.45ms]
(pass) ES Decorators > class decorators > class decorator receives correct context [333.81ms]
(pass) ES Decorators > class decorators > class decorator can replace class [478.03ms]
(pass) ES Decorators > class decorators > multiple class decorators apply in reverse order [331.29ms]
(pass) ES Decorators > class body observes the class returned by a class decorator > methods, getters, instance fields and static fields see the replacement [350.76ms]
(pass) ES Decorators > class body observes the class returned by a class decorator > static fields are initialized after the class decorator, in order, on the replacement [353.36ms]
(pass) ES Decorators > class body observes the class returned by a class decorator > relocated static fields keep their keys, evaluated once and in source order [368.13ms]
(pass) ES Decorators > class body observes the cl
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 860ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/131] gen bake.{client,server,error}.js
-> bake.client.js, bake.server.js, bake.error.js
[2/131] gen NodeModuleModule.lut.h
Generating /workspace/bun/build/release/codegen/NodeModuleModule.lut.h from /workspace/bun/src/jsc/modules/NodeModuleModule.cpp
[3/131] gen cpp.rs (cppbind)
[4/131] gen generated_host_exports.rs
generated_host_exports.rs: 93 exports (host=3, lazy=10, generic=80, rust=0); 240 extern-C blocks audited
[5/131] gen JS modules (bundle-modules)
Preprocess modules (9999ms)
Bundle modules (94ms)
Postprocesss modules (257ms)
Bundle Functions (853ms)
Generate Code (41ms)

[11.27s] Bundled "src/js" for production
  2637 kb
  197 internal modules
  13 native modules
  91 internal functions across 17 files
[5/130] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

^[[1m^[[92m   Compiling^[[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
^[[1m^[[92m   Compiling^[[0m bun_simdutf_sys v
... (truncated)
diff hotspot
src/js_parser/lower/lower_decorators.rs       | 258 +++++++++++--
 src/js_parser/p.rs                            |   9 +-
 src/js_parser/visit/mod.rs                    |  31 +-
 src/js_parser/visit/visit_expr.rs             |   2 +-
 src/js_parser/visit/visit_stmt.rs             |  10 +-
 test/bundler/transpiler/es-decorators.test.ts | 528 ++++++++++++++++++++++++++
 6 files changed, 786 insertions(+), 52 deletions(-)

gate history · 3 passed · 0 rejected · iteration 0

evidence per changed file
file                                           reads  edits  tests
src/js_parser/lower/lower_decorators.rs           32     56      0
src/js_parser/p.rs                                 6      1      0
src/js_parser/visit/mod.rs                         7     12      0
src/js_parser/visit/visit_expr.rs                  2      2      0
src/js_parser/visit/visit_stmt.rs                  2      4      0
test/bundler/transpiler/es-decorators.test.ts      8     12      0

…s return

With standard decorators, a class declaration's body resolved its own name
to the class's immutable inner binding, so after
`Foo = __decorateElement(...)` installed a replacement class, methods,
getters and field initializers inside Foo still used the undecorated class
(`static create() { return new Foo() }` built undecorated instances).
Undecorated static fields also stayed in the class body, so they were
initialized on the undecorated class before the class decorators ran.

For class statements with class decorators, visit_class now resolves the
body's references to a separate binding. The lowering declares it
(`let _Foo;`) after the pre-evaluated element decorators and computed keys,
so those still observe the TDZ, captures the class in a leading static block
and reassigns it together with the class name
(`Foo = _Foo = __decorateElement(...)`). When the body never names the class
the binding is merged into the class name and nothing extra is emitted; this
also drops the unconditional `let _Foo = Foo` the lowering used to append.
The binding is recorded as a declared symbol so the bundler renames it per
file. Classes with private static members keep the old resolution, since
those members stay installed on the class as written.

Undecorated public static fields of a class with class decorators are moved
out of the body and defined with __publicField after the class decorators
have been applied, in source order with the static blocks and decorated
fields, with `this` (and, for class expressions, the class's own name)
rewritten to the decorated class. Classes with private members keep their
static fields in place because relocated initializers cannot use private
names.
@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review; the diff is final.

Reproduced on the released bun with the snippet in the description (["W","orig","orig","orig"], expected ["W","W","W","W"]); this branch prints ["W","W","W","W"]. Tests are in test/bundler/transpiler/es-decorators.test.ts (class body observes the class returned by a class decorator, 16 tests, 12 of them failing before); esbuild's decorator conformance suite and the other decorator suites pass.

Review: relocation of undecorated static fields is all or nothing per class and only happens when the class (statement or expression) has no private static member and no static accessor, the body of a class expression does not use the expression's own name, and every initializer and computed key is a shape the rewriter fully covers; otherwise every static field stays in the body exactly as before. Relocating also pre-evaluates every computed key, so key order and the class binding's TDZ are preserved (56659df, 333c9d1, fa14851, 6366245, 7e0342c, 72086c7). All review threads are resolved.

CI (build 97278 on 72086c7, finished): 177 of 179 jobs passed with no failing tests; the handful of retried or batch-only flakes were in unrelated files (install, nodemailer, napi, importstar_ts, inspect-error-leak, 07261, runtime-transpiler). The two remaining jobs, the darwin 14 aarch64 test lanes, expired without ever being picked up: that queue (six arm64 previous-tier agents) is currently backlogged for every build, not something in this PR. The diff itself is green; a retrigger has already been used once, so I am leaving it for a maintainer.

@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 18 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: a5587566-faf6-4626-b636-0aaa615a41a8

📥 Commits

Reviewing files that changed from the base of the PR and between e7460e3 and 72086c7.

📒 Files selected for processing (6)
  • src/js_parser/lower/lower_decorators.rs
  • src/js_parser/p.rs
  • src/js_parser/visit/mod.rs
  • src/js_parser/visit/visit_expr.rs
  • src/js_parser/visit/visit_stmt.rs
  • test/bundler/transpiler/es-decorators.test.ts

Comment @coderabbitai help to get the list of available commands.

Comment thread src/js_parser/lower/lower_decorators.rs
Comment thread src/js_parser/lower/lower_decorators.rs Outdated
Comment thread src/js_parser/lower/lower_decorators.rs
Comment thread src/js_parser/visit/mod.rs
@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:05 PM PT - Aug 14th, 2026

@robobun, your commit 72086c7 has some failures in Build #97278 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 38731

That installs a local version of the PR into your bun-38731 executable, so you can run:

bun-38731 --bun

Relocating an undecorated static field printed its initializer outside the
class body verbatim, so `super.x`, `new.target`, private names, and `this`
in positions rewrite_expr does not visit (arrow parameter defaults, computed
object keys) either became syntax errors or lost their receiver. Relocation
is now limited to expression shapes the rewriter walks completely
(can_leave_class_body); anything else stays in the body as before.

Relocating a field also hoists its computed key ahead of the class, which
reordered it relative to the computed keys of members left in the body. When
static fields are relocated, every computed key in the class is now
pre-evaluated in source order (and relocation is skipped if one of them
cannot be), which also keeps those keys observing the class binding's TDZ.
Comment thread src/js_parser/lower/lower_decorators.rs Outdated
Comment thread src/js_parser/lower/lower_decorators.rs Outdated
Comment thread src/js_parser/lower/lower_decorators.rs Outdated
Comment thread src/js_parser/lower/lower_decorators.rs Outdated
Comment thread src/js_parser/lower/lower_decorators.rs Outdated
Comment thread src/js_parser/lower/lower_decorators.rs Outdated
Comment thread src/js_parser/lower/lower_decorators.rs Outdated
Comment thread src/js_parser/lower/lower_decorators.rs Outdated
Comment thread src/js_parser/lower/lower_decorators.rs Outdated
Comment thread src/js_parser/lower/lower_decorators.rs Outdated
Comment thread src/js_parser/lower/lower_decorators.rs Outdated
Comment thread src/js_parser/lower/lower_decorators.rs Outdated
Comment thread src/js_parser/lower/lower_decorators.rs Outdated
Comment thread src/js_parser/visit/mod.rs Outdated
Comment thread src/js_parser/visit/mod.rs Outdated
Comment thread src/js_parser/visit/mod.rs Outdated
Comment thread src/js_parser/visit/mod.rs Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional findings (outside current diff — PR may have been updated during review):

  • 🔴 src/js_parser/lower/lower_decorators.rs:1827-1839 — The per-field can_leave_class_body(&init) gate relocates each static field individually, so @wrap class Foo extends Base { static a = 1; static b = super.x; static c = 3 } now initializes b first (in-body, on the undecorated class) and a/c afterwards (in suffix, on the replacement) — bun 1.4.0 initialized [a, b, c] in source order. The added test 'initializers that only work inside the class body stay there' lists all four non-relocatable fields before the two relocatable ones, so it doesn't catch the reordering. Fold the initializer check into relocate_static_fields (all-or-nothing, same as computed keys already are): if any static field's initializer fails can_leave_class_body, keep every static field in the body.

    Extended reasoning...

    What the bug is

    The follow-up commit added can_leave_class_body to keep initializers that use super/new.target/arrows/#names/computed-key objects inside the class body (fixing the earlier review comments about SyntaxErrors and rewrite_expr gaps). But it applies that check per field at lower_decorators.rs:1827-1839, while relocate_static_fields (:1316-1320) is decided once for the whole class and only checks computed keys. So when a class-decorated class mixes 'simple' and 'complex' static initializers, the complex ones fall through to new_properties.push(prop_full_copy(prop)) and stay in the class body (running during ClassDefinitionEvaluation, before the class decorator), while their simple siblings become __publicField(...) calls in suffix_exprs (running after __decorateElement).

    Step-by-step proof

    const log = [];
    function wrap(cls, ctx) { log.push('decorator'); return class W extends cls {}; }
    class Base { static x = 0; }
    @wrap class Foo extends Base {
      static a = (log.push('a'), 1);
      static b = (log.push('b'), super.x);   // ESuper → can_leave_class_body = false
      static c = (log.push('c'), 3);
    }
    • relocate_static_fields: class_decorators_len = 1, no computed keys → true.
    • a: is_plain_static_field ✓, initializer is EBinary(ECall, ENumber)can_leave_class_body ✓ → pushed to relocated_static_fields, recorded in static_element_order as PlainField.
    • b: is_plain_static_field ✓, but initializer contains ESuper which hits _ => false → falls through to new_properties.push, stays in the body.
    • c: same as a → relocated.

    Emitted (roughly):

    let _Foo;
    var _init = __decoratorStart(undefined);
    class Foo extends Base {
      static { _Foo = this; }
      static b = (log.push('b'), super.x);        // runs here
    }
    Foo = _Foo = __decorateElement(_init, 0, 'Foo', _dec, Foo);
    __publicField(Foo, 'a', (log.push('a'), 1));  // runs here
    __publicField(Foo, 'c', (log.push('c'), 3));

    Observable result: log = ['b', 'decorator', 'a', 'c'], Object.hasOwn(original, 'b') === true but Object.hasOwn(original, 'a') === false. bun 1.4.0 produced log = ['a', 'b', 'c', 'decorator'] with all three own properties of the original class — not spec-perfect (spec runs the decorator first), but source-ordered and consistent, which is what code depending on 'the field above me is already set' relies on.

    Why the added tests don't catch it

    The 'initializers that only work inside the class body stay there' test lists fromSuper, newTarget, thisInParameter, thisInComputedKey (all non-relocatable) before relocated and instance (both relocatable), so the split coincidentally preserves source order and the ownership assertions pass. The 'class with instance private members' test even asserts the split ownership (Object.hasOwn(original, 'peek') / Object.hasOwn(Foo, 'self')) but doesn't log initialization order — same accident, since the arrow/#name fields are again listed before the relocatable self.

    Impact

    Any class-decorated class that has a 'simple' static field textually before a static field whose initializer is an arrow, a function/class expression, uses super., new.target, #name, or an object with a computed key — a common DI/mixin pattern — now (a) initializes those fields out of source order, so an initializer reading the value of an earlier field sees undefined, and (b) puts the two groups on different class objects, so Object.hasOwn/Object.keys on the decorated class silently misses the ones left behind.

    Fix

    Make the initializer check all-or-nothing at the class level, exactly as the follow-up already does for computed keys — fold it into relocate_static_fields:

    let relocate_static_fields = class_decorators_len > 0
        && class.properties.slice().iter().all(|prop| {
            (!prop.flags.contains(Flags::Property::IsComputed)
                || prop.key.is_none_or(|key| can_leave_class_body(&key)))
            && (!is_plain_static_field(prop)
                || prop.initializer.is_none_or(|init| can_leave_class_body(&init)))
        });

    Then the per-field guard at :1827-1831 reduces to relocate_static_fields && is_plain_static_field(prop). When any one initializer can't leave the body, every static field stays in place — same order and same owner as bun 1.4.0. And add a test that interleaves a non-relocatable initializer between two relocatable ones and asserts the log order.

Deciding per field split the static fields of one class between the body
(initialized before the class decorators, on the class as written) and the
suffix (after them, on the decorated class), which reordered them relative
to each other and put them on different objects. If any static field
initializer has to stay in the body, all of them now stay, as before.
@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Re the additional finding on the per-field can_leave_class_body check (review on 56659df): fixed in 333c9d1. The initializer check is now part of relocate_static_fields, next to the computed-key one, so a class either moves all of its static fields or none of them; a class with one super / new.target / arrow / #name initializer keeps everything in the body in source order, exactly as before this PR. The test for it now interleaves such a field between two plain ones and checks the log order, the field that reads its predecessor, and that both groups end up on the same class (an initializer that only works inside the class body keeps every static field there, in order); eagerly evaluated initializers of every common shape are relocated together covers the other side.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional findings (outside current diff — PR may have been updated during review):

  • 🔴 src/js_parser/lower/lower_decorators.rs:1793-1805 — The per-field can_leave_class_body gate relocates each static field independently, so a field that stays behind (arrow / super / new.target initializer) can be interleaved with siblings that move to the suffix — reordering their initializers and splitting ownership. @wrap class Foo { static config = {limit:10}; static make = () => this.config.limit } puts config on the replacement but leaves make's lexical this as the original, so Foo.make() throws TypeError where 1.4.0 returned 10. Mirror the computed-key bail-out at line 1285: if any plain static field's initializer fails can_leave_class_body, relocate none.

    Extended reasoning...

    What the bug is

    56659df narrowed relocation to fields whose initializers pass can_leave_class_body, but applies the check per field at lower_decorators.rs:1793-1805. A field that fails falls through to new_properties.push(prop_full_copy(prop)) and stays in the class body; a sibling that passes goes into static_element_order and is emitted in the suffix after __decorateElement. When a stay-behind field sits between two relocatable ones, or before one that reads it, the split changes both initialization order and which class the properties land on.

    Why nothing else prevents it

    relocate_static_fields (line 1285) is a class-wide flag, but it gates only on computed keys — there is no class-wide check on initializers. So a class like static a = 1; static b = super.x; static c = 3 has relocate_static_fields = true, then a and c relocate while b stays. The PR's test "initializers that only work inside the class body stay there" places all four stay-behind fields (fromSuper, newTarget, thisInParameter, thisInComputedKey) at indices 0–3 and both relocated fields at 4–5, so no interleaving is exercised, and it asserts only ownership, not relative order.

    Step-by-step proof — reordering

    const log = [];
    function wrap(cls, ctx) { log.push('dec'); return class W extends cls {}; }
    class Base { static x = 0; }
    @wrap class Foo extends Base {
      static a = (log.push('a'), 1);       // ENumber → passes → relocated
      static b = (log.push('b'), super.x); // EDot(ESuper) → '_ => false' → stays
      static c = (log.push('c'), 3);       // → relocated
    }

    Emitted (roughly):

    let _Foo;
    class Foo extends _base {
      static { _Foo = this; }
      static b = (log.push('b'), super.x);   // stays in body — runs FIRST
    }
    Foo = _Foo = __decorateElement(...);      // 'dec'
    __publicField(Foo, 'a', (log.push('a'), 1));
    __publicField(Foo, 'c', (log.push('c'), 3));
    • This PR: ['b', 'dec', 'a', 'c']a and b are swapped relative to source.
    • bun 1.4.0: ['a', 'b', 'c', 'dec'] — off-spec placement, but source order preserved.
    • Spec: ['dec', 'a', 'b', 'c'].

    So this trades one off-spec behaviour for a different off-spec behaviour that additionally reverses a and b. Any observable side effect in a (mutation, throw, dependency of b) now runs in the wrong order.

    Step-by-step proof — split this (working → TypeError)

    function wrap(cls, ctx) { return class W extends cls {}; }
    @wrap class Foo {
      static config = { limit: 10 };          // EObject, non-computed key, ENumber value → passes → relocated
      static make = () => this.config.limit;  // EArrow → '_ => false' → stays in body
    }
    Foo.make();
    • config is emitted as __publicField(Foo, 'config', {limit:10}) — an own property of the replacement.
    • make stays in the class body, so the arrow's lexical this is the original class (the one being defined when static { _Foo = this } runs).
    • Foo.make (inherited from the original) invokes the arrow → reads original.configundefined.limit throws TypeError: Cannot read properties of undefined.

    In 1.4.0 both fields stayed in the body, both were own properties of the original, the arrow's this.config was {limit:10}, and Foo.make() returned 10. This is a working→broken regression on a plausible pattern (a config object plus a factory arrow that reads it).

    Fix

    Mirror the existing computed-key all-or-nothing bail-out at line 1285: fold an initializer scan into relocate_static_fields so that if any is_plain_static_field has an initializer that fails can_leave_class_body, no field is relocated. That preserves 1.4.0 semantics for classes containing super/new.target/arrow/function/class/computed-object-key static initializers while keeping the new behaviour for the common all-plain-values case, and requires no new rewrite_expr machinery. (Alternatively, keep the per-field split but push the stay-behind initializer into static_element_order as a static-block wrapper at the same index — more work, but closer to spec.)

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

The second "additional findings" review describes the same per-field gate from 56659df; it is already replaced by the all-or-nothing check in 333c9d1 (see the comment above). Both examples from it now behave exactly as on the released bun with this branch: static a / b = super.x / c logs a, b, c, dec with all three fields on the class as written, and static config = {...}; static make = () => this.config.limit returns 10, because the arrow keeps every static field of that class in the body.

Comment thread src/js_parser/lower/lower_decorators.rs
…r static accessors

Undecorated private static fields stay in the class body and undecorated
static auto-accessors are initialized before the class decorator runs, so
neither goes through the source-ordered part of the suffix. Relocating the
public static fields around them moved those out from under them; a class
containing either now keeps all of its static fields in place.
Comment thread src/js_parser/lower/lower_decorators.rs
…ted class

A private static method (not just a field) already keeps the body's
references on the class as written, and a named class expression's body
always refers to the expression's own binding, yet static fields were still
moved onto the decorated class in both cases, so a body reading them by
name found nothing. The relocation gate now uses the same private-static
check as the inner binding and, for class expressions, requires the body not
to use the expression's name; the rewrite of that name in relocated
initializers is therefore gone.
Comment thread src/js_parser/lower/lower_decorators.rs Outdated
Comment thread src/js_parser/lower/lower_decorators.rs Outdated
…ate statics too

The private static check only applied to class statements; a class
expression with a private static field still had its public static fields
relocated around it, reordering the initializers. Both forms now share it.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this iteration and found no new issues — the four predicate-mismatch regressions from earlier rounds (private static field/accessor ordering, private static methods, decorated private statics, and the expression-branch gap) are each fixed and pinned by tests. Given the size of the semantic change to decorator lowering and the number of interacting gates (wants_inner_class_binding, keep_static_fields_in_body, can_leave_class_body), a maintainer look at the overall design would still be worthwhile.

What was reviewed:

  • keep_static_fields_in_body now shares has_private_static_member across statement and expression branches, and agrees with wants_inner_class_binding — the two gates can no longer disagree on whether the body names the original class.
  • visit_class's return value changed from shadow_ref to inner_class_ref; all three callers were checked (the two statement callers thread it to lower_class, the expression caller discards it and receives Ref::NONE), and the removed shadow_ref.set(Ref::NONE) branch was dead (no caller read it).
  • can_leave_class_body whitelist vs rewrite_expr coverage — arrows, functions, super, new.target, private names, and computed object keys are excluded, so relocation only fires on shapes the walker fully rewrites.
Extended reasoning...

Overview

This PR changes standard-decorator lowering in src/js_parser/lower/lower_decorators.rs (~260 LOC) plus visit_class in visit/mod.rs and its three call sites, so that a decorated class body's references to its own name resolve to a reassignable _C binding that is updated after the class decorator runs, and undecorated public static fields are relocated to run after decoration (via __publicField) when it is safe to do so. 528 lines of new tests cover the replacement-class behavior, initialization order, computed-key pre-evaluation, the various bail-outs (super/new.target/arrows/private statics/static accessors), per-evaluation bindings, TDZ, class expressions, exports, and bundling.

Security risks

None. This is a transpiler code-generation change; no untrusted-input parsing surface is added, no syscalls, no network. The can_leave_class_body recursion is bounded by the AST it walks.

Level of scrutiny

High. This changes user-visible transpilation semantics (when static fields initialize, which class body references resolve to) in a subtle area of the spec. Over the review it went through four rounds of predicate-mismatch regressions — each time a static-element kind slipped through one gate but not the other, reordering initializers or splitting them across the original and replacement class. Each was fixed with a test, and the final keep_static_fields_in_body = has_private_static_member(class) || (is_expr && name-used) is now structurally aligned with wants_inner_class_binding. Still, the number of interacting conditions and the whitelist-based can_leave_class_body design are the kind of thing a maintainer should sign off on rather than land purely on automated review.

Other factors

All four earlier findings are resolved and covered by tests that pass on the released bun (regression guards) or fail on it (behavior fixes), per the PR's evidence block. The esbuild conformance suite and the other decorator/transpiler suites are reported passing. The visit_class return-value change was traced through every caller and the removed shadow_ref reset was confirmed dead. No outstanding unresolved review threads remain.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Heads-up for the PlainField relocation site added here: #38730 (stacked on #38769) routes the other static relocation sites through one helper, rewrite_relocated_static_expr, which lowers super and then replaces this. Whichever of the two lands second should call it on the relocated field initializers too; otherwise a super.x in an undecorated static field of a class-decorated class goes back to being emitted verbatim.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants