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
43 changes: 35 additions & 8 deletions src/js_parser/parse/parse_entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Comment thread
robobun marked this conversation as resolved.

let sliced = arena.alloc_slice_copy(&[*stmt]);
p.append_part(&mut parts, sliced)?;
Expand All @@ -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)?;

Expand Down
74 changes: 74 additions & 0 deletions test/bundler/transpiler/runtime-transpiler-class-hoist.test.ts
Original file line number Diff line number Diff line change
@@ -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));
}
Comment thread
robobun marked this conversation as resolved.
${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<string, string>) {
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 => {
Comment thread
robobun marked this conversation as resolved.
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,
});
Comment thread
robobun marked this conversation as resolved.
});
});
Loading