Compare commits

...

5 Commits

Author SHA1 Message Date
Claude Bot
6918a1ee81 Fix code splitting chunk generation to match esbuild behavior
When using code splitting with multiple entry points, Bun was incorrectly
reusing entry point chunks as shared chunks, causing duplicate export
statements when re-exporting symbols between entry points.

Root cause: Entry point chunks were created AFTER markFileReachableForCodeSplitting
had already propagated entry bits to all reachable files. This meant that when
entry point b.ts was imported by entry point a.ts, b.ts would have BOTH bits
{0,1} set at chunk creation time. The chunk key was based on these accumulated
bits, causing the entry point chunk to be reused as a shared chunk.

The fix consists of two changes:

1. computeChunks.zig: Create entry point chunks with ONLY their own entry bit
   When creating entry point chunks, use a bitset containing ONLY the current
   entry point's bit, not the file's accumulated entry_bits. This ensures each
   entry point gets its own chunk, and shared code creates separate chunk files.

   IMPORTANT: Use this.allocator() not temp_allocator for the bitset, since it
   gets stored in the chunk which outlives the temp allocator. For large numbers
   of entry points (> 127), AutoBitSet uses dynamic allocation, and temp_allocator
   would cause use-after-free.

2. P.zig: Always create wrapper_ref symbol during parsing
   Previously, wrapper_ref was only created if needsWrapperRef() returned true
   (file has side effects). But the wrapping decision can be made later during
   linking (e.g., when we discover an ESM file is require'd). This caused
   wrap=.esm but wrapper_ref.isEmpty()=true, breaking code splitting.

   Now we match esbuild's behavior: always create the wrapper symbol during
   parsing when bundling, even if we don't think we need it yet. The linker
   decides whether to actually use it based on flags.wrap.

3. computeCrossChunkDependencies.zig: Remove isEmpty() checks
   Since wrapper_ref now always exists when bundling, we can remove the isEmpty()
   checks. The actual wrapping is still controlled by flags.wrap, so we don't
   generate unnecessary wrappers.

Result:
- Before: b.ts became both the entry point AND contained shared code, with
  duplicate export statements
- After: b.ts entry point imports from a shared chunk file, just like esbuild

This fixes the fundamental chunking strategy to properly separate entry points
from shared chunks.
2025-11-06 11:25:18 +00:00
Claude Bot
694ccf4b5e Fix code splitting chunk generation to match esbuild behavior
When using code splitting with multiple entry points, Bun was incorrectly
reusing entry point chunks as shared chunks, causing duplicate export
statements when re-exporting symbols between entry points.

Root cause: Entry point chunks were created AFTER markFileReachableForCodeSplitting
had already propagated entry bits to all reachable files. This meant that when
entry point b.ts was imported by entry point a.ts, b.ts would have BOTH bits
{0,1} set at chunk creation time. The chunk key was based on these accumulated
bits, causing the entry point chunk to be reused as a shared chunk.

The fix consists of two changes:

1. computeChunks.zig: Create entry point chunks with ONLY their own entry bit
   When creating entry point chunks, use a bitset containing ONLY the current
   entry point's bit, not the file's accumulated entry_bits. This ensures each
   entry point gets its own chunk, and shared code creates separate chunk files.

2. computeCrossChunkDependencies.zig: Don't import non-existent wrapper refs
   Some files have wrap != .none but no wrapper_ref symbol because they don't
   need wrapping at parse time (simple exports that can be moved). This happens
   when an ESM file is require'd but has simple exports - wrap gets set to .esm
   by scanImportsAndExports.zig, but no wrapper symbol was created by the parser
   because needsWrapperRef() returned false. When code splitting moves such files
   to shared chunks, we must not try to import the non-existent wrapper.

Result:
- Before: b.ts became both the entry point AND contained shared code, with
  duplicate export statements
- After: b.ts entry point imports from a shared chunk file, just like esbuild

This fixes the fundamental chunking strategy to properly separate entry points
from shared chunks.
2025-11-06 09:53:29 +00:00
pfg
b20a1489e5 flags 2025-11-04 17:15:16 -08:00
pfg
8b2516c6ad , 2025-11-04 17:13:11 -08:00
pfg
8e62e4659b add reproduction 2025-11-04 17:11:44 -08:00
5 changed files with 39 additions and 11 deletions

View File

@@ -6491,7 +6491,11 @@ pub fn NewParser_(
break :brk p.hmr_api_ref;
}
if (p.options.bundle and p.needsWrapperRef(parts.items)) {
// Always create a wrapper symbol, even if we don't think we need it yet.
// The wrapping decision may be made later during linking (e.g., when we
// discover a file is require'd), but the symbol must exist before then.
// This matches esbuild's behavior.
if (p.options.bundle) {
break :brk p.newSymbol(
.other,
std.fmt.allocPrint(

View File

@@ -35,18 +35,21 @@ pub noinline fn computeChunks(
for (entry_source_indices, 0..) |source_index, entry_id_| {
const entry_bit = @as(Chunk.EntryPoint.ID, @truncate(entry_id_));
var entry_bits = &this.graph.files.items(.entry_bits)[source_index];
entry_bits.set(entry_bit);
// For entry point chunks, create a bitset with ONLY this entry point's bit
// Don't use the file's accumulated entry_bits which may have multiple bits
// set from markFileReachableForCodeSplitting
var entry_point_bits = try AutoBitSet.initEmpty(this.allocator(), this.graph.entry_points.len);
entry_point_bits.set(entry_bit);
const has_html_chunk = loaders[source_index] == .html;
const js_chunk_key = brk: {
if (code_splitting) {
break :brk try temp_allocator.dupe(u8, entry_bits.bytes(this.graph.entry_points.len));
break :brk try temp_allocator.dupe(u8, entry_point_bits.bytes(this.graph.entry_points.len));
} else {
// Force HTML chunks to always be generated, even if there's an identical JS file.
break :brk try std.fmt.allocPrint(temp_allocator, "{}", .{JSChunkKeyFormatter{
.has_html = has_html_chunk,
.entry_bits = entry_bits.bytes(this.graph.entry_points.len),
.entry_bits = entry_point_bits.bytes(this.graph.entry_points.len),
}});
}
};
@@ -61,7 +64,7 @@ pub noinline fn computeChunks(
.source_index = source_index,
.is_entry_point = true,
},
.entry_bits = entry_bits.*,
.entry_bits = entry_point_bits,
.content = .html,
.output_source_map = SourceMap.SourceMapPieces.init(this.allocator()),
.is_browser_chunk_from_server_build = could_be_browser_target_from_server_build and ast_targets[source_index] == .browser,
@@ -74,7 +77,7 @@ pub noinline fn computeChunks(
// Create a chunk for the entry point here to ensure that the chunk is
// always generated even if the resulting file is empty
const hash_to_use = if (!this.options.css_chunking)
bun.hash(try temp_allocator.dupe(u8, entry_bits.bytes(this.graph.entry_points.len)))
bun.hash(try temp_allocator.dupe(u8, entry_point_bits.bytes(this.graph.entry_points.len)))
else brk: {
var hasher = std.hash.Wyhash.init(5);
bun.writeAnyToHasher(&hasher, order.len);
@@ -90,7 +93,7 @@ pub noinline fn computeChunks(
.source_index = source_index,
.is_entry_point = true,
},
.entry_bits = entry_bits.*,
.entry_bits = entry_point_bits,
.content = .{
.css = .{
.imports_in_chunk_in_order = order,
@@ -115,7 +118,7 @@ pub noinline fn computeChunks(
.source_index = source_index,
.is_entry_point = true,
},
.entry_bits = entry_bits.*,
.entry_bits = entry_point_bits,
.content = .{
.javascript = .{},
},
@@ -137,7 +140,7 @@ pub noinline fn computeChunks(
const use_content_based_key = css_chunking or has_server_html_imports;
const hash_to_use = if (!use_content_based_key)
bun.hash(try temp_allocator.dupe(u8, entry_bits.bytes(this.graph.entry_points.len)))
bun.hash(try temp_allocator.dupe(u8, entry_point_bits.bytes(this.graph.entry_points.len)))
else brk: {
var hasher = std.hash.Wyhash.init(5);
bun.writeAnyToHasher(&hasher, order.len);
@@ -165,7 +168,7 @@ pub noinline fn computeChunks(
.source_index = source_index,
.is_entry_point = true,
},
.entry_bits = entry_bits.*,
.entry_bits = entry_point_bits,
.content = .{
.css = .{
.imports_in_chunk_in_order = order,
@@ -205,6 +208,7 @@ pub noinline fn computeChunks(
if (this.graph.code_splitting) {
const js_chunk_key = try temp_allocator.dupe(u8, entry_bits.bytes(this.graph.entry_points.len));
var js_chunk_entry = try js_chunks.getOrPut(js_chunk_key);
if (!js_chunk_entry.found_existing) {

View File

@@ -0,0 +1 @@
export { b } from "./b.fixture.ts";

View File

@@ -0,0 +1,3 @@
export function b() {
return "b";
}

View File

@@ -0,0 +1,16 @@
import * as fs from "fs/promises";
test("no double export", async () => {
await fs.rm(import.meta.dir + "/dist", { recursive: true, force: true });
await Bun.build({
entrypoints: [import.meta.dir + "/a.fixture.ts", import.meta.dir + "/b.fixture.ts"],
splitting: true,
outdir: import.meta.dir + "/dist",
});
// @ts-ignore
const { b } = await import("./dist/a.fixture.js");
expect(b()).toBe("b");
// should not throw
});