mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-04-03 16:10:29 +00:00
This commit involves a number of changes to finalizers, all aimed towards better consistency and reliability. A big part of this has to do with v8::Inspector's ability to move objects across IsolatedWorlds. There has been a few previous efforts on this, the most significant being https://github.com/lightpanda-io/browser/pull/1901. To recap, a Zig instance can map to 0-N v8::Objects. Where N is the total number of IsolatedWorlds. Generally, IsolatedWorlds between origins are...isolated...but the v8::Inspector isn't bound by this. So a Zig instance cannot be tied to a Context/Identity/IsolatedWorld...it has to live until all references, possibly from different IsolatedWorlds, are released (or the page is reset). Finalizers could previously be managed via reference counting or explicitly toggling the instance as weak/strong. Now, only reference counting is supported. weak/strong can essentially be seen as an acquireRef (rc += 1) and releaseRef (rc -= 1). Explicit setting did make some things easier, like not having to worry so much about double-releasing (e.g. XHR abort being called multiple times), but it was only used in a few places AND it simply doesn't work with objects shared between IsolatedWorlds. It is never a boolean now, as 3 different IsolatedWorlds can each hold a reference. Temps and Globals are tracked on the Session. Previously, they were tracked on the Identity, but that makes no sense. If a Zig instance can outlive an Identity, then any of its Temp references can too. This hasn't been a problem because we've only seen MutationObserver and IntersectionObserver be used cross-origin, but the right CDP script can make this crash with a use-after-free (e.g. `MessageEvent.data` is released when the Identity is done, but `MessageEvent` is still referenced by a different IsolateWorld). Rather than deinit with a `comptime shutdown: bool`, there is now an explicit `releaseRef` and `deinit`. Bridge registration has been streamlined. Previously, types had to register their finalizer AND acquireRef/releaseRef/deinit had to be declared on the entire prototype chain, even if these methods just delegated to their proto. Finalizers are now automatically enabled if a type has a `acquireRef` function. If a type has an `acquireRef`, then it must have a `releaseRef` and a `deinit`. So if there's custom cleanup to do in `deinit`, then you also have to define `acquireRef` and `releaseRef` which will just delegate to the _proto. Furthermore these finalizer methods can be defined anywhere on the chain. Previously: ```zig const KeywboardEvent = struct { _proto: *Event, ... pub fn deinit(self: *KeyboardEvent, session: *Session) void { self._proto.deinit(session); } pub fn releaseRef(self: *KeyboardEvent, session: *Session) void { self._proto.releaseRef(session); } } ``` ```zig const KeyboardEvent = struct { _proto: *Event, ... // no deinit, releaseRef, acquireref } ``` Since the `KeyboardEvent` doesn't participate in finalization directly, it doesn't have to define anything. The bridge will detect the most specific place they are defined and call them there.
475 lines
15 KiB
Zig
475 lines
15 KiB
Zig
// Copyright (C) 2023-2026 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 lp = @import("lightpanda");
|
|
const String = @import("../../string.zig").String;
|
|
|
|
const js = @import("../js/js.zig");
|
|
const Page = @import("../Page.zig");
|
|
const Session = @import("../Session.zig");
|
|
const Node = @import("Node.zig");
|
|
const Element = @import("Element.zig");
|
|
const log = @import("../../log.zig");
|
|
|
|
const IS_DEBUG = @import("builtin").mode == .Debug;
|
|
|
|
const Allocator = std.mem.Allocator;
|
|
|
|
pub fn registerTypes() []const type {
|
|
return &.{
|
|
MutationObserver,
|
|
MutationRecord,
|
|
};
|
|
}
|
|
|
|
const MutationObserver = @This();
|
|
|
|
_rc: lp.RC(u8) = .{},
|
|
_arena: Allocator,
|
|
_callback: js.Function.Temp,
|
|
_observing: std.ArrayList(Observing) = .{},
|
|
_pending_records: std.ArrayList(*MutationRecord) = .{},
|
|
|
|
/// Intrusively linked to next element (see Page.zig).
|
|
node: std.DoublyLinkedList.Node = .{},
|
|
|
|
const Observing = struct {
|
|
target: *Node,
|
|
options: ResolvedOptions,
|
|
};
|
|
|
|
/// Internal options with all nullable bools resolved to concrete values.
|
|
const ResolvedOptions = struct {
|
|
attributes: bool = false,
|
|
attributeOldValue: bool = false,
|
|
childList: bool = false,
|
|
characterData: bool = false,
|
|
characterDataOldValue: bool = false,
|
|
subtree: bool = false,
|
|
attributeFilter: ?[]const []const u8 = null,
|
|
};
|
|
|
|
pub const ObserveOptions = struct {
|
|
attributes: ?bool = null,
|
|
attributeOldValue: ?bool = null,
|
|
childList: bool = false,
|
|
characterData: ?bool = null,
|
|
characterDataOldValue: ?bool = null,
|
|
subtree: bool = false,
|
|
attributeFilter: ?[]const []const u8 = null,
|
|
};
|
|
|
|
pub fn init(callback: js.Function.Temp, page: *Page) !*MutationObserver {
|
|
const arena = try page.getArena(.{ .debug = "MutationObserver" });
|
|
errdefer page.releaseArena(arena);
|
|
|
|
const self = try arena.create(MutationObserver);
|
|
self.* = .{
|
|
._arena = arena,
|
|
._callback = callback,
|
|
};
|
|
return self;
|
|
}
|
|
|
|
/// Force cleanup on Session shutdown.
|
|
pub fn deinit(self: *MutationObserver, session: *Session) void {
|
|
self._callback.release();
|
|
session.releaseArena(self._arena);
|
|
}
|
|
|
|
pub fn releaseRef(self: *MutationObserver, session: *Session) void {
|
|
self._rc.release(self, session);
|
|
}
|
|
|
|
pub fn acquireRef(self: *MutationObserver) void {
|
|
self._rc.acquire();
|
|
}
|
|
|
|
pub fn observe(self: *MutationObserver, target: *Node, options: ObserveOptions, page: *Page) !void {
|
|
const arena = self._arena;
|
|
|
|
// Per spec: if attributeOldValue/attributeFilter present and attributes
|
|
// not explicitly set, imply attributes=true. Same for characterData.
|
|
var resolved = options;
|
|
if (resolved.attributes == null and (resolved.attributeOldValue != null or resolved.attributeFilter != null)) {
|
|
resolved.attributes = true;
|
|
}
|
|
if (resolved.characterData == null and resolved.characterDataOldValue != null) {
|
|
resolved.characterData = true;
|
|
}
|
|
|
|
const attributes = resolved.attributes orelse false;
|
|
const character_data = resolved.characterData orelse false;
|
|
|
|
// Validate: at least one of childList/attributes/characterData must be true
|
|
if (!resolved.childList and !attributes and !character_data) {
|
|
return error.TypeError;
|
|
}
|
|
|
|
// Validate: attributeOldValue/attributeFilter require attributes != false
|
|
if ((resolved.attributeOldValue orelse false) and !attributes) {
|
|
return error.TypeError;
|
|
}
|
|
if (resolved.attributeFilter != null and !attributes) {
|
|
return error.TypeError;
|
|
}
|
|
|
|
// Validate: characterDataOldValue requires characterData != false
|
|
if ((resolved.characterDataOldValue orelse false) and !character_data) {
|
|
return error.TypeError;
|
|
}
|
|
|
|
// Build resolved options with concrete bool values
|
|
var store_options = ResolvedOptions{
|
|
.attributes = attributes,
|
|
.attributeOldValue = resolved.attributeOldValue orelse false,
|
|
.childList = resolved.childList,
|
|
.characterData = character_data,
|
|
.characterDataOldValue = resolved.characterDataOldValue orelse false,
|
|
.subtree = resolved.subtree,
|
|
.attributeFilter = resolved.attributeFilter,
|
|
};
|
|
|
|
// Deep copy attributeFilter if present
|
|
if (options.attributeFilter) |filter| {
|
|
const filter_copy = try arena.alloc([]const u8, filter.len);
|
|
for (filter, 0..) |name, i| {
|
|
filter_copy[i] = try arena.dupe(u8, name);
|
|
}
|
|
store_options.attributeFilter = filter_copy;
|
|
}
|
|
|
|
// Check if already observing this target
|
|
for (self._observing.items) |*obs| {
|
|
if (obs.target == target) {
|
|
obs.options = store_options;
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Register with page if this is our first observation
|
|
if (self._observing.items.len == 0) {
|
|
self._rc._refs += 1;
|
|
try page.registerMutationObserver(self);
|
|
}
|
|
|
|
try self._observing.append(arena, .{
|
|
.target = target,
|
|
.options = store_options,
|
|
});
|
|
}
|
|
|
|
pub fn disconnect(self: *MutationObserver, page: *Page) void {
|
|
for (self._pending_records.items) |record| {
|
|
_ = record.releaseRef(page._session);
|
|
}
|
|
self._pending_records.clearRetainingCapacity();
|
|
const observing_count = self._observing.items.len;
|
|
self._observing.clearRetainingCapacity();
|
|
|
|
if (observing_count > 0) {
|
|
_ = self.releaseRef(page._session);
|
|
}
|
|
page.unregisterMutationObserver(self);
|
|
}
|
|
|
|
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: String,
|
|
old_value: ?String,
|
|
page: *Page,
|
|
) !void {
|
|
const target_node = target.asNode();
|
|
|
|
for (self._observing.items) |obs| {
|
|
if (obs.target != target_node) {
|
|
if (!obs.options.subtree) {
|
|
continue;
|
|
}
|
|
if (!obs.target.contains(target_node)) {
|
|
continue;
|
|
}
|
|
}
|
|
if (!obs.options.attributes) {
|
|
continue;
|
|
}
|
|
if (obs.options.attributeFilter) |filter| {
|
|
for (filter) |name| {
|
|
if (attribute_name.eqlSlice(name)) {
|
|
break;
|
|
}
|
|
} else {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
const arena = try page.getArena(.{ .debug = "MutationRecord" });
|
|
const record = try arena.create(MutationRecord);
|
|
record.* = .{
|
|
._arena = arena,
|
|
._type = .attributes,
|
|
._target = target_node,
|
|
._attribute_name = try arena.dupe(u8, attribute_name.str()),
|
|
._old_value = if (obs.options.attributeOldValue and old_value != null)
|
|
try arena.dupe(u8, old_value.?.str())
|
|
else
|
|
null,
|
|
._added_nodes = &.{},
|
|
._removed_nodes = &.{},
|
|
._previous_sibling = null,
|
|
._next_sibling = null,
|
|
};
|
|
|
|
try self._pending_records.append(self._arena, record);
|
|
|
|
try page.scheduleMutationDelivery();
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Called when character data changes on a text node
|
|
pub fn notifyCharacterDataChange(
|
|
self: *MutationObserver,
|
|
target: *Node,
|
|
old_value: ?String,
|
|
page: *Page,
|
|
) !void {
|
|
for (self._observing.items) |obs| {
|
|
if (obs.target != target) {
|
|
if (!obs.options.subtree) {
|
|
continue;
|
|
}
|
|
if (!obs.target.contains(target)) {
|
|
continue;
|
|
}
|
|
}
|
|
if (!obs.options.characterData) {
|
|
continue;
|
|
}
|
|
|
|
const arena = try page.getArena(.{ .debug = "MutationRecord" });
|
|
const record = try arena.create(MutationRecord);
|
|
record.* = .{
|
|
._arena = arena,
|
|
._type = .characterData,
|
|
._target = target,
|
|
._attribute_name = null,
|
|
._old_value = if (obs.options.characterDataOldValue and old_value != null)
|
|
try arena.dupe(u8, old_value.?.str())
|
|
else
|
|
null,
|
|
._added_nodes = &.{},
|
|
._removed_nodes = &.{},
|
|
._previous_sibling = null,
|
|
._next_sibling = null,
|
|
};
|
|
|
|
try self._pending_records.append(self._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) {
|
|
if (!obs.options.subtree) {
|
|
continue;
|
|
}
|
|
if (!obs.target.contains(target)) {
|
|
continue;
|
|
}
|
|
}
|
|
if (!obs.options.childList) {
|
|
continue;
|
|
}
|
|
|
|
const arena = try page.getArena(.{ .debug = "MutationRecord" });
|
|
const record = try arena.create(MutationRecord);
|
|
record.* = .{
|
|
._arena = arena,
|
|
._type = .childList,
|
|
._target = target,
|
|
._attribute_name = null,
|
|
._old_value = null,
|
|
._added_nodes = try arena.dupe(*Node, added_nodes),
|
|
._removed_nodes = try arena.dupe(*Node, removed_nodes),
|
|
._previous_sibling = previous_sibling,
|
|
._next_sibling = next_sibling,
|
|
};
|
|
|
|
try self._pending_records.append(self._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);
|
|
var ls: js.Local.Scope = undefined;
|
|
page.js.localScope(&ls);
|
|
defer ls.deinit();
|
|
|
|
var caught: js.TryCatch.Caught = undefined;
|
|
ls.toLocal(self._callback).tryCall(void, .{ records, self }, &caught) catch |err| {
|
|
log.err(.page, "MutObserver.deliverRecords", .{ .err = err, .caught = caught });
|
|
return err;
|
|
};
|
|
}
|
|
|
|
pub const MutationRecord = struct {
|
|
_rc: lp.RC(u8) = .{},
|
|
_type: Type,
|
|
_target: *Node,
|
|
_arena: Allocator,
|
|
_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 deinit(self: *MutationRecord, session: *Session) void {
|
|
session.releaseArena(self._arena);
|
|
}
|
|
|
|
pub fn releaseRef(self: *MutationRecord, session: *Session) void {
|
|
self._rc.release(self, session);
|
|
}
|
|
|
|
pub fn acquireRef(self: *MutationRecord) void {
|
|
self._rc.acquire();
|
|
}
|
|
|
|
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 getAttributeNamespace(self: *const MutationRecord) ?[]const u8 {
|
|
_ = self;
|
|
// Non-namespaced attribute mutations return null. Full namespace tracking
|
|
// for setAttributeNS mutations is not yet implemented.
|
|
return null;
|
|
}
|
|
|
|
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 attributeNamespace = bridge.accessor(MutationRecord.getAttributeNamespace, 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);
|
|
|
|
pub const Meta = struct {
|
|
pub const name = "MutationObserver";
|
|
pub const prototype_chain = bridge.prototypeChain();
|
|
pub var class_id: bridge.ClassId = undefined;
|
|
};
|
|
|
|
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", .{});
|
|
}
|