Skip to content

js_parser: put a lowered accessor #x on the object before its first initializer - #42643

Open
robobun wants to merge 3 commits into
mainfrom
robobun/6f21d4fd/private-accessor-brand
Open

robobun wants to merge 3 commits into
mainfrom
robobun/6f21d4fd/private-accessor-brand

Conversation

@robobun

@robobun robobun commented Sep 13, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • class U { accessor #p = (this.seen = #p in this) }; new U().seen is false on main (a22b2aa) and true on 1.4.2. accessor #p is a private getter and setter, which an object has before its first field initializer runs.
  • js_parser: lower standard decorators without moving class members #40833 prints an undecorated accessor #x as the field #x (src/js_parser/lower/lower_decorators.rs:861). @dec accessor #x uses one WeakMap as brand and storage, filled where the accessor is written (:952). class D { a = #p in this; @dec accessor #p = 1 } gives false too.

Fix

  • Undecorated: print get #x / set #x over a new private field, #x_accessor_storage, at the place of the accessor. All three are native: the engine installs the pair first, and the field behaves as the #x field of main does.
  • Decorated: #x becomes a WeakSet brand that the object joins with the brands of private methods, before its first field. A second WeakMap holds the value. __decorateElement already takes the two as (target, extra).
  • Self-reviewed: 2 concerns held up, both addressed (first two Notes).
  • Verified: test/bundler/transpiler/es-decorators.test.ts: 4 new fixture keys in 3 modes (9 of 12 fail without the fix) and 1 test for the new field. Also es-decorators-esbuild, decorators, decorator-metadata.

Background

  • accessor x (decorators proposal) declares a getter, a setter and hidden storage. JavaScriptCore does not parse it, so bun always lowers it.
  • #x in obj asks whether obj has the private members of the class: the brand check.
  • Bun turns a #private name with a decorated member into a WeakMap or WeakSet that the helper __decorateElement can reach. #x in o becomes __privateIn(_x, o).
Notes

Output for class U { accessor #p = (this.seen = #p in this); m() {} }:

class U {
  get #p() { return this.#p_accessor_storage; }
  set #p(v) { this.#p_accessor_storage = v; }
  #p_accessor_storage = this.seen = #p in this;
  m() {}
}

Output for class D2 { a = #p in this; @dec accessor #p = 1 }:

class D2 {
  constructor() {
    __privateAdd(this, _p_storage, __runInitializers(_init, 8, this, 1));
    __runInitializers(_init, 11, this);
  }
  static {
    _p = new WeakSet;
    _p_storage = new WeakMap;
    _init = __decoratorStart(undefined);
    _p_acc = __decorateElement(_init, 20, "#p", _dec, _p, _p_storage);
    __decoratorMetadata(_init, this);
  }
  [(_dec = [dec], "a")] = (__privateAdd(this, _p), __runInitializers(_init, 5, this), __privateIn(_p, this));
}
  • The name of the new field: #x_accessor_storage, with a number appended while the class or a class around it declares that name (a reference from inside this class to such a name would reach the new field). The symbol is registered in the scope around the class, so --minify renames it with the other private names of a class inside a function. A class at the top level of a module keeps the long name.
  • The new fixture section compares four shapes against the pair written by hand (#v = init; get #p() {} set #p(v) {}), instance and static: the accessor alone, next to a decorated method, and decorated. Each class reads #p in this, this.#p and this.#p = 2 from an earlier initializer and from the accessor's own. All rows must equal the hand-written row.
  • A getter or setter that a decorator returns now runs before the storage exists, and context.access.has is true there (privateAccessorReplaced). Before, both threw TypeError: Cannot read from private field.
  • privateAccessorTwice (a constructor that returns an existing object, run twice) passes with and without the fix. It pins that the new WeakSet brand throws on the second add, as the native pair does.
  • An anonymous function in the initializer of an undecorated accessor #p keeps the name #p, as on main: the field would name it #p_accessor_storage, so the initializer goes through hosted_initializer.
  • Not changed: accessor x = () => {} and @dec f = () => {} do not name the function after the member. js_parser: keep function, base class and static name names through standard decorator lowering #42588 is open for that.
  • Same results through bun build --minify and --target=browser, and with useDefineForClassFields: false.
  • RuntimeTranspilerCache version 32 to 33, because the printed code for the same input changes.
  • Not changed: accessor x and @dec accessor #x keep their value in a WeakMap, so the limits in the first note still apply to them (js_parser: declare decorator lowering temporaries per iteration inside loops (stacked on #38734) #38933 and js_parser: declare decorator lowering temporaries per evaluation in parameter defaults and field initializers #38904 are open for the shared temporaries).
  • Not changed: this.#x++, this.#x += v and the other forms on a decorated #x that js_parser: lower standard decorators without moving class members #40833 lists as open (js_parser: rewrite every assignment target of a lowered #private member #42651 is open for them). They work on an undecorated accessor #x, because that pair is native.

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

fails on main (without fix)
ASAN without fix: 9 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bundler/transpiler/es-decorators.test.ts
bun test v1.4.3 (b99371011)

test/bundler/transpiler/es-decorators.test.ts:
(pass) ES Decorators > class decorators > basic class decorator [562.29ms]
(pass) ES Decorators > class decorators > class decorator receives correct context [435.91ms]
(pass) ES Decorators > class decorators > class decorator can replace class [546.75ms]
(pass) ES Decorators > class decorators > multiple class decorators apply in reverse order [434.09ms]
(pass) ES Decorators > method decorators > instance method decorator [346.44ms]
(pass) ES Decorators > method decorators > static method decorator [497.95ms]
(pass) ES Decorators > method decorators > method decorator context has correct access [320.19ms]
(pass) ES Decorators > getter decorators > getter decorator [430.59ms]
(pass) ES Decorators > setter decorators > setter decorator [401.48ms]
(pass) ES Decorators > field decorators > field decorator receives undefined value [339.98ms]
(pass) ES Decorators > field decorators > multiple field decorators [455.52ms]
(
... (truncated)

release without fix: 365 FAILED
bun test v1.4.3-canary.1 (b99371011)

test/bundler/transpiler/es-decorators.test.ts:
(pass) ES Decorators > class decorators > basic class decorator [9.12ms]
(pass) ES Decorators > class decorators > class decorator receives correct context [9.93ms]
(pass) ES Decorators > class decorators > class decorator can replace class [8.58ms]
(pass) ES Decorators > class decorators > multiple class decorators apply in reverse order [7.31ms]
(pass) ES Decorators > method decorators > instance method decorator [7.81ms]
(pass) ES Decorators > method decorators > static method decorator [9.11ms]
(pass) ES Decorators > method decorators > method decorator context has correct access [9.80ms]
(pass) ES Decorators > getter decorators > getter decorator [9.75ms]
(pass) ES Decorators > setter decorators > setter decorator [8.82ms]
(pass) ES Decorators > field decorators > field decorator receives undefined value [10.23ms]
(pass) ES Decorators > field decorators > multiple field decorators [15.18ms]
(pass) ES Decorators > field decorators > static field decorator [8.51ms]
(pass) ES Decorators > non-ASCII string-literal keys > Bun.Transpiler output preserves the key [0.74ms]
(pass) ES De
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bundler/transpiler/es-decorators.test.ts
bun test v1.4.3 (b99371011)

test/bundler/transpiler/es-decorators.test.ts:
(pass) ES Decorators > class decorators > basic class decorator [365.92ms]
(pass) ES Decorators > class decorators > class decorator receives correct context [324.76ms]
(pass) ES Decorators > class decorators > class decorator can replace class [401.98ms]
(pass) ES Decorators > class decorators > multiple class decorators apply in reverse order [338.97ms]
(pass) ES Decorators > method decorators > instance method decorator [345.37ms]
(pass) ES Decorators > method decorators > static method decorator [409.48ms]
(pass) ES Decorators > method decorators > method decorator context has correct access [355.28ms]
(pass) ES Decorators > getter decorators > getter decorator [414.98ms]
(pass) ES Decorators > setter decorators > setter decorator [463.01ms]
(pass) ES Decorators > field decorators > field decorator receives undefined value [469.09ms]
(pass) ES Decorators > field decorators > multiple field decorators [431.03ms]
(
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     dcf617336e
  features     baseline

23 deps, 131 codegen, 1176 objects in 897ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1248] gen ErrorCode+*.h
[2/1248] gen bindgenv2
[3/1248] install /workspace/bun
bun install v1.4.3-canary.1 (b99371011)

Checked 22 installs across 61 packages (no changes) [48.00ms]
[4/1248] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[5/1248] fetch tinycc
[tinycc] up to date
[6/1247] install /workspace/bun/packages/bun-error
bun install v1.4.3-canary.1 (b99371011)

Checked 1 install across 2 packages (no changes) [1.00ms]
[7/1247] fetch zlib
[zlib] up to date
[8/1247] install /workspace/bun/src/node-fallbacks
bun install v1.4.3-canary.1 (b99371011)

Checked 111 installs across 104 packages (no changes) [49.00ms]
[9/1247] gen node-fallbacks/react-refresh.js
Bundled 1 module in 7ms

  react-refresh.js  4.81 KB  (entry point)

[10/1247] gen bake.{client,server,error}.js
-> bake.client.js, bake.server.js, bake.error.js
[11/1247] gen .bind.ts → Ge
... (truncated)
diff hotspot
src/js_parser/lower/lower_decorators.rs       | 217 ++++++++++++++++++++++----
 src/jsc/RuntimeTranspilerCache.rs             |   3 +-
 test/bundler/transpiler/es-decorators.test.ts |  68 ++++++++
 3 files changed, 256 insertions(+), 32 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                           reads  edits  tests
src/js_parser/lower/lower_decorators.rs           11     14     23
src/jsc/RuntimeTranspilerCache.rs                  1      2     23
test/bundler/transpiler/es-decorators.test.ts      6      7     23

… initializer

`accessor #x` is a private getter and setter over separate storage. The
pair is on the object before the first field initializer runs, so
`#x in this` is true from the start of construction. The storage is
defined where the accessor is written.

The undecorated form was printed as the field `#x`, and the decorated
form used one WeakMap as brand and storage. Both made the name exist only
once the initializer of the accessor had run.

The undecorated form is `get #x` and `set #x` over a WeakMap again. The
decorated form has a WeakSet brand that the object joins with the brands
of private methods, next to the WeakMap of its value.
@coderabbitai

coderabbitai Bot commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

On-demand reviews are free for the next 7 days. After that, they cost $0.25 per reviewed file.

Or wait 46 seconds for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 85e72dfc-a573-4e4d-b2b3-46b220f74a2f

📥 Commits

Reviewing files that changed from the base of the PR and between 09bb546 and dcf6173.

📒 Files selected for processing (3)
  • src/js_parser/lower/lower_decorators.rs
  • src/jsc/RuntimeTranspilerCache.rs
  • test/bundler/transpiler/es-decorators.test.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Sep 13, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

How I reproduced it, on a debug build of main a22b2aa:

const dec = (v, c) => {};
class N { #q = (this.seen = #p in this); get #p() { return 1 } set #p(v) {} }
class U { accessor #p = (this.seen = #p in this); m() {} }
class D { accessor #p = (this.seen = #p in this); @dec m() {} }
console.log(new N().seen, new U().seen, new D().seen); // main: true false false, this branch: true true true

class D2 { a = #p in this; @dec accessor #p = 1 }
class S { static a = #s in this; @dec static accessor #s = 1 }
console.log(new D2().a, S.a); // main: false false, this branch: true true

bun bd test test/bundler/transpiler/es-decorators.test.ts: 9 of the 12 new fixture cells fail with src/ from main, all 431 tests pass with this branch.

Fix: #42643

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and didn't find any bugs. Because it changes the private-brand semantics of the decorator lowering (splitting accessor #x into a WeakSet brand + separate value WeakMap, and rerouting the undecorated case through a native get/set pair), a human look at the runtime-helper interaction would still be worthwhile.

What was reviewed:

  • The is_branded split in lower_decorators.rs: WeakSet brand pushed to instance_brands/static_brands up front, separate _*_storage WeakMap fed to __decorateElement as extra and to storage_init_effects — matches the (target, extra) shape the runtime helper already accepts.
  • The undecorated accessor #p branch: host_key_effects skipped for private keys (no last_key_host update), initializer routed through hosted_initializer so anonymous functions keep the #p name.
  • Cache version bump 32→33 present with an accurate history line.
  • Test matrix covers instance/static × {hand-written pair, bare, beside decorated method, decorated}, plus decorator-replaced get/set and constructor-return-override; each lowered shape is asserted equal to the hand-written ground-truth row.
Extended reasoning...

Overview

This PR fixes a regression in ES-decorator lowering for private auto-accessors (accessor #p). On main, an undecorated accessor #p was collapsed to a plain private field, and a decorated one used a single WeakMap for both brand and storage — so #p in this was false in any initializer that ran before the accessor's declared position. The fix in src/js_parser/lower/lower_decorators.rs treats a private accessor like a private method for branding: a WeakSet brand added via __privateAdd before the first field initializer, plus a separate _<name>_storage WeakMap populated at the accessor's position. The non-lowered undecorated case now emits a native get #p/set #p pair over a WeakMap (so JSC installs the brand first), skipping host_key_effects for private keys and wrapping the initializer via hosted_initializer to preserve function naming. RuntimeTranspilerCache.rs bumps EXPECTED_VERSION 32→33. es-decorators.test.ts adds a 4-shape × instance/static matrix asserting every lowered form matches a hand-written get #p/set #p pair, plus a decorator-replaced-accessor case and a constructor-return-override case.

Security risks

None. This is transpiler output correctness for class-body syntax; no untrusted-input parsing boundaries, no auth/crypto/permissions, no I/O. The generated code uses the existing __privateAdd/__privateIn/__decorateElement runtime helpers unchanged.

Level of scrutiny

Moderate-to-high. Decorator lowering is spec-compliance code where subtle ordering (brand installation vs. field initializer evaluation vs. __decorateElement's (target, extra) contract) determines observable behavior. The change is well-scoped and the test matrix is strong — each lowered shape is compared against a hand-written ground-truth column, covering #p in this before/at the accessor position, read/write TypeErrors before storage exists, Object.create inheritance, and re-branding the same object. The cache version bump satisfies the "any change to cached/serialized output bumps the format version" rule. Still, this is not a mechanical change: it restructures how private accessor brands and storage are represented, and interacts with __decorateElement's handling of the extra argument and with PrivateLoweredInfo bookkeeping. Someone familiar with the decorator lowering (and #40833, which introduced the regression) should confirm the runtime-helper contract and the private_lowered_map interaction.

Other factors

No CODEOWNERS covers these paths. No prior reviews or outstanding objections in the timeline. The bug hunt exited on dry_streak with no findings and no ruled-out candidates. The new Rust comments are concise why-comments (explaining brand-vs-storage timing and why the initializer is wrapped), not narration. Tests were added to the existing es-decorators.test.ts file per convention, use the existing extraSections/extraExpected fixture pattern, and the PR description states 9 of 12 new fixture keys fail without the fix. The PR also notes the interaction with open #42588 (function naming for accessor x / @ dec f) and #38933/#38904 (shared var temporaries in repeated class expressions), which are explicitly left unchanged.

…e field

The getter and setter `#x` now read and write `#x_accessor_storage`, a new
private field at the place of the accessor, not a WeakMap next to the
class. The pair and the field are native. The initializer runs as the
field initializer it is: `new.target` is undefined, it does not move into
the constructor, and each evaluation of the class has its own storage.

The new name is not one the class or a class around it declares.
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
@robobun

robobun commented Sep 13, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 2:04 PM PT - Sep 13th, 2026

✅ @robobun, your commit dcf617336e40b6e33e9ca53052f5982402a9da53 passed in Build #115289! 🎉


🧪   To try this PR locally:

bunx bun-pr 42643

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

bun-42643 --bun

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

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.

2 participants