-
Notifications
You must be signed in to change notification settings - Fork 5k
Parse and lower accessor fields under experimentalDecorators
#29201
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,259 @@ | ||
| // 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. | ||
|
Check warning on line 10 in test/regression/issue/29197.test.ts
|
||
|
Comment on lines
+1
to
+10
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Per CLAUDE.md, Extended reasoning...What the issue is CLAUDE.md's Test Organization section is explicit:
This PR places its tests in 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 ( Step-by-step proof
Why this matters / impact Test-organization only; product correctness is unaffected. Placing tests in the wrong directory hurts discoverability (the next person touching How to fix Move the ten |
||
|
|
||
| import { expect, test } from "bun:test"; | ||
| import { bunEnv, bunExe, tempDir } from "harness"; | ||
|
|
||
| async function runBun(cwd: string, ...args: string[]) { | ||
| await using proc = Bun.spawn({ | ||
| cmd: [bunExe(), ...args], | ||
| env: bunEnv, | ||
| cwd, | ||
| stderr: "pipe", | ||
| stdout: "pipe", | ||
| }); | ||
| return await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); | ||
| } | ||
|
|
||
| test.concurrent("accessor with various modifiers under experimentalDecorators: true", async () => { | ||
| using dir = tempDir("issue-29197-modifiers", { | ||
| "tsconfig.json": JSON.stringify({ | ||
| compilerOptions: { experimentalDecorators: true }, | ||
| }), | ||
| "main.ts": `class Foo { | ||
| accessor a = 1; | ||
| public accessor b = 2; | ||
| private accessor c = 3; | ||
| protected accessor d = 4; | ||
| static accessor e = 5; | ||
| readonly accessor f = 6; | ||
| getC() { return this.c; } | ||
| getD() { return this.d; } | ||
| } | ||
| const f = new Foo(); | ||
| console.log(f.a, f.b, f.getC(), f.getD(), Foo.e, f.f); | ||
| `, | ||
| }); | ||
|
|
||
| const [stdout, , exitCode] = await runBun(String(dir), "main.ts"); | ||
| expect(stdout).toBe("1 2 3 4 5 6\n"); | ||
| expect(exitCode).toBe(0); | ||
| }); | ||
|
|
||
| test.concurrent("accessor without tsconfig (TS file, no decorator flags)", async () => { | ||
| using dir = tempDir("issue-29197-plain", { | ||
| "main.ts": `class Foo { | ||
| accessor x: number = 42; | ||
| } | ||
| console.log(new Foo().x); | ||
| `, | ||
| }); | ||
|
|
||
| const [stdout, , exitCode] = await runBun(String(dir), "main.ts"); | ||
| expect(stdout).toBe("42\n"); | ||
| expect(exitCode).toBe(0); | ||
| }); | ||
|
|
||
| test.concurrent("accessor still works under standard decorators mode", async () => { | ||
| using dir = tempDir("issue-29197-std", { | ||
| "tsconfig.json": JSON.stringify({ | ||
| compilerOptions: { experimentalDecorators: false }, | ||
| }), | ||
| "main.ts": `function dec(value: any, context: any) { | ||
| console.log("dec", context.name, context.kind); | ||
| } | ||
| class Foo { | ||
| @dec accessor x: number = 7; | ||
| } | ||
| console.log(new Foo().x); | ||
| `, | ||
| }); | ||
|
|
||
| const [stdout, , exitCode] = await runBun(String(dir), "main.ts"); | ||
| expect(stdout).toBe("dec x accessor\n7\n"); | ||
| expect(exitCode).toBe(0); | ||
| }); | ||
|
|
||
| test.concurrent( | ||
| "mixing accessor with experimentalDecorators legacy @dec is a clear error, not silent wrong semantics", | ||
| async () => { | ||
| using dir = tempDir("issue-29197-mixed", { | ||
| "tsconfig.json": JSON.stringify({ | ||
| compilerOptions: { experimentalDecorators: true }, | ||
| }), | ||
| "main.ts": `function legacyDec(target: any, key: string) {} | ||
|
|
||
| class Foo { | ||
| @legacyDec | ||
| doThing() {} | ||
|
|
||
| accessor x: number = 0; | ||
| } | ||
| `, | ||
| }); | ||
|
|
||
| const [, stderr, exitCode] = await runBun(String(dir), "main.ts"); | ||
| expect(stderr).toContain("Cannot mix the `accessor` keyword with `experimentalDecorators: true`"); | ||
| expect(exitCode).not.toBe(0); | ||
| }, | ||
| ); | ||
|
|
||
| test.concurrent("static accessor field: direct access works; subclass access throws (TC39 spec)", async () => { | ||
| // The standard-decorator lowering stores static accessor state in a | ||
| // WeakMap keyed on the declaring class. `Counter.count` round-trips; a | ||
| // subclass access (`Sub.count`) invokes the inherited getter with | ||
| // `this === Sub`, which is not in the WeakMap — matches TC39's static | ||
| // private-field brand-check semantics (TypeError at the key lookup). | ||
| using dir = tempDir("issue-29197-subclass", { | ||
| "tsconfig.json": JSON.stringify({ | ||
| compilerOptions: { experimentalDecorators: false }, | ||
| }), | ||
| "main.ts": `class Counter { static accessor count = 10; } | ||
| class Sub extends Counter {} | ||
| console.log(Counter.count); | ||
| Counter.count = 99; | ||
| console.log(Counter.count); | ||
| try { | ||
| console.log("Sub.count=", Sub.count); | ||
| } catch (e) { | ||
| console.log("Sub caught:", (e as any).name); | ||
| } | ||
| `, | ||
| }); | ||
|
|
||
| const [stdout, , exitCode] = await runBun(String(dir), "main.ts"); | ||
| expect(stdout).toBe("10\n99\nSub caught: TypeError\n"); | ||
| expect(exitCode).toBe(0); | ||
| }); | ||
|
|
||
| test.concurrent("accessor field in a class expression", async () => { | ||
| using dir = tempDir("issue-29197-expr", { | ||
| "tsconfig.json": JSON.stringify({ | ||
| compilerOptions: { experimentalDecorators: false }, | ||
| }), | ||
| "main.ts": `const Foo = class { accessor x = 1; }; | ||
| const f = new Foo(); | ||
| console.log(f.x); | ||
| f.x = 2; | ||
| console.log(f.x); | ||
| `, | ||
| }); | ||
|
|
||
| const [stdout, , exitCode] = await runBun(String(dir), "main.ts"); | ||
| expect(stdout).toBe("1\n2\n"); | ||
| expect(exitCode).toBe(0); | ||
| }); | ||
|
|
||
| test.concurrent( | ||
| "newline between `accessor` and the name triggers ASI (two fields, not one auto-accessor)", | ||
| async () => { | ||
| // TC39 grammar: `accessor [no LineTerminator here] ClassElementName`. | ||
| // With a newline, `accessor` must be parsed as a plain field name | ||
| // terminated by ASI, and the following `y = 1` becomes a second | ||
| // data field — NOT a single auto-accessor `y`. Matches tsc/esbuild. | ||
| using dir = tempDir("issue-29197-asi", { | ||
| "tsconfig.json": JSON.stringify({ | ||
| compilerOptions: { experimentalDecorators: true }, | ||
| }), | ||
| "main.ts": `class C { | ||
| accessor | ||
| y = 1 | ||
| } | ||
| const c = new C() as any; | ||
| console.log("keys:", Object.getOwnPropertyNames(c).sort().join(",")); | ||
| console.log("accessor:", c.accessor); | ||
| console.log("y:", c.y); | ||
| `, | ||
| }); | ||
|
|
||
| const [stdout, , exitCode] = await runBun(String(dir), "main.ts"); | ||
| expect(stdout).toBe("keys: accessor,y\naccessor: undefined\ny: 1\n"); | ||
| expect(exitCode).toBe(0); | ||
| }, | ||
| ); | ||
|
|
||
| test.concurrent( | ||
| "`static accessor` with a side-effecting initializer is not hoisted past preceding statements", | ||
| async () => { | ||
| // Non-bundle tree-shaking calls `G::Class::can_be_moved()` on the | ||
| // pre-visit AST. `can_be_moved` used to only inspect `.Normal` static | ||
| // initializers, so a class with `static accessor x = sideEffect()` was | ||
| // (incorrectly) treated as movable and hoisted ahead of preceding | ||
| // statements, inverting evaluation order. | ||
| using dir = tempDir("issue-29197-hoist", { | ||
| "tsconfig.json": JSON.stringify({ | ||
| compilerOptions: { experimentalDecorators: false }, | ||
| }), | ||
| "main.ts": | ||
| 'console.log("first");\n' + | ||
| "export class Foo {\n" + | ||
| ' static accessor x = (console.log("second"), 42);\n' + | ||
| "}\n" + | ||
| 'console.log("third");\n', | ||
| }); | ||
|
|
||
| const [stdout, , exitCode] = await runBun(String(dir), "main.ts"); | ||
| expect(stdout).toBe("first\nsecond\nthird\n"); | ||
| expect(exitCode).toBe(0); | ||
| }, | ||
| ); | ||
|
|
||
| test.concurrent("anonymous `export default class` with a static accessor round-trips", async () => { | ||
| // Regression: `export default class { static accessor x = 1 }` used | ||
| // to trip a null-ref panic in `lower_standard_decorators_stmt` because | ||
| // `class.class_name` was only injected from `default_name` when the | ||
| // class had decorators. Auto-accessors go through the same lowering, | ||
| // so the name injection now also runs when any property is an | ||
| // `AutoAccessor`. | ||
| using dir = tempDir("issue-29197-default-export", { | ||
| "tsconfig.json": JSON.stringify({ | ||
| compilerOptions: { experimentalDecorators: false }, | ||
| }), | ||
| "base.ts": "export default class { static accessor x = 1; }\n", | ||
| "main.ts": | ||
| "import Base from './base';\n" + | ||
| "console.log('x=', Base.x);\n" + | ||
| "Base.x = 42;\n" + | ||
| "console.log('x=', Base.x);\n", | ||
| }); | ||
|
|
||
| const [stdout, , exitCode] = await runBun(String(dir), "main.ts"); | ||
| expect(stdout).toBe("x= 1\nx= 42\n"); | ||
| expect(exitCode).toBe(0); | ||
| }); | ||
|
|
||
| test.concurrent("accessor with a computed key evaluates the key exactly once", async () => { | ||
| // TC39 auto-accessor spec requires the PropertyName to be evaluated | ||
| // once. An undecorated `accessor [k()] = 1` lowers through | ||
| // `lower_decorators` into a `get [k()]` / `set [k()]` pair that shares | ||
| // `prop.key`; without the computed-key hoist (gated in older code on | ||
| // `ts_decorators.len > 0`), `k()` runs twice — breaking the spec and | ||
| // installing the getter/setter under different keys for a non-idempotent | ||
| // key. Widened the hoist gate to include `AutoAccessor`. | ||
| using dir = tempDir("issue-29197-computed-key", { | ||
| "main.ts": `let calls = 0; | ||
| const k = () => (calls++, "x"); | ||
| class C { | ||
| accessor [k()] = 42; | ||
| } | ||
| const c = new C() as any; | ||
| console.log("calls=", calls); | ||
| console.log("x=", c.x); | ||
| c.x = 99; | ||
| console.log("x=", c.x); | ||
| console.log("calls=", calls); | ||
| `, | ||
| }); | ||
|
|
||
| const [stdout, , exitCode] = await runBun(String(dir), "main.ts"); | ||
| expect(stdout).toBe("calls= 1\nx= 42\nx= 99\ncalls= 1\n"); | ||
| expect(exitCode).toBe(0); | ||
| }); | ||
Uh oh!
There was an error while loading. Please reload this page.