large-list-data-grid-contracts
Large list data grid contracts
A virtualized list or grid keeps only a window of rows mounted, so three things form a contract: the total scroll size derived from a per-row size estimate, the overscan buffer that mounts rows just outside the viewport, and the set of features that assume every row is in the DOM (find-in-page, screen-reader counts, focus, sticky alignment). The bugs are never in the small happy-path fixture; they surface at the seams — a wrong estimate yanks the scroll, too-little overscan blanks white, and DOM-window assumptions fail silently on real data.
Checklist (lead with the trap)
- A test that disables virtualization hides every bug below it.
disableVirtualization(MUI X),onShouldVirtualize={() => false}(Fluent),renderAllRows(Handsontable), or a 5-row jsdom fixture mounts the whole list, so blank rows, offset drift, and focus loss never reproduce. Keep at least one path with virtualization ON — a browser/e2e test at a realistic item count and a fixed container height — or CI stays green on a broken grid. - estimateSize must be close, and measurement must not yank the scroll. The virtualizer sizes the total scroll range from
estimateSizebefore rows measure; whenmeasureElementreports the real height, following offsets shift. A flat() => 35for rows that wrap to variable heights makes scrolling jump and flicker as each row corrects. Give a realistic estimate and attachmeasureElementso measured rows report true size. - Prepending items (chat/feed) is where position-loss bites hardest. Inserting rows above the viewport pushes every following offset down; with no compensation the user's read position jumps. Anchor to a stable item (many libs expose
firstItemIndex/prepend support or ascrollToIndexrestore). Note CSSoverflow-anchoronly anchors real DOM nodes — it cannot anchor a row the virtualizer has not mounted. - Overscan is a two-sided budget. Too small blanks white gaps on fast scroll (rows unmount before the next ones mount and measure); too large mounts extra rows every frame and costs INP and memory. Tune per surface, and lean slightly higher for dynamically-measured lists where measurement lag is what causes the blanks.
- Features that assume rows are all in the DOM break silently. Ctrl+F / find-in-page matches nothing off-screen; "select all", export-visible, and
querySelector-based logic see only the window. If in-page search is a requirement,content-visibility: autowithcontain-intrinsic-sizekeeps rows in the DOM (searchable, in the a11y tree) while skipping paint — a different tradeoff from true windowing, not a drop-in fix. - Screen readers need the full set size, not the mounted count. Without
aria-setsize/aria-posinseton list items (role="option"/listitem, orarticlein arole="feed"), AT announces "3 of 20" instead of "3 of 5000". For grids, putaria-rowcount/aria-colcounton therole="grid"container andaria-rowindex/aria-colindexreflecting the true position on partially-loaded rows and cells; use-1when the total is unknown. - Focus is lost when the focused row unmounts. Scrolling the focused row out of the window removes its node, so focus falls back to
<body>— keyboard nav and the AT reading position reset. Manage focus deliberately (rovingtabindex, restore on remount); the grid pattern expects author-managed focus with a single tab stop into the widget. - Sticky header and pinned column must share the body's scroll math. A header or pinned column rendered outside the virtualized body drifts from the rows when heights are dynamic or the scrollbar changes width. Verify alignment with variable-height rows and at the top and bottom scroll extremes, not just at rest.
Quick probes
Treat hits as leads; confirm the estimate-to-measure-to-render path and the aria/focus wiring at the call site. No linter catches windowing bugs — the source of truth is a browser/e2e scroll test with virtualization enabled; route the aria row/set-size assertions to a11y-contract-testing's harness (axe/AT), since value-dependent attributes are invisible to eslint-plugin-jsx-a11y.