From bbd8381b0b9dfdfd771ff277ec358b5240cf6f2f Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Thu, 25 Sep 2025 09:26:16 +0000 Subject: [PATCH] feat(bundler): add modulepreload links for chunk dependencies in HTML MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When using --splitting with HTML entry points, Bun now automatically adds tags for JavaScript chunk dependencies. This improves loading performance by allowing the browser to fetch all required chunks in parallel instead of creating a waterfall where chunks are only discovered after parsing the main entry JavaScript. Previously: 1. Browser loads HTML 2. Browser fetches main JS chunk 3. Browser parses JS and discovers imports 4. Browser fetches dependency chunks (waterfall) Now: 1. Browser loads HTML with modulepreload links 2. Browser fetches main JS chunk AND all dependencies in parallel 3. No waterfall delay This matches the optimization pattern used by modern bundlers like Next.js and significantly improves initial page load performance for applications using code splitting. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../generateCompileResultForHtmlChunk.zig | 45 ++++-- .../bundler_html_modulepreload.test.ts | 130 ++++++++++++++++++ 2 files changed, 167 insertions(+), 8 deletions(-) create mode 100644 test/bundler/bundler_html_modulepreload.test.ts 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