Repository navigation
Conversation
…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 ...; })()`.
|
Warning Review limit reached
Next review available in: 6 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (4)
Comment |
|
Status: reproduced and fixed; waiting on CI and review.
|
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
There was a problem hiding this comment.
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_sharedinvisit_args,visit_class(non-static initializers only), andvisit_stmts— no early-return paths skip the restore. - The IIFE fallback in phase 8: the arrow preserves
this/super/arguments/new.target, andawait/yieldare early errors in both target positions, so the wrapper is transparent; theNone-list case (top-level enum initializers) now falls back instead of silently dropping thevar. - The other
nearest_stmt_listconsumer (React refresh invisit_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.buildplain+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_listhandling invisit_stmts, and I traced each set site to verify no early return orcontinuebetween 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, athis/super/argumentssemantics guard for the wrapper, an output-shape test pinning that unchanged positions still hoist, and aBun.buildplain+minified round-trip. - The PR notes two adjacent bugs (loop-body
varsharing 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.
|
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.
| // 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`. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| /// 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. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| // 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. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| // An instance initializer runs once per construction; a | ||
| // static one runs with the class itself. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
There was a problem hiding this comment.
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_argsand non-staticproperty.initializer, cleared invisit_stmtsfor 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 resultwrapped in an arrow IIFE via the newiifehelper (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_classdoes not set the flag forproperty.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.buildwith and without minify). - The
iifehelper refactor is a straightforward de-duplication of the existing open-coded arrow+call at line ~2146; theis_async: falseexplicit field is dropped in favor ofDefault::default(), which is equivalent.
|
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 ( This PR stays open. It needs a rebase onto #40833, whose class-expression output is assembled in |
…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>
…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>
…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>
…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>
Problem
accessormember) 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 throwsTypeError: Cannot read from private field, and with a method decorator that usesaddInitializerthe instance silently receives a later class's initializers.src/js_parser/lower/lower_decorators.rs, phase 8) emits(_dec = [dec], _x = new WeakMap, _init = __decoratorStart(), _class = class {...}, __decorateElement(..., _x), _class)and pushes thevar _class, _init, _dec, _xfor those temporaries intop.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_argsvisits default values before the function body installs its own list (src/js_parser/visit/mod.rs), andvisit_classvisits 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.nearest_stmt_listisNonethe 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).varfor these inputs and fails the same way; tsc wraps every such class expression in an IIFE that declares the temporaries locally.Fix
P::nearest_stmt_list_is_shared:visit_argssets it while visiting the parameter list (plain and destructuring defaults) andvisit_classwhile visiting a non-static field or accessor initializer;visit_stmtssaves 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.varonly 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 fornearest_stmt_list == None.this,super,argumentsandnew.targetof 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 checksthis,superandargumentsexplicitly. A parameter default value and a class field initializer are the two expression positions whereawaitandyieldare 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).bun build/--minifygive 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 (theBun.buildtest covers plain and minified output).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, instanceaccessorinitializer of a class that is lowered itself, a static initializer nested in a parameter default, the top-level enum fallback, thethis/super/argumentsguard, transpiler output shape,Bun.buildplain and minified). 11 fail on the unfixed build with the error above; thethis/super/argumentstest passes on both and guards the wrapper's semantics.nearest_stmt_listuser; it is not affected by the flag), bundler_edgecase, bundler_minify, bundler_naming, esbuild/ts, esbuild/default, esbuild/lower;cargo clippy -p bun_js_parserclean._decentry in thevar(visible in the output below) is unrelated to this change. Two related bugs found while working on this are tracked separately: temporaries declared withvarare 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
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 separatevarstatement 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:_initholds the per-class array of initializers thataddInitializercallbacks and field initializers go into, and eachaccessorgets a WeakMap (_x) that stores the per-instance value.nearest_stmt_listis a pointer to the list thatvisit_stmtsemits 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 thevarabove.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_classvisits 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:
After:
For comparison, tsc 6.0 emits
K = (() => { let _x_decorators; let _x_initializers = []; ...; return class { ... }; })()for the same input, and esbuild 0.25.12 emitsvar _x_dec, _init, _x;at module level and fails at runtime like Bun did.