perf(parser): answer "is this name open" with a count per name

GU
13d ago
+37-4

#2481 made the open-element stack push/pop, which fixed deep nesting. Three lookups still read every open element, once per tag, so each one is quadratic in the nesting depth on its own:

  • onclosetag runs this.stack.lastIndexOf(name). For an end tag whose name is not open, it reads the whole stack and finds nothing.
  • emitOpenTag runs this.stack.includes("form") on every <form>.
  • readTagName runs this.stack.includes(adjusted) for an SVG-cased name (clippath, foreignobject, ...) once a foreign context is open.

A comment on #2481 points out the same three (https://github.com/fb55/htmlparser2/pull/2481#issuecomment-6072793486) and offers a follow-up. I haven't seen one land, so here is one. Happy to close this in favour of theirs.

The change

The parser keeps a Map from tag name to how many elements of that name are open. It's updated in the same three places the stack changes: push in emitOpenTag, pop in popElement, clear in reset. Then:

  • A name that is not open costs one lookup and no stack reads.
  • An open name is still found with lastIndexOf from the top. That scan only passes the elements the end tag then closes, and their open tags already paid for them.
  • The <form> and SVG-casing checks become openCounts.has(...).

Numbers

Reads of stack, counted with the countArrayReads helper the linear-work tests already use:

inputdepth 5001,0002,000
<div> x n, then </span> x n, before250,4991,000,9994,001,999
after4999991,999
<div> x n, then <form> x n, before250,9991,001,9994,003,999
after5001,0002,000
<svg><foreignObject>, <div> x n, then <clippath> x n, before376,2501,502,5006,005,000
after5001,0002,000
<div> x n, then </div> x n (unchanged)1,4992,9995,999

Wall time for one parser.end(input) with an onclosetag handler, master and this branch run back to back in the same session. The machine was under heavy load the whole time, so trust the ratios, not the absolute times:

inputnsizeNode 24.21 masterthisBun 1.4.2 masterthis
<b>x x n, then </i> x n10,00078 KB6.5 s20 ms4.7 s30 ms
20,000156 KB23.9 s96 ms39.2 s42 ms
<b>x x n, then <form> x n10,00098 KB2.9 s168 ms2.8 s29 ms
20,000195 KB9.1 s440 ms8.5 s401 ms
<svg><foreignObject>, <b>x x n, then <clippath> x n10,000137 KB6.6 s229 ms7.6 s30 ms
20,000273 KB20.6 s379 ms36.1 s43 ms

On the same machine, 12.0.0 took 46 s for 40,000 stray end tags (313 KB) and master 107 s.

Behaviour

Unchanged, as far as I can tell:

  • npm run test:vi: 204 tests pass, including three new ones, one per shape, in the style of the existing linear-work tests. Without the Parser.ts change the three new tests fail with 250,499, 250,999 and 376,250 reads against a limit of 5,000.
  • 20,000 seeded random malformed documents (implicit closes, foreign content, forms, void and self-closing tags, CDATA, comments), each under 5 option sets (default, xmlMode, recognizeSelfClosing, lowerCaseTags: false, decodeEntities: false) and written in two chunks split at a random point: 100,000 comparisons of the full event stream (open, close, text, comment, CDATA, processing instruction, attribute, each with startIndex and endIndex) between master and this branch, 0 differences. The same harness finds 433 differences in 15,000 comparisons between master and 12.0.0, so it does see changes.
  • eslint ., tsc --noEmit -p tsconfig.eslint.json and biome check on the two changed files pass.
  • The commit applies cleanly on top of #2539 and #2547, which touch the same files.

One thing to know: openCounts is a new private member, so a subclass that declares its own private openCounts would hit TS2415, as with any new private member. Happy to rename it.

Fixes #2548