diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 1ec7115a1dae..589fa39b128d 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -8975,13 +8975,6 @@ impl LowerUsingDeclarationsContext { result.push(stmt); continue; } - js_ast::StmtData::SClass(c) => { - if c.is_export { - // can't go in try/catch; hoist out - result.push(stmt); - continue; - } - } js_ast::StmtData::SExportDefault(_) => { continue; // this prevents re-exporting default since we already have it as an .s_export_clause } diff --git a/src/js_parser/visit/visit_stmt.rs b/src/js_parser/visit/visit_stmt.rs index dfce8bec1923..a64fc9b55aba 100644 --- a/src/js_parser/visit/visit_stmt.rs +++ b/src/js_parser/visit/visit_stmt.rs @@ -1066,7 +1066,10 @@ 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 + // Class counterpart of `select_local_kind` turning top-level `let`/`const` into `var`. + if p.will_wrap_module_in_try_catch_for_using && p.current_scope().parent.is_none() { + Self::convert_class_stmt_to_var(p, lowered, data); + } stmts.extend_from_slice(lowered); } else { let ref_ = data @@ -1118,6 +1121,35 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O Ok(()) } + /// Swaps the class statement in `lowered` for `var Foo = class Foo {}` (exported if it was). + fn convert_class_stmt_to_var(p: &mut Self, lowered: &mut [Stmt], data: &mut S::Class) { + let Some(slot) = lowered + .iter_mut() + .find(|s| matches!(s.data, StmtData::SClass(_))) + else { + return; + }; + let loc = slot.loc; + let name = data + .class + .class_name + .expect("infallible: class statements are always named"); + let class_expr = p.new_expr(core::mem::take(&mut data.class), loc); + let binding = p.b(B::Identifier { r#ref: name.ref_ }, name.loc); + *slot = p.s( + S::Local { + kind: S::Kind::KVar, + is_export: data.is_export, + decls: G::DeclList::from_slice(&[G::Decl { + binding, + value: Some(class_expr), + }]), + ..Default::default() + }, + loc, + ); + } + fn s_local( p: &mut Self, stmts: &mut StmtList<'a>, diff --git a/test/bundler/bundler_edgecase.test.ts b/test/bundler/bundler_edgecase.test.ts index 10d43bf3c113..687f68413abd 100644 --- a/test/bundler/bundler_edgecase.test.ts +++ b/test/bundler/bundler_edgecase.test.ts @@ -2348,6 +2348,177 @@ describe("bundler", () => { }, }); + // Lowering a top-level `using` wraps the module body in try/catch/finally. + // Class declarations have to stay in source order inside that block, but as + // module-scoped `var`s, like esbuild: exported classes used to be hoisted + // above the block (evaluating before the `using` value was initialized) and + // non-exported ones stayed block-scoped to it, so the hoisted function + // declarations could not see them. + itBundled("edgecase/UsingTopLevelClassDeclarations", { + files: { + "/entry.ts": ` + import { Exported, Clause, viaFunction, Sub } from "./module.ts"; + console.log(Exported.name, Exported.v); + console.log(Clause.name, Clause.v, Clause.self() === Clause); + console.log(viaFunction().name, viaFunction().v); + console.log(Sub.name, Sub.v, Object.getPrototypeOf(Sub) === Exported); + `, + "/module.ts": ` + using resource = { v: 42, [Symbol.dispose]() { console.log("Disposing"); } }; + export class Exported { static v = resource.v; } + class Clause { static v = resource.v; static self() { return Clause; } } + export { Clause }; + class Hidden { static v = resource.v; } + export function viaFunction() { return Hidden; } + export class Sub extends Exported {} + `, + }, + run: { + stdout: ` + Disposing + Exported 42 + Clause 42 true + Hidden 42 + Sub 42 true + `, + }, + onAfterBundle(api) { + const output = api.readFile("/out.js"); + expect(output).not.toMatch(/^\s*class /m); + // Only the assignment is pinned down: when bundling, the `var` itself is + // relocated into a top-level declaration, and the class expression may or + // may not carry a name. + expect(output).toMatch(/\bExported = class\b/); + expect(output).toMatch(/\bHidden = class\b/); + }, + }); + + // A module that is also reached through import() is evaluated inside an + // `__esm(...)` wrapper. The linker only hoists top-level declarations out of + // it, so the classes rely on the try block's `var`s being relocated into a + // top-level declaration (see LoweredTopLevelUsingInEsmWrapper below). + itBundled("edgecase/UsingTopLevelClassDeclarationsInEsmWrapper", { + files: { + "/entry.ts": ` + import { Exported, viaFunction } from "./module.ts"; + console.log("static", Exported.v, viaFunction().v); + const mod = await import("./module.ts"); + console.log("dynamic", mod.Exported === Exported, mod.viaFunction().v); + `, + "/module.ts": ` + using resource = { v: 42, [Symbol.dispose]() { console.log("Disposing"); } }; + export class Exported { static v = resource.v; } + class Hidden { static v = resource.v; } + export function viaFunction() { return Hidden; } + `, + }, + onAfterBundle(api) { + const output = api.readFile("/out.js"); + expect(output).toContain("__esm("); + expect(output).toMatch(/^var __stack, resource, Exported, Hidden;$/m); + }, + run: { + stdout: ` + Disposing + static 42 42 + dynamic true 42 + `, + }, + }); + + itBundled("edgecase/AwaitUsingTopLevelClassDeclarations", { + files: { + "/entry.ts": ` + import { Exported, viaFunction } from "./module.ts"; + console.log(Exported.v, viaFunction().v); + `, + "/module.ts": ` + await using resource = { v: 42, async [Symbol.asyncDispose]() { console.log("Disposing"); } }; + export class Exported { static v = resource.v; } + class Hidden { static v = resource.v; } + export function viaFunction() { return Hidden; } + `, + }, + run: { + stdout: "Disposing\n42 42", + }, + }); + + // `lower_class` emits extra statements around a decorated class; the class + // statement among them is the one that gets rewritten to a `var`. + itBundled("edgecase/UsingTopLevelClassStandardDecorators", { + files: { + "/entry.ts": ` + import { Exported, viaFunction } from "./module.ts"; + console.log(Exported.decorated, Exported.v, viaFunction().decorated, viaFunction().v); + `, + "/module.ts": ` + using resource = { v: 42, [Symbol.dispose]() { console.log("Disposing"); } }; + function decorate(value: any, context: ClassDecoratorContext) { + return class extends value { static decorated = context.name; }; + } + @decorate + export class Exported { static v = resource.v; } + @decorate + class Hidden { static v = resource.v; } + export function viaFunction() { return Hidden; } + `, + }, + run: { + stdout: "Disposing\nExported 42 Hidden 42", + }, + }); + + itBundled("edgecase/UsingTopLevelClassExperimentalDecorators", { + files: { + "/entry.ts": ` + import { Exported, viaFunction } from "./module.ts"; + console.log(Exported.decorated, Exported.v, Exported.fields, viaFunction().decorated, viaFunction().v); + `, + "/module.ts": ` + using resource = { v: 42, [Symbol.dispose]() { console.log("Disposing"); } }; + function decorate(target: any) { + return class extends target { static decorated = target.name; }; + } + function field(target: any, key: string) { + target.fields = key; + } + @decorate + export class Exported { static v = resource.v; @field static f = 1; } + @decorate + class Hidden { static v = resource.v; } + export function viaFunction() { return Hidden; } + `, + "/tsconfig.json": /* json */ `{ "compilerOptions": { "experimentalDecorators": true } }`, + }, + run: { + stdout: "Disposing\nExported 42 f Hidden 42", + }, + }); + + itBundled("edgecase/UsingTopLevelClassBakeDev", { + // The dev server's module format builds its export list from the same + // lowered statements. + format: "internal_bake_dev", + files: { + "/entry.ts": ` + using resource = { [Symbol.dispose]() {} }; + export class Exported {} + class Hidden {} + export function viaFunction() { return Hidden; } + `, + }, + onAfterBundle(api) { + const output = api.readFile("/out.js"); + expect(output).not.toMatch(/^\s*class (Exported|Hidden) /m); + expect(output).toMatch(/\bExported = class\b/); + expect(output).toMatch(/\bHidden = class\b/); + const [, exportList] = output.match(/hmr\.exports = \{([^}]*)\}/)!; + const exportNames = exportList.split(/[\s,]+/).filter(Boolean); + expect(exportNames.sort()).toEqual(["Exported", "viaFunction"]); + }, + }); + itBundled("edgecase/UsingExportFails", { files: { "/entry.ts": ` diff --git a/test/bundler/transpiler/__snapshots__/transpiler.test.js.snap b/test/bundler/transpiler/__snapshots__/transpiler.test.js.snap index a78b2167dd83..0b269bbd18d7 100644 --- a/test/bundler/transpiler/__snapshots__/transpiler.test.js.snap +++ b/test/bundler/transpiler/__snapshots__/transpiler.test.js.snap @@ -178,3 +178,37 @@ export { }; " `; + +exports[`Bun.Transpiler using top level turns class declarations into vars 1`] = ` +"import { __callDispose as __callDispose, __using as __using } from "bun:wrap"; +export function f() { + + class G { + } + return [new E, new G]; +} +let __bun_temp_ref_1$ = []; +try { + var a = __using(__bun_temp_ref_1$, b(), 0); + var C = class C { + static a = a; + }; + var D = class D extends C { + static self() { + return D; + } + }; + var E = class E { + }; +} catch (__bun_temp_ref_2$) { + var __bun_temp_ref_3$ = __bun_temp_ref_2$, __bun_temp_ref_4$ = 1; +} finally { + __callDispose(__bun_temp_ref_1$, __bun_temp_ref_3$, __bun_temp_ref_4$); +} + +export { + C, + D +}; +" +`; diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index a9a0d382aaed..27ab495028ae 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -4700,6 +4700,24 @@ console.log("boop"); export var q = r; `); }); + + it("using top level turns class declarations into vars", () => { + // Everything but imports, exports and function declarations moves into the + // try block, so classes become module-scoped `var`s in source order (and + // exported ones move to the export clause), matching esbuild. Classes in + // nested scopes are left alone. + expectPrintedSnapshot(` + using a = b(); + export class C { static a = a; } + class D extends C { static self() { return D; } } + export { D }; + class E {} + export function f() { + class G {} + return [new E(), new G()]; + } + `); + }); }); describe("await can only be used inside an async function message", () => {