diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig
index e6b96de63..5b0c20a38 100644
--- a/src/browser/EventManager.zig
+++ b/src/browser/EventManager.zig
@@ -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
diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig
index bbe669fc2..8d47d90e3 100644
--- a/src/browser/Frame.zig
+++ b/src/browser/Frame.zig
@@ -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 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);
diff --git a/src/browser/ScriptManagerBase.zig b/src/browser/ScriptManagerBase.zig
index f917ced60..28a531cd5 100644
--- a/src/browser/ScriptManagerBase.zig
+++ b/src/browser/ScriptManagerBase.zig
@@ -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
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig
index c276650ad..0d73685ab 100644
--- a/src/browser/webapi/Element.zig
+++ b/src/browser/webapi/Element.zig
@@ -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
diff --git a/src/browser/webapi/Event.zig b/src/browser/webapi/Event.zig
index fd878d287..5816d336b 100644
--- a/src/browser/webapi/Event.zig
+++ b/src/browser/webapi/Event.zig
@@ -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;
}
diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig
index 8a1580648..8479c3d7b 100644
--- a/src/browser/webapi/Node.zig
+++ b/src/browser/webapi/Node.zig
@@ -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,
diff --git a/src/browser/webapi/ShadowRoot.zig b/src/browser/webapi/ShadowRoot.zig
index 9c7fb94ff..c05693f03 100644
--- a/src/browser/webapi/ShadowRoot.zig
+++ b/src/browser/webapi/ShadowRoot.zig
@@ -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");
diff --git a/src/browser/webapi/cdata/Text.zig b/src/browser/webapi/cdata/Text.zig
index 2404b98cf..f29ef4acc 100644
--- a/src/browser/webapi/cdata/Text.zig
+++ b/src/browser/webapi/cdata/Text.zig
@@ -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 });
};
diff --git a/src/browser/webapi/element/html/Slot.zig b/src/browser/webapi/element/html/Slot.zig
index ecad56e5f..77a863567 100644
--- a/src/browser/webapi/element/html/Slot.zig
+++ b/src/browser/webapi/element/html/Slot.zig
@@ -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);