Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions contributingGuides/INTERACTION_MANAGER.md
Original file line number Diff line number Diff line change
@@ -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 |
5 changes: 1 addition & 4 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -8466,10 +8467,6 @@ const CONST = {
ADD_EXPENSE_APPROVALS: 'addExpenseApprovals',
},

MODAL_EVENTS: {
CLOSED: 'modalClosed',
},

LIST_BEHAVIOR: {
REGULAR: 'regular',
INVERTED: 'inverted',
Expand Down
2 changes: 1 addition & 1 deletion src/components/EmojiPicker/EmojiPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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?.();
Expand Down
4 changes: 1 addition & 3 deletions src/components/Modal/BaseModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -168,8 +168,6 @@ function BaseModal({
[],
);

useEffect(() => () => DeviceEventEmitter.emit(CONST.MODAL_EVENTS.CLOSED), []);

const handleShowModal = useCallback(() => {
if (shouldSetModalVisibility) {
setModalVisibility(true, type);
Expand Down
6 changes: 6 additions & 0 deletions src/components/Modal/ReanimatedModal/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -103,6 +104,7 @@ function ReanimatedModal({
// eslint-disable-next-line @typescript-eslint/no-deprecated
InteractionManager.clearInteractionHandle(handleRef.current);
}
TransitionTracker.endTransition();

setIsVisibleState(false);
setIsContainerOpen(false);
Expand All @@ -115,13 +117,15 @@ function ReanimatedModal({
if (isVisible && !isContainerOpen && !isTransitioning) {
// eslint-disable-next-line @typescript-eslint/no-deprecated
handleRef.current = InteractionManager.createInteractionHandle();
TransitionTracker.startTransition();
onModalWillShow();

setIsVisibleState(true);
setIsTransitioning(true);
} else if (!isVisible && isContainerOpen && !isTransitioning) {
// eslint-disable-next-line @typescript-eslint/no-deprecated
handleRef.current = InteractionManager.createInteractionHandle();
TransitionTracker.startTransition();
onModalWillHide();

blurActiveElement();
Expand All @@ -142,6 +146,7 @@ function ReanimatedModal({
// eslint-disable-next-line @typescript-eslint/no-deprecated
InteractionManager.clearInteractionHandle(handleRef.current);
}
TransitionTracker.endTransition();
onModalShow();
}, [onModalShow]);

Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -181,8 +181,6 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) {
}, [syncRHPKeys, clearWideRHPKeysAfterTabChanged]),
);

useEffect(() => () => DeviceEventEmitter.emit(CONST.MODAL_EVENTS.CLOSED), []);

return (
<NarrowPaneContextProvider>
<MultifactorAuthenticationContextProviders>
Expand Down
114 changes: 70 additions & 44 deletions src/libs/Navigation/Navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<GoBackOptions> = {
const defaultGoBackOptions: Required<Pick<GoBackOptions, 'compareParams' | 'waitForTransition'>> = {
compareParams: true,
waitForTransition: false,
};

/**
Expand Down Expand Up @@ -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,
});
}

/**
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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);
},
});
});
};
Expand Down Expand Up @@ -881,7 +903,7 @@ function clearPreloadedRoutes() {
*
* @param modalStackNames - names of the modal stacks we want to dismiss to
*/
function dismissToModalStack(modalStackNames: Set<string>) {
function dismissToModalStack(modalStackNames: Set<string>, options: {afterTransition?: () => void} = {}) {
const rootState = navigationRef.getRootState();
if (!rootState) {
return;
Expand All @@ -897,32 +919,36 @@ function dismissToModalStack(modalStackNames: Set<string>) {
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);
}

/**
Expand Down
Loading
Loading