Files
ChatApp/docs/superpowers/plans/2026-05-17-chat-switch-flicker.md

26 KiB
Raw Permalink Blame History

Chat-Switch Flicker — Fix 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: Eliminate the visible flicker / scroll-jump when switching between conversations so the transition matches Discord's "instant content swap" feel.

Architecture: Two complementary fixes that, together, kill seven independent state-bleed root causes:

  1. Force a fresh ConversationPage instance per :id route param via a wrapper component that reads useParams and passes key={id}. Today the same React component instance handles every conversation because React Router does not remount the route element when only a param changes — that is what lets chat A's state (messages, scroll position, composer drafts, Virtuoso scroll cache) bleed into chat B's first render.
  2. Add a module-scoped in-memory cache (Map<convId, DecryptedMessage[]>) in useConversationMessages so the freshly-mounted hook starts with prior data synchronously on the first render. SQLite cache still hydrates async for never-seen chats. Net effect: instant content for any previously-visited chat, no spinner-flash.

Tech Stack: React 18, React Router v6 (v7_startTransition opt-in), react-virtuoso, better-sqlite3 (Electron main-process bridge via window.electronAPI.sql*), Vitest.


Root-Cause Findings (Phase 1 evidence)

# Symptom File:line Why it happens
RC1 Ghost messages of previous chat for 50300 ms apps/desktop/src/lib/useConversationMessages.ts:85, 220-243, 249-263 useState<State>({messages: [], loading: true}) only runs on first mount. On conversationId change, the state still holds chat A's messages; refresh() skips loading: true because prev.messages.length > 0; the cache effect also bails (if (prev.messages.length > 0) return prev). Chat A's data renders under chat B's id until the network round-trip finishes.
RC2 Scroll jumps to wrong row on switch apps/desktop/src/pages/ConversationPage.tsx:1023, 1034, 470-481 Virtuoso instance is preserved across switches (parent isn't remounted). initialTopMostItemIndex is only honoured on Virtuoso's first mount, so chat A's last scroll position is what Virtuoso tries to keep when chat B's rows replace chat A's.
RC3 Saved positions corrupted across switches apps/desktop/src/pages/ConversationPage.tsx:705-716 While chat A's rows are still rendered under chat B's id (RC1 window), rangeChanged fires for chat A's visible range and writes the index into scrollPositions[chatBid]. Next time you re-enter chat B, it restores chat A's row index.
RC4 Stale composer / reply / search / unread state for one render apps/desktop/src/pages/ConversationPage.tsx:307-320 useEffect([id]) resets replyTo, displayCount, firstUnreadId, etc. — but effects fire after the first render of the new id. The first paint of chat B briefly shows chat A's reply preview and displayCount.
RC5 Phantom scroll-to-bottom after switching apps/desktop/src/pages/ConversationPage.tsx:736-746 lastPendingCountRef is never reset per-chat. Switching from a chat with 0 pending to a chat with N pending triggers pending.length > lastPendingCountRef.currentscrollToIndex(LAST) even though that pending state was always there.
RC6 newMessagesWhileAway briefly off apps/desktop/src/pages/ConversationPage.tsx:335-346 previousMessageIdsRef.current contains chat A's ids on the first render of chat B → the diff classifies every chat B message as "new while away" until the reset effect lands.
RC7 Cache load loses race with network refresh apps/desktop/src/lib/useConversationMessages.ts:249-263 Because state still holds chat A's messages, the cache-effect bails. Then refresh writes chat B's network result. Then the now-irrelevant loadCachedMessages(chatB) promise resolves and would no-op (length check still > 0 by then) — but the path is fragile and depends on timing.

All of RC1, RC3, RC4, RC5, RC6, RC7 are fixed in one stroke by Task 3 (key={id} remount). RC2 is fixed because Virtuoso unmounts with its parent and re-applies initialTopMostItemIndex on the fresh mount. The remaining flicker after a remount — the brief spinner before async cache hydration completes — is eliminated by Task 2 (in-memory cache, synchronous on first render).


File Structure

  • Create: apps/desktop/src/lib/messageMemoryCache.ts — small module-scoped Map plus get / set / has API. Lets us unit-test the cache logic without rendering the hook.
  • Create: apps/desktop/src/lib/messageMemoryCache.test.ts — vitest unit tests for the cache.
  • Modify: apps/desktop/src/lib/useConversationMessages.ts — initialize useState from messageMemoryCache, sync to it on every state change.
  • Modify: apps/desktop/src/App.tsx — add ConversationRoute wrapper that reads useParams and renders <ConversationPage key={id} />.
  • Modify: apps/desktop/src/pages/ConversationPage.tsx — delete the now-redundant useEffect([id]) reset block and update the scrollPositions doc comment.

Each task below is self-contained and can be committed independently.


Task 1: In-memory message cache helper

Files:

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

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

  • Step 1: Write the failing test

Create apps/desktop/src/lib/messageMemoryCache.test.ts:

import { afterEach, describe, expect, it } from 'vitest';
import type { DecryptedMessage } from '@chat-app/shared/chat';

import {
  __resetForTests,
  getCachedMessages,
  hasCachedMessages,
  setCachedMessages,
} from './messageMemoryCache';

function msg(id: string): DecryptedMessage {
  return {
    id,
    conversationId: 'conv-1',
    senderId: 'sender-1',
    senderDeviceId: null,
    replyToId: null,
    editedAt: null,
    deletedAt: null,
    createdAt: '2026-05-17T00:00:00Z',
    ciphertext: new Uint8Array(),
    nonce: new Uint8Array(),
    keyVersion: 1,
    plaintext: 'hi ' + id,
  };
}

describe('messageMemoryCache', () => {
  afterEach(() => {
    __resetForTests();
  });

  it('returns empty array when nothing is cached', () => {
    expect(getCachedMessages('unknown')).toEqual([]);
    expect(hasCachedMessages('unknown')).toBe(false);
  });

  it('stores and returns messages per conversation', () => {
    setCachedMessages('a', [msg('m1'), msg('m2')]);
    expect(getCachedMessages('a').map((m) => m.id)).toEqual(['m1', 'm2']);
    expect(hasCachedMessages('a')).toBe(true);
  });

  it('isolates conversations', () => {
    setCachedMessages('a', [msg('m1')]);
    setCachedMessages('b', [msg('m9')]);
    expect(getCachedMessages('a').map((m) => m.id)).toEqual(['m1']);
    expect(getCachedMessages('b').map((m) => m.id)).toEqual(['m9']);
  });

  it('overwrites prior cache when set again', () => {
    setCachedMessages('a', [msg('m1')]);
    setCachedMessages('a', [msg('m1'), msg('m2')]);
    expect(getCachedMessages('a').map((m) => m.id)).toEqual(['m1', 'm2']);
  });

  it('treats an explicit empty list as "cached"', () => {
    // A conversation that genuinely has zero messages should still be
    // flagged as cached so the hook skips the loading spinner on re-entry.
    setCachedMessages('a', []);
    expect(hasCachedMessages('a')).toBe(true);
    expect(getCachedMessages('a')).toEqual([]);
  });
});
  • Step 2: Run test to verify it fails

Run: pnpm --filter @chat-app/desktop test -- messageMemoryCache

Expected: FAIL — module ./messageMemoryCache does not exist.

  • Step 3: Implement the helper

Create apps/desktop/src/lib/messageMemoryCache.ts:

// In-memory cache of the most-recently-rendered messages for each
// conversation. Survives React component unmount/remount (used by
// `useConversationMessages` to initialize state synchronously when
// ConversationPage is remounted on chat switch). Session-scoped — lost
// on full app reload. The SQLite cache (`messageCache.ts`) is still the
// source of truth for cross-session persistence; this layer just shaves
// off the round-trip-to-disk spinner flash.
//
// Two-tier semantics:
//   * `hasCachedMessages(id)` returns true even for a known-empty chat
//     so the hook can suppress the loading spinner on re-entry.
//   * `getCachedMessages(id)` returns a defensive copy so callers can't
//     mutate the cached array.

import type { DecryptedMessage } from '@chat-app/shared/chat';

const cache = new Map<string, DecryptedMessage[]>();

export function getCachedMessages(conversationId: string): DecryptedMessage[] {
  const stored = cache.get(conversationId);
  return stored ? stored.slice() : [];
}

export function hasCachedMessages(conversationId: string): boolean {
  return cache.has(conversationId);
}

export function setCachedMessages(
  conversationId: string,
  messages: DecryptedMessage[],
): void {
  cache.set(conversationId, messages.slice());
}

export function __resetForTests(): void {
  cache.clear();
}
  • Step 4: Run tests to verify they pass

Run: pnpm --filter @chat-app/desktop test -- messageMemoryCache

Expected: PASS — all five test cases.

  • Step 5: Commit
git add apps/desktop/src/lib/messageMemoryCache.ts apps/desktop/src/lib/messageMemoryCache.test.ts
git commit -m "feat(chat-switch): in-memory message cache helper"

Task 2: Wire the memory cache into useConversationMessages

Files:

  • Modify: apps/desktop/src/lib/useConversationMessages.ts:85 (initial state)
  • Modify: apps/desktop/src/lib/useConversationMessages.ts:226-243 (refresh) — sync to memory cache
  • Modify: apps/desktop/src/lib/useConversationMessages.ts:249-263 (SQLite cache effect) — skip on memory-hit, sync after hydrate
  • Modify: apps/desktop/src/lib/useConversationMessages.ts:339, 350, 419, 433, 553 (realtime + optimistic-send paths) — keep cache in sync with state

The hook keeps using setState everywhere — we just mirror writes into the memory cache and read from it on first render. No behavioural change for other code paths.

  • Step 1: Import the helper and initialize state from cache

In apps/desktop/src/lib/useConversationMessages.ts, add the import near the other local-lib imports (around line 36):

import {
  getCachedMessages,
  hasCachedMessages,
  setCachedMessages,
} from './messageMemoryCache';

Replace the initial useState at line 85:

const [state, setState] = useState<State>({ messages: [], loading: true, error: null });

with:

// Initialize from the in-memory cache so a previously-viewed chat shows
// content on the very first render after the parent remounts on `:id`
// change. `loading` stays true ONLY for never-seen conversations (cache
// miss) so the spinner doesn't flash on every chat switch.
const [state, setState] = useState<State>(() => {
  if (conversationId && hasCachedMessages(conversationId)) {
    return {
      messages: getCachedMessages(conversationId),
      loading: false,
      error: null,
    };
  }
  return { messages: [], loading: true, error: null };
});
  • Step 2: Mirror successful refreshes into the memory cache

In the refresh function (around line 229-235), replace:

const rows = await fetchConversationMessages(supabase, conversationId);
const decrypted = await decryptBatch(rows);
setState({ messages: decrypted, loading: false, error: null });
// Persist the fresh batch to the local cache so next conversation
// switch / app start can hydrate instantly. Fire-and-forget — cache
// write failure is never user-visible.
void persistMessages(conversationId, decrypted);

with:

const rows = await fetchConversationMessages(supabase, conversationId);
const decrypted = await decryptBatch(rows);
setState({ messages: decrypted, loading: false, error: null });
setCachedMessages(conversationId, decrypted);
// Persist the fresh batch to the local cache so next conversation
// switch / app start can hydrate instantly. Fire-and-forget — cache
// write failure is never user-visible.
void persistMessages(conversationId, decrypted);
  • Step 3: Skip SQLite hydration on memory-cache hit, mirror cold-miss into memory

Replace the cache-hydration effect (around line 249-263):

useEffect(() => {
  if (!conversationId) return;
  let cancelled = false;
  void loadCachedMessages(conversationId).then((cached) => {
    if (cancelled || cached.length === 0) return;
    setState((prev) => {
      // Don't clobber a fresh server response that already landed.
      if (prev.messages.length > 0) return prev;
      return { messages: cached, loading: false, error: null };
    });
  });
  return () => {
    cancelled = true;
  };
}, [conversationId]);

with:

useEffect(() => {
  if (!conversationId) return;
  // Memory cache already populated state synchronously — skip the disk
  // round-trip entirely. The canonical data lands shortly via refresh();
  // the SQLite cache only matters for cold-start hydration.
  if (hasCachedMessages(conversationId)) return;
  let cancelled = false;
  void loadCachedMessages(conversationId).then((cached) => {
    if (cancelled || cached.length === 0) return;
    setState((prev) => {
      // Don't clobber a fresh server response that already landed.
      if (prev.messages.length > 0) return prev;
      setCachedMessages(conversationId, cached);
      return { messages: cached, loading: false, error: null };
    });
  });
  return () => {
    cancelled = true;
  };
}, [conversationId]);
  • Step 4: Mirror realtime INSERT into the memory cache

In handleInsert (around line 339), replace:

setState((prev) => {
  if (prev.messages.some((m) => m.id === decrypted!.id)) return prev;
  return { ...prev, messages: [...prev.messages, decrypted!] };
});

with:

setState((prev) => {
  if (prev.messages.some((m) => m.id === decrypted!.id)) return prev;
  const next = [...prev.messages, decrypted!];
  if (conversationId) setCachedMessages(conversationId, next);
  return { ...prev, messages: next };
});
  • Step 5: Mirror realtime UPDATE (both partial and re-decrypt paths)

In handleUpdate partial-update path (around line 350-362), replace:

setState((prev) => {
  const idx = prev.messages.findIndex((m) => m.id === partial.id);
  if (idx === -1) return prev;
  const existing = prev.messages[idx];
  if (!existing) return prev;
  const next = [...prev.messages];
  next[idx] = {
    ...existing,
    editedAt: partial.editedAt,
    deletedAt: partial.deletedAt,
  };
  return { ...prev, messages: next };
});

with:

setState((prev) => {
  const idx = prev.messages.findIndex((m) => m.id === partial.id);
  if (idx === -1) return prev;
  const existing = prev.messages[idx];
  if (!existing) return prev;
  const next = [...prev.messages];
  next[idx] = {
    ...existing,
    editedAt: partial.editedAt,
    deletedAt: partial.deletedAt,
  };
  if (conversationId) setCachedMessages(conversationId, next);
  return { ...prev, messages: next };
});

In the same function's re-decrypt path (around line 419-425), replace:

setState((prev) => {
  const idx = prev.messages.findIndex((m) => m.id === decrypted!.id);
  if (idx === -1) return prev;
  const next = [...prev.messages];
  next[idx] = decrypted!;
  return { ...prev, messages: next };
});

with:

setState((prev) => {
  const idx = prev.messages.findIndex((m) => m.id === decrypted!.id);
  if (idx === -1) return prev;
  const next = [...prev.messages];
  next[idx] = decrypted!;
  if (conversationId) setCachedMessages(conversationId, next);
  return { ...prev, messages: next };
});
  • Step 6: Mirror realtime DELETE

Replace handleDelete (around line 431-438):

const handleDelete = useCallback((row: Record<string, unknown>) => {
  const id = String(row.id);
  setState((prev) => ({
    ...prev,
    messages: prev.messages.filter((m) => m.id !== id),
  }));
  void deleteCachedMessage(id);
}, []);

with:

const handleDelete = useCallback(
  (row: Record<string, unknown>) => {
    const id = String(row.id);
    setState((prev) => {
      const next = prev.messages.filter((m) => m.id !== id);
      if (conversationId) setCachedMessages(conversationId, next);
      return { ...prev, messages: next };
    });
    void deleteCachedMessage(id);
  },
  [conversationId],
);
  • Step 7: Mirror optimistic send (sendText)

In sendText (around line 553-562), replace:

setState((prev) => {
  if (prev.messages.some((m) => m.id === msg.id)) return prev;
  return {
    ...prev,
    messages: [
      ...prev.messages,
      { ...msg, plaintext: text } as DecryptedMessage,
    ],
  };
});

with:

setState((prev) => {
  if (prev.messages.some((m) => m.id === msg.id)) return prev;
  const next = [
    ...prev.messages,
    { ...msg, plaintext: text } as DecryptedMessage,
  ];
  setCachedMessages(convId, next);
  return { ...prev, messages: next };
});

(convId is already a parameter of sendText — no extra capture needed.)

  • Step 8: Run all desktop tests to verify nothing regressed

Run: pnpm --filter @chat-app/desktop test

Expected: PASS — existing tests still green, the new messageMemoryCache tests still pass.

  • Step 9: Commit
git add apps/desktop/src/lib/useConversationMessages.ts
git commit -m "feat(chat-switch): hydrate useConversationMessages from in-memory cache"

Task 3: Force fresh ConversationPage mount per :id

Files:

  • Modify: apps/desktop/src/App.tsx:2 (import useParams)

  • Modify: apps/desktop/src/App.tsx:55-61 area (add wrapper)

  • Modify: apps/desktop/src/App.tsx:113-120 (the :id route)

  • Step 1: Add useParams to the router import

Change line 2:

import { HashRouter, Navigate, Outlet, Route, Routes } from 'react-router-dom';

to:

import { HashRouter, Navigate, Outlet, Route, Routes, useParams } from 'react-router-dom';
  • Step 2: Add the wrapper component

Below the RouteBoundary function (around line 61), add:

// Forces a fresh `ConversationPage` instance per `:id` so React unmounts
// the previous conversation entirely on switch. Without this, the same
// component instance handles every conversation, which leaks state
// between chats (messages, scroll position, composer drafts) for one
// render frame and gives the "flicker" we're trying to remove.
// `useConversationMessages` re-hydrates from `messageMemoryCache` on the
// fresh mount so previously-visited chats still render instantly.
function ConversationRoute() {
  const { id } = useParams<{ id: string }>();
  return <ConversationPage key={id ?? '__no_id__'} />;
}
  • Step 3: Use the wrapper in the route definition

Replace lines 113-120:

<Route
  path=":id"
  element={
    <ErrorBoundary scope="conversation">
      <ConversationPage />
    </ErrorBoundary>
  }
/>

with:

<Route
  path=":id"
  element={
    <ErrorBoundary scope="conversation">
      <ConversationRoute />
    </ErrorBoundary>
  }
/>
  • Step 4: Typecheck

Run: pnpm --filter @chat-app/desktop typecheck

Expected: PASS — no type errors.

  • Step 5: Manual smoke test in dev

Run: pnpm desktop:dev

In the app:

  1. Open two conversations with cached messages.
  2. Toggle between them rapidly (5+ switches).
  3. Verify: no "ghost" of the previous chat's last message ever appears, and the spinner does not flash on switch.
  4. Scroll chat A up by ~10 messages, switch to B, switch back to A. The Virtuoso list lands at the same scroll row, not the bottom and not at row 0.
  • Step 6: Commit
git add apps/desktop/src/App.tsx
git commit -m "fix(chat-switch): remount ConversationPage per conversation id"

Task 4: Drop redundant id-change reset effect & update doc comment

Because the parent is remounted per id (Task 3), every state in ConversationPage is already fresh on chat switch. The manual reset effect and related comments are now misleading dead weight.

Files:

  • Modify: apps/desktop/src/pages/ConversationPage.tsx:97-110 — update scrollPositions doc comment

  • Modify: apps/desktop/src/pages/ConversationPage.tsx:307-320 — delete the reset effect

  • Step 1: Update the scrollPositions doc comment

Replace lines 97-110:

// Per-conversation scroll memory. Module-scoped so it survives re-mounts
// of ConversationPage when the route param (`id`) changes — switching
// chats unmounts/remounts the page in our router setup. Session-only
// (lost on reload, like Discord). The `stickToBottom` flag is preserved
// alongside the topmost-visible row index so a chat the user left at the
// bottom keeps auto-following new messages when they return; a chat
// scrolled up returns to roughly the same row the user was reading.
//
// We track the topmost-visible row index rather than a pixel `scrollTop`
// because `react-virtuoso` virtualizes the list — the underlying scroll
// element's pixel offset depends on dynamically-measured row heights and
// is not stable across re-mounts. Using a row index restores the user's
// reading position even if some rows above re-render at different heights.
const scrollPositions = new Map<string, { topmostIndex: number; stickToBottom: boolean }>();

with:

// Per-conversation scroll memory. Module-scoped so it survives the
// per-id remount of ConversationPage (see `ConversationRoute` in
// App.tsx). Session-only (lost on reload, like Discord). The
// `stickToBottom` flag is preserved alongside the topmost-visible row
// index so a chat the user left at the bottom keeps auto-following new
// messages when they return; a chat scrolled up returns to roughly the
// same row the user was reading.
//
// We track the topmost-visible row index rather than a pixel `scrollTop`
// because `react-virtuoso` virtualizes the list — the underlying scroll
// element's pixel offset depends on dynamically-measured row heights and
// is not stable across remounts. Using a row index restores the user's
// reading position even if some rows above re-render at different heights.
const scrollPositions = new Map<string, { topmostIndex: number; stickToBottom: boolean }>();
  • Step 2: Delete the manual reset effect

Delete lines 307-320 entirely (the effect that resets replyTo, forwardTarget, searchOpen, mediaDrawerOpen, pollDialogOpen, searchQuery, displayCount, firstUnreadId, firstUnreadJumpDismissed, newMessagesWhileAway, previousMessageIdsRef, firstUnreadComputedRef):

useEffect(() => {
  setReplyTo(null);
  setForwardTarget(null);
  setSearchOpen(false);
  setMediaDrawerOpen(false);
  setPollDialogOpen(false);
  setSearchQuery('');
  setDisplayCount(150);
  setFirstUnreadId(null);
  setFirstUnreadJumpDismissed(false);
  setNewMessagesWhileAway(0);
  previousMessageIdsRef.current = new Set();
  firstUnreadComputedRef.current = false;
}, [id]);
  • Step 3: Typecheck + tests

Run in parallel:

pnpm --filter @chat-app/desktop typecheck
pnpm --filter @chat-app/desktop test

Expected: both PASS.

  • Step 4: Commit
git add apps/desktop/src/pages/ConversationPage.tsx
git commit -m "refactor(chat-switch): drop redundant id-change reset effect"

Task 5: Final QA in dev mode

Verification only — no code changes, no commit.

  • Step 1: Start dev

Run: pnpm desktop:dev

  • Step 2: Confirm each fix landed

Switch repeatedly between three chats (A, B, C). All of the following must hold:

Behaviour Pass criteria
Ghost messages Never see chat A's messages under chat B's header.
Spinner flash First visit to a chat = spinner OK. Subsequent visits = no spinner.
Scroll restore Chat A scrolled up 10 rows → switch to B → back to A → lands at row 10 ±1.
Composer state Type "draft" in chat A composer → switch to B → composer is empty (drafts intentionally don't persist).
Reply preview Click reply on chat A → switch to B → no reply preview in B.
Pending bubbles Send while offline → bubble shows in correct chat → switch away and back → bubble still in same chat.
Realtime updates Send a message in chat A from another device → it lands in chat A list → switch to B → switch back to A → message is still there (verifies memory cache stays in sync with realtime inserts).
  • Step 3: If any check fails

Open superpowers:systematic-debugging and form a single new hypothesis per failing check. Do NOT layer fixes — return to Phase 1, gather evidence, then patch.


Self-Review (post-write checklist)

Spec coverage: Each RC1RC7 is addressed:

  • RC1 (state bleed) → Task 3 (key-based remount) + Task 2 (memory cache so the fresh mount is instant).
  • RC2 (Virtuoso scroll) → Task 3 (Virtuoso unmounts with parent, initialTopMostItemIndex re-applied on fresh mount).
  • RC3 (scrollPositions corruption) → Task 3 (no more cross-chat render under wrong id).
  • RC4 (stale local state for one render) → Task 3 + Task 4 (cleanup).
  • RC5 (phantom scroll-to-bottom) → Task 3 (fresh ref).
  • RC6 (newMessagesWhileAway misfire) → Task 3 (fresh ref).
  • RC7 (cache vs network race) → Task 2 (deterministic init from memory cache, SQLite hydration only on cold cache miss).

Placeholders: none — every step lists exact files, exact code, exact commands.

Type consistency: ConversationRoute is the only new component; messageMemoryCache exports (getCachedMessages, hasCachedMessages, setCachedMessages, __resetForTests) match the test imports exactly.


Out of scope

  • Cross-fade animation between chats. A small view-transition or opacity tween could further polish the swap, but the user reported jumpiness, not the absence of an animation. Tackle in a follow-up if it still feels too "snap-y" after this lands.
  • Persisting composer drafts per chat. Today every chat-switch loses the in-progress draft. The remount in Task 3 preserves that behaviour deliberately (no regression). A draft-persistence feature is a separate spec.
  • Mobile (apps/mobile). The mobile chat list uses a different virtualization stack; this plan only covers apps/desktop.