Merge pull request #3195 from lightpanda-io/ce-reaction-shadowdom-pierce

webapi: Custom Element reactions need pierce the shadowdom
This commit is contained in:
Karl Seguin authored and GitHub committed 2026-08-15 07:36:39 +08:00
commit be7b87f3b5
8 files changed
+247 -48

No files matched your search

+29 -3
View File
@@ -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) ---
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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);
}
}
+1 -1
View File
@@ -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());
@@ -0,0 +1,84 @@
<!DOCTYPE html>
<body>
<script src="../testing.js"></script>
<script id="shadow_tree_callbacks">
{
let calls = [];
class MyElement extends HTMLElement {
connectedCallback() { calls.push('connected'); }
adoptedCallback(oldDoc, newDoc) {
calls.push('adopted');
calls.push(oldDoc === document);
calls.push(newDoc === otherDoc);
}
disconnectedCallback() { calls.push('disconnected'); }
}
customElements.define('my-shadowed-element', MyElement);
const otherDoc = document.implementation.createHTMLDocument('other');
function makeHost(mode) {
const host = document.createElement('div');
const shadow = host.attachShadow({mode: mode});
shadow.appendChild(document.createElement('my-shadowed-element'));
return host;
}
// Case 1: inserting a host fires connectedCallback inside its shadow tree.
{
const host = makeHost('open');
calls = [];
document.body.appendChild(host);
testing.expectEqual('connected', calls.join(','));
// and removing it fires disconnectedCallback.
calls = [];
host.remove();
testing.expectEqual('disconnected', calls.join(','));
}
// Case 2: closed shadow trees get the callbacks too.
{
const host = makeHost('closed');
calls = [];
document.body.appendChild(host);
host.remove();
testing.expectEqual('connected,disconnected', calls.join(','));
}
// Case 3: nested shadow trees (a host inside another host's shadow tree).
{
const outer = document.createElement('div');
outer.attachShadow({mode: 'open'}).appendChild(makeHost('open'));
calls = [];
document.body.appendChild(outer);
outer.remove();
testing.expectEqual('connected,disconnected', calls.join(','));
}
// Case 4: adopting a connected host into another document runs the shadow
// tree's adopted (and disconnected/connected) reactions, and re-owns it.
{
const host = makeHost('open');
const inner = host.shadowRoot.firstChild;
document.body.appendChild(host);
calls = [];
otherDoc.body.appendChild(host);
testing.expectEqual('disconnected,adopted,true,true,connected', calls.join(','));
testing.expectEqual(otherDoc, inner.ownerDocument);
}
// Case 5: inserting a shadow root (a DocumentFragment) into another
// document moves and adopts its children.
{
const host = makeHost('open');
const shadow = host.shadowRoot;
document.body.appendChild(host);
calls = [];
otherDoc.body.appendChild(shadow);
testing.expectEqual('disconnected,adopted,true,true,connected', calls.join(','));
}
}
</script>
</body>
+94 -30
View File
@@ -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);
}
+33 -8
View File
@@ -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 {
+2 -2
View File
@@ -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);
}
}