Merge pull request #471 from mnaza/fix/treewalker-movers-469

fix(js): descend on FILTER_SKIP in TreeWalker child/sibling movers (#469)
This commit is contained in:
SGavrl authored and GitHub committed 2026-07-24 15:21:58 +00:00
commit e314b9b0f3
3 files changed
+425 -28

No files matched your search

+49 -28
View File
@@ -2710,42 +2710,63 @@ class Document extends Node {
}
return null;
},
firstChild() {
let child = this.currentNode.firstChild;
while (child) {
if (this._accept(child)) { this.currentNode = child; return child; }
child = child.nextSibling;
// DOM 6.1 "traverse children" (issue #469). The movers used to step
// straight to the next sibling when a node was not accepted, so a
// FILTER_SKIP node hid its children instead of exposing them. `edge` and
// `step` pick the direction: first/next for forward, last/previous for
// backward.
_traverseChildren(edge, step) {
let node = this.currentNode[edge];
while (node) {
const verdict = this._filter(node);
if (verdict === 1) { this.currentNode = node; return node; }
// Only SKIP leaves the children eligible; REJECT prunes the subtree.
if (verdict === 3) {
const child = node[edge];
if (child) { node = child; continue; }
}
// Subtree exhausted: step sideways, climbing out without passing
// root or the node the walk started from.
while (node) {
const sibling = node[step];
if (sibling) { node = sibling; break; }
const parent = node.parentNode;
if (!parent || parent === this.root || parent === this.currentNode) return null;
node = parent;
}
}
return null;
},
lastChild() {
let child = this.currentNode.lastChild;
while (child) {
if (this._accept(child)) { this.currentNode = child; return child; }
child = child.previousSibling;
// DOM 6.1 "traverse siblings" (issue #469).
_traverseSiblings(edge, step) {
let node = this.currentNode;
if (node === this.root) return null;
for (;;) {
let sibling = node[step];
while (sibling) {
node = sibling;
const verdict = this._filter(node);
if (verdict === 1) { this.currentNode = node; return node; }
// Descend into a skipped sibling's subtree; a rejected one is
// off-limits, and a childless one has nothing to descend into.
sibling = node[edge];
if (verdict === 2 || !sibling) sibling = node[step];
}
node = node.parentNode;
if (!node || node === this.root) return null;
// An accepted parent is where the walk would go next, so there is no
// sibling to return.
if (this._filter(node) === 1) return null;
}
return null;
},
nextSibling() {
let sibling = this.currentNode.nextSibling;
while (sibling) {
if (this._accept(sibling)) { this.currentNode = sibling; return sibling; }
sibling = sibling.nextSibling;
}
return null;
},
previousSibling() {
let sibling = this.currentNode.previousSibling;
while (sibling) {
if (this._accept(sibling)) { this.currentNode = sibling; return sibling; }
sibling = sibling.previousSibling;
}
return null;
},
firstChild() { return this._traverseChildren('firstChild', 'nextSibling'); },
lastChild() { return this._traverseChildren('lastChild', 'previousSibling'); },
nextSibling() { return this._traverseSiblings('firstChild', 'nextSibling'); },
previousSibling() { return this._traverseSiblings('lastChild', 'previousSibling'); },
// DOM 6.1 "parentNode" (issue #475). The old version looked only at the
// immediate parent, so it couldn't climb past a skipped ancestor; it also
// excluded `root` as a result yet stepped to root's own parent when
// currentNode was root — returning a node OUTSIDE the walker's subtree.
// currentNode was root, returning a node OUTSIDE the walker's subtree.
// The loop's `node !== this.root` guard is what keeps the walk inside
// root while still allowing root itself to be returned.
parentNode() {
+93
View File
@@ -1920,6 +1920,99 @@ mod tests {
assert_eq!(result.value.unwrap(), serde_json::json!([1, 1, 400]));
}
/// Issue #469: FILTER_SKIP leaves a skipped node's children eligible, so
/// firstChild()/lastChild() must descend into them. FILTER_REJECT must not.
#[test]
fn tree_walker_child_movers_descend_on_skip_but_not_on_reject() {
let mut rt = setup_runtime(
r#"<div id="root"><section><a></a><b></b></section></div>"#,
);
let result = rt
.evaluate(
r#"
const root = document.getElementById('root');
function mover(verdict, method) {
const w = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, {
acceptNode(node) {
return node.tagName === 'SECTION' ? verdict : NodeFilter.FILTER_ACCEPT;
}
});
const found = w[method]();
return found ? found.tagName : null;
}
return [
mover(NodeFilter.FILTER_SKIP, 'firstChild'),
mover(NodeFilter.FILTER_SKIP, 'lastChild'),
mover(NodeFilter.FILTER_REJECT, 'firstChild'),
mover(NodeFilter.FILTER_REJECT, 'lastChild'),
];
"#,
)
.unwrap();
// SKIP descends into <section>; REJECT prunes it and finds nothing else.
assert_eq!(result, serde_json::json!(["A", "B", null, null]));
}
/// Issue #469: nextSibling()/previousSibling() must descend into a skipped
/// sibling's subtree rather than stepping straight over it.
#[test]
fn tree_walker_sibling_movers_descend_into_skipped_siblings() {
let mut rt = setup_runtime(
r#"<div id="root"><p id="start"></p><section><a></a></section><q></q></div>"#,
);
let result = rt
.evaluate(
r#"
const root = document.getElementById('root');
function mover(verdict, method, from) {
const w = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, {
acceptNode(node) {
return node.tagName === 'SECTION' ? verdict : NodeFilter.FILTER_ACCEPT;
}
});
w.currentNode = document.getElementById(from);
const found = w[method]();
return found ? found.tagName : null;
}
return [
// <section> is skipped, so its child <a> is the next sibling.
mover(NodeFilter.FILTER_SKIP, 'nextSibling', 'start'),
// Rejected: the subtree is off-limits, so skip past to <q>.
mover(NodeFilter.FILTER_REJECT, 'nextSibling', 'start'),
];
"#,
)
.unwrap();
assert_eq!(result, serde_json::json!(["A", "Q"]));
}
/// Issue #469: the backward sibling mover descends to *last* children.
#[test]
fn tree_walker_previous_sibling_descends_to_last_child() {
let mut rt = setup_runtime(
r#"<div id="root"><section><a></a><b></b></section><p id="start"></p></div>"#,
);
let result = rt
.evaluate(
r#"
const root = document.getElementById('root');
const w = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, {
acceptNode(node) {
return node.tagName === 'SECTION'
? NodeFilter.FILTER_SKIP
: NodeFilter.FILTER_ACCEPT;
}
});
w.currentNode = document.getElementById('start');
const found = w.previousSibling();
return found ? found.tagName : null;
"#,
)
.unwrap();
// Reverse order descends to <section>'s last child, not its first.
assert_eq!(result, serde_json::json!("B"));
}
#[test]
fn append_child_flattens_document_fragment() {
let mut rt = setup_runtime(r#"<main id="host"></main>"#);