Repository navigation
js_parser: declare decorator lowering temporaries per iteration inside loops (stacked on #38734) - #38933
Conversation
|
Reproduced on Bun 1.4.0 and on the base branch: a decorated class (or a class with an Fix in this PR: the temporaries are declared with |
There was a problem hiding this comment.
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()readsfn_or_arrow_data_visit.is_inside_loop, whichvisit_loop_bodysets for all five loop kinds and which resets tofalsein nested functions, arrows, and static blocks — matches the PR's claim.- All
var_decl/var_decl2call sites are renamed;declare_capture_temps_in_fn_bodycorrectly staysKVar(per-invocation, not per-iteration). - Checked the switch-in-loop test: two
letdecls 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()usestemp_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,
letwould turn silentvarsharing 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
lettonearest_stmt_list_mut();temp_decl_kind()is evaluated at the class expression's visit position, sois_inside_loopis true exactly when that statement list is inside the loop body — the two align by construction.
|
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 |
620fee0 to
4bc35f5
Compare
There was a problem hiding this comment.
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_kindgates onfn_or_arrow_data_visit.is_inside_loop, which is set byvisit_loop_bodyand reset for nested fns/arrows/static blocks — matches the described scope.- The bare
E::Identifier→use_refconversions (base_ref, init_ref, computed_key_refs, private_storage_ref/private_extra_ref) keepuse_count_estimate≥ 2 so minify-syntax single-use inlining can't drop alettemp still read elsewhere; the_base/extends and computed-key cases are covered by the new tests. letin a module-level loop is not touched bymaybe_relocate_vars_to_top_level(only KVar is relocated), so the wrapped-__esmcase 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.
4bc35f5 to
da891b2
Compare
|
Updated 12:15 PM PT - Aug 15th, 2026
✅ @robobun, your commit da891b29460f53c3d3773b3aae0cafafa78b5c43 passed in 🧪 To try this PR locally: bunx bun-pr 38933That installs a local version of the PR into your bun-38933 --bun |
|
On the over-counting question, counted per
Over-counting in general is harmless here: the inliner and the unused-name removal only act on counts of 1 and 0, and |
There was a problem hiding this comment.
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_loopis live at both lowering entry points (lower_classinvisit_stmt,lower_standard_decorators_exprinvisit_expr) and resets correctly for functions/arrows/static blocks. - Checked each new
use_refsite corresponds to an emitted reference; the one over-count (_computedKeyon 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
varsharing intoletduplicate-declaration SyntaxErrors. The base PR must land first. - I confirmed
fn_or_arrow_data_visit.is_inside_loopis set byvisit_loop_body(all five loop forms) and reset viaFnOrArrowDataVisit::default()on function/arrow/static-block entry, and that bothlower_standard_decorators_stmt(vialower_classinvisit_stmt.rs) andlower_standard_decorators_expr(invisit_expr.rs) run inside the visit pass where the flag is valid. declare_capture_temps_in_fn_bodycorrectly staysvar(method-body scope, fresh per invocation); the class-leveldrain_capture_temp_declscorrectly followstemp_decl_kind.- The
use_refaudit in the author's follow-up comment matches what I traced: exact for_base,_init, and storage refs; one benign over-count for_computedKeyon 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.
|
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. |
|
Status against #40833, which rewrites the standard decorator lowering: it does not cover this case. A class declared in a loop body still gets 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 |
…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>
Stacked on #38734 (base branch
farm/55355bb8/decorator-temp-names); the diff here is only the last commit.Problem
accessorlowering declares a class's temporaries withvar:_init,_dec,_computedKey,_base, one_<name>WeakMap or WeakSet per lowered member, and_classfor a class expression (src/js_parser/lower/lower_decorators.rs,temp_decland the hoisted declaration in Phase 8)._init,_computedKeyand the WeakMaps on everynew, and the getters/setters__decorateElementinstalled hold the WeakMap of the iteration that created the class.addInitializercallbacks and get the wrong computed key, andaccessorfields (decorated or not) and#privatemembers of lowered classes throwTypeError: Cannot read from private field. Same underbun run,bun build --no-bundleandbun build.Fix
temp_decl_kind: when the class is lowered inside a loop body (fn_or_arrow_data_visit.is_inside_loop, set byvisit_loop_bodyfor all five loop statements and reset for nested functions, arrows and static blocks) the temporaries are declared withletinstead ofvar. 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_decl2are renamedtemp_decl/temp_decl2because they no longer always emitvar; the capture temporariesdeclare_capture_temps_in_fn_bodyputs at the top of method bodies stayvar.letin 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.varandletwould bind the same;varis 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).var, an initializedletis a candidate for the single-use inlining invisit_stmts(--minify-syntax, which the runtime transpiler has on), and that pass trustsuse_count_estimate. The lowering created some references to temporaries as bareE::Identifiers, which do not count: the_baseargument of__decoratorStart,_initin the__decorateElementcalls, the storage WeakMap handed to__privateAddfor decorated private fields and accessors, and the second reference to a_computedKey. These now go throughuse_ref, andtemp_decl_kinddocuments 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 = Baseis directly followed bylet _init = __decoratorStart(_base), so the first version of this PR inlined_base, dropped its declaration and leftclass K extends _basethrowingReferenceError: _base is not defined. The other corrections are the same defect without a consumer next to it today.bun run,bun build --no-bundle) the temporaries print under the names they ask for, so withlettwo classes in one loop body (oraccessor xnext to#x) would go from silently sharing avarto 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 aletin a block of that scope (a name unique across the scope is unique inside the block too, and thelet _<Class>binding already worked this way).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.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_baseinlining), as does theMinifySyntaxbundler 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__esmwrapper. All four fail on the base branch.cargo clippy -p bun_js_parseris clean.bun build --no-bundleoutput for classes outside loops is byte-identical to the base branch.Background
class K { @dec m() {} }becomesvar _dec = [dec]; var _init = __decoratorStart(); class K { constructor() { __runInitializers(_init, 5, this); } m() {} } __decorateElement(_init, 1, "m", _dec, K);._initholds the class's decoration state (the initializers decorators registered). Anaccessor xfield keeps its value in a_xWeakMap: the constructor runs__privateAdd(this, _x, value), and the getter/setter read_xeither 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_loopis the visitor flag the parser already uses to validatebreak/continue: true while a loop body of the current function is being visited, false again inside nested function, arrow and static block bodies.visit_stmts(minify-syntax mode, non-module scopes) turnslet x = init; use(x)intouse(init)whenx'suse_count_estimateis 1 and the use is the next statement's first evaluated expression.vardeclarations are exempt because their uses may not all have been visited yet.use_count_estimateis maintained byrecord_usage;use_refin the lowering isrecord_usageplus the identifier.bun buildruns a renamer, so two symbols that ask for the same name print differently; the runtime transpiler andbun build --no-bundleprint 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:
After: the two
vars arelets. A class expression in a loop body goes fromvar _class, _init, _dec, _x;at the top of the body tolet _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 throwReferenceError: _base is not definedunderbun runandbun build --minify-syntax, because_basehad a recorded use count of 1 (only theextendsreference was recorded) and the single-use inliner substituted it into__decoratorStart. The current version records every reference (theuse_refhunks) and adds the two tests that fail on the first version.