emptyPageKeepsPaging.test.tsx3.6 KBView on GitHub import React from 'react';
import { render } from '@testing-library/react';
/**
* A page that came back EMPTY but carries a cursor is not the end of the list.
*
* REGRESSION THIS LOCKS DOWN (<email>, staging): 16 reads of her "Starred"
* split — `-in:CHAT in:inbox is:starred` — every one `dbThreadCount: 0`,
* `candidatesScanned: 2000`, `truncatedByOverFetch: 1`, while she has 2 starred threads sitting
* in an inbox of 9,138. The mirror read walks candidates until the over-fetch cap stops it and
* then returns a SHORT (here, zero-row) page plus a usable cursor —
* `resolveNextPageToken` case 3 in list-threads-from-db.ts, whose own docstring says "callers
* must not read 'short page' as 'end of list'".
*
* MailList read it as exactly that. With `items.length === 0` it renders the inbox-zero state
* instead of the `VList`, so `vListRef.current` is null, so the viewport-fill effect bails at
* its `if (!vListRef.current) return;` guard before it can ask for the next page — and there is
* no list to scroll, so nothing else asks either. A selective split over a large mailbox is
* therefore pinned on "You've reached inbox zero here :)" forever.
*/
const mockLoadMore = jest.fn();
const mockThreadsState = {
isLoading: false,
isFetching: false,
isFetchingNextPage: false,
hasNextPage: true,
isPlaceholderData: false,
};
jest.mock('@/modules/threads/threadList/hooks/use-threads', () => ({
useThreads: () => [mockThreadsState, [], mockLoadMore, []],
}));
jest.mock('@/modules/threads/threadList/hooks/use-mail-navigation', () => ({
useMailNavigation: () => ({}),
}));
jest.mock('@/hooks/use-hot-key', () => ({ useKeyState: () => () => false }));
jest.mock('@/modules/threads/hooks/use-unread-filter', () => ({ useUnreadOnly: () => false }));
jest.mock('@/lib/thread-open-profiler', () => ({ startThreadOpenProfiling: jest.fn() }));
jest.mock('@/lib/performance-logger', () => ({
perfLogger: { mark: jest.fn(), endSession: jest.fn() },
perfSessionLog: jest.fn(),
}));
jest.mock('@/modules/threads/threadList/threadItem', () => ({
Thread: () => null,
Draft: () => null,
}));
jest.mock('@/modules/userTasks/components/AnimatedCheckmark', () => ({
AnimatedCheckmark: () => null,
}));
jest.mock('react-router', () => ({ useParams: () => ({ folder: 'starred' }) }));
jest.mock('virtua', () => ({
VList: () => null,
}));
jest.mock('@/modules/store', () => ({
useCedarStore: (selector: (s: unknown) => unknown) =>
selector({
selectThreadId: jest.fn(),
setIsThreadOpen: jest.fn(),
toggleBulkSelection: jest.fn(),
setBulkSelected: jest.fn(),
setFocusedIndex: jest.fn(),
batchSetThreadData: jest.fn(),
setConversations: jest.fn(),
hasConversation: () => false,
isStale: () => false,
}),
}));
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { MailList } = require('@/modules/threads/threadList/components/mail-list');
beforeEach(() => mockLoadMore.mockClear());
describe('a truncated page is not the end of the list', () => {
it('asks for the next page when page one is empty but a cursor remains', () => {
render(<MailList />);
expect(mockLoadMore).toHaveBeenCalled();
});
it('does not announce inbox zero while more pages are still to come', () => {
const { queryByText } = render(<MailList />);
expect(queryByText(/inbox zero/i)).toBeNull();
});
it('stops asking once the list is genuinely exhausted', () => {
mockThreadsState.hasNextPage = false;
render(<MailList />);
expect(mockLoadMore).not.toHaveBeenCalled();
mockThreadsState.hasNextPage = true;
});
});