Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 44 additions & 3 deletions src/js_parser/visit/visit_stmt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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).
Comment thread
robobun marked this conversation as resolved.
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.
Comment thread
robobun marked this conversation as resolved.
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
Expand Down
8 changes: 5 additions & 3 deletions test/bundler/bundler_edgecase.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
137 changes: 137 additions & 0 deletions test/bundler/bundler_minify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading