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).
Typing in a conversation got progressively slower as its history grew.
The cause was not the input: every packet the node overhears was stored
in App state, so each one re-rendered the whole tree, including the
un-memoized MessageList. That cost scales with history length, and on a
busy mesh it saturated the main thread so keystrokes queued behind it.
Measured in Chromium with the real components, cost per overheard packet:
history before after
50 7.3ms ~0ms
500 48.5ms ~0ms
1000 97.7ms ~0ms
2000 228.4ms ~0ms
The packet stream now lives in a small external store that views subscribe
to individually, so only the map, visualizer, raw feed, and cracker re-render
when a packet arrives, so the chat view is no longer along for the ride.
This also retires useRawPacketStatsSession, whose session state was App
state for the same reason.