mirror of
https://github.com/lightpanda-io/browser.git
synced 2025-10-28 14:43:28 +00:00
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:
189
src/browser/SlotChangeMonitor.zig
Normal file
189
src/browser/SlotChangeMonitor.zig
Normal 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();
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user