diff --git a/build.zig.zon b/build.zig.zon
index 9d57095f..036d9ba5 100644
--- a/build.zig.zon
+++ b/build.zig.zon
@@ -5,9 +5,9 @@
.fingerprint = 0xda130f3af836cea0,
.dependencies = .{
.v8 = .{
- .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/305bb3706716d32d59b2ffa674731556caa1002b.tar.gz",
- .hash = "v8-0.0.0-xddH63bVAwBSEobaUok9J0er1FqsvEujCDDVy6ItqKQ5",
+ .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/3aa2b39cb1ab588b85970beef5b374effccf1415.tar.gz",
+ .hash = "v8-0.0.0-xddH66TeAwDDEs3QkHFlukxqqrRXITzzmmIn2NHISHCn",
},
- //.v8 = .{ .path = "../zig-v8-fork" }
+ // .v8 = .{ .path = "../zig-v8-fork" }
},
}
diff --git a/src/browser/Page.zig b/src/browser/Page.zig
index 6555a9d6..ff22f2b6 100644
--- a/src/browser/Page.zig
+++ b/src/browser/Page.zig
@@ -37,6 +37,7 @@ const Scheduler = @import("Scheduler.zig");
const History = @import("webapi/History.zig");
const EventManager = @import("EventManager.zig");
const ScriptManager = @import("ScriptManager.zig");
+
const polyfill = @import("polyfill/polyfill.zig");
const Parser = @import("parser/Parser.zig");
@@ -50,6 +51,8 @@ const Window = @import("webapi/Window.zig");
const Location = @import("webapi/Location.zig");
const Document = @import("webapi/Document.zig");
const HtmlScript = @import("webapi/Element.zig").Html.Script;
+const MutationObserver = @import("webapi/MutationObserver.zig");
+const IntersectionObserver = @import("webapi/IntersectionObserver.zig");
const storage = @import("webapi/storage/storage.zig");
const timestamp = @import("../datetime.zig").timestamp;
@@ -89,6 +92,14 @@ _element_class_lists: Element.ClassListLookup = .{},
_script_manager: ScriptManager,
+// List of active MutationObservers
+_mutation_observers: std.ArrayList(*MutationObserver) = .{},
+_mutation_delivery_scheduled: bool = false,
+
+// List of active IntersectionObservers
+_intersection_observers: std.ArrayList(*IntersectionObserver) = .{},
+_intersection_delivery_scheduled: bool = false,
+
_polyfill_loader: polyfill.Loader = .{},
// for heap allocations and managing WebAPI objects
@@ -190,6 +201,11 @@ fn reset(self: *Page, comptime initializing: bool) !void {
self._notified_network_idle = .init;
self._notified_network_almost_idle = .init;
+ self._mutation_observers = .{};
+ self._mutation_delivery_scheduled = false;
+ self._intersection_observers = .{};
+ self._intersection_delivery_scheduled = false;
+
try polyfill.preload(self.arena, self.js);
try self.registerBackgroundTasks();
}
@@ -677,6 +693,94 @@ pub fn domChanged(self: *Page) void {
self.version += 1;
}
+pub fn registerMutationObserver(self: *Page, observer: *MutationObserver) !void {
+ try self._mutation_observers.append(self.arena, observer);
+}
+
+pub fn unregisterMutationObserver(self: *Page, observer: *MutationObserver) void {
+ for (self._mutation_observers.items, 0..) |obs, i| {
+ if (obs == observer) {
+ _ = self._mutation_observers.swapRemove(i);
+ return;
+ }
+ }
+}
+
+pub fn registerIntersectionObserver(self: *Page, observer: *IntersectionObserver) !void {
+ try self._intersection_observers.append(self.arena, observer);
+}
+
+pub fn unregisterIntersectionObserver(self: *Page, observer: *IntersectionObserver) void {
+ for (self._intersection_observers.items, 0..) |obs, i| {
+ if (obs == observer) {
+ _ = self._intersection_observers.swapRemove(i);
+ return;
+ }
+ }
+}
+
+pub fn checkIntersections(self: *Page) !void {
+ for (self._intersection_observers.items) |observer| {
+ try observer.checkIntersections(self);
+ }
+}
+
+pub fn scheduleMutationDelivery(self: *Page) !void {
+ // Only queue if not already scheduled
+ if (self._mutation_delivery_scheduled) {
+ return;
+ }
+ self._mutation_delivery_scheduled = true;
+
+ // Queue mutation delivery as a microtask
+ try self.js.queueMutationDelivery();
+}
+
+pub fn scheduleIntersectionDelivery(self: *Page) !void {
+ // Only queue if not already scheduled
+ if (self._intersection_delivery_scheduled) {
+ return;
+ }
+ self._intersection_delivery_scheduled = true;
+
+ // Queue intersection delivery as a microtask
+ try self.js.queueIntersectionDelivery();
+}
+
+pub fn deliverIntersections(self: *Page) void {
+ if (!self._intersection_delivery_scheduled) {
+ return;
+ }
+ self._intersection_delivery_scheduled = false;
+
+ // Iterate backwards to handle observers that disconnect during their callback
+ var i = self._intersection_observers.items.len;
+ while (i > 0) {
+ i -= 1;
+ const observer = self._intersection_observers.items[i];
+ observer.deliverEntries(self) catch |err| {
+ log.err(.page, "page.deliverIntersections", .{ .err = err });
+ };
+ }
+}
+
+pub fn deliverMutations(self: *Page) void {
+ if (!self._mutation_delivery_scheduled) {
+ return;
+ }
+ self._mutation_delivery_scheduled = false;
+
+ // Iterate backwards to handle observers that disconnect during their callback
+ var i = self._mutation_observers.items.len;
+ while (i > 0) {
+ i -= 1;
+ const observer = self._mutation_observers.items[i];
+ observer.deliverRecords(self) catch |err| {
+ log.err(.page, "page.deliverMutations", .{ .err = err });
+ };
+ }
+}
+
fn notifyNetworkIdle(self: *Page) void {
std.debug.assert(self._notified_network_idle == .done);
self._session.browser.notification.dispatch(.page_network_idle, &.{
@@ -1106,6 +1210,10 @@ const RemoveNodeOpts = struct {
will_be_reconnected: bool,
};
pub fn removeNode(self: *Page, parent: *Node, child: *Node, opts: RemoveNodeOpts) void {
+ // Capture siblings before removing
+ const previous_sibling = child.previousSibling();
+ const next_sibling = child.nextSibling();
+
const children = parent._children.?;
switch (children.*) {
.one => |n| {
@@ -1132,6 +1240,11 @@ pub fn removeNode(self: *Page, parent: *Node, child: *Node, opts: RemoveNodeOpts
child._parent = null;
child._child_link = .{};
+ if (self.hasMutationObservers()) {
+ const removed = [_]*Node{child};
+ self.childListChange(parent, &.{}, &removed, previous_sibling, next_sibling);
+ }
+
if (opts.will_be_reconnected) {
// We might be removing the node only to re-insert it. If the node will
// remain connected, we can skip the expensive process of fully
@@ -1232,10 +1345,30 @@ pub fn _insertNodeRelative(self: *Page, comptime from_parser: bool, parent: *Nod
}
child._parent = parent;
- if (comptime from_parser == false) {
- // When the parser adds the node, nodeIsReady is only called when the
- // nodeComplete() callback is executed.
- try self.nodeIsReady(false, child);
+ // Tri-state behavior for mutations:
+ // 1. from_parser=true, parse_mode=document -> no mutations (initial document parse)
+ // 2. from_parser=true, parse_mode=fragment -> mutations (innerHTML additions)
+ // 3. from_parser=false, parse_mode=document -> mutation (js manipulation)
+ // split like this because from_parser can be comptime known.
+ const should_notify = if (comptime from_parser)
+ self._parse_mode == .fragment
+ else
+ true;
+
+ if (should_notify) {
+ if (comptime from_parser == false) {
+ // When the parser adds the node, nodeIsReady is only called when the
+ // nodeComplete() callback is executed.
+ try self.nodeIsReady(false, child);
+ }
+
+ // Notify mutation observers about childList change
+ if (self.hasMutationObservers()) {
+ const previous_sibling = child.previousSibling();
+ const next_sibling = child.nextSibling();
+ const added = [_]*Node{child};
+ self.childListChange(parent, &added, &.{}, previous_sibling, next_sibling);
+ }
}
var document_by_id = &self.document._elements_by_id;
@@ -1276,16 +1409,71 @@ pub fn _insertNodeRelative(self: *Page, comptime from_parser: bool, parent: *Nod
}
}
-pub fn attributeChange(self: *Page, element: *Element, name: []const u8, value: []const u8) void {
+pub fn attributeChange(self: *Page, element: *Element, name: []const u8, value: []const u8, old_value: ?[]const u8) void {
_ = Element.Build.call(element, "attributeChange", .{ element, name, value, self }) catch |err| {
log.err(.bug, "build.attributeChange", .{ .tag = element.getTag(), .name = name, .value = value, .err = err });
};
+
+ for (self._mutation_observers.items) |observer| {
+ observer.notifyAttributeChange(element, name, old_value, self) catch |err| {
+ log.err(.page, "attributeChange.notifyObserver", .{ .err = err });
+ };
+ }
}
-pub fn attributeRemove(self: *Page, element: *Element, name: []const u8) void {
+pub fn attributeRemove(self: *Page, element: *Element, name: []const u8, old_value: []const u8) void {
_ = Element.Build.call(element, "attributeRemove", .{ element, name, self }) catch |err| {
log.err(.bug, "build.attributeRemove", .{ .tag = element.getTag(), .name = name, .err = err });
};
+
+ for (self._mutation_observers.items) |observer| {
+ observer.notifyAttributeChange(element, name, old_value, self) catch |err| {
+ log.err(.page, "attributeRemove.notifyObserver", .{ .err = err });
+ };
+ }
+}
+
+pub fn hasMutationObservers(self: *const Page) bool {
+ return self._mutation_observers.items.len > 0;
+}
+
+pub fn characterDataChange(
+ self: *Page,
+ target: *Node,
+ old_value: []const u8,
+) void {
+ // Notify mutation observers
+ for (self._mutation_observers.items) |observer| {
+ observer.notifyCharacterDataChange(target, old_value, self) catch |err| {
+ log.err(.page, "cdataChange.notifyObserver", .{ .err = err });
+ };
+ }
+}
+
+pub fn childListChange(
+ self: *Page,
+ target: *Node,
+ added_nodes: []const *Node,
+ removed_nodes: []const *Node,
+ previous_sibling: ?*Node,
+ next_sibling: ?*Node,
+) void {
+ // Filter out HTML wrapper element during fragment parsing (html5ever quirk)
+ if (self._parse_mode == .fragment and added_nodes.len == 1) {
+ if (added_nodes[0].is(Element.Html.Html) != null) {
+ // This is the temporary HTML wrapper, added by html5ever
+ // that will be unwrapped, see:
+ // https://github.com/servo/html5ever/issues/583
+ return;
+ }
+ }
+
+ // Notify mutation observers
+ for (self._mutation_observers.items) |observer| {
+ observer.notifyChildListChange(target, added_nodes, removed_nodes, previous_sibling, next_sibling, self) catch |err| {
+ log.err(.page, "childListChange.notifyObserver", .{ .err = err });
+ };
+ }
}
// TODO: optimize and cleanup, this is called a lot (e.g., innerHTML = '')
@@ -1302,9 +1490,22 @@ pub fn parseHtmlAsChildren(self: *Page, node: *Node, html: []const u8) !void {
const first = children.one;
std.debug.assert(first.is(Element.Html.Html) != null);
node._children = first._children;
- var it = node.childrenIterator();
- while (it.next()) |child| {
- child._parent = node;
+
+ if (self.hasMutationObservers()) {
+ var it = node.childrenIterator();
+ while (it.next()) |child| {
+ child._parent = node;
+ // Notify mutation observers for each unwrapped child
+ const previous_sibling = child.previousSibling();
+ const next_sibling = child.nextSibling();
+ const added = [_]*Node{child};
+ self.childListChange(node, &added, &.{}, previous_sibling, next_sibling);
+ }
+ } else {
+ var it = node.childrenIterator();
+ while (it.next()) |child| {
+ child._parent = node;
+ }
}
}
diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig
index 087a4204..4c7ff66b 100644
--- a/src/browser/js/Context.zig
+++ b/src/browser/js/Context.zig
@@ -1907,6 +1907,26 @@ fn zigJsonToJs(isolate: v8.Isolate, v8_context: v8.Context, value: std.json.Valu
}
}
+// Microtasks
+pub fn queueMutationDelivery(self: *Context) !void {
+ self.isolate.enqueueMicrotask(struct {
+ fn run(data: ?*anyopaque) callconv(.c) void {
+ const page: *Page = @ptrCast(@alignCast(data.?));
+ page.deliverMutations();
+ }
+ }.run, self.page);
+}
+
+pub fn queueIntersectionDelivery(self: *Context) !void {
+ self.isolate.enqueueMicrotask(struct {
+ fn run(data: ?*anyopaque) callconv(.c) void {
+ const page: *Page = @ptrCast(@alignCast(data.?));
+ page.deliverIntersections();
+ }
+ }.run, self.page);
+}
+
+
// == Misc ==
// An interface for types that want to have their jsDeinit function to be
// called when the call context ends
diff --git a/src/browser/js/Inspector.zig b/src/browser/js/Inspector.zig
index d8394428..f04409e8 100644
--- a/src/browser/js/Inspector.zig
+++ b/src/browser/js/Inspector.zig
@@ -46,7 +46,14 @@ pub fn init(allocator: Allocator, isolate: v8.Isolate, ctx: anytype) !Inspector
// If necessary, turn a void context into something we can safely ptrCast
const safe_context: *anyopaque = if (ContextT == void) @ptrCast(@constCast(&{})) else ctx;
- const channel = v8.InspectorChannel.init(safe_context, InspectorContainer.onInspectorResponse, InspectorContainer.onInspectorEvent, isolate);
+ const channel = v8.InspectorChannel.init(
+ safe_context,
+ InspectorContainer.onInspectorResponse,
+ InspectorContainer.onInspectorEvent,
+ InspectorContainer.onRunMessageLoopOnPause,
+ InspectorContainer.onQuitMessageLoopOnPause,
+ isolate,
+ );
const client = v8.InspectorClient.init();
@@ -127,6 +134,8 @@ pub fn getNodePtr(self: *const Inspector, allocator: Allocator, object_id: []con
const NoopInspector = struct {
pub fn onInspectorResponse(_: *anyopaque, _: u32, _: []const u8) void {}
pub fn onInspectorEvent(_: *anyopaque, _: []const u8) void {}
+ pub fn onRunMessageLoopOnPause(_: *anyopaque, _: u32) void {}
+ pub fn onQuitMessageLoopOnPause(_: *anyopaque) void {}
};
pub fn getTaggedAnyOpaque(value: v8.Value) ?*js.TaggedAnyOpaque {
diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig
index 714407eb..447ce3ef 100644
--- a/src/browser/js/bridge.zig
+++ b/src/browser/js/bridge.zig
@@ -544,4 +544,5 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/URL.zig"),
@import("../webapi/Window.zig"),
@import("../webapi/MutationObserver.zig"),
+ @import("../webapi/IntersectionObserver.zig"),
});
diff --git a/src/browser/tests/intersection_observer/basic.html b/src/browser/tests/intersection_observer/basic.html
new file mode 100644
index 00000000..4131d1dd
--- /dev/null
+++ b/src/browser/tests/intersection_observer/basic.html
@@ -0,0 +1,31 @@
+
+
+
Target Element
+
+
diff --git a/src/browser/tests/intersection_observer/disconnect.html b/src/browser/tests/intersection_observer/disconnect.html
new file mode 100644
index 00000000..6677f906
--- /dev/null
+++ b/src/browser/tests/intersection_observer/disconnect.html
@@ -0,0 +1,30 @@
+
+
+Target Element
+
+
diff --git a/src/browser/tests/intersection_observer/multiple_targets.html b/src/browser/tests/intersection_observer/multiple_targets.html
new file mode 100644
index 00000000..358a8355
--- /dev/null
+++ b/src/browser/tests/intersection_observer/multiple_targets.html
@@ -0,0 +1,29 @@
+
+
+Target 1
+Target 2
+
+
diff --git a/src/browser/tests/intersection_observer/unobserve.html b/src/browser/tests/intersection_observer/unobserve.html
new file mode 100644
index 00000000..8d37e2c3
--- /dev/null
+++ b/src/browser/tests/intersection_observer/unobserve.html
@@ -0,0 +1,30 @@
+
+
+Target 1
+Target 2
+
+
diff --git a/src/browser/tests/mutation_observer/character_data.html b/src/browser/tests/mutation_observer/character_data.html
new file mode 100644
index 00000000..6afa299c
--- /dev/null
+++ b/src/browser/tests/mutation_observer/character_data.html
@@ -0,0 +1,79 @@
+
+Initial text
+Test
+Test
+
+
+
+
+
+
+
diff --git a/src/browser/tests/mutation_observer/childlist.html b/src/browser/tests/mutation_observer/childlist.html
new file mode 100644
index 00000000..915dbcf4
--- /dev/null
+++ b/src/browser/tests/mutation_observer/childlist.html
@@ -0,0 +1,327 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/mutation_observer/multiple_observers.html b/src/browser/tests/mutation_observer/multiple_observers.html
new file mode 100644
index 00000000..850aee64
--- /dev/null
+++ b/src/browser/tests/mutation_observer/multiple_observers.html
@@ -0,0 +1,47 @@
+
+Test
+
+
+
diff --git a/src/browser/tests/mutation_observer/mutation_observer.html b/src/browser/tests/mutation_observer/mutation_observer.html
new file mode 100644
index 00000000..8ced1815
--- /dev/null
+++ b/src/browser/tests/mutation_observer/mutation_observer.html
@@ -0,0 +1,114 @@
+
+Test
+Test
+Test
+Test
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/mutation_observer/mutations_during_callback.html b/src/browser/tests/mutation_observer/mutations_during_callback.html
new file mode 100644
index 00000000..829f3d5a
--- /dev/null
+++ b/src/browser/tests/mutation_observer/mutations_during_callback.html
@@ -0,0 +1,39 @@
+
+Test
+
+
+
diff --git a/src/browser/tests/mutation_observer/observe_multiple_targets.html b/src/browser/tests/mutation_observer/observe_multiple_targets.html
new file mode 100644
index 00000000..c4547526
--- /dev/null
+++ b/src/browser/tests/mutation_observer/observe_multiple_targets.html
@@ -0,0 +1,31 @@
+
+Test1
+Test2
+
+
+
diff --git a/src/browser/tests/mutation_observer/reobserve_same_target.html b/src/browser/tests/mutation_observer/reobserve_same_target.html
new file mode 100644
index 00000000..9249fbac
--- /dev/null
+++ b/src/browser/tests/mutation_observer/reobserve_same_target.html
@@ -0,0 +1,27 @@
+
+Test
+
+
+
diff --git a/src/browser/webapi/CData.zig b/src/browser/webapi/CData.zig
index a0b569e8..cb39a70b 100644
--- a/src/browser/webapi/CData.zig
+++ b/src/browser/webapi/CData.zig
@@ -61,11 +61,15 @@ pub fn getData(self: *const CData) []const u8 {
}
pub fn setData(self: *CData, value: ?[]const u8, page: *Page) !void {
+ const old_value = self._data;
+
if (value) |v| {
self._data = try page.dupeString(v);
} else {
self._data = "";
}
+
+ page.characterDataChange(self.asNode(), old_value);
}
pub fn format(self: *const CData, writer: *std.io.Writer) !void {
diff --git a/src/browser/webapi/IntersectionObserver.zig b/src/browser/webapi/IntersectionObserver.zig
new file mode 100644
index 00000000..7198dfa7
--- /dev/null
+++ b/src/browser/webapi/IntersectionObserver.zig
@@ -0,0 +1,342 @@
+// 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 Page = @import("../Page.zig");
+const Element = @import("Element.zig");
+const DOMRect = @import("DOMRect.zig");
+
+pub fn registerTypes() []const type {
+ return &.{
+ IntersectionObserver,
+ IntersectionObserverEntry,
+ };
+}
+
+const IntersectionObserver = @This();
+
+_callback: js.Function,
+_observing: std.ArrayList(*Element) = .{},
+_root: ?*Element = null,
+_root_margin: []const u8 = "0px",
+_threshold: []const f64 = &.{0.0},
+_pending_entries: std.ArrayList(*IntersectionObserverEntry) = .{},
+_previous_states: std.AutoHashMapUnmanaged(*Element, bool) = .{},
+
+// Shared zero DOMRect to avoid repeated allocations for non-intersecting elements
+var zero_rect: DOMRect = .{
+ ._x = 0.0,
+ ._y = 0.0,
+ ._width = 0.0,
+ ._height = 0.0,
+ ._top = 0.0,
+ ._right = 0.0,
+ ._bottom = 0.0,
+ ._left = 0.0,
+};
+
+pub const ObserverInit = struct {
+ root: ?*Element = null,
+ rootMargin: ?[]const u8 = null,
+ threshold: []const f64 = &.{0.0},
+};
+
+pub fn init(callback: js.Function, options: ?ObserverInit, page: *Page) !*IntersectionObserver {
+ const opts = options orelse ObserverInit{};
+ const root_margin = if (opts.rootMargin) |rm| try page.arena.dupe(u8, rm) else "0px";
+
+ return page._factory.create(IntersectionObserver{
+ ._callback = callback,
+ ._root = opts.root,
+ ._root_margin = root_margin,
+ ._threshold = try page.arena.dupe(f64, opts.threshold),
+ });
+}
+
+pub fn observe(self: *IntersectionObserver, target: *Element, page: *Page) !void {
+ // Check if already observing this target
+ for (self._observing.items) |elem| {
+ if (elem == target) {
+ return;
+ }
+ }
+
+ // Register with page if this is our first observation
+ if (self._observing.items.len == 0) {
+ try page.registerIntersectionObserver(self);
+ }
+
+ try self._observing.append(page.arena, target);
+
+ // Don't initialize previous state yet - let checkIntersection do it
+ // This ensures we get an entry on first observation
+
+ // Check intersection for this new target and schedule delivery
+ try self.checkIntersection(target, page);
+ if (self._pending_entries.items.len > 0) {
+ try page.scheduleIntersectionDelivery();
+ }
+}
+
+pub fn unobserve(self: *IntersectionObserver, target: *Element) void {
+ for (self._observing.items, 0..) |elem, i| {
+ if (elem == target) {
+ _ = self._observing.swapRemove(i);
+ _ = self._previous_states.remove(target);
+
+ // Remove any pending entries for this target
+ var j: usize = 0;
+ while (j < self._pending_entries.items.len) {
+ if (self._pending_entries.items[j]._target == target) {
+ _ = self._pending_entries.swapRemove(j);
+ } else {
+ j += 1;
+ }
+ }
+ return;
+ }
+ }
+}
+
+pub fn disconnect(self: *IntersectionObserver, page: *Page) void {
+ page.unregisterIntersectionObserver(self);
+ self._observing.clearRetainingCapacity();
+ self._previous_states.clearRetainingCapacity();
+ self._pending_entries.clearRetainingCapacity();
+}
+
+pub fn takeRecords(self: *IntersectionObserver, page: *Page) ![]*IntersectionObserverEntry {
+ const entries = try page.call_arena.dupe(*IntersectionObserverEntry, self._pending_entries.items);
+ self._pending_entries.clearRetainingCapacity();
+ return entries;
+}
+
+fn calculateIntersection(
+ self: *IntersectionObserver,
+ target: *Element,
+ page: *Page,
+) !IntersectionData {
+ const target_rect = try target.getBoundingClientRect(page);
+
+ // Use root element's rect or viewport (simplified: assume infinite viewport)
+ const root_rect = if (self._root) |root|
+ try root.getBoundingClientRect(page)
+ else
+ // Simplified viewport - assume 1920x1080 for now
+ try page._factory.create(DOMRect{
+ ._x = 0.0,
+ ._y = 0.0,
+ ._width = 1920.0,
+ ._height = 1080.0,
+ ._top = 0.0,
+ ._right = 1920.0,
+ ._bottom = 1080.0,
+ ._left = 0.0,
+ });
+
+ // Calculate intersection rectangle
+ const left = @max(target_rect._left, root_rect._left);
+ const top = @max(target_rect._top, root_rect._top);
+ const right = @min(target_rect._right, root_rect._right);
+ const bottom = @min(target_rect._bottom, root_rect._bottom);
+
+ const is_intersecting = left < right and top < bottom;
+
+ var intersection_rect: ?*DOMRect = null;
+ var intersection_ratio: f64 = 0.0;
+
+ if (is_intersecting) {
+ const width = right - left;
+ const height = bottom - top;
+ const intersection_area = width * height;
+ const target_area = target_rect._width * target_rect._height;
+
+ if (target_area > 0) {
+ intersection_ratio = intersection_area / target_area;
+ }
+
+ intersection_rect = try page._factory.create(DOMRect{
+ ._x = left,
+ ._y = top,
+ ._width = width,
+ ._height = height,
+ ._top = top,
+ ._right = right,
+ ._bottom = bottom,
+ ._left = left,
+ });
+ } else {
+ // No intersection - reuse shared zero rect to avoid allocation
+ intersection_rect = &zero_rect;
+ }
+
+ return .{
+ .is_intersecting = is_intersecting,
+ .intersection_ratio = intersection_ratio,
+ .intersection_rect = intersection_rect.?,
+ .bounding_client_rect = target_rect,
+ .root_bounds = root_rect,
+ };
+}
+
+const IntersectionData = struct {
+ is_intersecting: bool,
+ intersection_ratio: f64,
+ intersection_rect: *DOMRect,
+ bounding_client_rect: *DOMRect,
+ root_bounds: *DOMRect,
+};
+
+fn meetsThreshold(self: *IntersectionObserver, ratio: f64) bool {
+ for (self._threshold) |threshold| {
+ if (ratio >= threshold) {
+ return true;
+ }
+ }
+ return false;
+}
+
+fn checkIntersection(self: *IntersectionObserver, target: *Element, page: *Page) !void {
+ const data = try self.calculateIntersection(target, page);
+ const was_intersecting_opt = self._previous_states.get(target);
+ const is_now_intersecting = data.is_intersecting and self.meetsThreshold(data.intersection_ratio);
+
+ // Create entry if:
+ // 1. First time observing this target (was_intersecting_opt == null)
+ // 2. State changed
+ // 3. Currently intersecting
+ const should_report = was_intersecting_opt == null or
+ was_intersecting_opt.? != is_now_intersecting;
+
+ if (should_report) {
+ const entry = try page.arena.create(IntersectionObserverEntry);
+ entry.* = .{
+ ._target = target,
+ ._time = 0.0, // TODO: Get actual timestamp
+ ._bounding_client_rect = data.bounding_client_rect,
+ ._intersection_rect = data.intersection_rect,
+ ._root_bounds = data.root_bounds,
+ ._intersection_ratio = data.intersection_ratio,
+ ._is_intersecting = is_now_intersecting,
+ };
+
+ try self._pending_entries.append(page.arena, entry);
+ try self._previous_states.put(page.arena, target, is_now_intersecting);
+ }
+}
+
+pub fn checkIntersections(self: *IntersectionObserver, page: *Page) !void {
+ if (self._observing.items.len == 0) {
+ return;
+ }
+
+ for (self._observing.items) |target| {
+ try self.checkIntersection(target, page);
+ }
+
+ if (self._pending_entries.items.len > 0) {
+ try page.scheduleIntersectionDelivery();
+ }
+}
+
+pub fn deliverEntries(self: *IntersectionObserver, page: *Page) !void {
+ if (self._pending_entries.items.len == 0) {
+ return;
+ }
+
+ const entries = try self.takeRecords(page);
+ try self._callback.call(void, .{ entries, self });
+}
+
+pub const IntersectionObserverEntry = struct {
+ _target: *Element,
+ _time: f64,
+ _bounding_client_rect: *DOMRect,
+ _intersection_rect: *DOMRect,
+ _root_bounds: *DOMRect,
+ _intersection_ratio: f64,
+ _is_intersecting: bool,
+
+ pub fn getTarget(self: *const IntersectionObserverEntry) *Element {
+ return self._target;
+ }
+
+ pub fn getTime(self: *const IntersectionObserverEntry) f64 {
+ return self._time;
+ }
+
+ pub fn getBoundingClientRect(self: *const IntersectionObserverEntry) *DOMRect {
+ return self._bounding_client_rect;
+ }
+
+ pub fn getIntersectionRect(self: *const IntersectionObserverEntry) *DOMRect {
+ return self._intersection_rect;
+ }
+
+ pub fn getRootBounds(self: *const IntersectionObserverEntry) ?*DOMRect {
+ return self._root_bounds;
+ }
+
+ pub fn getIntersectionRatio(self: *const IntersectionObserverEntry) f64 {
+ return self._intersection_ratio;
+ }
+
+ pub fn getIsIntersecting(self: *const IntersectionObserverEntry) bool {
+ return self._is_intersecting;
+ }
+
+ pub const JsApi = struct {
+ pub const bridge = js.Bridge(IntersectionObserverEntry);
+
+ pub const Meta = struct {
+ pub const name = "IntersectionObserverEntry";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const target = bridge.accessor(IntersectionObserverEntry.getTarget, null, .{});
+ pub const time = bridge.accessor(IntersectionObserverEntry.getTime, null, .{});
+ pub const boundingClientRect = bridge.accessor(IntersectionObserverEntry.getBoundingClientRect, null, .{});
+ pub const intersectionRect = bridge.accessor(IntersectionObserverEntry.getIntersectionRect, null, .{});
+ pub const rootBounds = bridge.accessor(IntersectionObserverEntry.getRootBounds, null, .{});
+ pub const intersectionRatio = bridge.accessor(IntersectionObserverEntry.getIntersectionRatio, null, .{});
+ pub const isIntersecting = bridge.accessor(IntersectionObserverEntry.getIsIntersecting, null, .{});
+ };
+};
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(IntersectionObserver);
+
+ pub const Meta = struct {
+ pub const name = "IntersectionObserver";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const constructor = bridge.constructor(init, .{});
+
+ pub const observe = bridge.function(IntersectionObserver.observe, .{});
+ pub const unobserve = bridge.function(IntersectionObserver.unobserve, .{});
+ pub const disconnect = bridge.function(IntersectionObserver.disconnect, .{});
+ pub const takeRecords = bridge.function(IntersectionObserver.takeRecords, .{});
+};
+
+const testing = @import("../../testing.zig");
+test "WebApi: IntersectionObserver" {
+ try testing.htmlRunner("intersection_observer", .{});
+}
diff --git a/src/browser/webapi/MutationObserver.zig b/src/browser/webapi/MutationObserver.zig
index e33f3223..ed34d80f 100644
--- a/src/browser/webapi/MutationObserver.zig
+++ b/src/browser/webapi/MutationObserver.zig
@@ -16,18 +16,271 @@
// 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 Page = @import("../Page.zig");
+const Node = @import("Node.zig");
+const Element = @import("Element.zig");
+
+pub fn registerTypes() []const type {
+ return &.{
+ MutationObserver,
+ MutationRecord,
+ };
+}
-// @ZIGDOM (haha, bet you wish you hadn't opened this file)
-// puppeteer's startup script creates a MutationObserver, even if it doesn't use
-// it in simple scripts. This not-even-a-skeleton is required for puppeteer/cdp.js
-// to run
const MutationObserver = @This();
-pub fn init() MutationObserver {
- return .{};
+_callback: js.Function,
+_observing: std.ArrayList(Observing) = .{},
+_pending_records: std.ArrayList(*MutationRecord) = .{},
+
+const Observing = struct {
+ target: *Node,
+ options: ObserveOptions,
+};
+
+pub const ObserveOptions = struct {
+ attributes: bool = false,
+ attributeOldValue: bool = false,
+ childList: bool = false,
+ characterData: bool = false,
+ characterDataOldValue: bool = false,
+ // Future: subtree, attributeFilter
+};
+
+pub fn init(callback: js.Function, page: *Page) !*MutationObserver {
+ return page._factory.create(MutationObserver{
+ ._callback = callback,
+ });
}
+pub fn observe(self: *MutationObserver, target: *Node, options: ObserveOptions, page: *Page) !void {
+ // Check if already observing this target
+ for (self._observing.items) |*obs| {
+ if (obs.target == target) {
+ obs.options = options;
+ return;
+ }
+ }
+
+ // Register with page if this is our first observation
+ if (self._observing.items.len == 0) {
+ try page.registerMutationObserver(self);
+ }
+
+ try self._observing.append(page.arena, .{
+ .target = target,
+ .options = options,
+ });
+}
+
+pub fn disconnect(self: *MutationObserver, page: *Page) void {
+ page.unregisterMutationObserver(self);
+ self._observing.clearRetainingCapacity();
+ self._pending_records.clearRetainingCapacity();
+}
+
+pub fn takeRecords(self: *MutationObserver, page: *Page) ![]*MutationRecord {
+ const records = try page.call_arena.dupe(*MutationRecord, self._pending_records.items);
+ self._pending_records.clearRetainingCapacity();
+ return records;
+}
+
+// Called when an attribute changes on any element
+pub fn notifyAttributeChange(
+ self: *MutationObserver,
+ target: *Element,
+ attribute_name: []const u8,
+ old_value: ?[]const u8,
+ page: *Page,
+) !void {
+ const target_node = target.asNode();
+
+ for (self._observing.items) |obs| {
+ if (obs.target != target_node) {
+ continue;
+ }
+ if (!obs.options.attributes) {
+ continue;
+ }
+
+ const record = try page._factory.create(MutationRecord{
+ ._type = .attributes,
+ ._target = target_node,
+ ._attribute_name = try page.arena.dupe(u8, attribute_name),
+ ._old_value = if (obs.options.attributeOldValue and old_value != null)
+ try page.arena.dupe(u8, old_value.?)
+ else
+ null,
+ ._added_nodes = &.{},
+ ._removed_nodes = &.{},
+ ._previous_sibling = null,
+ ._next_sibling = null,
+ });
+
+ try self._pending_records.append(page.arena, record);
+
+ try page.scheduleMutationDelivery();
+ break;
+ }
+}
+
+// Called when character data changes on a text node
+pub fn notifyCharacterDataChange(
+ self: *MutationObserver,
+ target: *Node,
+ old_value: ?[]const u8,
+ page: *Page,
+) !void {
+ for (self._observing.items) |obs| {
+ if (obs.target != target) {
+ continue;
+ }
+ if (!obs.options.characterData) {
+ continue;
+ }
+
+ const record = try page._factory.create(MutationRecord{
+ ._type = .characterData,
+ ._target = target,
+ ._attribute_name = null,
+ ._old_value = if (obs.options.characterDataOldValue and old_value != null)
+ try page.arena.dupe(u8, old_value.?)
+ else
+ null,
+ ._added_nodes = &.{},
+ ._removed_nodes = &.{},
+ ._previous_sibling = null,
+ ._next_sibling = null,
+ });
+
+ try self._pending_records.append(page.arena, record);
+
+ try page.scheduleMutationDelivery();
+ break;
+ }
+}
+
+// Called when children are added or removed from a node
+pub fn notifyChildListChange(
+ self: *MutationObserver,
+ target: *Node,
+ added_nodes: []const *Node,
+ removed_nodes: []const *Node,
+ previous_sibling: ?*Node,
+ next_sibling: ?*Node,
+ page: *Page,
+) !void {
+ for (self._observing.items) |obs| {
+ if (obs.target != target) {
+ continue;
+ }
+ if (!obs.options.childList) {
+ continue;
+ }
+
+ const record = try page._factory.create(MutationRecord{
+ ._type = .childList,
+ ._target = target,
+ ._attribute_name = null,
+ ._old_value = null,
+ ._added_nodes = try page.arena.dupe(*Node, added_nodes),
+ ._removed_nodes = try page.arena.dupe(*Node, removed_nodes),
+ ._previous_sibling = previous_sibling,
+ ._next_sibling = next_sibling,
+ });
+
+ try self._pending_records.append(page.arena, record);
+
+ try page.scheduleMutationDelivery();
+ break;
+ }
+}
+
+pub fn deliverRecords(self: *MutationObserver, page: *Page) !void {
+ if (self._pending_records.items.len == 0) {
+ return;
+ }
+
+ // Take a copy of the records and clear the list before calling callback
+ // This ensures mutations triggered during the callback go into a fresh list
+ const records = try self.takeRecords(page);
+ try self._callback.call(void, .{ records, self });
+}
+
+pub const MutationRecord = struct {
+ _type: Type,
+ _target: *Node,
+ _attribute_name: ?[]const u8,
+ _old_value: ?[]const u8,
+ _added_nodes: []const *Node,
+ _removed_nodes: []const *Node,
+ _previous_sibling: ?*Node,
+ _next_sibling: ?*Node,
+
+ pub const Type = enum {
+ attributes,
+ childList,
+ characterData,
+ };
+
+ pub fn getType(self: *const MutationRecord) []const u8 {
+ return switch (self._type) {
+ .attributes => "attributes",
+ .childList => "childList",
+ .characterData => "characterData",
+ };
+ }
+
+ pub fn getTarget(self: *const MutationRecord) *Node {
+ return self._target;
+ }
+
+ pub fn getAttributeName(self: *const MutationRecord) ?[]const u8 {
+ return self._attribute_name;
+ }
+
+ pub fn getOldValue(self: *const MutationRecord) ?[]const u8 {
+ return self._old_value;
+ }
+
+ pub fn getAddedNodes(self: *const MutationRecord) []const *Node {
+ return self._added_nodes;
+ }
+
+ pub fn getRemovedNodes(self: *const MutationRecord) []const *Node {
+ return self._removed_nodes;
+ }
+
+ pub fn getPreviousSibling(self: *const MutationRecord) ?*Node {
+ return self._previous_sibling;
+ }
+
+ pub fn getNextSibling(self: *const MutationRecord) ?*Node {
+ return self._next_sibling;
+ }
+
+ pub const JsApi = struct {
+ pub const bridge = js.Bridge(MutationRecord);
+
+ pub const Meta = struct {
+ pub const name = "MutationRecord";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const @"type" = bridge.accessor(MutationRecord.getType, null, .{});
+ pub const target = bridge.accessor(MutationRecord.getTarget, null, .{});
+ pub const attributeName = bridge.accessor(MutationRecord.getAttributeName, null, .{});
+ pub const oldValue = bridge.accessor(MutationRecord.getOldValue, null, .{});
+ pub const addedNodes = bridge.accessor(MutationRecord.getAddedNodes, null, .{});
+ pub const removedNodes = bridge.accessor(MutationRecord.getRemovedNodes, null, .{});
+ pub const previousSibling = bridge.accessor(MutationRecord.getPreviousSibling, null, .{});
+ pub const nextSibling = bridge.accessor(MutationRecord.getNextSibling, null, .{});
+ };
+};
+
pub const JsApi = struct {
pub const bridge = js.Bridge(MutationObserver);
@@ -38,4 +291,13 @@ pub const JsApi = struct {
};
pub const constructor = bridge.constructor(MutationObserver.init, .{});
+
+ pub const observe = bridge.function(MutationObserver.observe, .{});
+ pub const disconnect = bridge.function(MutationObserver.disconnect, .{});
+ pub const takeRecords = bridge.function(MutationObserver.takeRecords, .{});
};
+
+const testing = @import("../../testing.zig");
+test "WebApi: MutationObserver" {
+ try testing.htmlRunner("mutation_observer", .{});
+}
diff --git a/src/browser/webapi/element/Attribute.zig b/src/browser/webapi/element/Attribute.zig
index 3d37f173..c07c68dd 100644
--- a/src/browser/webapi/element/Attribute.zig
+++ b/src/browser/webapi/element/Attribute.zig
@@ -156,7 +156,9 @@ pub const List = struct {
const is_id = isIdForConnected(result.normalized, element);
var entry: *Entry = undefined;
+ var old_value: ?[]const u8 = null;
if (result.entry) |e| {
+ old_value = try page.call_arena.dupe(u8, e._value.str());
if (is_id) {
_ = page.document._elements_by_id.remove(e._value.str());
}
@@ -174,7 +176,7 @@ pub const List = struct {
if (is_id) {
try page.document._elements_by_id.put(page.arena, entry._value.str(), element);
}
- page.attributeChange(element, result.normalized, entry._value.str());
+ page.attributeChange(element, result.normalized, entry._value.str(), old_value);
return entry;
}
@@ -226,12 +228,13 @@ pub const List = struct {
const entry = result.entry orelse return;
const is_id = isIdForConnected(result.normalized, element);
+ const old_value = entry._value.str();
if (is_id) {
_ = page.document._elements_by_id.remove(entry._value.str());
}
- page.attributeRemove(element, result.normalized);
+ page.attributeRemove(element, result.normalized, old_value);
_ = page._attribute_lookup.remove(@intFromPtr(entry));
self._list.remove(&entry._node);
page._factory.destroy(entry);
diff --git a/src/cdp/cdp.zig b/src/cdp/cdp.zig
index 73c5e514..dc897346 100644
--- a/src/cdp/cdp.zig
+++ b/src/cdp/cdp.zig
@@ -642,6 +642,19 @@ pub fn BrowserContext(comptime CDP_T: type) type {
};
}
+ // debugger events
+
+
+ pub fn onRunMessageLoopOnPause(_: *anyopaque, _: u32) void {
+ // onRunMessageLoopOnPause is called when a breakpoint is hit.
+ // Until quit pause, we must continue to run a nested message loop
+ // to interact with the the debugger ony (ie. Chrome DevTools).
+ }
+
+ pub fn onQuitMessageLoopOnPause(_: *anyopaque) void {
+ // Quit breakpoint pause.
+ }
+
// This is hacky x 2. First, we create the JSON payload by gluing our
// session_id onto it. Second, we're much more client/websocket aware than
// we should be.