From 6917aeb47b8935d2af790554bffa797b87807f3d Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sat, 10 Jan 2026 08:05:03 +0800 Subject: [PATCH 1/5] Walk document for doctype --- src/browser/webapi/Document.zig | 8 +++++++- src/browser/webapi/HTMLDocument.zig | 9 +++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index 3af6a59b..3313bcdf 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -491,7 +491,13 @@ pub fn elementsFromPoint(self: *Document, x: f64, y: f64, page: *Page) ![]const return result.items; } -pub fn getDocType(_: *const Document) ?*DocumentType { +pub fn getDocType(self: *Document) ?*Node { + var tw = @import("TreeWalker.zig").Full.init(self.asNode(), .{}); + while (tw.next()) |node| { + if (node._type == .document_type) { + return node; + } + } return null; } diff --git a/src/browser/webapi/HTMLDocument.zig b/src/browser/webapi/HTMLDocument.zig index 18aa9f56..8082ac67 100644 --- a/src/browser/webapi/HTMLDocument.zig +++ b/src/browser/webapi/HTMLDocument.zig @@ -215,6 +215,15 @@ pub fn getDocType(self: *HTMLDocument, page: *Page) !*DocumentType { if (self._document_type) |dt| { return dt; } + + var tw = @import("TreeWalker.zig").Full.init(self.asNode(), .{}); + while (tw.next()) |node| { + if (node._type == .document_type) { + self._document_type = node.as(DocumentType); + return self._document_type.?; + } + } + self._document_type = try page._factory.node(DocumentType{ ._proto = undefined, ._name = "html", From 05f0f8901e45b94aeb8748b14ae981618db9d0f2 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sat, 10 Jan 2026 08:24:12 +0800 Subject: [PATCH 2/5] make Node.isConnected() shadowroot-aware --- src/browser/webapi/Node.zig | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index 03f7ac7f..ae805456 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -378,8 +378,14 @@ pub fn isConnected(self: *const Node) bool { root = parent; } - // A node is connected if its root is a document - return root._type == .document; + switch (root._type) { + .document => return true, + .document_fragment => |df| { + const sr = df.is(ShadowRoot) orelse return false; + return sr._host.asNode().isConnected(); + }, + else => return false, + } } const GetRootNodeOpts = struct { From 75e78795ec3d46f8002b8692d90b47b5c168d17f Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sat, 10 Jan 2026 10:32:02 +0800 Subject: [PATCH 3/5] Add Document.replaceChildren Improve correctness (hierarchy validation) of various Document functions. --- src/browser/tests/cdata/cdata_section.html | 4 +- .../tests/document/replace_children.html | 344 ++++++++++++++++++ src/browser/tests/testing.js | 2 +- src/browser/webapi/Document.zig | 189 +++++++++- 4 files changed, 528 insertions(+), 11 deletions(-) create mode 100644 src/browser/tests/document/replace_children.html diff --git a/src/browser/tests/cdata/cdata_section.html b/src/browser/tests/cdata/cdata_section.html index 5c991bb5..6fc24a97 100644 --- a/src/browser/tests/cdata/cdata_section.html +++ b/src/browser/tests/cdata/cdata_section.html @@ -201,8 +201,8 @@ cdataClassName root.appendChild(cdata); root.appendChild(elem2); - testing.expectEqual('LAST', cdata.nextElementSibling.tagName); - testing.expectEqual('FIRST', cdata.previousElementSibling.tagName); + testing.expectEqual('last', cdata.nextElementSibling.tagName); + testing.expectEqual('first', cdata.previousElementSibling.tagName); } diff --git a/src/browser/tests/document/replace_children.html b/src/browser/tests/document/replace_children.html new file mode 100644 index 00000000..010f3b0d --- /dev/null +++ b/src/browser/tests/document/replace_children.html @@ -0,0 +1,344 @@ + + + + + document.replaceChildren Tests + + +
Original content
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/tests/testing.js b/src/browser/tests/testing.js index afc1fa69..62c8473f 100644 --- a/src/browser/tests/testing.js +++ b/src/browser/tests/testing.js @@ -36,7 +36,7 @@ function expectError(expected, fn) { withError((err) => { - expectEqual(expected, err.toString()); + expectEqual(true, err.toString().includes(expected)); }, fn); } diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index 3313bcdf..ee0749db 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -125,11 +125,11 @@ const CreateElementOptions = struct { pub fn createElement(self: *Document, name: []const u8, options_: ?CreateElementOptions, page: *Page) !*Element { const namespace: Element.Namespace = blk: { - if (self._type == .xml) { - @branchHint(.unlikely); - break :blk .xml; + if (self._type == .html) { + break :blk .html; } - break :blk .html; + // Generic and XML documents create XML elements + break :blk .xml; }; const node = try page.createElementNS(namespace, name, null); const element = node.as(Element); @@ -432,20 +432,103 @@ pub fn importNode(_: *const Document, node: *Node, deep_: ?bool, page: *Page) !* } pub fn append(self: *Document, nodes: []const Node.NodeOrText, page: *Page) !void { + try validateDocumentNodes(self, nodes, false); + + page.domChanged(); const parent = self.asNode(); + const parent_is_connected = parent.isConnected(); + for (nodes) |node_or_text| { const child = try node_or_text.toNode(page); - _ = try parent.appendChild(child, page); + + // DocumentFragments are special - append all their children + if (child.is(Node.DocumentFragment)) |_| { + try page.appendAllChildren(child, parent); + continue; + } + + var child_connected = false; + if (child._parent) |previous_parent| { + child_connected = child.isConnected(); + page.removeNode(previous_parent, child, .{ .will_be_reconnected = parent_is_connected }); + } + try page.appendNode(parent, child, .{ .child_already_connected = child_connected }); } } pub fn prepend(self: *Document, nodes: []const Node.NodeOrText, page: *Page) !void { + try validateDocumentNodes(self, nodes, false); + + page.domChanged(); const parent = self.asNode(); + const parent_is_connected = parent.isConnected(); + var i = nodes.len; while (i > 0) { i -= 1; const child = try nodes[i].toNode(page); - _ = try parent.insertBefore(child, parent.firstChild(), page); + + // DocumentFragments are special - need to insert all their children + if (child.is(Node.DocumentFragment)) |frag| { + const first_child = parent.firstChild(); + var frag_child = frag.asNode().lastChild(); + while (frag_child) |fc| { + const prev = fc.previousSibling(); + page.removeNode(frag.asNode(), fc, .{ .will_be_reconnected = parent_is_connected }); + if (first_child) |before| { + try page.insertNodeRelative(parent, fc, .{ .before = before }, .{}); + } else { + try page.appendNode(parent, fc, .{}); + } + frag_child = prev; + } + continue; + } + + var child_connected = false; + if (child._parent) |previous_parent| { + child_connected = child.isConnected(); + page.removeNode(previous_parent, child, .{ .will_be_reconnected = parent_is_connected }); + } + + const first_child = parent.firstChild(); + if (first_child) |before| { + try page.insertNodeRelative(parent, child, .{ .before = before }, .{ .child_already_connected = child_connected }); + } else { + try page.appendNode(parent, child, .{ .child_already_connected = child_connected }); + } + } +} + +pub fn replaceChildren(self: *Document, nodes: []const Node.NodeOrText, page: *Page) !void { + try validateDocumentNodes(self, nodes, true); + + page.domChanged(); + const parent = self.asNode(); + + // Remove all existing children + var it = parent.childrenIterator(); + while (it.next()) |child| { + page.removeNode(parent, child, .{ .will_be_reconnected = false }); + } + + // Append new children + const parent_is_connected = parent.isConnected(); + for (nodes) |node_or_text| { + const child = try node_or_text.toNode(page); + + // DocumentFragments are special - append all their children + if (child.is(Node.DocumentFragment)) |_| { + try page.appendAllChildren(child, parent); + continue; + } + + var child_connected = false; + if (child._parent) |previous_parent| { + child_connected = child.isConnected(); + page.removeNode(previous_parent, child, .{ .will_be_reconnected = parent_is_connected }); + } + try page.appendNode(parent, child, .{ .child_already_connected = child_connected }); } } @@ -699,6 +782,95 @@ pub fn setAdoptedStyleSheets(self: *Document, sheets: js.Object) !void { self._adopted_style_sheets = try sheets.persist(); } +// Validates that nodes can be inserted into a Document, respecting Document constraints: +// - At most one Element child +// - At most one DocumentType child +// - No Document, Attribute, or Text nodes +// - Only Element, DocumentType, Comment, and ProcessingInstruction are allowed +// When replacing=true, existing children are not counted (for replaceChildren) +fn validateDocumentNodes(self: *Document, nodes: []const Node.NodeOrText, comptime replacing: bool) !void { + const parent = self.asNode(); + + // Check existing elements and doctypes (unless we're replacing all children) + var has_element = false; + var has_doctype = false; + + if (!replacing) { + var it = parent.childrenIterator(); + while (it.next()) |child| { + if (child._type == .element) { + has_element = true; + } else if (child._type == .document_type) { + has_doctype = true; + } + } + } + + // Validate new nodes + for (nodes) |node_or_text| { + switch (node_or_text) { + .text => { + // Text nodes are not allowed as direct children of Document + return error.HierarchyError; + }, + .node => |child| { + // Check if it's a DocumentFragment - need to validate its children + if (child.is(Node.DocumentFragment)) |frag| { + var frag_it = frag.asNode().childrenIterator(); + while (frag_it.next()) |frag_child| { + // Document can only contain: Element, DocumentType, Comment, ProcessingInstruction + switch (frag_child._type) { + .element => { + if (has_element) { + return error.HierarchyError; + } + has_element = true; + }, + .document_type => { + if (has_doctype) { + return error.HierarchyError; + } + has_doctype = true; + }, + .cdata => |cd| switch (cd._type) { + .comment, .processing_instruction => {}, // Allowed + .text, .cdata_section => return error.HierarchyError, // Not allowed in Document + }, + .document, .attribute, .document_fragment => return error.HierarchyError, + } + } + } else { + // Validate node type for direct insertion + switch (child._type) { + .element => { + if (has_element) { + return error.HierarchyError; + } + has_element = true; + }, + .document_type => { + if (has_doctype) { + return error.HierarchyError; + } + has_doctype = true; + }, + .cdata => |cd| switch (cd._type) { + .comment, .processing_instruction => {}, // Allowed + .text, .cdata_section => return error.HierarchyError, // Not allowed in Document + }, + .document, .attribute, .document_fragment => return error.HierarchyError, + } + } + + // Check for cycles + if (child.contains(parent)) { + return error.HierarchyError; + } + }, + } + } +} + const ReadyState = enum { loading, interactive, @@ -768,8 +940,9 @@ pub const JsApi = struct { pub const getElementsByName = bridge.function(Document.getElementsByName, .{}); pub const adoptNode = bridge.function(Document.adoptNode, .{ .dom_exception = true }); pub const importNode = bridge.function(Document.importNode, .{ .dom_exception = true }); - pub const append = bridge.function(Document.append, .{}); - pub const prepend = bridge.function(Document.prepend, .{}); + pub const append = bridge.function(Document.append, .{ .dom_exception = true }); + pub const prepend = bridge.function(Document.prepend, .{ .dom_exception = true }); + pub const replaceChildren = bridge.function(Document.replaceChildren, .{ .dom_exception = true }); pub const elementFromPoint = bridge.function(Document.elementFromPoint, .{}); pub const elementsFromPoint = bridge.function(Document.elementsFromPoint, .{}); pub const write = bridge.function(Document.write, .{ .dom_exception = true }); From 8d3aa1f3faeec15deb67820440db83755779bef3 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sat, 10 Jan 2026 10:43:43 +0800 Subject: [PATCH 4/5] validate tag name given to document.createElement --- src/browser/webapi/Document.zig | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index ee0749db..e2ae52ab 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -124,6 +124,7 @@ const CreateElementOptions = struct { }; pub fn createElement(self: *Document, name: []const u8, options_: ?CreateElementOptions, page: *Page) !*Element { + try validateElementName(name); const namespace: Element.Namespace = blk: { if (self._type == .html) { break :blk .html; @@ -149,6 +150,7 @@ pub fn createElement(self: *Document, name: []const u8, options_: ?CreateElement } pub fn createElementNS(self: *Document, namespace: ?[]const u8, name: []const u8, page: *Page) !*Element { + try validateElementName(name); const node = try page.createElementNS(Element.Namespace.parse(namespace), name, null); // Track owner document if it's not the main document @@ -871,6 +873,29 @@ fn validateDocumentNodes(self: *Document, nodes: []const Node.NodeOrText, compti } } +fn validateElementName(name: []const u8) !void { + if (name.len == 0) { + return error.InvalidCharacterError; + } + + const first = name[0]; + // Element names cannot start with: digits, period, hyphen + if ((first >= '0' and first <= '9') or first == '.' or first == '-') { + return error.InvalidCharacterError; + } + + for (name[1..]) |c| { + const is_valid = (c >= 'a' and c <= 'z') or + (c >= 'A' and c <= 'Z') or + (c >= '0' and c <= '9') or + c == '_' or c == '-' or c == '.' or c == ':'; + + if (!is_valid) { + return error.InvalidCharacterError; + } + } +} + const ReadyState = enum { loading, interactive, From 2679175ae9ba81c30ec8fbc5df4f096d94b49a1c Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sat, 10 Jan 2026 16:00:11 +0800 Subject: [PATCH 5/5] make createElement return DOMException on error --- src/browser/webapi/Document.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index e2ae52ab..96ce7f03 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -934,8 +934,8 @@ pub const JsApi = struct { pub const compatMode = bridge.accessor(Document.getCompatMode, null, .{}); pub const referrer = bridge.accessor(Document.getReferrer, null, .{}); pub const domain = bridge.accessor(Document.getDomain, null, .{}); - pub const createElement = bridge.function(Document.createElement, .{}); - pub const createElementNS = bridge.function(Document.createElementNS, .{}); + pub const createElement = bridge.function(Document.createElement, .{.dom_exception = true}); + pub const createElementNS = bridge.function(Document.createElementNS, .{.dom_exception = true}); pub const createDocumentFragment = bridge.function(Document.createDocumentFragment, .{}); pub const createComment = bridge.function(Document.createComment, .{}); pub const createTextNode = bridge.function(Document.createTextNode, .{});