Merge pull request #1603 from egrs/wpt-spec-guards

spec compliance: missing validation guards
This commit is contained in:
Karl Seguin
2026-02-20 15:33:06 +08:00
committed by GitHub
7 changed files with 84 additions and 22 deletions

View File

@@ -681,9 +681,10 @@ const ActivationState = struct {
}
// Commit: fire input and change events only if state actually changed
// and the element is connected to a document (detached elements don't fire).
// For checkboxes, state always changes. For radios, only if was unchecked.
const state_changed = (input._input_type == .checkbox) or !self.old_checked;
if (state_changed) {
if (state_changed and input.asElement().asNode().isConnected()) {
fireEvent(page, input, "input") catch |err| {
log.warn(.event, "input event", .{ .err = err });
};

View File

@@ -311,6 +311,7 @@ fn handleError(comptime T: type, comptime F: type, local: *const Local, err: any
const js_err: *const v8.Value = switch (err) {
error.TryCatchRethrow => return,
error.InvalidArgument => isolate.createTypeError("invalid argument"),
error.TypeError => isolate.createTypeError(""),
error.OutOfMemory => isolate.createError("out of memory"),
error.IllegalConstructor => isolate.createError("Illegal Contructor"),
else => blk: {

View File

@@ -256,9 +256,9 @@ pub const JsApi = struct {
pub const childElementCount = bridge.accessor(DocumentFragment.getChildElementCount, null, .{});
pub const firstElementChild = bridge.accessor(DocumentFragment.firstElementChild, null, .{});
pub const lastElementChild = bridge.accessor(DocumentFragment.lastElementChild, null, .{});
pub const append = bridge.function(DocumentFragment.append, .{});
pub const prepend = bridge.function(DocumentFragment.prepend, .{});
pub const replaceChildren = bridge.function(DocumentFragment.replaceChildren, .{});
pub const append = bridge.function(DocumentFragment.append, .{ .dom_exception = true });
pub const prepend = bridge.function(DocumentFragment.prepend, .{ .dom_exception = true });
pub const replaceChildren = bridge.function(DocumentFragment.replaceChildren, .{ .dom_exception = true });
pub const innerHTML = bridge.accessor(_innerHTML, DocumentFragment.setInnerHTML, .{});
fn _innerHTML(self: *DocumentFragment, page: *Page) ![]const u8 {

View File

@@ -1612,13 +1612,13 @@ pub const JsApi = struct {
fn _attachShadow(self: *Element, init: ShadowRootInit, page: *Page) !*ShadowRoot {
return self.attachShadow(init.mode, page);
}
pub const replaceChildren = bridge.function(Element.replaceChildren, .{});
pub const replaceWith = bridge.function(Element.replaceWith, .{});
pub const replaceChildren = bridge.function(Element.replaceChildren, .{ .dom_exception = true });
pub const replaceWith = bridge.function(Element.replaceWith, .{ .dom_exception = true });
pub const remove = bridge.function(Element.remove, .{});
pub const append = bridge.function(Element.append, .{});
pub const prepend = bridge.function(Element.prepend, .{});
pub const before = bridge.function(Element.before, .{});
pub const after = bridge.function(Element.after, .{});
pub const append = bridge.function(Element.append, .{ .dom_exception = true });
pub const prepend = bridge.function(Element.prepend, .{ .dom_exception = true });
pub const before = bridge.function(Element.before, .{ .dom_exception = true });
pub const after = bridge.function(Element.after, .{ .dom_exception = true });
pub const firstElementChild = bridge.accessor(Element.firstElementChild, null, .{});
pub const lastElementChild = bridge.accessor(Element.lastElementChild, null, .{});
pub const nextElementSibling = bridge.accessor(Element.nextElementSibling, null, .{});

View File

@@ -189,7 +189,9 @@ pub fn getCurrentTarget(self: *const Event) ?*EventTarget {
}
pub fn preventDefault(self: *Event) void {
self._prevent_default = true;
if (self._cancelable) {
self._prevent_default = true;
}
}
pub fn stopPropagation(self: *Event) void {
@@ -210,7 +212,12 @@ pub fn getReturnValue(self: *const Event) bool {
}
pub fn setReturnValue(self: *Event, v: bool) void {
self._prevent_default = !v;
if (!v) {
// Setting returnValue=false is equivalent to preventDefault()
if (self._cancelable) {
self._prevent_default = true;
}
}
}
pub fn getCancelBubble(self: *const Event) bool {

View File

@@ -49,10 +49,11 @@ node: std.DoublyLinkedList.Node = .{},
const Observing = struct {
target: *Node,
options: ObserveOptions,
options: ResolvedOptions,
};
pub const ObserveOptions = struct {
/// Internal options with all nullable bools resolved to concrete values.
const ResolvedOptions = struct {
attributes: bool = false,
attributeOldValue: bool = false,
childList: bool = false,
@@ -62,6 +63,16 @@ pub const ObserveOptions = struct {
attributeFilter: ?[]const []const u8 = null,
};
pub const ObserveOptions = struct {
attributes: ?bool = null,
attributeOldValue: ?bool = null,
childList: bool = false,
characterData: ?bool = null,
characterDataOldValue: ?bool = null,
subtree: bool = false,
attributeFilter: ?[]const []const u8 = null,
};
pub fn init(callback: js.Function.Temp, page: *Page) !*MutationObserver {
const arena = try page.getArena(.{ .debug = "MutationObserver" });
errdefer page.releaseArena(arena);
@@ -88,24 +99,61 @@ pub fn deinit(self: *MutationObserver, shutdown: bool) void {
pub fn observe(self: *MutationObserver, target: *Node, options: ObserveOptions, page: *Page) !void {
const arena = self._arena;
// Per spec: if attributeOldValue/attributeFilter present and attributes
// not explicitly set, imply attributes=true. Same for characterData.
var resolved = options;
if (resolved.attributes == null and (resolved.attributeOldValue != null or resolved.attributeFilter != null)) {
resolved.attributes = true;
}
if (resolved.characterData == null and resolved.characterDataOldValue != null) {
resolved.characterData = true;
}
const attributes = resolved.attributes orelse false;
const character_data = resolved.characterData orelse false;
// Validate: at least one of childList/attributes/characterData must be true
if (!resolved.childList and !attributes and !character_data) {
return error.TypeError;
}
// Validate: attributeOldValue/attributeFilter require attributes != false
if ((resolved.attributeOldValue orelse false) and !attributes) {
return error.TypeError;
}
if (resolved.attributeFilter != null and !attributes) {
return error.TypeError;
}
// Validate: characterDataOldValue requires characterData != false
if ((resolved.characterDataOldValue orelse false) and !character_data) {
return error.TypeError;
}
// Build resolved options with concrete bool values
var store_options = ResolvedOptions{
.attributes = attributes,
.attributeOldValue = resolved.attributeOldValue orelse false,
.childList = resolved.childList,
.characterData = character_data,
.characterDataOldValue = resolved.characterDataOldValue orelse false,
.subtree = resolved.subtree,
.attributeFilter = resolved.attributeFilter,
};
// Deep copy attributeFilter if present
var copied_options = options;
if (options.attributeFilter) |filter| {
const filter_copy = try arena.alloc([]const u8, filter.len);
for (filter, 0..) |name, i| {
filter_copy[i] = try arena.dupe(u8, name);
}
copied_options.attributeFilter = filter_copy;
}
if (options.characterDataOldValue) {
copied_options.characterData = true;
store_options.attributeFilter = filter_copy;
}
// Check if already observing this target
for (self._observing.items) |*obs| {
if (obs.target == target) {
obs.options = copied_options;
obs.options = store_options;
return;
}
}
@@ -118,7 +166,7 @@ pub fn observe(self: *MutationObserver, target: *Node, options: ObserveOptions,
try self._observing.append(arena, .{
.target = target,
.options = copied_options,
.options = store_options,
});
}

View File

@@ -209,6 +209,11 @@ fn validateNodeInsertion(parent: *Node, node: *Node) !void {
if (node._type == .attribute) {
return error.HierarchyError;
}
// Doctype nodes can only be inserted into a Document
if (node._type == .document_type and parent._type != .document) {
return error.HierarchyError;
}
}
pub fn appendChild(self: *Node, child: *Node, page: *Page) !*Node {