# 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 corrects `scrollTop`. - 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 → server `refresh()` 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 1. Opening or switching a chat lands cleanly at the bottom (or the saved scrolled-up row) with **no visible jump or flicker**. 2. 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. 3. Scales to **large conversations** (thousands of messages, deep back-scroll) — virtualization stays. 4. 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. - **`ConversationPage`** keeps all feature state and rendering; it builds the same `VirtuosoRow[]` discriminated union (`loader | message | pending`), passes them + the existing per-row render (`itemContent`) into ``, 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`/`loaded` flag to `useMessageReactions`), **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 ```ts 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` → `MessageList` renders nothing; `ready` short-circuits to the existing empty state in `ConversationPage`. - 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 fresh `MessageList` instance with fresh measurements (no stale sizes carried over). - Very large `displayCount` after 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.ts` pure functions — anchor resolution, should-auto-scroll decision, load-older offset math, at-bottom threshold. - **Manual (dev):** iterate in `pnpm desktop:dev` with the temporary `SCROLL_DEBUG` logging 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 1. Add `@tanstack/react-virtual`. 2. Build `scrollController.ts` (+ tests) and `MessageList.tsx`. 3. Swap the `` block in `ConversationPage` for ``; add the `ready` flag to `useMessageReactions`. 4. Verify in dev with the user (flicker-free + all behaviors). 5. Remove the `SCROLL_DEBUG` instrumentation and the `react-virtuoso` dependency. 6. 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 `ready` reveal 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).