Skip to content

feat: allow flushing buffered character tokens - #1854

Open
domenic wants to merge 4 commits into
inikulin:masterfrom
domenic:codex/flush-character-tokens
Open

domenic wants to merge 4 commits into
inikulin:masterfrom
domenic:codex/flush-character-tokens

Conversation

@domenic

@domenic domenic commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

I asked GPT-6 Astra Extra High for the minimal parse5 change that would allow us to implement document.write() correctly in jsdom. It seems like it's mostly additive. It spent most of its time on tests, which is a good sign, and in particular was particularly concerned with getting the character offsets right.

I asked it a few times

Perform adversarial self-review. Is this a PR a human maintainer would be happy to receive?

and by the third round it seemed like it was happy with the results.

Thank you for the great library :)

Add flushCharacters() to ParserStream and Tokenizer instances. Character-token batching can leave trailing text out of the tree until more input arrives. Consumers implementing document.write(), such as jsdom, need to make that text synchronously observable without ending the input stream.

Flushing delivers buffered character tokens but leaves incomplete tags and character references pending. It does not resume suspended scripts, drain queued stream writes, or bypass the tree builder's pending table-character processing. Calls from parser callbacks are rejected to prevent reentrant token delivery.

Derive source boundaries from tokenizer state at flush time so ordinary parsing needs no per-character boundary tracking. Keep attribute locations independent of character locations, since flushing can occur while an attribute is incomplete. Correct source positions for lookahead emissions and surrogate pairs so offsets and columns agree.

Flushing splits character-token runs, so text-node source ranges can differ from parsing without flushing: subsequently ignored input, such as CDATA delimiters or the leading newline in a <pre> element, does not extend an already flushed run.

Co-Authored-By: codex <codex@openai.com>
domenic added a commit to jsdom/jsdom that referenced this pull request Sep 21, 2026
Suspend HTML and XML parsing at parsing-blocking scripts until their source and blocking stylesheets are ready. Resume from the same input position, preserving parser order without delaying unrelated resources behind scripts. Include blocking stylesheets in ancestor documents and stop consuming input when the document is stopped, replaced, or destroyed.

Feed document.write() into the HTML parser's input stream, retaining insertion points across nested scripts and processing written markup synchronously. Keep script-created input streams open until document.close(). Mark scripts from inert fragment parsing as already started, while preserving executable HTML fragments from range.createContextualFragment(). Report exceptions from synchronous customized built-in element construction without abandoning parsing.

Invoke custom element insertion reactions before processing subsequent tokens, including upgrades in shadow trees. Preserve written scripts and pending input when lifecycle callbacks reenter document.write() or document.close().

Construct initial about:blank frame documents directly and fire their frame load events synchronously, independent of registered event listeners. Keep subsequent navigations on the document-loading path.

The JSDOM constructor and fromURL()/fromFile() can now return a partially parsed document when a blocking resource is pending. Report XML syntax errors encountered after asynchronous resumption through the virtual console; synchronous constructor errors still throw. Documents without pending blockers remain synchronous.

Use supported parse5-parser-stream and saxes APIs. Trailing character data from document.write() can still remain buffered until another token arrives; exposing it synchronously requires inikulin/parse5#1854. Writes from parse5 tree-builder callbacks remain buffered until the callback returns because parse5 cannot tokenize that input reentrantly. Full VM microtask checkpoints remain unimplemented, affecting custom element construction, slotchange delivery, and mutation observer ordering around parser-inserted scripts.

Refs #2413.

Co-authored-by: codex <codex@openai.com>
domenic added a commit to jsdom/jsdom that referenced this pull request Sep 21, 2026
Suspend HTML and XML parsing at parsing-blocking scripts until their source and blocking stylesheets are ready. Resume from the same input position, preserving parser order without delaying unrelated resources behind scripts. Include blocking stylesheets in ancestor documents and stop consuming input when the document is stopped, replaced, or destroyed.

Feed document.write() into the HTML parser's input stream, retaining insertion points across nested scripts and processing written markup synchronously. Keep script-created input streams open until document.close(). Mark scripts from inert fragment parsing as already started, while preserving executable HTML fragments from range.createContextualFragment(). Report exceptions from synchronous customized built-in element construction without abandoning parsing.

Invoke custom element insertion reactions before processing subsequent tokens, including upgrades in shadow trees. Preserve written scripts and pending input when lifecycle callbacks reenter document.write() or document.close().

Construct initial about:blank frame documents directly and fire their frame load events synchronously, independent of registered event listeners. Keep subsequent navigations on the document-loading path.

The JSDOM constructor and fromURL()/fromFile() can now return a partially parsed document when a blocking resource is pending. Report XML syntax errors encountered after asynchronous resumption through the virtual console; synchronous constructor errors still throw. Documents without pending blockers remain synchronous.

Use supported parse5-parser-stream and saxes APIs. Trailing character data from document.write() can still remain buffered until another token arrives; exposing it synchronously requires inikulin/parse5#1854. Writes from parse5 tree-builder callbacks remain buffered until the callback returns because parse5 cannot tokenize that input reentrantly. Full VM microtask checkpoints remain unimplemented, affecting custom element construction, slotchange delivery, and mutation observer ordering around parser-inserted scripts.

Refs #2413.

Co-authored-by: codex <codex@openai.com>
domenic added a commit to jsdom/jsdom that referenced this pull request Sep 21, 2026
Suspend HTML and XML parsing at parsing-blocking scripts until their source and blocking stylesheets are ready. Resume from the same input position, preserving parser order without delaying unrelated resources behind scripts. Include blocking stylesheets in ancestor documents and stop consuming input when the document is stopped, replaced, or destroyed.

Feed document.write() into the HTML parser's input stream, retaining insertion points across nested scripts and processing written markup synchronously. Keep script-created input streams open until document.close(). Mark scripts from inert fragment parsing as already started, while preserving executable HTML fragments from range.createContextualFragment(). Report exceptions from synchronous customized built-in element construction without abandoning parsing.

Invoke custom element insertion reactions before processing subsequent tokens, including upgrades in shadow trees. Preserve written scripts and pending input when lifecycle callbacks reenter document.write() or document.close().

Construct initial about:blank frame documents directly and fire their frame load events synchronously, independent of registered event listeners. Keep subsequent navigations on the document-loading path.

The JSDOM constructor and fromURL()/fromFile() can now return a partially parsed document when a blocking resource is pending. Report XML syntax errors encountered after asynchronous resumption through the virtual console; synchronous constructor errors still throw. Documents without pending blockers remain synchronous.

Use supported parse5-parser-stream and saxes APIs. Trailing character data from document.write() can still remain buffered until another token arrives; exposing it synchronously requires inikulin/parse5#1854. Writes from parse5 tree-builder callbacks remain buffered until the callback returns because parse5 cannot tokenize that input reentrantly. Full VM microtask checkpoints remain unimplemented, affecting custom element construction, slotchange delivery, and mutation observer ordering around parser-inserted scripts.

Refs #2413.

Co-authored-by: codex <codex@openai.com>
domenic added a commit to jsdom/jsdom that referenced this pull request Sep 21, 2026
Suspend HTML and XML parsing at parsing-blocking scripts until their source and blocking stylesheets are ready. Resume from the same input position, preserving parser order without delaying unrelated resources behind scripts. Include blocking stylesheets in ancestor documents and stop consuming input when the document is stopped, replaced, or destroyed.

Feed document.write() into the HTML parser's input stream, retaining insertion points across nested scripts and processing written markup synchronously. Keep script-created input streams open until document.close(). Mark scripts from inert fragment parsing as already started, while preserving executable HTML fragments from range.createContextualFragment(). Report exceptions from synchronous customized built-in element construction without abandoning parsing.

Invoke custom element insertion reactions before processing subsequent tokens, including upgrades in shadow trees. Preserve written scripts and pending input when lifecycle callbacks reenter document.write() or document.close().

Construct initial about:blank frame documents directly and fire their frame load events synchronously, independent of registered event listeners. Keep subsequent navigations on the document-loading path.

The JSDOM constructor and fromURL()/fromFile() can now return a partially parsed document when a blocking resource is pending. Report XML syntax errors encountered after asynchronous resumption through the virtual console; synchronous constructor errors still throw. Documents without pending blockers remain synchronous.

Use supported parse5-parser-stream and saxes APIs. Trailing character data from document.write() can still remain buffered until another token arrives; exposing it synchronously requires inikulin/parse5#1854. Writes from parse5 tree-builder callbacks remain buffered until the callback returns because parse5 cannot tokenize that input reentrantly. Full VM microtask checkpoints remain unimplemented, affecting custom element construction, slotchange delivery, and mutation observer ordering around parser-inserted scripts.

Refs #2413.

Co-authored-by: codex <codex@openai.com>
@43081j

43081j commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator

given its still a large change, can you explain what its for?

what exactly are you trying to do in JSDOM that needs it? some context would be good

@domenic

domenic commented Sep 22, 2026

Copy link
Copy Markdown
Contributor Author

It's for jsdom's proper parser-blocking scripts / document.write() implementation. This should log "hello", but currently logs "":

document.open();
document.write("<p>hello");
console.log(document.querySelector("p").textContent);

We need to flush parse5's buffered text before document.write() returns, without ending the input stream.

domenic added a commit to jsdom/jsdom that referenced this pull request Sep 22, 2026
Suspend HTML and XML parsing at parsing-blocking scripts until their source and blocking stylesheets are ready. Resume from the same input position, preserving parser order without delaying unrelated resources behind scripts. Include blocking stylesheets in ancestor documents and stop consuming input when the document is stopped, replaced, or destroyed. Replacing the parser cancels its blocking and deferred scripts without aborting independent requests.

Feed document.write() into the HTML parser's input stream, retaining insertion points across nested scripts and processing written markup synchronously. Keep script-created input streams open until document.close(). Mark scripts from inert fragment parsing as already started, while preserving executable HTML fragments from range.createContextualFragment().

Invoke custom element insertion reactions before processing subsequent tokens, including upgrades in shadow trees. Report exceptions from synchronous customized built-in element construction without abandoning parsing. Preserve written scripts and pending input when lifecycle callbacks reenter document.write() or document.close().

Construct initial about:blank frame documents directly and fire their frame load events synchronously, independent of registered event listeners. Keep subsequent navigations on the document-loading path. Report unexpected frame parsing failures through resource error handling and release parent-document load delays, including when parsing resumes after a blocking script.

The JSDOM constructor and fromURL()/fromFile() can now return a partially parsed document when a blocking resource is pending. Report XML syntax errors encountered after asynchronous resumption through the virtual console; synchronous constructor errors still throw. Documents without pending blockers remain synchronous.

Use supported parse5-parser-stream and saxes APIs, feeding ordinary markup in large chunks while retaining script execution checkpoints. Trailing character data from document.write() can still remain buffered until another token arrives; exposing it synchronously requires inikulin/parse5#1854. Writes from parse5 tree-builder callbacks remain buffered until the callback returns because parse5 cannot tokenize that input reentrantly. Full VM microtask checkpoints remain unimplemented, affecting custom element construction, slotchange delivery, and mutation observer ordering around parser-inserted scripts.

Refs #2413.

Co-authored-by: codex <codex@openai.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
domenic added a commit to jsdom/jsdom that referenced this pull request Sep 22, 2026
Suspend HTML and XML parsing at parsing-blocking scripts until their source and blocking stylesheets are ready. Resume from the same input position, preserving parser order without delaying unrelated resources behind scripts. Include blocking stylesheets in ancestor documents and stop consuming input when the document is stopped, replaced, or destroyed. Replacing the parser cancels its blocking and deferred scripts without aborting independent requests.

Feed document.write() into the HTML parser's input stream, retaining insertion points across nested scripts and processing written markup synchronously. Keep script-created input streams open until document.close(). Mark scripts from inert fragment parsing as already started, while preserving executable HTML fragments from range.createContextualFragment().

Invoke custom element insertion reactions before processing subsequent tokens, including upgrades in shadow trees. Report exceptions from synchronous customized built-in element construction without abandoning parsing. Preserve written scripts and pending input when lifecycle callbacks reenter document.write() or document.close().

Construct initial about:blank frame documents directly and fire their frame load events synchronously, independent of registered event listeners. Keep subsequent navigations on the document-loading path. Report unexpected frame parsing failures through resource error handling and release parent-document load delays, including when parsing resumes after a blocking script.

The JSDOM constructor and fromURL()/fromFile() can now return a partially parsed document when a blocking resource is pending. Report XML syntax errors encountered after asynchronous resumption through the virtual console; synchronous constructor errors still throw. Documents without pending blockers remain synchronous.

Use supported parse5-parser-stream and saxes APIs, feeding ordinary markup in large chunks while retaining script execution checkpoints. Trailing character data from document.write() can still remain buffered until another token arrives; exposing it synchronously requires inikulin/parse5#1854. Writes from parse5 tree-builder callbacks remain buffered until the callback returns because parse5 cannot tokenize that input reentrantly. Full VM microtask checkpoints remain unimplemented, affecting custom element construction, slotchange delivery, and mutation observer ordering around parser-inserted scripts.

Refs #2413.

Co-authored-by: codex <codex@openai.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
domenic added a commit to jsdom/jsdom that referenced this pull request Oct 17, 2026
Suspend HTML and XML parsing at parsing-blocking scripts until their source and blocking stylesheets are ready. Resume from the same input position, preserving parser order without delaying unrelated resources behind scripts. Include blocking stylesheets in ancestor documents and stop consuming input when the document is stopped, replaced, or destroyed. Replacing the parser cancels its blocking and deferred scripts without aborting independent requests.

Feed document.write() into the HTML parser's input stream, retaining insertion points across nested scripts and processing written markup synchronously. Keep script-created input streams open until document.close(). Mark scripts from inert fragment parsing as already started, while preserving executable HTML fragments from range.createContextualFragment().

Invoke custom element insertion reactions before processing subsequent tokens, including upgrades in shadow trees. Report exceptions from synchronous customized built-in element construction without abandoning parsing. Preserve written scripts and pending input when lifecycle callbacks reenter document.write() or document.close().

Construct initial about:blank frame documents directly and fire their frame load events synchronously, independent of registered event listeners. Keep subsequent navigations on the document-loading path. Report unexpected frame parsing failures through resource error handling and release parent-document load delays, including when parsing resumes after a blocking script.

The JSDOM constructor and fromURL()/fromFile() can now return a partially parsed document when a blocking resource is pending. Report XML syntax errors encountered after asynchronous resumption through the virtual console; synchronous constructor errors still throw. Documents without pending blockers remain synchronous.

Use supported parse5-parser-stream and saxes APIs, feeding ordinary markup in large chunks while retaining script execution checkpoints. Trailing character data from document.write() can still remain buffered until another token arrives; exposing it synchronously requires inikulin/parse5#1854. Writes from parse5 tree-builder callbacks remain buffered until the callback returns because parse5 cannot tokenize that input reentrantly. Full VM microtask checkpoints remain unimplemented, affecting custom element construction, slotchange delivery, and mutation observer ordering around parser-inserted scripts.

Refs #2413.

Co-authored-by: codex <codex@openai.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
@domenic

domenic commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor Author

Gentle ping! jsdom is prepping for a new major release, and it would be great to have this bundled in. Without it, jsdom's document.write() support will take many steps forward and one step back. With this, jsdom will be many steps forward with no regressions, which is even better :)

@43081j

43081j commented Oct 19, 2026

Copy link
Copy Markdown
Collaborator

sorry this has taken me so long to get to. i'll read through it today 👍

Comment thread packages/parse5/lib/tokenizer/index.ts Outdated
@fb55

fb55 commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

Looked into merging this yesterday. My own agent review here flagged that this doesn't deal well with \r\n being split. Otherwise would be nice if tests could use it.each instead of the manual loops

domenic and others added 2 commits October 20, 2026 07:32
Exclude the ignored LF from the next character token's source range when its preceding CR was flushed. Text following the discarded leading newline in pre, textarea, and listing elements then starts at the actual character offset.

Co-Authored-By: codex <codex@openai.com>
Compute flushed character-token locations with indexOf() scans instead of allocating an array of line substrings. Keep the scans bounded to emitted text so large unfinished tags or attributes do not increase flush cost.

Co-Authored-By: codex <codex@openai.com>
@domenic

domenic commented Oct 20, 2026

Copy link
Copy Markdown
Contributor Author

Thank you both for the helpful reviews! I think I've addressed everything. CI failures look to be due to some out of sync CodeQL dependencies.

@43081j

43081j commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

have reviewed and all seems good 👍

there's actually 2 bug fixes in here too which flushing presumably uncovered (to you or your agent). locations were off in some places.

codeql stuff is sorted in main which i've now merged into your branch

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants