inboxCreateRace.test.tsx4.4 KBView on GitHub import { renderHook, act } from '@testing-library/react';
/**
* Two sub-inboxes added back to back, which is what clicking through the template
* gallery actually looks like.
*
* The failure this pins is not a rejected write — both creates succeed on the server.
* It is the REFETCH each one used to fire on settle: create #1's refetch is issued
* after #2 has already written its optimistic row, so the response (a server list
* that predates #2) lands last and overwrites it. The row disappears, the template
* card flips back to unselected, and the user clicks again — "adding an inbox doesn't
* save", from the outside.
*/
type InboxRow = { id: string; name: string; position: number; rule: unknown };
// `mock`-prefixed so babel-jest lets the hoisted `jest.mock` factories close over them.
let mockCache: InboxRow[] = [];
const mockInvalidate = jest.fn();
type Lifecycle = {
onMutate?: (vars: never) => Promise<unknown> | unknown;
onSettled?: () => void;
onError?: (e: unknown, vars: never, ctx: unknown) => void;
};
jest.mock('@tanstack/react-query', () => ({
useQuery: (() => {
let call = 0;
return () =>
call++ % 2 === 0
? { data: { settings: {} }, isLoading: false, isPending: false }
: { data: mockCache, isLoading: false, isPending: false };
})(),
useMutation: (opts: Lifecycle) => {
return {
mutate: jest.fn(),
// Resolve on a later microtask so a second create can interleave.
mutateAsync: jest.fn(async (vars: never) => {
const ctx = await opts?.onMutate?.(vars);
await Promise.resolve();
opts?.onSettled?.();
return ctx;
}),
};
},
useQueryClient: () => ({
getQueryData: () => mockCache,
setQueryData: (_key: unknown, next: InboxRow[]) => {
mockCache = next;
},
cancelQueries: jest.fn(),
invalidateQueries: mockInvalidate,
fetchQuery: jest.fn(),
}),
}));
jest.mock('@/modules/auth/utils/auth-client', () => ({
useSession: () => ({ data: { user: { id: 'u1' } } }),
}));
jest.mock('@/providers/query-provider', () => ({
useTRPC: () => ({
settings: {
get: { queryKey: () => ['settings'], queryOptions: () => ({}) },
save: { mutationOptions: (o: Lifecycle) => ({ ...o }) },
},
mail: {
listInboxes: { queryKey: () => ['inboxes'], queryOptions: () => ({}) },
createInbox: { mutationOptions: (o: Lifecycle) => ({ ...o }) },
updateInbox: { mutationOptions: (o: Lifecycle) => ({ ...o }) },
deleteInbox: { mutationOptions: (o: Lifecycle) => ({ ...o }) },
reorderInboxes: { mutationOptions: (o: Lifecycle) => ({ ...o }) },
previewSplitQuery: { queryOptions: () => ({}) },
getSplitCounts: { queryKey: () => ['counts'] },
listThreads: { infiniteQueryKey: () => ['threads'] },
},
}),
}));
import { useInboxes } from '@/modules/threads/hooks/use-inboxes';
beforeEach(() => {
mockCache = [];
mockInvalidate.mockClear();
});
describe('useInboxes — concurrent adds', () => {
it('keeps both rows when two inboxes are added back to back', async () => {
const { result } = renderHook(() => useInboxes());
await act(async () => {
await Promise.all([
result.current.addInbox({ name: 'Calendar', rule: { kind: 'all' } }),
result.current.addInbox({ name: 'Starred', rule: { kind: 'all' } }),
]);
});
expect(mockCache.map((i) => i.name).sort()).toEqual(['Calendar', 'Starred']);
});
it('gives each concurrent add its own position', async () => {
const { result } = renderHook(() => useInboxes());
await act(async () => {
await Promise.all([
result.current.addInbox({ name: 'Calendar', rule: { kind: 'all' } }),
result.current.addInbox({ name: 'Starred', rule: { kind: 'all' } }),
]);
});
const positions = mockCache.map((i) => i.position).sort();
expect(new Set(positions).size).toBe(positions.length);
});
it('refetches the list once, after the last create settles', async () => {
const { result } = renderHook(() => useInboxes());
await act(async () => {
await Promise.all([
result.current.addInbox({ name: 'Calendar', rule: { kind: 'all' } }),
result.current.addInbox({ name: 'Starred', rule: { kind: 'all' } }),
]);
});
const inboxInvalidations = mockInvalidate.mock.calls.filter(
([arg]) => (arg as { queryKey?: string[] })?.queryKey?.[0] === 'inboxes',
);
expect(inboxInvalidations).toHaveLength(1);
});
});