From 255dbdc712c68d7b41bea829b5edb031013928fb Mon Sep 17 00:00:00 2001 From: byGalax Date: Tue, 2 Jun 2026 20:16:42 +0200 Subject: [PATCH] docs(spec): message-list / scroll rewrite design (TanStack Virtual + deferred reveal) Co-Authored-By: Claude Opus 4.8 (1M context) --- ...6-02-message-list-scroll-rewrite-design.md | 184 ++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-02-message-list-scroll-rewrite-design.md diff --git a/docs/superpowers/specs/2026-06-02-message-list-scroll-rewrite-design.md b/docs/superpowers/specs/2026-06-02-message-list-scroll-rewrite-design.md new file mode 100644 index 0000000..b0503a4 --- /dev/null +++ b/docs/superpowers/specs/2026-06-02-message-list-scroll-rewrite-design.md @@ -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 ``, 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).