perf: maintain the open-tag stack with push/pop instead of unshift/shift - #2481
perf: maintain the open-tag stack with push/pop instead of unshift/shift#2481zhangj23 wants to merge 1 commit into
Conversation
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.
|
Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits. |
📝 WalkthroughWalkthroughThe 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. ChangesParser stack handling
Estimated code review effort: 3 (Moderate) | ~20 minutes Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
The parser keeps its open-tag stack innermost-first and maintains it with
unshiftandshift. 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
pushandpopinstead, which are O(1). Every read was inverted to match, andonendcloses 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:The growth column is the point:
masterroughly 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.jsis byte-identical between the two trees, so the win is attributable toParser.tsalone 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: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
findInnermostdoes before falling back toindexOf. 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 theindexOfscan 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.
onendreverses 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-readsthis.stack.lengtheach iteration rather than snapshotting it.onclosetagis a user callback and can change the stack. A handler calling the documentedreset()mid-iteration made a snapshotted bound read past the end and emitonclosetag(undefined, true)twice wheremasteremitted nothing, which is aTypeErrorunder a stockDomHandlerwithwithEndIndices.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 thenwrite()from insideonclosetagrepopulates the stack mid-loop.masterthen closes only the outermost of the new tags; this branch closes both: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
masterhere instead if you would rather keep the existing output.findInnermostis a module-level function rather than a private method. Asprivate findInnermostit collided with any subclass declaring a private member of the same name, which is aTS2415compile error for existing code that subclassesParserto override the documented protectedisVoidElement.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 callsreset(),write()orend()from insideonclosetag. 0 mismatches.Checks
npm testpasses with 187 tests and exit code 0. eslint,tsc --noEmitand biome are all clean.