Skip to content

Glyph grid: blank rows during very fast scrollbar drags #50

@kostyafarber

Description

@kostyafarber

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

  1. Open a font with a large character set (e.g. Noto Sans CJK or any pan-Unicode font with several thousand glyphs).
  2. On the Home view, grab the scrollbar thumb with the mouse and yank it down/up as fast as possible.
  3. 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)

  1. 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).
  2. 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.
  3. Custom rangeExtractor. Pre-extend the rendered range in the scroll direction to give new rows a head start on mounting.
  4. 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).
  5. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions