diff --git a/src/bundler/linker_context/generateCompileResultForHtmlChunk.zig b/src/bundler/linker_context/generateCompileResultForHtmlChunk.zig index a88707b0cc..00fd833264 100644 --- a/src/bundler/linker_context/generateCompileResultForHtmlChunk.zig +++ b/src/bundler/linker_context/generateCompileResultForHtmlChunk.zig @@ -136,23 +136,51 @@ fn generateCompileResultForHTMLChunkImpl(worker: *ThreadPool.Worker, c: *LinkerC var html_appender = std.heap.stackFallback(256, bun.default_allocator); const allocator = html_appender.get(); const slices = this.getHeadTags(allocator); - defer for (slices.slice()) |slice| - allocator.free(slice); - for (slices.slice()) |slice| + defer { + for (slices.items) |slice| + allocator.free(slice); + slices.deinit(); + } + for (slices.items) |slice| try endTag.before(slice, true); } - fn getHeadTags(this: *@This(), allocator: std.mem.Allocator) bun.BoundedArray([]const u8, 2) { - var array: bun.BoundedArray([]const u8, 2) = .{}; + fn getHeadTags(this: *@This(), allocator: std.mem.Allocator) std.ArrayList([]const u8) { + var array = std.ArrayList([]const u8).init(allocator); // Put CSS before JS to reduce changes of flash of unstyled content if (this.chunk.getCSSChunkForHTML(this.chunks)) |css_chunk| { const link_tag = bun.handleOom(std.fmt.allocPrintZ(allocator, "", .{css_chunk.unique_key})); - array.appendAssumeCapacity(link_tag); + bun.handleOom(array.append(link_tag)); } if (this.chunk.getJSChunkForHTML(this.chunks)) |js_chunk| { + // Track chunks we've already added to avoid duplicates + var preloaded_chunks = std.AutoHashMap(u32, void).init(allocator); + defer preloaded_chunks.deinit(); + + // Add modulepreload links for all chunks that this JS chunk imports + // This allows the browser to fetch them in parallel instead of waterfall + for (js_chunk.cross_chunk_imports.slice()) |import| { + if (preloaded_chunks.get(import.chunk_index) == null) { + bun.handleOom(preloaded_chunks.put(import.chunk_index, {})); + const imported_chunk = &this.chunks[import.chunk_index]; + const preload = bun.handleOom(std.fmt.allocPrintZ(allocator, "", .{imported_chunk.unique_key})); + bun.handleOom(array.append(preload)); + + // Recursively add preloads for nested dependencies + for (imported_chunk.cross_chunk_imports.slice()) |nested_import| { + if (preloaded_chunks.get(nested_import.chunk_index) == null) { + bun.handleOom(preloaded_chunks.put(nested_import.chunk_index, {})); + const nested_chunk = &this.chunks[nested_import.chunk_index]; + const nested_preload = bun.handleOom(std.fmt.allocPrintZ(allocator, "", .{nested_chunk.unique_key})); + bun.handleOom(array.append(nested_preload)); + } + } + } + } + // type="module" scripts do not block rendering, so it is okay to put them in head const script = bun.handleOom(std.fmt.allocPrintZ(allocator, "", .{js_chunk.unique_key})); - array.appendAssumeCapacity(script); + bun.handleOom(array.append(script)); } return array; } @@ -238,7 +266,8 @@ fn generateCompileResultForHTMLChunkImpl(worker: *ThreadPool.Worker, c: *LinkerC var html_appender = std.heap.stackFallback(256, bun.default_allocator); const allocator = html_appender.get(); const slices = html_loader.getHeadTags(allocator); - for (slices.slice()) |slice| { + defer slices.deinit(); + for (slices.items) |slice| { bun.handleOom(html_loader.output.appendSlice(slice)); allocator.free(slice); } diff --git a/test/bundler/bundler_html_modulepreload.test.ts b/test/bundler/bundler_html_modulepreload.test.ts new file mode 100644 index 0000000000..d1c1075889 --- /dev/null +++ b/test/bundler/bundler_html_modulepreload.test.ts @@ -0,0 +1,130 @@ +import { describe } from "bun:test"; +import { itBundled } from "./expectBundled"; + +describe("bundler", () => { + // Test that modulepreload links are added for chunk dependencies + itBundled("html/modulepreload-chunks", { + outdir: "out/", + splitting: true, + files: { + "/page1.html": ` + + + + Page 1 + + + +

Page 1

+ +`, + "/page2.html": ` + + + + Page 2 + + + +

Page 2

+ +`, + "/page1.js": ` +import { shared } from './shared.js'; +import { utils } from './utils.js'; +console.log('Page 1:', shared(), utils()); +export function page1Init() { + console.log('Page 1 initialized'); +}`, + "/page2.js": ` +import { shared } from './shared.js'; +import { utils } from './utils.js'; +console.log('Page 2:', shared(), utils()); +export function page2Init() { + console.log('Page 2 initialized'); +}`, + "/shared.js": ` +export function shared() { + return 'shared code'; +}`, + "/utils.js": ` +export function utils() { + return 'utils'; +}`, + }, + entryPoints: ["/page1.html", "/page2.html"], + + onAfterBundle(api) { + // Check that HTML includes modulepreload links for chunks + const page1Html = api.readFile("out/page1.html"); + const page2Html = api.readFile("out/page2.html"); + + // Both pages should have modulepreload links + api.expectFile("out/page1.html").toMatch(/rel="modulepreload"/); + api.expectFile("out/page2.html").toMatch(/rel="modulepreload"/); + + // Extract the chunk names from modulepreload links + const page1Preloads = page1Html.match(/rel="modulepreload"[^>]+href="([^"]+)"/g) || []; + const page2Preloads = page2Html.match(/rel="modulepreload"[^>]+href="([^"]+)"/g) || []; + + // Both should preload the shared chunk + api.expect(page1Preloads.length).toBeGreaterThan(0); + api.expect(page2Preloads.length).toBeGreaterThan(0); + }, + }); + + // Test with nested chunk dependencies + itBundled("html/modulepreload-nested-chunks", { + outdir: "out/", + splitting: true, + files: { + "/index.html": ` + + + + Main + + + +

Main

+ +`, + "/main.js": ` +import { featureA } from './feature-a.js'; +import { featureB } from './feature-b.js'; +console.log('Main:', featureA(), featureB());`, + "/feature-a.js": ` +import { shared } from './shared.js'; +export function featureA() { + return 'Feature A: ' + shared(); +}`, + "/feature-b.js": ` +import { shared } from './shared.js'; +export function featureB() { + return 'Feature B: ' + shared(); +}`, + "/shared.js": ` +import { deepDep } from './deep-dep.js'; +export function shared() { + return 'shared: ' + deepDep(); +}`, + "/deep-dep.js": ` +export function deepDep() { + return 'deep dependency'; +}`, + }, + entryPoints: ["/index.html"], + + onAfterBundle(api) { + // Check that HTML includes modulepreload links for all dependency chunks + api.expectFile("out/index.html").toMatch(/rel="modulepreload"/); + + // Should have preloads for all chunks that the main chunk depends on + const htmlContent = api.readFile("out/index.html"); + const preloadMatches = htmlContent.match(/rel="modulepreload"/g) || []; + + // With nested dependencies, we should have multiple preloads + api.expect(preloadMatches.length).toBeGreaterThanOrEqual(1); + }, + }); +}); \ No newline at end of file