fix(transpiler): fix export default class crash and accessor ! parsing with decorators

Fix two bugs in ES decorator lowering:
1. `export default class` with decorators crashed because the code assumed
   the first statement from lowerClass was always s_class, but standard
   decorator lowering can produce prefix variable declarations.
2. `accessor field!: Type` (definite assignment assertion) failed to parse
   because the `!` operator was only recognized for `kind == .normal`,
   excluding `auto_accessor`.

Also fix DCE check panic on non-s_class statements in export default.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jarred Sumner
2026-01-28 22:32:26 +01:00
parent 02d6a87305
commit 01311566ff
4 changed files with 169 additions and 11 deletions

View File

@@ -3768,7 +3768,10 @@ pub fn NewParser_(
}
},
else => {
Output.panic("Unexpected type in export default", .{});
// Standard decorator lowering can produce non-class
// statements as the export default value; conservatively
// assume they have side effects.
return false;
},
}
},

View File

@@ -421,7 +421,7 @@ pub fn ParseProperty(
try p.lexer.next();
} else if (p.lexer.token == .t_exclamation and
!p.lexer.has_newline_before and
kind == .normal and
(kind == .normal or kind == .auto_accessor) and
!opts.is_async and
!opts.is_generator)
{

View File

@@ -461,17 +461,29 @@ pub fn VisitStmt(
}
}
// This is to handle TS decorators, mostly.
// Lower the class (handles both TS legacy and standard decorators).
// Standard decorator lowering may produce prefix statements
// (variable declarations) before the class statement.
var class_stmts = p.lowerClass(.{ .stmt = s2 });
bun.assert(class_stmts[0].data == .s_class);
if (class_stmts.len > 1) {
data.value.stmt = class_stmts[0];
stmts.append(stmt.*) catch {};
stmts.appendSlice(class_stmts[1..]) catch {};
} else {
data.value.stmt = class_stmts[0];
stmts.append(stmt.*) catch {};
// Find the s_class statement in the returned list
var class_stmt_idx: usize = 0;
for (class_stmts, 0..) |cs, idx| {
if (cs.data == .s_class) {
class_stmt_idx = idx;
break;
}
}
// Emit any prefix statements before the export default
stmts.appendSlice(class_stmts[0..class_stmt_idx]) catch {};
data.value.stmt = class_stmts[class_stmt_idx];
stmts.append(stmt.*) catch {};
// Emit any suffix statements after the export default
if (class_stmt_idx + 1 < class_stmts.len) {
stmts.appendSlice(class_stmts[class_stmt_idx + 1 ..]) catch {};
}
if (p.options.features.server_components.wrapsExports()) {

View File

@@ -472,4 +472,147 @@ describe("ES Decorators", () => {
expect(exitCode).toBe(0);
});
});
describe("export default class", () => {
test("export default class with method decorator", async () => {
using dir = tempDir("es-dec-export-default", {
"entry.js": `
import Cls from "./mod.js";
const c = new Cls();
console.log(c.foo());
`,
"mod.js": `
function dec(target, ctx) { return target; }
export default class {
@dec foo() { return 42; }
}
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "entry.js"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
});
const [stdout, rawStderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(filterStderr(rawStderr)).toBe("");
expect(stdout).toBe("42\n");
expect(exitCode).toBe(0);
});
test("export default class with class decorator", async () => {
using dir = tempDir("es-dec-export-default-cls", {
"entry.js": `
import Cls from "./mod.js";
const c = new Cls();
console.log(c.value);
`,
"mod.js": `
function addValue(cls, ctx) {
return class extends cls { value = "decorated"; };
}
@addValue export default class {}
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "entry.js"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
});
const [stdout, rawStderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(filterStderr(rawStderr)).toBe("");
expect(stdout).toBe("decorated\n");
expect(exitCode).toBe(0);
});
test("export default named class with decorator", async () => {
using dir = tempDir("es-dec-export-default-named", {
"entry.js": `
import Cls from "./mod.js";
const c = new Cls();
console.log(c.foo());
`,
"mod.js": `
function dec(target, ctx) { return target; }
export default class MyClass {
@dec foo() { return "named"; }
}
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "entry.js"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
});
const [stdout, rawStderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(filterStderr(rawStderr)).toBe("");
expect(stdout).toBe("named\n");
expect(exitCode).toBe(0);
});
});
describe("accessor with TypeScript annotations", () => {
test("accessor with definite assignment assertion (!)", async () => {
using dir = tempDir("es-dec-accessor-bang", {
"tsconfig.json": JSON.stringify({ compilerOptions: {} }),
"test.ts": `
function dec(target: any, ctx: any) { return target; }
class Foo {
@dec accessor child!: string;
}
const f = new Foo();
f.child = "hello";
console.log(f.child);
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test.ts"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
});
const [stdout, rawStderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(filterStderr(rawStderr)).toBe("");
expect(stdout).toBe("hello\n");
expect(exitCode).toBe(0);
});
test("accessor with optional marker (?)", async () => {
using dir = tempDir("es-dec-accessor-optional", {
"tsconfig.json": JSON.stringify({ compilerOptions: {} }),
"test.ts": `
function dec(target: any, ctx: any) { return target; }
class Foo {
@dec accessor child?: string;
}
const f = new Foo();
console.log(f.child);
f.child = "world";
console.log(f.child);
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test.ts"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
});
const [stdout, rawStderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(filterStderr(rawStderr)).toBe("");
expect(stdout).toBe("undefined\nworld\n");
expect(exitCode).toBe(0);
});
});
});