From f6ffcc34888189e2011809adad13504281aaf21c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gustavo=20Salom=C3=A9?= Date: Mon, 19 Oct 2026 02:43:33 -0300 Subject: [PATCH] perf(parser): answer "is this name open" with a count per name After #2481 the open-element stack is push/pop, but three places still scan the whole stack, each once per tag, so each is quadratic in the nesting depth on its own: - `onclosetag` runs `lastIndexOf` for an end tag. When no element of that name is open, it reads every open element and finds nothing. - `emitOpenTag` runs `stack.includes("form")` on every `
`. - `readTagName` runs `stack.includes(adjusted)` for an SVG-cased name (`clippath`, `foreignobject`, ...) once a foreign context is open. The parser now keeps a `Map` from name to how many elements of that name are open, updated in push, pop and reset. A name that is not open is one lookup. An open one is still found with `lastIndexOf` from the top, so that scan only passes the elements the end tag then closes, and their open tags already paid for them. Reads of the stack, counted the way the existing linear-work tests count them, at depth 500 / 1,000 / 2,000: shape before after
x n, then x n 250,499 / 1,000,999 / 4,001,999 499 / 999 / 1,999
x n, then x n 250,999 / 1,001,999 / 4,003,999 500 / 1,000 / 2,000 ,
x n, then x n 376,250 / 1,502,500 / 6,005,000 500 / 1,000 / 2,000 The event stream is unchanged: 20,000 seeded random malformed documents, each under 5 option sets and split into two writes at a random point, give the same open, close, text, comment, CDATA, processing-instruction and attribute events, with the same start and end indices, before and after. --- src/Parser.spec.ts | 13 +++++++++++++ src/Parser.ts | 28 ++++++++++++++++++++++++---- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/src/Parser.spec.ts b/src/Parser.spec.ts index 0ba1e2a4..6c31f612 100644 --- a/src/Parser.spec.ts +++ b/src/Parser.spec.ts @@ -294,6 +294,19 @@ describe("API", () => { expect(reads).toBeLessThan(depth * 10); }); + it.each([ + ["end tags nobody opened", "
", ""], + ["repeated form tags", "
", ""], + ["SVG-cased names in an integration point", "
", ""], + ])("should do linear stack work for %s", (_, open, next) => { + const depth = 500; + const parser = new Parser({}); + const prefix = next === "" ? "" : ""; + const input = prefix + open.repeat(depth) + next.repeat(depth); + const reads = countArrayReads(parser, "stack", () => parser.end(input)); + expect(reads).toBeLessThan(depth * 10); + }); + it("should stop closing elements when reset at the end of the input", () => { const closed: string[] = []; const parser = new Parser({ diff --git a/src/Parser.ts b/src/Parser.ts index 8b83d269..e4bf2c4c 100644 --- a/src/Parser.ts +++ b/src/Parser.ts @@ -276,6 +276,11 @@ export class Parser implements Callbacks { private attribvalue = ""; private attribs: null | { [key: string]: string } = null; private readonly stack: string[] = []; + /** + * How many elements of each name `stack` holds. Asking whether a name is + * open is one lookup instead of a scan of every open element. + */ + private readonly openCounts = new Map(); private readonly foreignContext: ForeignContext[]; private readonly cbs: Partial; private readonly lowerCaseTagNames: boolean; @@ -349,7 +354,7 @@ export class Parser implements Callbacks { */ if (this.foreignContext.length > 1) { const adjusted = svgTagNameAdjustments.get(name); - if (adjusted !== undefined && this.stack.includes(adjusted)) { + if (adjusted !== undefined && this.openCounts.has(adjusted)) { return adjusted; } } @@ -371,7 +376,7 @@ export class Parser implements Callbacks { * stays null so endOpenTag is a no-op, and closeCurrentTag can't * match "" on the stack. */ - if (this.htmlMode && name === "form" && this.stack.includes("form")) { + if (this.htmlMode && name === "form" && this.openCounts.has("form")) { this.tagname = ""; return; } @@ -388,6 +393,7 @@ export class Parser implements Callbacks { } if (!this.isVoidElement(name)) { this.stack.push(name); + this.openCounts.set(name, (this.openCounts.get(name) ?? 0) + 1); if (this.htmlMode) { if (name === "svg") { @@ -419,12 +425,18 @@ export class Parser implements Callbacks { /** * Pop the top element off the stack, emit a close event, and maintain - * the foreign context stack. + * the open-name counts and the foreign context stack. * @param isImplied Whether this close is implied (not from an explicit end tag). */ private popElement(isImplied: boolean): void { // biome-ignore lint/style/noNonNullAssertion: The element is guaranteed to exist. const element = this.stack.pop()!; + const count = this.openCounts.get(element) ?? 0; + if (count > 1) { + this.openCounts.set(element, count - 1); + } else { + this.openCounts.delete(element); + } if ( this.htmlMode && (foreignContextElements.has(element) || @@ -558,7 +570,14 @@ export class Parser implements Callbacks { const name = this.readTagName(start, endIndex); if (!this.isVoidElement(name)) { - const pos = this.stack.lastIndexOf(name); + /* + * A name nobody opened is a miss without reading the stack. An + * open one is found from the top, so the scan only passes the + * elements this close tag then pops. + */ + const pos = this.openCounts.has(name) + ? this.stack.lastIndexOf(name) + : -1; if (pos !== -1) { for (let index = this.stack.length - 1; index > pos; index--) { this.popElement(true); @@ -764,6 +783,7 @@ export class Parser implements Callbacks { this.attribvalue = ""; this.attribs = null; this.stack.length = 0; + this.openCounts.clear(); this.startIndex = 0; this.endIndex = 0; this.cbs.onparserinit?.(this);