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
- Add DOM caching to `RenderWidget` base class (or `MacroAwareWidget`)
- Expensive widgets get it for free via inheritance
- Cheap widgets are unaffected (the cloneNode cost is negligible)
- No changes to decoration building or update predicates
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):
Cheap widgets (no caching needed):
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