diff --git a/knip.json b/knip.json index 3747de5784d7..9f4660634c26 100644 --- a/knip.json +++ b/knip.json @@ -52,6 +52,7 @@ }, "ignoreDependencies": [ "@expensify/react-native-hybrid-app", + "focus-trap", "group-ib-fp", "react-native-image-size", "react-native-picker-select", diff --git a/src/components/DialogLabelContext.tsx b/src/components/DialogLabelContext.tsx index 5ada08ee786f..e3c906813512 100644 --- a/src/components/DialogLabelContext.tsx +++ b/src/components/DialogLabelContext.tsx @@ -1,5 +1,6 @@ -import React, {createContext, useContext, useRef} from 'react'; +import React, {createContext, useContext, useEffect, useLayoutEffect, useRef} from 'react'; import type {View} from 'react-native'; +import isHTMLElement from '@libs/isHTMLElement'; type LabelEntry = {id: number; text: string}; @@ -27,18 +28,34 @@ const DialogLabelActionsContext = createContext({ type DialogLabelProviderProps = { children: React.ReactNode; - containerRef: React.RefObject; + /** Pass via `useState`/callback-ref so the provider observes node identity changes; a `RefObject` would pin the MutationObserver to the original node across Animated.View remounts. */ + containerNode: View | HTMLElement | null; }; -function DialogLabelProvider({children, containerRef}: DialogLabelProviderProps) { +// Title-stack and initial-focus claim are co-located: each pushLabel re-arms the focus claim so a sub-screen re-receives initial focus. +function DialogLabelProvider({children, containerNode}: DialogLabelProviderProps) { const nextIdRef = useRef(0); const labelStackRef = useRef([]); const initialFocusClaimedRef = useRef(false); + // Stable RefObject for `.current` consumers (e.g. useDialogContainerFocus reads inside a rAF after node swaps). + const containerRef = useRef(null); + useLayoutEffect(() => { + containerRef.current = (containerNode as View | null) ?? null; + }, [containerNode]); const updateContainerLabel = () => { + if (typeof document === 'undefined') { + return; + } const top = labelStackRef.current.at(-1); - const node = containerRef.current as unknown as HTMLElement | null; - if (!node || typeof node.setAttribute !== 'function') { + const node = containerRef.current; + if (!isHTMLElement(node)) { + return; + } + // aria-label on a container without dialog semantics is ignored; skip the set on mobile where the RHP has no dialog role. + const hasDialogSemantics = node.getAttribute('role') === 'dialog' || node.getAttribute('aria-modal') === 'true'; + if (!hasDialogSemantics) { + node.removeAttribute('aria-label'); return; } if (top?.text) { @@ -61,6 +78,19 @@ function DialogLabelProvider({children, containerRef}: DialogLabelProviderProps) updateContainerLabel(); }; + // Observe `role`/`aria-modal` so a viewport-resize flip re-applies the label; the initial call back-fills labels pushed by child effects before this parent effect ran. + useEffect(() => { + if (typeof MutationObserver === 'undefined' || !isHTMLElement(containerNode)) { + return; + } + const observer = new MutationObserver(updateContainerLabel); + observer.observe(containerNode, {attributes: true, attributeFilter: ['role', 'aria-modal']}); + updateContainerLabel(); + return () => { + observer.disconnect(); + }; + }, [containerNode, updateContainerLabel]); + const claimInitialFocus = (): boolean => { if (initialFocusClaimedRef.current) { return false; diff --git a/src/components/FocusTrap/FocusTrapForModal/index.web.tsx b/src/components/FocusTrap/FocusTrapForModal/index.web.tsx index 466855547409..fb0260912ac7 100644 --- a/src/components/FocusTrap/FocusTrapForModal/index.web.tsx +++ b/src/components/FocusTrap/FocusTrapForModal/index.web.tsx @@ -1,9 +1,10 @@ import {FocusTrap} from 'focus-trap-react'; import React, {useRef} from 'react'; -import sharedTrapStack from '@components/FocusTrap/sharedTrapStack'; import blurActiveElement from '@libs/Accessibility/blurActiveElement'; -import {scheduleClearActivePopoverLauncher, setActivePopoverLauncher} from '@libs/LauncherStack'; +import {markActivePopoverLauncherDeactivated, setActivePopoverLauncher} from '@libs/LauncherStack'; import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; +import restoreFocusWithModality from '@libs/restoreFocusWithModality'; +import sharedTrapStack from '@libs/sharedTrapStack'; import type FocusTrapForModalProps from './FocusTrapForModalProps'; function FocusTrapForModal({children, active, initialFocus = false, shouldPreventScroll = false, shouldReturnFocus = true}: FocusTrapForModalProps) { @@ -28,8 +29,11 @@ function FocusTrapForModal({children, active, initialFocus = false, shouldPreven if (!launcher) { return; } - // Deferred so popover paths that navigate after modal-hide can still consume. - scheduleClearActivePopoverLauncher(launcher); + // Mark first so a throw in restoreFocusWithModality can't leak the LauncherStack entry; the deferred clear keeps the post-hide capture window. + markActivePopoverLauncherDeactivated(launcher); + if (shouldReturnFocus && !ReportActionComposeFocusManager.isFocused() && document.contains(launcher)) { + restoreFocusWithModality(launcher, {preventScroll: shouldPreventScroll}); + } }, preventScroll: shouldPreventScroll, trapStack: sharedTrapStack, @@ -37,15 +41,7 @@ function FocusTrapForModal({children, active, initialFocus = false, shouldPreven initialFocus, // Lazy so document.body isn't evaluated at render time (SSR-safe). fallbackFocus: () => document.body, - setReturnFocus: (element) => { - if (ReportActionComposeFocusManager.isFocused()) { - return false; - } - if (shouldReturnFocus) { - return element; - } - return false; - }, + setReturnFocus: false, }} > {children} diff --git a/src/components/FocusTrap/FocusTrapForScreen/index.web.tsx b/src/components/FocusTrap/FocusTrapForScreen/index.web.tsx index 972053a02aa6..21d6256bd3b3 100644 --- a/src/components/FocusTrap/FocusTrapForScreen/index.web.tsx +++ b/src/components/FocusTrap/FocusTrapForScreen/index.web.tsx @@ -1,12 +1,12 @@ import {useIsFocused, useRoute} from '@react-navigation/native'; import {FocusTrap} from 'focus-trap-react'; import React, {useMemo} from 'react'; -import sharedTrapStack from '@components/FocusTrap/sharedTrapStack'; import TOP_TAB_SCREENS from '@components/FocusTrap/TOP_TAB_SCREENS'; import WIDE_LAYOUT_INACTIVE_SCREENS from '@components/FocusTrap/WIDE_LAYOUT_INACTIVE_SCREENS'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import {isCancellingDndKeyboardDrag} from '@libs/cancelDndKeyboardDrag'; import {isSidebarScreenName} from '@libs/Navigation/helpers/isNavigatorName'; +import sharedTrapStack from '@libs/sharedTrapStack'; import CONST from '@src/CONST'; import type FocusTrapProps from './FocusTrapProps'; diff --git a/src/components/HeaderWithBackButton/index.tsx b/src/components/HeaderWithBackButton/index.tsx index f1158dd3b63e..4d116bb8f619 100755 --- a/src/components/HeaderWithBackButton/index.tsx +++ b/src/components/HeaderWithBackButton/index.tsx @@ -13,6 +13,7 @@ import SidePanelButton from '@components/SidePanel/SidePanelButton'; import ThreeDotsMenu from '@components/ThreeDotsMenu'; import Tooltip from '@components/Tooltip'; import useDialogLabelRegistration from '@hooks/useDialogLabelRegistration'; +import useInitialFocusRef from '@hooks/useInitialFocusRef'; import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -90,6 +91,7 @@ function HeaderWithBackButton({ const [isDownloadButtonActive, temporarilyDisableDownloadButton] = useThrottledButtonState(); const {translate} = useLocalize(); const isInLandscapeMode = useIsInLandscapeMode(); + const setBackButtonRef = useInitialFocusRef({shouldSkip: shouldSkipFocusAfterTransition}); const downloadReasonAttributes = useMemo( () => ({ @@ -244,6 +246,7 @@ function HeaderWithBackButton({ {shouldShowBackButton && ( { if (Keyboard.isVisible()) { Keyboard.dismiss(); diff --git a/src/components/MoneyRequestConfirmationList/sections/TaxFields.tsx b/src/components/MoneyRequestConfirmationList/sections/TaxFields.tsx index f8a6a77abc6a..44bf6713fff8 100644 --- a/src/components/MoneyRequestConfirmationList/sections/TaxFields.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/TaxFields.tsx @@ -121,7 +121,8 @@ function TaxFields({policy, policyForMovingExpenses, iouCurrencyCode, canModifyT return ( <> ) : ( (null); + const composedRef = useMemo(() => mergeRefs(ref, internalRef), [ref]); + const routeKey = useRouteKey(); + // `||` so empty strings skip — never key off an empty prop. + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const focusIdentifier = rest.id || rest.nativeID || rest.testID || undefined; + + useEffect(() => { + if (screenReaderState === 'disabled' || !routeKey || !focusIdentifier) { + return; + } + return registerPressable(routeKey, focusIdentifier, internalRef); + }, [screenReaderState, routeKey, focusIdentifier]); const isDisabled = useMemo(() => { let shouldBeDisabledByScreenReader = false; @@ -123,9 +139,10 @@ function GenericPressable({ ref.current?.blur(); Accessibility.moveAccessibilityFocus(nextFocusRef); } + notifyPressedTrigger(internalRef, focusIdentifier); return onPress(event); }, - [shouldUseHapticsOnPress, onPress, nextFocusRef, ref, isDisabled, interactive], + [shouldUseHapticsOnPress, onPress, nextFocusRef, ref, isDisabled, interactive, focusIdentifier], ); const voidOnPressHandler = useCallback( @@ -176,7 +193,7 @@ function GenericPressable({ } + ref={composedRef} disabled={fullDisabled || undefined} onPress={!isDisabled ? singleExecution(onPressHandler) : undefined} onLongPress={!isDisabled && onLongPress ? onLongPressHandler : undefined} diff --git a/src/components/Search/SearchRouter/SearchButton.tsx b/src/components/Search/SearchRouter/SearchButton.tsx index f79beb917c32..35c53234f106 100644 --- a/src/components/Search/SearchRouter/SearchButton.tsx +++ b/src/components/Search/SearchRouter/SearchButton.tsx @@ -1,5 +1,5 @@ -import React, {useRef} from 'react'; -import type {StyleProp, View, ViewStyle} from 'react-native'; +import React from 'react'; +import type {StyleProp, ViewStyle} from 'react-native'; import Icon from '@components/Icon'; import {PressableWithoutFeedback} from '@components/Pressable'; import Tooltip from '@components/Tooltip'; @@ -24,13 +24,10 @@ function SearchButton({style, shouldUseAutoHitSlop = false}: SearchButtonProps) const theme = useTheme(); const {translate} = useLocalize(); const {openSearchRouter} = useSearchRouterActions(); - const pressableRef = useRef(null); const expensifyIcons = useMemoizedLazyExpensifyIcons(['MagnifyingGlass']); const onPress = () => { callFunctionIfActionIsAllowed(() => { - pressableRef.current?.blur(); - startSpan(CONST.TELEMETRY.SPAN_OPEN_SEARCH_ROUTER, { name: CONST.TELEMETRY.SPAN_OPEN_SEARCH_ROUTER, op: CONST.TELEMETRY.SPAN_OPEN_SEARCH_ROUTER, @@ -48,7 +45,6 @@ function SearchButton({style, shouldUseAutoHitSlop = false}: SearchButtonProps) return ( merchantArrayIndex !== index); onMerchantDataChange(updatedMerchantNames, updatedMerchantMatchTypes); } + // Skip on every submit-driven goBack — the parent list has its own Save button, and a re-focused row would hijack the next Enter. + skipNextFocusRestore(); goBack(); return; } @@ -89,6 +92,7 @@ function SpendRuleMerchantEditBase({policyID, merchantIndex, merchantMatchTypes, : merchantMatchTypes.map((type, merchantArrayIndex) => (merchantArrayIndex === index ? matchType : type)); onMerchantDataChange(updatedMerchantNames, updatedMerchantMatchTypes); + skipNextFocusRestore(); goBack(); }; diff --git a/src/components/SpendRules/configuration/SpendRuleMerchantsBase.tsx b/src/components/SpendRules/configuration/SpendRuleMerchantsBase.tsx index 36614e27edb5..b03186a4cfe7 100644 --- a/src/components/SpendRules/configuration/SpendRuleMerchantsBase.tsx +++ b/src/components/SpendRules/configuration/SpendRuleMerchantsBase.tsx @@ -17,15 +17,19 @@ import CONST from '@src/CONST'; import type {Route} from '@src/ROUTES'; import ROUTES from '@src/ROUTES'; +type SpendRuleMerchant = { + name: string; + matchType: ValueOf | undefined; +}; + type SpendRuleMerchantsBaseProps = { policyID: string; action: string; - merchantNames: string[]; - merchantMatchTypes: Array>; + merchants: SpendRuleMerchant[]; getEditMerchantRoute: (merchantIndex: string) => Route; }; -function SpendRuleMerchantsBase({policyID, action, merchantMatchTypes, merchantNames, getEditMerchantRoute}: SpendRuleMerchantsBaseProps) { +function SpendRuleMerchantsBase({policyID, action, merchants, getEditMerchantRoute}: SpendRuleMerchantsBaseProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['Plus']); @@ -76,23 +80,27 @@ function SpendRuleMerchantsBase({policyID, action, merchantMatchTypes, merchantN titleStyle={styles.textStrong} onPress={addMerchant} /> - {merchantNames.length > 0 ? ( - merchantNames.map((merchantName, index) => ( - navigateToMerchantEdit(String(index))} - shouldShowRightIcon - title={merchantName} - titleStyle={styles.flex1} - sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.MERCHANT_RULE_SECTION_ITEM} - /> - )) + {merchants.length > 0 ? ( + merchants.map(({name, matchType}, index) => { + // `name`/`matchType` are edited on the detail screen — keying by content would remount the row on save and lose the captured focus-return target. No per-merchant backend ID. + const rowId = `merchant-${index}`; + return ( + navigateToMerchantEdit(String(index))} + shouldShowRightIcon + title={name} + titleStyle={styles.flex1} + sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.MERCHANT_RULE_SECTION_ITEM} + /> + ); + }) ) : ( void; brickRoadIndicator?: BrickRoad; errorText?: string; + testID?: string; }; type ConfirmationStepProps = SubStepProps & @@ -70,18 +72,23 @@ function ConfirmationStep({ contentContainerStyle={[styles.flexGrow1, shouldApplySafeAreaPaddingBottom && {paddingBottom: safeAreaInsetPaddingBottom + styles.pb5.paddingBottom}]} > {pageTitle} - {summaryItems.map(({description, title, shouldShowRightIcon, onPress, brickRoadIndicator, errorText}) => ( - - ))} + {summaryItems.map(({id, description, title, shouldShowRightIcon, onPress, brickRoadIndicator, errorText, testID}, index) => { + // `description`+index is stable across edits (don't embed `title` — it's the edited value). Index disambiguates same-description rows; pass explicit `id` if conditional-hide above would shift indices. + const stableId = id ?? `${description}-${index}`; + return ( + + ); + })} {showOnfidoLinks && ( diff --git a/src/components/Table/TableRow.tsx b/src/components/Table/TableRow.tsx index 6fad4474fc0c..58a0d211acb8 100644 --- a/src/components/Table/TableRow.tsx +++ b/src/components/Table/TableRow.tsx @@ -188,6 +188,7 @@ export default function TableRow({ { useEffect(() => { if (!shouldMoveAccessibilityFocus || !didScreenTransitionEnd || !isFocused) { @@ -28,33 +26,47 @@ const useAccessibilityFocus: UseAccessibilityFocus = ({didScreenTransitionEnd, i return; } - const focusTargets = element.querySelectorAll(FOCUSABLE_ELEMENTS_SELECTOR); - for (const focusTarget of focusTargets) { - const isDisabledTarget = focusTarget.matches(':disabled') || focusTarget.getAttribute('aria-disabled') === 'true'; - if (isDisabledTarget || focusTarget.getAttribute('aria-hidden') === 'true') { - continue; - } + if (!tryClaim(Priorities.AUTO)) { + return; + } - if (focusTarget === activeElement) { - return; - } + // try/catch (RC rejects bare try/finally) so a stale-node throw still releases the AUTO cycle; log+swallow keeps a transient DOM throw out of React's error path. + try { + const focusTargets = element.querySelectorAll(FOCUSABLE_SELECTOR); + for (const focusTarget of focusTargets) { + const isDisabledTarget = focusTarget.matches(':disabled') || focusTarget.getAttribute('aria-disabled') === 'true'; + if (isDisabledTarget || focusTarget.getAttribute('aria-hidden') === 'true') { + continue; + } - const removeProgrammaticFocusAttr = () => { - focusTarget.removeAttribute(PROGRAMMATIC_FOCUS_DATA_ATTRIBUTE); - }; + if (focusTarget === activeElement) { + break; + } - focusTarget.setAttribute(PROGRAMMATIC_FOCUS_DATA_ATTRIBUTE, 'true'); - focusTarget.addEventListener('blur', removeProgrammaticFocusAttr, {once: true}); - focusTarget.focus(); + const unmarkProgrammaticFocus = markProgrammaticFocus(focusTarget); + let focusThrew = false; + try { + focusTarget.focus(); + } catch (focusError) { + focusThrew = true; + Log.warn('[useAccessibilityFocus] focus call threw', {error: focusError}); + } + if (focusThrew) { + unmarkProgrammaticFocus(); + break; + } - const focusedElement = document.activeElement; - if (focusedElement === focusTarget || (focusedElement && focusTarget.contains(focusedElement))) { - return; - } + const focusedElement = document.activeElement; + if (focusedElement === focusTarget || (focusedElement && focusTarget.contains(focusedElement))) { + break; + } - focusTarget.removeEventListener('blur', removeProgrammaticFocusAttr); - removeProgrammaticFocusAttr(); + unmarkProgrammaticFocus(); + } + } catch (error) { + Log.warn('[useAccessibilityFocus] focus walk threw', {error}); } + resetCycle(); }, [didScreenTransitionEnd, isFocused, ref, shouldMoveAccessibilityFocus]); }; diff --git a/src/hooks/useActiveElementRole/index.ts b/src/hooks/useActiveElementRole/index.ts index 98ae285f92b0..8685b600fe95 100644 --- a/src/hooks/useActiveElementRole/index.ts +++ b/src/hooks/useActiveElementRole/index.ts @@ -2,10 +2,7 @@ import {useContext} from 'react'; import {ActiveElementRoleContext} from '@components/ActiveElementRoleProvider'; import type UseActiveElementRole from './types'; -/** - * Listens for the focusin and focusout events and sets the DOM activeElement to the state. - * On native, we just return null. - */ +/** Listens for the focusin and focusout events and sets the DOM activeElement to the state. On native, we just return null. */ const useActiveElementRole: UseActiveElementRole = () => { const {role} = useContext(ActiveElementRoleContext); diff --git a/src/hooks/useDialogContainerFocus/index.ts b/src/hooks/useDialogContainerFocus/index.ts index 6bc55e16cc35..16f914d34716 100644 --- a/src/hooks/useDialogContainerFocus/index.ts +++ b/src/hooks/useDialogContainerFocus/index.ts @@ -1,13 +1,15 @@ import {useEffect} from 'react'; +import claimInitialFocus from '@libs/claimInitialFocus'; import FOCUSABLE_SELECTOR from '@libs/focusableSelector'; import hasFocusableAttributes from '@libs/focusGuards'; import getHadTabNavigation from '@libs/hadTabNavigation'; +import isHTMLElement from '@libs/isHTMLElement'; import TransitionTracker from '@libs/Navigation/TransitionTracker'; -import {Priorities, tryClaim} from '@libs/ScreenFocusArbiter'; import type UseDialogContainerFocus from './types'; function focusFirstInteractiveElement(container: HTMLElement | null): boolean { - if (!getHadTabNavigation() || !container || (document.activeElement && document.activeElement !== document.body)) { + // RHP initial focus is keyboard-only, so the ring is always shown (WCAG 2.4.7). + if (!getHadTabNavigation() || !container) { return false; } const targets = container.querySelectorAll(FOCUSABLE_SELECTOR); @@ -15,43 +17,32 @@ function focusFirstInteractiveElement(container: HTMLElement | null): boolean { if (!target) { return false; } - // Arbitrated so a concurrent RETURN restore wins over this dialog's initial focus. - if (!tryClaim(Priorities.INITIAL)) { - return false; - } - target.focus({preventScroll: true, focusVisible: true}); - return true; + return claimInitialFocus(target, {focusVisible: true}); } /** Focuses the first interactive element inside the dialog after the RHP transition for screen reader announcement. */ -const useDialogContainerFocus: UseDialogContainerFocus = (ref, isReady, claimInitialFocus, skipDialogContainerFocus = false) => { +const useDialogContainerFocus: UseDialogContainerFocus = (ref, isReady, claimInitialFocusGate, skipDialogContainerFocus = false) => { useEffect(() => { - if (!isReady || !claimInitialFocus?.() || skipDialogContainerFocus) { + if (!isReady || skipDialogContainerFocus || !claimInitialFocusGate?.()) { return; } - let cancelled = false; - let frameId: number; - // Deferred past useAutoFocusInput's InteractionManager + Promise chain. - const interactionHandle = TransitionTracker.runAfterTransitions({ + let rafId: number | null = null; + const handle = TransitionTracker.runAfterTransitions({ callback: () => { - if (cancelled) { - return; - } - frameId = requestAnimationFrame(() => { - if (cancelled) { - return; - } - const container = ref.current as unknown as HTMLElement | null; + // runAfterTransitions fires synchronously when no transition is active; defer one frame so late-mounted RHP content is queryable. + rafId = requestAnimationFrame(() => { + const container = isHTMLElement(ref.current) ? ref.current : null; focusFirstInteractiveElement(container); }); }, }); return () => { - cancelled = true; - interactionHandle.cancel(); - cancelAnimationFrame(frameId); + handle.cancel(); + if (rafId !== null) { + cancelAnimationFrame(rafId); + } }; - }, [isReady, ref, claimInitialFocus, skipDialogContainerFocus]); + }, [isReady, ref, claimInitialFocusGate, skipDialogContainerFocus]); }; export default useDialogContainerFocus; diff --git a/src/hooks/useInitialFocusRef.ts b/src/hooks/useInitialFocusRef.ts new file mode 100644 index 000000000000..18689ac21d93 --- /dev/null +++ b/src/hooks/useInitialFocusRef.ts @@ -0,0 +1,16 @@ +import {useState} from 'react'; +import isHTMLElement from '@libs/isHTMLElement'; +import useScreenInitialFocus from './useScreenInitialFocus'; +import type {UseScreenInitialFocusOptions} from './useScreenInitialFocus/types'; + +/** Returns a ref-callback for the element that should claim focus once its screen has mounted. Late attachment re-triggers the claim. */ +function useInitialFocusRef(options?: UseScreenInitialFocusOptions): (node: unknown) => void { + const [node, setNode] = useState(null); + useScreenInitialFocus(node, options); + return (newNode: unknown) => { + const next = isHTMLElement(newNode) ? newNode : null; + setNode((prev) => (prev === next ? prev : next)); + }; +} + +export default useInitialFocusRef; diff --git a/src/hooks/useNavigateBackOnSave/index.ts b/src/hooks/useNavigateBackOnSave/index.ts new file mode 100644 index 000000000000..d82005d322d9 --- /dev/null +++ b/src/hooks/useNavigateBackOnSave/index.ts @@ -0,0 +1,56 @@ +import {useEffect, useRef, useState} from 'react'; +import Navigation from '@libs/Navigation/Navigation'; +import {skipNextFocusRestore} from '@libs/NavigationFocusReturn'; +import type {Route} from '@src/ROUTES'; + +/** + * `navigateBack` = direct goBack. `armNavigateBack` = goBack on next save (or immediately if already saved). Within a single save cycle the nav fires at most once; a fresh `isSaved` false→true cycle re-arms. Both `backTo` and `shouldSkipFocusRestore` are snapshotted at arm time so a parent re-derivation between arm and save can't strand the user or swap focus-restore behavior. + */ +function useNavigateBackOnSave( + isSaved: boolean, + backTo: Route | undefined, + {shouldSkipFocusRestore}: {shouldSkipFocusRestore: boolean}, +): {navigateBack: () => void; armNavigateBack: () => void} { + const [isArmed, setIsArmed] = useState(false); + const hasNavigatedThisCycleRef = useRef(false); + const armedBackToRef = useRef(undefined); + const armedShouldSkipRef = useRef(false); + const prevSavedRef = useRef(isSaved); + + const navigateBack = () => { + Navigation.goBack(backTo); + }; + + const armNavigateBack = () => { + armedBackToRef.current = backTo; + armedShouldSkipRef.current = shouldSkipFocusRestore; + setIsArmed(true); + }; + + useEffect(() => { + // A fresh `isSaved` false→true edge re-opens the gate for a subsequent save. + if (!prevSavedRef.current && isSaved) { + hasNavigatedThisCycleRef.current = false; + } + prevSavedRef.current = isSaved; + + if (!isArmed || !isSaved) { + return; + } + // Clear the stale arm so it can't auto-fire on the next isSaved cycle. + if (hasNavigatedThisCycleRef.current) { + setIsArmed(false); + return; + } + hasNavigatedThisCycleRef.current = true; + setIsArmed(false); + if (armedShouldSkipRef.current) { + skipNextFocusRestore(); + } + Navigation.goBack(armedBackToRef.current); + }, [isArmed, isSaved]); + + return {navigateBack, armNavigateBack}; +} + +export default useNavigateBackOnSave; diff --git a/src/hooks/useRouteKey.ts b/src/hooks/useRouteKey.ts new file mode 100644 index 000000000000..f12bff87b99e --- /dev/null +++ b/src/hooks/useRouteKey.ts @@ -0,0 +1,9 @@ +import {NavigationRouteContext} from '@react-navigation/native'; +import {useContext} from 'react'; + +/** The current route's key from React Navigation, or null when the consumer isn't inside a navigator. */ +function useRouteKey(): string | null { + return useContext(NavigationRouteContext)?.key ?? null; +} + +export default useRouteKey; diff --git a/src/hooks/useScreenInitialFocus/index.native.ts b/src/hooks/useScreenInitialFocus/index.native.ts new file mode 100644 index 000000000000..e25e4d5b8043 --- /dev/null +++ b/src/hooks/useScreenInitialFocus/index.native.ts @@ -0,0 +1,6 @@ +import type UseScreenInitialFocus from './types'; + +// Native handles back-button focus via TalkBack / VoiceOver's own screen-mount announcement; no JS work needed. +const useScreenInitialFocus: UseScreenInitialFocus = () => {}; + +export default useScreenInitialFocus; diff --git a/src/hooks/useScreenInitialFocus/index.ts b/src/hooks/useScreenInitialFocus/index.ts new file mode 100644 index 000000000000..f968b7c91c92 --- /dev/null +++ b/src/hooks/useScreenInitialFocus/index.ts @@ -0,0 +1,80 @@ +import {useContext, useEffect, useRef} from 'react'; +import {useDialogLabelData} from '@components/DialogLabelContext'; +import ScreenWrapperStatusContext from '@components/ScreenWrapper/ScreenWrapperStatusContext'; +import Accessibility from '@libs/Accessibility'; +import claimInitialFocus from '@libs/claimInitialFocus'; +import hasHoverSupport from '@libs/DeviceCapabilities/hasHoverSupport'; +import {MAX_INITIAL_FOCUS_FRAMES} from '@libs/focusReturnTimings'; +import getHadTabNavigation from '@libs/hadTabNavigation'; +import type UseScreenInitialFocus from './types'; + +/** Geometry guard for off-screen Pressables (Growls, pre-animation drawers) that pass attribute checks. */ +function isOnScreen(el: HTMLElement): boolean { + const rect = el.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) { + return false; + } + if (rect.bottom <= 0 || rect.right <= 0) { + return false; + } + if (rect.top >= window.innerHeight || rect.left >= window.innerWidth) { + return false; + } + return true; +} + +/** + * Mobile-web focus on `node` once after `didScreenTransitionEnd`. Takes a node (not a ref) so late attachment re-runs the effect. + * Hover-capable devices gate on Tab (WCAG 2.4.7); touch-primary devices bypass. + */ +const useScreenInitialFocus: UseScreenInitialFocus = (node, options) => { + const status = useContext(ScreenWrapperStatusContext); + const {isInsideDialog} = useDialogLabelData(); + const claimedRef = useRef(false); + const shouldSkip = options?.shouldSkip ?? false; + const shouldClaimOnlyForScreenReader = options?.shouldClaimOnlyForScreenReader ?? false; + + useEffect(() => { + if (shouldSkip || isInsideDialog) { + return; + } + if (!status?.didScreenTransitionEnd) { + claimedRef.current = false; + return; + } + if (claimedRef.current) { + return; + } + if (!node) { + return; + } + if (hasHoverSupport() && !getHadTabNavigation()) { + return; + } + if (shouldClaimOnlyForScreenReader && Accessibility.getScreenReaderState() === 'disabled') { + return; + } + let rafId: number | null = null; + let framesLeft = MAX_INITIAL_FOCUS_FRAMES; + const attempt = () => { + if (isOnScreen(node) && claimInitialFocus(node, {focusVisible: getHadTabNavigation()})) { + claimedRef.current = true; + return; + } + framesLeft -= 1; + if (framesLeft <= 0) { + return; + } + rafId = requestAnimationFrame(attempt); + }; + attempt(); + return () => { + if (rafId === null) { + return; + } + cancelAnimationFrame(rafId); + }; + }, [shouldSkip, shouldClaimOnlyForScreenReader, isInsideDialog, status?.didScreenTransitionEnd, node]); +}; + +export default useScreenInitialFocus; diff --git a/src/hooks/useScreenInitialFocus/types.ts b/src/hooks/useScreenInitialFocus/types.ts new file mode 100644 index 000000000000..5657e18b1668 --- /dev/null +++ b/src/hooks/useScreenInitialFocus/types.ts @@ -0,0 +1,11 @@ +type UseScreenInitialFocusOptions = { + /** Opts the screen out of post-transition initial focus. */ + shouldSkip?: boolean; + /** Claim only when a screen reader is known-on; for screens with a competing async auto-focus target that would otherwise flash a ring for keyboard users. */ + shouldClaimOnlyForScreenReader?: boolean; +}; + +type UseScreenInitialFocus = (node: HTMLElement | null, options?: UseScreenInitialFocusOptions) => void; + +export default UseScreenInitialFocus; +export type {UseScreenInitialFocusOptions}; diff --git a/src/libs/Accessibility/fireFocusEvent/index.native.ts b/src/libs/Accessibility/fireFocusEvent/index.native.ts new file mode 100644 index 000000000000..5b8a4c4a2914 --- /dev/null +++ b/src/libs/Accessibility/fireFocusEvent/index.native.ts @@ -0,0 +1,15 @@ +import {AccessibilityInfo} from 'react-native'; +// eslint-disable-next-line no-restricted-imports -- type-only; mirrors PressableRef's cross-platform host-instance union. +import type {Text as RNText, View} from 'react-native'; +import Log from '@libs/Log'; + +/** Catches stale-handle throws so the orchestrator isn't aborted on Android; iOS silently no-ops on a stale handle. */ +function fireFocusEvent(view: View | RNText): void { + try { + AccessibilityInfo.sendAccessibilityEvent(view, 'focus'); + } catch (error: unknown) { + Log.warn('[fireFocusEvent] sendAccessibilityEvent threw', {error}); + } +} + +export default fireFocusEvent; diff --git a/src/libs/Accessibility/fireFocusEvent/index.ts b/src/libs/Accessibility/fireFocusEvent/index.ts new file mode 100644 index 000000000000..f4658f740373 --- /dev/null +++ b/src/libs/Accessibility/fireFocusEvent/index.ts @@ -0,0 +1,7 @@ +// eslint-disable-next-line no-restricted-imports -- type-only; mirrors PressableRef's cross-platform host-instance union. +import type {Text as RNText, View} from 'react-native'; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function fireFocusEvent(_view: View | RNText): void {} + +export default fireFocusEvent; diff --git a/src/libs/Accessibility/index.ts b/src/libs/Accessibility/index.ts index 37c6cd527e99..0f9e01312231 100644 --- a/src/libs/Accessibility/index.ts +++ b/src/libs/Accessibility/index.ts @@ -1,27 +1,95 @@ import {useCallback, useState, useSyncExternalStore} from 'react'; import type {LayoutChangeEvent} from 'react-native'; -import {AccessibilityInfo} from 'react-native'; +import {AccessibilityInfo, AppState} from 'react-native'; +import Log from '@libs/Log'; import isScreenReaderEnabled from './isScreenReaderEnabled'; import moveAccessibilityFocus from './moveAccessibilityFocus'; type HitSlop = {x: number; y: number}; +/** + * Memoized warmer: success is shared via one Promise; rejection clears the memo so the next caller retries. + * Subscribers `.then()` it to catch the boot-race — the platform listener only fires on toggles, never on the initial state. + * `refresh()` invalidates the memo and re-warms; used on AppState resume to recover from toggles that fire while no JS listener was active. + */ +function makeWarmCache( + label: string, + fetch: () => Promise, + apply: (value: T) => void, +): {ensure: () => Promise; reset: () => void; refresh: () => Promise; isWarm: () => boolean} { + let warm: Promise | null = null; + let warmed = false; + let generation = 0; + const ensure = () => { + if (warm) { + return warm; + } + // Capture at fetch start; a reset/refresh bumps generation, so a superseded resolve sees a mismatch and discards its value. + const myGeneration = generation; + warm = fetch() + .then((value) => { + if (myGeneration !== generation) { + return; + } + warmed = true; + apply(value); + }) + .catch((error: unknown) => { + Log.warn(`[Accessibility] Failed to warm ${label} cache`, {error}); + if (myGeneration === generation) { + warm = null; + } + }); + return warm; + }; + return { + ensure, + // Bump generation so a superseded in-flight fetch can't overwrite the latest value; clear warmed so the warm-up window is treated as unknown. + reset: () => { + warm = null; + warmed = false; + generation += 1; + }, + refresh: () => { + warm = null; + warmed = false; + generation += 1; + return ensure(); + }, + isWarm: () => warmed, + }; +} + let cachedScreenReaderValue = false; +const screenReaderSubscribers = new Set<() => void>(); +const { + ensure: ensureScreenReaderWarm, + reset: resetScreenReaderWarm, + refresh: refreshScreenReaderWarm, + isWarm: isScreenReaderCacheWarm, +} = makeWarmCache('screen-reader', isScreenReaderEnabled, (enabled) => { + cachedScreenReaderValue = enabled; +}); +ensureScreenReaderWarm(); function subscribeScreenReader(callback: () => void) { + screenReaderSubscribers.add(callback); const subscription = AccessibilityInfo.addEventListener('screenReaderChanged', (enabled) => { cachedScreenReaderValue = enabled; callback(); }); - - isScreenReaderEnabled() - .then((enabled) => { - cachedScreenReaderValue = enabled; - callback(); - }) - .catch(() => {}); - - return () => subscription?.remove(); + let cancelled = false; + ensureScreenReaderWarm().then(() => { + if (cancelled) { + return; + } + callback(); + }); + return () => { + cancelled = true; + screenReaderSubscribers.delete(callback); + subscription?.remove(); + }; } function getScreenReaderSnapshot() { @@ -30,23 +98,109 @@ function getScreenReaderSnapshot() { const useScreenReaderStatus = (): boolean => useSyncExternalStore(subscribeScreenReader, getScreenReaderSnapshot, () => false); +function isScreenReaderEnabledSync(): boolean { + return cachedScreenReaderValue; +} + +type ScreenReaderState = 'enabled' | 'disabled' | 'unknown'; + +/** Tri-state — `'unknown'` while the platform query is in-flight (cold start, AppState resume) so callers can register/capture defensively instead of bailing the moment the boolean cache says `false`. */ +function getScreenReaderState(): ScreenReaderState { + if (!isScreenReaderCacheWarm()) { + return 'unknown'; + } + return cachedScreenReaderValue ? 'enabled' : 'disabled'; +} + +/** Reactive variant of {@link getScreenReaderState} — for effects that need the tri-state inside React. */ +const useScreenReaderState = (): ScreenReaderState => useSyncExternalStore(subscribeScreenReader, getScreenReaderState, () => 'unknown'); + let cachedReduceMotionValue = false; +const reduceMotionSubscribers = new Set<() => void>(); +const { + ensure: ensureReduceMotionWarm, + reset: resetReduceMotionWarm, + refresh: refreshReduceMotionWarm, +} = makeWarmCache( + 'reduce-motion', + () => AccessibilityInfo.isReduceMotionEnabled(), + (enabled) => { + cachedReduceMotionValue = enabled; + }, +); +ensureReduceMotionWarm(); + +let appStateSubscription: ReturnType | null = null; +// Seed from currentState so a cold-start in 'background' (silent push, pre-warm) still refreshes on the first 'active'. +let wasBackgroundedSinceLastActive = AppState.currentState !== 'active'; + +function resetForTests() { + cachedScreenReaderValue = false; + cachedReduceMotionValue = false; + resetScreenReaderWarm(); + resetReduceMotionWarm(); + screenReaderSubscribers.clear(); + reduceMotionSubscribers.clear(); + appStateSubscription?.remove(); + appStateSubscription = null; + wasBackgroundedSinceLastActive = false; +} function subscribeReduceMotion(callback: () => void) { + reduceMotionSubscribers.add(callback); const subscription = AccessibilityInfo.addEventListener('reduceMotionChanged', (enabled) => { cachedReduceMotionValue = enabled; callback(); }); + let cancelled = false; + ensureReduceMotionWarm().then(() => { + if (cancelled) { + return; + } + callback(); + }); + return () => { + cancelled = true; + reduceMotionSubscribers.delete(callback); + subscription?.remove(); + }; +} - AccessibilityInfo.isReduceMotionEnabled() - .then((enabled) => { - cachedReduceMotionValue = enabled; - callback(); +/* + * Re-warm caches on resume from a real suspension. iOS resume is `background → inactive → active`; transient `inactive` (Notification Center, + * Control Center, banners) is `active → inactive → active`. The sticky flag distinguishes them — only a true background hop triggers refresh. + */ +appStateSubscription = AppState.addEventListener('change', (status) => { + if (status === 'background') { + wasBackgroundedSinceLastActive = true; + return; + } + if (status !== 'active' || !wasBackgroundedSinceLastActive) { + return; + } + wasBackgroundedSinceLastActive = false; + // refresh() invalidates `warmed` synchronously — notify so reactive consumers re-read during the in-flight window. + const settled = Promise.all([refreshScreenReaderWarm(), refreshReduceMotionWarm()]); + for (const cb of screenReaderSubscribers) { + cb(); + } + for (const cb of reduceMotionSubscribers) { + cb(); + } + settled + .then(() => { + // Re-notify unconditionally: `warmed` flips back true even when value unchanged. + for (const cb of screenReaderSubscribers) { + cb(); + } + for (const cb of reduceMotionSubscribers) { + cb(); + } }) - .catch(() => {}); - - return () => subscription?.remove(); -} + .catch((error: unknown) => { + Log.warn('[Accessibility] AppState refresh notify threw', {error}); + }); +}); function getReduceMotionSnapshot() { return cachedReduceMotionValue; @@ -82,9 +236,13 @@ const useAutoHitSlop = () => { return [getHitSlopForSize(frameSize), onLayout] as const; }; +export {resetForTests}; export default { moveAccessibilityFocus, useScreenReaderStatus, + useScreenReaderState, useAutoHitSlop, useReducedMotion, + isScreenReaderEnabledSync, + getScreenReaderState, }; diff --git a/src/libs/Accessibility/scheduleRefocus/index.android.ts b/src/libs/Accessibility/scheduleRefocus/index.android.ts new file mode 100644 index 000000000000..7a91910cb7c1 --- /dev/null +++ b/src/libs/Accessibility/scheduleRefocus/index.android.ts @@ -0,0 +1,26 @@ +import type {RefObject} from 'react'; +// eslint-disable-next-line no-restricted-imports -- type-only; mirrors PressableRef's cross-platform host-instance union. +import type {Text as RNText, View} from 'react-native'; +import fireFocusEvent from '@libs/Accessibility/fireFocusEvent'; + +/* + * TalkBack's auto-focus on TYPE_WINDOW_STATE_CHANGED clobbers the first event; re-fire when the + * thread next idles (300ms hard cap). See facebook/react-native#30097. + */ +const REFOCUS_TIMEOUT_MS = 300; + +function scheduleRefocus(ref: RefObject): {cancel: () => void} { + const id = requestIdleCallback( + () => { + const view = ref.current; + if (!view) { + return; + } + fireFocusEvent(view); + }, + {timeout: REFOCUS_TIMEOUT_MS}, + ); + return {cancel: () => cancelIdleCallback(id)}; +} + +export default scheduleRefocus; diff --git a/src/libs/Accessibility/scheduleRefocus/index.ts b/src/libs/Accessibility/scheduleRefocus/index.ts new file mode 100644 index 000000000000..ffdf71bd8c16 --- /dev/null +++ b/src/libs/Accessibility/scheduleRefocus/index.ts @@ -0,0 +1,10 @@ +import type {RefObject} from 'react'; +// eslint-disable-next-line no-restricted-imports -- type-only; mirrors PressableRef's cross-platform host-instance union. +import type {Text as RNText, View} from 'react-native'; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function scheduleRefocus(_ref: RefObject): {cancel: () => void} { + return {cancel: () => {}}; +} + +export default scheduleRefocus; diff --git a/src/libs/LauncherStack.ts b/src/libs/LauncherStack.ts index 963cfafb0b0b..2de159227450 100644 --- a/src/libs/LauncherStack.ts +++ b/src/libs/LauncherStack.ts @@ -2,14 +2,11 @@ * Stack of popover/modal launcher elements — the element that opened a focus trap. Top is the most recent. * pickLauncher prefers the topmost active entry, else the most recent deactivated-within-LAUNCHER_CLEAR_DELAY_MS. */ +import {LAUNCHER_CLEAR_DELAY_MS, LAUNCHER_STACK_MAX} from './focusReturnTimings'; // deactivatedAt is set on trap close; entry lives LAUNCHER_CLEAR_DELAY_MS so deferred-nav popovers can still consume it. type LauncherEntry = {element: HTMLElement; deactivatedAt?: number}; -// Covers click → state-listener → captureTriggerForRoute on slow devices. -const LAUNCHER_CLEAR_DELAY_MS = 1000; -const LAUNCHER_STACK_MAX = 8; - // Stack (not slot) so nested + sequential traps retain correct launcher context. const launcherStack: LauncherEntry[] = []; let hasWarnedAboutOverflow = false; @@ -81,7 +78,7 @@ function setActivePopoverLauncher(element: HTMLElement): void { } /** Mark a launcher (or top-of-stack) as deactivated. pickLauncher lazy-prunes on LAUNCHER_CLEAR_DELAY_MS. */ -function scheduleClearActivePopoverLauncher(element?: HTMLElement): void { +function markActivePopoverLauncherDeactivated(element?: HTMLElement): void { if (typeof document === 'undefined') { return; } @@ -100,4 +97,4 @@ function resetLauncherStackForTests(): void { hasWarnedAboutOverflow = false; } -export {pickLauncher, consumeLauncher, setActivePopoverLauncher, scheduleClearActivePopoverLauncher, resetLauncherStackForTests}; +export {pickLauncher, consumeLauncher, setActivePopoverLauncher, markActivePopoverLauncherDeactivated, resetLauncherStackForTests}; diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx index c64869214283..18dc5edc8a63 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx @@ -1,6 +1,7 @@ import type {NavigatorScreenParams} from '@react-navigation/native'; import {useFocusEffect} from '@react-navigation/native'; -import React, {useCallback, useEffect, useMemo, useRef} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import type {View} from 'react-native'; // eslint-disable-next-line no-restricted-imports import {Animated, DeviceEventEmitter} from 'react-native'; import {DialogLabelProvider} from '@components/DialogLabelContext'; @@ -111,7 +112,11 @@ const loadSearchSavePage = () => require('../../../../page function RightModalNavigator({navigation, route}: RightModalNavigatorProps) { // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout(); - const containerRef = useRef(null); + // Callback ref so DialogLabelProvider's observer re-attaches if Animated.View remounts across the breakpoint. + const [containerNode, setContainerNode] = useState(null); + const setContainerNodeFromRef = (node: View | null) => { + setContainerNode(node); + }; const isExecutingRef = useRef(false); const screenOptions = useRHPScreenOptions(); const {superWideRHPRouteKeys, wideRHPRouteKeys, shouldRenderTertiaryOverlay} = useWideRHPState(); @@ -224,12 +229,12 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) { {/* This one is to limit the outer Animated.View and allow the background to be pressable */} {/* Without it, the transparent half of the narrow format RHP card would cover the pressable part of the overlay */} - + ; }; -// Installs the modality flag (keydown/mousedown) and focus-return listeners (focusin/click); NavigationRoot.onReady attaches the state listener once live. +// Modality is module-load (must catch the first interaction); focus-return runs under NavigationRoot (needs navigationRef + a teardown point). setupHadTabNavigation(); -setupNavigationFocusReturn(); // Screens which are part of the 2FA setup flow - used to determine when to hide the RequireTwoFactorAuthOverlay const SET_UP_2FA_SCREENS = new Set([ diff --git a/src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx b/src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx index f9ed7c92bccc..739d12c4f4d8 100644 --- a/src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx +++ b/src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx @@ -20,22 +20,39 @@ function ScreenLayout({ navigation, }: ScreenLayoutArgs>) { const transitionHandleRef = useRef(null); + // Net-count overlapping starts so a single handle spans rapid back/forward re-fires — no decrement-to-zero seam for `runAfterTransitions` to flush through, and `transitionEnd` for the wrong leg can't end the active one. + const pendingTransitionsRef = useRef(0); useLayoutEffect(() => { const transitionStartListener = navigation.addListener('transitionStart', () => { - transitionHandleRef.current = TransitionTracker.startTransition(); + pendingTransitionsRef.current += 1; + if (!transitionHandleRef.current) { + transitionHandleRef.current = TransitionTracker.startTransition('navigation'); + } }); const transitionEndListener = navigation.addListener('transitionEnd', () => { - if (!transitionHandleRef.current) { - return; + if (pendingTransitionsRef.current > 0) { + pendingTransitionsRef.current -= 1; + } + if (pendingTransitionsRef.current === 0 && transitionHandleRef.current) { + TransitionTracker.endTransition(transitionHandleRef.current); + transitionHandleRef.current = null; } - TransitionTracker.endTransition(transitionHandleRef.current); - transitionHandleRef.current = null; }); return () => { transitionStartListener(); transitionEndListener(); + const handleToEnd = transitionHandleRef.current; + transitionHandleRef.current = null; + pendingTransitionsRef.current = 0; + if (!handleToEnd) { + return; + } + // Defer one frame so the incoming screen's `transitionStart` bumps `activeNavigationCount` first; an unmount mid-rapid-back/forward would otherwise drop the count to zero and flush any queued `runAfterTransitions` callback before the new screen mounts. + requestAnimationFrame(() => { + TransitionTracker.endTransition(handleToEnd); + }); }; }, [navigation]); diff --git a/src/libs/Navigation/TransitionTracker.ts b/src/libs/Navigation/TransitionTracker.ts index 978a1fdf36fb..5a6b2becfe81 100644 --- a/src/libs/Navigation/TransitionTracker.ts +++ b/src/libs/Navigation/TransitionTracker.ts @@ -3,6 +3,8 @@ import CONST from '@src/CONST'; type TransitionHandle = symbol; +type TransitionKind = 'navigation' | 'other'; + type CancelHandle = {cancel: () => void}; type RunAfterTransitionsOptions = { @@ -12,13 +14,12 @@ type RunAfterTransitionsOptions = { /** If true, the callback fires synchronously regardless of any active transitions. Defaults to false. */ runImmediately?: boolean; - /** If true, waits for the next transition to start before queuing the callback, so it runs after that transition ends. - * Useful when a navigation action has just been dispatched but the transition has not yet been registered. - * Defaults to false. */ - waitForUpcomingTransition?: boolean; + /** Wait for a transition before the callback (next-to-start if none active, else active-to-end). `true` = any; `'navigation'` = navigation only. Defaults to false. */ + waitForUpcomingTransition?: boolean | 'navigation'; }; -const activeTransitions = new Map>(); +const activeTransitions = new Map; kind: TransitionKind}>(); +let activeNavigationCount = 0; let pendingCallbacks: Array<() => void | Promise> = []; @@ -27,6 +28,11 @@ let promiseForNextTransitionStart = new Promise((resolve) => { nextTransitionStartResolve = resolve; }); +let nextNavigationTransitionStartResolve: (() => void) | null = null; +let promiseForNextNavigationTransitionStart = new Promise((resolve) => { + nextNavigationTransitionStartResolve = resolve; +}); + /** * Invokes and removes all pending callbacks. * Each callback is isolated so that one exception does not prevent the rest from running. @@ -63,25 +69,43 @@ function decrementAndFlush(): void { * Increments the active transition count and returns a handle that must be passed to {@link endTransition}. * Multiple overlapping transitions are tracked independently. * Each transition automatically ends after {@link CONST.MAX_TRANSITION_DURATION_MS} as a safety net. + * Pass `'navigation'` for screen transitions; default `'other'` covers keyboard / modal / layout and doesn't signal `waitForUpcomingTransition`. */ -function startTransition(): TransitionHandle { +function startTransition(kind: TransitionKind = 'other'): TransitionHandle { const handle: TransitionHandle = Symbol('transition'); - const resolve = nextTransitionStartResolve; - if (resolve) { + // Resolves on every start so legacy `waitForUpcomingTransition: true` callers see modal / keyboard / layout transitions. + const resolveAny = nextTransitionStartResolve; + if (resolveAny) { nextTransitionStartResolve = null; promiseForNextTransitionStart = new Promise((r) => { nextTransitionStartResolve = r; }); - resolve(); + resolveAny(); + } + + if (kind === 'navigation') { + const resolveNav = nextNavigationTransitionStartResolve; + if (resolveNav) { + nextNavigationTransitionStartResolve = null; + promiseForNextNavigationTransitionStart = new Promise((r) => { + nextNavigationTransitionStartResolve = r; + }); + resolveNav(); + } + activeNavigationCount += 1; } const timeout = setTimeout(() => { + const entry = activeTransitions.get(handle); activeTransitions.delete(handle); + if (entry?.kind === 'navigation') { + activeNavigationCount -= 1; + } decrementAndFlush(); }, CONST.MAX_TRANSITION_DURATION_MS); - activeTransitions.set(handle, timeout); + activeTransitions.set(handle, {timeout, kind}); return handle; } @@ -93,42 +117,48 @@ function startTransition(): TransitionHandle { * If the handle is unknown (already ended or already expired via safety timeout), this is a no-op. */ function endTransition(handle: TransitionHandle): void { - const timeout = activeTransitions.get(handle); - if (timeout === undefined) { + const entry = activeTransitions.get(handle); + if (!entry) { return; } - clearTimeout(timeout); + clearTimeout(entry.timeout); activeTransitions.delete(handle); + if (entry.kind === 'navigation') { + activeNavigationCount -= 1; + } decrementAndFlush(); } /** * Schedules a callback to run after all transitions complete. If no transitions are active - * or `runImmediately` is true, the callback fires synchronously. + * or `runImmediately` is true, the callback fires synchronously. `runImmediately` overrides `waitForUpcomingTransition`. * * @param options - Options object. * @param options.callback - The function to invoke once transitions finish. * @param options.runImmediately - If true, the callback fires synchronously regardless of active transitions. Defaults to false. - * @param options.waitForUpcomingTransition - If true, waits for the next transition to start before queuing the callback, so it runs after that transition ends. Use when navigation happens just before this call and the transition is not yet registered. Defaults to false. + * @param options.waitForUpcomingTransition - Wait for a transition before the callback: the upcoming one if none is active yet, else the active one to end. Defaults to false. * @returns A handle with a `cancel` method to prevent the callback from firing. */ function runAfterTransitions({callback, runImmediately = false, waitForUpcomingTransition = false}: RunAfterTransitionsOptions): CancelHandle { - if (waitForUpcomingTransition) { + if (runImmediately) { + callback(); + return {cancel: () => {}}; + } + const waitForNavigationOnly = waitForUpcomingTransition === 'navigation'; + // Gate on nav-active only: a concurrent non-nav transition ending would otherwise flush callbacks before the upcoming navigation. Web fires transitionStart before the nav state event, so a mid-flight nav must still take the active-end path. + if (waitForUpcomingTransition && activeNavigationCount === 0) { let cancelled = false; let innerHandle: CancelHandle | null = null; - // Guard against transitionStart never arriving. - // We race promiseForNextTransitionStart against a fallback timeout. - // Whichever resolves first wins. - // Afterwards we clearTimeout so the fallback doesn't keep the timer alive unnecessarily. let transitionStartTimeoutId!: ReturnType; const transitionStartTimeout = new Promise((resolve) => { transitionStartTimeoutId = setTimeout(resolve, CONST.MAX_TRANSITION_START_WAIT_MS); }); + const startPromise = waitForNavigationOnly ? promiseForNextNavigationTransitionStart : promiseForNextTransitionStart; (async () => { - await Promise.race([promiseForNextTransitionStart, transitionStartTimeout]); + await Promise.race([startPromise, transitionStartTimeout]); clearTimeout(transitionStartTimeoutId); if (!cancelled) { @@ -145,7 +175,7 @@ function runAfterTransitions({callback, runImmediately = false, waitForUpcomingT }; } - if (activeTransitions.size === 0 || runImmediately) { + if (activeTransitions.size === 0) { callback(); return {cancel: () => {}}; } diff --git a/src/libs/NavigationFocusReturn.native.ts b/src/libs/NavigationFocusReturn.native.ts deleted file mode 100644 index e7055e3e2635..000000000000 --- a/src/libs/NavigationFocusReturn.native.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** Native stub — focus return is web-only. No-ops so native doesn't bundle the web orchestrator. */ - -function setupNavigationFocusReturn(): void {} -function teardownNavigationFocusReturn(): void {} -/* eslint-disable @typescript-eslint/no-unused-vars */ -function notifyPushParamsForward(_routeKey: string, _prevParams: unknown): void {} -function notifyPushParamsBackward(_routeKey: string, _targetParams: unknown): void {} -/* eslint-enable @typescript-eslint/no-unused-vars */ -function cancelPendingFocusRestore(): void {} -function skipNextFocusRestore(): void {} -function isFocusRestoreInProgress(): boolean { - return false; -} -// Web-only guard; native has no DOM activeElement, so AUTO never needs to skip. -function shouldSkipAutoFocusDueToExistingFocus(): boolean { - return false; -} - -export { - setupNavigationFocusReturn, - teardownNavigationFocusReturn, - notifyPushParamsForward, - notifyPushParamsBackward, - cancelPendingFocusRestore, - skipNextFocusRestore, - isFocusRestoreInProgress, - shouldSkipAutoFocusDueToExistingFocus, -}; diff --git a/src/libs/NavigationFocusReturn/fifoMap.ts b/src/libs/NavigationFocusReturn/fifoMap.ts new file mode 100644 index 000000000000..2ac707055242 --- /dev/null +++ b/src/libs/NavigationFocusReturn/fifoMap.ts @@ -0,0 +1,15 @@ +/** Delete-then-set on re-insert so FIFO eviction drops the truly-oldest, not a recently-active key. */ +function setFifoEntry(map: Map, key: K, value: V, maxSize: number): void { + map.delete(key); + map.set(key, value); + while (map.size > maxSize) { + // `done` (not `value === undefined`) so a future caller storing `undefined` as a key wouldn't stall eviction. + const next = map.keys().next(); + if (next.done) { + break; + } + map.delete(next.value); + } +} + +export default setFifoEntry; diff --git a/src/libs/NavigationFocusReturn/index.native.ts b/src/libs/NavigationFocusReturn/index.native.ts new file mode 100644 index 000000000000..6a2dc9550ba5 --- /dev/null +++ b/src/libs/NavigationFocusReturn/index.native.ts @@ -0,0 +1,353 @@ +import type {NavigationState} from '@react-navigation/native'; +import type {RefObject} from 'react'; +import type {View} from 'react-native'; +import Accessibility from '@libs/Accessibility'; +import fireFocusEvent from '@libs/Accessibility/fireFocusEvent'; +import scheduleRefocus from '@libs/Accessibility/scheduleRefocus'; +import compoundParamsKey, {COMPOUND_KEY_DELIMITER} from '@libs/compoundParamsKey'; +import {MAX_RESTORE_FRAMES, PRESS_TRIGGER_TTL_MS, TRIGGER_MAP_MAX} from '@libs/focusReturnTimings'; +import Log from '@libs/Log'; +import navigationRef from '@libs/Navigation/navigationRef'; +import TransitionTracker from '@libs/Navigation/TransitionTracker'; +import {diffNavigationState} from '@libs/navigationStateDiff'; +import CONST from '@src/CONST'; +import setFifoEntry from './fifoMap'; + +type TriggerEntry = {ref: RefObject; identifier?: string}; + +const COLLISION_TOLERANT_IDENTIFIERS = new Set([CONST.BACK_BUTTON_NATIVE_ID]); + +let lastPressedTriggerRef: RefObject | null = null; +let lastPressedTriggerIdentifier: string | null = null; +let lastPressedTriggerAt = 0; +const triggerMap = new Map(); +const pressableRegistry = new Map>>>(); +let prevState: NavigationState | undefined; +let pendingRestore: {cancel: () => void} | null = null; +let skipNextRestore = false; +let stateUnsubscribe: (() => void) | null = null; + +// Recorded unconditionally so cold-start presses survive the warm-up window. performance.now is monotonic — Date.now would corrupt the TTL on clock jumps. +function notifyPressedTrigger(ref: RefObject | null, identifier?: string): void { + lastPressedTriggerRef = ref; + lastPressedTriggerIdentifier = identifier ?? null; + lastPressedTriggerAt = ref ? performance.now() : 0; +} + +/* Single-use: consumed by the next navigation so a later press-less forward can't reuse a stale ref within the TTL. */ +function clearStagedPress(): void { + lastPressedTriggerRef = null; + lastPressedTriggerIdentifier = null; + lastPressedTriggerAt = 0; +} + +/** Skip the next backward restore; call before a form-submit goBack. */ +function skipNextFocusRestore(): void { + skipNextRestore = true; +} + +function registerPressable(routeKey: string, identifier: string, ref: RefObject): () => void { + let routeMap = pressableRegistry.get(routeKey); + if (!routeMap) { + routeMap = new Map(); + pressableRegistry.set(routeKey, routeMap); + } + // Set per identifier (not last-write-wins) so a colliding identifier stays detectable — see restoreTriggerForRoute. + let refs = routeMap.get(identifier); + if (!refs) { + refs = new Set(); + routeMap.set(identifier, refs); + } + refs.add(ref); + return () => { + const map = pressableRegistry.get(routeKey); + const set = map?.get(identifier); + if (!map || !set) { + return; + } + set.delete(ref); + if (set.size === 0) { + map.delete(identifier); + } + if (map.size === 0) { + pressableRegistry.delete(routeKey); + } + }; +} + +// Gate on `'disabled'` so the warm-up window (cold start, AppState resume) — which returns `'unknown'` — still captures defensively. +function captureTriggerForRoute(routeKey: string): void { + if (Accessibility.getScreenReaderState() === 'disabled') { + return; + } + if (!lastPressedTriggerRef || performance.now() - lastPressedTriggerAt > PRESS_TRIGGER_TTL_MS) { + return; + } + setFifoEntry(triggerMap, routeKey, {ref: lastPressedTriggerRef, identifier: lastPressedTriggerIdentifier ?? undefined}, TRIGGER_MAP_MAX); +} + +// Caller passes the raw (compound-suffix-stripped) route key — pressables register under raw, but PUSH_PARAMS restores arrive under the compound key. +function resolveLiveRefFromRegistry(rawRouteKey: string, identifier: string): RefObject | null { + const refs = pressableRegistry.get(rawRouteKey)?.get(identifier); + if (!refs || refs.size === 0) { + return null; + } + // Fast path: single registration. Skip the Array.from + filter allocation in the common case. + if (refs.size === 1) { + const sole = refs.values().next().value; + return sole?.current ? sole : null; + } + // Multi-registration: a single live ref always wins; multi-live only resolves when on the collision-tolerant allowlist. + let firstLive: RefObject | null = null; + let liveCount = 0; + for (const ref of refs) { + if (!ref.current) { + continue; + } + liveCount += 1; + if (!firstLive) { + firstLive = ref; + } + } + if (liveCount === 1) { + return firstLive; + } + if (liveCount > 1 && COLLISION_TOLERANT_IDENTIFIERS.has(identifier)) { + return firstLive; + } + return null; +} + +/* + * Registry-first: a fresh re-registration wins over the captured ref because the captured native handle + * can stale-out across detach without nulling the JS ref. Falls back to the captured ref when the registry misses. + */ +function restoreTriggerForRoute(routeKey: string, rawRouteKey: string): RefObject | null { + const entry = triggerMap.get(routeKey); + if (!entry) { + return null; + } + const liveRef = entry.identifier ? resolveLiveRefFromRegistry(rawRouteKey, entry.identifier) : null; + const ref = liveRef ?? entry.ref; + const view = ref.current; + if (!view) { + return null; + } + fireFocusEvent(view); + return ref; +} + +function cancelPendingRestore(): void { + pendingRestore?.cancel(); + pendingRestore = null; +} + +/** Skip cleanup: cancel in-flight defer + drop the entry so a stale trigger can't be replayed by a later same-key backward. */ +function applySkippedRestore(restoreKey: string): void { + skipNextRestore = false; + cancelPendingRestore(); + triggerMap.delete(restoreKey); +} + +function scheduleRestore(routeKey: string, {waitForUpcomingTransition}: {waitForUpcomingTransition: false | 'navigation'}): void { + // Cancel first so a stale prior restore can't fire on the prior route after the user moved on (rapid double-back). + cancelPendingRestore(); + // Consume the entry so a later SR re-enable + press-less nav can't replay this stale capture. + if (Accessibility.getScreenReaderState() === 'disabled') { + triggerMap.delete(routeKey); + return; + } + if (!triggerMap.has(routeKey)) { + return; + } + let cancelled = false; + let refocusHandle: {cancel: () => void} | null = null; + let rafHandle: number | null = null; + let handle: {cancel: () => void} | null = null; + + // Assign pendingRestore before runAfterTransitions: the callback can fire synchronously, so a re-entrant cancel must see this handle to abort the rAF retry. + pendingRestore = { + cancel: () => { + cancelled = true; + handle?.cancel(); + refocusHandle?.cancel(); + if (rafHandle !== null) { + cancelAnimationFrame(rafHandle); + } + }, + }; + + // Hoist out of the retry loop — routeKey is invariant across rAF retries. + const rawRouteKey = routeKey.split(COMPOUND_KEY_DELIMITER).at(0) ?? routeKey; + + handle = TransitionTracker.runAfterTransitions({ + // Stack pops fire before their transition registers, so wait for it; PUSH_PARAMS emits none, so the caller opts out to avoid stalling on the 1s timeout. + waitForUpcomingTransition, + callback: () => { + // Keep the entry until success or budget exhaustion, so a transient re-attach miss doesn't drop it. + let framesLeft = MAX_RESTORE_FRAMES; + const attempt = () => { + if (cancelled) { + return; + } + const ref = restoreTriggerForRoute(routeKey, rawRouteKey); + if (ref) { + triggerMap.delete(routeKey); + refocusHandle = scheduleRefocus(ref); + return; + } + framesLeft -= 1; + if (framesLeft <= 0) { + // Surface exhaustion — silent failure would erode WCAG 2.4.3 without trace. + Log.warn('[NavigationFocusReturn] restore budget exhausted', {routeKey, frames: MAX_RESTORE_FRAMES}); + triggerMap.delete(routeKey); + return; + } + rafHandle = requestAnimationFrame(attempt); + }; + // PUSH_PARAMS dispatches pre-commit (from getStateForAction) — defer a frame so the new params render before we focus. + if (waitForUpcomingTransition === false) { + rafHandle = requestAnimationFrame(attempt); + } else { + attempt(); + } + }, + }); +} + +function handleStateChange(newState: NavigationState | undefined): void { + if (!newState) { + return; + } + const {action, removedKeys} = diffNavigationState(prevState, newState); + + if (action.type === 'forward') { + skipNextRestore = false; + cancelPendingRestore(); + captureTriggerForRoute(action.captureKey); + } else if (action.type === 'backward') { + if (skipNextRestore) { + applySkippedRestore(action.restoreKey); + } else { + scheduleRestore(action.restoreKey, {waitForUpcomingTransition: 'navigation'}); + } + } else if (action.type === 'lateral') { + skipNextRestore = false; + cancelPendingRestore(); + } + + const isRealNavigation = action.type !== 'noop'; + if (isRealNavigation) { + clearStagedPress(); + } + + for (const key of removedKeys) { + triggerMap.delete(key); + pressableRegistry.delete(key); + const compoundPrefix = `${key}${COMPOUND_KEY_DELIMITER}`; + for (const mapKey of triggerMap.keys()) { + if (mapKey.startsWith(compoundPrefix)) { + triggerMap.delete(mapKey); + } + } + } + prevState = newState; +} + +function navigationRefHasLiveState(): boolean { + return typeof navigationRef?.isReady === 'function' && navigationRef.isReady() && typeof navigationRef.getRootState === 'function'; +} + +function setupNavigationFocusReturn(): void { + if (!prevState && navigationRefHasLiveState()) { + prevState = navigationRef.getRootState() ?? prevState; + } + // Pre-mount addListener returns a queue-only unsubscribe; gate on `current` so we get a real subscription. + if (!stateUnsubscribe && navigationRef?.current != null && typeof navigationRef.addListener === 'function') { + stateUnsubscribe = navigationRef.addListener('state', () => { + if (typeof navigationRef.getRootState !== 'function') { + return; + } + handleStateChange(navigationRef.getRootState()); + }); + } +} + +function teardownNavigationFocusReturn(): void { + cancelPendingRestore(); + prevState = undefined; + triggerMap.clear(); + pressableRegistry.clear(); + clearStagedPress(); + skipNextRestore = false; + stateUnsubscribe?.(); + stateUnsubscribe = null; +} + +/** PUSH_PARAMS reuses the focused key, so `diffNavigationState` reports `noop`; key against `routeKey + params`. */ +function notifyPushParamsForward(routeKey: string, prevParams: unknown): void { + skipNextRestore = false; + cancelPendingRestore(); + captureTriggerForRoute(compoundParamsKey(routeKey, prevParams)); + clearStagedPress(); +} + +function notifyPushParamsBackward(routeKey: string, targetParams: unknown): void { + // Honor a one-shot skip on this param-revert too (form-submit goBack can land as PUSH_PARAMS, not a stack pop). + const compoundKey = compoundParamsKey(routeKey, targetParams); + if (skipNextRestore) { + applySkippedRestore(compoundKey); + } else { + scheduleRestore(compoundKey, {waitForUpcomingTransition: false}); + } + clearStagedPress(); +} + +function cancelPendingFocusRestore(): void { + cancelPendingRestore(); +} + +/** Web-only invariant; native returns false. */ +function isFocusRestoreInProgress(): boolean { + return false; +} + +/** Web-only invariant; native returns false. */ +function shouldSkipAutoFocusDueToExistingFocus(): boolean { + return false; +} + +function resetForTests(): void { + teardownNavigationFocusReturn(); +} + +function getTriggerMapSizeForTests(): number { + return triggerMap.size; +} + +function getRegistrySizeForTests(): number { + let total = 0; + for (const routeMap of pressableRegistry.values()) { + for (const refs of routeMap.values()) { + total += refs.size; + } + } + return total; +} + +export { + setupNavigationFocusReturn, + teardownNavigationFocusReturn, + handleStateChange, + notifyPressedTrigger, + registerPressable, + notifyPushParamsForward, + notifyPushParamsBackward, + cancelPendingFocusRestore, + skipNextFocusRestore, + isFocusRestoreInProgress, + shouldSkipAutoFocusDueToExistingFocus, + resetForTests, + getTriggerMapSizeForTests, + getRegistrySizeForTests, +}; diff --git a/src/libs/NavigationFocusReturn.ts b/src/libs/NavigationFocusReturn/index.ts similarity index 65% rename from src/libs/NavigationFocusReturn.ts rename to src/libs/NavigationFocusReturn/index.ts index 9c461fac87be..6600534c283d 100644 --- a/src/libs/NavigationFocusReturn.ts +++ b/src/libs/NavigationFocusReturn/index.ts @@ -1,102 +1,46 @@ -import {findFocusedRoute} from '@react-navigation/core'; -import type {NavigationState, PartialState} from '@react-navigation/native'; -// eslint-disable-next-line no-restricted-imports -- idiomatic defer primitive past navigation transitions. -import {InteractionManager} from 'react-native'; -import compoundParamsKey, {COMPOUND_KEY_DELIMITER} from './compoundParamsKey'; -import FOCUSABLE_SELECTOR from './focusableSelector'; -import hasFocusableAttributes from './focusGuards'; -import getHadTabNavigation from './hadTabNavigation'; -import {consumeLauncher, pickLauncher, resetLauncherStackForTests} from './LauncherStack'; -import navigationRef from './Navigation/navigationRef'; -import {isCycleIdle, Priorities, resetCycle, tryClaim} from './ScreenFocusArbiter'; +import type {NavigationState} from '@react-navigation/native'; +import type {RefObject} from 'react'; +import type {View} from 'react-native'; +import compoundParamsKey, {COMPOUND_KEY_DELIMITER} from '@libs/compoundParamsKey'; +import FOCUSABLE_SELECTOR from '@libs/focusableSelector'; +import hasFocusableAttributes from '@libs/focusGuards'; +import {MAX_RESTORE_FRAMES, MOUSE_TRIGGER_TTL_MS, RETURN_HOLD_MS, TRIGGER_MAP_MAX} from '@libs/focusReturnTimings'; +import getHadTabNavigation from '@libs/hadTabNavigation'; +import isEffectivelyVisible from '@libs/isEffectivelyVisible'; +import {consumeLauncher, pickLauncher, resetLauncherStackForTests} from '@libs/LauncherStack'; +import Log from '@libs/Log'; +import navigationRef from '@libs/Navigation/navigationRef'; +import TransitionTracker from '@libs/Navigation/TransitionTracker'; +import {diffNavigationState} from '@libs/navigationStateDiff'; +import restoreFocusWithModality from '@libs/restoreFocusWithModality'; +import {isCycleIdle, Priorities, resetCycle, tryClaim} from '@libs/ScreenFocusArbiter'; +import setFifoEntry from './fifoMap'; /** focusin tracks the last keyboard-focused element; a nav state listener captures it against the outgoing route and restores it on backward nav. */ -type AnyState = NavigationState | PartialState | undefined; - -type DiffAction = {type: 'forward'; captureKey: string} | {type: 'backward'; restoreKey: string} | {type: 'lateral'} | {type: 'noop'}; - // Fallback is the surrounding trap's launcher, used when primary can't accept focus at restore. type TriggerEntry = {primary: HTMLElement; fallback?: HTMLElement}; -// Bound triggerMap so forward-only PUSH_PARAMS sessions can't pin detached DOM nodes indefinitely. -const TRIGGER_MAP_MAX = 64; +const triggerMap = new Map(); +const MOUSE_ACTIVATION_EVENTS = ['pointerdown', 'mousedown', 'click'] as const; -let lastInteractiveElement: HTMLElement | null = null; // Cross-modality: mouse-click-forward → keyboard-back still needs focus returned (WCAG 2.4.3). let lastMouseTrigger: HTMLElement | null = null; +let lastInteractiveElement: HTMLElement | null = null; let lastMouseTriggerAt = 0; -// A click long before a timer-triggered nav shouldn't get captured as that nav's trigger. -const MOUSE_TRIGGER_TTL_MS = 3_000; -const triggerMap = new Map(); -// Refresh insertion order on re-set so FIFO eviction doesn't drop a recently-active key. function setTriggerEntry(routeKey: string, entry: TriggerEntry): void { - triggerMap.delete(routeKey); - triggerMap.set(routeKey, entry); - while (triggerMap.size > TRIGGER_MAP_MAX) { - const oldest = triggerMap.keys().next().value; - if (oldest === undefined) { - break; - } - triggerMap.delete(oldest); - } + setFifoEntry(triggerMap, routeKey, entry, TRIGGER_MAP_MAX); } let prevState: NavigationState | undefined; let pendingRestore: {cancel: () => void} | null = null; -let skipNextRestore = false; let isRestoringFocus = false; +let skipNextRestore = false; let focusinHandler: ((e: FocusEvent) => void) | null = null; let mouseActivationHandler: ((e: MouseEvent) => void) | null = null; let stateUnsubscribe: (() => void) | null = null; -// Three events for touch/pen/legacy/drag-to-release coverage; handler is idempotent. -const MOUSE_ACTIVATION_EVENTS = ['pointerdown', 'mousedown', 'click'] as const; - -function collectRouteKeys(state: AnyState, out = new Set()): Set { - if (!state?.routes) { - return out; - } - for (const route of state.routes) { - if (route.key) { - out.add(route.key); - } - if (route.state) { - collectRouteKeys(route.state as PartialState, out); - } - } - return out; -} - -function diffNavigationState(prev: AnyState, next: NavigationState): {action: DiffAction; removedKeys: string[]} { - const newFocusedKey = findFocusedRoute(next)?.key; - const prevFocusedKey = prev ? findFocusedRoute(prev as NavigationState)?.key : undefined; - - const prevKeys = collectRouteKeys(prev); - const newKeys = collectRouteKeys(next); - const removedKeys: string[] = []; - for (const key of prevKeys) { - if (!newKeys.has(key)) { - removedKeys.push(key); - } - } - - let action: DiffAction; - if (!prevFocusedKey || !newFocusedKey || prevFocusedKey === newFocusedKey) { - action = {type: 'noop'}; - } else if (prevKeys.has(newFocusedKey) && removedKeys.length > 0) { - action = {type: 'backward', restoreKey: newFocusedKey}; - } else if (!prevKeys.has(newFocusedKey)) { - action = {type: 'forward', captureKey: prevFocusedKey}; - } else { - // Key existed, nothing dropped — e.g. top-tab switch with all tabs mounted. - action = {type: 'lateral'}; - } - - return {action, removedKeys}; -} - function captureTriggerForRoute(routeKey: string): void { if (typeof document === 'undefined') { return; @@ -130,48 +74,67 @@ function captureTriggerForRoute(routeKey: string): void { setTriggerEntry(routeKey, {primary: inner}); } +/** Loose refs to the prior screen's focused element would pin detached DOM nodes; triggerMap already holds the captured copy. */ +function clearTransientCaptures(): void { + lastInteractiveElement = null; + lastMouseTrigger = null; + lastMouseTriggerAt = 0; +} + function notifyPushParamsForward(routeKey: string, prevParams: unknown): void { // Same-key transition is noop in handleStateChange — clear pending restores AND completed-RETURN state here so neither leaks into the next params screen. + skipNextRestore = false; cancelPendingFocusRestore(); captureTriggerForRoute(compoundParamsKey(routeKey, prevParams)); + clearTransientCaptures(); } function notifyPushParamsBackward(routeKey: string, targetParams: unknown): void { - scheduleRestore(compoundParamsKey(routeKey, targetParams)); + // Honor a one-shot skip on this param-revert too (form-submit goBack can land as PUSH_PARAMS, not a stack pop). + const compoundKey = compoundParamsKey(routeKey, targetParams); + if (skipNextRestore) { + applySkippedRestore(compoundKey); + return; + } + scheduleRestore(compoundKey, {waitForUpcomingTransition: false}); } -/** Skips the focus restore for the next back navigation. Call it before a form-submit goBack so the re-focused row doesn't eat the next Enter (which should hit the page's submit). Back and Esc don't call it, so they still restore focus. */ +/* + * Skips the focus restore for the next back navigation. Call it before a form-submit goBack so the re-focused row + * doesn't eat the next Enter (which should hit the page's submit). Back and Esc don't call it, so they still restore focus. + */ function skipNextFocusRestore(): void { skipNextRestore = true; } +/** Native-only. Web captures via `focusin`; no-op here so the import resolves cross-platform. */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function notifyPressedTrigger(_ref: RefObject | null, _identifier?: string): void {} + +/** Native-only registry no-op; cross-platform stub. */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function registerPressable(_routeKey: string, _identifier: string, _ref: RefObject): () => void { + return () => {}; +} + /** True only while restoreTriggerForRoute is in its .focus() call. Lists use it to tell the restore apart from a real keyboard Tab, which also has no sourceCapabilities. */ function isFocusRestoreInProgress(): boolean { return isRestoringFocus; } -// 'retry' = in DOM but cannot accept focus now; 'gone' = detached, drop the entry. -type RestorePick = {target: HTMLElement; source: 'primary' | 'fallback'} | 'retry' | 'gone'; - -function pickRestoreTarget(entry: TriggerEntry): RestorePick { - const {primary, fallback} = entry; - const primaryInDom = document.contains(primary); - const fallbackInDom = !!fallback && document.contains(fallback); - - if (primaryInDom && hasFocusableAttributes(primary)) { - return {target: primary, source: 'primary'}; - } - if (fallbackInDom && fallback && hasFocusableAttributes(fallback)) { - return {target: fallback, source: 'fallback'}; +/* Empty = nothing focusable yet (detached mid-remount, missing attributes); caller's retry budget owns cleanup, not this function. */ +function pickRestoreCandidates(entry: TriggerEntry): HTMLElement[] { + const candidates: HTMLElement[] = []; + if (document.contains(entry.primary) && hasFocusableAttributes(entry.primary)) { + candidates.push(entry.primary); } - if (primaryInDom || fallbackInDom) { - return 'retry'; + if (entry.fallback && document.contains(entry.fallback) && hasFocusableAttributes(entry.fallback)) { + candidates.push(entry.fallback); } - return 'gone'; + return candidates; } -// Grace window after a successful restore: vetoes in-flight AUTO/INITIAL, then releases so unrelated later claimers aren't blocked for CYCLE_TIMEOUT_MS. -const RETURN_HOLD_MS = 500; +// Distinct from the arbiter's cycle timeout: this hold is target-conditional (suppress AUTO only while the restored target stays focused). let returnHoldTimerId: ReturnType | undefined; // Set on successful RETURN; consulted at hold-release time to decide whether to eagerly reset the cycle or defer. let lastRestoreTarget: HTMLElement | null = null; @@ -187,16 +150,8 @@ function shouldSkipAutoFocusDueToExistingFocus(): boolean { if (!hasFocusableAttributes(document.activeElement)) { return false; } - if (typeof window !== 'undefined' && document.activeElement instanceof HTMLElement) { - // `display` is element-self only — walk ancestors. `visibility` is inherited — self-check suffices. - for (let node: HTMLElement | null = document.activeElement; node && node !== document.body; node = node.parentElement) { - if (window.getComputedStyle(node).display === 'none') { - return false; - } - } - if (window.getComputedStyle(document.activeElement).visibility === 'hidden') { - return false; - } + if (document.activeElement instanceof HTMLElement && !isEffectivelyVisible(document.activeElement)) { + return false; } return true; } @@ -235,7 +190,7 @@ function cancelPendingFocusRestore(): void { } } -function restoreTriggerForRoute(routeKey: string): boolean { +function restoreTriggerForRoute(routeKey: string, restoreBaseline: Element | null = null): boolean { if (typeof document === 'undefined') { return false; } @@ -244,17 +199,15 @@ function restoreTriggerForRoute(routeKey: string): boolean { return false; } - const pick = pickRestoreTarget(entry); - if (pick === 'retry') { - return false; - } - if (pick === 'gone') { - triggerMap.delete(routeKey); + const candidates = pickRestoreCandidates(entry); + if (candidates.length === 0) { return false; } - // Idle cycle + non-body focus = user manually focused during the defer; respect it. Held cycle (AUTO mid-defer) = system-driven; preempt per priority (Status → Clear after race). - if (isCycleIdle() && document.activeElement && document.activeElement !== document.body && hasFocusableAttributes(document.activeElement)) { + // Yield to existing focus only if it moved after the baseline (a user action mid-defer); pre-existing focus is a system-restored opener that RETURN overrides. A held cycle (AUTO mid-defer) is preempted by priority below. + const activeNow = document.activeElement; + const focusMovedDuringDefer = activeNow !== restoreBaseline; + if (isCycleIdle() && activeNow && activeNow !== document.body && hasFocusableAttributes(activeNow) && focusMovedDuringDefer) { triggerMap.delete(routeKey); return false; } @@ -264,17 +217,11 @@ function restoreTriggerForRoute(routeKey: string): boolean { } // activeElement verification catches silent-focus failures (display:none / visibility:hidden ancestors). - const candidates: HTMLElement[] = [pick.target]; - if (pick.source === 'primary' && entry.fallback && document.contains(entry.fallback) && hasFocusableAttributes(entry.fallback)) { - candidates.push(entry.fallback); - } - - const focusOptions: FocusOptions = {preventScroll: true, focusVisible: getHadTabNavigation()}; for (const candidate of candidates) { const before = document.activeElement; isRestoringFocus = true; try { - candidate.focus(focusOptions); + restoreFocusWithModality(candidate); } finally { isRestoringFocus = false; } @@ -304,59 +251,63 @@ function cancelPendingRestore(): void { pendingRestore = null; } -const MAX_RESTORE_ATTEMPTS = 2; -const RESTORE_RETRY_MS = 50; +/** Skip cleanup: cancel in-flight defer + drop the entry so a stale trigger can't be replayed by a later same-key backward. */ +function applySkippedRestore(restoreKey: string): void { + skipNextRestore = false; + cancelPendingRestore(); + triggerMap.delete(restoreKey); +} -function scheduleRestore(routeKey: string): void { +function scheduleRestore(routeKey: string, {waitForUpcomingTransition}: {waitForUpcomingTransition: false | 'navigation'}): void { + // Baseline: focus present synchronously at back-nav time is pre-existing, not a user action during the defer. + const restoreBaseline = typeof document !== 'undefined' ? document.activeElement : null; cancelPendingRestore(); - // `cancelled` flag in case a primitive's cancel races a queued callback. let cancelled = false; - let attempts = 0; - let frameId: number | undefined; - let retryTimerId: ReturnType | undefined; - let imHandle: {cancel: () => void} | undefined; - - const attempt = () => { - // Defer past the transition so useAutoFocusInput and React Navigation's own focus work settle first. - // eslint-disable-next-line @typescript-eslint/no-deprecated -- idiomatic defer primitive despite type-def deprecation. - imHandle = InteractionManager.runAfterInteractions(() => { - if (cancelled) { - return; + let rafId: number | undefined; + let handle: {cancel: () => void} | undefined; + + pendingRestore = { + cancel: () => { + cancelled = true; + handle?.cancel(); + if (rafId !== undefined) { + cancelAnimationFrame(rafId); } - frameId = requestAnimationFrame(() => { + }, + }; + + handle = TransitionTracker.runAfterTransitions({ + // Stack pops dispatch before their transition registers, so they wait for the upcoming one; PUSH_PARAMS emits none, so it opts out to avoid stalling on the timeout. + waitForUpcomingTransition, + callback: () => { + // A miss keeps the entry, so retry; stop once it's restored or removed elsewhere, and drop it ourselves only on exhaustion. + let framesLeft = MAX_RESTORE_FRAMES; + const attempt = () => { if (cancelled) { return; } - attempts += 1; - const restored = restoreTriggerForRoute(routeKey); + const restored = restoreTriggerForRoute(routeKey, restoreBaseline); if (restored || !triggerMap.has(routeKey)) { pendingRestore = null; return; } - if (attempts >= MAX_RESTORE_ATTEMPTS) { + framesLeft -= 1; + if (framesLeft <= 0) { + Log.warn('[NavigationFocusReturn] restore budget exhausted', {routeKey, frames: MAX_RESTORE_FRAMES}); triggerMap.delete(routeKey); pendingRestore = null; return; } - retryTimerId = setTimeout(attempt, RESTORE_RETRY_MS); - }); - }); - }; - - pendingRestore = { - cancel: () => { - cancelled = true; - imHandle?.cancel(); - if (frameId !== undefined) { - cancelAnimationFrame(frameId); - } - if (retryTimerId !== undefined) { - clearTimeout(retryTimerId); + rafId = requestAnimationFrame(attempt); + }; + // PUSH_PARAMS dispatches pre-commit (from getStateForAction) — defer a frame so the new params render before we focus. + if (waitForUpcomingTransition === false) { + rafId = requestAnimationFrame(attempt); + } else { + attempt(); } }, - }; - - attempt(); + }); } function handleStateChange(newState: NavigationState | undefined): void { @@ -376,16 +327,12 @@ function handleStateChange(newState: NavigationState | undefined): void { skipNextRestore = false; cancelPendingRestore(); captureTriggerForRoute(action.captureKey); - // Loose refs would pin detached unmounted nodes; triggerMap holds the captured copy. - lastInteractiveElement = null; - lastMouseTrigger = null; - lastMouseTriggerAt = 0; + clearTransientCaptures(); } else if (action.type === 'backward') { if (skipNextRestore) { - skipNextRestore = false; - cancelPendingRestore(); + applySkippedRestore(action.restoreKey); } else { - scheduleRestore(action.restoreKey); + scheduleRestore(action.restoreKey, {waitForUpcomingTransition: 'navigation'}); } } else if (action.type === 'lateral') { skipNextRestore = false; @@ -515,16 +462,15 @@ export { setupNavigationFocusReturn, teardownNavigationFocusReturn, handleStateChange, - diffNavigationState, - collectRouteKeys, captureTriggerForRoute, restoreTriggerForRoute, notifyPushParamsForward, notifyPushParamsBackward, cancelPendingFocusRestore, skipNextFocusRestore, + notifyPressedTrigger, + registerPressable, isFocusRestoreInProgress, - compoundParamsKey, shouldSkipAutoFocusDueToExistingFocus, resetForTests, setLastInteractiveElementForTests, diff --git a/src/libs/ScreenFocusArbiter.ts b/src/libs/ScreenFocusArbiter.ts index c24756f4b9b3..7299409ea32c 100644 --- a/src/libs/ScreenFocusArbiter.ts +++ b/src/libs/ScreenFocusArbiter.ts @@ -10,7 +10,6 @@ const RETURN = 3; type Priority = typeof INITIAL | typeof AUTO | typeof RETURN; const Priorities = {INITIAL, AUTO, RETURN} as const; - const CYCLE_TIMEOUT_MS = 2000; let currentPriority: Priority | 0 = 0; diff --git a/src/libs/claimInitialFocus.ts b/src/libs/claimInitialFocus.ts new file mode 100644 index 000000000000..71c980e3f469 --- /dev/null +++ b/src/libs/claimInitialFocus.ts @@ -0,0 +1,23 @@ +import {Priorities, resetCycle, tryClaim} from './ScreenFocusArbiter'; + +/** + * Shared tail for the web initial-focus hooks. `focusVisible` true shows the keyboard ring (WCAG 2.4.7); false tells + * the browser the focus is non-visible, so `:focus-visible` never matches and no ring (app or UA) is drawn for touch. + */ +function claimInitialFocus(el: HTMLElement, {focusVisible}: {focusVisible: boolean}): boolean { + if (document.activeElement && document.activeElement !== document.body) { + return false; + } + if (!tryClaim(Priorities.INITIAL)) { + return false; + } + el.focus({preventScroll: true, focusVisible}); + // Focus silently no-ops when an inert / visibility:hidden ancestor passes the focusable + geometry checks. + if (document.activeElement !== el) { + resetCycle(); + return false; + } + return true; +} + +export default claimInitialFocus; diff --git a/src/libs/focusReturnTimings.ts b/src/libs/focusReturnTimings.ts new file mode 100644 index 000000000000..7092b5c3935d --- /dev/null +++ b/src/libs/focusReturnTimings.ts @@ -0,0 +1,25 @@ +/** Stack-pop or re-attach can take up to this many `requestAnimationFrame` ticks before we give up and `Log.warn`. */ +const MAX_RESTORE_FRAMES = 5; + +/** Late-mounted screen headers (skeleton → real header, Suspense, conditional render) may attach after the transition; retry budget for `useScreenInitialFocus`. */ +const MAX_INITIAL_FOCUS_FRAMES = 5; + +/** Trigger map FIFO cap (web + native). Forward-only PUSH_PARAMS sessions can otherwise pin detached DOM nodes indefinitely. */ +const TRIGGER_MAP_MAX = 64; + +/** A click long before a timer-triggered nav must not be captured as that nav's trigger (web mouse modality). */ +const MOUSE_TRIGGER_TTL_MS = 3_000; + +/** Same window on native — the press that started a forward nav is consumed within this many ms. */ +const PRESS_TRIGGER_TTL_MS = 3_000; + +/** Grace window after a successful RETURN restore: vetoes in-flight AUTO/INITIAL so the restored target isn't trampled by the next screen's autofocus. */ +const RETURN_HOLD_MS = 500; + +/** Popover/modal launcher entry lives in the LauncherStack this long after `markActivePopoverLauncherDeactivated`; covers click→state-listener→capture latency. */ +const LAUNCHER_CLEAR_DELAY_MS = 1_000; + +/** Soft cap on the LauncherStack; warned once-per-session if exceeded (signals a pathological trap loop). */ +const LAUNCHER_STACK_MAX = 8; + +export {MAX_RESTORE_FRAMES, MAX_INITIAL_FOCUS_FRAMES, TRIGGER_MAP_MAX, MOUSE_TRIGGER_TTL_MS, PRESS_TRIGGER_TTL_MS, RETURN_HOLD_MS, LAUNCHER_CLEAR_DELAY_MS, LAUNCHER_STACK_MAX}; diff --git a/src/libs/isEffectivelyVisible.ts b/src/libs/isEffectivelyVisible.ts new file mode 100644 index 000000000000..21da1df4f795 --- /dev/null +++ b/src/libs/isEffectivelyVisible.ts @@ -0,0 +1,15 @@ +/** True when `el` is visible to the user: not under any `display: none` ancestor and not `visibility: hidden` itself. */ +function isEffectivelyVisible(el: HTMLElement): boolean { + if (typeof window === 'undefined') { + return true; + } + // `display` is element-self only — walk ancestors. `visibility` is inherited — self-check suffices. + for (let node: HTMLElement | null = el; node && node !== document.body; node = node.parentElement) { + if (window.getComputedStyle(node).display === 'none') { + return false; + } + } + return window.getComputedStyle(el).visibility !== 'hidden'; +} + +export default isEffectivelyVisible; diff --git a/src/libs/isHTMLElement.ts b/src/libs/isHTMLElement.ts new file mode 100644 index 000000000000..24ce49fe17a7 --- /dev/null +++ b/src/libs/isHTMLElement.ts @@ -0,0 +1,6 @@ +/** Typed guard. `typeof HTMLElement` covers stock native; the `getAttribute` duck-type covers HybridApp builds that expose `HTMLElement` but whose native View lacks DOM methods. */ +function isHTMLElement(value: unknown): value is HTMLElement { + return typeof HTMLElement !== 'undefined' && value instanceof HTMLElement && typeof value.getAttribute === 'function'; +} + +export default isHTMLElement; diff --git a/src/libs/navigationStateDiff.ts b/src/libs/navigationStateDiff.ts new file mode 100644 index 000000000000..6f625c652ac0 --- /dev/null +++ b/src/libs/navigationStateDiff.ts @@ -0,0 +1,51 @@ +import {findFocusedRoute} from '@react-navigation/core'; +import type {NavigationState, PartialState} from '@react-navigation/native'; + +type AnyState = NavigationState | PartialState | undefined; + +type DiffAction = {type: 'forward'; captureKey: string} | {type: 'backward'; restoreKey: string} | {type: 'lateral'} | {type: 'noop'}; + +function collectRouteKeys(state: AnyState, out = new Set()): Set { + if (!state?.routes) { + return out; + } + for (const route of state.routes) { + if (route.key) { + out.add(route.key); + } + if (route.state) { + collectRouteKeys(route.state, out); + } + } + return out; +} + +function diffNavigationState(prev: AnyState, next: NavigationState): {action: DiffAction; removedKeys: string[]} { + const newFocusedKey = findFocusedRoute(next)?.key; + const prevFocusedKey = prev ? findFocusedRoute(prev)?.key : undefined; + + const prevKeys = collectRouteKeys(prev); + const newKeys = collectRouteKeys(next); + const removedKeys: string[] = []; + for (const key of prevKeys) { + if (!newKeys.has(key)) { + removedKeys.push(key); + } + } + + let action: DiffAction; + if (!prevFocusedKey || !newFocusedKey || prevFocusedKey === newFocusedKey) { + action = {type: 'noop'}; + } else if (prevKeys.has(newFocusedKey) && removedKeys.length > 0) { + action = {type: 'backward', restoreKey: newFocusedKey}; + } else if (!prevKeys.has(newFocusedKey)) { + action = {type: 'forward', captureKey: prevFocusedKey}; + } else { + // Key existed, nothing dropped — e.g. top-tab switch with all tabs mounted. + action = {type: 'lateral'}; + } + + return {action, removedKeys}; +} + +export {collectRouteKeys, diffNavigationState}; diff --git a/src/libs/programmaticFocus.ts b/src/libs/programmaticFocus.ts new file mode 100644 index 000000000000..53e182046577 --- /dev/null +++ b/src/libs/programmaticFocus.ts @@ -0,0 +1,15 @@ +// Suppresses the `:focus-visible` ring (see web/index.html) on programmatically-focused elements so mouse users don't see a ring (WCAG 2.4.7). +const PROGRAMMATIC_FOCUS_DATA_ATTRIBUTE = 'data-programmatic-focus'; + +/** Marks `el` as receiving programmatic (a11y autofocus) focus so the `:focus-visible` ring is suppressed. Auto-clears on blur; the returned function clears synchronously if the focus call never landed. */ +function markProgrammaticFocus(el: HTMLElement): () => void { + el.setAttribute(PROGRAMMATIC_FOCUS_DATA_ATTRIBUTE, 'true'); + const clear = () => el.removeAttribute(PROGRAMMATIC_FOCUS_DATA_ATTRIBUTE); + el.addEventListener('blur', clear, {once: true}); + return () => { + el.removeEventListener('blur', clear); + clear(); + }; +} + +export default markProgrammaticFocus; diff --git a/src/libs/restoreFocusWithModality.ts b/src/libs/restoreFocusWithModality.ts new file mode 100644 index 000000000000..eb7c861746d7 --- /dev/null +++ b/src/libs/restoreFocusWithModality.ts @@ -0,0 +1,25 @@ +import getHadTabNavigation from './hadTabNavigation'; +import sharedTrapStack from './sharedTrapStack'; + +/** + * Pauses the topmost focus-trap during the focus call — works around focus-trap-react auto-unpausing the next-topmost trap on + * `deactivate()`, whose re-attached `checkFocusIn` would otherwise yank focus back into the closing container. Leaves an + * already-paused trap alone so we don't resurrect a pause owned by another caller. + */ +function restoreFocusWithModality(el: HTMLElement, {preventScroll = true}: {preventScroll?: boolean} = {}): void { + const parentTrap = sharedTrapStack.at(-1); + const wasAlreadyPaused = parentTrap?.paused ?? false; + if (parentTrap && !wasAlreadyPaused) { + parentTrap.pause(); + } + try { + el.focus({preventScroll, focusVisible: getHadTabNavigation()}); + } finally { + // Mirror the pause — unpause only if we paused, even if `parentTrap` is no longer topmost (focus-trap's deactivate auto-unwind depends on it). + if (parentTrap && !wasAlreadyPaused) { + parentTrap.unpause(); + } + } +} + +export default restoreFocusWithModality; diff --git a/src/components/FocusTrap/sharedTrapStack.ts b/src/libs/sharedTrapStack.ts similarity index 100% rename from src/components/FocusTrap/sharedTrapStack.ts rename to src/libs/sharedTrapStack.ts diff --git a/src/pages/inbox/HeaderView.tsx b/src/pages/inbox/HeaderView.tsx index 177cf5009048..438aefb9413c 100644 --- a/src/pages/inbox/HeaderView.tsx +++ b/src/pages/inbox/HeaderView.tsx @@ -24,6 +24,7 @@ import Text from '@components/Text'; import Tooltip from '@components/Tooltip'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useHasTeam2025Pricing from '@hooks/useHasTeam2025Pricing'; +import useInitialFocusRef from '@hooks/useInitialFocusRef'; import useIsInSidePanel from '@hooks/useIsInSidePanel'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -125,6 +126,7 @@ function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) const {translate, localeCompare, formatPhoneNumber} = useLocalize(); const theme = useTheme(); const styles = useThemeStyles(); + const setBackButtonRef = useInitialFocusRef({shouldClaimOnlyForScreenReader: true}); const isSelfDM = isSelfDMReportUtils(report); const isGroupChat = isGroupChatReportUtils(report) || isDeprecatedGroupDM(report, isReportArchived); const isConciergeChat = isConciergeChatReport(report, conciergeReportID); @@ -293,6 +295,7 @@ function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) {shouldShowBackButton && ( { - Navigation.goBack(backTo); - }, [backTo]); - - useEffect(() => { - if (!isSaved || !shouldNavigateAfterSaveRef.current) { - return; - } - shouldNavigateAfterSaveRef.current = false; - navigateBack(); - }, [isSaved, navigateBack]); + const {navigateBack, armNavigateBack} = useNavigateBackOnSave(isSaved, backTo, {shouldSkipFocusRestore: !isEditing}); const updateDescriptionRef = (value: string) => { setCurrentDescription(value); @@ -128,14 +117,14 @@ function IOURequestStepDescription({ if (newComment === currentDescriptionInMarkdown) { setIsSaved(true); - shouldNavigateAfterSaveRef.current = true; + armNavigateBack(); return; } if (isEditingSplit) { setDraftSplitTransaction(transaction?.transactionID, splitDraftTransaction, {comment: newComment}); setIsSaved(true); - shouldNavigateAfterSaveRef.current = true; + armNavigateBack(); return; } @@ -159,7 +148,7 @@ function IOURequestStepDescription({ } setIsSaved(true); - shouldNavigateAfterSaveRef.current = true; + armNavigateBack(); }; const shouldShowNotFoundPage = useShowNotFoundPageInIOUStep(action, iouType, reportActionID, report, transaction); diff --git a/src/pages/iou/request/step/IOURequestStepMerchant.tsx b/src/pages/iou/request/step/IOURequestStepMerchant.tsx index 537e0b1dc7d6..46b64db90309 100644 --- a/src/pages/iou/request/step/IOURequestStepMerchant.tsx +++ b/src/pages/iou/request/step/IOURequestStepMerchant.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useEffect, useRef, useState} from 'react'; +import React, {useCallback, useState} from 'react'; import {View} from 'react-native'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; @@ -9,6 +9,7 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useDelegateAccountID from '@hooks/useDelegateAccountID'; import useDiscardChangesConfirmation from '@hooks/useDiscardChangesConfirmation'; import useLocalize from '@hooks/useLocalize'; +import useNavigateBackOnSave from '@hooks/useNavigateBackOnSave'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; @@ -17,8 +18,6 @@ import useRestartOnReceiptFailure from '@hooks/useRestartOnReceiptFailure'; import useShowNotFoundPageInIOUStep from '@hooks/useShowNotFoundPageInIOUStep'; import useThemeStyles from '@hooks/useThemeStyles'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import Navigation from '@libs/Navigation/Navigation'; -import {skipNextFocusRestore} from '@libs/NavigationFocusReturn'; import {getTransactionDetails, isExpenseRequest, isPolicyExpenseChat} from '@libs/ReportUtils'; import {hasReceipt} from '@libs/TransactionUtils'; import {isInvalidMerchantValue, isValidInputLength} from '@libs/ValidationUtils'; @@ -67,7 +66,6 @@ function IOURequestStepMerchant({ const [currentMerchant, setCurrentMerchant] = useState(initialMerchant); const [isSaved, setIsSaved] = useState(false); const [isDiscardModalVisible, setIsDiscardModalVisible] = useState(false); - const shouldNavigateAfterSaveRef = useRef(false); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const delegateAccountID = useDelegateAccountID(); const currentUserAccountIDParam = currentUserPersonalDetails.accountID; @@ -78,19 +76,7 @@ function IOURequestStepMerchant({ const isMerchantRequired = isPolicyExpenseChat(report) || isExpenseRequest(report) || transaction?.participants?.some((participant) => !!participant.isPolicyExpenseChat); - const navigateBack = useCallback(() => { - Navigation.goBack(backTo); - }, [backTo]); - - useEffect(() => { - if (!isSaved || !shouldNavigateAfterSaveRef.current) { - return; - } - shouldNavigateAfterSaveRef.current = false; - // Only on the save path. The Back button (onBackButtonPress) should still restore focus. - skipNextFocusRestore(); - navigateBack(); - }, [isSaved, navigateBack]); + const {navigateBack, armNavigateBack} = useNavigateBackOnSave(isSaved, backTo, {shouldSkipFocusRestore: !isEditing}); const validate = useCallback( (value: FormOnyxValues) => { @@ -121,19 +107,19 @@ function IOURequestStepMerchant({ if (isEditingSplitBill) { setDraftSplitTransaction(transactionID, splitDraftTransaction, {merchant: newMerchant}); setIsSaved(true); - shouldNavigateAfterSaveRef.current = true; + armNavigateBack(); return; } if (newMerchant === '' && isInvalidMerchantValue(merchant)) { setIsSaved(true); - shouldNavigateAfterSaveRef.current = true; + armNavigateBack(); clearMoneyRequestMerchant(transactionID); return; } if (newMerchant === merchant || (newMerchant === '' && isInvalidMerchantValue(merchant))) { setIsSaved(true); - shouldNavigateAfterSaveRef.current = true; + armNavigateBack(); return; } // updateMoneyRequestMerchant's optimisticData already sets merchant on TRANSACTION{id}, @@ -161,7 +147,7 @@ function IOURequestStepMerchant({ setMoneyRequestMerchant(transactionID, newMerchant, true, hasReceipt(transaction)); } setIsSaved(true); - shouldNavigateAfterSaveRef.current = true; + armNavigateBack(); }; useDiscardChangesConfirmation({ diff --git a/src/pages/settings/Profile/ProfilePage.tsx b/src/pages/settings/Profile/ProfilePage.tsx index e4d2faa33bd2..22911c919854 100755 --- a/src/pages/settings/Profile/ProfilePage.tsx +++ b/src/pages/settings/Profile/ProfilePage.tsx @@ -97,6 +97,7 @@ function ProfilePage() { description: translate('displayNamePage.headerTitle'), title: formatPhoneNumber(getDisplayNameOrDefault(currentUserPersonalDetails)), pageRoute: ROUTES.SETTINGS_DISPLAY_NAME, + testID: 'display-name-menu-item', sentryLabel: CONST.SENTRY_LABEL.SETTINGS_PROFILE.DISPLAY_NAME, }, { @@ -115,6 +116,7 @@ function ProfilePage() { title: emojiCode ? `${emojiCode} ${currentUserPersonalDetails?.status?.text ?? ''}` : '', pageRoute: ROUTES.SETTINGS_STATUS, brickRoadIndicator: isEmptyObject(vacationDelegate?.errors) ? undefined : CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR, + testID: 'status-menu-item', sentryLabel: CONST.SENTRY_LABEL.SETTINGS_PROFILE.STATUS, }, ...(!isAgentAccount @@ -149,18 +151,21 @@ function ProfilePage() { { description: translate('privatePersonalDetails.legalName'), title: legalName, + testID: 'legal-name-menu-item', sentryLabel: CONST.SENTRY_LABEL.SETTINGS_PROFILE.LEGAL_NAME, action: () => navigateToPrivateDetails(INPUT_IDS.LEGAL_FIRST_NAME), }, { description: translate('common.dob'), title: privateDetails.dob ?? '', + testID: 'dob-menu-item', sentryLabel: CONST.SENTRY_LABEL.SETTINGS_PROFILE.DATE_OF_BIRTH, action: () => navigateToPrivateDetails(INPUT_IDS.DATE_OF_BIRTH), }, { description: translate('common.phoneNumber'), title: privateDetails.phoneNumber ?? '', + testID: 'phone-number-menu-item', sentryLabel: CONST.SENTRY_LABEL.SETTINGS_PROFILE.PHONE_NUMBER, action: () => navigateToPrivateDetails(INPUT_IDS.PHONE_NUMBER), brickRoadIndicator: privatePersonalDetails?.errorFields?.phoneNumber ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, @@ -168,6 +173,7 @@ function ProfilePage() { { description: translate('privatePersonalDetails.address'), title: getFormattedAddress(privateDetails), + testID: 'address-menu-item', sentryLabel: CONST.SENTRY_LABEL.SETTINGS_PROFILE.ADDRESS, action: () => navigateToPrivateDetails(INPUT_IDS.ADDRESS_LINE_1), }, @@ -255,7 +261,7 @@ function ProfilePage() { return ( ( ))} diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/subPages/Confirmation.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/subPages/Confirmation.tsx index 56276b106f1e..cbca9e8f21cf 100644 --- a/src/pages/settings/Wallet/InternationalDepositAccount/subPages/Confirmation.tsx +++ b/src/pages/settings/Wallet/InternationalDepositAccount/subPages/Confirmation.tsx @@ -24,6 +24,7 @@ import type {CorpayFormField} from '@src/types/onyx'; const STEP_INDEXES = CONST.CORPAY_FIELDS.INDEXES.MAPPING; type MenuItemProps = { + id: string; description: string; title: string; shouldShowRightIcon: boolean; @@ -85,6 +86,7 @@ function Confirmation({onNext, onMove, formValues, fieldsMap}: CustomSubPageProp const summaryItems: MenuItemProps[] = [ { + id: 'bankCountry', description: translate('common.country'), title: translate(`allCountries.${formValues.bankCountry}` as TranslationPaths), shouldShowRightIcon: true, @@ -94,6 +96,7 @@ function Confirmation({onNext, onMove, formValues, fieldsMap}: CustomSubPageProp disabled: isOffline, }, { + id: 'bankCurrency', description: translate('common.currency'), title: `${formValues.bankCurrency} - ${getCurrencySymbol(formValues.bankCurrency)}`, shouldShowRightIcon: true, @@ -106,6 +109,7 @@ function Confirmation({onNext, onMove, formValues, fieldsMap}: CustomSubPageProp for (const [fieldName, field] of Object.entries(fieldsMap[CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_DETAILS] ?? {})) { summaryItems.push({ + id: `${CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_DETAILS}-${fieldName}`, description: field.label + (field.isRequired ? '' : ` (${translate('common.optional')})`), title: getTitle(field, fieldName), shouldShowRightIcon: true, @@ -117,6 +121,7 @@ function Confirmation({onNext, onMove, formValues, fieldsMap}: CustomSubPageProp for (const [fieldName, field] of Object.entries(fieldsMap[CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_TYPE] ?? {})) { summaryItems.push({ + id: `${CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_TYPE}-${fieldName}`, description: field.label + (field.isRequired ? '' : ` (${translate('common.optional')})`), title: getTitle(field, fieldName), shouldShowRightIcon: true, @@ -130,6 +135,7 @@ function Confirmation({onNext, onMove, formValues, fieldsMap}: CustomSubPageProp ([field1], [field2]) => CONST.CORPAY_FIELDS.BANK_INFORMATION_FIELDS.indexOf(field1) - CONST.CORPAY_FIELDS.BANK_INFORMATION_FIELDS.indexOf(field2), )) { summaryItems.push({ + id: `${CONST.CORPAY_FIELDS.PAGE_NAME.BANK_INFORMATION}-${fieldName}`, description: field.label + (field.isRequired ? '' : ` (${translate('common.optional')})`), title: getTitle(field, fieldName), shouldShowRightIcon: true, @@ -143,6 +149,7 @@ function Confirmation({onNext, onMove, formValues, fieldsMap}: CustomSubPageProp ([field1], [field2]) => CONST.CORPAY_FIELDS.ACCOUNT_HOLDER_FIELDS.indexOf(field1) - CONST.CORPAY_FIELDS.ACCOUNT_HOLDER_FIELDS.indexOf(field2), )) { summaryItems.push({ + id: `${CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_HOLDER_DETAILS}-${fieldName}`, description: field.label + (field.isRequired ? '' : ` (${translate('common.optional')})`), title: fieldName === CONST.CORPAY_FIELDS.ACCOUNT_HOLDER_COUNTRY_KEY ? translate(`allCountries.${formValues.bankCountry}` as TranslationPaths) : getTitle(field, fieldName), shouldShowRightIcon: fieldName !== CONST.CORPAY_FIELDS.ACCOUNT_HOLDER_COUNTRY_KEY, @@ -170,9 +177,10 @@ function Confirmation({onNext, onMove, formValues, fieldsMap}: CustomSubPageProp {translate('addPersonalBankAccount.confirmationStepHeader')} {translate('addPersonalBankAccount.confirmationStepSubHeader')} - {summaryItems.map(({description, title, shouldShowRightIcon, interactive, disabled, onPress}) => ( + {summaryItems.map(({id, description, title, shouldShowRightIcon, interactive, disabled, onPress}) => ( ({name, matchType: merchantMatchTypes.at(index)})); return ( createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW_SPEND_RULE_MERCHANT_EDIT.getRoute(merchantIndex))} /> diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx index b9b1f84335d4..ff55fb49a285 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx @@ -337,6 +337,7 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM interactive={!isReimburser} description={translate('common.role')} shouldShowRightIcon={!isReimburser} + pressableTestID="member-role-menu-item" onPress={() => { if ( tryNavigateToSubmitWorkspaceUpgrade( @@ -361,6 +362,7 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM title={member?.employeeUserID} shouldShowRightIcon onPress={() => Navigation.navigate(ROUTES.WORKSPACE_CUSTOM_FIELDS.getRoute(policyID, accountID, 'customField1'))} + pressableTestID="member-customField1-menu-item" /> @@ -369,6 +371,7 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM title={member?.employeePayrollID} shouldShowRightIcon onPress={() => Navigation.navigate(ROUTES.WORKSPACE_CUSTOM_FIELDS.getRoute(policyID, accountID, 'customField2'))} + pressableTestID="member-customField2-menu-item" /> @@ -379,6 +382,7 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM icon={icons.Info} onPress={navigateToProfile} shouldShowRightIcon + pressableTestID="member-profile-menu-item" /> {memberCards.length > 0 && ( <> @@ -396,13 +400,14 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM return ( ({name, matchType: merchantMatchTypes.at(index)})); return ( ROUTES.RULES_SPEND_MERCHANT_EDIT.getRoute(policyID, ruleID, merchantIndex)} /> ); diff --git a/tests/perf-test/SelectionList.perf-test.tsx b/tests/perf-test/SelectionList.perf-test.tsx index c523436645a3..89ce33c23c61 100644 --- a/tests/perf-test/SelectionList.perf-test.tsx +++ b/tests/perf-test/SelectionList.perf-test.tsx @@ -4,6 +4,7 @@ import React, {useState} from 'react'; import type {ComponentType} from 'react'; import type ReactNative from 'react-native'; import {measureRenders} from 'reassure'; +import mockReactNavigationNative from 'tests/utils/mockReactNavigationNative'; import SelectionList from '@components/SelectionList'; import MultiSelectListItem from '@components/SelectionList/ListItem/MultiSelectListItem'; import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; @@ -61,6 +62,7 @@ jest.mock('@react-navigation/stack', () => ({ })); jest.mock('@react-navigation/native', () => ({ + ...mockReactNavigationNative(), useFocusEffect: () => {}, useIsFocused: () => true, createNavigationContainerRef: jest.fn(), diff --git a/tests/ui/IOURequestStepAmountDraftTest.tsx b/tests/ui/IOURequestStepAmountDraftTest.tsx index 818f141dba22..9c39685f2526 100644 --- a/tests/ui/IOURequestStepAmountDraftTest.tsx +++ b/tests/ui/IOURequestStepAmountDraftTest.tsx @@ -5,6 +5,7 @@ import {act, fireEvent, render, screen} from '@testing-library/react-native'; import React from 'react'; import Onyx from 'react-native-onyx'; +import mockReactNavigationNative from 'tests/utils/mockReactNavigationNative'; import {CurrentUserPersonalDetailsProvider} from '@components/CurrentUserPersonalDetailsProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; import IOURequestStepAmount from '@pages/iou/request/step/IOURequestStepAmount'; @@ -109,6 +110,7 @@ jest.mock('@react-navigation/native', () => { getState: jest.fn(() => ({})), }; return { + ...mockReactNavigationNative(), createNavigationContainerRef: jest.fn(() => mockRef), useIsFocused: () => true, useNavigation: () => ({navigate: jest.fn(), addListener: jest.fn()}), diff --git a/tests/ui/IOURequestStepDistanceTest.tsx b/tests/ui/IOURequestStepDistanceTest.tsx index 9c99b46c5a3e..399ca570460f 100644 --- a/tests/ui/IOURequestStepDistanceTest.tsx +++ b/tests/ui/IOURequestStepDistanceTest.tsx @@ -6,6 +6,7 @@ import {act, fireEvent, render, screen} from '@testing-library/react-native'; import React from 'react'; import Onyx from 'react-native-onyx'; +import mockReactNavigationNative from 'tests/utils/mockReactNavigationNative'; import {CurrentUserPersonalDetailsProvider} from '@components/CurrentUserPersonalDetailsProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; @@ -153,6 +154,7 @@ jest.mock('@react-navigation/native', () => { getState: jest.fn(() => ({})), }; return { + ...mockReactNavigationNative(), createNavigationContainerRef: jest.fn(() => mockRef), useIsFocused: () => true, useNavigation: () => ({navigate: jest.fn(), addListener: jest.fn()}), diff --git a/tests/ui/IOURequestStepTimeRateTest.tsx b/tests/ui/IOURequestStepTimeRateTest.tsx index 67b0c5ebcd0d..3c65438393a5 100644 --- a/tests/ui/IOURequestStepTimeRateTest.tsx +++ b/tests/ui/IOURequestStepTimeRateTest.tsx @@ -1,6 +1,7 @@ import {act, fireEvent, render, screen} from '@testing-library/react-native'; import React from 'react'; import Onyx from 'react-native-onyx'; +import mockReactNavigationNative from 'tests/utils/mockReactNavigationNative'; import {CurrencyListContextProvider} from '@components/CurrencyListContextProvider'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; @@ -29,6 +30,7 @@ jest.mock('@react-navigation/native', () => { getState: jest.fn(() => ({})), }; return { + ...mockReactNavigationNative(), createNavigationContainerRef: jest.fn(() => mockRef), useIsFocused: () => true, useNavigation: () => ({ diff --git a/tests/ui/TimeExpenseConfirmationTest.tsx b/tests/ui/TimeExpenseConfirmationTest.tsx index faebe1c0500e..b1ac6956c493 100644 --- a/tests/ui/TimeExpenseConfirmationTest.tsx +++ b/tests/ui/TimeExpenseConfirmationTest.tsx @@ -1,6 +1,7 @@ import {act, fireEvent, render, screen, within} from '@testing-library/react-native'; import React from 'react'; import Onyx from 'react-native-onyx'; +import mockReactNavigationNative from 'tests/utils/mockReactNavigationNative'; import {CurrencyListContextProvider} from '@components/CurrencyListContextProvider'; import {CurrentUserPersonalDetailsProvider} from '@components/CurrentUserPersonalDetailsProvider'; import HTMLEngineProvider from '@components/HTMLEngineProvider'; @@ -110,6 +111,7 @@ jest.mock('@react-navigation/native', () => { getState: jest.fn(() => ({})), }; return { + ...mockReactNavigationNative(), createNavigationContainerRef: jest.fn(() => mockRef), useIsFocused: () => true, useNavigation: () => ({navigate: jest.fn(), addListener: jest.fn()}), diff --git a/tests/ui/components/HoldReasonFormViewTest.tsx b/tests/ui/components/HoldReasonFormViewTest.tsx index 3bf8f35195b1..350ee21f8b64 100644 --- a/tests/ui/components/HoldReasonFormViewTest.tsx +++ b/tests/ui/components/HoldReasonFormViewTest.tsx @@ -1,5 +1,6 @@ import {render, screen} from '@testing-library/react-native'; import React from 'react'; +import mockReactNavigationNative from 'tests/utils/mockReactNavigationNative'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; import HoldReasonFormView from '@pages/iou/HoldReasonFormView'; import {translateLocal} from '../../utils/TestHelper'; @@ -12,6 +13,7 @@ jest.mock('@libs/Navigation/Navigation', () => ({ navigate: jest.fn(), })); jest.mock('@react-navigation/native', () => ({ + ...mockReactNavigationNative(), createNavigationContainerRef: jest.fn(), useIsFocused: () => true, useNavigation: () => ({navigate: jest.fn(), addListener: jest.fn(), goBack: jest.fn(), isFocused: () => true}), diff --git a/tests/ui/components/IOURequestStepConfirmationPageTest.tsx b/tests/ui/components/IOURequestStepConfirmationPageTest.tsx index c1c0f3b8b1d6..c6c0d247798c 100644 --- a/tests/ui/components/IOURequestStepConfirmationPageTest.tsx +++ b/tests/ui/components/IOURequestStepConfirmationPageTest.tsx @@ -2,6 +2,7 @@ import {act, fireEvent, render, screen, waitFor} from '@testing-library/react-na import React from 'react'; import Onyx from 'react-native-onyx'; import OnyxUtils from 'react-native-onyx/dist/OnyxUtils'; +import mockReactNavigationNative from 'tests/utils/mockReactNavigationNative'; import {CurrentUserPersonalDetailsProvider} from '@components/CurrentUserPersonalDetailsProvider'; import HTMLEngineProvider from '@components/HTMLEngineProvider'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; @@ -145,6 +146,7 @@ jest.mock('@react-navigation/native', () => { getState: jest.fn(() => ({})), }; return { + ...mockReactNavigationNative(), createNavigationContainerRef: jest.fn(() => mockRef), useIsFocused: () => true, useNavigation: () => ({navigate: jest.fn(), addListener: jest.fn()}), diff --git a/tests/ui/components/IOURequestStepDistanceTest.tsx b/tests/ui/components/IOURequestStepDistanceTest.tsx index 76bd75ca8b5a..1fb35d8120f2 100644 --- a/tests/ui/components/IOURequestStepDistanceTest.tsx +++ b/tests/ui/components/IOURequestStepDistanceTest.tsx @@ -1,6 +1,7 @@ import {render, screen} from '@testing-library/react-native'; import React from 'react'; import Onyx from 'react-native-onyx'; +import mockReactNavigationNative from 'tests/utils/mockReactNavigationNative'; import {CurrentUserPersonalDetailsProvider} from '@components/CurrentUserPersonalDetailsProvider'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; @@ -107,6 +108,7 @@ jest.mock('@react-navigation/native', () => { getState: jest.fn(() => ({})), }; return { + ...mockReactNavigationNative(), createNavigationContainerRef: jest.fn(() => mockRef), useIsFocused: () => true, useNavigation: () => ({navigate: jest.fn(), addListener: jest.fn()}), diff --git a/tests/ui/components/IOURequestStepReportTest.tsx b/tests/ui/components/IOURequestStepReportTest.tsx index be407b9d47de..53a83a1e7fed 100644 --- a/tests/ui/components/IOURequestStepReportTest.tsx +++ b/tests/ui/components/IOURequestStepReportTest.tsx @@ -1,6 +1,7 @@ import {fireEvent, render, screen} from '@testing-library/react-native'; import React from 'react'; import Onyx from 'react-native-onyx'; +import mockReactNavigationNative from 'tests/utils/mockReactNavigationNative'; import {CurrentUserPersonalDetailsProvider} from '@components/CurrentUserPersonalDetailsProvider'; import HTMLEngineProvider from '@components/HTMLEngineProvider'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; @@ -56,6 +57,7 @@ jest.mock('@react-navigation/native', () => { getState: jest.fn(() => ({})), }; return { + ...mockReactNavigationNative(), createNavigationContainerRef: jest.fn(() => mockRef), useIsFocused: () => true, useNavigation: () => ({navigate: jest.fn(), addListener: jest.fn()}), diff --git a/tests/ui/components/SearchAutocompleteListTest.tsx b/tests/ui/components/SearchAutocompleteListTest.tsx index cf727b0865c4..c81cbe901eb9 100644 --- a/tests/ui/components/SearchAutocompleteListTest.tsx +++ b/tests/ui/components/SearchAutocompleteListTest.tsx @@ -1,6 +1,7 @@ import {act, render} from '@testing-library/react-native'; import React from 'react'; import Onyx from 'react-native-onyx'; +import mockReactNavigationNative from 'tests/utils/mockReactNavigationNative'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; import SearchAutocompleteList from '@components/Search/SearchAutocompleteList'; @@ -13,6 +14,7 @@ import waitForBatchedUpdatesWithAct from '../../utils/waitForBatchedUpdatesWithA jest.mock('@src/components/ConfirmedRoute.tsx'); jest.mock('@react-navigation/native', () => ({ + ...mockReactNavigationNative(), useIsFocused: jest.fn(), useRoute: jest.fn(), usePreventRemove: jest.fn(), diff --git a/tests/ui/components/SettlementButtonTest.tsx b/tests/ui/components/SettlementButtonTest.tsx index 46c666283677..eee2024914c9 100644 --- a/tests/ui/components/SettlementButtonTest.tsx +++ b/tests/ui/components/SettlementButtonTest.tsx @@ -1,6 +1,7 @@ import {act, fireEvent, render, screen} from '@testing-library/react-native'; import React from 'react'; import Onyx from 'react-native-onyx'; +import mockReactNavigationNative from 'tests/utils/mockReactNavigationNative'; import type {ValueOf} from 'type-fest'; import ComposeProviders from '@components/ComposeProviders'; import {CurrentUserPersonalDetailsProvider} from '@components/CurrentUserPersonalDetailsProvider'; @@ -56,6 +57,7 @@ jest.mock('@react-navigation/native', () => { getState: jest.fn(() => ({})), }; return { + ...mockReactNavigationNative(), createNavigationContainerRef: jest.fn(() => mockRef), useIsFocused: () => true, useNavigation: () => ({navigate: jest.fn(), addListener: jest.fn()}), diff --git a/tests/ui/components/SpendRules/SpendRuleMerchantEditBaseTest.tsx b/tests/ui/components/SpendRules/SpendRuleMerchantEditBaseTest.tsx new file mode 100644 index 000000000000..fa7b119ba017 --- /dev/null +++ b/tests/ui/components/SpendRules/SpendRuleMerchantEditBaseTest.tsx @@ -0,0 +1,149 @@ +import {act, render} from '@testing-library/react-native'; +import React from 'react'; +import SpendRuleMerchantEditBase from '@components/SpendRules/configuration/SpendRuleMerchantEditBase'; +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; + +type FormProviderMockProps = {children?: React.ReactNode; onSubmit?: () => void}; +type InputWrapperMockProps = {onChangeText?: (value: string) => void}; +type ChildrenOnly = {children?: React.ReactNode}; + +// Captured callbacks across mocks let us drive the form imperatively without rendering the real FormProvider chain. +let capturedSubmit: (() => void) | null = null; +let capturedOnChangeText: ((value: string) => void) | null = null; + +const mockGoBack = jest.fn(); +const mockSkipNextFocusRestore = jest.fn(); + +jest.mock('@react-navigation/native', () => ({ + __esModule: true, + useNavigation: () => ({goBack: mockGoBack}), +})); + +jest.mock('@libs/NavigationFocusReturn', () => ({ + __esModule: true, + skipNextFocusRestore: () => { + mockSkipNextFocusRestore(); + }, +})); + +jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key, formatPhoneNumber: (phone: string) => phone, preferredLocale: 'en'})); +jest.mock('@hooks/useThemeStyles', () => () => new Proxy({}, {get: () => ({})})); +jest.mock('@hooks/useCanWriteCardSpendRules', () => () => true); +jest.mock('@hooks/useAutoFocusInput', () => () => ({inputCallbackRef: () => {}})); + +jest.mock('@components/Form/FormProvider', () => ({ + __esModule: true, + default: ({children, onSubmit}: FormProviderMockProps) => { + capturedSubmit = onSubmit ?? null; + return children; + }, +})); + +jest.mock('@components/Form/InputWrapper', () => ({ + __esModule: true, + default: ({onChangeText}: InputWrapperMockProps) => { + capturedOnChangeText = onChangeText ?? null; + return null; + }, +})); + +jest.mock('@components/HeaderWithBackButton', () => ({__esModule: true, default: () => null})); +jest.mock('@components/ScreenWrapper', () => ({__esModule: true, default: ({children}: ChildrenOnly) => children})); +jest.mock('@components/SelectionList', () => ({__esModule: true, default: () => null})); +jest.mock('@components/SelectionList/ListItem/SingleSelectListItem', () => ({__esModule: true, default: () => null})); +jest.mock('@components/Text', () => ({__esModule: true, default: () => null})); +jest.mock('@components/TextInput', () => ({__esModule: true, default: () => null})); +jest.mock('@pages/workspace/AccessOrNotFoundWrapper', () => ({__esModule: true, default: ({children}: ChildrenOnly) => children})); + +type EditProps = Parameters[0]; + +function renderEdit(props: Partial = {}, onMerchantDataChange = jest.fn()) { + const defaults: EditProps = { + policyID: 'policy-1', + merchantIndex: '0', + merchantNames: ['Acme'], + merchantMatchTypes: [CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS], + onMerchantDataChange, + }; + const result = render( + , + ); + return {...result, onMerchantDataChange}; +} + +beforeEach(() => { + capturedSubmit = null; + capturedOnChangeText = null; + mockGoBack.mockReset(); + mockSkipNextFocusRestore.mockReset(); +}); + +describe('SpendRuleMerchantEditBase.submit — skipNextFocusRestore fires on every submit-driven goBack (#90838 class)', () => { + it('delete branch (existing merchant, empty name): skips restore, navigates back, and filters the row out', () => { + const {onMerchantDataChange} = renderEdit({ + merchantIndex: '0', + merchantNames: ['Acme', 'Globex'], + merchantMatchTypes: [CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS, CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO], + }); + + act(() => capturedOnChangeText?.('')); + capturedSubmit?.(); + + expect(mockSkipNextFocusRestore).toHaveBeenCalledTimes(1); + expect(mockGoBack).toHaveBeenCalledTimes(1); + expect(onMerchantDataChange).toHaveBeenCalledWith(['Globex'], [CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO]); + }); + + it('cancel-on-new (new-merchant flow, empty name): still skips restore even though onMerchantDataChange is NOT invoked — the destination form Save button must not be hijacked', () => { + const {onMerchantDataChange} = renderEdit({merchantIndex: ROUTES.NEW, merchantNames: ['Acme'], merchantMatchTypes: [CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS]}); + + capturedSubmit?.(); + + expect(mockSkipNextFocusRestore).toHaveBeenCalledTimes(1); + expect(mockGoBack).toHaveBeenCalledTimes(1); + expect(onMerchantDataChange).not.toHaveBeenCalled(); + }); + + it('rename (existing merchant, non-empty name): skips restore, navigates back, and replaces only the indexed row — defends the Codex-caught Enter-hijack on the destination list', () => { + const {onMerchantDataChange} = renderEdit({ + merchantIndex: '0', + merchantNames: ['Acme', 'Globex'], + merchantMatchTypes: [CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS, CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO], + }); + + act(() => capturedOnChangeText?.('Acme Inc')); + capturedSubmit?.(); + + expect(mockSkipNextFocusRestore).toHaveBeenCalledTimes(1); + expect(mockGoBack).toHaveBeenCalledTimes(1); + expect(onMerchantDataChange).toHaveBeenCalledWith(['Acme Inc', 'Globex'], [CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS, CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO]); + }); + + it('new-merchant add (new flow, non-empty name): skips restore, navigates back, and appends to the arrays — same Enter-hijack defense', () => { + const {onMerchantDataChange} = renderEdit({merchantIndex: ROUTES.NEW, merchantNames: ['Acme'], merchantMatchTypes: [CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS]}); + + act(() => capturedOnChangeText?.('Globex')); + capturedSubmit?.(); + + expect(mockSkipNextFocusRestore).toHaveBeenCalledTimes(1); + expect(mockGoBack).toHaveBeenCalledTimes(1); + expect(onMerchantDataChange).toHaveBeenCalledWith(['Acme', 'Globex'], [CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS, CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS]); + }); + + it('order invariant: skipNextFocusRestore is called BEFORE Navigation.goBack on every branch — pinning the #90838 fix contract', () => { + const order: string[] = []; + mockSkipNextFocusRestore.mockImplementation(() => order.push('skip')); + mockGoBack.mockImplementation(() => order.push('goBack')); + + // Normal save (Codex-caught branch — the one that lacked the skip before this fix). + renderEdit({merchantIndex: '0', merchantNames: ['Acme'], merchantMatchTypes: [CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS]}); + act(() => capturedOnChangeText?.('Acme Inc')); + capturedSubmit?.(); + + expect(order).toEqual(['skip', 'goBack']); + }); +}); diff --git a/tests/ui/components/SpendRules/SpendRuleMerchantsBaseTest.tsx b/tests/ui/components/SpendRules/SpendRuleMerchantsBaseTest.tsx new file mode 100644 index 000000000000..f3b509551b48 --- /dev/null +++ b/tests/ui/components/SpendRules/SpendRuleMerchantsBaseTest.tsx @@ -0,0 +1,82 @@ +import {render, screen} from '@testing-library/react-native'; +import React from 'react'; +import SpendRuleMerchantsBase from '@components/SpendRules/configuration/SpendRuleMerchantsBase'; +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type {Route} from '@src/ROUTES'; + +type RNComponent = React.ComponentType<{testID?: string; children?: React.ReactNode}>; +type ReactNative = {Pressable: RNComponent; View: RNComponent; Text: RNComponent}; + +jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key, formatPhoneNumber: (phone: string) => phone, preferredLocale: 'en'})); +jest.mock('@hooks/useThemeStyles', () => () => new Proxy({}, {get: () => ({})})); +jest.mock('@hooks/useCanWriteCardSpendRules', () => () => true); +jest.mock('@hooks/useLazyAsset', () => ({ + useMemoizedLazyExpensifyIcons: () => ({Plus: () => null}), + useMemoizedLazyIllustrations: () => ({FoodTruck: () => null}), +})); + +jest.mock('@components/MenuItemWithTopDescription', () => { + const reactNative = jest.requireActual('react-native'); + const RNPressable = reactNative.Pressable; + function MockMenuItem({pressableTestID, title}: {pressableTestID?: string; title?: string}) { + const RNView = reactNative.View; + return ( + + {title} + + ); + } + return {__esModule: true, default: MockMenuItem}; +}); + +jest.mock('@components/MenuItem', () => ({__esModule: true, default: () => null})); +jest.mock('@components/FormAlertWithSubmitButton', () => ({__esModule: true, default: () => null})); +jest.mock('@components/HeaderWithBackButton', () => ({__esModule: true, default: () => null})); +jest.mock('@components/BlockingViews/BlockingView', () => ({__esModule: true, default: () => null})); +jest.mock('@components/ScreenWrapper', () => { + const reactNative = jest.requireActual('react-native'); + const RNView = reactNative.View; + return {__esModule: true, default: ({children}: {children: React.ReactNode}) => {children}}; +}); +jest.mock('@components/ScrollView', () => { + const reactNative = jest.requireActual('react-native'); + return {__esModule: true, default: reactNative.View}; +}); +jest.mock('@pages/workspace/AccessOrNotFoundWrapper', () => { + const reactNative = jest.requireActual('react-native'); + const RNView = reactNative.View; + return {__esModule: true, default: ({children}: {children: React.ReactNode}) => {children}}; +}); + +type MerchantsProp = Parameters[0]['merchants']; + +function renderMerchants(merchants: MerchantsProp) { + return render( + ROUTES.HOME as Route} + />, + ); +} + +describe('SpendRuleMerchantsBase — rowId stability', () => { + it('rowId is derived from index alone, so editing `name` or `matchType` does not shift the focus-return identifier', () => { + renderMerchants([ + {name: 'Acme', matchType: CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS}, + {name: 'Globex', matchType: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO}, + ]); + expect(screen.getByTestId('merchant-0')).toBeOnTheScreen(); + expect(screen.getByTestId('merchant-1')).toBeOnTheScreen(); + + screen.unmount(); + renderMerchants([ + {name: 'Acme Inc', matchType: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO}, + {name: 'Globex', matchType: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO}, + ]); + expect(screen.getByTestId('merchant-0')).toBeOnTheScreen(); + expect(screen.getByTestId('merchant-0-title').props.children).toBe('Acme Inc'); + }); +}); diff --git a/tests/ui/components/SubStepForms/ConfirmationStepTest.tsx b/tests/ui/components/SubStepForms/ConfirmationStepTest.tsx new file mode 100644 index 000000000000..97ecc8e65bd4 --- /dev/null +++ b/tests/ui/components/SubStepForms/ConfirmationStepTest.tsx @@ -0,0 +1,102 @@ +import {render, screen} from '@testing-library/react-native'; +import React from 'react'; +import ConfirmationStep from '@components/SubStepForms/ConfirmationStep'; + +type RNComponent = React.ComponentType<{testID?: string; children?: React.ReactNode}>; +type ReactNative = {Pressable: RNComponent; View: RNComponent; Text: RNComponent}; + +jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key, formatPhoneNumber: (phone: string) => phone, preferredLocale: 'en'})); +jest.mock('@hooks/useThemeStyles', () => () => new Proxy({}, {get: () => ({})})); +jest.mock('@hooks/useNetwork', () => () => ({isOffline: false})); +jest.mock('@hooks/useSafeAreaPaddings', () => () => ({paddingTop: 0, paddingBottom: 0, insets: undefined, safeAreaPaddingBottomStyle: {}})); + +jest.mock('@components/MenuItemWithTopDescription', () => { + const reactNative = jest.requireActual('react-native'); + const RNPressable = reactNative.Pressable; + const RNView = reactNative.View; + function MockMenuItem({pressableTestID, title, description}: {pressableTestID?: string; title: string; description: string}) { + return ( + + {title} + {description} + + ); + } + return {__esModule: true, default: MockMenuItem}; +}); + +jest.mock('@components/RenderHTML', () => ({__esModule: true, default: () => null})); +jest.mock('@components/DotIndicatorMessage', () => ({__esModule: true, default: () => null})); +jest.mock('@components/Button', () => ({__esModule: true, default: () => null})); +jest.mock('@components/Text', () => { + const reactNative = jest.requireActual('react-native'); + return {__esModule: true, default: reactNative.Text}; +}); +jest.mock('@components/ScrollView', () => { + const reactNative = jest.requireActual('react-native'); + return {__esModule: true, default: reactNative.View}; +}); + +type SummaryItem = { + id?: string; + description: string; + title: string; + shouldShowRightIcon: boolean; + onPress: () => void; + testID?: string; +}; + +function renderStep(items: SummaryItem[]) { + return render( + {}} + onMove={() => {}} + pageTitle="title" + summaryItems={items} + showOnfidoLinks={false} + />, + ); +} + +describe('ConfirmationStep — stableId fallback', () => { + it('derives the row id from `description` + index (not `title`), so an edited title does not shift the focus-return identifier', () => { + const before: SummaryItem[] = [ + {description: 'Legal name', title: 'John Doe', shouldShowRightIcon: true, onPress: () => {}}, + {description: 'Date of birth', title: '1990-01-01', shouldShowRightIcon: true, onPress: () => {}}, + ]; + renderStep(before); + const idBefore = screen.getByTestId('Legal name-0'); + expect(idBefore).toBeOnTheScreen(); + + screen.unmount(); + const after: SummaryItem[] = [ + {description: 'Legal name', title: 'Jane Smith', shouldShowRightIcon: true, onPress: () => {}}, + {description: 'Date of birth', title: '1990-01-01', shouldShowRightIcon: true, onPress: () => {}}, + ]; + renderStep(after); + const idAfter = screen.getByTestId('Legal name-0'); + expect(idAfter).toBeOnTheScreen(); + expect(screen.getByTestId('Legal name-0-title').props.children).toBe('Jane Smith'); + }); + + it('honors an explicit `id` over the description+index fallback', () => { + renderStep([{id: 'firstName', description: 'Legal name', title: 'John', shouldShowRightIcon: true, onPress: () => {}}]); + expect(screen.getByTestId('firstName')).toBeOnTheScreen(); + expect(screen.queryByTestId('Legal name-0')).toBeNull(); + }); + + it('honors an explicit `testID` over the stableId', () => { + renderStep([{description: 'Legal name', title: 'John', shouldShowRightIcon: true, onPress: () => {}, testID: 'explicit-test-id'}]); + expect(screen.getByTestId('explicit-test-id')).toBeOnTheScreen(); + }); + + it('disambiguates two rows with the same description via the index suffix', () => { + renderStep([ + {description: 'Address', title: '123 Main St', shouldShowRightIcon: true, onPress: () => {}}, + {description: 'Address', title: '456 Other Ave', shouldShowRightIcon: true, onPress: () => {}}, + ]); + expect(screen.getByTestId('Address-0')).toBeOnTheScreen(); + expect(screen.getByTestId('Address-1')).toBeOnTheScreen(); + }); +}); diff --git a/tests/unit/BaseGenericPressableRegistryGateTest.tsx b/tests/unit/BaseGenericPressableRegistryGateTest.tsx new file mode 100644 index 000000000000..9e0e9e5debfa --- /dev/null +++ b/tests/unit/BaseGenericPressableRegistryGateTest.tsx @@ -0,0 +1,169 @@ +import {NavigationRouteContext} from '@react-navigation/native'; +import {fireEvent, render, screen} from '@testing-library/react-native'; +import React from 'react'; + +const mockRegisterPressable = jest.fn<() => void, [string, string, {current: unknown}]>(); +const mockNotifyPressedTrigger = jest.fn(); + +let mockScreenReaderState: 'enabled' | 'disabled' | 'unknown' = 'unknown'; +let mockIsScreenReaderActive = false; + +jest.mock('@libs/NavigationFocusReturn', () => ({ + __esModule: true, + registerPressable: (routeKey: string, identifier: string, ref: {current: unknown}) => { + mockRegisterPressable(routeKey, identifier, ref); + return () => {}; + }, + notifyPressedTrigger: (ref: {current: unknown} | null, identifier?: string) => { + mockNotifyPressedTrigger(ref, identifier); + }, +})); + +jest.mock('@libs/Accessibility', () => ({ + __esModule: true, + default: { + useScreenReaderStatus: () => mockIsScreenReaderActive, + useScreenReaderState: () => mockScreenReaderState, + useAutoHitSlop: () => [undefined, jest.fn()], + moveAccessibilityFocus: jest.fn(), + }, +})); + +jest.mock('@hooks/useThemeStyles', () => () => new Proxy({}, {get: () => ({})})); +jest.mock('@hooks/useStyleUtils', () => () => new Proxy({}, {get: () => () => ({})})); +jest.mock('@hooks/useKeyboardShortcut', () => ({__esModule: true, default: () => {}})); +jest.mock('@libs/HapticFeedback', () => ({__esModule: true, default: {press: jest.fn(), longPress: jest.fn()}})); +jest.mock('@hooks/useSingleExecution', () => ({ + __esModule: true, + default: () => ({isExecuting: false, singleExecution: (fn: (...args: unknown[]) => unknown) => fn}), +})); + +const GenericPressable = require<{ + default: React.ComponentType<{id?: string; nativeID?: string; testID?: string; onPress?: () => void; children?: React.ReactNode}>; +}>('../../src/components/Pressable/GenericPressable/implementation/BaseGenericPressable').default; + +const ROUTE_KEY = 'route-A'; + +function renderInsideRoute(node: React.ReactElement) { + return render({node}); +} + +beforeEach(() => { + mockRegisterPressable.mockClear(); + mockNotifyPressedTrigger.mockClear(); + mockScreenReaderState = 'unknown'; + mockIsScreenReaderActive = false; +}); + +describe('BaseGenericPressable — focus-return registry gate', () => { + it("registers during the SR warm-up window (state is 'unknown') — symmetric with the capture-side `getScreenReaderState() === 'disabled'` bail, so cold-start press → detach → back has a fallback target", () => { + mockScreenReaderState = 'unknown'; + renderInsideRoute( + {}} + />, + ); + expect(mockRegisterPressable).toHaveBeenCalledTimes(1); + const callArgs = mockRegisterPressable.mock.calls.at(0); + expect(callArgs?.[0]).toBe(ROUTE_KEY); + expect(callArgs?.[1]).toBe('row-1'); + expect(callArgs?.[2]).toHaveProperty('current'); + }); + + it('skips registration when SR is known-off (cache warm + value false) so sighted users pay zero registry cost', () => { + mockScreenReaderState = 'disabled'; + renderInsideRoute( + {}} + />, + ); + expect(mockRegisterPressable).not.toHaveBeenCalled(); + }); + + it('registers when SR is known-on', () => { + mockScreenReaderState = 'unknown'; + mockIsScreenReaderActive = true; + renderInsideRoute( + {}} + />, + ); + expect(mockRegisterPressable).toHaveBeenCalledTimes(1); + }); + + it('does not register when no focusIdentifier is available (no id / nativeID / testID — the registry rescue has no key to use)', () => { + mockScreenReaderState = 'unknown'; + renderInsideRoute( {}} />); + expect(mockRegisterPressable).not.toHaveBeenCalled(); + }); + + it('does not register when routeKey is null (consumer outside a navigator)', () => { + mockScreenReaderState = 'unknown'; + render( + {}} + />, + ); + expect(mockRegisterPressable).not.toHaveBeenCalled(); + }); + + it('prefers `id` when `id`/`nativeID`/`testID` are all set', () => { + mockScreenReaderState = 'unknown'; + renderInsideRoute( + {}} + />, + ); + expect(mockRegisterPressable.mock.calls.at(0)?.[1]).toBe('prefer-id'); + }); + + it('uses `nativeID` when `id` is absent', () => { + mockScreenReaderState = 'unknown'; + renderInsideRoute( + {}} + />, + ); + expect(mockRegisterPressable.mock.calls.at(0)?.[1]).toBe('native-id-only'); + }); + + it('uses `testID` when `id` and `nativeID` are absent', () => { + mockScreenReaderState = 'unknown'; + renderInsideRoute( + {}} + />, + ); + expect(mockRegisterPressable.mock.calls.at(0)?.[1]).toBe('test-id-only'); + }); + + it('calls notifyPressedTrigger(internalRef, identifier) on press so the focus-return capture has a fresh trigger', () => { + mockScreenReaderState = 'unknown'; + const onPress = jest.fn(); + renderInsideRoute( + , + ); + + fireEvent.press(screen.getByTestId('pressable-host')); + + expect(onPress).toHaveBeenCalledTimes(1); + expect(mockNotifyPressedTrigger).toHaveBeenCalledTimes(1); + const notifyArgs = mockNotifyPressedTrigger.mock.calls.at(0); + expect(notifyArgs?.[0]).toHaveProperty('current'); + expect(notifyArgs?.[1]).toBe('row-1'); + }); +}); diff --git a/tests/unit/DialogLabelContextTest.tsx b/tests/unit/DialogLabelContextTest.tsx index bfb45f423ded..1c0515e3baee 100644 --- a/tests/unit/DialogLabelContextTest.tsx +++ b/tests/unit/DialogLabelContextTest.tsx @@ -1,15 +1,18 @@ import {act, renderHook} from '@testing-library/react-native'; -import React, {createRef} from 'react'; +import React from 'react'; import type {PropsWithChildren} from 'react'; -import type {View} from 'react-native'; import {DialogLabelProvider, useDialogLabelActions, useDialogLabelData} from '@components/DialogLabelContext'; -const testContainerRef = createRef(); +let currentContainerNode: HTMLElement | null = null; function wrapper({children}: PropsWithChildren) { - return {children}; + return {children}; } +beforeEach(() => { + currentContainerNode = null; +}); + describe('DialogLabelContext', () => { describe('outside provider', () => { it('returns defaults when used outside provider', () => { @@ -35,6 +38,7 @@ describe('DialogLabelContext', () => { it('pushLabel sets aria-label on the container element', () => { const {result} = renderHook(() => ({...useDialogLabelData(), ...useDialogLabelActions()}), {wrapper}); const mockElement = document.createElement('div'); + mockElement.setAttribute('aria-modal', 'true'); (result.current.containerRef as {current: unknown}).current = mockElement; act(() => { @@ -44,6 +48,18 @@ describe('DialogLabelContext', () => { expect(mockElement.getAttribute('aria-label')).toBe('Settings'); }); + it('pushLabel does not set aria-label when the container has no dialog semantics', () => { + const {result} = renderHook(() => ({...useDialogLabelData(), ...useDialogLabelActions()}), {wrapper}); + const mockElement = document.createElement('div'); + (result.current.containerRef as {current: unknown}).current = mockElement; + + act(() => { + result.current.pushLabel('Settings'); + }); + + expect(mockElement.hasAttribute('aria-label')).toBe(false); + }); + it('pushLabel is safe when containerRef is not set', () => { const {result} = renderHook(() => useDialogLabelActions(), {wrapper}); @@ -55,9 +71,33 @@ describe('DialogLabelContext', () => { expect(id).toBeGreaterThanOrEqual(0); }); + it('pushLabel does not crash when containerRef.current is a non-DOM object that satisfies `instanceof HTMLElement` but lacks DOM methods (HybridApp native View)', () => { + const fakeNativeView: Record = {}; + const originalHasInstance = Object.getOwnPropertyDescriptor(HTMLElement, Symbol.hasInstance); + Object.defineProperty(HTMLElement, Symbol.hasInstance, {value: (v: unknown) => v === fakeNativeView, configurable: true}); + + try { + const {result} = renderHook(() => ({...useDialogLabelData(), ...useDialogLabelActions()}), {wrapper}); + (result.current.containerRef as {current: unknown}).current = fakeNativeView; + + expect(() => + act(() => { + result.current.pushLabel('Settings'); + }), + ).not.toThrow(); + } finally { + if (originalHasInstance) { + Object.defineProperty(HTMLElement, Symbol.hasInstance, originalHasInstance); + } else { + Reflect.deleteProperty(HTMLElement, Symbol.hasInstance); + } + } + }); + it('popLabel removes the label and restores the previous one', () => { const {result} = renderHook(() => ({...useDialogLabelData(), ...useDialogLabelActions()}), {wrapper}); const mockElement = document.createElement('div'); + mockElement.setAttribute('aria-modal', 'true'); (result.current.containerRef as {current: unknown}).current = mockElement; let idA: number; @@ -88,6 +128,7 @@ describe('DialogLabelContext', () => { it('popLabel removes by ID, not by stack position', () => { const {result} = renderHook(() => ({...useDialogLabelData(), ...useDialogLabelActions()}), {wrapper}); const mockElement = document.createElement('div'); + mockElement.setAttribute('aria-modal', 'true'); (result.current.containerRef as {current: unknown}).current = mockElement; let idA: number; @@ -139,6 +180,48 @@ describe('DialogLabelContext', () => { expect(result.current.claimInitialFocus()).toBe(false); }); + it('re-applies aria-label when the container gains dialog semantics on viewport resize (MutationObserver path)', async () => { + const mockElement = document.createElement('div'); + currentContainerNode = mockElement; + const {result, unmount} = renderHook(() => ({...useDialogLabelData(), ...useDialogLabelActions()}), {wrapper}); + + act(() => { + result.current.pushLabel('Settings'); + }); + expect(mockElement.hasAttribute('aria-label')).toBe(false); + + await act(async () => { + mockElement.setAttribute('role', 'dialog'); + mockElement.setAttribute('aria-modal', 'true'); + await Promise.resolve(); + }); + + expect(mockElement.getAttribute('aria-label')).toBe('Settings'); + unmount(); + }); + + it('removes aria-label when the container loses dialog semantics (wide→narrow resize — symmetric to the narrow→wide path)', async () => { + const mockElement = document.createElement('div'); + mockElement.setAttribute('role', 'dialog'); + mockElement.setAttribute('aria-modal', 'true'); + currentContainerNode = mockElement; + const {result, unmount} = renderHook(() => ({...useDialogLabelData(), ...useDialogLabelActions()}), {wrapper}); + + act(() => { + result.current.pushLabel('Settings'); + }); + expect(mockElement.getAttribute('aria-label')).toBe('Settings'); + + await act(async () => { + mockElement.removeAttribute('role'); + mockElement.removeAttribute('aria-modal'); + await Promise.resolve(); + }); + + expect(mockElement.hasAttribute('aria-label')).toBe(false); + unmount(); + }); + it('assigns unique IDs to each pushed label', () => { const {result} = renderHook(() => useDialogLabelActions(), {wrapper}); diff --git a/tests/unit/FocusTrapForModalTest.tsx b/tests/unit/FocusTrapForModalTest.tsx index e37ab4ef5611..77a91023476e 100644 --- a/tests/unit/FocusTrapForModalTest.tsx +++ b/tests/unit/FocusTrapForModalTest.tsx @@ -1,11 +1,11 @@ import {render} from '@testing-library/react-native'; import React from 'react'; import FocusTrapForModal from '@components/FocusTrap/FocusTrapForModal/index.web'; -import {scheduleClearActivePopoverLauncher, setActivePopoverLauncher} from '@libs/LauncherStack'; +import {markActivePopoverLauncherDeactivated, setActivePopoverLauncher} from '@libs/LauncherStack'; jest.mock('@libs/LauncherStack', () => ({ setActivePopoverLauncher: jest.fn(), - scheduleClearActivePopoverLauncher: jest.fn(), + markActivePopoverLauncherDeactivated: jest.fn(), })); let capturedOptions: {onActivate?: () => void; onPostDeactivate?: () => void} | null = null; @@ -19,6 +19,14 @@ jest.mock('focus-trap-react', () => ({ jest.mock('@libs/Accessibility/blurActiveElement', () => ({__esModule: true, default: jest.fn()})); +const mockRestoreFocusWithModality = jest.fn(); +jest.mock('@libs/restoreFocusWithModality', () => ({ + __esModule: true, + default: (...args: unknown[]): void => { + mockRestoreFocusWithModality(...args); + }, +})); + // document.activeElement isn't settable under the RN-web test harness — stub via Document.prototype descriptor. function withActiveElement(element: HTMLElement, fn: () => T): T { const descriptor = Object.getOwnPropertyDescriptor(Document.prototype, 'activeElement'); @@ -36,7 +44,8 @@ describe('FocusTrapForModal — launcher capture', () => { beforeEach(() => { capturedOptions = null; (setActivePopoverLauncher as jest.Mock).mockClear(); - (scheduleClearActivePopoverLauncher as jest.Mock).mockClear(); + (markActivePopoverLauncherDeactivated as jest.Mock).mockClear(); + mockRestoreFocusWithModality.mockReset(); document.body.innerHTML = ''; }); @@ -52,7 +61,7 @@ describe('FocusTrapForModal — launcher capture', () => { }); expect(setActivePopoverLauncher).toHaveBeenCalledWith(launcher); - expect(scheduleClearActivePopoverLauncher).toHaveBeenCalled(); + expect(markActivePopoverLauncherDeactivated).toHaveBeenCalled(); }); it('captures the launcher even when shouldReturnFocus is false (PopoverMenu / ThreeDotsMenu / ReanimatedModal with new focus management)', () => { @@ -75,7 +84,28 @@ describe('FocusTrapForModal — launcher capture', () => { }); expect(setActivePopoverLauncher).toHaveBeenCalledWith(launcher); - expect(scheduleClearActivePopoverLauncher).toHaveBeenCalled(); + expect(markActivePopoverLauncherDeactivated).toHaveBeenCalled(); + }); + + it('marks the LauncherStack entry deactivated even if restoreFocusWithModality throws', () => { + const launcher = document.createElement('button'); + document.body.appendChild(launcher); + mockRestoreFocusWithModality.mockImplementation(() => { + throw new Error('focus failed'); + }); + + render({null}); + + withActiveElement(launcher, () => { + capturedOptions?.onActivate?.(); + try { + capturedOptions?.onPostDeactivate?.(); + } catch { + // swallow — mocked throw, the assertion below pins markActive ran first + } + }); + + expect(markActivePopoverLauncherDeactivated).toHaveBeenCalledWith(launcher); }); it('skips launcher capture when activeElement is document.body (nothing to capture)', () => { @@ -87,6 +117,6 @@ describe('FocusTrapForModal — launcher capture', () => { }); expect(setActivePopoverLauncher).not.toHaveBeenCalled(); - expect(scheduleClearActivePopoverLauncher).not.toHaveBeenCalled(); + expect(markActivePopoverLauncherDeactivated).not.toHaveBeenCalled(); }); }); diff --git a/tests/unit/LauncherStackTest.ts b/tests/unit/LauncherStackTest.ts index 262418bbcae0..50812665ce9f 100644 --- a/tests/unit/LauncherStackTest.ts +++ b/tests/unit/LauncherStackTest.ts @@ -1,10 +1,10 @@ // Typed require with explicit .ts path — matches the project's test-file convention. /* eslint-disable import/extensions */ -const {pickLauncher, consumeLauncher, setActivePopoverLauncher, scheduleClearActivePopoverLauncher, resetLauncherStackForTests} = require<{ +const {pickLauncher, consumeLauncher, setActivePopoverLauncher, markActivePopoverLauncherDeactivated, resetLauncherStackForTests} = require<{ pickLauncher: () => HTMLElement | null; consumeLauncher: (element: HTMLElement) => void; setActivePopoverLauncher: (element: HTMLElement) => void; - scheduleClearActivePopoverLauncher: (element?: HTMLElement) => void; + markActivePopoverLauncherDeactivated: (element?: HTMLElement) => void; resetLauncherStackForTests: () => void; }>('../../src/libs/LauncherStack.ts'); /* eslint-enable import/extensions */ @@ -48,7 +48,7 @@ describe('LauncherStack', () => { const inner = appendButton(); setActivePopoverLauncher(outer); setActivePopoverLauncher(inner); - scheduleClearActivePopoverLauncher(inner); + markActivePopoverLauncherDeactivated(inner); expect(pickLauncher()).toBe(outer); }); @@ -57,8 +57,8 @@ describe('LauncherStack', () => { const b = appendButton(); setActivePopoverLauncher(a); setActivePopoverLauncher(b); - scheduleClearActivePopoverLauncher(a); - scheduleClearActivePopoverLauncher(b); + markActivePopoverLauncherDeactivated(a); + markActivePopoverLauncherDeactivated(b); expect(pickLauncher()).toBe(b); }); @@ -66,7 +66,7 @@ describe('LauncherStack', () => { withFakeTimers(() => { const a = appendButton(); setActivePopoverLauncher(a); - scheduleClearActivePopoverLauncher(); + markActivePopoverLauncherDeactivated(); jest.advanceTimersByTime(2000); expect(pickLauncher()).toBeNull(); }); @@ -85,7 +85,7 @@ describe('LauncherStack', () => { const a = appendButton(); const b = appendButton(); setActivePopoverLauncher(a); - scheduleClearActivePopoverLauncher(a); + markActivePopoverLauncherDeactivated(a); jest.advanceTimersByTime(500); setActivePopoverLauncher(b); // b active; a still within window but deactivated → b wins. @@ -98,7 +98,7 @@ describe('LauncherStack', () => { const a = appendButton(); const b = appendButton(); setActivePopoverLauncher(a); - scheduleClearActivePopoverLauncher(a); + markActivePopoverLauncherDeactivated(a); setActivePopoverLauncher(b); setActivePopoverLauncher(a); expect(pickLauncher()).toBe(a); @@ -121,11 +121,11 @@ describe('LauncherStack', () => { }); }); - describe('scheduleClearActivePopoverLauncher', () => { + describe('markActivePopoverLauncherDeactivated', () => { it('marks the entry deactivated without immediate removal (deferred-clear within window)', () => { const a = appendButton(); setActivePopoverLauncher(a); - scheduleClearActivePopoverLauncher(a); + markActivePopoverLauncherDeactivated(a); expect(pickLauncher()).toBe(a); }); @@ -134,7 +134,7 @@ describe('LauncherStack', () => { const b = appendButton(); setActivePopoverLauncher(a); setActivePopoverLauncher(b); - scheduleClearActivePopoverLauncher(); + markActivePopoverLauncherDeactivated(); expect(pickLauncher()).toBe(a); }); @@ -143,7 +143,7 @@ describe('LauncherStack', () => { const a = appendButton(); const b = appendButton(); setActivePopoverLauncher(a); - scheduleClearActivePopoverLauncher(); + markActivePopoverLauncherDeactivated(); jest.advanceTimersByTime(100); setActivePopoverLauncher(b); jest.advanceTimersByTime(2000); @@ -157,8 +157,8 @@ describe('LauncherStack', () => { const inner = appendButton(); setActivePopoverLauncher(outer); setActivePopoverLauncher(inner); - scheduleClearActivePopoverLauncher(inner); - scheduleClearActivePopoverLauncher(outer); + markActivePopoverLauncherDeactivated(inner); + markActivePopoverLauncherDeactivated(outer); expect(pickLauncher()).toBe(outer); }); }); diff --git a/tests/unit/Navigation/TransitionTrackerTest.ts b/tests/unit/Navigation/TransitionTrackerTest.ts index d7a7ae9cfbc2..fb3825172601 100644 --- a/tests/unit/Navigation/TransitionTrackerTest.ts +++ b/tests/unit/Navigation/TransitionTrackerTest.ts @@ -31,6 +31,12 @@ describe('TransitionTracker', () => { drainTransitions(); }); + it('runImmediately wins over waitForUpcomingTransition when both are set', () => { + const callback = jest.fn(); + TransitionTracker.runAfterTransitions({callback, runImmediately: true, waitForUpcomingTransition: 'navigation'}); + expect(callback).toHaveBeenCalledTimes(1); + }); + it('queues callback when transition is active and runs it after endTransition', () => { const callback = jest.fn(); const handle = TransitionTracker.startTransition(); @@ -73,11 +79,11 @@ describe('TransitionTracker', () => { jest.useRealTimers(); }); - it('waitForUpcomingTransition queues callback after next transition starts and runs it after transition ends', async () => { + it('waitForUpcomingTransition queues callback after next navigation transition starts and runs it after transition ends', async () => { const callback = jest.fn(); TransitionTracker.runAfterTransitions({callback, waitForUpcomingTransition: true}); expect(callback).not.toHaveBeenCalled(); - const handle = TransitionTracker.startTransition(); + const handle = TransitionTracker.startTransition('navigation'); // Two ticks: one for promiseForNextTransitionStart, one for Promise.race wrapper await Promise.resolve(); await Promise.resolve(); @@ -87,6 +93,65 @@ describe('TransitionTracker', () => { drainTransitions(); }); + it('waitForUpcomingTransition waits for an already-active navigation transition to end (web order: transitionStart before the call) instead of a phantom next start', () => { + const callback = jest.fn(); + const handle = TransitionTracker.startTransition('navigation'); + TransitionTracker.runAfterTransitions({callback, waitForUpcomingTransition: true}); + expect(callback).not.toHaveBeenCalled(); + TransitionTracker.endTransition(handle); + expect(callback).toHaveBeenCalledTimes(1); + drainTransitions(); + }); + + it("waitForUpcomingTransition: 'navigation' ignores non-navigation transitions and waits for an upcoming navigation start", async () => { + const callback = jest.fn(); + const otherHandle = TransitionTracker.startTransition(); + TransitionTracker.runAfterTransitions({callback, waitForUpcomingTransition: 'navigation'}); + + TransitionTracker.endTransition(otherHandle); + await Promise.resolve(); + expect(callback).not.toHaveBeenCalled(); + + const navHandle = TransitionTracker.startTransition('navigation'); + await Promise.resolve(); + await Promise.resolve(); + expect(callback).not.toHaveBeenCalled(); + TransitionTracker.endTransition(navHandle); + expect(callback).toHaveBeenCalledTimes(1); + drainTransitions(); + }); + + it('waitForUpcomingTransition: true (legacy) waits for any transition — modal close (no navigation) still fires the callback', async () => { + const callback = jest.fn(); + TransitionTracker.runAfterTransitions({callback, waitForUpcomingTransition: true}); + + const modalHandle = TransitionTracker.startTransition(); + await Promise.resolve(); + await Promise.resolve(); + expect(callback).not.toHaveBeenCalled(); + TransitionTracker.endTransition(modalHandle); + expect(callback).toHaveBeenCalledTimes(1); + drainTransitions(); + }); + + it('waitForUpcomingTransition: true with a non-navigation transition active still waits for the upcoming nav-start (register-before-dispatch)', async () => { + const callback = jest.fn(); + const otherHandle = TransitionTracker.startTransition(); + TransitionTracker.runAfterTransitions({callback, waitForUpcomingTransition: true}); + + TransitionTracker.endTransition(otherHandle); + await Promise.resolve(); + expect(callback).not.toHaveBeenCalled(); + + const navHandle = TransitionTracker.startTransition('navigation'); + await Promise.resolve(); + await Promise.resolve(); + expect(callback).not.toHaveBeenCalled(); + TransitionTracker.endTransition(navHandle); + expect(callback).toHaveBeenCalledTimes(1); + drainTransitions(); + }); + it('waitForUpcomingTransition fires callback after timeout if transitionStart never arrives', async () => { const callback = jest.fn(); TransitionTracker.runAfterTransitions({callback, waitForUpcomingTransition: true}); @@ -101,7 +166,7 @@ describe('TransitionTracker', () => { it('cancel prevents waitForUpcomingTransition callback from running after transition starts', () => { const callback = jest.fn(); const cancelHandle = TransitionTracker.runAfterTransitions({callback, waitForUpcomingTransition: true}); - const transitionHandle = TransitionTracker.startTransition(); + const transitionHandle = TransitionTracker.startTransition('navigation'); cancelHandle.cancel(); TransitionTracker.endTransition(transitionHandle); expect(callback).not.toHaveBeenCalled(); @@ -112,7 +177,7 @@ describe('TransitionTracker', () => { const callback = jest.fn(); const cancelHandle = TransitionTracker.runAfterTransitions({callback, waitForUpcomingTransition: true}); cancelHandle.cancel(); - const transitionHandle = TransitionTracker.startTransition(); + const transitionHandle = TransitionTracker.startTransition('navigation'); TransitionTracker.endTransition(transitionHandle); expect(callback).not.toHaveBeenCalled(); drainTransitions(); diff --git a/tests/unit/NavigationFocusReturnNativeTest.ts b/tests/unit/NavigationFocusReturnNativeTest.ts new file mode 100644 index 000000000000..494cb7a3794e --- /dev/null +++ b/tests/unit/NavigationFocusReturnNativeTest.ts @@ -0,0 +1,935 @@ +import {AccessibilityInfo} from 'react-native'; + +/* eslint-disable import/extensions */ +type NavState = { + type: string; + key: string; + index: number; + routeNames: string[]; + routes: Array<{key: string; name: string; state?: unknown}>; + stale: boolean; + history: unknown[]; +}; + +const mockFireFocusEvent = jest.fn(); +const mockSendAccessibilityEvent = jest.fn(); +const mockLogWarn = jest.fn(); +let mockScreenReaderEnabled = true; +let mockScreenReaderCacheWarmed = true; + +jest.mock('@libs/Log', () => ({ + __esModule: true, + default: { + warn: (...args: unknown[]) => { + mockLogWarn(...args); + }, + info: jest.fn(), + alert: jest.fn(), + hmmm: jest.fn(), + client: jest.fn(), + }, +})); + +jest.mock('../../src/libs/Accessibility', () => ({ + __esModule: true, + default: { + moveAccessibilityFocus: jest.fn(), + isScreenReaderEnabledSync: () => mockScreenReaderEnabled, + getScreenReaderState: () => { + if (!mockScreenReaderCacheWarmed) { + return 'unknown'; + } + return mockScreenReaderEnabled ? 'enabled' : 'disabled'; + }, + useScreenReaderStatus: () => mockScreenReaderEnabled, + useReducedMotion: () => false, + }, +})); + +jest.mock('../../src/libs/Accessibility/fireFocusEvent', () => ({ + __esModule: true, + default: (view: unknown): void => { + mockFireFocusEvent(view); + }, +})); + +AccessibilityInfo.sendAccessibilityEvent = mockSendAccessibilityEvent; + +type TtEntry = {cb: () => void; cancelled: boolean; waitForUpcomingTransition: boolean | 'navigation'}; +let mockTtQueue: TtEntry[] = []; +jest.mock('../../src/libs/Navigation/TransitionTracker', () => ({ + __esModule: true, + default: { + startTransition: jest.fn(), + endTransition: jest.fn(), + runAfterTransitions: ({callback, waitForUpcomingTransition = false}: {callback: () => void; waitForUpcomingTransition?: boolean | 'navigation'}) => { + const entry: TtEntry = {cb: callback, cancelled: false, waitForUpcomingTransition}; + mockTtQueue.push(entry); + return { + cancel: () => { + entry.cancelled = true; + }, + }; + }, + }, +})); + +let mockNavigationRefState: NavState | undefined; +let mockStateListeners: Array<() => void> = []; + +jest.mock('../../src/libs/Navigation/navigationRef.ts', () => ({ + __esModule: true, + default: { + get current() { + return mockNavigationRefState ? {} : null; + }, + isReady: () => mockNavigationRefState !== undefined, + getRootState: () => mockNavigationRefState, + addListener: (event: string, cb: () => void) => { + if (event !== 'state') { + return () => {}; + } + mockStateListeners.push(cb); + return () => { + mockStateListeners = mockStateListeners.filter((l) => l !== cb); + }; + }, + }, +})); + +const { + setupNavigationFocusReturn, + teardownNavigationFocusReturn, + handleStateChange, + notifyPressedTrigger, + registerPressable, + notifyPushParamsForward, + notifyPushParamsBackward, + cancelPendingFocusRestore, + skipNextFocusRestore, + isFocusRestoreInProgress, + shouldSkipAutoFocusDueToExistingFocus, + resetForTests, + getTriggerMapSizeForTests, + getRegistrySizeForTests, +} = require<{ + setupNavigationFocusReturn: () => void; + teardownNavigationFocusReturn: () => void; + handleStateChange: (state: unknown) => void; + notifyPressedTrigger: (ref: unknown, identifier?: string) => void; + registerPressable: (routeKey: string, identifier: string, ref: unknown) => () => void; + notifyPushParamsForward: (routeKey: string, prevParams: unknown) => void; + notifyPushParamsBackward: (routeKey: string, targetParams: unknown) => void; + cancelPendingFocusRestore: () => void; + skipNextFocusRestore: () => void; + isFocusRestoreInProgress: () => boolean; + shouldSkipAutoFocusDueToExistingFocus: () => boolean; + resetForTests: () => void; + getTriggerMapSizeForTests: () => number; + getRegistrySizeForTests: () => number; +}>('../../src/libs/NavigationFocusReturn/index.native.ts'); +/* eslint-enable import/extensions */ + +function stackState(focused: number, routes: Array<{key: string; name: string; state?: unknown}>): NavState { + return { + type: 'stack', + key: `nav-${routes.map((r) => r.key).join('-')}`, + index: focused, + routeNames: routes.map((r) => r.name), + routes, + stale: false, + history: [], + }; +} + +function flushTransitions(): void { + const buffered = mockTtQueue; + mockTtQueue = []; + for (const entry of buffered) { + if (!entry.cancelled) { + entry.cb(); + } + } +} + +function fakeView(label = 'view'): {label: string} { + return {label}; +} + +function fakeRef(view: unknown): {current: unknown} { + return {current: view}; +} + +beforeEach(() => { + jest.useFakeTimers(); + mockSendAccessibilityEvent.mockClear(); + mockFireFocusEvent.mockClear(); + mockLogWarn.mockClear(); + mockScreenReaderEnabled = true; + mockScreenReaderCacheWarmed = true; + mockStateListeners = []; + mockNavigationRefState = undefined; + mockTtQueue = []; + resetForTests(); +}); + +afterEach(() => { + jest.useRealTimers(); +}); + +describe('notifyPressedTrigger', () => { + it('does not capture when the screen reader is known off — non-AT users pay zero capture cost', () => { + mockScreenReaderEnabled = false; + mockScreenReaderCacheWarmed = true; + notifyPressedTrigger(fakeRef(fakeView('button'))); + const prev = stackState(0, [{key: 'a', name: 'A'}]); + const next = stackState(1, [ + {key: 'a', name: 'A'}, + {key: 'b', name: 'B'}, + ]); + handleStateChange(prev); + handleStateChange(next); + expect(getTriggerMapSizeForTests()).toBe(0); + }); + + it('captures defensively when the SR cache has not yet warmed — cold-start / resume press is not dropped', () => { + mockScreenReaderEnabled = false; + mockScreenReaderCacheWarmed = false; + notifyPressedTrigger(fakeRef(fakeView('button'))); + const prev = stackState(0, [{key: 'a', name: 'A'}]); + const next = stackState(1, [ + {key: 'a', name: 'A'}, + {key: 'b', name: 'B'}, + ]); + handleStateChange(prev); + handleStateChange(next); + expect(getTriggerMapSizeForTests()).toBe(1); + }); + + it('stores the most recently pressed ref when the screen reader is on', () => { + notifyPressedTrigger(fakeRef(fakeView('button-1'))); + const prev = stackState(0, [{key: 'a', name: 'A'}]); + const next = stackState(1, [ + {key: 'a', name: 'A'}, + {key: 'b', name: 'B'}, + ]); + handleStateChange(prev); + handleStateChange(next); + expect(getTriggerMapSizeForTests()).toBe(1); + }); + + it('overwrites the staged trigger on each press so the freshest tap wins', () => { + notifyPressedTrigger(fakeRef(fakeView('button-1'))); + notifyPressedTrigger(fakeRef(fakeView('button-2'))); + const prev = stackState(0, [{key: 'a', name: 'A'}]); + const next = stackState(1, [ + {key: 'a', name: 'A'}, + {key: 'b', name: 'B'}, + ]); + handleStateChange(prev); + handleStateChange(next); + expect(getTriggerMapSizeForTests()).toBe(1); + }); + + it('drops a stale press so a much-later forward nav (deeplink, timer) does not capture an unrelated trigger', () => { + const nowSpy = jest.spyOn(performance, 'now'); + nowSpy.mockReturnValue(0); + notifyPressedTrigger(fakeRef(fakeView('non-nav-toggle'))); + nowSpy.mockReturnValue(4_000); + const prev = stackState(0, [{key: 'a', name: 'A'}]); + const next = stackState(1, [ + {key: 'a', name: 'A'}, + {key: 'b', name: 'B'}, + ]); + handleStateChange(prev); + handleStateChange(next); + expect(getTriggerMapSizeForTests()).toBe(0); + nowSpy.mockRestore(); + }); +}); + +describe('handleStateChange — forward', () => { + it('captures the staged trigger against the outgoing route key', () => { + notifyPressedTrigger(fakeRef(fakeView('display-name'))); + const prev = stackState(0, [{key: 'profile', name: 'Profile'}]); + const next = stackState(1, [ + {key: 'profile', name: 'Profile'}, + {key: 'display-name-page', name: 'DisplayName'}, + ]); + handleStateChange(prev); + handleStateChange(next); + expect(getTriggerMapSizeForTests()).toBe(1); + }); + + it('skips capture entirely when no trigger was staged', () => { + const prev = stackState(0, [{key: 'profile', name: 'Profile'}]); + const next = stackState(1, [ + {key: 'profile', name: 'Profile'}, + {key: 'display-name-page', name: 'DisplayName'}, + ]); + handleStateChange(prev); + handleStateChange(next); + expect(getTriggerMapSizeForTests()).toBe(0); + }); +}); + +describe('handleStateChange — backward', () => { + it('restores accessibility focus to the captured view after transitions flush', () => { + const view = fakeView('display-name'); + notifyPressedTrigger(fakeRef(view)); + const prev = stackState(0, [{key: 'profile', name: 'Profile'}]); + const forward = stackState(1, [ + {key: 'profile', name: 'Profile'}, + {key: 'display-name-page', name: 'DisplayName'}, + ]); + const back = stackState(0, [{key: 'profile', name: 'Profile'}]); + + handleStateChange(prev); + handleStateChange(forward); + handleStateChange(back); + flushTransitions(); + + // Web/iOS scheduleRefocus resolves to a no-op under jsdom, so we expect exactly one fire here. + // The Android race-mitigation re-fire lives in scheduleRefocus/index.android.ts. + expect(mockFireFocusEvent).toHaveBeenCalledTimes(1); + expect(mockFireFocusEvent).toHaveBeenCalledWith(view); + }); + + it('stack-pop restore fires synchronously inside the transition callback (no rAF defer)', () => { + const view = fakeView('display-name'); + notifyPressedTrigger(fakeRef(view)); + handleStateChange(stackState(0, [{key: 'profile', name: 'Profile'}])); + handleStateChange( + stackState(1, [ + {key: 'profile', name: 'Profile'}, + {key: 'display-name-page', name: 'DisplayName'}, + ]), + ); + handleStateChange(stackState(0, [{key: 'profile', name: 'Profile'}])); + flushTransitions(); + expect(mockFireFocusEvent).toHaveBeenCalledWith(view); + }); + + it('waits for the upcoming transition on a stack pop', () => { + notifyPressedTrigger(fakeRef(fakeView('display-name'))); + handleStateChange(stackState(0, [{key: 'profile', name: 'Profile'}])); + handleStateChange( + stackState(1, [ + {key: 'profile', name: 'Profile'}, + {key: 'display-name-page', name: 'DisplayName'}, + ]), + ); + handleStateChange(stackState(0, [{key: 'profile', name: 'Profile'}])); + + expect(mockTtQueue.at(-1)?.waitForUpcomingTransition).toBe('navigation'); + }); + + it('does NOT restore when skipNextFocusRestore was called before goBack (form-submit path)', () => { + notifyPressedTrigger(fakeRef(fakeView('display-name'))); + handleStateChange(stackState(0, [{key: 'profile', name: 'Profile'}])); + handleStateChange( + stackState(1, [ + {key: 'profile', name: 'Profile'}, + {key: 'display-name-page', name: 'DisplayName'}, + ]), + ); + + skipNextFocusRestore(); + handleStateChange(stackState(0, [{key: 'profile', name: 'Profile'}])); + flushTransitions(); + + expect(mockFireFocusEvent).not.toHaveBeenCalled(); + expect(getTriggerMapSizeForTests()).toBe(0); + }); + + it('clears the skipped entry so a later deeplink Back to the same route cannot inherit it', () => { + notifyPressedTrigger(fakeRef(fakeView('display-name'))); + handleStateChange(stackState(0, [{key: 'profile', name: 'Profile'}])); + handleStateChange( + stackState(1, [ + {key: 'profile', name: 'Profile'}, + {key: 'display-name-page', name: 'DisplayName'}, + ]), + ); + + skipNextFocusRestore(); + handleStateChange(stackState(0, [{key: 'profile', name: 'Profile'}])); + flushTransitions(); + + handleStateChange( + stackState(1, [ + {key: 'profile', name: 'Profile'}, + {key: 'new-screen', name: 'NewScreen'}, + ]), + ); + handleStateChange(stackState(0, [{key: 'profile', name: 'Profile'}])); + flushTransitions(); + expect(mockFireFocusEvent).not.toHaveBeenCalled(); + }); + + it('does NOT call sendAccessibilityEvent when no trigger was staged before the forward navigation', () => { + const prev = stackState(0, [{key: 'profile', name: 'Profile'}]); + const forward = stackState(1, [ + {key: 'profile', name: 'Profile'}, + {key: 'display-name-page', name: 'DisplayName'}, + ]); + const back = stackState(0, [{key: 'profile', name: 'Profile'}]); + + handleStateChange(prev); + handleStateChange(forward); + handleStateChange(back); + flushTransitions(); + + expect(mockFireFocusEvent).not.toHaveBeenCalled(); + }); + + it('does NOT call sendAccessibilityEvent when the captured ref has been nulled (Pressable unmounted)', () => { + const detachedRef = fakeRef(null); + notifyPressedTrigger(detachedRef); + const prev = stackState(0, [{key: 'profile', name: 'Profile'}]); + const forward = stackState(1, [ + {key: 'profile', name: 'Profile'}, + {key: 'display-name-page', name: 'DisplayName'}, + ]); + const back = stackState(0, [{key: 'profile', name: 'Profile'}]); + + handleStateChange(prev); + handleStateChange(forward); + handleStateChange(back); + flushTransitions(); + + expect(mockFireFocusEvent).not.toHaveBeenCalled(); + }); + + it('cleans the trigger entry from the map after a successful restore', () => { + notifyPressedTrigger(fakeRef(fakeView('display-name'))); + const prev = stackState(0, [{key: 'profile', name: 'Profile'}]); + const forward = stackState(1, [ + {key: 'profile', name: 'Profile'}, + {key: 'display-name-page', name: 'DisplayName'}, + ]); + const back = stackState(0, [{key: 'profile', name: 'Profile'}]); + + handleStateChange(prev); + handleStateChange(forward); + expect(getTriggerMapSizeForTests()).toBe(1); + handleStateChange(back); + flushTransitions(); + expect(getTriggerMapSizeForTests()).toBe(0); + }); + + it('back navigation skips the restore work AND consumes the trigger when the screen reader is known off', () => { + const view = fakeView('display-name'); + notifyPressedTrigger(fakeRef(view)); + handleStateChange(stackState(0, [{key: 'profile', name: 'Profile'}])); + handleStateChange( + stackState(1, [ + {key: 'profile', name: 'Profile'}, + {key: 'display-name-page', name: 'DisplayName'}, + ]), + ); + expect(getTriggerMapSizeForTests()).toBe(1); + + mockScreenReaderEnabled = false; + mockScreenReaderCacheWarmed = true; + handleStateChange(stackState(0, [{key: 'profile', name: 'Profile'}])); + flushTransitions(); + + expect(mockTtQueue).toHaveLength(0); + expect(mockLogWarn).not.toHaveBeenCalled(); + expect(mockFireFocusEvent).not.toHaveBeenCalled(); + expect(getTriggerMapSizeForTests()).toBe(0); + }); + + it('cancels a pending restore when a subsequent backward supersedes it before its transition flushes (rapid double-back)', () => { + const viewB = fakeView('open-B-button'); + const viewC = fakeView('open-C-button'); + + handleStateChange(stackState(0, [{key: 'A', name: 'A'}])); + notifyPressedTrigger(fakeRef(viewB)); + handleStateChange( + stackState(1, [ + {key: 'A', name: 'A'}, + {key: 'B', name: 'B'}, + ]), + ); + notifyPressedTrigger(fakeRef(viewC)); + handleStateChange( + stackState(2, [ + {key: 'A', name: 'A'}, + {key: 'B', name: 'B'}, + {key: 'C', name: 'C'}, + ]), + ); + + handleStateChange( + stackState(1, [ + {key: 'A', name: 'A'}, + {key: 'B', name: 'B'}, + ]), + ); + expect(mockTtQueue.at(-1)?.cancelled).toBe(false); + + handleStateChange(stackState(0, [{key: 'A', name: 'A'}])); + expect(mockTtQueue.at(0)?.cancelled).toBe(true); + + flushTransitions(); + expect(mockFireFocusEvent).toHaveBeenCalledTimes(1); + expect(mockFireFocusEvent).toHaveBeenCalledWith(viewB); + }); + + it('warm-up race — back navigation while SR cache is not yet resolved still restores (resume / cold start)', () => { + mockScreenReaderEnabled = false; + mockScreenReaderCacheWarmed = false; + const view = fakeView('display-name'); + notifyPressedTrigger(fakeRef(view)); + handleStateChange(stackState(0, [{key: 'profile', name: 'Profile'}])); + handleStateChange( + stackState(1, [ + {key: 'profile', name: 'Profile'}, + {key: 'display-name-page', name: 'DisplayName'}, + ]), + ); + expect(getTriggerMapSizeForTests()).toBe(1); + + handleStateChange(stackState(0, [{key: 'profile', name: 'Profile'}])); + flushTransitions(); + + expect(mockFireFocusEvent).toHaveBeenCalledWith(view); + }); + + it('clears the staged press on a backward nav so a later press-less forward cannot capture the stale Back/Save ref', () => { + handleStateChange(stackState(0, [{key: 'A', name: 'A'}])); + handleStateChange( + stackState(1, [ + {key: 'A', name: 'A'}, + {key: 'B', name: 'B'}, + ]), + ); + + notifyPressedTrigger(fakeRef(fakeView('back-button')), 'Back'); + handleStateChange(stackState(0, [{key: 'A', name: 'A'}])); + + handleStateChange( + stackState(1, [ + {key: 'A', name: 'A'}, + {key: 'C', name: 'C'}, + ]), + ); + expect(getTriggerMapSizeForTests()).toBe(0); + }); +}); + +describe('handleStateChange — lateral & cleanup', () => { + it('cancels a pending restore on a subsequent lateral tab switch', () => { + notifyPressedTrigger(fakeRef(fakeView('display-name'))); + const initial = stackState(0, [{key: 'profile', name: 'Profile'}]); + const forward = stackState(1, [ + {key: 'profile', name: 'Profile'}, + {key: 'display-name-page', name: 'DisplayName'}, + ]); + const back = stackState(0, [{key: 'profile', name: 'Profile'}]); + + handleStateChange(initial); + handleStateChange(forward); + handleStateChange(back); + + const lateral = { + type: 'tab', + key: 'tab-root', + index: 1, + routeNames: ['A', 'B'], + routes: [ + {key: 'tab-a', name: 'A'}, + {key: 'tab-b', name: 'B'}, + ], + stale: false, + history: [], + }; + const lateralAfter = {...lateral, index: 0}; + handleStateChange(lateral); + handleStateChange(lateralAfter); + + flushTransitions(); + expect(mockFireFocusEvent).not.toHaveBeenCalled(); + }); + + it('drops trigger entries for routes removed from the stack', () => { + notifyPressedTrigger(fakeRef(fakeView('row-a'))); + const initial = stackState(0, [{key: 'profile', name: 'Profile'}]); + const intoA = stackState(1, [ + {key: 'profile', name: 'Profile'}, + {key: 'detail-a', name: 'DetailA'}, + ]); + const resetStack = stackState(0, [{key: 'home', name: 'Home'}]); + + handleStateChange(initial); + handleStateChange(intoA); + expect(getTriggerMapSizeForTests()).toBe(1); + handleStateChange(resetStack); + expect(getTriggerMapSizeForTests()).toBe(0); + }); + + it('cancelPendingFocusRestore drops any queued restore', () => { + notifyPressedTrigger(fakeRef(fakeView('display-name'))); + const initial = stackState(0, [{key: 'profile', name: 'Profile'}]); + const forward = stackState(1, [ + {key: 'profile', name: 'Profile'}, + {key: 'display-name-page', name: 'DisplayName'}, + ]); + const back = stackState(0, [{key: 'profile', name: 'Profile'}]); + + handleStateChange(initial); + handleStateChange(forward); + handleStateChange(back); + cancelPendingFocusRestore(); + flushTransitions(); + + expect(mockFireFocusEvent).not.toHaveBeenCalled(); + }); +}); + +describe('setup / teardown', () => { + it('is idempotent: a second setup call does not double-subscribe', () => { + mockNavigationRefState = stackState(0, [{key: 'home', name: 'Home'}]); + setupNavigationFocusReturn(); + const first = mockStateListeners.length; + setupNavigationFocusReturn(); + expect(mockStateListeners.length).toBe(first); + }); + + it('teardown clears triggerMap and the staged trigger', () => { + notifyPressedTrigger(fakeRef(fakeView('row'))); + handleStateChange(stackState(0, [{key: 'profile', name: 'Profile'}])); + handleStateChange( + stackState(1, [ + {key: 'profile', name: 'Profile'}, + {key: 'display-name-page', name: 'DisplayName'}, + ]), + ); + expect(getTriggerMapSizeForTests()).toBe(1); + teardownNavigationFocusReturn(); + expect(getTriggerMapSizeForTests()).toBe(0); + }); +}); + +describe('PUSH_PARAMS — same-route param change', () => { + const ROUTE_KEY = 'Search_Root-K1'; + + it('captures against the compound key on forward, restores on backward', () => { + const view = fakeView('search-tab-expense'); + notifyPressedTrigger(fakeRef(view)); + + notifyPushParamsForward(ROUTE_KEY, {q: 'old'}); + expect(getTriggerMapSizeForTests()).toBe(1); + + notifyPushParamsBackward(ROUTE_KEY, {q: 'old'}); + // PUSH_PARAMS emits no transition — restore must not wait for one (would stall on the 1s timeout). + expect(mockTtQueue.at(-1)?.waitForUpcomingTransition).toBe(false); + flushTransitions(); + jest.advanceTimersByTime(20); + expect(mockFireFocusEvent).toHaveBeenCalledWith(view); + }); + + it('clears the staged press after a PUSH_PARAMS forward so a later stack forward cannot reuse it', () => { + notifyPressedTrigger(fakeRef(fakeView('search-tab')), 'search-tab'); + notifyPushParamsForward(ROUTE_KEY, {q: 'old'}); + + handleStateChange(stackState(0, [{key: 'A', name: 'A'}])); + handleStateChange( + stackState(1, [ + {key: 'A', name: 'A'}, + {key: 'B', name: 'B'}, + ]), + ); + handleStateChange(stackState(0, [{key: 'A', name: 'A'}])); + flushTransitions(); + expect(mockFireFocusEvent).not.toHaveBeenCalled(); + }); + + it('clears the staged press on a PUSH_PARAMS backward so a later press-less forward cannot reuse it', () => { + handleStateChange(stackState(0, [{key: 'A', name: 'A'}])); + + notifyPressedTrigger(fakeRef(fakeView('back-button')), 'Back'); + notifyPushParamsBackward('A', {q: 'old'}); + + handleStateChange( + stackState(1, [ + {key: 'A', name: 'A'}, + {key: 'B', name: 'B'}, + ]), + ); + expect(getTriggerMapSizeForTests()).toBe(0); + }); + + it('restores via the registry under the raw route key when the captured ref was nulled (compound key)', () => { + const detachedRef = fakeRef(fakeView('row')); + notifyPressedTrigger(detachedRef, 'row'); + notifyPushParamsForward(ROUTE_KEY, {q: 'old'}); + + detachedRef.current = null; + const liveView = fakeView('row-remount'); + registerPressable(ROUTE_KEY, 'row', fakeRef(liveView)); + + notifyPushParamsBackward(ROUTE_KEY, {q: 'old'}); + flushTransitions(); + jest.advanceTimersByTime(20); + expect(mockFireFocusEvent).toHaveBeenCalledWith(liveView); + }); + + it('does NOT restore when the back targets a different params hash than the captured one', () => { + notifyPressedTrigger(fakeRef(fakeView('search-tab-expense'))); + + notifyPushParamsForward(ROUTE_KEY, {q: 'old'}); + notifyPushParamsBackward(ROUTE_KEY, {q: 'unrelated'}); + flushTransitions(); + expect(mockFireFocusEvent).not.toHaveBeenCalled(); + }); + + it('drops compound entries when the route is removed from the tree', () => { + notifyPressedTrigger(fakeRef(fakeView('search-tab-expense'))); + notifyPushParamsForward(ROUTE_KEY, {q: 'old'}); + expect(getTriggerMapSizeForTests()).toBe(1); + + handleStateChange(stackState(0, [{key: ROUTE_KEY, name: 'Search'}])); + handleStateChange(stackState(0, [{key: 'OtherRoot', name: 'Other'}])); + expect(getTriggerMapSizeForTests()).toBe(0); + }); + + it('defers the first restore attempt by one frame so the post-commit render lands before focus', () => { + const view = fakeView('row'); + notifyPressedTrigger(fakeRef(view)); + notifyPushParamsForward(ROUTE_KEY, {q: 'old'}); + + notifyPushParamsBackward(ROUTE_KEY, {q: 'old'}); + flushTransitions(); + expect(mockFireFocusEvent).not.toHaveBeenCalled(); + jest.advanceTimersByTime(20); + expect(mockFireFocusEvent).toHaveBeenCalledWith(view); + }); + + it('cancelPendingFocusRestore drops the rAF-deferred attempt so a later nav cannot replay it', () => { + notifyPressedTrigger(fakeRef(fakeView('row'))); + notifyPushParamsForward(ROUTE_KEY, {q: 'old'}); + + notifyPushParamsBackward(ROUTE_KEY, {q: 'old'}); + flushTransitions(); + cancelPendingFocusRestore(); + jest.advanceTimersByTime(20); + expect(mockFireFocusEvent).not.toHaveBeenCalled(); + }); +}); + +describe('pressable registry — identifier-based fallback', () => { + it('registers and deregisters by routeKey + identifier', () => { + const deregister = registerPressable('A', 'row', fakeRef(fakeView('row'))); + expect(getRegistrySizeForTests()).toBe(1); + deregister(); + expect(getRegistrySizeForTests()).toBe(0); + }); + + it('deregister removes only its own ref, so a same-identifier sibling survives (remount race)', () => { + const deregisterOld = registerPressable('A', 'row', fakeRef(fakeView('old'))); + registerPressable('A', 'row', fakeRef(fakeView('new'))); + deregisterOld(); + expect(getRegistrySizeForTests()).toBe(1); + }); + + it('restoreTriggerForRoute falls back to the registry when the captured ref was nulled by detach', () => { + const detachedRef = fakeRef(fakeView('row')); + notifyPressedTrigger(detachedRef, 'row'); + + handleStateChange(stackState(0, [{key: 'A', name: 'A'}])); + handleStateChange( + stackState(1, [ + {key: 'A', name: 'A'}, + {key: 'B', name: 'B'}, + ]), + ); + + detachedRef.current = null; + const liveView = fakeView('row-remount'); + registerPressable('A', 'row', fakeRef(liveView)); + + handleStateChange(stackState(0, [{key: 'A', name: 'A'}])); + flushTransitions(); + + expect(mockFireFocusEvent).toHaveBeenCalledWith(liveView); + }); + + it('rAF retry rescues focus when re-attach lags transitionEnd', () => { + const detachedRef = fakeRef(fakeView('row')); + notifyPressedTrigger(detachedRef, 'row'); + + handleStateChange(stackState(0, [{key: 'A', name: 'A'}])); + handleStateChange( + stackState(1, [ + {key: 'A', name: 'A'}, + {key: 'B', name: 'B'}, + ]), + ); + + detachedRef.current = null; + handleStateChange(stackState(0, [{key: 'A', name: 'A'}])); + flushTransitions(); + expect(mockFireFocusEvent).not.toHaveBeenCalled(); + + const liveView = fakeView('row-remount'); + registerPressable('A', 'row', fakeRef(liveView)); + jest.advanceTimersByTime(20); + + expect(mockFireFocusEvent).toHaveBeenCalledWith(liveView); + }); + + it('keeps retrying across several frames while re-attach lags, instead of giving up after one frame', () => { + const detachedRef = fakeRef(fakeView('row')); + notifyPressedTrigger(detachedRef, 'row'); + + handleStateChange(stackState(0, [{key: 'A', name: 'A'}])); + handleStateChange( + stackState(1, [ + {key: 'A', name: 'A'}, + {key: 'B', name: 'B'}, + ]), + ); + + detachedRef.current = null; + handleStateChange(stackState(0, [{key: 'A', name: 'A'}])); + flushTransitions(); + + jest.advanceTimersByTime(20); + jest.advanceTimersByTime(20); + expect(mockFireFocusEvent).not.toHaveBeenCalled(); + expect(getTriggerMapSizeForTests()).toBe(1); + + const liveView = fakeView('row-late-remount'); + registerPressable('A', 'row', fakeRef(liveView)); + jest.advanceTimersByTime(20); + expect(mockFireFocusEvent).toHaveBeenCalledWith(liveView); + }); + + it('gives up and clears the entry once the retry budget is exhausted', () => { + const detachedRef = fakeRef(fakeView('row')); + notifyPressedTrigger(detachedRef, 'row'); + + handleStateChange(stackState(0, [{key: 'A', name: 'A'}])); + handleStateChange( + stackState(1, [ + {key: 'A', name: 'A'}, + {key: 'B', name: 'B'}, + ]), + ); + + detachedRef.current = null; + handleStateChange(stackState(0, [{key: 'A', name: 'A'}])); + flushTransitions(); + + jest.advanceTimersByTime(200); + expect(mockFireFocusEvent).not.toHaveBeenCalled(); + expect(getTriggerMapSizeForTests()).toBe(0); + }); + + it('stores two same-route entries under distinct identifiers — duplicate-label rows do NOT collide when distinct ids exist', () => { + registerPressable('A', 'row-a', fakeRef(fakeView('a'))); + registerPressable('A', 'row-b', fakeRef(fakeView('b'))); + expect(getRegistrySizeForTests()).toBe(2); + }); + + it('fallback resolves the captured identifier even when other same-label registry entries exist for the route', () => { + const pressedRef = fakeRef(fakeView('a')); + const otherRef = fakeRef(fakeView('b')); + notifyPressedTrigger(pressedRef, 'row-a'); + + handleStateChange(stackState(0, [{key: 'A', name: 'List'}])); + handleStateChange( + stackState(1, [ + {key: 'A', name: 'List'}, + {key: 'B', name: 'Detail'}, + ]), + ); + + pressedRef.current = null; + const liveA = fakeView('a-remount'); + registerPressable('A', 'row-a', fakeRef(liveA)); + registerPressable('A', 'row-b', otherRef); + + handleStateChange(stackState(0, [{key: 'A', name: 'List'}])); + flushTransitions(); + + expect(mockFireFocusEvent).toHaveBeenCalledWith(liveA); + expect(mockFireFocusEvent).not.toHaveBeenCalledWith(otherRef.current); + }); + + it('declines the fallback when a colliding identifier maps to multiple live pressables (no wrong-row restore)', () => { + const detachedRef = fakeRef(fakeView('edit-pressed')); + notifyPressedTrigger(detachedRef, 'Edit'); + + handleStateChange(stackState(0, [{key: 'A', name: 'List'}])); + handleStateChange( + stackState(1, [ + {key: 'A', name: 'List'}, + {key: 'B', name: 'Detail'}, + ]), + ); + + detachedRef.current = null; + registerPressable('A', 'Edit', fakeRef(fakeView('row-1-edit'))); + registerPressable('A', 'Edit', fakeRef(fakeView('row-2-edit'))); + + handleStateChange(stackState(0, [{key: 'A', name: 'List'}])); + flushTransitions(); + jest.advanceTimersByTime(200); + + expect(mockFireFocusEvent).not.toHaveBeenCalled(); + expect(getTriggerMapSizeForTests()).toBe(0); + }); + + it('back button tie-breaks on collision (dual-header layout) — any backButton in the same route is a correct target', () => { + const detachedRef = fakeRef(fakeView('back-pressed')); + notifyPressedTrigger(detachedRef, 'backButton'); + + handleStateChange(stackState(0, [{key: 'A', name: 'A'}])); + handleStateChange( + stackState(1, [ + {key: 'A', name: 'A'}, + {key: 'B', name: 'B'}, + ]), + ); + + detachedRef.current = null; + const liveView = fakeView('backButton-1'); + registerPressable('A', 'backButton', fakeRef(liveView)); + registerPressable('A', 'backButton', fakeRef(fakeView('backButton-2'))); + + handleStateChange(stackState(0, [{key: 'A', name: 'A'}])); + flushTransitions(); + + // Tie-break: the first live ref is focused (not declined like row-level collisions). + expect(mockFireFocusEvent).toHaveBeenCalledWith(liveView); + }); + + it('clears the registry for a route key when that route is removed from the navigation tree', () => { + registerPressable('B', 'row', fakeRef(fakeView('row'))); + expect(getRegistrySizeForTests()).toBe(1); + + handleStateChange( + stackState(1, [ + {key: 'A', name: 'A'}, + {key: 'B', name: 'B'}, + ]), + ); + handleStateChange(stackState(0, [{key: 'A', name: 'A'}])); + + expect(getRegistrySizeForTests()).toBe(0); + }); +}); + +describe('web-only stubs return constant values on native', () => { + it('isFocusRestoreInProgress always returns false', () => { + expect(isFocusRestoreInProgress()).toBe(false); + }); + + it('shouldSkipAutoFocusDueToExistingFocus always returns false', () => { + expect(shouldSkipAutoFocusDueToExistingFocus()).toBe(false); + }); +}); diff --git a/tests/unit/NavigationFocusReturnTest.ts b/tests/unit/NavigationFocusReturnTest.ts index 29f2a1f08d5e..41981cbffa39 100644 --- a/tests/unit/NavigationFocusReturnTest.ts +++ b/tests/unit/NavigationFocusReturnTest.ts @@ -1,4 +1,25 @@ // Typed require with explicit .ts path — matches the project's test-file convention. + +// scheduleRestore defers through TransitionTracker; mock it so the deferred restore can be flushed deterministically (waitForUpcomingTransition is Promise-based and can't be driven by fake timers alone). +type TtEntry = {cb: () => void; cancelled: boolean; waitForUpcomingTransition: boolean | 'navigation'}; +let mockTtQueue: TtEntry[] = []; +jest.mock('../../src/libs/Navigation/TransitionTracker', () => ({ + __esModule: true, + default: { + startTransition: jest.fn(), + endTransition: jest.fn(), + runAfterTransitions: ({callback, waitForUpcomingTransition = false}: {callback: () => void; waitForUpcomingTransition?: boolean | 'navigation'}) => { + const entry: TtEntry = {cb: callback, cancelled: false, waitForUpcomingTransition}; + mockTtQueue.push(entry); + return { + cancel: () => { + entry.cancelled = true; + }, + }; + }, + }, +})); + /* eslint-disable import/extensions */ const {resetCycle: resetArbiter, tryClaim, Priorities} = require<{ resetCycle: () => void; @@ -10,8 +31,6 @@ const {resetForTests: resetHadTabNavigation, setupHadTabNavigation} = require<{ setupHadTabNavigation: () => void; }>('../../src/libs/hadTabNavigation.ts'); const { - diffNavigationState, - collectRouteKeys, captureTriggerForRoute, restoreTriggerForRoute, handleStateChange, @@ -23,13 +42,10 @@ const { cancelPendingFocusRestore, skipNextFocusRestore, isFocusRestoreInProgress, - compoundParamsKey, shouldSkipAutoFocusDueToExistingFocus, setupNavigationFocusReturn, teardownNavigationFocusReturn, } = require<{ - diffNavigationState: (prev: unknown, next: unknown) => {action: {type: string; captureKey?: string; restoreKey?: string}; removedKeys: string[]}; - collectRouteKeys: (state: unknown) => Set; captureTriggerForRoute: (routeKey: string) => void; restoreTriggerForRoute: (routeKey: string) => boolean; handleStateChange: (state: unknown) => void; @@ -41,14 +57,18 @@ const { cancelPendingFocusRestore: () => void; skipNextFocusRestore: () => void; isFocusRestoreInProgress: () => boolean; - compoundParamsKey: (routeKey: string, params: unknown) => string; shouldSkipAutoFocusDueToExistingFocus: () => boolean; setupNavigationFocusReturn: () => void; teardownNavigationFocusReturn: () => void; -}>('../../src/libs/NavigationFocusReturn.ts'); -const {setActivePopoverLauncher, scheduleClearActivePopoverLauncher} = require<{ +}>('../../src/libs/NavigationFocusReturn/index.ts'); +const {diffNavigationState, collectRouteKeys} = require<{ + diffNavigationState: (prev: unknown, next: unknown) => {action: {type: string; captureKey?: string; restoreKey?: string}; removedKeys: string[]}; + collectRouteKeys: (state: unknown) => Set; +}>('../../src/libs/navigationStateDiff.ts'); +const {default: compoundParamsKey} = require<{default: (routeKey: string, params: unknown) => string}>('../../src/libs/compoundParamsKey.ts'); +const {setActivePopoverLauncher, markActivePopoverLauncherDeactivated} = require<{ setActivePopoverLauncher: (element: HTMLElement) => void; - scheduleClearActivePopoverLauncher: (element?: HTMLElement) => void; + markActivePopoverLauncherDeactivated: (element?: HTMLElement) => void; }>('../../src/libs/LauncherStack.ts'); const {default: hasFocusableAttributes} = require<{ default: (el: Element) => boolean; @@ -119,6 +139,18 @@ function withFakeTimers(fn: () => T): T { } } +// Runs the restore callbacks that scheduleRestore queued through the mocked TransitionTracker (mirrors a transition completing). +function flushTransitions(): void { + const buffered = mockTtQueue; + mockTtQueue = []; + for (const entry of buffered) { + if (entry.cancelled) { + continue; + } + entry.cb(); + } +} + setupHadTabNavigation(); setupNavigationFocusReturn(); @@ -126,6 +158,7 @@ beforeEach(() => { resetForTests(); resetArbiter(); resetHadTabNavigation(); + mockTtQueue = []; document.body.innerHTML = ''; }); @@ -432,7 +465,7 @@ describe('captureTriggerForRoute', () => { // Popover opens then closes: launcher set, deferred clear pending. setActivePopoverLauncher(launcher); - scheduleClearActivePopoverLauncher(); + markActivePopoverLauncherDeactivated(); // FocusTrap returnFocus puts focus on launcher first. launcher.focus(); @@ -1102,7 +1135,7 @@ describe('restoreTriggerForRoute', () => { const clearSpy = jest.spyOn(clearAfterButton, 'focus'); // Scheduled restore fires; RETURN preempts AUTO and focus lands on "Clear after", not the Message input. - jest.runAllTimers(); + flushTransitions(); expect(clearSpy).toHaveBeenCalled(); expect(messageSpy).not.toHaveBeenCalled(); }); @@ -1127,7 +1160,7 @@ describe('restoreTriggerForRoute', () => { // Esc → backward → scheduled restore refocuses Clear after. Hold extends because the target is still focused. handleStateChange(onStatus); - jest.runAllTimers(); + flushTransitions(); expect(document.activeElement).toBe(clearAfterButton); // Late useAutoFocusInput: the guard catches it before it reaches tryClaim. @@ -1143,6 +1176,26 @@ describe('restoreTriggerForRoute', () => { }); }); + it('stack-pop restore fires synchronously inside the transition callback (no rAF defer)', () => { + withFakeTimers(() => { + simulateTab(); + const trigger = appendButton(); + fireFocusIn(trigger); + handleStateChange(stackState(0, [{key: 'route-a', name: 'A'}])); + handleStateChange( + stackState(1, [ + {key: 'route-a', name: 'A'}, + {key: 'route-b', name: 'B'}, + ]), + ); + trigger.blur(); + handleStateChange(stackState(0, [{key: 'route-a', name: 'A'}])); + const spy = jest.spyOn(trigger, 'focus'); + flushTransitions(); + expect(spy).toHaveBeenCalled(); + }); + }); + it('should consume the entry so a second restore returns false', () => { const trigger = document.createElement('button'); document.body.appendChild(trigger); @@ -1451,7 +1504,7 @@ describe('handleStateChange integration', () => { handleStateChange(onA); const spy = jest.spyOn(trigger, 'focus'); - jest.runAllTimers(); + flushTransitions(); expect(spy).toHaveBeenCalled(); }); }); @@ -1469,18 +1522,42 @@ describe('handleStateChange integration', () => { skipNextFocusRestore(); const spy = jest.spyOn(trigger, 'focus'); handleStateChange(onA); - jest.runAllTimers(); + flushTransitions(); expect(spy).not.toHaveBeenCalled(); - // The flag is one-shot: a subsequent Back-button dismissal restores normally. + // The flag is one-shot: a fresh capture + Back-button dismissal restores normally. + fireFocusIn(trigger); handleStateChange(onAB); trigger.blur(); handleStateChange(onA); - jest.runAllTimers(); + flushTransitions(); expect(spy).toHaveBeenCalled(); }); }); + it('skipNextFocusRestore drops the entry, so a later same-key backward without re-capture does not replay a stale trigger', () => { + withFakeTimers(() => { + simulateTab(); + handleStateChange(onA); + + const trigger = appendButton(); + fireFocusIn(trigger); + handleStateChange(onAB); + trigger.blur(); + + skipNextFocusRestore(); + handleStateChange(onA); + flushTransitions(); + + const spy = jest.spyOn(trigger, 'focus'); + handleStateChange(onAB); + trigger.blur(); + handleStateChange(onA); + flushTransitions(); + expect(spy).not.toHaveBeenCalled(); + }); + }); + it('skipNextFocusRestore flag is cleared by an intervening forward nav so it cannot leak into a later backward', () => { withFakeTimers(() => { simulateTab(); @@ -1495,7 +1572,7 @@ describe('handleStateChange integration', () => { trigger.blur(); const spy = jest.spyOn(trigger, 'focus'); handleStateChange(onA); - jest.runAllTimers(); + flushTransitions(); expect(spy).toHaveBeenCalled(); }); }); @@ -1536,7 +1613,7 @@ describe('handleStateChange integration', () => { handleStateChange(onTab2); const spy = jest.spyOn(trigger, 'focus'); - jest.runAllTimers(); + flushTransitions(); expect(spy).not.toHaveBeenCalled(); }); }); @@ -1562,7 +1639,7 @@ describe('handleStateChange integration', () => { handleStateChange(onAC); const spy = jest.spyOn(trigger, 'focus'); - jest.runAllTimers(); + flushTransitions(); expect(spy).not.toHaveBeenCalled(); }); }); @@ -1581,7 +1658,7 @@ describe('handleStateChange integration', () => { expect(restoreTriggerForRoute('a')).toBe(false); }); - it('should drop the stale entry after MAX_RESTORE_ATTEMPTS retries all fail', () => { + it('should drop the stale entry after the retry budget is exhausted (trigger stays aria-hidden)', () => { withFakeTimers(() => { simulateTab(); const hidden = document.createElement('div'); @@ -1596,7 +1673,8 @@ describe('handleStateChange integration', () => { trigger.blur(); handleStateChange(onA); - // Trigger stays aria-hidden across all retry attempts — scheduleRestore gives up. + // Trigger stays aria-hidden across the transition + every rAF retry — scheduleRestore gives up. + flushTransitions(); jest.runAllTimers(); const spy = jest.spyOn(trigger, 'focus'); @@ -1683,6 +1761,27 @@ describe('PUSH_PARAMS notifications', () => { const spy = jest.spyOn(trigger, 'focus'); notifyPushParamsBackward('search-x', {q: 'foo'}); + flushTransitions(); + jest.runAllTimers(); + expect(spy).toHaveBeenCalled(); + }); + }); + + it('recovers focus when the trigger is detached at the first attempt and remounts within the retry budget', () => { + withFakeTimers(() => { + const trigger = appendInput(); + fireFocusIn(trigger); + notifyPushParamsForward('search-x', {q: 'foo'}); + + trigger.remove(); + + const spy = jest.spyOn(trigger, 'focus'); + notifyPushParamsBackward('search-x', {q: 'foo'}); + + flushTransitions(); + expect(spy).not.toHaveBeenCalled(); + + document.body.appendChild(trigger); jest.runAllTimers(); expect(spy).toHaveBeenCalled(); }); @@ -1712,6 +1811,56 @@ describe('PUSH_PARAMS notifications', () => { const spy = jest.spyOn(trigger, 'focus'); notifyPushParamsBackward('search-x', {q: 'baz'}); + flushTransitions(); + expect(spy).not.toHaveBeenCalled(); + }); + }); + + it('defers the first restore attempt by one frame so the post-commit render lands before focus', () => { + withFakeTimers(() => { + const trigger = appendInput(); + fireFocusIn(trigger); + notifyPushParamsForward('search-x', {q: 'foo'}); + trigger.blur(); + + const spy = jest.spyOn(trigger, 'focus'); + notifyPushParamsBackward('search-x', {q: 'foo'}); + flushTransitions(); + expect(spy).not.toHaveBeenCalled(); + jest.runAllTimers(); + expect(spy).toHaveBeenCalled(); + }); + }); + + it('yields to a user focus that lands during the rAF defer (baseline-vs-activeElement check still wins)', () => { + withFakeTimers(() => { + const trigger = appendInput(); + fireFocusIn(trigger); + notifyPushParamsForward('search-x', {q: 'foo'}); + trigger.blur(); + + const triggerSpy = jest.spyOn(trigger, 'focus'); + notifyPushParamsBackward('search-x', {q: 'foo'}); + flushTransitions(); + const userTarget = appendButton(); + userTarget.focus(); + jest.runAllTimers(); + expect(triggerSpy).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(userTarget); + }); + }); + + it('cancelPendingFocusRestore drops the rAF-deferred attempt so a later nav cannot replay it', () => { + withFakeTimers(() => { + const trigger = appendInput(); + fireFocusIn(trigger); + notifyPushParamsForward('search-x', {q: 'foo'}); + trigger.blur(); + + const spy = jest.spyOn(trigger, 'focus'); + notifyPushParamsBackward('search-x', {q: 'foo'}); + flushTransitions(); + cancelPendingFocusRestore(); jest.runAllTimers(); expect(spy).not.toHaveBeenCalled(); }); @@ -1790,7 +1939,7 @@ describe('teardown / setup lifecycle', () => { const spy = jest.spyOn(trigger, 'focus'); teardownNavigationFocusReturn(); - jest.runAllTimers(); // if cancellation failed, restore would fire here + flushTransitions(); // if cancellation failed, the restore would fire here expect(spy).not.toHaveBeenCalled(); }); }); diff --git a/tests/unit/fireFocusEventAndroidTest.ts b/tests/unit/fireFocusEventAndroidTest.ts new file mode 100644 index 000000000000..75ffa84fd27f --- /dev/null +++ b/tests/unit/fireFocusEventAndroidTest.ts @@ -0,0 +1,44 @@ +import {AccessibilityInfo} from 'react-native'; + +const mockLogWarn = jest.fn(); +jest.mock('@libs/Log', () => ({ + __esModule: true, + default: { + warn: (...args: unknown[]): void => { + mockLogWarn(...args); + }, + info: jest.fn(), + alert: jest.fn(), + hmmm: jest.fn(), + client: jest.fn(), + }, +})); + +const mockSendAccessibilityEvent = jest.fn(); +AccessibilityInfo.sendAccessibilityEvent = mockSendAccessibilityEvent; + +const fireFocusEvent = require<{default: (view: unknown) => void}>('../../src/libs/Accessibility/fireFocusEvent/index.native').default; + +beforeEach(() => { + mockSendAccessibilityEvent.mockClear(); + mockLogWarn.mockClear(); +}); + +describe('fireFocusEvent (native)', () => { + it('dispatches sendAccessibilityEvent with `focus` for the given view', () => { + const view = {label: 'pressable'}; + fireFocusEvent(view); + expect(mockSendAccessibilityEvent).toHaveBeenCalledTimes(1); + expect(mockSendAccessibilityEvent).toHaveBeenCalledWith(view, 'focus'); + }); + + it('catches and logs (does not rethrow) when sendAccessibilityEvent throws on a stale native handle', () => { + mockSendAccessibilityEvent.mockImplementationOnce(() => { + throw new Error('View has been removed'); + }); + const view = {label: 'detached'}; + + expect(() => fireFocusEvent(view)).not.toThrow(); + expect(mockLogWarn).toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/libs/Accessibility/useScreenReaderStateTest.tsx b/tests/unit/libs/Accessibility/useScreenReaderStateTest.tsx new file mode 100644 index 000000000000..aec94c35a5cc --- /dev/null +++ b/tests/unit/libs/Accessibility/useScreenReaderStateTest.tsx @@ -0,0 +1,123 @@ +import {act, renderHook} from '@testing-library/react-native'; +import {useEffect, useRef} from 'react'; + +const mockScreenReaderResolvers: Array<(value: boolean) => void> = []; + +jest.mock('@libs/Log'); +jest.mock('@libs/Accessibility/isScreenReaderEnabled', () => ({ + __esModule: true, + default: () => + new Promise((resolve) => { + mockScreenReaderResolvers.push(resolve); + }), +})); + +jest.mock('react-native', () => ({ + __esModule: true, + AccessibilityInfo: { + addEventListener: jest.fn(() => ({remove: jest.fn()})), + isReduceMotionEnabled: jest.fn(() => Promise.resolve(false)), + }, + AppState: { + addEventListener: jest.fn(() => ({remove: jest.fn()})), + currentState: 'active', + }, +})); + +type ScreenReaderState = 'enabled' | 'disabled' | 'unknown'; + +const Accessibility = require<{ + default: { + useScreenReaderState: () => ScreenReaderState; + getScreenReaderState: () => ScreenReaderState; + }; + resetForTests: () => void; +}>('@libs/Accessibility'); + +async function flushPromises(): Promise { + await new Promise((resolve) => { + setImmediate(resolve); + }); +} + +beforeEach(() => { + Accessibility.resetForTests(); + mockScreenReaderResolvers.length = 0; +}); + +describe('useScreenReaderState', () => { + it("returns 'unknown' during the warm-up window (cache not yet resolved) so callers register/capture defensively", () => { + const {result, unmount} = renderHook(() => Accessibility.default.useScreenReaderState()); + expect(result.current).toBe('unknown'); + unmount(); + }); + + it("re-renders to 'disabled' after warm resolves with SR-off — the load-bearing reactivity a Pressable mounted mid-warm-up relies on", async () => { + let renderCount = 0; + const {result, unmount} = renderHook(() => { + renderCount += 1; + return Accessibility.default.useScreenReaderState(); + }); + expect(result.current).toBe('unknown'); + const initialRenderCount = renderCount; + + await act(async () => { + mockScreenReaderResolvers[0]?.(false); + await flushPromises(); + }); + expect(result.current).toBe('disabled'); + expect(renderCount).toBeGreaterThan(initialRenderCount); + unmount(); + }); + + it("re-renders to 'enabled' after warm resolves with SR-on", async () => { + const {result, unmount} = renderHook(() => Accessibility.default.useScreenReaderState()); + expect(result.current).toBe('unknown'); + + await act(async () => { + mockScreenReaderResolvers[0]?.(true); + await flushPromises(); + }); + expect(result.current).toBe('enabled'); + unmount(); + }); + + it('snapshot stays consistent with `getScreenReaderState()` across the warm-up transition', async () => { + expect(Accessibility.default.getScreenReaderState()).toBe('unknown'); + const {result, unmount} = renderHook(() => Accessibility.default.useScreenReaderState()); + expect(result.current).toBe(Accessibility.default.getScreenReaderState()); + + await act(async () => { + mockScreenReaderResolvers[0]?.(false); + await flushPromises(); + }); + expect(result.current).toBe(Accessibility.default.getScreenReaderState()); + expect(result.current).toBe('disabled'); + unmount(); + }); + + it("an effect depending on the hook re-runs when warm resolves (the architectural contract `BaseGenericPressable`'s registry effect relies on)", async () => { + const sideEffect = jest.fn(); + const {unmount} = renderHook(() => { + const state = Accessibility.default.useScreenReaderState(); + const isFirstRunRef = useRef(true); + useEffect(() => { + sideEffect(state); + if (isFirstRunRef.current) { + isFirstRunRef.current = false; + } + }, [state]); + return state; + }); + expect(sideEffect).toHaveBeenCalledTimes(1); + expect(sideEffect).toHaveBeenLastCalledWith('unknown'); + + await act(async () => { + mockScreenReaderResolvers[0]?.(false); + await flushPromises(); + }); + expect(sideEffect).toHaveBeenCalledTimes(2); + expect(sideEffect).toHaveBeenLastCalledWith('disabled'); + unmount(); + }); +}); diff --git a/tests/unit/libs/Accessibility/warmCacheTest.ts b/tests/unit/libs/Accessibility/warmCacheTest.ts new file mode 100644 index 000000000000..5156479b7b4c --- /dev/null +++ b/tests/unit/libs/Accessibility/warmCacheTest.ts @@ -0,0 +1,204 @@ +type AppStateChangeListener = (status: string) => void; +type ScreenReaderState = 'enabled' | 'disabled' | 'unknown'; +type AccessibilityModule = { + default: { + isScreenReaderEnabledSync: () => boolean; + getScreenReaderState: () => ScreenReaderState; + }; +}; + +const appStateListeners: AppStateChangeListener[] = []; + +let mockScreenReaderValue = false; +let mockReduceMotionValue = false; +let mockReduceMotionFetchCount = 0; +let mockScreenReaderDeferred = false; +let mockInitialAppState: 'active' | 'background' | 'inactive' = 'active'; +const mockScreenReaderResolvers: Array<(value: boolean) => void> = []; + +jest.mock('@libs/Log'); +jest.mock('@libs/Accessibility/isScreenReaderEnabled', () => ({ + __esModule: true, + default: () => { + if (mockScreenReaderDeferred) { + return new Promise((resolve) => { + mockScreenReaderResolvers.push(resolve); + }); + } + return Promise.resolve(mockScreenReaderValue); + }, +})); + +jest.mock('react-native', () => ({ + __esModule: true, + AccessibilityInfo: { + addEventListener: jest.fn(() => ({remove: jest.fn()})), + isReduceMotionEnabled: jest.fn(() => { + mockReduceMotionFetchCount += 1; + return Promise.resolve(mockReduceMotionValue); + }), + }, + AppState: { + addEventListener: jest.fn((event: string, listener: AppStateChangeListener) => { + if (event === 'change') { + appStateListeners.push(listener); + } + return {remove: jest.fn()}; + }), + get currentState() { + return mockInitialAppState; + }, + }, +})); + +beforeEach(() => { + jest.resetModules(); + appStateListeners.length = 0; + mockScreenReaderValue = false; + mockReduceMotionValue = false; + mockReduceMotionFetchCount = 0; + mockScreenReaderDeferred = false; + mockInitialAppState = 'active'; + mockScreenReaderResolvers.length = 0; +}); + +function loadModule(): AccessibilityModule { + return require('@libs/Accessibility'); +} + +function emitAppState(status: string): void { + for (const cb of appStateListeners) { + cb(status); + } +} + +async function flushPromises(): Promise { + await new Promise((resolve) => { + setImmediate(resolve); + }); +} + +describe('Accessibility warm cache — AppState refresh', () => { + it('re-fetches the screen-reader value on background→active transition', async () => { + mockScreenReaderValue = false; + const Accessibility = loadModule(); + await flushPromises(); + expect(Accessibility.default.isScreenReaderEnabledSync()).toBe(false); + + mockScreenReaderValue = true; + emitAppState('background'); + emitAppState('active'); + await flushPromises(); + + expect(Accessibility.default.isScreenReaderEnabledSync()).toBe(true); + }); + + it('cold-start with AppState.currentState=background still refreshes on first active event', async () => { + mockInitialAppState = 'background'; + mockScreenReaderValue = false; + const Accessibility = loadModule(); + await flushPromises(); + + mockScreenReaderValue = true; + emitAppState('active'); + await flushPromises(); + + expect(Accessibility.default.isScreenReaderEnabledSync()).toBe(true); + }); + + it('re-fetches on iOS-style background→inactive→active resume sequence', async () => { + mockScreenReaderValue = false; + const Accessibility = loadModule(); + await flushPromises(); + expect(Accessibility.default.isScreenReaderEnabledSync()).toBe(false); + + mockScreenReaderValue = true; + emitAppState('background'); + emitAppState('inactive'); + emitAppState('active'); + await flushPromises(); + + expect(Accessibility.default.isScreenReaderEnabledSync()).toBe(true); + }); + + it('does NOT re-fetch on inactive→active without a background hop', async () => { + mockScreenReaderValue = false; + const Accessibility = loadModule(); + await flushPromises(); + const initialReduceMotionFetches = mockReduceMotionFetchCount; + + // A foreground touch or control-center pull-down fires inactive→active without a background. + mockScreenReaderValue = true; + emitAppState('active'); + await flushPromises(); + + expect(Accessibility.default.isScreenReaderEnabledSync()).toBe(false); + expect(mockReduceMotionFetchCount).toBe(initialReduceMotionFetches); + }); + + it("getScreenReaderState returns 'unknown' before warm resolves and 'disabled' only after a false-resolution", async () => { + mockScreenReaderValue = false; + const Accessibility = loadModule(); + expect(Accessibility.default.getScreenReaderState()).toBe('unknown'); + await flushPromises(); + expect(Accessibility.default.getScreenReaderState()).toBe('disabled'); + }); + + it("getScreenReaderState returns 'enabled' after warm resolves with SR enabled", async () => { + mockScreenReaderValue = true; + const Accessibility = loadModule(); + await flushPromises(); + expect(Accessibility.default.getScreenReaderState()).toBe('enabled'); + }); + + it('discards a superseded in-flight warm fetch on out-of-order resolution (newer value wins)', async () => { + mockScreenReaderDeferred = true; + const Accessibility = loadModule(); + expect(mockScreenReaderResolvers).toHaveLength(1); + + emitAppState('background'); + emitAppState('active'); + expect(mockScreenReaderResolvers).toHaveLength(2); + + // Refresh (#2) resolves first with SR enabled. + mockScreenReaderResolvers[1](true); + await flushPromises(); + expect(Accessibility.default.isScreenReaderEnabledSync()).toBe(true); + expect(Accessibility.default.getScreenReaderState()).toBe('enabled'); + + // The superseded initial fetch (#1) resolves later with the obsolete value — must NOT overwrite the refresh result. + mockScreenReaderResolvers[0](false); + await flushPromises(); + expect(Accessibility.default.isScreenReaderEnabledSync()).toBe(true); + expect(Accessibility.default.getScreenReaderState()).toBe('enabled'); + }); + + it("getScreenReaderState returns 'unknown' during a resume refresh (warmed flag invalidates until the new value resolves)", async () => { + mockScreenReaderValue = false; + const Accessibility = loadModule(); + await flushPromises(); + expect(Accessibility.default.getScreenReaderState()).toBe('disabled'); + + mockScreenReaderValue = true; + emitAppState('background'); + emitAppState('active'); + expect(Accessibility.default.getScreenReaderState()).toBe('unknown'); + + await flushPromises(); + expect(Accessibility.default.getScreenReaderState()).toBe('enabled'); + }); + + it('re-fetches the reduce-motion value on background→active transition', async () => { + mockReduceMotionValue = false; + loadModule(); + await flushPromises(); + const initialFetches = mockReduceMotionFetchCount; + + mockReduceMotionValue = true; + emitAppState('background'); + emitAppState('active'); + await flushPromises(); + + expect(mockReduceMotionFetchCount).toBeGreaterThan(initialFetches); + }); +}); diff --git a/tests/unit/libs/NavigationFocusReturn/fifoMapTest.ts b/tests/unit/libs/NavigationFocusReturn/fifoMapTest.ts new file mode 100644 index 000000000000..916845baede2 --- /dev/null +++ b/tests/unit/libs/NavigationFocusReturn/fifoMapTest.ts @@ -0,0 +1,50 @@ +import setFifoEntry from '@libs/NavigationFocusReturn/fifoMap'; + +describe('setFifoEntry', () => { + it('appends a new key at the tail', () => { + const map = new Map(); + setFifoEntry(map, 'a', 1, 3); + setFifoEntry(map, 'b', 2, 3); + setFifoEntry(map, 'c', 3, 3); + expect(Array.from(map.keys())).toEqual(['a', 'b', 'c']); + }); + + it('moves a re-set key to the tail so FIFO eviction drops the truly-oldest, not a recently-active key', () => { + const map = new Map(); + setFifoEntry(map, 'a', 1, 3); + setFifoEntry(map, 'b', 2, 3); + setFifoEntry(map, 'c', 3, 3); + setFifoEntry(map, 'a', 99, 3); // re-set 'a' — should move to tail. + expect(Array.from(map.keys())).toEqual(['b', 'c', 'a']); + expect(map.get('a')).toBe(99); + }); + + it('evicts the oldest entry when size exceeds maxSize', () => { + const map = new Map(); + setFifoEntry(map, 'a', 1, 3); + setFifoEntry(map, 'b', 2, 3); + setFifoEntry(map, 'c', 3, 3); + setFifoEntry(map, 'd', 4, 3); + expect(Array.from(map.keys())).toEqual(['b', 'c', 'd']); + expect(map.has('a')).toBe(false); + }); + + it('respects the move-to-tail invariant under eviction — a recently re-set key survives an eviction that would otherwise drop it', () => { + const map = new Map(); + setFifoEntry(map, 'a', 1, 3); + setFifoEntry(map, 'b', 2, 3); + setFifoEntry(map, 'c', 3, 3); + setFifoEntry(map, 'a', 99, 3); // 'a' moves to tail; oldest is now 'b'. + setFifoEntry(map, 'd', 4, 3); // eviction drops 'b', not 'a'. + expect(Array.from(map.keys())).toEqual(['c', 'a', 'd']); + expect(map.has('b')).toBe(false); + }); + + it('treats maxSize <= 0 as a zero-element cap, evicting on every insert so the map never retains entries', () => { + const map = new Map(); + setFifoEntry(map, 'a', 1, 0); + expect(map.size).toBe(0); + setFifoEntry(map, 'b', 2, 0); + expect(map.size).toBe(0); + }); +}); diff --git a/tests/unit/restoreFocusWithModalityTest.ts b/tests/unit/restoreFocusWithModalityTest.ts new file mode 100644 index 000000000000..963592c135c5 --- /dev/null +++ b/tests/unit/restoreFocusWithModalityTest.ts @@ -0,0 +1,129 @@ +type MockTrap = {paused: boolean; pause: jest.Mock; unpause: jest.Mock}; + +let mockHadTabNavigation = true; + +jest.mock('@libs/hadTabNavigation', () => ({ + __esModule: true, + default: () => mockHadTabNavigation, +})); + +jest.mock('@libs/sharedTrapStack', () => ({ + __esModule: true, + default: [], +})); + +const mockTrapStack = require<{default: MockTrap[]}>('@libs/sharedTrapStack').default; +const restoreFocusWithModality = require<{default: (el: HTMLElement, options?: {preventScroll?: boolean}) => void}>('@libs/restoreFocusWithModality').default; + +function pushMockTrap({paused = false}: {paused?: boolean} = {}): MockTrap { + const trap: MockTrap = {paused, pause: jest.fn(), unpause: jest.fn()}; + trap.pause.mockImplementation(() => { + trap.paused = true; + }); + trap.unpause.mockImplementation(() => { + trap.paused = false; + }); + mockTrapStack.push(trap); + return trap; +} + +beforeEach(() => { + mockTrapStack.length = 0; + mockHadTabNavigation = true; +}); + +describe('restoreFocusWithModality', () => { + it('focuses the element with focusVisible derived from getHadTabNavigation', () => { + const el = document.createElement('button'); + const focusSpy = jest.spyOn(el, 'focus'); + + restoreFocusWithModality(el); + + expect(focusSpy).toHaveBeenCalledTimes(1); + expect(focusSpy).toHaveBeenCalledWith({preventScroll: true, focusVisible: true}); + }); + + it('passes focusVisible=false when the prior modality was not keyboard', () => { + mockHadTabNavigation = false; + const el = document.createElement('button'); + const focusSpy = jest.spyOn(el, 'focus'); + + restoreFocusWithModality(el); + + expect(focusSpy).toHaveBeenCalledWith({preventScroll: true, focusVisible: false}); + }); + + it('honors preventScroll: false so an off-screen launcher can scroll into view on modal close', () => { + const el = document.createElement('button'); + const focusSpy = jest.spyOn(el, 'focus'); + + restoreFocusWithModality(el, {preventScroll: false}); + + expect(focusSpy).toHaveBeenCalledWith({preventScroll: false, focusVisible: true}); + }); + + it('pauses and unpauses the topmost trap when it was active', () => { + const trap = pushMockTrap({paused: false}); + const el = document.createElement('button'); + + restoreFocusWithModality(el); + + expect(trap.pause).toHaveBeenCalledTimes(1); + expect(trap.unpause).toHaveBeenCalledTimes(1); + }); + + it('leaves an already-paused trap untouched so a pause owned by another caller is not resurrected', () => { + const trap = pushMockTrap({paused: true}); + const el = document.createElement('button'); + + restoreFocusWithModality(el); + + expect(trap.pause).not.toHaveBeenCalled(); + expect(trap.unpause).not.toHaveBeenCalled(); + expect(trap.paused).toBe(true); + }); + + it('only affects the topmost trap', () => { + const lower = pushMockTrap({paused: false}); + const topmost = pushMockTrap({paused: false}); + const el = document.createElement('button'); + + restoreFocusWithModality(el); + + expect(topmost.pause).toHaveBeenCalledTimes(1); + expect(topmost.unpause).toHaveBeenCalledTimes(1); + expect(lower.pause).not.toHaveBeenCalled(); + expect(lower.unpause).not.toHaveBeenCalled(); + }); + + it('is safe with an empty trap stack', () => { + const el = document.createElement('button'); + expect(() => restoreFocusWithModality(el)).not.toThrow(); + }); + + it('unpauses the trap even if el.focus throws — never leaves a trap paused', () => { + const trap = pushMockTrap({paused: false}); + const el = document.createElement('button'); + jest.spyOn(el, 'focus').mockImplementation(() => { + throw new Error('focus failed'); + }); + + expect(() => restoreFocusWithModality(el)).toThrow('focus failed'); + expect(trap.pause).toHaveBeenCalledTimes(1); + expect(trap.unpause).toHaveBeenCalledTimes(1); + }); + + it('unpauses the captured parent trap even when el.focus synchronously activates a new trap that takes the stack-top', () => { + // focus-trap's unpause clears state.manuallyPaused=false BEFORE the topmost check; skipping it blocks the next trap's auto-unwind. + const parent = pushMockTrap({paused: false}); + const el = document.createElement('button'); + jest.spyOn(el, 'focus').mockImplementation(() => { + pushMockTrap({paused: false}); + }); + + restoreFocusWithModality(el); + + expect(parent.pause).toHaveBeenCalledTimes(1); + expect(parent.unpause).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/scheduleRefocusAndroidTest.ts b/tests/unit/scheduleRefocusAndroidTest.ts new file mode 100644 index 000000000000..e308573709aa --- /dev/null +++ b/tests/unit/scheduleRefocusAndroidTest.ts @@ -0,0 +1,68 @@ +const mockFireFocusEvent = jest.fn(); +jest.mock('@libs/Accessibility/fireFocusEvent', () => ({ + __esModule: true, + default: (view: unknown): void => { + mockFireFocusEvent(view); + }, +})); + +const FAKE_IDLE_ID = 4242; +let capturedIdleCallback: (() => void) | null = null; +let capturedIdleOptions: {timeout?: number} | undefined; +const mockCancelIdleCallback = jest.fn(); + +const originalRequestIdleCallback = global.requestIdleCallback; +const originalCancelIdleCallback = global.cancelIdleCallback; + +const scheduleRefocus = require<{default: (ref: {current: unknown}) => {cancel: () => void}}>('../../src/libs/Accessibility/scheduleRefocus/index.android').default; + +beforeEach(() => { + capturedIdleCallback = null; + capturedIdleOptions = undefined; + mockFireFocusEvent.mockClear(); + mockCancelIdleCallback.mockClear(); + // Capture the idle callback instead of running it, so each test drives the re-fire deterministically. + global.requestIdleCallback = jest.fn((callback, options) => { + capturedIdleCallback = () => callback({didTimeout: false, timeRemaining: () => 50}); + capturedIdleOptions = options; + return FAKE_IDLE_ID; + }); + global.cancelIdleCallback = mockCancelIdleCallback; +}); + +afterEach(() => { + global.requestIdleCallback = originalRequestIdleCallback; + global.cancelIdleCallback = originalCancelIdleCallback; +}); + +describe('scheduleRefocus (android)', () => { + it('schedules the re-fire on idle with the 300ms timeout cap and does not fire synchronously', () => { + scheduleRefocus({current: {label: 'row'}}); + expect(global.requestIdleCallback).toHaveBeenCalledTimes(1); + expect(capturedIdleOptions).toEqual({timeout: 300}); + expect(mockFireFocusEvent).not.toHaveBeenCalled(); + }); + + it('re-fires focus on the still-mounted view when the thread idles (wins the TYPE_WINDOW_STATE_CHANGED race)', () => { + const view = {label: 'row'}; + scheduleRefocus({current: view}); + capturedIdleCallback?.(); + expect(mockFireFocusEvent).toHaveBeenCalledTimes(1); + expect(mockFireFocusEvent).toHaveBeenCalledWith(view); + }); + + it('does NOT re-fire when the view unmounted (ref nulled) before the thread idled', () => { + const ref: {current: unknown} = {current: {label: 'row'}}; + scheduleRefocus(ref); + ref.current = null; + capturedIdleCallback?.(); + expect(mockFireFocusEvent).not.toHaveBeenCalled(); + }); + + it('cancel() cancels the scheduled idle callback so a superseded restore does not re-fire', () => { + const {cancel} = scheduleRefocus({current: {label: 'row'}}); + cancel(); + expect(mockCancelIdleCallback).toHaveBeenCalledTimes(1); + expect(mockCancelIdleCallback).toHaveBeenCalledWith(FAKE_IDLE_ID); + }); +}); diff --git a/tests/unit/stableKeyContractTest.tsx b/tests/unit/stableKeyContractTest.tsx new file mode 100644 index 000000000000..1bfdd0d026a5 --- /dev/null +++ b/tests/unit/stableKeyContractTest.tsx @@ -0,0 +1,92 @@ +import {render} from '@testing-library/react-native'; +import React from 'react'; +import {View} from 'react-native'; + +type Card = {cardID: number; cardTitle: string}; + +function CardRow({captureInstance, cardID, cardTitle}: {captureInstance: (id: number, instance: unknown) => void; cardID: number; cardTitle: string}) { + return ( + captureInstance(cardID, node)} + accessibilityLabel={cardTitle} + /> + ); +} + +function CardList({cards, keyFn, captureInstance}: {cards: Card[]; keyFn: (c: Card) => string | number; captureInstance: (id: number, instance: unknown) => void}) { + return ( + + {cards.map((c) => ( + + ))} + + ); +} + +describe('Stable React-key contract for focus-return rows', () => { + it('preserves the row ref identity across a value change when the key is a stable identifier', () => { + const refs = new Map(); + const capture = (id: number, instance: unknown) => { + if (instance === null) { + return; + } + refs.set(id, instance); + }; + const {rerender} = render( + c.cardID} + captureInstance={capture} + />, + ); + const before = refs.get(1); + + rerender( + c.cardID} + captureInstance={capture} + />, + ); + const after = refs.get(1); + + expect(before).toBeTruthy(); + expect(after).toBe(before); + }); + + it('remounts the row across a value change when the key embeds the value (the failure mode the contract prevents)', () => { + const seenInstances: unknown[] = []; + const capture = (_id: number, instance: unknown) => { + if (instance === null) { + return; + } + seenInstances.push(instance); + }; + const {rerender} = render( + `${c.cardTitle}_${c.cardID}`} + captureInstance={capture} + />, + ); + const initialCount = seenInstances.length; + expect(initialCount).toBeGreaterThanOrEqual(1); + + rerender( + `${c.cardTitle}_${c.cardID}`} + captureInstance={capture} + />, + ); + expect(seenInstances.length).toBeGreaterThan(initialCount); + const lastInstance = seenInstances.at(-1); + const firstInstance = seenInstances.at(0); + expect(lastInstance).not.toBe(firstInstance); + }); +}); diff --git a/tests/unit/useAccessibilityFocusTest.tsx b/tests/unit/useAccessibilityFocusTest.tsx new file mode 100644 index 000000000000..d97374b98892 --- /dev/null +++ b/tests/unit/useAccessibilityFocusTest.tsx @@ -0,0 +1,151 @@ +import {render} from '@testing-library/react-native'; +import React, {useRef} from 'react'; + +const mockLogWarn = jest.fn(); +jest.mock('@libs/Log', () => ({ + __esModule: true, + default: { + warn: (...args: unknown[]) => { + mockLogWarn(...args); + }, + info: jest.fn(), + alert: jest.fn(), + hmmm: jest.fn(), + client: jest.fn(), + }, +})); + +/* eslint-disable import/extensions */ +const {default: useAccessibilityFocus} = require<{ + default: (params: {didScreenTransitionEnd: boolean; isFocused: boolean; ref: React.RefObject; shouldMoveAccessibilityFocus?: boolean}) => void; +}>('../../src/hooks/useAccessibilityFocus/index.ts'); +const {resetCycle, tryClaim, Priorities, isCycleIdle} = require<{ + resetCycle: () => void; + tryClaim: (priority: 1 | 2 | 3) => boolean; + Priorities: {INITIAL: 1; AUTO: 2; RETURN: 3}; + isCycleIdle: () => boolean; +}>('../../src/libs/ScreenFocusArbiter.ts'); +/* eslint-enable import/extensions */ + +function makeContainer(): {container: HTMLElement; button: HTMLButtonElement} { + const container = document.createElement('div'); + const button = document.createElement('button'); + container.appendChild(button); + document.body.appendChild(container); + return {container, button}; +} + +function Harness({ + container, + isFocused, + didScreenTransitionEnd, + shouldMoveAccessibilityFocus = true, +}: { + container: HTMLElement | null; + isFocused: boolean; + didScreenTransitionEnd: boolean; + shouldMoveAccessibilityFocus?: boolean; +}) { + const ref = useRef(container); + useAccessibilityFocus({didScreenTransitionEnd, isFocused, ref, shouldMoveAccessibilityFocus}); + return null; +} + +beforeEach(() => { + document.body.innerHTML = ''; + resetCycle(); + mockLogWarn.mockClear(); +}); + +describe('useAccessibilityFocus — arbiter integration', () => { + it('claims AUTO before focusing so an in-flight RETURN can preempt, then releases so a same-tree sub-modal INITIAL is not blocked', () => { + const {container, button} = makeContainer(); + const spy = jest.spyOn(button, 'focus'); + render( + , + ); + expect(spy).toHaveBeenCalled(); + expect(tryClaim(Priorities.INITIAL)).toBe(true); + }); + + it('yields to an in-flight RETURN restore', () => { + const {container, button} = makeContainer(); + tryClaim(Priorities.RETURN); + const spy = jest.spyOn(button, 'focus'); + render( + , + ); + expect(spy).not.toHaveBeenCalled(); + }); + + it('releases the cycle when no target accepts focus, so a later claim is not held off', () => { + const container = document.createElement('div'); + document.body.appendChild(container); + render( + , + ); + expect(isCycleIdle()).toBe(true); + }); + + it('does nothing when shouldMoveAccessibilityFocus is false and does not claim the arbiter cycle', () => { + const {container, button} = makeContainer(); + const spy = jest.spyOn(button, 'focus'); + render( + , + ); + expect(spy).not.toHaveBeenCalled(); + expect(isCycleIdle()).toBe(true); + }); + + it('does nothing until didScreenTransitionEnd is true', () => { + const {container, button} = makeContainer(); + const spy = jest.spyOn(button, 'focus'); + render( + , + ); + expect(spy).not.toHaveBeenCalled(); + expect(isCycleIdle()).toBe(true); + }); + + it('releases the AUTO cycle and logs (rather than escalating) when focus() throws on a stale node', () => { + const {container, button} = makeContainer(); + jest.spyOn(button, 'focus').mockImplementation(() => { + throw new Error('detached element'); + }); + + expect(() => + render( + , + ), + ).not.toThrow(); + + expect(isCycleIdle()).toBe(true); + expect(mockLogWarn).toHaveBeenCalled(); + expect(button.hasAttribute('data-programmatic-focus')).toBe(false); + }); +}); diff --git a/tests/unit/useDialogContainerFocusTest.tsx b/tests/unit/useDialogContainerFocusTest.tsx new file mode 100644 index 000000000000..ffabb93b314b --- /dev/null +++ b/tests/unit/useDialogContainerFocusTest.tsx @@ -0,0 +1,78 @@ +import {renderHook} from '@testing-library/react-native'; +import {createRef} from 'react'; +import type {View} from 'react-native'; + +jest.mock('@libs/Navigation/TransitionTracker', () => ({ + __esModule: true, + default: { + runAfterTransitions: jest.fn(() => ({cancel: jest.fn()})), + }, +})); + +// Force the web variant — jest-expo's RN resolver prefers `index.native.ts` by default. +/* eslint-disable import/extensions */ +const useDialogContainerFocus = require<{ + default: (ref: {current: View | null}, isReady: boolean, gate?: () => boolean, skip?: boolean) => void; +}>('../../src/hooks/useDialogContainerFocus/index.ts').default; +/* eslint-enable import/extensions */ + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('useDialogContainerFocus — short-circuit order', () => { + it('does NOT invoke `claimInitialFocusGate` when `skipDialogContainerFocus` is true (gate is one-shot — bail path must not burn it)', () => { + const ref = createRef(); + const gate = jest.fn(() => true); + renderHook(() => useDialogContainerFocus(ref, true, gate, true)); + expect(gate).not.toHaveBeenCalled(); + }); + + it('invokes the gate when `skip: false` (baseline — gate is the load-bearing claim primitive)', () => { + const ref = createRef(); + const gate = jest.fn(() => true); + renderHook(() => useDialogContainerFocus(ref, true, gate, false)); + expect(gate).toHaveBeenCalledTimes(1); + }); + + it('preserves the gate across a skip→unskip cycle so a later `skip: false` render can still claim', () => { + const ref = createRef(); + const gate = jest.fn(() => true); + const {rerender} = renderHook(({skip}: {skip: boolean}) => useDialogContainerFocus(ref, true, gate, skip), {initialProps: {skip: true}}); + expect(gate).not.toHaveBeenCalled(); + + rerender({skip: false}); + expect(gate).toHaveBeenCalledTimes(1); + }); + + it('does not invoke the gate when `isReady` is false even with `skip: false`', () => { + const ref = createRef(); + const gate = jest.fn(() => true); + renderHook(() => useDialogContainerFocus(ref, false, gate, false)); + expect(gate).not.toHaveBeenCalled(); + }); + + it('does not schedule `runAfterTransitions` when the gate returns false (claim was already consumed by another path)', () => { + const ref = createRef(); + const gate = jest.fn(() => false); + const TransitionTracker = require<{default: {runAfterTransitions: jest.Mock}}>('../../src/libs/Navigation/TransitionTracker').default; + + renderHook(() => useDialogContainerFocus(ref, true, gate, false)); + expect(gate).toHaveBeenCalledTimes(1); + expect(TransitionTracker.runAfterTransitions).not.toHaveBeenCalled(); + }); + + it('cancels the scheduled `runAfterTransitions` on unmount so a destroyed ref does not receive late focus', () => { + const ref = createRef(); + const gate = jest.fn(() => true); + const cancel = jest.fn(); + const TransitionTracker = require<{default: {runAfterTransitions: jest.Mock}}>('../../src/libs/Navigation/TransitionTracker').default; + TransitionTracker.runAfterTransitions.mockImplementationOnce(() => ({cancel})); + + const {unmount} = renderHook(() => useDialogContainerFocus(ref, true, gate, false)); + expect(TransitionTracker.runAfterTransitions).toHaveBeenCalledTimes(1); + + unmount(); + expect(cancel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/useNavigateBackOnSaveTest.ts b/tests/unit/useNavigateBackOnSaveTest.ts new file mode 100644 index 000000000000..a079fe6c045a --- /dev/null +++ b/tests/unit/useNavigateBackOnSaveTest.ts @@ -0,0 +1,212 @@ +import {act, renderHook} from '@testing-library/react-native'; +import useNavigateBackOnSave from '@hooks/useNavigateBackOnSave'; +import Navigation from '@libs/Navigation/Navigation'; +import {skipNextFocusRestore} from '@libs/NavigationFocusReturn'; +import type {Route} from '@src/ROUTES'; + +jest.mock('@libs/Navigation/Navigation', () => ({ + __esModule: true, + default: {goBack: jest.fn()}, +})); + +jest.mock('@libs/NavigationFocusReturn', () => ({ + __esModule: true, + skipNextFocusRestore: jest.fn(), +})); + +const mockGoBack = jest.mocked(Navigation.goBack); +const mockSkip = jest.mocked(skipNextFocusRestore); + +const BACK_TO = 'settings/profile' as Route; + +function renderSave(initialIsSaved = false, backTo: Route | undefined = BACK_TO, shouldSkipFocusRestore = true) { + return renderHook(({isSaved}: {isSaved: boolean}) => useNavigateBackOnSave(isSaved, backTo, {shouldSkipFocusRestore}), {initialProps: {isSaved: initialIsSaved}}); +} + +beforeEach(() => { + mockGoBack.mockReset(); + mockSkip.mockReset(); +}); + +describe('useNavigateBackOnSave', () => { + it('navigates back once isSaved flips after arming (save path)', () => { + const {result, rerender} = renderSave(); + act(() => result.current.armNavigateBack()); + expect(mockGoBack).not.toHaveBeenCalled(); + + rerender({isSaved: true}); + expect(mockSkip).toHaveBeenCalledTimes(1); + expect(mockGoBack).toHaveBeenCalledTimes(1); + expect(mockGoBack).toHaveBeenCalledWith(BACK_TO); + }); + + it('skips focus restore BEFORE navigating, so the follow-up Enter hits the page shortcut and not the re-focused row (#90838)', () => { + const order: string[] = []; + mockSkip.mockImplementation(() => order.push('skip')); + mockGoBack.mockImplementation(() => order.push('goBack')); + + const {result, rerender} = renderSave(); + act(() => result.current.armNavigateBack()); + rerender({isSaved: true}); + + expect(order).toEqual(['skip', 'goBack']); + }); + + it('does NOT skip focus restore when shouldSkipFocusRestore is false (editing an existing expense), but still navigates back', () => { + const {result, rerender} = renderSave(false, BACK_TO, false); + act(() => result.current.armNavigateBack()); + rerender({isSaved: true}); + + expect(mockSkip).not.toHaveBeenCalled(); + expect(mockGoBack).toHaveBeenCalledTimes(1); + expect(mockGoBack).toHaveBeenCalledWith(BACK_TO); + }); + + it('does nothing when isSaved flips true without arming (an unrelated save elsewhere must not navigate)', () => { + const {rerender} = renderSave(); + rerender({isSaved: true}); + expect(mockSkip).not.toHaveBeenCalled(); + expect(mockGoBack).not.toHaveBeenCalled(); + }); + + it('waits for the save to land — arming while isSaved is still false defers nav until it flips', () => { + const {result, rerender} = renderSave(); + act(() => result.current.armNavigateBack()); + rerender({isSaved: false}); + expect(mockGoBack).not.toHaveBeenCalled(); + + rerender({isSaved: true}); + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + + it('navigateBack (Back button) goes back WITHOUT skipping focus restore', () => { + const {result} = renderSave(); + act(() => result.current.navigateBack()); + expect(mockGoBack).toHaveBeenCalledWith(BACK_TO); + expect(mockSkip).not.toHaveBeenCalled(); + }); + + it('is one-shot — a second isSaved cycle without re-arming does not navigate again', () => { + const {result, rerender} = renderSave(); + act(() => result.current.armNavigateBack()); + rerender({isSaved: true}); + expect(mockGoBack).toHaveBeenCalledTimes(1); + + rerender({isSaved: false}); + rerender({isSaved: true}); + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + + it('forwards the backTo target to goBack', () => { + const other = 'settings/wallet' as Route; + const {result} = renderSave(false, other); + act(() => result.current.navigateBack()); + expect(mockGoBack).toHaveBeenCalledWith(other); + }); + + it('navigates when `armNavigateBack` is called while `isSaved` is already true (draft-already-matches-stored: setIsSaved(true) is a no-op)', () => { + const {result} = renderSave(true); + act(() => result.current.armNavigateBack()); + expect(mockGoBack).toHaveBeenCalledTimes(1); + expect(mockGoBack).toHaveBeenCalledWith(BACK_TO); + }); + + it('a fresh save cycle (isSaved false → true) re-opens the gate so a long-lived consumer can navigate again after re-arming', () => { + const {result, rerender} = renderSave(); + act(() => result.current.armNavigateBack()); + rerender({isSaved: true}); + expect(mockGoBack).toHaveBeenCalledTimes(1); + + rerender({isSaved: false}); + act(() => result.current.armNavigateBack()); + rerender({isSaved: true}); + expect(mockGoBack).toHaveBeenCalledTimes(2); + }); + + it('double-tap on save while already saved navigates exactly once (defends the within-cycle invariant against rapid repeat arms)', () => { + const {result} = renderSave(true); + act(() => { + result.current.armNavigateBack(); + result.current.armNavigateBack(); + }); + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + + it('within a single save cycle, repeat arms after the first navigate are no-ops (no double-pop)', () => { + const {result, rerender} = renderSave(); + act(() => result.current.armNavigateBack()); + rerender({isSaved: true}); + expect(mockGoBack).toHaveBeenCalledTimes(1); + + // No `isSaved` cycle reset: a second arm should be ignored. + act(() => result.current.armNavigateBack()); + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + + it('an intra-cycle re-arm is cleared so it cannot auto-fire on the next isSaved cycle (effect clears stale `isArmed` on the hasNavigated bail)', () => { + const {result, rerender} = renderSave(); + act(() => result.current.armNavigateBack()); + rerender({isSaved: true}); + expect(mockGoBack).toHaveBeenCalledTimes(1); + + // Intra-cycle re-arm: the effect must clear the stale `isArmed` so it doesn't survive into the next cycle. + act(() => result.current.armNavigateBack()); + expect(mockGoBack).toHaveBeenCalledTimes(1); + + // Fresh `isSaved` false→true edge resets `hasNavigated`. Without the bail-path clear, the leftover `isArmed=true` would auto-fire here. + rerender({isSaved: false}); + rerender({isSaved: true}); + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + + it('snapshots `shouldSkipFocusRestore` at arm time — a later prop change cannot flip focus-restore behavior between arm and save', () => { + const {result, rerender} = renderHook( + ({isSaved, shouldSkipFocusRestore}: {isSaved: boolean; shouldSkipFocusRestore: boolean}) => useNavigateBackOnSave(isSaved, BACK_TO, {shouldSkipFocusRestore}), + { + initialProps: {isSaved: false as boolean, shouldSkipFocusRestore: true}, + }, + ); + + act(() => result.current.armNavigateBack()); + rerender({isSaved: false, shouldSkipFocusRestore: false}); + rerender({isSaved: true, shouldSkipFocusRestore: false}); + + // shouldSkipFocusRestore was true at arm time — snapshot must win over the later prop change. + expect(mockSkip).toHaveBeenCalledTimes(1); + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + + it('snapshots `shouldSkipFocusRestore=false` at arm time — a later true prop change cannot inject an unwanted skip', () => { + const {result, rerender} = renderHook( + ({isSaved, shouldSkipFocusRestore}: {isSaved: boolean; shouldSkipFocusRestore: boolean}) => useNavigateBackOnSave(isSaved, BACK_TO, {shouldSkipFocusRestore}), + { + initialProps: {isSaved: false as boolean, shouldSkipFocusRestore: false}, + }, + ); + + act(() => result.current.armNavigateBack()); + rerender({isSaved: false, shouldSkipFocusRestore: true}); + rerender({isSaved: true, shouldSkipFocusRestore: true}); + + // shouldSkipFocusRestore was false at arm time — snapshot must win over the later prop change; no skip fires. + expect(mockSkip).not.toHaveBeenCalled(); + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + + it('snapshots `backTo` at arm time — a later prop change cannot strand the user (e.g. parent clears route.params between arm and save)', () => { + const initial = 'settings/profile' as Route; + const later = 'settings/wallet' as Route; + + const {result, rerender} = renderHook(({isSaved, backTo}: {isSaved: boolean; backTo: Route | undefined}) => useNavigateBackOnSave(isSaved, backTo, {shouldSkipFocusRestore: false}), { + initialProps: {isSaved: false as boolean, backTo: initial as Route | undefined}, + }); + + act(() => result.current.armNavigateBack()); + // The parent clears/changes backTo before the save flips. + rerender({isSaved: false, backTo: later}); + rerender({isSaved: true, backTo: later}); + + expect(mockGoBack).toHaveBeenCalledTimes(1); + expect(mockGoBack).toHaveBeenCalledWith(initial); + }); +}); diff --git a/tests/unit/useReducedMotionTest.tsx b/tests/unit/useReducedMotionTest.tsx index f515f8b887f7..88a82c84ae45 100644 --- a/tests/unit/useReducedMotionTest.tsx +++ b/tests/unit/useReducedMotionTest.tsx @@ -1,6 +1,6 @@ import {act, renderHook} from '@testing-library/react-native'; import {AccessibilityInfo} from 'react-native'; -import Accessibility from '@libs/Accessibility'; +import Accessibility, {resetForTests} from '@libs/Accessibility'; describe('useReducedMotion', () => { let mockIsReduceMotionEnabled: jest.Mock; @@ -14,6 +14,7 @@ describe('useReducedMotion', () => { jest.spyOn(AccessibilityInfo, 'isReduceMotionEnabled').mockImplementation(mockIsReduceMotionEnabled); jest.spyOn(AccessibilityInfo, 'addEventListener').mockImplementation(mockAddEventListener); + resetForTests(); }); afterEach(() => { diff --git a/tests/unit/useScreenInitialFocusTest.tsx b/tests/unit/useScreenInitialFocusTest.tsx new file mode 100644 index 000000000000..b32eb3c6a4e5 --- /dev/null +++ b/tests/unit/useScreenInitialFocusTest.tsx @@ -0,0 +1,321 @@ +import {render} from '@testing-library/react-native'; +import React, {useMemo} from 'react'; +import ScreenWrapperStatusContext from '@components/ScreenWrapper/ScreenWrapperStatusContext'; + +let mockHasHoverSupport = true; +jest.mock('@libs/DeviceCapabilities/hasHoverSupport', () => ({ + __esModule: true, + default: () => mockHasHoverSupport, +})); + +let mockScreenReaderState: 'enabled' | 'disabled' | 'unknown' = 'enabled'; +jest.mock('@libs/Accessibility', () => ({ + __esModule: true, + default: { + getScreenReaderState: () => mockScreenReaderState, + isScreenReaderEnabledSync: () => mockScreenReaderState === 'enabled', + useScreenReaderStatus: () => mockScreenReaderState === 'enabled', + useReducedMotion: () => false, + moveAccessibilityFocus: jest.fn(), + }, +})); + +/* eslint-disable import/extensions */ +const {default: useScreenInitialFocus} = require<{ + default: (node: HTMLElement | null, options?: {shouldSkip?: boolean; shouldClaimOnlyForScreenReader?: boolean}) => void; +}>('../../src/hooks/useScreenInitialFocus/index.ts'); +const {resetCycle: resetArbiter, tryClaim: arbiterClaim, Priorities: arbiterPriorities} = require<{ + resetCycle: () => void; + tryClaim: (priority: 1 | 2 | 3) => boolean; + Priorities: {INITIAL: 1; AUTO: 2; RETURN: 3}; +}>('../../src/libs/ScreenFocusArbiter.ts'); +const {teardownHadTabNavigation, setupHadTabNavigation, resetForTests: resetHadTabNavigation} = require<{ + teardownHadTabNavigation: () => void; + setupHadTabNavigation: () => void; + resetForTests: () => void; +}>('../../src/libs/hadTabNavigation.ts'); +/* eslint-enable import/extensions */ + +setupHadTabNavigation(); + +function simulateTab() { + document.dispatchEvent(new KeyboardEvent('keydown', {key: 'Tab', bubbles: true})); +} +function simulatePointer() { + document.dispatchEvent(new Event('pointerdown', {bubbles: true})); +} + +type HarnessProps = {target: HTMLElement | null; didScreenTransitionEnd: boolean; shouldSkip?: boolean; shouldClaimOnlyForScreenReader?: boolean}; + +function MountedHarness({target, didScreenTransitionEnd, shouldSkip, shouldClaimOnlyForScreenReader}: HarnessProps) { + const contextValue = useMemo(() => ({didScreenTransitionEnd, isSafeAreaTopPaddingApplied: false, isSafeAreaBottomPaddingApplied: false}), [didScreenTransitionEnd]); + return ( + + + + ); +} +function Inner({target, shouldSkip, shouldClaimOnlyForScreenReader}: {target: HTMLElement | null; shouldSkip?: boolean; shouldClaimOnlyForScreenReader?: boolean}) { + const options = shouldSkip === undefined && shouldClaimOnlyForScreenReader === undefined ? undefined : {shouldSkip, shouldClaimOnlyForScreenReader}; + useScreenInitialFocus(target, options); + return null; +} + +function makeButton(): HTMLElement { + const b = document.createElement('button'); + document.body.appendChild(b); + jest.spyOn(b, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + top: 0, + left: 0, + bottom: 40, + right: 40, + width: 40, + height: 40, + toJSON: () => ({}), + } as DOMRect); + return b; +} + +beforeEach(() => { + document.body.innerHTML = ''; + resetArbiter(); + mockHasHoverSupport = true; + mockScreenReaderState = 'enabled'; + teardownHadTabNavigation(); + resetHadTabNavigation(); + setupHadTabNavigation(); +}); + +describe('useScreenInitialFocus', () => { + it('focuses the ref after didScreenTransitionEnd in keyboard modality (desktop Tab user)', () => { + simulateTab(); + const button = makeButton(); + const spy = jest.spyOn(button, 'focus'); + render( + , + ); + expect(spy).toHaveBeenCalledWith({preventScroll: true, focusVisible: true}); + // Keyboard users must see the ring (WCAG 2.4.7), so it is not suppressed. + expect(button.getAttribute('data-programmatic-focus')).toBeNull(); + }); + + it('focuses the ref on touch-primary devices (no hover) with focusVisible:false, so :focus-visible never matches and no ring shows (WCAG 2.4.7)', () => { + mockHasHoverSupport = false; + simulatePointer(); + const button = makeButton(); + const spy = jest.spyOn(button, 'focus'); + render( + , + ); + expect(spy).toHaveBeenCalledWith({preventScroll: true, focusVisible: false}); + expect(button.getAttribute('data-programmatic-focus')).toBeNull(); + }); + + it('releases the arbiter cycle when focus silently fails to land (touch), so a later claim is not blocked', () => { + mockHasHoverSupport = false; + simulatePointer(); + const button = makeButton(); + // focus() no-ops (e.g. inert / visibility:hidden ancestor), so activeElement never becomes the button. + jest.spyOn(button, 'focus').mockImplementation(() => {}); + render( + , + ); + expect(arbiterClaim(arbiterPriorities.INITIAL)).toBe(true); + }); + + it('does NOT focus on desktop mouse modality (hasHoverSupport && !hadTab) — WCAG 2.4.7', () => { + simulatePointer(); + const button = makeButton(); + const spy = jest.spyOn(button, 'focus'); + render( + , + ); + expect(spy).not.toHaveBeenCalled(); + }); + + it('does NOT focus until didScreenTransitionEnd is true', () => { + simulateTab(); + const button = makeButton(); + const spy = jest.spyOn(button, 'focus'); + render( + , + ); + expect(spy).not.toHaveBeenCalled(); + }); + + it('does NOT focus when another element already has focus', () => { + simulateTab(); + const input = document.createElement('input'); + document.body.appendChild(input); + input.focus(); + const button = makeButton(); + const spy = jest.spyOn(button, 'focus'); + render( + , + ); + expect(spy).not.toHaveBeenCalled(); + }); + + it('does NOT focus an off-screen ref (Growl-style transformed Pressable)', () => { + simulateTab(); + const offscreen = document.createElement('div'); + offscreen.setAttribute('tabindex', '0'); + document.body.appendChild(offscreen); + jest.spyOn(offscreen, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: -255, + top: -255, + left: 0, + bottom: -195, + right: 350, + width: 350, + height: 60, + toJSON: () => ({}), + } as DOMRect); + const spy = jest.spyOn(offscreen, 'focus'); + render( + , + ); + expect(spy).not.toHaveBeenCalled(); + }); + + it('defers to a higher-priority arbiter claim (RETURN restore wins over screen-mount INITIAL)', () => { + simulateTab(); + arbiterClaim(arbiterPriorities.RETURN); + const button = makeButton(); + const spy = jest.spyOn(button, 'focus'); + render( + , + ); + expect(spy).not.toHaveBeenCalled(); + }); + + it('claims focus when the target attaches after transition end (skeleton → real header, Suspense, conditional render)', () => { + simulateTab(); + const button = makeButton(); + const spy = jest.spyOn(button, 'focus'); + const {rerender} = render( + , + ); + expect(spy).not.toHaveBeenCalled(); + + rerender( + , + ); + expect(spy).toHaveBeenCalledWith({preventScroll: true, focusVisible: true}); + }); + + it('is one-shot per mount — re-renders with the same ref do not re-claim', () => { + simulateTab(); + const button = makeButton(); + const spy = jest.spyOn(button, 'focus'); + const {rerender} = render( + , + ); + expect(spy).toHaveBeenCalledTimes(1); + rerender( + , + ); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('bails when shouldSkip=true so screens that opt out of post-transition focus', () => { + simulateTab(); + const button = makeButton(); + const spy = jest.spyOn(button, 'focus'); + render( + , + ); + expect(spy).not.toHaveBeenCalled(); + }); + + it('shouldClaimOnlyForScreenReader=true + SR known-off → bails (keyboard user does not see a ring flash before the screen auto-focuses its own target)', () => { + mockScreenReaderState = 'disabled'; + simulateTab(); + const button = makeButton(); + const spy = jest.spyOn(button, 'focus'); + render( + , + ); + expect(spy).not.toHaveBeenCalled(); + }); + + it('shouldClaimOnlyForScreenReader=true + SR on → claims (TalkBack/VoiceOver needs back-button orientation while the composer is delayed)', () => { + mockScreenReaderState = 'enabled'; + simulateTab(); + const button = makeButton(); + const spy = jest.spyOn(button, 'focus'); + render( + , + ); + expect(spy).toHaveBeenCalledWith({preventScroll: true, focusVisible: true}); + }); + + it('shouldClaimOnlyForScreenReader=false (default) preserves the unconditional claim path so non-chat headers still focus for keyboard users', () => { + mockScreenReaderState = 'disabled'; + simulateTab(); + const button = makeButton(); + const spy = jest.spyOn(button, 'focus'); + render( + , + ); + expect(spy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/utils/mockReactNavigationNative.ts b/tests/utils/mockReactNavigationNative.ts new file mode 100644 index 000000000000..11dee83a0adb --- /dev/null +++ b/tests/utils/mockReactNavigationNative.ts @@ -0,0 +1,14 @@ +/** + * Spreads the real `@react-navigation/native` into a `jest.mock` factory so `NavigationRouteContext` survives — + * without it, any component calling `useContext(NavigationRouteContext)` (e.g. `GenericPressable`) crashes with + * `Cannot read properties of undefined (reading '$$typeof')`. Import under a `mock`-prefixed name (jest hoisting): + * jest.mock('@react-navigation/native', () => ({...mockReactNavigationNative(), useIsFocused: () => true})); + */ +function mockReactNavigationNative(overrides: Record = {}): Record { + return { + ...jest.requireActual>('@react-navigation/native'), + ...overrides, + }; +} + +export default mockReactNavigationNative;