From 6e6912a8dfad22f2f3205a7bfa463eb948ecda69 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Thu, 19 Mar 2026 11:45:25 +0100 Subject: [PATCH 01/11] Reapply "Merge pull request #83899 from software-mansion-labs/collectioneur/transition-tracker-v2" This reverts commit a6b928bc0c9695c8dca2292d3eabfd4e798c8716. --- contributingGuides/INTERACTION_MANAGER.md | 67 ++++++++ src/CONST/index.ts | 5 +- src/components/EmojiPicker/EmojiPicker.tsx | 2 +- src/components/Modal/BaseModal.tsx | 4 +- .../Modal/ReanimatedModal/index.tsx | 6 + .../Navigators/RightModalNavigator.tsx | 6 +- src/libs/Navigation/Navigation.ts | 114 ++++++++------ .../PlatformStackNavigation/ScreenLayout.tsx | 39 +++++ .../index.native.tsx | 2 + .../index.tsx | 2 + src/libs/Navigation/TransitionTracker.ts | 148 ++++++++++++++++++ src/libs/Navigation/helpers/linkTo/types.ts | 6 +- src/libs/actions/Report/index.ts | 2 +- src/pages/NewChatPage.tsx | 2 +- .../routes/TransactionReceiptModalContent.tsx | 9 +- .../WorkspaceCategoriesSettingsPage.tsx | 9 +- .../WorkspaceInviteMessageComponent.tsx | 6 +- .../workspace/tags/WorkspaceCreateTagPage.tsx | 7 +- src/utils/keyboard/index.android.ts | 11 +- src/utils/keyboard/index.ts | 11 +- src/utils/keyboard/index.website.ts | 21 ++- src/utils/keyboard/types.ts | 7 + .../unit/Navigation/TransitionTrackerTest.ts | 108 +++++++++++++ .../unit/keyboard/AndroidKeyboardUtilsTest.ts | 1 + tests/unit/keyboard/KeyboardUtilsTest.ts | 23 ++- 25 files changed, 530 insertions(+), 88 deletions(-) create mode 100644 contributingGuides/INTERACTION_MANAGER.md create mode 100644 src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx create mode 100644 src/libs/Navigation/TransitionTracker.ts create mode 100644 src/utils/keyboard/types.ts create mode 100644 tests/unit/Navigation/TransitionTrackerTest.ts diff --git a/contributingGuides/INTERACTION_MANAGER.md b/contributingGuides/INTERACTION_MANAGER.md new file mode 100644 index 000000000000..08008b0b42ad --- /dev/null +++ b/contributingGuides/INTERACTION_MANAGER.md @@ -0,0 +1,67 @@ +# InteractionManager Migration + +## Why + +`InteractionManager` is being removed from React Native. We currently maintain a patch to keep it working, but that's a temporary measure and upstream libraries will also drop support over time. + +Rather than keep patching, we're replacing `InteractionManager.runAfterInteractions` with purpose-built alternatives that are more precise. + +## Current state + +`runAfterInteractions` is used across the codebase for a wide range of reasons: waiting for navigation transitions, deferring work after modals close, managing input focus, delaying scroll operations, and many other cases that are hard to classify. + +## The problem + +`runAfterInteractions` is a global queue with no granularity. This made it a convenient catch-all, but the intent behind each call is often unclear. Many usages exist simply because it "just worked" as a timing workaround, not because it was the right tool for the job. + +This makes the migration non-trivial: you have to understand *what each call is actually waiting for* before you can pick the right replacement. + +## The approach + +**TransitionTracker** is the backbone. It tracks navigation transitions explicitly, so other APIs can hook into transition lifecycle without relying on a global queue. + +On top of TransitionTracker, existing APIs gain transition-aware callbacks: + +- Navigation methods accept `afterTransition` — a callback that runs after the triggered navigation transition completes +- Navigation methods accept `waitForTransition` — the call waits for all ongoing transitions to finish before navigating +- Keyboard methods accept `afterTransition` — a callback that runs after the keyboard transition completes +- `useConfirmModal` hook's `showConfirmModal` returns a Promise that resolves **after the modal close transition completes**, so any work awaited after it naturally runs post-transition — no explicit `afterTransition` callback needed + +This makes the code self-descriptive: instead of a generic `runAfterInteractions`, each call site says exactly what it's waiting for and why. + +> **Note:** `TransitionTracker.runAfterTransitions` is an internal primitive. Application code should use the higher-level APIs (`Navigation`, `useConfirmModal`, etc.) rather than importing TransitionTracker directly. + +## How +The migration is split into 9 issues. Current status of the migration can be found in the parent Github issue [here](https://github.com/Expensify/App/issues/71913). + +## Primitives comparison + +For reference, here's how the available timing primitives compare: + +### `requestAnimationFrame` (rAF) + +- Fires **before the next paint** (~16ms at 60fps) +- Guaranteed to run every frame if the thread isn't blocked +- Use for: UI updates that need to happen on the next frame (scroll, layout measurement, enabling a button after a state flush) + +### `requestIdleCallback` + +- Fires when the runtime has **idle time** — no pending frames, no urgent work +- May be delayed indefinitely if the main thread stays busy +- Accepts a `timeout` option to force execution after a deadline +- Use for: Non-urgent background work (Pusher subscriptions, search API calls, contact imports) + +### `InteractionManager.runAfterInteractions` (legacy — do not use) + +- React Native-specific. Fires after all **ongoing interactions** (animations, touches) complete +- Tracks interactions via `createInteractionHandle()` — anything that calls `handle.done()` unblocks the queue +- In practice, this means "run after the current navigation transition finishes" +- Problem: it's a global queue with no granularity — you can't say "after _this specific_ transition" + +### Summary + +| | Timing | Granularity | Platform | +| ---------------------- | ------------------------- | ------------------------- | --------------------- | +| `rAF` | Next frame (~16ms) | None — just "next paint" | Web + RN | +| `requestIdleCallback` | When idle (unpredictable) | None — "whenever free" | Web + RN (polyfilled) | +| `runAfterInteractions` | After animations finish | Global — all interactions | RN only | diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 77403685225b..b49faa86ea93 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -231,6 +231,7 @@ const CONST = { ANIMATED_PROGRESS_BAR_DURATION: 750, ANIMATION_IN_TIMING: 100, COMPOSER_FOCUS_DELAY: 150, + MAX_TRANSITION_DURATION_MS: 1000, ANIMATION_DIRECTION: { IN: 'in', OUT: 'out', @@ -8466,10 +8467,6 @@ const CONST = { ADD_EXPENSE_APPROVALS: 'addExpenseApprovals', }, - MODAL_EVENTS: { - CLOSED: 'modalClosed', - }, - LIST_BEHAVIOR: { REGULAR: 'regular', INVERTED: 'inverted', diff --git a/src/components/EmojiPicker/EmojiPicker.tsx b/src/components/EmojiPicker/EmojiPicker.tsx index 1fec2354705b..4a147f34c4d4 100644 --- a/src/components/EmojiPicker/EmojiPicker.tsx +++ b/src/components/EmojiPicker/EmojiPicker.tsx @@ -116,7 +116,7 @@ function EmojiPicker({viewportOffsetTop, ref}: EmojiPickerProps) { // It's possible that the anchor is inside an active modal (e.g., add emoji reaction in report context menu). // So, we need to get the anchor position first before closing the active modal which will also destroy the anchor. - KeyboardUtils.dismiss(true).then(() => + KeyboardUtils.dismiss({shouldSkipSafari: true}).then(() => calculateAnchorPosition(emojiPopoverAnchor?.current, anchorOriginValue).then((value) => { close(() => { onWillShow?.(); diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index 2cfe2ab955c5..9bd6fec116ae 100644 --- a/src/components/Modal/BaseModal.tsx +++ b/src/components/Modal/BaseModal.tsx @@ -2,7 +2,7 @@ import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} fr import type {LayoutChangeEvent} from 'react-native'; // Animated required for side panel navigation // eslint-disable-next-line no-restricted-imports -import {Animated, DeviceEventEmitter, View} from 'react-native'; +import {Animated, View} from 'react-native'; import ColorSchemeWrapper from '@components/ColorSchemeWrapper'; import NavigationBar from '@components/NavigationBar'; import ScreenWrapperOfflineIndicatorContext from '@components/ScreenWrapper/ScreenWrapperOfflineIndicatorContext'; @@ -168,8 +168,6 @@ function BaseModal({ [], ); - useEffect(() => () => DeviceEventEmitter.emit(CONST.MODAL_EVENTS.CLOSED), []); - const handleShowModal = useCallback(() => { if (shouldSetModalVisibility) { setModalVisibility(true, type); diff --git a/src/components/Modal/ReanimatedModal/index.tsx b/src/components/Modal/ReanimatedModal/index.tsx index 4ab09749bd9d..8458f38c316b 100644 --- a/src/components/Modal/ReanimatedModal/index.tsx +++ b/src/components/Modal/ReanimatedModal/index.tsx @@ -9,6 +9,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import blurActiveElement from '@libs/Accessibility/blurActiveElement'; import getPlatform from '@libs/getPlatform'; +import TransitionTracker from '@libs/Navigation/TransitionTracker'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import Backdrop from './Backdrop'; @@ -103,6 +104,7 @@ function ReanimatedModal({ // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.clearInteractionHandle(handleRef.current); } + TransitionTracker.endTransition(); setIsVisibleState(false); setIsContainerOpen(false); @@ -115,6 +117,7 @@ function ReanimatedModal({ if (isVisible && !isContainerOpen && !isTransitioning) { // eslint-disable-next-line @typescript-eslint/no-deprecated handleRef.current = InteractionManager.createInteractionHandle(); + TransitionTracker.startTransition(); onModalWillShow(); setIsVisibleState(true); @@ -122,6 +125,7 @@ function ReanimatedModal({ } else if (!isVisible && isContainerOpen && !isTransitioning) { // eslint-disable-next-line @typescript-eslint/no-deprecated handleRef.current = InteractionManager.createInteractionHandle(); + TransitionTracker.startTransition(); onModalWillHide(); blurActiveElement(); @@ -142,6 +146,7 @@ function ReanimatedModal({ // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.clearInteractionHandle(handleRef.current); } + TransitionTracker.endTransition(); onModalShow(); }, [onModalShow]); @@ -152,6 +157,7 @@ function ReanimatedModal({ // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.clearInteractionHandle(handleRef.current); } + TransitionTracker.endTransition(); // Because on Android, the Modal's onDismiss callback does not work reliably. There's a reported issue at: // https://stackoverflow.com/questions/58937956/react-native-modal-ondismiss-not-invoked diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx index e38adcd58e4c..ed493451d7b5 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx @@ -1,8 +1,8 @@ import type {NavigatorScreenParams} from '@react-navigation/native'; import {useFocusEffect} from '@react-navigation/native'; -import React, {useCallback, useEffect, useMemo, useRef} from 'react'; +import React, {useCallback, useMemo, useRef} from 'react'; // eslint-disable-next-line no-restricted-imports -import {Animated, DeviceEventEmitter, InteractionManager} from 'react-native'; +import {Animated, InteractionManager} from 'react-native'; import NoDropZone from '@components/DragAndDrop/NoDropZone'; import {MultifactorAuthenticationContextProviders} from '@components/MultifactorAuthentication/Context'; import { @@ -181,8 +181,6 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) { }, [syncRHPKeys, clearWideRHPKeysAfterTabChanged]), ); - useEffect(() => () => DeviceEventEmitter.emit(CONST.MODAL_EVENTS.CLOSED), []); - return ( diff --git a/src/libs/Navigation/Navigation.ts b/src/libs/Navigation/Navigation.ts index 714e3e7ed733..41db04f1d9e0 100644 --- a/src/libs/Navigation/Navigation.ts +++ b/src/libs/Navigation/Navigation.ts @@ -5,7 +5,7 @@ import {Str} from 'expensify-common'; // eslint-disable-next-line you-dont-need-lodash-underscore/omit import omit from 'lodash/omit'; import {nanoid} from 'nanoid/non-secure'; -import {DeviceEventEmitter, Dimensions, InteractionManager} from 'react-native'; +import {Dimensions} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {Writable} from 'type-fest'; @@ -42,6 +42,7 @@ import setNavigationActionToMicrotaskQueue from './helpers/setNavigationActionTo import {linkingConfig} from './linkingConfig'; import {SPLIT_TO_SIDEBAR} from './linkingConfig/RELATIONS'; import navigationRef from './navigationRef'; +import TransitionTracker from './TransitionTracker'; import type { NavigationPartialRoute, NavigationRef, @@ -331,9 +332,18 @@ function navigate(route: Route, options?: LinkToOptions) { } } - const targetRoute = route.startsWith(CONST.SAML_REDIRECT_URL) ? ROUTES.HOME : route; - linkTo(navigationRef.current, targetRoute, options); - closeSidePanelOnNarrowScreen(route); + const runImmediately = !options?.waitForTransition; + TransitionTracker.runAfterTransitions({ + callback: () => { + const targetRoute = route.startsWith(CONST.SAML_REDIRECT_URL) ? ROUTES.HOME : route; + linkTo(navigationRef.current, targetRoute, options); + closeSidePanelOnNarrowScreen(route); + if (options?.afterTransition) { + TransitionTracker.runAfterTransitions({callback: options.afterTransition, waitForUpcomingTransition: true}); + } + }, + runImmediately, + }); } /** * When routes are compared to determine whether the fallback route passed to the goUp function is in the state, @@ -398,10 +408,15 @@ type GoBackOptions = { * In that case we want to goUp to a country picker with any params so we don't compare them. */ compareParams?: boolean; + // Callback to execute after the navigation transition animation completes. + afterTransition?: () => void | undefined; + // If true, waits for ongoing transitions to finish before going back. Defaults to false (goes back immediately). + waitForTransition?: boolean; }; -const defaultGoBackOptions: Required = { +const defaultGoBackOptions: Required> = { compareParams: true, + waitForTransition: false, }; /** @@ -490,22 +505,26 @@ function goBack(backToRoute?: Route, options?: GoBackOptions) { return; } - if (backToRoute) { - goUp(backToRoute, options); - return; - } - - if (shouldPopToSidebar) { - popToSidebar(); - return; - } - - if (!navigationRef.current?.canGoBack()) { - Log.hmmm('[Navigation] Unable to go back'); - return; - } + const runImmediately = !options?.waitForTransition; + TransitionTracker.runAfterTransitions({ + callback: () => { + if (backToRoute) { + goUp(backToRoute, options); + } else if (shouldPopToSidebar) { + popToSidebar(); + } else if (!navigationRef.current?.canGoBack()) { + Log.hmmm('[Navigation] Unable to go back'); + return; + } else { + navigationRef.current?.goBack(); + } - navigationRef.current?.goBack(); + if (options?.afterTransition) { + TransitionTracker.runAfterTransitions({callback: options.afterTransition, waitForUpcomingTransition: true}); + } + }, + runImmediately, + }); } /** @@ -738,25 +757,27 @@ function getTopmostSuperWideRHPReportID(state: NavigationState = navigationRef.g * * @param options - Configuration object * @param options.ref - Navigation ref to use (defaults to navigationRef) - * @param options.callback - Optional callback to execute after the modal has finished closing. - * The callback fires when RightModalNavigator unmounts. + * @param options.afterTransition - Optional callback to execute after the navigation transition animation completes. * * For detailed information about dismissing modals, * see the NAVIGATION.md documentation. */ -const dismissModal = ({ref = navigationRef, callback}: {ref?: NavigationRef; callback?: () => void} = {}) => { +function dismissModal({ref = navigationRef, afterTransition, waitForTransition}: {ref?: NavigationRef; afterTransition?: () => void; waitForTransition?: boolean} = {}) { clearSelectedTextIfComposerBlurred(); + const runImmediately = !waitForTransition; isNavigationReady().then(() => { - if (callback) { - const subscription = DeviceEventEmitter.addListener(CONST.MODAL_EVENTS.CLOSED, () => { - subscription.remove(); - callback(); - }); - } + TransitionTracker.runAfterTransitions({ + callback: () => { + ref.dispatch({type: CONST.NAVIGATION.ACTION_TYPE.DISMISS_MODAL}); - ref.dispatch({type: CONST.NAVIGATION.ACTION_TYPE.DISMISS_MODAL}); + if (afterTransition) { + TransitionTracker.runAfterTransitions({callback: afterTransition, waitForUpcomingTransition: true}); + } + }, + runImmediately, + }); }); -}; +} /** * Dismisses the modal and opens the given report. @@ -793,10 +814,11 @@ const dismissModalWithReport = ( navigate(reportRoute, {forceReplace: true}); return; } - dismissModal(); - // eslint-disable-next-line @typescript-eslint/no-deprecated - InteractionManager.runAfterInteractions(() => { - navigate(reportRoute); + + dismissModal({ + afterTransition: () => { + navigate(reportRoute); + }, }); }); }; @@ -881,7 +903,7 @@ function clearPreloadedRoutes() { * * @param modalStackNames - names of the modal stacks we want to dismiss to */ -function dismissToModalStack(modalStackNames: Set) { +function dismissToModalStack(modalStackNames: Set, options: {afterTransition?: () => void} = {}) { const rootState = navigationRef.getRootState(); if (!rootState) { return; @@ -897,32 +919,36 @@ function dismissToModalStack(modalStackNames: Set) { const routesToPop = rhpState.routes.length - lastFoundModalStackIndex - 1; if (routesToPop <= 0 || lastFoundModalStackIndex === -1) { - dismissModal(); + dismissModal(options); return; } navigationRef.dispatch({...StackActions.pop(routesToPop), target: rhpState.key}); + + if (options?.afterTransition) { + TransitionTracker.runAfterTransitions({callback: options.afterTransition, waitForUpcomingTransition: true}); + } } /** * Dismiss top layer modal and go back to the Wide/Super Wide RHP. */ -function dismissToPreviousRHP() { - return dismissToModalStack(ALL_WIDE_RIGHT_MODALS); +function dismissToPreviousRHP(options: {afterTransition?: () => void} = {}) { + return dismissToModalStack(ALL_WIDE_RIGHT_MODALS, options); } -function navigateBackToLastSuperWideRHPScreen() { - return dismissToModalStack(SUPER_WIDE_RIGHT_MODALS); +function navigateBackToLastSuperWideRHPScreen(options: {afterTransition?: () => void} = {}) { + return dismissToModalStack(SUPER_WIDE_RIGHT_MODALS, options); } -function dismissToSuperWideRHP() { +function dismissToSuperWideRHP(options: {afterTransition?: () => void} = {}) { // On narrow layouts (mobile), Super Wide RHP doesn't exist, so just dismiss the modal completely if (getIsNarrowLayout()) { - dismissModal(); + dismissModal(options); return; } // On wide layouts, dismiss back to the Super Wide RHP modal stack - navigateBackToLastSuperWideRHPScreen(); + navigateBackToLastSuperWideRHPScreen(options); } /** diff --git a/src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx b/src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx new file mode 100644 index 000000000000..4fd130261efb --- /dev/null +++ b/src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx @@ -0,0 +1,39 @@ +import type {ParamListBase, ScreenLayoutArgs} from '@react-navigation/native'; +import React, {useLayoutEffect} from 'react'; +import TransitionTracker from '@libs/Navigation/TransitionTracker'; +import type {PlatformSpecificNavigationOptions, PlatformStackNavigationOptions, PlatformStackNavigationProp} from './types'; + +// screenLayout is invoked as a render function (not JSX), so we need this wrapper to create a proper React component boundary for hooks. +function screenLayoutWrapper({navigation, ...rest}: ScreenLayoutArgs) { + return ( + } + /> + ); +} + +function ScreenLayout({ + children, + navigation, +}: ScreenLayoutArgs>) { + useLayoutEffect(() => { + const transitionStartListener = navigation.addListener('transitionStart', () => { + TransitionTracker.startTransition(); + }); + const transitionEndListener = navigation.addListener('transitionEnd', () => { + TransitionTracker.endTransition(); + }); + + return () => { + transitionStartListener(); + transitionEndListener(); + }; + }, [navigation]); + + return children; +} + +export default screenLayoutWrapper; diff --git a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.native.tsx b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.native.tsx index ce41090ab349..ac05214c6302 100644 --- a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.native.tsx +++ b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.native.tsx @@ -5,6 +5,7 @@ import type {NativeStackNavigationEventMap, NativeStackNavigationOptions} from ' import React, {useMemo} from 'react'; import {addCustomHistoryRouterExtension} from '@libs/Navigation/AppNavigator/customHistory'; import convertToNativeNavigationOptions from '@libs/Navigation/PlatformStackNavigation/navigationOptions/convertToNativeNavigationOptions'; +import screenLayout from '@libs/Navigation/PlatformStackNavigation/ScreenLayout'; import type { CreatePlatformStackNavigatorComponentOptions, CustomCodeProps, @@ -60,6 +61,7 @@ function createPlatformStackNavigatorComponent void}; + +type RunAfterTransitionsOptions = { + /** The function to invoke once all active transitions have completed. */ + callback: () => void; + + /** If true, the callback fires synchronously regardless of any active transitions. Defaults to false. */ + runImmediately?: boolean; + + /** If true, waits for the next transition to start before queuing the callback, so it runs after that transition ends. + * Useful when a navigation action has just been dispatched but the transition has not yet been registered. + * Defaults to false. */ + waitForUpcomingTransition?: boolean; +}; + +let activeCount = 0; + +const activeTimeouts: Array> = []; + +let pendingCallbacks: Array<() => void> = []; + +let nextTransitionStartResolve: (() => void) | null = null; +let promiseForNextTransitionStart = new Promise((resolve) => { + nextTransitionStartResolve = resolve; +}); + +/** + * Invokes and removes all pending callbacks. + */ +function flushCallbacks(): void { + const callbacks = pendingCallbacks; + pendingCallbacks = []; + for (const callback of callbacks) { + callback(); + } +} + +/** + * Decrements the active count and flushes callbacks when all transitions are idle. + * Shared by {@link endTransition} (manual) and the auto-timeout. + */ +function decrementAndFlush(): void { + activeCount = Math.max(0, activeCount - 1); + + if (activeCount === 0) { + flushCallbacks(); + } +} + +/** + * Increments the active transition count. + * Multiple overlapping transitions are counted. + * Each transition automatically ends after {@link MAX_TRANSITION_DURATION_MS} as a safety net. + */ +function startTransition(): void { + activeCount += 1; + + const resolve = nextTransitionStartResolve; + if (resolve) { + nextTransitionStartResolve = null; + promiseForNextTransitionStart = new Promise((r) => { + nextTransitionStartResolve = r; + }); + resolve(); + } + + const timeout = setTimeout(() => { + const idx = activeTimeouts.indexOf(timeout); + if (idx !== -1) { + activeTimeouts.splice(idx, 1); + } + decrementAndFlush(); + }, CONST.MAX_TRANSITION_DURATION_MS); + + activeTimeouts.push(timeout); +} + +/** + * Decrements the active transition count. + * Clears the corresponding auto-timeout since the transition ended normally. + * When the count reaches zero, flushes all pending callbacks. + */ +function endTransition(): void { + const timeout = activeTimeouts.shift(); + if (timeout !== undefined) { + clearTimeout(timeout); + } + + decrementAndFlush(); +} + +/** + * Schedules a callback to run after all transitions complete. If no transitions are active + * or `runImmediately` is true, the callback fires synchronously. + * + * @param options - Options object. + * @param options.callback - The function to invoke once transitions finish. + * @param options.runImmediately - If true, the callback fires synchronously regardless of active transitions. Defaults to false. + * @param options.waitForUpcomingTransition - If true, waits for the next transition to start before queuing the callback, so it runs after that transition ends. Use when navigation happens just before this call and the transition is not yet registered. Defaults to false. + * @returns A handle with a `cancel` method to prevent the callback from firing. + */ +function runAfterTransitions({callback, runImmediately = false, waitForUpcomingTransition = false}: RunAfterTransitionsOptions): CancelHandle { + if (waitForUpcomingTransition) { + let cancelled = false; + let innerHandle: CancelHandle | null = null; + + (async () => { + await promiseForNextTransitionStart; + if (!cancelled) { + innerHandle = runAfterTransitions({callback}); + } + })(); + + return { + cancel: () => { + cancelled = true; + innerHandle?.cancel(); + }, + }; + } + + if (activeCount === 0 || runImmediately) { + callback(); + return {cancel: () => {}}; + } + + pendingCallbacks.push(callback); + + return { + cancel: () => { + const idx = pendingCallbacks.indexOf(callback); + if (idx !== -1) { + pendingCallbacks.splice(idx, 1); + } + }, + }; +} + +const TransitionTracker = { + startTransition, + endTransition, + runAfterTransitions, +}; + +export default TransitionTracker; +export type {CancelHandle}; diff --git a/src/libs/Navigation/helpers/linkTo/types.ts b/src/libs/Navigation/helpers/linkTo/types.ts index 26217f561b9e..0cb394540505 100644 --- a/src/libs/Navigation/helpers/linkTo/types.ts +++ b/src/libs/Navigation/helpers/linkTo/types.ts @@ -10,7 +10,11 @@ type ActionPayload = { type LinkToOptions = { // To explicitly set the action type to replace. - forceReplace: boolean; + forceReplace?: boolean; + // Callback to execute after the navigation transition animation completes. + afterTransition?: () => void; + // If true, waits for ongoing transitions to finish before navigating. Defaults to false (navigates immediately). + waitForTransition?: boolean; }; export type {ActionPayload, LinkToOptions}; diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index f520810ed5fc..aaa3f7d83303 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -1988,7 +1988,7 @@ function createTransactionThreadReport( function navigateToReport(reportID: string | undefined, shouldDismissModal = true) { if (shouldDismissModal) { Navigation.dismissModal({ - callback: () => { + afterTransition: () => { if (!reportID) { return; } diff --git a/src/pages/NewChatPage.tsx b/src/pages/NewChatPage.tsx index ddaf2206f10e..e33d1e031bd7 100755 --- a/src/pages/NewChatPage.tsx +++ b/src/pages/NewChatPage.tsx @@ -385,7 +385,7 @@ function NewChatPage({ref}: NewChatPageProps) { if (option?.reportID) { Navigation.dismissModal({ - callback: () => { + afterTransition: () => { Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(option?.reportID)); }, }); diff --git a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx index ca25690930bd..64fd1e351bd1 100644 --- a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx +++ b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx @@ -556,16 +556,9 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre Navigation.getActiveRoute(), ); }; - if (isNative) { - Navigation.goBack(); - Navigation.setNavigationActionToMicrotaskQueue(() => { - Navigation.navigate(getDestinationRoute()); - }); - return; - } Navigation.dismissModal({ - callback: () => Navigation.navigate(getDestinationRoute()), + afterTransition: () => Navigation.navigate(getDestinationRoute()), }); }} text={translate('common.replace')} diff --git a/src/pages/workspace/categories/WorkspaceCategoriesSettingsPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesSettingsPage.tsx index 21ba475f877f..a88bb89c670f 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesSettingsPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesSettingsPage.tsx @@ -1,5 +1,5 @@ import React, {useCallback, useMemo, useState} from 'react'; -import {InteractionManager, Keyboard, View} from 'react-native'; +import {View} from 'react-native'; import CategorySelectorModal from '@components/CategorySelector/CategorySelectorModal'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -26,6 +26,7 @@ import {clearPolicyErrorField, setWorkspaceDefaultSpendCategory} from '@userActi import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; +import KeyboardUtils from '@src/utils/keyboard'; type WorkspaceCategoriesSettingsPageProps = WithPolicyConnectionsProps & ( @@ -80,10 +81,8 @@ function WorkspaceCategoriesSettingsPage({policy, route}: WorkspaceCategoriesSet setWorkspaceDefaultSpendCategory(policyID, currentGroupID, selectedCategory.keyForList); } - Keyboard.dismiss(); - // eslint-disable-next-line @typescript-eslint/no-deprecated - InteractionManager.runAfterInteractions(() => { - setIsSelectorModalVisible(false); + KeyboardUtils.dismiss({ + afterTransition: () => setIsSelectorModalVisible(false), }); }; diff --git a/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx b/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx index 4e95f50ca45d..36269d9570ab 100644 --- a/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx +++ b/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx @@ -142,7 +142,7 @@ function WorkspaceInviteMessageComponent({ } if ((backTo as string)?.endsWith('members')) { - Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.dismissModal()); + Navigation.dismissModal(); return; } @@ -151,8 +151,8 @@ function WorkspaceInviteMessageComponent({ return; } - Navigation.setNavigationActionToMicrotaskQueue(() => { - Navigation.dismissModal({callback: () => Navigation.navigate(ROUTES.WORKSPACE_MEMBERS.getRoute(policyID))}); + Navigation.dismissModal({ + afterTransition: () => Navigation.navigate(ROUTES.WORKSPACE_MEMBERS.getRoute(policyID)), }); }; diff --git a/src/pages/workspace/tags/WorkspaceCreateTagPage.tsx b/src/pages/workspace/tags/WorkspaceCreateTagPage.tsx index ffe1bb6efba1..0fbadcd27021 100644 --- a/src/pages/workspace/tags/WorkspaceCreateTagPage.tsx +++ b/src/pages/workspace/tags/WorkspaceCreateTagPage.tsx @@ -1,5 +1,4 @@ import React, {useCallback} from 'react'; -import {Keyboard} from 'react-native'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; @@ -25,6 +24,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/WorkspaceTagForm'; +import KeyboardUtils from '@src/utils/keyboard'; type WorkspaceCreateTagPageProps = | PlatformStackScreenProps @@ -71,8 +71,9 @@ function WorkspaceCreateTagPage({route}: WorkspaceCreateTagPageProps) { const createTag = useCallback( (values: FormOnyxValues) => { createPolicyTag(policyData, values.tagName.trim(), setupTagsTaskReport, setupCategoriesAndTagsTaskReport, policyHasCustomCategories); - Keyboard.dismiss(); - Navigation.goBack(isQuickSettingsFlow ? ROUTES.SETTINGS_TAGS_ROOT.getRoute(policyID, backTo) : undefined); + KeyboardUtils.dismiss({ + afterTransition: () => Navigation.goBack(isQuickSettingsFlow ? ROUTES.SETTINGS_TAGS_ROOT.getRoute(policyID, backTo) : undefined), + }); }, [policyID, policyData, isQuickSettingsFlow, backTo, setupTagsTaskReport, setupCategoriesAndTagsTaskReport, policyHasCustomCategories], ); diff --git a/src/utils/keyboard/index.android.ts b/src/utils/keyboard/index.android.ts index b15d81367e30..9dd0dbfbf662 100644 --- a/src/utils/keyboard/index.android.ts +++ b/src/utils/keyboard/index.android.ts @@ -1,5 +1,7 @@ import {Keyboard} from 'react-native'; import {KeyboardEvents} from 'react-native-keyboard-controller'; +import TransitionTracker from '@libs/Navigation/TransitionTracker'; +import type {DismissKeyboardOptions} from './types'; type SimplifiedKeyboardEvent = { height?: number; @@ -21,9 +23,10 @@ const subscribeKeyboardVisibilityChange = (cb: (isVisible: boolean) => void) => }; // eslint-disable-next-line @typescript-eslint/no-unused-vars -const dismiss = (shouldSkipSafari?: boolean): Promise => { +const dismiss = (options?: DismissKeyboardOptions): Promise => { return new Promise((resolve) => { if (!isVisible) { + options?.afterTransition?.(); resolve(); return; @@ -31,10 +34,16 @@ const dismiss = (shouldSkipSafari?: boolean): Promise => { const subscription = Keyboard.addListener('keyboardDidHide', () => { resolve(); + TransitionTracker.endTransition(); subscription.remove(); }); + TransitionTracker.startTransition(); Keyboard.dismiss(); + + if (options?.afterTransition) { + TransitionTracker.runAfterTransitions({callback: options.afterTransition}); + } }); }; diff --git a/src/utils/keyboard/index.ts b/src/utils/keyboard/index.ts index 3ff8096680c6..f8d35ab4a99e 100644 --- a/src/utils/keyboard/index.ts +++ b/src/utils/keyboard/index.ts @@ -1,4 +1,6 @@ import {Keyboard} from 'react-native'; +import TransitionTracker from '@libs/Navigation/TransitionTracker'; +import type {DismissKeyboardOptions} from './types'; type SimplifiedKeyboardEvent = { height?: number; @@ -20,9 +22,10 @@ const subscribeKeyboardVisibilityChange = (cb: (isVisible: boolean) => void) => }; // eslint-disable-next-line @typescript-eslint/no-unused-vars -const dismiss = (shouldSkipSafari?: boolean): Promise => { +const dismiss = (options?: DismissKeyboardOptions): Promise => { return new Promise((resolve) => { if (!isVisible) { + options?.afterTransition?.(); resolve(); return; @@ -30,10 +33,16 @@ const dismiss = (shouldSkipSafari?: boolean): Promise => { const subscription = Keyboard.addListener('keyboardDidHide', () => { resolve(); + TransitionTracker.endTransition(); subscription.remove(); }); + TransitionTracker.startTransition(); Keyboard.dismiss(); + + if (options?.afterTransition) { + TransitionTracker.runAfterTransitions({callback: options.afterTransition}); + } }); }; diff --git a/src/utils/keyboard/index.website.ts b/src/utils/keyboard/index.website.ts index 3c40a2eced2e..50242b71b36d 100644 --- a/src/utils/keyboard/index.website.ts +++ b/src/utils/keyboard/index.website.ts @@ -1,6 +1,8 @@ import {Keyboard} from 'react-native'; import {isMobile, isMobileSafari} from '@libs/Browser'; +import TransitionTracker from '@libs/Navigation/TransitionTracker'; import CONST from '@src/CONST'; +import type {DismissKeyboardOptions} from './types'; let isVisible = false; const initialViewportHeight = window?.visualViewport?.height; @@ -34,13 +36,13 @@ const handleResize = () => { window.visualViewport?.addEventListener('resize', handleResize); -const dismiss = (shouldSkipSafari = false): Promise => { +const dismiss = (options?: DismissKeyboardOptions): Promise => { return new Promise((resolve) => { - if (shouldSkipSafari && isMobileSafari()) { - resolve(); - return; - } - if (!isVisible || !isMobile()) { + const shouldSkipSafari = options?.shouldSkipSafari && isMobileSafari(); + const shouldDismiss = !isVisible || !isMobile(); + + if (shouldDismiss || shouldSkipSafari) { + options?.afterTransition?.(); resolve(); return; } @@ -58,11 +60,18 @@ const dismiss = (shouldSkipSafari = false): Promise => { } window.visualViewport?.removeEventListener('resize', handleDismissResize); + TransitionTracker.endTransition(); return resolve(); }; window.visualViewport?.addEventListener('resize', handleDismissResize); + + TransitionTracker.startTransition(); Keyboard.dismiss(); + + if (options?.afterTransition) { + TransitionTracker.runAfterTransitions({callback: options.afterTransition}); + } }); }; diff --git a/src/utils/keyboard/types.ts b/src/utils/keyboard/types.ts new file mode 100644 index 000000000000..374054755852 --- /dev/null +++ b/src/utils/keyboard/types.ts @@ -0,0 +1,7 @@ +type DismissKeyboardOptions = { + shouldSkipSafari?: boolean; + afterTransition?: () => void; +}; + +// eslint-disable-next-line import/prefer-default-export +export type {DismissKeyboardOptions}; diff --git a/tests/unit/Navigation/TransitionTrackerTest.ts b/tests/unit/Navigation/TransitionTrackerTest.ts new file mode 100644 index 000000000000..3173b6f8803e --- /dev/null +++ b/tests/unit/Navigation/TransitionTrackerTest.ts @@ -0,0 +1,108 @@ +import TransitionTracker from '@libs/Navigation/TransitionTracker'; +import CONST from '@src/CONST'; + +describe('TransitionTracker', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + function drainTransitions(): void { + jest.runAllTimers(); + } + + describe('runAfterTransitions', () => { + it('runs callback immediately when no transition is active', () => { + const callback = jest.fn(); + TransitionTracker.runAfterTransitions({callback}); + expect(callback).toHaveBeenCalledTimes(1); + drainTransitions(); + }); + + it('runs callback immediately when runImmediately is true even with active transition', () => { + TransitionTracker.startTransition(); + const callback = jest.fn(); + TransitionTracker.runAfterTransitions({callback, runImmediately: true}); + expect(callback).toHaveBeenCalledTimes(1); + TransitionTracker.endTransition(); + drainTransitions(); + }); + + it('queues callback when transition is active and runs it after endTransition', () => { + const callback = jest.fn(); + TransitionTracker.startTransition(); + TransitionTracker.runAfterTransitions({callback}); + expect(callback).not.toHaveBeenCalled(); + TransitionTracker.endTransition(); + expect(callback).toHaveBeenCalledTimes(1); + drainTransitions(); + }); + + it('runs queued callbacks only when all overlapping transitions end', () => { + const callback = jest.fn(); + TransitionTracker.startTransition(); + TransitionTracker.startTransition(); + TransitionTracker.runAfterTransitions({callback}); + TransitionTracker.endTransition(); + expect(callback).not.toHaveBeenCalled(); + TransitionTracker.endTransition(); + expect(callback).toHaveBeenCalledTimes(1); + drainTransitions(); + }); + + it('cancel prevents queued callback from running', () => { + const callback = jest.fn(); + TransitionTracker.startTransition(); + const handle = TransitionTracker.runAfterTransitions({callback}); + handle.cancel(); + TransitionTracker.endTransition(); + expect(callback).not.toHaveBeenCalled(); + drainTransitions(); + }); + + it('safety timeout flushes callbacks when endTransition is never called', () => { + const callback = jest.fn(); + TransitionTracker.startTransition(); + TransitionTracker.runAfterTransitions({callback}); + expect(callback).not.toHaveBeenCalled(); + jest.advanceTimersByTime(CONST.MAX_TRANSITION_DURATION_MS); + expect(callback).toHaveBeenCalledTimes(1); + jest.useRealTimers(); + }); + + it('waitForUpcomingTransition queues callback after next transition starts and runs it after transition ends', async () => { + const callback = jest.fn(); + TransitionTracker.runAfterTransitions({callback, waitForUpcomingTransition: true}); + expect(callback).not.toHaveBeenCalled(); + TransitionTracker.startTransition(); + await Promise.resolve(); + expect(callback).not.toHaveBeenCalled(); + TransitionTracker.endTransition(); + expect(callback).toHaveBeenCalledTimes(1); + drainTransitions(); + }); + + it('cancel prevents waitForUpcomingTransition callback from running after transition starts', () => { + const callback = jest.fn(); + const handle = TransitionTracker.runAfterTransitions({callback, waitForUpcomingTransition: true}); + TransitionTracker.startTransition(); + handle.cancel(); + TransitionTracker.endTransition(); + expect(callback).not.toHaveBeenCalled(); + drainTransitions(); + }); + + it('cancel before transition starts prevents waitForUpcomingTransition callback from running', () => { + const callback = jest.fn(); + const handle = TransitionTracker.runAfterTransitions({callback, waitForUpcomingTransition: true}); + handle.cancel(); + TransitionTracker.startTransition(); + TransitionTracker.endTransition(); + expect(callback).not.toHaveBeenCalled(); + drainTransitions(); + }); + }); +}); diff --git a/tests/unit/keyboard/AndroidKeyboardUtilsTest.ts b/tests/unit/keyboard/AndroidKeyboardUtilsTest.ts index 0b4a8f045290..07e199298c27 100644 --- a/tests/unit/keyboard/AndroidKeyboardUtilsTest.ts +++ b/tests/unit/keyboard/AndroidKeyboardUtilsTest.ts @@ -20,6 +20,7 @@ jest.mock('react-native', () => ({ Platform: { Version: 35, }, + PixelRatio: {getFontScale: () => 1}, })); // Mock react-native-keyboard-controller diff --git a/tests/unit/keyboard/KeyboardUtilsTest.ts b/tests/unit/keyboard/KeyboardUtilsTest.ts index 9482f047b6b1..fc9af0ca0f45 100644 --- a/tests/unit/keyboard/KeyboardUtilsTest.ts +++ b/tests/unit/keyboard/KeyboardUtilsTest.ts @@ -1,4 +1,5 @@ import type {SimplifiedKeyboardEvent} from '@src/utils/keyboard'; +import type {DismissKeyboardOptions} from '@src/utils/keyboard/types'; const mockKeyboardListeners: Record void>> = {}; const mockKeyboardControllerListeners: Record void>> = {}; @@ -17,6 +18,7 @@ jest.mock('react-native', () => ({ }; }), }, + PixelRatio: {getFontScale: () => 1}, })); // Mock react-native-keyboard-controller @@ -51,7 +53,7 @@ const clearListeners = () => { describe('Keyboard utils: general native', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any - let utils: {dismiss: () => Promise; dismissKeyboardAndExecute: (cb: () => void) => Promise}; + let utils: {dismiss: (options?: DismissKeyboardOptions) => Promise; dismissKeyboardAndExecute: (cb: () => void) => Promise}; beforeEach(() => { // Clear all mocks @@ -61,7 +63,10 @@ describe('Keyboard utils: general native', () => { jest.resetModules(); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - utils = require('@src/utils/keyboard').default as {dismiss: () => Promise; dismissKeyboardAndExecute: (cb: () => void) => Promise}; + utils = require('@src/utils/keyboard').default as { + dismiss: (options?: DismissKeyboardOptions) => Promise; + dismissKeyboardAndExecute: (cb: () => void) => Promise; + }; }); describe('dismiss', () => { @@ -114,6 +119,20 @@ describe('Keyboard utils: general native', () => { await expect(Promise.all([promise1, promise2])).resolves.toEqual([undefined, undefined]); }); + + it('schedules afterTransition with TransitionTracker when keyboard is visible and runs it after keyboardDidHide', async () => { + triggerKeyboardEvent('keyboardDidShow'); + + const afterTransition = jest.fn(); + const dismissPromise = utils.dismiss({afterTransition}); + + expect(afterTransition).not.toHaveBeenCalled(); + + triggerKeyboardEvent('keyboardDidHide'); + await dismissPromise; + + expect(afterTransition).toHaveBeenCalledTimes(1); + }); }); describe('dismissKeyboardAndExecute', () => { From 6c47a591bbde18253eb1e4d93c3a87871f429d0a Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Wed, 8 Apr 2026 15:34:19 +0200 Subject: [PATCH 02/11] fix lint check --- src/pages/workspace/tags/WorkspaceCreateTagPage.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/workspace/tags/WorkspaceCreateTagPage.tsx b/src/pages/workspace/tags/WorkspaceCreateTagPage.tsx index 76b44f397bc4..100b1c42d5c1 100644 --- a/src/pages/workspace/tags/WorkspaceCreateTagPage.tsx +++ b/src/pages/workspace/tags/WorkspaceCreateTagPage.tsx @@ -25,7 +25,6 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/WorkspaceTagForm'; -import KeyboardUtils from '@src/utils/keyboard'; type WorkspaceCreateTagPageProps = | PlatformStackScreenProps From 5d532f39050f7c98aea5a969e3ffb24ab4a4cc11 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Wed, 8 Apr 2026 15:47:59 +0200 Subject: [PATCH 03/11] add timeout guard to runAfterTransitions (waitForUpcomingTransition) --- src/CONST/index.ts | 1 + src/libs/Navigation/TransitionTracker.ts | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 2d2ef4bd4b9c..7731dddcd1d3 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -235,6 +235,7 @@ const CONST = { ANIMATION_IN_TIMING: 100, COMPOSER_FOCUS_DELAY: 150, MAX_TRANSITION_DURATION_MS: 1000, + MAX_TRANSITION_START_WAIT_MS: 500, ANIMATION_DIRECTION: { IN: 'in', OUT: 'out', diff --git a/src/libs/Navigation/TransitionTracker.ts b/src/libs/Navigation/TransitionTracker.ts index 53995a1bf376..a1c611607e10 100644 --- a/src/libs/Navigation/TransitionTracker.ts +++ b/src/libs/Navigation/TransitionTracker.ts @@ -106,8 +106,14 @@ function runAfterTransitions({callback, runImmediately = false, waitForUpcomingT let cancelled = false; let innerHandle: CancelHandle | null = null; + // Guard against transitionStart never arriving. + // If no transition starts within the timeout, we proceed as if there is none. + const transitionStartTimeout = new Promise((resolve) => { + setTimeout(resolve, CONST.MAX_TRANSITION_START_WAIT_MS); + }); + (async () => { - await promiseForNextTransitionStart; + await Promise.race([promiseForNextTransitionStart, transitionStartTimeout]); if (!cancelled) { innerHandle = runAfterTransitions({callback}); } From 6624e384a9f347fced1aca0614d645cd93bbe593 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Wed, 8 Apr 2026 16:01:55 +0200 Subject: [PATCH 04/11] add and fix tests --- tests/unit/Navigation/TransitionTrackerTest.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/unit/Navigation/TransitionTrackerTest.ts b/tests/unit/Navigation/TransitionTrackerTest.ts index 3173b6f8803e..6c18368fb85f 100644 --- a/tests/unit/Navigation/TransitionTrackerTest.ts +++ b/tests/unit/Navigation/TransitionTrackerTest.ts @@ -78,6 +78,8 @@ describe('TransitionTracker', () => { TransitionTracker.runAfterTransitions({callback, waitForUpcomingTransition: true}); expect(callback).not.toHaveBeenCalled(); TransitionTracker.startTransition(); + // Two ticks: one for promiseForNextTransitionStart, one for Promise.race wrapper + await Promise.resolve(); await Promise.resolve(); expect(callback).not.toHaveBeenCalled(); TransitionTracker.endTransition(); @@ -85,6 +87,17 @@ describe('TransitionTracker', () => { drainTransitions(); }); + it('waitForUpcomingTransition fires callback after timeout if transitionStart never arrives', async () => { + const callback = jest.fn(); + TransitionTracker.runAfterTransitions({callback, waitForUpcomingTransition: true}); + expect(callback).not.toHaveBeenCalled(); + jest.advanceTimersByTime(CONST.MAX_TRANSITION_START_WAIT_MS); + await Promise.resolve(); + await Promise.resolve(); + expect(callback).toHaveBeenCalledTimes(1); + drainTransitions(); + }); + it('cancel prevents waitForUpcomingTransition callback from running after transition starts', () => { const callback = jest.fn(); const handle = TransitionTracker.runAfterTransitions({callback, waitForUpcomingTransition: true}); From 93876b376589f72f4aa4b825582ad330f18fdd77 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Thu, 9 Apr 2026 11:40:15 +0200 Subject: [PATCH 05/11] add timeout cancel after promise race --- src/CONST/index.ts | 2 +- src/libs/Navigation/TransitionTracker.ts | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 7731dddcd1d3..577df46e3302 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -235,7 +235,7 @@ const CONST = { ANIMATION_IN_TIMING: 100, COMPOSER_FOCUS_DELAY: 150, MAX_TRANSITION_DURATION_MS: 1000, - MAX_TRANSITION_START_WAIT_MS: 500, + MAX_TRANSITION_START_WAIT_MS: 1000, ANIMATION_DIRECTION: { IN: 'in', OUT: 'out', diff --git a/src/libs/Navigation/TransitionTracker.ts b/src/libs/Navigation/TransitionTracker.ts index a1c611607e10..47634672e670 100644 --- a/src/libs/Navigation/TransitionTracker.ts +++ b/src/libs/Navigation/TransitionTracker.ts @@ -107,13 +107,18 @@ function runAfterTransitions({callback, runImmediately = false, waitForUpcomingT let innerHandle: CancelHandle | null = null; // Guard against transitionStart never arriving. - // If no transition starts within the timeout, we proceed as if there is none. + // We race promiseForNextTransitionStart against a fallback timeout. + // Whichever resolves first wins. + // Afterwards we clearTimeout so the fallback doesn't keep the timer alive unnecessarily. + let transitionStartTimeoutId!: ReturnType; const transitionStartTimeout = new Promise((resolve) => { - setTimeout(resolve, CONST.MAX_TRANSITION_START_WAIT_MS); + transitionStartTimeoutId = setTimeout(resolve, CONST.MAX_TRANSITION_START_WAIT_MS); }); (async () => { await Promise.race([promiseForNextTransitionStart, transitionStartTimeout]); + clearTimeout(transitionStartTimeoutId); + if (!cancelled) { innerHandle = runAfterTransitions({callback}); } From 629a571beb565798ca386991c6cdef8fbcc8e49a Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Thu, 9 Apr 2026 13:01:06 +0200 Subject: [PATCH 06/11] add react-native-screens patch to fix screen lifecycle events being dismissed in HybridApp --- patches/react-native-screens/details.md | 8 ++++++++ ...+001+fix-lifecycle-events-in-fragment-host.patch | 13 +++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 patches/react-native-screens/details.md create mode 100644 patches/react-native-screens/react-native-screens+4.15.4+001+fix-lifecycle-events-in-fragment-host.patch diff --git a/patches/react-native-screens/details.md b/patches/react-native-screens/details.md new file mode 100644 index 000000000000..e9986d919fec --- /dev/null +++ b/patches/react-native-screens/details.md @@ -0,0 +1,8 @@ +# `react-native-screens` patches + +### [react-native-screens+4.15.4+001+fix-lifecycle-events-in-fragment-host.patch](react-native-screens+4.15.4+001+fix-lifecycle-events-in-fragment-host.patch) + +- Reason: In HybridApp, React Native is hosted inside a `ReactNativeFragment`, which causes `ScreenFragment.dispatchViewAnimationEvent()` to silently dismiss lifecycle events for root screen fragments. This prevents `transitionStart`/`transitionEnd` from being emitted, which breaks `TransitionTracker` (`src/libs/Navigation/TransitionTracker.ts`). The fix allows event dispatch when the parent fragment is not a `ScreenFragment`. +- Upstream PR/issue: https://github.com/software-mansion/react-native-screens/pull/3854 — once merged and released, bump the version and remove this patch. +- E/App issue: 🛑 +- PR Introducing Patch: https://github.com/Expensify/App/pull/85759 diff --git a/patches/react-native-screens/react-native-screens+4.15.4+001+fix-lifecycle-events-in-fragment-host.patch b/patches/react-native-screens/react-native-screens+4.15.4+001+fix-lifecycle-events-in-fragment-host.patch new file mode 100644 index 000000000000..7060a0baaa35 --- /dev/null +++ b/patches/react-native-screens/react-native-screens+4.15.4+001+fix-lifecycle-events-in-fragment-host.patch @@ -0,0 +1,13 @@ +diff --git a/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/ScreenFragment.kt b/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/ScreenFragment.kt +index 65c6e30..3b9f2e2 100644 +--- a/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/ScreenFragment.kt ++++ b/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/ScreenFragment.kt +@@ -293,7 +293,7 @@ open class ScreenFragment : + // check for `isTransitioning` should be enough since the child's animation should take only + // 20ms due to always being `StackAnimation.NONE` when nested stack being pushed + val parent = parentFragment +- if (parent == null || (parent is ScreenFragment && !parent.isTransitioning)) { ++ if (parent == null || parent !is ScreenFragment || (parent is ScreenFragment && !parent.isTransitioning)) { + // onViewAnimationStart/End is triggered from View#onAnimationStart/End method of the fragment's root + // view. We override an appropriate method of the StackFragment's + // root view in order to achieve this. From 591d811e62421015f703083bedef437333d9e1f7 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Mon, 13 Apr 2026 14:08:05 +0200 Subject: [PATCH 07/11] add useEffect because of other PR using it --- .../AppNavigator/Navigators/RightModalNavigator.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx index 5f8c3512f421..df999d791ae5 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx @@ -1,8 +1,8 @@ import type {NavigatorScreenParams} from '@react-navigation/native'; import {useFocusEffect} from '@react-navigation/native'; -import React, {useCallback, useMemo, useRef} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef} from 'react'; // eslint-disable-next-line no-restricted-imports -import {Animated, InteractionManager} from 'react-native'; +import {Animated, DeviceEventEmitter, InteractionManager} from 'react-native'; import {DialogLabelProvider} from '@components/DialogLabelContext'; import NoDropZone from '@components/DragAndDrop/NoDropZone'; import {MultifactorAuthenticationContextProviders} from '@components/MultifactorAuthentication/Context'; From e191958065ab94d361ade5a28becd2c903374f4d Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Mon, 13 Apr 2026 14:24:00 +0200 Subject: [PATCH 08/11] fix eslint and ts --- src/libs/Navigation/Navigation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/Navigation/Navigation.ts b/src/libs/Navigation/Navigation.ts index 4a27f6b5d396..764ef5c14875 100644 --- a/src/libs/Navigation/Navigation.ts +++ b/src/libs/Navigation/Navigation.ts @@ -5,7 +5,7 @@ import {Str} from 'expensify-common'; // eslint-disable-next-line you-dont-need-lodash-underscore/omit import omit from 'lodash/omit'; import {nanoid} from 'nanoid/non-secure'; -import {Dimensions} from 'react-native'; +import {DeviceEventEmitter, Dimensions} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {Writable} from 'type-fest'; From d17a8824ede0253c4da23826977398e36ca01b7b Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Tue, 14 Apr 2026 13:56:38 +0200 Subject: [PATCH 09/11] Update TransitionTracker to handle callback errors and adjust keyboard dismiss logic to start transition before subscribing to listeners --- src/libs/Navigation/TransitionTracker.ts | 8 +++++++- src/utils/keyboard/index.android.ts | 3 +-- src/utils/keyboard/index.ts | 3 +-- src/utils/keyboard/index.website.ts | 3 +-- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/libs/Navigation/TransitionTracker.ts b/src/libs/Navigation/TransitionTracker.ts index 47634672e670..f106b4caa201 100644 --- a/src/libs/Navigation/TransitionTracker.ts +++ b/src/libs/Navigation/TransitionTracker.ts @@ -1,3 +1,4 @@ +import Log from '@libs/Log'; import CONST from '@src/CONST'; type CancelHandle = {cancel: () => void}; @@ -28,12 +29,17 @@ let promiseForNextTransitionStart = new Promise((resolve) => { /** * Invokes and removes all pending callbacks. + * Each callback is isolated so that one exception does not prevent the rest from running. */ function flushCallbacks(): void { const callbacks = pendingCallbacks; pendingCallbacks = []; for (const callback of callbacks) { - callback(); + try { + callback(); + } catch (error) { + Log.warn('[TransitionTracker] A pending callback threw an error', {error}); + } } } diff --git a/src/utils/keyboard/index.android.ts b/src/utils/keyboard/index.android.ts index 9dd0dbfbf662..77352174f428 100644 --- a/src/utils/keyboard/index.android.ts +++ b/src/utils/keyboard/index.android.ts @@ -32,13 +32,12 @@ const dismiss = (options?: DismissKeyboardOptions): Promise => { return; } + TransitionTracker.startTransition(); const subscription = Keyboard.addListener('keyboardDidHide', () => { resolve(); TransitionTracker.endTransition(); subscription.remove(); }); - - TransitionTracker.startTransition(); Keyboard.dismiss(); if (options?.afterTransition) { diff --git a/src/utils/keyboard/index.ts b/src/utils/keyboard/index.ts index f8d35ab4a99e..e9cfdce8a2fd 100644 --- a/src/utils/keyboard/index.ts +++ b/src/utils/keyboard/index.ts @@ -31,13 +31,12 @@ const dismiss = (options?: DismissKeyboardOptions): Promise => { return; } + TransitionTracker.startTransition(); const subscription = Keyboard.addListener('keyboardDidHide', () => { resolve(); TransitionTracker.endTransition(); subscription.remove(); }); - - TransitionTracker.startTransition(); Keyboard.dismiss(); if (options?.afterTransition) { diff --git a/src/utils/keyboard/index.website.ts b/src/utils/keyboard/index.website.ts index cef64dbbae7f..1bbcab61d688 100644 --- a/src/utils/keyboard/index.website.ts +++ b/src/utils/keyboard/index.website.ts @@ -64,9 +64,8 @@ const dismiss = (options?: DismissKeyboardOptions): Promise => { return resolve(); }; - window.visualViewport?.addEventListener('resize', handleDismissResize); - TransitionTracker.startTransition(); + window.visualViewport?.addEventListener('resize', handleDismissResize); Keyboard.dismiss(); if (options?.afterTransition) { From 6ec2e4e60e080d5d6af84d829094bf2367c01164 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Tue, 14 Apr 2026 14:24:22 +0200 Subject: [PATCH 10/11] fix tests --- tests/unit/keyboard/AndroidKeyboardUtilsTest.ts | 4 ++++ tests/unit/keyboard/KeyboardUtilsTest.ts | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/tests/unit/keyboard/AndroidKeyboardUtilsTest.ts b/tests/unit/keyboard/AndroidKeyboardUtilsTest.ts index 07e199298c27..78c6d492c543 100644 --- a/tests/unit/keyboard/AndroidKeyboardUtilsTest.ts +++ b/tests/unit/keyboard/AndroidKeyboardUtilsTest.ts @@ -4,6 +4,10 @@ const mockKeyboardListeners: Record void>> = {}; const mockDismissKeyboard = jest.fn(); +jest.mock('@libs/Log', () => ({ + warn: jest.fn(), +})); + jest.mock('react-native', () => ({ Keyboard: { dismiss: mockDismissKeyboard, diff --git a/tests/unit/keyboard/KeyboardUtilsTest.ts b/tests/unit/keyboard/KeyboardUtilsTest.ts index fc9af0ca0f45..f6b7f0126b34 100644 --- a/tests/unit/keyboard/KeyboardUtilsTest.ts +++ b/tests/unit/keyboard/KeyboardUtilsTest.ts @@ -5,6 +5,10 @@ const mockKeyboardListeners: Record void>> = {}; const mockDismissKeyboard = jest.fn(); +jest.mock('@libs/Log', () => ({ + warn: jest.fn(), +})); + jest.mock('react-native', () => ({ Keyboard: { dismiss: mockDismissKeyboard, From fec5014bada107f0a666de2996a503ea45ca4dba Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Wed, 15 Apr 2026 15:54:42 +0200 Subject: [PATCH 11/11] add handle-based transitions --- .../Modal/ReanimatedModal/index.tsx | 21 +++- .../PlatformStackNavigation/ScreenLayout.tsx | 13 ++- src/libs/Navigation/TransitionTracker.ts | 56 ++++----- src/utils/keyboard/index.android.ts | 4 +- src/utils/keyboard/index.ts | 4 +- src/utils/keyboard/index.website.ts | 5 +- .../unit/Navigation/TransitionTrackerTest.ts | 108 ++++++++++++++---- 7 files changed, 148 insertions(+), 63 deletions(-) diff --git a/src/components/Modal/ReanimatedModal/index.tsx b/src/components/Modal/ReanimatedModal/index.tsx index 8458f38c316b..cbd701225e90 100644 --- a/src/components/Modal/ReanimatedModal/index.tsx +++ b/src/components/Modal/ReanimatedModal/index.tsx @@ -10,6 +10,7 @@ import useWindowDimensions from '@hooks/useWindowDimensions'; import blurActiveElement from '@libs/Accessibility/blurActiveElement'; import getPlatform from '@libs/getPlatform'; import TransitionTracker from '@libs/Navigation/TransitionTracker'; +import type {TransitionHandle} from '@libs/Navigation/TransitionTracker'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import Backdrop from './Backdrop'; @@ -58,6 +59,7 @@ function ReanimatedModal({ const backHandlerListener = useRef(null); const handleRef = useRef(undefined); + const transitionHandleRef = useRef(null); const styles = useThemeStyles(); @@ -104,7 +106,10 @@ function ReanimatedModal({ // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.clearInteractionHandle(handleRef.current); } - TransitionTracker.endTransition(); + if (transitionHandleRef.current) { + TransitionTracker.endTransition(transitionHandleRef.current); + transitionHandleRef.current = null; + } setIsVisibleState(false); setIsContainerOpen(false); @@ -117,7 +122,7 @@ function ReanimatedModal({ if (isVisible && !isContainerOpen && !isTransitioning) { // eslint-disable-next-line @typescript-eslint/no-deprecated handleRef.current = InteractionManager.createInteractionHandle(); - TransitionTracker.startTransition(); + transitionHandleRef.current = TransitionTracker.startTransition(); onModalWillShow(); setIsVisibleState(true); @@ -125,7 +130,7 @@ function ReanimatedModal({ } else if (!isVisible && isContainerOpen && !isTransitioning) { // eslint-disable-next-line @typescript-eslint/no-deprecated handleRef.current = InteractionManager.createInteractionHandle(); - TransitionTracker.startTransition(); + transitionHandleRef.current = TransitionTracker.startTransition(); onModalWillHide(); blurActiveElement(); @@ -146,7 +151,10 @@ function ReanimatedModal({ // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.clearInteractionHandle(handleRef.current); } - TransitionTracker.endTransition(); + if (transitionHandleRef.current) { + TransitionTracker.endTransition(transitionHandleRef.current); + transitionHandleRef.current = null; + } onModalShow(); }, [onModalShow]); @@ -157,7 +165,10 @@ function ReanimatedModal({ // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.clearInteractionHandle(handleRef.current); } - TransitionTracker.endTransition(); + if (transitionHandleRef.current) { + TransitionTracker.endTransition(transitionHandleRef.current); + transitionHandleRef.current = null; + } // Because on Android, the Modal's onDismiss callback does not work reliably. There's a reported issue at: // https://stackoverflow.com/questions/58937956/react-native-modal-ondismiss-not-invoked diff --git a/src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx b/src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx index 4fd130261efb..5ee2e3abe344 100644 --- a/src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx +++ b/src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx @@ -1,6 +1,7 @@ import type {ParamListBase, ScreenLayoutArgs} from '@react-navigation/native'; -import React, {useLayoutEffect} from 'react'; +import React, {useLayoutEffect, useRef} from 'react'; import TransitionTracker from '@libs/Navigation/TransitionTracker'; +import type {TransitionHandle} from '@libs/Navigation/TransitionTracker'; import type {PlatformSpecificNavigationOptions, PlatformStackNavigationOptions, PlatformStackNavigationProp} from './types'; // screenLayout is invoked as a render function (not JSX), so we need this wrapper to create a proper React component boundary for hooks. @@ -19,12 +20,18 @@ function ScreenLayout({ children, navigation, }: ScreenLayoutArgs>) { + const transitionHandleRef = useRef(null); + useLayoutEffect(() => { const transitionStartListener = navigation.addListener('transitionStart', () => { - TransitionTracker.startTransition(); + transitionHandleRef.current = TransitionTracker.startTransition(); }); const transitionEndListener = navigation.addListener('transitionEnd', () => { - TransitionTracker.endTransition(); + if (!transitionHandleRef.current) { + return; + } + TransitionTracker.endTransition(transitionHandleRef.current); + transitionHandleRef.current = null; }); return () => { diff --git a/src/libs/Navigation/TransitionTracker.ts b/src/libs/Navigation/TransitionTracker.ts index f106b4caa201..e5bd76bc746e 100644 --- a/src/libs/Navigation/TransitionTracker.ts +++ b/src/libs/Navigation/TransitionTracker.ts @@ -1,6 +1,8 @@ import Log from '@libs/Log'; import CONST from '@src/CONST'; +type TransitionHandle = symbol; + type CancelHandle = {cancel: () => void}; type RunAfterTransitionsOptions = { @@ -16,9 +18,7 @@ type RunAfterTransitionsOptions = { waitForUpcomingTransition?: boolean; }; -let activeCount = 0; - -const activeTimeouts: Array> = []; +const activeTransitions = new Map>(); let pendingCallbacks: Array<() => void> = []; @@ -44,24 +44,23 @@ function flushCallbacks(): void { } /** - * Decrements the active count and flushes callbacks when all transitions are idle. + * Flushes callbacks when all transitions are idle. * Shared by {@link endTransition} (manual) and the auto-timeout. */ function decrementAndFlush(): void { - activeCount = Math.max(0, activeCount - 1); - - if (activeCount === 0) { - flushCallbacks(); + if (activeTransitions.size !== 0) { + return; } + flushCallbacks(); } /** - * Increments the active transition count. - * Multiple overlapping transitions are counted. - * Each transition automatically ends after {@link MAX_TRANSITION_DURATION_MS} as a safety net. + * Increments the active transition count and returns a handle that must be passed to {@link endTransition}. + * Multiple overlapping transitions are tracked independently. + * Each transition automatically ends after {@link CONST.MAX_TRANSITION_DURATION_MS} as a safety net. */ -function startTransition(): void { - activeCount += 1; +function startTransition(): TransitionHandle { + const handle: TransitionHandle = Symbol('transition'); const resolve = nextTransitionStartResolve; if (resolve) { @@ -73,27 +72,29 @@ function startTransition(): void { } const timeout = setTimeout(() => { - const idx = activeTimeouts.indexOf(timeout); - if (idx !== -1) { - activeTimeouts.splice(idx, 1); - } + activeTransitions.delete(handle); decrementAndFlush(); }, CONST.MAX_TRANSITION_DURATION_MS); - activeTimeouts.push(timeout); + activeTransitions.set(handle, timeout); + + return handle; } /** - * Decrements the active transition count. - * Clears the corresponding auto-timeout since the transition ended normally. - * When the count reaches zero, flushes all pending callbacks. + * Ends the transition identified by {@link handle}. + * Clears the corresponding safety timeout since the transition ended normally. + * When no active transitions remain, flushes all pending callbacks. + * If the handle is unknown (already ended or already expired via safety timeout), this is a no-op. */ -function endTransition(): void { - const timeout = activeTimeouts.shift(); - if (timeout !== undefined) { - clearTimeout(timeout); +function endTransition(handle: TransitionHandle): void { + const timeout = activeTransitions.get(handle); + if (timeout === undefined) { + return; } + clearTimeout(timeout); + activeTransitions.delete(handle); decrementAndFlush(); } @@ -133,12 +134,13 @@ function runAfterTransitions({callback, runImmediately = false, waitForUpcomingT return { cancel: () => { cancelled = true; + clearTimeout(transitionStartTimeoutId); innerHandle?.cancel(); }, }; } - if (activeCount === 0 || runImmediately) { + if (activeTransitions.size === 0 || runImmediately) { callback(); return {cancel: () => {}}; } @@ -162,4 +164,4 @@ const TransitionTracker = { }; export default TransitionTracker; -export type {CancelHandle}; +export type {CancelHandle, TransitionHandle}; diff --git a/src/utils/keyboard/index.android.ts b/src/utils/keyboard/index.android.ts index 77352174f428..c0e470b4f2ce 100644 --- a/src/utils/keyboard/index.android.ts +++ b/src/utils/keyboard/index.android.ts @@ -32,10 +32,10 @@ const dismiss = (options?: DismissKeyboardOptions): Promise => { return; } - TransitionTracker.startTransition(); + const transitionHandle = TransitionTracker.startTransition(); const subscription = Keyboard.addListener('keyboardDidHide', () => { resolve(); - TransitionTracker.endTransition(); + TransitionTracker.endTransition(transitionHandle); subscription.remove(); }); Keyboard.dismiss(); diff --git a/src/utils/keyboard/index.ts b/src/utils/keyboard/index.ts index e9cfdce8a2fd..83243968af9c 100644 --- a/src/utils/keyboard/index.ts +++ b/src/utils/keyboard/index.ts @@ -31,10 +31,10 @@ const dismiss = (options?: DismissKeyboardOptions): Promise => { return; } - TransitionTracker.startTransition(); + const transitionHandle = TransitionTracker.startTransition(); const subscription = Keyboard.addListener('keyboardDidHide', () => { resolve(); - TransitionTracker.endTransition(); + TransitionTracker.endTransition(transitionHandle); subscription.remove(); }); Keyboard.dismiss(); diff --git a/src/utils/keyboard/index.website.ts b/src/utils/keyboard/index.website.ts index 1bbcab61d688..ecf12f7f2610 100644 --- a/src/utils/keyboard/index.website.ts +++ b/src/utils/keyboard/index.website.ts @@ -47,6 +47,8 @@ const dismiss = (options?: DismissKeyboardOptions): Promise => { return; } + const transitionHandle = TransitionTracker.startTransition(); + const handleDismissResize = () => { const viewportHeight = window?.visualViewport?.height; @@ -60,11 +62,10 @@ const dismiss = (options?: DismissKeyboardOptions): Promise => { } window.visualViewport?.removeEventListener('resize', handleDismissResize); - TransitionTracker.endTransition(); + TransitionTracker.endTransition(transitionHandle); return resolve(); }; - TransitionTracker.startTransition(); window.visualViewport?.addEventListener('resize', handleDismissResize); Keyboard.dismiss(); diff --git a/tests/unit/Navigation/TransitionTrackerTest.ts b/tests/unit/Navigation/TransitionTrackerTest.ts index 6c18368fb85f..d7a7ae9cfbc2 100644 --- a/tests/unit/Navigation/TransitionTrackerTest.ts +++ b/tests/unit/Navigation/TransitionTrackerTest.ts @@ -23,42 +23,42 @@ describe('TransitionTracker', () => { }); it('runs callback immediately when runImmediately is true even with active transition', () => { - TransitionTracker.startTransition(); + const handle = TransitionTracker.startTransition(); const callback = jest.fn(); TransitionTracker.runAfterTransitions({callback, runImmediately: true}); expect(callback).toHaveBeenCalledTimes(1); - TransitionTracker.endTransition(); + TransitionTracker.endTransition(handle); drainTransitions(); }); it('queues callback when transition is active and runs it after endTransition', () => { const callback = jest.fn(); - TransitionTracker.startTransition(); + const handle = TransitionTracker.startTransition(); TransitionTracker.runAfterTransitions({callback}); expect(callback).not.toHaveBeenCalled(); - TransitionTracker.endTransition(); + TransitionTracker.endTransition(handle); expect(callback).toHaveBeenCalledTimes(1); drainTransitions(); }); it('runs queued callbacks only when all overlapping transitions end', () => { const callback = jest.fn(); - TransitionTracker.startTransition(); - TransitionTracker.startTransition(); + const handleA = TransitionTracker.startTransition(); + const handleB = TransitionTracker.startTransition(); TransitionTracker.runAfterTransitions({callback}); - TransitionTracker.endTransition(); + TransitionTracker.endTransition(handleA); expect(callback).not.toHaveBeenCalled(); - TransitionTracker.endTransition(); + TransitionTracker.endTransition(handleB); expect(callback).toHaveBeenCalledTimes(1); drainTransitions(); }); it('cancel prevents queued callback from running', () => { const callback = jest.fn(); - TransitionTracker.startTransition(); - const handle = TransitionTracker.runAfterTransitions({callback}); - handle.cancel(); - TransitionTracker.endTransition(); + const transitionHandle = TransitionTracker.startTransition(); + const cancelHandle = TransitionTracker.runAfterTransitions({callback}); + cancelHandle.cancel(); + TransitionTracker.endTransition(transitionHandle); expect(callback).not.toHaveBeenCalled(); drainTransitions(); }); @@ -77,12 +77,12 @@ describe('TransitionTracker', () => { const callback = jest.fn(); TransitionTracker.runAfterTransitions({callback, waitForUpcomingTransition: true}); expect(callback).not.toHaveBeenCalled(); - TransitionTracker.startTransition(); + const handle = TransitionTracker.startTransition(); // Two ticks: one for promiseForNextTransitionStart, one for Promise.race wrapper await Promise.resolve(); await Promise.resolve(); expect(callback).not.toHaveBeenCalled(); - TransitionTracker.endTransition(); + TransitionTracker.endTransition(handle); expect(callback).toHaveBeenCalledTimes(1); drainTransitions(); }); @@ -100,21 +100,85 @@ describe('TransitionTracker', () => { it('cancel prevents waitForUpcomingTransition callback from running after transition starts', () => { const callback = jest.fn(); - const handle = TransitionTracker.runAfterTransitions({callback, waitForUpcomingTransition: true}); - TransitionTracker.startTransition(); - handle.cancel(); - TransitionTracker.endTransition(); + const cancelHandle = TransitionTracker.runAfterTransitions({callback, waitForUpcomingTransition: true}); + const transitionHandle = TransitionTracker.startTransition(); + cancelHandle.cancel(); + TransitionTracker.endTransition(transitionHandle); expect(callback).not.toHaveBeenCalled(); drainTransitions(); }); it('cancel before transition starts prevents waitForUpcomingTransition callback from running', () => { const callback = jest.fn(); - const handle = TransitionTracker.runAfterTransitions({callback, waitForUpcomingTransition: true}); - handle.cancel(); - TransitionTracker.startTransition(); - TransitionTracker.endTransition(); + const cancelHandle = TransitionTracker.runAfterTransitions({callback, waitForUpcomingTransition: true}); + cancelHandle.cancel(); + const transitionHandle = TransitionTracker.startTransition(); + TransitionTracker.endTransition(transitionHandle); + expect(callback).not.toHaveBeenCalled(); + drainTransitions(); + }); + }); + + describe('handle-based pairing', () => { + it('out-of-order end: transitions ended in reverse order still flush correctly', () => { + const callback = jest.fn(); + const handleA = TransitionTracker.startTransition(); + const handleB = TransitionTracker.startTransition(); + TransitionTracker.runAfterTransitions({callback}); + TransitionTracker.endTransition(handleB); + expect(callback).not.toHaveBeenCalled(); + TransitionTracker.endTransition(handleA); + expect(callback).toHaveBeenCalledTimes(1); + drainTransitions(); + }); + + it('double-end with same handle is a no-op and does not corrupt the count', () => { + const callback = jest.fn(); + const handleA = TransitionTracker.startTransition(); + const handleB = TransitionTracker.startTransition(); + TransitionTracker.runAfterTransitions({callback}); + + TransitionTracker.endTransition(handleA); + TransitionTracker.endTransition(handleA); expect(callback).not.toHaveBeenCalled(); + + TransitionTracker.endTransition(handleB); + expect(callback).toHaveBeenCalledTimes(1); + drainTransitions(); + }); + + it('safety timeout fires then manual endTransition is a no-op — no double-decrement', () => { + const callback = jest.fn(); + const handle = TransitionTracker.startTransition(); + TransitionTracker.runAfterTransitions({callback}); + + jest.advanceTimersByTime(CONST.MAX_TRANSITION_DURATION_MS); + expect(callback).toHaveBeenCalledTimes(1); + + TransitionTracker.endTransition(handle); + expect(callback).toHaveBeenCalledTimes(1); + + const laterCallback = jest.fn(); + const laterHandle = TransitionTracker.startTransition(); + TransitionTracker.runAfterTransitions({callback: laterCallback}); + expect(laterCallback).not.toHaveBeenCalled(); + TransitionTracker.endTransition(laterHandle); + expect(laterCallback).toHaveBeenCalledTimes(1); + drainTransitions(); + }); + + it('exception in one callback does not prevent subsequent callbacks from running', () => { + const handle = TransitionTracker.startTransition(); + const callbackA = jest.fn(() => { + throw new Error('boom'); + }); + const callbackB = jest.fn(); + TransitionTracker.runAfterTransitions({callback: callbackA}); + TransitionTracker.runAfterTransitions({callback: callbackB}); + + TransitionTracker.endTransition(handle); + expect(callbackA).toHaveBeenCalledTimes(1); + expect(callbackB).toHaveBeenCalledTimes(1); drainTransitions(); }); });