Compare commits

...

5 Commits

Author SHA1 Message Date
Alistair Smith
bf404e0a53 Merge branch 'main' into ali/gc-orphaned-dce-chunks 2026-03-09 14:59:41 -07:00
Alistair Smith
59adf07d51 Merge branch 'main' into ali/gc-orphaned-dce-chunks 2026-03-09 12:39:33 -07:00
Claude
7041077163 Simplify orphan chunk GC: drop edge pre-collection and array compaction
Replace the DynImportEdge collection, separate live_dynamic_entries
bitset, and entry_points array compaction with a simpler fixpoint
that scans reachable files directly using a promoted bitset. Dead
dynamic-import entries are demoted to .none in-place rather than
removed, so the entry_points slice doesn't need to be re-captured.

Skip demoted entries in markFileReachableForCodeSplitting and
computeChunks to prevent entry_bits contamination.
2026-03-05 23:30:53 +00:00
Alistair Smith
9bbc009776 make it a lil faster 2026-03-05 14:09:28 -08:00
Alistair Smith
0eb37ee471 bundler(splitting): GC orphan chunks when all import() refs are tree-shaken
Dynamic import targets were promoted to entry points during the pre-link
module-graph walk (findReachableFiles), which iterates the flat per-file
ImportRecord list. LinkerGraph.load() then appended them to entry_points
before tree-shaking ran. Since tree-shaking treats entry_points as roots,
these files were unconditionally marked live — even if the part containing
the import() was itself dead (e.g. inside a helper function whose only
callers were DCE'd via --define constant folding).

Result: computeChunks emitted a chunk with zero inbound references.

Fix: defer .dynamic_import entry points from the initial tree-shaking
root set. After marking parts live from user-specified entries, run a
fixpoint that promotes dynamic targets only when a live part's import
record references them (handles transitive import() chains). Sweep
unreferenced entries before markFileReachableForCodeSplitting so
entry_bits are sized correctly.

A separate BitSet tracks "referenced by live import()" distinct from
files_live, since a file can be live via static import yet have no
live dynamic import — it should be inlined, not emitted as a chunk.
2026-03-05 13:56:49 -08:00
3 changed files with 218 additions and 2 deletions

View File

@@ -582,8 +582,11 @@ pub const LinkerContext = struct {
const trace2 = bun.perf.trace("Bundler.markFileLiveForTreeShaking");
defer trace2.end();
// Tree shaking: Each entry point marks all files reachable from itself
// Tree shaking: Each entry point marks all files reachable from itself.
// Dynamic-import entry points are deferred so that orphan chunks whose
// only import() call sites were DCE'd are not emitted.
for (entry_points) |entry_point| {
if (entry_point_kinds[entry_point] == .dynamic_import) continue;
c.markFileLiveForTreeShaking(
entry_point,
side_effects,
@@ -593,6 +596,40 @@ pub const LinkerContext = struct {
css_reprs,
);
}
// Fixpoint: promote dynamic-import entries that a live part references.
// Promoting a target marks its parts live, which may enable further
// promotions (transitive import() chains). On completion, demote any
// remaining .dynamic_import entries to .none so they are not treated as
// entry points (no chunk emitted, isExternalDynamicImport returns false).
if (c.graph.code_splitting) {
var promoted = try bun.bit_set.DynamicBitSetUnmanaged.initEmpty(c.allocator(), c.graph.files.len);
defer promoted.deinit(c.allocator());
var changed = true;
while (changed) {
changed = false;
for (c.graph.reachable_files) |source_index| {
const id = source_index.get();
for (parts[id].slice()) |part| {
if (!part.is_live) continue;
for (part.import_record_indices.slice()) |import_record_index| {
const record = import_records[id].at(import_record_index);
if (record.kind != .dynamic or !record.source_index.isValid()) continue;
const target = record.source_index.get();
if (entry_point_kinds[target] != .dynamic_import or promoted.isSet(target)) continue;
promoted.set(target);
c.markFileLiveForTreeShaking(target, side_effects, parts, import_records, entry_point_kinds, css_reprs);
changed = true;
}
}
}
}
for (entry_points) |ep| {
if (entry_point_kinds[ep] == .dynamic_import and !promoted.isSet(ep))
entry_point_kinds[ep] = .none;
}
}
}
{
@@ -615,6 +652,7 @@ pub const LinkerContext = struct {
// between live parts within the same file. All liveness has to be computed
// first before determining which entry points can reach which files.
for (entry_points, 0..) |entry_point, i| {
if (!entry_point_kinds[entry_point].isEntryPoint()) continue;
c.markFileReachableForCodeSplitting(
entry_point,
i,

View File

@@ -29,11 +29,15 @@ pub noinline fn computeChunks(
const code_splitting = this.graph.code_splitting;
const could_be_browser_target_from_server_build = this.options.target.isServerSide() and this.parse_graph.html_imports.html_source_indices.len > 0;
const has_server_html_imports = this.parse_graph.html_imports.server_source_indices.len > 0;
const entry_point_kinds = this.graph.files.items(.entry_point_kind);
// Create chunks for entry points
for (entry_source_indices, 0..) |source_index, entry_id_| {
const entry_bit = @as(Chunk.EntryPoint.ID, @truncate(entry_id_));
// Skip dead dynamic-import entries that were demoted during tree-shaking.
if (!entry_point_kinds[source_index].isEntryPoint()) continue;
var entry_bits = &this.graph.files.items(.entry_bits)[source_index];
entry_bits.set(entry_bit);

View File

@@ -1,5 +1,6 @@
import { describe } from "bun:test";
import { describe, expect } from "bun:test";
import { bunEnv } from "harness";
import { readdirSync } from "node:fs";
import { itBundled } from "./expectBundled";
const env = {
@@ -323,4 +324,177 @@ describe("bundler", () => {
stdout: "a.js executed\na loaded from entry\nb.js executed\nb.js imports a {}\nb loaded from entry, value: B",
},
});
// Orphan chunk GC: when all `import()` call sites for a dynamic chunk are
// removed by tree-shaking, the chunk itself should not be emitted.
// Previously, chunks were committed during module-graph scan (before
// tree-shaking) and never revisited.
itBundled("splitting/OrphanChunkDCE_NeverCalled", {
files: {
"/entry.ts": /* ts */ `
async function dead() { return await import('./secret.ts') }
console.log('done')
`,
"/secret.ts": `export const SECRET = 'this_should_not_be_in_output'`,
},
entryPoints: ["/entry.ts"],
splitting: true,
outdir: "/out",
format: "esm",
target: "bun",
minifySyntax: true,
treeShaking: true,
run: { stdout: "done" },
onAfterBundle(api) {
const files = readdirSync(api.outdir);
expect(files).toEqual(["entry.js"]);
expect(api.readFile("/out/entry.js")).not.toContain("this_should_not_be_in_output");
},
});
itBundled("splitting/OrphanChunkDCE_DefineFalse", {
files: {
"/entry.ts": /* ts */ `
async function loadSecret() { return await import('./secret.ts') }
if (process.env.GATE === 'on') {
const m = await loadSecret()
console.log(m.SECRET)
}
console.log('done')
`,
"/secret.ts": `export const SECRET = 'this_should_not_be_in_output'`,
},
entryPoints: ["/entry.ts"],
splitting: true,
outdir: "/out",
format: "esm",
target: "bun",
minifySyntax: true,
treeShaking: true,
define: { "process.env.GATE": '"off"' },
run: { stdout: "done" },
onAfterBundle(api) {
const files = readdirSync(api.outdir);
expect(files).toEqual(["entry.js"]);
expect(api.readFile("/out/entry.js")).not.toContain("this_should_not_be_in_output");
},
});
itBundled("splitting/OrphanChunkDCE_DefineTrueKeepsChunk", {
// Sanity: when the gate is ON, the chunk must still be emitted and referenced.
files: {
"/entry.ts": /* ts */ `
async function loadSecret() { return await import('./secret.ts') }
if (process.env.GATE === 'on') {
const m = await loadSecret()
console.log(m.SECRET)
}
console.log('done')
`,
"/secret.ts": `export const SECRET = 'secret_value_present'`,
},
entryPoints: ["/entry.ts"],
splitting: true,
outdir: "/out",
format: "esm",
target: "bun",
minifySyntax: true,
treeShaking: true,
define: { "process.env.GATE": '"on"' },
run: { stdout: "secret_value_present\ndone" },
onAfterBundle(api) {
const files = readdirSync(api.outdir).sort();
const secretChunk = files.find(f => f.startsWith("secret-"));
expect(secretChunk).toBeDefined();
// entry.js should reference the chunk via import()
expect(api.readFile("/out/entry.js")).toContain(`import("./${secretChunk}")`);
},
});
itBundled("splitting/OrphanChunkDCE_TransitiveChainDead", {
// entry -> dead import(a) -> a has import(b). Both a and b are orphans.
files: {
"/entry.ts": /* ts */ `
async function loadA() { return await import('./a.ts') }
console.log('done')
`,
"/a.ts": /* ts */ `
export async function loadB() { return await import('./b.ts') }
export const A = 'chain_a_value'
`,
"/b.ts": `export const B = 'chain_b_value'`,
},
entryPoints: ["/entry.ts"],
splitting: true,
outdir: "/out",
format: "esm",
target: "bun",
minifySyntax: true,
treeShaking: true,
run: { stdout: "done" },
onAfterBundle(api) {
const files = readdirSync(api.outdir);
expect(files).toEqual(["entry.js"]);
const entry = api.readFile("/out/entry.js");
expect(entry).not.toContain("chain_a_value");
expect(entry).not.toContain("chain_b_value");
},
});
itBundled("splitting/OrphanChunkDCE_TransitiveChainLive", {
// entry -> live import(a) -> a.loadB() -> import(b). Both chunks must survive.
files: {
"/entry.ts": /* ts */ `
const a = await import('./a.ts')
const b = await a.loadB()
console.log(a.A, b.B)
`,
"/a.ts": /* ts */ `
export async function loadB() { return await import('./b.ts') }
export const A = 'chain_a_live'
`,
"/b.ts": `export const B = 'chain_b_live'`,
},
entryPoints: ["/entry.ts"],
splitting: true,
outdir: "/out",
format: "esm",
target: "bun",
minifySyntax: true,
treeShaking: true,
run: { stdout: "chain_a_live chain_b_live" },
onAfterBundle(api) {
const files = readdirSync(api.outdir).sort();
expect(files.some(f => f.startsWith("a-"))).toBe(true);
expect(files.some(f => f.startsWith("b-"))).toBe(true);
},
});
itBundled("splitting/OrphanChunkDCE_StaticImportPlusDeadDynamic", {
// Static import keeps secret.ts live (content inlined into entry chunk),
// but the dead dynamic import should NOT also emit it as a separate chunk.
files: {
"/entry.ts": /* ts */ `
import { SECRET } from './secret.ts'
async function dead() { return await import('./secret.ts') }
console.log(SECRET)
`,
"/secret.ts": `export const SECRET = 'inlined_secret'`,
},
entryPoints: ["/entry.ts"],
splitting: true,
outdir: "/out",
format: "esm",
target: "bun",
minifySyntax: true,
treeShaking: true,
run: { stdout: "inlined_secret" },
onAfterBundle(api) {
const files = readdirSync(api.outdir);
expect(files).toEqual(["entry.js"]);
// secret is inlined, not chunked
expect(api.readFile("/out/entry.js")).toContain("inlined_secret");
},
});
});