Skip to content

Parse and lower accessor fields under experimentalDecorators - #29201

Closed
robobun wants to merge 3 commits into
mainfrom
farm/9ef3d383/accessor-experimental-decorators
Closed

Parse and lower accessor fields under experimentalDecorators#29201
robobun wants to merge 3 commits into
mainfrom
farm/9ef3d383/accessor-experimental-decorators

Conversation

@robobun

@robobun robobun commented Apr 11, 2026

Copy link
Copy Markdown
Collaborator

Fixes #29197.
Fixes #27335.

Repro

// tsconfig.json: { "compilerOptions": { "experimentalDecorators": true } }
class Foo {
  accessor x = "value";
}
error: Expected ";" but found "x"
error: Expected identifier but found "="

Root cause

The accessor class field modifier (TC39 Stage 4 / TypeScript 4.9+) was
gated on Bun's internal standard_decorators feature flag in the parser.
Under experimentalDecorators: true, standard_decorators is false,
so the parser never accepted the keyword and fell through to treat the
next token as a property name, choking on x.

The accessor keyword is a standalone proposal and is valid class
syntax regardless of decorator model.

Fix

All changes are in the Rust parser (src/js_parser, src/ast):

  1. Parser (parse_property.rs) — drop the standard_decorators gate
    on the p_accessor branch. Add TC39's [no LineTerminator here]
    restriction so class C { accessor\n y = 1 } parses as two fields via
    ASI (matching the existing .p_async check).

  2. Lowering routing (parse/mod.rs) — JSC does not parse accessor,
    so any class with an auto-accessor must go through the existing
    standard-decorator lowering (WeakMap + getter/setter). Widen
    should_lower_standard_decorators to has_auto_accessor || (standard && has_any_decorators).

  3. Mixed legacy + accessor → clear error (parse/mod.rs) — if a class
    has both legacy (@dec) decorators AND an accessor field under
    experimentalDecorators: true, silently rerouting the legacy decorators
    through the standard-proposal runtime would be wrong. Report a clear
    compile-time error instead.

  4. Hoist ordering (g.rs can_be_moved) — include .AutoAccessor
    static initializers in the side-effect check so static accessor x = sideEffect() isn't hoisted past preceding statements in the non-bundle
    tree-shaking path.

  5. Computed-key single evaluation (lower_decorators.rs) — widen the
    computed-key hoist (previously gated on ts_decorators.len > 0) to
    undecorated auto-accessors, so accessor [k()] = 1 evaluates k()
    exactly once instead of twice.

Anonymous export default class { accessor x = 1 } name injection is
already handled on main by the has_decorators || should_lower_standard_decorators
check in visit_stmt.rs.

Verification

Regression test test/regression/issue/29197.test.ts covers 10 scenarios:

  • accessor with public/private/protected/static/readonly modifiers
  • accessor in a .ts file with no tsconfig
  • @dec accessor in standard-decorators mode
  • Mixed legacy decorator + accessor emits the clear error
  • static accessor: direct access works, subclass access throws (TC39 spec)
  • accessor in a class expression
  • Newline-ASI after accessor (two fields, not one auto-accessor)
  • static accessor x = sideEffect() evaluates in source order
  • Anonymous export default class { static accessor x = 1 } doesn't panic
  • Computed key accessor [k()] evaluates the key exactly once

Rebase note

The Zig parser this PR originally targeted was fully removed from main
(migrated to Rust). All fixes were re-applied to the Rust parser; the
obsolete Zig-side changes were dropped. The final diff is Rust-only.


[review] gate passed · iteration 18 · 5 files touched

fails on main (without fix)
ASAN without fix: 4 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" "test/regression/issue/29197.test.ts"
bun test v1.4.0 (00b6d6570)

test/regression/issue/29197.test.ts:
42 | console.log(f.a, f.b, f.getC(), f.getD(), Foo.e, f.f);
43 | `,
44 |   });
45 | 
46 |   const [stdout, , exitCode] = await runBun(String(dir), "main.ts");
47 |   expect(stdout).toBe("1 2 3 4 5 6\n");
                      ^
error: expect(received).toBe(expected)

- "1 2 3 4 5 6
- "
+ ""

- Expected  - 2
+ Received  + 1

      at <anonymous> (/workspace/bun/test/regression/issue/29197.test.ts:47:18)
(fail) accessor with various modifiers under experimentalDecorators: true [387.91ms]
(pass) accessor without tsconfig (TS file, no decorator flags) [349.74ms]
 99 | }
100 | `,
101 |     });
102 | 
103 |     const [, stderr, exitCode] = await runBun(String(dir), "main.ts");
104 |     expect(stderr).toContain("Cannot mix the `accessor` keyword with `experimentalDecorators: true`");
                         ^
error: expect(received).toContain(expected)

Expected to contain: "Cannot mix the `accessor` keyword with `experimentalDecorators: 
... (truncated)

release without fix: 4 FAILED
bun test v1.4.0-canary.1 (da3851e57)

test/regression/issue/29197.test.ts:
42 | console.log(f.a, f.b, f.getC(), f.getD(), Foo.e, f.f);
43 | `,
44 |   });
45 | 
46 |   const [stdout, , exitCode] = await runBun(String(dir), "main.ts");
47 |   expect(stdout).toBe("1 2 3 4 5 6\n");
                      ^
error: expect(received).toBe(expected)

- "1 2 3 4 5 6
- "
+ ""

- Expected  - 2
+ Received  + 1

      at <anonymous> (/workspace/bun/test/regression/issue/29197.test.ts:47:18)
 99 | }
100 | `,
101 |     });
102 | 
103 |     const [, stderr, exitCode] = await runBun(String(dir), "main.ts");
104 |     expect(stderr).toContain("Cannot mix the `accessor` keyword with `experimentalDecorators: true`");
                         ^
error: expect(received).toContain(expected)

Expected to contain: "Cannot mix the `accessor` keyword with `experimentalDecorators: true`"
Received: "7 |   accessor x: number = 0;\n               ^\nerror: Expected \";\" but found \"x\"\n    at /tmp/issue-29197-mixed_smRquY/main.ts:7:12\n\n7 |   accessor x: number = 0;\n                ^\nerror: Expected identifier but found \":\"\n    at /tmp/issue-29197-mixed_smRquY/main.ts:7:13\n\nBun v1.4.0-cana
... (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/mechgate.xml" "test/regression/issue/29197.test.ts"
bun test v1.4.0 (00b6d6570)

test/regression/issue/29197.test.ts:
(pass) mixing accessor with experimentalDecorators legacy @dec is a clear error, not silent wrong semantics [272.61ms]
(pass) accessor with various modifiers under experimentalDecorators: true [398.93ms]
(pass) accessor without tsconfig (TS file, no decorator flags) [434.74ms]
(pass) accessor still works under standard decorators mode [420.78ms]
(pass) static accessor field: direct access works; subclass access throws (TC39 spec) [403.12ms]
(pass) accessor field in a class expression [339.62ms]
(pass) newline between `accessor` and the name triggers ASI (two fields, not one auto-accessor) [382.45ms]
(pass) `static accessor` with a side-effecting initializer is not hoisted past preceding statements [389.41ms]
(pass) anonymous `export default class` with a static accessor round-trips [378.05ms]
(pass) accessor with a computed key evaluates the key exactly once [390.79ms]

 10 pass
 0 fail
 20 expect() calls
Ran 10 tests across 1 file. 
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 719ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/7] gen bake.{client,server,error}.js
-> bake.client.js, bake.server.js, bake.error.js
[2/7] gen generated_host_exports.rs
generated_host_exports.rs: 93 exports (host=3, lazy=10, generic=80, rust=0); 239 extern-C blocks audited
[2/7] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m   Compiling�[0m
... (truncated)
diff hotspot
src/ast/g.rs                            |   7 +-
 src/js_parser/lower/lower_decorators.rs |   4 +-
 src/js_parser/parse/mod.rs              |  15 +-
 src/js_parser/parse/parse_property.rs   |   4 +-
 test/regression/issue/29197.test.ts     | 259 ++++++++++++++++++++++++++++++++
 5 files changed, 283 insertions(+), 6 deletions(-)

gate history · 1 passed · 0 rejected · iteration 18

evidence per changed file
file                                     reads  edits  tests
src/ast/g.rs                                 5      6    109
src/js_parser/lower/lower_decorators.rs      5      2    109
src/js_parser/parse/mod.rs                   5      4    109
src/js_parser/parse/parse_property.rs        5      3    109
test/regression/issue/29197.test.ts         21     32    109

root cause · written by the author bot

The parser only recognized the accessor keyword on class fields when the standard decorators feature was enabled, so under experimentalDecorators the token fell through to generic parsing and produced syntax errors. The fix removes that guard so accessor is parsed as an auto-accessor property in any class context, and adds a lowering pass that rewrites each auto-accessor into a private backing field with synthesized get and set members for both class statements and class expressions. This preserves decorator and TypeScript metadata, evaluates computed keys once via hoisted temporaries, …

@robobun

robobun commented Apr 11, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:29 AM PT - Aug 13th, 2026

Your commit 00b6d65 is building: #94821

@coderabbitai

coderabbitai Bot commented Apr 11, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Rewrites class accessor auto-accessor fields into private backing fields plus synthesized get/set members, relaxes parsing to accept accessor in class contexts without requiring standard-decorators, applies the rewrite to class expressions, and adds regression tests exercising decorated, computed, static, typed, and collision cases.

Changes

Cohort / File(s) Summary
Accessor transformation & lowering
src/ast/P.zig
Adds pub fn rewriteAutoAccessorProperties(p: *P, class: *G.Class, prefix_stmts: ?*ListManaged(Stmt)) void to replace .auto_accessor properties with a private backing field (#_accessor_storage_N) and synthesized get/set members, copy decorator/TS metadata to getters, generate collision-avoidant backing names, and optionally hoist computed-key temps via prefix_stmts; integrates emit ordering into legacy/no-standard-decorator lowering.
Parsing change
src/ast/parseProperty.zig
Removes the features.standard_decorators guard from the p_accessor branch so accessor is parsed as .auto_accessor whenever opts.is_class is true; preserves existing error/restart semantics.
Class-expression handling
src/ast/visitExpr.zig
Invokes p.rewriteAutoAccessorProperties(e_, null) in the e_class visitor during the non-standard-decorators flow so class expressions are rewritten to getter/setter forms when needed.
Regression tests
test/regression/issue/29197.test.ts
Adds tests that spawn TypeScript files with experimentalDecorators to validate decorated and undecorated accessor fields (instance/static), typed initializers, class expressions, access modifiers, computed/non-identifier keys, single evaluation of computed keys, backing-storage collision avoidance, and emitted legacy decorator metadata and lowering output.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: parsing and lowering accessor fields under experimentalDecorators, which is the core objective addressed across all file changes.
Linked Issues check ✅ Passed The PR satisfactorily addresses both #29197 (decorators on accessor fields failing to parse) and #27335 (accessor keyword failing to parse). Parser gate removal and lowering implementation enable parsing and runtime execution of accessor fields under experimentalDecorators.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing accessor field parsing and lowering under experimentalDecorators. Parser changes remove the standard_decorators gate, lowering helpers rewrite accessors to backing storage, and tests validate the fix.
Description check ✅ Passed The description clearly explains the problem, root cause, implementation, and verification results, covering both required template sections.

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

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. TypeScript accessor keyword in classes fails to parse #27335 - Reports the same accessor keyword parsing failure in classes that this PR fixes by removing the standard_decorators gate

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

Fixes #27335

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. fix(parser): allow accessor keyword with experimentalDecorators: true #27336 - Fixes the same accessor parsing bug under experimentalDecorators: true by removing the standard_decorators gate in parseProperty.zig
  2. feat(transpiler): lower auto-accessor class fields #26431 - Implements the same auto-accessor lowering (accessor → private field + getter/setter) in the same files (P.zig, parseProperty.zig)

🤖 Generated with Claude Code

@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: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/ast/P.zig`:
- Around line 4967-5066: Replace the naked "catch bun.outOfMemory()" uses in the
new helper with bun.handleOom(...) calls: specifically change the
ListManaged(Property).initCapacity call, both std.fmt.allocPrint calls used to
build storage_name, the p.newSymbol(storage_kind, storage_name) call, all
p.allocator.alloc(...) calls (get_body_stmts, setter_args, set_body_stmts), and
any p.newExpr/p.newSymbol/p.newExpr(E.Function...) sites that currently end with
"catch bun.outOfMemory()" to instead wrap the failing call with
bun.handleOom(...). This preserves the same behavior but uses the repo
convention that converts OutOfMemory into a crash without swallowing other
errors; locate these in the helper around symbols rewritten, storage_name,
storage_ref, get_body_stmts, get_fn_expr, setter_param_ref, setter_args,
set_body_stmts, and set_fn_expr and replace the tail "catch bun.outOfMemory()"
with bun.handleOom(...)
- Around line 4981-5003: The code builds private storage names in the
storage_name block by embedding non-computed string keys directly, which can
produce invalid private identifiers; modify the storage_name logic (the brk
block around storage_name, referencing prop.flags, prop.key, and
k.data.e_string.data) to validate that the string is a legal private identifier
(starts with $, _, or UnicodeIDStart and subsequent chars are only those or
digits) before using the "#{s}_accessor_storage" branch; if the validation
fails, fall back to the numeric counter branch that generates
"#_accessor_storage_{d}". Ensure the validation runs only when
prop.flags.contains(.is_computed) is false and prop.key is present.
- Around line 5034-5066: Computed accessor keys are being evaluated twice
because the code directly reuses prop.key when appending the synthesized get and
set members; modify the rewrite to hoist the computed key expression into a
temporary (evaluate prop.key once into a temp key symbol/expression before
creating get/set), then use that temp for both the getter and setter entries
passed to rewritten.append so both members reference the same pre-evaluated key
while preserving original evaluation order and side effects; update the block
that constructs get_fn_expr/set_fn_expr (and the subsequent rewritten.append
calls) to consume the hoisted temp instead of prop.key (keep existing symbols
like storage_ref, setter_param_ref, and the
E.Function/E.PrivateIdentifier/E.Index construction intact).

In `@test/regression/issue/29197.test.ts`:
- Line 63: Remove the fragile empty-stderr assertions by deleting each
expect(stderr).toBe("") (and any exact-empty stderr checks) in the regression
subprocess tests; instead rely on the existing stdout and exitCode assertions
(or, if you need to check stderr content, assert specific error-free patterns
rather than exact emptiness). Update the tests that contain
expect(stderr).toBe("") (and similar exact-empty checks) so they no longer fail
due to ASAN/debug startup warnings.
🪄 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: bbbdf20b-a1d7-4b3a-92f9-c61b65e85dc1

📥 Commits

Reviewing files that changed from the base of the PR and between 6b9ee63 and f1d2d07.

📒 Files selected for processing (4)
  • src/ast/P.zig
  • src/ast/parseProperty.zig
  • src/ast/visitExpr.zig
  • test/regression/issue/29197.test.ts

Comment thread src/ast/P.zig Outdated
Comment thread src/ast/P.zig Outdated
Comment thread src/ast/P.zig Outdated
Comment thread test/regression/issue/29197.test.ts Outdated
Comment thread src/ast/P.zig Outdated
Comment thread src/ast/P.zig Outdated
Comment thread src/ast/P.zig Outdated
Comment thread src/ast/P.zig Outdated
Comment thread src/ast/P.zig 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: 2

♻️ Duplicate comments (1)
src/ast/P.zig (1)

5039-5067: ⚠️ Potential issue | 🔴 Critical

Computed keys still run twice for class expressions.

When prefix_stmts is null, shared_key stays as prop.key, so the synthesized getter and setter each emit their own computed name. class { accessor [sideEffect()] = 1 } will still evaluate sideEffect() twice. Separate getter/setter definitions each evaluate ClassElementName independently. (tc39.es)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/ast/P.zig` around lines 5039 - 5067, The computed property key is only
hoisted when prefix_stmts is present, so getter/setter pairs still evaluate
prop.key twice; change the logic in the prop.flags.contains(.is_computed) branch
to always synthesize a single temporary key symbol when prop.key exists (create
key_ref via p.newSymbol, append it to p.current_scope.generated, build
shared_key = p.newExpr(E.Identifier{ .ref = key_ref }, k.loc)), and if
prefix_stmts is non-null also emit the Local var declaration into prefix_stmts
as you currently do; this ensures shared_key is replaced with the temp
identifier for both synthesized accessor definitions so ClassElementName is
evaluated only once.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/ast/P.zig`:
- Around line 5059-5062: The change moves accessor_prefix_stmts (the temp
initialization emitted via bun.handleOom and ps.append(p.s(... S.Local{ .kind =
.k_var, .decls = G.Decl.List.fromOwnedSlice(decls) }, k.loc))) before the class
declaration, which incorrectly evaluates computed keys earlier than the class
body; instead restore the temp initialization to be emitted at the accessor's
original class-element slot (not prepended), i.e. remove/undo the prepended
ps.append call and ensure the temp variable is created and inserted where the
accessor is emitted so the computed-key evaluation and accessor initialization
occur in original source order (also apply same fix for the other occurrences
around lines referenced 5177-5195 and 5456-5463).
- Around line 5043-5051: The generated symbol name "_computedAccessorKey{d}" is
predictable and can collide with user identifiers; replace this with a
collision-proof generated temp by using a dedicated generated-symbol facility or
by constructing a name that guarantees uniqueness (e.g., include a unique
per-process/per-scope token or use an existing generator function) when calling
p.newSymbol, then append that symbol to p.current_scope.generated as before
(references: p.newSymbol, key_ref, p.current_scope.generated.append). Ensure the
chosen approach produces a symbol that cannot conflict with user code in
non-renaming output.

---

Duplicate comments:
In `@src/ast/P.zig`:
- Around line 5039-5067: The computed property key is only hoisted when
prefix_stmts is present, so getter/setter pairs still evaluate prop.key twice;
change the logic in the prop.flags.contains(.is_computed) branch to always
synthesize a single temporary key symbol when prop.key exists (create key_ref
via p.newSymbol, append it to p.current_scope.generated, build shared_key =
p.newExpr(E.Identifier{ .ref = key_ref }, k.loc)), and if prefix_stmts is
non-null also emit the Local var declaration into prefix_stmts as you currently
do; this ensures shared_key is replaced with the temp identifier for both
synthesized accessor definitions so ClassElementName is evaluated only once.
🪄 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: 19b45965-993e-428c-aba7-de0d5ad077cb

📥 Commits

Reviewing files that changed from the base of the PR and between f1d2d07 and f3601d1.

📒 Files selected for processing (3)
  • src/ast/P.zig
  • src/ast/visitExpr.zig
  • test/regression/issue/29197.test.ts

Comment thread src/ast/P.zig Outdated
Comment thread src/js_parser/ast/P.zig 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.

♻️ Duplicate comments (2)
src/ast/P.zig (2)

5043-5052: ⚠️ Potential issue | 🟠 Major

Use the parser’s temp-ref generator here.

_computedAccessorKey{d} is still a user-visible declaration name. In non-renaming output it can collide with real bindings and turn this rewrite into a duplicate declaration/capture bug. generateTempRef("_computedAccessorKey") already handles the collision-resistant path used elsewhere in this file.

🛠️ Suggested fix
-                            const key_ref = bun.handleOom(p.newSymbol(
-                                .other,
-                                bun.handleOom(std.fmt.allocPrint(
-                                    p.allocator,
-                                    "_computedAccessorKey{d}",
-                                    .{counter},
-                                )),
-                            ));
-                            bun.handleOom(p.current_scope.generated.append(p.allocator, key_ref));
-                            counter += 1;
+                            const key_ref = p.generateTempRef("_computedAccessorKey");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/ast/P.zig` around lines 5043 - 5052, The code is creating a user-visible
symbol "_computedAccessorKey{d}" which can collide with real bindings; replace
the manual newSymbol + counter approach with the parser's collision-safe
temp-ref generator by calling p.generateTempRef("_computedAccessorKey") (and use
bun.handleOom(...) around that call as needed), then append that returned temp
ref to p.current_scope.generated instead of using the manual counter; remove the
counter and the std.fmt.allocPrint/newSymbol usage so the generated temp ref
mechanism handles uniqueness.

4958-4968: ⚠️ Potential issue | 🔴 Critical

Preserve computed-key evaluation at the original class-element slot.

This still changes runtime semantics. When prefix_stmts is present, var _computedAccessorKey... = expr runs before the class, so the key no longer evaluates in class-element order and can no longer see the class’s initialized inner binding (class C { accessor [C.name] = 1 }). When prefix_stmts is null, the fallback reuses prop.key on both synthesized members, so class expressions still evaluate the key twice. This needs an in-class single-evaluation strategy (e.g. assign the temp in the first synthesized key and reuse it in the second) or another lowering that preserves class evaluation order.

In ECMAScript class evaluation, are computed property names evaluated after the `extends` clause and with the class’s inner name binding initialized? If `accessor [expr] = init` is lowered either to `var t = expr; class C extends Base { get [t]() {} set [t](v) {} }` or to `class C { get [expr]() {} set [expr](v) {} }`, do those preserve the original semantics?

Also applies to: 5039-5066, 5190-5197, 5458-5465

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/ast/P.zig` around lines 4958 - 4968, The computed-key hoisting currently
moves evaluation to prefix_stmts (or duplicates prop.key when prefix_stmts is
null), changing class-element evaluation order and breaking cases like class C {
accessor [C.name] = 1 }; modify the lowering so the temp is created at the first
synthesized class-element slot instead of before the class: when lowering an
accessor with computed key (see prop.key and the synthesized members:
field/getter/setter), emit the assignment to the temp as part of the first
synthesized member’s key expression (or an injected statement that is produced
inline with that first member) and have the subsequent synthesized members reuse
that temp; when prefix_stmts is null, do the same inline-first-member assignment
rather than re-evaluating prop.key so the key is evaluated exactly once in
class-element order and the class’s inner binding is observable as per
ECMAScript semantics.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/ast/P.zig`:
- Around line 5043-5052: The code is creating a user-visible symbol
"_computedAccessorKey{d}" which can collide with real bindings; replace the
manual newSymbol + counter approach with the parser's collision-safe temp-ref
generator by calling p.generateTempRef("_computedAccessorKey") (and use
bun.handleOom(...) around that call as needed), then append that returned temp
ref to p.current_scope.generated instead of using the manual counter; remove the
counter and the std.fmt.allocPrint/newSymbol usage so the generated temp ref
mechanism handles uniqueness.
- Around line 4958-4968: The computed-key hoisting currently moves evaluation to
prefix_stmts (or duplicates prop.key when prefix_stmts is null), changing
class-element evaluation order and breaking cases like class C { accessor
[C.name] = 1 }; modify the lowering so the temp is created at the first
synthesized class-element slot instead of before the class: when lowering an
accessor with computed key (see prop.key and the synthesized members:
field/getter/setter), emit the assignment to the temp as part of the first
synthesized member’s key expression (or an injected statement that is produced
inline with that first member) and have the subsequent synthesized members reuse
that temp; when prefix_stmts is null, do the same inline-first-member assignment
rather than re-evaluating prop.key so the key is evaluated exactly once in
class-element order and the class’s inner binding is observable as per
ECMAScript semantics.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 78b3fa40-9247-494f-be29-8635e0d47b79

📥 Commits

Reviewing files that changed from the base of the PR and between f3601d1 and 038881f.

📒 Files selected for processing (1)
  • src/ast/P.zig

Comment thread src/ast/visitExpr.zig Outdated
Comment thread src/ast/P.zig 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

♻️ Duplicate comments (1)
src/ast/P.zig (1)

5034-5053: ⚠️ Potential issue | 🟠 Major

Use the generated-temp facility here instead of a raw formatted name.

__bun_accessor_key_{d}$ is still a predictable plain identifier, so in non-renaming output it can collide with user bindings in the hoisting scope. This is the same collision class as the earlier _computedAccessorKey issue; please allocate this through the existing temp/gensym path instead of formatting a bare symbol name.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/ast/P.zig` around lines 5034 - 5053, The generated temp name currently
created with std.fmt.allocPrint("__bun_accessor_key_{d}$", tmp_n) produces a
predictable identifier that can collide with user bindings; replace this raw
formatted name by allocating the temporary through the project's gensym/temp
facility (use the existing generated-temp API rather than creating tmp_name
manually), then pass that generated symbol to p.newSymbol (keep tmp_ref,
hoist_scope logic, hoist_scope.generated.append, and p.declared_symbols.append
as before) so the temp is unguessable and allocated consistently with other
temps.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/ast/P.zig`:
- Around line 4997-5024: The synthesized storage name generation using counter
and storage_name (format "#_accessor_storage_{d}") can collide with
user-declared private fields; modify the logic around counter, storage_name, and
p.newSymbol so you first check that the candidate name is not already used by
any class.properties key or existing symbols in p.current_scope.generated, and
if it is, increment the counter (or re-mangle) and retry until you find an
unused name; only then call p.newSymbol and append to p.current_scope.generated
to reserve it. Ensure the uniqueness check covers both user-declared private
keys and previously generated storage_ref entries.

---

Duplicate comments:
In `@src/ast/P.zig`:
- Around line 5034-5053: The generated temp name currently created with
std.fmt.allocPrint("__bun_accessor_key_{d}$", tmp_n) produces a predictable
identifier that can collide with user bindings; replace this raw formatted name
by allocating the temporary through the project's gensym/temp facility (use the
existing generated-temp API rather than creating tmp_name manually), then pass
that generated symbol to p.newSymbol (keep tmp_ref, hoist_scope logic,
hoist_scope.generated.append, and p.declared_symbols.append as before) so the
temp is unguessable and allocated consistently with other temps.
🪄 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: 6ce850da-6e63-4050-860a-9be66e536b9e

📥 Commits

Reviewing files that changed from the base of the PR and between 038881f and b9970ca.

📒 Files selected for processing (1)
  • src/ast/P.zig

Comment thread src/ast/P.zig Outdated
Comment thread src/ast/P.zig 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: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/ast/P.zig`:
- Around line 5005-5076: Change the counter to a wider integer and use checked
arithmetic: replace the local counter (currently u32) with u64, parse existing
storage suffixes with std.fmt.parseInt(u64, ...) (or cast parsed u32 -> u64),
and use `@addWithOverflow` when computing n+1 or bumping counter (both in the
pre-scan comparison near storage_prefix and in the computed-key tmp_name loop
where n is incremented) to avoid wrapping; also ensure any std.fmt.allocPrint
that prints the numeric id uses the updated counter type (e.g., storage_n as
u64) and update references to counter/ n accordingly (symbols: storage_prefix,
counter, parseInt call, storage_n/storage_name creation, tmp_name / tmp_name
generation loop).
- Around line 5176-5182: The decorator emission must not reuse the full
computed-assignment expression stored in getter_key (which is currently set to
"(_tmp = expr())"); instead, change the decorator descriptor_key generation to
reference only the cached temp variable created by the assignment so the key
expression is not re-run. Locate where getter_key and prop.key are used to build
descriptor_key in the decorator loop and replace usage of the full assignment
expression with an expression that reads the temp variable (the RHS of the
assignment result), ensuring the member-definition keeps the assignment but the
decorator descriptor uses only the temp reference (so
__legacyDecorateClassTS/... receives the cached key and expr() is not executed
twice).

In `@test/regression/issue/29197.test.ts`:
- Around line 3-13: Remove the long bug-history prose in the leading comment
block that explains why `accessor` was rejected and how Bun desugars it; keep
only a single line with the issue URL and any short notes that are directly
about the test design (e.g., why we desugar to `#storage` + getter/setter).
Apply the same trimming to the other verbose comment block referenced around
lines 116-119 so both blocks contain only the issue URL and concise
test-rationale comments explaining the signal/design choice.
- Around line 33-44: The spawned process `proc` pipes stderr but never consumes
it; to avoid possible backpressure stall, concurrently drain `proc.stderr`
(e.g., await `proc.stderr.text()` or otherwise consume its stream) alongside
`proc.stdout.text()` and `proc.exited` so stderr is read even when not asserted;
update the Promise.all that currently awaits `proc.stdout.text()` and
`proc.exited` to include consuming `proc.stderr` (referencing `Bun.spawn`, the
`proc` variable, `proc.stdout.text()`, `proc.stderr`, and `proc.exited`).
🪄 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: c997f4d3-7846-4aa0-a4db-9714abebcbd4

📥 Commits

Reviewing files that changed from the base of the PR and between b9970ca and 1156fc0.

📒 Files selected for processing (2)
  • src/ast/P.zig
  • test/regression/issue/29197.test.ts

Comment thread src/ast/P.zig Outdated
Comment thread src/js_parser/ast/P.zig Outdated
Comment thread test/regression/issue/29197.test.ts Outdated
Comment thread test/regression/issue/29197.test.ts Outdated
Comment thread src/ast/P.zig Outdated
Comment thread src/js_parser/ast/visitExpr.zig Outdated
Comment thread src/js_parser/ast/P.zig Outdated
Comment thread src/js_parser/ast/P.zig Outdated
Comment thread src/js_parser/ast/P.zig Outdated
robobun pushed a commit that referenced this pull request Apr 12, 2026
…or on decorated class-expression accessor

Review feedback on PR #29201:

1. **Sibling-class temp collision**: the computed-key `__bun_accessor_key_N$`
   counter was a function-local reset to 0 on every call, so two sibling
   classes at the same hoisting scope would both pick `__bun_accessor_key_0$`,
   emitting duplicate `var` declarations and aliased refs. The collision
   check only consulted `hoist_scope.members` (user-declared), not
   `hoist_scope.generated` (synthesized refs from earlier calls). Fix:
   scan `hoist_scope.generated` for existing names matching our prefix
   and start the counter one above the maximum seen.

2. **Missing recordUsage on the descriptor_key unwrap**: when we extract
   the LHS identifier from a computed-key assignment (`(_tmp = expr())`)
   for the decorator descriptor, that's a third runtime read of `_tmp`
   (after the getter-key LHS assignment and the setter-key read). We were
   skipping the `recordUsage` call, leaving the minifier's char-frequency
   tally one low. Fix: call `p.recordUsage` on the extracted ref.

3. **Silent decorator drop on class-expression accessor**: class
   expressions don't go through `lowerClass`, so any `ts_decorators`
   on a synthesized getter are never emitted into a
   `__legacyDecorateClassTS` call. This is a pre-existing gap for ALL
   legacy decorators on class-expression members, but this PR widens the
   reachable surface by accepting `accessor` syntax under
   `experimentalDecorators`. Until class-expression legacy decorators
   are implemented end-to-end, emit a clear parse-time error for the
   `@dec accessor x` in a class expression so users don't silently get
   valid-looking code where `@dec` never fires.

4. Stale comment on the `.auto_accessor` branch of the decorator
   metadata switch replaced with an explanation that matches the new
   reality: the branch is dead because `rewriteAutoAccessorProperties`
   has already converted accessors to getter/setter pairs by the time
   this loop runs.

New regression tests:
- `sibling classes with computed accessor keys use distinct temp vars`
- `decorated accessor in a class expression is rejected with a clear error`
Comment thread src/js_parser/ast/visitExpr.zig Outdated
Comment thread src/js_parser/ast/P.zig Outdated
Comment thread src/js_parser/ast/P.zig Outdated
Comment thread src/ast/P.zig Outdated
robobun pushed a commit that referenced this pull request Apr 12, 2026
…l class-expr violations

Review feedback on PR #29201:

1. Split `counter` into `storage_counter` and `key_counter` so the
   backing-field index sequence stays contiguous (`#_accessor_storage_0`,
   `_1`, `_2`, ...) even when a computed-key accessor inserts a temp.
   The shared counter was cosmetically skipping storage indices.

2. Narrow the `descriptor_key` unwrap in `lowerClass` so it only strips
   `(__bun_accessor_key_N$ = expr)` assignments synthesized by
   `rewriteAutoAccessorProperties`. A user-written
   `@dec get [(x = computeKey())]()` must pass through unchanged, so
   the runtime key is the result of `computeKey()` and not a stale
   `x`. Guard the unwrap on the LHS identifier's original name.

3. Remove the `break` in the class-expression validation loop so every
   decorated auto-accessor is reported in a single compile pass, not one
   per edit-compile cycle.

4. Document the known quality trade-off where synthesized refs land in
   the enclosing scope's `generated` list (the class body scope has
   already been popped by the time `rewriteAutoAccessorProperties`
   runs). The renamer still produces unique names; only the scope-local
   slot counters are slightly suboptimal for minification. Refactoring
   the visit/lower split to keep the class scope alive through lowering
   is out of scope for this PR.
Comment thread src/js_parser/ast/P.zig Outdated
Comment thread src/js_parser/ast/P.zig Outdated
Comment thread test/regression/issue/29197.test.ts Outdated

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

No issues found in this pass, but this is a substantial new AST lowering pass (~300 lines in P.zig) with many edge cases that have already required multiple correctness fixes — worth a human pass over the final shape of rewriteAutoAccessorProperties and its interaction with lowerClass.

Extended reasoning...

Overview

This PR enables parsing of the accessor class-field keyword under experimentalDecorators: true (previously gated on standard_decorators) and adds a new ~300-line rewriteAutoAccessorProperties helper in src/ast/P.zig that desugars accessor x = init into a private backing field plus synthesized get/set members. Supporting changes thread the rewrite through lowerClass (class statements) and the e_class visitor in visitExpr.zig (class expressions), plus a 17-case regression suite.

Security risks

None. This is a transpiler/AST transformation; it does not touch auth, crypto, network, filesystem permissions, or process boundaries. The only attack-surface-adjacent concern is generating syntactically invalid JS from adversarial input (e.g. private-name collisions, non-identifier keys), and those paths now have collision-avoidance and tests.

Level of scrutiny

High. This is core parser/lowering logic on a hot path that affects every TypeScript file using accessor fields. The PR went through at least ten rounds of bug-hunter feedback (invalid private-identifier synthesis, computed-key double-evaluation, static-accessor subclass brand-check failure, missing return_ts_metadata, descriptor-key re-evaluation in decorator calls, scope placement of synthesized symbols, temp-name collisions across sibling classes, private-identifier descriptor printing, etc.), all of which were addressed — but the volume of corrections suggests the design surface is subtle enough that a human familiar with the parser internals should sign off on the final implementation.

Other factors

  • A documented known limitation remains: computed accessor keys in class expressions (where there is no statement-level prefix sink) still evaluate twice; the author chose to error on the decorated class-expression case but accept the undecorated double-eval as strictly-better-than-before.
  • Synthesized symbols are appended to the enclosing scope's generated list rather than the (already-popped) class-body scope — acknowledged in a doc comment as a renamer-slot-optimality trade-off deferred to a follow-up.
  • The three CI failures (worker_threads segfault on Windows, 26225 assertion, bun-install) appear unrelated to this change.
  • All prior inline review comments are marked resolved, and the current bug-hunter pass found nothing new.

Comment thread src/js_parser/ast/P.zig Outdated
Comment thread src/js_parser/parse/mod.rs

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

All prior review feedback has been addressed and the bug hunter found nothing new this round, but this changes parser/lowering semantics across both the Rust and Zig implementations and bakes in a design decision (hard-error on accessor + legacy @dec in the same class, routing accessor-only classes through standard-decorator lowering) — worth a human sign-off.

Extended reasoning...

Overview

This PR un-gates the accessor class field keyword from standard_decorators so it parses under experimentalDecorators: true, and routes any class containing an auto-accessor through the existing lower_decorators WeakMap+getter/setter lowering (since JSC does not parse accessor natively). It touches 10 files across both the Rust and Zig parser implementations: parse_property (drop gate, add [no LineTerminator here] ASI guard), parse/mod (widen should_lower_standard_decorators, add a hard error for mixing accessor with legacy @dec), lower_decorators (widen computed-key hoist to undecorated auto-accessors), g.{rs,zig} (can_be_moved now inspects .auto_accessor static initializers), and visit_stmt.zig (inject default_name when should_lower_standard_decorators). A 10-scenario regression suite covers modifiers, ASI, hoist ordering, computed-key single-evaluation, class expressions, anonymous default export, and the mixed-mode error.

Security risks

None. This is JS/TS parser and AST-lowering logic; no auth, crypto, FS, or network surface. The added error path uses a fixed message string; no user input flows into anything sensitive.

Level of scrutiny

High. Parser correctness changes are production-critical — getting them wrong silently emits incorrect JavaScript for end users. The PR went through ~15 bot-review iterations that surfaced real bugs each time (double-evaluation of computed keys, static-accessor brand-check failures, null-unwrap panics on anonymous default exports, missing ASI guard, Rust/Zig parity gaps). All have been addressed in the final diff, and the most recent commits (270324d..0715021) cleanly resolve the last round of feedback. But two things warrant human judgment rather than bot approval:

  1. Design decision: hard-erroring on accessor + legacy @dec in the same class (rather than attempting to lower legacy decorators alongside the accessor) is a reasonable but user-visible policy choice that diverges from tsc (which compiles this combination). A maintainer should confirm this is the intended UX.
  2. Semantic widening: should_lower_standard_decorators = has_auto_accessor || (...) means accessor-only classes now always take the standard-decorator lowering path regardless of mode. This is the simplest correct strategy, but it is a meaningful change to an internal routing flag that other code reads (visit_stmt, lower_class, the class-expression visitor).

Other factors

  • Dual-language port (Rust + Zig) is now in lockstep; I verified visit_stmt.rs on main already has the matching has_decorators || should_lower_standard_decorators check that visit_stmt.zig was aligned to in 0715021.
  • Test coverage is solid for the documented scenarios; all use test.concurrent per repo convention and avoid the ASAN-flaky empty-stderr pattern.
  • The PR has had no human reviewer engagement yet — only bot feedback — so there is no existing human approval to lean on.

@robobun
robobun force-pushed the farm/9ef3d383/accessor-experimental-decorators branch from 0715021 to 5cf316a Compare June 25, 2026 21:10
Comment thread src/js_parser/lower/lower_decorators.rs Outdated
@robobun

robobun commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator Author

This parse failure was independently re-reported. Rather than open a second PR, I pushed the newer work to a branch: farm/57075cac/ts-legacy-decorators-accessor.

The one place it disagrees with this PR is the legacy-decorator interaction. This PR adds a compile error for a class that has both legacy decorators and an accessor field. TypeScript 4.9 through 5.9 all accept that combination, in both forms:

// tsconfig: { "compilerOptions": { "experimentalDecorators": true } }
class Entity {
  @Column() id: number = 0; // legacy decorator on a sibling member
  accessor name = "";       // ok in tsc, rejected by this PR
}
class B {
  @dec accessor n = 1;      // legacy decorator on the accessor itself: also ok in tsc
}

The branch instead rewrites each auto-accessor into a private backing field plus a getter/setter pair inside the class body (the shape tsc emits, and the same shape #26431 used):

#name_accessor_storage = "";
get name() { return this.#name_accessor_storage; }
set name(v) { this.#name_accessor_storage = v; }

A legacy decorator on the accessor moves to the getter, so the existing __legacyDecorateClassTS(decorators, proto, key, null) call matches what tsc's __decorate produces for a legacy-decorated auto-accessor, and design:type metadata comes from the accessor's annotated type. It also keeps accessor-only classes off the standard-decorator lowering path, which would otherwise wrap them in __decoratorStart / __decoratorMetadata and attach Symbol.metadata to classes that have no decorators at all.

dylan-conway pushed a commit that referenced this pull request Jul 15, 2026
### Problem

Bun's TS parser treats `declare`, `abstract`, `interface`, and
`accessor` as modifiers even when a newline follows them, silently
deleting the declaration that comes after. esbuild and tsc both apply
ASI and treat the keyword as a standalone identifier expression
(statement level) or class field (class body).

```console
$ printf 'declare\nfunction foo() { return 1 }\nconsole.log(foo())\n' > r.ts
$ bun build r.ts --no-bundle
console.log(foo());                 # function body gone; ReferenceError at runtime
$ npx esbuild r.ts
declare;
function foo() { return 1; }
console.log(foo());
```

```console
$ printf 'class Foo { declare\n foo() { return 1 } }\nconsole.log(new Foo().foo())\n' > r2.ts
$ bun build r2.ts --no-bundle
class Foo {}                        # method deleted; new Foo().foo() throws
console.log(new Foo().foo());
```

Sibling shapes with the same bug: `abstract\nclass A {}`,
`interface\nA\n{ sideEffect() }`, `abstract class A { abstract\nfoo() {}
}`, `export default abstract\nclass A {}`, `class A { accessor\n x = 1
}`. Also `class A { get\n*x() {} }` was rejected with `Unexpected *`;
esbuild/tsc parse a field `get` followed by a generator `*x`.

### Cause

esbuild (`internal/js_parser/js_parser.go`) gates each of these paths on
`!p.lexer.HasNewlineBefore` before committing to the modifier
interpretation, and validates the body of every `declare` statement
after the recursive parse. Bun's port lost both:

- `TsStmtDeclare`, `TsStmtInterface`, `TsStmtAbstract` in
`parse_stmt_fallthrough_ts_keyword` had no newline check.
- The `export default abstract` class path had no newline check.
- Class-body `PDeclare`, `PAbstract`, `PAccessor` in `parse_property`
had no newline check.
- `could_be_modifier_keyword` counted `*` after `get`/`set` as a
modifier follow-up; esbuild excludes it.
- `TsStmtDeclare` unconditionally wrapped whatever the recursive parse
returned in `S::TypeScript{}`, so a newline-split keyword that fell
through to `SExpr` silently left the rest of the input as live runtime
statements (`declare type\nFoo = number` emitted `Foo = number;`).

### Fix

- Add the missing `!p.lexer.has_newline_before` gates at each site
above.
- In `TsStmtDeclare`, after the recursive `parse_stmt`, reject any
result that is not `STypeScript`/`SLocal`/`SEmpty` with `Unexpected
"<token>"` pointing at the token captured before recursion. This
uniformly catches `declare
{interface,abstract,type,namespace,module,declare}\n`, `declare foo`,
and `declare foo: bar` while accepting every valid ambient form and
everything inside ambient bodies (`declare namespace { ... }`, `declare
module "m" { ... }`, `declare global { ... }`).
- `export default interface \n Foo {}` stays accepted (esbuild
explicitly allows a newline there) via the existing `is_name_optional`
flag. `export interface \n Foo {}` now reports `Unexpected "interface"`
like esbuild. `@decorator` followed by `declare`/`abstract` split by a
newline reports `Unexpected "declare"`/`"abstract"` instead of the
self-contradictory `Expected "class" but found "class"`.
- Drop the redundant `is_typescript_declare = true` pre-set in
`t_export`'s `SDeclare` arm (the `declare` arm sets it itself).

### Verification

New test in `test/bundler/transpiler/transpiler.test.js` (`contextual
keywords followed by a newline apply ASI instead of acting as
modifiers`) covers 50 assertions across every shape above: the ASI
cases, no-newline control cases, every valid `declare X` form, every
rejected `declare X` form, ambient-body cases (accepted), `export
abstract\n`/`export declare\n` (accepted), and the decorator variants.
Fails at the first assertion on the unfixed build.

Two cases in `test/bundler/transpiler/scope-mismatch-panic.test.ts`
(`declare foo: bar` and `declare module : es2015`) were previously
accepted and are now parse errors matching esbuild; their assertions
moved to the new test block.

- `bun bd test test/bundler/transpiler/transpiler.test.js` — 172 pass, 0
fail
- `bun bd test test/bundler/esbuild/ts.test.ts` — 57 pass, 0 fail
- `bun bd test
test/bundler/transpiler/{decorators,decorator-metadata,es-decorators}.test.ts
test/bundler/bundler_decorator_metadata.test.ts` — 78 pass, 0 fail

Related: #29201 independently adds the `accessor` newline check while
lifting the `standard_decorators` gate; whichever lands second has a
one-hunk rebase on that branch.

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

---

**[review]** gate passed · iteration 3 · 4 files touched

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

```console
ASAN without fix: 1 failed, 22 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/transpiler/scope-mismatch-panic.test.ts test/bundler/transpiler/transpiler.test.js
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (8e0d9d1)

test/bundler/transpiler/scope-mismatch-panic.test.ts:
(pass) scope mismatch panic regression test > should not panic with scope mismatch when arrow function is followed by array literal [741.41ms]
(pass) scope mismatch panic regression test > should not panic with simpler arrow function followed by array [440.32ms]
(pass) scope mismatch panic regression test > correctly rejects direct indexing into block body arrow function [428.17ms]
(pass) macro tagged templates visit their interpolations > tagged template macro with arrow interpolation in dead code is erased [431.69ms]
(pass) macro tagged templates visit their interpolations > tagged template macro with arr
... (truncated)

release without fix: 22 skipped
bun test v1.4.0-canary.1 (21df535)

test/bundler/transpiler/scope-mismatch-panic.test.ts:
(pass) scope mismatch panic regression test > should not panic with scope mismatch when arrow function is followed by array literal [22.06ms]
(pass) scope mismatch panic regression test > should not panic with simpler arrow function followed by array [13.58ms]
(pass) scope mismatch panic regression test > correctly rejects direct indexing into block body arrow function [11.90ms]
(pass) macro tagged templates visit their interpolations > tagged template macro with arrow interpolation in dead code is erased [12.44ms]
(pass) macro tagged templates visit their interpolations > tagged template macro with arrow interpolation reports the macro error [13.21ms]
(pass) macro tagged templates visit their interpolations > member-expression macro tag with function interpolation reports the macro error [11.90ms]
(pass) TypeScript 'declare' statements discard scopes of dropped statements > declare global containing nested blocks followed by a class [24.10ms]
(pass) TypeScript 'declare' statements discard scopes of dropped statements > declare const with an arrow function initializer followe
... (truncated)
```

</details>

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

```console
ASAN with fix: 22 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/transpiler/scope-mismatch-panic.test.ts test/bundler/transpiler/transpiler.test.js
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (8e0d9d1)

test/bundler/transpiler/scope-mismatch-panic.test.ts:
(pass) scope mismatch panic regression test > should not panic with scope mismatch when arrow function is followed by array literal [740.34ms]
(pass) scope mismatch panic regression test > should not panic with simpler arrow function followed by array [440.90ms]
(pass) scope mismatch panic regression test > correctly rejects direct indexing into block body arrow function [435.01ms]
(pass) macro tagged templates visit their interpolations > tagged template macro with arrow interpolation reports the macro error [438.93ms]
(pass) macro tagged templates visit their interpolations > tagged template macro with ar
... (truncated)

release with fix: 22 skipped
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped) in 761ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[0/5] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: component rust-std is up to date

  nightly-2026-05-06-x86_64-unknown-linux-gnu unchanged - rustc 1.97.0-nightly (e95e73209 2026-05-05)

info: checking for self-update (current version: 1.29.0)
�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl
... (truncated)
```

</details>

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

```
src/js_parser/parse/parse_property.rs              |  8 +-
 src/js_parser/parse/parse_stmt.rs                  | 75 ++++++++++++++++--
 .../transpiler/scope-mismatch-panic.test.ts        |  9 +--
 test/bundler/transpiler/transpiler.test.js         | 90 ++++++++++++++++++++++
 4 files changed, 165 insertions(+), 17 deletions(-)
```

</details>

**gate history** · 4 passed · 0 rejected · iteration 3

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

```
file                                                  reads  edits  tests
src/js_parser/parse/parse_property.rs                     3      4      0
src/js_parser/parse/parse_stmt.rs                        11     15      0
test/bundler/transpiler/scope-mismatch-panic.test.ts      3      5      0
test/bundler/transpiler/transpiler.test.js                4     10      0
```

</details>

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

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

This bug came up again on current main. #38125 is a fresh fix on top of today's main that follows the in-place model described in the previous comment (backing private field plus getter/setter pair generated inside the class body, legacy decorators moving to the getter), so a class mixing legacy decorators and accessor members compiles the way tsc compiles it instead of being rejected. It also keeps the can_be_moved fix from this PR, handles useDefineForClassFields: false, computed keys and bundling, and has the tests in test/bundler/transpiler/decorators.test.ts. This PR has conflicts with main at this point; #38125 is meant to replace it.

…decorator lowering

Fixes #29197, #27335.

The `accessor` class-field keyword (TC39 / TS 4.9+) was gated on Bun's
`standard_decorators` flag in the parser, so `experimentalDecorators: true`
rejected it with a confusing "Expected ';'" syntax error. JSC doesn't
parse the keyword natively, so it must be lowered.

Changes (all in the Rust parser — the Zig parser was removed from main):

- parse_property.rs: drop the `standard_decorators` gate on `.p_accessor`;
  add the TC39 `[no LineTerminator here]` restriction (matches `.p_async`)
  so `accessor\n x` is two fields via ASI, not one auto-accessor.
- parse/mod.rs: route any class with an auto-accessor through
  `should_lower_standard_decorators` (WeakMap + getter/setter), and error
  clearly when legacy `@dec` is mixed with `accessor` rather than silently
  rerouting the decorator through the standard-proposal runtime.
- g.rs (can_be_moved): include `.AutoAccessor` static initializers in the
  side-effect check so `static accessor x = sideEffect()` isn't hoisted past
  preceding statements in the non-bundle tree-shaking path.
- lower_decorators.rs: widen the computed-key hoist to undecorated
  auto-accessors so `accessor [k()]` evaluates the key exactly once.

Regression test covers modifiers, no-tsconfig, standard-decorator mode,
mixed-mode error, subclass static access (TC39 brand-check), class
expression, newline ASI, hoist ordering, anonymous default export, and
computed-key single evaluation.
@robobun
robobun force-pushed the farm/9ef3d383/accessor-experimental-decorators branch from 5cf316a to 8c4cbd3 Compare August 13, 2026 18:02
Comment thread src/ast/g.rs Outdated
Comment thread src/js_parser/lower/lower_decorators.rs Outdated
Comment thread src/js_parser/parse/mod.rs Outdated
Comment thread src/js_parser/parse/parse_property.rs Outdated
Comment thread src/ast/g.rs
Comment thread src/js_parser/lower/lower_decorators.rs
Comment thread src/js_parser/parse/mod.rs
Comment thread src/js_parser/parse/parse_property.rs
Comment thread test/regression/issue/29197.test.ts Outdated
Comment thread src/js_parser/parse/mod.rs
The stdout .toBe + exitCode 0 assertions already fail on any panic;
the not.toContain("panic") lines can never fail first and violate
the repo rule against no-panic output checks. Renamed the second test
to state what it positively asserts.
Comment thread src/js_parser/parse/mod.rs
Comment thread src/js_parser/parse/mod.rs
@robobun robobun closed this Aug 13, 2026
Comment on lines +1 to +10
// https://github.com/oven-sh/bun/issues/29197 (and #27335)
//
// The `accessor` keyword (TC39 auto-accessors / TS 4.9+) was rejected as a
// syntax error when a project's tsconfig.json had `experimentalDecorators: true`.
// The keyword should be accepted under either decorator mode. JSC doesn't
// parse `accessor` natively, so any class with auto-accessors is routed
// through the standard-decorator lowering (WeakMap + getter/setter)
// regardless of mode. Mixing `accessor` with legacy TS decorators errors
// clearly instead of silently rerouting decorators through the standard
// runtime.

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.

🟡 Per CLAUDE.md, test/regression/issue/${N}.test.ts is reserved for true regressions (worked in a previous release, then broke) — but the PR's own root-cause section says accessor under experimentalDecorators: true was always gated off, so #29197/#27335 are missing-feature bugs, not regressions. These transpiler/decorator tests belong in test/bundler/transpiler/decorators.test.ts (which is where replacement PR #38125 already puts them, per robobun's 2026-08-13 comment).

Extended reasoning...

What the issue is

CLAUDE.md's Test Organization section is explicit:

Exception: test/regression/issue/${issueNumber}.test.ts is reserved for bugs with a GitHub issue number and that are true regressions (worked in a previous release, then broke). If the behavior was never correct, it's not a regression — the test belongs in the existing file for that module.

This PR places its tests in test/regression/issue/29197.test.ts, but the PR's own "Root cause" section states that the accessor class field modifier "was gated on Bun's internal standard_decorators feature flag in the parser. Under experimentalDecorators: true, standard_decorators is false, so the parser never accepted the keyword." In other words, accessor under experimentalDecorators: true never worked in any prior Bun release — #29197 and #27335 report a missing feature, not a regression.

Where the tests belong

CLAUDE.md's default is "add your test to the existing test file for the code you're changing." The code being changed is the transpiler's decorator/class-property handling (src/js_parser/parse/parse_property.rs, src/js_parser/lower/lower_decorators.rs), and the existing test file for that surface is test/bundler/transpiler/decorators.test.ts (1107 lines, already covering both legacy and standard decorator lowering). The robobun 2026-08-13 timeline comment confirms this: replacement PR #38125 "has the tests in test/bundler/transpiler/decorators.test.ts."

Step-by-step proof

  1. CLAUDE.md reserves test/regression/issue/ for bugs where the behavior was correct in a previous release and then broke.
  2. The PR description's Root cause section: "was gated on Bun's internal standard_decorators feature flag" → the parser has always rejected accessor under experimentalDecorators: true; there is no prior release in which it worked.
  3. Therefore Decorators on accessor class fields fail to parse in Bun #29197 is not a regression by CLAUDE.md's definition; the test file does not qualify for the test/regression/issue/ exception.
  4. CLAUDE.md's fallback rule: "the test belongs in the existing file for that module." test/bundler/transpiler/decorators.test.ts exists on main and is the established home for decorator-transpilation tests.
  5. PR Accept and lower the accessor keyword in TypeScript files using experimentalDecorators #38125 (per robobun 2026-08-13), which is intended to replace this PR, independently places the same coverage in test/bundler/transpiler/decorators.test.ts — corroborating that as the correct location.

Why this matters / impact

Test-organization only; product correctness is unaffected. Placing tests in the wrong directory hurts discoverability (the next person touching lower_decorators.rs looks in decorators.test.ts, not under a five-digit issue number) and duplicates setup that decorators.test.ts already provides. It also dilutes the test/regression/issue/ directory's meaning as a regression-only bucket.

How to fix

Move the ten test.concurrent(...) cases into test/bundler/transpiler/decorators.test.ts (e.g. under a describe("auto-accessor (TC39 / TS 4.9+)", ...) block) and delete test/regression/issue/29197.test.ts. The runBun helper can be replaced with the existing spawn helpers already used in that file, or inlined per-test since each case already builds its own tempDir. Given that #38125 is intended to replace this PR and already places its tests there, aligning now avoids a second churn.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing out in favor of #38125, which fixes #29197 by desugaring each accessor in place (backing private field plus getter/setter pair), so a class that mixes legacy decorators with accessor members compiles the way tsc compiles it instead of being rejected.

The test file from this PR was run against #38125's branch: every case passes there except the deliberate "cannot mix" error and the no-tsconfig computed-key case, which exercises the standard-decorator path and is covered by #31926. The modifier coverage from here (private / protected readonly / public static / abstract accessor) was added to #38125's tests.

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.

Decorators on accessor class fields fail to parse in Bun TypeScript accessor keyword in classes fails to parse

1 participant