Skip to content

fix: update heading fold decorations only where an edit can change them - #693

Merged
Azganoth merged 1 commit into
mainfrom
bug/heading-fold-decorations
Oct 12, 2026
Merged

Azganoth merged 1 commit into
mainfrom
bug/heading-fold-decorations

Conversation

@Azganoth

Copy link
Copy Markdown
Owner

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.

  • The fold plugin now maps its decorations through each transaction and keeps them in two layers. A heading's marker and folded indicator depend only on the heading, its next sibling, and whether it is folded, so they are decorated again only for the textblocks a change touches, the block just before each change, headings inside a node whose own boundary it changed, and headings folded or unfolded. The blocks a folded section hides are rebuilt only when the folds change, a folded heading is deleted, or a change reaches a folded section or a node holding one.
  • Typing in a paragraph creates no fold decorations, with or without folded headings. Folding, unfolding, revealing, and the outline's view of folded headings behave as before.
  • The changed-range collection from fix: highlight only the code blocks an edit changes #678 moves into a shared changedRanges utility that the code block decorations also use.
  • CHANGELOG.md records the faster typing in documents with many headings.

Related Issue

Closes #684

Verification

  • Focused tests compare the fold decorations with a full rebuild after edits that create, remove, split, and join headings, change a heading's level, give a heading a section or take it away, wrap blocks in a quote and lift them out, insert and remove content inside folded sections, combine several steps, and replace the whole document, each with and without folded headings. They also assert that typing in a paragraph creates no decorations and that folding decorates only the folded heading and its section. Removing any one rule that selects what to re-decorate makes them fail.
  • pnpm check:frontend passed.

Manually verified in a Node test on Windows 11, with a temporary probe that was not committed:

  1. The 5 MB dense fixture from Measure large documents again after the #638 fixes and set a supported size #680, parsed by the editor into 11,860 top-level blocks and 3,177 headings, held an editor state with only the fold plugin. A character was typed 30 times in a paragraph near the middle. The fold plugin's update took a median of 92 ms per keystroke on this branch, against 348 ms for the full rebuild every edit ran on main at 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 a DecorationSet, which is outside this change.

@Azganoth Azganoth added the Bug Something isn't working label Oct 12, 2026
@Azganoth Azganoth self-assigned this Oct 12, 2026
@Azganoth
Azganoth merged commit 3ba0cdc into main Oct 12, 2026
3 checks passed
@Azganoth
Azganoth deleted the bug/heading-fold-decorations branch October 12, 2026 21:22
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Bug Something isn't working

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Every edit rebuilds the heading fold decorations of the whole document

1 participant