Repository navigation
Conversation
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
| let headings = cache.get(doc) | ||
| if (!headings) { | ||
| headings = extractHeadings(doc) | ||
| cache.set(doc, headings) |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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}`) |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
This allows prosemirror to handle the mapping itself.
| 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) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
Okay... so we're not going through the state but instead our new cache.
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.Assisted-by: ClaudeCode:claude-opus-5-5
🏁 Checklist
npm run lint/npm run stylelint/composer run cs:check)🤖 AI (if applicable)