improve parsed (i.e. static) custom element callbacks

This commit is contained in:
Karl Seguin
2025-11-26 19:43:08 +08:00
parent 18b51de696
commit 67f63a6bb3
11 changed files with 486 additions and 44 deletions

View File

@@ -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);
}
}
}

View File

@@ -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 <slot> 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("</");
try writer.writeAll(el.getTagNameDump());
try writer.writeByte('>');
}
},
.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) {

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

@@ -91,3 +91,4 @@
testing.expectEqual(1, connectedCount);
}
</script>

View File

@@ -0,0 +1,122 @@
<!DOCTYPE html>
<head>
<script src="../testing.js"></script>
<script>
{
// Define the custom element BEFORE the HTML is parsed
window.preParseConnectedCount = 0;
class PreParseElement extends HTMLElement {
connectedCallback() {
window.preParseConnectedCount++;
}
}
customElements.define('pre-parse-element', PreParseElement);
}
</script>
</head>
<body>
<!-- This element is in the HTML and should have connectedCallback invoked -->
<pre-parse-element id="static-element"></pre-parse-element>
<script id="test-static-element">
{
// connectedCallback should have been called for the element in HTML
testing.expectEqual(1, window.preParseConnectedCount);
const el = document.getElementById('static-element');
testing.expectTrue(el !== null);
testing.expectEqual('PRE-PARSE-ELEMENT', el.tagName);
}
</script>
<script id="test-programmatic-still-works">
{
// Reset counter
window.preParseConnectedCount = 0;
// This should still work (programmatic creation)
const el = document.createElement('pre-parse-element');
testing.expectEqual(0, window.preParseConnectedCount);
document.body.appendChild(el);
testing.expectEqual(1, window.preParseConnectedCount);
}
</script>
<script>
{
window.nestedParentCount = 0;
window.nestedChildCount = 0;
class NestedParent extends HTMLElement {
connectedCallback() {
window.nestedParentCount++;
}
}
class NestedChild extends HTMLElement {
connectedCallback() {
window.nestedChildCount++;
}
}
customElements.define('nested-parent', NestedParent);
customElements.define('nested-child', NestedChild);
}
</script>
<nested-parent id="parent-element">
<nested-child id="child-element"></nested-child>
</nested-parent>
<script id="verify-nested">
{
// Both parent and child should have connectedCallback invoked
testing.expectEqual(1, window.nestedParentCount);
testing.expectEqual(1, window.nestedChildCount);
const parent = document.getElementById('parent-element');
const child = document.getElementById('child-element');
testing.expectTrue(parent !== null);
testing.expectTrue(child !== null);
}
</script>
<script>
{
// Test attributeChangedCallback for initial attributes during parsing
window.attrChangedCalls = [];
class AttrElement extends HTMLElement {
static get observedAttributes() {
return ['foo', 'bar'];
}
attributeChangedCallback(name, oldValue, newValue) {
window.attrChangedCalls.push({ name, oldValue, newValue });
}
}
customElements.define('attr-element', AttrElement);
}
</script>
<attr-element foo="value1" bar="value2" ignored="value3"></attr-element>
<script id="verify-attribute-changed">
{
// attributeChangedCallback should have been called for initial attributes
testing.expectEqual(2, window.attrChangedCalls.length);
testing.expectEqual('foo', window.attrChangedCalls[0].name);
testing.expectEqual(null, window.attrChangedCalls[0].oldValue);
testing.expectEqual('value1', window.attrChangedCalls[0].newValue);
testing.expectEqual('bar', window.attrChangedCalls[1].name);
testing.expectEqual(null, window.attrChangedCalls[1].oldValue);
testing.expectEqual('value2', window.attrChangedCalls[1].newValue);
}
</script>
</body>

View File

@@ -0,0 +1,151 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<body></body>
<script id="innerHTML_excludes_shadow">
{
const host = document.createElement('div');
const shadow = host.attachShadow({ mode: 'open' });
shadow.innerHTML = '<p>Shadow content</p>';
document.body.appendChild(host);
// Per spec, innerHTML does NOT include shadow DOM
const html = host.innerHTML;
testing.expectEqual('', html);
host.remove();
}
</script>
<script id="innerHTML_only_light_dom">
{
const host = document.createElement('div');
const shadow = host.attachShadow({ mode: 'open' });
shadow.innerHTML = '<p>Shadow content with <slot></slot></p>';
const lightChild = document.createElement('span');
lightChild.textContent = 'Light DOM';
host.appendChild(lightChild);
document.body.appendChild(host);
const html = host.innerHTML;
// innerHTML only returns light DOM, not shadow DOM
testing.expectEqual(false, html.indexOf('Shadow content') >= 0);
testing.expectEqual(true, html.indexOf('Light DOM') >= 0);
testing.expectEqual(true, html.indexOf('<span>Light DOM</span>') >= 0);
host.remove();
}
</script>
<script id="outerHTML_excludes_shadow">
{
const host = document.createElement('div');
host.id = 'test-host';
const shadow = host.attachShadow({ mode: 'open' });
shadow.innerHTML = '<p>Shadow content</p>';
document.body.appendChild(host);
// outerHTML also excludes shadow DOM per spec
const html = host.outerHTML;
testing.expectEqual(true, html.indexOf('<div id="test-host">') >= 0);
testing.expectEqual(false, html.indexOf('Shadow content') >= 0);
testing.expectEqual(true, html.indexOf('</div>') >= 0);
host.remove();
}
</script>
<script id="innerHTML_closed_shadow">
{
const host = document.createElement('div');
const shadow = host.attachShadow({ mode: 'closed' });
shadow.innerHTML = '<p>Closed shadow content</p>';
document.body.appendChild(host);
// innerHTML never includes shadow DOM (open or closed)
const html = host.innerHTML;
testing.expectEqual('', html);
host.remove();
}
</script>
<script id="innerHTML_nested_shadow">
{
const outer = document.createElement('div');
const outerShadow = outer.attachShadow({ mode: 'open' });
outerShadow.innerHTML = '<div id="inner-host"></div>';
const innerHost = outerShadow.getElementById('inner-host');
const innerShadow = innerHost.attachShadow({ mode: 'open' });
innerShadow.innerHTML = '<p>Nested shadow content</p>';
document.body.appendChild(outer);
// innerHTML on outer doesn't include its shadow DOM
const html = outer.innerHTML;
testing.expectEqual('', html);
outer.remove();
}
</script>
<script id="shadowRoot_innerHTML">
{
const host = document.createElement('div');
const shadow = host.attachShadow({ mode: 'open' });
shadow.innerHTML = '<p>Para 1</p><p>Para 2</p>';
document.body.appendChild(host);
const shadowHtml = shadow.innerHTML;
testing.expectEqual(true, shadowHtml.indexOf('Para 1') >= 0);
testing.expectEqual(true, shadowHtml.indexOf('Para 2') >= 0);
host.remove();
}
</script>
<script id="innerHTML_with_light_dom">
{
const host = document.createElement('div');
const shadow = host.attachShadow({ mode: 'open' });
shadow.innerHTML = '<slot></slot>';
const lightChild = document.createElement('span');
lightChild.textContent = 'Light only';
host.appendChild(lightChild);
document.body.appendChild(host);
// innerHTML returns light DOM children
const html = host.innerHTML;
testing.expectEqual(true, html.indexOf('Light only') >= 0);
testing.expectEqual(true, html.indexOf('<span>Light only</span>') >= 0);
host.remove();
}
</script>
<script id="shadowRoot_innerHTML_direct">
{
const host = document.createElement('div');
const shadow = host.attachShadow({ mode: 'open' });
shadow.innerHTML = '<p class="test" data-foo="bar">Shadow text</p>';
document.body.appendChild(host);
// Accessing shadow.innerHTML directly DOES return shadow content
const html = shadow.innerHTML;
testing.expectEqual(true, html.indexOf('class="test"') >= 0);
testing.expectEqual(true, html.indexOf('data-foo="bar"') >= 0);
testing.expectEqual(true, html.indexOf('Shadow text') >= 0);
host.remove();
}
</script>

View File

@@ -0,0 +1,84 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<body></body>
<script id="innerHTML_spec_compliance">
{
const host = document.createElement('div');
const shadow = host.attachShadow({ mode: 'open' });
shadow.innerHTML = '<p>Before <slot></slot> After</p>';
const slotted = document.createElement('span');
slotted.textContent = 'SLOTTED';
host.appendChild(slotted);
document.body.appendChild(host);
// host.innerHTML returns only light DOM (spec compliant)
const hostHTML = host.innerHTML;
testing.expectEqual(true, hostHTML.indexOf('SLOTTED') >= 0);
testing.expectEqual(false, hostHTML.indexOf('Before') >= 0);
testing.expectEqual(false, hostHTML.indexOf('<slot>') >= 0);
// shadow.innerHTML returns shadow DOM content
const shadowHTML = shadow.innerHTML;
testing.expectEqual(true, shadowHTML.indexOf('Before') >= 0);
testing.expectEqual(true, shadowHTML.indexOf('<slot>') >= 0);
testing.expectEqual(false, shadowHTML.indexOf('SLOTTED') >= 0);
host.remove();
}
</script>
<script id="innerHTML_named_slots">
{
const host = document.createElement('div');
const shadow = host.attachShadow({ mode: 'open' });
shadow.innerHTML = '<slot name="header"></slot><slot name="footer"></slot>';
const header = document.createElement('h1');
header.setAttribute('slot', 'header');
header.textContent = 'Header Content';
host.appendChild(header);
const footer = document.createElement('p');
footer.setAttribute('slot', 'footer');
footer.textContent = 'Footer Content';
host.appendChild(footer);
document.body.appendChild(host);
// host.innerHTML returns light DOM with slot attributes
const hostHTML = host.innerHTML;
testing.expectEqual(true, hostHTML.indexOf('Header Content') >= 0);
testing.expectEqual(true, hostHTML.indexOf('Footer Content') >= 0);
testing.expectEqual(true, hostHTML.indexOf('slot="header"') >= 0);
testing.expectEqual(true, hostHTML.indexOf('slot="footer"') >= 0);
// shadow.innerHTML returns slot elements
const shadowHTML = shadow.innerHTML;
testing.expectEqual(true, shadowHTML.indexOf('<slot name="header"></slot>') >= 0);
testing.expectEqual(true, shadowHTML.indexOf('<slot name="footer"></slot>') >= 0);
testing.expectEqual(false, shadowHTML.indexOf('Header Content') >= 0);
host.remove();
}
</script>
<script id="innerHTML_no_shadow">
{
const host = document.createElement('div');
const child = document.createElement('span');
child.textContent = 'Regular content';
host.appendChild(child);
document.body.appendChild(host);
// Without shadow DOM, innerHTML works normally
const html = host.innerHTML;
testing.expectEqual(true, html.indexOf('Regular content') >= 0);
testing.expectEqual(true, html.indexOf('<span>') >= 0);
host.remove();
}
</script>

View File

@@ -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;

View File

@@ -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();
}
};

View File

@@ -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();
}

View File

@@ -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;