perf, http: Preload modules

Similar to https://github.com/lightpanda-io/browser/pull/2675. That PR added
support for rel=preload, this one adds it for rel=modulepreload. The impact here
is generally less significant. Modules were already asynchronously preloaded
as part of the module-loading flow. What this does is potentially start the
preload earlier (i.e. when the link is encountered).  You can imagine:

// pseudo-html
<link rel=modulepreload href=mod1.js>
<link rel=modulepreload href=mod2.j2>

<script src=s1.js></script>
<script src=s2.js></script>

If s1.js imports mod1 and mod2, then there won't be a huge difference...I mean
mod1 and mod2 can start loading before s1.js is complete, but it won't be huge.
If s1.js doesn't import mod1 and/or mod2, but s2.js does, then the gain is a
little more (because now we have to wait for both s1 and s2 to load). So it
really depends.

This commit also better manages preloaded scripts. There were common cases where
a preloaded script might remain preloaded until page teardown..well past its
actual usage. This happened because, while every preload increments the waiters
count, only the first to fetch it decrements it. Once cached, waiters was never
decremented. This is now fixed.
This commit is contained in:
Karl Seguin
2026-06-11 14:19:31 +08:00
parent d695ce10c3
commit a70c3cefb8
14 changed files with 199 additions and 26 deletions

View File

@@ -1714,6 +1714,23 @@ pub fn preloadScriptHint(self: *Frame, href: []const u8) void {
self._script_manager.preloadScript(resolved) catch {};
}
// start prefetching <link rel="modulepreload" href=...>
pub fn preloadModuleHint(self: *Frame, href: []const u8) void {
if (self.isGoingAway() or self._parse_mode == .fragment) {
return;
}
// The url becomes the imported_modules key, which must outlive the fetch
// so it lives on the frame arena
const resolved = URL.resolve(self.arena, self.base(), href, .{ .encoding = self.charset }) catch return;
if (!std.ascii.startsWithIgnoreCase(resolved, "http:") and !std.ascii.startsWithIgnoreCase(resolved, "https:")) {
// data:/blob: are synthesized locally — no round-trip to hide.
return;
}
self._script_manager.base.preloadModuleHint(resolved, self.url) catch {};
}
// Synchronously fetch and parse an external `<link rel=stylesheet>`.
// href is passed in as an optimization since the [currently] only callsite has
// it, so why look it up again?

View File

@@ -217,10 +217,19 @@ pub fn resolveSpecifier(self: *ScriptManagerBase, arena: Allocator, base: [:0]co
return error.SpecifierResolutionFailed;
}
pub fn preloadImport(self: *ScriptManagerBase, url: [:0]const u8, referrer: []const u8) !void {
const PreloadOpts = struct {
hint: bool = false,
};
pub fn preloadImport(self: *ScriptManagerBase, url: [:0]const u8, referrer: []const u8, opts: PreloadOpts) !void {
const gop = try self.imported_modules.getOrPut(self.allocator, url);
if (gop.found_existing) {
gop.value_ptr.waiters += 1;
if (gop.value_ptr.hint) {
// A <link rel=modulepreload> never calls waitForImport, so the
// first real import adopts its waiter slot rather than adding one.
gop.value_ptr.hint = false;
} else {
gop.value_ptr.waiters += 1;
}
return;
}
errdefer _ = self.imported_modules.remove(url);
@@ -239,7 +248,7 @@ pub fn preloadImport(self: *ScriptManagerBase, url: [:0]const u8, referrer: []co
.extra = .import,
};
gop.value_ptr.* = ImportedModule{};
gop.value_ptr.* = .{ .state = .{ .loading = script }, .hint = opts.hint };
if (comptime IS_DEBUG) {
var ls: js.Local.Scope = undefined;
@@ -284,6 +293,15 @@ pub fn preloadImport(self: *ScriptManagerBase, url: [:0]const u8, referrer: []co
};
}
// <link rel=modulepreload href=...> — start fetching a module before import
// resolution discovers it.
pub fn preloadModuleHint(self: *ScriptManagerBase, url: [:0]const u8, referrer: []const u8) !void {
if (self.imported_modules.contains(url)) {
return;
}
try self.preloadImport(url, referrer, .{ .hint = true });
}
pub fn waitForImport(self: *ScriptManagerBase, url: [:0]const u8) !ModuleSource {
const entry = self.imported_modules.getEntry(url) orelse {
// It shouldn't be possible for v8 to ask for a module that we didn't
@@ -324,7 +342,57 @@ pub fn waitForImport(self: *ScriptManagerBase, url: [:0]const u8) !ModuleSource
}
}
pub fn releaseImport(self: *ScriptManagerBase, url: [:0]const u8) void {
const entry = self.imported_modules.getEntry(url) orelse {
return;
};
if (entry.value_ptr.waiters > 1) {
entry.value_ptr.waiters -= 1;
return;
}
switch (entry.value_ptr.state) {
.done => |script| script.deinit(),
.loading, .err => return,
}
self.imported_modules.removeByPtr(entry.key_ptr);
}
pub fn getAsyncImport(self: *ScriptManagerBase, url: [:0]const u8, cb: ImportAsync.Callback, cb_data: *anyopaque, referrer: []const u8) !void {
// A <link rel=modulepreload> hint may already be fetching/fetched this module
if (self.imported_modules.getEntry(url)) |entry| {
if (entry.value_ptr.hint) {
switch (entry.value_ptr.state) {
.loading => |script| {
// fetch is in flight, take the script and turn it into
// our normal getAsyncImport flow (e.g. what we do at the
// end of this file as-if imported_modules didn't have this script)
if (comptime IS_DEBUG) {
log.debug(.http, "script adopt", .{ .url = url, .ctx = "dynamic module", .state = "loading" });
}
script.extra = .{ .import_async = .{ .callback = cb, .data = cb_data } };
self.imported_modules.removeByPtr(entry.key_ptr);
return;
},
.done => |script| {
// fetch is complete; deliver through the normal
// ready_scripts flow. evaluate() runs it now, or — if an
// evaluation window is open — evaluate_pending runs it
// when that window closes.
if (comptime IS_DEBUG) {
log.debug(.http, "script adopt", .{ .url = url, .ctx = "dynamic module", .state = "done" });
}
script.extra = .{ .import_async = .{ .callback = cb, .data = cb_data } };
self.imported_modules.removeByPtr(entry.key_ptr);
self.ready_scripts.append(&script.node);
self.evaluate();
return;
},
// The hint's fetch failed; give the import its own attempt.
.err => {},
}
}
}
const arena = try self.acquireArena(.large, "SM.getAsyncImport");
errdefer self.releaseArena(arena);
@@ -890,12 +958,17 @@ pub const ModuleSource = struct {
pub const ImportedModule = struct {
waiters: u16 = 1,
state: State = .loading,
// Created by a <link rel=modulepreload> hint and not yet claimed by a real
// import. While set, the single waiter slot belongs to the hint, which
// will never collect it (see preloadModuleHint). A dynamic import may
// adopt a hint entry outright (see getAsyncImport).
hint: bool = false,
state: State,
buffer: std.ArrayList(u8) = .{},
pub const State = union(enum) {
err,
loading,
loading: *Script,
done: *Script,
};
};

View File

@@ -516,7 +516,7 @@ fn postCompileModule(self: *Context, mod: js.Module, url: [:0]const u8, local: *
const owned_specifier = try self.arena.dupeZ(u8, normalized_specifier);
nested_gop.key_ptr.* = owned_specifier;
nested_gop.value_ptr.* = .{};
try script_manager.preloadImport(owned_specifier, url);
try script_manager.preloadImport(owned_specifier, url, .{});
} else if (nested_gop.value_ptr.module == null) {
// Entry exists but module failed to compile previously.
// The imported_modules entry may have been consumed, so
@@ -524,7 +524,7 @@ fn postCompileModule(self: *Context, mod: js.Module, url: [:0]const u8, local: *
// Key was stored via dupeZ so it has a sentinel in memory.
const key = nested_gop.key_ptr.*;
const key_z: [:0]const u8 = key.ptr[0..key.len :0];
try script_manager.preloadImport(key_z, url);
try script_manager.preloadImport(key_z, url, .{});
}
}
}
@@ -733,6 +733,11 @@ fn _resolveModuleCallback(self: *Context, referrer: js.Module, specifier: [:0]co
const entry = self.module_cache.getPtr(normalized_specifier).?;
if (entry.module) |m| {
// This import registered a waiter via preloadImport when it was discovered
// but the compiled module is already cached so we don't have to call
// waitForImport. Release our waiter so we no longer hold on waiter on
// the resource.
self.script_manager.releaseImport(normalized_specifier);
return local.toLocal(m).handle;
}
@@ -740,7 +745,7 @@ fn _resolveModuleCallback(self: *Context, referrer: js.Module, specifier: [:0]co
error.UnknownModule => blk: {
// Module is in cache but was consumed from imported_modules
// (e.g., by a previous failed resolution). Re-preload and retry.
try self.script_manager.preloadImport(normalized_specifier, referrer_path);
try self.script_manager.preloadImport(normalized_specifier, referrer_path, .{});
break :blk try self.script_manager.waitForImport(normalized_specifier);
},
else => return err,

View File

@@ -120,11 +120,15 @@
<script id="refs">
{
const rels = ['stylesheet', 'preload', 'modulepreload'];
// stylesheet fetching is off by default and a bare preload has no `as`, so
// neither touches the network. modulepreload actually fetches, so it needs
// a real file — the load event still fires synthetically either way.
const hrefs = ['/nope', '/nope', 'script/modulepreload.js'];
const results = rels.map(() => false);
rels.forEach((rel, i) => {
let link = document.createElement('link')
link.rel = rel;
link.href = '/nope';
link.href = hrefs[i];
link.onload = () => results[i] = true;
document.documentElement.appendChild(link);
});

View File

@@ -0,0 +1,57 @@
<!DOCTYPE html>
<script src="../../../testing.js"></script>
<!--
The modulepreload hint is declared before the module that imports it, so by
the time import resolution asks for modulepreload.js the fetch is already in
flight (or done). The hint registers no waiter of its own: the first real
preloadImport adopts its slot, so waitForImport transfers buffer ownership
exactly as if the hint never existed. Both halves of that handover run under
the leak detector here.
-->
<link rel="modulepreload" href="modulepreload.js">
<!--
A hinted module that nothing imports is fetched but never evaluated. It
lingers as a `.done` imported_modules entry until reset() frees it —
exercises the map cleanup under the leak detector — and it must not run on
its own, nor delay the load event.
-->
<link rel="modulepreload" href="modulepreload_unused.js">
<!-- A duplicate hint must not double-fetch or double-register a waiter. -->
<link rel="modulepreload" href="modulepreload.js">
<!--
The Vite pattern: a runtime-style hint immediately followed by a dynamic
import(). The hint's load event is synthetic, so the import fires while the
hint's fetch is still in flight — getAsyncImport adopts the loading Script
(converts it to .import_async) instead of fetching the module a second time.
-->
<link rel="modulepreload" href="modulepreload_dynamic.js">
<script id="dynamic_adopt_loading">
window.dynamic_val = '';
import('./modulepreload_dynamic.js').then((m) => { window.dynamic_val = m.dval; });
testing.onload(() => testing.expectEqual('dynamic-preloaded', window.dynamic_val));
</script>
<link rel="modulepreload" href="modulepreload_dynamic2.js">
<script id="consume" type="module">
import { val } from "./modulepreload.js";
testing.expectEqual('preloaded-module', val, {script_id: 'consume'});
</script>
<script id="dynamic_adopt_done" type="module">
// Deferred module: by the time this evaluates, the hint above has usually
// finished fetching — the import() adopts a .done entry and the body is
// delivered inline. (If the fetch is still in flight it exercises the
// loading-adoption path instead; both must produce the same result.)
const m = await import("./modulepreload_dynamic2.js");
testing.expectEqual('dynamic-preloaded-2', m.dval, {script_id: 'dynamic_adopt_done'});
</script>
<script id="unused_check">
// A hint that nothing imports must never evaluate.
testing.expectEqual(undefined, window.modulepreload_unused_ran);
</script>

View File

@@ -0,0 +1 @@
export const val = 'preloaded-module';

View File

@@ -0,0 +1 @@
export const dval = 'dynamic-preloaded';

View File

@@ -0,0 +1 @@
export const dval = 'dynamic-preloaded-2';

View File

@@ -0,0 +1,5 @@
// Nothing imports this module, so it must never be evaluated. If it runs, the
// flag trips the assertion in modulepreload.html (and this fail() fires
// directly).
window.modulepreload_unused_ran = true;
testing.fail('an unconsumed <link rel=modulepreload> must not evaluate');

View File

@@ -58,6 +58,18 @@
testing.expectEqual('a', getFromA());
</script>
<script id=diamond-imports type=module>
// a and b both statically import diamond-shared.js: two waiter
// registrations on one imported_modules entry, but only one waitForImport —
// the other resolution is satisfied from module_cache and must release its
// slot (Context._resolveModuleCallback) so the source buffer is freed at
// resolution, not at reset. Runs under the leak detector.
import { aShared } from "./modules/diamond-a.js";
import { bShared } from "./modules/diamond-b.js";
testing.expectEqual('diamond-shared', aShared);
testing.expectEqual('diamond-shared', bShared);
</script>
<script id=basic-async type=module>
import { "val1" as val1 } from "./mod1.js";
const m = await import("./mod1.js");

View File

@@ -0,0 +1 @@
export { sharedValue as aShared } from './diamond-shared.js';

View File

@@ -0,0 +1 @@
export { sharedValue as bShared } from './diamond-shared.js';

View File

@@ -0,0 +1 @@
export const sharedValue = 'diamond-shared';

View File

@@ -203,33 +203,27 @@ pub fn linkAddedCallback(self: *Link, frame: *Frame) !void {
const rel = element.getAttributeSafe(comptime .wrap("rel")) orelse return;
// Opt-in fetch for `rel="stylesheet"` — drives `frame.loadExternalStylesheet`,
// which fires the load/error event itself. Other rels (preload,
// modulepreload) and the disabled case keep the rendering-free stub that
// fires a synthetic `load` event without touching the network.
// which fires the load/error event itself. preload/modulepreload start a
// background fetch (a hint, never evaluated on its own) and fire a
// synthetic `load` event regardless of how the fetch goes.
if (std.mem.eql(u8, rel, "stylesheet")) {
return frame.loadExternalStylesheet(self, href);
}
var queue_load = false;
if (std.mem.eql(u8, rel, "preload")) {
const as = element.getAttributeSafe(comptime .wrap("as")) orelse "";
if (std.ascii.eqlIgnoreCase(as, "script")) {
frame.preloadScriptHint(href);
}
queue_load = true;
}
{
// this block just means we don't need to re-check rel for a type we
// already processed, e.g. "preload"
const loadable_rels = std.StaticStringMap(void).initComptime(.{
.{ "stylesheet", {} },
.{ "modulepreload", {} },
});
if (queue_load == false and loadable_rels.has(rel) == false) {
return;
} else if (std.mem.eql(u8, rel, "modulepreload")) {
// "as" default to script in this case
const as = element.getAttributeSafe(comptime .wrap("as")) orelse "";
if (as.len == 0 or std.ascii.eqlIgnoreCase(as, "script")) {
frame.preloadModuleHint(href);
}
} else {
// remaining rels (icon, canonical, ...): no fetch, no load event
return;
}
try frame.queueLoad(self._proto);