You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
NodeTraversor revisits nodes created in head() causing infinite loop when mutating (since 1.21.2)Β #2472
Thanks for the clear report and regression analysis! Yes, this was a bug.
The change in 1.21.2 for #2355 made the traversor re-enter head() on whatever node ended up in the current sibling position after a replaceWith(), before()+remove(), or after()+remove(). In cases like your example, that could loop indefinitely.
I've fixed that by refactoring the traversal path into a clearer forward-cursor walk. head() is called once for the node at the current cursor position; if that position is replaced, traversal continues from there without calling head() on the replacement again. That fixes the loop and makes the mutation path much easier to reason about.
I also added regression tests and tightened the Javadocs to spell out the supported contract:
structural changes to the current node are supported during head()
structural changes to the current node are not supported during tail()
nodes inserted before the current cursor are not visited in the same traversal
children inserted under the current node, and later siblings, may still be visited
Please go ahead and test with a snapshot install and confirm that it's fixed for you.
Just to detail this a bit more, it ended up being a bit broader than just the loop fix.
NodeTraversor now has a clearer and more capable mutation contract during head(): current-node edits like remove(), replaceWith(), and unwrap() recover from the correct cursor position, exposed children and later siblings may still be visited when they are still ahead of the traversal, and traversal stays within the original root subtree.
That makes single-pass DOM rewrite visitors more predictable and easier to reason about, beyond fixing the 1.21.2 regression that could loop when replacing the current node.
Activity
jhy commentedon Mar 6, 2026
Thanks for the clear report and regression analysis! Yes, this was a bug.
The change in 1.21.2 for #2355 made the traversor re-enter
head()on whatever node ended up in the current sibling position after areplaceWith(),before()+remove(), orafter()+remove(). In cases like your example, that could loop indefinitely.I've fixed that by refactoring the traversal path into a clearer forward-cursor walk.
head()is called once for the node at the current cursor position; if that position is replaced, traversal continues from there without callinghead()on the replacement again. That fixes the loop and makes the mutation path much easier to reason about.I also added regression tests and tightened the Javadocs to spell out the supported contract:
head()tail()Please go ahead and test with a snapshot install and confirm that it's fixed for you.
Refactor NodeTraversor.traverse to have a clearer state machine impl
jhy commentedon Mar 6, 2026
(Reworked the structural mod detection to support more cases like
unwrapetc, in a96388d)kna commentedon Mar 6, 2026
@jhy
Thank you so much! I'll check it with my code next week.
Update CHANGES.md for NodeTraversor fixes
jhy commentedon Mar 8, 2026
Just to detail this a bit more, it ended up being a bit broader than just the loop fix.
NodeTraversornow has a clearer and more capable mutation contract duringhead(): current-node edits likeremove(),replaceWith(), andunwrap()recover from the correct cursor position, exposed children and later siblings may still be visited when they are still ahead of the traversal, and traversal stays within the original root subtree.That makes single-pass DOM rewrite visitors more predictable and easier to reason about, beyond fixing the 1.21.2 regression that could loop when replacing the current node.
kna commentedon Mar 9, 2026
@jhy
I've checked jsoup on master branch with my code and it works well. Thank you.