Skip to content

perf(editor): derive heading ids from the doc instead of plugin state - #9555

Open
mejo- wants to merge 1 commit into
mainfrom
perf/headings
Open

mejo- wants to merge 1 commit into
mainfrom
perf/headings

Conversation

@mejo-

@mejo- mejo- commented Nov 5, 2026

Copy link
Copy Markdown
Member

Heading ids are now computed by getHeadings(doc), which caches the result per doc node. The headingAnchor plugin only keeps its DecorationSet, mapping it through transactions while heading ids stay the same and rebuilding it otherwise.

Anchor widgets are keyed by heading id, so ProseMirror reuses their DOM without looking up existing anchors. That lookup ran once per heading on every rebuild, making edits that change a heading id quadratic in the number of headings (~280 ms vs ~5.6 ms per keystroke with 500 headings in jsdom).
Here's the table:

Time per transaction in ms (jsdom with a mounted EditorView, average of 2 runs × 50 transactions). Document of N sections, each one <h2> followed by five paragraphs.

N=200 before N=200 after N=500 before N=500 after
Typing in a paragraph 1.13 1.27 2.68 2.73
Typing in a heading 46.7 2.0 284 5.6
Selection change 0.02 0.02 0.04 0.03

Assisted-by: ClaudeCode:claude-opus-5-5

🏁 Checklist

  • Code is properly formatted (npm run lint / npm run stylelint / composer run cs:check)
  • Sign-off message is added to all commits
  • Tests (unit, integration and/or end-to-end) passing and the changes are covered with tests

🤖 AI (if applicable)

  • The content of this PR was partly or fully generated using AI tools
  • The AI-generated content was reviewed, comprehended and tested by a human

Heading ids are now computed by getHeadings(doc), which caches the
result per doc node. The headingAnchor plugin only keeps its
DecorationSet, mapping it through transactions while heading ids stay
the same and rebuilding it otherwise.

Anchor widgets are keyed by heading id, so ProseMirror reuses their DOM
without looking up existing anchors. That lookup ran once per heading
on every rebuild, making edits that change a heading id quadratic in
the number of headings (~280 ms vs ~5.6 ms per keystroke with 500
headings in jsdom).

Time per transaction (ms) in jsdom with a mounted EditorView. Document
of N sections, each one <h2> followed by five paragraphs:

                         N=200            N=500
                      before  after    before  after
  typing in paragraph   1.13   1.27      2.68   2.73
  typing in heading    46.7    2.0     284      5.6
  selection change      0.02   0.02      0.04   0.03

Signed-off-by: Jonas <jonas@freesources.org>
Assisted-by: ClaudeCode:claude-opus-5-5
@mejo- mejo- self-assigned this Nov 5, 2026
@github-project-automation github-project-automation Bot moved this to 🧭 Planning evaluation (don't pick) in 📝 Productivity team Nov 5, 2026
@nextcloud-command nextcloud-command added the AI assisted This PR contains AI-assisted commits label Nov 5, 2026
@mejo- mejo- moved this from 🧭 Planning evaluation (don't pick) to 👀 In review in 📝 Productivity team Nov 5, 2026
let headings = cache.get(doc)
if (!headings) {
headings = extractHeadings(doc)
cache.set(doc, headings)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I don't think that cache ever gets cleared. So we're basically keeping a reference on all docs and all sets of headings we run through. I don't think it's likely we will return to the same doc (except for undoing actions I guess).
Maybe just keep a reference to the last doc and last headings and return the last headings if doc is the last doc?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Okay, I see how this is used now - by calling getHeadings from various places for the same doc and by reusing it with the old doc.

*/
function headingToDom(heading: Heading) {
return (view: EditorView) => {
const existing = view.dom.querySelector(`#${heading.id}`)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

So this is the slow part, right?
And the quadratic nature is because the document grows - so querySelector takes longer linear in the size of the document and then there are more headings to search for.

function decorationForHeading(heading: Heading) {
return Decoration.widget(heading.offset + 1, headingToDom(heading), {
side: -1,
key: heading.id,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This allows prosemirror to handle the mapping itself.

Comment on lines -109 to +58
function isDifferentFrom(other: Heading[]) {
return (heading: Heading, i: number) => {
return heading.id !== other[i].id || heading.level !== other[i].level
}
function sameIds(a: Heading[], b: Heading[]) {
return a.length === b.length && a.every((heading, i) => heading.id === b[i].id)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We used to also detect level changes here. Now it's only changes to the ids.
The level does not matter for the anchors as far as i can tell. It matters for the toc though. I wonder if we've also been using the heading state for the toc.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Yes... we have been using it before - but now we just use the getHeadings function directly handing in the doc (see useEditorHeadings composable).

const updateHeadings = () => {
headings.value
= headingAnchorPluginKey.getState(editor.state)?.headings ?? []
headings.value = getHeadings(editor.state.doc)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Okay... so we're not going through the state but instead our new cache.

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

3. to review AI assisted This PR contains AI-assisted commits performance 🚀

Projects

Status: 👀 In review

Development

Successfully merging this pull request may close these issues.

3 participants