Skip to content

Commit 777c64d

Browse files
committed
Merge branch 'main' into collectioneur/dynamic-routes-suffix-layering
2 parents d1a25aa + adfcbb7 commit 777c64d

31 files changed

Lines changed: 660 additions & 90 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/components/MoneyRequestReportView/MoneyRequestReportView.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,7 @@ function MoneyRequestReportView({report, policy, reportMetadata, shouldDisplayRe
291291
report={transactionThreadReport}
292292
fillSpace
293293
isDisplayedInWideRHP
294+
hasParentPendingAction={!!reportPendingAction}
294295
/>
295296
</ScrollView>
296297
</Animated.View>

src/components/ReportActionItem/MoneyRequestReceiptView.tsx

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ type MoneyRequestReceiptViewProps = {
8686

8787
/** Whether it's displayed in Wide RHP */
8888
isDisplayedInWideRHP?: boolean;
89+
90+
/** Whether the parent component has a pending action */
91+
hasParentPendingAction?: boolean;
8992
};
9093

9194
const receiptImageViolationNames = new Set<OnyxTypes.ViolationName>([
@@ -100,7 +103,15 @@ const receiptImageViolationNames = new Set<OnyxTypes.ViolationName>([
100103

101104
const receiptFieldViolationNames = new Set<OnyxTypes.ViolationName>([CONST.VIOLATIONS.MODIFIED_AMOUNT, CONST.VIOLATIONS.MODIFIED_DATE]);
102105

103-
function MoneyRequestReceiptView({report, readonly = false, updatedTransaction, fillSpace = false, mergeTransactionID, isDisplayedInWideRHP = false}: MoneyRequestReceiptViewProps) {
106+
function MoneyRequestReceiptView({
107+
report,
108+
readonly = false,
109+
updatedTransaction,
110+
fillSpace = false,
111+
mergeTransactionID,
112+
isDisplayedInWideRHP = false,
113+
hasParentPendingAction = false,
114+
}: MoneyRequestReceiptViewProps) {
104115
const styles = useThemeStyles();
105116
const {translate} = useLocalize();
106117
const {environmentURL} = useEnvironment();
@@ -177,7 +188,16 @@ function MoneyRequestReceiptView({report, readonly = false, updatedTransaction,
177188
}
178189
const pendingAction = transaction?.pendingAction;
179190
// Need to return undefined when we have pendingAction to avoid the duplicate pending action
180-
const getPendingFieldAction = (fieldPath: TransactionPendingFieldsKey) => (pendingAction ? undefined : transaction?.pendingFields?.[fieldPath]);
191+
const getPendingFieldAction = (fieldPath: TransactionPendingFieldsKey) => {
192+
if (hasParentPendingAction) {
193+
return undefined;
194+
}
195+
if (isDisplayedInWideRHP) {
196+
return transaction?.pendingFields?.[fieldPath] ?? pendingAction;
197+
}
198+
199+
return pendingAction ? undefined : transaction?.pendingFields?.[fieldPath];
200+
};
181201

182202
const transactionToCheck = updatedTransaction ?? transaction;
183203
const doesTransactionHaveReceipt = !!transactionToCheck?.receipt && !isEmptyObject(transactionToCheck?.receipt);

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>

0 commit comments

Comments
 (0)