0025: Long lists draw only the rows in view
On this page
Status: accepted
Context
Logs keep up to 2,000 lines, Dumps up to 500 entries of each kind, Requests 300 rows and a webhook 300 deliveries, and all of them grow while you watch. With every row in the page, a busy site made each new event cost a layout of the whole list, and the window stuttered. The rows vary in height: a log line wraps, a dump shows any number of values, a query can open its plan. The app runs in the system web view, and the oldest macOS Short Leash supports (13) has Safari 16's WebKit, which lacks CSS content-visibility.
Decision
- Draw only the rows in view, plus a few either side.
VirtualListin the desktop app wrapsuseVirtualizerfrom@tanstack/react-virtual(MIT), pinned to an exact version. - Rows stay in the list's normal flow. Instead of positioning rows absolutely, the list keeps its own element, classes, role and label, draws the visible rows as its children, and stands in for the rest with one spacer above and one below, each as tall as the rows it replaces. The list's gap and its place in the scroller are read from its CSS, so the screens' styles apply unchanged.
- Rows are measured as they draw, margins included, so any height works; until then a row counts as a typical height for its list.
- Following a log scrolls so the last line's bottom meets the bottom of the view, as before, and stays there while rows settle, until the reader scrolls away.
- Live events reach these lists through
useFrameBatch, one state update per animation frame.
Alternatives
- CSS
content-visibility: autowould skip rendering off-screen rows with no code, but macOS 13's WebKit doesn't support it, and the rows would still all be in the page. - Windowing written for Short Leash would avoid a dependency, but measuring rows of any height, correcting the scroll position when a row above the view changes size, and keeping a followed log at its end are what the library already does and tests.
Consequences
- Each list puts a few dozen rows in the page however long it is, so a burst of events stays cheap.
- Rows not drawn aren't in the page: the browser's find in page and the accessibility tree only see the drawn ones, and selecting text across more than the drawn rows isn't possible. The lists' own search and filters cover finding.
- The scrollbar is exact for rows that have been drawn and estimated for the rest, so it can shift slightly the first time you scroll through a long list.
- jsdom has no layout, so the UI tests give lists a tall stand-in view (
setVirtualListFallbackRect) and every row draws, as before. - A new long list should use
VirtualList; short ones (Mail keeps 100) only need memoised rows.