mirror of
https://github.com/oven-sh/bun
synced 2026-03-13 10:48:00 +01:00
Compare commits
7 Commits
spawnsync-
...
ali/gc-orp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ac93936a2 | ||
|
|
8fc01cb7b1 | ||
|
|
bf404e0a53 | ||
|
|
59adf07d51 | ||
|
|
7041077163 | ||
|
|
9bbc009776 | ||
|
|
0eb37ee471 |
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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, {});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user