pendingComposeAction.test.tsx5.0 KBView on GitHub /**
* `r` from the list has to still mean "reply" once the thread finishes loading.
*
* Pressing `r` on a row selects the thread, opens it, and leaves a `pendingComposeAction` for
* `ThreadDisplayHotkeys` to turn into a draft. But `createDraftInThread` replies to the last
* non-draft MESSAGE, and the row it was pressed on has none loaded yet — so the first pass
* fails by design and the whole thing rests on the retry.
*
* The retry did not exist. The effect depended on `threadId` and on `createDraftInThread`,
* whose own dependencies are the thread id and a set of stable store actions — nothing in
* either list changes when `mail.get` lands the messages. So the pending action sat there and
* the composer never appeared, which is exactly what the comment above it claimed was handled.
*/
import { act, render } from '@testing-library/react';
import { HotkeysProvider } from 'react-hotkeys-hook';
import React from 'react';
// ── Module mocks (must precede the imports they affect) ─────────────────────
jest.mock('react-router', () => ({ useParams: () => ({ folder: 'inbox' }) }));
jest.mock('@/modules/threads/threadList/threadItem/components/thread', () => ({
triggerThreadExitAnimation: jest.fn(() => Promise.resolve()),
}));
jest.mock('@/modules/threads/rendering/use-optimistic-actions', () => ({
useOptimisticActions: () => ({
optimisticMarkAsRead: jest.fn(),
optimisticMarkAsUnread: jest.fn(),
optimisticToggleStar: jest.fn(),
undoLastAction: jest.fn(),
}),
}));
jest.mock('@/modules/threads/common/hooks/use-move-to', () => ({
__esModule: true,
default: () => ({ mutate: jest.fn() }),
}));
jest.mock('@/modules/threads/common/hooks/use-delete', () => ({
__esModule: true,
default: () => ({ mutate: jest.fn() }),
}));
jest.mock('@/modules/threads/thread/hooks/use-archive-thread', () => ({
useArchiveThread: () => jest.fn(),
}));
jest.mock('@/modules/ux/layout/useBackOrUp', () => ({ useBackOrClose: (fn: () => void) => fn }));
jest.mock('@/hooks/use-connections', () => ({
useActiveConnection: () => ({ data: { email: '<email>', name: 'Me' } }),
}));
jest.mock('sonner', () => ({
toast: Object.assign(jest.fn(), { info: jest.fn(), error: jest.fn(), dismiss: jest.fn() }),
}));
import { ThreadDisplayHotkeys } from '@/modules/threads/thread/utils/thread-display-hotkeys';
import { useCedarStore } from '@/modules/store';
const THREAD_ID = 'thr_1';
/** A thread whose body has landed — one ordinary inbound message. */
const loadedThread = () => ({
id: THREAD_ID,
messages: [
{
id: 'msg_1',
connectionId: 'conn_1',
subject: 'Pricing',
sender: { email: '<email>', name: 'Dana' },
to: [{ email: '<email>', name: 'Me' }],
cc: null,
bcc: null,
receivedOn: '2026-09-01T00:00:00.000Z',
unread: true,
isDraft: false,
processedHtml: '',
blobUrl: '',
threadId: THREAD_ID,
tags: [],
attachments: [],
tls: true,
},
],
hasUnread: true,
totalReplies: 1,
labels: [],
});
/** The same thread as the LIST knows it: selected and open, with no body fetched yet. */
const unloadedThread = () => ({ ...loadedThread(), messages: [] });
const renderHotkeys = () =>
render(
<HotkeysProvider initiallyActiveScopes={['thread-display']}>
<ThreadDisplayHotkeys />
</HotkeysProvider>,
);
const draftsIn = (threadId: string) =>
(useCedarStore.getState().threadData[threadId]?.messages ?? []).filter((m) => m.isDraft);
beforeEach(() => {
useCedarStore.setState({
selectedThreadId: THREAD_ID,
pendingComposeAction: null,
threadData: {},
threadMap: {},
mainThreadId: '',
activeThreadId: '',
});
});
describe('a reply queued from the list survives the thread still loading', () => {
it('drafts the reply when the messages arrive, not only if they were already there', () => {
act(() => {
useCedarStore.getState().setThreadData(THREAD_ID, unloadedThread() as never);
useCedarStore.getState().setPendingComposeAction('reply');
});
renderHotkeys();
// Nothing to reply to yet — and crucially the action is still pending, not discarded.
expect(draftsIn(THREAD_ID)).toHaveLength(0);
expect(useCedarStore.getState().pendingComposeAction).toBe('reply');
act(() => {
useCedarStore.getState().setThreadData(THREAD_ID, loadedThread() as never);
});
const drafts = draftsIn(THREAD_ID);
expect(drafts).toHaveLength(1);
expect(drafts[0]?.to).toEqual([{ email: '<email>', name: 'Dana' }]);
expect(useCedarStore.getState().pendingComposeAction).toBeNull();
});
it('drafts immediately when the thread was already loaded', () => {
act(() => {
useCedarStore.getState().setThreadData(THREAD_ID, loadedThread() as never);
useCedarStore.getState().setPendingComposeAction('reply');
});
renderHotkeys();
expect(draftsIn(THREAD_ID)).toHaveLength(1);
expect(useCedarStore.getState().pendingComposeAction).toBeNull();
});
});