From 13547c0ff82b0d72d8324ae88ba2343366d7145c Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 27 May 2026 16:31:28 +0800 Subject: [PATCH] Add declarative shadow dom (DSD) Normally, a shadow dom is attached to an element via `el.attachShadow(mode)`. With DSD, the shadow dom is attached during parsing. Essentially, when we see: it has the end result of calling attachShadow on the parent element. This is used increasingly by a number of frameworks, though normally with backwards compatibility that fallbacks to doing it in JavaScript. DSD happens during parsing and document.write, but not via innerHTML = ''. However, both Element and DocumentFragment gain a `setHTMLUnsafe` which is like innerHTML WITH DSD. I initially thought this feature could be implement exactly like I describe: when the parser adds a template, check for a `shadowrootmode` attribute and call attachShadow...except..you need to call attachShadow on the parent, which the parser hasn't popped yet, and it alters where the children are added. Thankfully, html5ever has a boolean to enable/disable dsd..hence the html5ever binding changes to (a) enable / disable this featuer and (b) the new callback. --- src/browser/Frame.zig | 15 ++- src/browser/markdown.zig | 23 +++- src/browser/parser/Parser.zig | 55 +++++++- src/browser/parser/html5ever.zig | 10 ++ src/browser/tests/document/write.html | 17 +++ src/browser/tests/shadowroot/declarative.html | 124 ++++++++++++++++++ .../webapi/CustomElementDefinition.zig | 3 + src/browser/webapi/CustomElementRegistry.zig | 14 ++ src/browser/webapi/DOMParser.zig | 6 +- src/browser/webapi/Document.zig | 4 +- src/browser/webapi/DocumentFragment.zig | 17 +-- src/browser/webapi/Element.zig | 58 +++++--- src/browser/webapi/Node.zig | 19 +++ src/browser/webapi/ShadowRoot.zig | 9 +- src/html5ever/lib.rs | 20 +++ src/html5ever/sink.rs | 24 ++++ src/html5ever/types.rs | 7 + 17 files changed, 382 insertions(+), 43 deletions(-) create mode 100644 src/browser/tests/shadowroot/declarative.html diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 6561f2cd..de5242f0 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -576,7 +576,7 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo }; const parse_arena = try self.getArena(.medium, "Frame.parseBlob"); defer self.releaseArena(parse_arena); - var parser = Parser.init(parse_arena, self.document.asNode(), self); + var parser = Parser.init(parse_arena, self.document.asNode(), self, .{ .allow_declarative_shadow = true }); parser.parse(blob._slice); } else { self.document.injectBlank(self) catch |err| { @@ -1187,7 +1187,7 @@ fn frameDoneCallback(ctx: *anyopaque) !void { const parse_arena = try self.getArena(.medium, "Frame.parse"); defer self.releaseArena(parse_arena); - var parser = Parser.init(parse_arena, self.document.asNode(), self); + var parser = Parser.init(parse_arena, self.document.asNode(), self, .{ .allow_declarative_shadow = true }); switch (self._parse_state) { .html => |*html| { @@ -3563,11 +3563,20 @@ pub fn updateRangesForNodeRemoval(self: *Frame, parent: *Node, child: *Node, chi // TODO: optimize and cleanup, this is called a lot (e.g., innerHTML = '') pub fn parseHtmlAsChildren(self: *Frame, node: *Node, html: []const u8) !void { + return self.parseHtmlAsChildrenInner(node, html, false); +} + +// setHTMLUnsafe variant: parse a fragment that may contain declarative shadow node +pub fn parseHtmlUnsafeAsChildren(self: *Frame, node: *Node, html: []const u8) !void { + return self.parseHtmlAsChildrenInner(node, html, true); +} + +fn parseHtmlAsChildrenInner(self: *Frame, node: *Node, html: []const u8, allow_declarative_shadow: bool) !void { const previous_parse_mode = self._parse_mode; self._parse_mode = .fragment; defer self._parse_mode = previous_parse_mode; - var parser = Parser.init(self.call_arena, node, self); + var parser = Parser.init(self.call_arena, node, self, .{ .allow_declarative_shadow = allow_declarative_shadow }); parser.parseFragment(html); // html5ever wraps fragment output in an element; unwrap so its diff --git a/src/browser/markdown.zig b/src/browser/markdown.zig index 898608c8..66a1f89d 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("open", frame); + const sr = try host.attachShadow(comptime .wrap("open"), frame); try frame.parseHtmlAsChildren(sr.asNode(), shadow); var aw: std.Io.Writer.Allocating = .init(testing.allocator); @@ -814,3 +814,24 @@ test "browser.markdown: slot fallback content when nothing assigned" { \\Default text , "Default text\n"); } + +// End-to-end: a declarative shadow root (parsed via setHTMLUnsafe) is attached +// as a real shadow tree, and markdown's composed-tree piercing then renders it. +test "browser.markdown: declarative shadow DOM renders through piercing" { + const testing = @import("../testing.zig"); + const frame = try testing.test_session.createPage(); + defer testing.test_session.removePage(); + frame.url = "http://localhost/"; + + const doc = frame.window._document; + const host = try doc.createElement("div", null, frame); + try host.setHTMLUnsafe( + \\
+ , frame); + + var aw: std.Io.Writer.Allocating = .init(testing.allocator); + defer aw.deinit(); + try dump(host.asNode(), .{}, &aw.writer, frame); + + try testing.expectString("\nshadow content\n", aw.written()); +} diff --git a/src/browser/parser/Parser.zig b/src/browser/parser/Parser.zig index 5dcb8284..65d66e4a 100644 --- a/src/browser/parser/Parser.zig +++ b/src/browser/parser/Parser.zig @@ -71,7 +71,16 @@ pending_text: ?PendingText, // second chunk of a run, so the common case stays at one copy. buf: std.ArrayList(u8), -pub fn init(arena: Allocator, node: *Node, frame: *Frame) Parser { +// Whether `