Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
05f4610f9d fix(bundler): deoptimize CJS named exports inside control flow
When `exports.x = value` or `module.exports.x = value` appeared inside
control flow (if/else, while, for, etc.), the bundler incorrectly tried
to convert it to `var $x = value; export { $x as x };` inline. This
produced invalid JavaScript because:

1. Export statements were placed inside if blocks (must be top-level)
2. The fallback export clause referenced `__INVALID__REF__` sentinel

The root cause was that `is_top_level` in `s_expr` only checked
`p.current_scope == p.module_scope`, but if-statement bodies without
braces (and some loop bodies) don't push a new scope, so the check
incorrectly returned true.

Fix: Track control flow nesting depth and deoptimize CJS named exports
when they appear inside control flow. This causes the module to be
treated as a dynamic CJS module with runtime property access, which
correctly handles conditional exports.

Closes #11032

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-20 05:02:10 +00:00
5 changed files with 202 additions and 1 deletions

View File

@@ -233,6 +233,12 @@ pub fn NewParser_(
had_commonjs_named_exports_this_visit: bool = false,
commonjs_replacement_stmts: StmtNodeList = &.{},
/// Tracks whether we are inside a control flow construct (if/for/while/
/// do-while/switch/try/with/etc.). Used to deoptimize CJS named exports
/// that appear inside control flow, since they cannot be statically
/// converted to ESM export clauses (export statements must be top-level).
control_flow_nesting_depth: u32 = 0,
parse_pass_symbol_uses: ParsePassSymbolUsageType = undefined,
/// Used by commonjs_at_runtime

View File

@@ -314,6 +314,15 @@ pub fn AstMaybe(
return null;
}
// Deoptimize if exports.x appears inside control flow
// (if/else/for/while/etc.), since the export value is
// dynamic and cannot be statically converted to an ESM
// export clause (which must be at the top level).
if (p.control_flow_nesting_depth > 0) {
p.deoptimizeCommonJSNamedExports();
return null;
}
const named_export_entry = p.commonjs_named_exports.getOrPut(p.allocator, name) catch unreachable;
if (!named_export_entry.found_existing) {
const new_ref = p.newSymbol(
@@ -487,6 +496,13 @@ pub fn AstMaybe(
return null;
}
// Deoptimize if module.exports.x appears inside
// control flow, same as for exports.x above.
if (p.control_flow_nesting_depth > 0) {
p.deoptimizeCommonJSNamedExports();
return null;
}
const named_export_entry = p.commonjs_named_exports.getOrPut(p.allocator, name) catch unreachable;
if (!named_export_entry.found_existing) {
const new_ref = p.newSymbol(

View File

@@ -475,7 +475,9 @@ pub fn Visit(
const old_is_inside_loop = p.fn_or_arrow_data_visit.is_inside_loop;
p.fn_or_arrow_data_visit.is_inside_loop = true;
p.loop_body = stmt.data;
p.control_flow_nesting_depth += 1;
const res = p.visitSingleStmt(stmt, .loop_body);
p.control_flow_nesting_depth -= 1;
p.fn_or_arrow_data_visit.is_inside_loop = old_is_inside_loop;
return res;
}

View File

@@ -820,7 +820,7 @@ pub fn VisitStmt(
p.stmt_expr_value = data.value.data;
defer p.stmt_expr_value = .{ .e_missing = .{} };
const is_top_level = p.current_scope == p.module_scope;
const is_top_level = p.current_scope == p.module_scope and p.control_flow_nesting_depth == 0;
if (p.shouldUnwrapCommonJSToESM()) {
p.commonjs_named_exports_needs_conversion = if (is_top_level)
std.math.maxInt(u32)
@@ -1025,6 +1025,9 @@ pub fn VisitStmt(
data.test_ = SideEffects.simplifyBoolean(p, data.test_);
}
p.control_flow_nesting_depth += 1;
defer p.control_flow_nesting_depth -= 1;
const effects = SideEffects.toBoolean(p, data.test_.data);
if (effects.ok and !effects.value) {
const old = p.is_control_flow_dead;

View File

@@ -0,0 +1,174 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
// https://github.com/oven-sh/bun/issues/11032
// Bun.build produces invalid output when CJS exports are inside control flow
// (e.g. if/else, while, for). The bundler was placing ESM export clauses
// inside if blocks (invalid syntax) and referencing __INVALID__REF__.
test("bundling CJS exports inside if/else produces valid output", async () => {
using dir = tempDir("issue-11032", {
"mod.js": `if (true) exports.x = "yes"; else exports.x = "no";`,
"entry.js": `import {x} from "./mod.js"; console.log(x);`,
});
const result = await Bun.build({
entrypoints: [`${dir}/entry.js`],
outdir: `${dir}/dist`,
target: "browser",
});
expect(result.success).toBe(true);
const entry = result.outputs.find(o => o.kind === "entry-point");
expect(entry).toBeDefined();
const content = await entry!.text();
// Must not contain __INVALID__REF__ sentinel
expect(content).not.toContain("__INVALID__REF__");
// Must not contain tagSymbol (old sentinel name)
expect(content).not.toContain("tagSymbol");
// export {} must not appear inside an if block — only at top level
// (a rough check: there should be no "export" keyword inside braces of an if statement)
expect(content).not.toMatch(/if\s*\([^)]*\)\s*\{[^}]*\bexport\b/);
});
test("bundling CJS exports inside if/else runs correctly", async () => {
using dir = tempDir("issue-11032-run", {
"mod.js": `if (true) exports.x = "yes"; else exports.x = "no";`,
"entry.js": `import {x} from "./mod.js"; console.log(x);`,
});
const result = await Bun.build({
entrypoints: [`${dir}/entry.js`],
outdir: `${dir}/dist`,
});
expect(result.success).toBe(true);
const entryOutput = result.outputs.find(o => o.kind === "entry-point");
expect(entryOutput).toBeDefined();
await using proc = Bun.spawn({
cmd: [bunExe(), entryOutput!.path],
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(stdout.trim()).toBe("yes");
expect(stderr).toBe("");
expect(exitCode).toBe(0);
});
test("bundling CJS exports inside while loop produces valid output", async () => {
using dir = tempDir("issue-11032-while", {
"mod.js": `
var done = false;
while (!done) {
exports.x = "from-loop";
done = true;
}
`,
"entry.js": `import {x} from "./mod.js"; console.log(x);`,
});
const result = await Bun.build({
entrypoints: [`${dir}/entry.js`],
outdir: `${dir}/dist`,
});
expect(result.success).toBe(true);
const entry = result.outputs.find(o => o.kind === "entry-point");
expect(entry).toBeDefined();
const content = await entry!.text();
expect(content).not.toContain("__INVALID__REF__");
await using proc = Bun.spawn({
cmd: [bunExe(), entry!.path],
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(stdout.trim()).toBe("from-loop");
expect(stderr).toBe("");
expect(exitCode).toBe(0);
});
test("bundling CJS exports inside braced if block produces valid output", async () => {
using dir = tempDir("issue-11032-braced", {
"mod.js": `if (true) { exports.x = "braced"; } else { exports.x = "other"; }`,
"entry.js": `import {x} from "./mod.js"; console.log(x);`,
});
const result = await Bun.build({
entrypoints: [`${dir}/entry.js`],
outdir: `${dir}/dist`,
});
expect(result.success).toBe(true);
const entry = result.outputs.find(o => o.kind === "entry-point");
expect(entry).toBeDefined();
const content = await entry!.text();
expect(content).not.toContain("__INVALID__REF__");
await using proc = Bun.spawn({
cmd: [bunExe(), entry!.path],
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(stdout.trim()).toBe("braced");
expect(stderr).toBe("");
expect(exitCode).toBe(0);
});
test("top-level CJS exports still work correctly after fix", async () => {
using dir = tempDir("issue-11032-toplevel", {
"mod.js": `exports.x = "top-level"; exports.y = 42;`,
"entry.js": `import {x, y} from "./mod.js"; console.log(x, y);`,
});
const result = await Bun.build({
entrypoints: [`${dir}/entry.js`],
outdir: `${dir}/dist`,
});
expect(result.success).toBe(true);
const entry = result.outputs.find(o => o.kind === "entry-point");
expect(entry).toBeDefined();
const content = await entry!.text();
expect(content).not.toContain("__INVALID__REF__");
await using proc = Bun.spawn({
cmd: [bunExe(), entry!.path],
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(stdout.trim()).toBe("top-level 42");
expect(stderr).toBe("");
expect(exitCode).toBe(0);
});