diff --git a/CHANGES.md b/CHANGES.md
index 0b7c25e084..fe616b8025 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -11,6 +11,7 @@
* Named character references without a semicolon before `-` or `_` now decode correctly in attribute values, matching HTML and browser behavior (e.g., `©-` becomes `©-`). [#2588](https://github.com/jhy/jsoup/issues/2588)
* Support downloads > 2GB via `Response.bodyStream()` when `maxBodySize(0)` is configured. [#2593](https://github.com/jhy/jsoup/issues/2593)
* Updated table and table-fragment parsing to place misnested content correctly per the HTML5 spec. For example, `
X` places the paragraph before the table. [#2601](https://github.com/jhy/jsoup/issues/2601)
+* `StreamParser` now preserves its progress if you use the same `Parser` to parse another document, or call `Element.append(String)` on a streamed element. Previously, these operations replaced the active parse state and prevented streaming from continuing correctly. [#2605](https://github.com/jhy/jsoup/issues/2605)
* Updated the adoption-agency algorithm to match the current HTML5 spec and to preserve formatting order when recovering misnested elements. [#2604](https://github.com/jhy/jsoup/pull/2604)
## 1.23.2 (2026-Aug-26)
diff --git a/src/main/java/org/jsoup/parser/StreamParser.java b/src/main/java/org/jsoup/parser/StreamParser.java
index 607c1ad16f..3e52bebd1c 100644
--- a/src/main/java/org/jsoup/parser/StreamParser.java
+++ b/src/main/java/org/jsoup/parser/StreamParser.java
@@ -57,18 +57,19 @@ Selectors that depend on knowing all siblings (e.g. {@code :last-child}, {@code
@since 1.18.1 */
public class StreamParser implements Closeable {
final private Parser parser;
- final private TreeBuilder treeBuilder;
+ final TreeBuilder treeBuilder;
final private ElementIterator it = new ElementIterator();
@Nullable private Document document;
- private boolean stopped = false;
+ private boolean stopped = true;
/**
Construct a new StreamParser, using the supplied base Parser.
+ The supplied parser provides configuration and collects parse errors; streaming uses an independent tree builder.
@param parser the configured base parser
*/
public StreamParser(Parser parser) {
this.parser = parser;
- treeBuilder = parser.getTreeBuilder();
+ treeBuilder = parser.getTreeBuilder().newInstance();
treeBuilder.nodeListener(it);
}
@@ -180,7 +181,6 @@ read. Structural changes (e.g. insert, remove) may be made to the Document conte
@return the (partial) Document
*/
public Document document() {
- document = treeBuilder.doc;
Validate.notNull(document, "Must run parse() before calling.");
return document;
}
diff --git a/src/test/java/org/jsoup/parser/HtmlTreeBuilderTest.java b/src/test/java/org/jsoup/parser/HtmlTreeBuilderTest.java
index c61eddca06..6e0f19ed89 100644
--- a/src/test/java/org/jsoup/parser/HtmlTreeBuilderTest.java
+++ b/src/test/java/org/jsoup/parser/HtmlTreeBuilderTest.java
@@ -8,6 +8,7 @@
import org.junit.jupiter.api.Test;
import java.io.IOException;
+import java.io.StringReader;
import java.lang.annotation.Annotation;
import java.util.ArrayList;
import java.util.List;
@@ -130,11 +131,16 @@ public class HtmlTreeBuilderTest {
// exercise an adoption error, then verify that the parser can consume the remaining input
private static void assertAdoptionRecovery(HtmlTreeBuilder tb, Consumer exercise, String message) throws IOException {
Parser parser = new Parser(tb).setTrackErrors(20);
- try (StreamParser stream = new StreamParser(parser).parseFragment("After
", new Element("div"), "")) {
+ tb.initialiseParse(new StringReader("After
"), "", parser);
+ tb.initialiseParseFragment(new Element("div"));
+ try {
exercise.accept(tb);
assertTrue(parser.getErrors().stream().anyMatch(error -> error.getErrorMessage().equals(message)),
() -> "Expected error: " + message + "; got: " + parser.getErrors());
- assertEquals("After", stream.complete().text());
+ tb.runParser();
+ assertEquals("After", tb.doc.text());
+ } finally {
+ tb.closeParse();
}
}
@@ -185,7 +191,7 @@ private static void assertAdoptionRecovery(HtmlTreeBuilder tb, ConsumerOneFull
", "")) {
+ TreeBuilder treeBuilder = streamParser.treeBuilder;
+ assertFalse(treeBuilder.isComplete());
streamParser.expectFirst("title");
Element open = streamParser.document().expectFirst("#hit");
assertTrue(treeBuilder.isOpen(open));
diff --git a/src/test/java/org/jsoup/parser/StreamParserTest.java b/src/test/java/org/jsoup/parser/StreamParserTest.java
index 2f24613de7..a6f288a1bc 100644
--- a/src/test/java/org/jsoup/parser/StreamParserTest.java
+++ b/src/test/java/org/jsoup/parser/StreamParserTest.java
@@ -96,6 +96,72 @@ void canStreamXml() {
assertEquals("", seen3.toString());
}
+ @Test void streamsAfterParserReuse() throws IOException {
+ Parser parser = Parser.htmlParser();
+ try (StreamParser streamer = new StreamParser(parser).parse("One
Two
", "")) {
+ Document doc = streamer.document();
+ assertSame(parser, doc.parser());
+ assertEquals("One", streamer.expectFirst("p").text());
+
+ Document ordinary = parser.parseInput("Other
", "");
+ assertEquals("Other", ordinary.body().text());
+
+ assertSame(doc, streamer.document());
+ assertEquals("Two", streamer.selectNext("p").text());
+ assertEquals("One
\nTwo
", streamer.complete().body().html());
+ }
+ }
+
+ @Test void streamsAfterHtmlAppend() throws IOException {
+ Parser parser = Parser.htmlParser();
+ try (StreamParser streamer = new StreamParser(parser).parse("One
Two
", "")) {
+ Document doc = streamer.document();
+ assertSame(parser, doc.parser());
+ Element first = streamer.expectFirst("p");
+ assertEquals("One", first.text());
+
+ first.append("Added");
+ assertEquals("OneAdded", first.html());
+
+ assertSame(doc, streamer.document());
+ assertEquals("Two", streamer.selectNext("p").text());
+ assertEquals("OneAdded
\nTwo
", streamer.complete().body().html());
+ }
+ }
+
+ @Test void sharesParserSettingsAndErrors() throws IOException {
+ Parser parser = Parser.htmlParser();
+ try (StreamParser streamer = new StreamParser(parser)) {
+ parser.settings(ParseSettings.preserveCase).setTrackPosition(true).setTrackErrors(10);
+ Document doc = streamer.parse("One
", "").complete();
+
+ assertSame(parser, doc.parser());
+ Element p = doc.expectFirst("P");
+ assertEquals("P", p.tagName());
+ assertTrue(p.attributes().hasKey("ID"));
+ assertTrue(p.sourceRange().isTracked());
+ assertFalse(parser.getErrors().isEmpty());
+ }
+ }
+
+ @Test void iteratorEmptyBeforeParse() {
+ try (StreamParser streamer = new StreamParser(Parser.htmlParser())) {
+ Iterator iterator = streamer.iterator();
+ assertFalse(iterator.hasNext());
+ assertThrows(NoSuchElementException.class, iterator::next);
+ }
+ }
+
+ @Test void parserReuseDoesNotStartStream() {
+ Parser parser = Parser.htmlParser();
+ try (StreamParser streamer = new StreamParser(parser)) {
+ parser.parseInput("Other
", "");
+
+ assertThrows(IllegalArgumentException.class, streamer::document);
+ assertFalse(streamer.iterator().hasNext());
+ }
+ }
+
@Test void canStopAndCompleteAndReuse() throws IOException {
StreamParser parser = new StreamParser(Parser.htmlParser());
String html1 = "One
Two";
@@ -526,12 +592,11 @@ static StreamParser basic() {
}
static boolean isClosed(StreamParser streamer) {
- // a bit of a back door in!
return getReader(streamer) == null;
}
- private static CharacterReader getReader(StreamParser streamer) {
- return streamer.document().parser().getTreeBuilder().reader;
+ private static CharacterReader getReader(StreamParser streamer) {
+ return streamer.treeBuilder.reader;
}
@Test void doesNotReadPastParse() throws IOException {
@@ -694,7 +759,7 @@ void canStreamFragment() {
.parseFragment("
One
Two
", context, "")) {
Element container = parser.expectFirst("#context");
assertEquals("One Two", container.text());
- assertFalse(parser.document().parser().getTreeBuilder().isOpen(container));
+ assertFalse(parser.treeBuilder.isOpen(container));
assertSame(parser.document().child(0), container);
assertEquals(container.childNodes(), parser.completeFragment());
}