From 5d6e275d0242302b85c725472ebda7f6d78353ec Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 11 Jun 2026 17:56:30 +0800 Subject: [PATCH] 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) --- src/browser/EventManager.zig | 85 +++-- src/browser/Frame.zig | 383 ++++++++++++++++------- src/browser/ScriptManagerBase.zig | 4 + src/browser/js/Context.zig | 11 - src/browser/tests/element/html/slot.html | 147 +++++++++ src/browser/webapi/Element.zig | 4 +- src/browser/webapi/Event.zig | 18 +- src/browser/webapi/Node.zig | 2 + src/browser/webapi/ShadowRoot.zig | 13 + src/browser/webapi/cdata/Text.zig | 6 + src/browser/webapi/element/html/Slot.zig | 174 +++++----- 11 files changed, 617 insertions(+), 230 deletions(-) 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);