diff --git a/src/js_parser/parse/parse_entry.rs b/src/js_parser/parse/parse_entry.rs index 713900cde056..7abae74a443d 100644 --- a/src/js_parser/parse/parse_entry.rs +++ b/src/js_parser/parse/parse_entry.rs @@ -1052,10 +1052,19 @@ impl<'a> Parser<'a> { } js_ast::StmtData::SClass(class) => { - // Move class export statements to the top of the file if we can - // This automatically resolves some cyclical import issues - // https://github.com/kysely-org/kysely/issues/412 - let should_move = !p.options.bundle && class.class.can_be_moved(); + // Move class statements ahead of other code to help cyclical imports + // (https://github.com/kysely-org/kysely/issues/412). Skip when any + // already-visited statement mentions the name so its TDZ is kept. + let used_before_decl = match class.class.class_name { + Some(name) => { + p.symbols.as_slice()[name.ref_.inner_index() as usize] + .use_count_estimate + > 0 + } + None => false, + }; + let should_move = + !p.options.bundle && !used_before_decl && class.class.can_be_moved(); let sliced = arena.alloc_slice_copy(&[*stmt]); p.append_part(&mut parts, sliced)?; @@ -1066,10 +1075,28 @@ impl<'a> Parser<'a> { } } js_ast::StmtData::SExportDefault(value) => { - // We move export default statements when we can - // This automatically resolves some cyclical import issues in packages like luxon - // https://github.com/oven-sh/bun/issues/1961 - let should_move = !p.options.bundle && value.can_be_moved(); + // Move export default ahead of other code to help cyclical imports + // (e.g. luxon, #1961). Like `SClass` above, keep a named default + // class in place if an earlier statement already references it. + let class_name_ref = match &value.value { + js_ast::StmtOrExpr::Stmt(s) => match &s.data { + js_ast::StmtData::SClass(c) => { + c.class.class_name.map(|name| name.ref_) + } + _ => None, + }, + // Class-expression names are not visible at module scope. + js_ast::StmtOrExpr::Expr(_) => None, + }; + let used_before_decl = match class_name_ref { + Some(ref_) => { + p.symbols.as_slice()[ref_.inner_index() as usize].use_count_estimate + > 0 + } + None => false, + }; + let should_move = + !p.options.bundle && !used_before_decl && value.can_be_moved(); let sliced = arena.alloc_slice_copy(&[*stmt]); p.append_part(&mut parts, sliced)?; diff --git a/test/bundler/transpiler/runtime-transpiler-class-hoist.test.ts b/test/bundler/transpiler/runtime-transpiler-class-hoist.test.ts new file mode 100644 index 000000000000..309314bba5c9 --- /dev/null +++ b/test/bundler/transpiler/runtime-transpiler-class-hoist.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; + +// The runtime transpiler hoists top-level class declarations to help cyclic +// imports. That hoist must not jump over an earlier reference to the class +// binding, or the temporal dead zone disappears. +describe.concurrent("top-level class declaration TDZ", () => { + const tdzFixture = (decl: string) => /* js */ ` + const out = []; + try { + out.push("typeof=" + typeof K); + out.push("constructed=" + new K().constructor.name); + } catch (e) { + out.push("ERR=" + String(e)); + } + ${decl} + out.push("after=" + typeof K); + console.log(out.join(" | ")); + `; + const expected = "ERR=ReferenceError: Cannot access 'K' before initialization. | after=function"; + + async function run(cmd: string[], files: Record) { + using dir = tempDir("runtime-transpiler-class-tdz", files); + await using proc = Bun.spawn({ + cmd: [bunExe(), ...cmd], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout: stdout.trim(), stderr, exitCode }; + } + + test.each([ + ["class K { m() {} }", "class declaration"], + ["export class K { m() {} }", "exported class declaration"], + ["export default class K { m() {} }", "export default named class"], + ])("preserved for a %s referenced before its declaration (%s)", async decl => { + expect(await run(["index.mjs"], { "index.mjs": tdzFixture(decl) })).toMatchObject({ + stdout: expected, + exitCode: 0, + }); + }); + + test("preserved for a CommonJS top-level class declaration", async () => { + expect(await run(["index.cjs"], { "index.cjs": tdzFixture("class K { m() {} }") })).toMatchObject({ + stdout: expected, + exitCode: 0, + }); + }); + + // Classes with no prior mention of their name are still hoisted. References + // inside a preceding function body count as a mention, so those stay put. + test("still hoisted when no already-visited statement mentions the name", async () => { + const { stdout, exitCode } = await run(["build", "--no-bundle", "--target=bun", "index.mjs"], { + "index.mjs": /* js */ ` + const unrelated = 1; + export class A { m() { return "A"; } } + function make() { return new B(); } + export class B {} + console.log(new A().m(), unrelated, make()); + `, + }); + const tokens = ["class A", "unrelated = 1", "function make", "class B"]; + const indexed = tokens.map(s => [s, stdout.indexOf(s)] as const); + const order = [...indexed].sort((a, b) => a[1] - b[1]).map(([s]) => s); + expect({ missing: indexed.filter(([, i]) => i < 0).map(([s]) => s), order, exitCode }).toEqual({ + missing: [], + order: tokens, + exitCode: 0, + }); + }); +});