Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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. ExportsMessageList,MessageListHandle,VirtuosoRowis imported from ConversationPage's shared type (moved in Task 5). - Modify:
apps/desktop/src/lib/useMessageReactions.ts— addreadyflag for the reveal gate. - Modify:
apps/desktop/src/pages/ConversationPage.tsx— export the row type, swap<Virtuoso>for<MessageList>, drive the handle, passready. - Modify:
apps/desktop/package.json— add@tanstack/react-virtual; removereact-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
readyto 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
importsnippet; keep only the full file. TherequestAnimationFrametiming inapplyAnchor/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
virtuosoReftype + 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(removereact-virtuoso) -
Step 1: Remove the
SCROLL_DEBUGinstrumentation
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,resolveInitialAnchorsignatures are consistent across Tasks 2/5/6.readyflag added in Task 3 is consumed in Task 6.