From 4b1eb2794ffaf8d69cc54c745904fa94fa941277 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 11 Aug 2025 15:01:51 +0800 Subject: [PATCH] Add ShadowRoot get/set innerHTML Adds event.composedPath() This depends on https://github.com/lightpanda-io/libdom/pull/34 --- src/browser/dom/element.zig | 2 + src/browser/dom/shadow_root.zig | 53 +++++++++++++++++-- src/browser/events/event.zig | 92 +++++++++++++++++++++++++++++++++ src/browser/netsurf.zig | 9 ++++ vendor/netsurf/libdom | 2 +- 5 files changed, 154 insertions(+), 4 deletions(-) diff --git a/src/browser/dom/element.zig b/src/browser/dom/element.zig index 3746abcc..6021ad62 100644 --- a/src/browser/dom/element.zig +++ b/src/browser/dom/element.zig @@ -558,6 +558,8 @@ pub const Element = struct { .proto = fragment, }; state.shadow_root = sr; + parser.documentFragmentSetHost(sr.proto, @alignCast(@ptrCast(self))); + return sr; } diff --git a/src/browser/dom/shadow_root.zig b/src/browser/dom/shadow_root.zig index 7d8977df..ade9c4e9 100644 --- a/src/browser/dom/shadow_root.zig +++ b/src/browser/dom/shadow_root.zig @@ -17,10 +17,12 @@ // along with this program. If not, see . const std = @import("std"); +const dump = @import("../dump.zig"); const parser = @import("../netsurf.zig"); const Env = @import("../env.zig").Env; const Page = @import("../page.zig").Page; +const Node = @import("node.zig").Node; const Element = @import("element.zig").Element; const ElementUnion = @import("element.zig").Union; @@ -56,13 +58,48 @@ pub const ShadowRoot = struct { pub fn set_adoptedStyleSheets(self: *ShadowRoot, sheets: Env.JsObject) !void { self.adopted_style_sheets = try sheets.persist(); } + + pub fn get_innerHTML(self: *ShadowRoot, page: *Page) ![]const u8 { + var buf = std.ArrayList(u8).init(page.call_arena); + try dump.writeChildren(parser.documentFragmentToNode(self.proto), .{}, buf.writer()); + return buf.items; + } + + pub fn set_innerHTML(self: *ShadowRoot, str_: ?[]const u8) !void { + const sr_doc = parser.documentFragmentToNode(self.proto); + const doc = try parser.nodeOwnerDocument(sr_doc) orelse return parser.DOMError.WrongDocument; + try Node.removeChildren(sr_doc); + const str = str_ orelse return; + + const fragment = try parser.documentParseFragmentFromStr(doc, str); + const fragment_node = parser.documentFragmentToNode(fragment); + + // Element.set_innerHTML also has some weirdness here. It isn't clear + // what should and shouldn't be set. Whatever string you pass to libdom, + // it always creates a full HTML document, with an html, head and body + // element. + // For ShadowRoot, it appears the only the children within the body should + // be set. + const html = try parser.nodeFirstChild(fragment_node) orelse return; + const head = try parser.nodeFirstChild(html) orelse return; + const body = try parser.nodeNextSibling(head) orelse return; + + const children = try parser.nodeGetChildNodes(body); + const ln = try parser.nodeListLength(children); + for (0..ln) |_| { + // always index 0, because nodeAppendChild moves the node out of + // the nodeList and into the new tree + const child = try parser.nodeListItem(children, 0) orelse continue; + _ = try parser.nodeAppendChild(sr_doc, child); + } + } }; const testing = @import("../../testing.zig"); test "Browser.DOM.ShadowRoot" { defer testing.reset(); - var runner = try testing.jsRunner(testing.tracking_allocator, .{ .html = + var runner = try testing.jsRunner(testing.tracking_allocator, .{ .html = \\
nope
}); defer runner.deinit(); @@ -94,8 +131,8 @@ test "Browser.DOM.ShadowRoot" { try runner.testCases(&.{ .{ "sr2.getElementById('conflict')", "null" }, .{ "const n1 = document.createElement('div')", null }, - .{ "n1.id = 'conflict'", null}, - .{ "sr2.append(n1)", null}, + .{ "n1.id = 'conflict'", null }, + .{ "sr2.append(n1)", null }, .{ "sr2.getElementById('conflict') == n1", "true" }, }, .{}); @@ -105,4 +142,14 @@ test "Browser.DOM.ShadowRoot" { .{ "acss.push(new CSSStyleSheet())", null }, .{ "sr2.adoptedStyleSheets.length", "1" }, }, .{}); + + try runner.testCases(&.{ + .{ "sr1.innerHTML = '

hello

'", null }, + .{ "sr1.innerHTML", "

hello

" }, + .{ "sr1.querySelector('*')", "[object HTMLParagraphElement]" }, + + .{ "sr1.innerHTML = null", null }, + .{ "sr1.innerHTML", "" }, + .{ "sr1.querySelector('*')", "null" }, + }, .{}); } diff --git a/src/browser/events/event.zig b/src/browser/events/event.zig index 4a524be0..8d16be9e 100644 --- a/src/browser/events/event.zig +++ b/src/browser/events/event.zig @@ -24,6 +24,7 @@ const parser = @import("../netsurf.zig"); const generate = @import("../../runtime/generate.zig"); const Page = @import("../page.zig").Page; +const Node = @import("../dom/node.zig").Node; const DOMException = @import("../dom/exceptions.zig").DOMException; const EventTarget = @import("../dom/event_target.zig").EventTarget; const EventTargetUnion = @import("../dom/event_target.zig").Union; @@ -139,6 +140,64 @@ pub const Event = struct { pub fn _preventDefault(self: *parser.Event) !void { return try parser.eventPreventDefault(self); } + + pub fn _composedPath(self: *parser.Event, page: *Page) ![]const EventTargetUnion { + const et_ = try parser.eventTarget(self); + const et = et_ orelse return &.{}; + + var node: ?*parser.Node = switch (try parser.eventTargetInternalType(et)) { + .libdom_node => @as(*parser.Node, @ptrCast(et)), + .plain => parser.eventTargetToNode(et), + else => { + // Window, XHR, MessagePort, etc...no path beyond the event itself + return &.{try EventTarget.toInterface(et, page)}; + }, + }; + + const arena = page.call_arena; + var path: std.ArrayListUnmanaged(EventTargetUnion) = .empty; + while (node) |n| { + try path.append(arena, .{ + .node = try Node.toInterface(n), + }); + + node = try parser.nodeParentNode(n); + if (node == null and try parser.nodeType(n) == .document_fragment) { + // we have a non-continuous hook from a shadowroot to its host ( + // it's parent element). libdom doesn't really support ShdowRoots + // and, for the most part, that works out well since it naturally + // provides isolation. But events don't follow the same + // shadowroot isolation as most other things, so, if this is + // a parent-less document fragment, we need to check if it has + // a host. + if (parser.documentFragmentGetHost(@ptrCast(n))) |host| { + node = host; + + // If a document fragment has a host, then that host + // _has_ to have a state and that state _has_ to have + // a shadow_root field. All of this is set in Element._attachShadow + if (page.getNodeState(host).?.shadow_root.?.mode == .closed) { + // if the shadow root is closed, then the composedPath + // starts at the host element. + path.clearRetainingCapacity(); + } + } else { + // Our document fragement has no parent and no host, we + // can break out of the loop. + break; + } + } + } + + if (path.getLastOrNull()) |last| { + // the Window isn't part of the DOM hierarchy, but for events, it + // is, so we need to glue it on. + if (last.node == .HTMLDocument and last.node.HTMLDocument == page.window.document) { + try path.append(arena, .{ .node = .{ .Window = &page.window } }); + } + } + return path.items; + } }; pub const EventHandler = struct { @@ -446,4 +505,37 @@ test "Browser.Event" { .{ "nb", "2" }, .{ "document.removeEventListener('count', cbk)", "undefined" }, }, .{}); + + try runner.testCases(&.{ + .{ "new Event('').composedPath()", "" }, + .{ + \\ let div1 = document.createElement('div'); + \\ let sr1 = div1.attachShadow({mode: 'open'}); + \\ sr1.innerHTML = "

"; + \\ document.getElementsByTagName('body')[0].appendChild(div1); + \\ let cp = null; + \\ div1.addEventListener('click', (e) => { + \\ cp = e.composedPath().map((n) => n.id || n.nodeName || n.toString()); + \\ }); + \\ sr1.getElementById('srp1').click(); + \\ cp.join(', '); + , + "srp1, #document-fragment, DIV, BODY, HTML, #document, [object Window]", + }, + + .{ + \\ let div2 = document.createElement('div'); + \\ let sr2 = div2.attachShadow({mode: 'closed'}); + \\ sr2.innerHTML = "

"; + \\ document.getElementsByTagName('body')[0].appendChild(div2); + \\ cp = null; + \\ div2.addEventListener('click', (e) => { + \\ cp = e.composedPath().map((n) => n.id || n.nodeName || n.toString()); + \\ }); + \\ sr2.getElementById('srp2').click(); + \\ cp.join(', '); + , + "DIV, BODY, HTML, #document, [object Window]", + }, + }, .{}); } diff --git a/src/browser/netsurf.zig b/src/browser/netsurf.zig index 068df34d..73e9a1ab 100644 --- a/src/browser/netsurf.zig +++ b/src/browser/netsurf.zig @@ -1993,6 +1993,15 @@ pub inline fn documentFragmentToNode(doc: *DocumentFragment) *Node { return @as(*Node, @alignCast(@ptrCast(doc))); } +pub fn documentFragmentGetHost(frag: *DocumentFragment) ?*Node { + var node: ?*NodeExternal = undefined; + c._dom_document_fragment_get_host(frag, &node); + return if (node) |n| @ptrCast(n) else null; +} +pub fn documentFragmentSetHost(frag: *DocumentFragment, host: *Node) void { + c._dom_document_fragment_set_host(frag, host); +} + // Document Position pub const DocumentPosition = enum(u32) { diff --git a/vendor/netsurf/libdom b/vendor/netsurf/libdom index 0c590b26..c0df4581 160000 --- a/vendor/netsurf/libdom +++ b/vendor/netsurf/libdom @@ -1 +1 @@ -Subproject commit 0c590b265a65b937042d68ad34902c9b4a05839a +Subproject commit c0df458132162aba136d57ce1ba2179122a9e717