webapi: NodeFilter follows WebIDL callback interface semantics

Fixes the 5 failing subtests of WPT
/dom/traversal/TreeWalker-acceptNode-filter.html (7/12 -> 12/12):

- A filter object was converted eagerly at createTreeWalker time, so an
  object without a callable acceptNode threw "invalid argument" at
  creation. Per WebIDL any object converts to the NodeFilter callback
  interface; the TypeError belongs at invocation time.
- The acceptNode member was cached at conversion. Per "call a user
  object's operation" it must be looked up with a fresh Get on every
  traversal, rethrowing errors from a throwing getter.
- The callback was invoked with the default this; the spec requires the
  filter object itself as the this value.

NodeFilter now stores the raw function or object (js.Object.Global) and
performs the per-invocation lookup, callability check and this-binding
in acceptNode.

Coverage: /dom/traversal/TreeWalker-acceptNode-filter.html 7/12 ->
12/12 (fully green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Francis Bouvier
2026-07-11 16:15:27 +02:00
parent 6db15601ff
commit 54069e565c
3 changed files with 24 additions and 19 deletions

View File

@@ -60,7 +60,7 @@ pub fn getWhatToShow(self: *const DOMNodeIterator) u32 {
}
pub fn getFilter(self: *const DOMNodeIterator) ?FilterOpts {
return self._filter._original_filter;
return self._filter._opts;
}
pub fn nextNode(self: *DOMNodeIterator, frame: *Frame) !?*Node {

View File

@@ -52,7 +52,7 @@ pub fn getWhatToShow(self: *const DOMTreeWalker) u32 {
}
pub fn getFilter(self: *const DOMTreeWalker) ?FilterOpts {
return self._filter._original_filter;
return self._filter._opts;
}
pub fn getCurrentNode(self: *const DOMTreeWalker) *Node {

View File

@@ -21,27 +21,17 @@ const Node = @import("Node.zig");
const NodeFilter = @This();
_func: ?js.Function.Global,
_original_filter: ?FilterOpts,
_opts: ?FilterOpts,
pub const FilterOpts = union(enum) {
function: js.Function.Global,
object: struct {
pub const js_as_object = true;
acceptNode: js.Function.Global,
},
// Any object is a valid callback interface; whether its acceptNode
// member is callable is only checked when the filter is invoked.
object: js.Object.Global,
};
pub fn init(opts_: ?FilterOpts) !NodeFilter {
const opts = opts_ orelse return .{ ._func = null, ._original_filter = null };
const func = switch (opts) {
.function => |func| func,
.object => |obj| obj.acceptNode,
};
return .{
._func = func,
._original_filter = opts_,
};
return .{ ._opts = opts_ };
}
// Constants
@@ -65,8 +55,23 @@ pub const SHOW_DOCUMENT_FRAGMENT: u32 = 0x400;
pub const SHOW_NOTATION: u32 = 0x800;
pub fn acceptNode(self: *const NodeFilter, node: *Node, local: *const js.Local) !i32 {
const func = self._func orelse return FILTER_ACCEPT;
return local.toLocal(func).callRethrow(i32, .{node});
const opts = self._opts orelse return FILTER_ACCEPT;
switch (opts) {
.function => |func| return local.toLocal(func).callRethrow(i32, .{node}),
.object => |obj| {
// Per WebIDL "call a user object's operation": the acceptNode
// member is looked up on every invocation (rethrowing getter
// errors), must be callable (TypeError otherwise), and is
// invoked with the filter object as its this value.
const filter_obj = obj.local(local);
const member = try filter_obj.get("acceptNode");
if (!member.isFunction()) {
return error.TypeError;
}
const func = js.Function{ .local = local, .handle = @ptrCast(member.handle) };
return func.callWithThisRethrow(i32, filter_obj, .{node});
},
}
}
pub fn shouldShow(node: *const Node, what_to_show: u32) bool {