From 99262190dacccc15ae3c745b0dcced42e2d42819 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Wed, 22 Jul 2026 13:44:51 +0200 Subject: [PATCH 01/34] feat(tools): add Brave search + /searchEngine metacommand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Brave as a second API-backed web search engine: the search tool's auto mode tries Brave then Tavily (keyed on BRAVE_API_KEY/TAVILY_API_KEY) before the DuckDuckGo scrape. A new /searchEngine REPL metacommand pins the engine explicitly (auto|tavily|brave|duckduckgo) — an explicit engine surfaces its failures instead of silently degrading — and is persisted to .lp-agent.zon like the other REPL settings. Bumps zenai to pick up the zenai.search.brave client. --- build.zig.zon | 4 +- src/agent/Agent.zig | 25 ++++++- src/agent/SlashCommand.zig | 5 +- src/agent/settings.zig | 20 ++++++ src/browser/tools.zig | 139 +++++++++++++++++++++++++++++++++---- 5 files changed, 174 insertions(+), 19 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 64aff55d1..c2e50cc14 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -35,8 +35,8 @@ .hash = "sqlite3-3.51.0-DMxLWssOAABZ8cAvU_LfBIbp0kZjm824PU8sSLXpEDdr", }, .zenai = .{ - .url = "git+https://github.com/lightpanda-io/zenai.git#1df180de89ef72535d973af7854bf24dc8ab94a7", - .hash = "zenai-0.0.0-iOY_VPyeBQDnDr5NOUWDK7HWpFTJR8JXZ5UYcwNGEMOu", + .url = "git+https://github.com/lightpanda-io/zenai.git#31da07c1fe9805267ab4dbe4ce41c0cc2d16a36a", + .hash = "zenai-0.0.0-iOY_VGnDBQDS-bBNsgXuHhwEMqTh9Q7bQuP0BKziOdrR", }, .isocline = .{ .url = "git+https://github.com/arrufat/isocline?ref=lightpanda#832a9fe25f5f4458fcc47b5acc7c21db669c2f47", diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index 6666b21b1..ab2a3868d 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -296,10 +296,11 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent const effort = settings.resolveEffort(opts, remembered, will_repl, if (resolved) |r| r.credentials.provider else null); const verbosity = settings.resolveVerbosity(opts, remembered); const stream_enabled = settings.resolveStream(remembered); + browser_tools.search_engine = settings.resolveSearchEngine(remembered); if (resolved) |r| { if (r.source == .picked) { - settings.saveRemembered(.{ .provider = r.credentials.provider, .model = model, .effort = effort, .verbosity = verbosity, .stream = stream_enabled }) catch {}; + settings.saveRemembered(.{ .provider = r.credentials.provider, .model = model, .effort = effort, .verbosity = verbosity, .stream = stream_enabled, .search_engine = browser_tools.search_engine }) catch {}; } // provider/model now live in the status bar; just space before the help std.debug.print("\n", .{}); @@ -721,6 +722,7 @@ fn handleMeta(self: *Agent, arena: std.mem.Allocator, meta: *const SlashCommand. .load => self.handleLoad(rest), .model => self.handleModel(arena, rest), .provider => self.handleProvider(arena, rest), + .searchEngine => self.handleSearchEngine(rest), } return false; } @@ -760,6 +762,21 @@ fn handleStream(self: *Agent, rest: []const u8) void { self.reportSaved("stream", if (on) "on" else "off"); } +/// `/searchEngine`: bare prints the current engine; an argument sets and +/// persists it, warning when the chosen engine's API key is unset. +fn handleSearchEngine(self: *Agent, rest: []const u8) void { + self.setEnumOption("searchEngine", &browser_tools.search_engine, rest); + const selected = std.meta.stringToEnum(browser_tools.SearchEngine, rest) orelse return; + const env_var: []const u8 = switch (selected) { + .tavily => "TAVILY_API_KEY", + .brave => "BRAVE_API_KEY", + .auto, .duckduckgo => return, + }; + if (std.posix.getenv(env_var) == null) { + self.terminal.printWarning("{s} is not set; the search tool will fail until you export it", .{env_var}); + } +} + /// Print cumulative session token usage, broken down so the cache's effect is /// visible — the REPL otherwise never surfaces the `$usage` line `--task` /// prints. Reads `total_usage` (accumulated per turn by `processUserMessage`); @@ -870,7 +887,7 @@ fn reportSaved(self: *Agent, label: []const u8, value: []const u8) void { self.terminal.printInfo("{s}: {s}", .{ label, value }); return; } - if (settings.saveRemembered(.{ .provider = provider, .model = self.model, .effort = self.effort, .verbosity = self.terminal.verbosity, .stream = self.stream_enabled })) { + if (settings.saveRemembered(.{ .provider = provider, .model = self.model, .effort = self.effort, .verbosity = self.terminal.verbosity, .stream = self.stream_enabled, .search_engine = browser_tools.search_engine })) { self.terminal.printInfo("{s}: {s} (saved to {s})", .{ label, value, settings.remembered_path }); } else |_| { self.terminal.printInfo("{s}: {s}", .{ label, value }); @@ -1360,6 +1377,10 @@ fn printSlashHelp(self: *Agent, arena: std.mem.Allocator, target: []const u8) vo "/provider [name|null] — change the provider, or 'null' to disable the LLM (persisted, so the next launch starts in basic mode); Tab completes detected providers, bare /provider shows the current one", .{}, ), + .searchEngine => self.terminal.printInfo( + "/searchEngine " ++ Config.tagHint(browser_tools.SearchEngine) ++ " — set the web search engine behind the search tool (currently: {s}); saved to {s}. 'auto' tries Brave then Tavily (when their API keys are set) and falls back to the DuckDuckGo scrape; an explicit engine is used alone. Bare /searchEngine prints the engine.", + .{ @tagName(browser_tools.search_engine), settings.remembered_path }, + ), } return; } diff --git a/src/agent/SlashCommand.zig b/src/agent/SlashCommand.zig index 790f48fe7..e84d855db 100644 --- a/src/agent/SlashCommand.zig +++ b/src/agent/SlashCommand.zig @@ -17,7 +17,7 @@ // along with this program. If not, see . //! REPL-only meta slash commands (`/help`, `/quit`, `/verbosity`, `/effort`, -//! `/stream`, `/usage`, `/model`, `/provider`). Not tool slash commands — handled by +//! `/stream`, `/usage`, `/model`, `/provider`, `/searchEngine`). Not tool slash commands — handled by //! `Agent.handleMeta`, never reaching the recorder. Tool slash-command schema //! primitives live in `lp.Schema`; import that directly. @@ -46,7 +46,7 @@ pub const MetaCommand = struct { /// Dispatched by `Agent.handleMeta` via an exhaustive switch, so a new meta /// command is a compile error until it's wired up there too. - const Tag = enum { help, quit, verbosity, effort, stream, usage, clear, reset, save, load, model, provider }; + const Tag = enum { help, quit, verbosity, effort, stream, usage, clear, reset, save, load, model, provider, searchEngine }; }; const tagNames = Config.tagNames; @@ -65,6 +65,7 @@ pub const meta_commands = [_]MetaCommand{ .{ .tag = .load, .name = "load", .hint = "", .values = &.{}, .description = "Load and run a script from disk" }, .{ .tag = .model, .name = "model", .hint = "[name]", .values = &.{}, .description = "Change the model" }, .{ .tag = .provider, .name = "provider", .hint = "[name|null]", .values = &.{}, .description = "Change the provider, or 'null' to disable the LLM" }, + .{ .tag = .searchEngine, .name = "searchEngine", .hint = tagHint(browser_tools.SearchEngine), .values = tagNames(browser_tools.SearchEngine), .description = "Change the web search engine" }, }; /// Derived from `Command.LlmCommand` — name and description both come from the diff --git a/src/agent/settings.zig b/src/agent/settings.zig index ad265c72d..1908305df 100644 --- a/src/agent/settings.zig +++ b/src/agent/settings.zig @@ -194,6 +194,7 @@ pub const Remembered = struct { effort: ?Config.Effort = null, verbosity: ?Config.AgentVerbosity = null, stream: ?bool = null, + search_engine: ?lp.tools.SearchEngine = null, }; pub fn loadRemembered(allocator: std.mem.Allocator) ?Remembered { @@ -278,6 +279,13 @@ pub fn resolveStream(remembered: ?Remembered) bool { return true; } +/// Precedence: remembered `.lp-agent.zon` value > default (auto). No CLI +/// flag — the REPL `/searchEngine` command sets and persists it. +pub fn resolveSearchEngine(remembered: ?Remembered) lp.tools.SearchEngine { + if (remembered) |r| if (r.search_engine) |e| return e; + return .auto; +} + pub const ReconciledModel = union(enum) { /// Owned by the allocator passed to reconcileModel. use: []u8, @@ -348,6 +356,18 @@ test "parseRemembered: stream field round-trips" { try testing.expect(remembered.stream == false); } +test "parseRemembered: search_engine field round-trips" { + const remembered = parseRemembered(testing.allocator, ".{ .model = \"m\", .search_engine = .brave }").?; + defer std.zon.parse.free(testing.allocator, remembered); + try testing.expect(remembered.search_engine == .brave); +} + +test "resolveSearchEngine: default auto, remembered wins" { + try testing.expect(resolveSearchEngine(null) == .auto); + try testing.expect(resolveSearchEngine(.{ .model = "m", .search_engine = null }) == .auto); + try testing.expect(resolveSearchEngine(.{ .model = "m", .search_engine = .duckduckgo }) == .duckduckgo); +} + test "resolveStream: default on, remembered wins" { try testing.expect(resolveStream(null)); try testing.expect(resolveStream(.{ .model = "m", .stream = null })); diff --git a/src/browser/tools.zig b/src/browser/tools.zig index 4812bf624..1d53a66c5 100644 --- a/src/browser/tools.zig +++ b/src/browser/tools.zig @@ -22,6 +22,7 @@ const zenai = @import("zenai"); const log = lp.log; const tavily = zenai.search.tavily; +const brave = zenai.search.brave; const DOMNode = @import("webapi/Node.zig"); const CDPNode = @import("../cdp/Node.zig"); @@ -335,7 +336,7 @@ pub const Tool = enum { ), }, .search => .{ - .description = "Run a web search and return results as markdown. When TAVILY_API_KEY is set, queries the Tavily Search API and returns a numbered list of {title, url, snippet}. Otherwise (or on Tavily failure) falls back to scraping the DuckDuckGo HTML endpoint — degraded results, may rate-limit on bursty traffic. Prefer this over goto-ing google.com/search directly (Google blocks the browser on User-Agent/TLS). Browser state after this call is unspecified — to interact with a result, use `goto` with its URL; do not assume the browser DOM matches the results page.", + .description = "Run a web search and return results as markdown. When BRAVE_API_KEY or TAVILY_API_KEY is set, queries that search API (Brave preferred) and returns a numbered list of {title, url, snippet}. Otherwise (or on API failure) falls back to scraping the DuckDuckGo HTML endpoint — degraded results, may rate-limit on bursty traffic. Prefer this over goto-ing google.com/search directly (Google blocks the browser on User-Agent/TLS). Browser state after this call is unspecified — to interact with a result, use `goto` with its URL; do not assume the browser DOM matches the results page.", .summary = "Web search, results as markdown", .input_schema = minify( \\{ @@ -814,7 +815,7 @@ fn dispatch( ) ToolError!ToolResult { return switch (tool) { .goto => .{ .text = try execGoto(arena, session, registry, substituted) }, - .search => .{ .text = try execSearch(arena, session, registry, substituted) }, + .search => execSearch(arena, session, registry, substituted), .markdown => .{ .text = try execMarkdown(arena, session, registry, substituted) }, .html => .{ .text = try execHtml(arena, session, registry, substituted) }, .links => .{ .text = try execLinks(arena, session, registry, substituted) }, @@ -946,19 +947,38 @@ pub const SearchParams = struct { timeout: ?u32 = null, }; -fn execSearch(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode.Registry, arguments: ?std.json.Value) ToolError![]const u8 { +/// Search backend for `execSearch`. `.auto` tries Brave then Tavily (each +/// only when its API key is set), then the DuckDuckGo scrape; an explicit +/// engine is used alone. Only the agent REPL's `/searchEngine` mutates this. +pub const SearchEngine = enum { auto, tavily, brave, duckduckgo }; +pub var search_engine: SearchEngine = .auto; + +fn execSearch(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode.Registry, arguments: ?std.json.Value) ToolError!ToolResult { const args = try parseArgs(SearchParams, arena, arguments); if (args.query.len == 0) return ToolError.InvalidParams; - // Tavily path: only when TAVILY_API_KEY is set in the process env. On any - // failure (network, non-2xx, parse) fall through to the DuckDuckGo scrape - // so a single Tavily outage doesn't kill a whole benchmark run. - if (std.posix.getenv("TAVILY_API_KEY")) |api_key| { - if (tavilySearch(arena, api_key, args.query)) |markdown_| { - return markdown_; - } else |err| { - log.warn(.browser, "tavily fallback", .{ .err = err }); - } + switch (search_engine) { + // Any failure (network, non-2xx, parse) falls through to the next + // engine so a single outage doesn't kill a whole benchmark run. + .auto => { + if (std.posix.getenv("BRAVE_API_KEY")) |api_key| { + if (braveSearch(arena, api_key, args.query)) |markdown_| { + return .{ .text = markdown_ }; + } else |err| { + log.warn(.browser, "brave fallback", .{ .err = err }); + } + } + if (std.posix.getenv("TAVILY_API_KEY")) |api_key| { + if (tavilySearch(arena, api_key, args.query)) |markdown_| { + return .{ .text = markdown_ }; + } else |err| { + log.warn(.browser, "tavily fallback", .{ .err = err }); + } + } + }, + .tavily => return searchExplicit(arena, "tavily", "TAVILY_API_KEY", tavilySearch, args.query), + .brave => return searchExplicit(arena, "brave", "BRAVE_API_KEY", braveSearch, args.query), + .duckduckgo => {}, } const encoded = lp.URL.percentEncodeSegment(arena, args.query, .component) catch return ToolError.OutOfMemory; @@ -970,7 +990,28 @@ fn execSearch(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode ) catch return ToolError.OutOfMemory; _ = try performGoto(session, registry, ddg_url, args.timeout); const ddg_frame = try requireFrame(session); - return renderFrameMarkdown(arena, ddg_frame); + return .{ .text = try renderFrameMarkdown(arena, ddg_frame) }; +} + +/// Search with an explicitly selected engine: a missing key or a failed call +/// comes back as an error result — no DDG fallback. +fn searchExplicit( + arena: std.mem.Allocator, + comptime label: []const u8, + comptime env_var: []const u8, + searchFn: anytype, + query: []const u8, +) ToolError!ToolResult { + const api_key = std.posix.getenv(env_var) orelse return .{ + .text = "web search engine is set to " ++ label ++ " but " ++ env_var ++ " is not set in the environment", + .is_error = true, + }; + const markdown_ = searchFn(arena, api_key, query) catch |err| { + const msg = std.fmt.allocPrint(arena, label ++ " search failed: {s}", .{@errorName(err)}) catch + return ToolError.OutOfMemory; + return .{ .text = msg, .is_error = true }; + }; + return .{ .text = markdown_ }; } /// Thin wrapper over `zenai.search.tavily.Client` that handles client @@ -1015,6 +1056,45 @@ fn formatTavilyMarkdown(arena: std.mem.Allocator, resp: tavily.types.SearchRespo return aw.written(); } +/// Thin wrapper over `zenai.search.brave.Client` that handles client +/// lifetime and renders the structured response as markdown for the agent. +/// `arena` owns the returned slice. `api_key` is the value of BRAVE_API_KEY. +fn braveSearch( + arena: std.mem.Allocator, + api_key: []const u8, + query: []const u8, +) ![]const u8 { + var client: brave.Client = .init(arena, api_key, .{}); + defer client.deinit(); + + // text_decorations=false: no markup in model-read snippets. + var response = client.search(query, .{ .count = 10, .text_decorations = false }) catch |err| { + if (client.last_error_status) |status| { + log.warn(.browser, "brave non-2xx", .{ + .status = status, + .body = client.last_error_body orelse "", + }); + } + return err; + }; + defer response.deinit(); + + return formatBraveMarkdown(arena, response.value); +} + +fn formatBraveMarkdown(arena: std.mem.Allocator, resp: brave.types.SearchResponse) ![]const u8 { + var aw: std.Io.Writer.Allocating = .init(arena); + const w = &aw.writer; + const results: []const brave.types.Result = if (resp.web) |web| web.results else &.{}; + for (results, 0..) |r, i| { + try w.print("{d}. **{s}** — {s}\n {s}\n\n", .{ i + 1, r.title, r.url, r.description }); + } + if (results.len == 0) { + try w.writeAll("No results."); + } + return aw.written(); +} + fn renderFrameMarkdown(arena: std.mem.Allocator, frame: *lp.Frame) ToolError![]const u8 { var aw: std.Io.Writer.Allocating = .init(arena); lp.markdown.dump(frame.document.asNode(), .{}, &aw.writer, frame) catch @@ -2236,6 +2316,39 @@ test "formatTavilyMarkdown handles empty results" { try std.testing.expectEqualStrings("No results.", md); } +test "formatBraveMarkdown renders web results" { + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const aa = arena.allocator(); + + const resp: brave.types.SearchResponse = .{ + .web = .{ + .results = &.{ + .{ .title = "Paris - Wikipedia", .url = "https://en.wikipedia.org/wiki/Paris", .description = "Paris is the capital of France." }, + .{ .title = "France", .url = "https://example.org/fr", .description = "Country in Western Europe." }, + }, + }, + }; + + const md = try formatBraveMarkdown(aa, resp); + try std.testing.expect(std.mem.indexOf(u8, md, "1. **Paris - Wikipedia**") != null); + try std.testing.expect(std.mem.indexOf(u8, md, "https://en.wikipedia.org/wiki/Paris") != null); + try std.testing.expect(std.mem.indexOf(u8, md, "Paris is the capital of France.") != null); + try std.testing.expect(std.mem.indexOf(u8, md, "2. **France**") != null); +} + +test "formatBraveMarkdown handles empty results" { + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const aa = arena.allocator(); + + const md_no_web = try formatBraveMarkdown(aa, .{}); + try std.testing.expectEqualStrings("No results.", md_no_web); + + const md_empty_web = try formatBraveMarkdown(aa, .{ .web = .{} }); + try std.testing.expectEqualStrings("No results.", md_empty_web); +} + test "isPathSafe: relative paths without traversal are accepted" { try std.testing.expect(isPathSafe("foo.txt")); try std.testing.expect(isPathSafe("./foo.txt")); From 542d9a333b9570e1a24a3b17bfcebdade77c102f Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 23 Jul 2026 20:10:40 +0800 Subject: [PATCH 02/34] perf, fix: Avoid tight tick loop before page loads https://github.com/lightpanda-io/browser/pull/2999 fixed a spin loop when the page had no i/o but had tasks, something we'd expect to see at page-load end. But it introduced a similar spin loop as what it fixed on page-start, before the page has anything to do. Specifically, 2999 preventing i/o pollings when tasks were waiting. But, the code doesn't run tasks until there's a "runnable" task. On Frame start, we register a background task. Net result is that, for the first 200ms, everything is fine, but then the task is due, so we don't poll, but we also don't run the tasks, repeating a tick(0) loop. The solution is simply to disable the 2999 optimization when there's no runnable page. --- src/browser/Runner.zig | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/browser/Runner.zig b/src/browser/Runner.zig index 7ecc63061..0c4065a4f 100644 --- a/src/browser/Runner.zig +++ b/src/browser/Runner.zig @@ -217,7 +217,9 @@ fn _tick(self: *Runner, comptime is_cdp: bool, timeout_ms: u32, conditions: []Wa return .{ .ok = 0 }; } - if (hasRunnablePage(session)) { + const has_runnable_page = hasRunnablePage(session); + + if (has_runnable_page) { try browser.runMacrotasks(); } @@ -295,12 +297,18 @@ fn _tick(self: *Runner, comptime is_cdp: bool, timeout_ms: u32, conditions: []Wa } if ((comptime is_cdp) or want_http_tick) { - var ms_to_wait = @min(timeout_ms, browser.msToNextTask() orelse 200); - if (browser.hasBackgroundTasks()) { - // background work will queue more to do soon — don't block long - // for a client message; loop back and run macrotasks instead. - ms_to_wait = @min(ms_to_wait, 10); - } + const ms_to_next_task = blk: { + if (has_runnable_page == false) { + break :blk 200; + } + if (browser.hasBackgroundTasks()) { + // msToNextTask could be less than this, but 10ms drift is ok + break :blk 10; + } + break :blk browser.msToNextTask() orelse 200; + }; + const ms_to_wait = @min(timeout_ms, ms_to_next_task); + const waited = try http_client.tick(@intCast(ms_to_wait)); // If the HttpClient didn't wait/poll then it has nothing to do, and we From 2b945aa1f9463cef0edaf99a6964a430b3730489 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 24 Jul 2026 08:20:32 +0800 Subject: [PATCH 03/34] cdp: ignore duplicate enable calls CDP driver can send multiple Network.enable which would register the same listener multiple times. This commit makes it so that only one (the first) callback registered for a listener+eventtype is used. Subsequent registration for the same listener+eventtype are ignored. This is safe because all callbacks are currently static. It's a mistake (enforced by a debug-only assertion) for code to try to register a different callback for an already registered listener+ eventtype. This generalizes https://github.com/lightpanda-io/browser/pull/3038 --- src/Notification.zig | 20 +++++++++++++------- src/cdp/CDP.zig | 13 +------------ 2 files changed, 14 insertions(+), 19 deletions(-) diff --git a/src/Notification.zig b/src/Notification.zig index 55dcc4c1b..e8830f9a7 100644 --- a/src/Notification.zig +++ b/src/Notification.zig @@ -392,8 +392,20 @@ pub fn deinit(self: *Notification) void { } pub fn register(self: *Notification, comptime event: EventType, receiver: anytype, func: EventFunc(event)) !void { - var list = &@field(self.event_listeners, @tagName(event)); + const allocator = self.allocator; + const gop = try self.listeners.getOrPut(allocator, @intFromPtr(receiver)); + if (gop.found_existing) { + for (gop.value_ptr.items) |existing| { + if (existing.event == event) { + lp.assert(@as(*const anyopaque, @ptrCast(func)) == existing.func, "different notification callbacks per receiver", .{.event = event}); + return; + } + } + } else { + gop.value_ptr.* = .empty; + } + var list = &@field(self.event_listeners, @tagName(event)); var listener = try self.mem_pool.create(); errdefer self.mem_pool.destroy(listener); @@ -405,12 +417,6 @@ pub fn register(self: *Notification, comptime event: EventType, receiver: anytyp .func = @ptrCast(func), .struct_name = @typeName(@typeInfo(@TypeOf(receiver)).pointer.child), }; - - const allocator = self.allocator; - const gop = try self.listeners.getOrPut(allocator, @intFromPtr(receiver)); - if (gop.found_existing == false) { - gop.value_ptr.* = .empty; - } try gop.value_ptr.append(allocator, listener); // we don't add this until we've successfully added the entry to diff --git a/src/cdp/CDP.zig b/src/cdp/CDP.zig index 7cf27b991..bac8a64a6 100644 --- a/src/cdp/CDP.zig +++ b/src/cdp/CDP.zig @@ -569,10 +569,6 @@ pub const BrowserContext = struct { http_proxy_changed: bool = false, user_agent_changed: bool = false, - // True once we've registered for the download notifications, so repeated - // Browser.setDownloadBehavior calls don't add duplicate listeners. - download_events_registered: bool = false, - // Extra headers to add to all requests. extra_headers: std.ArrayList([*c]const u8) = .empty, @@ -826,6 +822,7 @@ pub const BrowserContext = struct { } pub fn fetchEnable(self: *BrowserContext, authRequests: bool) !void { + self.fetchDisable(); //in case of multiple calls try self.notification.register(.http_request_intercept, self, onHttpRequestIntercept); if (authRequests) { try self.notification.register(.http_request_auth_required, self, onHttpRequestAuthRequired); @@ -868,21 +865,13 @@ pub const BrowserContext = struct { // Registers for the download notifications dispatched by Frame when a // navigation is treated as a file download. Idempotent. See issue #2701. pub fn downloadEventsEnable(self: *BrowserContext) !void { - if (self.download_events_registered) { - return; - } try self.notification.register(.download_will_begin, self, onDownloadWillBegin); try self.notification.register(.download_progress, self, onDownloadProgress); - self.download_events_registered = true; } pub fn downloadEventsDisable(self: *BrowserContext) void { - if (self.download_events_registered == false) { - return; - } self.notification.unregister(.download_will_begin, self); self.notification.unregister(.download_progress, self); - self.download_events_registered = false; } pub fn onDownloadWillBegin(ctx: *anyopaque, msg: *const Notification.DownloadWillBegin) !void { From a6af331ab2bdc17eb333d40fb79c82ba5ff016aa Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 24 Jul 2026 08:25:21 +0800 Subject: [PATCH 04/34] zig fmt --- src/Notification.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Notification.zig b/src/Notification.zig index e8830f9a7..74fd5af3f 100644 --- a/src/Notification.zig +++ b/src/Notification.zig @@ -397,7 +397,7 @@ pub fn register(self: *Notification, comptime event: EventType, receiver: anytyp if (gop.found_existing) { for (gop.value_ptr.items) |existing| { if (existing.event == event) { - lp.assert(@as(*const anyopaque, @ptrCast(func)) == existing.func, "different notification callbacks per receiver", .{.event = event}); + lp.assert(@as(*const anyopaque, @ptrCast(func)) == existing.func, "different notification callbacks per receiver", .{ .event = event }); return; } } From ed9a25f470b2c7f66706721a4a5dc28ad8506b48 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 24 Jul 2026 10:18:59 +0800 Subject: [PATCH 05/34] fix test --- src/cdp/domains/browser.zig | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/cdp/domains/browser.zig b/src/cdp/domains/browser.zig index b690f8301..c05a4a5a8 100644 --- a/src/cdp/domains/browser.zig +++ b/src/cdp/domains/browser.zig @@ -320,7 +320,6 @@ test "cdp.browser: setDownloadBehavior stores config on the session" { try testing.expectEqual(.allow_and_name, bc.session.download_behavior); try testing.expectEqualSlices(u8, "/tmp/lp-downloads", bc.session.download_path.?); try testing.expect(bc.session.download_events_enabled); - try testing.expect(bc.download_events_registered); // `default` maps to `deny` and tears the registration down again. try ctx.processMessage(.{ @@ -331,7 +330,6 @@ test "cdp.browser: setDownloadBehavior stores config on the session" { try testing.expectEqual(.deny, bc.session.download_behavior); try testing.expect(bc.session.download_path == null); try testing.expect(bc.session.download_events_enabled == false); - try testing.expect(bc.download_events_registered == false); } test "cdp.browser: setDownloadBehavior is a no-op when no context is loaded" { From 122a4f1001d72078967b56e65b2d48f45eb8ad73 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 24 Jul 2026 11:23:00 +0800 Subject: [PATCH 06/34] perf: improve ResizeObserver performance https://github.com/lightpanda-io/browser/pull/3000 improved the correctness of ResizeObserver. The main changes were (a) making sure an observe results in an initial callback and (b) invoking the callback for cases that we can identity (e.g. visibility change). Like the other observers, we triggered a check on domChanged. But, unlike the other observers, the check is relatively expensive, namely because it involves style lookups. This commit introduces a number of performance improvements to reduce the check and dispatch frequency. 1 - Only trigger on an allow list of attribute changed (id, class, hidden, width ...) 2 - Pre-filter only on observed elements 3 - Leverage the visibility cache for more efficient delivery --- src/browser/Frame.zig | 2 +- src/browser/StyleManager.zig | 2 + src/browser/frame/observers.zig | 97 ++++++++++++++++++++++++--- src/browser/js/Context.zig | 11 +++ src/browser/webapi/ResizeObserver.zig | 37 +++++++++- src/string.zig | 14 +++- 6 files changed, 148 insertions(+), 15 deletions(-) diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 827aa3a3c..de726838c 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -1956,7 +1956,7 @@ pub fn domChanged(self: *Frame) void { // A DOM change is our "rendering opportunity": re-evaluate the layout // observers. Both are no-ops unless something they track actually changed. observers.scheduleIntersectionChecks(self); - observers.scheduleResizeDelivery(self); + observers.scheduleResizeChecks(self); } const ElementIdMaps = struct { lookup: *std.StringHashMapUnmanaged(*Element), removed_ids: *std.StringHashMapUnmanaged(void) }; diff --git a/src/browser/StyleManager.zig b/src/browser/StyleManager.zig index 868545c19..0001991dc 100644 --- a/src/browser/StyleManager.zig +++ b/src/browser/StyleManager.zig @@ -511,10 +511,12 @@ fn addRawRule(self: *StyleManager, build_arena: Allocator, selector_text: []cons pub fn sheetRemoved(self: *StyleManager) void { self.dirty = true; + Frame.observers.scheduleResizeDelivery(self.frame); } pub fn sheetModified(self: *StyleManager) void { self.dirty = true; + Frame.observers.scheduleResizeDelivery(self.frame); } /// Rebuilds the rule list from all document stylesheets. diff --git a/src/browser/frame/observers.zig b/src/browser/frame/observers.zig index b0b42bd43..8fadf65be 100644 --- a/src/browser/frame/observers.zig +++ b/src/browser/frame/observers.zig @@ -16,10 +16,11 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -// Per-frame MutationObserver and IntersectionObserver bookkeeping: registration, -// the scheduling of the microtask deliveries, and broadcasting DOM mutations to -// the registered observers. The state lives on the Frame (frame._mutation / -// frame._intersection); these functions operate on it. +// Per-frame MutationObserver, IntersectionObserver and ResizeObserver +// bookkeeping: registration, the scheduling of the microtask deliveries, and +// broadcasting DOM mutations to the registered observers. The state lives on +// the Frame (frame._mutation / frame._intersection / frame._resize); these +// functions operate on it. const std = @import("std"); const lp = @import("lightpanda"); @@ -57,6 +58,7 @@ pub const Intersection = struct { pub const Resize = struct { // List of active ResizeObservers (i.e. those with >= 1 observation) observers: std.ArrayList(*ResizeObserver) = .empty, + check_scheduled: bool = false, delivery_scheduled: bool = false, delivery_depth: u32 = 0, }; @@ -171,6 +173,81 @@ pub fn scheduleResizeDelivery(frame: *Frame) void { }; } +// Called on every DOM change, a full delivery is too expensive to call here. +// What we can do is schedule a check. +pub fn scheduleResizeChecks(frame: *Frame) void { + if (frame._resize.observers.items.len == 0) { + return; + } + if (frame._resize.check_scheduled or frame._resize.delivery_scheduled) { + // check is already scheduled OR delivery is already scheduled + return; + } + frame._resize.check_scheduled = true; + frame.js.queueResizeChecks() catch |err| { + frame._resize.check_scheduled = false; + log.err(.frame, "frame.scheduleResizeChecks", .{ .err = err, .type = frame._type, .url = frame.url }); + }; +} + +pub fn performScheduledResizeChecks(frame: *Frame) void { + if (!frame._resize.check_scheduled) { + return; + } + + frame._resize.check_scheduled = false; + if (frame._resize.delivery_scheduled) { + return; + } + + // Check if we should schedule a delivery. If you're wondering why we're + // scheduling within a schedule, it's because this can be expensive and we + // want to coalesce as many of these into a single call as possible. + for (frame._resize.observers.items) |observer| { + if (observer.connectivityChanged()) { + scheduleResizeDelivery(frame); + return; + } + } +} + +// Only these attributes can change an element's size or visibility in our +// styling model (StyleManager.isHidden + Element.getElementDimensions), and +// only for the element itself and its descendants — so a delivery is only +// scheduled when an observed element is in the changed element's subtree. +fn resizeAttributeChanged(frame: *Frame, element: *Element, name: String) void { + if (frame._resize.observers.items.len == 0) { + return; + } + if (frame._resize.delivery_scheduled) { + return; + } + + // This has proven to be on the hot path + switch (name.len) { + 2 => if (!name.eqlWithSameLen(comptime .wrap("id"))) { + return; + }, + 4 => if (!name.eqlWithSameLen(comptime .wrap("type")) and !name.eqlWithSameLen(comptime .wrap("open"))) { + return; + }, + 5 => if (!name.eqlWithSameLen(comptime .wrap("style")) and !name.eqlWithSameLen(comptime .wrap("class")) and !name.eqlWithSameLen(comptime .wrap("width"))) { + return; + }, + 6 => if (!name.eqlWithSameLen(comptime .wrap("hidden")) and !name.eqlWithSameLen(comptime .wrap("height"))) { + return; + }, + else => return, + } + + for (frame._resize.observers.items) |observer| { + if (observer.observesWithin(element)) { + scheduleResizeDelivery(frame); + return; + } + } +} + pub fn deliverResizes(frame: *Frame) void { if (!frame._resize.delivery_scheduled) { return; @@ -183,7 +260,7 @@ pub fn deliverResizes(frame: *Frame) void { defer if (!frame._resize.delivery_scheduled) { frame._resize.delivery_depth = 0; }; - if (frame._resize.delivery_depth > 50) { + if (frame._resize.delivery_depth > 16) { log.warn(.frame, "frame.ResizeLimit", .{ .type = frame._type, .url = frame.url }); frame._resize.delivery_depth = 0; return; @@ -242,7 +319,7 @@ pub fn deliverMutations(frame: *Frame) void { frame._mutation.delivery_depth = 0; }; - if (frame._mutation.delivery_depth > 50) { + if (frame._mutation.delivery_depth > 16) { log.err(.frame, "frame.MutationLimit", .{ .type = frame._type, .url = frame.url }); frame._mutation.delivery_depth = 0; return; @@ -292,9 +369,10 @@ pub fn deliverMutations(frame: *Frame) void { } } -// Broadcast an attribute change to every registered MutationObserver. The -// caller (Frame.attributeChange / attributeRemove) handles the non-observer -// side effects (build hooks, custom-element callbacks, slot/popover updates). +// Broadcast an attribute change to every registered MutationObserver and to +// the resize bookkeeping. The caller (Frame.attributeChange / attributeRemove) +// handles the non-observer side effects (build hooks, custom-element +// callbacks, slot/popover updates). pub fn notifyAttributeChange(frame: *Frame, element: *Element, name: String, old_value: ?String) void { var it: ?*std.DoublyLinkedList.Node = frame._mutation.observers.first; while (it) |node| : (it = node.next) { @@ -303,6 +381,7 @@ pub fn notifyAttributeChange(frame: *Frame, element: *Element, name: String, old log.err(.frame, "attributeChange.notifyObserver", .{ .err = err, .type = frame._type, .url = frame.url }); }; } + resizeAttributeChanged(frame, element, name); } pub fn notifyCharacterDataChange(frame: *Frame, target: *Node, old_value: String) void { diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index f8c5163e8..0641a06ab 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -1093,6 +1093,17 @@ pub fn queueIntersectionDelivery(self: *Context) !void { }.run); } +pub fn queueResizeChecks(self: *Context) !void { + self.enqueueMicrotask(struct { + fn run(ctx: *Context) void { + switch (ctx.global) { + .frame => |frame| Frame.observers.performScheduledResizeChecks(frame), + .worker => unreachable, + } + } + }.run); +} + pub fn queueResizeDelivery(self: *Context) !void { self.enqueueMicrotask(struct { fn run(ctx: *Context) void { diff --git a/src/browser/webapi/ResizeObserver.zig b/src/browser/webapi/ResizeObserver.zig index 0029ca2a0..6dac4a385 100644 --- a/src/browser/webapi/ResizeObserver.zig +++ b/src/browser/webapi/ResizeObserver.zig @@ -52,6 +52,7 @@ _observations: std.ArrayList(Observation) = .empty, const Observation = struct { target: *Element, + connected: bool = false, last_width: f64 = 0, last_height: f64 = 0, }; @@ -123,18 +124,50 @@ pub fn disconnect(self: *ResizeObserver, frame: *Frame) void { Frame.observers.unregisterResizeObserver(frame, self); } +// True if any observed element's connectedness changed since the last +// delivery. Runs on every DOM change (via Frame.observers), so it must stay +// pointer walks only — no style lookups. +pub fn connectivityChanged(self: *const ResizeObserver) bool { + for (self._observations.items) |obs| { + if (obs.target.asNode().isConnected() != obs.connected) { + return true; + } + } + return false; +} + +// True if any observed element is `ancestor` or one of its descendants. Walks +// the same parentElement chain StyleManager.isHidden resolves visibility +// through. +pub fn observesWithin(self: *const ResizeObserver, ancestor: *Element) bool { + for (self._observations.items) |obs| { + var current: ?*Element = obs.target; + while (current) |el| : (current = el.parentElement()) { + if (el == ancestor) { + return true; + } + } + } + return false; +} + // Gather the observations whose size changed since the last delivery and, if // any, invoke the callback. pub fn deliverEntries(self: *ResizeObserver, frame: *Frame) !void { var entries: std.ArrayList(*ResizeObserverEntry) = .empty; + // Observed elements share ancestors, so one cache serves the whole pass. + var visibility_cache: Element.VisibilityCache = .empty; for (self._observations.items) |*obs| { const target = obs.target; + const connected = target.asNode().isConnected(); + obs.connected = connected; const width, const height = blk: { - if (obs.target.asNode().isConnected() == false) { + if (!connected or !target.checkVisibilityCached(&visibility_cache, frame)) { break :blk .{ 0, 0 }; } - break :blk .{ target.getClientWidth(frame), target.getClientHeight(frame) }; + const dims = target.getElementDimensions(frame); + break :blk .{ dims.width, dims.height }; }; if (width == obs.last_width and height == obs.last_height) { diff --git a/src/string.zig b/src/string.zig index 384031e26..437133ebf 100644 --- a/src/string.zig +++ b/src/string.zig @@ -179,16 +179,24 @@ pub const String = packed struct { return false; } - const len = a.len; - if (len < 0 or b.len < 0) { + if (a.len < 0 or b.len < 0) { return false; } + return eqlWithSameLen(a, b); + } + // Dangerous. Use this only when you have to (and, obviously, when you know + // a.len == b.len) + pub fn eqlWithSameLen(a: String, b: String) bool { + if (comptime IS_DEBUG) { + std.debug.assert(a.len == b.len); + } + + const len = a.len; if (len <= 12) { return a.payload.content == b.payload.content; } - // a.len == b.len at this point const al: usize = @intCast(len); const bl: usize = @intCast(len); const ap: [*]const u8 = @ptrFromInt(a.payload.heap.ptr); From 5a59743f135cb00f014eb12de905170e351d61da Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 24 Jul 2026 17:17:33 +0800 Subject: [PATCH 07/34] stability: Poll terminate state during large HTML parsing V8's terminate isn't pre-emptive. We can arm it, but it still hast to cross a boundary to fire. JavaScript that triggers a near endless Zig loop won't be able to interrupt the Zig code: ``` while (true) { el.innerHTML += el.innerHTML; } ``` Can generate a huge HTML input that the Parser will work on for ages. I don't have a general solution to this. I've made the Parser (and DOMParser) check the terminate state every 1024 appends. This only covers one specific case, but it's possibly common enough to guard again, specifically because we don't have rendering/dimensions, and many websites will append text until certain dimensions are reached. But it wouldn't for example, catch a similar: while (true) { el.appendChild(el.firstChild.cloneNode(true)); } --- src/browser/Frame.zig | 3 ++ src/browser/parser/Parser.zig | 52 ++++++++++++++++++++++++++++++++ src/browser/webapi/DOMParser.zig | 6 ++++ 3 files changed, 61 insertions(+) diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 827aa3a3c..8a3c227b4 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -2910,6 +2910,9 @@ fn parseHtmlAsChildrenInner(self: *Frame, node: *Node, html: []const u8, opts: F var parser = Parser.init(self.call_arena, node, self, .{ .allow_declarative_shadow = opts.allow_declarative_shadow }); parser.parseFragment(html); + if (parser.terminated) { + return error.ExecutionTerminated; + } // html5ever wraps fragment output in an element; unwrap so its // children land directly on `node`. See https://github.com/servo/html5ever/issues/583. diff --git a/src/browser/parser/Parser.zig b/src/browser/parser/Parser.zig index d252aa9db..b95e4715b 100644 --- a/src/browser/parser/Parser.zig +++ b/src/browser/parser/Parser.zig @@ -28,6 +28,7 @@ const CData = @import("../webapi/CData.zig"); pub const AttributeIterator = h5e.AttributeIterator; const Allocator = std.mem.Allocator; +const TERMINATE_CHECK_INTERVAL = 1024; const IS_DEBUG = @import("builtin").mode == .Debug; pub const ParsedNode = struct { @@ -76,6 +77,9 @@ buf: std.ArrayList(u8), // innerHTML and DOMParser (per spec). Set from Options at init. allow_declarative_shadow: bool = false, +terminated: bool = false, +appends_until_terminate_check: u16 = TERMINATE_CHECK_INTERVAL, + pub const Options = struct { allow_declarative_shadow: bool = false, }; @@ -428,6 +432,9 @@ fn parseErrorCallback(ctx: *anyopaque, err: h5e.StringSlice) callconv(.c) void { fn popCallback(ctx: *anyopaque, node_ref: *anyopaque) callconv(.c) void { const self: *Parser = @ptrCast(@alignCast(ctx)); + if (self.terminated) { + return; + } const cp = self.frame._ce_reactions.push(); defer self.frame._ce_reactions.popAndInvoke(cp, self.frame); self._popCallback(getNode(node_ref)) catch |err| { @@ -535,6 +542,9 @@ fn _createProcessingInstruction(self: *Parser, target: []const u8, data: []const fn appendDoctypeToDocument(ctx: *anyopaque, name: h5e.StringSlice, public_id: h5e.StringSlice, system_id: h5e.StringSlice) callconv(.c) void { const self: *Parser = @ptrCast(@alignCast(ctx)); + if (self.terminated) { + return; + } const cp = self.frame._ce_reactions.push(); defer self.frame._ce_reactions.popAndInvoke(cp, self.frame); self._appendDoctypeToDocument(name.slice(), public_id.slice(), system_id.slice()) catch |err| { @@ -559,6 +569,9 @@ fn _appendDoctypeToDocument(self: *Parser, name: []const u8, public_id: []const fn addAttrsIfMissingCallback(ctx: *anyopaque, target_ref: *anyopaque, attributes: h5e.AttributeIterator) callconv(.c) void { const self: *Parser = @ptrCast(@alignCast(ctx)); + if (self.terminated) { + return; + } const cp = self.frame._ce_reactions.push(); defer self.frame._ce_reactions.popAndInvoke(cp, self.frame); self._addAttrsIfMissingCallback(getNode(target_ref), attributes) catch |err| { @@ -607,6 +620,7 @@ fn _getTemplateContentsCallback(self: *Parser, node: *Node) !*anyopaque { // fall back to inserting the template as a normal light-DOM element. fn attachDeclarativeShadowCallback(ctx: *anyopaque, host_ref: *anyopaque, template_ref: *anyopaque, mode_is_open: u8) callconv(.c) u8 { const self: *Parser = @ptrCast(@alignCast(ctx)); + if (self.terminated) return 0; return self._attachDeclarativeShadowCallback(getNode(host_ref), getNode(template_ref), mode_is_open != 0) catch |err| { self.err = .{ .err = err, .source = .attach_declarative_shadow }; return 0; @@ -643,6 +657,10 @@ fn getDataCallback(ctx: *anyopaque) callconv(.c) *anyopaque { fn appendCallback(ctx: *anyopaque, parent_ref: *anyopaque, node_or_text: h5e.NodeOrText) callconv(.c) void { const self: *Parser = @ptrCast(@alignCast(ctx)); + if (self.pollTerminate()) { + return; + } + const cp = self.frame._ce_reactions.push(); defer self.frame._ce_reactions.popAndInvoke(cp, self.frame); self._appendCallback(getNode(parent_ref), node_or_text) catch |err| { @@ -677,6 +695,9 @@ fn _appendCallback(self: *Parser, parent: *Node, node_or_text: h5e.NodeOrText) ! fn removeFromParentCallback(ctx: *anyopaque, target_ref: *anyopaque) callconv(.c) void { const self: *Parser = @ptrCast(@alignCast(ctx)); + if (self.terminated) { + return; + } const cp = self.frame._ce_reactions.push(); defer self.frame._ce_reactions.popAndInvoke(cp, self.frame); self._removeFromParentCallback(getNode(target_ref)) catch |err| { @@ -695,6 +716,9 @@ fn _removeFromParentCallback(self: *Parser, node: *Node) !void { fn reparentChildrenCallback(ctx: *anyopaque, node_ref: *anyopaque, new_parent_ref: *anyopaque) callconv(.c) void { const self: *Parser = @ptrCast(@alignCast(ctx)); + if (self.terminated) { + return; + } const cp = self.frame._ce_reactions.push(); defer self.frame._ce_reactions.popAndInvoke(cp, self.frame); self._reparentChildrenCallback(getNode(node_ref), getNode(new_parent_ref)) catch |err| { @@ -711,6 +735,10 @@ fn _reparentChildrenCallback(self: *Parser, node: *Node, new_parent: *Node) !voi fn appendBeforeSiblingCallback(ctx: *anyopaque, sibling_ref: *anyopaque, node_or_text: h5e.NodeOrText) callconv(.c) void { const self: *Parser = @ptrCast(@alignCast(ctx)); + if (self.pollTerminate()) { + return; + } + const cp = self.frame._ce_reactions.push(); defer self.frame._ce_reactions.popAndInvoke(cp, self.frame); self._appendBeforeSiblingCallback(getNode(sibling_ref), node_or_text) catch |err| { @@ -741,6 +769,10 @@ fn _appendBeforeSiblingCallback(self: *Parser, sibling: *Node, node_or_text: h5e fn appendBasedOnParentNodeCallback(ctx: *anyopaque, element_ref: *anyopaque, prev_element_ref: *anyopaque, node_or_text: h5e.NodeOrText) callconv(.c) void { const self: *Parser = @ptrCast(@alignCast(ctx)); + if (self.pollTerminate()) { + return; + } + const cp = self.frame._ce_reactions.push(); defer self.frame._ce_reactions.popAndInvoke(cp, self.frame); self._appendBasedOnParentNodeCallback(getNode(element_ref), getNode(prev_element_ref), node_or_text) catch |err| { @@ -772,3 +804,23 @@ fn asUint(comptime string: anytype) std.meta.Int( return @bitCast(@as(*const [byteLength]u8, string).*); } + +// v8's terminate isn't pre-emptive. A parse of unbounded input +// (this.innerHTML += this.innerHTML) has to poll the terminate flag itself. +// We'll poll this (atomic) variable every TERMINATE_CHECK_INTERVAL append. +fn pollTerminate(self: *Parser) bool { + if (self.terminated) { + return true; + } + + const next_check = self.appends_until_terminate_check - 1; + if (next_check > 0) { + self.appends_until_terminate_check = next_check; + return false; + } + self.appends_until_terminate_check = TERMINATE_CHECK_INTERVAL; + + const terminated = self.frame.js.env.terminatePending(); + self.terminated = terminated; + return terminated; +} diff --git a/src/browser/webapi/DOMParser.zig b/src/browser/webapi/DOMParser.zig index c85b497d2..57c0f4080 100644 --- a/src/browser/webapi/DOMParser.zig +++ b/src/browser/webapi/DOMParser.zig @@ -79,6 +79,9 @@ pub fn parseFromString( // Parse HTML into the document var parser = Parser.init(arena, doc.asNode(), frame, .{}); parser.parse(normalized); + if (parser.terminated) { + return error.ExecutionTerminated; + } if (parser.err) |pe| { return pe.err; @@ -96,6 +99,9 @@ pub fn parseFromString( const doc_node = doc.asNode(); var parser = Parser.init(arena, doc_node, frame, .{}); parser.parseXML(html); + if (parser.terminated) { + return error.ExecutionTerminated; + } if (parser.err != null or doc_node.firstChild() == null) { // Return a document with a element per spec. From 51c9115e9bb1fb8c8c38b78ad7be75e948dc55f9 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Fri, 24 Jul 2026 11:48:32 +0200 Subject: [PATCH 08/34] derive scrollWidth/scrollHeight from element content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both aliased clientWidth/clientHeight, computed from the element's own box without looking at its children, so any "measure, mutate, re-measure" loop never terminated. The marquee idiom on readthetrieb.com is the reported case: while (lane.scrollWidth < track.offsetWidth * 2) lane.innerHTML += lane.innerHTML; Doubling the markup left scrollWidth unchanged, so the loop spun while innerHTML grew the DOM exponentially and wedged the page in under a second. Return max(clientSize, contentSize), summing the direct child elements. - No layout-mode detection: getStyle() sees only the inline style attribute, so display:flex or white-space:nowrap from a stylesheet is invisible. Each axis assumes the arrangement that produces overflow — width lays children in a row, height stacks them — bounding content extent per axis rather than modelling one layout. - Text children are not measured. Estimating a run from its length needs a per-character advance that tracks font-size, or shrink-to-fit loops stop converging, and it reports overflow for nearly every element holding text. - Direct children only, so cost is O(fan-out), with a shared VisibilityCache collapsing N ancestor walks into one. - / keep their synthetic defaults, so page-level overflow and infinite-scroll checks are unaffected; only inner containers change. Tests in element/position.html cover growth per child, text contributing to neither axis, hidden elements, the root carve-out, and both loops terminating. --- src/browser/tests/element/position.html | 176 +++++++++++++++++++++++- src/browser/webapi/Element.zig | 106 +++++++++++++- 2 files changed, 277 insertions(+), 5 deletions(-) diff --git a/src/browser/tests/element/position.html b/src/browser/tests/element/position.html index e8137bbad..6a846dfa8 100644 --- a/src/browser/tests/element/position.html +++ b/src/browser/tests/element/position.html @@ -24,12 +24,186 @@ { const test1 = $('#test1'); - // In dummy layout, scroll dimensions equal client dimensions (no overflow) + // Both scroll dimensions are approximated from the content, so neither falls + // below the element's own box but either may exceed it. + testing.expectTrue(test1.scrollWidth >= test1.clientWidth); + testing.expectTrue(test1.scrollHeight >= test1.clientHeight); + + // test1 holds only a text node, and text is not measured on either axis, so + // both still match the element's own box. testing.expectEqual(test1.clientWidth, test1.scrollWidth); testing.expectEqual(test1.clientHeight, test1.scrollHeight); } + + + + + + + + + diff --git a/src/browser/webapi/net/FormData.zig b/src/browser/webapi/net/FormData.zig index d4ffce43c..bf87bd789 100644 --- a/src/browser/webapi/net/FormData.zig +++ b/src/browser/webapi/net/FormData.zig @@ -25,6 +25,7 @@ const Frame = @import("../../Frame.zig"); const Form = @import("../element/html/Form.zig"); const Element = @import("../Element.zig"); const File = @import("../File.zig"); +const Blob = @import("../Blob.zig"); const KeyValueList = @import("../KeyValueList.zig"); const log = lp.log; @@ -165,9 +166,65 @@ pub fn has(self: *const FormData, name: String) bool { return false; } -pub fn set(self: *FormData, name: String, value: []const u8, exec: *Execution) !void { +// The value half of the JS-facing append()/set(). `bytes` must stay last: +// js.Bridge tries the JsApi classes first and maps anything else to a string, +// so an earlier position would swallow File and Blob arguments. +pub const EntryValue = union(enum) { + file: *File, + blob: *Blob, + bytes: []const u8, +}; + +pub fn jsSet(self: *FormData, name: String, value: EntryValue, filename: ?[]const u8, exec: *Execution) !void { self.deleteByName(name, exec); - return self.append(name.str(), value); + return self.jsAppend(name.str(), value, filename, exec); +} + +// https://xhr.spec.whatwg.org/#create-an-entry +pub fn jsAppend(self: *FormData, name: []const u8, value: EntryValue, filename: ?[]const u8, exec: *Execution) !void { + const entry_value: Entry.Value = switch (value) { + // A Blob that is not a File becomes a File named "blob", unless a + // filename was supplied. + .blob => |blob| blk: { + if (blob._type == .file and filename == null) { + const file = blob._type.file; + file.acquireRef(); + break :blk .{ .file = file }; + } + break :blk .{ .file = try fileFrom(blob, filename orelse "blob", exec.page) }; + }, + // A File keeps its bytes; a supplied filename means a new File over + // the same bytes rather than a rename of the caller's object. + .file => |file| blk: { + if (filename) |n| break :blk .{ .file = try fileFrom(file._proto, n, exec.page) }; + file.acquireRef(); + break :blk .{ .file = file }; + }, + .bytes => |b| .{ .string = try String.init(self._arena, b, .{}) }, + }; + + try self._entries.append(self._arena, .{ + .name = try String.init(self._arena, name, .{}), + .value = entry_value, + }); +} + +// Mirrors File.init — a Blob and File sharing one reference-counted arena — +// but over bytes we already hold rather than JS parts. Returned at refcount 1: +// the entry owns that reference and deleteByName releases it. +fn fileFrom(source: *Blob, name: []const u8, page: *Page) !*File { + const blob = try Blob.initFromBytes(source._slice, source._mime, page); + errdefer blob.deinit(page); + + const file = try blob._arena.create(File); + file.* = .{ + ._proto = blob, + ._name = try blob._arena.dupe(u8, name), + ._last_modified = std.Io.Clock.now(.real, lp.io).toMilliseconds(), + }; + blob._type = .{ .file = file }; + blob.acquireRef(); + return file; } pub fn append(self: *FormData, name: []const u8, value: []const u8) !void { @@ -511,8 +568,8 @@ pub const JsApi = struct { pub const constructor = bridge.constructor(FormData.init, .{}); pub const has = bridge.function(FormData.has, .{}); pub const get = bridge.function(FormData.get, .{}); - pub const set = bridge.function(FormData.set, .{}); - pub const append = bridge.function(FormData.append, .{}); + pub const set = bridge.function(FormData.jsSet, .{}); + pub const append = bridge.function(FormData.jsAppend, .{}); pub const getAll = bridge.function(FormData.getAll, .{}); pub const delete = bridge.function(FormData.delete, .{}); pub const keys = bridge.function(FormData.keys, .{}); @@ -599,8 +656,6 @@ test "FormData: multipart empty body" { try testing.expectString("--B--\r\n", buf.written()); } -const Blob = @import("../Blob.zig"); - fn buildTestFile(arena: Allocator, page: *@import("../../Page.zig"), name: []const u8, mime: []const u8, body: []const u8) !*File { const blob = try Blob.initFromBytes(body, mime, page); blob.acquireRef(); From 77bbbf5e843a6ab73d4d4913278809fad99b6bed Mon Sep 17 00:00:00 2001 From: Navid EMAD Date: Fri, 24 Jul 2026 21:23:52 +0200 Subject: [PATCH 11/34] forms: close the ancestor dialog on method=dialog submission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Form.normalizeMethod` already canonicalizes `method="dialog"`, but `Frame.submitForm` only branched on `"post"`, so a dialog submission fell through to the GET path: the document was reloaded, the dialog kept its `open` attribute, `returnValue` was never set, and no `close` event fired. A promise awaiting `close` — the idiomatic way to await a confirm dialog — hung forever while the page reloaded underneath it. Add the branch the HTML form-submission algorithm calls for: walk up from the form to the nearest ancestor `` and close it with the submitter's value through the existing `Dialog.close()`, then return without scheduling a navigation. With no ancestor dialog the submission is dropped, which is also what the spec asks for — and still not a navigation. Completes the `` support started in #2435, which implemented `show`/`showModal`/`close`/`returnValue` but left the form-submission integration that drives them unwired. Closes #3053 --- src/browser/Frame.zig | 25 ++++++ src/browser/tests/element/html/dialog.html | 96 ++++++++++++++++++++++ 2 files changed, 121 insertions(+) diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 7e13f3db3..abd9c6aaf 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -3404,6 +3404,31 @@ pub fn submitForm(self: *Frame, submitter_: ?*Element, form_: ?*Element.Html.For const method = Element.Html.Form.normalizeMethod(method_attr, "get"); const is_post = std.mem.eql(u8, method, "post"); + // Per the HTML form-submission algorithm, the dialog method closes the + // form's nearest ancestor dialog with the submitter's value and performs no + // navigation. Falling through here would submit the form as a GET, which + // both reloads the page and leaves the dialog open forever. + // https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#form-submission-algorithm + if (std.mem.eql(u8, method, "dialog")) { + var ancestor: ?*Element = form_element; + while (ancestor) |el| : (ancestor = el.parentElement()) { + const dialog = el.is(Element.Html.Dialog) orelse continue; + // A submit button always has a value (empty when the attribute is + // absent); with no submitter at all the dialog's existing + // returnValue is left untouched. + const result: ?[]const u8 = if (submit_button) |s| + s.getAttributeSafe(comptime .wrap("value")) orelse "" + else + null; + try dialog.close(result, self); + break; + } + // Nothing is navigated, so the arena reserved for the request body goes + // unused — the errdefer above only covers the error path. + self._session.releaseArena(arena); + return; + } + // Get charset from accept-charset attribute or fall back to document charset const charset: []const u8 = blk: { if (form_element.getAttributeSafe(.wrap("accept-charset"))) |ac| { diff --git a/src/browser/tests/element/html/dialog.html b/src/browser/tests/element/html/dialog.html index e0e6fb45c..59aecd4db 100644 --- a/src/browser/tests/element/html/dialog.html +++ b/src/browser/tests/element/html/dialog.html @@ -198,3 +198,99 @@ testing.expectEqual(0, fired) } + + + + + + + + + + From 39981f2534a4cc401cadea56e6854538def8a30d Mon Sep 17 00:00:00 2001 From: Ppsoft1991 <18442117+Ppsoft1991@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:22:41 +0800 Subject: [PATCH 12/34] cdp: complete Network event payloads Add required timestamp, type, request, response, and timing fields so strict CDP clients can deserialize emitted events. --- src/cdp/domains/network.zig | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/cdp/domains/network.zig b/src/cdp/domains/network.zig index cd4172f0b..597eb92b2 100644 --- a/src/cdp/domains/network.zig +++ b/src/cdp/domains/network.zig @@ -328,6 +328,7 @@ pub fn httpRequestFail(bc: *CDP.BrowserContext, msg: *const Notification.Request // We're missing a bunch of fields, but, for now, this seems like enough try bc.cdp.sendEvent("Network.loadingFailed", .{ .requestId = &id.toRequestId(msg.transfer), + .timestamp = timestamp(.monotonic), // Seems to be what chrome answers with. I assume it depends on the type of error? .type = "Ping", .errorText = msg.err, @@ -382,6 +383,8 @@ pub fn httpResponseHeaderDone(arena: Allocator, bc: *CDP.BrowserContext, msg: *c .frameId = &id.toFrameId(req.frame_id), .requestId = &id.toRequestId(transfer), .loaderId = &id.toLoaderId(req.loader_id), + .timestamp = timestamp(.monotonic), + .type = req.resource_type.string(), .response = ResponseWriter.init(arena, msg.transfer), .hasExtraInfo = false, // TODO change after adding Network.responseReceivedExtraInfo }, .{ .session_id = session_id }); @@ -393,6 +396,7 @@ pub fn httpRequestDone(bc: *CDP.BrowserContext, msg: *const Notification.Request const session_id = bc.session_id orelse return; try bc.cdp.sendEvent("Network.loadingFinished", .{ .requestId = &id.toRequestId(msg.transfer), + .timestamp = timestamp(.monotonic), .encodedDataLength = msg.content_length, }, .{ .session_id = session_id }); } @@ -447,6 +451,16 @@ pub const RequestWriter = struct { try jws.write(request.body != null); } + { + try jws.objectField("initialPriority"); + try jws.write("High"); + } + + { + try jws.objectField("referrerPolicy"); + try jws.write("strict-origin-when-cross-origin"); + } + { try jws.objectField("headers"); try jws.beginObject(); @@ -516,6 +530,17 @@ const ResponseWriter = struct { try jws.write(transfer._from_cache); } + { + try jws.objectField("connectionReused"); + try jws.write(false); + try jws.objectField("connectionId"); + try jws.write(0); + try jws.objectField("encodedDataLength"); + try jws.write(transfer._cdp_content_length); + try jws.objectField("securityState"); + try jws.write("unknown"); + } + { try jws.objectField("timing"); try jws.write(.{ @@ -533,6 +558,12 @@ const ResponseWriter = struct { .sendStart = -1, .sslEnd = -1, .sslStart = -1, + .workerStart = -1, + .workerReady = -1, + .workerFetchStart = -1, + .workerRespondWithSettled = -1, + .pushStart = -1, + .pushEnd = -1, }); } From e3ec937f7886d37dd98ce3b19d00e4d727f63ddc Mon Sep 17 00:00:00 2001 From: Ppsoft1991 <18442117+Ppsoft1991@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:23:00 +0800 Subject: [PATCH 13/34] cdp: emit worker network requests Keep worker requests visible on the page CDP session when their WorkerGlobalScope frame id is absent from the document frame tree. Add a regression fixture covering the worker script and a fetch from inside the worker. --- src/browser/tests/cdp/worker_network.html | 4 ++ src/browser/tests/cdp/worker_network.js | 1 + src/cdp/domains/network.zig | 67 ++++++++++++++++++++++- 3 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 src/browser/tests/cdp/worker_network.html create mode 100644 src/browser/tests/cdp/worker_network.js diff --git a/src/browser/tests/cdp/worker_network.html b/src/browser/tests/cdp/worker_network.html new file mode 100644 index 000000000..0863bd183 --- /dev/null +++ b/src/browser/tests/cdp/worker_network.html @@ -0,0 +1,4 @@ + + diff --git a/src/browser/tests/cdp/worker_network.js b/src/browser/tests/cdp/worker_network.js new file mode 100644 index 000000000..432ab3761 --- /dev/null +++ b/src/browser/tests/cdp/worker_network.js @@ -0,0 +1 @@ +fetch("/echo_method"); diff --git a/src/cdp/domains/network.zig b/src/cdp/domains/network.zig index 597eb92b2..744c8856a 100644 --- a/src/cdp/domains/network.zig +++ b/src/cdp/domains/network.zig @@ -345,7 +345,16 @@ pub fn httpRequestStart(bc: *CDP.BrowserContext, msg: *const Notification.Reques const transfer = msg.transfer; const req = &transfer.req; const frame_id = req.frame_id; - const frame = bc.session.findFrameByFrameId(frame_id) orelse return; + // Worker requests use their WorkerGlobalScope id rather than a document + // frame id. They still belong to this page session and must be visible to + // Network clients; otherwise responseReceived/loadingFinished are emitted + // without the corresponding requestWillBeSent event and CDP clients drop + // the whole request. A worker has no document URL, so use its request URL + // when the id does not resolve to a document frame. + const document_url = if (bc.session.findFrameByFrameId(frame_id)) |frame| + frame.url + else + req.url; // Modify request with extra CDP headers. Use set (replace by name) so a // caller-supplied header overrides a built-in default of the same name @@ -360,7 +369,7 @@ pub fn httpRequestStart(bc: *CDP.BrowserContext, msg: *const Notification.Reques .requestId = &id.toRequestId(transfer), .loaderId = &id.toLoaderId(req.loader_id), .type = req.resource_type.string(), - .documentURL = frame.url, + .documentURL = document_url, .request = RequestWriter.init(transfer), .initiator = .{ .type = "other" }, .redirectHasExtraInfo = false, // TODO change after adding Network.requestWillBeSentExtraInfo @@ -1052,3 +1061,57 @@ test "cdp.Network: setBlockedURLs blocks requests with inspector reason" { }, .{ .session_id = "SID-BLOCK" }); try testing.expectEqual(error.UrlBlocked, error_context.err.?); } + +test "cdp.Network: worker requests emit network events" { + var ctx = try testing.context(); + defer ctx.deinit(); + + const cdp = ctx.cdp(); + _ = try cdp.createBrowserContext(); + var bc = &cdp.browser_context.?; + bc.id = "BID-NW"; + bc.session_id = "SID-NW"; + bc.target_id = "TID-NW-0000000".*; + + try ctx.processMessage(.{ .id = 1, .method = "Network.enable" }); + try ctx.expectSentResult(null, .{ .id = 1 }); + + const fixture_root = "http://127.0.0.1:9582/src/browser/tests/cdp/"; + const worker_url = fixture_root ++ "worker_network.js"; + const api_url = "http://127.0.0.1:9582/echo_method"; + const page = try bc.session.createPage(); + try page.navigate(fixture_root ++ "worker_network.html", .{}); + try testing.waitForPage(bc); + + try ctx.expectSentEvent("Network.requestWillBeSent", .{ + .documentURL = worker_url, + .type = "Script", + .request = .{ + .url = worker_url, + .initialPriority = "High", + .referrerPolicy = "strict-origin-when-cross-origin", + }, + }, .{ .session_id = "SID-NW" }); + try ctx.expectSentEvent("Network.responseReceived", .{ + .type = "Script", + .response = .{ + .url = worker_url, + .connectionReused = false, + .connectionId = 0, + .securityState = "unknown", + .timing = .{ + .workerStart = -1, + .workerReady = -1, + .workerFetchStart = -1, + .workerRespondWithSettled = -1, + .pushStart = -1, + .pushEnd = -1, + }, + }, + }, .{ .session_id = "SID-NW" }); + try ctx.expectSentEvent("Network.requestWillBeSent", .{ + .documentURL = api_url, + .type = "Fetch", + .request = .{ .url = api_url }, + }, .{ .session_id = "SID-NW" }); +} From d58cbc54406ffa5a138b47df11ef8308e31c536e Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sat, 25 Jul 2026 08:56:39 +0800 Subject: [PATCH 14/34] explicit visibility check on root --- src/browser/tests/element/position.html | 20 ++++++++ src/browser/webapi/Element.zig | 61 ++++++++++++------------- 2 files changed, 48 insertions(+), 33 deletions(-) diff --git a/src/browser/tests/element/position.html b/src/browser/tests/element/position.html index 6a846dfa8..3022384c9 100644 --- a/src/browser/tests/element/position.html +++ b/src/browser/tests/element/position.html @@ -89,6 +89,15 @@ document.body.appendChild(hidden); testing.expectEqual(0, hidden.scrollWidth); + // A visible width:0 box (an off-screen measurement container) is not + // hidden: its content still overflows it. + const zeroWidth = document.createElement('div'); + zeroWidth.style.width = '0'; + zeroWidth.appendChild(document.createElement('span')); + document.body.appendChild(zeroWidth); + testing.expectEqual(0, zeroWidth.clientWidth); + testing.expectTrue(zeroWidth.scrollWidth > 0); + // The root containers keep their synthetic size rather than summing // children, so horizontal page-overflow checks stay stable. testing.expectEqual(document.body.clientWidth, document.body.scrollWidth); @@ -169,6 +178,17 @@ document.body.appendChild(hidden); testing.expectEqual(0, hidden.scrollHeight); + // The collapse/expand idiom: scrollHeight is read while the panel is + // collapsed at height:0 to know how far to open it. A zero-height box is + // not hidden; its content is still measured. + const panel = document.createElement('div'); + panel.style.height = '0'; + panel.appendChild(document.createElement('div')); + panel.appendChild(document.createElement('div')); + document.body.appendChild(panel); + testing.expectEqual(0, panel.clientHeight); + testing.expectTrue(panel.scrollHeight > 0); + // The root containers keep their synthetic size rather than summing // children. This is what keeps infinite-scroll triggers reading // `scrollTop + clientHeight >= scrollHeight` on body/documentElement diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index b1a78ab7c..e44c1f066 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -1377,22 +1377,21 @@ pub fn setScrollLeft(self: *Element, value: i32, frame: *Frame) !void { } pub fn getScrollHeight(self: *Element, frame: *Frame) f64 { - const client_height = self.getClientHeight(frame); - - // Hidden: getClientHeight already reported 0, and a hidden element's - // content doesn't overflow anything. - if (client_height == 0.0) { + var visibility_cache: VisibilityCache = .{}; + if (!self.checkVisibilityCached(&visibility_cache, frame)) { return 0.0; } - switch (self.getTag()) { - // As in getScrollWidth: the root containers carry artificial giant - // defaults, and page-level overflow checks read them. - .html, .body => return client_height, - else => {}, + const height = self.getElementDimensions(frame).height; + + const tag = self.getTag(); + // As in getScrollWidth: the root containers carry artificial giant + // defaults, and page-level overflow checks read them. + if (tag == .html or tag == .body) { + return height; } - return @max(client_height, self.contentHeight(frame)); + return @max(height, self.contentHeight(frame, &visibility_cache)); } // The height of the direct child elements stacked vertically, the counterpart @@ -1405,15 +1404,13 @@ pub fn getScrollHeight(self: *Element, frame: *Frame) f64 { // extent per axis rather than describing one coherent layout: reporting no // overflow when there is some is what wedges measure-then-mutate loops, // while the reverse merely over-reports. -fn contentHeight(self: *Element, frame: *Frame) f64 { +fn contentHeight(self: *Element, frame: *Frame, visibility_cache: *VisibilityCache) f64 { var total: f64 = 0; - var visibility_cache: VisibilityCache = .{}; - var child = self.asNode().firstChild(); while (child) |node| : (child = node.nextSibling()) { if (node.is(Element)) |el| { - if (el.checkVisibilityCached(&visibility_cache, frame)) { + if (el.checkVisibilityCached(visibility_cache, frame)) { total += el.getElementDimensions(frame).height; } } @@ -1423,23 +1420,22 @@ fn contentHeight(self: *Element, frame: *Frame) f64 { } pub fn getScrollWidth(self: *Element, frame: *Frame) f64 { - const client_width = self.getClientWidth(frame); - - // Hidden: getClientWidth already reported 0, and a hidden element's - // content doesn't overflow anything. - if (client_width == 0.0) { + var visibility_cache: VisibilityCache = .{}; + if (!self.checkVisibilityCached(&visibility_cache, frame)) { return 0.0; } - switch (self.getTag()) { - // The root containers carry artificial giant defaults (1920 and - // 100_000_000, see getElementDimensions). Stacking their children on - // top would inflate a value sites read to detect page overflow. - .html, .body => return client_width, - else => {}, + const width = self.getElementDimensions(frame).width; + + const tag = self.getTag(); + // The root containers carry artificial giant defaults (1920 and + // 100_000_000, see getElementDimensions). Stacking their children on + // top would inflate a value sites read to detect page overflow. + if (tag == .html or tag == .body) { + return width; } - return @max(client_width, self.contentWidth(frame)); + return @max(width, self.contentWidth(frame, &visibility_cache)); } // The width of the direct child elements laid end to end on a single row. @@ -1465,17 +1461,16 @@ pub fn getScrollWidth(self: *Element, frame: *Frame) f64 { // would report overflow for practically every element containing text, since a // few words already exceed the default box. Element children are what content // grown by script actually consists of. -fn contentWidth(self: *Element, frame: *Frame) f64 { +fn contentWidth(self: *Element, frame: *Frame, visibility_cache: *VisibilityCache) f64 { var total: f64 = 0; - // Siblings share their entire ancestor chain, so one cache across the loop - // collapses N ancestor walks into one walk plus N own-element checks. - var visibility_cache: VisibilityCache = .{}; - + // The cache arrives seeded by the caller's own visibility walk, and + // siblings share that ancestor chain, so the loop costs one own-element + // check per child rather than N ancestor walks. var child = self.asNode().firstChild(); while (child) |node| : (child = node.nextSibling()) { if (node.is(Element)) |el| { - if (el.checkVisibilityCached(&visibility_cache, frame)) { + if (el.checkVisibilityCached(visibility_cache, frame)) { total += el.getElementDimensions(frame).width; } } From 0e0d4418c3826bacd839dc216c55bdf3f368f1be Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sat, 25 Jul 2026 09:27:06 +0800 Subject: [PATCH 15/34] Simplify FormData value union Collapse File -> Blob, since a File is a blob. Code was already checking for blob._type == .file. --- src/browser/webapi/net/FormData.zig | 55 +++++++++++++--------------- src/browser/webapi/net/body_init.zig | 4 +- 2 files changed, 27 insertions(+), 32 deletions(-) diff --git a/src/browser/webapi/net/FormData.zig b/src/browser/webapi/net/FormData.zig index bf87bd789..0db380690 100644 --- a/src/browser/webapi/net/FormData.zig +++ b/src/browser/webapi/net/FormData.zig @@ -166,39 +166,34 @@ pub fn has(self: *const FormData, name: String) bool { return false; } -// The value half of the JS-facing append()/set(). `bytes` must stay last: -// js.Bridge tries the JsApi classes first and maps anything else to a string, -// so an earlier position would swallow File and Blob arguments. pub const EntryValue = union(enum) { - file: *File, - blob: *Blob, - bytes: []const u8, + blob: *Blob, // can be of _type == .file + bytes: []const u8, //must be last, everything can coerce to a []const u8 }; -pub fn jsSet(self: *FormData, name: String, value: EntryValue, filename: ?[]const u8, exec: *Execution) !void { +pub fn set(self: *FormData, name: String, value: EntryValue, filename: ?[]const u8, exec: *Execution) !void { self.deleteByName(name, exec); - return self.jsAppend(name.str(), value, filename, exec); + return self.append(name.str(), value, filename, exec); } // https://xhr.spec.whatwg.org/#create-an-entry -pub fn jsAppend(self: *FormData, name: []const u8, value: EntryValue, filename: ?[]const u8, exec: *Execution) !void { +pub fn append(self: *FormData, name: []const u8, value: EntryValue, filename: ?[]const u8, exec: *Execution) !void { const entry_value: Entry.Value = switch (value) { - // A Blob that is not a File becomes a File named "blob", unless a - // filename was supplied. .blob => |blob| blk: { - if (blob._type == .file and filename == null) { + if (filename) |n| { + // A supplied filename means a new File over the same bytes rather + // than a rename of the caller's object. + break :blk .{ .file = try fileFrom(blob, n, exec.page) }; + } + + if (blob._type == .file) { const file = blob._type.file; file.acquireRef(); break :blk .{ .file = file }; } - break :blk .{ .file = try fileFrom(blob, filename orelse "blob", exec.page) }; - }, - // A File keeps its bytes; a supplied filename means a new File over - // the same bytes rather than a rename of the caller's object. - .file => |file| blk: { - if (filename) |n| break :blk .{ .file = try fileFrom(file._proto, n, exec.page) }; - file.acquireRef(); - break :blk .{ .file = file }; + + // A Blob that is not a File becomes a File named "blob". + break :blk .{ .file = try fileFrom(blob, "blob", exec.page) }; }, .bytes => |b| .{ .string = try String.init(self._arena, b, .{}) }, }; @@ -227,7 +222,7 @@ fn fileFrom(source: *Blob, name: []const u8, page: *Page) !*File { return file; } -pub fn append(self: *FormData, name: []const u8, value: []const u8) !void { +pub fn appendText(self: *FormData, name: []const u8, value: []const u8) !void { try self._entries.append(self._arena, .{ .name = try String.init(self._arena, name, .{}), .value = .{ .string = try String.init(self._arena, value, .{}) }, @@ -568,8 +563,8 @@ pub const JsApi = struct { pub const constructor = bridge.constructor(FormData.init, .{}); pub const has = bridge.function(FormData.has, .{}); pub const get = bridge.function(FormData.get, .{}); - pub const set = bridge.function(FormData.jsSet, .{}); - pub const append = bridge.function(FormData.jsAppend, .{}); + pub const set = bridge.function(FormData.set, .{}); + pub const append = bridge.function(FormData.append, .{}); pub const getAll = bridge.function(FormData.getAll, .{}); pub const delete = bridge.function(FormData.delete, .{}); pub const keys = bridge.function(FormData.keys, .{}); @@ -592,8 +587,8 @@ test "FormData: multipart write" { ._arena = allocator, ._entries = .empty, }; - try fd.append("name", "John"); - try fd.append("note", "two\r\nlines"); + try fd.appendText("name", "John"); + try fd.appendText("note", "two\r\nlines"); var buf = std.Io.Writer.Allocating.init(allocator); try fd.write(.{ @@ -621,7 +616,7 @@ test "FormData: multipart escapes name CR/LF/quote" { ._arena = allocator, ._entries = .empty, }; - try fd.append("a\"b\r\nc", "v"); + try fd.appendText("a\"b\r\nc", "v"); var buf = std.Io.Writer.Allocating.init(allocator); try fd.write(.{ @@ -681,7 +676,7 @@ test "FormData: multipart with file" { ._arena = allocator, ._entries = .empty, }; - try fd.append("field", "value"); + try fd.appendText("field", "value"); try fd._entries.append(allocator, .{ .name = try String.init(allocator, "upload", .{}), .value = .{ .file = file }, @@ -852,9 +847,9 @@ test "FormData: plaintext write" { ._arena = allocator, ._entries = .empty, }; - try fd.append("name", "John"); - try fd.append("note", "two\r\nlines"); - try fd.append("equals", "a=b"); + try fd.appendText("name", "John"); + try fd.appendText("note", "two\r\nlines"); + try fd.appendText("equals", "a=b"); var buf = std.Io.Writer.Allocating.init(allocator); try fd.write(.{ .encoding = .plaintext, .allocator = allocator }, &buf.writer); diff --git a/src/browser/webapi/net/body_init.zig b/src/browser/webapi/net/body_init.zig index b98e724bd..025fff531 100644 --- a/src/browser/webapi/net/body_init.zig +++ b/src/browser/webapi/net/body_init.zig @@ -163,8 +163,8 @@ test "BodyInit: FormData emits multipart with random boundary" { const fd = try arena.create(FormData); fd.* = .{ ._rc = .{}, ._arena = arena, ._entries = .empty }; - try fd.append("username", "alice"); - try fd.append("email", "alice@example.com"); + try fd.appendText("username", "alice"); + try fd.appendText("email", "alice@example.com"); const r = try (BodyInit{ .form_data = fd }).extract(arena); From c205e118ebf9a39ba5c800110a7bdf8f07b09162 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sat, 25 Jul 2026 09:50:54 +0800 Subject: [PATCH 16/34] Get submitter value from the IDL value Move code that dialog block doesn't need under the dialog block (e.g. no need to get an arena from the pool). --- src/browser/Frame.zig | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index abd9c6aaf..e6691e5ae 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -3382,9 +3382,6 @@ pub fn submitForm(self: *Frame, submitter_: ?*Element, form_: ?*Element.Html.For form_data.acquireRef(); defer form_data.releaseRef(self._page); - const arena = try self._session.getArena(.medium, "submitForm"); - errdefer self._session.releaseArena(arena); - // Per HTML spec form-submission algorithm, when the submitter is a submit // button, its formaction/formmethod/formenctype attributes override the // form's corresponding attributes (matching how formtarget is honored above). @@ -3402,7 +3399,6 @@ pub fn submitForm(self: *Frame, submitter_: ?*Element, form_: ?*Element.Html.For break :blk form_element.getAttributeSafe(comptime .wrap("method")); }; const method = Element.Html.Form.normalizeMethod(method_attr, "get"); - const is_post = std.mem.eql(u8, method, "post"); // Per the HTML form-submission algorithm, the dialog method closes the // form's nearest ancestor dialog with the submitter's value and performs no @@ -3410,25 +3406,30 @@ pub fn submitForm(self: *Frame, submitter_: ?*Element, form_: ?*Element.Html.For // both reloads the page and leaves the dialog open forever. // https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#form-submission-algorithm if (std.mem.eql(u8, method, "dialog")) { + // A submit button always has a value (empty when unset); with no + // submitter at all the dialog's existing returnValue is left untouched. + const result: ?[]const u8 = if (submit_button) |s| blk: { + if (s.is(Element.Html.Form.Input)) |input| { + break :blk input.getValue(); + } + // if not an Input, it has to be a Button (checked above by isSubmitButton) + break :blk s.as(Element.Html.Form.Button).getValue(); + } else null; + var ancestor: ?*Element = form_element; while (ancestor) |el| : (ancestor = el.parentElement()) { const dialog = el.is(Element.Html.Dialog) orelse continue; - // A submit button always has a value (empty when the attribute is - // absent); with no submitter at all the dialog's existing - // returnValue is left untouched. - const result: ?[]const u8 = if (submit_button) |s| - s.getAttributeSafe(comptime .wrap("value")) orelse "" - else - null; try dialog.close(result, self); break; } - // Nothing is navigated, so the arena reserved for the request body goes - // unused — the errdefer above only covers the error path. - self._session.releaseArena(arena); return; } + const is_post = std.mem.eql(u8, method, "post"); + + const arena = try self._session.getArena(.medium, "submitForm"); + errdefer self._session.releaseArena(arena); + // Get charset from accept-charset attribute or fall back to document charset const charset: []const u8 = blk: { if (form_element.getAttributeSafe(.wrap("accept-charset"))) |ac| { From dab5ff77792af566da7c50b3535c9436f4b44270 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 23 Jul 2026 13:37:42 +0800 Subject: [PATCH 17/34] webapi: XHR uses XML parser for XML content-type responses Expand mime type to be aware of more XML types. DOMParser and XHR now use the existing Parser.parseXML (via a Frame helper), rather than the HTML parser. Both these APIs predate the addition of parseXML. --- src/browser/Frame.zig | 36 ++++++++++++ src/browser/Mime.zig | 37 ++++++++++++ src/browser/tests/net/xhr.html | 69 ++++++++++++++++++++--- src/browser/webapi/DOMParser.zig | 60 ++++++-------------- src/browser/webapi/net/XMLHttpRequest.zig | 24 ++++---- src/testing.zig | 18 ++++++ 6 files changed, 184 insertions(+), 60 deletions(-) diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 7e13f3db3..764e075e0 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -2938,6 +2938,42 @@ fn parseHtmlAsChildrenInner(self: *Frame, node: *Node, html: []const u8, opts: F } } +// Build a detached XMLDocument from `xml` (DOMParser.parseFromString and +// XMLHttpRequest.responseXML). Returns null when the input isn't well-formed +// XML. The parse borrows the fragment parse-mode so frame-side hooks triggered +// from `Build.created` / `nodeIsReady` (external stylesheet fetches, script +// execution, mutation-observer fan-out, default-script injection) treat the +// parsed nodes as detached and skip side effects on the live document. +pub fn parseXmlDocument(self: *Frame, xml: []const u8) !?*Document.XMLDocument { + const arena = try self.getArena(.medium, "Frame.parseXmlDocument"); + defer self.releaseArena(arena); + + const previous_parse_mode = self._parse_mode; + self._parse_mode = .fragment; + defer self._parse_mode = previous_parse_mode; + + const doc = try self._factory.document(Document.XMLDocument{ ._proto = undefined }); + const doc_node = doc.asNode(); + var parser = Parser.init(arena, doc_node, self, .{}); + parser.parseXML(xml); + if (parser.terminated) { + return error.ExecutionTerminated; + } + + if (parser.err != null or doc_node.firstChild() == null) { + return null; + } + + // If first node is a `ProcessingInstruction` (e.g. the + // declaration), skip it. + const first_child = doc_node.firstChild().?; + if (first_child.getNodeType() == 7) { + _ = try doc_node.removeChild(first_child, self); + } + + return doc; +} + // Runs the "ready" work for an inserted node and, when it's an element with // children, for its descendants in tree order: appending a subtree // containing scripts must execute them all, after the whole insertion. diff --git a/src/browser/Mime.zig b/src/browser/Mime.zig index 8145f4979..72268c7fb 100644 --- a/src/browser/Mime.zig +++ b/src/browser/Mime.zig @@ -49,6 +49,7 @@ pub const ContentTypeEnum = enum { application_json, unknown, other, + other_xml, }; pub const ContentType = union(ContentTypeEnum) { @@ -68,6 +69,7 @@ pub const ContentType = union(ContentTypeEnum) { // A valid but unrecognized type/subtype. Keeping it would require some // memory management of the input. Nothing needs it right now, so why bother. other: void, + other_xml: void, // i.e. application/xml or */*+xml }; pub fn contentTypeString(mime: *const Mime) []const u8 { @@ -346,6 +348,13 @@ pub fn isHTML(self: *const Mime) bool { return self.content_type == .text_html; } +pub fn isXML(self: *const Mime) bool { + return switch (self.content_type) { + .text_xml, .other_xml => true, + else => false, + }; +} + pub fn isText(mime: *const Mime) bool { return switch (mime.content_type) { .text_xml, .text_html, .text_javascript, .text_plain, .text_css, .text_markdown => true, @@ -378,9 +387,11 @@ fn parseContentType(value: []const u8) !struct { ContentType, usize } { @"image/webp", @"application/json", + @"application/xml", }, type_name)) |known_type| { const ct: ContentType = switch (known_type) { .@"text/xml" => .{ .text_xml = {} }, + .@"application/xml" => .{ .other_xml = {} }, .@"text/html" => .{ .text_html = {} }, .@"text/javascript", .@"application/javascript", .@"application/x-javascript" => .{ .text_javascript = {} }, .@"text/plain" => .{ .text_plain = {} }, @@ -408,6 +419,10 @@ fn parseContentType(value: []const u8) !struct { ContentType, usize } { return error.Invalid; } + if (std.mem.endsWith(u8, type_name, "+xml")) { + return .{ .{ .other_xml = {} }, attribute_start }; + } + return .{ .{ .other = {} }, attribute_start }; } @@ -897,6 +912,28 @@ test "Mime: isHTML" { try assert(false, "over/9000"); } +test "Mime: isXML" { + defer testing.reset(); + + const assert = struct { + fn assert(expected: bool, input: []const u8) !void { + const mutable_input = try testing.arena_allocator.dupe(u8, input); + var mime = try Mime.parse(mutable_input); + try testing.expectEqual(expected, mime.isXML()); + } + }.assert; + try assert(true, "text/xml"); + try assert(true, "TEXT/XML; charset=utf-8"); + try assert(true, "application/xml"); + try assert(true, "image/svg+xml"); + try assert(true, "application/xhtml+xml"); + try assert(true, "application/rss+xml;charset=utf-8"); + try assert(false, "text/html"); + try assert(false, "application/json"); + try assert(false, "application/xmlfoo"); + try assert(false, "text/xm"); +} + test "Mime: sniff" { try testing.expectEqual(null, Mime.sniff("")); try testing.expectEqual(null, Mime.sniff(" { - // End-to-end: server returns text/html, but overrideMimeType("text/xml") - // makes responseXML lazily parse the body as a Document, while - // responseText is unaffected (responseType is still the default ""). + // End-to-end: server returns text/plain, but overrideMimeType("text/xml") + // makes responseXML lazily parse the body as XML, while responseText is + // unaffected (responseType is still the default ""). + const state = await testing.async(); + const req = new XMLHttpRequest(); + req.overrideMimeType('text/xml'); + req.open('GET', 'http://127.0.0.1:9582/xhr/xml_as_text'); + req.onload = () => state.resolve(); + req.send(); + await state.done(() => { + testing.expectEqual(200, req.status); + testing.expectEqual(true, req.responseText.startsWith(' + + + + + + diff --git a/src/browser/webapi/DOMParser.zig b/src/browser/webapi/DOMParser.zig index 57c0f4080..f30467608 100644 --- a/src/browser/webapi/DOMParser.zig +++ b/src/browser/webapi/DOMParser.zig @@ -24,7 +24,6 @@ const Frame = @import("../Frame.zig"); const Parser = @import("../parser/Parser.zig"); const HTMLDocument = @import("HTMLDocument.zig"); -const XMLDocument = @import("XMLDocument.zig"); const Document = @import("Document.zig"); const DOMParser = @This(); @@ -50,22 +49,22 @@ pub fn parseFromString( @"image/svg+xml", }, mime_type) orelse return error.NotSupported; - const arena = try frame.getArena(.medium, "DOMParser.parseFromString"); - defer frame.releaseArena(arena); - - // DOMParser builds a detached Document. Borrow the same fragment - // parse-mode that `parseHtmlAsChildren` uses so frame-side hooks - // triggered from `Build.created` / `nodeIsReady` (external stylesheet - // fetches, script execution, mutation-observer fan-out, default-script - // injection) treat the parsed nodes as detached and skip - // side effects on the live document. The frame's `_parse_mode` is - // restored on exit. - const previous_parse_mode = frame._parse_mode; - frame._parse_mode = .fragment; - defer frame._parse_mode = previous_parse_mode; - return switch (target_mime) { .@"text/html" => { + const arena = try frame.getArena(.medium, "DOMParser.parseFromString"); + defer frame.releaseArena(arena); + + // DOMParser builds a detached Document. Borrow the same fragment + // parse-mode that `parseHtmlAsChildren` uses so frame-side hooks + // triggered from `Build.created` / `nodeIsReady` (external + // stylesheet fetches, script execution, mutation-observer fan-out, + // default-script injection) treat the parsed nodes as detached and + // skip side effects on the live document. The frame's + // `_parse_mode` is restored on exit. + const previous_parse_mode = frame._parse_mode; + frame._parse_mode = .fragment; + defer frame._parse_mode = previous_parse_mode; + // Create a new HTMLDocument const doc = try frame._factory.document(HTMLDocument{ ._proto = undefined, @@ -90,35 +89,10 @@ pub fn parseFromString( return doc.asDocument(); }, else => { - // Create a new XMLDocument. - const doc = try frame._factory.document(XMLDocument{ - ._proto = undefined, - }); - - // Parse XML into XMLDocument. - const doc_node = doc.asNode(); - var parser = Parser.init(arena, doc_node, frame, .{}); - parser.parseXML(html); - if (parser.terminated) { - return error.ExecutionTerminated; - } - - if (parser.err != null or doc_node.firstChild() == null) { + const doc = (try frame.parseXmlDocument(html)) orelse blk: { // Return a document with a element per spec. - const err_doc = try frame._factory.document(XMLDocument{ ._proto = undefined }); - var err_parser = Parser.init(arena, err_doc.asNode(), frame, .{}); - err_parser.parseXML("error"); - return err_doc.asDocument(); - } - - const first_child = doc_node.firstChild().?; - - // If first node is a `ProcessingInstruction`, skip it. - if (first_child.getNodeType() == 7) { - // We're sure that firstChild exist, this cannot fail. - _ = try doc_node.removeChild(first_child, frame); - } - + break :blk (try frame.parseXmlDocument("error")).?; + }; return doc.asDocument(); }, }; diff --git a/src/browser/webapi/net/XMLHttpRequest.zig b/src/browser/webapi/net/XMLHttpRequest.zig index db3627b50..d59c6cc26 100644 --- a/src/browser/webapi/net/XMLHttpRequest.zig +++ b/src/browser/webapi/net/XMLHttpRequest.zig @@ -434,14 +434,16 @@ pub fn getResponse(self: *XMLHttpRequest, exec: *const Execution) !?Response { .document => blk: { // responseType=document is only meaningful in a Frame; workers // have no DOM. Drastically different impls -> switch on global. - // - // TODO: per WHATWG XHR "set a document response", the final MIME - // type (_override_mime ?? _response_mime) should select an XML - // parser when it is an XML MIME type, and the HTML parser - // otherwise. We only have an HTML parser today, so the body is - // always parsed as HTML regardless of the override. switch (exec.js.global) { .frame => |frame| { + const final: Mime = self._override_mime orelse self._response_mime orelse .{ .content_type = .text_xml }; + if (final.isXML()) { + const document = (try frame.parseXmlDocument(data)) orelse return null; + break :blk .{ .document = document.asDocument() }; + } + if (!final.isHTML()) { + return null; + } const document = try exec._factory.node(Node.Document{ ._proto = undefined, ._type = .generic }); try frame.parseHtmlAsChildren(document.asNode(), data); break :blk .{ .document = document }; @@ -478,13 +480,15 @@ pub fn getResponseXML(self: *XMLHttpRequest, exec: *const Execution) !?*Node.Doc return document; } - const final = self._override_mime orelse self._response_mime orelse return null; - if (final.content_type != .text_xml) return null; + // With responseType "", only an XML final MIME type is parsed (an HTML + // one yields null); absent a Content-Type it defaults to text/xml. + const final: Mime = self._override_mime orelse self._response_mime orelse .{ .content_type = .text_xml }; + if (!final.isXML()) return null; switch (exec.js.global) { .frame => |frame| { - const document = try exec._factory.node(Node.Document{ ._proto = undefined, ._type = .generic }); - try frame.parseHtmlAsChildren(document.asNode(), self._response_data.items); + const xml_document = (try frame.parseXmlDocument(self._response_data.items)) orelse return null; + const document = xml_document.asDocument(); self._response_xml = document; return document; }, diff --git a/src/testing.zig b/src/testing.zig index 7cf26c4a7..3c335d0e8 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -637,6 +637,24 @@ fn testHTTPHandler(req: *std.http.Server.Request) !void { }); } + const xhr_xml_body = "2026"; + + if (std.mem.eql(u8, path, "/xhr/xml")) { + return req.respond(xhr_xml_body, .{ + .extra_headers = &.{ + .{ .name = "Content-Type", .value = "application/xml" }, + }, + }); + } + + if (std.mem.eql(u8, path, "/xhr/xml_as_text")) { + return req.respond(xhr_xml_body, .{ + .extra_headers = &.{ + .{ .name = "Content-Type", .value = "text/plain" }, + }, + }); + } + if (std.mem.eql(u8, path, "/xhr/json")) { return req.respond("{\"over\":\"9000!!!\",\"updated_at\":1765867200000}", .{ .extra_headers = &.{ From 705bc78b7276858c6516ffa6787b0bdc0fb6727d Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 23 Jul 2026 13:51:34 +0800 Subject: [PATCH 18/34] cleanup: Mechanical move various frame.parse* method Follows the observer, user_input, etc.. pattern and relocates various `Frame.parse*` methods into Frame.parse.* functions. Goal is just to keep Frame neat. --- src/browser/Frame.zig | 108 +-------------- src/browser/forms.zig | 2 +- src/browser/frame/parse.zig | 129 ++++++++++++++++++ src/browser/interactive.zig | 2 +- src/browser/links.zig | 2 +- src/browser/markdown.zig | 12 +- src/browser/structured_data.zig | 4 +- .../parser_wrapper_removed.html | 2 +- src/browser/webapi/DOMParser.zig | 6 +- src/browser/webapi/Element.zig | 2 +- src/browser/webapi/HTMLDocument.zig | 2 +- src/browser/webapi/Node.zig | 4 +- src/browser/webapi/Range.zig | 2 +- src/browser/webapi/element/Html.zig | 2 +- src/browser/webapi/net/XMLHttpRequest.zig | 7 +- 15 files changed, 155 insertions(+), 131 deletions(-) create mode 100644 src/browser/frame/parse.zig diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 764e075e0..ce78f7588 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -73,6 +73,7 @@ const milliTimestamp = @import("../datetime.zig").milliTimestamp; const GlobalEventHandlersLookup = @import("webapi/global_event_handlers.zig").Lookup; +pub const parse = @import("frame/parse.zig"); pub const preload = @import("frame/preload.zig"); pub const observers = @import("frame/observers.zig"); pub const user_input = @import("frame/user_input.zig"); @@ -2867,113 +2868,6 @@ pub fn updateRangesForNodeRemoval(self: *Frame, parent: *Node, child: *Node, chi } } -// TODO: optimize and cleanup, this is called a lot (e.g., innerHTML = '') -pub fn parseHtmlAsChildren(self: *Frame, node: *Node, html: []const u8) !void { - return self.parseHtmlAsChildrenInner(node, html, .{}); -} - -// setHTMLUnsafe variant: parse a fragment that may contain declarative shadow node -pub fn parseHtmlUnsafeAsChildren(self: *Frame, node: *Node, html: []const u8) !void { - return self.parseHtmlAsChildrenInner(node, html, .{ .allow_declarative_shadow = true }); -} - -// Range.createContextualFragment variant: unlike innerHTML et al., its scripts -// are run when the fragment is inserted into a document. -pub fn parseContextualFragment(self: *Frame, node: *Node, html: []const u8) !void { - return self.parseHtmlAsChildrenInner(node, html, .{ .scripts_runnable = true }); -} - -const FragmentParseOpts = struct { - scripts_runnable: bool = false, - allow_declarative_shadow: bool = false, -}; - -fn parseHtmlAsChildrenInner(self: *Frame, node: *Node, html: []const u8, opts: FragmentParseOpts) !void { - const previous_parse_mode = self._parse_mode; - self._parse_mode = .fragment; - defer self._parse_mode = previous_parse_mode; - - // The html5ever wrapper-unwrap below rebinds children without going - // through the insertion path, so recompute slot assignments for any - // shadow tree this fragment landed in (idempotent; signals only on diff). - defer if (self._element_shadow_roots.count() != 0) { - const root = node.getRootNode(.{}); - if (root.is(ShadowRoot) != null) { - slotting.assignSlottablesForTree(root, self); - } - if (node.is(Element)) |el| { - if (self._element_shadow_roots.get(el)) |shadow_root| { - slotting.assignSlottablesForTree(shadow_root.asNode(), self); - } - } - }; - - const previous_scripts_runnable = self._fragment_scripts_runnable; - self._fragment_scripts_runnable = opts.scripts_runnable; - defer self._fragment_scripts_runnable = previous_scripts_runnable; - - var parser = Parser.init(self.call_arena, node, self, .{ .allow_declarative_shadow = opts.allow_declarative_shadow }); - parser.parseFragment(html); - if (parser.terminated) { - return error.ExecutionTerminated; - } - - // html5ever wraps fragment output in an element; unwrap so its - // children land directly on `node`. See https://github.com/servo/html5ever/issues/583. - // Because of custom element callbacks, the structure might not be what - // we expect, and nodes might be altogether removed. We deal with this in a - // few different places, but always the same way: leave it as-is. - const children = node._children orelse return; - const first = Node.linkToNode(children.first.?); - if (first.is(Element.Html.Html) == null) { - return; - } - node._children = first._children; - - // No mutation records for the unwrapped children either; see the comment - // about fragment parses in _insertNodeRelative. - var it = node.childrenIterator(); - while (it.next()) |child| { - child._parent = node; - } -} - -// Build a detached XMLDocument from `xml` (DOMParser.parseFromString and -// XMLHttpRequest.responseXML). Returns null when the input isn't well-formed -// XML. The parse borrows the fragment parse-mode so frame-side hooks triggered -// from `Build.created` / `nodeIsReady` (external stylesheet fetches, script -// execution, mutation-observer fan-out, default-script injection) treat the -// parsed nodes as detached and skip side effects on the live document. -pub fn parseXmlDocument(self: *Frame, xml: []const u8) !?*Document.XMLDocument { - const arena = try self.getArena(.medium, "Frame.parseXmlDocument"); - defer self.releaseArena(arena); - - const previous_parse_mode = self._parse_mode; - self._parse_mode = .fragment; - defer self._parse_mode = previous_parse_mode; - - const doc = try self._factory.document(Document.XMLDocument{ ._proto = undefined }); - const doc_node = doc.asNode(); - var parser = Parser.init(arena, doc_node, self, .{}); - parser.parseXML(xml); - if (parser.terminated) { - return error.ExecutionTerminated; - } - - if (parser.err != null or doc_node.firstChild() == null) { - return null; - } - - // If first node is a `ProcessingInstruction` (e.g. the - // declaration), skip it. - const first_child = doc_node.firstChild().?; - if (first_child.getNodeType() == 7) { - _ = try doc_node.removeChild(first_child, self); - } - - return doc; -} - // Runs the "ready" work for an inserted node and, when it's an element with // children, for its descendants in tree order: appending a subtree // containing scripts must execute them all, after the whole insertion. diff --git a/src/browser/forms.zig b/src/browser/forms.zig index 18d068734..ce6de36b1 100644 --- a/src/browser/forms.zig +++ b/src/browser/forms.zig @@ -282,7 +282,7 @@ fn testForms(html: []const u8) ![]FormInfo { const doc = frame.window._document; const div = try doc.createElement("div", null, frame); - try frame.parseHtmlAsChildren(div.asNode(), html); + try Frame.parse.htmlAsChildren(frame, div.asNode(), html); return collectForms(frame.call_arena, div.asNode(), frame); } diff --git a/src/browser/frame/parse.zig b/src/browser/frame/parse.zig new file mode 100644 index 000000000..526fc872d --- /dev/null +++ b/src/browser/frame/parse.zig @@ -0,0 +1,129 @@ +// 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 . + +const Frame = @import("../Frame.zig"); +const Parser = @import("../parser/Parser.zig"); + +const Node = @import("../webapi/Node.zig"); +const Element = @import("../webapi/Element.zig"); +const Document = @import("../webapi/Document.zig"); +const ShadowRoot = @import("../webapi/ShadowRoot.zig"); +const slotting = @import("../webapi/element/slotting.zig"); + +pub fn htmlAsChildren(frame: *Frame, node: *Node, html: []const u8) !void { + return htmlAsChildrenInner(frame, node, html, .{}); +} + +// setHTMLUnsafe variant: parse a fragment that may contain declarative shadow node +pub fn htmlUnsafeAsChildren(frame: *Frame, node: *Node, html: []const u8) !void { + return htmlAsChildrenInner(frame, node, html, .{ .allow_declarative_shadow = true }); +} + +// Range.createContextualFragment variant: unlike innerHTML et al., its scripts +// are run when the fragment is inserted into a document. +pub fn contextualFragment(frame: *Frame, node: *Node, html: []const u8) !void { + return htmlAsChildrenInner(frame, node, html, .{ .scripts_runnable = true }); +} + +const FragmentParseOpts = struct { + scripts_runnable: bool = false, + allow_declarative_shadow: bool = false, +}; + +fn htmlAsChildrenInner(frame: *Frame, node: *Node, html: []const u8, opts: FragmentParseOpts) !void { + const previous_parse_mode = frame._parse_mode; + frame._parse_mode = .fragment; + defer frame._parse_mode = previous_parse_mode; + + // The html5ever wrapper-unwrap below rebinds children without going + // through the insertion path, so recompute slot assignments for any + // shadow tree this fragment landed in (idempotent; signals only on diff). + defer if (frame._element_shadow_roots.count() != 0) { + const root = node.getRootNode(.{}); + if (root.is(ShadowRoot) != null) { + slotting.assignSlottablesForTree(root, frame); + } + if (node.is(Element)) |el| { + if (frame._element_shadow_roots.get(el)) |shadow_root| { + slotting.assignSlottablesForTree(shadow_root.asNode(), frame); + } + } + }; + + const previous_scripts_runnable = frame._fragment_scripts_runnable; + frame._fragment_scripts_runnable = opts.scripts_runnable; + defer frame._fragment_scripts_runnable = previous_scripts_runnable; + + var parser = Parser.init(frame.call_arena, node, frame, .{ .allow_declarative_shadow = opts.allow_declarative_shadow }); + parser.parseFragment(html); + if (parser.terminated) { + return error.ExecutionTerminated; + } + + // html5ever wraps fragment output in an element; unwrap so its + // children land directly on `node`. See https://github.com/servo/html5ever/issues/583. + // Because of custom element callbacks, the structure might not be what + // we expect, and nodes might be altogether removed. We deal with this in a + // few different places, but always the same way: leave it as-is. + const children = node._children orelse return; + const first = Node.linkToNode(children.first.?); + if (first.is(Element.Html.Html) == null) { + return; + } + node._children = first._children; + + // No mutation records for the unwrapped children either; see the comment + // about fragment parses in _insertNodeRelative. + var it = node.childrenIterator(); + while (it.next()) |child| { + child._parent = node; + } +} + +// Build a detached XMLDocument from `xml` (DOMParser.parseFromString and +// XMLHttpRequest.responseXML). Returns null when the input isn't well-formed +// XML. +pub fn xmlDocument(frame: *Frame, xml: []const u8) !?*Document.XMLDocument { + const arena = try frame.getArena(.medium, "parse.xmlDocument"); + defer frame.releaseArena(arena); + + const previous_parse_mode = frame._parse_mode; + frame._parse_mode = .fragment; + defer frame._parse_mode = previous_parse_mode; + + const doc = try frame._factory.document(Document.XMLDocument{ ._proto = undefined }); + const doc_node = doc.asNode(); + var parser = Parser.init(arena, doc_node, frame, .{}); + parser.parseXML(xml); + if (parser.terminated) { + return error.ExecutionTerminated; + } + + if (parser.err != null or doc_node.firstChild() == null) { + return null; + } + + // If first node is a `ProcessingInstruction` (e.g. the + // declaration), skip it. + const first_child = doc_node.firstChild().?; + if (first_child.getNodeType() == 7) { + _ = try doc_node.removeChild(first_child, frame); + } + + return doc; +} diff --git a/src/browser/interactive.zig b/src/browser/interactive.zig index 8c9c51e31..d6f092842 100644 --- a/src/browser/interactive.zig +++ b/src/browser/interactive.zig @@ -501,7 +501,7 @@ fn testInteractive(html: []const u8) ![]InteractiveElement { const doc = frame.window._document; const div = try doc.createElement("div", null, frame); - try frame.parseHtmlAsChildren(div.asNode(), html); + try Frame.parse.htmlAsChildren(frame, div.asNode(), html); return collectInteractiveElements(div.asNode(), frame.call_arena, frame); } diff --git a/src/browser/links.zig b/src/browser/links.zig index 45695923a..95624ce0f 100644 --- a/src/browser/links.zig +++ b/src/browser/links.zig @@ -94,7 +94,7 @@ fn testLinks(html: []const u8) ![]Link { const doc = frame.window._document; const div = try doc.createElement("div", null, frame); - try frame.parseHtmlAsChildren(div.asNode(), html); + try Frame.parse.htmlAsChildren(frame, div.asNode(), html); return collectLinks(frame.call_arena, div.asNode(), frame); } diff --git a/src/browser/markdown.zig b/src/browser/markdown.zig index ae702e2d4..86cdd45f4 100644 --- a/src/browser/markdown.zig +++ b/src/browser/markdown.zig @@ -599,7 +599,7 @@ fn testMarkdownHTML(html: []const u8, expected: []const u8) !void { const doc = frame.window._document; const div = try doc.createElement("div", null, frame); - try frame.parseHtmlAsChildren(div.asNode(), html); + try Frame.parse.htmlAsChildren(frame, div.asNode(), html); var aw: std.Io.Writer.Allocating = .init(testing.allocator); defer aw.deinit(); @@ -800,7 +800,7 @@ test "browser.markdown: resolve links" { const doc = frame.window._document; const div = try doc.createElement("div", null, frame); - try frame.parseHtmlAsChildren(div.asNode(), + try Frame.parse.htmlAsChildren(frame, div.asNode(), \\Link \\Img \\Space @@ -839,7 +839,7 @@ test "browser.markdown: max_bytes leaves output untouched when under cap" { const doc = frame.window._document; const div = try doc.createElement("div", null, frame); - try frame.parseHtmlAsChildren(div.asNode(), "

Short

"); + try Frame.parse.htmlAsChildren(frame, div.asNode(), "

Short

"); var aw: std.Io.Writer.Allocating = .init(testing.allocator); defer aw.deinit(); @@ -855,7 +855,7 @@ test "browser.markdown: max_bytes truncates with marker" { const doc = frame.window._document; const div = try doc.createElement("div", null, frame); - try frame.parseHtmlAsChildren(div.asNode(), "

" ++ ("AAAA " ** 100) ++ "

"); + try Frame.parse.htmlAsChildren(frame, div.asNode(), "

" ++ ("AAAA " ** 100) ++ "

"); var aw: std.Io.Writer.Allocating = .init(testing.allocator); defer aw.deinit(); @@ -878,11 +878,11 @@ fn testMarkdownShadow(light: []const u8, shadow: []const u8, expected: []const u const host = try doc.createElement("div", null, frame); if (light.len > 0) { - try frame.parseHtmlAsChildren(host.asNode(), light); + try Frame.parse.htmlAsChildren(frame, host.asNode(), light); } const sr = try host.attachShadow(.{ .mode = .open }, frame); - try frame.parseHtmlAsChildren(sr.asNode(), shadow); + try Frame.parse.htmlAsChildren(frame, sr.asNode(), shadow); var aw: std.Io.Writer.Allocating = .init(testing.allocator); defer aw.deinit(); diff --git a/src/browser/structured_data.zig b/src/browser/structured_data.zig index 8e5aac614..0090f81eb 100644 --- a/src/browser/structured_data.zig +++ b/src/browser/structured_data.zig @@ -494,7 +494,7 @@ fn testStructuredData(html: []const u8) !StructuredData { const doc = frame.window._document; const div = try doc.createElement("div", null, frame); - try frame.parseHtmlAsChildren(div.asNode(), html); + try Frame.parse.htmlAsChildren(frame, div.asNode(), html); return collectStructuredData(div.asNode(), frame.call_arena, frame); } @@ -714,7 +714,7 @@ test "structured_data: link headers from response" { const doc = frame.window._document; const div = try doc.createElement("div", null, frame); - try frame.parseHtmlAsChildren(div.asNode(), "Example"); + try Frame.parse.htmlAsChildren(frame, div.asNode(), "Example"); const data = try collectStructuredData(div.asNode(), frame.call_arena, frame); diff --git a/src/browser/tests/custom_elements/parser_wrapper_removed.html b/src/browser/tests/custom_elements/parser_wrapper_removed.html index 77c637eb5..c2ca9f62b 100644 --- a/src/browser/tests/custom_elements/parser_wrapper_removed.html +++ b/src/browser/tests/custom_elements/parser_wrapper_removed.html @@ -2,7 +2,7 @@ - + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/webapi/collections/HTMLCollection.zig b/src/browser/webapi/collections/HTMLCollection.zig index 4311623d2..57969c406 100644 --- a/src/browser/webapi/collections/HTMLCollection.zig +++ b/src/browser/webapi/collections/HTMLCollection.zig @@ -36,6 +36,7 @@ const Mode = enum { child_elements, child_tag, cells, + select_options, selected_options, links, anchors, @@ -54,6 +55,7 @@ _data: union(Mode) { child_elements: NodeLive(.child_elements), child_tag: NodeLive(.child_tag), cells: NodeLive(.cells), + select_options: NodeLive(.select_options), selected_options: NodeLive(.selected_options), links: NodeLive(.links), anchors: NodeLive(.anchors), @@ -111,6 +113,7 @@ pub fn iterator(self: *HTMLCollection, exec: *const Execution) !*Iterator { .child_elements => |*impl| .{ .child_elements = impl._tw.clone() }, .child_tag => |*impl| .{ .child_tag = impl._tw.clone() }, .cells => |*impl| .{ .cells = impl._tw.clone() }, + .select_options => |*impl| .{ .select_options = impl._tw.clone() }, .selected_options => |*impl| .{ .selected_options = impl._tw.clone() }, .links => |*impl| .{ .links = impl._tw.clone() }, .anchors => |*impl| .{ .anchors = impl._tw.clone() }, @@ -132,7 +135,8 @@ pub const Iterator = GenericIterator(struct { child_elements: TreeWalker.Children, child_tag: TreeWalker.Children, cells: TreeWalker.Children, - selected_options: TreeWalker.Children, + select_options: TreeWalker.FullExcludeSelf, + selected_options: TreeWalker.FullExcludeSelf, links: TreeWalker.FullExcludeSelf, anchors: TreeWalker.FullExcludeSelf, form: TreeWalker.FullExcludeSelf, @@ -157,6 +161,7 @@ pub const Iterator = GenericIterator(struct { .child_elements => |*impl| impl.nextTw(&self.tw.child_elements), .child_tag => |*impl| impl.nextTw(&self.tw.child_tag), .cells => |*impl| impl.nextTw(&self.tw.cells), + .select_options => |*impl| impl.nextTw(&self.tw.select_options), .selected_options => |*impl| impl.nextTw(&self.tw.selected_options), .links => |*impl| impl.nextTw(&self.tw.links), .anchors => |*impl| impl.nextTw(&self.tw.anchors), diff --git a/src/browser/webapi/collections/node_live.zig b/src/browser/webapi/collections/node_live.zig index 221075eb6..36c919732 100644 --- a/src/browser/webapi/collections/node_live.zig +++ b/src/browser/webapi/collections/node_live.zig @@ -40,6 +40,7 @@ const Mode = enum { child_elements, child_tag, cells, + select_options, selected_options, links, anchors, @@ -68,6 +69,7 @@ const Filters = union(Mode) { child_elements, child_tag: Element.Tag, cells, + select_options, selected_options, links, anchors, @@ -100,7 +102,10 @@ pub fn NodeLive(comptime mode: Mode) type { const Filter = Filters.TypeOf(mode); const TW = switch (mode) { .tag, .tag_name, .tag_name_ns, .class_name, .name, .all_elements, .links, .anchors, .form => TreeWalker.FullExcludeSelf, - .child_elements, .child_tag, .cells, .selected_options => TreeWalker.Children, + .child_elements, .child_tag, .cells => TreeWalker.Children, + // A select's options can sit one level down, inside an , so + // these two walk the subtree and filter on the parent instead. + .select_options, .selected_options => TreeWalker.FullExcludeSelf, }; return struct { _tw: TW, @@ -299,11 +304,23 @@ pub fn NodeLive(comptime mode: Mode) type { const el = node.is(Element) orelse return false; return el.is(Element.Html.TableCell) != null; }, - .selected_options => { + .select_options, .selected_options => { + // The select's list of options is its option children plus + // the option children of its optgroup children — NOT every + // option descendant. + // https://html.spec.whatwg.org/multipage/form-elements.html#the-select-element const el = node.is(Element) orelse return false; const Option = Element.Html.Option; const opt = el.is(Option) orelse return false; - return opt.getSelected(); + + const parent = node.parentNode() orelse return false; + if (parent != self._tw._root) { + const group = parent.is(Element) orelse return false; + if (group.getTag() != .optgroup) return false; + if (parent.parentNode() != self._tw._root) return false; + } + + return if (comptime mode == .selected_options) opt.getSelected() else true; }, .links => { // Links are elements with href attribute (TODO: also when implemented) @@ -390,6 +407,7 @@ pub fn NodeLive(comptime mode: Mode) type { .child_elements => HTMLCollection{ ._data = .{ .child_elements = self } }, .child_tag => HTMLCollection{ ._data = .{ .child_tag = self } }, .cells => HTMLCollection{ ._data = .{ .cells = self } }, + .select_options => HTMLCollection{ ._data = .{ .select_options = self } }, .selected_options => HTMLCollection{ ._data = .{ .selected_options = self } }, .links => HTMLCollection{ ._data = .{ .links = self } }, .anchors => HTMLCollection{ ._data = .{ .anchors = self } }, diff --git a/src/browser/webapi/element/html/Select.zig b/src/browser/webapi/element/html/Select.zig index 3c3a04a3d..62eb68329 100644 --- a/src/browser/webapi/element/html/Select.zig +++ b/src/browser/webapi/element/html/Select.zig @@ -48,6 +48,43 @@ pub fn asConstNode(self: *const Select) *const Node { return self.asConstElement().asConstNode(); } +// Walks the select's list of options in tree order: its option children plus +// the option children of its optgroup children, per HTML +// §the-select-element. Options nested any deeper are not in the list. +const OptionIterator = struct { + // Cursor over the select's children. + _child: ?*Node, + // Cursor over the current optgroup's children, while inside one. + _in_group: ?*Node = null, + + fn init(select: *const Select) OptionIterator { + return .{ ._child = select.asConstNode().firstChild() }; + } + + fn next(self: *OptionIterator) ?*Option { + while (true) { + if (self._in_group) |node| { + self._in_group = node.nextSibling(); + if (node.is(Option)) |option| { + return option; + } + continue; + } + + const node = self._child orelse return null; + self._child = node.nextSibling(); + + if (node.is(Option)) |option| { + return option; + } + const element = node.is(Element) orelse continue; + if (element.getTag() == .optgroup) { + self._in_group = node.firstChild(); + } + } + } +}; + // Resolves the option whose selectedness contributes to the select's value // per HTML §form-elements§selectedness-setting-algorithm: an explicitly // selected non-disabled option, falling back to the first non-disabled @@ -56,10 +93,12 @@ pub fn asConstNode(self: *const Select) *const Node { // and contributes no entry to a FormData set. pub fn effectiveOption(self: *const Select) ?*Option { var first_option: ?*Option = null; - var maybe_child = self.asConstNode().firstChild(); - while (maybe_child) |child| : (maybe_child = child.nextSibling()) { - const option = child.is(Option) orelse continue; - if (option.getDisabled()) continue; + var it = OptionIterator.init(self); + while (it.next()) |option| { + // Element.isDisabled, not Option.getDisabled: an option is also + // disabled when its parent is an + // (HTML "concept-option-disabled"). + if (option.asElement().isDisabled()) continue; if (option.getSelected()) return option; if (first_option == null) first_option = option; } @@ -77,9 +116,8 @@ pub fn setValue(self: *Select, value: []const u8, frame: *Frame) !void { // Find option with matching value and select it // Note: This updates the current state (_selected), not the default state (attribute) // Setting value always deselects all others, even for multiple selects - var iter = self.asNode().childrenIterator(); - while (iter.next()) |child| { - const option = child.is(Option) orelse continue; + var it = OptionIterator.init(self); + while (it.next()) |option| { option._selected = std.mem.eql(u8, option.getValue(frame), value); } } @@ -87,9 +125,8 @@ pub fn setValue(self: *Select, value: []const u8, frame: *Frame) !void { pub fn getSelectedIndex(self: *Select) i32 { var index: i32 = 0; var has_options = false; - var iter = self.asNode().childrenIterator(); - while (iter.next()) |child| { - const option = child.is(Option) orelse continue; + var it = OptionIterator.init(self); + while (it.next()) |option| { has_options = true; if (option.getSelected()) { return index; @@ -112,9 +149,8 @@ pub fn setSelectedIndex(self: *Select, index: i32) !void { // Note: This updates the current state (_selected), not the default state (attribute) const is_multiple = self.getMultiple(); var current_index: i32 = 0; - var iter = self.asNode().childrenIterator(); - while (iter.next()) |child| { - const option = child.is(Option) orelse continue; + var it = OptionIterator.init(self); + while (it.next()) |option| { if (current_index == index) { option._selected = true; } else if (!is_multiple) { @@ -200,8 +236,9 @@ pub fn setRequired(self: *Select, required: bool, frame: *Frame) !void { } pub fn getOptions(self: *Select, frame: *Frame) !*collections.HTMLOptionsCollection { - // For options, we use the child_tag mode to filter only