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/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/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", .{ 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; + }); - -