mark-done-flow.test.tsx11.3 KBView on GitHub /**
* Mark-as-done: the archived threads must not come back.
*
* Reproduces the production bug traced in
* apps/server/src/docs/bug-mark-done-threads-reappear.md.
*
* The mirror and `mail.listThreads` are provably correct — the archive leaves
* `crm_thread_labels` ~230-380 ms after the click and never returns. The window
* that breaks is the one INSIDE that round-trip: a `listThreads` fetch issued
* after `cancelQueries()` has already run but before the server committed the
* archive gets a page that legitimately still contains the thread. When it
* lands, React Query replaces the cache with it — and `optimisticMarkDone`
* never re-asserts the removal, so the thread is back in the inbox until the
* next page-1 refresh (the 5-minute interval in use-threads.ts).
*
* `optimisticMoveThreadsTo` and `optimisticDeleteThreads` already close this
* window by calling `removeThreadsFromQueryCache` a second time after awaiting
* the mutation; §2 below pins that behaviour so the three actions can't drift
* apart again.
*/
import { act, renderHook } from '@testing-library/react';
// ── Module mocks (must precede the imports they affect) ─────────────────────
jest.mock('@/providers/query-provider', () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { makeTrpcShim } = require('../../lib/labelFlowHarness');
return { useTRPC: () => makeTrpcShim() };
});
jest.mock('@/hooks/use-predictive-prefetch', () => ({
usePredictivePrefetch: () => ({ afterArchiveOrDelete: jest.fn() }),
}));
jest.mock('posthog-js', () => ({
__esModule: true,
default: { capture: jest.fn() },
capture: jest.fn(),
}));
jest.mock('sonner', () => ({
toast: { success: jest.fn(), error: jest.fn(), info: jest.fn(), warning: jest.fn() },
}));
jest.mock('@/modules/drafting/hooks/use-undo-send', () => ({
hasPendingSend: () => false,
performUndo: jest.fn(),
}));
jest.mock('@/modules/threads/thread/utils/thread-actions', () => ({
moveThreadsTo: jest.fn(async () => ({ ok: true })),
}));
// ── Imports (after mocks) ───────────────────────────────────────────────────
import {
HarnessProvider,
beginHarness,
endHarness,
flushMicrotasks,
seedThread,
} from '../../lib/labelFlowHarness';
import { useOptimisticActions } from '@/modules/threads/rendering/use-optimistic-actions';
import type { GmailSimulator, ServerThread } from '../../lib/gmailSimulator';
import type { QueryClient } from '@tanstack/react-query';
// ── Fixtures ────────────────────────────────────────────────────────────────
const LIST_KEY = ['mail', 'listThreads', { inboxName: 'Inbox' }] as const;
function inboxThread(id: string, receivedOn: string): ServerThread {
return {
id,
labels: [
{ id: 'INBOX', name: 'INBOX', type: 'system' },
{ id: 'UNREAD', name: 'UNREAD', type: 'system' },
],
subject: `subject ${id}`,
sender: { name: `sender ${id}`, email: `${id}@example.com` },
snippet: `snippet ${id}`,
receivedOn,
messageCount: 1,
};
}
type InfiniteListData = {
pages: Array<{ threads: Array<{ id: string }>; nextPageToken=[redacted] | null }>;
pageParams: unknown[];
};
/** The ids the inbox list would render right now, flattened across pages. */
function listedIds(queryClient: QueryClient): string[] {
const data = queryClient.getQueryData<InfiniteListData>(LIST_KEY);
return (data?.pages ?? []).flatMap((page) => page.threads.map((t) => t.id));
}
/** Seed the cache the way a completed page-1 load would have. */
function seedListCache(queryClient: QueryClient, sim: GmailSimulator): void {
queryClient.setQueryData<InfiniteListData>(LIST_KEY, {
pages: [{ threads: sim.listThreadsResponse().threads, nextPageToken=[redacted] }],
pageParams: [''],
});
}
/**
* A `listThreads` response landing in the cache. Its contents come from the
* simulator's CURRENT server state — so calling this while a mark-done is still
* pending reproduces exactly the response a request issued mid-round-trip would
* have received: the archive has not committed, so the threads are still there.
*/
function landListThreadsResponse(queryClient: QueryClient, sim: GmailSimulator): void {
queryClient.setQueryData<InfiniteListData>(LIST_KEY, {
pages: [{ threads: sim.listThreadsResponse().threads, nextPageToken=[redacted] }],
pageParams: [''],
});
}
// ── Suite ───────────────────────────────────────────────────────────────────
describe('mark as done — archived threads must not reappear', () => {
let sim: GmailSimulator;
let queryClient: QueryClient;
beforeEach(() => {
({ sim, queryClient } = beginHarness());
seedThread(inboxThread('t1', '2026-08-16T10:00:00.000Z'));
seedThread(inboxThread('t2', '2026-08-16T09:00:00.000Z'));
seedThread(inboxThread('t3', '2026-08-16T08:00:00.000Z'));
seedListCache(queryClient, sim);
});
afterEach(() => {
endHarness();
});
// §1 — the regression itself
it('does not resurrect threads when a pre-archive listThreads response lands mid-mutation', async () => {
const { result } = renderHook(() => useOptimisticActions(), { wrapper: HarnessProvider });
expect(listedIds(queryClient)).toEqual(['t1', 't2', 't3']);
// Fire and DON'T await — the simulator holds both markDone calls pending,
// which is the ~230-380 ms the real server spends in threads.modify.
let settled: Promise<void>;
await act(async () => {
settled = result.current.optimisticMarkDone(['t1', 't2']);
await flushMicrotasks();
});
// Optimistic removal landed; the server has not committed yet.
expect(listedIds(queryClient)).toEqual(['t3']);
expect(sim.pendingCount()).toBe(2);
expect(sim.getLabelNames('t1')).toContain('INBOX');
// A listThreads fetch issued inside the round-trip resolves. It is a
// perfectly valid server response — it just predates the archive. Round 1
// let it land and repaired the flicker afterwards; the guard now refuses
// the write outright, so there is no window in which the user sees them.
await act(async () => {
landListThreadsResponse(queryClient, sim);
await flushMicrotasks();
});
expect(listedIds(queryClient)).toEqual(['t3']);
// Server commits the archive and the mutation settles.
await act(async () => {
sim.flushAll();
await settled;
await flushMicrotasks();
});
expect(sim.getLabelNames('t1')).not.toContain('INBOX');
expect(sim.getLabelNames('t2')).not.toContain('INBOX');
// The action has finished and the server agrees the threads are archived,
// so the rendered list must not still be showing them.
expect(listedIds(queryClient)).toEqual(['t3']);
});
// §1b — the Round 2 regression: prod's three recorded resurrections all landed
// 15.7-18.2 s after the click, i.e. AFTER the mutation settled and after the
// one-shot repair had already run. Nothing opposes that write today.
// See bug-mark-done-threads-reappear.md Round 2.
it('does not resurrect when a pre-archive listThreads response lands AFTER the mutation settles', async () => {
const { result } = renderHook(() => useOptimisticActions(), { wrapper: HarnessProvider });
expect(listedIds(queryClient)).toEqual(['t1', 't2', 't3']);
// A slow archive: staging measured mail.markDone at up to 7381 ms. A page-1
// refetch issued inside that window is answered from pre-archive state.
let settled: Promise<void>;
await act(async () => {
settled = result.current.optimisticMarkDone(['t1', 't2']);
await flushMicrotasks();
});
const staleResponse = sim.listThreadsResponse().threads;
expect(staleResponse.map((t) => t.id)).toEqual(['t1', 't2', 't3']);
// The mutation settles and the repair pass runs.
await act(async () => {
sim.flushAll();
await settled;
await flushMicrotasks();
});
expect(sim.getLabelNames('t1')).not.toContain('INBOX');
expect(listedIds(queryClient)).toEqual(['t3']);
// ...and only THEN does the in-flight response resolve. The repair has
// already fired; there is nothing left to undo this write.
await act(async () => {
queryClient.setQueryData(LIST_KEY, {
pages: [{ threads: staleResponse, nextPageToken=[redacted] }],
pageParams: [''],
});
await flushMicrotasks();
});
expect(listedIds(queryClient)).toEqual(['t3']);
});
it('keeps a late stale response from resurrecting a bulk archive', async () => {
const { result } = renderHook(() => useOptimisticActions(), { wrapper: HarnessProvider });
let settled: Promise<void>;
await act(async () => {
settled = result.current.optimisticMarkDone(['t1', 't2', 't3']);
await flushMicrotasks();
});
const staleResponse = sim.listThreadsResponse().threads;
await act(async () => {
sim.flushAll();
await settled;
await flushMicrotasks();
});
expect(listedIds(queryClient)).toEqual([]);
await act(async () => {
queryClient.setQueryData(LIST_KEY, {
pages: [{ threads: staleResponse, nextPageToken=[redacted] }],
pageParams: [''],
});
await flushMicrotasks();
});
expect(listedIds(queryClient)).toEqual([]);
});
it('keeps them out when no stale response ever lands (baseline)', async () => {
const { result } = renderHook(() => useOptimisticActions(), { wrapper: HarnessProvider });
await act(async () => {
const settled = result.current.optimisticMarkDone(['t1', 't2']);
await flushMicrotasks();
sim.flushAll();
await settled;
await flushMicrotasks();
});
expect(listedIds(queryClient)).toEqual(['t3']);
});
it('restores the threads when the server rejects the archive', async () => {
const { result } = renderHook(() => useOptimisticActions(), { wrapper: HarnessProvider });
await act(async () => {
const settled = result.current.optimisticMarkDone(['t1']);
await flushMicrotasks();
sim.failNext(new Error('Gmail API error'));
sim.flushAll();
await settled;
await flushMicrotasks();
});
// Rollback puts it back — the repair pass must not defeat the error path.
expect(listedIds(queryClient)).toContain('t1');
expect(sim.getLabelNames('t1')).toContain('INBOX');
});
// §2 — the two sibling actions already close this window; pin it.
it('optimisticDeleteThreads also survives a stale response landing mid-mutation', async () => {
const { result } = renderHook(() => useOptimisticActions(), { wrapper: HarnessProvider });
let settled: Promise<void>;
await act(async () => {
settled = result.current.optimisticDeleteThreads(['t1']);
await flushMicrotasks();
});
expect(sim.pendingCount()).toBe(1);
await act(async () => {
landListThreadsResponse(queryClient, sim);
await flushMicrotasks();
});
expect(listedIds(queryClient)).toContain('t1');
await act(async () => {
sim.flushAll();
await settled;
await flushMicrotasks();
});
expect(listedIds(queryClient)).not.toContain('t1');
});
});