Compare commits

...

8 Commits

Author SHA1 Message Date
Alistair Smith
7561b46fa3 Merge branch 'main' into claude/fix-splitting-duplicate-exports 2026-01-14 11:31:49 -08:00
Alistair Smith
8b3336cafc Merge branch 'main' into claude/fix-splitting-duplicate-exports 2026-01-09 08:47:13 -08:00
Claude Bot
1ac254f72c fix: use correct source index for imports_to_bind lookup
Use the export's source_index instead of the entry point's source_index
when looking up imports_to_bind, matching the behavior in the walk
function. Also add null check for symbol lookup to avoid panics.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-07 15:01:26 +00:00
Claude Bot
66dfc9172f test: add aliased exports test for splitting with entry point overlap
Tests the case where the same value is exported under multiple names
(e.g., `export const foo = "foo"; export { foo as bar };`) and the
module is both an entry point and imported by other entry points.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-07 14:49:29 +00:00
Claude Bot
97ad7a789a test: add edge case tests for splitting with entry point overlap
Add tests for additional scenarios where entry points interact with
code splitting:

- Named re-exports: `export { foo } from './shared'` where shared is
  also an entry point
- Export star: `export * from './shared'` where shared is also an
  entry point
- CommonJS imports: ESM importing from CJS module that is also an
  entry point (tests namespace_alias resolution)
- Transitive imports: A imports B, B imports C, all three are entry
  points (tests chained dependencies)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-07 14:42:15 +00:00
Claude Bot
b66976941f fix: add namespace_alias resolution and improve test assertion
- Add namespace_alias resolution for CommonJS re-exports to match the
  walk function's behavior when resolving refs
- Change test assertion from > 1 to !== 1 to catch both duplicate and
  missing exports

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-07 14:25:13 +00:00
Claude Bot
22570815df fix(bundler): prevent duplicate exports when entry point is also imported with splitting
When a module is both an entry point AND imported by another entry point
with splitting enabled, the bundler was generating duplicate export
statements. This happened because:

1. generateEntryPointTailJS generates exports for entry point's exports
2. cross-chunk suffix also generates exports for symbols other chunks need

The fix tracks which refs will be exported by the entry point tail and
skips generating duplicate cross-chunk exports for those refs, while
still populating exports_to_other_chunks so cross-chunk imports work.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-07 13:54:44 +00:00
Claude Bot
234a92f85d test: add failing test for duplicate exports with splitting (#22884)
When a shared module is listed as both an entry point AND imported by
another entry point with splitting enabled, the bundler outputs
duplicate export statements.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-07 13:21:24 +00:00
2 changed files with 275 additions and 7 deletions

View File

@@ -296,6 +296,10 @@ fn computeCrossChunkDependenciesWithChunkMetas(c: *LinkerContext, chunks: []Chun
var stable_ref_list = std.array_list.Managed(StableRef).init(c.allocator());
defer stable_ref_list.deinit();
const sorted_and_filtered_export_aliases = c.graph.meta.items(.sorted_and_filtered_export_aliases);
const resolved_exports = c.graph.meta.items(.resolved_exports);
const imports_to_bind = c.graph.meta.items(.imports_to_bind);
for (chunks, chunk_metas) |*chunk, *chunk_meta| {
if (chunk.content != .javascript) continue;
@@ -307,16 +311,68 @@ fn computeCrossChunkDependenciesWithChunkMetas(c: *LinkerContext, chunks: []Chun
chunk_meta.exports,
&stable_ref_list,
);
var clause_items = BabyList(js_ast.ClauseItem).initCapacity(c.allocator(), stable_ref_list.items.len) catch unreachable;
clause_items.len = @as(u32, @truncate(stable_ref_list.items.len));
// For entry point chunks, we need to track which exports will already be
// generated by generateEntryPointTailJS to avoid duplicate export statements.
// We still need to populate exports_to_other_chunks for these refs so that
// cross-chunk imports can find the correct alias.
// See: https://github.com/oven-sh/bun/issues/22884
var entry_point_export_refs: ?std.AutoArrayHashMapUnmanaged(Ref, []const u8) = null;
defer if (entry_point_export_refs) |*m| m.deinit(c.allocator());
if (chunk.entry_point.is_entry_point) {
const source_index = chunk.entry_point.source_index;
const entry_export_aliases = sorted_and_filtered_export_aliases[source_index];
const entry_resolved_exports = resolved_exports[source_index];
if (entry_export_aliases.len > 0) {
entry_point_export_refs = .{};
entry_point_export_refs.?.ensureTotalCapacity(c.allocator(), @intCast(entry_export_aliases.len)) catch unreachable;
for (entry_export_aliases) |alias| {
if (entry_resolved_exports.get(alias)) |resolved_export| {
var ref = resolved_export.data.import_ref;
// Follow the import binding if necessary (use the export's source index)
if (imports_to_bind[resolved_export.data.source_index.get()].get(ref)) |import_data| {
ref = import_data.data.import_ref;
}
// If this is an ES6 import from a CommonJS file, it will become a
// property access off the namespace symbol instead of a bare
// identifier. In that case we want to pull in the namespace symbol
// instead. The namespace symbol stores the result of "require()".
if (c.graph.symbols.getConst(ref)) |symbol| {
if (symbol.namespace_alias) |namespace_alias| {
ref = namespace_alias.namespace_ref;
}
}
// Store the alias so we can use it for exports_to_other_chunks
entry_point_export_refs.?.putAssumeCapacity(ref, alias);
}
}
}
}
var clause_items = std.ArrayList(js_ast.ClauseItem).initCapacity(c.allocator(), stable_ref_list.items.len) catch unreachable;
repr.exports_to_other_chunks.ensureUnusedCapacity(c.allocator(), stable_ref_list.items.len) catch unreachable;
r.clearRetainingCapacity();
for (stable_ref_list.items, clause_items.slice()) |stable_ref, *clause_item| {
for (stable_ref_list.items) |stable_ref| {
const ref = stable_ref.ref;
// Check if this ref is already exported by the entry point tail
if (entry_point_export_refs) |export_refs| {
if (export_refs.get(ref)) |entry_alias| {
// Still need to record the alias for cross-chunk imports,
// but use the entry point's alias (the original export name)
repr.exports_to_other_chunks.putAssumeCapacity(ref, entry_alias);
// Skip adding to clause_items since entry point tail will export it
continue;
}
}
const alias = if (c.options.minify_identifiers) try r.nextMinifiedName(c.allocator()) else r.nextRenamedName(c.graph.symbols.get(ref).?.original_name);
clause_item.* = .{
clause_items.appendAssumeCapacity(.{
.name = .{
.ref = ref,
.loc = Logger.Loc.Empty,
@@ -324,7 +380,7 @@ fn computeCrossChunkDependenciesWithChunkMetas(c: *LinkerContext, chunks: []Chun
.alias = alias,
.alias_loc = Logger.Loc.Empty,
.original_name = "",
};
});
repr.exports_to_other_chunks.putAssumeCapacity(
ref,
@@ -332,11 +388,11 @@ fn computeCrossChunkDependenciesWithChunkMetas(c: *LinkerContext, chunks: []Chun
);
}
if (clause_items.len > 0) {
if (clause_items.items.len > 0) {
var stmts = BabyList(js_ast.Stmt).initCapacity(c.allocator(), 1) catch unreachable;
const export_clause = c.allocator().create(js_ast.S.ExportClause) catch unreachable;
export_clause.* = .{
.items = clause_items.slice(),
.items = clause_items.items,
.is_single_line = true,
};
stmts.appendAssumeCapacity(.{

View File

@@ -323,4 +323,216 @@ describe("bundler", () => {
stdout: "a.js executed\na loaded from entry\nb.js executed\nb.js imports a {}\nb loaded from entry, value: B",
},
});
// https://github.com/oven-sh/bun/issues/22884
// When a shared module is listed as both an entry point AND imported by another
// entry point with splitting enabled, the output should not contain duplicate exports.
itBundled("splitting/NoDuplicateExportsWhenSharedModuleIsEntryPoint", {
files: {
"/shared.ts": `export const shared = "shared";`,
"/entry-a.ts": `
import { shared } from "./shared";
console.log(shared);
`,
},
entryPoints: ["/entry-a.ts", "/shared.ts"],
splitting: true,
outdir: "/out",
format: "esm",
onAfterBundle(api) {
const sharedContent = api.readFile("/out/shared.js");
// Count how many times "export {" or "export{" appears in the file
const exportMatches = sharedContent.match(/export\s*\{/g) || [];
if (exportMatches.length !== 1) {
throw new Error(
`shared.js contains ${exportMatches.length} export statements, expected exactly 1. Content:\n${sharedContent}`,
);
}
},
run: [
{ file: "/out/entry-a.js", stdout: "shared" },
{ file: "/out/shared.js", stdout: "" },
],
});
// Test: Named re-export with entry point overlap
// When entry-a re-exports from shared, and shared is also an entry point
itBundled("splitting/NamedReExportWithEntryPointOverlap", {
files: {
"/shared.ts": `export const foo = "foo"; export const bar = "bar";`,
"/entry-a.ts": `
export { foo } from "./shared";
console.log("entry-a");
`,
},
entryPoints: ["/entry-a.ts", "/shared.ts"],
splitting: true,
outdir: "/out",
format: "esm",
onAfterBundle(api) {
const sharedContent = api.readFile("/out/shared.js");
const exportMatches = sharedContent.match(/export\s*\{/g) || [];
if (exportMatches.length !== 1) {
throw new Error(
`shared.js contains ${exportMatches.length} export statements, expected exactly 1. Content:\n${sharedContent}`,
);
}
},
run: [
{ file: "/out/entry-a.js", stdout: "entry-a" },
{ file: "/out/shared.js", stdout: "" },
],
});
// Test: Export star with entry point overlap
// When entry-a does export * from shared, and shared is also an entry point
itBundled("splitting/ExportStarWithEntryPointOverlap", {
files: {
"/shared.ts": `export const foo = "foo"; export const bar = "bar";`,
"/entry-a.ts": `
export * from "./shared";
console.log("entry-a");
`,
},
entryPoints: ["/entry-a.ts", "/shared.ts"],
splitting: true,
outdir: "/out",
format: "esm",
onAfterBundle(api) {
const sharedContent = api.readFile("/out/shared.js");
const exportMatches = sharedContent.match(/export\s*\{/g) || [];
if (exportMatches.length !== 1) {
throw new Error(
`shared.js contains ${exportMatches.length} export statements, expected exactly 1. Content:\n${sharedContent}`,
);
}
},
run: [
{ file: "/out/entry-a.js", stdout: "entry-a" },
{ file: "/out/shared.js", stdout: "" },
],
});
// Test: CommonJS import with entry point overlap
// When entry-a imports from a CJS module that is also an entry point
itBundled("splitting/CommonJSImportWithEntryPointOverlap", {
files: {
"/shared.js": `module.exports = { foo: "foo", bar: "bar" };`,
"/entry-a.ts": `
import { foo } from "./shared.js";
console.log(foo);
`,
},
entryPoints: ["/entry-a.ts", "/shared.js"],
splitting: true,
outdir: "/out",
format: "esm",
run: [{ file: "/out/entry-a.js", stdout: "foo" }],
});
// Test: ESM re-exporting namespace from CommonJS with entry point overlap (namespace_alias case)
// This specifically tests the namespace_alias resolution path where an ESM entry point
// re-exports from a CJS module that is also an entry point.
// Uses namespace import to avoid unrelated CJS re-export bugs.
itBundled("splitting/ESMReExportFromCJSWithEntryPointOverlap", {
files: {
"/cjs-module.js": `
module.exports = { value: "from-cjs" };
`,
"/esm-reexport.ts": `
import * as cjs from "./cjs-module.js";
export const value = cjs.value;
`,
"/consumer.ts": `
import { value } from "./esm-reexport";
console.log(value);
`,
},
entryPoints: ["/consumer.ts", "/esm-reexport.ts", "/cjs-module.js"],
splitting: true,
outdir: "/out",
format: "esm",
run: [{ file: "/out/consumer.js", stdout: "from-cjs" }],
});
// Test: Three entry points with transitive imports
// A imports B, B imports C, all three are entry points
itBundled("splitting/ThreeEntryPointsWithTransitiveImports", {
files: {
"/a.ts": `
import { b } from "./b";
console.log("a", b);
`,
"/b.ts": `
import { c } from "./c";
export const b = "b+" + c;
`,
"/c.ts": `export const c = "c";`,
},
entryPoints: ["/a.ts", "/b.ts", "/c.ts"],
splitting: true,
outdir: "/out",
format: "esm",
onAfterBundle(api) {
// Check that c.js has exactly one export
const cContent = api.readFile("/out/c.js");
const cExportMatches = cContent.match(/export\s*\{/g) || [];
if (cExportMatches.length !== 1) {
throw new Error(
`c.js contains ${cExportMatches.length} export statements, expected exactly 1. Content:\n${cContent}`,
);
}
// Check that b.js has exactly one export
const bContent = api.readFile("/out/b.js");
const bExportMatches = bContent.match(/export\s*\{/g) || [];
if (bExportMatches.length !== 1) {
throw new Error(
`b.js contains ${bExportMatches.length} export statements, expected exactly 1. Content:\n${bContent}`,
);
}
},
run: [
{ file: "/out/a.js", stdout: "a b+c" },
{ file: "/out/b.js", stdout: "" },
{ file: "/out/c.js", stdout: "" },
],
});
// Test: Aliased exports with entry point overlap
// When shared.ts exports the same value under multiple names, and is both
// an entry point and imported by other entry points
itBundled("splitting/AliasedExportsWithEntryPointOverlap", {
files: {
"/shared.ts": `
export const foo = "foo";
export { foo as bar };
`,
"/entry-a.ts": `
import { foo } from "./shared";
console.log("a:", foo);
`,
"/entry-b.ts": `
import { bar } from "./shared";
console.log("b:", bar);
`,
},
entryPoints: ["/entry-a.ts", "/entry-b.ts", "/shared.ts"],
splitting: true,
outdir: "/out",
format: "esm",
onAfterBundle(api) {
const sharedContent = api.readFile("/out/shared.js");
const exportMatches = sharedContent.match(/export\s*\{/g) || [];
if (exportMatches.length !== 1) {
throw new Error(
`shared.js contains ${exportMatches.length} export statements, expected exactly 1. Content:\n${sharedContent}`,
);
}
},
run: [
{ file: "/out/entry-a.js", stdout: "a: foo" },
{ file: "/out/entry-b.js", stdout: "b: foo" },
{ file: "/out/shared.js", stdout: "" },
],
});
});