d-v-list
DsVirtualList is the virtual list of DeepSeek’s web chat, restored from the JavaScript their site serves. It is kept for reference while building VirtualList: it is not part of raft-ui, is not exported, and must not ship. The demos run the restored code itself, unchanged: the restored files plus only the import declarations that bind their bundle neighbours (checked node by node against the restored AST), with the modules they require and DeepSeek’s own CSS, extracted rule by rule. They run on the React the code was built with, 18.3.1, in React roots of their own. Only the demo files and the stand-ins for the parts left out (the message components and the app’s service container) are new code; see the folder’s README.md. This page runs the JavaScript form, js/; d-v-list (TypeScript) runs the same demos on the TypeScript form, ts/: the same files with only types added, checked the same way.
Items are measured as they mount, offsets come from a Fenwick tree, the rendered window is sized from a minimum item height (no overscan), and a new range is committed synchronously (flushSync) from the scroll handler.
Demos
Basic
10,000 rows of one to four lines, with heights unknown until a row mounts (itemResizable, itemEstimatedSize 84, itemMinSize 57). Scroll to index and Scroll to key call scrollTo({ index | key, debounce: false }); the target is offset from the Fenwick tree, so it is an estimate until the rows on the way are measured, and the jump is replayed after the target and its neighbours are measured (for one frame plus one task).
Chat
DeepSeek’s ChatMessageList (itemMinSize 60, itemEstimatedSize 140) and its scroll controller, driven the way the app drives them: the conversation is in the app’s message store, the route names the open session, and the app’s providers are in place. Stream an answer updates the store the way the stream service does: a question and a WIP answer are added and the session’s forceScrollToBottomTrigger goes up; then the answer grows every 40 ms, first a thinking block, then the body; the message component calls requireScrollToBottom() on every update, which bumps scrollToBottomTrigger. While you are at the bottom the list follows, until the top of the answer body (.ds-assistant-message-main-content) reaches the top of the view: following stops there, once per answer, so you read it from the start. Scroll up to stop following; ↓ Latest (shown when the controller asks for the scroll-to-bottom button) sends clickButtonToScrollToBottom on the app’s event bus. Jump to message sends scrollNavActivateMessage, as the scroll navigation does; Jump to answer body sets a pending search target, and the list aligns the answer body 48 px from the top. Some answers hold an image with no known size, below their first paragraph, that loads up to two seconds after the message mounts: rows entering above the view don’t move what you are reading (the difference between their estimate and their first measurement is scrolled away), but an image that loads later in the part of a message above the view pushes the text in view down by its height, since only first measurements are compensated.
Keeping selected rows
While text is selected, rows scrolled out of the range stay mounted, positioned absolutely where they were (highlighted here), so the selection and copying survive scrolling. Select text in row 2, then Scroll to row 1000. As restored, the check keeps every mounted row without an input, not only the one holding the selection, so the count grows while a selection exists.
Virtual on and off
DeepSeek turns virtualization off before printing and for short chats on iOS. Switching off mounts every row in normal flow and restores the row at the top of the view from a snapshot taken before the switch; switching on records the measured heights of every row first. Switching back on does not keep the position: nothing re-applies the spacer height on that transition, so the browser clamps the scroll offset while only the rendered rows are left. DeepSeek turns virtualization back on only together with a new session, where the position is reset anyway.