Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
9.2 KiB
Message-List / Scroll Rewrite — Design
Date: 2026-06-02
Status: Approved (brainstorming) — pending spec review → implementation plan
Scope: Desktop app only (apps/desktop). Mobile is out of scope.
1. Problem & Root Cause
Switching conversations causes a visible jump and then a multi-flicker. Root cause (established via systematic debugging, not guessing):
- The message list is virtualized (
react-virtuoso). Virtualization paints rows with estimated heights, then measures real heights and correctsscrollTop. - On chat open, several async sources change row heights after the first paint:
message reactions (
useMessageReactions), the unread divider (firstUnreadId), delivery/read receipts, and the two-phase message load (in-memory cache render → serverrefresh()replaces the array). - Each post-paint height change makes the virtualizer re-measure and re-anchor → the viewport visibly moves several times = "flickert paar mal".
A first targeted fix (initialTopMostItemIndex: { index: 'LAST', align: 'end' })
addressed only the initial anchor, not the post-paint cascade — so the flicker
remained/worsened. Conclusion: re-architect the scroll system.
2. Goals / Success Criteria
- Opening or switching a chat lands cleanly at the bottom (or the saved scrolled-up row) with no visible jump or flicker.
- Discord-like live behavior: auto-scroll on new message when at bottom; "X new messages" pill when scrolled up; unread divider; load-older without the viewport jumping; jump-to-message / search / pin scroll.
- Scales to large conversations (thousands of messages, deep back-scroll) — virtualization stays.
- No regression of the existing features that live in
ConversationPage.
3. Decision
Build on @tanstack/react-virtual (MIT, free) as the virtualization primitive,
and kill the flicker at its root with a deferred-reveal strategy: never show the
list while its row heights are still settling.
Rejected alternatives: keeping react-virtuoso (we are fighting it); the commercial
@virtuoso.dev/message-list (license cost); dropping virtualization entirely
(large chats would render thousands of DOM nodes).
4. Architecture (isolation)
The scroll/virtualization logic moves out of the ~2000-line ConversationPage into
two focused, independently-testable units:
apps/desktop/src/components/MessageList.tsx— owns the scroll container, the TanStack virtualizer, dynamic measurement, deferred reveal, stick-to-bottom, and load-older position preservation. Receives rows + a render function; emits scroll events + exposes an imperative handle. Knows nothing about messages, reactions, drafts, calls, etc.apps/desktop/src/lib/scrollController.ts— the pure, DOM-free decision logic (anchor computation, "should auto-scroll to bottom?", load-older index/offset math, at-bottom threshold). Unit-tested with vitest.ConversationPagekeeps all feature state and rendering; it builds the sameVirtuosoRow[]discriminated union (loader | message | pending), passes them + the existing per-row render (itemContent) into<MessageList>, and drives the imperative handle for jump-to-message/search.
Boundary contract: in = rows + renderRow; out = scroll events + an imperative
handle. The internals of MessageList can change without touching ConversationPage.
5. No-Flicker Core
5.1 Deferred reveal
MessageList is always mounted (so TanStack can measure the initial window), but
rendered visually hidden (opacity: 0, pointer-events none) behind a spinner
until ready is true. When ready flips true, in a useLayoutEffect (before the
browser paints) it scrolls to initialAnchor (bottom, or the saved row), then
reveals (opacity: 1) and removes the spinner. The user sees: brief spinner →
final, correctly-anchored list. The height-changing cascade happens while hidden.
ready (owned by ConversationPage, passed in) is defined as:
- messages loaded (
!loading && rows.length > 0), AND - the initial reactions fetch for the current message-id set has completed
(requires adding a
ready/loadedflag touseMessageReactions), AND - a hard max-timeout of ~300 ms fallback so a slow/empty reactions fetch never hangs the reveal.
The unread divider is computed synchronously in an effect right after messages load, i.e. before reactions resolve — so it is present before reveal. Delivery/read receipts render as inline ticks (no meaningful height change) and are intentionally not gated.
5.2 Stick-to-bottom
A ResizeObserver on the inner content element: while the user is at the bottom
(within atBottomThreshold, default 64px), any content-size growth re-pins the view
to the bottom in a layout effect (before paint) — so a live incoming message/reaction
never leaves the newest message half-scrolled.
5.3 Dynamic measurement
TanStack measureElement (ResizeObserver per rendered row) handles variable bubble
heights. Only the virtual window (visible + overscan ~8 rows) is rendered/measured.
5.4 Load-older without jump
On onReachTop, ConversationPage grows displayCount (prepending older rows).
Because prepending shifts indices, MessageList preserves position: capture
scrollHeight before the row growth, then after re-render set
scrollTop += (newScrollHeight − oldScrollHeight) in a layout effect keyed on
"rows grew at the top". Items keep stable keys via computeKey (message id). This
also fixes the second audit gap (older-load jump).
6. Preserved Behaviors
| Behavior | New mechanism |
|---|---|
| Open → bottom / saved row | initialAnchor applied in useLayoutEffect before reveal |
| New message while at bottom → follow | stick-to-bottom controller |
| New message while scrolled up → pill | onAtBottomChange drives the existing pill |
| Unread divider | computed before reveal → no later height change |
| Load older (scroll to top) | onReachTop + scrollHeight-delta preservation |
| Jump-to-message / search / pin | imperative scrollToRow(index, align) |
| Pending (outbox) bubbles | stay as a row kind in the same list |
| Per-conversation scroll memory | unchanged module-scoped scrollPositions map, fed by onAtBottomChange + onTopRowChange |
7. Interface
export interface MessageListHandle {
scrollToBottom(behavior?: 'auto' | 'smooth'): void;
scrollToRow(index: number, align?: 'center' | 'end', behavior?: 'auto' | 'smooth'): void;
}
export interface MessageListProps {
rows: VirtuosoRow[]; // loader | message | pending
renderRow: (index: number, row: VirtuosoRow) => React.ReactNode;
computeKey: (row: VirtuosoRow) => string; // message id / pending id / '__loader__'
initialAnchor: { type: 'bottom' } | { type: 'row'; index: number };
ready: boolean; // reveal gate (§5.1)
estimateRowHeight?: number; // default ~64
atBottomThreshold?: number; // default 64px
onReachTop(): void; // load older
onAtBottomChange(atBottom: boolean): void;
onTopRowChange(topIndex: number): void; // scroll memory
}
8. Error Handling / Edge Cases
- Empty conversation:
rows.length === 0→MessageListrenders nothing;readyshort-circuits to the existing empty state inConversationPage. - Single / very short conversation (content < viewport): bottom-anchor is a no-op; reveal immediately.
- Rapid chat switching: each switch remounts
ConversationPage(per-id key) → a freshMessageListinstance with fresh measurements (no stale sizes carried over). - Very large
displayCountafter deep back-scroll: only the virtual window renders; memory bounded by overscan. - Reactions fetch error/empty: max-timeout reveals the list anyway.
9. Testing
- Unit (vitest):
scrollController.tspure functions — anchor resolution, should-auto-scroll decision, load-older offset math, at-bottom threshold. - Manual (dev): iterate in
pnpm desktop:devwith the temporarySCROLL_DEBUGlogging until chat-switch is flicker-free and all §6 behaviors verified live. - No automated DOM/layout test (jsdom has no layout); the running app is the test.
10. Rollout
- Add
@tanstack/react-virtual. - Build
scrollController.ts(+ tests) andMessageList.tsx. - Swap the
<Virtuoso>block inConversationPagefor<MessageList>; add thereadyflag touseMessageReactions. - Verify in dev with the user (flicker-free + all behaviors).
- Remove the
SCROLL_DEBUGinstrumentation and thereact-virtuosodependency. - Release 0.21.6 to
update.netralax.de(served on.de+.cloud).
11. Out of Scope
Composer, header, dialogs, calls, search UI, message rendering (MessageBubble),
encryption/data layer, mobile app. Reaction/receipt data loading is touched only to
add the ready flag for the reveal gate.
12. Open Risks
- TanStack prepend position-preservation needs careful layout-effect timing; mitigated by dev iteration before release.
- The
readyreveal adds a brief (≤300 ms) spinner on chat open even for cached chats; acceptable trade-off vs flicker. A future in-memory reaction cache could make revisits instant (not in this scope).