From e9a4eb8364d4ae34d0843a7b30e557baa6f740c1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:20:59 +0000 Subject: [PATCH 1/5] Convert top-level class statements to var declarations when bundling When bundling, rewrite a top-level `class X {}` statement into `var X = class {}` so the binding can merge with adjacent `var` declarations. A standalone class statement breaks the `var` chain on either side of it, producing larger minified output. This matches esbuild's bundle-mode behavior. The class expression keeps a name only when the class body refers to its own name, preserving the inner immutable binding; otherwise it is emitted anonymously. Function statements are left untouched, also matching esbuild. --- src/js_parser/visit/visit_stmt.rs | 45 ++++++++++++++++-- test/bundler/bundler_minify.test.ts | 71 +++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 3 deletions(-) diff --git a/src/js_parser/visit/visit_stmt.rs b/src/js_parser/visit/visit_stmt.rs index dfce8bec1923..8de523e8eebd 100644 --- a/src/js_parser/visit/visit_stmt.rs +++ b/src/js_parser/visit/visit_stmt.rs @@ -1054,7 +1054,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O p.is_control_flow_dead = true; } - let _ = p.visit_class(stmt.loc, &mut data.class, Ref::NONE); + let shadow_ref = p.visit_class(stmt.loc, &mut data.class, Ref::NONE); // Remove the export flag inside a namespace let was_export_inside_namespace = data.is_export && p.enclosing_namespace_arg_ref.is_some(); @@ -1066,8 +1066,47 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let lowered = p.lower_class(js_ast::StmtOrExpr::Stmt(*stmt)); if !mark_as_dead || was_export_inside_namespace { - // Lower class field syntax for browsers that don't support it - stmts.extend_from_slice(lowered); + // When bundling, rewrite a top-level `class X {}` statement into + // `var X = class {}`. This lets the binding merge with adjacent + // `var` declarations, matching esbuild. Only applies when class + // lowering left the class as a single plain statement (no field or + // decorator lowering rewrote it into other statements). + if (p.options.bundle || p.will_wrap_module_in_try_catch_for_using) + && p.current_scope().parent.is_none() + && lowered.len() == 1 + && let Some(mut sc) = lowered[0].data.s_class() + { + let loc = lowered[0].loc; + let name = sc + .class + .class_name + .expect("infallible: class statement has a name"); + // Keep a named class expression only when the class body refers + // to its own name; otherwise emit an anonymous class expression. + if shadow_ref.is_empty() { + sc.class.class_name = None; + } + let is_export = sc.is_export; + let kind = p.select_local_kind(S::Kind::KLet); + let class_value = core::mem::take(&mut sc.class); + let class_expr = p.new_expr(class_value, loc); + let binding = p.b(B::Identifier { r#ref: name.ref_ }, name.loc); + stmts.push(p.s( + S::Local { + kind, + is_export, + decls: G::DeclList::from_slice(&[G::Decl { + binding, + value: Some(class_expr), + }]), + ..Default::default() + }, + loc, + )); + } else { + // Lower class field syntax for browsers that don't support it + stmts.extend_from_slice(lowered); + } } else { let ref_ = data .class diff --git a/test/bundler/bundler_minify.test.ts b/test/bundler/bundler_minify.test.ts index 0f49f32e1795..2a920028518a 100644 --- a/test/bundler/bundler_minify.test.ts +++ b/test/bundler/bundler_minify.test.ts @@ -1472,6 +1472,77 @@ describe("bundler", () => { expect(code.match(/let keep = /g)).toHaveLength(10); }, }); + + // When bundling, a top-level `class X {}` statement is rewritten to + // `var X = class {}` so the binding can merge into an adjacent `var` + // declaration chain, matching esbuild. A standalone class statement would + // otherwise break the chain and produce larger output. + // https://github.com/oven-sh/bun/issues/32652 + itBundled("minify/ClassStatementChainsWithVarDeclarations", { + files: { + "/entry.js": /* js */ ` + export const A = 1; + export class B { v() { return 42; } } + export const C = 2; + console.log(A, new B().v(), C); + `, + }, + minifySyntax: true, + minifyWhitespace: true, + minifyIdentifiers: false, + onAfterBundle(api) { + const code = api.readFile("/out.js"); + // The class became an anonymous class expression chained into the vars. + expect(code).toContain("var A=1,B=class{"); + // No standalone `class B {}` statement survives to break the chain. + expect(code).not.toContain("class B{"); + }, + run: { stdout: "1 42 2" }, + }); + + // If the class body refers to its own name, the conversion keeps a named + // class expression (whose name is an immutable binding), so re-assigning the + // outer binding does not change what the method returns. + itBundled("minify/ClassSelfReferenceKeepsNamedExpression", { + files: { + "/entry.js": /* js */ ` + export class B { self() { return B; } } + const inst = new B(); + const Original = B; + B = 123; + console.log(inst.self() === Original); + `, + }, + minifySyntax: true, + minifyWhitespace: true, + minifyIdentifiers: false, + onAfterBundle(api) { + const code = api.readFile("/out.js"); + expect(code).toContain("B=class B{"); + }, + run: { stdout: "true" }, + }); + + // Function statements are hoisted and are not shorter as expressions, so + // they stay statements (matching esbuild) and are never chained. + itBundled("minify/FunctionStatementNotConvertedToExpression", { + files: { + "/entry.js": /* js */ ` + export const A = 1; + export function B() { return 5; } + export const C = 2; + `, + }, + minifySyntax: true, + minifyWhitespace: true, + minifyIdentifiers: false, + onAfterBundle(api) { + const code = api.readFile("/out.js"); + expect(code).toContain("function B("); + expect(code).not.toContain("B=function"); + expect(code).not.toContain("B=class"); + }, + }); }); // The runtime transpiler (`bun run`/`bun test`) implicitly enables From f2a89b370da05e1f92e6b67e73600ef5b028c622 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:54:56 +0000 Subject: [PATCH 2/5] Gate class-expression name drop on keep-names and direct eval When converting a top-level class statement to `var X = class {}`, only drop the class expression's name when neither `--keep-names` is set nor the scope contains a direct `eval`, matching the class-expression path in the visitor. A direct `eval` can reference the class by name at runtime; keeping the immutable inner name makes `eval("X")` inside a method resolve to the class rather than the reassignable outer binding. Adds tests for the keep-names and direct-eval cases and a tree-shaking regression test for an unused converted class. --- src/js_parser/visit/visit_stmt.rs | 12 ++++-- test/bundler/bundler_minify.test.ts | 66 +++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/src/js_parser/visit/visit_stmt.rs b/src/js_parser/visit/visit_stmt.rs index 8de523e8eebd..57c75fe51254 100644 --- a/src/js_parser/visit/visit_stmt.rs +++ b/src/js_parser/visit/visit_stmt.rs @@ -1081,9 +1081,15 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O .class .class_name .expect("infallible: class statement has a name"); - // Keep a named class expression only when the class body refers - // to its own name; otherwise emit an anonymous class expression. - if shadow_ref.is_empty() { + // Drop the class expression name when the class body never + // refers to it. Keep it under `--keep-names` or when the scope + // contains a direct `eval` (which may reference the name + // dynamically), matching the class-expression path in + // `visit_expr`. + let drop_name = shadow_ref.is_empty() + && !p.options.features.minify_keep_names + && !p.current_scope().contains_direct_eval; + if drop_name { sc.class.class_name = None; } let is_export = sc.is_export; diff --git a/test/bundler/bundler_minify.test.ts b/test/bundler/bundler_minify.test.ts index 2a920028518a..11f9540543f7 100644 --- a/test/bundler/bundler_minify.test.ts +++ b/test/bundler/bundler_minify.test.ts @@ -1543,6 +1543,72 @@ describe("bundler", () => { expect(code).not.toContain("B=class"); }, }); + + // Under --keep-names the class expression keeps its name instead of being + // emitted anonymously (the name-drop is suppressed, mirroring the + // class-expression path in the visitor), so `.name` is preserved. + itBundled("minify/ClassStatementKeepsNameWithKeepNames", { + files: { + "/entry.js": /* js */ ` + export class B { m() { return 1; } } + console.log(B.name, new B().m()); + `, + }, + keepNames: true, + minifySyntax: true, + minifyWhitespace: true, + minifyIdentifiers: false, + onAfterBundle(api) { + const code = api.readFile("/out.js"); + expect(code).toContain("B=class B{"); + }, + run: { stdout: "B 1" }, + }); + + // A direct `eval` may reference the class by name at runtime, so the class + // expression must keep its (immutable inner) name even when the body has no + // static self-reference. Dropping it would make `eval("B")` resolve to the + // reassignable outer binding instead of the class. + itBundled("minify/ClassStatementKeepsNameWithDirectEval", { + files: { + "/entry.js": /* js */ ` + export class B { self() { return eval("B"); } } + const inst = new B(); + const Original = B; + B = 999; + console.log(inst.self() === Original); + `, + }, + minifySyntax: true, + minifyWhitespace: true, + minifyIdentifiers: false, + onAfterBundle(api) { + const code = api.readFile("/out.js"); + expect(code).toContain("B=class B{"); + }, + run: { stdout: "true" }, + }); + + // The converted `var X = class {}` must remain tree-shakeable: an unused + // top-level class is still dropped entirely. + itBundled("minify/UnusedConvertedClassIsTreeShaken", { + files: { + "/entry.js": /* js */ ` + class Unused { m() { return 1; } } + export const A = 1; + console.log(A); + `, + }, + minifySyntax: true, + minifyWhitespace: true, + minifyIdentifiers: false, + onAfterBundle(api) { + const code = api.readFile("/out.js"); + expect(code).not.toContain("Unused"); + expect(code).not.toContain("class"); + }, + run: { stdout: "1" }, + }); }); // The runtime transpiler (`bun run`/`bun test`) implicitly enables From 1032197bc7bc97fe5e9001ae920b9f042731e99e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:16:20 +0000 Subject: [PATCH 3/5] ci: retrigger From db49fe0f3d7b8071f6c786fd931e3abc693f5401 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 18:40:17 +0000 Subject: [PATCH 4/5] Update NonAsciiIdentifierPreserved for class statement conversion The bundled output shape changed from `class X {}` to `var X = class {}`; the test's intent (identifiers survive un-mangled) is unchanged. --- test/bundler/bundler_edgecase.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/bundler/bundler_edgecase.test.ts b/test/bundler/bundler_edgecase.test.ts index 7fe388d3a623..15d45c54c8ca 100644 --- a/test/bundler/bundler_edgecase.test.ts +++ b/test/bundler/bundler_edgecase.test.ts @@ -2715,10 +2715,12 @@ describe("bundler", () => { run: { stdout: '["Café","naïve","Cafá","模块",1,2,3]' }, onAfterBundle(api) { const out = api.readFile("/out.js"); - expect(out).toContain("class Café"); + // Top-level class statements become `var X = class {}` when bundling; + // the identifier must survive un-mangled either way. + expect(out).toContain("var Café = class"); expect(out).toContain("function naïve"); - expect(out).toContain("class Cafá"); - expect(out).toContain("class 模块"); + expect(out).toContain("var Cafá = class"); + expect(out).toContain("var 模块 = class"); expect(out).toContain("var aπ"); expect(out).toContain("var a𝒜"); expect(out).toContain("var élan"); From 9831df22634100491dbf29ef7cb9810fe2127345 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 18:45:05 +0000 Subject: [PATCH 5/5] Tighten comments in class statement conversion --- src/js_parser/visit/visit_stmt.rs | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/js_parser/visit/visit_stmt.rs b/src/js_parser/visit/visit_stmt.rs index 57c75fe51254..2aa0eefd1deb 100644 --- a/src/js_parser/visit/visit_stmt.rs +++ b/src/js_parser/visit/visit_stmt.rs @@ -1066,11 +1066,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let lowered = p.lower_class(js_ast::StmtOrExpr::Stmt(*stmt)); if !mark_as_dead || was_export_inside_namespace { - // When bundling, rewrite a top-level `class X {}` statement into - // `var X = class {}`. This lets the binding merge with adjacent - // `var` declarations, matching esbuild. Only applies when class - // lowering left the class as a single plain statement (no field or - // decorator lowering rewrote it into other statements). + // When bundling, rewrite a top-level `class X {}` into + // `var X = class {}` so the binding merges with adjacent `var` + // declarations (matches esbuild). if (p.options.bundle || p.will_wrap_module_in_try_catch_for_using) && p.current_scope().parent.is_none() && lowered.len() == 1 @@ -1081,11 +1079,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O .class .class_name .expect("infallible: class statement has a name"); - // Drop the class expression name when the class body never - // refers to it. Keep it under `--keep-names` or when the scope - // contains a direct `eval` (which may reference the name - // dynamically), matching the class-expression path in - // `visit_expr`. + // Same name-drop gating as the class-expression path in + // `visit_expr`: keep the name if the body references it, + // under `--keep-names`, or with direct `eval` in scope. let drop_name = shadow_ref.is_empty() && !p.options.features.minify_keep_names && !p.current_scope().contains_direct_eval;