taskThreadPrefetchHooks.test.tsx5.8 KBView on GitHub /**
* The two preload entry points, held to the shape of the ones `/inbox` already has.
*
* Both failure modes are invisible in the UI — a preload that never fires just leaves the old
* spinner, and one that fires too widely burns a request per task on a 500-task board — so the
* behaviour is pinned here rather than left to be noticed.
*/
import React from 'react';
import { render } from '@testing-library/react';
import { usePrefetchTaskThreads, usePreloadTaskThreads } from '@/modules/userTasks/hooks/use-prefetch-task-threads';
const mockPrefetchThreads = jest.fn();
const mockPrefetchQuery = jest.fn();
const mockFetchQuery = jest.fn();
const mockBatchSetThreadData = jest.fn();
const mockShouldPrefetch = jest.fn(() => true);
jest.mock('@/modules/threads/hooks/use-prefetch-thread', () => ({
usePrefetchThread: () => ({ prefetchThreads: (ids: string[]) => mockPrefetchThreads(ids) }),
shouldPrefetch: () => mockShouldPrefetch(),
}));
jest.mock('@tanstack/react-query', () => ({
useQueryClient: () => ({
prefetchQuery: (...args: unknown[]) => mockPrefetchQuery(...args),
fetchQuery: (...args: unknown[]) => mockFetchQuery(...args),
}),
}));
jest.mock('@/providers/query-provider', () => ({
useTRPC: () => ({
mail: { get: { queryOptions: (input: { id: string }) => ({ queryKey: ['mail.get', input.id] }) } },
userTasks: {
getTaskById: {
queryOptions: (input: { taskId: string }) => ({ queryKey: ['getTaskById', input.taskId] }),
},
},
}),
}));
jest.mock('@/modules/store', () => ({
useCedarStore: (selector: (s: unknown) => unknown) => selector({ batchSetThreadData: mockBatchSetThreadData }),
}));
/** Mounts a hook so its effects run, with no DOM of its own. */
function Harness({ run }: { run: () => void }) {
run();
return null;
}
function emailTask(id: string, threadId: string) {
return { id, taskOutput: { kind: 'email', threadId } };
}
beforeEach(() => {
jest.clearAllMocks();
mockShouldPrefetch.mockReturnValue(true);
mockFetchQuery.mockResolvedValue(null);
});
describe('usePrefetchTaskThreads — hover', () => {
function hover(task: Parameters<ReturnType<typeof usePrefetchTaskThreads>>[0]) {
let prefetch: ReturnType<typeof usePrefetchTaskThreads> = () => {};
render(<Harness run={() => { prefetch = usePrefetchTaskThreads(); }} />);
prefetch(task);
}
it('pulls down the thread the task would open', () => {
hover(emailTask('task-1', 'thread-1'));
expect(mockPrefetchThreads).toHaveBeenCalledWith(['thread-1']);
});
it('pulls down the ticket query too — the ticket blocks on it before it renders anything', () => {
// A task with no thread still opens something: preloading only bodies would move the spinner
// rather than remove it.
hover({ id: 'task-2', taskOutput: { kind: 'none' } });
expect(mockPrefetchThreads).not.toHaveBeenCalled();
expect(mockPrefetchQuery).toHaveBeenCalledWith(
expect.objectContaining({ queryKey: ['getTaskById', 'task-2'] }),
);
});
it('sends nothing speculative on a slow or data-saver connection', () => {
mockShouldPrefetch.mockReturnValue(false);
hover(emailTask('task-3', 'thread-3'));
expect(mockPrefetchThreads).not.toHaveBeenCalled();
expect(mockPrefetchQuery).not.toHaveBeenCalled();
});
});
describe('usePreloadTaskThreads — the sweep after the list loads', () => {
function load(tasks: unknown[] | undefined) {
render(<Harness run={() => usePreloadTaskThreads(tasks as never)} />);
jest.runOnlyPendingTimers();
}
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());
it('fetches the bodies behind the loaded tasks', () => {
load([emailTask('t1', 'thread-1'), emailTask('t2', 'thread-2')]);
expect(mockFetchQuery).toHaveBeenCalledTimes(2);
expect(mockFetchQuery).toHaveBeenCalledWith(
expect.objectContaining({ queryKey: ['mail.get', 'thread-1'] }),
);
expect(mockFetchQuery).toHaveBeenCalledWith(
expect.objectContaining({ queryKey: ['mail.get', 'thread-2'] }),
);
});
it('stops at the first screenful — a board holds 500 tasks, not 20', () => {
load(Array.from({ length: 40 }, (_, i) => emailTask(`t${i}`, `thread-${i}`)));
expect(mockFetchQuery).toHaveBeenCalledTimes(20);
expect(mockFetchQuery).not.toHaveBeenCalledWith(
expect.objectContaining({ queryKey: ['mail.get', 'thread-20'] }),
);
});
it('fetches a thread once even when several tasks share it', () => {
// Several commitments off one email thread is the normal case, not an edge one.
load([emailTask('t1', 'thread-1'), emailTask('t2', 'thread-1')]);
expect(mockFetchQuery).toHaveBeenCalledTimes(1);
});
it('writes the bodies into the thread slice so the thread opens from the store', async () => {
mockFetchQuery.mockResolvedValue({
messages: [{ id: 'm1' }],
latest: { id: 'm1' },
hasUnread: false,
totalReplies: 1,
labels: [],
conversationId: 'conv-1',
trackingData: null,
});
load([emailTask('t1', 'thread-1')]);
// The sweep hands each body through fetch → catch → Promise.all before it writes; flush the
// whole chain rather than guessing at its length.
for (let i = 0; i < 10; i++) await Promise.resolve();
expect(mockBatchSetThreadData).toHaveBeenCalledWith(
expect.objectContaining({ 'thread-1': expect.objectContaining({ conversationId: 'conv-1' }) }),
);
});
it('does nothing before the list has loaded, or when no task names a thread', () => {
load(undefined);
load([{ id: 't1', taskOutput: { kind: 'none' } }]);
expect(mockFetchQuery).not.toHaveBeenCalled();
});
it('sends nothing speculative on a slow or data-saver connection', () => {
mockShouldPrefetch.mockReturnValue(false);
load([emailTask('t1', 'thread-1')]);
expect(mockFetchQuery).not.toHaveBeenCalled();
});
});