mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-06-11 01:25:53 -04:00
Merge pull request #2675 from lightpanda-io/script_preload
perf,http: Support for script preloading
This commit is contained in:
@@ -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?
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
38
src/browser/tests/element/html/script/preload.html
Normal file
38
src/browser/tests/element/html/script/preload.html
Normal 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>
|
||||
2
src/browser/tests/element/html/script/preload.js
Normal file
2
src/browser/tests/element/html/script/preload.js
Normal file
@@ -0,0 +1,2 @@
|
||||
order += 'a';
|
||||
testing.expectEqual('a', order);
|
||||
4
src/browser/tests/element/html/script/preload_unused.js
Normal file
4
src/browser/tests/element/html/script/preload_unused.js
Normal 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');
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user