diff --git a/src/pages/inbox/ChatReportActions.tsx b/src/pages/inbox/ChatReportActions.tsx
new file mode 100644
index 000000000000..2c98a0405ff4
--- /dev/null
+++ b/src/pages/inbox/ChatReportActions.tsx
@@ -0,0 +1,48 @@
+/** Keep chat effects unmounted during app loading, except for the Concierge welcome screen. */
+import {useIsAppLoadPending} from '@hooks/useInFlightRequests';
+import useNetwork from '@hooks/useNetwork';
+import useOnyx from '@hooks/useOnyx';
+
+import {isConciergeChatReport} from '@libs/ReportUtils';
+
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {Report} from '@src/types/onyx';
+
+import React from 'react';
+
+import ReportActionsList from './report/ReportActionsList';
+import ReportActionsLoadingSkeleton from './report/ReportActionsLoadingSkeleton';
+import UserTypingEventListener from './report/UserTypingEventListener';
+
+type ChatReportActionsProps = {
+ report: Report;
+};
+
+function ChatReportActions({report}: ChatReportActionsProps) {
+ const {isOffline} = useNetwork();
+ const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID);
+ const isAppLoadPending = useIsAppLoadPending();
+ const isConciergeMainDM = isConciergeChatReport(report, conciergeReportID);
+
+ if (isAppLoadPending && !isOffline && !isConciergeMainDM) {
+ return (
+
+ );
+ }
+
+ return (
+ <>
+
+
+ >
+ );
+}
+
+export default ChatReportActions;
diff --git a/src/pages/inbox/MoneyRequestReportActions.tsx b/src/pages/inbox/MoneyRequestReportActions.tsx
new file mode 100644
index 000000000000..cafd23019985
--- /dev/null
+++ b/src/pages/inbox/MoneyRequestReportActions.tsx
@@ -0,0 +1,59 @@
+/** Only money-request and invoice reports need transactions to select their actions layout. */
+import MoneyRequestReportActionsList from '@components/MoneyRequestReportView/MoneyRequestReportActionsList';
+
+import {useIsReportLoadPending} from '@hooks/useInFlightRequests';
+import useNetwork from '@hooks/useNetwork';
+import useOnyx from '@hooks/useOnyx';
+import usePaginatedReportActions from '@hooks/usePaginatedReportActions';
+import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection';
+
+import {getAllNonDeletedTransactions, shouldDisplayReportTableView, shouldWaitForTransactions as shouldWaitForTransactionsUtil} from '@libs/MoneyRequestReportUtils';
+
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {Report} from '@src/types/onyx';
+
+import React from 'react';
+
+import ChatReportActions from './ChatReportActions';
+import ReportActionsLoadingSkeleton from './report/ReportActionsLoadingSkeleton';
+
+const defaultReportLoadingState = {
+ hasOnceLoadedReportActions: false,
+ isLoadingInitialReportActions: true,
+ isLoadingOlderReportActions: false,
+ hasLoadingOlderReportActionsError: false,
+ isLoadingNewerReportActions: false,
+ hasLoadingNewerReportActionsError: false,
+};
+
+type MoneyRequestReportActionsProps = {
+ report: Report;
+};
+
+function MoneyRequestReportActions({report}: MoneyRequestReportActionsProps) {
+ const {isOffline} = useNetwork();
+ const [reportLoadingState = defaultReportLoadingState] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${report.reportID}`);
+ const isReportLoadPending = useIsReportLoadPending(report.reportID);
+ const {reportActions} = usePaginatedReportActions(report.reportID);
+ const allReportTransactions = useReportTransactionsCollection(report.reportID);
+ const reportTransactions = getAllNonDeletedTransactions(allReportTransactions, reportActions, isOffline, true);
+ const shouldWaitForTransactions = shouldWaitForTransactionsUtil(report, reportTransactions, reportLoadingState, isReportLoadPending, isOffline);
+
+ if (shouldWaitForTransactions) {
+ return (
+
+ );
+ }
+
+ if (shouldDisplayReportTableView(report, reportTransactions)) {
+ return ;
+ }
+
+ return ;
+}
+
+export default MoneyRequestReportActions;
diff --git a/src/pages/inbox/ReportActions.tsx b/src/pages/inbox/ReportActions.tsx
index 633e720169a6..b8c67d5d7271 100644
--- a/src/pages/inbox/ReportActions.tsx
+++ b/src/pages/inbox/ReportActions.tsx
@@ -1,15 +1,7 @@
-import MoneyRequestReportActionsList from '@components/MoneyRequestReportView/MoneyRequestReportActionsList';
-import NavigationDeferredMount from '@components/NavigationDeferredMount';
-
-import {useIsAppLoadPending, useIsReportLoadPending} from '@hooks/useInFlightRequests';
-import useNetwork from '@hooks/useNetwork';
import useOnyx from '@hooks/useOnyx';
-import usePaginatedReportActions from '@hooks/usePaginatedReportActions';
-import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection';
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
-import {getAllNonDeletedTransactions, shouldDisplayReportTableView, shouldWaitForTransactions as shouldWaitForTransactionsUtil} from '@libs/MoneyRequestReportUtils';
-import {isConciergeChatReport, isInvoiceReport, isMoneyRequestReport} from '@libs/ReportUtils';
+import {isInvoiceReport, isMoneyRequestReport} from '@libs/ReportUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
@@ -19,55 +11,17 @@ import React from 'react';
import type ReportScreenNavigationProps from './types';
-import ReportActionsList from './report/ReportActionsList';
+import ChatReportActions from './ChatReportActions';
+import MoneyRequestReportActions from './MoneyRequestReportActions';
import ReportActionsLoadingSkeleton from './report/ReportActionsLoadingSkeleton';
-import UserTypingEventListener from './report/UserTypingEventListener';
-
-const defaultReportLoadingState = {
- hasOnceLoadedReportActions: false,
- isLoadingInitialReportActions: true,
- isLoadingOlderReportActions: false,
- hasLoadingOlderReportActionsError: false,
- isLoadingNewerReportActions: false,
- hasLoadingNewerReportActionsError: false,
-};
-/**
- * Route-only orchestrator for the report actions surface. It owns the coarse branching only —
- * skeleton vs. money-request table view vs. the chat list — and subscribes to just what those
- * branches need. All heavy data derivation lives inside the hook-driven `ReportActionsList` body,
- * which is mounted only for the chat path so its hooks/effects never run while a skeleton shows.
- */
+/** Choose the report surface without mounting transaction-based layout selection for ordinary chats. */
function ReportActions() {
const route = useRoute();
const reportIDFromRoute = getNonEmptyStringOnyxID(route.params?.reportID);
-
- const {isOffline} = useNetwork();
-
const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportIDFromRoute}`);
- const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID);
- const [reportLoadingState = defaultReportLoadingState] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${reportIDFromRoute}`);
- const isAppLoadPending = useIsAppLoadPending();
- const isReportLoadPending = useIsReportLoadPending(reportIDFromRoute);
- const {reportActions} = usePaginatedReportActions(reportIDFromRoute);
-
- const allReportTransactions = useReportTransactionsCollection(reportIDFromRoute);
- const reportTransactions = getAllNonDeletedTransactions(allReportTransactions, reportActions, isOffline, true);
-
- const isMoneyRequestOrInvoiceReport = isMoneyRequestReport(report) || isInvoiceReport(report);
- const shouldWaitForTransactions = shouldWaitForTransactionsUtil(report, reportTransactions, reportLoadingState, isReportLoadPending, isOffline);
- const shouldDisplayMoneyRequestActionsList = isMoneyRequestOrInvoiceReport && shouldDisplayReportTableView(report, reportTransactions);
- // The app-load skeleton is hoisted out of the body so the body's data hooks/effects never run
- // during app boot. It only applies on the chat path (after the skeleton and money-request
- // branches below) — matching the previous behavior, where this skeleton lived inside the
- // chat-only ReportActionsView.
- //
- // Concierge is excluded so its welcome screen shows while the app loads.
- const isConciergeMainDM = isConciergeChatReport(report, conciergeReportID);
- const shouldShowAppLoadSkeleton = isAppLoadPending && !isOffline && !!report && !shouldWaitForTransactions && !shouldDisplayMoneyRequestActionsList && !isConciergeMainDM;
-
- if (!report || shouldWaitForTransactions) {
+ if (!report) {
return (
;
- }
-
- if (shouldShowAppLoadSkeleton) {
- return (
-
- );
- }
-
- return (
- <>
-
-
- >
- );
-}
-
-type ReportActionsWithInboxTabDeferredMountProps = {
- /** The report ID used by the deferred loading skeleton */
- reportID: string | undefined;
-
- /** Whether to defer mounting report actions during the initial Inbox tab navigation */
- shouldDefer: boolean;
-};
-
-function ReportActionsWithInboxTabDeferredMount({reportID, shouldDefer}: ReportActionsWithInboxTabDeferredMountProps) {
- if (!shouldDefer) {
- return ;
+ if (isMoneyRequestReport(report) || isInvoiceReport(report)) {
+ return ;
}
- return (
-
- }
- >
-
-
- );
+ return ;
}
-export {ReportActionsWithInboxTabDeferredMount};
export default ReportActions;
diff --git a/src/pages/inbox/ReportActionsWithInboxTabDeferredMount.tsx b/src/pages/inbox/ReportActionsWithInboxTabDeferredMount.tsx
new file mode 100644
index 000000000000..8e141281a433
--- /dev/null
+++ b/src/pages/inbox/ReportActionsWithInboxTabDeferredMount.tsx
@@ -0,0 +1,41 @@
+/** Defer mounting report actions until the initial Inbox tab transition settles. */
+import NavigationDeferredMount from '@components/NavigationDeferredMount';
+
+import CONST from '@src/CONST';
+
+import React from 'react';
+
+import ReportActionsLoadingSkeleton from './report/ReportActionsLoadingSkeleton';
+import ReportActions from './ReportActions';
+
+type ReportActionsWithInboxTabDeferredMountProps = {
+ /** The report ID used by the deferred loading skeleton */
+ reportID: string | undefined;
+
+ /** Whether to defer mounting report actions during the initial Inbox tab navigation */
+ shouldDefer: boolean;
+};
+
+function ReportActionsWithInboxTabDeferredMount({reportID, shouldDefer}: ReportActionsWithInboxTabDeferredMountProps) {
+ if (!shouldDefer) {
+ return ;
+ }
+
+ return (
+
+ }
+ >
+
+
+ );
+}
+
+export default ReportActionsWithInboxTabDeferredMount;
diff --git a/src/pages/inbox/ReportScreen.tsx b/src/pages/inbox/ReportScreen.tsx
index ab67b595b3e2..89abccfddf72 100644
--- a/src/pages/inbox/ReportScreen.tsx
+++ b/src/pages/inbox/ReportScreen.tsx
@@ -39,7 +39,7 @@ import ReportActionCompose from './report/ReportActionCompose/ReportActionCompos
import {ReportActionEditMessageContextProvider, ReportScreenEditMessageProviderWithTransactionThread} from './report/ReportActionEditMessageContext';
import ReportFooter from './report/ReportFooter';
import useClearReportActionDraftsOnReportChange from './report/useClearReportActionDraftsOnReportChange';
-import {ReportActionsWithInboxTabDeferredMount} from './ReportActions';
+import ReportActionsWithInboxTabDeferredMount from './ReportActionsWithInboxTabDeferredMount';
import ReportDragAndDropProvider from './ReportDragAndDropProvider';
import ReportFetchHandler from './ReportFetchHandler';
import ReportHeader from './ReportHeader';
diff --git a/tests/ui/ReportActionsTest.tsx b/tests/ui/ReportActionsTest.tsx
index 6099444f6a52..d69d04cb0842 100644
--- a/tests/ui/ReportActionsTest.tsx
+++ b/tests/ui/ReportActionsTest.tsx
@@ -1,32 +1,37 @@
-import {render, screen} from '@testing-library/react-native';
+import {act, render, renderHook, screen} from '@testing-library/react-native';
import MoneyRequestReportActionsList from '@components/MoneyRequestReportView/MoneyRequestReportActionsList';
+import {useIsAppLoadPending, useIsReportLoadPending} from '@hooks/useInFlightRequests';
import useNetwork from '@hooks/useNetwork';
import useOnyx from '@hooks/useOnyx';
import usePaginatedReportActions from '@hooks/usePaginatedReportActions';
import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection';
-import {shouldDisplayReportTableView, shouldWaitForTransactions} from '@libs/MoneyRequestReportUtils';
-import {isConciergeChatReport, isInvoiceReport, isMoneyRequestReport} from '@libs/ReportUtils';
+import {WRITE_COMMANDS} from '@libs/API/types';
import markOpenReportEnd from '@libs/telemetry/markOpenReportEnd';
import ReportActionsListBody from '@pages/inbox/report/ReportActionsList';
import UserTypingEventListener from '@pages/inbox/report/UserTypingEventListener';
import ReportActions from '@pages/inbox/ReportActions';
+import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type * as OnyxTypes from '@src/types/onyx';
-/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */
import type * as ReactNavigation from '@react-navigation/native';
+import type {OnyxKey, OnyxValue} from 'react-native-onyx';
+import {useRoute} from '@react-navigation/native';
import React from 'react';
+import {View} from 'react-native';
import Onyx from 'react-native-onyx';
+import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct';
const REPORT_ID = '123';
+const OTHER_REPORT_ID = '456';
jest.mock('@react-navigation/native', () => {
const actualNav = jest.requireActual('@react-navigation/native');
@@ -41,40 +46,22 @@ jest.mock('@hooks/useOnyx', () => jest.fn());
jest.mock('@hooks/usePaginatedReportActions', () => jest.fn());
jest.mock('@hooks/useReportTransactionsCollection', () => jest.fn());
-// Only the two helpers the orchestrator branches on are mocked, so each branch can be driven
-// deterministically without constructing full transaction fixtures.
-jest.mock('@libs/MoneyRequestReportUtils', () => ({
- getAllNonDeletedTransactions: jest.fn(() => []),
- shouldDisplayReportTableView: jest.fn(() => false),
- shouldWaitForTransactions: jest.fn(() => false),
-}));
-jest.mock('@libs/ReportUtils', () => ({
- isMoneyRequestReport: jest.fn(() => false),
- isInvoiceReport: jest.fn(() => false),
- isConciergeChatReport: jest.fn(() => false),
-}));
-
-// The children are mocked so these tests assert only the orchestrator's branching decision, not the
-// children's internals (the body has its own dedicated coverage). The skeleton view is kept real so
-// we can observe it via its testID.
+// Keep the routing, transaction filtering and loading predicates real. Only the expensive leaf
+// lists are mocked so assertions describe which surface the user receives.
jest.mock('@components/MoneyRequestReportView/MoneyRequestReportActionsList', () => jest.fn(() => null));
jest.mock('@pages/inbox/report/ReportActionsList', () => jest.fn(() => null));
jest.mock('@pages/inbox/report/UserTypingEventListener', () => jest.fn(() => null));
jest.mock('@libs/telemetry/markOpenReportEnd', () => jest.fn());
-const mockUseNetwork = useNetwork as jest.MockedFunction;
-const mockUseOnyx = useOnyx as jest.MockedFunction;
-const mockUsePaginatedReportActions = usePaginatedReportActions as jest.MockedFunction;
-const mockUseReportTransactionsCollection = useReportTransactionsCollection as jest.MockedFunction;
-const mockShouldDisplayReportTableView = shouldDisplayReportTableView as jest.MockedFunction;
-const mockShouldWaitForTransactions = shouldWaitForTransactions as jest.MockedFunction;
-const mockIsMoneyRequestReport = isMoneyRequestReport as jest.MockedFunction;
-const mockIsInvoiceReport = isInvoiceReport as jest.MockedFunction;
-const mockIsConciergeChatReport = isConciergeChatReport as jest.MockedFunction;
-const mockMoneyRequestList = MoneyRequestReportActionsList as jest.MockedFunction;
-const mockReportActionsListBody = ReportActionsListBody as jest.MockedFunction;
-const mockUserTypingEventListener = UserTypingEventListener as jest.MockedFunction;
-const mockMarkOpenReportEnd = markOpenReportEnd as jest.MockedFunction;
+const mockUseNetwork = jest.mocked(useNetwork);
+const mockUseOnyx = jest.mocked(useOnyx);
+const mockUseRoute = jest.mocked(useRoute);
+const mockUsePaginatedReportActions = jest.mocked(usePaginatedReportActions);
+const mockUseReportTransactionsCollection = jest.mocked(useReportTransactionsCollection);
+const mockMoneyRequestList = jest.mocked(MoneyRequestReportActionsList);
+const mockReportActionsListBody = jest.mocked(ReportActionsListBody);
+const mockUserTypingEventListener = jest.mocked(UserTypingEventListener);
+const mockMarkOpenReportEnd = jest.mocked(markOpenReportEnd);
const defaultPaginatedReportActionsResult: ReturnType = {
reportActions: [],
@@ -88,153 +75,590 @@ const defaultPaginatedReportActionsResult: ReturnType;
+const moneyReportTypes = [CONST.REPORT.TYPE.EXPENSE, CONST.REPORT.TYPE.IOU, CONST.REPORT.TYPE.INVOICE];
-/** Builds the keyed useOnyx mock the orchestrator reads: the report, its loading state and the app-load request queue. */
-const setupUseOnyx = (options: {report?: OnyxTypes.Report | undefined; isLoadingApp?: boolean; loadingState?: ReportLoadingStateOverrides} = {}) => {
- // `'report' in options` distinguishes "not passed" (default to mockReport) from an explicit
- // `{report: undefined}` (the report-not-available case) — a destructuring default would swallow the latter.
+type ReportLoadingStateOverrides = Partial;
+
+function setupUseOnyx(
+ options: {
+ report?: OnyxTypes.Report | undefined;
+ isLoadingApp?: boolean;
+ isReportLoadPending?: boolean;
+ loadingState?: ReportLoadingStateOverrides;
+ conciergeReportID?: string;
+ } = {},
+) {
const report = 'report' in options ? options.report : mockReport;
- const isLoadingApp = options.isLoadingApp ?? false;
- const loadingState = options.loadingState ?? {};
- mockUseOnyx.mockImplementation((key: string) => {
- // ReportActions derives app-load state from the request queue via useIsAppLoadPending, which reads these
- // queue keys through selectors that resolve to a boolean. Returning that boolean directly mirrors what
- // useOnyx yields once the selector runs. The legacy IS_LOADING_APP flag is kept for any direct reader.
- if (key === ONYXKEYS.IS_LOADING_APP || key === ONYXKEYS.PERSISTED_REQUESTS || key === ONYXKEYS.PERSISTED_ONGOING_REQUESTS) {
- return [isLoadingApp, {status: 'loaded'}];
- }
- if (key.includes('reportLoadingState')) {
- return [{isLoadingInitialReportActions: false, hasOnceLoadedReportActions: true, ...loadingState}, {status: 'loaded'}];
- }
- if (key === `${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`) {
- return [report, {status: 'loaded'}];
- }
- return [undefined, {status: 'loaded'}];
+ const requests: OnyxTypes.AnyRequest[] = [];
+ if (options.isLoadingApp) {
+ requests.push({command: WRITE_COMMANDS.OPEN_APP});
+ }
+ if (options.isReportLoadPending) {
+ requests.push({command: WRITE_COMMANDS.OPEN_REPORT, data: {reportID: report?.reportID ?? REPORT_ID}});
+ }
+
+ const values: Partial>> = {
+ [ONYXKEYS.IS_LOADING_APP]: options.isLoadingApp ?? false,
+ [ONYXKEYS.PERSISTED_REQUESTS]: requests,
+ [ONYXKEYS.CONCIERGE_REPORT_ID]: options.conciergeReportID,
+ [`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${report?.reportID ?? REPORT_ID}`]: {
+ isLoadingInitialReportActions: false,
+ hasOnceLoadedReportActions: true,
+ ...options.loadingState,
+ },
+ [`${ONYXKEYS.COLLECTION.REPORT}${report?.reportID ?? REPORT_ID}`]: report,
+ };
+
+ mockUseOnyx.mockImplementation((key, onyxOptions) => {
+ // Each key has its own value type; the generic hook mock also has to support selectors
+ // used by the real in-flight request hooks instead of returning a shared loading boolean.
+ const value = values[key];
+ const selectedValue = onyxOptions?.selector ? onyxOptions.selector(value) : value;
+ return [selectedValue ?? undefined, {status: 'loaded'}];
});
-};
+}
+
+function createTransactions(count: number, reportID = REPORT_ID): OnyxTypes.Transaction[] {
+ return Array.from({length: count}, (_, index) => ({
+ transactionID: `${index + 1}`,
+ reportID,
+ amount: 100,
+ currency: 'USD',
+ created: '2023-01-01',
+ merchant: 'Test merchant',
+ }));
+}
+
+function setupTransactions(transactions: OnyxTypes.Transaction[], reportActions: OnyxTypes.ReportAction[] = []) {
+ mockUseReportTransactionsCollection.mockReturnValue(
+ Object.fromEntries(transactions.map((transaction) => [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction])),
+ );
+ mockUsePaginatedReportActions.mockReturnValue({...defaultPaginatedReportActionsResult, reportActions});
+}
+
+function restoreReportDataHooks() {
+ mockUseOnyx.mockImplementation(jest.requireActual<{default: typeof useOnyx}>('@hooks/useOnyx').default);
+ mockUsePaginatedReportActions.mockImplementation(jest.requireActual<{default: typeof usePaginatedReportActions}>('@hooks/usePaginatedReportActions').default);
+ mockUseReportTransactionsCollection.mockImplementation(jest.requireActual<{default: typeof useReportTransactionsCollection}>('@hooks/useReportTransactionsCollection').default);
+ mockReportActionsListBody.mockImplementation(({reportID}) => );
+ mockMoneyRequestList.mockImplementation(() => );
+}
+
+async function seedReportTransactions(reportID: string, transactions: OnyxTypes.Transaction[]) {
+ // Derived values are not registered in this suite, so seed the real key the transaction hook
+ // subscribes to without bringing the application's full derived-value initialization into these tests.
+ await act(async () => {
+ await Onyx.merge(ONYXKEYS.DERIVED.REPORT_TRANSACTIONS_AND_VIOLATIONS, {
+ [reportID]: {transactions: Object.fromEntries(transactions.map((transaction) => [transaction.transactionID, transaction])), violations: {}},
+ });
+ await waitForBatchedUpdates();
+ });
+}
-describe('ReportActions (orchestrator)', () => {
+function createMoneyRequestAction(transaction: OnyxTypes.Transaction): OnyxTypes.ReportAction {
+ return {
+ reportActionID: `${transaction.reportID}-${transaction.transactionID}`,
+ reportID: transaction.reportID,
+ actionName: CONST.REPORT.ACTIONS.TYPE.IOU,
+ actorAccountID: mockReport.ownerAccountID,
+ created: transaction.created,
+ message: {type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, IOUTransactionID: transaction.transactionID, html: 'Created an expense', text: 'Created an expense'},
+ };
+}
+
+describe('ReportActions', () => {
beforeAll(() => {
Onyx.init({keys: ONYXKEYS});
});
beforeEach(() => {
jest.clearAllMocks();
+ mockUseRoute.mockReturnValue({key: 'report-route', name: 'Report', params: {reportID: REPORT_ID}});
mockUseNetwork.mockReturnValue({isOffline: false});
mockUsePaginatedReportActions.mockReturnValue(defaultPaginatedReportActionsResult);
mockUseReportTransactionsCollection.mockReturnValue({});
- mockShouldWaitForTransactions.mockReturnValue(false);
- mockShouldDisplayReportTableView.mockReturnValue(false);
- mockIsMoneyRequestReport.mockReturnValue(false);
- mockIsInvoiceReport.mockReturnValue(false);
- mockIsConciergeChatReport.mockReturnValue(false);
+ mockReportActionsListBody.mockImplementation(() => );
+ mockMoneyRequestList.mockImplementation(() => );
setupUseOnyx();
});
afterEach(async () => {
+ // The request hooks retain observed requests for deferred Onyx flushes. Clear that session
+ // state through the real hooks so a prior pending request cannot affect the next test.
+ setupUseOnyx();
+ const {unmount} = renderHook(() => {
+ useIsAppLoadPending();
+ useIsReportLoadPending(REPORT_ID);
+ useIsReportLoadPending(OTHER_REPORT_ID);
+ });
+ unmount();
await waitForBatchedUpdatesWithAct();
await Onyx.clear();
});
it('renders a skeleton when the report is not available yet', () => {
+ // Given the route exists before its report arrives from Onyx.
setupUseOnyx({report: undefined});
+ // When the report surface mounts before its data is available.
render();
+ // Then neither list can mount with a missing report.
expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy();
expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy();
expect(mockReportActionsListBody).not.toHaveBeenCalled();
expect(mockMoneyRequestList).not.toHaveBeenCalled();
});
- it('renders a skeleton while waiting for transactions', () => {
- mockShouldWaitForTransactions.mockReturnValue(true);
+ it.each(moneyReportTypes)('chooses the %s layout from the real transaction count', (type) => {
+ // Given an empty money report whose initial data has already loaded.
+ setupUseOnyx({report: {...mockReport, type}});
+ const {rerender} = render();
+
+ // When no transactions exist, the empty table is still the correct money surface.
+ // Then no chat body or typing listener should be mounted.
+ expect(mockMoneyRequestList).toHaveBeenCalled();
+ expect(mockReportActionsListBody).not.toHaveBeenCalled();
+ expect(mockUserTypingEventListener).not.toHaveBeenCalled();
+
+ // When a single expense arrives, it uses the chat surface instead of a one-row table.
+ jest.clearAllMocks();
+ setupTransactions(createTransactions(1));
+ setupUseOnyx({report: {...mockReport, type}});
+ rerender();
+
+ // Then the shared body and typing listener receive the current report.
+ expect(mockMoneyRequestList).not.toHaveBeenCalled();
+ expect(mockReportActionsListBody.mock.calls.at(-1)?.at(0)).toEqual(expect.objectContaining({reportID: REPORT_ID}));
+ expect(mockUserTypingEventListener.mock.calls.at(-1)?.at(0)).toEqual(expect.objectContaining({report: expect.objectContaining({type})}));
+
+ // When another expense arrives on the same route, the table must replace the chat surface.
+ jest.clearAllMocks();
+ setupTransactions(createTransactions(2));
+ setupUseOnyx({report: {...mockReport, type}});
+ rerender();
+ // Then the transaction count, not a mocked layout predicate, selects the table.
+ expect(mockMoneyRequestList).toHaveBeenCalled();
+ expect(mockReportActionsListBody).not.toHaveBeenCalled();
+ expect(mockUserTypingEventListener).not.toHaveBeenCalled();
+ expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull();
+ });
+
+ it.each(moneyReportTypes)('waits for missing %s transactions while online', (type) => {
+ // Given the report total proves that an empty local collection is incomplete.
+ setupUseOnyx({report: {...mockReport, type, total: -100}});
+
+ // When the report mounts before its transactions are delivered.
render();
- expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy();
+ // Then a loading skeleton prevents briefly showing the wrong layout.
expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy();
expect(mockReportActionsListBody).not.toHaveBeenCalled();
expect(mockMoneyRequestList).not.toHaveBeenCalled();
});
- it('passes report pending state to transaction readiness', () => {
+ it.each(moneyReportTypes)('waits for the first pending %s report load even with a zero total', (type) => {
+ // Given OpenReport is queued and the report has never loaded its actions.
+ setupUseOnyx({report: {...mockReport, type}, isReportLoadPending: true, loadingState: {hasOnceLoadedReportActions: false}});
+
+ // When the real request selectors scope the load to this report.
render();
- expect(mockShouldWaitForTransactions).toHaveBeenLastCalledWith(mockReport, [], expect.objectContaining({hasOnceLoadedReportActions: true}), false, false);
+ // Then a zero total cannot prematurely reveal an empty table.
+ expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy();
+ expect(mockMoneyRequestList).not.toHaveBeenCalled();
+ expect(mockReportActionsListBody).not.toHaveBeenCalled();
});
- it('renders the money-request table view for a money-request report', () => {
- mockIsMoneyRequestReport.mockReturnValue(true);
- mockShouldDisplayReportTableView.mockReturnValue(true);
+ it('does not treat a stored loading flag as an active report request', () => {
+ // Given a loaded, empty expense report has a stale initial-actions flag but no queued request.
+ setupUseOnyx({report: {...mockReport, type: CONST.REPORT.TYPE.EXPENSE}, loadingState: {isLoadingInitialReportActions: true, hasOnceLoadedReportActions: false}});
+ // When the route mounts after a restart.
render();
+ // Then stale metadata cannot keep the report on a skeleton forever.
expect(mockMoneyRequestList).toHaveBeenCalled();
- expect(mockReportActionsListBody).not.toHaveBeenCalled();
expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull();
});
- it('renders the chat list body and the typing listener for a chat report', () => {
+ it('shows a loaded empty report during a later pending report refresh', () => {
+ // Given the report has loaded before, so an empty collection is meaningful during refresh.
+ setupUseOnyx({report: {...mockReport, type: CONST.REPORT.TYPE.EXPENSE}, isReportLoadPending: true});
+
+ // When a subsequent OpenReport request is still queued.
render();
+ // Then refreshing does not replace the empty table with a skeleton.
+ expect(mockMoneyRequestList).toHaveBeenCalled();
+ expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull();
+ });
+
+ it.each(moneyReportTypes)('shows cached empty %s reports offline instead of waiting for transactions', (type) => {
+ // Given incomplete data cannot be fetched until the device reconnects.
+ mockUseNetwork.mockReturnValue({isOffline: true});
+ setupUseOnyx({report: {...mockReport, type, total: -100}, isReportLoadPending: true, loadingState: {hasOnceLoadedReportActions: false}});
+
+ // When the offline report mounts with no cached transactions.
+ render();
+
+ // Then the user receives the money surface rather than a permanent loader.
+ expect(mockMoneyRequestList).toHaveBeenCalled();
+ expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull();
+ });
+
+ it('does not wait for transactions in an optimistically created report', () => {
+ // Given creation is queued locally and transactions have not reached the report collection.
+ setupUseOnyx({report: {...mockReport, type: CONST.REPORT.TYPE.EXPENSE, total: -100, pendingFields: {createReport: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}}});
+
+ // When the optimistic report opens before its server response.
+ render();
+
+ // Then the optimistic report remains usable without a transaction wait.
+ expect(mockMoneyRequestList).toHaveBeenCalled();
+ expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull();
+ });
+
+ it.each([false, true])('keeps pending transaction deletions in the layout count (offline: %s)', (isOffline) => {
+ // Given a deleting expense must remain visible beside the surviving expense until deletion resolves.
+ const [first, second] = createTransactions(2);
+ setupTransactions([{...first, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}, second]);
+ mockUseNetwork.mockReturnValue({isOffline});
+ setupUseOnyx({report: {...mockReport, type: CONST.REPORT.TYPE.EXPENSE}});
+
+ // When transaction filtering runs with a pending deletion.
+ render();
+
+ // Then the pending row still requires the table rather than the single-expense fallback.
+ expect(mockMoneyRequestList).toHaveBeenCalled();
+ expect(mockReportActionsListBody).not.toHaveBeenCalled();
+ });
+
+ it.each([false, true])('filters deleted action transactions according to connectivity (offline: %s)', (isOffline) => {
+ // Given two transactions, one whose action is blanked while its deletion is queued.
+ const transactions = createTransactions(2);
+ const deletedAction: OnyxTypes.ReportAction = {
+ reportActionID: 'deleted-action',
+ actionName: CONST.REPORT.ACTIONS.TYPE.IOU,
+ created: '2023-01-01',
+ actorAccountID: 123,
+ message: {type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, IOUTransactionID: transactions[0].transactionID, html: '', text: ''},
+ pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE,
+ };
+ setupTransactions(transactions, [deletedAction]);
+ mockUseNetwork.mockReturnValue({isOffline});
+ setupUseOnyx({report: {...mockReport, type: CONST.REPORT.TYPE.EXPENSE}});
+
+ // When the real filter applies the online or offline deletion rules.
+ render();
+
+ // Then offline retains the pending row, while online falls back to the single remaining expense.
+ expect(mockMoneyRequestList.mock.calls.length > 0).toBe(isOffline);
+ expect(mockReportActionsListBody.mock.calls.length > 0).toBe(!isOffline);
+ });
+
+ it('renders the chat list body and typing listener for an ordinary chat', () => {
+ // Given an ordinary chat has no money transactions.
+ setupUseOnyx();
+
+ // When its route mounts.
+ render();
+
+ // Then the shared chat surface receives the report rather than a table.
expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull();
expect(mockMoneyRequestList).not.toHaveBeenCalled();
- expect(mockReportActionsListBody).toHaveBeenCalled();
expect(mockReportActionsListBody.mock.calls.at(-1)?.at(0)).toEqual(expect.objectContaining({reportID: REPORT_ID}));
- expect(mockUserTypingEventListener).toHaveBeenCalled();
expect(mockUserTypingEventListener.mock.calls.at(-1)?.at(0)).toEqual(expect.objectContaining({report: mockReport}));
});
+ it('does not subscribe ordinary chats to money pagination or transaction collections', () => {
+ // Given stale money data must not make an ordinary chat depend on money hooks.
+ setupUseOnyx();
+ setupTransactions(createTransactions(2));
+
+ // When the real route chooses its ordinary chat boundary.
+ render();
+
+ // Then money-only hook work is absent, not merely ignored after it runs.
+ expect(mockReportActionsListBody).toHaveBeenCalled();
+ expect(mockUsePaginatedReportActions).not.toHaveBeenCalled();
+ expect(mockUseReportTransactionsCollection).not.toHaveBeenCalled();
+ });
+
+ it('does not subscribe to money data before the report exists', () => {
+ // Given the route cannot classify a report that has not arrived yet.
+ setupUseOnyx({report: undefined});
+
+ // When it displays the missing-report loader.
+ render();
+
+ // Then no money boundary or its subscriptions may mount speculatively.
+ expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy();
+ expect(mockUsePaginatedReportActions).not.toHaveBeenCalled();
+ expect(mockUseReportTransactionsCollection).not.toHaveBeenCalled();
+ });
+
+ it('does not wait for money data during an ordinary chat report load', () => {
+ // Given an ordinary chat has an initial OpenReport request and a nonzero stored total.
+ setupUseOnyx({report: {...mockReport, total: -100}, isReportLoadPending: true, loadingState: {hasOnceLoadedReportActions: false}});
+
+ // When the chat mounts while only the report request is pending.
+ render();
+
+ // Then money readiness cannot block the chat body or run money subscriptions.
+ expect(mockReportActionsListBody).toHaveBeenCalled();
+ expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull();
+ expect(mockUsePaginatedReportActions).not.toHaveBeenCalled();
+ expect(mockUseReportTransactionsCollection).not.toHaveBeenCalled();
+ });
+
it('renders the app-load skeleton on the chat path and closes the open-report span with warm:false', () => {
+ // Given OpenApp is queued while an ordinary chat is selected.
setupUseOnyx({isLoadingApp: true});
+ // When the report mounts during app initialization.
render();
+ // Then the heavy chat body waits and the loading render closes its report span as cold.
expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy();
expect(mockReportActionsListBody).not.toHaveBeenCalled();
expect(mockMarkOpenReportEnd).toHaveBeenCalledWith(REPORT_ID, mockReport, {warm: false});
});
- it('mounts the body (not the orchestrator app-load skeleton) for a Concierge report during app load', () => {
- mockIsConciergeChatReport.mockReturnValue(true);
- setupUseOnyx({isLoadingApp: true});
+ it('mounts the Concierge chat body during app load', () => {
+ // Given this is the main Concierge DM where the welcome screen must remain available.
+ setupUseOnyx({isLoadingApp: true, conciergeReportID: REPORT_ID});
+ // When OpenApp is still queued.
render();
+ // Then Concierge bypasses the app-load skeleton and its telemetry.
expect(mockReportActionsListBody).toHaveBeenCalled();
expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull();
expect(mockMarkOpenReportEnd).not.toHaveBeenCalled();
});
- it('does not show the app-load skeleton (or fire telemetry) while offline', () => {
+ it('does not exempt a different chat when a Concierge report ID exists', () => {
+ // Given the account has a Concierge DM, but this route is a different ordinary chat.
+ setupUseOnyx({isLoadingApp: true, conciergeReportID: OTHER_REPORT_ID});
+
+ // When app initialization is pending.
+ render();
+
+ // Then only the actual Concierge DM may bypass the app-load skeleton.
+ expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy();
+ expect(mockReportActionsListBody).not.toHaveBeenCalled();
+ });
+
+ it('does not show the app-load skeleton or fire its telemetry while offline', () => {
+ // Given an app-load request cannot resolve on the offline device.
mockUseNetwork.mockReturnValue({isOffline: true});
setupUseOnyx({isLoadingApp: true});
+ // When the cached chat opens.
render();
+ // Then offline use remains available without an app-load skeleton.
expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull();
expect(mockReportActionsListBody).toHaveBeenCalled();
expect(mockMarkOpenReportEnd).not.toHaveBeenCalled();
});
- it('does not show the app-load skeleton for a money-request report', () => {
- mockIsMoneyRequestReport.mockReturnValue(true);
- mockShouldDisplayReportTableView.mockReturnValue(true);
- setupUseOnyx({isLoadingApp: true});
+ it.each(moneyReportTypes)('does not show the app-load skeleton over a %s table', (type) => {
+ // Given complete money-report data can already select its table.
+ setupUseOnyx({report: {...mockReport, type}, isLoadingApp: true});
+ setupTransactions(createTransactions(2));
+ // When app initialization is still queued.
render();
+ // Then the app gate applies only to the chat path.
expect(mockMoneyRequestList).toHaveBeenCalled();
expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull();
expect(mockMarkOpenReportEnd).not.toHaveBeenCalled();
});
+
+ it.each(moneyReportTypes)('keeps the app-load gate for the single-transaction %s chat fallback', (type) => {
+ // Given a single money transaction selects the shared chat surface.
+ setupUseOnyx({report: {...mockReport, type}, isLoadingApp: true});
+ setupTransactions(createTransactions(1));
+
+ // When OpenApp has not completed.
+ render();
+
+ // Then using a money report does not bypass the chat path's app-load gate.
+ expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy();
+ expect(mockMoneyRequestList).not.toHaveBeenCalled();
+ expect(mockReportActionsListBody).not.toHaveBeenCalled();
+ });
+
+ it('changes reports on the same route without retaining the prior money layout', () => {
+ // Given the mounted route currently displays a report with two expenses.
+ setupUseOnyx({report: {...mockReport, type: CONST.REPORT.TYPE.EXPENSE}});
+ setupTransactions(createTransactions(2));
+ const {rerender} = render();
+ expect(mockMoneyRequestList).toHaveBeenCalled();
+
+ // When navigation reuses that route for an ordinary chat.
+ jest.clearAllMocks();
+ mockUseRoute.mockReturnValue({key: 'report-route', name: 'Report', params: {reportID: OTHER_REPORT_ID}});
+ setupUseOnyx({report: {...mockReport, reportID: OTHER_REPORT_ID}});
+ rerender();
+
+ // Then the current report, not the previous transaction count, controls the surface.
+ expect(mockMoneyRequestList).not.toHaveBeenCalled();
+ expect(mockReportActionsListBody.mock.calls.at(-1)?.at(0)).toEqual(expect.objectContaining({reportID: OTHER_REPORT_ID}));
+ expect(mockUserTypingEventListener.mock.calls.at(-1)?.at(0)).toEqual(expect.objectContaining({report: expect.objectContaining({reportID: OTHER_REPORT_ID})}));
+ expect(mockUsePaginatedReportActions).not.toHaveBeenCalled();
+ expect(mockUseReportTransactionsCollection).not.toHaveBeenCalled();
+ });
+
+ it('switches from chat to a loading money report and then back on the same route', () => {
+ // Given an ordinary chat is already mounted on a reusable route.
+ const {rerender} = render();
+ expect(mockReportActionsListBody).toHaveBeenCalled();
+
+ // When the route switches to an expense report whose transactions are missing.
+ jest.clearAllMocks();
+ mockUseRoute.mockReturnValue({key: 'report-route', name: 'Report', params: {reportID: OTHER_REPORT_ID}});
+ setupUseOnyx({report: {...mockReport, reportID: OTHER_REPORT_ID, type: CONST.REPORT.TYPE.EXPENSE, total: -100}});
+ rerender();
+
+ // Then the new report must wait rather than reuse the old chat body.
+ expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy();
+ expect(mockReportActionsListBody).not.toHaveBeenCalled();
+ expect(mockUsePaginatedReportActions).toHaveBeenCalledWith(OTHER_REPORT_ID);
+ expect(mockUseReportTransactionsCollection).toHaveBeenCalledWith(OTHER_REPORT_ID);
+
+ // When navigation reuses the route for the original chat again.
+ jest.clearAllMocks();
+ mockUseRoute.mockReturnValue({key: 'report-route', name: 'Report', params: {reportID: REPORT_ID}});
+ setupUseOnyx();
+ rerender();
+
+ // Then the money loader and its subscriptions do not leak into the chat lifetime.
+ expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull();
+ expect(mockReportActionsListBody).toHaveBeenCalled();
+ expect(mockUsePaginatedReportActions).not.toHaveBeenCalled();
+ expect(mockUseReportTransactionsCollection).not.toHaveBeenCalled();
+ });
+
+ it('hydrates a cold money report through real Onyx subscriptions before choosing its layout', async () => {
+ // Given neither the report nor its transactions have been cached when the route first opens.
+ restoreReportDataHooks();
+ const {unmount} = render();
+ await waitForBatchedUpdatesWithAct();
+ expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy();
+ expect(mockUsePaginatedReportActions).not.toHaveBeenCalled();
+ expect(mockUseReportTransactionsCollection).not.toHaveBeenCalled();
+
+ // When the report arrives before its expenses, real subscription updates must retain the loader.
+ await act(async () => {
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, {...mockReport, type: CONST.REPORT.TYPE.EXPENSE, total: -100});
+ await waitForBatchedUpdates();
+ });
+
+ // Then incomplete cold data must not briefly select an empty table or chat body.
+ expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy();
+ expect(screen.queryByTestId('money-report-table')).toBeNull();
+ expect(screen.queryByTestId(`chat-report-${REPORT_ID}`)).toBeNull();
+
+ // When the first expense and its action hydrate through the keys the real hooks observe.
+ const transactions = createTransactions(1);
+ const action = createMoneyRequestAction(transactions[0]);
+ await act(async () => {
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, {[action.reportActionID]: action});
+ await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_PAGES}${REPORT_ID}`, [[CONST.PAGINATION_START_ID, action.reportActionID, CONST.PAGINATION_END_ID]]);
+ await waitForBatchedUpdates();
+ });
+ await seedReportTransactions(REPORT_ID, transactions);
+
+ // Then the single-expense chat appears without remounting the route or replacing the hooks with mocks.
+ expect(screen.getByTestId(`chat-report-${REPORT_ID}`)).toBeTruthy();
+ expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull();
+ expect(screen.queryByTestId('money-report-table')).toBeNull();
+ expect(mockUsePaginatedReportActions.mock.results.at(-1)?.value).toEqual(expect.objectContaining({reportActions: [action]}));
+ unmount();
+ });
+
+ it('does not carry old report subscriptions through an unavailable destination on the same route', async () => {
+ // Given the current route has a fully cached two-expense report and real money hooks mounted.
+ restoreReportDataHooks();
+ const firstReport = {...mockReport, type: CONST.REPORT.TYPE.EXPENSE, total: -200};
+ const firstTransactions = createTransactions(2);
+ const firstActions = firstTransactions.map(createMoneyRequestAction);
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, firstReport);
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, Object.fromEntries(firstActions.map((action) => [action.reportActionID, action])));
+ await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_PAGES}${REPORT_ID}`, [
+ [CONST.PAGINATION_START_ID, ...firstActions.map((action) => action.reportActionID), CONST.PAGINATION_END_ID],
+ ]);
+ await seedReportTransactions(REPORT_ID, firstTransactions);
+ const {rerender, unmount} = render();
+ await waitForBatchedUpdatesWithAct();
+ expect(screen.getByTestId('money-report-table')).toBeTruthy();
+
+ // When the same route changes to a report whose Onyx key has never been populated.
+ mockUseRoute.mockReturnValue({key: 'report-route', name: 'Report', params: {reportID: OTHER_REPORT_ID}});
+ rerender();
+ await waitForBatchedUpdatesWithAct();
+
+ // Then the previous report and transaction collection must not select the destination's surface.
+ expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy();
+ expect(screen.queryByTestId('money-report-table')).toBeNull();
+ expect(screen.queryByTestId(`chat-report-${REPORT_ID}`)).toBeNull();
+
+ // When the destination report hydrates without any of its own transactions yet.
+ await act(async () => {
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${OTHER_REPORT_ID}`, {...mockReport, reportID: OTHER_REPORT_ID, type: CONST.REPORT.TYPE.EXPENSE, total: -100});
+ await waitForBatchedUpdates();
+ });
+
+ // Then the source's two expenses cannot satisfy the destination's transaction readiness.
+ expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy();
+ expect(screen.queryByTestId('money-report-table')).toBeNull();
+ expect(mockUseReportTransactionsCollection.mock.results.at(-1)?.value).toEqual({});
+
+ // When only the destination's single expense and action arrive.
+ const destinationTransactions = createTransactions(1, OTHER_REPORT_ID).map((transaction) => ({...transaction, transactionID: 'destination-expense'}));
+ const destinationAction = createMoneyRequestAction(destinationTransactions[0]);
+ await act(async () => {
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${OTHER_REPORT_ID}`, {[destinationAction.reportActionID]: destinationAction});
+ await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_PAGES}${OTHER_REPORT_ID}`, [[CONST.PAGINATION_START_ID, destinationAction.reportActionID, CONST.PAGINATION_END_ID]]);
+ await waitForBatchedUpdates();
+ });
+ await seedReportTransactions(OTHER_REPORT_ID, destinationTransactions);
+
+ // Then its own action page and transaction collection select the single-expense chat.
+ expect(screen.getByTestId(`chat-report-${OTHER_REPORT_ID}`)).toBeTruthy();
+ expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull();
+ expect(screen.queryByTestId('money-report-table')).toBeNull();
+ expect(mockUsePaginatedReportActions.mock.results.at(-1)?.value).toEqual(expect.objectContaining({reportActions: [destinationAction]}));
+ expect(mockUseReportTransactionsCollection.mock.results.at(-1)?.value).toEqual({[destinationTransactions[0].transactionID]: destinationTransactions[0]});
+
+ // When late report, action and transaction updates arrive for the source after navigation.
+ await act(async () => {
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, {total: 0, type: CONST.REPORT.TYPE.CHAT});
+ await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, {});
+ await waitForBatchedUpdates();
+ });
+ await seedReportTransactions(REPORT_ID, createTransactions(3));
+
+ // Then the destination's mounted chat and its own action page remain unchanged.
+ expect(screen.getByTestId(`chat-report-${OTHER_REPORT_ID}`)).toBeTruthy();
+ expect(screen.queryByTestId(`chat-report-${REPORT_ID}`)).toBeNull();
+ expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull();
+ expect(screen.queryByTestId('money-report-table')).toBeNull();
+ expect(mockUsePaginatedReportActions.mock.results.at(-1)?.value).toEqual(expect.objectContaining({reportActions: [destinationAction]}));
+ expect(mockUseReportTransactionsCollection.mock.results.at(-1)?.value).toEqual({[destinationTransactions[0].transactionID]: destinationTransactions[0]});
+ unmount();
+ });
});
diff --git a/tests/unit/ReportActionsWithInboxTabDeferredMountTest.tsx b/tests/unit/ReportActionsWithInboxTabDeferredMountTest.tsx
index 557ded97b566..a0e4ba9f5897 100644
--- a/tests/unit/ReportActionsWithInboxTabDeferredMountTest.tsx
+++ b/tests/unit/ReportActionsWithInboxTabDeferredMountTest.tsx
@@ -1,6 +1,9 @@
import {render, screen} from '@testing-library/react-native';
-import {ReportActionsWithInboxTabDeferredMount} from '@pages/inbox/ReportActions';
+import usePaginatedReportActions from '@hooks/usePaginatedReportActions';
+import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection';
+
+import ReportActionsWithInboxTabDeferredMount from '@pages/inbox/ReportActionsWithInboxTabDeferredMount';
import type {ComponentType} from 'react';
@@ -27,8 +30,8 @@ jest.mock(
jest.mock('@hooks/useMarkOpenReportEndOnSkeleton', () => () => undefined);
jest.mock('@hooks/useNetwork', () => () => ({isOffline: false}));
jest.mock('@hooks/useOnyx', () => () => [undefined]);
-jest.mock('@hooks/usePaginatedReportActions', () => () => ({reportActions: {}}));
-jest.mock('@hooks/useReportTransactionsCollection', () => () => ({}));
+jest.mock('@hooks/usePaginatedReportActions', () => jest.fn(() => ({reportActions: []})));
+jest.mock('@hooks/useReportTransactionsCollection', () => jest.fn(() => ({})));
jest.mock('@react-navigation/native', () => {
const navigation = jest.requireActual>('@react-navigation/native');
@@ -49,7 +52,13 @@ jest.mock(
);
describe('ReportActionsWithInboxTabDeferredMount', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
it('renders report actions immediately without an explicit defer request', () => {
+ // Given Inbox has not requested navigation deferral.
+ // When the report surface mounts before its report data arrives.
render(
{
/>,
);
+ // Then missing data uses the report loader without introducing a navigation delay.
expect(screen.getByTestId('report-actions')).toBeOnTheScreen();
expect(screen.queryByTestId('navigation-deferred-mount')).not.toBeOnTheScreen();
});
it('defers report actions when the Inbox navigation requests it', () => {
+ // Given Inbox needs its transition to complete before mounting report work.
+ // When navigation explicitly requests deferral.
render(
{
/>,
);
+ // Then the report boundary, including its money subscriptions, has not mounted yet.
expect(screen.getByTestId('navigation-deferred-mount')).toBeOnTheScreen();
expect(screen.queryByTestId('report-actions')).not.toBeOnTheScreen();
+ expect(usePaginatedReportActions).not.toHaveBeenCalled();
+ expect(useReportTransactionsCollection).not.toHaveBeenCalled();
+ });
+
+ it('mounts report actions when Inbox releases the same deferred route', () => {
+ // Given a report is waiting for an Inbox navigation transition.
+ const {rerender} = render(
+ ,
+ );
+
+ // When Inbox releases deferral without changing the report route.
+ rerender(
+ ,
+ );
+
+ // Then the report data loader replaces navigation deferral immediately.
+ expect(screen.getByTestId('report-actions')).toBeOnTheScreen();
+ expect(screen.queryByTestId('navigation-deferred-mount')).not.toBeOnTheScreen();
});
});