diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig
index 17f90e714..863fe5594 100644
--- a/src/browser/Frame.zig
+++ b/src/browser/Frame.zig
@@ -71,6 +71,7 @@ const milliTimestamp = @import("../datetime.zig").milliTimestamp;
const GlobalEventHandlersLookup = @import("webapi/global_event_handlers.zig").Lookup;
+pub const preload = @import("frame/preload.zig");
pub const observers = @import("frame/observers.zig");
pub const user_input = @import("frame/user_input.zig");
pub const node_factory = @import("frame/node_factory.zig");
@@ -1558,6 +1559,8 @@ fn frameDoneCallback(ctx: *anyopaque) !void {
const raw_html = html.buffer.items;
+ preload.prescan(self, raw_html);
+
if (std.mem.eql(u8, self.charset, "UTF-8")) {
parser.parse(raw_html);
} else {
@@ -2096,40 +2099,6 @@ pub fn queueHashChange(self: *Frame, old_url: []const u8, new_url: []const u8) !
// splitting by route anyway).
const MAX_STYLESHEET_BYTES: usize = 2 * 1024 * 1024;
-// start prefetching `
-pub fn preloadScriptHint(self: *Frame, element: *Element.Html, href: []const u8) bool {
- if (self.isGoingAway() or self._parse_mode == .fragment) {
- return false;
- }
-
- const arena = self.getArena(.small, "Frame.preloadScriptHint") catch return false;
- defer self.releaseArena(arena);
-
- const resolved = URL.resolve(arena, self.base(), href, .{ .encoding = self.charset }) catch return false;
- if (!std.ascii.startsWithIgnoreCase(resolved, "http:") and !std.ascii.startsWithIgnoreCase(resolved, "https:")) {
- // data:/blob: are synthesized locally — no round-trip to hide.
- return false;
- }
- return self._script_manager.preloadScript(element, resolved) catch false;
-}
-
-// start prefetching
-pub fn preloadModuleHint(self: *Frame, element: *Element.Html, href: []const u8) bool {
- if (self.isGoingAway() or self._parse_mode == .fragment) {
- return false;
- }
-
- // The url becomes the imported_modules key, which must outlive the fetch
- // so it lives on the frame arena
- const resolved = URL.resolve(self.arena, self.base(), href, .{ .encoding = self.charset }) catch return false;
- if (!std.ascii.startsWithIgnoreCase(resolved, "http:") and !std.ascii.startsWithIgnoreCase(resolved, "https:")) {
- // data:/blob: are synthesized locally — no round-trip to hide.
- return false;
- }
-
- return self._script_manager.base.preloadModuleHint(element, resolved, self.url) catch false;
-}
-
// 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 0d3d2de84..26c90f0cb 100644
--- a/src/browser/ScriptManager.zig
+++ b/src/browser/ScriptManager.zig
@@ -46,7 +46,8 @@ frame: *Frame,
// "load" event).
frame_notified_of_completion: bool,
-// scripts loaded based on a found during parsing
+// Scripts loaded based on a found during
+// parsing, keyed by resolved URL.
preloaded_scripts: std.StringHashMapUnmanaged(PreloadedScript),
pub fn init(allocator: Allocator, http_client: *HttpClient, frame: *Frame) ScriptManager {
@@ -104,7 +105,8 @@ fn getHeaders(self: *ScriptManager) !HttpClient.Headers {
// Returns true when a fetch was started: the link's load/error event fires
// when the fetch settles. false (duplicate hint) = no event will fire.
-pub fn preloadScript(self: *ScriptManager, element: *Element.Html, url: []const u8) !bool {
+// element is null when the hint came from the prescan rather than a .
+pub fn preloadScript(self: *ScriptManager, element: ?*Element.Html, url: []const u8) !bool {
if (self.preloaded_scripts.contains(url)) {
return false;
}
@@ -225,54 +227,28 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
return;
};
- var handover = false;
const frame = self.frame;
+ const base_url = frame.base();
- // 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 src = element.getAttributeSafe(comptime .wrap("src")) orelse {
+ return self.addInlineScript(script_element, kind);
};
+ // The script is remote (even data: and blob: are synthesized via HttpClient)
+
+ // Set once arena ownership is resolved — transferred to `script`, or
+ // released early on the adoption path — so the errdefer can't double-free.
+ var handover = false;
+
const arena = try frame.getArena(.large, "SM.addFromElement");
- errdefer if (!handover) {
+ errdefer if (handover == false) {
frame.releaseArena(arena);
};
- var source: Script.Source = undefined;
- var remote_url: ?[:0]const u8 = null;
- const base_url = frame.base();
- if (element.getAttributeSafe(comptime .wrap("src"))) |src| {
- // data: and blob: srcs flow through the normal request path; HttpClient
- // synthesizes the response. Execution mode (blocking vs async/defer) is
- // attribute-driven, the same as any other src.
- remote_url = try URL.resolve(arena, base_url, src, .{ .encoding = frame.charset });
- source = .{ .remote = .{} };
- } else {
- var buf = std.Io.Writer.Allocating.init(arena);
- try element.asNode().getChildTextContent(&buf.writer);
- try buf.writer.writeByte(0);
- const data = buf.written();
- const inline_source: [:0]const u8 = data[0 .. data.len - 1 :0];
- if (inline_source.len == 0) {
- // we haven't set script_element._executed = true yet, which is good.
- // If content is appended to the script, we will execute it then.
- frame.releaseArena(arena);
- return;
- }
- source = .{ .@"inline" = inline_source };
- }
-
- // Only set _executed (already-started) when we actually have content to execute
+ const remote_url = try URL.resolve(arena, base_url, src, .{ .encoding = frame.charset });
script_element._executed = true;
- const is_inline = source == .@"inline";
const mode: Script.Extra.FrameExtra.Mode = blk: {
- if (source == .@"inline") {
- break :blk if (kind == .module) .@"defer" else .normal;
- }
-
if (element.getAttributeSafe(comptime .wrap("async")) != null) {
break :blk .async;
}
@@ -294,54 +270,84 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
break :blk .normal;
};
+ if (comptime IS_DEBUG) {
+ var ls: js.Local.Scope = undefined;
+ frame.js.localScope(&ls);
+ defer ls.deinit();
+
+ log.debug(.http, "script queue", .{
+ .ctx = ctx,
+ .url = remote_url,
+ .element = element,
+ .stack = ls.local.stackTrace() catch "???",
+ });
+ }
+
+ const frame_extra: Script.Extra = .{ .frame = .{
+ .kind = kind,
+ .mode = mode,
+ .script_element = script_element,
+ .frame = frame,
+ } };
+
+ if (mode != .normal) {
+ var preloaded = self.takePreload(remote_url);
+ if (preloaded == null and kind == .module) {
+ preloaded = self.base.takeModuleHint(remote_url);
+ }
+ if (preloaded) |pre| {
+ if (comptime IS_DEBUG) {
+ log.debug(.http, "script adopt", .{ .url = remote_url, .ctx = ctx, .state = if (pre.complete) "done" else "loading" });
+ }
+ pre.extra = frame_extra;
+
+ // The adopted Script has its own arena; ours held only the URL
+ // resolution, which nothing below needs.
+ handover = true;
+ frame.releaseArena(arena);
+
+ if (pre.complete and mode == .async) {
+ // The fetch already finished, so no doneCallback will move it
+ // to ready_scripts; queue it there directly.
+ self.base.ready_scripts.append(&pre.node);
+ } else {
+ self.base.scriptList(pre).append(&pre.node);
+ }
+ if (pre.complete) {
+ // ...and no doneCallback will trigger evaluation.
+ self.base.evaluate();
+ }
+ return;
+ }
+ }
+
const script = try arena.create(Script);
script.* = .{
.node = .{},
.arena = arena,
.manager = &self.base,
- .source = source,
- .complete = is_inline,
- .status = if (is_inline) 200 else 0,
- .url = remote_url orelse base_url,
- .extra = .{ .frame = .{
- .kind = kind,
- .mode = mode,
- .script_element = script_element,
- .frame = frame,
- } },
+ .source = .{ .remote = .{} },
+ .complete = false,
+ .url = remote_url,
+ .extra = frame_extra,
};
- const is_blocking = mode == .normal;
+ if (mode == .normal) {
+ // Blocking: fetch synchronously and evaluate before the parser resumes.
- // Once parsing is done, the deferred-script batch has already drained and
- // won't run again, so a non-blocking script inserted afterwards would go
- // unprocessed. Run it immediately instead. Remote scripts still need to
- // be queued so they execute when their fetch completes.
- const run_immediately = is_blocking or (self.base.static_scripts_done and remote_url == null);
- if (run_immediately == false) {
- self.base.scriptList(script).append(&script.node);
- }
+ // 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();
+ };
- if (remote_url) |url| {
- if (comptime IS_DEBUG) {
- var ls: js.Local.Scope = undefined;
- frame.js.localScope(&ls);
- defer ls.deinit();
+ {
+ const was_evaluating = self.base.is_evaluating;
+ self.base.is_evaluating = true;
+ defer self.base.endEvaluationWindow(was_evaluating);
- log.debug(.http, "script queue", .{
- .ctx = ctx,
- .url = remote_url.?,
- .element = element,
- .stack = ls.local.stackTrace() catch "???",
- });
- }
-
- const was_evaluating = self.base.is_evaluating;
- self.base.is_evaluating = true;
- defer self.base.endEvaluationWindow(was_evaluating);
-
- if (is_blocking) {
- if (self.waitForPreload(url)) |pre| {
+ if (self.waitForPreload(remote_url)) |pre| {
// There was a preloaded script, we borrow it's source and status
consumed_preload = pre;
script.source = pre.source;
@@ -349,7 +355,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
script.complete = true;
} else {
const response = try self.base.client.syncRequest(arena, .{
- .url = url,
+ .url = remote_url,
.method = .GET,
.frame_id = frame._frame_id,
.loader_id = frame._loader_id,
@@ -365,48 +371,107 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
script.status = response.status;
script.complete = true;
}
- } else {
- errdefer {
- self.base.scriptList(script).remove(&script.node);
- // Let the outer errdefer handle releasing the arena if client.request fails
- }
-
- try frame.makeRequest(.{
- .ctx = script,
- .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,
- .start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null,
- .header_callback = Script.headerCallback,
- .data_callback = Script.dataCallback,
- .done_callback = Script.doneCallback,
- .error_callback = Script.errorCallback,
- // Nothing holds the transfer; teardown cleanup runs through
- // the manager's script lists.
- .shutdown_callback = HttpClient.noopShutdown,
- });
+ handover = true;
}
- handover = true;
+ if (script.status < 200 or script.status > 299) {
+ log.info(.http, "script load error", .{ .status = script.status });
+ script.executeCallback(comptime .wrap("error"));
+ script.deinit();
+ return;
+ }
+
+ return self.evalNow(script);
}
- if (run_immediately == false) {
+ // async/defer: queue the script and fetch in the background; doneCallback
+ // routes it through the ready_scripts / defer_scripts draining.
+ self.base.scriptList(script).append(&script.node);
+
+ const was_evaluating = self.base.is_evaluating;
+ self.base.is_evaluating = true;
+ defer self.base.endEvaluationWindow(was_evaluating);
+
+ errdefer self.base.scriptList(script).remove(&script.node);
+ try frame.makeRequest(.{
+ .ctx = script,
+ .url = remote_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,
+ .start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null,
+ .header_callback = Script.headerCallback,
+ .data_callback = Script.dataCallback,
+ .done_callback = Script.doneCallback,
+ .error_callback = Script.errorCallback,
+ // Nothing holds the transfer; teardown cleanup runs through
+ // the manager's script lists.
+ .shutdown_callback = HttpClient.noopShutdown,
+ });
+ handover = true;
+}
+
+// A
+ \\
+ \\
+ \\
+ \\
+ \\
+ \\
+ \\
+ \\
+ \\
+ \\
+ \\
+ \\
+ \\
+ \\
+ \\