Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
1cd682b58c fix(bundler): tree-shake per-entrypoint when multiple entrypoints share a module
When bundling multiple entrypoints that share a common module, the
bundler was including the union of all used exports in every output
file instead of only the exports actually used by each entrypoint.

The root cause was that the tree-shaking phase tracked part liveness
with a single global `is_live` boolean per part. When entry point A
used `foo` and entry point B used `bar` from a shared module, both
parts got `is_live = true`, causing both to appear in both outputs.

The fix adds per-entry-point part liveness tracking via `part_entry_bits`
(an AutoBitSet per part per source file) that records which entry points
make each part live. During chunk generation, parts are now only included
if they are live for at least one of the chunk's entry points.

Closes #11476

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-19 09:40:12 +00:00
4 changed files with 267 additions and 8 deletions

View File

@@ -578,12 +578,31 @@ pub const LinkerContext = struct {
const entry_points = c.graph.entry_points.items(.source_index);
const distances = c.graph.files.items(.distance_from_entry_point);
// Initialize per-entry-point part liveness tracking. This is used
// to enable per-entry-point tree-shaking: when multiple entry points
// share a module, each chunk only includes parts actually used by its
// entry point(s), rather than the union of all parts used by any entry point.
if (entry_points.len > 1) {
c.graph.part_entry_bits = try c.allocator().alloc([]AutoBitSet, parts.len);
for (parts, c.graph.part_entry_bits) |file_parts, *peb| {
const num_parts = file_parts.len;
if (num_parts > 0) {
peb.* = try c.allocator().alloc(AutoBitSet, num_parts);
for (peb.*) |*bits| {
bits.* = try AutoBitSet.initEmpty(c.allocator(), entry_points.len);
}
} else {
peb.* = &[_]AutoBitSet{};
}
}
}
{
const trace2 = bun.perf.trace("Bundler.markFileLiveForTreeShaking");
defer trace2.end();
// Tree shaking: Each entry point marks all files reachable from itself
for (entry_points) |entry_point| {
for (entry_points, 0..) |entry_point, entry_id| {
c.markFileLiveForTreeShaking(
entry_point,
side_effects,
@@ -591,6 +610,7 @@ pub const LinkerContext = struct {
import_records,
entry_point_kinds,
css_reprs,
@intCast(entry_id),
);
}
}
@@ -1655,6 +1675,7 @@ pub const LinkerContext = struct {
import_records: []bun.BabyList(bun.ImportRecord),
entry_point_kinds: []EntryPoint.Kind,
css_reprs: []?*bun.css.BundlerStyleSheet,
entry_point_id: u32,
) void {
if (comptime bun.Environment.allow_assert) {
debugTreeShake("markFileLiveForTreeShaking({d}, {s} {s}) = {s}", .{
@@ -1669,7 +1690,15 @@ pub const LinkerContext = struct {
debugTreeShake("end()", .{});
};
if (c.graph.files_live.isSet(source_index)) return;
// Unlike the global files_live check, we must not early-return here when
// files_live is already set if we're tracking per-entry-point part liveness.
// We still need to traverse into the file to propagate entry point bits to
// parts, even if the file was already marked live by a previous entry point.
const already_live = c.graph.files_live.isSet(source_index);
const has_part_entry_bits = c.graph.part_entry_bits.len > 0;
if (already_live and !has_part_entry_bits) return;
c.graph.files_live.set(source_index);
if (source_index >= c.graph.ast.len) {
@@ -1688,6 +1717,7 @@ pub const LinkerContext = struct {
import_records,
entry_point_kinds,
css_reprs,
entry_point_id,
);
}
}
@@ -1730,6 +1760,7 @@ pub const LinkerContext = struct {
import_records,
entry_point_kinds,
css_reprs,
entry_point_id,
);
} else if (record.flags.is_external_without_side_effects) {
// This can be removed if it's unused
@@ -1757,6 +1788,7 @@ pub const LinkerContext = struct {
import_records,
entry_point_kinds,
css_reprs,
entry_point_id,
);
}
}
@@ -1771,15 +1803,33 @@ pub const LinkerContext = struct {
import_records: []bun.BabyList(bun.ImportRecord),
entry_point_kinds: []EntryPoint.Kind,
css_reprs: []?*bun.css.BundlerStyleSheet,
entry_point_id: u32,
) void {
const part: *Part = &parts[source_index].slice()[part_index];
// only once
if (part.is_live) {
return;
}
const was_live = part.is_live;
part.is_live = true;
// Track per-entry-point part liveness if we have multiple entry points.
// We can't early-return based on is_live alone when tracking per-entry-point
// bits, because we need to propagate the new entry point's bit through
// dependencies even if the part was already marked live by another entry point.
const has_part_entry_bits = c.graph.part_entry_bits.len > 0;
if (has_part_entry_bits) {
const peb = &c.graph.part_entry_bits[source_index][part_index];
if (peb.isSet(entry_point_id)) {
// This part was already marked live for this entry point,
// so we don't need to traverse its dependencies again.
return;
}
peb.set(entry_point_id);
} else {
// Single entry point or no per-entry tracking: use the old behavior.
if (was_live) {
return;
}
}
if (comptime bun.Environment.isDebug) {
debugTreeShake("markPartLiveForTreeShaking({d}): {s}:{d} = {d}, {s}", .{
source_index,
@@ -1802,6 +1852,7 @@ pub const LinkerContext = struct {
import_records,
entry_point_kinds,
css_reprs,
entry_point_id,
);
if (Environment.enable_logs and part.dependencies.slice().len == 0) {
@@ -1825,6 +1876,7 @@ pub const LinkerContext = struct {
import_records,
entry_point_kinds,
css_reprs,
entry_point_id,
);
}
}

View File

@@ -11,6 +11,13 @@ allocator: std.mem.Allocator,
code_splitting: bool = false,
/// Per-entry-point part liveness tracking for tree-shaking with multiple
/// entry points. Indexed as [source_index][part_index], each AutoBitSet
/// tracks which entry points make the part live. This enables per-entry-point
/// tree-shaking so that shared modules only include exports actually used by
/// each specific entry point's chunk.
part_entry_bits: [][]AutoBitSet = &[_][]AutoBitSet{},
// This is an alias from Graph
// it is not a clone!
ast: MultiArrayList(JSAst) = .{},

View File

@@ -56,6 +56,7 @@ pub fn findImportedPartsInJSOrder(
c: *LinkerContext,
entry_point: Chunk.EntryPoint,
chunk_index: u32,
part_entry_bits: [][]AutoBitSet,
fn appendOrExtendRange(
ranges: *std.array_list.Managed(PartRange),
@@ -77,6 +78,17 @@ pub fn findImportedPartsInJSOrder(
}) catch unreachable;
}
/// Check if a part is live for this chunk's entry points. When we have
/// per-entry-point part liveness tracking, we check if the part is live
/// for any of the entry points in this chunk. Otherwise, fall back to
/// the global is_live flag.
fn isPartLiveForChunk(v: *const @This(), source_index: Index.Int, part_index: u32, part: Part) bool {
if (!part.is_live) return false;
if (v.part_entry_bits.len == 0) return true;
// Check if this part is live for any entry point that belongs to this chunk
return v.entry_bits.hasIntersection(&v.part_entry_bits[source_index][part_index]);
}
// Traverse the graph using this stable order and linearize the files with
// dependencies before dependents
pub fn visit(
@@ -100,7 +112,7 @@ pub fn findImportedPartsInJSOrder(
const can_be_split = v.flags[source_index].wrap == .none;
const parts = v.parts[source_index].slice();
if (can_be_split and is_file_in_chunk and parts[js_ast.namespace_export_part_index].is_live) {
if (can_be_split and is_file_in_chunk and v.isPartLiveForChunk(source_index, js_ast.namespace_export_part_index, parts[js_ast.namespace_export_part_index])) {
appendOrExtendRange(&v.part_ranges, source_index, js_ast.namespace_export_part_index);
}
@@ -108,7 +120,7 @@ pub fn findImportedPartsInJSOrder(
for (parts, 0..) |part, part_index_| {
const part_index = @as(u32, @truncate(part_index_));
const is_part_in_this_chunk = is_file_in_chunk and part.is_live;
const is_part_in_this_chunk = is_file_in_chunk and v.isPartLiveForChunk(source_index, part_index, part);
for (part.import_record_indices.slice()) |record_id| {
const record: *const ImportRecord = &records[record_id];
if (record.source_index.isValid() and (record.kind == .stmt or is_part_in_this_chunk)) {
@@ -175,6 +187,7 @@ pub fn findImportedPartsInJSOrder(
.c = this,
.entry_point = chunk.entry_point,
.chunk_index = chunk_index,
.part_entry_bits = this.graph.part_entry_bits,
};
defer {
part_ranges_shared.* = visitor.part_ranges;

View File

@@ -0,0 +1,187 @@
import { expect, test } from "bun:test";
import { tempDir } from "harness";
// https://github.com/oven-sh/bun/issues/11476
// Tree-shaking with multiple entrypoints doesn't work properly.
// When bundling multiple entrypoints that share a common module, the bundler
// includes the union of all used exports from the shared module in every output
// file, instead of only the exports actually used by each specific entrypoint.
test("tree-shaking with multiple entrypoints only includes used exports per chunk", async () => {
using dir = tempDir("issue-11476", {
"package.ts": `
export function entrypoint1Function() { console.log("entrypoint1Function called"); }
export function entrypoint2Function() { console.log("entrypoint2Function called"); }
export function unusedByBoth() { console.log("unusedByBoth called"); }
`,
"entrypoint1.ts": `
import { entrypoint1Function } from "./package";
entrypoint1Function();
`,
"entrypoint2.ts": `
import { entrypoint2Function } from "./package";
entrypoint2Function();
`,
});
const result = await Bun.build({
entrypoints: [`${dir}/entrypoint1.ts`, `${dir}/entrypoint2.ts`],
outdir: `${dir}/dist`,
});
expect(result.success).toBe(true);
expect(result.outputs.length).toBe(2);
const output1 = await result.outputs[0].text();
const output2 = await result.outputs[1].text();
// entrypoint1 should only contain entrypoint1Function
expect(output1).toContain("entrypoint1Function");
expect(output1).not.toContain("entrypoint2Function");
expect(output1).not.toContain("unusedByBoth");
// entrypoint2 should only contain entrypoint2Function
expect(output2).toContain("entrypoint2Function");
expect(output2).not.toContain("entrypoint1Function");
expect(output2).not.toContain("unusedByBoth");
});
test("tree-shaking with multiple entrypoints and overlapping imports", async () => {
using dir = tempDir("issue-11476-overlap", {
"shared.ts": `
export function sharedFunc() { console.log("shared"); }
export function onlyInOne() { console.log("onlyInOne"); }
export function onlyInTwo() { console.log("onlyInTwo"); }
export function inBoth() { console.log("inBoth"); }
export function unused() { console.log("unused"); }
`,
"ep1.ts": `
import { sharedFunc, onlyInOne, inBoth } from "./shared";
sharedFunc();
onlyInOne();
inBoth();
`,
"ep2.ts": `
import { sharedFunc, onlyInTwo, inBoth } from "./shared";
sharedFunc();
onlyInTwo();
inBoth();
`,
});
const result = await Bun.build({
entrypoints: [`${dir}/ep1.ts`, `${dir}/ep2.ts`],
outdir: `${dir}/dist`,
});
expect(result.success).toBe(true);
expect(result.outputs.length).toBe(2);
const output1 = await result.outputs[0].text();
const output2 = await result.outputs[1].text();
// ep1 should have sharedFunc, onlyInOne, and inBoth but NOT onlyInTwo or unused
expect(output1).toContain("sharedFunc");
expect(output1).toContain("onlyInOne");
expect(output1).toContain("inBoth");
expect(output1).not.toContain("onlyInTwo");
expect(output1).not.toContain("unused");
// ep2 should have sharedFunc, onlyInTwo, and inBoth but NOT onlyInOne or unused
expect(output2).toContain("sharedFunc");
expect(output2).toContain("onlyInTwo");
expect(output2).toContain("inBoth");
expect(output2).not.toContain("onlyInOne");
expect(output2).not.toContain("unused");
});
test("tree-shaking with 3 entrypoints sharing a module", async () => {
using dir = tempDir("issue-11476-three", {
"shared.ts": `
export function funcA() { console.log("A"); }
export function funcB() { console.log("B"); }
export function funcC() { console.log("C"); }
export function unused() { console.log("unused"); }
`,
"ep1.ts": `
import { funcA } from "./shared";
funcA();
`,
"ep2.ts": `
import { funcB } from "./shared";
funcB();
`,
"ep3.ts": `
import { funcA, funcC } from "./shared";
funcA();
funcC();
`,
});
const result = await Bun.build({
entrypoints: [`${dir}/ep1.ts`, `${dir}/ep2.ts`, `${dir}/ep3.ts`],
outdir: `${dir}/dist`,
});
expect(result.success).toBe(true);
expect(result.outputs.length).toBe(3);
const output1 = await result.outputs[0].text();
const output2 = await result.outputs[1].text();
const output3 = await result.outputs[2].text();
// ep1 should only have funcA
expect(output1).toContain("funcA");
expect(output1).not.toContain("funcB");
expect(output1).not.toContain("funcC");
expect(output1).not.toContain("unused");
// ep2 should only have funcB
expect(output2).toContain("funcB");
expect(output2).not.toContain("funcA");
expect(output2).not.toContain("funcC");
expect(output2).not.toContain("unused");
// ep3 should have funcA and funcC
expect(output3).toContain("funcA");
expect(output3).toContain("funcC");
expect(output3).not.toContain("funcB");
expect(output3).not.toContain("unused");
});
test("tree-shaking with multiple entrypoints and --minify", async () => {
using dir = tempDir("issue-11476-minify", {
"package.ts": `
export function entrypoint1Function() { console.log("entrypoint1Function called"); }
export function entrypoint2Function() { console.log("entrypoint2Function called"); }
`,
"entrypoint1.ts": `
import { entrypoint1Function } from "./package";
entrypoint1Function();
`,
"entrypoint2.ts": `
import { entrypoint2Function } from "./package";
entrypoint2Function();
`,
});
const result = await Bun.build({
entrypoints: [`${dir}/entrypoint1.ts`, `${dir}/entrypoint2.ts`],
outdir: `${dir}/dist`,
minify: true,
});
expect(result.success).toBe(true);
expect(result.outputs.length).toBe(2);
const output1 = await result.outputs[0].text();
const output2 = await result.outputs[1].text();
// Even when minified, entrypoint1 should not contain entrypoint2's string
expect(output1).toContain("entrypoint1Function called");
expect(output1).not.toContain("entrypoint2Function called");
// And vice versa
expect(output2).toContain("entrypoint2Function called");
expect(output2).not.toContain("entrypoint1Function called");
});