Repository navigation
perf(preview): process and sanitize only the blocks that changed - #1024
Merged
Merged
Conversation
After every typing pause the preview took the whole document through processMarkdownHtml, DOMPurify and a diff that serialised every block, so a pause cost the size of the document: about 0.8s at 1MB and 4.5s at 5MB on the main thread to change one paragraph. The preview now asks render_markdown for its split form and patchPreview diffs the renderer's own blocks, keyed on their markup without source ranges. Only the blocks that changed are processed (processBlockMarkup, the per-block half of processMarkdownHtml), sanitized with the same shared policy, and inserted. Kept blocks that moved get their new data-sourcepos written onto them. The heading wrappers and fold keys are kept on the live tree with DOM calls; a block goes after the one before it, and only adding, removing or re-levelling a top-level heading regroups. richContent records what it replaced a block with (replacedBy), so the patch can follow a code block into its shell or a diagram into its container. blockPatch.ts is gone; its tests are ported to previewPatch.spec.ts, and previewPatchParity.spec.ts holds the new path to the old one over the renderer's real output for samples/markdown-syntax.md, after eight kinds of edit and after undoing each.
PathGao
force-pushed
the
perf/patch-changed-blocks
branch
from
October 24, 2026 05:36
2cec591 to
baf1e0f
Compare
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.
In split view, after every typing pause the preview took the whole document through
processMarkdownHtml(DOMParser plus a dozen passes), DOMPurify, and a diff that serialised every block to compare it. All three scale with the document, not the edit. On a 5 MB file the preview froze for over two seconds after every word.This PR builds on the previous one. The preview now asks
render_markdownfor its split form, and the newpreviewPatch.tsdiffs the renderer's own blocks. Each block is keyed on its markup withdata-sourceposremoved, so a block that only moved down a line is still the same block. Only the blocks that changed are processed and sanitized, through the same shared policy, and inserted.processBlockMarkupis the per-block half ofprocessMarkdownHtml. The export still calls the whole-document function and its output is unchanged.Everything that needs the whole document either comes from comrak's full parse (footnotes, reference links, heading-id dedupe) or is done on the live tree with DOM calls after the filter:
Nothing is parsed after
sanitizeMarkdownFragment.previewSanitize.test.tspins this: the one markup parse inpreviewPatch.tsis the renderer output, into a detached document, before the filter.Two things need special care:
data-sourceposwritten onto them. Each range the renderer wrote maps to its counterpart in the new markup, and anchors the preview itself derived shift by the block's offset. Scroll sync, anchor restore and the task checkbox toggle all read these ranges.<pre>.richContent.tsnow records these inreplacedBy, so the patch can still find and remove them.Obsidian is the closest comparison. Its developers describe re-rendering only the blank-line-separated blocks that changed (forum), and its API exposes those sections (
getSectionInfoin obsidian.d.ts).Main-thread cost per pause, from the start of processing through the patch. The renderer itself runs off the main thread (about 90 ms at 1 MB and 460 ms at 5 MB, unchanged). Enrichment and layout are not included. Measured in headless Chrome over synthetic dense documents, comparing master's
processMarkdownHtml->sanitizeMarkdownFragment->patchPreviewBlockswith this branch'spatchPreview, median of 2-4 runs:##sectionA first render is slower here (about 30% at 5 MB) because every block gets its own DOMPurify call. A new section still regroups the whole document. The next PR fixes both.
Risks:
<details>. Stranger unbalanced markup could still render differently from before.replacedBywould leave the old node behind when its block changes.Tests:
blockPatch.tsis deleted. Its tests are ported topreviewPatch.spec.ts: keystroke, Enter above, heading typing, renumbered duplicate headings, new section and paragraph placement, folds, enrichment-replaced blocks, previewAnchor memos, and an article rewritten from outside.previewPatchParity.spec.tsreplays the patch over the renderer's real output forsamples/markdown-syntax.md, with callouts, folds, footnotes, math, Mermaid, tasks and<details>. It checks that a first render equals master's pipeline, and that eight kinds of edit, and undoing each, equal a first render of the result.preview_block_fixture_is_currentfails when it goes stale, andMARKPAD_UPDATE_FIXTURES=1 cargo test preview_block_fixturerewrites it.npm run check,npm test, vitest (574), andcargo fmt/clippy/testpass.