fix(js): honour NodeFilter.FILTER_REJECT in TreeWalker (#461)

`_accept` collapsed every non-ACCEPT filter result into a single boolean,
so FILTER_REJECT behaved exactly like FILTER_SKIP: the node was not
returned, but the walk still descended into its subtree. Per DOM 6.1,
FILTER_REJECT rejects a node *and* its descendants.

This was unreachable until #440 exposed the real NodeFilter constants,
which made the canonical prune idiom

    if (node.tagName === 'SCRIPT') return NodeFilter.FILTER_REJECT;

start yielding nodes from inside the pruned subtree instead of failing
loudly — sanitizers and text extractors built on TreeWalker silently got
extra content.

Split the filter into a three-valued `_filter` (keeping `_accept` as the
boolean wrapper the sibling/child movers use) and give nextNode a second
traversal step for the reject case. That step lives in the DOM layer as
`DomTree::next_after_subtree`, mirroring how #447 put `next_in_subtree`
there: both share the ancestor climb, and REJECT costs no more JS/native
crossings than SKIP.

NodeIterator shares this walker but has no subtree pruning (DOM 6.2:
FILTER_REJECT behaves as FILTER_SKIP), so `createNodeIterator` marks the
walker `_rejectIsSkip` and keeps the plain step. Covered by its own test
so the shared implementation cannot drift.
This commit is contained in:
Andrey Mnatsakanov committed 2026-07-22 18:57:00 +02:00
1 parent 2974a71f3c
commit 1a5c27a592
4 files changed
+141 -9

No files matched your search

+25 -9
View File
@@ -2616,21 +2616,33 @@ class Document extends Node {
currentNode: root,
whatToShow: whatToShow,
filter: filter || null,
_accept(node) {
// Three-valued per NodeFilter: 1 ACCEPT, 2 REJECT, 3 SKIP. REJECT and
// SKIP both mean "don't return this node", but only REJECT prunes its
// descendants, so nextNode() needs to tell them apart (issue #461).
// A node filtered out by whatToShow is a SKIP: the spec never consults
// the filter for it, and its descendants stay eligible.
_filter(node) {
const nodeType = node.nodeType;
const show = (whatToShow >> (nodeType - 1)) & 1;
if (!show) return false;
if (!((whatToShow >> (nodeType - 1)) & 1)) return 3;
if (this.filter) {
if (typeof this.filter === 'function') return this.filter(node) === 1;
if (this.filter.acceptNode) return this.filter.acceptNode(node) === 1;
if (typeof this.filter === 'function') return this.filter(node);
if (this.filter.acceptNode) return this.filter.acceptNode(node);
}
return true;
return 1;
},
_accept(node) { return this._filter(node) === 1; },
nextNode() {
let node = _wrap(+_dom("next_in_subtree", this.root._nid, this.currentNode._nid));
while (node) {
if (this._accept(node)) { this.currentNode = node; return node; }
node = _wrap(+_dom("next_in_subtree", this.root._nid, node._nid));
const verdict = this._filter(node);
if (verdict === 1) { this.currentNode = node; return node; }
// FILTER_REJECT skips the node AND its subtree; FILTER_SKIP (and any
// other non-accept value) skips only the node. NodeIterator has no
// pruning at all, so `_rejectIsSkip` keeps it on the plain step.
const step = (verdict === 2 && !this._rejectIsSkip)
? "next_after_subtree"
: "next_in_subtree";
node = _wrap(+_dom(step, this.root._nid, node._nid));
}
return null;
},
@@ -2693,7 +2705,11 @@ class Document extends Node {
return walker;
}
createNodeIterator(root, whatToShow, filter) {
return this.createTreeWalker(root, whatToShow, filter);
// Shares the TreeWalker implementation, but DOM 6.2 gives NodeIterator no
// subtree pruning: FILTER_REJECT behaves exactly as FILTER_SKIP there.
const iterator = this.createTreeWalker(root, whatToShow, filter);
iterator._rejectIsSkip = true;
return iterator;
}
getSelection() { return this.defaultView ? _selectionFor(this) : null; }
get activeElement() { return globalThis.__obscura_focused || this.body; }