delete-draft-flow.test.tsx16.7 KBView on GitHub /**
* Regression test for the "delete-draft-comes-back" bug.
*
* Repro path:
* 1. A thread contains a draft (userEdited=true) — e.g. a reply draft or a
* synthetic one whose row id equals its draftSessionId.
* 2. User clicks delete on the draft row in the thread list.
* 3. `optimisticDeleteDraft` fires. Before the fix it only called
* `removeFromList` (which touches the thread list, not threadData), so
* the draft stayed inside `threadData[threadId].messages` and any
* subsequent `mail.get` merge re-adopted it because `userEdited: true`
* tells `mergeDraftsByUserEdited` to preserve unmatched local drafts.
*
* The fix layers three things onto the delete:
* - `removeDraftFromThread` → physically drop the message from threadData
* - `addDraftTombstones` → suppress any in-flight `mail.get` that still
* echoes the draft (e.g. Gmail not yet synced)
* - mirror the removal into the React Query cache (`mail.get` queryKey)
*
* And passes the full identifier triplet `{ draftId, emailHeaderMessageId,
* threadId }` to the server so the provider lookup can resolve the draft
* even when the row id isn't itself a Gmail draftId.
*/
import { act, renderHook } from '@testing-library/react';
// ── Module mocks (must precede imports that pull in the mocked modules) ─────
jest.mock('@/providers/query-provider', () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { makeTrpcShim } = require('../../lib/labelFlowHarness');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { __getDraftDeleteSpy } = require('./delete-draft-flow.helpers');
return {
useTRPC: () => {
const base = makeTrpcShim();
return {
...base,
drafts: {
...base.drafts,
delete: {
mutationOptions: () => ({
mutationFn: async (
input: { draftId?: string | null; emailHeaderMessageId?: string | null; threadId?: string },
) => {
__getDraftDeleteSpy().calls.push(input);
if (__getDraftDeleteSpy().mode === 'reject') {
throw __getDraftDeleteSpy().error ?? new Error('mock delete failure');
}
return true;
},
}),
},
list: {
queryKey: () => ['drafts', 'list'] as readonly unknown[],
},
},
};
},
};
});
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(),
}));
// ── Imports (after mocks) ───────────────────────────────────────────────────
import {
HarnessProvider,
beginHarness,
endHarness,
flushMicrotasks,
} from '../../lib/labelFlowHarness';
import { __getDraftDeleteSpy, __resetDraftDeleteSpy } from './delete-draft-flow.helpers';
import { useCedarStore } from '@/modules/store';
import { useOptimisticActions } from '@/modules/threads/rendering/use-optimistic-actions';
import type { ParsedMessage, ThreadData } from '@/modules/threads/threadList/store/threadSlice';
import type { QueryClient } from '@tanstack/react-query';
// ── Fixtures ────────────────────────────────────────────────────────────────
const THREAD_ID = 'thread-delete-draft';
const makeReply = (overrides: Partial<ParsedMessage> = {}): ParsedMessage => ({
id: 'msg-real',
threadId: THREAD_ID,
isDraft: false,
subject: 'Re: Meeting Follow-Up',
sender: { email: '<email>', name: 'Nicolas Gutierrez' },
to: [{ email: '<email>', name: 'Jesse' }],
cc: null,
bcc: null,
tls: false,
receivedOn: '2026-05-22T00:39:04.000Z',
unread: false,
processedHtml: '<p>Sounds good</p>',
blobUrl: '',
tags: [
{ id: 'INBOX', name: 'INBOX', type: 'system' },
],
snippet: 'Sounds good',
attachments: [],
...overrides,
});
const makeSyntheticDraft = (overrides: Partial<ParsedMessage> = {}): ParsedMessage => ({
id: 'b8c9d44f-7ae5-4149-a9b5-e4f2a6889ab2',
draftId: 'r-6871551176658842359',
draftSessionId: 'b8c9d44f-7ae5-4149-a9b5-e4f2a6889ab2',
emailHeaderMessageId: 'b8c9d44f-7ae5-4149-a9b5-e4f2a6889ab2',
userEdited: true,
threadId: THREAD_ID,
isDraft: true,
subject: 'Re: Meeting Follow-Up',
sender: { email: '<email>', name: 'jesse' },
to: [{ email: '<email>', name: 'nico' }],
cc: null,
bcc: null,
tls: true,
receivedOn: '2026-05-22T06:15:11.320Z',
unread: false,
processedHtml: '<div>Yo - was dope seeing you in person.</div>',
blobUrl: '',
tags: [{ id: 'DRAFT', name: 'DRAFT', type: 'system' }],
snippet: '',
attachments: [],
...overrides,
});
const seed = (messages: ParsedMessage[]): void => {
useCedarStore.getState().setThreadData(THREAD_ID, {
messages,
latest: messages[messages.length - 1],
hasUnread: false,
totalReplies: messages.length,
labels: [{ id: 'INBOX', name: 'INBOX' }],
} as Omit<ThreadData, 'id' | 'lastLoadedAt'>);
};
const draftsInThread = (): ParsedMessage[] =>
useCedarStore.getState().threadData[THREAD_ID]?.messages.filter((m) => m.isDraft) ?? [];
function renderActions() {
return renderHook(() => useOptimisticActions(), { wrapper: HarnessProvider });
}
// ── Lifecycle ───────────────────────────────────────────────────────────────
let queryClient: QueryClient;
beforeEach(() => {
({ queryClient } = beginHarness());
// beginHarness resets threadData but not the tombstone map — without this
// clear, a tombstone added by a prior test causes the next seed() to filter
// the draft out (applyDraftTombstones runs inside setThreadData), and the
// hook can no longer find the draft to derive identityCandidates / cache
// updates from.
useCedarStore.setState((s) => ({ ...s, pendingDraftDeletions: {} }));
__resetDraftDeleteSpy();
});
afterEach(() => {
endHarness();
});
// =============================================================================
describe('optimisticDeleteDraft', () => {
it('removes the draft from threadData and tombstones it so a stale mail.get cannot re-add it', async () => {
const real = makeReply();
const draft = makeSyntheticDraft();
seed([real, draft]);
expect(draftsInThread()).toHaveLength(1);
const { result } = renderActions();
await act(async () => {
await result.current.optimisticDeleteDraft(draft.id);
await flushMicrotasks();
});
// Optimistic removal landed in threadData.
expect(draftsInThread()).toHaveLength(0);
// A tombstone for the draft's identities is in place.
const tombstones =
useCedarStore.getState().pendingDraftDeletions?.[THREAD_ID] ?? {};
expect(tombstones[draft.draftId!]).toBeDefined();
expect(tombstones[draft.draftSessionId!]).toBeDefined();
expect(tombstones[draft.id]).toBeDefined();
// Server received the full identifier triplet (not just the row id).
const spy = __getDraftDeleteSpy();
expect(spy.calls).toHaveLength(1);
expect(spy.calls[0]).toEqual({
draftId: draft.draftId,
emailHeaderMessageId: draft.emailHeaderMessageId,
threadId: THREAD_ID,
});
// Simulate the regression: a stale `mail.get` lands and still echoes the
// draft (Gmail not yet synced, or canvas pendingDraft not yet cleared).
act(() => {
useCedarStore.getState().setThreadData(THREAD_ID, {
messages: [real, draft],
latest: real,
hasUnread: false,
totalReplies: 2,
labels: [{ id: 'INBOX', name: 'INBOX' }],
} as Omit<ThreadData, 'id' | 'lastLoadedAt'>);
});
// Tombstone suppressed it — the row stays gone.
expect(draftsInThread()).toHaveLength(0);
});
it('mirrors the removal into the React Query mail.get cache so an in-flight refetch cannot snap the draft back', async () => {
const real = makeReply();
const draft = makeSyntheticDraft();
seed([real, draft]);
// Pre-populate the React Query cache as if `mail.get` had already returned.
queryClient.setQueryData(['mail', 'get', { id: THREAD_ID }], {
messages: [real, draft],
latest: real,
totalReplies: 2,
});
const { result } = renderActions();
await act(async () => {
await result.current.optimisticDeleteDraft(draft.id);
await flushMicrotasks();
});
const cached = queryClient.getQueryData(['mail', 'get', { id: THREAD_ID }]) as {
messages: ParsedMessage[];
totalReplies: number;
};
expect(cached.messages.some((m) => m.isDraft)).toBe(false);
expect(cached.totalReplies).toBe(1);
});
it('rolls back threadData and the cache when the server delete rejects', async () => {
const real = makeReply();
const draft = makeSyntheticDraft();
seed([real, draft]);
queryClient.setQueryData(['mail', 'get', { id: THREAD_ID }], {
messages: [real, draft],
latest: real,
totalReplies: 2,
});
const spy = __getDraftDeleteSpy();
spy.mode = 'reject';
spy.error = new Error('Gmail 500');
// Silence the expected `console.error('Error deleting draft:', …)` the hook
// logs before rolling back.
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
const { result } = renderActions();
await act(async () => {
await result.current.optimisticDeleteDraft(draft.id);
await flushMicrotasks();
});
errorSpy.mockRestore();
// threadData restored to its pre-delete shape.
expect(draftsInThread()).toHaveLength(1);
// Tombstone cleared so the restored draft isn't filtered out by a future merge.
const tombstones =
useCedarStore.getState().pendingDraftDeletions?.[THREAD_ID] ?? {};
expect(tombstones[draft.draftId!]).toBeUndefined();
// Cache restored too.
const cached = queryClient.getQueryData(['mail', 'get', { id: THREAD_ID }]) as {
messages: ParsedMessage[];
};
expect(cached.messages.some((m) => m.isDraft)).toBe(true);
});
it('REGRESSION: thread-list draft row passes the THREAD id; we still resolve the inner draft and call drafts.delete with the Gmail draftId', async () => {
// This is the user's actual repro shape. The mail.get response includes a
// single Gmail draft whose `id` and `draftId` are completely different from
// the threadId. The Draft thread-list component calls
// `optimisticDeleteDraft(message.id)` where `message.id` is the THREAD id —
// before the fix that produced no `draftMessage`, identityCandidates was
// empty, and the server delete was silently skipped (= the bug).
const REAL_THREAD_ID = '19e41d0eef80bbed';
const real = makeReply({ id: REAL_THREAD_ID, threadId: REAL_THREAD_ID });
const gmailDraft = makeReply({
id: '19e4d71f2ffffc91',
threadId: REAL_THREAD_ID,
isDraft: true,
tags: [{ id: 'DRAFT', name: 'DRAFT', type: 'system' }],
sender: { email: '<email>', name: 'Jesse Li' },
to: [{ email: '<email>', name: '' }],
emailHeaderMessageId: '<<email>>',
processedHtml: '<div>Best,<br>Jesse</div>',
snippet: 'Best, Jesse - 770 309 0537',
receivedOn: '2026-05-22T02:09:33.000Z',
});
// Gmail draft id lives on its own property — not on `id`.
(gmailDraft as ParsedMessage & { draftId?: string }).draftId = 'r6490562241473124820';
useCedarStore.getState().setThreadData(REAL_THREAD_ID, {
messages: [real, gmailDraft],
latest: gmailDraft,
hasUnread: false,
totalReplies: 2,
labels: [{ id: 'INBOX', name: 'INBOX' }],
} as Omit<ThreadData, 'id' | 'lastLoadedAt'>);
const { result } = renderActions();
await act(async () => {
// Caller passes the THREAD id (what Draft thread-list row passes today).
await result.current.optimisticDeleteDraft(REAL_THREAD_ID);
await flushMicrotasks();
});
const spy = __getDraftDeleteSpy();
expect(spy.calls).toHaveLength(1);
// Must have resolved the inner draft and sent its Gmail draftId.
expect(spy.calls[0]).toEqual({
draftId: 'r6490562241473124820',
emailHeaderMessageId:
'<<email>>',
threadId: REAL_THREAD_ID,
});
// And the draft is gone from threadData.
expect(
useCedarStore.getState().threadData[REAL_THREAD_ID]!.messages.some((m) => m.isDraft),
).toBe(false);
});
it('REGRESSION: a draft row backed only by list-preview placeholders still calls drafts.delete', async () => {
// The row the user actually clicks in the inbox is often one `mail.get` has
// never loaded: `batchPopulateThreadMetadata` fills `messages` with
// placeholders (`id: ''`, `isDraft: false`) built from the listThreads
// preview. Neither lookup in optimisticDeleteDraft can find a draft in that,
// so before the fix `parentThreadId` stayed undefined, the server guard was
// all-falsy, and `drafts.delete` was never called — the row disappeared
// locally and came straight back on the next listThreads refetch, opening to
// a blank page with only the subject (the placeholders' empty bodies).
const PREVIEW_THREAD_ID = '1a03966786acd842';
const placeholder = makeReply({
id: '',
threadId: PREVIEW_THREAD_ID,
isDraft: false,
processedHtml: '',
snippet: '',
});
useCedarStore.getState().setThreadData(PREVIEW_THREAD_ID, {
messages: [placeholder],
latest: placeholder,
hasUnread: false,
totalReplies: 1,
hasDraft: true,
labels: [{ id: 'INBOX', name: 'INBOX' }],
} as Omit<ThreadData, 'id' | 'lastLoadedAt'>);
const { result } = renderActions();
await act(async () => {
await result.current.optimisticDeleteDraft(PREVIEW_THREAD_ID);
await flushMicrotasks();
});
const spy = __getDraftDeleteSpy();
expect(spy.calls).toHaveLength(1);
// No draft identifiers are resolvable client-side, so the server is handed the
// thread id and resolves the draft from the provider / stored snapshot itself.
expect(spy.calls[0]).toEqual({
draftId: null,
emailHeaderMessageId: null,
threadId: PREVIEW_THREAD_ID,
});
// And the deletion is tombstoned, so a mail.get still echoing the draft can't
// re-add it while Gmail catches up.
expect(
useCedarStore.getState().pendingDraftDeletions[PREVIEW_THREAD_ID],
).toBeDefined();
});
it('leaves a client-only compose session local — no provider draft to delete', async () => {
// `draftSessionId-…` keys have no provider thread behind them; firing
// drafts.delete for one would 400. The fallback must not widen to these.
const { result } = renderActions();
await act(async () => {
await result.current.optimisticDeleteDraft('draftSessionId-abc123');
await flushMicrotasks();
});
expect(__getDraftDeleteSpy().calls).toHaveLength(0);
});
it('handles a row id that is the draftSessionId (not a Gmail draftId) and still resolves identities', async () => {
// Synthetic draft whose row id equals the draftSessionId — this is the
// shape `Draft` thread-list rows pass to optimisticDeleteDraft when the
// thread itself is just the draft session.
const real = makeReply();
const draft = makeSyntheticDraft({ draftId: undefined, emailHeaderMessageId: undefined });
seed([real, draft]);
const { result } = renderActions();
await act(async () => {
// Caller passes the draftSessionId / row id rather than a real Gmail draftId.
await result.current.optimisticDeleteDraft(draft.draftSessionId!);
await flushMicrotasks();
});
expect(draftsInThread()).toHaveLength(0);
const spy = __getDraftDeleteSpy();
expect(spy.calls).toHaveLength(1);
// No real Gmail draftId — server still gets threadId so it can resolve the
// latest draft via the provider lookup.
expect(spy.calls[0]).toEqual({
draftId: null,
emailHeaderMessageId: null,
threadId: THREAD_ID,
});
});
});