diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index afa97ae91..81a380575 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -2497,6 +2497,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(); @@ -2632,6 +2638,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); @@ -2704,19 +2713,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 }); } } @@ -2906,6 +2927,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); } } } @@ -2977,11 +2999,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); } }