From 852c30b2e5497fe60ed99dba58f5ca3a1f1e12ac Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 18 Sep 2025 19:07:18 +0800 Subject: [PATCH 1/2] Rework IntersectionObserver 1 - Always fire the callback on the next tick. This is probably the most important change, as frameworks like React don't react well if the callback is fired immediately (they expect to continue processing the page in its current state, not in the mutated state from the callback) 2 - Always fire the callback for observed elements with a parent, whether or not those intersect or are connected. From MDN, the callback is fired "The first time the observer is initially asked to watch a target element." 3 - Add a mutation observer so that if a node is added to the root (or removed) the callback is fired. This, I think, is the best we can currently do for "intersection". --- src/browser/dom/IntersectionObserver.zig | 329 ++++++++++++++++++++++ src/browser/dom/dom.zig | 3 +- src/browser/dom/intersection_observer.zig | 186 ------------ src/tests/dom/intersection_observer.html | 209 +++++++++----- 4 files changed, 468 insertions(+), 259 deletions(-) create mode 100644 src/browser/dom/IntersectionObserver.zig delete mode 100644 src/browser/dom/intersection_observer.zig diff --git a/src/browser/dom/IntersectionObserver.zig b/src/browser/dom/IntersectionObserver.zig new file mode 100644 index 00000000..e5f50178 --- /dev/null +++ b/src/browser/dom/IntersectionObserver.zig @@ -0,0 +1,329 @@ +// Copyright (C) 2023-2025 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 std = @import("std"); + +const js = @import("../js/js.zig"); +const log = @import("../../log.zig"); +const parser = @import("../netsurf.zig"); +const Page = @import("../page.zig").Page; +const Node = @import("node.zig").Node; +const Element = @import("element.zig").Element; + +pub const Interfaces = .{ + IntersectionObserver, + Entry, +}; + +// This implementation attempts to be as less wrong as possible. Since we don't +// render, or know how things are positioned, our best guess isn't very good. +const IntersectionObserver = @This(); +page: *Page, +root: *parser.Node, +callback: js.Function, +event_node: parser.EventNode, +observed_entries: std.ArrayList(Entry), +pending_elements: std.ArrayList(*parser.Element), +ready_elements: std.ArrayList(*parser.Element), + +pub fn constructor(callback: js.Function, opts_: ?IntersectionObserverOptions, page: *Page) !*IntersectionObserver { + const opts = opts_ orelse IntersectionObserverOptions{}; + + const self = try page.arena.create(IntersectionObserver); + self.* = .{ + .page = page, + .callback = callback, + .ready_elements = .{}, + .observed_entries = .{}, + .pending_elements = .{}, + .event_node = .{ .func = mutationCallback }, + .root = opts.root orelse parser.documentToNode(parser.documentHTMLToDocument(page.window.document)), + }; + + _ = try parser.eventTargetAddEventListener( + parser.toEventTarget(parser.Node, self.root), + "DOMNodeInserted", + &self.event_node, + false, + ); + + _ = try parser.eventTargetAddEventListener( + parser.toEventTarget(parser.Node, self.root), + "DOMNodeRemoved", + &self.event_node, + false, + ); + + return self; +} + +pub fn _disconnect(self: *IntersectionObserver) !void { + // We don't free as it is on an arena + self.ready_elements = .{}; + self.observed_entries = .{}; + self.pending_elements = .{}; +} + +pub fn _observe(self: *IntersectionObserver, target_element: *parser.Element, page: *Page) !void { + for (self.observed_entries.items) |*observer| { + if (observer.target == target_element) { + return; // Already observed + } + } + + if (self.isPending(target_element)) { + return; // Already pending + } + + for (self.ready_elements.items) |element| { + if (element == target_element) { + return; // Already primed + } + } + + // We can never fire callbacks synchronously. Code like React expects any + // callback to fire in the future (e.g. via microtasks). + try self.ready_elements.append(self.page.arena, target_element); + if (self.ready_elements.items.len == 1) { + // this is our first ready entry, schedule a callback + try page.scheduler.add(self, processReady, 0, .{ + .name = "intersection ready", + }); + } +} + +pub fn _unobserve(self: *IntersectionObserver, target: *parser.Element) !void { + if (self.removeObserved(target)) { + return; + } + + for (self.ready_elements.items, 0..) |el, index| { + if (el == target) { + _ = self.ready_elements.swapRemove(index); + return; + } + } + + for (self.pending_elements.items, 0..) |el, index| { + if (el == target) { + _ = self.pending_elements.swapRemove(index); + return; + } + } +} + +pub fn _takeRecords(self: *IntersectionObserver) []Entry { + return self.observed_entries.items; +} + +fn processReady(ctx: *anyopaque) ?u32 { + const self: *IntersectionObserver = @ptrCast(@alignCast(ctx)); + self._processReady() catch |err| { + log.err(.web_api, "intersection ready", .{ .err = err }); + }; + return null; +} + +fn _processReady(self: *IntersectionObserver) !void { + defer self.ready_elements.clearRetainingCapacity(); + for (self.ready_elements.items) |element| { + // IntersectionObserver probably doesn't work like what your intuition + // thinks. As long as a node has a parent, even if that parent isn't + // connected and even if the two nodes don't intersect, it'll fire the + // callback once. + if (try Node.get_parentNode(@ptrCast(element)) == null) { + if (!self.isPending(element)) { + try self.pending_elements.append(self.page.arena, element); + } + continue; + } + try self.forceObserve(element); + } +} + +fn isPending(self: *IntersectionObserver, element: *parser.Element) bool { + for (self.pending_elements.items) |el| { + if (el == element) { + return true; + } + } + return false; +} + +fn mutationCallback(en: *parser.EventNode, event: *parser.Event) void { + const mutation_event = parser.eventToMutationEvent(event); + const self: *IntersectionObserver = @fieldParentPtr("event_node", en); + self._mutationCallback(mutation_event) catch |err| { + log.err(.web_api, "mutation callback", .{ .err = err, .source = "intersection observer" }); + }; +} + +fn _mutationCallback(self: *IntersectionObserver, event: *parser.MutationEvent) !void { + const event_type = parser.eventType(@ptrCast(event)); + + if (std.mem.eql(u8, event_type, "DOMNodeInserted")) { + const node = parser.mutationEventRelatedNode(event) catch return orelse return; + if (parser.nodeType(node) != .element) { + return; + } + const el: *parser.Element = @ptrCast(node); + if (self.removePending(el)) { + // It was pending (because it wasn't in the root), but now it is + // we should observe it. + try self.forceObserve(el); + } + return; + } + + if (std.mem.eql(u8, event_type, "DOMNodeRemoved")) { + const node = parser.mutationEventRelatedNode(event) catch return orelse return; + if (parser.nodeType(node) != .element) { + return; + } + + const el: *parser.Element = @ptrCast(node); + if (self.removeObserved(el)) { + // It _was_ observed, it no longer is in our root, but if it was + // to get re-added, it should be observed again (I think), so + // we add it to our pending list + try self.pending_elements.append(self.page.arena, el); + } + + return; + } + + // impossible event type + unreachable; +} + +// Exists to skip the checks made _observe when called from a DOMNodeInserted +// event. In such events, the event handler has alread done the necessary +// checks. +fn forceObserve(self: *IntersectionObserver, target: *parser.Element) !void { + try self.observed_entries.append(self.page.arena, .{ + .page = self.page, + .root = self.root, + .target = target, + }); + + var result: js.Function.Result = undefined; + self.callback.tryCall(void, .{self.observed_entries.items}, &result) catch { + log.debug(.user_script, "callback error", .{ + .err = result.exception, + .stack = result.stack, + .source = "intersection observer", + }); + }; +} + +fn removeObserved(self: *IntersectionObserver, target: *parser.Element) bool { + for (self.observed_entries.items, 0..) |*observer, index| { + if (observer.target == target) { + _ = self.observed_entries.swapRemove(index); + return true; + } + } + return false; +} + +fn removePending(self: *IntersectionObserver, target: *parser.Element) bool { + for (self.pending_elements.items, 0..) |el, index| { + if (el == target) { + _ = self.pending_elements.swapRemove(index); + return true; + } + } + return false; +} + +const IntersectionObserverOptions = struct { + root: ?*parser.Node = null, // Element or Document + rootMargin: ?[]const u8 = "0px 0px 0px 0px", + threshold: ?Threshold = .{ .single = 0.0 }, + + const Threshold = union(enum) { + single: f32, + list: []const f32, + }; +}; + +// https://developer.mozilla.org/en-US/docs/Web/API/Entry +// https://w3c.github.io/IntersectionObserver/#intersection-observer-entry +pub const Entry = struct { + page: *Page, + root: *parser.Node, + target: *parser.Element, + + // Returns the bounds rectangle of the target element as a DOMRectReadOnly. The bounds are computed as described in the documentation for Element.getBoundingClientRect(). + pub fn get_boundingClientRect(self: *const Entry) !Element.DOMRect { + return Element._getBoundingClientRect(self.target, self.page); + } + + // Returns the ratio of the intersectionRect to the boundingClientRect. + pub fn get_intersectionRatio(_: *const Entry) f32 { + return 1.0; + } + + // Returns a DOMRectReadOnly representing the target's visible area. + pub fn get_intersectionRect(self: *const Entry) !Element.DOMRect { + return Element._getBoundingClientRect(self.target, self.page); + } + + // A Boolean value which is true if the target element intersects with the + // intersection observer's root. If this is true, then, the + // Entry describes a transition into a state of + // intersection; if it's false, then you know the transition is from + // intersecting to not-intersecting. + pub fn get_isIntersecting(_: *const Entry) bool { + return true; + } + + // Returns a DOMRectReadOnly for the intersection observer's root. + pub fn get_rootBounds(self: *const Entry) !Element.DOMRect { + const root = self.root; + if (@intFromPtr(root) == @intFromPtr(self.page.window.document)) { + return self.page.renderer.boundingRect(); + } + + const root_type = parser.nodeType(root); + + var element: *parser.Element = undefined; + switch (root_type) { + .element => element = parser.nodeToElement(root), + .document => { + const doc = parser.nodeToDocument(root); + element = (try parser.documentGetDocumentElement(doc)).?; + }, + else => return error.InvalidState, + } + + return Element._getBoundingClientRect(element, self.page); + } + + // The Element whose intersection with the root changed. + pub fn get_target(self: *const Entry) *parser.Element { + return self.target; + } + + // TODO: pub fn get_time(self: *const Entry) +}; + +const testing = @import("../../testing.zig"); +test "Browser: DOM.IntersectionObserver" { + try testing.htmlRunner("dom/intersection_observer.html"); +} diff --git a/src/browser/dom/dom.zig b/src/browser/dom/dom.zig index c73ac47f..7e90e273 100644 --- a/src/browser/dom/dom.zig +++ b/src/browser/dom/dom.zig @@ -25,7 +25,6 @@ const NodeList = @import("nodelist.zig"); const Node = @import("node.zig"); const ResizeObserver = @import("resize_observer.zig"); const MutationObserver = @import("mutation_observer.zig"); -const IntersectionObserver = @import("intersection_observer.zig"); const DOMParser = @import("dom_parser.zig").DOMParser; const TreeWalker = @import("tree_walker.zig").TreeWalker; const NodeIterator = @import("node_iterator.zig").NodeIterator; @@ -44,7 +43,6 @@ pub const Interfaces = .{ Node.Interfaces, ResizeObserver.Interfaces, MutationObserver.Interfaces, - IntersectionObserver.Interfaces, DOMParser, TreeWalker, NodeIterator, @@ -54,4 +52,5 @@ pub const Interfaces = .{ @import("range.zig").Interfaces, @import("Animation.zig"), @import("MessageChannel.zig").Interfaces, + @import("IntersectionObserver.zig").Interfaces, }; diff --git a/src/browser/dom/intersection_observer.zig b/src/browser/dom/intersection_observer.zig deleted file mode 100644 index 8dccd29f..00000000 --- a/src/browser/dom/intersection_observer.zig +++ /dev/null @@ -1,186 +0,0 @@ -// Copyright (C) 2023-2025 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 std = @import("std"); - -const js = @import("../js/js.zig"); -const log = @import("../../log.zig"); -const parser = @import("../netsurf.zig"); -const Page = @import("../page.zig").Page; - -const Element = @import("element.zig").Element; - -pub const Interfaces = .{ - IntersectionObserver, - IntersectionObserverEntry, -}; - -// This is supposed to listen to change between the root and observation targets. -// However, our rendered stores everything as 1 pixel sized boxes in a long row that never changes. -// As such, there are no changes to intersections between the root and any target. -// Instead we keep a list of all entries that are being observed. -// The callback is called with all entries everytime a new entry is added(observed). -// Potentially we should also call the callback at a regular interval. -// The returned Entries are phony, they always indicate full intersection. -// https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver -pub const IntersectionObserver = struct { - page: *Page, - callback: js.Function, - options: IntersectionObserverOptions, - - observed_entries: std.ArrayListUnmanaged(IntersectionObserverEntry), - - // new IntersectionObserver(callback) - // new IntersectionObserver(callback, options) [not supported yet] - pub fn constructor(callback: js.Function, options_: ?IntersectionObserverOptions, page: *Page) !IntersectionObserver { - var options = IntersectionObserverOptions{ - .root = parser.documentToNode(parser.documentHTMLToDocument(page.window.document)), - .rootMargin = "0px 0px 0px 0px", - .threshold = .{ .single = 0.0 }, - }; - if (options_) |*o| { - if (o.root) |root| { - options.root = root; - } // Other properties are not used due to the way we render - } - - return .{ - .page = page, - .callback = callback, - .options = options, - .observed_entries = .{}, - }; - } - - pub fn _disconnect(self: *IntersectionObserver) !void { - self.observed_entries = .{}; // We don't free as it is on an arena - } - - pub fn _observe(self: *IntersectionObserver, target_element: *parser.Element) !void { - for (self.observed_entries.items) |*observer| { - if (observer.target == target_element) { - return; // Already observed - } - } - - try self.observed_entries.append(self.page.arena, .{ - .page = self.page, - .target = target_element, - .options = &self.options, - }); - - var result: js.Function.Result = undefined; - self.callback.tryCall(void, .{self.observed_entries.items}, &result) catch { - log.debug(.user_script, "callback error", .{ - .err = result.exception, - .stack = result.stack, - .source = "intersection observer", - }); - }; - } - - pub fn _unobserve(self: *IntersectionObserver, target: *parser.Element) !void { - for (self.observed_entries.items, 0..) |*observer, index| { - if (observer.target == target) { - _ = self.observed_entries.swapRemove(index); - break; - } - } - } - - pub fn _takeRecords(self: *IntersectionObserver) []IntersectionObserverEntry { - return self.observed_entries.items; - } -}; - -const IntersectionObserverOptions = struct { - root: ?*parser.Node, // Element or Document - rootMargin: ?[]const u8, - threshold: ?Threshold, - - const Threshold = union(enum) { - single: f32, - list: []const f32, - }; -}; - -// https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry -// https://w3c.github.io/IntersectionObserver/#intersection-observer-entry -pub const IntersectionObserverEntry = struct { - page: *Page, - target: *parser.Element, - options: *IntersectionObserverOptions, - - // Returns the bounds rectangle of the target element as a DOMRectReadOnly. The bounds are computed as described in the documentation for Element.getBoundingClientRect(). - pub fn get_boundingClientRect(self: *const IntersectionObserverEntry) !Element.DOMRect { - return Element._getBoundingClientRect(self.target, self.page); - } - - // Returns the ratio of the intersectionRect to the boundingClientRect. - pub fn get_intersectionRatio(_: *const IntersectionObserverEntry) f32 { - return 1.0; - } - - // Returns a DOMRectReadOnly representing the target's visible area. - pub fn get_intersectionRect(self: *const IntersectionObserverEntry) !Element.DOMRect { - return Element._getBoundingClientRect(self.target, self.page); - } - - // A Boolean value which is true if the target element intersects with the - // intersection observer's root. If this is true, then, the - // IntersectionObserverEntry describes a transition into a state of - // intersection; if it's false, then you know the transition is from - // intersecting to not-intersecting. - pub fn get_isIntersecting(_: *const IntersectionObserverEntry) bool { - return true; - } - - // Returns a DOMRectReadOnly for the intersection observer's root. - pub fn get_rootBounds(self: *const IntersectionObserverEntry) !Element.DOMRect { - const root = self.options.root.?; - if (@intFromPtr(root) == @intFromPtr(self.page.window.document)) { - return self.page.renderer.boundingRect(); - } - - const root_type = parser.nodeType(root); - - var element: *parser.Element = undefined; - switch (root_type) { - .element => element = parser.nodeToElement(root), - .document => { - const doc = parser.nodeToDocument(root); - element = (try parser.documentGetDocumentElement(doc)).?; - }, - else => return error.InvalidState, - } - - return Element._getBoundingClientRect(element, self.page); - } - - // The Element whose intersection with the root changed. - pub fn get_target(self: *const IntersectionObserverEntry) *parser.Element { - return self.target; - } - - // TODO: pub fn get_time(self: *const IntersectionObserverEntry) -}; - -const testing = @import("../../testing.zig"); -test "Browser: DOM.IntersectionObserver" { - try testing.htmlRunner("dom/intersection_observer.html"); -} diff --git a/src/tests/dom/intersection_observer.html b/src/tests/dom/intersection_observer.html index 408d23c6..6117bcd7 100644 --- a/src/tests/dom/intersection_observer.html +++ b/src/tests/dom/intersection_observer.html @@ -1,96 +1,163 @@ + + { + let count = 0; + let observer = new IntersectionObserver(entries => { + count += entries.length; + }); - - From 75e0637d2d4b3a49a054afe2cabdfc96552967aa Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 9 Oct 2025 16:29:09 +0800 Subject: [PATCH 2/2] Ensure page background tasks are re-registered on reset --- src/browser/Scheduler.zig | 6 ++++-- src/browser/js/Function.zig | 7 ++----- src/browser/page.zig | 42 +++++++++++++++++++++++-------------- 3 files changed, 32 insertions(+), 23 deletions(-) diff --git a/src/browser/Scheduler.zig b/src/browser/Scheduler.zig index 5362c5bf..ecafbb42 100644 --- a/src/browser/Scheduler.zig +++ b/src/browser/Scheduler.zig @@ -37,8 +37,10 @@ pub fn init(allocator: Allocator) Scheduler { } pub fn reset(self: *Scheduler) void { - self.high_priority.clearRetainingCapacity(); - self.low_priority.clearRetainingCapacity(); + // Our allocator is the page arena, it's been reset. We cannot use + // clearAndRetainCapacity, since that space is no longer ours + self.high_priority.clearAndFree(); + self.low_priority.clearAndFree(); } const AddOpts = struct { diff --git a/src/browser/js/Function.zig b/src/browser/js/Function.zig index bcb35a80..538a814d 100644 --- a/src/browser/js/Function.zig +++ b/src/browser/js/Function.zig @@ -138,10 +138,7 @@ fn getThis(self: *const Function) v8.Object { return self.this orelse self.context.v8_context.getGlobal(); } -// debug/helper to print the source of the JS callback -pub fn printFunc(self: Function) !void { - const context = self.context; +pub fn src(self: *const Function) ![]const u8 { const value = self.func.castToFunction().toValue(); - const src = try js.valueToString(context.call_arena, value, context.isolate, context.v8_context); - std.debug.print("{s}\n", .{src}); + return self.context.valueToString(value, .{}); } diff --git a/src/browser/page.zig b/src/browser/page.zig index 6cdc4f96..721e4c26 100644 --- a/src/browser/page.zig +++ b/src/browser/page.zig @@ -24,6 +24,7 @@ const Allocator = std.mem.Allocator; const Dump = @import("dump.zig"); const State = @import("State.zig"); const Mime = @import("mime.zig").Mime; +const Browser = @import("browser.zig").Browser; const Session = @import("session.zig").Session; const Renderer = @import("renderer.zig").Renderer; const Window = @import("html/window.zig").Window; @@ -146,11 +147,7 @@ pub const Page = struct { self.js = try session.executor.createContext(self, true, js.GlobalMissingCallback.init(&self.polyfill_loader)); try polyfill.preload(self.arena, self.js); - try self.scheduler.add(self, runMicrotasks, 5, .{ .name = "page.microtasks" }); - // message loop must run only non-test env - if (comptime !builtin.is_test) { - try self.scheduler.add(self, runMessageLoop, 5, .{ .name = "page.messageLoop" }); - } + try self.registerBackgroundTasks(); } pub fn deinit(self: *Page) void { @@ -160,7 +157,7 @@ pub const Page = struct { self.script_manager.deinit(); } - fn reset(self: *Page) void { + fn reset(self: *Page) !void { // Force running the micro task to drain the queue. self.session.browser.env.runMicrotasks(); @@ -171,18 +168,31 @@ pub const Page = struct { self.load_state = .parsing; self.mode = .{ .pre = {} }; _ = self.session.browser.page_arena.reset(.{ .retain_with_limit = 1 * 1024 * 1024 }); + + try self.registerBackgroundTasks(); } - fn runMicrotasks(ctx: *anyopaque) ?u32 { - const self: *Page = @ptrCast(@alignCast(ctx)); - self.session.browser.runMicrotasks(); - return 5; - } + fn registerBackgroundTasks(self: *Page) !void { + if (comptime builtin.is_test) { + // HTML test runner manually calls these as necessary + return; + } - fn runMessageLoop(ctx: *anyopaque) ?u32 { - const self: *Page = @ptrCast(@alignCast(ctx)); - self.session.browser.runMessageLoop(); - return 100; + try self.scheduler.add(self.session.browser, struct { + fn runMicrotasks(ctx: *anyopaque) ?u32 { + const b: *Browser = @ptrCast(@alignCast(ctx)); + b.runMicrotasks(); + return 5; + } + }.runMicrotasks, 5, .{ .name = "page.microtasks" }); + + try self.scheduler.add(self.session.browser, struct { + fn runMessageLoop(ctx: *anyopaque) ?u32 { + const b: *Browser = @ptrCast(@alignCast(ctx)); + b.runMessageLoop(); + return 100; + } + }.runMessageLoop, 5, .{ .name = "page.messageLoop" }); } pub const DumpOpts = struct { @@ -529,7 +539,7 @@ pub const Page = struct { if (self.mode != .pre) { // it's possible for navigate to be called multiple times on the // same page (via CDP). We want to reset the page between each call. - self.reset(); + try self.reset(); } log.info(.http, "navigate", .{