255dbdc712
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
185 lines
9.2 KiB
Markdown
185 lines
9.2 KiB
Markdown
# 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).
|