From 081979be3bfb2297292d2294a2884e39f893683f Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 16 Feb 2026 17:43:38 +0800 Subject: [PATCH 1/8] Initial support for frames Missing: - [ ] Navigation support within frames (in fact, as-is, any navigation done inside a frame, will almost certainly break things - [ ] Correct CDP support. I don't know how frames are supposed to be exposed to CDP. Normal navigate events? Distinct CDP frame_ids? - [ ] Cross-origin restrictions. The interaction between frames is supposed to change depending on whether or not they're on the same origin - [ ] Potentially handling src-less frames incorrectly. Might not really matter Adds basic frame support. Initially explored adding a BrowsingContext and embedding it in Page, with the goal of also having it embedded in a to-be created Frame. But it turns out that 98% of Page _was_ BrowsingContext and introducing a BrowsingContext as the primary interaction unit broke pretty much _every_ single WebAPI. So Page was expanded: - Added `_parent: ?*Page`, which is `null` for "root" page. - Added `frame: ?*IFrame`, which is `null` for the "root" page. This is the HTMLIFrameElement for frame-pages. - Added a _type: enum{root, frame}, which is currently only used to improve the logs - Added a frames: std.ArrayList(*Page). This is a list of frames for the page. Note that a "frame-page" can itself haven nested frames. Besides the above, there were 3 "big" changes. 1 - Adding frames (dynamically, parsed) has to create a new page, start navigation, track it (in the frames list). Part of this was just piggybacking off of code that handles + + + + + + + + + + + diff --git a/src/browser/tests/frames/support/sub1.html b/src/browser/tests/frames/support/sub1.html new file mode 100644 index 00000000..f6b8ec4b --- /dev/null +++ b/src/browser/tests/frames/support/sub1.html @@ -0,0 +1,6 @@ + +
sub1 div1
+ diff --git a/src/browser/tests/frames/support/sub2.html b/src/browser/tests/frames/support/sub2.html new file mode 100644 index 00000000..ca1aaa21 --- /dev/null +++ b/src/browser/tests/frames/support/sub2.html @@ -0,0 +1,7 @@ + +
sub2 div1
+ + diff --git a/src/browser/tests/testing.js b/src/browser/tests/testing.js index 62c8473f..314fc001 100644 --- a/src/browser/tests/testing.js +++ b/src/browser/tests/testing.js @@ -220,4 +220,8 @@ return val; }); } + + if (window._lightpanda_skip_auto_assert !== true) { + window.addEventListener('load', testing.assertOk); + } })(); diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index 124da937..5bdfa5fb 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -679,7 +679,7 @@ pub fn normalize(self: *Node, page: *Page) !void { return self._normalize(page.call_arena, &buffer, page); } -pub fn cloneNode(self: *Node, deep_: ?bool, page: *Page) error{ OutOfMemory, StringTooLarge, NotSupported, NotImplemented, InvalidCharacterError, CloneError }!*Node { +pub fn cloneNode(self: *Node, deep_: ?bool, page: *Page) error{ OutOfMemory, StringTooLarge, NotSupported, NotImplemented, InvalidCharacterError, CloneError, IFrameLoadError }!*Node { const deep = deep_ orelse false; switch (self._type) { .cdata => |cd| { diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index d339d60b..80694dfa 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -52,6 +52,7 @@ const Allocator = std.mem.Allocator; const Window = @This(); _proto: *EventTarget, +_page: *Page, _document: *Document, _css: CSS = .init, _crypto: Crypto = .init, @@ -96,6 +97,21 @@ pub fn getWindow(self: *Window) *Window { return self; } +pub fn getTop(self: *Window) *Window { + var p = self._page; + while (p.parent) |parent| { + p = parent; + } + return p.window; +} + +pub fn getParent(self: *Window) *Window { + if (self._page.parent) |p| { + return p.window; + } + return self; +} + pub fn getDocument(self: *Window) *Document { return self._document; } @@ -388,23 +404,31 @@ pub fn atob(_: *const Window, input: []const u8, page: *Page) ![]const u8 { return decoded; } -pub fn getFrame(_: *Window, _: usize) !?*Window { - // TODO return the iframe's window. - return null; +pub fn getFrame(self: *Window, idx: usize) !?*Window { + const page = self._page; + const frames = page.frames.items; + if (idx >= frames.len) { + return null; + } + + if (page.frames_sorted == false) { + std.mem.sort(*Page, frames, {}, struct { + fn lessThan(_: void, a: *Page, b: *Page) bool { + const iframe_a = a.iframe orelse return false; + const iframe_b = b.iframe orelse return true; + + const pos = iframe_a.asNode().compareDocumentPosition(iframe_b.asNode()); + // Return true if a precedes b (a should come before b in sorted order) + return (pos & 0x04) != 0; // FOLLOWING bit: b follows a + } + }.lessThan); + page.frames_sorted = true; + } + return frames[idx].window; } pub fn getFramesLength(self: *const Window) u32 { - const TreeWalker = @import("TreeWalker.zig"); - var walker = TreeWalker.Full.init(self._document.asNode(), .{}); - - var ln: u32 = 0; - while (walker.next()) |node| { - if (node.is(Element.Html.IFrame) != null) { - ln += 1; - } - } - - return ln; + return @intCast(self._page.frames.items.len); } pub fn getScrollX(self: *const Window) u32 { @@ -716,10 +740,10 @@ pub const JsApi = struct { pub const document = bridge.accessor(Window.getDocument, null, .{ .cache = .{ .internal = 1 } }); pub const console = bridge.accessor(Window.getConsole, null, .{ .cache = .{ .internal = 2 } }); - pub const top = bridge.accessor(Window.getWindow, null, .{}); + pub const top = bridge.accessor(Window.getTop, null, .{}); pub const self = bridge.accessor(Window.getWindow, null, .{}); pub const window = bridge.accessor(Window.getWindow, null, .{}); - pub const parent = bridge.accessor(Window.getWindow, null, .{}); + pub const parent = bridge.accessor(Window.getParent, null, .{}); pub const navigator = bridge.accessor(Window.getNavigator, null, .{}); pub const screen = bridge.accessor(Window.getScreen, null, .{}); pub const visualViewport = bridge.accessor(Window.getVisualViewport, null, .{}); diff --git a/src/browser/webapi/element/Html.zig b/src/browser/webapi/element/Html.zig index 5f2cb867..8f7ebe95 100644 --- a/src/browser/webapi/element/Html.zig +++ b/src/browser/webapi/element/Html.zig @@ -342,6 +342,7 @@ pub fn click(self: *HtmlElement, page: *Page) !void { try page._event_manager.dispatch(self.asEventTarget(), event); } +<<<<<<< HEAD // TODO: Per spec, hidden is a tristate: true | false | "until-found". // We only support boolean for now; "until-found" would need bridge union support. pub fn getHidden(self: *HtmlElement) bool { @@ -373,13 +374,14 @@ pub fn setTabIndex(self: *HtmlElement, value: i32, page: *Page) !void { try self.asElement().setAttributeSafe(comptime .wrap("tabindex"), .wrap(str), page); } -fn getAttributeFunction( + +pub fn getAttributeFunction( self: *HtmlElement, listener_type: GlobalEventHandler, page: *Page, ) !?js.Function.Global { const element = self.asElement(); - if (page.getAttrListener(element, listener_type)) |cached| { + if (page._element_attr_listeners.get(.{ .target = element.asEventTarget(), .handler = listener_type })) |cached| { return cached; } diff --git a/src/browser/webapi/element/html/IFrame.zig b/src/browser/webapi/element/html/IFrame.zig index 4aa65df0..7d4d183f 100644 --- a/src/browser/webapi/element/html/IFrame.zig +++ b/src/browser/webapi/element/html/IFrame.zig @@ -16,15 +16,21 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const log = @import("../../../../log.zig"); const js = @import("../../../js/js.zig"); const Page = @import("../../../Page.zig"); const Window = @import("../../Window.zig"); +const Document = @import("../../Document.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); +const URL = @import("../../URL.zig"); const IFrame = @This(); _proto: *HtmlElement, +_src: []const u8 = "", +_executed: bool = false, +_content_window: ?*Window = null, pub fn asElement(self: *IFrame) *Element { return self._proto._proto; @@ -33,8 +39,27 @@ pub fn asNode(self: *IFrame) *Node { return self.asElement().asNode(); } -pub fn getContentWindow(_: *const IFrame, page: *Page) *Window { - return page.window; +pub fn getContentWindow(self: *const IFrame) ?*Window { + return self._content_window; +} + +pub fn getContentDocument(self: *const IFrame) ?*Document { + const window = self._content_window orelse return null; + return window._document; +} + +pub fn getSrc(self: *const IFrame, page: *Page) ![:0]const u8 { + if (self._src.len == 0) return ""; + return try URL.resolve(page.call_arena, page.base(), self._src, .{}); +} + +pub fn setSrc(self: *IFrame, src: []const u8, page: *Page) !void { + const element = self.asElement(); + try element.setAttributeSafe(comptime .wrap("src"), .wrap(src), page); + self._src = element.getAttributeSafe(comptime .wrap("src")) orelse unreachable; + if (element.asNode().isConnected()) { + try page.iframeAddedCallback(self); + } } pub const JsApi = struct { @@ -46,5 +71,15 @@ pub const JsApi = struct { pub var class_id: bridge.ClassId = undefined; }; + pub const src = bridge.accessor(IFrame.getSrc, IFrame.setSrc, .{}); pub const contentWindow = bridge.accessor(IFrame.getContentWindow, null, .{}); + pub const contentDocument = bridge.accessor(IFrame.getContentDocument, null, .{}); +}; + +pub const Build = struct { + pub fn complete(node: *Node, _: *Page) !void { + const self = node.as(IFrame); + const element = self.asElement(); + self._src = element.getAttributeSafe(comptime .wrap("src")) orelse ""; + } }; diff --git a/src/cdp/cdp.zig b/src/cdp/cdp.zig index f7a3dd88..ae83ad17 100644 --- a/src/cdp/cdp.zig +++ b/src/cdp/cdp.zig @@ -763,7 +763,7 @@ const IsolatedWorld = struct { // Currently we have only 1 page/frame and thus also only 1 state in the isolate world. pub fn createContext(self: *IsolatedWorld, page: *Page) !*js.Context { if (self.context == null) { - self.context = try self.browser.env.createContext(page, false); + self.context = try self.browser.env.createContext(page); } else { log.warn(.cdp, "not implemented", .{ .feature = "createContext: Not implemented second isolated context creation", diff --git a/src/testing.zig b/src/testing.zig index b79eacd4..62ec8870 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -414,6 +414,15 @@ fn runWebApiTest(test_file: [:0]const u8) !void { try_catch.init(&ls.local); defer try_catch.deinit(); + // by default, on load, testing.js will call testing.assertOk(). This makes our + // tests work well in a browser. But, for our test runner, we disable that + // and call it explicitly. This gives us better error messages. + ls.local.eval("window._lightpanda_skip_auto_assert = true;", "auto_assert") catch |err| { + const caught = try_catch.caughtOrError(arena_allocator, err); + std.debug.print("disable auto assert failure\nError: {f}\n", .{caught}); + return err; + }; + try page.navigate(url, .{}); _ = test_session.wait(2000); From da48ffe05ca45e137907cbd357470880094a2d46 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 17 Feb 2026 17:54:29 +0800 Subject: [PATCH 2/8] Move page.wait to session.wait page.wait is the only significant difference between the "root" page and a page for an iframe. I think it's more explicit to move this out of the page and into the session, which was already the sole entry-point for page.wait. --- src/browser/Page.zig | 7 +- src/browser/Session.zig | 174 ++++++++++++++++++++++++++++++++++++---- src/main_wpt.zig | 2 +- 3 files changed, 165 insertions(+), 18 deletions(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 964233ad..52a1b07f 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -942,6 +942,7 @@ fn clearTransferArena(self: *Page) void { self.arena_pool.reset(self._session.transfer_arena, 4 * 1024); } +<<<<<<< HEAD pub fn wait(self: *Page, wait_ms: u32) Session.WaitResult { return self._wait(wait_ms) catch |err| { switch (err) { @@ -1173,6 +1174,8 @@ fn printWaitAnalysis(self: *Page) void { } } +======= +>>>>>>> 3bd80eb3 (Move page.wait to session.wait) pub fn isGoingAway(self: *const Page) bool { return self._queued_navigation != null; } @@ -1565,7 +1568,7 @@ pub fn deliverSlotchangeEvents(self: *Page) void { } } -fn notifyNetworkIdle(self: *Page) void { +pub fn notifyNetworkIdle(self: *Page) void { lp.assert(self._notified_network_idle == .done, "Page.notifyNetworkIdle", .{}); self._session.notification.dispatch(.page_network_idle, &.{ .page_id = self.id, @@ -1574,7 +1577,7 @@ fn notifyNetworkIdle(self: *Page) void { }); } -fn notifyNetworkAlmostIdle(self: *Page) void { +pub fn notifyNetworkAlmostIdle(self: *Page) void { lp.assert(self._notified_network_almost_idle == .done, "Page.notifyNetworkAlmostIdle", .{}); self._session.notification.dispatch(.page_network_almost_idle, &.{ .page_id = self.id, diff --git a/src/browser/Session.zig b/src/browser/Session.zig index 0b9228bd..1fef35c9 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -18,6 +18,7 @@ const std = @import("std"); const lp = @import("lightpanda"); +const builtin = @import("builtin"); const log = @import("../log.zig"); @@ -31,7 +32,7 @@ const Browser = @import("Browser.zig"); const Notification = @import("../Notification.zig"); const Allocator = std.mem.Allocator; -const IS_DEBUG = @import("builtin").mode == .Debug; +const IS_DEBUG = builtin.mode == .Debug; // Session is like a browser's tab. // It owns the js env and the loader for all the pages of the session. @@ -176,26 +177,167 @@ pub fn findPage(self: *Session, id: u32) ?*Page { } pub fn wait(self: *Session, wait_ms: u32) WaitResult { + var page = &(self.page orelse return .no_page); while (true) { - if (self.page) |*page| { - switch (page.wait(wait_ms)) { - .done => { - if (page._queued_navigation == null) { - return .done; - } - self.processScheduledNavigation() catch return .done; - }, - else => |result| return result, + const wait_result = self._wait(page, wait_ms) catch |err| { + switch (err) { + error.JsError => {}, // already logged (with hopefully more context) + else => log.err(.browser, "session wait", .{ .err = err, }), } - } else { - return .no_page; + return .done; + }; + + switch (wait_result) { + .done => { + if (page._queued_navigation == null) { + return .done; + } + page = self.processScheduledNavigation() catch return .done; + }, + else => |result| return result, } - // if we've successfull navigated, we'll give the new page another - // page.wait(wait_ms) } } -fn processScheduledNavigation(self: *Session) !void { +fn _wait(self: *Session, page: *Page, wait_ms: u32) !WaitResult { + var timer = try std.time.Timer.start(); + var ms_remaining = wait_ms; + + const browser = self.browser; + var http_client = browser.http_client; + + // I'd like the page to know NOTHING about cdp_socket / CDP, but the + // fact is that the behavior of wait changes depending on whether or + // not we're using CDP. + // If we aren't using CDP, as soon as we think there's nothing left + // to do, we can exit - we'de done. + // But if we are using CDP, we should wait for the whole `wait_ms` + // because the http_click.tick() also monitors the CDP socket. And while + // we could let CDP poll http (like it does for HTTP requests), the fact + // is that we know more about the timing of stuff (e.g. how long to + // poll/sleep) in the page. + const exit_when_done = http_client.cdp_client == null; + + while (true) { + switch (page._parse_state) { + .pre, .raw, .text, .image => { + // The main page hasn't started/finished navigating. + // There's no JS to run, and no reason to run the scheduler. + if (http_client.active == 0 and exit_when_done) { + // haven't started navigating, I guess. + return .done; + } + // Either we have active http connections, or we're in CDP + // mode with an extra socket. Either way, we're waiting + // for http traffic + if (try http_client.tick(@intCast(ms_remaining)) == .cdp_socket) { + // exit_when_done is explicitly set when there isn't + // an extra socket, so it should not be possibl to + // get an cdp_socket message when exit_when_done + // is true. + if (IS_DEBUG) { + std.debug.assert(exit_when_done == false); + } + + // data on a socket we aren't handling, return to caller + return .cdp_socket; + } + }, + .html, .complete => { + if (page._queued_navigation != null) { + return .done; + } + + // The HTML page was parsed. We now either have JS scripts to + // download, or scheduled tasks to execute, or both. + + // scheduler.run could trigger new http transfers, so do not + // store http_client.active BEFORE this call and then use + // it AFTER. + const ms_to_next_task = try browser.runMacrotasks(); + + const http_active = http_client.active; + const total_network_activity = http_active + http_client.intercepted; + if (page._notified_network_almost_idle.check(total_network_activity <= 2)) { + page.notifyNetworkAlmostIdle(); + } + if (page._notified_network_idle.check(total_network_activity == 0)) { + page.notifyNetworkIdle(); + } + + if (http_active == 0 and exit_when_done) { + // we don't need to consider http_client.intercepted here + // because exit_when_done is true, and that can only be + // the case when interception isn't possible. + if (comptime IS_DEBUG) { + std.debug.assert(http_client.intercepted == 0); + } + + const ms = ms_to_next_task orelse blk: { + if (wait_ms - ms_remaining < 100) { + if (comptime builtin.is_test) { + return .done; + } + // Look, we want to exit ASAP, but we don't want + // to exit so fast that we've run none of the + // background jobs. + break :blk 50; + } + // No http transfers, no cdp extra socket, no + // scheduled tasks, we're done. + return .done; + }; + + if (ms > ms_remaining) { + // Same as above, except we have a scheduled task, + // it just happens to be too far into the future + // compared to how long we were told to wait. + return .done; + } + + // We have a task to run in the not-so-distant future. + // You might think we can just sleep until that task is + // ready, but we should continue to run lowPriority tasks + // in the meantime, and that could unblock things. So + // we'll just sleep for a bit, and then restart our wait + // loop to see if anything new can be processed. + std.Thread.sleep(std.time.ns_per_ms * @as(u64, @intCast(@min(ms, 20)))); + } else { + // We're here because we either have active HTTP + // connections, or exit_when_done == false (aka, there's + // an cdp_socket registered with the http client). + // We should continue to run lowPriority tasks, so we + // minimize how long we'll poll for network I/O. + const ms_to_wait = @min(200, @min(ms_remaining, ms_to_next_task orelse 200)); + if (try http_client.tick(ms_to_wait) == .cdp_socket) { + // data on a socket we aren't handling, return to caller + return .cdp_socket; + } + } + }, + .err => |err| { + page._parse_state = .{ .raw_done = @errorName(err) }; + return err; + }, + .raw_done => { + if (exit_when_done) { + return .done; + } + // we _could_ http_client.tick(ms_to_wait), but this has + // the same result, and I feel is more correct. + return .no_page; + }, + } + + const ms_elapsed = timer.lap() / 1_000_000; + if (ms_elapsed >= ms_remaining) { + return .done; + } + ms_remaining -= @intCast(ms_elapsed); + } +} + +fn processScheduledNavigation(self: *Session) !*Page { defer self.browser.arena_pool.reset(self.transfer_arena, 4 * 1024); const url, const opts, const page_id = blk: { const page = self.page.?; @@ -226,4 +368,6 @@ fn processScheduledNavigation(self: *Session) !void { log.err(.browser, "queued navigation error", .{ .err = err, .url = url }); return err; }; + + return page; } diff --git a/src/main_wpt.zig b/src/main_wpt.zig index bf63c6c2..3db9d92b 100644 --- a/src/main_wpt.zig +++ b/src/main_wpt.zig @@ -126,7 +126,7 @@ fn run( const url = try std.fmt.allocPrintSentinel(arena, "http://localhost:9582/{s}", .{test_file}, 0); try page.navigate(url, .{}); - _ = page.wait(2000); + _ = session.wait(2000); var ls: lp.js.Local.Scope = undefined; page.js.localScope(&ls); From 6e6082119fbc0a5b8d237df7f774cbf39cc9bb5c Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 17 Feb 2026 18:16:35 +0800 Subject: [PATCH 3/8] Remove session.transfer_arena This no longer works with frames. Multiple frames could have a scheduled navigation, so a single arena no longer has a clear lifecycle. Instead an arena from the pool is used per navigation event, thus the queued_navigation is self- contained. This required having libcurl copy the body. Unfortunate. Currently we free the arena as soon as the navigation begins. This is clean. But it means the body is immediately freed (thus we need libcurl to copy it). As an alternative, each page could maintain an optional transfer_arena, which it could free on httpDone/Error. --- src/browser/Page.zig | 57 +++++++++++++-------------------- src/browser/Session.zig | 70 ++++++++++++++++------------------------- src/http/Http.zig | 2 +- 3 files changed, 50 insertions(+), 79 deletions(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 52a1b07f..6d6828bc 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -181,7 +181,7 @@ _notified_network_almost_idle: IdleNotification = .init, // A navigation event that happens from a script gets scheduled to run on the // next tick. -_queued_navigation: ?QueuedNavigation = null, +_queued_navigation: ?*QueuedNavigation = null, // The URL of the current page url: [:0]const u8 = "about:blank", @@ -542,23 +542,27 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !voi // We cannot navigate immediately as navigating will delete the DOM tree, // which holds this event's node. // As such we schedule the function to be called as soon as possible. -// The page.arena is safe to use here, but the transfer_arena exists -// specifically for this type of lifetime. pub fn scheduleNavigation(self: *Page, request_url: []const u8, opts: NavigateOpts, priority: NavigationPriority) !void { if (self.canScheduleNavigation(priority) == false) { return; } + const arena = try self.arena_pool.acquire(); + errdefer self.arena_pool.release(arena); + return self.scheduleNavigationWithArena(arena, request_url, opts, priority); +} - const session = self._session; - +fn scheduleNavigationWithArena(self: *Page, arena: Allocator, request_url: []const u8, opts: NavigateOpts, priority: NavigationPriority) !void { const resolved_url = try URL.resolve( - session.transfer_arena, + arena, self.base(), request_url, .{ .always_dupe = true }, ); + const session = self._session; if (!opts.force and URL.eqlDocument(self.url, resolved_url)) { + self.arena_pool.release(arena); + self.url = try self.arena.dupeZ(u8, resolved_url); self.window._location = try Location.init(self.url, self); self.document._location = self.window._location; @@ -572,13 +576,16 @@ pub fn scheduleNavigation(self: *Page, request_url: []const u8, opts: NavigateOp .type = self._type, }); - self._session.browser.http_client.abort(); + session.browser.http_client.abort(); - self._queued_navigation = .{ + const qn = try arena.create(QueuedNavigation); + qn.* = .{ .opts = opts, + .arena = arena, .url = resolved_url, .priority = priority, }; + self._queued_navigation = qn; } // A script can have multiple competing navigation events, say it starts off @@ -837,8 +844,6 @@ fn pageDoneCallback(ctx: *anyopaque) !void { log.debug(.page, "navigate done", .{ .type = self._type }); } - self.clearTransferArena(); - //We need to handle different navigation types differently. try self._session.navigation.commitNavigation(self); @@ -925,24 +930,6 @@ fn pageErrorCallback(ctx: *anyopaque, err: anyerror) void { }; } -// The transfer arena is useful and interesting, but has a weird lifetime. -// When we're transferring from one page to another (via delayed navigation) -// we need things in memory: like the URL that we're navigating to and -// optionally the body to POST. That cannot exist in the page.arena, because -// the page that we have is going to be destroyed and a new page is going -// to be created. If we used the page.arena, we'd wouldn't be able to reset -// it between navigation. -// So the transfer arena is meant to exist between a navigation event. It's -// freed when the main html navigation is complete, either in pageDoneCallback -// or pageErrorCallback. It needs to exist for this long because, if we set -// a body, CURLOPT_POSTFIELDS does not copy the body (it optionally can, but -// why would we want to) and requires the body to live until the transfer -// is complete. -fn clearTransferArena(self: *Page) void { - self.arena_pool.reset(self._session.transfer_arena, 4 * 1024); -} - -<<<<<<< HEAD pub fn wait(self: *Page, wait_ms: u32) Session.WaitResult { return self._wait(wait_ms) catch |err| { switch (err) { @@ -1174,8 +1161,6 @@ fn printWaitAnalysis(self: *Page) void { } } -======= ->>>>>>> 3bd80eb3 (Move page.wait to session.wait) pub fn isGoingAway(self: *const Page) bool { return self._queued_navigation != null; } @@ -3150,7 +3135,8 @@ const NavigationPriority = enum { anchor, }; -const QueuedNavigation = struct { +pub const QueuedNavigation = struct { + arena: Allocator, url: [:0]const u8, opts: NavigateOpts, priority: NavigationPriority, @@ -3333,11 +3319,12 @@ pub fn submitForm(self: *Page, submitter_: ?*Element, form_: ?*Element.Html.Form // I don't think this is technically correct, but FormData handles it ok const form_data = try FormData.init(form, submitter_, self); - const transfer_arena = self._session.transfer_arena; + const arena = try self.arena_pool.acquire(); + errdefer self.arena_pool.release(arena); const encoding = form_element.getAttributeSafe(comptime .wrap("enctype")); - var buf = std.Io.Writer.Allocating.init(transfer_arena); + var buf = std.Io.Writer.Allocating.init(arena); try form_data.write(encoding, &buf.writer); const method = form_element.getAttributeSafe(comptime .wrap("method")) orelse ""; @@ -3353,9 +3340,9 @@ pub fn submitForm(self: *Page, submitter_: ?*Element, form_: ?*Element.Html.Form // form_data.write currently only supports this encoding, so we know this has to be the content type opts.header = "Content-Type: application/x-www-form-urlencoded"; } else { - action = try URL.concatQueryString(transfer_arena, action, buf.written()); + action = try URL.concatQueryString(arena, action, buf.written()); } - return self.scheduleNavigation(action, opts, .form); + return self.scheduleNavigationWithArena(arena, action, opts, .form); } // insertText is a shortcut to insert text into the active element. diff --git a/src/browser/Session.zig b/src/browser/Session.zig index 1fef35c9..c61cd4f7 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -46,16 +46,6 @@ notification: *Notification, // Used to create our Inspector and in the BrowserContext. arena: Allocator, -// The page's arena is unsuitable for data that has to existing while -// navigating from one page to another. For example, if we're clicking -// on an HREF, the URL exists in the original page (where the click -// originated) but also has to exist in the new page. -// While we could use the Session's arena, this could accumulate a lot of -// memory if we do many navigation events. The `transfer_arena` is meant to -// bridge the gap: existing long enough to store any data needed to end one -// page and start another. -transfer_arena: Allocator, - cookie_jar: storage.Cookie.Jar, storage_shed: storage.Shed, @@ -71,9 +61,6 @@ pub fn init(self: *Session, browser: *Browser, notification: *Notification) !voi const arena = try browser.arena_pool.acquire(); errdefer browser.arena_pool.release(arena); - const transfer_arena = try browser.arena_pool.acquire(); - errdefer browser.arena_pool.release(transfer_arena); - self.* = .{ .page = null, .arena = arena, @@ -84,7 +71,6 @@ pub fn init(self: *Session, browser: *Browser, notification: *Notification) !voi .storage_shed = .{}, .browser = browser, .notification = notification, - .transfer_arena = transfer_arena, .cookie_jar = storage.Cookie.Jar.init(allocator), }; } @@ -97,7 +83,6 @@ pub fn deinit(self: *Session) void { self.cookie_jar.deinit(); self.storage_shed.deinit(browser.app.allocator); - browser.arena_pool.release(self.transfer_arena); browser.arena_pool.release(self.arena); } @@ -182,17 +167,17 @@ pub fn wait(self: *Session, wait_ms: u32) WaitResult { const wait_result = self._wait(page, wait_ms) catch |err| { switch (err) { error.JsError => {}, // already logged (with hopefully more context) - else => log.err(.browser, "session wait", .{ .err = err, }), + else => log.err(.browser, "session wait", .{ + .err = err, + }), } return .done; }; switch (wait_result) { .done => { - if (page._queued_navigation == null) { - return .done; - } - page = self.processScheduledNavigation() catch return .done; + const qn = page._queued_navigation orelse return .done; + page = self.processScheduledNavigation(qn) catch return .done; }, else => |result| return result, } @@ -337,35 +322,34 @@ fn _wait(self: *Session, page: *Page, wait_ms: u32) !WaitResult { } } -fn processScheduledNavigation(self: *Session) !*Page { - defer self.browser.arena_pool.reset(self.transfer_arena, 4 * 1024); - const url, const opts, const page_id = blk: { - const page = self.page.?; - const qn = page._queued_navigation.?; - // qn might not be safe to use after self.removePage is called, hence - // this block; - const url = qn.url; - const opts = qn.opts; +fn processScheduledNavigation(self: *Session, qn: *Page.QueuedNavigation) !*Page { + const browser = self.browser; + defer browser.arena_pool.release(qn.arena); - // This was already aborted on the page, but it would be pretty - // bad if old requests went to the new page, so let's make double sure - self.browser.http_client.abort(); + const page_id, const parent = blk: { + const page = &self.page.?; + const page_id = page.id; + const parent = page._parent; + + browser.http_client.abort(); self.removePage(); - break :blk .{ url, opts, page.id }; + break :blk .{page_id, parent}; }; - const page = self.createPage() catch |err| { - log.err(.browser, "queued navigation page error", .{ - .err = err, - .url = url, - }); - return err; - }; - page.id = page_id; + self.page = @as(Page, undefined); + const page = &self.page.?; + try Page.init(page, page_id, self, parent); - page.navigate(url, opts) catch |err| { - log.err(.browser, "queued navigation error", .{ .err = err, .url = url }); + // Creates a new NavigationEventTarget for this page. + try self.navigation.onNewPage(page); + + // start JS env + // Inform CDP the main page has been created such that additional context for other Worlds can be created as well + self.notification.dispatch(.page_created, page); + + page.navigate(qn.url, qn.opts) catch |err| { + log.err(.browser, "queued navigation error", .{ .err = err, .url = qn.url }); return err; }; diff --git a/src/http/Http.zig b/src/http/Http.zig index 0c20faa8..553c1c0e 100644 --- a/src/http/Http.zig +++ b/src/http/Http.zig @@ -211,7 +211,7 @@ pub const Connection = struct { const easy = self.easy; try errorCheck(c.curl_easy_setopt(easy, c.CURLOPT_HTTPPOST, @as(c_long, 1))); try errorCheck(c.curl_easy_setopt(easy, c.CURLOPT_POSTFIELDSIZE, @as(c_long, @intCast(body.len)))); - try errorCheck(c.curl_easy_setopt(easy, c.CURLOPT_POSTFIELDS, body.ptr)); + try errorCheck(c.curl_easy_setopt(easy, c.CURLOPT_COPYPOSTFIELDS, body.ptr)); } // These are headers that may not be send to the users for inteception. From 815319140f98f52adfab01fef83247017274cb3f Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 18 Feb 2026 08:51:32 +0800 Subject: [PATCH 4/8] cleanupany incomplete scheduled_navigation on renavigate or page.deinit --- src/browser/Page.zig | 12 ++++++++++-- src/browser/Session.zig | 12 +++++++++--- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 6d6828bc..2d64f1fe 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -332,6 +332,10 @@ pub fn deinit(self: *Page) void { // stats.print(&stream) catch unreachable; } + if (self._queued_navigation) |qn| { + self.arena_pool.release(qn.arena); + } + const session = self._session; session.browser.env.destroyContext(self.js); @@ -585,6 +589,10 @@ fn scheduleNavigationWithArena(self: *Page, arena: Allocator, request_url: []con .url = resolved_url, .priority = priority, }; + + if (self._queued_navigation) |existing| { + self.arena_pool.release(existing.arena); + } self._queued_navigation = qn; } @@ -917,9 +925,9 @@ fn pageDoneCallback(ctx: *anyopaque) !void { } fn pageErrorCallback(ctx: *anyopaque, err: anyerror) void { - log.err(.page, "navigate failed", .{ .err = err, .type = self._type }); - var self: *Page = @ptrCast(@alignCast(ctx)); + + log.err(.page, "navigate failed", .{ .err = err, .type = self._type }); self._parse_state = .{ .err = err }; // In case of error, we want to complete the page with a custom HTML diff --git a/src/browser/Session.zig b/src/browser/Session.zig index c61cd4f7..9a3abf20 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -176,8 +176,10 @@ pub fn wait(self: *Session, wait_ms: u32) WaitResult { switch (wait_result) { .done => { - const qn = page._queued_navigation orelse return .done; - page = self.processScheduledNavigation(qn) catch return .done; + if (page._queued_navigation == null) { + return .done; + } + page = self.processScheduledNavigation(page) catch return .done; }, else => |result| return result, } @@ -322,8 +324,12 @@ fn _wait(self: *Session, page: *Page, wait_ms: u32) !WaitResult { } } -fn processScheduledNavigation(self: *Session, qn: *Page.QueuedNavigation) !*Page { +fn processScheduledNavigation(self: *Session, current_page: *Page) !*Page { const browser = self.browser; + + const qn = current_page._queued_navigation.?; + // take ownership of the page's queued navigation + current_page._queued_navigation = null; defer browser.arena_pool.release(qn.arena); const page_id, const parent = blk: { From bb01a5cb31a35d640e9928908d34d8e1b347aa02 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 18 Feb 2026 12:17:22 +0800 Subject: [PATCH 5/8] Make CDP frame-aware --- src/browser/Page.zig | 3 ++- src/cdp/domains/page.zig | 15 ++++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 2d64f1fe..d08bb634 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -1210,7 +1210,7 @@ pub fn iframeAddedCallback(self: *Page, iframe: *Element.Html.IFrame) !void { page_frame.iframe = iframe; iframe._content_window = page_frame.window; - page_frame.navigate(src, .{}) catch |err| { + page_frame.navigate(src, .{.reason = .initialFrameNavigation}) catch |err| { log.warn(.page, "iframe navigate failure", .{ .url = src, .err = err }); self._pending_loads -= 1; iframe._content_window = null; @@ -3119,6 +3119,7 @@ pub const NavigateReason = enum { script, history, navigation, + initialFrameNavigation, }; pub const NavigateOpts = struct { diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index dad180e5..5e093dde 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -252,13 +252,14 @@ pub fn pageNavigate(bc: anytype, event: *const Notification.PageNavigate) !void .address_bar => null, }; if (reason_) |reason| { - try cdp.sendEvent("Page.frameScheduledNavigation", .{ - .frameId = frame_id, - .delay = 0, - .reason = reason, - .url = event.url, - }, .{ .session_id = session_id }); - + if (reason != .initialFrameNavigation) { + try cdp.sendEvent("Page.frameScheduledNavigation", .{ + .frameId = frame_id, + .delay = 0, + .reason = reason, + .url = event.url, + }, .{ .session_id = session_id }); + } try cdp.sendEvent("Page.frameRequestedNavigation", .{ .frameId = frame_id, .reason = reason, From db2927eea78b27ab5c2469302b7753f3fa907c7d Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 19 Feb 2026 13:21:41 +0800 Subject: [PATCH 6/8] cleanup a not-so-great rebase --- src/browser/Page.zig | 9 +++++---- src/browser/Session.zig | 17 ++++++++++------- src/browser/webapi/element/Html.zig | 2 -- src/cdp/domains/page.zig | 4 +++- 4 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index d08bb634..c2f94c6d 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -226,7 +226,6 @@ frames_sorted: bool = true, // DOM version used to invalidate cached state of "live" collections version: usize = 0, - // This is maybe not great. It's a counter on the number of events that we're // waiting on before triggering the "load" event. Essentially, we need all // synchronous scripts and all iframes to be loaded. Scripts are handled by the @@ -236,7 +235,7 @@ _pending_loads: u32, _parent_notified: if (IS_DEBUG) bool else void = if (IS_DEBUG) false else {}, _type: enum { root, frame }, // only used for logs right now -_req_id: ?u32 = null, +_req_id: u32 = 0, _navigated_options: ?NavigatedOpts = null, pub fn init(self: *Page, id: u32, session: *Session, parent: ?*Page) !void { @@ -1202,15 +1201,17 @@ pub fn iframeAddedCallback(self: *Page, iframe: *Element.Html.IFrame) !void { return; } + const session = self._session; + iframe._executed = true; const page_frame = try self.arena.create(Page); - try Page.init(page_frame, self._session, self); + try Page.init(page_frame, session.nextPageId(), session, self); self._pending_loads += 1; page_frame.iframe = iframe; iframe._content_window = page_frame.window; - page_frame.navigate(src, .{.reason = .initialFrameNavigation}) catch |err| { + page_frame.navigate(src, .{ .reason = .initialFrameNavigation }) catch |err| { log.warn(.page, "iframe navigate failure", .{ .url = src, .err = err }); self._pending_loads -= 1; iframe._content_window = null; diff --git a/src/browser/Session.zig b/src/browser/Session.zig index 9a3abf20..c9b4db48 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -91,12 +91,9 @@ pub fn deinit(self: *Session) void { pub fn createPage(self: *Session) !*Page { lp.assert(self.page == null, "Session.createPage - page not null", .{}); - const id = self.page_id_gen +% 1; - self.page_id_gen = id; - self.page = @as(Page, undefined); const page = &self.page.?; - try Page.init(page, id, self, null); + try Page.init(page, self.nextPageId(), self, null); // Creates a new NavigationEventTarget for this page. try self.navigation.onNewPage(page); @@ -135,7 +132,7 @@ pub fn replacePage(self: *Session) !*Page { var current = self.page.?; const page_id = current.id; - const parent = current._parent; + const parent = current.parent; current.deinit(); self.browser.env.memoryPressureNotification(.moderate); @@ -335,12 +332,12 @@ fn processScheduledNavigation(self: *Session, current_page: *Page) !*Page { const page_id, const parent = blk: { const page = &self.page.?; const page_id = page.id; - const parent = page._parent; + const parent = page.parent; browser.http_client.abort(); self.removePage(); - break :blk .{page_id, parent}; + break :blk .{ page_id, parent }; }; self.page = @as(Page, undefined); @@ -361,3 +358,9 @@ fn processScheduledNavigation(self: *Session, current_page: *Page) !*Page { return page; } + +pub fn nextPageId(self: *Session) u32 { + const id = self.page_id_gen +% 1; + self.page_id_gen = id; + return id; +} diff --git a/src/browser/webapi/element/Html.zig b/src/browser/webapi/element/Html.zig index 8f7ebe95..cba1306b 100644 --- a/src/browser/webapi/element/Html.zig +++ b/src/browser/webapi/element/Html.zig @@ -342,7 +342,6 @@ pub fn click(self: *HtmlElement, page: *Page) !void { try page._event_manager.dispatch(self.asEventTarget(), event); } -<<<<<<< HEAD // TODO: Per spec, hidden is a tristate: true | false | "until-found". // We only support boolean for now; "until-found" would need bridge union support. pub fn getHidden(self: *HtmlElement) bool { @@ -374,7 +373,6 @@ pub fn setTabIndex(self: *HtmlElement, value: i32, page: *Page) !void { try self.asElement().setAttributeSafe(comptime .wrap("tabindex"), .wrap(str), page); } - pub fn getAttributeFunction( self: *HtmlElement, listener_type: GlobalEventHandler, diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index 5e093dde..2c4aad75 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -250,9 +250,10 @@ pub fn pageNavigate(bc: anytype, event: *const Notification.PageNavigate) !void else => unreachable, }, .address_bar => null, + .initialFrameNavigation => "initialFrameNavigation", }; if (reason_) |reason| { - if (reason != .initialFrameNavigation) { + if (event.opts.reason != .initialFrameNavigation) { try cdp.sendEvent("Page.frameScheduledNavigation", .{ .frameId = frame_id, .delay = 0, @@ -346,6 +347,7 @@ pub fn pageNavigated(arena: Allocator, bc: anytype, event: *const Notification.P else => unreachable, }, .address_bar => null, + .initialFrameNavigation => "initialFrameNavigation", }; if (reason_ != null) { From 71d34592d924d26db3596e51ad98b2a530b8d5e2 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 19 Feb 2026 13:38:08 +0800 Subject: [PATCH 7/8] add frame created cdp messages --- src/Notification.zig | 8 ++++++++ src/browser/Page.zig | 13 ++++++++++--- src/cdp/cdp.zig | 11 ++++++----- src/cdp/domains/page.zig | 21 +++++++++++++++++++++ 4 files changed, 45 insertions(+), 8 deletions(-) diff --git a/src/Notification.zig b/src/Notification.zig index 91fcb673..d01492c8 100644 --- a/src/Notification.zig +++ b/src/Notification.zig @@ -73,6 +73,7 @@ const EventListeners = struct { page_navigated: List = .{}, page_network_idle: List = .{}, page_network_almost_idle: List = .{}, + page_frame_created: List = .{}, http_request_fail: List = .{}, http_request_start: List = .{}, http_request_intercept: List = .{}, @@ -89,6 +90,7 @@ const Events = union(enum) { page_navigated: *const PageNavigated, page_network_idle: *const PageNetworkIdle, page_network_almost_idle: *const PageNetworkAlmostIdle, + page_frame_created: *const PageFrameCreated, http_request_fail: *const RequestFail, http_request_start: *const RequestStart, http_request_intercept: *const RequestIntercept, @@ -129,6 +131,12 @@ pub const PageNetworkAlmostIdle = struct { timestamp: u64, }; +pub const PageFrameCreated = struct { + page_id: u32, + parent_id: u32, + timestamp: u64, +}; + pub const RequestStart = struct { transfer: *Transfer, }; diff --git a/src/browser/Page.zig b/src/browser/Page.zig index c2f94c6d..e44fbc86 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -1201,16 +1201,23 @@ pub fn iframeAddedCallback(self: *Page, iframe: *Element.Html.IFrame) !void { return; } - const session = self._session; - iframe._executed = true; + + const session = self._session; + const page_id = session.nextPageId(); const page_frame = try self.arena.create(Page); - try Page.init(page_frame, session.nextPageId(), session, self); + try Page.init(page_frame, page_id, session, self); self._pending_loads += 1; page_frame.iframe = iframe; iframe._content_window = page_frame.window; + self._session.notification.dispatch(.page_frame_created, &.{ + .page_id = page_id, + .parent_id = self.id, + .timestamp = timestamp(.monotonic), + }); + page_frame.navigate(src, .{ .reason = .initialFrameNavigation }) catch |err| { log.warn(.page, "iframe navigate failure", .{ .url = src, .err = err }); self._pending_loads -= 1; diff --git a/src/cdp/cdp.zig b/src/cdp/cdp.zig index ae83ad17..d8ccfb2b 100644 --- a/src/cdp/cdp.zig +++ b/src/cdp/cdp.zig @@ -431,6 +431,7 @@ pub fn BrowserContext(comptime CDP_T: type) type { try notification.register(.page_created, self, onPageCreated); try notification.register(.page_navigate, self, onPageNavigate); try notification.register(.page_navigated, self, onPageNavigated); + try notification.register(.page_frame_created, self, onPageFrameCreated); } pub fn deinit(self: *Self) void { @@ -587,7 +588,6 @@ pub fn BrowserContext(comptime CDP_T: type) type { pub fn onPageNavigate(ctx: *anyopaque, msg: *const Notification.PageNavigate) !void { const self: *Self = @ptrCast(@alignCast(ctx)); - defer self.resetNotificationArena(); return @import("domains/page.zig").pageNavigate(self, msg); } @@ -597,6 +597,11 @@ pub fn BrowserContext(comptime CDP_T: type) type { return @import("domains/page.zig").pageNavigated(self.notification_arena, self, msg); } + pub fn onPageFrameCreated(ctx: *anyopaque, msg: *const Notification.PageFrameCreated) !void { + const self: *Self = @ptrCast(@alignCast(ctx)); + return @import("domains/page.zig").pageFrameCreated(self, msg); + } + pub fn onPageNetworkIdle(ctx: *anyopaque, msg: *const Notification.PageNetworkIdle) !void { const self: *Self = @ptrCast(@alignCast(ctx)); return @import("domains/page.zig").pageNetworkIdle(self, msg); @@ -609,19 +614,16 @@ pub fn BrowserContext(comptime CDP_T: type) type { pub fn onHttpRequestStart(ctx: *anyopaque, msg: *const Notification.RequestStart) !void { const self: *Self = @ptrCast(@alignCast(ctx)); - defer self.resetNotificationArena(); try @import("domains/network.zig").httpRequestStart(self, msg); } pub fn onHttpRequestIntercept(ctx: *anyopaque, msg: *const Notification.RequestIntercept) !void { const self: *Self = @ptrCast(@alignCast(ctx)); - defer self.resetNotificationArena(); try @import("domains/fetch.zig").requestIntercept(self, msg); } pub fn onHttpRequestFail(ctx: *anyopaque, msg: *const Notification.RequestFail) !void { const self: *Self = @ptrCast(@alignCast(ctx)); - defer self.resetNotificationArena(); return @import("domains/network.zig").httpRequestFail(self, msg); } @@ -633,7 +635,6 @@ pub fn BrowserContext(comptime CDP_T: type) type { pub fn onHttpRequestDone(ctx: *anyopaque, msg: *const Notification.RequestDone) !void { const self: *Self = @ptrCast(@alignCast(ctx)); - defer self.resetNotificationArena(); return @import("domains/network.zig").httpRequestDone(self, msg); } diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index 2c4aad75..5639cfde 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -302,6 +302,27 @@ pub fn pageCreated(bc: anytype, page: *Page) !void { bc.captured_responses = .empty; } +pub fn pageFrameCreated(bc: anytype, event: *const Notification.PageFrameCreated) !void { + const session_id = bc.session_id orelse return; + + const cdp = bc.cdp; + const frame_id = &id.toFrameId(event.page_id); + + try cdp.sendEvent("Page.frameAttached", .{ .params = .{ + .frameId = frame_id, + .parentFrameId = &id.toFrameId(event.parent_id), + } }, .{ .session_id = session_id }); + + if (bc.page_life_cycle_events) { + try cdp.sendEvent("Page.lifecycleEvent", LifecycleEvent{ + .name = "init", + .frameId = frame_id, + .loaderId = &id.toLoaderId(event.page_id), + .timestamp = event.timestamp, + }, .{ .session_id = session_id }); + } +} + pub fn pageNavigated(arena: Allocator, bc: anytype, event: *const Notification.PageNavigated) !void { // detachTarget could be called, in which case, we still have a page doing // things, but no session. From 924eb33b3f2086f31a47f015487ee0221c0b35d7 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sat, 21 Feb 2026 07:02:21 +0800 Subject: [PATCH 8/8] Update src/browser/js/Env.zig Co-authored-by: Pierre Tachoire --- src/browser/js/Env.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig index c51c055e..458ff099 100644 --- a/src/browser/js/Env.zig +++ b/src/browser/js/Env.zig @@ -175,7 +175,7 @@ pub fn init(app: *App, opts: InitOpts) !Env { .data = null, .flags = v8.kOnlyInterceptStrings | v8.kNonMasking, }); - // I don' 100% understand this. We actually set this up in the snapshot, + // I don't 100% understand this. We actually set this up in the snapshot, // but for the global instance, it doesn't work. SetIndexedHandler and // SetNamedHandler are set on the Instance template, and that's the key // difference. The context has its own global instance, so we need to set