From 67f63a6bb325dd96bf3fed60a6acf284fb3e7b52 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 26 Nov 2025 19:43:08 +0800 Subject: [PATCH] improve parsed (i.e. static) custom element callbacks --- src/browser/Page.zig | 25 ++- src/browser/dump.zig | 54 ++++++- src/browser/js/Function.zig | 2 +- .../tests/custom_elements/connected.html | 1 + .../connected_from_parser.html | 122 ++++++++++++++ src/browser/tests/shadowroot/dump.html | 151 ++++++++++++++++++ .../tests/shadowroot/innerHTML_spec.html | 84 ++++++++++ src/browser/webapi/CustomElementRegistry.zig | 1 - src/browser/webapi/DocumentFragment.zig | 6 +- src/browser/webapi/Element.zig | 12 +- src/browser/webapi/element/html/Custom.zig | 72 ++++++--- 11 files changed, 486 insertions(+), 44 deletions(-) create mode 100644 src/browser/tests/custom_elements/connected_from_parser.html create mode 100644 src/browser/tests/shadowroot/dump.html create mode 100644 src/browser/tests/shadowroot/innerHTML_spec.html diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 80500316..b84e297e 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -1216,6 +1216,22 @@ pub fn createElement(self: *Page, ns_: ?[]const u8, name: []const u8, attribute_ return node; }; + + // After constructor runs, invoke attributeChangedCallback for initial attributes + const element = node.as(Element); + if (element._attributes) |attributes| { + var it = attributes.iterator(); + while (it.next()) |attr| { + Element.Html.Custom.invokeAttributeChangedCallbackOnElement( + element, + attr._name.str(), + null, // old_value is null for initial attributes + attr._value.str(), + self, + ); + } + } + return node; } @@ -1485,6 +1501,13 @@ pub fn _insertNodeRelative(self: *Page, comptime from_parser: bool, parent: *Nod if (el.getAttributeSafe("id")) |id| { try self.addElementId(parent, el, id); } + + // Invoke connectedCallback for custom elements during parsing + // For main document parsing, we know nodes are connected (fast path) + // For fragment parsing (innerHTML), we need to check connectivity + if (self._parse_mode == .document or child.isConnected()) { + try Element.Html.Custom.invokeConnectedCallbackOnElement(true, el, self); + } } return; } @@ -1518,7 +1541,7 @@ pub fn _insertNodeRelative(self: *Page, comptime from_parser: bool, parent: *Nod } if (should_invoke_connected) { - Element.Html.Custom.invokeConnectedCallbackOnElement(el, self); + try Element.Html.Custom.invokeConnectedCallbackOnElement(false, el, self); } } } diff --git a/src/browser/dump.zig b/src/browser/dump.zig index 0617b488..73ebe42b 100644 --- a/src/browser/dump.zig +++ b/src/browser/dump.zig @@ -19,6 +19,7 @@ const std = @import("std"); const Page = @import("Page.zig"); const Node = @import("webapi/Node.zig"); +const Slot = @import("webapi/element/html/Slot.zig"); pub const RootOpts = struct { with_base: bool = false, @@ -27,11 +28,24 @@ pub const RootOpts = struct { pub const Opts = struct { strip: Strip = .{}, + shadow: Shadow = .rendered, + pub const Strip = struct { js: bool = false, ui: bool = false, css: bool = false, }; + + pub const Shadow = enum { + // Skip shadow DOM entirely (innerHTML/outerHTML) + skip, + + // Dump everyhting (like "view source") + complete, + + // Resolve slot elements (like what actually gets rendered) + rendered, + }; }; pub fn root(opts: RootOpts, writer: *std.Io.Writer, page: *Page) !void { @@ -45,10 +59,10 @@ pub fn root(opts: RootOpts, writer: *std.Io.Writer, page: *Page) !void { } } - return deep(doc.asNode(), .{ .strip = opts.strip }, writer); + return deep(doc.asNode(), .{ .strip = opts.strip }, writer, page); } -pub fn deep(node: *Node, opts: Opts, writer: *std.Io.Writer) error{WriteFailed}!void { +pub fn deep(node: *Node, opts: Opts, writer: *std.Io.Writer, page: *Page) error{WriteFailed}!void { switch (node._type) { .cdata => |cd| try writer.writeAll(cd.getData()), .element => |el| { @@ -56,25 +70,39 @@ pub fn deep(node: *Node, opts: Opts, writer: *std.Io.Writer) error{WriteFailed}! return; } + // Handle elements in rendered mode + if (opts.shadow == .rendered) { + if (el.is(Slot)) |slot| { + return dumpSlotContent(slot, opts, writer, page); + } + } + try el.format(writer); - try children(node, opts, writer); + + if (opts.shadow != .skip) { + if (page._element_shadow_roots.get(el)) |shadow| { + try children(shadow.asNode(), opts, writer, page); + } + } + + try children(node, opts, writer, page); if (!isVoidElement(el)) { try writer.writeAll("'); } }, - .document => try children(node, opts, writer), + .document => try children(node, opts, writer, page), .document_type => {}, - .document_fragment => try children(node, opts, writer), + .document_fragment => try children(node, opts, writer, page), .attribute => unreachable, } } -pub fn children(parent: *Node, opts: Opts, writer: *std.Io.Writer) !void { +pub fn children(parent: *Node, opts: Opts, writer: *std.Io.Writer, page: *Page) !void { var it = parent.childrenIterator(); while (it.next()) |child| { - try deep(child, opts, writer); + try deep(child, opts, writer, page); } } @@ -118,6 +146,18 @@ pub fn toJSON(node: *Node, writer: *std.json.Stringify) !void { try writer.endObject(); } +fn dumpSlotContent(slot: *Slot, opts: Opts, writer: *std.Io.Writer, page: *Page) !void { + const assigned = slot.assignedNodes(null, page) catch return; + + if (assigned.len > 0) { + for (assigned) |assigned_node| { + try deep(assigned_node, opts, writer, page); + } + } else { + try children(slot.asNode(), opts, writer, page); + } +} + fn isVoidElement(el: *const Node.Element) bool { return switch (el._type) { .html => |html| switch (html._type) { diff --git a/src/browser/js/Function.zig b/src/browser/js/Function.zig index 41d8fa2c..4ab5be8a 100644 --- a/src/browser/js/Function.zig +++ b/src/browser/js/Function.zig @@ -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; } diff --git a/src/browser/tests/custom_elements/connected.html b/src/browser/tests/custom_elements/connected.html index c126fab6..4b0abff9 100644 --- a/src/browser/tests/custom_elements/connected.html +++ b/src/browser/tests/custom_elements/connected.html @@ -91,3 +91,4 @@ testing.expectEqual(1, connectedCount); } + diff --git a/src/browser/tests/custom_elements/connected_from_parser.html b/src/browser/tests/custom_elements/connected_from_parser.html new file mode 100644 index 00000000..770c309b --- /dev/null +++ b/src/browser/tests/custom_elements/connected_from_parser.html @@ -0,0 +1,122 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/tests/shadowroot/dump.html b/src/browser/tests/shadowroot/dump.html new file mode 100644 index 00000000..57544393 --- /dev/null +++ b/src/browser/tests/shadowroot/dump.html @@ -0,0 +1,151 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/tests/shadowroot/innerHTML_spec.html b/src/browser/tests/shadowroot/innerHTML_spec.html new file mode 100644 index 00000000..029f0e7a --- /dev/null +++ b/src/browser/tests/shadowroot/innerHTML_spec.html @@ -0,0 +1,84 @@ + + + + + + + + + diff --git a/src/browser/webapi/CustomElementRegistry.zig b/src/browser/webapi/CustomElementRegistry.zig index 361aced5..9c295170 100644 --- a/src/browser/webapi/CustomElementRegistry.zig +++ b/src/browser/webapi/CustomElementRegistry.zig @@ -84,7 +84,6 @@ pub fn define(self: *CustomElementRegistry, name: []const u8, constructor: js.Fu var idx: usize = 0; while (idx < page._undefined_custom_elements.items.len) { const custom = page._undefined_custom_elements.items[idx]; - if (!custom._tag_name.eqlSlice(name)) { idx += 1; continue; diff --git a/src/browser/webapi/DocumentFragment.zig b/src/browser/webapi/DocumentFragment.zig index f7b20878..6c712f55 100644 --- a/src/browser/webapi/DocumentFragment.zig +++ b/src/browser/webapi/DocumentFragment.zig @@ -160,9 +160,9 @@ pub fn replaceChildren(self: *DocumentFragment, nodes: []const Node.NodeOrText, } } -pub fn getInnerHTML(self: *DocumentFragment, writer: *std.Io.Writer) !void { +pub fn getInnerHTML(self: *DocumentFragment, writer: *std.Io.Writer, page: *Page) !void { const dump = @import("../dump.zig"); - return dump.children(self.asNode(), .{}, writer); + return dump.children(self.asNode(), .{ .shadow = .complete }, writer, page); } pub fn setInnerHTML(self: *DocumentFragment, html: []const u8, page: *Page) !void { @@ -224,7 +224,7 @@ pub const JsApi = struct { fn _innerHTML(self: *DocumentFragment, page: *Page) ![]const u8 { var buf = std.Io.Writer.Allocating.init(page.call_arena); - try self.getInnerHTML(&buf.writer); + try self.getInnerHTML(&buf.writer, page); return buf.written(); } }; diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index fb9b927e..f73e5374 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -227,14 +227,14 @@ pub fn getInnerText(self: *Element, writer: *std.Io.Writer) !void { } } -pub fn getOuterHTML(self: *Element, writer: *std.Io.Writer) !void { +pub fn getOuterHTML(self: *Element, writer: *std.Io.Writer, page: *Page) !void { const dump = @import("../dump.zig"); - return dump.deep(self.asNode(), .{}, writer); + return dump.deep(self.asNode(), .{ .shadow = .skip }, writer, page); } -pub fn getInnerHTML(self: *Element, writer: *std.Io.Writer) !void { +pub fn getInnerHTML(self: *Element, writer: *std.Io.Writer, page: *Page) !void { const dump = @import("../dump.zig"); - return dump.children(self.asNode(), .{}, writer); + return dump.children(self.asNode(), .{ .shadow = .skip }, writer, page); } pub fn setInnerHTML(self: *Element, html: []const u8, page: *Page) !void { @@ -906,14 +906,14 @@ pub const JsApi = struct { pub const outerHTML = bridge.accessor(_outerHTML, null, .{}); fn _outerHTML(self: *Element, page: *Page) ![]const u8 { var buf = std.Io.Writer.Allocating.init(page.call_arena); - try self.getOuterHTML(&buf.writer); + try self.getOuterHTML(&buf.writer, page); return buf.written(); } pub const innerHTML = bridge.accessor(_innerHTML, Element.setInnerHTML, .{}); fn _innerHTML(self: *Element, page: *Page) ![]const u8 { var buf = std.Io.Writer.Allocating.init(page.call_arena); - try self.getInnerHTML(&buf.writer); + try self.getInnerHTML(&buf.writer, page); return buf.written(); } diff --git a/src/browser/webapi/element/html/Custom.zig b/src/browser/webapi/element/html/Custom.zig index 50e8518e..a8c95d5c 100644 --- a/src/browser/webapi/element/html/Custom.zig +++ b/src/browser/webapi/element/html/Custom.zig @@ -53,7 +53,9 @@ pub fn invokeConnectedCallback(self: *Custom, page: *Page) void { pub fn invokeDisconnectedCallback(self: *Custom, page: *Page) void { // Only invoke if we haven't already called it while disconnected - if (self._disconnected_callback_invoked) return; + if (self._disconnected_callback_invoked) { + return; + } self._disconnected_callback_invoked = true; self._connected_callback_invoked = false; @@ -62,30 +64,49 @@ pub fn invokeDisconnectedCallback(self: *Custom, page: *Page) void { pub fn invokeAttributeChangedCallback(self: *Custom, name: []const u8, old_value: ?[]const u8, new_value: ?[]const u8, page: *Page) void { const definition = self._definition orelse return; - if (!definition.isAttributeObserved(name)) return; + if (!definition.isAttributeObserved(name)) { + return; + } self.invokeCallback("attributeChangedCallback", .{ name, old_value, new_value }, page); } -// Static helpers that work on any Element (autonomous or customized built-in) -pub fn invokeConnectedCallbackOnElement(element: *Element, page: *Page) void { +pub fn invokeConnectedCallbackOnElement(comptime from_parser: bool, element: *Element, page: *Page) !void { // Autonomous custom element if (element.is(Custom)) |custom| { - custom.invokeConnectedCallback(page); + if (comptime from_parser) { + // From parser, we know the element is brand new + custom._connected_callback_invoked = true; + custom.invokeCallback("connectedCallback", .{}, page); + } else { + custom.invokeConnectedCallback(page); + } return; } - // Customized built-in element - // Check if we've already invoked connectedCallback while connected - if (page._customized_builtin_connected_callback_invoked.contains(element)) return; + // Customized built-in element - check if it actually has a definition first + const definition = page.getCustomizedBuiltInDefinition(element) orelse return; + + if (comptime from_parser) { + // From parser, we know the element is brand new, skip the tracking check + try page._customized_builtin_connected_callback_invoked.put( + page.arena, + element, + {}, + ); + } else { + // Not from parser, check if we've already invoked while connected + const gop = try page._customized_builtin_connected_callback_invoked.getOrPut( + page.arena, + element, + ); + if (gop.found_existing) { + return; + } + gop.value_ptr.* = {}; + } - page._customized_builtin_connected_callback_invoked.put( - page.arena, - element, - {}, - ) catch return; _ = page._customized_builtin_disconnected_callback_invoked.remove(element); - - invokeCallbackOnElement(element, "connectedCallback", .{}, page); + invokeCallbackOnElement(element, definition, "connectedCallback", .{}, page); } pub fn invokeDisconnectedCallbackOnElement(element: *Element, page: *Page) void { @@ -95,18 +116,20 @@ pub fn invokeDisconnectedCallbackOnElement(element: *Element, page: *Page) void return; } - // Customized built-in element - // Check if we've already invoked disconnectedCallback while disconnected - if (page._customized_builtin_disconnected_callback_invoked.contains(element)) return; + // Customized built-in element - check if it actually has a definition first + const definition = page.getCustomizedBuiltInDefinition(element) orelse return; - page._customized_builtin_disconnected_callback_invoked.put( + // Check if we've already invoked disconnectedCallback while disconnected + const gop = page._customized_builtin_disconnected_callback_invoked.getOrPut( page.arena, element, - {}, ) catch return; + if (gop.found_existing) return; + gop.value_ptr.* = {}; + _ = page._customized_builtin_connected_callback_invoked.remove(element); - invokeCallbackOnElement(element, "disconnectedCallback", .{}, page); + invokeCallbackOnElement(element, definition, "disconnectedCallback", .{}, page); } pub fn invokeAttributeChangedCallbackOnElement(element: *Element, name: []const u8, old_value: ?[]const u8, new_value: ?[]const u8, page: *Page) void { @@ -119,12 +142,11 @@ pub fn invokeAttributeChangedCallbackOnElement(element: *Element, name: []const // Customized built-in element - check if attribute is observed const definition = page.getCustomizedBuiltInDefinition(element) orelse return; if (!definition.isAttributeObserved(name)) return; - invokeCallbackOnElement(element, "attributeChangedCallback", .{ name, old_value, new_value }, page); + invokeCallbackOnElement(element, definition, "attributeChangedCallback", .{ name, old_value, new_value }, page); } -fn invokeCallbackOnElement(element: *Element, comptime callback_name: [:0]const u8, args: anytype, page: *Page) void { - // Check if this element has a customized built-in definition - _ = page.getCustomizedBuiltInDefinition(element) orelse return; +fn invokeCallbackOnElement(element: *Element, definition: *CustomElementDefinition, comptime callback_name: [:0]const u8, args: anytype, page: *Page) void { + _ = definition; const context = page.js;