From a5336b6b23dddba64d26f4726a3e025e2114070b Mon Sep 17 00:00:00 2001 From: GCyganek Date: Fri, 29 May 2026 12:02:52 +0200 Subject: [PATCH 1/4] InteractionManager migration - MoneyRequestConfirmationList --- config/eslint/eslint.seatbelt.tsv | 1 - .../MoneyRequestConfirmationList.tsx | 24 +++---------------- 2 files changed, 3 insertions(+), 22 deletions(-) diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index 30eebd57e476..ec63257c445f 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -118,7 +118,6 @@ "../../src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx" "@typescript-eslint/no-deprecated/InteractionManager.runAfterInteractions" 1 "../../src/components/MoneyReportHeaderModals.tsx" "@typescript-eslint/no-deprecated/InteractionManager.runAfterInteractions" 1 "../../src/components/MoneyRequestAmountInput.tsx" "react-hooks/immutability" 2 -"../../src/components/MoneyRequestConfirmationList.tsx" "@typescript-eslint/no-deprecated/InteractionManager.runAfterInteractions" 1 "../../src/components/MoneyRequestConfirmationList.tsx" "react-hooks/set-state-in-effect" 2 "../../src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx" "@typescript-eslint/no-deprecated/InteractionManager.runAfterInteractions" 3 "../../src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx" "react-hooks/refs" 6 diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 6ad172948a44..089d9f558481 100644 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -1,7 +1,6 @@ -import {useFocusEffect, useIsFocused} from '@react-navigation/native'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; -// eslint-disable-next-line no-restricted-imports -import {InteractionManager, View} from 'react-native'; +import {useIsFocused} from '@react-navigation/native'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; @@ -13,7 +12,6 @@ import usePolicyForTransaction from '@hooks/usePolicyForTransaction'; import usePreferredPolicy from '@hooks/usePreferredPolicy'; import usePrevious from '@hooks/usePrevious'; import useThemeStyles from '@hooks/useThemeStyles'; -import blurActiveElement from '@libs/Accessibility/blurActiveElement'; import {isCategoryDescriptionRequired} from '@libs/CategoryUtils'; import {isMovingTransactionFromTrackExpense as isMovingTransactionFromTrackExpenseUtil} from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; @@ -483,22 +481,6 @@ function MoneyRequestConfirmationList({ onSendMoney, }); - const focusTimeoutRef = useRef(null); - useFocusEffect( - useCallback(() => { - // Blurring the active element after transition fights AmountField focus in the new manual flow (RHP reopen). - if (isNewManualExpenseFlowEnabled) { - return undefined; - } - focusTimeoutRef.current = setTimeout(() => { - InteractionManager.runAfterInteractions(() => { - blurActiveElement(); - }); - }, CONST.ANIMATED_TRANSITION); - return () => focusTimeoutRef.current && clearTimeout(focusTimeoutRef.current); - }, [isNewManualExpenseFlowEnabled]), - ); - const isCompactMode = !showMoreFields && isScanRequest && !isInLandscapeMode; const selectionListStyle = { containerStyle: [styles.flexBasisAuto], From 8bcb53de822b9bee2d040b47651a8f82875a8874 Mon Sep 17 00:00:00 2001 From: GCyganek Date: Tue, 2 Jun 2026 15:58:45 +0200 Subject: [PATCH 2/4] migrate useDialogContainerFocus and useAutoFocusInput + add skipDialogContainerFocus --- config/eslint/eslint.seatbelt.tsv | 4 +- src/components/Header.tsx | 6 ++- src/components/HeaderWithBackButton/index.tsx | 3 ++ src/components/HeaderWithBackButton/types.ts | 3 ++ src/hooks/useAutoFocusInput.ts | 51 ++++++++++--------- src/hooks/useDialogContainerFocus/index.ts | 27 +++++----- src/hooks/useDialogContainerFocus/types.ts | 2 +- .../step/IOURequestStepConfirmation.tsx | 2 + 8 files changed, 56 insertions(+), 42 deletions(-) diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index ec63257c445f..cc6969ebee96 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -197,14 +197,14 @@ "../../src/components/WideRHPContextProvider/useShouldRenderOverlay.ts" "react-hooks/set-state-in-effect" 1 "../../src/components/ZeroWidthView/index.tsx" "no-restricted-syntax" 2 "../../src/hooks/useAnimatedHighlightStyle/index.ts" "react-hooks/set-state-in-effect" 2 -"../../src/hooks/useAutoFocusInput.ts" "@typescript-eslint/no-deprecated/InteractionManager.runAfterInteractions" 1 +"../../src/hooks/useAutoFocusInput.ts" "no-restricted-imports" 1 "../../src/hooks/useBasePopoverReactionList/index.ts" "no-restricted-syntax" 2 "../../src/hooks/useBasePopoverReactionList/index.ts" "react-hooks/set-state-in-effect" 1 "../../src/hooks/useCachedImageSource.ts" "react-hooks/set-state-in-effect" 1 "../../src/hooks/useCancellationType.ts" "react-hooks/refs" 2 "../../src/hooks/useCancellationType.ts" "react-hooks/set-state-in-effect" 1 "../../src/hooks/useDebouncedState.ts" "react-hooks/refs" 2 -"../../src/hooks/useDialogContainerFocus/index.ts" "@typescript-eslint/no-deprecated/InteractionManager.runAfterInteractions" 1 +"../../src/hooks/useDialogContainerFocus/index.ts" "no-restricted-imports" 1 "../../src/hooks/useDomainGroupFilter.ts" "react-hooks/set-state-in-effect" 1 "../../src/hooks/useDragAndDrop/types.ts" "@typescript-eslint/no-deprecated/React.MutableRefObject" 1 "../../src/hooks/useExpenseActions.ts" "@typescript-eslint/no-deprecated/InteractionManager.runAfterInteractions" 2 diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 681f0c5301b4..df7339fb6410 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -37,6 +37,9 @@ type HeaderProps = { /** Whether this is the screen-level header (registers dialog label and focus). Only HeaderWithBackButton should set this. */ isScreenHeader?: boolean; + + /** Whether to skip focus of the first interactive element inside the dialog after the RHP transition for screen reader announcement. */ + skipDialogContainerFocus?: boolean; }; function Header({ @@ -49,11 +52,12 @@ function Header({ subTitleLink = '', numberOfTitleLines = 2, isScreenHeader = false, + skipDialogContainerFocus = false, }: HeaderProps) { const styles = useThemeStyles(); const {isTransitionReady, claimInitialFocus, containerRef} = useDialogLabelRegistration(isScreenHeader ? title : ''); - useDialogContainerFocus(containerRef, isTransitionReady, claimInitialFocus); + useDialogContainerFocus(containerRef, isTransitionReady, claimInitialFocus, skipDialogContainerFocus); const renderedSubtitle = useMemo( () => ( diff --git a/src/components/HeaderWithBackButton/index.tsx b/src/components/HeaderWithBackButton/index.tsx index 18deb1a7e68e..a232ee484656 100755 --- a/src/components/HeaderWithBackButton/index.tsx +++ b/src/components/HeaderWithBackButton/index.tsx @@ -79,6 +79,7 @@ function HeaderWithBackButton({ subTitleLink = '', shouldMinimizeMenuButton = false, openParentReportInCurrentTab = false, + skipDialogContainerFocus = false, }: HeaderWithBackButtonProps) { // Avatar-header routes skip Header, so register the dialog label here. useDialogLabelRegistration(shouldShowReportAvatarWithDisplay ? (report?.reportName ?? '') : ''); @@ -153,6 +154,7 @@ function HeaderWithBackButton({ subTitleLink={subTitleLink} numberOfTitleLines={1} isScreenHeader + skipDialogContainerFocus={skipDialogContainerFocus} /> ); }, [ @@ -176,6 +178,7 @@ function HeaderWithBackButton({ translate, openParentReportInCurrentTab, shouldDisplayStatus, + skipDialogContainerFocus, ]); const ThreeDotMenuButton = useMemo(() => { if (shouldShowThreeDotsButton) { diff --git a/src/components/HeaderWithBackButton/types.ts b/src/components/HeaderWithBackButton/types.ts index c77cb5e8b357..a3d7edccc110 100644 --- a/src/components/HeaderWithBackButton/types.ts +++ b/src/components/HeaderWithBackButton/types.ts @@ -173,6 +173,9 @@ type HeaderWithBackButtonProps = Partial & { shouldMinimizeMenuButton?: boolean; /** Whether to open the parent report link in the current tab if possible */ openParentReportInCurrentTab?: boolean; + + /** Whether to skip focus of the first interactive element inside the dialog after the RHP transition for screen reader announcement. */ + skipDialogContainerFocus?: boolean; }; export type {ThreeDotsMenuItem}; diff --git a/src/hooks/useAutoFocusInput.ts b/src/hooks/useAutoFocusInput.ts index ea29b398cc03..d58c711a1a62 100644 --- a/src/hooks/useAutoFocusInput.ts +++ b/src/hooks/useAutoFocusInput.ts @@ -2,13 +2,12 @@ import {useFocusEffect, useNavigation} from '@react-navigation/native'; import {useCallback, useEffect, useRef, useState} from 'react'; import type {RefObject} from 'react'; import type {TextInput} from 'react-native'; -// eslint-disable-next-line no-restricted-imports -- idiomatic defer primitive past navigation transitions. -import {InteractionManager} from 'react-native'; import Accessibility from '@libs/Accessibility'; import ComposerFocusManager from '@libs/ComposerFocusManager'; import {moveSelectionToEnd, scrollToBottom} from '@libs/InputUtils'; import isWindowReadyToFocus from '@libs/isWindowReadyToFocus'; import type {PlatformStackNavigationProp} from '@libs/Navigation/PlatformStackNavigation/types'; +import TransitionTracker from '@libs/Navigation/TransitionTracker'; import type {RootNavigatorParamList} from '@libs/Navigation/types'; import {shouldSkipAutoFocusDueToExistingFocus} from '@libs/NavigationFocusReturn'; import {Priorities, resetCycle, tryClaim} from '@libs/ScreenFocusArbiter'; @@ -66,30 +65,32 @@ export default function useAutoFocusInput(isMultiline = false): UseAutoFocusInpu ) { return; } - const focusTaskHandle = InteractionManager.runAfterInteractions(() => { - if (inputRef.current && isMultiline) { - moveSelectionToEnd(inputRef.current); - } - isWindowReadyToFocus().then(() => { - // Null-ref claim would block fallbacks on the destination screen. - const input = inputRef.current; - if (!input) { - return; - } - if (shouldSkipAutoFocusDueToExistingFocus()) { - return; - } - if (!tryClaim(Priorities.AUTO)) { - return; + const focusTaskHandle = TransitionTracker.runAfterTransitions({ + callback: () => { + if (inputRef.current && isMultiline) { + moveSelectionToEnd(inputRef.current); } - // Silent no-op (RN-Web TextInput hidden/disabled) leaves AUTO claimed; release so INITIAL/RETURN aren't blocked for 2s. - const beforeActive = typeof document !== 'undefined' ? document.activeElement : null; - input.focus(); - if (beforeActive !== null && document.activeElement === beforeActive) { - resetCycle(); - } - }); - setIsScreenTransitionEnded(false); + isWindowReadyToFocus().then(() => { + // Null-ref claim would block fallbacks on the destination screen. + const input = inputRef.current; + if (!input) { + return; + } + if (shouldSkipAutoFocusDueToExistingFocus()) { + return; + } + if (!tryClaim(Priorities.AUTO)) { + return; + } + // Silent no-op (RN-Web TextInput hidden/disabled) leaves AUTO claimed; release so INITIAL/RETURN aren't blocked for 2s. + const beforeActive = typeof document !== 'undefined' ? document.activeElement : null; + input.focus(); + if (beforeActive !== null && document.activeElement === beforeActive) { + resetCycle(); + } + }); + setIsScreenTransitionEnded(false); + }, }); return () => { diff --git a/src/hooks/useDialogContainerFocus/index.ts b/src/hooks/useDialogContainerFocus/index.ts index b7678226c3a8..6bc55e16cc35 100644 --- a/src/hooks/useDialogContainerFocus/index.ts +++ b/src/hooks/useDialogContainerFocus/index.ts @@ -1,9 +1,8 @@ import {useEffect} from 'react'; -// eslint-disable-next-line no-restricted-imports -- idiomatic defer primitive past navigation transitions. -import {InteractionManager} from 'react-native'; import FOCUSABLE_SELECTOR from '@libs/focusableSelector'; import hasFocusableAttributes from '@libs/focusGuards'; import getHadTabNavigation from '@libs/hadTabNavigation'; +import TransitionTracker from '@libs/Navigation/TransitionTracker'; import {Priorities, tryClaim} from '@libs/ScreenFocusArbiter'; import type UseDialogContainerFocus from './types'; @@ -25,32 +24,34 @@ function focusFirstInteractiveElement(container: HTMLElement | null): boolean { } /** Focuses the first interactive element inside the dialog after the RHP transition for screen reader announcement. */ -const useDialogContainerFocus: UseDialogContainerFocus = (ref, isReady, claimInitialFocus) => { +const useDialogContainerFocus: UseDialogContainerFocus = (ref, isReady, claimInitialFocus, skipDialogContainerFocus = false) => { useEffect(() => { - if (!isReady || !claimInitialFocus?.()) { + if (!isReady || !claimInitialFocus?.() || skipDialogContainerFocus) { return; } let cancelled = false; let frameId: number; // Deferred past useAutoFocusInput's InteractionManager + Promise chain. - const interactionHandle = InteractionManager.runAfterInteractions(() => { - if (cancelled) { - return; - } - frameId = requestAnimationFrame(() => { + const interactionHandle = TransitionTracker.runAfterTransitions({ + callback: () => { if (cancelled) { return; } - const container = ref.current as unknown as HTMLElement | null; - focusFirstInteractiveElement(container); - }); + frameId = requestAnimationFrame(() => { + if (cancelled) { + return; + } + const container = ref.current as unknown as HTMLElement | null; + focusFirstInteractiveElement(container); + }); + }, }); return () => { cancelled = true; interactionHandle.cancel(); cancelAnimationFrame(frameId); }; - }, [isReady, ref, claimInitialFocus]); + }, [isReady, ref, claimInitialFocus, skipDialogContainerFocus]); }; export default useDialogContainerFocus; diff --git a/src/hooks/useDialogContainerFocus/types.ts b/src/hooks/useDialogContainerFocus/types.ts index d9305561c4ce..c02c82ba87dc 100644 --- a/src/hooks/useDialogContainerFocus/types.ts +++ b/src/hooks/useDialogContainerFocus/types.ts @@ -1,6 +1,6 @@ import type {RefObject} from 'react'; import type {View} from 'react-native'; -type UseDialogContainerFocus = (ref: RefObject, isReady: boolean, claimInitialFocus?: () => boolean) => void; +type UseDialogContainerFocus = (ref: RefObject, isReady: boolean, claimInitialFocus?: () => boolean, skipDialogContainerFocus?: boolean) => void; export default UseDialogContainerFocus; diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index be8d11571f16..8b88dd65bb9b 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -793,6 +793,8 @@ function IOURequestStepConfirmation({ title={headerTitle} subtitle={hasMultipleTransactions ? `${currentTransactionIndex + 1} ${translate('common.of')} ${transactions.length}` : undefined} onBackButtonPress={navigateBack} + /** Skip focus of the first interactive element in the header to make sure that Enter key submits the expense on the confirmation page instead of navigating back. */ + skipDialogContainerFocus > {hasMultipleTransactions ? ( Date: Wed, 3 Jun 2026 10:45:37 +0200 Subject: [PATCH 3/4] shouldSkipFocusAfterTransition --- src/components/Header.tsx | 8 ++++---- src/components/HeaderWithBackButton/index.tsx | 6 +++--- src/components/HeaderWithBackButton/types.ts | 4 ++-- src/pages/iou/request/step/IOURequestStepConfirmation.tsx | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/components/Header.tsx b/src/components/Header.tsx index df7339fb6410..92a3e87be6a2 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -38,8 +38,8 @@ type HeaderProps = { /** Whether this is the screen-level header (registers dialog label and focus). Only HeaderWithBackButton should set this. */ isScreenHeader?: boolean; - /** Whether to skip focus of the first interactive element inside the dialog after the RHP transition for screen reader announcement. */ - skipDialogContainerFocus?: boolean; + /** Whether to skip focus of the first interactive element inside the header after the RHP transition for screen reader announcement. */ + shouldSkipFocusAfterTransition?: boolean; }; function Header({ @@ -52,12 +52,12 @@ function Header({ subTitleLink = '', numberOfTitleLines = 2, isScreenHeader = false, - skipDialogContainerFocus = false, + shouldSkipFocusAfterTransition = false, }: HeaderProps) { const styles = useThemeStyles(); const {isTransitionReady, claimInitialFocus, containerRef} = useDialogLabelRegistration(isScreenHeader ? title : ''); - useDialogContainerFocus(containerRef, isTransitionReady, claimInitialFocus, skipDialogContainerFocus); + useDialogContainerFocus(containerRef, isTransitionReady, claimInitialFocus, shouldSkipFocusAfterTransition); const renderedSubtitle = useMemo( () => ( diff --git a/src/components/HeaderWithBackButton/index.tsx b/src/components/HeaderWithBackButton/index.tsx index a232ee484656..2e393347bbd9 100755 --- a/src/components/HeaderWithBackButton/index.tsx +++ b/src/components/HeaderWithBackButton/index.tsx @@ -79,7 +79,7 @@ function HeaderWithBackButton({ subTitleLink = '', shouldMinimizeMenuButton = false, openParentReportInCurrentTab = false, - skipDialogContainerFocus = false, + shouldSkipFocusAfterTransition = false, }: HeaderWithBackButtonProps) { // Avatar-header routes skip Header, so register the dialog label here. useDialogLabelRegistration(shouldShowReportAvatarWithDisplay ? (report?.reportName ?? '') : ''); @@ -154,7 +154,7 @@ function HeaderWithBackButton({ subTitleLink={subTitleLink} numberOfTitleLines={1} isScreenHeader - skipDialogContainerFocus={skipDialogContainerFocus} + shouldSkipFocusAfterTransition={shouldSkipFocusAfterTransition} /> ); }, [ @@ -178,7 +178,7 @@ function HeaderWithBackButton({ translate, openParentReportInCurrentTab, shouldDisplayStatus, - skipDialogContainerFocus, + shouldSkipFocusAfterTransition, ]); const ThreeDotMenuButton = useMemo(() => { if (shouldShowThreeDotsButton) { diff --git a/src/components/HeaderWithBackButton/types.ts b/src/components/HeaderWithBackButton/types.ts index a3d7edccc110..3108a8c0ab70 100644 --- a/src/components/HeaderWithBackButton/types.ts +++ b/src/components/HeaderWithBackButton/types.ts @@ -174,8 +174,8 @@ type HeaderWithBackButtonProps = Partial & { /** Whether to open the parent report link in the current tab if possible */ openParentReportInCurrentTab?: boolean; - /** Whether to skip focus of the first interactive element inside the dialog after the RHP transition for screen reader announcement. */ - skipDialogContainerFocus?: boolean; + /** Whether to skip focus of the first interactive element inside the header after the RHP transition for screen reader announcement. */ + shouldSkipFocusAfterTransition?: boolean; }; export type {ThreeDotsMenuItem}; diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index 8b88dd65bb9b..438d542964a9 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -794,7 +794,7 @@ function IOURequestStepConfirmation({ subtitle={hasMultipleTransactions ? `${currentTransactionIndex + 1} ${translate('common.of')} ${transactions.length}` : undefined} onBackButtonPress={navigateBack} /** Skip focus of the first interactive element in the header to make sure that Enter key submits the expense on the confirmation page instead of navigating back. */ - skipDialogContainerFocus + shouldSkipFocusAfterTransition > {hasMultipleTransactions ? ( Date: Tue, 9 Jun 2026 12:45:45 +0200 Subject: [PATCH 4/4] Re-run tests