From 3778ac8685e512dab95a74d87ede4ce483167c75 Mon Sep 17 00:00:00 2001 From: borys3kk Date: Fri, 15 May 2026 10:13:05 +0200 Subject: [PATCH 01/40] inital commits --- src/components/GrowlNotification/index.tsx | 22 +++- src/libs/Growl.ts | 17 ++- .../helpers/navigateAfterExpenseCreate.ts | 108 +++++++++++++++++- src/libs/actions/IOU/NavigationHelpers.ts | 6 +- src/libs/actions/IOU/PerDiem.ts | 2 +- src/libs/actions/IOU/SendInvoice.ts | 2 + src/libs/actions/IOU/Split.ts | 8 +- src/libs/actions/IOU/TrackExpense.ts | 4 + ...replaceOptimisticReportWithActualReport.ts | 2 +- .../step/confirmation/useExpenseSubmission.ts | 2 + 10 files changed, 159 insertions(+), 14 deletions(-) diff --git a/src/components/GrowlNotification/index.tsx b/src/components/GrowlNotification/index.tsx index 58d99ea81507..14380f840795 100644 --- a/src/components/GrowlNotification/index.tsx +++ b/src/components/GrowlNotification/index.tsx @@ -11,7 +11,7 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {setIsReady} from '@libs/Growl'; -import type {GrowlRef} from '@libs/Growl'; +import type {GrowlAction, GrowlRef} from '@libs/Growl'; import CONST from '@src/CONST'; import type IconAsset from '@src/types/utils/IconAsset'; import GrowlNotificationContainer from './GrowlNotificationContainer'; @@ -30,6 +30,7 @@ function GrowlNotification({ref}: GrowlNotificationProps) { const [bodyText, setBodyText] = useState(''); const [type, setType] = useState('success'); const [duration, setDuration] = useState(); + const [action, setAction] = useState(); const theme = useTheme(); const styles = useThemeStyles(); const icons = useMemoizedLazyExpensifyIcons(['Exclamation', 'Checkmark']); @@ -70,10 +71,11 @@ function GrowlNotification({ref}: GrowlNotificationProps) { * @param {String} type * @param {Number} duration */ - const show = useCallback((text: string, growlType: string, growlDuration: number) => { + const show = useCallback((text: string, growlType: string, growlDuration: number, growlAction?: GrowlAction) => { setBodyText(text); setType(growlType); setDuration(growlDuration); + setAction(growlAction); }, []); /** @@ -140,7 +142,21 @@ function GrowlNotification({ref}: GrowlNotificationProps) { src={types[type].icon} fill={types[type].iconColor} /> - {bodyText} + {bodyText} + {!!action && ( + { + const onPress = action.onPress; + fling(); + setAction(undefined); + onPress(); + }} + style={[styles.ml2, styles.p2]} + > + {action.label} + + )} diff --git a/src/libs/Growl.ts b/src/libs/Growl.ts index 3812a155ba1f..df6ef97d2618 100644 --- a/src/libs/Growl.ts +++ b/src/libs/Growl.ts @@ -1,8 +1,13 @@ import React from 'react'; import CONST from '@src/CONST'; +type GrowlAction = { + label: string; + onPress: () => void; +}; + type GrowlRef = { - show?: (bodyText: string, type: string, duration: number) => void; + show?: (bodyText: string, type: string, duration: number, action?: GrowlAction) => void; }; const growlRef = React.createRef(); @@ -21,12 +26,12 @@ function setIsReady() { /** * Show the growl notification */ -function show(bodyText: string, type: string, duration: number = CONST.GROWL.DURATION) { +function show(bodyText: string, type: string, duration: number = CONST.GROWL.DURATION, action?: GrowlAction) { isReadyPromise.then(() => { if (!growlRef?.current?.show) { return; } - growlRef.current.show(bodyText, type, duration); + growlRef.current.show(bodyText, type, duration, action); }); } @@ -40,8 +45,8 @@ function error(bodyText: string, duration: number = CONST.GROWL.DURATION) { /** * Show success growl */ -function success(bodyText: string, duration: number = CONST.GROWL.DURATION) { - show(bodyText, CONST.GROWL.SUCCESS, duration); +function success(bodyText: string, duration: number = CONST.GROWL.DURATION, action?: GrowlAction) { + show(bodyText, CONST.GROWL.SUCCESS, duration, action); } export default { @@ -50,6 +55,6 @@ export default { success, }; -export type {GrowlRef}; +export type {GrowlRef, GrowlAction}; export {growlRef, setIsReady}; diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index 34d2128ac126..d60d67cbdf9d 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -1,19 +1,72 @@ +import Onyx from 'react-native-onyx'; +import {createTransactionThreadReport, setOptimisticTransactionThread} from '@libs/actions/Report'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; +import Growl from '@libs/Growl'; import Log from '@libs/Log'; 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 dismissModalAndOpenReportInInboxTab from './dismissModalAndOpenReportInInboxTab'; import isReportTopmostSplitNavigator from './isReportTopmostSplitNavigator'; import isSearchTopmostFullScreenRoute from './isSearchTopmostFullScreenRoute'; +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; + }, +}); + +const allTransactions: Record = {}; +Onyx.connectWithoutView({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (transactions) => { + if (!transactions) { + return; + } + for (const [key, value] of Object.entries(transactions)) { + if (!value) { + delete allTransactions[key]; + } else { + allTransactions[key] = value; + } + } + }, +}); + type NavigateAfterExpenseCreateParams = { activeReportID?: string; + iouReportID?: string; transactionID?: string; + transactionThreadReportID?: string; isFromGlobalCreate?: boolean; isInvoice?: boolean; hasMultipleTransactions: boolean; @@ -26,8 +79,17 @@ type NavigateAfterExpenseCreateParams = { * - If it is created on the inbox tab, it will open the chat report containing that expense. * - If it is created elsewhere, it will navigate to Reports > Expense and highlight the newly created expense. */ -function navigateAfterExpenseCreate({activeReportID, transactionID, isFromGlobalCreate, isInvoice, hasMultipleTransactions}: NavigateAfterExpenseCreateParams) { +function navigateAfterExpenseCreate({ + activeReportID, + iouReportID, + transactionID, + transactionThreadReportID: providedTransactionThreadReportID, + isFromGlobalCreate, + isInvoice, + hasMultipleTransactions, +}: NavigateAfterExpenseCreateParams) { const isUserOnInbox = isReportTopmostSplitNavigator(); + const isUserOnSpend = isSearchTopmostFullScreenRoute(); // 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 @@ -39,6 +101,50 @@ function navigateAfterExpenseCreate({activeReportID, transactionID, isFromGlobal const type = isInvoice ? CONST.SEARCH.DATA_TYPES.INVOICE : CONST.SEARCH.DATA_TYPES.EXPENSE; + // POC: When the expense is created from outside Inbox AND outside Spend (e.g. from Settings), + // show a growl with a "View" action instead of force-navigating away from the user's current screen. + // Clicking View takes the user to Spend and opens the RHP focused on this expense. + if (!isUserOnSpend) { + const queryStringForGrowl = buildCannedSearchQuery({type}); + Navigation.dismissModal(); + Growl.success('Expense added', 6000, { + label: 'View', + onPress: () => { + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: queryStringForGrowl}), {forceReplace: true}); + + const iouReport = iouReportID ? getReportOrDraftReport(iouReportID) : undefined; + const iouAction = iouReportID ? getIOUActionForReportID(iouReportID, transactionID) : undefined; + let threadReportID = providedTransactionThreadReportID ?? iouAction?.childReportID; + + // Mirrors MoneyRequestReportTransactionList.navigateToTransaction: if the IOU action has no + // childReportID yet (shouldGenerateTransactionThreadReport=false in useExpenseSubmission), + // build an optimistic transaction thread on the fly so we can deep-link to a single expense. + 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); + } + + if (threadReportID) { + Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: threadReportID})); + } else if (activeReportID) { + Navigation.navigate(ROUTES.SEARCH_MONEY_REQUEST_REPORT.getRoute({reportID: activeReportID})); + } + }, + }); + return; + } + // 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 rootState = navigationRef.getRootState(); diff --git a/src/libs/actions/IOU/NavigationHelpers.ts b/src/libs/actions/IOU/NavigationHelpers.ts index c7bd0ee8fb65..7e4eb0722591 100644 --- a/src/libs/actions/IOU/NavigationHelpers.ts +++ b/src/libs/actions/IOU/NavigationHelpers.ts @@ -36,17 +36,21 @@ function highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate: boolean | */ function handleNavigateAfterExpenseCreate({ activeReportID, + iouReportID, transactionID, + transactionThreadReportID, isFromGlobalCreate, isInvoice, }: { activeReportID?: string; + iouReportID?: string; transactionID?: string; + transactionThreadReportID?: string; isFromGlobalCreate?: boolean; isInvoice?: boolean; }) { const hasMultipleTransactions = Object.values(getAllTransactions()).filter((transaction) => transaction?.reportID === activeReportID).length > 0; - navigateAfterExpenseCreate({activeReportID, transactionID, isFromGlobalCreate, isInvoice, hasMultipleTransactions}); + navigateAfterExpenseCreate({activeReportID, iouReportID, transactionID, transactionThreadReportID, isFromGlobalCreate, isInvoice, hasMultipleTransactions}); } export {dismissModalAndOpenReportInInboxTab, handleNavigateAfterExpenseCreate, highlightTransactionOnSearchRouteIfNeeded}; diff --git a/src/libs/actions/IOU/PerDiem.ts b/src/libs/actions/IOU/PerDiem.ts index 69c1c75ca53c..79adff99cc76 100644 --- a/src/libs/actions/IOU/PerDiem.ts +++ b/src/libs/actions/IOU/PerDiem.ts @@ -1014,7 +1014,7 @@ function submitPerDiemExpense(submitPerDiemExpenseInformation: PerDiemExpenseInf notifyNewAction(activeReportID, undefined, participantParams.payeeAccountID === currentUserAccountIDParam); } - return {iouReport}; + return {iouReport, transactionThreadReportID}; } /** diff --git a/src/libs/actions/IOU/SendInvoice.ts b/src/libs/actions/IOU/SendInvoice.ts index 63bd2d68bc16..3028f4be8998 100644 --- a/src/libs/actions/IOU/SendInvoice.ts +++ b/src/libs/actions/IOU/SendInvoice.ts @@ -814,7 +814,9 @@ function sendInvoice({ if (shouldHandleNavigation) { handleNavigateAfterExpenseCreate({ activeReportID: invoiceRoom.reportID, + iouReportID: invoiceReportID, transactionID, + transactionThreadReportID, isFromGlobalCreate, isInvoice: true, }); diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index 90ac609fe906..3f58842a6f0b 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -2086,7 +2086,13 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest if (shouldHandleNavigation) { highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate, parameters.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE); - handleNavigateAfterExpenseCreate({activeReportID: backToReport ?? activeReportID, isFromGlobalCreate, transactionID: parameters.transactionID}); + handleNavigateAfterExpenseCreate({ + activeReportID: backToReport ?? activeReportID, + iouReportID: parameters.iouReportID, + isFromGlobalCreate, + transactionID: parameters.transactionID, + transactionThreadReportID: parameters.transactionThreadReportID, + }); } if (!isMoneyRequestReport) { diff --git a/src/libs/actions/IOU/TrackExpense.ts b/src/libs/actions/IOU/TrackExpense.ts index db37183ba741..4298cdfa3e72 100644 --- a/src/libs/actions/IOU/TrackExpense.ts +++ b/src/libs/actions/IOU/TrackExpense.ts @@ -1854,7 +1854,9 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep if (shouldHandleNavigation) { handleNavigateAfterExpenseCreate({ activeReportID: backToReport ?? activeReportID, + iouReportID: iouReport?.reportID, transactionID: transaction.transactionID, + transactionThreadReportID: transactionThreadReportID ?? iouAction?.childReportID, isFromGlobalCreate, }); } @@ -2652,7 +2654,9 @@ function trackExpense(params: CreateTrackExpenseParams) { if (shouldHandleNavigation) { handleNavigateAfterExpenseCreate({ activeReportID, + iouReportID: iouReport?.reportID, transactionID: transaction?.transactionID, + transactionThreadReportID: transactionThreadReportID ?? iouAction?.childReportID, isFromGlobalCreate, }); } diff --git a/src/libs/actions/replaceOptimisticReportWithActualReport.ts b/src/libs/actions/replaceOptimisticReportWithActualReport.ts index 150f65a6827d..9a2c1504d612 100644 --- a/src/libs/actions/replaceOptimisticReportWithActualReport.ts +++ b/src/libs/actions/replaceOptimisticReportWithActualReport.ts @@ -140,7 +140,7 @@ function replaceOptimisticReportWithActualReport(report: Report, draftReportComm const backTo = (currentRouteInfo?.params as {backTo?: Route})?.backTo; const screenName = currentRouteInfo?.name; - const isOptimisticReportFocused = activeRoute.includes(`/r/${reportID}`); + const isOptimisticReportFocused = activeRoute.includes(`/r/${reportID}`) || activeRoute.includes(`/search/view/${reportID}`); // Fix specific case: https://github.com/Expensify/App/pull/77657#issuecomment-3678696730. // When user is editing a money request report (/e/:reportID route) and has diff --git a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts index 370c2df32e02..caf57884d2fc 100644 --- a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts +++ b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts @@ -456,7 +456,9 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { if (shouldHandleNav && result && activeReportID) { navigateAfterExpenseCreate({ activeReportID, + iouReportID: result.iouReport?.reportID, transactionID: transaction.transactionID, + transactionThreadReportID: result.transactionThreadReportID, isFromGlobalCreate: transaction.isFromFloatingActionButton ?? transaction.isFromGlobalCreate, hasMultipleTransactions: reportTransactions.length > 0, }); From 034aaabd7c54f2b555b05b4bd39b349937fb3a23 Mon Sep 17 00:00:00 2001 From: borys3kk Date: Wed, 20 May 2026 15:26:46 +0200 Subject: [PATCH 02/40] creating expense and navigating to it works reliably --- src/CONST/index.ts | 1 + src/components/GrowlNotification/index.tsx | 27 ++- .../MoneyRequestReportTransactionList.tsx | 32 ++++ src/libs/Growl.ts | 8 + .../helpers/navigateAfterExpenseCreate.ts | 175 ++++++++++++++---- 5 files changed, 205 insertions(+), 38 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 030b12a463b7..b717ea86d1c0 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -3599,6 +3599,7 @@ const CONST = { SUCCESS: 'success', ERROR: 'error', WARNING: 'warning', + LOADING: 'loading', DURATION: 2000, DURATION_LONG: 3500, }, diff --git a/src/components/GrowlNotification/index.tsx b/src/components/GrowlNotification/index.tsx index 14380f840795..6580ba33ec55 100644 --- a/src/components/GrowlNotification/index.tsx +++ b/src/components/GrowlNotification/index.tsx @@ -4,6 +4,7 @@ import {View} from 'react-native'; import {Directions, Gesture, GestureDetector} from 'react-native-gesture-handler'; import {useSharedValue, withSpring} from 'react-native-reanimated'; import type {SvgProps} from 'react-native-svg'; +import ActivityIndicator from '@components/ActivityIndicator'; import Icon from '@components/Icon'; import * as Pressables from '@components/Pressable'; import Text from '@components/Text'; @@ -109,15 +110,23 @@ function GrowlNotification({ref}: GrowlNotificationProps) { }, []); useEffect(() => { - if (!duration) { + if (duration === undefined) { return; } fling(0); - setTimeout(() => { + + if (duration <= 0) { + // Indefinite (loading) growl - slide in but don't auto-dismiss. + return; + } + + const timeoutId = setTimeout(() => { fling(); setDuration(undefined); }, duration); + + return () => clearTimeout(timeoutId); }, [duration, fling]); // GestureDetector by default runs callbacks on UI thread using Reanimated. In this @@ -134,18 +143,24 @@ function GrowlNotification({ref}: GrowlNotificationProps) { fling()} > - + {type === CONST.GROWL.LOADING ? ( + + ) : ( + + )} {bodyText} {!!action && ( { const onPress = action.onPress; fling(); diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index 42b6cc141d14..1af13a80d15c 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -459,6 +459,19 @@ function MoneyRequestReportTransactionList({ const backTo = Navigation.getActiveRoute(); let reportIDToNavigate = iouAction?.childReportID; + console.log('[growl-view] [navigateToTransaction] entry', { + activeTransactionID, + iouActionExists: !!iouAction, + iouActionReportActionID: iouAction?.reportActionID, + iouActionChildReportID: iouAction?.childReportID, + iouActionPendingAction: iouAction?.pendingAction, + reportExists: !!report, + reportID: report?.reportID, + reportPolicyID: report?.policyID, + backTo, + initialReportIDToNavigate: reportIDToNavigate, + }); + const routeParams = { reportID: reportIDToNavigate, backTo, @@ -466,6 +479,14 @@ function MoneyRequestReportTransactionList({ if (!reportIDToNavigate) { const transaction = sortedTransactions.find((t) => t.transactionID === activeTransactionID); + console.log('[growl-view] [navigateToTransaction] no childReportID – creating optimistic thread', { + transactionExists: !!transaction, + transactionPendingAction: transaction?.pendingAction, + transactionErrors: transaction?.errors, + transactionReportID: transaction?.reportID, + introSelectedExists: !!introSelected, + betasCount: betas?.length, + }); const transactionThreadReport = createTransactionThreadReport({ introSelected, currentUserLogin: currentUserDetails.email ?? '', @@ -475,11 +496,20 @@ function MoneyRequestReportTransactionList({ iouReportAction: iouAction, transaction, }); + console.log('[growl-view] [navigateToTransaction] createTransactionThreadReport result', { + optimisticThreadExists: !!transactionThreadReport, + optimisticThreadReportID: transactionThreadReport?.reportID, + optimisticThreadParentReportID: transactionThreadReport?.parentReportID, + optimisticThreadParentReportActionID: transactionThreadReport?.parentReportActionID, + optimisticThreadType: transactionThreadReport?.type, + optimisticThreadChatType: transactionThreadReport?.chatType, + }); if (transactionThreadReport) { reportIDToNavigate = transactionThreadReport.reportID; routeParams.reportID = reportIDToNavigate; } } else { + console.log('[growl-view] [navigateToTransaction] childReportID exists – calling setOptimisticTransactionThread', {reportIDToNavigate}); setOptimisticTransactionThread(reportIDToNavigate, report?.reportID, iouAction?.reportActionID, report?.policyID); } @@ -487,8 +517,10 @@ function MoneyRequestReportTransactionList({ // to display prev/next arrows in RHP for navigation - use visual order from grouped transactions setActiveTransactionIDs(visualOrderTransactionIDs).then(() => { if (reportIDToNavigate) { + console.log('[growl-view] [navigateToTransaction] marking reportID as expense', {reportIDToNavigate}); markReportIDAsExpense(reportIDToNavigate); } + console.log('[growl-view] [navigateToTransaction] setActiveTransactionIDs resolved – navigating', {routeParams}); Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute(routeParams)); }); }, diff --git a/src/libs/Growl.ts b/src/libs/Growl.ts index df6ef97d2618..6408e9c54fc1 100644 --- a/src/libs/Growl.ts +++ b/src/libs/Growl.ts @@ -49,10 +49,18 @@ function success(bodyText: string, duration: number = CONST.GROWL.DURATION, acti show(bodyText, CONST.GROWL.SUCCESS, duration, action); } +/** + * Show indefinite loading growl (no auto-dismiss). Call success/error/show again to replace it. + */ +function loading(bodyText: string) { + show(bodyText, CONST.GROWL.LOADING, 0); +} + export default { show, error, success, + loading, }; export type {GrowlRef, GrowlAction}; diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index d60d67cbdf9d..e082f22cf692 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -1,5 +1,6 @@ import Onyx from 'react-native-onyx'; import {createTransactionThreadReport, setOptimisticTransactionThread} from '@libs/actions/Report'; +import {setActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import Growl from '@libs/Growl'; import Log from '@libs/Log'; @@ -102,46 +103,156 @@ function navigateAfterExpenseCreate({ const type = isInvoice ? CONST.SEARCH.DATA_TYPES.INVOICE : CONST.SEARCH.DATA_TYPES.EXPENSE; // POC: When the expense is created from outside Inbox AND outside Spend (e.g. from Settings), - // show a growl with a "View" action instead of force-navigating away from the user's current screen. - // Clicking View takes the user to Spend and opens the RHP focused on this expense. + // show a loading growl while the backend confirms creation, then swap to a "View" growl that + // deep-links to the new expense's RHP. if (!isUserOnSpend) { const queryStringForGrowl = buildCannedSearchQuery({type}); + console.log('[growl-view] entering growl branch', { + transactionID, + iouReportID, + providedTransactionThreadReportID, + isInvoice, + activeReportID, + queryStringForGrowl, + currentUserEmail, + currentUserAccountID, + }); Navigation.dismissModal(); - Growl.success('Expense added', 6000, { - label: 'View', - onPress: () => { - Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: queryStringForGrowl}), {forceReplace: true}); - - const iouReport = iouReportID ? getReportOrDraftReport(iouReportID) : undefined; - const iouAction = iouReportID ? getIOUActionForReportID(iouReportID, transactionID) : undefined; - let threadReportID = providedTransactionThreadReportID ?? iouAction?.childReportID; - - // Mirrors MoneyRequestReportTransactionList.navigateToTransaction: if the IOU action has no - // childReportID yet (shouldGenerateTransactionThreadReport=false in useExpenseSubmission), - // build an optimistic transaction thread on the fly so we can deep-link to a single expense. - 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); + Growl.loading('Adding expense…'); + + const navigateToExpenseRHP = () => { + const iouReport = iouReportID ? getReportOrDraftReport(iouReportID) : undefined; + const iouAction = iouReportID ? getIOUActionForReportID(iouReportID, transactionID) : undefined; + let threadReportID = providedTransactionThreadReportID ?? iouAction?.childReportID; + + console.log('[growl-view] View clicked – resolving thread', { + providedTransactionThreadReportID, + iouActionExists: !!iouAction, + iouActionReportActionID: iouAction?.reportActionID, + iouActionChildReportID: iouAction?.childReportID, + iouActionPendingAction: iouAction?.pendingAction, + iouReportExists: !!iouReport, + iouReportID: iouReport?.reportID, + iouReportPolicyID: iouReport?.policyID, + resolvedThreadReportID: threadReportID, + }); + + // Mirrors MoneyRequestReportTransactionList.navigateToTransaction: if no childReportID exists yet + // (shouldGenerateTransactionThreadReport=false in useExpenseSubmission), build the optimistic + // thread on the fly. createTransactionThreadReport internally calls openReport with the proper + // newReportObject/participants/parent links, which is what prevents the BE auth error when + // ReportScreen subsequently fetches the thread. + if (!threadReportID) { + const transaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + console.log('[growl-view] no childReportID – creating optimistic thread', { + transactionExists: !!transaction, + transactionPendingAction: transaction?.pendingAction, + transactionErrors: transaction?.errors, + transactionReportID: transaction?.reportID, + introSelectedExists: !!introSelected, + betasCount: betas?.length, + }); + const optimisticThread = createTransactionThreadReport({ + introSelected, + currentUserLogin: currentUserEmail, + currentUserAccountID, + betas, + iouReport, + iouReportAction: iouAction, + transaction, + }); + threadReportID = optimisticThread?.reportID; + console.log('[growl-view] createTransactionThreadReport result', { + optimisticThreadExists: !!optimisticThread, + optimisticThreadReportID: optimisticThread?.reportID, + optimisticThreadParentReportID: optimisticThread?.parentReportID, + optimisticThreadParentReportActionID: optimisticThread?.parentReportActionID, + optimisticThreadType: optimisticThread?.type, + optimisticThreadChatType: optimisticThread?.chatType, + }); + } else { + console.log('[growl-view] childReportID exists – calling setOptimisticTransactionThread', {threadReportID}); + setOptimisticTransactionThread(threadReportID, iouReport?.reportID, iouAction?.reportActionID, iouReport?.policyID); + } + + if (!threadReportID) { + console.log('[growl-view] BAILING – no threadReportID after fallback'); + Log.warn('[Growl View] Unable to resolve transaction thread reportID; skipping nav.'); + return; + } + + // Capture the originating route BEFORE swapping the full-screen to Search so the RHP back arrow + // returns the user to where they tapped "View" from (matches MoneyRequestReportTransactionList.navigateToTransaction). + const backTo = Navigation.getActiveRoute(); + console.log('[growl-view] before SEARCH_ROOT swap', {backTo, threadReportID}); + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: queryStringForGrowl}), {forceReplace: true}); + + // Defer the RHP push until after the full-screen swap settles, matching navigateToTransaction's ordering. + setActiveTransactionIDs([transactionID]).then(() => { + const targetRoute = ROUTES.SEARCH_REPORT.getRoute({ + reportID: threadReportID ?? '', + backTo, + }); + console.log('[growl-view] setActiveTransactionIDs resolved – navigating to RHP', { + targetRoute, + threadReportID, + }); + requestAnimationFrame(() => { + console.log('[growl-view] rAF fired – calling Navigation.navigate', {targetRoute}); + Navigation.navigate(targetRoute); + }); + }); + }; + + // Wait for the backend success callback (transaction.pendingAction === null) before swapping the + // loading growl to the "View" affordance. Bail after a safety timeout so we never hang forever. + let resolved = false; + const connectionId = Onyx.connectWithoutView({ + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + callback: (transaction) => { + if (resolved || !transaction) { + console.log('[growl-view] transaction onyx callback ignored', {resolved, transactionExists: !!transaction}); + return; } + const hasErrors = !!transaction.errors && Object.keys(transaction.errors).length > 0; + const isConfirmed = transaction.pendingAction === null || transaction.pendingAction === undefined; + + console.log('[growl-view] transaction onyx callback', { + transactionID, + pendingAction: transaction.pendingAction, + hasErrors, + errors: transaction.errors, + isConfirmed, + reportID: transaction.reportID, + }); - if (threadReportID) { - Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: threadReportID})); - } else if (activeReportID) { - Navigation.navigate(ROUTES.SEARCH_MONEY_REQUEST_REPORT.getRoute({reportID: activeReportID})); + if (hasErrors) { + resolved = true; + Onyx.disconnect(connectionId); + console.log('[growl-view] showing ERROR growl'); + Growl.error('Failed to add expense', CONST.GROWL.DURATION_LONG); + return; + } + if (isConfirmed) { + resolved = true; + Onyx.disconnect(connectionId); + console.log('[growl-view] BE confirmed – swapping to SUCCESS growl with View action'); + Growl.success('Expense added', 6000, {label: 'View', onPress: navigateToExpenseRHP}); } }, }); + + setTimeout(() => { + if (resolved) { + return; + } + resolved = true; + Onyx.disconnect(connectionId); + console.log('[growl-view] TIMEOUT – BE never confirmed, showing SUCCESS growl anyway'); + // Backend never confirmed - still let the user navigate using the optimistic thread. + Growl.success('Expense added', 6000, {label: 'View', onPress: navigateToExpenseRHP}); + }, 30000); + return; } From 8dae063222044551a391d9d323f1b911adc1f759 Mon Sep 17 00:00:00 2001 From: borys3kk Date: Wed, 20 May 2026 16:25:12 +0200 Subject: [PATCH 03/40] fix styles in Growl --- src/components/GrowlNotification/index.tsx | 2 +- src/styles/theme/themes/dark.ts | 1 + src/styles/theme/themes/light.ts | 1 + src/styles/theme/types.ts | 1 + 4 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/GrowlNotification/index.tsx b/src/components/GrowlNotification/index.tsx index 6580ba33ec55..510dcf73b298 100644 --- a/src/components/GrowlNotification/index.tsx +++ b/src/components/GrowlNotification/index.tsx @@ -169,7 +169,7 @@ function GrowlNotification({ref}: GrowlNotificationProps) { }} style={[styles.ml2, styles.p2]} > - {action.label} + {action.label} )} diff --git a/src/styles/theme/themes/dark.ts b/src/styles/theme/themes/dark.ts index 55e1374410b4..fc8b58a7302b 100644 --- a/src/styles/theme/themes/dark.ts +++ b/src/styles/theme/themes/dark.ts @@ -27,6 +27,7 @@ const darkTheme = { syntax: colors.productDark800, link: colors.blue300, linkHover: colors.blue100, + linkReversed: colors.blue600, buttonDefaultBG: colors.productDark400, buttonHoveredBG: colors.productDark500, buttonPressedBG: colors.productDark600, diff --git a/src/styles/theme/themes/light.ts b/src/styles/theme/themes/light.ts index 3418f9ad908c..a9d91201952f 100644 --- a/src/styles/theme/themes/light.ts +++ b/src/styles/theme/themes/light.ts @@ -27,6 +27,7 @@ const lightTheme = { syntax: colors.productLight800, link: colors.blue600, linkHover: colors.blue500, + linkReversed: colors.blue300, buttonDefaultBG: colors.productLight400, buttonHoveredBG: colors.productLight500, buttonPressedBG: colors.productLight600, diff --git a/src/styles/theme/types.ts b/src/styles/theme/types.ts index a48a083ed36b..cb3b09b9cc6a 100644 --- a/src/styles/theme/types.ts +++ b/src/styles/theme/types.ts @@ -33,6 +33,7 @@ type ThemeColors = { syntax: Color; link: Color; linkHover: Color; + linkReversed: Color; buttonDefaultBG: Color; buttonHoveredBG: Color; buttonPressedBG: Color; From 6192f2fd9d8d0a3f4a610f1d93a21e9951eda93e Mon Sep 17 00:00:00 2001 From: borys3kk Date: Wed, 20 May 2026 17:32:01 +0200 Subject: [PATCH 04/40] show growl instantly when adding outside of search/inbox --- .../helpers/navigateAfterExpenseCreate.ts | 185 +++++++++--------- 1 file changed, 94 insertions(+), 91 deletions(-) diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index e082f22cf692..fdd64396abb0 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -1,6 +1,8 @@ +/* eslint-disable no-console -- temporary debug instrumentation for [growl-view] POC */ import Onyx from 'react-native-onyx'; import {createTransactionThreadReport, setOptimisticTransactionThread} from '@libs/actions/Report'; import {setActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation'; +import {flushDeferredWrite, hasDeferredWrite} from '@libs/deferredLayoutWrite'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import Growl from '@libs/Growl'; import Log from '@libs/Log'; @@ -103,54 +105,85 @@ function navigateAfterExpenseCreate({ const type = isInvoice ? CONST.SEARCH.DATA_TYPES.INVOICE : CONST.SEARCH.DATA_TYPES.EXPENSE; // POC: When the expense is created from outside Inbox AND outside Spend (e.g. from Settings), - // show a loading growl while the backend confirms creation, then swap to a "View" growl that - // deep-links to the new expense's RHP. + // we want a growl with "View" deep-link. The thread can only be built once the optimistic IOU + // action lands in Onyx (parentReportActionID must be valid so the BE registers the thread + // correctly via openReport(newReportObject); otherwise ReportScreen 404s). + // + // Because the FAB path uses deferOrExecuteWrite (deferred-for-search), the optimistic data + // isn't applied to the Onyx cache until later. Strategy: leave the modal open (so the RHP + // submit button's spinner keeps spinning) and subscribe to the IOU report's reportActions. + // As soon as the new IOU action appears, dismiss the modal, build the thread, and show the growl. if (!isUserOnSpend) { const queryStringForGrowl = buildCannedSearchQuery({type}); - console.log('[growl-view] entering growl branch', { + console.log('[growl-view] entering growl branch (modal stays open, waiting for iouAction)', { transactionID, iouReportID, providedTransactionThreadReportID, isInvoice, activeReportID, + hasMultipleTransactions, queryStringForGrowl, currentUserEmail, currentUserAccountID, + introSelectedExists: !!introSelected, + betasCount: betas?.length, }); - Navigation.dismissModal(); - Growl.loading('Adding expense…'); - const navigateToExpenseRHP = () => { + // The submission flow registers the API.write via deferOrExecuteWrite with shouldDeferForSearch=true + // when isFromGlobalCreate. That defers optimistic-data application until the Search component lays out + // (or the 5s safety timeout fires). We are NOT navigating to Search here — we are staying on the origin + // route and showing a growl — so nothing will ever flush that channel and the user waits 5s for the + // optimistic IOU action to land. Flush it now to apply optimistic data immediately. + const hadSearchDeferral = hasDeferredWrite(CONST.DEFERRED_LAYOUT_WRITE_KEYS.SEARCH); + console.log('[growl-view] flushing deferred SEARCH write if any', {hadSearchDeferral}); + if (hadSearchDeferral) { + flushDeferredWrite(CONST.DEFERRED_LAYOUT_WRITE_KEYS.SEARCH); + } + + const showGrowlAndDismiss = (threadReportID: string | undefined, source: 'onyx' | 'timeout') => { + console.log('[growl-view] dismissing modal + showing growl', {threadReportID, source}); + Navigation.dismissModal(); + if (!threadReportID) { + Log.warn('[navigateAfterExpenseCreate] Unable to resolve transaction thread reportID; growl without View.'); + Growl.success('Expense added', CONST.GROWL.DURATION_LONG); + return; + } + const resolvedThreadReportID = threadReportID; + const navigateToExpenseRHP = () => { + console.log('[growl-view] View clicked – starting navigation', { + resolvedThreadReportID, + transactionID, + currentActiveRoute: Navigation.getActiveRoute(), + }); + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: queryStringForGrowl}), {forceReplace: true}); + const targetRoute = ROUTES.SEARCH_REPORT.getRoute({reportID: resolvedThreadReportID}); + setActiveTransactionIDs([transactionID]).then(() => { + setTimeout(() => { + console.log('[growl-view] navigating to RHP', {targetRoute, currentActiveRoute: Navigation.getActiveRoute()}); + Navigation.navigate(targetRoute); + }, 350); + }); + }; + Growl.success('Expense added', 6000, {label: 'View', onPress: navigateToExpenseRHP}); + }; + + const buildThreadFromOnyx = (logTag: string): string | undefined => { const iouReport = iouReportID ? getReportOrDraftReport(iouReportID) : undefined; const iouAction = iouReportID ? getIOUActionForReportID(iouReportID, transactionID) : undefined; let threadReportID = providedTransactionThreadReportID ?? iouAction?.childReportID; - - console.log('[growl-view] View clicked – resolving thread', { - providedTransactionThreadReportID, + console.log(`[growl-view] ${logTag} – resolving thread`, { + iouReportExists: !!iouReport, iouActionExists: !!iouAction, iouActionReportActionID: iouAction?.reportActionID, iouActionChildReportID: iouAction?.childReportID, - iouActionPendingAction: iouAction?.pendingAction, - iouReportExists: !!iouReport, - iouReportID: iouReport?.reportID, - iouReportPolicyID: iouReport?.policyID, - resolvedThreadReportID: threadReportID, + initialThreadReportID: threadReportID, }); - - // Mirrors MoneyRequestReportTransactionList.navigateToTransaction: if no childReportID exists yet - // (shouldGenerateTransactionThreadReport=false in useExpenseSubmission), build the optimistic - // thread on the fly. createTransactionThreadReport internally calls openReport with the proper - // newReportObject/participants/parent links, which is what prevents the BE auth error when - // ReportScreen subsequently fetches the thread. if (!threadReportID) { const transaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; - console.log('[growl-view] no childReportID – creating optimistic thread', { + console.log(`[growl-view] ${logTag} – building optimistic thread`, { transactionExists: !!transaction, transactionPendingAction: transaction?.pendingAction, - transactionErrors: transaction?.errors, - transactionReportID: transaction?.reportID, - introSelectedExists: !!introSelected, - betasCount: betas?.length, + iouActionPresent: !!iouAction, }); const optimisticThread = createTransactionThreadReport({ introSelected, @@ -162,83 +195,53 @@ function navigateAfterExpenseCreate({ transaction, }); threadReportID = optimisticThread?.reportID; - console.log('[growl-view] createTransactionThreadReport result', { + console.log(`[growl-view] ${logTag} – createTransactionThreadReport result`, { optimisticThreadExists: !!optimisticThread, optimisticThreadReportID: optimisticThread?.reportID, - optimisticThreadParentReportID: optimisticThread?.parentReportID, optimisticThreadParentReportActionID: optimisticThread?.parentReportActionID, - optimisticThreadType: optimisticThread?.type, - optimisticThreadChatType: optimisticThread?.chatType, }); } else { - console.log('[growl-view] childReportID exists – calling setOptimisticTransactionThread', {threadReportID}); setOptimisticTransactionThread(threadReportID, iouReport?.reportID, iouAction?.reportActionID, iouReport?.policyID); } - - if (!threadReportID) { - console.log('[growl-view] BAILING – no threadReportID after fallback'); - Log.warn('[Growl View] Unable to resolve transaction thread reportID; skipping nav.'); - return; - } - - // Capture the originating route BEFORE swapping the full-screen to Search so the RHP back arrow - // returns the user to where they tapped "View" from (matches MoneyRequestReportTransactionList.navigateToTransaction). - const backTo = Navigation.getActiveRoute(); - console.log('[growl-view] before SEARCH_ROOT swap', {backTo, threadReportID}); - Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: queryStringForGrowl}), {forceReplace: true}); - - // Defer the RHP push until after the full-screen swap settles, matching navigateToTransaction's ordering. - setActiveTransactionIDs([transactionID]).then(() => { - const targetRoute = ROUTES.SEARCH_REPORT.getRoute({ - reportID: threadReportID ?? '', - backTo, - }); - console.log('[growl-view] setActiveTransactionIDs resolved – navigating to RHP', { - targetRoute, - threadReportID, - }); - requestAnimationFrame(() => { - console.log('[growl-view] rAF fired – calling Navigation.navigate', {targetRoute}); - Navigation.navigate(targetRoute); - }); - }); + return threadReportID; }; - // Wait for the backend success callback (transaction.pendingAction === null) before swapping the - // loading growl to the "View" affordance. Bail after a safety timeout so we never hang forever. + // Fast path: maybe optimistic data already landed (e.g. non-deferred write, or providedTransactionThreadReportID). + const existingThreadReportID = providedTransactionThreadReportID ?? (iouReportID ? getIOUActionForReportID(iouReportID, transactionID)?.childReportID : undefined); + if (existingThreadReportID || (iouReportID && getIOUActionForReportID(iouReportID, transactionID))) { + console.log('[growl-view] fast path – iouAction already in Onyx, dismissing immediately'); + const threadReportID = buildThreadFromOnyx('fast-path'); + showGrowlAndDismiss(threadReportID, 'onyx'); + return; + } + + // Slow path: subscribe to the IOU report's reportActions and wait for our IOU action to appear. + // Modal stays open during the wait → "Add expense" button spinner keeps spinning. + const SAFETY_TIMEOUT_MS = 8000; let resolved = false; + const reportActionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}` as const; + console.log('[growl-view] slow path – subscribing to reportActions', {reportActionsKey, transactionID, safetyTimeoutMs: SAFETY_TIMEOUT_MS}); const connectionId = Onyx.connectWithoutView({ - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, - callback: (transaction) => { - if (resolved || !transaction) { - console.log('[growl-view] transaction onyx callback ignored', {resolved, transactionExists: !!transaction}); + key: reportActionsKey, + callback: () => { + if (resolved || !iouReportID) { return; } - const hasErrors = !!transaction.errors && Object.keys(transaction.errors).length > 0; - const isConfirmed = transaction.pendingAction === null || transaction.pendingAction === undefined; - - console.log('[growl-view] transaction onyx callback', { - transactionID, - pendingAction: transaction.pendingAction, - hasErrors, - errors: transaction.errors, - isConfirmed, - reportID: transaction.reportID, - }); - - if (hasErrors) { - resolved = true; - Onyx.disconnect(connectionId); - console.log('[growl-view] showing ERROR growl'); - Growl.error('Failed to add expense', CONST.GROWL.DURATION_LONG); + const iouAction = getIOUActionForReportID(iouReportID, transactionID); + if (!iouAction?.reportActionID) { + console.log('[growl-view] reportActions callback – iouAction not yet present', { + iouActionExists: !!iouAction, + }); return; } - if (isConfirmed) { - resolved = true; - Onyx.disconnect(connectionId); - console.log('[growl-view] BE confirmed – swapping to SUCCESS growl with View action'); - Growl.success('Expense added', 6000, {label: 'View', onPress: navigateToExpenseRHP}); - } + resolved = true; + Onyx.disconnect(connectionId); + console.log('[growl-view] reportActions callback – iouAction landed in Onyx', { + iouActionReportActionID: iouAction.reportActionID, + iouActionChildReportID: iouAction.childReportID, + }); + const threadReportID = buildThreadFromOnyx('onyx-landed'); + showGrowlAndDismiss(threadReportID, 'onyx'); }, }); @@ -248,10 +251,10 @@ function navigateAfterExpenseCreate({ } resolved = true; Onyx.disconnect(connectionId); - console.log('[growl-view] TIMEOUT – BE never confirmed, showing SUCCESS growl anyway'); - // Backend never confirmed - still let the user navigate using the optimistic thread. - Growl.success('Expense added', 6000, {label: 'View', onPress: navigateToExpenseRHP}); - }, 30000); + console.log('[growl-view] SAFETY TIMEOUT – iouAction never landed, falling back', {SAFETY_TIMEOUT_MS}); + const threadReportID = buildThreadFromOnyx('timeout'); + showGrowlAndDismiss(threadReportID, 'timeout'); + }, SAFETY_TIMEOUT_MS); return; } From 85082c488fd86d581b76a4ec0adb1587ca724235 Mon Sep 17 00:00:00 2001 From: borys3kk Date: Wed, 20 May 2026 18:08:09 +0200 Subject: [PATCH 05/40] implement navigation first then show growl --- .../helpers/navigateAfterExpenseCreate.ts | 133 +++++++++--------- 1 file changed, 65 insertions(+), 68 deletions(-) diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index fdd64396abb0..bcf139eebf8b 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -2,7 +2,6 @@ import Onyx from 'react-native-onyx'; import {createTransactionThreadReport, setOptimisticTransactionThread} from '@libs/actions/Report'; import {setActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation'; -import {flushDeferredWrite, hasDeferredWrite} from '@libs/deferredLayoutWrite'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import Growl from '@libs/Growl'; import Log from '@libs/Log'; @@ -104,69 +103,48 @@ function navigateAfterExpenseCreate({ const type = isInvoice ? CONST.SEARCH.DATA_TYPES.INVOICE : CONST.SEARCH.DATA_TYPES.EXPENSE; - // POC: When the expense is created from outside Inbox AND outside Spend (e.g. from Settings), - // we want a growl with "View" deep-link. The thread can only be built once the optimistic IOU - // action lands in Onyx (parentReportActionID must be valid so the BE registers the thread - // correctly via openReport(newReportObject); otherwise ReportScreen 404s). + // POC variant B: navigate to Search immediately (same flow as if the user had been on Spend), + // then show the "View" growl on Search once the optimistic IOU action lands in Onyx. // - // Because the FAB path uses deferOrExecuteWrite (deferred-for-search), the optimistic data - // isn't applied to the Onyx cache until later. Strategy: leave the modal open (so the RHP - // submit button's spinner keeps spinning) and subscribe to the IOU report's reportActions. - // As soon as the new IOU action appears, dismiss the modal, build the thread, and show the growl. + // 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. if (!isUserOnSpend) { - const queryStringForGrowl = buildCannedSearchQuery({type}); - console.log('[growl-view] entering growl branch (modal stays open, waiting for iouAction)', { + const queryString = buildCannedSearchQuery({type}); + console.log('[growl-view] entering variant-B (navigate-to-Search-then-growl-when-ready)', { transactionID, iouReportID, providedTransactionThreadReportID, isInvoice, activeReportID, hasMultipleTransactions, - queryStringForGrowl, - currentUserEmail, - currentUserAccountID, - introSelectedExists: !!introSelected, - betasCount: betas?.length, + queryString, }); - // The submission flow registers the API.write via deferOrExecuteWrite with shouldDeferForSearch=true - // when isFromGlobalCreate. That defers optimistic-data application until the Search component lays out - // (or the 5s safety timeout fires). We are NOT navigating to Search here — we are staying on the origin - // route and showing a growl — so nothing will ever flush that channel and the user waits 5s for the - // optimistic IOU action to land. Flush it now to apply optimistic data immediately. - const hadSearchDeferral = hasDeferredWrite(CONST.DEFERRED_LAYOUT_WRITE_KEYS.SEARCH); - console.log('[growl-view] flushing deferred SEARCH write if any', {hadSearchDeferral}); - if (hadSearchDeferral) { - flushDeferredWrite(CONST.DEFERRED_LAYOUT_WRITE_KEYS.SEARCH); - } + setPendingSubmitFollowUpAction(CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.NAVIGATE_TO_SEARCH); - const showGrowlAndDismiss = (threadReportID: string | undefined, source: 'onyx' | 'timeout') => { - console.log('[growl-view] dismissing modal + showing growl', {threadReportID, source}); - Navigation.dismissModal(); - if (!threadReportID) { - Log.warn('[navigateAfterExpenseCreate] Unable to resolve transaction thread reportID; growl without View.'); - Growl.success('Expense added', CONST.GROWL.DURATION_LONG); - return; + const navigateToSearch = () => { + console.log('[growl-view] navigateToSearch firing', { + isNarrow: getIsNarrowLayout(), + fullscreenPreInserted: Navigation.getIsFullscreenPreInsertedUnderRHP?.(), + }); + if (getIsNarrowLayout() && Navigation.getIsFullscreenPreInsertedUnderRHP()) { + Navigation.clearFullscreenPreInsertedFlag(); + Navigation.dismissModal(); + } else if (getIsNarrowLayout()) { + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: queryString}), {forceReplace: true}); + } else { + Navigation.revealRouteBeforeDismissingModal(ROUTES.SEARCH_ROOT.getRoute({query: queryString})); } - const resolvedThreadReportID = threadReportID; - const navigateToExpenseRHP = () => { - console.log('[growl-view] View clicked – starting navigation', { - resolvedThreadReportID, - transactionID, - currentActiveRoute: Navigation.getActiveRoute(), - }); - Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: queryStringForGrowl}), {forceReplace: true}); - const targetRoute = ROUTES.SEARCH_REPORT.getRoute({reportID: resolvedThreadReportID}); - setActiveTransactionIDs([transactionID]).then(() => { - setTimeout(() => { - console.log('[growl-view] navigating to RHP', {targetRoute, currentActiveRoute: Navigation.getActiveRoute()}); - Navigation.navigate(targetRoute); - }, 350); - }); - }; - Growl.success('Expense added', 6000, {label: 'View', onPress: navigateToExpenseRHP}); }; + if (navigationRef.isReady()) { + navigateToSearch(); + } else { + Navigation.isNavigationReady().then(navigateToSearch); + } + const buildThreadFromOnyx = (logTag: string): string | undefined => { const iouReport = iouReportID ? getReportOrDraftReport(iouReportID) : undefined; const iouAction = iouReportID ? getIOUActionForReportID(iouReportID, transactionID) : undefined; @@ -180,11 +158,6 @@ function navigateAfterExpenseCreate({ }); if (!threadReportID) { const transaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; - console.log(`[growl-view] ${logTag} – building optimistic thread`, { - transactionExists: !!transaction, - transactionPendingAction: transaction?.pendingAction, - iouActionPresent: !!iouAction, - }); const optimisticThread = createTransactionThreadReport({ introSelected, currentUserLogin: currentUserEmail, @@ -206,21 +179,47 @@ function navigateAfterExpenseCreate({ return threadReportID; }; - // Fast path: maybe optimistic data already landed (e.g. non-deferred write, or providedTransactionThreadReportID). - const existingThreadReportID = providedTransactionThreadReportID ?? (iouReportID ? getIOUActionForReportID(iouReportID, transactionID)?.childReportID : undefined); - if (existingThreadReportID || (iouReportID && getIOUActionForReportID(iouReportID, transactionID))) { - console.log('[growl-view] fast path – iouAction already in Onyx, dismissing immediately'); + const showGrowl = (threadReportID: string | undefined, source: 'onyx' | 'timeout') => { + console.log('[growl-view] showing growl on Search', {threadReportID, source}); + if (!threadReportID) { + Log.warn('[navigateAfterExpenseCreate] Unable to resolve transaction thread reportID; growl without View.'); + Growl.success('Expense added', CONST.GROWL.DURATION_LONG); + return; + } + const resolvedThreadReportID = threadReportID; + const navigateToExpenseRHP = () => { + console.log('[growl-view] View clicked – pushing SEARCH_REPORT RHP', { + resolvedThreadReportID, + transactionID, + currentActiveRoute: Navigation.getActiveRoute(), + }); + const targetRoute = ROUTES.SEARCH_REPORT.getRoute({reportID: resolvedThreadReportID}); + setActiveTransactionIDs([transactionID]).then(() => { + Navigation.navigate(targetRoute); + }); + }; + Growl.success('Expense added', 6000, {label: 'View', onPress: navigateToExpenseRHP}); + }; + + // Fast path: iouAction already in Onyx (rare here since the FAB-from-outside-Spend path + // typically defers the write, but covers retry / non-deferred edge cases). + if (iouReportID && getIOUActionForReportID(iouReportID, transactionID)?.reportActionID) { + console.log('[growl-view] fast path – iouAction already in Onyx'); const threadReportID = buildThreadFromOnyx('fast-path'); - showGrowlAndDismiss(threadReportID, 'onyx'); + showGrowl(threadReportID, 'onyx'); return; } - // Slow path: subscribe to the IOU report's reportActions and wait for our IOU action to appear. - // Modal stays open during the wait → "Add expense" button spinner keeps spinning. + // 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; - console.log('[growl-view] slow path – subscribing to reportActions', {reportActionsKey, transactionID, safetyTimeoutMs: SAFETY_TIMEOUT_MS}); + console.log('[growl-view] slow path – subscribing to reportActions, waiting for Search to flush', { + reportActionsKey, + transactionID, + safetyTimeoutMs: SAFETY_TIMEOUT_MS, + }); const connectionId = Onyx.connectWithoutView({ key: reportActionsKey, callback: () => { @@ -229,9 +228,7 @@ function navigateAfterExpenseCreate({ } const iouAction = getIOUActionForReportID(iouReportID, transactionID); if (!iouAction?.reportActionID) { - console.log('[growl-view] reportActions callback – iouAction not yet present', { - iouActionExists: !!iouAction, - }); + console.log('[growl-view] reportActions callback – iouAction not yet present'); return; } resolved = true; @@ -241,7 +238,7 @@ function navigateAfterExpenseCreate({ iouActionChildReportID: iouAction.childReportID, }); const threadReportID = buildThreadFromOnyx('onyx-landed'); - showGrowlAndDismiss(threadReportID, 'onyx'); + showGrowl(threadReportID, 'onyx'); }, }); @@ -253,7 +250,7 @@ function navigateAfterExpenseCreate({ Onyx.disconnect(connectionId); console.log('[growl-view] SAFETY TIMEOUT – iouAction never landed, falling back', {SAFETY_TIMEOUT_MS}); const threadReportID = buildThreadFromOnyx('timeout'); - showGrowlAndDismiss(threadReportID, 'timeout'); + showGrowl(threadReportID, 'timeout'); }, SAFETY_TIMEOUT_MS); return; From b1a7d78a4ddb29f29b1d9b9ecb543d684d50dd52 Mon Sep 17 00:00:00 2001 From: borys3kk Date: Wed, 20 May 2026 18:52:45 +0200 Subject: [PATCH 06/40] fix errors --- .../Navigation/helpers/navigateAfterExpenseCreate.ts | 12 +++++------- src/libs/actions/IOU/PerDiem.ts | 2 +- src/libs/actions/IOU/Split.ts | 6 ++---- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index b5c594a2300f..fdead24113c7 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -1,8 +1,8 @@ /* eslint-disable no-console -- temporary debug instrumentation for [growl-view] POC */ import Onyx from 'react-native-onyx'; +import {addPendingNewTransactionIDs} from '@libs/actions/IOU/PendingNewTransactions'; import {createTransactionThreadReport, setOptimisticTransactionThread} from '@libs/actions/Report'; import {setActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation'; -import {addPendingNewTransactionIDs} from '@libs/actions/IOU/PendingNewTransactions'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import Growl from '@libs/Growl'; import Log from '@libs/Log'; @@ -84,20 +84,18 @@ type NavigateAfterExpenseCreateParams = { * - If it is created elsewhere, it will navigate to Reports > Expense and highlight the newly created expense. */ function navigateAfterExpenseCreate({ - activeReportID, - + iouReportID, transactionID, transactionThreadReportID: providedTransactionThreadReportID, - + isFromGlobalCreate, - + isInvoice, - + hasMultipleTransactions, shouldAddPendingNewTransactionIDs = false, -, }: NavigateAfterExpenseCreateParams) { const isUserOnInbox = isReportTopmostSplitNavigator(); const isUserOnSpend = isSearchTopmostFullScreenRoute(); diff --git a/src/libs/actions/IOU/PerDiem.ts b/src/libs/actions/IOU/PerDiem.ts index f0e8ec67747a..25eaaa74c094 100644 --- a/src/libs/actions/IOU/PerDiem.ts +++ b/src/libs/actions/IOU/PerDiem.ts @@ -1015,7 +1015,7 @@ function submitPerDiemExpense(submitPerDiemExpenseInformation: PerDiemExpenseInf notifyNewAction(activeReportID, undefined, participantParams.payeeAccountID === currentUserAccountIDParam); } - return {iouReport, transactionID: transaction.transactionID ?? transactionThreadReportID}; + return {iouReport, transactionID: transaction.transactionID, transactionThreadReportID}; } /** diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index 81a0551d749a..becf6e74d871 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -2157,15 +2157,13 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest const navigationActiveReportID = backToReport ?? activeReportID; highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate, parameters.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE); handleNavigateAfterExpenseCreate({ - activeReportID: navigationActiveReportID, - + iouReportID: parameters.iouReportID, isFromGlobalCreate, - + transactionID: parameters.transactionID, transactionThreadReportID: parameters.transactionThreadReportID, - , shouldAddPendingNewTransactionIDs: navigationActiveReportID === parameters.chatReportID, }); } From d16069f4e51797f586de98797ccf116af5098e96 Mon Sep 17 00:00:00 2001 From: borys3kk Date: Wed, 20 May 2026 19:54:07 +0200 Subject: [PATCH 07/40] fix failing tests, prettier, eslint, typecheck --- .../MoneyRequestReportTransactionList.tsx | 32 ------------------- .../helpers/navigateAfterExpenseCreate.ts | 10 +++--- src/libs/actions/IOU/NavigationHelpers.ts | 11 ++++++- 3 files changed, 16 insertions(+), 37 deletions(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index 1af13a80d15c..42b6cc141d14 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -459,19 +459,6 @@ function MoneyRequestReportTransactionList({ const backTo = Navigation.getActiveRoute(); let reportIDToNavigate = iouAction?.childReportID; - console.log('[growl-view] [navigateToTransaction] entry', { - activeTransactionID, - iouActionExists: !!iouAction, - iouActionReportActionID: iouAction?.reportActionID, - iouActionChildReportID: iouAction?.childReportID, - iouActionPendingAction: iouAction?.pendingAction, - reportExists: !!report, - reportID: report?.reportID, - reportPolicyID: report?.policyID, - backTo, - initialReportIDToNavigate: reportIDToNavigate, - }); - const routeParams = { reportID: reportIDToNavigate, backTo, @@ -479,14 +466,6 @@ function MoneyRequestReportTransactionList({ if (!reportIDToNavigate) { const transaction = sortedTransactions.find((t) => t.transactionID === activeTransactionID); - console.log('[growl-view] [navigateToTransaction] no childReportID – creating optimistic thread', { - transactionExists: !!transaction, - transactionPendingAction: transaction?.pendingAction, - transactionErrors: transaction?.errors, - transactionReportID: transaction?.reportID, - introSelectedExists: !!introSelected, - betasCount: betas?.length, - }); const transactionThreadReport = createTransactionThreadReport({ introSelected, currentUserLogin: currentUserDetails.email ?? '', @@ -496,20 +475,11 @@ function MoneyRequestReportTransactionList({ iouReportAction: iouAction, transaction, }); - console.log('[growl-view] [navigateToTransaction] createTransactionThreadReport result', { - optimisticThreadExists: !!transactionThreadReport, - optimisticThreadReportID: transactionThreadReport?.reportID, - optimisticThreadParentReportID: transactionThreadReport?.parentReportID, - optimisticThreadParentReportActionID: transactionThreadReport?.parentReportActionID, - optimisticThreadType: transactionThreadReport?.type, - optimisticThreadChatType: transactionThreadReport?.chatType, - }); if (transactionThreadReport) { reportIDToNavigate = transactionThreadReport.reportID; routeParams.reportID = reportIDToNavigate; } } else { - console.log('[growl-view] [navigateToTransaction] childReportID exists – calling setOptimisticTransactionThread', {reportIDToNavigate}); setOptimisticTransactionThread(reportIDToNavigate, report?.reportID, iouAction?.reportActionID, report?.policyID); } @@ -517,10 +487,8 @@ function MoneyRequestReportTransactionList({ // to display prev/next arrows in RHP for navigation - use visual order from grouped transactions setActiveTransactionIDs(visualOrderTransactionIDs).then(() => { if (reportIDToNavigate) { - console.log('[growl-view] [navigateToTransaction] marking reportID as expense', {reportIDToNavigate}); markReportIDAsExpense(reportIDToNavigate); } - console.log('[growl-view] [navigateToTransaction] setActiveTransactionIDs resolved – navigating', {routeParams}); Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute(routeParams)); }); }, diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index fdead24113c7..4cfd62f57001 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -135,14 +135,16 @@ function navigateAfterExpenseCreate({ setPendingSubmitFollowUpAction(CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.NAVIGATE_TO_SEARCH); const navigateToSearch = () => { + const isNarrow = getIsNarrowLayout(); + const isFullscreenPreInserted = Navigation.getIsFullscreenPreInsertedUnderRHP(); console.log('[growl-view] navigateToSearch firing', { - isNarrow: getIsNarrowLayout(), - fullscreenPreInserted: Navigation.getIsFullscreenPreInsertedUnderRHP?.(), + isNarrow, + fullscreenPreInserted: isFullscreenPreInserted, }); - if (getIsNarrowLayout() && Navigation.getIsFullscreenPreInsertedUnderRHP()) { + if (isNarrow && isFullscreenPreInserted) { Navigation.clearFullscreenPreInsertedFlag(); Navigation.dismissModal(); - } else if (getIsNarrowLayout()) { + } else if (isNarrow) { Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: queryString}), {forceReplace: true}); } else { Navigation.revealRouteBeforeDismissingModal(ROUTES.SEARCH_ROOT.getRoute({query: queryString})); diff --git a/src/libs/actions/IOU/NavigationHelpers.ts b/src/libs/actions/IOU/NavigationHelpers.ts index 35839b6cd72b..7c716bd5c424 100644 --- a/src/libs/actions/IOU/NavigationHelpers.ts +++ b/src/libs/actions/IOU/NavigationHelpers.ts @@ -52,7 +52,16 @@ function handleNavigateAfterExpenseCreate({ shouldAddPendingNewTransactionIDs?: boolean; }) { const hasMultipleTransactions = Object.values(getAllTransactions()).filter((transaction) => transaction?.reportID === activeReportID).length > 0; - navigateAfterExpenseCreate({activeReportID, iouReportID, transactionID, transactionThreadReportID, isFromGlobalCreate, isInvoice, hasMultipleTransactions, shouldAddPendingNewTransactionIDs}); + navigateAfterExpenseCreate({ + activeReportID, + iouReportID, + transactionID, + transactionThreadReportID, + isFromGlobalCreate, + isInvoice, + hasMultipleTransactions, + shouldAddPendingNewTransactionIDs, + }); } export {dismissModalAndOpenReportInInboxTab, handleNavigateAfterExpenseCreate, highlightTransactionOnSearchRouteIfNeeded}; From 7ab84dce368efc458dd8ca9de460e042352a6d84 Mon Sep 17 00:00:00 2001 From: borys3kk Date: Fri, 22 May 2026 15:45:38 +0200 Subject: [PATCH 08/40] reposition + unify navigation path for "View" action --- .../GrowlNotificationContainer/index.tsx | 6 +- .../GrowlNotificationContainer/types.ts | 3 + src/components/GrowlNotification/index.tsx | 47 ++- .../helpers/navigateAfterExpenseCreate.ts | 297 +++++++++--------- src/styles/index.ts | 10 + 5 files changed, 200 insertions(+), 163 deletions(-) diff --git a/src/components/GrowlNotification/GrowlNotificationContainer/index.tsx b/src/components/GrowlNotification/GrowlNotificationContainer/index.tsx index a3d2e6379ea6..eaaa48802502 100644 --- a/src/components/GrowlNotification/GrowlNotificationContainer/index.tsx +++ b/src/components/GrowlNotification/GrowlNotificationContainer/index.tsx @@ -4,11 +4,15 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import type GrowlNotificationContainerProps from './types'; -function GrowlNotificationContainer({children, translateY}: GrowlNotificationContainerProps) { +function GrowlNotificationContainer({children, translateY, useBottomPosition}: GrowlNotificationContainerProps) { const styles = useThemeStyles(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const animatedStyles = useAnimatedStyle(() => styles.growlNotificationTranslateY(translateY)); + if (useBottomPosition) { + return {children}; + } + return ( {children} ); diff --git a/src/components/GrowlNotification/GrowlNotificationContainer/types.ts b/src/components/GrowlNotification/GrowlNotificationContainer/types.ts index 5f137f6d3c48..52ccf445c79b 100644 --- a/src/components/GrowlNotification/GrowlNotificationContainer/types.ts +++ b/src/components/GrowlNotification/GrowlNotificationContainer/types.ts @@ -3,6 +3,9 @@ import type ChildrenProps from '@src/types/utils/ChildrenProps'; type GrowlNotificationContainerProps = ChildrenProps & { translateY: SharedValue; + + /** When true, position the growl at the bottom-right (wide screens with an action button). */ + useBottomPosition?: boolean; }; export default GrowlNotificationContainerProps; diff --git a/src/components/GrowlNotification/index.tsx b/src/components/GrowlNotification/index.tsx index 510dcf73b298..541d24d64556 100644 --- a/src/components/GrowlNotification/index.tsx +++ b/src/components/GrowlNotification/index.tsx @@ -1,5 +1,6 @@ +/* eslint-disable no-console -- temporary debug instrumentation for [growl-view] POC */ import type {ForwardedRef} from 'react'; -import React, {useCallback, useEffect, useImperativeHandle, useState} from 'react'; +import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; import {View} from 'react-native'; import {Directions, Gesture, GestureDetector} from 'react-native-gesture-handler'; import {useSharedValue, withSpring} from 'react-native-reanimated'; @@ -9,6 +10,7 @@ import Icon from '@components/Icon'; import * as Pressables from '@components/Pressable'; import Text from '@components/Text'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {setIsReady} from '@libs/Growl'; @@ -17,7 +19,8 @@ import CONST from '@src/CONST'; import type IconAsset from '@src/types/utils/IconAsset'; import GrowlNotificationContainer from './GrowlNotificationContainer'; -const INACTIVE_POSITION_Y = -255; +const INACTIVE_OFFSET = 255; +const INACTIVE_POSITION_Y = -INACTIVE_OFFSET; const PressableWithoutFeedback = Pressables.PressableWithoutFeedback; @@ -32,10 +35,18 @@ function GrowlNotification({ref}: GrowlNotificationProps) { const [type, setType] = useState('success'); const [duration, setDuration] = useState(); const [action, setAction] = useState(); + // Guards against double-firing the action's onPress while the slide-out animation + // is still on screen. Reset whenever a new growl is shown. + const isActionPressedRef = useRef(false); const theme = useTheme(); const styles = useThemeStyles(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); const icons = useMemoizedLazyExpensifyIcons(['Exclamation', 'Checkmark']); + // Derived live so that resizing the window flips position + slide direction during display. + const useBottomPosition = !!action && !shouldUseNarrowLayout; + const inactiveY = useBottomPosition ? INACTIVE_OFFSET : INACTIVE_POSITION_Y; + type GrowlIconTypes = Record< /** String representing the growl type, all type strings * for growl notifications are stored in CONST.GROWL @@ -73,6 +84,8 @@ function GrowlNotification({ref}: GrowlNotificationProps) { * @param {Number} duration */ const show = useCallback((text: string, growlType: string, growlDuration: number, growlAction?: GrowlAction) => { + console.log('[growl-view] show() called', {text, growlType, growlDuration, hasAction: !!growlAction, actionLabel: growlAction?.label}); + isActionPressedRef.current = false; setBodyText(text); setType(growlType); setDuration(growlDuration); @@ -85,7 +98,7 @@ function GrowlNotification({ref}: GrowlNotificationProps) { * @param {Number} val */ const fling = useCallback( - (val = INACTIVE_POSITION_Y) => { + (val = inactiveY) => { 'worklet'; translateY.set( @@ -94,7 +107,7 @@ function GrowlNotification({ref}: GrowlNotificationProps) { }), ); }, - [translateY], + [translateY, inactiveY], ); useImperativeHandle( @@ -114,6 +127,9 @@ function GrowlNotification({ref}: GrowlNotificationProps) { return; } + // Snap to inactive offscreen position before sliding in, so the slide-in direction + // matches the current placement (from above for top, from below for bottom). + translateY.set(inactiveY); fling(0); if (duration <= 0) { @@ -127,20 +143,25 @@ function GrowlNotification({ref}: GrowlNotificationProps) { }, duration); return () => clearTimeout(timeoutId); - }, [duration, fling]); + }, [duration, fling, inactiveY, translateY]); // GestureDetector by default runs callbacks on UI thread using Reanimated. In this // case we want to trigger an RN's Animated animation, which needs to be done on JS thread. const flingGesture = Gesture.Fling() - .direction(Directions.UP) + .direction(useBottomPosition ? Directions.DOWN : Directions.UP) .runOnJS(true) .onStart(() => { fling(); }); + console.log('[growl-view] render', {bodyText, type, duration, hasAction: !!action, useBottomPosition, shouldUseNarrowLayout}); + return ( - + { - const onPress = action.onPress; + if (isActionPressedRef.current) { + console.log('[growl-view] action button pressed again – ignoring (already dismissing)'); + return; + } + isActionPressedRef.current = true; + console.log('[growl-view] action button pressed', {actionLabel: action.label}); + console.log('[growl-view] calling fling() (slide-out animation start)'); fling(); - setAction(undefined); - onPress(); + console.log('[growl-view] calling action.onPress() (navigates)'); + action.onPress(); }} style={[styles.ml2, styles.p2]} > diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index 4cfd62f57001..d22e22b3c533 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -113,86 +113,119 @@ function navigateAfterExpenseCreate({ const type = isInvoice ? CONST.SEARCH.DATA_TYPES.INVOICE : CONST.SEARCH.DATA_TYPES.EXPENSE; - // POC variant B: navigate to Search immediately (same flow as if the user had been on Spend), - // then show the "View" growl on Search once the optimistic IOU action lands in Onyx. + // 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. - if (!isUserOnSpend) { - const queryString = buildCannedSearchQuery({type}); - console.log('[growl-view] entering variant-B (navigate-to-Search-then-growl-when-ready)', { - transactionID, - iouReportID, - providedTransactionThreadReportID, - isInvoice, - activeReportID, - hasMultipleTransactions, - queryString, - }); + const queryString = buildCannedSearchQuery({type}); + const rootState = navigationRef.getRootState(); + const searchNavigatorRoute = rootState?.routes?.findLast((route) => route.name === NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR); + const lastSearchRoute = searchNavigatorRoute?.state?.routes?.at(-1); + const alreadyOnSearchRoot = isUserOnSpend && lastSearchRoute?.name === SCREENS.SEARCH.ROOT; + const currentSearchQueryJSON = alreadyOnSearchRoot ? getCurrentSearchQueryJSON() : undefined; + const isSameSearchType = currentSearchQueryJSON?.type === type; + console.log('[growl-view] entering variant-B (navigate-to-Search-then-growl-when-ready)', { + transactionID, + iouReportID, + providedTransactionThreadReportID, + isInvoice, + activeReportID, + hasMultipleTransactions, + queryString, + isUserOnSpend, + alreadyOnSearchRoot, + isSameSearchType, + }); - setPendingSubmitFollowUpAction(CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.NAVIGATE_TO_SEARCH); + setPendingSubmitFollowUpAction( + alreadyOnSearchRoot && isSameSearchType ? CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_ONLY : CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.NAVIGATE_TO_SEARCH, + ); - const navigateToSearch = () => { - const isNarrow = getIsNarrowLayout(); - const isFullscreenPreInserted = Navigation.getIsFullscreenPreInsertedUnderRHP(); - console.log('[growl-view] navigateToSearch firing', { - isNarrow, - fullscreenPreInserted: isFullscreenPreInserted, - }); - if (isNarrow && isFullscreenPreInserted) { - Navigation.clearFullscreenPreInsertedFlag(); - Navigation.dismissModal(); - } else if (isNarrow) { + const navigateToSearch = () => { + const isNarrow = getIsNarrowLayout(); + const isFullscreenPreInserted = Navigation.getIsFullscreenPreInsertedUnderRHP(); + console.log('[growl-view] navigateToSearch firing', { + isNarrow, + fullscreenPreInserted: isFullscreenPreInserted, + alreadyOnSearchRoot, + isSameSearchType, + }); + if (isNarrow && isFullscreenPreInserted) { + Navigation.clearFullscreenPreInsertedFlag(); + Navigation.dismissModal(); + 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 { - Navigation.revealRouteBeforeDismissingModal(ROUTES.SEARCH_ROOT.getRoute({query: queryString})); + console.log('[growl-view] navigateToSearch: already on matching Search root with RHP dismissed - no-op'); } - }; - - if (navigationRef.isReady()) { - navigateToSearch(); - } else { - Navigation.isNavigationReady().then(navigateToSearch); + return; } + if (alreadyOnSearchRoot && isSameSearchType) { + console.log('[growl-view] navigateToSearch (wide): already on matching Search root - no-op'); + return; + } + Navigation.revealRouteBeforeDismissingModal(ROUTES.SEARCH_ROOT.getRoute({query: queryString})); + }; - const buildThreadFromOnyx = (logTag: string): string | undefined => { - const iouReport = iouReportID ? getReportOrDraftReport(iouReportID) : undefined; - const iouAction = iouReportID ? getIOUActionForReportID(iouReportID, transactionID) : undefined; - let threadReportID = providedTransactionThreadReportID ?? iouAction?.childReportID; - console.log(`[growl-view] ${logTag} – resolving thread`, { - iouReportExists: !!iouReport, - iouActionExists: !!iouAction, - iouActionReportActionID: iouAction?.reportActionID, - iouActionChildReportID: iouAction?.childReportID, - initialThreadReportID: threadReportID, + if (navigationRef.isReady()) { + navigateToSearch(); + } else { + Navigation.isNavigationReady().then(navigateToSearch); + } + + const buildThreadFromOnyx = (logTag: string): string | undefined => { + const iouReport = iouReportID ? getReportOrDraftReport(iouReportID) : undefined; + const iouAction = iouReportID ? getIOUActionForReportID(iouReportID, transactionID) : undefined; + let threadReportID = providedTransactionThreadReportID ?? iouAction?.childReportID; + console.log(`[growl-view] ${logTag} – resolving thread`, { + iouReportExists: !!iouReport, + iouActionExists: !!iouAction, + iouActionReportActionID: iouAction?.reportActionID, + iouActionChildReportID: iouAction?.childReportID, + initialThreadReportID: threadReportID, + }); + if (!threadReportID) { + const transaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + const optimisticThread = createTransactionThreadReport({ + introSelected, + currentUserLogin: currentUserEmail, + currentUserAccountID, + betas, + iouReport, + iouReportAction: iouAction, + transaction, }); - if (!threadReportID) { - const transaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; - const optimisticThread = createTransactionThreadReport({ - introSelected, - currentUserLogin: currentUserEmail, - currentUserAccountID, - betas, - iouReport, - iouReportAction: iouAction, - transaction, - }); - threadReportID = optimisticThread?.reportID; - console.log(`[growl-view] ${logTag} – createTransactionThreadReport result`, { - optimisticThreadExists: !!optimisticThread, - optimisticThreadReportID: optimisticThread?.reportID, - optimisticThreadParentReportActionID: optimisticThread?.parentReportActionID, - }); - } else { - setOptimisticTransactionThread(threadReportID, iouReport?.reportID, iouAction?.reportActionID, iouReport?.policyID); - } - return threadReportID; - }; + threadReportID = optimisticThread?.reportID; + console.log(`[growl-view] ${logTag} – createTransactionThreadReport result`, { + optimisticThreadExists: !!optimisticThread, + optimisticThreadReportID: optimisticThread?.reportID, + optimisticThreadParentReportActionID: optimisticThread?.parentReportActionID, + }); + } else { + setOptimisticTransactionThread(threadReportID, iouReport?.reportID, iouAction?.reportActionID, iouReport?.policyID); + } + return threadReportID; + }; - const showGrowl = (threadReportID: string | undefined, source: 'onyx' | 'timeout') => { - console.log('[growl-view] showing growl on Search', {threadReportID, source}); + const showGrowl = (threadReportID: string | undefined, source: 'onyx' | 'timeout') => { + console.log('[growl-view] showing growl on Search (waiting for interactions)', {threadReportID, source}); + // Defer the growl until Search has finished its initial render. The iouAction lands in + // Onyx as part of Search's flushDeferredWrite → API.write cycle, so without this defer + // the slide-in animation fights with Search's render work and judders. runAfterInteractions + // yields to any in-flight interactions / animations and runs the growl once the JS thread + // is idle, so the slide-in plays smoothly. + InteractionManager.runAfterInteractions(() => { + console.log('[growl-view] interactions complete – triggering growl now', {threadReportID, source}); if (!threadReportID) { Log.warn('[navigateAfterExpenseCreate] Unable to resolve transaction thread reportID; growl without View.'); Growl.success('Expense added', CONST.GROWL.DURATION_LONG); @@ -211,100 +244,60 @@ function navigateAfterExpenseCreate({ }); }; Growl.success('Expense added', 6000, {label: 'View', onPress: navigateToExpenseRHP}); - }; - - // Fast path: iouAction already in Onyx (rare here since the FAB-from-outside-Spend path - // typically defers the write, but covers retry / non-deferred edge cases). - if (iouReportID && getIOUActionForReportID(iouReportID, transactionID)?.reportActionID) { - console.log('[growl-view] fast path – iouAction already in Onyx'); - const threadReportID = buildThreadFromOnyx('fast-path'); - showGrowl(threadReportID, 'onyx'); - 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; - console.log('[growl-view] slow path – subscribing to reportActions, waiting for Search to flush', { - reportActionsKey, - transactionID, - safetyTimeoutMs: SAFETY_TIMEOUT_MS, - }); - const connectionId = Onyx.connectWithoutView({ - key: reportActionsKey, - callback: () => { - if (resolved || !iouReportID) { - return; - } - const iouAction = getIOUActionForReportID(iouReportID, transactionID); - if (!iouAction?.reportActionID) { - console.log('[growl-view] reportActions callback – iouAction not yet present'); - return; - } - resolved = true; - Onyx.disconnect(connectionId); - console.log('[growl-view] reportActions callback – iouAction landed in Onyx', { - iouActionReportActionID: iouAction.reportActionID, - iouActionChildReportID: iouAction.childReportID, - }); - const threadReportID = buildThreadFromOnyx('onyx-landed'); - showGrowl(threadReportID, 'onyx'); - }, }); + }; - setTimeout(() => { - if (resolved) { + // Fast path: iouAction already in Onyx (rare here since the FAB-from-outside-Spend path + // typically defers the write, but covers retry / non-deferred edge cases). + if (iouReportID && getIOUActionForReportID(iouReportID, transactionID)?.reportActionID) { + console.log('[growl-view] fast path – iouAction already in Onyx'); + const threadReportID = buildThreadFromOnyx('fast-path'); + showGrowl(threadReportID, 'onyx'); + 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; + console.log('[growl-view] slow path – subscribing to reportActions, waiting for Search to flush', { + reportActionsKey, + transactionID, + safetyTimeoutMs: SAFETY_TIMEOUT_MS, + }); + const connectionId = Onyx.connectWithoutView({ + key: reportActionsKey, + callback: () => { + if (resolved || !iouReportID) { + return; + } + const iouAction = getIOUActionForReportID(iouReportID, transactionID); + if (!iouAction?.reportActionID) { + console.log('[growl-view] reportActions callback – iouAction not yet present'); return; } resolved = true; Onyx.disconnect(connectionId); - console.log('[growl-view] SAFETY TIMEOUT – iouAction never landed, falling back', {SAFETY_TIMEOUT_MS}); - const threadReportID = buildThreadFromOnyx('timeout'); - showGrowl(threadReportID, 'timeout'); - }, SAFETY_TIMEOUT_MS); - - return; - } - - // 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 rootState = navigationRef.getRootState(); - const searchNavigatorRoute = rootState?.routes?.findLast((route) => route.name === NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR); - const lastSearchRoute = searchNavigatorRoute?.state?.routes?.at(-1); - const alreadyOnSearchRoot = isSearchTopmostFullScreenRoute() && lastSearchRoute?.name === SCREENS.SEARCH.ROOT; - const currentSearchQueryJSON = alreadyOnSearchRoot ? getCurrentSearchQueryJSON() : undefined; - const isSameSearchType = currentSearchQueryJSON?.type === type; - setPendingSubmitFollowUpAction( - alreadyOnSearchRoot && isSameSearchType ? CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_ONLY : CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.NAVIGATE_TO_SEARCH, - ); + console.log('[growl-view] reportActions callback – iouAction landed in Onyx', { + iouActionReportActionID: iouAction.reportActionID, + iouActionChildReportID: iouAction.childReportID, + }); + const threadReportID = buildThreadFromOnyx('onyx-landed'); + showGrowl(threadReportID, 'onyx'); + }, + }); - const queryString = buildCannedSearchQuery({type}); - 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()) { - Navigation.clearFullscreenPreInsertedFlag(); - Navigation.dismissModal(); - } else if (getIsNarrowLayout()) { - 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})); + setTimeout(() => { + if (resolved) { + return; } - }; - - if (navigationRef.isReady()) { - navigateToSearch(); - } else { - Navigation.isNavigationReady().then(navigateToSearch); - } + resolved = true; + Onyx.disconnect(connectionId); + console.log('[growl-view] SAFETY TIMEOUT – iouAction never landed, falling back', {SAFETY_TIMEOUT_MS}); + const threadReportID = buildThreadFromOnyx('timeout'); + showGrowl(threadReportID, 'timeout'); + }, SAFETY_TIMEOUT_MS); } export default navigateAfterExpenseCreate; diff --git a/src/styles/index.ts b/src/styles/index.ts index 4072909e46e0..6bbff612fbac 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -3316,6 +3316,16 @@ const staticStyles = (theme: ThemeColors) => ...positioning.pFixed, }, + growlNotificationContainerBottomRight: { + maxWidth: variables.sideBarWidth, + width: '100%', + right: 0, + bottom: 20, + ...spacing.pl5, + ...spacing.pr5, + ...positioning.pFixed, + }, + growlNotificationBox: { backgroundColor: theme.inverse, borderRadius: variables.componentBorderRadiusNormal, From 765bd05a2b3e1963fa0f5aeb308bfc8598bc294b Mon Sep 17 00:00:00 2001 From: borys3kk Date: Fri, 22 May 2026 16:40:26 +0200 Subject: [PATCH 09/40] fix showing growl on native platforms --- .../helpers/navigateAfterExpenseCreate.ts | 46 ++++++++----------- 1 file changed, 19 insertions(+), 27 deletions(-) diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index d22e22b3c533..1e6846a34e8b 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -218,33 +218,25 @@ function navigateAfterExpenseCreate({ }; const showGrowl = (threadReportID: string | undefined, source: 'onyx' | 'timeout') => { - console.log('[growl-view] showing growl on Search (waiting for interactions)', {threadReportID, source}); - // Defer the growl until Search has finished its initial render. The iouAction lands in - // Onyx as part of Search's flushDeferredWrite → API.write cycle, so without this defer - // the slide-in animation fights with Search's render work and judders. runAfterInteractions - // yields to any in-flight interactions / animations and runs the growl once the JS thread - // is idle, so the slide-in plays smoothly. - InteractionManager.runAfterInteractions(() => { - console.log('[growl-view] interactions complete – triggering growl now', {threadReportID, source}); - if (!threadReportID) { - Log.warn('[navigateAfterExpenseCreate] Unable to resolve transaction thread reportID; growl without View.'); - Growl.success('Expense added', CONST.GROWL.DURATION_LONG); - return; - } - const resolvedThreadReportID = threadReportID; - const navigateToExpenseRHP = () => { - console.log('[growl-view] View clicked – pushing SEARCH_REPORT RHP', { - resolvedThreadReportID, - transactionID, - currentActiveRoute: Navigation.getActiveRoute(), - }); - const targetRoute = ROUTES.SEARCH_REPORT.getRoute({reportID: resolvedThreadReportID}); - setActiveTransactionIDs([transactionID]).then(() => { - Navigation.navigate(targetRoute); - }); - }; - Growl.success('Expense added', 6000, {label: 'View', onPress: navigateToExpenseRHP}); - }); + console.log('[growl-view] triggering growl now', {threadReportID, source}); + if (!threadReportID) { + Log.warn('[navigateAfterExpenseCreate] Unable to resolve transaction thread reportID; growl without View.'); + Growl.success('Expense added', CONST.GROWL.DURATION_LONG); + return; + } + const resolvedThreadReportID = threadReportID; + const navigateToExpenseRHP = () => { + console.log('[growl-view] View clicked – pushing SEARCH_REPORT RHP', { + resolvedThreadReportID, + transactionID, + currentActiveRoute: Navigation.getActiveRoute(), + }); + const targetRoute = ROUTES.SEARCH_REPORT.getRoute({reportID: resolvedThreadReportID}); + setActiveTransactionIDs([transactionID]).then(() => { + Navigation.navigate(targetRoute); + }); + }; + Growl.success('Expense added', 6000, {label: 'View', onPress: navigateToExpenseRHP}); }; // Fast path: iouAction already in Onyx (rare here since the FAB-from-outside-Spend path From 8526f562dd6e80076d3ab3cf45b9a78c28babe62 Mon Sep 17 00:00:00 2001 From: borys3kk Date: Mon, 25 May 2026 13:57:33 +0200 Subject: [PATCH 10/40] fix positiong after resize from narrow to wide and from wide to narrow --- .../index.native.tsx | 4 +-- .../GrowlNotificationContainer/index.tsx | 4 +-- .../GrowlNotificationContainer/types.ts | 6 +++- src/components/GrowlNotification/index.tsx | 32 ++++++++++--------- src/styles/index.ts | 8 ----- 5 files changed, 26 insertions(+), 28 deletions(-) diff --git a/src/components/GrowlNotification/GrowlNotificationContainer/index.native.tsx b/src/components/GrowlNotification/GrowlNotificationContainer/index.native.tsx index 44dea7d7784c..eaeeb85a3964 100644 --- a/src/components/GrowlNotification/GrowlNotificationContainer/index.native.tsx +++ b/src/components/GrowlNotification/GrowlNotificationContainer/index.native.tsx @@ -5,11 +5,11 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import type GrowlNotificationContainerProps from './types'; -function GrowlNotificationContainer({children, translateY}: GrowlNotificationContainerProps) { +function GrowlNotificationContainer({children, progress, inactiveY}: GrowlNotificationContainerProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const insets = useSafeAreaInsets(); - const animatedStyles = useAnimatedStyle(() => styles.growlNotificationTranslateY(translateY)); + const animatedStyles = useAnimatedStyle(() => ({transform: [{translateY: inactiveY * (1 - progress.get())}]}), [inactiveY]); return {children}; } diff --git a/src/components/GrowlNotification/GrowlNotificationContainer/index.tsx b/src/components/GrowlNotification/GrowlNotificationContainer/index.tsx index eaaa48802502..4bf10ca2cdad 100644 --- a/src/components/GrowlNotification/GrowlNotificationContainer/index.tsx +++ b/src/components/GrowlNotification/GrowlNotificationContainer/index.tsx @@ -4,10 +4,10 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import type GrowlNotificationContainerProps from './types'; -function GrowlNotificationContainer({children, translateY, useBottomPosition}: GrowlNotificationContainerProps) { +function GrowlNotificationContainer({children, progress, inactiveY, useBottomPosition}: GrowlNotificationContainerProps) { const styles = useThemeStyles(); const {shouldUseNarrowLayout} = useResponsiveLayout(); - const animatedStyles = useAnimatedStyle(() => styles.growlNotificationTranslateY(translateY)); + const animatedStyles = useAnimatedStyle(() => ({transform: [{translateY: inactiveY * (1 - progress.get())}]}), [inactiveY]); if (useBottomPosition) { return {children}; diff --git a/src/components/GrowlNotification/GrowlNotificationContainer/types.ts b/src/components/GrowlNotification/GrowlNotificationContainer/types.ts index 52ccf445c79b..bd935c9bc279 100644 --- a/src/components/GrowlNotification/GrowlNotificationContainer/types.ts +++ b/src/components/GrowlNotification/GrowlNotificationContainer/types.ts @@ -2,7 +2,11 @@ import type {SharedValue} from 'react-native-reanimated'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; type GrowlNotificationContainerProps = ChildrenProps & { - translateY: SharedValue; + /** Normalized visibility (0 = fully offscreen, 1 = fully visible). Translated to a pixel offset at render time. */ + progress: SharedValue; + + /** Pixel offset that corresponds to the current "offscreen" position for the active anchor. */ + inactiveY: number; /** When true, position the growl at the bottom-right (wide screens with an action button). */ useBottomPosition?: boolean; diff --git a/src/components/GrowlNotification/index.tsx b/src/components/GrowlNotification/index.tsx index 541d24d64556..31027195ee2e 100644 --- a/src/components/GrowlNotification/index.tsx +++ b/src/components/GrowlNotification/index.tsx @@ -30,7 +30,10 @@ type GrowlNotificationProps = { }; function GrowlNotification({ref}: GrowlNotificationProps) { - const translateY = useSharedValue(INACTIVE_POSITION_Y); + // Normalized: 0 = fully offscreen for the current anchor, 1 = fully visible. The container + // multiplies this against the live `inactiveY`, so the offscreen position stays correct + // even when the responsive layout flips after the growl is dismissed. + const progress = useSharedValue(0); const [bodyText, setBodyText] = useState(''); const [type, setType] = useState('success'); const [duration, setDuration] = useState(); @@ -93,21 +96,19 @@ function GrowlNotification({ref}: GrowlNotificationProps) { }, []); /** - * Animate growl notification - * - * @param {Number} val + * Animate growl notification. `targetProgress` is 0 for offscreen, 1 for visible. */ const fling = useCallback( - (val = inactiveY) => { + (targetProgress = 0) => { 'worklet'; - translateY.set( - withSpring(val, { + progress.set( + withSpring(targetProgress, { overshootClamping: false, }), ); }, - [translateY, inactiveY], + [progress], ); useImperativeHandle( @@ -127,10 +128,10 @@ function GrowlNotification({ref}: GrowlNotificationProps) { return; } - // Snap to inactive offscreen position before sliding in, so the slide-in direction - // matches the current placement (from above for top, from below for bottom). - translateY.set(inactiveY); - fling(0); + // Snap to fully offscreen before sliding in so the slide-in direction matches the + // current placement (from above for top, from below for bottom). + progress.set(0); + fling(1); if (duration <= 0) { // Indefinite (loading) growl - slide in but don't auto-dismiss. @@ -138,12 +139,12 @@ function GrowlNotification({ref}: GrowlNotificationProps) { } const timeoutId = setTimeout(() => { - fling(); + fling(0); setDuration(undefined); }, duration); return () => clearTimeout(timeoutId); - }, [duration, fling, inactiveY, translateY]); + }, [duration, fling, progress]); // GestureDetector by default runs callbacks on UI thread using Reanimated. In this // case we want to trigger an RN's Animated animation, which needs to be done on JS thread. @@ -159,7 +160,8 @@ function GrowlNotification({ref}: GrowlNotificationProps) { return ( RHPNavigatorContainerNavigatorContainerStyles: (isSmallScreenWidth: boolean) => ({marginLeft: isSmallScreenWidth ? 0 : variables.sideBarWidth, flex: 1}) satisfies ViewStyle, - growlNotificationTranslateY: (translateY: SharedValue) => { - 'worklet'; - - return { - transform: [{translateY: translateY.get()}], - }; - }, - activeDropzoneDashedBorder: (borderColor: string, isActive: boolean) => { const browser = getBrowser(); const isSafariOrChromeBrowser = getPlatform() === CONST.PLATFORM.WEB && (browser === CONST.BROWSER.SAFARI || browser === CONST.BROWSER.CHROME); From 0f563bc321e0a8969a653087c0078df088176e07 Mon Sep 17 00:00:00 2001 From: borys3kk Date: Tue, 26 May 2026 10:55:09 +0200 Subject: [PATCH 11/40] fix growl styles in native, minimize re-renders --- .../GrowlNotificationContent.tsx | 205 +++++++++++++++++ src/components/GrowlNotification/index.tsx | 217 +++--------------- .../helpers/navigateAfterExpenseCreate.ts | 3 +- src/styles/index.ts | 10 +- 4 files changed, 248 insertions(+), 187 deletions(-) create mode 100644 src/components/GrowlNotification/GrowlNotificationContent.tsx diff --git a/src/components/GrowlNotification/GrowlNotificationContent.tsx b/src/components/GrowlNotification/GrowlNotificationContent.tsx new file mode 100644 index 000000000000..b8148fd73c1b --- /dev/null +++ b/src/components/GrowlNotification/GrowlNotificationContent.tsx @@ -0,0 +1,205 @@ +/* eslint-disable no-console -- temporary debug instrumentation for [growl-view] POC */ +import React, {useCallback, useEffect, useRef} from 'react'; +import {View} from 'react-native'; +import {Directions, Gesture, GestureDetector} from 'react-native-gesture-handler'; +import {useSharedValue, withSpring} from 'react-native-reanimated'; +import type {SvgProps} from 'react-native-svg'; +import ActivityIndicator from '@components/ActivityIndicator'; +import Icon from '@components/Icon'; +import * as Pressables from '@components/Pressable'; +import Text from '@components/Text'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import type {GrowlAction} from '@libs/Growl'; +import CONST from '@src/CONST'; +import type IconAsset from '@src/types/utils/IconAsset'; +import GrowlNotificationContainer from './GrowlNotificationContainer'; + +const INACTIVE_OFFSET = 255; +const INACTIVE_POSITION_Y = -INACTIVE_OFFSET; +// Approximate time for the slide spring to settle. Used to delay onDismissed +// until the slide-out animation has visibly finished. +const SLIDE_DURATION_MS = 400; + +const PressableWithoutFeedback = Pressables.PressableWithoutFeedback; + +type GrowlNotificationContentProps = { + bodyText: string; + type: string; + duration: number; + action?: GrowlAction; + onDismissed: () => void; +}; + +function GrowlNotificationContent({bodyText, type, duration, action, onDismissed}: GrowlNotificationContentProps) { + // Normalized: 0 = fully offscreen for the current anchor, 1 = fully visible. The container + // multiplies this against the live `inactiveY`, so the offscreen position stays correct + // even when the responsive layout flips after the growl is dismissed. + const progress = useSharedValue(0); + // Guards against double-firing the action's onPress while the slide-out animation + // is still on screen. Reset whenever new growl content arrives. + const isActionPressedRef = useRef(false); + // Holds the post-fling-out setTimeout so it can be cancelled when new content arrives + // mid-dismissal (otherwise the old dismiss would unmount the new growl). + const dismissTimeoutRef = useRef | null>(null); + + const theme = useTheme(); + const styles = useThemeStyles(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const icons = useMemoizedLazyExpensifyIcons(['Exclamation', 'Checkmark']); + + // Derived live so that resizing the window flips position + slide direction during display. + const useBottomPosition = !!action && !shouldUseNarrowLayout; + const inactiveY = useBottomPosition ? INACTIVE_OFFSET : INACTIVE_POSITION_Y; + + type GrowlIconTypes = Record< + string, + { + icon: React.FC | IconAsset; + iconColor: string; + } + >; + + const types: GrowlIconTypes = { + [CONST.GROWL.SUCCESS]: { + icon: icons.Checkmark, + iconColor: theme.success, + }, + [CONST.GROWL.ERROR]: { + icon: icons.Exclamation, + iconColor: theme.danger, + }, + [CONST.GROWL.WARNING]: { + icon: icons.Exclamation, + iconColor: theme.warning, + }, + }; + + /** + * Animate growl notification. `targetProgress` is 0 for offscreen, 1 for visible. + */ + const fling = useCallback( + (targetProgress = 0) => { + 'worklet'; + + progress.set( + withSpring(targetProgress, { + overshootClamping: false, + }), + ); + }, + [progress], + ); + + /** + * Slide the growl off-screen and schedule its unmount once the animation has visibly + * finished. Safe to call multiple times — pending timeouts are replaced. + */ + const triggerDismiss = useCallback(() => { + fling(0); + if (dismissTimeoutRef.current) { + clearTimeout(dismissTimeoutRef.current); + } + dismissTimeoutRef.current = setTimeout(() => { + dismissTimeoutRef.current = null; + onDismissed(); + }, SLIDE_DURATION_MS); + }, [fling, onDismissed]); + + useEffect(() => { + isActionPressedRef.current = false; + // New content arrived — cancel any in-flight unmount-after-slide-out from the previous growl. + if (dismissTimeoutRef.current) { + clearTimeout(dismissTimeoutRef.current); + dismissTimeoutRef.current = null; + } + + // Snap to fully offscreen before sliding in so the slide-in direction matches the + // current placement (from above for top, from below for bottom). + progress.set(0); + fling(1); + + if (duration <= 0) { + // Indefinite (loading) growl - slide in but don't auto-dismiss. + return; + } + + const autoDismissTimeoutId = setTimeout(triggerDismiss, duration); + return () => clearTimeout(autoDismissTimeoutId); + }, [duration, fling, progress, triggerDismiss]); + + useEffect( + () => () => { + if (!dismissTimeoutRef.current) { + return; + } + clearTimeout(dismissTimeoutRef.current); + }, + [], + ); + + // GestureDetector by default runs callbacks on UI thread using Reanimated. In this + // case we want to trigger an RN's Animated animation, which needs to be done on JS thread. + const flingGesture = Gesture.Fling() + .direction(useBottomPosition ? Directions.DOWN : Directions.UP) + .runOnJS(true) + .onStart(() => { + triggerDismiss(); + }); + + console.log('[growl-view] INNER render', {bodyText, type, duration, hasAction: !!action, useBottomPosition, shouldUseNarrowLayout}); + + return ( + + + triggerDismiss()} + > + + + {type === CONST.GROWL.LOADING ? ( + + ) : ( + + )} + {bodyText} + {!!action && ( + { + if (isActionPressedRef.current) { + console.log('[growl-view] action button pressed again – ignoring (already dismissing)'); + return; + } + isActionPressedRef.current = true; + console.log('[growl-view] action button pressed', {actionLabel: action.label}); + triggerDismiss(); + console.log('[growl-view] calling action.onPress() (navigates)'); + action.onPress(); + }} + style={[styles.mlAuto, styles.p2]} + > + {action.label} + + )} + + + + + + ); +} + +export default GrowlNotificationContent; diff --git a/src/components/GrowlNotification/index.tsx b/src/components/GrowlNotification/index.tsx index 31027195ee2e..7c59e9668cd4 100644 --- a/src/components/GrowlNotification/index.tsx +++ b/src/components/GrowlNotification/index.tsx @@ -1,212 +1,59 @@ /* eslint-disable no-console -- temporary debug instrumentation for [growl-view] POC */ import type {ForwardedRef} from 'react'; -import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; -import {View} from 'react-native'; -import {Directions, Gesture, GestureDetector} from 'react-native-gesture-handler'; -import {useSharedValue, withSpring} from 'react-native-reanimated'; -import type {SvgProps} from 'react-native-svg'; -import ActivityIndicator from '@components/ActivityIndicator'; -import Icon from '@components/Icon'; -import * as Pressables from '@components/Pressable'; -import Text from '@components/Text'; -import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; +import React, {useCallback, useEffect, useImperativeHandle, useState} from 'react'; import {setIsReady} from '@libs/Growl'; import type {GrowlAction, GrowlRef} from '@libs/Growl'; -import CONST from '@src/CONST'; -import type IconAsset from '@src/types/utils/IconAsset'; -import GrowlNotificationContainer from './GrowlNotificationContainer'; +import GrowlNotificationContent from './GrowlNotificationContent'; -const INACTIVE_OFFSET = 255; -const INACTIVE_POSITION_Y = -INACTIVE_OFFSET; - -const PressableWithoutFeedback = Pressables.PressableWithoutFeedback; +type GrowlContent = { + bodyText: string; + type: string; + duration: number; + action?: GrowlAction; +}; type GrowlNotificationProps = { /** Reference to outer element */ ref?: ForwardedRef; }; +/** + * Outer growl shell. Intentionally does NOT subscribe to navigation/theme/responsive contexts — + * those live in GrowlNotificationContent, which is mounted only while a growl is visible. + * This keeps the shell from re-rendering on every screen change, focus change, etc. + */ function GrowlNotification({ref}: GrowlNotificationProps) { - // Normalized: 0 = fully offscreen for the current anchor, 1 = fully visible. The container - // multiplies this against the live `inactiveY`, so the offscreen position stays correct - // even when the responsive layout flips after the growl is dismissed. - const progress = useSharedValue(0); - const [bodyText, setBodyText] = useState(''); - const [type, setType] = useState('success'); - const [duration, setDuration] = useState(); - const [action, setAction] = useState(); - // Guards against double-firing the action's onPress while the slide-out animation - // is still on screen. Reset whenever a new growl is shown. - const isActionPressedRef = useRef(false); - const theme = useTheme(); - const styles = useThemeStyles(); - const {shouldUseNarrowLayout} = useResponsiveLayout(); - const icons = useMemoizedLazyExpensifyIcons(['Exclamation', 'Checkmark']); - - // Derived live so that resizing the window flips position + slide direction during display. - const useBottomPosition = !!action && !shouldUseNarrowLayout; - const inactiveY = useBottomPosition ? INACTIVE_OFFSET : INACTIVE_POSITION_Y; - - type GrowlIconTypes = Record< - /** String representing the growl type, all type strings - * for growl notifications are stored in CONST.GROWL - */ - string, - { - /** Expensicon for the page */ - icon: React.FC | IconAsset; + const [content, setContent] = useState(null); - /** Color for the icon (should be from theme) */ - iconColor: string; - } - >; - - const types: GrowlIconTypes = { - [CONST.GROWL.SUCCESS]: { - icon: icons.Checkmark, - iconColor: theme.success, - }, - [CONST.GROWL.ERROR]: { - icon: icons.Exclamation, - iconColor: theme.danger, - }, - [CONST.GROWL.WARNING]: { - icon: icons.Exclamation, - iconColor: theme.warning, - }, - }; - - /** - * Show the growl notification - * - * @param {String} bodyText - * @param {String} type - * @param {Number} duration - */ - const show = useCallback((text: string, growlType: string, growlDuration: number, growlAction?: GrowlAction) => { - console.log('[growl-view] show() called', {text, growlType, growlDuration, hasAction: !!growlAction, actionLabel: growlAction?.label}); - isActionPressedRef.current = false; - setBodyText(text); - setType(growlType); - setDuration(growlDuration); - setAction(growlAction); + const show = useCallback((text: string, growlType: string, duration: number, action?: GrowlAction) => { + setContent({bodyText: text, type: growlType, duration, action}); }, []); - /** - * Animate growl notification. `targetProgress` is 0 for offscreen, 1 for visible. - */ - const fling = useCallback( - (targetProgress = 0) => { - 'worklet'; - - progress.set( - withSpring(targetProgress, { - overshootClamping: false, - }), - ); - }, - [progress], - ); - - useImperativeHandle( - ref, - () => ({ - show, - }), - [show], - ); + useImperativeHandle(ref, () => ({show}), [show]); useEffect(() => { setIsReady(); }, []); - useEffect(() => { - if (duration === undefined) { - return; - } - - // Snap to fully offscreen before sliding in so the slide-in direction matches the - // current placement (from above for top, from below for bottom). - progress.set(0); - fling(1); - - if (duration <= 0) { - // Indefinite (loading) growl - slide in but don't auto-dismiss. - return; - } - - const timeoutId = setTimeout(() => { - fling(0); - setDuration(undefined); - }, duration); - - return () => clearTimeout(timeoutId); - }, [duration, fling, progress]); + const handleDismissed = useCallback(() => { + setContent(null); + }, []); - // GestureDetector by default runs callbacks on UI thread using Reanimated. In this - // case we want to trigger an RN's Animated animation, which needs to be done on JS thread. - const flingGesture = Gesture.Fling() - .direction(useBottomPosition ? Directions.DOWN : Directions.UP) - .runOnJS(true) - .onStart(() => { - fling(); - }); + console.log('[growl-view] OUTER render', {hasContent: !!content}); - console.log('[growl-view] render', {bodyText, type, duration, hasAction: !!action, useBottomPosition, shouldUseNarrowLayout}); + if (!content) { + return null; + } return ( - - - fling()} - > - - - {type === CONST.GROWL.LOADING ? ( - - ) : ( - - )} - {bodyText} - {!!action && ( - { - if (isActionPressedRef.current) { - console.log('[growl-view] action button pressed again – ignoring (already dismissing)'); - return; - } - isActionPressedRef.current = true; - console.log('[growl-view] action button pressed', {actionLabel: action.label}); - console.log('[growl-view] calling fling() (slide-out animation start)'); - fling(); - console.log('[growl-view] calling action.onPress() (navigates)'); - action.onPress(); - }} - style={[styles.ml2, styles.p2]} - > - {action.label} - - )} - - - - - + ); } -export default GrowlNotification; +export default React.memo(GrowlNotification); diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index 1e6846a34e8b..fabfc90586d6 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -236,7 +236,8 @@ function navigateAfterExpenseCreate({ Navigation.navigate(targetRoute); }); }; - Growl.success('Expense added', 6000, {label: 'View', onPress: navigateToExpenseRHP}); + // DEBUG: duration 0 → indefinite (no auto-dismiss) so we can inspect why "View" button isn't visible. + Growl.success('Expense added', 0, {label: 'View', onPress: navigateToExpenseRHP}); }; // Fast path: iouAction already in Onyx (rare here since the FAB-from-outside-Spend path diff --git a/src/styles/index.ts b/src/styles/index.ts index 779e21ff2e9a..d8089b4d1325 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -3339,12 +3339,20 @@ const staticStyles = (theme: ThemeColors) => growlNotificationText: { fontSize: variables.fontSizeNormal, ...FontUtils.fontFamily.platform.EXP_NEUE, - width: '90%', lineHeight: variables.fontSizeNormalHeight, color: theme.textReversed, ...spacing.ml4, }, + growlNotificationTextWithoutAction: { + width: '90%', + }, + + growlNotificationTextWithAction: { + flexShrink: 1, + minWidth: 0, + }, + noSelect: { boxShadow: 'none', // After https://github.com/facebook/react-native/pull/46284 RN accepts only 3 options and undefined From 2d46511d6adeb3594360fd430c91072098bfd8f2 Mon Sep 17 00:00:00 2001 From: borys3kk Date: Tue, 26 May 2026 13:24:34 +0200 Subject: [PATCH 12/40] fix growl timeout, fix react compiler compliance --- .../GrowlNotificationContent.tsx | 67 +++++++++++-------- .../helpers/navigateAfterExpenseCreate.ts | 3 +- 2 files changed, 39 insertions(+), 31 deletions(-) diff --git a/src/components/GrowlNotification/GrowlNotificationContent.tsx b/src/components/GrowlNotification/GrowlNotificationContent.tsx index b8148fd73c1b..15584701c2ed 100644 --- a/src/components/GrowlNotification/GrowlNotificationContent.tsx +++ b/src/components/GrowlNotification/GrowlNotificationContent.tsx @@ -1,5 +1,5 @@ /* eslint-disable no-console -- temporary debug instrumentation for [growl-view] POC */ -import React, {useCallback, useEffect, useRef} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import {Directions, Gesture, GestureDetector} from 'react-native-gesture-handler'; import {useSharedValue, withSpring} from 'react-native-reanimated'; @@ -41,9 +41,13 @@ function GrowlNotificationContent({bodyText, type, duration, action, onDismissed // Guards against double-firing the action's onPress while the slide-out animation // is still on screen. Reset whenever new growl content arrives. const isActionPressedRef = useRef(false); - // Holds the post-fling-out setTimeout so it can be cancelled when new content arrives - // mid-dismissal (otherwise the old dismiss would unmount the new growl). + // Holds the post-fling-out setTimeout so the content-change effect can cancel a + // pending unmount when a new growl arrives mid-dismissal. Only touched inside effects. const dismissTimeoutRef = useRef | null>(null); + // Counter that triggers the dismiss-after-slide-out effect. Kept in state (rather than + // a ref) so triggerDismiss doesn't transitively read a ref — that would make the React + // Compiler reject the gesture's useMemo as "ref access during render". + const [dismissNonce, setDismissNonce] = useState(0); const theme = useTheme(); const styles = useThemeStyles(); @@ -94,23 +98,32 @@ function GrowlNotificationContent({bodyText, type, duration, action, onDismissed ); /** - * Slide the growl off-screen and schedule its unmount once the animation has visibly - * finished. Safe to call multiple times — pending timeouts are replaced. + * Slide the growl off-screen and schedule its unmount via the dismissNonce effect. */ const triggerDismiss = useCallback(() => { fling(0); - if (dismissTimeoutRef.current) { - clearTimeout(dismissTimeoutRef.current); + setDismissNonce((n) => n + 1); + }, [fling]); + + // Schedule unmount once a dismiss has been requested. Effect cleanup cancels the timer + // when this effect re-runs or the component unmounts. + useEffect(() => { + if (dismissNonce === 0) { + return; } - dismissTimeoutRef.current = setTimeout(() => { + const timeoutId = setTimeout(onDismissed, SLIDE_DURATION_MS); + dismissTimeoutRef.current = timeoutId; + return () => { + clearTimeout(timeoutId); dismissTimeoutRef.current = null; - onDismissed(); - }, SLIDE_DURATION_MS); - }, [fling, onDismissed]); + }; + }, [dismissNonce, onDismissed]); useEffect(() => { isActionPressedRef.current = false; - // New content arrived — cancel any in-flight unmount-after-slide-out from the previous growl. + // New content arrived — cancel any in-flight unmount-after-slide-out from the previous + // growl. (We can't reset dismissNonce here because setState in an effect body is + // disallowed by lint; clearing the timer directly via the ref achieves the same result.) if (dismissTimeoutRef.current) { clearTimeout(dismissTimeoutRef.current); dismissTimeoutRef.current = null; @@ -128,26 +141,22 @@ function GrowlNotificationContent({bodyText, type, duration, action, onDismissed const autoDismissTimeoutId = setTimeout(triggerDismiss, duration); return () => clearTimeout(autoDismissTimeoutId); - }, [duration, fling, progress, triggerDismiss]); - - useEffect( - () => () => { - if (!dismissTimeoutRef.current) { - return; - } - clearTimeout(dismissTimeoutRef.current); - }, - [], - ); + }, [bodyText, type, action, duration, fling, progress, triggerDismiss]); // GestureDetector by default runs callbacks on UI thread using Reanimated. In this // case we want to trigger an RN's Animated animation, which needs to be done on JS thread. - const flingGesture = Gesture.Fling() - .direction(useBottomPosition ? Directions.DOWN : Directions.UP) - .runOnJS(true) - .onStart(() => { - triggerDismiss(); - }); + // Wrapped in useMemo so the React Compiler doesn't flag the gesture builder's internal + // mutable state as ref access during render. + const flingGesture = useMemo( + () => + Gesture.Fling() + .direction(useBottomPosition ? Directions.DOWN : Directions.UP) + .runOnJS(true) + .onStart(() => { + triggerDismiss(); + }), + [useBottomPosition, triggerDismiss], + ); console.log('[growl-view] INNER render', {bodyText, type, duration, hasAction: !!action, useBottomPosition, shouldUseNarrowLayout}); diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index fabfc90586d6..1e6846a34e8b 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -236,8 +236,7 @@ function navigateAfterExpenseCreate({ Navigation.navigate(targetRoute); }); }; - // DEBUG: duration 0 → indefinite (no auto-dismiss) so we can inspect why "View" button isn't visible. - Growl.success('Expense added', 0, {label: 'View', onPress: navigateToExpenseRHP}); + Growl.success('Expense added', 6000, {label: 'View', onPress: navigateToExpenseRHP}); }; // Fast path: iouAction already in Onyx (rare here since the FAB-from-outside-Spend path From de2fb6d914fc2ef8631bfe0628bb3ce433503cee Mon Sep 17 00:00:00 2001 From: borys3kk Date: Tue, 26 May 2026 16:20:45 +0200 Subject: [PATCH 13/40] remove searchHighlightAndScroll --- src/ONYXKEYS.ts | 4 - .../SearchList/BaseSearchList/index.tsx | 5 +- .../Search/SearchList/BaseSearchList/types.ts | 5 +- .../ListItem/TransactionGroupListItem.tsx | 19 - .../Search/SearchList/ListItem/types.ts | 1 - src/components/Search/SearchList/index.tsx | 83 +--- src/components/Search/index.tsx | 48 +-- src/hooks/useSearchAutoRefetch.ts | 160 ++++++++ src/hooks/useSearchHighlightAndScroll.ts | 357 ------------------ src/libs/actions/IOU/NavigationHelpers.ts | 16 +- src/libs/actions/IOU/PerDiem.ts | 4 +- src/libs/actions/IOU/SendInvoice.ts | 4 +- src/libs/actions/IOU/Split.ts | 3 +- src/libs/actions/IOU/TrackExpense.ts | 5 +- src/libs/actions/Transaction.ts | 6 - tests/actions/IOUTest.ts | 30 -- ...ollTest.ts => useSearchAutoRefetchTest.ts} | 173 +-------- 17 files changed, 186 insertions(+), 737 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 8e30de4e3f7b..e08514339b3f 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -721,9 +721,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 preferred policy ID to be used when creating a group */ DOMAIN_GROUP_CREATE_PREFERRED_POLICY_ID: 'domainGroupCreatePreferredPolicyID', @@ -1622,7 +1619,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.DOMAIN_GROUP_CREATE_PREFERRED_POLICY_ID]: string | undefined; }; diff --git a/src/components/Search/SearchList/BaseSearchList/index.tsx b/src/components/Search/SearchList/BaseSearchList/index.tsx index 7ac21fc26c9f..910ada056e10 100644 --- a/src/components/Search/SearchList/BaseSearchList/index.tsx +++ b/src/components/Search/SearchList/BaseSearchList/index.tsx @@ -37,7 +37,6 @@ function BaseSearchList({ onLayout, contentContainerStyle, flattenedItemsLength, - newTransactions, selectedTransactions, isAttendeesEnabledForMovingPolicy, nonPersonalAndWorkspaceCards, @@ -137,8 +136,8 @@ function BaseSearchList({ }, [setHasKeyBeenPressed]); const extraData = useMemo( - () => [focusedIndex, columns, newTransactions, selectedTransactions, nonPersonalAndWorkspaceCards, isAttendeesEnabledForMovingPolicy], - [focusedIndex, columns, newTransactions, selectedTransactions, nonPersonalAndWorkspaceCards, isAttendeesEnabledForMovingPolicy], + () => [focusedIndex, columns, selectedTransactions, nonPersonalAndWorkspaceCards, isAttendeesEnabledForMovingPolicy], + [focusedIndex, columns, selectedTransactions, nonPersonalAndWorkspaceCards, isAttendeesEnabledForMovingPolicy], ); return ( diff --git a/src/components/Search/SearchList/BaseSearchList/types.ts b/src/components/Search/SearchList/BaseSearchList/types.ts index 98a95be7c065..93ce59c39eec 100644 --- a/src/components/Search/SearchList/BaseSearchList/types.ts +++ b/src/components/Search/SearchList/BaseSearchList/types.ts @@ -4,7 +4,7 @@ import type {NativeSyntheticEvent} from 'react-native'; import type {SearchListItem} from '@components/Search/SearchList/ListItem/types'; import type {SearchColumnType, SelectedTransactions} 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'; type BaseSearchListProps = Pick< FlashListProps, @@ -27,9 +27,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/TransactionGroupListItem.tsx b/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx index 72d8fd1657da..01a280572cf9 100644 --- a/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx @@ -77,7 +77,6 @@ function TransactionGroupListItem({ columns, groupBy, searchType, - newTransactionID, lastPaymentMethod, personalPolicyID, nonPersonalAndWorkspaceCards, @@ -221,24 +220,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 d74fa21f2c56..6515e69fe011 100644 --- a/src/components/Search/SearchList/ListItem/types.ts +++ b/src/components/Search/SearchList/ListItem/types.ts @@ -424,7 +424,6 @@ type TransactionGroupListItemProps = ListItemProps, 'onScroll' | 'conten /** Whether mobile selection mode is enabled */ isMobileSelectionModeEnabled: boolean; - newTransactions?: Transaction[]; - /** Selected transactions for determining isSelected state */ selectedTransactions: SelectedTransactions; @@ -148,49 +133,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, @@ -212,7 +154,6 @@ function SearchList({ onLayout, shouldAnimate, isMobileSelectionModeEnabled, - newTransactions = [], nonPersonalAndWorkspaceCards, selectedTransactions, hasLoadedAllTransactions, @@ -309,22 +250,6 @@ function SearchList({ const [longPressedItemTransactions, setLongPressedItemTransactions] = useState(); - 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 {windowWidth} = useWindowDimensions(); const minTableWidth = getTableMinWidth(columns, queryJSON.type, isActionColumnWide); const shouldScrollHorizontally = !!SearchTableHeader && minTableWidth > windowWidth; @@ -433,7 +358,6 @@ function SearchList({ const isDisabled = item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; const shouldApplyAnimation = shouldAnimate && index < data.length - 1; - const newTransactionID = item.keyForList ? newTransactionIDByItemKey.get(item.keyForList) : undefined; // Apply selection lazily per row so we don't rebuild a list-wide wrapper structure on every render. const {itemWithSelection} = applySelectionToItem(item, canSelectMultiple, selectedTransactions); @@ -463,7 +387,6 @@ function SearchList({ ownerBillingGracePeriodEnd={ownerBillingGracePeriodEnd} nonPersonalAndWorkspaceCards={nonPersonalAndWorkspaceCards} onFocus={onFocus} - newTransactionID={newTransactionID} onUndelete={handleUndelete} keyForList={item.keyForList} isFirstItem={index === firstVisibleIndex} @@ -475,7 +398,6 @@ function SearchList({ [ type, groupBy, - newTransactionIDByItemKey, shouldAnimate, data.length, styles.overflowHidden, @@ -563,7 +485,6 @@ function SearchList({ onViewableItemsChanged={onViewableItemsChanged} onLayout={onLayout} contentContainerStyle={contentContainerStyle} - newTransactions={newTransactions} selectedTransactions={selectedTransactions} isAttendeesEnabledForMovingPolicy={isAttendeesEnabledForMovingPolicy} nonPersonalAndWorkspaceCards={nonPersonalAndWorkspaceCards} diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 84ca7b28d048..0ad437476f5c 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -25,7 +25,7 @@ import usePrevious from '@hooks/usePrevious'; import useReportAttributes from '@hooks/useReportAttributes'; 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'; @@ -363,7 +363,7 @@ function Search({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [isSmallScreenWidth]); - const {newSearchResultKeys, handleSelectionListScroll, newTransactions, hasQueuedHighlights} = useSearchHighlightAndScroll({ + useSearchAutoRefetch({ searchResults, transactions, previousTransactions, @@ -376,16 +376,6 @@ function Search({ shouldUseLiveData, }); - // 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]); - // 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 const isDataLoaded = shouldUseLiveData || isSearchDataLoaded(searchResults, queryJSON); @@ -445,14 +435,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. @@ -1307,28 +1289,12 @@ function Search({ const sortedData = useMemo( () => getSortedSections(type, status, filteredData, localeCompare, translate, sortBy, sortOrder, validGroupBy).map((item) => { - const baseKey = isChat - ? `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${(item as ReportActionListItemType).reportActionID}` - : `${ONYXKEYS.COLLECTION.TRANSACTION}${(item as TransactionListItemType).transactionID}`; - - const isBaseKeyMatch = !!newSearchResultKeys?.has(baseKey); - - const isAnyTransactionMatch = - !isChat && - (item as TransactionGroupListItemType)?.transactions?.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}; }), - [type, status, filteredData, localeCompare, translate, sortBy, sortOrder, validGroupBy, isChat, newSearchResultKeys, hash], + [type, status, filteredData, localeCompare, translate, sortBy, sortOrder, validGroupBy, hash], ); useSaveSortedReportIDs(type, sortedData); @@ -1429,9 +1395,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. @@ -1751,7 +1716,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..85e5ff04b1ae --- /dev/null +++ b/src/hooks/useSearchAutoRefetch.ts @@ -0,0 +1,160 @@ +import {useIsFocused} from '@react-navigation/native'; +import {useEffect, useRef} from 'react'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +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 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 extractTransactionIDsFromSearchResults(searchResultsData: Partial): string[] { + const transactionIDs: string[] = []; + + for (const item of Object.values(searchResultsData)) { + const maybeTransaction = item as {transactionID?: string; transactions?: Array<{transactionID?: string}>}; + if (maybeTransaction?.transactionID) { + transactionIDs.push(maybeTransaction.transactionID); + } + + if (Array.isArray(maybeTransaction?.transactions)) { + for (const transaction of maybeTransaction.transactions) { + if (!transaction?.transactionID) { + continue; + } + transactionIDs.push(transaction.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 079214806cd6..000000000000 --- a/src/hooks/useSearchHighlightAndScroll.ts +++ /dev/null @@ -1,357 +0,0 @@ -import {useIsFocused} from '@react-navigation/native'; -import {useCallback, useEffect, useRef, useState} from 'react'; -import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; -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 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/libs/actions/IOU/NavigationHelpers.ts b/src/libs/actions/IOU/NavigationHelpers.ts index 7c716bd5c424..04110c523f21 100644 --- a/src/libs/actions/IOU/NavigationHelpers.ts +++ b/src/libs/actions/IOU/NavigationHelpers.ts @@ -1,8 +1,5 @@ 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'; /** @@ -16,17 +13,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. @@ -64,4 +50,4 @@ function handleNavigateAfterExpenseCreate({ }); } -export {dismissModalAndOpenReportInInboxTab, handleNavigateAfterExpenseCreate, highlightTransactionOnSearchRouteIfNeeded}; +export {dismissModalAndOpenReportInInboxTab, handleNavigateAfterExpenseCreate}; diff --git a/src/libs/actions/IOU/PerDiem.ts b/src/libs/actions/IOU/PerDiem.ts index 770becac7005..49808d92ef43 100644 --- a/src/libs/actions/IOU/PerDiem.ts +++ b/src/libs/actions/IOU/PerDiem.ts @@ -60,7 +60,7 @@ import { mergePolicyRecentlyUsedCurrencies, } from './MoneyRequestBuilder'; import type {MoneyRequestInformation} from './MoneyRequestBuilder'; -import {dismissModalAndOpenReportInInboxTab, highlightTransactionOnSearchRouteIfNeeded} from './NavigationHelpers'; +import {dismissModalAndOpenReportInInboxTab} from './NavigationHelpers'; import type BasePolicyParams from './types/BasePolicyParams'; import type BaseTransactionParams from './types/BaseTransactionParams'; import type RequestMoneyParticipantParams from './types/RequestMoneyParticipantParams'; @@ -1018,8 +1018,6 @@ function submitPerDiemExpense(submitPerDiemExpenseInformation: PerDiemExpenseInf InteractionManager.runAfterInteractions(() => removeDraftTransaction(CONST.IOU.OPTIMISTIC_TRANSACTION_ID)); - highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate, transaction.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE); - if (activeReportID) { notifyNewAction(activeReportID, undefined, participantParams.payeeAccountID === currentUserAccountIDParam); } diff --git a/src/libs/actions/IOU/SendInvoice.ts b/src/libs/actions/IOU/SendInvoice.ts index 3028f4be8998..e7cb65872f66 100644 --- a/src/libs/actions/IOU/SendInvoice.ts +++ b/src/libs/actions/IOU/SendInvoice.ts @@ -37,7 +37,7 @@ import type {Receipt} from '@src/types/onyx/Transaction'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {getAllPersonalDetails} from '.'; import {getReceiptError, mergePolicyRecentlyUsedCategories, mergePolicyRecentlyUsedCurrencies} from './MoneyRequestBuilder'; -import {handleNavigateAfterExpenseCreate, highlightTransactionOnSearchRouteIfNeeded} from './NavigationHelpers'; +import {handleNavigateAfterExpenseCreate} from './NavigationHelpers'; import {getSearchOnyxUpdate} from './SearchUpdate'; import type BasePolicyParams from './types/BasePolicyParams'; @@ -809,8 +809,6 @@ function sendInvoice({ InteractionManager.runAfterInteractions(() => removeDraftTransaction(CONST.IOU.OPTIMISTIC_TRANSACTION_ID)); - highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate, transactionID, CONST.SEARCH.DATA_TYPES.INVOICE); - if (shouldHandleNavigation) { handleNavigateAfterExpenseCreate({ activeReportID: invoiceRoom.reportID, diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index 25da15770480..48832c858613 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -77,7 +77,7 @@ import { mergePolicyRecentlyUsedCurrencies, } from './MoneyRequestBuilder'; import type {BuildOnyxDataForMoneyRequestKeys, OneOnOneIOUReport} from './MoneyRequestBuilder'; -import {dismissModalAndOpenReportInInboxTab, handleNavigateAfterExpenseCreate, highlightTransactionOnSearchRouteIfNeeded} from './NavigationHelpers'; +import {dismissModalAndOpenReportInInboxTab, handleNavigateAfterExpenseCreate} from './NavigationHelpers'; import type BasePolicyParams from './types/BasePolicyParams'; import type BaseTransactionParams from './types/BaseTransactionParams'; @@ -2166,7 +2166,6 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest if (shouldHandleNavigation) { const navigationActiveReportID = backToReport ?? activeReportID; - highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate, parameters.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE); handleNavigateAfterExpenseCreate({ activeReportID: navigationActiveReportID, diff --git a/src/libs/actions/IOU/TrackExpense.ts b/src/libs/actions/IOU/TrackExpense.ts index 28911c6e4e94..2bf6f615eca2 100644 --- a/src/libs/actions/IOU/TrackExpense.ts +++ b/src/libs/actions/IOU/TrackExpense.ts @@ -107,7 +107,7 @@ import {deleteMoneyRequest, getCleanUpTransactionThreadReportOnyxData, getNaviga import {getAllReports, getAllTransactionDrafts, getAllTransactions, getAllTransactionViolations, getMoneyRequestPolicyTags} from './index'; import {buildMinimalTransactionForFormula, getMoneyRequestInformation, getReceiptError, getReportPreviewAction, getTransactionWithPreservedLocalReceiptSource} from './MoneyRequestBuilder'; import type {BuildOnyxDataForMoneyRequestKeys, RequestMoneyInformation} from './MoneyRequestBuilder'; -import {handleNavigateAfterExpenseCreate, highlightTransactionOnSearchRouteIfNeeded} from './NavigationHelpers'; +import {handleNavigateAfterExpenseCreate} from './NavigationHelpers'; import type {ReplaceReceipt} from './Receipt'; import {getSearchOnyxUpdate} from './SearchUpdate'; import type {StartSplitBilActionParams} from './Split'; @@ -1885,7 +1885,6 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep } if (!requestMoneyInformation.isRetry) { - highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate, transaction.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE); if (shouldHandleNavigation) { const navigationReportID = backToReport ?? activeReportID; handleNavigateAfterExpenseCreate({ @@ -2704,8 +2703,6 @@ function trackExpense(params: CreateTrackExpenseParams) { }); if (!params.isRetry) { - highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate, transaction?.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE); - if (shouldHandleNavigation) { handleNavigateAfterExpenseCreate({ activeReportID, diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts index 861d0064a293..459b6dbb467d 100644 --- a/src/libs/actions/Transaction.ts +++ b/src/libs/actions/Transaction.ts @@ -82,7 +82,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'; @@ -1872,10 +1871,6 @@ function changeTransactionsReport({ }); } -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; @@ -1907,6 +1902,5 @@ export { revert, changeTransactionsReport, setTransactionReport, - mergeTransactionIdsHighlightOnSearchRoute, getDuplicateTransactionDetails, }; diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts index 1e8ab8310647..4d9649a0440c 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -21,7 +21,6 @@ import { setMoneyRequestTag, } from '@libs/actions/IOU/MoneyRequest'; import {calculateDiffAmount} from '@libs/actions/IOU/MoneyRequestBuilder'; -import {handleNavigateAfterExpenseCreate} from '@libs/actions/IOU/NavigationHelpers'; import {shouldOptimisticallyUpdateSearch} from '@libs/actions/IOU/SearchUpdate'; import {completeSplitBill, createSplitsAndOnyxData, splitBill, startSplitBill} from '@libs/actions/IOU/Split'; import {updateSplitTransactionsFromSplitExpensesFlow} from '@libs/actions/IOU/SplitTransactionUpdate'; @@ -34,7 +33,6 @@ import {subscribeToUserEvents} from '@libs/actions/User'; import type {ApiCommand} from '@libs/API/types'; import {WRITE_COMMANDS} from '@libs/API/types'; import Log from '@libs/Log'; -import isReportTopmostSplitNavigator from '@libs/Navigation/helpers/isReportTopmostSplitNavigator'; import Navigation from '@libs/Navigation/Navigation'; import {rand64} from '@libs/NumberUtils'; import type * as PolicyUtils from '@libs/PolicyUtils'; @@ -108,7 +106,6 @@ jest.mock('@src/libs/actions/Report', () => { }; }); jest.mock('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute', () => jest.fn()); -jest.mock('@libs/Navigation/helpers/isReportTopmostSplitNavigator', () => jest.fn()); // In production, requestMoney defers its API.write() call until the target screen's // content lays out (or a safety timeout fires). In tests there is no target component // to flush the deferred write, so we bypass the deferral by executing the callback immediately. @@ -6582,33 +6579,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('resetDraftTransactionsCustomUnit', () => { it('should do nothing if transaction is not passed', async () => { // Call the reset function without a transaction 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 9a7c3e4680e8..2c50a3e5c1c7 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 Onyx from 'react-native-onyx'; -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'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -25,14 +25,14 @@ afterEach(() => { jest.clearAllMocks(); }); -describe('useSearchHighlightAndScroll', () => { +describe('useSearchAutoRefetch', () => { beforeAll(async () => { Onyx.init({ keys: ONYXKEYS, }); }); - const baseProps: UseSearchHighlightAndScroll = { + const baseProps: UseSearchAutoRefetch = { shouldUseLiveData: false, searchResults: { data: { @@ -70,7 +70,7 @@ describe('useSearchHighlightAndScroll', () => { }; it('should not trigger search when collections are empty', () => { - renderHook(() => useSearchHighlightAndScroll(baseProps)); + renderHook(() => useSearchAutoRefetch(baseProps)); expect(search).not.toHaveBeenCalled(); }); @@ -81,7 +81,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, @@ -105,7 +105,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, }); @@ -138,7 +138,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, @@ -173,7 +173,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, @@ -212,7 +212,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, @@ -232,157 +232,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 ba654a8435117e16a29aac3547bc64cfb73f8a6e Mon Sep 17 00:00:00 2001 From: borys3kk Date: Wed, 27 May 2026 12:09:14 +0200 Subject: [PATCH 14/40] fix lint with original justification --- src/hooks/useSearchAutoRefetch.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/hooks/useSearchAutoRefetch.ts b/src/hooks/useSearchAutoRefetch.ts index 85e5ff04b1ae..f11ce25a5f5b 100644 --- a/src/hooks/useSearchAutoRefetch.ts +++ b/src/hooks/useSearchAutoRefetch.ts @@ -4,6 +4,7 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {SearchQueryJSON} from '@components/Search/types'; import {search} from '@libs/actions/Search'; import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; +// eslint-disable-next-line no-restricted-imports -- TransitionTracker is the right primitive here: search should be deferred until after navigation transitions complete, not blocked on idle time import TransitionTracker from '@libs/Navigation/TransitionTracker'; import {isReportActionEntry} from '@libs/SearchUIUtils'; import type {SearchKey} from '@libs/SearchUIUtils'; From 905fe1d9a2f054f9167c148ea7d675cbdacacac6 Mon Sep 17 00:00:00 2001 From: borys3kk Date: Wed, 27 May 2026 17:13:07 +0200 Subject: [PATCH 15/40] fix failing tests --- tests/actions/IOU/SplitReportTotalsTest.ts | 30 ---------------------- 1 file changed, 30 deletions(-) diff --git a/tests/actions/IOU/SplitReportTotalsTest.ts b/tests/actions/IOU/SplitReportTotalsTest.ts index ca5f13300780..726def79d90a 100644 --- a/tests/actions/IOU/SplitReportTotalsTest.ts +++ b/tests/actions/IOU/SplitReportTotalsTest.ts @@ -2,10 +2,8 @@ import Onyx from 'react-native-onyx'; import type {OnyxEntry, OnyxMergeCollectionInput} from 'react-native-onyx'; import '@libs/actions/IOU/MoneyRequest'; -import {handleNavigateAfterExpenseCreate} from '@libs/actions/IOU/NavigationHelpers'; import {createSplitsAndOnyxData} from '@libs/actions/IOU/Split'; 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'; import CONST from '@src/CONST'; @@ -56,7 +54,6 @@ jest.mock('@src/libs/actions/Report', () => { }; }); jest.mock('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute', () => jest.fn()); -jest.mock('@libs/Navigation/helpers/isReportTopmostSplitNavigator', () => jest.fn()); // In production, requestMoney defers its API.write() call until the target screen's // content lays out (or a safety timeout fires). In tests there is no target component // to flush the deferred write, so we bypass the deferral by executing the callback immediately. @@ -421,33 +418,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'}, From 4f56a9c83a739e02faf71a9b074aa7280357b7c9 Mon Sep 17 00:00:00 2001 From: borys3kk Date: Fri, 29 May 2026 15:55:39 +0200 Subject: [PATCH 16/40] fix growl not showing on spend page --- .../GrowlNotificationContent.tsx | 6 - src/components/GrowlNotification/index.tsx | 3 - .../helpers/navigateAfterExpenseCreate.ts | 230 ++++++++---------- src/libs/actions/IOU/SendInvoice.ts | 10 + src/libs/actions/IOU/Split.ts | 10 + src/libs/actions/IOU/TrackExpense.ts | 17 ++ 6 files changed, 138 insertions(+), 138 deletions(-) diff --git a/src/components/GrowlNotification/GrowlNotificationContent.tsx b/src/components/GrowlNotification/GrowlNotificationContent.tsx index 15584701c2ed..b0cc640e45b9 100644 --- a/src/components/GrowlNotification/GrowlNotificationContent.tsx +++ b/src/components/GrowlNotification/GrowlNotificationContent.tsx @@ -1,4 +1,3 @@ -/* eslint-disable no-console -- temporary debug instrumentation for [growl-view] POC */ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import {Directions, Gesture, GestureDetector} from 'react-native-gesture-handler'; @@ -158,8 +157,6 @@ function GrowlNotificationContent({bodyText, type, duration, action, onDismissed [useBottomPosition, triggerDismiss], ); - console.log('[growl-view] INNER render', {bodyText, type, duration, hasAction: !!action, useBottomPosition, shouldUseNarrowLayout}); - return ( { if (isActionPressedRef.current) { - console.log('[growl-view] action button pressed again – ignoring (already dismissing)'); return; } isActionPressedRef.current = true; - console.log('[growl-view] action button pressed', {actionLabel: action.label}); triggerDismiss(); - console.log('[growl-view] calling action.onPress() (navigates)'); action.onPress(); }} style={[styles.mlAuto, styles.p2]} diff --git a/src/components/GrowlNotification/index.tsx b/src/components/GrowlNotification/index.tsx index 7c59e9668cd4..5f113f6cf1b7 100644 --- a/src/components/GrowlNotification/index.tsx +++ b/src/components/GrowlNotification/index.tsx @@ -1,4 +1,3 @@ -/* eslint-disable no-console -- temporary debug instrumentation for [growl-view] POC */ import type {ForwardedRef} from 'react'; import React, {useCallback, useEffect, useImperativeHandle, useState} from 'react'; import {setIsReady} from '@libs/Growl'; @@ -39,8 +38,6 @@ function GrowlNotification({ref}: GrowlNotificationProps) { setContent(null); }, []); - console.log('[growl-view] OUTER render', {hasContent: !!content}); - if (!content) { return null; } diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index 1e6846a34e8b..d00274fffb41 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -1,4 +1,3 @@ -/* eslint-disable no-console -- temporary debug instrumentation for [growl-view] POC */ import Onyx from 'react-native-onyx'; import {addPendingNewTransactionIDs} from '@libs/actions/IOU/PendingNewTransactions'; import {createTransactionThreadReport, setOptimisticTransactionThread} from '@libs/actions/Report'; @@ -76,6 +75,105 @@ type NavigateAfterExpenseCreateParams = { shouldAddPendingNewTransactionIDs?: boolean; }; +type ShowExpenseAddedGrowlParams = { + iouReportID?: string; + transactionID?: string; + transactionThreadReportID?: string; +}; + +/** + * 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}: ShowExpenseAddedGrowlParams) { + if (!transactionID) { + return; + } + + 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('Expense added', CONST.GROWL.DURATION_LONG); + return; + } + const resolvedThreadReportID = threadReportID; + const navigateToExpenseRHP = () => { + const targetRoute = ROUTES.SEARCH_REPORT.getRoute({reportID: resolvedThreadReportID}); + setActiveTransactionIDs([transactionID]).then(() => { + Navigation.navigate(targetRoute); + }); + }; + Growl.success('Expense added', 6000, {label: 'View', onPress: navigateToExpenseRHP}); + }; + + // Fast path: iouAction already in Onyx (rare here since the FAB-from-outside-Spend path + // typically defers the write, but covers retry / non-deferred edge cases). + if (iouReportID && getIOUActionForReportID(iouReportID, transactionID)?.reportActionID) { + const threadReportID = buildThreadFromOnyx(); + showGrowl(threadReportID); + 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); +} + /** * 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. @@ -130,18 +228,6 @@ function navigateAfterExpenseCreate({ const alreadyOnSearchRoot = isUserOnSpend && lastSearchRoute?.name === SCREENS.SEARCH.ROOT; const currentSearchQueryJSON = alreadyOnSearchRoot ? getCurrentSearchQueryJSON() : undefined; const isSameSearchType = currentSearchQueryJSON?.type === type; - console.log('[growl-view] entering variant-B (navigate-to-Search-then-growl-when-ready)', { - transactionID, - iouReportID, - providedTransactionThreadReportID, - isInvoice, - activeReportID, - hasMultipleTransactions, - queryString, - isUserOnSpend, - alreadyOnSearchRoot, - isSameSearchType, - }); setPendingSubmitFollowUpAction( alreadyOnSearchRoot && isSameSearchType ? CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_ONLY : CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.NAVIGATE_TO_SEARCH, @@ -150,12 +236,6 @@ function navigateAfterExpenseCreate({ const navigateToSearch = () => { const isNarrow = getIsNarrowLayout(); const isFullscreenPreInserted = Navigation.getIsFullscreenPreInsertedUnderRHP(); - console.log('[growl-view] navigateToSearch firing', { - isNarrow, - fullscreenPreInserted: isFullscreenPreInserted, - alreadyOnSearchRoot, - isSameSearchType, - }); if (isNarrow && isFullscreenPreInserted) { Navigation.clearFullscreenPreInsertedFlag(); Navigation.dismissModal(); @@ -165,13 +245,10 @@ function navigateAfterExpenseCreate({ 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 { - console.log('[growl-view] navigateToSearch: already on matching Search root with RHP dismissed - no-op'); } return; } if (alreadyOnSearchRoot && isSameSearchType) { - console.log('[growl-view] navigateToSearch (wide): already on matching Search root - no-op'); return; } Navigation.revealRouteBeforeDismissingModal(ROUTES.SEARCH_ROOT.getRoute({query: queryString})); @@ -183,113 +260,8 @@ function navigateAfterExpenseCreate({ Navigation.isNavigationReady().then(navigateToSearch); } - const buildThreadFromOnyx = (logTag: string): string | undefined => { - const iouReport = iouReportID ? getReportOrDraftReport(iouReportID) : undefined; - const iouAction = iouReportID ? getIOUActionForReportID(iouReportID, transactionID) : undefined; - let threadReportID = providedTransactionThreadReportID ?? iouAction?.childReportID; - console.log(`[growl-view] ${logTag} – resolving thread`, { - iouReportExists: !!iouReport, - iouActionExists: !!iouAction, - iouActionReportActionID: iouAction?.reportActionID, - iouActionChildReportID: iouAction?.childReportID, - initialThreadReportID: threadReportID, - }); - if (!threadReportID) { - const transaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; - const optimisticThread = createTransactionThreadReport({ - introSelected, - currentUserLogin: currentUserEmail, - currentUserAccountID, - betas, - iouReport, - iouReportAction: iouAction, - transaction, - }); - threadReportID = optimisticThread?.reportID; - console.log(`[growl-view] ${logTag} – createTransactionThreadReport result`, { - optimisticThreadExists: !!optimisticThread, - optimisticThreadReportID: optimisticThread?.reportID, - optimisticThreadParentReportActionID: optimisticThread?.parentReportActionID, - }); - } else { - setOptimisticTransactionThread(threadReportID, iouReport?.reportID, iouAction?.reportActionID, iouReport?.policyID); - } - return threadReportID; - }; - - const showGrowl = (threadReportID: string | undefined, source: 'onyx' | 'timeout') => { - console.log('[growl-view] triggering growl now', {threadReportID, source}); - if (!threadReportID) { - Log.warn('[navigateAfterExpenseCreate] Unable to resolve transaction thread reportID; growl without View.'); - Growl.success('Expense added', CONST.GROWL.DURATION_LONG); - return; - } - const resolvedThreadReportID = threadReportID; - const navigateToExpenseRHP = () => { - console.log('[growl-view] View clicked – pushing SEARCH_REPORT RHP', { - resolvedThreadReportID, - transactionID, - currentActiveRoute: Navigation.getActiveRoute(), - }); - const targetRoute = ROUTES.SEARCH_REPORT.getRoute({reportID: resolvedThreadReportID}); - setActiveTransactionIDs([transactionID]).then(() => { - Navigation.navigate(targetRoute); - }); - }; - Growl.success('Expense added', 6000, {label: 'View', onPress: navigateToExpenseRHP}); - }; - - // Fast path: iouAction already in Onyx (rare here since the FAB-from-outside-Spend path - // typically defers the write, but covers retry / non-deferred edge cases). - if (iouReportID && getIOUActionForReportID(iouReportID, transactionID)?.reportActionID) { - console.log('[growl-view] fast path – iouAction already in Onyx'); - const threadReportID = buildThreadFromOnyx('fast-path'); - showGrowl(threadReportID, 'onyx'); - 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; - console.log('[growl-view] slow path – subscribing to reportActions, waiting for Search to flush', { - reportActionsKey, - transactionID, - safetyTimeoutMs: SAFETY_TIMEOUT_MS, - }); - const connectionId = Onyx.connectWithoutView({ - key: reportActionsKey, - callback: () => { - if (resolved || !iouReportID) { - return; - } - const iouAction = getIOUActionForReportID(iouReportID, transactionID); - if (!iouAction?.reportActionID) { - console.log('[growl-view] reportActions callback – iouAction not yet present'); - return; - } - resolved = true; - Onyx.disconnect(connectionId); - console.log('[growl-view] reportActions callback – iouAction landed in Onyx', { - iouActionReportActionID: iouAction.reportActionID, - iouActionChildReportID: iouAction.childReportID, - }); - const threadReportID = buildThreadFromOnyx('onyx-landed'); - showGrowl(threadReportID, 'onyx'); - }, - }); - - setTimeout(() => { - if (resolved) { - return; - } - resolved = true; - Onyx.disconnect(connectionId); - console.log('[growl-view] SAFETY TIMEOUT – iouAction never landed, falling back', {SAFETY_TIMEOUT_MS}); - const threadReportID = buildThreadFromOnyx('timeout'); - showGrowl(threadReportID, 'timeout'); - }, SAFETY_TIMEOUT_MS); + showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadReportID: providedTransactionThreadReportID}); } export default navigateAfterExpenseCreate; +export {showExpenseAddedGrowl}; diff --git a/src/libs/actions/IOU/SendInvoice.ts b/src/libs/actions/IOU/SendInvoice.ts index e7cb65872f66..0969ff8fe717 100644 --- a/src/libs/actions/IOU/SendInvoice.ts +++ b/src/libs/actions/IOU/SendInvoice.ts @@ -11,6 +11,8 @@ import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import {formatPhoneNumber} from '@libs/LocalePhoneNumber'; import Log from '@libs/Log'; import isReportTopmostSplitNavigator from '@libs/Navigation/helpers/isReportTopmostSplitNavigator'; +import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; +import {showExpenseAddedGrowl} from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; import {getReportActionHtml, getReportActionText} from '@libs/ReportActionsUtils'; import type {OptimisticChatReport, OptimisticCreatedReportAction, OptimisticIOUReportAction} from '@libs/ReportUtils'; import { @@ -818,6 +820,14 @@ function sendInvoice({ isFromGlobalCreate, isInvoice: true, }); + } else if (isFromGlobalCreate && isSearchTopmostFullScreenRoute()) { + // Dismiss-first paths (orchestrator owns navigation); still surface the "Expense added" + // growl with "View" when the user lands on Spend. + showExpenseAddedGrowl({ + iouReportID: invoiceReportID, + transactionID, + transactionThreadReportID, + }); } notifyNewAction(invoiceRoom.reportID, undefined, true); diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index 99a27d6a2206..d892104e2eb2 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -15,6 +15,8 @@ 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 isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; +import {showExpenseAddedGrowl} from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; import Navigation from '@libs/Navigation/Navigation'; import {roundToTwoDecimalPlaces} from '@libs/NumberUtils'; import * as NumberUtils from '@libs/NumberUtils'; @@ -2165,6 +2167,14 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest transactionThreadReportID: parameters.transactionThreadReportID, shouldAddPendingNewTransactionIDs: navigationActiveReportID === parameters.chatReportID, }); + } else if (isFromGlobalCreate && isSearchTopmostFullScreenRoute()) { + // Dismiss-first paths (orchestrator owns navigation); still surface the "Expense added" + // growl with "View" when the user lands on Spend. + showExpenseAddedGrowl({ + iouReportID: parameters.iouReportID, + transactionID: parameters.transactionID, + transactionThreadReportID: parameters.transactionThreadReportID, + }); } if (!isMoneyRequestReport) { diff --git a/src/libs/actions/IOU/TrackExpense.ts b/src/libs/actions/IOU/TrackExpense.ts index 2bf6f615eca2..01f7722e625f 100644 --- a/src/libs/actions/IOU/TrackExpense.ts +++ b/src/libs/actions/IOU/TrackExpense.ts @@ -22,6 +22,8 @@ import isFileUploadable from '@libs/isFileUploadable'; import {formatPhoneNumber} from '@libs/LocalePhoneNumber'; import Log from '@libs/Log'; import isReportTopmostSplitNavigator from '@libs/Navigation/helpers/isReportTopmostSplitNavigator'; +import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; +import {showExpenseAddedGrowl} from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; import Navigation from '@libs/Navigation/Navigation'; import TransitionTracker from '@libs/Navigation/TransitionTracker'; import {roundToTwoDecimalPlaces} from '@libs/NumberUtils'; @@ -1895,6 +1897,15 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep isFromGlobalCreate, shouldAddPendingNewTransactionIDs: navigationReportID === chatReport.reportID, }); + } else if (isFromGlobalCreate && isSearchTopmostFullScreenRoute()) { + // Navigation is owned by SubmitExpenseOrchestrator (dismiss-first paths). The + // "Expense added" growl with the "View" deep link still needs to fire when the user + // ends up on Spend after the dismissal. + showExpenseAddedGrowl({ + iouReportID: iouReport?.reportID, + transactionID: transaction.transactionID, + transactionThreadReportID: transactionThreadReportID ?? iouAction?.childReportID, + }); } } @@ -2712,6 +2723,12 @@ function trackExpense(params: CreateTrackExpenseParams) { isFromGlobalCreate, shouldAddPendingNewTransactionIDs: action === CONST.IOU.ACTION.CATEGORIZE || action === CONST.IOU.ACTION.SHARE, }); + } else if (isFromGlobalCreate && isSearchTopmostFullScreenRoute()) { + showExpenseAddedGrowl({ + iouReportID: iouReport?.reportID, + transactionID: transaction?.transactionID, + transactionThreadReportID: transactionThreadReportID ?? iouAction?.childReportID, + }); } } From 90c15d974c73f72c5497e5381d5f842d4d0c1bda Mon Sep 17 00:00:00 2001 From: borys3kk Date: Wed, 3 Jun 2026 19:15:02 +0200 Subject: [PATCH 17/40] show growls on every tab, adjust styling --- .../GrowlNotificationContent.tsx | 15 ++++++++----- .../helpers/navigateAfterExpenseCreate.ts | 20 ++++++++++++++---- src/libs/actions/IOU/SendInvoice.ts | 5 ++--- src/libs/actions/IOU/Split.ts | 5 ++--- src/libs/actions/IOU/TrackExpense.ts | 11 +++++----- src/styles/index.ts | 21 ++++++++++++++++--- src/styles/theme/themes/dark.ts | 1 + src/styles/theme/themes/light.ts | 1 + src/styles/theme/types.ts | 2 ++ 9 files changed, 58 insertions(+), 23 deletions(-) diff --git a/src/components/GrowlNotification/GrowlNotificationContent.tsx b/src/components/GrowlNotification/GrowlNotificationContent.tsx index b0cc640e45b9..81ba61d55a92 100644 --- a/src/components/GrowlNotification/GrowlNotificationContent.tsx +++ b/src/components/GrowlNotification/GrowlNotificationContent.tsx @@ -4,6 +4,7 @@ import {Directions, Gesture, GestureDetector} from 'react-native-gesture-handler import {useSharedValue, withSpring} from 'react-native-reanimated'; import type {SvgProps} from 'react-native-svg'; import ActivityIndicator from '@components/ActivityIndicator'; +import Button from '@components/Button'; import Icon from '@components/Icon'; import * as Pressables from '@components/Pressable'; import Text from '@components/Text'; @@ -181,7 +182,9 @@ function GrowlNotificationContent({bodyText, type, duration, action, onDismissed )} {bodyText} {!!action && ( - { @@ -192,10 +195,12 @@ function GrowlNotificationContent({bodyText, type, duration, action, onDismissed triggerDismiss(); action.onPress(); }} - style={[styles.mlAuto, styles.p2]} - > - {action.label} - + innerStyles={styles.bgTransparent} + textStyles={styles.growlNotificationActionText} + shouldUseDefaultHover={false} + hoverStyles={styles.growlNotificationActionHovered} + isNested + /> )} diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index d00274fffb41..e3061edd5428 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -198,10 +198,10 @@ function navigateAfterExpenseCreate({ const isUserOnInbox = isReportTopmostSplitNavigator(); const isUserOnSpend = isSearchTopmostFullScreenRoute(); - // 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) { + // 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) { dismissModalAndOpenReportInInboxTab(activeReportID, isInvoice, hasMultipleTransactions); if (shouldAddPendingNewTransactionIDs) { addPendingNewTransactionIDs(activeReportID, transactionID); @@ -209,6 +209,18 @@ function navigateAfterExpenseCreate({ return; } + // 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) { + dismissModalAndOpenReportInInboxTab(activeReportID, isInvoice, hasMultipleTransactions); + if (shouldAddPendingNewTransactionIDs) { + addPendingNewTransactionIDs(activeReportID, transactionID); + } + showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadReportID: providedTransactionThreadReportID}); + return; + } + 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 diff --git a/src/libs/actions/IOU/SendInvoice.ts b/src/libs/actions/IOU/SendInvoice.ts index 0969ff8fe717..0345108f924a 100644 --- a/src/libs/actions/IOU/SendInvoice.ts +++ b/src/libs/actions/IOU/SendInvoice.ts @@ -11,7 +11,6 @@ import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import {formatPhoneNumber} from '@libs/LocalePhoneNumber'; import Log from '@libs/Log'; import isReportTopmostSplitNavigator from '@libs/Navigation/helpers/isReportTopmostSplitNavigator'; -import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; import {showExpenseAddedGrowl} from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; import {getReportActionHtml, getReportActionText} from '@libs/ReportActionsUtils'; import type {OptimisticChatReport, OptimisticCreatedReportAction, OptimisticIOUReportAction} from '@libs/ReportUtils'; @@ -820,9 +819,9 @@ function sendInvoice({ isFromGlobalCreate, isInvoice: true, }); - } else if (isFromGlobalCreate && isSearchTopmostFullScreenRoute()) { + } else if (isFromGlobalCreate) { // Dismiss-first paths (orchestrator owns navigation); still surface the "Expense added" - // growl with "View" when the user lands on Spend. + // growl with "View" wherever the user lands after the dismissal (Spend or a report). showExpenseAddedGrowl({ iouReportID: invoiceReportID, transactionID, diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index d892104e2eb2..81b597ec5b57 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -15,7 +15,6 @@ 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 isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; import {showExpenseAddedGrowl} from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; import Navigation from '@libs/Navigation/Navigation'; import {roundToTwoDecimalPlaces} from '@libs/NumberUtils'; @@ -2167,9 +2166,9 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest transactionThreadReportID: parameters.transactionThreadReportID, shouldAddPendingNewTransactionIDs: navigationActiveReportID === parameters.chatReportID, }); - } else if (isFromGlobalCreate && isSearchTopmostFullScreenRoute()) { + } else if (isFromGlobalCreate) { // Dismiss-first paths (orchestrator owns navigation); still surface the "Expense added" - // growl with "View" when the user lands on Spend. + // growl with "View" wherever the user lands after the dismissal (Spend or a report). showExpenseAddedGrowl({ iouReportID: parameters.iouReportID, transactionID: parameters.transactionID, diff --git a/src/libs/actions/IOU/TrackExpense.ts b/src/libs/actions/IOU/TrackExpense.ts index 01f7722e625f..15e013be1531 100644 --- a/src/libs/actions/IOU/TrackExpense.ts +++ b/src/libs/actions/IOU/TrackExpense.ts @@ -22,7 +22,6 @@ import isFileUploadable from '@libs/isFileUploadable'; import {formatPhoneNumber} from '@libs/LocalePhoneNumber'; import Log from '@libs/Log'; import isReportTopmostSplitNavigator from '@libs/Navigation/helpers/isReportTopmostSplitNavigator'; -import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; import {showExpenseAddedGrowl} from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; import Navigation from '@libs/Navigation/Navigation'; import TransitionTracker from '@libs/Navigation/TransitionTracker'; @@ -1897,10 +1896,10 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep isFromGlobalCreate, shouldAddPendingNewTransactionIDs: navigationReportID === chatReport.reportID, }); - } else if (isFromGlobalCreate && isSearchTopmostFullScreenRoute()) { + } else if (isFromGlobalCreate) { // Navigation is owned by SubmitExpenseOrchestrator (dismiss-first paths). The - // "Expense added" growl with the "View" deep link still needs to fire when the user - // ends up on Spend after the dismissal. + // "Expense added" growl with the "View" deep link still needs to fire wherever the + // user ends up after the dismissal (Spend or a report). showExpenseAddedGrowl({ iouReportID: iouReport?.reportID, transactionID: transaction.transactionID, @@ -2723,7 +2722,9 @@ function trackExpense(params: CreateTrackExpenseParams) { isFromGlobalCreate, shouldAddPendingNewTransactionIDs: action === CONST.IOU.ACTION.CATEGORIZE || action === CONST.IOU.ACTION.SHARE, }); - } else if (isFromGlobalCreate && isSearchTopmostFullScreenRoute()) { + } else if (isFromGlobalCreate) { + // Navigation is owned by SubmitExpenseOrchestrator (dismiss-first paths); still surface + // the "Expense added" growl wherever the user lands after the dismissal. showExpenseAddedGrowl({ iouReportID: iouReport?.reportID, transactionID: transaction?.transactionID, diff --git a/src/styles/index.ts b/src/styles/index.ts index 0300a9b78445..4d5c739cbec3 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -3349,7 +3349,14 @@ const staticStyles = (theme: ThemeColors) => flexDirection: 'row', justifyContent: 'space-between', boxShadow: `${theme.shadow}`, - ...spacing.p5, + // Spec: padding-left 16px, padding-top/right/bottom 8px, 12px gap between items. + // The 8px top/bottom padding + the 40px-tall Medium Link action button add up to the + // 56px growl height from the design. + ...spacing.pl4, + ...spacing.pt2, + ...spacing.pr2, + ...spacing.pb2, + ...spacing.gap3, }, growlNotificationText: { @@ -3357,7 +3364,6 @@ const staticStyles = (theme: ThemeColors) => ...FontUtils.fontFamily.platform.EXP_NEUE, lineHeight: variables.fontSizeNormalHeight, color: theme.textReversed, - ...spacing.ml4, }, growlNotificationTextWithoutAction: { @@ -3365,10 +3371,19 @@ const staticStyles = (theme: ThemeColors) => }, growlNotificationTextWithAction: { - flexShrink: 1, + flex: 1, minWidth: 0, }, + // "View" action rendered as a Medium Link Button on the growl's inverse-colored surface. + growlNotificationActionText: { + color: theme.linkReversed, + }, + + growlNotificationActionHovered: { + backgroundColor: theme.buttonHoveredBGReversed, + }, + noSelect: { boxShadow: 'none', // After https://github.com/facebook/react-native/pull/46284 RN accepts only 3 options and undefined diff --git a/src/styles/theme/themes/dark.ts b/src/styles/theme/themes/dark.ts index fc8b58a7302b..5eada5d2a33c 100644 --- a/src/styles/theme/themes/dark.ts +++ b/src/styles/theme/themes/dark.ts @@ -30,6 +30,7 @@ const darkTheme = { linkReversed: colors.blue600, buttonDefaultBG: colors.productDark400, buttonHoveredBG: colors.productDark500, + buttonHoveredBGReversed: colors.productLight500, buttonPressedBG: colors.productDark600, buttonSuccessText: colors.productLight100, danger: colors.red, diff --git a/src/styles/theme/themes/light.ts b/src/styles/theme/themes/light.ts index a9d91201952f..5839c0dfcf92 100644 --- a/src/styles/theme/themes/light.ts +++ b/src/styles/theme/themes/light.ts @@ -30,6 +30,7 @@ const lightTheme = { linkReversed: colors.blue300, buttonDefaultBG: colors.productLight400, buttonHoveredBG: colors.productLight500, + buttonHoveredBGReversed: colors.productDark500, buttonPressedBG: colors.productLight600, buttonSuccessText: colors.productLight100, danger: colors.red, diff --git a/src/styles/theme/types.ts b/src/styles/theme/types.ts index cb3b09b9cc6a..a6631f036a17 100644 --- a/src/styles/theme/types.ts +++ b/src/styles/theme/types.ts @@ -36,6 +36,8 @@ type ThemeColors = { linkReversed: Color; buttonDefaultBG: Color; buttonHoveredBG: Color; + /** Hover background for buttons rendered on an inverse-colored surface (e.g. the growl notification). */ + buttonHoveredBGReversed: Color; buttonPressedBG: Color; buttonSuccessText: Color; danger: Color; From 4e894c091ed073cb256d3d6df6c48c4b01972ca2 Mon Sep 17 00:00:00 2001 From: borys3kk Date: Fri, 12 Jun 2026 11:39:11 +0200 Subject: [PATCH 18/40] update growl to super wide rhp of report and then rhp of the expense --- .../helpers/navigateAfterExpenseCreate.ts | 46 ++++++++++++++++--- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index e3061edd5428..f61396ae3200 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -19,6 +19,7 @@ import type {Beta, IntroSelected, Transaction} from '@src/types/onyx'; import dismissModalAndOpenReportInInboxTab from './dismissModalAndOpenReportInInboxTab'; import isReportTopmostSplitNavigator from './isReportTopmostSplitNavigator'; import isSearchTopmostFullScreenRoute from './isSearchTopmostFullScreenRoute'; +import setNavigationActionToMicrotaskQueue from './setNavigationActionToMicrotaskQueue'; let currentUserEmail = ''; let currentUserAccountID: number = CONST.DEFAULT_NUMBER_ID; @@ -79,6 +80,12 @@ 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; }; /** @@ -90,7 +97,7 @@ type ShowExpenseAddedGrowlParams = { * 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}: ShowExpenseAddedGrowlParams) { +function showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadReportID: providedTransactionThreadReportID, isInbox}: ShowExpenseAddedGrowlParams) { if (!transactionID) { return; } @@ -125,9 +132,36 @@ function showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadRep } const resolvedThreadReportID = threadReportID; const navigateToExpenseRHP = () => { - const targetRoute = ROUTES.SEARCH_REPORT.getRoute({reportID: resolvedThreadReportID}); - setActiveTransactionIDs([transactionID]).then(() => { - Navigation.navigate(targetRoute); + 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: open the expense report in a super wide RHP underneath, then + // stack the transaction thread RHP on top of it. + if (iouReportID) { + 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()})); + }); }); }; Growl.success('Expense added', 6000, {label: 'View', onPress: navigateToExpenseRHP}); @@ -217,7 +251,7 @@ function navigateAfterExpenseCreate({ if (shouldAddPendingNewTransactionIDs) { addPendingNewTransactionIDs(activeReportID, transactionID); } - showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadReportID: providedTransactionThreadReportID}); + showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadReportID: providedTransactionThreadReportID, isInbox: true}); return; } @@ -272,7 +306,7 @@ function navigateAfterExpenseCreate({ Navigation.isNavigationReady().then(navigateToSearch); } - showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadReportID: providedTransactionThreadReportID}); + showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadReportID: providedTransactionThreadReportID, isInbox: false}); } export default navigateAfterExpenseCreate; From 2c542561a4270d3592043898b6254a1d08ce9353 Mon Sep 17 00:00:00 2001 From: borys3kk Date: Fri, 12 Jun 2026 12:39:47 +0200 Subject: [PATCH 19/40] fix eslint errors --- src/hooks/useSearchAutoRefetch.ts | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/src/hooks/useSearchAutoRefetch.ts b/src/hooks/useSearchAutoRefetch.ts index f11ce25a5f5b..d03c60a41321 100644 --- a/src/hooks/useSearchAutoRefetch.ts +++ b/src/hooks/useSearchAutoRefetch.ts @@ -128,21 +128,33 @@ function useSearchAutoRefetch({ }, [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)) { - const maybeTransaction = item as {transactionID?: string; transactions?: Array<{transactionID?: string}>}; - if (maybeTransaction?.transactionID) { - transactionIDs.push(maybeTransaction.transactionID); + if (!item || typeof item !== 'object') { + continue; + } + + const itemTransactionID = getTransactionIDFromValue(item); + if (itemTransactionID) { + transactionIDs.push(itemTransactionID); } - if (Array.isArray(maybeTransaction?.transactions)) { - for (const transaction of maybeTransaction.transactions) { - if (!transaction?.transactionID) { - continue; + if ('transactions' in item && Array.isArray(item.transactions)) { + for (const transaction of item.transactions) { + const transactionID = getTransactionIDFromValue(transaction); + if (transactionID) { + transactionIDs.push(transactionID); } - transactionIDs.push(transaction.transactionID); } } } From 0f1ca9d280401ad9ec067b4ac92fce1336141600 Mon Sep 17 00:00:00 2001 From: borys3kk Date: Fri, 12 Jun 2026 13:28:41 +0200 Subject: [PATCH 20/40] remove unused eslint disable directive --- src/hooks/useSearchAutoRefetch.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/hooks/useSearchAutoRefetch.ts b/src/hooks/useSearchAutoRefetch.ts index d03c60a41321..24f53d47f611 100644 --- a/src/hooks/useSearchAutoRefetch.ts +++ b/src/hooks/useSearchAutoRefetch.ts @@ -4,7 +4,6 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {SearchQueryJSON} from '@components/Search/types'; import {search} from '@libs/actions/Search'; import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; -// eslint-disable-next-line no-restricted-imports -- TransitionTracker is the right primitive here: search should be deferred until after navigation transitions complete, not blocked on idle time import TransitionTracker from '@libs/Navigation/TransitionTracker'; import {isReportActionEntry} from '@libs/SearchUIUtils'; import type {SearchKey} from '@libs/SearchUIUtils'; From 5ae4c8b5a549449d1d0f6bf72285afc932a159da Mon Sep 17 00:00:00 2001 From: borys3kk Date: Fri, 12 Jun 2026 16:09:44 +0200 Subject: [PATCH 21/40] fix double growl showing --- .../step/confirmation/useExpenseSubmission.ts | 39 +++---------------- 1 file changed, 5 insertions(+), 34 deletions(-) diff --git a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts index 7679e9f9460a..caf09cafbcb5 100644 --- a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts +++ b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts @@ -23,7 +23,6 @@ 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'; @@ -50,7 +49,6 @@ import { isGPSDistanceRequest as isGPSDistanceRequestTransactionUtils, isManualDistanceRequest as isManualDistanceRequestTransactionUtils, } from '@libs/TransactionUtils'; -import {resolveChatTargetForSubmitCleanup} from '@pages/iou/request/step/resolveChatTarget'; import {submitPerDiemExpenseForSelfDM, submitPerDiemExpense as submitPerDiemExpenseIOUActions} from '@userActions/IOU/PerDiem'; import {getReceiverType, sendInvoice} from '@userActions/IOU/SendInvoice'; import {sendMoneyElsewhere, sendMoneyWithWallet} from '@userActions/IOU/SendMoney'; @@ -288,13 +286,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { const [storedTransactions] = useTransactionsByID(transactionIDs); function performPostBatchCleanup({ - participant, - shouldHandleNavigation, allTransactionsCreated, - fallbackOptimisticChatReportID, - navigateBackToReport, - lastOptimisticTransactionID, - preResolvedChatTarget, }: { participant: Participant; shouldHandleNavigation: boolean; @@ -309,32 +301,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) { From 63804a168571407df133f14b20d6055c2d19b38e Mon Sep 17 00:00:00 2001 From: borys3kk Date: Fri, 12 Jun 2026 16:42:12 +0200 Subject: [PATCH 22/40] fix failing submission tests with growls --- src/components/Search/SearchList/index.tsx | 4 --- tests/unit/hooks/useExpenseSubmission.test.ts | 35 ++++++++++--------- 2 files changed, 19 insertions(+), 20 deletions(-) diff --git a/src/components/Search/SearchList/index.tsx b/src/components/Search/SearchList/index.tsx index 3c56fe0aa480..6949c751ffaa 100644 --- a/src/components/Search/SearchList/index.tsx +++ b/src/components/Search/SearchList/index.tsx @@ -465,7 +465,6 @@ function SearchList({ if (isGroupChildrenContainerItem(item)) { const containerItem = item; const originalKey = (item.keyForList ?? '').replace('children_', ''); - const containerNewTransactionID = item.keyForList ? newTransactionIDByItemKey.get(originalKey) : undefined; const isContainerSelected = !!containerItem.isSelected || (containerItem.transactions.length > 0 && containerItem.transactions.every((t) => selectedTransactions[t.transactionID]?.isSelected)); return ( @@ -483,7 +482,6 @@ function SearchList({ nonPersonalAndWorkspaceCards={nonPersonalAndWorkspaceCards} onUndelete={handleUndelete} isLastItem={index === lastVisibleIndex && !ListFooterComponent} - newTransactionID={containerNewTransactionID} bankAccountList={bankAccountList} cardFeeds={cardFeeds} conciergeReportID={conciergeReportID} @@ -495,8 +493,6 @@ function SearchList({ const isDisabled = item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; const shouldApplyAnimation = shouldAnimate && index < listData.length - 1; - const newTransactionID = item.keyForList ? newTransactionIDByItemKey.get(item.keyForList) : undefined; - return ( { 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(); @@ -220,14 +220,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', @@ -258,16 +259,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())); @@ -279,7 +281,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 () => { @@ -352,7 +355,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(); @@ -362,8 +365,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 () => { From 84e2a4ed602d30c05a25737377369c211fd8a6d4 Mon Sep 17 00:00:00 2001 From: borys3kk Date: Mon, 15 Jun 2026 12:15:22 +0200 Subject: [PATCH 23/40] fix lint --- src/components/Search/SearchList/index.tsx | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/src/components/Search/SearchList/index.tsx b/src/components/Search/SearchList/index.tsx index eea083c87555..f746586da242 100644 --- a/src/components/Search/SearchList/index.tsx +++ b/src/components/Search/SearchList/index.tsx @@ -42,19 +42,7 @@ import GroupHeader from './ListItem/GroupHeader'; 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 {isGroupChildrenContainerItem, isGroupHeaderItem} from './ListItem/types'; import SearchSelectAllMenu from './SearchSelectAllMenu'; From 1af768fc33aba4c0bf2f6d8a8eb7061419fd0912 Mon Sep 17 00:00:00 2001 From: borys3kk Date: Mon, 15 Jun 2026 14:50:14 +0200 Subject: [PATCH 24/40] fix preserve dismiss-first navigation ownership --- .../helpers/cleanupAfterSkipConfirmSubmit.ts | 13 +++++-------- src/libs/actions/IOU/MoneyRequest.ts | 5 +++++ src/pages/Share/SubmitDetailsPage.tsx | 4 ++++ src/pages/iou/request/step/IOURequestStepAmount.tsx | 4 +++- .../handleMoneyRequestStepDistanceNavigation.ts | 7 ++++--- .../components/ScanSkipConfirmation.tsx | 9 +++++---- tests/unit/cleanupAfterSkipConfirmSubmitTest.ts | 12 ++---------- 7 files changed, 28 insertions(+), 26 deletions(-) diff --git a/src/libs/Navigation/helpers/cleanupAfterSkipConfirmSubmit.ts b/src/libs/Navigation/helpers/cleanupAfterSkipConfirmSubmit.ts index 65224064cb68..a01b95a3a292 100644 --- a/src/libs/Navigation/helpers/cleanupAfterSkipConfirmSubmit.ts +++ b/src/libs/Navigation/helpers/cleanupAfterSkipConfirmSubmit.ts @@ -1,16 +1,13 @@ import cleanupAfterExpenseCreate from './cleanupAfterExpenseCreate'; -import cleanupAndNavigateAfterExpenseCreate from './cleanupAndNavigateAfterExpenseCreate'; import type {CleanupAndNavigateAfterExpenseCreateParams} 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/actions/IOU/MoneyRequest.ts b/src/libs/actions/IOU/MoneyRequest.ts index d7cbdfeb5659..2621c6bf9d7a 100644 --- a/src/libs/actions/IOU/MoneyRequest.ts +++ b/src/libs/actions/IOU/MoneyRequest.ts @@ -83,6 +83,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({ @@ -111,6 +113,7 @@ function createTransaction({ optimisticTransactionIDs, optimisticChatReportID, currentUserLocalCurrency, + shouldHandleNavigation = true, }: CreateTransactionParams) { const draftTransactionIDs = Object.keys(allTransactionDrafts ?? {}); @@ -158,6 +161,7 @@ function createTransaction({ optimisticChatReportID, optimisticTransactionID, currentUserLocalCurrency, + shouldHandleNavigation, }); } else { const existingTransactionID = getExistingTransactionID(transaction?.linkedTrackedExpenseReportAction); @@ -200,6 +204,7 @@ function createTransaction({ personalDetails, optimisticChatReportID, optimisticTransactionID, + shouldHandleNavigation, }); } } diff --git a/src/pages/Share/SubmitDetailsPage.tsx b/src/pages/Share/SubmitDetailsPage.tsx index 2d0bf0f73840..7981fe3473b8 100644 --- a/src/pages/Share/SubmitDetailsPage.tsx +++ b/src/pages/Share/SubmitDetailsPage.tsx @@ -265,6 +265,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; @@ -309,6 +311,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/IOURequestStepAmount.tsx b/src/pages/iou/request/step/IOURequestStepAmount.tsx index 22bbe45a6e87..a7eb68294c78 100644 --- a/src/pages/iou/request/step/IOURequestStepAmount.tsx +++ b/src/pages/iou/request/step/IOURequestStepAmount.tsx @@ -350,6 +350,7 @@ function IOURequestStepAmount({ optimisticChatReportID, optimisticTransactionID, currentUserLocalCurrency: currentUserPersonalDetails.localCurrencyCode ?? CONST.CURRENCY.USD, + shouldHandleNavigation: overrides.shouldHandleNavigation, }); } else { const existingTransactionDraft = existingTransactionID ? transactionDrafts?.[existingTransactionID] : undefined; @@ -384,9 +385,10 @@ function IOURequestStepAmount({ personalDetails, optimisticChatReportID, optimisticTransactionID, + shouldHandleNavigation: overrides.shouldHandleNavigation, }); } - cleanupAfterSkipConfirmSubmit(overrides.shouldHandleNavigation, { + cleanupAfterSkipConfirmSubmit({ report, action, draftTransactionIDs, diff --git a/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts b/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts index c7b8c9920e12..188588b5f867 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts +++ b/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts @@ -265,7 +265,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, @@ -317,8 +317,9 @@ function handleMoneyRequestStepDistanceNavigation({ optimisticTransactionID, optimisticChatReportID, currentUserLocalCurrency, + shouldHandleNavigation: overrides.shouldHandleNavigation, }); - cleanupAfterSkipConfirmSubmit(overrides.shouldHandleNavigation, { + cleanupAfterSkipConfirmSubmit({ report, action, draftTransactionIDs, @@ -393,7 +394,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 7d94d402b4bb..84ec9541246c 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx @@ -202,7 +202,7 @@ function ScanSkipConfirmation({report, action, iouType, reportID, transactionID, shouldHandleNavigation: overrides.shouldHandleNavigation, shouldDeferForSearch: false, }); - cleanupAfterSkipConfirmSubmit(overrides.shouldHandleNavigation, { + cleanupAfterSkipConfirmSubmit({ report, action, draftTransactionIDs, @@ -272,7 +272,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, @@ -291,19 +291,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/tests/unit/cleanupAfterSkipConfirmSubmitTest.ts b/tests/unit/cleanupAfterSkipConfirmSubmitTest.ts index 7a4d5c95848f..567755979bec 100644 --- a/tests/unit/cleanupAfterSkipConfirmSubmitTest.ts +++ b/tests/unit/cleanupAfterSkipConfirmSubmitTest.ts @@ -28,16 +28,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({ From 56cf623de17aa94289fbfd38a0d617c78a223b51 Mon Sep 17 00:00:00 2001 From: borys3kk Date: Mon, 15 Jun 2026 14:50:34 +0200 Subject: [PATCH 25/40] Show tracked-expense growl immediately when the thread ID is known --- .../Navigation/helpers/navigateAfterExpenseCreate.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index f61396ae3200..4f2c9ea141a8 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -167,9 +167,12 @@ function showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadRep Growl.success('Expense added', 6000, {label: 'View', onPress: navigateToExpenseRHP}); }; - // Fast path: iouAction already in Onyx (rare here since the FAB-from-outside-Spend path - // typically defers the write, but covers retry / non-deferred edge cases). - if (iouReportID && getIOUActionForReportID(iouReportID, transactionID)?.reportActionID) { + // 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; From 5c84b3095b6d792bc58dcfe1164512d7b322392d Mon Sep 17 00:00:00 2001 From: borys3kk Date: Mon, 15 Jun 2026 14:50:47 +0200 Subject: [PATCH 26/40] add translations for each expense type --- src/languages/en.ts | 2 ++ .../helpers/navigateAfterExpenseCreate.ts | 16 +++++++++++----- src/libs/actions/IOU/SendInvoice.ts | 1 + 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/languages/en.ts b/src/languages/en.ts index 67528ab5a2fd..050c82af4dcb 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1259,6 +1259,8 @@ const translations = { }, iou: { amount: 'Amount', + expenseAdded: 'Expense added', + invoiceSent: 'Invoice sent', percent: 'Percent', date: 'Date', taxAmount: 'Tax amount', diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index 4f2c9ea141a8..d22d74568300 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -4,6 +4,7 @@ import {createTransactionThreadReport, setOptimisticTransactionThread} from '@li 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 Navigation, {navigationRef} from '@libs/Navigation/Navigation'; import {getIOUActionForReportID} from '@libs/ReportActionsUtils'; @@ -86,6 +87,9 @@ type ShowExpenseAddedGrowlParams = { * 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; }; /** @@ -97,11 +101,13 @@ type ShowExpenseAddedGrowlParams = { * 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}: ShowExpenseAddedGrowlParams) { +function showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadReportID: providedTransactionThreadReportID, isInbox, isInvoice}: ShowExpenseAddedGrowlParams) { if (!transactionID) { return; } + 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; @@ -127,7 +133,7 @@ function showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadRep const showGrowl = (threadReportID: string | undefined) => { if (!threadReportID) { Log.warn('[showExpenseAddedGrowl] Unable to resolve transaction thread reportID; growl without View.'); - Growl.success('Expense added', CONST.GROWL.DURATION_LONG); + Growl.success(growlMessage, CONST.GROWL.DURATION_LONG); return; } const resolvedThreadReportID = threadReportID; @@ -164,7 +170,7 @@ function showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadRep }); }); }; - Growl.success('Expense added', 6000, {label: 'View', onPress: navigateToExpenseRHP}); + Growl.success(growlMessage, 6000, {label: translateLocal('common.view'), onPress: navigateToExpenseRHP}); }; // Fast path: the thread is already resolvable, so show the growl immediately instead of waiting on @@ -254,7 +260,7 @@ function navigateAfterExpenseCreate({ if (shouldAddPendingNewTransactionIDs) { addPendingNewTransactionIDs(activeReportID, transactionID); } - showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadReportID: providedTransactionThreadReportID, isInbox: true}); + showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadReportID: providedTransactionThreadReportID, isInbox: true, isInvoice}); return; } @@ -309,7 +315,7 @@ function navigateAfterExpenseCreate({ Navigation.isNavigationReady().then(navigateToSearch); } - showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadReportID: providedTransactionThreadReportID, isInbox: false}); + showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadReportID: providedTransactionThreadReportID, isInbox: false, isInvoice}); } export default navigateAfterExpenseCreate; diff --git a/src/libs/actions/IOU/SendInvoice.ts b/src/libs/actions/IOU/SendInvoice.ts index 1107ceaf779a..26b4a44411a5 100644 --- a/src/libs/actions/IOU/SendInvoice.ts +++ b/src/libs/actions/IOU/SendInvoice.ts @@ -830,6 +830,7 @@ function sendInvoice({ iouReportID: invoiceReportID, transactionID, transactionThreadReportID, + isInvoice: true, }); removeDraftTransaction(CONST.IOU.OPTIMISTIC_TRANSACTION_ID); } else { From 13a286e0056d966818f35ca70a70dd0a1e25fd45 Mon Sep 17 00:00:00 2001 From: borys3kk Date: Mon, 15 Jun 2026 18:10:46 +0200 Subject: [PATCH 27/40] fix after merge errors --- src/components/Search/SearchList/index.tsx | 2 -- src/languages/de.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/navigateAfterExpenseCreate.ts | 2 ++ tests/actions/IOU/MoneyRequestTest.ts | 29 +++++++------------ tests/ui/IOURequestStepAmountDraftTest.tsx | 10 +++---- tests/ui/ScanSkipConfirmationTest.tsx | 8 ++--- 14 files changed, 40 insertions(+), 29 deletions(-) diff --git a/src/components/Search/SearchList/index.tsx b/src/components/Search/SearchList/index.tsx index 4736bce2487a..11df08bc1bec 100644 --- a/src/components/Search/SearchList/index.tsx +++ b/src/components/Search/SearchList/index.tsx @@ -25,7 +25,6 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useUndeleteTransactions from '@hooks/useUndeleteTransactions'; import useWindowDimensions from '@hooks/useWindowDimensions'; import {turnOnMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; -import DateUtils from '@libs/DateUtils'; import getPlatform from '@libs/getPlatform'; import type {ModifiedMouseEvent} from '@libs/Navigation/helpers/openInternalRouteInNewTab'; import navigationRef from '@libs/Navigation/navigationRef'; @@ -501,7 +500,6 @@ function SearchList({ if (isGroupChildrenContainerItem(item)) { const containerItem = item; const originalKey = (item.keyForList ?? '').replace('children_', ''); - const containerNewTransactionID = item.keyForList ? newTransactionIDByItemKey.get(originalKey) : undefined; return ( = { }, iou: { amount: 'Betrag', + expenseAdded: 'Ausgabe hinzugefügt', + invoiceSent: 'Rechnung gesendet', percent: 'Prozent', date: 'Datum', taxAmount: 'Steuerbetrag', diff --git a/src/languages/es.ts b/src/languages/es.ts index efc84f0d89f7..af14d759d42d 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1160,6 +1160,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 f068b0e60990..e49468b85bc3 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1198,6 +1198,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 4e57d145e430..e91a48100d1a 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1195,6 +1195,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 d9290d064531..b99dc21d1936 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1181,6 +1181,8 @@ const translations: TranslationDeepObject = { }, iou: { amount: '金額', + expenseAdded: '経費を追加しました', + invoiceSent: '請求書を送信しました', percent: 'パーセント', date: '日付', taxAmount: '税額', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 3f1d07d72658..1ade836c2b67 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1193,6 +1193,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 b56f419ce639..231eb79024af 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1193,6 +1193,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 b4d9053135b1..50d0e7587712 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1193,6 +1193,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 ca839366cb3a..cf47502199f0 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1155,6 +1155,8 @@ const translations: TranslationDeepObject = { }, iou: { amount: '金额', + expenseAdded: '已添加支出', + invoiceSent: '已发送发票', percent: '百分比', date: '日期', taxAmount: '税额', diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index f1d45315db86..e6e972bb2b20 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -107,6 +107,7 @@ function showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadRep 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 => { @@ -171,6 +172,7 @@ function showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadRep }); }); }; + // eslint-disable-next-line @typescript-eslint/no-deprecated -- imperative module (not a React component); no useLocalize hook available here Growl.success(growlMessage, 6000, {label: translateLocal('common.view'), onPress: navigateToExpenseRHP}); }; diff --git a/tests/actions/IOU/MoneyRequestTest.ts b/tests/actions/IOU/MoneyRequestTest.ts index 819f5f0337d4..7c254288273d 100644 --- a/tests/actions/IOU/MoneyRequestTest.ts +++ b/tests/actions/IOU/MoneyRequestTest.ts @@ -66,19 +66,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); }, })); @@ -179,10 +172,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', () => { @@ -696,7 +689,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, @@ -710,7 +703,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(); @@ -745,8 +738,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, }), @@ -766,7 +759,7 @@ describe('MoneyRequest', () => { await waitForBatchedUpdates(); - expect(mockCleanupAndNavigateAfterExpenseCreate).toHaveBeenCalledWith( + expect(mockCleanupAfterSkipConfirmSubmit).toHaveBeenCalledWith( expect.objectContaining({ transactionID: fakeTransaction.transactionID, }), diff --git a/tests/ui/IOURequestStepAmountDraftTest.tsx b/tests/ui/IOURequestStepAmountDraftTest.tsx index 818f141dba22..4a8773f39e60 100644 --- a/tests/ui/IOURequestStepAmountDraftTest.tsx +++ b/tests/ui/IOURequestStepAmountDraftTest.tsx @@ -13,7 +13,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import SCREENS from '@src/SCREENS'; import type {Report, Transaction} from '@src/types/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'; @@ -96,8 +96,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', () => { @@ -254,9 +254,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 33e8ea3659dc..89efb1aeccb4 100644 --- a/tests/ui/ScanSkipConfirmationTest.tsx +++ b/tests/ui/ScanSkipConfirmationTest.tsx @@ -83,8 +83,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); }, })); @@ -179,7 +179,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); @@ -188,6 +188,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'})); }); }); From 337cd0ffd5e98c8f729c844602b352ae19246ac5 Mon Sep 17 00:00:00 2001 From: borys3kk Date: Tue, 16 Jun 2026 16:40:59 +0200 Subject: [PATCH 28/40] remove all previous highlight logic --- .../Search/SearchList/BaseSearchList/index.tsx | 5 +---- .../Search/SearchList/BaseSearchList/types.ts | 5 +---- .../SearchList/ListItem/GroupChildrenContainer.tsx | 2 -- .../SearchList/ListItem/GroupChildrenContent.tsx | 10 ---------- src/components/Search/SearchList/ListItem/types.ts | 1 - 5 files changed, 2 insertions(+), 21 deletions(-) diff --git a/src/components/Search/SearchList/BaseSearchList/index.tsx b/src/components/Search/SearchList/BaseSearchList/index.tsx index afc35b2a571c..81e860a8879d 100644 --- a/src/components/Search/SearchList/BaseSearchList/index.tsx +++ b/src/components/Search/SearchList/BaseSearchList/index.tsx @@ -163,10 +163,7 @@ function BaseSearchList({ return () => removeKeyDownPressListener(setHasKeyBeenPressed); }, [setHasKeyBeenPressed]); - const extraData = useMemo( - () => [focusedIndex, columns, newTransactions, selectedTransactions, nonPersonalAndWorkspaceCards], - [focusedIndex, columns, newTransactions, selectedTransactions, nonPersonalAndWorkspaceCards], - ); + const extraData = useMemo(() => [focusedIndex, columns, selectedTransactions, nonPersonalAndWorkspaceCards], [focusedIndex, columns, selectedTransactions, nonPersonalAndWorkspaceCards]); return ( , @@ -30,9 +30,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 a6432c493e57..35fe8c523a7f 100644 --- a/src/components/Search/SearchList/ListItem/GroupChildrenContainer.tsx +++ b/src/components/Search/SearchList/ListItem/GroupChildrenContainer.tsx @@ -26,7 +26,6 @@ function GroupChildrenContainer({ nonPersonalAndWorkspaceCards, onUndelete, isLastItem, - newTransactionID, bankAccountList, cardFeeds, conciergeReportID, @@ -78,7 +77,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 6f876082cd80..9c64f8b671cc 100644 --- a/src/components/Search/SearchList/ListItem/GroupChildrenContent.tsx +++ b/src/components/Search/SearchList/ListItem/GroupChildrenContent.tsx @@ -28,7 +28,6 @@ function GroupChildrenContent({ onLongPressRow, nonPersonalAndWorkspaceCards, onUndelete, - newTransactionID, bankAccountList, cardFeeds, conciergeReportID, @@ -121,15 +120,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/types.ts b/src/components/Search/SearchList/ListItem/types.ts index 06f75d1555ab..4f35c4112845 100644 --- a/src/components/Search/SearchList/ListItem/types.ts +++ b/src/components/Search/SearchList/ListItem/types.ts @@ -526,7 +526,6 @@ type GroupChildrenContentProps = { onLongPressRow?: (item: SearchListItem, itemTransactions?: TransactionListItemType[]) => void; nonPersonalAndWorkspaceCards?: CardList; onUndelete?: (transaction: Transaction) => void; - newTransactionID?: string; bankAccountList?: OnyxEntry; cardFeeds?: OnyxCollection; conciergeReportID?: string; From 245045ee400bb2d76ee8172330bbdb0f00114bb3 Mon Sep 17 00:00:00 2001 From: borys3kk Date: Wed, 17 Jun 2026 10:47:33 +0200 Subject: [PATCH 29/40] fix double rhp opened in empty report --- .../helpers/navigateAfterExpenseCreate.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index e6e972bb2b20..8fe752a4795c 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -161,9 +161,18 @@ function showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadRep return; } - // Inbox + wide layout: open the expense report in a super wide RHP underneath, then - // stack the transaction thread RHP on top of it. - if (iouReportID) { + // 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(() => { From 74b3194b29cc67f5ef01e79d807dcb8353c5d989 Mon Sep 17 00:00:00 2001 From: borys3kk Date: Thu, 18 Jun 2026 14:02:53 +0200 Subject: [PATCH 30/40] fix eslint --- src/pages/iou/request/step/AmountSubmission.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/pages/iou/request/step/AmountSubmission.ts b/src/pages/iou/request/step/AmountSubmission.ts index b71a99cdc727..4ebf1d5f2f4a 100644 --- a/src/pages/iou/request/step/AmountSubmission.ts +++ b/src/pages/iou/request/step/AmountSubmission.ts @@ -385,6 +385,7 @@ function submitAmount({ isSelfTourViewed, optimisticChatReportID, optimisticTransactionID, + shouldHandleNavigation: overrides.shouldHandleNavigation, }); } else { const existingTransactionDraft = existingTransactionID ? transactionDrafts?.[existingTransactionID] : undefined; @@ -419,6 +420,7 @@ function submitAmount({ personalDetails: allPersonalDetails, optimisticChatReportID, optimisticTransactionID, + shouldHandleNavigation: overrides.shouldHandleNavigation, }); } cleanupAfterSkipConfirmSubmit({ From cb37ad3a302543d6e97270d55aa19f4257cd6eba Mon Sep 17 00:00:00 2001 From: borys3kk Date: Fri, 19 Jun 2026 13:43:55 +0200 Subject: [PATCH 31/40] batch submissions to one navigation --- src/libs/actions/IOU/MoneyRequestBuilder.ts | 7 +++++++ src/libs/actions/IOU/TrackExpense.ts | 10 ++++++++-- src/libs/actions/IOU/types/CreateTrackExpenseParams.ts | 7 +++++++ .../request/step/confirmation/useExpenseSubmission.ts | 8 ++++++-- 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/libs/actions/IOU/MoneyRequestBuilder.ts b/src/libs/actions/IOU/MoneyRequestBuilder.ts index ae847754fc89..b37ee7c73351 100644 --- a/src/libs/actions/IOU/MoneyRequestBuilder.ts +++ b/src/libs/actions/IOU/MoneyRequestBuilder.ts @@ -159,6 +159,13 @@ type RequestMoneyInformation = { 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[]; diff --git a/src/libs/actions/IOU/TrackExpense.ts b/src/libs/actions/IOU/TrackExpense.ts index 3bbd5724ad8c..fa31736300e8 100644 --- a/src/libs/actions/IOU/TrackExpense.ts +++ b/src/libs/actions/IOU/TrackExpense.ts @@ -1602,6 +1602,7 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep action, shouldPlaySound = true, shouldHandleNavigation = true, + isLastTransactionOfBatch = true, backToReport, optimisticChatReportID, optimisticCreatedReportActionID, @@ -1891,7 +1892,9 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep }); } - if (!requestMoneyInformation.isRetry) { + // 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({ @@ -2336,6 +2339,7 @@ function trackExpense(params: CreateTrackExpenseParams) { transactionParams: transactionData, accountantParams, shouldHandleNavigation = true, + isLastTransactionOfBatch = true, shouldPlaySound = true, optimisticChatReportID, optimisticTransactionID, @@ -2732,7 +2736,9 @@ function trackExpense(params: CreateTrackExpenseParams) { } } - if (!params.isRetry) { + // 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, diff --git a/src/libs/actions/IOU/types/CreateTrackExpenseParams.ts b/src/libs/actions/IOU/types/CreateTrackExpenseParams.ts index 6d42a728536a..f94ece4b10fa 100644 --- a/src/libs/actions/IOU/types/CreateTrackExpenseParams.ts +++ b/src/libs/actions/IOU/types/CreateTrackExpenseParams.ts @@ -23,6 +23,13 @@ type CreateTrackExpenseParams = { 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/pages/iou/request/step/confirmation/useExpenseSubmission.ts b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts index 7deb67630c67..2fd5adba61b9 100644 --- a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts +++ b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts @@ -331,7 +331,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; @@ -437,6 +437,8 @@ 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, + // Action owns post-create navigation + growl; only the final transaction should trigger it. + isLastTransactionOfBatch: index === transactions.length - 1, shouldGenerateTransactionThreadReport, isASAPSubmitBetaEnabled, currentUserAccountIDParam: currentUserPersonalDetails.accountID, @@ -594,7 +596,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}`]; @@ -650,6 +652,8 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { }, optimisticChatReportID: optimisticSelfDMReportID, optimisticTransactionID: lastOptimisticTransactionID, + // Action owns post-create navigation + growl; only the final transaction should trigger it. + isLastTransactionOfBatch: index === transactions.length - 1, isASAPSubmitBetaEnabled, currentUser: {accountID: currentUserPersonalDetails.accountID, email}, introSelected, From 7cb6deff495428c3216ee2d44d21d70fef97eaa4 Mon Sep 17 00:00:00 2001 From: borys3kk Date: Fri, 19 Jun 2026 13:46:23 +0200 Subject: [PATCH 32/40] remove redundant useMemo --- src/components/GrowlNotification/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/GrowlNotification/index.tsx b/src/components/GrowlNotification/index.tsx index 5f113f6cf1b7..7291ac4b7c73 100644 --- a/src/components/GrowlNotification/index.tsx +++ b/src/components/GrowlNotification/index.tsx @@ -1,5 +1,5 @@ import type {ForwardedRef} from 'react'; -import React, {useCallback, useEffect, useImperativeHandle, useState} from 'react'; +import {useCallback, useEffect, useImperativeHandle, useState} from 'react'; import {setIsReady} from '@libs/Growl'; import type {GrowlAction, GrowlRef} from '@libs/Growl'; import GrowlNotificationContent from './GrowlNotificationContent'; @@ -53,4 +53,4 @@ function GrowlNotification({ref}: GrowlNotificationProps) { ); } -export default React.memo(GrowlNotification); +export default GrowlNotification; From fcfc4cd3294b50cdc33a1e7287dc959e008bb80e Mon Sep 17 00:00:00 2001 From: borys3kk Date: Fri, 19 Jun 2026 13:48:07 +0200 Subject: [PATCH 33/40] move growl duration to const --- src/CONST/index.ts | 2 ++ src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 06d779f602f5..90c7247151d1 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -3841,6 +3841,8 @@ const CONST = { LOADING: 'loading', DURATION: 2000, DURATION_LONG: 3500, + // Longer duration for growls with an actionable button (e.g. "View"), giving the user enough time to tap it. + DURATION_WITH_ACTION: 6000, }, LOCALES, diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index 8fe752a4795c..c810ba491fae 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -182,7 +182,7 @@ function showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadRep }); }; // eslint-disable-next-line @typescript-eslint/no-deprecated -- imperative module (not a React component); no useLocalize hook available here - Growl.success(growlMessage, 6000, {label: translateLocal('common.view'), onPress: navigateToExpenseRHP}); + 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 From 828b8753bd9fe5edc3e25624efe879d7105d319a Mon Sep 17 00:00:00 2001 From: borys3kk Date: Fri, 19 Jun 2026 15:33:20 +0200 Subject: [PATCH 34/40] preserve dismiss first navigation ownership pt2 --- src/components/GrowlNotification/index.tsx | 2 +- .../request/step/confirmation/useExpenseSubmission.ts | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/components/GrowlNotification/index.tsx b/src/components/GrowlNotification/index.tsx index 7291ac4b7c73..2d191933372f 100644 --- a/src/components/GrowlNotification/index.tsx +++ b/src/components/GrowlNotification/index.tsx @@ -1,5 +1,5 @@ import type {ForwardedRef} from 'react'; -import {useCallback, useEffect, useImperativeHandle, useState} from 'react'; +import React, {useCallback, useEffect, useImperativeHandle, useState} from 'react'; import {setIsReady} from '@libs/Growl'; import type {GrowlAction, GrowlRef} from '@libs/Growl'; import GrowlNotificationContent from './GrowlNotificationContent'; diff --git a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts index 2fd5adba61b9..3952690946df 100644 --- a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts +++ b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts @@ -437,7 +437,10 @@ 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, - // Action owns post-create navigation + growl; only the final transaction should trigger it. + // 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, @@ -652,7 +655,10 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { }, optimisticChatReportID: optimisticSelfDMReportID, optimisticTransactionID: lastOptimisticTransactionID, - // Action owns post-create navigation + growl; only the final transaction should trigger it. + // 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}, From c3fb5336b308b8b11d9a01ccb1c9cca8eb43920d Mon Sep 17 00:00:00 2001 From: borys3kk Date: Fri, 19 Jun 2026 15:41:00 +0200 Subject: [PATCH 35/40] fix waiting for timeout in no iouReportID --- .../Navigation/helpers/navigateAfterExpenseCreate.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index c810ba491fae..139fa938a9c5 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -196,6 +196,15 @@ function showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadRep 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; From cae8608cb5c4d67487de3e4c438b7f9f5a5e9304 Mon Sep 17 00:00:00 2001 From: borys3kk Date: Fri, 19 Jun 2026 21:49:52 +0200 Subject: [PATCH 36/40] fix for allTransactions reset --- .../helpers/navigateAfterExpenseCreate.ts | 21 +++++++------------ 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index 139fa938a9c5..7f690bbf6205 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -1,4 +1,5 @@ import Onyx from 'react-native-onyx'; +import type {OnyxCollection} from 'react-native-onyx'; import {addPendingNewTransactionIDs} from '@libs/actions/IOU/PendingNewTransactions'; import {createTransactionThreadReport, setOptimisticTransactionThread} from '@libs/actions/Report'; import {setActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation'; @@ -48,21 +49,12 @@ Onyx.connectWithoutView({ }, }); -const allTransactions: Record = {}; +let allTransactions: OnyxCollection; Onyx.connectWithoutView({ key: ONYXKEYS.COLLECTION.TRANSACTION, waitForCollectionCallback: true, callback: (transactions) => { - if (!transactions) { - return; - } - for (const [key, value] of Object.entries(transactions)) { - if (!value) { - delete allTransactions[key]; - } else { - allTransactions[key] = value; - } - } + allTransactions = transactions; }, }); @@ -115,7 +107,7 @@ function showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadRep const iouAction = iouReportID ? getIOUActionForReportID(iouReportID, transactionID) : undefined; let threadReportID = providedTransactionThreadReportID ?? iouAction?.childReportID; if (!threadReportID) { - const transaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; const optimisticThread = createTransactionThreadReport({ introSelected, currentUserLogin: currentUserEmail, @@ -169,8 +161,9 @@ 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; + 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})); From d6a8d121109f297e191d61629022c12a8c5d4976 Mon Sep 17 00:00:00 2001 From: borys3kk Date: Wed, 24 Jun 2026 15:00:31 +0200 Subject: [PATCH 37/40] fix higlighting in report table --- .../helpers/navigateAfterExpenseCreate.ts | 31 ++++++++++++++++- src/libs/actions/IOU/SendInvoice.ts | 12 +++---- src/libs/actions/IOU/Split.ts | 15 ++++---- src/libs/actions/IOU/TrackExpense.ts | 24 +++++++------ .../step/confirmation/useExpenseSubmission.ts | 34 +++++++++++++------ 5 files changed, 78 insertions(+), 38 deletions(-) diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index 7f690bbf6205..7ca5dec7f8b7 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -231,6 +231,35 @@ function showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadRep }, 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}); +} + /** * 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. @@ -343,4 +372,4 @@ function navigateAfterExpenseCreate({ } export default navigateAfterExpenseCreate; -export {showExpenseAddedGrowl}; +export {showExpenseAddedGrowl, surfaceExpenseCreatedFeedback}; diff --git a/src/libs/actions/IOU/SendInvoice.ts b/src/libs/actions/IOU/SendInvoice.ts index 26b4a44411a5..118cd16bc566 100644 --- a/src/libs/actions/IOU/SendInvoice.ts +++ b/src/libs/actions/IOU/SendInvoice.ts @@ -9,7 +9,7 @@ import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import {formatPhoneNumber} from '@libs/LocalePhoneNumber'; import Log from '@libs/Log'; import isReportTopmostSplitNavigator from '@libs/Navigation/helpers/isReportTopmostSplitNavigator'; -import {showExpenseAddedGrowl} from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; +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'; @@ -823,18 +823,16 @@ function sendInvoice({ isFromGlobalCreate, isInvoice: true, }); - } else if (isFromGlobalCreate) { - // Dismiss-first paths (orchestrator owns navigation); still surface the "Expense added" - // growl with "View" wherever the user lands after the dismissal (Spend or a report). - showExpenseAddedGrowl({ + } 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); - } else { - removeDraftTransaction(CONST.IOU.OPTIMISTIC_TRANSACTION_ID); } notifyNewAction(invoiceRoom.reportID, undefined, true); diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index 84f613f97b37..bfff40661345 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -13,7 +13,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 {showExpenseAddedGrowl} from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; +import {surfaceExpenseCreatedFeedback} from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; import Navigation from '@libs/Navigation/Navigation'; import TransitionTracker from '@libs/Navigation/TransitionTracker'; import {roundToTwoDecimalPlaces} from '@libs/NumberUtils'; @@ -2197,19 +2197,18 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest transactionID: parameters.transactionID, transactionThreadReportID: parameters.transactionThreadReportID, - shouldAddPendingNewTransactionIDs: navigationActiveReportID === parameters.chatReportID, + shouldAddPendingNewTransactionIDs: isMoneyRequestReport, }); - } else if (isFromGlobalCreate) { - // Dismiss-first paths (orchestrator owns navigation); still surface the "Expense added" - // growl with "View" wherever the user lands after the dismissal (Spend or a report). - showExpenseAddedGrowl({ + } 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); - } else { - removeDraftTransaction(CONST.IOU.OPTIMISTIC_TRANSACTION_ID); } if (!isMoneyRequestReport) { diff --git a/src/libs/actions/IOU/TrackExpense.ts b/src/libs/actions/IOU/TrackExpense.ts index fa31736300e8..8c001c7310ba 100644 --- a/src/libs/actions/IOU/TrackExpense.ts +++ b/src/libs/actions/IOU/TrackExpense.ts @@ -22,7 +22,7 @@ import {isMovingTransactionFromTrackExpense as isMovingTransactionFromTrackExpen import isFileUploadable from '@libs/isFileUploadable'; import {formatPhoneNumber} from '@libs/LocalePhoneNumber'; import Log from '@libs/Log'; -import {showExpenseAddedGrowl} from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; +import {surfaceExpenseCreatedFeedback} from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; import Navigation from '@libs/Navigation/Navigation'; import {roundToTwoDecimalPlaces} from '@libs/NumberUtils'; import * as NumberUtils from '@libs/NumberUtils'; @@ -1903,16 +1903,17 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep transactionID: transaction.transactionID, transactionThreadReportID: transactionThreadReportID ?? iouAction?.childReportID, isFromGlobalCreate, - shouldAddPendingNewTransactionIDs: navigationReportID === chatReport.reportID, + shouldAddPendingNewTransactionIDs: isMoneyRequestReport, }); - } else if (isFromGlobalCreate) { - // Navigation is owned by SubmitExpenseOrchestrator (dismiss-first paths). The - // "Expense added" growl with the "View" deep link still needs to fire wherever the - // user ends up after the dismissal (Spend or a report). - showExpenseAddedGrowl({ + } 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, }); } } @@ -2748,13 +2749,14 @@ function trackExpense(params: CreateTrackExpenseParams) { isFromGlobalCreate, shouldAddPendingNewTransactionIDs: action === CONST.IOU.ACTION.CATEGORIZE || action === CONST.IOU.ACTION.SHARE, }); - } else if (isFromGlobalCreate) { - // Navigation is owned by SubmitExpenseOrchestrator (dismiss-first paths); still surface - // the "Expense added" growl wherever the user lands after the dismissal. - showExpenseAddedGrowl({ + } 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, }); } } diff --git a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts index 3952690946df..71e436d5bdcb 100644 --- a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts +++ b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts @@ -24,7 +24,7 @@ import Log from '@libs/Log'; import cleanupAfterExpenseCreate from '@libs/Navigation/helpers/cleanupAfterExpenseCreate'; 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 { @@ -568,16 +568,28 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { const isOneToTwoTransition = !backToReport && isOneToTwoTransactionTransition(isMoneyRequestReport, reportTransactions); if (result && targetReportID) { - navigateAfterExpenseCreate({ - activeReportID: targetReportID, - iouReportID: result.iouReport?.reportID, - transactionID: result.transactionID, - transactionThreadReportID: result.transactionThreadReportID, - 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, + }); + } } } } From 07531057dcbee48d208ac164ff3d16d056496211 Mon Sep 17 00:00:00 2001 From: borys3kk Date: Thu, 25 Jun 2026 11:32:55 +0200 Subject: [PATCH 38/40] narrow growl types --- .../GrowlNotification/GrowlNotificationContent.tsx | 8 +++++--- src/components/GrowlNotification/index.tsx | 6 +++--- src/libs/Growl.ts | 9 ++++++--- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/components/GrowlNotification/GrowlNotificationContent.tsx b/src/components/GrowlNotification/GrowlNotificationContent.tsx index 81ba61d55a92..fdfb7fdc59ee 100644 --- a/src/components/GrowlNotification/GrowlNotificationContent.tsx +++ b/src/components/GrowlNotification/GrowlNotificationContent.tsx @@ -12,7 +12,7 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import type {GrowlAction} from '@libs/Growl'; +import type {GrowlAction, GrowlType} from '@libs/Growl'; import CONST from '@src/CONST'; import type IconAsset from '@src/types/utils/IconAsset'; import GrowlNotificationContainer from './GrowlNotificationContainer'; @@ -27,7 +27,7 @@ const PressableWithoutFeedback = Pressables.PressableWithoutFeedback; type GrowlNotificationContentProps = { bodyText: string; - type: string; + type: GrowlType; duration: number; action?: GrowlAction; onDismissed: () => void; @@ -58,8 +58,10 @@ function GrowlNotificationContent({bodyText, type, duration, action, onDismissed const useBottomPosition = !!action && !shouldUseNarrowLayout; const inactiveY = useBottomPosition ? INACTIVE_OFFSET : INACTIVE_POSITION_Y; + // Every non-loading growl variant must have an icon mapping. Loading is rendered separately + // (ActivityIndicator), so it's excluded here — keeping this map exhaustive over the rest. type GrowlIconTypes = Record< - string, + Exclude, { icon: React.FC | IconAsset; iconColor: string; diff --git a/src/components/GrowlNotification/index.tsx b/src/components/GrowlNotification/index.tsx index 2d191933372f..ea267874d63e 100644 --- a/src/components/GrowlNotification/index.tsx +++ b/src/components/GrowlNotification/index.tsx @@ -1,12 +1,12 @@ import type {ForwardedRef} from 'react'; import React, {useCallback, useEffect, useImperativeHandle, useState} from 'react'; import {setIsReady} from '@libs/Growl'; -import type {GrowlAction, GrowlRef} from '@libs/Growl'; +import type {GrowlAction, GrowlRef, GrowlType} from '@libs/Growl'; import GrowlNotificationContent from './GrowlNotificationContent'; type GrowlContent = { bodyText: string; - type: string; + type: GrowlType; duration: number; action?: GrowlAction; }; @@ -24,7 +24,7 @@ type GrowlNotificationProps = { function GrowlNotification({ref}: GrowlNotificationProps) { const [content, setContent] = useState(null); - const show = useCallback((text: string, growlType: string, duration: number, action?: GrowlAction) => { + const show = useCallback((text: string, growlType: GrowlType, duration: number, action?: GrowlAction) => { setContent({bodyText: text, type: growlType, duration, action}); }, []); diff --git a/src/libs/Growl.ts b/src/libs/Growl.ts index 6408e9c54fc1..112ecdd717a6 100644 --- a/src/libs/Growl.ts +++ b/src/libs/Growl.ts @@ -6,8 +6,11 @@ type GrowlAction = { onPress: () => void; }; +/** The set of growl variants the notification UI knows how to render. */ +type GrowlType = typeof CONST.GROWL.SUCCESS | typeof CONST.GROWL.ERROR | typeof CONST.GROWL.WARNING | typeof CONST.GROWL.LOADING; + type GrowlRef = { - show?: (bodyText: string, type: string, duration: number, action?: GrowlAction) => void; + show?: (bodyText: string, type: GrowlType, duration: number, action?: GrowlAction) => void; }; const growlRef = React.createRef(); @@ -26,7 +29,7 @@ function setIsReady() { /** * Show the growl notification */ -function show(bodyText: string, type: string, duration: number = CONST.GROWL.DURATION, action?: GrowlAction) { +function show(bodyText: string, type: GrowlType, duration: number = CONST.GROWL.DURATION, action?: GrowlAction) { isReadyPromise.then(() => { if (!growlRef?.current?.show) { return; @@ -63,6 +66,6 @@ export default { loading, }; -export type {GrowlRef, GrowlAction}; +export type {GrowlRef, GrowlAction, GrowlType}; export {growlRef, setIsReady}; From 95e0afc094b52b92cf4cd7f0a56ab15dae27f40a Mon Sep 17 00:00:00 2001 From: borys3kk Date: Fri, 26 Jun 2026 13:22:12 +0200 Subject: [PATCH 39/40] fix root lookup --- .../Navigation/helpers/navigateAfterExpenseCreate.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index 7ca5dec7f8b7..73f0ffec9d9a 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -7,6 +7,7 @@ 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'; @@ -19,6 +20,7 @@ import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import type {Beta, IntroSelected, Transaction} from '@src/types/onyx'; import dismissModalAndOpenReportInInboxTab from './dismissModalAndOpenReportInInboxTab'; +import getTopmostFullScreenRoute from './getTopmostFullScreenRoute'; import isReportTopmostSplitNavigator from './isReportTopmostSplitNavigator'; import isSearchTopmostFullScreenRoute from './isSearchTopmostFullScreenRoute'; import setNavigationActionToMicrotaskQueue from './setNavigationActionToMicrotaskQueue'; @@ -330,9 +332,13 @@ function navigateAfterExpenseCreate({ // 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}); - const rootState = navigationRef.getRootState(); - const searchNavigatorRoute = rootState?.routes?.findLast((route) => route.name === NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR); - const lastSearchRoute = searchNavigatorRoute?.state?.routes?.at(-1); + // 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 alreadyOnSearchRoot = isUserOnSpend && lastSearchRoute?.name === SCREENS.SEARCH.ROOT; const currentSearchQueryJSON = alreadyOnSearchRoot ? getCurrentSearchQueryJSON() : undefined; const isSameSearchType = currentSearchQueryJSON?.type === type; From 1e63b7a45775785ae9bd7e642058116fcac5705b Mon Sep 17 00:00:00 2001 From: borys3kk Date: Mon, 29 Jun 2026 16:05:24 +0200 Subject: [PATCH 40/40] fix failing tests --- src/components/Search/ExpenseFlatSearchView.tsx | 8 +------- src/components/Search/index.tsx | 2 -- src/languages/de.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 ++ src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts | 2 +- tests/unit/Search/ExpenseFlatSearchViewTest.tsx | 1 - 13 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/components/Search/ExpenseFlatSearchView.tsx b/src/components/Search/ExpenseFlatSearchView.tsx index 8ac03d41a910..df24ff4dfbdb 100644 --- a/src/components/Search/ExpenseFlatSearchView.tsx +++ b/src/components/Search/ExpenseFlatSearchView.tsx @@ -69,9 +69,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; @@ -109,8 +106,7 @@ const isRowDeleted = (item: SearchListItem) => item.pendingAction === CONST.RED_ * primitives directly around `BaseSearchList` — no `SearchList` shell, no `ListItem` prop, no factory. * `TransactionListItem` is the only row renderer for the flat-expense path, and rows always animate, so the * group/sticky/chat/task branches of `SearchList` do not apply here. 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, @@ -124,7 +120,6 @@ function ExpenseFlatSearchView({ SearchTableHeader: searchTableHeader, tableHeaderVisible, hasLoadedAllTransactions, - newTransactions, onSelectRow, ListFooterComponent, onEndReached, @@ -279,7 +274,6 @@ function ExpenseFlatSearchView({ ListFooterComponent={ListFooterComponent} onLayout={onLayout} contentContainerStyle={contentContainerStyle} - newTransactions={newTransactions} isAttendeesEnabledForMovingPolicy={isAttendeesEnabledForMovingPolicy} nonPersonalAndWorkspaceCards={nonPersonalAndWorkspaceCards} /> diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 213579a3fc09..eaeef6118633 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -1037,7 +1037,6 @@ function Search({ ListFooterComponent={listFooterComponent} onLayout={onLayout} isMobileSelectionModeEnabled={isMobileSelectionModeEnabled} - newTransactions={newTransactions} hasLoadedAllTransactions={hasLoadedAllTransactions} isAttendeesEnabledForMovingPolicy={isAttendeesEnabledForMovingPolicy} nonPersonalAndWorkspaceCards={nonPersonalAndWorkspaceCards} @@ -1063,7 +1062,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/languages/de.ts b/src/languages/de.ts index 4396e8c7778c..bfef6d7bd2a7 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7520,6 +7520,8 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc agentRules: { title: 'Agentenregeln', subtitle: 'Legen Sie Regeln fest, wie KI-Agenten mit Ausgaben in diesem Workspace umgehen.', + enforcedBy: 'Agentregeln werden erzwungen durch', + ruleBotName: 'RuleBot', addRule: 'Agentenregel hinzufügen', findRule: 'Agentenregel finden', addRuleTitle: 'Regel hinzufügen', diff --git a/src/languages/es.ts b/src/languages/es.ts index 7f211a9fd19b..30aa70c6802e 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -7413,6 +7413,8 @@ ${amount} para ${merchant} - ${date}`, agentRules: { title: 'Reglas del agente', subtitle: 'Configura reglas para cómo los agentes de IA gestionan los gastos en este espacio de trabajo.', + enforcedBy: 'Las reglas del agente se aplican mediante', + ruleBotName: 'RuleBot', addRule: 'Añadir regla de agente', findRule: 'Encontrar regla de agente', addRuleTitle: 'Añadir regla', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 596de6a39f17..29a9221be16a 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7547,6 +7547,8 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e agentRules: { title: 'Règles d’agent', subtitle: 'Définissez des règles pour déterminer comment les agents IA gèrent les dépenses dans cet espace de travail.', + enforcedBy: 'Les règles des agents sont appliquées par', + ruleBotName: 'RuleBot', addRule: 'Ajouter une règle d’agent', findRule: 'Rechercher une règle d’agent', addRuleTitle: 'Ajouter une règle', diff --git a/src/languages/it.ts b/src/languages/it.ts index 9f60483d4605..b8b88be90734 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7505,6 +7505,8 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, agentRules: { title: 'Regole agente', subtitle: 'Imposta le regole su come gli agenti IA gestiscono le spese in questo spazio di lavoro.', + enforcedBy: 'Le regole degli agenti sono applicate da', + ruleBotName: 'RuleBot', addRule: 'Aggiungi regola agente', findRule: 'Trova regola agente', addRuleTitle: 'Aggiungi regola', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 5729a07e2e87..32cb21734101 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7418,6 +7418,8 @@ ${reportName}`, agentRules: { title: 'エージェントルール', subtitle: 'このワークスペースで AI エージェントが経費を処理する方法のルールを設定します。', + enforcedBy: 'エージェントルールは次によって適用されます', + ruleBotName: 'RuleBot', addRule: 'エージェントルールを追加', findRule: 'エージェントルールを検索', addRuleTitle: 'ルールを追加', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 2faeb4bab174..3a894979478d 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7479,6 +7479,8 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, agentRules: { title: 'Agentregels', subtitle: 'Stel regels in voor hoe AI-agenten met uitgaven omgaan in deze werkruimte.', + enforcedBy: 'Agentregels worden afgedwongen door', + ruleBotName: 'RuleBot', addRule: 'Agentregel toevoegen', findRule: 'Agentregel zoeken', addRuleTitle: 'Regel toevoegen', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 27bc73e18f9a..d7153f9ced14 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -7472,6 +7472,8 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, agentRules: { title: 'Zasady agenta', subtitle: 'Ustaw zasady dotyczące tego, jak agenci AI obsługują wydatki w tym obszarze roboczym.', + enforcedBy: 'Zasady agenta są egzekwowane przez', + ruleBotName: 'RuleBot', addRule: 'Dodaj regułę agenta', findRule: 'Znajdź regułę agenta', addRuleTitle: 'Dodaj regułę', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 499f9768ad4c..a282464ed09e 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7474,6 +7474,8 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, agentRules: { title: 'Regras do agente', subtitle: 'Defina regras para como os agentes de IA lidam com despesas neste workspace.', + enforcedBy: 'As regras do agente são aplicadas por', + ruleBotName: 'RuleBot', addRule: 'Adicionar regra de agente', findRule: 'Encontrar regra de agente', addRuleTitle: 'Adicionar regra', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 802678b431d6..f1208da78605 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -7275,6 +7275,8 @@ ${reportName}`, agentRules: { title: '代理规则', subtitle: '为此工作区设置 AI 代理处理报销的规则。', + enforcedBy: '代理规则强制执行者:', + ruleBotName: 'RuleBot', addRule: '添加代理规则', findRule: '查找代理规则', addRuleTitle: '添加规则', diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index 73f0ffec9d9a..7482f7970e37 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -378,4 +378,4 @@ function navigateAfterExpenseCreate({ } export default navigateAfterExpenseCreate; -export {showExpenseAddedGrowl, surfaceExpenseCreatedFeedback}; +export {surfaceExpenseCreatedFeedback}; diff --git a/tests/unit/Search/ExpenseFlatSearchViewTest.tsx b/tests/unit/Search/ExpenseFlatSearchViewTest.tsx index 67f7124f6440..7b8bc0c9657c 100644 --- a/tests/unit/Search/ExpenseFlatSearchViewTest.tsx +++ b/tests/unit/Search/ExpenseFlatSearchViewTest.tsx @@ -189,7 +189,6 @@ function renderView(overrides: RenderOverrides = {}) { isMobileSelectionModeEnabled={overrides.isMobileSelectionModeEnabled ?? false} tableHeaderVisible={overrides.tableHeaderVisible ?? false} hasLoadedAllTransactions={overrides.hasLoadedAllTransactions ?? true} - newTransactions={[]} onSelectRow={onSelectRow} onEndReached={onEndReached} onLayout={onLayout}