diff --git a/src/App.tsx b/src/App.tsx index 8ef6d1a55f195..3f0117cf275de 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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'; @@ -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'; @@ -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 ( diff --git a/src/components/ApprovalWorkflowSection.tsx b/src/components/ApprovalWorkflowSection.tsx index c218909a6a3d7..7a4ef6dd9a1f1 100644 --- a/src/components/ApprovalWorkflowSection.tsx +++ b/src/components/ApprovalWorkflowSection.tsx @@ -71,6 +71,8 @@ function ApprovalWorkflowSection({approvalWorkflow, onPress, currency = CONST.CU )} + {/* MenuItems are display-only (interactive={false}) because the outer + PressableWithoutFeedback handles all click interactions. */} @@ -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} diff --git a/src/components/ButtonWithDropdownMenu/index.tsx b/src/components/ButtonWithDropdownMenu/index.tsx index afe40ec903efc..014b9bba18de0 100644 --- a/src/components/ButtonWithDropdownMenu/index.tsx +++ b/src/components/ButtonWithDropdownMenu/index.tsx @@ -1,4 +1,4 @@ -import type {RefObject} from 'react'; +import type {RefCallback} from 'react'; import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; import {View} from 'react-native'; import type {GestureResponderEvent} from 'react-native'; @@ -14,9 +14,11 @@ 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'; +import viewRef from '@src/types/utils/viewRef'; import type {ButtonWithDropdownMenuProps} from './types'; const defaultAnchorAlignment = { @@ -25,6 +27,8 @@ const defaultAnchorAlignment = { vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP, }; +type DropdownAnchor = View | HTMLDivElement | null; + function ButtonWithDropdownMenu({ref, ...props}: ButtonWithDropdownMenuProps) { const { success = true, @@ -74,18 +78,22 @@ function ButtonWithDropdownMenu({ref, ...props}: ButtonWithDropdownM // In tests, skip the popover anchor position calculation. The default values are needed for popover menu to be rendered in tests. const defaultPopoverAnchorPosition = process.env.NODE_ENV === 'test' ? {horizontal: 100, vertical: 100} : null; const [popoverAnchorPosition, setPopoverAnchorPosition] = useState(defaultPopoverAnchorPosition); - const dropdownAnchor = useRef(null); + const dropdownAnchor = useRef(null); + const [wasOpenedViaKeyboard, setWasOpenedViaKeyboard] = useState(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(); - const dropdownButtonRef = isSplitButton ? buttonRef : mergeRefs(buttonRef, dropdownAnchor); + const setDropdownAnchor: RefCallback = useCallback((node: DropdownAnchor) => { + dropdownAnchor.current = node; + }, []); + // eslint-disable-next-line react-hooks/refs -- mergeRefs creates a ref callback and does not read ref.current during render + const dropdownButtonRef = isSplitButton ? buttonRef : mergeRefs(buttonRef, setDropdownAnchor); const selectedItem = options.at(selectedItemIndex) ?? options.at(0); const areAllOptionsDisabled = options.every((option) => option.disabled); const innerStyleDropButton = StyleUtils.getDropDownButtonHeight(buttonSize); const isButtonSizeLarge = buttonSize === CONST.DROPDOWN_BUTTON_SIZE.LARGE; const isButtonSizeSmall = buttonSize === CONST.DROPDOWN_BUTTON_SIZE.SMALL; const isButtonSizeExtraSmall = buttonSize === CONST.DROPDOWN_BUTTON_SIZE.EXTRA_SMALL; - const nullCheckRef = (refParam: RefObject) => refParam ?? null; const shouldShowButtonRightIcon = !!options.at(0)?.shouldShowButtonRightIcon; useEffect(() => { @@ -101,7 +109,7 @@ function ButtonWithDropdownMenu({ref, ...props}: ButtonWithDropdownM return; } - calculatePopoverPosition(dropdownAnchor, anchorAlignment).then(setPopoverAnchorPosition); + calculatePopoverPosition(viewRef(dropdownAnchor), anchorAlignment).then(setPopoverAnchorPosition); }, [isMenuVisible, calculatePopoverPosition, anchorAlignment]); const handleSingleOptionPress = useCallback( @@ -123,12 +131,27 @@ function ButtonWithDropdownMenu({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 + const wasKeyboardInteraction = NavigationFocusManager.wasRecentKeyboardInteraction(); + setWasOpenedViaKeyboard(wasKeyboardInteraction); + if (wasKeyboardInteraction) { + NavigationFocusManager.clearKeyboardInteractionFlag(); + } + } else { + setWasOpenedViaKeyboard(false); + } + setIsMenuVisible(!isMenuVisible); + }, [isMenuVisible]); + useKeyboardShortcut( CONST.KEYBOARD_SHORTCUTS.CTRL_ENTER, (e) => { if (shouldAlwaysShowDropdownMenu || options.length) { if (!isSplitButton) { - setIsMenuVisible(!isMenuVisible); + toggleMenu(); return; } if (selectedItem?.value) { @@ -150,18 +173,28 @@ function ButtonWithDropdownMenu({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, () => ({ setIsMenuVisible, })); + const focusDropdownAnchor = useCallback(() => { + const anchor = dropdownAnchor.current; + + if (!anchor || !('focus' in anchor) || typeof anchor.focus !== 'function') { + return; + } + + anchor.focus(); + }, []); + return ( {shouldAlwaysShowDropdownMenu || options.length > 1 ? ( @@ -199,12 +232,12 @@ function ButtonWithDropdownMenu({ref, ...props}: ButtonWithDropdownM {isSplitButton && ( + // This is an unusual structure but we should handle it. + + const outerButton = document.createElement('button'); + outerButton.id = 'outer-button'; + outerButton.setAttribute('role', 'button'); + + const innerMenuitem = document.createElement('div'); + innerMenuitem.setAttribute('role', 'menuitem'); + innerMenuitem.id = 'inner-menuitem'; + innerMenuitem.textContent = 'Click me'; + + outerButton.appendChild(innerMenuitem); + document.body.appendChild(outerButton); + + // First, capture a non-menuitem anchor + const anchor = document.createElement('button'); + anchor.id = 'anchor-for-edge-case'; + document.body.appendChild(anchor); + + // Register route BEFORE interaction (required for state-based validation) + NavigationFocusManager.registerFocusedRoute('a1-nested-edge-route'); + + const anchorEvent = new PointerEvent('pointerdown', {bubbles: true, cancelable: true}); + Object.defineProperty(anchorEvent, 'target', {value: anchor}); + document.dispatchEvent(anchorEvent); + + // Now click on the inner menuitem + const menuitemEvent = new PointerEvent('pointerdown', {bubbles: true, cancelable: true}); + Object.defineProperty(menuitemEvent, 'target', {value: innerMenuitem}); + document.dispatchEvent(menuitemEvent); + + NavigationFocusManager.captureForRoute('a1-nested-edge-route'); + + // Then: Should preserve anchor (innerMenuitem has role="menuitem" via closest) + const retrieved = NavigationFocusManager.retrieveForRoute('a1-nested-edge-route'); + expect(retrieved).toBe(anchor); + }); + + it('should handle Space key on menuitem the same as Enter key', () => { + // Given: An anchor button + const moreButton = document.createElement('button'); + moreButton.id = 'more-button-space'; + document.body.appendChild(moreButton); + + // Register route BEFORE interaction (required for state-based validation) + NavigationFocusManager.registerFocusedRoute('a1-space-key-route'); + + // Capture anchor via Enter + moreButton.focus(); + const anchorEvent = new KeyboardEvent('keydown', {key: 'Enter', bubbles: true}); + document.dispatchEvent(anchorEvent); + + // Create and focus menuitem + const menuItem = document.createElement('div'); + menuItem.setAttribute('role', 'menuitem'); + menuItem.id = 'menuitem-space'; + menuItem.tabIndex = 0; + document.body.appendChild(menuItem); + menuItem.focus(); + + // Press Space on menuitem + const spaceEvent = new KeyboardEvent('keydown', {key: ' ', bubbles: true}); + document.dispatchEvent(spaceEvent); + + NavigationFocusManager.captureForRoute('a1-space-key-route'); + + // Then: Anchor should be preserved + const retrieved = NavigationFocusManager.retrieveForRoute('a1-space-key-route'); + expect(retrieved).toBe(moreButton); + }); + + it('should handle element with aria-haspopup but not role="menuitem"', () => { + // Given: A button that opens a popup (not a menuitem itself) + const popupTrigger = document.createElement('button'); + popupTrigger.id = 'popup-trigger'; + popupTrigger.setAttribute('aria-haspopup', 'true'); + document.body.appendChild(popupTrigger); + + // Register route BEFORE interaction (required for state-based validation) + NavigationFocusManager.registerFocusedRoute('a1-popup-trigger-route'); + + // Click popup trigger + const triggerEvent = new PointerEvent('pointerdown', {bubbles: true, cancelable: true}); + Object.defineProperty(triggerEvent, 'target', {value: popupTrigger}); + document.dispatchEvent(triggerEvent); + + // Create and click menuitem + const menuItem = document.createElement('div'); + menuItem.setAttribute('role', 'menuitem'); + menuItem.id = 'popup-menuitem'; + document.body.appendChild(menuItem); + + const menuitemEvent = new PointerEvent('pointerdown', {bubbles: true, cancelable: true}); + Object.defineProperty(menuitemEvent, 'target', {value: menuItem}); + document.dispatchEvent(menuitemEvent); + + NavigationFocusManager.captureForRoute('a1-popup-trigger-route'); + + // Then: Popup trigger should be preserved (it's not a menuitem) + const retrieved = NavigationFocusManager.retrieveForRoute('a1-popup-trigger-route'); + expect(retrieved).toBe(popupTrigger); + }); + }); + + describe('Regression Prevention', () => { + it('should NOT break normal button navigation (no menuitem involved)', () => { + // Given: A simple button navigation scenario + const navButton = document.createElement('button'); + navButton.id = 'nav-button'; + navButton.setAttribute('aria-label', 'Navigate'); + document.body.appendChild(navButton); + + // Register route BEFORE interaction (required for state-based validation) + NavigationFocusManager.registerFocusedRoute('a1-regression-route'); + + // When: User clicks button and navigates + const pointerEvent = new PointerEvent('pointerdown', {bubbles: true, cancelable: true}); + Object.defineProperty(pointerEvent, 'target', {value: navButton}); + document.dispatchEvent(pointerEvent); + + NavigationFocusManager.captureForRoute('a1-regression-route'); + + // Then: Button should be captured normally + const retrieved = NavigationFocusManager.retrieveForRoute('a1-regression-route'); + expect(retrieved).toBe(navButton); + }); + + it('should NOT break link navigation (no menuitem involved)', () => { + // Given: A link element + const link = document.createElement('a'); + link.id = 'nav-link'; + link.href = '#test'; + link.textContent = 'Navigate'; + document.body.appendChild(link); + + // Register route BEFORE interaction (required for state-based validation) + NavigationFocusManager.registerFocusedRoute('a1-link-route'); + + // When: User clicks link + const pointerEvent = new PointerEvent('pointerdown', {bubbles: true, cancelable: true}); + Object.defineProperty(pointerEvent, 'target', {value: link}); + document.dispatchEvent(pointerEvent); + + NavigationFocusManager.captureForRoute('a1-link-route'); + + // Then: Link should be captured normally + const retrieved = NavigationFocusManager.retrieveForRoute('a1-link-route'); + expect(retrieved).toBe(link); + }); + + it('should NOT break role="button" div navigation', () => { + // Given: A div with role="button" (common in React Native Web) + const divButton = document.createElement('div'); + divButton.id = 'div-button'; + divButton.setAttribute('role', 'button'); + divButton.tabIndex = 0; + document.body.appendChild(divButton); + + // Register route BEFORE interaction (required for state-based validation) + NavigationFocusManager.registerFocusedRoute('a1-div-button-route'); + + // When: User clicks div button + const pointerEvent = new PointerEvent('pointerdown', {bubbles: true, cancelable: true}); + Object.defineProperty(pointerEvent, 'target', {value: divButton}); + document.dispatchEvent(pointerEvent); + + NavigationFocusManager.captureForRoute('a1-div-button-route'); + + // Then: Div button should be captured normally + const retrieved = NavigationFocusManager.retrieveForRoute('a1-div-button-route'); + expect(retrieved).toBe(divButton); + }); + }); + }); + + // ============================================================================ + // Approach 6: Keyboard Interaction Check (Issue #76921) + // ============================================================================ + // These tests verify the keyboard interaction tracking used to distinguish + // keyboard navigation from mouse navigation, allowing the composer to skip + // auto-focus when users navigate back via keyboard. + // ============================================================================ + + describe('Approach 6: Keyboard Interaction Tracking', () => { + describe('wasRecentKeyboardInteraction() Flag', () => { + it('should return false initially (no interaction)', () => { + // Given: Fresh state + NavigationFocusManager.clearKeyboardInteractionFlag(); + + // Then: Flag should be false + expect(NavigationFocusManager.wasRecentKeyboardInteraction()).toBe(false); + }); + + it('should set flag to true on Enter keydown', () => { + // Given: Fresh state + NavigationFocusManager.clearKeyboardInteractionFlag(); + + // When: Enter key is pressed + const keyEvent = new KeyboardEvent('keydown', { + key: 'Enter', + bubbles: true, + }); + document.dispatchEvent(keyEvent); + + // Then: Flag should be true + expect(NavigationFocusManager.wasRecentKeyboardInteraction()).toBe(true); + }); + + it('should set flag to true on Space keydown', () => { + // Given: Fresh state + NavigationFocusManager.clearKeyboardInteractionFlag(); + + // When: Space key is pressed + const keyEvent = new KeyboardEvent('keydown', { + key: ' ', + bubbles: true, + }); + document.dispatchEvent(keyEvent); + + // Then: Flag should be true + expect(NavigationFocusManager.wasRecentKeyboardInteraction()).toBe(true); + }); + + it('should set flag to true on Escape keydown (back navigation)', () => { + // Given: Fresh state + NavigationFocusManager.clearKeyboardInteractionFlag(); + + // When: Escape key is pressed + const keyEvent = new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + }); + document.dispatchEvent(keyEvent); + + // Then: Flag should be true + expect(NavigationFocusManager.wasRecentKeyboardInteraction()).toBe(true); + }); + + it('should NOT set flag on other key presses (e.g., Tab, Arrow keys)', () => { + // Given: Fresh state + NavigationFocusManager.clearKeyboardInteractionFlag(); + + // When: Tab key is pressed + const tabEvent = new KeyboardEvent('keydown', { + key: 'Tab', + bubbles: true, + }); + document.dispatchEvent(tabEvent); + + // Then: Flag should still be false + expect(NavigationFocusManager.wasRecentKeyboardInteraction()).toBe(false); + + // When: ArrowDown is pressed + const arrowEvent = new KeyboardEvent('keydown', { + key: 'ArrowDown', + bubbles: true, + }); + document.dispatchEvent(arrowEvent); + + // Then: Flag should still be false + expect(NavigationFocusManager.wasRecentKeyboardInteraction()).toBe(false); + }); + }); + + describe('clearKeyboardInteractionFlag()', () => { + it('should clear the flag after it was set by keyboard interaction', () => { + // Given: Flag set by Enter keydown + const keyEvent = new KeyboardEvent('keydown', { + key: 'Enter', + bubbles: true, + }); + document.dispatchEvent(keyEvent); + expect(NavigationFocusManager.wasRecentKeyboardInteraction()).toBe(true); + + // When: Flag is cleared + NavigationFocusManager.clearKeyboardInteractionFlag(); + + // Then: Flag should be false + expect(NavigationFocusManager.wasRecentKeyboardInteraction()).toBe(false); + }); + + it('should be safe to call multiple times', () => { + // Given: Flag is already false + NavigationFocusManager.clearKeyboardInteractionFlag(); + + // When: Called multiple times + NavigationFocusManager.clearKeyboardInteractionFlag(); + NavigationFocusManager.clearKeyboardInteractionFlag(); + + // Then: No errors, flag remains false + expect(NavigationFocusManager.wasRecentKeyboardInteraction()).toBe(false); + }); + }); + + describe('Pointer Interaction Clears Flag', () => { + it('should clear keyboard flag on pointerdown (mouse click)', () => { + // Given: Flag set by keyboard interaction + const keyEvent = new KeyboardEvent('keydown', { + key: 'Enter', + bubbles: true, + }); + document.dispatchEvent(keyEvent); + expect(NavigationFocusManager.wasRecentKeyboardInteraction()).toBe(true); + + // When: Mouse click occurs + const button = document.createElement('button'); + document.body.appendChild(button); + + const pointerEvent = new PointerEvent('pointerdown', { + bubbles: true, + cancelable: true, + }); + Object.defineProperty(pointerEvent, 'target', {value: button}); + document.dispatchEvent(pointerEvent); + + // Then: Flag should be cleared + expect(NavigationFocusManager.wasRecentKeyboardInteraction()).toBe(false); + }); + + it('should handle keyboard → mouse → keyboard sequence correctly', () => { + // Step 1: Keyboard interaction + const keyEvent1 = new KeyboardEvent('keydown', {key: 'Enter', bubbles: true}); + document.dispatchEvent(keyEvent1); + expect(NavigationFocusManager.wasRecentKeyboardInteraction()).toBe(true); + + // Step 2: Mouse interaction (clears flag) + const button = document.createElement('button'); + document.body.appendChild(button); + const pointerEvent = new PointerEvent('pointerdown', {bubbles: true, cancelable: true}); + Object.defineProperty(pointerEvent, 'target', {value: button}); + document.dispatchEvent(pointerEvent); + expect(NavigationFocusManager.wasRecentKeyboardInteraction()).toBe(false); + + // Step 3: Keyboard interaction again + const keyEvent2 = new KeyboardEvent('keydown', {key: 'Escape', bubbles: true}); + document.dispatchEvent(keyEvent2); + expect(NavigationFocusManager.wasRecentKeyboardInteraction()).toBe(true); + }); + }); + + describe('Escape Key Behavior (Approach 6 Specific)', () => { + it('should set flag but NOT capture element on Escape (back navigation only needs flag)', () => { + // Given: A focused button + const button = document.createElement('button'); + button.id = 'escape-test-button'; + document.body.appendChild(button); + button.focus(); + + // And: Clear any prior capture + NavigationFocusManager.captureForRoute('clear-prior'); + NavigationFocusManager.retrieveForRoute('clear-prior'); + NavigationFocusManager.clearKeyboardInteractionFlag(); + + // When: Escape is pressed + const escapeEvent = new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + }); + document.dispatchEvent(escapeEvent); + + // Then: Flag should be set + expect(NavigationFocusManager.wasRecentKeyboardInteraction()).toBe(true); + + // But: No element should be captured via interaction capture + // (Escape doesn't call the element capture logic, only sets flag) + // captureForRoute will use activeElement fallback since lastInteractionCapture is null + NavigationFocusManager.captureForRoute('escape-route'); + const retrieved = NavigationFocusManager.retrieveForRoute('escape-route'); + + // The button may be captured via activeElement fallback, which is expected + // The key point is the keyboard flag was set + expect(retrieved).toBe(button); // via activeElement fallback + }); + + it('should NOT overwrite prior capture when Escape is pressed (preserves forward nav element)', () => { + // Given: An element captured via Enter (forward navigation) + const forwardButton = document.createElement('button'); + forwardButton.id = 'forward-button'; + forwardButton.setAttribute('aria-label', 'Forward'); + document.body.appendChild(forwardButton); + + // Register route for immediate capture + NavigationFocusManager.registerFocusedRoute('preserve-forward-route'); + + // Press Enter on forward button + forwardButton.focus(); + const enterEvent = new KeyboardEvent('keydown', {key: 'Enter', bubbles: true}); + document.dispatchEvent(enterEvent); + + // Now press Escape (back navigation) - should NOT overwrite + const escapeEvent = new KeyboardEvent('keydown', {key: 'Escape', bubbles: true}); + document.dispatchEvent(escapeEvent); + + // Unregister route + NavigationFocusManager.unregisterFocusedRoute('preserve-forward-route'); + + // Then: Should retrieve the forward button, not null + const retrieved = NavigationFocusManager.retrieveForRoute('preserve-forward-route'); + expect(retrieved).toBe(forwardButton); + }); + + it('should differentiate Escape from Enter/Space in element capture', () => { + // Given: Fresh state + NavigationFocusManager.captureForRoute('clear-state'); + NavigationFocusManager.retrieveForRoute('clear-state'); + NavigationFocusManager.clearKeyboardInteractionFlag(); + + // Create two buttons + const button1 = document.createElement('button'); + button1.id = 'button-1'; + document.body.appendChild(button1); + + const button2 = document.createElement('button'); + button2.id = 'button-2'; + document.body.appendChild(button2); + + // Register route BEFORE interaction (required for state-based validation) + NavigationFocusManager.registerFocusedRoute('differentiate-route'); + + // Press Enter on button1 (captures element) + button1.focus(); + const enterEvent = new KeyboardEvent('keydown', {key: 'Enter', bubbles: true}); + document.dispatchEvent(enterEvent); + + // Press Escape while button2 is focused (should NOT capture button2) + button2.focus(); + const escapeEvent = new KeyboardEvent('keydown', {key: 'Escape', bubbles: true}); + document.dispatchEvent(escapeEvent); + + // Capture for route + NavigationFocusManager.captureForRoute('differentiate-route'); + const retrieved = NavigationFocusManager.retrieveForRoute('differentiate-route'); + + // Should have captured button1 (from Enter), not button2 (from Escape) + // because Escape only sets flag, doesn't capture + expect(retrieved).toBe(button1); + }); + }); + + describe('Integration: Keyboard vs Mouse User Flow', () => { + /** + * Simulates the keyboard user flow: + * 1. User presses Enter on chat header → wasKeyboardInteraction = true + * 2. RHP opens + * 3. User presses Escape → wasKeyboardInteraction = true + * 4. RHP closes + * 5. ComposerWithSuggestions checks flag → true → skips auto-focus + */ + it('should maintain keyboard flag through navigation cycle', () => { + // Step 1: Enter on element (forward navigation) + NavigationFocusManager.clearKeyboardInteractionFlag(); + const chatHeader = document.createElement('button'); + chatHeader.id = 'chat-header'; + document.body.appendChild(chatHeader); + chatHeader.focus(); + + const enterEvent = new KeyboardEvent('keydown', {key: 'Enter', bubbles: true}); + document.dispatchEvent(enterEvent); + + expect(NavigationFocusManager.wasRecentKeyboardInteraction()).toBe(true); + + // Step 2: Simulate RHP open (flag still true) + // In real app, this would be modal opening + + // Step 3: Escape (back navigation) + const escapeEvent = new KeyboardEvent('keydown', {key: 'Escape', bubbles: true}); + document.dispatchEvent(escapeEvent); + + expect(NavigationFocusManager.wasRecentKeyboardInteraction()).toBe(true); + + // Step 4: This is where ComposerWithSuggestions would check and clear + const wasKeyboard = NavigationFocusManager.wasRecentKeyboardInteraction(); + NavigationFocusManager.clearKeyboardInteractionFlag(); + + expect(wasKeyboard).toBe(true); + expect(NavigationFocusManager.wasRecentKeyboardInteraction()).toBe(false); + }); + + /** + * Simulates the mouse user flow: + * 1. User clicks chat header → wasKeyboardInteraction = false + * 2. RHP opens + * 3. User clicks Back button → wasKeyboardInteraction = false + * 4. RHP closes + * 5. ComposerWithSuggestions checks flag → false → auto-focuses + */ + it('should maintain mouse flag (false) through navigation cycle', () => { + // Step 1: Click on element (forward navigation) + NavigationFocusManager.clearKeyboardInteractionFlag(); + const chatHeader = document.createElement('button'); + chatHeader.id = 'chat-header-mouse'; + document.body.appendChild(chatHeader); + + const clickEvent1 = new PointerEvent('pointerdown', {bubbles: true, cancelable: true}); + Object.defineProperty(clickEvent1, 'target', {value: chatHeader}); + document.dispatchEvent(clickEvent1); + + expect(NavigationFocusManager.wasRecentKeyboardInteraction()).toBe(false); + + // Step 2: Simulate RHP open + + // Step 3: Click Back button + const backButton = document.createElement('button'); + backButton.id = 'back-button'; + document.body.appendChild(backButton); + + const clickEvent2 = new PointerEvent('pointerdown', {bubbles: true, cancelable: true}); + Object.defineProperty(clickEvent2, 'target', {value: backButton}); + document.dispatchEvent(clickEvent2); + + expect(NavigationFocusManager.wasRecentKeyboardInteraction()).toBe(false); + + // Step 4: ComposerWithSuggestions would check and find flag=false → auto-focus + const wasKeyboard = NavigationFocusManager.wasRecentKeyboardInteraction(); + expect(wasKeyboard).toBe(false); + }); + + /** + * Mixed input: keyboard navigate forward, mouse navigate back + * Expected: Mouse wins (flag=false), composer should auto-focus + */ + it('should correctly handle keyboard forward → mouse back', () => { + // Step 1: Enter on element + NavigationFocusManager.clearKeyboardInteractionFlag(); + const element = document.createElement('button'); + document.body.appendChild(element); + element.focus(); + + const enterEvent = new KeyboardEvent('keydown', {key: 'Enter', bubbles: true}); + document.dispatchEvent(enterEvent); + + expect(NavigationFocusManager.wasRecentKeyboardInteraction()).toBe(true); + + // Step 2: Click back button (mouse) + const backButton = document.createElement('button'); + document.body.appendChild(backButton); + + const clickEvent = new PointerEvent('pointerdown', {bubbles: true, cancelable: true}); + Object.defineProperty(clickEvent, 'target', {value: backButton}); + document.dispatchEvent(clickEvent); + + // Then: Mouse should have cleared the keyboard flag + expect(NavigationFocusManager.wasRecentKeyboardInteraction()).toBe(false); + }); + + /** + * Mixed input: mouse navigate forward, keyboard navigate back + * Expected: Keyboard wins (flag=true), focus restoration should work + */ + it('should correctly handle mouse forward → keyboard back', () => { + // Step 1: Click on element + NavigationFocusManager.clearKeyboardInteractionFlag(); + const element = document.createElement('button'); + document.body.appendChild(element); + + const clickEvent = new PointerEvent('pointerdown', {bubbles: true, cancelable: true}); + Object.defineProperty(clickEvent, 'target', {value: element}); + document.dispatchEvent(clickEvent); + + expect(NavigationFocusManager.wasRecentKeyboardInteraction()).toBe(false); + + // Step 2: Press Escape (keyboard back) + const escapeEvent = new KeyboardEvent('keydown', {key: 'Escape', bubbles: true}); + document.dispatchEvent(escapeEvent); + + // Then: Keyboard should have set the flag + expect(NavigationFocusManager.wasRecentKeyboardInteraction()).toBe(true); + }); + }); + + describe('Edge Cases', () => { + it('should handle rapid keyboard events', () => { + NavigationFocusManager.clearKeyboardInteractionFlag(); + + // Rapid keyboard events + for (let i = 0; i < 10; i++) { + const event = new KeyboardEvent('keydown', {key: 'Enter', bubbles: true}); + document.dispatchEvent(event); + } + + // Flag should still be true + expect(NavigationFocusManager.wasRecentKeyboardInteraction()).toBe(true); + }); + + it('should handle rapid alternating keyboard/mouse events', () => { + NavigationFocusManager.clearKeyboardInteractionFlag(); + const button = document.createElement('button'); + document.body.appendChild(button); + + // Alternate between keyboard and mouse + for (let i = 0; i < 5; i++) { + // Keyboard + const keyEvent = new KeyboardEvent('keydown', {key: 'Enter', bubbles: true}); + document.dispatchEvent(keyEvent); + expect(NavigationFocusManager.wasRecentKeyboardInteraction()).toBe(true); + + // Mouse + const pointerEvent = new PointerEvent('pointerdown', {bubbles: true, cancelable: true}); + Object.defineProperty(pointerEvent, 'target', {value: button}); + document.dispatchEvent(pointerEvent); + expect(NavigationFocusManager.wasRecentKeyboardInteraction()).toBe(false); + } + }); + }); + }); + + describe('Memory Management', () => { + it('should clear all stored focus on destroy', () => { + // Given: Multiple routes with stored focus + const button1 = document.createElement('button'); + document.body.appendChild(button1); + button1.focus(); + NavigationFocusManager.captureForRoute('memory-route-1'); + + const button2 = document.createElement('button'); + document.body.appendChild(button2); + button2.focus(); + NavigationFocusManager.captureForRoute('memory-route-2'); + + // When: Manager is destroyed + NavigationFocusManager.destroy(); + + // Re-initialize for retrieval test + NavigationFocusManager.initialize(); + + // Then: All stored focus should be cleared + expect(NavigationFocusManager.retrieveForRoute('memory-route-1')).toBeNull(); + expect(NavigationFocusManager.retrieveForRoute('memory-route-2')).toBeNull(); + }); + + it('should support clearForRoute to manually clear stored focus', () => { + // Given: A route with stored focus + const button = document.createElement('button'); + document.body.appendChild(button); + button.focus(); + NavigationFocusManager.captureForRoute('clear-test-route'); + + // When: clearForRoute is called + NavigationFocusManager.clearForRoute('clear-test-route'); + + // Then: Retrieval should return null + expect(NavigationFocusManager.retrieveForRoute('clear-test-route')).toBeNull(); + }); + + it('should support hasStoredFocus to check without consuming', () => { + // Given: A route with stored focus + const button = document.createElement('button'); + document.body.appendChild(button); + button.focus(); + NavigationFocusManager.captureForRoute('has-focus-route'); + + // When: hasStoredFocus is called + const hasFocus = NavigationFocusManager.hasStoredFocus('has-focus-route'); + + // Then: Should return true without consuming + expect(hasFocus).toBe(true); + + // And: Retrieval should still work + const retrieved = NavigationFocusManager.retrieveForRoute('has-focus-route'); + expect(retrieved).toBe(button); + }); + }); + + // ============================================================================ + // App.tsx Integration Risk Tests (Issue #76921) + // ============================================================================ + // These tests verify the risks identified in the App.tsx regression analysis: + // - React StrictMode double-invocation + // - Idempotent initialization + // - Event listener cleanup + // - Capture phase event handling + // - Performance characteristics + // ============================================================================ + + describe('App.tsx Integration Risks', () => { + describe('Risk: React StrictMode Double-Invocation', () => { + /** + * React StrictMode in development mode calls useEffect twice: + * mount → unmount → mount + * This simulates that pattern to ensure NavigationFocusManager handles it correctly. + */ + it('should handle StrictMode mount/unmount/mount cycle correctly', () => { + // Given: Manager is already initialized from beforeEach + + // Simulate StrictMode: First unmount (cleanup) + NavigationFocusManager.destroy(); + + // Simulate StrictMode: Second mount + NavigationFocusManager.initialize(); + + // Then: Manager should still work correctly + const button = document.createElement('button'); + button.id = 'strict-mode-button'; + document.body.appendChild(button); + + // Register route BEFORE interaction (required for state-based validation) + NavigationFocusManager.registerFocusedRoute('strict-mode-route'); + + // Pointerdown should still capture + const pointerEvent = new PointerEvent('pointerdown', { + bubbles: true, + cancelable: true, + }); + Object.defineProperty(pointerEvent, 'target', {value: button}); + document.dispatchEvent(pointerEvent); + + NavigationFocusManager.captureForRoute('strict-mode-route'); + const retrieved = NavigationFocusManager.retrieveForRoute('strict-mode-route'); + + expect(retrieved).toBe(button); + }); + + it('should not duplicate event listeners after StrictMode cycle', () => { + // Given: Track how many times the handler is called + const originalAddEventListener = document.addEventListener.bind(document); + const listenerCalls: string[] = []; + + jest.spyOn(document, 'addEventListener').mockImplementation((type, listener, options) => { + listenerCalls.push(type); + return originalAddEventListener(type, listener, options); + }); + + // Reset and re-initialize to track calls + NavigationFocusManager.destroy(); + listenerCalls.length = 0; + + // Simulate StrictMode cycle + NavigationFocusManager.initialize(); // First mount + NavigationFocusManager.destroy(); // First unmount + NavigationFocusManager.initialize(); // Second mount + + // Then: Should only have listeners from final initialization + // (pointerdown, keydown = 2 listeners - visibilitychange was removed in state-based refactor) + const pointerdownCalls = listenerCalls.filter((t) => t === 'pointerdown').length; + expect(pointerdownCalls).toBe(2); // Once per initialize call + + jest.restoreAllMocks(); + }); + + it('should preserve no state across StrictMode cycle', () => { + // Given: Some state captured before destroy + const button = document.createElement('button'); + document.body.appendChild(button); + button.focus(); + NavigationFocusManager.captureForRoute('pre-destroy-route'); + + // When: StrictMode cycle occurs + NavigationFocusManager.destroy(); + NavigationFocusManager.initialize(); + + // Then: Old state should be cleared + const retrieved = NavigationFocusManager.retrieveForRoute('pre-destroy-route'); + expect(retrieved).toBeNull(); + }); + }); + + describe('Risk: Hot Reload Module Replacement', () => { + it('should de-duplicate listeners across module replacement and keep new instance active', () => { + const oldManager = NavigationFocusManager; + + jest.resetModules(); + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + const newManager = require('@libs/NavigationFocusManager/index.web').default as NavigationFocusManagerType; + newManager.initialize(); + + const button = document.createElement('button'); + button.id = 'hot-reload-button'; + document.body.appendChild(button); + + oldManager.registerFocusedRoute('hot-reload-old-route'); + newManager.registerFocusedRoute('hot-reload-new-route'); + + const pointerEvent = new PointerEvent('pointerdown', {bubbles: true, cancelable: true}); + Object.defineProperty(pointerEvent, 'target', {value: button}); + document.dispatchEvent(pointerEvent); + + const oldRetrieved = oldManager.retrieveForRoute('hot-reload-old-route'); + const newRetrieved = newManager.retrieveForRoute('hot-reload-new-route'); + + expect(oldRetrieved).toBeNull(); + expect(newRetrieved).toBe(button); + + oldManager.unregisterFocusedRoute('hot-reload-old-route'); + newManager.unregisterFocusedRoute('hot-reload-new-route'); + newManager.destroy(); + }); + + it('should keep new listeners active when stale old instance calls destroy()', () => { + const oldManager = NavigationFocusManager; + + jest.resetModules(); + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + const newManager = require('@libs/NavigationFocusManager/index.web').default as NavigationFocusManagerType; + newManager.initialize(); + + oldManager.destroy(); + + const button = document.createElement('button'); + button.id = 'stale-destroy-button'; + document.body.appendChild(button); + + newManager.registerFocusedRoute('stale-destroy-new-route'); + const pointerEvent = new PointerEvent('pointerdown', {bubbles: true, cancelable: true}); + Object.defineProperty(pointerEvent, 'target', {value: button}); + document.dispatchEvent(pointerEvent); + + const newRetrieved = newManager.retrieveForRoute('stale-destroy-new-route'); + expect(newRetrieved).toBe(button); + + newManager.unregisterFocusedRoute('stale-destroy-new-route'); + newManager.destroy(); + }); + + it('should keep new listeners active when stale old instance calls initialize()', () => { + const oldManager = NavigationFocusManager; + + jest.resetModules(); + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + const newManager = require('@libs/NavigationFocusManager/index.web').default as NavigationFocusManagerType; + newManager.initialize(); + + oldManager.initialize(); + + const button = document.createElement('button'); + button.id = 'stale-initialize-button'; + document.body.appendChild(button); + + oldManager.registerFocusedRoute('stale-initialize-old-route'); + newManager.registerFocusedRoute('stale-initialize-new-route'); + + const pointerEvent = new PointerEvent('pointerdown', {bubbles: true, cancelable: true}); + Object.defineProperty(pointerEvent, 'target', {value: button}); + document.dispatchEvent(pointerEvent); + + const oldRetrieved = oldManager.retrieveForRoute('stale-initialize-old-route'); + const newRetrieved = newManager.retrieveForRoute('stale-initialize-new-route'); + + expect(oldRetrieved).toBeNull(); + expect(newRetrieved).toBe(button); + + oldManager.unregisterFocusedRoute('stale-initialize-old-route'); + newManager.unregisterFocusedRoute('stale-initialize-new-route'); + newManager.destroy(); + }); + }); + + describe('Risk: Idempotent Initialization', () => { + it('should be safe to call initialize() multiple times', () => { + // Given: Manager already initialized + + // When: initialize() called multiple times + NavigationFocusManager.initialize(); + NavigationFocusManager.initialize(); + NavigationFocusManager.initialize(); + + // Then: Should still work correctly (no errors, single set of listeners) + const button = document.createElement('button'); + document.body.appendChild(button); + + // Register route BEFORE interaction (required for state-based validation) + NavigationFocusManager.registerFocusedRoute('idempotent-route'); + + const pointerEvent = new PointerEvent('pointerdown', { + bubbles: true, + cancelable: true, + }); + Object.defineProperty(pointerEvent, 'target', {value: button}); + document.dispatchEvent(pointerEvent); + + NavigationFocusManager.captureForRoute('idempotent-route'); + const retrieved = NavigationFocusManager.retrieveForRoute('idempotent-route'); + + expect(retrieved).toBe(button); + }); + }); + + describe('Risk: Event Listener Cleanup', () => { + it('should remove all event listeners on destroy', () => { + // Given: Track removeEventListener calls + const removedListeners: string[] = []; + const originalRemoveEventListener = document.removeEventListener.bind(document); + + jest.spyOn(document, 'removeEventListener').mockImplementation((type, listener, options) => { + removedListeners.push(type); + return originalRemoveEventListener(type, listener, options); + }); + + // When: destroy() is called + NavigationFocusManager.destroy(); + + // Then: Both listeners should be removed (visibilitychange was removed in state-based refactor) + expect(removedListeners).toContain('pointerdown'); + expect(removedListeners).toContain('keydown'); + + jest.restoreAllMocks(); + + // Re-initialize for next test + NavigationFocusManager.initialize(); + }); + }); + + describe('Risk: Capture Phase Event Handling', () => { + it('should capture element BEFORE bubbling phase handlers run', () => { + // Given: A button with a click handler that might change focus + const button = document.createElement('button'); + button.id = 'capture-phase-button'; + document.body.appendChild(button); + + // Register route BEFORE interaction (required for state-based validation) + NavigationFocusManager.registerFocusedRoute('capture-phase-route'); + + let capturedBeforeHandler = false; + let handlerRan = false; + + // Add a bubbling-phase click handler + button.addEventListener('click', () => { + handlerRan = true; + // Check if we already captured the element + NavigationFocusManager.captureForRoute('capture-phase-route'); + const retrieved = NavigationFocusManager.retrieveForRoute('capture-phase-route'); + if (retrieved === button) { + capturedBeforeHandler = true; + } + }); + + // When: Pointerdown fires (capture phase) + const pointerEvent = new PointerEvent('pointerdown', { + bubbles: true, + cancelable: true, + }); + Object.defineProperty(pointerEvent, 'target', {value: button}); + document.dispatchEvent(pointerEvent); + + // Simulate click + button.click(); + + // Then: Element should have been captured before handler ran + expect(handlerRan).toBe(true); + expect(capturedBeforeHandler).toBe(true); + }); + + it('should not interfere with other capture-phase listeners', () => { + // Given: Another capture-phase listener + let otherListenerCalled = false; + const otherListener = () => { + otherListenerCalled = true; + }; + document.addEventListener('pointerdown', otherListener, {capture: true}); + + // Register route BEFORE interaction (required for state-based validation) + NavigationFocusManager.registerFocusedRoute('coexist-route'); + + // When: Pointerdown fires + const button = document.createElement('button'); + document.body.appendChild(button); + + const pointerEvent = new PointerEvent('pointerdown', { + bubbles: true, + cancelable: true, + }); + Object.defineProperty(pointerEvent, 'target', {value: button}); + document.dispatchEvent(pointerEvent); + + // Then: Both listeners should have been called + expect(otherListenerCalled).toBe(true); + + // And: NavigationFocusManager should have captured + NavigationFocusManager.captureForRoute('coexist-route'); + const retrieved = NavigationFocusManager.retrieveForRoute('coexist-route'); + expect(retrieved).toBe(button); + + // Cleanup + document.removeEventListener('pointerdown', otherListener, {capture: true}); + }); + + it('should not prevent default or stop propagation', () => { + // Given: A button with handlers checking event state + const button = document.createElement('button'); + document.body.appendChild(button); + + let eventDefaultPrevented = false; + + button.addEventListener('pointerdown', (e) => { + eventDefaultPrevented = e.defaultPrevented; + }); + + // When: Pointerdown fires through NavigationFocusManager + const pointerEvent = new PointerEvent('pointerdown', { + bubbles: true, + cancelable: true, + }); + Object.defineProperty(pointerEvent, 'target', {value: button}); + button.dispatchEvent(pointerEvent); + + // Then: Event should not have been prevented or stopped + expect(eventDefaultPrevented).toBe(false); + }); + }); + + describe('State-Based Route Validation', () => { + it('should reject capture from a different route', () => { + // Given: A button on route-A + const buttonA = document.createElement('button'); + buttonA.id = 'route-a-button'; + document.body.appendChild(buttonA); + + // Register route-A as the current focused route + NavigationFocusManager.registerFocusedRoute('route-A'); + + // Capture button on route-A + const pointerEvent = new PointerEvent('pointerdown', { + bubbles: true, + cancelable: true, + }); + Object.defineProperty(pointerEvent, 'target', {value: buttonA}); + document.dispatchEvent(pointerEvent); + + // Unregister route-A + NavigationFocusManager.unregisterFocusedRoute('route-A'); + + // When: Try to capture for a DIFFERENT route (route-B) + // The capture belongs to route-A, not route-B + NavigationFocusManager.captureForRoute('route-B'); + + // Then: Retrieval for route-B should use activeElement fallback, not the route-A capture + // This is because the capture's forRoute (route-A) doesn't match the requested route (route-B) + const retrieved = NavigationFocusManager.retrieveForRoute('route-B'); + // The button should NOT be retrieved since it was captured for a different route + // (it will fall back to activeElement which is body in JSDOM) + expect(retrieved).not.toBe(buttonA); + }); + + it('should accept capture from the same route', () => { + // Given: A button on route-A + const button = document.createElement('button'); + button.id = 'same-route-button'; + document.body.appendChild(button); + + // Register route-A as the current focused route + NavigationFocusManager.registerFocusedRoute('route-A'); + + // Capture button + const pointerEvent = new PointerEvent('pointerdown', { + bubbles: true, + cancelable: true, + }); + Object.defineProperty(pointerEvent, 'target', {value: button}); + document.dispatchEvent(pointerEvent); + + // Unregister route-A + NavigationFocusManager.unregisterFocusedRoute('route-A'); + + // When: Capture for route-A (matching the capture's forRoute) + NavigationFocusManager.captureForRoute('route-A'); + + // Then: Retrieval should return the button + const retrieved = NavigationFocusManager.retrieveForRoute('route-A'); + expect(retrieved).toBe(button); + }); + + it('should reject capture with null forRoute for safety', () => { + // Given: A button captured BEFORE any route is registered + const button = document.createElement('button'); + button.id = 'null-route-button'; + document.body.appendChild(button); + + // Don't register any route - forRoute will be null + + // Capture button (forRoute will be null) + const pointerEvent = new PointerEvent('pointerdown', { + bubbles: true, + cancelable: true, + }); + Object.defineProperty(pointerEvent, 'target', {value: button}); + document.dispatchEvent(pointerEvent); + + // When: Try to capture for any route + NavigationFocusManager.captureForRoute('some-route'); + + // Then: Should fall back to activeElement since forRoute was null + const retrieved = NavigationFocusManager.retrieveForRoute('some-route'); + // The null-forRoute capture is rejected, falls back to activeElement + expect(retrieved).not.toBe(button); + }); + }); + + describe('cleanupRemovedRoutes', () => { + it('should clean up focus data for routes not in navigation state', () => { + // Given: Focus captured for multiple routes + const buttonA = document.createElement('button'); + buttonA.id = 'button-a'; + document.body.appendChild(buttonA); + buttonA.focus(); + NavigationFocusManager.captureForRoute('route-A-key'); + + const buttonB = document.createElement('button'); + buttonB.id = 'button-b'; + document.body.appendChild(buttonB); + buttonB.focus(); + NavigationFocusManager.captureForRoute('route-B-key'); + + // Verify both have stored focus + // Note: hasStoredFocus doesn't consume the entry + // We need to re-capture since captureForRoute clears lastInteractionCapture + buttonA.focus(); + NavigationFocusManager.captureForRoute('route-A-key'); + buttonB.focus(); + NavigationFocusManager.captureForRoute('route-B-key'); + + expect(NavigationFocusManager.hasStoredFocus('route-A-key')).toBe(true); + expect(NavigationFocusManager.hasStoredFocus('route-B-key')).toBe(true); + + // When: cleanupRemovedRoutes is called with state containing only route-A + const mockNavigationState = { + routes: [{key: 'route-A-key', name: 'ScreenA'}], + index: 0, + stale: false, + type: 'stack', + key: 'root', + routeNames: ['ScreenA'], + }; + NavigationFocusManager.cleanupRemovedRoutes(mockNavigationState); + + // Then: route-A should still have focus data, route-B should be cleaned up + expect(NavigationFocusManager.hasStoredFocus('route-A-key')).toBe(true); + expect(NavigationFocusManager.hasStoredFocus('route-B-key')).toBe(false); + }); + + it('should preserve focus data for routes still in navigation state', () => { + // Given: Focus captured for a route + const button = document.createElement('button'); + button.id = 'preserved-button'; + document.body.appendChild(button); + button.focus(); + NavigationFocusManager.captureForRoute('preserved-route-key'); + + expect(NavigationFocusManager.hasStoredFocus('preserved-route-key')).toBe(true); + + // When: cleanupRemovedRoutes is called with state containing the route + const mockNavigationState = { + routes: [{key: 'preserved-route-key', name: 'PreservedScreen'}], + index: 0, + stale: false, + type: 'stack', + key: 'root', + routeNames: ['PreservedScreen'], + }; + NavigationFocusManager.cleanupRemovedRoutes(mockNavigationState); + + // Then: Focus data should still be available + expect(NavigationFocusManager.hasStoredFocus('preserved-route-key')).toBe(true); + + // And retrieval should work + const retrieved = NavigationFocusManager.retrieveForRoute('preserved-route-key'); + expect(retrieved).toBe(button); + }); + + it('should handle nested routes in navigation state', () => { + // Given: Focus captured for a nested route + const button = document.createElement('button'); + button.id = 'nested-route-button'; + document.body.appendChild(button); + button.focus(); + NavigationFocusManager.captureForRoute('nested-screen-key'); + + expect(NavigationFocusManager.hasStoredFocus('nested-screen-key')).toBe(true); + + // When: cleanupRemovedRoutes is called with nested state structure + const mockNavigationState = { + routes: [ + { + key: 'navigator-key', + name: 'Navigator', + state: { + routes: [{key: 'nested-screen-key', name: 'NestedScreen'}], + index: 0, + }, + }, + ], + index: 0, + stale: false, + type: 'stack', + key: 'root', + routeNames: ['Navigator'], + }; + NavigationFocusManager.cleanupRemovedRoutes(mockNavigationState); + + // Then: Nested route's focus data should be preserved + expect(NavigationFocusManager.hasStoredFocus('nested-screen-key')).toBe(true); + }); + }); + }); +}); diff --git a/tests/unit/useAutoFocusInputTest.ts b/tests/unit/useAutoFocusInputTest.ts new file mode 100644 index 0000000000000..6fc9a53edb196 --- /dev/null +++ b/tests/unit/useAutoFocusInputTest.ts @@ -0,0 +1,301 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import {act, renderHook} from '@testing-library/react-native'; +import type {TextInput} from 'react-native'; +import {InteractionManager} from 'react-native'; +import useAutoFocusInput from '@hooks/useAutoFocusInput'; +import ComposerFocusManager from '@libs/ComposerFocusManager'; +import isWindowReadyToFocus from '@libs/isWindowReadyToFocus'; +import CONST from '@src/CONST'; + +type TransitionEndListener = (event?: {data?: {closing?: boolean}}) => void; + +let capturedFocusEffect: (() => void | (() => void)) | undefined; +let capturedTransitionEndListener: TransitionEndListener | undefined; + +const mockAddListener = jest.fn(); +const mockSidePanelState = {isSidePanelTransitionEnded: false, shouldHideSidePanel: false}; + +jest.mock('@react-navigation/native', () => ({ + useFocusEffect: (callback: () => void | (() => void)) => { + capturedFocusEffect = callback; + }, + useNavigation: () => ({ + addListener: mockAddListener, + }), +})); + +jest.mock('@hooks/useOnyx', () => ({ + __esModule: true, + default: () => [null], +})); + +jest.mock('@src/SplashScreenStateContext', () => ({ + useSplashScreenState: () => ({ + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + splashScreenState: require('@src/CONST').default.BOOT_SPLASH_STATE.HIDDEN, + }), +})); + +jest.mock('@hooks/useSidePanelState', () => ({ + __esModule: true, + default: () => mockSidePanelState, +})); + +jest.mock('@libs/ComposerFocusManager', () => ({ + __esModule: true, + default: { + isReadyToFocus: jest.fn(() => Promise.resolve(true)), + }, +})); + +jest.mock('@libs/isWindowReadyToFocus', () => ({ + __esModule: true, + default: jest.fn(() => Promise.resolve()), +})); + +const originalDocumentDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'document'); +let originalActiveElementDescriptor: PropertyDescriptor | undefined; +let activeElement: unknown; + +function createDeferredPromise() { + let resolvePromise: (() => void) | undefined; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + + return { + promise, + resolve: () => resolvePromise?.(), + }; +} + +function createInput() { + const focusOwner = document.createElement('input'); + const focus = jest.fn(); + const isFocused = jest.fn(() => activeElement === focusOwner); + const input = {} as TextInput; + input.focus = focus; + input.isFocused = isFocused; + + return { + focus, + input, + }; +} + +async function flushPromises() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +describe('useAutoFocusInput', () => { + beforeAll(() => { + if (!globalThis.document) { + Object.defineProperty(globalThis, 'document', { + value: {body: {}, documentElement: {}} as Document, + configurable: true, + }); + } + + originalActiveElementDescriptor = Object.getOwnPropertyDescriptor(document, 'activeElement'); + Object.defineProperty(document, 'activeElement', { + configurable: true, + get: () => activeElement as Element | null, + }); + }); + + beforeEach(() => { + capturedFocusEffect = undefined; + capturedTransitionEndListener = undefined; + activeElement = document.body; + mockSidePanelState.isSidePanelTransitionEnded = false; + mockSidePanelState.shouldHideSidePanel = false; + + jest.clearAllMocks(); + jest.useFakeTimers(); + + mockAddListener.mockImplementation((eventName: string, callback: TransitionEndListener) => { + if (eventName === 'transitionEnd') { + capturedTransitionEndListener = callback; + } + return jest.fn(); + }); + + jest.spyOn(InteractionManager, 'runAfterInteractions').mockImplementation((task?: (() => void) | {gen?: () => void}) => { + if (typeof task === 'function') { + task(); + } else { + task?.gen?.(); + } + + return { + then: jest.fn(() => Promise.resolve()), + done: jest.fn(), + cancel: jest.fn(), + } as never; + }); + }); + + afterEach(() => { + act(() => { + jest.runOnlyPendingTimers(); + }); + jest.restoreAllMocks(); + jest.useRealTimers(); + }); + + afterAll(() => { + if (originalActiveElementDescriptor) { + Object.defineProperty(document, 'activeElement', originalActiveElementDescriptor); + } + + if (originalDocumentDescriptor) { + Object.defineProperty(globalThis, 'document', originalDocumentDescriptor); + return; + } + + Reflect.deleteProperty(globalThis as typeof globalThis & {document?: Document}, 'document'); + }); + + it('autofocuses after transitionEnd when no other element owns focus', async () => { + const {focus, input} = createInput(); + const {result} = renderHook(() => useAutoFocusInput()); + + act(() => { + result.current.inputCallbackRef(input); + capturedFocusEffect?.(); + }); + + expect(mockAddListener).toHaveBeenCalledWith('transitionEnd', expect.any(Function)); + + await act(async () => { + capturedTransitionEndListener?.({data: {closing: false}}); + }); + await flushPromises(); + + expect(focus).toHaveBeenCalledTimes(1); + }); + + it('autofocuses via timeout fallback when transitionEnd does not fire', async () => { + const {focus, input} = createInput(); + const {result} = renderHook(() => useAutoFocusInput()); + + act(() => { + result.current.inputCallbackRef(input); + capturedFocusEffect?.(); + jest.advanceTimersByTime(CONST.SCREEN_TRANSITION_END_TIMEOUT); + }); + await flushPromises(); + + expect(focus).toHaveBeenCalledTimes(1); + }); + + it('re-arms autofocus on a later screen focus', async () => { + const {focus, input} = createInput(); + const {result} = renderHook(() => useAutoFocusInput()); + + act(() => { + result.current.inputCallbackRef(input); + }); + + let cleanup: void | (() => void) | undefined; + act(() => { + cleanup = capturedFocusEffect?.(); + }); + + await act(async () => { + capturedTransitionEndListener?.({data: {closing: false}}); + }); + await flushPromises(); + + act(() => { + cleanup?.(); + cleanup = capturedFocusEffect?.(); + }); + + await act(async () => { + capturedTransitionEndListener?.({data: {closing: false}}); + }); + await flushPromises(); + + expect(focus).toHaveBeenCalledTimes(2); + }); + + it('does not steal focus if another element becomes active before focus executes', async () => { + const deferred = createDeferredPromise(); + jest.mocked(isWindowReadyToFocus).mockReturnValueOnce(deferred.promise); + + const {focus, input} = createInput(); + const otherFocusedElement = {} as HTMLElement; + const {result} = renderHook(() => useAutoFocusInput()); + + act(() => { + result.current.inputCallbackRef(input); + capturedFocusEffect?.(); + }); + + await act(async () => { + capturedTransitionEndListener?.({data: {closing: false}}); + }); + + activeElement = otherFocusedElement; + + await act(async () => { + deferred.resolve(); + await deferred.promise; + }); + + expect(focus).not.toHaveBeenCalled(); + }); + + it('recovers after skipping focus while another element is active', async () => { + const {focus, input} = createInput(); + const otherFocusedElement = {} as HTMLElement; + const {result} = renderHook(() => useAutoFocusInput()); + + act(() => { + result.current.inputCallbackRef(input); + capturedFocusEffect?.(); + }); + + activeElement = otherFocusedElement; + + await act(async () => { + capturedTransitionEndListener?.({data: {closing: false}}); + }); + await flushPromises(); + + expect(focus).not.toHaveBeenCalled(); + + activeElement = document.body; + + await act(async () => { + capturedTransitionEndListener?.({data: {closing: false}}); + }); + await flushPromises(); + + expect(focus).toHaveBeenCalledTimes(1); + }); + + it('still autofocuses when the side panel finishes closing', async () => { + const {focus, input} = createInput(); + const {result, rerender} = renderHook(() => useAutoFocusInput()); + + act(() => { + result.current.inputCallbackRef(input); + }); + + mockSidePanelState.shouldHideSidePanel = true; + rerender(undefined); + + mockSidePanelState.isSidePanelTransitionEnded = true; + rerender(undefined); + await flushPromises(); + + expect(jest.mocked(ComposerFocusManager.isReadyToFocus)).toHaveBeenCalled(); + expect(jest.mocked(isWindowReadyToFocus)).toHaveBeenCalled(); + expect(focus).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/useSyncFocusTest.ts b/tests/unit/useSyncFocusTest.ts index f4a1c7f66fe05..1354195b08977 100644 --- a/tests/unit/useSyncFocusTest.ts +++ b/tests/unit/useSyncFocusTest.ts @@ -36,4 +36,16 @@ describe('useSyncFocus', () => { // Then the ref focus will be called. expect(refMock.current.focus).toHaveBeenCalled(); }); + + it("doesn't steal focus from another already-focused element", () => { + const refMock = {current: {focus: jest.fn()}}; + const activeButton = document.createElement('button'); + document.body.appendChild(activeButton); + activeButton.focus(); + + renderHook(() => useSyncFocus(refMock as unknown as RefObject, true, true)); + + expect(document.activeElement).toBe(activeButton); + expect(refMock.current.focus).not.toHaveBeenCalled(); + }); });