docs(plan): message-list / scroll rewrite implementation plan

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
byGalax
2026-06-02 20:22:28 +02:00
parent 255dbdc712
commit e822f6f58f
@@ -0,0 +1,673 @@
# 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/`):
```bash
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**
```bash
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**
```ts
// 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**
```ts
// 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**
```bash
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:
```ts
ready: boolean;
```
After `const [rows, setRows] = useState<MessageReaction[]>([]);` add:
```ts
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`:
```ts
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`:
```ts
const ready = readyKey === idsKey;
```
And add `ready` to the returned object:
```ts
return { byMessage, toggle, voteExclusive, ready };
```
- [ ] **Step 4: Typecheck**
Run: `pnpm --filter @chat-app/desktop typecheck`
Expected: PASS (no output).
- [ ] **Step 5: Commit**
```bash
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:
```ts
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**
```bash
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**
```tsx
// 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:
```tsx
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**
```bash
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:
```ts
import { MessageList, type MessageListHandle } from '../components/MessageList';
```
Capture the reactions `ready` flag — change the `useMessageReactions` destructure to also pull `ready`:
```ts
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):
```ts
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:
```ts
const virtuosoRef = useRef<VirtuosoHandle>(null);
```
to:
```ts
const listRef = useRef<MessageListHandle>(null);
```
Replace every `virtuosoRef.current?.scrollToIndex({ index: 'LAST', align: 'end', behavior })`
call (in `jumpToBottom`, the pending-snap effect, `snapToBottom`) with:
```ts
listRef.current?.scrollToBottom('auto');
```
Replace the `jumpToMessage` scroll (`virtuosoRef.current?.scrollToIndex({ index: rowIndex, align: 'center', behavior: 'smooth' })`) with:
```ts
listRef.current?.scrollToRow(rowIndex, 'center', 'smooth');
```
- [ ] **Step 3: Compute `initialAnchor`**
Replace the `initialTopMostIndex` `useMemo` (the `IndexLocationWithAlign` one from the
earlier hotfix) with:
```ts
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:
```tsx
<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**
```bash
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:
```bash
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.
```bash
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.