Compare commits

...

7 Commits

Author SHA1 Message Date
Alistair Smith
9ac93936a2 refactor: rename is_unused to is_barrel_deferred for barrel optimization clarity 2026-03-11 13:20:45 -07:00
Claude Bot
8fc01cb7b1 Merge remote-tracking branch 'origin/main' into ali/gc-orphaned-dce-chunks 2026-03-10 21:29:59 +00:00
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
11 changed files with 235 additions and 36 deletions

View File

@@ -309,11 +309,11 @@ fn deduplicatedImport(
ir.flags.is_unused = true;
const stmt = ctx.stmts.items[gop.value_ptr.stmt_index].data.s_import;
// The surviving record may have been marked is_unused by barrel
// The surviving record may have been marked is_barrel_deferred by barrel
// optimization (when the first export-from statement's exports
// were all deferred). Since we are merging new items into it,
// clear is_unused so the import is actually emitted.
p.import_records.items[stmt.import_record_index].flags.is_unused = false;
// clear is_barrel_deferred so the import is actually emitted.
p.import_records.items[stmt.import_record_index].flags.is_barrel_deferred = false;
if (items.len > 0) {
if (stmt.items.len == 0) {

View File

@@ -582,16 +582,20 @@ 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.
// Skip dynamic-import entries — they become live only if a live part
// transitively depends on them (via part.dependencies).
for (entry_points) |entry_point| {
c.markFileLiveForTreeShaking(
entry_point,
side_effects,
parts,
import_records,
entry_point_kinds,
css_reprs,
);
if (entry_point_kinds[entry_point] == .dynamic_import) continue;
c.markFileLiveForTreeShaking(entry_point, side_effects, parts, import_records, entry_point_kinds, css_reprs);
}
// Demote dynamic-import entries that were never reached by
// tree-shaking (their import() call was in dead code).
if (c.graph.code_splitting) {
for (entry_points) |ep| {
if (entry_point_kinds[ep] == .dynamic_import and !c.graph.live_dynamic_import_targets.isSet(ep))
entry_point_kinds[ep] = .none;
}
}
}
@@ -615,6 +619,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,
@@ -1188,7 +1193,7 @@ pub const LinkerContext = struct {
) !bool {
const record = ast.import_records.at(import_record_index);
// Barrel optimization: deferred import records should be dropped
if (record.flags.is_unused) {
if (record.flags.is_unused or record.flags.is_barrel_deferred) {
return true;
}
// Is this an external import?
@@ -1850,6 +1855,16 @@ pub const LinkerContext = struct {
css_reprs,
);
}
// Follow dynamic imports — if this part is live and contains an
// import() call, the target file must be live too.
for (part.import_record_indices.slice()) |iri| {
const rec = import_records[source_index].at(iri);
if (rec.kind == .dynamic and rec.source_index.isValid()) {
c.graph.live_dynamic_import_targets.set(rec.source_index.get());
c.markFileLiveForTreeShaking(rec.source_index.get(), side_effects, parts, import_records, entry_point_kinds, css_reprs);
}
}
}
pub fn matchImportWithExport(
@@ -2346,7 +2361,7 @@ pub const LinkerContext = struct {
}
// Barrel optimization: deferred import records point to empty ASTs
if (record.flags.is_unused) {
if (record.flags.is_unused or record.flags.is_barrel_deferred) {
return .{
.value = .{},
.status = .external,

View File

@@ -4,6 +4,7 @@ const debug = Output.scoped(.LinkerGraph, .visible);
files: File.List = .{},
files_live: BitSet = undefined,
live_dynamic_import_targets: BitSet = undefined,
entry_points: EntryPoint.List = .{},
symbols: js_ast.Symbol.Map = .{},
@@ -39,6 +40,7 @@ pub fn init(allocator: std.mem.Allocator, file_count: usize) !LinkerGraph {
return LinkerGraph{
.allocator = allocator,
.files_live = try BitSet.initEmpty(allocator, file_count),
.live_dynamic_import_targets = try BitSet.initEmpty(allocator, file_count),
};
}
@@ -235,6 +237,10 @@ pub fn load(
this.allocator,
sources.len,
);
this.live_dynamic_import_targets = try BitSet.initEmpty(
this.allocator,
sources.len,
);
this.files.len = sources.len;
var files = this.files.slice();

View File

@@ -30,7 +30,7 @@ fn resolveBarrelExport(alias: []const u8, named_exports: JSAst.NamedExports, nam
}
/// Analyze a parsed file to determine if it's a barrel and mark unneeded
/// import records as is_unused so they won't be resolved. Runs BEFORE resolution.
/// import records as is_barrel_deferred so they won't be resolved. Runs BEFORE resolution.
///
/// A file qualifies as a barrel if:
/// 1. It has `sideEffects: false` or is in `optimize_imports`, AND
@@ -149,14 +149,14 @@ fn applyBarrelOptimizationImpl(this: *BundleV2, parse_result: *ParseTask.Result)
}
}
// Mark unneeded named re-export records as is_unused.
// Mark unneeded named re-export records as barrel-deferred.
var has_deferrals = false;
export_iter = named_exports.iterator();
while (export_iter.next()) |entry| {
if (named_imports.get(entry.value_ptr.ref)) |imp| {
if (!needed_records.contains(imp.import_record_index)) {
if (imp.import_record_index < ast.import_records.len) {
ast.import_records.slice()[imp.import_record_index].flags.is_unused = true;
ast.import_records.slice()[imp.import_record_index].flags.is_barrel_deferred = true;
has_deferrals = true;
}
}
@@ -190,12 +190,12 @@ fn applyBarrelOptimizationImpl(this: *BundleV2, parse_result: *ParseTask.Result)
}
}
/// Clear is_unused on a deferred barrel record. Returns true if the record was un-deferred.
/// Clear is_barrel_deferred on a deferred barrel record. Returns true if the record was un-deferred.
fn unDeferRecord(import_records: *ImportRecord.List, record_idx: u32) bool {
if (record_idx >= import_records.len) return false;
const rec = &import_records.slice()[record_idx];
if (rec.flags.is_internal or !rec.flags.is_unused) return false;
rec.flags.is_unused = false;
if (rec.flags.is_internal or !rec.flags.is_barrel_deferred) return false;
rec.flags.is_barrel_deferred = false;
return true;
}
@@ -436,7 +436,7 @@ pub fn scheduleBarrelDeferredImports(this: *BundleV2, result: *ParseTask.Result.
if (item.is_star) {
for (barrel_ir.slice(), 0..) |rec, idx| {
if (rec.flags.is_unused and !rec.flags.is_internal) {
if (rec.flags.is_barrel_deferred and !rec.flags.is_internal) {
if (unDeferRecord(barrel_ir, @intCast(idx))) {
try barrels_to_resolve.put(barrels_to_resolve_alloc, barrel_idx, {});
}

View File

@@ -3433,16 +3433,11 @@ pub const BundleV2 = struct {
import_record.source_index = Index.runtime;
}
// For non-dev-server builds, barrel-deferred records need their
// source_index cleared so they don't get linked. For dev server,
// skip this — is_unused is also set by ConvertESMExportsForHmr
// deduplication, and clearing those source_indices breaks module
// identity (e.g., __esModule on ESM namespace objects).
if (import_record.flags.is_unused and this.transpiler.options.dev_server == null) {
if (import_record.flags.is_barrel_deferred) {
import_record.source_index = Index.invalid;
}
estimated_resolve_queue_count += @as(usize, @intFromBool(!(import_record.flags.is_internal or import_record.flags.is_unused or import_record.source_index.isValid())));
estimated_resolve_queue_count += @as(usize, @intFromBool(!(import_record.flags.is_internal or import_record.flags.is_unused or import_record.flags.is_barrel_deferred or import_record.source_index.isValid())));
}
var resolve_queue = ResolveQueue.init(this.allocator());
bun.handleOom(resolve_queue.ensureTotalCapacity(@intCast(estimated_resolve_queue_count)));
@@ -3458,6 +3453,7 @@ pub const BundleV2 = struct {
if (
// Don't resolve TypeScript types
import_record.flags.is_unused or
import_record.flags.is_barrel_deferred or
// Don't resolve the runtime
import_record.flags.is_internal or

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

@@ -131,7 +131,7 @@ pub fn convertStmtsForChunk(
const record = ast.import_records.at(s.import_record_index);
// Barrel optimization: deferred export * records should be dropped
if (record.flags.is_unused) {
if (record.flags.is_unused or record.flags.is_barrel_deferred) {
continue;
}

View File

@@ -60,7 +60,9 @@ pub fn convertStmtsForChunkForDevServer(
const record = ast.import_records.mut(st.import_record_index);
if (record.path.is_disabled) continue;
if (record.flags.is_unused) {
if (record.flags.is_unused) continue;
if (record.flags.is_barrel_deferred) {
// Barrel optimization: this import was deferred (unused submodule).
// Don't add to dep array, but declare the namespace ref as an
// empty object so body code referencing it doesn't throw.

View File

@@ -155,10 +155,9 @@ pub fn postProcessJSChunk(ctx: GenerateChunkCtx, worker: *ThreadPool.Worker, chu
// imports by the linker. The printer already recorded them
// when printing cross_chunk_prefix_stmts.
if (record.source_index.isValid()) continue;
// Skip barrel-optimized-away imports — marked is_unused by
// barrel_imports.zig. Never resolved (source_index invalid),
// and removed by convertStmtsForChunk. Not in emitted code.
if (record.flags.is_unused) continue;
// Skip imports not in emitted code: barrel-deferred imports
// and TypeScript type-only imports.
if (record.flags.is_barrel_deferred or record.flags.is_unused) continue;
const import_path = record.path.text;
const irp_id = mi.str(import_path) catch continue;

View File

@@ -176,7 +176,10 @@ pub const ImportRecord = struct {
wrap_with_to_esm: bool = false,
wrap_with_to_commonjs: bool = false,
_padding: u1 = 0,
/// Set by barrel_imports.zig when this import record is deferred (unused
/// submodule of a barrel file). Unlike `is_unused` (which is for TypeScript
/// type-only import removal), this flag is exclusively for barrel optimization.
is_barrel_deferred: bool = false,
};
pub const List = bun.BabyList(ImportRecord);

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");
},
});
});