mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-06-11 01:25:53 -04:00
perf,http: Support for script preloading
Implement <link rel=preload as=script href=...>. The implementation is similar to preloadImport / waitForImport. Consider a website that does something like: ``` // pseudo html <head> <link preload script1.js> <link preload script2.js> <link preload script3.js> <link preload script4.js> <script script1.js></script> <script script2.js></script> <script script3.js></script> <script script4.js></script> ``` Then, without preloading, we hit <script script1.js></script> and block while we load it + execute it. Repeat for 2, 3, 4 ... With preloading, by the time we block on <script script1.js></script> all the scripts are already being downloaded in the background. I opted to remove the script on first use. If a script happens to be used twice (we have seen this happen for imports, but I guess it's more rare on blocking scripts), then it'll get re-downloaded the 2nd time, just like before (and just like before, the http cache is a better mechanism to rely on here). airbnb preloads 41 scripts.
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,40 @@ 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.freeDonePreloads();
|
||||
self.base.deinit();
|
||||
self.preloaded_scripts.deinit(self.base.allocator);
|
||||
}
|
||||
|
||||
pub fn reset(self: *ScriptManager) void {
|
||||
self.freeDonePreloads();
|
||||
self.preloaded_scripts.clearRetainingCapacity();
|
||||
self.base.reset();
|
||||
self.frame_notified_of_completion = false;
|
||||
}
|
||||
|
||||
fn freeDonePreloads(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 +101,95 @@ 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(.medium, "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, .{});
|
||||
errdefer _ = self.preloaded_scripts.remove(owned_url);
|
||||
|
||||
if (comptime IS_DEBUG) {
|
||||
log.debug(.http, "script queue", .{ .url = owned_url, .ctx = "preload" });
|
||||
}
|
||||
|
||||
// Tracked in async_scripts only while in flight so shutdown/reset can free a
|
||||
// never-consumed preload; preloadDoneCallback moves ownership to the map.
|
||||
self.base.async_scripts.append(&script.node);
|
||||
|
||||
// Guard against a synchronous completion re-entering evaluate() mid-parse,
|
||||
// the same reason getAsyncImport does.
|
||||
const was_evaluating = self.base.is_evaluating;
|
||||
self.base.is_evaluating = true;
|
||||
defer self.base.is_evaluating = was_evaluating;
|
||||
|
||||
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,
|
||||
}) catch |err| {
|
||||
self.base.async_scripts.remove(&script.node);
|
||||
return err;
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
},
|
||||
.err => return null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 +236,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 +348,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 +384,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 +428,54 @@ 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 = .loading,
|
||||
|
||||
const State = union(enum) {
|
||||
err,
|
||||
loading,
|
||||
done: *Script,
|
||||
};
|
||||
|
||||
pub fn deinit(self: PreloadedScript) void {
|
||||
switch (self.state) {
|
||||
.done => |script| script.deinit(),
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
// Hand ownership to the map; the blocking path adopts the body via
|
||||
// waitForPreload, and reset() frees the .done entry.
|
||||
self.base.async_scripts.remove(&script.node);
|
||||
self.preloaded_scripts.getPtr(script.url).?.state = .{ .done = script };
|
||||
|
||||
self.base.evaluate();
|
||||
}
|
||||
|
||||
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.base.async_scripts.remove(&script.node);
|
||||
_ = self.preloaded_scripts.remove(script.url);
|
||||
script.deinit();
|
||||
|
||||
if (self.base.shutdown == false) {
|
||||
self.base.evaluate();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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.preloadDoneCallback
|
||||
}
|
||||
manager.evaluate();
|
||||
}
|
||||
@@ -688,6 +693,7 @@ pub const Script = struct {
|
||||
entry.state = .err;
|
||||
},
|
||||
.frame => self.executeCallback(comptime .wrap("error")),
|
||||
.preload => unreachable, // preloads use ScriptManager.preloadErrorCallback
|
||||
}
|
||||
self.deinit();
|
||||
manager.evaluate();
|
||||
|
||||
@@ -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