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();
  });
});