mirror of
https://github.com/lightpanda-io/browser.git
synced 2025-12-14 15:28:57 +00:00
MutationObserver and IntersectionObserver
This commit is contained in:
@@ -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" }
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -544,4 +544,5 @@ pub const JsApis = flattenTypes(&.{
|
||||
@import("../webapi/URL.zig"),
|
||||
@import("../webapi/Window.zig"),
|
||||
@import("../webapi/MutationObserver.zig"),
|
||||
@import("../webapi/IntersectionObserver.zig"),
|
||||
});
|
||||
|
||||
31
src/browser/tests/intersection_observer/basic.html
Normal file
31
src/browser/tests/intersection_observer/basic.html
Normal 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>
|
||||
30
src/browser/tests/intersection_observer/disconnect.html
Normal file
30
src/browser/tests/intersection_observer/disconnect.html
Normal 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>
|
||||
@@ -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>
|
||||
30
src/browser/tests/intersection_observer/unobserve.html
Normal file
30
src/browser/tests/intersection_observer/unobserve.html
Normal 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>
|
||||
79
src/browser/tests/mutation_observer/character_data.html
Normal file
79
src/browser/tests/mutation_observer/character_data.html
Normal 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>
|
||||
327
src/browser/tests/mutation_observer/childlist.html
Normal file
327
src/browser/tests/mutation_observer/childlist.html
Normal 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>
|
||||
47
src/browser/tests/mutation_observer/multiple_observers.html
Normal file
47
src/browser/tests/mutation_observer/multiple_observers.html
Normal 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>
|
||||
114
src/browser/tests/mutation_observer/mutation_observer.html
Normal file
114
src/browser/tests/mutation_observer/mutation_observer.html
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
342
src/browser/webapi/IntersectionObserver.zig
Normal file
342
src/browser/webapi/IntersectionObserver.zig
Normal 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", .{});
|
||||
}
|
||||
@@ -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", .{});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user