Skip to content

js_parser: declare decorator lowering temporaries per evaluation in parameter defaults and field initializers - #38904

Open
robobun wants to merge 2 commits into
mainfrom
farm/cbdc0cdd/decorator-class-expr-per-evaluation-temps
Open

robobun wants to merge 2 commits into
mainfrom
farm/cbdc0cdd/decorator-class-expr-per-evaluation-temps

Conversation

@robobun

@robobun robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • A class expression that needs the standard decorator lowering (decorators or an accessor member) fails when it is a parameter default value or an instance field initializer and the function is called, or the outer class constructed, more than once: constructing an instance of an earlier class throws TypeError: Cannot read from private field, and with a method decorator that uses addInitializer the instance silently receives a later class's initializers.
    function dec(_value, _ctx) {}
    function make(K = class { @dec accessor x = 0 }) { return K; }
    const K1 = make(), K2 = make();
    new K1().x; // TypeError: Cannot read from private field
    
    class Outer { inner = class { @dec accessor x = 0 }; }
    const a = new Outer(), b = new Outer();
    new a.inner().x; // same
  • The expression-mode lowering (src/js_parser/lower/lower_decorators.rs, phase 8) emits (_dec = [dec], _x = new WeakMap, _init = __decoratorStart(), _class = class {...}, __decorateElement(..., _x), _class) and pushes the var _class, _init, _dec, _x for those temporaries into p.nearest_stmt_list, the statement list around the enclosing statement (the function declaration, the outer class). That list runs once, the expression runs once per call / per construction, so every evaluation assigns the same variables. The class body reads them later, at construction time (__privateAdd(this, _x, ...), __runInitializers(_init, ...)), so an instance of the first class uses the second evaluation's WeakMap and initializer array, while its accessor was bound to the first one by __decorateElement.
  • visit_args visits default values before the function body installs its own list (src/js_parser/visit/mod.rs), and visit_class visits field initializers with the list of the surrounding statements installed; nothing in between runs once per evaluation, so there is no list the declaration could go into.
  • When nearest_stmt_list is None the declaration was silently dropped (ReferenceError: _dec is not defined); top-level enum member initializers are visited that way (js_parser: declare temporaries hoisted out of enum initializers inside the enum closure #38761 covers that case by giving the enum body a list; see below).
  • esbuild 0.25 emits the same shared module-level var for these inputs and fails the same way; tsc wraps every such class expression in an IIFE that declares the temporaries locally.

Fix

  • New P::nearest_stmt_list_is_shared: visit_args sets it while visiting the parameter list (plain and destructuring defaults) and visit_class while visiting a non-static field or accessor initializer; visit_stmts saves it, clears it while its own list is installed, and restores it, so a function body, arrow body or static block nested inside one of those expressions hoists exactly as before. Everything else inherits the value, which is what makes nested cases right: a class inside the static initializer, computed key or extends clause of a class expression that is itself a parameter default is still created once per call, and is wrapped; the same positions in a class statement still hoist.
  • Phase 8 hoists the var only when a list is installed and the flag is clear. Otherwise it emits (() => { var _class, _init, _dec, _x; return _dec = [dec], ..., _class; })() in place of the comma chain, so each evaluation of the expression gets fresh bindings. This is also the fallback for nearest_stmt_list == None.
  • Why the arrow is correct there: an arrow function keeps the this, super, arguments and new.target of the code it appears in, so the class's decorator expressions, computed keys and extends clause (the only user code evaluated in the chain; everything else in it is generated) behave as they did in the source; the new test checks this, super and arguments explicitly. A parameter default value and a class field initializer are the two expression positions where await and yield are early errors, so the chain can never contain one, which is what would make a plain arrow wrapper invalid in ordinary statement context. Evaluation order is unchanged because the wrapper is called in the exact position the chain occupied. Output for every other position is byte-for-byte unchanged (the transpiler test pins a statement, a static initializer, a computed key and a body nested in an initializer).
  • Both renamer paths are fine with the wrapper: the temporaries are still registered in the scope current at lowering time, so bun build / --minify give them names distinct from everything visible there, and the declaration now sits in a scope nested inside it. Sibling functions that each have such a default used to share one module-level declaration even in bundled output; they are independent now (the Bun.build test covers plain and minified output).
  • Verified with test/bundler/transpiler/es-decorators.test.ts, describe "class expressions evaluated once per call or per construction" (12 tests: function, arrow, constructor and method defaults, a destructuring default, instance field initializer in JS and TS, instance accessor initializer of a class that is lowered itself, a static initializer nested in a parameter default, the top-level enum fallback, the this/super/arguments guard, transpiler output shape, Bun.build plain and minified). 11 fail on the unfixed build with the error above; the this/super/arguments test passes on both and guards the wrapper's semantics.
  • Also pass with the fix: es-decorators-esbuild (the ported esbuild suite), decorators, decorator-metadata, bundler_decorator_metadata, ts-use-define-for-class-fields, ts-enum-redecl-panic, transpiler.test.js, react-compiler and react-compiler-fixtures (react refresh is the other nearest_stmt_list user; it is not affected by the flag), bundler_edgecase, bundler_minify, bundler_naming, esbuild/ts, esbuild/default, esbuild/lower; cargo clippy -p bun_js_parser clean.
  • Relation to open work in this file: js_parser: declare temporaries hoisted out of enum initializers inside the enum closure #38761 makes enum bodies install their own list, which is leaner than the fallback here for enums; both can land in either order. js_parser: give standard decorator lowering temporaries unique names #38734 changes how the temporaries are named and does not change where they are declared; its duplicate _dec entry in the var (visible in the output below) is unrelated to this change. Two related bugs found while working on this are tracked separately: temporaries declared with var are also shared across the iterations of a loop (statement and expression mode), and a decorated class used as a plain parameter default gets .name === "_class".

Background

  • Standard decorator lowering: Bun has no runtime support for TC39 decorators or accessor, so the parser rewrites such a class into a plain class plus calls to runtime helpers. For a class expression the result has to stay an expression, so the temporaries it needs are assigned inside a comma expression and declared by a separate var statement emitted somewhere else. The constructor of the rewritten class refers to those temporaries by name, which is why it matters where and how often they are declared: _init holds the per-class array of initializers that addInitializer callbacks and field initializers go into, and each accessor gets a WeakMap (_x) that stores the per-instance value.
  • nearest_stmt_list is a pointer to the list that visit_stmts emits ahead of the statements it is currently visiting; it is saved and restored around every nested statement list (function bodies, blocks, static blocks). React fast refresh uses it to insert _s = $RefreshSig$() declarations, and the decorator lowering uses it for the var above.
  • Visiting order for the two positions: visit_func / the arrow visitor push the argument scope and visit the parameters before pushing the body scope and visiting the body statements, so during a default value the list installed is still the one around the function. visit_class visits member initializers directly, inside the class body scope, with the list around the class statement installed; whether a field is static is a flag on the property.
Output for the first repro, before and after (bun build --no-bundle, helper import elided)

Before:

var _class, _init, _dec, _dec, _x;
function dec(_value, _ctx) {}
function make(K = (_dec = [dec], _x = new WeakMap, _init = __decoratorStart(undefined), _class = class {
  constructor() {
    __runInitializers(_init, 5, this);
    __privateAdd(this, _x, __runInitializers(_init, 8, this, 0));
    __runInitializers(_init, 11, this);
  }
}, __decorateElement(_init, 4, "x", _dec, _class, _x), __decoratorMetadata(_init, _class), _class)) {
  return K;
}

After:

function dec(_value, _ctx) {}
function make(K = (() => {
  var _class, _init, _dec, _dec, _x;
  return _dec = [dec], _x = new WeakMap, _init = __decoratorStart(undefined), _class = class {
    constructor() {
      __runInitializers(_init, 5, this);
      __privateAdd(this, _x, __runInitializers(_init, 8, this, 0));
      __runInitializers(_init, 11, this);
    }
  }, __decorateElement(_init, 4, "x", _dec, _class, _x), __decoratorMetadata(_init, _class), _class;
})()) {
  return K;
}

For comparison, tsc 6.0 emits K = (() => { let _x_decorators; let _x_initializers = []; ...; return class { ... }; })() for the same input, and esbuild 0.25.12 emits var _x_dec, _init, _x; at module level and fails at runtime like Bun did.

…ation in parameter defaults and field initializers

The standard decorator lowering of a class expression hoists the `var`
declaring its temporaries (_init, _class, _dec, the accessor WeakMaps)
into the nearest statement list. A parameter default value or an
instance field initializer is evaluated once per call or per
construction while that list runs once, so every class created there
shared one set of temporaries and instances of an earlier class read a
later one's storage ("Cannot read from private field").

Track, while visiting those positions, that the nearest statement list
does not run once per evaluation (visit_stmts clears the flag for any
nested body), and in that case, or when there is no list to hoist into,
declare the temporaries inside an arrow wrapped around the lowered
expression instead: `(() => { var ...; return ...; })()`.
@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: 6 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: 2baae0f3-b3d3-4792-8061-3d5bb8de5c61

📥 Commits

Reviewing files that changed from the base of the PR and between 7d276b9 and 64d1e40.

📒 Files selected for processing (4)
  • src/js_parser/lower/lower_decorators.rs
  • src/js_parser/p.rs
  • src/js_parser/visit/mod.rs
  • test/bundler/transpiler/es-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: reproduced and fixed; waiting on CI and review.

  • Reproduced on the released build (1.4.0-canary) and on a debug build of main with both repros from the report (class expression as a parameter default, and as an instance field initializer): TypeError: Cannot read from private field once the function has been called, or the outer class constructed, twice. Arrow and method defaults, a destructuring default, and a class nested in a static initializer of a parameter-default class fail the same way.
  • Fix in this PR: the temporaries are declared inside an arrow wrapped around the lowered expression in those positions (hoisting is unchanged everywhere else). Tests: test/bundler/transpiler/es-decorators.test.ts, describe "class expressions evaluated once per call or per construction"; 11 of the 12 new tests fail on the unfixed build.

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. js_parser: declare temporaries hoisted out of enum initializers inside the enum closure #38761 - Fixes the same defect shape (decorator lowering temporaries hoisted into a statement list that is not evaluated per-evaluation, plus the silently-dropped var when nearest_stmt_list is None) for the enum-initializer producer, and takes the opposite design decision on the None case — it makes enum bodies install their own statement list and adds a debug_assert!(nearest_stmt_list.is_some()), which contradicts this PR's None → IIFE fallback and its top-level enum test.

🤖 Generated with Claude Code

@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 visitor state threading in the JS parser and the shape of decorator-lowered output, a human look would still be worthwhile.

What was reviewed:

  • The save/set/restore of nearest_stmt_list_is_shared in visit_args, visit_class (non-static initializers only), and visit_stmts — no early-return paths skip the restore.
  • The IIFE fallback in phase 8: the arrow preserves this/super/arguments/new.target, and await/yield are early errors in both target positions, so the wrapper is transparent; the None-list case (top-level enum initializers) now falls back instead of silently dropping the var.
  • The other nearest_stmt_list consumer (React refresh in visit_expr.rs) does not read the new flag and is unaffected.
  • Test coverage spans function/arrow/method/constructor/destructuring defaults, JS+TS instance field initializers, nested static-in-default, the enum fallback, output-shape pinning for the unchanged positions, and Bun.build plain+minified.
Extended reasoning...

Overview

This PR fixes a correctness bug in the standard-decorator lowering for class expressions. When a lowered class expression appears as a parameter default value or an instance field initializer, the temporaries (_class, _init, _dec, accessor WeakMaps) were declared once via var in the enclosing statement list but assigned once per evaluation, so multiple evaluations shared state and later instances of an earlier class read the wrong WeakMap/initializer array. The fix introduces P::nearest_stmt_list_is_shared, set during visit_args and non-static visit_class initializer visits and cleared inside every nested visit_stmts; when set (or when no list exists), phase 8 of lower_decorators.rs wraps the comma chain in an arrow IIFE that declares the temporaries locally instead of hoisting them. An iife() helper is extracted and the one existing inline IIFE construction is switched to it. runDecoratorTS is hoisted to file scope in the test file, and 12 new tests are added.

Security risks

None. This is a code-transformation correctness fix in the transpiler; no untrusted input handling, auth, crypto, or filesystem/network surface is touched.

Level of scrutiny

Moderate-to-high. The JS parser visitor and decorator lowering are core transpiler paths that every user's decorated code flows through. The change is small and surgical (one bool flag, three save/restore sites, one read site, one helper extraction), but correctness depends on subtle invariants: that the flag is cleared for every nested statement list (so hoisting still happens inside a function body nested in an initializer), that the arrow wrapper is semantically transparent everywhere it can be emitted, and that the renamer still assigns non-colliding names when the declaration moves into a nested scope. The PR description argues each of these carefully and the tests pin them, but a maintainer familiar with the parser's scope/renamer machinery should confirm.

Other factors

  • No bugs were found by the bug hunting system.
  • The save/restore pattern matches the existing nearest_stmt_list handling in visit_stmts, and I traced each set site to verify no early return or continue between the set and the restore.
  • The test suite is thorough: it covers the full variant matrix (function/arrow/constructor/method/destructuring defaults, JS and TS field initializers, an accessor initializer in a class that is itself lowered, a static initializer nested inside a parameter default), the None-list enum fallback, a this/super/arguments semantics guard for the wrapper, an output-shape test pinning that unchanged positions still hoist, and a Bun.build plain+minified round-trip.
  • The PR notes two adjacent bugs (loop-body var sharing and .name === "_class") are tracked separately, and coordinates with #38761 and #38734.
  • No CODEOWNERS cover the changed files. No prior human review comments to address.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Not a duplicate of #38761; the two fix different producers of the same failure and compose.

A parameter default or field initializer never hoists, so do not touch
the list at all in that case; only the hoisting path looks it up.
Comment on lines +2521 to +2529
// The class keeps reading the temporaries after it is created
// (`_init` in the constructor, the accessor storage WeakMaps), so
// each evaluation of the expression needs its own set. Hoisting
// them into the nearest statement list is only right when that list
// runs once per evaluation; otherwise (a parameter default value, an
// instance field initializer, no list at all) they are declared in
// an arrow wrapped around the chain, which keeps `this`, `super`,
// `arguments` and `new.target` of the enclosing code. Neither of
// those positions can contain `await` or `yield`.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/js_parser/p.rs
Comment on lines +607 to +611
/// Set while visiting an expression that runs once per call or per
/// construction (a parameter default value, an instance field initializer)
/// although `nearest_stmt_list` runs once: a declaration hoisted there would
/// be shared by every evaluation of the expression. `visit_stmts` clears it
/// for the statements of any body nested inside such an expression.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +221 to +223
// Default values (including those nested in destructuring patterns) run
// once per call, while the statement list enclosing the function runs
// once; the function's own body installs its list later.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +995 to +996
// An instance initializer runs once per construction; a
// static one runs with the class itself.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

@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 adds new parser-wide state (nearest_stmt_list_is_shared) with save/restore across several visitor entry points, and the arrow-IIFE wrapper's correctness rests on a language-semantics argument (this/super/arguments/new.target preservation; await/yield being early errors in both target positions), a human look would still be worthwhile.

What was reviewed:

  • Flag lifecycle: set in visit_args and non-static property.initializer, cleared in visit_stmts for nested bodies, restored on every path — no leak into sibling visits.
  • Phase-8 emission: hoist only when list present and flag clear; otherwise var + return result wrapped in an arrow IIFE via the new iife helper (which also replaces the existing open-coded IIFE).
  • Checked that the static-initializer-inside-a-parameter-default case inherits the flag correctly (test covers it), and that visit_class does not set the flag for property.value (methods) or static initializers.
Extended reasoning...

Overview

The PR fixes shared-temporary bugs in the standard-decorator lowering when a decorated class expression appears as a parameter default or an instance field initializer. It adds a boolean P::nearest_stmt_list_is_shared, sets it in visit_args (all defaults, including destructuring) and in visit_class for non-static property.initializer, clears/restores it in visit_stmts, and in phase 8 of lower_decorators.rs wraps the comma chain in (() => { var ...; return chain; })() instead of hoisting the var when the flag is set or no statement list is installed. A small iife helper is extracted and reused at the existing IIFE site. Tests add a 12-case describe block plus a transpiler-output-shape test and a Bun.build plain/minified round-trip; runDecoratorTS is hoisted to file scope.

Security risks

None identified. This is transpiler output-shape work; no untrusted-input parsing, allocation sizing, or FFI is touched.

Level of scrutiny

High. This is core parser/transpiler code that runs on every file containing decorators or accessor. The fix introduces new state on P that must be saved and restored correctly across nested visits, and the arrow-wrapper correctness depends on a subtle spec argument (arrows preserve this/super/arguments/new.target; parameter defaults and class field initializers are the two expression positions where await/yield are early errors, so the wrapped chain can never contain one). The PR description makes that argument carefully and a test pins this/super/arguments, but a maintainer should confirm the reasoning and the interaction with #38761 (which adds a debug_assert!(nearest_stmt_list.is_some()) on the same code path).

Other factors

  • The comment-cop bot flagged four multi-line comments; the author hasn't responded yet. Those are style nags rather than correctness issues, but they're outstanding.
  • Test coverage is thorough (function/arrow/constructor/method/destructuring defaults, JS+TS field initializers, nested lowering, static-in-default inheritance, top-level enum fallback, output-shape pinning for the unchanged positions, Bun.build with and without minify).
  • The iife helper refactor is a straightforward de-duplication of the existing open-coded arrow+call at line ~2146; the is_async: false explicit field is dropped in favor of Default::default(), which is equivalent.

@robobun

robobun commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator Author

Status against #40833, which rewrites the standard decorator lowering: it does not cover this case. A decorated class expression in a parameter default or a field initializer still hoists its temporaries (var _class, _init, _dec, _x) to the enclosing function or module, so the second evaluation reuses the first one's WeakMap and _init. All 11 tests added here fail on that branch with TypeError: Cannot read from private field. esbuild 0.25 produces the same shape and fails the same way.

This PR stays open. It needs a rebase onto #40833, whose class-expression output is assembled in lower_impl (the is_expr branch pushes the var declaration to nearest_stmt_list_mut).

Jarred-Sumner added a commit that referenced this pull request Sep 12, 2026
…ame (#40895)

### What was wrong

Lowering a class with standard decorators (or a bare `accessor`)
declares `var _init`, `var _dec`, and one WeakMap/WeakSet per accessor
and `#private` member next to the class. **Every class used the same
names.**

- **`bun run`** does not run the symbol renamer (on purpose, for speed)
and prints those names as they are. Two classes in one scope shared one
`var _init`, so a constructor that ran after a later class was evaluated
read that class's initializers:

  ```js
  class Entity { @field id }
  class Action { @AccessorDecorator accessor success }
  new Entity().id // "success"
  ```

The same sharing made `class B extends A { accessor name }` throw
`Cannot add the same private member more than once`, made two classes
with `accessor x` throw `Cannot read from private field`, let `accessor
x` and `#x` collide inside a single class, and turned a user's `const
_init` next to a decorated class into a SyntaxError.
- **`bun build`** has a renamer, but it names a file's top level from
`Part::declared_symbols`, and the temporaries were only in
`Scope::generated`. Module-level classes collided inside one file and
across files once the linker put them in one scope. Class expressions
inside blocks registered their (function-hoisted) `var`s in the block
scope, so sibling blocks got the same name, minified or not.

### The fix

`P::generate_temp_var(name)` in `src/js_parser/p.rs` creates every
temporary the lowering declares:

- Without a renamer it appends the parser's existing per-file
`temp_ref_count` to the name (`_init$1`, `_dec$2`), the same counter
`generate_temp_ref` uses for `__bun_temp_ref_N$`. This is the line that
fixes `bun run`.
- It registers the symbol in `generated` of the function/module scope
that receives the `var`, and in `declared_symbols` (`declare_temp_var`).
This is what fixes `bun build`, which then prints `_init`, `_init2` like
esbuild. A class in a parameter default has its `var`s emitted outside
the function, so registration skips the `FunctionArgs` scope.

In `lower_decorators.rs` every `new_sym(Kind::Other, …)` for a temporary
becomes `generate_temp_var(…)`. Because names are unique by
construction, the per-class `_dec2` / `_computedKey2` /
`_accessor_storage0` counters and `bump_name` are deleted. `new_sym`
remains for the three symbols whose printed name is observable or fixed:
the class name inferred from context, the setter parameter `v`, and
`arguments`.

Also in this PR, all in the touched code:

- The `using` lowering had its own copy of the "walk to the hoisting
scope, append to `generated` and `declared_symbols`" sequence. It now
calls `declare_temp_var`.
- `generate_temp_ref` pushed every ref onto `temp_refs_to_declare`. The
only reader was the `_obj` receiver temporary of this lowering, which
now pushes its own ref, so that push is deleted.
- `accessor "x y"` declared `var _x y`. The key is used for the storage
name only when it is an identifier.
- A class expression listed each member's `_dec` / `_computedKey` twice
in its `var` statement.
- `RuntimeTranspilerCache` version 30 → 31: cached output has the shared
names.

### Tests

All new tests fail with `USE_SYSTEM_BUN=1` (1.4.1) and pass with the
debug build.

- `test/bundler/transpiler/es-decorators.test.ts` → "lowering
temporaries": 9 cases under `bun run`, including the repros from #40761,
#28010, #28316, #29837.
- `test/bundler/bundler_edgecase.test.ts` →
`edgecase/StandardDecorator*`: 6 cases under `bun build` (per class,
across files, sibling blocks ± `minifyIdentifiers`, parameter defaults,
`_obj` vs a user `_obj`). Each clause of `declare_temp_var` has a test
that fails without it.

Also run: the rest of `es-decorators`, `es-decorators-esbuild`,
`decorators`, `decorator-metadata`, `bundler_decorator_metadata`,
`bundler_edgecase`, `transpiler.test.js`, `runtime-transpiler`,
`lower-using-bun-target`, `explicit-resource-management`,
`transpiler-cache`, and `regression/issue/{11100,14709,27526,27575}`.

### Not in this PR

- A class *expression* that is evaluated more than once (loop body,
called function's parameter default) still shares its `var`s between
evaluations. That needs a binding per evaluation, not a name per class:
#38933, #38904.
- The class name inferred from context (`const Thing = class { @dec … }`
→ `class Thing`) is still created with `new_sym`. In a bundle it can
capture a reference to an import that prints as `Thing`. Same behaviour
as 1.4.1; renaming it like a temporary would change `.name` in every
bundle, so it needs to be bound in the class's own scope instead.
- #40833 (rewrite of the lowering) uses the same `name$N` scheme and an
equivalent of `declare_temp_var`; it can rebase onto this and drop those
hunks.

Fixes #40761
Fixes #28010
Fixes #28316
Fixes #29837

<!-- robobun:evidence:begin -->

---

**[human-review]** gate passed · iteration 6 · 5 files touched

<details><summary>fails on main (without fix)</summary>

```console
ASAN without fix: 15 failed, 11 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bundler/bundler_edgecase.test.ts test/bundler/transpiler/es-decorators.test.ts
bun test v1.4.3 (6a92015)

test/bundler/bundler_edgecase.test.ts:
(pass) bundler > edgecase/EmptyFile [592.49ms]
(pass) bundler > edgecase/EmptyCommonJSModule [420.69ms]
(pass) bundler > edgecase/NestedRedirectToABuiltin [440.35ms]
(pass) bundler > edgecase/ImportStarFunction [376.07ms]
(pass) bundler > edgecase/ImportStarSyntaxErrorBug [360.26ms]
(todo) bundler > edgecase/BunPluginTreeShakeImport
(pass) bundler > edgecase/TemplateStringIssue622 [120.15ms]
(pass) bundler > edgecase/ImportNamedFromExportStarCJS [411.84ms]
(pass) bundler > edgecase/NodeEnvDefaultUnset [218.20ms]
(pass) bundler > edgecase/NodeEnvDefaultDevelopment [291.06ms]
(pass) bundler > edgecase/NodeEnvDefaultProduction [222.65ms]
(todo) bundler > edgecase/NodeEnvOptionalChaining
(pass) bundler > edgecase/StarExternal [129.18ms]
(pass) bundler > edgecase/ImportNamespaceAndDefault [406.02ms]
(todo) bundler > edgecase/ExternalES6ConvertedToCommonJSSimplified
(pass) bundler > edgecase/I
... (truncated)

release without fix: 15 failed, 11 skipped
bun test v1.4.3-canary.1 (6a92015)

test/bundler/bundler_edgecase.test.ts:
(pass) bundler > edgecase/EmptyFile [15.00ms]
(pass) bundler > edgecase/EmptyCommonJSModule [12.54ms]
(pass) bundler > edgecase/NestedRedirectToABuiltin [12.31ms]
(pass) bundler > edgecase/ImportStarFunction [13.27ms]
(pass) bundler > edgecase/ImportStarSyntaxErrorBug [13.71ms]
(todo) bundler > edgecase/BunPluginTreeShakeImport
(pass) bundler > edgecase/TemplateStringIssue622 [5.19ms]
(pass) bundler > edgecase/ImportNamedFromExportStarCJS [13.17ms]
(pass) bundler > edgecase/NodeEnvDefaultUnset [7.14ms]
(pass) bundler > edgecase/NodeEnvDefaultDevelopment [5.25ms]
(pass) bundler > edgecase/NodeEnvDefaultProduction [5.40ms]
(todo) bundler > edgecase/NodeEnvOptionalChaining
(pass) bundler > edgecase/StarExternal [3.98ms]
(pass) bundler > edgecase/ImportNamespaceAndDefault [10.59ms]
(todo) bundler > edgecase/ExternalES6ConvertedToCommonJSSimplified
(pass) bundler > edgecase/ImportTrailingSlash [11.26ms]
(pass) bundler > edgecase/ValidLoaderSeenAsInvalid [5.31ms]
(pass) bundler > edgecase/InvalidLoaderSegfault [2.74ms]
(todo) bundler > edgecase/ScriptTagEscape
(pass) bundler > edgecase/JSONDefaul
... (truncated)
```

</details>

<details><summary>passes on PR (with fix)</summary>

```console
ASAN with fix: 11 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bundler/bundler_edgecase.test.ts test/bundler/transpiler/es-decorators.test.ts
bun test v1.4.3 (6a92015)

test/bundler/bundler_edgecase.test.ts:
(pass) bundler > edgecase/EmptyFile [779.81ms]
(pass) bundler > edgecase/EmptyCommonJSModule [592.14ms]
(pass) bundler > edgecase/NestedRedirectToABuiltin [669.46ms]
(pass) bundler > edgecase/ImportStarFunction [546.88ms]
(pass) bundler > edgecase/ImportStarSyntaxErrorBug [560.62ms]
(todo) bundler > edgecase/BunPluginTreeShakeImport
(pass) bundler > edgecase/TemplateStringIssue622 [182.33ms]
(pass) bundler > edgecase/ImportNamedFromExportStarCJS [674.20ms]
(pass) bundler > edgecase/NodeEnvDefaultUnset [323.97ms]
(pass) bundler > edgecase/NodeEnvDefaultDevelopment [311.38ms]
(pass) bundler > edgecase/NodeEnvDefaultProduction [321.34ms]
(todo) bundler > edgecase/NodeEnvOptionalChaining
(pass) bundler > edgecase/StarExternal [176.72ms]
(pass) bundler > edgecase/ImportNamespaceAndDefault [551.46ms]
(todo) bundler > edgecase/ExternalES6ConvertedToCommonJSSimplified
(pass) bundler > edgecase/I
... (truncated)

release with fix: 11 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     9e49008
  features     baseline

23 deps, 131 codegen, 1172 objects in 781ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1244] install /workspace/bun
bun install v1.4.3-canary.1 (6a92015)

Checked 22 installs across 61 packages (no changes) [7.00ms]
[2/1244] gen ErrorCode+*.h
[3/1244] install /workspace/bun/packages/bun-error
bun install v1.4.3-canary.1 (6a92015)

Checked 1 install across 2 packages (no changes) [4.00ms]
[4/1244] gen bindgenv2
[5/1244] install /workspace/bun/src/node-fallbacks
bun install v1.4.3-canary.1 (6a92015)

Checked 111 installs across 104 packages (no changes) [7.00ms]
[6/1244] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[7/1217] gen node-fallbacks/react-refresh.js
Bundled 1 module in 5ms

  react-refresh.js  4.81 KB  (entry point)

[8/1217] fetch zlib
[zlib] up to date
[9/1217] fetch tinycc
[tinycc] up to date
[10/1216] gen .bind.ts → GeneratedBindings.cpp
[11/1216] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build
... (truncated)
```

</details>

<details><summary>diff hotspot</summary>

```
src/js_parser/lower/lower_decorators.rs       | 118 ++++++---------------
 src/js_parser/p.rs                            |  77 ++++++--------
 src/jsc/RuntimeTranspilerCache.rs             |   3 +-
 test/bundler/bundler_edgecase.test.ts         | 105 +++++++++++++++++++
 test/bundler/transpiler/es-decorators.test.ts | 145 ++++++++++++++++++++++++++
 5 files changed, 319 insertions(+), 129 deletions(-)
```

</details>

**gate history** · 4 passed · 0 rejected · iteration 6

<details><summary>evidence per changed file</summary>

```
file                                           reads  edits  tests
src/js_parser/lower/lower_decorators.rs           22     26     25
src/js_parser/p.rs                                13      7     24
src/jsc/RuntimeTranspilerCache.rs                  2      2     25
test/bundler/bundler_edgecase.test.ts              2      4     19
test/bundler/transpiler/es-decorators.test.ts      1      3     16
```

</details>

<!-- robobun:evidence:end -->

---------

Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
Jarred-Sumner added a commit that referenced this pull request Sep 13, 2026
…0833)

### What was wrong

Lowering a class with standard decorators (or a bare `accessor`) moved
part of the class out of its body: decorated fields into the
constructor, decorated static fields and every static block to after the
class, decorator lists to before it. Whatever moved lost the context it
was written in, and whatever stayed ran in a different order:

```js
class A { @dec a = 1; b = this.a }          // b saw a === undefined: `b` stayed, `a` moved
class A extends B { @dec x = 1 }            // B's `set x` ran: `this.x = …` instead of a field
class A { @dec static x = this }            // `this` was the module's
class A extends B { @dec static x = super.y; static { super.z } }   // SyntaxError
const C = class Foo { @dec static s = Foo.f() }                     // ReferenceError: Foo
class A { accessor x; static #m; static { this.#m } }               // SyntaxError: #m (#31921)
class A { @dec m() {} #q = 1; d = this.#q }                         // SyntaxError: #q (#28118)
class A { static accessor a; static {} static accessor b }          // ran a, b, block
```

Every `#private` member was turned into a WeakMap as soon as one member
was decorated, so that moved code could still reach it, and the
hand-written walker that rewrites those accesses misses many forms
(`this.#x++`, `this.#x ??= v`, nested functions and classes, parameter
defaults).

### The fix

`lower_class_body` (`src/js_parser/lower/lower_decorators.rs`, mostly
rewritten: +912 −1889) no longer moves members. Fields, static blocks
and undecorated `#private` members stay native, so order, [[Define]]
semantics, `this`, `super`, `new.target` and private names are right by
construction instead of by rewriting. What the runtime helpers need is
added around them:

```js
class A extends (_base = B) {
  static {                                  // first static element
    _init = __decoratorStart(_base);
    __decorateElement(_init, 1, "m", _dec2, this);
    __decorateElement(_init, 5, "a", _dec, this);
    __decoratorMetadata(_init, this);
  }
  [(_dec = [dec], "a")] = __runInitializers(_init, 8, this, 1);      // still a field
  b = (__runInitializers(_init, 11, this), this.a);                  // a's extra initializers, then b
  [(_dec2 = [dec], "m")]() {}
}
```

- **Decorator lists** are evaluated in the computed key of their member.
That is their position in the spec: in order with computed keys, with
the outer `this` / `await` / `arguments`, inside the class's private
scope, before the class binding is initialized. A decorated `#private`
member has no key and borrows a neighbor's. A class with no usable key
at all (only `#private` members and a constructor) gets a `static
[(lists, "__decorators")]() {}` that the leading block deletes first.
- **A leading `static {}`** applies the decorators in the helper's
order, the class decorators last, so they run before any static field,
as the spec says.
- **Decorated fields** keep their place and wrap their initializer.
Decorated static fields are followed by a `static {}` for their extra
initializers.
- **Between two instance fields** there is nowhere to put a statement,
so accessor storage, lowered `#private` fields and extra initializers
ride in the initializer of the next instance field, or in the
constructor after the last one. An anonymous function or class in that
initializer is still named by its field: `h = (effects, { h: () => {}
}.h)` (the JIT sinks the object; 1e7 constructions measure the same with
and without it).
- **Only `#private` names with a decorated member become WeakMaps**,
because `__decorateElement` reaches them through `__privateGet`. The
access rewriter now also walks nested functions, classes, parameter and
destructuring defaults and object keys.
- An undecorated `accessor #x` is a plain `#x` field. A class with only
accessors gets no `_init` and no `Symbol.metadata`. A class expression
without class decorators is returned as is, so its context names it;
`(class { @dec x }).name` is `""`, not `_class$1`. The inner binding
that used to name such classes (and captured references to outer
variables of the same name) is gone.
- The extra initializers of class decorators still run after the class
is bound to its name, so they can refer to it.
- `LowerUsingDeclarationsContext::finalize` (`p.rs`) moves every `export
class` in front of the `try` it wraps a module with top-level `using`
in. A lowered class applies its decorators in a static block and
evaluates their lists in its keys, so an exported class with static
blocks or computed keys now stays in place as `var A = class A {}` plus
an export clause, like exported `const`s already do.
- The generic `ReplaceThis` / `ReplaceRef` tree rewriter, static block
extraction (and its arrow IIFE), `inner_class_ref` (`let _Foo = Foo`,
which nothing read) and the `prop_copy` / `class_copy` `ptr::read`s are
deleted. No new runtime helpers.
- `RuntimeTranspilerCache` version 31 → 32.

### Behaviour that changes on purpose

- Static fields and static blocks of a class with **class decorators**
now run after the class decorators (spec, tsc); before, undecorated ones
ran first.
- When a class decorator **returns a different class**, static members
stay on the class as written, and a static block that names its own
class sees that one too (methods and fields already did). `@wrap class A
{ @dec static accessor n = 1 }; A.n` now throws like the native `static
#n` on a subclass does; 1.4.1 installed decorated static members on what
the decorator returned.
- Decorated fields are defined, not assigned (the second example above).

### Tests

All in `test/bundler/transpiler/es-decorators.test.ts` (68 → 416 tests):

- "members stay where they are written": 18 single-purpose tests (order,
[[Define]], static order, `new.target`, `super`, decorator lists in key
position, class decorators before static fields, anonymous class names,
function names on fields that carry effects, classes with no usable key,
exported class next to a lowered `using`). 12 fail on 1.4.1; the other 6
pin what 1.4.1 already did right and the new placement has to keep.
- "ES decorators lowering matrix" (330 tests, built from the fixture of
the previous revision): member kind × static × private × class decorator
× derived, each cell a class with undecorated instance, private and
static fields and a static block around the decorated member, run as
`.js`, `.ts` and through `bun build`; plus fixture sections for the
reported repros (#28118, #31917, #31921, #31929) and for `super` /
`this` / private forms in static code, checked against what node prints
for the undecorated class. On 1.4.1 the fixture does not load.
- Unchanged and passing: `es-decorators-esbuild.test.ts` (esbuild's 147
decorator tests), `decorators.test.ts`, `decorator-metadata.test.ts`,
`bundler_edgecase.test.ts`, `transpiler.test.js`,
`ts-use-define-for-class-fields.test.ts`,
`lower-using-bun-target.test.ts`, `regression/issue/{27526,27575}`.
- I also compared 500 randomly generated classes (all member kinds,
static / private / computed, class and member decorators, `extends`,
explicit constructors) against TypeScript's ES2022 decorator emit under
node, as `bun run`, `bun build` and `bun build --minify`: no difference
except 8 cases where tsc's own output throws (anonymous class with a
static accessor).

### Cost

Release builds of the same tree with and without `src/`, `bun build` of
3000 decorated classes (1.1 MB), user-space instructions: 529.8 M →
520.0 M bundled, 437.3 M → 429.4 M with `--no-bundle`, 568.4 M → 552.9 M
with `--minify`. 3000 undecorated classes: 197.7 M → 197.6 M. The binary
is 28 KB smaller. The output for that fixture is 6% larger (static
blocks and computed keys).

### Not in this PR

- `this.#x++`, `this.#x += v`, destructuring and `for-of` targets,
`o?.#x` and tagged templates **on a decorated `#x`** still print invalid
or wrong code, and `super` in a decorated `#m()` is a SyntaxError: its
body still becomes a function expression. Both are unchanged;
undecorated privates no longer reach that path. Fixing them needs
`__privateWrapper` / `__superGet`-style runtime helpers.
- Effects after the last instance field go into the constructor after a
top-level `super(...)` statement, as before. `const r = super()` or `if
(x) super(); else super()` still puts them first. When an instance field
follows, they now ride in it and this works.
- With TypeScript's `useDefineForClassFields: false`, fields of a
decorated class are still defined, as on main.
- From the previous revision of this PR, dropped: the `__superGet` /
`__superSet` / `__superWrapper` / `__privateWrapper` helpers and the
rewriters that used them (no longer needed for static code), the parse
errors for `@x static {}` and parameter decorators in JS (#35544), and
`var {…} = require("bun:wrap")` for CommonJS-wrapped modules (a
decorated class in a `.cjs` file still fails to load; it is a module
wrapper bug, not a lowering one).
- A class expression evaluated more than once still shares its `var`
temporaries: #38933, #38904.

Fixes #28118
Fixes #31917
Fixes #31921

<!-- robobun:evidence:begin -->

---

**[human-review]** gate passed · iteration 10 · 6 files touched

<details><summary>fails on main (without fix)</summary>

```console
ASAN without fix: 343 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bundler/transpiler/es-decorators.test.ts
bun test v1.4.3 (6a92015)

test/bundler/transpiler/es-decorators.test.ts:
(pass) ES Decorators > class decorators > basic class decorator [310.54ms]
(pass) ES Decorators > class decorators > class decorator receives correct context [292.39ms]
(pass) ES Decorators > class decorators > class decorator can replace class [309.17ms]
(pass) ES Decorators > class decorators > multiple class decorators apply in reverse order [377.09ms]
(pass) ES Decorators > method decorators > instance method decorator [293.94ms]
(pass) ES Decorators > method decorators > static method decorator [302.05ms]
(pass) ES Decorators > method decorators > method decorator context has correct access [397.91ms]
(pass) ES Decorators > getter decorators > getter decorator [409.87ms]
(pass) ES Decorators > setter decorators > setter decorator [378.03ms]
(pass) ES Decorators > field decorators > field decorator receives undefined value [297.63ms]
(pass) ES Decorators > field decorators > multiple field decorators [297.73ms]
(
... (truncated)

release without fix: all passed
bun test v1.4.3-canary.1 (d83b736)

test/bundler/transpiler/es-decorators.test.ts:
(pass) ES Decorators > class decorators > basic class decorator [7.49ms]
(pass) ES Decorators > class decorators > class decorator receives correct context [9.75ms]
(pass) ES Decorators > class decorators > class decorator can replace class [6.42ms]
(pass) ES Decorators > class decorators > multiple class decorators apply in reverse order [7.29ms]
(pass) ES Decorators > method decorators > instance method decorator [6.36ms]
(pass) ES Decorators > method decorators > static method decorator [6.63ms]
(pass) ES Decorators > method decorators > method decorator context has correct access [6.29ms]
(pass) ES Decorators > getter decorators > getter decorator [6.35ms]
(pass) ES Decorators > setter decorators > setter decorator [6.33ms]
(pass) ES Decorators > field decorators > field decorator receives undefined value [6.88ms]
(pass) ES Decorators > field decorators > multiple field decorators [6.84ms]
(pass) ES Decorators > field decorators > static field decorator [7.26ms]
(pass) ES Decorators > non-ASCII string-literal keys > Bun.Transpiler output preserves the key [0.88ms]
(pass) ES Deco
... (truncated)
```

</details>

<details><summary>passes on PR (with fix)</summary>

```console
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bundler/transpiler/es-decorators.test.ts
bun test v1.4.3 (6a92015)

test/bundler/transpiler/es-decorators.test.ts:
(pass) ES Decorators > class decorators > basic class decorator [338.17ms]
(pass) ES Decorators > class decorators > class decorator receives correct context [308.25ms]
(pass) ES Decorators > class decorators > class decorator can replace class [396.76ms]
(pass) ES Decorators > class decorators > multiple class decorators apply in reverse order [316.26ms]
(pass) ES Decorators > method decorators > instance method decorator [391.13ms]
(pass) ES Decorators > method decorators > static method decorator [393.40ms]
(pass) ES Decorators > method decorators > method decorator context has correct access [317.29ms]
(pass) ES Decorators > getter decorators > getter decorator [334.81ms]
(pass) ES Decorators > setter decorators > setter decorator [310.19ms]
(pass) ES Decorators > field decorators > field decorator receives undefined value [312.04ms]
(pass) ES Decorators > field decorators > multiple field decorators [303.38ms]
(
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 625ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/5] gen generated_host_exports.rs
generated_host_exports.rs: 122 exports (host=5, lazy=10, generic=107, rust=0); 243 extern-C blocks audited
[1/5] cargo bun_runtime → libbun_runtime.a
�[1m�[92m   Compiling�[0m bun_js_parser v0.0.0 (/workspace/bun/src/js_parser)
�[1m�[92m   Compiling�[0m bun_resolver v0.0.0 (/workspace/bun/src/resolver)
�[1m�[92m   Compiling�[0m bun_ini v0.0.0 (/workspace/bun/src/ini)
�[1m�[92m   Compiling�[0m bun_bundler v0.0.0 (/workspace/bun/src/bundler)
�[1m�[92m   Compiling�[0m bun_router v0.0.0 (/workspace/bun/src/router)
�[1m�[92m   Compiling�[0m bun_standalone_graph v0.0.0 (/workspace/bun/src/standalone_graph)
�[1m�[92m   Compiling�[0m bun_transpiler v0.0.0 (/workspace/bun/src/transpiler)
�[1m�[92m   Compiling�[0m bun_bunfig v0.0.0 (/workspace/bun/src/bunfig)
�[1m�[92m   Compiling�[0m bun_install v0.0.0 (/workspace/bun/src/install)
�[1m�[92m   Compiling�[0m bun_jsc v0.0.0 (/workspace/bun/src/jsc)
�[1m�[92m   Compiling�[0m bun_js_parser_jsc v0.0.0 (/workspace/bun/src/js_parser_js
... (truncated)
```

</details>

<details><summary>diff hotspot</summary>

```
src/js_parser/lower/lower_decorators.rs       | 2852 +++++++++----------------
 src/js_parser/p.rs                            |   39 +-
 src/js_parser/visit/mod.rs                    |    2 +-
 src/js_parser/visit/visit_expr.rs             |    2 +-
 src/jsc/RuntimeTranspilerCache.rs             |    3 +-
 test/bundler/transpiler/es-decorators.test.ts | 1151 ++++++++++
 6 files changed, 2150 insertions(+), 1899 deletions(-)
```

</details>

**gate history** · 7 passed · 1 rejected · iteration 10

<details><summary>evidence per changed file</summary>

```
file                                           reads  edits  tests
src/js_parser/lower/lower_decorators.rs           44     37     33
src/js_parser/p.rs                                 6      3     29
src/js_parser/visit/mod.rs                         2      2     29
src/js_parser/visit/visit_expr.rs                  1      0     28
src/jsc/RuntimeTranspilerCache.rs                  1      1     28
test/bundler/transpiler/es-decorators.test.ts      3      2     27
```

</details>

<!-- robobun:evidence:end -->

---------

Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
usrbinkat pushed a commit to usrbinkat/bun that referenced this pull request Sep 15, 2026
…en-sh#40833)

### What was wrong

Lowering a class with standard decorators (or a bare `accessor`) moved
part of the class out of its body: decorated fields into the
constructor, decorated static fields and every static block to after the
class, decorator lists to before it. Whatever moved lost the context it
was written in, and whatever stayed ran in a different order:

```js
class A { @dec a = 1; b = this.a }          // b saw a === undefined: `b` stayed, `a` moved
class A extends B { @dec x = 1 }            // B's `set x` ran: `this.x = …` instead of a field
class A { @dec static x = this }            // `this` was the module's
class A extends B { @dec static x = super.y; static { super.z } }   // SyntaxError
const C = class Foo { @dec static s = Foo.f() }                     // ReferenceError: Foo
class A { accessor x; static #m; static { this.#m } }               // SyntaxError: #m (oven-sh#31921)
class A { @dec m() {} #q = 1; d = this.#q }                         // SyntaxError: #q (oven-sh#28118)
class A { static accessor a; static {} static accessor b }          // ran a, b, block
```

Every `#private` member was turned into a WeakMap as soon as one member
was decorated, so that moved code could still reach it, and the
hand-written walker that rewrites those accesses misses many forms
(`this.#x++`, `this.#x ??= v`, nested functions and classes, parameter
defaults).

### The fix

`lower_class_body` (`src/js_parser/lower/lower_decorators.rs`, mostly
rewritten: +912 −1889) no longer moves members. Fields, static blocks
and undecorated `#private` members stay native, so order, [[Define]]
semantics, `this`, `super`, `new.target` and private names are right by
construction instead of by rewriting. What the runtime helpers need is
added around them:

```js
class A extends (_base = B) {
  static {                                  // first static element
    _init = __decoratorStart(_base);
    __decorateElement(_init, 1, "m", _dec2, this);
    __decorateElement(_init, 5, "a", _dec, this);
    __decoratorMetadata(_init, this);
  }
  [(_dec = [dec], "a")] = __runInitializers(_init, 8, this, 1);      // still a field
  b = (__runInitializers(_init, 11, this), this.a);                  // a's extra initializers, then b
  [(_dec2 = [dec], "m")]() {}
}
```

- **Decorator lists** are evaluated in the computed key of their member.
That is their position in the spec: in order with computed keys, with
the outer `this` / `await` / `arguments`, inside the class's private
scope, before the class binding is initialized. A decorated `#private`
member has no key and borrows a neighbor's. A class with no usable key
at all (only `#private` members and a constructor) gets a `static
[(lists, "__decorators")]() {}` that the leading block deletes first.
- **A leading `static {}`** applies the decorators in the helper's
order, the class decorators last, so they run before any static field,
as the spec says.
- **Decorated fields** keep their place and wrap their initializer.
Decorated static fields are followed by a `static {}` for their extra
initializers.
- **Between two instance fields** there is nowhere to put a statement,
so accessor storage, lowered `#private` fields and extra initializers
ride in the initializer of the next instance field, or in the
constructor after the last one. An anonymous function or class in that
initializer is still named by its field: `h = (effects, { h: () => {}
}.h)` (the JIT sinks the object; 1e7 constructions measure the same with
and without it).
- **Only `#private` names with a decorated member become WeakMaps**,
because `__decorateElement` reaches them through `__privateGet`. The
access rewriter now also walks nested functions, classes, parameter and
destructuring defaults and object keys.
- An undecorated `accessor #x` is a plain `#x` field. A class with only
accessors gets no `_init` and no `Symbol.metadata`. A class expression
without class decorators is returned as is, so its context names it;
`(class { @dec x }).name` is `""`, not `_class$1`. The inner binding
that used to name such classes (and captured references to outer
variables of the same name) is gone.
- The extra initializers of class decorators still run after the class
is bound to its name, so they can refer to it.
- `LowerUsingDeclarationsContext::finalize` (`p.rs`) moves every `export
class` in front of the `try` it wraps a module with top-level `using`
in. A lowered class applies its decorators in a static block and
evaluates their lists in its keys, so an exported class with static
blocks or computed keys now stays in place as `var A = class A {}` plus
an export clause, like exported `const`s already do.
- The generic `ReplaceThis` / `ReplaceRef` tree rewriter, static block
extraction (and its arrow IIFE), `inner_class_ref` (`let _Foo = Foo`,
which nothing read) and the `prop_copy` / `class_copy` `ptr::read`s are
deleted. No new runtime helpers.
- `RuntimeTranspilerCache` version 31 → 32.

### Behaviour that changes on purpose

- Static fields and static blocks of a class with **class decorators**
now run after the class decorators (spec, tsc); before, undecorated ones
ran first.
- When a class decorator **returns a different class**, static members
stay on the class as written, and a static block that names its own
class sees that one too (methods and fields already did). `@wrap class A
{ @dec static accessor n = 1 }; A.n` now throws like the native `static
#n` on a subclass does; 1.4.1 installed decorated static members on what
the decorator returned.
- Decorated fields are defined, not assigned (the second example above).

### Tests

All in `test/bundler/transpiler/es-decorators.test.ts` (68 → 416 tests):

- "members stay where they are written": 18 single-purpose tests (order,
[[Define]], static order, `new.target`, `super`, decorator lists in key
position, class decorators before static fields, anonymous class names,
function names on fields that carry effects, classes with no usable key,
exported class next to a lowered `using`). 12 fail on 1.4.1; the other 6
pin what 1.4.1 already did right and the new placement has to keep.
- "ES decorators lowering matrix" (330 tests, built from the fixture of
the previous revision): member kind × static × private × class decorator
× derived, each cell a class with undecorated instance, private and
static fields and a static block around the decorated member, run as
`.js`, `.ts` and through `bun build`; plus fixture sections for the
reported repros (oven-sh#28118, oven-sh#31917, oven-sh#31921, oven-sh#31929) and for `super` /
`this` / private forms in static code, checked against what node prints
for the undecorated class. On 1.4.1 the fixture does not load.
- Unchanged and passing: `es-decorators-esbuild.test.ts` (esbuild's 147
decorator tests), `decorators.test.ts`, `decorator-metadata.test.ts`,
`bundler_edgecase.test.ts`, `transpiler.test.js`,
`ts-use-define-for-class-fields.test.ts`,
`lower-using-bun-target.test.ts`, `regression/issue/{27526,27575}`.
- I also compared 500 randomly generated classes (all member kinds,
static / private / computed, class and member decorators, `extends`,
explicit constructors) against TypeScript's ES2022 decorator emit under
node, as `bun run`, `bun build` and `bun build --minify`: no difference
except 8 cases where tsc's own output throws (anonymous class with a
static accessor).

### Cost

Release builds of the same tree with and without `src/`, `bun build` of
3000 decorated classes (1.1 MB), user-space instructions: 529.8 M →
520.0 M bundled, 437.3 M → 429.4 M with `--no-bundle`, 568.4 M → 552.9 M
with `--minify`. 3000 undecorated classes: 197.7 M → 197.6 M. The binary
is 28 KB smaller. The output for that fixture is 6% larger (static
blocks and computed keys).

### Not in this PR

- `this.#x++`, `this.#x += v`, destructuring and `for-of` targets,
`o?.#x` and tagged templates **on a decorated `#x`** still print invalid
or wrong code, and `super` in a decorated `#m()` is a SyntaxError: its
body still becomes a function expression. Both are unchanged;
undecorated privates no longer reach that path. Fixing them needs
`__privateWrapper` / `__superGet`-style runtime helpers.
- Effects after the last instance field go into the constructor after a
top-level `super(...)` statement, as before. `const r = super()` or `if
(x) super(); else super()` still puts them first. When an instance field
follows, they now ride in it and this works.
- With TypeScript's `useDefineForClassFields: false`, fields of a
decorated class are still defined, as on main.
- From the previous revision of this PR, dropped: the `__superGet` /
`__superSet` / `__superWrapper` / `__privateWrapper` helpers and the
rewriters that used them (no longer needed for static code), the parse
errors for `@x static {}` and parameter decorators in JS (oven-sh#35544), and
`var {…} = require("bun:wrap")` for CommonJS-wrapped modules (a
decorated class in a `.cjs` file still fails to load; it is a module
wrapper bug, not a lowering one).
- A class expression evaluated more than once still shares its `var`
temporaries: oven-sh#38933, oven-sh#38904.

Fixes oven-sh#28118
Fixes oven-sh#31917
Fixes oven-sh#31921

<!-- robobun:evidence:begin -->

---

**[human-review]** gate passed · iteration 10 · 6 files touched

<details><summary>fails on main (without fix)</summary>

```console
ASAN without fix: 343 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bundler/transpiler/es-decorators.test.ts
bun test v1.4.3 (6a92015)

test/bundler/transpiler/es-decorators.test.ts:
(pass) ES Decorators > class decorators > basic class decorator [310.54ms]
(pass) ES Decorators > class decorators > class decorator receives correct context [292.39ms]
(pass) ES Decorators > class decorators > class decorator can replace class [309.17ms]
(pass) ES Decorators > class decorators > multiple class decorators apply in reverse order [377.09ms]
(pass) ES Decorators > method decorators > instance method decorator [293.94ms]
(pass) ES Decorators > method decorators > static method decorator [302.05ms]
(pass) ES Decorators > method decorators > method decorator context has correct access [397.91ms]
(pass) ES Decorators > getter decorators > getter decorator [409.87ms]
(pass) ES Decorators > setter decorators > setter decorator [378.03ms]
(pass) ES Decorators > field decorators > field decorator receives undefined value [297.63ms]
(pass) ES Decorators > field decorators > multiple field decorators [297.73ms]
(
... (truncated)

release without fix: all passed
bun test v1.4.3-canary.1 (d83b736)

test/bundler/transpiler/es-decorators.test.ts:
(pass) ES Decorators > class decorators > basic class decorator [7.49ms]
(pass) ES Decorators > class decorators > class decorator receives correct context [9.75ms]
(pass) ES Decorators > class decorators > class decorator can replace class [6.42ms]
(pass) ES Decorators > class decorators > multiple class decorators apply in reverse order [7.29ms]
(pass) ES Decorators > method decorators > instance method decorator [6.36ms]
(pass) ES Decorators > method decorators > static method decorator [6.63ms]
(pass) ES Decorators > method decorators > method decorator context has correct access [6.29ms]
(pass) ES Decorators > getter decorators > getter decorator [6.35ms]
(pass) ES Decorators > setter decorators > setter decorator [6.33ms]
(pass) ES Decorators > field decorators > field decorator receives undefined value [6.88ms]
(pass) ES Decorators > field decorators > multiple field decorators [6.84ms]
(pass) ES Decorators > field decorators > static field decorator [7.26ms]
(pass) ES Decorators > non-ASCII string-literal keys > Bun.Transpiler output preserves the key [0.88ms]
(pass) ES Deco
... (truncated)
```

</details>

<details><summary>passes on PR (with fix)</summary>

```console
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bundler/transpiler/es-decorators.test.ts
bun test v1.4.3 (6a92015)

test/bundler/transpiler/es-decorators.test.ts:
(pass) ES Decorators > class decorators > basic class decorator [338.17ms]
(pass) ES Decorators > class decorators > class decorator receives correct context [308.25ms]
(pass) ES Decorators > class decorators > class decorator can replace class [396.76ms]
(pass) ES Decorators > class decorators > multiple class decorators apply in reverse order [316.26ms]
(pass) ES Decorators > method decorators > instance method decorator [391.13ms]
(pass) ES Decorators > method decorators > static method decorator [393.40ms]
(pass) ES Decorators > method decorators > method decorator context has correct access [317.29ms]
(pass) ES Decorators > getter decorators > getter decorator [334.81ms]
(pass) ES Decorators > setter decorators > setter decorator [310.19ms]
(pass) ES Decorators > field decorators > field decorator receives undefined value [312.04ms]
(pass) ES Decorators > field decorators > multiple field decorators [303.38ms]
(
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 625ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/5] gen generated_host_exports.rs
generated_host_exports.rs: 122 exports (host=5, lazy=10, generic=107, rust=0); 243 extern-C blocks audited
[1/5] cargo bun_runtime → libbun_runtime.a
�[1m�[92m   Compiling�[0m bun_js_parser v0.0.0 (/workspace/bun/src/js_parser)
�[1m�[92m   Compiling�[0m bun_resolver v0.0.0 (/workspace/bun/src/resolver)
�[1m�[92m   Compiling�[0m bun_ini v0.0.0 (/workspace/bun/src/ini)
�[1m�[92m   Compiling�[0m bun_bundler v0.0.0 (/workspace/bun/src/bundler)
�[1m�[92m   Compiling�[0m bun_router v0.0.0 (/workspace/bun/src/router)
�[1m�[92m   Compiling�[0m bun_standalone_graph v0.0.0 (/workspace/bun/src/standalone_graph)
�[1m�[92m   Compiling�[0m bun_transpiler v0.0.0 (/workspace/bun/src/transpiler)
�[1m�[92m   Compiling�[0m bun_bunfig v0.0.0 (/workspace/bun/src/bunfig)
�[1m�[92m   Compiling�[0m bun_install v0.0.0 (/workspace/bun/src/install)
�[1m�[92m   Compiling�[0m bun_jsc v0.0.0 (/workspace/bun/src/jsc)
�[1m�[92m   Compiling�[0m bun_js_parser_jsc v0.0.0 (/workspace/bun/src/js_parser_js
... (truncated)
```

</details>

<details><summary>diff hotspot</summary>

```
src/js_parser/lower/lower_decorators.rs       | 2852 +++++++++----------------
 src/js_parser/p.rs                            |   39 +-
 src/js_parser/visit/mod.rs                    |    2 +-
 src/js_parser/visit/visit_expr.rs             |    2 +-
 src/jsc/RuntimeTranspilerCache.rs             |    3 +-
 test/bundler/transpiler/es-decorators.test.ts | 1151 ++++++++++
 6 files changed, 2150 insertions(+), 1899 deletions(-)
```

</details>

**gate history** · 7 passed · 1 rejected · iteration 10

<details><summary>evidence per changed file</summary>

```
file                                           reads  edits  tests
src/js_parser/lower/lower_decorators.rs           44     37     33
src/js_parser/p.rs                                 6      3     29
src/js_parser/visit/mod.rs                         2      2     29
src/js_parser/visit/visit_expr.rs                  1      0     28
src/jsc/RuntimeTranspilerCache.rs                  1      1     28
test/bundler/transpiler/es-decorators.test.ts      3      2     27
```

</details>

<!-- robobun:evidence:end -->

---------

Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
dylan-conway added a commit that referenced this pull request Oct 21, 2026
…the decorated class (#45435)

Fixes #44915

### Problem
- With standard (TC39) decorators, a class decorator that returns a new
class is invisible inside the class body. `static self = () => Model`,
`@capture(() => Model)`, `static { Model.x }`, methods and instance
fields all see the original class. Babel, tsc and bun 1.4.2 (for
decorator arguments) give the decorated class. The spec initializes the
inner class binding with the decorated class, after the class decorators
run and before the static fields.
- The lowering keeps the class statement and applies the class
decorators from a leading `static {}`
(`src/js_parser/lower/lower_decorators.rs:721`). Every `Model` in the
body binds to the class statement's own inner binding. Only the outer
`Model` is reassigned.

### Fix
- `visit_class` (`src/js_parser/visit/mod.rs:1034`) binds the class name
inside a class-decorated body to a fresh symbol `_Model`. Every body
reference resolves through that one scope member, so no tree walk is
needed.
- `lower_class_body` uses that symbol as the target of
`__decorateElement` and declares it with `let _Model;` in front of the
class. The leading static block assigns the decorated class to it before
any static field runs. A `let` gives each evaluation in a loop its own
binding.
- A write to the class name inside the body (`C = x`, `C++`) still
targets the class's own binding, so it throws a `TypeError` as it does
natively. The name of a class expression stays in the renamer's view, so
a bundle keeps it apart from an import of the same name.
- Classes without class decorators (none, or member decorators only)
emit byte-identical output (checked with `cmp` on 400 classes). The
runtime transpiler cache version is 41 to 42.
- Verified: `test/regression/issue/44915.test.ts` (fails on 1.4.3), five
new tests in `test/bundler/transpiler/es-decorators.test.ts`. Also
`es-decorators-esbuild.test.ts`, `decorators.test.ts`,
`decorator-metadata.test.ts`, `ts-use-define-for-class-fields.test.ts`,
`transpiler.test.js`, `bundler_edgecase.test.ts`.

### Background
- `visit_class` pushes a `ClassName` scope with one member for the class
name. The body's identifiers resolve through it. Before this PR the
member was the class name symbol itself.
- #40833 wrote that a static block sees the class as written when a
decorator replaces it. That was a description of the lowering, not a
spec reading. This PR reverses it.
- Considered the tree walk in #44995: it keeps the scope member wrong
and rewrites every body reference afterwards, through the `#private`
rewriter. #38731 had this PR's mechanism against the old lowering and
went stale. This PR ports it and drops its private-static carve-out.
- #45351 touches the same functions and also bumps the cache version.
Whichever lands second rebases and takes the next one.

### Downsides
- `C.#s()` in a class that a decorator replaced now throws a
`TypeError`: `C` is the replacement, which has no brand for `#s`. The
spec throws here too. 1.4.3 returned the member because `C` was the
original class. `this.#s()` in static code still works (pinned in the
test).
- A read of the class name before the class decorators run (a computed
key, an eager element decorator argument) gives `undefined` instead of a
`ReferenceError`. tsc does the same. The esbuild suite test that asserts
the `ReferenceError` is moved to `todoTests`.
- One `let` declaration per class-decorated class, 10 bytes in the
output of the issue repro. `size_of::<G::Class>()` 88 to 96 bytes. A
class expression in a field initializer or a parameter default has no
statement list of its own, so it is printed as `((_C) => …)()` and the
binding is the arrow's parameter: one per evaluation. Neither position
allows `await` or `yield`. TypeScript `experimentalDecorators` classes
have the same inner-name defect and are not changed here.

<details><summary>Notes</summary>

Lowered output of the issue repro after this PR:

```js
var _init$2, _dec$3, _dec$4;
let _Model$1;
_dec$4 = [replace];
class Model {
  static {
    _init$2 = __decoratorStart(undefined);
    __decorateElement(_init$2, 5, "field", _dec$3, this);
    _Model$1 = __decorateElement(_init$2, 0, "Model", _dec$4, this);
  }
  static self = () => _Model$1;
  [(_dec$3 = [capture(() => _Model$1)], "field")] = __runInitializers(_init$2, 8, this, 1);
}
Model = _Model$1;
__runInitializers(_init$2, 1, _Model$1);
```

Probes on the debug build: class statement, named class expression,
`export class`, `export default class`, a class in a loop body, a class
expression returned from a function, `bun build --target=browser` of the
exported forms. All five body positions print `true` where 1.4.3 prints
`false`. tsc (ES2022 emit) prints `true` for the same five. esbuild
prints `true` for the decorator argument and the static field of the
issue repro, and `false` for methods and static blocks (its body
references resolve to the inner name of the class expression it emits).

Static private members: tsc keeps `C.#s()` working because it lowers
private statics of a decorated class and brand-checks them against the
decorated class. esbuild and bun 1.4.3 keep it working only because the
body name is the original class, which is the bug.

Self-reviewed: 9 concerns raised, 8 addressed. Review bots found two
more, both fixed: the renamer lost the name of a decorated class
expression, and a write to the class name inside the body reached the
inner binding. Rejected: declaring the inner binding through
`temp_refs_to_declare` as a hoisted `var`. The `_init` and `_dec`
temporaries are shared across loop iterations (#38933, #38904), which is
a known defect. The inner binding is user-observable across evaluations,
so it is a `let` next to the class.

`test/bundler/bundler_edgecase.test.ts` `DeepImportDiamondDAG` fails on
main in this container as well, without this diff.
</details>

<!-- robobun:evidence:begin -->

---

**[human-review]** gate passed · iteration 0 · 11 files touched

<details><summary>fails on main (without fix)</summary>

```console
ASAN without fix: BUILD FAILED (no junit output)
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bundler/transpiler/es-decorators-esbuild.test.ts test/bundler/transpiler/es-decorators.test.ts "test/regression/issue/44915.test.ts"
error: Source list 'bindgenV2' matched nothing
  at: file:///workspace/bun/scripts/glob-sources.ts
  hint: Patterns: src/**/*.bindv2.ts
error: script "bd" exited with code 1
__F:-1:S:0

release without fix: 6 failed, 1 skipped
bun test v1.4.3-canary.1 (367d939)

test/regression/issue/44915.test.ts:
34 |     cwd: String(dir),
35 |     stderr: "pipe",
36 |   });
37 |   const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
38 |   expect(stderr).toBe("");
39 |   expect(stdout).toBe("decorator argument: true\n");
                      ^
error: expect(received).toBe(expected)

- "decorator argument: true
+ "decorator argument: false
  "

- Expected  - 1
+ Received  + 1

      at <anonymous> (/workspace/bun/test/regression/issue/44915.test.ts:39:18)
(fail) a member decorator argument sees the class its class decorator returns [29.21ms]

test/bundler/transpiler/es-decorators.test.ts:
(pass) ES Decorators > class decorators > basic class decorator [13.30ms]
(pass) ES Decorators > class decorators > class decorator receives correct context [13.27ms]
(pass) ES Decorators > class decorators > class decorator can replace class [211.56ms]
(pass) ES Decorators > class decorators > multiple class decorators apply in reverse order [98.23ms]
(pass) ES Decorators > method decorators > instance method decorator [46.37ms]
(pass) ES Decorators > method d
... (truncated)
```

</details>

<details><summary>passes on PR (with fix)</summary>

```console
ASAN with fix: 1 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bundler/transpiler/es-decorators-esbuild.test.ts test/bundler/transpiler/es-decorators.test.ts "test/regression/issue/44915.test.ts"
bun test v1.4.4 (367d939)

test/regression/issue/44915.test.ts:
(pass) a member decorator argument sees the class its class decorator returns [453.08ms]

test/bundler/transpiler/es-decorators.test.ts:
(pass) ES Decorators > class decorators > basic class decorator [403.47ms]
(pass) ES Decorators > class decorators > class decorator receives correct context [325.72ms]
(pass) ES Decorators > class decorators > class decorator can replace class [352.36ms]
(pass) ES Decorators > class decorators > multiple class decorators apply in reverse order [326.70ms]
(pass) ES Decorators > method decorators > instance method decorator [352.62ms]
(pass) ES Decorators > method decorators > static method decorator [453.13ms]
(pass) ES Decorators > method decorators > method decorator context has correct access [319.16ms]
(pass) ES Decorators > getter decorators > getter decorator [299.12ms]
(pass) ES Decorators > set
... (truncated)

release with fix: 1 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     419a9db
  features     lto, baseline

23 deps, 146 codegen, 1176 objects in 3551ms

ninja: Entering directory `/workspace/bun/build/release'
[1/4] fetch lolhtml
[lolhtml] up to date
[2/4] fetch rust-argon2
[rust-argon2] up to date
[2/4] cargo plan → /workspace/bun/build/release/rust-target/plan.json
264 units: 192 lib, 16 proc-macro (host), 19 custom-build (host), 15 run custom-build, 17 lib (host), 4 run custom-build (host), 1 rlib
[3/4] reconfigure
[1/1529] mkdir stamps
[2/1529] mkdir codegen
[3/1529] gen bake.{client,server,error}.js
-> bake.client.js, bake.server.js, bake.error.js
[4/1529] install /workspace/bun
bun install v1.4.3-canary.1 (367d939)

Checked 22 installs across 44 packages (no changes) [4.59s]
[5/1529] install /workspace/bun/packages/bun-error
bun install v1.4.3-canary.1 (367d939)

Checked 1 install across 2 packages (no changes) [250.00ms]
[6/1529] install /workspace/bun/src/node-fallbacks
bun install v1.4.3-canary.1 (367d939)

Checked 111 installs across
... (truncated)
```

</details>

<details><summary>diff hotspot</summary>

```
src/ast/expr.rs                                    |   1 +
 src/ast/g.rs                                       |   5 +
 src/js_parser/lower/lower_decorators.rs            |  45 +++++--
 src/js_parser/p.rs                                 |   6 +
 src/js_parser/parse/mod.rs                         |   1 +
 src/js_parser/visit/mod.rs                         |  27 ++++-
 src/js_parser/visit/visit_expr.rs                  |  12 ++
 src/jsc/RuntimeTranspilerCache.rs                  |   4 +-
 .../transpiler/es-decorators-esbuild.test.ts       |   9 +-
 test/bundler/transpiler/es-decorators.test.ts      | 132 ++++++++++++++++++++-
 test/regression/issue/44915.test.ts                |  41 +++++++
 11 files changed, 270 insertions(+), 13 deletions(-)
```

</details>

**gate history** · 1 passed · 0 rejected · iteration 0

<details><summary>evidence per changed file</summary>

```
file                                                   reads  edits  tests
src/ast/expr.rs                                            0      0     28
src/ast/g.rs                                               0      0     28
src/js_parser/lower/lower_decorators.rs                    2      0     28
src/js_parser/p.rs                                         0      0     28
src/js_parser/parse/mod.rs                                 0      0     28
src/js_parser/visit/mod.rs                                 2      1     28
src/js_parser/visit/visit_expr.rs                          0      0     28
src/jsc/RuntimeTranspilerCache.rs                          0      0     28
test/bundler/transpiler/es-decorators-esbuild.test.ts      0      0     11
test/bundler/transpiler/es-decorators.test.ts              1      2     20
test/regression/issue/44915.test.ts                        0      1      7
```

</details>

**root cause** · written by the author bot

Bun's lowering of standard (TC39) class decorators bound the class name
inside the class body to the original, undecorated class, so arrow
functions in decorator arguments and static field initializers that
referenced the class by name kept pointing at the pre-decoration value
even after a class decorator returned a replacement. The fix introduces
a dedicated inner binding for the class name, declared as a `let` that
the lowering assigns once decorators have run, and resolves references
within the class body (including decorator arguments and static
initializers) to that binding so they obs…

<!-- robobun:evidence:end -->

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Dylan Conway <dylan.conway567@gmail.com>

This branch has not been deployed

No deployments
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