From c81b4d13d966ecb0d364647cf7ea7ebb7496ed30 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 9 Jun 2026 15:16:34 +0800 Subject: [PATCH] perf,http: Support for script preloading Implement . The implementation is similar to preloadImport / waitForImport. Consider a website that does something like: ``` // pseudo html ``` Then, without preloading, we hit and block while we load it + execute it. Repeat for 2, 3, 4 ... With preloading, by the time we block on 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. --- src/browser/Frame.zig | 17 ++ src/browser/ScriptManager.zig | 204 +++++++++++++++++++++-- src/browser/ScriptManagerBase.zig | 6 + src/browser/webapi/element/html/Link.zig | 34 ++-- 4 files changed, 233 insertions(+), 28 deletions(-) diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 61667086..70910efa 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -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 ` +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 ``. // href is passed in as an optimization since the [currently] only callsite has // it, so why look it up again? diff --git a/src/browser/ScriptManager.zig b/src/browser/ScriptManager.zig index 346e3f37..298bf317 100644 --- a/src/browser/ScriptManager.zig +++ b/src/browser/ScriptManager.zig @@ -46,25 +46,40 @@ frame: *Frame, // "load" event). frame_notified_of_completion: bool, +// scripts loaded based on a 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(); + } + } +}; diff --git a/src/browser/ScriptManagerBase.zig b/src/browser/ScriptManagerBase.zig index 2eaafd66..e3cc3b00 100644 --- a/src/browser/ScriptManagerBase.zig +++ b/src/browser/ScriptManagerBase.zig @@ -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, + // + preload, //