From 6baa39b1c4f6c228b0011592c4edd8fa33e66a84 Mon Sep 17 00:00:00 2001 From: Marc Glasser Date: Thu, 19 Mar 2026 13:30:19 -1000 Subject: [PATCH] Revert "fix: [Web] Focus restoration mechanism on back navigation (#76921)" --- src/App.tsx | 13 +- src/components/ApprovalWorkflowSection.tsx | 6 +- .../ButtonWithDropdownMenu/index.tsx | 58 +- src/components/ConfirmModal.tsx | 152 +- .../ConfirmModal/focusRestore/index.ts | 12 - .../ConfirmModal/focusRestore/index.web.ts | 60 - .../ConfirmModal/focusRestore/types.ts | 13 - .../FocusTrapForScreen/index.web.tsx | 176 +- .../FocusTrap/__mocks__/TOP_TAB_SCREENS.ts | 7 - .../__mocks__/WIDE_LAYOUT_INACTIVE_SCREENS.ts | 7 - .../FocusTrap/__mocks__/sharedTrapStack.ts | 8 - src/components/MenuItem.tsx | 25 +- .../MoneyRequestConfirmationList.tsx | 6 +- src/components/PopoverMenu.tsx | 104 +- src/components/PopoverMenuUtils.ts | 61 - .../ScreenWrapper/ScreenWrapperContainer.tsx | 42 +- src/components/ThreeDotsMenu/index.tsx | 23 - src/hooks/useAutoFocusInput.ts | 30 +- .../useSyncFocusImplementation.ts | 7 - .../blurActiveInputElement/index.native.ts | 5 - .../blurActiveInputElement/index.ts | 19 - .../blurActiveInputElement/types.ts | 3 - src/libs/Navigation/NavigationRoot.tsx | 2 - src/libs/NavigationFocusManager/constants.ts | 5 - src/libs/NavigationFocusManager/index.ts | 18 - src/libs/NavigationFocusManager/index.web.ts | 825 ------ src/libs/NavigationFocusManager/types.ts | 18 - src/libs/focusComposerWithDelay/index.ts | 4 +- src/libs/focusUtils/index.native.ts | 8 - src/libs/focusUtils/index.ts | 3 - src/libs/focusUtils/index.web.ts | 45 - src/libs/focusUtils/types.ts | 6 - .../ComposerWithSuggestions.tsx | 15 - src/pages/tasks/NewTaskPage.tsx | 4 +- src/pages/workspace/WorkspaceNamePage.tsx | 4 +- ...uttonWithDropdownMenuFocusCoverageTest.tsx | 175 -- tests/ui/components/PopoverMenu.tsx | 266 +- .../ScreenWrapperRouteBoundaryTest.tsx | 151 -- .../ThreeDotsMenuFocusCoverageTest.tsx | 207 -- tests/unit/MenuItemInteractivePropsTest.tsx | 249 -- .../ConfirmModalIntegrationTest.tsx | 198 -- .../ConfirmModal/focusRestoreTest.ts | 106 - .../FocusTrap/FocusTrapForScreenTest.tsx | 724 ------ .../Modal/ModalNavigationBackTest.tsx | 183 -- .../BlurActiveInputElementTest.ts | 59 - tests/unit/libs/FocusUtilsTest.ts | 222 -- ...yboardIntentArbitrationIntegrationTest.tsx | 248 -- .../unit/libs/NavigationFocusManagerTest.tsx | 2238 ----------------- tests/unit/useAutoFocusInputTest.ts | 301 --- tests/unit/useSyncFocusTest.ts | 12 - 50 files changed, 148 insertions(+), 6985 deletions(-) delete mode 100644 src/components/ConfirmModal/focusRestore/index.ts delete mode 100644 src/components/ConfirmModal/focusRestore/index.web.ts delete mode 100644 src/components/ConfirmModal/focusRestore/types.ts delete mode 100644 src/components/FocusTrap/__mocks__/TOP_TAB_SCREENS.ts delete mode 100644 src/components/FocusTrap/__mocks__/WIDE_LAYOUT_INACTIVE_SCREENS.ts delete mode 100644 src/components/FocusTrap/__mocks__/sharedTrapStack.ts delete mode 100644 src/components/PopoverMenuUtils.ts delete mode 100644 src/libs/Accessibility/blurActiveInputElement/index.native.ts delete mode 100644 src/libs/Accessibility/blurActiveInputElement/index.ts delete mode 100644 src/libs/Accessibility/blurActiveInputElement/types.ts delete mode 100644 src/libs/NavigationFocusManager/constants.ts delete mode 100644 src/libs/NavigationFocusManager/index.ts delete mode 100644 src/libs/NavigationFocusManager/index.web.ts delete mode 100644 src/libs/NavigationFocusManager/types.ts delete mode 100644 src/libs/focusUtils/index.native.ts delete mode 100644 src/libs/focusUtils/index.ts delete mode 100644 src/libs/focusUtils/index.web.ts delete mode 100644 src/libs/focusUtils/types.ts delete mode 100644 tests/ui/components/ButtonWithDropdownMenuFocusCoverageTest.tsx delete mode 100644 tests/ui/components/ScreenWrapperRouteBoundaryTest.tsx delete mode 100644 tests/ui/components/ThreeDotsMenuFocusCoverageTest.tsx delete mode 100644 tests/unit/MenuItemInteractivePropsTest.tsx delete mode 100644 tests/unit/components/ConfirmModal/ConfirmModalIntegrationTest.tsx delete mode 100644 tests/unit/components/ConfirmModal/focusRestoreTest.ts delete mode 100644 tests/unit/components/FocusTrap/FocusTrapForScreenTest.tsx delete mode 100644 tests/unit/components/Modal/ModalNavigationBackTest.tsx delete mode 100644 tests/unit/libs/Accessibility/BlurActiveInputElementTest.ts delete mode 100644 tests/unit/libs/FocusUtilsTest.ts delete mode 100644 tests/unit/libs/KeyboardIntentArbitrationIntegrationTest.tsx delete mode 100644 tests/unit/libs/NavigationFocusManagerTest.tsx delete mode 100644 tests/unit/useAutoFocusInputTest.ts diff --git a/src/App.tsx b/src/App.tsx index 3f0117cf275de..8ef6d1a55f195 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, {useEffect} from 'react'; +import React from 'react'; import {LogBox, View} from 'react-native'; import {GestureHandlerRootView} from 'react-native-gesture-handler'; import {PickerStateProvider} from 'react-native-picker-select'; @@ -51,7 +51,6 @@ 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'; @@ -76,16 +75,6 @@ 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 7a4ef6dd9a1f1..c218909a6a3d7 100644 --- a/src/components/ApprovalWorkflowSection.tsx +++ b/src/components/ApprovalWorkflowSection.tsx @@ -71,8 +71,6 @@ function ApprovalWorkflowSection({approvalWorkflow, onPress, currency = CONST.CU )} - {/* MenuItems are display-only (interactive={false}) because the outer - PressableWithoutFeedback handles all click interactions. */} @@ -108,7 +106,7 @@ function ApprovalWorkflowSection({approvalWorkflow, onPress, currency = CONST.CU iconWidth={20} numberOfLinesDescription={1} iconFill={theme.icon} - interactive={false} + onPress={onPress} 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 014b9bba18de0..afe40ec903efc 100644 --- a/src/components/ButtonWithDropdownMenu/index.tsx +++ b/src/components/ButtonWithDropdownMenu/index.tsx @@ -1,4 +1,4 @@ -import type {RefCallback} from 'react'; +import type {RefObject} from 'react'; import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; import {View} from 'react-native'; import type {GestureResponderEvent} from 'react-native'; @@ -14,11 +14,9 @@ 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 = { @@ -27,8 +25,6 @@ const defaultAnchorAlignment = { vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP, }; -type DropdownAnchor = View | HTMLDivElement | null; - function ButtonWithDropdownMenu({ref, ...props}: ButtonWithDropdownMenuProps) { const { success = true, @@ -78,22 +74,18 @@ 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 [wasOpenedViaKeyboard, setWasOpenedViaKeyboard] = useState(false); + const dropdownAnchor = useRef(null); // 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 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 dropdownButtonRef = isSplitButton ? buttonRef : mergeRefs(buttonRef, dropdownAnchor); 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(() => { @@ -109,7 +101,7 @@ function ButtonWithDropdownMenu({ref, ...props}: ButtonWithDropdownM return; } - calculatePopoverPosition(viewRef(dropdownAnchor), anchorAlignment).then(setPopoverAnchorPosition); + calculatePopoverPosition(dropdownAnchor, anchorAlignment).then(setPopoverAnchorPosition); }, [isMenuVisible, calculatePopoverPosition, anchorAlignment]); const handleSingleOptionPress = useCallback( @@ -131,27 +123,12 @@ 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) { - toggleMenu(); + setIsMenuVisible(!isMenuVisible); return; } if (selectedItem?.value) { @@ -173,28 +150,18 @@ function ButtonWithDropdownMenu({ref, ...props}: ButtonWithDropdownM const handlePress = useCallback( (event?: GestureResponderEvent | KeyboardEvent) => { if (!isSplitButton) { - toggleMenu(); + setIsMenuVisible(!isMenuVisible); } else if (selectedItem?.value) { onPress(event, selectedItem.value); } }, - [isSplitButton, onPress, selectedItem?.value, toggleMenu], + [isMenuVisible, isSplitButton, onPress, selectedItem?.value], ); 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 ? ( @@ -232,12 +199,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 deleted file mode 100644 index 6fc9a53edb196..0000000000000 --- a/tests/unit/useAutoFocusInputTest.ts +++ /dev/null @@ -1,301 +0,0 @@ -/* 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 1354195b08977..f4a1c7f66fe05 100644 --- a/tests/unit/useSyncFocusTest.ts +++ b/tests/unit/useSyncFocusTest.ts @@ -36,16 +36,4 @@ 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(); - }); });