Skip to content

Standard decorators: keep a decorated #private member a member of the class - #45351

Draft
robobun wants to merge 1 commit into
mainfrom
robobun/1f39b8d0/decorated-private-members
Draft

robobun wants to merge 1 commit into
mainfrom
robobun/1f39b8d0/decorated-private-members

Conversation

@robobun

@robobun robobun commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #31910

Problem

  • A standard decorator on a #private member breaks 26 of 31 uses. this.#count++ gives SyntaxError: 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

  • The name stays declared, so the engine runs every use. The pass is deleted.
  • Each evaluation of a class expression gets its own temporaries.
  • A class statement with no decorated #private member prints what main prints, outside a loop body: 1610 classes are byte-identical.
  • Verified: test/bundler/transpiler/es-decorators.test.ts (131 of 621 fail on main).

Background

Downsides

  • JavaScriptCore has no megamorphic cache for #private names. A mixin on 9 or more classes: one read and write of @dec #x costs 26 to 59 ns (main: 12 ns).
  • Each function of a class copies its #private names. 30 decorated #private methods cost 216 KB (main: 118 KB).
  • Six cases throw where main ran: Notes, "Loud".
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, with BUN_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

  1. The layer. The ask was js_parser: rewrite every assignment target of a lowered #private member #42651 rebased with the super rewrite of Rewrite super property accesses in private methods extracted by decorator lowering #38769. This PR keeps the decorated #private name 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 #private member #42651 with Rewrite super property accesses in private methods extracted by decorator lowering #38769 keeps the rewrite pass and adds five bun:wrap exports (__privateWrapper and four __super* helpers). It leaves 7 of the 31 forms: o?.#x, o?.#m(), this.#x?.(), two tagged templates (js_parser: keep this for a tagged template through a decorated #private member #42677 has those), import(this.#x) and a direct eval. That pair is not built here: the super rewrite 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; }.
  2. super in the body of a decorated #private method. A body that has a super or a direct eval of its own stays a method of the class, under a temporary key. The leading static block reads the function and deletes the key, as lower_class_body does for its __decorators member on main. Next to a computed key the temporary key is a new symbol from $$typeof.constructor(), because the file can declare Symbol. Every other body is a function expression, as on main: also a body where the super belongs to a nested class or object, and a body next to an eval in another member. To drop it: remove the HasSuperOrDirectEval arm, Host, take_host, new_symbol_once and their tests. The form then stays the SyntaxError that it is on main and in the output of tsc 6.0.2.
  3. The value of @dec accessor #x stays in a WeakMap, as the value of every accessor does on main. Only the name #x is new: a get and a set of 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), and toMatchObject compares the value. The price of the WeakMap: the initializer of every accessor still runs in the constructor, and a scope that declares WeakMap still breaks the three accessor forms, both as on main.
  4. The name of a class with a class decorator. An arrow function around a class is between the class and what names it, so the visit pass now gives the lowering the name at every place that names an anonymous class. A class decorator gets that name too (first bullet of Silent). To drop it: a class that has a class decorator takes no name at the new places, which is a test of ts_decorators at 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 of visit_expr.rs.
  5. Each evaluation has its own temporaries, for every class that the lowering rewrites. Only a class with a decorated #private method, 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 only accessor a or @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 its var temporaries". The price is in Silent (a stack frame, the fold names) and in Costs (the arrow function). To drop it: lower_class_body returns whether the class has such a member, and temps_kind() and the arrow function ask for it (7 lines by hand). Then @dec accessor a throws TypeError: Cannot read from private field again in those places, the place tests of this PR fail as written for the four kinds of class with no decorated #private member, 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

  • .name and context.name of an anonymous class with a class decorator were "" at ??=, ||=, &&=, a parameter default, a for declaration, a numeric key, a #private key 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-in or a for-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 or extends. Also for class { accessor a } with no decorator. .name, Bun.inspect and a snapshot of the class and of its objects are as on main.

  • In those places, the .name of 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. So x = true ? class { accessor a } : 0, and the same with null ??, 0 ||, (0, C), [C][0] or a condition that --define folds: "x" -> "". x = { k: class { accessor a } }.k: "x" -> "k". A plain class {} there keeps main's name. js_parser: do not fold { f: () => {} }.f to 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 #private member as a template tag behind something that the visit pass folds: (0, this.#t)`x`, a const t = this.#t that is used once, or a constant ?:. this in the tag function: undefined -> the object. The fold makes the tag a member access, and a hand-written #t gets the object on main too. Keep this undefined for call targets and template tags that a rewrite turned into a property access #40829 fixes the fold.

  • #a in this before the initializer of @dec accessor #a ran: false -> true, as for a get #a() (part of js_parser: put a lowered accessor #x on the object before its first initializer #42643).

  • new.target in the initializer of a decorated #private field 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.access and a decorated accessor keep the messages of main, and a test pins them.

    Use main now
    read or write of a field Cannot read from private field, Cannot write to private field Cannot access invalid private field (evaluating 'o.#x')
    a method, getter, setter or accessor Cannot access private method, Cannot read from private field Cannot access private method or acessor (evaluating 'o.#m') (the spelling of the engine)
    the same, static the same Cannot access static private method or accessor
    a null receiver the same null is not an object (evaluating 'o.#x')
    a write to a decorated #m() t.set is not a function Trying to access an undefined private setter
    a read of a name that has only a setter Cannot read from private field Trying to access an undefined private getter
  • A write to a decorated set #s: the stack had one frame in bun:wrap -> none. The TypeError of a decorated accessor x for an object that has no value: two or three frames in bun:wrap -> one.

  • A decorated #private method with a direct eval in its body: its decorators got a function expression -> a method, which has no prototype and which new cannot call. With super in the body main does not load.

  • toMatchObject, objectContaining and Bun.deepMatch compare #private fields, for a hand-written #x on main too (PrivateSymbolMode::Include in Bun__deepMatch). So they now compare the value of a decorated #x field: Bun.deepMatch(new C(1), new C(2)) was true -> false. Not the value of a decorated accessor #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 #private field.

  • An error that the initializer of a decorated #private field throws, where no instance field follows: a release build reported (evaluating 'load().value.deep') at file.js:5:20 -> (near '...constructor() {...') at file.js:3:1, the line of the class. It is a field initializer now, and the engine reports a plain field initializer as 1:11 on main.

  • bun test --coverage counts the functions that the lowering adds. A file that uses each decorated #private member is at 100% of its functions as on main: with --coverage the 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

  • Shared temporaries. Five conditions. (1) A class expression that the lowering cannot put an arrow function around: an await or a yield in a key, a decorator or extends, a computed key or a bigint key that names a class with no class decorator, or a new.target in 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 #private method, getter or setter names a decorated #private member, itself included. (5) The object was made after the last evaluation. Then 1,1,1 -> TypeError,TypeError,1, and #m in this in that method gives false,false,true with 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.target in the initializer of a decorated #private field, 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.newTargetLocal when the arrow function is entered. It is a field initializer now, and JavaScriptCore throws that for a hand-written #x = new.target too ([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 prints undefined there.
  • eval("arguments") in the initializer of a decorated #private field: main ran the initializer in the constructor, so it gave the arguments of new -> SyntaxError: Unexpected identifier 'arguments', as in every field initializer.
  • A hand-written #x in o of a decorated name, in an arrow function in the head of a for (;;): main printed __privateIn -> SyntaxError: Unexpected keyword 'in' at load. JavaScriptCore takes no in there, for a name with no decorator on main too (Parser: allow in inside arrow bodies, parameters and destructuring patterns nested in a for statement's init WebKit#429). The #x in o of the access function is inside a ?: for this reason.
  • A decorated #private name that ends in a character that is not ASCII, with --minify-whitespace: the output has #πin o and does not parse. The printer puts no space there, for a hand-written #π in o on main too. js_printer: keep the space between a non-ASCII identifier and a keyword #41597 fixes the printer. A test.todo has the case.
  • (o?.#m)`x` and new (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

  • A class expression that is evaluated more than once (a loop body, a parameter default, an instance field initializer, the head of a loop, an enum initializer): the temporaries were one var for all evaluations. An object of an earlier evaluation ran the initializers and the decorated functions of the last one, and an accessor threw TypeError: 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 are let in 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 {} }; })().K gives the class its name again.
  • The initializer of a decorated #private field 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 o for a decorated #x was 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.mjs is new and has the same cases.

What main now
One read and one write of @dec #x. The objects come from 1, 8, 9, 16, 32 evaluations of one class expression and meet at one call site 6.0, 12.4, 11.6, 11.4, 11.4 ns 0.9, 8.4, 25.8, 49.9, 59.4 ns
Same, a hand-written #x 0.9, 8.4, 25.5, 45.6, 60.1 ns the same
Same, @dec #m() 2.1, 6.0, 6.0, 6.1, 6.1 ns 0.5, 7.7, 7.9, 10.5, 11.3 ns
Same, @dec get #g() and @dec set #g(v) over a hand-written #s 5.3, 14.8, 29.5, 50.2, 65.0 ns 0.7, 18.3, 32.0, 60.5, 81.2 ns
Same, @dec accessor #x (five runs) 12 to 13, 22 to 23, 22 to 26, 22 to 26, 22 to 24 ns 4, 23 to 27, 23 to 28, 24 to 25, 28 to 40 ns
context.access of @dec #x: has, get and set, objects of 1, 8, 9, 16 evaluations 7.1, 12.4, 13.0, 12.3 ns 5.1, 13.7, 28.4, 45.4 ns
Same, @dec #m(): has and get 3.6, 5.7, 5.7, 5.8 ns 6.9, 15.1, 15.5, 24.9 ns
Same, @dec accessor #x 14.4, 22.9, 22.7, 22.5 ns 14.6, 28.2, 27.2, 33.3 ns
First load of a module, user instructions for each class with 1 and with 10 @dec #x and 4 uses 385 K, 1423 K 424 K, 1587 K
Same, @dec #m() 398 K, 1623 K 433 K, 1714 K
Same, @dec accessor #x 432 K, 1706 K 509 K, 2408 K
Same from a .jsc of --bytecode, @dec #x 57 K, 186 K 66 K, 211 K
Same from a .jsc, @dec accessor #x 63 K, 251 K 77 K, 384 K
Bytes of a minified bundle, 300 classes with one @dec #x and 0, 4, 12 uses of it 54,467, 87,230, 153,111 B 76,025, 101,777, 153,883 B
Same, @dec #m(), 0, 2, 4 uses 57,798, 75,912, 94,259 B 71,256, 79,782, 88,608 B
Same, @dec accessor #x, 0, 4, 12 uses 61,034, 110,596, 210,071 B 98,611, 124,363, 176,469 B
Bytes of the .jsc, @dec #x, 0, 12, 40 uses 274,656, 368,920, 570,344 B 473,952, 501,136, 557,312 B
Same, @dec accessor #x, 0, 12, 40 uses 289,552, 445,728, 1,799,064 B 518,400, 551,616, 609,808 B
RSS for each class, 1000 classes with 1, 3, 10, 30 @dec #x 20, 31, 53, 94 KB 27, 38, 60, 116 KB
Same, @dec #m() {} 22, 32, 58, 118 KB 27, 39, 76, 216 KB
Same, @dec accessor #x 24, 35, 58, 128 KB 30, 44, 96, 282 KB
One class with 8000 @dec #x: define it and make one object 0.22 s, 86 MB 0.22 s, 85 MB
One class with 4000 @dec #x and 4000 plain methods 0.12 s, 70 MB 1.1 s, 590 MB
The same class with no decorator 0.7 to 0.9 s, 556 MB the same
One class with 4000 @dec #m() {} 0.12 s, 66 MB 2.1 to 3.3 s, 1.1 GB
One class with 4000 @dec accessor #x 0.14 s, 76 MB 1.7 to 2.4 s, 1.1 GB
One hand-written class with 4000 #m() {} 0.6 to 0.9 s, 550 MB the same
Evaluate one class expression again, 1, 10, 100 @dec #x (user instructions) 5012, 11176, 119570 5662, 16980, 163376
Same, @dec #m() {} 6454, 24654, 235703 7113, 26434, 245171
Same, a hand-written #x 1824, 5586, 42605 the same
One call of function f(K = class { accessor a = 1 }) {} (user instructions), and the bytes of the function 1537, 289 B 1716, 353 B
Same, class { @dec m() {} } 5195, 376 B 6402, 438 B
bun test --coverage, two decorated #private methods that never run 2 of 4 functions, lines 9-10,13-14 3 of 7, the same lines
bun test --coverage, a @dec accessor #id that is only read 5 of 7 functions 7 of 10, the same lines
Release binary, size text, before the last round of this PR and on the same commit of main 106,687,338 B 106,711,658 B
  • The engine keeps one inline cache entry for each #private name, 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 #x too. 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 o have no tracker.
  • The engine copies the #private names of a class for each function in the class body (BytecodeGenerator::getAvailablePrivateAccessNames). A decorated #x field adds a name that each function of the class copies, as a hand-written #x does. A decorated #private method is two functions there: its body, and the get that gives what the decorators returned. A decorated accessor #x is two there. With 8000 decorated #private methods 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.
  • Output is larger than main's for a member with few uses, and smaller from 2 to 4 uses in --no-bundle output, from 4 to 12 uses in a minified bundle, and from about 40 uses in a .jsc.
  • context.access of a method, a getter or an accessor tests #m in o through 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)

What main now
The 31 forms of the test table that fail 26 0
bun build --no-bundle, 3000 classes with @dec #x, 120 reads and writes each 32,359,911 B, 723,006 helper calls 12,351,723 B, 0
Same, @dec #m() 26,780,238 B 7,550,277 B
Same, @dec accessor #x 45,800,818 B 12,966,540 B
Same, accessor x, @dec accessor x, @dec x, a hand-written #x byte-identical
Retained for each object, @dec #x 117.7 B 43.8 B
1e6 new, @dec #x 314 ms 12 ms
5e6 read and write, @dec #x, one class 28 to 50 ms 3.3 ms
Retained for each object, @dec #m() 96.7 B 75.7 B
5e6 calls, @dec #m() 7.6 to 9.2 ms 1.5 ms
5e6 read and write, @dec accessor #x, one class 57 to 78 ms 31 to 33 ms
One read and one write of @dec #x, objects of 1, 8, 9, 16, 32 subclasses of one class 5.9, 8.1, 11.1, 13.2, 12.2 ns 0.8, 2.8, 4.3, 2.8, 3.2 ns
Same, @dec accessor #x 11.9, 13.7, 13.6, 14.8, 16.5 ns 4.4, 7.9, 9.0, 11.1, 13.6 ns
Evaluate one class expression again, 1, 10, 100 @dec accessor #x (user instructions) 9578, 58937, 611749 9250, 52800, 545416
One read and one write of a public @dec accessor x, objects of 1, 8, 9, 16, 32 evaluations 6.6, 28.4, 28.8, 43.4, 56.2 ns 4.7, 26.0, 26.3, 42.2, 54.0 ns

Fixed

  • Every use of a decorated #private member that is not a plain read, a plain write or a call: ++, --, +=, ??=, ||=, &&=, a destructuring target, the head of a for-of or a for-in, o?.#x, o?.#m(), this.#x?.(), a tagged template, import(this.#x), a direct eval, and super in the body of a decorated #private method. The class of Signals Polyfill Typescript Decorator not Working #14529 (@signal accessor #value, this.#value++) prints 1 0.
  • A scope that declares WeakMap or WeakSet next to a decorated #private field or method. The three accessor forms still use the global name, as on main (decision 3).

Not changed

  • The output for a public accessor member, decorated or not, and for every class statement with no decorated #private member, outside a loop body: 1610 classes (24 kinds of member in pairs, with and without a class decorator and extends) print byte-identical output with --no-bundle, as .js and 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 are let, and in the five kinds of place of decision 5 a class expression is in an arrow function.
  • @dec declare #x and @dec abstract #x are the field #x, as on main. 38 rows of TypeScript syntax on a decorated #private member are in the tests. With useDefineForClassFields: false a class in an instance field gets no name, as on main.
  • A derived class whose constructor has no super() as a statement of its own (if/else, try, const made = super(), an arrow function): every form that this PR fixes throws ReferenceError at new, as on main. js_parser: run lowered decorator effects after a super() that is not a statement #42663 owns it.
  • experimentalDecorators. A class expression with a decorated declare or abstract field 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.
  • toMatchObject on an accessor. The using lowering, the fold of { f: x }.f, src/sourcemap_jsc/CodeCoverage.rs and src/ast/runtime.rs.
  • The cost of a file with no decorator: a bundle of 20,000 modules takes the same user instructions (1.57e9) and prints the same bytes on main and on this branch.

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):

var _init, _base, _dec, _at, _dec2, _m_fn, _dec3, _a, _a_acc;
class A extends (_base = B) {
  constructor() {
    super(...arguments);
    __runInitializers(_init, 15, this);
    __privateAdd(this, _a, __runInitializers(_init, 8, this, 1));
    __runInitializers(_init, 11, this);
  }
  static {
    _a = new WeakMap;
    _at = (i, o, v) => {
      switch (i) {
        case 0: return i == 0 ? #count in o : 0;
        case 1: return o.#count;
        case 2: return o.#count = v;
        case 3: return i == 3 ? #m in o : 0;
        case 4: return i == 4 ? #a in o : 0;
      }
    };
    _init = __decoratorStart(_base);
    _m_fn = __decorateElement(_init, 113, "#m", _dec2, _at, function() {
      return 1;
    });
    _a_acc = __decorateElement(_init, 148, "#a", _dec3, _at, _a);
    __decorateElement(_init, 21, "#count", _dec, _at);
    __decoratorMetadata(_init, this);
  }
  #count = (__runInitializers(_init, 5, this), __runInitializers(_init, 12, this, 0));
  get #m() {
    return _m_fn;
  }
  get #a() {
    return _a_acc.get.call(this);
  }
  set #a(v) {
    return _a_acc.set.call(this, v);
  }
  [(_dec = [dec], _dec2 = [dec], _dec3 = [dec], "inc")]() {
    return this.#count++ + this.#m() + this.#a;
  }
}

Other pull requests

Tests. es-decorators.test.ts has 621 tests, 7 of them test.todo (the places of the first Loud case, the second Loud case, and the --minify-whitespace case). 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 #private member. bundler_edgecase.test.ts is not touched: its six StandardDecorator* tests pass, and the user bindings next to _m_fn, _at, o and v are 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.ts and cyclic-imports-async-bundler.test.js. With the release build: es-decorators.test.ts and bundler_edgecase.test.ts (188). In the debug build of this container edgecase/DeepImportDiamondDAG of 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 clippy for bun_js_parser and bun_ast is 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 in inside a ?: (terser and the head of a for), 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.access throws the messages of main from the helper, the coverage call is in the output of bun test --coverage only, and a class in an instance field gets no name under useDefineForClassFields: false. Fixed after the fourth: the name under a constant computed key, the abort for @dec declare #x, the new.target rule, the method form for a body whose super or eval is not its own, context.access of an accessor after the class is evaluated again, and the function count of bun test --coverage. Stated here and not changed: decision 5, the six Loud cases, the fold names, the stack frame. Rejected, with the reason:

  • "Wrap a class that a computed key names, and name it through a key temporary." The key has to be converted once and kept for as long as the outer member lives. The case that is left is the first one of Loud, which yield has too.
  • "Put the arrow function around the expression of the place, not around the class." One test.todo row passes then, and three rows with await or yield that pass today fail.
  • "Keep _m_fn in a static #private slot of the class." It needs an inner name for the class. With await or yield the lowering knows the name and __name can put .name back, but the inner name shows in Bun.inspect and 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.
  • "Skip the functions of the lowering in 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.
  • "Make the kind match in lower_class_body exhaustive." The match is main's. The line above it now turns declare and abstract into a field, which was the case that the wildcard hid.
  • "Fold js_parser: print new.target in a class field initializer or a static block as undefined #45656, js_printer: keep the space between a non-ASCII identifier and a keyword #41597 and js_printer: keep the parentheses a new callee or template tag needs #41638 into this PR." Each is open with its own tests and fixes a bug that main has with no decorator.
  • "File trackers for the two engine costs before the merge." They are costs of a hand-written #x on main. The repro for each is in Costs, and a report for the engine is asked for apart from this PR.

@robobun

robobun commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status of #45351

  • Reproduction: one module for each form of use of a decorated #private member, run with and without the decorator (the table privateUses in test/bundler/transpiler/es-decorators.test.ts). On main 26 of the 31 forms differ from the undecorated class: 21 are a SyntaxError at load or in eval, 3 a TypeError, 2 a wrong value. With this branch none differs.
  • The class of Signals Polyfill Typescript Decorator not Working #14529 (@signal accessor #value, this.#value++) is a test in the same file.
  • bun bd test test/bundler/transpiler/es-decorators.test.ts: 614 pass and 7 todo with this branch. With the src/ of main, 131 of the 621 tests fail.
  • Pull request: Standard decorators: keep a decorated #private member a member of the class #45351 (head 5f97c3e, one commit on main 9634520).

@robobun

robobun commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:13 PM PT - Oct 21st, 2026

✅ @robobun, your commit 5f97c3e00ae7934ef397b67c75af36c2087f5943 passed in Build #130234! 🎉


🧪   To try this PR locally:

bunx bun-pr 45351

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

bun-45351 --bun

@robobun
robobun force-pushed the robobun/1f39b8d0/decorated-private-members branch from b3ecc9a to e9de9f9 Compare October 15, 2026 15:53
Comment thread src/ast/lib.rs Outdated
Comment thread src/js_parser/lower/lower_decorators.rs Outdated
Comment thread src/js_parser/lower/lower_decorators.rs Outdated
Comment thread src/js_parser/lower/lower_decorators.rs Outdated
Comment thread src/js_parser/lower/lower_decorators.rs Outdated
Comment thread src/js_parser/lower/lower_decorators.rs Outdated
Comment thread src/js_parser/lower/lower_decorators.rs Outdated
Comment thread src/js_parser/lower/lower_decorators.rs Outdated
Comment thread src/js_parser/lower/lower_decorators.rs Outdated
Comment thread src/js_parser/lower/lower_decorators.rs Outdated
Comment thread src/js_parser/lower/lower_decorators.rs Outdated
Comment thread src/js_parser/lower/lower_decorators.rs Outdated
Comment thread src/js_parser/lower/lower_decorators.rs Outdated
Comment thread src/js_parser/lower/lower_decorators.rs Outdated
Comment thread src/js_parser/lower/lower_decorators.rs Outdated
Comment thread src/js_parser/lower/lower_decorators.rs Outdated
Comment thread src/js_parser/lower/lower_decorators.rs Outdated
Comment thread src/js_parser/lower/lower_decorators.rs Outdated
Comment thread src/js_parser/lower/lower_decorators.rs Outdated
Comment thread src/js_parser/lower/lower_decorators.rs Outdated
Comment thread src/js_parser/lower/lower_decorators.rs Outdated
Comment thread src/js_parser/lower/lower_decorators.rs Outdated
Comment thread src/js_parser/lower/lower_decorators.rs Outdated
Comment thread src/js_parser/lower/lower_decorators.rs Outdated
Comment thread src/js_parser/lower/lower_decorators.rs Outdated
Comment thread src/js_parser/p.rs Outdated
Comment thread src/js_parser/p.rs Outdated
Comment thread src/js_parser/p.rs Outdated
Comment thread src/js_parser/visit/mod.rs Outdated
Comment thread src/js_parser/visit/mod.rs Outdated
Comment thread src/js_parser/visit/mod.rs Outdated
Comment thread src/js_printer/renamer.rs Outdated
Comment thread src/runtime.js Outdated
Comment thread src/runtime.js Outdated
Comment thread src/sourcemap_jsc/CodeCoverage.rs Outdated
Comment thread src/sourcemap_jsc/CodeCoverage.rs Outdated
@robobun
robobun force-pushed the robobun/1f39b8d0/decorated-private-members branch from e9de9f9 to 1d8a4ad Compare October 15, 2026 16:23
@robobun
robobun force-pushed the robobun/1f39b8d0/decorated-private-members branch from 1d8a4ad to 005660d Compare October 20, 2026 00:54
@robobun robobun changed the title Standard decorators: keep a decorated #private member and accessor storage as members of the class Standard decorators: keep a decorated #private member a member of the class Oct 20, 2026
@robobun
robobun force-pushed the robobun/1f39b8d0/decorated-private-members branch from 005660d to 3eb4b1f Compare October 20, 2026 01:56
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
robobun force-pushed the robobun/1f39b8d0/decorated-private-members branch from 3eb4b1f to 5f97c3e Compare October 21, 2026 16:57
dylan-conway added a commit that referenced this pull request Oct 21, 2026
…the decorated class (#45435)

Fixes #44915

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

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

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

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

<details><summary>Notes</summary>

Lowered output of the issue repro after this PR:

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

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

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

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

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

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

---

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

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

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

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

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

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

- Expected  - 1
+ Received  + 1

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

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

</details>

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

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

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

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

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

23 deps, 146 codegen, 1176 objects in 3551ms

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

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

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

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

</details>

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

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

</details>

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

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

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

</details>

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

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

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

---------

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

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Optional chains through lowered private members do not short-circuit in decorated classes

2 participants