From 2fcda1047d2eb80311d2148c84a4c5d9e8b4d1ec Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:56:10 +0000 Subject: [PATCH 1/3] bundler: take the metafile inputs of a JS output from the compile results of its chunk A JS chunk prints the class names of a stylesheet that it uses. The stylesheet is not a file of the chunk, so the byte counter of the print worker had no key for it, and the metafile record of the JS output did not list it. The metafile now adds up the compile results of the chunk. The files of the chunk keep their place and their order. A source that is not one of them gets a record after them when the chunk has code of it. The print worker no longer counts, so files_with_parts_in_chunk is a set again. --- src/bundler/Chunk.rs | 16 +- src/bundler/LinkerContext.rs | 1 - src/bundler/linker_context/MetafileBuilder.rs | 31 ++- src/bundler/linker_context/computeChunks.rs | 18 +- .../generateCompileResultForJSChunk.rs | 25 +- .../linker_context/postProcessJSChunk.rs | 1 - test/bundler/metafile.test.ts | 259 ++++++++++++++++++ 7 files changed, 293 insertions(+), 58 deletions(-) diff --git a/src/bundler/Chunk.rs b/src/bundler/Chunk.rs index 40fd3ee559d2..eb13b327e8da 100644 --- a/src/bundler/Chunk.rs +++ b/src/bundler/Chunk.rs @@ -46,14 +46,8 @@ pub struct Chunk<'a> { /// Like `unique_key`, but replaced with `id()` rather than the chunk's path. pub(crate) id_key: &'static [u8], - /// Maps source index to bytes contributed to this chunk's output (for metafile). - /// Empty for a CSS or HTML chunk: the metafile takes the inputs of those from the compile results. - /// The value is updated during parallel chunk generation to track bytesInOutput. - /// CONCURRENCY: the key set is frozen before codegen starts; worker threads - /// only `fetch_add` the per-source counters (see - /// `generate_compile_result_for_js_chunk`), so the value type is - /// `AtomicUsize` rather than `usize` to avoid materializing aliased `&mut`. - pub(crate) files_with_parts_in_chunk: ArrayHashMap, + /// The files assigned to a JS chunk, in the order of the graph. No stylesheet is one. Empty for a CSS or HTML chunk. + pub(crate) files_with_parts_in_chunk: ArrayHashMap, /// We must not keep pointers to this type until all chunks have been allocated. pub(crate) entry_bits: AutoBitSet, @@ -124,12 +118,10 @@ impl Default for Content<'_> { // (UnsafeCell-per-slot) so the per-task write is routed through interior // mutability and never requires an aliased `&mut Chunk` / // `&mut [CompileResult]` — see [`Chunk::write_compile_result_slot`]. -// `files_with_parts_in_chunk` values are bumped via atomic RMW; -// the renamer is fully populated before fan-out, and the printer only reads it. +// The renamer is fully populated before fan-out, and the printer only reads it. unsafe impl Send for Chunk<'_> {} // SAFETY: shared `&Chunk` access during the worker fan-out writes only -// `compile_results_for_chunk` (UnsafeCell-per-slot, disjoint indices) and -// `files_with_parts_in_chunk` atomic counters; the remaining fields are +// `compile_results_for_chunk` (UnsafeCell-per-slot, disjoint indices); the remaining fields are // frozen before fan-out. unsafe impl Sync for Chunk<'_> {} diff --git a/src/bundler/LinkerContext.rs b/src/bundler/LinkerContext.rs index f51818fb9c88..0c5cbcadba05 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -1888,7 +1888,6 @@ pub struct PendingPartRange<'c, 'a> { /// - `chunk.compile_results_for_chunk[i]` is written at a per-task unique `i` /// via [`Chunk::write_compile_result_slot`] (raw `addr_of_mut!` + /// `UnsafeCell` slot write — never `&mut Chunk`), -/// - `chunk.files_with_parts_in_chunk` entries are updated via atomic RMW only, /// - all other access through `ctx.c` / `ctx.chunk` during codegen is read-only. #[inline] pub(crate) unsafe fn pending_part_range_prologue<'c, 'a>( diff --git a/src/bundler/linker_context/MetafileBuilder.rs b/src/bundler/linker_context/MetafileBuilder.rs index aae65f2021ec..ca2d01019910 100644 --- a/src/bundler/linker_context/MetafileBuilder.rs +++ b/src/bundler/linker_context/MetafileBuilder.rs @@ -56,13 +56,30 @@ fn fmt_size(bytes: u64) -> bfmt::SizeFormatter { /// The inputs of the output of `chunk`, each with its "bytesInOutput", in the order of the record. fn chunk_inputs(chunk: &Chunk) -> crate::Result> { match &chunk.content { - ChunkContent::Javascript(_) => { - let counted = chunk.files_with_parts_in_chunk.iter(); - let load = |bytes: &core::sync::atomic::AtomicUsize| { - // The codegen workers that add to the counters are joined before any output is emitted. - bytes.load(core::sync::atomic::Ordering::Relaxed) - }; - Ok(counted.map(|(&file, bytes)| (file, load(bytes))).collect()) + ChunkContent::Javascript(js) => { + let results = &chunk.compile_results_for_chunk; + debug_assert_eq!( + results.len(), + js.parts_in_chunk_in_order.len(), + "the metafile reads the compile results of the chunk", + ); + // Unlike esbuild, which lists sources in print order, the files of the chunk come first, in graph order. + let files = &chunk.files_with_parts_in_chunk; + let mut inputs: Vec<(IndexInt, usize)> = + files.keys().iter().map(|&file| (file, 0)).collect(); + // A stylesheet is a file of no JS chunk: each JS chunk that uses its class names prints its own copy. + let mut copied: ArrayHashMap = ArrayHashMap::new(); + for result in results.iter() { + let bytes = result.code().len(); + match files.get_index(&result.source_index()) { + Some(file) => inputs[file].1 += bytes, + None => *copied.get_or_put(result.source_index())?.value_ptr += bytes, + } + } + // Unlike esbuild, no record for a stylesheet that the chunk imports and prints nothing of. + let printed = copied.iter().filter(|&(_, &bytes)| bytes > 0); + inputs.extend(printed.map(|(&file, &bytes)| (file, bytes))); + Ok(inputs) } ChunkContent::Css(_) => { // One input for each stylesheet, also when the chunk prints it under two import conditions. diff --git a/src/bundler/linker_context/computeChunks.rs b/src/bundler/linker_context/computeChunks.rs index 8ac8d537acfe..9abe08a2be1b 100644 --- a/src/bundler/linker_context/computeChunks.rs +++ b/src/bundler/linker_context/computeChunks.rs @@ -1,5 +1,4 @@ use crate::mal_prelude::*; -use core::sync::atomic::AtomicUsize; use bun_alloc::Arena; // bumpalo::Bump re-export use bun_collections::{ArrayHashMap, AutoBitSet, VecExt, index_sort}; @@ -354,14 +353,10 @@ pub(crate) fn compute_chunks<'a>( .insert(chunk::Flags::IS_BROWSER_CHUNK_FROM_SERVER_BUILD); } - let entry = js_chunk_entry + js_chunk_entry .value_ptr .files_with_parts_in_chunk - .get_or_put(source_index.get() as u32) - .expect("unreachable"); - if !entry.found_existing { - *entry.value_ptr = AtomicUsize::new(0); // Initialize byte count to 0 - } + .put(source_index.get(), ())?; } else { // Pass a context struct + fn pointer to entry_bits' forEach. struct Handler<'a, 'c> { @@ -377,13 +372,8 @@ pub(crate) fn compute_chunks<'a>( return; } - let entry = c.chunks[chunk_idx as usize] - .files_with_parts_in_chunk - .get_or_put(c.source_id as u32) - .expect("unreachable"); - if !entry.found_existing { - *entry.value_ptr = AtomicUsize::new(0); // Initialize byte count to 0 - } + let files = &mut c.chunks[chunk_idx as usize].files_with_parts_in_chunk; + bun_core::handle_oom(files.put(c.source_id, ())); } let mut handler = Handler { chunks: js_chunks.values_mut(), diff --git a/src/bundler/linker_context/generateCompileResultForJSChunk.rs b/src/bundler/linker_context/generateCompileResultForJSChunk.rs index f39841f65f5e..a91f48b50e92 100644 --- a/src/bundler/linker_context/generateCompileResultForJSChunk.rs +++ b/src/bundler/linker_context/generateCompileResultForJSChunk.rs @@ -1,8 +1,7 @@ use crate::mal_prelude::*; -use core::sync::atomic::Ordering; use bun_ast::Scope; -use bun_js_printer::{self as js_printer, PrintResult}; +use bun_js_printer as js_printer; use bun_threading::thread_pool as ThreadPoolLib; use crate::analyze_transpiled_module::ModuleInfo; @@ -15,8 +14,7 @@ use super::generate_code_for_file_in_chunk_js::generate_code_for_file_in_chunk_j // CONCURRENCY: thread-pool callback — runs on worker threads, one task per // `PendingPartRange`. Writes: `chunk.compile_results_for_chunk[i]` (disjoint -// by per-task `i`), `chunk.files_with_parts_in_chunk[source].counter` -// (atomic RMW). Reads `c.graph`/`c.parse_graph` SoA columns shared. Never +// by per-task `i`). Reads `c.graph`/`c.parse_graph` SoA columns shared. Never // forms `&mut LinkerContext` or `&mut Chunk`. // `PendingPartRange` is `Send` because its only non-auto-`Send` field is // `&GenerateChunkCtx` whose pointee is `Send + Sync`. @@ -136,25 +134,6 @@ fn generate_compile_result_for_js_chunk_impl( module_info.as_deref_mut(), ); - // Update bytesInOutput for this source in the chunk (for metafile) - // Use atomic operation since multiple threads may update the same counter - let code_len = match &result { - PrintResult::Result(r) => r.code.len(), - _ => 0, - }; - if code_len > 0 && !part_range.source_index.is_runtime() { - // CONCURRENCY: the map's key set is frozen before parallel codegen; we - // only need a shared `&AtomicUsize` to RMW the counter. Using `get` - // (not `get_ptr_mut`) avoids materializing an aliased `&mut` to a slot - // that other worker threads may be updating for the same source. - if let Some(bytes) = chunk - .files_with_parts_in_chunk - .get(&part_range.source_index.get()) - { - let _ = bytes.fetch_add(code_len, Ordering::Relaxed); - } - } - CompileResult::Javascript { source_index: part_range.source_index.get(), result, diff --git a/src/bundler/linker_context/postProcessJSChunk.rs b/src/bundler/linker_context/postProcessJSChunk.rs index 1455395e4bc6..f72831a05194 100644 --- a/src/bundler/linker_context/postProcessJSChunk.rs +++ b/src/bundler/linker_context/postProcessJSChunk.rs @@ -673,7 +673,6 @@ pub(crate) fn post_process_js_chunk( } } - // TODO: metafile newline_before_comment = !compile_result.code().is_empty(); } // An entry chunk whose code all lives in shared chunks has no compile results of its own. diff --git a/test/bundler/metafile.test.ts b/test/bundler/metafile.test.ts index 2bc3a453808d..41866c80b605 100644 --- a/test/bundler/metafile.test.ts +++ b/test/bundler/metafile.test.ts @@ -996,6 +996,265 @@ describe.concurrent("metafile outputs[..].inputs of a JS chunk", () => { ], ]); }); + + /** The inputs of each metafile output by file name, in the order of the record. */ + const inputsByOutput = (metafile: Metafile) => + Object.fromEntries(Object.entries(outputRecords(metafile)).map(([file, output]) => [file, output.inputs])); + + /** The code that a JS output has of `file`: what follows each `// ` comment, up to the next comment. */ + function codeOf(text: string, file: string) { + // [the text before the first comment, a path, its code, a path, its code, ...] + const parts = text.split(/^\/\/ (.+)\n/m); + let code = ""; + for (let i = 1; i < parts.length; i += 2) { + if (fileName(parts[i]) !== file) continue; + // The blank line before the next comment is not code of the file. + code += parts[i + 1].endsWith("\n\n") ? parts[i + 1].slice(0, -1) : parts[i + 1]; + } + return code; + } + + /** The rules of a CSS output. Its first line is the comment with the path of the stylesheet. */ + const rulesOf = (css: string) => css.slice(css.indexOf("\n") + 1); + + const textOf = (result: Bun.BuildOutput, extension: string) => + result.outputs.find(artifact => artifact.path.endsWith(extension))!.text(); + + // A stylesheet is a file of no JS chunk. A JS chunk that uses what the stylesheet exports prints that itself. + // `prints` is what the chunk prints of the stylesheet, or how that starts. + const uses = [ + { + use: "a default import", + stylesheet: "a.module.css", + entry: `import styles from "./a.module.css";\nconsole.log(styles.foo);\n`, + prints: /^var a_module_default = \{\n foo: "foo_[^"]+"\n\};\n$/, + }, + { + use: "a named import", + stylesheet: "a.module.css", + entry: `import { foo } from "./a.module.css";\nconsole.log(foo);\n`, + prints: /^var foo = "foo_[^"]+";\n$/, + }, + { + use: "a namespace import", + stylesheet: "a.module.css", + entry: `import * as styles from "./a.module.css";\nconsole.log(styles);\n`, + prints: /^var exports_a_module = \{\};\n/, + }, + { + use: "require()", + stylesheet: "a.module.css", + entry: `const styles = require("./a.module.css");\nconsole.log(styles.foo);\nexport {};\n`, + prints: /^var require_a_module = /, + }, + { + use: "import() with no splitting", + stylesheet: "a.module.css", + entry: `import("./a.module.css").then(styles => console.log(styles.foo));\n`, + prints: /^var require_a_module = /, + }, + { + use: "a default import of a stylesheet that is not a CSS module", + stylesheet: "plain.css", + entry: `import styles from "./plain.css";\nconsole.log(styles);\n`, + prints: /^var plain_default = \{\};\n$/, + }, + ]; + + test.each(uses.flatMap(use => (["esm", "cjs"] as const).map(format => ({ ...use, format }))))( + "list a stylesheet that the chunk prints code of ($use, format: $format)", + async ({ stylesheet, entry, prints, format }) => { + using dir = tempDir("metafile-js-inputs-stylesheet", { + "entry.js": entry, + [stylesheet]: `.foo { color: red; }\n`, + }); + + const result = await Bun.build({ entrypoints: [`${dir}/entry.js`], format, metafile: true }); + expect(result.success).toBe(true); + const metafile = result.metafile as Metafile; + + const js = await textOf(result, ".js"); + expect(codeOf(js, stylesheet)).toMatch(prints); + // The files of the chunk come first. The stylesheet has its own bytes in each output. + expect(inputsByOutput(metafile)).toEqual({ + "entry.js": [ + ["entry.js", codeOf(js, "entry.js").length], + [stylesheet, codeOf(js, stylesheet).length], + ], + "entry.css": [[stylesheet, rulesOf(await textOf(result, ".css")).length]], + }); + const listed = Object.values(metafile.outputs).flatMap(output => Object.keys(output.inputs)); + expect(listed.filter(input => !(input in metafile.inputs))).toEqual([]); + }, + ); + + // The chunk prints the namespace objects of the two stylesheets first, and then the class names of each. + test("list a stylesheet one time, with all the pieces that the chunk prints of it", async () => { + using dir = tempDir("metafile-js-inputs-stylesheet-pieces", { + "entry.js": `import * as a from "./a.module.css";\nimport * as b from "./b.module.css";\nconsole.log(a, b);\n`, + "a.module.css": `.foo { color: red; }\n`, + "b.module.css": `.longer { color: blue; }\n`, + }); + + const result = await Bun.build({ entrypoints: [`${dir}/entry.js`], metafile: true }); + expect(result.success).toBe(true); + + const js = await textOf(result, ".js"); + const comments = Array.from(js.matchAll(/^\/\/ (.+)$/gm), comment => fileName(comment[1])); + expect(comments).toEqual(["a.module.css", "b.module.css", "a.module.css", "b.module.css", "entry.js"]); + expect(inputsByOutput(result.metafile as Metafile)["entry.js"]).toEqual([ + ["entry.js", `console.log(exports_a_module, exports_b_module);\n`.length], + ["a.module.css", codeOf(js, "a.module.css").length], + ["b.module.css", codeOf(js, "b.module.css").length], + ]); + expect(codeOf(js, "b.module.css").length).toBeGreaterThan(codeOf(js, "a.module.css").length); + }); + + // With --splitting a JS file is in one chunk. Each chunk that uses the class names has its own copy of them. + test.each([false, true])("list a stylesheet in each chunk that prints it (splitting: %p)", async splitting => { + const printed = { + "one.js": `console.log("one", a_module_default.foo, shared);\n`, + "two.js": `console.log("two", a_module_default.foo, shared);\n`, + "shared.js": `var shared = a_module_default.foo + "!";\n`, + }; + using dir = tempDir("metafile-js-inputs-stylesheet-chunks", { + "one.js": `import styles from "./a.module.css";\nimport { shared } from "./shared.js";\nconsole.log("one", styles.foo, shared);\n`, + "two.js": `import styles from "./a.module.css";\nimport { shared } from "./shared.js";\nconsole.log("two", styles.foo, shared);\n`, + "shared.js": `import styles from "./a.module.css";\nexport const shared = styles.foo + "!";\n`, + "a.module.css": `.foo { color: red; }\n`, + }); + + const result = await Bun.build({ + entrypoints: [`${dir}/one.js`, `${dir}/two.js`], + splitting, + metafile: true, + }); + expect(result.success).toBe(true); + + const scripts = result.outputs.filter(artifact => artifact.path.endsWith(".js")); + const copies = await Promise.all(scripts.map(async script => codeOf(await script.text(), "a.module.css"))); + expect(copies).toEqual(scripts.map(() => expect.stringMatching(/^var a_module_default = \{\n foo: "foo_[^"]+"\n\};\n$/))); + + const bytes = (...files: (keyof typeof printed)[]) => files.map(file => [file, printed[file].length]); + const stylesheet = ["a.module.css", copies[0].length]; + const inputs = Object.entries(inputsByOutput(result.metafile as Metafile)); + expect(inputs.filter(([file]) => file.endsWith(".js")).map(([, inputs]) => inputs)).toEqual( + splitting + ? [ + [...bytes("one.js"), stylesheet], + [...bytes("shared.js"), stylesheet], + [...bytes("two.js"), stylesheet], + ] + : [ + [...bytes("shared.js", "one.js"), stylesheet], + [...bytes("shared.js", "two.js"), stylesheet], + ], + ); + }); + + // The page is a file of its JS chunk and prints nothing there. The stylesheet that the page links is not. + test("list a stylesheet in the JS chunk of an HTML page", async () => { + using dir = tempDir("metafile-js-inputs-stylesheet-html", { + "index.html": `\n`, + "app.js": `import styles from "./a.module.css";\nconsole.log(styles.foo);\n`, + "a.module.css": `.foo { color: red; }\n`, + "page.css": `.page { color: red; }\n`, + }); + + const result = await Bun.build({ entrypoints: [`${dir}/index.html`], metafile: true }); + expect(result.success).toBe(true); + + const js = await textOf(result, ".js"); + const inputs = Object.entries(inputsByOutput(result.metafile as Metafile)); + expect(inputs.filter(([file]) => file.endsWith(".js")).map(([, inputs]) => inputs)).toEqual([ + [ + ["app.js", `console.log(a_module_default.foo);\n`.length], + ["index.html", 0], + ["a.module.css", codeOf(js, "a.module.css").length], + ], + ]); + expect(codeOf(js, "a.module.css")).toMatch(/^var a_module_default = \{\n foo: "foo_[^"]+"\n\};\n$/); + }); + + // esbuild lists such a stylesheet with 0 bytes. Each of these builds has the rules of the stylesheet in its CSS output. + test.each([ + { + use: "a bare import", + files: { + "entry.js": `import "./a.module.css";\nconsole.log("entry");\n`, + "a.module.css": `.foo { color: red; }\n`, + }, + }, + { + use: "a default import that nothing reads", + files: { + "entry.js": `import styles from "./a.module.css";\nconsole.log("entry");\n`, + "a.module.css": `.foo { color: red; }\n`, + }, + }, + { + // The `require()` makes the stylesheet a wrapped file, which the chunk prints in one piece. Nothing calls the wrapper. + use: "a bare import and a require() that nothing calls, of a package with no side effects", + files: { + "entry.js": `import "./own.css";\nimport "kit/a.module.css";\nfunction dead() {\n return require("kit/a.module.css");\n}\nconsole.log("entry");\n`, + "own.css": `@import "kit/a.module.css";\n.own { color: red; }\n`, + "node_modules/kit/package.json": JSON.stringify({ name: "kit", version: "1.0.0", sideEffects: false }), + "node_modules/kit/a.module.css": `.foo { color: red; }\n`, + }, + }, + ])("do not list a stylesheet that the chunk prints nothing of ($use)", async ({ files }) => { + using dir = tempDir("metafile-js-inputs-stylesheet-unused", files); + + const result = await Bun.build({ entrypoints: [`${dir}/entry.js`], metafile: true }); + expect(result.success).toBe(true); + + // The JS output is the comment of the entry point and its code. + expect((await textOf(result, ".js")).replace(/^\/\/ .+\n/, "")).toBe(`console.log("entry");\n`); + const rules = (await textOf(result, ".css")).match(/^\.foo_[^ ]+ \{\n color: red;\n\}\n/m)![0]; + const inputs = inputsByOutput(result.metafile as Metafile); + expect(inputs["entry.js"]).toEqual([["entry.js", `console.log("entry");\n`.length]]); + expect(inputs["entry.css"]).toContainEqual(["a.module.css", rules.length]); + }); + + test("bun build --metafile --metafile-md list a stylesheet that the chunk prints code of", async () => { + const entryCode = `console.log(a_module_default.foo);\n`; + using dir = tempDir("metafile-js-inputs-stylesheet-cli", { + "entry.js": `import styles from "./a.module.css";\nconsole.log(styles.foo);\n`, + "a.module.css": `.foo { color: red; }\n`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "./entry.js", "--outdir=dist", "--metafile=meta.json", "--metafile-md=meta.md"], + 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, exitCode }).toEqual({ stderr: "", exitCode: 0 }); + + const js = await Bun.file(`${dir}/dist/entry.js`).text(); + const classNames = js.match(/^var a_module_default = \{\n foo: "foo_[^"]+"\n\};\n/m)?.[0]; + expect(js).toBe(`// a.module.css\n${classNames}\n// entry.js\n${entryCode}`); + const inJs = classNames!.length; + const inCss = rulesOf(await Bun.file(`${dir}/dist/entry.css`).text()).length; + + // The JSON has the key of the stylesheet one time in each of the two outputs. + const json = await Bun.file(`${dir}/meta.json`).text(); + expect(json.split(`"a.module.css": {\n "bytesInOutput"`)).toHaveLength(3); + expect(inputsByOutput(JSON.parse(json) as Metafile)).toEqual({ + "entry.js": [ + ["entry.js", entryCode.length], + ["a.module.css", inJs], + ], + "entry.css": [["a.module.css", inCss]], + }); + + // The report adds up what the stylesheet has in the two outputs. + const report = await Bun.file(`${dir}/meta.md`).text(); + expect(report).toContain(`| ${inJs} bytes | \`a.module.css\` |\n| ${entryCode.length} bytes | \`entry.js\` |\n`); + expect(report).toContain(`[OUTPUT_BYTES: a.module.css = ${inJs + inCss} bytes]\n`); + }); }); describe("Bun.build metafile option variants", () => { From 1ab0c353c10cc1de545fdfe04ba95ed3e581e8b7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:19:51 +0000 Subject: [PATCH 2/3] test(bundler): name what a JS chunk prints for the default import of a CSS module --- test/bundler/metafile.test.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/test/bundler/metafile.test.ts b/test/bundler/metafile.test.ts index 41866c80b605..b2a686f44555 100644 --- a/test/bundler/metafile.test.ts +++ b/test/bundler/metafile.test.ts @@ -1020,6 +1020,9 @@ describe.concurrent("metafile outputs[..].inputs of a JS chunk", () => { const textOf = (result: Bun.BuildOutput, extension: string) => result.outputs.find(artifact => artifact.path.endsWith(extension))!.text(); + /** What a JS chunk prints of `a.module.css` with the class `foo`, for a default import that reads `foo`. */ + const classNames = /^var a_module_default = \{\n foo: "foo_[^"]+"\n\};\n$/; + // A stylesheet is a file of no JS chunk. A JS chunk that uses what the stylesheet exports prints that itself. // `prints` is what the chunk prints of the stylesheet, or how that starts. const uses = [ @@ -1027,7 +1030,7 @@ describe.concurrent("metafile outputs[..].inputs of a JS chunk", () => { use: "a default import", stylesheet: "a.module.css", entry: `import styles from "./a.module.css";\nconsole.log(styles.foo);\n`, - prints: /^var a_module_default = \{\n foo: "foo_[^"]+"\n\};\n$/, + prints: classNames, }, { use: "a named import", @@ -1133,7 +1136,7 @@ describe.concurrent("metafile outputs[..].inputs of a JS chunk", () => { const scripts = result.outputs.filter(artifact => artifact.path.endsWith(".js")); const copies = await Promise.all(scripts.map(async script => codeOf(await script.text(), "a.module.css"))); - expect(copies).toEqual(scripts.map(() => expect.stringMatching(/^var a_module_default = \{\n foo: "foo_[^"]+"\n\};\n$/))); + expect(copies).toEqual(scripts.map(() => expect.stringMatching(classNames))); const bytes = (...files: (keyof typeof printed)[]) => files.map(file => [file, printed[file].length]); const stylesheet = ["a.module.css", copies[0].length]; @@ -1173,7 +1176,7 @@ describe.concurrent("metafile outputs[..].inputs of a JS chunk", () => { ["a.module.css", codeOf(js, "a.module.css").length], ], ]); - expect(codeOf(js, "a.module.css")).toMatch(/^var a_module_default = \{\n foo: "foo_[^"]+"\n\};\n$/); + expect(codeOf(js, "a.module.css")).toMatch(classNames); }); // esbuild lists such a stylesheet with 0 bytes. Each of these builds has the rules of the stylesheet in its CSS output. @@ -1234,9 +1237,10 @@ describe.concurrent("metafile outputs[..].inputs of a JS chunk", () => { expect({ stderr, exitCode }).toEqual({ stderr: "", exitCode: 0 }); const js = await Bun.file(`${dir}/dist/entry.js`).text(); - const classNames = js.match(/^var a_module_default = \{\n foo: "foo_[^"]+"\n\};\n/m)?.[0]; - expect(js).toBe(`// a.module.css\n${classNames}\n// entry.js\n${entryCode}`); - const inJs = classNames!.length; + const stylesheetCode = codeOf(js, "a.module.css"); + expect(stylesheetCode).toMatch(classNames); + expect(js).toBe(`// a.module.css\n${stylesheetCode}\n// entry.js\n${entryCode}`); + const inJs = stylesheetCode.length; const inCss = rulesOf(await Bun.file(`${dir}/dist/entry.css`).text()).length; // The JSON has the key of the stylesheet one time in each of the two outputs. From e2ea6f9d6b9d72d85e15086a1fdfb66c57c7627a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 30 Oct 2026 23:34:52 +0000 Subject: [PATCH 3/3] bundler: say what the filter of the metafile JS records does, and pin the order of the stylesheet records The comment on the filter named a difference from esbuild that the filter does not cause. The filter is for a wrapped stylesheet whose wrapper the chunk drops. New tests: the order of two stylesheet records, the wrapper that the chunk prints for a bare import in a wrapped file, and the 0-byte record of a file of the chunk with and without --splitting. --- src/bundler/Chunk.rs | 2 +- src/bundler/linker_context/MetafileBuilder.rs | 4 +- test/bundler/metafile.test.ts | 142 ++++++++++++++---- 3 files changed, 112 insertions(+), 36 deletions(-) diff --git a/src/bundler/Chunk.rs b/src/bundler/Chunk.rs index eb13b327e8da..f9a918dc81f7 100644 --- a/src/bundler/Chunk.rs +++ b/src/bundler/Chunk.rs @@ -46,7 +46,7 @@ pub struct Chunk<'a> { /// Like `unique_key`, but replaced with `id()` rather than the chunk's path. pub(crate) id_key: &'static [u8], - /// The files assigned to a JS chunk, in the order of the graph. No stylesheet is one. Empty for a CSS or HTML chunk. + /// The files assigned to a JS chunk, in graph order. Not all that it prints: the JS code of a stylesheet has no key (`files_in_chunk_order` lists it). Empty for a CSS or HTML chunk. pub(crate) files_with_parts_in_chunk: ArrayHashMap, /// We must not keep pointers to this type until all chunks have been allocated. diff --git a/src/bundler/linker_context/MetafileBuilder.rs b/src/bundler/linker_context/MetafileBuilder.rs index ca2d01019910..c5124ee4b08c 100644 --- a/src/bundler/linker_context/MetafileBuilder.rs +++ b/src/bundler/linker_context/MetafileBuilder.rs @@ -67,7 +67,7 @@ fn chunk_inputs(chunk: &Chunk) -> crate::Result> { let files = &chunk.files_with_parts_in_chunk; let mut inputs: Vec<(IndexInt, usize)> = files.keys().iter().map(|&file| (file, 0)).collect(); - // A stylesheet is a file of no JS chunk: each JS chunk that uses its class names prints its own copy. + // A stylesheet is a file of no JS chunk. The ones that the chunk prints code of come next, in print order. let mut copied: ArrayHashMap = ArrayHashMap::new(); for result in results.iter() { let bytes = result.code().len(); @@ -76,7 +76,7 @@ fn chunk_inputs(chunk: &Chunk) -> crate::Result> { None => *copied.get_or_put(result.source_index())?.value_ptr += bytes, } } - // Unlike esbuild, no record for a stylesheet that the chunk imports and prints nothing of. + // A wrapped stylesheet has a compile result also when the chunk drops its wrapper. That one gets no record. let printed = copied.iter().filter(|&(_, &bytes)| bytes > 0); inputs.extend(printed.map(|(&file, &bytes)| (file, bytes))); Ok(inputs) diff --git a/test/bundler/metafile.test.ts b/test/bundler/metafile.test.ts index b2a686f44555..6b0e0996e181 100644 --- a/test/bundler/metafile.test.ts +++ b/test/bundler/metafile.test.ts @@ -997,6 +997,34 @@ describe.concurrent("metafile outputs[..].inputs of a JS chunk", () => { ]); }); + // With --splitting, a file that the chunk has no compile result for is not a file of the chunk. + test.each([false, true])( + "keep the record of a file of the chunk that prints nothing (splitting: %p)", + async splitting => { + using dir = tempDir("metafile-js-inputs-no-code", { + "entry.js": `import { x } from "./reexport.js";\nimport "./barrel.js";\nconsole.log(x);\n`, + "reexport.js": `export { x } from "./x.js";\n`, + "x.js": `export const x = 1;\n`, + "barrel.js": `import "./side.js";\n`, + "side.js": `console.log("side");\n`, + }); + + const result = await Bun.build({ entrypoints: [`${dir}/entry.js`], splitting, metafile: true }); + expect(result.success).toBe(true); + + expect(inputsOf(result)).toEqual([ + [ + ["x.js", `var x = 1;\n`.length], + ["reexport.js", 0], + ["side.js", `console.log("side");\n`.length], + // A file that only has `import` statements has no compile result. + ...(splitting ? [] : [["barrel.js", 0]]), + ["entry.js", `console.log(x);\n`.length], + ], + ]); + }, + ); + /** The inputs of each metafile output by file name, in the order of the record. */ const inputsByOutput = (metafile: Metafile) => Object.fromEntries(Object.entries(outputRecords(metafile)).map(([file, output]) => [file, output.inputs])); @@ -1014,6 +1042,9 @@ describe.concurrent("metafile outputs[..].inputs of a JS chunk", () => { return code; } + /** The file names in the `// ` comments of a JS output: the files in the order the output prints them. */ + const printOrder = (text: string) => Array.from(text.matchAll(/^\/\/ (.+)$/gm), comment => fileName(comment[1])); + /** The rules of a CSS output. Its first line is the comment with the path of the stylesheet. */ const rulesOf = (css: string) => css.slice(css.indexOf("\n") + 1); @@ -1024,7 +1055,8 @@ describe.concurrent("metafile outputs[..].inputs of a JS chunk", () => { const classNames = /^var a_module_default = \{\n foo: "foo_[^"]+"\n\};\n$/; // A stylesheet is a file of no JS chunk. A JS chunk that uses what the stylesheet exports prints that itself. - // `prints` is what the chunk prints of the stylesheet, or how that starts. + // `prints` is what the chunk prints of the stylesheet, or how that starts. The metafile counts it as it is: + // #38286 and #38307 change what the chunk prints for a namespace import, for `require()` and for `import()`. const uses = [ { use: "a default import", @@ -1103,8 +1135,7 @@ describe.concurrent("metafile outputs[..].inputs of a JS chunk", () => { expect(result.success).toBe(true); const js = await textOf(result, ".js"); - const comments = Array.from(js.matchAll(/^\/\/ (.+)$/gm), comment => fileName(comment[1])); - expect(comments).toEqual(["a.module.css", "b.module.css", "a.module.css", "b.module.css", "entry.js"]); + expect(printOrder(js)).toEqual(["a.module.css", "b.module.css", "a.module.css", "b.module.css", "entry.js"]); expect(inputsByOutput(result.metafile as Metafile)["entry.js"]).toEqual([ ["entry.js", `console.log(exports_a_module, exports_b_module);\n`.length], ["a.module.css", codeOf(js, "a.module.css").length], @@ -1113,6 +1144,55 @@ describe.concurrent("metafile outputs[..].inputs of a JS chunk", () => { expect(codeOf(js, "b.module.css").length).toBeGreaterThan(codeOf(js, "a.module.css").length); }); + // The two entry points import the same two stylesheets in opposite orders. + test.each([false, true])( + "list the stylesheets after the files of the chunk, in the order the chunk prints them (splitting: %p)", + async splitting => { + using dir = tempDir("metafile-js-inputs-stylesheet-order", { + "first.js": `import a from "./a.module.css";\nimport b from "./b.module.css";\nconsole.log("first", a.foo, b.longer);\n`, + "second.js": `import b from "./b.module.css";\nimport a from "./a.module.css";\nconsole.log("second", b.longer, a.foo);\n`, + "a.module.css": `.foo { color: red; }\n`, + "b.module.css": `.longer { color: blue; }\n`, + }); + + const result = await Bun.build({ + entrypoints: [`${dir}/first.js`, `${dir}/second.js`], + splitting, + metafile: true, + }); + expect(result.success).toBe(true); + + const inputs = inputsByOutput(result.metafile as Metafile); + const printed = { "first.js": ["a.module.css", "b.module.css"], "second.js": ["b.module.css", "a.module.css"] }; + for (const [script, stylesheets] of Object.entries(printed)) { + const js = await textOf(result, script); + expect(printOrder(js)).toEqual([...stylesheets, script]); + expect(inputs[script]).toEqual([script, ...stylesheets].map(file => [file, codeOf(js, file).length])); + } + }, + ); + + // `route.js` is a wrapped file here, so the chunk prints a wrapper for the stylesheet that it imports. + test("list a stylesheet with a bare import when the chunk prints a wrapper for it", async () => { + const wrapper = `var init_plain = () => {};\n`; + using dir = tempDir("metafile-js-inputs-stylesheet-wrapper", { + "entry.js": `import("./route.js").then(route => console.log(route.name));\n`, + "route.js": `import "./plain.css";\nexport const name = "route";\n`, + "plain.css": `.foo { color: red; }\n`, + }); + + const result = await Bun.build({ entrypoints: [`${dir}/entry.js`], metafile: true }); + expect(result.success).toBe(true); + + const js = await textOf(result, ".js"); + expect(codeOf(js, "plain.css")).toBe(wrapper); + expect(inputsByOutput(result.metafile as Metafile)["entry.js"]).toEqual([ + ["route.js", codeOf(js, "route.js").length], + ["entry.js", codeOf(js, "entry.js").length], + ["plain.css", wrapper.length], + ]); + }); + // With --splitting a JS file is in one chunk. Each chunk that uses the class names has its own copy of them. test.each([false, true])("list a stylesheet in each chunk that prints it (splitting: %p)", async splitting => { const printed = { @@ -1179,44 +1259,40 @@ describe.concurrent("metafile outputs[..].inputs of a JS chunk", () => { expect(codeOf(js, "a.module.css")).toMatch(classNames); }); - // esbuild lists such a stylesheet with 0 bytes. Each of these builds has the rules of the stylesheet in its CSS output. - test.each([ - { - use: "a bare import", - files: { - "entry.js": `import "./a.module.css";\nconsole.log("entry");\n`, - "a.module.css": `.foo { color: red; }\n`, - }, - }, - { - use: "a default import that nothing reads", - files: { - "entry.js": `import styles from "./a.module.css";\nconsole.log("entry");\n`, - "a.module.css": `.foo { color: red; }\n`, - }, - }, - { - // The `require()` makes the stylesheet a wrapped file, which the chunk prints in one piece. Nothing calls the wrapper. - use: "a bare import and a require() that nothing calls, of a package with no side effects", - files: { - "entry.js": `import "./own.css";\nimport "kit/a.module.css";\nfunction dead() {\n return require("kit/a.module.css");\n}\nconsole.log("entry");\n`, - "own.css": `@import "kit/a.module.css";\n.own { color: red; }\n`, - "node_modules/kit/package.json": JSON.stringify({ name: "kit", version: "1.0.0", sideEffects: false }), - "node_modules/kit/a.module.css": `.foo { color: red; }\n`, - }, - }, - ])("do not list a stylesheet that the chunk prints nothing of ($use)", async ({ files }) => { - using dir = tempDir("metafile-js-inputs-stylesheet-unused", files); + /** The inputs of the JS output of a build of `entry.js`. That output must be the code of `entry.js` alone. */ + async function jsInputsOfEntryAlone(files: Record) { + using dir = tempDir("metafile-js-inputs-stylesheet-no-code", files); const result = await Bun.build({ entrypoints: [`${dir}/entry.js`], metafile: true }); expect(result.success).toBe(true); - // The JS output is the comment of the entry point and its code. expect((await textOf(result, ".js")).replace(/^\/\/ .+\n/, "")).toBe(`console.log("entry");\n`); + // The build has the stylesheet: its rules are in the CSS output. const rules = (await textOf(result, ".css")).match(/^\.foo_[^ ]+ \{\n color: red;\n\}\n/m)![0]; const inputs = inputsByOutput(result.metafile as Metafile); - expect(inputs["entry.js"]).toEqual([["entry.js", `console.log("entry");\n`.length]]); expect(inputs["entry.css"]).toContainEqual(["a.module.css", rules.length]); + return inputs["entry.js"]; + } + + // esbuild lists the stylesheet with 0 bytes in these two JS outputs. + test.each([ + ["a bare import", `import "./a.module.css";\nconsole.log("entry");\n`], + ["a default import that nothing reads", `import styles from "./a.module.css";\nconsole.log("entry");\n`], + ])("do not list a stylesheet that the chunk prints nothing of (%s)", async (_, entry) => { + const files = { "entry.js": entry, "a.module.css": `.foo { color: red; }\n` }; + expect(await jsInputsOfEntryAlone(files)).toEqual([["entry.js", `console.log("entry");\n`.length]]); + }); + + // The `require()` makes the stylesheet a wrapped file, so the chunk has a piece for it. Nothing calls the wrapper, + // and the piece is empty. esbuild keeps the wrapper, and lists the stylesheet with the bytes of it. + test("do not list a wrapped stylesheet whose wrapper the chunk drops", async () => { + const files = { + "entry.js": `import "./own.css";\nimport "kit/a.module.css";\nfunction dead() {\n return require("kit/a.module.css");\n}\nconsole.log("entry");\n`, + "own.css": `@import "kit/a.module.css";\n.own { color: red; }\n`, + "node_modules/kit/package.json": JSON.stringify({ name: "kit", version: "1.0.0", sideEffects: false }), + "node_modules/kit/a.module.css": `.foo { color: red; }\n`, + }; + expect(await jsInputsOfEntryAlone(files)).toEqual([["entry.js", `console.log("entry");\n`.length]]); }); test("bun build --metafile --metafile-md list a stylesheet that the chunk prints code of", async () => {