MutationObserver and IntersectionObserver

This commit is contained in:
Karl Seguin
2025-11-18 11:53:45 +08:00
parent 5819cfb438
commit 54a2e7650a
21 changed files with 1660 additions and 21 deletions

View File

@@ -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" }
},
}

View File

@@ -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;
}
}
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -544,4 +544,5 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/URL.zig"),
@import("../webapi/Window.zig"),
@import("../webapi/MutationObserver.zig"),
@import("../webapi/IntersectionObserver.zig"),
});

View File

@@ -0,0 +1,31 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<div id="target" style="width: 100px; height: 100px;">Target Element</div>
<script id="basic">
const target = document.getElementById('target');
let callbackCalled = false;
let entries = null;
const observer = new IntersectionObserver((observerEntries, obs) => {
callbackCalled = true;
entries = observerEntries;
});
observer.observe(target);
testing.eventually(() => {
testing.expectEqual(true, callbackCalled);
testing.expectEqual(1, entries.length);
const entry = entries[0];
testing.expectEqual(target, entry.target);
testing.expectEqual('boolean', typeof entry.isIntersecting);
testing.expectEqual('number', typeof entry.intersectionRatio);
testing.expectEqual('object', typeof entry.boundingClientRect);
testing.expectEqual('object', typeof entry.intersectionRect);
observer.disconnect();
});
</script>

View File

@@ -0,0 +1,30 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<div id="target" style="width: 100px; height: 100px;">Target Element</div>
<script id="disconnect">
const target = document.getElementById('target');
let callCount = 0;
const observer = new IntersectionObserver(() => {
callCount++;
});
observer.observe(target);
testing.eventually(() => {
testing.expectEqual(1, callCount);
observer.disconnect();
// Create a new observer to trigger another check
// If disconnect worked, the old observer won't fire
const observer2 = new IntersectionObserver(() => {});
observer2.observe(target);
testing.eventually(() => {
observer2.disconnect();
testing.expectEqual(1, callCount);
});
});
</script>

View File

@@ -0,0 +1,29 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<div id="target1" style="width: 100px; height: 100px;">Target 1</div>
<div id="target2" style="width: 100px; height: 100px;">Target 2</div>
<script id="multiple">
const target1 = document.getElementById('target1');
const target2 = document.getElementById('target2');
let entryCount = 0;
const seenTargets = new Set();
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
entryCount++;
seenTargets.add(entry.target);
});
});
observer.observe(target1);
observer.observe(target2);
testing.eventually(() => {
testing.expectEqual(2, entryCount);
testing.expectTrue(seenTargets.has(target1));
testing.expectTrue(seenTargets.has(target2));
observer.disconnect();
});
</script>

View File

@@ -0,0 +1,30 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<div id="target1" style="width: 100px; height: 100px;">Target 1</div>
<div id="target2" style="width: 100px; height: 100px;">Target 2</div>
<script id="unobserve">
const target1 = document.getElementById('target1');
const target2 = document.getElementById('target2');
const seenTargets = [];
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
seenTargets.push(entry.target);
});
});
// Observe target1, unobserve it, then observe target2
// We should only get a callback for target2
observer.observe(target1);
observer.unobserve(target1);
observer.observe(target2);
testing.eventually(() => {
// Should only see target2, not target1
testing.expectEqual(1, seenTargets.length);
testing.expectEqual(target2, seenTargets[0]);
observer.disconnect();
});
</script>

View File

@@ -0,0 +1,79 @@
<!DOCTYPE html>
<div id="test-element-1">Initial text</div>
<div id="test-element-2">Test</div>
<div id="test-element-3">Test</div>
<script src="../testing.js"></script>
<script id="character_data">
testing.async(async () => {
const element = document.getElementById('test-element-1');
const textNode = element.firstChild;
let mutations = null;
const observer = new MutationObserver((records) => {
observer.disconnect();
mutations = records;
});
observer.observe(textNode, { characterData: true });
textNode.data = 'Changed text';
Promise.resolve().then(() => {
testing.expectEqual(1, mutations.length, {script_id: 'character_data'});
testing.expectEqual('characterData', mutations[0].type);
testing.expectEqual(textNode, mutations[0].target);
testing.expectEqual(null, mutations[0].oldValue);
});
});
</script>
<script id="character_data_old_value">
testing.async(async () => {
const element = document.getElementById('test-element-2');
const textNode = element.firstChild;
let mutations = null;
const observer = new MutationObserver((records) => {
observer.disconnect();
mutations = records;
});
observer.observe(textNode, { characterData: true, characterDataOldValue: true });
textNode.data = 'First change';
textNode.data = 'Second change';
Promise.resolve().then(() => {
testing.expectEqual(2, mutations.length, {script_id: 'character_data_old_value'});
testing.expectEqual('characterData', mutations[0].type);
testing.expectEqual(textNode, mutations[0].target);
testing.expectEqual('Test', mutations[0].oldValue);
testing.expectEqual('characterData', mutations[1].type);
testing.expectEqual(textNode, mutations[1].target);
testing.expectEqual('First change', mutations[1].oldValue);
});
});
</script>
<script id="character_data_no_observe">
testing.async(async () => {
const element = document.getElementById('test-element-3');
const textNode = element.firstChild;
let callbackCalled = false;
const observer = new MutationObserver(() => {
callbackCalled = true;
});
// Observe for attributes, not characterData
observer.observe(element, { attributes: true });
// Change text node data (should not trigger)
textNode.data = 'Changed but not observed';
Promise.resolve().then(() => {
testing.expectEqual(false, callbackCalled);
});
});
</script>

View File

@@ -0,0 +1,327 @@
<!DOCTYPE html>
<div id="parent"><div id="child1">Child 1</div></div>
<div id="empty-parent"></div>
<div id="remove-parent"><div id="only-child">Only</div></div>
<div id="text-parent"></div>
<div id="middle-parent"><div id="first">First</div><div id="middle">Middle</div><div id="last">Last</div></div>
<script src="../testing.js"></script>
<script id="childlist">
testing.async(async () => {
const parent = document.getElementById('parent');
const child1 = document.getElementById('child1');
let mutations = null;
const observer = new MutationObserver((records) => {
observer.disconnect();
mutations = records;
});
observer.observe(parent, { childList: true });
const child2 = document.createElement('div');
child2.textContent = 'Child 2';
parent.appendChild(child2);
Promise.resolve().then(() => {
testing.expectEqual(1, mutations.length, {script_id: 'childlist'});
testing.expectEqual('childList', mutations[0].type);
testing.expectEqual(parent, mutations[0].target);
testing.expectEqual(1, mutations[0].addedNodes.length);
testing.expectEqual(child2, mutations[0].addedNodes[0]);
testing.expectEqual(0, mutations[0].removedNodes.length);
testing.expectEqual(child1, mutations[0].previousSibling);
testing.expectEqual(null, mutations[0].nextSibling);
});
});
</script>
<script id="childlist_empty_parent">
testing.async(async () => {
const emptyParent = document.getElementById('empty-parent');
let mutations = null;
const observer = new MutationObserver((records) => {
observer.disconnect();
mutations = records;
});
observer.observe(emptyParent, { childList: true });
const firstChild = document.createElement('div');
firstChild.textContent = 'First child';
emptyParent.appendChild(firstChild);
Promise.resolve().then(() => {
testing.expectEqual(1, mutations.length, {script_id: 'childlist_empty_parent'});
testing.expectEqual('childList', mutations[0].type);
testing.expectEqual(emptyParent, mutations[0].target);
testing.expectEqual(1, mutations[0].addedNodes.length);
testing.expectEqual(firstChild, mutations[0].addedNodes[0]);
testing.expectEqual(0, mutations[0].removedNodes.length);
testing.expectEqual(null, mutations[0].previousSibling);
testing.expectEqual(null, mutations[0].nextSibling);
});
});
</script>
<script id="childlist_remove_last">
testing.async(async () => {
const removeParent = document.getElementById('remove-parent');
const onlyChild = document.getElementById('only-child');
let mutations = null;
const observer = new MutationObserver((records) => {
observer.disconnect();
mutations = records;
});
observer.observe(removeParent, { childList: true });
removeParent.removeChild(onlyChild);
Promise.resolve().then(() => {
testing.expectEqual(1, mutations.length, {script_id: 'childlist_remove_last'});
testing.expectEqual('childList', mutations[0].type);
testing.expectEqual(removeParent, mutations[0].target);
testing.expectEqual(0, mutations[0].addedNodes.length);
testing.expectEqual(1, mutations[0].removedNodes.length);
testing.expectEqual(onlyChild, mutations[0].removedNodes[0]);
testing.expectEqual(null, mutations[0].previousSibling);
testing.expectEqual(null, mutations[0].nextSibling);
});
});
</script>
<script id="childlist_text_node">
testing.async(async () => {
const textParent = document.getElementById('text-parent');
let mutations = null;
const observer = new MutationObserver((records) => {
observer.disconnect();
mutations = records;
});
observer.observe(textParent, { childList: true });
const textNode = document.createTextNode('Hello world');
textParent.appendChild(textNode);
Promise.resolve().then(() => {
testing.expectEqual(1, mutations.length, {script_id: 'childlist_text_node'});
testing.expectEqual('childList', mutations[0].type);
testing.expectEqual(textParent, mutations[0].target);
testing.expectEqual(1, mutations[0].addedNodes.length);
testing.expectEqual(textNode, mutations[0].addedNodes[0]);
testing.expectEqual(null, mutations[0].previousSibling);
testing.expectEqual(null, mutations[0].nextSibling);
});
});
</script>
<script id="childlist_remove_middle">
testing.async(async () => {
const middleParent = document.getElementById('middle-parent');
const first = document.getElementById('first');
const middle = document.getElementById('middle');
const last = document.getElementById('last');
let mutations = null;
const observer = new MutationObserver((records) => {
observer.disconnect();
mutations = records;
});
observer.observe(middleParent, { childList: true });
middleParent.removeChild(middle);
Promise.resolve().then(() => {
testing.expectEqual(1, mutations.length, {script_id: 'childlist_remove_middle'});
testing.expectEqual('childList', mutations[0].type);
testing.expectEqual(middleParent, mutations[0].target);
testing.expectEqual(0, mutations[0].addedNodes.length);
testing.expectEqual(1, mutations[0].removedNodes.length);
testing.expectEqual(middle, mutations[0].removedNodes[0]);
testing.expectEqual(first, mutations[0].previousSibling);
testing.expectEqual(last, mutations[0].nextSibling);
});
});
</script>
<div id="insert-parent"><div id="insert-first">First</div><div id="insert-last">Last</div></div>
<script id="childlist_insert_before">
testing.async(async () => {
const insertParent = document.getElementById('insert-parent');
const insertFirst = document.getElementById('insert-first');
const insertLast = document.getElementById('insert-last');
let mutations = null;
const observer = new MutationObserver((records) => {
observer.disconnect();
mutations = records;
});
observer.observe(insertParent, { childList: true });
const insertMiddle = document.createElement('div');
insertMiddle.textContent = 'Middle';
insertParent.insertBefore(insertMiddle, insertLast);
Promise.resolve().then(() => {
testing.expectEqual(1, mutations.length, {script_id: 'childlist_insert_before'});
testing.expectEqual('childList', mutations[0].type);
testing.expectEqual(insertParent, mutations[0].target);
testing.expectEqual(1, mutations[0].addedNodes.length);
testing.expectEqual(insertMiddle, mutations[0].addedNodes[0]);
testing.expectEqual(0, mutations[0].removedNodes.length);
testing.expectEqual(insertFirst, mutations[0].previousSibling);
testing.expectEqual(insertLast, mutations[0].nextSibling);
});
});
</script>
<div id="replace-parent"><div id="replace-old">Old</div></div>
<script id="childlist_replace_child">
testing.async(async () => {
const replaceParent = document.getElementById('replace-parent');
const replaceOld = document.getElementById('replace-old');
let mutations = null;
const observer = new MutationObserver((records) => {
observer.disconnect();
mutations = records;
});
observer.observe(replaceParent, { childList: true });
const replaceNew = document.createElement('div');
replaceNew.textContent = 'New';
replaceParent.replaceChild(replaceNew, replaceOld);
Promise.resolve().then(() => {
// replaceChild generates two separate mutation records in modern spec:
// 1. First record for insertBefore (new node added)
// 2. Second record for removeChild (old node removed)
testing.expectEqual(2, mutations.length, {script_id: 'childlist_replace_child'});
// First mutation: insertion of new node
testing.expectEqual('childList', mutations[0].type);
testing.expectEqual(replaceParent, mutations[0].target);
testing.expectEqual(1, mutations[0].addedNodes.length);
testing.expectEqual(replaceNew, mutations[0].addedNodes[0]);
testing.expectEqual(0, mutations[0].removedNodes.length);
testing.expectEqual(null, mutations[0].previousSibling);
testing.expectEqual(replaceOld, mutations[0].nextSibling);
// Second mutation: removal of old node
testing.expectEqual('childList', mutations[1].type);
testing.expectEqual(replaceParent, mutations[1].target);
testing.expectEqual(0, mutations[1].addedNodes.length);
testing.expectEqual(1, mutations[1].removedNodes.length);
testing.expectEqual(replaceOld, mutations[1].removedNodes[0]);
testing.expectEqual(replaceNew, mutations[1].previousSibling);
testing.expectEqual(null, mutations[1].nextSibling);
});
});
</script>
<div id="multiple-parent"></div>
<script id="childlist_multiple_mutations">
testing.async(async () => {
const multipleParent = document.getElementById('multiple-parent');
let mutations = null;
const observer = new MutationObserver((records) => {
observer.disconnect();
mutations = records;
});
observer.observe(multipleParent, { childList: true });
const child1 = document.createElement('div');
child1.textContent = 'Child 1';
multipleParent.appendChild(child1);
const child2 = document.createElement('div');
child2.textContent = 'Child 2';
multipleParent.appendChild(child2);
const child3 = document.createElement('div');
child3.textContent = 'Child 3';
multipleParent.appendChild(child3);
Promise.resolve().then(() => {
testing.expectEqual(3, mutations.length, {script_id: 'childlist_multiple_mutations'});
testing.expectEqual('childList', mutations[0].type);
testing.expectEqual(child1, mutations[0].addedNodes[0]);
testing.expectEqual(null, mutations[0].previousSibling);
testing.expectEqual(null, mutations[0].nextSibling);
testing.expectEqual('childList', mutations[1].type);
testing.expectEqual(child2, mutations[1].addedNodes[0]);
testing.expectEqual(child1, mutations[1].previousSibling);
testing.expectEqual(null, mutations[1].nextSibling);
testing.expectEqual('childList', mutations[2].type);
testing.expectEqual(child3, mutations[2].addedNodes[0]);
testing.expectEqual(child2, mutations[2].previousSibling);
testing.expectEqual(null, mutations[2].nextSibling);
});
});
</script>
<div id="inner-html-parent"><div>Old 1</div><div>Old 2</div><div>Old 3</div></div>
<script id="childlist_inner_html">
testing.async(async () => {
const innerHtmlParent = document.getElementById('inner-html-parent');
const oldChildren = Array.from(innerHtmlParent.children);
let mutations = null;
const observer = new MutationObserver((records) => {
observer.disconnect();
mutations = records;
});
observer.observe(innerHtmlParent, { childList: true });
// No whitespace between tags to avoid text node mutations
innerHtmlParent.innerHTML = '<span>New 1</span><span>New 2</span><span>New 3</span>';
Promise.resolve().then(() => {
// innerHTML triggers mutations for both removals and additions
// With tri-state: from_parser=true + parse_mode=fragment -> mutations fire
// HTML wrapper element is filtered out, so: 3 removals + 3 additions = 6
testing.expectEqual(6, mutations.length, {script_id: 'childlist_inner_html'});
// First 3: removals
testing.expectEqual('childList', mutations[0].type);
testing.expectEqual(1, mutations[0].removedNodes.length);
testing.expectEqual(oldChildren[0], mutations[0].removedNodes[0]);
testing.expectEqual(0, mutations[0].addedNodes.length);
testing.expectEqual('childList', mutations[1].type);
testing.expectEqual(1, mutations[1].removedNodes.length);
testing.expectEqual(oldChildren[1], mutations[1].removedNodes[0]);
testing.expectEqual(0, mutations[1].addedNodes.length);
testing.expectEqual('childList', mutations[2].type);
testing.expectEqual(1, mutations[2].removedNodes.length);
testing.expectEqual(oldChildren[2], mutations[2].removedNodes[0]);
testing.expectEqual(0, mutations[2].addedNodes.length);
// Last 3: additions (unwrapped span elements)
testing.expectEqual('childList', mutations[3].type);
testing.expectEqual(0, mutations[3].removedNodes.length);
testing.expectEqual(1, mutations[3].addedNodes.length);
testing.expectEqual('SPAN', mutations[3].addedNodes[0].nodeName);
testing.expectEqual('childList', mutations[4].type);
testing.expectEqual(0, mutations[4].removedNodes.length);
testing.expectEqual(1, mutations[4].addedNodes.length);
testing.expectEqual('SPAN', mutations[4].addedNodes[0].nodeName);
testing.expectEqual('childList', mutations[5].type);
testing.expectEqual(0, mutations[5].removedNodes.length);
testing.expectEqual(1, mutations[5].addedNodes.length);
testing.expectEqual('SPAN', mutations[5].addedNodes[0].nodeName);
});
});
</script>

View File

@@ -0,0 +1,47 @@
<!DOCTYPE html>
<div id="target">Test</div>
<script src="../testing.js"></script>
<script id="multiple_observers">
testing.async(async () => {
const element = document.getElementById('target');
let records1 = null;
let records2 = null;
let callbackCount = 0;
const observer1 = new MutationObserver((records) => {
records1 = records;
observer1.disconnect();
callbackCount++;
});
const observer2 = new MutationObserver((records) => {
records2 = records;
observer2.disconnect();
callbackCount++;
});
observer1.observe(element, { attributes: true });
observer2.observe(element, { attributes: true, attributeOldValue: true });
element.setAttribute('data-foo', 'bar');
element.setAttribute('data-foo', 'baz');
Promise.resolve().then(() => {
testing.expectEqual(2, callbackCount);
testing.expectEqual(2, records1.length);
testing.expectEqual(2, records2.length);
testing.expectEqual('data-foo', records1[0].attributeName);
testing.expectEqual(null, records1[0].oldValue);
testing.expectEqual('data-foo', records1[1].attributeName);
testing.expectEqual(null, records1[1].oldValue);
testing.expectEqual('data-foo', records2[0].attributeName);
testing.expectEqual(null, records2[0].oldValue);
testing.expectEqual('data-foo', records2[1].attributeName);
testing.expectEqual('bar', records2[1].oldValue);
});
});
</script>

View File

@@ -0,0 +1,114 @@
<!DOCTYPE html>
<div id="test-element-1" class="initial">Test</div>
<div id="test-element-2">Test</div>
<div id="test-element-3">Test</div>
<div id="test-element-4">Test</div>
<script src="../testing.js"></script>
<script id="mutation_observer">
testing.async(async () => {
const element = document.getElementById('test-element-1');
let mutations = null;
const observer = new MutationObserver((records) => {
observer.disconnect();
mutations = records;
});
observer.observe(element, { attributes: true });
element.setAttribute('data-foo', 'bar');
element.setAttribute('class', 'changed');
element.removeAttribute('data-foo');
Promise.resolve().then(() => {
testing.expectEqual(3, mutations.length, {script_id: 'mutation_observer'});
testing.expectEqual('attributes', mutations[0].type);
testing.expectEqual(element, mutations[0].target);
testing.expectEqual('data-foo', mutations[0].attributeName);
testing.expectEqual(null, mutations[0].oldValue);
testing.expectEqual('attributes', mutations[1].type);
testing.expectEqual(element, mutations[1].target);
testing.expectEqual('class', mutations[1].attributeName);
testing.expectEqual(null, mutations[1].oldValue);
testing.expectEqual('attributes', mutations[2].type);
testing.expectEqual(element, mutations[2].target);
testing.expectEqual('data-foo', mutations[2].attributeName);
testing.expectEqual(null, mutations[2].oldValue);
});
});
</script>
<script id="mutation_observer_old_value">
testing.async(async () => {
const element = document.getElementById('test-element-2');
let mutations = null;
const observer = new MutationObserver((records) => {
observer.disconnect();
mutations = records;
});
observer.observe(element, { attributes: true, attributeOldValue: true });
element.setAttribute('data-test', 'value1');
element.setAttribute('data-test', 'value2');
element.removeAttribute('data-test');
Promise.resolve().then(() => {
testing.expectEqual(3, mutations.length, {script_id: 'mutation_observer_old_value'});
testing.expectEqual('data-test', mutations[0].attributeName);
testing.expectEqual(null, mutations[0].oldValue);
testing.expectEqual('data-test', mutations[1].attributeName);
testing.expectEqual('value1', mutations[1].oldValue);
testing.expectEqual('data-test', mutations[2].attributeName);
testing.expectEqual('value2', mutations[2].oldValue);
});
});
</script>
<script id="mutation_observer_disconnect">
testing.async(async () => {
const element = document.getElementById('test-element-3');
let callbackCalled = false;
const observer = new MutationObserver(() => {
callbackCalled = true;
});
observer.observe(element, { attributes: true });
observer.disconnect();
element.setAttribute('data-disconnected', 'test');
Promise.resolve().then(() => {
testing.expectEqual(false, callbackCalled, {script_id: 'mutation_observer_disconnect'});
});
});
</script>
<script id="mutation_observer_take_records">
testing.async(async () => {
const element = document.getElementById('test-element-4');
let callbackCalled = false;
const observer = new MutationObserver(() => {
callbackCalled = true;
});
observer.observe(element, { attributes: true });
element.setAttribute('data-take', 'records');
const taken = observer.takeRecords();
testing.expectEqual(1, taken.length);
testing.expectEqual('data-take', taken[0].attributeName);
Promise.resolve().then(() => {
testing.expectEqual(false, callbackCalled);
});
});
</script>

View File

@@ -0,0 +1,39 @@
<!DOCTYPE html>
<div id="target">Test</div>
<script src="../testing.js"></script>
<script id="mutations_during_callback">
testing.async(async () => {
const element = document.getElementById('target');
let callCount = 0;
let firstRecords = null;
let secondRecords = null;
const observer = new MutationObserver((records) => {
callCount++;
if (callCount === 1) {
firstRecords = records;
element.setAttribute('data-second', 'from-callback');
} else if (callCount === 2) {
secondRecords = records;
observer.disconnect();
}
});
observer.observe(element, { attributes: true });
element.setAttribute('data-first', 'initial');
Promise.resolve().then(() => {
// After first microtask, first callback should have run and triggered second mutation
}).then(() => {
// After second microtask, second callback should have run
testing.expectEqual(2, callCount, {script_id: 'mutations_during_callback'});
testing.expectEqual(1, firstRecords.length);
testing.expectEqual('data-first', firstRecords[0].attributeName);
testing.expectEqual(1, secondRecords.length);
testing.expectEqual('data-second', secondRecords[0].attributeName);
});
});
</script>

View File

@@ -0,0 +1,31 @@
<!DOCTYPE html>
<div id="target1">Test1</div>
<div id="target2">Test2</div>
<script src="../testing.js"></script>
<script id="observe_multiple_targets">
testing.async(async () => {
const element1 = document.getElementById('target1');
const element2 = document.getElementById('target2');
let mutations = null;
const observer = new MutationObserver((records) => {
observer.disconnect();
mutations = records;
});
observer.observe(element1, { attributes: true });
observer.observe(element2, { attributes: true });
element1.setAttribute('data-one', 'value1');
element2.setAttribute('data-two', 'value2');
Promise.resolve().then(() => {
testing.expectEqual(2, mutations.length);
testing.expectEqual(element1, mutations[0].target);
testing.expectEqual('data-one', mutations[0].attributeName);
testing.expectEqual(element2, mutations[1].target);
testing.expectEqual('data-two', mutations[1].attributeName);
});
});
</script>

View File

@@ -0,0 +1,27 @@
<!DOCTYPE html>
<div id="target">Test</div>
<script src="../testing.js"></script>
<script id="reobserve_same_target">
testing.async(async () => {
const element = document.getElementById('target');
let mutations = null;
const observer = new MutationObserver((records) => {
observer.disconnect();
mutations = records;
});
observer.observe(element, { attributes: true, attributeOldValue: false });
observer.observe(element, { attributes: true, attributeOldValue: true });
element.setAttribute('data-foo', 'value1');
element.setAttribute('data-foo', 'value2');
Promise.resolve().then(() => {
testing.expectEqual(2, mutations.length);
testing.expectEqual(null, mutations[0].oldValue);
testing.expectEqual('value1', mutations[1].oldValue);
});
});
</script>

View File

@@ -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 {

View File

@@ -0,0 +1,342 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
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", .{});
}

View File

@@ -16,18 +16,271 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
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", .{});
}

View File

@@ -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);

View File

@@ -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.