diff --git a/src/browser/SlotChangeMonitor.zig b/src/browser/SlotChangeMonitor.zig
new file mode 100644
index 00000000..dddd1d27
--- /dev/null
+++ b/src/browser/SlotChangeMonitor.zig
@@ -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();
+}
diff --git a/src/browser/dom/element.zig b/src/browser/dom/element.zig
index 81237c3d..02d01c4b 100644
--- a/src/browser/dom/element.zig
+++ b/src/browser/dom/element.zig
@@ -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");
}
diff --git a/src/browser/dom/event_target.zig b/src/browser/dom/event_target.zig
index 5f4064c6..5ee77751 100644
--- a/src/browser/dom/event_target.zig
+++ b/src/browser/dom/event_target.zig
@@ -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) {
diff --git a/src/browser/netsurf.zig b/src/browser/netsurf.zig
index a391950b..823d6546 100644
--- a/src/browser/netsurf.zig
+++ b/src/browser/netsurf.zig
@@ -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);
diff --git a/src/browser/page.zig b/src/browser/page.zig
index d253adc3..87b42534 100644
--- a/src/browser/page.zig
+++ b/src/browser/page.zig
@@ -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 {
diff --git a/src/tests/html/slot.html b/src/tests/html/slot.html
index 2fd7882c..026e13e0 100644
--- a/src/tests/html/slot.html
+++ b/src/tests/html/slot.html
@@ -34,12 +34,13 @@
default
xx other
More default2
!!
-
+
+
+
+
+
+hello
+
+
+hello
+
+
+
+
+
+hello
+
diff --git a/src/tests/testing.js b/src/tests/testing.js
index f199b61c..3180419c 100644
--- a/src/tests/testing.js
+++ b/src/tests/testing.js
@@ -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