Skip to content

NodeTraversor revisits nodes created in head() causing infinite loop when mutating (since 1.21.2)Β #2472

Closed

Description

@kna

Since 1.21.2, this code will enter an infinite loop because of 967306f .

public class Main {
    public static void main(String[] args) {
        Document document = Jsoup.parseBodyFragment("<div>a</div>");
        document.traverse(new NodeVisitor() {
            @Override
            public void head(Node node, int depth) {
                if (node instanceof TextNode) {
                    Node newNode = new TextNode("b");

                    node.replaceWith(newNode);
                    // or
                    // node.before(newNode);
                    // node.remove();
                    // or
                    // node.after(newNode);
                    // node.remove();
                }
            }
        });
    }
}

head() visits new nodes created in head() and it causes infinite loop.
It might be OK to revisit a new node to deal with it.

But this behaviour have some problems:

  • you can't use replaceWith() in head()
  • you can use before() without remove(), but not with remove()

I'm not sure if this is a bug or not. If isn't, please close this.

Activity

  1. self-assigned this
    on Mar 4, 2026
  2. jhy commented on Mar 6, 2026

    @jhy
    Owner

    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.

  3. added
    bugA confirmed bug, that we should fix
    fixedAn {bug|improvement} that has been {fixed|implemented}
    on Mar 6, 2026
  4. added this to the 1.22.2 milestone on Mar 6, 2026
  5. added a commit that references this issue on Mar 6, 2026
    b6fabf8
  6. jhy commented on Mar 6, 2026

    @jhy
    Owner

    (Reworked the structural mod detection to support more cases like unwrap etc, in a96388d)

  7. kna commented on Mar 6, 2026

    @kna
    Author

    @jhy
    Thank you so much! I'll check it with my code next week.

  8. added a commit that references this issue on Mar 8, 2026
  9. jhy commented on Mar 8, 2026

    @jhy
    Owner

    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.

  10. kna commented on Mar 9, 2026

    @kna
    Author

    @jhy
    I've checked jsoup on master branch with my code and it works well. Thank you.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugA confirmed bug, that we should fixfixedAn {bug|improvement} that has been {fixed|implemented}improvementAn improvement / new feature idea

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions