Skip to content

Commit d5f883d

Browse files
committed
fix: migrate auto-focus from raw InteractionManager to useAutoFocusInput
Replace the `InteractionManager.runAfterInteractions(() => ref.focus())` pattern in SplitListItem, DatePicker, and IOURequestEditReportCommon with the canonical `useAutoFocusInput` hook. The raw pattern has no cancellation mechanism, so a deferred focus callback can survive the dismissing screen and fire during the next RHP's slide-in animation, blocking frames and causing the sluggish animation reported in Expensify#87174. useAutoFocusInput wraps the same call in a useEffect whose cleanup calls focusTaskHandle.cancel(), and a useFocusEffect that resets the screen-transition-ended flag on unfocus — together these cancel any pending focus task when the screen starts closing. Aligns these three outlier files with the same pattern applied in Expensify#87070 and the 100+ existing useAutoFocusInput usages elsewhere. Fixes Expensify#87174
1 parent 3e7e8cf commit d5f883d

3 files changed

Lines changed: 36 additions & 34 deletions

File tree

src/components/DatePicker/index.tsx

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
33
import {InteractionManager, View} from 'react-native';
44
import TextInput from '@components/TextInput';
55
import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types';
6+
import useAutoFocusInput from '@hooks/useAutoFocusInput';
67
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
78
import useLocalize from '@hooks/useLocalize';
89
import useThemeStyles from '@hooks/useThemeStyles';
@@ -41,10 +42,13 @@ function DatePicker({
4142
const [isModalVisible, setIsModalVisible] = useState(false);
4243
const [selectedDate, setSelectedDate] = useState(() => value ?? defaultValue ?? '');
4344
const [popoverPosition, setPopoverPosition] = useState({horizontal: 0, vertical: 0});
44-
const textInputRef = useRef<BaseTextInputRef>(null);
45+
const textInputRef = useRef<BaseTextInputRef | null>(null);
4546
const anchorRef = useRef<View>(null);
4647
const [isInverted, setIsInverted] = useState(false);
47-
const isAutoFocused = useRef(false);
48+
49+
const {inputCallbackRef: autoFocusCallbackRef} = useAutoFocusInput();
50+
const autoFocusCallbackRefRef = useRef(autoFocusCallbackRef);
51+
autoFocusCallbackRefRef.current = autoFocusCallbackRef;
4852

4953
useEffect(() => {
5054
if (shouldSaveDraft && formID) {
@@ -103,16 +107,20 @@ function DatePicker({
103107
});
104108
}, [calculatePopoverPosition, windowWidth]);
105109

106-
useEffect(() => {
107-
if (!autoFocus || isAutoFocused.current) {
108-
return;
109-
}
110-
isAutoFocused.current = true;
111-
// eslint-disable-next-line @typescript-eslint/no-deprecated
112-
InteractionManager.runAfterInteractions(() => {
113-
textInputRef.current?.focus();
114-
});
115-
}, [autoFocus]);
110+
// Combined ref: updates textInputRef (needed for blur() in showDatePickerModal) and connects
111+
// autoFocusCallbackRef only when autoFocus=true so useAutoFocusInput's useFocusEffect cleanup
112+
// can cancel any pending focus task when the screen starts closing.
113+
const combinedTextInputRef = useCallback(
114+
(ref: BaseTextInputRef | null) => {
115+
textInputRef.current = ref;
116+
if (autoFocus) {
117+
(autoFocusCallbackRefRef.current as unknown as (ref: BaseTextInputRef | null) => void)(ref);
118+
}
119+
},
120+
// autoFocusCallbackRefRef is a stable ref — its identity never changes, so it's not a dep
121+
// eslint-disable-next-line react-hooks/exhaustive-deps
122+
[autoFocus],
123+
);
116124

117125
const getValidDateForCalendar = useMemo(() => {
118126
if (!selectedDate) {
@@ -129,7 +137,7 @@ function DatePicker({
129137
style={styles.mv2}
130138
>
131139
<TextInput
132-
ref={textInputRef}
140+
ref={combinedTextInputRef}
133141
inputID={inputID}
134142
forceActiveLabel
135143
icon={selectedDate ? null : icons.Calendar}

src/components/SelectionList/ListItem/SplitListItem.tsx

Lines changed: 10 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
1-
import React, {useCallback, useEffect, useRef, useState} from 'react';
2-
import {InteractionManager, View} from 'react-native';
1+
import React, {useCallback, useState} from 'react';
2+
import {View} from 'react-native';
33
import Icon from '@components/Icon';
44
import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
55
import type {ListItem} from '@components/SelectionList/types';
66
import Text from '@components/Text';
77
import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types';
88
import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle';
9+
import useAutoFocusInput from '@hooks/useAutoFocusInput';
910
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
1011
import useLocalize from '@hooks/useLocalize';
11-
import useScreenWrapperTransitionStatus from '@hooks/useScreenWrapperTransitionStatus';
1212
import useTheme from '@hooks/useTheme';
1313
import useThemeStyles from '@hooks/useThemeStyles';
1414
import {getDecodedCategoryName} from '@libs/CategoryUtils';
@@ -37,8 +37,6 @@ function SplitListItem<TItem extends ListItem>({
3737
const theme = useTheme();
3838
const styles = useThemeStyles();
3939
const {translate} = useLocalize();
40-
const {didScreenTransitionEnd} = useScreenWrapperTransitionStatus();
41-
4240
const splitItem = item as unknown as SplitListItemType;
4341

4442
const formattedOriginalAmount = convertToDisplayStringWithoutCurrency(splitItem.originalAmount, splitItem.currency);
@@ -55,7 +53,7 @@ function SplitListItem<TItem extends ListItem>({
5553
[splitItem],
5654
);
5755

58-
const inputRef = useRef<BaseTextInputRef | null>(null);
56+
const {inputCallbackRef: autoFocusCallbackRef} = useAutoFocusInput();
5957

6058
// Animated highlight style for selected item
6159
const animatedHighlightStyle = useAnimatedHighlightStyle({
@@ -75,23 +73,14 @@ function SplitListItem<TItem extends ListItem>({
7573
onInputFocus?.(item);
7674
}, [onInputFocus, item]);
7775

78-
// Auto-focus input when item is selected and screen transition ends
79-
useEffect(() => {
80-
if (!didScreenTransitionEnd || !splitItem.isSelected || !splitItem.isEditable || !inputRef.current) {
76+
// Only connect the auto-focus ref to the selected item so useAutoFocusInput's useFocusEffect
77+
// cleanup can cancel any pending focus task when the screen starts closing, preventing
78+
// the focused input from interfering with the close animation.
79+
const inputCallbackRef: (ref: BaseTextInputRef | null) => void = (ref) => {
80+
if (!splitItem.isSelected || !splitItem.isEditable) {
8181
return;
8282
}
83-
84-
// Use InteractionManager to ensure input focus happens after all animations/interactions complete.
85-
// This prevents focus from interrupting modal close/open animations which would cause UI glitches
86-
// and "jumping" behavior when quickly navigating between screens.
87-
// eslint-disable-next-line @typescript-eslint/no-deprecated
88-
InteractionManager.runAfterInteractions(() => {
89-
inputRef.current?.focus();
90-
});
91-
}, [didScreenTransitionEnd, splitItem.isSelected, splitItem.isEditable]);
92-
93-
const inputCallbackRef = (ref: BaseTextInputRef | null) => {
94-
inputRef.current = ref;
83+
(autoFocusCallbackRef as unknown as (ref: BaseTextInputRef | null) => void)(ref);
9584
};
9685

9786
const isPercentageMode = splitItem.mode === CONST.TAB.SPLIT.PERCENTAGE;

src/pages/iou/request/step/IOURequestEditReportCommon.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import {usePersonalDetails} from '@components/OnyxListItemProvider';
66
import SelectionList from '@components/SelectionList';
77
import InviteMemberListItem from '@components/SelectionList/ListItem/InviteMemberListItem';
88
import type {ListItem} from '@components/SelectionList/types';
9+
import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types';
10+
import useAutoFocusInput from '@hooks/useAutoFocusInput';
911
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
1012
import useDebouncedState from '@hooks/useDebouncedState';
1113
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
@@ -67,6 +69,7 @@ function IOURequestEditReportCommon({
6769
isTimeRequest = false,
6870
}: Props) {
6971
const icons = useMemoizedLazyExpensifyIcons(['Close', 'Document']);
72+
const {inputCallbackRef} = useAutoFocusInput();
7073
const {translate, localeCompare} = useLocalize();
7174
const personalDetails = usePersonalDetails();
7275
const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY);
@@ -302,6 +305,8 @@ function IOURequestEditReportCommon({
302305
label: translate('common.search'),
303306
headerMessage,
304307
onChangeText: setSearchValue,
308+
disableAutoFocus: true,
309+
ref: inputCallbackRef as (ref: BaseTextInputRef | null) => void,
305310
}}
306311
shouldSingleExecuteRowSelect
307312
initiallyFocusedItemKey={selectedReportID}

0 commit comments

Comments
 (0)