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

Merged
ZH
22d ago
+75-13

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:

depthmasterthis branchspeedupmaster growthbranch growth
40001.75 ms0.78 ms2.3x--
80005.09 ms1.50 ms3.4xx2.90x1.93
1600031.48 ms3.04 ms10.3xx6.19x2.03
32000154.64 ms6.11 ms25.3xx4.91x2.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.

Summary by CodeRabbit

  • Bug Fixes
    • Improved handling of deeply nested HTML and SVG elements, including matching closing tags and processing elements in the correct order.
    • Parsing now stops emitting closing callbacks when parsing is reset during a callback.
  • Performance
    • Reduced the work required to process deeply nested elements.