Skip to content

fix: highlight only the code blocks an edit changes - #678

Merged
Azganoth merged 3 commits into
mainfrom
bug/code-highlight-decorations
Oct 10, 2026
Merged

Azganoth merged 3 commits into
mainfrom
bug/code-highlight-decorations

Conversation

@Azganoth

@Azganoth Azganoth commented Oct 10, 2026 •

Copy link
Copy Markdown
Owner

Summary

On every document change, prosemirror-highlight 0.15.3 (under @milkdown/plugin-highlight) gathered every code block's tokens and built one new DecorationSet from them, which costs top-level blocks times tokens. Typing anywhere in a document with much code slowed with the square of its code.

  • A Leafdown plugin replaces the package's plugin and keeps the same Shiki parser, so the decorations it produces are unchanged. It highlights every code block when the document opens; after that, each transaction maps the existing set and highlights again only the code blocks in its changed ranges. An edit outside code does no highlighting work.
  • Two cases need more than the changed ranges. A node whose own opening or closing token changed, such as a new quote wrapped around blocks, has every code block inside it highlighted again, because mapping drops their block decorations once they fail validation under the new parent. An attribute step maps no positions, so the node it changes is added as a range.
  • @milkdown/plugin-highlight stays as the source of the Shiki createParser.
  • docs/architecture.md describes the incremental highlighting, and CHANGELOG.md records the faster typing under Fixed.

Related Issue

Closes #673

Verification

  • Focused tests compare the plugin's decorations with a full rebuild after edits inside, between, and across code blocks, splits and joins, language changes through setNodeMarkup and setNodeAttribute, conversions between code and paragraph, wrapping in and lifting out of a quote, multi-step transactions, and whole-document replacement, and they assert which blocks the parser runs for. Disabling the wrapped-node rule, the removal of stale decorations, or the attribute-step range each makes them fail.
  • pnpm check:frontend passed.

Manually verified in release builds on Windows 11, under a separate application identity in a 1280×900 window, driven through the WebView2 DevTools protocol:

  1. The fixture was 1 MB of fenced code: 930 30-line excerpts of the repository's TypeScript, Rust, and CSS, each after a one-sentence paragraph. Each run opened it in a fresh process and typed x five times at the end of the middle top-level paragraph, then five times at the end of the middle code block, waiting for 1.5 s without long frames after each keystroke. Two runs of main at 7cea7b9 were interleaved with two of this branch; figures are each run's median keystroke.

    Keystroke main This branch
    Paragraph, key press to paint 2.26–3.39 s 1.64–1.78 s
    Paragraph, long frames without forced layout 0.76–1.08 s 0.33–0.37 s
    Code block, key press to paint 2.10–2.52 s 1.77–1.78 s
    Code block, long frames without forced layout 0.79–0.96 s 0.40–0.42 s
  2. Characters typed into the code block were highlighted with Shiki's light and dark colours in the release build.

Not verified: opening times were not compared beyond noting they stayed within the same noisy 12.6–19.7 s range on both builds.

Notes

Both builds predate #677, so their paint times include more than 1 s of forced layout per keystroke from the block gutters it since removed, and the machine slowed by up to twice between runs. The frame time without forced layout is the stable comparison.

codeLineNumbers.ts also rebuilds its whole decoration set on every edit while code line numbers are shown. It is left unchanged here.

@Azganoth Azganoth added the Bug Something isn't working label Oct 10, 2026
@Azganoth Azganoth self-assigned this Oct 10, 2026
@Azganoth
Azganoth enabled auto-merge (squash) October 10, 2026 19:30
@Azganoth
Azganoth merged commit 0e4a96a into main Oct 10, 2026
3 checks passed
@Azganoth
Azganoth deleted the bug/code-highlight-decorations branch October 10, 2026 19:37
@github-project-automation github-project-automation Bot moved this from Backlog to Done in Leafdown Oct 10, 2026
Azganoth added a commit that referenced this pull request Oct 10, 2026
## Summary

After each edit, once typing paused for 200 ms, Milkdown's listener
plugin serialized the whole document so the session held a current copy
of its Markdown, and it serialized once more when the editor was
created. That blocked the main thread for about a third of a second in a
200 KB document of tables and about a second at 500 KB (#672).

The listener is removed. The session's `content` is now the Markdown a
document was opened, created, or last saved with, and each reader that
needs the current text gets it from the editor:

- Save and moving the open document already read the editor bridge first
and are unchanged.
- Reopening the same path reloads the editor whenever the document has
edits, not only when the file's text differs. The edits were discarded
to reopen it, and the session's text no longer shows them.
- An editor that lets go of an edited document that stays open hands its
Markdown to the session once. A remounted editor starts from that text,
and Save still writes the edits after a crash in the document surface
removes the editor, because the menu bar sits outside that error
boundary.
- Folder search treated the session's text changing as its cue to search
the open document again. It now waits 200 ms after a content-change
signal that the editor bridge fires on each edit without reading the
text.

The bridge's `getMarkdown` returns `null` instead of throwing while the
editor is still being created, so these readers fall back to the
session's text, which no edit can have changed yet.

## Related Issue

Closes #672

## Verification

- `openSession`: an edited document whose session text still matches the
file reloads when reopened after its edits are discarded.
- `document-screen`: edits fire the content signal without changing the
session's text; when the editor unmounts, the edited Markdown moves to
the session and the next editor mounts with it.
- `documentWorkflows`: Save writes the handed-over text once no editor
holds the document, and a clean or replaced document's editor is not
read.
- `folderSearchWorkflows`: the open document's results follow an edit
once it settles.
- Each of these tests failed with its fix disabled. The editor tests of
the listener were removed; the dirty-tracking assertions they held
remain.
- `pnpm check:frontend` passed after merging `main`.

Manually verified in a Windows 11 release build, before merging
#675–#678 from `main`:

1. In 205 KB and 505 KB documents of 120-row, 6-column tables, five
spaced keystrokes in a cell of the middle table were each followed by no
long animation frame. The keystroke's own frame, before the character
painted, took 69–85 ms at 205 KB and 181–226 ms at 505 KB, mostly the
input handler.
2. The same harness on `main` at 7cea7b9 recorded a 636–1179 ms timer
frame 230–290 ms after every keystroke at 205 KB.

Not verified: the release-build timings after merging #675–#678.
Azganoth added a commit that referenced this pull request Oct 10, 2026
## Summary

While code line numbers were shown, every document change rebuilt the
line-number decorations of every code block in one new `DecorationSet`,
which costs top-level blocks times decorations. Typing anywhere in a
document with much code slowed with the square of its code, as
highlighting did before #678.

- #678's incremental update moves out of the highlight plugin into a
shared `codeBlockDecorations` utility, and both plugins use it: the set
is built once, then each transaction maps it and decorates again only
the code blocks in its changed ranges, plus every code block inside a
node whose own boundary it changed. Highlighting behaves as before.
- With line numbers shown, an edit outside code does no numbering work
and an edit inside a block renumbers only that block. Turning the
setting on still numbers every block, and turning it off clears them.
- `docs/architecture.md` describes the line numbers alongside the
incremental highlighting, and the existing `CHANGELOG.md` entry for
faster typing in code-heavy documents now covers line numbers.

## Related Issue

Closes #679

## Verification

- Focused tests compare the line-number decorations with a full rebuild
after edits inside, between, and across code blocks, splits and joins,
conversions between code and paragraph, wrapping in and lifting out of a
quote, whole-document replacement, and toggling the setting with edits
in between, and they assert which blocks are renumbered. Rebuilding the
whole set, skipping the removal of stale decorations, or skipping the
code blocks inside a node whose boundary changed each makes them fail.
The highlight tests pass unchanged through the shared utility.
- `pnpm check:frontend` passed.

Manually verified in a happy-dom test mount on Windows 11, with a
temporary probe that was not committed:

1. The fixture was about 1 MB of fenced code: roughly 1,030 30-line
excerpts of the repository's TypeScript, Rust, and CSS, each after a
one-sentence paragraph. Each keystroke's transaction was applied to the
editor state seven times at the end of the middle paragraph and seven
times inside the middle code block.

   | Keystroke | `main` at fa9e842 | This branch |
   | --- | --- | --- |
   | Paragraph, line numbers shown | 163–476 ms | 9–19 ms |
   | Code block, line numbers shown | 158–182 ms | 9–12 ms |
   | Either, line numbers hidden | 6–24 ms | 6–14 ms |

Not verified: keystroke times in a release build.
Azganoth added a commit that referenced this pull request Oct 12, 2026
…em (#693)

## 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 #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 #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.
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 highlighting decorations of all code blocks

1 participant