Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/ast/parse.zig
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ pub fn Parse(
.body_loc = body_loc,
.properties = properties.items,
.has_decorators = has_any_decorators,
.should_lower_standard_decorators = p.options.features.standard_decorators and (has_any_decorators or has_auto_accessor),
.should_lower_standard_decorators = has_auto_accessor or (p.options.features.standard_decorators and has_any_decorators),

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.

🔴 Critical: Bug: when experimentalDecorators: true and a class has an accessor field, should_lower_standard_decorators is set to true because has_auto_accessor is now evaluated before the standard_decorators guard. This routes the entire class (including any legacy experimental decorators on other members) through the TC39 standard decorator lowering path (lowerStandardDecoratorsStmt / __decorateElement), which calls decorators with (value, context) instead of the expected experimental signature (target, key, descriptor). The fix should gate has_auto_accessor on standard_decorators as well, or handle accessor lowering separately from the decorator lowering path when in experimental mode.

Why this is a problem

What the Bug Is

The PR changes the assignment of should_lower_standard_decorators from:

p.options.features.standard_decorators and (has_any_decorators or has_auto_accessor)

to:

has_auto_accessor or (p.options.features.standard_decorators and has_any_decorators)

This boolean logic change means that the presence of an accessor field alone is now sufficient to set should_lower_standard_decorators = true, regardless of whether the file is using standard or experimental decorators. In the old code, standard_decorators was a prerequisite for the entire expression, ensuring experimental decorator mode could never reach the standard lowering path. In the new code, has_auto_accessor short-circuits past that guard.

The Specific Code Path

When experimentalDecorators: true is set in a TypeScript project's tsconfig.json, the transpiler sets opts.features.standard_decorators = false (confirmed at src/transpiler.zig:1106 and src/bundler/ParseTask.zig:1217). Under these conditions, a class like:

class Foo {
  accessor x = 1;
  @log greet() { return "hello"; }
}

will have has_auto_accessor = true (from the accessor x field) and has_any_decorators = true (from the @log decorator). With the new code, should_lower_standard_decorators evaluates to true or (false and true) = true. In P.zig lowerClass (line 4873), when should_lower_standard_decorators is true, the class is immediately routed to lowerStandardDecoratorsStmt which returns, completely bypassing the legacy experimental decorator path starting at line 4884.

Why Existing Tests Don't Catch It

The PR includes a test case (test case 4: "accessor with experimental decorators on other members") that appears to validate this scenario, but the @log decorator used in that test is a no-op: function log(target: any, key: string) {}. Since it discards its arguments, it produces no observable difference regardless of whether it's called with (value, context) (standard TC39 protocol) or (target, key, descriptor) (experimental protocol). A real-world experimental decorator that inspects or modifies target, key, or descriptor would silently receive incorrect arguments.

Step-by-Step Proof

  1. User has experimentalDecorators: true in tsconfig.json, so standard_decorators = false.
  2. User writes a class with both accessor x = 1 and @myDecorator greet() {}, where myDecorator is an experimental-style decorator expecting (target, key, descriptor).
  3. The parser sets has_auto_accessor = true and has_any_decorators = true.
  4. Line 212 evaluates: should_lower_standard_decorators = true or (false and true) = true.
  5. In P.zig lowerClass (line 4873), the condition stmt.data.s_class.class.should_lower_standard_decorators is true.
  6. lowerStandardDecoratorsStmt is called, which processes ALL decorators in the class using __decorateElement (in lowerDecorators.zig), calling each decorator with the TC39 standard protocol (value, context).
  7. @myDecorator receives (value, context) instead of (target, key, descriptor). The target parameter gets a function value instead of the class prototype, key gets a context object instead of the method name string, and descriptor is undefined.
  8. The decorator silently produces incorrect behavior or throws a runtime error.

How to Fix It

The fix should ensure that has_auto_accessor only triggers the standard decorator lowering path when standard_decorators is also true. One approach is:

.should_lower_standard_decorators = p.options.features.standard_decorators and (has_any_decorators or has_auto_accessor),

(i.e., reverting to the original logic) and handling the accessor keyword lowering for experimental decorator mode through a separate mechanism. Alternatively, if the intent is to support accessor with experimental decorators, the accessor field should be lowered independently of the decorator lowering path, perhaps by transforming it into a getter/setter pair without routing through lowerStandardDecoratorsStmt.

};
}

Expand Down
5 changes: 3 additions & 2 deletions src/ast/parseProperty.zig
Original file line number Diff line number Diff line change
Expand Up @@ -300,8 +300,9 @@ pub fn ParseProperty(
}
},
.p_accessor => {
// "accessor" keyword for auto-accessor fields (TC39 standard decorators)
if (opts.is_class and p.options.features.standard_decorators and
// "accessor" keyword for auto-accessor fields (TC39 proposal)
// Always recognized in classes regardless of decorator mode
if (opts.is_class and
(js_lexer.PropertyModifierKeyword.List.get(raw) orelse .p_static) == .p_accessor)
{
kind = .auto_accessor;
Expand Down
140 changes: 140 additions & 0 deletions test/regression/issue/27335.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";

// https://github.com/oven-sh/bun/issues/27335
// The `accessor` keyword should work in TypeScript classes even when
// `experimentalDecorators: true` is set in tsconfig.json.

test("accessor keyword works with experimentalDecorators: true", async () => {
using dir = tempDir("issue-27335", {
"tsconfig.json": JSON.stringify({
compilerOptions: {
experimentalDecorators: true,
},
}),
"main.ts": `
class Person {
public accessor name: string = "John";
}

const p = new Person();
console.log(p.name);
`,
});

await using proc = Bun.spawn({
cmd: [bunExe(), "main.ts"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
});

const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

expect(stdout).toBe("John\n");
expect(exitCode).toBe(0);
});

test("accessor keyword works with various modifiers and experimentalDecorators", async () => {
using dir = tempDir("issue-27335-modifiers", {
"tsconfig.json": JSON.stringify({
compilerOptions: {
experimentalDecorators: true,
},
}),
"main.ts": `
class Foo {
accessor x = 1;
public accessor y = 2;
private accessor z = 3;
static accessor w = 4;

getZ() { return this.z; }
}

const f = new Foo();
console.log(f.x);
console.log(f.y);
console.log(f.getZ());
console.log(Foo.w);
`,
});

await using proc = Bun.spawn({
cmd: [bunExe(), "main.ts"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
});

const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

expect(stdout).toBe("1\n2\n3\n4\n");
expect(exitCode).toBe(0);
});

test("accessor keyword works without experimentalDecorators (standard mode)", async () => {
using dir = tempDir("issue-27335-standard", {
"tsconfig.json": JSON.stringify({
compilerOptions: {},
}),
"main.ts": `
class Person {
public accessor name: string = "John";
}

const p = new Person();
console.log(p.name);
`,
});

await using proc = Bun.spawn({
cmd: [bunExe(), "main.ts"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
});

const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

expect(stdout).toBe("John\n");
expect(exitCode).toBe(0);
});

test("accessor with experimental decorators on other members", async () => {
using dir = tempDir("issue-27335-mixed", {
"tsconfig.json": JSON.stringify({
compilerOptions: {
experimentalDecorators: true,
},
}),
"main.ts": `
function log(target: any, key: string) {
// simple experimental decorator
}

class MyClass {
@log
greet() { return "hello"; }

accessor count: number = 42;
}

const obj = new MyClass();
console.log(obj.greet());
console.log(obj.count);
`,
});

await using proc = Bun.spawn({
cmd: [bunExe(), "main.ts"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
});

const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

expect(stdout).toBe("hello\n42\n");
expect(exitCode).toBe(0);
});