From 4af5484ac1ec167821015eaacf9b097fdf527ff6 Mon Sep 17 00:00:00 2001 From: borys3kk Date: Mon, 6 Jul 2026 14:05:52 +0200 Subject: [PATCH 01/28] rebase against main --- src/ONYXKEYS.ts | 4 - src/components/Search/ChatSearchView.tsx | 6 - .../Search/ExpenseFlatSearchView.tsx | 10 +- .../Search/ExpenseGroupedSearchView.tsx | 29 +- .../Search/ExpenseReportSearchView.tsx | 6 - .../SearchList/BaseSearchList/index.tsx | 5 +- .../Search/SearchList/BaseSearchList/types.ts | 5 +- .../ListItem/GroupChildrenContainer.tsx | 2 - .../ListItem/GroupChildrenContent.tsx | 10 - .../ListItem/TransactionGroupListItem.tsx | 19 - .../Search/SearchList/ListItem/types.ts | 2 - src/components/Search/SearchList/index.tsx | 87 +---- src/components/Search/TaskSearchView.tsx | 6 - src/components/Search/index.tsx | 33 +- src/hooks/useSearchAutoRefetch.ts | 177 +++++++++ src/hooks/useSearchHighlightAndScroll.ts | 362 ------------------ src/languages/de.ts | 2 + src/languages/en.ts | 2 + src/languages/es.ts | 2 + src/languages/fr.ts | 2 + src/languages/it.ts | 2 + src/languages/ja.ts | 2 + src/languages/nl.ts | 2 + src/languages/pl.ts | 2 + src/languages/pt-BR.ts | 2 + src/languages/zh-hans.ts | 2 + .../helpers/cleanupAfterSkipConfirmSubmit.ts | 13 +- .../helpers/navigateAfterExpenseCreate.ts | 328 ++++++++++++++-- src/libs/actions/IOU/MoneyRequest.ts | 5 + src/libs/actions/IOU/MoneyRequestBuilder.ts | 9 + src/libs/actions/IOU/NavigationHelpers.ts | 35 +- src/libs/actions/IOU/PerDiem.ts | 7 +- src/libs/actions/IOU/SendInvoice.ts | 15 +- src/libs/actions/IOU/Split.ts | 36 +- src/libs/actions/IOU/TrackExpense.ts | 64 +++- .../IOU/types/CreateTrackExpenseParams.ts | 8 + src/libs/actions/Transaction.ts | 6 - src/pages/Share/SubmitDetailsPage.tsx | 4 + .../iou/request/step/AmountSubmission.ts | 4 +- ...andleMoneyRequestStepDistanceNavigation.ts | 7 +- .../components/ScanSkipConfirmation.tsx | 9 +- .../step/confirmation/useExpenseSubmission.ts | 86 ++--- tests/actions/IOU/MoneyRequestTest.ts | 29 +- tests/actions/IOU/SplitReportTotalsTest.ts | 29 -- tests/ui/IOURequestStepAmountDraftTest.tsx | 10 +- tests/ui/ScanSkipConfirmationTest.tsx | 8 +- .../unit/Search/ExpenseFlatSearchViewTest.tsx | 1 - .../Search/ExpenseGroupedSearchViewTest.tsx | 8 - .../unit/cleanupAfterSkipConfirmSubmitTest.ts | 12 +- tests/unit/hooks/useExpenseSubmission.test.ts | 35 +- ...ollTest.ts => useSearchAutoRefetchTest.ts} | 173 +-------- 51 files changed, 747 insertions(+), 977 deletions(-) create mode 100644 src/hooks/useSearchAutoRefetch.ts delete mode 100644 src/hooks/useSearchHighlightAndScroll.ts rename tests/unit/{useSearchHighlightAndScrollTest.ts => useSearchAutoRefetchTest.ts} (50%) diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index b8b902b6a410..6088d0163b20 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -762,9 +762,6 @@ const ONYXKEYS = { /** Whether the user has denied the contact import permission prompt */ HAS_DENIED_CONTACT_IMPORT_PROMPT: 'hasDeniedContactImportPrompt', - /** The transaction IDs to be highlighted when opening the Expenses search route page */ - TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE: 'transactionIdsHighlightOnSearchRoute', - /** The report ID to be highlighted when returning to the workspace rooms page */ ROOM_ID_HIGHLIGHT_ON_ROOMS_PAGE: 'roomIDHighlightOnRoomsPage', @@ -1683,7 +1680,6 @@ type OnyxValuesMapping = { [ONYXKEYS.NVP_REPORT_DETAILS_COLUMNS]: string[]; [ONYXKEYS.HAS_DENIED_CONTACT_IMPORT_PROMPT]: boolean | undefined; [ONYXKEYS.PERSONAL_POLICY_ID]: string; - [ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE]: Record>; [ONYXKEYS.ROOM_ID_HIGHLIGHT_ON_ROOMS_PAGE]: string | null; [ONYXKEYS.DOMAIN_GROUP_CREATE_PREFERRED_POLICY_ID]: string | undefined; }; diff --git a/src/components/Search/ChatSearchView.tsx b/src/components/Search/ChatSearchView.tsx index 82344661b64f..f7dd52beac1b 100644 --- a/src/components/Search/ChatSearchView.tsx +++ b/src/components/Search/ChatSearchView.tsx @@ -4,7 +4,6 @@ import type {TransactionPreviewData} from '@libs/actions/Search'; import type {ModifiedMouseEvent} from '@libs/Navigation/helpers/openInternalRouteInNewTab'; import CONST from '@src/CONST'; -import type {Transaction} from '@src/types/onyx'; import type {ForwardedRef} from 'react'; import type {NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle} from 'react-native'; @@ -54,9 +53,6 @@ type ChatSearchViewProps = { /** Whether everything has been loaded (gates the fully-checked select-all state). */ hasLoadedAllTransactions?: boolean; - /** Rows flagged for the post-create highlight animation (feeds BaseSearchList extraData). */ - newTransactions: Transaction[]; - /** The navigation handler for a row tap (owned by the router). */ onSelectRow: (item: SearchListItem, transactionPreviewData?: TransactionPreviewData, event?: ModifiedMouseEvent) => void; @@ -105,7 +101,6 @@ function ChatSearchView({ SearchTableHeader: searchTableHeader, tableHeaderVisible, hasLoadedAllTransactions, - newTransactions, onSelectRow, ListFooterComponent, onEndReached, @@ -196,7 +191,6 @@ function ChatSearchView({ ListFooterComponent={ListFooterComponent} onLayout={onLayout} contentContainerStyle={contentContainerStyle} - newTransactions={newTransactions} /> {modal} diff --git a/src/components/Search/ExpenseFlatSearchView.tsx b/src/components/Search/ExpenseFlatSearchView.tsx index 90a88fbd4e20..b9d1c7ebf85c 100644 --- a/src/components/Search/ExpenseFlatSearchView.tsx +++ b/src/components/Search/ExpenseFlatSearchView.tsx @@ -4,7 +4,7 @@ import type {TransactionPreviewData} from '@libs/actions/Search'; import type {ModifiedMouseEvent} from '@libs/Navigation/helpers/openInternalRouteInNewTab'; import CONST from '@src/CONST'; -import type {CardList, Transaction} from '@src/types/onyx'; +import type {CardList} from '@src/types/onyx'; import type {ForwardedRef} from 'react'; import type {NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle} from 'react-native'; @@ -60,9 +60,6 @@ type ExpenseFlatSearchViewProps = { /** Whether every transaction has been loaded (gates the fully-checked select-all state). */ hasLoadedAllTransactions?: boolean; - /** Transactions flagged for the post-create highlight animation (feeds BaseSearchList extraData). */ - newTransactions: Transaction[]; - /** The navigation/thread-creation handler for a row tap (owned by the router). */ onSelectRow: (item: SearchListItem, transactionPreviewData?: TransactionPreviewData, event?: ModifiedMouseEvent) => void; @@ -102,8 +99,7 @@ const isRowDeleted = (item: SearchListItem) => item.pendingAction === CONST.RED_ * renderer, single-pass visibility/selection counts, and the highlight-scroll imperative handle. * `TransactionListItem` is the only row renderer here and rows always animate, so the * group/sticky/chat/task branches of `SearchList` do not apply. Keyboard navigation is inherited from - * `BaseSearchList`; the post-create highlight stays in the router (the snapshot stamps - * `shouldAnimateInHighlight`, and `newTransactions` flows into `extraData`). + * `BaseSearchList`. */ function ExpenseFlatSearchView({ queryJSON, @@ -117,7 +113,6 @@ function ExpenseFlatSearchView({ SearchTableHeader: searchTableHeader, tableHeaderVisible, hasLoadedAllTransactions, - newTransactions, onSelectRow, ListFooterComponent, onEndReached, @@ -241,7 +236,6 @@ function ExpenseFlatSearchView({ ListFooterComponent={ListFooterComponent} onLayout={onLayout} contentContainerStyle={contentContainerStyle} - newTransactions={newTransactions} isAttendeesEnabledForMovingPolicy={isAttendeesEnabledForMovingPolicy} nonPersonalAndWorkspaceCards={nonPersonalAndWorkspaceCards} /> diff --git a/src/components/Search/ExpenseGroupedSearchView.tsx b/src/components/Search/ExpenseGroupedSearchView.tsx index c52b5e4955d5..6094af105d5f 100644 --- a/src/components/Search/ExpenseGroupedSearchView.tsx +++ b/src/components/Search/ExpenseGroupedSearchView.tsx @@ -13,7 +13,7 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {columnsSelector} from '@src/selectors/AdvancedSearchFiltersForm'; -import type {CardList, Transaction} from '@src/types/onyx'; +import type {CardList} from '@src/types/onyx'; import type {ForwardedRef} from 'react'; import type {NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle} from 'react-native'; @@ -26,7 +26,6 @@ import type {SearchColumnType, SearchQueryJSON, SelectedTransactions} from './ty import useSearchListViewState from './hooks/useSearchListViewState'; import AnimatedExitRow from './primitives/AnimatedExitRow'; import SelectionTopBar from './primitives/SelectionTopBar'; -import {isTransactionMatchWithGroupItem} from './SearchList'; import BaseSearchList from './SearchList/BaseSearchList'; import GroupChildrenContainer from './SearchList/ListItem/GroupChildrenContainer'; import GroupHeader from './SearchList/ListItem/GroupHeader'; @@ -73,9 +72,6 @@ type ExpenseGroupedSearchViewProps = { /** Whether everything has been loaded (gates the fully-checked select-all state). */ hasLoadedAllTransactions?: boolean; - /** Transactions flagged for the post-create highlight animation. */ - newTransactions: Transaction[]; - /** The navigation handler for a row tap (owned by the router). */ onSelectRow: (item: SearchListItem, transactionPreviewData?: TransactionPreviewData, event?: ModifiedMouseEvent) => void; @@ -133,21 +129,6 @@ function buildSplitGroupData(data: SearchListItem[], shouldSplitGroups: boolean) }; } -/** Maps each group's `keyForList` to the id of a just-created transaction inside it, for the highlight animation. */ -function buildNewTransactionIDMap(data: SearchListItem[], newTransactions: Transaction[], groupBy: SearchQueryJSON['groupBy']) { - const map = new Map(); - if (newTransactions.length === 0) { - return map; - } - for (const item of data) { - const matchedTransactionID = newTransactions.find((transaction) => isTransactionMatchWithGroupItem(transaction, item, groupBy))?.transactionID; - if (matchedTransactionID && item.keyForList) { - map.set(item.keyForList, matchedTransactionID); - } - } - return map; -} - /** * The grouped-expense Search list (expense search with a valid group-by). * @@ -170,7 +151,6 @@ function ExpenseGroupedSearchView({ SearchTableHeader: searchTableHeader, tableHeaderVisible, hasLoadedAllTransactions, - newTransactions, onSelectRow, ListFooterComponent, onEndReached, @@ -225,8 +205,6 @@ function ExpenseGroupedSearchView({ scrollToListIndex, } = useSearchListViewState({data, listData, isMobileSelectionModeEnabled, onSelectRow}); - const newTransactionIDByItemKey = buildNewTransactionIDMap(data, newTransactions, groupBy); - // Selection is tracked per child transaction (plus empty groups), so flatten each group's transactions. const groupItems = data.filter(isTransactionGroupListItemType); const flattenedTransactions = groupItems.flatMap((item) => item.transactions); @@ -310,7 +288,6 @@ function ExpenseGroupedSearchView({ if (isGroupChildrenContainerItem(item)) { const originalKey = (item.keyForList ?? '').replace('children_', ''); - const containerNewTransactionID = item.keyForList ? newTransactionIDByItemKey.get(originalKey) : undefined; return ( void; @@ -109,7 +105,6 @@ function ExpenseReportSearchView({ SearchTableHeader: searchTableHeader, tableHeaderVisible, hasLoadedAllTransactions, - newTransactions, onSelectRow, ListFooterComponent, onEndReached, @@ -229,7 +224,6 @@ function ExpenseReportSearchView({ ListFooterComponent={ListFooterComponent} onLayout={onLayout} contentContainerStyle={contentContainerStyle} - newTransactions={newTransactions} /> {modal} diff --git a/src/components/Search/SearchList/BaseSearchList/index.tsx b/src/components/Search/SearchList/BaseSearchList/index.tsx index 5f7273b9f6f8..8def180d07f5 100644 --- a/src/components/Search/SearchList/BaseSearchList/index.tsx +++ b/src/components/Search/SearchList/BaseSearchList/index.tsx @@ -64,7 +64,6 @@ function BaseSearchList({ onLayout, contentContainerStyle, flattenedItemsLength, - newTransactions, isAttendeesEnabledForMovingPolicy, nonPersonalAndWorkspaceCards, stickyHeaderIndices, @@ -171,8 +170,8 @@ function BaseSearchList({ }, [setHasKeyBeenPressed]); const extraData = useMemo( - () => [focusedIndex, columns, newTransactions, nonPersonalAndWorkspaceCards, isAttendeesEnabledForMovingPolicy], - [focusedIndex, columns, newTransactions, nonPersonalAndWorkspaceCards, isAttendeesEnabledForMovingPolicy], + () => [focusedIndex, columns, nonPersonalAndWorkspaceCards, isAttendeesEnabledForMovingPolicy], + [focusedIndex, columns, nonPersonalAndWorkspaceCards, isAttendeesEnabledForMovingPolicy], ); return ( diff --git a/src/components/Search/SearchList/BaseSearchList/types.ts b/src/components/Search/SearchList/BaseSearchList/types.ts index a726f5faffa9..41bb1c9d01e5 100644 --- a/src/components/Search/SearchList/BaseSearchList/types.ts +++ b/src/components/Search/SearchList/BaseSearchList/types.ts @@ -2,7 +2,7 @@ import type {SearchListItem} from '@components/Search/SearchList/ListItem/types' import type {SearchColumnType} from '@components/Search/types'; import type {ExtendedTargetedEvent} from '@components/SelectionList/ListItem/types'; -import type {CardList, Transaction} from '@src/types/onyx'; +import type {CardList} from '@src/types/onyx'; import type {FlashListProps, FlashListRef} from '@shopify/flash-list'; import type {RefObject} from 'react'; @@ -32,9 +32,6 @@ type BaseSearchListProps = Pick< /** The columns that might change to trigger re-render via extraData */ columns: SearchColumnType[]; - /** The transactions that might trigger re-render via extraData */ - newTransactions: Transaction[]; - /** The length of the flattened items in the list */ flattenedItemsLength: number; diff --git a/src/components/Search/SearchList/ListItem/GroupChildrenContainer.tsx b/src/components/Search/SearchList/ListItem/GroupChildrenContainer.tsx index f894686a766d..8ac467b72746 100644 --- a/src/components/Search/SearchList/ListItem/GroupChildrenContainer.tsx +++ b/src/components/Search/SearchList/ListItem/GroupChildrenContainer.tsx @@ -30,7 +30,6 @@ function GroupChildrenContainer({ nonPersonalAndWorkspaceCards, onUndelete, isLastItem, - newTransactionID, bankAccountList, cardFeeds, conciergeReportID, @@ -82,7 +81,6 @@ function GroupChildrenContainer({ onLongPressRow={onLongPressRow} nonPersonalAndWorkspaceCards={nonPersonalAndWorkspaceCards} onUndelete={onUndelete} - newTransactionID={newTransactionID} bankAccountList={bankAccountList} cardFeeds={cardFeeds} conciergeReportID={conciergeReportID} diff --git a/src/components/Search/SearchList/ListItem/GroupChildrenContent.tsx b/src/components/Search/SearchList/ListItem/GroupChildrenContent.tsx index 227a7761d81b..95f533dffb47 100644 --- a/src/components/Search/SearchList/ListItem/GroupChildrenContent.tsx +++ b/src/components/Search/SearchList/ListItem/GroupChildrenContent.tsx @@ -34,7 +34,6 @@ function GroupChildrenContent({ onLongPressRow, nonPersonalAndWorkspaceCards, onUndelete, - newTransactionID, bankAccountList, cardFeeds, conciergeReportID, @@ -127,15 +126,6 @@ function GroupChildrenContent({ }); }; - useEffect(() => { - if (!newTransactionID || !isExpanded) { - return; - } - refreshTransactions(); - // Only refresh when a new transaction is created in this group — refreshTransactions is excluded to avoid infinite loops - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [newTransactionID, isExpanded]); - useEffect(() => { if (!isExpanded || isExpenseReportType) { return; diff --git a/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx b/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx index 8ef9ad69c479..3265e970162d 100644 --- a/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx @@ -87,7 +87,6 @@ function TransactionGroupListItem({ columns, groupBy, searchType, - newTransactionID, lastPaymentMethod, personalPolicyID, nonPersonalAndWorkspaceCards, @@ -243,24 +242,6 @@ function TransactionGroupListItem({ ]; const pressableRef = useRef(null); - useEffect(() => { - if (!newTransactionID || !isExpanded) { - return; - } - if (!groupItem.transactionsQueryJSON) { - return; - } - - search({ - queryJSON: groupItem.transactionsQueryJSON, - searchKey: undefined, - offset: 0, - shouldCalculateTotals: false, - isLoading: !!transactionsSnapshot?.search?.isLoading, - isOffline, - }); - }, [newTransactionID, isExpanded, groupItem.transactionsQueryJSON, isOffline, transactionsSnapshot?.search?.isLoading]); - const wasScreenFocusedRef = useRef(isScreenFocused); useEffect(() => { const didReturnToScreen = wasScreenFocusedRef.current === false && isScreenFocused === true; diff --git a/src/components/Search/SearchList/ListItem/types.ts b/src/components/Search/SearchList/ListItem/types.ts index 047c328d1c05..f226cd9d17e2 100644 --- a/src/components/Search/SearchList/ListItem/types.ts +++ b/src/components/Search/SearchList/ListItem/types.ts @@ -475,7 +475,6 @@ type TransactionGroupListItemProps = ListItemProps void; nonPersonalAndWorkspaceCards?: CardList; onUndelete?: (transaction: Transaction) => void; - newTransactionID?: string; bankAccountList?: OnyxEntry; cardFeeds?: OnyxCollection; conciergeReportID?: string; diff --git a/src/components/Search/SearchList/index.tsx b/src/components/Search/SearchList/index.tsx index d4da9f0bde26..cee696e40b63 100644 --- a/src/components/Search/SearchList/index.tsx +++ b/src/components/Search/SearchList/index.tsx @@ -4,7 +4,7 @@ import SelectionTopBar from '@components/Search/primitives/SelectionTopBar'; import useRowLongPressMenu from '@components/Search/primitives/useRowLongPressMenu'; import useScrollRestoration from '@components/Search/primitives/useScrollRestoration'; import {useSearchRowSelectionActions, useSearchSelectionContext} from '@components/Search/SearchContext'; -import type {SearchColumnType, SearchGroupBy, SearchQueryJSON} from '@components/Search/types'; +import type {SearchColumnType, SearchQueryJSON} from '@components/Search/types'; import type {ExtendedTargetedEvent} from '@components/SelectionList/ListItem/types'; import {useEditingCellState} from '@components/TransactionItemRow/EditableCell'; @@ -17,7 +17,6 @@ import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; import useThemeStyles from '@hooks/useThemeStyles'; import useUndeleteTransactions from '@hooks/useUndeleteTransactions'; -import DateUtils from '@libs/DateUtils'; import getPlatform from '@libs/getPlatform'; import type {ModifiedMouseEvent} from '@libs/Navigation/helpers/openInternalRouteInNewTab'; import {splitGroupsIntoPairs} from '@libs/SearchUIUtils'; @@ -44,19 +43,7 @@ import type ExpenseReportListItem from './ListItem/ExpenseReportListItem'; import type TaskListItem from './ListItem/TaskListItem'; import type TransactionGroupListItem from './ListItem/TransactionGroupListItem'; import type TransactionListItem from './ListItem/TransactionListItem'; -import type { - ReportActionListItemType, - TaskListItemType, - TransactionCardGroupListItemType, - TransactionCategoryGroupListItemType, - TransactionGroupListItemType, - TransactionListItemType, - TransactionMerchantGroupListItemType, - TransactionMonthGroupListItemType, - TransactionQuarterGroupListItemType, - TransactionWeekGroupListItemType, - TransactionYearGroupListItemType, -} from './ListItem/types'; +import type {ReportActionListItemType, TaskListItemType, TransactionGroupListItemType, TransactionListItemType} from './ListItem/types'; import BaseSearchList from './BaseSearchList'; import GroupChildrenContainer from './ListItem/GroupChildrenContainer'; @@ -111,8 +98,6 @@ type SearchListProps = Pick, 'onScroll' | 'conten /** Whether mobile selection mode is enabled */ isMobileSelectionModeEnabled: boolean; - newTransactions?: Transaction[]; - /** Non-personal and workspace cards (same drill path as former custom card names for rows) */ nonPersonalAndWorkspaceCards?: CardList; @@ -142,49 +127,6 @@ function isTransactionGroupListItemArray(data: SearchListItem[]): data is Transa return typeof firstElement === 'object' && 'transactions' in firstElement; } -function isTransactionMatchWithGroupItem(transaction: Transaction, groupItem: SearchListItem, groupBy: SearchGroupBy | undefined) { - if (groupBy === CONST.SEARCH.GROUP_BY.CARD) { - return transaction.cardID === (groupItem as TransactionCardGroupListItemType).cardID; - } - if (groupBy === CONST.SEARCH.GROUP_BY.FROM) { - return !!transaction.transactionID; - } - if (groupBy === CONST.SEARCH.GROUP_BY.CATEGORY) { - return (transaction.category ?? '') === ((groupItem as TransactionCategoryGroupListItemType).category ?? ''); - } - if (groupBy === CONST.SEARCH.GROUP_BY.MERCHANT) { - return (transaction.merchant ?? '') === ((groupItem as TransactionMerchantGroupListItemType).merchant ?? ''); - } - if (groupBy === CONST.SEARCH.GROUP_BY.MONTH) { - const monthGroup = groupItem as TransactionMonthGroupListItemType; - const transactionDateString = transaction.modifiedCreated ?? transaction.created ?? ''; - return DateUtils.isDateStringInMonth(transactionDateString, monthGroup.year, monthGroup.month); - } - if (groupBy === CONST.SEARCH.GROUP_BY.WEEK) { - const weekGroup = groupItem as TransactionWeekGroupListItemType; - const transactionDateString = transaction.modifiedCreated ?? transaction.created ?? ''; - const datePart = transactionDateString.substring(0, 10); - const {start: weekStart, end: weekEnd} = DateUtils.getWeekDateRange(weekGroup.week); - return datePart >= weekStart && datePart <= weekEnd; - } - if (groupBy === CONST.SEARCH.GROUP_BY.YEAR) { - const yearGroup = groupItem as TransactionYearGroupListItemType; - const transactionDateString = transaction.modifiedCreated ?? transaction.created ?? ''; - const transactionYear = parseInt(transactionDateString.substring(0, 4), 10); - return transactionYear === yearGroup.year; - } - if (groupBy === CONST.SEARCH.GROUP_BY.QUARTER) { - const quarterGroup = groupItem as TransactionQuarterGroupListItemType; - const transactionDateString = transaction.modifiedCreated ?? transaction.created ?? ''; - const transactionYear = parseInt(transactionDateString.substring(0, 4), 10); - const transactionMonth = parseInt(transactionDateString.substring(5, 7), 10); - // Calculate which quarter the transaction belongs to (1-4) - const transactionQuarter = Math.floor((transactionMonth - 1) / 3) + 1; - return transactionYear === quarterGroup.year && transactionQuarter === quarterGroup.quarter; - } - return false; -} - function SearchList({ data, ListItem, @@ -204,7 +146,6 @@ function SearchList({ onLayout, shouldAnimate, isMobileSelectionModeEnabled, - newTransactions = [], nonPersonalAndWorkspaceCards, hasLoadedAllTransactions, policyTags, @@ -377,22 +318,6 @@ function SearchList({ const handleUndelete = (transaction: Transaction) => undeleteTransactions([transaction]); - const newTransactionIDByItemKey = (() => { - if (newTransactions.length === 0) { - return CONST.EMPTY_MAP; - } - - const mappedTransactionIDs = new Map(); - for (const item of data) { - const matchedTransactionID = newTransactions.find((transaction) => isTransactionMatchWithGroupItem(transaction, item, groupBy))?.transactionID; - if (matchedTransactionID && item.keyForList) { - mappedTransactionIDs.set(item.keyForList, matchedTransactionID); - } - } - - return mappedTransactionIDs; - })(); - const {onLongPressRow, modal} = useRowLongPressMenu({shouldPreventLongPressRow, isSmallScreenWidth, isMobileSelectionModeEnabled}); // In mobile selection mode a row tap toggles selection. This must live here (not in ) because @@ -506,7 +431,6 @@ function SearchList({ if (isGroupChildrenContainerItem(item)) { const containerItem = item; const originalKey = (item.keyForList ?? '').replace('children_', ''); - const containerNewTransactionID = item.keyForList ? newTransactionIDByItemKey.get(originalKey) : undefined; return ( void; @@ -104,7 +100,6 @@ function TaskSearchView({ SearchTableHeader: searchTableHeader, tableHeaderVisible, hasLoadedAllTransactions, - newTransactions, onSelectRow, ListFooterComponent, onEndReached, @@ -194,7 +189,6 @@ function TaskSearchView({ ListFooterComponent={ListFooterComponent} onLayout={onLayout} contentContainerStyle={contentContainerStyle} - newTransactions={newTransactions} /> {modal} diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index d6fe878315ec..448bbb80df27 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -14,7 +14,7 @@ import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSaveSortedReportIDs from '@hooks/useSaveSortedReportIDs'; -import useSearchHighlightAndScroll from '@hooks/useSearchHighlightAndScroll'; +import useSearchAutoRefetch from '@hooks/useSearchAutoRefetch'; import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals'; import useStableArrayReference from '@hooks/useStableArrayReference'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -208,7 +208,7 @@ function Search({ clearSelectedTransactions(); }, [validGroupBy, prevValidGroupBy, clearSelectedTransactions]); - const {newSearchResultKeys, handleSelectionListScroll, newTransactions, hasQueuedHighlights} = useSearchHighlightAndScroll({ + useSearchAutoRefetch({ searchResults, transactions, previousTransactions, @@ -237,17 +237,7 @@ function Search({ hasPendingWriteOnMountRef, skipDeferralOnFocusRef, rearmTracking, - } = useSearchSnapshot({queryJSON, searchResults, newSearchResultKeys, transactions, reportActions}); - - // Mirror `hasQueuedHighlights` into a ref so the post-create-flow `useFocusEffect` - // (which has empty deps) can read the latest value without re-creating its callback. - // Used to skip the deferral that would otherwise hide the freshly-added row from - // FlashList during the RHP dismiss transition, which would prevent the highlight - // animation from ever firing on it. - const hasQueuedHighlightsRef = useRef(hasQueuedHighlights); - useEffect(() => { - hasQueuedHighlightsRef.current = hasQueuedHighlights; - }, [hasQueuedHighlights]); + } = useSearchSnapshot({queryJSON, searchResults, newSearchResultKeys: null, transactions, reportActions}); // There's a race condition in Onyx which makes it return data from the previous Search, so in addition to checking that the data is loaded // we also need to check that the searchResults matches the type and status of the current search @@ -308,14 +298,6 @@ function Search({ return; } - // If the highlight hook already queued rows for the post-create animation, - // skip the skeleton-during-transition defer. Otherwise FlashList stays empty - // for ~1s while the RHP dismiss transition runs, the row never mounts inside - // the 300ms highlight window, and `useAnimatedHighlightStyle` never fires. - if (hasQueuedHighlightsRef.current) { - return; - } - // Show skeleton while the RHP dismiss animation plays. The transition // hasn't started yet when useFocusEffect fires (it begins after paint), // so waitForUpcomingTransition defers until the animation actually ends. @@ -748,9 +730,8 @@ function Search({ const onLayout = useCallback(() => { onLayoutBase(); - handleSelectionListScroll(stableSortedData, searchListRef.current); onContentReady?.(); - }, [onLayoutBase, handleSelectionListScroll, stableSortedData, onContentReady]); + }, [onLayoutBase, onContentReady]); // Must be a ref, not state: cancelNavigationSpans is called during render // (inside conditional returns), so using setState would trigger infinite re-renders. @@ -1078,7 +1059,6 @@ function Search({ ListFooterComponent={listFooterComponent} onLayout={onLayout} isMobileSelectionModeEnabled={isMobileSelectionModeEnabled} - newTransactions={newTransactions} hasLoadedAllTransactions={hasLoadedAllTransactions} isAttendeesEnabledForMovingPolicy={isAttendeesEnabledForMovingPolicy} nonPersonalAndWorkspaceCards={nonPersonalAndWorkspaceCards} @@ -1103,7 +1083,6 @@ function Search({ ListFooterComponent={listFooterComponent} onLayout={onLayout} isMobileSelectionModeEnabled={isMobileSelectionModeEnabled} - newTransactions={newTransactions} hasLoadedAllTransactions={hasLoadedAllTransactions} isAttendeesEnabledForMovingPolicy={isAttendeesEnabledForMovingPolicy} nonPersonalAndWorkspaceCards={nonPersonalAndWorkspaceCards} @@ -1128,7 +1107,6 @@ function Search({ ListFooterComponent={listFooterComponent} onLayout={onLayout} isMobileSelectionModeEnabled={isMobileSelectionModeEnabled} - newTransactions={newTransactions} hasLoadedAllTransactions={hasLoadedAllTransactions} isActionColumnWide={isTask || hasDeletedTransaction} /> @@ -1151,7 +1129,6 @@ function Search({ ListFooterComponent={listFooterComponent} onLayout={onLayout} isMobileSelectionModeEnabled={isMobileSelectionModeEnabled} - newTransactions={newTransactions} hasLoadedAllTransactions={hasLoadedAllTransactions} isActionColumnWide={isTask || hasDeletedTransaction} /> @@ -1174,7 +1151,6 @@ function Search({ ListFooterComponent={listFooterComponent} onLayout={onLayout} isMobileSelectionModeEnabled={isMobileSelectionModeEnabled} - newTransactions={newTransactions} hasLoadedAllTransactions={hasLoadedAllTransactions} isActionColumnWide={isTask || hasDeletedTransaction} /> @@ -1200,7 +1176,6 @@ function Search({ onLayout={onLayout} isMobileSelectionModeEnabled={isMobileSelectionModeEnabled} shouldAnimate={type === CONST.SEARCH.DATA_TYPES.EXPENSE} - newTransactions={newTransactions} hasLoadedAllTransactions={hasLoadedAllTransactions} isAttendeesEnabledForMovingPolicy={isAttendeesEnabledForMovingPolicy} nonPersonalAndWorkspaceCards={nonPersonalAndWorkspaceCards} diff --git a/src/hooks/useSearchAutoRefetch.ts b/src/hooks/useSearchAutoRefetch.ts new file mode 100644 index 000000000000..b2d4ca3c954c --- /dev/null +++ b/src/hooks/useSearchAutoRefetch.ts @@ -0,0 +1,177 @@ +import type {SearchQueryJSON} from '@components/Search/types'; + +import {search} from '@libs/actions/Search'; +import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; +import TransitionTracker from '@libs/Navigation/TransitionTracker'; +import {isReportActionEntry} from '@libs/SearchUIUtils'; +import type {SearchKey} from '@libs/SearchUIUtils'; + +import CONST from '@src/CONST'; +import type {ReportActions, SearchResults, Transaction} from '@src/types/onyx'; + +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; + +import {useIsFocused} from '@react-navigation/native'; +import {useEffect, useRef} from 'react'; + +import useNetwork from './useNetwork'; + +type UseSearchAutoRefetch = { + searchResults: OnyxEntry; + transactions: OnyxCollection; + previousTransactions: OnyxCollection; + reportActions: OnyxCollection; + previousReportActions: OnyxCollection; + queryJSON: SearchQueryJSON; + searchKey: SearchKey | undefined; + offset: number; + shouldCalculateTotals: boolean; + shouldUseLiveData: boolean; +}; + +function useSearchAutoRefetch({ + searchResults, + transactions, + previousTransactions, + reportActions, + previousReportActions, + queryJSON, + searchKey, + offset, + shouldCalculateTotals, + shouldUseLiveData, +}: UseSearchAutoRefetch) { + const isFocused = useIsFocused(); + const {isOffline} = useNetwork(); + const searchTriggeredRef = useRef(false); + const hasPendingSearchRef = useRef(false); + const isChat = queryJSON.type === CONST.SEARCH.DATA_TYPES.CHAT; + const searchResultsData = searchResults?.data; + + useEffect(() => { + const previousTransactionIDsLocal = Object.keys(previousTransactions ?? {}); + const transactionsIDs = Object.keys(transactions ?? {}); + + const reportActionsIDs = Object.values(reportActions ?? {}) + .map((actions) => Object.keys(actions ?? {})) + .flat(); + const previousReportActionsIDs = Object.values(previousReportActions ?? {}) + .map((actions) => Object.keys(actions ?? {})) + .flat(); + + if ((previousTransactionIDsLocal.length === 0 && previousReportActionsIDs.length === 0) || searchTriggeredRef.current) { + return; + } + + const previousTransactionsIDsSet = new Set(previousTransactionIDsLocal); + const previousReportActionsIDsSet = new Set(previousReportActionsIDs); + const hasTransactionsIDsChange = transactionsIDs.length !== previousTransactionIDsLocal.length || transactionsIDs.some((id) => !previousTransactionsIDsSet.has(id)); + const hasReportActionsIDsChange = reportActionsIDs.some((id) => !previousReportActionsIDsSet.has(id)); + + if ((!isChat && hasTransactionsIDsChange) || hasReportActionsIDsChange || hasPendingSearchRef.current) { + // An RHP layered on top of Search makes `isFocused` false but keeps Search as the topmost + // fullscreen route, so we still want to refetch — otherwise the snapshot can't reflect + // entries the user creates from the RHP until they close it. + const isSearchStillActive = isFocused || isSearchTopmostFullScreenRoute(); + if (!isSearchStillActive || isOffline) { + hasPendingSearchRef.current = true; + return; + } + hasPendingSearchRef.current = false; + + const newIDs = isChat ? reportActionsIDs : transactionsIDs; + let currentSearchResultIDs: string[] = []; + if (searchResultsData) { + currentSearchResultIDs = isChat ? extractReportActionIDsFromSearchResults(searchResultsData) : extractTransactionIDsFromSearchResults(searchResultsData); + } + const existingSearchResultIDsSet = new Set(currentSearchResultIDs); + const hasAGenuinelyNewID = newIDs.some((id) => !existingSearchResultIDsSet.has(id)); + + // Only skip search if there are no new items AND search results aren't empty. + // This ensures deletions that result in empty data still trigger search. + if (!hasAGenuinelyNewID && currentSearchResultIDs.length > 0) { + const newIDsSet = new Set(newIDs); + const hasDeletedID = currentSearchResultIDs.some((id) => !newIDsSet.has(id)); + if (!hasDeletedID) { + return; + } + } + + TransitionTracker.runAfterTransitions({ + callback: () => { + search({queryJSON, searchKey, offset, shouldCalculateTotals, isLoading: !!searchResults?.search?.isLoading}); + }, + }); + + searchTriggeredRef.current = true; + } + }, [ + isFocused, + transactions, + previousTransactions, + queryJSON, + searchKey, + offset, + shouldCalculateTotals, + reportActions, + previousReportActions, + isChat, + searchResultsData, + isOffline, + searchResults?.search?.isLoading, + ]); + + useEffect(() => { + // For live data, isLoading is always false, so we also need to reset when searchResultsData changes. + // For snapshot data, we wait for isLoading to become false after the API call completes. + if (searchResults?.search?.isLoading) { + return; + } + + searchTriggeredRef.current = false; + }, [searchResults?.search?.isLoading, shouldUseLiveData, searchResultsData]); +} + +function getTransactionIDFromValue(value: unknown): string | undefined { + if (!value || typeof value !== 'object' || !('transactionID' in value)) { + return undefined; + } + const {transactionID} = value; + return typeof transactionID === 'string' && transactionID ? transactionID : undefined; +} + +function extractTransactionIDsFromSearchResults(searchResultsData: Partial): string[] { + const transactionIDs: string[] = []; + + for (const item of Object.values(searchResultsData)) { + if (!item || typeof item !== 'object') { + continue; + } + + const itemTransactionID = getTransactionIDFromValue(item); + if (itemTransactionID) { + transactionIDs.push(itemTransactionID); + } + + if ('transactions' in item && Array.isArray(item.transactions)) { + for (const transaction of item.transactions) { + const transactionID = getTransactionIDFromValue(transaction); + if (transactionID) { + transactionIDs.push(transactionID); + } + } + } + } + + return transactionIDs; +} + +function extractReportActionIDsFromSearchResults(searchResultsData: Partial): string[] { + return Object.keys(searchResultsData ?? {}) + .filter(isReportActionEntry) + .map((key) => Object.keys(searchResultsData[key] ?? {})) + .flat(); +} + +export default useSearchAutoRefetch; +export type {UseSearchAutoRefetch}; diff --git a/src/hooks/useSearchHighlightAndScroll.ts b/src/hooks/useSearchHighlightAndScroll.ts deleted file mode 100644 index b11c67ac96c8..000000000000 --- a/src/hooks/useSearchHighlightAndScroll.ts +++ /dev/null @@ -1,362 +0,0 @@ -import type {SearchListItem, TransactionGroupListItemType, TransactionListItemType} from '@components/Search/SearchList/ListItem/types'; -import type {SearchQueryJSON} from '@components/Search/types'; -import type {SelectionListHandle} from '@components/SelectionList/types'; - -import {search} from '@libs/actions/Search'; -import {mergeTransactionIdsHighlightOnSearchRoute} from '@libs/actions/Transaction'; -import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; -import TransitionTracker from '@libs/Navigation/TransitionTracker'; -import {isReportActionEntry} from '@libs/SearchUIUtils'; -import type {SearchKey} from '@libs/SearchUIUtils'; - -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {ReportActions, SearchResults, Transaction} from '@src/types/onyx'; - -import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; - -import {useIsFocused} from '@react-navigation/native'; -import {useCallback, useEffect, useRef, useState} from 'react'; - -import useNetwork from './useNetwork'; -import useOnyx from './useOnyx'; -import usePrevious from './usePrevious'; - -type UseSearchHighlightAndScroll = { - searchResults: OnyxEntry; - transactions: OnyxCollection; - previousTransactions: OnyxCollection; - reportActions: OnyxCollection; - previousReportActions: OnyxCollection; - queryJSON: SearchQueryJSON; - searchKey: SearchKey | undefined; - offset: number; - shouldCalculateTotals: boolean; - shouldUseLiveData: boolean; -}; - -/** - * Hook used to trigger a search when a new transaction or report action is added and handle highlighting and scrolling. - */ -function useSearchHighlightAndScroll({ - searchResults, - transactions, - previousTransactions, - reportActions, - previousReportActions, - queryJSON, - searchKey, - offset, - shouldCalculateTotals, - shouldUseLiveData, -}: UseSearchHighlightAndScroll) { - const isFocused = useIsFocused(); - const {isOffline} = useNetwork(); - // Ref to track if the search was triggered by this hook - const triggeredByHookRef = useRef(false); - const searchTriggeredRef = useRef(false); - const hasNewItemsRef = useRef(false); - const previousSearchResults = usePrevious(searchResults?.data); - const [newSearchResultKeys, setNewSearchResultKeys] = useState | null>(null); - const highlightedIDs = useRef>(new Set()); - const initializedRef = useRef(false); - const hasPendingSearchRef = useRef(false); - const isChat = queryJSON.type === CONST.SEARCH.DATA_TYPES.CHAT; - - const transactionIDsToHighlightSelector = useCallback((allTransactionIDs: OnyxEntry>>) => allTransactionIDs?.[queryJSON.type], [queryJSON.type]); - const [transactionIDsToHighlight] = useOnyx(ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE, { - selector: transactionIDsToHighlightSelector, - }); - const searchResultsData = searchResults?.data; - - const prevTransactionsIDs = Object.keys(previousTransactions ?? {}); - const newTransactions: Transaction[] = []; - if (prevTransactionsIDs.length > 0) { - const previousIDs = new Set(prevTransactionsIDs); - for (const [id, transaction] of Object.entries(transactions ?? {})) { - if (!previousIDs.has(id) && transaction) { - newTransactions.push(transaction); - } - } - } - - // Trigger search when a new report action is added while on chat or when a new transaction is added for the other search types. - useEffect(() => { - const previousTransactionIDsLocal = Object.keys(previousTransactions ?? {}); - const transactionsIDs = Object.keys(transactions ?? {}); - - const reportActionsIDs = Object.values(reportActions ?? {}) - .map((actions) => Object.keys(actions ?? {})) - .flat(); - const previousReportActionsIDs = Object.values(previousReportActions ?? {}) - .map((actions) => Object.keys(actions ?? {})) - .flat(); - - // Only proceed if we have previous data to compare against - // This prevents triggering on initial data load - if ((previousTransactionIDsLocal.length === 0 && previousReportActionsIDs.length === 0) || searchTriggeredRef.current) { - return; - } - - const previousTransactionsIDsSet = new Set(previousTransactionIDsLocal); - const previousReportActionsIDsSet = new Set(previousReportActionsIDs); - const hasTransactionsIDsChange = transactionsIDs.length !== previousTransactionIDsLocal.length || transactionsIDs.some((id) => !previousTransactionsIDsSet.has(id)); - const hasReportActionsIDsChange = reportActionsIDs.some((id) => !previousReportActionsIDsSet.has(id)); - - // Check if there is a change in the transactions or report actions list - if ((!isChat && hasTransactionsIDsChange) || hasReportActionsIDsChange || hasPendingSearchRef.current) { - // Skip if offline, or if the user has navigated to a different fullscreen page entirely. - // An RHP layered on top of Search makes `isFocused` false but keeps Search as the topmost - // fullscreen route, so we still want to refetch — otherwise the snapshot can't reflect - // entries the user creates from the RHP until they close it. - const isSearchStillActive = isFocused || isSearchTopmostFullScreenRoute(); - if (!isSearchStillActive || isOffline) { - hasPendingSearchRef.current = true; - return; - } - hasPendingSearchRef.current = false; - - const newIDs = isChat ? reportActionsIDs : transactionsIDs; - let currentSearchResultIDs: string[] = []; - if (searchResultsData) { - currentSearchResultIDs = isChat ? extractReportActionIDsFromSearchResults(searchResultsData) : extractTransactionIDsFromSearchResults(searchResultsData); - } - const existingSearchResultIDsSet = new Set(currentSearchResultIDs); - const hasAGenuinelyNewID = newIDs.some((id) => !existingSearchResultIDsSet.has(id)); - - // Only skip search if there are no new items AND search results aren't empty - // This ensures deletions that result in empty data still trigger search - if (!hasAGenuinelyNewID && currentSearchResultIDs.length > 0) { - const newIDsSet = new Set(newIDs); - const hasDeletedID = currentSearchResultIDs.some((id) => !newIDsSet.has(id)); - if (!hasDeletedID) { - return; - } - } - // We only want to highlight new items if the addition of transactions or report actions triggered the search. - // This is because, on deletion of items, the backend sometimes returns old items in place of the deleted ones. - // We don't want to highlight these old items, even if they appear new in the current search results. - hasNewItemsRef.current = isChat ? reportActionsIDs.length > previousReportActionsIDs.length : transactionsIDs.length > previousTransactionIDsLocal.length; - - // Set the flag indicating the search is triggered by the hook - triggeredByHookRef.current = true; - - // Trigger the search - TransitionTracker.runAfterTransitions({ - callback: () => { - search({queryJSON, searchKey, offset, shouldCalculateTotals, isLoading: !!searchResults?.search?.isLoading}); - }, - }); - - // Set the ref to prevent further triggers until reset - searchTriggeredRef.current = true; - } - }, [ - isFocused, - transactions, - previousTransactions, - queryJSON, - searchKey, - offset, - shouldCalculateTotals, - reportActions, - previousReportActions, - isChat, - searchResultsData, - isOffline, - searchResults?.search?.isLoading, - ]); - - useEffect(() => { - // For live data, isLoading is always false, so we also need to reset when searchResultsData changes - // For snapshot data, we wait for isLoading to become false after the API call completes - if (searchResults?.search?.isLoading) { - return; - } - - searchTriggeredRef.current = false; - }, [searchResults?.search?.isLoading, shouldUseLiveData, searchResultsData]); - - // Initialize the set with existing IDs only once - useEffect(() => { - if (initializedRef.current || !searchResultsData) { - return; - } - - const initialIDs = isChat ? extractReportActionIDsFromSearchResults(searchResultsData) : extractTransactionIDsFromSearchResults(searchResultsData); - highlightedIDs.current = new Set(initialIDs); - initializedRef.current = true; - }, [searchResultsData, isChat]); - - // Detect new items (transactions or report actions) - useEffect(() => { - if (!previousSearchResults || !searchResults?.data) { - return; - } - if (isChat) { - const previousReportActionIDs = extractReportActionIDsFromSearchResults(previousSearchResults); - const currentReportActionIDs = extractReportActionIDsFromSearchResults(searchResults.data); - - // Find new report action IDs that are not in the previousReportActionIDs and not already highlighted - const newReportActionIDs = currentReportActionIDs.filter((id) => !previousReportActionIDs.includes(id) && !highlightedIDs.current.has(id)); - - if (!triggeredByHookRef.current || newReportActionIDs.length === 0 || !hasNewItemsRef.current) { - return; - } - - const newKeys = new Set(); - for (const id of newReportActionIDs) { - const newReportActionKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${id}`; - highlightedIDs.current.add(newReportActionKey); - newKeys.add(newReportActionKey); - } - setNewSearchResultKeys(newKeys); - } else { - const previousTransactionIDs = extractTransactionIDsFromSearchResults(previousSearchResults); - const currentTransactionIDs = extractTransactionIDsFromSearchResults(searchResults.data); - const manualHighlightTransactionIDs = new Set(Object.keys(transactionIDsToHighlight ?? {}).filter((id) => !!transactionIDsToHighlight?.[id])); - - // Find new transaction IDs that are not in the previousTransactionIDs and not already highlighted - const newTransactionIDs = currentTransactionIDs.filter((id) => { - if (manualHighlightTransactionIDs.has(id)) { - return true; - } - if (!triggeredByHookRef.current || !hasNewItemsRef.current) { - return false; - } - return !previousTransactionIDs.includes(id) && !highlightedIDs.current.has(id); - }); - - if (newTransactionIDs.length === 0) { - return; - } - - const newKeys = new Set(); - const consumedManualIDs: string[] = []; - for (const id of newTransactionIDs) { - const newTransactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${id}`; - highlightedIDs.current.add(newTransactionKey); - newKeys.add(newTransactionKey); - if (manualHighlightTransactionIDs.has(id)) { - consumedManualIDs.push(id); - } - } - setNewSearchResultKeys(newKeys); - - // Clear consumed manual highlight flags so subsequent detect runs don't re-highlight the same IDs. - if (consumedManualIDs.length > 0) { - mergeTransactionIdsHighlightOnSearchRoute(queryJSON.type, Object.fromEntries(consumedManualIDs.map((id) => [id, false]))); - } - } - }, [searchResults?.data, previousSearchResults, isChat, transactionIDsToHighlight, queryJSON.type]); - - // Remove transactionIDsToHighlight when the user leaves the current search type - useEffect( - () => () => { - mergeTransactionIdsHighlightOnSearchRoute(queryJSON.type, null); - }, - [queryJSON.type], - ); - - // Reset newSearchResultKey after it's been used - useEffect(() => { - if (newSearchResultKeys === null) { - return; - } - - const timer = setTimeout(() => { - setNewSearchResultKeys(null); - }, CONST.ANIMATED_HIGHLIGHT_START_DURATION); - - return () => clearTimeout(timer); - }, [newSearchResultKeys]); - - /** - * Callback to handle scrolling to the new search result. - */ - const handleSelectionListScroll = (data: SearchListItem[], ref: SelectionListHandle | null) => { - // Early return if there's no ref, new transaction wasn't brought in by this hook - // or there's no new search result key - const newSearchResultKey = newSearchResultKeys?.values().next().value; - if (!ref || !triggeredByHookRef.current || !newSearchResultKey) { - return; - } - - // Extract the transaction/report action ID from the newSearchResultKey - const newID = newSearchResultKey.replace(isChat ? ONYXKEYS.COLLECTION.REPORT_ACTIONS : ONYXKEYS.COLLECTION.TRANSACTION, ''); - - // Find the index of the new transaction/report action in the data array - const indexOfNewItem = data.findIndex((item) => { - if (isChat) { - if ('reportActionID' in item && item.reportActionID === newID) { - return true; - } - } else { - // Handle TransactionListItemType - if ('transactionID' in item && item.transactionID === newID) { - return true; - } - - // Handle TransactionGroupListItemType with transactions array - if ('transactions' in item && Array.isArray(item.transactions)) { - return item.transactions.some((transaction) => transaction?.transactionID === newID); - } - } - - return false; - }); - - // Early return if the new item is not found in the data array - if (indexOfNewItem <= 0) { - return; - } - - // Perform the scrolling action - ref.scrollToIndex(indexOfNewItem); - // Reset the trigger flag to prevent unintended future scrolls and highlights - triggeredByHookRef.current = false; - }; - - const hasQueuedHighlights = newSearchResultKeys !== null && newSearchResultKeys.size > 0; - - return {newSearchResultKeys, handleSelectionListScroll, newTransactions, hasQueuedHighlights}; -} - -/** - * Helper function to extract transaction IDs from search results data. - */ -function extractTransactionIDsFromSearchResults(searchResultsData: Partial): string[] { - const transactionIDs: string[] = []; - - for (const item of Object.values(searchResultsData)) { - // Check for transactionID directly on the item (TransactionListItemType) - if ((item as TransactionListItemType)?.transactionID) { - transactionIDs.push((item as TransactionListItemType).transactionID); - } - - // Check for transactions array within the item (TransactionGroupListItemType) - if (Array.isArray((item as TransactionGroupListItemType)?.transactions)) { - for (const transaction of (item as TransactionGroupListItemType).transactions) { - if (!transaction?.transactionID) { - continue; - } - transactionIDs.push(transaction.transactionID); - } - } - } - - return transactionIDs; -} - -/** - * Helper function to extract report action IDs from search results data. - */ -function extractReportActionIDsFromSearchResults(searchResultsData: Partial): string[] { - return Object.keys(searchResultsData ?? {}) - .filter(isReportActionEntry) - .map((key) => Object.keys(searchResultsData[key] ?? {})) - .flat(); -} - -export default useSearchHighlightAndScroll; -export type {UseSearchHighlightAndScroll}; diff --git a/src/languages/de.ts b/src/languages/de.ts index a62f082fe36b..73a72776ff25 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1222,6 +1222,8 @@ const translations: TranslationDeepObject = { }, iou: { amount: 'Betrag', + expenseAdded: 'Ausgabe hinzugefügt', + invoiceSent: 'Rechnung gesendet', percent: 'Prozent', date: 'Datum', taxAmount: 'Steuerbetrag', diff --git a/src/languages/en.ts b/src/languages/en.ts index 50f48f2d444e..24856c80fc58 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1289,6 +1289,8 @@ const translations = { }, iou: { amount: 'Amount', + expenseAdded: 'Expense added', + invoiceSent: 'Invoice sent', percent: 'Percent', date: 'Date', taxAmount: 'Tax amount', diff --git a/src/languages/es.ts b/src/languages/es.ts index 9b8219bd0b36..717fef2c9b1a 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1181,6 +1181,8 @@ const translations: TranslationDeepObject = { }, iou: { amount: 'Importe', + expenseAdded: 'Gasto añadido', + invoiceSent: 'Factura enviada', percent: 'Porcentaje', date: 'Fecha', taxAmount: 'Importe del impuesto', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 194741bd75ce..c28c3cf58c39 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1226,6 +1226,8 @@ const translations: TranslationDeepObject = { }, iou: { amount: 'Montant', + expenseAdded: 'Dépense ajoutée', + invoiceSent: 'Facture envoyée', percent: 'Pourcentage', date: 'Date', taxAmount: 'Montant de la taxe', diff --git a/src/languages/it.ts b/src/languages/it.ts index ced18bde4860..07b418a27277 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1222,6 +1222,8 @@ const translations: TranslationDeepObject = { }, iou: { amount: 'Importo', + expenseAdded: 'Spesa aggiunta', + invoiceSent: 'Fattura inviata', percent: 'Percentuale', date: 'Data', taxAmount: 'Importo imposta', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 00ac27dbd9a7..0430cf254bb3 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1204,6 +1204,8 @@ const translations: TranslationDeepObject = { }, iou: { amount: '金額', + expenseAdded: '経費を追加しました', + invoiceSent: '請求書を送信しました', percent: 'パーセント', date: '日付', taxAmount: '税額', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index cd605d22e0d2..a43a303613d7 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1220,6 +1220,8 @@ const translations: TranslationDeepObject = { }, iou: { amount: 'Bedrag', + expenseAdded: 'Uitgave toegevoegd', + invoiceSent: 'Factuur verzonden', percent: 'Procent', date: 'Datum', taxAmount: 'Belastingbedrag', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 2127c9808542..600aa2e3e28a 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1216,6 +1216,8 @@ const translations: TranslationDeepObject = { }, iou: { amount: 'Kwota', + expenseAdded: 'Dodano wydatek', + invoiceSent: 'Wysłano fakturę', percent: 'Procent', date: 'Data', taxAmount: 'Kwota podatku', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 0b7f5c5bb7c9..cba683d8fd44 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1220,6 +1220,8 @@ const translations: TranslationDeepObject = { }, iou: { amount: 'Valor', + expenseAdded: 'Despesa adicionada', + invoiceSent: 'Fatura enviada', percent: 'Porcentagem', date: 'Data', taxAmount: 'Valor do imposto', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 24cdbbf2cbeb..fa27bfc5e012 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1173,6 +1173,8 @@ const translations: TranslationDeepObject = { }, iou: { amount: '金额', + expenseAdded: '已添加支出', + invoiceSent: '已发送发票', percent: '百分比', date: '日期', taxAmount: '税额', diff --git a/src/libs/Navigation/helpers/cleanupAfterSkipConfirmSubmit.ts b/src/libs/Navigation/helpers/cleanupAfterSkipConfirmSubmit.ts index 86a219a591de..be5a15ac4431 100644 --- a/src/libs/Navigation/helpers/cleanupAfterSkipConfirmSubmit.ts +++ b/src/libs/Navigation/helpers/cleanupAfterSkipConfirmSubmit.ts @@ -1,17 +1,14 @@ import type {CleanupAndNavigateAfterExpenseCreateParams} from './cleanupAndNavigateAfterExpenseCreate'; import cleanupAfterExpenseCreate from './cleanupAfterExpenseCreate'; -import cleanupAndNavigateAfterExpenseCreate from './cleanupAndNavigateAfterExpenseCreate'; /** - * Skip-confirmation cleanup dispatcher: `shouldHandleNavigation` (from `submitWithDismissFirst`) picks - * cleanup-only vs cleanup-and-navigate. The skip-confirm analog of `useExpenseSubmission`'s `performPostBatchCleanup`. + * Cleanup-only after a skip-confirmation submit. The skip-confirm analog of `useExpenseSubmission`'s + * `performPostBatchCleanup`: the write action now owns post-creation navigation (via its own + * `shouldHandleNavigation`), so cleanup must never navigate too or the fallback path would run + * navigation/growl twice. Navigation on dismiss-first paths is done by `submitWithDismissFirst`. */ -function cleanupAfterSkipConfirmSubmit(shouldHandleNavigation: boolean, params: CleanupAndNavigateAfterExpenseCreateParams) { - if (shouldHandleNavigation) { - cleanupAndNavigateAfterExpenseCreate(params); - return; - } +function cleanupAfterSkipConfirmSubmit(params: CleanupAndNavigateAfterExpenseCreateParams) { cleanupAfterExpenseCreate({draftTransactionIDs: params.draftTransactionIDs, linkedTrackedExpenseReportAction: params.linkedTrackedExpenseReportAction}); } diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index 9cea646bc6c1..42f300fc2477 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -1,23 +1,74 @@ import {addPendingNewTransactionIDs} from '@libs/actions/IOU/PendingNewTransactions'; +import {createTransactionThreadReport, setOptimisticTransactionThread} from '@libs/actions/Report'; +import {setActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; +import Growl from '@libs/Growl'; +import {translateLocal} from '@libs/Localize'; import Log from '@libs/Log'; import {getPreservedNavigatorState} from '@libs/Navigation/AppNavigator/createSplitNavigator/usePreserveNavigatorState'; import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; +import {getIOUActionForReportID} from '@libs/ReportActionsUtils'; +import {getReportOrDraftReport} from '@libs/ReportUtils'; import {buildCannedSearchQuery, getCurrentSearchQueryJSON} from '@libs/SearchQueryUtils'; import {setPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; +import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; +import type {Beta, IntroSelected, Transaction} from '@src/types/onyx'; + +import type {OnyxCollection} from 'react-native-onyx'; + +import Onyx from 'react-native-onyx'; import dismissModalAndOpenReportInInboxTab from './dismissModalAndOpenReportInInboxTab'; +import getTopmostFullScreenRoute from './getTopmostFullScreenRoute'; import isReportTopmostSplitNavigator from './isReportTopmostSplitNavigator'; import isSearchTopmostFullScreenRoute from './isSearchTopmostFullScreenRoute'; +import setNavigationActionToMicrotaskQueue from './setNavigationActionToMicrotaskQueue'; + +let currentUserEmail = ''; +let currentUserAccountID: number = CONST.DEFAULT_NUMBER_ID; +Onyx.connectWithoutView({ + key: ONYXKEYS.SESSION, + callback: (value) => { + currentUserEmail = value?.email ?? ''; + currentUserAccountID = value?.accountID ?? CONST.DEFAULT_NUMBER_ID; + }, +}); + +let introSelected: IntroSelected | undefined; +Onyx.connectWithoutView({ + key: ONYXKEYS.NVP_INTRO_SELECTED, + callback: (value) => { + introSelected = value ?? undefined; + }, +}); + +let betas: Beta[] | undefined; +Onyx.connectWithoutView({ + key: ONYXKEYS.BETAS, + callback: (value) => { + betas = value ?? undefined; + }, +}); + +let allTransactions: OnyxCollection; +Onyx.connectWithoutView({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (transactions) => { + allTransactions = transactions; + }, +}); type NavigateAfterExpenseCreateParams = { activeReportID?: string; + iouReportID?: string; transactionID?: string; + transactionThreadReportID?: string; isFromGlobalCreate?: boolean; isInvoice?: boolean; hasMultipleTransactions: boolean; @@ -25,12 +76,194 @@ type NavigateAfterExpenseCreateParams = { shouldNavigate?: boolean; }; -function getNavigateAfterCreateSearchNavigatorState() { - const rootState = navigationRef.getRootState(); - const tabNavigatorRoute = rootState?.routes?.findLast((route) => route.name === NAVIGATORS.TAB_NAVIGATOR); - const tabState = tabNavigatorRoute?.state ?? (tabNavigatorRoute?.key ? getPreservedNavigatorState(tabNavigatorRoute.key) : undefined); - const searchNavigatorRoute = tabState?.routes?.findLast((route) => route.name === NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR); - return searchNavigatorRoute?.state ?? (searchNavigatorRoute?.key ? getPreservedNavigatorState(searchNavigatorRoute.key) : undefined); +type ShowExpenseAddedGrowlParams = { + iouReportID?: string; + transactionID?: string; + transactionThreadReportID?: string; + + /** + * Whether the growl was shown in the Inbox context. When omitted (dismiss-first orchestrator + * paths that don't know where the user lands), the context is resolved at "View" press time. + */ + isInbox?: boolean; + + /** Whether this confirmation is for an invoice (changes the toast copy from "Expense added"). */ + isInvoice?: boolean; +}; + +/** + * Shows the "Expense added" growl with a "View" action that deep-links to the new expense's RHP. + * + * The IOU action's optimistic data is typically not yet in the Onyx cache when this runs — the + * `API.write` is deferred (deferred-for-search pattern) until Search's content layout flushes the + * channel. We subscribe to the iouReport's reportActions and wait for the optimistic iouAction + * to land, then build the thread + show the growl. A safety timeout falls back to a growl + * without the "View" link if the iouAction never appears. + */ +function showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadReportID: providedTransactionThreadReportID, isInbox, isInvoice}: ShowExpenseAddedGrowlParams) { + if (!transactionID) { + return; + } + + // eslint-disable-next-line @typescript-eslint/no-deprecated -- imperative module (not a React component); no useLocalize hook available here + const growlMessage = isInvoice ? translateLocal('iou.invoiceSent') : translateLocal('iou.expenseAdded'); + + const buildThreadFromOnyx = (): string | undefined => { + const iouReport = iouReportID ? getReportOrDraftReport(iouReportID) : undefined; + const iouAction = iouReportID ? getIOUActionForReportID(iouReportID, transactionID) : undefined; + let threadReportID = providedTransactionThreadReportID ?? iouAction?.childReportID; + if (!threadReportID) { + const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + const optimisticThread = createTransactionThreadReport({ + introSelected, + currentUserLogin: currentUserEmail, + currentUserAccountID, + betas, + iouReport, + iouReportAction: iouAction, + transaction, + }); + threadReportID = optimisticThread?.reportID; + } else { + setOptimisticTransactionThread(threadReportID, iouReport?.reportID, iouAction?.reportActionID, iouReport?.policyID); + } + return threadReportID; + }; + + const showGrowl = (threadReportID: string | undefined) => { + if (!threadReportID) { + Log.warn('[showExpenseAddedGrowl] Unable to resolve transaction thread reportID; growl without View.'); + Growl.success(growlMessage, CONST.GROWL.DURATION_LONG); + return; + } + const resolvedThreadReportID = threadReportID; + const navigateToExpenseRHP = () => { + const backTo = Navigation.getActiveRoute(); + // The explicit flag wins (set when the growl's origin context is known). Otherwise + // (dismiss-first orchestrator paths) resolve the context at press time. + const openOnInbox = isInbox ?? (isReportTopmostSplitNavigator() && !isSearchTopmostFullScreenRoute()); + + if (!openOnInbox) { + // Spend context: open the transaction thread RHP within Search (the report is shown + // underneath via the Wide RHP machinery). + setActiveTransactionIDs([transactionID]).then(() => { + Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: resolvedThreadReportID, backTo})); + }); + return; + } + + // Inbox + narrow layout: super wide RHP is unavailable, so open the transaction thread + // as a full report view (matches MoneyRequestReportPreview's narrow-screen behavior). + if (getIsNarrowLayout()) { + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(resolvedThreadReportID, undefined, undefined, backTo)); + return; + } + + // Inbox + wide layout. A report with multiple transactions opens in a super wide RHP, so + // we open the expense report underneath and stack the transaction thread RHP on top of it - + // the super wide RHP keeps both visible as one cohesive surface. A single-transaction report + // does NOT use the super wide RHP (see SearchMoneyRequestReportPage's `shouldShowSuperWideRHP`), + // so stacking would surface as two separate RHP panels (RHP appears to open twice). In that + // case navigate straight to the transaction thread instead; the report still shows underneath + // via the Wide RHP machinery, matching the Spend-context path above. + const hasMultipleReportTransactions = + Object.values(allTransactions ?? {}).filter( + (transaction) => transaction?.reportID === iouReportID && transaction?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + ).length > 1; + + if (iouReportID && hasMultipleReportTransactions) { + Navigation.navigate(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: iouReportID, backTo})); + } + setNavigationActionToMicrotaskQueue(() => { + setActiveTransactionIDs([transactionID]).then(() => { + Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: resolvedThreadReportID, backTo: Navigation.getActiveRoute()})); + }); + }); + }; + // eslint-disable-next-line @typescript-eslint/no-deprecated -- imperative module (not a React component); no useLocalize hook available here + Growl.success(growlMessage, CONST.GROWL.DURATION_WITH_ACTION, {label: translateLocal('common.view'), onPress: navigateToExpenseRHP}); + }; + + // Fast path: the thread is already resolvable, so show the growl immediately instead of waiting on + // the reportActions subscription (which would otherwise hit the 8s safety timeout). This covers: + // - personal tracked expenses (unreported/self-DM): there's no iouReportID, but the thread ID is + // passed in directly, so we can build from it right away; + // - the iouAction already being in Onyx (retry / non-deferred edge cases). + if (providedTransactionThreadReportID || (iouReportID && getIOUActionForReportID(iouReportID, transactionID)?.reportActionID)) { + const threadReportID = buildThreadFromOnyx(); + showGrowl(threadReportID); + return; + } + + // No iouReportID to subscribe to (and no thread ID was passed): the reportActions key would be + // `reportActions_undefined` and the callback below early-returns on `!iouReportID` forever, so the + // subscription can never resolve and would only fire via the 8s timeout. Resolve immediately with + // the same fallback the timeout would produce (a growl without "View" when no thread is resolvable). + if (!iouReportID) { + showGrowl(buildThreadFromOnyx()); + return; + } + + // Slow path: wait for Search to render → flushDeferredWrite('search') → API.write applies + // optimistic data → iouAction lands → we show the growl. + const SAFETY_TIMEOUT_MS = 8000; + let resolved = false; + const reportActionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}` as const; + const connectionId = Onyx.connectWithoutView({ + key: reportActionsKey, + callback: () => { + if (resolved || !iouReportID) { + return; + } + const iouAction = getIOUActionForReportID(iouReportID, transactionID); + if (!iouAction?.reportActionID) { + return; + } + resolved = true; + Onyx.disconnect(connectionId); + const threadReportID = buildThreadFromOnyx(); + showGrowl(threadReportID); + }, + }); + + setTimeout(() => { + if (resolved) { + return; + } + resolved = true; + Onyx.disconnect(connectionId); + const threadReportID = buildThreadFromOnyx(); + showGrowl(threadReportID); + }, SAFETY_TIMEOUT_MS); +} + +type SurfaceExpenseCreatedFeedbackParams = { + iouReportID?: string; + transactionID?: string; + transactionThreadReportID?: string; + + /** + * Whether the expense was added from within its own expense report (i.e. the report table is the + * surface in front of the user). When true we highlight the new row instead of showing a growl. + */ + isMoneyRequestReport?: boolean; + + /** Whether this is an invoice (changes the growl copy). */ + isInvoice?: boolean; +}; + +/** + * Surfaces post-create feedback when navigation is owned elsewhere (the dismiss-first orchestrator) or + * isn't needed. Single decision point shared by every expense type: + * - if the user is looking at the expense report's table (in-report add) → highlight the new row; + * - otherwise → show the "Expense added" growl with a "View" deep link. + */ +function surfaceExpenseCreatedFeedback({iouReportID, transactionID, transactionThreadReportID, isMoneyRequestReport, isInvoice}: SurfaceExpenseCreatedFeedbackParams) { + if (isMoneyRequestReport && iouReportID && transactionID) { + addPendingNewTransactionIDs(iouReportID, transactionID); + return; + } + showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadReportID, isInvoice}); } /** @@ -42,63 +275,101 @@ function getNavigateAfterCreateSearchNavigatorState() { */ function navigateAfterExpenseCreate({ activeReportID, + + iouReportID, transactionID, + transactionThreadReportID: providedTransactionThreadReportID, + isFromGlobalCreate, + isInvoice, + hasMultipleTransactions, shouldAddPendingNewTransactionIDs = false, shouldNavigate = true, }: NavigateAfterExpenseCreateParams) { const isUserOnInbox = isReportTopmostSplitNavigator(); + const isUserOnSpend = isSearchTopmostFullScreenRoute(); + + // If the expense is not created from global create or there is no transaction to link, + // we just need to dismiss the money request flow screens and open the report chat + // containing the IOU report. No growl is shown in this case. + if (!isFromGlobalCreate || !transactionID) { + if (shouldNavigate) { + dismissModalAndOpenReportInInboxTab(activeReportID, isInvoice, hasMultipleTransactions); + } + if (shouldAddPendingNewTransactionIDs) { + addPendingNewTransactionIDs(activeReportID, transactionID); + } + return; + } - // If the expense is not created from global create or is currently on the inbox tab, - // we just need to dismiss the money request flow screens - // and open the report chat containing the IOU report - if (!isFromGlobalCreate || isUserOnInbox || !transactionID) { + // From global create on the Inbox tab: stay on Inbox (dismiss the flow and open the report + // chat containing the IOU report) and show the "Expense added" growl on top of it. We don't + // redirect to Spend here - the growl just shows. + if (isUserOnInbox) { if (shouldNavigate) { dismissModalAndOpenReportInInboxTab(activeReportID, isInvoice, hasMultipleTransactions); } if (shouldAddPendingNewTransactionIDs) { addPendingNewTransactionIDs(activeReportID, transactionID); } + showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadReportID: providedTransactionThreadReportID, isInbox: true, isInvoice}); return; } + // The caller owns navigation in this case, so skip redirecting to Search. if (!shouldNavigate) { return; } const type = isInvoice ? CONST.SEARCH.DATA_TYPES.INVOICE : CONST.SEARCH.DATA_TYPES.EXPENSE; - // When already on Search ROOT with the same type (expense vs invoice), we navigate to the same screen (no-op or refresh); record as dismiss_modal_only. - // When on another Search sub-tab (e.g. Chats), or on Search with a different type (e.g. on Invoice, submitting expense), record as navigate_to_search. - const searchNavigatorState = getNavigateAfterCreateSearchNavigatorState(); + // POC variant B: navigate to Search (if not already there), then show the "View" growl on + // Search once the optimistic IOU action lands in Onyx. Runs whether or not the user is + // already on Spend — on the SEARCH_PRE_INSERT fast path the orchestrator pre-inserts Search + // and dismisses the modal before createTransaction runs, so by the time we get here the user + // is already on Search (isUserOnSpend=true). The growl must still fire in that case. + // + // Why this works without an explicit deferred-write flush: Search's content onLayout callback + // calls flushDeferredWrite('search') as soon as the actual list (not the skeleton) renders. + // That fires API.write, which applies optimistic data → our reportActions subscription fires + // → we have a valid iouAction to build the thread from → show growl with working "View" link. + const queryString = buildCannedSearchQuery({type}); + // SEARCH_FULLSCREEN_NAVIGATOR is nested inside TAB_NAVIGATOR, not at the root, and its `state` + // can be undefined when the split navigator isn't actively rendered - so we resolve it via the + // same TAB_NAVIGATOR traversal isSearchTopmostFullScreenRoute uses, then fall back to the + // preserved navigator state for the search navigator's own inner routes. + const searchNavigatorRoute = isUserOnSpend ? getTopmostFullScreenRoute() : undefined; + const searchNavigatorState = searchNavigatorRoute?.state ?? (searchNavigatorRoute?.key ? getPreservedNavigatorState(searchNavigatorRoute.key) : undefined); const lastSearchRoute = searchNavigatorState?.routes?.at(-1); - const isSearchTopmost = isSearchTopmostFullScreenRoute(); - const alreadyOnSearchRoot = isSearchTopmost && lastSearchRoute?.name === SCREENS.SEARCH.ROOT; + const alreadyOnSearchRoot = isUserOnSpend && lastSearchRoute?.name === SCREENS.SEARCH.ROOT; const currentSearchQueryJSON = alreadyOnSearchRoot ? getCurrentSearchQueryJSON() : undefined; const isSameSearchType = currentSearchQueryJSON?.type === type; - const followUpAction = alreadyOnSearchRoot && isSameSearchType ? CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_ONLY : CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.NAVIGATE_TO_SEARCH; - setPendingSubmitFollowUpAction(followUpAction); - const queryString = buildCannedSearchQuery({type}); + setPendingSubmitFollowUpAction( + alreadyOnSearchRoot && isSameSearchType ? CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_ONLY : CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.NAVIGATE_TO_SEARCH, + ); + const navigateToSearch = () => { - // On the fast path, onConfirm already cleared the flag and dismissed the modal, - // so this branch is only reached on the slow path (user submitted before the - // 300ms pre-insert timer fired). - if (getIsNarrowLayout() && Navigation.getIsFullscreenPreInsertedUnderRHP()) { + const isNarrow = getIsNarrowLayout(); + const isFullscreenPreInserted = Navigation.getIsFullscreenPreInsertedUnderRHP(); + if (isNarrow && isFullscreenPreInserted) { Navigation.clearFullscreenPreInsertedFlag(); Navigation.dismissModal(); - } else if (getIsNarrowLayout()) { + return; + } + if (isNarrow) { const isRHPStillOnTop = navigationRef.getRootState()?.routes?.at(-1)?.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR; if (!alreadyOnSearchRoot || !isSameSearchType || isRHPStillOnTop) { Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: queryString}), {forceReplace: true}); - } else { - Log.info('[IOU] navigateToSearch: already on matching Search root with RHP dismissed - no-op'); } - } else { - Navigation.revealRouteBeforeDismissingModal(ROUTES.SEARCH_ROOT.getRoute({query: queryString})); + return; } + if (alreadyOnSearchRoot && isSameSearchType) { + return; + } + Navigation.revealRouteBeforeDismissingModal(ROUTES.SEARCH_ROOT.getRoute({query: queryString})); }; if (navigationRef.isReady()) { @@ -106,6 +377,9 @@ function navigateAfterExpenseCreate({ } else { Navigation.isNavigationReady().then(navigateToSearch); } + + showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadReportID: providedTransactionThreadReportID, isInbox: false, isInvoice}); } export default navigateAfterExpenseCreate; +export {surfaceExpenseCreatedFeedback}; diff --git a/src/libs/actions/IOU/MoneyRequest.ts b/src/libs/actions/IOU/MoneyRequest.ts index 909064792a82..60cdfb3605e7 100644 --- a/src/libs/actions/IOU/MoneyRequest.ts +++ b/src/libs/actions/IOU/MoneyRequest.ts @@ -93,6 +93,8 @@ type CreateTransactionParams = { optimisticTransactionIDs: string[]; optimisticChatReportID: string | undefined; currentUserLocalCurrency: string | undefined; + /** Whether the created action should own post-creation navigation. Skip-confirm orchestrators that dismiss/reveal first pass `false` so navigation isn't run twice. */ + shouldHandleNavigation?: boolean; }; function createTransaction({ @@ -121,6 +123,7 @@ function createTransaction({ optimisticTransactionIDs, optimisticChatReportID, currentUserLocalCurrency, + shouldHandleNavigation = true, }: CreateTransactionParams) { const draftTransactionIDs = Object.keys(allTransactionDrafts ?? {}); @@ -168,6 +171,7 @@ function createTransaction({ optimisticChatReportID, optimisticTransactionID, currentUserLocalCurrency, + shouldHandleNavigation, }); } else { const existingTransactionID = getExistingTransactionID(transaction?.linkedTrackedExpenseReportAction); @@ -210,6 +214,7 @@ function createTransaction({ personalDetails, optimisticChatReportID, optimisticTransactionID, + shouldHandleNavigation, }); } } diff --git a/src/libs/actions/IOU/MoneyRequestBuilder.ts b/src/libs/actions/IOU/MoneyRequestBuilder.ts index ffe1aa61a683..592fcfc3dd95 100644 --- a/src/libs/actions/IOU/MoneyRequestBuilder.ts +++ b/src/libs/actions/IOU/MoneyRequestBuilder.ts @@ -166,6 +166,15 @@ type RequestMoneyInformation = { transactionParams: RequestMoneyTransactionParams; isRetry?: boolean; shouldPlaySound?: boolean; + shouldHandleNavigation?: boolean; + /** + * When a confirmation submits several transactions, the orchestrator calls this action once per + * transaction in a loop. Post-create navigation + the "Expense added" growl are owned by the + * action, so they must fire only once - for the final transaction of the batch. Defaults to true + * so single-transaction callers keep their existing behavior. + */ + isLastTransactionOfBatch?: boolean; + backToReport?: string; /** Retry-path cleanup only; the action itself never reads this. */ draftTransactionIDs?: string[]; optimisticChatReportID?: string; diff --git a/src/libs/actions/IOU/NavigationHelpers.ts b/src/libs/actions/IOU/NavigationHelpers.ts index dbbbd2f241cb..ef64491edb46 100644 --- a/src/libs/actions/IOU/NavigationHelpers.ts +++ b/src/libs/actions/IOU/NavigationHelpers.ts @@ -1,11 +1,6 @@ import sharedDismissModalAndOpenReportInInboxTab from '@libs/Navigation/helpers/dismissModalAndOpenReportInInboxTab'; -import isReportTopmostSplitNavigator from '@libs/Navigation/helpers/isReportTopmostSplitNavigator'; import navigateAfterExpenseCreate from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; -import {mergeTransactionIdsHighlightOnSearchRoute} from '@userActions/Transaction'; - -import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; - import {getAllTransactions} from './index'; /** @@ -19,17 +14,6 @@ function dismissModalAndOpenReportInInboxTab(reportID?: string, isInvoice?: bool sharedDismissModalAndOpenReportInInboxTab(reportID, isInvoice, hasMultipleTransactions); } -/** - * Marks a transaction for highlight on the Search page when the expense was created - * from the global create button and the user is not on the Inbox tab. - */ -function highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate: boolean | undefined, transactionID: string | undefined, dataType: SearchDataTypes) { - if (!isFromGlobalCreate || isReportTopmostSplitNavigator() || !transactionID) { - return; - } - mergeTransactionIdsHighlightOnSearchRoute(dataType, {[transactionID]: true}); -} - /** * Helper to navigate after an expense is created in order to standardize the post‑creation experience * when creating an expense from the global create button. @@ -39,21 +23,32 @@ function highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate: boolean | */ function handleNavigateAfterExpenseCreate({ activeReportID, + iouReportID, transactionID, + transactionThreadReportID, isFromGlobalCreate, isInvoice, shouldAddPendingNewTransactionIDs = false, - shouldNavigate = true, }: { activeReportID?: string; + iouReportID?: string; transactionID?: string; + transactionThreadReportID?: string; isFromGlobalCreate?: boolean; isInvoice?: boolean; shouldAddPendingNewTransactionIDs?: boolean; - shouldNavigate?: boolean; }) { const hasMultipleTransactions = Object.values(getAllTransactions()).filter((transaction) => transaction?.reportID === activeReportID).length > 0; - navigateAfterExpenseCreate({activeReportID, transactionID, isFromGlobalCreate, isInvoice, hasMultipleTransactions, shouldAddPendingNewTransactionIDs, shouldNavigate}); + navigateAfterExpenseCreate({ + activeReportID, + iouReportID, + transactionID, + transactionThreadReportID, + isFromGlobalCreate, + isInvoice, + hasMultipleTransactions, + shouldAddPendingNewTransactionIDs, + }); } -export {dismissModalAndOpenReportInInboxTab, handleNavigateAfterExpenseCreate, highlightTransactionOnSearchRouteIfNeeded}; +export {dismissModalAndOpenReportInInboxTab, handleNavigateAfterExpenseCreate}; diff --git a/src/libs/actions/IOU/PerDiem.ts b/src/libs/actions/IOU/PerDiem.ts index c7bca2242de7..aaef1395396c 100644 --- a/src/libs/actions/IOU/PerDiem.ts +++ b/src/libs/actions/IOU/PerDiem.ts @@ -67,7 +67,6 @@ import { mergePolicyRecentlyUsedCategories, mergePolicyRecentlyUsedCurrencies, } from './MoneyRequestBuilder'; -import {highlightTransactionOnSearchRouteIfNeeded} from './NavigationHelpers'; function removeSubrate(transaction: OnyxEntry, currentIndex: string) { // Index comes from the route params and is a string @@ -926,7 +925,7 @@ function submitPerDiemExpense(submitPerDiemExpenseInformation: PerDiemExpenseInf optimisticTransactionID, delegateAccountID, } = submitPerDiemExpenseInformation; - const {currency, comment = '', category, tag, created, customUnit, attendees, isFromGlobalCreate} = transactionParams; + const {currency, comment = '', category, tag, created, customUnit, attendees} = transactionParams; if ( isEmptyObject(policyParams.policy) || @@ -1033,13 +1032,11 @@ function submitPerDiemExpense(submitPerDiemExpenseInformation: PerDiemExpenseInf TransitionTracker.runAfterTransitions({callback: () => removeDraftTransaction(CONST.IOU.OPTIMISTIC_TRANSACTION_ID), waitForUpcomingTransition: true}); - highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate, transaction.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE); - if (activeReportID) { notifyNewAction(activeReportID, undefined, participantParams.payeeAccountID === currentUserAccountIDParam); } - return {iouReport, transactionID: transaction.transactionID}; + return {iouReport, transactionID: transaction.transactionID, transactionThreadReportID}; } /** diff --git a/src/libs/actions/IOU/SendInvoice.ts b/src/libs/actions/IOU/SendInvoice.ts index 109931924dc5..c958781b95d8 100644 --- a/src/libs/actions/IOU/SendInvoice.ts +++ b/src/libs/actions/IOU/SendInvoice.ts @@ -7,6 +7,7 @@ import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import {formatPhoneNumber} from '@libs/LocalePhoneNumber'; import Log from '@libs/Log'; import isReportTopmostSplitNavigator from '@libs/Navigation/helpers/isReportTopmostSplitNavigator'; +import {surfaceExpenseCreatedFeedback} from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; import TransitionTracker from '@libs/Navigation/TransitionTracker'; import {getReportActionHtml, getReportActionText} from '@libs/ReportActionsUtils'; import type {OptimisticChatReport, OptimisticCreatedReportAction, OptimisticIOUReportAction} from '@libs/ReportUtils'; @@ -43,7 +44,7 @@ import type BasePolicyParams from './types/BasePolicyParams'; import {getAllPersonalDetails} from '.'; import {getReceiptError, mergePolicyRecentlyUsedCategories, mergePolicyRecentlyUsedCurrencies} from './MoneyRequestBuilder'; -import {handleNavigateAfterExpenseCreate, highlightTransactionOnSearchRouteIfNeeded} from './NavigationHelpers'; +import {handleNavigateAfterExpenseCreate} from './NavigationHelpers'; import {getSearchOnyxUpdate} from './SearchUpdate'; type SendInvoiceInformation = { @@ -818,17 +819,25 @@ function sendInvoice({ onDeferred: () => addOptimization(CONST.TELEMETRY.SUBMIT_OPTIMIZATION.DEFERRED_WRITE), }); - highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate, transactionID, CONST.SEARCH.DATA_TYPES.INVOICE); - if (shouldHandleNavigation) { TransitionTracker.runAfterTransitions({callback: () => removeDraftTransaction(CONST.IOU.OPTIMISTIC_TRANSACTION_ID), waitForUpcomingTransition: true}); handleNavigateAfterExpenseCreate({ activeReportID: invoiceRoom.reportID, + iouReportID: invoiceReportID, transactionID, + transactionThreadReportID, isFromGlobalCreate, isInvoice: true, }); } else { + // Dismiss-first paths (orchestrator owns navigation); still surface feedback wherever the user + // lands. Invoices go to an invoice room (no expense-report table), so this resolves to the growl. + surfaceExpenseCreatedFeedback({ + iouReportID: invoiceReportID, + transactionID, + transactionThreadReportID, + isInvoice: true, + }); removeDraftTransaction(CONST.IOU.OPTIMISTIC_TRANSACTION_ID); } diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index 6114823272b8..49fcc2257550 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -10,6 +10,7 @@ import {calculateAmount as calculateIOUAmount, updateIOUOwnerAndTotal} from '@li import {formatPhoneNumber} from '@libs/LocalePhoneNumber'; import * as Localize from '@libs/Localize'; import isReportTopmostSplitNavigator from '@libs/Navigation/helpers/isReportTopmostSplitNavigator'; +import {surfaceExpenseCreatedFeedback} from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; import Navigation from '@libs/Navigation/Navigation'; import TransitionTracker from '@libs/Navigation/TransitionTracker'; import {roundToTwoDecimalPlaces} from '@libs/NumberUtils'; @@ -31,7 +32,6 @@ import { getReimbursableTotal, getReportNotificationPreference, getReportOrDraftReport, - getReportTransactions, getTransactionDetails, getUnheldReimbursableTotal, hasViolations as hasViolationsReportUtils, @@ -88,8 +88,7 @@ import { mergePolicyRecentlyUsedCategories, mergePolicyRecentlyUsedCurrencies, } from './MoneyRequestBuilder'; -import {dismissModalAndOpenReportInInboxTab, handleNavigateAfterExpenseCreate, highlightTransactionOnSearchRouteIfNeeded} from './NavigationHelpers'; -import {isOneToTwoTransactionTransition} from './PendingNewTransactions'; +import {dismissModalAndOpenReportInInboxTab, handleNavigateAfterExpenseCreate} from './NavigationHelpers'; type IOURequestType = ValueOf; @@ -2207,8 +2206,6 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest API.write(WRITE_COMMANDS.CREATE_DISTANCE_REQUEST, parameters, onyxData); }; - const isOneToTwoTransition = isOneToTwoTransactionTransition(isMoneyRequestReport, getReportTransactions(moneyRequestReportID)); - deferOrExecuteWrite(apiWrite, { shouldDeferForSearch: shouldDeferForSearch || !!(shouldHandleNavigation && isFromGlobalCreate && !isReportTopmostSplitNavigator()), optimisticWatchKey: `${ONYXKEYS.COLLECTION.TRANSACTION}${parameters.transactionID}`, @@ -2219,20 +2216,29 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest if (shouldHandleNavigation) { TransitionTracker.runAfterTransitions({callback: () => removeDraftTransaction(CONST.IOU.OPTIMISTIC_TRANSACTION_ID), waitForUpcomingTransition: true}); - highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate, parameters.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE); + const navigationActiveReportID = backToReport ?? activeReportID; + handleNavigateAfterExpenseCreate({ + activeReportID: navigationActiveReportID, + + iouReportID: parameters.iouReportID, + isFromGlobalCreate, + + transactionID: parameters.transactionID, + transactionThreadReportID: parameters.transactionThreadReportID, + shouldAddPendingNewTransactionIDs: isMoneyRequestReport, + }); } else { + // Dismiss-first paths (orchestrator owns navigation). Surface feedback wherever the user lands: + // highlight the new row for in-report adds, otherwise the "Expense added" growl with "View". + surfaceExpenseCreatedFeedback({ + iouReportID: parameters.iouReportID, + transactionID: parameters.transactionID, + transactionThreadReportID: parameters.transactionThreadReportID, + isMoneyRequestReport, + }); removeDraftTransaction(CONST.IOU.OPTIMISTIC_TRANSACTION_ID); } - const navigationActiveReportID = backToReport ?? activeReportID; - handleNavigateAfterExpenseCreate({ - activeReportID: navigationActiveReportID, - isFromGlobalCreate, - transactionID: parameters.transactionID, - shouldAddPendingNewTransactionIDs: isOneToTwoTransition || (shouldHandleNavigation && navigationActiveReportID === parameters.chatReportID), - shouldNavigate: shouldHandleNavigation, - }); - if (!isMoneyRequestReport) { notifyNewAction(activeReportID, undefined, true); } diff --git a/src/libs/actions/IOU/TrackExpense.ts b/src/libs/actions/IOU/TrackExpense.ts index d43512085c00..d763e7784ece 100644 --- a/src/libs/actions/IOU/TrackExpense.ts +++ b/src/libs/actions/IOU/TrackExpense.ts @@ -20,6 +20,7 @@ import {isMovingTransactionFromTrackExpense as isMovingTransactionFromTrackExpen import isFileUploadable from '@libs/isFileUploadable'; import {formatPhoneNumber} from '@libs/LocalePhoneNumber'; import Log from '@libs/Log'; +import {surfaceExpenseCreatedFeedback} from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; import Navigation from '@libs/Navigation/Navigation'; import {roundToTwoDecimalPlaces} from '@libs/NumberUtils'; import * as NumberUtils from '@libs/NumberUtils'; @@ -54,7 +55,6 @@ import { getParsedComment, getReportOrDraftReport, getReportRecipientAccountIDs, - getReportTransactions, isDraftReport, isHiddenForCurrentUser, isMoneyRequestReport as isMoneyRequestReportReportUtils, @@ -126,8 +126,7 @@ import type { import {deleteMoneyRequest, getCleanUpTransactionThreadReportOnyxData, getNavigationUrlOnMoneyRequestDelete} from './DeleteMoneyRequest'; import {getAllReports, getAllTransactionDrafts, getAllTransactions, getAllTransactionViolations, getMoneyRequestPolicyTags} from './index'; import {buildMinimalTransactionForFormula, getMoneyRequestInformation, getReceiptError, getReportPreviewAction, getTransactionWithPreservedLocalReceiptSource} from './MoneyRequestBuilder'; -import {highlightTransactionOnSearchRouteIfNeeded} from './NavigationHelpers'; -import {addPendingNewTransactionIDs, isOneToTwoTransactionTransition} from './PendingNewTransactions'; +import {handleNavigateAfterExpenseCreate} from './NavigationHelpers'; import {getSearchOnyxUpdate} from './SearchUpdate'; type TrackExpenseInformation = { @@ -1628,6 +1627,9 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep gpsPoint, action, shouldPlaySound = true, + shouldHandleNavigation = true, + isLastTransactionOfBatch = true, + backToReport, optimisticChatReportID, optimisticCreatedReportActionID, optimisticIOUReportID, @@ -1907,10 +1909,6 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep } } - if (isOneToTwoTransactionTransition(isMoneyRequestReport, getReportTransactions(moneyRequestReportID))) { - addPendingNewTransactionIDs(activeReportID, transaction.transactionID); - } - if (deferredAPIWrite) { deferOrExecuteWrite(deferredAPIWrite, { shouldDeferForSearch: false, @@ -1920,8 +1918,30 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep }); } - if (!requestMoneyInformation.isRetry) { - highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate, transaction.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE); + // Gate post-create navigation/growl to the final transaction of a multi-transaction batch so a + // multi-receipt submit fires a single dismiss/navigate and a single "Expense added" toast. + if (!requestMoneyInformation.isRetry && isLastTransactionOfBatch) { + if (shouldHandleNavigation) { + const navigationReportID = backToReport ?? activeReportID; + handleNavigateAfterExpenseCreate({ + activeReportID: navigationReportID, + iouReportID: iouReport?.reportID, + transactionID: transaction.transactionID, + transactionThreadReportID: transactionThreadReportID ?? iouAction?.childReportID, + isFromGlobalCreate, + shouldAddPendingNewTransactionIDs: isMoneyRequestReport, + }); + } else { + // Navigation is owned by SubmitExpenseOrchestrator (dismiss-first paths). Surface feedback + // wherever the user lands: highlight the new row for in-report adds, otherwise the + // "Expense added" growl with a "View" deep link. + surfaceExpenseCreatedFeedback({ + iouReportID: iouReport?.reportID, + transactionID: transaction.transactionID, + transactionThreadReportID: transactionThreadReportID ?? iouAction?.childReportID, + isMoneyRequestReport, + }); + } } if (activeReportID && !isMoneyRequestReport) { @@ -2347,6 +2367,8 @@ function trackExpense(params: CreateTrackExpenseParams) { existingTransaction, transactionParams: transactionData, accountantParams, + shouldHandleNavigation = true, + isLastTransactionOfBatch = true, shouldPlaySound = true, optimisticChatReportID, optimisticTransactionID, @@ -2745,8 +2767,28 @@ function trackExpense(params: CreateTrackExpenseParams) { } } - if (!params.isRetry) { - highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate, transaction?.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE); + // Gate post-create navigation/growl to the final transaction of a multi-transaction batch so a + // multi-receipt submit fires a single dismiss/navigate and a single "Expense added" toast. + if (!params.isRetry && isLastTransactionOfBatch) { + if (shouldHandleNavigation) { + handleNavigateAfterExpenseCreate({ + activeReportID, + iouReportID: iouReport?.reportID, + transactionID: transaction?.transactionID, + transactionThreadReportID: transactionThreadReportID ?? iouAction?.childReportID, + isFromGlobalCreate, + shouldAddPendingNewTransactionIDs: action === CONST.IOU.ACTION.CATEGORIZE || action === CONST.IOU.ACTION.SHARE, + }); + } else { + // Navigation is owned by SubmitExpenseOrchestrator (dismiss-first paths). Surface feedback + // wherever the user lands: highlight the new row for in-report adds, otherwise the growl. + surfaceExpenseCreatedFeedback({ + iouReportID: iouReport?.reportID, + transactionID: transaction?.transactionID, + transactionThreadReportID: transactionThreadReportID ?? iouAction?.childReportID, + isMoneyRequestReport, + }); + } } notifyNewAction(activeReportID, undefined, payeeAccountID === currentUserAccountIDParam); diff --git a/src/libs/actions/IOU/types/CreateTrackExpenseParams.ts b/src/libs/actions/IOU/types/CreateTrackExpenseParams.ts index 500b70f15a60..e969de336276 100644 --- a/src/libs/actions/IOU/types/CreateTrackExpenseParams.ts +++ b/src/libs/actions/IOU/types/CreateTrackExpenseParams.ts @@ -25,6 +25,14 @@ type CreateTrackExpenseParams = { accountantParams?: TrackExpenseAccountantParams; isRetry?: boolean; shouldPlaySound?: boolean; + shouldHandleNavigation?: boolean; + /** + * When a confirmation submits several transactions, the orchestrator calls this action once per + * transaction in a loop. Post-create navigation + the "Expense added" growl are owned by the + * action, so they must fire only once - for the final transaction of the batch. Defaults to true + * so single-transaction callers keep their existing behavior. + */ + isLastTransactionOfBatch?: boolean; /** Retry-path cleanup only; the action itself never reads this. */ draftTransactionIDs?: string[]; optimisticChatReportID?: string; diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts index 1ff25f94b6dd..c555ce6c3e7f 100644 --- a/src/libs/actions/Transaction.ts +++ b/src/libs/actions/Transaction.ts @@ -78,7 +78,6 @@ import type { } from '@src/types/onyx'; import type {OriginalMessageIOU, OriginalMessageModifiedExpense} from '@src/types/onyx/OriginalMessage'; import type {OnyxData} from '@src/types/onyx/Request'; -import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; import type {Waypoint, WaypointCollection} from '@src/types/onyx/Transaction'; import type TransactionState from '@src/types/utils/TransactionStateType'; @@ -1920,10 +1919,6 @@ function getDefaultP2PMileageRate() { API.read(READ_COMMANDS.GET_DEFAULT_P2P_MILEAGE_RATE, null); } -function mergeTransactionIdsHighlightOnSearchRoute(type: SearchDataTypes, data: Record | null) { - return Onyx.merge(ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE, {[type]: data}); -} - function getDuplicateTransactionDetails(transactionID?: string) { if (!transactionID) { return; @@ -1956,6 +1951,5 @@ export { changeTransactionsReport, setTransactionReport, getDefaultP2PMileageRate, - mergeTransactionIdsHighlightOnSearchRoute, getDuplicateTransactionDetails, }; diff --git a/src/pages/Share/SubmitDetailsPage.tsx b/src/pages/Share/SubmitDetailsPage.tsx index 4b81d07923e2..0ef65dfe818e 100644 --- a/src/pages/Share/SubmitDetailsPage.tsx +++ b/src/pages/Share/SubmitDetailsPage.tsx @@ -289,6 +289,8 @@ function SubmitDetailsPage({ isSelfTourViewed, optimisticTransactionID, currentUserLocalCurrency: currentUserPersonalDetails.localCurrencyCode ?? CONST.CURRENCY.USD, + // Navigation is owned by cleanupAndNavigateAfterExpenseCreate below; don't let the action navigate too. + shouldHandleNavigation: false, }); } else { const existingTransactionDraft = existingTransactionID ? transactionDrafts?.[existingTransactionID] : undefined; @@ -333,6 +335,8 @@ function SubmitDetailsPage({ betas, personalDetails, optimisticTransactionID, + // Navigation is owned by cleanupAndNavigateAfterExpenseCreate below; don't let the action navigate too. + shouldHandleNavigation: false, }); } cleanupAndNavigateAfterExpenseCreate({ diff --git a/src/pages/iou/request/step/AmountSubmission.ts b/src/pages/iou/request/step/AmountSubmission.ts index 128ecccd668b..d7841ca87a34 100644 --- a/src/pages/iou/request/step/AmountSubmission.ts +++ b/src/pages/iou/request/step/AmountSubmission.ts @@ -389,6 +389,7 @@ function submitAmount({ isSelfTourViewed, optimisticChatReportID, optimisticTransactionID, + shouldHandleNavigation: overrides.shouldHandleNavigation, }); } else { const existingTransactionDraft = existingTransactionID ? transactionDrafts?.[existingTransactionID] : undefined; @@ -423,9 +424,10 @@ function submitAmount({ personalDetails: allPersonalDetails, optimisticChatReportID, optimisticTransactionID, + shouldHandleNavigation: overrides.shouldHandleNavigation, }); } - cleanupAfterSkipConfirmSubmit(overrides.shouldHandleNavigation, { + cleanupAfterSkipConfirmSubmit({ report, action, draftTransactionIDs: draftTransactionIDsList, diff --git a/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts b/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts index fae24d3f1969..0a0b26e40620 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts +++ b/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts @@ -278,7 +278,7 @@ function handleMoneyRequestStepDistanceNavigation({ }); if (isCreatingTrackExpense && participant) { submitWithDismissFirst({ - // trackExpense is a void action with no navigation params; submitWithDismissFirst owns dismiss/reveal and cleanup runs after. + // submitWithDismissFirst owns dismiss/reveal; pass its shouldHandleNavigation through so trackExpense doesn't also navigate. executeWrite: (overrides) => { trackExpense({ report, @@ -330,8 +330,9 @@ function handleMoneyRequestStepDistanceNavigation({ optimisticTransactionID, optimisticChatReportID, currentUserLocalCurrency, + shouldHandleNavigation: overrides.shouldHandleNavigation, }); - cleanupAfterSkipConfirmSubmit(overrides.shouldHandleNavigation, { + cleanupAfterSkipConfirmSubmit({ report, action, draftTransactionIDs, @@ -406,7 +407,7 @@ function handleMoneyRequestStepDistanceNavigation({ policyTagList, }, }); - cleanupAfterSkipConfirmSubmit(overrides.shouldHandleNavigation, { + cleanupAfterSkipConfirmSubmit({ report, action, draftTransactionIDs, diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx index becc825f8ae9..c0abff775a87 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx @@ -213,7 +213,7 @@ function ScanSkipConfirmation({report, action, iouType, reportID, transactionID, shouldHandleNavigation: overrides.shouldHandleNavigation, shouldDeferForSearch: false, }); - cleanupAfterSkipConfirmSubmit(overrides.shouldHandleNavigation, { + cleanupAfterSkipConfirmSubmit({ report, action, draftTransactionIDs, @@ -283,7 +283,7 @@ function ScanSkipConfirmation({report, action, iouType, reportID, transactionID, executeWrite: (overrides) => { // Cleanup runs after each write (not once up front) so a stalled GPS lookup can't clear the draft before the expense exists. const runCleanup = () => - cleanupAfterSkipConfirmSubmit(overrides.shouldHandleNavigation, { + cleanupAfterSkipConfirmSubmit({ report, action, draftTransactionIDs, @@ -302,19 +302,20 @@ function ScanSkipConfirmation({report, action, iouType, reportID, transactionID, lat: successData.coords.latitude, long: successData.coords.longitude, }, + shouldHandleNavigation: overrides.shouldHandleNavigation, }); runCleanup(); }, (errorData) => { Log.info('[ScanSkipConfirmation] getCurrentPosition failed', false, errorData); // When there is an error, the money can still be requested, it just won't include the GPS coordinates - createTransaction(baseParams); + createTransaction({...baseParams, shouldHandleNavigation: overrides.shouldHandleNavigation}); runCleanup(); }, ); return; } - createTransaction(baseParams); + createTransaction({...baseParams, shouldHandleNavigation: overrides.shouldHandleNavigation}); runCleanup(); }, destinationReportID: scanDestinationReportID, diff --git a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts index 181266b3a21e..40920130cdff 100644 --- a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts +++ b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts @@ -19,10 +19,9 @@ import {getStringifiedGPSCoordinates} from '@libs/GPSDraftDetailsUtils'; import {getExistingTransactionID, resolveOptimisticChatReportID} from '@libs/IOUUtils'; import Log from '@libs/Log'; import cleanupAfterExpenseCreate from '@libs/Navigation/helpers/cleanupAfterExpenseCreate'; -import cleanupAndNavigateAfterExpenseCreate from '@libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate'; import dismissModalAndOpenReportInInboxTabHelper from '@libs/Navigation/helpers/dismissModalAndOpenReportInInboxTab'; import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; -import navigateAfterExpenseCreate from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; +import navigateAfterExpenseCreate, {surfaceExpenseCreatedFeedback} from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; import {rand64, roundToTwoDecimalPlaces} from '@libs/NumberUtils'; import {isTaxTrackingEnabled} from '@libs/PolicyUtils'; import { @@ -47,8 +46,6 @@ import { isManualDistanceRequest as isManualDistanceRequestTransactionUtils, } from '@libs/TransactionUtils'; -import {resolveChatTargetForSubmitCleanup} from '@pages/iou/request/step/resolveChatTarget'; - import {isOneToTwoTransactionTransition} from '@userActions/IOU/PendingNewTransactions'; import {submitPerDiemExpenseForSelfDM, submitPerDiemExpense as submitPerDiemExpenseIOUActions} from '@userActions/IOU/PerDiem'; import {getReceiverType, sendInvoice} from '@userActions/IOU/SendInvoice'; @@ -295,13 +292,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { const [storedTransactions] = useTransactionsByID(transactionIDs); function performPostBatchCleanup({ - participant, - shouldHandleNavigation, allTransactionsCreated, - fallbackOptimisticChatReportID, - navigateBackToReport, - lastOptimisticTransactionID, - preResolvedChatTarget, }: { participant: Participant; shouldHandleNavigation: boolean; @@ -316,32 +307,11 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { if (!allTransactionsCreated) { return; } - if (!shouldHandleNavigation) { - cleanupAfterExpenseCreate({draftTransactionIDs, linkedTrackedExpenseReportAction: lastTransaction?.linkedTrackedExpenseReportAction}); - return; - } - // requestMoney passes the chat it wrote to (iouReport.chatReportID) as preResolvedChatTarget; trackExpense is void so it still derives (self-DM case). - const {report: resolvedReport, chatReportID} = - preResolvedChatTarget ?? - resolveChatTargetForSubmitCleanup({ - participant, - currentUserAccountID: currentUserPersonalDetails.accountID, - report, - fallbackOptimisticChatReportID, - action, - }); - // Move-from-track (SUBMIT/CATEGORIZE/SHARE) reuses the tracked transaction's ID — mirror the builder's `existingTransactionID ?? optimisticTransactionID`. - const lastTransactionID = getExistingTransactionID(lastTransaction?.linkedTrackedExpenseReportAction) ?? lastOptimisticTransactionID; - cleanupAndNavigateAfterExpenseCreate({ - report: resolvedReport, - action, - draftTransactionIDs, - transactionID: lastTransactionID, - isFromGlobalCreate: getIsFromGlobalCreate(lastTransaction), - backToReport: navigateBackToReport, - optimisticChatReportID: chatReportID, - linkedTrackedExpenseReportAction: lastTransaction?.linkedTrackedExpenseReportAction, - }); + // Navigation + the "Expense added" growl are owned by the IOU action itself (requestMoney/trackExpense + // call handleNavigateAfterExpenseCreate / showExpenseAddedGrowl internally). Doing it here as well fired + // the growl twice and ran navigation twice on the shouldHandleNavigation=true paths, so this only performs + // the non-navigation cleanup now. + cleanupAfterExpenseCreate({draftTransactionIDs, linkedTrackedExpenseReportAction: lastTransaction?.linkedTrackedExpenseReportAction}); } function requestMoney(shouldHandleNavigation: boolean, gpsPoint?: GpsPoint) { @@ -366,7 +336,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { let allTransactionsCreated = true; let lastOptimisticTransactionID: string | undefined; - for (const item of transactions) { + for (const [index, item] of transactions.entries()) { lastOptimisticTransactionID = rand64(); const receipt = receiptFiles[item.transactionID]; const isTestReceipt = receipt?.isTestReceipt ?? false; @@ -472,6 +442,11 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { ...(isTimeRequest ? {type: CONST.TRANSACTION.TYPE.TIME, count: item.comment?.units?.count, rate: item.comment?.units?.rate, unit: CONST.TIME_TRACKING.UNIT.HOUR} : {}), }, optimisticTransactionID: lastOptimisticTransactionID, + // The action owns post-create navigation + growl, but only when the caller permits it + // (dismiss-first orchestrators pass shouldHandleNavigation=false after revealing/dismissing + // the destination themselves) and only for the final transaction of the batch. + shouldHandleNavigation, + isLastTransactionOfBatch: index === transactions.length - 1, shouldGenerateTransactionThreadReport, isASAPSubmitBetaEnabled, currentUserAccountIDParam: currentUserPersonalDetails.accountID, @@ -598,14 +573,28 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { const isOneToTwoTransition = !backToReport && isOneToTwoTransactionTransition(isMoneyRequestReport, reportTransactions); if (result && targetReportID) { - navigateAfterExpenseCreate({ - activeReportID: targetReportID, - transactionID: result.transactionID, - isFromGlobalCreate: getIsFromGlobalCreate(transaction), - hasMultipleTransactions: reportTransactions.length > 0, - shouldAddPendingNewTransactionIDs: (shouldHandleNavigation && targetReportID === chatReportID) || isOneToTwoTransition, - shouldNavigate: shouldHandleNavigation, - }); + if (shouldHandleNavigation) { + navigateAfterExpenseCreate({ + activeReportID: targetReportID, + iouReportID: result.iouReport?.reportID, + transactionID: result.transactionID, + transactionThreadReportID: result.transactionThreadReportID, + isFromGlobalCreate: getIsFromGlobalCreate(transaction), + hasMultipleTransactions: reportTransactions.length > 0, + shouldAddPendingNewTransactionIDs: targetReportID === chatReportID || isOneToTwoTransition, + }); + } else { + // Navigation is owned by SubmitExpenseOrchestrator (dismiss-first paths). Surface + // feedback wherever the user lands: highlight the new row for in-report adds, + // otherwise the "Expense added" growl with a "View" deep link - matching + // requestMoney/trackExpense/split/invoice. + surfaceExpenseCreatedFeedback({ + iouReportID: result.iouReport?.reportID, + transactionID: result.transactionID, + transactionThreadReportID: result.transactionThreadReportID, + isMoneyRequestReport, + }); + } } } } @@ -627,7 +616,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { const optimisticSelfDMReportID = selfDMReport?.reportID ?? generateReportID(); const policyExpenseChatReportActions = getAllPolicyExpenseChatReportActions(allReports, allReportActions); let lastOptimisticTransactionID: string | undefined; - for (const item of transactions) { + for (const [index, item] of transactions.entries()) { lastOptimisticTransactionID = rand64(); const isLinkedTrackedExpenseReportArchived = !!item.linkedTrackedExpenseReportID && privateIsArchivedMap[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${item.linkedTrackedExpenseReportID}`]; @@ -683,6 +672,11 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { }, optimisticChatReportID: optimisticSelfDMReportID, optimisticTransactionID: lastOptimisticTransactionID, + // The action owns post-create navigation + growl, but only when the caller permits it + // (dismiss-first orchestrators pass shouldHandleNavigation=false after revealing/dismissing + // the destination themselves) and only for the final transaction of the batch. + shouldHandleNavigation, + isLastTransactionOfBatch: index === transactions.length - 1, isASAPSubmitBetaEnabled, currentUser: {accountID: currentUserPersonalDetails.accountID, email}, introSelected, diff --git a/tests/actions/IOU/MoneyRequestTest.ts b/tests/actions/IOU/MoneyRequestTest.ts index 78e607bf2783..958bd5477652 100644 --- a/tests/actions/IOU/MoneyRequestTest.ts +++ b/tests/actions/IOU/MoneyRequestTest.ts @@ -72,19 +72,12 @@ jest.mock('@libs/getCurrentPosition'); // Fire executeWrite synchronously so downstream writes can be asserted. jest.mock('@libs/Navigation/helpers/submitWithDismissFirst', () => jest.requireActual('../../__mocks__/submitWithDismissFirst')); -// Cleanup helpers are spies so the move-from-track cleanup-id contract can be asserted. -const mockCleanupAndNavigateAfterExpenseCreate = jest.fn(); -const mockCleanupAfterExpenseCreate = jest.fn(); -jest.mock('@libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate', () => ({ +// cleanupAfterSkipConfirmSubmit is a spy so the move-from-track cleanup-id contract can be asserted. It's cleanup-only — the write action owns post-create navigation. +const mockCleanupAfterSkipConfirmSubmit = jest.fn(); +jest.mock('@libs/Navigation/helpers/cleanupAfterSkipConfirmSubmit', () => ({ __esModule: true, default: (...args: unknown[]): void => { - mockCleanupAndNavigateAfterExpenseCreate(...args); - }, -})); -jest.mock('@libs/Navigation/helpers/cleanupAfterExpenseCreate', () => ({ - __esModule: true, - default: (...args: unknown[]): void => { - mockCleanupAfterExpenseCreate(...args); + mockCleanupAfterSkipConfirmSubmit(...args); }, })); @@ -185,10 +178,10 @@ describe('MoneyRequest', () => { }), ); - // Deferral is channel-driven; the action no longer receives a shouldDeferForSearch / shouldHandleNavigation flag. + // Deferral is channel-driven (no shouldDeferForSearch flag), but the action owns post-create navigation, so it receives shouldHandleNavigation. const lastTrackExpenseParams = jest.mocked(TrackExpense.trackExpense).mock.calls.at(-1)?.at(0); expect(lastTrackExpenseParams && 'shouldDeferForSearch' in lastTrackExpenseParams).toBeFalsy(); - expect(lastTrackExpenseParams && 'shouldHandleNavigation' in lastTrackExpenseParams).toBeFalsy(); + expect(lastTrackExpenseParams && 'shouldHandleNavigation' in lastTrackExpenseParams).toBeTruthy(); }); it('should call requestMoney for non-TRACK (SEND) iouType', () => { @@ -702,7 +695,7 @@ describe('MoneyRequest', () => { expect.objectContaining({ report: baseParams.report, isDraftPolicy: false, - // Action is nav-free — UI owns navigation; draft + optimistic IDs are threaded in. + // The action owns post-create navigation; draft + optimistic IDs are threaded in alongside shouldHandleNavigation. existingTransaction: baseParams.transaction, transactionParams: expect.objectContaining({ amount: 0, @@ -716,7 +709,7 @@ describe('MoneyRequest', () => { const distanceTrackExpenseParams = jest.mocked(TrackExpense.trackExpense).mock.calls.at(-1)?.at(0); expect(typeof distanceTrackExpenseParams?.optimisticTransactionID).toBe('string'); expect(typeof distanceTrackExpenseParams?.optimisticChatReportID).toBe('string'); - expect(distanceTrackExpenseParams && 'shouldHandleNavigation' in distanceTrackExpenseParams).toBeFalsy(); + expect(distanceTrackExpenseParams && 'shouldHandleNavigation' in distanceTrackExpenseParams).toBeTruthy(); // The function must return after trackExpense and not call createDistanceRequest expect(Split.createDistanceRequest).not.toHaveBeenCalled(); @@ -751,8 +744,8 @@ describe('MoneyRequest', () => { await waitForBatchedUpdates(); - expect(mockCleanupAndNavigateAfterExpenseCreate).toHaveBeenCalledTimes(1); - expect(mockCleanupAndNavigateAfterExpenseCreate).toHaveBeenCalledWith( + expect(mockCleanupAfterSkipConfirmSubmit).toHaveBeenCalledTimes(1); + expect(mockCleanupAfterSkipConfirmSubmit).toHaveBeenCalledWith( expect.objectContaining({ transactionID: EXISTING_TRACKED_TRANSACTION_ID, }), @@ -772,7 +765,7 @@ describe('MoneyRequest', () => { await waitForBatchedUpdates(); - expect(mockCleanupAndNavigateAfterExpenseCreate).toHaveBeenCalledWith( + expect(mockCleanupAfterSkipConfirmSubmit).toHaveBeenCalledWith( expect.objectContaining({ transactionID: fakeTransaction.transactionID, }), diff --git a/tests/actions/IOU/SplitReportTotalsTest.ts b/tests/actions/IOU/SplitReportTotalsTest.ts index fe29acfd1ce9..9a9ce29f393b 100644 --- a/tests/actions/IOU/SplitReportTotalsTest.ts +++ b/tests/actions/IOU/SplitReportTotalsTest.ts @@ -1,10 +1,8 @@ -import {handleNavigateAfterExpenseCreate} from '@libs/actions/IOU/NavigationHelpers'; import {addPendingNewTransactionIDs} from '@libs/actions/IOU/PendingNewTransactions'; import '@libs/actions/IOU/MoneyRequest'; import {createSplitsAndOnyxData} from '@libs/actions/IOU/Split'; import {updateSplitTransactionsFromSplitExpensesFlow} from '@libs/actions/IOU/SplitTransactionUpdate'; import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; -import isReportTopmostSplitNavigator from '@libs/Navigation/helpers/isReportTopmostSplitNavigator'; import {rand64} from '@libs/NumberUtils'; import type * as PolicyUtils from '@libs/PolicyUtils'; @@ -432,33 +430,6 @@ describe('actions/IOU', () => { }); }); - it('handleNavigateAfterExpenseCreate', async () => { - const mockedIsReportTopmostSplitNavigator = isReportTopmostSplitNavigator as jest.MockedFunction; - const spyOnMergeTransactionIdsHighlightOnSearchRoute = jest.spyOn(require('@libs/actions/Transaction'), 'mergeTransactionIdsHighlightOnSearchRoute'); - const activeReportID = '1'; - const transactionID = '1'; - mockedIsReportTopmostSplitNavigator.mockReturnValue(false); - - handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: false}); - expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(0); - - handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: true}); - expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(0); - - mockedIsReportTopmostSplitNavigator.mockReturnValue(true); - handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: true, transactionID}); - expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(0); - - mockedIsReportTopmostSplitNavigator.mockReturnValue(false); - handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: true, transactionID}); - expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(0); - - handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: true, transactionID, isInvoice: true}); - expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(0); - - spyOnMergeTransactionIdsHighlightOnSearchRoute.mockReset(); - }); - describe('createSplitsAndOnyxData', () => { const mockPersonalDetails: PersonalDetailsList = { [RORY_ACCOUNT_ID]: {accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL, displayName: 'Rory'}, diff --git a/tests/ui/IOURequestStepAmountDraftTest.tsx b/tests/ui/IOURequestStepAmountDraftTest.tsx index 619ea1fae771..9f86f34113dd 100644 --- a/tests/ui/IOURequestStepAmountDraftTest.tsx +++ b/tests/ui/IOURequestStepAmountDraftTest.tsx @@ -18,7 +18,7 @@ import React from 'react'; import Onyx from 'react-native-onyx'; import * as TrackExpense from '../../src/libs/actions/IOU/TrackExpense'; -import cleanupAndNavigateAfterExpenseCreate from '../../src/libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate'; +import cleanupAfterSkipConfirmSubmit from '../../src/libs/Navigation/helpers/cleanupAfterSkipConfirmSubmit'; import createRandomTransaction from '../utils/collections/transaction'; import {signInWithTestUser} from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; @@ -105,8 +105,8 @@ jest.mock('@libs/Navigation/Navigation', () => { jest.mock('@libs/Navigation/helpers/submitWithDismissFirst', () => jest.requireActual('../__mocks__/submitWithDismissFirst')); -// Action-assertion test: post-create navigation is exercised elsewhere; keep the nav helpers inert here. -jest.mock('@libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate', () => jest.fn()); +// Action-assertion test: post-create navigation is exercised elsewhere; keep the cleanup helper inert here. +jest.mock('@libs/Navigation/helpers/cleanupAfterSkipConfirmSubmit', () => jest.fn()); jest.mock('@libs/Navigation/helpers/cleanupAfterExpenseCreate', () => jest.fn()); jest.mock('@react-navigation/native', () => { @@ -264,9 +264,9 @@ describe('IOURequestStepAmount - draft transactions coverage', () => { }), ); - // The same optimisticTransactionID must reach the action AND post-create cleanup nav, else the destination report highlights the wrong transaction. + // The same optimisticTransactionID must reach the action AND post-create cleanup, else the destination report highlights the wrong transaction. const requestMoneyArg = jest.mocked(TrackExpense.requestMoney).mock.calls.at(0)?.[0]; - const cleanupArg = jest.mocked(cleanupAndNavigateAfterExpenseCreate).mock.calls.at(0)?.[0]; + const cleanupArg = jest.mocked(cleanupAfterSkipConfirmSubmit).mock.calls.at(0)?.[0]; expect(typeof requestMoneyArg?.optimisticTransactionID).toBe('string'); expect(cleanupArg?.transactionID).toBe(requestMoneyArg?.optimisticTransactionID); }); diff --git a/tests/ui/ScanSkipConfirmationTest.tsx b/tests/ui/ScanSkipConfirmationTest.tsx index 6b21c513d9f4..84a465ef14e1 100644 --- a/tests/ui/ScanSkipConfirmationTest.tsx +++ b/tests/ui/ScanSkipConfirmationTest.tsx @@ -89,8 +89,8 @@ jest.mock('@libs/Navigation/helpers/submitWithDismissFirst', () => ({ jest.mock('@libs/Navigation/helpers/cleanupAfterSkipConfirmSubmit', () => ({ __esModule: true, - default: (shouldHandleNavigation: boolean, params: Record) => { - mockCleanupAfterSkipConfirmSubmit(shouldHandleNavigation, params); + default: (params: Record) => { + mockCleanupAfterSkipConfirmSubmit(params); }, })); @@ -185,7 +185,7 @@ describe('ScanSkipConfirmation submit orchestration', () => { }); await waitForBatchedUpdates(); - // The action layer must not navigate; the view layer drives dismiss-first + cleanup itself. + // The view layer threads the UI-resolved optimistic ids into the action and runs cleanup-only after dismiss-first; the action owns post-create navigation. expect(mockSubmitWithDismissFirst).toHaveBeenCalledTimes(1); expect(mockCreateTransaction).toHaveBeenCalledTimes(1); @@ -194,6 +194,6 @@ describe('ScanSkipConfirmation submit orchestration', () => { expect(capturedCreateTransactionArg?.optimisticChatReportID).toBe('optimistic-resolved'); expect(mockCleanupAfterSkipConfirmSubmit).toHaveBeenCalledTimes(1); - expect(mockCleanupAfterSkipConfirmSubmit).toHaveBeenCalledWith(true, expect.objectContaining({optimisticChatReportID: 'chat-resolved'})); + expect(mockCleanupAfterSkipConfirmSubmit).toHaveBeenCalledWith(expect.objectContaining({optimisticChatReportID: 'chat-resolved'})); }); }); diff --git a/tests/unit/Search/ExpenseFlatSearchViewTest.tsx b/tests/unit/Search/ExpenseFlatSearchViewTest.tsx index edb772aae689..445330225beb 100644 --- a/tests/unit/Search/ExpenseFlatSearchViewTest.tsx +++ b/tests/unit/Search/ExpenseFlatSearchViewTest.tsx @@ -194,7 +194,6 @@ function renderView(overrides: RenderOverrides = {}) { isMobileSelectionModeEnabled={overrides.isMobileSelectionModeEnabled ?? false} tableHeaderVisible={overrides.tableHeaderVisible ?? false} hasLoadedAllTransactions={overrides.hasLoadedAllTransactions ?? true} - newTransactions={[]} onSelectRow={onSelectRow} onEndReached={onEndReached} onLayout={onLayout} diff --git a/tests/unit/Search/ExpenseGroupedSearchViewTest.tsx b/tests/unit/Search/ExpenseGroupedSearchViewTest.tsx index 11d5c32dc589..5071abfdb7f3 100644 --- a/tests/unit/Search/ExpenseGroupedSearchViewTest.tsx +++ b/tests/unit/Search/ExpenseGroupedSearchViewTest.tsx @@ -61,13 +61,6 @@ jest.mock('@libs/getPlatform', () => ({ default: jest.fn(() => 'ios'), })); -// The grouped view imports isTransactionMatchWithGroupItem from the heavy SearchList module; stub it out. -jest.mock('@components/Search/SearchList', () => ({ - __esModule: true, - default: () => null, - isTransactionMatchWithGroupItem: jest.fn(() => false), -})); - // Group header/children only render on the split path; stub them so importing the view stays lightweight. jest.mock('@components/Search/SearchList/ListItem/GroupHeader', () => ({__esModule: true, default: () => null})); jest.mock('@components/Search/SearchList/ListItem/GroupChildrenContainer', () => ({__esModule: true, default: () => null})); @@ -203,7 +196,6 @@ function renderView(overrides: RenderOverrides = {}) { isMobileSelectionModeEnabled={overrides.isMobileSelectionModeEnabled ?? false} tableHeaderVisible={overrides.tableHeaderVisible ?? false} hasLoadedAllTransactions={overrides.hasLoadedAllTransactions ?? true} - newTransactions={[]} onSelectRow={onSelectRow} onEndReached={onEndReached} onLayout={onLayout} diff --git a/tests/unit/cleanupAfterSkipConfirmSubmitTest.ts b/tests/unit/cleanupAfterSkipConfirmSubmitTest.ts index f6d9de3ed130..059ff1584421 100644 --- a/tests/unit/cleanupAfterSkipConfirmSubmitTest.ts +++ b/tests/unit/cleanupAfterSkipConfirmSubmitTest.ts @@ -30,16 +30,8 @@ describe('cleanupAfterSkipConfirmSubmit', () => { jest.clearAllMocks(); }); - it('should delegate to cleanupAndNavigateAfterExpenseCreate with the full params when shouldHandleNavigation is true', () => { - cleanupAfterSkipConfirmSubmit(true, params); - - expect(cleanupAndNavigateAfterExpenseCreate).toHaveBeenCalledTimes(1); - expect(cleanupAndNavigateAfterExpenseCreate).toHaveBeenCalledWith(params); - expect(cleanupAfterExpenseCreate).not.toHaveBeenCalled(); - }); - - it('should delegate only the cleanup-relevant subset to cleanupAfterExpenseCreate when shouldHandleNavigation is false', () => { - cleanupAfterSkipConfirmSubmit(false, params); + it('should delegate only the cleanup-relevant subset to cleanupAfterExpenseCreate and never navigate (the write action owns navigation)', () => { + cleanupAfterSkipConfirmSubmit(params); expect(cleanupAfterExpenseCreate).toHaveBeenCalledTimes(1); expect(cleanupAfterExpenseCreate).toHaveBeenCalledWith({ diff --git a/tests/unit/hooks/useExpenseSubmission.test.ts b/tests/unit/hooks/useExpenseSubmission.test.ts index d3239773419b..051246cacf47 100644 --- a/tests/unit/hooks/useExpenseSubmission.test.ts +++ b/tests/unit/hooks/useExpenseSubmission.test.ts @@ -214,7 +214,7 @@ describe('useExpenseSubmission orchestrator-suppressed cleanup', () => { expect(mockCleanupAndNavigateAfterExpenseCreate).not.toHaveBeenCalled(); }); - it('calls cleanupAndNavigateAfterExpenseCreate (which composes cleanup) when shouldHandleNavigation=true', async () => { + it('does cleanup-only and never cleanupAndNavigateAfterExpenseCreate when shouldHandleNavigation=true (IOU action owns nav/growl)', async () => { const {result} = renderHook(() => useExpenseSubmission(buildParams())); await waitForBatchedUpdatesWithAct(); @@ -224,14 +224,15 @@ describe('useExpenseSubmission orchestrator-suppressed cleanup', () => { await waitForBatchedUpdatesWithAct(); expect(mockRequestMoneyAction).toHaveBeenCalledTimes(1); - expect(mockCleanupAndNavigateAfterExpenseCreate).toHaveBeenCalledTimes(1); - // cleanupAndNavigate is mocked here, so it never calls through to the real cleanupAfterExpenseCreate. - expect(mockCleanupAfterExpenseCreate).not.toHaveBeenCalled(); + // Navigation + the "Expense added" growl are owned by requestMoney itself; the hook only runs the + // non-navigation cleanup regardless of shouldHandleNavigation. + expect(mockCleanupAfterExpenseCreate).toHaveBeenCalledTimes(1); + expect(mockCleanupAndNavigateAfterExpenseCreate).not.toHaveBeenCalled(); }); - it('passes the existing tracked transaction ID (not a fresh optimistic id) to cleanup for a move-from-track SUBMIT', async () => { - // Move-from-track SUBMIT: the action writes the transaction under the EXISTING tracked transaction id, - // so cleanup must reference that same id — not a fresh rand64() optimistic one. + it('passes the moved transaction linkedTrackedExpenseReportAction to cleanup for a move-from-track SUBMIT', async () => { + // Move-from-track SUBMIT: cleanup is cleanup-only now, so it carries the moved transaction's + // linkedTrackedExpenseReportAction (used to release the original tracked expense draft). const EXISTING_TRACKED_TRANSACTION_ID = 'tracked-transaction-99'; const linkedTrackedExpenseReportAction = buildReportAction({ reportActionID: 'linked-action-1', @@ -261,16 +262,17 @@ describe('useExpenseSubmission orchestrator-suppressed cleanup', () => { }); await waitForBatchedUpdatesWithAct(); - expect(mockCleanupAndNavigateAfterExpenseCreate).toHaveBeenCalledTimes(1); - expect(mockCleanupAndNavigateAfterExpenseCreate).toHaveBeenCalledWith( + expect(mockCleanupAndNavigateAfterExpenseCreate).not.toHaveBeenCalled(); + expect(mockCleanupAfterExpenseCreate).toHaveBeenCalledTimes(1); + expect(mockCleanupAfterExpenseCreate).toHaveBeenCalledWith( expect.objectContaining({ - transactionID: EXISTING_TRACKED_TRANSACTION_ID, + linkedTrackedExpenseReportAction, }), ); }); - // F2: requestMoney returns the chat it wrote to via {iouReport}; the UI reads that instead of re-deriving it through resolveChatTargetForSubmitCleanup. - it('uses iouReport.chatReportID for cleanup nav and does not re-derive it via resolveChatTargetForSubmitCleanup', async () => { + // The IOU action owns post-submit navigation now, so the hook no longer derives a chat target for cleanup nav. + it('does not derive the chat target via resolveChatTargetForSubmitCleanup (the IOU action owns post-submit nav)', async () => { mockRequestMoneyAction.mockReturnValue({iouReport: {reportID: 'iou-1', chatReportID: 'iou-chat-77'}}); const {result} = renderHook(() => useExpenseSubmission(buildParams())); @@ -282,7 +284,8 @@ describe('useExpenseSubmission orchestrator-suppressed cleanup', () => { await waitForBatchedUpdatesWithAct(); expect(mockResolveChatTargetForSubmitCleanup).not.toHaveBeenCalled(); - expect(mockCleanupAndNavigateAfterExpenseCreate).toHaveBeenCalledWith(expect.objectContaining({optimisticChatReportID: 'iou-chat-77'})); + expect(mockCleanupAndNavigateAfterExpenseCreate).not.toHaveBeenCalled(); + expect(mockCleanupAfterExpenseCreate).toHaveBeenCalledTimes(1); }); it('routes tracked per diem SUBMIT through requestMoney so the original tracked expense is moved', async () => { @@ -354,7 +357,7 @@ describe('useExpenseSubmission orchestrator-suppressed cleanup', () => { expect(mockCleanupAndNavigateAfterExpenseCreate).not.toHaveBeenCalled(); }); - it('calls cleanupAndNavigateAfterExpenseCreate when shouldHandleNavigation=true', async () => { + it('does cleanup-only and never cleanupAndNavigateAfterExpenseCreate when shouldHandleNavigation=true (IOU action owns nav/growl)', async () => { const {result} = renderHook(() => useExpenseSubmission(buildParams({iouType: CONST.IOU.TYPE.TRACK}))); await waitForBatchedUpdatesWithAct(); @@ -364,8 +367,8 @@ describe('useExpenseSubmission orchestrator-suppressed cleanup', () => { await waitForBatchedUpdatesWithAct(); expect(mockTrackExpenseAction).toHaveBeenCalledTimes(1); - expect(mockCleanupAndNavigateAfterExpenseCreate).toHaveBeenCalledTimes(1); - expect(mockCleanupAfterExpenseCreate).not.toHaveBeenCalled(); + expect(mockCleanupAfterExpenseCreate).toHaveBeenCalledTimes(1); + expect(mockCleanupAndNavigateAfterExpenseCreate).not.toHaveBeenCalled(); }); it('forwards the per-iteration draft as existingTransaction so getTrackExpenseInformation finds it', async () => { diff --git a/tests/unit/useSearchHighlightAndScrollTest.ts b/tests/unit/useSearchAutoRefetchTest.ts similarity index 50% rename from tests/unit/useSearchHighlightAndScrollTest.ts rename to tests/unit/useSearchAutoRefetchTest.ts index 83e4428e13e0..72b0181e81a2 100644 --- a/tests/unit/useSearchHighlightAndScrollTest.ts +++ b/tests/unit/useSearchAutoRefetchTest.ts @@ -1,8 +1,8 @@ /* eslint-disable @typescript-eslint/naming-convention */ import {renderHook} from '@testing-library/react-native'; -import useSearchHighlightAndScroll from '@hooks/useSearchHighlightAndScroll'; -import type {UseSearchHighlightAndScroll} from '@hooks/useSearchHighlightAndScroll'; +import useSearchAutoRefetch from '@hooks/useSearchAutoRefetch'; +import type {UseSearchAutoRefetch} from '@hooks/useSearchAutoRefetch'; import {search} from '@libs/actions/Search'; @@ -29,14 +29,14 @@ afterEach(() => { jest.clearAllMocks(); }); -describe('useSearchHighlightAndScroll', () => { +describe('useSearchAutoRefetch', () => { beforeAll(async () => { Onyx.init({ keys: ONYXKEYS, }); }); - const baseProps: UseSearchHighlightAndScroll = { + const baseProps: UseSearchAutoRefetch = { shouldUseLiveData: false, searchResults: { data: { @@ -74,7 +74,7 @@ describe('useSearchHighlightAndScroll', () => { }; it('should not trigger search when collections are empty', () => { - renderHook(() => useSearchHighlightAndScroll(baseProps)); + renderHook(() => useSearchAutoRefetch(baseProps)); expect(search).not.toHaveBeenCalled(); }); @@ -85,7 +85,7 @@ describe('useSearchHighlightAndScroll', () => { previousTransactions: {'1': {transactionID: '1'}}, }; - const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), { + const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error initialProps, @@ -109,7 +109,7 @@ describe('useSearchHighlightAndScroll', () => { it('should not trigger search when not focused', () => { mockUseIsFocused.mockReturnValue(false); - const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), { + const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), { initialProps: baseProps, }); @@ -142,7 +142,7 @@ describe('useSearchHighlightAndScroll', () => { }, }; - const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), { + const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error initialProps: chatProps, @@ -177,7 +177,7 @@ describe('useSearchHighlightAndScroll', () => { }, }; - const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), { + const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error initialProps, @@ -216,7 +216,7 @@ describe('useSearchHighlightAndScroll', () => { }, }; - const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), { + const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error initialProps: chatProps, @@ -236,157 +236,4 @@ describe('useSearchHighlightAndScroll', () => { rerender(updatedProps); expect(search).not.toHaveBeenCalled(); }); - - it('should return multiple new search result keys when there are multiple new expenses', () => { - const {rerender, result} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), { - initialProps: baseProps, - }); - const updatedProps = { - ...baseProps, - searchResults: { - ...baseProps.searchResults, - data: { - transactions_1: { - transactionID: '1', - }, - transactions_2: { - transactionID: '2', - }, - }, - }, - transactions: { - '1': {transactionID: '1'}, - '2': {transactionID: '2'}, - '3': {transactionID: '3'}, - }, - previousTransactions: { - '1': {transactionID: '1'}, - }, - }; - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - rerender(updatedProps); - expect(result.current.newSearchResultKeys?.size).toBe(2); - }); - - it('should return new search result keys for manually highlighted expenses', async () => { - const spyOnMergeTransactionIdsHighlightOnSearchRoute = jest - .spyOn(require('@libs/actions/Transaction'), 'mergeTransactionIdsHighlightOnSearchRoute') - .mockImplementationOnce(jest.fn()); - // We need to mock requestAnimationFrame to mimic long Onyx merge overhead - jest.spyOn(global, 'requestAnimationFrame').mockImplementation((callback: FrameRequestCallback) => { - callback(performance.now()); - return 0; - }); - - await Onyx.merge(ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE, {[baseProps.queryJSON.type]: {'3': true}}); - - const {rerender, result} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), { - initialProps: baseProps, - }); - const updatedProps1 = { - ...baseProps, - searchResults: { - ...baseProps.searchResults, - data: { - transactions_1: { - transactionID: '1', - }, - transactions_2: { - transactionID: '2', - }, - }, - }, - transactions: { - '1': {transactionID: '1'}, - '2': {transactionID: '2'}, - '3': {transactionID: '3'}, - }, - previousTransactions: { - '1': {transactionID: '1'}, - }, - } as unknown as UseSearchHighlightAndScroll; - - // When there is no data yet, even if the transactionID has been added to manual highlight transactionIDs, - // it still will not be included in newSearchResultKeys. - rerender(updatedProps1); - expect(result.current.newSearchResultKeys?.size).toBe(2); - expect([...(result.current.newSearchResultKeys ?? new Set())]).not.toContain('transactions_3'); - - // When the data contains the highlight transactionID, it will be highlighted. - const updatedProps2 = { - ...updatedProps1, - searchResults: { - ...updatedProps1.searchResults, - data: { - transactions_1: { - transactionID: '1', - }, - transactions_2: { - transactionID: '2', - }, - transactions_3: { - transactionID: '3', - }, - }, - }, - } as unknown as UseSearchHighlightAndScroll; - - rerender(updatedProps2); - expect(result.current.newSearchResultKeys?.size).toBe(1); - expect([...(result.current.newSearchResultKeys ?? new Set())]).toContain('transactions_3'); - - expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledWith(baseProps.queryJSON.type, {'3': false}); - }); - - it('should return multiple new search result keys when there are multiple new chats', () => { - const chatProps = { - ...baseProps, - queryJSON: {...baseProps.queryJSON, type: 'chat' as const}, - reportActions: { - reportActions_1: { - '1': {actionName: 'EXISTING', reportActionID: '1'}, - }, - }, - }; - const {rerender, result} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - initialProps: chatProps, - }); - const updatedProps = { - ...chatProps, - searchResults: { - ...baseProps.searchResults, - data: { - reportActions_1: { - '1': {actionName: 'EXISTING', reportActionID: '1'}, - }, - reportActions_2: { - '2': {actionName: 'EXISTING', reportActionID: '2'}, - }, - }, - }, - reportActions: { - reportActions_1: { - '1': {actionName: 'EXISTING', reportActionID: '1'}, - }, - reportActions_2: { - '2': {actionName: 'EXISTING', reportActionID: '2'}, - }, - reportActions_3: { - '3': {actionName: 'EXISTING', reportActionID: '3'}, - }, - }, - previousReportActions: { - reportActions_1: { - '1': {actionName: 'EXISTING', reportActionID: '1'}, - }, - }, - }; - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - rerender(updatedProps); - expect(result.current.newSearchResultKeys?.size).toBe(2); - }); }); From 1099740af2789cdca19602e6352601d33967e30c Mon Sep 17 00:00:00 2001 From: borys3kk Date: Tue, 7 Jul 2026 12:11:51 +0200 Subject: [PATCH 02/28] Resolve self-review findings for the expense-added growl wiring - Fix multi-receipt batches firing navigation/growl once per receipt: createTransaction now threads isLastTransactionOfBatch into requestMoney/trackExpense so post-create feedback fires only on the final transaction - Forward backToReport from useExpenseSubmission into requestMoney so post-create navigation returns to the originating report - Narrow cleanupAfterSkipConfirmSubmit to cleanup-only (CleanupAfterExpenseCreateParams): the write action owns post-create navigation now, so the dispatcher must never navigate too; trim the dead navigation params from all skip-confirm call sites and drop the now-unused action/getExistingTransactionID plumbing from ScanRouter/ScanSkipConfirmation/distance navigation - Trim performPostBatchCleanup to {allTransactionsCreated} and remove the dead chat-target resolution from useExpenseSubmission - Remove the newSearchResultKeys highlight stamping from useSearchSnapshot and the shouldUseLiveData prop from useSearchAutoRefetch (both dead after the growl replaced the post-create highlight) - showExpenseAddedGrowl: clear the safety timeout once resolved, resolve immediately when there is no iouReportID to subscribe to, and drop the microtask deferral on the single-transaction inbox path - Update affected tests for the cleanup-only contract --- .../Search/hooks/useSearchSnapshot.ts | 57 ++++--------------- src/components/Search/index.tsx | 3 +- src/hooks/useSearchAutoRefetch.ts | 4 +- .../helpers/cleanupAfterExpenseCreate.ts | 1 + .../helpers/cleanupAfterSkipConfirmSubmit.ts | 6 +- .../helpers/navigateAfterExpenseCreate.ts | 27 +++++---- src/libs/actions/IOU/MoneyRequest.ts | 3 + src/libs/actions/IOU/MoneyRequestBuilder.ts | 1 + src/libs/actions/IOU/TrackExpense.ts | 3 + .../iou/request/step/AmountSubmission.ts | 6 -- ...andleMoneyRequestStepDistanceNavigation.ts | 20 +------ .../hooks/useDistanceNavigation.ts | 1 - .../hooks/useOdometerNavigation.ts | 1 - .../index.native.tsx | 1 - .../step/IOURequestStepDistanceManual.tsx | 1 - .../step/IOURequestStepScan/ScanRouter.tsx | 8 +-- .../components/ScanSkipConfirmation.tsx | 25 ++------ .../step/confirmation/useExpenseSubmission.ts | 37 ++---------- tests/actions/IOU/MoneyRequestTest.ts | 13 +++-- tests/ui/IOURequestStepAmountDraftTest.tsx | 6 +- tests/ui/ScanSkipConfirmationTest.tsx | 2 +- tests/unit/Search/useSearchSnapshotTest.ts | 30 +--------- .../unit/cleanupAfterSkipConfirmSubmitTest.ts | 23 +------- tests/unit/useSearchAutoRefetchTest.ts | 1 - 24 files changed, 69 insertions(+), 211 deletions(-) diff --git a/src/components/Search/hooks/useSearchSnapshot.ts b/src/components/Search/hooks/useSearchSnapshot.ts index 1b1729e2678b..393b2218d725 100644 --- a/src/components/Search/hooks/useSearchSnapshot.ts +++ b/src/components/Search/hooks/useSearchSnapshot.ts @@ -1,5 +1,5 @@ import {useSearchQueryContext, useSearchResultsContext} from '@components/Search/SearchContext'; -import type {ReportActionListItemType, SearchListItem, TransactionGroupListItemType, TransactionListItemType} from '@components/Search/SearchList/ListItem/types'; +import type {SearchListItem, TransactionGroupListItemType, TransactionListItemType} from '@components/Search/SearchList/ListItem/types'; import type {SearchColumnType, SearchData, SearchQueryJSON} from '@components/Search/types'; import useActionLoadingReportIDs from '@hooks/useActionLoadingReportIDs'; @@ -70,10 +70,8 @@ type UseSearchSnapshotParams = { queryJSON: Readonly; /** The current search snapshot, owned by the ancestor and passed in. */ searchResults: SearchResults | undefined; - /** Keys flagged for the post-create highlight animation. */ - newSearchResultKeys: Set | null | undefined; /** Full TRANSACTION + REPORT_ACTIONS collections used by the optimistic-row tracking. Threaded in from - * the parent (which already subscribes to them for the highlight hook) so we don't open duplicate + * the parent (which already subscribes to them for the auto-refetch hook) so we don't open duplicate * full-collection reads. */ transactions: OptimisticTrackingParams['transactions']; reportActions: OptimisticTrackingParams['reportActions']; @@ -90,11 +88,11 @@ const hashToString = (queryHash?: number) => (queryHash || queryHash === 0 ? Str * Single data layer for the Search screen. * * Owns the live inputs `getSections` needs for sort/group correctness, runs the deferral-gated - * sort/group/paginate projection, stamps the post-create highlight, enriches grouped views with their - * per-group sub-snapshots, and absorbs the optimistic-row resilience. Returns the sorted rows plus the - * list-level meta and the optimistic-tracking carriers that `` consumes. + * sort/group/paginate projection, enriches grouped views with their per-group sub-snapshots, and + * absorbs the optimistic-row resilience. Returns the sorted rows plus the list-level meta and the + * optimistic-tracking carriers that `` consumes. */ -function useSearchSnapshot({queryJSON, searchResults, newSearchResultKeys, transactions, reportActions}: UseSearchSnapshotParams): SearchSnapshotResult { +function useSearchSnapshot({queryJSON, searchResults, transactions, reportActions}: UseSearchSnapshotParams): SearchSnapshotResult { const {type, status, sortBy, sortOrder, hash, groupBy} = queryJSON; const {isOffline} = useNetwork(); @@ -297,7 +295,7 @@ function useSearchSnapshot({queryJSON, searchResults, newSearchResultKeys, trans convertToDisplayString, ]); - // Stage 3: sort the (enriched) data, then stamp the post-create highlight on each row. getSortedSections + // Stage 3: sort the (enriched) data, then stamp the query hash on each row. getSortedSections // accepts the full section union; our SearchListItem[] is a compatible subset of that input. const chartData = useMemo(() => { if (!shouldComputeSections) { @@ -309,48 +307,13 @@ function useSearchSnapshot({queryJSON, searchResults, newSearchResultKeys, trans policyTags, fallbackPolicyID: policyForMovingExpensesID, }).map((item) => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- chat variant rows are report actions - const reportActionID = (item as ReportActionListItemType).reportActionID; - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- non-chat variant rows carry a transactionID - const transactionID = (item as TransactionListItemType).transactionID; - const baseKey = isChat ? `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportActionID}` : `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`; - - const isBaseKeyMatch = !!newSearchResultKeys?.has(baseKey); - - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- group rows expose nested transactions - const groupTransactionsForHighlight = (item as TransactionGroupListItemType)?.transactions; - const isAnyTransactionMatch = - !isChat && - groupTransactionsForHighlight?.some((transaction) => { - const transactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`; - return !!newSearchResultKeys?.has(transactionKey); - }); - - const shouldAnimateInHighlight = isBaseKeyMatch || isAnyTransactionMatch; - - if (item.shouldAnimateInHighlight === shouldAnimateInHighlight && item.hash === hash) { + if (item.hash === hash) { return item; } - return {...item, shouldAnimateInHighlight, hash}; + return {...item, hash}; }); - }, [ - shouldComputeSections, - type, - status, - filteredData, - localeCompare, - translate, - sortBy, - sortOrder, - validGroupBy, - policyCategories, - policyTags, - policyForMovingExpensesID, - isChat, - newSearchResultKeys, - hash, - ]); + }, [shouldComputeSections, type, status, filteredData, localeCompare, translate, sortBy, sortOrder, validGroupBy, policyCategories, policyTags, policyForMovingExpensesID, hash]); // Keep the optimistic row visible across a snapshot-replacement gap for up to // OPTIMISTIC_ROLLBACK_GRACE_MS until the new snapshot picks it up or the grace expires. diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index f23ae385f7a9..92a932929b4c 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -216,7 +216,6 @@ function Search({ shouldCalculateTotals, reportActions, previousReportActions, - shouldUseLiveData, }); const { @@ -235,7 +234,7 @@ function Search({ hasPendingWriteOnMountRef, skipDeferralOnFocusRef, rearmTracking, - } = useSearchSnapshot({queryJSON, searchResults, newSearchResultKeys: null, transactions, reportActions}); + } = useSearchSnapshot({queryJSON, searchResults, transactions, reportActions}); // There's a race condition in Onyx which makes it return data from the previous Search, so in addition to checking that the data is loaded // we also need to check that the searchResults matches the type and status of the current search diff --git a/src/hooks/useSearchAutoRefetch.ts b/src/hooks/useSearchAutoRefetch.ts index b2d4ca3c954c..ade8fa59e520 100644 --- a/src/hooks/useSearchAutoRefetch.ts +++ b/src/hooks/useSearchAutoRefetch.ts @@ -26,7 +26,6 @@ type UseSearchAutoRefetch = { searchKey: SearchKey | undefined; offset: number; shouldCalculateTotals: boolean; - shouldUseLiveData: boolean; }; function useSearchAutoRefetch({ @@ -39,7 +38,6 @@ function useSearchAutoRefetch({ searchKey, offset, shouldCalculateTotals, - shouldUseLiveData, }: UseSearchAutoRefetch) { const isFocused = useIsFocused(); const {isOffline} = useNetwork(); @@ -129,7 +127,7 @@ function useSearchAutoRefetch({ } searchTriggeredRef.current = false; - }, [searchResults?.search?.isLoading, shouldUseLiveData, searchResultsData]); + }, [searchResults?.search?.isLoading, searchResultsData]); } function getTransactionIDFromValue(value: unknown): string | undefined { diff --git a/src/libs/Navigation/helpers/cleanupAfterExpenseCreate.ts b/src/libs/Navigation/helpers/cleanupAfterExpenseCreate.ts index 0fe14f77451a..611267c38d7a 100644 --- a/src/libs/Navigation/helpers/cleanupAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/cleanupAfterExpenseCreate.ts @@ -28,3 +28,4 @@ function cleanupAfterExpenseCreate({draftTransactionIDs, linkedTrackedExpenseRep } export default cleanupAfterExpenseCreate; +export type {CleanupAfterExpenseCreateParams}; diff --git a/src/libs/Navigation/helpers/cleanupAfterSkipConfirmSubmit.ts b/src/libs/Navigation/helpers/cleanupAfterSkipConfirmSubmit.ts index be5a15ac4431..3c959f01732b 100644 --- a/src/libs/Navigation/helpers/cleanupAfterSkipConfirmSubmit.ts +++ b/src/libs/Navigation/helpers/cleanupAfterSkipConfirmSubmit.ts @@ -1,4 +1,4 @@ -import type {CleanupAndNavigateAfterExpenseCreateParams} from './cleanupAndNavigateAfterExpenseCreate'; +import type {CleanupAfterExpenseCreateParams} from './cleanupAfterExpenseCreate'; import cleanupAfterExpenseCreate from './cleanupAfterExpenseCreate'; @@ -8,8 +8,8 @@ import cleanupAfterExpenseCreate from './cleanupAfterExpenseCreate'; * `shouldHandleNavigation`), so cleanup must never navigate too or the fallback path would run * navigation/growl twice. Navigation on dismiss-first paths is done by `submitWithDismissFirst`. */ -function cleanupAfterSkipConfirmSubmit(params: CleanupAndNavigateAfterExpenseCreateParams) { - cleanupAfterExpenseCreate({draftTransactionIDs: params.draftTransactionIDs, linkedTrackedExpenseReportAction: params.linkedTrackedExpenseReportAction}); +function cleanupAfterSkipConfirmSubmit({draftTransactionIDs, linkedTrackedExpenseReportAction}: CleanupAfterExpenseCreateParams) { + cleanupAfterExpenseCreate({draftTransactionIDs, linkedTrackedExpenseReportAction}); } export default cleanupAfterSkipConfirmSubmit; diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index 42f300fc2477..c3d6f7f9d532 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -173,11 +173,16 @@ function showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadRep if (iouReportID && hasMultipleReportTransactions) { Navigation.navigate(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: iouReportID, backTo})); - } - setNavigationActionToMicrotaskQueue(() => { - setActiveTransactionIDs([transactionID]).then(() => { - Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: resolvedThreadReportID, backTo: Navigation.getActiveRoute()})); + // Defer so the thread RHP stacks on top of the expense report navigation above. + setNavigationActionToMicrotaskQueue(() => { + setActiveTransactionIDs([transactionID]).then(() => { + Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: resolvedThreadReportID, backTo: Navigation.getActiveRoute()})); + }); }); + return; + } + setActiveTransactionIDs([transactionID]).then(() => { + Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: resolvedThreadReportID, backTo})); }); }; // eslint-disable-next-line @typescript-eslint/no-deprecated -- imperative module (not a React component); no useLocalize hook available here @@ -196,9 +201,9 @@ function showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadRep } // No iouReportID to subscribe to (and no thread ID was passed): the reportActions key would be - // `reportActions_undefined` and the callback below early-returns on `!iouReportID` forever, so the - // subscription can never resolve and would only fire via the 8s timeout. Resolve immediately with - // the same fallback the timeout would produce (a growl without "View" when no thread is resolvable). + // `reportActions_undefined`, so the subscription below could never resolve an iouAction and would only + // fire via the safety timeout. Resolve immediately with the same fallback the timeout would produce + // (a growl without "View" when no thread is resolvable). if (!iouReportID) { showGrowl(buildThreadFromOnyx()); return; @@ -208,11 +213,12 @@ function showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadRep // optimistic data → iouAction lands → we show the growl. const SAFETY_TIMEOUT_MS = 8000; let resolved = false; + let safetyTimeoutID: ReturnType | undefined; const reportActionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}` as const; const connectionId = Onyx.connectWithoutView({ key: reportActionsKey, callback: () => { - if (resolved || !iouReportID) { + if (resolved) { return; } const iouAction = getIOUActionForReportID(iouReportID, transactionID); @@ -220,13 +226,14 @@ function showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadRep return; } resolved = true; + clearTimeout(safetyTimeoutID); Onyx.disconnect(connectionId); const threadReportID = buildThreadFromOnyx(); showGrowl(threadReportID); }, }); - setTimeout(() => { + safetyTimeoutID = setTimeout(() => { if (resolved) { return; } @@ -325,7 +332,7 @@ function navigateAfterExpenseCreate({ const type = isInvoice ? CONST.SEARCH.DATA_TYPES.INVOICE : CONST.SEARCH.DATA_TYPES.EXPENSE; - // POC variant B: navigate to Search (if not already there), then show the "View" growl on + // Navigate to Search (if not already there), then show the "View" growl on // Search once the optimistic IOU action lands in Onyx. Runs whether or not the user is // already on Spend — on the SEARCH_PRE_INSERT fast path the orchestrator pre-inserts Search // and dismisses the modal before createTransaction runs, so by the time we get here the user diff --git a/src/libs/actions/IOU/MoneyRequest.ts b/src/libs/actions/IOU/MoneyRequest.ts index 60cdfb3605e7..db3076555152 100644 --- a/src/libs/actions/IOU/MoneyRequest.ts +++ b/src/libs/actions/IOU/MoneyRequest.ts @@ -172,6 +172,8 @@ function createTransaction({ optimisticTransactionID, currentUserLocalCurrency, shouldHandleNavigation, + // Navigation/growl must fire once per multi-receipt batch, on its final transaction. + isLastTransactionOfBatch: index === files.length - 1, }); } else { const existingTransactionID = getExistingTransactionID(transaction?.linkedTrackedExpenseReportAction); @@ -215,6 +217,7 @@ function createTransaction({ optimisticChatReportID, optimisticTransactionID, shouldHandleNavigation, + isLastTransactionOfBatch: index === files.length - 1, }); } } diff --git a/src/libs/actions/IOU/MoneyRequestBuilder.ts b/src/libs/actions/IOU/MoneyRequestBuilder.ts index 592fcfc3dd95..336217c1c397 100644 --- a/src/libs/actions/IOU/MoneyRequestBuilder.ts +++ b/src/libs/actions/IOU/MoneyRequestBuilder.ts @@ -174,6 +174,7 @@ type RequestMoneyInformation = { * so single-transaction callers keep their existing behavior. */ isLastTransactionOfBatch?: boolean; + /** Report the money request flow started from; post-create navigation returns there instead of the written-to chat. */ backToReport?: string; /** Retry-path cleanup only; the action itself never reads this. */ draftTransactionIDs?: string[]; diff --git a/src/libs/actions/IOU/TrackExpense.ts b/src/libs/actions/IOU/TrackExpense.ts index d763e7784ece..315dad91347a 100644 --- a/src/libs/actions/IOU/TrackExpense.ts +++ b/src/libs/actions/IOU/TrackExpense.ts @@ -2777,6 +2777,9 @@ function trackExpense(params: CreateTrackExpenseParams) { transactionID: transaction?.transactionID, transactionThreadReportID: transactionThreadReportID ?? iouAction?.childReportID, isFromGlobalCreate, + // Moving a tracked expense (CATEGORIZE/SHARE) lands on a report where the transaction + // already exists before the view mounts, so diff-based new-transaction detection misses + // it - pending IDs are the fallback highlight path (see useNewTransactions). shouldAddPendingNewTransactionIDs: action === CONST.IOU.ACTION.CATEGORIZE || action === CONST.IOU.ACTION.SHARE, }); } else { diff --git a/src/pages/iou/request/step/AmountSubmission.ts b/src/pages/iou/request/step/AmountSubmission.ts index d7841ca87a34..0d4c5b0bac8f 100644 --- a/src/pages/iou/request/step/AmountSubmission.ts +++ b/src/pages/iou/request/step/AmountSubmission.ts @@ -428,13 +428,7 @@ function submitAmount({ }); } cleanupAfterSkipConfirmSubmit({ - report, - action, draftTransactionIDs: draftTransactionIDsList, - transactionID: existingTransactionID ?? optimisticTransactionID, - isFromGlobalCreate: getIsFromGlobalCreate(transaction), - backToReport, - optimisticChatReportID, linkedTrackedExpenseReportAction: transaction?.linkedTrackedExpenseReportAction, }); }; diff --git a/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts b/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts index 0a0b26e40620..f3d72431b162 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts +++ b/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts @@ -11,7 +11,7 @@ import {createDistanceRequest, resetSplitShares} from '@libs/actions/IOU/Split'; import {trackExpense} from '@libs/actions/IOU/TrackExpense'; import {getCurrencySymbol} from '@libs/CurrencyUtils'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; -import {calculateDefaultReimbursable, getExistingTransactionID, navigateToConfirmationPage, navigateToParticipantPage} from '@libs/IOUUtils'; +import {calculateDefaultReimbursable, navigateToConfirmationPage, navigateToParticipantPage} from '@libs/IOUUtils'; import {toLocaleDigit} from '@libs/LocaleDigitUtils'; import cleanupAfterSkipConfirmSubmit from '@libs/Navigation/helpers/cleanupAfterSkipConfirmSubmit'; import {submitWithDismissFirst} from '@libs/Navigation/helpers/submitWithDismissFirst'; @@ -24,7 +24,7 @@ import {getDefaultTaxCode, getDistanceRequestType, getIsFromGlobalCreate, getVal import {setTransactionReport} from '@userActions/Transaction'; -import type {IOUAction, IOUType} from '@src/CONST'; +import type {IOUType} from '@src/CONST'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; import type {TranslationParameters, TranslationPaths} from '@src/languages/types'; @@ -100,7 +100,6 @@ type MoneyRequestStepDistanceNavigationParams = { optimisticTransactionID: string; optimisticChatReportID: string | undefined; reportDraft: OnyxEntry | undefined; - action: IOUAction; }; /** Amount + merchant for a manual-distance submit; pending placeholders otherwise (waypoint/GPS distance is computed server-side). */ @@ -194,7 +193,6 @@ function handleMoneyRequestStepDistanceNavigation({ optimisticTransactionID, optimisticChatReportID, reportDraft, - action, }: MoneyRequestStepDistanceNavigationParams): void { const isManualDistance = manualDistance !== undefined; const isOdometerDistance = odometerDistance !== undefined; @@ -210,7 +208,6 @@ function handleMoneyRequestStepDistanceNavigation({ } const distance = manualDistance ?? gpsDistance ?? odometerDistance; - const transactionIsFromGlobalCreate = getIsFromGlobalCreate(transaction); const transactionLinkedTrackedExpenseReportAction = transaction?.linkedTrackedExpenseReportAction; // If a reportID exists in the report object, it's because either: @@ -333,13 +330,7 @@ function handleMoneyRequestStepDistanceNavigation({ shouldHandleNavigation: overrides.shouldHandleNavigation, }); cleanupAfterSkipConfirmSubmit({ - report, - action, draftTransactionIDs, - transactionID: getExistingTransactionID(transactionLinkedTrackedExpenseReportAction) ?? optimisticTransactionID, - isFromGlobalCreate: transactionIsFromGlobalCreate, - backToReport, - optimisticChatReportID, linkedTrackedExpenseReportAction: transactionLinkedTrackedExpenseReportAction, }); }, @@ -408,14 +399,7 @@ function handleMoneyRequestStepDistanceNavigation({ }, }); cleanupAfterSkipConfirmSubmit({ - report, - action, draftTransactionIDs, - // createDistanceRequest writes under the existing draft transaction, so the cleanup target must mirror that id, not a fresh optimistic one. - transactionID: getExistingTransactionID(transactionLinkedTrackedExpenseReportAction) ?? transaction?.transactionID, - isFromGlobalCreate: transactionIsFromGlobalCreate, - backToReport, - optimisticChatReportID, linkedTrackedExpenseReportAction: transactionLinkedTrackedExpenseReportAction, }); }, diff --git a/src/pages/iou/request/step/IOURequestStepDistance/hooks/useDistanceNavigation.ts b/src/pages/iou/request/step/IOURequestStepDistance/hooks/useDistanceNavigation.ts index d422e2334886..439fa085c733 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance/hooks/useDistanceNavigation.ts +++ b/src/pages/iou/request/step/IOURequestStepDistance/hooks/useDistanceNavigation.ts @@ -150,7 +150,6 @@ function useDistanceNavigation({ handleMoneyRequestStepDistanceNavigation({ iouType, - action, report, policy, transaction, diff --git a/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerNavigation.ts b/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerNavigation.ts index cde930a180c6..44f7f43f655f 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerNavigation.ts +++ b/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerNavigation.ts @@ -154,7 +154,6 @@ function useOdometerNavigation({ handleMoneyRequestStepDistanceNavigation({ iouType, - action, report, policy, transaction, diff --git a/src/pages/iou/request/step/IOURequestStepDistanceGPS/index.native.tsx b/src/pages/iou/request/step/IOURequestStepDistanceGPS/index.native.tsx index 57f367d6e9c9..04fd18593062 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceGPS/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceGPS/index.native.tsx @@ -124,7 +124,6 @@ function IOURequestStepDistanceGPS({ handleMoneyRequestStepDistanceNavigation({ iouType, - action, report, policy, transaction, diff --git a/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx b/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx index c6f5c9012585..d6480563bb67 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx @@ -245,7 +245,6 @@ function IOURequestStepDistanceManual({ handleMoneyRequestStepDistanceNavigation({ iouType, - action, report, policy, transaction, diff --git a/src/pages/iou/request/step/IOURequestStepScan/ScanRouter.tsx b/src/pages/iou/request/step/IOURequestStepScan/ScanRouter.tsx index 89125ae666dd..66c08f49ab0b 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/ScanRouter.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/ScanRouter.tsx @@ -33,7 +33,6 @@ type ScanRouterProps = { type NonGlobalCreateProps = { report: OnyxEntry; - action: IOUAction; iouType: IOUType; reportID: string; transactionID: string; @@ -49,7 +48,7 @@ const policyRequiresTagOrCategorySelector = (policy: OnyxEntry) => !!pol * Owns the policy + skip-confirmation subscriptions so the edit and global-create branches don't pay for them. * Decides between SkipConfirmation (quick action) and FromReport (report (+) entry). */ -function ScanNonGlobalCreate({report, action, iouType, reportID, transactionID, transaction, backToReport}: NonGlobalCreateProps) { +function ScanNonGlobalCreate({report, iouType, reportID, transactionID, transaction, backToReport}: NonGlobalCreateProps) { const [policyRequiresTagOrCategory] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`, {selector: policyRequiresTagOrCategorySelector}); const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID}`); const shouldSkipConfirmation = !!skipConfirmation && !!report?.reportID && !(isPolicyExpenseChat(report) && policyRequiresTagOrCategory); @@ -58,7 +57,6 @@ function ScanNonGlobalCreate({report, action, iouType, reportID, transactionID, return ( ; - action: IOUAction; iouType: IOUType; reportID: string; transactionID: string; @@ -68,7 +67,7 @@ type ScanSkipConfirmationProps = WithCurrentUserPersonalDetailsProps & { * Handles GPS permission flow, split bills, and direct submission. * Most complex variant with the most Onyx subscriptions. */ -function ScanSkipConfirmation({report, action, iouType, reportID, transactionID, transaction, backToReport, currentUserPersonalDetails}: ScanSkipConfirmationProps) { +function ScanSkipConfirmation({report, iouType, reportID, transactionID, transaction, backToReport, currentUserPersonalDetails}: ScanSkipConfirmationProps) { const policy = usePolicy(report?.policyID); const {policyForMovingExpenses} = usePolicyForMovingExpenses(); const isArchived = useReportIsArchived(report?.reportID); @@ -167,16 +166,14 @@ function ScanSkipConfirmation({report, action, iouType, reportID, transactionID, cancelShutterSpans(); const participant = participants.at(0); - const {chatReportID, optimisticChatReportID} = resolveChatTargetForScan({ + const {optimisticChatReportID} = resolveChatTargetForScan({ iouType, participant, report, currentUserAccountID: currentUserPersonalDetails.accountID, }); const optimisticTransactionIDs = files.map(() => rand64()); - const lastOptimisticTransactionID = optimisticTransactionIDs.at(-1) ?? transactionID; const linkedTrackedExpenseReportAction = transaction?.linkedTrackedExpenseReportAction; - const isFromGlobalCreate = getIsFromGlobalCreate(transaction); const firstReceiptFile = files.at(0); @@ -214,13 +211,7 @@ function ScanSkipConfirmation({report, action, iouType, reportID, transactionID, shouldDeferForSearch: false, }); cleanupAfterSkipConfirmSubmit({ - report, - action, draftTransactionIDs, - transactionID: getExistingTransactionID(linkedTrackedExpenseReportAction) ?? lastOptimisticTransactionID, - isFromGlobalCreate, - backToReport, - optimisticChatReportID: chatReportID, linkedTrackedExpenseReportAction, }); }, @@ -284,13 +275,7 @@ function ScanSkipConfirmation({report, action, iouType, reportID, transactionID, // Cleanup runs after each write (not once up front) so a stalled GPS lookup can't clear the draft before the expense exists. const runCleanup = () => cleanupAfterSkipConfirmSubmit({ - report, - action, draftTransactionIDs, - transactionID: lastOptimisticTransactionID, - isFromGlobalCreate, - backToReport, - optimisticChatReportID: chatReportID, linkedTrackedExpenseReportAction, }); if (locationPermissionGranted) { diff --git a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts index 40920130cdff..df82ab46940f 100644 --- a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts +++ b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts @@ -291,17 +291,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { const transactionIDs = transactions?.map((tx) => tx.transactionID); const [storedTransactions] = useTransactionsByID(transactionIDs); - function performPostBatchCleanup({ - allTransactionsCreated, - }: { - participant: Participant; - shouldHandleNavigation: boolean; - allTransactionsCreated: boolean; - fallbackOptimisticChatReportID: string; - navigateBackToReport: string | undefined; - lastOptimisticTransactionID: string | undefined; - preResolvedChatTarget?: {report: OnyxEntry; chatReportID: string}; - }) { + function performPostBatchCleanup({allTransactionsCreated}: {allTransactionsCreated: boolean}) { const lastTransaction = transactions.at(-1); // Action bailed mid-batch — keep drafts for retry. if (!allTransactionsCreated) { @@ -447,6 +437,8 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { // the destination themselves) and only for the final transaction of the batch. shouldHandleNavigation, isLastTransactionOfBatch: index === transactions.length - 1, + // Post-create navigation must return to the report the flow started from, not the chat the expense was written to. + backToReport, shouldGenerateTransactionThreadReport, isASAPSubmitBetaEnabled, currentUserAccountIDParam: currentUserPersonalDetails.accountID, @@ -466,19 +458,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { allTransactionsCreated = false; } } - const isExpenseReport = isMoneyRequestReportReportUtils(report); - performPostBatchCleanup({ - participant, - shouldHandleNavigation, - allTransactionsCreated, - fallbackOptimisticChatReportID: optimisticChatReportID, - navigateBackToReport: backToReport, - lastOptimisticTransactionID, - preResolvedChatTarget: { - report: isExpenseReport ? report : undefined, - chatReportID: isExpenseReport ? optimisticChatReportID : (existingIOUReport?.chatReportID ?? optimisticChatReportID), - }, - }); + performPostBatchCleanup({allTransactionsCreated}); } function submitPerDiemExpense(trimmedComment: string, shouldHandleNavigation: boolean, policyRecentlyUsedCategoriesParam?: RecentlyUsedCategories) { @@ -693,14 +673,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { currentUserLocalCurrency: currentUserPersonalDetails.localCurrencyCode ?? CONST.CURRENCY.USD, }); } - performPostBatchCleanup({ - participant, - shouldHandleNavigation, - allTransactionsCreated: true, - fallbackOptimisticChatReportID: optimisticSelfDMReportID, - navigateBackToReport: undefined, - lastOptimisticTransactionID, - }); + performPostBatchCleanup({allTransactionsCreated: true}); } function createDistanceRequest(trimmedComment: string, shouldHandleNavigation = true, shouldDeferForSearch = false) { diff --git a/tests/actions/IOU/MoneyRequestTest.ts b/tests/actions/IOU/MoneyRequestTest.ts index 958bd5477652..be4b55b64779 100644 --- a/tests/actions/IOU/MoneyRequestTest.ts +++ b/tests/actions/IOU/MoneyRequestTest.ts @@ -72,7 +72,7 @@ jest.mock('@libs/getCurrentPosition'); // Fire executeWrite synchronously so downstream writes can be asserted. jest.mock('@libs/Navigation/helpers/submitWithDismissFirst', () => jest.requireActual('../../__mocks__/submitWithDismissFirst')); -// cleanupAfterSkipConfirmSubmit is a spy so the move-from-track cleanup-id contract can be asserted. It's cleanup-only — the write action owns post-create navigation. +// cleanupAfterSkipConfirmSubmit is a spy so the cleanup contract (draft ids + linked tracked action) can be asserted. It's cleanup-only — the write action owns post-create navigation. const mockCleanupAfterSkipConfirmSubmit = jest.fn(); jest.mock('@libs/Navigation/helpers/cleanupAfterSkipConfirmSubmit', () => ({ __esModule: true, @@ -715,7 +715,7 @@ describe('MoneyRequest', () => { expect(Split.createDistanceRequest).not.toHaveBeenCalled(); }); - it('should pass the existing tracked transaction ID (not the optimistic id) to cleanup for a move-from-track distance submission', async () => { + it('should pass the moved transaction linkedTrackedExpenseReportAction to cleanup for a move-from-track distance submission', async () => { const EXISTING_TRACKED_TRANSACTION_ID = 'tracked-transaction-88'; const linkedTrackedExpenseReportAction = { reportActionID: 'linked-action-1', @@ -745,15 +745,16 @@ describe('MoneyRequest', () => { await waitForBatchedUpdates(); expect(mockCleanupAfterSkipConfirmSubmit).toHaveBeenCalledTimes(1); + // Cleanup is cleanup-only now; it carries the moved transaction's linkedTrackedExpenseReportAction + // (used to release the original tracked expense thread screen). expect(mockCleanupAfterSkipConfirmSubmit).toHaveBeenCalledWith( expect.objectContaining({ - transactionID: EXISTING_TRACKED_TRANSACTION_ID, + linkedTrackedExpenseReportAction, }), ); }); - // createDistanceRequest writes under the draft transaction, so cleanup must target that id — not the UI's optimistic one. - it('should pass the draft transaction id (not the optimistic id) to cleanup for a non-track distance submission', async () => { + it('should pass the draft transaction ids to cleanup for a non-track distance submission', async () => { handleMoneyRequestStepDistanceNavigation({ ...baseParams, shouldSkipConfirmation: true, @@ -767,7 +768,7 @@ describe('MoneyRequest', () => { expect(mockCleanupAfterSkipConfirmSubmit).toHaveBeenCalledWith( expect.objectContaining({ - transactionID: fakeTransaction.transactionID, + draftTransactionIDs: [baseParams.transactionID], }), ); }); diff --git a/tests/ui/IOURequestStepAmountDraftTest.tsx b/tests/ui/IOURequestStepAmountDraftTest.tsx index 9f86f34113dd..75f6526608f0 100644 --- a/tests/ui/IOURequestStepAmountDraftTest.tsx +++ b/tests/ui/IOURequestStepAmountDraftTest.tsx @@ -264,10 +264,10 @@ describe('IOURequestStepAmount - draft transactions coverage', () => { }), ); - // The same optimisticTransactionID must reach the action AND post-create cleanup, else the destination report highlights the wrong transaction. + // The action receives the UI-resolved optimistic id (it owns post-create navigation/growl); + // cleanup is cleanup-only and no longer takes transaction ids beyond the draft list. const requestMoneyArg = jest.mocked(TrackExpense.requestMoney).mock.calls.at(0)?.[0]; - const cleanupArg = jest.mocked(cleanupAfterSkipConfirmSubmit).mock.calls.at(0)?.[0]; expect(typeof requestMoneyArg?.optimisticTransactionID).toBe('string'); - expect(cleanupArg?.transactionID).toBe(requestMoneyArg?.optimisticTransactionID); + expect(cleanupAfterSkipConfirmSubmit).toHaveBeenCalledTimes(1); }); }); diff --git a/tests/ui/ScanSkipConfirmationTest.tsx b/tests/ui/ScanSkipConfirmationTest.tsx index 84a465ef14e1..8e9fcd015729 100644 --- a/tests/ui/ScanSkipConfirmationTest.tsx +++ b/tests/ui/ScanSkipConfirmationTest.tsx @@ -193,7 +193,7 @@ describe('ScanSkipConfirmation submit orchestration', () => { expect(capturedCreateTransactionArg?.optimisticTransactionIDs).toHaveLength(1); expect(capturedCreateTransactionArg?.optimisticChatReportID).toBe('optimistic-resolved'); + // Cleanup is cleanup-only (draft ids + linked tracked action); it no longer receives navigation params. expect(mockCleanupAfterSkipConfirmSubmit).toHaveBeenCalledTimes(1); - expect(mockCleanupAfterSkipConfirmSubmit).toHaveBeenCalledWith(expect.objectContaining({optimisticChatReportID: 'chat-resolved'})); }); }); diff --git a/tests/unit/Search/useSearchSnapshotTest.ts b/tests/unit/Search/useSearchSnapshotTest.ts index b767a84419eb..77300fc6376e 100644 --- a/tests/unit/Search/useSearchSnapshotTest.ts +++ b/tests/unit/Search/useSearchSnapshotTest.ts @@ -180,7 +180,6 @@ describe('useSearchSnapshot', () => { useSearchSnapshot({ queryJSON: makeQueryJSON(), searchResults, - newSearchResultKeys: undefined, transactions: undefined, reportActions: undefined, }), @@ -190,7 +189,7 @@ describe('useSearchSnapshot', () => { expect(mockGetSortedSections).toHaveBeenCalled(); expect(result.current.chartData).toHaveLength(1); expect(result.current.chartData.at(0)?.keyForList).toBe('1'); - // data is the stabilized passthrough of the (highlight-stamped) chartData in this mock setup. + // data is the stabilized passthrough of chartData in this mock setup. expect(result.current.data).toBe(result.current.chartData); expect(result.current.columns).toEqual(['merchant']); expect(result.current.isLoading).toBe(false); @@ -206,7 +205,6 @@ describe('useSearchSnapshot', () => { useSearchSnapshot({ queryJSON: makeQueryJSON(), searchResults: undefined, - newSearchResultKeys: undefined, transactions: undefined, reportActions: undefined, }), @@ -225,7 +223,6 @@ describe('useSearchSnapshot', () => { useSearchSnapshot({ queryJSON: makeQueryJSON(), searchResults, - newSearchResultKeys: undefined, transactions: undefined, reportActions: undefined, }), @@ -245,7 +242,6 @@ describe('useSearchSnapshot', () => { useSearchSnapshot({ queryJSON: makeQueryJSON(), searchResults, - newSearchResultKeys: undefined, transactions: undefined, reportActions: undefined, }), @@ -268,7 +264,6 @@ describe('useSearchSnapshot', () => { groupBy: CONST.SEARCH.GROUP_BY.FROM, }), searchResults, - newSearchResultKeys: undefined, transactions: undefined, reportActions: undefined, }), @@ -289,7 +284,6 @@ describe('useSearchSnapshot', () => { useSearchSnapshot({ queryJSON: makeQueryJSON(), searchResults, - newSearchResultKeys: undefined, transactions: undefined, reportActions: undefined, }), @@ -314,7 +308,6 @@ describe('useSearchSnapshot', () => { useSearchSnapshot({ queryJSON: makeQueryJSON(), searchResults, - newSearchResultKeys: undefined, transactions: undefined, reportActions: undefined, }), @@ -342,7 +335,6 @@ describe('useSearchSnapshot', () => { useSearchSnapshot({ queryJSON: makeQueryJSON(), searchResults, - newSearchResultKeys: undefined, transactions: undefined, reportActions: undefined, }), @@ -352,24 +344,6 @@ describe('useSearchSnapshot', () => { expect(result.current.hasCachedOptimisticItem).toBe(true); }); - it('stamps the post-create highlight on matching rows (newSearchResultKeys)', () => { - const searchResults = makeSearchResults(); - mockUseOptimisticSearchTracking.mockReturnValue(trackingReturn(searchResults.data)); - mockGetSortedSections.mockReturnValue([{transactionID: '7', keyForList: '7'}]); - - const {result} = renderHook(() => - useSearchSnapshot({ - queryJSON: makeQueryJSON(), - searchResults, - newSearchResultKeys: new Set([`${ONYXKEYS.COLLECTION.TRANSACTION}7`]), - transactions: undefined, - reportActions: undefined, - }), - ); - - expect(result.current.chartData.at(0)).toEqual(expect.objectContaining({shouldAnimateInHighlight: true})); - }); - it('passes the query type through to getSortedSections for each variant shape', () => { const variants = [ CONST.SEARCH.DATA_TYPES.CHAT, @@ -387,7 +361,6 @@ describe('useSearchSnapshot', () => { useSearchSnapshot({ queryJSON: makeQueryJSON({type}), searchResults, - newSearchResultKeys: undefined, transactions: undefined, reportActions: undefined, }), @@ -415,7 +388,6 @@ describe('useSearchSnapshot', () => { const props = { queryJSON: makeQueryJSON(), searchResults, - newSearchResultKeys: undefined, transactions: undefined, reportActions: undefined, }; diff --git a/tests/unit/cleanupAfterSkipConfirmSubmitTest.ts b/tests/unit/cleanupAfterSkipConfirmSubmitTest.ts index 059ff1584421..4a5ac314a947 100644 --- a/tests/unit/cleanupAfterSkipConfirmSubmitTest.ts +++ b/tests/unit/cleanupAfterSkipConfirmSubmitTest.ts @@ -1,43 +1,26 @@ import cleanupAfterExpenseCreate from '@libs/Navigation/helpers/cleanupAfterExpenseCreate'; import cleanupAfterSkipConfirmSubmit from '@libs/Navigation/helpers/cleanupAfterSkipConfirmSubmit'; -import cleanupAndNavigateAfterExpenseCreate from '@libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate'; -import type {CleanupAndNavigateAfterExpenseCreateParams} from '@libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate'; -import CONST from '@src/CONST'; -import type {Report, ReportAction} from '@src/types/onyx'; +import type {ReportAction} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; jest.mock('@libs/Navigation/helpers/cleanupAfterExpenseCreate', () => jest.fn()); -jest.mock('@libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate', () => jest.fn()); -const chatReport = {reportID: 'chat-1'} as Report; const linkedTrackedExpenseReportAction = {childReportID: 'child-1'} as OnyxEntry; -const params: CleanupAndNavigateAfterExpenseCreateParams = { - action: CONST.IOU.ACTION.CREATE, - report: chatReport, - draftTransactionIDs: ['txn-1', 'txn-2'], - transactionID: 'txn-1', - isFromGlobalCreate: false, - backToReport: 'back-to-this-report', - optimisticChatReportID: 'optimistic-1', - linkedTrackedExpenseReportAction, -}; - describe('cleanupAfterSkipConfirmSubmit', () => { beforeEach(() => { jest.clearAllMocks(); }); - it('should delegate only the cleanup-relevant subset to cleanupAfterExpenseCreate and never navigate (the write action owns navigation)', () => { - cleanupAfterSkipConfirmSubmit(params); + it('should delegate to cleanupAfterExpenseCreate and never navigate (the write action owns navigation)', () => { + cleanupAfterSkipConfirmSubmit({draftTransactionIDs: ['txn-1', 'txn-2'], linkedTrackedExpenseReportAction}); expect(cleanupAfterExpenseCreate).toHaveBeenCalledTimes(1); expect(cleanupAfterExpenseCreate).toHaveBeenCalledWith({ draftTransactionIDs: ['txn-1', 'txn-2'], linkedTrackedExpenseReportAction, }); - expect(cleanupAndNavigateAfterExpenseCreate).not.toHaveBeenCalled(); }); }); diff --git a/tests/unit/useSearchAutoRefetchTest.ts b/tests/unit/useSearchAutoRefetchTest.ts index 72b0181e81a2..2eeb96a7d3aa 100644 --- a/tests/unit/useSearchAutoRefetchTest.ts +++ b/tests/unit/useSearchAutoRefetchTest.ts @@ -37,7 +37,6 @@ describe('useSearchAutoRefetch', () => { }); const baseProps: UseSearchAutoRefetch = { - shouldUseLiveData: false, searchResults: { data: { personalDetailsList: {}, From b8da09678879cb6914a5573c3139e1cd67e8d39d Mon Sep 17 00:00:00 2001 From: borys3kk Date: Tue, 7 Jul 2026 16:39:45 +0200 Subject: [PATCH 03/28] Address second self-review pass on growl wiring - Remove the dead shouldNavigate param from navigateAfterExpenseCreate; its "feedback without navigation" job is fully owned by surfaceExpenseCreatedFeedback since the nav-ownership refactor - Resolve the growl View button's inbox/spend context at press time instead of freezing it at growl-show time (drop the isInbox param) - Delete the cleanupAfterSkipConfirmSubmit passthrough; call sites use cleanupAfterExpenseCreate directly - Restore backToReport on skip-confirm submits: createTransaction accepts and forwards it to requestMoney, AmountSubmission passes it - in-chat "Add expense" flows return to the originating chat again - Scope the per-iteration optimistic transaction id to the submit loops in useExpenseSubmission (the outer "last" variable was a leftover) - Rename the useSearchAutoRefetch params type to UseSearchAutoRefetchParams - Wire mockUseIsFocused into the navigation mock in useSearchAutoRefetchTest so the not-focused test actually exercises the focus branch --- src/hooks/useSearchAutoRefetch.ts | 6 +-- .../helpers/cleanupAfterSkipConfirmSubmit.ts | 15 ------ .../helpers/navigateAfterExpenseCreate.ts | 33 +++--------- src/libs/actions/IOU/MoneyRequest.ts | 4 ++ .../iou/request/step/AmountSubmission.ts | 5 +- ...andleMoneyRequestStepDistanceNavigation.ts | 6 +-- .../components/ScanSkipConfirmation.tsx | 6 +-- .../step/confirmation/useExpenseSubmission.ts | 10 ++-- tests/actions/IOU/MoneyRequestTest.ts | 14 ++--- tests/ui/IOURequestStepAmountDraftTest.tsx | 5 +- tests/ui/ScanSkipConfirmationTest.tsx | 8 +-- tests/unit/AmountSubmissionTest.ts | 2 +- .../unit/cleanupAfterSkipConfirmSubmitTest.ts | 26 ---------- tests/unit/useSearchAutoRefetchTest.ts | 51 +++++++++++++------ 14 files changed, 77 insertions(+), 114 deletions(-) delete mode 100644 src/libs/Navigation/helpers/cleanupAfterSkipConfirmSubmit.ts delete mode 100644 tests/unit/cleanupAfterSkipConfirmSubmitTest.ts diff --git a/src/hooks/useSearchAutoRefetch.ts b/src/hooks/useSearchAutoRefetch.ts index ade8fa59e520..72761bb737fd 100644 --- a/src/hooks/useSearchAutoRefetch.ts +++ b/src/hooks/useSearchAutoRefetch.ts @@ -16,7 +16,7 @@ import {useEffect, useRef} from 'react'; import useNetwork from './useNetwork'; -type UseSearchAutoRefetch = { +type UseSearchAutoRefetchParams = { searchResults: OnyxEntry; transactions: OnyxCollection; previousTransactions: OnyxCollection; @@ -38,7 +38,7 @@ function useSearchAutoRefetch({ searchKey, offset, shouldCalculateTotals, -}: UseSearchAutoRefetch) { +}: UseSearchAutoRefetchParams) { const isFocused = useIsFocused(); const {isOffline} = useNetwork(); const searchTriggeredRef = useRef(false); @@ -172,4 +172,4 @@ function extractReportActionIDsFromSearchResults(searchResultsData: Partial { const backTo = Navigation.getActiveRoute(); - // The explicit flag wins (set when the growl's origin context is known). Otherwise - // (dismiss-first orchestrator paths) resolve the context at press time. - const openOnInbox = isInbox ?? (isReportTopmostSplitNavigator() && !isSearchTopmostFullScreenRoute()); + // Resolved at press time (not growl-show time) so navigation matches whatever surface the + // user is actually looking at, even if they switched tabs while the growl was up. + const openOnInbox = isReportTopmostSplitNavigator() && !isSearchTopmostFullScreenRoute(); if (!openOnInbox) { // Spend context: open the transaction thread RHP within Search (the report is shown @@ -293,7 +286,6 @@ function navigateAfterExpenseCreate({ hasMultipleTransactions, shouldAddPendingNewTransactionIDs = false, - shouldNavigate = true, }: NavigateAfterExpenseCreateParams) { const isUserOnInbox = isReportTopmostSplitNavigator(); const isUserOnSpend = isSearchTopmostFullScreenRoute(); @@ -302,9 +294,7 @@ function navigateAfterExpenseCreate({ // we just need to dismiss the money request flow screens and open the report chat // containing the IOU report. No growl is shown in this case. if (!isFromGlobalCreate || !transactionID) { - if (shouldNavigate) { - dismissModalAndOpenReportInInboxTab(activeReportID, isInvoice, hasMultipleTransactions); - } + dismissModalAndOpenReportInInboxTab(activeReportID, isInvoice, hasMultipleTransactions); if (shouldAddPendingNewTransactionIDs) { addPendingNewTransactionIDs(activeReportID, transactionID); } @@ -315,18 +305,11 @@ function navigateAfterExpenseCreate({ // chat containing the IOU report) and show the "Expense added" growl on top of it. We don't // redirect to Spend here - the growl just shows. if (isUserOnInbox) { - if (shouldNavigate) { - dismissModalAndOpenReportInInboxTab(activeReportID, isInvoice, hasMultipleTransactions); - } + dismissModalAndOpenReportInInboxTab(activeReportID, isInvoice, hasMultipleTransactions); if (shouldAddPendingNewTransactionIDs) { addPendingNewTransactionIDs(activeReportID, transactionID); } - showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadReportID: providedTransactionThreadReportID, isInbox: true, isInvoice}); - return; - } - - // The caller owns navigation in this case, so skip redirecting to Search. - if (!shouldNavigate) { + showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadReportID: providedTransactionThreadReportID, isInvoice}); return; } @@ -385,7 +368,7 @@ function navigateAfterExpenseCreate({ Navigation.isNavigationReady().then(navigateToSearch); } - showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadReportID: providedTransactionThreadReportID, isInbox: false, isInvoice}); + showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadReportID: providedTransactionThreadReportID, isInvoice}); } export default navigateAfterExpenseCreate; diff --git a/src/libs/actions/IOU/MoneyRequest.ts b/src/libs/actions/IOU/MoneyRequest.ts index db3076555152..c02f47fa0039 100644 --- a/src/libs/actions/IOU/MoneyRequest.ts +++ b/src/libs/actions/IOU/MoneyRequest.ts @@ -95,6 +95,8 @@ type CreateTransactionParams = { currentUserLocalCurrency: string | undefined; /** Whether the created action should own post-creation navigation. Skip-confirm orchestrators that dismiss/reveal first pass `false` so navigation isn't run twice. */ shouldHandleNavigation?: boolean; + /** Report the flow started from; post-create navigation returns there instead of the written-to report. */ + backToReport?: string; }; function createTransaction({ @@ -124,6 +126,7 @@ function createTransaction({ optimisticChatReportID, currentUserLocalCurrency, shouldHandleNavigation = true, + backToReport, }: CreateTransactionParams) { const draftTransactionIDs = Object.keys(allTransactionDrafts ?? {}); @@ -218,6 +221,7 @@ function createTransaction({ optimisticTransactionID, shouldHandleNavigation, isLastTransactionOfBatch: index === files.length - 1, + backToReport, }); } } diff --git a/src/pages/iou/request/step/AmountSubmission.ts b/src/pages/iou/request/step/AmountSubmission.ts index 0d4c5b0bac8f..844a6d85cece 100644 --- a/src/pages/iou/request/step/AmountSubmission.ts +++ b/src/pages/iou/request/step/AmountSubmission.ts @@ -8,7 +8,7 @@ import { navigateToParticipantPage, resolveOptimisticChatReportID, } from '@libs/IOUUtils'; -import cleanupAfterSkipConfirmSubmit from '@libs/Navigation/helpers/cleanupAfterSkipConfirmSubmit'; +import cleanupAfterExpenseCreate from '@libs/Navigation/helpers/cleanupAfterExpenseCreate'; import type {WriteOverrides} from '@libs/Navigation/helpers/submitWithDismissFirst'; import {submitWithDismissFirst} from '@libs/Navigation/helpers/submitWithDismissFirst'; import Navigation from '@libs/Navigation/Navigation'; @@ -425,9 +425,10 @@ function submitAmount({ optimisticChatReportID, optimisticTransactionID, shouldHandleNavigation: overrides.shouldHandleNavigation, + backToReport, }); } - cleanupAfterSkipConfirmSubmit({ + cleanupAfterExpenseCreate({ draftTransactionIDs: draftTransactionIDsList, linkedTrackedExpenseReportAction: transaction?.linkedTrackedExpenseReportAction, }); diff --git a/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts b/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts index f3d72431b162..40b7963ced9a 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts +++ b/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts @@ -13,7 +13,7 @@ import {getCurrencySymbol} from '@libs/CurrencyUtils'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import {calculateDefaultReimbursable, navigateToConfirmationPage, navigateToParticipantPage} from '@libs/IOUUtils'; import {toLocaleDigit} from '@libs/LocaleDigitUtils'; -import cleanupAfterSkipConfirmSubmit from '@libs/Navigation/helpers/cleanupAfterSkipConfirmSubmit'; +import cleanupAfterExpenseCreate from '@libs/Navigation/helpers/cleanupAfterExpenseCreate'; import {submitWithDismissFirst} from '@libs/Navigation/helpers/submitWithDismissFirst'; import Navigation from '@libs/Navigation/Navigation'; import {roundToTwoDecimalPlaces} from '@libs/NumberUtils'; @@ -329,7 +329,7 @@ function handleMoneyRequestStepDistanceNavigation({ currentUserLocalCurrency, shouldHandleNavigation: overrides.shouldHandleNavigation, }); - cleanupAfterSkipConfirmSubmit({ + cleanupAfterExpenseCreate({ draftTransactionIDs, linkedTrackedExpenseReportAction: transactionLinkedTrackedExpenseReportAction, }); @@ -398,7 +398,7 @@ function handleMoneyRequestStepDistanceNavigation({ policyTagList, }, }); - cleanupAfterSkipConfirmSubmit({ + cleanupAfterExpenseCreate({ draftTransactionIDs, linkedTrackedExpenseReportAction: transactionLinkedTrackedExpenseReportAction, }); diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx index f62c4854735d..56ecf02bf24a 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx @@ -20,7 +20,7 @@ import {clearUserLocation, setUserLocation} from '@libs/actions/UserLocation'; import getCurrentPosition from '@libs/getCurrentPosition'; import {calculateDefaultReimbursable} from '@libs/IOUUtils'; import Log from '@libs/Log'; -import cleanupAfterSkipConfirmSubmit from '@libs/Navigation/helpers/cleanupAfterSkipConfirmSubmit'; +import cleanupAfterExpenseCreate from '@libs/Navigation/helpers/cleanupAfterExpenseCreate'; import {submitWithDismissFirst} from '@libs/Navigation/helpers/submitWithDismissFirst'; import {rand64} from '@libs/NumberUtils'; import {isMoneyRequestReport} from '@libs/ReportUtils'; @@ -210,7 +210,7 @@ function ScanSkipConfirmation({report, iouType, reportID, transactionID, transac shouldHandleNavigation: overrides.shouldHandleNavigation, shouldDeferForSearch: false, }); - cleanupAfterSkipConfirmSubmit({ + cleanupAfterExpenseCreate({ draftTransactionIDs, linkedTrackedExpenseReportAction, }); @@ -274,7 +274,7 @@ function ScanSkipConfirmation({report, iouType, reportID, transactionID, transac executeWrite: (overrides) => { // Cleanup runs after each write (not once up front) so a stalled GPS lookup can't clear the draft before the expense exists. const runCleanup = () => - cleanupAfterSkipConfirmSubmit({ + cleanupAfterExpenseCreate({ draftTransactionIDs, linkedTrackedExpenseReportAction, }); diff --git a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts index df82ab46940f..a41bfd157cb9 100644 --- a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts +++ b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts @@ -324,10 +324,9 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { const optimisticReportPreviewActionID = rand64(); let existingIOUReport: Report | undefined; let allTransactionsCreated = true; - let lastOptimisticTransactionID: string | undefined; for (const [index, item] of transactions.entries()) { - lastOptimisticTransactionID = rand64(); + const optimisticTransactionID = rand64(); const receipt = receiptFiles[item.transactionID]; const isTestReceipt = receipt?.isTestReceipt ?? false; const isTestDriveReceipt = receipt?.isTestDriveReceipt ?? false; @@ -431,7 +430,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { isFromGlobalCreate: getIsFromGlobalCreate(item), ...(isTimeRequest ? {type: CONST.TRANSACTION.TYPE.TIME, count: item.comment?.units?.count, rate: item.comment?.units?.rate, unit: CONST.TIME_TRACKING.UNIT.HOUR} : {}), }, - optimisticTransactionID: lastOptimisticTransactionID, + optimisticTransactionID, // The action owns post-create navigation + growl, but only when the caller permits it // (dismiss-first orchestrators pass shouldHandleNavigation=false after revealing/dismissing // the destination themselves) and only for the final transaction of the batch. @@ -595,9 +594,8 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { } const optimisticSelfDMReportID = selfDMReport?.reportID ?? generateReportID(); const policyExpenseChatReportActions = getAllPolicyExpenseChatReportActions(allReports, allReportActions); - let lastOptimisticTransactionID: string | undefined; for (const [index, item] of transactions.entries()) { - lastOptimisticTransactionID = rand64(); + const optimisticTransactionID = rand64(); const isLinkedTrackedExpenseReportArchived = !!item.linkedTrackedExpenseReportID && privateIsArchivedMap[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${item.linkedTrackedExpenseReportID}`]; const itemDistance = isManualDistanceRequest || isOdometerDistanceRequest || isGPSDistanceRequest ? (item.comment?.customUnit?.quantity ?? undefined) : undefined; @@ -651,7 +649,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { accountant: item.accountant, }, optimisticChatReportID: optimisticSelfDMReportID, - optimisticTransactionID: lastOptimisticTransactionID, + optimisticTransactionID, // The action owns post-create navigation + growl, but only when the caller permits it // (dismiss-first orchestrators pass shouldHandleNavigation=false after revealing/dismissing // the destination themselves) and only for the final transaction of the batch. diff --git a/tests/actions/IOU/MoneyRequestTest.ts b/tests/actions/IOU/MoneyRequestTest.ts index be4b55b64779..563da15f98a2 100644 --- a/tests/actions/IOU/MoneyRequestTest.ts +++ b/tests/actions/IOU/MoneyRequestTest.ts @@ -72,12 +72,12 @@ jest.mock('@libs/getCurrentPosition'); // Fire executeWrite synchronously so downstream writes can be asserted. jest.mock('@libs/Navigation/helpers/submitWithDismissFirst', () => jest.requireActual('../../__mocks__/submitWithDismissFirst')); -// cleanupAfterSkipConfirmSubmit is a spy so the cleanup contract (draft ids + linked tracked action) can be asserted. It's cleanup-only — the write action owns post-create navigation. -const mockCleanupAfterSkipConfirmSubmit = jest.fn(); -jest.mock('@libs/Navigation/helpers/cleanupAfterSkipConfirmSubmit', () => ({ +// cleanupAfterExpenseCreate is a spy so the cleanup contract (draft ids + linked tracked action) can be asserted. It's cleanup-only — the write action owns post-create navigation. +const mockCleanupAfterExpenseCreate = jest.fn(); +jest.mock('@libs/Navigation/helpers/cleanupAfterExpenseCreate', () => ({ __esModule: true, default: (...args: unknown[]): void => { - mockCleanupAfterSkipConfirmSubmit(...args); + mockCleanupAfterExpenseCreate(...args); }, })); @@ -744,10 +744,10 @@ describe('MoneyRequest', () => { await waitForBatchedUpdates(); - expect(mockCleanupAfterSkipConfirmSubmit).toHaveBeenCalledTimes(1); + expect(mockCleanupAfterExpenseCreate).toHaveBeenCalledTimes(1); // Cleanup is cleanup-only now; it carries the moved transaction's linkedTrackedExpenseReportAction // (used to release the original tracked expense thread screen). - expect(mockCleanupAfterSkipConfirmSubmit).toHaveBeenCalledWith( + expect(mockCleanupAfterExpenseCreate).toHaveBeenCalledWith( expect.objectContaining({ linkedTrackedExpenseReportAction, }), @@ -766,7 +766,7 @@ describe('MoneyRequest', () => { await waitForBatchedUpdates(); - expect(mockCleanupAfterSkipConfirmSubmit).toHaveBeenCalledWith( + expect(mockCleanupAfterExpenseCreate).toHaveBeenCalledWith( expect.objectContaining({ draftTransactionIDs: [baseParams.transactionID], }), diff --git a/tests/ui/IOURequestStepAmountDraftTest.tsx b/tests/ui/IOURequestStepAmountDraftTest.tsx index 75f6526608f0..8fffc858eff3 100644 --- a/tests/ui/IOURequestStepAmountDraftTest.tsx +++ b/tests/ui/IOURequestStepAmountDraftTest.tsx @@ -18,7 +18,7 @@ import React from 'react'; import Onyx from 'react-native-onyx'; import * as TrackExpense from '../../src/libs/actions/IOU/TrackExpense'; -import cleanupAfterSkipConfirmSubmit from '../../src/libs/Navigation/helpers/cleanupAfterSkipConfirmSubmit'; +import cleanupAfterExpenseCreate from '../../src/libs/Navigation/helpers/cleanupAfterExpenseCreate'; import createRandomTransaction from '../utils/collections/transaction'; import {signInWithTestUser} from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; @@ -106,7 +106,6 @@ jest.mock('@libs/Navigation/Navigation', () => { jest.mock('@libs/Navigation/helpers/submitWithDismissFirst', () => jest.requireActual('../__mocks__/submitWithDismissFirst')); // Action-assertion test: post-create navigation is exercised elsewhere; keep the cleanup helper inert here. -jest.mock('@libs/Navigation/helpers/cleanupAfterSkipConfirmSubmit', () => jest.fn()); jest.mock('@libs/Navigation/helpers/cleanupAfterExpenseCreate', () => jest.fn()); jest.mock('@react-navigation/native', () => { @@ -268,6 +267,6 @@ describe('IOURequestStepAmount - draft transactions coverage', () => { // cleanup is cleanup-only and no longer takes transaction ids beyond the draft list. const requestMoneyArg = jest.mocked(TrackExpense.requestMoney).mock.calls.at(0)?.[0]; expect(typeof requestMoneyArg?.optimisticTransactionID).toBe('string'); - expect(cleanupAfterSkipConfirmSubmit).toHaveBeenCalledTimes(1); + expect(cleanupAfterExpenseCreate).toHaveBeenCalledTimes(1); }); }); diff --git a/tests/ui/ScanSkipConfirmationTest.tsx b/tests/ui/ScanSkipConfirmationTest.tsx index 8e9fcd015729..c227fbf74cec 100644 --- a/tests/ui/ScanSkipConfirmationTest.tsx +++ b/tests/ui/ScanSkipConfirmationTest.tsx @@ -32,7 +32,7 @@ let capturedCreateTransactionArg: CreateTransactionArg | undefined; const mockCreateTransaction = jest.fn((arg: CreateTransactionArg) => { capturedCreateTransactionArg = arg; }); -const mockCleanupAfterSkipConfirmSubmit = jest.fn(); +const mockCleanupAfterExpenseCreate = jest.fn(); const mockResolveChatTargetForScan = jest.fn(() => ({report: undefined, chatReportID: 'chat-resolved', optimisticChatReportID: 'optimistic-resolved'})); // Fire the write synchronously with the fallback override so createTransaction + cleanup run inline. const mockSubmitWithDismissFirst = jest.fn((params: {executeWrite: (overrides: {shouldHandleNavigation: boolean}) => void}) => params.executeWrite({shouldHandleNavigation: true})); @@ -87,10 +87,10 @@ jest.mock('@libs/Navigation/helpers/submitWithDismissFirst', () => ({ submitWithDismissFirst: (params: {executeWrite: (overrides: {shouldHandleNavigation: boolean}) => void}) => mockSubmitWithDismissFirst(params), })); -jest.mock('@libs/Navigation/helpers/cleanupAfterSkipConfirmSubmit', () => ({ +jest.mock('@libs/Navigation/helpers/cleanupAfterExpenseCreate', () => ({ __esModule: true, default: (params: Record) => { - mockCleanupAfterSkipConfirmSubmit(params); + mockCleanupAfterExpenseCreate(params); }, })); @@ -194,6 +194,6 @@ describe('ScanSkipConfirmation submit orchestration', () => { expect(capturedCreateTransactionArg?.optimisticChatReportID).toBe('optimistic-resolved'); // Cleanup is cleanup-only (draft ids + linked tracked action); it no longer receives navigation params. - expect(mockCleanupAfterSkipConfirmSubmit).toHaveBeenCalledTimes(1); + expect(mockCleanupAfterExpenseCreate).toHaveBeenCalledTimes(1); }); }); diff --git a/tests/unit/AmountSubmissionTest.ts b/tests/unit/AmountSubmissionTest.ts index d30e146deff4..161f8f47b172 100644 --- a/tests/unit/AmountSubmissionTest.ts +++ b/tests/unit/AmountSubmissionTest.ts @@ -33,7 +33,7 @@ jest.mock('@libs/Navigation/helpers/submitWithDismissFirst', () => ({ }), })); -jest.mock('@libs/Navigation/helpers/cleanupAfterSkipConfirmSubmit', () => jest.fn()); +jest.mock('@libs/Navigation/helpers/cleanupAfterExpenseCreate', () => jest.fn()); const mockSendMoneyElsewhere = jest.fn(); const mockSendMoneyWithWallet = jest.fn(); diff --git a/tests/unit/cleanupAfterSkipConfirmSubmitTest.ts b/tests/unit/cleanupAfterSkipConfirmSubmitTest.ts deleted file mode 100644 index 4a5ac314a947..000000000000 --- a/tests/unit/cleanupAfterSkipConfirmSubmitTest.ts +++ /dev/null @@ -1,26 +0,0 @@ -import cleanupAfterExpenseCreate from '@libs/Navigation/helpers/cleanupAfterExpenseCreate'; -import cleanupAfterSkipConfirmSubmit from '@libs/Navigation/helpers/cleanupAfterSkipConfirmSubmit'; - -import type {ReportAction} from '@src/types/onyx'; - -import type {OnyxEntry} from 'react-native-onyx'; - -jest.mock('@libs/Navigation/helpers/cleanupAfterExpenseCreate', () => jest.fn()); - -const linkedTrackedExpenseReportAction = {childReportID: 'child-1'} as OnyxEntry; - -describe('cleanupAfterSkipConfirmSubmit', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('should delegate to cleanupAfterExpenseCreate and never navigate (the write action owns navigation)', () => { - cleanupAfterSkipConfirmSubmit({draftTransactionIDs: ['txn-1', 'txn-2'], linkedTrackedExpenseReportAction}); - - expect(cleanupAfterExpenseCreate).toHaveBeenCalledTimes(1); - expect(cleanupAfterExpenseCreate).toHaveBeenCalledWith({ - draftTransactionIDs: ['txn-1', 'txn-2'], - linkedTrackedExpenseReportAction, - }); - }); -}); diff --git a/tests/unit/useSearchAutoRefetchTest.ts b/tests/unit/useSearchAutoRefetchTest.ts index 2eeb96a7d3aa..6e8d71d7365d 100644 --- a/tests/unit/useSearchAutoRefetchTest.ts +++ b/tests/unit/useSearchAutoRefetchTest.ts @@ -2,7 +2,7 @@ import {renderHook} from '@testing-library/react-native'; import useSearchAutoRefetch from '@hooks/useSearchAutoRefetch'; -import type {UseSearchAutoRefetch} from '@hooks/useSearchAutoRefetch'; +import type {UseSearchAutoRefetchParams} from '@hooks/useSearchAutoRefetch'; import {search} from '@libs/actions/Search'; @@ -11,11 +11,19 @@ import ONYXKEYS from '@src/ONYXKEYS'; import Onyx from 'react-native-onyx'; +const mockUseIsFocused = jest.fn((): boolean => true); + jest.mock('@libs/actions/Search'); jest.mock('@react-navigation/native', () => ({ - useIsFocused: jest.fn(() => true), + useIsFocused: () => mockUseIsFocused(), createNavigationContainerRef: () => ({}), })); +// The unfocused path also consults isSearchTopmostFullScreenRoute (an RHP over Search keeps refetching); +// the stubbed navigationRef above can't answer that, so model "not on Search" explicitly. +jest.mock('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute', () => ({ + __esModule: true, + default: jest.fn(() => false), +})); jest.mock('@rnmapbox/maps', () => ({ __esModule: true, default: {}, @@ -23,7 +31,9 @@ jest.mock('@rnmapbox/maps', () => ({ setAccessToken: jest.fn(), })); -const mockUseIsFocused = jest.fn().mockReturnValue(true); +beforeEach(() => { + mockUseIsFocused.mockReturnValue(true); +}); afterEach(() => { jest.clearAllMocks(); @@ -36,7 +46,7 @@ describe('useSearchAutoRefetch', () => { }); }); - const baseProps: UseSearchAutoRefetch = { + const baseProps: UseSearchAutoRefetchParams = { searchResults: { data: { personalDetailsList: {}, @@ -84,7 +94,7 @@ describe('useSearchAutoRefetch', () => { previousTransactions: {'1': {transactionID: '1'}}, }; - const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), { + const {rerender} = renderHook((props: UseSearchAutoRefetchParams) => useSearchAutoRefetch(props), { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error initialProps, @@ -108,13 +118,26 @@ describe('useSearchAutoRefetch', () => { it('should not trigger search when not focused', () => { mockUseIsFocused.mockReturnValue(false); - const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), { - initialProps: baseProps, + // Non-empty previous data so the hook gets past the initial-load early return and the focus + // check is what actually suppresses the search. + const initialProps = { + ...baseProps, + transactions: {'1': {transactionID: '1'}}, + previousTransactions: {'1': {transactionID: '1'}}, + }; + + const {rerender} = renderHook((props: UseSearchAutoRefetchParams) => useSearchAutoRefetch(props), { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + initialProps, }); const updatedProps = { - ...baseProps, - transactions: {'1': {transactionID: '1'}}, + ...initialProps, + transactions: { + '1': {transactionID: '1'}, + '2': {transactionID: '2'}, + }, }; // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -124,8 +147,6 @@ describe('useSearchAutoRefetch', () => { }); it('should trigger search for chat when report actions added and focused', () => { - mockUseIsFocused.mockReturnValue(true); - const chatProps = { ...baseProps, queryJSON: {...baseProps.queryJSON, type: 'chat' as const}, @@ -141,7 +162,7 @@ describe('useSearchAutoRefetch', () => { }, }; - const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), { + const {rerender} = renderHook((props: UseSearchAutoRefetchParams) => useSearchAutoRefetch(props), { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error initialProps: chatProps, @@ -176,7 +197,7 @@ describe('useSearchAutoRefetch', () => { }, }; - const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), { + const {rerender} = renderHook((props: UseSearchAutoRefetchParams) => useSearchAutoRefetch(props), { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error initialProps, @@ -196,8 +217,6 @@ describe('useSearchAutoRefetch', () => { }); it('should not trigger search for chat when report actions removed and focused', () => { - mockUseIsFocused.mockReturnValue(true); - const chatProps = { ...baseProps, queryJSON: {...baseProps.queryJSON, type: 'chat' as const}, @@ -215,7 +234,7 @@ describe('useSearchAutoRefetch', () => { }, }; - const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), { + const {rerender} = renderHook((props: UseSearchAutoRefetchParams) => useSearchAutoRefetch(props), { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error initialProps: chatProps, From 5a77400bcb3c3578495c174599c9697de6db7ba0 Mon Sep 17 00:00:00 2001 From: borys3kk Date: Tue, 7 Jul 2026 17:23:35 +0200 Subject: [PATCH 04/28] fix knip --- src/libs/Navigation/helpers/cleanupAfterExpenseCreate.ts | 1 - .../helpers/cleanupAndNavigateAfterExpenseCreate.ts | 1 - src/libs/SearchUIUtils.ts | 1 - src/pages/iou/request/step/IOURequestStepDistance.tsx | 1 - .../IOURequestStepDistance/hooks/useDistanceNavigation.ts | 6 +----- .../IOURequestStepDistance/hooks/useOdometerNavigation.ts | 6 +----- src/pages/iou/request/step/IOURequestStepDistanceMap.tsx | 1 - .../iou/request/step/IOURequestStepDistanceOdometer.tsx | 1 - 8 files changed, 2 insertions(+), 16 deletions(-) diff --git a/src/libs/Navigation/helpers/cleanupAfterExpenseCreate.ts b/src/libs/Navigation/helpers/cleanupAfterExpenseCreate.ts index 6dfdb3206c6e..6ff768afd573 100644 --- a/src/libs/Navigation/helpers/cleanupAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/cleanupAfterExpenseCreate.ts @@ -37,4 +37,3 @@ function cleanupAfterExpenseCreate({draftTransactionIDs, linkedTrackedExpenseRep } export default cleanupAfterExpenseCreate; -export type {CleanupAfterExpenseCreateParams}; diff --git a/src/libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate.ts index de218cbc6b4d..b6afaaa63331 100644 --- a/src/libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate.ts @@ -55,4 +55,3 @@ function cleanupAndNavigateAfterExpenseCreate({ } export default cleanupAndNavigateAfterExpenseCreate; -export type {CleanupAndNavigateAfterExpenseCreateParams}; diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 2ca6aa645655..a6a832764bf8 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -6480,7 +6480,6 @@ export { getSuggestedSearchesVisibility, getSortedSections, getViolationsFromSearchData, - isTransactionMatchWithGroupItem, isTransactionGroupListItemType, isTransactionReportGroupListItemType, isTransactionCategoryGroupListItemType, diff --git a/src/pages/iou/request/step/IOURequestStepDistance.tsx b/src/pages/iou/request/step/IOURequestStepDistance.tsx index 77fbe9633357..645521b3a6d1 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistance.tsx @@ -366,7 +366,6 @@ function IOURequestStepDistance({ const navigateToNextStep = useDistanceNavigation({ iouType, - action, report, policy, transaction, diff --git a/src/pages/iou/request/step/IOURequestStepDistance/hooks/useDistanceNavigation.ts b/src/pages/iou/request/step/IOURequestStepDistance/hooks/useDistanceNavigation.ts index 439fa085c733..f6389cff7997 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance/hooks/useDistanceNavigation.ts +++ b/src/pages/iou/request/step/IOURequestStepDistance/hooks/useDistanceNavigation.ts @@ -7,7 +7,7 @@ import {generateReportID, isMoneyRequestReport} from '@libs/ReportUtils'; import handleMoneyRequestStepDistanceNavigation from '@pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation'; -import type {IOUAction, IOUType} from '@src/CONST'; +import type {IOUType} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; import type {Beta, IntroSelected, PersonalDetailsList, Policy, RecentWaypoint, Report, Transaction} from '@src/types/onyx'; @@ -24,9 +24,6 @@ type UseDistanceNavigationParams = { /** Type of IOU flow (request, split, track, etc.). */ iouType: IOUType; - /** Route param: the IOU action (create / edit). */ - action: IOUAction; - /** The chat/expense report that owns this transaction. */ report: OnyxEntry; @@ -105,7 +102,6 @@ type UseDistanceNavigationParams = { function useDistanceNavigation({ iouType, - action, report, policy, transaction, diff --git a/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerNavigation.ts b/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerNavigation.ts index 44f7f43f655f..861f23dbeaeb 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerNavigation.ts +++ b/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerNavigation.ts @@ -7,7 +7,7 @@ import {generateReportID, isMoneyRequestReport} from '@libs/ReportUtils'; import handleMoneyRequestStepDistanceNavigation from '@pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation'; -import type {IOUAction, IOUType} from '@src/CONST'; +import type {IOUType} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Beta, IntroSelected, OdometerDraft, PersonalDetailsList, Policy, RecentWaypoint, Report, Transaction} from '@src/types/onyx'; import type {ReportAttributesDerivedValue} from '@src/types/onyx/DerivedValues'; @@ -22,9 +22,6 @@ type UseOdometerNavigationParams = { /** Type of IOU flow (request, split, track, etc.). */ iouType: IOUType; - /** Route param: the IOU action (create / edit). */ - action: IOUAction; - /** The chat/expense report that owns this transaction. */ report: OnyxEntry; @@ -111,7 +108,6 @@ type NavigateOptions = { function useOdometerNavigation({ iouType, - action, report, policy, transaction, diff --git a/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx b/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx index b5fa2b08dc3a..28414483286d 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx @@ -244,7 +244,6 @@ function IOURequestStepDistanceMap({ const navigateToNextStep = useDistanceNavigation({ iouType, - action, report, policy, transaction, diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index f14b5bdc7e3c..2dfff48147ef 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -217,7 +217,6 @@ function IOURequestStepDistanceOdometer({ const navigateToNextStep = useOdometerNavigation({ iouType, - action, report, policy, transaction, From ecb42ae7ca16c20f905b8e5fc87cdb636f856d8b Mon Sep 17 00:00:00 2001 From: borys3kk Date: Wed, 8 Jul 2026 12:35:28 +0200 Subject: [PATCH 05/28] Fix growl fast path and Share flow post-create feedback showExpenseAddedGrowl: a provided transactionThreadReportID no longer skips the report-actions wait when an iouReportID exists - the thread ID arrives before the deferred API.write flushes, so View could open a thread not yet in Onyx. Fast path now requires no iouReportID or an already-present IOU action. Share flow: shouldHandleNavigation: false still surfaced the growl before SubmitDetailsPage navigated, and global-create paths could show it twice. Added shouldShowPostCreateFeedback (default true) so the page keeps the action silent while cleanupAndNavigateAfterExpenseCreate owns navigation and feedback. Also threaded iouReportID / transactionThreadReportID through the cleanup helper so the growl View deep link resolves. Also removes dead isTransactionMatchWithGroupItem from SearchUIUtils - this branch already deleted its only caller and its export, leaving an unused function that failed lint. --- .../cleanupAndNavigateAfterExpenseCreate.ts | 8 ++++ .../helpers/navigateAfterExpenseCreate.ts | 9 ++-- src/libs/SearchUIUtils.ts | 46 ------------------- src/libs/actions/IOU/MoneyRequestBuilder.ts | 8 ++++ src/libs/actions/IOU/TrackExpense.ts | 6 ++- .../IOU/types/CreateTrackExpenseParams.ts | 8 ++++ src/pages/Share/SubmitDetailsPage.tsx | 13 ++++-- 7 files changed, 44 insertions(+), 54 deletions(-) diff --git a/src/libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate.ts index b6afaaa63331..cb42c580e204 100644 --- a/src/libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate.ts @@ -13,6 +13,10 @@ type CleanupAndNavigateAfterExpenseCreateParams = { report: OnyxEntry; draftTransactionIDs: string[] | undefined; transactionID: string | undefined; + + /** IOU report the expense landed in; lets the "Expense added" growl resolve its "View" deep link. */ + iouReportID?: string; + transactionThreadReportID?: string; isFromGlobalCreate: boolean | undefined; backToReport?: string; optimisticChatReportID?: string; @@ -25,6 +29,8 @@ function cleanupAndNavigateAfterExpenseCreate({ report, draftTransactionIDs, transactionID, + iouReportID, + transactionThreadReportID, isFromGlobalCreate, backToReport, optimisticChatReportID, @@ -46,7 +52,9 @@ function cleanupAndNavigateAfterExpenseCreate({ navigateAfterExpenseCreate({ activeReportID: finalActiveReportID, + iouReportID, transactionID, + transactionThreadReportID, isFromGlobalCreate, isInvoice, hasMultipleTransactions, diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index a2c177d77591..b7c034168800 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -184,10 +184,13 @@ function showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadRep // Fast path: the thread is already resolvable, so show the growl immediately instead of waiting on // the reportActions subscription (which would otherwise hit the 8s safety timeout). This covers: - // - personal tracked expenses (unreported/self-DM): there's no iouReportID, but the thread ID is - // passed in directly, so we can build from it right away; + // - personal tracked expenses (unreported/self-DM): there's no iouReportID to subscribe to, and the + // thread ID passed in directly is the only handle we'll ever get, so build from it right away; // - the iouAction already being in Onyx (retry / non-deferred edge cases). - if (providedTransactionThreadReportID || (iouReportID && getIOUActionForReportID(iouReportID, transactionID)?.reportActionID)) { + // When an iouReportID exists but the iouAction hasn't landed yet (deferred write), a provided thread + // ID is NOT enough - its optimistic report data is inside the deferred write too, so "View" would + // open a broken RHP. Fall through to the subscription and wait for the action instead. + if ((providedTransactionThreadReportID && !iouReportID) || (iouReportID && getIOUActionForReportID(iouReportID, transactionID)?.reportActionID)) { const threadReportID = buildThreadFromOnyx(); showGrowl(threadReportID); return; diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index a6a832764bf8..f9ef91bffffd 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -6427,52 +6427,6 @@ function splitGroupsIntoPairs(data: SearchListItem[]): {splitData: SearchListIte return {splitData, stickyHeaderIndices}; } -/** - * Checks whether a transaction belongs to the given group list item, based on the active groupBy. - */ -function isTransactionMatchWithGroupItem(transaction: OnyxTypes.Transaction, groupItem: SearchListItem, groupBy: SearchGroupBy | undefined): boolean { - if (groupBy === CONST.SEARCH.GROUP_BY.CARD) { - return transaction.cardID === (groupItem as TransactionCardGroupListItemType).cardID; - } - if (groupBy === CONST.SEARCH.GROUP_BY.FROM) { - return !!transaction.transactionID; - } - if (groupBy === CONST.SEARCH.GROUP_BY.CATEGORY) { - return (transaction.category ?? '') === ((groupItem as TransactionCategoryGroupListItemType).category ?? ''); - } - if (groupBy === CONST.SEARCH.GROUP_BY.MERCHANT) { - return (transaction.merchant ?? '') === ((groupItem as TransactionMerchantGroupListItemType).merchant ?? ''); - } - if (groupBy === CONST.SEARCH.GROUP_BY.MONTH) { - const monthGroup = groupItem as TransactionMonthGroupListItemType; - const transactionDateString = transaction.modifiedCreated ?? transaction.created ?? ''; - return DateUtils.isDateStringInMonth(transactionDateString, monthGroup.year, monthGroup.month); - } - if (groupBy === CONST.SEARCH.GROUP_BY.WEEK) { - const weekGroup = groupItem as TransactionWeekGroupListItemType; - const transactionDateString = transaction.modifiedCreated ?? transaction.created ?? ''; - const datePart = transactionDateString.substring(0, 10); - const {start: weekStart, end: weekEnd} = DateUtils.getWeekDateRange(weekGroup.week); - return datePart >= weekStart && datePart <= weekEnd; - } - if (groupBy === CONST.SEARCH.GROUP_BY.YEAR) { - const yearGroup = groupItem as TransactionYearGroupListItemType; - const transactionDateString = transaction.modifiedCreated ?? transaction.created ?? ''; - const transactionYear = parseInt(transactionDateString.substring(0, 4), 10); - return transactionYear === yearGroup.year; - } - if (groupBy === CONST.SEARCH.GROUP_BY.QUARTER) { - const quarterGroup = groupItem as TransactionQuarterGroupListItemType; - const transactionDateString = transaction.modifiedCreated ?? transaction.created ?? ''; - const transactionYear = parseInt(transactionDateString.substring(0, 4), 10); - const transactionMonth = parseInt(transactionDateString.substring(5, 7), 10); - // Calculate which quarter the transaction belongs to (1-4) - const transactionQuarter = Math.floor((transactionMonth - 1) / 3) + 1; - return transactionYear === quarterGroup.year && transactionQuarter === quarterGroup.quarter; - } - return false; -} - export { getSearchBulkEditPolicyID, getSuggestedSearches, diff --git a/src/libs/actions/IOU/MoneyRequestBuilder.ts b/src/libs/actions/IOU/MoneyRequestBuilder.ts index 336217c1c397..bbf58f7e9b2e 100644 --- a/src/libs/actions/IOU/MoneyRequestBuilder.ts +++ b/src/libs/actions/IOU/MoneyRequestBuilder.ts @@ -167,6 +167,14 @@ type RequestMoneyInformation = { isRetry?: boolean; shouldPlaySound?: boolean; shouldHandleNavigation?: boolean; + /** + * Only read when shouldHandleNavigation is false. Defaults to true: navigation is owned by the + * orchestrator (dismiss-first paths), which has already navigated, so the action surfaces the + * post-create feedback (growl/row highlight). Pass false when the caller owns the whole + * post-create flow - navigation AND feedback - and the action must stay fully silent + * (e.g. the Share flow, which navigates after the action returns). + */ + shouldShowPostCreateFeedback?: boolean; /** * When a confirmation submits several transactions, the orchestrator calls this action once per * transaction in a loop. Post-create navigation + the "Expense added" growl are owned by the diff --git a/src/libs/actions/IOU/TrackExpense.ts b/src/libs/actions/IOU/TrackExpense.ts index a339f13a017c..886bcef01a4a 100644 --- a/src/libs/actions/IOU/TrackExpense.ts +++ b/src/libs/actions/IOU/TrackExpense.ts @@ -1631,6 +1631,7 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep action, shouldPlaySound = true, shouldHandleNavigation = true, + shouldShowPostCreateFeedback = true, isLastTransactionOfBatch = true, backToReport, optimisticChatReportID, @@ -1934,7 +1935,7 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep isFromGlobalCreate, shouldAddPendingNewTransactionIDs: isMoneyRequestReport, }); - } else { + } else if (shouldShowPostCreateFeedback) { // Navigation is owned by SubmitExpenseOrchestrator (dismiss-first paths). Surface feedback // wherever the user lands: highlight the new row for in-report adds, otherwise the // "Expense added" growl with a "View" deep link. @@ -2369,6 +2370,7 @@ function trackExpense(params: CreateTrackExpenseParams) { transactionParams: transactionData, accountantParams, shouldHandleNavigation = true, + shouldShowPostCreateFeedback = true, isLastTransactionOfBatch = true, shouldPlaySound = true, optimisticChatReportID, @@ -2786,7 +2788,7 @@ function trackExpense(params: CreateTrackExpenseParams) { // it - pending IDs are the fallback highlight path (see useNewTransactions). shouldAddPendingNewTransactionIDs: action === CONST.IOU.ACTION.CATEGORIZE || action === CONST.IOU.ACTION.SHARE, }); - } else { + } else if (shouldShowPostCreateFeedback) { // Navigation is owned by SubmitExpenseOrchestrator (dismiss-first paths). Surface feedback // wherever the user lands: highlight the new row for in-report adds, otherwise the growl. surfaceExpenseCreatedFeedback({ diff --git a/src/libs/actions/IOU/types/CreateTrackExpenseParams.ts b/src/libs/actions/IOU/types/CreateTrackExpenseParams.ts index 021380826308..909d6026d1b8 100644 --- a/src/libs/actions/IOU/types/CreateTrackExpenseParams.ts +++ b/src/libs/actions/IOU/types/CreateTrackExpenseParams.ts @@ -26,6 +26,14 @@ type CreateTrackExpenseParams = { isRetry?: boolean; shouldPlaySound?: boolean; shouldHandleNavigation?: boolean; + /** + * Only read when shouldHandleNavigation is false. Defaults to true: navigation is owned by the + * orchestrator (dismiss-first paths), which has already navigated, so the action surfaces the + * post-create feedback (growl/row highlight). Pass false when the caller owns the whole + * post-create flow - navigation AND feedback - and the action must stay fully silent + * (e.g. the Share flow, which navigates after the action returns). + */ + shouldShowPostCreateFeedback?: boolean; /** * When a confirmation submits several transactions, the orchestrator calls this action once per * transaction in a loop. Post-create navigation + the "Expense added" growl are owned by the diff --git a/src/pages/Share/SubmitDetailsPage.tsx b/src/pages/Share/SubmitDetailsPage.tsx index 00544bdfc8de..ecd7972ef64d 100644 --- a/src/pages/Share/SubmitDetailsPage.tsx +++ b/src/pages/Share/SubmitDetailsPage.tsx @@ -249,6 +249,7 @@ function SubmitDetailsPage({ // `transaction.transactionID` is the draft placeholder; mirror the action's `existingTransactionID ?? rand64()` chain so cleanup nav targets the created expense. const optimisticTransactionID = existingTransactionID ?? rand64(); + let iouReportID: string | undefined; if (isSelfDM(report)) { trackExpense({ @@ -290,14 +291,16 @@ function SubmitDetailsPage({ isSelfTourViewed, optimisticTransactionID, currentUserLocalCurrency: currentUserPersonalDetails.localCurrencyCode ?? CONST.CURRENCY.USD, - // Navigation is owned by cleanupAndNavigateAfterExpenseCreate below; don't let the action navigate too. + // The whole post-create flow (navigation + feedback) is owned by cleanupAndNavigateAfterExpenseCreate below, + // which runs after this action returns - keep the action fully silent. shouldHandleNavigation: false, + shouldShowPostCreateFeedback: false, reportActionsList: undefined, }); } else { const existingTransactionDraft = existingTransactionID ? transactionDrafts?.[existingTransactionID] : undefined; - requestMoney({ + const {iouReport} = requestMoney({ report: reportToSubmit, participantParams: {payeeEmail: currentUserPersonalDetails.login, payeeAccountID: currentUserPersonalDetails.accountID, participant}, policyParams: {policy, policyTagList: policyTags, policyCategories, policyRecentlyUsedCategories, policyRecentlyUsedTags}, @@ -337,15 +340,19 @@ function SubmitDetailsPage({ betas, personalDetails, optimisticTransactionID, - // Navigation is owned by cleanupAndNavigateAfterExpenseCreate below; don't let the action navigate too. + // The whole post-create flow (navigation + feedback) is owned by cleanupAndNavigateAfterExpenseCreate below, + // which runs after this action returns - keep the action fully silent. shouldHandleNavigation: false, + shouldShowPostCreateFeedback: false, }); + iouReportID = iouReport?.reportID; } cleanupAndNavigateAfterExpenseCreate({ report: isSelfDM(report) ? report : reportToSubmit, action: CONST.IOU.ACTION.CREATE, draftTransactionIDs, transactionID: optimisticTransactionID, + iouReportID, isFromGlobalCreate: getIsFromGlobalCreate(transaction), optimisticChatReportID: reportOrAccountID, linkedTrackedExpenseReportAction: transaction.linkedTrackedExpenseReportAction, From 134d8272752dc698624de5e38b10840904c45913 Mon Sep 17 00:00:00 2001 From: borys3kk Date: Thu, 9 Jul 2026 11:39:17 +0200 Subject: [PATCH 06/28] fix share feedback consistent --- .../cleanupAndNavigateAfterExpenseCreate.ts | 5 ++++ .../helpers/navigateAfterExpenseCreate.ts | 10 ++++++- src/pages/Share/SubmitDetailsPage.tsx | 2 ++ tests/unit/navigateAfterExpenseCreateTest.ts | 26 +++++++++++++++++++ 4 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate.ts index cb42c580e204..b09ff6afc31a 100644 --- a/src/libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate.ts @@ -23,6 +23,9 @@ type CleanupAndNavigateAfterExpenseCreateParams = { isInvoice?: boolean; linkedTrackedExpenseReportAction?: OnyxEntry; action: DeepValueOf; + + /** Show the "Expense added" growl even on the non-global-create path (e.g. the Share flow, whose drafts never set isFromGlobalCreate). */ + shouldAlwaysShowFeedback?: boolean; }; function cleanupAndNavigateAfterExpenseCreate({ @@ -37,6 +40,7 @@ function cleanupAndNavigateAfterExpenseCreate({ isInvoice, linkedTrackedExpenseReportAction, action, + shouldAlwaysShowFeedback, }: CleanupAndNavigateAfterExpenseCreateParams) { cleanupAfterExpenseCreate({ draftTransactionIDs, @@ -59,6 +63,7 @@ function cleanupAndNavigateAfterExpenseCreate({ isInvoice, hasMultipleTransactions, shouldAddPendingNewTransactionIDs, + shouldAlwaysShowFeedback, }); } diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index b7c034168800..b511fe399b1f 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -73,6 +73,9 @@ type NavigateAfterExpenseCreateParams = { isInvoice?: boolean; hasMultipleTransactions: boolean; shouldAddPendingNewTransactionIDs?: boolean; + + /** Show the "Expense added" growl even on the non-global-create path (e.g. the Share flow, whose drafts never set isFromGlobalCreate). */ + shouldAlwaysShowFeedback?: boolean; }; type ShowExpenseAddedGrowlParams = { @@ -289,18 +292,23 @@ function navigateAfterExpenseCreate({ hasMultipleTransactions, shouldAddPendingNewTransactionIDs = false, + shouldAlwaysShowFeedback = false, }: NavigateAfterExpenseCreateParams) { const isUserOnInbox = isReportTopmostSplitNavigator(); const isUserOnSpend = isSearchTopmostFullScreenRoute(); // If the expense is not created from global create or there is no transaction to link, // we just need to dismiss the money request flow screens and open the report chat - // containing the IOU report. No growl is shown in this case. + // containing the IOU report. No growl is shown in this case (landing in the report is + // the feedback), unless the caller opts in via shouldAlwaysShowFeedback. if (!isFromGlobalCreate || !transactionID) { dismissModalAndOpenReportInInboxTab(activeReportID, isInvoice, hasMultipleTransactions); if (shouldAddPendingNewTransactionIDs) { addPendingNewTransactionIDs(activeReportID, transactionID); } + if (shouldAlwaysShowFeedback && transactionID) { + showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadReportID: providedTransactionThreadReportID, isInvoice}); + } return; } diff --git a/src/pages/Share/SubmitDetailsPage.tsx b/src/pages/Share/SubmitDetailsPage.tsx index ecd7972ef64d..0bd8adf6e7c9 100644 --- a/src/pages/Share/SubmitDetailsPage.tsx +++ b/src/pages/Share/SubmitDetailsPage.tsx @@ -356,6 +356,8 @@ function SubmitDetailsPage({ isFromGlobalCreate: getIsFromGlobalCreate(transaction), optimisticChatReportID: reportOrAccountID, linkedTrackedExpenseReportAction: transaction.linkedTrackedExpenseReportAction, + // Share drafts never set isFromGlobalCreate, but the user still deserves the "Expense added" growl. + shouldAlwaysShowFeedback: true, }); }; diff --git a/tests/unit/navigateAfterExpenseCreateTest.ts b/tests/unit/navigateAfterExpenseCreateTest.ts index b7da2c18731e..fa2e23388340 100644 --- a/tests/unit/navigateAfterExpenseCreateTest.ts +++ b/tests/unit/navigateAfterExpenseCreateTest.ts @@ -1,3 +1,4 @@ +import Growl from '@libs/Growl'; import navigateAfterExpenseCreate from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; import Navigation from '@libs/Navigation/Navigation'; @@ -51,6 +52,16 @@ jest.mock('@libs/Navigation/Navigation', () => ({ jest.mock('@react-navigation/native'); +jest.mock('@libs/Growl', () => ({ + __esModule: true, + default: {success: jest.fn()}, +})); + +jest.mock('@libs/actions/Report', () => ({ + createTransactionThreadReport: jest.fn(), + setOptimisticTransactionThread: jest.fn(), +})); + describe('navigateAfterExpenseCreate', () => { beforeAll(() => { const followUpMock = require('@libs/telemetry/submitFollowUpAction') as {setPendingSubmitFollowUpAction: jest.Mock}; @@ -76,6 +87,21 @@ describe('navigateAfterExpenseCreate', () => { expect(Navigation.dismissModalWithReport).toHaveBeenCalledWith({reportID: 'report-123'}); expect(Navigation.navigate).not.toHaveBeenCalled(); + expect(Growl.success).not.toHaveBeenCalled(); + }); + + it('should show the expense added growl on the non-global path when shouldAlwaysShowFeedback is set', () => { + navigateAfterExpenseCreate({ + activeReportID: 'report-123', + transactionID: 'txn-1', + transactionThreadReportID: 'thread-1', + isFromGlobalCreate: false, + hasMultipleTransactions: false, + shouldAlwaysShowFeedback: true, + }); + + expect(Navigation.dismissModalWithReport).toHaveBeenCalledWith({reportID: 'report-123'}); + expect(Growl.success).toHaveBeenCalledWith(expect.any(String), expect.any(Number), expect.objectContaining({label: expect.any(String), onPress: expect.any(Function)})); }); it('should dismiss to report when user is on inbox tab', () => { From 917abe5474d195d72cfeb42534fe58c3dfccd975 Mon Sep 17 00:00:00 2001 From: borys3kk Date: Thu, 9 Jul 2026 14:14:57 +0200 Subject: [PATCH 07/28] Address review feedback on growl wiring Share flow: navigateAfterExpenseCreate gains an opt-in shouldAlwaysShowFeedback flag, threaded through cleanupAndNavigateAfterExpenseCreate, so SubmitDetailsPage surfaces the "Expense added" growl even though Share drafts never set isFromGlobalCreate (its non-global path used to skip feedback entirely). Restore the chat-destination pending-ID registration main provided via the cleanup path: requestMoney and createDistanceRequest now register pendingNewTransactionIDs when the user lands in a chat, so the first expense in a chat highlights in the fresh-mounted preview card. In-report adds keep the 1-to-2 fresh-mount fallback. Also clear the growl safety timeout if the connect callback somehow resolves before the timer is assigned, and restore the empty-participant guard in the distance flow that was dropped with the performPostBatchCleanup slim-down. --- src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts | 5 +++++ src/libs/actions/IOU/Split.ts | 5 ++++- src/libs/actions/IOU/TrackExpense.ts | 5 ++++- .../iou/request/step/confirmation/useExpenseSubmission.ts | 4 ++++ 4 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index b511fe399b1f..3c08f7d29674 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -241,6 +241,11 @@ function showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadRep const threadReportID = buildThreadFromOnyx(); showGrowl(threadReportID); }, SAFETY_TIMEOUT_MS); + + // If the connect callback somehow resolved before the timeout was assigned, its clearTimeout was a no-op - drop the now-useless timer. + if (resolved) { + clearTimeout(safetyTimeoutID); + } } type SurfaceExpenseCreatedFeedbackParams = { diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index 98523183ba0a..30afa7940724 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -2225,7 +2225,10 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest transactionID: parameters.transactionID, transactionThreadReportID: parameters.transactionThreadReportID, - shouldAddPendingNewTransactionIDs: isMoneyRequestReport, + // Pending IDs are the fallback highlight when diff detection can't run (see useNewTransactions): + // the chat preview card fresh-mounts on a chat destination's first expense, and the report table + // fresh-mounts on the in-report 1→2 transition. + shouldAddPendingNewTransactionIDs: isMoneyRequestReport || navigationActiveReportID === parameters.chatReportID, }); } else { // Dismiss-first paths (orchestrator owns navigation). Surface feedback wherever the user lands: diff --git a/src/libs/actions/IOU/TrackExpense.ts b/src/libs/actions/IOU/TrackExpense.ts index 886bcef01a4a..4aa895bf4bc1 100644 --- a/src/libs/actions/IOU/TrackExpense.ts +++ b/src/libs/actions/IOU/TrackExpense.ts @@ -1933,7 +1933,10 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep transactionID: transaction.transactionID, transactionThreadReportID: transactionThreadReportID ?? iouAction?.childReportID, isFromGlobalCreate, - shouldAddPendingNewTransactionIDs: isMoneyRequestReport, + // Pending IDs are the fallback highlight when diff detection can't run (see useNewTransactions): + // the chat preview card fresh-mounts on a chat destination's first expense, and the report table + // fresh-mounts on the in-report 1→2 transition. + shouldAddPendingNewTransactionIDs: isMoneyRequestReport || navigationReportID === chatReport.reportID, }); } else if (shouldShowPostCreateFeedback) { // Navigation is owned by SubmitExpenseOrchestrator (dismiss-first paths). Surface feedback diff --git a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts index ccffc43a6987..3223f31115c5 100644 --- a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts +++ b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts @@ -681,6 +681,10 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { if (!transaction) { return; } + // Without a selected participant the action would fall back to an empty participant object and build an invalid request. + if (selectedParticipantsForRequest.length === 0) { + return; + } createDistanceRequestIOUActions({ report, From 6fe6ec5db1c74acd9749e4d6acb0f4d7d9cef240 Mon Sep 17 00:00:00 2001 From: borys3kk Date: Mon, 13 Jul 2026 13:13:52 +0200 Subject: [PATCH 08/28] Address code-review pass on growl UX edge cases Per-diem tracked to the self-DM now surfaces the "Expense added" growl on global create (and feedback on the orchestrator-owned path, which previously got nothing): submitPerDiemExpenseForSelfDM returns the transaction/thread IDs and the hook keeps its dismiss-to-self-DM navigation while matching the other flows feedback-wise. Defer transaction-thread materialization to "View" press time: showing the growl now only runs a pure resolvability check, and the context merge or optimistic thread creation (with its OpenReport write) happens in onPress - matching every other thread navigation entry point and keeping untapped growls free of Onyx/API side effects. Also: remove the now-dead resolveChatTargetForSubmitCleanup (and its test file and leftover mocks), cover the offline-stash -> reconnect fires-exactly- once path in useSearchAutoRefetchTest, and promote the growl safety timeout to CONST.TIMING.EXPENSE_ADDED_GROWL_SAFETY_TIMEOUT. --- src/CONST/index.ts | 1 + .../helpers/navigateAfterExpenseCreate.ts | 60 +++-- src/libs/actions/IOU/PerDiem.ts | 2 + .../step/confirmation/useExpenseSubmission.ts | 18 +- .../iou/request/step/resolveChatTarget.ts | 54 +--- tests/ui/ScanSkipConfirmationTest.tsx | 1 - tests/unit/hooks/useExpenseSubmission.test.ts | 12 +- tests/unit/navigateAfterExpenseCreateTest.ts | 26 ++ tests/unit/resolveChatTargetTest.ts | 233 ------------------ tests/unit/useSearchAutoRefetchTest.ts | 63 +++++ 10 files changed, 152 insertions(+), 318 deletions(-) delete mode 100644 tests/unit/resolveChatTargetTest.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index d10d6d71c2c4..32d91f7d5904 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2066,6 +2066,7 @@ const CONST = { SHOW_HOVER_PREVIEW_DELAY: 270, SHOW_HOVER_PREVIEW_ANIMATION_DURATION: 250, ACTIVITY_INDICATOR_TIMEOUT: 10000, + EXPENSE_ADDED_GROWL_SAFETY_TIMEOUT: 8000, GET_INITIAL_URL_TIMEOUT: 10000, MIN_SMOOTH_SCROLL_EVENT_THROTTLE: 16, }, diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index 3c08f7d29674..c44f18d77125 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -93,8 +93,13 @@ type ShowExpenseAddedGrowlParams = { * The IOU action's optimistic data is typically not yet in the Onyx cache when this runs — the * `API.write` is deferred (deferred-for-search pattern) until Search's content layout flushes the * channel. We subscribe to the iouReport's reportActions and wait for the optimistic iouAction - * to land, then build the thread + show the growl. A safety timeout falls back to a growl - * without the "View" link if the iouAction never appears. + * to land, then show the growl. A safety timeout falls back to a growl without the "View" link + * if the iouAction never appears. + * + * The transaction thread itself is only materialized (context merge, or optimistic creation with + * its OpenReport write) when the user actually presses "View" — matching how every other thread + * navigation entry point builds the thread at navigation time, and keeping untapped growls free + * of Onyx/API side effects. */ function showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadReportID: providedTransactionThreadReportID, isInvoice}: ShowExpenseAddedGrowlParams) { if (!transactionID) { @@ -126,24 +131,41 @@ function showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadRep return threadReportID; }; - const showGrowl = (threadReportID: string | undefined) => { - if (!threadReportID) { + // Pure mirror of buildThreadFromOnyx's requirements, used at show time to decide whether "View" + // can be offered without materializing anything: a known thread ID, or an anchor to build one + // from (the IOU report, or the cached transaction for tracked/unreported expenses). + const canResolveThread = (): boolean => { + const iouAction = iouReportID ? getIOUActionForReportID(iouReportID, transactionID) : undefined; + if (providedTransactionThreadReportID ?? iouAction?.childReportID) { + return true; + } + return !!(iouReportID && getReportOrDraftReport(iouReportID)) || !!allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + }; + + const showGrowl = () => { + if (!canResolveThread()) { Log.warn('[showExpenseAddedGrowl] Unable to resolve transaction thread reportID; growl without View.'); Growl.success(growlMessage, CONST.GROWL.DURATION_LONG); return; } - const resolvedThreadReportID = threadReportID; const navigateToExpenseRHP = () => { + // Everything resolves at press time (not growl-show time): the thread is only materialized + // if the user actually taps "View" (with the freshest data available by then), and navigation + // matches whatever surface the user is looking at, even if they switched tabs while the + // growl was up. + const threadReportID = buildThreadFromOnyx(); + if (!threadReportID) { + Log.warn('[showExpenseAddedGrowl] Unable to resolve transaction thread reportID on View press.'); + return; + } const backTo = Navigation.getActiveRoute(); - // Resolved at press time (not growl-show time) so navigation matches whatever surface the - // user is actually looking at, even if they switched tabs while the growl was up. const openOnInbox = isReportTopmostSplitNavigator() && !isSearchTopmostFullScreenRoute(); if (!openOnInbox) { // Spend context: open the transaction thread RHP within Search (the report is shown // underneath via the Wide RHP machinery). setActiveTransactionIDs([transactionID]).then(() => { - Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: resolvedThreadReportID, backTo})); + Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: threadReportID, backTo})); }); return; } @@ -151,7 +173,7 @@ function showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadRep // Inbox + narrow layout: super wide RHP is unavailable, so open the transaction thread // as a full report view (matches MoneyRequestReportPreview's narrow-screen behavior). if (getIsNarrowLayout()) { - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(resolvedThreadReportID, undefined, undefined, backTo)); + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(threadReportID, undefined, undefined, backTo)); return; } @@ -172,13 +194,13 @@ function showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadRep // Defer so the thread RHP stacks on top of the expense report navigation above. setNavigationActionToMicrotaskQueue(() => { setActiveTransactionIDs([transactionID]).then(() => { - Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: resolvedThreadReportID, backTo: Navigation.getActiveRoute()})); + Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: threadReportID, backTo: Navigation.getActiveRoute()})); }); }); return; } setActiveTransactionIDs([transactionID]).then(() => { - Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: resolvedThreadReportID, backTo})); + Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: threadReportID, backTo})); }); }; // eslint-disable-next-line @typescript-eslint/no-deprecated -- imperative module (not a React component); no useLocalize hook available here @@ -188,14 +210,13 @@ function showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadRep // Fast path: the thread is already resolvable, so show the growl immediately instead of waiting on // the reportActions subscription (which would otherwise hit the 8s safety timeout). This covers: // - personal tracked expenses (unreported/self-DM): there's no iouReportID to subscribe to, and the - // thread ID passed in directly is the only handle we'll ever get, so build from it right away; + // thread ID passed in directly is the only handle we'll ever get; // - the iouAction already being in Onyx (retry / non-deferred edge cases). // When an iouReportID exists but the iouAction hasn't landed yet (deferred write), a provided thread // ID is NOT enough - its optimistic report data is inside the deferred write too, so "View" would // open a broken RHP. Fall through to the subscription and wait for the action instead. if ((providedTransactionThreadReportID && !iouReportID) || (iouReportID && getIOUActionForReportID(iouReportID, transactionID)?.reportActionID)) { - const threadReportID = buildThreadFromOnyx(); - showGrowl(threadReportID); + showGrowl(); return; } @@ -204,13 +225,12 @@ function showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadRep // fire via the safety timeout. Resolve immediately with the same fallback the timeout would produce // (a growl without "View" when no thread is resolvable). if (!iouReportID) { - showGrowl(buildThreadFromOnyx()); + showGrowl(); return; } // Slow path: wait for Search to render → flushDeferredWrite('search') → API.write applies // optimistic data → iouAction lands → we show the growl. - const SAFETY_TIMEOUT_MS = 8000; let resolved = false; let safetyTimeoutID: ReturnType | undefined; const reportActionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}` as const; @@ -227,8 +247,7 @@ function showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadRep resolved = true; clearTimeout(safetyTimeoutID); Onyx.disconnect(connectionId); - const threadReportID = buildThreadFromOnyx(); - showGrowl(threadReportID); + showGrowl(); }, }); @@ -238,9 +257,8 @@ function showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadRep } resolved = true; Onyx.disconnect(connectionId); - const threadReportID = buildThreadFromOnyx(); - showGrowl(threadReportID); - }, SAFETY_TIMEOUT_MS); + showGrowl(); + }, CONST.TIMING.EXPENSE_ADDED_GROWL_SAFETY_TIMEOUT); // If the connect callback somehow resolved before the timeout was assigned, its clearTimeout was a no-op - drop the now-useless timer. if (resolved) { diff --git a/src/libs/actions/IOU/PerDiem.ts b/src/libs/actions/IOU/PerDiem.ts index aaef1395396c..9fdf56da450b 100644 --- a/src/libs/actions/IOU/PerDiem.ts +++ b/src/libs/actions/IOU/PerDiem.ts @@ -1115,6 +1115,8 @@ function submitPerDiemExpenseForSelfDM(submitPerDiemExpenseInformation: PerDiemE TransitionTracker.runAfterTransitions({callback: () => removeDraftTransaction(CONST.IOU.OPTIMISTIC_TRANSACTION_ID), waitForUpcomingTransition: true}); notifyNewAction(chatReport.reportID, undefined, true); + + return {transactionID: transaction.transactionID, transactionThreadReportID}; } export { diff --git a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts index 3223f31115c5..f3cd1393546d 100644 --- a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts +++ b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts @@ -473,7 +473,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { } if (isTrackExpense) { const optimisticChatReportID = selfDMReport?.reportID ?? generateReportID(); - submitPerDiemExpenseForSelfDM({ + const result = submitPerDiemExpenseForSelfDM({ selfDMReport, policy, transactionParams: { @@ -495,6 +495,22 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { }); if (shouldHandleNavigation) { dismissModalAndOpenReportInInboxTabHelper(optimisticChatReportID, false, false); + // Keep the historical dismiss-to-self-DM navigation, but match the other flows' + // global-create growl. There's no iouReportID in the self-DM, so the growl resolves + // its "View" link from the transaction thread directly. + if (getIsFromGlobalCreate(transaction)) { + surfaceExpenseCreatedFeedback({ + transactionID: result?.transactionID, + transactionThreadReportID: result?.transactionThreadReportID, + }); + } + } else { + // Navigation is owned by SubmitExpenseOrchestrator (dismiss-first paths) - surface + // feedback wherever the user lands, matching the workspace per-diem branch below. + surfaceExpenseCreatedFeedback({ + transactionID: result?.transactionID, + transactionThreadReportID: result?.transactionThreadReportID, + }); } } else { const isExpenseReport = isMoneyRequestReportReportUtils(report); diff --git a/src/pages/iou/request/step/resolveChatTarget.ts b/src/pages/iou/request/step/resolveChatTarget.ts index 0b44a6cb6965..32fda38ae249 100644 --- a/src/pages/iou/request/step/resolveChatTarget.ts +++ b/src/pages/iou/request/step/resolveChatTarget.ts @@ -1,7 +1,6 @@ import {resolveOptimisticChatReportID} from '@libs/IOUUtils'; -import {getChatByParticipants, getReportOrDraftReport, isDeprecatedGroupDM, isGroupChat, isMoneyRequestReport, isPolicyExpenseChat, isSelfDM} from '@libs/ReportUtils'; -import type {IOUAction, IOUType} from '@src/CONST'; +import type {IOUType} from '@src/CONST'; import CONST from '@src/CONST'; import type {Report} from '@src/types/onyx'; import type {Participant} from '@src/types/onyx/IOU'; @@ -24,14 +23,6 @@ type ResolveChatTargetForScanParams = { currentUserAccountID: number; }; -type ResolveChatTargetForSubmitCleanupParams = { - participant: Participant; - currentUserAccountID: number; - report: OnyxEntry; - fallbackOptimisticChatReportID: string; - action: IOUAction; -}; - /** Pre-action scan-flow resolver: returns both the builder's optimistic ID and the cleanup nav target so they stay in lockstep. */ function resolveChatTargetForScan({iouType, participant, report, currentUserAccountID}: ResolveChatTargetForScanParams): ChatTarget { if (iouType === CONST.IOU.TYPE.TRACK && report?.reportID) { @@ -44,45 +35,4 @@ function resolveChatTargetForScan({iouType, participant, report, currentUserAcco return {report: undefined, chatReportID: resolved.chatReportID, optimisticChatReportID: resolved.optimisticChatReportID}; } -/** - * Post-action confirmation-cleanup resolver: mirrors the action's chat resolution so cleanup navigation - * lands on the same report the action wrote to. Falls back to `fallbackOptimisticChatReportID` (UI's - * pre-generated optimistic ID for this submission) when no existing chat resolves. - */ -function resolveChatTargetForSubmitCleanup({participant, currentUserAccountID, report, fallbackOptimisticChatReportID, action}: ResolveChatTargetForSubmitCleanupParams): ChatTarget { - if (isMoneyRequestReport(report)) { - return {report, chatReportID: fallbackOptimisticChatReportID, optimisticChatReportID: undefined}; - } - - // SUBMIT (move-from-track) writes to the participant's policy/1:1 chat, never the self-DM source — skip the keep-source shortcut. - const isMoveFromTrackSubmit = action === CONST.IOU.ACTION.SUBMIT; - - // Keep `report` unless it's a non-special 1:1 chat whose participants don't match the submission target. - if (!isMoveFromTrackSubmit && report?.reportID) { - const isSpecialChat = !!participant.isPolicyExpenseChat || isPolicyExpenseChat(report) || isSelfDM(report) || isGroupChat(report) || isDeprecatedGroupDM(report); - if (isSpecialChat) { - return {report, chatReportID: fallbackOptimisticChatReportID, optimisticChatReportID: undefined}; - } - if (participant.accountID) { - const reportParticipants = Object.keys(report.participants ?? {}) - .map(Number) - .sort(); - const expected = [participant.accountID, currentUserAccountID].sort(); - const participantsMatch = expected.length === reportParticipants.length && expected.every((id, i) => id === reportParticipants.at(i)); - if (participantsMatch) { - return {report, chatReportID: fallbackOptimisticChatReportID, optimisticChatReportID: undefined}; - } - } - } - - let chatReportID: string | undefined; - if (participant.isPolicyExpenseChat && participant.reportID && getReportOrDraftReport(participant.reportID)) { - chatReportID = participant.reportID; - } else if (participant.accountID) { - chatReportID = getChatByParticipants([participant.accountID, currentUserAccountID])?.reportID; - } - - return {report: undefined, chatReportID: chatReportID ?? fallbackOptimisticChatReportID, optimisticChatReportID: undefined}; -} - -export {resolveChatTargetForScan, resolveChatTargetForSubmitCleanup}; +export {resolveChatTargetForScan}; diff --git a/tests/ui/ScanSkipConfirmationTest.tsx b/tests/ui/ScanSkipConfirmationTest.tsx index c227fbf74cec..9d24d303c766 100644 --- a/tests/ui/ScanSkipConfirmationTest.tsx +++ b/tests/ui/ScanSkipConfirmationTest.tsx @@ -96,7 +96,6 @@ jest.mock('@libs/Navigation/helpers/cleanupAfterExpenseCreate', () => ({ jest.mock('@pages/iou/request/step/resolveChatTarget', () => ({ resolveChatTargetForScan: () => mockResolveChatTargetForScan(), - resolveChatTargetForSubmitCleanup: jest.fn(), })); const REPORT_ID = '1'; diff --git a/tests/unit/hooks/useExpenseSubmission.test.ts b/tests/unit/hooks/useExpenseSubmission.test.ts index 051246cacf47..99ad8b35a597 100644 --- a/tests/unit/hooks/useExpenseSubmission.test.ts +++ b/tests/unit/hooks/useExpenseSubmission.test.ts @@ -17,7 +17,6 @@ const mockSubmitPerDiemExpenseAction = jest.fn(); const mockSubmitPerDiemExpenseForSelfDMAction = jest.fn(); const mockCleanupAfterExpenseCreate = jest.fn(); const mockCleanupAndNavigateAfterExpenseCreate = jest.fn(); -const mockResolveChatTargetForSubmitCleanup = jest.fn(); jest.mock('@userActions/IOU/TrackExpense', () => ({ requestMoney: (...args: unknown[]) => mockRequestMoneyAction(...args), @@ -39,10 +38,6 @@ jest.mock('@libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate', () => default: (...args: unknown[]) => mockCleanupAndNavigateAfterExpenseCreate(...args), })); -jest.mock('@pages/iou/request/step/resolveChatTarget', () => ({ - resolveChatTargetForSubmitCleanup: (...args: unknown[]) => mockResolveChatTargetForSubmitCleanup(...args), -})); - jest.mock('@hooks/useLocalize', () => ({ __esModule: true, default: () => ({translate: jest.fn((key: string) => key), toLocaleDigit: jest.fn((digit: string) => digit)}), @@ -191,7 +186,6 @@ describe('useExpenseSubmission orchestrator-suppressed cleanup', () => { jest.clearAllMocks(); await Onyx.clear(); mockRequestMoneyAction.mockReturnValue({iouReport: {reportID: 'iou-1'}}); - mockResolveChatTargetForSubmitCleanup.mockReturnValue({report: {reportID: REPORT_ID}, chatReportID: 'fallback-id', optimisticChatReportID: undefined}); }); describe('requestMoney path', () => { @@ -271,8 +265,8 @@ describe('useExpenseSubmission orchestrator-suppressed cleanup', () => { ); }); - // The IOU action owns post-submit navigation now, so the hook no longer derives a chat target for cleanup nav. - it('does not derive the chat target via resolveChatTargetForSubmitCleanup (the IOU action owns post-submit nav)', async () => { + // The IOU action owns post-submit navigation now, so the hook performs no cleanup navigation of its own. + it('does not run cleanup navigation from the hook (the IOU action owns post-submit nav)', async () => { mockRequestMoneyAction.mockReturnValue({iouReport: {reportID: 'iou-1', chatReportID: 'iou-chat-77'}}); const {result} = renderHook(() => useExpenseSubmission(buildParams())); @@ -283,7 +277,6 @@ describe('useExpenseSubmission orchestrator-suppressed cleanup', () => { }); await waitForBatchedUpdatesWithAct(); - expect(mockResolveChatTargetForSubmitCleanup).not.toHaveBeenCalled(); expect(mockCleanupAndNavigateAfterExpenseCreate).not.toHaveBeenCalled(); expect(mockCleanupAfterExpenseCreate).toHaveBeenCalledTimes(1); }); @@ -421,7 +414,6 @@ describe('useExpenseSubmission action-bailout safety', () => { beforeEach(async () => { jest.clearAllMocks(); await Onyx.clear(); - mockResolveChatTargetForSubmitCleanup.mockReturnValue({report: {reportID: REPORT_ID}, chatReportID: 'fallback-id', optimisticChatReportID: undefined}); }); it('skips requestMoney entirely (including the action call) when SUBMIT batch is missing linked-track metadata', async () => { diff --git a/tests/unit/navigateAfterExpenseCreateTest.ts b/tests/unit/navigateAfterExpenseCreateTest.ts index fa2e23388340..ea947c27f914 100644 --- a/tests/unit/navigateAfterExpenseCreateTest.ts +++ b/tests/unit/navigateAfterExpenseCreateTest.ts @@ -38,6 +38,7 @@ jest.mock('@libs/Navigation/Navigation', () => ({ dismissModalWithReport: jest.fn(), pop: jest.fn(), navigate: jest.fn(), + getActiveRoute: jest.fn(() => ''), revealRouteBeforeDismissingModal: jest.fn(), isNavigationReady: jest.fn(() => Promise.resolve()), getIsFullscreenPreInsertedUnderRHP: jest.fn(() => false), @@ -62,6 +63,10 @@ jest.mock('@libs/actions/Report', () => ({ setOptimisticTransactionThread: jest.fn(), })); +jest.mock('@libs/actions/TransactionThreadNavigation', () => ({ + setActiveTransactionIDs: jest.fn(() => Promise.resolve()), +})); + describe('navigateAfterExpenseCreate', () => { beforeAll(() => { const followUpMock = require('@libs/telemetry/submitFollowUpAction') as {setPendingSubmitFollowUpAction: jest.Mock}; @@ -104,6 +109,27 @@ describe('navigateAfterExpenseCreate', () => { expect(Growl.success).toHaveBeenCalledWith(expect.any(String), expect.any(Number), expect.objectContaining({label: expect.any(String), onPress: expect.any(Function)})); }); + it('should defer thread materialization to View press instead of growl-show time', () => { + const {setOptimisticTransactionThread} = require('@libs/actions/Report') as {setOptimisticTransactionThread: jest.Mock}; + + navigateAfterExpenseCreate({ + activeReportID: 'report-123', + transactionID: 'txn-1', + transactionThreadReportID: 'thread-1', + isFromGlobalCreate: false, + hasMultipleTransactions: false, + shouldAlwaysShowFeedback: true, + }); + + // Showing the growl must not touch the thread report. + expect(setOptimisticTransactionThread).not.toHaveBeenCalled(); + + const growlAction = (Growl.success as jest.Mock).mock.calls.at(0)?.at(2) as {onPress: () => void}; + growlAction.onPress(); + + expect(setOptimisticTransactionThread).toHaveBeenCalledWith('thread-1', undefined, undefined, undefined); + }); + it('should dismiss to report when user is on inbox tab', () => { mockIsReportTopmostSplitNavigator.mockReturnValue(true); diff --git a/tests/unit/resolveChatTargetTest.ts b/tests/unit/resolveChatTargetTest.ts deleted file mode 100644 index aac57807bafc..000000000000 --- a/tests/unit/resolveChatTargetTest.ts +++ /dev/null @@ -1,233 +0,0 @@ -import {getChatByParticipants, getReportOrDraftReport, isDeprecatedGroupDM, isGroupChat, isMoneyRequestReport, isPolicyExpenseChat, isSelfDM} from '@libs/ReportUtils'; - -import {resolveChatTargetForSubmitCleanup} from '@pages/iou/request/step/resolveChatTarget'; - -import CONST from '@src/CONST'; -import type {Report} from '@src/types/onyx'; -import type {Participant} from '@src/types/onyx/IOU'; - -jest.mock('@libs/ReportUtils', () => ({ - getChatByParticipants: jest.fn(), - getReportOrDraftReport: jest.fn(), - isMoneyRequestReport: jest.fn(), - isPolicyExpenseChat: jest.fn(), - isSelfDM: jest.fn(), - isGroupChat: jest.fn(), - isDeprecatedGroupDM: jest.fn(), -})); - -// Stub IOUUtils (only resolveChatTargetForScan reaches it) to keep the test bundle light. -jest.mock('@libs/IOUUtils', () => ({ - resolveOptimisticChatReportID: jest.fn(), -})); - -const CURRENT_USER_ACCOUNT_ID = 1; -const PARTICIPANT_ACCOUNT_ID = 42; -const OTHER_PARTICIPANT_ACCOUNT_ID = 99; -const FALLBACK = 'fallback-optimistic-id'; - -describe('resolveChatTargetForSubmitCleanup', () => { - beforeEach(() => { - jest.clearAllMocks(); - (getChatByParticipants as jest.Mock).mockReturnValue(undefined); - (getReportOrDraftReport as jest.Mock).mockReturnValue({reportID: 'cached'}); - (isMoneyRequestReport as jest.Mock).mockReturnValue(false); - (isPolicyExpenseChat as jest.Mock).mockReturnValue(false); - (isSelfDM as jest.Mock).mockReturnValue(false); - (isGroupChat as jest.Mock).mockReturnValue(false); - (isDeprecatedGroupDM as jest.Mock).mockReturnValue(false); - }); - - it('should keep the source report when it is a money-request (IOU/expense) report so navigation lands back on it', () => { - const participant: Participant = {accountID: PARTICIPANT_ACCOUNT_ID}; - const report = {reportID: 'iou-report-1'} as Report; - (isMoneyRequestReport as jest.Mock).mockReturnValue(true); - - const result = resolveChatTargetForSubmitCleanup({ - participant, - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - report, - fallbackOptimisticChatReportID: FALLBACK, - action: CONST.IOU.ACTION.CREATE, - }); - - expect(isMoneyRequestReport).toHaveBeenCalledWith(report); - expect(getChatByParticipants).not.toHaveBeenCalled(); - expect(result).toEqual({report, chatReportID: FALLBACK, optimisticChatReportID: undefined}); - }); - - it('should keep the source report when participants match (action keeps parentChatReport even if getChatByParticipants returns no match)', () => { - const participant: Participant = {accountID: PARTICIPANT_ACCOUNT_ID}; - const report = {reportID: 'chat-A', participants: {[PARTICIPANT_ACCOUNT_ID]: {}, [CURRENT_USER_ACCOUNT_ID]: {}}} as unknown as Report; - (getChatByParticipants as jest.Mock).mockReturnValue(undefined); - - const result = resolveChatTargetForSubmitCleanup({ - participant, - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - report, - fallbackOptimisticChatReportID: FALLBACK, - action: CONST.IOU.ACTION.CREATE, - }); - - expect(result).toEqual({report, chatReportID: FALLBACK, optimisticChatReportID: undefined}); - }); - - it('should keep the source report for special chat types (policyExpenseChat / selfDM / groupChat / deprecatedGroupDM) regardless of participant match', () => { - const participant: Participant = {accountID: PARTICIPANT_ACCOUNT_ID}; - const report = {reportID: 'self-dm-1'} as Report; - (isSelfDM as jest.Mock).mockReturnValue(true); - - const result = resolveChatTargetForSubmitCleanup({ - participant, - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - report, - fallbackOptimisticChatReportID: FALLBACK, - action: CONST.IOU.ACTION.CREATE, - }); - - expect(result).toEqual({report, chatReportID: FALLBACK, optimisticChatReportID: undefined}); - }); - - it('should keep the source report when participant.isPolicyExpenseChat=true (action skips participant validation)', () => { - const participant: Participant = {isPolicyExpenseChat: true, reportID: 'workspace-1'}; - const report = {reportID: 'some-report'} as Report; - - const result = resolveChatTargetForSubmitCleanup({ - participant, - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - report, - fallbackOptimisticChatReportID: FALLBACK, - action: CONST.IOU.ACTION.CREATE, - }); - - expect(result).toEqual({report, chatReportID: FALLBACK, optimisticChatReportID: undefined}); - }); - - it('should resolve to participant.reportID when participant is policyExpenseChat and no source report', () => { - const participant: Participant = {isPolicyExpenseChat: true, reportID: 'workspace-1'}; - - const result = resolveChatTargetForSubmitCleanup({ - participant, - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - report: undefined, - fallbackOptimisticChatReportID: FALLBACK, - action: CONST.IOU.ACTION.CREATE, - }); - - expect(result).toEqual({report: undefined, chatReportID: 'workspace-1', optimisticChatReportID: undefined}); - }); - - it('should fall back to optimisticChatReportID when participant.isPolicyExpenseChat targets a report not present in the Onyx cache (mirrors action behavior)', () => { - const participant: Participant = {isPolicyExpenseChat: true, reportID: 'workspace-uncached'}; - (getReportOrDraftReport as jest.Mock).mockReturnValue(undefined); - - const result = resolveChatTargetForSubmitCleanup({ - participant, - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - report: undefined, - fallbackOptimisticChatReportID: FALLBACK, - action: CONST.IOU.ACTION.CREATE, - }); - - expect(result).toEqual({report: undefined, chatReportID: FALLBACK, optimisticChatReportID: undefined}); - }); - - it('should resolve to the existing 1:1 DM via getChatByParticipants when participant differs from source report', () => { - const participant: Participant = {accountID: OTHER_PARTICIPANT_ACCOUNT_ID}; - const report = {reportID: 'chat-A', participants: {[PARTICIPANT_ACCOUNT_ID]: {}, [CURRENT_USER_ACCOUNT_ID]: {}}} as unknown as Report; - (getChatByParticipants as jest.Mock).mockReturnValue({reportID: 'chat-B'}); - - const result = resolveChatTargetForSubmitCleanup({ - participant, - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - report, - fallbackOptimisticChatReportID: FALLBACK, - action: CONST.IOU.ACTION.CREATE, - }); - - expect(getChatByParticipants).toHaveBeenCalledWith([OTHER_PARTICIPANT_ACCOUNT_ID, CURRENT_USER_ACCOUNT_ID]); - expect(result).toEqual({report: undefined, chatReportID: 'chat-B', optimisticChatReportID: undefined}); - }); - - it('should fall back to optimisticChatReportID and discard report when participant changed to a brand-new contact (no existing chat)', () => { - const participant: Participant = {accountID: OTHER_PARTICIPANT_ACCOUNT_ID}; - const report = {reportID: 'chat-A', participants: {[PARTICIPANT_ACCOUNT_ID]: {}, [CURRENT_USER_ACCOUNT_ID]: {}}} as unknown as Report; - (getChatByParticipants as jest.Mock).mockReturnValue(undefined); - - const result = resolveChatTargetForSubmitCleanup({ - participant, - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - report, - fallbackOptimisticChatReportID: FALLBACK, - action: CONST.IOU.ACTION.CREATE, - }); - - expect(result).toEqual({report: undefined, chatReportID: FALLBACK, optimisticChatReportID: undefined}); - }); - - it('should fall back to optimisticChatReportID when both report and participant are empty', () => { - const participant: Participant = {}; - - const result = resolveChatTargetForSubmitCleanup({ - participant, - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - report: undefined, - fallbackOptimisticChatReportID: FALLBACK, - action: CONST.IOU.ACTION.CREATE, - }); - - expect(result).toEqual({report: undefined, chatReportID: FALLBACK, optimisticChatReportID: undefined}); - }); - - describe('tracked-expense submit (action === SUBMIT)', () => { - it('should NOT keep the self-DM source — the action writes to the participant 1:1 chat, not back to self-DM', () => { - const participant: Participant = {accountID: PARTICIPANT_ACCOUNT_ID}; - const report = {reportID: 'self-dm-1'} as Report; - (isSelfDM as jest.Mock).mockReturnValue(true); - (getChatByParticipants as jest.Mock).mockReturnValue({reportID: 'one-on-one-chat'}); - - const result = resolveChatTargetForSubmitCleanup({ - participant, - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - report, - fallbackOptimisticChatReportID: FALLBACK, - action: CONST.IOU.ACTION.SUBMIT, - }); - - expect(getChatByParticipants).toHaveBeenCalledWith([PARTICIPANT_ACCOUNT_ID, CURRENT_USER_ACCOUNT_ID]); - expect(result).toEqual({report: undefined, chatReportID: 'one-on-one-chat', optimisticChatReportID: undefined}); - }); - - it('should resolve to the participant policy-expense chat (not the self-DM source) for a workspace submit', () => { - const participant: Participant = {isPolicyExpenseChat: true, reportID: 'workspace-1'}; - const report = {reportID: 'self-dm-1'} as Report; - (isSelfDM as jest.Mock).mockReturnValue(true); - - const result = resolveChatTargetForSubmitCleanup({ - participant, - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - report, - fallbackOptimisticChatReportID: FALLBACK, - action: CONST.IOU.ACTION.SUBMIT, - }); - - expect(result).toEqual({report: undefined, chatReportID: 'workspace-1', optimisticChatReportID: undefined}); - }); - - it('should still keep the source when it is a money-request report even for SUBMIT (mirrors action: isMoneyRequestReport ? report.reportID)', () => { - const participant: Participant = {accountID: PARTICIPANT_ACCOUNT_ID}; - const report = {reportID: 'iou-report-1'} as Report; - (isMoneyRequestReport as jest.Mock).mockReturnValue(true); - - const result = resolveChatTargetForSubmitCleanup({ - participant, - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - report, - fallbackOptimisticChatReportID: FALLBACK, - action: CONST.IOU.ACTION.SUBMIT, - }); - - expect(result).toEqual({report, chatReportID: FALLBACK, optimisticChatReportID: undefined}); - }); - }); -}); diff --git a/tests/unit/useSearchAutoRefetchTest.ts b/tests/unit/useSearchAutoRefetchTest.ts index 6e8d71d7365d..3b0c37d28945 100644 --- a/tests/unit/useSearchAutoRefetchTest.ts +++ b/tests/unit/useSearchAutoRefetchTest.ts @@ -12,8 +12,13 @@ import ONYXKEYS from '@src/ONYXKEYS'; import Onyx from 'react-native-onyx'; const mockUseIsFocused = jest.fn((): boolean => true); +const mockUseNetwork = jest.fn((): {isOffline: boolean} => ({isOffline: false})); jest.mock('@libs/actions/Search'); +jest.mock('@hooks/useNetwork', () => ({ + __esModule: true, + default: () => mockUseNetwork(), +})); jest.mock('@react-navigation/native', () => ({ useIsFocused: () => mockUseIsFocused(), createNavigationContainerRef: () => ({}), @@ -33,6 +38,7 @@ jest.mock('@rnmapbox/maps', () => ({ beforeEach(() => { mockUseIsFocused.mockReturnValue(true); + mockUseNetwork.mockReturnValue({isOffline: false}); }); afterEach(() => { @@ -216,6 +222,63 @@ describe('useSearchAutoRefetch', () => { expect(search).not.toHaveBeenCalled(); }); + it('should defer search while offline and fire it exactly once after reconnect', () => { + mockUseNetwork.mockReturnValue({isOffline: true}); + + const initialProps = { + ...baseProps, + transactions: {'1': {transactionID: '1'}}, + previousTransactions: {'1': {transactionID: '1'}}, + }; + + const {rerender} = renderHook((props: UseSearchAutoRefetchParams) => useSearchAutoRefetch(props), { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + initialProps, + }); + + // A new transaction lands while offline: the search must be stashed, not fired. + const offlineChangeProps = { + ...baseProps, + transactions: { + '1': {transactionID: '1'}, + '2': {transactionID: '2'}, + }, + previousTransactions: {'1': {transactionID: '1'}}, + }; + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + rerender(offlineChangeProps); + expect(search).not.toHaveBeenCalled(); + + // Reconnect with the diff no longer detectable (previous caught up) - only the pending + // flag stashed while offline can trigger the search now. + mockUseNetwork.mockReturnValue({isOffline: false}); + const reconnectedProps = { + ...baseProps, + transactions: { + '1': {transactionID: '1'}, + '2': {transactionID: '2'}, + }, + previousTransactions: { + '1': {transactionID: '1'}, + '2': {transactionID: '2'}, + }, + }; + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + rerender(reconnectedProps); + expect(search).toHaveBeenCalledTimes(1); + + // Further data-identical updates must not re-fire. + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + rerender({...reconnectedProps, transactions: {...reconnectedProps.transactions}}); + expect(search).toHaveBeenCalledTimes(1); + }); + it('should not trigger search for chat when report actions removed and focused', () => { const chatProps = { ...baseProps, From 3dc2fe1da67ffafa69ecb85d3b26f09610cb64ff Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Mon, 13 Jul 2026 16:45:01 +0200 Subject: [PATCH 09/28] Remove the isLastTransactionOfBatch prop --- src/libs/actions/IOU/MoneyRequest.ts | 10 ++++++---- src/libs/actions/IOU/MoneyRequestBuilder.ts | 14 ++++++-------- src/libs/actions/IOU/Split.ts | 4 ++++ src/libs/actions/IOU/TrackExpense.ts | 10 ++-------- .../actions/IOU/types/CreateTrackExpenseParams.ts | 11 ++++------- .../step/confirmation/useExpenseSubmission.ts | 14 ++++++++------ 6 files changed, 30 insertions(+), 33 deletions(-) diff --git a/src/libs/actions/IOU/MoneyRequest.ts b/src/libs/actions/IOU/MoneyRequest.ts index 28343b642c43..71005d8602b4 100644 --- a/src/libs/actions/IOU/MoneyRequest.ts +++ b/src/libs/actions/IOU/MoneyRequest.ts @@ -200,9 +200,9 @@ function createTransaction({ optimisticChatReportID, optimisticTransactionID, currentUserLocalCurrency, - shouldHandleNavigation, // Navigation/growl must fire once per multi-receipt batch, on its final transaction. - isLastTransactionOfBatch: index === files.length - 1, + shouldHandleNavigation: shouldHandleNavigation && index === files.length - 1, + shouldShowPostCreateFeedback: index === files.length - 1, delegateAccountID, reportActionsList: undefined, }); @@ -247,8 +247,10 @@ function createTransaction({ personalDetails, optimisticChatReportID, optimisticTransactionID, - shouldHandleNavigation, - isLastTransactionOfBatch: index === files.length - 1, + // Navigation/growl must fire once per multi-receipt batch, on its final transaction: silence + // every non-final write so the action surfaces feedback only for the last one. + shouldHandleNavigation: shouldHandleNavigation && index === files.length - 1, + shouldShowPostCreateFeedback: index === files.length - 1, backToReport, delegateAccountID, }); diff --git a/src/libs/actions/IOU/MoneyRequestBuilder.ts b/src/libs/actions/IOU/MoneyRequestBuilder.ts index 7727ea50ec0f..87aec60fa5cd 100644 --- a/src/libs/actions/IOU/MoneyRequestBuilder.ts +++ b/src/libs/actions/IOU/MoneyRequestBuilder.ts @@ -168,22 +168,20 @@ type RequestMoneyInformation = { transactionParams: RequestMoneyTransactionParams; isRetry?: boolean; shouldPlaySound?: boolean; + /** + * Whether this action owns the post-create flow: dismiss the money request screens, navigate to the + * destination and surface the "Expense added" feedback. Defaults to true. + */ shouldHandleNavigation?: boolean; /** * Only read when shouldHandleNavigation is false. Defaults to true: navigation is owned by the * orchestrator (dismiss-first paths), which has already navigated, so the action surfaces the * post-create feedback (growl/row highlight). Pass false when the caller owns the whole * post-create flow - navigation AND feedback - and the action must stay fully silent - * (e.g. the Share flow, which navigates after the action returns). + * (e.g. the Share flow, which navigates after the action returns, or a non-final write in a + * multi-transaction batch whose feedback must fire only once). */ shouldShowPostCreateFeedback?: boolean; - /** - * When a confirmation submits several transactions, the orchestrator calls this action once per - * transaction in a loop. Post-create navigation + the "Expense added" growl are owned by the - * action, so they must fire only once - for the final transaction of the batch. Defaults to true - * so single-transaction callers keep their existing behavior. - */ - isLastTransactionOfBatch?: boolean; /** Report the money request flow started from; post-create navigation returns there instead of the written-to chat. */ backToReport?: string; /** Retry-path cleanup only; the action itself never reads this. */ diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index 79bbe9da750d..1ffd7a57eb27 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -136,6 +136,10 @@ type CreateDistanceRequestInformation = { policyRecentlyUsedCurrencies: string[]; recentWaypoints: OnyxEntry; customUnitPolicyID?: string; + /** + * Whether this action owns the post-create flow: dismiss the money request screens, navigate to the + * destination and surface the "Expense added" feedback. Defaults to true. + */ shouldHandleNavigation?: boolean; shouldDeferForSearch?: boolean; shouldPlaySound?: boolean; diff --git a/src/libs/actions/IOU/TrackExpense.ts b/src/libs/actions/IOU/TrackExpense.ts index e6986e19c673..f27c828a0bb7 100644 --- a/src/libs/actions/IOU/TrackExpense.ts +++ b/src/libs/actions/IOU/TrackExpense.ts @@ -1632,7 +1632,6 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep shouldPlaySound = true, shouldHandleNavigation = true, shouldShowPostCreateFeedback = true, - isLastTransactionOfBatch = true, backToReport, optimisticChatReportID, optimisticCreatedReportActionID, @@ -1914,9 +1913,7 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep }); } - // Gate post-create navigation/growl to the final transaction of a multi-transaction batch so a - // multi-receipt submit fires a single dismiss/navigate and a single "Expense added" toast. - if (!requestMoneyInformation.isRetry && isLastTransactionOfBatch) { + if (!requestMoneyInformation.isRetry) { if (shouldHandleNavigation) { const navigationReportID = backToReport ?? activeReportID; handleNavigateAfterExpenseCreate({ @@ -2420,7 +2417,6 @@ function trackExpense(params: CreateTrackExpenseParams) { accountantParams, shouldHandleNavigation = true, shouldShowPostCreateFeedback = true, - isLastTransactionOfBatch = true, shouldPlaySound = true, optimisticChatReportID, optimisticTransactionID, @@ -2878,9 +2874,7 @@ function trackExpense(params: CreateTrackExpenseParams) { } } - // Gate post-create navigation/growl to the final transaction of a multi-transaction batch so a - // multi-receipt submit fires a single dismiss/navigate and a single "Expense added" toast. - if (!params.isRetry && isLastTransactionOfBatch) { + if (!params.isRetry) { if (shouldHandleNavigation) { handleNavigateAfterExpenseCreate({ activeReportID, diff --git a/src/libs/actions/IOU/types/CreateTrackExpenseParams.ts b/src/libs/actions/IOU/types/CreateTrackExpenseParams.ts index 6a1f0614a90c..06024617d05b 100644 --- a/src/libs/actions/IOU/types/CreateTrackExpenseParams.ts +++ b/src/libs/actions/IOU/types/CreateTrackExpenseParams.ts @@ -25,6 +25,10 @@ type CreateTrackExpenseParams = { accountantParams?: TrackExpenseAccountantParams; isRetry?: boolean; shouldPlaySound?: boolean; + /** + * Whether this action owns the post-create flow: dismiss the money request screens, navigate to the + * destination and surface the "Expense added" feedback. Defaults to true. + */ shouldHandleNavigation?: boolean; /** * Only read when shouldHandleNavigation is false. Defaults to true: navigation is owned by the @@ -34,13 +38,6 @@ type CreateTrackExpenseParams = { * (e.g. the Share flow, which navigates after the action returns). */ shouldShowPostCreateFeedback?: boolean; - /** - * When a confirmation submits several transactions, the orchestrator calls this action once per - * transaction in a loop. Post-create navigation + the "Expense added" growl are owned by the - * action, so they must fire only once - for the final transaction of the batch. Defaults to true - * so single-transaction callers keep their existing behavior. - */ - isLastTransactionOfBatch?: boolean; /** Retry-path cleanup only; the action itself never reads this. */ draftTransactionIDs?: string[]; optimisticChatReportID?: string; diff --git a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts index f855a77bdb3e..e97e506001b8 100644 --- a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts +++ b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts @@ -444,9 +444,10 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { optimisticTransactionID, // The action owns post-create navigation + growl, but only when the caller permits it // (dismiss-first orchestrators pass shouldHandleNavigation=false after revealing/dismissing - // the destination themselves) and only for the final transaction of the batch. - shouldHandleNavigation, - isLastTransactionOfBatch: index === transactions.length - 1, + // the destination themselves). Silence every non-final write of a multi-transaction batch so + // the feedback fires once, for the last transaction only. + shouldHandleNavigation: shouldHandleNavigation && index === transactions.length - 1, + shouldShowPostCreateFeedback: index === transactions.length - 1, // Post-create navigation must return to the report the flow started from, not the chat the expense was written to. backToReport, shouldGenerateTransactionThreadReport, @@ -681,9 +682,10 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { optimisticTransactionID, // The action owns post-create navigation + growl, but only when the caller permits it // (dismiss-first orchestrators pass shouldHandleNavigation=false after revealing/dismissing - // the destination themselves) and only for the final transaction of the batch. - shouldHandleNavigation, - isLastTransactionOfBatch: index === transactions.length - 1, + // the destination themselves). Silence every non-final write of a multi-transaction batch so + // the feedback fires once, for the last transaction only. + shouldHandleNavigation: shouldHandleNavigation && index === transactions.length - 1, + shouldShowPostCreateFeedback: index === transactions.length - 1, isASAPSubmitBetaEnabled, currentUser: {accountID: currentUserPersonalDetails.accountID, email}, introSelected, From 348ca764e9da19041155c52a1cbc38ac2b1f5f08 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Mon, 13 Jul 2026 16:56:07 +0200 Subject: [PATCH 10/28] Add missing jsdoc comments to type properties --- src/hooks/useSearchAutoRefetch.ts | 17 ++++++++++++ .../cleanupAndNavigateAfterExpenseCreate.ts | 2 ++ .../helpers/navigateAfterExpenseCreate.ts | 27 ++++++++++++++++++- src/libs/actions/IOU/MoneyRequestBuilder.ts | 2 +- src/libs/actions/IOU/Split.ts | 6 ++++- .../IOU/types/CreateTrackExpenseParams.ts | 2 +- 6 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/hooks/useSearchAutoRefetch.ts b/src/hooks/useSearchAutoRefetch.ts index 72761bb737fd..821ab8254df8 100644 --- a/src/hooks/useSearchAutoRefetch.ts +++ b/src/hooks/useSearchAutoRefetch.ts @@ -17,14 +17,31 @@ import {useEffect, useRef} from 'react'; import useNetwork from './useNetwork'; type UseSearchAutoRefetchParams = { + /** Current search snapshot. */ searchResults: OnyxEntry; + + /** Live transaction collection. */ transactions: OnyxCollection; + + /** Previous transaction collection. */ previousTransactions: OnyxCollection; + + /** Live report-actions collection. */ reportActions: OnyxCollection; + + /** Previous report-actions collection. */ previousReportActions: OnyxCollection; + + /** Parsed query driving the refetch. */ queryJSON: SearchQueryJSON; + + /** Suggested-search key passed through to the refetch. */ searchKey: SearchKey | undefined; + + /** Pagination offset passed through to the refetch. */ offset: number; + + /** Whether the refetch should recompute list totals. */ shouldCalculateTotals: boolean; }; diff --git a/src/libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate.ts index b09ff6afc31a..0eea9d9dbbe5 100644 --- a/src/libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate.ts @@ -16,6 +16,8 @@ type CleanupAndNavigateAfterExpenseCreateParams = { /** IOU report the expense landed in; lets the "Expense added" growl resolve its "View" deep link. */ iouReportID?: string; + + /** Transaction thread report the growl's "View" action opens. */ transactionThreadReportID?: string; isFromGlobalCreate: boolean | undefined; backToReport?: string; diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index c44f18d77125..a4a1533bc32e 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -65,13 +65,28 @@ Onyx.connectWithoutView({ }); type NavigateAfterExpenseCreateParams = { + /** Report to open when navigation stays on the Inbox tab. */ activeReportID?: string; + + /** IOU report the transaction landed in, used to resolve the growl's "View" deep link. */ iouReportID?: string; + + /** The created transaction's ID. */ transactionID?: string; + + /** Transaction thread report the growl's "View" action opens. */ transactionThreadReportID?: string; + + /** Whether the expense was created from the global create (FAB). */ isFromGlobalCreate?: boolean; + + /** Whether the expense is an invoice. */ isInvoice?: boolean; + + /** Whether the target report already holds other transactions. */ hasMultipleTransactions: boolean; + + /** Mark the new transaction for the in-report row highlight. */ shouldAddPendingNewTransactionIDs?: boolean; /** Show the "Expense added" growl even on the non-global-create path (e.g. the Share flow, whose drafts never set isFromGlobalCreate). */ @@ -79,8 +94,13 @@ type NavigateAfterExpenseCreateParams = { }; type ShowExpenseAddedGrowlParams = { + /** IOU report the transaction landed in, used to resolve the growl's "View" deep link. */ iouReportID?: string; + + /** The created transaction. */ transactionID?: string; + + /** Transaction thread report the growl's "View" action opens. */ transactionThreadReportID?: string; /** Whether this confirmation is for an invoice (changes the toast copy from "Expense added"). */ @@ -267,8 +287,13 @@ function showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadRep } type SurfaceExpenseCreatedFeedbackParams = { + /** IOU report the transaction landed in, used to resolve the growl's "View" deep link. */ iouReportID?: string; + + /** The created transaction. */ transactionID?: string; + + /** Transaction thread report the growl's "View" action opens. */ transactionThreadReportID?: string; /** @@ -277,7 +302,7 @@ type SurfaceExpenseCreatedFeedbackParams = { */ isMoneyRequestReport?: boolean; - /** Whether this is an invoice (changes the growl copy). */ + /** Whether the expense is an invoice (changes the growl copy). */ isInvoice?: boolean; }; diff --git a/src/libs/actions/IOU/MoneyRequestBuilder.ts b/src/libs/actions/IOU/MoneyRequestBuilder.ts index 87aec60fa5cd..15355fc3b476 100644 --- a/src/libs/actions/IOU/MoneyRequestBuilder.ts +++ b/src/libs/actions/IOU/MoneyRequestBuilder.ts @@ -169,7 +169,7 @@ type RequestMoneyInformation = { isRetry?: boolean; shouldPlaySound?: boolean; /** - * Whether this action owns the post-create flow: dismiss the money request screens, navigate to the + * Whether the action owns the post-create flow: dismiss the money request screens, navigate to the * destination and surface the "Expense added" feedback. Defaults to true. */ shouldHandleNavigation?: boolean; diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index 1ffd7a57eb27..b4eca8d41ec2 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -129,6 +129,8 @@ type CreateDistanceRequestInformation = { existingTransaction?: OnyxEntry; transactionParams: DistanceRequestTransactionParams; policyParams?: BasePolicyParams; + + /** Report the flow started from, post-create navigation returns there instead of the written-to chat. */ backToReport?: string; isASAPSubmitBetaEnabled: boolean; transactionViolations: OnyxCollection; @@ -137,10 +139,12 @@ type CreateDistanceRequestInformation = { recentWaypoints: OnyxEntry; customUnitPolicyID?: string; /** - * Whether this action owns the post-create flow: dismiss the money request screens, navigate to the + * Whether the action owns the post-create flow: dismiss the money request screens, navigate to the * destination and surface the "Expense added" feedback. Defaults to true. */ shouldHandleNavigation?: boolean; + + /** Defer the API.write until Search flushes it (the deferred-for-search pattern). */ shouldDeferForSearch?: boolean; shouldPlaySound?: boolean; personalDetails: OnyxEntry; diff --git a/src/libs/actions/IOU/types/CreateTrackExpenseParams.ts b/src/libs/actions/IOU/types/CreateTrackExpenseParams.ts index 06024617d05b..7b8430b2e212 100644 --- a/src/libs/actions/IOU/types/CreateTrackExpenseParams.ts +++ b/src/libs/actions/IOU/types/CreateTrackExpenseParams.ts @@ -26,7 +26,7 @@ type CreateTrackExpenseParams = { isRetry?: boolean; shouldPlaySound?: boolean; /** - * Whether this action owns the post-create flow: dismiss the money request screens, navigate to the + * Whether the action owns the post-create flow: dismiss the money request screens, navigate to the * destination and surface the "Expense added" feedback. Defaults to true. */ shouldHandleNavigation?: boolean; From 3e03816583dfa933ba4e51e451708a2906a13dd3 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Tue, 14 Jul 2026 13:08:35 +0200 Subject: [PATCH 11/28] Remove Onyx.connectWithoutView usages that can be removed --- .../cleanupAndNavigateAfterExpenseCreate.ts | 7 ++ .../helpers/navigateAfterExpenseCreate.ts | 115 ++++++++++-------- src/libs/actions/IOU/MoneyRequest.ts | 1 + src/libs/actions/IOU/MoneyRequestBuilder.ts | 2 + src/libs/actions/IOU/NavigationHelpers.ts | 4 + src/libs/actions/IOU/TrackExpense.ts | 19 +++ src/pages/Share/SubmitDetailsPage.tsx | 7 ++ .../step/confirmation/useExpenseSubmission.ts | 1 + 8 files changed, 102 insertions(+), 54 deletions(-) diff --git a/src/libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate.ts index 0eea9d9dbbe5..1489ec03142d 100644 --- a/src/libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate.ts @@ -6,6 +6,8 @@ import type DeepValueOf from '@src/types/utils/DeepValueOf'; import type {OnyxEntry} from 'react-native-onyx'; +import type {BuildTransactionThreadParams} from './navigateAfterExpenseCreate'; + import cleanupAfterExpenseCreate from './cleanupAfterExpenseCreate'; import navigateAfterExpenseCreate from './navigateAfterExpenseCreate'; @@ -28,6 +30,9 @@ type CleanupAndNavigateAfterExpenseCreateParams = { /** Show the "Expense added" growl even on the non-global-create path (e.g. the Share flow, whose drafts never set isFromGlobalCreate). */ shouldAlwaysShowFeedback?: boolean; + + /** Data the "View" action needs to build the transaction thread. */ + buildTransactionThreadParams?: BuildTransactionThreadParams; }; function cleanupAndNavigateAfterExpenseCreate({ @@ -43,6 +48,7 @@ function cleanupAndNavigateAfterExpenseCreate({ linkedTrackedExpenseReportAction, action, shouldAlwaysShowFeedback, + buildTransactionThreadParams, }: CleanupAndNavigateAfterExpenseCreateParams) { cleanupAfterExpenseCreate({ draftTransactionIDs, @@ -66,6 +72,7 @@ function cleanupAndNavigateAfterExpenseCreate({ hasMultipleTransactions, shouldAddPendingNewTransactionIDs, shouldAlwaysShowFeedback, + buildTransactionThreadParams, }); } diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index a4a1533bc32e..2fab1818ede7 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -8,7 +8,7 @@ import Log from '@libs/Log'; import {getPreservedNavigatorState} from '@libs/Navigation/AppNavigator/createSplitNavigator/usePreserveNavigatorState'; import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; import {getIOUActionForReportID} from '@libs/ReportActionsUtils'; -import {getReportOrDraftReport} from '@libs/ReportUtils'; +import {getReportOrDraftReport, getReportTransactions} from '@libs/ReportUtils'; import {buildCannedSearchQuery, getCurrentSearchQueryJSON} from '@libs/SearchQueryUtils'; import {setPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction'; @@ -19,8 +19,6 @@ import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import type {Beta, IntroSelected, Transaction} from '@src/types/onyx'; -import type {OnyxCollection} from 'react-native-onyx'; - import Onyx from 'react-native-onyx'; import dismissModalAndOpenReportInInboxTab from './dismissModalAndOpenReportInInboxTab'; @@ -29,40 +27,22 @@ import isReportTopmostSplitNavigator from './isReportTopmostSplitNavigator'; import isSearchTopmostFullScreenRoute from './isSearchTopmostFullScreenRoute'; import setNavigationActionToMicrotaskQueue from './setNavigationActionToMicrotaskQueue'; -let currentUserEmail = ''; -let currentUserAccountID: number = CONST.DEFAULT_NUMBER_ID; -Onyx.connectWithoutView({ - key: ONYXKEYS.SESSION, - callback: (value) => { - currentUserEmail = value?.email ?? ''; - currentUserAccountID = value?.accountID ?? CONST.DEFAULT_NUMBER_ID; - }, -}); - -let introSelected: IntroSelected | undefined; -Onyx.connectWithoutView({ - key: ONYXKEYS.NVP_INTRO_SELECTED, - callback: (value) => { - introSelected = value ?? undefined; - }, -}); - -let betas: Beta[] | undefined; -Onyx.connectWithoutView({ - key: ONYXKEYS.BETAS, - callback: (value) => { - betas = value ?? undefined; - }, -}); - -let allTransactions: OnyxCollection; -Onyx.connectWithoutView({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (transactions) => { - allTransactions = transactions; - }, -}); +type BuildTransactionThreadParams = { + /** Current user's email. */ + currentUserLogin: string; + + /** Current user's account ID. */ + currentUserAccountID: number; + + /** Enabled betas. */ + betas: Beta[] | undefined; + + /** The onboarding intro selection. */ + introSelected: IntroSelected | undefined; + + /** The just-created transaction. */ + transaction: Transaction | undefined; +}; type NavigateAfterExpenseCreateParams = { /** Report to open when navigation stays on the Inbox tab. */ @@ -91,6 +71,9 @@ type NavigateAfterExpenseCreateParams = { /** Show the "Expense added" growl even on the non-global-create path (e.g. the Share flow, whose drafts never set isFromGlobalCreate). */ shouldAlwaysShowFeedback?: boolean; + + /** Data the "View" action needs to build the transaction thread. */ + buildTransactionThreadParams?: BuildTransactionThreadParams; }; type ShowExpenseAddedGrowlParams = { @@ -105,6 +88,9 @@ type ShowExpenseAddedGrowlParams = { /** Whether this confirmation is for an invoice (changes the toast copy from "Expense added"). */ isInvoice?: boolean; + + /** Data the "View" action needs to build the transaction thread. */ + buildTransactionThreadParams?: BuildTransactionThreadParams; }; /** @@ -121,7 +107,13 @@ type ShowExpenseAddedGrowlParams = { * navigation entry point builds the thread at navigation time, and keeping untapped growls free * of Onyx/API side effects. */ -function showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadReportID: providedTransactionThreadReportID, isInvoice}: ShowExpenseAddedGrowlParams) { +function showExpenseAddedGrowl({ + iouReportID, + transactionID, + transactionThreadReportID: providedTransactionThreadReportID, + isInvoice, + buildTransactionThreadParams, +}: ShowExpenseAddedGrowlParams) { if (!transactionID) { return; } @@ -129,20 +121,22 @@ function showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadRep // eslint-disable-next-line @typescript-eslint/no-deprecated -- imperative module (not a React component); no useLocalize hook available here const growlMessage = isInvoice ? translateLocal('iou.invoiceSent') : translateLocal('iou.expenseAdded'); + const getTransaction = (): Transaction | undefined => + buildTransactionThreadParams?.transaction ?? (iouReportID ? getReportTransactions(iouReportID).find((transaction) => transaction.transactionID === transactionID) : undefined); + const buildThreadFromOnyx = (): string | undefined => { const iouReport = iouReportID ? getReportOrDraftReport(iouReportID) : undefined; const iouAction = iouReportID ? getIOUActionForReportID(iouReportID, transactionID) : undefined; let threadReportID = providedTransactionThreadReportID ?? iouAction?.childReportID; if (!threadReportID) { - const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; const optimisticThread = createTransactionThreadReport({ - introSelected, - currentUserLogin: currentUserEmail, - currentUserAccountID, - betas, + introSelected: buildTransactionThreadParams?.introSelected, + currentUserLogin: buildTransactionThreadParams?.currentUserLogin ?? '', + currentUserAccountID: buildTransactionThreadParams?.currentUserAccountID ?? CONST.DEFAULT_NUMBER_ID, + betas: buildTransactionThreadParams?.betas, iouReport, iouReportAction: iouAction, - transaction, + transaction: getTransaction(), }); threadReportID = optimisticThread?.reportID; } else { @@ -153,13 +147,13 @@ function showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadRep // Pure mirror of buildThreadFromOnyx's requirements, used at show time to decide whether "View" // can be offered without materializing anything: a known thread ID, or an anchor to build one - // from (the IOU report, or the cached transaction for tracked/unreported expenses). + // from (the IOU report, or the passed-in transaction for tracked/unreported expenses). const canResolveThread = (): boolean => { const iouAction = iouReportID ? getIOUActionForReportID(iouReportID, transactionID) : undefined; if (providedTransactionThreadReportID ?? iouAction?.childReportID) { return true; } - return !!(iouReportID && getReportOrDraftReport(iouReportID)) || !!allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + return !!(iouReportID && getReportOrDraftReport(iouReportID)) || !!getTransaction(); }; const showGrowl = () => { @@ -205,9 +199,7 @@ function showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadRep // case navigate straight to the transaction thread instead; the report still shows underneath // via the Wide RHP machinery, matching the Spend-context path above. const hasMultipleReportTransactions = - Object.values(allTransactions ?? {}).filter( - (transaction) => transaction?.reportID === iouReportID && transaction?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, - ).length > 1; + getReportTransactions(iouReportID).filter((transaction) => transaction?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length > 1; if (iouReportID && hasMultipleReportTransactions) { Navigation.navigate(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: iouReportID, backTo})); @@ -251,6 +243,9 @@ function showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadRep // Slow path: wait for Search to render → flushDeferredWrite('search') → API.write applies // optimistic data → iouAction lands → we show the growl. + // Unlike the params threaded in above, this can't be passed down: it awaits a *future* write, not + // current state. Onyx has no promise-based get, so a transient, self-disconnecting subscription is + // the only way to wait for it. let resolved = false; let safetyTimeoutID: ReturnType | undefined; const reportActionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}` as const; @@ -304,6 +299,9 @@ type SurfaceExpenseCreatedFeedbackParams = { /** Whether the expense is an invoice (changes the growl copy). */ isInvoice?: boolean; + + /** Data the "View" action needs to build the transaction thread. */ + buildTransactionThreadParams?: BuildTransactionThreadParams; }; /** @@ -312,12 +310,19 @@ type SurfaceExpenseCreatedFeedbackParams = { * - if the user is looking at the expense report's table (in-report add) → highlight the new row; * - otherwise → show the "Expense added" growl with a "View" deep link. */ -function surfaceExpenseCreatedFeedback({iouReportID, transactionID, transactionThreadReportID, isMoneyRequestReport, isInvoice}: SurfaceExpenseCreatedFeedbackParams) { +function surfaceExpenseCreatedFeedback({ + iouReportID, + transactionID, + transactionThreadReportID, + isMoneyRequestReport, + isInvoice, + buildTransactionThreadParams, +}: SurfaceExpenseCreatedFeedbackParams) { if (isMoneyRequestReport && iouReportID && transactionID) { addPendingNewTransactionIDs(iouReportID, transactionID); return; } - showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadReportID, isInvoice}); + showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadReportID, isInvoice, buildTransactionThreadParams}); } /** @@ -341,6 +346,7 @@ function navigateAfterExpenseCreate({ hasMultipleTransactions, shouldAddPendingNewTransactionIDs = false, shouldAlwaysShowFeedback = false, + buildTransactionThreadParams, }: NavigateAfterExpenseCreateParams) { const isUserOnInbox = isReportTopmostSplitNavigator(); const isUserOnSpend = isSearchTopmostFullScreenRoute(); @@ -355,7 +361,7 @@ function navigateAfterExpenseCreate({ addPendingNewTransactionIDs(activeReportID, transactionID); } if (shouldAlwaysShowFeedback && transactionID) { - showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadReportID: providedTransactionThreadReportID, isInvoice}); + showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadReportID: providedTransactionThreadReportID, isInvoice, buildTransactionThreadParams}); } return; } @@ -368,7 +374,7 @@ function navigateAfterExpenseCreate({ if (shouldAddPendingNewTransactionIDs) { addPendingNewTransactionIDs(activeReportID, transactionID); } - showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadReportID: providedTransactionThreadReportID, isInvoice}); + showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadReportID: providedTransactionThreadReportID, isInvoice, buildTransactionThreadParams}); return; } @@ -427,8 +433,9 @@ function navigateAfterExpenseCreate({ Navigation.isNavigationReady().then(navigateToSearch); } - showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadReportID: providedTransactionThreadReportID, isInvoice}); + showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadReportID: providedTransactionThreadReportID, isInvoice, buildTransactionThreadParams}); } export default navigateAfterExpenseCreate; export {surfaceExpenseCreatedFeedback}; +export type {BuildTransactionThreadParams}; diff --git a/src/libs/actions/IOU/MoneyRequest.ts b/src/libs/actions/IOU/MoneyRequest.ts index 71005d8602b4..3ef50c25ac41 100644 --- a/src/libs/actions/IOU/MoneyRequest.ts +++ b/src/libs/actions/IOU/MoneyRequest.ts @@ -245,6 +245,7 @@ function createTransaction({ existingTransaction: transaction, isSelfTourViewed, personalDetails, + introSelected, optimisticChatReportID, optimisticTransactionID, // Navigation/growl must fire once per multi-receipt batch, on its final transaction: silence diff --git a/src/libs/actions/IOU/MoneyRequestBuilder.ts b/src/libs/actions/IOU/MoneyRequestBuilder.ts index 15355fc3b476..7121917c73c6 100644 --- a/src/libs/actions/IOU/MoneyRequestBuilder.ts +++ b/src/libs/actions/IOU/MoneyRequestBuilder.ts @@ -203,6 +203,8 @@ type RequestMoneyInformation = { isSelfTourViewed: boolean; betas: OnyxEntry; personalDetails: OnyxEntry; + /** Onboarding intro selection. */ + introSelected?: OnyxEntry; shouldDeferAutoSubmit?: boolean; delegateAccountID: number | undefined; }; diff --git a/src/libs/actions/IOU/NavigationHelpers.ts b/src/libs/actions/IOU/NavigationHelpers.ts index ef64491edb46..71e595fe93f3 100644 --- a/src/libs/actions/IOU/NavigationHelpers.ts +++ b/src/libs/actions/IOU/NavigationHelpers.ts @@ -1,5 +1,6 @@ import sharedDismissModalAndOpenReportInInboxTab from '@libs/Navigation/helpers/dismissModalAndOpenReportInInboxTab'; import navigateAfterExpenseCreate from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; +import type {BuildTransactionThreadParams} from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; import {getAllTransactions} from './index'; @@ -29,6 +30,7 @@ function handleNavigateAfterExpenseCreate({ isFromGlobalCreate, isInvoice, shouldAddPendingNewTransactionIDs = false, + buildTransactionThreadParams, }: { activeReportID?: string; iouReportID?: string; @@ -37,6 +39,7 @@ function handleNavigateAfterExpenseCreate({ isFromGlobalCreate?: boolean; isInvoice?: boolean; shouldAddPendingNewTransactionIDs?: boolean; + buildTransactionThreadParams?: BuildTransactionThreadParams; }) { const hasMultipleTransactions = Object.values(getAllTransactions()).filter((transaction) => transaction?.reportID === activeReportID).length > 0; navigateAfterExpenseCreate({ @@ -48,6 +51,7 @@ function handleNavigateAfterExpenseCreate({ isInvoice, hasMultipleTransactions, shouldAddPendingNewTransactionIDs, + buildTransactionThreadParams, }); } diff --git a/src/libs/actions/IOU/TrackExpense.ts b/src/libs/actions/IOU/TrackExpense.ts index f27c828a0bb7..f8c1c6f07ea7 100644 --- a/src/libs/actions/IOU/TrackExpense.ts +++ b/src/libs/actions/IOU/TrackExpense.ts @@ -1649,6 +1649,7 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep isSelfTourViewed, betas, personalDetails, + introSelected, shouldDeferAutoSubmit, delegateAccountID, } = requestMoneyInformation; @@ -1914,6 +1915,13 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep } if (!requestMoneyInformation.isRetry) { + const buildTransactionThreadParams = { + currentUserLogin: currentUserEmailParam, + currentUserAccountID: currentUserAccountIDParam, + betas, + introSelected, + transaction, + }; if (shouldHandleNavigation) { const navigationReportID = backToReport ?? activeReportID; handleNavigateAfterExpenseCreate({ @@ -1926,6 +1934,7 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep // the chat preview card fresh-mounts on a chat destination's first expense, and the report table // fresh-mounts on the in-report 1→2 transition. shouldAddPendingNewTransactionIDs: isMoneyRequestReport || navigationReportID === chatReport.reportID, + buildTransactionThreadParams, }); } else if (shouldShowPostCreateFeedback) { // Navigation is owned by SubmitExpenseOrchestrator (dismiss-first paths). Surface feedback @@ -1936,6 +1945,7 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep transactionID: transaction.transactionID, transactionThreadReportID: transactionThreadReportID ?? iouAction?.childReportID, isMoneyRequestReport, + buildTransactionThreadParams, }); } } @@ -2875,6 +2885,13 @@ function trackExpense(params: CreateTrackExpenseParams) { } if (!params.isRetry) { + const buildTransactionThreadParams = { + currentUserLogin: currentUserEmailParam, + currentUserAccountID: currentUserAccountIDParam, + betas, + introSelected, + transaction, + }; if (shouldHandleNavigation) { handleNavigateAfterExpenseCreate({ activeReportID, @@ -2886,6 +2903,7 @@ function trackExpense(params: CreateTrackExpenseParams) { // already exists before the view mounts, so diff-based new-transaction detection misses // it - pending IDs are the fallback highlight path (see useNewTransactions). shouldAddPendingNewTransactionIDs: action === CONST.IOU.ACTION.CATEGORIZE || action === CONST.IOU.ACTION.SHARE, + buildTransactionThreadParams, }); } else if (shouldShowPostCreateFeedback) { // Navigation is owned by SubmitExpenseOrchestrator (dismiss-first paths). Surface feedback @@ -2895,6 +2913,7 @@ function trackExpense(params: CreateTrackExpenseParams) { transactionID: transaction?.transactionID, transactionThreadReportID: transactionThreadReportID ?? iouAction?.childReportID, isMoneyRequestReport, + buildTransactionThreadParams, }); } } diff --git a/src/pages/Share/SubmitDetailsPage.tsx b/src/pages/Share/SubmitDetailsPage.tsx index 4f2b934b544c..c732a778e0ff 100644 --- a/src/pages/Share/SubmitDetailsPage.tsx +++ b/src/pages/Share/SubmitDetailsPage.tsx @@ -368,6 +368,13 @@ function SubmitDetailsPage({ linkedTrackedExpenseReportAction: transaction.linkedTrackedExpenseReportAction, // Share drafts never set isFromGlobalCreate, but the user still deserves the "Expense added" growl. shouldAlwaysShowFeedback: true, + buildTransactionThreadParams: { + currentUserLogin: currentUserPersonalDetails.login ?? '', + currentUserAccountID: currentUserPersonalDetails.accountID, + betas, + introSelected, + transaction, + }, }); }; diff --git a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts index e97e506001b8..f13366b0ca4c 100644 --- a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts +++ b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts @@ -463,6 +463,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { isSelfTourViewed, betas, personalDetails, + introSelected, delegateAccountID, }); existingIOUReport = iouReport; From 6c817ed755e4691a7c397373cb2538dd1c68b396 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Wed, 15 Jul 2026 12:56:17 +0200 Subject: [PATCH 12/28] Fix after merge --- src/libs/ExportOnyxState/common.ts | 1 - src/libs/actions/IOU/SendInvoice.ts | 30 ++----------------- .../step/IOURequestStepCompanyInfo.tsx | 7 +++-- .../step/confirmation/useExpenseSubmission.ts | 16 ++++++++-- 4 files changed, 19 insertions(+), 35 deletions(-) diff --git a/src/libs/ExportOnyxState/common.ts b/src/libs/ExportOnyxState/common.ts index 36dd807d3927..a919e069d3a2 100644 --- a/src/libs/ExportOnyxState/common.ts +++ b/src/libs/ExportOnyxState/common.ts @@ -285,7 +285,6 @@ const safeOnyxKeys = new Set([ ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_FAILED, ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_PENDING, ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_SUCCESSFUL, - ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE, ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS, ONYXKEYS.TRAVEL_INVOICE_STATEMENT, ONYXKEYS.VALIDATE_DOMAIN_TWO_FACTOR_CODE, diff --git a/src/libs/actions/IOU/SendInvoice.ts b/src/libs/actions/IOU/SendInvoice.ts index 0c3f13c363d3..7066fed6bf3b 100644 --- a/src/libs/actions/IOU/SendInvoice.ts +++ b/src/libs/actions/IOU/SendInvoice.ts @@ -6,9 +6,6 @@ import {deferOrExecuteWrite} from '@libs/deferredLayoutWrite'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import {formatPhoneNumber} from '@libs/LocalePhoneNumber'; import Log from '@libs/Log'; -import isReportTopmostSplitNavigator from '@libs/Navigation/helpers/isReportTopmostSplitNavigator'; -import {surfaceExpenseCreatedFeedback} from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; -import TransitionTracker from '@libs/Navigation/TransitionTracker'; import {getReportActionHtml, getReportActionText} from '@libs/ReportActionsUtils'; import type {OptimisticChatReport, OptimisticCreatedReportAction, OptimisticIOUReportAction} from '@libs/ReportUtils'; import { @@ -43,7 +40,6 @@ import type BasePolicyParams from './types/BasePolicyParams'; import {getAllPersonalDetails} from '.'; import {getReceiptError, mergePolicyRecentlyUsedCategories, mergePolicyRecentlyUsedCurrencies} from './MoneyRequestBuilder'; -import {handleNavigateAfterExpenseCreate} from './NavigationHelpers'; import {getSearchOnyxUpdate} from './SearchUpdate'; type SendInvoiceInformation = { @@ -86,7 +82,6 @@ type SendInvoiceOptions = { companyWebsite?: string; policyRecentlyUsedCategories?: OnyxEntry; policyRecentlyUsedTags?: OnyxEntry; - isFromGlobalCreate?: boolean; senderPolicyTags: OnyxEntry; // TODO: delegateAccountID will be made required in PR 12 when all callers pass the value (https://github.com/Expensify/App/issues/66425) delegateAccountID?: number | undefined; @@ -740,7 +735,6 @@ function sendInvoice({ companyWebsite, policyRecentlyUsedCategories, policyRecentlyUsedTags, - isFromGlobalCreate = false, senderPolicyTags, delegateAccountID, }: SendInvoiceOptions) { @@ -816,29 +810,9 @@ function sendInvoice({ onDeferred: () => addOptimization(CONST.TELEMETRY.SUBMIT_OPTIMIZATION.DEFERRED_WRITE), }); - if (shouldHandleNavigation) { - TransitionTracker.runAfterTransitions({callback: () => removeDraftTransaction(CONST.IOU.OPTIMISTIC_TRANSACTION_ID), waitForUpcomingTransition: true}); - handleNavigateAfterExpenseCreate({ - activeReportID: invoiceRoom.reportID, - iouReportID: invoiceReportID, - transactionID, - transactionThreadReportID, - isFromGlobalCreate, - isInvoice: true, - }); - } else { - // Dismiss-first paths (orchestrator owns navigation); still surface feedback wherever the user - // lands. Invoices go to an invoice room (no expense-report table), so this resolves to the growl. - surfaceExpenseCreatedFeedback({ - iouReportID: invoiceReportID, - transactionID, - transactionThreadReportID, - isInvoice: true, - }); - removeDraftTransaction(CONST.IOU.OPTIMISTIC_TRANSACTION_ID); - } - notifyNewAction(invoiceRoom.reportID, undefined, true); + + return {invoiceReportID, transactionID, transactionThreadReportID}; } export {getReceiverType, getSendInvoiceInformation, sendInvoice}; diff --git a/src/pages/iou/request/step/IOURequestStepCompanyInfo.tsx b/src/pages/iou/request/step/IOURequestStepCompanyInfo.tsx index 42763de1717e..339026b1fe23 100644 --- a/src/pages/iou/request/step/IOURequestStepCompanyInfo.tsx +++ b/src/pages/iou/request/step/IOURequestStepCompanyInfo.tsx @@ -105,7 +105,7 @@ function IOURequestStepCompanyInfo({route, report, transaction}: IOURequestStepC ); reserveSearchChannelIfGlobalCreate(!!isFromGlobalCreate); const invoiceChatReportID = report?.reportID ? undefined : reportID; - sendInvoice({ + const invoiceResult = sendInvoice({ currentUserAccountID: currentUserPersonalDetails.accountID, transaction, policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], @@ -118,14 +118,15 @@ function IOURequestStepCompanyInfo({route, report, transaction}: IOURequestStepC companyWebsite, policyRecentlyUsedCategories, policyRecentlyUsedTags, - isFromGlobalCreate, senderPolicyTags: policyTags ?? {}, }); cleanupAndNavigateAfterExpenseCreate({ report: undefined, action: CONST.IOU.ACTION.CREATE, draftTransactionIDs, - transactionID: transaction?.transactionID, + transactionID: invoiceResult?.transactionID ?? transaction?.transactionID, + iouReportID: invoiceResult?.invoiceReportID, + transactionThreadReportID: invoiceResult?.transactionThreadReportID, isFromGlobalCreate, optimisticChatReportID: report?.reportID ?? reportID, isInvoice: true, diff --git a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts index 27eb203396ef..b80a41b004a6 100644 --- a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts +++ b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts @@ -21,6 +21,7 @@ import {getStringifiedGPSCoordinates} from '@libs/GPSDraftDetailsUtils'; import {getExistingTransactionID, isSelfDMSoleDestination, resolveOptimisticChatReportID} from '@libs/IOUUtils'; import Log from '@libs/Log'; import cleanupAfterExpenseCreate from '@libs/Navigation/helpers/cleanupAfterExpenseCreate'; +import cleanupAndNavigateAfterExpenseCreate from '@libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate'; import dismissModalAndOpenReportInInboxTabHelper from '@libs/Navigation/helpers/dismissModalAndOpenReportInInboxTab'; import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; import navigateAfterExpenseCreate, {surfaceExpenseCreatedFeedback} from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; @@ -932,7 +933,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { const invoiceChatReport = !isEmptyObject(report) && report?.reportID ? report : existingInvoiceReport; const invoiceChatReportID = invoiceChatReport ? undefined : reportID; - sendInvoice({ + const invoiceResult = sendInvoice({ currentUserAccountID: currentUserPersonalDetails.accountID, transaction, policyRecentlyUsedCurrencies, @@ -943,7 +944,6 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { policyTagList: policyTags, policyCategories, policyRecentlyUsedCategories, - isFromGlobalCreate: getIsFromGlobalCreate(transaction), policyRecentlyUsedTags, senderPolicyTags: senderWorkspacePolicyTags ?? {}, }); @@ -952,13 +952,23 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { report: undefined, action, draftTransactionIDs, - transactionID: transaction?.transactionID, + transactionID: invoiceResult?.transactionID ?? transaction?.transactionID, + iouReportID: invoiceResult?.invoiceReportID, + transactionThreadReportID: invoiceResult?.transactionThreadReportID, isFromGlobalCreate: getIsFromGlobalCreate(transaction), optimisticChatReportID: invoiceChatReport?.reportID ?? invoiceChatReportID, isInvoice: true, }); } else { cleanupAfterExpenseCreate({draftTransactionIDs}); + // Dismiss-first paths (orchestrator owns navigation); still surface the growl wherever the + // user lands. Invoices go to an invoice room (no expense-report table), so this resolves to the growl. + surfaceExpenseCreatedFeedback({ + iouReportID: invoiceResult?.invoiceReportID, + transactionID: invoiceResult?.transactionID, + transactionThreadReportID: invoiceResult?.transactionThreadReportID, + isInvoice: true, + }); } markSubmitExpenseEnd(); return; From 56c98aefbc2b6bc79b3c934a3f56918954e8b780 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Wed, 15 Jul 2026 15:03:11 +0200 Subject: [PATCH 13/28] Move growl invocation away from actions and into UI --- src/libs/IOUAmountSubmission.ts | 49 ++++-- src/libs/actions/IOU/MoneyRequest.ts | 22 +-- src/libs/actions/IOU/MoneyRequestBuilder.ts | 18 --- src/libs/actions/IOU/TrackExpense.ts | 86 +---------- .../IOU/types/CreateTrackExpenseParams.ts | 13 -- src/pages/Share/SubmitDetailsPage.tsx | 8 - ...andleMoneyRequestStepDistanceNavigation.ts | 46 +++++- .../components/ScanSkipConfirmation.tsx | 72 ++++++--- .../step/confirmation/useExpenseSubmission.ts | 145 +++++++++++++----- .../iou/request/step/resolveChatTarget.ts | 54 ++++++- tests/unit/hooks/useExpenseSubmission.test.ts | 39 ++--- 11 files changed, 316 insertions(+), 236 deletions(-) diff --git a/src/libs/IOUAmountSubmission.ts b/src/libs/IOUAmountSubmission.ts index a2a8fa468a64..88e31633052b 100644 --- a/src/libs/IOUAmountSubmission.ts +++ b/src/libs/IOUAmountSubmission.ts @@ -39,6 +39,8 @@ import { resolveOptimisticChatReportID, } from './IOUUtils'; import cleanupAfterExpenseCreate from './Navigation/helpers/cleanupAfterExpenseCreate'; +import cleanupAndNavigateAfterExpenseCreate from './Navigation/helpers/cleanupAndNavigateAfterExpenseCreate'; +import {surfaceExpenseCreatedFeedback} from './Navigation/helpers/navigateAfterExpenseCreate'; import {submitWithDismissFirst} from './Navigation/helpers/submitWithDismissFirst'; import Navigation from './Navigation/Navigation'; import {rand64} from './NumberUtils'; @@ -306,8 +308,9 @@ function submitSkipConfirmationExpense(args: SubmitAmountArgs, ctx: SubmitAmount const draftTransactionIDsList = Object.keys(transactionDrafts ?? {}); const isSelfTourViewed = hasSeenTourSelector(onboarding) ?? false; const executeExpenseWrite = (overrides: WriteOverrides) => { + let result: {iouReport?: OnyxEntry; transactionID?: string; transactionThreadReportID?: string} | undefined; if (isTrackExpenseSubmit) { - trackExpense({ + result = trackExpense({ report, isDraftPolicy: false, isDraftChatReport: !!isDraftChatReport, @@ -335,13 +338,12 @@ function submitSkipConfirmationExpense(args: SubmitAmountArgs, ctx: SubmitAmount isSelfTourViewed, optimisticChatReportID, optimisticTransactionID, - shouldHandleNavigation: overrides.shouldHandleNavigation, delegateAccountID, reportActionsList: undefined, }); } else { const existingTransactionDraft = existingTransactionID ? transactionDrafts?.[existingTransactionID] : undefined; - requestMoney({ + result = requestMoney({ report, betas, participantParams: { @@ -373,16 +375,45 @@ function submitSkipConfirmationExpense(args: SubmitAmountArgs, ctx: SubmitAmount personalDetails: allPersonalDetails, optimisticChatReportID, optimisticTransactionID, - shouldHandleNavigation: overrides.shouldHandleNavigation, - backToReport, delegateAccountID, isTrackIntentUser, }); } - cleanupAfterExpenseCreate({ - draftTransactionIDs: draftTransactionIDsList, - linkedTrackedExpenseReportAction: transaction?.linkedTrackedExpenseReportAction, - }); + + const buildTransactionThreadParams = { + currentUserLogin: currentUserEmail, + currentUserAccountID, + betas, + introSelected, + transaction, + }; + if (overrides.shouldHandleNavigation) { + cleanupAndNavigateAfterExpenseCreate({ + report: isMoneyRequestReport(report) ? report : undefined, + action: CONST.IOU.ACTION.CREATE, + draftTransactionIDs: draftTransactionIDsList, + transactionID: result?.transactionID, + iouReportID: result?.iouReport?.reportID, + transactionThreadReportID: result?.transactionThreadReportID, + isFromGlobalCreate: getIsFromGlobalCreate(transaction), + backToReport, + optimisticChatReportID, + linkedTrackedExpenseReportAction: transaction?.linkedTrackedExpenseReportAction, + buildTransactionThreadParams, + }); + } else { + cleanupAfterExpenseCreate({ + draftTransactionIDs: draftTransactionIDsList, + linkedTrackedExpenseReportAction: transaction?.linkedTrackedExpenseReportAction, + }); + surfaceExpenseCreatedFeedback({ + iouReportID: result?.iouReport?.reportID, + transactionID: result?.transactionID, + transactionThreadReportID: result?.transactionThreadReportID, + isMoneyRequestReport: isMoneyRequestReport(report), + buildTransactionThreadParams, + }); + } }; submitWithDismissFirst({ executeWrite: executeExpenseWrite, diff --git a/src/libs/actions/IOU/MoneyRequest.ts b/src/libs/actions/IOU/MoneyRequest.ts index 76eca5dc25d1..7d4c27b93a1a 100644 --- a/src/libs/actions/IOU/MoneyRequest.ts +++ b/src/libs/actions/IOU/MoneyRequest.ts @@ -99,10 +99,6 @@ type CreateTransactionParams = { optimisticChatReportID: string | undefined; currentUserLocalCurrency: string | undefined; isTrackIntentUser: boolean | undefined; - /** Whether the created action should own post-creation navigation. Skip-confirm orchestrators that dismiss/reveal first pass `false` so navigation isn't run twice. */ - shouldHandleNavigation?: boolean; - /** Report the flow started from; post-create navigation returns there instead of the written-to report. */ - backToReport?: string; delegateAccountID: number | undefined; }; @@ -133,12 +129,11 @@ function createTransaction({ optimisticChatReportID, currentUserLocalCurrency, isTrackIntentUser, - shouldHandleNavigation = true, - backToReport, delegateAccountID, }: CreateTransactionParams) { const draftTransactionIDs = Object.keys(allTransactionDrafts ?? {}); + let lastResult: {iouReport?: OnyxEntry; transactionID?: string; transactionThreadReportID?: string} | undefined; for (const [index, receiptFile] of files.entries()) { const transaction = transactions.find((item) => item.transactionID === receiptFile.transactionID); const receipt: Receipt = receiptFile.file ?? {}; @@ -158,7 +153,7 @@ function createTransaction({ iouType, }); if (iouType === CONST.IOU.TYPE.TRACK && report) { - trackExpense({ + lastResult = trackExpense({ report, isDraftPolicy: false, existingTransaction: transaction, @@ -194,9 +189,6 @@ function createTransaction({ optimisticChatReportID, optimisticTransactionID, currentUserLocalCurrency, - // Navigation/growl must fire once per multi-receipt batch, on its final transaction. - shouldHandleNavigation: shouldHandleNavigation && index === files.length - 1, - shouldShowPostCreateFeedback: index === files.length - 1, delegateAccountID, reportActionsList: undefined, }); @@ -204,7 +196,7 @@ function createTransaction({ const existingTransactionID = getExistingTransactionID(transaction?.linkedTrackedExpenseReportAction); const existingTransactionDraft = existingTransactionID ? allTransactionDrafts?.[existingTransactionID] : undefined; - requestMoney({ + lastResult = requestMoney({ report, betas, participantParams: { @@ -239,19 +231,15 @@ function createTransaction({ existingTransaction: transaction, isSelfTourViewed, personalDetails, - introSelected, optimisticChatReportID, optimisticTransactionID, isTrackIntentUser, - // Navigation/growl must fire once per multi-receipt batch, on its final transaction: silence - // every non-final write so the action surfaces feedback only for the last one. - shouldHandleNavigation: shouldHandleNavigation && index === files.length - 1, - shouldShowPostCreateFeedback: index === files.length - 1, - backToReport, delegateAccountID, }); } } + + return lastResult; } function getMoneyRequestParticipantOptions( diff --git a/src/libs/actions/IOU/MoneyRequestBuilder.ts b/src/libs/actions/IOU/MoneyRequestBuilder.ts index 323b35e72c16..cb0d841ee694 100644 --- a/src/libs/actions/IOU/MoneyRequestBuilder.ts +++ b/src/libs/actions/IOU/MoneyRequestBuilder.ts @@ -168,22 +168,6 @@ type RequestMoneyInformation = { transactionParams: RequestMoneyTransactionParams; isRetry?: boolean; shouldPlaySound?: boolean; - /** - * Whether the action owns the post-create flow: dismiss the money request screens, navigate to the - * destination and surface the "Expense added" feedback. Defaults to true. - */ - shouldHandleNavigation?: boolean; - /** - * Only read when shouldHandleNavigation is false. Defaults to true: navigation is owned by the - * orchestrator (dismiss-first paths), which has already navigated, so the action surfaces the - * post-create feedback (growl/row highlight). Pass false when the caller owns the whole - * post-create flow - navigation AND feedback - and the action must stay fully silent - * (e.g. the Share flow, which navigates after the action returns, or a non-final write in a - * multi-transaction batch whose feedback must fire only once). - */ - shouldShowPostCreateFeedback?: boolean; - /** Report the money request flow started from; post-create navigation returns there instead of the written-to chat. */ - backToReport?: string; /** Retry-path cleanup only; the action itself never reads this. */ draftTransactionIDs?: string[]; optimisticChatReportID?: string; @@ -203,8 +187,6 @@ type RequestMoneyInformation = { isSelfTourViewed: boolean; betas: OnyxEntry; personalDetails: OnyxEntry; - /** Onboarding intro selection. */ - introSelected?: OnyxEntry; shouldDeferAutoSubmit?: boolean; delegateAccountID: number | undefined; isTrackIntentUser: boolean | undefined; diff --git a/src/libs/actions/IOU/TrackExpense.ts b/src/libs/actions/IOU/TrackExpense.ts index e92ec868419a..77a5d2704772 100644 --- a/src/libs/actions/IOU/TrackExpense.ts +++ b/src/libs/actions/IOU/TrackExpense.ts @@ -13,7 +13,6 @@ import {isMovingTransactionFromTrackExpense as isMovingTransactionFromTrackExpen import isFileUploadable from '@libs/isFileUploadable'; import {formatPhoneNumber} from '@libs/LocalePhoneNumber'; import Log from '@libs/Log'; -import {surfaceExpenseCreatedFeedback} from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; import Navigation from '@libs/Navigation/Navigation'; import {roundToTwoDecimalPlaces} from '@libs/NumberUtils'; import * as NumberUtils from '@libs/NumberUtils'; @@ -120,7 +119,6 @@ import type { import {deleteMoneyRequest, getCleanUpTransactionThreadReportOnyxData, getNavigationUrlOnMoneyRequestDelete} from './DeleteMoneyRequest'; import {getAllReports, getAllTransactionDrafts, getAllTransactions, getAllTransactionViolations} from './index'; import {buildMinimalTransactionForFormula, getMoneyRequestInformation, getReceiptError, getReportPreviewAction, getTransactionWithPreservedLocalReceiptSource} from './MoneyRequestBuilder'; -import {handleNavigateAfterExpenseCreate} from './NavigationHelpers'; import {getSearchOnyxUpdate} from './SearchUpdate'; type TrackExpenseInformation = { @@ -1620,7 +1618,7 @@ function convertTrackedExpenseToRequest(convertTrackedExpenseParams: ConvertTrac /** * Submit expense to another user */ -function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouReport?: OnyxTypes.Report} { +function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouReport?: OnyxTypes.Report; transactionID?: string; transactionThreadReportID?: string} { const { report, existingIOUReport, @@ -1630,9 +1628,6 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep gpsPoint, action, shouldPlaySound = true, - shouldHandleNavigation = true, - shouldShowPostCreateFeedback = true, - backToReport, optimisticChatReportID, optimisticCreatedReportActionID, optimisticIOUReportID, @@ -1649,7 +1644,6 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep isSelfTourViewed, betas, personalDetails, - introSelected, shouldDeferAutoSubmit, delegateAccountID, isTrackIntentUser, @@ -1683,7 +1677,6 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep count, rate, unit, - isFromGlobalCreate = false, } = transactionParams; const testDriveCommentReportActionID = isTestDrive ? NumberUtils.rand64() : undefined; @@ -1916,42 +1909,6 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep }); } - if (!requestMoneyInformation.isRetry) { - const buildTransactionThreadParams = { - currentUserLogin: currentUserEmailParam, - currentUserAccountID: currentUserAccountIDParam, - betas, - introSelected, - transaction, - }; - if (shouldHandleNavigation) { - const navigationReportID = backToReport ?? activeReportID; - handleNavigateAfterExpenseCreate({ - activeReportID: navigationReportID, - iouReportID: iouReport?.reportID, - transactionID: transaction.transactionID, - transactionThreadReportID: transactionThreadReportID ?? iouAction?.childReportID, - isFromGlobalCreate, - // Pending IDs are the fallback highlight when diff detection can't run (see useNewTransactions): - // the chat preview card fresh-mounts on a chat destination's first expense, and the report table - // fresh-mounts on the in-report 1→2 transition. - shouldAddPendingNewTransactionIDs: isMoneyRequestReport || navigationReportID === chatReport.reportID, - buildTransactionThreadParams, - }); - } else if (shouldShowPostCreateFeedback) { - // Navigation is owned by SubmitExpenseOrchestrator (dismiss-first paths). Surface feedback - // wherever the user lands: highlight the new row for in-report adds, otherwise the - // "Expense added" growl with a "View" deep link. - surfaceExpenseCreatedFeedback({ - iouReportID: iouReport?.reportID, - transactionID: transaction.transactionID, - transactionThreadReportID: transactionThreadReportID ?? iouAction?.childReportID, - isMoneyRequestReport, - buildTransactionThreadParams, - }); - } - } - if (activeReportID && !isMoneyRequestReport) { Navigation.setNavigationActionToMicrotaskQueue(() => setTimeout(() => { @@ -1960,7 +1917,7 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep ); } - return {iouReport}; + return {iouReport, transactionID: transaction.transactionID, transactionThreadReportID: transactionThreadReportID ?? iouAction?.childReportID}; } /** @@ -2430,8 +2387,6 @@ function trackExpense(params: CreateTrackExpenseParams) { existingTransaction, transactionParams: transactionData, accountantParams, - shouldHandleNavigation = true, - shouldShowPostCreateFeedback = true, shouldPlaySound = true, optimisticChatReportID, optimisticTransactionID, @@ -2480,7 +2435,6 @@ function trackExpense(params: CreateTrackExpenseParams) { attendees, odometerStart, odometerEnd, - isFromGlobalCreate = false, gpsCoordinates, distanceRequestType, } = transactionData; @@ -2889,41 +2843,9 @@ function trackExpense(params: CreateTrackExpenseParams) { } } - if (!params.isRetry) { - const buildTransactionThreadParams = { - currentUserLogin: currentUserEmailParam, - currentUserAccountID: currentUserAccountIDParam, - betas, - introSelected, - transaction, - }; - if (shouldHandleNavigation) { - handleNavigateAfterExpenseCreate({ - activeReportID, - iouReportID: iouReport?.reportID, - transactionID: transaction?.transactionID, - transactionThreadReportID: transactionThreadReportID ?? iouAction?.childReportID, - isFromGlobalCreate, - // Moving a tracked expense (CATEGORIZE/SHARE) lands on a report where the transaction - // already exists before the view mounts, so diff-based new-transaction detection misses - // it - pending IDs are the fallback highlight path (see useNewTransactions). - shouldAddPendingNewTransactionIDs: action === CONST.IOU.ACTION.CATEGORIZE || action === CONST.IOU.ACTION.SHARE, - buildTransactionThreadParams, - }); - } else if (shouldShowPostCreateFeedback) { - // Navigation is owned by SubmitExpenseOrchestrator (dismiss-first paths). Surface feedback - // wherever the user lands: highlight the new row for in-report adds, otherwise the growl. - surfaceExpenseCreatedFeedback({ - iouReportID: iouReport?.reportID, - transactionID: transaction?.transactionID, - transactionThreadReportID: transactionThreadReportID ?? iouAction?.childReportID, - isMoneyRequestReport, - buildTransactionThreadParams, - }); - } - } - notifyNewAction(activeReportID, undefined, payeeAccountID === currentUserAccountIDParam); + + return {iouReport, transactionID: transaction?.transactionID, transactionThreadReportID: transactionThreadReportID ?? iouAction?.childReportID}; } /** diff --git a/src/libs/actions/IOU/types/CreateTrackExpenseParams.ts b/src/libs/actions/IOU/types/CreateTrackExpenseParams.ts index 7b8430b2e212..aa8674e278d0 100644 --- a/src/libs/actions/IOU/types/CreateTrackExpenseParams.ts +++ b/src/libs/actions/IOU/types/CreateTrackExpenseParams.ts @@ -25,19 +25,6 @@ type CreateTrackExpenseParams = { accountantParams?: TrackExpenseAccountantParams; isRetry?: boolean; shouldPlaySound?: boolean; - /** - * Whether the action owns the post-create flow: dismiss the money request screens, navigate to the - * destination and surface the "Expense added" feedback. Defaults to true. - */ - shouldHandleNavigation?: boolean; - /** - * Only read when shouldHandleNavigation is false. Defaults to true: navigation is owned by the - * orchestrator (dismiss-first paths), which has already navigated, so the action surfaces the - * post-create feedback (growl/row highlight). Pass false when the caller owns the whole - * post-create flow - navigation AND feedback - and the action must stay fully silent - * (e.g. the Share flow, which navigates after the action returns). - */ - shouldShowPostCreateFeedback?: boolean; /** Retry-path cleanup only; the action itself never reads this. */ draftTransactionIDs?: string[]; optimisticChatReportID?: string; diff --git a/src/pages/Share/SubmitDetailsPage.tsx b/src/pages/Share/SubmitDetailsPage.tsx index 7f8b71852633..9cf929b95de8 100644 --- a/src/pages/Share/SubmitDetailsPage.tsx +++ b/src/pages/Share/SubmitDetailsPage.tsx @@ -312,10 +312,6 @@ function SubmitDetailsPage({ isSelfTourViewed, optimisticTransactionID, currentUserLocalCurrency: currentUserPersonalDetails.localCurrencyCode ?? CONST.CURRENCY.USD, - // The whole post-create flow (navigation + feedback) is owned by cleanupAndNavigateAfterExpenseCreate below, - // which runs after this action returns - keep the action fully silent. - shouldHandleNavigation: false, - shouldShowPostCreateFeedback: false, delegateAccountID, reportActionsList: undefined, }); @@ -363,10 +359,6 @@ function SubmitDetailsPage({ personalDetails, optimisticTransactionID, isTrackIntentUser, - // The whole post-create flow (navigation + feedback) is owned by cleanupAndNavigateAfterExpenseCreate below, - // which runs after this action returns - keep the action fully silent. - shouldHandleNavigation: false, - shouldShowPostCreateFeedback: false, delegateAccountID, }); iouReportID = iouReport?.reportID; diff --git a/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts b/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts index bd8e9c9b1925..e8b011614d23 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts +++ b/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts @@ -13,10 +13,12 @@ import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import {calculateDefaultReimbursable, navigateToConfirmationPage, navigateToParticipantPage} from '@libs/IOUUtils'; import {toLocaleDigit} from '@libs/LocaleDigitUtils'; import cleanupAfterExpenseCreate from '@libs/Navigation/helpers/cleanupAfterExpenseCreate'; +import cleanupAndNavigateAfterExpenseCreate from '@libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate'; +import {surfaceExpenseCreatedFeedback} from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; import {submitWithDismissFirst} from '@libs/Navigation/helpers/submitWithDismissFirst'; import Navigation from '@libs/Navigation/Navigation'; import {roundToTwoDecimalPlaces} from '@libs/NumberUtils'; -import {getPolicyExpenseChat, isSelfDM} from '@libs/ReportUtils'; +import {getPolicyExpenseChat, isMoneyRequestReport, isSelfDM} from '@libs/ReportUtils'; import shouldUseDefaultExpensePolicy from '@libs/shouldUseDefaultExpensePolicy'; import {cancelSpan} from '@libs/telemetry/activeSpans'; import {getDefaultTaxCode, getDistanceRequestType, getIsFromGlobalCreate, getValidWaypoints} from '@libs/TransactionUtils'; @@ -273,9 +275,8 @@ function handleMoneyRequestStepDistanceNavigation({ if (isCreatingTrackExpense && participant) { submitWithDismissFirst({ - // submitWithDismissFirst owns dismiss/reveal; pass its shouldHandleNavigation through so trackExpense doesn't also navigate. executeWrite: (overrides) => { - trackExpense({ + const result = trackExpense({ report, isDraftPolicy: false, existingTransaction: transaction, @@ -325,14 +326,43 @@ function handleMoneyRequestStepDistanceNavigation({ optimisticTransactionID, optimisticChatReportID, currentUserLocalCurrency, - shouldHandleNavigation: overrides.shouldHandleNavigation, delegateAccountID, reportActionsList: undefined, }); - cleanupAfterExpenseCreate({ - draftTransactionIDs, - linkedTrackedExpenseReportAction: transactionLinkedTrackedExpenseReportAction, - }); + + const buildTransactionThreadParams = { + currentUserLogin: currentUserLogin ?? '', + currentUserAccountID, + betas, + introSelected, + transaction, + }; + if (overrides.shouldHandleNavigation) { + cleanupAndNavigateAfterExpenseCreate({ + report: isMoneyRequestReport(report) ? report : undefined, + action: CONST.IOU.ACTION.CREATE, + draftTransactionIDs, + transactionID: result?.transactionID, + iouReportID: result?.iouReport?.reportID, + transactionThreadReportID: result?.transactionThreadReportID, + isFromGlobalCreate: getIsFromGlobalCreate(transaction), + optimisticChatReportID, + linkedTrackedExpenseReportAction: transactionLinkedTrackedExpenseReportAction, + buildTransactionThreadParams, + }); + } else { + cleanupAfterExpenseCreate({ + draftTransactionIDs, + linkedTrackedExpenseReportAction: transactionLinkedTrackedExpenseReportAction, + }); + surfaceExpenseCreatedFeedback({ + iouReportID: result?.iouReport?.reportID, + transactionID: result?.transactionID, + transactionThreadReportID: result?.transactionThreadReportID, + isMoneyRequestReport: isMoneyRequestReport(report), + buildTransactionThreadParams, + }); + } }, destinationReportID: report?.reportID ?? selfDMReport?.reportID, telemetryContext: { diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx index 522b8fbd1823..76cb463cdfd9 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx @@ -24,6 +24,8 @@ import getCurrentPosition from '@libs/getCurrentPosition'; import {calculateDefaultReimbursable} from '@libs/IOUUtils'; import Log from '@libs/Log'; import cleanupAfterExpenseCreate from '@libs/Navigation/helpers/cleanupAfterExpenseCreate'; +import cleanupAndNavigateAfterExpenseCreate from '@libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate'; +import {surfaceExpenseCreatedFeedback} from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; import {submitWithDismissFirst} from '@libs/Navigation/helpers/submitWithDismissFirst'; import {rand64} from '@libs/NumberUtils'; import {isTrackOnboardingChoice} from '@libs/OnboardingUtils'; @@ -31,14 +33,14 @@ import {isMoneyRequestReport as isMoneyRequestReportReportUtils} from '@libs/Rep import {cancelSpan} from '@libs/telemetry/activeSpans'; import type {ReceiptCaptureSource} from '@libs/telemetry/ReceiptObservability'; import {getPickerCaptureSource} from '@libs/telemetry/ReceiptObservability'; -import {getDefaultTaxCode, getTaxValue} from '@libs/TransactionUtils'; +import {getDefaultTaxCode, getIsFromGlobalCreate, getTaxValue} from '@libs/TransactionUtils'; import {getLocationPermission} from '@pages/iou/request/step/IOURequestStepScan/LocationPermission'; import type {ReceiptFile} from '@pages/iou/request/step/IOURequestStepScan/types'; import buildReceiptFiles from '@pages/iou/request/step/IOURequestStepScan/utils/buildReceiptFiles'; import getFileSource from '@pages/iou/request/step/IOURequestStepScan/utils/getFileSource'; import useScanFileReadabilityCheck from '@pages/iou/request/step/IOURequestStepScan/utils/useScanFileReadabilityCheck'; -import resolveChatTargetForScan from '@pages/iou/request/step/resolveChatTarget'; +import {resolveChatTargetForScan} from '@pages/iou/request/step/resolveChatTarget'; import CONST from '@src/CONST'; import type {IOUType} from '@src/CONST'; @@ -269,7 +271,6 @@ function ScanSkipConfirmation({report, iouType, reportID, transactionID, transac report, currentUserAccountID: currentUserPersonalDetails.accountID, currentUserEmail: currentUserPersonalDetails.login, - backToReport, shouldGenerateTransactionThreadReport: false, isASAPSubmitBetaEnabled, transactionViolations, @@ -297,37 +298,64 @@ function ScanSkipConfirmation({report, iouType, reportID, transactionID, transac const scanDestinationReportID = iouType === CONST.IOU.TYPE.TRACK ? (report?.reportID ?? selfDMReport?.reportID) : report?.reportID; submitWithDismissFirst({ executeWrite: (overrides) => { - // Cleanup runs after each write (not once up front) so a stalled GPS lookup can't clear the draft before the expense exists. - const runCleanup = () => - cleanupAfterExpenseCreate({ - draftTransactionIDs, - linkedTrackedExpenseReportAction, - }); + // Runs after each write (not once up front) so a stalled GPS lookup can't clear the draft before the expense exists. + const finish = (result: {iouReport?: OnyxEntry; transactionID?: string; transactionThreadReportID?: string} | undefined) => { + const lastTransaction = transactions.at(-1); + const buildTransactionThreadParams = { + currentUserLogin: currentUserPersonalDetails.login ?? '', + currentUserAccountID: currentUserPersonalDetails.accountID, + betas, + introSelected, + transaction: lastTransaction, + }; + if (overrides.shouldHandleNavigation) { + cleanupAndNavigateAfterExpenseCreate({ + report: isMoneyRequestReportReportUtils(report) ? report : undefined, + action: CONST.IOU.ACTION.CREATE, + draftTransactionIDs, + transactionID: result?.transactionID, + iouReportID: result?.iouReport?.reportID, + transactionThreadReportID: result?.transactionThreadReportID, + isFromGlobalCreate: getIsFromGlobalCreate(lastTransaction), + backToReport, + optimisticChatReportID, + linkedTrackedExpenseReportAction, + buildTransactionThreadParams, + }); + } else { + cleanupAfterExpenseCreate({draftTransactionIDs, linkedTrackedExpenseReportAction}); + surfaceExpenseCreatedFeedback({ + iouReportID: result?.iouReport?.reportID, + transactionID: result?.transactionID, + transactionThreadReportID: result?.transactionThreadReportID, + isMoneyRequestReport: isMoneyRequestReportReportUtils(report), + buildTransactionThreadParams, + }); + } + }; if (locationPermissionGranted) { getCurrentPosition( (successData) => { - createTransaction({ - ...baseParams, - gpsPoint: { - lat: successData.coords.latitude, - long: successData.coords.longitude, - }, - shouldHandleNavigation: overrides.shouldHandleNavigation, - }); - runCleanup(); + finish( + createTransaction({ + ...baseParams, + gpsPoint: { + lat: successData.coords.latitude, + long: successData.coords.longitude, + }, + }), + ); }, (errorData) => { Log.info('[ScanSkipConfirmation] getCurrentPosition failed', false, errorData); // When there is an error, the money can still be requested, it just won't include the GPS coordinates - createTransaction({...baseParams, shouldHandleNavigation: overrides.shouldHandleNavigation}); - runCleanup(); + finish(createTransaction(baseParams)); }, ); return; } - createTransaction({...baseParams, shouldHandleNavigation: overrides.shouldHandleNavigation}); - runCleanup(); + finish(createTransaction(baseParams)); }, destinationReportID: scanDestinationReportID, telemetryContext: { diff --git a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts index b80a41b004a6..52233447994f 100644 --- a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts +++ b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts @@ -50,6 +50,8 @@ import { isManualDistanceRequest as isManualDistanceRequestTransactionUtils, } from '@libs/TransactionUtils'; +import {resolveChatTargetForSubmitCleanup} from '@pages/iou/request/step/resolveChatTarget'; + import {isOneToTwoTransactionTransition} from '@userActions/IOU/PendingNewTransactions'; import {submitPerDiemExpenseForSelfDM, submitPerDiemExpense as submitPerDiemExpenseIOUActions} from '@userActions/IOU/PerDiem'; import {getReceiverType, sendInvoice} from '@userActions/IOU/SendInvoice'; @@ -308,17 +310,74 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { const transactionIDs = transactions?.map((tx) => tx.transactionID); const [storedTransactions] = useTransactionsByID(transactionIDs); - function performPostBatchCleanup({allTransactionsCreated}: {allTransactionsCreated: boolean}) { + function performPostBatchCleanup({ + participant, + shouldHandleNavigation, + allTransactionsCreated, + fallbackOptimisticChatReportID, + navigateBackToReport, + lastOptimisticTransactionID, + preResolvedChatTarget, + iouReportID, + transactionThreadReportID, + }: { + participant: Participant; + shouldHandleNavigation: boolean; + allTransactionsCreated: boolean; + fallbackOptimisticChatReportID: string; + navigateBackToReport: string | undefined; + lastOptimisticTransactionID: string | undefined; + preResolvedChatTarget?: {report: OnyxEntry; chatReportID: string}; + iouReportID?: string; + transactionThreadReportID?: string; + }) { const lastTransaction = transactions.at(-1); // Action bailed mid-batch — keep drafts for retry. if (!allTransactionsCreated) { return; } - // Navigation + the "Expense added" growl are owned by the IOU action itself (requestMoney/trackExpense - // call handleNavigateAfterExpenseCreate / showExpenseAddedGrowl internally). Doing it here as well fired - // the growl twice and ran navigation twice on the shouldHandleNavigation=true paths, so this only performs - // the non-navigation cleanup now. - cleanupAfterExpenseCreate({draftTransactionIDs, linkedTrackedExpenseReportAction: lastTransaction?.linkedTrackedExpenseReportAction}); + // Move-from-track (SUBMIT/CATEGORIZE/SHARE) reuses the tracked transaction's ID — mirror the builder's `existingTransactionID ?? optimisticTransactionID`. + const lastTransactionID = getExistingTransactionID(lastTransaction?.linkedTrackedExpenseReportAction) ?? lastOptimisticTransactionID; + const buildTransactionThreadParams = { + currentUserLogin: currentUserPersonalDetails.login ?? '', + currentUserAccountID: currentUserPersonalDetails.accountID, + betas, + introSelected, + transaction: lastTransaction, + }; + if (!shouldHandleNavigation) { + cleanupAfterExpenseCreate({draftTransactionIDs, linkedTrackedExpenseReportAction: lastTransaction?.linkedTrackedExpenseReportAction}); + surfaceExpenseCreatedFeedback({ + iouReportID, + transactionID: lastTransactionID, + transactionThreadReportID, + isMoneyRequestReport, + buildTransactionThreadParams, + }); + return; + } + const {report: resolvedReport, chatReportID} = + preResolvedChatTarget ?? + resolveChatTargetForSubmitCleanup({ + participant, + currentUserAccountID: currentUserPersonalDetails.accountID, + report, + fallbackOptimisticChatReportID, + action, + }); + cleanupAndNavigateAfterExpenseCreate({ + report: resolvedReport, + action, + draftTransactionIDs, + transactionID: lastTransactionID, + iouReportID, + transactionThreadReportID, + isFromGlobalCreate: getIsFromGlobalCreate(lastTransaction), + backToReport: navigateBackToReport, + optimisticChatReportID: chatReportID, + linkedTrackedExpenseReportAction: lastTransaction?.linkedTrackedExpenseReportAction, + buildTransactionThreadParams, + }); } function requestMoney(shouldHandleNavigation: boolean, gpsPoint?: GpsPoint) { @@ -341,9 +400,11 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { const optimisticReportPreviewActionID = rand64(); let existingIOUReport: Report | undefined; let allTransactionsCreated = true; + let lastOptimisticTransactionID: string | undefined; + let lastTransactionThreadReportID: string | undefined; - for (const [index, item] of transactions.entries()) { - const optimisticTransactionID = rand64(); + for (const item of transactions) { + lastOptimisticTransactionID = rand64(); const receipt = receiptFiles[item.transactionID]; const isTestReceipt = receipt?.isTestReceipt ?? false; const isTestDriveReceipt = receipt?.isTestDriveReceipt ?? false; @@ -390,7 +451,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { } } - const {iouReport} = requestMoneyIOUActions({ + const {iouReport, transactionThreadReportID} = requestMoneyIOUActions({ report, existingIOUReport, optimisticChatReportID, @@ -447,15 +508,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { isFromGlobalCreate: getIsFromGlobalCreate(item), ...(isTimeRequest ? {type: CONST.TRANSACTION.TYPE.TIME, count: item.comment?.units?.count, rate: item.comment?.units?.rate, unit: CONST.TIME_TRACKING.UNIT.HOUR} : {}), }, - optimisticTransactionID, - // The action owns post-create navigation + growl, but only when the caller permits it - // (dismiss-first orchestrators pass shouldHandleNavigation=false after revealing/dismissing - // the destination themselves). Silence every non-final write of a multi-transaction batch so - // the feedback fires once, for the last transaction only. - shouldHandleNavigation: shouldHandleNavigation && index === transactions.length - 1, - shouldShowPostCreateFeedback: index === transactions.length - 1, - // Post-create navigation must return to the report the flow started from, not the chat the expense was written to. - backToReport, + optimisticTransactionID: lastOptimisticTransactionID, shouldGenerateTransactionThreadReport, isASAPSubmitBetaEnabled, currentUserAccountIDParam: currentUserPersonalDetails.accountID, @@ -470,15 +523,29 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { betas, personalDetails, isTrackIntentUser, - introSelected, delegateAccountID, }); existingIOUReport = iouReport; + lastTransactionThreadReportID = transactionThreadReportID; if (!iouReport) { allTransactionsCreated = false; } } - performPostBatchCleanup({allTransactionsCreated}); + const isExpenseReport = isMoneyRequestReportReportUtils(report); + performPostBatchCleanup({ + participant, + shouldHandleNavigation, + allTransactionsCreated, + fallbackOptimisticChatReportID: optimisticChatReportID, + navigateBackToReport: backToReport, + lastOptimisticTransactionID, + preResolvedChatTarget: { + report: isExpenseReport ? report : undefined, + chatReportID: isExpenseReport ? optimisticChatReportID : (existingIOUReport?.chatReportID ?? optimisticChatReportID), + }, + iouReportID: existingIOUReport?.reportID, + transactionThreadReportID: lastTransactionThreadReportID, + }); } function submitPerDiemExpense(trimmedComment: string, shouldHandleNavigation: boolean, policyRecentlyUsedCategoriesParam?: RecentlyUsedCategories) { @@ -525,8 +592,6 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { }); } } else { - // Navigation is owned by SubmitExpenseOrchestrator (dismiss-first paths) - surface - // feedback wherever the user lands, matching the workspace per-diem branch below. surfaceExpenseCreatedFeedback({ transactionID: result?.transactionID, transactionThreadReportID: result?.transactionThreadReportID, @@ -602,10 +667,6 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { shouldAddPendingNewTransactionIDs: targetReportID === chatReportID || isOneToTwoTransition, }); } else { - // Navigation is owned by SubmitExpenseOrchestrator (dismiss-first paths). Surface - // feedback wherever the user lands: highlight the new row for in-report adds, - // otherwise the "Expense added" growl with a "View" deep link - matching - // requestMoney/trackExpense/split/invoice. surfaceExpenseCreatedFeedback({ iouReportID: result.iouReport?.reportID, transactionID: result.transactionID, @@ -636,14 +697,16 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { // non-self route report) so getTrackExpenseInformation defaults to the self-DM instead of the route report. const trackReport = isSelfDMDestination ? undefined : report; const policyExpenseChatReportActions = getAllPolicyExpenseChatReportActions(allReports, allReportActions); - for (const [index, item] of transactions.entries()) { - const optimisticTransactionID = rand64(); + let lastTrackResult: {iouReport?: Report; transactionID?: string; transactionThreadReportID?: string} | undefined; + let lastOptimisticTransactionID: string | undefined; + for (const item of transactions) { + lastOptimisticTransactionID = rand64(); const isLinkedTrackedExpenseReportArchived = !!item.linkedTrackedExpenseReportID && privateIsArchivedMap[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${item.linkedTrackedExpenseReportID}`]; const itemDistance = isManualDistanceRequest || isOdometerDistanceRequest || isGPSDistanceRequest ? (item.comment?.customUnit?.quantity ?? undefined) : undefined; const email = currentUserPersonalDetails.email ?? ''; - trackExpenseIOUActions({ + lastTrackResult = trackExpenseIOUActions({ report: trackReport, isDraftPolicy, isDraftChatReport, @@ -692,13 +755,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { accountant: item.accountant, }, optimisticChatReportID: optimisticSelfDMReportID, - optimisticTransactionID, - // The action owns post-create navigation + growl, but only when the caller permits it - // (dismiss-first orchestrators pass shouldHandleNavigation=false after revealing/dismissing - // the destination themselves). Silence every non-final write of a multi-transaction batch so - // the feedback fires once, for the last transaction only. - shouldHandleNavigation: shouldHandleNavigation && index === transactions.length - 1, - shouldShowPostCreateFeedback: index === transactions.length - 1, + optimisticTransactionID: lastOptimisticTransactionID, isASAPSubmitBetaEnabled, currentUser: {accountID: currentUserPersonalDetails.accountID, email}, introSelected, @@ -716,7 +773,19 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { delegateAccountID, }); } - performPostBatchCleanup({allTransactionsCreated: true}); + performPostBatchCleanup({ + participant, + shouldHandleNavigation, + allTransactionsCreated: true, + fallbackOptimisticChatReportID: optimisticSelfDMReportID, + navigateBackToReport: undefined, + lastOptimisticTransactionID, + // trackExpense wrote to optimisticSelfDMReportID, so resolve the self-DM nav target directly instead of + // falling back to a route report that may not be loaded yet on a first-time self-DM create. + preResolvedChatTarget: isSelfDMDestination ? {report: selfDMReport, chatReportID: optimisticSelfDMReportID} : undefined, + iouReportID: lastTrackResult?.iouReport?.reportID, + transactionThreadReportID: lastTrackResult?.transactionThreadReportID, + }); } function createDistanceRequest(trimmedComment: string, shouldHandleNavigation = true, shouldDeferForSearch = false) { @@ -961,8 +1030,6 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { }); } else { cleanupAfterExpenseCreate({draftTransactionIDs}); - // Dismiss-first paths (orchestrator owns navigation); still surface the growl wherever the - // user lands. Invoices go to an invoice room (no expense-report table), so this resolves to the growl. surfaceExpenseCreatedFeedback({ iouReportID: invoiceResult?.invoiceReportID, transactionID: invoiceResult?.transactionID, diff --git a/src/pages/iou/request/step/resolveChatTarget.ts b/src/pages/iou/request/step/resolveChatTarget.ts index 973821912e7f..0b44a6cb6965 100644 --- a/src/pages/iou/request/step/resolveChatTarget.ts +++ b/src/pages/iou/request/step/resolveChatTarget.ts @@ -1,6 +1,7 @@ import {resolveOptimisticChatReportID} from '@libs/IOUUtils'; +import {getChatByParticipants, getReportOrDraftReport, isDeprecatedGroupDM, isGroupChat, isMoneyRequestReport, isPolicyExpenseChat, isSelfDM} from '@libs/ReportUtils'; -import type {IOUType} from '@src/CONST'; +import type {IOUAction, IOUType} from '@src/CONST'; import CONST from '@src/CONST'; import type {Report} from '@src/types/onyx'; import type {Participant} from '@src/types/onyx/IOU'; @@ -23,6 +24,14 @@ type ResolveChatTargetForScanParams = { currentUserAccountID: number; }; +type ResolveChatTargetForSubmitCleanupParams = { + participant: Participant; + currentUserAccountID: number; + report: OnyxEntry; + fallbackOptimisticChatReportID: string; + action: IOUAction; +}; + /** Pre-action scan-flow resolver: returns both the builder's optimistic ID and the cleanup nav target so they stay in lockstep. */ function resolveChatTargetForScan({iouType, participant, report, currentUserAccountID}: ResolveChatTargetForScanParams): ChatTarget { if (iouType === CONST.IOU.TYPE.TRACK && report?.reportID) { @@ -35,4 +44,45 @@ function resolveChatTargetForScan({iouType, participant, report, currentUserAcco return {report: undefined, chatReportID: resolved.chatReportID, optimisticChatReportID: resolved.optimisticChatReportID}; } -export default resolveChatTargetForScan; +/** + * Post-action confirmation-cleanup resolver: mirrors the action's chat resolution so cleanup navigation + * lands on the same report the action wrote to. Falls back to `fallbackOptimisticChatReportID` (UI's + * pre-generated optimistic ID for this submission) when no existing chat resolves. + */ +function resolveChatTargetForSubmitCleanup({participant, currentUserAccountID, report, fallbackOptimisticChatReportID, action}: ResolveChatTargetForSubmitCleanupParams): ChatTarget { + if (isMoneyRequestReport(report)) { + return {report, chatReportID: fallbackOptimisticChatReportID, optimisticChatReportID: undefined}; + } + + // SUBMIT (move-from-track) writes to the participant's policy/1:1 chat, never the self-DM source — skip the keep-source shortcut. + const isMoveFromTrackSubmit = action === CONST.IOU.ACTION.SUBMIT; + + // Keep `report` unless it's a non-special 1:1 chat whose participants don't match the submission target. + if (!isMoveFromTrackSubmit && report?.reportID) { + const isSpecialChat = !!participant.isPolicyExpenseChat || isPolicyExpenseChat(report) || isSelfDM(report) || isGroupChat(report) || isDeprecatedGroupDM(report); + if (isSpecialChat) { + return {report, chatReportID: fallbackOptimisticChatReportID, optimisticChatReportID: undefined}; + } + if (participant.accountID) { + const reportParticipants = Object.keys(report.participants ?? {}) + .map(Number) + .sort(); + const expected = [participant.accountID, currentUserAccountID].sort(); + const participantsMatch = expected.length === reportParticipants.length && expected.every((id, i) => id === reportParticipants.at(i)); + if (participantsMatch) { + return {report, chatReportID: fallbackOptimisticChatReportID, optimisticChatReportID: undefined}; + } + } + } + + let chatReportID: string | undefined; + if (participant.isPolicyExpenseChat && participant.reportID && getReportOrDraftReport(participant.reportID)) { + chatReportID = participant.reportID; + } else if (participant.accountID) { + chatReportID = getChatByParticipants([participant.accountID, currentUserAccountID])?.reportID; + } + + return {report: undefined, chatReportID: chatReportID ?? fallbackOptimisticChatReportID, optimisticChatReportID: undefined}; +} + +export {resolveChatTargetForScan, resolveChatTargetForSubmitCleanup}; diff --git a/tests/unit/hooks/useExpenseSubmission.test.ts b/tests/unit/hooks/useExpenseSubmission.test.ts index fd7b9c690d0c..c59b0c27407b 100644 --- a/tests/unit/hooks/useExpenseSubmission.test.ts +++ b/tests/unit/hooks/useExpenseSubmission.test.ts @@ -214,7 +214,7 @@ describe('useExpenseSubmission orchestrator-suppressed cleanup', () => { expect(mockCleanupAndNavigateAfterExpenseCreate).not.toHaveBeenCalled(); }); - it('does cleanup-only and never cleanupAndNavigateAfterExpenseCreate when shouldHandleNavigation=true (IOU action owns nav/growl)', async () => { + it('runs cleanupAndNavigateAfterExpenseCreate (nav + feedback) when shouldHandleNavigation=true', async () => { const {result} = renderHook(() => useExpenseSubmission(buildParams())); await waitForBatchedUpdatesWithAct(); @@ -224,15 +224,13 @@ describe('useExpenseSubmission orchestrator-suppressed cleanup', () => { await waitForBatchedUpdatesWithAct(); expect(mockRequestMoneyAction).toHaveBeenCalledTimes(1); - // Navigation + the "Expense added" growl are owned by requestMoney itself; the hook only runs the - // non-navigation cleanup regardless of shouldHandleNavigation. - expect(mockCleanupAfterExpenseCreate).toHaveBeenCalledTimes(1); - expect(mockCleanupAndNavigateAfterExpenseCreate).not.toHaveBeenCalled(); + expect(mockCleanupAndNavigateAfterExpenseCreate).toHaveBeenCalledTimes(1); + expect(mockCleanupAfterExpenseCreate).not.toHaveBeenCalled(); }); - it('passes the moved transaction linkedTrackedExpenseReportAction to cleanup for a move-from-track SUBMIT', async () => { - // Move-from-track SUBMIT: cleanup is cleanup-only now, so it carries the moved transaction's - // linkedTrackedExpenseReportAction (used to release the original tracked expense draft). + it('passes the moved transaction linkedTrackedExpenseReportAction through cleanupAndNavigateAfterExpenseCreate for a move-from-track SUBMIT', async () => { + // Move-from-track SUBMIT carries the moved transaction's linkedTrackedExpenseReportAction + // (used to release the original tracked expense draft) into the post-create nav/cleanup. const EXISTING_TRACKED_TRANSACTION_ID = 'tracked-transaction-99'; const linkedTrackedExpenseReportAction = buildReportAction({ reportActionID: 'linked-action-1', @@ -262,17 +260,16 @@ describe('useExpenseSubmission orchestrator-suppressed cleanup', () => { }); await waitForBatchedUpdatesWithAct(); - expect(mockCleanupAndNavigateAfterExpenseCreate).not.toHaveBeenCalled(); - expect(mockCleanupAfterExpenseCreate).toHaveBeenCalledTimes(1); - expect(mockCleanupAfterExpenseCreate).toHaveBeenCalledWith( + expect(mockCleanupAfterExpenseCreate).not.toHaveBeenCalled(); + expect(mockCleanupAndNavigateAfterExpenseCreate).toHaveBeenCalledTimes(1); + expect(mockCleanupAndNavigateAfterExpenseCreate).toHaveBeenCalledWith( expect.objectContaining({ linkedTrackedExpenseReportAction, }), ); }); - // The IOU action owns post-submit navigation now, so the hook performs no cleanup navigation of its own. - it('does not run cleanup navigation from the hook (the IOU action owns post-submit nav)', async () => { + it('threads the returned iouReport into cleanupAndNavigateAfterExpenseCreate', async () => { mockRequestMoneyAction.mockReturnValue({iouReport: {reportID: 'iou-1', chatReportID: 'iou-chat-77'}}); const {result} = renderHook(() => useExpenseSubmission(buildParams())); @@ -283,8 +280,14 @@ describe('useExpenseSubmission orchestrator-suppressed cleanup', () => { }); await waitForBatchedUpdatesWithAct(); - expect(mockCleanupAndNavigateAfterExpenseCreate).not.toHaveBeenCalled(); - expect(mockCleanupAfterExpenseCreate).toHaveBeenCalledTimes(1); + expect(mockCleanupAfterExpenseCreate).not.toHaveBeenCalled(); + expect(mockCleanupAndNavigateAfterExpenseCreate).toHaveBeenCalledTimes(1); + expect(mockCleanupAndNavigateAfterExpenseCreate).toHaveBeenCalledWith( + expect.objectContaining({ + iouReportID: 'iou-1', + optimisticChatReportID: 'iou-chat-77', + }), + ); }); it('routes tracked per diem SUBMIT through requestMoney so the original tracked expense is moved', async () => { @@ -356,7 +359,7 @@ describe('useExpenseSubmission orchestrator-suppressed cleanup', () => { expect(mockCleanupAndNavigateAfterExpenseCreate).not.toHaveBeenCalled(); }); - it('does cleanup-only and never cleanupAndNavigateAfterExpenseCreate when shouldHandleNavigation=true (IOU action owns nav/growl)', async () => { + it('runs cleanupAndNavigateAfterExpenseCreate (nav + feedback) when shouldHandleNavigation=true', async () => { const {result} = renderHook(() => useExpenseSubmission(buildParams({iouType: CONST.IOU.TYPE.TRACK}))); await waitForBatchedUpdatesWithAct(); @@ -366,8 +369,8 @@ describe('useExpenseSubmission orchestrator-suppressed cleanup', () => { await waitForBatchedUpdatesWithAct(); expect(mockTrackExpenseAction).toHaveBeenCalledTimes(1); - expect(mockCleanupAfterExpenseCreate).toHaveBeenCalledTimes(1); - expect(mockCleanupAndNavigateAfterExpenseCreate).not.toHaveBeenCalled(); + expect(mockCleanupAndNavigateAfterExpenseCreate).toHaveBeenCalledTimes(1); + expect(mockCleanupAfterExpenseCreate).not.toHaveBeenCalled(); }); it('forwards the per-iteration draft as existingTransaction so getTrackExpenseInformation finds it', async () => { From 5f23f0797cb677797b1612d8b8be117e4f011fbf Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Wed, 15 Jul 2026 15:22:16 +0200 Subject: [PATCH 14/28] Fix tests --- tests/actions/IOU/MoneyRequestTest.ts | 19 ++++++++++++------- tests/ui/ScanSkipConfirmationTest.tsx | 2 +- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/tests/actions/IOU/MoneyRequestTest.ts b/tests/actions/IOU/MoneyRequestTest.ts index 06020cb44bba..afac5b1dd33e 100644 --- a/tests/actions/IOU/MoneyRequestTest.ts +++ b/tests/actions/IOU/MoneyRequestTest.ts @@ -73,7 +73,6 @@ jest.mock('@libs/getCurrentPosition'); // Fire executeWrite synchronously so downstream writes can be asserted. jest.mock('@libs/Navigation/helpers/submitWithDismissFirst', () => jest.requireActual('../../__mocks__/submitWithDismissFirst')); -// cleanupAfterExpenseCreate is a spy so the cleanup contract (draft ids + linked tracked action) can be asserted. It's cleanup-only — the write action owns post-create navigation. const mockCleanupAfterExpenseCreate = jest.fn(); jest.mock('@libs/Navigation/helpers/cleanupAfterExpenseCreate', () => ({ __esModule: true, @@ -81,6 +80,13 @@ jest.mock('@libs/Navigation/helpers/cleanupAfterExpenseCreate', () => ({ mockCleanupAfterExpenseCreate(...args); }, })); +const mockCleanupAndNavigateAfterExpenseCreate = jest.fn(); +jest.mock('@libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate', () => ({ + __esModule: true, + default: (...args: unknown[]): void => { + mockCleanupAndNavigateAfterExpenseCreate(...args); + }, +})); describe('MoneyRequest', () => { const currentUserAccountID = 111; @@ -182,10 +188,9 @@ describe('MoneyRequest', () => { }), ); - // Deferral is channel-driven (no shouldDeferForSearch flag), but the action owns post-create navigation, so it receives shouldHandleNavigation. const lastTrackExpenseParams = jest.mocked(TrackExpense.trackExpense).mock.calls.at(-1)?.at(0); expect(lastTrackExpenseParams && 'shouldDeferForSearch' in lastTrackExpenseParams).toBeFalsy(); - expect(lastTrackExpenseParams && 'shouldHandleNavigation' in lastTrackExpenseParams).toBeTruthy(); + expect(lastTrackExpenseParams && 'shouldHandleNavigation' in lastTrackExpenseParams).toBeFalsy(); }); it('should call requestMoney for non-TRACK (SEND) iouType', () => { @@ -769,7 +774,7 @@ describe('MoneyRequest', () => { const distanceTrackExpenseParams = jest.mocked(TrackExpense.trackExpense).mock.calls.at(-1)?.at(0); expect(typeof distanceTrackExpenseParams?.optimisticTransactionID).toBe('string'); expect(typeof distanceTrackExpenseParams?.optimisticChatReportID).toBe('string'); - expect(distanceTrackExpenseParams && 'shouldHandleNavigation' in distanceTrackExpenseParams).toBeTruthy(); + expect(distanceTrackExpenseParams && 'shouldHandleNavigation' in distanceTrackExpenseParams).toBeFalsy(); // The function must return after trackExpense and not call createDistanceRequest expect(Split.createDistanceRequest).not.toHaveBeenCalled(); @@ -805,10 +810,10 @@ describe('MoneyRequest', () => { await waitForBatchedUpdates(); - expect(mockCleanupAfterExpenseCreate).toHaveBeenCalledTimes(1); - // Cleanup is cleanup-only now; it carries the moved transaction's linkedTrackedExpenseReportAction + expect(mockCleanupAndNavigateAfterExpenseCreate).toHaveBeenCalledTimes(1); + // Navigate-path cleanup carries the moved transaction's linkedTrackedExpenseReportAction // (used to release the original tracked expense thread screen). - expect(mockCleanupAfterExpenseCreate).toHaveBeenCalledWith( + expect(mockCleanupAndNavigateAfterExpenseCreate).toHaveBeenCalledWith( expect.objectContaining({ linkedTrackedExpenseReportAction, }), diff --git a/tests/ui/ScanSkipConfirmationTest.tsx b/tests/ui/ScanSkipConfirmationTest.tsx index 1aab66b21c1a..144a18811466 100644 --- a/tests/ui/ScanSkipConfirmationTest.tsx +++ b/tests/ui/ScanSkipConfirmationTest.tsx @@ -96,7 +96,7 @@ jest.mock('@libs/Navigation/helpers/cleanupAfterExpenseCreate', () => ({ jest.mock('@pages/iou/request/step/resolveChatTarget', () => ({ __esModule: true, - default: () => mockResolveChatTargetForScan(), + resolveChatTargetForScan: () => mockResolveChatTargetForScan(), })); const REPORT_ID = '1'; From 21434f21bc55a8db9b58d74f1a156f4d231bfe2d Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Wed, 15 Jul 2026 15:36:05 +0200 Subject: [PATCH 15/28] Fix more tests --- tests/ui/IOURequestStepAmountDraftTest.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/ui/IOURequestStepAmountDraftTest.tsx b/tests/ui/IOURequestStepAmountDraftTest.tsx index 8fffc858eff3..a86aaa2ba4a6 100644 --- a/tests/ui/IOURequestStepAmountDraftTest.tsx +++ b/tests/ui/IOURequestStepAmountDraftTest.tsx @@ -18,7 +18,7 @@ import React from 'react'; import Onyx from 'react-native-onyx'; import * as TrackExpense from '../../src/libs/actions/IOU/TrackExpense'; -import cleanupAfterExpenseCreate from '../../src/libs/Navigation/helpers/cleanupAfterExpenseCreate'; +import cleanupAndNavigateAfterExpenseCreate from '../../src/libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate'; import createRandomTransaction from '../utils/collections/transaction'; import {signInWithTestUser} from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; @@ -105,8 +105,9 @@ jest.mock('@libs/Navigation/Navigation', () => { jest.mock('@libs/Navigation/helpers/submitWithDismissFirst', () => jest.requireActual('../__mocks__/submitWithDismissFirst')); -// Action-assertion test: post-create navigation is exercised elsewhere; keep the cleanup helper inert here. +// Action-assertion test: post-create navigation is exercised elsewhere; keep the cleanup helpers inert here. jest.mock('@libs/Navigation/helpers/cleanupAfterExpenseCreate', () => jest.fn()); +jest.mock('@libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate', () => jest.fn()); jest.mock('@react-navigation/native', () => { const mockRef = { @@ -263,10 +264,10 @@ describe('IOURequestStepAmount - draft transactions coverage', () => { }), ); - // The action receives the UI-resolved optimistic id (it owns post-create navigation/growl); - // cleanup is cleanup-only and no longer takes transaction ids beyond the draft list. + // The action receives the UI-resolved optimistic id; the view owns post-create navigation, so on the + // shouldHandleNavigation=true fast path the feedback runs through cleanupAndNavigateAfterExpenseCreate. const requestMoneyArg = jest.mocked(TrackExpense.requestMoney).mock.calls.at(0)?.[0]; expect(typeof requestMoneyArg?.optimisticTransactionID).toBe('string'); - expect(cleanupAfterExpenseCreate).toHaveBeenCalledTimes(1); + expect(cleanupAndNavigateAfterExpenseCreate).toHaveBeenCalledTimes(1); }); }); From b92595bd76252895ea1591171390f89e88b093c6 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Wed, 15 Jul 2026 16:39:27 +0200 Subject: [PATCH 16/28] Add runAfterDeferredWrite to remove the growl safety timeout --- src/CONST/index.ts | 1 - .../helpers/navigateAfterExpenseCreate.ts | 74 ++----------------- src/libs/deferredLayoutWrite.ts | 26 +++++++ tests/unit/deferredLayoutWriteTest.ts | 50 +++++++++++++ 4 files changed, 84 insertions(+), 67 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index bed96cde28de..7d37ca62ac30 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2093,7 +2093,6 @@ const CONST = { SHOW_HOVER_PREVIEW_DELAY: 270, SHOW_HOVER_PREVIEW_ANIMATION_DURATION: 250, ACTIVITY_INDICATOR_TIMEOUT: 10000, - EXPENSE_ADDED_GROWL_SAFETY_TIMEOUT: 8000, GET_INITIAL_URL_TIMEOUT: 10000, MIN_SMOOTH_SCROLL_EVENT_THROTTLE: 16, }, diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index 2fab1818ede7..456049196f32 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -1,6 +1,7 @@ import {addPendingNewTransactionIDs} from '@libs/actions/IOU/PendingNewTransactions'; import {createTransactionThreadReport, setOptimisticTransactionThread} from '@libs/actions/Report'; import {setActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation'; +import {runAfterDeferredWrite} from '@libs/deferredLayoutWrite'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import Growl from '@libs/Growl'; import {translateLocal} from '@libs/Localize'; @@ -14,13 +15,10 @@ import {setPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpActi import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; -import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import type {Beta, IntroSelected, Transaction} from '@src/types/onyx'; -import Onyx from 'react-native-onyx'; - import dismissModalAndOpenReportInInboxTab from './dismissModalAndOpenReportInInboxTab'; import getTopmostFullScreenRoute from './getTopmostFullScreenRoute'; import isReportTopmostSplitNavigator from './isReportTopmostSplitNavigator'; @@ -98,9 +96,8 @@ type ShowExpenseAddedGrowlParams = { * * The IOU action's optimistic data is typically not yet in the Onyx cache when this runs — the * `API.write` is deferred (deferred-for-search pattern) until Search's content layout flushes the - * channel. We subscribe to the iouReport's reportActions and wait for the optimistic iouAction - * to land, then show the growl. A safety timeout falls back to a growl without the "View" link - * if the iouAction never appears. + * channel. `runAfterDeferredWrite` shows the growl right after that write applies (its channel's own + * safety timeout guarantees it fires). If no write is pending, the growl shows immediately. * * The transaction thread itself is only materialized (context merge, or optimistic creation with * its OpenReport write) when the user actually presses "View" — matching how every other thread @@ -219,66 +216,11 @@ function showExpenseAddedGrowl({ Growl.success(growlMessage, CONST.GROWL.DURATION_WITH_ACTION, {label: translateLocal('common.view'), onPress: navigateToExpenseRHP}); }; - // Fast path: the thread is already resolvable, so show the growl immediately instead of waiting on - // the reportActions subscription (which would otherwise hit the 8s safety timeout). This covers: - // - personal tracked expenses (unreported/self-DM): there's no iouReportID to subscribe to, and the - // thread ID passed in directly is the only handle we'll ever get; - // - the iouAction already being in Onyx (retry / non-deferred edge cases). - // When an iouReportID exists but the iouAction hasn't landed yet (deferred write), a provided thread - // ID is NOT enough - its optimistic report data is inside the deferred write too, so "View" would - // open a broken RHP. Fall through to the subscription and wait for the action instead. - if ((providedTransactionThreadReportID && !iouReportID) || (iouReportID && getIOUActionForReportID(iouReportID, transactionID)?.reportActionID)) { - showGrowl(); - return; - } - - // No iouReportID to subscribe to (and no thread ID was passed): the reportActions key would be - // `reportActions_undefined`, so the subscription below could never resolve an iouAction and would only - // fire via the safety timeout. Resolve immediately with the same fallback the timeout would produce - // (a growl without "View" when no thread is resolvable). - if (!iouReportID) { - showGrowl(); - return; - } - - // Slow path: wait for Search to render → flushDeferredWrite('search') → API.write applies - // optimistic data → iouAction lands → we show the growl. - // Unlike the params threaded in above, this can't be passed down: it awaits a *future* write, not - // current state. Onyx has no promise-based get, so a transient, self-disconnecting subscription is - // the only way to wait for it. - let resolved = false; - let safetyTimeoutID: ReturnType | undefined; - const reportActionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}` as const; - const connectionId = Onyx.connectWithoutView({ - key: reportActionsKey, - callback: () => { - if (resolved) { - return; - } - const iouAction = getIOUActionForReportID(iouReportID, transactionID); - if (!iouAction?.reportActionID) { - return; - } - resolved = true; - clearTimeout(safetyTimeoutID); - Onyx.disconnect(connectionId); - showGrowl(); - }, - }); - - safetyTimeoutID = setTimeout(() => { - if (resolved) { - return; - } - resolved = true; - Onyx.disconnect(connectionId); - showGrowl(); - }, CONST.TIMING.EXPENSE_ADDED_GROWL_SAFETY_TIMEOUT); - - // If the connect callback somehow resolved before the timeout was assigned, its clearTimeout was a no-op - drop the now-useless timer. - if (resolved) { - clearTimeout(safetyTimeoutID); - } + // The optimistic IOU action may still be sitting inside a deferred write (the deferred-for-search + // pattern), in which case a provided thread ID isn't enough yet - its report data is in that same + // write, so "View" would open a broken RHP. Show the growl once the pending write has applied, + // if nothing is pending, this runs synchronously. + runAfterDeferredWrite(showGrowl); } type SurfaceExpenseCreatedFeedbackParams = { diff --git a/src/libs/deferredLayoutWrite.ts b/src/libs/deferredLayoutWrite.ts index e648d600da1b..d3b95813bd2d 100644 --- a/src/libs/deferredLayoutWrite.ts +++ b/src/libs/deferredLayoutWrite.ts @@ -56,6 +56,12 @@ type DeferredChannel = { * instead of creating a new deferred channel. */ flushRequested?: boolean; + + /** + * One-shot callbacks registered via `runAfterDeferredWrite`, run right after `write()` applies the + * optimistic data. Lets consumers sequence work that needs the data to exist. + */ + onFlush?: Array<() => void>; }; const channels = new Map(); @@ -140,6 +146,25 @@ function flushDeferredWrite(key: string) { clearChannelTimeout(channel); channels.delete(key); channel.write(); + if (channel.onFlush) { + for (const cb of channel.onFlush) { + cb(); + } + } +} + +/** + * Run `callback` once the pending optimistic write has been applied to Onyx, after the next flush of the + * most-recently-registered active channel or immediately if nothing is pending. + * Used to sequence work that depends on the optimistic data existing. + */ +function runAfterDeferredWrite(callback: () => void) { + const channel = [...channels.values()].reverse().find((c) => !c.isReserved); + if (!channel) { + callback(); + return; + } + channel.onFlush = [...(channel.onFlush ?? []), callback]; } /** @@ -292,5 +317,6 @@ export { hasDeferredWriteForReport, getOptimisticWatchKey, deferOrExecuteWrite, + runAfterDeferredWrite, resetForTesting, }; diff --git a/tests/unit/deferredLayoutWriteTest.ts b/tests/unit/deferredLayoutWriteTest.ts index 1c21b4e2ff30..c96d5f8650c5 100644 --- a/tests/unit/deferredLayoutWriteTest.ts +++ b/tests/unit/deferredLayoutWriteTest.ts @@ -8,6 +8,7 @@ import { registerDeferredWrite, reserveDeferredWriteChannel, resetForTesting, + runAfterDeferredWrite, } from '@libs/deferredLayoutWrite'; import CONST from '@src/CONST'; @@ -330,4 +331,53 @@ describe('deferredLayoutWrite', () => { expect(hasDeferredWriteForReport(CONST.DEFERRED_LAYOUT_WRITE_KEYS.DISMISS_MODAL, 'report-A')).toBe(false); }); }); + + describe('runAfterDeferredWrite', () => { + it('runs the callback immediately when no write is pending', () => { + const callback = jest.fn(); + runAfterDeferredWrite(callback); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('runs the callback after the pending write flushes, not before', () => { + const apiWrite = jest.fn(); + registerDeferredWrite('test', apiWrite); + + const callback = jest.fn(); + runAfterDeferredWrite(callback); + expect(callback).not.toHaveBeenCalled(); + + flushDeferredWrite('test'); + expect(apiWrite).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('runs the callback when the pending write fires via its safety timeout', () => { + registerDeferredWrite('test', jest.fn(), {safetyTimeoutMs: 3000}); + + const callback = jest.fn(); + runAfterDeferredWrite(callback); + expect(callback).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(3000); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('runs the callback only once even across multiple flushes', () => { + registerDeferredWrite('test', jest.fn()); + const callback = jest.fn(); + runAfterDeferredWrite(callback); + + flushDeferredWrite('test'); + flushDeferredWrite('test'); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('ignores reserved-but-unregistered channels (runs immediately)', () => { + reserveDeferredWriteChannel(CONST.DEFERRED_LAYOUT_WRITE_KEYS.SEARCH); + const callback = jest.fn(); + runAfterDeferredWrite(callback); + expect(callback).toHaveBeenCalledTimes(1); + }); + }); }); From 1a3857639e366a7ccbaac27d6b5bf1c6ea6d9a42 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Thu, 16 Jul 2026 14:09:35 +0200 Subject: [PATCH 17/28] Extract navigateToTransactionThread function and add tests for it --- .../helpers/navigateAfterExpenseCreate.ts | 101 ++++++++++-------- tests/unit/navigateAfterExpenseCreateTest.ts | 65 ++++++++++- 2 files changed, 122 insertions(+), 44 deletions(-) diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index 456049196f32..3088c155c6b7 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -91,6 +91,62 @@ type ShowExpenseAddedGrowlParams = { buildTransactionThreadParams?: BuildTransactionThreadParams; }; +type NavigateToTransactionThreadParams = { + /** The transaction thread report to open. */ + threadReportID: string; + + /** The created transaction's ID. */ + transactionID: string; + + /** IOU report the transaction landed in, used to decide whether to stack the expense report underneath. */ + iouReportID?: string; +}; + +/** + * Navigates to a just-created transaction thread, choosing the destination from the surface the user + * is currently looking at (they may have switched tabs while the growl was up) + * - Spend tab: the transaction thread RHP within Spend (report shown underneath via the wide RHP) + * - Inbox tab, narrow layout: the transaction thread as a full report view + * - Inbox tab, wide layout, multi-transaction report: the expense report with the thread RHP stacked on top + * - Inbox tab, wide layout, single-transaction report: the transaction thread as a wide RHP overlaying the + * Inbox tab (a single-transaction report does not use the super wide RHP, so stacking would surface as two RHP panels). + */ +function navigateToTransactionThread({threadReportID, transactionID, iouReportID}: NavigateToTransactionThreadParams) { + const backTo = Navigation.getActiveRoute(); + const openOnInbox = isReportTopmostSplitNavigator() && !isSearchTopmostFullScreenRoute(); + + if (!openOnInbox) { + setActiveTransactionIDs([transactionID]).then(() => { + Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: threadReportID, backTo})); + }); + return; + } + + if (getIsNarrowLayout()) { + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(threadReportID, undefined, undefined, backTo)); + return; + } + + // See SearchMoneyRequestReportPage's `shouldShowSuperWideRHP`: only multi-transaction reports use the + // super wide RHP, so only then do we open the expense report underneath and stack the thread RHP on top. + const hasMultipleReportTransactions = getReportTransactions(iouReportID).filter((transaction) => transaction?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length > 1; + + if (iouReportID && hasMultipleReportTransactions) { + Navigation.navigate(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: iouReportID, backTo})); + // Defer so the thread RHP stacks on top of the expense report navigation above. + setNavigationActionToMicrotaskQueue(() => { + setActiveTransactionIDs([transactionID]).then(() => { + Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: threadReportID, backTo: Navigation.getActiveRoute()})); + }); + }); + return; + } + + setActiveTransactionIDs([transactionID]).then(() => { + Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: threadReportID, backTo})); + }); +} + /** * Shows the "Expense added" growl with a "View" action that deep-links to the new expense's RHP. * @@ -169,48 +225,7 @@ function showExpenseAddedGrowl({ Log.warn('[showExpenseAddedGrowl] Unable to resolve transaction thread reportID on View press.'); return; } - const backTo = Navigation.getActiveRoute(); - const openOnInbox = isReportTopmostSplitNavigator() && !isSearchTopmostFullScreenRoute(); - - if (!openOnInbox) { - // Spend context: open the transaction thread RHP within Search (the report is shown - // underneath via the Wide RHP machinery). - setActiveTransactionIDs([transactionID]).then(() => { - Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: threadReportID, backTo})); - }); - return; - } - - // Inbox + narrow layout: super wide RHP is unavailable, so open the transaction thread - // as a full report view (matches MoneyRequestReportPreview's narrow-screen behavior). - if (getIsNarrowLayout()) { - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(threadReportID, undefined, undefined, backTo)); - return; - } - - // Inbox + wide layout. A report with multiple transactions opens in a super wide RHP, so - // we open the expense report underneath and stack the transaction thread RHP on top of it - - // the super wide RHP keeps both visible as one cohesive surface. A single-transaction report - // does NOT use the super wide RHP (see SearchMoneyRequestReportPage's `shouldShowSuperWideRHP`), - // so stacking would surface as two separate RHP panels (RHP appears to open twice). In that - // case navigate straight to the transaction thread instead; the report still shows underneath - // via the Wide RHP machinery, matching the Spend-context path above. - const hasMultipleReportTransactions = - getReportTransactions(iouReportID).filter((transaction) => transaction?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length > 1; - - if (iouReportID && hasMultipleReportTransactions) { - Navigation.navigate(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: iouReportID, backTo})); - // Defer so the thread RHP stacks on top of the expense report navigation above. - setNavigationActionToMicrotaskQueue(() => { - setActiveTransactionIDs([transactionID]).then(() => { - Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: threadReportID, backTo: Navigation.getActiveRoute()})); - }); - }); - return; - } - setActiveTransactionIDs([transactionID]).then(() => { - Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: threadReportID, backTo})); - }); + navigateToTransactionThread({threadReportID, transactionID, iouReportID}); }; // eslint-disable-next-line @typescript-eslint/no-deprecated -- imperative module (not a React component); no useLocalize hook available here Growl.success(growlMessage, CONST.GROWL.DURATION_WITH_ACTION, {label: translateLocal('common.view'), onPress: navigateToExpenseRHP}); @@ -379,5 +394,5 @@ function navigateAfterExpenseCreate({ } export default navigateAfterExpenseCreate; -export {surfaceExpenseCreatedFeedback}; +export {surfaceExpenseCreatedFeedback, navigateToTransactionThread}; export type {BuildTransactionThreadParams}; diff --git a/tests/unit/navigateAfterExpenseCreateTest.ts b/tests/unit/navigateAfterExpenseCreateTest.ts index 93679fea6518..da2143e4e440 100644 --- a/tests/unit/navigateAfterExpenseCreateTest.ts +++ b/tests/unit/navigateAfterExpenseCreateTest.ts @@ -1,9 +1,12 @@ import Growl from '@libs/Growl'; -import navigateAfterExpenseCreate from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; +import navigateAfterExpenseCreate, {navigateToTransactionThread} from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; import Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; +import type {Transaction} from '@src/types/onyx'; + +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; const mockIsReportTopmostSplitNavigator = jest.fn(); const mockIsSearchTopmostFullScreenRoute = jest.fn(); @@ -13,6 +16,7 @@ const mockGetTrackingState = jest.fn(); // Declared but assigned after jest.mock hoisting - use require() to access the mock in tests let mockSetPendingSubmitFollowUpAction: jest.Mock; const mockGetCurrentSearchQueryJSON = jest.fn(); +const mockGetReportTransactions = jest.fn(); jest.mock('@libs/Navigation/helpers/isReportTopmostSplitNavigator', () => () => mockIsReportTopmostSplitNavigator()); jest.mock('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute', () => () => mockIsSearchTopmostFullScreenRoute()); @@ -31,6 +35,12 @@ jest.mock('@libs/SearchQueryUtils', () => ({ buildCannedSearchQuery: jest.fn(({type}: {type: string}) => `type:${type}`), getCurrentSearchQueryJSON: () => mockGetCurrentSearchQueryJSON(), })); +jest.mock('@libs/ReportUtils', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- partial mock of the real module + const actual = jest.requireActual('@libs/ReportUtils'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return -- spread the real module and override one export + return {...actual, __esModule: true, getReportTransactions: (reportID: string | undefined) => mockGetReportTransactions(reportID)}; +}); jest.mock('@libs/Navigation/Navigation', () => ({ dismissModal: jest.fn(), @@ -80,6 +90,7 @@ describe('navigateAfterExpenseCreate', () => { mockIsReportOpenInRHP.mockReturnValue(false); mockGetTrackingState.mockReturnValue(false); mockGetCurrentSearchQueryJSON.mockReturnValue(undefined); + mockGetReportTransactions.mockReturnValue([]); }); it('should dismiss to report when not from global create', () => { @@ -214,4 +225,56 @@ describe('navigateAfterExpenseCreate', () => { expect(Navigation.dismissModal).toHaveBeenCalled(); expect(Navigation.navigate).not.toHaveBeenCalled(); }); + + describe('navigateToTransactionThread', () => { + it('should open the transaction thread in the Spend RHP when the user is on the Spend tab', async () => { + mockIsReportTopmostSplitNavigator.mockReturnValue(false); + mockIsSearchTopmostFullScreenRoute.mockReturnValue(true); + + navigateToTransactionThread({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'}); + await waitForBatchedUpdates(); + + expect(Navigation.navigate).toHaveBeenCalledTimes(1); + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SEARCH_REPORT.getRoute({reportID: 'thread-1', backTo: ''})); + }); + + it('should open the transaction thread as a full report when the user is on the Inbox tab on a narrow layout', () => { + mockIsReportTopmostSplitNavigator.mockReturnValue(true); + mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); + mockGetIsNarrowLayout.mockReturnValue(true); + + navigateToTransactionThread({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'}); + + expect(Navigation.navigate).toHaveBeenCalledTimes(1); + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute('thread-1', undefined, undefined, '')); + }); + + it('should open the expense report then stack the thread RHP when the user is on the Inbox tab on a wide layout and the report has multiple transactions', async () => { + mockIsReportTopmostSplitNavigator.mockReturnValue(true); + mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); + mockGetIsNarrowLayout.mockReturnValue(false); + mockGetReportTransactions.mockReturnValue([{transactionID: 'txn-1'}, {transactionID: 'txn-2'}] as Transaction[]); + + navigateToTransactionThread({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'}); + await waitForBatchedUpdates(); + + expect(Navigation.navigate).toHaveBeenNthCalledWith(1, ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: 'iou-1', backTo: ''})); + expect(Navigation.navigate).toHaveBeenNthCalledWith(2, ROUTES.SEARCH_REPORT.getRoute({reportID: 'thread-1', backTo: ''})); + }); + + // SEARCH_REPORT is the only wide-RHP screen that renders a transaction thread with its report + // underneath; on the Inbox tab it overlays the current tab rather than switching to Spend. + it('should open the transaction thread as a wide RHP when the user is on the Inbox tab on a wide layout and the report has a single transaction', async () => { + mockIsReportTopmostSplitNavigator.mockReturnValue(true); + mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); + mockGetIsNarrowLayout.mockReturnValue(false); + mockGetReportTransactions.mockReturnValue([{transactionID: 'txn-1'}] as Transaction[]); + + navigateToTransactionThread({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'}); + await waitForBatchedUpdates(); + + expect(Navigation.navigate).toHaveBeenCalledTimes(1); + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SEARCH_REPORT.getRoute({reportID: 'thread-1', backTo: ''})); + }); + }); }); From f116c1306414de49ec40d90252da18d9bcd1acf3 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Thu, 16 Jul 2026 15:13:48 +0200 Subject: [PATCH 18/28] Navigate to expense report for single-transaction case instead of /search/view in navigateToTransactionThread --- .../helpers/navigateAfterExpenseCreate.ts | 32 +++++++++++-------- tests/unit/navigateAfterExpenseCreateTest.ts | 15 +++++++-- 2 files changed, 30 insertions(+), 17 deletions(-) diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index 3088c155c6b7..94f19968d7e4 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -107,9 +107,9 @@ type NavigateToTransactionThreadParams = { * is currently looking at (they may have switched tabs while the growl was up) * - Spend tab: the transaction thread RHP within Spend (report shown underneath via the wide RHP) * - Inbox tab, narrow layout: the transaction thread as a full report view - * - Inbox tab, wide layout, multi-transaction report: the expense report with the thread RHP stacked on top - * - Inbox tab, wide layout, single-transaction report: the transaction thread as a wide RHP overlaying the - * Inbox tab (a single-transaction report does not use the super wide RHP, so stacking would surface as two RHP panels). + * - Inbox tab, wide layout, with an expense report: the expense report - a multi-transaction report opens super + * wide with the specific thread RHP stacked on top; a single-transaction report collapses to the thread itself + * - Inbox tab, wide layout, tracked/unreported expense (no expense report): the transaction thread RHP directly */ function navigateToTransactionThread({threadReportID, transactionID, iouReportID}: NavigateToTransactionThreadParams) { const backTo = Navigation.getActiveRoute(); @@ -126,22 +126,26 @@ function navigateToTransactionThread({threadReportID, transactionID, iouReportID Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(threadReportID, undefined, undefined, backTo)); return; } - - // See SearchMoneyRequestReportPage's `shouldShowSuperWideRHP`: only multi-transaction reports use the - // super wide RHP, so only then do we open the expense report underneath and stack the thread RHP on top. - const hasMultipleReportTransactions = getReportTransactions(iouReportID).filter((transaction) => transaction?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length > 1; - - if (iouReportID && hasMultipleReportTransactions) { + if (iouReportID) { Navigation.navigate(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: iouReportID, backTo})); - // Defer so the thread RHP stacks on top of the expense report navigation above. - setNavigationActionToMicrotaskQueue(() => { - setActiveTransactionIDs([transactionID]).then(() => { - Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: threadReportID, backTo: Navigation.getActiveRoute()})); + + // A multi-transaction report opens super wide (see SearchMoneyRequestReportPage's `shouldShowSuperWideRHP`), + // so stack the specific thread RHP on top of it. A single-transaction report collapses to the thread + // itself, so the expense report navigation above already lands on it. + const hasMultipleReportTransactions = + getReportTransactions(iouReportID).filter((transaction) => transaction?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length > 1; + if (hasMultipleReportTransactions) { + // Defer so the thread RHP stacks on top of the expense report navigation above. + setNavigationActionToMicrotaskQueue(() => { + setActiveTransactionIDs([transactionID]).then(() => { + Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: threadReportID, backTo: Navigation.getActiveRoute()})); + }); }); - }); + } return; } + // Tracked/unreported expense: there's no expense report to open, so go straight to the transaction thread. setActiveTransactionIDs([transactionID]).then(() => { Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: threadReportID, backTo})); }); diff --git a/tests/unit/navigateAfterExpenseCreateTest.ts b/tests/unit/navigateAfterExpenseCreateTest.ts index da2143e4e440..b28185d1f826 100644 --- a/tests/unit/navigateAfterExpenseCreateTest.ts +++ b/tests/unit/navigateAfterExpenseCreateTest.ts @@ -262,15 +262,24 @@ describe('navigateAfterExpenseCreate', () => { expect(Navigation.navigate).toHaveBeenNthCalledWith(2, ROUTES.SEARCH_REPORT.getRoute({reportID: 'thread-1', backTo: ''})); }); - // SEARCH_REPORT is the only wide-RHP screen that renders a transaction thread with its report - // underneath; on the Inbox tab it overlays the current tab rather than switching to Spend. - it('should open the transaction thread as a wide RHP when the user is on the Inbox tab on a wide layout and the report has a single transaction', async () => { + it('should open the expense report when the user is on the Inbox tab on a wide layout and the report has a single transaction', () => { mockIsReportTopmostSplitNavigator.mockReturnValue(true); mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); mockGetIsNarrowLayout.mockReturnValue(false); mockGetReportTransactions.mockReturnValue([{transactionID: 'txn-1'}] as Transaction[]); navigateToTransactionThread({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'}); + + expect(Navigation.navigate).toHaveBeenCalledTimes(1); + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: 'iou-1', backTo: ''})); + }); + + it('should open the transaction thread directly when there is no expense report (tracked/unreported expense)', async () => { + mockIsReportTopmostSplitNavigator.mockReturnValue(true); + mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); + mockGetIsNarrowLayout.mockReturnValue(false); + + navigateToTransactionThread({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: undefined}); await waitForBatchedUpdates(); expect(Navigation.navigate).toHaveBeenCalledTimes(1); From 97fdc55ccdf049dd1a6d9b4aa6834209d037dbba Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Thu, 16 Jul 2026 15:21:16 +0200 Subject: [PATCH 19/28] Rename navigateToTransactionThread to navigateToCreatedExpense --- .../helpers/navigateAfterExpenseCreate.ts | 10 +++++----- tests/unit/navigateAfterExpenseCreateTest.ts | 20 +++++++++---------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index 94f19968d7e4..7c0692874d17 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -91,7 +91,7 @@ type ShowExpenseAddedGrowlParams = { buildTransactionThreadParams?: BuildTransactionThreadParams; }; -type NavigateToTransactionThreadParams = { +type NavigateToCreatedExpenseParams = { /** The transaction thread report to open. */ threadReportID: string; @@ -103,7 +103,7 @@ type NavigateToTransactionThreadParams = { }; /** - * Navigates to a just-created transaction thread, choosing the destination from the surface the user + * Navigates to a just-created expense, choosing the destination from the surface the user * is currently looking at (they may have switched tabs while the growl was up) * - Spend tab: the transaction thread RHP within Spend (report shown underneath via the wide RHP) * - Inbox tab, narrow layout: the transaction thread as a full report view @@ -111,7 +111,7 @@ type NavigateToTransactionThreadParams = { * wide with the specific thread RHP stacked on top; a single-transaction report collapses to the thread itself * - Inbox tab, wide layout, tracked/unreported expense (no expense report): the transaction thread RHP directly */ -function navigateToTransactionThread({threadReportID, transactionID, iouReportID}: NavigateToTransactionThreadParams) { +function navigateToCreatedExpense({threadReportID, transactionID, iouReportID}: NavigateToCreatedExpenseParams) { const backTo = Navigation.getActiveRoute(); const openOnInbox = isReportTopmostSplitNavigator() && !isSearchTopmostFullScreenRoute(); @@ -229,7 +229,7 @@ function showExpenseAddedGrowl({ Log.warn('[showExpenseAddedGrowl] Unable to resolve transaction thread reportID on View press.'); return; } - navigateToTransactionThread({threadReportID, transactionID, iouReportID}); + navigateToCreatedExpense({threadReportID, transactionID, iouReportID}); }; // eslint-disable-next-line @typescript-eslint/no-deprecated -- imperative module (not a React component); no useLocalize hook available here Growl.success(growlMessage, CONST.GROWL.DURATION_WITH_ACTION, {label: translateLocal('common.view'), onPress: navigateToExpenseRHP}); @@ -398,5 +398,5 @@ function navigateAfterExpenseCreate({ } export default navigateAfterExpenseCreate; -export {surfaceExpenseCreatedFeedback, navigateToTransactionThread}; +export {surfaceExpenseCreatedFeedback, navigateToCreatedExpense}; export type {BuildTransactionThreadParams}; diff --git a/tests/unit/navigateAfterExpenseCreateTest.ts b/tests/unit/navigateAfterExpenseCreateTest.ts index b28185d1f826..1d8f0ff6b50d 100644 --- a/tests/unit/navigateAfterExpenseCreateTest.ts +++ b/tests/unit/navigateAfterExpenseCreateTest.ts @@ -1,5 +1,5 @@ import Growl from '@libs/Growl'; -import navigateAfterExpenseCreate, {navigateToTransactionThread} from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; +import navigateAfterExpenseCreate, {navigateToCreatedExpense} from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; import Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; @@ -16,7 +16,7 @@ const mockGetTrackingState = jest.fn(); // Declared but assigned after jest.mock hoisting - use require() to access the mock in tests let mockSetPendingSubmitFollowUpAction: jest.Mock; const mockGetCurrentSearchQueryJSON = jest.fn(); -const mockGetReportTransactions = jest.fn(); +const mockGetReportTransactions = jest.fn>, [string | undefined]>(); jest.mock('@libs/Navigation/helpers/isReportTopmostSplitNavigator', () => () => mockIsReportTopmostSplitNavigator()); jest.mock('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute', () => () => mockIsSearchTopmostFullScreenRoute()); @@ -226,12 +226,12 @@ describe('navigateAfterExpenseCreate', () => { expect(Navigation.navigate).not.toHaveBeenCalled(); }); - describe('navigateToTransactionThread', () => { + describe('navigateToCreatedExpense', () => { it('should open the transaction thread in the Spend RHP when the user is on the Spend tab', async () => { mockIsReportTopmostSplitNavigator.mockReturnValue(false); mockIsSearchTopmostFullScreenRoute.mockReturnValue(true); - navigateToTransactionThread({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'}); + navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'}); await waitForBatchedUpdates(); expect(Navigation.navigate).toHaveBeenCalledTimes(1); @@ -243,7 +243,7 @@ describe('navigateAfterExpenseCreate', () => { mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); mockGetIsNarrowLayout.mockReturnValue(true); - navigateToTransactionThread({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'}); + navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'}); expect(Navigation.navigate).toHaveBeenCalledTimes(1); expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute('thread-1', undefined, undefined, '')); @@ -253,9 +253,9 @@ describe('navigateAfterExpenseCreate', () => { mockIsReportTopmostSplitNavigator.mockReturnValue(true); mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); mockGetIsNarrowLayout.mockReturnValue(false); - mockGetReportTransactions.mockReturnValue([{transactionID: 'txn-1'}, {transactionID: 'txn-2'}] as Transaction[]); + mockGetReportTransactions.mockReturnValue([{transactionID: 'txn-1'}, {transactionID: 'txn-2'}]); - navigateToTransactionThread({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'}); + navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'}); await waitForBatchedUpdates(); expect(Navigation.navigate).toHaveBeenNthCalledWith(1, ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: 'iou-1', backTo: ''})); @@ -266,9 +266,9 @@ describe('navigateAfterExpenseCreate', () => { mockIsReportTopmostSplitNavigator.mockReturnValue(true); mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); mockGetIsNarrowLayout.mockReturnValue(false); - mockGetReportTransactions.mockReturnValue([{transactionID: 'txn-1'}] as Transaction[]); + mockGetReportTransactions.mockReturnValue([{transactionID: 'txn-1'}]); - navigateToTransactionThread({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'}); + navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'}); expect(Navigation.navigate).toHaveBeenCalledTimes(1); expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: 'iou-1', backTo: ''})); @@ -279,7 +279,7 @@ describe('navigateAfterExpenseCreate', () => { mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); mockGetIsNarrowLayout.mockReturnValue(false); - navigateToTransactionThread({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: undefined}); + navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: undefined}); await waitForBatchedUpdates(); expect(Navigation.navigate).toHaveBeenCalledTimes(1); From b308b000f3f4dba9e8e17b037354c37082078a63 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Thu, 16 Jul 2026 16:34:51 +0200 Subject: [PATCH 20/28] Remove the getReportTransactions fallback from showExpenseAddedGrowl --- .../Navigation/helpers/navigateAfterExpenseCreate.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index 7c0692874d17..7c64b15e83c0 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -78,7 +78,7 @@ type ShowExpenseAddedGrowlParams = { /** IOU report the transaction landed in, used to resolve the growl's "View" deep link. */ iouReportID?: string; - /** The created transaction. */ + /** The created transaction's ID. */ transactionID?: string; /** Transaction thread report the growl's "View" action opens. */ @@ -87,7 +87,7 @@ type ShowExpenseAddedGrowlParams = { /** Whether this confirmation is for an invoice (changes the toast copy from "Expense added"). */ isInvoice?: boolean; - /** Data the "View" action needs to build the transaction thread. */ + /** Data the "View" action needs to build the transaction thread when no thread ID is known yet. */ buildTransactionThreadParams?: BuildTransactionThreadParams; }; @@ -178,9 +178,6 @@ function showExpenseAddedGrowl({ // eslint-disable-next-line @typescript-eslint/no-deprecated -- imperative module (not a React component); no useLocalize hook available here const growlMessage = isInvoice ? translateLocal('iou.invoiceSent') : translateLocal('iou.expenseAdded'); - const getTransaction = (): Transaction | undefined => - buildTransactionThreadParams?.transaction ?? (iouReportID ? getReportTransactions(iouReportID).find((transaction) => transaction.transactionID === transactionID) : undefined); - const buildThreadFromOnyx = (): string | undefined => { const iouReport = iouReportID ? getReportOrDraftReport(iouReportID) : undefined; const iouAction = iouReportID ? getIOUActionForReportID(iouReportID, transactionID) : undefined; @@ -193,7 +190,7 @@ function showExpenseAddedGrowl({ betas: buildTransactionThreadParams?.betas, iouReport, iouReportAction: iouAction, - transaction: getTransaction(), + transaction: buildTransactionThreadParams?.transaction, }); threadReportID = optimisticThread?.reportID; } else { @@ -210,7 +207,7 @@ function showExpenseAddedGrowl({ if (providedTransactionThreadReportID ?? iouAction?.childReportID) { return true; } - return !!(iouReportID && getReportOrDraftReport(iouReportID)) || !!getTransaction(); + return !!(iouReportID && getReportOrDraftReport(iouReportID)) || !!buildTransactionThreadParams?.transaction; }; const showGrowl = () => { From 99a4af2ee48c6d26d36a6eb6d546fa60a35bbc6f Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Thu, 16 Jul 2026 18:26:56 +0200 Subject: [PATCH 21/28] Replace deprecated allReports usage in showExpenseAddedGrowl with Onyx.connectWithoutView --- .../helpers/navigateAfterExpenseCreate.ts | 103 +++++++++++------- src/libs/actions/IOU/Split.ts | 16 ++- tests/unit/navigateAfterExpenseCreateTest.ts | 7 +- 3 files changed, 78 insertions(+), 48 deletions(-) diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index 7c64b15e83c0..63b0dc68ead6 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -8,16 +8,21 @@ import {translateLocal} from '@libs/Localize'; import Log from '@libs/Log'; import {getPreservedNavigatorState} from '@libs/Navigation/AppNavigator/createSplitNavigator/usePreserveNavigatorState'; import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; -import {getIOUActionForReportID} from '@libs/ReportActionsUtils'; -import {getReportOrDraftReport, getReportTransactions} from '@libs/ReportUtils'; +import {getIOUActionForTransactionID} from '@libs/ReportActionsUtils'; +import {getReportTransactions} from '@libs/ReportUtils'; import {buildCannedSearchQuery, getCurrentSearchQueryJSON} from '@libs/SearchQueryUtils'; import {setPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; +import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; -import type {Beta, IntroSelected, Transaction} from '@src/types/onyx'; +import type {Beta, IntroSelected, Report, ReportAction, ReportActions, Transaction} from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import Onyx from 'react-native-onyx'; import dismissModalAndOpenReportInInboxTab from './dismissModalAndOpenReportInInboxTab'; import getTopmostFullScreenRoute from './getTopmostFullScreenRoute'; @@ -163,6 +168,10 @@ function navigateToCreatedExpense({threadReportID, transactionID, iouReportID}: * its OpenReport write) when the user actually presses "View" — matching how every other thread * navigation entry point builds the thread at navigation time, and keeping untapped growls free * of Onyx/API side effects. + * + * Callers must only invoke this for expenses that resolve to a single transaction thread (a provided + * thread ID, an IOU report to build one from, or the created transaction). Splits fan out into one + * thread per participant, so they skip the growl at the call site rather than showing a dead "View". */ function showExpenseAddedGrowl({ iouReportID, @@ -178,55 +187,69 @@ function showExpenseAddedGrowl({ // eslint-disable-next-line @typescript-eslint/no-deprecated -- imperative module (not a React component); no useLocalize hook available here const growlMessage = isInvoice ? translateLocal('iou.invoiceSent') : translateLocal('iou.expenseAdded'); - const buildThreadFromOnyx = (): string | undefined => { - const iouReport = iouReportID ? getReportOrDraftReport(iouReportID) : undefined; - const iouAction = iouReportID ? getIOUActionForReportID(iouReportID, transactionID) : undefined; - let threadReportID = providedTransactionThreadReportID ?? iouAction?.childReportID; - if (!threadReportID) { - const optimisticThread = createTransactionThreadReport({ - introSelected: buildTransactionThreadParams?.introSelected, - currentUserLogin: buildTransactionThreadParams?.currentUserLogin ?? '', - currentUserAccountID: buildTransactionThreadParams?.currentUserAccountID ?? CONST.DEFAULT_NUMBER_ID, - betas: buildTransactionThreadParams?.betas, - iouReport, - iouReportAction: iouAction, - transaction: buildTransactionThreadParams?.transaction, - }); - threadReportID = optimisticThread?.reportID; - } else { - setOptimisticTransactionThread(threadReportID, iouReport?.reportID, iouAction?.reportActionID, iouReport?.policyID); + // Reads the freshest IOU report and its IOU action - the server-created transaction thread's + // childReportID lands on the IOU action only after creation, so a value captured at growl-show time + // would be stale and would fabricate a duplicate optimistic thread. + const resolveIOUReportAndAction = async (): Promise<{iouReport: OnyxEntry; iouAction: OnyxEntry}> => { + if (!iouReportID) { + return {iouReport: undefined, iouAction: undefined}; } - return threadReportID; + const [iouReport, iouReportActions] = await Promise.all([ + new Promise>((resolve) => { + const connection = Onyx.connectWithoutView({ + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value); + }, + }); + }), + new Promise>((resolve) => { + const connection = Onyx.connectWithoutView({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value); + }, + }); + }), + ]); + const iouAction = getIOUActionForTransactionID(Object.values(iouReportActions ?? {}), transactionID); + return {iouReport, iouAction}; }; - // Pure mirror of buildThreadFromOnyx's requirements, used at show time to decide whether "View" - // can be offered without materializing anything: a known thread ID, or an anchor to build one - // from (the IOU report, or the passed-in transaction for tracked/unreported expenses). - const canResolveThread = (): boolean => { - const iouAction = iouReportID ? getIOUActionForReportID(iouReportID, transactionID) : undefined; - if (providedTransactionThreadReportID ?? iouAction?.childReportID) { - return true; + const buildThreadFromOnyx = async (): Promise => { + const {iouReport, iouAction} = await resolveIOUReportAndAction(); + const threadReportID = providedTransactionThreadReportID ?? iouAction?.childReportID; + if (threadReportID) { + setOptimisticTransactionThread(threadReportID, iouReport?.reportID, iouAction?.reportActionID, iouReport?.policyID); + return threadReportID; } - return !!(iouReportID && getReportOrDraftReport(iouReportID)) || !!buildTransactionThreadParams?.transaction; + const optimisticThread = createTransactionThreadReport({ + introSelected: buildTransactionThreadParams?.introSelected, + currentUserLogin: buildTransactionThreadParams?.currentUserLogin ?? '', + currentUserAccountID: buildTransactionThreadParams?.currentUserAccountID ?? CONST.DEFAULT_NUMBER_ID, + betas: buildTransactionThreadParams?.betas, + iouReport, + iouReportAction: iouAction, + transaction: buildTransactionThreadParams?.transaction, + }); + return optimisticThread?.reportID; }; const showGrowl = () => { - if (!canResolveThread()) { - Log.warn('[showExpenseAddedGrowl] Unable to resolve transaction thread reportID; growl without View.'); - Growl.success(growlMessage, CONST.GROWL.DURATION_LONG); - return; - } const navigateToExpenseRHP = () => { // Everything resolves at press time (not growl-show time): the thread is only materialized // if the user actually taps "View" (with the freshest data available by then), and navigation // matches whatever surface the user is looking at, even if they switched tabs while the // growl was up. - const threadReportID = buildThreadFromOnyx(); - if (!threadReportID) { - Log.warn('[showExpenseAddedGrowl] Unable to resolve transaction thread reportID on View press.'); - return; - } - navigateToCreatedExpense({threadReportID, transactionID, iouReportID}); + buildThreadFromOnyx().then((threadReportID) => { + if (!threadReportID) { + Log.warn('[showExpenseAddedGrowl] Unable to resolve transaction thread reportID on View press.'); + return; + } + navigateToCreatedExpense({threadReportID, transactionID, iouReportID}); + }); }; // eslint-disable-next-line @typescript-eslint/no-deprecated -- imperative module (not a React component); no useLocalize hook available here Growl.success(growlMessage, CONST.GROWL.DURATION_WITH_ACTION, {label: translateLocal('common.view'), onPress: navigateToExpenseRHP}); diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index e1013df8c046..d9b125e34b0a 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -2272,12 +2272,16 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest } else { // Dismiss-first paths (orchestrator owns navigation). Surface feedback wherever the user lands: // highlight the new row for in-report adds, otherwise the "Expense added" growl with "View". - surfaceExpenseCreatedFeedback({ - iouReportID: parameters.iouReportID, - transactionID: parameters.transactionID, - transactionThreadReportID: parameters.transactionThreadReportID, - isMoneyRequestReport, - }); + // Splits fan out into one transaction thread per participant, so there's no single thread for + // "View" to open - skip the growl for them entirely. + if (iouType !== CONST.IOU.TYPE.SPLIT) { + surfaceExpenseCreatedFeedback({ + iouReportID: parameters.iouReportID, + transactionID: parameters.transactionID, + transactionThreadReportID: parameters.transactionThreadReportID, + isMoneyRequestReport, + }); + } removeDraftTransaction(CONST.IOU.OPTIMISTIC_TRANSACTION_ID); } diff --git a/tests/unit/navigateAfterExpenseCreateTest.ts b/tests/unit/navigateAfterExpenseCreateTest.ts index 1d8f0ff6b50d..de0cff76596f 100644 --- a/tests/unit/navigateAfterExpenseCreateTest.ts +++ b/tests/unit/navigateAfterExpenseCreateTest.ts @@ -106,7 +106,7 @@ describe('navigateAfterExpenseCreate', () => { expect(Growl.success).not.toHaveBeenCalled(); }); - it('should show the expense added growl on the non-global path when shouldAlwaysShowFeedback is set', () => { + it('should show the expense added growl on the non-global path when shouldAlwaysShowFeedback is set', async () => { navigateAfterExpenseCreate({ activeReportID: 'report-123', transactionID: 'txn-1', @@ -117,6 +117,7 @@ describe('navigateAfterExpenseCreate', () => { }); expect(Navigation.dismissModalWithReport).toHaveBeenCalledWith({reportID: 'report-123'}); + await waitForBatchedUpdates(); const [growlText, growlDuration, growlAction] = jest.mocked(Growl.success).mock.calls.at(0) ?? []; expect(growlText).toEqual(expect.any(String)); expect(growlDuration).toEqual(expect.any(Number)); @@ -124,7 +125,7 @@ describe('navigateAfterExpenseCreate', () => { expect(growlAction?.onPress).toEqual(expect.any(Function)); }); - it('should defer thread materialization to View press instead of growl-show time', () => { + it('should defer thread materialization to View press instead of growl-show time', async () => { const {setOptimisticTransactionThread} = require('@libs/actions/Report') as {setOptimisticTransactionThread: jest.Mock}; navigateAfterExpenseCreate({ @@ -135,12 +136,14 @@ describe('navigateAfterExpenseCreate', () => { hasMultipleTransactions: false, shouldAlwaysShowFeedback: true, }); + await waitForBatchedUpdates(); // Showing the growl must not touch the thread report. expect(setOptimisticTransactionThread).not.toHaveBeenCalled(); const growlAction = jest.mocked(Growl.success).mock.calls.at(0)?.[2]; growlAction?.onPress(); + await waitForBatchedUpdates(); expect(setOptimisticTransactionThread).toHaveBeenCalledWith('thread-1', undefined, undefined, undefined); }); From da8e1a32ef4a89b38699205f4610f8a6be7dfb61 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Thu, 16 Jul 2026 18:43:22 +0200 Subject: [PATCH 22/28] Remove unused scroll-related code --- src/components/Search/ChatSearchView.tsx | 6 +---- .../Search/ExpenseFlatSearchView.tsx | 6 +---- .../Search/ExpenseGroupedSearchView.tsx | 23 +------------------ .../Search/ExpenseReportSearchView.tsx | 6 +---- src/components/Search/TaskSearchView.tsx | 6 +---- src/components/Search/index.tsx | 3 --- src/components/Search/searchViewProps.ts | 9 -------- 7 files changed, 5 insertions(+), 54 deletions(-) diff --git a/src/components/Search/ChatSearchView.tsx b/src/components/Search/ChatSearchView.tsx index 08f6625d7d07..ecc9af8aa876 100644 --- a/src/components/Search/ChatSearchView.tsx +++ b/src/components/Search/ChatSearchView.tsx @@ -4,7 +4,7 @@ import CONST from '@src/CONST'; import type {NativeSyntheticEvent} from 'react-native'; -import React, {useImperativeHandle} from 'react'; +import React from 'react'; import type {SearchListItem} from './SearchList/ListItem/types'; import type {CommonSearchViewProps} from './searchViewProps'; @@ -48,7 +48,6 @@ function ChatSearchView({ onScroll, contentContainerStyle, containerStyle, - ref, }: ChatSearchViewProps) { const {type} = queryJSON; @@ -64,9 +63,6 @@ function ChatSearchView({ const selectedItemsLength = data.reduce((acc, item) => acc + (item.keyForList && selectedTransactions[item.keyForList]?.isSelected ? 1 : 0), 0); const totalItems = data.filter((item) => !isRowDeleted(item)).length; - // Flat data maps 1:1 to the rendered list, so highlight-scroll-to-index is the same as scroll-to-data-index. - useImperativeHandle(ref, () => ({scrollToIndex: scrollToListIndex}), [scrollToListIndex]); - const renderItem = (item: SearchListItem, index: number, isItemFocused: boolean, onFocus?: (event: NativeSyntheticEvent) => void) => ( // Chat rows never animate their exit (only grouped expenses do), so the wrapper just preserves the overflow clip. acc + (item.keyForList && selectedTransactions[item.keyForList]?.isSelected ? 1 : 0), 0); const totalItems = data.filter((item) => !isRowDeleted(item)).length; - // Flat data maps 1:1 to the rendered list, so highlight-scroll-to-index is the same as scroll-to-data-index. - useImperativeHandle(ref, () => ({scrollToIndex: scrollToListIndex}), [scrollToListIndex]); - const renderItem = (item: SearchListItem, index: number, isItemFocused: boolean, onFocus?: (event: NativeSyntheticEvent) => void) => { const isDisabled = isRowDeleted(item); // Only expense row exits animate; invoice and trip transaction lists do not (matches the legacy per-type gate). diff --git a/src/components/Search/ExpenseGroupedSearchView.tsx b/src/components/Search/ExpenseGroupedSearchView.tsx index 45f1963aec1c..dcafea925a40 100644 --- a/src/components/Search/ExpenseGroupedSearchView.tsx +++ b/src/components/Search/ExpenseGroupedSearchView.tsx @@ -14,7 +14,7 @@ import {columnsSelector} from '@src/selectors/AdvancedSearchFiltersForm'; import type {NativeSyntheticEvent} from 'react-native'; -import React, {useImperativeHandle, useState} from 'react'; +import React, {useState} from 'react'; import type {SearchListItem} from './SearchList/ListItem/types'; import type {CommonSearchViewProps, TransactionViewExtras} from './searchViewProps'; @@ -93,7 +93,6 @@ function ExpenseGroupedSearchView({ onScroll, contentContainerStyle, containerStyle, - ref, }: ExpenseGroupedSearchViewProps) { const {type, groupBy} = queryJSON; const {isLargeScreenWidth} = useResponsiveLayout(); @@ -154,26 +153,6 @@ function ExpenseGroupedSearchView({ const firstVisibleIndex = listData.findIndex(isItemVisible); const lastVisibleIndex = listData.findLastIndex(isItemVisible); - // The router highlights by source-data index; remap it to the split-list index before scrolling. - useImperativeHandle( - ref, - () => ({ - scrollToIndex: (index: number, animated = true) => { - if (!shouldSplit) { - scrollToListIndex(index, animated); - return; - } - const item = data.at(index); - if (!item) { - return; - } - const splitIndex = item.keyForList ? listData.findIndex((listItem) => listItem.keyForList === `header_${item.keyForList}`) : -1; - scrollToListIndex(splitIndex !== -1 ? splitIndex : index, animated); - }, - }), - [data, listData, shouldSplit, scrollToListIndex], - ); - const getItemType = (item: SearchListItem) => { if (!shouldSplit) { return undefined; diff --git a/src/components/Search/ExpenseReportSearchView.tsx b/src/components/Search/ExpenseReportSearchView.tsx index d594fb1345a6..8a1e509cc4ec 100644 --- a/src/components/Search/ExpenseReportSearchView.tsx +++ b/src/components/Search/ExpenseReportSearchView.tsx @@ -6,7 +6,7 @@ import CONST from '@src/CONST'; import type {NativeSyntheticEvent} from 'react-native'; -import React, {useImperativeHandle} from 'react'; +import React from 'react'; import type {SearchListItem} from './SearchList/ListItem/types'; import type {CommonSearchViewProps} from './searchViewProps'; @@ -54,7 +54,6 @@ function ExpenseReportSearchView({ onScroll, contentContainerStyle, containerStyle, - ref, }: ExpenseReportSearchViewProps) { const {type} = queryJSON; @@ -91,9 +90,6 @@ function ExpenseReportSearchView({ emptyReports.reduce((acc, item) => acc + (isRowSelected(item.keyForList, selectedTransactions) ? 1 : 0), 0); const totalItems = flattenedTransactions.filter((item) => !isRowDeleted(item)).length + emptyReports.filter((item) => !isRowDeleted(item)).length; - // Report data maps 1:1 to the rendered list, so highlight-scroll-to-index is the same as scroll-to-data-index. - useImperativeHandle(ref, () => ({scrollToIndex: scrollToListIndex}), [scrollToListIndex]); - const renderItem = (item: SearchListItem, index: number, isItemFocused: boolean, onFocus?: (event: NativeSyntheticEvent) => void) => ( // Report rows never animate their exit (only grouped expenses do), so the wrapper just preserves the overflow clip. acc + (item.keyForList && selectedTransactions[item.keyForList]?.isSelected ? 1 : 0), 0); const totalItems = data.filter((item) => !isRowDeleted(item)).length; - // Flat data maps 1:1 to the rendered list, so highlight-scroll-to-index is the same as scroll-to-data-index. - useImperativeHandle(ref, () => ({scrollToIndex: scrollToListIndex}), [scrollToListIndex]); - const renderItem = (item: SearchListItem, index: number, isItemFocused: boolean, onFocus?: (event: NativeSyntheticEvent) => void) => ( // Task rows never animate their exit (only grouped expenses do), so the wrapper just preserves the overflow clip. | null>(null); const savedSearchSelector = useCallback((searches: OnyxEntry) => searches?.[hash], [hash]); const [savedSearch] = useOnyx(ONYXKEYS.SAVED_SEARCHES, { @@ -1040,7 +1038,6 @@ function Search({ const isTransactionListView = type !== CONST.SEARCH.DATA_TYPES.CHAT && type !== CONST.SEARCH.DATA_TYPES.TASK && type !== CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; const commonViewProps: CommonSearchViewProps = { - ref: searchListRef, queryJSON, data: stableSortedData, columns: columnsToShow, diff --git a/src/components/Search/searchViewProps.ts b/src/components/Search/searchViewProps.ts index 2f4125e0d1aa..71c9abd59de5 100644 --- a/src/components/Search/searchViewProps.ts +++ b/src/components/Search/searchViewProps.ts @@ -4,17 +4,11 @@ import type {ModifiedMouseEvent} from '@libs/Navigation/helpers/openInternalRout import type {CardList} from '@src/types/onyx'; import type React from 'react'; -import type {ForwardedRef} from 'react'; import type {NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle} from 'react-native'; import type {SearchListItem} from './SearchList/ListItem/types'; import type {SearchColumnType, SearchQueryJSON} from './types'; -/** Imperative handle the router uses for highlight-driven scrolling. */ -type SearchListHandle = { - scrollToIndex: (index: number, animated?: boolean) => void; -}; - /** * Props shared by every dedicated Search view. The router (``) builds these once and spreads * them into whichever view renders; each view narrows to its own extras on top of this shape. @@ -67,9 +61,6 @@ type CommonSearchViewProps = { /** Outer container style for the list wrapper. */ containerStyle: StyleProp; - - /** Imperative handle for highlight-driven scrolling, set by the router. */ - ref?: ForwardedRef; }; /** Extra props specific to the transaction views (expense/invoice/trip): attendee tracking and card rendering. */ From 2989e8ddfd9344d5495dc6be2c05a7c424da8c3d Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Thu, 16 Jul 2026 20:03:01 +0200 Subject: [PATCH 23/28] Bring back refetching group snapshot on new transactions --- .../Search/ExpenseGroupedSearchView.tsx | 27 ++++++++++- .../ListItem/GroupChildrenContainer.tsx | 2 + .../ListItem/GroupChildrenContent.tsx | 10 ++++ .../ListItem/TransactionGroupListItem.tsx | 19 ++++++++ .../Search/SearchList/ListItem/types.ts | 2 + src/components/Search/index.tsx | 3 +- src/components/Search/searchViewProps.ts | 5 +- src/hooks/useSearchAutoRefetch.ts | 13 +++++ src/libs/SearchUIUtils.ts | 47 +++++++++++++++++++ tests/unit/Search/ChatSearchViewTest.tsx | 1 + .../unit/Search/ExpenseFlatSearchViewTest.tsx | 1 + .../Search/ExpenseGroupedSearchViewTest.tsx | 1 + .../Search/ExpenseReportSearchViewTest.tsx | 1 + tests/unit/Search/TaskSearchViewTest.tsx | 1 + 14 files changed, 129 insertions(+), 4 deletions(-) diff --git a/src/components/Search/ExpenseGroupedSearchView.tsx b/src/components/Search/ExpenseGroupedSearchView.tsx index dcafea925a40..57b0798ecd5a 100644 --- a/src/components/Search/ExpenseGroupedSearchView.tsx +++ b/src/components/Search/ExpenseGroupedSearchView.tsx @@ -4,13 +4,14 @@ import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import getPlatform from '@libs/getPlatform'; -import {isTransactionGroupListItemType, splitGroupsIntoPairs} from '@libs/SearchUIUtils'; +import {isTransactionGroupListItemType, isTransactionMatchWithGroupItem, splitGroupsIntoPairs} from '@libs/SearchUIUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {columnsSelector} from '@src/selectors/AdvancedSearchFiltersForm'; +import type {Transaction} from '@src/types/onyx'; import type {NativeSyntheticEvent} from 'react-native'; @@ -18,7 +19,7 @@ import React, {useState} from 'react'; import type {SearchListItem} from './SearchList/ListItem/types'; import type {CommonSearchViewProps, TransactionViewExtras} from './searchViewProps'; -import type {SelectedTransactions} from './types'; +import type {SearchQueryJSON, SelectedTransactions} from './types'; import useSearchListViewState from './hooks/useSearchListViewState'; import AnimatedExitRow from './primitives/AnimatedExitRow'; @@ -64,6 +65,21 @@ function buildSplitGroupData(data: SearchListItem[], shouldSplitGroups: boolean) }; } +/** Maps each group's `keyForList` to the id of a newly-added transaction inside it, used to refetch the group's child snapshot. */ +function buildNewTransactionIDMap(data: SearchListItem[], newTransactions: Transaction[], groupBy: SearchQueryJSON['groupBy']) { + const map = new Map(); + if (newTransactions.length === 0) { + return map; + } + for (const item of data) { + const matchedTransactionID = newTransactions.find((transaction) => isTransactionMatchWithGroupItem(transaction, item, groupBy))?.transactionID; + if (matchedTransactionID && item.keyForList) { + map.set(item.keyForList, matchedTransactionID); + } + } + return map; +} + /** * The grouped-expense Search list (expense search with a valid group-by). * @@ -86,6 +102,7 @@ function ExpenseGroupedSearchView({ SearchTableHeader: searchTableHeader, tableHeaderVisible, hasLoadedAllTransactions, + newTransactions, onSelectRow, ListFooterComponent, onEndReached, @@ -139,6 +156,8 @@ function ExpenseGroupedSearchView({ scrollToListIndex, } = useSearchListViewState({data, listData, isMobileSelectionModeEnabled, onSelectRow}); + const newTransactionIDByItemKey = buildNewTransactionIDMap(data, newTransactions, groupBy); + // Selection is tracked per child transaction (plus empty groups), so flatten each group's transactions. const groupItems = data.filter(isTransactionGroupListItemType); const flattenedTransactions = groupItems.flatMap((item) => item.transactions); @@ -205,6 +224,7 @@ function ExpenseGroupedSearchView({ if (isGroupChildrenContainerItem(item)) { const originalKey = (item.keyForList ?? '').replace('children_', ''); + const containerNewTransactionID = item.keyForList ? newTransactionIDByItemKey.get(originalKey) : undefined; return ( { + if (!newTransactionID || !isExpanded) { + return; + } + refreshTransactions(); + // Only refresh when a new transaction is created in this group — refreshTransactions is excluded to avoid infinite loops + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [newTransactionID, isExpanded]); + useEffect(() => { if (!isExpanded || isExpenseReportType) { return; diff --git a/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx b/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx index dbdca2050772..2ae26d74a261 100644 --- a/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx @@ -89,6 +89,7 @@ function TransactionGroupListItem({ columns, groupBy, searchType, + newTransactionID, lastPaymentMethod, personalPolicyID, nonPersonalAndWorkspaceCards, @@ -245,6 +246,24 @@ function TransactionGroupListItem({ ]; const pressableRef = useRef(null); + useEffect(() => { + if (!newTransactionID || !isExpanded) { + return; + } + if (!groupItem.transactionsQueryJSON) { + return; + } + + search({ + queryJSON: groupItem.transactionsQueryJSON, + searchKey: undefined, + offset: 0, + shouldCalculateTotals: false, + isLoading: !!transactionsSnapshot?.search?.isLoading, + isOffline, + }); + }, [newTransactionID, isExpanded, groupItem.transactionsQueryJSON, isOffline, transactionsSnapshot?.search?.isLoading]); + const wasScreenFocusedRef = useRef(isScreenFocused); useEffect(() => { const didReturnToScreen = wasScreenFocusedRef.current === false && isScreenFocused === true; diff --git a/src/components/Search/SearchList/ListItem/types.ts b/src/components/Search/SearchList/ListItem/types.ts index 731c9377e093..5bfd75262fa7 100644 --- a/src/components/Search/SearchList/ListItem/types.ts +++ b/src/components/Search/SearchList/ListItem/types.ts @@ -478,6 +478,7 @@ type TransactionGroupListItemProps = ListItemProps void; nonPersonalAndWorkspaceCards?: CardList; onUndelete?: (transaction: Transaction) => void; + newTransactionID?: string; bankAccountList?: OnyxEntry; cardFeeds?: OnyxCollection; conciergeReportID?: string; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index bbda93e890d0..07b6da5c8543 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -205,7 +205,7 @@ function Search({ clearSelectedTransactions(); }, [validGroupBy, prevValidGroupBy, clearSelectedTransactions]); - useSearchAutoRefetch({ + const {newTransactions} = useSearchAutoRefetch({ searchResults, transactions, previousTransactions, @@ -1052,6 +1052,7 @@ function Search({ ListFooterComponent: listFooterComponent, onLayout, isMobileSelectionModeEnabled, + newTransactions, hasLoadedAllTransactions, isActionColumnWide: isTask || hasDeletedTransaction, }; diff --git a/src/components/Search/searchViewProps.ts b/src/components/Search/searchViewProps.ts index 71c9abd59de5..bc9d48696982 100644 --- a/src/components/Search/searchViewProps.ts +++ b/src/components/Search/searchViewProps.ts @@ -1,7 +1,7 @@ import type {TransactionPreviewData} from '@libs/actions/Search'; import type {ModifiedMouseEvent} from '@libs/Navigation/helpers/openInternalRouteInNewTab'; -import type {CardList} from '@src/types/onyx'; +import type {CardList, Transaction} from '@src/types/onyx'; import type React from 'react'; import type {NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle} from 'react-native'; @@ -41,6 +41,9 @@ type CommonSearchViewProps = { /** Whether everything has been loaded (gates the fully-checked select-all state). */ hasLoadedAllTransactions?: boolean; + /** Transactions newly added since the previous render, used to refetch the matching expanded group's snapshot. */ + newTransactions: Transaction[]; + /** The navigation handler for a row tap (owned by the router). */ onSelectRow: (item: SearchListItem, transactionPreviewData?: TransactionPreviewData, event?: ModifiedMouseEvent) => void; diff --git a/src/hooks/useSearchAutoRefetch.ts b/src/hooks/useSearchAutoRefetch.ts index 821ab8254df8..1633bf45ff65 100644 --- a/src/hooks/useSearchAutoRefetch.ts +++ b/src/hooks/useSearchAutoRefetch.ts @@ -63,6 +63,17 @@ function useSearchAutoRefetch({ const isChat = queryJSON.type === CONST.SEARCH.DATA_TYPES.CHAT; const searchResultsData = searchResults?.data; + const prevTransactionsIDs = Object.keys(previousTransactions ?? {}); + const newTransactions: Transaction[] = []; + if (prevTransactionsIDs.length > 0) { + const previousIDs = new Set(prevTransactionsIDs); + for (const [id, transaction] of Object.entries(transactions ?? {})) { + if (!previousIDs.has(id) && transaction) { + newTransactions.push(transaction); + } + } + } + useEffect(() => { const previousTransactionIDsLocal = Object.keys(previousTransactions ?? {}); const transactionsIDs = Object.keys(transactions ?? {}); @@ -145,6 +156,8 @@ function useSearchAutoRefetch({ searchTriggeredRef.current = false; }, [searchResults?.search?.isLoading, searchResultsData]); + + return {newTransactions}; } function getTransactionIDFromValue(value: unknown): string | undefined { diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 0bc89b6d5403..4662b5aae58f 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -6429,6 +6429,52 @@ function splitGroupsIntoPairs(data: SearchListItem[]): {splitData: SearchListIte return {splitData, stickyHeaderIndices}; } +/** + * Checks whether a transaction belongs to the given group list item, based on the active groupBy. + */ +function isTransactionMatchWithGroupItem(transaction: OnyxTypes.Transaction, groupItem: SearchListItem, groupBy: SearchGroupBy | undefined): boolean { + if (groupBy === CONST.SEARCH.GROUP_BY.CARD) { + return transaction.cardID === (groupItem as TransactionCardGroupListItemType).cardID; + } + if (groupBy === CONST.SEARCH.GROUP_BY.FROM) { + return !!transaction.transactionID; + } + if (groupBy === CONST.SEARCH.GROUP_BY.CATEGORY) { + return (transaction.category ?? '') === ((groupItem as TransactionCategoryGroupListItemType).category ?? ''); + } + if (groupBy === CONST.SEARCH.GROUP_BY.MERCHANT) { + return (transaction.merchant ?? '') === ((groupItem as TransactionMerchantGroupListItemType).merchant ?? ''); + } + if (groupBy === CONST.SEARCH.GROUP_BY.MONTH) { + const monthGroup = groupItem as TransactionMonthGroupListItemType; + const transactionDateString = transaction.modifiedCreated ?? transaction.created ?? ''; + return DateUtils.isDateStringInMonth(transactionDateString, monthGroup.year, monthGroup.month); + } + if (groupBy === CONST.SEARCH.GROUP_BY.WEEK) { + const weekGroup = groupItem as TransactionWeekGroupListItemType; + const transactionDateString = transaction.modifiedCreated ?? transaction.created ?? ''; + const datePart = transactionDateString.substring(0, 10); + const {start: weekStart, end: weekEnd} = DateUtils.getWeekDateRange(weekGroup.week); + return datePart >= weekStart && datePart <= weekEnd; + } + if (groupBy === CONST.SEARCH.GROUP_BY.YEAR) { + const yearGroup = groupItem as TransactionYearGroupListItemType; + const transactionDateString = transaction.modifiedCreated ?? transaction.created ?? ''; + const transactionYear = parseInt(transactionDateString.substring(0, 4), 10); + return transactionYear === yearGroup.year; + } + if (groupBy === CONST.SEARCH.GROUP_BY.QUARTER) { + const quarterGroup = groupItem as TransactionQuarterGroupListItemType; + const transactionDateString = transaction.modifiedCreated ?? transaction.created ?? ''; + const transactionYear = parseInt(transactionDateString.substring(0, 4), 10); + const transactionMonth = parseInt(transactionDateString.substring(5, 7), 10); + // Calculate which quarter the transaction belongs to (1-4) + const transactionQuarter = Math.floor((transactionMonth - 1) / 3) + 1; + return transactionYear === quarterGroup.year && transactionQuarter === quarterGroup.quarter; + } + return false; +} + export { getSearchBulkEditPolicyID, getSuggestedSearches, @@ -6436,6 +6482,7 @@ export { getSuggestedSearchesVisibility, getSortedSections, getViolationsFromSearchData, + isTransactionMatchWithGroupItem, isTransactionGroupListItemType, isTransactionReportGroupListItemType, isTransactionCategoryGroupListItemType, diff --git a/tests/unit/Search/ChatSearchViewTest.tsx b/tests/unit/Search/ChatSearchViewTest.tsx index 08b88e1eca29..649e8a126d39 100644 --- a/tests/unit/Search/ChatSearchViewTest.tsx +++ b/tests/unit/Search/ChatSearchViewTest.tsx @@ -183,6 +183,7 @@ function renderView(overrides: RenderOverrides = {}) { isMobileSelectionModeEnabled={overrides.isMobileSelectionModeEnabled ?? false} tableHeaderVisible={overrides.tableHeaderVisible ?? false} hasLoadedAllTransactions={overrides.hasLoadedAllTransactions ?? true} + newTransactions={[]} onSelectRow={onSelectRow} onEndReached={onEndReached} onLayout={onLayout} diff --git a/tests/unit/Search/ExpenseFlatSearchViewTest.tsx b/tests/unit/Search/ExpenseFlatSearchViewTest.tsx index 168b8c82ee09..bf91cdef2427 100644 --- a/tests/unit/Search/ExpenseFlatSearchViewTest.tsx +++ b/tests/unit/Search/ExpenseFlatSearchViewTest.tsx @@ -192,6 +192,7 @@ function renderView(overrides: RenderOverrides = {}) { isMobileSelectionModeEnabled={overrides.isMobileSelectionModeEnabled ?? false} tableHeaderVisible={overrides.tableHeaderVisible ?? false} hasLoadedAllTransactions={overrides.hasLoadedAllTransactions ?? true} + newTransactions={[]} onSelectRow={onSelectRow} onEndReached={onEndReached} onLayout={onLayout} diff --git a/tests/unit/Search/ExpenseGroupedSearchViewTest.tsx b/tests/unit/Search/ExpenseGroupedSearchViewTest.tsx index b304f2575114..82831b99305f 100644 --- a/tests/unit/Search/ExpenseGroupedSearchViewTest.tsx +++ b/tests/unit/Search/ExpenseGroupedSearchViewTest.tsx @@ -194,6 +194,7 @@ function renderView(overrides: RenderOverrides = {}) { isMobileSelectionModeEnabled={overrides.isMobileSelectionModeEnabled ?? false} tableHeaderVisible={overrides.tableHeaderVisible ?? false} hasLoadedAllTransactions={overrides.hasLoadedAllTransactions ?? true} + newTransactions={[]} onSelectRow={onSelectRow} onEndReached={onEndReached} onLayout={onLayout} diff --git a/tests/unit/Search/ExpenseReportSearchViewTest.tsx b/tests/unit/Search/ExpenseReportSearchViewTest.tsx index 95abdaafaa04..2e40e0ba8679 100644 --- a/tests/unit/Search/ExpenseReportSearchViewTest.tsx +++ b/tests/unit/Search/ExpenseReportSearchViewTest.tsx @@ -185,6 +185,7 @@ function renderView(overrides: RenderOverrides = {}) { isMobileSelectionModeEnabled={overrides.isMobileSelectionModeEnabled ?? false} tableHeaderVisible={overrides.tableHeaderVisible ?? false} hasLoadedAllTransactions={overrides.hasLoadedAllTransactions ?? true} + newTransactions={[]} onSelectRow={onSelectRow} onEndReached={onEndReached} onLayout={onLayout} diff --git a/tests/unit/Search/TaskSearchViewTest.tsx b/tests/unit/Search/TaskSearchViewTest.tsx index 2526594d2f14..9950eb81df1d 100644 --- a/tests/unit/Search/TaskSearchViewTest.tsx +++ b/tests/unit/Search/TaskSearchViewTest.tsx @@ -183,6 +183,7 @@ function renderView(overrides: RenderOverrides = {}) { isMobileSelectionModeEnabled={overrides.isMobileSelectionModeEnabled ?? false} tableHeaderVisible={overrides.tableHeaderVisible ?? false} hasLoadedAllTransactions={overrides.hasLoadedAllTransactions ?? true} + newTransactions={[]} onSelectRow={onSelectRow} onEndReached={onEndReached} onLayout={onLayout} From 99763aea1998b61707624143464fdfef25075911 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Fri, 17 Jul 2026 10:29:15 +0200 Subject: [PATCH 24/28] Fix growl's "View" navigation for image share to selfDM --- src/pages/Share/SubmitDetailsPage.tsx | 6 ++++- tests/unit/navigateAfterExpenseCreateTest.ts | 24 ++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/pages/Share/SubmitDetailsPage.tsx b/src/pages/Share/SubmitDetailsPage.tsx index 9cf929b95de8..9f59cfc58218 100644 --- a/src/pages/Share/SubmitDetailsPage.tsx +++ b/src/pages/Share/SubmitDetailsPage.tsx @@ -262,6 +262,7 @@ function SubmitDetailsPage({ // `transaction.transactionID` is the draft placeholder; mirror the action's `existingTransactionID ?? rand64()` chain so cleanup nav targets the created expense. const optimisticTransactionID = existingTransactionID ?? rand64(); let iouReportID: string | undefined; + let transactionThreadReportID: string | undefined; // This path skips createTransaction, so log the submit milestone here to map the draft id to the final one. logReceiptSubmitted({ @@ -273,7 +274,7 @@ function SubmitDetailsPage({ }); if (isSelfDM(report)) { - trackExpense({ + const trackExpenseResult = trackExpense({ report: report ?? {reportID: reportOrAccountID}, isDraftPolicy: false, isDraftChatReport: !!reportDraft, @@ -315,6 +316,8 @@ function SubmitDetailsPage({ delegateAccountID, reportActionsList: undefined, }); + iouReportID = trackExpenseResult?.iouReport?.reportID; + transactionThreadReportID = trackExpenseResult?.transactionThreadReportID; } else { const existingTransactionDraft = existingTransactionID ? transactionDrafts?.[existingTransactionID] : undefined; @@ -369,6 +372,7 @@ function SubmitDetailsPage({ draftTransactionIDs, transactionID: optimisticTransactionID, iouReportID, + transactionThreadReportID, isFromGlobalCreate: getIsFromGlobalCreate(transaction), optimisticChatReportID: reportOrAccountID, linkedTrackedExpenseReportAction: transaction.linkedTrackedExpenseReportAction, diff --git a/tests/unit/navigateAfterExpenseCreateTest.ts b/tests/unit/navigateAfterExpenseCreateTest.ts index de0cff76596f..6949e9177a2e 100644 --- a/tests/unit/navigateAfterExpenseCreateTest.ts +++ b/tests/unit/navigateAfterExpenseCreateTest.ts @@ -229,6 +229,30 @@ describe('navigateAfterExpenseCreate', () => { expect(Navigation.navigate).not.toHaveBeenCalled(); }); + describe('Share flow self-DM track path (View action)', () => { + it('should open the transaction thread on View when a transactionThreadReportID is provided but there is no iouReportID (tracked self-DM expense)', async () => { + mockIsReportTopmostSplitNavigator.mockReturnValue(true); + mockGetIsNarrowLayout.mockReturnValue(true); + + navigateAfterExpenseCreate({ + activeReportID: 'self-dm-1', + transactionID: 'txn-1', + transactionThreadReportID: 'thread-1', + iouReportID: undefined, + isFromGlobalCreate: false, + hasMultipleTransactions: false, + shouldAlwaysShowFeedback: true, + }); + await waitForBatchedUpdates(); + + const growlAction = jest.mocked(Growl.success).mock.calls.at(0)?.[2]; + growlAction?.onPress(); + await waitForBatchedUpdates(); + + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute('thread-1', undefined, undefined, '')); + }); + }); + describe('navigateToCreatedExpense', () => { it('should open the transaction thread in the Spend RHP when the user is on the Spend tab', async () => { mockIsReportTopmostSplitNavigator.mockReturnValue(false); From 1f77a2f5e83526fe40ba6f5ef657f23ac0dd51f1 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Fri, 17 Jul 2026 12:08:05 +0200 Subject: [PATCH 25/28] Resolve transaction lazily in showExpenseAddedGrowl instead of passing stale value as argument --- src/libs/IOUAmountSubmission.ts | 1 - .../helpers/navigateAfterExpenseCreate.ts | 19 +++++++++++++++---- src/pages/Share/SubmitDetailsPage.tsx | 1 - ...andleMoneyRequestStepDistanceNavigation.ts | 1 - .../components/ScanSkipConfirmation.tsx | 1 - .../step/confirmation/useExpenseSubmission.ts | 1 - 6 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/libs/IOUAmountSubmission.ts b/src/libs/IOUAmountSubmission.ts index 88e31633052b..25148f6643a9 100644 --- a/src/libs/IOUAmountSubmission.ts +++ b/src/libs/IOUAmountSubmission.ts @@ -385,7 +385,6 @@ function submitSkipConfirmationExpense(args: SubmitAmountArgs, ctx: SubmitAmount currentUserAccountID, betas, introSelected, - transaction, }; if (overrides.shouldHandleNavigation) { cleanupAndNavigateAfterExpenseCreate({ diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index 63b0dc68ead6..1c3c141233c6 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -42,9 +42,6 @@ type BuildTransactionThreadParams = { /** The onboarding intro selection. */ introSelected: IntroSelected | undefined; - - /** The just-created transaction. */ - transaction: Transaction | undefined; }; type NavigateAfterExpenseCreateParams = { @@ -218,6 +215,19 @@ function showExpenseAddedGrowl({ return {iouReport, iouAction}; }; + // Resolve the created transaction fresh from Onyx by its ID rather than capturing an object at + // feedback-creation time. + const resolveTransaction = (): Promise> => + new Promise>((resolve) => { + const connection = Onyx.connectWithoutView({ + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value); + }, + }); + }); + const buildThreadFromOnyx = async (): Promise => { const {iouReport, iouAction} = await resolveIOUReportAndAction(); const threadReportID = providedTransactionThreadReportID ?? iouAction?.childReportID; @@ -225,6 +235,7 @@ function showExpenseAddedGrowl({ setOptimisticTransactionThread(threadReportID, iouReport?.reportID, iouAction?.reportActionID, iouReport?.policyID); return threadReportID; } + const transaction = await resolveTransaction(); const optimisticThread = createTransactionThreadReport({ introSelected: buildTransactionThreadParams?.introSelected, currentUserLogin: buildTransactionThreadParams?.currentUserLogin ?? '', @@ -232,7 +243,7 @@ function showExpenseAddedGrowl({ betas: buildTransactionThreadParams?.betas, iouReport, iouReportAction: iouAction, - transaction: buildTransactionThreadParams?.transaction, + transaction, }); return optimisticThread?.reportID; }; diff --git a/src/pages/Share/SubmitDetailsPage.tsx b/src/pages/Share/SubmitDetailsPage.tsx index 9f59cfc58218..372c54118215 100644 --- a/src/pages/Share/SubmitDetailsPage.tsx +++ b/src/pages/Share/SubmitDetailsPage.tsx @@ -383,7 +383,6 @@ function SubmitDetailsPage({ currentUserAccountID: currentUserPersonalDetails.accountID, betas, introSelected, - transaction, }, }); }; diff --git a/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts b/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts index e8b011614d23..fa8beac358f3 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts +++ b/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts @@ -335,7 +335,6 @@ function handleMoneyRequestStepDistanceNavigation({ currentUserAccountID, betas, introSelected, - transaction, }; if (overrides.shouldHandleNavigation) { cleanupAndNavigateAfterExpenseCreate({ diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx index 76cb463cdfd9..17907fa45ed0 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx @@ -306,7 +306,6 @@ function ScanSkipConfirmation({report, iouType, reportID, transactionID, transac currentUserAccountID: currentUserPersonalDetails.accountID, betas, introSelected, - transaction: lastTransaction, }; if (overrides.shouldHandleNavigation) { cleanupAndNavigateAfterExpenseCreate({ diff --git a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts index 52233447994f..6444d8954031 100644 --- a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts +++ b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts @@ -343,7 +343,6 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { currentUserAccountID: currentUserPersonalDetails.accountID, betas, introSelected, - transaction: lastTransaction, }; if (!shouldHandleNavigation) { cleanupAfterExpenseCreate({draftTransactionIDs, linkedTrackedExpenseReportAction: lastTransaction?.linkedTrackedExpenseReportAction}); From 06fc9395ecdbc4e38fdb412a02fe705cb7f0c493 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Fri, 17 Jul 2026 15:31:20 +0200 Subject: [PATCH 26/28] Show growl after splitting an expense --- src/libs/actions/IOU/SplitTransactionUpdate.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/libs/actions/IOU/SplitTransactionUpdate.ts b/src/libs/actions/IOU/SplitTransactionUpdate.ts index 5719edf8aa64..50683a4f9c78 100644 --- a/src/libs/actions/IOU/SplitTransactionUpdate.ts +++ b/src/libs/actions/IOU/SplitTransactionUpdate.ts @@ -8,6 +8,7 @@ import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import {calculateAmount as calculateIOUAmount} from '@libs/IOUUtils'; import Log from '@libs/Log'; import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; +import {surfaceExpenseCreatedFeedback} from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; import popReportsSplitNavigatorToReport from '@libs/Navigation/helpers/popReportsSplitNavigatorToReport'; import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; import TransitionTracker from '@libs/Navigation/TransitionTracker'; @@ -1860,6 +1861,8 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac const splitExpenses = params.transactionData?.splitExpenses ?? []; const originalTransactionID = params.transactionData?.originalTransactionID ?? CONST.IOU.OPTIMISTIC_TRANSACTION_ID; const allChildTransactions = getChildTransactions(params.allTransactionsList, originalTransactionID, false); + const existingChildTransactionIDs = new Set(allChildTransactions.map((tx) => tx?.transactionID).filter(Boolean)); + const lastNewSplitExpense = [...splitExpenses].reverse().find((splitExpense) => !!splitExpense.transactionID && !existingChildTransactionIDs.has(splitExpense.transactionID)); const originalChildTransactions = allChildTransactions.filter((tx) => tx?.reportID !== CONST.REPORT.UNREPORTED_REPORT_ID); const hasEditableSplitExpensesLeft = splitExpenses.some((expense) => (expense.statusNum ?? 0) < CONST.REPORT.STATUS_NUM.SUBMITTED); const isReverseSplitOperation = @@ -1909,6 +1912,9 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac updateSplitTransactions({...params, isFromSplitExpensesFlow: true}); }); params?.searchContext?.clearSelectedTransactions?.(true); + if (lastNewSplitExpense?.transactionID) { + surfaceExpenseCreatedFeedback({iouReportID: lastNewSplitExpense.reportID, transactionID: lastNewSplitExpense.transactionID}); + } return; } @@ -1932,7 +1938,6 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac // We skip existing transactions (already in allChildTransactions), reverse splits (no new transactions are created), // and the last-transaction case (the report navigates away before the highlight renders). if (params.expenseReport?.reportID && !isReverseSplitOperation && !isLastTransactionInReport) { - const existingChildTransactionIDs = new Set(allChildTransactions.map((tx) => tx?.transactionID).filter(Boolean)); for (const splitExpense of splitExpenses) { if (!splitExpense.transactionID || existingChildTransactionIDs.has(splitExpense.transactionID)) { continue; @@ -1946,6 +1951,10 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac Navigation.navigateBackToLastSuperWideRHPScreen(); } + if (isSearchPageTopmostFullScreenRoute && lastNewSplitExpense?.transactionID) { + surfaceExpenseCreatedFeedback({iouReportID: lastNewSplitExpense.reportID, transactionID: lastNewSplitExpense.transactionID}); + } + // After the modal is dismissed, remove the transaction thread report screen // to avoid navigating back to a report removed by the split transaction. requestAnimationFrame(() => { From d95b0b6f8f001c881b61bf1700877823e5a8aeb8 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Fri, 17 Jul 2026 15:58:42 +0200 Subject: [PATCH 27/28] Add a warning comment to runAfterDeferredWrite --- src/libs/deferredLayoutWrite.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/libs/deferredLayoutWrite.ts b/src/libs/deferredLayoutWrite.ts index d3b95813bd2d..367c3395586b 100644 --- a/src/libs/deferredLayoutWrite.ts +++ b/src/libs/deferredLayoutWrite.ts @@ -157,6 +157,11 @@ function flushDeferredWrite(key: string) { * Run `callback` once the pending optimistic write has been applied to Onyx, after the next flush of the * most-recently-registered active channel or immediately if nothing is pending. * Used to sequence work that depends on the optimistic data existing. + * + * IMPORTANT: only call this synchronously right after the deferred write it depends on was registered. + * It attaches to whatever the latest active channel is at call time, so any intervening async work (or an + * unrelated deferred write) can cause it to hook onto the wrong channel. + * This is an interim helper, not a general-purpose pattern. */ function runAfterDeferredWrite(callback: () => void) { const channel = [...channels.values()].reverse().find((c) => !c.isReserved); From b47d07acb7ce7c6f3578425388ee1f1a90dc2f3a Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Mon, 20 Jul 2026 09:30:48 +0200 Subject: [PATCH 28/28] Show growl after duplicating expenses --- .../MoneyRequestHeaderSecondaryActions.tsx | 11 ++++++++- src/hooks/useExpenseActions.ts | 11 ++++++++- .../helpers/navigateAfterExpenseCreate.ts | 16 +++++++++---- src/libs/actions/IOU/Duplicate.ts | 11 +++++++++ tests/unit/navigateAfterExpenseCreateTest.ts | 23 ++++++++++++++----- 5 files changed, 59 insertions(+), 13 deletions(-) diff --git a/src/components/MoneyRequestHeaderSecondaryActions.tsx b/src/components/MoneyRequestHeaderSecondaryActions.tsx index dc640cf73251..67101231b4b0 100644 --- a/src/components/MoneyRequestHeaderSecondaryActions.tsx +++ b/src/components/MoneyRequestHeaderSecondaryActions.tsx @@ -33,6 +33,7 @@ import initSplitExpense from '@libs/actions/SplitExpenses'; import {setNameValuePair} from '@libs/actions/User'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getExistingTransactionID} from '@libs/IOUUtils'; +import {surfaceExpenseCreatedFeedback} from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {ReportsSplitNavigatorParamList, RightModalNavigatorParamList} from '@libs/Navigation/types'; @@ -243,11 +244,12 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money const optimisticIOUReportID = generateReportID(); const activePolicyCategoriesMap = defaultPolicyCategories ?? {}; + let lastDuplicate: {iouReportID?: string; transactionID?: string} | undefined; for (const item of transactions) { const existingTransactionID = getExistingTransactionID(item.linkedTrackedExpenseReportAction); const existingTransactionDraft = existingTransactionID ? transactionDrafts?.[existingTransactionID] : undefined; - duplicateTransactionAction({ + const result = duplicateTransactionAction({ transaction: item, optimisticChatReportID, optimisticIOUReportID, @@ -271,6 +273,13 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money delegateAccountID, policyTagList, }); + if (result?.transactionID) { + lastDuplicate = {iouReportID: result.iouReport?.reportID, transactionID: result.transactionID}; + } + } + + if (lastDuplicate?.transactionID) { + surfaceExpenseCreatedFeedback(lastDuplicate); } }; diff --git a/src/hooks/useExpenseActions.ts b/src/hooks/useExpenseActions.ts index d4b0b7f1906a..9d5d39e1bb1f 100644 --- a/src/hooks/useExpenseActions.ts +++ b/src/hooks/useExpenseActions.ts @@ -12,6 +12,7 @@ import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getExistingTransactionID} from '@libs/IOUUtils'; import Log from '@libs/Log'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; +import {surfaceExpenseCreatedFeedback} from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; import Navigation from '@libs/Navigation/Navigation'; import {isTrackOnboardingChoice} from '@libs/OnboardingUtils'; import {isPolicyAccessible} from '@libs/PolicyUtils'; @@ -252,11 +253,12 @@ function useExpenseActions({reportID, isReportInSearch = false, backTo, onDuplic const optimisticIOUReportID = generateReportID(); const activePolicyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${defaultExpensePolicy?.id}`] ?? {}; + let lastDuplicate: {iouReportID?: string; transactionID?: string} | undefined; for (const item of transactionList) { const existingTransactionID = getExistingTransactionID(item.linkedTrackedExpenseReportAction); const existingTransactionDraft = existingTransactionID ? transactionDrafts?.[existingTransactionID] : undefined; - duplicateTransactionAction({ + const result = duplicateTransactionAction({ transaction: item, optimisticChatReportID, optimisticIOUReportID, @@ -280,6 +282,13 @@ function useExpenseActions({reportID, isReportInSearch = false, backTo, onDuplic delegateAccountID, policyTagList, }); + if (result?.transactionID) { + lastDuplicate = {iouReportID: result.iouReport?.reportID, transactionID: result.transactionID}; + } + } + + if (lastDuplicate?.transactionID) { + surfaceExpenseCreatedFeedback(lastDuplicate); } }; diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index 1c3c141233c6..0e7ba3ccdf04 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -26,6 +26,7 @@ import Onyx from 'react-native-onyx'; import dismissModalAndOpenReportInInboxTab from './dismissModalAndOpenReportInInboxTab'; import getTopmostFullScreenRoute from './getTopmostFullScreenRoute'; +import isReportOpenInRHP from './isReportOpenInRHP'; import isReportTopmostSplitNavigator from './isReportTopmostSplitNavigator'; import isSearchTopmostFullScreenRoute from './isSearchTopmostFullScreenRoute'; import setNavigationActionToMicrotaskQueue from './setNavigationActionToMicrotaskQueue'; @@ -117,19 +118,23 @@ function navigateToCreatedExpense({threadReportID, transactionID, iouReportID}: const backTo = Navigation.getActiveRoute(); const openOnInbox = isReportTopmostSplitNavigator() && !isSearchTopmostFullScreenRoute(); + // When a report/expense is already open in the RHP the app's convention is to replace it rather than stack a second + // report RHP on top of it. + const forceReplace = isReportOpenInRHP(navigationRef.getRootState()); + if (!openOnInbox) { setActiveTransactionIDs([transactionID]).then(() => { - Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: threadReportID, backTo})); + Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: threadReportID, backTo}), {forceReplace}); }); return; } if (getIsNarrowLayout()) { - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(threadReportID, undefined, undefined, backTo)); + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(threadReportID, undefined, undefined, backTo), {forceReplace}); return; } if (iouReportID) { - Navigation.navigate(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: iouReportID, backTo})); + Navigation.navigate(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: iouReportID, backTo}), {forceReplace}); // A multi-transaction report opens super wide (see SearchMoneyRequestReportPage's `shouldShowSuperWideRHP`), // so stack the specific thread RHP on top of it. A single-transaction report collapses to the thread @@ -137,7 +142,8 @@ function navigateToCreatedExpense({threadReportID, transactionID, iouReportID}: const hasMultipleReportTransactions = getReportTransactions(iouReportID).filter((transaction) => transaction?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length > 1; if (hasMultipleReportTransactions) { - // Defer so the thread RHP stacks on top of the expense report navigation above. + // Defer so the thread RHP stacks on top of the expense report navigation above. This is always a + // push (never a replace) — it stacks on the report we just opened, not on the previously-open one. setNavigationActionToMicrotaskQueue(() => { setActiveTransactionIDs([transactionID]).then(() => { Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: threadReportID, backTo: Navigation.getActiveRoute()})); @@ -149,7 +155,7 @@ function navigateToCreatedExpense({threadReportID, transactionID, iouReportID}: // Tracked/unreported expense: there's no expense report to open, so go straight to the transaction thread. setActiveTransactionIDs([transactionID]).then(() => { - Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: threadReportID, backTo})); + Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: threadReportID, backTo}), {forceReplace}); }); } diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index 16ec5d43afc5..cc6956b50038 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -7,6 +7,7 @@ import {WRITE_COMMANDS} from '@libs/API/types'; import DateUtils from '@libs/DateUtils'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import {getExistingTransactionID} from '@libs/IOUUtils'; +import {surfaceExpenseCreatedFeedback} from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; import * as NumberUtils from '@libs/NumberUtils'; import Parser from '@libs/Parser'; import {isInstantSubmitEnabled, isPolicyAccessible, isSubmitAndClose} from '@libs/PolicyUtils'; @@ -1144,6 +1145,8 @@ function bulkDuplicateExpenses({ let currentTargetReport = targetReport; let optimisticIOUReport: OnyxEntry; + let lastDuplicate: {iouReportID?: string; transactionID?: string} | undefined; + // When instant-submit + submit-and-close is active AND the duplicated // expenses are all non-reimbursable (or the policy disables reimbursement // entirely), canAddTransaction will reject the optimistic report after @@ -1223,12 +1226,20 @@ function bulkDuplicateExpenses({ optimisticIOUReport = result.iouReport; } + if (result?.transactionID) { + lastDuplicate = {iouReportID: result.iouReport?.reportID, transactionID: result.transactionID}; + } + if (currentTargetReport && !currentTargetReport.iouReportID) { currentTargetReport = {...currentTargetReport, iouReportID: currentOptimisticIOUReportID}; } } playSound(SOUNDS.DONE); + + if (lastDuplicate?.transactionID) { + surfaceExpenseCreatedFeedback(lastDuplicate); + } } type BulkDuplicateReportsParams = { diff --git a/tests/unit/navigateAfterExpenseCreateTest.ts b/tests/unit/navigateAfterExpenseCreateTest.ts index 6949e9177a2e..a2061caba028 100644 --- a/tests/unit/navigateAfterExpenseCreateTest.ts +++ b/tests/unit/navigateAfterExpenseCreateTest.ts @@ -249,7 +249,7 @@ describe('navigateAfterExpenseCreate', () => { growlAction?.onPress(); await waitForBatchedUpdates(); - expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute('thread-1', undefined, undefined, '')); + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute('thread-1', undefined, undefined, ''), {forceReplace: false}); }); }); @@ -262,7 +262,18 @@ describe('navigateAfterExpenseCreate', () => { await waitForBatchedUpdates(); expect(Navigation.navigate).toHaveBeenCalledTimes(1); - expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SEARCH_REPORT.getRoute({reportID: 'thread-1', backTo: ''})); + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SEARCH_REPORT.getRoute({reportID: 'thread-1', backTo: ''}), {forceReplace: false}); + }); + + it('should replace the currently-open report instead of stacking when one is already open in the RHP', async () => { + mockIsReportTopmostSplitNavigator.mockReturnValue(false); + mockIsSearchTopmostFullScreenRoute.mockReturnValue(true); + mockIsReportOpenInRHP.mockReturnValue(true); + + navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'}); + await waitForBatchedUpdates(); + + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SEARCH_REPORT.getRoute({reportID: 'thread-1', backTo: ''}), {forceReplace: true}); }); it('should open the transaction thread as a full report when the user is on the Inbox tab on a narrow layout', () => { @@ -273,7 +284,7 @@ describe('navigateAfterExpenseCreate', () => { navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'}); expect(Navigation.navigate).toHaveBeenCalledTimes(1); - expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute('thread-1', undefined, undefined, '')); + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute('thread-1', undefined, undefined, ''), {forceReplace: false}); }); it('should open the expense report then stack the thread RHP when the user is on the Inbox tab on a wide layout and the report has multiple transactions', async () => { @@ -285,7 +296,7 @@ describe('navigateAfterExpenseCreate', () => { navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'}); await waitForBatchedUpdates(); - expect(Navigation.navigate).toHaveBeenNthCalledWith(1, ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: 'iou-1', backTo: ''})); + expect(Navigation.navigate).toHaveBeenNthCalledWith(1, ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: 'iou-1', backTo: ''}), {forceReplace: false}); expect(Navigation.navigate).toHaveBeenNthCalledWith(2, ROUTES.SEARCH_REPORT.getRoute({reportID: 'thread-1', backTo: ''})); }); @@ -298,7 +309,7 @@ describe('navigateAfterExpenseCreate', () => { navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'}); expect(Navigation.navigate).toHaveBeenCalledTimes(1); - expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: 'iou-1', backTo: ''})); + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: 'iou-1', backTo: ''}), {forceReplace: false}); }); it('should open the transaction thread directly when there is no expense report (tracked/unreported expense)', async () => { @@ -310,7 +321,7 @@ describe('navigateAfterExpenseCreate', () => { await waitForBatchedUpdates(); expect(Navigation.navigate).toHaveBeenCalledTimes(1); - expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SEARCH_REPORT.getRoute({reportID: 'thread-1', backTo: ''})); + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SEARCH_REPORT.getRoute({reportID: 'thread-1', backTo: ''}), {forceReplace: false}); }); }); });