Skip to content

js_parser: keep legacy-decorated static field initializers inside the class body - #38953

Open
robobun wants to merge 2 commits into
mainfrom
farm/af8f2f12/legacy-decorated-static-init-this-super
Open

js_parser: keep legacy-decorated static field initializers inside the class body#38953
robobun wants to merge 2 commits into
mainfrom
farm/af8f2f12/legacy-decorated-static-init-this-super

Conversation

@robobun

@robobun robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • With experimentalDecorators: true, a decorated static field whose initializer uses super makes the whole module fail to load: SyntaxError: super is not valid in this context.
  • One whose initializer uses this gets the enclosing scope's this (undefined in ESM), so @dec static z = this.tag throws TypeError: undefined is not an object (evaluating 'this.tag'). tsc gives this the class and super.x the parent's x with the class as receiver.
  • Cause: the legacy lowering in lower_class (src/js_parser/p.rs, the static_members list) removes the field from the class body and emits A.y = <initializer>; as a statement after the class. The initializer is printed verbatim, so its this and super now belong to whatever encloses the class (and super does not parse there at all).
  • Same relocation, smaller symptom: decorated static initializers ran after every undecorated static member of the class instead of in source order.

Fix

  • A decorated static field whose key is a name or a literal (static y, static ["y"], static 1) now stays at its position in the class body as static { this.y = <initializer>; }, built with make_static_block from the standard-decorator lowering (made pub(crate)).
  • Why that is correct: a static block is evaluated with exactly the environment a static field initializer has (this is the class, [[HomeObject]] is the class so every super form works natively, strict code, arguments forbidden), and this.y = ... keeps the [[Set]] assignment the lowering already used. Nothing in the initializer is rewritten and no runtime helper is needed, so there is no list of super forms to get wrong. It is also the output tsc emits for experimentalDecorators + useDefineForClassFields: false, and esbuild's.
  • What stays the same: the initializer still runs before __legacyDecorateClassTS is applied (decorators are still emitted after the class; the existing decorators random test checks that a property decorator sees the assigned value), and instance fields are untouched.
  • A decorated static field with any other computed key (static [sym], static [this.k], static [await p]) keeps the previous emission. A computed key is evaluated in the scope enclosing the class, so printing it inside a static block would change what this, arguments or await in the key mean (or fail to parse). Evaluating the key once, outside the class, is what js_parser: evaluate the computed key of a legacy-decorated member once #38142 adds; once the key is a hoisted temporary these fields can take the static block too. Until then their initializers keep the old behaviour (this/super in them are still wrong), unchanged by this PR. The static_members list in lower_class stays for them. One consequence of the split: in a class that mixes the two kinds, a decorated static with a non-literal computed key now runs after its name-keyed decorated siblings, even if it precedes them in the source (before, all decorated statics ran after the class in source order; tsc runs everything in source order). Hoisting the key resolves this as well.
  • Behaviour note: a decorated static initializer now runs inside the class body, so a reference to the class name inside it binds like every other class-body reference, to the inner binding (the class as declared). This is only observable when a class decorator returns a replacement class and the initializer captures the name lazily; before this PR such a reference saw the replaced class, unlike references from methods and undecorated fields. tsc's A_1 aliasing, which makes all of them see the replacement, is not implemented in either of Bun's lowerings and is independent of this change.
  • Verified: test/bundler/transpiler/decorators.test.ts, new decorated static field initializers block: this forms (identity, inherited and own reads, literal and numeric keys, arrow capture, a function expression keeping its own this), the combination with a class decorator, a non-literal computed key using the enclosing this and arguments (pins the unchanged path), source order against plain statics and a static block, every super form in a spawned fixture (call with receiver check, getter, computed member, arrow, assignment through super landing on the subclass, literal computed key, anonymous export default class), and an inline snapshot of the emitted code showing both emissions. Five of the six fail on the released Bun (TypeError, wrong order, SyntaxError, missing static blocks); the computed-key test passes before and after and exists to keep that path unchanged.
  • Also run: the rest of decorators.test.ts, decorator-metadata.test.ts, ts-use-define-for-class-fields.test.ts, es-decorators*.test.ts, bundler_decorator_metadata.test.ts, bundler/esbuild/ts.test.ts, transpiler.test.js, bundler_edgecase.test.ts, the nest and typegraphql integration tests; the repro also runs correctly after bun build, --minify and --format=cjs under node.
  • Related work on the other lowering: Substitute this and inner class name in relocated static initializers during decorator lowering #31922, Rewrite super property accesses in private methods extracted by decorator lowering #38769 and Lower super in static blocks and static initializers relocated by decorator lowering (stacked on #38769) #38730 fix the analogous relocation in the standard (TC39) decorator lowering in lower_decorators.rs. That lowering has to relocate (standard semantics run static initializers after the decorators) and therefore rewrites this/super; the legacy lowering runs initializers before decorators, which is why staying in the class body is available here.

Background

  • Legacy (experimentalDecorators) lowering keeps decorated fields as assignments rather than class fields so that a property decorator which installs an accessor on the constructor or prototype is hit by the initializer ([[Set]]) instead of being shadowed by an own data property ([[Define]]). Instance fields become this.x = init in the constructor; static fields are the subject of this PR.
  • A class static block (static { ... }) runs during class definition, at its position among the other static members, with this bound to the class and super.x resolving through the class's parent: the same environment a static field initializer is evaluated in.
  • A computed member key (static [expr] = ...) is different: expr is evaluated in the scope that contains the class, before any static initializer runs, so it may legitimately use that scope's this, arguments or await.
  • __legacyDecorateClassTS is Bun's runtime equivalent of tsc's __decorate; it is called once per decorated member after the class statement, so member initializers observe the undecorated class in both the old and the new output.
Before / after for the repro
// tsconfig.json: { "compilerOptions": { "experimentalDecorators": true } }
function dec(target: any, key?: any) {}
class Base { static sgreet() { return "shi"; } static tag = "base-tag"; }
class A extends Base {
  @dec static y = super.sgreet();
  @dec static z = this.tag;
}
console.log(A.y, A.z);

Before (bun build --no-bundle), which fails to load with SyntaxError: super is not valid in this context.:

class A extends Base {
}
A.y = super.sgreet();
A.z = this.tag;
__legacyDecorateClassTS([dec], A, "y", undefined);
__legacyDecorateClassTS([dec], A, "z", undefined);

After, printing shi base-tag like tsc:

class A extends Base {
  static {
    this.y = super.sgreet();
  }
  static {
    this.z = this.tag;
  }
}
__legacyDecorateClassTS([dec], A, "y", undefined);
__legacyDecorateClassTS([dec], A, "z", undefined);

tsc 5.9, --experimentalDecorators --target es2022 --useDefineForClassFields false, for the same input:

class A extends Base {
    static { this.y = super.sgreet(); }
    static { this.z = this.tag; }
}
__decorate([dec], A, "y", void 0);
__decorate([dec], A, "z", void 0);

Evaluation order of a mixed class (static a, decorated static b, a static block, decorated static c, static d): tsc runs a, b, block, c, d; Bun ran a, block, d, b, c and now matches tsc.

Computed keys: what is and is not changed
function make(this: { k: string }, first: string) {
  class A {
    @dec static [this.k] = 1;        // key uses the enclosing `this`
    @dec static [arguments[0]] = 2;  // key uses the enclosing `arguments`
    @dec static plain = this;        // initializer uses the class
  }
  return A;
}

is emitted as

class A {
  static {
    this.plain = this;
  }
}
A[this.k] = 1;
A[arguments[0]] = 2;
__legacyDecorateClassTS([dec], A, this.k, undefined);
...

The two computed-key fields are emitted exactly as before this PR (same keys defined and decorated as on the released Bun); plain gets the fix. The first revision of this PR put computed keys inside the block as well, which evaluated them in the wrong scope; that is what the keep a non-literal computed key in the enclosing scope test pins.

… class body

With experimentalDecorators, a decorated static field with an initializer
was lowered to an `A.x = init` statement after the class, so `this` in the
initializer was the enclosing scope's `this` and `super.x` did not parse at
all. Emit `static { this.x = init; }` at the field's position instead,
which is what tsc emits for useDefineForClassFields: false. This also runs
the initializer in source order with the other static members, as tsc does.
@coderabbitai

coderabbitai Bot commented Aug 15, 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: 3 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: bc74c350-307a-4c17-b30b-0acff5d9a52c

📥 Commits

Reviewing files that changed from the base of the PR and between 9fb606f and 1b78564.

📒 Files selected for processing (3)
  • src/js_parser/lower/lower_decorators.rs
  • src/js_parser/p.rs
  • test/bundler/transpiler/decorators.test.ts

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

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Status: fix and tests pushed, self-review addressed.

  • Reproduced on Bun 1.4.0-canary (eabb96d) with the two-file repro from the description (tsconfig.json with experimentalDecorators: true): SyntaxError: super is not valid in this context. for the super field, TypeError: undefined is not an object (evaluating 'this.tag') with only the this field.
  • Fix: decorated static fields with a name or literal key stay in the class body as static { this.x = init; }; fields with any other computed key keep the previous emission so the key is still evaluated in the enclosing scope (see the PR description; js_parser: evaluate the computed key of a legacy-decorated member once #38142 is the piece that makes those hoistable too).
  • Tests: test/bundler/transpiler/decorators.test.ts, decorated static field initializers block. Five of the six tests fail on the released Bun and pass with this change; the sixth pins the computed-key path as unchanged.

@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.

LGTM — decorated static field initializers now stay in the class body as static { this.x = ... }, matching tsc's experimentalDecorators output.

What was reviewed:

  • The static_members relocation is fully removed and replaced with an in-place static block; this/super binding, [[Set]] semantics, and initializer-before-decorator ordering are all preserved.
  • Checked that the removed record_usage(class_ref) is correct to drop — the static path no longer emits a class-name reference.
  • Verified the existing decorators random test's static-field assertions (S.j, S[h], S[q]) still hold under the new emission order.
  • New tests cover this, super (call/getter/computed/assign/arrow), source order, computed keys, anon export default class, and an output snapshot.
Extended reasoning...

Overview

This PR fixes the legacy (experimentalDecorators) TypeScript decorator lowering in src/js_parser/p.rs: decorated static fields with initializers were being hoisted out of the class body as ClassName.y = <init>; statements after the class, which broke super (SyntaxError) and this (wrong receiver) in the initializer, and reordered them after undecorated static members. The fix keeps the field in the class body as static { this.y = <init>; }, reusing the existing make_static_block helper (bumped to pub(crate)). The static_members vec and its post-class splice are deleted; instance-field lowering is unchanged.

Security risks

None. This is a transpiler output-shape change with no untrusted-input parsing, no allocation-size arithmetic, and no FFI/syscall surface.

Level of scrutiny

Medium-high — lower_class is on every TS class's path — but the change is ~30 net lines, localized to one branch of one loop, and the semantic argument is airtight: a static block runs with exactly the environment a static field initializer has (this = class, [[HomeObject]] = class, source-order among static elements), and this.y = ... preserves the [[Set]] semantics the lowering intentionally uses. The relative order of "static assignment → __legacyDecorateClassTS" is preserved (blocks run during class definition; decorator calls remain after the class statement), so property decorators that install accessors on the constructor still see the initializer via [[Set]] — I traced this through the decorators random test's S.j / S[h] assertions and they hold. The removed record_usage(class_ref) is correct because the static branch no longer emits an E::Identifier for the class name.

Other factors

The output shape is byte-for-byte what tsc 5.9 (--experimentalDecorators --target es2022 --useDefineForClassFields false) and esbuild produce, so this converges on the reference implementation rather than inventing a new form. Test coverage is thorough for the size of the fix: this (identity, inherited/own reads, computed key, arrow-captured, function-expr non-capture), source order interleaved with plain statics and a real static block, every super form (call, getter with receiver check, computed member, arrow, assignment through super landing on the subclass, computed key), an anonymous export default class, and an inline snapshot of the emitted body plus a check that only __legacyDecorateClassTS calls follow the class. The PR description states all four new tests fail on released Bun and pass with the change, and that the wider decorator/bundler suites and NestJS/TypeGraphQL integration tests were re-run. No bugs were found by the bug-hunting system.

…outside the class body

A computed key is evaluated in the scope enclosing the class, so moving it
into a static block would change what `this`, `arguments` or `await` in the
key refer to. Only fields whose key is a name or a literal move into a
static block; the others keep the previous emission.
@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 12:13 PM PT - Aug 15th, 2026

🔄 @robobun, the build for your commit 1b785649 (Build #98192) was cancelled — waiting for the next build...

Comment thread src/js_parser/p.rs
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.

1 participant