From afaf105cb0b19467381d6a28c31c45c5f40bf6b3 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 20 Nov 2025 19:41:14 +0800 Subject: [PATCH] ShadowRoot --- src/browser/Factory.zig | 10 + src/browser/Page.zig | 72 +++++-- src/browser/js/bridge.zig | 1 + src/browser/tests/element/closest.html | 94 +++++++++ src/browser/tests/shadowroot/basic.html | 156 +++++++++++++++ .../tests/shadowroot/custom_elements.html | 138 +++++++++++++ .../tests/shadowroot/dom_traversal.html | 142 +++++++++++++ src/browser/tests/shadowroot/edge_cases.html | 186 ++++++++++++++++++ src/browser/tests/shadowroot/events.html | 85 ++++++++ .../tests/shadowroot/id_collision.html | 55 ++++++ .../tests/shadowroot/id_management.html | 167 ++++++++++++++++ src/browser/tests/shadowroot/scoping.html | 92 +++++++++ src/browser/webapi/DocumentFragment.zig | 52 +++++ src/browser/webapi/Element.zig | 50 +++++ src/browser/webapi/Node.zig | 17 +- src/browser/webapi/ShadowRoot.zig | 97 +++++++++ src/browser/webapi/element/Attribute.zig | 28 ++- src/browser/webapi/element/html/Custom.zig | 1 - src/browser/webapi/selector/List.zig | 2 +- src/browser/webapi/selector/Selector.zig | 2 +- 20 files changed, 1417 insertions(+), 30 deletions(-) create mode 100644 src/browser/tests/element/closest.html create mode 100644 src/browser/tests/shadowroot/basic.html create mode 100644 src/browser/tests/shadowroot/custom_elements.html create mode 100644 src/browser/tests/shadowroot/dom_traversal.html create mode 100644 src/browser/tests/shadowroot/edge_cases.html create mode 100644 src/browser/tests/shadowroot/events.html create mode 100644 src/browser/tests/shadowroot/id_collision.html create mode 100644 src/browser/tests/shadowroot/id_management.html create mode 100644 src/browser/tests/shadowroot/scoping.html create mode 100644 src/browser/webapi/ShadowRoot.zig diff --git a/src/browser/Factory.zig b/src/browser/Factory.zig index d4f7428b..89f76f42 100644 --- a/src/browser/Factory.zig +++ b/src/browser/Factory.zig @@ -121,6 +121,16 @@ pub fn document(self: *Factory, child: anytype) !*@TypeOf(child) { return child_ptr; } +pub fn documentFragment(self: *Factory, child: anytype) !*@TypeOf(child) { + const child_ptr = try self.createT(@TypeOf(child)); + child_ptr.* = child; + child_ptr._proto = try self.node(Node.DocumentFragment{ + ._proto = undefined, + ._type = unionInit(Node.DocumentFragment.Type, child_ptr), + }); + return child_ptr; +} + pub fn element(self: *Factory, child: anytype) !*@TypeOf(child) { const child_ptr = try self.createT(@TypeOf(child)); child_ptr.* = child; diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 33e1e565..079e4006 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -50,6 +50,8 @@ const Element = @import("webapi/Element.zig"); const Window = @import("webapi/Window.zig"); const Location = @import("webapi/Location.zig"); const Document = @import("webapi/Document.zig"); +const DocumentFragment = @import("webapi/DocumentFragment.zig"); +const ShadowRoot = @import("webapi/ShadowRoot.zig"); const Performance = @import("webapi/Performance.zig"); const HtmlScript = @import("webapi/Element.zig").Html.Script; const MutationObserver = @import("webapi/MutationObserver.zig"); @@ -91,6 +93,7 @@ _attribute_named_node_map_lookup: std.AutoHashMapUnmanaged(usize, *Element.Attri _element_styles: Element.StyleLookup = .{}, _element_datasets: Element.DatasetLookup = .{}, _element_class_lists: Element.ClassListLookup = .{}, +_element_shadow_roots: Element.ShadowRootLookup = .{}, _script_manager: ScriptManager, @@ -211,6 +214,7 @@ fn reset(self: *Page, comptime initializing: bool) !void { self._element_styles = .{}; self._element_datasets = .{}; self._element_class_lists = .{}; + self._element_shadow_roots = .{}; self._notified_network_idle = .init; self._notified_network_almost_idle = .init; @@ -714,6 +718,39 @@ pub fn domChanged(self: *Page) void { self.version += 1; } +pub fn getElementIdMap(page: *Page, node: *Node) *std.StringHashMapUnmanaged(*Element) { + if (node.is(ShadowRoot)) |shadow_root| { + return &shadow_root._elements_by_id; + } + + var parent = node._parent; + while (parent) |n| { + if (n.is(ShadowRoot)) |shadow_root| { + return &shadow_root._elements_by_id; + } + parent = n._parent; + } + return &page.document._elements_by_id; +} + +pub fn addElementId(self: *Page, parent: *Node, element: *Element, id: []const u8) !void { + var id_map = self.getElementIdMap(parent); + const gop = try id_map.getOrPut(self.arena, id); + if (!gop.found_existing) { + gop.value_ptr.* = element; + } +} + +pub fn removeElementId(self: *Page, element: *Element, id: []const u8) void { + var id_map = self.getElementIdMap(element.asNode()); + _ = id_map.remove(id); +} + +pub fn getElementByIdFromNode(self: *Page, node: *Node, id: []const u8) ?*Element { + const id_map = self.getElementIdMap(node); + return id_map.get(id); +} + pub fn registerMutationObserver(self: *Page, observer: *MutationObserver) !void { try self._mutation_observers.append(self.arena, observer); } @@ -1314,12 +1351,11 @@ pub fn removeNode(self: *Page, parent: *Node, child: *Node, opts: RemoveNodeOpts // The child was connected and now it no longer is. We need to "disconnect" // it and all of its descendants. For now "disconnect" just means updating - // document._elements_by_id and invoking disconnectedCallback for custom elements - var elements_by_id = &self.document._elements_by_id; + // the ID map and invoking disconnectedCallback for custom elements var tw = @import("webapi/TreeWalker.zig").Full.Elements.init(child, .{}); while (tw.next()) |el| { if (el.getAttributeSafe("id")) |id| { - _ = elements_by_id.remove(id); + self.removeElementId(el, id); } Element.Html.Custom.invokeDisconnectedCallbackOnElement(el, self); @@ -1427,15 +1463,10 @@ pub fn _insertNodeRelative(self: *Page, comptime from_parser: bool, parent: *Nod } } - var document_by_id = &self.document._elements_by_id; - if (comptime from_parser) { if (child.is(Element)) |el| { if (el.getAttributeSafe("id")) |id| { - const gop = try document_by_id.getOrPut(self.arena, id); - if (!gop.found_existing) { - gop.value_ptr.* = el; - } + try self.addElementId(parent, el, id); } } return; @@ -1446,24 +1477,27 @@ pub fn _insertNodeRelative(self: *Page, comptime from_parser: bool, parent: *Nod return; } - if (parent.isConnected() == false) { - // The parent isn't connected, we don't have to connect the child + const parent_in_shadow = parent.is(ShadowRoot) != null or parent.isInShadowTree(); + const parent_is_connected = parent.isConnected(); + + if (!parent_in_shadow and !parent_is_connected) { return; } - // If we're here, it means that a disconnected child became connected. We - // need to connect it (and all of its descendants) - + // If we're here, it means either: + // 1. A disconnected child became connected (parent.isConnected() == true) + // 2. Child is being added to a shadow tree (parent_in_shadow == true) + // In both cases, we need to update ID maps and invoke callbacks var tw = @import("webapi/TreeWalker.zig").Full.Elements.init(child, .{}); while (tw.next()) |el| { if (el.getAttributeSafe("id")) |id| { - const gop = try document_by_id.getOrPut(self.arena, id); - if (!gop.found_existing) { - gop.value_ptr.* = el; - } + try self.addElementId(el.asNode()._parent.?, el, id); } - Element.Html.Custom.invokeConnectedCallbackOnElement(el, self); + // Only invoke connected callback if actually connected to document + if (parent_is_connected) { + Element.Html.Custom.invokeConnectedCallbackOnElement(el, self); + } } } diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index bd75bee8..984d68c9 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -483,6 +483,7 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/KeyValueList.zig"), @import("../webapi/DocumentFragment.zig"), @import("../webapi/DocumentType.zig"), + @import("../webapi/ShadowRoot.zig"), @import("../webapi/DOMException.zig"), @import("../webapi/DOMImplementation.zig"), @import("../webapi/DOMTreeWalker.zig"), diff --git a/src/browser/tests/element/closest.html b/src/browser/tests/element/closest.html new file mode 100644 index 00000000..65c1a373 --- /dev/null +++ b/src/browser/tests/element/closest.html @@ -0,0 +1,94 @@ + + + + +
+
+
+

Content

+
+
+
+ + + + + + + + + + + + diff --git a/src/browser/tests/shadowroot/basic.html b/src/browser/tests/shadowroot/basic.html new file mode 100644 index 00000000..ed82ab18 --- /dev/null +++ b/src/browser/tests/shadowroot/basic.html @@ -0,0 +1,156 @@ + + + +
+
+
+ + + + diff --git a/src/browser/tests/shadowroot/custom_elements.html b/src/browser/tests/shadowroot/custom_elements.html new file mode 100644 index 00000000..32a7ee31 --- /dev/null +++ b/src/browser/tests/shadowroot/custom_elements.html @@ -0,0 +1,138 @@ + + + + + + + + + + + + + diff --git a/src/browser/tests/shadowroot/dom_traversal.html b/src/browser/tests/shadowroot/dom_traversal.html new file mode 100644 index 00000000..a1b7c7c7 --- /dev/null +++ b/src/browser/tests/shadowroot/dom_traversal.html @@ -0,0 +1,142 @@ + + + + + + + + + + + + + + + + + diff --git a/src/browser/tests/shadowroot/edge_cases.html b/src/browser/tests/shadowroot/edge_cases.html new file mode 100644 index 00000000..3412e110 --- /dev/null +++ b/src/browser/tests/shadowroot/edge_cases.html @@ -0,0 +1,186 @@ + + + +
+ + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/tests/shadowroot/events.html b/src/browser/tests/shadowroot/events.html new file mode 100644 index 00000000..46285cb2 --- /dev/null +++ b/src/browser/tests/shadowroot/events.html @@ -0,0 +1,85 @@ + + + + + + + diff --git a/src/browser/tests/shadowroot/id_collision.html b/src/browser/tests/shadowroot/id_collision.html new file mode 100644 index 00000000..d8389249 --- /dev/null +++ b/src/browser/tests/shadowroot/id_collision.html @@ -0,0 +1,55 @@ + + + +
Document
+ + + + + diff --git a/src/browser/tests/shadowroot/id_management.html b/src/browser/tests/shadowroot/id_management.html new file mode 100644 index 00000000..0d498d3e --- /dev/null +++ b/src/browser/tests/shadowroot/id_management.html @@ -0,0 +1,167 @@ + + + + + + + + + + + + + + + diff --git a/src/browser/tests/shadowroot/scoping.html b/src/browser/tests/shadowroot/scoping.html new file mode 100644 index 00000000..37caaeb1 --- /dev/null +++ b/src/browser/tests/shadowroot/scoping.html @@ -0,0 +1,92 @@ + + + + + + + + + + + diff --git a/src/browser/webapi/DocumentFragment.zig b/src/browser/webapi/DocumentFragment.zig index 9879f766..f7b20878 100644 --- a/src/browser/webapi/DocumentFragment.zig +++ b/src/browser/webapi/DocumentFragment.zig @@ -22,15 +22,39 @@ const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); const Node = @import("Node.zig"); const Element = @import("Element.zig"); +const ShadowRoot = @import("ShadowRoot.zig"); const collections = @import("collections.zig"); const Selector = @import("selector/Selector.zig"); const DocumentFragment = @This(); +_type: Type, _proto: *Node, +pub const Type = union(enum) { + generic, + shadow_root: *ShadowRoot, +}; + +pub fn is(self: *DocumentFragment, comptime T: type) ?*T { + switch (self._type) { + .shadow_root => |shadow_root| { + if (T == ShadowRoot) { + return shadow_root; + } + }, + .generic => {}, + } + return null; +} + +pub fn as(self: *DocumentFragment, comptime T: type) *T { + return self.is(T).?; +} + pub fn init(page: *Page) !*DocumentFragment { return page._factory.node(DocumentFragment{ + ._type = .generic, ._proto = undefined, }); } @@ -136,6 +160,27 @@ pub fn replaceChildren(self: *DocumentFragment, nodes: []const Node.NodeOrText, } } +pub fn getInnerHTML(self: *DocumentFragment, writer: *std.Io.Writer) !void { + const dump = @import("../dump.zig"); + return dump.children(self.asNode(), .{}, writer); +} + +pub fn setInnerHTML(self: *DocumentFragment, html: []const u8, page: *Page) !void { + const parent = self.asNode(); + + page.domChanged(); + var it = parent.childrenIterator(); + while (it.next()) |child| { + page.removeNode(parent, child, .{ .will_be_reconnected = false }); + } + + if (html.len == 0) { + return; + } + + try page.parseHtmlAsChildren(parent, html); +} + pub fn cloneFragment(self: *DocumentFragment, deep: bool, page: *Page) !*Node { const fragment = try DocumentFragment.init(page); const fragment_node = fragment.asNode(); @@ -175,6 +220,13 @@ pub const JsApi = struct { pub const append = bridge.function(DocumentFragment.append, .{}); pub const prepend = bridge.function(DocumentFragment.prepend, .{}); pub const replaceChildren = bridge.function(DocumentFragment.replaceChildren, .{}); + pub const innerHTML = bridge.accessor(_innerHTML, DocumentFragment.setInnerHTML, .{}); + + fn _innerHTML(self: *DocumentFragment, page: *Page) ![]const u8 { + var buf = std.Io.Writer.Allocating.init(page.call_arena); + try self.getInnerHTML(&buf.writer); + return buf.written(); + } }; const testing = @import("../../testing.zig"); diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index 7b727a21..6f5f15aa 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -33,6 +33,7 @@ const CSSStyleProperties = @import("css/CSSStyleProperties.zig"); pub const DOMStringMap = @import("element/DOMStringMap.zig"); const DOMRect = @import("DOMRect.zig"); const css = @import("css.zig"); +const ShadowRoot = @import("ShadowRoot.zig"); pub const Svg = @import("element/Svg.zig"); pub const Html = @import("element/Html.zig"); @@ -42,6 +43,7 @@ const Element = @This(); pub const DatasetLookup = std.AutoHashMapUnmanaged(*Element, *DOMStringMap); pub const StyleLookup = std.AutoHashMapUnmanaged(*Element, *CSSStyleProperties); pub const ClassListLookup = std.AutoHashMapUnmanaged(*Element, *collections.DOMTokenList); +pub const ShadowRootLookup = std.AutoHashMapUnmanaged(*Element, *ShadowRoot); pub const Namespace = enum(u8) { html, @@ -311,6 +313,22 @@ fn getOrCreateAttributeList(self: *Element, page: *Page) !*Attribute.List { }; } +pub fn getShadowRoot(self: *Element, page: *Page) ?*ShadowRoot { + const shadow_root = page._element_shadow_roots.get(self) orelse return null; + if (shadow_root._mode == .closed) return null; + return shadow_root; +} + +pub fn attachShadow(self: *Element, mode_str: []const u8, page: *Page) !*ShadowRoot { + if (page._element_shadow_roots.get(self)) |_| { + return error.AlreadyHasShadowRoot; + } + const mode = try ShadowRoot.Mode.fromString(mode_str); + const shadow_root = try ShadowRoot.init(self, mode, page); + try page._element_shadow_roots.put(page.arena, self, shadow_root); + return shadow_root; +} + pub fn setAttributeNode(self: *Element, attr: *Attribute, page: *Page) !?*Attribute { if (attr._element) |el| { if (el == self) { @@ -522,6 +540,28 @@ pub fn querySelectorAll(self: *Element, input: []const u8, page: *Page) !*Select return Selector.querySelectorAll(self.asNode(), input, page); } +pub fn closest(self: *Element, selector: []const u8, page: *Page) !?*Element { + if (selector.len == 0) { + return error.SyntaxError; + } + + var current: ?*Element = self; + while (current) |el| { + if (try el.matches(selector, page)) { + return el; + } + + const parent = el._proto._parent orelse break; + + if (parent.is(ShadowRoot) != null) { + break; + } + + current = parent.is(Element); + } + return null; +} + pub fn parentElement(self: *Element) ?*Element { return self._proto.parentElement(); } @@ -867,6 +907,15 @@ pub const JsApi = struct { pub const removeAttribute = bridge.function(Element.removeAttribute, .{}); pub const getAttributeNames = bridge.function(Element.getAttributeNames, .{}); pub const removeAttributeNode = bridge.function(Element.removeAttributeNode, .{ .dom_exception = true }); + pub const shadowRoot = bridge.accessor(Element.getShadowRoot, null, .{}); + pub const attachShadow = bridge.function(_attachShadow, .{ .dom_exception = true }); + + const ShadowRootInit = struct { + mode: []const u8, + }; + fn _attachShadow(self: *Element, init: ShadowRootInit, page: *Page) !*ShadowRoot { + return self.attachShadow(init.mode, page); + } pub const replaceChildren = bridge.function(Element.replaceChildren, .{}); pub const remove = bridge.function(Element.remove, .{}); pub const append = bridge.function(Element.append, .{}); @@ -879,6 +928,7 @@ pub const JsApi = struct { pub const matches = bridge.function(Element.matches, .{ .dom_exception = true }); pub const querySelector = bridge.function(Element.querySelector, .{ .dom_exception = true }); pub const querySelectorAll = bridge.function(Element.querySelectorAll, .{ .dom_exception = true }); + pub const closest = bridge.function(Element.closest, .{ .dom_exception = true }); pub const checkVisibility = bridge.function(Element.checkVisibility, .{}); pub const getBoundingClientRect = bridge.function(Element.getBoundingClientRect, .{}); pub const getElementsByTagName = bridge.function(Element.getElementsByTagName, .{}); diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index 73c59476..9ae1ec48 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -33,6 +33,7 @@ pub const HTMLDocument = @import("HTMLDocument.zig"); pub const Children = @import("children.zig").Children; pub const DocumentFragment = @import("DocumentFragment.zig"); pub const DocumentType = @import("DocumentType.zig"); +pub const ShadowRoot = @import("ShadowRoot.zig"); const Allocator = std.mem.Allocator; const LinkedList = std.DoublyLinkedList; @@ -106,6 +107,9 @@ pub fn is(self: *Node, comptime T: type) ?*T { if (T == DocumentFragment) { return doc; } + if (T == ShadowRoot) { + return doc.is(ShadowRoot); + } }, } return null; @@ -191,7 +195,7 @@ pub fn setTextContent(self: *Node, data: []const u8, page: *Page) !void { .cdata => |c| c._data = try page.arena.dupe(u8, data), .document => {}, .document_type => {}, - .document_fragment => {}, + .document_fragment => |frag| return frag.replaceChildren(&.{.{ .text = data }}, page), .attribute => |attr| return attr.setValue(data, page), } } @@ -224,6 +228,17 @@ pub fn nodeType(self: *const Node) u8 { }; } +pub fn isInShadowTree(self: *Node) bool { + var node = self._parent; + while (node) |n| { + if (n.is(ShadowRoot) != null) { + return true; + } + node = n._parent; + } + return false; +} + pub fn isConnected(self: *const Node) bool { const target = Page.current.document.asNode(); if (self == target) { diff --git a/src/browser/webapi/ShadowRoot.zig b/src/browser/webapi/ShadowRoot.zig new file mode 100644 index 00000000..9fb11c07 --- /dev/null +++ b/src/browser/webapi/ShadowRoot.zig @@ -0,0 +1,97 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const js = @import("../js/js.zig"); + +const Page = @import("../Page.zig"); +const Node = @import("Node.zig"); +const DocumentFragment = @import("DocumentFragment.zig"); +const Element = @import("Element.zig"); + +const ShadowRoot = @This(); + +pub const Mode = enum { + open, + closed, + + pub fn fromString(str: []const u8) !Mode { + return std.meta.stringToEnum(Mode, str) orelse error.InvalidMode; + } +}; + +_proto: *DocumentFragment, +_mode: Mode, +_host: *Element, +_elements_by_id: std.StringHashMapUnmanaged(*Element) = .{}, + +pub fn init(host: *Element, mode: Mode, page: *Page) !*ShadowRoot { + return page._factory.documentFragment(ShadowRoot{ + ._proto = undefined, + ._mode = mode, + ._host = host, + }); +} + +pub fn asDocumentFragment(self: *ShadowRoot) *DocumentFragment { + return self._proto; +} + +pub fn asNode(self: *ShadowRoot) *Node { + return self._proto.asNode(); +} + +pub fn asEventTarget(self: *ShadowRoot) *@import("EventTarget.zig") { + return self.asNode().asEventTarget(); +} + +pub fn className(_: *const ShadowRoot) []const u8 { + return "[object ShadowRoot]"; +} + +pub fn getMode(self: *const ShadowRoot) []const u8 { + return @tagName(self._mode); +} + +pub fn getHost(self: *const ShadowRoot) *Element { + return self._host; +} + +pub fn getElementById(self: *ShadowRoot, id_: ?[]const u8) ?*Element { + const id = id_ orelse return null; + return self._elements_by_id.get(id); +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(ShadowRoot); + + pub const Meta = struct { + pub const name = "ShadowRoot"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const mode = bridge.accessor(ShadowRoot.getMode, null, .{}); + pub const host = bridge.accessor(ShadowRoot.getHost, null, .{}); + pub const getElementById = bridge.function(ShadowRoot.getElementById, .{}); +}; + +const testing = @import("../../testing.zig"); +test "WebApi: ShadowRoot" { + try testing.htmlRunner("shadowroot", .{}); +} diff --git a/src/browser/webapi/element/Attribute.zig b/src/browser/webapi/element/Attribute.zig index c07c68dd..46e5705e 100644 --- a/src/browser/webapi/element/Attribute.zig +++ b/src/browser/webapi/element/Attribute.zig @@ -153,14 +153,14 @@ pub const List = struct { } fn _put(self: *List, result: NormalizeAndEntry, value: []const u8, element: *Element, page: *Page) !*Entry { - const is_id = isIdForConnected(result.normalized, element); + const is_id = shouldAddToIdMap(result.normalized, element); var entry: *Entry = undefined; var old_value: ?[]const u8 = null; if (result.entry) |e| { old_value = try page.call_arena.dupe(u8, e._value.str()); if (is_id) { - _ = page.document._elements_by_id.remove(e._value.str()); + page.removeElementId(element, e._value.str()); } e._value = try String.init(page.arena, value, .{}); entry = e; @@ -174,7 +174,11 @@ pub const List = struct { } if (is_id) { - try page.document._elements_by_id.put(page.arena, entry._value.str(), element); + const parent = element.asNode()._parent orelse { + std.debug.assert(false); + return entry; + }; + try page.addElementId(parent, element, entry._value.str()); } page.attributeChange(element, result.normalized, entry._value.str(), old_value); return entry; @@ -227,11 +231,11 @@ pub const List = struct { const result = try self.getEntryAndNormalizedName(name, page); const entry = result.entry orelse return; - const is_id = isIdForConnected(result.normalized, element); + const is_id = shouldAddToIdMap(result.normalized, element); const old_value = entry._value.str(); if (is_id) { - _ = page.document._elements_by_id.remove(entry._value.str()); + page.removeElementId(element, entry._value.str()); } page.attributeRemove(element, result.normalized, old_value); @@ -312,8 +316,18 @@ pub const List = struct { }; }; -fn isIdForConnected(normalized_id: []const u8, element: *const Element) bool { - return std.mem.eql(u8, normalized_id, "id") and element.asConstNode().isConnected(); +fn shouldAddToIdMap(normalized_name: []const u8, element: *Element) bool { + if (!std.mem.eql(u8, normalized_name, "id")) { + return false; + } + + const node = element.asNode(); + // Shadow tree elements are always added to their shadow root's map + if (node.isInShadowTree()) { + return true; + } + // Document tree elements only when connected + return node.isConnected(); } pub fn normalizeNameForLookup(name: []const u8, page: *Page) ![]const u8 { diff --git a/src/browser/webapi/element/html/Custom.zig b/src/browser/webapi/element/html/Custom.zig index abf8a341..31e46bcf 100644 --- a/src/browser/webapi/element/html/Custom.zig +++ b/src/browser/webapi/element/html/Custom.zig @@ -132,7 +132,6 @@ pub fn checkAndAttachBuiltIn(element: *Element, page: *Page) !void { }; } - fn invokeCallback(self: *Custom, comptime callback_name: [:0]const u8, args: anytype, page: *Page) void { if (self._definition == null) { return; diff --git a/src/browser/webapi/selector/List.zig b/src/browser/webapi/selector/List.zig index 449fc70b..8f91d8ec 100644 --- a/src/browser/webapi/selector/List.zig +++ b/src/browser/webapi/selector/List.zig @@ -106,7 +106,7 @@ fn optimizeSelector(root: *Node, selector: *const Selector.Selector, page: *Page const segment_index = anchor.segment_index; // Look up the element by ID (O(1) hash map lookup) - const id_element = page.document._elements_by_id.get(id) orelse return null; + const id_element = page.getElementByIdFromNode(root, id) orelse return null; const id_node = id_element.asNode(); if (!root.contains(id_node)) { diff --git a/src/browser/webapi/selector/Selector.zig b/src/browser/webapi/selector/Selector.zig index 3f72c442..bed15801 100644 --- a/src/browser/webapi/selector/Selector.zig +++ b/src/browser/webapi/selector/Selector.zig @@ -36,7 +36,7 @@ pub fn querySelector(root: *Node, input: []const u8, page: *Page) !?*Node.Elemen if (selector.segments.len == 0 and selector.first.parts.len == 1) { const first = selector.first.parts[0]; if (first == .id) { - const el = page.document._elements_by_id.get(first.id) orelse continue; + const el = page.getElementByIdFromNode(root, first.id) orelse continue; // Check if the element is within the root subtree if (root.contains(el.asNode())) { return el;