Skip to content

js_parser: parse export default @dec class as a class declaration - #44723

Open
robobun wants to merge 3 commits into
mainfrom
robobun/bd7dc71c/export-default-decorated-class
Open

robobun wants to merge 3 commits into
mainfrom
robobun/bd7dc71c/export-default-decorated-class

Conversation

@robobun

@robobun robobun commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • export default @dec class Df {} leaves Df unbound: ReferenceError: Df is not defined. With experimentalDecorators the decorator never runs. Found by audit.
  • The export default declaration list (src/js_parser/parse/parse_stmt.rs:1587) has no @, so the class goes to parse_expr. That also miscompiles --minify bundles and panics --splitting --minify: index out of bounds: the len is 0 but the index is 0.

Fix

  • The list takes T::TAt. The class is a declaration in every pass, as in tsc, esbuild and the decorators spec (Add Class and Class Element Decorators and accessor Keyword tc39/ecma262#2417).
  • Alone, the token renames export default @dec class {} to <file>_default. So lower_standard_decorators_stmt takes the default export's binding and names an anonymous class "default".
  • The class arm now honours exports.replace.default and keeps a decorated class bound under the "use client" export wrap.
  • Verified: es-decorators.test.ts (60 new tests fail on main), transpiler.test.js, decorators.test.ts, bake/dev/bundle.test.ts. Self-reviewed: 21 concerns, 19 addressed.

Background

Downsides

Notes

What changes, main bd599f5 and this PR (release builds)

Input main PR
export default @dec class Df {}, then new Df() ReferenceError: Df is not defined bound
the same with experimentalDecorators the decorator is not called, exit 0 called once
export default @dec abstract class Df {} Expected "class" but found "abstract" parses
a next line that starts with ( or [ TypeError: Cannot call a class constructor Df without |new| a statement of its own
an importer does new User(name), bun build --minify the bundle throws TypeError: "x" is not a constructor (evaluating 'new t(t)') ["x","t:users"]
two entry points, bun build --splitting --minify exit 134, panic: index out of bounds: the len is 0 but the index is 0 exit 0
@dec export default class {} .name and context.name are <file>_default default
export default class { @dec m() {} }, export default class { accessor x = 1 } .name is <file>_default default (without a bundle)
replace: { default: 42 } on export default class Df {} prints the class prints export default 42;
Bake dev server, a "use client" module, experimentalDecorators, @dec export default class Df {} ReferenceError: Df is not defined loads, the decorator runs once
the same module with export default @dec class Df {} loads, the decorator is not called loads, the decorator runs once

bun check on main already types new Df().m() for the first row, so the type checker and the runtime disagreed on one file. Its tolerant parse mode takes a decorator after default as a class modifier (parse_stmt.rs:1492). T::TAt never reaches the changed list in that mode. The output of bun check is the same on both builds.

The minify rows are fixed for this spelling only. Their cause is default_name_for_expr (src/js_parser/p.rs). It makes the inner name of a named class or function expression the binding of the default export. export default (class X {}) and export default (function f() {}) still fail both ways after this PR. #23810 is a user report of that family. It is not fixed here.

The token is the one that #34245 added to the list of plain export. Bun 1.3.13 and 1.4.2 parse the class as an expression too. No issue reports this spelling.

Measurements (release builds of main bd599f5 and of this PR, llvm-nm 23, size, qemu-x86_64 10.0.13 with -d exec)

  • machine code: parse_statement_after_export +221 B (5 copies), s_export_default +820 B (2), lower_class -2,139 B (2), lower_standard_decorators_stmt 2,483 B (2, inlined into lower_class on main), lower_class_body +0 B (2), s_class +0 B (2), text total +1,792 B (88,770,351 to 88,772,143, size bun)
  • instructions in parse_statement_after_export per export default foo;: 162 -> 164. Per export default {a:1};: 159 -> 161. Per export default class A {}: 117 -> 121. Per export default function(){}: 120 -> 126 (one activation each)
  • per undecorated class statement: s_class 73 -> 73, lower_class 45 -> 38 (js) and 113 -> 110 (ts) instructions (1,000 classes)
  • instructions in s_export_default per export default foo;: 133 -> 132. Per export default {a:1};: 125 -> 124. Per export default class A {}: 153 -> 157. Per export default function(){}: 172 -> 166. The largest sum of parse and visit is +8, for the class
  • allocator calls per transformSync: 5 unaffected inputs 26, 24, 25, 28, 27 -> the same (delta 0). export default @dec class Df {} 86 -> 88 (js), 89 -> 91 (ts). export default @dec class {} 91 -> 93 (js), 95 -> 97 (ts). Calls of 11 mi_* entry points, slope over 200 calls
  • transpiler cache: EXPECTED_VERSION 34 -> 35. bun-v1.4.2 is at 28, so the bump adds 0 invalidations for a user who moves from a stable release, and one transpile per cached source for a canary user
  • transpiled output differs for 3 of 58,327 files under test/ and src/js (sha1 per file and decorator mode, 5 of 77,026): the 2 new fixtures and decorator-export-default-class-fixture-anon.ts (an anonymous default class with a decorated method)
  • matrix of 12 spellings in js, ts and ts with experimentalDecorators: 32 of 33 cells match (main: 7). The cell left is accessor with experimentalDecorators, a parse error on both. Anonymous default classes with the name default: 8 of 8 without a bundle (main: 1), 4 of 8 in bun build output (main: 1), 4 of 8 with --minify (main: 1)
  • export default @dec class {}: 490 -> 513 B of output, Bun.inspect [class (anonymous)] -> [class mod_default]. @dec export default class {}: 533 -> 513 B. .name and context.name are default for both
  • inputs that ran on main and fail now: 3 of 3 shapes that tsc and esbuild reject too (export default @dec class X {} as any, export default @dec class X {}.name, const X = 1; export default @dec class X {}). 4 of 4 cells with a lowered top-level using (named and anonymous, bun build --target=node and Bun.Transpiler). The controls @dec export default class Df {} and export default class Df {} fail 4 of 4 on main

The two downsides in detail

Self-review: 21 concerns raised, 19 addressed

Other PRs

Left as it is

  • bun build names every default class statement <file>_default, so an anonymous default class with only member decorators keeps that .name in a bundle.
  • With experimentalDecorators an anonymous default class keeps the binding and the name <file>_default (esbuild prints the same, tsc prints default_1).
  • @x export default @y class keeps Expected "class" but found "@".
  • A decorator with no class after export default reports once for the common tails. @dec[0] class Foo {}, @dec namespace N {} and @dec label: 1 report a second message from the statement that follows.
  • esbuild passes the empty string as the name of an anonymous decorated default class. Bun keeps default, which is the spec text and tsc.

Tests

  • es-decorators.test.ts: 484 pass, 5 todo on this PR. 424 pass and 60 fail on main. The matrix runs one module per cell as .js, as .ts and as a bundle.
  • transpiler.test.js: 255 pass on this PR, 18 fail on main. decorators.test.ts: 26 pass on this PR. On main the file does not load (ReferenceError: DecoratedClass is not defined). bake/dev/bundle.test.ts: 26 pass on this PR, 3 fail on main.
  • Five rows of the syntax error table pass on both builds (export default @dec function f() {} and the four tails with one message). They hold texts that this route could have moved.
  • Also run on this PR: es-decorators-esbuild, decorator-metadata, bundler_decorator_metadata, esbuild/ts, bundler_minify, bundler_edgecase, ts-use-define-for-class-fields. The last one passes on the release build. On the debug build its 11 concurrent spawns pass the 5 s limit now and then on my machine.

[human-review] gate passed · iteration 0 · 11 files touched

fails on main (without fix)
ASAN without fix: BUILD FAILED (no junit output)
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bake/dev/bundle.test.ts test/bundler/transpiler/decorators.test.ts test/bundler/transpiler/es-decorators.test.ts test/bundler/transpiler/transpiler.test.js
ninja: Entering directory `/workspace/bun/build/debug'
[0/2] cargo plan → /workspace/bun/build/debug/rust-target/plan.json
FAILED: [code=1] rust-target/plan.json /workspace/bun/build/debug/rust-target/plan.json 
/workspace/bun/build/release/bun /workspace/bun/scripts/build/stream.ts cargo --console /workspace/bun/build/release/bun /workspace/bun/scripts/build/rust/plan.ts /workspace/bun/build/debug/rust-target/plan.input.json /workspace/bun/build/debug/rust-target/plan.json
�[1m�[91merror�[0m: cannot update the lock file /workspace/bun/Cargo.lock because --locked was passed to prevent this
help: to generate the lock file without accessing the network, remove the --locked flag and use --offline instead.
error: /opt/rust/bin/cargo build -p bun_runtime --lib … exited with 101
ninja: error: rebuilding 'build.ninja': subcommand failed
error: script "bd" exited with code 1
__F:-1:S:0

release without fix: 81 failed, 27 skipped
bun test v1.4.3-canary.1 (a08b50c6e)

test/bundler/transpiler/transpiler.test.js:
(pass) Bun.Transpiler > handles errors when parsing macros [0.21ms]
(pass) Bun.Transpiler > normalizes \r\n [0.35ms]
1
(pass) Bun.Transpiler > doesn't hang indefinitely #2746 [0.14ms]
(pass) Bun.Transpiler > property access inlining > bails out with spread [0.14ms]
(pass) Bun.Transpiler > property access inlining > bails out with multiple items [0.03ms]
(pass) Bun.Transpiler > property access inlining > works [0.03ms]
(pass) Bun.Transpiler > property access inlining > works nested [0.02ms]
(pass) Bun.Transpiler > property access inlining > bails out when the array item is an optional chain [0.68ms]
(pass) Bun.Transpiler > property access inlining > bails out or strips `this` when the index is a call/assignment target [0.22ms]
(pass) Bun.Transpiler > property access inlining > preserves runtime semantics when inlining from a literal index [10.34ms]
(pass) Bun.Transpiler > property access inlining > bails out on optional-chain index into enum [0.90ms]
(pass) Bun.Transpiler > property access inlining > template literal around an inlined string enum member > member as the first part of the
... (truncated)
passes on PR (with fix)
ASAN with fix: 27 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bake/dev/bundle.test.ts test/bundler/transpiler/decorators.test.ts test/bundler/transpiler/es-decorators.test.ts test/bundler/transpiler/transpiler.test.js
bun test v1.4.3 (367d939d9)

test/bundler/transpiler/transpiler.test.js:
(pass) Bun.Transpiler > handles errors when parsing macros [12.79ms]
(pass) Bun.Transpiler > normalizes \r\n [10.75ms]
1
(pass) Bun.Transpiler > doesn't hang indefinitely #2746 [15.45ms]
(pass) Bun.Transpiler > property access inlining > bails out with spread [17.56ms]
(pass) Bun.Transpiler > property access inlining > bails out with multiple items [5.53ms]
(pass) Bun.Transpiler > property access inlining > works [6.25ms]
(pass) Bun.Transpiler > property access inlining > works nested [5.53ms]
(pass) Bun.Transpiler > property access inlining > bails out when the array item is an optional chain [109.21ms]
(pass) Bun.Transpiler > property access inlining > bails out or strips `this` when the index is a call/assignment target [48.05ms]
(pass) Bun.Transpiler > property access inlining > preserves runtime semant
... (truncated)

release with fix: 27 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 1571ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[0/2] cargo plan → /workspace/bun/build/release/rust-target/plan.json
247 units: 175 lib, 16 proc-macro (host), 19 custom-build (host), 15 run custom-build, 17 lib (host), 4 run custom-build (host), 1 rlib
[1/44] gen generated_host_exports.rs
generated_host_exports.rs: 120 exports (host=5, lazy=10, generic=105, rust=0); 248 extern-C blocks audited
[2/43] rustc bun_ast 
[3/43] rustc bun_resolve_builtins 
[4/43] rustc bun_install_types 
[5/43] rustc bun_options_types 
[6/43] rustc bun_api 
[7/43] rustc bun_http 
[8/43] rustc bun_parsers 
[9/43] rustc bun_sourcemap 
[10/43] rustc bun_js_printer 
[11/43] rustc bun_react_compiler 
[12/43] rustc bun_css 
[13/43] rustc bun_sema 
[14/43] rustc bun_ini 
[15/43] rustc bun_js_parser 
[16/43] rustc bun_resolver 
[17/43] rustc bun_router 
[18/43] rustc bun_sema_driver 
[19/43] rustc bun_sema_baselines 
[20/43] rustc bun_bundler 
[21/43] rustc bun_transpiler 
[22/43] rustc bun_standalone_graph 
[23/43] rustc bun_bunfig 
[24/43] rustc bun_install 
[25/43] rustc bun_js
... (truncated)
diff hotspot
src/js_parser/lower/lower_decorators.rs            |  12 +-
 src/js_parser/p.rs                                 |   5 +-
 src/js_parser/parse/parse_stmt.rs                  |  20 +-
 src/js_parser/visit/visit_stmt.rs                  |  59 +++-
 src/jsc/RuntimeTranspilerCache.rs                  |   4 +-
 test/bake/dev/bundle.test.ts                       |  32 ++
 ...ator-after-export-default-class-fixture-anon.ts |  11 +
 ...decorator-after-export-default-class-fixture.ts |  12 +
 test/bundler/transpiler/decorators.test.ts         |  18 ++
 test/bundler/transpiler/es-decorators.test.ts      | 349 +++++++++++++++++++++
 test/bundler/transpiler/transpiler.test.js         |  32 ++
 11 files changed, 532 insertions(+), 22 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                                      reads  edits  tests
src/js_parser/lower/lower_decorators.rs                       1      1     54
src/js_parser/p.rs                                            2      3     56
src/js_parser/parse/parse_stmt.rs                             6      2     57
src/js_parser/visit/visit_stmt.rs                             7      4     55
src/jsc/RuntimeTranspilerCache.rs                             1      1     56
test/bake/dev/bundle.test.ts                                  0      0     11
…er/decorator-after-export-default-class-fixture-anon.ts      0      1     54
…nspiler/decorator-after-export-default-class-fixture.ts      0      1     54
test/bundler/transpiler/decorators.test.ts                    1      1     19
test/bundler/transpiler/es-decorators.test.ts                 5      5     37
test/bundler/transpiler/transpiler.test.js                    2      2     15

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.
…e 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.
@robobun

robobun commented Oct 7, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

How I reproduced it, on Bun 1.4.2 and on main (bd599f5):

// a.ts
const dec = (v: any, c: any) => {};
export default @dec class Df { m() { return "m"; } }
console.log(typeof Df, new Df().m());

bun a.ts prints ReferenceError: Df is not defined. With this PR it prints function m.

The same parse gives these results on main, and each one is a test in this PR:

  • With "experimentalDecorators": true the decorator is never called.
  • export default @dec abstract class Df {} is a syntax error: Expected "class" but found "abstract".
  • A next line that starts with ( calls the class: TypeError: Cannot call a class constructor Df without |new|.
  • bun build --minify of an importer gives a bundle that throws TypeError: "x" is not a constructor.
  • bun build e1.js e2.js --splitting --minify with the class in both entry points exits 134: panic: index out of bounds: the len is 0 but the index is 0.

Test files: test/bundler/transpiler/es-decorators.test.ts, test/bundler/transpiler/transpiler.test.js, test/bundler/transpiler/decorators.test.ts.

@coderabbitai

coderabbitai Bot commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Essentials
  • Run ID: 30754004-c6f9-46e4-ba55-6b1fd9c56938
📥 Commits

Reviewing files that changed from the base of the PR and between 87b6862 and 9981d09.

📒 Files selected for processing (3)
  • src/js_parser/visit/visit_stmt.rs
  • test/bake/dev/bundle.test.ts
  • test/bundler/transpiler/es-decorators.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 7 remain after this review.


Walkthrough

The parser now recognizes decorated default-export declarations. Class lowering passes the default-export binding to standard-decorator lowering and handles direct replacement expressions. Tests cover parsing, class names, replacements, and bundled output.

Changes

Decorated default-export classes

Layer / File(s) Summary
Parse decorated default exports
src/js_parser/parse/parse_stmt.rs, test/bundler/transpiler/transpiler.test.js
The parser routes decorator-prefixed default exports through declaration parsing and returns a syntax error when decorator parsing reports an error. Tests cover invalid declaration forms.
Lower and validate default-export classes
src/js_parser/lower/lower_decorators.rs, src/js_parser/visit/visit_stmt.rs, src/js_parser/p.rs, src/jsc/RuntimeTranspilerCache.rs, test/bundler/transpiler/*
Standard-decorator lowering receives the default-export binding, and direct replacement expressions remain default exports. Tests cover named and anonymous classes, decorator results, syntax and export scanning, and bundled output. The runtime transpiler cache version changes from 34 to 35.

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 9981d

This change makes decorated default-export classes parse and lower correctly, and it adds regression coverage. No actionable merge-blocking risk was identified in the supplied context.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the main change: parsing export default @dec class as a class declaration.
Description check ✅ Passed The description explains the problem, implementation, scope, known limitations, and verification results. It does not use the exact template headings, but it provides the required information and is s…
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

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.

Beyond the inline findings, I also checked the new expect("a class statement has a name") behind get_or_insert_with in src/js_parser/lower/lower_decorators.rs: the None call site in src/js_parser/p.rs is only reached through lower_class for class statements the parser always names, since s_export_default now calls lower_standard_decorators_stmt directly, so it is not reachable from user input. The Replace arm change in src/js_parser/visit/visit_stmt.rs now mirrors the SFunction arm above it (push the export with the replacement value, then return), and the errors_before_decorators count only short-circuits the second "Unexpected" message when the nested parse already logged one.

Extended reasoning...

The change touches the Rust JS parser (parse_stmt, visit_stmt, decorator lowering), bumps the runtime transpiler cache version, and adds tests; it touches no security-sensitive surface. Inline findings were posted (Bake "use client" rebind, top-level using with non-bun targets, and the generated <file>_default name collision), so a human must still weigh them; this note only records the adjacent concerns examined and ruled out.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🔴 src/js_parser/visit/visit_stmt.rs — Bake dev-server users whose "use client" module has export default @ dec class Df {} with standard decorators now get ReferenceError at module load; on main the module loaded. The class is lowered as a statement at visit_stmt.rs:840, which appends the rebind Df = _Df after the export (visit_stmt.rs:867); the wrap at visit_stmt.rs:874-878 then moves the class into registerClientReference(...), so Df is no longer declared when the rebind runs. The todo_panic at p.rs:8766 is skipped when jsx.development is set, which is the dev server. Fix: when wraps_exports() is true, lower the default class through the expression path (as main did) or emit the rebind against the wrapped value, so no assignment targets a binding the wrap removed.

    Why this was flagged

    Trigger: a Bake app with server components and separate_ssr_graph: false (ParseTask.rs:2585 selects WrapExportsForClientReference) in development, where a "use client" file contains export default @ dec class Df {} under standard decorators. On the base commit the @ after default went to parse_expr, so s_export_default took the Expr arm: the class decorators were lowered with a comma expression (lower_decorators.rs:772-777) and the wrap at visit_stmt.rs:510-512 wrapped that expression; no rebind statement existed. With this PR the Stmt arm runs: lower_standard_decorators_stmt pushes Df = _Df (lower_decorators.rs:749-752), visit_stmt.rs:867 appends it after the export, and visit_stmt.rs:874-878 then takes class.class out of the statement and wraps it, so the printed output is export default registerClientReference(class Df {...}, ...); Df = _Df; with no Df binding; strict-mode ESM throws ReferenceError at load. p.rs:8763 only panics when jsx.development is false; the dev server sets development.

    Verification: export default @ dec class Df {} now reaches the SClass arm; visit_stmt.rs:860-868 emits the Df = _Df; suffix, then visit_stmt.rs:870-879 wraps the class in registerClientReference(...), so the rebind is a ReferenceError under strict code. p.rs:8756-8767 only panics for non-development. On the base visit_stmt.rs:510-512 wrapped the comma expression and the module loaded.

  • 🔴 src/js_parser/visit/visit_stmt.rs — Users who bundle for a non-bun target (the bun build default) with a top-level using now lose export default @ dec class Df {} entirely; main still emitted the default export. The decorated class now takes the Stmt branch at visit_stmt.rs:778, and finalize drops every S::ExportDefault statement there (p.rs:10232), so importers get No matching export in "mod.js" for import "default" or ReferenceError: Df is not defined. Fix: under will_wrap_module_in_try_catch_for_using, rewrite a default class or function statement to var <name> = ...; export { <name> as default } as the Expr branch does at visit_stmt.rs:515, which also covers @ dec export default class and export default function (#44720). [also at: src/js_parser/parse/parse_stmt.rs:1591 - Users who build a module with a top-level using and export default @ dec class Df {} for a non-bun target lose the default export; on main it works.]

    Why this was flagged

    Input: a module with using resource = {...} at top level and export default @ dec class Df {}, built with bun build --target=node or --target=browser (the CLI default), or run through Bun.Transpiler; lower_using is !target.is_bun() (src/bundler/transpiler.rs:1528, src/bundler/ParseTask.rs:2549) so will_wrap_module_in_try_catch_for_using is true (src/js_parser/parse/parse_entry.rs:1230). On main the @ after export default went to parse_expr, so data.value was an Expr and visit_stmt.rs:515 turned it into var mod_default = ...; export { mod_default as default }, which finalize keeps as an SExportClause. With T::TAt in the declaration lookahead (src/js_parser/parse/parse_stmt.rs:1591) the value is StmtOrExpr::Stmt(SClass), handled at visit_stmt.rs:778-883, which has no using conversion; finalize then hits SExportDefault(_) => continue at src/js_parser/p.rs:10232 and discards the statement, class and export together. The bundle either fails to link the default import or throws ReferenceError: Df is not defined at runtime.

    Verification: The StmtData::SClass arm (visit_stmt.rs:778-884) has no using rewrite; it lowers the class and pushes the S::ExportDefault stmt. finalize then unconditionally drops it: js_ast::StmtData::SExportDefault(_) => { continue; } at p.rs:10232-10234. So the class declaration and the default export are both gone, while the base branch's Expr path produced a working var+export {} pair for the same input.

Comment on lines +744 to +747
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"));

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.

🟡 (optional) Users who transpile (not bundle) export default @ dec class {} next to a local named <file>_default now get SyntaxError: Cannot declare a class twice where main loaded the module. When a class decorator needs a rebind, the anonymous class takes the default export's generated symbol as its declared name at src/js_parser/lower/lower_decorators.rs:747, and without a bundle no renamer guards that name against user bindings. Fix: rebind through a name that cannot collide (a fresh temp, or print the anonymous class as an expression assigned to a temp and export default that), covering @ dec export default class {} too, which already fails this way on main. The PR notes this under #44721 with two test.todo rows; the todo documents the cost but does not bound it.

Why this was flagged

Input: a JS or TS module test.js containing const test_default = 1; and export default @ dec class {} with standard decorators, run directly with bun test.js (no bundle). Parsing now takes the statement path (src/js_parser/parse/parse_stmt.rs:1591), and s_export_default calls lower_standard_decorators_stmt with Some(data.default_name) (src/js_parser/visit/visit_stmt.rs:840-843). Because the class has a class decorator, lowered.class_decorators is Some and get_or_insert_with at src/js_parser/lower/lower_decorators.rs:747 sets class_name to the generated <file>_default symbol; the printer then emits export default class test_default {} plus test_default = _default with no renaming pass. The engine rejects the module: SyntaxError: Cannot declare a class twice: 'test_default'. On main the same input parsed as a class expression and was lowered with temps only, so the module loaded and printed 1. The author lists this as a downside and marks it test.todo, which records the failure but leaves the user-facing load error in place.

Verification: Trigger: a module run without a bundle containing export default @ dec class {} together with a user binding whose name equals the generated default-export name. src/js_parser/lower/lower_decorators.rs:744-752 does class_name.get_or_insert_with(|| default_export.expect(..)) and emits class <name> {}. On bd599f5 the same input was parsed as a class expression, so the module loaded.

Comment thread test/bundler/transpiler/es-decorators.test.ts
@robobun

robobun commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:46 PM PT - Oct 7th, 2026

✅ @robobun, your commit 9981d096d72a3018ab6ec02f292ebba3931c5208 passed in Build #123796! 🎉


🧪   To try this PR locally:

bunx bun-pr 44723

That installs a local version of the PR into your bun-44723 executable, so you can run:

bun-44723 --bun

…rence 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.
Comment on lines +723 to +726
/// `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.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/js_parser/p.rs
Comment on lines +7455 to +7456
// keep this function's slice contract. `s_export_default` calls
// it itself, so a class that arrives here has a name.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +1587 to +1588
// "@" is in the lookahead set that rules out an expression after
// "export default": a decorator here starts a class declaration.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +1631 to +1635
// ("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.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +801 to +802
// The class is discarded. Lowering it would put it back
// in `data.value`.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +822 to +824
// 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.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +866 to +867
// Decorator lowering assigns to the class by name after it, so
// the class stays a declaration and the export wraps its binding.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +67 to +68
/// Version 35: `export default @dec class` is a class declaration, and standard decorator
/// lowering names an anonymous `export default class` "default".

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

@claude claude Bot left a comment

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.

Still open from earlier reviews (1):

  • Unresolved: 1 minor or pre-existing.

Comment on lines +863 to +884
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(());

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.

🔴 Production Bake builds of a "use client" or "use server" module with a decorated export default class now emit a bundle that throws a SyntaxError on load; on main the bundle loaded. The new branch at src/js_parser/visit/visit_stmt.rs:872-881 keeps the class as a declaration named by class_name and sets data.value to registerClientReference(<same name>), while data.default_name is that same ref. src/bundler/linker_context/convertStmtsForChunk.rs:625-641 then prints var Df = ... next to class Df {}. Fix: give the export a symbol distinct from the class binding on this path (e.g. a fresh create_default_name for data.default_name before wrapping) for named, anonymous, legacy and standard cases.

Why this was flagged

Trigger: a Bake production build, a module under "use server" (src/bundler/ParseTask.rs:2588) or "use client" without a separate SSR graph (src/bundler/ParseTask.rs:2585), containing export default @ dec class Df {} or @ dec export default class Df {}. The new branch at src/js_parser/visit/visit_stmt.rs:863-884 pushes the class statement as a standalone declaration named class_name and sets data.value to an identifier wrapped by wrap_value_for_server_component_reference. data.default_name is the same ref as the class name: for a named class parse_stmt.rs:1620-1624 takes it from the class. The production linker rewrites the export default <expr> into var <default_name> = <expr> (convertStmtsForChunk.rs:625-641), so the chunk contains class Df {} and var Df = registerClientReference(Df, "default"), and a var sharing a name with a class declaration in the same scope is an early SyntaxError when the bundle is loaded. On main the class was wrapped as a class expression and the chunk loaded. The dev server does not hit this because InternalBakeDev uses convertStmtsForChunkForDevServer.

Verification: src/js_parser/visit/visit_stmt.rs:872-880 pushes the lowered class as a standalone declaration and sets data.value to registerClientReference(Identifier(name.ref_), "default"); default_name is the class's own ref (src/js_parser/parse/parse_stmt.rs:1619-1625). src/bundler/linker_context/convertStmtsForChunk.rs:625-641 rewrites it to var Df = registerClientReference(Df, "default") in the same scope as class Df {}, an early SyntaxError.

@robobun

robobun commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

Result of the work on #44721, the <file>_default guard that the Notes and the two test.todo rows here point at. There is no PR for it.

Measured cost of the check, for the record (release builds, main bd599f5 against the check): +2 instructions per transpile with no exports, +38 for a named default declaration, 0 allocator calls, +3,666 B of text.

This branch has not been deployed

No deployments
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.

2 participants