Skip to content

js_parser: declare decorator lowering temporaries per iteration inside loops (stacked on #38734) - #38933

Open
robobun wants to merge 1 commit into
farm/55355bb8/decorator-temp-namesfrom
farm/3560fb67/decorator-temps-per-iteration
Open

robobun wants to merge 1 commit into
farm/55355bb8/decorator-temp-namesfrom
farm/3560fb67/decorator-temps-per-iteration

Conversation

@robobun

@robobun robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #38734 (base branch farm/55355bb8/decorator-temp-names); the diff here is only the last commit.

Problem

  • The standard decorator / accessor lowering declares a class's temporaries with var: _init, _dec, _computedKey, _base, one _<name> WeakMap or WeakSet per lowered member, and _class for a class expression (src/js_parser/lower/lower_decorators.rs, temp_decl and the hoisted declaration in Phase 8).
  • Inside a loop body that is one binding for every iteration, but the class an iteration creates keeps using the temporaries after the next iteration has reassigned them: its constructor reads _init, _computedKey and the WeakMaps on every new, and the getters/setters __decorateElement installed hold the WeakMap of the iteration that created the class.
  • So every class a loop creates ends up on the last iteration's data: instances run the wrong addInitializer callbacks and get the wrong computed key, and accessor fields (decorated or not) and #private members of lowered classes throw TypeError: Cannot read from private field. Same under bun run, bun build --no-bundle and bun build.
let n = 0;
function dec(value, ctx) { const id = ++n; ctx.addInitializer(function () { this.id = id; }); }
const classes = [];
for (let i = 0; i < 2; i++) { class K { @dec m() {} } classes.push(K); }
console.log(new classes[0]().id, new classes[1]().id); // "2 2", expected "1 2"

const objs = [];
for (let i = 0; i < 2; i++) { class K { accessor x = i; } objs.push(new K()); }
console.log(objs[0].x); // TypeError: Cannot read from private field
  • tsc gives every evaluation of the class its own temporaries. esbuild 0.25.12 has the same bug.

Fix

  • temp_decl_kind: when the class is lowered inside a loop body (fn_or_arrow_data_visit.is_inside_loop, set by visit_loop_body for all five loop statements and reset for nested functions, arrows and static blocks) the temporaries are declared with let instead of var. That covers statement mode (the declarations sit next to the class), expression mode (the declaration goes to the top of the innermost statement list, which is inside the loop body; a single-statement body is turned into a block) and the receiver-capture temporaries declared with them. var_decl/var_decl2 are renamed temp_decl/temp_decl2 because they no longer always emit var; the capture temporaries declare_capture_temps_in_fn_body puts at the top of method bodies stay var.
  • Correct because a let in the loop body is a new binding on every iteration, which is what every evaluation of the class gets in the spec. Every use of a temporary is emitted after its declaration in the same list or sits in the class's own code, which runs later, so no use can reach the binding before it is initialized.
  • Outside loops the statement list runs once per function invocation, so var and let would bind the same; var is kept there so that output stays unchanged and stays hoisted (a class expression in the parameter default of a hoisted function can run before the declaration statement, e.g. through an import cycle).
  • Unlike var, an initialized let is a candidate for the single-use inlining in visit_stmts (--minify-syntax, which the runtime transpiler has on), and that pass trusts use_count_estimate. The lowering created some references to temporaries as bare E::Identifiers, which do not count: the _base argument of __decoratorStart, _init in the __decorateElement calls, the storage WeakMap handed to __privateAdd for decorated private fields and accessors, and the second reference to a _computedKey. These now go through use_ref, and temp_decl_kind documents the rule. The only shape where the miscount was adjacent to a consumer is @dec class K extends Base {} (no members) in a loop: let _base = Base is directly followed by let _init = __decoratorStart(_base), so the first version of this PR inlined _base, dropped its declaration and left class K extends _base throwing ReferenceError: _base is not defined. The other corrections are the same defect without a consumer next to it today.
  • Why this needs js_parser: give standard decorator lowering temporaries unique names #38734: without a renamer (bun run, bun build --no-bundle) the temporaries print under the names they ask for, so with let two classes in one loop body (or accessor x next to #x) would go from silently sharing a var to a duplicate declaration SyntaxError. js_parser: give standard decorator lowering temporaries unique names #38734 gives them file-unique names. It also registers each temporary with the var-hoisting scope; that stays valid for a let in a block of that scope (a name unique across the scope is unique inside the block too, and the let _<Class> binding already worked this way).
  • Not changed: a class expression in a loop head (while (f(class { ... })), for (;; x = class { ... })) is evaluated repeatedly without a statement list of its own per evaluation and would need a per-evaluation wrapper; parameter defaults and field initializers, which have the same problem, are js_parser: declare decorator lowering temporaries per evaluation in parameter defaults and field initializers #38904.
  • Verified:
    • test/bundler/transpiler/es-decorators.test.ts, "lowering temporaries of a class created in a loop" (17 tests): class statements (initializers, accessor storage, private members, two classes in one body, one class with every kind of lowered member including a capture temporary, class decorator + extends clause only) and class expressions (instances created after and during the loop, the five loop statements with block and single-statement bodies, a nested block, class decorator + extends + computed key, switch cases, a static field of a class created in the loop). 16 fail on the base branch built without this diff; "class decorator and extends clause only" passes there and fails on the first version of this PR (the _base inlining), as does the MinifySyntax bundler test below.
    • test/bundler/bundler_edgecase.test.ts, edgecase/DecoratorLoweringTempsInLoops, ...MinifyIdentifiers, ...MinifySyntax, ...InWrappedModule: module-level and function-level loops through the bundler, with each minify flag, and inside an __esm wrapper. All four fail on the base branch.
    • es-decorators, es-decorators-esbuild, decorators, decorator-metadata, bundler_edgecase, bundler_decorator_metadata, bundler_minify, bundler_naming, transpiler.test.js, runtime-transpiler and esbuild/{ts,lower,default} pass; cargo clippy -p bun_js_parser is clean. bun build --no-bundle output for classes outside loops is byte-identical to the base branch.

Background

  • Lowering shape: class K { @dec m() {} } becomes var _dec = [dec]; var _init = __decoratorStart(); class K { constructor() { __runInitializers(_init, 5, this); } m() {} } __decorateElement(_init, 1, "m", _dec, K);. _init holds the class's decoration state (the initializers decorators registered). An accessor x field keeps its value in a _x WeakMap: the constructor runs __privateAdd(this, _x, value), and the getter/setter read _x either through the binding (undecorated) or through the WeakMap object handed to __decorateElement (decorated). A class expression is lowered to a comma chain assigning the same temporaries, which are declared once, without initializers, at the top of the enclosing statement list.
  • is_inside_loop is the visitor flag the parser already uses to validate break/continue: true while a loop body of the current function is being visited, false again inside nested function, arrow and static block bodies.
  • Single-use inlining: after visiting a block, visit_stmts (minify-syntax mode, non-module scopes) turns let x = init; use(x) into use(init) when x's use_count_estimate is 1 and the use is the next statement's first evaluated expression. var declarations are exempt because their uses may not all have been visited yet. use_count_estimate is maintained by record_usage; use_ref in the lowering is record_usage plus the identifier.
  • Renaming: bun build runs a renamer, so two symbols that ask for the same name print differently; the runtime transpiler and bun build --no-bundle print the requested names as they are. js_parser: give standard decorator lowering temporaries unique names #38734 makes the requested names unique for that path.
Output for the first repro, before and after

Before:

for (let i = 0; i < 2; i++) {
  var _dec = [dec];
  var _init = __decoratorStart(undefined);
  class K { constructor() { __runInitializers(_init, 5, this); } m() {} }
  __decorateElement(_init, 1, "m", _dec, K);
  __decoratorMetadata(_init, K);
  let _K = K;
  classes.push(K);
}

After: the two vars are lets. A class expression in a loop body goes from var _class, _init, _dec, _x; at the top of the body to let _class, _init, _dec, _x;. Classes outside loops print as before.

First version of this PR

The first push only switched the keyword. Review found that this made @dec class K extends Base {} inside a loop throw ReferenceError: _base is not defined under bun run and bun build --minify-syntax, because _base had a recorded use count of 1 (only the extends reference was recorded) and the single-use inliner substituted it into __decoratorStart. The current version records every reference (the use_ref hunks) and adds the two tests that fail on the first version.

@robobun

robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator Author

Reproduced on Bun 1.4.0 and on the base branch: a decorated class (or a class with an accessor field) created inside a loop body shares its var temporaries across iterations, so instances of an earlier iteration's class run the last iteration's initializers, get its computed key, or throw TypeError: Cannot read from private field (statement and expression mode; bun run, --no-bundle and bun build).

Fix in this PR: the temporaries are declared with let when the class is lowered inside a loop body, and every reference to a temporary is now recorded so the single-use inliner (minify-syntax) leaves the lets alone. Of the 21 new tests, 20 fail on the base branch; the remaining one and the MinifySyntax bundler test fail on the first version of this PR, which did not record the references. Stacked on #38734, which gives the temporaries unique names; with let, colliding names would be a SyntaxError instead of silent sharing.

@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 transpiler output for decorator/accessor lowering and is stacked on #38734, a human look would still be worthwhile before landing.

What was reviewed:

  • temp_decl_kind() reads fn_or_arrow_data_visit.is_inside_loop, which visit_loop_body sets for all five loop kinds and which resets to false in nested functions, arrows, and static blocks — matches the PR's claim.
  • All var_decl/var_decl2 call sites are renamed; declare_capture_temps_in_fn_body correctly stays KVar (per-invocation, not per-iteration).
  • Checked the switch-in-loop test: two let decls in sibling case clauses share the switch block scope, but #38734's unique names prevent redeclaration and only one case runs per iteration, so no TDZ read.
  • Expression-mode hoisted decl at nearest_stmt_list_mut() uses temp_decl_kind() evaluated at the class expression's visit position, which aligns with the innermost statement list being inside the loop body.
Extended reasoning...

Overview

This PR fixes a correctness bug in the standard-decorator / accessor lowering: temporaries (_init, _dec, _computedKey, _base, per-member WeakMaps/WeakSets, _class) were declared with var, so inside a loop body all iterations shared one binding. Classes created in earlier iterations would read the last iteration's temporaries when constructed later. The fix adds a 7-line temp_decl_kind() helper that returns KLet when is_inside_loop is set, and threads it through the ~15 declaration sites (mostly a mechanical rename of var_decl→temp_decl). 18 new tests across es-decorators.test.ts and bundler_edgecase.test.ts.

Security risks

None. This is a JS-to-JS lowering change with no I/O, no untrusted input parsing beyond what the parser already does, and no allocation-size arithmetic.

Level of scrutiny

Moderate-to-high. The Rust diff is small and mechanical, and the flag it reads (fn_or_arrow_data_visit.is_inside_loop) is an existing, well-understood parser state used for break/continue validation. But this changes transpiler output for every decorated class inside a loop, across bun run, --no-bundle, and bun build. I verified the flag's lifecycle (set in visit_loop_body at src/js_parser/visit/mod.rs:708-715, reset to default when entering functions/arrows/static blocks at mod.rs:84, mod.rs:855, visit_expr.rs:2428), that all old var_decl/var_decl2 call sites were updated, and that the method-body capture path (declare_capture_temps_in_fn_body) intentionally stays KVar.

Other factors

  • Stacked PR: this depends on #38734 for unique temp names. Without it, let would turn silent var sharing into a duplicate-declaration SyntaxError. A human should confirm the stacking/merge order.
  • Test coverage is excellent: all five loop statement types, block vs single-statement bodies, class statements vs expressions, every lowered-member kind (including receiver-capture temps), nested blocks, switch-in-loop, minified identifiers, and __esm-wrapped modules. The PR states all 18 fail on the base branch and pass with the fix.
  • Acknowledged gaps: class expressions in loop heads, parameter defaults, and instance field initializers are explicitly left for a follow-up (they have no per-evaluation statement list).
  • The expression-mode path pushes the let to nearest_stmt_list_mut(); temp_decl_kind() is evaluated at the class expression's visit position, so is_inside_loop is true exactly when that statement list is inside the loop body — the two align by construction.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

On the stacking question: #38734 has to land first. Once it does, GitHub retargets this PR to main and the diff stays this single commit; if #38734 changes, I will rebase this on top of it. Landing this one on its own is not an option: without the unique names, two lowered classes in one loop body (or accessor x next to #x) would print two let declarations of the same name in one block, which is a SyntaxError, where the vars today merely share a binding.

@robobun
robobun force-pushed the farm/3560fb67/decorator-temps-per-iteration branch from 620fee0 to 4bc35f5 Compare August 15, 2026 12:35

@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 decorator-lowering codegen in the transpiler (var→let scoping with knock-on effects on single-use inlining and __esm hoisting) and is stacked on #38734, a human look would still be worthwhile before landing.

What was reviewed:

  • temp_decl_kind gates on fn_or_arrow_data_visit.is_inside_loop, which is set by visit_loop_body and reset for nested fns/arrows/static blocks — matches the described scope.
  • The bare E::Identifier → use_ref conversions (base_ref, init_ref, computed_key_refs, private_storage_ref/private_extra_ref) keep use_count_estimate ≥ 2 so minify-syntax single-use inlining can't drop a let temp still read elsewhere; the _base/extends and computed-key cases are covered by the new tests.
  • let in a module-level loop is not touched by maybe_relocate_vars_to_top_level (only KVar is relocated), so the wrapped-__esm case keeps per-iteration bindings; covered by the InWrappedModule test.
  • Switch-case temporaries share the switch block scope but get unique names from #38734, so two lets per iteration don't collide.
Extended reasoning...

Overview

Single-commit change stacked on #38734. In src/js_parser/lower/lower_decorators.rs, standard decorator/accessor lowering temporaries (_init, _dec, _base, _computedKey, per-member WeakMap/WeakSet refs, the class-expression temp, and receiver-capture temps declared alongside them) are now emitted as let when the class is lowered inside a loop body, and stay var otherwise. var_decl/var_decl2 are renamed to temp_decl/temp_decl2; drain_capture_temp_decls gains a kind parameter (still KVar for the per-method-body path, temp_decl_kind() for the class-level path). Several bare E::Identifier { ref_ } sites become p.use_ref(ref_, ..) so use_count_estimate reflects every reference. 15 new runtime-transpiler tests in es-decorators.test.ts and 4 new itBundled tests in bundler_edgecase.test.ts.

Security risks

None. This is JS/TS-to-JS lowering; no auth, crypto, filesystem, or network surface is touched. The change picks a declaration keyword and adjusts symbol use-count bookkeeping.

Level of scrutiny

High. Transpiler codegen is user-visible for every file that uses standard decorators or accessor, and var→let changes hoisting semantics, TDZ, and interacts with the bundler's minify-syntax single-use-let inlining and its __esm var-relocation pass. The PR is also stacked: correctness of the let output for two classes in one loop body (or accessor x next to #x) depends on #38734's unique naming, so land order matters.

Other factors

The reasoning is careful and the test matrix is broad: all five loop forms, block and single-statement bodies, statement and expression classes, nested blocks, switch cases, static-field-nested class expressions, minify-identifiers, minify-syntax, and the __esm wrapped-module path. I traced the specific hazards the diff introduces — the single-use-inlining risk that motivates the use_ref conversions is real (a let with count 1 followed by an immediately-consumed reference is inlined by visit_stmts), and each converted site does have a second use the count now reflects. The one open edge the author calls out (class expressions in loop heads / param defaults / instance field initializers) is explicitly left for a follow-up and is not made worse here. Given the subtlety and the stacking dependency, I'd like a human to confirm the use_ref additions don't over-count in any path (they look correct to me) and to sequence the merge behind #38734.

…e loops

The standard decorator / accessor lowering declared a class's temporaries
(_init, _dec, _computedKey, _base, the _<name> WeakMaps and WeakSets of
lowered members, _class in expression mode) with var. Inside a loop body
that is a single binding shared by every iteration, while the class each
iteration creates keeps reading it afterwards: its constructor reads _init,
_computedKey and the WeakMaps on every instantiation, and the accessors
installed by __decorateElement hold the WeakMap of their own iteration. So
instances of an earlier iteration's class ran the last iteration's
initializers, got its computed key, or threw "Cannot read from private
field" for accessor fields and private members.

When the class is lowered inside a loop body (is_inside_loop), declare the
temporaries with let, in both statement mode and expression mode (where the
declaration is hoisted to the top of the innermost statement list, which is
inside the loop body), so each iteration gets its own set. Outside loops
the output is unchanged.

Unlike var declarations, initialized let declarations take part in the
single-use inlining of visit_stmts, which trusts use_count_estimate, so the
references to temporaries that the lowering created as bare identifiers
(the _base argument of __decoratorStart, _init in the __decorateElement
calls, the storage WeakMaps handed to __privateAdd, the second reference to
a _computedKey) now go through use_ref. Without that, `@dec class K extends
Base {}` in a loop had _base inlined into __decoratorStart and dropped
while `extends _base` still read it.
@robobun
robobun force-pushed the farm/3560fb67/decorator-temps-per-iteration branch from 4bc35f5 to da891b2 Compare August 15, 2026 12:54
@robobun

robobun commented Aug 15, 2026 •

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

✅ @robobun, your commit da891b29460f53c3d3773b3aae0cafafa78b5c43 passed in Build #98096! 🎉


🧪   To try this PR locally:

bunx bun-pr 38933

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

bun-38933 --bun

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

On the over-counting question, counted per use_ref site against what the printer emits (checked with bun build --no-bundle on the debug build):

  • _base: printed in extends _base and in __decoratorStart(_base); recorded twice. Exact.
  • _init in the member and class __decorateElement calls: each call expression ends up in exactly one of the suffix lists, so one recording per printed call. The remaining _init references (__runInitializers, __decoratorMetadata) already went through use_ref. Exact.
  • The storage WeakMap stored on the init entry (prop_shallow.key for private members, .value for public decorated accessors): printed once, in that entry's __privateAdd; recorded once. Exact.
  • _computedKey: decorated methods, getters, setters and fields print it twice (the member's own key or the this[_computedKey] assignment, plus the decorate call) and record twice. A decorated accessor [k] prints it once, because the member itself is replaced by what __decorateElement installs, while two references are recorded. That is the one over-count: it can only keep the inliner away from a declaration, and none of the generated statements consumes _computedKey as its first expression anyway, so it changes no output. Recording per emitting site is deliberately kept simple rather than special-casing accessors, since under-counting is the direction that breaks.

Over-counting in general is harmless here: the inliner and the unused-name removal only act on counts of 1 and 0, and var temporaries were never inlined at all, so nothing that was inlined before stops being inlined. I amended the commit (da891b2) to make the code comment at the computed-key site say this; no code or test changes.

@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 parser lowering semantics (var→let with inliner interactions) and is stacked on #38734, a human look would still be worthwhile before landing.

What was reviewed:

  • Verified is_inside_loop is live at both lowering entry points (lower_class in visit_stmt, lower_standard_decorators_expr in visit_expr) and resets correctly for functions/arrows/static blocks.
  • Checked each new use_ref site corresponds to an emitted reference; the one over-count (_computedKey on decorated accessors) is documented and harmless.
  • Checked the switch-case test for TDZ hazards — each iteration gets a fresh switch block scope and #38734's unique names keep the two cases' lets distinct.
Extended reasoning...

Overview

The PR changes src/js_parser/lower/lower_decorators.rs so that the temporaries the standard-decorator/accessor lowering declares (_init, _dec, _base, _computedKey, per-member WeakMaps/WeakSets, receiver-capture temps, and the class-expression hoisted decl) use let instead of var when the class is lowered inside a loop body, so each iteration gets its own bindings. It also converts several bare E::Identifier constructions to use_ref so use_count_estimate is accurate for the single-use inliner, and renames var_decl/var_decl2 → temp_decl/temp_decl2. 21 new tests span the runtime transpiler (es-decorators.test.ts) and the bundler with minify variants (bundler_edgecase.test.ts).

Security risks

None. This is a code-generation correctness fix in the JS parser; no auth, crypto, filesystem, or network surface is touched.

Level of scrutiny

Medium-high. Decorator lowering is correctness-critical (wrong output silently miscompiles user code), and the fix interacts with two other subsystems: the visit_stmts single-use inliner (via use_count_estimate) and the temp-naming pass from #38734. The author already found and fixed one such interaction (the _base inlining regression) after the first push, which is exactly the kind of subtlety that argues for a human pass.

Other factors

  • Stacked PR: depends on #38734 for unique temp names; landing this alone would turn silent var sharing into let duplicate-declaration SyntaxErrors. The base PR must land first.
  • I confirmed fn_or_arrow_data_visit.is_inside_loop is set by visit_loop_body (all five loop forms) and reset via FnOrArrowDataVisit::default() on function/arrow/static-block entry, and that both lower_standard_decorators_stmt (via lower_class in visit_stmt.rs) and lower_standard_decorators_expr (in visit_expr.rs) run inside the visit pass where the flag is valid.
  • declare_capture_temps_in_fn_body correctly stays var (method-body scope, fresh per invocation); the class-level drain_capture_temp_decls correctly follows temp_decl_kind.
  • The use_ref audit in the author's follow-up comment matches what I traced: exact for _base, _init, and storage refs; one benign over-count for _computedKey on decorated accessors, which can only prevent inlining (never cause it) and is documented at the site.
  • Test coverage is thorough: statement and expression mode, all five loop forms, block vs single-statement bodies, nested blocks, switch cases, static field initializers, the minify-syntax inlining hazard, and the __esm-wrapped module path. The author states 20/21 fail on the base branch and the remaining one guards the first-version regression.
  • Known unfixed cases (loop-head class expressions, parameter defaults, field initializers) are explicitly called out and deferred to #38904.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Confirming this review is against the amended head (da891b2), which only reworded the code comment at the computed-key site; the code and tests are the ones reviewed in the previous round. Nothing further is planned for this branch unless CI turns something up, so the remaining step is landing #38734 and then this.

@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 class declared in a loop body still gets var temporaries, so every iteration's class shares one _init and one _x WeakMap. All 16 tests and the 4 bundler cases 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.

The unique-name part this PR was stacked on (#38734) is covered by #40833, including the three cases it missed (ported in 1c58acd), so #38734 is closed. This PR stays open for the per-iteration declarations. It needs a rebase onto #40833, where new_sym now registers every temporary with the var-hoisting scope.

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

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

#40895 merged as 5822b76 and carries the unique temporary names that #38734 had, so the base branch of this PR (farm/55355bb8/decorator-temp-names, from the closed #38734) is superseded. To move forward, retarget this PR to main and rebase the per-iteration let change onto P::generate_temp_var.

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>

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant