From b56b292da0109439483b46203806e2c1d5b9bc90 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 14 Aug 2026 13:42:19 +0800 Subject: [PATCH] webapi: Custom Element reactions need pierce the shadowdom CustomElement reactions (connected/disconnected) need to pierce the shadowdom. This represents a potentially expensive treewalk on some common operations (add /remove). That walk requires checking Frame._customized_builtin_definitions and Frame._element_shadow_roots on every element. EXPENSIVE. So, a packed _flags: Flags struct is added to Elements which is used to signal that the element is present in Frame._customized_builtin_definitions and Frame._element_shadow_roots (1 bool per lookup). Element has spare padding, so this currently costs no memory. There's spare space in the flag which we should use for the other hot lookups, but I'll do that in a separate commit. Because of this new optimized flag, the change touches a few more files than just the CE reaction change. (Any place that accessed those two Frame lookups directly now go through a helper which first checks the flag). Also added a bunch of Element aria getter/setter. (All of this was done while looking at various /custom-elements/ WPT failures). --- src/browser/Frame.zig | 32 ++++- src/browser/dump.zig | 6 +- src/browser/frame/parse.zig | 2 +- src/browser/markdown.zig | 2 +- .../shadow_tree_callbacks.html | 84 ++++++++++++ src/browser/webapi/Element.zig | 124 +++++++++++++----- src/browser/webapi/element/html/Custom.zig | 41 ++++-- src/browser/webapi/element/slotting.zig | 4 +- 8 files changed, 247 insertions(+), 48 deletions(-) create mode 100644 src/browser/tests/custom_elements/shadow_tree_callbacks.html diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index aa0c2ece0..ff2d49bb9 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -2505,6 +2505,12 @@ pub fn adoptNodeTree(self: *Frame, node: *Node, old_owner: *Document, new_owner: // Per spec, adopted steps run on each element after its document is set. if (node.is(Element)) |el| { Element.Html.Custom.enqueueAdoptedCallbackOnElement(el, old_owner, new_owner, self); + + // The shadow tree follows its host across documents: re-own it and + // run its adopted reactions too (spec: shadow-including descendants). + if (el.hostedShadowRoot(self)) |shadow_root| { + try self.adoptNodeTree(shadow_root.asNode(), old_owner, new_owner); + } } var it = node.childrenIterator(); @@ -2640,6 +2646,9 @@ pub fn removeNode(self: *Frame, parent: *Node, child: *Node, opts: RemoveNodeOpt } Element.Html.Custom.enqueueDisconnectedCallbackOnElement(el, self); + Element.Html.Custom.enqueueShadowTreeCallbacks(el, .disconnected, self) catch |err| { + log.warn(.bug, "ce_reactions enqueue fail", .{ .err = err }); + }; popover.removeFromOpen(el, self); @@ -2712,19 +2721,31 @@ pub fn moveAllChildren(self: *Frame, source: *Node, parent: *Node, ref_node: ?*N // Every child shares source's root, and source itself doesn't move. const previous_root = source.getRootNode(.{}); + // Fragment insertion adopts like single-node insertion does. Every child + // shares source's owner document, so one comparison covers them all. + const source_owner = source.ownerDocument(self); + const parent_owner = parent.ownerDocument(self) orelse parent.as(Document); + const adopting = source_owner != null and source_owner.? != parent_owner; + var it = source.childrenIterator(); while (it.next()) |child| { try moved.append(self.call_arena, child); - self.removeNode(source, child, .{ .reconnect_to = parent, .notify_observers = false }); + self.removeNode(source, child, .{ + .reconnect_to = if (adopting) null else parent, + .notify_observers = false, + }); + if (adopting) { + try self.adoptNodeTree(child, source_owner.?, parent_owner); + } if (ref_node) |ref| { try self.insertNodeRelative( parent, child, .{ .before = ref }, - .{ .previous_root = previous_root, .notify_observers = false, .run_ready = false }, + .{ .previous_root = previous_root, .adopting_to_new_document = adopting, .notify_observers = false, .run_ready = false }, ); } else { - try self.appendNode(parent, child, .{ .previous_root = previous_root, .notify_observers = false, .run_ready = false }); + try self.appendNode(parent, child, .{ .previous_root = previous_root, .adopting_to_new_document = adopting, .notify_observers = false, .run_ready = false }); } } @@ -2914,6 +2935,7 @@ pub fn _insertNodeRelative(self: *Frame, comptime from_parser: bool, parent: *No if (should_invoke_connected) { try Element.Html.Custom.enqueueConnectedCallbackOnElement(false, el, self); + try Element.Html.Custom.enqueueShadowTreeCallbacks(el, .connected, self); } } } @@ -2985,11 +3007,15 @@ pub fn signalSlotChange(self: *Frame, slot: *Element.Html.Slot) void { } pub fn getCustomizedBuiltInDefinition(self: *Frame, element: *Element) ?*CustomElementDefinition { + if (!element._flags.customized_builtin) { + return null; + } return self._customized_builtin_definitions.get(element); } pub fn setCustomizedBuiltInDefinition(self: *Frame, element: *Element, definition: *CustomElementDefinition) !void { try self._customized_builtin_definitions.put(self.arena, element, definition); + element._flags.customized_builtin = true; } // --- Live range update methods (DOM spec §4.2.3, §4.2.4, §4.7, §4.8) --- diff --git a/src/browser/dump.zig b/src/browser/dump.zig index c1b68af57..f9761f3c5 100644 --- a/src/browser/dump.zig +++ b/src/browser/dump.zig @@ -138,7 +138,7 @@ fn _deep(node: *Node, opts: Opts, comptime force_slot: bool, writer: *std.Io.Wri switch (opts.shadow) { .skip => {}, .complete, .rendered => { - if (frame._element_shadow_roots.get(el)) |shadow| { + if (el.hostedShadowRoot(frame)) |shadow| { try children(shadow.asNode(), opts, writer, frame); // In rendered mode, light DOM is only shown through slots, not directly if (opts.shadow == .rendered) { @@ -153,7 +153,7 @@ fn _deep(node: *Node, opts: Opts, comptime force_slot: bool, writer: *std.Io.Wri } }, .declarative => |declarative| { - if (frame._element_shadow_roots.get(el)) |shadow| { + if (el.hostedShadowRoot(frame)) |shadow| { if (shouldSerializeShadow(shadow, declarative)) { try writeDeclarativeShadow(shadow, opts, writer, frame); } @@ -223,7 +223,7 @@ fn _deep(node: *Node, opts: Opts, comptime force_slot: bool, writer: *std.Io.Wri pub fn getHTML(node: *Node, declarative: Opts.Shadow.Declarative, writer: *std.Io.Writer, frame: *Frame) !void { const opts = Opts{ .shadow = .{ .declarative = declarative } }; if (node.is(Node.Element)) |el| { - if (frame._element_shadow_roots.get(el)) |shadow| { + if (el.hostedShadowRoot(frame)) |shadow| { if (shouldSerializeShadow(shadow, declarative)) { // if the element's shadowroot tree is rendered before its // children (assume the opts say that it should serialize the diff --git a/src/browser/frame/parse.zig b/src/browser/frame/parse.zig index 5ff8b9ba2..ea00bdece 100644 --- a/src/browser/frame/parse.zig +++ b/src/browser/frame/parse.zig @@ -59,7 +59,7 @@ fn htmlAsChildrenInner(frame: *Frame, node: *Node, html: []const u8, opts: Fragm slotting.assignSlottablesForTree(root, frame); } if (node.is(Element)) |el| { - if (frame._element_shadow_roots.get(el)) |shadow_root| { + if (el.hostedShadowRoot(frame)) |shadow_root| { slotting.assignSlottablesForTree(shadow_root.asNode(), frame); } } diff --git a/src/browser/markdown.zig b/src/browser/markdown.zig index 3606a4d6a..3a68e41db 100644 --- a/src/browser/markdown.zig +++ b/src/browser/markdown.zig @@ -439,7 +439,7 @@ const Context = struct { // path (cf. dump.zig's default .rendered mode), so we always pierce; the // early-return tags above can never be valid shadow hosts, so only this // generic path needs the check. - if (self.frame._element_shadow_roots.get(el)) |shadow| { + if (el.hostedShadowRoot(self.frame)) |shadow| { try self.renderChildren(shadow.asNode()); } else { try self.renderChildren(el.asNode()); diff --git a/src/browser/tests/custom_elements/shadow_tree_callbacks.html b/src/browser/tests/custom_elements/shadow_tree_callbacks.html new file mode 100644 index 000000000..a00b5ea6c --- /dev/null +++ b/src/browser/tests/custom_elements/shadow_tree_callbacks.html @@ -0,0 +1,84 @@ + + + + + diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index befc812f4..a11caf0b9 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -132,8 +132,19 @@ pub const Namespace = enum(u8) { } }; +pub const Flags = packed struct(u8) { + shadow_host: bool = false, + customized_builtin: bool = false, + _unused: u6 = 0, +}; + _type: Type, _namespace: Namespace = .html, +// Presence hints for the frame's element-keyed side tables: a set bit means +// "maybe in the map" (the map stays the authority), a clear bit skips the +// lookup. Turns the per-element map probe in tree walks into a bit test on +// memory the walk already touches. Fits in existing struct padding. +_flags: Flags = .{}, _attributes: Attribute.List = .{}, // In debug, set so that we can check that we have a proper contiguous block // of memory for the entire chain (and thus, simple pointer arithmetics will @@ -624,31 +635,6 @@ pub fn setDir(self: *Element, value: []const u8, frame: *Frame) !void { return self.setAttributeSafe(comptime .wrap("dir"), .wrap(value), frame); } -// ARIAMixin - ARIA attribute reflection -pub fn getAriaAtomic(self: *const Element) ?[]const u8 { - return self.getAttributeSafe(comptime .wrap("aria-atomic")); -} - -pub fn setAriaAtomic(self: *Element, value: ?[]const u8, frame: *Frame) !void { - if (value) |v| { - try self.setAttributeSafe(comptime .wrap("aria-atomic"), .wrap(v), frame); - } else { - try self.removeAttribute(comptime .wrap("aria-atomic"), frame); - } -} - -pub fn getAriaLive(self: *const Element) ?[]const u8 { - return self.getAttributeSafe(comptime .wrap("aria-live")); -} - -pub fn setAriaLive(self: *Element, value: ?[]const u8, frame: *Frame) !void { - if (value) |v| { - try self.setAttributeSafe(comptime .wrap("aria-live"), .wrap(v), frame); - } else { - try self.removeAttribute(comptime .wrap("aria-live"), frame); - } -} - pub fn getClassName(self: *const Element) []const u8 { return self.getAttributeSafe(comptime .wrap("class")) orelse ""; } @@ -815,7 +801,7 @@ pub fn setAttributeSafe(self: *Element, name: String, value: String, frame: *Fra } pub fn getShadowRoot(self: *Element, frame: *Frame) ?*ShadowRoot { - const shadow_root = frame._element_shadow_roots.get(self) orelse return null; + const shadow_root = self.hostedShadowRoot(frame) orelse return null; if (shadow_root._mode == .closed) return null; return shadow_root; } @@ -852,7 +838,7 @@ pub fn attachShadow(self: *Element, opts: ShadowRoot.AttachOptions, frame: *Fram } } - if (frame._element_shadow_roots.get(self)) |existing| { + if (self.hostedShadowRoot(frame)) |existing| { // Imperative attachShadow over a declarative shadow root with a matching // mode empties it and returns the same root. The parser // (opts.declarative) never replaces an existing root. @@ -866,9 +852,20 @@ pub fn attachShadow(self: *Element, opts: ShadowRoot.AttachOptions, frame: *Fram const shadow_root = try ShadowRoot.init(self, opts, frame); try frame._element_shadow_roots.put(frame.arena, self, shadow_root); + self._flags.shadow_host = true; return shadow_root; } +// The shadow root this element hosts, closed ones included (the JS-facing +// getShadowRoot filters those). The flag check skips the map probe for the +// overwhelming majority of elements, which host nothing. +pub fn hostedShadowRoot(self: *Element, frame: *const Frame) ?*ShadowRoot { + if (!self._flags.shadow_host) { + return null; + } + return frame._element_shadow_roots.get(self); +} + pub fn insertAdjacentElement( self: *Element, position: []const u8, @@ -1789,7 +1786,7 @@ pub fn clone(self: *Element, deep: bool, frame: *Frame) !*Node { // Per spec, a clonable shadow root is cloned along with its host — its // children always deep-cloned, even when the host clone is shallow. - if (frame._element_shadow_roots.get(self)) |shadow| { + if (self.hostedShadowRoot(frame)) |shadow| { if (shadow._clonable) { const cloned_shadow = node.as(Element).attachShadow(.{ .mode = shadow._mode, @@ -2362,8 +2359,50 @@ pub const JsApi = struct { pub const localName = bridge.accessor(Element.getLocalName, null, .{}); pub const id = bridge.accessor(Element.getId, Element.setId, .{ .ce_reactions = true }); pub const slot = bridge.accessor(Element.getSlot, Element.setSlot, .{ .ce_reactions = true }); - pub const ariaAtomic = bridge.accessor(Element.getAriaAtomic, Element.setAriaAtomic, .{ .ce_reactions = true }); - pub const ariaLive = bridge.accessor(Element.getAriaLive, Element.setAriaLive, .{ .ce_reactions = true }); + pub const role = ariaAccessor("role"); + pub const ariaAtomic = ariaAccessor("aria-atomic"); + pub const ariaAutoComplete = ariaAccessor("aria-autocomplete"); + pub const ariaBrailleLabel = ariaAccessor("aria-braillelabel"); + pub const ariaBrailleRoleDescription = ariaAccessor("aria-brailleroledescription"); + pub const ariaBusy = ariaAccessor("aria-busy"); + pub const ariaChecked = ariaAccessor("aria-checked"); + pub const ariaColCount = ariaAccessor("aria-colcount"); + pub const ariaColIndex = ariaAccessor("aria-colindex"); + pub const ariaColIndexText = ariaAccessor("aria-colindextext"); + pub const ariaColSpan = ariaAccessor("aria-colspan"); + pub const ariaCurrent = ariaAccessor("aria-current"); + pub const ariaDescription = ariaAccessor("aria-description"); + pub const ariaDisabled = ariaAccessor("aria-disabled"); + pub const ariaExpanded = ariaAccessor("aria-expanded"); + pub const ariaHasPopup = ariaAccessor("aria-haspopup"); + pub const ariaHidden = ariaAccessor("aria-hidden"); + pub const ariaInvalid = ariaAccessor("aria-invalid"); + pub const ariaKeyShortcuts = ariaAccessor("aria-keyshortcuts"); + pub const ariaLabel = ariaAccessor("aria-label"); + pub const ariaLevel = ariaAccessor("aria-level"); + pub const ariaLive = ariaAccessor("aria-live"); + pub const ariaModal = ariaAccessor("aria-modal"); + pub const ariaMultiLine = ariaAccessor("aria-multiline"); + pub const ariaMultiSelectable = ariaAccessor("aria-multiselectable"); + pub const ariaOrientation = ariaAccessor("aria-orientation"); + pub const ariaPlaceholder = ariaAccessor("aria-placeholder"); + pub const ariaPosInSet = ariaAccessor("aria-posinset"); + pub const ariaPressed = ariaAccessor("aria-pressed"); + pub const ariaReadOnly = ariaAccessor("aria-readonly"); + pub const ariaRelevant = ariaAccessor("aria-relevant"); + pub const ariaRequired = ariaAccessor("aria-required"); + pub const ariaRoleDescription = ariaAccessor("aria-roledescription"); + pub const ariaRowCount = ariaAccessor("aria-rowcount"); + pub const ariaRowIndex = ariaAccessor("aria-rowindex"); + pub const ariaRowIndexText = ariaAccessor("aria-rowindextext"); + pub const ariaRowSpan = ariaAccessor("aria-rowspan"); + pub const ariaSelected = ariaAccessor("aria-selected"); + pub const ariaSetSize = ariaAccessor("aria-setsize"); + pub const ariaSort = ariaAccessor("aria-sort"); + pub const ariaValueMax = ariaAccessor("aria-valuemax"); + pub const ariaValueMin = ariaAccessor("aria-valuemin"); + pub const ariaValueNow = ariaAccessor("aria-valuenow"); + pub const ariaValueText = ariaAccessor("aria-valuetext"); pub const dir = bridge.accessor(Element.getDir, Element.setDir, .{ .ce_reactions = true }); pub const className = bridge.accessor(Element.getClassName, Element.setClassName, .{ .ce_reactions = true }); pub const classList = bridge.accessor(Element.getClassList, Element.setClassList, .{ .ce_reactions = true }); @@ -2463,6 +2502,23 @@ pub const JsApi = struct { pub const scroll = bridge.function(Element.scrollTo, .{}); pub const scrollTo = bridge.function(Element.scrollTo, .{}); pub const scrollBy = bridge.function(Element.scrollBy, .{}); + + fn ariaAccessor(comptime attr: []const u8) js.bridge.Accessor { + const R = struct { + pub fn get(self: *const Element) ?[]const u8 { + return self.getAttributeSafe(.wrap(attr)); + } + + pub fn set(self: *Element, value: ?[]const u8, frame: *Frame) !void { + if (value) |v| { + try self.setAttributeSafe(.wrap(attr), .wrap(v), frame); + } else { + try self.removeAttribute(.wrap(attr), frame); + } + } + }; + return bridge.accessor(R.get, R.set, .{ .ce_reactions = true }); + } }; pub const Build = struct { @@ -2502,3 +2558,11 @@ const testing = @import("../../testing.zig"); test "WebApi: Element" { try testing.htmlRunner("element", .{}); } + +test "Element: div chain slot size" { + // Guard against accidental growth: new Element fields (e.g. _flags) must + // fit in existing padding. Debug is larger from the _proto_canary fields. + const Div = @import("element/html/Div.zig"); + const slot = comptime Factory.chainOffsetOf(Div, Div) + @sizeOf(Div); + try testing.expectEqual(if (comptime lp.IS_DEBUG) 120 else 74, slot); +} diff --git a/src/browser/webapi/element/html/Custom.zig b/src/browser/webapi/element/html/Custom.zig index 153657bef..409f8800e 100644 --- a/src/browser/webapi/element/html/Custom.zig +++ b/src/browser/webapi/element/html/Custom.zig @@ -25,6 +25,7 @@ const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); +const TreeWalker = @import("../../TreeWalker.zig"); const Document = @import("../../Document.zig"); const HtmlElement = @import("../Html.zig"); const CustomElementDefinition = @import("../../CustomElementDefinition.zig"); @@ -142,16 +143,40 @@ pub fn enqueueDisconnectedCallbackOnElement(element: *Element, frame: *Frame) vo // Enqueues an atomic-move reaction (moveBefore). Unlike connect/disconnect there // is no dedup state to flip: a move always fires, and the element's connected -// state is unchanged by the move. +// state is unchanged by the move. The element's shadow tree (if any) always +// moves with it. pub fn enqueueMoveCallbackOnElement(element: *Element, frame: *Frame) void { - if (element.is(Custom)) |custom| { - if (custom._definition == null) return; - } else { - if (frame.getCustomizedBuiltInDefinition(element) == null) return; + const eligible = if (element.is(Custom)) |custom| + custom._definition != null + else + frame.getCustomizedBuiltInDefinition(element) != null; + + if (eligible) { + frame._ce_reactions.enqueueMove(frame, element) catch |err| { + log.warn(.bug, "ce_reactions enqueue fail", .{ .err = err }); + }; + } + + const shadow_root = element.hostedShadowRoot(frame) orelse return; + var tw = TreeWalker.FullExcludeSelf.Elements.init(shadow_root.asNode(), .{}); + while (tw.next()) |el| { + enqueueMoveCallbackOnElement(el, frame); + } +} + +// Reactions descend through the shadodom, so when an element is connected or +// disconnected, we need to enqueue the connect/disconnect callback for any +// nested element including those nested in a shadow root. +pub fn enqueueShadowTreeCallbacks(host: *Element, comptime reaction: enum { connected, disconnected }, frame: *Frame) error{OutOfMemory}!void { + const shadow_root = host.hostedShadowRoot(frame) orelse return; + var tw = TreeWalker.FullExcludeSelf.Elements.init(shadow_root.asNode(), .{}); + while (tw.next()) |el| { + switch (comptime reaction) { + .connected => try enqueueConnectedCallbackOnElement(false, el, frame), + .disconnected => enqueueDisconnectedCallbackOnElement(el, frame), + } + try enqueueShadowTreeCallbacks(el, reaction, frame); } - frame._ce_reactions.enqueueMove(frame, element) catch |err| { - log.warn(.bug, "ce_reactions enqueue fail", .{ .err = err }); - }; } pub fn enqueueAdoptedCallbackOnElement(element: *Element, old_document: *Document, new_document: *Document, frame: *Frame) void { diff --git a/src/browser/webapi/element/slotting.zig b/src/browser/webapi/element/slotting.zig index 1e26ca1d6..8acbfcd88 100644 --- a/src/browser/webapi/element/slotting.zig +++ b/src/browser/webapi/element/slotting.zig @@ -41,7 +41,7 @@ pub fn isSlottable(node: *Node) bool { pub fn findSlot(slottable: *Node, comptime open_only: bool, frame: *Frame) ?*Slot { const parent = slottable.parentElement() orelse return null; - const shadow_root = frame._element_shadow_roots.get(parent) orelse return null; + const shadow_root = parent.hostedShadowRoot(frame) orelse return null; if (open_only and shadow_root._mode != .open) { return null; @@ -172,7 +172,7 @@ fn subtreeHasSlot(node: *Node) bool { pub fn insertionSteps(parent: *Node, child: *Node, in_fragment_parse: bool, frame: *Frame) void { // The new child may be a slottable to assign in the parent's shadow tree. if (parent.is(Element)) |parent_el| { - if (frame._element_shadow_roots.get(parent_el) != null and isSlottable(child)) { + if (parent_el.hostedShadowRoot(frame) != null and isSlottable(child)) { assignASlot(child, frame); } }