Files
ChatApp/docs/superpowers/specs/2026-06-02-message-list-scroll-rewrite-design.md
T
2026-06-02 20:16:42 +02:00

9.2 KiB
Raw Blame History

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

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 === 0MessageList 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).