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,
//