From ecf02bf7b61789b6d4017802ce6d027dc604a9b5 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Wed, 24 Jun 2026 16:35:23 +0200 Subject: [PATCH 1/4] add initial migrations --- .../helpers/cleanupAfterExpenseCreate.ts | 17 +- .../cleanupAndNavigateAfterExpenseCreate.ts | 2 +- src/libs/NavigationFocusReturn.ts | 42 +-- ...replaceOptimisticReportWithActualReport.ts | 254 +++++++++--------- src/pages/DynamicReportDetailsPage.tsx | 8 +- .../Search/SearchTransactionsChangeReport.tsx | 8 +- src/pages/inbox/ReportFetchHandler.tsx | 14 +- .../useReportActionsNewActionLiveTail.ts | 85 +++--- .../step/IOURequestStepDistanceOdometer.tsx | 7 +- .../hooks/useReportSelectionActions.ts | 101 +++---- .../MergeAccounts/MergeResultPage.tsx | 11 +- .../ReplaceDeviceVerifyNewPage.tsx | 14 +- src/pages/signin/LoginForm/BaseLoginForm.tsx | 8 +- .../workflows/WorkspaceWorkflowsPage.tsx | 9 +- tests/unit/cleanupAfterExpenseCreateTest.ts | 12 +- 15 files changed, 297 insertions(+), 295 deletions(-) diff --git a/src/libs/Navigation/helpers/cleanupAfterExpenseCreate.ts b/src/libs/Navigation/helpers/cleanupAfterExpenseCreate.ts index 7455d8c1b59b..cbfa5fd4ec22 100644 --- a/src/libs/Navigation/helpers/cleanupAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/cleanupAfterExpenseCreate.ts @@ -1,20 +1,25 @@ -// eslint-disable-next-line no-restricted-imports -- InteractionManager is the only cross-platform API to defer work past the dismiss animation -import {InteractionManager} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {removeDraftTransactionsByIDs} from '@libs/actions/TransactionEdit'; import Navigation from '@libs/Navigation/Navigation'; +import TransitionTracker from '@libs/Navigation/TransitionTracker'; import type {ReportAction} from '@src/types/onyx'; type CleanupAfterExpenseCreateParams = { draftTransactionIDs: string[] | undefined; linkedTrackedExpenseReportAction?: OnyxEntry; + + /** + * Pass `true` when cleanup is called before navigation has been dispatched (e.g. cleanupAndNavigateAfterExpenseCreate). + * TransitionTracker will then wait for the upcoming transition to start before queuing the callback. + * Leave `false` (default) when navigation has already been dispatched and a transition may already be in progress. + */ + waitForUpcomingTransition?: boolean; }; /** Cleanup-only after a submit. Use `cleanupAndNavigateAfterExpenseCreate` when the flow also needs navigation. */ -function cleanupAfterExpenseCreate({draftTransactionIDs, linkedTrackedExpenseReportAction}: CleanupAfterExpenseCreateParams) { - // Defer past the modal-dismiss animation so it doesn't block the JS thread. - // eslint-disable-next-line @typescript-eslint/no-deprecated -- InteractionManager is widely used across the codebase and kept alive via a dedicated RN patch - InteractionManager.runAfterInteractions(() => removeDraftTransactionsByIDs(draftTransactionIDs)); +function cleanupAfterExpenseCreate({draftTransactionIDs, linkedTrackedExpenseReportAction, waitForUpcomingTransition = false}: CleanupAfterExpenseCreateParams) { + // Defer past the modal-dismiss animation so cleanup doesn't block the JS thread / cause jank during post-submit navigation. + TransitionTracker.runAfterTransitions({callback: () => removeDraftTransactionsByIDs(draftTransactionIDs), waitForUpcomingTransition}); if (linkedTrackedExpenseReportAction?.childReportID) { const trackReport = Navigation.getReportRouteByID(linkedTrackedExpenseReportAction.childReportID); diff --git a/src/libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate.ts index d2b94176a600..a57d99f9f284 100644 --- a/src/libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate.ts @@ -29,7 +29,7 @@ function cleanupAndNavigateAfterExpenseCreate({ linkedTrackedExpenseReportAction, action, }: CleanupAndNavigateAfterExpenseCreateParams) { - cleanupAfterExpenseCreate({draftTransactionIDs, linkedTrackedExpenseReportAction}); + cleanupAfterExpenseCreate({draftTransactionIDs, linkedTrackedExpenseReportAction, waitForUpcomingTransition: true}); const finalActiveReportID = backToReport ?? report?.reportID ?? optimisticChatReportID; const finalActiveReport = finalActiveReportID === report?.reportID ? report : getReportOrDraftReport(finalActiveReportID); diff --git a/src/libs/NavigationFocusReturn.ts b/src/libs/NavigationFocusReturn.ts index 9c461fac87be..d90198ca756a 100644 --- a/src/libs/NavigationFocusReturn.ts +++ b/src/libs/NavigationFocusReturn.ts @@ -1,13 +1,12 @@ import {findFocusedRoute} from '@react-navigation/core'; import type {NavigationState, PartialState} from '@react-navigation/native'; -// eslint-disable-next-line no-restricted-imports -- idiomatic defer primitive past navigation transitions. -import {InteractionManager} from 'react-native'; import compoundParamsKey, {COMPOUND_KEY_DELIMITER} from './compoundParamsKey'; import FOCUSABLE_SELECTOR from './focusableSelector'; import hasFocusableAttributes from './focusGuards'; import getHadTabNavigation from './hadTabNavigation'; import {consumeLauncher, pickLauncher, resetLauncherStackForTests} from './LauncherStack'; import navigationRef from './Navigation/navigationRef'; +import TransitionTracker from './Navigation/TransitionTracker'; import {isCycleIdle, Priorities, resetCycle, tryClaim} from './ScreenFocusArbiter'; /** focusin tracks the last keyboard-focused element; a nav state listener captures it against the outgoing route and restores it on backward nav. */ @@ -318,28 +317,29 @@ function scheduleRestore(routeKey: string): void { const attempt = () => { // Defer past the transition so useAutoFocusInput and React Navigation's own focus work settle first. - // eslint-disable-next-line @typescript-eslint/no-deprecated -- idiomatic defer primitive despite type-def deprecation. - imHandle = InteractionManager.runAfterInteractions(() => { - if (cancelled) { - return; - } - frameId = requestAnimationFrame(() => { + imHandle = TransitionTracker.runAfterTransitions({ + callback: () => { if (cancelled) { return; } - attempts += 1; - const restored = restoreTriggerForRoute(routeKey); - if (restored || !triggerMap.has(routeKey)) { - pendingRestore = null; - return; - } - if (attempts >= MAX_RESTORE_ATTEMPTS) { - triggerMap.delete(routeKey); - pendingRestore = null; - return; - } - retryTimerId = setTimeout(attempt, RESTORE_RETRY_MS); - }); + frameId = requestAnimationFrame(() => { + if (cancelled) { + return; + } + attempts += 1; + const restored = restoreTriggerForRoute(routeKey); + if (restored || !triggerMap.has(routeKey)) { + pendingRestore = null; + return; + } + if (attempts >= MAX_RESTORE_ATTEMPTS) { + triggerMap.delete(routeKey); + pendingRestore = null; + return; + } + retryTimerId = setTimeout(attempt, RESTORE_RETRY_MS); + }); + }, }); }; diff --git a/src/libs/actions/replaceOptimisticReportWithActualReport.ts b/src/libs/actions/replaceOptimisticReportWithActualReport.ts index 0bb103ce149f..ce5be7f99c43 100644 --- a/src/libs/actions/replaceOptimisticReportWithActualReport.ts +++ b/src/libs/actions/replaceOptimisticReportWithActualReport.ts @@ -1,8 +1,8 @@ -// eslint-disable-next-line no-restricted-imports -import {DeviceEventEmitter, InteractionManager} from 'react-native'; +import {DeviceEventEmitter} from 'react-native'; import type {OnyxCollection} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; +import TransitionTracker from '@libs/Navigation/TransitionTracker'; import {isMoneyRequest, isMoneyRequestReport, isOneTransactionReport} from '@libs/ReportUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; @@ -83,126 +83,136 @@ function replaceOptimisticReportWithActualReport(report: Report, draftReportComm } } - InteractionManager.runAfterInteractions(() => { - // It is possible that we optimistically created a DM/group-DM for a set of users for which a report already exists. - // Or we optimistically created a transaction thread chat report for an IOU report action that already has an associated child chat report. - // Or we optimistically created a thread report under a comment that already has an associated child chat report. - // In this case, the API will let us know by returning a preexistingReportID. - // We should clear out the optimistically created report and re-route the user to the preexisting report. - const existingReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`]; - const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`]; - let callback = () => { - Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, null); - Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`, null); + TransitionTracker.runAfterTransitions({ + callback: () => { + // It is possible that we optimistically created a DM/group-DM for a set of users for which a report already exists. + // Or we optimistically created a transaction thread chat report for an IOU report action that already has an associated child chat report. + // Or we optimistically created a thread report under a comment that already has an associated child chat report. + // In this case, the API will let us know by returning a preexistingReportID. + // We should clear out the optimistically created report and re-route the user to the preexisting report. + const existingReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`]; + const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`]; + let callback = () => { + Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, null); + Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`, null); - if (!parentReportActionID) { - // DMs and group-DMs don't have parent actions, so we only need to merge report data and preserve existing participants - Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`, { - ...report, - reportID: preexistingReportID, - preexistingReportID: null, - // Replacing the existing report's participants to avoid duplicates - participants: existingReport?.participants ?? report.participants, - }); - } else { - // Thread reports have parent actions that need their childReportID updated to point to the preexisting thread - Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`, { - ...report, - reportID: preexistingReportID, - preexistingReportID: null, - }); - // Non-optimistic parent actions already exist, so we update their childReportID; - // optimistic actions were already cleaned up above - const parentReportAction = parentReportID ? allReportActions?.[parentReportID]?.[parentReportActionID] : null; - if (parentReportAction && !parentReportAction.isOptimisticAction) { - Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, { - [parentReportActionID]: {childReportID: preexistingReportID}, + if (!parentReportActionID) { + // DMs and group-DMs don't have parent actions, so we only need to merge report data and preserve existing participants + Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`, { + ...report, + reportID: preexistingReportID, + preexistingReportID: null, + // Replacing the existing report's participants to avoid duplicates + participants: existingReport?.participants ?? report.participants, }); + } else { + // Thread reports have parent actions that need their childReportID updated to point to the preexisting thread + Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`, { + ...report, + reportID: preexistingReportID, + preexistingReportID: null, + }); + // Non-optimistic parent actions already exist, so we update their childReportID; + // optimistic actions were already cleaned up above + const parentReportAction = parentReportID ? allReportActions?.[parentReportID]?.[parentReportActionID] : null; + if (parentReportAction && !parentReportAction.isOptimisticAction) { + Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, { + [parentReportActionID]: {childReportID: preexistingReportID}, + }); + } } - } - }; + }; - const isParentOneTransactionReport = isOneTransactionReport(parentReport); + const isParentOneTransactionReport = isOneTransactionReport(parentReport); - // One-transaction reports display the expense via the parent IOU report screen, not the thread, so the draft belongs there - const reportToCopyDraftTo = !!parentReportID && isParentOneTransactionReport ? parentReportID : preexistingReportID; + // One-transaction reports display the expense via the parent IOU report screen, not the thread, so the draft belongs there + const reportToCopyDraftTo = !!parentReportID && isParentOneTransactionReport ? parentReportID : preexistingReportID; - if (!navigationRef.isReady()) { - callback(); - return; - } + if (!navigationRef.isReady()) { + callback(); + return; + } - // Use Navigation.getActiveRoute() instead of navigationRef.getCurrentRoute()?.path because - // getCurrentRoute().path can be undefined during first navigation. - // We still need getCurrentRoute() for params and screen name as getActiveRoute() only returns the path string. - const activeRoute = Navigation.getActiveRoute(); - const currentRouteInfo = navigationRef.getCurrentRoute(); - const backTo = (currentRouteInfo?.params as {backTo?: Route})?.backTo; - const screenName = currentRouteInfo?.name; + // Use Navigation.getActiveRoute() instead of navigationRef.getCurrentRoute()?.path because + // getCurrentRoute().path can be undefined during first navigation. + // We still need getCurrentRoute() for params and screen name as getActiveRoute() only returns the path string. + const activeRoute = Navigation.getActiveRoute(); + const currentRouteInfo = navigationRef.getCurrentRoute(); + const backTo = (currentRouteInfo?.params as {backTo?: Route})?.backTo; + const screenName = currentRouteInfo?.name; - // Besides the central-pane report route (/r/:reportID), the optimistic report can also be focused - // in the RHP transaction-thread carousel (search/view/:reportID) used by the "Review X expenses" - // flow. Matched via route name + reportID param (not a path substring) since the carousel's nested - // backTo can contain other report IDs. Without this, the report is cleared out from under the user - // and ReportNavigateAwayHandler treats it as removed, dismissing the RHP and navigating to the parent. - const isOptimisticReportFocusedInSearchRHP = - screenName === SCREENS.RIGHT_MODAL.SEARCH_REPORT && - !!currentRouteInfo?.params && - typeof currentRouteInfo.params === 'object' && - 'reportID' in currentRouteInfo.params && - currentRouteInfo.params.reportID === reportID; - const isOptimisticReportFocused = activeRoute.includes(`/r/${reportID}`) || isOptimisticReportFocusedInSearchRHP; + // Besides the central-pane report route (/r/:reportID), the optimistic report can also be focused + // in the RHP transaction-thread carousel (search/view/:reportID) used by the "Review X expenses" + // flow. Matched via route name + reportID param (not a path substring) since the carousel's nested + // backTo can contain other report IDs. Without this, the report is cleared out from under the user + // and ReportNavigateAwayHandler treats it as removed, dismissing the RHP and navigating to the parent. + const isOptimisticReportFocusedInSearchRHP = + screenName === SCREENS.RIGHT_MODAL.SEARCH_REPORT && + !!currentRouteInfo?.params && + typeof currentRouteInfo.params === 'object' && + 'reportID' in currentRouteInfo.params && + currentRouteInfo.params.reportID === reportID; + const isOptimisticReportFocused = activeRoute.includes(`/r/${reportID}`) || isOptimisticReportFocusedInSearchRHP; - // 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 - // an optimistic report in the background that should be replaced with preexisting report - const isOptimisticReportInBackground = screenName === SCREENS.RIGHT_MODAL.EXPENSE_REPORT && backTo && backTo.includes(`/r/${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 + // an optimistic report in the background that should be replaced with preexisting report + const isOptimisticReportInBackground = screenName === SCREENS.RIGHT_MODAL.EXPENSE_REPORT && backTo && backTo.includes(`/r/${reportID}`); - // Only re-route them if they are still looking at the optimistically created report - if (isOptimisticReportFocused || isOptimisticReportInBackground) { - const currCallback = callback; - callback = () => { - if (isOptimisticReportFocused) { - if (!parentReportActionID || !isParentOneTransactionReport) { - // We are either in a DM/group-DM that do not have a parent report, - // a thread under any comment, - // or transaction thread report under an IOU report action that its parent IOU report is not a one expense report, - // we need to navigate to the preexisting report chat - // because we will clear the optimistically created report in the currCallback - Navigation.setParams({reportID: preexistingReportID.toString()}); - } else { - // We are in a transaction thread report under an IOU report action where the parent IOU report is a one transaction report - // We need to navigate to the one expense report screen instead of the preexisting report chat - // because we will clear the optimistically created transaction thread report in the currCallback - // and the one transaction should be accessed via the one expense report screen and not the preexisting report chat - Navigation.setParams({reportID: parentReportID}); + // Only re-route them if they are still looking at the optimistically created report + if (isOptimisticReportFocused || isOptimisticReportInBackground) { + const currCallback = callback; + callback = () => { + if (isOptimisticReportFocused) { + if (!parentReportActionID || !isParentOneTransactionReport) { + // We are either in a DM/group-DM that do not have a parent report, + // a thread under any comment, + // or transaction thread report under an IOU report action that its parent IOU report is not a one expense report, + // we need to navigate to the preexisting report chat + // because we will clear the optimistically created report in the currCallback + Navigation.setParams({reportID: preexistingReportID.toString()}); + } else { + // We are in a transaction thread report under an IOU report action where the parent IOU report is a one transaction report + // We need to navigate to the one expense report screen instead of the preexisting report chat + // because we will clear the optimistically created transaction thread report in the currCallback + // and the one transaction should be accessed via the one expense report screen and not the preexisting report chat + Navigation.setParams({reportID: parentReportID}); + } + } else if (isOptimisticReportInBackground) { + // Navigate to the correct backTo route with the preexisting report ID + Navigation.navigate(backTo.replace(`/r/${reportID}`, `/r/${preexistingReportID}`) as Route); } - } else if (isOptimisticReportInBackground) { - // Navigate to the correct backTo route with the preexisting report ID - Navigation.navigate(backTo.replace(`/r/${reportID}`, `/r/${preexistingReportID}`) as Route); - } - currCallback(); - }; + currCallback(); + }; - // The report screen will listen to this event and transfer the draft comment to the existing report - // This will allow the newest draft comment to be transferred to the existing report - DeviceEventEmitter.emit(`switchToPreExistingReport_${reportID}`, { - preexistingReportID, - reportToCopyDraftTo, - callback, - }); + // The report screen will listen to this event and transfer the draft comment to the existing report + // This will allow the newest draft comment to be transferred to the existing report + DeviceEventEmitter.emit(`switchToPreExistingReport_${reportID}`, { + preexistingReportID, + reportToCopyDraftTo, + callback, + }); - return; - } + return; + } - if ( - parentReportID && - isParentOneTransactionReport && - (activeRoute.includes(ROUTES.REPORT_WITH_ID.getRoute(parentReportID)) || activeRoute.includes(ROUTES.SEARCH_REPORT.getRoute({reportID: parentReportID}))) - ) { - if (draftReportComment) { - // Draft must be saved first because the callback will clear the optimistic report and its associated draft - saveReportDraftComment(parentReportID, draftReportComment, () => { + if ( + parentReportID && + isParentOneTransactionReport && + (activeRoute.includes(ROUTES.REPORT_WITH_ID.getRoute(parentReportID)) || activeRoute.includes(ROUTES.SEARCH_REPORT.getRoute({reportID: parentReportID}))) + ) { + if (draftReportComment) { + // Draft must be saved first because the callback will clear the optimistic report and its associated draft + saveReportDraftComment(parentReportID, draftReportComment, () => { + callback(); + + // We are already on the parent one expense report, so just call the API to fetch report data + // betas is safe to pass as undefined because introSelected is undefined, so the code path + // that uses betas is never reached. Passing it explicitly so the compiler flags this when + // betas becomes required. Refactor issue: https://github.com/Expensify/App/issues/66424 + openReport({reportID: parentReportID, introSelected: undefined, betas: undefined}); + }); + } else { callback(); // We are already on the parent one expense report, so just call the API to fetch report data @@ -210,27 +220,19 @@ function replaceOptimisticReportWithActualReport(report: Report, draftReportComm // that uses betas is never reached. Passing it explicitly so the compiler flags this when // betas becomes required. Refactor issue: https://github.com/Expensify/App/issues/66424 openReport({reportID: parentReportID, introSelected: undefined, betas: undefined}); - }); - } else { - callback(); - - // We are already on the parent one expense report, so just call the API to fetch report data - // betas is safe to pass as undefined because introSelected is undefined, so the code path - // that uses betas is never reached. Passing it explicitly so the compiler flags this when - // betas becomes required. Refactor issue: https://github.com/Expensify/App/issues/66424 - openReport({reportID: parentReportID, introSelected: undefined, betas: undefined}); + } + return; } - return; - } - // In case the user is not on the report screen, we will transfer the report draft comment directly to the existing report - // after that clear the optimistically created report - if (!draftReportComment) { - callback(); - return; - } + // In case the user is not on the report screen, we will transfer the report draft comment directly to the existing report + // after that clear the optimistically created report + if (!draftReportComment) { + callback(); + return; + } - saveReportDraftComment(reportToCopyDraftTo, draftReportComment, callback); + saveReportDraftComment(reportToCopyDraftTo, draftReportComment, callback); + }, }); } diff --git a/src/pages/DynamicReportDetailsPage.tsx b/src/pages/DynamicReportDetailsPage.tsx index a2440bb27740..c911a66adcf1 100644 --- a/src/pages/DynamicReportDetailsPage.tsx +++ b/src/pages/DynamicReportDetailsPage.tsx @@ -3,8 +3,7 @@ import {delegateEmailSelector} from '@selectors/Account'; import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft'; import React, {useCallback, useEffect, useMemo, useState} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; -// eslint-disable-next-line no-restricted-imports -import {InteractionManager, View} from 'react-native'; +import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import AvatarWithImagePicker from '@components/AvatarWithImagePicker'; @@ -50,6 +49,7 @@ import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/crea import isReportTopmostSplitNavigator from '@libs/Navigation/helpers/isReportTopmostSplitNavigator'; import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import TransitionTracker from '@libs/Navigation/TransitionTracker'; import type {ReportDetailsNavigatorParamList, RightModalNavigatorParamList} from '@libs/Navigation/types'; import Parser from '@libs/Parser'; import Permissions from '@libs/Permissions'; @@ -1086,9 +1086,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report navigateToTargetUrl(); // Delay deletion until the RHP close animation finishes to prevent a brief // "Not Found" flash inside the animating-out panel on slower devices. - InteractionManager.runAfterInteractions(() => { - deleteTransaction(); - }); + TransitionTracker.runAfterTransitions({callback: deleteTransaction, waitForUpcomingTransition: true}); }); }, [showConfirmModal, translate, caseID, iouTransactionID, shouldOpenSplitExpenseEditFlowOnDelete, navigateToTargetUrl, deleteTransaction]); diff --git a/src/pages/Search/SearchTransactionsChangeReport.tsx b/src/pages/Search/SearchTransactionsChangeReport.tsx index ffea6f131155..30ddf056bb8e 100644 --- a/src/pages/Search/SearchTransactionsChangeReport.tsx +++ b/src/pages/Search/SearchTransactionsChangeReport.tsx @@ -1,6 +1,4 @@ import React, {useEffect, useMemo} from 'react'; -// eslint-disable-next-line no-restricted-imports -import {InteractionManager} from 'react-native'; import type {OnyxCollection} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import {usePersonalDetails, useSession} from '@components/OnyxListItemProvider'; @@ -248,11 +246,7 @@ function SearchTransactionsChangeReport() { allTransactionViolation: transactionViolations, allReports, }); - InteractionManager.runAfterInteractions(() => { - clearSelectedTransactions(); - }); - - Navigation.goBack(); + Navigation.goBack(undefined, {afterTransition: clearSelectedTransactions}); }; const removeFromReport = () => { diff --git a/src/pages/inbox/ReportFetchHandler.tsx b/src/pages/inbox/ReportFetchHandler.tsx index 3095f53868b0..7e75ac0f57cd 100644 --- a/src/pages/inbox/ReportFetchHandler.tsx +++ b/src/pages/inbox/ReportFetchHandler.tsx @@ -1,7 +1,5 @@ import {useIsFocused, useNavigation, useRoute} from '@react-navigation/native'; import {useEffect, useEffectEvent, useRef} from 'react'; -// eslint-disable-next-line no-restricted-imports -import {InteractionManager} from 'react-native'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useIsAnonymousUser from '@hooks/useIsAnonymousUser'; import useIsInSidePanel from '@hooks/useIsInSidePanel'; @@ -16,6 +14,8 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; +import TransitionTracker from '@libs/Navigation/TransitionTracker'; +import type {CancelHandle} from '@libs/Navigation/TransitionTracker'; import {getFilteredReportActionsForReportView, getIOUActionForReportID, getOneTransactionThreadReportID, isCreatedAction} from '@libs/ReportActionsUtils'; import { isChatThread, @@ -335,11 +335,13 @@ function ReportFetchHandler() { // any `pendingFields.createChat` or `pendingFields.addWorkspaceRoom` fields are set to null. // Existing reports created will have empty fields for `pendingFields`. const didCreateReportSuccessfully = !report?.pendingFields || (!report?.pendingFields.addWorkspaceRoom && !report?.pendingFields.createChat); - let interactionTask: ReturnType | null = null; + let interactionTask: CancelHandle | null = null; if (!didSubscribeToReportLeavingEvents.current && didCreateReportSuccessfully) { - interactionTask = InteractionManager.runAfterInteractions(() => { - subscribeToReportLeavingEvents(reportIDFromRoute, currentUserAccountID); - didSubscribeToReportLeavingEvents.current = true; + interactionTask = TransitionTracker.runAfterTransitions({ + callback: () => { + subscribeToReportLeavingEvents(reportIDFromRoute, currentUserAccountID); + didSubscribeToReportLeavingEvents.current = true; + }, }); } return () => { diff --git a/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts b/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts index b684c66485e5..da469de4d508 100644 --- a/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts +++ b/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts @@ -1,12 +1,11 @@ import {useCallback, useEffect, useEffectEvent, useRef, useState} from 'react'; -// eslint-disable-next-line no-restricted-imports -import {InteractionManager} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import type useReportScrollManager from '@hooks/useReportScrollManager'; import type {OpenReportActionParams} from '@libs/actions/Report'; import {openReport, pruneReportActionPagesToNewestWindow, subscribeToNewActionEvent} from '@libs/actions/Report'; import isReportTopmostSplitNavigator from '@libs/Navigation/helpers/isReportTopmostSplitNavigator'; import Navigation from '@libs/Navigation/Navigation'; +import TransitionTracker from '@libs/Navigation/TransitionTracker'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; @@ -76,56 +75,58 @@ function useReportActionsNewActionLiveTail({ const [isScrollToBottomEnabled, setIsScrollToBottomEnabled] = useState(false); const scrollToBottomForCurrentUserAction = useEffectEvent((isFromCurrentUser: boolean, action?: OnyxTypes.ReportAction) => { - InteractionManager.runAfterInteractions(() => { - // If a new comment is added and it's from the current user scroll to the bottom otherwise leave the user positioned where - // they are now in the list. - if (!isFromCurrentUser || (!isReportTopmostSplitNavigator() && !Navigation.getReportRHPActiveRoute())) { - return; - } - if (!hasNewestReportAction && !isFromCurrentUser) { - if (Navigation.getReportRHPActiveRoute()) { + TransitionTracker.runAfterTransitions({ + callback: () => { + // If a new comment is added and it's from the current user scroll to the bottom otherwise leave the user positioned where + // they are now in the list. + if (!isFromCurrentUser || (!isReportTopmostSplitNavigator() && !Navigation.getReportRHPActiveRoute())) { return; } - Navigation.setNavigationActionToMicrotaskQueue(() => { - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID)); - }); - return; - } - - const shouldJumpToLiveTail = - !isOffline && action?.actionName !== CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW && (hasNewerActions || !!linkedReportActionID || unreadMarkerReportActionID); - - if (shouldJumpToLiveTail) { - if (liveTailJumpRef.current.stage === 'idle') { - liveTailJumpRef.current = {stage: 'open_report'}; - openReport({ - reportID, - introSelected, - betas, + if (!hasNewestReportAction && !isFromCurrentUser) { + if (Navigation.getReportRHPActiveRoute()) { + return; + } + Navigation.setNavigationActionToMicrotaskQueue(() => { + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID)); }); + return; + } + + const shouldJumpToLiveTail = + !isOffline && action?.actionName !== CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW && (hasNewerActions || !!linkedReportActionID || unreadMarkerReportActionID); + + if (shouldJumpToLiveTail) { + if (liveTailJumpRef.current.stage === 'idle') { + liveTailJumpRef.current = {stage: 'open_report'}; + openReport({ + reportID, + introSelected, + betas, + }); + } + return; } - return; - } - const index = sortedVisibleReportActions.findIndex((item) => item.reportActionID === action?.reportActionID); - if (action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { - if (index > 0) { - setTimeout(() => { - reportScrollManager.scrollToIndex(index); - }, 100); + const index = sortedVisibleReportActions.findIndex((item) => item.reportActionID === action?.reportActionID); + if (action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { + if (index > 0) { + setTimeout(() => { + reportScrollManager.scrollToIndex(index); + }, 100); + } else { + setIsFloatingMessageCounterVisible(false); + reportScrollManager.scrollToBottom(); + } + if (action?.reportActionID) { + setActionIdToHighlight(action.reportActionID); + } } else { setIsFloatingMessageCounterVisible(false); reportScrollManager.scrollToBottom(); } - if (action?.reportActionID) { - setActionIdToHighlight(action.reportActionID); - } - } else { - setIsFloatingMessageCounterVisible(false); - reportScrollManager.scrollToBottom(); - } - setIsScrollToBottomEnabled(true); + setIsScrollToBottomEnabled(true); + }, }); }); diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 0fb1ab5ba4c3..32a27b00d656 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -1,8 +1,7 @@ import {useIsFocused} from '@react-navigation/native'; import lodashIsEmpty from 'lodash/isEmpty'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; -// eslint-disable-next-line no-restricted-imports -import {InteractionManager, View} from 'react-native'; +import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import Button from '@components/Button'; import FormHelpMessage from '@components/FormHelpMessage'; @@ -518,9 +517,7 @@ function IOURequestStepDistanceOdometer({ useDiscardChangesConfirmation({ onCancel: () => { - InteractionManager.runAfterInteractions(() => { - lastFocusedInputRef.current?.focus(); - }); + lastFocusedInputRef.current?.focus(); }, getHasUnsavedChanges: () => { if ( diff --git a/src/pages/iou/request/step/IOURequestStepReport/hooks/useReportSelectionActions.ts b/src/pages/iou/request/step/IOURequestStepReport/hooks/useReportSelectionActions.ts index b3878e80e96c..e72c909c1004 100644 --- a/src/pages/iou/request/step/IOURequestStepReport/hooks/useReportSelectionActions.ts +++ b/src/pages/iou/request/step/IOURequestStepReport/hooks/useReportSelectionActions.ts @@ -1,5 +1,3 @@ -// eslint-disable-next-line no-restricted-imports -import {InteractionManager} from 'react-native'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {useSearchSelectionActions} from '@components/Search/SearchContext'; import type {ListItem} from '@components/SelectionList/types'; @@ -9,6 +7,7 @@ import {setCustomUnitID, setCustomUnitRateID} from '@libs/actions/IOU/MoneyReque import {clearSubrates} from '@libs/actions/IOU/PerDiem'; import {changeTransactionsReport, setTransactionReport} from '@libs/actions/Transaction'; import Navigation from '@libs/Navigation/Navigation'; +import TransitionTracker from '@libs/Navigation/TransitionTracker'; import {getPerDiemCustomUnit} from '@libs/PolicyUtils'; import {getReportOrDraftReport} from '@libs/ReportUtils'; import CONST from '@src/CONST'; @@ -175,38 +174,41 @@ function useReportSelectionActions({ } handleGoBack(); - InteractionManager.runAfterInteractions(() => { - Navigation.setNavigationActionToMicrotaskQueue(() => { - const participants = buildParticipants(report); - - setTransactionReport( - transaction.transactionID, - { - reportID: item.value, - participants, - }, - !isEditing, - ); - - if (isEditing) { - const policyTagList = item?.policyID ? allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${item.policyID}`] : {}; - changeTransactionsReport({ - transactionIDs: [transaction.transactionID], - isASAPSubmitBetaEnabled, - accountID: session?.accountID ?? CONST.DEFAULT_NUMBER_ID, - email: session?.email ?? '', - newReport: report, - policy: allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${item.policyID}`], - reportNextStep: undefined, - policyCategories: allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${item.policyID}`], - allTransactions, - policyTagList, - allTransactionViolation: transactionViolations, - allReports, - }); - removeTransaction(transaction.transactionID); - } - }); + TransitionTracker.runAfterTransitions({ + waitForUpcomingTransition: true, + callback: () => { + Navigation.setNavigationActionToMicrotaskQueue(() => { + const participants = buildParticipants(report); + + setTransactionReport( + transaction.transactionID, + { + reportID: item.value, + participants, + }, + !isEditing, + ); + + if (isEditing) { + const policyTagList = item?.policyID ? allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${item.policyID}`] : {}; + changeTransactionsReport({ + transactionIDs: [transaction.transactionID], + isASAPSubmitBetaEnabled, + accountID: session?.accountID ?? CONST.DEFAULT_NUMBER_ID, + email: session?.email ?? '', + newReport: report, + policy: allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${item.policyID}`], + reportNextStep: undefined, + policyCategories: allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${item.policyID}`], + allTransactions, + policyTagList, + allTransactionViolation: transactionViolations, + allReports, + }); + removeTransaction(transaction.transactionID); + } + }); + }, }); }; @@ -214,21 +216,22 @@ function useReportSelectionActions({ if (!transaction) { return; } - Navigation.dismissToSuperWideRHP(); - InteractionManager.runAfterInteractions(() => { - const policyTagList = personalPolicyID ? allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${personalPolicyID}`] : {}; - changeTransactionsReport({ - transactionIDs: [transaction.transactionID], - isASAPSubmitBetaEnabled, - accountID: session?.accountID ?? CONST.DEFAULT_NUMBER_ID, - email: session?.email ?? '', - policy: allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${personalPolicyID}`], - allTransactions, - policyTagList, - allTransactionViolation: transactionViolations, - allReports, - }); - removeTransaction(transaction.transactionID); + Navigation.dismissToSuperWideRHP({ + afterTransition: () => { + const policyTagList = personalPolicyID ? allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${personalPolicyID}`] : {}; + changeTransactionsReport({ + transactionIDs: [transaction.transactionID], + isASAPSubmitBetaEnabled, + accountID: session?.accountID ?? CONST.DEFAULT_NUMBER_ID, + email: session?.email ?? '', + policy: allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${personalPolicyID}`], + allTransactions, + policyTagList, + allTransactionViolation: transactionViolations, + allReports, + }); + removeTransaction(transaction.transactionID); + }, }); }; diff --git a/src/pages/settings/Security/MergeAccounts/MergeResultPage.tsx b/src/pages/settings/Security/MergeAccounts/MergeResultPage.tsx index 0ee61ed9f870..116e312f0a5f 100644 --- a/src/pages/settings/Security/MergeAccounts/MergeResultPage.tsx +++ b/src/pages/settings/Security/MergeAccounts/MergeResultPage.tsx @@ -1,8 +1,7 @@ import {useRoute} from '@react-navigation/native'; import {emailSelector} from '@selectors/Session'; import React, {useEffect, useMemo} from 'react'; -// eslint-disable-next-line no-restricted-imports -import {InteractionManager, View} from 'react-native'; +import {View} from 'react-native'; import type {ValueOf} from 'type-fest'; import ConfirmationPage from '@components/ConfirmationPage'; import type {ConfirmationPageProps} from '@components/ConfirmationPage'; @@ -18,6 +17,7 @@ import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; +import TransitionTracker from '@libs/Navigation/TransitionTracker'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import {shouldHideOldAppRedirect} from '@libs/TryNewDotUtils'; import {closeReactNativeApp} from '@userActions/HybridApp'; @@ -197,9 +197,12 @@ function MergeResultPage() { return; } - InteractionManager.runAfterInteractions(() => { - Navigation.removeScreenFromNavigationState(SCREENS.SETTINGS.MERGE_ACCOUNTS.ACCOUNT_DETAILS); + const handle = TransitionTracker.runAfterTransitions({ + callback: () => { + Navigation.removeScreenFromNavigationState(SCREENS.SETTINGS.MERGE_ACCOUNTS.ACCOUNT_DETAILS); + }, }); + return () => handle.cancel(); }, [result]); const { diff --git a/src/pages/settings/Security/TwoFactorAuth/ReplaceDeviceVerifyNewPage.tsx b/src/pages/settings/Security/TwoFactorAuth/ReplaceDeviceVerifyNewPage.tsx index 67c01ca46684..e073fe239af3 100644 --- a/src/pages/settings/Security/TwoFactorAuth/ReplaceDeviceVerifyNewPage.tsx +++ b/src/pages/settings/Security/TwoFactorAuth/ReplaceDeviceVerifyNewPage.tsx @@ -1,6 +1,5 @@ import React, {useEffect, useRef} from 'react'; -// eslint-disable-next-line no-restricted-imports -import {InteractionManager, View} from 'react-native'; +import {View} from 'react-native'; // eslint-disable-next-line no-restricted-imports import type {ScrollView as RNScrollView} from 'react-native'; import Button from '@components/Button'; @@ -14,6 +13,7 @@ import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; import {getLatestErrorMessage} from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; +import TransitionTracker from '@libs/Navigation/TransitionTracker'; import {getContactMethod} from '@libs/UserUtils'; import {clearAccountMessages, replaceTwoFactorDevice} from '@userActions/Session'; import CONST from '@src/CONST'; @@ -57,10 +57,12 @@ function ReplaceDeviceVerifyNewPage() { }, [account, account?.twoFactorAuthSecretKey]); const handleInputFocus = () => { - InteractionManager.runAfterInteractions(() => { - requestAnimationFrame(() => { - scrollViewRef.current?.scrollToEnd({animated: true}); - }); + TransitionTracker.runAfterTransitions({ + callback: () => { + requestAnimationFrame(() => { + scrollViewRef.current?.scrollToEnd({animated: true}); + }); + }, }); }; diff --git a/src/pages/signin/LoginForm/BaseLoginForm.tsx b/src/pages/signin/LoginForm/BaseLoginForm.tsx index 62437474629e..dd807a18c24a 100644 --- a/src/pages/signin/LoginForm/BaseLoginForm.tsx +++ b/src/pages/signin/LoginForm/BaseLoginForm.tsx @@ -1,7 +1,6 @@ import {useIsFocused} from '@react-navigation/native'; import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; -// eslint-disable-next-line no-restricted-imports -import {InteractionManager, View} from 'react-native'; +import {View} from 'react-native'; import DotIndicatorMessage from '@components/DotIndicatorMessage'; import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; import AppleSignIn from '@components/SignInButtons/AppleSignIn'; @@ -23,6 +22,7 @@ import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; import {getLatestErrorMessage} from '@libs/ErrorUtils'; import isInputAutoFilled from '@libs/isInputAutoFilled'; import {appendCountryCode, getPhoneNumberWithoutSpecialChars} from '@libs/LoginUtils'; +import TransitionTracker from '@libs/Navigation/TransitionTracker'; import {parsePhoneNumber} from '@libs/PhoneNumber'; import {isAgentEmail} from '@libs/SessionUtils'; import StringUtils from '@libs/StringUtils'; @@ -225,9 +225,7 @@ function BaseLoginForm({submitBehavior = 'submit', isVisible, ref}: BaseLoginFor // On mobile WebKit browsers, when an input field gains focus, the keyboard appears and the virtual viewport is resized and scrolled to make the input field visible. // This occurs even when there is enough space to display both the input field and the submit button in the current view. // so this change to correct the scroll position when the input field gains focus. - InteractionManager.runAfterInteractions(() => { - htmlDivElementRef(submitContainerRef).current?.scrollIntoView?.({behavior: 'smooth', block: 'end'}); - }); + TransitionTracker.runAfterTransitions({callback: () => htmlDivElementRef(submitContainerRef).current?.scrollIntoView?.({behavior: 'smooth', block: 'end'})}); }, []); const handleSignIn = () => setIsSigningWithAppleOrGoogle(true); diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx index 7993669516d2..f4faf5d88d3e 100644 --- a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx @@ -1,8 +1,7 @@ import {hasSeenTourSelector} from '@selectors/Onboarding'; import {Str} from 'expensify-common'; import React, {useCallback, useEffect, useMemo} from 'react'; -// eslint-disable-next-line no-restricted-imports -import {InteractionManager, View} from 'react-native'; +import {View} from 'react-native'; import type {TupleToUnion} from 'type-fest'; import ApprovalWorkflowSection from '@components/ApprovalWorkflowSection'; import Icon from '@components/Icon'; @@ -50,6 +49,7 @@ import {getLatestErrorField} from '@libs/ErrorUtils'; import {getConnectedHRProvider, getHRFinalApprover, isAnyHRConnected, isAnyHRReadOnlyWorkflowMode, isHRAdvancedMode} from '@libs/HRUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import TransitionTracker from '@libs/Navigation/TransitionTracker'; import {getPaymentMethodDescription} from '@libs/PaymentUtils'; import {getDisplayNameOrDefault, getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; import { @@ -192,9 +192,8 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { const {showLockedAccountModal} = useLockedAccountActions(); useEffect(() => { - InteractionManager.runAfterInteractions(() => { - fetchData(); - }); + const handle = TransitionTracker.runAfterTransitions({callback: fetchData}); + return handle.cancel; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/tests/unit/cleanupAfterExpenseCreateTest.ts b/tests/unit/cleanupAfterExpenseCreateTest.ts index 5af0cc7ba898..57b3654f1dee 100644 --- a/tests/unit/cleanupAfterExpenseCreateTest.ts +++ b/tests/unit/cleanupAfterExpenseCreateTest.ts @@ -9,12 +9,10 @@ jest.mock('@libs/actions/TransactionEdit', () => ({ removeDraftTransactionsByIDs: (ids: string[] | undefined) => mockRemoveDraftTransactionsByIDs(ids) as void, })); -jest.mock('react-native', () => ({ - InteractionManager: { - runAfterInteractions: (callback: () => void) => { - callback(); - return {then: (cb: () => void) => cb(), cancel: jest.fn()}; - }, +jest.mock('@libs/Navigation/TransitionTracker', () => ({ + runAfterTransitions: ({callback}: {callback: () => void}) => { + callback(); + return {cancel: jest.fn()}; }, })); @@ -28,7 +26,7 @@ describe('cleanupAfterExpenseCreate', () => { jest.clearAllMocks(); }); - it('should remove draft transactions via InteractionManager when draftTransactionIDs is provided', () => { + it('should remove draft transactions after transition when draftTransactionIDs is provided', () => { cleanupAfterExpenseCreate({ draftTransactionIDs: ['txn-1', 'txn-2'], }); From 0140839d701b079ab16102116145ede81fc24c66 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Fri, 26 Jun 2026 16:58:10 +0200 Subject: [PATCH 2/4] test and fix all of the migrations --- src/pages/inbox/ReportFetchHandler.tsx | 1 + .../hooks/useReportSelectionActions.ts | 60 +++++++++---------- .../MergeAccounts/MergeResultPage.tsx | 1 + .../ReplaceDeviceVerifyNewPage.tsx | 9 +-- src/pages/signin/LoginForm/BaseLoginForm.tsx | 8 ++- .../workflows/WorkspaceWorkflowsPage.tsx | 4 +- 6 files changed, 39 insertions(+), 44 deletions(-) diff --git a/src/pages/inbox/ReportFetchHandler.tsx b/src/pages/inbox/ReportFetchHandler.tsx index 7e75ac0f57cd..590573b87e5e 100644 --- a/src/pages/inbox/ReportFetchHandler.tsx +++ b/src/pages/inbox/ReportFetchHandler.tsx @@ -342,6 +342,7 @@ function ReportFetchHandler() { subscribeToReportLeavingEvents(reportIDFromRoute, currentUserAccountID); didSubscribeToReportLeavingEvents.current = true; }, + waitForUpcomingTransition: true, }); } return () => { diff --git a/src/pages/iou/request/step/IOURequestStepReport/hooks/useReportSelectionActions.ts b/src/pages/iou/request/step/IOURequestStepReport/hooks/useReportSelectionActions.ts index e72c909c1004..71d839cfefef 100644 --- a/src/pages/iou/request/step/IOURequestStepReport/hooks/useReportSelectionActions.ts +++ b/src/pages/iou/request/step/IOURequestStepReport/hooks/useReportSelectionActions.ts @@ -177,37 +177,35 @@ function useReportSelectionActions({ TransitionTracker.runAfterTransitions({ waitForUpcomingTransition: true, callback: () => { - Navigation.setNavigationActionToMicrotaskQueue(() => { - const participants = buildParticipants(report); - - setTransactionReport( - transaction.transactionID, - { - reportID: item.value, - participants, - }, - !isEditing, - ); - - if (isEditing) { - const policyTagList = item?.policyID ? allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${item.policyID}`] : {}; - changeTransactionsReport({ - transactionIDs: [transaction.transactionID], - isASAPSubmitBetaEnabled, - accountID: session?.accountID ?? CONST.DEFAULT_NUMBER_ID, - email: session?.email ?? '', - newReport: report, - policy: allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${item.policyID}`], - reportNextStep: undefined, - policyCategories: allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${item.policyID}`], - allTransactions, - policyTagList, - allTransactionViolation: transactionViolations, - allReports, - }); - removeTransaction(transaction.transactionID); - } - }); + const participants = buildParticipants(report); + + setTransactionReport( + transaction.transactionID, + { + reportID: item.value, + participants, + }, + !isEditing, + ); + + if (isEditing) { + const policyTagList = item?.policyID ? allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${item.policyID}`] : {}; + changeTransactionsReport({ + transactionIDs: [transaction.transactionID], + isASAPSubmitBetaEnabled, + accountID: session?.accountID ?? CONST.DEFAULT_NUMBER_ID, + email: session?.email ?? '', + newReport: report, + policy: allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${item.policyID}`], + reportNextStep: undefined, + policyCategories: allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${item.policyID}`], + allTransactions, + policyTagList, + allTransactionViolation: transactionViolations, + allReports, + }); + removeTransaction(transaction.transactionID); + } }, }); }; diff --git a/src/pages/settings/Security/MergeAccounts/MergeResultPage.tsx b/src/pages/settings/Security/MergeAccounts/MergeResultPage.tsx index 116e312f0a5f..bab1146f6329 100644 --- a/src/pages/settings/Security/MergeAccounts/MergeResultPage.tsx +++ b/src/pages/settings/Security/MergeAccounts/MergeResultPage.tsx @@ -201,6 +201,7 @@ function MergeResultPage() { callback: () => { Navigation.removeScreenFromNavigationState(SCREENS.SETTINGS.MERGE_ACCOUNTS.ACCOUNT_DETAILS); }, + waitForUpcomingTransition: true, }); return () => handle.cancel(); }, [result]); diff --git a/src/pages/settings/Security/TwoFactorAuth/ReplaceDeviceVerifyNewPage.tsx b/src/pages/settings/Security/TwoFactorAuth/ReplaceDeviceVerifyNewPage.tsx index e073fe239af3..8e5314555df5 100644 --- a/src/pages/settings/Security/TwoFactorAuth/ReplaceDeviceVerifyNewPage.tsx +++ b/src/pages/settings/Security/TwoFactorAuth/ReplaceDeviceVerifyNewPage.tsx @@ -13,7 +13,6 @@ import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; import {getLatestErrorMessage} from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; -import TransitionTracker from '@libs/Navigation/TransitionTracker'; import {getContactMethod} from '@libs/UserUtils'; import {clearAccountMessages, replaceTwoFactorDevice} from '@userActions/Session'; import CONST from '@src/CONST'; @@ -57,12 +56,8 @@ function ReplaceDeviceVerifyNewPage() { }, [account, account?.twoFactorAuthSecretKey]); const handleInputFocus = () => { - TransitionTracker.runAfterTransitions({ - callback: () => { - requestAnimationFrame(() => { - scrollViewRef.current?.scrollToEnd({animated: true}); - }); - }, + requestAnimationFrame(() => { + scrollViewRef.current?.scrollToEnd({animated: true}); }); }; diff --git a/src/pages/signin/LoginForm/BaseLoginForm.tsx b/src/pages/signin/LoginForm/BaseLoginForm.tsx index dd807a18c24a..62437474629e 100644 --- a/src/pages/signin/LoginForm/BaseLoginForm.tsx +++ b/src/pages/signin/LoginForm/BaseLoginForm.tsx @@ -1,6 +1,7 @@ import {useIsFocused} from '@react-navigation/native'; import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; -import {View} from 'react-native'; +// eslint-disable-next-line no-restricted-imports +import {InteractionManager, View} from 'react-native'; import DotIndicatorMessage from '@components/DotIndicatorMessage'; import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; import AppleSignIn from '@components/SignInButtons/AppleSignIn'; @@ -22,7 +23,6 @@ import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; import {getLatestErrorMessage} from '@libs/ErrorUtils'; import isInputAutoFilled from '@libs/isInputAutoFilled'; import {appendCountryCode, getPhoneNumberWithoutSpecialChars} from '@libs/LoginUtils'; -import TransitionTracker from '@libs/Navigation/TransitionTracker'; import {parsePhoneNumber} from '@libs/PhoneNumber'; import {isAgentEmail} from '@libs/SessionUtils'; import StringUtils from '@libs/StringUtils'; @@ -225,7 +225,9 @@ function BaseLoginForm({submitBehavior = 'submit', isVisible, ref}: BaseLoginFor // On mobile WebKit browsers, when an input field gains focus, the keyboard appears and the virtual viewport is resized and scrolled to make the input field visible. // This occurs even when there is enough space to display both the input field and the submit button in the current view. // so this change to correct the scroll position when the input field gains focus. - TransitionTracker.runAfterTransitions({callback: () => htmlDivElementRef(submitContainerRef).current?.scrollIntoView?.({behavior: 'smooth', block: 'end'})}); + InteractionManager.runAfterInteractions(() => { + htmlDivElementRef(submitContainerRef).current?.scrollIntoView?.({behavior: 'smooth', block: 'end'}); + }); }, []); const handleSignIn = () => setIsSigningWithAppleOrGoogle(true); diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx index f4faf5d88d3e..1a9b266e0077 100644 --- a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx @@ -49,7 +49,6 @@ import {getLatestErrorField} from '@libs/ErrorUtils'; import {getConnectedHRProvider, getHRFinalApprover, isAnyHRConnected, isAnyHRReadOnlyWorkflowMode, isHRAdvancedMode} from '@libs/HRUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; -import TransitionTracker from '@libs/Navigation/TransitionTracker'; import {getPaymentMethodDescription} from '@libs/PaymentUtils'; import {getDisplayNameOrDefault, getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; import { @@ -192,8 +191,7 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { const {showLockedAccountModal} = useLockedAccountActions(); useEffect(() => { - const handle = TransitionTracker.runAfterTransitions({callback: fetchData}); - return handle.cancel; + fetchData(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); From 36e0c4bd962586292d7040641cfa9355216ff6b4 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Fri, 26 Jun 2026 17:16:20 +0200 Subject: [PATCH 3/4] fix test --- tests/unit/cleanupAndNavigateAfterExpenseCreateTest.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/cleanupAndNavigateAfterExpenseCreateTest.ts b/tests/unit/cleanupAndNavigateAfterExpenseCreateTest.ts index 88ae0e8b7994..e68f3c9c5ff3 100644 --- a/tests/unit/cleanupAndNavigateAfterExpenseCreateTest.ts +++ b/tests/unit/cleanupAndNavigateAfterExpenseCreateTest.ts @@ -40,6 +40,7 @@ describe('cleanupAndNavigateAfterExpenseCreate', () => { expect(cleanupAfterExpenseCreate).toHaveBeenCalledWith({ draftTransactionIDs: ['txn-1'], linkedTrackedExpenseReportAction, + waitForUpcomingTransition: true, }); expect(navigateAfterExpenseCreate).toHaveBeenCalledTimes(1); }); From 59e7a1e1932653469413bf172d91d2fc00eb88e8 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Mon, 29 Jun 2026 11:40:13 +0200 Subject: [PATCH 4/4] remove cleanup after expense create migration --- .../helpers/cleanupAfterExpenseCreate.ts | 17 ++++++----------- .../cleanupAndNavigateAfterExpenseCreate.ts | 2 +- tests/unit/cleanupAfterExpenseCreateTest.ts | 12 +++++++----- .../cleanupAndNavigateAfterExpenseCreateTest.ts | 1 - 4 files changed, 14 insertions(+), 18 deletions(-) diff --git a/src/libs/Navigation/helpers/cleanupAfterExpenseCreate.ts b/src/libs/Navigation/helpers/cleanupAfterExpenseCreate.ts index cbfa5fd4ec22..7455d8c1b59b 100644 --- a/src/libs/Navigation/helpers/cleanupAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/cleanupAfterExpenseCreate.ts @@ -1,25 +1,20 @@ +// eslint-disable-next-line no-restricted-imports -- InteractionManager is the only cross-platform API to defer work past the dismiss animation +import {InteractionManager} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {removeDraftTransactionsByIDs} from '@libs/actions/TransactionEdit'; import Navigation from '@libs/Navigation/Navigation'; -import TransitionTracker from '@libs/Navigation/TransitionTracker'; import type {ReportAction} from '@src/types/onyx'; type CleanupAfterExpenseCreateParams = { draftTransactionIDs: string[] | undefined; linkedTrackedExpenseReportAction?: OnyxEntry; - - /** - * Pass `true` when cleanup is called before navigation has been dispatched (e.g. cleanupAndNavigateAfterExpenseCreate). - * TransitionTracker will then wait for the upcoming transition to start before queuing the callback. - * Leave `false` (default) when navigation has already been dispatched and a transition may already be in progress. - */ - waitForUpcomingTransition?: boolean; }; /** Cleanup-only after a submit. Use `cleanupAndNavigateAfterExpenseCreate` when the flow also needs navigation. */ -function cleanupAfterExpenseCreate({draftTransactionIDs, linkedTrackedExpenseReportAction, waitForUpcomingTransition = false}: CleanupAfterExpenseCreateParams) { - // Defer past the modal-dismiss animation so cleanup doesn't block the JS thread / cause jank during post-submit navigation. - TransitionTracker.runAfterTransitions({callback: () => removeDraftTransactionsByIDs(draftTransactionIDs), waitForUpcomingTransition}); +function cleanupAfterExpenseCreate({draftTransactionIDs, linkedTrackedExpenseReportAction}: CleanupAfterExpenseCreateParams) { + // Defer past the modal-dismiss animation so it doesn't block the JS thread. + // eslint-disable-next-line @typescript-eslint/no-deprecated -- InteractionManager is widely used across the codebase and kept alive via a dedicated RN patch + InteractionManager.runAfterInteractions(() => removeDraftTransactionsByIDs(draftTransactionIDs)); if (linkedTrackedExpenseReportAction?.childReportID) { const trackReport = Navigation.getReportRouteByID(linkedTrackedExpenseReportAction.childReportID); diff --git a/src/libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate.ts index a57d99f9f284..d2b94176a600 100644 --- a/src/libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate.ts @@ -29,7 +29,7 @@ function cleanupAndNavigateAfterExpenseCreate({ linkedTrackedExpenseReportAction, action, }: CleanupAndNavigateAfterExpenseCreateParams) { - cleanupAfterExpenseCreate({draftTransactionIDs, linkedTrackedExpenseReportAction, waitForUpcomingTransition: true}); + cleanupAfterExpenseCreate({draftTransactionIDs, linkedTrackedExpenseReportAction}); const finalActiveReportID = backToReport ?? report?.reportID ?? optimisticChatReportID; const finalActiveReport = finalActiveReportID === report?.reportID ? report : getReportOrDraftReport(finalActiveReportID); diff --git a/tests/unit/cleanupAfterExpenseCreateTest.ts b/tests/unit/cleanupAfterExpenseCreateTest.ts index 57b3654f1dee..5af0cc7ba898 100644 --- a/tests/unit/cleanupAfterExpenseCreateTest.ts +++ b/tests/unit/cleanupAfterExpenseCreateTest.ts @@ -9,10 +9,12 @@ jest.mock('@libs/actions/TransactionEdit', () => ({ removeDraftTransactionsByIDs: (ids: string[] | undefined) => mockRemoveDraftTransactionsByIDs(ids) as void, })); -jest.mock('@libs/Navigation/TransitionTracker', () => ({ - runAfterTransitions: ({callback}: {callback: () => void}) => { - callback(); - return {cancel: jest.fn()}; +jest.mock('react-native', () => ({ + InteractionManager: { + runAfterInteractions: (callback: () => void) => { + callback(); + return {then: (cb: () => void) => cb(), cancel: jest.fn()}; + }, }, })); @@ -26,7 +28,7 @@ describe('cleanupAfterExpenseCreate', () => { jest.clearAllMocks(); }); - it('should remove draft transactions after transition when draftTransactionIDs is provided', () => { + it('should remove draft transactions via InteractionManager when draftTransactionIDs is provided', () => { cleanupAfterExpenseCreate({ draftTransactionIDs: ['txn-1', 'txn-2'], }); diff --git a/tests/unit/cleanupAndNavigateAfterExpenseCreateTest.ts b/tests/unit/cleanupAndNavigateAfterExpenseCreateTest.ts index e68f3c9c5ff3..88ae0e8b7994 100644 --- a/tests/unit/cleanupAndNavigateAfterExpenseCreateTest.ts +++ b/tests/unit/cleanupAndNavigateAfterExpenseCreateTest.ts @@ -40,7 +40,6 @@ describe('cleanupAndNavigateAfterExpenseCreate', () => { expect(cleanupAfterExpenseCreate).toHaveBeenCalledWith({ draftTransactionIDs: ['txn-1'], linkedTrackedExpenseReportAction, - waitForUpcomingTransition: true, }); expect(navigateAfterExpenseCreate).toHaveBeenCalledTimes(1); });