From 4585d34e97600df19a4160e7eacda894c3220e32 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 09:25:56 +0000 Subject: [PATCH 1/6] transpiler: stop hoisting class declarations in the runtime transpile path The runtime transpiler (target=bun, non-bundle) moved side-effect-free class declarations and export default statements to the top of the module via the 'before' parts list. This erased the TDZ for class bindings: 'typeof Foo' and 'new Foo()' would succeed before the declaration line instead of throwing ReferenceError, diverging from Node and browsers. The hoist was originally added (eec1a07907, c3dc64d468) to paper over early ESM cycle evaluation-order differences that hit kysely and luxon. Both packages now import cleanly without it, so drop the reordering and let SClass / SExportDefault fall through to the default per-statement part path, preserving source order and class TDZ semantics. --- src/js_parser/parse/parse_entry.rs | 26 ------ .../transpiler/runtime-transpiler.test.ts | 85 +++++++++++++++++++ 2 files changed, 85 insertions(+), 26 deletions(-) diff --git a/src/js_parser/parse/parse_entry.rs b/src/js_parser/parse/parse_entry.rs index 9227cd63a118..441de62078be 100644 --- a/src/js_parser/parse/parse_entry.rs +++ b/src/js_parser/parse/parse_entry.rs @@ -934,32 +934,6 @@ impl<'a> Parser<'a> { p.append_part(parts_list, sliced)?; } - 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(); - - let sliced = arena.alloc_slice_copy(&[*stmt]); - p.append_part(&mut parts, sliced)?; - - if should_move { - // `Part` isn't `Copy`; pop+push instead of last+truncate. - before.push(parts.pop().expect("unreachable")); - } - } - 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(); - let sliced = arena.alloc_slice_copy(&[*stmt]); - p.append_part(&mut parts, sliced)?; - - if should_move { - before.push(parts.pop().expect("unreachable")); - } - } js_ast::StmtData::SEnum(_) => { // `Part` isn't `Clone`; move out the // pre-visited parts instead of `appendSlice`. diff --git a/test/bundler/transpiler/runtime-transpiler.test.ts b/test/bundler/transpiler/runtime-transpiler.test.ts index 5c05e408c92a..52128af5439d 100644 --- a/test/bundler/transpiler/runtime-transpiler.test.ts +++ b/test/bundler/transpiler/runtime-transpiler.test.ts @@ -252,3 +252,88 @@ describe("unterminated string literals in large files", () => { expect(exitCode).toBe(1); }); }); + +describe("class declaration TDZ is preserved", () => { + const probe = + `const t = (f) => { try { return f(); } catch (e) { return "THROW:" + e.constructor.name; } };\n` + + `console.log(JSON.stringify([t(() => typeof Pure), t(() => new Pure().m()), t(() => typeof WithBlock)]));\n`; + + test.concurrent.each([ + ["class", ""], + ["export class", "export "], + ])("runtime: %s stays in TDZ until its declaration", async (_, prefix) => { + using dir = tempDir("transpiler-class-tdz", { + "entry.mjs": + probe + + `${prefix}class Pure { m() { return "ok"; } f = 1; get g() { return 2; } static s = 3; }\n` + + `${prefix}class WithBlock { static { void 0; } }\n`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "entry.mjs"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual(["THROW:ReferenceError", "THROW:ReferenceError", "THROW:ReferenceError"]); + expect(exitCode).toBe(0); + }); + + test.concurrent("runtime: export default class stays in TDZ until its declaration", async () => { + using dir = tempDir("transpiler-class-tdz-default", { + "entry.mjs": + `const t = (f) => { try { return f(); } catch (e) { return "THROW:" + e.constructor.name; } };\n` + + `console.log(JSON.stringify([t(() => typeof Named), t(() => new Named().m())]));\n` + + `export default class Named { m() { return "ok"; } static s = 3; }\n`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "entry.mjs"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual(["THROW:ReferenceError", "THROW:ReferenceError"]); + expect(exitCode).toBe(0); + }); + + test.concurrent("--no-bundle output keeps class declarations in source order", async () => { + using dir = tempDir("transpiler-class-tdz-print", { + "entry.mjs": `const marker = 1;\nclass Pure { m() { return "ok"; } static s = 3; }\nexport default class Named { static s = 3; }\n`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "--no-bundle", "--target=bun", "entry.mjs"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + const markerPos = stdout.indexOf("marker"); + const purePos = stdout.indexOf("class Pure"); + const namedPos = stdout.indexOf("class Named"); + expect({ markerPos, purePos, namedPos }).toEqual({ + markerPos: expect.any(Number), + purePos: expect.any(Number), + namedPos: expect.any(Number), + }); + expect(markerPos).toBeGreaterThanOrEqual(0); + expect(markerPos).toBeLessThan(purePos); + expect(purePos).toBeLessThan(namedPos); + expect(exitCode).toBe(0); + }); +}); From 2040c75519d64e093fcb39fa528c29fd41b5c861 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 11:55:29 +0000 Subject: [PATCH 2/6] test: replace vacuous indexOf assertion and add cyclic default-class import coverage Address review on #35648: - Drop the expect.any(Number) object check in the --no-bundle order test (indexOf always returns a number) and assert the sorted token order directly so a regression shows the actual emitted order in the diff. - Add a two-file mutual 'export default class' cycle (the luxon/kysely pattern the removed hoist was originally added for) to guard against regressing those imports. --- .../transpiler/runtime-transpiler.test.ts | 38 ++++++++++++++----- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/test/bundler/transpiler/runtime-transpiler.test.ts b/test/bundler/transpiler/runtime-transpiler.test.ts index 52128af5439d..d4655e28beea 100644 --- a/test/bundler/transpiler/runtime-transpiler.test.ts +++ b/test/bundler/transpiler/runtime-transpiler.test.ts @@ -323,17 +323,35 @@ describe("class declaration TDZ is preserved", () => { const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).toBe(""); - const markerPos = stdout.indexOf("marker"); - const purePos = stdout.indexOf("class Pure"); - const namedPos = stdout.indexOf("class Named"); - expect({ markerPos, purePos, namedPos }).toEqual({ - markerPos: expect.any(Number), - purePos: expect.any(Number), - namedPos: expect.any(Number), + const tokens = ["marker", "class Pure", "class Named"]; + const positions = tokens.map(t => [t, stdout.indexOf(t)] as const); + const order = [...positions].sort((a, b) => a[1] - b[1]).map(([t]) => t); + expect({ missing: positions.filter(([, i]) => i < 0).map(([t]) => t), order }).toEqual({ + missing: [], + order: tokens, }); - expect(markerPos).toBeGreaterThanOrEqual(0); - expect(markerPos).toBeLessThan(purePos); - expect(purePos).toBeLessThan(namedPos); + expect(exitCode).toBe(0); + }); + + test.concurrent("cyclic default-class imports still evaluate (luxon/kysely pattern)", async () => { + using dir = tempDir("transpiler-class-tdz-cycle", { + "entry.mjs": `import A from "./a.mjs";\nimport B from "./b.mjs";\nconsole.log(JSON.stringify([A.useB(), B.useA()]));\n`, + "a.mjs": `import B from "./b.mjs";\nexport default class A { static useB() { return B.name; } }\n`, + "b.mjs": `import A from "./a.mjs";\nexport default class B { static useA() { return A.name; } }\n`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "entry.mjs"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual(["B", "A"]); expect(exitCode).toBe(0); }); }); From 33dac8a5fd6587ade60e749134ea2e7bdf70e76f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 13:44:08 +0000 Subject: [PATCH 3/6] transpiler: keep SExportDefault hoist with a use-count guard for named default classes svelte.test.ts segfaults on linux-aarch64 release when the SExportDefault hoist is dropped entirely (build 80714, both distros, identical trace; adjacent PR builds 80740/80750 pass the same shard). Restore the hoist so 'export default function' / 'export default ' keep the existing cycle-friendly ordering, but suppress it when the default is a named class whose name an earlier top-level statement already references, so 'export default class Named' still stays in TDZ when observed before its declaration. Plain 'class'/'export class' declarations remain in source order (SClass arm still removed). Test updates: - --no-bundle order test now covers 'class'/'export class' (both SClass) - cyclic default-class test still exercises the hoisted path --- src/js_parser/parse/parse_entry.rs | 30 +++++++++++++++++++ .../transpiler/runtime-transpiler.test.ts | 4 +-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/js_parser/parse/parse_entry.rs b/src/js_parser/parse/parse_entry.rs index 441de62078be..7176114da91d 100644 --- a/src/js_parser/parse/parse_entry.rs +++ b/src/js_parser/parse/parse_entry.rs @@ -934,6 +934,36 @@ impl<'a> Parser<'a> { p.append_part(parts_list, sliced)?; } + js_ast::StmtData::SExportDefault(value) => { + // Move export default ahead of other code to help cyclical imports + // in packages like luxon (#1961). Keep a named default class in + // place when an earlier top-level statement already references the + // class name so its TDZ is preserved. + 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, + }, + 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)?; + + if should_move { + before.push(parts.pop().expect("unreachable")); + } + } js_ast::StmtData::SEnum(_) => { // `Part` isn't `Clone`; move out the // pre-visited parts instead of `appendSlice`. diff --git a/test/bundler/transpiler/runtime-transpiler.test.ts b/test/bundler/transpiler/runtime-transpiler.test.ts index d4655e28beea..e6718da1e38d 100644 --- a/test/bundler/transpiler/runtime-transpiler.test.ts +++ b/test/bundler/transpiler/runtime-transpiler.test.ts @@ -309,7 +309,7 @@ describe("class declaration TDZ is preserved", () => { test.concurrent("--no-bundle output keeps class declarations in source order", async () => { using dir = tempDir("transpiler-class-tdz-print", { - "entry.mjs": `const marker = 1;\nclass Pure { m() { return "ok"; } static s = 3; }\nexport default class Named { static s = 3; }\n`, + "entry.mjs": `const marker = 1;\nclass Pure { m() { return "ok"; } static s = 3; }\nexport class Exported { static s = 3; }\n`, }); await using proc = Bun.spawn({ @@ -323,7 +323,7 @@ describe("class declaration TDZ is preserved", () => { const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).toBe(""); - const tokens = ["marker", "class Pure", "class Named"]; + const tokens = ["marker", "class Pure", "class Exported"]; const positions = tokens.map(t => [t, stdout.indexOf(t)] as const); const order = [...positions].sort((a, b) => a[1] - b[1]).map(([t]) => t); expect({ missing: positions.filter(([, i]) => i < 0).map(([t]) => t), order }).toEqual({ From 246d1367e6c1475027643c3e23d8eec847d8bfcb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 13:53:21 +0000 Subject: [PATCH 4/6] parse_entry: trim SExportDefault hoist comment --- src/js_parser/parse/parse_entry.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/js_parser/parse/parse_entry.rs b/src/js_parser/parse/parse_entry.rs index 7176114da91d..d6e4c8c3d985 100644 --- a/src/js_parser/parse/parse_entry.rs +++ b/src/js_parser/parse/parse_entry.rs @@ -935,10 +935,8 @@ impl<'a> Parser<'a> { } js_ast::StmtData::SExportDefault(value) => { - // Move export default ahead of other code to help cyclical imports - // in packages like luxon (#1961). Keep a named default class in - // place when an earlier top-level statement already references the - // class name so its TDZ is preserved. + // Hoist for cyclic-import compat (#1961), except when a named + // default class is already referenced earlier (preserve its TDZ). let class_name_ref = match &value.value { js_ast::StmtOrExpr::Stmt(s) => match &s.data { js_ast::StmtData::SClass(c) => { From bf41c525797f0ee4b9cd8be9cefe7a2a2801c64d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 14:15:11 +0000 Subject: [PATCH 5/6] parse_entry: never hoist a named export default class The use_count_estimate guard only sees references in textually-earlier statements, so a function declared after the default class (but engine-hoisted and callable before it) could still observe the class out of TDZ. Replace the heuristic with a simple is_named_default_class check: a named default class always stays in source order; anonymous 'export default class {}', 'export default function', and constant defaults have no module-scope TDZ binding and keep the #1961 hoist. Extends the export-default TDZ test with a trailing 'function probe()' that reads the class name, covering the forward-reference case. --- src/js_parser/parse/parse_entry.rs | 27 +++++++------------ .../transpiler/runtime-transpiler.test.ts | 11 +++++--- 2 files changed, 17 insertions(+), 21 deletions(-) diff --git a/src/js_parser/parse/parse_entry.rs b/src/js_parser/parse/parse_entry.rs index d6e4c8c3d985..09ddaaf35e92 100644 --- a/src/js_parser/parse/parse_entry.rs +++ b/src/js_parser/parse/parse_entry.rs @@ -935,26 +935,17 @@ impl<'a> Parser<'a> { } js_ast::StmtData::SExportDefault(value) => { - // Hoist for cyclic-import compat (#1961), except when a named - // default class is already referenced earlier (preserve its TDZ). - 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, - }, - 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, + // Hoist for cyclic-import compat (#1961). A named default class + // has a TDZ binding, so leave it in place. + let is_named_default_class = match &value.value { + js_ast::StmtOrExpr::Stmt(s) => matches!( + &s.data, + js_ast::StmtData::SClass(c) if c.class.class_name.is_some() + ), + js_ast::StmtOrExpr::Expr(_) => false, }; let should_move = - !p.options.bundle && !used_before_decl && value.can_be_moved(); + !p.options.bundle && !is_named_default_class && 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.test.ts b/test/bundler/transpiler/runtime-transpiler.test.ts index e6718da1e38d..d5e174dfc80c 100644 --- a/test/bundler/transpiler/runtime-transpiler.test.ts +++ b/test/bundler/transpiler/runtime-transpiler.test.ts @@ -288,8 +288,9 @@ describe("class declaration TDZ is preserved", () => { using dir = tempDir("transpiler-class-tdz-default", { "entry.mjs": `const t = (f) => { try { return f(); } catch (e) { return "THROW:" + e.constructor.name; } };\n` + - `console.log(JSON.stringify([t(() => typeof Named), t(() => new Named().m())]));\n` + - `export default class Named { m() { return "ok"; } static s = 3; }\n`, + `console.log(JSON.stringify([t(() => typeof Named), t(() => new Named().m()), t(probe)]));\n` + + `export default class Named { m() { return "ok"; } static s = 3; }\n` + + `function probe() { return typeof Named; }\n`, }); await using proc = Bun.spawn({ @@ -303,7 +304,11 @@ describe("class declaration TDZ is preserved", () => { const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).toBe(""); - expect(JSON.parse(stdout)).toEqual(["THROW:ReferenceError", "THROW:ReferenceError"]); + expect(JSON.parse(stdout)).toEqual([ + "THROW:ReferenceError", + "THROW:ReferenceError", + "THROW:ReferenceError", + ]); expect(exitCode).toBe(0); }); From 277884fe96cf3b26f51ffc4c93a439c93aa3e9ab Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 14:17:25 +0000 Subject: [PATCH 6/6] [autofix.ci] apply automated fixes --- test/bundler/transpiler/runtime-transpiler.test.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/test/bundler/transpiler/runtime-transpiler.test.ts b/test/bundler/transpiler/runtime-transpiler.test.ts index d5e174dfc80c..5b6940e08817 100644 --- a/test/bundler/transpiler/runtime-transpiler.test.ts +++ b/test/bundler/transpiler/runtime-transpiler.test.ts @@ -304,11 +304,7 @@ describe("class declaration TDZ is preserved", () => { const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).toBe(""); - expect(JSON.parse(stdout)).toEqual([ - "THROW:ReferenceError", - "THROW:ReferenceError", - "THROW:ReferenceError", - ]); + expect(JSON.parse(stdout)).toEqual(["THROW:ReferenceError", "THROW:ReferenceError", "THROW:ReferenceError"]); expect(exitCode).toBe(0); });