From d1a0e9a450907ce97b3495912d54f7d9ede9dd10 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Sat, 14 Feb 2026 23:35:36 +0000 Subject: [PATCH 1/2] fix(bundler): include CSS in all HTML entrypoints sharing a deduplicated CSS chunk When multiple HTML entrypoints shared the same CSS file with `--production`, only the first HTML file included the `` tag. This was caused by two issues: 1. `getCSSChunkForHTML` matched CSS chunks by `entry_point_id`, but deduplicated CSS chunks only stored the first entry point's ID. Fixed by looking up CSS chunks through the JS chunk's `css_chunks` indices instead. 2. The CSS chunk index remapping used `js_chunks.count()` as the starting offset, which didn't account for HTML chunks interleaved with JS chunks in the sorted array. Fixed by using `sorted_chunks.len` instead. Closes #23668 Co-Authored-By: Claude --- src/bundler/Chunk.zig | 10 ++++ src/bundler/linker_context/computeChunks.zig | 5 +- test/bundler/bundler_html.test.ts | 56 ++++++++++++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/bundler/Chunk.zig b/src/bundler/Chunk.zig index d4eece7c71b4..3b822ff6db75 100644 --- a/src/bundler/Chunk.zig +++ b/src/bundler/Chunk.zig @@ -68,6 +68,16 @@ pub const Chunk = struct { } pub fn getCSSChunkForHTML(this: *const Chunk, chunks: []Chunk) ?*Chunk { + // Look up the CSS chunk via the JS chunk's css_chunks indices. + // This correctly handles deduplicated CSS chunks that are shared + // across multiple HTML entry points (see issue #23668). + if (this.getJSChunkForHTML(chunks)) |js_chunk| { + const css_chunk_indices = js_chunk.content.javascript.css_chunks; + if (css_chunk_indices.len > 0) { + return &chunks[css_chunk_indices[0]]; + } + } + // Fallback: match by entry_point_id for cases without a JS chunk. const entry_point_id = this.entry_point.entry_point_id; for (chunks) |*other| { if (other.content == .css) { diff --git a/src/bundler/linker_context/computeChunks.zig b/src/bundler/linker_context/computeChunks.zig index c2bb9d7fdac1..1182b8b05fb3 100644 --- a/src/bundler/linker_context/computeChunks.zig +++ b/src/bundler/linker_context/computeChunks.zig @@ -322,7 +322,10 @@ pub noinline fn computeChunks( const remapped_css_indexes = try temp_allocator.alloc(u32, css_chunks.count()); const css_chunk_values = css_chunks.values(); - for (sorted_css_keys, js_chunks.count()..) |key, sorted_index| { + // Use sorted_chunks.len as the starting index because HTML chunks + // may be interleaved with JS chunks, so js_chunks.count() would be + // incorrect when HTML entry points are present. + for (sorted_css_keys, sorted_chunks.len..) |key, sorted_index| { const index = css_chunks.getIndex(key) orelse unreachable; sorted_chunks.appendAssumeCapacity(css_chunk_values[index]); remapped_css_indexes[index] = @intCast(sorted_index); diff --git a/test/bundler/bundler_html.test.ts b/test/bundler/bundler_html.test.ts index f69e512381f3..1eb44b7d6806 100644 --- a/test/bundler/bundler_html.test.ts +++ b/test/bundler/bundler_html.test.ts @@ -843,4 +843,60 @@ body { api.expectFile("out/" + jsFile).toContain("sourceMappingURL"); }, }); + + // Test that multiple HTML entrypoints sharing the same CSS file both get + // the CSS link tag in production mode (css_chunking deduplication). + // Regression test for https://github.com/oven-sh/bun/issues/23668 + itBundled("html/SharedCSSProductionMultipleEntries", { + outdir: "out/", + production: true, + files: { + "/entry1.html": ` + + + + + +
+ + +`, + "/entry2.html": ` + + + + + +
+ + +`, + "/global.css": `h1 { font-size: 24px; }`, + "/main1.tsx": `console.log("entry1");`, + "/main2.tsx": `console.log("entry2");`, + }, + entryPoints: ["/entry1.html", "/entry2.html"], + onAfterBundle(api) { + const entry1Html = api.readFile("out/entry1.html"); + const entry2Html = api.readFile("out/entry2.html"); + + // Both HTML files must contain a CSS link tag + const cssMatch1 = entry1Html.match(/href="(.*\.css)"/); + const cssMatch2 = entry2Html.match(/href="(.*\.css)"/); + + expect(cssMatch1).not.toBeNull(); + expect(cssMatch2).not.toBeNull(); + + // Both should reference the same deduplicated CSS chunk + expect(cssMatch1![1]).toBe(cssMatch2![1]); + + // The CSS file should contain the shared styles + const cssContent = api.readFile("out/" + cssMatch1![1]); + expect(cssContent).toContain("font-size"); + + // Both HTML files should also have their respective JS bundles + expect(entry1Html).toMatch(/src=".*\.js"/); + expect(entry2Html).toMatch(/src=".*\.js"/); + }, + }); }); From f0cae21ab1f55cb4ea9e8a8d619c7a0f46d9d239 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Sun, 15 Feb 2026 09:12:56 +0000 Subject: [PATCH 2/2] fix(bundler): always deduplicate CSS chunks by content for JS entry points Use content-based hashing for CSS chunk deduplication even when css_chunking is disabled. This prevents "Multiple files share the same output path" errors when multiple JS entry points import the same CSS and hash-based entry naming is used. Unskips the css/MetafileCSSBundleTwoToOne test. Co-Authored-By: Claude --- src/bundler/linker_context/computeChunks.zig | 10 +++++----- test/bundler/esbuild/css.test.ts | 5 +---- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/bundler/linker_context/computeChunks.zig b/src/bundler/linker_context/computeChunks.zig index 1182b8b05fb3..db9e9cb3153e 100644 --- a/src/bundler/linker_context/computeChunks.zig +++ b/src/bundler/linker_context/computeChunks.zig @@ -22,7 +22,6 @@ pub noinline fn computeChunks( const entry_source_indices = this.graph.entry_points.items(.source_index); const css_asts = this.graph.ast.items(.css); - const css_chunking = this.options.css_chunking; var html_chunks = bun.StringArrayHashMap(Chunk).init(temp_allocator); const loaders = this.parse_graph.input_files.items(.loader); const ast_targets = this.graph.ast.items(.target); @@ -148,10 +147,11 @@ pub noinline fn computeChunks( if (css_source_indices.len > 0) { const order = this.findImportedFilesInCSSOrder(temp_allocator, css_source_indices.slice()); - const use_content_based_key = css_chunking or has_server_html_imports; - const hash_to_use = if (!use_content_based_key) - bun.hash(try temp_allocator.dupe(u8, entry_bits.bytes(this.graph.entry_points.len))) - else brk: { + // Always use content-based hashing for CSS chunk deduplication. + // This ensures that when multiple JS entry points import the + // same CSS files, they share a single CSS output chunk rather + // than producing duplicates that collide on hash-based naming. + const hash_to_use = brk: { var hasher = std.hash.Wyhash.init(5); bun.writeAnyToHasher(&hasher, order.len); for (order.slice()) |x| x.hash(&hasher); diff --git a/test/bundler/esbuild/css.test.ts b/test/bundler/esbuild/css.test.ts index 8b4eb0311538..504cc61ce975 100644 --- a/test/bundler/esbuild/css.test.ts +++ b/test/bundler/esbuild/css.test.ts @@ -2150,10 +2150,7 @@ c { toplevel-tilde.css: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10) `, */ }); - // TODO: Bun's bundler doesn't support multiple entry points generating CSS outputs - // with identical content hashes to the same output path. This test exposes that - // limitation. Skip until the bundler can deduplicate or handle this case. - itBundled.skip("css/MetafileCSSBundleTwoToOne", { + itBundled("css/MetafileCSSBundleTwoToOne", { files: { "/foo/entry.js": /* js */ ` import '../common.css'