From fcb5df062e12bc95348c80bef22c558e786e908f Mon Sep 17 00:00:00 2001 From: Justin Zhang Date: Wed, 29 Jul 2026 19:31:28 -0400 Subject: [PATCH] perf: maintain the open-tag stack with push/pop instead of unshift/shift 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("") 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 `
` 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. --- src/Parser.ts | 88 ++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 73 insertions(+), 15 deletions(-) diff --git a/src/Parser.ts b/src/Parser.ts index 33050243b..c24b4291d 100644 --- a/src/Parser.ts +++ b/src/Parser.ts @@ -254,6 +254,39 @@ export interface Handler { const reNameEnd = /\s|\//; +/** + * Index of the innermost open tag with the given name in `stack`, or -1. + * + * Equivalent to `stack.lastIndexOf(name)`, spelled out because + * `Array#lastIndexOf` is a good deal slower than `indexOf` here: tag names are + * freshly sliced out of the buffer, so they are not internalized and every + * comparison is a full string compare. + * + * A module-level function rather than a private method, so that it cannot + * collide with a private member of the same name on a subclass. + * @param stack Open tag names, outermost first. + * @param name Tag name to look for. + */ +function findInnermost(stack: string[], name: string): number { + const top = stack.length - 1; + + // A well-formed end tag closes the tag we are currently inside. + if (top >= 0 && stack[top] === name) return top; + + /* + * Otherwise the name is often not open at all, which is the common case for + * a stray end tag. `indexOf` answers that in one fast builtin scan, and when + * it does match it bounds the scan below. + */ + const outermost = stack.indexOf(name); + if (outermost === -1) return -1; + + for (let index = top - 1; index > outermost; index--) { + if (stack[index] === name) return index; + } + return outermost; +} + /** * Incremental parser implementation. */ @@ -272,7 +305,14 @@ export class Parser implements Callbacks { private attribname = ""; private attribvalue = ""; private attribs: null | { [key: string]: string } = null; + /** + * The stack of currently open tags, outermost first. The innermost (current) + * tag is the *last* element, so pushing and popping are `push`/`pop`: an + * `unshift`/`shift`-based stack would move every entry on each tag, making + * a document of depth `d` cost O(d^2). + */ private readonly stack: string[] = []; + /** Foreign (SVG/MathML) contexts, outermost first - see `stack`. */ private readonly foreignContext: ForeignContext[]; private readonly cbs: Partial; private readonly lowerCaseTagNames: boolean; @@ -335,7 +375,11 @@ export class Parser implements Callbacks { /** @internal */ isInForeignContext(): boolean { - return this.foreignContext[0] !== ForeignContext.None; + return this.currentForeignContext() !== ForeignContext.None; + } + + private currentForeignContext(): ForeignContext { + return this.foreignContext[this.foreignContext.length - 1]; } /** @@ -365,7 +409,7 @@ export class Parser implements Callbacks { return name; } - if (this.foreignContext[0] === ForeignContext.Svg) { + if (this.currentForeignContext() === ForeignContext.Svg) { return svgTagNameAdjustments.get(name) ?? name; } @@ -373,7 +417,7 @@ export class Parser implements Callbacks { * Closing tags for SVG elements inside HTML integration points * (e.g. while inside its own content) need case * adjustment so the name matches what was pushed to the stack. - * `foreignContext.length > 1` means a foreign ancestor exists — + * `foreignContext.length > 1` means a foreign ancestor exists - * the base [None] entry plus at least one pushed context. */ if (this.foreignContext.length > 1) { @@ -418,20 +462,23 @@ export class Parser implements Callbacks { const impliesClose = this.htmlMode && openImpliesClose.get(name); if (impliesClose) { - while (this.stack.length > 0 && impliesClose.has(this.stack[0])) { + while ( + this.stack.length > 0 && + impliesClose.has(this.stack[this.stack.length - 1]) + ) { this.popElement(true); } } if (!this.isVoidElement(name)) { - this.stack.unshift(name); + this.stack.push(name); if (this.htmlMode) { if (name === "svg") { - this.foreignContext.unshift(ForeignContext.Svg); + this.foreignContext.push(ForeignContext.Svg); } else if (name === "math") { - this.foreignContext.unshift(ForeignContext.MathML); + this.foreignContext.push(ForeignContext.MathML); } else if (htmlIntegrationElements.has(name)) { - this.foreignContext.unshift(ForeignContext.None); + this.foreignContext.push(ForeignContext.None); } } } @@ -475,9 +522,10 @@ export class Parser implements Callbacks { const name = this.readTagName(start, endIndex); if (!this.isVoidElement(name)) { - const pos = this.stack.indexOf(name); + // The innermost matching tag is closest to the end of the stack. + const pos = findInnermost(this.stack, name); if (pos !== -1) { - for (let index = 0; index < pos; index++) { + for (let index = this.stack.length - 1; index > pos; index--) { this.popElement(true); } this.popElement(false); @@ -521,13 +569,13 @@ export class Parser implements Callbacks { */ private popElement(implied: boolean): void { // biome-ignore lint/style/noNonNullAssertion: The element is guaranteed to exist. - const element = this.stack.shift()!; + const element = this.stack.pop()!; if ( this.htmlMode && (foreignContextElements.has(element) || htmlIntegrationElements.has(element)) ) { - this.foreignContext.shift(); + this.foreignContext.pop(); } this.cbs.onclosetag?.(element, implied); } @@ -537,7 +585,7 @@ export class Parser implements Callbacks { this.endOpenTag(isOpenImplied); // Self-closing tags will be on the top of the stack - if (this.stack[0] === name) { + if (this.stack[this.stack.length - 1] === name) { this.popElement(!isOpenImplied); } } @@ -700,7 +748,17 @@ export class Parser implements Callbacks { if (this.cbs.onclosetag) { // Set the end index for all remaining tags this.endIndex = this.startIndex; - for (let index = 0; index < this.stack.length; index++) { + /* + * Close from the innermost tag outwards. The upper bound is re-read + * every iteration because `onclosetag` is a user callback and may + * shrink the stack - `reset()` empties it - and a snapshot bound + * would then read past the end and emit `onclosetag(undefined)`. + */ + for ( + let index = this.stack.length - 1; + index >= 0 && index < this.stack.length; + index-- + ) { this.cbs.onclosetag(this.stack[index], true); } } @@ -723,7 +781,7 @@ export class Parser implements Callbacks { this.cbs.onparserinit?.(this); this.buffers.length = 0; this.foreignContext.length = 0; - this.foreignContext.unshift(ForeignContext.None); + this.foreignContext.push(ForeignContext.None); this.bufferOffset = 0; this.writeIndex = 0; this.ended = false;