From e44392278829d8d5de7fc9ac83ccf6deb9128371 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 12 Aug 2026 17:23:20 +0800 Subject: [PATCH] webapi: Element/ShadowRoot getHTML Adds the getHTML methods to Element and ShadowRoot. This is expected to add another ~6K passing WPT tests (significantly improving out score in the /shadow-dom/ category). It's also probably also a useful API to implement. --- src/browser/dump.zig | 95 ++++++++++-- src/browser/tests/shadowroot/get_html.html | 161 +++++++++++++++++++++ src/browser/webapi/DocumentFragment.zig | 5 +- src/browser/webapi/Element.zig | 24 ++- src/browser/webapi/ShadowRoot.zig | 22 ++- 5 files changed, 289 insertions(+), 18 deletions(-) create mode 100644 src/browser/tests/shadowroot/get_html.html diff --git a/src/browser/dump.zig b/src/browser/dump.zig index 89c35423b..c1b68af57 100644 --- a/src/browser/dump.zig +++ b/src/browser/dump.zig @@ -36,7 +36,7 @@ pub const Opts = struct { invisible: bool = false, }; - pub const Shadow = enum { + pub const Shadow = union(enum) { // Skip shadow DOM entirely (innerHTML/outerHTML) skip, @@ -45,6 +45,16 @@ pub const Opts = struct { // Resolve slot elements (like what actually gets rendered) rendered, + + // Element/ShadowRoot.getHTML can control how it handles shadow elements + declarative: Declarative, + + pub const Declarative = struct { + // Serialize shadow roots whose `serializable` flag is set + serializable_shadow_roots: bool = false, + // Serialize these roots regardless of their flags or mode + shadow_roots: []const *Node.ShadowRoot = &.{}, + }; }; }; @@ -125,20 +135,30 @@ fn _deep(node: *Node, opts: Opts, comptime force_slot: bool, writer: *std.Io.Wri return writer.writeAll(""); } } - if (opts.shadow != .skip) { - if (frame._element_shadow_roots.get(el)) |shadow| { - try children(shadow.asNode(), opts, writer, frame); - // In rendered mode, light DOM is only shown through slots, not directly - if (opts.shadow == .rendered) { - // Skip rendering light DOM children - if (!isVoidElement(el)) { - try writer.writeAll("'); + switch (opts.shadow) { + .skip => {}, + .complete, .rendered => { + if (frame._element_shadow_roots.get(el)) |shadow| { + try children(shadow.asNode(), opts, writer, frame); + // In rendered mode, light DOM is only shown through slots, not directly + if (opts.shadow == .rendered) { + // Skip rendering light DOM children + if (!isVoidElement(el)) { + try writer.writeAll("'); + } + return; } - return; } - } + }, + .declarative => |declarative| { + if (frame._element_shadow_roots.get(el)) |shadow| { + if (shouldSerializeShadow(shadow, declarative)) { + try writeDeclarativeShadow(shadow, opts, writer, frame); + } + } + }, } if (opts.with_frames and el.is(IFrame) != null) { @@ -199,6 +219,22 @@ fn _deep(node: *Node, opts: Opts, comptime force_slot: bool, writer: *std.Io.Wri } } +// Element.getHTML / ShadowRoot.getHTML +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 (shouldSerializeShadow(shadow, declarative)) { + // if the element's shadowroot tree is rendered before its + // children (assume the opts say that it should serialize the + // shadowroot at all (i.e. shouldSerializeShadow). + try writeDeclarativeShadow(shadow, opts, writer, frame); + } + } + } + return children(node, opts, writer, frame); +} + pub fn children(parent: *Node, opts: Opts, writer: *std.Io.Writer, frame: *Frame) !void { var it = parent.childrenIterator(); while (it.next()) |child| { @@ -247,6 +283,39 @@ pub fn toJSON(node: *Node, writer: *std.json.Stringify) !void { try writer.endObject(); } +fn shouldSerializeShadow(shadow: *const Node.ShadowRoot, declarative: Opts.Shadow.Declarative) bool { + if (declarative.serializable_shadow_roots and shadow._serializable) { + return true; + } + for (declarative.shadow_roots) |sr| { + if (sr == shadow) { + // if it's explictly requested, it's serialized even if _serialized == false + return true; + } + } + return false; +} + +// The spec's "attach a declarative shadow root" serialization: attribute order +// is fixed, and boolean attributes serialize with an explicit ="". +fn writeDeclarativeShadow(shadow: *Node.ShadowRoot, opts: Opts, writer: *std.Io.Writer, frame: *Frame) !void { + try writer.writeAll(""); +} + fn dumpSlotContent(slot: *Slot, opts: Opts, writer: *std.Io.Writer, frame: *Frame) !void { const assigned = slot.assignedNodes(null, frame) catch return; diff --git a/src/browser/tests/shadowroot/get_html.html b/src/browser/tests/shadowroot/get_html.html new file mode 100644 index 000000000..d2d10dca0 --- /dev/null +++ b/src/browser/tests/shadowroot/get_html.html @@ -0,0 +1,161 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/webapi/DocumentFragment.zig b/src/browser/webapi/DocumentFragment.zig index 2580ca815..3720eca71 100644 --- a/src/browser/webapi/DocumentFragment.zig +++ b/src/browser/webapi/DocumentFragment.zig @@ -154,7 +154,10 @@ pub fn moveBefore(self: *DocumentFragment, node: js.Value, child: js.Value, fram pub fn getInnerHTML(self: *DocumentFragment, writer: *std.Io.Writer, frame: *Frame) !void { const dump = @import("../dump.zig"); - return dump.children(self.asNode(), .{ .shadow = .complete }, writer, frame); + // Fragment serialization never includes nested shadow trees — a host + // inside this fragment (or shadow root) serializes only its light + // children, exactly like Element.innerHTML. + return dump.children(self.asNode(), .{ .shadow = .skip }, writer, frame); } pub fn setInnerHTML(self: *DocumentFragment, html: []const u8, frame: *Frame) !void { diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index 497ea81c4..571c50acd 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -20,10 +20,11 @@ const std = @import("std"); const lp = @import("lightpanda"); const js = @import("../js/js.zig"); +const dump = @import("../dump.zig"); const Frame = @import("../Frame.zig"); +const reflect = @import("../reflect.zig"); const Factory = @import("../Factory.zig"); const StyleManager = @import("../StyleManager.zig"); -const reflect = @import("../reflect.zig"); const CSS = @import("CSS.zig"); const Node = @import("Node.zig"); @@ -518,7 +519,6 @@ pub fn insertAdjacentHTML( } pub fn getOuterHTML(self: *Element, writer: *std.Io.Writer, frame: *Frame) !void { - const dump = @import("../dump.zig"); return dump.deep(self.asNode(), .{ .shadow = .skip }, writer, frame); } @@ -583,10 +583,13 @@ pub fn setOuterHTML(self: *Element, html: []const u8, frame: *Frame) !void { } pub fn getInnerHTML(self: *Element, writer: *std.Io.Writer, frame: *Frame) !void { - const dump = @import("../dump.zig"); return dump.children(self.asNode(), .{ .shadow = .skip }, writer, frame); } +pub fn getHTML(self: *Element, opts: dump.Opts.Shadow.Declarative, writer: *std.Io.Writer, frame: *Frame) !void { + return dump.getHTML(self.asNode(), opts, writer, frame); +} + pub fn setInnerHTML(self: *Element, html: []const u8, frame: *Frame) !void { const parent = self.asNode(); return parent.setHTML(html, false, frame); @@ -2333,6 +2336,21 @@ pub const JsApi = struct { return self.setInnerHTML(if (value.isNull()) "" else try value.toZig([]const u8), frame); } + pub const getHTML = bridge.function(_getHTML, .{}); + const GetHTMLOpts = struct { + serializableShadowRoots: bool = false, + shadowRoots: []const *ShadowRoot = &.{}, + }; + fn _getHTML(self: *Element, opts_: ?GetHTMLOpts, frame: *Frame) ![]const u8 { + const opts = opts_ orelse GetHTMLOpts{}; + var buf = std.Io.Writer.Allocating.init(frame.local_arena); + try self.getHTML(.{ + .shadow_roots = opts.shadowRoots, + .serializable_shadow_roots = opts.serializableShadowRoots, + }, &buf.writer, frame); + return buf.written(); + } + pub const prefix = bridge.accessor(Element._prefix, null, .{}); pub const setAttribute = bridge.function(_setAttribute, .{ .ce_reactions = true }); diff --git a/src/browser/webapi/ShadowRoot.zig b/src/browser/webapi/ShadowRoot.zig index 878b4d2f2..83f633b89 100644 --- a/src/browser/webapi/ShadowRoot.zig +++ b/src/browser/webapi/ShadowRoot.zig @@ -19,10 +19,12 @@ const std = @import("std"); const js = @import("../js/js.zig"); +const dump = @import("../dump.zig"); const Frame = @import("../Frame.zig"); + const Node = @import("Node.zig"); -const DocumentFragment = @import("DocumentFragment.zig"); const Element = @import("Element.zig"); +const DocumentFragment = @import("DocumentFragment.zig"); const ShadowRoot = @This(); @@ -112,6 +114,10 @@ pub fn setHTMLUnsafe(self: *ShadowRoot, html: []const u8, frame: *Frame) !void { return self.asDocumentFragment().setHTMLUnsafe(html, frame); } +pub fn getHTML(self: *ShadowRoot, opts: dump.Opts.Shadow.Declarative, writer: *std.Io.Writer, frame: *Frame) !void { + return dump.getHTML(self.asNode(), opts, writer, frame); +} + pub fn getOnSlotChange(self: *ShadowRoot, frame: *Frame) ?js.Function.Global { return frame._event_target_attr_listeners.get(.{ .target = self.asEventTarget(), .handler = .onslotchange }); } @@ -214,6 +220,20 @@ pub const JsApi = struct { } pub const adoptedStyleSheets = bridge.accessor(ShadowRoot.getAdoptedStyleSheets, ShadowRoot.setAdoptedStyleSheets, .{}); pub const setHTMLUnsafe = bridge.function(ShadowRoot.setHTMLUnsafe, .{ .ce_reactions = true }); + pub const getHTML = bridge.function(_getHTML, .{}); + const GetHTMLOpts = struct { + serializableShadowRoots: bool = false, + shadowRoots: []const *ShadowRoot = &.{}, + }; + fn _getHTML(self: *ShadowRoot, opts_: ?GetHTMLOpts, frame: *Frame) ![]const u8 { + const opts = opts_ orelse GetHTMLOpts{}; + var buf = std.Io.Writer.Allocating.init(frame.local_arena); + try self.getHTML(.{ + .shadow_roots = opts.shadowRoots, + .serializable_shadow_roots = opts.serializableShadowRoots, + }, &buf.writer, frame); + return buf.written(); + } pub const onslotchange = bridge.accessor(ShadowRoot.getOnSlotChange, ShadowRoot.setOnSlotChange, .{}); };