perf(parser): answer "is this name open" with a count per name
#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:
onclosetagrunsthis.stack.lastIndexOf(name). For an end tag whose name is not open, it reads the whole stack and finds nothing.emitOpenTagrunsthis.stack.includes("form")on every<form>.readTagNamerunsthis.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
lastIndexOffrom 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 becomeopenCounts.has(...).
Numbers
Reads of stack, counted with the countArrayReads helper the linear-work tests already use:
| input | depth 500 | 1,000 | 2,000 |
|---|---|---|---|
<div> x n, then </span> x n, before | 250,499 | 1,000,999 | 4,001,999 |
| after | 499 | 999 | 1,999 |
<div> x n, then <form> x n, before | 250,999 | 1,001,999 | 4,003,999 |
| after | 500 | 1,000 | 2,000 |
<svg><foreignObject>, <div> x n, then <clippath> x n, before | 376,250 | 1,502,500 | 6,005,000 |
| after | 500 | 1,000 | 2,000 |
<div> x n, then </div> x n (unchanged) | 1,499 | 2,999 | 5,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:
| input | n | size | Node 24.21 master | this | Bun 1.4.2 master | this |
|---|---|---|---|---|---|---|
<b>x x n, then </i> x n | 10,000 | 78 KB | 6.5 s | 20 ms | 4.7 s | 30 ms |
| 20,000 | 156 KB | 23.9 s | 96 ms | 39.2 s | 42 ms | |
<b>x x n, then <form> x n | 10,000 | 98 KB | 2.9 s | 168 ms | 2.8 s | 29 ms |
| 20,000 | 195 KB | 9.1 s | 440 ms | 8.5 s | 401 ms | |
<svg><foreignObject>, <b>x x n, then <clippath> x n | 10,000 | 137 KB | 6.6 s | 229 ms | 7.6 s | 30 ms |
| 20,000 | 273 KB | 20.6 s | 379 ms | 36.1 s | 43 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 theParser.tschange 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 withstartIndexandendIndex) 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.jsonandbiome checkon 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