Skip to content

perf(preview): process and sanitize only the blocks that changed - #1024

Merged
PathGao merged 1 commit into
perf/split-blocks-rustfrom
perf/patch-changed-blocks
Oct 24, 2026
Merged

PathGao merged 1 commit into
perf/split-blocks-rustfrom
perf/patch-changed-blocks

Conversation

@PathGao

@PathGao PathGao commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator

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_markdown for its split form, and the new previewPatch.ts diffs the renderer's own blocks. Each block is keyed on its markup with data-sourcepos removed, 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. processBlockMarkup is the per-block half of processMarkdownHtml. 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:

  • The heading wrappers. A new block goes after the block before it, or at the top of that heading's wrapper.
  • The fold keys. They are reassigned only when a fold head changes, and a fold the reader toggled keeps its state.

Nothing is parsed after sanitizeMarkdownFragment. previewSanitize.test.ts pins this: the one markup parse in previewPatch.ts is the renderer output, into a detached document, before the filter.

Two things need special care:

  • Kept blocks that moved get their new data-sourcepos written 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.
  • Enrichment replaces some blocks with its own nodes: a code block's shell, or a Mermaid container in place of the <pre>. richContent.ts now records these in replacedBy, 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 (getSectionInfo in 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 -> patchPreviewBlocks with this branch's patchPreview, median of 2-4 runs:

200 KB 1 MB 5 MB
a keystroke in a paragraph 82 -> 1 ms 430 -> 4 ms 2246 -> 22 ms
Enter at the top (every range below moves) 82 -> 9 ms 434 -> 53 ms 2414 -> 280-500 ms
renaming a heading 78 -> 1 ms 408 -> 7 ms 2144 -> 37 ms
a new paragraph 78 -> 1 ms 413 -> 3 ms 2306 -> 16 ms
a new ## section 80 -> 5 ms 424 -> 29-70 ms 2324 -> 173-398 ms
first render 97 -> 111 ms 458 -> 606 ms 2332 -> 3005 ms

A 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:

  • Raw HTML that the browser would have nested across blocks is parsed one block at a time. The renderer keeps open raw HTML in one block (previous PR), and the parity test covers <details>. Stranger unbalanced markup could still render differently from before.
  • Any future enrichment that replaces a block without recording it in replacedBy would leave the old node behind when its block changes.
  • Not yet verified in the real Tauri app. Everything above ran under vitest/jsdom and headless Chrome.

Tests:

  • blockPatch.ts is deleted. Its tests are ported to previewPatch.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.ts replays the patch over the renderer's real output for samples/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.
  • The fixture is the renderer's JSON output. preview_block_fixture_is_current fails when it goes stale, and MARKPAD_UPDATE_FIXTURES=1 cargo test preview_block_fixture rewrites it.
  • I mutation-checked the patch: no-op range moves, always-prepend insertion, a dropped fold target and a missing tamper check each fail tests.

npm run check, npm test, vitest (574), and cargo fmt/clippy/test pass.

@PathGao
PathGao added this pull request to stack #1026 October 23, 2026 20:22
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
PathGao force-pushed the perf/patch-changed-blocks branch from 2cec591 to baf1e0f Compare October 24, 2026 05:36
@PathGao
PathGao merged commit f98291a into master Oct 24, 2026
3 of 4 checks passed
@PathGao
PathGao deleted the perf/patch-changed-blocks branch October 24, 2026 06:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant