Skip to content

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

Open
gusnips wants to merge 1 commit into
fb55:masterfrom
gusnips:perf/open-name-counts
Open

gusnips wants to merge 1 commit into
fb55:masterfrom
gusnips:perf/open-name-counts

Conversation

@gusnips

@gusnips gusnips commented Oct 19, 2026 •

Copy link
Copy Markdown

#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 (#2481 (comment)) 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:

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 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 fix(parser): make reset() safe inside callbacks #2539 and fix(parser): close an open <p> for li, dd, dt, listing, plaintext and xmp #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

After fb55#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 `<form>`.
- `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
    <div> x n, then </span> x n          250,499 / 1,000,999 / 4,001,999   499 / 999 / 1,999
    <div> x n, then <form> x n           250,999 / 1,001,999 / 4,003,999   500 / 1,000 / 2,000
    <svg><foreignObject>, <div> x n,
      then <clippath> 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.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ⚠️ Failed 2026-10-19T06:26:43.341457Z f6ffcc3 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@coderabbitai

coderabbitai Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 67f4ea72-e0ce-4872-9b9e-54b1afeb0c41
📥 Commits

Reviewing files that changed from the base of the PR and between a4c60b8 and f6ffcc3.

📒 Files selected for processing: 2
  • src/Parser.spec.ts
  • src/Parser.ts

Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 7 remain after this review.

📝 Walkthrough

The parser now tracks the number of open elements by name. It uses these counts in tag-name adjustment, duplicate-<form> handling, and closing-tag lookup. Parameterized tests check stack reads for three repeated-input cases.

Changes: Parser stack lookup

Layer / File(s) Summary
Track open elements
src/Parser.ts
The parser updates open-element counts when it pushes or pops elements. reset() clears the counts.
Use counts in lookup paths
src/Parser.ts, src/Parser.spec.ts
Tag-name adjustment, duplicate-<form> suppression, and closing-tag lookup use the counts. Tests cover repeated unmatched closing tags, <form> openings, and SVG clippath names.

Priority: ⬇️ Low

Estimated code review effort: 2 (Simple) | ~15 minutes

Change: Refactor

Suggested reviewers: fb55

Merge Risk: ⚪ Minimal · up to f6ffc

The change reduces repeated stack reads while preserving the parser’s lookup behavior. No actionable merge risk was identified.

Pre-merge checks: Passed 5
Check name Status Explanation
Linked Issues check Passed The changes address all three cases in issue #2548. openCounts avoids stack scans for names that are not open. The same map replaces stack scans for repeated form tags and SVG name adjustment. Pus…
Out of Scope Changes check Passed The pull request changes only src/Parser.ts and its tests. The implementation and tests directly support the performance objective in issue #2548. No unrelated behavior or files are changed.
Docstring Coverage Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 2…
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title clearly and concisely describes the main change: tracking open element counts by name to improve parser performance.
Full details: Linked Issues check

Explanation

The changes address all three cases in issue #2548. openCounts avoids stack scans for names that are not open. The same map replaces stack scans for repeated form tags and SVG name adjustment. Push, pop, and reset() maintain the counts. Parser.spec.ts adds coverage for the three cases and checks fewer than 5,000 stack reads at depth 500.

Full details: Docstring Coverage

Explanation

No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 2 files.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

A rabbit counts each tag in sight
Open names tucked away just right
A closing tag can skip the search
The stack keeps count without a rush
Three test paths hop through the code
Then reset clears the counting load

Comment @coderabbitai help to get the list of available commands.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 2 files

View guided diff | Re-trigger cubic

@greptile-apps

greptile-apps Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The PR appears safe to merge; no actionable issues were found.

Summary

The PR adds openCounts to check whether a tag name is open without scanning stack.

  • The parser checks open tag names without scanning for a match first.

Reviews (1) · Last reviewed commit: "perf(parser): answer "is this name open"..." · Reviewed by Greptile

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Parser: end tags nobody opened, repeated <form> and SVG-cased names still scan every open element

1 participant