Skip to content

Lower private names referenced by class static blocks during decorator lowering - #31405

Closed
robobun wants to merge 9 commits into
mainfrom
farm/5d9e5c46/decorator-static-block-private
Closed

robobun wants to merge 9 commits into
mainfrom
farm/5d9e5c46/decorator-static-block-private

Conversation

@robobun

@robobun robobun commented May 25, 2026 •

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes a transpiler bug (found by fuzzing the round-trip invariant) where the standard-decorator / auto-accessor lowering emits a private name outside the class body, so the printed output does not parse — plus the related case where private references inside moved field initializers are not rewritten.

Fixes #28118

Repro (fuzzer input)

new Bun.Transpiler({ loader: "js", target: "node", minifyWhitespace: true, deadCodeElimination: true })
  .transformSync("class Foo {\n static {\n [_caitProto] = babelHe323(#a in _).e;\n }\n #a() {\n }\n accessor 0;\n}")

Before, this printed:

class Foo{constructor(){...}#a(){}get 0(){...}set 0(v){...}}[_caitProto]=babelHe323(#a in _).e;...

#a in _ is only legal inside the class that declares #a, so the output fails to reparse (Unexpected #a). The same happens at runtime for files like:

class Foo {
  static { console.log(#a in new Foo()); }
  #a() {}
  accessor x = 1;
}

for class-decorator-only classes (@dec class Foo { static { this.#a() } #a() {} }), and for the case in #28118:

class Broken {
  @id accessor label: string = ''
  #name = 'hello'
  #callback = () => this.#name   // SyntaxError: Cannot reference undeclared private names: "#name"
  run() { return this.#callback() }
}

Cause

lower_impl in src/js_parser/lower/lower_decorators.rs moves code out of its original position in the class (static blocks always run after decoration; lowered private field initializers move into __privateAdd calls in the constructor, into static __privateAdd blocks, or after the class), but:

  1. it only lowers private members when at least one decorated property exists, so a class with only an accessor field or only a class decorator keeps #a native while its static blocks are still moved out with raw #a references, and
  2. the private-access rewrite pass skips several containers that hold moved user code: constructor-injected statements, static __privateAdd blocks, suffix expressions (static auto-accessor initializers), and the initializers of kept public fields.

Fix

  • Private identifiers resolved inside class static blocks and static auto-accessor initializers are recorded during the existing visit pass; the lowering forces private lowering when one of the class's own private names was recorded, so those references are rewritten to __privateIn/__privateGet/__privateMethod calls (same output shape already produced when a decorated property is present). Classes whose static blocks/initializers don't touch private names are unaffected, and no extra AST passes are added.
  • Undecorated private auto-accessors record their storage WeakMap, which is merged into the rewrite map only for the code emitted outside the class body (extracted static blocks, lowered private method bodies, decorator element arguments, suffix expressions), so references like #x in obj there rewrite correctly while code retained in the class keeps native access (update/compound assignment on the accessor keeps working). Each container is rewritten once.
  • The rewrite pass now recurses into function declarations, not just function expressions.
  • The Phase-5 rewrite now also covers constructor-injected statements, static __privateAdd blocks, suffix expressions, and kept field initializers, which fixes this.#field not rewritten in class field initializers when class has @decorated accessor #28118 (#callback = () => this.#name) and the analogous static-field/static-accessor initializer cases.

Known remaining gaps (pre-existing, called out by review):

  • update/compound assignment on a lowered private member (e.g. this.#field += 1 inside a static block of a decorated class) still prints as __privateGet(...) += 1; handling that needs __privateSet-based rewrites.
  • a kept public instance field whose initializer reads a lowered private (pub = this.#name) now prints as valid syntax but still throws at instantiation, because native field initializers run before the constructor body where __privateAdd lands; fully fixing that requires moving kept public instance fields into the constructor in source order.

How did you verify your code works?

Added tests in test/bundler/transpiler/es-decorators.test.ts:

  • "static blocks referencing private names": runtime behavior for accessor + private method brand check, private field access, private auto-accessor brand check, class-decorator-only + static private method call, a function declaration inside a static block, update/compound assignment on a private auto-accessor with decorated members (regression guard), the already-working decorated-method case, and a Bun.Transpiler reparse check using the exact fuzzer input
  • "field initializers referencing lowered privates": the this.#field not rewritten in class field initializers when class has @decorated accessor #28118 repro (TS, standard decorators), static private field + static auto-accessor initializers referencing a private, and a reparse check for the moved-initializer shapes

Most of the new tests fail without the src change and all pass with it. Also ran es-decorators-esbuild.test.ts (147 pass), decorators.test.ts, decorator-metadata.test.ts, transpiler.test.js, and bundler/esbuild/lower.test.ts with the debug build — no regressions.

…r lowering

The standard decorator/auto-accessor lowering moves every class static block
out of the class body so it runs after decoration, but it only lowered private
members when a decorated property was present. With only an accessor field or
only a class decorator, a static block containing `#a in x` or `this.#a()`
was emitted after the class with the private name intact, producing output
that does not parse.

The pre-scan now also forces private lowering when a static block references
one of the class's own private names, and private auto-accessors record their
storage WeakMap in the lowering map so brand checks rewrite to __privateIn.
@coderabbitai

coderabbitai Bot commented May 25, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@robobun, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 22 minutes and 7 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: ec2b4161-6196-407b-b6e6-1033c059e6ad

📥 Commits

Reviewing files that changed from the base of the PR and between 94d6377 and bf086da.

📒 Files selected for processing (6)
  • src/js_parser/lower/lower_decorators.rs
  • src/js_parser/p.rs
  • src/js_parser/visit/mod.rs
  • src/js_parser/visit/visit_binary.rs
  • src/js_parser/visit/visit_expr.rs
  • test/bundler/transpiler/es-decorators.test.ts

Walkthrough

This PR enhances ES decorator lowering: it pre-scans extracted static blocks and auto-accessor initializers for private-name usage, forces full private-member lowering when found, records extracted-only accessor WeakMaps, and expands private-access rewriting to moved code paths and property initializers.

Changes

Private names in static blocks and field initializers

Layer / File(s) Summary
Static block private reference detection
src/js_parser/lower/lower_decorators.rs
Helper functions scan expressions and statements for EPrivateIdentifier references; pre-scan collects declared private names and checks extracted static blocks and accessor initializers to set lower_all_private.
Accessor WeakMap tracking
src/js_parser/lower/lower_decorators.rs
Adds extracted_accessor_map and records generated WeakMap entries for private auto-accessors whose declarations must remain on the class while moved code is rewritten.
Expanded private-access rewriting
src/js_parser/lower/lower_decorators.rs
Rewriting now visits SFunction bodies, rewrites private accesses inside property initializer expressions, and covers constructor_inject_stmts, extracted __privateAdd static blocks, suffix expressions, and pre/prefix helper statements; applies extracted_accessor_map to moved code.
Test coverage for static blocks and initializers
test/bundler/transpiler/es-decorators.test.ts
Adds runtime tests for private-brand checks and private access from static {} blocks, tests for private-dependent field initializers (including TS regression fixture), and transpiler reparse checks for lowered outputs.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and specifically describes the main change: lowering private names referenced by class static blocks during decorator lowering, which is the core fix for the transpiler bug.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The PR description comprehensively covers what the PR does, the root cause, the implemented fix, known limitations, and detailed verification steps including specific test additions.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

@robobun

robobun commented May 25, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 8:27 PM PT - May 25th, 2026

✅ @robobun, your commit bf086da16357a1b4824b483e7504000d18e7bcd3 passed in Build #58092! 🎉


🧪   To try this PR locally:

bunx bun-pr 31405

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

bun-31405 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. this.#field not rewritten in class field initializers when class has @decorated accessor #28118 - Private field references (this.#field) are not rewritten when a class has a @decorated accessor, causing "Cannot reference undeclared private names" — this PR forces lower_all_private = true when private names are referenced outside the class body, which addresses this category of bug.

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #28118

🤖 Generated with Claude Code

Initializers of lowered private fields/accessors are emitted inside
__privateAdd calls in the constructor, in static __privateAdd blocks, or
after the class, and kept public field initializers stay in the class while
the privates they reference are removed from it. Run the private-access
rewrite over those containers too so references like `() => this.#name`
in a private field initializer keep working.

Fixes #28118
Comment thread src/js_parser/lower/lower_decorators.rs Outdated
Comment thread src/js_parser/lower/lower_decorators.rs Outdated
…function declarations

Recording the auto-accessor storage in the lowering map whenever all privates
were lowered would also rewrite previously-valid references like `this.#x++`
into `__privateGet(...)++`, which does not parse. Only record it when lowering
was forced because a static block references the class's private names — the
case whose output was invalid before.

Also recurse into function declarations in both the private-reference scan and
the rewrite pass so a function declared inside a static block is handled like a
function expression.

@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.

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 src/js_parser/lower/lower_decorators.rs:2080-2082 — The nprop.initializer rewrite makes the second reparse-test input (pub = this.#name) parse, but it still doesn't run: pub stays a native class field (line 1765) while __privateAdd(this, _name, 2) goes into the constructor body (line 1621), and native field initializers execute before the constructor body, so __privateGet(this, _name) throws at instantiation. Not a regression (was a SyntaxError before), but the reparse-only test labelled "kept public field initializer referencing a lowered private" gives false confidence — a full fix would need to move such kept public instance fields into constructor_inject_stmts in source order alongside the __privateAdd calls.

    Extended reasoning...

    What the bug is

    The new lines 2080-2082 rewrite nprop.initializer for properties that remain in new_properties. For a kept public instance field like pub = this.#name (where #name is being lowered), this produces pub = __privateGet(this, _name). However, pub is still emitted as a native class field (it reaches new_properties.push(prop_full_copy(prop)) at line 1765 and new_properties becomes class.properties at the end of lower_impl), while the lowered #name becomes __privateAdd(this, _name, 2) pushed into constructor_inject_stmts at line 1621 and later spliced into the constructor body (lines ~2434-2516).

    Per the ES spec, for a base class [[Construct]] runs InitializeInstanceElements (all native instance field initializers) before OrdinaryCallEvaluateBody (the constructor body). For a derived class, native field initializers run immediately after super() returns — still before the spliced statements at super_index + 1. So pub's initializer evaluates __privateGet(this, _name) before __privateAdd(this, _name, 2) has registered this in the WeakMap → TypeError: Cannot read private member from an object whose class did not declare it.

    Step-by-step proof

    Input:

    function id(v, c) { return v; }
    class Foo {
      @id accessor a = 1;
      #name = 2;
      pub = this.#name;
    }
    new Foo();
    1. Pre-scan (line 1489): @id accessor a → has_any_decorated = true; #name → has_any_private = true; so lower_all_private = true.
    2. Property loop, #name = 2: undecorated private field, non-accessor → enters the branch at lines 1606-1631. __privateAdd(this, _name, 2) is pushed to constructor_inject_stmts (line 1621). #name is removed from the class.
    3. Property loop, pub = this.#name: undecorated, public, non-accessor, not a static block → falls through to line 1765, pushed to new_properties unchanged. It remains a native instance field on the emitted class.
    4. Phase 5 (lines 2080-2082, new in this PR): pub's initializer is rewritten to __privateGet(this, _name).
    5. Constructor injection (lines ~2434-2516): a constructor is synthesized (or the existing one is patched) with __privateAdd(this, _name, 2) in its body. The kept pub field is not touched here.
    6. Emitted class (simplified):
      class Foo {
        pub = __privateGet(this, _name);   // native field — runs FIRST
        constructor() {
          __privateAdd(this, _name, 2);    // body — runs SECOND
          ...
        }
        get a() {...} set a(v) {...}
      }
    7. new Foo(): InitializeInstanceElements evaluates pub's initializer → __privateGet(this, _name) → _name WeakMap doesn't have this yet → TypeError.

    Why existing code doesn't prevent it

    Nothing in lower_impl moves undecorated public instance fields out of new_properties. The only mechanism that controls instance-field ordering relative to lowered privates is constructor_inject_stmts, and public fields never go there. The Phase-5 rewrite at 2080-2082 only changes the expression, not where the field is evaluated.

    Why this is not a regression

    Before this PR, lines 2080-2082 didn't exist and only nprop.value was rewritten. So pub = this.#name kept a raw #name reference while #name had been removed from the class → the printed output failed to parse with SyntaxError: Cannot reference undeclared private names: "#name". After this PR the output parses but new Foo() throws at runtime. That's strictly less broken (the module loads; the error is per-instantiation rather than per-module), so this PR is a net improvement for this input.

    Why it's worth flagging anyway

    The PR adds a reparse-only test entry for exactly this input — second inputs[] element in the new "Bun.Transpiler output with private references in field initializers reparses" test, with the comment // kept public field initializer referencing a lowered private. That test passes (the output now reparses), which could lead a reader to believe the case is fully handled. The two new runtime tests in the same describe block cover private→private (where both sides land in constructor_inject_stmts, so ordering is preserved) and static cases, but not public-instance-field→private. A runtime test for the second inputs[] entry would fail.

    How to fix

    When lower_all_private is true, undecorated public instance fields whose initializers reference a lowered private (or, more simply, all undecorated public instance fields when any private is lowered) should also be moved into constructor_inject_stmts as this.pub = <init> assignments, interleaved in source order with the __privateAdd calls so that define-order semantics are preserved. Alternatively, drop the second inputs[] entry / its comment so the test file doesn't imply this shape is fixed, and track it as a follow-up.

Comment thread src/js_parser/lower/lower_decorators.rs Outdated

@coderabbitai coderabbitai 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.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5755c54b-8dfc-4009-a55e-6c082e6fdedb

📥 Commits

Reviewing files that changed from the base of the PR and between cd1ad59 and 29d1b42.

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

Comment thread src/js_parser/lower/lower_decorators.rs Outdated
Undecorated static auto-accessor initializers are emitted after the class as
__privateAdd(Class, storage, init), so a private reference inside one needs
private lowering just like a static block. Rename the gating flag accordingly
and clarify the reparse test for kept public field initializers.
@robobun

robobun commented May 25, 2026

Copy link
Copy Markdown
Collaborator Author

Re the additional finding about pub = this.#name (kept public field reading a lowered private): correct — that shape now prints as valid syntax but still throws at instantiation because native field initializers run before the constructor body where __privateAdd is injected. That ordering is a pre-existing property of this lowering (it affects any mixed public/private field class with decorators, independent of this PR), and fixing it properly means moving kept public instance fields into the constructor in source order, which is a larger change than this fix. I've clarified the comment on the reparse-only test entry so it doesn't read as full support, and listed it in the PR description as a known remaining gap.

Comment thread src/js_parser/lower/lower_decorators.rs Outdated
@robobun

robobun commented May 25, 2026

Copy link
Copy Markdown
Collaborator Author

CI status for f13cd0e (build 58038): the three red lanes are unrelated to this diff —

  • darwin 14 x64 – test-bun: vendor test dependency install hit ConnectionRefused against registry.npmjs.org (infra hiccup); the job died at file 133/1010 before reaching any transpiler tests. A retry of that one job should clear it.
  • windows 2019 x64 / x64-baseline – test-bun: the known Bun.Transpiler … stack overflows failures at transpiler.test.js:4051/4057, which pre-date this PR and are being fixed separately in Deepen the lots-of-for-loop fixture so the transpiler stack-overflow tests throw on Windows #31382. No decorator/es-decorators test failed there.

All lanes that ran the decorator suites are green, and es-decorators.test.ts (47 tests), the esbuild decorator corpus (147), decorators.test.ts, decorator-metadata.test.ts, and transpiler.test.js pass locally with this change.

Comment thread src/js_parser/lower/lower_decorators.rs Outdated
robobun added 2 commits May 25, 2026 23:15
… code

Private auto-accessors keep their getter/setter declaration on the class, so
their storage entries now live in a separate map that is applied only to code
emitted outside the class body (extracted static blocks, suffix expressions,
pre-eval/prefix statements). Retained class code keeps native access, so
update and compound assignment on the accessor keep working, and the scan now
also runs when lowering was already forced by a decorated member so those
classes get the same treatment.
Comment thread src/js_parser/lower/lower_decorators.rs Outdated
Comment thread src/js_parser/lower/lower_decorators.rs Outdated

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Come up with a faster approach. Do not add extra passes. And, delete all your code comments.

The map is only ever applied to code emitted outside the class body, so
recording every undecorated private auto-accessor is safe and covers all the
containers that can carry such references (extracted static blocks, lowered
private method bodies in prefix statements, decorator element arguments,
suffix expressions) without scanning each of them. This also removes the
dependency on has_any_private, which was not set when the first private
member in source order was decorated.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/js_parser/lower/lower_decorators.rs`:
- Around line 2163-2195: The extracted_accessor_map rewrite is missing for
constructor_inject_stmts and static_private_add_blocks; update the same
post-extraction block that rewrites other moved containers to also rewrite
these: call p.rewrite_private_accesses_in_stmts(&mut constructor_inject_stmts,
&extracted_accessor_map) for constructor_inject_stmts, and for
static_private_add_blocks iterate mutably (or call the appropriate API) to
rewrite initializers/stmt slices inside each block (using
p.rewrite_private_accesses_in_expr / p.rewrite_private_accesses_in_stmts as
needed) so any references to auto-accessors are transformed just like the other
containers (keep using the existing p.rewrite_* helpers and the
extracted_accessor_map).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6a67c63f-53ad-46c6-a7d0-a5b40ae5f902

📥 Commits

Reviewing files that changed from the base of the PR and between 29d1b42 and 94d6377.

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

Comment thread src/js_parser/lower/lower_decorators.rs Outdated
Record private identifiers resolved inside class static blocks and static
auto-accessor initializers while they are being visited, and drop the
AST-walking pre-scan from the decorator lowering. Accessor storage entries
are merged into the single rewrite map between the retained-code and
moved-code rewrites, so each container is processed once.
@robobun

robobun commented May 26, 2026

Copy link
Copy Markdown
Collaborator Author

Reworked in 7497ef6 per the review:

  • No extra passes / faster: the AST-walking pre-scan is gone. Private identifiers are recorded while they are already being resolved in the visit pass (two 3-line hooks in visit_expr/visit_binary, plus a depth counter around static block / static accessor initializer visits), and the lowering just checks that set. Private-access rewriting is applied once per container — the separate accessor-map pass was removed by merging its entries into the one map between the retained-code and moved-code rewrites.
  • Comments: all comments I had added to the lowering are deleted.
  • Net diff for the rework: +50 / −267 lines. All decorator suites pass (es-decorators 51, esbuild decorator corpus 147, transpiler 179, decorators 22, decorator-metadata 5).

@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.

Additional findings (outside current diff — PR may have been updated during review):

  • 🟣 src/js_parser/lower/lower_decorators.rs:208-211 — 🟣 Pre-existing, in scope: the EFunction/EArrow arms here (and the new SFunction arm at line 288, plus the matching rewrite arms at lines 997-1004 / 1129) only recurse into body.stmts and never walk args[i].default. So a private reference that appears in a default parameter value of a function/arrow inside an extracted static block — e.g. class Foo { accessor a = 1; #m() {} static { const check = (o = #m in new Foo()) => o; check(); } } — is neither detected by the pre-scan (so lower_all_private is not forced) nor rewritten (so even when lowering is forced, the raw #m survives in the moved code), and the output keeps #m outside the class → unparseable. Same shape as the SFunction-body gap fixed in 29d1b42, just one level deeper; fix is to also iterate args and recurse into each arg.default in both functions. Fine as a follow-up.

    Extended reasoning...

    What the bug is

    Both the new scan (expr_references_private_name / stmts_reference_private_name) and the rewrite (rewrite_private_accesses_in_expr / _stmts) handle EFunction, EArrow, and SFunction by recursing into body.stmts only. G::Arg (src/ast/g.rs:340) carries a separate default: Option<ExprNodeIndex> field, and neither traversal iterates func.args / e.args to visit it. So a private-name reference that appears in a default parameter value — (o = #m in new Foo()) => o, function f(o = this.#m) {} — is invisible to both passes.

    The PR's doc comment on the scan says it "mirrors rewrite_private_accesses_in_expr/_stmts", and indeed it inherits this gap from the rewrite, which already had it on main. The same applies to defaults inside destructuring binding patterns in SLocal (const { v = #m in x } = obj — only decl.value is walked, not the binding pattern's default values).

    Step-by-step proof — scan miss

    class Foo {
      accessor a = 1;
      #m() {}
      static {
        const check = (o = #m in new Foo()) => o;
        console.log(check());
      }
    }
    1. accessor a triggers lower_impl. #m() → has_any_private = true; no decorators → has_any_decorated = false. Line 1504 leaves lower_all_private = false.
    2. The static-block pre-scan (line 1513) walks the block: SLocal → decl.value → EArrow. The EArrow arm at line 211 calls stmts_reference_private_name on e.body.stmts only — the body is the single expression-statement o, which has no private. e.args[0].default (#m in new Foo()) is never visited. The SExpr for console.log(check()) also has no private. Scan returns false → lower_all_private stays false.
    3. #m() is therefore kept as a native private method on the class.
    4. The static block is extracted out of the class body. Phase 5 is skipped (private_lowered_map is empty) and the extracted_accessor_map rewrite doesn't know about #m (it's a method, not an auto-accessor).
    5. Output contains (o = #m in new Foo()) => o outside the class → SyntaxError: Unexpected #m.

    Step-by-step proof — rewrite miss

    class Foo {
      @dec m() {}
      #helper() {}
      static {
        const f = (o = #helper in new Foo()) => o;
        f();
      }
    }
    1. @dec m() + #helper() → line 1504 sets lower_all_private = true. #helper is fully lowered into private_lowered_map and removed from the class.
    2. Phase 5 walks extracted_static_blocks → SLocal → decl.value → EArrow. The EArrow arm at line 1001 calls rewrite_private_accesses_in_stmts on e.body.stmts only — the body o has nothing to rewrite. e.args[0].default (#helper in new Foo()) is never visited.
    3. The extracted block is emitted after the class with the raw #helper still in the default parameter → unparseable.

    Why existing code doesn't prevent it

    The EFunction arm (lines 208-210 scan / 997-1000 rewrite), EArrow arm (line 211 / 1001-1004), and SFunction arm (line 288 / 1129-1132) all descend into body.stmts and nothing else. G::Arg.default is a separate field not reachable from the body. Default-parameter values are part of the function's lexical scope (and so can legally reference the enclosing class's private names), but they live on the parameter list, not in the body statement array.

    Why this is pre-existing

    The rewrite arms at lines 997-1004 are unchanged context lines; on main (which has no scan and the same rewrite gap) both repros above already produce unparseable output. So this is not a regression introduced by this PR. It is, however, exactly the bug class this PR closes (private names escaping the class via extracted static blocks), and commit 29d1b42 in this PR just added the analogous SFunction arm to both functions for the function-declaration gap raised earlier in review — the parameter-default gap is the same shape one level deeper.

    Impact

    Narrow trigger: requires decorator/auto-accessor lowering to fire, plus a function/arrow inside a moved container whose only private-name reference is in a default parameter value (or a destructuring-binding default). Unusual, but it's the same fuzzer-discoverable round-trip-invariant violation the PR is fixing elsewhere.

    How to fix

    Have the EFunction/EArrow/SFunction arms in both expr_references_private_name/stmts_reference_private_name and rewrite_private_accesses_in_expr/_stmts also iterate args and recurse into each arg.default, e.g.:

    js_ast::ExprData::EArrow(e) => {
        e.args.slice().iter().any(|a| a.default.is_some_and(|d| expr_references_private_name(&d, names)))
            || stmts_reference_private_name(e.body.stmts.slice(), names)
    }

    (and the analogous mutable iteration in the rewrite). The same treatment applies to destructuring-binding defaults in SLocal if you want to be exhaustive. Fine as a follow-up.

@robobun

robobun commented May 26, 2026

Copy link
Copy Markdown
Collaborator Author

CI status for 7497ef6 (build 58074): 278 jobs passed (including the Windows and macOS lanes that were red on earlier runs); the single failure is alpine 3.23 x64-baseline – test-bun, where bun-install-registry.test.ts ("cafile from bunfig does not exist") expected exit 1 but the install process died with SIGILL inside bun_http::http_thread while reporting the missing CA file. That code path isn't touched by this PR (parser/decorator lowering only), and the latest main build (58069) is green on the same lane, so it looks like a one-off on that runner — a retry of that single job should clear it.

Comment thread src/js_parser/lower/lower_decorators.rs
Comment thread src/js_parser/visit/mod.rs
Reset the depth counter when entering a nested class so ordinary members of
a class declared inside another class's static block are not treated as
static-init code, and keep only the recorded references that resolve to the
visited class's own private names so captures of an enclosing class's
privates do not force lowering on that class.
Comment thread src/js_parser/visit/mod.rs
@robobun

robobun commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator Author

Closing in favor of #40833, which rewrites the standard decorator lowering to follow esbuild's lowerClass and lowers every private member once any member is decorated. That covers the case fixed here: private names referenced from static blocks and from field initializers that the lowering relocates (#28118). All 18 tests added by this PR pass on that branch, and its fixture now includes the static block cases (staticBlockPrivate: brand checks, private calls, a function declared in the block, a nested class) in every mode.

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.

this.#field not rewritten in class field initializers when class has @decorated accessor

2 participants