WebAPI: much better shadowdom slot support

This adds more comprehensive and correct support for slot assignment. The
changes can be broken down into 3 buckets:

1 - Slot.assign is now implemented, and general correctness around finding/
    assigning slots (e.g. Text.assignedSlot)

2 - Event dispatching for "composed" (propagating through the shadowdom) better,
    specifically around the composedPath and slots

3 - The slotchange event is fires at the right time (snapshot before mutation
    fire after mutation).

A number of WPT tests now pass, and there should be real-world impact for
slot-heavy frameworks (e.g. lit)
This commit is contained in:
Karl Seguin
2026-06-11 17:56:30 +08:00
parent d695ce10c3
commit 5d6e275d02
11 changed files with 617 additions and 230 deletions

View File

@@ -210,6 +210,7 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
}
}
const target_root = target.getRootNode(.{});
var node: ?*Node = target;
while (node) |n| {
if (path_len >= path_buffer.len) break;
@@ -220,8 +221,8 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
if (n.is(ShadowRoot)) |shadow| {
event._needs_retargeting = true;
// If event is not composed, stop at shadow boundary
if (!event._composed) {
// A non-composed event stops at its own tree's root.
if (!event._composed and n == target_root) {
break;
}
@@ -230,6 +231,13 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
continue;
}
// an assigned slottable's event-path parent is its assigned slot,
// routing the event into the slot's shadow tree
if (frame._assigned_slots.get(n)) |slot| {
node = slot.asNode();
continue;
}
node = n._parent;
}
@@ -305,6 +313,34 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
event._event_phase = .bubbling_phase;
for (path[1..]) |current_target| {
if (event._stop_propagation) break;
// Inline handlers (e.g. shadowRoot.onslotchange, div.onclick) are
// regular non-capture listeners and also fire on ancestors.
if (self.getInlineHandler(current_target, event)) |inline_handler| {
was_handled = true;
event._current_target = current_target;
const original_target = event._target;
if (event._needs_retargeting) {
event._target = getAdjustedTarget(original_target, current_target);
}
ls.toLocal(inline_handler).callWithThis(void, current_target, .{event}) catch |err| {
log.warn(.event, "inline handler", .{ .err = err });
};
if (event._needs_retargeting) {
event._target = original_target;
}
if (event._stop_propagation) {
break;
}
if (event._stop_immediate_propagation) {
continue;
}
}
if (self.base.getListeners(current_target, event._type_string)) |list| {
try self.dispatchPhase(list, current_target, event, &was_handled, &ls.local, comptime .init(false, opts));
}
@@ -420,6 +456,12 @@ fn getInlineHandler(self: *EventManager, target: *EventTarget, event: *Event) ?j
const global_event_handlers = @import("webapi/global_event_handlers.zig");
const handler_type = global_event_handlers.fromEventType(event._type_string.str()) orelse return null;
// Non-element targets (e.g. ShadowRoot.onslotchange) only ever set their
// handler via the property, so the lookup alone covers them.
if (self.frame._event_target_attr_listeners.get(.{ .target = target, .handler = handler_type })) |cached| {
return cached;
}
// Look up the inline handler for this target
const html_element = switch (target._type) {
.node => |n| n.is(Element.Html) orelse return null,
@@ -432,9 +474,8 @@ fn getInlineHandler(self: *EventManager, target: *EventTarget, event: *Event) ?j
};
}
// Computes the adjusted target for shadow DOM event retargeting
// Returns the lowest shadow-including ancestor of original_target that is
// also an ancestor-or-self of current_target
// DOM spec "retarget": walk original_target out of shadow trees until the
// node is visible from current_target's tree.
fn getAdjustedTarget(original_target: ?*EventTarget, current_target: *EventTarget) ?*EventTarget {
const ShadowRoot = @import("webapi/ShadowRoot.zig");
@@ -447,24 +488,32 @@ fn getAdjustedTarget(original_target: ?*EventTarget, current_target: *EventTarge
else => return original_target,
};
// Walk up from original target, checking if we can reach current target
var node: ?*Node = orig_node;
while (node) |n| {
// Check if current_target is an ancestor of n (or n itself)
if (isAncestorOrSelf(curr_node, n)) {
return n.asEventTarget();
var node = orig_node;
while (true) {
const root = node.getRootNode(.{});
const shadow = root.is(ShadowRoot) orelse return node.asEventTarget();
if (isShadowIncludingInclusiveAncestor(root, curr_node)) {
return node.asEventTarget();
}
node = shadow._host.asNode();
}
}
// Cross shadow boundary if needed
if (n.is(ShadowRoot)) |shadow| {
node = shadow._host.asNode();
fn isShadowIncludingInclusiveAncestor(ancestor: *Node, node: *Node) bool {
const ShadowRoot = @import("webapi/ShadowRoot.zig");
var n: ?*Node = node;
while (n) |cur| {
if (cur == ancestor) {
return true;
}
if (cur.is(ShadowRoot)) |shadow| {
n = shadow._host.asNode();
continue;
}
node = n._parent;
n = cur._parent;
}
return original_target;
return false;
}
// Whether the target's tree root (without crossing shadow boundaries) is a

View File

@@ -41,6 +41,7 @@ const Node = @import("webapi/Node.zig");
const Event = @import("webapi/Event.zig");
const EventTarget = @import("webapi/EventTarget.zig");
const CData = @import("webapi/CData.zig");
const Text = @import("webapi/cdata/Text.zig");
const Element = @import("webapi/Element.zig");
const HtmlElement = @import("webapi/element/Html.zig");
const Window = @import("webapi/Window.zig");
@@ -121,10 +122,14 @@ _element_class_lists: Element.ClassListLookup = .empty,
_element_rel_lists: Element.RelListLookup = .empty,
_element_shadow_roots: Element.ShadowRootLookup = .empty,
_node_owner_documents: Node.OwnerDocumentLookup = .empty,
_element_assigned_slots: Element.AssignedSlotLookup = .empty,
_element_scroll_positions: Element.ScrollPositionLookup = .empty,
_element_namespace_uris: Element.NamespaceUriLookup = .empty,
// Same as above, but for Nodes (slot assigments apply to both Element AND
// Text nodes)
_assigned_slots: Node.AssignedSlotLookup = .empty,
_manual_slot_assignments: Node.AssignedSlotLookup = .empty,
/// Lazily-created inline event listeners (or listeners provided as attributes).
/// Avoids bloating all elements with extra function fields for rare usage.
///
@@ -178,9 +183,10 @@ _intersection_observers: std.ArrayList(*IntersectionObserver) = .{},
_intersection_check_scheduled: bool = false,
_intersection_delivery_scheduled: bool = false,
// Slots that need slotchange events to be fired
_slots_pending_slotchange: std.AutoHashMapUnmanaged(*Element.Html.Slot, void) = .{},
_slotchange_delivery_scheduled: bool = false,
// Slots that need slotchange events to be fired, in signal order. Delivered
// by deliverMutations because there is specific timing with for these events
// with respect to mutations
_slots_pending_slotchange: std.AutoArrayHashMapUnmanaged(*Element.Html.Slot, void) = .{},
// Lookup for customized built-in elements. Maps element pointer to definition.
_customized_builtin_definitions: std.AutoHashMapUnmanaged(*Element, *CustomElementDefinition) = .{},
@@ -1867,14 +1873,6 @@ pub fn scheduleIntersectionDelivery(self: *Frame) !void {
try self.js.queueIntersectionDelivery();
}
pub fn scheduleSlotchangeDelivery(self: *Frame) !void {
if (self._slotchange_delivery_scheduled) {
return;
}
self._slotchange_delivery_scheduled = true;
try self.js.queueSlotchangeDelivery();
}
pub fn scheduleCustomElementBackupDrain(self: *Frame) !void {
try self.js.queueCustomElementBackupDrain();
}
@@ -1924,39 +1922,38 @@ pub fn deliverMutations(self: *Frame) void {
return;
}
// snapshot the pending slots to deliver. We'll deliver these AFTER the mutation
// but new pending slots that land during mutation should only be delivered
// on the microtask tick.
const slots = self.call_arena.dupe(*Element.Html.Slot, self._slots_pending_slotchange.keys()) catch |err| blk: {
log.err(.frame, "deliverMutations.slots", .{ .err = err, .type = self._type, .url = self.url });
break :blk &.{};
};
self._slots_pending_slotchange.clearRetainingCapacity();
// We only deliver notifications for observers that have records BEFORE
// we started the delivery. So we need to snapshot this. Any observers which
// get records during this phase will only be processed on the next microtask tick.
var notify: std.ArrayList(*MutationObserver) = .empty;
var it: ?*std.DoublyLinkedList.Node = self._mutation_observers.first;
while (it) |node| : (it = node.next) {
const observer: *MutationObserver = @fieldParentPtr("node", node);
if (observer._pending_records.items.len == 0) {
continue;
}
notify.append(self.call_arena, observer) catch |err| {
log.err(.frame, "deliverMutations.notify", .{ .err = err, .type = self._type, .url = self.url });
break;
};
}
for (notify.items) |observer| {
observer.deliverRecords(self) catch |err| {
log.err(.frame, "frame.deliverMutations", .{ .err = err, .type = self._type, .url = self.url });
};
}
}
pub fn deliverSlotchangeEvents(self: *Frame) void {
if (!self._slotchange_delivery_scheduled) {
return;
}
self._slotchange_delivery_scheduled = false;
// we need to collect the pending slots, and then clear it and THEN exeute
// the slot change. We do this in case the slotchange event itself schedules
// more slot changes (which should only be executed on the next microtask)
const pending = self._slots_pending_slotchange.count();
var i: usize = 0;
var slots = self.call_arena.alloc(*Element.Html.Slot, pending) catch |err| {
log.err(.frame, "deliverSlotchange.append", .{ .err = err, .type = self._type, .url = self.url });
return;
};
var it = self._slots_pending_slotchange.keyIterator();
while (it.next()) |slot| {
slots[i] = slot.*;
i += 1;
}
self._slots_pending_slotchange.clearRetainingCapacity();
// slotchange events fire after the observer callbacks (spec step order)
for (slots) |slot| {
const event = Event.initTrusted(comptime .wrap("slotchange"), .{ .bubbles = true }, self._page) catch |err| {
log.err(.frame, "deliverSlotchange.init", .{ .err = err, .type = self._type, .url = self.url });
@@ -3095,27 +3092,7 @@ pub fn removeNode(self: *Frame, parent: *Node, child: *Node, opts: RemoveNodeOpt
self.updateRangesForNodeRemoval(parent, child, idx);
}
// Handle slot assignment removal before mutation observers
if (child.is(Element)) |el| {
// Check if the parent was a shadow host
if (parent.is(Element)) |parent_el| {
if (self._element_shadow_roots.get(parent_el)) |shadow_root| {
// Signal slot changes for any affected slots
const slot_name = el.getAttributeSafe(comptime .wrap("slot")) orelse "";
var tw = @import("webapi/TreeWalker.zig").Full.Elements.init(shadow_root.asNode(), .{});
while (tw.next()) |slot_el| {
if (slot_el.is(Element.Html.Slot)) |slot| {
if (std.mem.eql(u8, slot.getName(), slot_name)) {
self.signalSlotChange(slot);
break;
}
}
}
}
}
// Remove from assigned slot lookup
_ = self._element_assigned_slots.remove(el);
}
self.slotRemovalSteps(parent, child);
if (self.hasMutationObservers()) {
const removed = [_]*Node{child};
@@ -3261,6 +3238,16 @@ pub fn _insertNodeRelative(self: *Frame, comptime from_parser: bool, parent: *No
}
}
if (self._element_shadow_roots.count() != 0) {
// html5ever wraps fragment parses in a temporary <html> element that
// gets unwrapped later; it must not take part in slot assignment.
const is_fragment_wrapper = from_parser and
self._parse_mode == .fragment and child.is(Element.Html.Html) != null;
if (!is_fragment_wrapper) {
self.slotInsertionSteps(parent, child);
}
}
const parent_is_connected = parent.isConnected();
// Tri-state behavior for mutations:
@@ -3315,12 +3302,6 @@ pub fn _insertNodeRelative(self: *Frame, comptime from_parser: bool, parent: *No
return;
}
// Update slot assignments for the inserted child if parent is a shadow host
// This needs to happen even if the element isn't connected to the document
if (child.is(Element)) |el| {
self.updateElementAssignedSlot(el);
}
if (opts.child_already_connected and !opts.adopting_to_new_document) {
// The child is already connected in the same document, we don't have to reconnect it.
// On cross-document adoption the child has already fired
@@ -3377,11 +3358,12 @@ pub fn attributeChange(self: *Frame, element: *Element, name: String, value: Str
// Handle slot assignment changes
if (name.eql(comptime .wrap("slot"))) {
self.updateSlotAssignments(element);
const old = if (old_value) |o| o.str() else "";
self.slottableNameChange(element.asNode(), old, value.str());
} else if (name.eql(comptime .wrap("name"))) {
// Check if this is a slot element
if (element.is(Element.Html.Slot)) |slot| {
self.signalSlotChange(slot);
const old = if (old_value) |o| o.str() else "";
self.slotNameChange(slot, old, value.str());
}
} else if (name.eql(comptime .wrap("popover"))) {
const old = if (old_value) |o| o.str() else null;
@@ -3406,11 +3388,10 @@ pub fn attributeRemove(self: *Frame, element: *Element, name: String, old_value:
// Handle slot assignment changes
if (name.eql(comptime .wrap("slot"))) {
self.updateSlotAssignments(element);
self.slottableNameChange(element.asNode(), old_value.str(), "");
} else if (name.eql(comptime .wrap("name"))) {
// Check if this is a slot element
if (element.is(Element.Html.Slot)) |slot| {
self.signalSlotChange(slot);
self.slotNameChange(slot, old_value.str(), "");
}
} else if (name.eql(comptime .wrap("popover"))) {
popover.attributeChanged(element, old_value.str(), null, self);
@@ -3422,66 +3403,56 @@ fn signalSlotChange(self: *Frame, slot: *Element.Html.Slot) void {
log.err(.frame, "signalSlotChange.put", .{ .err = err, .type = self._type, .url = self.url });
return;
};
self.scheduleSlotchangeDelivery() catch |err| {
self.scheduleMutationDelivery() catch |err| {
log.err(.frame, "signalSlotChange.schedule", .{ .err = err, .type = self._type, .url = self.url });
};
}
fn updateSlotAssignments(self: *Frame, element: *Element) void {
// Find all slots in the shadow root that might be affected
const parent = element.asNode()._parent orelse return;
// Check if parent is a shadow host
const parent_el = parent.is(Element) orelse return;
_ = self._element_shadow_roots.get(parent_el) orelse return;
// Signal change for the old slot (if any)
if (self._element_assigned_slots.get(element)) |old_slot| {
self.signalSlotChange(old_slot);
}
// Update the assignedSlot lookup to the new slot
self.updateElementAssignedSlot(element);
// Signal change for the new slot (if any)
if (self._element_assigned_slots.get(element)) |new_slot| {
self.signalSlotChange(new_slot);
}
pub fn isSlottable(node: *Node) bool {
return switch (node._type) {
.element => true,
.cdata => node.is(Text) != null,
else => false,
};
}
fn updateElementAssignedSlot(self: *Frame, element: *Element) void {
// Remove old assignment
_ = self._element_assigned_slots.remove(element);
pub fn findSlotForSlottable(self: *Frame, slottable: *Node, comptime open_only: bool) ?*Element.Html.Slot {
const parent = slottable.parentElement() orelse return null;
// Find the new assigned slot
const parent = element.asNode()._parent orelse return;
const parent_el = parent.is(Element) orelse return;
const shadow_root = self._element_shadow_roots.get(parent_el) orelse return;
const shadow_root = self._element_shadow_roots.get(parent) orelse return null;
const slot_name = element.getAttributeSafe(comptime .wrap("slot")) orelse "";
// Recursively search through the shadow root for a matching slot
if (findMatchingSlot(shadow_root.asNode(), slot_name)) |slot| {
self._element_assigned_slots.put(self.arena, element, slot) catch |err| {
log.err(.frame, "updateElementAssignedSlot.put", .{ .err = err, .type = self._type, .url = self.url });
};
if (open_only and shadow_root._mode != .open) {
return null;
}
const shadow_node = shadow_root.asNode();
if (shadow_root._slot_assignment == .manual) {
const slot = self._manual_slot_assignments.get(slottable) orelse return null;
if (slot.asNode().getRootNode(.{}) != shadow_node) {
return null;
}
return slot;
}
const slottable_name = blk: {
const el = slottable.is(Element) orelse break :blk "";
break :blk el.getAttributeSafe(comptime .wrap("slot")) orelse "";
};
return findNamedSlot(shadow_node, slottable_name);
}
fn findMatchingSlot(node: *Node, slot_name: []const u8) ?*Element.Html.Slot {
// Check if this node is a matching slot
if (node.is(Element)) |el| {
if (el.is(Element.Html.Slot)) |slot| {
if (std.mem.eql(u8, slot.getName(), slot_name)) {
return slot;
}
// First slot, in tree order, with the given name.
fn findNamedSlot(node: *Node, slot_name: []const u8) ?*Element.Html.Slot {
if (node.is(Element.Html.Slot)) |slot| {
if (std.mem.eql(u8, slot.getName(), slot_name)) {
return slot;
}
}
// Search children
var it = node.childrenIterator();
while (it.next()) |child| {
if (findMatchingSlot(child, slot_name)) |slot| {
if (findNamedSlot(child, slot_name)) |slot| {
return slot;
}
}
@@ -3489,6 +3460,175 @@ fn findMatchingSlot(node: *Node, slot_name: []const u8) ?*Element.Html.Slot {
return null;
}
// DOM spec "assign slottables": recompute a slot's assigned nodes, signaling
// a slot change when the assignment actually changed.
pub fn assignSlottables(self: *Frame, slot: *Element.Html.Slot) void {
self._assignSlottables(slot) catch |err| {
log.err(.frame, "assignSlottables", .{ .err = err, .type = self._type, .url = self.url });
};
}
fn _assignSlottables(self: *Frame, slot: *Element.Html.Slot) !void {
var slottables: std.ArrayList(*Node) = .empty;
if (slot.asNode().getRootNode(.{}).is(ShadowRoot)) |shadow_root| {
const host = shadow_root.getHost();
if (shadow_root._slot_assignment == .manual) {
// manual assignment preserves the assign(...) order, not tree order
for (slot._manually_assigned.items) |node| {
if (node._parent == host.asNode()) {
try slottables.append(self.call_arena, node);
}
}
} else {
var it = host.asNode().childrenIterator();
while (it.next()) |child| {
if (!isSlottable(child)) {
continue;
}
if (self.findSlotForSlottable(child, false) == slot) {
try slottables.append(self.call_arena, child);
}
}
}
}
const old = slot._assigned.items;
const changed = blk: {
if (old.len != slottables.items.len) {
break :blk true;
}
for (old, slottables.items) |a, b| {
if (a != b) break :blk true;
}
break :blk false;
};
if (!changed) {
return;
}
self.signalSlotChange(slot);
for (old) |node| {
if (self._assigned_slots.get(node) == slot) {
_ = self._assigned_slots.remove(node);
}
}
slot._assigned.clearRetainingCapacity();
try slot._assigned.appendSlice(self.arena, slottables.items);
for (slottables.items) |node| {
try self._assigned_slots.put(self.arena, node, slot);
}
}
// DOM spec "assign a slot"
fn assignASlot(self: *Frame, slottable: *Node) void {
const slot = self.findSlotForSlottable(slottable, false) orelse return;
self.assignSlottables(slot);
}
// DOM spec "assign slottables for a tree"
pub fn assignSlottablesForTree(self: *Frame, root: *Node) void {
var tw = @import("webapi/TreeWalker.zig").Full.Elements.init(root, .{});
while (tw.next()) |el| {
if (el.is(Element.Html.Slot)) |slot| {
self.assignSlottables(slot);
}
}
}
fn subtreeHasSlot(node: *Node) bool {
if (node.is(Element) == null) {
return false;
}
var tw = @import("webapi/TreeWalker.zig").Full.Elements.init(node, .{});
while (tw.next()) |el| {
if (el.is(Element.Html.Slot) != null) {
return true;
}
}
return false;
}
// DOM spec insertion steps that affect slot assignment.
fn slotInsertionSteps(self: *Frame, parent: *Node, child: *Node) void {
// The new child may be a slottable to assign in the parent's shadow tree.
if (parent.is(Element)) |parent_el| {
if (self._element_shadow_roots.get(parent_el) != null and isSlottable(child)) {
self.assignASlot(child);
}
}
// New fallback content in a slot that currently renders its fallback.
if (parent.is(Element.Html.Slot)) |parent_slot| {
if (parent_slot._assigned.items.len == 0 and parent.getRootNode(.{}).is(ShadowRoot) != null) {
self.signalSlotChange(parent_slot);
}
}
// A subtree containing slots was inserted into a shadow tree.
if (subtreeHasSlot(child)) {
const root = child.getRootNode(.{});
if (root.is(ShadowRoot) != null) {
self.assignSlottablesForTree(root);
}
}
}
// DOM spec removing steps that affect slot assignment. Runs after child has
// been unlinked from parent.
fn slotRemovalSteps(self: *Frame, parent: *Node, child: *Node) void {
if (self._element_shadow_roots.count() == 0) {
// shortcut
return;
}
if (self._assigned_slots.get(child)) |slot| {
self.assignSlottables(slot);
}
// Fallback content was removed from a slot that renders its fallback.
if (parent.is(Element.Html.Slot)) |parent_slot| {
if (parent_slot._assigned.items.len == 0 and parent.getRootNode(.{}).is(ShadowRoot) != null) {
self.signalSlotChange(parent_slot);
}
}
// A subtree containing slots was removed: update assignments in the old
// tree, and clear assignments held by slots in the detached subtree.
if (subtreeHasSlot(child)) {
const root = parent.getRootNode(.{});
if (root.is(ShadowRoot) != null) {
self.assignSlottablesForTree(root);
}
self.assignSlottablesForTree(child);
}
}
// DOM spec attribute change steps for the `slot` attribute on a slottable.
fn slottableNameChange(self: *Frame, slottable: *Node, old_value: []const u8, value: []const u8) void {
if (std.mem.eql(u8, old_value, value)) {
return;
}
if (self._element_shadow_roots.count() == 0) {
return;
}
if (self._assigned_slots.get(slottable)) |old_slot| {
self.assignSlottables(old_slot);
}
self.assignASlot(slottable);
}
// HTML spec attribute change steps for the `name` attribute on a slot.
fn slotNameChange(self: *Frame, slot: *Element.Html.Slot, old_value: []const u8, value: []const u8) void {
if (std.mem.eql(u8, old_value, value)) {
return;
}
const root = slot.asNode().getRootNode(.{});
if (root.is(ShadowRoot) != null) {
self.assignSlottablesForTree(root);
}
}
pub fn hasMutationObservers(self: *const Frame) bool {
return self._mutation_observers.first != null;
}
@@ -3605,6 +3745,21 @@ fn parseHtmlAsChildrenInner(self: *Frame, node: *Node, html: []const u8, allow_d
self._parse_mode = .fragment;
defer self._parse_mode = previous_parse_mode;
// The html5ever wrapper-unwrap below rebinds children without going
// through the insertion path, so recompute slot assignments for any
// shadow tree this fragment landed in (idempotent; signals only on diff).
defer if (self._element_shadow_roots.count() != 0) {
const root = node.getRootNode(.{});
if (root.is(ShadowRoot) != null) {
self.assignSlottablesForTree(root);
}
if (node.is(Element)) |el| {
if (self._element_shadow_roots.get(el)) |shadow_root| {
self.assignSlottablesForTree(shadow_root.asNode());
}
}
};
var parser = Parser.init(self.call_arena, node, self, .{ .allow_declarative_shadow = allow_declarative_shadow });
parser.parseFragment(html);

View File

@@ -774,6 +774,10 @@ pub const Script = struct {
const local = &ls.local;
// Per spec, trigger any microtasks BEFORE execution in case the parser
// (e.g. via event callbacks) queued anything.
local.runMicrotasks();
// Handle importmap special case here: the content is a JSON containing imports.
// Multiple <script type="importmap"> elements merge with first-wins semantics.
if (fe.kind == .importmap) {

View File

@@ -1063,17 +1063,6 @@ pub fn queueIntersectionDelivery(self: *Context) !void {
}.run);
}
pub fn queueSlotchangeDelivery(self: *Context) !void {
self.enqueueMicrotask(struct {
fn run(ctx: *Context) void {
switch (ctx.global) {
.frame => |frame| frame.deliverSlotchangeEvents(),
.worker => unreachable,
}
}
}.run);
}
pub fn queueCustomElementBackupDrain(self: *Context) !void {
self.enqueueMicrotask(struct {
fn run(ctx: *Context) void {

View File

@@ -516,3 +516,150 @@
});
}
</script>
<script id="Slot#no_slotchange_when_assignment_unchanged">
{
// Renaming a detached slot or one whose assignment doesn't change must
// not fire slotchange.
let calls = 0;
const host = document.createElement('div');
const shadow = host.attachShadow({ mode: 'open' });
const slot = document.createElement('slot');
slot.addEventListener('slotchange', () => { calls += 1; });
slot.name = 'detached-rename'; // detached: no signal
shadow.appendChild(slot); // host has no children: no signal
slot.name = 'still-nothing-assigned'; // assignment ([]) unchanged: no signal
testing.onload(() => {
testing.expectEqual(0, calls);
});
}
</script>
<script id="Slot#slotchange_bubbles_to_shadow_root">
{
let listener_calls = 0;
let handler_calls = 0;
const host = document.createElement('div');
const shadow = host.attachShadow({ mode: 'open' });
const slot = document.createElement('slot');
shadow.appendChild(slot);
shadow.addEventListener('slotchange', (e) => {
testing.expectTrue(e.target === slot);
listener_calls += 1;
});
shadow.onslotchange = (e) => {
testing.expectTrue(e.target === slot);
handler_calls += 1;
};
host.appendChild(document.createElement('span'));
testing.onload(() => {
testing.expectEqual(1, listener_calls);
testing.expectEqual(1, handler_calls);
});
}
</script>
<script id="Slot#assignedSlot_null_for_closed_shadow_root">
{
const host = document.createElement('div');
const shadow = host.attachShadow({ mode: 'closed' });
const slot = document.createElement('slot');
shadow.appendChild(slot);
const span = document.createElement('span');
host.appendChild(span);
// assigned (slot sees it), but hidden from the outside
testing.expectEqual(1, slot.assignedNodes().length);
testing.expectEqual(null, span.assignedSlot);
}
</script>
<script id="Slot#text_assignedSlot">
{
const host = document.createElement('div');
const shadow = host.attachShadow({ mode: 'open' });
const slot = document.createElement('slot');
shadow.appendChild(slot);
const text = document.createTextNode('hello');
host.appendChild(text);
testing.expectEqual(slot, text.assignedSlot);
testing.expectEqual(null, document.createTextNode('detached').assignedSlot);
}
</script>
<script id="Slot#manual_assignment">
{
const host = document.createElement('div');
const shadow = host.attachShadow({ mode: 'open', slotAssignment: 'manual' });
const s1 = document.createElement('slot');
const s2 = document.createElement('slot');
shadow.appendChild(s1);
shadow.appendChild(s2);
const c1 = document.createElement('div');
const c2 = document.createElement('div');
const c3 = document.createElement('div');
c1.setAttribute('slot', 'ignored-in-manual-mode');
host.appendChild(c1);
host.appendChild(c2);
host.appendChild(c3);
// slot attribute is ignored in manual mode
testing.expectEqual(0, s1.assignedNodes().length);
// assign() order is preserved, not tree order
s1.assign(c2, c3, c1);
testing.expectEqual(3, s1.assignedNodes().length);
testing.expectTrue(s1.assignedNodes()[0] === c2);
testing.expectTrue(s1.assignedNodes()[1] === c3);
testing.expectTrue(s1.assignedNodes()[2] === c1);
testing.expectEqual(s1, c2.assignedSlot);
// re-assigning a node moves it to the new slot
s2.assign(c2);
testing.expectEqual(2, s1.assignedNodes().length);
testing.expectTrue(s1.assignedNodes()[0] === c3);
testing.expectEqual(1, s2.assignedNodes().length);
testing.expectEqual(s2, c2.assignedSlot);
// an array is not allowed: assign is variadic
testing.expectError('TypeError', () => { s1.assign([c1, c2]); });
}
</script>
<script id="Slot#flatten_uses_fallback_of_nested_slot">
{
// outer host content assigned to outer slot; outer slot's tree contains
// an inner shadow host whose slot has fallback content
const host = document.createElement('div');
const shadow = host.attachShadow({ mode: 'open' });
const slot = document.createElement('slot');
shadow.appendChild(slot);
// fallback of the slot is itself a slot (not in a shadow tree of its own,
// it shares this shadow tree) with its own fallback
const innerSlot = document.createElement('slot');
innerSlot.name = 'inner';
const fallback = document.createElement('em');
innerSlot.appendChild(fallback);
slot.appendChild(innerSlot);
// host has no children: flatten resolves the fallback chain
const flat = slot.assignedNodes({ flatten: true });
testing.expectEqual(1, flat.length);
testing.expectTrue(flat[0] === fallback);
}
</script>

View File

@@ -49,7 +49,6 @@ pub const StyleLookup = std.AutoHashMapUnmanaged(*Element, *CSSStyleProperties);
pub const ClassListLookup = std.AutoHashMapUnmanaged(*Element, *collections.DOMTokenList);
pub const RelListLookup = std.AutoHashMapUnmanaged(*Element, *collections.DOMTokenList);
pub const ShadowRootLookup = std.AutoHashMapUnmanaged(*Element, *ShadowRoot);
pub const AssignedSlotLookup = std.AutoHashMapUnmanaged(*Element, *Html.Slot);
pub const NamespaceUriLookup = std.AutoHashMapUnmanaged(*Element, []const u8);
pub const ScrollPosition = struct {
@@ -748,7 +747,8 @@ pub fn getShadowRoot(self: *Element, frame: *Frame) ?*ShadowRoot {
}
pub fn getAssignedSlot(self: *Element, frame: *Frame) ?*Html.Slot {
return frame._element_assigned_slots.get(self);
// Hidden by a closed shadow tree
return frame.findSlotForSlottable(self.asNode(), true);
}
// Whether this element may host a shadow root

View File

@@ -292,6 +292,12 @@ pub fn composedPath(self: *Event, exec: *Execution) ![]const *EventTarget {
// Track closed shadow boundaries (position in path and host position)
var closed_shadow_boundary: ?struct { shadow_end: usize, host_start: usize } = null;
const frame_ = switch (exec.js.global) {
.frame => |frame| frame,
else => null,
};
const target_root = target_node.getRootNode(.{});
var node: ?*Node = target_node;
while (node) |n| {
if (path_len >= path_buffer.len) {
@@ -305,8 +311,7 @@ pub fn composedPath(self: *Event, exec: *Execution) ![]const *EventTarget {
if (n._type.document_fragment._type == .shadow_root) {
const shadow = n._type.document_fragment._type.shadow_root;
// If event is not composed, stop at shadow boundary
if (!self._composed) {
if (!self._composed and n == target_root) {
stopped_at_shadow_boundary = true;
break;
}
@@ -327,6 +332,15 @@ pub fn composedPath(self: *Event, exec: *Execution) ![]const *EventTarget {
}
}
// an assigned slottable's event-path parent is its assigned slot,
// routing the event into the slot's shadow tree
if (frame_) |frame| {
if (frame._assigned_slots.get(n)) |slot| {
node = slot.asNode();
continue;
}
}
node = n._parent;
}

View File

@@ -40,6 +40,8 @@ const String = lp.String;
const Allocator = std.mem.Allocator;
const LinkedList = std.DoublyLinkedList;
pub const AssignedSlotLookup = std.AutoHashMapUnmanaged(*Node, *Element.Html.Slot);
const Node = @This();
_type: Type,

View File

@@ -110,6 +110,18 @@ pub fn setHTMLUnsafe(self: *ShadowRoot, html: []const u8, frame: *Frame) !void {
return self.asDocumentFragment().setHTMLUnsafe(html, frame);
}
pub fn getOnSlotChange(self: *ShadowRoot, frame: *Frame) ?js.Function.Global {
return frame._event_target_attr_listeners.get(.{ .target = self.asEventTarget(), .handler = .onslotchange });
}
pub fn setOnSlotChange(self: *ShadowRoot, callback: ?js.Function.Global, frame: *Frame) !void {
if (callback) |cb| {
try frame._event_target_attr_listeners.put(frame.arena, .{ .target = self.asEventTarget(), .handler = .onslotchange }, cb);
} else {
_ = frame._event_target_attr_listeners.remove(.{ .target = self.asEventTarget(), .handler = .onslotchange });
}
}
pub fn getElementById(self: *ShadowRoot, id: []const u8, frame: *Frame) ?*Element {
if (id.len == 0) {
return null;
@@ -182,6 +194,7 @@ pub const JsApi = struct {
}
pub const adoptedStyleSheets = bridge.accessor(ShadowRoot.getAdoptedStyleSheets, ShadowRoot.setAdoptedStyleSheets, .{});
pub const setHTMLUnsafe = bridge.function(ShadowRoot.setHTMLUnsafe, .{ .dom_exception = true, .ce_reactions = true });
pub const onslotchange = bridge.accessor(ShadowRoot.getOnSlotChange, ShadowRoot.setOnSlotChange, .{});
};
const testing = @import("../../testing.zig");

View File

@@ -19,6 +19,7 @@
const js = @import("../../js/js.zig");
const Frame = @import("../../Frame.zig");
const CData = @import("../CData.zig");
const Slot = @import("../element/html/Slot.zig");
const Text = @This();
@@ -33,6 +34,10 @@ pub fn getWholeText(self: *Text) []const u8 {
return self._proto._data.str();
}
pub fn getAssignedSlot(self: *Text, frame: *Frame) ?*Slot {
return frame.findSlotForSlottable(self._proto.asNode(), true);
}
pub fn splitText(self: *Text, offset: usize, frame: *Frame) !*Text {
const data = self._proto._data.str();
@@ -77,5 +82,6 @@ pub const JsApi = struct {
pub const constructor = bridge.constructor(Text.init, .{});
pub const wholeText = bridge.accessor(Text.getWholeText, null, .{});
pub const assignedSlot = bridge.accessor(Text.getAssignedSlot, null, .{});
pub const splitText = bridge.function(Text.splitText, .{ .dom_exception = true });
};

View File

@@ -1,5 +1,4 @@
const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
@@ -7,11 +6,15 @@ const Element = @import("../../Element.zig");
const HtmlElement = @import("../Html.zig");
const ShadowRoot = @import("../../ShadowRoot.zig");
const log = lp.log;
const Slot = @This();
_proto: *HtmlElement,
// DOM spec "assigned nodes". Maintained by Frame.assignSlottables; always
// empty while the slot isn't in a shadow tree.
_assigned: std.ArrayList(*Node) = .empty,
// DOM spec "manually assigned nodes", set via assign(). Only consulted when
// the shadow root was attached with slotAssignment: "manual".
_manually_assigned: std.ArrayList(*Node) = .empty,
pub fn asElement(self: *Slot) *Element {
return self._proto._proto;
@@ -39,15 +42,26 @@ const AssignedNodesOptions = struct {
pub fn assignedNodes(self: *Slot, opts_: ?AssignedNodesOptions, frame: *Frame) ![]const *Node {
const opts = opts_ orelse AssignedNodesOptions{};
if (!opts.flatten) {
return self._assigned.items;
}
var nodes: std.ArrayList(*Node) = .empty;
try self.collectAssignedNodes(false, &nodes, opts, frame);
try self.collectFlattened(false, &nodes, frame);
return nodes.items;
}
pub fn assignedElements(self: *Slot, opts_: ?AssignedNodesOptions, frame: *Frame) ![]const *Element {
const opts = opts_ orelse AssignedNodesOptions{};
var elements: std.ArrayList(*Element) = .empty;
try self.collectAssignedNodes(true, &elements, opts, frame);
const opts = opts_ orelse AssignedNodesOptions{};
if (!opts.flatten) {
for (self._assigned.items) |node| {
if (node.is(Element)) |el| {
try elements.append(frame.call_arena, el);
}
}
return elements.items;
}
try self.collectFlattened(true, &elements, frame);
return elements.items;
}
@@ -55,97 +69,91 @@ fn CollectionType(comptime elements: bool) type {
return if (elements) *std.ArrayList(*Element) else *std.ArrayList(*Node);
}
fn collectAssignedNodes(self: *Slot, comptime elements: bool, coll: CollectionType(elements), opts: AssignedNodesOptions, frame: *Frame) !void {
// Find the shadow root this slot belongs to
const shadow_root = self.findShadowRoot() orelse return;
// DOM spec "find flattened slottables"
fn collectFlattened(self: *Slot, comptime elements: bool, coll: CollectionType(elements), frame: *Frame) error{OutOfMemory}!void {
if (self.asNode().getRootNode(.{}).is(ShadowRoot) == null) {
return;
}
const slot_name = self.getName();
const allocator = frame.call_arena;
if (self._assigned.items.len > 0) {
for (self._assigned.items) |node| {
try appendFlattened(elements, coll, node, frame);
}
return;
}
const host = shadow_root.getHost();
const initial_count = coll.items.len;
var it = host.asNode().childrenIterator();
// no assigned nodes; flatten the slot's fallback content
var it = self.asNode().childrenIterator();
while (it.next()) |child| {
if (!isAssignedToSlot(child, slot_name)) {
if (!Frame.isSlottable(child)) {
continue;
}
try appendFlattened(elements, coll, child, frame);
}
}
if (opts.flatten) {
if (child.is(Slot)) |child_slot| {
// Only flatten if the child slot is actually in a shadow tree
if (child_slot.findShadowRoot()) |_| {
try child_slot.collectAssignedNodes(elements, coll, opts, frame);
continue;
fn appendFlattened(comptime elements: bool, coll: CollectionType(elements), node: *Node, frame: *Frame) error{OutOfMemory}!void {
if (node.is(Slot)) |nested| {
// a slottable (or fallback child) that is itself a slot in a shadow
// tree flattens to its own flattened slottables
if (nested.asNode().getRootNode(.{}).is(ShadowRoot) != null) {
return nested.collectFlattened(elements, coll, frame);
}
}
if (comptime elements) {
if (node.is(Element)) |el| {
try coll.append(frame.call_arena, el);
}
} else {
try coll.append(frame.call_arena, node);
}
}
// DOM spec HTMLSlotElement.assign(...nodes). Takes js.Value so the bridge
// always treats the parameter as variadic: per WebIDL it's a rest parameter
// of (Element or Text), so passing an array must throw a TypeError.
pub fn assign(self: *Slot, values: []const js.Value, frame: *Frame) !void {
const nodes = try frame.call_arena.alloc(*Node, values.len);
for (values, nodes) |value, *entry| {
const node = value.toZig(*Node) catch return error.TypeError;
if (!Frame.isSlottable(node)) {
return error.TypeError;
}
entry.* = node;
}
for (self._manually_assigned.items) |node| {
_ = frame._manual_slot_assignments.remove(node);
}
self._manually_assigned.clearRetainingCapacity();
for (nodes) |node| {
const gop = try frame._manual_slot_assignments.getOrPut(frame.arena, node);
if (gop.found_existing) {
const other = gop.value_ptr.*;
if (other == self) {
// duplicate within `nodes`; an ordered set keeps the first position
continue;
}
// steal the node from the slot it was previously assigned to
for (other._manually_assigned.items, 0..) |n, i| {
if (n == node) {
_ = other._manually_assigned.orderedRemove(i);
break;
}
// Otherwise, treat it as a regular element and fall through
}
}
if (comptime elements) {
if (child.is(Element)) |el| {
try coll.append(allocator, el);
}
} else {
try coll.append(allocator, child);
}
gop.value_ptr.* = self;
try self._manually_assigned.append(frame.arena, node);
}
// If flatten is true and no assigned nodes were found, return fallback content
if (opts.flatten and coll.items.len == initial_count) {
var child_it = self.asNode().childrenIterator();
while (child_it.next()) |child| {
if (comptime elements) {
if (child.is(Element)) |el| {
try coll.append(allocator, el);
}
} else {
try coll.append(allocator, child);
}
}
const root = self.asNode().getRootNode(.{});
if (root.is(ShadowRoot) != null) {
frame.assignSlottablesForTree(root);
}
}
pub fn assign(self: *Slot, nodes: []const *Node) void {
// Imperative slot assignment API
// This would require storing manually assigned nodes
// For now, this is a placeholder for the API
_ = self;
_ = nodes;
// let's see if this is ever actually used
log.warn(.not_implemented, "Slot.assign", .{});
}
fn findShadowRoot(self: *Slot) ?*ShadowRoot {
// Walk up the parent chain to find the shadow root
var parent = self.asNode()._parent;
while (parent) |p| {
if (p.is(ShadowRoot)) |shadow_root| {
return shadow_root;
}
parent = p._parent;
}
return null;
}
fn isAssignedToSlot(node: *Node, slot_name: []const u8) bool {
// Check if a node should be assigned to a slot with the given name
if (node.is(Element)) |element| {
// Get the slot attribute from the element
const node_slot = element.getAttributeSafe(comptime .wrap("slot")) orelse "";
// Match if:
// - Both are empty (default slot)
// - They match exactly
return std.mem.eql(u8, node_slot, slot_name);
}
// Text nodes, comments, etc. are only assigned to the default slot
// (when they have no preceding/following element siblings with slot attributes)
// For simplicity, text nodes go to default slot if slot_name is empty
return slot_name.len == 0;
}
pub const JsApi = struct {
pub const bridge = js.Bridge(Slot);