From a18886b1a93f20c6ace596c310eb67887aaf1a21 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 15 Jul 2026 12:04:26 +0800 Subject: [PATCH 1/2] cleanup: Enhance preload scripts This fixes a bug with preload script so that they're actually used for async/defer scripts (previously, only used for blocking scripts). More importantly, this cleans up the ScriptManager's addFromElement. For example inline scripts are handled in their own function, which means we aren't weaving the two modes in a single function. It also allows the inline-script to use a better-sized arena. The goal for this cleanup is the follow up commit which will bring a pre-parse step to preload scripts without an explicit hint. --- src/browser/ScriptManager.zig | 307 +++++++++++------- .../html/script/preload_nonblocking.html | 56 ++++ src/browser/webapi/Node.zig | 13 + src/testing.zig | 29 ++ 4 files changed, 296 insertions(+), 109 deletions(-) create mode 100644 src/browser/tests/element/html/script/preload_nonblocking.html diff --git a/src/browser/ScriptManager.zig b/src/browser/ScriptManager.zig index 0d3d2de84..95ec7334c 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 { @@ -225,54 +226,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 +269,86 @@ 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) { + if (self.takePreload(remote_url)) |pre| { + // A already fetched (or is still + // fetching) this URL. Adopt the preloaded Script, zero-copy — the + // classic-script mirror of getAsyncImport's module-hint adoption. + // The transfer's still-registered PreloadedScript callbacks see + // the rewritten extra and route to the normal Script callbacks. + // (Blocking scripts consume preloads via waitForPreload below.) + 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 +356,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 +372,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 + + + + + + + + + + + + + + + + + diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index 2151e1d14..c9f4603c5 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -306,6 +306,19 @@ pub fn getChildTextContent(self: *Node, writer: *std.Io.Writer) error{WriteFaile } } +// The byte length of what getChildTextContent writes; lets callers size a +// buffer (or arena) before extracting. +pub fn childTextContentLen(self: *Node) usize { + var len: usize = 0; + var it = self.childrenIterator(); + while (it.next()) |child| { + if (child.is(CData.Text)) |text| { + len += text._proto._data.str().len; + } + } + return len; +} + pub fn setTextContent(self: *Node, data: []const u8, frame: *Frame) !void { switch (self._type) { .element => |el| { diff --git a/src/testing.zig b/src/testing.zig index cc3a191da..05f7b8150 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -605,6 +605,11 @@ fn serveCDP(wg: *std.Thread.WaitGroup) !void { test_app.network.run(); } +// /serve-count/ counters; only ever touched from the test HTTP server thread. +var serve_count_defer: u32 = 0; +var serve_count_async: u32 = 0; +var serve_count_dynamic: u32 = 0; + fn testHTTPHandler(req: *std.http.Server.Request) !void { const path = req.head.target; @@ -732,6 +737,30 @@ fn testHTTPHandler(req: *std.http.Server.Request) !void { }); } + if (std.mem.startsWith(u8, path, "/serve-count/") and std.mem.endsWith(u8, path, ".js")) { + // Serves `window.__serve_count_ = N;` where N counts how many + // times this URL has been served. Lets a fixture assert a preloaded + // script was fetched exactly once: the body the consumer executes + // carries N == 1, while a duplicate fetch would execute N == 2. + // no-store so the second fetch can't be satisfied by the HTTP cache. + const name = path["/serve-count/".len .. path.len - ".js".len]; + const slot: *u32 = blk: { + if (std.mem.eql(u8, name, "defer")) break :blk &serve_count_defer; + if (std.mem.eql(u8, name, "async")) break :blk &serve_count_async; + if (std.mem.eql(u8, name, "dynamic")) break :blk &serve_count_dynamic; + return req.respond("unknown counter", .{ .status = .not_found }); + }; + slot.* += 1; + var buf: [64]u8 = undefined; + const body = try std.fmt.bufPrint(&buf, "window.__serve_count_{s} = {d};", .{ name, slot.* }); + return req.respond(body, .{ + .extra_headers = &.{ + .{ .name = "Content-Type", .value = "application/javascript" }, + .{ .name = "Cache-Control", .value = "no-store" }, + }, + }); + } + if (std.mem.eql(u8, path, "/xhr/500")) { return req.respond("Internal Server Error", .{ .status = .internal_server_error, From 2edc2eb1313c3eff4a9d2652f831f9c7113dd664 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 15 Jul 2026 14:27:47 +0800 Subject: [PATCH 2/2] perf: Pre-parse HTML to find and preload scripts Builds on top of the recently added support for and to scan the HTML for script tags to preload. I.e. adds script preloading without actually having any preload hits. At least for this first pass, I opted for a simple approach which leverages are fully buffered HTML body and html5ever's tokenizer to prescan the body and kickoff any script fetching before starting the complete parse. There are doubtless cases where this will either decrease performance and/or increase memory usage. E.g. a site with no script gets its html scanned twice and loading multiple blocking scripts in parallel obvious uses more memory than loading them sequentially. But for most sites and I think most use-cases, the impact should range between neutral to significantly faster loads. This is something most browsers do. --- src/browser/Frame.zig | 37 +--- src/browser/ScriptManager.zig | 15 +- src/browser/ScriptManagerBase.zig | 47 +++- src/browser/frame/preload.zig | 196 +++++++++++++++++ src/browser/parser/Parser.zig | 10 + src/browser/parser/html5ever.zig | 20 ++ .../tests/element/html/script/prescan.html | 38 ++++ src/browser/webapi/element/html/Link.zig | 4 +- src/html5ever/lib.rs | 1 + src/html5ever/prescan.rs | 202 ++++++++++++++++++ src/testing.zig | 17 +- 11 files changed, 530 insertions(+), 57 deletions(-) create mode 100644 src/browser/frame/preload.zig create mode 100644 src/browser/tests/element/html/script/prescan.html create mode 100644 src/html5ever/prescan.rs diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 4e7e5b34f..99277d936 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -69,6 +69,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"); @@ -1516,6 +1517,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 { @@ -2054,40 +2057,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 95ec7334c..26c90f0cb 100644 --- a/src/browser/ScriptManager.zig +++ b/src/browser/ScriptManager.zig @@ -105,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; } @@ -290,13 +291,11 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e } }; if (mode != .normal) { - if (self.takePreload(remote_url)) |pre| { - // A already fetched (or is still - // fetching) this URL. Adopt the preloaded Script, zero-copy — the - // classic-script mirror of getAsyncImport's module-hint adoption. - // The transfer's still-registered PreloadedScript callbacks see - // the rewritten extra and route to the normal Script callbacks. - // (Blocking scripts consume preloads via waitForPreload below.) + 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" }); } diff --git a/src/browser/ScriptManagerBase.zig b/src/browser/ScriptManagerBase.zig index d2ff6e4f9..c2e5f6104 100644 --- a/src/browser/ScriptManagerBase.zig +++ b/src/browser/ScriptManagerBase.zig @@ -216,7 +216,12 @@ pub fn resolveSpecifier(self: *ScriptManagerBase, arena: Allocator, base: [:0]co } const PreloadOpts = struct { - // set when the preload comes from a hint + // true when the preload is a hint (modulepreload link or prescan) and can + // be taken by anyone. + hint: bool = false, + + // the element to fire load/error on, when the + // hint came from one. hint_element: ?*Element.Html = null, }; pub fn preloadImport(self: *ScriptManagerBase, url: [:0]const u8, referrer: []const u8, opts: PreloadOpts) !void { @@ -248,7 +253,7 @@ pub fn preloadImport(self: *ScriptManagerBase, url: [:0]const u8, referrer: []co .hint_element = opts.hint_element, }; - gop.value_ptr.* = .{ .state = .{ .loading = script }, .hint = opts.hint_element != null }; + gop.value_ptr.* = .{ .state = .{ .loading = script }, .hint = opts.hint }; if (comptime IS_DEBUG) { var ls: js.Local.Scope = undefined; @@ -294,17 +299,45 @@ pub fn preloadImport(self: *ScriptManagerBase, url: [:0]const u8, referrer: []co }; } -// — start fetching a module before import -// resolution discovers it. Returns false when no fetch was started (the -// module is already fetched/fetching): no event will fire on the link. -pub fn preloadModuleHint(self: *ScriptManagerBase, element: *Element.Html, url: [:0]const u8, referrer: []const u8) !bool { +// (element set) or the prescan finding a +// + \\ + \\ + \\ + \\ + \\ + \\ + \\ + \\ + \\ + \\ + \\ + \\ + \\ + \\ + \\ + ); + + const sm = &frame._script_manager; + try testing.expectEqual(2, sm.preloaded_scripts.count()); + try testing.expectEqual(true, sm.preloaded_scripts.contains("http://127.0.0.1:9582/serve-count/unit_a.js")); + try testing.expectEqual(true, sm.preloaded_scripts.contains("http://127.0.0.1:9582/serve-count/sub/based.js")); + + try testing.expectEqual(1, sm.base.imported_modules.count()); + const module = sm.base.imported_modules.get("http://127.0.0.1:9582/serve-count/unit_m.js") orelse return error.MissingModule; + try testing.expectEqual(true, module.hint); +} diff --git a/src/browser/parser/Parser.zig b/src/browser/parser/Parser.zig index 159dbe372..d252aa9db 100644 --- a/src/browser/parser/Parser.zig +++ b/src/browser/parser/Parser.zig @@ -171,6 +171,16 @@ const Error = struct { }; }; +pub const PrescanResource = h5e.PrescanResource; +pub const PrescanCallback = h5e.PrescanCallback; + +// Preload scanner: a tokenizer-only pass over a buffered document, reporting +// fetchable script resources (and the first ) through `callback`. +// Builds no tree; purely a hint source. +pub fn prescan(html: []const u8, charset: []const u8, ctx: *anyopaque, callback: PrescanCallback) void { + h5e.html5ever_prescan(html.ptr, html.len, charset.ptr, charset.len, ctx, callback); +} + pub fn parse(self: *Parser, html: []const u8) void { h5e.html5ever_parse_document( html.ptr, diff --git a/src/browser/parser/html5ever.zig b/src/browser/parser/html5ever.zig index 3c5905302..b6d1c7ff3 100644 --- a/src/browser/parser/html5ever.zig +++ b/src/browser/parser/html5ever.zig @@ -315,3 +315,23 @@ pub extern "c" fn encoding_max_encode_buffer_length( handle: *anyopaque, input_len: usize, ) usize; + +// Preload scanner (see prescan.rs). Kinds must stay in sync with the +// KIND_* constants there. +pub const PrescanResource = enum(u32) { + script = 0, + module = 1, + base = 2, + _, +}; + +pub const PrescanCallback = *const fn (ctx: *anyopaque, kind: PrescanResource, url: [*c]const u8, url_len: usize) callconv(.c) void; + +pub extern "c" fn html5ever_prescan( + html: [*c]const u8, + len: usize, + charset: [*c]const u8, + charset_len: usize, + ctx: *anyopaque, + callback: PrescanCallback, +) void; diff --git a/src/browser/tests/element/html/script/prescan.html b/src/browser/tests/element/html/script/prescan.html new file mode 100644 index 000000000..61989552e --- /dev/null +++ b/src/browser/tests/element/html/script/prescan.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + diff --git a/src/browser/webapi/element/html/Link.zig b/src/browser/webapi/element/html/Link.zig index 7eb2190f5..5c766c9db 100644 --- a/src/browser/webapi/element/html/Link.zig +++ b/src/browser/webapi/element/html/Link.zig @@ -211,7 +211,7 @@ pub fn linkAddedCallback(self: *Link, frame: *Frame) !void { if (std.mem.eql(u8, rel, "preload")) { const as = element.getAttributeSafe(comptime .wrap("as")) orelse ""; if (std.ascii.eqlIgnoreCase(as, "script")) { - if (frame.preloadScriptHint(self._proto, href)) { + if (Frame.preload.scriptHint(frame, self._proto, href)) { // load/error fires when the fetch settles return; } @@ -224,7 +224,7 @@ pub fn linkAddedCallback(self: *Link, frame: *Frame) !void { // "as" defaults to script in this case const as = element.getAttributeSafe(comptime .wrap("as")) orelse ""; if (as.len == 0 or std.ascii.eqlIgnoreCase(as, "script")) { - if (frame.preloadModuleHint(self._proto, href)) { + if (Frame.preload.moduleHint(frame, self._proto, href)) { // load/error fires when the fetch settles return; } diff --git a/src/html5ever/lib.rs b/src/html5ever/lib.rs index f778dbeb7..8504da071 100644 --- a/src/html5ever/lib.rs +++ b/src/html5ever/lib.rs @@ -16,6 +16,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +mod prescan; mod sink; mod types; mod url; diff --git a/src/html5ever/prescan.rs b/src/html5ever/prescan.rs new file mode 100644 index 000000000..e5702f22f --- /dev/null +++ b/src/html5ever/prescan.rs @@ -0,0 +1,202 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// A preload scanner: a lightweight tokenizer-only pass over a document that +// reports fetchable script resources, so downloads can start before the tree +// builder (which stalls on every blocking