docs(spec): message-list / scroll rewrite design (TanStack Virtual + deferred reveal)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
byGalax
2026-06-02 20:16:42 +02:00
parent 51a8630114
commit 255dbdc712
@@ -0,0 +1,184 @@
# 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 `<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`/`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 `<Virtuoso>` block in `ConversationPage` for `<MessageList>`; 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).