Repository navigation
fix: update heading fold decorations only where an edit can change them - #693
Merged
Merged
Conversation
This was referenced Oct 12, 2026
Azganoth
added a commit
that referenced
this pull request
Oct 12, 2026
) ## Summary Before every commit, React DOM saves the selection of the focused element when its `contentEditable` property reads `"true"`, counting text offsets over every node beneath it. While the editor had focus, each React commit therefore walked the whole document. In a release build of `main`, these commits happen while the editor has focus: - `StatusBar` on every keystroke, inside the key's own task, for the word and character counts and the block path. - `HeadingOutline` about one frame later on every keystroke when a heading follows the caret, since the headings' positions shift. - `Shell` on the first edit and when the command state changes. - Base UI `ScrollArea` when the view scrolls, including wheel scrolling. What changes: - A new editor plugin gives the editor's root element an own `contentEditable` getter that reports `"inherit"` where the element's value is `"true"`, so React no longer treats the editor as selection-capable and skips the walk. The `contenteditable` attribute, `isContentEditable`, and editing are unchanged, and the plugin removes the property when the editor is destroyed. - Nothing is deferred: the status bar, outline, dirty state, and other React-rendered state commit exactly as before. - `CHANGELOG.md` records the faster typing. Deferring the status bar and outline commits until typing pauses was rejected: it would delay what the user sees, and each deferred commit would still walk the document. ## Related Issue Closes #685 ## Verification - A focused test focuses the editor, places the caret, and triggers a React commit while counting reads of the editor's text nodes. It asserts no reads, then deletes the plugin's property and asserts the walk returns, so the test fails if React changes how it decides to save a focused element's selection. Other tests cover the attribute staying `"true"`, a read-only editor reporting `"false"`, and removal on destroy. - `pnpm check:frontend` passed. Manually verified in release builds on Windows 11, driven over the WebView2 DevTools protocol with the 1 MB code and table fixtures from #680: 1. A temporary React DevTools hook on `main` at 9aa80f9 recorded the commits listed in the Summary, every one with the editor focused. 2. This change built on 9aa80f9 against the same build with only the plugin's registration removed, median of 5 keystrokes, script time and keystroke-to-paint in ms: | Target | Script, before → after | Keystroke to paint, before → after | | --- | --- | --- | | Code fixture, paragraph | 56 → 27 | 168 → 152 | | Code fixture, code block | 164 → 103 | 296 → 224 | | Tables fixture, paragraph | 103 → 69 | 192 → 152 | | Tables fixture, cell | 111 → 67 | 192 → 144 | 3. In CPU profiles of one keystroke per target, React's time fell from 49–87 ms, 44–74 ms of it in `commitBeforeMutationEffects`, to 0–2.4 ms, and that function no longer appeared. 4. On the branch, the status bar's character count rose by one with every keystroke. Not verified: scrolling times, and timings after rebasing onto #693. ## Notes The fix depends on React DOM's `hasSelectionCapabilities` reading `elem.contentEditable === "true"` (React DOM 19.3.0). If React changes that check, the walk returns without breaking behavior, and the focused test fails. When a commit moves focus out of the editor, React still refocuses it but no longer sets the caret itself; the browser keeps the editor's own selection.
Azganoth
added a commit
that referenced
this pull request
Oct 12, 2026
) ## Summary Opening a document highlighted every code block before the editor could respond, in one main-thread task, so a document made mostly of code missed #638's 3 s open budget from about 200 KB. Shiki's JavaScript regular-expression engine takes about half a millisecond per line of code, and highlighting was 81–82% of opening code documents in #680. - Creating the editor state highlights only the document's first 100 lines of code, enough to fill a tall window. The code highlight plugin's view highlights the remaining blocks afterwards in timer tasks of about 10 ms, each dispatched as a meta-only transaction outside history, starting from the block at the top of the viewport so code scrolled to is reached first. - The blocks still pending are positions mapped through every transaction. A block whose opening token a transaction deletes or replaces leaves the list, since that transaction already highlights whatever stands there; a pending block an edit has already highlighted is highlighted again in its turn, replacing its decorations. Mapping uses `MapResult.deleted` because ProseMirror also reports `deletedAfter` for a plain insertion at the position, which would drop a block whenever something was inserted right before it. - Timer tasks are used instead of `requestIdleCallback`: in the release WebView2 window on Windows 11, idle callbacks stopped firing altogether after a document opened, on `main` as well, while animation frames and timers ran. - Loading Limits in `docs/specification.md` now states a supported size of 200 KB, the largest size at which every shape #680 measured meets both budgets: at 500 KB, tables miss the open budget and typing in a code block misses the keystroke budget. The specification also says a code-heavy document is shown before all of its code is highlighted, `docs/architecture.md` describes the mechanism, and `CHANGELOG.md` records the faster opening. ## Related Issue Closes #683 ## Verification - Focused tests mount an editor view with more than 100 lines of code and run its timers: the first block is highlighted at creation, the rest afterwards, and the result equals highlighting every block at once. The same comparison holds after editing, retyping, adding, removing, wrapping in a quote, inserting a paragraph before, and converting to a paragraph code blocks that are still pending. Further tests cover starting from the block at the top of the viewport and stopping when the view is destroyed. Mapping pending positions toward the start of an insertion, keeping positions whose opening token was deleted, not removing a re-highlighted block's earlier decorations, and ignoring the viewport each make them fail. The existing incremental highlighting tests pass unchanged. - `pnpm check:frontend` passed. Manually verified in release builds on Windows 11, `main` and this branch both built from 9aa80f9 under a separate application identity with #680's method and fixtures, opening each fixture in a fresh process and typing 5 keystrokes per target: 1. Opening code documents. "Shown" is when the document appears and the window responds; on `main` that is also when all of its code is highlighted. | Code document | `main`: shown | Branch: shown | Branch: all code highlighted | Branch: longest frame meanwhile | | --- | --- | --- | --- | --- | | 200 KB, 2 runs | 3.42–3.62 s | 0.82–0.94 s | 4.2–4.8 s | 59 ms | | 500 KB, 2 runs | 7.60–10.77 s | 1.00–1.14 s | 9.6–11.4 s | 70 ms | | 1 MB, 1 run | 22.3 s | 1.66 s | 28.1 s | 85 ms | 2. Keystrokes after highlighting finished took as long on both builds: a median paint of 32–48 ms in a paragraph and 80–88 ms in a code block at 200 KB, and 136–224 ms in a code block at 500 KB. Not verified: the release timings on top of #693, which `main` gained after they were taken; scrolling during the background pass; and how the pass behaves in a window behind others or minimized. ## Notes - At 1 MB, the background pass finishes later than `main`'s single freeze did, because every slice's transaction redraws the editor; a CPU profile puts about a quarter of the pass there, mostly in ProseMirror's `DecorationSet.forChild` walking the decoration set once per top-level node, which every keystroke pays as well. - A faster engine was measured but not taken: Shiki's Oniguruma engine highlighted the 200 KB fixture about 3.3 times faster in Node, but it is WebAssembly, which the content security policy would have to allow, and highlighting would still grow with the document.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Every document change rebuilt the heading fold decorations of the whole document, folded or not: it walked every heading, resolved each one to see whether it has a section, and built one new
DecorationSet. In a document with many headings this was the largest Leafdown-owned part of a keystroke.changedRangesutility that the code block decorations also use.CHANGELOG.mdrecords the faster typing in documents with many headings.Related Issue
Closes #684
Verification
pnpm check:frontendpassed.Manually verified in a Node test on Windows 11, with a temporary probe that was not committed:
mainat 9aa80f9.Not verified: keystroke paint times in a release build.
Notes
The remaining 92 ms is ProseMirror's
DecorationSet.map, which validates each node decoration on a top-level block with a linear search of the document's top-level children, so it costs headings times top-level blocks and falls with the square of the document's size. Removing it would take a heading node view or a decoration source other than aDecorationSet, which is outside this change.