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
29 changes: 11 additions & 18 deletions src/js_parser/parse/parse_entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -934,25 +934,18 @@ 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();
// Hoist for cyclic-import compat (#1961). A named default class
// has a TDZ binding, so leave it in place.
Comment thread
robobun marked this conversation as resolved.
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 && !is_named_default_class && value.can_be_moved();
let sliced = arena.alloc_slice_copy(&[*stmt]);
p.append_part(&mut parts, sliced)?;

Expand Down
104 changes: 104 additions & 0 deletions test/bundler/transpiler/runtime-transpiler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,3 +252,107 @@ 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()), 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({
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("--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 class Exported { 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 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({
missing: [],
order: tokens,
});
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);
});
});
Loading