Skip to content

Commit c4d00e1

Browse files
authored
Merge pull request Expensify#83899 from software-mansion-labs/collectioneur/transition-tracker-v2
Migration navigation from InteractionManager to TransitionTracker V2
2 parents fd3c052 + 85da06c commit c4d00e1

25 files changed

Lines changed: 530 additions & 88 deletions

File tree

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# InteractionManager Migration
2+
3+
## Why
4+
5+
`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.
6+
7+
Rather than keep patching, we're replacing `InteractionManager.runAfterInteractions` with purpose-built alternatives that are more precise.
8+
9+
## Current state
10+
11+
`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.
12+
13+
## The problem
14+
15+
`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.
16+
17+
This makes the migration non-trivial: you have to understand *what each call is actually waiting for* before you can pick the right replacement.
18+
19+
## The approach
20+
21+
**TransitionTracker** is the backbone. It tracks navigation transitions explicitly, so other APIs can hook into transition lifecycle without relying on a global queue.
22+
23+
On top of TransitionTracker, existing APIs gain transition-aware callbacks:
24+
25+
- Navigation methods accept `afterTransition` — a callback that runs after the triggered navigation transition completes
26+
- Navigation methods accept `waitForTransition` — the call waits for all ongoing transitions to finish before navigating
27+
- Keyboard methods accept `afterTransition` — a callback that runs after the keyboard transition completes
28+
- `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
29+
30+
This makes the code self-descriptive: instead of a generic `runAfterInteractions`, each call site says exactly what it's waiting for and why.
31+
32+
> **Note:** `TransitionTracker.runAfterTransitions` is an internal primitive. Application code should use the higher-level APIs (`Navigation`, `useConfirmModal`, etc.) rather than importing TransitionTracker directly.
33+
34+
## How
35+
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).
36+
37+
## Primitives comparison
38+
39+
For reference, here's how the available timing primitives compare:
40+
41+
### `requestAnimationFrame` (rAF)
42+
43+
- Fires **before the next paint** (~16ms at 60fps)
44+
- Guaranteed to run every frame if the thread isn't blocked
45+
- Use for: UI updates that need to happen on the next frame (scroll, layout measurement, enabling a button after a state flush)
46+
47+
### `requestIdleCallback`
48+
49+
- Fires when the runtime has **idle time** — no pending frames, no urgent work
50+
- May be delayed indefinitely if the main thread stays busy
51+
- Accepts a `timeout` option to force execution after a deadline
52+
- Use for: Non-urgent background work (Pusher subscriptions, search API calls, contact imports)
53+
54+
### `InteractionManager.runAfterInteractions` (legacy — do not use)
55+
56+
- React Native-specific. Fires after all **ongoing interactions** (animations, touches) complete
57+
- Tracks interactions via `createInteractionHandle()` — anything that calls `handle.done()` unblocks the queue
58+
- In practice, this means "run after the current navigation transition finishes"
59+
- Problem: it's a global queue with no granularity — you can't say "after _this specific_ transition"
60+
61+
### Summary
62+
63+
| | Timing | Granularity | Platform |
64+
| ---------------------- | ------------------------- | ------------------------- | --------------------- |
65+
| `rAF` | Next frame (~16ms) | None — just "next paint" | Web + RN |
66+
| `requestIdleCallback` | When idle (unpredictable) | None — "whenever free" | Web + RN (polyfilled) |
67+
| `runAfterInteractions` | After animations finish | Global — all interactions | RN only |

src/CONST/index.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,7 @@ const CONST = {
231231
ANIMATED_PROGRESS_BAR_DURATION: 750,
232232
ANIMATION_IN_TIMING: 100,
233233
COMPOSER_FOCUS_DELAY: 150,
234+
MAX_TRANSITION_DURATION_MS: 1000,
234235
ANIMATION_DIRECTION: {
235236
IN: 'in',
236237
OUT: 'out',
@@ -8459,10 +8460,6 @@ const CONST = {
84598460
ADD_EXPENSE_APPROVALS: 'addExpenseApprovals',
84608461
},
84618462

8462-
MODAL_EVENTS: {
8463-
CLOSED: 'modalClosed',
8464-
},
8465-
84668463
LIST_BEHAVIOR: {
84678464
REGULAR: 'regular',
84688465
INVERTED: 'inverted',

src/components/EmojiPicker/EmojiPicker.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ function EmojiPicker({viewportOffsetTop, ref}: EmojiPickerProps) {
116116

117117
// It's possible that the anchor is inside an active modal (e.g., add emoji reaction in report context menu).
118118
// So, we need to get the anchor position first before closing the active modal which will also destroy the anchor.
119-
KeyboardUtils.dismiss(true).then(() =>
119+
KeyboardUtils.dismiss({shouldSkipSafari: true}).then(() =>
120120
calculateAnchorPosition(emojiPopoverAnchor?.current, anchorOriginValue).then((value) => {
121121
close(() => {
122122
onWillShow?.();

src/components/Modal/BaseModal.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} fr
22
import type {LayoutChangeEvent} from 'react-native';
33
// Animated required for side panel navigation
44
// eslint-disable-next-line no-restricted-imports
5-
import {Animated, DeviceEventEmitter, View} from 'react-native';
5+
import {Animated, View} from 'react-native';
66
import ColorSchemeWrapper from '@components/ColorSchemeWrapper';
77
import NavigationBar from '@components/NavigationBar';
88
import ScreenWrapperOfflineIndicatorContext from '@components/ScreenWrapper/ScreenWrapperOfflineIndicatorContext';
@@ -167,8 +167,6 @@ function BaseModal({
167167
[],
168168
);
169169

170-
useEffect(() => () => DeviceEventEmitter.emit(CONST.MODAL_EVENTS.CLOSED), []);
171-
172170
const handleShowModal = useCallback(() => {
173171
if (shouldSetModalVisibility) {
174172
setModalVisibility(true, type);

src/components/Modal/ReanimatedModal/index.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import useThemeStyles from '@hooks/useThemeStyles';
99
import useWindowDimensions from '@hooks/useWindowDimensions';
1010
import blurActiveElement from '@libs/Accessibility/blurActiveElement';
1111
import getPlatform from '@libs/getPlatform';
12+
import TransitionTracker from '@libs/Navigation/TransitionTracker';
1213
import variables from '@styles/variables';
1314
import CONST from '@src/CONST';
1415
import Backdrop from './Backdrop';
@@ -102,6 +103,7 @@ function ReanimatedModal({
102103
// eslint-disable-next-line @typescript-eslint/no-deprecated
103104
InteractionManager.clearInteractionHandle(handleRef.current);
104105
}
106+
TransitionTracker.endTransition();
105107

106108
setIsVisibleState(false);
107109
setIsContainerOpen(false);
@@ -114,13 +116,15 @@ function ReanimatedModal({
114116
if (isVisible && !isContainerOpen && !isTransitioning) {
115117
// eslint-disable-next-line @typescript-eslint/no-deprecated
116118
handleRef.current = InteractionManager.createInteractionHandle();
119+
TransitionTracker.startTransition();
117120
onModalWillShow();
118121

119122
setIsVisibleState(true);
120123
setIsTransitioning(true);
121124
} else if (!isVisible && isContainerOpen && !isTransitioning) {
122125
// eslint-disable-next-line @typescript-eslint/no-deprecated
123126
handleRef.current = InteractionManager.createInteractionHandle();
127+
TransitionTracker.startTransition();
124128
onModalWillHide();
125129

126130
blurActiveElement();
@@ -141,6 +145,7 @@ function ReanimatedModal({
141145
// eslint-disable-next-line @typescript-eslint/no-deprecated
142146
InteractionManager.clearInteractionHandle(handleRef.current);
143147
}
148+
TransitionTracker.endTransition();
144149
onModalShow();
145150
}, [onModalShow]);
146151

@@ -151,6 +156,7 @@ function ReanimatedModal({
151156
// eslint-disable-next-line @typescript-eslint/no-deprecated
152157
InteractionManager.clearInteractionHandle(handleRef.current);
153158
}
159+
TransitionTracker.endTransition();
154160

155161
// Because on Android, the Modal's onDismiss callback does not work reliably. There's a reported issue at:
156162
// https://stackoverflow.com/questions/58937956/react-native-modal-ondismiss-not-invoked

src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import type {NavigatorScreenParams} from '@react-navigation/native';
22
import {useFocusEffect} from '@react-navigation/native';
3-
import React, {useCallback, useEffect, useMemo, useRef} from 'react';
3+
import React, {useCallback, useMemo, useRef} from 'react';
44
// eslint-disable-next-line no-restricted-imports
5-
import {Animated, DeviceEventEmitter, InteractionManager} from 'react-native';
5+
import {Animated, InteractionManager} from 'react-native';
66
import NoDropZone from '@components/DragAndDrop/NoDropZone';
77
import {MultifactorAuthenticationContextProviders} from '@components/MultifactorAuthentication/Context';
88
import {
@@ -181,8 +181,6 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) {
181181
}, [syncRHPKeys, clearWideRHPKeysAfterTabChanged]),
182182
);
183183

184-
useEffect(() => () => DeviceEventEmitter.emit(CONST.MODAL_EVENTS.CLOSED), []);
185-
186184
return (
187185
<NarrowPaneContextProvider>
188186
<MultifactorAuthenticationContextProviders>

src/libs/Navigation/Navigation.ts

Lines changed: 70 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {CommonActions, StackActions} from '@react-navigation/native';
44
import {Str} from 'expensify-common';
55
// eslint-disable-next-line you-dont-need-lodash-underscore/omit
66
import omit from 'lodash/omit';
7-
import {DeviceEventEmitter, Dimensions, InteractionManager} from 'react-native';
7+
import {Dimensions} from 'react-native';
88
import type {OnyxEntry} from 'react-native-onyx';
99
import Onyx from 'react-native-onyx';
1010
import type {Writable} from 'type-fest';
@@ -40,6 +40,7 @@ import setNavigationActionToMicrotaskQueue from './helpers/setNavigationActionTo
4040
import {linkingConfig} from './linkingConfig';
4141
import {SPLIT_TO_SIDEBAR} from './linkingConfig/RELATIONS';
4242
import navigationRef from './navigationRef';
43+
import TransitionTracker from './TransitionTracker';
4344
import type {
4445
NavigationPartialRoute,
4546
NavigationRef,
@@ -329,9 +330,18 @@ function navigate(route: Route, options?: LinkToOptions) {
329330
}
330331
}
331332

332-
const targetRoute = route.startsWith(CONST.SAML_REDIRECT_URL) ? ROUTES.HOME : route;
333-
linkTo(navigationRef.current, targetRoute, options);
334-
closeSidePanelOnNarrowScreen(route);
333+
const runImmediately = !options?.waitForTransition;
334+
TransitionTracker.runAfterTransitions({
335+
callback: () => {
336+
const targetRoute = route.startsWith(CONST.SAML_REDIRECT_URL) ? ROUTES.HOME : route;
337+
linkTo(navigationRef.current, targetRoute, options);
338+
closeSidePanelOnNarrowScreen(route);
339+
if (options?.afterTransition) {
340+
TransitionTracker.runAfterTransitions({callback: options.afterTransition, waitForUpcomingTransition: true});
341+
}
342+
},
343+
runImmediately,
344+
});
335345
}
336346
/**
337347
* When routes are compared to determine whether the fallback route passed to the goUp function is in the state,
@@ -396,10 +406,15 @@ type GoBackOptions = {
396406
* In that case we want to goUp to a country picker with any params so we don't compare them.
397407
*/
398408
compareParams?: boolean;
409+
// Callback to execute after the navigation transition animation completes.
410+
afterTransition?: () => void | undefined;
411+
// If true, waits for ongoing transitions to finish before going back. Defaults to false (goes back immediately).
412+
waitForTransition?: boolean;
399413
};
400414

401-
const defaultGoBackOptions: Required<GoBackOptions> = {
415+
const defaultGoBackOptions: Required<Pick<GoBackOptions, 'compareParams' | 'waitForTransition'>> = {
402416
compareParams: true,
417+
waitForTransition: false,
403418
};
404419

405420
/**
@@ -472,22 +487,26 @@ function goBack(backToRoute?: Route, options?: GoBackOptions) {
472487
return;
473488
}
474489

475-
if (backToRoute) {
476-
goUp(backToRoute, options);
477-
return;
478-
}
479-
480-
if (shouldPopToSidebar) {
481-
popToSidebar();
482-
return;
483-
}
484-
485-
if (!navigationRef.current?.canGoBack()) {
486-
Log.hmmm('[Navigation] Unable to go back');
487-
return;
488-
}
490+
const runImmediately = !options?.waitForTransition;
491+
TransitionTracker.runAfterTransitions({
492+
callback: () => {
493+
if (backToRoute) {
494+
goUp(backToRoute, options);
495+
} else if (shouldPopToSidebar) {
496+
popToSidebar();
497+
} else if (!navigationRef.current?.canGoBack()) {
498+
Log.hmmm('[Navigation] Unable to go back');
499+
return;
500+
} else {
501+
navigationRef.current?.goBack();
502+
}
489503

490-
navigationRef.current?.goBack();
504+
if (options?.afterTransition) {
505+
TransitionTracker.runAfterTransitions({callback: options.afterTransition, waitForUpcomingTransition: true});
506+
}
507+
},
508+
runImmediately,
509+
});
491510
}
492511

493512
/**
@@ -720,25 +739,27 @@ function getTopmostSuperWideRHPReportID(state: NavigationState = navigationRef.g
720739
*
721740
* @param options - Configuration object
722741
* @param options.ref - Navigation ref to use (defaults to navigationRef)
723-
* @param options.callback - Optional callback to execute after the modal has finished closing.
724-
* The callback fires when RightModalNavigator unmounts.
742+
* @param options.afterTransition - Optional callback to execute after the navigation transition animation completes.
725743
*
726744
* For detailed information about dismissing modals,
727745
* see the NAVIGATION.md documentation.
728746
*/
729-
const dismissModal = ({ref = navigationRef, callback}: {ref?: NavigationRef; callback?: () => void} = {}) => {
747+
function dismissModal({ref = navigationRef, afterTransition, waitForTransition}: {ref?: NavigationRef; afterTransition?: () => void; waitForTransition?: boolean} = {}) {
730748
clearSelectedTextIfComposerBlurred();
749+
const runImmediately = !waitForTransition;
731750
isNavigationReady().then(() => {
732-
if (callback) {
733-
const subscription = DeviceEventEmitter.addListener(CONST.MODAL_EVENTS.CLOSED, () => {
734-
subscription.remove();
735-
callback();
736-
});
737-
}
751+
TransitionTracker.runAfterTransitions({
752+
callback: () => {
753+
ref.dispatch({type: CONST.NAVIGATION.ACTION_TYPE.DISMISS_MODAL});
738754

739-
ref.dispatch({type: CONST.NAVIGATION.ACTION_TYPE.DISMISS_MODAL});
755+
if (afterTransition) {
756+
TransitionTracker.runAfterTransitions({callback: afterTransition, waitForUpcomingTransition: true});
757+
}
758+
},
759+
runImmediately,
760+
});
740761
});
741-
};
762+
}
742763

743764
/**
744765
* Dismisses the modal and opens the given report.
@@ -775,10 +796,11 @@ const dismissModalWithReport = (
775796
navigate(reportRoute, {forceReplace: true});
776797
return;
777798
}
778-
dismissModal();
779-
// eslint-disable-next-line @typescript-eslint/no-deprecated
780-
InteractionManager.runAfterInteractions(() => {
781-
navigate(reportRoute);
799+
800+
dismissModal({
801+
afterTransition: () => {
802+
navigate(reportRoute);
803+
},
782804
});
783805
});
784806
};
@@ -863,7 +885,7 @@ function clearPreloadedRoutes() {
863885
*
864886
* @param modalStackNames - names of the modal stacks we want to dismiss to
865887
*/
866-
function dismissToModalStack(modalStackNames: Set<string>) {
888+
function dismissToModalStack(modalStackNames: Set<string>, options: {afterTransition?: () => void} = {}) {
867889
const rootState = navigationRef.getRootState();
868890
if (!rootState) {
869891
return;
@@ -879,32 +901,36 @@ function dismissToModalStack(modalStackNames: Set<string>) {
879901
const routesToPop = rhpState.routes.length - lastFoundModalStackIndex - 1;
880902

881903
if (routesToPop <= 0 || lastFoundModalStackIndex === -1) {
882-
dismissModal();
904+
dismissModal(options);
883905
return;
884906
}
885907

886908
navigationRef.dispatch({...StackActions.pop(routesToPop), target: rhpState.key});
909+
910+
if (options?.afterTransition) {
911+
TransitionTracker.runAfterTransitions({callback: options.afterTransition, waitForUpcomingTransition: true});
912+
}
887913
}
888914

889915
/**
890916
* Dismiss top layer modal and go back to the Wide/Super Wide RHP.
891917
*/
892-
function dismissToPreviousRHP() {
893-
return dismissToModalStack(ALL_WIDE_RIGHT_MODALS);
918+
function dismissToPreviousRHP(options: {afterTransition?: () => void} = {}) {
919+
return dismissToModalStack(ALL_WIDE_RIGHT_MODALS, options);
894920
}
895921

896-
function navigateBackToLastSuperWideRHPScreen() {
897-
return dismissToModalStack(SUPER_WIDE_RIGHT_MODALS);
922+
function navigateBackToLastSuperWideRHPScreen(options: {afterTransition?: () => void} = {}) {
923+
return dismissToModalStack(SUPER_WIDE_RIGHT_MODALS, options);
898924
}
899925

900-
function dismissToSuperWideRHP() {
926+
function dismissToSuperWideRHP(options: {afterTransition?: () => void} = {}) {
901927
// On narrow layouts (mobile), Super Wide RHP doesn't exist, so just dismiss the modal completely
902928
if (getIsNarrowLayout()) {
903-
dismissModal();
929+
dismissModal(options);
904930
return;
905931
}
906932
// On wide layouts, dismiss back to the Super Wide RHP modal stack
907-
navigateBackToLastSuperWideRHPScreen();
933+
navigateBackToLastSuperWideRHPScreen(options);
908934
}
909935

910936
/**

0 commit comments

Comments
 (0)