From a08b50c6ec11a81568f7cf878330c7a2af2a9010 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:32:56 +0000 Subject: [PATCH 1/3] js_parser: parse `export default @dec class` as a class declaration A decorator after `export default` starts a class declaration. The declaration list of the `export default` arm did not hold the `@` token, so the class was parsed as an expression. Its name was not bound in the module, a `(` on the next line became a call on the class, `abstract` was rejected, and experimentalDecorators dropped the decorator. The declaration path gave an anonymous default class the symbol of the default export as its name. Standard decorator lowering now takes that binding as a separate input and names the class "default". The binding becomes the class name only when class decorators rebind the class. --- src/js_parser/lower/lower_decorators.rs | 12 +- src/js_parser/p.rs | 2 +- src/js_parser/parse/parse_stmt.rs | 3 + src/js_parser/visit/visit_stmt.rs | 21 +- src/jsc/RuntimeTranspilerCache.rs | 4 +- ...after-export-default-class-fixture-anon.ts | 11 + ...ator-after-export-default-class-fixture.ts | 12 + test/bundler/transpiler/decorators.test.ts | 18 ++ test/bundler/transpiler/es-decorators.test.ts | 238 ++++++++++++++++++ 9 files changed, 310 insertions(+), 11 deletions(-) create mode 100644 test/bundler/transpiler/decorator-after-export-default-class-fixture-anon.ts create mode 100644 test/bundler/transpiler/decorator-after-export-default-class-fixture.ts diff --git a/src/js_parser/lower/lower_decorators.rs b/src/js_parser/lower/lower_decorators.rs index 65ee01070f57..75ffee537e00 100644 --- a/src/js_parser/lower/lower_decorators.rs +++ b/src/js_parser/lower/lower_decorators.rs @@ -720,25 +720,31 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool, const SEMA: bool> // ── Public API ─────────────────────────────────────── + /// `default_export` is the binding of an `export default class` statement, + /// the one class statement that can have no name. Such a class is named + /// "default", and takes the binding as its name only when class decorators + /// have to rebind it. pub(crate) fn lower_standard_decorators_stmt( &mut self, stmt: Stmt, + default_export: Option, out: &mut BumpVec<'a, Stmt>, ) { let mut s_class = match stmt.data { js_ast::StmtData::SClass(c) => c, _ => unreachable!(), }; - let lowered = self.lower_class_body(&mut s_class.class, stmt.loc, None); + let name_from_context = default_export.map(|_| js_ast::ClauseItem::DEFAULT_ALIAS); + let lowered = self.lower_class_body(&mut s_class.class, stmt.loc, name_from_context); out.extend(lowered.temps); let Some(decorators) = lowered.class_decorators else { out.push(stmt); return; }; - let name = s_class + let name = *s_class .class .class_name - .expect("a class statement has a name"); + .get_or_insert_with(|| default_export.expect("a class statement has a name")); let decorated = self.use_ref(decorators.decorated, name.loc); let rebind = self.assign_to(name.ref_, decorated, name.loc); out.push(self.expr_stmt(decorators.evaluate, stmt.loc)); diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index dc3c9ee4fe7c..2d9d91802092 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -7454,7 +7454,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool, const SEMA: bool> // `lower_standard_decorators_stmt` takes an out-param Vec; wrap to // keep this function's slice contract. let mut out = BumpVec::::new_in(self.arena); - self.lower_standard_decorators_stmt(stmt, &mut out); + self.lower_standard_decorators_stmt(stmt, None, &mut out); return out.into_bump_slice_mut(); } diff --git a/src/js_parser/parse/parse_stmt.rs b/src/js_parser/parse/parse_stmt.rs index d98ce05993df..6131a5f50041 100644 --- a/src/js_parser/parse/parse_stmt.rs +++ b/src/js_parser/parse/parse_stmt.rs @@ -1584,8 +1584,11 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool, const SEMA: bool> )); } + // "@" is in the lookahead set that rules out an expression after + // "export default": a decorator here starts a class declaration. if p.lexer.token == T::TFunction || p.lexer.token == T::TClass + || p.lexer.token == T::TAt || p.lexer.is_contextual_keyword(b"interface") { let mut _opts = ParseStatementOptions { diff --git a/src/js_parser/visit/visit_stmt.rs b/src/js_parser/visit/visit_stmt.rs index 1ef633e650fc..806fa84a5782 100644 --- a/src/js_parser/visit/visit_stmt.rs +++ b/src/js_parser/visit/visit_stmt.rs @@ -816,12 +816,11 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool, const SEMA: bool> data.default_name = p.create_default_name(stmt.loc); } - // We only inject a name into classes when decorator lowering - // needs one: legacy TS decorators (`has_decorators`) or - // standard decorator lowering, which also covers classes with - // only auto-accessor fields and no decorators. + // Only the legacy TS decorator lowering refers to the class by + // name, so only it has the default export's symbol injected as + // the name of an anonymous class. if class.class.has_decorators - || class.class.should_lower_standard_decorators + && !class.class.should_lower_standard_decorators { if class.class.class_name.is_none() || class.class.class_name.unwrap().ref_.is_empty() @@ -833,7 +832,17 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool, const SEMA: bool> // Lower the class (handles both TS legacy and standard decorators). // Standard decorator lowering may produce prefix statements // (variable declarations) before the class statement. - let class_stmts = p.lower_class(js_ast::StmtOrExpr::Stmt(s2_copy)); + let class_stmts = if class.class.should_lower_standard_decorators { + let mut lowered: StmtList<'a> = BumpVec::new_in(p.arena); + p.lower_standard_decorators_stmt( + s2_copy, + Some(data.default_name), + &mut lowered, + ); + lowered.into_bump_slice_mut() + } else { + p.lower_class(js_ast::StmtOrExpr::Stmt(s2_copy)) + }; // Find the s_class statement in the returned list let mut class_stmt_idx: usize = 0; diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index ff170d4c8c38..cb335c2bcea9 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -64,7 +64,9 @@ bun_core::declare_scope!(cache, visible); /// `onResolve` rewrote (`namespace:path`). Older entries request the bare path, and /// the cache-HIT path reinstates #33904 for them. /// Version 34: An import that a plugin `onResolve` answers is printed as it is written. -const EXPECTED_VERSION: u32 = 34; +/// Version 35: `export default @dec class` is a class declaration, and standard decorator +/// lowering names an anonymous `export default class` "default". +const EXPECTED_VERSION: u32 = 35; /// Source files smaller than this are not written to / read from the on-disk /// transpiler cache. Originally 50 KiB, which excluded almost every file in a diff --git a/test/bundler/transpiler/decorator-after-export-default-class-fixture-anon.ts b/test/bundler/transpiler/decorator-after-export-default-class-fixture-anon.ts new file mode 100644 index 000000000000..bc1f2122c1fa --- /dev/null +++ b/test/bundler/transpiler/decorator-after-export-default-class-fixture-anon.ts @@ -0,0 +1,11 @@ +export const decorated: any[] = []; + +function decorator(target: any) { + decorated.push(target); +} + +export default @decorator class { + method() { + return 42; + } +} diff --git a/test/bundler/transpiler/decorator-after-export-default-class-fixture.ts b/test/bundler/transpiler/decorator-after-export-default-class-fixture.ts new file mode 100644 index 000000000000..824780ef5194 --- /dev/null +++ b/test/bundler/transpiler/decorator-after-export-default-class-fixture.ts @@ -0,0 +1,12 @@ +export const decorated: string[] = []; + +function decorator(target: any) { + decorated.push(target.name); + return class Replaced extends target { + replaced = true; + }; +} + +export default @decorator class DecoratedClass {} + +export const binding = DecoratedClass; diff --git a/test/bundler/transpiler/decorators.test.ts b/test/bundler/transpiler/decorators.test.ts index 80800f17c415..273e75434897 100644 --- a/test/bundler/transpiler/decorators.test.ts +++ b/test/bundler/transpiler/decorators.test.ts @@ -1,6 +1,13 @@ // @ts-nocheck import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe } from "harness"; +import DecoratedAfterDefaultClass, { + decorated as decoratedAfterDefault, + binding as decoratedAfterDefaultBinding, +} from "./decorator-after-export-default-class-fixture"; +import DecoratedAfterDefaultAnonClass, { + decorated as decoratedAfterDefaultAnon, +} from "./decorator-after-export-default-class-fixture-anon"; import DecoratedClass from "./decorator-export-default-class-fixture"; import DecoratedAnonClass from "./decorator-export-default-class-fixture-anon"; @@ -1020,6 +1027,17 @@ test("export default class works (anonymous name)", () => { expect(new DecoratedAnonClass()["methoddecorated"]).toBe(true); }); +test("export default @decorator class Named runs the decorator and binds the name", () => { + expect(decoratedAfterDefault).toEqual(["DecoratedClass"]); + expect(decoratedAfterDefaultBinding).toBe(DecoratedAfterDefaultClass); + expect(new DecoratedAfterDefaultClass()["replaced"]).toBe(true); +}); + +test("export default @decorator class runs the decorator (anonymous name)", () => { + expect(decoratedAfterDefaultAnon).toEqual([DecoratedAfterDefaultAnonClass]); + expect(new DecoratedAfterDefaultAnonClass().method()).toBe(42); +}); + test("field with supra-BMP string-literal key and initializer is assigned under the correct key", () => { function dec(_t: any, _k: any) {} class Foo { diff --git a/test/bundler/transpiler/es-decorators.test.ts b/test/bundler/transpiler/es-decorators.test.ts index f46d0e8c403d..5c3181832cdb 100644 --- a/test/bundler/transpiler/es-decorators.test.ts +++ b/test/bundler/transpiler/es-decorators.test.ts @@ -969,6 +969,58 @@ describe("ES Decorators", () => { expect(stdout).toBe("true\n"); expect(exitCode).toBe(0); }); + + test("Bun.Transpiler prints export default @dec class as a class declaration", () => { + const transpiler = new Bun.Transpiler({ loader: "js" }); + const source = "function dec() {}\nexport default @dec class Df {}"; + expect(transpiler.transformSync(source)).toContain("export default class Df {"); + expect(transpiler.scan(source).exports).toEqual(["default"]); + }); + + function parseErrors(source: string) { + try { + new Bun.Transpiler({ loader: "ts" }).transformSync(source); + return []; + } catch (e: any) { + return (e.errors ?? [e]).map((error: Error) => error.message); + } + } + + test.each([ + ["@a export default @b class X {}", ['Expected "class" but found "@"']], + ["export default @dec function f() {}", ['Expected "class" but found "function"']], + ["export default @dec export class X {}", ["Unexpected export"]], + // The class declaration ends at its closing brace. + ["export default @dec class Df {} as any", ['Expected ";" but found "any"']], + ["export default @dec class Df {}.name", ["Unexpected ."]], + // The class name is declared in the module scope. + ["const Df = 1; export default @dec class Df {}", ['"Df" has already been declared']], + ])("%s is a syntax error", (source, messages) => { + expect(parseErrors(source)).toEqual(messages); + }); + + // A top-level `using` is lowered for targets other than bun. That lowering + // drops every `export default` class declaration, so the build fails with + // `No matching export in "mod.js" for import "default"`. `export default + // class Df {}` and `@dec export default class Df {}` fail the same way. + test.todo("export default @dec class next to a lowered top-level using", async () => { + using dir = tempDir("es-dec-export-default-using", { + "entry.js": ` + import Cls from "./mod.js"; + console.log(Cls.name); + `, + "mod.js": ` + function dec(cls, ctx) {} + using resource = { [Symbol.dispose]() {} }; + export default @dec class Df {} + `, + }); + + const build = await runIn(String(dir), ["build", "entry.js", "--target=node", "--outfile=out.js"]); + expect({ stderr: filterStderr(build.stderr), exitCode: build.exitCode }).toEqual({ stderr: "", exitCode: 0 }); + const { stdout, stderr, exitCode } = await runIn(String(dir), ["out.js"]); + expect({ stdout, stderr: filterStderr(stderr), exitCode }).toEqual({ stdout: "Df\n", stderr: "", exitCode: 0 }); + }); }); describe("anonymous class expressions with reserved-word inferred names", () => { @@ -2534,3 +2586,189 @@ describe("ES decorators lowering matrix", () => { }); }); }); + +// `export default @dec class` is a class declaration, like `@dec export default +// class`. Each cell is a module with its own default export; `main` imports +// them all and prints one JSON array. Per cell, `seen` is what the decorators +// were called with, `inside` is what the module observed of its own binding, +// and `name` is the `.name` of the default import. + +const defaultExportPrelude = ` +const seen = []; +function dec(value, ctx) { + seen.push(ctx.kind + " " + ctx.name); +} +function mk() { + return dec; +} +const ns = { dec }; +function observe(fn) { + try { + return fn(); + } catch (e) { + return e.name; + } +} +`; + +type DefaultExportCell = { + source: string; + expected: { seen: string[]; inside?: unknown; name?: string }; + // TypeScript syntax. + ts?: true; + // `bun build` names every default class statement after its module, decorated + // or not, and a wrapped module in a bundle has no TDZ for its classes. + unbundledOnly?: true; +}; + +const namedClass = `class Df { m() { return "m"; } }`; +const insideNamedClass = `export const inside = observe(() => [typeof Df, new Df().m()]);`; +const namedClassExpected = { seen: ["class Df"], inside: ["function", "m"], name: "Df" }; +const anonymousClassExpected = { seen: ["class default"], name: "default" }; + +const defaultExportCells: Record = { + "named class": { + source: `export default @dec ${namedClass}\n${insideNamedClass}`, + expected: namedClassExpected, + }, + "two decorators": { + source: `export default @dec @dec ${namedClass}\n${insideNamedClass}`, + expected: { ...namedClassExpected, seen: ["class Df", "class Df"] }, + }, + "decorator call": { + source: `export default @mk() ${namedClass}\n${insideNamedClass}`, + expected: namedClassExpected, + }, + "decorator member access": { + source: `export default @ns.dec ${namedClass}\n${insideNamedClass}`, + expected: namedClassExpected, + }, + "parenthesized decorator": { + source: `export default @(dec) ${namedClass}\n${insideNamedClass}`, + expected: namedClassExpected, + }, + "line break before class": { + source: `export default @dec\n${namedClass}\n${insideNamedClass}`, + expected: namedClassExpected, + }, + "next line starts with (": { + source: `export default @dec ${namedClass}\n(() => seen.push("next statement"))();\n${insideNamedClass}`, + expected: { ...namedClassExpected, seen: ["class Df", "next statement"] }, + }, + "next line starts with [": { + source: `export default @dec ${namedClass}\n[seen.push("next statement")];\n${insideNamedClass}`, + expected: { ...namedClassExpected, seen: ["class Df", "next statement"] }, + }, + "decorator returns a subclass": { + source: ` + function replace(value) { + return class Replaced extends value { + replaced = true; + }; + } + export default @replace class Df {} + export const inside = observe(() => [Df.name, new Df().replaced]); + `, + expected: { seen: [], inside: ["Replaced", true], name: "Replaced" }, + }, + "temporal dead zone": { + source: ` + const before = observe(() => Df); + export default @dec class Df {} + export const inside = [before, observe(() => typeof Df)]; + `, + expected: { seen: ["class Df"], inside: ["ReferenceError", "function"], name: "Df" }, + unbundledOnly: true, + }, + "abstract class": { + source: `export default @dec abstract ${namedClass}\n${insideNamedClass}`, + expected: namedClassExpected, + ts: true, + }, + "declare class": { + source: `export default @mk(() => 0) declare class Df {}`, + expected: { seen: [] }, + ts: true, + }, + "anonymous class": { + source: `export default @dec class {}`, + expected: anonymousClassExpected, + }, + "anonymous class, next line starts with (": { + source: `export default @dec class {}\n(() => seen.push("next statement"))();`, + expected: { ...anonymousClassExpected, seen: ["class default", "next statement"] }, + }, + "anonymous class, decorator before export": { + source: `@dec export default class {}`, + expected: anonymousClassExpected, + }, + "anonymous abstract class": { + source: `export default @dec abstract class {}`, + expected: anonymousClassExpected, + ts: true, + }, + "anonymous abstract class, decorator before export": { + source: `@dec export default abstract class {}`, + expected: anonymousClassExpected, + ts: true, + }, + "anonymous class with a decorated method": { + source: `export default class { @dec m() {} }`, + expected: { seen: ["method m"], name: "default" }, + unbundledOnly: true, + }, + "anonymous class with an accessor": { + source: `export default class { accessor x = 1; }`, + expected: { seen: [], name: "default" }, + unbundledOnly: true, + }, +}; + +async function runDefaultExportCells(mode: Mode) { + const ext = mode.file.slice(mode.file.lastIndexOf(".") + 1); + const names = Object.keys(defaultExportCells).filter(name => { + const cell = defaultExportCells[name]; + return (!cell.ts || ext === "ts") && !(cell.unbundledOnly && mode.bundle); + }); + const files: Record = { "tsconfig.json": "{}" }; + names.forEach((name, i) => { + files[`cell${i}.${ext}`] = `${defaultExportPrelude}${defaultExportCells[name].source}\nexport { seen };\n`; + }); + files[mode.file] = ` + ${names.map((_, i) => `import * as cell${i} from "./cell${i}";`).join("\n")} + const cells = [${names.map((_, i) => `cell${i}`).join(", ")}]; + console.log(JSON.stringify(cells.map(cell => ({ seen: cell.seen, inside: cell.inside, name: cell.default?.name })))); + `; + + using dir = tempDir("es-dec-export-default-cells", files); + let entry = mode.file; + if (mode.bundle) { + const build = await runIn(String(dir), ["build", mode.file, "--target=bun", "--outfile=bundled.js"]); + if (build.exitCode !== 0) return { names, out: {}, stderr: filterStderr(build.stderr), exitCode: build.exitCode }; + entry = "bundled.js"; + } + const { stdout, stderr, exitCode } = await runIn(String(dir), [entry]); + const results: unknown[] = exitCode === 0 ? JSON.parse(stdout) : []; + const out: Record = Object.fromEntries(names.map((name, i) => [name, results[i]])); + return { names, out, stderr: filterStderr(stderr), exitCode }; +} + +const defaultExportRuns = await Promise.all(modes.map(runDefaultExportCells)); + +describe("export default @dec class is a class declaration", () => { + modes.forEach((mode, i) => { + describe(mode.name, () => { + const { names, out, stderr, exitCode } = defaultExportRuns[i]; + + test("the fixture runs", () => { + expect({ stderr, exitCode }).toEqual({ stderr: "", exitCode: 0 }); + }); + + for (const name of names) { + test(name, () => { + expect(out[name]).toEqual(defaultExportCells[name].expected); + }); + } + }); + }); +}); From 87b6862e169c96381567c9b81b5b7c105b51243a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:34:27 +0000 Subject: [PATCH 2/3] js_parser: report a decorator with no class once, keep exports.replace for default classes After `export default`, a decorator that no class follows reported `Expected "class"` and then a second `Unexpected ""` from the fallback arm. The arm now returns after the first report. `exports.replace` for `default` set the replacement and then lowered the class, which put the class back. The class arm now emits the replacement and returns, as the function arm does. `export default @dec class` kept the replacement only because it was parsed as an expression. Tests: minified and split bundles of `export default @dec class`, the `abstract = 1` and `declare = 1` tails after decorators, and the temporal dead zone cell in the bundled run. --- src/js_parser/p.rs | 3 +- src/js_parser/parse/parse_stmt.rs | 17 ++- src/js_parser/visit/visit_stmt.rs | 15 ++- test/bundler/transpiler/es-decorators.test.ts | 124 +++++++++++++++--- test/bundler/transpiler/transpiler.test.js | 32 +++++ 5 files changed, 164 insertions(+), 27 deletions(-) diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 2d9d91802092..b8b833c255c3 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -7452,7 +7452,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool, const SEMA: bool> // Standard decorator lowering path (for both JS and TS files) if s_class.class.should_lower_standard_decorators { // `lower_standard_decorators_stmt` takes an out-param Vec; wrap to - // keep this function's slice contract. + // keep this function's slice contract. `s_export_default` calls + // it itself, so a class that arrives here has a name. let mut out = BumpVec::::new_in(self.arena); self.lower_standard_decorators_stmt(stmt, None, &mut out); return out.into_bump_slice_mut(); diff --git a/src/js_parser/parse/parse_stmt.rs b/src/js_parser/parse/parse_stmt.rs index 6131a5f50041..b7a692529e9d 100644 --- a/src/js_parser/parse/parse_stmt.rs +++ b/src/js_parser/parse/parse_stmt.rs @@ -1597,6 +1597,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool, const SEMA: bool> lexical_decl: LexicalDecl::AllowAll, ..Default::default() }; + let errors_before_decorators = + (p.lexer.token == T::TAt).then(|| p.log().errors); let stmt = p.parse_stmt(&mut _opts)?; let default_name: LocRef = 'default_name_getter: { @@ -1626,11 +1628,18 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool, const SEMA: bool> // declaration: the nested statement came back as an // expression statement ("export default interface = 2", // "export default interface => 1") or a labeled statement - // ("export default interface: 0"). None of these can be a - // default export value, so report a syntax error instead of - // building an S.ExportDefault that the visit and print - // passes don't support. + // ("export default interface: 0"). Decorators that no class + // follows end here too ("export default @dec abstract = 1"). + // None of these can be a default export value, so report a + // syntax error instead of building an S.ExportDefault that the + // visit and print passes don't support. _ => { + // `t_at` has reported the token that is not a class. + if errors_before_decorators + .is_some_and(|errors| p.log().errors > errors) + { + return Err(crate::Error::SyntaxError); + } let r = js_lexer::range_of_identifier(p.source, p.real_loc(stmt.loc)); p.log().add_range_error_fmt( diff --git a/src/js_parser/visit/visit_stmt.rs b/src/js_parser/visit/visit_stmt.rs index 806fa84a5782..707333ca86e4 100644 --- a/src/js_parser/visit/visit_stmt.rs +++ b/src/js_parser/visit/visit_stmt.rs @@ -798,7 +798,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool, const SEMA: bool> replace_expr, ) = entry { + // The class is discarded. Lowering it would put it back + // in `data.value`. data.value = js_ast::StmtOrExpr::Expr(replace_expr); + stmts.push(*stmt); } else { let _ = p.inject_replacement_export( stmts, @@ -806,19 +809,19 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool, const SEMA: bool> bun_ast::Loc::EMPTY, &entry, ); - restore_dead!(); - record_on_exit!(); - return Ok(()); } + restore_dead!(); + record_on_exit!(); + return Ok(()); } if !data.default_name.ref_.is_symbol() { data.default_name = p.create_default_name(stmt.loc); } - // Only the legacy TS decorator lowering refers to the class by - // name, so only it has the default export's symbol injected as - // the name of an anonymous class. + // The legacy TS decorator lowering reads `class_name`, so an + // anonymous class takes the default export's symbol as its name. + // The standard lowering takes that symbol as an argument. if class.class.has_decorators && !class.class.should_lower_standard_decorators { diff --git a/test/bundler/transpiler/es-decorators.test.ts b/test/bundler/transpiler/es-decorators.test.ts index 5c3181832cdb..d325ce57ce5f 100644 --- a/test/bundler/transpiler/es-decorators.test.ts +++ b/test/bundler/transpiler/es-decorators.test.ts @@ -988,7 +988,12 @@ describe("ES Decorators", () => { test.each([ ["@a export default @b class X {}", ['Expected "class" but found "@"']], + // A decorator that no class follows is reported once. ["export default @dec function f() {}", ['Expected "class" but found "function"']], + ["export default @dec 1", ['Expected "class" but found "1"']], + ["export default @dec(1);", ['Expected "class" but found ";"']], + ["export default @dec const x = 1", ['Expected "class" but found "const"']], + ["export default @dec enum E {}", ['Expected "class" but found "enum"']], ["export default @dec export class X {}", ["Unexpected export"]], // The class declaration ends at its closing brace. ["export default @dec class Df {} as any", ['Expected ";" but found "any"']], @@ -999,28 +1004,113 @@ describe("ES Decorators", () => { expect(parseErrors(source)).toEqual(messages); }); - // A top-level `using` is lowered for targets other than bun. That lowering - // drops every `export default` class declaration, so the build fails with - // `No matching export in "mod.js" for import "default"`. `export default - // class Df {}` and `@dec export default class Df {}` fail the same way. - test.todo("export default @dec class next to a lowered top-level using", async () => { - using dir = tempDir("es-dec-export-default-using", { + // As a class expression, the class gave its own name to the default export. + // The minifier then gave the import the name of a local. + test("a minified bundle keeps the import of export default @dec class apart from locals", async () => { + using dir = tempDir("es-dec-export-default-minify", { "entry.js": ` - import Cls from "./mod.js"; - console.log(Cls.name); + import User from "./user.js"; + function make(name) { return new User(name); } + function table(prefix) { return prefix + User.table; } + console.log(JSON.stringify([make("x").n, table("t:")])); + `, + "user.js": ` + function entity(cls, ctx) {} + export default @entity class User { + static table = "users"; + constructor(n) { this.n = n; } + } + `, + }); + + const build = await runIn(String(dir), ["build", "entry.js", "--target=bun", "--minify", "--outfile=out.js"]); + expect({ stderr: filterStderr(build.stderr), exitCode: build.exitCode }).toEqual({ stderr: "", exitCode: 0 }); + const { stdout, stderr, exitCode } = await runIn(String(dir), ["out.js"]); + expect({ stdout, stderr: filterStderr(stderr), exitCode }).toEqual({ + stdout: '["x","t:users"]\n', + stderr: "", + exitCode: 0, + }); + }); + + test("export default @dec class in two entry points builds with --splitting --minify", async () => { + using dir = tempDir("es-dec-export-default-splitting", { + "e1.js": ` + import Df from "./mod.js"; + console.log(Df.name); + `, + "e2.js": ` + import Df from "./mod.js"; + export const x = Df; `, + "mod.js": ` + function dec(cls, ctx) {} + export default @dec class Df {} + `, + }); + + const build = await runIn(String(dir), [ + "build", + "e1.js", + "e2.js", + "--target=bun", + "--splitting", + "--minify", + "--outdir=out", + ]); + expect({ stderr: filterStderr(build.stderr), exitCode: build.exitCode }).toEqual({ stderr: "", exitCode: 0 }); + const { stdout, stderr, exitCode } = await runIn(String(dir), ["out/e1.js"]); + expect({ stdout, stderr: filterStderr(stderr), exitCode }).toEqual({ stdout: "Df\n", stderr: "", exitCode: 0 }); + }); + + // https://github.com/oven-sh/bun/issues/44720: a top-level `using` is lowered + // for targets other than bun, and that lowering drops every `export default` + // function and class declaration. With a default import the build fails: `No + // matching export in "mod.js" for import "default"`. The other two builds + // exit 0 and the bundle throws `ReferenceError: Df is not defined`. + // `@dec export default class Df {}` fails the same three ways. + test.todo.each([ + ["a default import", `import Df from "./mod.js"; console.log(typeof Df);`, "loaded function\nfunction\n"], + [ + "a namespace import", + `import * as ns from "./mod.js"; console.log(Object.keys(ns).join());`, + "loaded function\ndefault,other\n", + ], + ["a bare import", `import "./mod.js";`, "loaded function\n"], + ])("export default @dec class next to a lowered top-level using, with %s", async (_, entry, expected) => { + using dir = tempDir("es-dec-export-default-using", { + "entry.js": entry, "mod.js": ` function dec(cls, ctx) {} using resource = { [Symbol.dispose]() {} }; export default @dec class Df {} + export const other = 1; + console.log("loaded", typeof Df); `, }); const build = await runIn(String(dir), ["build", "entry.js", "--target=node", "--outfile=out.js"]); expect({ stderr: filterStderr(build.stderr), exitCode: build.exitCode }).toEqual({ stderr: "", exitCode: 0 }); const { stdout, stderr, exitCode } = await runIn(String(dir), ["out.js"]); - expect({ stdout, stderr: filterStderr(stderr), exitCode }).toEqual({ stdout: "Df\n", stderr: "", exitCode: 0 }); + expect({ stdout, stderr: filterStderr(stderr), exitCode }).toEqual({ stdout: expected, stderr: "", exitCode: 0 }); }); + + // https://github.com/oven-sh/bun/issues/44721: without a bundle the class is + // printed as `class test_default`, the generated name of the default export. + // The module fails to load: `SyntaxError: Cannot declare a class twice: + // 'test_default'`. + test.todo.each(["export default @dec class {}", "@dec export default class {}"])( + "%s next to a binding with the generated name of the default export", + async spelling => { + const { stdout, stderr, exitCode } = await runDecorator(` + function dec(cls, ctx) {} + const test_default = 1; + ${spelling} + console.log(test_default); + `); + expect({ stdout, stderr, exitCode }).toEqual({ stdout: "1\n", stderr: "", exitCode: 0 }); + }, + ); }); describe("anonymous class expressions with reserved-word inferred names", () => { @@ -2617,8 +2707,8 @@ type DefaultExportCell = { // TypeScript syntax. ts?: true; // `bun build` names every default class statement after its module, decorated - // or not, and a wrapped module in a bundle has no TDZ for its classes. - unbundledOnly?: true; + // or not, so the bundled run takes any name. + nameUnbundledOnly?: true; }; const namedClass = `class Df { m() { return "m"; } }`; @@ -2678,7 +2768,6 @@ const defaultExportCells: Record = { export const inside = [before, observe(() => typeof Df)]; `, expected: { seen: ["class Df"], inside: ["ReferenceError", "function"], name: "Df" }, - unbundledOnly: true, }, "abstract class": { source: `export default @dec abstract ${namedClass}\n${insideNamedClass}`, @@ -2715,12 +2804,12 @@ const defaultExportCells: Record = { "anonymous class with a decorated method": { source: `export default class { @dec m() {} }`, expected: { seen: ["method m"], name: "default" }, - unbundledOnly: true, + nameUnbundledOnly: true, }, "anonymous class with an accessor": { source: `export default class { accessor x = 1; }`, expected: { seen: [], name: "default" }, - unbundledOnly: true, + nameUnbundledOnly: true, }, }; @@ -2728,7 +2817,7 @@ async function runDefaultExportCells(mode: Mode) { const ext = mode.file.slice(mode.file.lastIndexOf(".") + 1); const names = Object.keys(defaultExportCells).filter(name => { const cell = defaultExportCells[name]; - return (!cell.ts || ext === "ts") && !(cell.unbundledOnly && mode.bundle); + return !cell.ts || ext === "ts"; }); const files: Record = { "tsconfig.json": "{}" }; names.forEach((name, i) => { @@ -2766,7 +2855,10 @@ describe("export default @dec class is a class declaration", () => { for (const name of names) { test(name, () => { - expect(out[name]).toEqual(defaultExportCells[name].expected); + const { expected, nameUnbundledOnly } = defaultExportCells[name]; + expect(out[name]).toEqual( + mode.bundle && nameUnbundledOnly ? { ...expected, name: expect.any(String) } : expected, + ); }); } }); diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 2ec82493d4ff..e551cb11033d 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -670,6 +670,8 @@ describe("Bun.Transpiler", () => { err("function dec(c){return c}\n@dec declare\nclass Foo {}", 'Unexpected "declare"'); err("function dec(c){return c}\n@dec abstract\nclass Foo {}", 'Unexpected "abstract"'); err("function dec(c){return c}\n@dec export default abstract\nclass Foo {}", 'Unexpected "abstract"'); + err("function dec(c){return c}\nexport default @dec declare\nclass Foo {}", 'Unexpected "declare"'); + err("function dec(c){return c}\nexport default @dec abstract\nclass Foo {}", 'Unexpected "abstract"'); }); it("does not crash when export default abstract is an expression followed by a class", () => { @@ -685,6 +687,11 @@ describe("Bun.Transpiler", () => { err("@dec export default abstract = 1", 'Expected "class" but found end of file'); err("@dec(() => 0) export default abstract = 1\nclass Foo {}", 'Expected "class" but found "class"'); + + // After "export default", decorators that no class follows are a syntax error too. + err("export default @dec abstract = 1", 'Unexpected "abstract"'); + err("export default @dec(() => 0) abstract = 1\nclass Foo {}", 'Unexpected "abstract"'); + err("export default @dec(() => 0) declare = 1\nclass Foo {}", 'Unexpected "declare"'); }); it("scope tracking stays balanced when a contextual keyword starts a larger expression", () => { @@ -2404,6 +2411,31 @@ export default class { expect(output.includes("localVarToReplace")).toBe(true); expect(output.includes("localVarToRemove")).toBe(false); }); + + // A replacement for `default` discards an `export default class`, as it + // discards an `export default function`. + it.each( + [ + ["js", { loader: "js" }], + ["ts", { loader: "ts" }], + [ + "ts, experimentalDecorators", + { loader: "ts", tsconfig: { compilerOptions: { experimentalDecorators: true } } }, + ], + ].flatMap(([mode, options]) => + [ + "export default class Df {}", + "export default class {}", + "@dec export default class Df {}", + "@dec export default class {}", + "export default @dec class Df {}", + "export default @dec class {}", + ].map(source => [source, mode, options]), + ), + )("replaces the default export of `%s` (%s)", (source, mode, options) => { + const replacer = new Bun.Transpiler({ ...options, exports: { replace: { default: 42 } } }); + expect(replacer.transformSync(source)).toBe("export default 42;\n"); + }); }); const bunTranspiler = new Bun.Transpiler({ From 9981d096d72a3018ab6ec02f292ebba3931c5208 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:46:05 +0000 Subject: [PATCH 3/3] js_parser: keep a decorated default class bound under the client reference wrap A "use client" module without a separate SSR graph exports `registerClientReference(value, ...)`. The class arm of `export default` moved the lowered class into that call. The statements that decorator lowering puts after the class assign to the class by name, so they ran with no binding: `ReferenceError: Df is not defined`. When such statements follow the class, it now stays a declaration and the export wraps its binding. The transpiler test for `export default @dec class` now compares every statement of the output. --- src/js_parser/visit/visit_stmt.rs | 29 +++++++++++++++-- test/bake/dev/bundle.test.ts | 32 +++++++++++++++++++ test/bundler/transpiler/es-decorators.test.ts | 21 +++++++++++- 3 files changed, 78 insertions(+), 4 deletions(-) diff --git a/src/js_parser/visit/visit_stmt.rs b/src/js_parser/visit/visit_stmt.rs index 707333ca86e4..cb116ae04c5d 100644 --- a/src/js_parser/visit/visit_stmt.rs +++ b/src/js_parser/visit/visit_stmt.rs @@ -859,13 +859,36 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool, const SEMA: bool> // Emit any prefix statements before the export default stmts.extend_from_slice(&class_stmts[0..class_stmt_idx]); + let after_class = &class_stmts[class_stmt_idx + 1..]; + if p.options.features.server_components.wraps_exports() + && !after_class.is_empty() + { + // Decorator lowering assigns to the class by name after it, so + // the class stays a declaration and the export wraps its binding. + let name = class + .class + .class_name + .expect("decorator lowering names the class"); + stmts.push(class_stmts[class_stmt_idx]); + stmts.extend_from_slice(after_class); + p.record_usage(name.ref_); + data.value = js_ast::StmtOrExpr::Expr( + p.wrap_value_for_server_component_reference( + Expr::init_identifier(name.ref_, name.loc), + b"default", + ), + ); + stmts.push(*stmt); + restore_dead!(); + record_on_exit!(); + return Ok(()); + } + data.value = js_ast::StmtOrExpr::Stmt(class_stmts[class_stmt_idx]); stmts.push(*stmt); // Emit any suffix statements after the export default - if class_stmt_idx + 1 < class_stmts.len() { - stmts.extend_from_slice(&class_stmts[class_stmt_idx + 1..]); - } + stmts.extend_from_slice(after_class); if p.options.features.server_components.wraps_exports() { // `data.value` is mutated *after* pushing `stmt`; the pushed diff --git a/test/bake/dev/bundle.test.ts b/test/bake/dev/bundle.test.ts index 3b3f8f9a7d6e..b68e702e20be 100644 --- a/test/bake/dev/bundle.test.ts +++ b/test/bake/dev/bundle.test.ts @@ -940,3 +940,35 @@ devTest("a render() that does not return a Response is reported as that", { }).toEqual({ status: 500, saysWhatIsWrong: true, referenceError: false }); }, }); +// A "use client" module without a separate SSR graph exports `registerClientReference(value, ...)`. +// The statements that decorator lowering puts after a class assign to the class by name, so the +// class has to stay a declaration: as the wrapped value it left `Df = ...` with no binding +// ("ReferenceError: Df is not defined"). +for (const spelling of [ + "@dec export default class Df {}", + "export default @dec class Df {}", + "export default @dec class {}", +]) { + devTest(`a "use client" module keeps the binding of \`${spelling}\` (experimentalDecorators)`, { + framework: minimalFramework, + files: { + "tsconfig.json": JSON.stringify({ compilerOptions: { experimentalDecorators: true } }), + "client.ts": ` + "use client"; + function dec(cls: any) { + globalThis.decorated = (globalThis.decorated ?? 0) + 1; + } + ${spelling} + `, + "routes/index.ts": ` + import Client from "../client"; + export default function (req, meta) { + return new Response(JSON.stringify([typeof Client.value, Client.uid, globalThis.decorated])); + } + `, + }, + async test(dev) { + await dev.fetch("/").equals('["function","default",1]'); + }, + }); +} diff --git a/test/bundler/transpiler/es-decorators.test.ts b/test/bundler/transpiler/es-decorators.test.ts index d325ce57ce5f..58c9096125c4 100644 --- a/test/bundler/transpiler/es-decorators.test.ts +++ b/test/bundler/transpiler/es-decorators.test.ts @@ -973,7 +973,26 @@ describe("ES Decorators", () => { test("Bun.Transpiler prints export default @dec class as a class declaration", () => { const transpiler = new Bun.Transpiler({ loader: "js" }); const source = "function dec() {}\nexport default @dec class Df {}"; - expect(transpiler.transformSync(source)).toContain("export default class Df {"); + // The runtime helpers are imported under names that end in a hash. + const output = transpiler.transformSync(source).replace(/\b(__\w+?)_[a-z0-9]{8}\b/g, "$1"); + expect(output.slice(output.indexOf("function dec"))).toBe( + [ + "function dec() {}", + "var _init$1, _dec$2, _Df$3;", + "_dec$2 = [", + " dec", + "];", + "export default class Df {", + " static {", + " _init$1 = __decoratorStart(undefined);", + ' _Df$3 = __decorateElement(_init$1, 0, "Df", _dec$2, this);', + " }", + "}", + "Df = _Df$3;", + "__runInitializers(_init$1, 1, _Df$3);", + "", + ].join("\n"), + ); expect(transpiler.scan(source).exports).toEqual(["default"]); });