Dispatch slotchange event

The first time a `slotchange` event is registered, we setup a SlotChangeMonitor
on the page. This uses a global (ugh) MutationEvent to detect slot changes.

We could improve the perfomance of this by installing a MutationEvent per
custom element, but a global is obviously a lot easier.

Our MutationEvent currently fired _during_ the changes. This is problematic
(in general, but specifically for slotchange). You can image something like:

```
slot.addEventListener('slotchange', () => {
   // do something with slot.assignedNodes()
});
```

But, if we dispatch the `slotchange` during the MutationEvent, assignedNodes
will return old nodes. So, our SlotChangeMonitor uses the page scheduler to
schedule dispatches on the next tick.
This commit is contained in:
Karl Seguin
2025-09-23 17:41:05 +08:00
parent db166b4633
commit 1a7dbd56ac
7 changed files with 343 additions and 18 deletions

View File

@@ -0,0 +1,189 @@
const std = @import("std");
const log = @import("../log.zig");
const parser = @import("netsurf.zig");
const collection = @import("dom/html_collection.zig");
const Page = @import("page.zig").Page;
const SlotChangeMonitor = @This();
page: *Page,
event_node: parser.EventNode,
slots_changed: std.ArrayList(*parser.Slot),
// Monitors the document in order to trigger slotchange events.
pub fn init(page: *Page) !*SlotChangeMonitor {
// on the heap, we need a stable address for event_node
const self = try page.arena.create(SlotChangeMonitor);
self.* = .{
.page = page,
.slots_changed = .empty,
.event_node = .{ .func = mutationCallback },
};
const root = parser.documentToNode(parser.documentHTMLToDocument(page.window.document));
_ = try parser.eventTargetAddEventListener(
parser.toEventTarget(parser.Node, root),
"DOMNodeInserted",
&self.event_node,
false,
);
_ = try parser.eventTargetAddEventListener(
parser.toEventTarget(parser.Node, root),
"DOMNodeRemoved",
&self.event_node,
false,
);
_ = try parser.eventTargetAddEventListener(
parser.toEventTarget(parser.Node, root),
"DOMAttrModified",
&self.event_node,
false,
);
return self;
}
// Given a element, finds its slot, if any.
pub fn findSlot(element: *parser.Element, page: *const Page) !?*parser.Slot {
const target_name = (try parser.elementGetAttribute(element, "slot")) orelse return null;
return findNamedSlot(element, target_name, page);
}
// Given an element and a name, find the slo, if any. This is only useful for
// MutationEvents where findSlot is unreliable because parser.elementGetAttribute(element, "slot")
// could return the new or old value.
fn findNamedSlot(element: *parser.Element, target_name: []const u8, page: *const Page) !?*parser.Slot {
// I believe elements need to be added as direct descendents of the host,
// so we don't need to go find the host, we just grab the parent.
const host = parser.nodeParentNode(@ptrCast(element)) orelse return null;
const state = page.getNodeState(host) orelse return null;
const shadow_root = state.shadow_root orelse return null;
// if we're here, we found a host, now find the slot
var nodes = collection.HTMLCollectionByTagName(
@ptrCast(@alignCast(shadow_root.proto)),
"slot",
.{ .include_root = false },
);
for (0..1000) |i| {
const n = (try nodes.item(@intCast(i))) orelse return null;
const slot_name = (try parser.elementGetAttribute(@ptrCast(n), "name")) orelse "";
if (std.mem.eql(u8, target_name, slot_name)) {
return @ptrCast(n);
}
}
return null;
}
// Event callback from the mutation event, signaling either the addition of
// a node, removal of a node, or a change in attribute
fn mutationCallback(en: *parser.EventNode, event: *parser.Event) void {
const mutation_event = parser.eventToMutationEvent(event);
const self: *SlotChangeMonitor = @fieldParentPtr("event_node", en);
self._mutationCallback(mutation_event) catch |err| {
log.err(.web_api, "slot change callback", .{ .err = err });
};
}
fn _mutationCallback(self: *SlotChangeMonitor, event: *parser.MutationEvent) !void {
const event_type = parser.eventType(@ptrCast(event));
if (std.mem.eql(u8, event_type, "DOMNodeInserted")) {
const event_target = parser.eventTarget(@ptrCast(event)) orelse return;
return self.nodeAddedOrRemoved(@ptrCast(event_target));
}
if (std.mem.eql(u8, event_type, "DOMNodeRemoved")) {
const event_target = parser.eventTarget(@ptrCast(event)) orelse return;
return self.nodeAddedOrRemoved(@ptrCast(event_target));
}
if (std.mem.eql(u8, event_type, "DOMAttrModified")) {
const attribute_name = try parser.mutationEventAttributeName(event);
if (std.mem.eql(u8, attribute_name, "slot") == false) {
return;
}
const new_value = parser.mutationEventNewValue(event);
const prev_value = parser.mutationEventPrevValue(event);
const event_target = parser.eventTarget(@ptrCast(event)) orelse return;
return self.nodeAttributeChanged(@ptrCast(event_target), new_value, prev_value);
}
}
// A node was removed or added. If it's an element, and if it has a slot attribute
// then we'll dispatch a slotchange event.
fn nodeAddedOrRemoved(self: *SlotChangeMonitor, node: *parser.Node) !void {
if (parser.nodeType(node) != .element) {
return;
}
const el: *parser.Element = @ptrCast(node);
if (try findSlot(el, self.page)) |slot| {
return self.scheduleSlotChange(slot);
}
}
// An attribute was modified. If the attribute is "slot", then we'll trigger 1
// slotchange for the old slot (if there was one) and 1 slotchange for the new
// one (if there is one)
fn nodeAttributeChanged(self: *SlotChangeMonitor, node: *parser.Node, new_value: ?[]const u8, prev_value: ?[]const u8) !void {
if (parser.nodeType(node) != .element) {
return;
}
const el: *parser.Element = @ptrCast(node);
if (try findNamedSlot(el, prev_value orelse "", self.page)) |slot| {
try self.scheduleSlotChange(slot);
}
if (try findNamedSlot(el, new_value orelse "", self.page)) |slot| {
try self.scheduleSlotChange(slot);
}
}
// OK. Our MutationEvent is not a MutationObserver - it's an older, deprecated
// API. It gets dispatched in the middle of the change. While I'm sure it has
// some rules, from our point of view, it fires too early. DOMAttrModified fires
// before the attribute is actually updated and DOMNodeRemoved before the node
// is actually removed. This is a problem if the callback will call
// `slot.assignedNodes`, since that won't return the new state.
// So, we use the page schedule to schedule the dispatching of the slotchange
// event.
fn scheduleSlotChange(self: *SlotChangeMonitor, slot: *parser.Slot) !void {
for (self.slots_changed.items) |changed| {
if (slot == changed) {
return;
}
}
try self.slots_changed.append(self.page.arena, slot);
if (self.slots_changed.items.len == 1) {
// first item added, schedule the callback
try self.page.scheduler.add(self, scheduleCallback, 0, .{ .name = "slot change" });
}
}
// Callback from the schedule. Time to dispatch the slotchange event
fn scheduleCallback(ctx: *anyopaque) ?u32 {
var self: *SlotChangeMonitor = @ptrCast(@alignCast(ctx));
self._scheduleCallback() catch |err| {
log.err(.app, "slot change schedule", .{ .err = err });
};
return null;
}
fn _scheduleCallback(self: *SlotChangeMonitor) !void {
for (self.slots_changed.items) |slot| {
const event = try parser.eventCreate();
defer parser.eventDestroy(event);
try parser.eventInit(event, "slotchange", .{});
_ = try parser.eventTargetDispatchEvent(
parser.toEventTarget(parser.Element, @ptrCast(@alignCast(slot))),
event,
);
}
self.slots_changed.clearRetainingCapacity();
}

View File

@@ -136,6 +136,10 @@ pub const Element = struct {
return try parser.elementSetAttribute(self, "slot", slot);
}
pub fn get_assignedSlot(self: *parser.Element, page: *const Page) !?*parser.Slot {
return @import("../SlotChangeMonitor.zig").findSlot(self, page);
}
pub fn get_classList(self: *parser.Element) !*parser.TokenList {
return try parser.tokenListCreate(self, "class");
}

View File

@@ -100,6 +100,9 @@ pub const EventTarget = struct {
page: *Page,
) !void {
_ = try EventHandler.register(page.arena, self, typ, listener, opts);
if (std.mem.eql(u8, typ, "slotchange")) {
try page.registerSlotChangeMonitor();
}
}
const RemoveEventListenerOpts = union(enum) {

View File

@@ -581,6 +581,14 @@ pub fn mutationEventPrevValue(evt: *MutationEvent) ?[]const u8 {
return strToData(s.?);
}
pub fn mutationEventNewValue(evt: *MutationEvent) ?[]const u8 {
var s: ?*String = null;
const err = c._dom_mutation_event_get_new_value(evt, &s);
std.debug.assert(err == c.DOM_NO_ERR);
if (s == null) return null;
return strToData(s.?);
}
pub fn mutationEventRelatedNode(evt: *MutationEvent) !?*Node {
var n: NodeExternal = undefined;
const err = c._dom_mutation_event_get_related_node(evt, &n);

View File

@@ -32,6 +32,7 @@ const Walker = @import("dom/walker.zig").WalkerDepthFirst;
const Scheduler = @import("Scheduler.zig");
const Http = @import("../http/Http.zig");
const ScriptManager = @import("ScriptManager.zig");
const SlotChangeMonitor = @import("SlotChangeMonitor.zig");
const HTMLDocument = @import("html/document.zig").HTMLDocument;
const URL = @import("../url.zig").URL;
@@ -90,6 +91,10 @@ pub const Page = struct {
load_state: LoadState = .parsing,
// expensive, adds a a global MutationObserver, so we only do it if there's
// an "slotchange" event registered
slot_change_monitor: ?*SlotChangeMonitor = null,
notified_network_idle: IdleNotification = .init,
notified_network_almost_idle: IdleNotification = .init,
@@ -1117,6 +1122,13 @@ pub const Page = struct {
}
return null;
}
pub fn registerSlotChangeMonitor(self: *Page) !void {
if (self.slot_change_monitor != null) {
return;
}
self.slot_change_monitor = try SlotChangeMonitor.init(self);
}
};
pub const NavigateReason = enum {

View File

@@ -34,12 +34,13 @@
<lp-test id=lp5 mode=1><p slot=slot-1>default</p> xx <b slot=slot-1>other</b></lp-test>
<lp-test id=lp6 mode=2>More <p slot=slot-1>default2</p> <span>!!</span></lp-test>
<script id=HTMLSlotElement>
<script>
function assertNodes(expected, actual) {
actual = actual.map((n) => n.id || n.textContent)
testing.expectEqual(expected, actual);
}
</script>
<script id=HTMLSlotElement>
for (let idx of [1, 2, 3, 4]) {
const lp = $(`#lp${idx}`);
const slot = lp.shadowRoot.querySelector('slot');
@@ -54,19 +55,125 @@
}
}
const lp5 = $('#lp5');
const s5 = lp5.shadowRoot.querySelector('slot');
assertNodes(['default', 'other'], s5.assignedNodes());
{
const lp5 = $('#lp5');
const s5 = lp5.shadowRoot.querySelector('slot');
assertNodes(['default', 'other'], s5.assignedNodes());
const lp6 = $('#lp6');
const s6 = lp6.shadowRoot.querySelectorAll('slot');
assertNodes(['default2'], s6[0].assignedNodes({}));
assertNodes(['default2'], s6[0].assignedNodes({flatten: true}));
assertNodes(['More ', ' ', '!!'], s6[1].assignedNodes({}));
assertNodes(['More ', ' ', '!!'], s6[1].assignedNodes({flatten: true}));
const lp6 = $('#lp6');
const s6 = lp6.shadowRoot.querySelectorAll('slot');
assertNodes(['default2'], s6[0].assignedNodes({}));
assertNodes(['default2'], s6[0].assignedNodes({flatten: true}));
assertNodes(['More ', ' ', '!!'], s6[1].assignedNodes({}));
assertNodes(['More ', ' ', '!!'], s6[1].assignedNodes({flatten: true}));
assertNodes(['default2'], s6[0].assignedElements({}));
assertNodes(['default2'], s6[0].assignedElements({flatten: true}));
assertNodes(['!!'], s6[1].assignedElements({}));
assertNodes(['!!'], s6[1].assignedElements({flatten: true}));
assertNodes(['default2'], s6[0].assignedElements({}));
assertNodes(['default2'], s6[0].assignedElements({flatten: true}));
assertNodes(['!!'], s6[1].assignedElements({}));
assertNodes(['!!'], s6[1].assignedElements({flatten: true}));
}
</script>
<lp-test id=sc1 mode=1></lp-test>
<script id=slotChange1>
{
let calls = 0;
const lp = $('#sc1');
const slot = lp.shadowRoot.querySelector('slot');
slot.addEventListener('slotchange', (e) => {
assertNodes(['slotted'], slot.assignedNodes({}));
calls += 1
}, {});
const div = document.createElement('div');
div.textContent = 'Hello!';
div.id = 'slotted';
testing.expectEqual(null, div.assignedSlot);
div.setAttribute('slot', 'slot-1');
lp.appendChild(div);
testing.expectEqual(slot, div.assignedSlot);
testing.eventually(() => {
testing.expectEqual(1, calls)
});
}
</script>
<lp-test id=sc2 mode=1><div id=s2 slot=slot-1>hello</div></lp-test>
<script id=slotChange2>
{
let calls = 0;
const lp = $('#sc2');
const slot = lp.shadowRoot.querySelector('slot');
slot.addEventListener('slotchange', (e) => {
assertNodes([], slot.assignedNodes({}));
calls += 1;
});
const div = $('#s2');
div.removeAttribute('slot');
testing.eventually(() => {
testing.expectEqual(1, calls)
});
}
</script>
<lp-test id=sc3 mode=1><div id=s3 slot=slot-1>hello</div></lp-test>
<script id=slotChange3>
{
let calls = 0;
const lp = $('#sc3');
const slot = lp.shadowRoot.querySelector('slot');
slot.addEventListener('slotchange', (e) => {
assertNodes([], slot.assignedNodes({}));
calls += 1;
});
const div = $('#s3');
div.slot = 'other';
testing.eventually(() => {
testing.expectEqual(1, calls)
});
}
</script>
<lp-test id=sc4 mode=1></lp-test>
<script id=slotChange4>
{
let calls = 0;
const lp = $('#sc4');
const slot = lp.shadowRoot.querySelector('slot');
slot.addEventListener('slotchange', (e) => {
assertNodes(['slotted'], slot.assignedNodes({}));
calls += 1;
});
const div = document.createElement('div');
div.id = 'slotted';
div.slot = 'other';
lp.appendChild(div);
div.slot = 'slot-1'
testing.eventually(() => {
testing.expectEqual(1, calls)
});
}
</script>
<lp-test id=sc5 mode=1><div id=s5 slot=slot-1>hello</div></lp-test>
<script id=slotChange5>
{
let calls = 0;
const lp = $('#sc5');
const slot = lp.shadowRoot.querySelector('slot');
slot.addEventListener('slotchange', (e) => {
assertNodes([], slot.assignedNodes({}));
calls += 1;
});
$('#s5').remove();
testing.eventually(() => {
testing.expectEqual(1, calls)
});
}
</script>

View File

@@ -107,9 +107,11 @@
}
testing._status = 'ok';
const script_id = testing._captured?.script_id || document.currentScript.id;
testing._executed_scripts.add(script_id);
_registerErrorCallback();
if (testing._captured || document.currentScript) {
const script_id = testing._captured?.script_id || document.currentScript.id;
testing._executed_scripts.add(script_id);
_registerErrorCallback();
}
}
// We want to attach an onError callback to each <script>, so that we can