Summary
When scrolling the home glyph grid very fast via the scrollbar (mouse drag), rows render as blank for a few frames before the SVG previews paint. Slow/normal scrolling is fine. This is the classic TanStack Virtual fast-scroll gap: the scroll position outruns the row-mount + paint budget, so the overscan buffer is exhausted before new rows can render.
Repro
- Open a font with a large character set (e.g. Noto Sans CJK or any pan-Unicode font with several thousand glyphs).
- On the Home view, grab the scrollbar thumb with the mouse and yank it down/up as fast as possible.
- Observe: rows in the viewport are blank/empty for ~1–3 frames before the glyphs flash in.
Relevant code
- Grid:
apps/desktop/src/renderer/src/components/home/GlyphGrid.tsx
overscan: 5 (line 58, 104)
ROW_HEIGHT = CELL_HEIGHT + 40 + 8 = 123px (line 53)
- Preview:
apps/desktop/src/renderer/src/components/home/GlyphPreview.tsx
- Each visible cell subscribes to
outline.$svgPath and glyph.$xAdvance signals on mount and renders an <svg><path d={…}/></svg> (lines 70–96).
Why it happens
overscan: 5 rows above/below = ~615px buffer. A scrollbar fling can throw the viewport by thousands of px in one frame, blowing past the buffer.
- Each newly visible cell does work on mount: signal subscription, SVG path string read, React commit, layout/paint of a non-trivial SVG path. With wide rows (10+ columns) that's 50+ paths per row.
- Nothing is rendered for the in-between rows that haven't mounted yet, so the user sees a blank band until the next animation frame catches up.
Proposed fixes (cheap → ambitious)
- Bigger overscan + skeleton placeholders. Bump
overscan to ~15 and render a lightweight placeholder cell (just the cell box, no SVG) for rows whose $svgPath signal hasn't resolved yet. This is what TanStack recommends for fast-scroll blanking (discussion #694).
- Velocity-aware rendering. Track scroll velocity; while velocity is above a threshold, render skeletons (just the cell frames or unicode fallback), then swap in the SVG once velocity drops. Avoids paying the SVG path cost during the throw.
- Custom
rangeExtractor. Pre-extend the rendered range in the scroll direction to give new rows a head start on mounting.
- Thumbnail cache + raster strategy. Rasterize each glyph preview once into an
OffscreenCanvas / ImageBitmap keyed by (glyphName, designLocation, $svgPath version). On scroll, blit the bitmap instead of re-laying-out an SVG element each frame. This is the real path to 120fps for 50k+ glyph sets — DOM/SVG hits a ceiling around ~5k visible-mountable cells (see research notes below).
- CSS containment. Add
contain: layout paint / content-visibility: auto to row containers to bound style/layout recalcs.
Research: 120fps on massive glyph sets
Notes from TanStack Virtual docs and large-list performance literature:
- DOM/SVG ceiling. Below ~5k items, vanilla TanStack Virtual + memoized cells is fine. Beyond that, the per-cell mount + paint cost dominates and you need either skeletons-on-throw or a canvas strategy.
- Tuning levers in TanStack Virtual.
overscan — directly trades work-per-scroll for fewer blanks.
rangeExtractor — full control over which indexes render; can extend asymmetrically based on scroll direction.
measureElement — only needed if cell heights are dynamic (ours are fixed at 123px, so no).
- React-side cost reduction. Heavy memoization on cell components,
useMemo for viewBox and cellWidth, avoid creating new style objects per render, kill any context subscriptions inside the cell.
- Beyond React. For 100k+ glyph sets:
- Glyph thumbnail atlas. Rasterize all unique glyphs into a sprite-sheet
OffscreenCanvas (or WebGL texture atlas) once after font load. Each visible cell becomes a single drawImage/textured quad. Re-rasterize on outline change via signal subscription.
- Single canvas grid. Replace the per-cell
<svg> with one <canvas> per visible viewport. Scroll redraws are O(visible rows × cols) draw calls instead of N React reconciliations.
- Web Worker rasterization. Move SVG-path → bitmap conversion to a worker so the main thread stays free for input/scroll.
- CSS hygiene.
contain: strict on the scroll container, will-change: transform on the absolutely-positioned rows, content-visibility: auto on cells.
Suggested first step
Land fix #1 (bigger overscan + skeleton fallback when $svgPath is null) as a low-risk improvement, then evaluate whether the bitmap-atlas approach is worth pursuing for the Noto CJK / pan-Unicode use case.
Summary
When scrolling the home glyph grid very fast via the scrollbar (mouse drag), rows render as blank for a few frames before the SVG previews paint. Slow/normal scrolling is fine. This is the classic TanStack Virtual fast-scroll gap: the scroll position outruns the row-mount + paint budget, so the overscan buffer is exhausted before new rows can render.
Repro
Relevant code
apps/desktop/src/renderer/src/components/home/GlyphGrid.tsxoverscan: 5(line 58, 104)ROW_HEIGHT = CELL_HEIGHT + 40 + 8= 123px (line 53)apps/desktop/src/renderer/src/components/home/GlyphPreview.tsxoutline.$svgPathandglyph.$xAdvancesignals on mount and renders an<svg><path d={…}/></svg>(lines 70–96).Why it happens
overscan: 5rows above/below = ~615px buffer. A scrollbar fling can throw the viewport by thousands of px in one frame, blowing past the buffer.Proposed fixes (cheap → ambitious)
overscanto ~15 and render a lightweight placeholder cell (just the cell box, no SVG) for rows whose$svgPathsignal hasn't resolved yet. This is what TanStack recommends for fast-scroll blanking (discussion #694).rangeExtractor. Pre-extend the rendered range in the scroll direction to give new rows a head start on mounting.OffscreenCanvas/ImageBitmapkeyed by(glyphName, designLocation, $svgPath version). On scroll, blit the bitmap instead of re-laying-out an SVG element each frame. This is the real path to 120fps for 50k+ glyph sets — DOM/SVG hits a ceiling around ~5k visible-mountable cells (see research notes below).contain: layout paint/content-visibility: autoto row containers to bound style/layout recalcs.Research: 120fps on massive glyph sets
Notes from TanStack Virtual docs and large-list performance literature:
overscan— directly trades work-per-scroll for fewer blanks.rangeExtractor— full control over which indexes render; can extend asymmetrically based on scroll direction.measureElement— only needed if cell heights are dynamic (ours are fixed at 123px, so no).useMemoforviewBoxandcellWidth, avoid creating new style objects per render, kill any context subscriptions inside the cell.OffscreenCanvas(or WebGL texture atlas) once after font load. Each visible cell becomes a singledrawImage/textured quad. Re-rasterize on outline change via signal subscription.<svg>with one<canvas>per visible viewport. Scroll redraws are O(visible rows × cols) draw calls instead of N React reconciliations.contain: stricton the scroll container,will-change: transformon the absolutely-positioned rows,content-visibility: autoon cells.Suggested first step
Land fix #1 (bigger overscan + skeleton fallback when
$svgPathis null) as a low-risk improvement, then evaluate whether the bitmap-atlas approach is worth pursuing for the Noto CJK / pan-Unicode use case.