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:
@@ -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).
|
||||||
Reference in New Issue
Block a user