mirror of
https://github.com/oven-sh/bun
synced 2026-02-09 10:28:47 +00:00
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:
@@ -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);
|
||||
}
|
||||
|
||||
130
test/bundler/bundler_html_modulepreload.test.ts
Normal file
130
test/bundler/bundler_html_modulepreload.test.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user