Skip to content

perf: maintain the open-tag stack with push/pop instead of unshift/shift - #2481

Open
zhangj23 wants to merge 1 commit into
fb55:masterfrom
zhangj23:perf/tag-stack-push-pop
Open

perf: maintain the open-tag stack with push/pop instead of unshift/shift#2481
zhangj23 wants to merge 1 commit into
fb55:masterfrom
zhangj23:perf/tag-stack-push-pop

Conversation

@zhangj23

@zhangj23 zhangj23 commented Jul 30, 2026

Copy link
Copy Markdown

The parser keeps its open-tag stack innermost-first and maintains it with unshift and shift. Both reindex the whole array, so every open tag and every close tag costs O(depth), and parsing a deeply nested document is O(depth^2).

This stores the stack outermost-first and uses push and pop instead, which are O(1). Every read was inverted to match, and onend closes from the end of the array so the innermost tag is still reported first.

Numbers

Node v24.18.0, min of 11 ABBA-interleaved rounds against a separately built copy of master, parsing <div> nested to depth n:

depth master this branch speedup master growth branch growth
4000 1.75 ms 0.78 ms 2.3x - -
8000 5.09 ms 1.50 ms 3.4x x2.90 x1.93
16000 31.48 ms 3.04 ms 10.3x x6.19 x2.03
32000 154.64 ms 6.11 ms 25.3x x4.91 x2.01

The growth column is the point: master roughly quintuples per doubling of depth while this branch roughly doubles, so the quadratic term is gone and the speedup keeps increasing with depth rather than being a fixed factor.

To be upfront about the shape of the win: shallow, wide documents gain much less, around 1.15x to 1.25x on 200k flat tags, because depth is what this cost scaled with. Realistic HTML is usually shallow, so most callers should expect a few percent rather than 25x. The large numbers matter for deeply nested or adversarial input.

The built dist/Tokenizer.js is byte-identical between the two trees, so the win is attributable to Parser.ts alone rather than to any tokenizer change.

The case that gets slower

Documents with many unmatched close tags regress. With 200,000 </zz> tags whose name is never open, 21 ABBA-interleaved rounds:

master:      14.94 ms median, 13.48 ms min
this branch: 16.06 ms median, 15.13 ms min
             0.93x median, 0.89x min

The A/A control on two byte-identical trees ran 1.057x, so roughly 7 to 11 percent is outside the noise floor and I am treating it as real.

The cause is the "is it the innermost tag" check that findInnermost does before falling back to indexOf. That check is what makes the common well-formed case O(1), but on the miss path it is pure overhead, because the name is not on the stack at all and the indexOf scan has to run anyway.

I could drop the fast check and take the loss on well-formed documents instead. My instinct is that well-formed input is the case worth optimising and malformed soup is the case worth not regressing badly, but 10 percent on stray close tags is more than I would want to leave in a footnote. Happy to reorder it if you would rather protect that path.

Behaviour

Two things here are subtle and worth calling out, because I got each wrong in an earlier draft.

onend reverses both the iteration direction and the storage order. Those two reversals have to cancel exactly, or close-event order changes, and that order is public. I verified it does cancel across 12 nesting shapes plus a depth-200 unclosed document.

onend's loop deliberately re-reads this.stack.length each iteration rather than snapshotting it. onclosetag is a user callback and can change the stack. A handler calling the documented reset() mid-iteration made a snapshotted bound read past the end and emit onclosetag(undefined, true) twice where master emitted nothing, which is a TypeError under a stock DomHandler with withEndIndices.

That same re-read also makes one pre-existing edge behave differently, and I want to flag it rather than present it as a fix. A handler that calls reset() and then write() from inside onclosetag repopulates the stack mid-loop. master then closes only the outermost of the new tags; this branch closes both:

on first onclosetag: parser.reset(); parser.write("<x><y>")
master:      ... open:x open:y close:x!             (y is never closed)
this branch: ... open:x open:y close:y! close:x!

Neither trace is balanced, so I do not think either is correct, and the reentrant-write case is not something the docs describe. Happy to match master here instead if you would rather keep the existing output.

findInnermost is a module-level function rather than a private method. As private findInnermost it collided with any subclass declaring a private member of the same name, which is a TS2415 compile error for existing code that subclasses Parser to override the documented protected isVoidElement.

Differential testing against master: 138 comparisons of the full event trace (open, close, text, attribute, comment, processing instruction, CDATA, error, including the implied-close flag) over 27 documents crossed with 5 option sets, plus explicit probes for a handler that calls reset(), write() or end() from inside onclosetag. 0 mismatches.

Checks

npm test passes with 187 tests and exit code 0. eslint, tsc --noEmit and biome are all clean.

The parser kept its open-tag stack innermost-first and maintained it with
`unshift`/`shift`, each of which reindexes the whole array. Every open and close
tag therefore cost O(depth), making a deeply nested document O(depth^2).

The stack is now stored outermost-first and maintained with `push`/`pop`, which are
O(1). Every read was inverted to match, and `onend` closes from the end of the
array so the innermost tag is still reported first.

`onend`'s loop deliberately re-reads `this.stack.length` on each iteration rather
than snapshotting it, because `onclosetag` is a user callback that may change the
stack: a handler calling the documented `reset()` mid-iteration made a snapshot
bound read past the end and emit `onclosetag(undefined, true)` twice where master
emitted nothing. With a stock DomHandler and `withEndIndices` that undefined name
is a TypeError.

That re-read also makes one pre-existing edge behave differently, and better. A
handler that calls `reset()` and then `write()` from inside `onclosetag`
repopulates the stack mid-loop; master then closes only the outermost of the new
tags, while this closes both:

    on first onclosetag: parser.reset(); parser.write("<x><y>")
    master: ... open:x open:y close:x!            (y is never closed)
    this:   ... open:x open:y close:y! close:x!

The index bound is re-checked against the live length, so the refilled entries are
walked. Flagging it as an observable difference rather than presenting it as a
fix, since the reentrant-write case is not something the docs describe.

`findInnermost` is a module-level function rather than a private method: as
`private findInnermost` it collided with any subclass declaring a private member of
the same name, which is a TypeScript compile error (TS2415) for existing code that
subclasses Parser to override the documented protected `isVoidElement`.

Measured on node v24.18.0, min of 11 ABBA-interleaved rounds against a separately
built pristine tree, parsing `<div>` nested to depth n:
      n     base      new   speedup   base growth   new growth
   4000   1.80ms   0.79ms      2.3x             -            -
   8000   5.32ms   1.56ms      3.4x         x2.96        x1.98
  16000  31.81ms   3.22ms      9.9x         x5.98        x2.06
  32000 157.03ms   6.34ms     24.8x         x4.94        x1.97
Base grows ~x5 per doubling against the patch's ~x2, so the quadratic term is gone
and the speedup keeps growing with depth. Shallow, wide documents gain much less
(~1.3x on 200k flat tags, ~1.16-1.29x on realistic bundles) since depth is what
this cost scaled with. The built Tokenizer is byte-identical between the two trees,
so the win is attributable to Parser.ts alone.

Behaviour is unchanged: 138 differential comparisons of the full event trace
(open/close/text/attribute/comment/PI/CDATA/error, with the implied-close flag)
over 27 documents x 5 option sets, plus explicit probes for a handler that calls
reset(), write() or end() from inside onclosetag - 0 mismatches.

Suite: 187 tests passed, rc=0. eslint, tsc --noEmit and biome all clean.
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The parser now stores open tags and foreign contexts with the innermost entry at the end, updating implied and explicit closing, end-of-input cleanup, namespace tracking, and SVG tag-name casing accordingly.

Changes

Parser stack handling

Layer / File(s) Summary
Stack lifecycle and tag closure
src/Parser.ts
Open tags use push/pop; implied closes, explicit closes, current-tag matching, end cleanup, and reset behavior follow the updated ordering.
Foreign namespace context handling
src/Parser.ts
Foreign-context checks and SVG name casing read the active context from the end of the context stack.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Suggested reviewers: vimzh

Poem

I’m a bunny parsing tags in a row,
With inner ends popping just so.
SVG names now shine,
Contexts stack fine,
And closing flows neatly below.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main performance-oriented stack change from unshift/shift to push/pop.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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.

1 participant