Virtualize the message list

A conversation rendered every message it had loaded. Nothing bounded that
work, so cost grew with history: at 2000 messages the list mounted ~20k DOM
nodes, took ~285ms to mount, and ~212ms to re-render on any update. While
that runs, nothing else on the main thread gets a turn, typing included.

Only the visible window is mounted now, measured in Chromium with the real
component tree:

  history    mount            re-render        DOM nodes
    2000     285ms -> 4ms     212ms -> 2.2ms   22038 -> 225

Rendered rows stay at ~17 regardless of how much history is loaded.

Row heights are measured rather than assumed, since messages vary from one line
to a wrapped paragraph with path badges, so the scroll behaviors that used
to do height arithmetic now anchor by index instead, which is exact even
when off-screen rows still carry estimated heights:

- opening a conversation, and following new messages, anchor to the last row
- loading older messages anchors to the message that was on top
- jump-to-message and jump-to-unread scroll by index, since the target row
  may not be mounted yet

Two degenerate cases are handled explicitly: a viewport that measures zero
(before first layout, hidden tab) falls back to a nominal height instead of
collapsing the window to nothing, and a row that measures zero keeps its
estimate instead of ballooning the window.

Most of the diff is re-indentation from the new wrapper element; the real
change is 112 added / 36 removed lines (git diff -w).
This commit is contained in:
Ryan Gregg
2026-07-25 00:40:47 -07:00
parent 889bb19ef5
commit fab331e67e
4 changed files with 403 additions and 310 deletions
+1
View File
@@ -25,6 +25,7 @@
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@tanstack/react-virtual": "^3.14.8",
"@uiw/react-codemirror": "^4.25.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",