Skip to content
Merged
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
4 changes: 1 addition & 3 deletions config/eslint/eslint.seatbelt.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,6 @@
"../../src/components/MoneyReportHeaderActions/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../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/MoneyRequestConfirmationList/ConfirmationFooterContent.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/components/MoneyRequestConfirmationList/hooks/useDistanceRequestState.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
Expand Down Expand Up @@ -504,7 +503,6 @@
"../../src/hooks/useAnimatedHighlightStyle/index.ts" "react-hooks/set-state-in-effect" 2
"../../src/hooks/useAssignCard.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/hooks/useAutoCreateTrackWorkspace.ts" "no-restricted-imports" 1
"../../src/hooks/useAutoFocusInput.ts" "@typescript-eslint/no-deprecated/InteractionManager.runAfterInteractions" 1
"../../src/hooks/useAutoUpdateTimezone.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/hooks/useAutocompleteSuggestions.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/hooks/useBasePopoverReactionList/index.ts" "no-restricted-syntax" 2
Expand All @@ -523,8 +521,8 @@
"../../src/hooks/useDebounceNonReactive.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/hooks/useDebouncedState.ts" "react-hooks/refs" 2
"../../src/hooks/useDebouncedValue.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/hooks/useDialogContainerFocus/index.ts" "@typescript-eslint/no-deprecated/InteractionManager.runAfterInteractions" 1
"../../src/hooks/useDialogContainerFocus/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/hooks/useDialogContainerFocus/index.ts" "no-restricted-imports" 1
"../../src/hooks/useDiscardChangesConfirmation/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/hooks/useDomainGroupFilter.ts" "react-hooks/set-state-in-effect" 1
"../../src/hooks/useDragAndDrop/types.ts" "@typescript-eslint/no-deprecated/React.MutableRefObject" 1
Expand Down
6 changes: 5 additions & 1 deletion src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 header after the RHP transition for screen reader announcement. */
shouldSkipFocusAfterTransition?: boolean;
};

function Header({
Expand All @@ -49,11 +52,12 @@ function Header({
subTitleLink = '',
numberOfTitleLines = 2,
isScreenHeader = false,
shouldSkipFocusAfterTransition = false,
}: HeaderProps) {
const styles = useThemeStyles();
const {isTransitionReady, claimInitialFocus, containerRef} = useDialogLabelRegistration(isScreenHeader ? title : '');

useDialogContainerFocus(containerRef, isTransitionReady, claimInitialFocus);
useDialogContainerFocus(containerRef, isTransitionReady, claimInitialFocus, shouldSkipFocusAfterTransition);

const renderedSubtitle = useMemo(
() => (
Expand Down
3 changes: 3 additions & 0 deletions src/components/HeaderWithBackButton/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ function HeaderWithBackButton({
subTitleLink = '',
shouldMinimizeMenuButton = false,
openParentReportInCurrentTab = false,
shouldSkipFocusAfterTransition = false,
}: HeaderWithBackButtonProps) {
// Avatar-header routes skip Header, so register the dialog label here.
useDialogLabelRegistration(shouldShowReportAvatarWithDisplay ? (report?.reportName ?? '') : '');
Expand Down Expand Up @@ -151,6 +152,7 @@ function HeaderWithBackButton({
subTitleLink={subTitleLink}
numberOfTitleLines={1}
isScreenHeader
shouldSkipFocusAfterTransition={shouldSkipFocusAfterTransition}
/>
);
}, [
Expand All @@ -173,6 +175,7 @@ function HeaderWithBackButton({
translate,
openParentReportInCurrentTab,
shouldDisplayStatus,
shouldSkipFocusAfterTransition,
]);
const ThreeDotMenuButton = useMemo(() => {
if (shouldShowThreeDotsButton) {
Expand Down
3 changes: 3 additions & 0 deletions src/components/HeaderWithBackButton/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,9 @@ type HeaderWithBackButtonProps = Partial<ChildrenProps> & {
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 header after the RHP transition for screen reader announcement. */
shouldSkipFocusAfterTransition?: boolean;
};

export type {ThreeDotsMenuItem};
Expand Down
24 changes: 3 additions & 21 deletions src/components/MoneyRequestConfirmationList.tsx
Original file line number Diff line number Diff line change
@@ -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 useAttendees from '@hooks/useAttendees';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
Expand All @@ -14,7 +13,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';
Expand Down Expand Up @@ -485,22 +483,6 @@ function MoneyRequestConfirmationList({
onSendMoney,
});

const focusTimeoutRef = useRef<NodeJS.Timeout | null>(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]),
);
Comment on lines -488 to -502

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Issue: Potential Regression for Other Flows Using MoneyRequestConfirmationList

Important: The removed useFocusEffect + blurActiveElement logic was running for all consumers of MoneyRequestConfirmationList (when !isNewManualExpenseFlowEnabled). By removing this blurring behavior entirely without replacing it on a per-flow basis, any other flow using this component that does not also pass shouldSkipFocusAfterTransition to its header will now retain focus on the first interactive element in the header (e.g., the back button), which could break keyboard/screen reader navigation in flows that previously relied on the blur.

Why this matters: The old code was a global workaround for a local problem. Removing it is correct, but the fix (shouldSkipFocusAfterTransition) is currently only applied in IOURequestStepConfirmation.tsx. If MoneyRequestConfirmationList is used within other modals or RHP panels, they might now exhibit incorrect focus behavior.

Suggested fix / verification: Add a quick audit or grep to identify all direct consumers of MoneyRequestConfirmationList. For example:

grep -rn "MoneyRequestConfirmationList" src/pages/ --include="*.tsx"

For any other non-deprecated consumers, verify if the focus landing on the back button is problematic. If it is, those flows should also receive shouldSkipFocusAfterTransition on their respective HeaderWithBackButton (or their wrapper should handle focus differently).

Severity: Medium — could cause keyboard navigation regressions in untested flows.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Other components using MoneyRequestConfirmationList:

  • SubmitDetailsPage - native mobile app only, so no need to handle keyboard navigation
  • SplitBillDetailsPage - no submit button that could be triggered by tapping Enter, currently on prod after opening RHP with keyboard navigation there is a blur and tapping Enter does nothing, after these changes there is no blur so focus stays on Back button and Enter closes RHP which IMO is better than what we currently have considering the content of the RHP:
Screen.Recording.2026-06-10.at.08.35.15.mov


const isCompactMode = !showMoreFields && isScanRequest && !isInLandscapeMode;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore the delayed blur after keyboard navigation

When the old money-request flow is advanced by keyboard (for example, tab to the previous step's Next button and press Enter/Space), React Navigation's stack can leave that previous control focused while the confirmation screen is pushed. The confirmation CTA relies on pressOnEnter/useKeyboardShortcuts, and Button disables its Enter shortcut whenever useActiveElementRole() reports a focused button/textbox, so without the deleted delayed blurActiveElement() the user can land on confirmation with focus still on a hidden prior control and Enter won't submit until they click elsewhere. The removed focus effect was the only cleanup that handled this non-mouse path after the transition in the old flow.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True, now it keeps back button focused after entering the confirmation screen via keyboard. Will restore the blur then

Screen.Recording.2026-06-01.at.16.04.56.mov

const selectionListStyle = {
containerStyle: [styles.flexBasisAuto],
Expand Down
51 changes: 26 additions & 25 deletions src/hooks/useAutoFocusInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 () => {
Expand Down
27 changes: 14 additions & 13 deletions src/hooks/useDialogContainerFocus/index.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/useDialogContainerFocus/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type {RefObject} from 'react';
import type {View} from 'react-native';

type UseDialogContainerFocus = (ref: RefObject<View | null>, isReady: boolean, claimInitialFocus?: () => boolean) => void;
type UseDialogContainerFocus = (ref: RefObject<View | null>, isReady: boolean, claimInitialFocus?: () => boolean, skipDialogContainerFocus?: boolean) => void;

export default UseDialogContainerFocus;
2 changes: 2 additions & 0 deletions src/pages/iou/request/step/IOURequestStepConfirmation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -783,6 +783,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. */
shouldSkipFocusAfterTransition
>
{hasMultipleTransactions ? (
<PrevNextButtons
Expand Down
Loading