Event.composedPath and adjusted target when crossing shadowroot boundary

This commit is contained in:
Karl Seguin
2025-11-27 16:57:33 +08:00
parent 0d57356c11
commit f25b8fc7b0
4 changed files with 282 additions and 26 deletions

View File

@@ -162,18 +162,36 @@ pub fn dispatchWithFunction(self: *EventManager, target: *EventTarget, event: *E
}
fn dispatchNode(self: *EventManager, target: *Node, event: *Event, was_handled: *bool) !void {
const ShadowRoot = @import("webapi/ShadowRoot.zig");
var path_len: usize = 0;
var path_buffer: [128]*EventTarget = undefined;
var node: ?*Node = target;
while (node) |n| : (node = n._parent) {
while (node) |n| {
if (path_len >= path_buffer.len) break;
path_buffer[path_len] = n.asEventTarget();
path_len += 1;
// Check if this node is a shadow root
if (n.is(ShadowRoot)) |shadow| {
event._needs_retargeting = true;
// If event is not composed, stop at shadow boundary
if (!event._composed) {
break;
}
// Otherwise, jump to the shadow host and continue
node = shadow._host.asNode();
continue;
}
node = n._parent;
}
// Even though the window isn't part of the DOM, events always propagate
// through it in the capture phase
// through it in the capture phase (unless we stopped at a shadow boundary)
if (path_len < path_buffer.len) {
path_buffer[path_len] = self.page.window.asEventTarget();
path_len += 1;
@@ -257,6 +275,12 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe
was_handled.* = true;
event._current_target = current_target;
// Compute adjusted target for shadow DOM retargeting (only if needed)
const original_target = event._target;
if (event._needs_retargeting) {
event._target = getAdjustedTarget(original_target, current_target);
}
switch (listener.function) {
.value => |value| try value.call(void, .{event}),
.string => |string| {
@@ -265,6 +289,11 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe
},
}
// Restore original target (only if we changed it)
if (event._needs_retargeting) {
event._target = original_target;
}
if (listener.once) {
self.removeListener(list, listener);
}
@@ -325,3 +354,56 @@ const Function = union(enum) {
};
}
};
// Computes the adjusted target for shadow DOM event retargeting
// Returns the lowest shadow-including ancestor of original_target that is
// also an ancestor-or-self of current_target
fn getAdjustedTarget(original_target: ?*EventTarget, current_target: *EventTarget) ?*EventTarget {
const ShadowRoot = @import("webapi/ShadowRoot.zig");
const orig_node = switch ((original_target orelse return null)._type) {
.node => |n| n,
else => return original_target,
};
const curr_node = switch (current_target._type) {
.node => |n| n,
else => return original_target,
};
// Walk up from original target, checking if we can reach current target
var node: ?*Node = orig_node;
while (node) |n| {
// Check if current_target is an ancestor of n (or n itself)
if (isAncestorOrSelf(curr_node, n)) {
return n.asEventTarget();
}
// Cross shadow boundary if needed
if (n.is(ShadowRoot)) |shadow| {
node = shadow._host.asNode();
continue;
}
node = n._parent;
}
return original_target;
}
// 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 {
if (ancestor == node) {
return true;
}
var current: ?*Node = node._parent;
while (current) |n| {
if (n == ancestor) {
return true;
}
current = n._parent;
}
return false;
}

View File

@@ -144,7 +144,7 @@ pub fn callWithThis(self: *const Function, comptime T: type, this: anytype, args
const result = self.func.castToFunction().call(context.v8_context, js_this, js_args);
if (result == null) {
// std.debug.print("CB ERR: {s}\n", .{self.src() catch "???"});
std.debug.print("CB ERR: {s}\n", .{self.src() catch "???"});
return error.JSExecCallback;
}

View File

@@ -52,34 +52,135 @@
</script>
<script id="event_composed_escapes_shadow">
// @ZIGDOM TODO
testing.expectEqual(true, true);
{
const host = document.createElement('div');
const shadow = host.attachShadow({ mode: 'open' });
shadow.innerHTML = '<button id="btn">Click</button>';
document.body.appendChild(host);
// {
// const host = document.createElement('div');
// const shadow = host.attachShadow({ mode: 'open' });
// shadow.innerHTML = '<button id="btn">Click</button>';
// document.body.appendChild(host);
const button = shadow.getElementById('btn');
// const button = shadow.getElementById('btn');
let hostCalled = false;
let hostTarget = null;
// let hostCalled = false;
// let hostTarget = null;
host.addEventListener('click', (e) => {
hostCalled = true;
hostTarget = e.target;
});
// host.addEventListener('click', (e) => {
// hostCalled = true;
// hostTarget = e.target;
// });
// With composed:true, event SHOULD escape shadow tree
const event = new Event('click', { bubbles: true, composed: true });
button.dispatchEvent(event);
// // With composed:true, event SHOULD escape shadow tree
// const event = new Event('click', { bubbles: true, composed: true });
// button.dispatchEvent(event);
testing.expectEqual(true, hostCalled);
// testing.expectEqual(true, hostCalled);
// Event target should be retargeted to host when outside shadow tree
testing.expectEqual(host, hostTarget);
// // Event target should be retargeted to host when outside shadow tree
// testing.expectEqual(host, hostTarget);
// host.remove();
// }
host.remove();
}
</script>
<script id="composedPath_basic">
{
const div1 = document.createElement('div');
const div2 = document.createElement('div');
const button = document.createElement('button');
div1.appendChild(div2);
div2.appendChild(button);
document.body.appendChild(div1);
let capturedPath = null;
button.addEventListener('click', (e) => {
capturedPath = e.composedPath();
});
const event = new Event('click', { bubbles: true });
button.dispatchEvent(event);
testing.expectEqual(7, capturedPath.length);
testing.expectEqual(button, capturedPath[0]);
testing.expectEqual(div2, capturedPath[1]);
testing.expectEqual(div1, capturedPath[2]);
testing.expectEqual(document.body, capturedPath[3]);
testing.expectEqual(document.documentElement, capturedPath[4]);
testing.expectEqual(document, capturedPath[5]);
testing.expectEqual(window, capturedPath[6]);
div1.remove();
}
</script>
<script id="composedPath_after_dispatch">
{
const button = document.createElement('button');
document.body.appendChild(button);
const event = new Event('click', { bubbles: true });
button.dispatchEvent(event);
const path = event.composedPath();
testing.expectEqual(0, path.length);
button.remove();
}
</script>
<script id="composedPath_with_shadow_not_composed">
{
const host = document.createElement('div');
const shadow = host.attachShadow({ mode: 'open' });
const button = document.createElement('button');
shadow.appendChild(button);
document.body.appendChild(host);
let capturedPath = null;
button.addEventListener('click', (e) => {
capturedPath = e.composedPath();
});
const event = new Event('click', { bubbles: true });
button.dispatchEvent(event);
testing.expectEqual(2, capturedPath.length);
testing.expectEqual(button, capturedPath[0]);
testing.expectEqual(shadow, capturedPath[1]);
host.remove();
}
</script>
<script id="composedPath_with_shadow_composed">
{
const host = document.createElement('div');
const shadow = host.attachShadow({ mode: 'open' });
const button = document.createElement('button');
shadow.appendChild(button);
document.body.appendChild(host);
let capturedPath = null;
button.addEventListener('click', (e) => {
capturedPath = e.composedPath();
});
const event = new Event('click', { bubbles: true, composed: true });
button.dispatchEvent(event);
testing.expectEqual(7, capturedPath.length);
testing.expectEqual(button, capturedPath[0]);
testing.expectEqual(shadow, capturedPath[1]);
testing.expectEqual(host, capturedPath[2]);
testing.expectEqual(document.body, capturedPath[3]);
testing.expectEqual(document.documentElement, capturedPath[4]);
testing.expectEqual(document, capturedPath[5]);
testing.expectEqual(window, capturedPath[6]);
host.remove();
}
</script>

View File

@@ -21,6 +21,7 @@ const js = @import("../js/js.zig");
const Page = @import("../Page.zig");
const EventTarget = @import("EventTarget.zig");
const Node = @import("Node.zig");
const String = @import("../../string.zig").String;
pub const Event = @This();
@@ -28,6 +29,7 @@ pub const Event = @This();
_type: Type,
_bubbles: bool = false,
_cancelable: bool = false,
_composed: bool = false,
_type_string: String,
_target: ?*EventTarget = null,
_current_target: ?*EventTarget = null,
@@ -36,6 +38,7 @@ _stop_propagation: bool = false,
_stop_immediate_propagation: bool = false,
_event_phase: EventPhase = .none,
_time_stamp: u64 = 0,
_needs_retargeting: bool = false,
pub const EventPhase = enum(u8) {
none = 0,
@@ -54,6 +57,7 @@ pub const Type = union(enum) {
const Options = struct {
bubbles: bool = false,
cancelable: bool = false,
composed: bool = false,
};
pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*Event {
@@ -68,6 +72,7 @@ pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*Event {
._bubbles = opts.bubbles,
._time_stamp = time_stamp,
._cancelable = opts.cancelable,
._composed = opts.composed,
._type_string = try String.init(page.arena, typ, .{}),
});
}
@@ -84,6 +89,10 @@ pub fn getCancelable(self: *const Event) bool {
return self._cancelable;
}
pub fn getComposed(self: *const Event) bool {
return self._composed;
}
pub fn getTarget(self: *const Event) ?*EventTarget {
return self._target;
}
@@ -117,6 +126,68 @@ pub fn getTimeStamp(self: *const Event) u64 {
return self._time_stamp;
}
pub fn composedPath(self: *Event, page: *Page) ![]const *EventTarget {
// Return empty array if event is not being dispatched
if (self._event_phase == .none) {
return &.{};
}
// If there's no target, return empty array
const target = self._target orelse return &.{};
// Only nodes have a propagation path
const target_node = switch (target._type) {
.node => |n| n,
else => return &.{},
};
// Build the path by walking up from target
var path_len: usize = 0;
var path_buffer: [128]*EventTarget = undefined;
var stopped_at_shadow_boundary = false;
var node: ?*Node = target_node;
while (node) |n| {
if (path_len >= path_buffer.len) {
break;
}
path_buffer[path_len] = n.asEventTarget();
path_len += 1;
// Check if this node is a shadow root
if (n._type == .document_fragment) {
if (n._type.document_fragment._type == .shadow_root) {
const shadow = n._type.document_fragment._type.shadow_root;
// If event is not composed, stop at shadow boundary
if (!self._composed) {
stopped_at_shadow_boundary = true;
break;
}
// Otherwise, jump to the shadow host and continue
node = shadow._host.asNode();
continue;
}
}
node = n._parent;
}
// Add window at the end (unless we stopped at shadow boundary)
if (!stopped_at_shadow_boundary) {
if (path_len < path_buffer.len) {
path_buffer[path_len] = page.window.asEventTarget();
path_len += 1;
}
}
// Allocate and return the path using call_arena (short-lived)
const path = try page.call_arena.alloc(*EventTarget, path_len);
@memcpy(path, path_buffer[0..path_len]);
return path;
}
pub const JsApi = struct {
pub const bridge = js.Bridge(Event);
@@ -131,6 +202,7 @@ pub const JsApi = struct {
pub const @"type" = bridge.accessor(Event.getType, null, .{});
pub const bubbles = bridge.accessor(Event.getBubbles, null, .{});
pub const cancelable = bridge.accessor(Event.getCancelable, null, .{});
pub const composed = bridge.accessor(Event.getComposed, null, .{});
pub const target = bridge.accessor(Event.getTarget, null, .{});
pub const currentTarget = bridge.accessor(Event.getCurrentTarget, null, .{});
pub const eventPhase = bridge.accessor(Event.getEventPhase, null, .{});
@@ -139,6 +211,7 @@ pub const JsApi = struct {
pub const preventDefault = bridge.function(Event.preventDefault, .{});
pub const stopPropagation = bridge.function(Event.stopPropagation, .{});
pub const stopImmediatePropagation = bridge.function(Event.stopImmediatePropagation, .{});
pub const composedPath = bridge.function(Event.composedPath, .{});
// Event phase constants
pub const NONE = bridge.property(@intFromEnum(EventPhase.none));