diff --git a/src/bundler/linker_context/MetafileBuilder.rs b/src/bundler/linker_context/MetafileBuilder.rs index 459fe0e50cb7..a9e4fdb4ee58 100644 --- a/src/bundler/linker_context/MetafileBuilder.rs +++ b/src/bundler/linker_context/MetafileBuilder.rs @@ -200,7 +200,7 @@ pub(crate) fn generate_chunk_json( /// Assembles the final metafile JSON from pre-built chunk fragments. /// Called after all chunks have been generated in parallel. -/// Chunk references (unique_keys) are resolved to their final output paths. +/// Chunk references (unique_keys) left in the output are resolved to their final output paths. /// The caller is responsible for freeing the returned slice. pub(crate) fn generate(c: &mut LinkerContext, chunks: &mut [Chunk]) -> crate::Result> { // Use StringJoiner so we can use breakOutputIntoPieces to resolve chunk references @@ -220,6 +220,8 @@ pub(crate) fn generate(c: &mut LinkerContext, chunks: &mut [Chunk]) -> crate::Re let mut seen_sources = DynamicBitSet::init_empty(sources.len())?; // defer seen_sources.deinit() — handled by Drop + let mut entry_point_by_chunk_key: StringHashMap = StringHashMap::default(); + // Mark all files that appear in chunks for chunk in chunks.iter() { for &source_index in chunk.files_with_parts_in_chunk.keys() { @@ -227,6 +229,10 @@ pub(crate) fn generate(c: &mut LinkerContext, chunks: &mut [Chunk]) -> crate::Re seen_sources.set(source_index as usize); } } + if chunk.entry_point.is_entry_point() && !chunk.unique_key.is_empty() { + entry_point_by_chunk_key + .put_static_key(chunk.unique_key, chunk.entry_point.source_index())?; + } } // Write inputs @@ -292,17 +298,21 @@ pub(crate) fn generate(c: &mut LinkerContext, chunks: &mut [Chunk]) -> crate::Re } first_import = false; + let target_source_index: Option = if record.source_index.is_valid() { + Some(record.source_index.get()) + } else { + // A chunk's unique key, see `compute_cross_chunk_dependencies`. + entry_point_by_chunk_key.get(record.path.text).copied() + }; + j.push_static(b"\n {\n \"path\": "); // Bundled imports use the target source's pretty path (same string as the // "inputs" key). `record.path.text` is unreliable here: dedup can set // `source_index` without rewriting the path. Externals/chunk refs fall through. let import_path: &[u8] = 'path: { - if record.source_index.is_valid() - && record.source_index.get() != Index::RUNTIME.get() - { - let idx = record.source_index.get() as usize; - if idx < sources.len() { - let pretty = sources[idx].path.pretty; + if let Some(idx) = target_source_index { + if idx != Index::RUNTIME.get() && (idx as usize) < sources.len() { + let pretty = sources[idx as usize].path.pretty; if !pretty.is_empty() { break 'path pretty; } @@ -339,16 +349,15 @@ pub(crate) fn generate(c: &mut LinkerContext, chunks: &mut [Chunk]) -> crate::Re if record .flags .contains(ImportRecordFlags::IS_EXTERNAL_WITHOUT_SIDE_EFFECTS) - || !record.source_index.is_valid() + || target_source_index.is_none() { j.push_static(b",\n \"external\": true"); } // Add "with" for import attributes (json, toml, text loaders) - if record.source_index.is_valid() - && (record.source_index.get() as usize) < loaders.len() + if let Some(loader) = + target_source_index.and_then(|idx| loaders.get(idx as usize).copied()) { - let loader = loaders[record.source_index.get() as usize]; let with_type: Option<&'static [u8]> = match loader { Loader::Json => Some(b"json"), Loader::Toml => Some(b"toml"), diff --git a/test/bundler/metafile.test.ts b/test/bundler/metafile.test.ts index 3ca6436b214d..678a01dbef02 100644 --- a/test/bundler/metafile.test.ts +++ b/test/bundler/metafile.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { tempDir } from "harness"; +import { bunEnv, bunExe, tempDir } from "harness"; // Type definitions for metafile structure interface MetafileImport { @@ -416,10 +416,11 @@ describe("bundler metafile", () => { expect(requireImport!.kind).toBe("require-call"); }); - test("metafile tracks dynamic-import imports", async () => { + test("metafile tracks dynamic-import imports with code splitting", async () => { using dir = tempDir("metafile-dynamic-import-test", { - "entry.js": `import("./dynamic.js").then(m => console.log(m));`, + "entry.js": `import("./dynamic.js").then(m => console.log(m)); import("./styles.css");`, "dynamic.js": `export const value = 123;`, + "styles.css": `.dynamic { color: red; }`, }); const result = await Bun.build({ @@ -429,33 +430,85 @@ describe("bundler metafile", () => { }); expect(result.success).toBe(true); - expect(result.metafile).toBeDefined(); + const metafile = result.metafile as Metafile; + const inputKeys = Object.keys(metafile.inputs); + const entryKey = inputKeys.find(k => k.endsWith("entry.js"))!; + const dynamicKey = inputKeys.find(k => k.endsWith("dynamic.js"))!; + const stylesKey = entryKey.replace(/entry\.js$/, "styles.css"); + + // Splitting gives each import() target a chunk of its own (a JS chunk for dynamic.js, + // a CSS chunk for styles.css), but the inputs graph still links source files to + // source files: the import resolves to the "inputs" key of the imported file and is + // not external (esbuild reports it the same way). + expect(metafile.inputs[entryKey].imports).toEqual([ + { path: dynamicKey, kind: "dynamic-import", original: "./dynamic.js" }, + { path: stylesKey, kind: "dynamic-import", original: "./styles.css" }, + ]); + + // The chunk itself is what the outputs graph links to. + const [dynamicChunkPath] = Object.entries(metafile.outputs).find(([, output]) => output.entryPoint === dynamicKey)!; + const entryOutput = Object.values(metafile.outputs).find(output => output.entryPoint === entryKey)!; + expect(entryOutput.imports).toContainEqual({ path: dynamicChunkPath, kind: "dynamic-import" }); + }); - // Find the entry file in inputs - const inputs = (result.metafile as Metafile).inputs as Record; - let dynamicImport: MetafileImport | null = null; - for (const [path, input] of Object.entries(inputs)) { - if (path.includes("entry.js")) { - for (const imp of input.imports) { - if (imp.kind === "dynamic-import" && imp.original === "./dynamic.js") { - dynamicImport = imp; - break; - } - } - break; - } + test("metafile inputs are the same with and without --splitting", async () => { + const files = { + "entry.js": ` + import { shared } from "./shared.js"; + console.log(shared, import("./data.json"), import("./other.js"), import("./lazy.js")); + `, + // A second user entry point that entry.js also import()s. + "other.js": `export const other = "other";`, + // Only reachable through import(), so splitting makes it an entry point of its own. + "lazy.js": `import { shared } from "./shared.js"; export const lazy = shared + "!";`, + "shared.js": `export const shared = "shared";`, + "data.json": `{ "answer": 42 }`, + }; + + async function buildMetafile(...extraArgs: string[]): Promise { + using dir = tempDir("metafile-splitting-inputs", files); + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "entry.js", "other.js", "--outdir=out", "--metafile=meta.json", ...extraArgs], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + return await Bun.file(`${dir}/meta.json`).json(); } - expect(dynamicImport).not.toBeNull(); - expect(dynamicImport!.kind).toBe("dynamic-import"); - expect(dynamicImport!.original).toBe("./dynamic.js"); - // The path should be the final chunk path (e.g., "./chunk-xxx.js"), not the internal unique_key - expect(dynamicImport!.path).toMatch(/^\.\/chunk-[a-z0-9]+\.js$/); + const [withSplitting, withoutSplitting] = await Promise.all([buildMetafile("--splitting"), buildMetafile()]); - // Verify the path corresponds to an actual output chunk - const outputs = (result.metafile as Metafile).outputs as Record; - const outputPaths = Object.keys(outputs); - expect(outputPaths).toContain(dynamicImport!.path); + expect(withSplitting.inputs["entry.js"].imports).toEqual([ + { path: "shared.js", kind: "import-statement", original: "./shared.js" }, + { path: "data.json", kind: "dynamic-import", original: "./data.json", with: { type: "json" } }, + { path: "other.js", kind: "dynamic-import", original: "./other.js" }, + { path: "lazy.js", kind: "dynamic-import", original: "./lazy.js" }, + ]); + expect(withSplitting.inputs).toEqual(withoutSplitting.inputs); + + // Everything this fixture imports ends up in the bundle, so every import edge in the + // inputs graph points at another input, splitting or not. + const inputKeys = Object.keys(withSplitting.inputs); + for (const input of Object.values(withSplitting.inputs)) { + for (const imp of input.imports) { + expect(inputKeys).toContain(imp.path); + } + } + + // The chunks the dynamic imports load are listed in the outputs graph. + const outputPathOf = (entryPoint: string) => + Object.entries(withSplitting.outputs).find(([, output]) => output.entryPoint === entryPoint)![0]; + expect(withSplitting.outputs[outputPathOf("entry.js")].imports).toEqual( + expect.arrayContaining([ + { path: outputPathOf("data.json"), kind: "dynamic-import" }, + { path: outputPathOf("other.js"), kind: "dynamic-import" }, + { path: outputPathOf("lazy.js"), kind: "dynamic-import" }, + ]), + ); }); test("metafile includes cssBundle for CSS outputs", async () => { @@ -800,8 +853,6 @@ describe("Bun.build metafile option variants", () => { }); // CLI tests for --metafile-md -import { bunEnv, bunExe } from "harness"; - describe("bun build --metafile-md", () => { test("generates markdown metafile with default name", async () => { using dir = tempDir("metafile-md-test", { @@ -1088,6 +1139,31 @@ describe("bun build --metafile-md", () => { expect(content).toContain("require-call"); }); + test("markdown links dynamically imported modules to their importers with --splitting", async () => { + using dir = tempDir("metafile-md-splitting-dynamic-import", { + "entry.js": `import("./dynamic.js").then(m => console.log(m));`, + "dynamic.js": `export const dynamic_value = 2;`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "entry.js", "--metafile-md", "--outdir=dist", "--splitting"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + + const content = await Bun.file(`${dir}/meta.md`).text(); + expect(content).toContain("[IMPORT: entry.js -> dynamic.js]"); + expect(content).toContain("[IMPORTED_BY: dynamic.js <- entry.js]"); + expect(content).not.toContain("[EXTERNAL: entry.js"); + expect(content).not.toContain("| External imports |"); + }); + test("markdown shows commonly imported modules", async () => { using dir = tempDir("metafile-md-common-imports", { "a.js": `import { shared } from "./shared.js"; console.log("a", shared);`,