Skip to content

js_parser: evaluate the computed key of a legacy-decorated member once - #38142

Closed
robobun wants to merge 1 commit into
mainfrom
farm/3e49585e/legacy-decorator-computed-key-once
Closed

robobun wants to merge 1 commit into
mainfrom
farm/3e49585e/legacy-decorator-computed-key-once

Conversation

@robobun

@robobun robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • With experimentalDecorators, a decorated class member whose key is computed has the key expression evaluated twice: once by the class body and again as the argument of the generated __legacyDecorateClassTS(...) call. If the expression is not pure, the member is defined under one key and the decorator is applied to another (repro below: the prototype gets m1, the decorator receives m2).
  • Decorated fields are removed from the body and their initializer is emitted as this[key()] = init (or A[key()] = init for statics), so their key is also re-evaluated on every construction.
  • Cause: lower_class in src/js_parser/p.rs (the prop.ts_decorators block, previously descriptor_key = prop.key, and the field relocation below it) reuses the key expression in every place that needs the key, so the printer emits it several times.
  • tsc evaluates such keys into a temporary ([_a = key()]() {} and __decorate([...], A.prototype, _a, null)), and Bun's standard-decorator lowering in lower_decorators.rs already does the same (_computedKey). Only the legacy path was missing it.
Repro (tsconfig with "experimentalDecorators": true)
let n = 0;
const k = () => "m" + ++n;
const dec: any = (_t: any, key: string) => console.log("decorated:", key);
class A { @dec [k()]() {} }
console.log("evaluations:", n, Object.getOwnPropertyNames(A.prototype));

Before:

decorated: m2
evaluations: 2 [ "constructor", "m1" ]

After (same as tsc):

decorated: m1
evaluations: 1 [ "constructor", "m1" ]

bun build output after the fix for class A { @dec [k()]() {} @dec [k()] = 1 } (helper definition omitted):

var _computedKey, _computedKey2 = k();

class A {
  constructor() {
    this[_computedKey2] = 1;
  }
  [_computedKey = k()]() {}
}
__legacyDecorateClassTS([
  dec
], A.prototype, _computedKey, null);
__legacyDecorateClassTS([
  dec
], A.prototype, _computedKey2, undefined);

The runtime transpiler emits the same shape with the temporaries spelled __bun_temp_ref_1$, __bun_temp_ref_2$.

Fix

  • For a decorated member whose key is computed and not a primitive literal, lower_class now creates a temporary, passes it to __legacyDecorateClassTS, and emits one var statement declaring the temporaries in front of the class statement.
    • Methods and accessors stay in the class body, so the key is assigned where it was: [_computedKey = key()]() {}. This is tsc's output shape, and the key keeps its source-order evaluation position.
    • Fields have no body position left (their initializer is relocated), so the key is evaluated by the declaration itself, var _computedKey = key();, and the relocated initializer becomes this[_computedKey] = init. It is evaluated before the class rather than after it because the constructor can already run while the class is being defined (static instance = new A()); tsc gets the same guarantee by also relocating static initializers, which Bun does not do. The "instance created while the class is being defined" test pins this.
  • Keys that are primitive literals (["x"], [1], inlined enum members) take the old path unchanged: evaluating them twice is unobservable. The inline snapshot in the new tests shows no temporary is introduced for them.
  • Temporaries come from a small helper, declare_var_temp_ref: generate_temp_ref_with_scope on the scope the var hoists to, plus a DeclaredSymbol for the current part. This is the registration the using lowering does for its temporaries, and what declare_generated_symbol's comment asks for. In the runtime transpiler the temporaries print as file-unique __bun_temp_ref_N$; in the bundler the renamer produces _computedKey, _computedKey2, ... across all files of the chunk. Both parts are needed: without the DeclaredSymbol, every class in a bundle shares one _computedKey; registering in the current scope instead of the hoisting scope makes two classes in sibling blocks share one hoisted var. The bundler test below fails in both of those variants (checked by building them). Accept and lower the accessor keyword in TypeScript files using experimentalDecorators #38125 adds the same helper privately for accessor; whichever lands second can drop its copy.
  • Why this is correct: every decorated member's key expression now runs exactly once per class definition, and that one value is used for the member definition, the relocated initializer and the decorate call, which is what tsc emits (methods) or guarantees (fields). Members with literal keys and undecorated members go through unchanged code.
  • Verified:
    • test/bundler/transpiler/decorators.test.ts, new describe("decorated members with computed keys"): methods/accessors/static methods, parameter decorators, instance fields across two instances, static fields, fields without an initializer (including declare), construction during the class definition, two classes in one scope, evaluation order, and an inline snapshot of the transpiled shape. 8 of the 9 fail on the released build (the no-initializer one is a guard that the new path does not add an evaluation), all pass with the fix.
    • test/bundler/bundler_edgecase.test.ts, edgecase/TypeScriptDecoratorComputedKeyTemporaries: two modules plus two sibling blocks in the entry, every instance constructed after all classes exist. Fails on the released build (12 evaluations instead of 6).
    • bun bd test on decorators, decorator-metadata, bundler_decorator_metadata, ts-use-define-for-class-fields, es-decorators, es-decorators-esbuild, esbuild/ts, bundler_edgecase, integration/typegraphql and regression tests 27526 / 27575: pass.

Background

  • Legacy decorators: under experimentalDecorators, Bun keeps the class statement and appends one __legacyDecorateClassTS(decorators, target, key, descriptor) statement per decorated member (the equivalent of tsc's __decorate). Decorated fields are additionally taken out of the class body and their initializer is re-emitted as an assignment in the constructor (or after the class for statics). js_parser: keep [[Define]] semantics for decorated class fields #35537 proposes to stop relocating them; if that lands, the field branch here becomes the method branch.
  • Computed key: a member named with [expr]. Natively the expression runs once, when the class is defined; any lowering that prints it in two places changes that, which is only observable when the expression has side effects or returns a fresh value.
  • Renamers: the runtime transpiler prints symbol names verbatim, so generated symbols must get unique names at creation time (generate_temp_ref). The bundler renames instead, taking top-level names from each part's declared_symbols and nested names from the scope tree, so a generated var has to be registered in the scope it really binds in and, at module level, as a declared symbol.

With experimentalDecorators, lower_class reused a decorated member's key
expression both in the class body (or the relocated field initializer)
and as the __legacyDecorateClassTS argument, so a computed key with side
effects ran twice and the decorator was applied to a different property
than the one defined. Evaluate the key into a temporary instead: in place
for methods and accessors, and in a var before the class for fields,
whose initializers are moved into the constructor.
@robobun

robobun commented Aug 13, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 10:05 AM PT - Aug 13th, 2026

❌ @robobun, your commit 0fa1876 has some failures in Build #94527 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 38142

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

bun-38142 --bun

@coderabbitai

coderabbitai Bot commented Aug 13, 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: 11 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: 1dc55eab-8888-4890-ae1a-8b0ba8283170

📥 Commits

Reviewing files that changed from the base of the PR and between b7a0431 and 0fa1876.

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

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

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Status

  • Reproduced on the released build (bun 1.4.0-canary) with the snippet in the description: the decorator receives m2 while the class defines m1, and a decorated field re-evaluates its key on every new.
  • Fix in src/js_parser/p.rs (lower_class); tests in test/bundler/transpiler/decorators.test.ts and test/bundler/bundler_edgecase.test.ts fail on the released build and pass with this branch.
  • Waiting on CI.

@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 PR and didn't find any bugs. Because it changes lower_class codegen in the parser — including a deliberate deviation from tsc's evaluation order for decorated field keys (evaluated before the class rather than in source position) and new renamer/scope registration for the generated temporaries — a human look would still be worthwhile.

Checked: the S::Local default is var so the pre-class declaration hoists as intended; record_usage counts line up with the two emitted identifier uses (over-count by one for initializer-less fields is harmless); primitive-literal and inlined-enum keys skip the new path via unwrap_inlined().is_primitive_literal(); the field-relocation block below correctly picks up the rewritten prop.key for this[_computedKey].

Extended reasoning...

Overview

The PR fixes double-evaluation of computed keys on legacy-decorated class members. In src/js_parser/p.rs's lower_class, a decorated member with a non-literal computed key now allocates a temporary via a new declare_var_temp_ref helper: methods rewrite the key in place to [_computedKey = expr], fields hoist var _computedKey = expr; before the class and the relocated initializer becomes this[_computedKey]. One var statement holding all temporaries is emitted before the class. ~50 lines of Rust plus a 9-test describe in decorators.test.ts (methods/accessors/statics, param decorators, fields with/without init, multiple instances, multiple classes, evaluation order, transpiled snapshot) and one itBundled case exercising cross-module and sibling-block temporaries.

Security risks

None. This is transpiler codegen for TypeScript's experimentalDecorators; no auth, crypto, filesystem, or untrusted-input parsing is touched.

Level of scrutiny

Moderate-to-high. lower_class is a load-bearing codegen path and the change interacts with two subtle subsystems: (1) the renamer — declare_var_temp_ref walks up to the hoisting scope and registers the ref both in scope.generated and declared_symbols, which the PR description says is required to keep temporaries distinct across bundled modules and sibling blocks; and (2) evaluation order — decorated field keys are now evaluated before the class (all field keys first, then in-body members), which differs from tsc's source-order evaluation. The PR justifies this by noting Bun doesn't relocate static initializers the way tsc does, so a static initializer that constructs the class needs the key already evaluated. That's a reasonable tradeoff but is a design call a maintainer should confirm.

Other factors

The tests are thorough and the PR description states 8/9 of the new transpiler tests fail on the released build. I verified the mechanics: S::Local { ..Default::default() } emits var (so hoisting is correct), the field-relocation block at lines ~6693-6717 reads back the rewritten prop.key and thus emits this[_computedKey], and is_primitive_literal after unwrap_inlined correctly excludes string/number/inlined-enum keys from the new path. I didn't find a case where the generated var could shadow or collide — the hoisting-scope walk plus DeclaredSymbol registration matches how the using lowering handles its temps. The description also notes #38125 adds an equivalent helper for accessor; whichever lands second should dedupe.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

On the evaluation-order point from the review, for whoever looks at this: decorated field keys were not evaluated in source position before this change either. The field is taken out of the class body, so its key ran after the class (in the decorate call, and again in the relocated initializer). This PR moves that single evaluation to just before the class, so an instance created by a static initializer of the same class (static instance = new A()) sees the key already assigned; the "instance created while the class is being defined" test covers that case. Keys of members that stay in the body (methods, accessors) are evaluated in source position, in the same shape tsc emits. If #35537 lands and decorated fields stay in the body, the field branch goes away and every key is evaluated in place.

@robobun

robobun commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator Author

A maintainer asked for one change that covers every experimental decorator lowering bug in this area. #40830 does that, and it includes the fix this PR makes (decorated fields stay in the class body, computed keys are captured once, parameter decorators use the enclosing scope, decorators that read a private name run in a static block, export default @dec class, accessor lowering). If #40830 lands, this PR can be closed.

@robobun

robobun commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #40830. Closing in favor of that PR.

I ran the tests of this PR against a build of #40830. Seven of the nine tests in "decorated members with computed keys" pass, and so does edgecase/TypeScriptDecoratorComputedKeyTemporaries from bundler_edgecase.test.ts. The remaining two encode an evaluation order that #40830 changes on purpose. tsc (6.0.2, transpileModule) agrees with #40830 in both cases:

  • "evaluation order": decorated fields stay in the class body, so the computed keys run in source order: method, field, undecoratedMethod, staticField, getter.
  • "fields without an initializer": the key of a decorated declare field runs in the decorator call after the class: field1, staticField2, declared3.

The inline output snapshot differs for the same reason: the keys are captured as [_key = expr] inside the body. #40830 covers this in its fixtures "computed keys are evaluated once and the decorator sees the same key" and "a decorated declare field is dropped but its computed key still runs once".

@robobun robobun closed this Aug 29, 2026
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