diff --git a/src/js_parser/visit/visit_stmt.rs b/src/js_parser/visit/visit_stmt.rs index dfce8bec1923..2aa0eefd1deb 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,49 @@ 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 {}` 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 + && 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"); + // 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; + if drop_name { + 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_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"); diff --git a/test/bundler/bundler_minify.test.ts b/test/bundler/bundler_minify.test.ts index 0f49f32e1795..11f9540543f7 100644 --- a/test/bundler/bundler_minify.test.ts +++ b/test/bundler/bundler_minify.test.ts @@ -1472,6 +1472,143 @@ 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"); + }, + }); + + // 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