Merge pull request #2675 from lightpanda-io/script_preload

perf,http: Support for script preloading
This commit is contained in:
Karl Seguin
2026-06-10 07:43:42 +08:00
committed by GitHub
7 changed files with 252 additions and 28 deletions

View File

@@ -1697,6 +1697,23 @@ pub fn queueLoad(self: *Frame, html: *Element.Html) !void {
// splitting by route anyway).
const MAX_STYLESHEET_BYTES: usize = 2 * 1024 * 1024;
// start prefetching <link rel="preload" as="script" href=...>`
pub fn preloadScriptHint(self: *Frame, href: []const u8) void {
if (self.isGoingAway() or self._parse_mode == .fragment) {
return;
}
const arena = self.getArena(.small, "Frame.preloadScriptHint") catch return;
defer self.releaseArena(arena);
const resolved = URL.resolve(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.preloadScript(resolved) 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

@@ -46,25 +46,41 @@ frame: *Frame,
// "load" event).
frame_notified_of_completion: bool,
// scripts loaded based on a <link rel=preload as=script href=...> found during parsing
preloaded_scripts: std.StringHashMapUnmanaged(PreloadedScript),
pub fn init(allocator: Allocator, http_client: *HttpClient, frame: *Frame) ScriptManager {
var base = ScriptManagerBase.init(allocator, http_client, .{ .frame = frame });
base.tail_hook = tailHook;
return .{
.frame = frame,
.base = base,
.frame = frame,
.preloaded_scripts = .empty,
.frame_notified_of_completion = false,
};
}
pub fn deinit(self: *ScriptManager) void {
self.freePreloads();
self.base.deinit();
self.preloaded_scripts.deinit(self.base.allocator);
}
pub fn reset(self: *ScriptManager) void {
self.freePreloads();
self.preloaded_scripts.clearRetainingCapacity();
self.base.reset();
self.frame_notified_of_completion = false;
}
// Frees every preloaded Script
fn freePreloads(self: *ScriptManager) void {
var it = self.preloaded_scripts.valueIterator();
while (it.next()) |preload_script| {
preload_script.deinit();
}
}
// Frame wrapper uses this to fire documentIsLoaded and scriptsCompletedLoading
// once Base has finished processing its ready / defer queues.
pub fn tailHook(base: *ScriptManagerBase) void {
@@ -86,6 +102,81 @@ fn getHeaders(self: *ScriptManager) !HttpClient.Headers {
return self.base.getHeaders();
}
pub fn preloadScript(self: *ScriptManager, url: []const u8) !void {
if (self.preloaded_scripts.contains(url)) {
return;
}
const frame = self.frame;
const arena = try frame.getArena(.large, "SM.preloadScript");
errdefer frame.releaseArena(arena);
const owned_url = try arena.dupeZ(u8, url);
const script = try arena.create(Script);
script.* = .{
.arena = arena,
.url = owned_url,
.node = .{},
.manager = &self.base,
.complete = false,
.source = .{ .remote = .{} },
.extra = .preload,
};
try self.preloaded_scripts.putNoClobber(self.base.allocator, owned_url, .{ .state = .{ .loading = script } });
errdefer _ = self.preloaded_scripts.remove(owned_url);
if (comptime IS_DEBUG) {
log.debug(.http, "script queue", .{ .url = owned_url, .ctx = "preload" });
}
try frame.makeRequest(.{
.ctx = script,
.url = owned_url,
.method = .GET,
.frame_id = frame._frame_id,
.loader_id = frame._loader_id,
.headers = try self.base.getHeaders(),
.cookie_jar = &frame._session.cookie_jar,
.cookie_origin = frame.url,
.resource_type = .script,
.notification = frame._session.notification,
.start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null,
.header_callback = Script.headerCallback,
.data_callback = Script.dataCallback,
.done_callback = PreloadedScript.doneCallback,
.error_callback = PreloadedScript.errorCallback,
});
}
fn waitForPreload(self: *ScriptManager, url: [:0]const u8) ?*Script {
if (self.preloaded_scripts.getPtr(url) == null) {
return null;
}
const was_evaluating = self.base.is_evaluating;
self.base.is_evaluating = true;
defer self.base.is_evaluating = was_evaluating;
var client = self.base.client;
while (true) {
const entry = self.preloaded_scripts.getPtr(url) orelse return null;
switch (entry.state) {
.loading => {
_ = client.tick(200, .sync_wait) catch return null;
continue;
},
.done => |script| {
// Preload scripts are single-use. We return it and it becomes
// the caller's responsibility to free.
_ = self.preloaded_scripts.remove(url);
return script;
},
}
}
}
pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_element: *Element.Html.Script, comptime ctx: []const u8) !void {
if (script_element._executed) {
// If a script tag gets dynamically created and added to the dom:
@@ -132,6 +223,13 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
var handover = false;
const frame = self.frame;
// A consumed preload (waitForPreload below) is owned by us: its buffer is
// borrowed by `script`, so it must outlive eval.
var consumed_preload: ?*Script = null;
defer if (consumed_preload) |p| {
p.deinit();
};
const arena = try frame.getArena(.large, "SM.addFromElement");
errdefer if (!handover) {
frame.releaseArena(arena);
@@ -237,24 +335,30 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
self.base.is_evaluating = true;
defer self.base.is_evaluating = was_evaluating;
const headers = try self.getHeaders();
if (is_blocking) {
const response = try self.base.client.syncRequest(arena, .{
.url = url,
.method = .GET,
.frame_id = frame._frame_id,
.loader_id = frame._loader_id,
.headers = headers,
.cookie_jar = &frame._session.cookie_jar,
.cookie_origin = frame.url,
.resource_type = .script,
.notification = frame._session.notification,
});
if (self.waitForPreload(url)) |pre| {
// There was a preloaded script, we borrow it's source and status
consumed_preload = pre;
script.source = pre.source;
script.status = pre.status;
script.complete = true;
} else {
const response = try self.base.client.syncRequest(arena, .{
.url = url,
.method = .GET,
.frame_id = frame._frame_id,
.loader_id = frame._loader_id,
.headers = try self.getHeaders(),
.cookie_jar = &frame._session.cookie_jar,
.cookie_origin = frame.url,
.resource_type = .script,
.notification = frame._session.notification,
});
script.source = .{ .remote = response.body };
script.status = response.status;
script.complete = true;
script.source = .{ .remote = response.body };
script.status = response.status;
script.complete = true;
}
} else {
errdefer {
self.base.scriptList(script).remove(&script.node);
@@ -267,7 +371,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
.method = .GET,
.frame_id = frame._frame_id,
.loader_id = frame._loader_id,
.headers = headers,
.headers = try self.getHeaders(),
.cookie_jar = &frame._session.cookie_jar,
.cookie_origin = frame.url,
.resource_type = .script,
@@ -311,3 +415,42 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
pub fn staticScriptsDone(self: *ScriptManager) void {
self.base.staticScriptsDone();
}
const PreloadedScript = struct {
state: State,
const State = union(enum) {
loading: *Script,
done: *Script,
};
pub fn deinit(self: PreloadedScript) void {
switch (self.state) {
inline else => |script| script.deinit(),
}
}
fn doneCallback(ctx: *anyopaque) !void {
const script: *Script = @ptrCast(@alignCast(ctx));
script.complete = true;
if (comptime IS_DEBUG) {
log.debug(.http, "script fetch complete", .{ .req = script.url });
}
const self: *ScriptManager = @fieldParentPtr("base", script.manager);
self.preloaded_scripts.getPtr(script.url).?.state = .{ .done = script };
}
fn errorCallback(ctx: *anyopaque, err: anyerror) void {
const script: *Script = @ptrCast(@alignCast(ctx));
if (script.status == 404) {
log.info(.http, "script 404", .{ .req = script.url, .extra = "preload" });
} else {
log.warn(.http, "script fetch error", .{ .err = err, .req = script.url, .extra = "preload", .status = script.status });
}
const self: *ScriptManager = @fieldParentPtr("base", script.manager);
_ = self.preloaded_scripts.remove(script.url);
script.deinit();
}
};

View File

@@ -193,6 +193,7 @@ fn releaseArena(self: *ScriptManagerBase, arena: Allocator) void {
pub fn scriptList(self: *ScriptManagerBase, script: *const Script) *std.DoublyLinkedList {
return switch (script.extra) {
.import, .import_async => &self.async_scripts,
.preload => unreachable, // done/error are handled directly, never via scriptList
.frame => |fe| switch (fe.mode) {
.normal => unreachable, // not added to a list, executed immediately
.@"defer" => &self.defer_scripts,
@@ -433,6 +434,7 @@ pub fn evaluate(self: *ScriptManagerBase) void {
}
},
.import => unreachable, // .import doesn't go through ready_scripts
.preload => unreachable, // .preload is buffered in the map, never queued
}
}
@@ -505,6 +507,8 @@ pub const Script = struct {
import,
// Dynamic JS import() — resolved via ready_scripts callback.
import_async: ImportAsync,
// <link rel=preload as=script href=...>
preload,
// <script> tag in a frame.
frame: FrameExtra,
@@ -646,6 +650,7 @@ pub const Script = struct {
entry.state = .{ .done = self };
entry.buffer = self.source.remote;
},
.preload => unreachable, // preloads use ScriptManager.PreloadedScript.doneCallback
}
manager.evaluate();
}
@@ -688,6 +693,7 @@ pub const Script = struct {
entry.state = .err;
},
.frame => self.executeCallback(comptime .wrap("error")),
.preload => unreachable, // preloads use ScriptManager.PreloadedScript.errorCallback
}
self.deinit();
manager.evaluate();

View File

@@ -0,0 +1,38 @@
<!DOCTYPE html>
<script src="../../../testing.js"></script>
<!--
The preload hint is declared before the <script> that consumes it, so by the
time the parser blocks on `preload.js` the fetch is already in flight (or
done). ScriptManager.waitForPreload adopts that body instead of issuing a
fresh syncRequest, then the consumed entry is freed by the caller. Both halves
of that ownership handover run under the leak detector here.
-->
<link rel="preload" as="script" href="preload.js">
<!--
A preload with no matching <script> is fetched but never executed. It lingers
as a `.done` (or still-loading) entry until reset() frees it — exercises
freePreloads / map cleanup under the leak detector — and it must not run on
its own, nor delay the load event.
-->
<link rel="preload" as="script" href="preload_unused.js">
<script id="setup">
var order = '';
testing.expectEqual('', order);
</script>
<!-- Blocking remote script: its body is adopted from the preload above. -->
<script id="preloaded" src="preload.js"></script>
<script id="after">
// The preloaded blocking script executed, in document order, before us.
order += 'b';
testing.expectEqual('ab', order);
</script>
<script id="unused_check">
// A preload hint that nothing consumes must never execute.
testing.expectEqual(undefined, window.unused_preload_ran);
</script>

View File

@@ -0,0 +1,2 @@
order += 'a';
testing.expectEqual('a', order);

View File

@@ -0,0 +1,4 @@
// Nothing consumes this preload, so it must never be evaluated. If it runs, the
// flag trips the assertion in preload.html (and this fail() fires directly).
window.unused_preload_ran = true;
testing.fail('an unconsumed <link rel=preload as=script> must not execute');

View File

@@ -195,21 +195,13 @@ pub fn linkAddedCallback(self: *Link, frame: *Frame) !void {
const element = self.asElement();
const rel = element.getAttributeSafe(comptime .wrap("rel")) orelse return;
const loadable_rels = std.StaticStringMap(void).initComptime(.{
.{ "stylesheet", {} },
.{ "preload", {} },
.{ "modulepreload", {} },
});
if (loadable_rels.has(rel) == false) {
return;
}
const href = element.getAttributeSafe(comptime .wrap("href")) orelse return;
if (href.len == 0) {
return;
}
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
@@ -218,6 +210,28 @@ pub fn linkAddedCallback(self: *Link, frame: *Frame) !void {
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;
}
}
try frame.queueLoad(self._proto);
}