Skip to content

perf(preview): load rich-content libraries on demand, two fewer parses per render, cached scroll samples - #949

Merged
PathGao merged 3 commits into
masterfrom
perf/preview
Oct 1, 2026
Merged

PathGao merged 3 commits into
masterfrom
perf/preview

Conversation

@PathGao

@PathGao PathGao commented Oct 1, 2026

Copy link
Copy Markdown
Collaborator

What this is

Three preview changes, one commit each.

  • Libraries on demand. Every window loaded full highlight.js (194 languages), KaTeX and Mermaid on mount, and enrich waited for all three. Each now loads only when the document holds code, math or a diagram, and enriches as soon as it lands. highlight.js is lib/common. Other fenced languages load per file by name. An alias (docker, ps1) or unknown name registers every grammar once per window, since only the grammars know their aliases. Export shares the same loaders.
  • Two fewer parses per render. The preview serialized DOMPurify's tree to a string, then blockPatch parsed it again in a <template>. sanitizeMarkdownFragment now returns DOMPurify's fragment (RETURN_DOM_FRAGMENT), and blockPatch moves those nodes in. The sanitizer stays the last step before the DOM, and nothing reparses after it. Switching back to a tab whose preview already holds that content skips sanitize and patch.
  • Scroll samples. collectLineSamples walked the whole preview on every scroll event, twice in split view. The element list is now cached per root, and positions are still measured live. It is invalidated through invalidateAnchorMemos on patch, fold, a tab switch that skips the patch, and a Mermaid diagram replacing its <pre>.

Mechanism

Libraries a first render fetches (vite build): any document before, hljs 971 KB + Mermaid 605 KB + KaTeX/mhchem ~295 KB. After: a plain-text document 0, code 164 KB plus its languages, KaTeX and Mermaid only when used.

jsdom, 1.15 MB document, one word changed, min of 5: sanitize + patch 4.3–5.5 s → 2.4–2.5 s. Line lookup on 17,430 blocks 38.1 ms → 0.08 ms. jsdom is far slower than WebKit, so only the ratios mean anything.

Tests

Red without the change, green with it:

  • richContentLazyLoad.spec.ts
  • previewSanitize.spec.ts: <style>, <script>, onerror and javascript: through the fragment path
  • lineSamplesCache.spec.ts: cache, fold invalidation, diagram invalidation

Verification

Rebased on master after #945–#947:

  • npm run check: 0 errors
  • npm test: 1112/1112
  • npx vitest run: 492/492
  • npm run build: ok

Not verified: the Tauri app. Each preview now restores its reading position once after the first enrich, where before that only happened when the libraries arrived after the document.

…ent uses them

The viewer imported full highlight.js (194 grammars), KaTeX + mhchem and
Mermaid on every mount, and enrichment waited for all three. Each library
is now imported by renderRichContent the first time the roots hold code,
math or a diagram, and each pass runs as soon as its own library is in.
highlight.js is the `common` build; other fenced grammars load per name
(aliases by registering every grammar once). The export shares the path.

vite build, rich-content chunks fetched on first render:
  before: hljs 971 KB + Mermaid 605 KB + KaTeX/mhchem ~295 KB, every document
  after:  plain text 0; code 164 KB (+ the named grammar's chunk);
          KaTeX and Mermaid only for math / diagrams
Total chunk bytes on disk +153 KB (per-grammar chunks).
…an unchanged host

Every render serialized DOMPurify's tree to a string only for blockPatch to
parse it again into a <template>. The preview now asks DOMPurify for a
fragment (same MARKDOWN_SANITIZE_CONFIG) and the patch moves those nodes,
so the sanitizer is still the last thing before the DOM, with no reparse
after it. A tab switch whose host already holds the tab's HTML is no
longer sanitized and diffed again.

jsdom, ~1.15 MB markdown (1.78 MB HTML), one-word edit, min of 5:
  sanitize + patch           4.3-5.5 s -> 2.4-2.5 s
  process + sanitize + patch 5.0-5.4 s -> 4.3-4.5 s
  tab switch, same content   2.7-3.6 s -> skipped
collectLineSamples walked the whole preview on every scroll event, twice
per event in split view. The table (elements only; boxes are still
measured live) is now kept per root and dropped by invalidateAnchorMemos,
which the patch already calls and which a fold toggle, a tab switch that
skips the patch, and a Mermaid diagram replacing its <pre> now call too.

jsdom, 17,430 blocks: 38.1 ms -> 0.08 ms per offset-to-line mapping.
@PathGao
PathGao merged commit 09cb7af into master Oct 1, 2026
4 checks passed
@PathGao
PathGao deleted the perf/preview branch October 1, 2026 12:58
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