From caeb359f75ec56e5cd488e388185d3f939d08115 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 10 Jun 2026 15:28:11 +0800 Subject: [PATCH] wpt, shadowdom: Improve shadowdom This was driven by various WPT tests in the /shadow-dom/ category. There are 4 distinct changes. 1. Template hooks into "cloned" to include the _content. This change required passing `deep: bool` which is why TextArea and Input are also changed (they ignore that new parameter) 2. Node.getRootNode and Node.ownerDocument will now traverse through the ShadowRoot to find the root/document 3. DOM events won't gain the Window when triggered from within a ShadowRoot 4. attachShadow now takes a full option, not just a string mode. This also touched a few different places since it's called internally too. --- src/browser/EventManager.zig | 43 ++++++-- src/browser/EventManagerBase.zig | 3 + src/browser/markdown.zig | 2 +- src/browser/parser/Parser.zig | 15 ++- src/browser/tests/element/html/template.html | 22 ++++ src/browser/tests/shadowroot/declarative.html | 93 ++++++++++++++++ src/browser/tests/shadowroot/events.html | 45 ++++++++ src/browser/tests/shadowroot/scoping.html | 16 +++ src/browser/tests/window/report_error.html | 3 +- src/browser/webapi/Element.zig | 72 ++++++++++--- src/browser/webapi/Event.zig | 26 +++-- src/browser/webapi/Node.zig | 12 ++- src/browser/webapi/ShadowRoot.zig | 48 ++++++++- src/browser/webapi/element/html/Input.zig | 3 +- src/browser/webapi/element/html/Script.zig | 3 +- src/browser/webapi/element/html/Template.zig | 100 +++++++++++++++++- src/browser/webapi/element/html/TextArea.zig | 3 +- 17 files changed, 467 insertions(+), 42 deletions(-) diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig index b88b6d04..e6b96de6 100644 --- a/src/browser/EventManager.zig +++ b/src/browser/EventManager.zig @@ -176,12 +176,21 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts const activation_state = try ActivationState.create(event, target, frame); + var path_len: usize = 0; + var node_path_len: usize = 0; + var path_buffer: [128]*EventTarget = undefined; + // Defer runs even on early return - ensures event phase is reset // and default actions execute (unless prevented) defer { event._event_phase = .none; + event._current_target = null; event._stop_propagation = false; event._stop_immediate_propagation = false; + if (event._needs_retargeting and node_path_len > 0) { + const adjusted = getAdjustedTarget(event._dispatch_target, path_buffer[node_path_len - 1]); + event._target = if (rootIsShadowRoot(adjusted)) null else adjusted; + } // Handle checkbox/radio activation rollback or commit if (activation_state) |state| { state.restore(event, frame); @@ -201,9 +210,6 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts } } - var path_len: usize = 0; - var path_buffer: [128]*EventTarget = undefined; - var node: ?*Node = target; while (node) |n| { if (path_len >= path_buffer.len) break; @@ -227,11 +233,18 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts node = n._parent; } + node_path_len = path_len; + // Even though the window isn't part of the DOM, most events propagate - // through it in the capture phase (unless we stopped at a shadow boundary) - // The only explicit exception is "load" - if (event._type_string.eql(comptime .wrap("load")) == false) { - if (path_len < path_buffer.len) { + // through it in the capture phase. It only participates when the tree's + // root is the document (not for detached trees, and not when propagation + // stopped at a shadow boundary). The only explicit exception is "load". + if (event._type_string.eql(comptime .wrap("load")) == false and path_len < path_buffer.len) { + const root_is_document = path_len > 0 and switch (path_buffer[path_len - 1]._type) { + .node => |n| n._type == .document, + else => false, + }; + if (root_is_document) { path_buffer[path_len] = frame.window.asEventTarget(); path_len += 1; } @@ -454,6 +467,22 @@ fn getAdjustedTarget(original_target: ?*EventTarget, current_target: *EventTarge return original_target; } +// Whether the target's tree root (without crossing shadow boundaries) is a +// shadow root. Used for the spec's post-dispatch "clear targets" step. +fn rootIsShadowRoot(target_: ?*EventTarget) bool { + const ShadowRoot = @import("webapi/ShadowRoot.zig"); + + const target = target_ orelse return false; + var current: *Node = switch (target._type) { + .node => |n| n, + else => return false, + }; + while (current._parent) |p| { + current = p; + } + return current.is(ShadowRoot) != null; +} + // Check if ancestor is an ancestor of (or the same as) node // WITHOUT crossing shadow boundaries (just regular DOM tree) fn isAncestorOrSelf(ancestor: *Node, node: *Node) bool { diff --git a/src/browser/EventManagerBase.zig b/src/browser/EventManagerBase.zig index 5c09a6c5..d736ec2b 100644 --- a/src/browser/EventManagerBase.zig +++ b/src/browser/EventManagerBase.zig @@ -253,6 +253,9 @@ pub fn dispatchDirect( ls.deinit(); } + // Per spec, currentTarget is only set while listeners are being invoked + defer event._current_target = null; + // Call the property handler (e.g., onmessage) if present if (getFunction(handler, &ls.local)) |func| { event._current_target = target; diff --git a/src/browser/markdown.zig b/src/browser/markdown.zig index 66a1f89d..32cfc084 100644 --- a/src/browser/markdown.zig +++ b/src/browser/markdown.zig @@ -775,7 +775,7 @@ fn testMarkdownShadow(light: []const u8, shadow: []const u8, expected: []const u try frame.parseHtmlAsChildren(host.asNode(), light); } - const sr = try host.attachShadow(comptime .wrap("open"), frame); + const sr = try host.attachShadow(.{ .mode = .open }, frame); try frame.parseHtmlAsChildren(sr.asNode(), shadow); var aw: std.Io.Writer.Allocating = .init(testing.allocator); diff --git a/src/browser/parser/Parser.zig b/src/browser/parser/Parser.zig index d83018ad..be02eb8e 100644 --- a/src/browser/parser/Parser.zig +++ b/src/browser/parser/Parser.zig @@ -579,14 +579,19 @@ fn attachDeclarativeShadowCallback(ctx: *anyopaque, host_ref: *anyopaque, templa fn _attachDeclarativeShadowCallback(self: *Parser, host_node: *Node, template_node: *Node, mode_is_open: bool) !u8 { // guaranteed by html5ever const host = host_node.as(Element); - const mode: lp.String = if (mode_is_open) comptime .wrap("open") else comptime .wrap("closed"); - const shadow = host.attachShadow(mode, self.frame) catch |err| switch (err) { - // Expected per-spec fall-backs (host can't host a shadow, or already - // has one): keep the