diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index e1b13da7bf46..715676ac5920 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -1,6 +1,5 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import {useIsReportLoadPending} from '@hooks/useInFlightRequests'; -import useLoadReportActions from '@hooks/useLoadReportActions'; import useLocalize from '@hooks/useLocalize'; import useMarkAsRead from '@hooks/useMarkAsRead'; import useNetwork from '@hooks/useNetwork'; @@ -25,12 +24,10 @@ import REPORT_LINK_ROUTE_PARAMS from '@libs/Navigation/reportLinkRouteParams'; import TransitionTracker from '@libs/Navigation/TransitionTracker'; import type {ReportsSplitNavigatorParamList} from '@libs/Navigation/types'; import {isTrackOnboardingChoice} from '@libs/OnboardingUtils'; -import {getFilteredReportActionsForReportView, getOneTransactionThreadReportID, hasNextActionMadeBySameActor, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import {getFilteredReportActionsForReportView, getOneTransactionThreadReportID, hasNextActionMadeBySameActor} from '@libs/ReportActionsUtils'; import {canUserPerformWriteAction, chatIncludesChronosWithID, getReportLastVisibleActionCreated, isHarvestCreatedExpenseReport, shouldShowMarkAsDone} from '@libs/ReportUtils'; import markOpenReportEnd from '@libs/telemetry/markOpenReportEnd'; -import isSearchTopmostFullScreenRoute from '@navigation/helpers/isSearchTopmostFullScreenRoute'; - import ConciergeThinkingMessage from '@pages/home/report/ConciergeThinkingMessage'; import {useActionListContext, useActionListRef} from '@pages/inbox/ActionListContext'; import {useAgentZeroStatus} from '@pages/inbox/AgentZeroStatusContext'; @@ -40,7 +37,7 @@ import {ReportActionPositionContextProvider, ReportActionScrollToNewestContext} import ReportActionsListItemRenderer from '@pages/inbox/report/ReportActionsListItemRenderer'; import useReportUnreadMessageScrollTracking from '@pages/inbox/report/useReportUnreadMessageScrollTracking'; -import {getOlderActions, openReport, subscribeToNewActionEvent} from '@userActions/Report'; +import {openReport, subscribeToNewActionEvent} from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -51,7 +48,6 @@ import type * as OnyxTypes from '@src/types/onyx'; import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; -/* eslint-disable rulesdir/prefer-early-return */ import {useIsFocused, useRoute} from '@react-navigation/native'; import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; import isEmpty from 'lodash/isEmpty'; @@ -61,6 +57,7 @@ import {View} from 'react-native'; import MoneyRequestReportEmptyStateView from './MoneyRequestReportEmptyStateView'; import MoneyRequestReportTransactionList from './MoneyRequestReportTransactionList'; import SelectionToolbar from './SelectionToolbar'; +import useMoneyRequestReportPagination from './useMoneyRequestReportPagination'; import useMoneyRequestReportVisibleActions from './useMoneyRequestReportVisibleActions'; /** @@ -71,10 +68,6 @@ const EmptyParentReportActionForTransactionThread = undefined; // Amount of time to wait until all list items should be rendered and scrollToEnd will behave well const DELAY_FOR_SCROLLING_TO_END = 100; -// The server page size for report actions is ~50. Gaps from IOU prioritization only happen -// when the initial load is truncated, so skip backfill for smaller reports. -const BACKFILL_MIN_ACTIONS_THRESHOLD = 50; - type MoneyRequestReportListProps = { onLayout?: (event: LayoutChangeEvent) => void; }; @@ -193,101 +186,16 @@ function MoneyRequestReportActionsListContent({reportIDFromRoute, onLayout}: Mon const lastVisibleActionCreated = getReportLastVisibleActionCreated(report, transactionThreadReport); const hasNewestReportAction = lastAction?.created === lastVisibleActionCreated; - const reportActionIDs = useMemo(() => { - return reportActions?.map((action) => action.reportActionID) ?? []; - }, [reportActions]); - - const {loadOlderChats, loadNewerChats} = useLoadReportActions({ + const {onStartReached, onEndReached} = useMoneyRequestReportPagination({ reportID, reportActions, - allReportActionIDs: reportActionIDs, transactionThreadReportID, hasOlderActions, hasNewerActions, - newestFetchedReportActionID: reportPaginationState?.newestFetchedReportActionID, - }); - - const hasFinishedInitialLoad = reportLoadingState?.isLoadingInitialReportActions === false; - const prevNewestFetchedIDRef = useRef(undefined); - useEffect(() => { - if (hasFinishedInitialLoad && hasNewerActions && reportActions.length > 0 && !isOffline && !reportLoadingState?.isLoadingNewerReportActions) { - // Safety guard: if the cursor hasn't advanced since the last call, the server - // isn't returning new data. Stop to prevent an infinite request loop. - const currentCursor = reportPaginationState?.newestFetchedReportActionID; - if (prevNewestFetchedIDRef.current !== undefined && prevNewestFetchedIDRef.current === currentCursor) { - return; - } - prevNewestFetchedIDRef.current = currentCursor; - loadNewerChats(false); - } - }, [ - hasFinishedInitialLoad, - reportActions.length, - hasNewerActions, - isOffline, - reportLoadingState?.isLoadingNewerReportActions, - reportPaginationState?.newestFetchedReportActionID, - loadNewerChats, - ]); - - // Backfill loop: the backend prioritizes IOU actions in OpenReport/GetNewerActions for money - // request reports, which can leave non-IOU chat messages in a gap between the IOU-biased cursor - // and older messages. After auto-pagination finishes, walk backwards from the IOU cursor using - // getOlderActions. Each response advances oldestFetchedReportActionID so the next call picks up - // where the previous one left off, until the cursor stops advancing (gap filled). - const prevBackfillCursorRef = useRef(undefined); - const isBackfillingRef = useRef(false); - useEffect(() => { - if (!hasFinishedInitialLoad || isOffline || hasNewerActions || reportLoadingState?.isLoadingNewerReportActions || reportLoadingState?.isLoadingOlderReportActions) { - return; - } - - if (!isBackfillingRef.current) { - const hasIOUActions = reportActions.some((action) => isMoneyRequestAction(action)); - if (!hasIOUActions || reportActions.length < BACKFILL_MIN_ACTIONS_THRESHOLD || !reportPaginationState?.newestFetchedReportActionID) { - return; - } - } - - const cursor = isBackfillingRef.current ? reportPaginationState?.oldestFetchedReportActionID : reportPaginationState?.newestFetchedReportActionID; - if (!cursor) { - return; - } - - if (prevBackfillCursorRef.current === cursor) { - return; - } - - isBackfillingRef.current = true; - prevBackfillCursorRef.current = cursor; - const handle = TransitionTracker.runAfterTransitions({callback: () => getOlderActions(reportID, cursor)}); - - return () => handle.cancel(); - }, [ - hasFinishedInitialLoad, isOffline, - hasNewerActions, - reportLoadingState?.isLoadingNewerReportActions, - reportLoadingState?.isLoadingOlderReportActions, - reportPaginationState?.newestFetchedReportActionID, - reportPaginationState?.oldestFetchedReportActionID, - reportActions, - reportID, - ]); - - const onStartReached = useCallback(() => { - if (!isSearchTopmostFullScreenRoute()) { - loadOlderChats(false); - return; - } - TransitionTracker.runAfterTransitions({ - callback: () => loadOlderChats(false), - }); - }, [loadOlderChats]); - - const onEndReached = useCallback(() => { - loadNewerChats(false); - }, [loadNewerChats]); + reportPaginationState, + reportLoadingState, + }); const [hasScrolledOverThreshold, setHasScrolledOverThreshold] = useState(false); const listLayoutHeightRef = useRef(0); diff --git a/src/components/MoneyRequestReportView/useMoneyRequestReportPagination.ts b/src/components/MoneyRequestReportView/useMoneyRequestReportPagination.ts new file mode 100644 index 000000000000..6574ed657628 --- /dev/null +++ b/src/components/MoneyRequestReportView/useMoneyRequestReportPagination.ts @@ -0,0 +1,165 @@ +import useLoadReportActions from '@hooks/useLoadReportActions'; + +import TransitionTracker from '@libs/Navigation/TransitionTracker'; +import {isMoneyRequestAction} from '@libs/ReportActionsUtils'; + +import isSearchTopmostFullScreenRoute from '@navigation/helpers/isSearchTopmostFullScreenRoute'; + +import {getOlderActions} from '@userActions/Report'; + +import type * as OnyxTypes from '@src/types/onyx'; + +import {useEffect, useRef} from 'react'; + +// The server page size for report actions is ~50. Gaps from IOU prioritization only happen +// when the initial load is truncated, so skip backfill for smaller reports. +const BACKFILL_MIN_ACTIONS_THRESHOLD = 50; + +type UseMoneyRequestReportPaginationParams = { + /** The report whose actions are being paginated */ + reportID: string | undefined; + + /** Paginated report actions, newest-first */ + reportActions: OnyxTypes.ReportAction[]; + + /** The single-transaction thread report ID, when one exists */ + transactionThreadReportID: string | undefined; + + /** Whether older actions exist beyond the loaded page */ + hasOlderActions: boolean; + + /** Whether newer actions exist beyond the loaded page */ + hasNewerActions: boolean; + + /** Whether the network is offline */ + isOffline: boolean; + + /** The report's pagination cursor state */ + reportPaginationState: OnyxTypes.ReportPaginationState | undefined; + + /** The report's loading state */ + reportLoadingState: OnyxTypes.ReportLoadingState | undefined; +}; + +type UseMoneyRequestReportPaginationResult = { + /** FlashList onStartReached handler that loads older actions */ + onStartReached: () => void; + + /** FlashList onEndReached handler that loads newer actions */ + onEndReached: () => void; +}; + +/** + * Owns loading more actions for the money-request report view: the list-edge handlers, the + * auto-load-newer loop, and the IOU backfill loop. + * + * The consuming component remounts per report (keyed by reportID), so the cursor refs below reset + * naturally on report switch and never need render-phase resets. + */ +function useMoneyRequestReportPagination({ + reportID, + reportActions, + transactionThreadReportID, + hasOlderActions, + hasNewerActions, + isOffline, + reportPaginationState, + reportLoadingState, +}: UseMoneyRequestReportPaginationParams): UseMoneyRequestReportPaginationResult { + const {loadOlderChats, loadNewerChats} = useLoadReportActions({ + reportID, + reportActions, + allReportActionIDs: reportActions.map((action) => action.reportActionID), + transactionThreadReportID, + hasOlderActions, + hasNewerActions, + newestFetchedReportActionID: reportPaginationState?.newestFetchedReportActionID, + }); + + const hasFinishedInitialLoad = reportLoadingState?.isLoadingInitialReportActions === false; + const prevNewestFetchedIDRef = useRef(undefined); + useEffect(() => { + if (!hasFinishedInitialLoad || !hasNewerActions || reportActions.length === 0 || isOffline || reportLoadingState?.isLoadingNewerReportActions) { + return; + } + // Safety guard: if the cursor hasn't advanced since the last call, the server + // isn't returning new data. Stop to prevent an infinite request loop. + const currentCursor = reportPaginationState?.newestFetchedReportActionID; + if (prevNewestFetchedIDRef.current !== undefined && prevNewestFetchedIDRef.current === currentCursor) { + return; + } + prevNewestFetchedIDRef.current = currentCursor; + loadNewerChats(false); + }, [ + hasFinishedInitialLoad, + reportActions.length, + hasNewerActions, + isOffline, + reportLoadingState?.isLoadingNewerReportActions, + reportPaginationState?.newestFetchedReportActionID, + loadNewerChats, + ]); + + // Backfill loop: the backend prioritizes IOU actions in OpenReport/GetNewerActions for money + // request reports, which can leave non-IOU chat messages in a gap between the IOU-biased cursor + // and older messages. After auto-pagination finishes, walk backwards from the IOU cursor using + // getOlderActions. Each response advances oldestFetchedReportActionID so the next call picks up + // where the previous one left off, until the cursor stops advancing (gap filled). + const prevBackfillCursorRef = useRef(undefined); + const isBackfillingRef = useRef(false); + useEffect(() => { + if (!hasFinishedInitialLoad || isOffline || hasNewerActions || reportLoadingState?.isLoadingNewerReportActions || reportLoadingState?.isLoadingOlderReportActions) { + return; + } + + if (!isBackfillingRef.current) { + const hasIOUActions = reportActions.some((action) => isMoneyRequestAction(action)); + if (!hasIOUActions || reportActions.length < BACKFILL_MIN_ACTIONS_THRESHOLD || !reportPaginationState?.newestFetchedReportActionID) { + return; + } + } + + const cursor = isBackfillingRef.current ? reportPaginationState?.oldestFetchedReportActionID : reportPaginationState?.newestFetchedReportActionID; + if (!cursor) { + return; + } + + if (prevBackfillCursorRef.current === cursor) { + return; + } + + isBackfillingRef.current = true; + prevBackfillCursorRef.current = cursor; + const handle = TransitionTracker.runAfterTransitions({callback: () => getOlderActions(reportID, cursor)}); + + return () => handle.cancel(); + }, [ + hasFinishedInitialLoad, + isOffline, + hasNewerActions, + reportLoadingState?.isLoadingNewerReportActions, + reportLoadingState?.isLoadingOlderReportActions, + reportPaginationState?.newestFetchedReportActionID, + reportPaginationState?.oldestFetchedReportActionID, + reportActions, + reportID, + ]); + + const onStartReached = () => { + if (!isSearchTopmostFullScreenRoute()) { + loadOlderChats(false); + return; + } + TransitionTracker.runAfterTransitions({ + callback: () => loadOlderChats(false), + }); + }; + + const onEndReached = () => { + loadNewerChats(false); + }; + + return {onStartReached, onEndReached}; +} + +export default useMoneyRequestReportPagination; diff --git a/tests/unit/useMoneyRequestReportPaginationTest.ts b/tests/unit/useMoneyRequestReportPaginationTest.ts new file mode 100644 index 000000000000..202aa1349171 --- /dev/null +++ b/tests/unit/useMoneyRequestReportPaginationTest.ts @@ -0,0 +1,301 @@ +import {renderHook} from '@testing-library/react-native'; + +import useMoneyRequestReportPagination from '@components/MoneyRequestReportView/useMoneyRequestReportPagination'; + +import CONST from '@src/CONST'; +import type * as OnyxTypes from '@src/types/onyx'; + +import createRandomReportAction from '../utils/collections/reportActions'; + +const REPORT_ID = '1'; + +const mockLoadOlderChats = jest.fn(); +const mockLoadNewerChats = jest.fn(); +jest.mock('@hooks/useLoadReportActions', () => ({ + __esModule: true, + default: () => ({loadOlderChats: mockLoadOlderChats, loadNewerChats: mockLoadNewerChats}), +})); + +jest.mock('@userActions/Report', () => ({ + getOlderActions: jest.fn(), +})); + +let mockIsSearchTopmostFullScreenRoute = false; +jest.mock('@navigation/helpers/isSearchTopmostFullScreenRoute', () => ({ + __esModule: true, + default: () => mockIsSearchTopmostFullScreenRoute, +})); + +// `runAfterTransitions` normally defers to the end of an in-flight navigation animation. Run the +// callback synchronously here and record the cancel handle so tests can assert cleanup. +const mockCancel = jest.fn(); +const mockRunAfterTransitions = jest.fn(({callback}: {callback: () => void}) => { + callback(); + return {cancel: mockCancel}; +}); +jest.mock('@libs/Navigation/TransitionTracker', () => ({ + __esModule: true, + default: { + runAfterTransitions: (options: {callback: () => void}) => mockRunAfterTransitions(options), + }, +})); + +const {getOlderActions: mockGetOlderActions} = jest.requireMock<{getOlderActions: jest.Mock}>('@userActions/Report'); + +function makeAction(reportActionID: string, actionName: OnyxTypes.ReportAction['actionName'] = CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT): OnyxTypes.ReportAction { + return {...createRandomReportAction(Number(reportActionID)), reportActionID, actionName} as OnyxTypes.ReportAction; +} + +/** A page of actions large enough to pass the backfill threshold, with one IOU action in it. */ +function makeActionsAboveBackfillThreshold(): OnyxTypes.ReportAction[] { + const actions = Array.from({length: 60}, (value, index) => makeAction(`${index + 1}`)); + actions[0] = makeAction('1', CONST.REPORT.ACTIONS.TYPE.IOU); + return actions; +} + +type Params = Parameters[0]; + +function renderPagination(params: Partial = {}) { + const initialParams: Params = { + reportID: REPORT_ID, + reportActions: [makeAction('1')], + transactionThreadReportID: undefined, + hasOlderActions: true, + hasNewerActions: false, + isOffline: false, + reportPaginationState: undefined, + reportLoadingState: {isLoadingInitialReportActions: false}, + ...params, + }; + + return renderHook((props: Params) => useMoneyRequestReportPagination(props), {initialProps: initialParams}); +} + +describe('useMoneyRequestReportPagination', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockIsSearchTopmostFullScreenRoute = false; + }); + + describe('auto-load of newer actions', () => { + it('should load newer actions once the initial load has finished and newer actions exist', () => { + renderPagination({hasNewerActions: true, reportPaginationState: {newestFetchedReportActionID: 'a'}}); + + expect(mockLoadNewerChats).toHaveBeenCalledTimes(1); + expect(mockLoadNewerChats).toHaveBeenCalledWith(false); + }); + + it('should not load newer actions while the initial load is still in flight', () => { + renderPagination({hasNewerActions: true, reportLoadingState: {isLoadingInitialReportActions: true}}); + + expect(mockLoadNewerChats).not.toHaveBeenCalled(); + }); + + it('should not load newer actions while offline', () => { + renderPagination({hasNewerActions: true, isOffline: true}); + + expect(mockLoadNewerChats).not.toHaveBeenCalled(); + }); + + it('should not load newer actions while a newer-actions request is already in flight', () => { + renderPagination({hasNewerActions: true, reportLoadingState: {isLoadingInitialReportActions: false, isLoadingNewerReportActions: true}}); + + expect(mockLoadNewerChats).not.toHaveBeenCalled(); + }); + + it('should not load newer actions when there are no loaded actions yet', () => { + renderPagination({hasNewerActions: true, reportActions: []}); + + expect(mockLoadNewerChats).not.toHaveBeenCalled(); + }); + + it('should keep loading newer actions while the newest-fetched cursor advances', () => { + const {rerender} = renderPagination({hasNewerActions: true, reportPaginationState: {newestFetchedReportActionID: 'a'}}); + expect(mockLoadNewerChats).toHaveBeenCalledTimes(1); + + rerender({ + reportID: REPORT_ID, + reportActions: [makeAction('1')], + transactionThreadReportID: undefined, + hasOlderActions: true, + hasNewerActions: true, + isOffline: false, + reportPaginationState: {newestFetchedReportActionID: 'b'}, + reportLoadingState: {isLoadingInitialReportActions: false}, + }); + + expect(mockLoadNewerChats).toHaveBeenCalledTimes(2); + }); + + it('should stop loading newer actions once the newest-fetched cursor stops advancing', () => { + const props: Params = { + reportID: REPORT_ID, + reportActions: [makeAction('1')], + transactionThreadReportID: undefined, + hasOlderActions: true, + hasNewerActions: true, + isOffline: false, + reportPaginationState: {newestFetchedReportActionID: 'a'}, + reportLoadingState: {isLoadingInitialReportActions: false}, + }; + const {rerender} = renderPagination(props); + expect(mockLoadNewerChats).toHaveBeenCalledTimes(1); + + // The same cursor coming back means the server has no more newer data to give. + rerender({...props, reportActions: [makeAction('1'), makeAction('2')]}); + + expect(mockLoadNewerChats).toHaveBeenCalledTimes(1); + }); + }); + + describe('IOU backfill', () => { + it('should backfill from the newest-fetched cursor once auto-pagination has finished', () => { + renderPagination({reportActions: makeActionsAboveBackfillThreshold(), reportPaginationState: {newestFetchedReportActionID: 'newest'}}); + + expect(mockGetOlderActions).toHaveBeenCalledTimes(1); + expect(mockGetOlderActions).toHaveBeenCalledWith(REPORT_ID, 'newest'); + }); + + it('should not backfill a report with fewer actions than the page-size threshold', () => { + renderPagination({reportActions: [makeAction('1', CONST.REPORT.ACTIONS.TYPE.IOU)], reportPaginationState: {newestFetchedReportActionID: 'newest'}}); + + expect(mockGetOlderActions).not.toHaveBeenCalled(); + }); + + it('should not backfill a report with no IOU actions', () => { + const actions = Array.from({length: 60}, (value, index) => makeAction(`${index + 1}`)); + renderPagination({reportActions: actions, reportPaginationState: {newestFetchedReportActionID: 'newest'}}); + + expect(mockGetOlderActions).not.toHaveBeenCalled(); + }); + + it('should not backfill while newer actions are still pending', () => { + renderPagination({reportActions: makeActionsAboveBackfillThreshold(), hasNewerActions: true, reportPaginationState: {newestFetchedReportActionID: 'newest'}}); + + expect(mockGetOlderActions).not.toHaveBeenCalled(); + }); + + it('should not backfill while offline', () => { + renderPagination({reportActions: makeActionsAboveBackfillThreshold(), isOffline: true, reportPaginationState: {newestFetchedReportActionID: 'newest'}}); + + expect(mockGetOlderActions).not.toHaveBeenCalled(); + }); + + it('should not backfill while an older-actions request is already in flight', () => { + renderPagination({ + reportActions: makeActionsAboveBackfillThreshold(), + reportPaginationState: {newestFetchedReportActionID: 'newest'}, + reportLoadingState: {isLoadingInitialReportActions: false, isLoadingOlderReportActions: true}, + }); + + expect(mockGetOlderActions).not.toHaveBeenCalled(); + }); + + it('should walk backwards from the oldest-fetched cursor on the passes after the first', () => { + const reportActions = makeActionsAboveBackfillThreshold(); + const props: Params = { + reportID: REPORT_ID, + reportActions, + transactionThreadReportID: undefined, + hasOlderActions: true, + hasNewerActions: false, + isOffline: false, + reportPaginationState: {newestFetchedReportActionID: 'newest'}, + reportLoadingState: {isLoadingInitialReportActions: false}, + }; + const {rerender} = renderPagination(props); + expect(mockGetOlderActions).toHaveBeenNthCalledWith(1, REPORT_ID, 'newest'); + + rerender({...props, reportPaginationState: {newestFetchedReportActionID: 'newest', oldestFetchedReportActionID: 'older-1'}}); + expect(mockGetOlderActions).toHaveBeenNthCalledWith(2, REPORT_ID, 'older-1'); + + rerender({...props, reportPaginationState: {newestFetchedReportActionID: 'newest', oldestFetchedReportActionID: 'older-2'}}); + expect(mockGetOlderActions).toHaveBeenNthCalledWith(3, REPORT_ID, 'older-2'); + expect(mockGetOlderActions).toHaveBeenCalledTimes(3); + }); + + it('should stop backfilling once the oldest-fetched cursor stops advancing', () => { + const reportActions = makeActionsAboveBackfillThreshold(); + const props: Params = { + reportID: REPORT_ID, + reportActions, + transactionThreadReportID: undefined, + hasOlderActions: true, + hasNewerActions: false, + isOffline: false, + reportPaginationState: {newestFetchedReportActionID: 'newest'}, + reportLoadingState: {isLoadingInitialReportActions: false}, + }; + const {rerender} = renderPagination(props); + expect(mockGetOlderActions).toHaveBeenCalledTimes(1); + + rerender({...props, reportPaginationState: {newestFetchedReportActionID: 'newest', oldestFetchedReportActionID: 'oldest'}}); + expect(mockGetOlderActions).toHaveBeenCalledTimes(2); + + // Re-run the effect with a fresh actions array but the same cursor: the gap is filled, so no further request. + rerender({...props, reportActions: [...reportActions], reportPaginationState: {newestFetchedReportActionID: 'newest', oldestFetchedReportActionID: 'oldest'}}); + + expect(mockGetOlderActions).toHaveBeenCalledTimes(2); + }); + + it('should cancel the pending transition callback on unmount', () => { + const {unmount} = renderPagination({reportActions: makeActionsAboveBackfillThreshold(), reportPaginationState: {newestFetchedReportActionID: 'newest'}}); + + unmount(); + + expect(mockCancel).toHaveBeenCalled(); + }); + + // Each renderHook call gets fresh refs, so this does not exercise the `key={reportID}` + // remount contract in `MoneyRequestReportActionsList`. What it does guard is the cursor + // refs being hoisted to module scope, which would leak one report's cursor into the next. + it('should start a fresh backfill pass from the newest-fetched cursor on a fresh mount', () => { + const reportActions = makeActionsAboveBackfillThreshold(); + const {unmount} = renderPagination({ + reportID: 'A', + reportActions, + reportPaginationState: {newestFetchedReportActionID: 'newest-a', oldestFetchedReportActionID: 'oldest-a'}, + }); + expect(mockGetOlderActions).toHaveBeenCalledWith('A', 'newest-a'); + unmount(); + + renderPagination({ + reportID: 'B', + reportActions, + reportPaginationState: {newestFetchedReportActionID: 'newest-b', oldestFetchedReportActionID: 'oldest-b'}, + }); + + expect(mockGetOlderActions).toHaveBeenLastCalledWith('B', 'newest-b'); + expect(mockGetOlderActions).toHaveBeenCalledTimes(2); + }); + }); + + describe('list edge handlers', () => { + it('should load older actions immediately when the search route is not topmost', () => { + const {result} = renderPagination(); + + result.current.onStartReached(); + + expect(mockLoadOlderChats).toHaveBeenCalledWith(false); + expect(mockRunAfterTransitions).not.toHaveBeenCalled(); + }); + + it('should defer loading older actions until transitions finish when the search route is topmost', () => { + mockIsSearchTopmostFullScreenRoute = true; + const {result} = renderPagination(); + + result.current.onStartReached(); + + expect(mockRunAfterTransitions).toHaveBeenCalledTimes(1); + expect(mockLoadOlderChats).toHaveBeenCalledWith(false); + }); + + it('should load newer actions when the list end is reached', () => { + const {result} = renderPagination(); + + result.current.onEndReached(); + + expect(mockLoadNewerChats).toHaveBeenCalledWith(false); + }); + }); +});