feat(bundler): add modulepreload links for chunk dependencies in HTML

When using --splitting with HTML entry points, Bun now automatically adds
<link rel="modulepreload"> 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 <noreply@anthropic.com>
This commit is contained in:
Claude Bot
2025-09-25 09:26:16 +00:00
parent e555702653
commit bbd8381b0b
2 changed files with 167 additions and 8 deletions

View File

@@ -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, "<link rel=\"stylesheet\" crossorigin href=\"{s}\">", .{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, "<link rel=\"modulepreload\" crossorigin href=\"{s}\">", .{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, "<link rel=\"modulepreload\" crossorigin href=\"{s}\">", .{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, "<script type=\"module\" crossorigin src=\"{s}\"></script>", .{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);
}

View File

@@ -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": `
<!DOCTYPE html>
<html>
<head>
<title>Page 1</title>
<script type="module" src="./page1.js"></script>
</head>
<body>
<h1>Page 1</h1>
</body>
</html>`,
"/page2.html": `
<!DOCTYPE html>
<html>
<head>
<title>Page 2</title>
<script type="module" src="./page2.js"></script>
</head>
<body>
<h1>Page 2</h1>
</body>
</html>`,
"/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": `
<!DOCTYPE html>
<html>
<head>
<title>Main</title>
<script type="module" src="./main.js"></script>
</head>
<body>
<h1>Main</h1>
</body>
</html>`,
"/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);
},
});
});