Skip to content
Open
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
cc6d020
fix: ensure focus restoration on back navigation (#76921)
mavrickdeveloper Jan 18, 2026
383fd2a
refactor: use state-based validation for focus restoration (#76921)
mavrickdeveloper Jan 21, 2026
ec56985
fix(ConfirmModal): defer keyboard check to trap activation time
mavrickdeveloper Jan 22, 2026
170a84c
fix(NavigationFocusManager): prioritize exact text matches over prefi…
mavrickdeveloper Jan 23, 2026
b31475f
fix(focus): harden focus restoration paths and add regression coverage
mavrickdeveloper Feb 9, 2026
569e1f9
fix(focus): make popover keyboard focus deterministic and HMR-safe
mavrickdeveloper Feb 10, 2026
1ed0d06
Merge remote-tracking branch 'origin/main' into fix/76921-focus-resto…
mavrickdeveloper Feb 16, 2026
21dec14
Merge remote-tracking branch 'origin/main' into fix/76921-focus-resto…
mavrickdeveloper Feb 18, 2026
dd3a68c
Merge remote-tracking branch 'origin/main' into fix/76921-focus-resto…
mavrickdeveloper Feb 24, 2026
1b8a7f0
test: add integration tests for ConfirmModal and ButtonWithDropdownMe…
mavrickdeveloper Feb 24, 2026
64790cb
test: update focus restoration and navigation test suites
mavrickdeveloper Feb 24, 2026
93d2d94
test: add accessibility and focus unit tests and trim redundant navig…
mavrickdeveloper Feb 25, 2026
3e91814
fix: resolve CI blockers for ESLint, typecheck, spellcheck, and Prettier
mavrickdeveloper Feb 25, 2026
06e1957
fix: inline web logic in blurActiveInputElementTest to avoid jest-exp…
mavrickdeveloper Feb 25, 2026
a7e94c6
style: apply Prettier formatting to match CI lockfile dependencies
mavrickdeveloper Feb 25, 2026
4c56440
Merge remote-tracking branch 'origin/main' into fix/76921-focus-resto…
mavrickdeveloper Feb 27, 2026
826d7ae
NAB: Test File Names Don't Follow Existing Convention
mavrickdeveloper Feb 27, 2026
7e519ab
Missing wasOpenedViaKeyboard in React.memo Comparison
mavrickdeveloper Feb 27, 2026
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
13 changes: 12 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {PortalProvider} from '@gorhom/portal';
import * as Sentry from '@sentry/react-native';
import React from 'react';
import React, {useEffect} from 'react';
import {LogBox, View} from 'react-native';
import {GestureHandlerRootView} from 'react-native-gesture-handler';
import {PickerStateProvider} from 'react-native-picker-select';
Expand Down Expand Up @@ -51,6 +51,7 @@ import useDefaultDragAndDrop from './hooks/useDefaultDragAndDrop';
import HybridAppHandler from './HybridAppHandler';
import OnyxUpdateManager from './libs/actions/OnyxUpdateManager';
import './libs/HybridApp';
import NavigationFocusManager from './libs/NavigationFocusManager';
import {AttachmentModalContextProvider} from './pages/media/AttachmentModalScreen/AttachmentModalContext';
import ExpensifyCardContextProvider from './pages/settings/Wallet/ExpensifyCardPage/ExpensifyCardContextProvider';
import TravelCVVContextProvider from './pages/settings/Wallet/TravelCVVPage/TravelCVVContextProvider';
Expand All @@ -75,6 +76,16 @@ function App() {
useDefaultDragAndDrop();
OnyxUpdateManager();

// Initialize NavigationFocusManager for web focus restoration during back navigation
// This captures focus on pointerdown/keydown before navigation changes focus to body
useEffect(() => {
NavigationFocusManager.initialize();

return () => {
NavigationFocusManager.destroy();
};
}, []);

return (
<StrictModeWrapper>
<SplashScreenStateContextProvider>
Expand Down
6 changes: 4 additions & 2 deletions src/components/ApprovalWorkflowSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ function ApprovalWorkflowSection({approvalWorkflow, onPress, currency = CONST.CU
</Text>
</View>
)}
{/* MenuItems are display-only (interactive={false}) because the outer
PressableWithoutFeedback handles all click interactions. */}
<MenuItem
title={translate('workflowsExpensesFromPage.title')}
style={styles.p0}
Expand All @@ -84,7 +86,7 @@ function ApprovalWorkflowSection({approvalWorkflow, onPress, currency = CONST.CU
iconHeight={20}
iconWidth={20}
iconFill={theme.icon}
onPress={onPress}
interactive={false}
shouldRemoveBackground
sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.WORKFLOWS.APPROVAL_SECTION_EXPENSES_FROM}
/>
Expand All @@ -106,7 +108,7 @@ function ApprovalWorkflowSection({approvalWorkflow, onPress, currency = CONST.CU
iconWidth={20}
numberOfLinesDescription={1}
iconFill={theme.icon}
onPress={onPress}
interactive={false}
shouldRemoveBackground
helperText={getApprovalLimitDescription({approver, currency, translate, personalDetailsByEmail})}
helperTextStyle={styles.workflowApprovalLimitText}
Expand Down
31 changes: 27 additions & 4 deletions src/components/ButtonWithDropdownMenu/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import mergeRefs from '@libs/mergeRefs';
import NavigationFocusManager from '@libs/NavigationFocusManager';
import variables from '@styles/variables';
import CONST from '@src/CONST';
import type {AnchorPosition} from '@src/styles';
Expand Down Expand Up @@ -73,6 +74,7 @@ function ButtonWithDropdownMenu<IValueType>({ref, ...props}: ButtonWithDropdownM
const defaultPopoverAnchorPosition = process.env.NODE_ENV === 'test' ? {horizontal: 100, vertical: 100} : null;
const [popoverAnchorPosition, setPopoverAnchorPosition] = useState<AnchorPosition | null>(defaultPopoverAnchorPosition);
const dropdownAnchor = useRef<View | null>(null);
const wasOpenedViaKeyboardRef = useRef(false);
// We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to apply correct popover styles
// eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
const {isSmallScreenWidth} = useResponsiveLayout();
Expand Down Expand Up @@ -121,12 +123,26 @@ function ButtonWithDropdownMenu<IValueType>({ref, ...props}: ButtonWithDropdownM
[options, onPress, onOptionSelected, onSubItemSelected],
);

/** Opens or closes the menu with keyboard tracking */
const toggleMenu = useCallback(() => {
if (!isMenuVisible) {
// Capture keyboard state BEFORE menu opens
wasOpenedViaKeyboardRef.current = NavigationFocusManager.wasRecentKeyboardInteraction();
if (wasOpenedViaKeyboardRef.current) {
NavigationFocusManager.clearKeyboardInteractionFlag();
}
} else {
wasOpenedViaKeyboardRef.current = false;
}
setIsMenuVisible(!isMenuVisible);
}, [isMenuVisible]);

useKeyboardShortcut(
CONST.KEYBOARD_SHORTCUTS.CTRL_ENTER,
(e) => {
if (shouldAlwaysShowDropdownMenu || options.length) {
if (!isSplitButton) {
setIsMenuVisible(!isMenuVisible);
toggleMenu();
return;
}
if (selectedItem?.value) {
Expand All @@ -148,12 +164,12 @@ function ButtonWithDropdownMenu<IValueType>({ref, ...props}: ButtonWithDropdownM
const handlePress = useCallback(
(event?: GestureResponderEvent | KeyboardEvent) => {
if (!isSplitButton) {
setIsMenuVisible(!isMenuVisible);
toggleMenu();
} else if (selectedItem?.value) {
onPress(event, selectedItem.value);
}
},
[isMenuVisible, isSplitButton, onPress, selectedItem?.value],
[isSplitButton, onPress, selectedItem?.value, toggleMenu],
);

useImperativeHandle(ref, () => ({
Expand Down Expand Up @@ -198,7 +214,7 @@ function ButtonWithDropdownMenu<IValueType>({ref, ...props}: ButtonWithDropdownM
isDisabled={isDisabled}
shouldStayNormalOnDisable={shouldStayNormalOnDisable}
style={[styles.pl0]}
onPress={() => setIsMenuVisible(!isMenuVisible)}
onPress={toggleMenu}
shouldRemoveLeftBorderRadius
extraSmall={buttonSize === CONST.DROPDOWN_BUTTON_SIZE.EXTRA_SMALL}
large={buttonSize === CONST.DROPDOWN_BUTTON_SIZE.LARGE}
Expand Down Expand Up @@ -264,9 +280,16 @@ function ButtonWithDropdownMenu<IValueType>({ref, ...props}: ButtonWithDropdownM
isVisible={isMenuVisible}
onClose={() => {
setIsMenuVisible(false);
wasOpenedViaKeyboardRef.current = false;
onOptionsMenuHide?.();
}}
wasOpenedViaKeyboard={wasOpenedViaKeyboardRef.current}
onModalShow={onOptionsMenuShow}
onModalHide={() => {
// Focus the anchor button after modal closes but before navigation triggers
// This ensures NavigationFocusManager can capture it for focus restoration on back navigation
(dropdownAnchor.current as unknown as HTMLElement)?.focus?.();
Copy link
Contributor

Choose a reason for hiding this comment

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

🔴 Critical Issue: Forbidden Type Casting (as unknown as HTMLElement)

Caution

Type casting is forbidden per Expensify's STYLE.md. This PR introduces a double cast in ButtonWithDropdownMenu.

Per the STYLE.md Refs section, when you need DOM methods on a RN ref, declare the ref as a union type and use proper narrowing:

// ✅ FIX — union type ref + proper narrowing
const dropdownAnchor = useRef<View | HTMLDivElement>(null);

// In onModalHide:
onModalHide={() => {
    if (dropdownAnchor.current && 'focus' in dropdownAnchor.current) {
        (dropdownAnchor.current as HTMLDivElement).focus();
    }
}}

Bug if not addressed: The double cast silences TypeScript entirely. If dropdownAnchor.current is not an HTMLElement on native platforms, calling .focus?.() would fail silently or crash.

}}
onItemSelected={(selectedSubitem, index, event) => {
onSubItemSelected?.(selectedSubitem, index, event);
if (selectedSubitem.shouldCloseModalOnSelect !== false) {
Expand Down
152 changes: 118 additions & 34 deletions src/components/ConfirmModal.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import type {ReactNode} from 'react';
import React from 'react';
import React, {useLayoutEffect, useRef} from 'react';
import type {StyleProp, TextStyle, ViewStyle} from 'react-native';
import {View} from 'react-native';
import usePrevious from '@hooks/usePrevious';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useThemeStyles from '@hooks/useThemeStyles';
import getPlatform from '@libs/getPlatform';
import NavigationFocusManager from '@libs/NavigationFocusManager';
import CONST from '@src/CONST';
import type IconAsset from '@src/types/utils/IconAsset';
import ConfirmContent from './ConfirmContent';
import {getInitialFocusTarget, isWebPlatform, restoreCapturedAnchorFocus, shouldTryKeyboardInitialFocus} from './ConfirmModal/focusRestore';
import Modal from './Modal';
import type BaseModalProps from './Modal/types';

Expand Down Expand Up @@ -161,6 +165,72 @@ function ConfirmModal({
// Previous state needed for exiting animation to play correctly.
const prevVisible = usePrevious(isVisible);

// Use undefined as initial value to distinguish "not yet captured" from "captured as false"
// This is critical for StrictMode double-invocation protection
const wasOpenedViaKeyboardRef = useRef<boolean | undefined>(undefined);

// Ref for scoping DOM queries to this modal's container
// CRITICAL: Prevents finding buttons from other modals in nested scenarios
const modalContainerRef = useRef<View>(null);

// Ref for storing the captured anchor element for focus restoration
// Captured when modal opens, used when modal closes
const capturedAnchorRef = useRef<HTMLElement | null>(null);

// Capture keyboard state and anchor element when modal opens
// useLayoutEffect ensures this runs synchronously before FocusTrap activates
useLayoutEffect(() => {
if (isVisible && !prevVisible) {
// StrictMode guard: Only capture if we haven't already
// In StrictMode, effects run twice. Without this guard:
// 1st run: reads true, clears flag, stores true in ref
// 2nd run: reads false (already cleared!), overwrites ref with false ← BUG!
if (wasOpenedViaKeyboardRef.current === undefined) {
const wasKeyboard = NavigationFocusManager.wasRecentKeyboardInteraction();
wasOpenedViaKeyboardRef.current = wasKeyboard;
if (wasKeyboard) {
NavigationFocusManager.clearKeyboardInteractionFlag();
}
}

// Capture the anchor element for focus restoration
// This must happen NOW, before user clicks within the modal (which would overwrite it)
if (capturedAnchorRef.current === null) {
capturedAnchorRef.current = NavigationFocusManager.getCapturedAnchorElement();
}
} else if (!isVisible && prevVisible) {
// Reset keyboard ref when modal closes (allows next open to capture)
// NOTE: capturedAnchorRef is reset in onModalHide AFTER focus restoration
wasOpenedViaKeyboardRef.current = undefined;
}
}, [isVisible, prevVisible]);

/**
* Compute initialFocus for Modal's FocusTrap.
*
* IMPORTANT: This must be a function (not an IIFE) so the keyboard check
* happens at trap ACTIVATION time, not render time. The useLayoutEffect
* that sets wasOpenedViaKeyboardRef runs after render but before trap
* activation, so a lazy check will see the correct value.
*
* Returns false for mouse opens (no auto-focus) or non-web platforms.
* Returns HTMLElement (first button) for keyboard opens.
*/
const computeInitialFocus = (): HTMLElement | false => {
const platform = getPlatform();
const shouldTryFocus = shouldTryKeyboardInitialFocus(!!wasOpenedViaKeyboardRef.current);

// Check ref LAZILY - this runs when FocusTrap activates (after useLayoutEffect)
if (!shouldTryFocus || !isWebPlatform(platform)) {
return false;
}

return getInitialFocusTarget({
isOpenedViaKeyboard: shouldTryFocus,
containerElementRef: modalContainerRef.current,
});
};

// Perf: Prevents from rendering whole confirm modal on initial render.
if (!isVisible && !prevVisible) {
return null;
Expand All @@ -172,46 +242,60 @@ function ConfirmModal({
onBackdropPress={onBackdropPress}
isVisible={isVisible}
shouldSetModalVisibility={shouldSetModalVisibility}
onModalHide={onModalHide}
onModalHide={() => {
if (isWebPlatform(getPlatform())) {
restoreCapturedAnchorFocus(capturedAnchorRef.current);
}

// Reset the ref AFTER focus restoration (not in useLayoutEffect)
capturedAnchorRef.current = null;
onModalHide();
}}
type={isSmallScreenWidth ? CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED : CONST.MODAL.MODAL_TYPE.CONFIRM}
innerContainerStyle={styles.pv0}
shouldEnableNewFocusManagement={shouldEnableNewFocusManagement}
restoreFocusType={restoreFocusType}
shouldHandleNavigationBack={shouldHandleNavigationBack}
shouldIgnoreBackHandlerDuringTransition={shouldIgnoreBackHandlerDuringTransition}
initialFocus={computeInitialFocus}
>
<ConfirmContent
title={title}
/* Disable onConfirm function if the modal is being dismissed, otherwise the confirmation
function can be triggered multiple times if the user clicks on the button multiple times. */
onConfirm={() => (isVisible ? onConfirm() : null)}
onCancel={onCancel}
confirmText={confirmText}
cancelText={cancelText}
prompt={prompt}
success={success}
danger={danger}
isVisible={isVisible}
shouldDisableConfirmButtonWhenOffline={shouldDisableConfirmButtonWhenOffline}
shouldShowCancelButton={shouldShowCancelButton}
shouldCenterContent={shouldCenterContent}
iconSource={iconSource}
contentStyles={isSmallScreenWidth && shouldShowDismissIcon ? styles.mt2 : undefined}
iconFill={iconFill}
iconHeight={iconHeight}
iconWidth={iconWidth}
shouldCenterIcon={shouldCenterIcon}
shouldShowDismissIcon={shouldShowDismissIcon}
titleContainerStyles={titleContainerStyles}
iconAdditionalStyles={iconAdditionalStyles}
titleStyles={titleStyles}
promptStyles={promptStyles}
shouldStackButtons={shouldStackButtons}
shouldReverseStackedButtons={shouldReverseStackedButtons}
image={image}
imageStyles={imageStyles}
isConfirmLoading={isConfirmLoading}
/>
<View
ref={modalContainerRef}
testID="confirm-modal-container"
>
<ConfirmContent
title={title}
/* Disable onConfirm function if the modal is being dismissed, otherwise the confirmation
function can be triggered multiple times if the user clicks on the button multiple times. */
onConfirm={() => (isVisible ? onConfirm() : null)}
onCancel={onCancel}
confirmText={confirmText}
cancelText={cancelText}
prompt={prompt}
success={success}
danger={danger}
isVisible={isVisible}
shouldDisableConfirmButtonWhenOffline={shouldDisableConfirmButtonWhenOffline}
shouldShowCancelButton={shouldShowCancelButton}
shouldCenterContent={shouldCenterContent}
iconSource={iconSource}
contentStyles={isSmallScreenWidth && shouldShowDismissIcon ? styles.mt2 : undefined}
iconFill={iconFill}
iconHeight={iconHeight}
iconWidth={iconWidth}
shouldCenterIcon={shouldCenterIcon}
shouldShowDismissIcon={shouldShowDismissIcon}
titleContainerStyles={titleContainerStyles}
iconAdditionalStyles={iconAdditionalStyles}
titleStyles={titleStyles}
promptStyles={promptStyles}
shouldStackButtons={shouldStackButtons}
shouldReverseStackedButtons={shouldReverseStackedButtons}
image={image}
imageStyles={imageStyles}
isConfirmLoading={isConfirmLoading}
/>
</View>
</Modal>
);
}
Expand Down
24 changes: 24 additions & 0 deletions src/components/ConfirmModal/focusRestore/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
type InitialFocusParams = {
isOpenedViaKeyboard: boolean;
containerElementRef: unknown;
};

// eslint-disable-next-line @typescript-eslint/no-unused-vars
function getInitialFocusTarget(_params: InitialFocusParams): HTMLElement | false {
return false;
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
function restoreCapturedAnchorFocus(_capturedAnchorElement: HTMLElement | null): void {}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
function shouldTryKeyboardInitialFocus(_isOpenedViaKeyboard: boolean): boolean {
return false;
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
function isWebPlatform(_platform: string): boolean {
return false;
}

export {getInitialFocusTarget, restoreCapturedAnchorFocus, shouldTryKeyboardInitialFocus, isWebPlatform};
Loading
Loading