Repository navigation
Conversation
Collaborator
Author
|
Status of #45351
|
Collaborator
Author
|
Updated 5:13 PM PT - Oct 21st, 2026
✅ @robobun, your commit 5f97c3e00ae7934ef397b67c75af36c2087f5943 passed in 🧪 To try this PR locally: bunx bun-pr 45351That installs a local version of the PR into your bun-45351 --bun |
robobun
force-pushed
the
robobun/1f39b8d0/decorated-private-members
branch
from
October 15, 2026 15:53
b3ecc9a to
e9de9f9
Compare
robobun
force-pushed
the
robobun/1f39b8d0/decorated-private-members
branch
from
October 15, 2026 16:23
e9de9f9 to
1d8a4ad
Compare
This was referenced Oct 16, 2026
Merged
robobun
force-pushed
the
robobun/1f39b8d0/decorated-private-members
branch
from
October 20, 2026 00:54
1d8a4ad to
005660d
Compare
robobun
force-pushed
the
robobun/1f39b8d0/decorated-private-members
branch
from
October 20, 2026 01:56
005660d to
3eb4b1f
Compare
A standard decorator on a `#private` member replaced the name with a WeakMap or a WeakSet, and a second pass rewrote each use of the name into a helper call. The pass knew four forms of use. Every other form printed invalid or wrong code: `this.#x++`, a destructuring target, `o?.#x`, a tagged template, `import(this.#x)`, a direct `eval`, and `super` in the body of a decorated `#private` method. The name now stays declared, so the engine runs every use, and the pass is deleted: - A decorated `#private` field is the field. - A decorated `#private` method, getter or setter is a `get` or a `set` of the name that gives what the decorators returned. - A decorated `accessor #x` is a `get` and a `set` of the name over what the decorators returned. Its value stays in a WeakMap, as the value of `accessor x` does. - `__decorateElement` reaches the names through one function of the class, for `context.access`. The first `get` and `set` of a decorated accessor read the WeakMap themselves. - A body with a `super` or a direct `eval` of its own stays a method of the class, under a key that the leading static block deletes. Each evaluation of a class expression has its own temporaries: `let` in a loop body, and an arrow function around the class where the class runs more often than its statement list. The visit pass gives the lowering the name of an anonymous class, because the arrow function is between the class and what names it. A class with an `await` or a `yield` in its head, a name that only the engine knows, or a `new.target` in a field initializer or a static block stays where it is written, with shared temporaries. With `bun test --coverage` the class calls its access function once, so that the function counts as run. Outside a loop body, a class statement with no decorated `#private` member prints the same output as before.
robobun
force-pushed
the
robobun/1f39b8d0/decorated-private-members
branch
from
October 21, 2026 16:57
3eb4b1f to
5f97c3e
Compare
dylan-conway
added a commit
that referenced
this pull request
Oct 21, 2026
…the decorated class (#45435) Fixes #44915 ### Problem - With standard (TC39) decorators, a class decorator that returns a new class is invisible inside the class body. `static self = () => Model`, `@capture(() => Model)`, `static { Model.x }`, methods and instance fields all see the original class. Babel, tsc and bun 1.4.2 (for decorator arguments) give the decorated class. The spec initializes the inner class binding with the decorated class, after the class decorators run and before the static fields. - The lowering keeps the class statement and applies the class decorators from a leading `static {}` (`src/js_parser/lower/lower_decorators.rs:721`). Every `Model` in the body binds to the class statement's own inner binding. Only the outer `Model` is reassigned. ### Fix - `visit_class` (`src/js_parser/visit/mod.rs:1034`) binds the class name inside a class-decorated body to a fresh symbol `_Model`. Every body reference resolves through that one scope member, so no tree walk is needed. - `lower_class_body` uses that symbol as the target of `__decorateElement` and declares it with `let _Model;` in front of the class. The leading static block assigns the decorated class to it before any static field runs. A `let` gives each evaluation in a loop its own binding. - A write to the class name inside the body (`C = x`, `C++`) still targets the class's own binding, so it throws a `TypeError` as it does natively. The name of a class expression stays in the renamer's view, so a bundle keeps it apart from an import of the same name. - Classes without class decorators (none, or member decorators only) emit byte-identical output (checked with `cmp` on 400 classes). The runtime transpiler cache version is 41 to 42. - Verified: `test/regression/issue/44915.test.ts` (fails on 1.4.3), five new tests in `test/bundler/transpiler/es-decorators.test.ts`. Also `es-decorators-esbuild.test.ts`, `decorators.test.ts`, `decorator-metadata.test.ts`, `ts-use-define-for-class-fields.test.ts`, `transpiler.test.js`, `bundler_edgecase.test.ts`. ### Background - `visit_class` pushes a `ClassName` scope with one member for the class name. The body's identifiers resolve through it. Before this PR the member was the class name symbol itself. - #40833 wrote that a static block sees the class as written when a decorator replaces it. That was a description of the lowering, not a spec reading. This PR reverses it. - Considered the tree walk in #44995: it keeps the scope member wrong and rewrites every body reference afterwards, through the `#private` rewriter. #38731 had this PR's mechanism against the old lowering and went stale. This PR ports it and drops its private-static carve-out. - #45351 touches the same functions and also bumps the cache version. Whichever lands second rebases and takes the next one. ### Downsides - `C.#s()` in a class that a decorator replaced now throws a `TypeError`: `C` is the replacement, which has no brand for `#s`. The spec throws here too. 1.4.3 returned the member because `C` was the original class. `this.#s()` in static code still works (pinned in the test). - A read of the class name before the class decorators run (a computed key, an eager element decorator argument) gives `undefined` instead of a `ReferenceError`. tsc does the same. The esbuild suite test that asserts the `ReferenceError` is moved to `todoTests`. - One `let` declaration per class-decorated class, 10 bytes in the output of the issue repro. `size_of::<G::Class>()` 88 to 96 bytes. A class expression in a field initializer or a parameter default has no statement list of its own, so it is printed as `((_C) => …)()` and the binding is the arrow's parameter: one per evaluation. Neither position allows `await` or `yield`. TypeScript `experimentalDecorators` classes have the same inner-name defect and are not changed here. <details><summary>Notes</summary> Lowered output of the issue repro after this PR: ```js var _init$2, _dec$3, _dec$4; let _Model$1; _dec$4 = [replace]; class Model { static { _init$2 = __decoratorStart(undefined); __decorateElement(_init$2, 5, "field", _dec$3, this); _Model$1 = __decorateElement(_init$2, 0, "Model", _dec$4, this); } static self = () => _Model$1; [(_dec$3 = [capture(() => _Model$1)], "field")] = __runInitializers(_init$2, 8, this, 1); } Model = _Model$1; __runInitializers(_init$2, 1, _Model$1); ``` Probes on the debug build: class statement, named class expression, `export class`, `export default class`, a class in a loop body, a class expression returned from a function, `bun build --target=browser` of the exported forms. All five body positions print `true` where 1.4.3 prints `false`. tsc (ES2022 emit) prints `true` for the same five. esbuild prints `true` for the decorator argument and the static field of the issue repro, and `false` for methods and static blocks (its body references resolve to the inner name of the class expression it emits). Static private members: tsc keeps `C.#s()` working because it lowers private statics of a decorated class and brand-checks them against the decorated class. esbuild and bun 1.4.3 keep it working only because the body name is the original class, which is the bug. Self-reviewed: 9 concerns raised, 8 addressed. Review bots found two more, both fixed: the renamer lost the name of a decorated class expression, and a write to the class name inside the body reached the inner binding. Rejected: declaring the inner binding through `temp_refs_to_declare` as a hoisted `var`. The `_init` and `_dec` temporaries are shared across loop iterations (#38933, #38904), which is a known defect. The inner binding is user-observable across evaluations, so it is a `let` next to the class. `test/bundler/bundler_edgecase.test.ts` `DeepImportDiamondDAG` fails on main in this container as well, without this diff. </details> <!-- robobun:evidence:begin --> --- **[human-review]** gate passed · iteration 0 · 11 files touched <details><summary>fails on main (without fix)</summary> ```console ASAN without fix: BUILD FAILED (no junit output) $ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bundler/transpiler/es-decorators-esbuild.test.ts test/bundler/transpiler/es-decorators.test.ts "test/regression/issue/44915.test.ts" error: Source list 'bindgenV2' matched nothing at: file:///workspace/bun/scripts/glob-sources.ts hint: Patterns: src/**/*.bindv2.ts error: script "bd" exited with code 1 __F:-1:S:0 release without fix: 6 failed, 1 skipped bun test v1.4.3-canary.1 (367d939) test/regression/issue/44915.test.ts: 34 | cwd: String(dir), 35 | stderr: "pipe", 36 | }); 37 | const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); 38 | expect(stderr).toBe(""); 39 | expect(stdout).toBe("decorator argument: true\n"); ^ error: expect(received).toBe(expected) - "decorator argument: true + "decorator argument: false " - Expected - 1 + Received + 1 at <anonymous> (/workspace/bun/test/regression/issue/44915.test.ts:39:18) (fail) a member decorator argument sees the class its class decorator returns [29.21ms] test/bundler/transpiler/es-decorators.test.ts: (pass) ES Decorators > class decorators > basic class decorator [13.30ms] (pass) ES Decorators > class decorators > class decorator receives correct context [13.27ms] (pass) ES Decorators > class decorators > class decorator can replace class [211.56ms] (pass) ES Decorators > class decorators > multiple class decorators apply in reverse order [98.23ms] (pass) ES Decorators > method decorators > instance method decorator [46.37ms] (pass) ES Decorators > method d ... (truncated) ``` </details> <details><summary>passes on PR (with fix)</summary> ```console ASAN with fix: 1 skipped $ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bundler/transpiler/es-decorators-esbuild.test.ts test/bundler/transpiler/es-decorators.test.ts "test/regression/issue/44915.test.ts" bun test v1.4.4 (367d939) test/regression/issue/44915.test.ts: (pass) a member decorator argument sees the class its class decorator returns [453.08ms] test/bundler/transpiler/es-decorators.test.ts: (pass) ES Decorators > class decorators > basic class decorator [403.47ms] (pass) ES Decorators > class decorators > class decorator receives correct context [325.72ms] (pass) ES Decorators > class decorators > class decorator can replace class [352.36ms] (pass) ES Decorators > class decorators > multiple class decorators apply in reverse order [326.70ms] (pass) ES Decorators > method decorators > instance method decorator [352.62ms] (pass) ES Decorators > method decorators > static method decorator [453.13ms] (pass) ES Decorators > method decorators > method decorator context has correct access [319.16ms] (pass) ES Decorators > getter decorators > getter decorator [299.12ms] (pass) ES Decorators > set ... (truncated) release with fix: 1 skipped $ bun scripts/build.ts --profile=release [configured] bun-profile → bun (stripped) target linux-x64-gnu build type Release build dir ./build/release revision 419a9db features lto, baseline 23 deps, 146 codegen, 1176 objects in 3551ms ninja: Entering directory `/workspace/bun/build/release' [1/4] fetch lolhtml [lolhtml] up to date [2/4] fetch rust-argon2 [rust-argon2] up to date [2/4] cargo plan → /workspace/bun/build/release/rust-target/plan.json 264 units: 192 lib, 16 proc-macro (host), 19 custom-build (host), 15 run custom-build, 17 lib (host), 4 run custom-build (host), 1 rlib [3/4] reconfigure [1/1529] mkdir stamps [2/1529] mkdir codegen [3/1529] gen bake.{client,server,error}.js -> bake.client.js, bake.server.js, bake.error.js [4/1529] install /workspace/bun bun install v1.4.3-canary.1 (367d939) Checked 22 installs across 44 packages (no changes) [4.59s] [5/1529] install /workspace/bun/packages/bun-error bun install v1.4.3-canary.1 (367d939) Checked 1 install across 2 packages (no changes) [250.00ms] [6/1529] install /workspace/bun/src/node-fallbacks bun install v1.4.3-canary.1 (367d939) Checked 111 installs across ... (truncated) ``` </details> <details><summary>diff hotspot</summary> ``` src/ast/expr.rs | 1 + src/ast/g.rs | 5 + src/js_parser/lower/lower_decorators.rs | 45 +++++-- src/js_parser/p.rs | 6 + src/js_parser/parse/mod.rs | 1 + src/js_parser/visit/mod.rs | 27 ++++- src/js_parser/visit/visit_expr.rs | 12 ++ src/jsc/RuntimeTranspilerCache.rs | 4 +- .../transpiler/es-decorators-esbuild.test.ts | 9 +- test/bundler/transpiler/es-decorators.test.ts | 132 ++++++++++++++++++++- test/regression/issue/44915.test.ts | 41 +++++++ 11 files changed, 270 insertions(+), 13 deletions(-) ``` </details> **gate history** · 1 passed · 0 rejected · iteration 0 <details><summary>evidence per changed file</summary> ``` file reads edits tests src/ast/expr.rs 0 0 28 src/ast/g.rs 0 0 28 src/js_parser/lower/lower_decorators.rs 2 0 28 src/js_parser/p.rs 0 0 28 src/js_parser/parse/mod.rs 0 0 28 src/js_parser/visit/mod.rs 2 1 28 src/js_parser/visit/visit_expr.rs 0 0 28 src/jsc/RuntimeTranspilerCache.rs 0 0 28 test/bundler/transpiler/es-decorators-esbuild.test.ts 0 0 11 test/bundler/transpiler/es-decorators.test.ts 1 2 20 test/regression/issue/44915.test.ts 0 1 7 ``` </details> **root cause** · written by the author bot Bun's lowering of standard (TC39) class decorators bound the class name inside the class body to the original, undecorated class, so arrow functions in decorator arguments and static field initializers that referenced the class by name kept pointing at the pre-decoration value even after a class decorator returned a replacement. The fix introduces a dedicated inner binding for the class name, declared as a `let` that the lowering assigns once decorators have run, and resolves references within the class body (including decorator arguments and static initializers) to that binding so they obs… <!-- robobun:evidence:end --> --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Dylan Conway <dylan.conway567@gmail.com>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #31910
Problem
#privatemember breaks 26 of 31 uses.this.#count++givesSyntaxError: Postfix ++ operator applied to value that is not a reference.lower_class_body(src/js_parser/lower/lower_decorators.rs) replaced the name with a WeakMap. A second pass,rewrite_private_accesses_*, rewrote each use. It knew four forms.Fix
#privatemember prints what main prints, outside a loop body: 1610 classes are byte-identical.test/bundler/transpiler/es-decorators.test.ts(131 of 621 fail on main).Background
bun:wraphelper calls.#x. The class gives the helper one function that reaches each name, forcontext.access.#privatemember #42651 rebased with Rewrite super property accesses in private methods extracted by decorator lowering #38769: it keeps the pass, adds five exports and leaves 7 of the 31 forms. This PR adds none.main -> now, and five decisions.Downsides
#privatenames. A mixin on 9 or more classes: one read and write of@dec #xcosts 26 to 59 ns (main: 12 ns).#privatenames. 30 decorated#privatemethods cost 216 KB (main: 118 KB).Notes
Every line below is
main -> now. The rule is the one of #45304: a visible change stays only where main is broken, elsewhere this PR behaves like main. Each bullet was run on release builds of main and of this branch, Linux x64, withBUN_RUNTIME_TRANSPILER_CACHE_PATH=0. The test counts, the coverage rows and the error position are from main 9634520, after the rebase over #46021 and #45677. The other rows are from main d0831bc and this branch on that commit.Waiting for the maintainer's word
#privatemember #42651 rebased with thesuperrewrite of Rewrite super property accesses in private methods extracted by decorator lowering #38769. This PR keeps the decorated#privatename declared instead, as js_parser: lower standard decorators without moving class members #40833 did for every other member: "This one leaves members where they are written, so there is nothing to rewrite [...] with no new runtime helpers" (comment). On Lower private names referenced by class static blocks during decorator lowering #31405: "Come up with a faster approach. Do not add extra passes" (review). The pass is gone. "Faster" holds for a class that uses its members, and not for each row: see Costs. js_parser: rewrite every assignment target of a lowered#privatemember #42651 with Rewrite super property accesses in private methods extracted by decorator lowering #38769 keeps the rewrite pass and adds fivebun:wrapexports (__privateWrapperand four__super*helpers). It leaves 7 of the 31 forms:o?.#x,o?.#m(),this.#x?.(), two tagged templates (js_parser: keepthisfor a tagged template through a decorated#privatemember #42677 has those),import(this.#x)and a directeval. That pair is not built here: thesuperrewrite of Rewrite super property accesses in private methods extracted by decorator lowering #38769 is an arm of a walker that js_parser: lower standard decorators without moving class members #40833 deleted. tsc 6.0.2 prints the shape of this PR:get #m() { return _private_m_descriptor.value; }.superin the body of a decorated#privatemethod. A body that has asuperor a directevalof its own stays a method of the class, under a temporary key. The leading static block reads the function and deletes the key, aslower_class_bodydoes for its__decoratorsmember on main. Next to a computed key the temporary key is a new symbol from$$typeof.constructor(), because the file can declareSymbol. Every other body is a function expression, as on main: also a body where thesuperbelongs to a nested class or object, and a body next to anevalin another member. To drop it: remove theHasSuperOrDirectEvalarm,Host,take_host,new_symbol_onceand their tests. The form then stays the SyntaxError that it is on main and in the output of tsc 6.0.2.@dec accessor #xstays in a WeakMap, as the value of everyaccessordoes on main. Only the name#xis new: agetand asetof the class over what the decorators returned. With the value in a native field, a read and a write take 0.7 ns for one class (now: 4 ns, main: 12 ns), but 80 ns for a mixin on 32 classes (now: 28 to 40 ns, main: 22 to 24 ns), andtoMatchObjectcompares the value. The price of the WeakMap: the initializer of everyaccessorstill runs in the constructor, and a scope that declaresWeakMapstill breaks the threeaccessorforms, both as on main.ts_decoratorsat four sites of the visit pass. Then one test and 12 rows of the name table change, and js_parser: name an anonymous decorated class assigned with ??=, ||= or &&= after the target #38924 and js_parser: name an anonymous decorated class after the parameter or for loop variable it initializes #38906 stay open. bundler: implement --keep-names with __name helper #40503 (--keep-names) names a class from its key in the same block ofvisit_expr.rs.#privatemethod, getter, setter or accessor needs them for this fix:get #m() { return _m_fn; }reads a temporary after the class is defined. A class with onlyaccessor aor@dec m()gets them too, because main is broken for it in the same places (first bullet of "Main was broken", js_parser: declare decorator lowering temporaries per evaluation in parameter defaults and field initializers #38904, js_parser: declare decorator lowering temporaries per iteration inside loops (stacked on #38734) #38933), and js_parser: lower standard decorators without moving class members #40833 left that open: "A class expression evaluated more than once still shares itsvartemporaries". The price is in Silent (a stack frame, the fold names) and in Costs (the arrow function). To drop it:lower_class_bodyreturns whether the class has such a member, andtemps_kind()and the arrow function ask for it (7 lines by hand). Then@dec accessor athrowsTypeError: Cannot read from private fieldagain in those places, the place tests of this PR fail as written for the four kinds of class with no decorated#privatemember, and js_parser: declare decorator lowering temporaries per evaluation in parameter defaults and field initializers #38904 and js_parser: declare decorator lowering temporaries per iteration inside loops (stacked on #38734) #38933 stay open.Silent: the same code gives another result
.nameandcontext.nameof an anonymous class with a class decorator were""at??=,||=,&&=, a parameter default, afordeclaration, a numeric key, a#privatekey and a quoted key with a character that is not ASCII -> the name that the place gives (js_parser: name an anonymous decorated class assigned with ??=, ||= or &&= after the target #38924, js_parser: name an anonymous decorated class after the parameter or for loop variable it initializes #38906, part of js_parser: name lowered anonymous classes after numeric, non-ASCII and private property keys #38787).{ ["a" + "b"]: @dec class {} }:"a"->"ab".{ __proto__: @dec class {} }:"__proto__"->"", because that entry names nothing. At=,const, a plain key and a computed key that is a string main gave the name already, and a computed key of a class field gives""as on main.A class expression in a parameter default, an instance field initializer, the test or the update of a loop, the head of a
for-inor afor-of, or an enum initializer is in an arrow function (decision 5). That is one<anonymous>frame in a stack that is taken while the class is defined: in a decorator, a static initializer, a computed key orextends. Also forclass { accessor a }with no decorator..name,Bun.inspectand a snapshot of the class and of its objects are as on main.In those places, the
.nameof a class with no class decorator is the name that is written. Main names the class after the visit pass folds what is around it. Sox = true ? class { accessor a } : 0, and the same withnull ??,0 ||,(0, C),[C][0]or a condition that--definefolds:"x"->"".x = { k: class { accessor a } }.k:"x"->"k". A plainclass {}there keeps main's name. js_parser: do not fold{ f: () => {} }.fto the bare function #42585 and js_parser: keep(0, value)where a fold would let a binding name an anonymous function or class #42691 are about these folds. With--minify, main gave such a class the short name of its binding:"n"->"Klass", the name that is written.A decorated
#privatemember as a template tag behind something that the visit pass folds:(0, this.#t)`x`, aconst t = this.#tthat is used once, or a constant?:.thisin the tag function:undefined-> the object. The fold makes the tag a member access, and a hand-written#tgets the object on main too. Keepthisundefined for call targets and template tags that a rewrite turned into a property access #40829 fixes the fold.#a in thisbefore the initializer of@dec accessor #aran:false->true, as for aget #a()(part of js_parser: put a loweredaccessor #xon the object before its first initializer #42643).new.targetin the initializer of a decorated#privatefield that no instance field follows: the constructor ->undefined, as in every field initializer. Where an arrow function is around the class, see Loud.A wrong receiver of a use that is written in the class: the TypeError of the helper -> the TypeError of the engine. No error changes its class.
context.accessand a decorated accessor keep the messages of main, and a test pins them.Cannot read from private field,Cannot write to private fieldCannot access invalid private field (evaluating 'o.#x')Cannot access private method,Cannot read from private fieldCannot access private method or acessor (evaluating 'o.#m')(the spelling of the engine)Cannot access static private method or accessornullreceivernull is not an object (evaluating 'o.#x')#m()t.set is not a functionTrying to access an undefined private setterCannot read from private fieldTrying to access an undefined private getterA write to a decorated
set #s: the stack had one frame inbun:wrap-> none. The TypeError of a decoratedaccessor xfor an object that has no value: two or three frames inbun:wrap-> one.A decorated
#privatemethod with a directevalin its body: its decorators got a function expression -> a method, which has noprototypeand whichnewcannot call. Withsuperin the body main does not load.toMatchObject,objectContainingandBun.deepMatchcompare#privatefields, for a hand-written#xon main too (PrivateSymbolMode::IncludeinBun__deepMatch). So they now compare the value of a decorated#xfield:Bun.deepMatch(new C(1), new C(2))wastrue->false. Not the value of a decoratedaccessor #x. Compare functions by identity in toMatchObject and Bun.deepMatch #38339 and Treat Proxy as transparent in deep equality and toMatchObject #32948 change that for every#privatefield.An error that the initializer of a decorated
#privatefield throws, where no instance field follows: a release build reported(evaluating 'load().value.deep')atfile.js:5:20->(near '...constructor() {...')atfile.js:3:1, the line of the class. It is a field initializer now, and the engine reports a plain field initializer as1:11on main.bun test --coveragecounts the functions that the lowering adds. A file that uses each decorated#privatemember is at 100% of its functions as on main: with--coveragethe class calls its access function once. A file with members that no test uses: Costs.bun test --coverage, a class expression in a parameter default (or another place that gets an arrow function) that no test evaluates: 1 of 7 functions -> 1 of 3 functions, with the same lines.A bundle that uses a decorator has 574 B more of
__decorateElement(392 B minified).Loud: an error where main ran
awaitor ayieldin a key, a decorator orextends, a computed key or a bigint key that names a class with no class decorator, or anew.targetin a field initializer or a static block of the class. (2) It is in a place that runs more often than its statement list. (3) It is evaluated more than once. (4) A decorated#privatemethod, getter or setter names a decorated#privatemember, itself included. (5) The object was made after the last evaluation. Then1,1,1->TypeError,TypeError,1, and#m in thisin that method givesfalse,false,truewith no error. The temporaries are shared there, as on main, so an object of an earlier evaluation runs the function of the last one. Main works because its WeakMaps are shared too. With condition 5 false, main throws as well. A test pins these results.new.targetin the initializer of a decorated#privatefield, in a class that is directly in an arrow function, with no instance field after it: the constructor ->ReferenceError: Can't find private variable: PrivateSymbol.newTargetLocalwhen the arrow function is entered. It is a field initializer now, and JavaScriptCore throws that for a hand-written#x = new.targettoo ([JSC] new.target in a class field initializer or a class static block throws a ReferenceError when the class is in an arrow function WebKit#647). js_parser: print new.target in a class field initializer or a static block as undefined #45656 printsundefinedthere.eval("arguments")in the initializer of a decorated#privatefield: main ran the initializer in the constructor, so it gave the arguments ofnew->SyntaxError: Unexpected identifier 'arguments', as in every field initializer.#x in oof a decorated name, in an arrow function in the head of afor (;;): main printed__privateIn->SyntaxError: Unexpected keyword 'in'at load. JavaScriptCore takes nointhere, for a name with no decorator on main too (Parser: allowininside arrow bodies, parameters and destructuring patterns nested in a for statement's init WebKit#429). The#x in oof the access function is inside a?:for this reason.#privatename that ends in a character that is not ASCII, with--minify-whitespace: the output has#πin oand does not parse. The printer puts no space there, for a hand-written#π in oon main too. js_printer: keep the space between a non-ASCII identifier and a keyword #41597 fixes the printer. Atest.todohas the case.(o?.#m)`x`andnew (o?.#C)()on a decorated member: main printed a helper call ->SyntaxError: Cannot use tagged templates in an optional chain.at load. The printer drops the parentheses, for a hand-written member on main too. js_printer: keep the parentheses a new callee or template tag needs #41638 fixes the printer.Main was broken; code that leaned on it will notice
varfor all evaluations. An object of an earlier evaluation ran the initializers and the decorated functions of the last one, and anaccessorthrewTypeError: Cannot read from private field-> each evaluation has its own (js_parser: declare decorator lowering temporaries per evaluation in parameter defaults and field initializers #38904, js_parser: declare decorator lowering temporaries per iteration inside loops (stacked on #38734) #38933). They areletin a loop body, for a class statement too. Where the statement list runs less often than the class, an arrow function around the class declares them, and(() => { ...; return { K: class {} }; })().Kgives the class its name again.#privatefield saw the parameters of the constructor:var x = "outer"; class A { @dec #x = x; constructor(x) {} }read the parameter ->"outer", the scope of the class.#x in ofor a decorated#xwas a WeakMap lookup -> the test of the engine.Costs
Release builds, one process for each cell, main and this branch in turn.
bench/snippets/decorators.mjsis new and has the same cases.@dec #x. The objects come from 1, 8, 9, 16, 32 evaluations of one class expression and meet at one call site#x@dec #m()@dec get #g()and@dec set #g(v)over a hand-written#s@dec accessor #x(five runs)context.accessof@dec #x:has,getandset, objects of 1, 8, 9, 16 evaluations@dec #m():hasandget@dec accessor #x@dec #xand 4 uses@dec #m()@dec accessor #x.jscof--bytecode,@dec #x.jsc,@dec accessor #x@dec #xand 0, 4, 12 uses of it@dec #m(), 0, 2, 4 uses@dec accessor #x, 0, 4, 12 uses.jsc,@dec #x, 0, 12, 40 uses@dec accessor #x, 0, 12, 40 uses@dec #x@dec #m() {}@dec accessor #x@dec #x: define it and make one object@dec #xand 4000 plain methods@dec #m() {}@dec accessor #x#m() {}@dec #x(user instructions)@dec #m() {}#xfunction f(K = class { accessor a = 1 }) {}(user instructions), and the bytes of the functionclass { @dec m() {} }bun test --coverage, two decorated#privatemethods that never run9-10,13-14bun test --coverage, a@dec accessor #idthat is only readsizetext, before the last round of this PR and on the same commit of main#privatename, up to 8. A class expression that is evaluated again has new names. So the cost starts at the ninth class of a mixin, for a hand-written#xtoo. Main's WeakMap does not pay it. [JSC] Private field get ICs should fold to megamorphic loads WebKit/WebKit#68043 adds a megamorphic cache for the read. The write and#x in ohave no tracker.#privatenames of a class for each function in the class body (BytecodeGenerator::getAvailablePrivateAccessNames). A decorated#xfield adds a name that each function of the class copies, as a hand-written#xdoes. A decorated#privatemethod is two functions there: its body, and thegetthat gives what the decorators returned. A decoratedaccessor #xis two there. With 8000 decorated#privatemethods the class takes 4.2 GB (a hand-written class with 8000#m() {}: 2.1 GB on both), and 11 to 64 s in a container that swaps. The copy has no tracker.--no-bundleoutput, from 4 to 12 uses in a minified bundle, and from about 40 uses in a.jsc.context.accessof a method, a getter or an accessor tests#m in othrough two more calls than the WeakSet test of main. A direct test in the helper is possible for more bytes in each bundle. It is not taken.Gains (same runs)
bun build --no-bundle, 3000 classes with@dec #x, 120 reads and writes each@dec #m()@dec accessor #xaccessor x,@dec accessor x,@dec x, a hand-written#x@dec #xnew,@dec #x@dec #x, one class@dec #m()@dec #m()@dec accessor #x, one class@dec #x, objects of 1, 8, 9, 16, 32 subclasses of one class@dec accessor #x@dec accessor #x(user instructions)@dec accessor x, objects of 1, 8, 9, 16, 32 evaluationsFixed
#privatemember that is not a plain read, a plain write or a call:++,--,+=,??=,||=,&&=, a destructuring target, the head of afor-ofor afor-in,o?.#x,o?.#m(),this.#x?.(), a tagged template,import(this.#x), a directeval, andsuperin the body of a decorated#privatemethod. The class of Signals Polyfill Typescript Decorator not Working #14529 (@signal accessor #value,this.#value++) prints1 0.WeakMaporWeakSetnext to a decorated#privatefield or method. The threeaccessorforms still use the global name, as on main (decision 3).Not changed
accessormember, decorated or not, and for every class statement with no decorated#privatemember, outside a loop body: 1610 classes (24 kinds of member in pairs, with and without a class decorator andextends) print byte-identical output with--no-bundle, as.jsand as.ts. In a bundle only the helper differs. A class expression of those kinds is the same too where it runs once. In a loop body the temporaries of a class arelet, and in the five kinds of place of decision 5 a class expression is in an arrow function.@dec declare #xand@dec abstract #xare the field#x, as on main. 38 rows of TypeScript syntax on a decorated#privatemember are in the tests. WithuseDefineForClassFields: falsea class in an instance field gets no name, as on main.super()as a statement of its own (if/else,try,const made = super(), an arrow function): every form that this PR fixes throwsReferenceErroratnew, as on main. js_parser: run lowered decorator effects after asuper()that is not a statement #42663 owns it.experimentalDecorators. A class expression with a decorateddeclareorabstractfield aborts there on main and here (panic: infallible: prop has value). js_parser: fix panic on decorated declare/abstract fields in class expressions with experimentalDecorators #38095 fixes it.toMatchObjecton anaccessor. Theusinglowering, the fold of{ f: x }.f,src/sourcemap_jsc/CodeCoverage.rsandsrc/ast/runtime.rs.Output for
class A extends B { @dec #count = 0; @dec #m() { return 1; } @dec accessor #a = 1; inc() { return this.#count++ + this.#m() + this.#a; } }(helper aliases and name counters removed):__decorateElement(src/runtime.js, +28 and -5 lines) gets the access function of the class as its target, with the firstcaseof the member in the flags above bit 4.context.access.getand.setof a field call it, and throw the TypeError of main for an object that does not have the field. For a method, a getter, a setter and an accessor it is thehasof the object that the helper tested as a WeakSet before. No export and no top-level statement is added, so each part of the runtime keeps its index and a bundle keeps itsdebugId. The old branch stays for--no-bundleoutput of an older Bun, and a test runs such output withcontext.access. The other way does not work:--no-bundleoutput of this branch on an older Bun runs every use, butcontext.access.hasand.getthrowTypeError: e.has is not a functionthere.i == 0 ? #count in o : 0is#count in o. The?:is there for two parsers: JavaScriptCore takes no bareinin an arrow function in the head of afor (;;)(Parser: allowininside arrow bodies, parameters and destructuring patterns nested in a for statement's init WebKit#429), and terser 5.16.0 to 5.40.0 throwsdid not find a shallow_cmp function for AST_PrivateInwhen twocases are the same up to a#x in o. With the?:, terser 5.16.0, 5.40.0 and 5.41.0 minify the class and the result runs.getandsetof a decorated accessor, public or#private, now read the WeakMap themselves and throw the same TypeError themselves. With__privateGetin that function a read and a write of@dec accessor #xtook 17 to 41 ns, where main takes 12 to 26 ns. JavaScriptCore also makes the branch that runs slow when the other branch calls a closure, so thethrowis written in place.bun test --coveragethe class calls its access function once with no case, so that a function of the lowering is not a function that never ran.code_coverageis a new parser feature for this, set from the option thatbun test --coveragealready sets, and part of the transpiler cache key. No other output has the call.scripts/build/codegen.ts:runtime.out.jsdepends onsrc/runtime.js. Without the line, an incremental build keeps the old helper. The hunk is the one of bun test: Vitest compatibility #44833. build: list the files the bake and runtime.out.js bundles read outside their globs #38096 adds the same edge.export default @dec classas a class declaration #44723 and bun test: Vitest compatibility #44833 take a new version too: the second to merge takes the next one.Other pull requests
lowers a long chain of additionsnow has eachthis.#pand no__privateGet.#privatemember #42651, Rewrite super property accesses in private methods extracted by decorator lowering #38769, js_parser: keepthisfor a tagged template through a decorated#privatemember #42677, js_parser: walk binary chains on the heap and check the stack when rewriting decorated #private accesses #44662 (they patch the deleted pass. Main has js_parser: walk binary chains on the heap and check the stack when rewriting decorated #private accesses #44662 through Hardening: fix crashes across the runtime, bundler, dev server, shell and CLI #46021), js_parser: declare decorator lowering temporaries per evaluation in parameter defaults and field initializers #38904, js_parser: declare decorator lowering temporaries per iteration inside loops (stacked on #38734) #38933 (temporaries for each evaluation), js_parser: name an anonymous decorated class assigned with ??=, ||= or &&= after the target #38924, js_parser: name an anonymous decorated class after the parameter or for loop variable it initializes #38906 (names).#privatekey. Not a bigint key). js_parser: put a loweredaccessor #xon the object before its first initializer #42643 (#p in thisfor a decoratedaccessor #p. An undecoratedaccessor #xis still the field#x). js_parser: declare temporaries hoisted out of enum initializers inside the enum closure #38761 (a decorated class in an enum initializer).new.targetin a field initializer or a static block asundefined. If it merges first, this PR dropsnew_target_count, thenew.targetclause of the first Loud case and the second Loud case. This PR sets the sameFnOnlyDataVisit::is_new_target_undefinedin the same two places.in), js_printer: keep the parentheses a new callee or template tag needs #41638 (the parentheses of a tag) and Keepthisundefined for call targets and template tags that a rewrite turned into a property access #40829 (thisof a folded tag) fix the printer and the fold for the cases under Loud and Silent that name them.lower_class_body), js_parser: print new.target in a class field initializer or a static block as undefined #45656 (three hunks in the visit pass), js_parser: parseexport default @dec classas a class declaration #44723 (lower_decorators.rsand the test file), js_parser: run lowered decorator effects after asuper()that is not a statement #42663 (lower_decorators.rs,p.rs), bundler: implement --keep-names with __name helper #40503 (visit_expr.rs), bun test: Vitest compatibility #44833 (the cache version line).{ f: () => {} }.fto the bare function #42585, js_parser: keep(0, value)where a fold would let a binding name an anonymous function or class #42691, js_parser: keep function, base class and staticnamenames through standard decorator lowering #42588, Accept and lower theaccessorkeyword in TypeScript files using experimentalDecorators #38125, js_parser: name an anonymous decorated export default class "default" #38758, js_parser: relocate the module-level declarations of a lowered top-levelusingout of its try #38284, Bind the JSX runtime and bun:wrap helpers with require() in CommonJS-wrapped modules #38012, Compare functions by identity in toMatchObject and Bun.deepMatch #38339, Treat Proxy as transparent in deep equality and toMatchObject #32948, parser: require the tsconfig options in ParserOptions::init, fix scanImports on decorators and accessor fields #45557, js_parser: fix panic on decorated declare/abstract fields in class expressions with experimentalDecorators #38095, js_parser: fix TypeScript experimental decorator lowering #40830, js_parser: validate TypeScript class member modifiers like esbuild #40817. The older ones of these were not merged here to check for a conflict.Tests.
es-decorators.test.tshas 621 tests, 7 of themtest.todo(the places of the first Loud case, the second Loud case, and the--minify-whitespacecase). On main 131 fail and 483 pass. The 330 cells of the matrix that was there before pass on main. New: a fixture of the members in three modes (.js,.ts, a bundle), one module for each of the 31 forms of use, 38 rows of TypeScript syntax, 27 places by 12 kinds of class for the evaluations, and 43 forms by 5 places by 6 kinds of class for the names, in three run modes. One test pins the results of the first Loud case, and one the messages for a wrong receiver. The eight tests of "private member calls in lowered classes" run as on main, and again with a decorator on the#privatemember.bundler_edgecase.test.tsis not touched: its sixStandardDecorator*tests pass, and the user bindings next to_m_fn,_at,oandvare in the member fixture here. The file takes 58 to 120 s in a debug build (main: 38 s).Suites. With the debug build, on main 9634520:
es-decorators.test.ts(614 pass, 7 todo),es-decorators-esbuild.test.ts(147),decorators.test.ts(25),decorator-metadata.test.ts,bundler_decorator_metadata.test.ts,test/cli/test/coverage.test.ts(36),transpiler.test.js(254),bundler_minify.test.ts(65),runtime-transpiler.test.ts,ts-use-define-for-class-fields.test.ts,ts-enum-redecl-panic.test.ts,react-compiler.test.ts(117),bundler_promiseall_deadcode.test.tsandcyclic-imports-async-bundler.test.js. With the release build:es-decorators.test.tsandbundler_edgecase.test.ts(188). In the debug build of this containeredgecase/DeepImportDiamondDAGof that file takes 72 s for its limit of 60 s: a release build makes that bundle of 20,000 modules in 0.9 s on main and on this branch, with the same instruction count and the same output (measured before the rebase).cargo clippyforbun_js_parserandbun_astis clean.Self-review. Five reviews. The fourth raised 34 concerns and the fifth 33, each on the revision before. Fixed in code after the fifth: the access function takes
ininside a?:(terser and the head of afor), the arrow function names its class through a plain key read off the call (Bun.inspect, a snapshot, and the name in a static initializer are main's again),context.accessthrows the messages of main from the helper, the coverage call is in the output ofbun test --coverageonly, and a class in an instance field gets no name underuseDefineForClassFields: false. Fixed after the fourth: the name under a constant computed key, the abort for@dec declare #x, thenew.targetrule, the method form for a body whosesuperorevalis not its own,context.accessof an accessor after the class is evaluated again, and the function count ofbun test --coverage. Stated here and not changed: decision 5, the six Loud cases, the fold names, the stack frame. Rejected, with the reason:yieldhas too.test.todorow passes then, and three rows withawaitoryieldthat pass today fail._m_fnin a static#privateslot of the class." It needs an inner name for the class. Withawaitoryieldthe lowering knows the name and__namecan put.nameback, but the inner name shows inBun.inspectand in a stack, it is a second way to print each member, and it does not help under a computed key or a bigint key.bun test --coverage." The rule that did it read the shape of the source map and was wrong for a plain bundle. Source positions: coverage, stack frames, call sites and source maps agree with the source #45677 has rewritten that function since.kindmatch inlower_class_bodyexhaustive." The match is main's. The line above it now turnsdeclareandabstractinto a field, which was the case that the wildcard hid.#xon main. The repro for each is in Costs, and a report for the engine is asked for apart from this PR.