Various small DOM fixes, WPT driven

1. Implement document.adoptNode (we were removing from the existing document,
   but not adding to the new document)

2. Document.url should use the document's frame, falling back to the execution
   frame

3. Move HTMLDocument.location to Document.location

4. DOMImplementation.createDocument uses a more appropriate default namespace
   (xml -> null)

5. Map querySelector functions to DOMException-safe errors. The Selector returns
   specific errors, but for the DOM apis (document.querySelector,
   df.querySelectorAll, elem.matches, etc...) these largely all map to
   SyntaxError
This commit is contained in:
Karl Seguin
2026-05-11 14:29:47 +08:00
parent efbf1db87c
commit c16c15bedf
11 changed files with 107 additions and 35 deletions

View File

@@ -380,6 +380,53 @@
testing.expectEqual(0, nd.childElementCount);
</script>
<script id=adoptNode>
{
// Adopting a Document throws NotSupportedError
testing.withError((err) => {
testing.expectEqual(9, err.code);
testing.expectEqual("NotSupportedError", err.name);
}, () => document.adoptNode(document));
// Same-document adopt: returns the node, clears parent, owner unchanged
const el = document.createElement('div');
const child = document.createElement('span');
el.appendChild(child);
document.body.appendChild(el);
testing.expectEqual(document.body, el.parentNode);
testing.expectEqual(document, el.ownerDocument);
testing.expectEqual(document, child.ownerDocument);
testing.expectEqual(el, document.adoptNode(el));
testing.expectEqual(null, el.parentNode);
testing.expectEqual(child, el.firstChild);
testing.expectEqual(document, el.ownerDocument);
testing.expectEqual(document, child.ownerDocument);
// Cross-document adopt: node + descendants retarget to the new document
const otherDoc = new Document();
testing.expectEqual(el, otherDoc.adoptNode(el));
testing.expectEqual(null, el.parentNode);
testing.expectEqual(child, el.firstChild);
testing.expectEqual(otherDoc, el.ownerDocument);
testing.expectEqual(otherDoc, child.ownerDocument);
// Round-trip back to the main document
testing.expectEqual(el, document.adoptNode(el));
testing.expectEqual(document, el.ownerDocument);
testing.expectEqual(document, child.ownerDocument);
// Adopt across documents removes the node from its old parent
const orphan = document.createElement('p');
document.body.appendChild(orphan);
testing.expectEqual(document.body, orphan.parentNode);
const otherDoc2 = new Document();
testing.expectEqual(orphan, otherDoc2.adoptNode(orphan));
testing.expectEqual(null, orphan.parentNode);
testing.expectEqual(otherDoc2, orphan.ownerDocument);
}
</script>
<script id=adoptedStyleSheets>
{
const acss = document.adoptedStyleSheets;

View File

@@ -25,7 +25,7 @@
document.querySelector('p[data-random="abc\\5C def"]').textContent);
// A bare newline inside a string token is a parse error.
testing.expectError("Error: InvalidAttributeSelector",
testing.expectError("SyntaxError",
() => document.querySelector('p[data-random="line one\nline two"]'));
}
</script>

View File

@@ -10,9 +10,9 @@
const container = $('#container');
// Empty functional pseudo-classes should error
testing.expectError("Error: InvalidPseudoClass", () => container.querySelector(':has()'));
testing.expectError("Error: InvalidPseudoClass", () => container.querySelector(':not()'));
testing.expectError("Error: InvalidPseudoClass", () => container.querySelector(':lang()'));
testing.expectError("SyntaxError", () => container.querySelector(':has()'));
testing.expectError("SyntaxError", () => container.querySelector(':not()'));
testing.expectError("SyntaxError", () => container.querySelector(':lang()'));
}
</script>
@@ -21,9 +21,9 @@
const container = $('#container');
// Invalid nth patterns
testing.expectError("Error: InvalidNthPattern", () => container.querySelector(':nth-child(foo)'));
testing.expectError("Error: InvalidNthPattern", () => container.querySelector(':nth-child(-)'));
testing.expectError("Error: InvalidNthPattern", () => container.querySelector(':nth-child(+)'));
testing.expectError("SyntaxError", () => container.querySelector(':nth-child(foo)'));
testing.expectError("SyntaxError", () => container.querySelector(':nth-child(-)'));
testing.expectError("SyntaxError", () => container.querySelector(':nth-child(+)'));
}
</script>
@@ -32,9 +32,9 @@
const container = $('#container');
// Unknown pseudo-classes
testing.expectError("Error: UnknownPseudoClass", () => container.querySelector(':unknown'));
testing.expectError("Error: UnknownPseudoClass", () => container.querySelector(':not-a-real-pseudo'));
testing.expectError("Error: UnknownPseudoClass", () => container.querySelector(':fake(test)'));
testing.expectError("SyntaxError", () => container.querySelector(':unknown'));
testing.expectError("SyntaxError", () => container.querySelector(':not-a-real-pseudo'));
testing.expectError("SyntaxError", () => container.querySelector(':fake(test)'));
}
</script>
@@ -53,8 +53,8 @@
const container = $('#container');
// Combinators with nothing after
testing.expectError("Error: InvalidSelector", () => container.querySelector('p >'));
testing.expectError("Error: InvalidSelector", () => container.querySelector('p +'));
testing.expectError("Error: InvalidSelector", () => container.querySelector('p ~'));
testing.expectError("SyntaxError", () => container.querySelector('p >'));
testing.expectError("SyntaxError", () => container.querySelector('p +'));
testing.expectError("SyntaxError", () => container.querySelector('p ~'));
}
</script>

View File

@@ -30,7 +30,8 @@
testing.expectEqual('undefined', typeof plainDoc.scripts);
testing.expectEqual('undefined', typeof plainDoc.links);
testing.expectEqual('undefined', typeof plainDoc.forms);
testing.expectEqual('undefined', typeof plainDoc.location);
// location lives on Document (returns null for non-HTMLDocument).
testing.expectEqual(null, plainDoc.location);
// Both should have common Document properties
testing.expectEqual('string', typeof document.URL);

View File

@@ -78,7 +78,7 @@ pub fn createDocument(_: *const DOMImplementation, namespace_: ?[]const u8, qual
// Create and append root element if qualified_name provided
if (qualified_name) |qname| {
if (qname.len > 0) {
const namespace = if (namespace_) |ns| Node.Element.Namespace.parse(ns) else .xml;
const namespace = Node.Element.Namespace.parse(namespace_);
const root = try frame.createElementNS(namespace, qname, null);
_ = try document.asNode().appendChild(root, frame);
}

View File

@@ -119,7 +119,18 @@ pub fn asEventTarget(self: *Document) *@import("EventTarget.zig") {
}
pub fn getURL(self: *const Document, frame: *const Frame) [:0]const u8 {
return self._url orelse frame.url;
return self._url orelse (self._frame orelse frame).url;
}
pub fn getLocation(self: *const Document) ?*Location {
if (self._type != .html) return null;
const doc_frame = self._frame orelse return null;
return doc_frame.window._location;
}
pub fn setLocation(self: *Document, url: [:0]const u8, frame: *Frame) !void {
if (self._type != .html) return;
return frame.scheduleNavigation(url, .{ .reason = .script, .kind = .{ .push = null } }, .{ .script = self._frame });
}
pub fn getContentType(self: *const Document) []const u8 {
@@ -277,11 +288,11 @@ pub fn getSelection(self: *Document) *Selection {
}
pub fn querySelector(self: *Document, input: String, frame: *Frame) !?*Element {
return Selector.querySelector(self.asNode(), input.str(), frame);
return Selector.querySelector(self.asNode(), input.str(), frame) catch |err| Selector.mapErrorToDOM(err);
}
pub fn querySelectorAll(self: *Document, input: String, frame: *Frame) !*Selector.List {
return Selector.querySelectorAll(self.asNode(), input.str(), frame);
return Selector.querySelectorAll(self.asNode(), input.str(), frame) catch |err| Selector.mapErrorToDOM(err);
}
pub fn getImplementation(self: *Document, frame: *Frame) !*DOMImplementation {
@@ -465,15 +476,21 @@ pub fn getFonts(self: *Document, frame: *Frame) !*FontFaceSet {
return fonts;
}
pub fn adoptNode(_: *const Document, node: *Node, frame: *Frame) !*Node {
pub fn adoptNode(self: *Document, node: *Node, frame: *Frame) !*Node {
if (node._type == .document) {
return error.NotSupported;
}
const old_owner = node.ownerDocument(frame) orelse frame.document;
if (node._parent) |parent| {
frame.removeNode(parent, node, .{ .will_be_reconnected = false });
}
if (old_owner != self) {
try frame.adoptNodeTree(node, old_owner, self);
}
return node;
}
@@ -1029,6 +1046,7 @@ pub const JsApi = struct {
pub const onselectionchange = bridge.accessor(Document.getOnSelectionChange, Document.setOnSelectionChange, .{});
pub const URL = bridge.accessor(Document.getURL, null, .{});
pub const location = bridge.accessor(Document.getLocation, Document.setLocation, .{});
pub const documentURI = bridge.accessor(Document.getURL, null, .{});
pub const documentElement = bridge.accessor(Document.getDocumentElement, null, .{});
pub const scrollingElement = bridge.accessor(Document.getDocumentElement, null, .{});

View File

@@ -84,11 +84,11 @@ pub fn getElementById(self: *DocumentFragment, id: []const u8) ?*Element {
}
pub fn querySelector(self: *DocumentFragment, selector: []const u8, frame: *Frame) !?*Element {
return Selector.querySelector(self.asNode(), selector, frame);
return Selector.querySelector(self.asNode(), selector, frame) catch |err| Selector.mapErrorToDOM(err);
}
pub fn querySelectorAll(self: *DocumentFragment, input: []const u8, frame: *Frame) !*Selector.List {
return Selector.querySelectorAll(self.asNode(), input, frame);
return Selector.querySelectorAll(self.asNode(), input, frame) catch |err| Selector.mapErrorToDOM(err);
}
pub fn getChildren(self: *DocumentFragment, frame: *Frame) !collections.NodeLive(.child_elements) {

View File

@@ -1071,15 +1071,15 @@ pub fn getChildElementCount(self: *Element) usize {
}
pub fn matches(self: *Element, selector: []const u8, frame: *Frame) !bool {
return Selector.matches(self, selector, frame);
return Selector.matches(self, selector, frame) catch |err| Selector.mapErrorToDOM(err);
}
pub fn querySelector(self: *Element, selector: []const u8, frame: *Frame) !?*Element {
return Selector.querySelector(self.asNode(), selector, frame);
return Selector.querySelector(self.asNode(), selector, frame) catch |err| Selector.mapErrorToDOM(err);
}
pub fn querySelectorAll(self: *Element, input: []const u8, frame: *Frame) !*Selector.List {
return Selector.querySelectorAll(self.asNode(), input, frame);
return Selector.querySelectorAll(self.asNode(), input, frame) catch |err| Selector.mapErrorToDOM(err);
}
pub fn getAnimations(_: *const Element) []*Animation {

View File

@@ -196,15 +196,6 @@ pub fn getCurrentScript(self: *const HTMLDocument) ?*Element.Html.Script {
return self._proto._current_script;
}
pub fn getLocation(self: *const HTMLDocument) ?*@import("Location.zig") {
const frame = self._proto._frame orelse return null;
return frame.window._location;
}
pub fn setLocation(self: *HTMLDocument, url: [:0]const u8, frame: *Frame) !void {
return frame.scheduleNavigation(url, .{ .reason = .script, .kind = .{ .push = null } }, .{ .script = self._proto._frame });
}
pub fn getDir(self: *HTMLDocument) []const u8 {
const el = self._proto.getDocumentElement() orelse return "";
const html = el.is(Element.Html) orelse return "";
@@ -311,7 +302,6 @@ pub const JsApi = struct {
pub const applets = bridge.accessor(HTMLDocument.getApplets, null, .{});
pub const plugins = bridge.accessor(HTMLDocument.getEmbeds, null, .{});
pub const currentScript = bridge.accessor(HTMLDocument.getCurrentScript, null, .{});
pub const location = bridge.accessor(HTMLDocument.getLocation, HTMLDocument.setLocation, .{});
pub const all = bridge.accessor(HTMLDocument.getAll, null, .{});
pub const cookie = bridge.accessor(HTMLDocument.getCookie, HTMLDocument.setCookie, .{});
pub const doctype = bridge.accessor(HTMLDocument.getDocType, null, .{});

View File

@@ -166,7 +166,7 @@ pub fn findAdjacentNodes(self: *Node, position: []const u8) !struct { *Node, ?*N
// Returned if:
// * position is not one of the four listed values.
// * The input is XML that is not well-formed.
return error.Syntax;
return error.SyntaxError;
}
pub fn firstChild(self: *const Node) ?*Node {

View File

@@ -28,6 +28,22 @@ pub const List = @import("List.zig");
const String = lp.String;
const Allocator = std.mem.Allocator;
// translate a Selector error to a DOMException known type.
pub fn mapErrorToDOM(err: anyerror) anyerror {
return switch (err) {
error.InvalidSelector,
error.InvalidAttributeSelector,
error.InvalidIDSelector,
error.InvalidClassSelector,
error.UnknownPseudoClass,
error.InvalidTagSelector,
error.InvalidPseudoClass,
error.InvalidNthPattern,
=> error.SyntaxError,
else => err,
};
}
pub fn parseLeaky(arena: Allocator, input: []const u8) !Parsed {
if (input.len == 0) {
return error.SyntaxError;