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:
@@ -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.
|
||||
Reference in New Issue
Block a user