Files
ChatApp/docs/superpowers/plans/2026-06-02-message-list-scroll-rewrite.md
T
byGalax e822f6f58f docs(plan): message-list / scroll rewrite implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 20:22:28 +02:00

23 KiB

Message-List / Scroll Rewrite — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Replace the react-virtuoso message list with a TanStack-Virtual list that opens/switches chats flicker-free (Discord-like), preserving every existing behavior.

Architecture: Pure scroll-decision logic (scrollController.ts, unit-tested) + an isolated virtualization component (MessageList.tsx, TanStack Virtual, deferred reveal) + ConversationPage wiring. The flicker is killed by keeping the list hidden until messages+reactions+divider are stable, then anchoring before paint.

Tech Stack: React 18, TypeScript, @tanstack/react-virtual (new), vitest, electron-vite.

Spec: docs/superpowers/specs/2026-06-02-message-list-scroll-rewrite-design.md


File Structure

  • Create: apps/desktop/src/lib/scrollController.ts — pure scroll math (no DOM/React).
  • Create: apps/desktop/src/lib/scrollController.test.ts — vitest unit tests.
  • Create: apps/desktop/src/components/MessageList.tsx — TanStack virtual list + reveal/stick/load-older. Exports MessageList, MessageListHandle, VirtuosoRow is imported from ConversationPage's shared type (moved in Task 5).
  • Modify: apps/desktop/src/lib/useMessageReactions.ts — add ready flag for the reveal gate.
  • Modify: apps/desktop/src/pages/ConversationPage.tsx — export the row type, swap <Virtuoso> for <MessageList>, drive the handle, pass ready.
  • Modify: apps/desktop/package.json — add @tanstack/react-virtual; remove react-virtuoso (Task 8).

Task 1: Add the TanStack Virtual dependency

Files:

  • Modify: apps/desktop/package.json

  • Step 1: Install

Run (from repo root chat-app/):

pnpm --filter @chat-app/desktop add @tanstack/react-virtual@^3.10.0

Expected: adds @tanstack/react-virtual to apps/desktop/package.json dependencies; lockfile updated.

  • Step 2: Verify it resolves

Run: pnpm --filter @chat-app/desktop exec node -e "require.resolve('@tanstack/react-virtual'); console.log('ok')" Expected: ok

  • Step 3: Commit
git add apps/desktop/package.json pnpm-lock.yaml
git commit -m "build(desktop): add @tanstack/react-virtual"

Task 2: Pure scroll-decision logic (TDD)

Files:

  • Create: apps/desktop/src/lib/scrollController.ts

  • Test: apps/desktop/src/lib/scrollController.test.ts

  • Step 1: Write the failing tests

// apps/desktop/src/lib/scrollController.test.ts
import { describe, expect, it } from 'vitest';
import { isNearBottom, isNearTop, resolveInitialAnchor } from './scrollController';

const m = (scrollTop: number, scrollHeight: number, clientHeight: number) => ({
  scrollTop,
  scrollHeight,
  clientHeight,
});

describe('isNearBottom', () => {
  it('true exactly at the bottom', () => {
    expect(isNearBottom(m(900, 1000, 100), 64)).toBe(true);
  });
  it('true within threshold', () => {
    expect(isNearBottom(m(860, 1000, 100), 64)).toBe(true);
  });
  it('false beyond threshold', () => {
    expect(isNearBottom(m(800, 1000, 100), 64)).toBe(false);
  });
});

describe('isNearTop', () => {
  it('true at top', () => {
    expect(isNearTop(m(0, 1000, 100), 64)).toBe(true);
  });
  it('false past threshold', () => {
    expect(isNearTop(m(200, 1000, 100), 64)).toBe(false);
  });
});

describe('resolveInitialAnchor', () => {
  it('anchors to last row at end by default (no saved position)', () => {
    expect(resolveInitialAnchor(null, 50)).toEqual({ index: 49, align: 'end' });
  });
  it('anchors to bottom when saved position stuck to bottom', () => {
    expect(resolveInitialAnchor({ topmostIndex: 10, stickToBottom: true }, 50)).toEqual({
      index: 49,
      align: 'end',
    });
  });
  it('restores the saved row at the top when scrolled up', () => {
    expect(resolveInitialAnchor({ topmostIndex: 12, stickToBottom: false }, 50)).toEqual({
      index: 12,
      align: 'start',
    });
  });
  it('clamps a stale saved index to the current row count', () => {
    expect(resolveInitialAnchor({ topmostIndex: 999, stickToBottom: false }, 50)).toEqual({
      index: 49,
      align: 'start',
    });
  });
  it('handles an empty list', () => {
    expect(resolveInitialAnchor(null, 0)).toEqual({ index: 0, align: 'end' });
  });
});
  • Step 2: Run, verify FAIL

Run: pnpm --filter @chat-app/desktop exec vitest run src/lib/scrollController.test.ts Expected: FAIL — "Failed to resolve import './scrollController'".

  • Step 3: Implement
// apps/desktop/src/lib/scrollController.ts
// Pure, DOM-free scroll-decision logic for MessageList. Unit-tested so the
// tricky math is verified without a browser (jsdom has no layout).

export interface ScrollMetrics {
  scrollTop: number;
  scrollHeight: number;
  clientHeight: number;
}

/** Distance from the bottom edge is within `threshold` px. */
export function isNearBottom(m: ScrollMetrics, threshold: number): boolean {
  return m.scrollHeight - (m.scrollTop + m.clientHeight) <= threshold;
}

/** Scroll offset is within `threshold` px of the top. */
export function isNearTop(m: ScrollMetrics, threshold: number): boolean {
  return m.scrollTop <= threshold;
}

export interface SavedPosition {
  topmostIndex: number;
  stickToBottom: boolean;
}

export interface Anchor {
  index: number;
  align: 'start' | 'end';
}

/**
 * Where a freshly-opened chat should start.
 * - default / "left at bottom" → last row, aligned to the viewport bottom.
 * - "left scrolled up" → the saved top-most row, aligned to the viewport top
 *   (clamped in case the cached row count shrank).
 */
export function resolveInitialAnchor(saved: SavedPosition | null, rowCount: number): Anchor {
  if (rowCount <= 0) return { index: 0, align: 'end' };
  if (saved && !saved.stickToBottom) {
    const index = Math.max(0, Math.min(saved.topmostIndex, rowCount - 1));
    return { index, align: 'start' };
  }
  return { index: rowCount - 1, align: 'end' };
}
  • Step 4: Run, verify PASS

Run: pnpm --filter @chat-app/desktop exec vitest run src/lib/scrollController.test.ts Expected: PASS (11 tests).

  • Step 5: Commit
git add apps/desktop/src/lib/scrollController.ts apps/desktop/src/lib/scrollController.test.ts
git commit -m "feat(desktop): pure scroll-decision logic for new message list"

Task 3: Reveal-gate flag on useMessageReactions

Files:

  • Modify: apps/desktop/src/lib/useMessageReactions.ts

Reactions are the main post-paint height changer. The list reveal waits on their first fetch, so add a ready flag that is true once reactions for the current message-id set have been fetched (or there are no messages).

  • Step 1: Add ready to the result type + state

In UseMessageReactionsResult add:

  ready: boolean;

After const [rows, setRows] = useState<MessageReaction[]>([]); add:

  const [readyKey, setReadyKey] = useState<string | null>(null);
  • Step 2: Set the key after each fetch

Replace the refresh callback body so both branches stamp readyKey:

  const refresh = useCallback(async () => {
    if (messageIds.length === 0) {
      setRows([]);
      setReadyKey(idsKey);
      return;
    }
    try {
      const data = await listReactionsForMessages(supabase, messageIds);
      setRows(data);
    } catch (err: unknown) {
      console.error('listReactionsForMessages failed', err);
    } finally {
      setReadyKey(idsKey);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [idsKey]);
  • Step 3: Derive + return ready

Before the return:

  const ready = readyKey === idsKey;

And add ready to the returned object:

  return { byMessage, toggle, voteExclusive, ready };
  • Step 4: Typecheck

Run: pnpm --filter @chat-app/desktop typecheck Expected: PASS (no output).

  • Step 5: Commit
git add apps/desktop/src/lib/useMessageReactions.ts
git commit -m "feat(desktop): expose reactions reveal-gate flag (ready)"

Task 4: Export the shared row type from ConversationPage

Files:

  • Modify: apps/desktop/src/pages/ConversationPage.tsx

MessageList needs the row union. Export it from ConversationPage (smallest change; the type already lives there).

  • Step 1: Export the type

Change the type VirtuosoRow = … declaration (near the top of the file) to:

export type VirtuosoRow =
  | { kind: 'loader'; key: string }
  | { kind: 'message'; key: string; message: DecryptedMessage; idx: number }
  | { kind: 'pending'; key: string; item: OutboxItem };
  • Step 2: Typecheck

Run: pnpm --filter @chat-app/desktop typecheck Expected: PASS.

  • Step 3: Commit
git add apps/desktop/src/pages/ConversationPage.tsx
git commit -m "refactor(desktop): export VirtuosoRow type for MessageList"

Task 5: The MessageList component

Files:

  • Create: apps/desktop/src/components/MessageList.tsx

This is the integration unit. It is verified by typecheck here and visually in dev in Task 7 (jsdom can't layout-test it). The TanStack specifics (scrollToIndex timing, prepend offset) are the parts to refine during dev iteration.

  • Step 1: Implement
// apps/desktop/src/components/MessageList.tsx
import { useVirtualizer } from '@tanstack/react-virtual';
import {
  forwardRef,
  useCallback as _unused, // placeholder removed below
} from 'react';

NOTE for the implementer: write the file with the imports below (the line above is illustrative only — do not keep it). Full file:

import { useVirtualizer } from '@tanstack/react-virtual';
import {
  forwardRef,
  useCallback,
  useImperativeHandle,
  useLayoutEffect,
  useRef,
  useState,
  type ReactNode,
} from 'react';

import { isNearBottom, isNearTop, resolveInitialAnchor, type Anchor } from '../lib/scrollController';
import type { VirtuosoRow } from '../pages/ConversationPage';

export interface MessageListHandle {
  scrollToBottom(behavior?: ScrollBehavior): void;
  scrollToRow(index: number, align?: 'center' | 'end', behavior?: ScrollBehavior): void;
}

export interface MessageListProps {
  rows: VirtuosoRow[];
  renderRow: (index: number, row: VirtuosoRow) => ReactNode;
  computeKey: (row: VirtuosoRow) => string;
  initialAnchor: { type: 'bottom' } | { type: 'row'; index: number };
  /** Reveal gate — list stays hidden behind a spinner until true (no flicker). */
  ready: boolean;
  estimateRowHeight?: number;
  atBottomThreshold?: number;
  onReachTop?: () => void;
  onAtBottomChange?: (atBottom: boolean) => void;
  onTopRowChange?: (topIndex: number) => void;
}

export const MessageList = forwardRef<MessageListHandle, MessageListProps>(function MessageList(
  {
    rows,
    renderRow,
    computeKey,
    initialAnchor,
    ready,
    estimateRowHeight = 64,
    atBottomThreshold = 64,
    onReachTop,
    onAtBottomChange,
    onTopRowChange,
  },
  ref,
) {
  const scrollElRef = useRef<HTMLDivElement>(null);
  const [revealed, setRevealed] = useState(false);
  const atBottomRef = useRef(true);
  // Load-older preservation: remember scrollHeight + first key across renders.
  const prevFirstKeyRef = useRef<string | null>(null);
  const prevScrollHeightRef = useRef(0);

  const virtualizer = useVirtualizer({
    count: rows.length,
    getScrollElement: () => scrollElRef.current,
    estimateSize: () => estimateRowHeight,
    overscan: 8,
    getItemKey: (index) => computeKey(rows[index]!),
  });

  const metrics = () => {
    const el = scrollElRef.current;
    return el
      ? { scrollTop: el.scrollTop, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight }
      : { scrollTop: 0, scrollHeight: 0, clientHeight: 0 };
  };

  const applyAnchor = useCallback(
    (anchor: Anchor) => {
      virtualizer.scrollToIndex(anchor.index, { align: anchor.align });
      // Re-apply on the next frame: dynamic measurement settles after the first
      // paint, so a single scrollToIndex can land a few px off. The list is still
      // hidden here, so this correction is never visible.
      requestAnimationFrame(() => virtualizer.scrollToIndex(anchor.index, { align: anchor.align }));
    },
    [virtualizer],
  );

  // Deferred reveal: when ready, anchor (before paint) then reveal.
  useLayoutEffect(() => {
    if (!ready || revealed || rows.length === 0) return;
    const anchor: Anchor =
      initialAnchor.type === 'bottom'
        ? { index: rows.length - 1, align: 'end' }
        : { index: Math.max(0, Math.min(initialAnchor.index, rows.length - 1)), align: 'start' };
    applyAnchor(anchor);
    atBottomRef.current = initialAnchor.type === 'bottom';
    onAtBottomChange?.(atBottomRef.current);
    requestAnimationFrame(() => setRevealed(true));
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [ready, rows.length]);

  // Stick-to-bottom: when content grows and we were at the bottom, re-pin.
  useLayoutEffect(() => {
    if (!revealed) return;
    if (atBottomRef.current) {
      virtualizer.scrollToIndex(rows.length - 1, { align: 'end' });
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [rows.length, virtualizer.getTotalSize()]);

  // Load-older preservation: if rows were prepended (first key changed and count
  // grew), restore scrollTop by the height delta so the viewport stays put.
  useLayoutEffect(() => {
    const firstKey = rows.length > 0 ? computeKey(rows[0]!) : null;
    const el = scrollElRef.current;
    if (el && revealed && prevFirstKeyRef.current && firstKey !== prevFirstKeyRef.current) {
      const delta = el.scrollHeight - prevScrollHeightRef.current;
      if (delta > 0 && el.scrollTop < atBottomThreshold) {
        el.scrollTop += delta;
      }
    }
    prevFirstKeyRef.current = firstKey;
    prevScrollHeightRef.current = el?.scrollHeight ?? 0;
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [rows]);

  const handleScroll = useCallback(() => {
    const m = metrics();
    const atBottom = isNearBottom(m, atBottomThreshold);
    if (atBottom !== atBottomRef.current) {
      atBottomRef.current = atBottom;
      onAtBottomChange?.(atBottom);
    }
    if (isNearTop(m, atBottomThreshold * 4)) onReachTop?.();
    const first = virtualizer.getVirtualItems()[0];
    if (first) onTopRowChange?.(first.index);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [atBottomThreshold, onAtBottomChange, onReachTop, onTopRowChange, virtualizer]);

  useImperativeHandle(
    ref,
    () => ({
      scrollToBottom: () => {
        atBottomRef.current = true;
        virtualizer.scrollToIndex(rows.length - 1, { align: 'end' });
      },
      scrollToRow: (index, align = 'center') => {
        virtualizer.scrollToIndex(index, { align });
      },
    }),
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [virtualizer, rows.length],
  );

  const items = virtualizer.getVirtualItems();

  return (
    <div
      ref={scrollElRef}
      onScroll={handleScroll}
      className="h-full overflow-y-auto"
      style={{ opacity: revealed ? 1 : 0, position: 'relative' }}
    >
      <div style={{ height: virtualizer.getTotalSize(), position: 'relative', width: '100%' }}>
        {items.map((vi) => (
          <div
            key={vi.key}
            data-index={vi.index}
            ref={virtualizer.measureElement}
            style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${vi.start}px)` }}
          >
            {renderRow(vi.index, rows[vi.index]!)}
          </div>
        ))}
      </div>
      {/* 12px bottom breathing space (matches the old Footer). */}
      <div style={{ height: 12 }} />
    </div>
  );
});

Implementer note: delete the illustrative first import snippet; keep only the full file. The requestAnimationFrame timing in applyAnchor/reveal is the most likely spot to refine during dev (Task 7).

  • Step 2: Typecheck

Run: pnpm --filter @chat-app/desktop typecheck Expected: PASS.

  • Step 3: Commit
git add apps/desktop/src/components/MessageList.tsx
git commit -m "feat(desktop): TanStack-Virtual MessageList with deferred reveal"

Task 6: Wire MessageList into ConversationPage

Files:

  • Modify: apps/desktop/src/pages/ConversationPage.tsx

  • Step 1: Imports + reveal gate

Replace the react-virtuoso import with:

import { MessageList, type MessageListHandle } from '../components/MessageList';

Capture the reactions ready flag — change the useMessageReactions destructure to also pull ready:

  const {
    byMessage: reactionsByMessage,
    toggle: toggleReaction,
    voteExclusive: votePoll,
    ready: reactionsReady,
  } = useMessageReactions(messageIds, session?.user.id);

Add a reveal gate with a 300ms max-timeout fallback (so empty/slow reactions never hang):

  const [revealTimedOut, setRevealTimedOut] = useState(false);
  useEffect(() => {
    if (!id || loading || messages.length === 0) return;
    const t = window.setTimeout(() => setRevealTimedOut(true), 300);
    return () => window.clearTimeout(t);
  }, [id, loading, messages.length]);
  const listReady = !loading && messages.length > 0 && (reactionsReady || revealTimedOut);
  • Step 2: Replace the virtuosoRef type + handle

Change:

  const virtuosoRef = useRef<VirtuosoHandle>(null);

to:

  const listRef = useRef<MessageListHandle>(null);

Replace every virtuosoRef.current?.scrollToIndex({ index: 'LAST', align: 'end', behavior }) call (in jumpToBottom, the pending-snap effect, snapToBottom) with:

  listRef.current?.scrollToBottom('auto');

Replace the jumpToMessage scroll (virtuosoRef.current?.scrollToIndex({ index: rowIndex, align: 'center', behavior: 'smooth' })) with:

  listRef.current?.scrollToRow(rowIndex, 'center', 'smooth');
  • Step 3: Compute initialAnchor

Replace the initialTopMostIndex useMemo (the IndexLocationWithAlign one from the earlier hotfix) with:

  const initialAnchor = useMemo<{ type: 'bottom' } | { type: 'row'; index: number }>(() => {
    const saved = savedPositionRef.current;
    if (saved && !saved.stickToBottom) return { type: 'row', index: saved.topmostIndex };
    return { type: 'bottom' };
  }, []);

Remove the now-unused IndexLocationWithAlign import.

  • Step 4: Swap the JSX

Replace the entire <Virtuoso … /> element with:

          <MessageList
            ref={listRef}
            rows={virtuosoRows}
            ready={listReady}
            computeKey={(row) => row.key}
            initialAnchor={initialAnchor}
            atBottomThreshold={250}
            onReachTop={handleStartReached}
            onAtBottomChange={handleAtBottomStateChange}
            onTopRowChange={(topIndex) => handleRangeChanged({ startIndex: topIndex, endIndex: topIndex })}
            renderRow={(_index, row) => {
              // ...exact same body the old `itemContent` had (loader / pending /
              // message branches) — move it verbatim from the deleted <Virtuoso>.
              return renderConversationRow(row);
            }}
          />

Move the old itemContent body into a local renderConversationRow(row) helper (or inline it) so the message/loader/pending branches are unchanged. handleRangeChanged already accepts { startIndex, endIndex }.

  • Step 5: Typecheck + unit tests

Run: pnpm --filter @chat-app/desktop typecheck && pnpm --filter @chat-app/desktop test Expected: PASS.

  • Step 6: Commit
git add apps/desktop/src/pages/ConversationPage.tsx
git commit -m "feat(desktop): use MessageList in ConversationPage (replace react-virtuoso)"

Task 7: Dev verification (with the user) — iterate until smooth

Files: none (runtime verification)

  • Step 1: Run the dev build

User runs (in chat-app/): pnpm desktop:dev

  • Step 2: Verify behaviors live

Switch between several chats repeatedly and confirm, using the SCROLL_DEBUG console output where helpful:

  • No jump and no multi-flicker on chat switch (opens cleanly at the bottom / saved row).

  • New message while at bottom auto-scrolls; while scrolled up shows the pill.

  • Unread divider present without a later shift.

  • Scroll to top loads older without the viewport jumping.

  • Jump-to-message (reply tap / pinned / search) scrolls to the target.

  • Sent/pending message snaps to bottom.

  • Step 3: Refine

If any behavior is off, adjust MessageList.tsx (most likely the applyAnchor/reveal requestAnimationFrame timing or the stick-to-bottom effect) and re-verify. Commit each refinement:

git commit -am "fix(desktop): refine MessageList <specific behavior>"

Task 8: Cleanup + release 0.21.6

Files:

  • Modify: apps/desktop/src/pages/ConversationPage.tsx (remove instrumentation)

  • Modify: apps/desktop/package.json (remove react-virtuoso)

  • Step 1: Remove the SCROLL_DEBUG instrumentation

Delete the SCROLL_DEBUG/dbgNow/dbgLog block, the render-logger useEffect, and the dbgLog(...) calls inside handleRangeChanged / handleAtBottomStateChange.

  • Step 2: Remove the old dependency

Run: pnpm --filter @chat-app/desktop remove react-virtuoso Then confirm no references remain: Run: grep -rn "react-virtuoso\|Virtuoso\b" apps/desktop/src || echo "clean" Expected: clean.

  • Step 3: Typecheck + tests + commit

Run: pnpm --filter @chat-app/desktop typecheck && pnpm --filter @chat-app/desktop test Expected: PASS.

git add -A
git commit -m "chore(desktop): drop react-virtuoso + scroll debug instrumentation"
  • Step 4: Release

Run (from chat-app/, tree clean): node scripts/release.mjs 0.21.6 "- Nachrichtenliste komplett überarbeitet: Chat-Wechsel öffnet jetzt ruckel- und flackerfrei direkt unten\n- Älteren Verlauf laden springt nicht mehr" Then verify latest.yml shows 0.21.6 on update.netralax.de and update.netralax.cloud.


Self-Review

  • Spec coverage: deferred reveal (Tasks 3,5,6) ✓; TanStack virtualization (Tasks 1,5) ✓; isolation into MessageList + scrollController (Tasks 2,5) ✓; stick-to-bottom (Task 5) ✓; load-older preservation (Task 5) ✓; preserved behaviors incl. jump-to-message/pill/divider/pending (Task 6) ✓; pure-logic unit tests (Task 2) ✓; dev verification (Task 7) ✓; cleanup + release (Task 8) ✓.
  • Placeholders: the only prose-only steps are the deliberately runtime Task 7 (no code possible) and the "move itemContent verbatim" in Task 6 Step 4 (the body is large and unchanged — copying it verbatim, not rewriting). The illustrative throwaway import in Task 5 Step 1 is explicitly flagged for deletion.
  • Type consistency: MessageListHandle.scrollToBottom/scrollToRow, VirtuosoRow, Anchor, ScrollMetrics, resolveInitialAnchor signatures are consistent across Tasks 2/5/6. ready flag added in Task 3 is consumed in Task 6.