Skip to content

perf: cache widget DOM to avoid re-rendering on scroll (KaTeX, inline render, block headers) #514

@chaoxu

Description

@chaoxu

Problem

CM6 virtual rendering destroys DOM for lines leaving the viewport buffer and recreates them on scroll-back. Each recreation calls `toDOM()` / `createDOM()` on every widget in the recreated lines. Several widgets do expensive work in `createDOM()`:

Expensive widgets (re-render on every scroll in/out):

Widget File Cost What it does
MathWidget `math-render.ts:110` HIGH — calls `renderKatex()` → `katex.render()` (5-20ms per equation) Full KaTeX HTML generation
PdfCanvasWidget `image-render.ts:64` HIGH — clones canvas via `drawImage()` Canvas pixel copy
FootnoteSectionWidget `sidenote-render.ts:255` MEDIUM — calls `renderDocumentFragmentToDom()` which runs KaTeX for math in footnotes Inline rendering with math
FootnoteInlineWidget `sidenote-render.ts:443` MEDIUM — same `renderDocumentFragmentToDom()` Inline rendering with math
EmbedBlockWidget (plugin-render) `plugin-render.ts:76` MEDIUM — calls `renderDocumentFragmentToDom()` for block titles with math Inline rendering
FrontmatterTitleWidget `frontmatter-state.ts:107` LOW — calls `renderDocumentFragmentToDom()` for the title Inline rendering
HoverPreview content `hover-preview.ts:165,255` MEDIUM — renders block content + KaTeX for tooltip Full block rendering
InlineRender (math) `inline-render.ts:124` MEDIUM — calls `katex.renderToString()` KaTeX string generation

Cheap widgets (no caching needed):

Widget Cost What it does
CrossrefWidget Cheap — createElement + textContent Plain text spans
ClusteredCrossrefWidget Cheap — same Plain text spans
MixedClusterWidget Cheap — same Plain text spans
ImageWidget Cheap — createElement + set src Browser handles image caching
PdfLoadingWidget Cheap — createElement + text Placeholder text
CheckboxWidget Cheap — createElement Single checkbox element

Fix

Cache the rendered DOM inside each expensive widget. On subsequent `toDOM()` calls, clone the cached element instead of re-rendering:

```typescript
// In RenderWidget base class or per-widget:
private _cachedDOM: HTMLElement | null = null;

createDOM(): HTMLElement {
if (this._cachedDOM) return this._cachedDOM.cloneNode(true) as HTMLElement;
const el = /* ... expensive render ... */;
this._cachedDOM = el.cloneNode(true) as HTMLElement;
return el;
}
```

Cache is invalidated when `eq()` returns false (CM6 creates a new widget instance with different props, old cache is GC'd with old instance).

Impact

For a document with 30 equations, scrolling through the entire document currently triggers ~30 KaTeX re-renders. With caching: 0. Each KaTeX render is 5-20ms, so this saves 150-600ms of jank during a full scroll-through.

Scope

  1. Add DOM caching to `RenderWidget` base class (or `MacroAwareWidget`)
  2. Expensive widgets get it for free via inheritance
  3. Cheap widgets are unaffected (the cloneNode cost is negligible)
  4. No changes to decoration building or update predicates

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions