fix(bundler): default import from 'bun' with --bytecode no longer undefined (#27175)

## Summary
- Fixes `import Bun from 'bun'` being `undefined` when bundled with
`--bytecode` flag
- The CJS lowering pass was incorrectly adding `.default` property
access to `globalThis.Bun`, which doesn't have a `default` property
- Also skips unnecessary `__toESM` wrapper inclusion for built-in `bun`
module imports

## Root Cause
When `--bytecode` is used, the bundler wraps output in CJS format. For
external modules, the linker sets up a `namespace_alias` with `alias:
"default"` so the printer generates property access like
`require_module().default`. The printer correctly replaces the `bun`
import source with `globalThis.Bun`, but was still appending `.default`
— producing `import_bun.default` instead of just `import_bun`.

## Fix
Three targeted changes:
1. **`scanImportsAndExports.zig`**: Skip CJS interop wrapping
(`__toESM`) for `bun` tagged imports since `globalThis.Bun` doesn't need
it
2. **`LinkerContext.zig`**: Pass `import_record_index` through to
`namespace_alias` so the printer can identify the import source
3. **`js_printer.zig`**: Skip the `.default` property access for `bun`
tagged import records, while preserving named property accesses (e.g.
`.serve`)

## Test plan
- [x] `bun bd test test/regression/issue/20670.test.ts` — 4 pass
(default, aliased, combined, namespace imports)
- [x] Tests fail with `USE_SYSTEM_BUN=1` confirming they test the actual
fix
- [x] `bun bd test test/bundler/bundler_bun.test.ts` — 10 pass
- [x] `bun bd test test/bundler/bundler_cjs.test.ts` — 23 pass
- [x] `bun bd test test/bundler/bundler_edgecase.test.ts` — 99 pass

Closes #20670

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
This commit is contained in:
robobun
2026-03-01 02:26:08 -08:00
committed by GitHub
parent e5ac0ee0d6
commit 87deb0e98e
4 changed files with 197 additions and 11 deletions

View File

@@ -2492,6 +2492,7 @@ pub const LinkerContext = struct {
c.graph.symbols.get(import_ref).?.namespace_alias = js_ast.G.NamespaceAlias{
.namespace_ref = result.namespace_ref,
.alias = result.alias,
.import_record_index = named_import.import_record_index,
};
},
.normal_and_namespace => {
@@ -2510,6 +2511,7 @@ pub const LinkerContext = struct {
c.graph.symbols.get(import_ref).?.namespace_alias = js_ast.G.NamespaceAlias{
.namespace_ref = result.namespace_ref,
.alias = result.alias,
.import_record_index = named_import.import_record_index,
};
},
.cycle => {

View File

@@ -659,6 +659,10 @@ pub fn scanImportsAndExports(this: *LinkerContext) ScanImportsAndExportsError!vo
if (!record.source_index.isValid() or this.isExternalDynamicImport(record, source_index)) {
if (output_format == .internal_bake_dev) continue;
// The "bun" module is replaced with "globalThis.Bun" by the
// printer. It doesn't need CJS interop wrapping.
if (record.tag == .bun) continue;
// This is an external import. Check if it will be a "require()" call.
if (kind == .require or !output_format.keepES6ImportExportSyntax() or kind == .dynamic) {
if (record.source_index.isValid() and kind == .dynamic and ast_flags[other_id].force_cjs_to_esm) {

View File

@@ -2976,17 +2976,29 @@ fn NewPrinter(
p.printSpaceBeforeIdentifier();
p.addSourceMapping(expr.loc);
p.printSymbol(namespace.namespace_ref);
const alias = namespace.alias;
if (js_lexer.isIdentifier(alias)) {
p.print(".");
// TODO: addSourceMappingForName
p.printIdentifier(alias);
} else {
p.print("[");
// TODO: addSourceMappingForName
// p.addSourceMappingForName(alias);
p.printStringLiteralUTF8(alias, false);
p.print("]");
// The "bun" module is replaced with "globalThis.Bun"
// by the printer, which is already the namespace object
// itself. For default imports, skip the ".default" property
// access since globalThis.Bun IS the namespace and does
// not have a "default" property.
const skip_alias = is_bun_platform and
strings.eqlComptime(namespace.alias, "default") and
namespace.import_record_index < p.import_records.len and
p.importRecord(namespace.import_record_index).tag == .bun;
if (!skip_alias) {
const alias = namespace.alias;
if (js_lexer.isIdentifier(alias)) {
p.print(".");
// TODO: addSourceMappingForName
p.printIdentifier(alias);
} else {
p.print("[");
// TODO: addSourceMappingForName
// p.addSourceMappingForName(alias);
p.printStringLiteralUTF8(alias, false);
p.print("]");
}
}
if (wrap) {

View File

@@ -0,0 +1,168 @@
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
import { join } from "path";
// Regression test for https://github.com/oven-sh/bun/issues/20670
// Default import from 'bun' was undefined when using --bytecode flag.
// The CJS lowering pass incorrectly added ".default" property access
// to globalThis.Bun, which doesn't have a "default" property.
describe("issue #20670: import from 'bun' with --bytecode", () => {
test("default import", async () => {
using dir = tempDir("20670-default", {
"index.js": `
import Bun from 'bun';
console.log(Bun === undefined ? 'FAIL' : 'PASS');
`,
});
await using build = Bun.spawn({
cmd: [
bunExe(),
"build",
"--target=bun",
"--bytecode",
"--outdir",
String(dir) + "/out",
join(String(dir), "index.js"),
],
env: bunEnv,
stderr: "pipe",
});
const [buildStderr, buildExit] = await Promise.all([build.stderr.text(), build.exited]);
expect(buildStderr).toBe("");
expect(buildExit).toBe(0);
await using run = Bun.spawn({
cmd: [bunExe(), join(String(dir), "out", "index.js")],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([run.stdout.text(), run.stderr.text(), run.exited]);
expect(stdout).toContain("PASS");
expect(exitCode).toBe(0);
});
test("default import with alias name", async () => {
using dir = tempDir("20670-alias", {
"index.js": `
import MyBun from 'bun';
console.log(MyBun === undefined ? 'FAIL' : 'PASS');
`,
});
await using build = Bun.spawn({
cmd: [
bunExe(),
"build",
"--target=bun",
"--bytecode",
"--outdir",
String(dir) + "/out",
join(String(dir), "index.js"),
],
env: bunEnv,
stderr: "pipe",
});
const [buildStderr, buildExit] = await Promise.all([build.stderr.text(), build.exited]);
expect(buildStderr).toBe("");
expect(buildExit).toBe(0);
await using run = Bun.spawn({
cmd: [bunExe(), join(String(dir), "out", "index.js")],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([run.stdout.text(), run.stderr.text(), run.exited]);
expect(stdout).toContain("PASS");
expect(exitCode).toBe(0);
});
test("default import combined with named import", async () => {
using dir = tempDir("20670-combined", {
"index.js": `
import Bun, { serve } from 'bun';
console.log(Bun === undefined ? 'FAIL:default' : 'PASS:default');
console.log(typeof serve === 'function' ? 'PASS:named' : 'FAIL:named');
`,
});
await using build = Bun.spawn({
cmd: [
bunExe(),
"build",
"--target=bun",
"--bytecode",
"--outdir",
String(dir) + "/out",
join(String(dir), "index.js"),
],
env: bunEnv,
stderr: "pipe",
});
const [buildStderr, buildExit] = await Promise.all([build.stderr.text(), build.exited]);
expect(buildStderr).toBe("");
expect(buildExit).toBe(0);
await using run = Bun.spawn({
cmd: [bunExe(), join(String(dir), "out", "index.js")],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([run.stdout.text(), run.stderr.text(), run.exited]);
expect(stdout).toContain("PASS:default");
expect(stdout).toContain("PASS:named");
expect(exitCode).toBe(0);
});
test("namespace import", async () => {
using dir = tempDir("20670-star", {
"index.js": `
import * as Bun from 'bun';
console.log(Bun === undefined ? 'FAIL' : 'PASS');
`,
});
await using build = Bun.spawn({
cmd: [
bunExe(),
"build",
"--target=bun",
"--bytecode",
"--outdir",
String(dir) + "/out",
join(String(dir), "index.js"),
],
env: bunEnv,
stderr: "pipe",
});
const [buildStderr, buildExit] = await Promise.all([build.stderr.text(), build.exited]);
expect(buildStderr).toBe("");
expect(buildExit).toBe(0);
await using run = Bun.spawn({
cmd: [bunExe(), join(String(dir), "out", "index.js")],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([run.stdout.text(), run.stderr.text(), run.exited]);
expect(stdout).toContain("PASS");
expect(exitCode).toBe(0);
});
});