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

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, …

Loading
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