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..96631d00 100644 --- a/src/browser/ScriptManager.zig +++ b/src/browser/ScriptManager.zig @@ -46,25 +46,41 @@ 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.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(); + } +}; diff --git a/src/browser/ScriptManagerBase.zig b/src/browser/ScriptManagerBase.zig index 2eaafd66..f5a672ae 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, // + + + + + + + + + + + + + + + diff --git a/src/browser/tests/element/html/script/preload.js b/src/browser/tests/element/html/script/preload.js new file mode 100644 index 00000000..658e1e9f --- /dev/null +++ b/src/browser/tests/element/html/script/preload.js @@ -0,0 +1,2 @@ +order += 'a'; +testing.expectEqual('a', order); diff --git a/src/browser/tests/element/html/script/preload_unused.js b/src/browser/tests/element/html/script/preload_unused.js new file mode 100644 index 00000000..f0d04b8a --- /dev/null +++ b/src/browser/tests/element/html/script/preload_unused.js @@ -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 must not execute'); diff --git a/src/browser/webapi/element/html/Link.zig b/src/browser/webapi/element/html/Link.zig index 49ab7a6d..999bac1a 100644 --- a/src/browser/webapi/element/html/Link.zig +++ b/src/browser/webapi/element/html/Link.zig @@ -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); }