mirror of
https://github.com/oven-sh/bun
synced 2026-02-17 14:22:01 +00:00
fix(bundler): include HTML-only asset references in HTMLBundle files array
Images and other assets referenced only via HTML tags (<img src>, <link rel="icon">, etc.) were missing from the HTMLBundle manifest's files array. These assets use .url import records which don't propagate entry_bits through the linker's code splitting graph, causing the intersection check in HTMLImportManifest to exclude them. The fix builds a set of source indices directly referenced by the HTML file's import records and includes matching additional output files in the manifest alongside the existing entry_bits check. Closes #27031 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -165,6 +165,19 @@ pub fn write(index: u32, graph: *const Graph, linker_graph: *const LinkerGraph,
|
||||
var already_visited_output_file = try bun.bit_set.AutoBitSet.initEmpty(bun.default_allocator, additional_output_files.len);
|
||||
defer already_visited_output_file.deinit(bun.default_allocator);
|
||||
|
||||
// Build a set of source indices directly referenced by the HTML file's import records.
|
||||
// Assets referenced only via HTML tags (e.g. <img src>, <link rel="icon">) use .url
|
||||
// import records which don't propagate entry_bits through the linker's code splitting
|
||||
// graph. We need to include these files in the manifest so they are served correctly.
|
||||
const import_records = graph.ast.items(.import_records);
|
||||
var html_referenced_sources = try bun.bit_set.AutoBitSet.initEmpty(bun.default_allocator, graph.input_files.len);
|
||||
defer html_referenced_sources.deinit(bun.default_allocator);
|
||||
for (import_records[browser_source_index].slice()) |*record| {
|
||||
if (record.source_index.isValid()) {
|
||||
html_referenced_sources.set(record.source_index.get());
|
||||
}
|
||||
}
|
||||
|
||||
// Write all chunks that have files associated with this entry point.
|
||||
// Also include browser chunks from server builds (lazy-loaded chunks from dynamic imports).
|
||||
// When there's only one HTML import, all browser chunks belong to that manifest.
|
||||
@@ -219,7 +232,7 @@ pub fn write(index: u32, graph: *const Graph, linker_graph: *const LinkerGraph,
|
||||
if (source_index.get() == server_source_index) continue;
|
||||
const bits: *const AutoBitSet = &file_entry_bits[source_index.get()];
|
||||
|
||||
if (bits.hasIntersection(&entry_point_bits)) {
|
||||
if (bits.hasIntersection(&entry_point_bits) or html_referenced_sources.isSet(source_index.get())) {
|
||||
already_visited_output_file.set(i);
|
||||
if (!first) try writer.writeAll(",");
|
||||
first = false;
|
||||
|
||||
214
test/regression/issue/27031.test.ts
Normal file
214
test/regression/issue/27031.test.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { describe, expect } from "bun:test";
|
||||
import { itBundled } from "../../bundler/expectBundled";
|
||||
|
||||
// Small valid PNG bytes for test assets
|
||||
const pngBytes = Buffer.from([
|
||||
0x89,
|
||||
0x50,
|
||||
0x4e,
|
||||
0x47,
|
||||
0x0d,
|
||||
0x0a,
|
||||
0x1a,
|
||||
0x0a, // PNG header
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x0d,
|
||||
0x49,
|
||||
0x48,
|
||||
0x44,
|
||||
0x52, // IHDR chunk
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x10,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x10, // 16x16
|
||||
0x08,
|
||||
0x02,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x90,
|
||||
0x91,
|
||||
0x68, // 8-bit RGB
|
||||
0x36,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x49,
|
||||
0x45,
|
||||
0x4e, // IEND chunk
|
||||
0x44,
|
||||
0xae,
|
||||
0x42,
|
||||
0x60,
|
||||
0x82,
|
||||
]);
|
||||
|
||||
// Different content so each file gets a unique hash
|
||||
const pngBytes2 = Buffer.from([
|
||||
0x89,
|
||||
0x50,
|
||||
0x4e,
|
||||
0x47,
|
||||
0x0d,
|
||||
0x0a,
|
||||
0x1a,
|
||||
0x0a,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x0d,
|
||||
0x49,
|
||||
0x48,
|
||||
0x44,
|
||||
0x52,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x20,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x20, // 32x32
|
||||
0x08,
|
||||
0x02,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0xfc,
|
||||
0x18,
|
||||
0xed,
|
||||
0xa3,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x49,
|
||||
0x45,
|
||||
0x4e,
|
||||
0x44,
|
||||
0xae,
|
||||
0x42,
|
||||
0x60,
|
||||
0x82,
|
||||
]);
|
||||
|
||||
const pngBytes3 = Buffer.from([
|
||||
0x89,
|
||||
0x50,
|
||||
0x4e,
|
||||
0x47,
|
||||
0x0d,
|
||||
0x0a,
|
||||
0x1a,
|
||||
0x0a,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x0d,
|
||||
0x49,
|
||||
0x48,
|
||||
0x44,
|
||||
0x52,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x08,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x08, // 8x8
|
||||
0x08,
|
||||
0x02,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x4b,
|
||||
0x6d,
|
||||
0x29,
|
||||
0xde,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x49,
|
||||
0x45,
|
||||
0x4e,
|
||||
0x44,
|
||||
0xae,
|
||||
0x42,
|
||||
0x60,
|
||||
0x82,
|
||||
]);
|
||||
|
||||
describe.concurrent("bundler", () => {
|
||||
// Regression test: Images referenced only via HTML tags should appear in
|
||||
// HTMLBundle.files array, not just images imported via JavaScript.
|
||||
// https://github.com/oven-sh/bun/issues/27031
|
||||
itBundled("html-import/html-only-asset-references", {
|
||||
outdir: "out/",
|
||||
files: {
|
||||
"/server.js": `
|
||||
import html from "./index.html";
|
||||
|
||||
const manifest = html;
|
||||
|
||||
// All three images should be in the files array
|
||||
const fileLoaders = manifest.files.map(f => f.loader);
|
||||
const fileInputs = manifest.files.map(f => f.input);
|
||||
|
||||
// logo.png and banner.png are only referenced via HTML tags, not JS imports
|
||||
// icon.png is imported via both HTML and JS
|
||||
const hasLogo = fileInputs.some(i => i === "logo.png");
|
||||
const hasBanner = fileInputs.some(i => i === "banner.png");
|
||||
const hasIcon = fileInputs.some(i => i === "icon.png");
|
||||
|
||||
if (!hasLogo) throw new Error("logo.png missing from manifest files (referenced via <img src>)");
|
||||
if (!hasBanner) throw new Error("banner.png missing from manifest files (referenced via <img src>)");
|
||||
if (!hasIcon) throw new Error("icon.png missing from manifest files (referenced via <link rel=icon> and JS import)");
|
||||
|
||||
// All image files should have loader "file"
|
||||
const imageFiles = manifest.files.filter(f => f.path.includes(".png"));
|
||||
for (const img of imageFiles) {
|
||||
if (img.loader !== "file") throw new Error("Expected loader 'file' for " + img.path + ", got " + img.loader);
|
||||
if (!img.headers || !img.headers["content-type"]) throw new Error("Missing content-type header for " + img.path);
|
||||
}
|
||||
|
||||
console.log("OK: " + imageFiles.length + " image files in manifest");
|
||||
`,
|
||||
"/index.html": `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<link rel="icon" href="./icon.png" />
|
||||
</head>
|
||||
<body>
|
||||
<img src="./logo.png" alt="Logo" />
|
||||
<img src="./banner.png" alt="Banner" />
|
||||
<script type="module" src="./app.js"></script>
|
||||
</body>
|
||||
</html>`,
|
||||
"/app.js": `
|
||||
import icon from './icon.png';
|
||||
console.log("Icon imported via JS:", icon);
|
||||
`,
|
||||
"/logo.png": pngBytes,
|
||||
"/banner.png": pngBytes2,
|
||||
"/icon.png": pngBytes3,
|
||||
},
|
||||
entryPoints: ["/server.js"],
|
||||
target: "bun",
|
||||
|
||||
run: {
|
||||
validate({ stdout }) {
|
||||
expect(stdout).toContain("OK: 3 image files in manifest");
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user