From 6c7e04d348025a93a088e27af86327b9a0dab266 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Tue, 26 May 2026 22:11:34 +0300 Subject: [PATCH 01/89] fix: Screen Reader: Global: Focus is lost when returning to the previous screen --- .../implementation/BaseGenericPressable.tsx | 10 +- src/libs/Accessibility/index.ts | 12 + src/libs/NavigationFocusReturn.native.ts | 218 ++++++++- src/libs/NavigationFocusReturn.ts | 57 +-- src/libs/navigationStateDiff.ts | 54 +++ tests/unit/NavigationFocusReturnNativeTest.ts | 415 ++++++++++++++++++ 6 files changed, 708 insertions(+), 58 deletions(-) create mode 100644 src/libs/navigationStateDiff.ts create mode 100644 tests/unit/NavigationFocusReturnNativeTest.ts diff --git a/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx b/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx index e6b6e90d184a..ebfa810d4eff 100644 --- a/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx +++ b/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx @@ -1,5 +1,4 @@ -import type {ForwardedRef} from 'react'; -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useCallback, useMemo, useRef, useState} from 'react'; import type {GestureResponderEvent, View} from 'react-native'; // eslint-disable-next-line no-restricted-imports import {Pressable} from 'react-native'; @@ -11,6 +10,8 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import Accessibility from '@libs/Accessibility'; import HapticFeedback from '@libs/HapticFeedback'; +import mergeRefs from '@libs/mergeRefs'; +import {notifyPressedTrigger} from '@libs/NavigationFocusReturn'; import CONST from '@src/CONST'; function GenericPressable({ @@ -50,6 +51,8 @@ function GenericPressable({ const [hitSlop, onLayout] = Accessibility.useAutoHitSlop(); const [isHovered, setIsHovered] = useState(false); const isRoleButton = [rest.accessibilityRole, rest.role].includes(CONST.ROLE.BUTTON); + const internalRef = useRef(null); + const composedRef = useMemo(() => mergeRefs(ref, internalRef), [ref]); const isDisabled = useMemo(() => { let shouldBeDisabledByScreenReader = false; @@ -123,6 +126,7 @@ function GenericPressable({ ref.current?.blur(); Accessibility.moveAccessibilityFocus(nextFocusRef); } + notifyPressedTrigger(internalRef.current); return onPress(event); }, [shouldUseHapticsOnPress, onPress, nextFocusRef, ref, isDisabled, interactive], @@ -176,7 +180,7 @@ function GenericPressable({ } + ref={composedRef} disabled={fullDisabled || undefined} onPress={!isDisabled ? singleExecution(onPressHandler) : undefined} onLongPress={!isDisabled && onLongPress ? onLongPressHandler : undefined} diff --git a/src/libs/Accessibility/index.ts b/src/libs/Accessibility/index.ts index 37c6cd527e99..8f8272fd7825 100644 --- a/src/libs/Accessibility/index.ts +++ b/src/libs/Accessibility/index.ts @@ -8,6 +8,13 @@ type HitSlop = {x: number; y: number}; let cachedScreenReaderValue = false; +// Warm the cache at module load so the sync read is meaningful before any hook subscribes. +isScreenReaderEnabled() + .then((enabled) => { + cachedScreenReaderValue = enabled; + }) + .catch(() => {}); + function subscribeScreenReader(callback: () => void) { const subscription = AccessibilityInfo.addEventListener('screenReaderChanged', (enabled) => { cachedScreenReaderValue = enabled; @@ -30,6 +37,10 @@ function getScreenReaderSnapshot() { const useScreenReaderStatus = (): boolean => useSyncExternalStore(subscribeScreenReader, getScreenReaderSnapshot, () => false); +function isScreenReaderEnabledSync(): boolean { + return cachedScreenReaderValue; +} + let cachedReduceMotionValue = false; function subscribeReduceMotion(callback: () => void) { @@ -87,4 +98,5 @@ export default { useScreenReaderStatus, useAutoHitSlop, useReducedMotion, + isScreenReaderEnabledSync, }; diff --git a/src/libs/NavigationFocusReturn.native.ts b/src/libs/NavigationFocusReturn.native.ts index e7055e3e2635..2fac4bb85d98 100644 --- a/src/libs/NavigationFocusReturn.native.ts +++ b/src/libs/NavigationFocusReturn.native.ts @@ -1,28 +1,234 @@ -/** Native stub — focus return is web-only. No-ops so native doesn't bundle the web orchestrator. */ +import type {NavigationState} from '@react-navigation/native'; +import type {RefObject} from 'react'; +import {AccessibilityInfo} from 'react-native'; +import type {View} from 'react-native'; +// eslint-disable-next-line no-restricted-imports -- .native.ts only; the rule guards web bundles from pulling the native stub. +import findNodeHandle from '@src/utils/findNodeHandle'; +import Accessibility from './Accessibility'; +import navigationRef from './Navigation/navigationRef'; +// eslint-disable-next-line no-restricted-imports -- focus-return is a sibling primitive to TransitionTracker; the exact transitionEnd signal is what we need to avoid focus-restore races with the OS. +import TransitionTracker from './Navigation/TransitionTracker'; +import {diffNavigationState} from './navigationStateDiff'; + +/** Press-driven capture of the triggering View on forward nav; restore via `AccessibilityInfo.sendAccessibilityEvent` on backward. */ + +type TriggerEntry = {ref: RefObject}; + +const TRIGGER_MAP_MAX = 64; +// The first focus event can lose to the OS's own window-change auto-focus; a delayed second call lands after the OS settles. +const RESTORE_RETRY_MS = 300; + +let lastPressedTrigger: View | null = null; +const triggerMap = new Map(); +let prevState: NavigationState | undefined; +let pendingRestore: {cancel: () => void} | null = null; +let skipNextRestore = false; +let stateUnsubscribe: (() => void) | null = null; + +// Delete-then-set so a re-set moves the key to the tail and FIFO eviction drops the truly oldest. +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); + } +} + +function notifyPressedTrigger(node: View | null): void { + if (!Accessibility.isScreenReaderEnabledSync()) { + return; + } + lastPressedTrigger = node; +} + +function captureTriggerForRoute(routeKey: string): void { + if (!Accessibility.isScreenReaderEnabledSync()) { + return; + } + if (!lastPressedTrigger) { + return; + } + const ref: RefObject = {current: lastPressedTrigger}; + setTriggerEntry(routeKey, {ref}); +} + +function fireFocusEvent(view: View): boolean { + // Truthy ref can still point to a detached View; findNodeHandle returns null then. + if (findNodeHandle(view) == null) { + return false; + } + AccessibilityInfo.sendAccessibilityEvent(view, 'focus'); + return true; +} + +function restoreTriggerForRoute(routeKey: string): View | null { + const entry = triggerMap.get(routeKey); + if (!entry) { + return null; + } + const view = entry.ref.current; + if (!view) { + return null; + } + return fireFocusEvent(view) ? view : null; +} + +function cancelPendingRestore(): void { + pendingRestore?.cancel(); + pendingRestore = null; +} + +function scheduleRestore(routeKey: string): void { + cancelPendingRestore(); + let cancelled = false; + let retryTimerId: ReturnType | undefined; + const handle = TransitionTracker.runAfterTransitions({ + callback: () => { + if (cancelled) { + return; + } + const view = restoreTriggerForRoute(routeKey); + if (!view) { + pendingRestore = null; + return; + } + retryTimerId = setTimeout(() => { + if (cancelled) { + return; + } + fireFocusEvent(view); + triggerMap.delete(routeKey); + pendingRestore = null; + }, RESTORE_RETRY_MS); + }, + }); + + pendingRestore = { + cancel: () => { + cancelled = true; + handle.cancel(); + if (retryTimerId !== undefined) { + clearTimeout(retryTimerId); + } + }, + }; +} + +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); + lastPressedTrigger = null; + } else if (action.type === 'backward') { + if (skipNextRestore) { + skipNextRestore = false; + cancelPendingRestore(); + } else { + scheduleRestore(action.restoreKey); + } + } else if (action.type === 'lateral') { + skipNextRestore = false; + cancelPendingRestore(); + } + + for (const key of removedKeys) { + triggerMap.delete(key); + } + 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(); + lastPressedTrigger = null; + skipNextRestore = false; + stateUnsubscribe?.(); + stateUnsubscribe = null; +} + +/** Skip the next backward restore; call before a form-submit goBack. */ +function skipNextFocusRestore(): void { + skipNextRestore = true; +} -function setupNavigationFocusReturn(): void {} -function teardownNavigationFocusReturn(): void {} /* eslint-disable @typescript-eslint/no-unused-vars */ +// Web-only stubs (PUSH_PARAMS, list restore-in-progress flag, AUTO-skip guard). 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 cancelPendingFocusRestore(): void { + cancelPendingRestore(); +} + function isFocusRestoreInProgress(): boolean { return false; } -// Web-only guard; native has no DOM activeElement, so AUTO never needs to skip. + function shouldSkipAutoFocusDueToExistingFocus(): boolean { return false; } +function resetForTests(): void { + cancelPendingRestore(); + prevState = undefined; + triggerMap.clear(); + lastPressedTrigger = null; + skipNextRestore = false; + stateUnsubscribe?.(); + stateUnsubscribe = null; +} + +function setLastPressedTriggerForTests(node: View | null): void { + lastPressedTrigger = node; +} + +function getTriggerMapSizeForTests(): number { + return triggerMap.size; +} + export { setupNavigationFocusReturn, teardownNavigationFocusReturn, + handleStateChange, + notifyPressedTrigger, notifyPushParamsForward, notifyPushParamsBackward, cancelPendingFocusRestore, skipNextFocusRestore, isFocusRestoreInProgress, shouldSkipAutoFocusDueToExistingFocus, + resetForTests, + setLastPressedTriggerForTests, + getTriggerMapSizeForTests, }; diff --git a/src/libs/NavigationFocusReturn.ts b/src/libs/NavigationFocusReturn.ts index 9c461fac87be..b5592bf3f21c 100644 --- a/src/libs/NavigationFocusReturn.ts +++ b/src/libs/NavigationFocusReturn.ts @@ -1,21 +1,18 @@ -import {findFocusedRoute} from '@react-navigation/core'; -import type {NavigationState, PartialState} from '@react-navigation/native'; +import type {NavigationState} from '@react-navigation/native'; // eslint-disable-next-line no-restricted-imports -- idiomatic defer primitive past navigation transitions. import {InteractionManager} from 'react-native'; +import type {View} 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 {collectRouteKeys, diffNavigationState} from './navigationStateDiff'; import {isCycleIdle, Priorities, resetCycle, tryClaim} from './ScreenFocusArbiter'; /** 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}; @@ -54,49 +51,6 @@ 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; @@ -145,6 +99,10 @@ 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(_node: View | null): void {} + /** 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; @@ -523,6 +481,7 @@ export { notifyPushParamsBackward, cancelPendingFocusRestore, skipNextFocusRestore, + notifyPressedTrigger, isFocusRestoreInProgress, compoundParamsKey, shouldSkipAutoFocusDueToExistingFocus, diff --git a/src/libs/navigationStateDiff.ts b/src/libs/navigationStateDiff.ts new file mode 100644 index 000000000000..cca640f6e51b --- /dev/null +++ b/src/libs/navigationStateDiff.ts @@ -0,0 +1,54 @@ +import {findFocusedRoute} from '@react-navigation/core'; +import type {NavigationState, PartialState} from '@react-navigation/native'; + +/** Classifies a navigation state transition as forward / backward / lateral / noop and reports removed route keys. */ + +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 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}; +} + +export {collectRouteKeys, diffNavigationState}; +export type {AnyState, DiffAction}; diff --git a/tests/unit/NavigationFocusReturnNativeTest.ts b/tests/unit/NavigationFocusReturnNativeTest.ts new file mode 100644 index 000000000000..c2361ee77c44 --- /dev/null +++ b/tests/unit/NavigationFocusReturnNativeTest.ts @@ -0,0 +1,415 @@ +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 mockSendAccessibilityEvent = jest.fn(); +let mockScreenReaderEnabled = true; + +jest.mock('../../src/libs/Accessibility', () => ({ + __esModule: true, + default: { + moveAccessibilityFocus: jest.fn(), + isScreenReaderEnabledSync: () => mockScreenReaderEnabled, + useScreenReaderStatus: () => mockScreenReaderEnabled, + useReducedMotion: () => false, + }, +})); + +AccessibilityInfo.sendAccessibilityEvent = mockSendAccessibilityEvent; + +// jsdom resolves the web stub (returns null). Treat fake views as attached unless they carry `detached: true`. +jest.mock('@src/utils/findNodeHandle', () => ({ + __esModule: true, + default: (view: unknown): number | null => { + if (view == null) { + return null; + } + if (typeof view === 'object' && (view as {detached?: boolean}).detached) { + return null; + } + return 1; + }, +})); + +type TtEntry = {cb: () => void; cancelled: boolean}; +let mockTtQueue: TtEntry[] = []; +jest.mock('../../src/libs/Navigation/TransitionTracker', () => ({ + __esModule: true, + default: { + startTransition: jest.fn(), + endTransition: jest.fn(), + runAfterTransitions: ({callback}: {callback: () => void}) => { + const entry: TtEntry = {cb: callback, cancelled: false}; + 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, + cancelPendingFocusRestore, + skipNextFocusRestore, + isFocusRestoreInProgress, + shouldSkipAutoFocusDueToExistingFocus, + resetForTests, + setLastPressedTriggerForTests, + getTriggerMapSizeForTests, +} = require<{ + setupNavigationFocusReturn: () => void; + teardownNavigationFocusReturn: () => void; + handleStateChange: (state: unknown) => void; + notifyPressedTrigger: (node: unknown) => void; + cancelPendingFocusRestore: () => void; + skipNextFocusRestore: () => void; + isFocusRestoreInProgress: () => boolean; + shouldSkipAutoFocusDueToExistingFocus: () => boolean; + resetForTests: () => void; + setLastPressedTriggerForTests: (node: unknown) => void; + getTriggerMapSizeForTests: () => number; +}>('../../src/libs/NavigationFocusReturn.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}; +} + +beforeEach(() => { + jest.useFakeTimers(); + mockSendAccessibilityEvent.mockClear(); + mockScreenReaderEnabled = true; + mockStateListeners = []; + mockNavigationRefState = undefined; + mockTtQueue = []; + resetForTests(); +}); + +afterEach(() => { + jest.useRealTimers(); +}); + +describe('notifyPressedTrigger', () => { + it('is a no-op when the screen reader is off — non-AT users pay zero capture cost', () => { + mockScreenReaderEnabled = false; + notifyPressedTrigger(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('stores the most recently pressed view when the screen reader is on', () => { + const view = fakeView('button-1'); + notifyPressedTrigger(view); + 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(fakeView('button-1')); + notifyPressedTrigger(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); + }); +}); + +describe('handleStateChange — forward', () => { + it('captures the staged trigger against the outgoing route key', () => { + const view = fakeView('display-name'); + setLastPressedTriggerForTests(view); + 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'); + setLastPressedTriggerForTests(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(); + expect(mockSendAccessibilityEvent).toHaveBeenCalledTimes(1); + expect(mockSendAccessibilityEvent).toHaveBeenNthCalledWith(1, view, 'focus'); + + jest.runAllTimers(); + expect(mockSendAccessibilityEvent).toHaveBeenCalledTimes(2); + expect(mockSendAccessibilityEvent).toHaveBeenNthCalledWith(2, view, 'focus'); + }); + + it('does NOT restore when skipNextFocusRestore was called before goBack (form-submit path)', () => { + const view = fakeView('display-name'); + setLastPressedTriggerForTests(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); + skipNextFocusRestore(); + handleStateChange(back); + flushTransitions(); + + expect(mockSendAccessibilityEvent).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(mockSendAccessibilityEvent).not.toHaveBeenCalled(); + }); + + it('does NOT call sendAccessibilityEvent when the captured view has been detached (parent screen replaced)', () => { + const view = {label: 'display-name', detached: true}; + setLastPressedTriggerForTests(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(); + + expect(mockSendAccessibilityEvent).not.toHaveBeenCalled(); + }); + + it('cleans the trigger entry from the map after a successful restore', () => { + setLastPressedTriggerForTests(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(); + jest.runAllTimers(); + expect(getTriggerMapSizeForTests()).toBe(0); + }); +}); + +describe('handleStateChange — lateral & cleanup', () => { + it('cancels a pending restore on a subsequent lateral tab switch', () => { + setLastPressedTriggerForTests(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(mockSendAccessibilityEvent).not.toHaveBeenCalled(); + }); + + it('drops trigger entries for routes removed from the stack', () => { + setLastPressedTriggerForTests(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', () => { + setLastPressedTriggerForTests(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(mockSendAccessibilityEvent).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', () => { + setLastPressedTriggerForTests(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('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); + }); +}); From 2d66e4e063ef492246814bbbdf8331fe9a6f4ea2 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Wed, 27 May 2026 09:29:48 +0300 Subject: [PATCH 02/89] fix: cap stale press triggers; log Accessibility cache-warm failures; dedup native teardown --- src/libs/Accessibility/index.ts | 37 +++++++++++-------- src/libs/NavigationFocusReturn.native.ts | 16 ++++---- tests/unit/NavigationFocusReturnNativeTest.ts | 15 ++++++++ 3 files changed, 45 insertions(+), 23 deletions(-) diff --git a/src/libs/Accessibility/index.ts b/src/libs/Accessibility/index.ts index 8f8272fd7825..940863010502 100644 --- a/src/libs/Accessibility/index.ts +++ b/src/libs/Accessibility/index.ts @@ -1,19 +1,26 @@ import {useCallback, useState, useSyncExternalStore} from 'react'; import type {LayoutChangeEvent} from 'react-native'; import {AccessibilityInfo} from 'react-native'; +import Log from '@libs/Log'; import isScreenReaderEnabled from './isScreenReaderEnabled'; import moveAccessibilityFocus from './moveAccessibilityFocus'; type HitSlop = {x: number; y: number}; +function warmCache(label: string, fetch: () => Promise, apply: (value: T) => void): void { + fetch() + .then(apply) + .catch((error: unknown) => { + Log.warn(`[Accessibility] Failed to warm ${label} cache`, {error}); + }); +} + let cachedScreenReaderValue = false; // Warm the cache at module load so the sync read is meaningful before any hook subscribes. -isScreenReaderEnabled() - .then((enabled) => { - cachedScreenReaderValue = enabled; - }) - .catch(() => {}); +warmCache('screen-reader', isScreenReaderEnabled, (enabled) => { + cachedScreenReaderValue = enabled; +}); function subscribeScreenReader(callback: () => void) { const subscription = AccessibilityInfo.addEventListener('screenReaderChanged', (enabled) => { @@ -21,12 +28,10 @@ function subscribeScreenReader(callback: () => void) { callback(); }); - isScreenReaderEnabled() - .then((enabled) => { - cachedScreenReaderValue = enabled; - callback(); - }) - .catch(() => {}); + warmCache('screen-reader', isScreenReaderEnabled, (enabled) => { + cachedScreenReaderValue = enabled; + callback(); + }); return () => subscription?.remove(); } @@ -49,12 +54,14 @@ function subscribeReduceMotion(callback: () => void) { callback(); }); - AccessibilityInfo.isReduceMotionEnabled() - .then((enabled) => { + warmCache( + 'reduce-motion', + () => AccessibilityInfo.isReduceMotionEnabled(), + (enabled) => { cachedReduceMotionValue = enabled; callback(); - }) - .catch(() => {}); + }, + ); return () => subscription?.remove(); } diff --git a/src/libs/NavigationFocusReturn.native.ts b/src/libs/NavigationFocusReturn.native.ts index 2fac4bb85d98..b6e222a3d54a 100644 --- a/src/libs/NavigationFocusReturn.native.ts +++ b/src/libs/NavigationFocusReturn.native.ts @@ -17,8 +17,11 @@ type TriggerEntry = {ref: RefObject}; const TRIGGER_MAP_MAX = 64; // The first focus event can lose to the OS's own window-change auto-focus; a delayed second call lands after the OS settles. const RESTORE_RETRY_MS = 300; +// A press long before a delayed nav (timer / deeplink / async redirect) shouldn't be captured as that nav's trigger. +const PRESS_TRIGGER_TTL_MS = 3_000; let lastPressedTrigger: View | null = null; +let lastPressedTriggerAt = 0; const triggerMap = new Map(); let prevState: NavigationState | undefined; let pendingRestore: {cancel: () => void} | null = null; @@ -43,13 +46,14 @@ function notifyPressedTrigger(node: View | null): void { return; } lastPressedTrigger = node; + lastPressedTriggerAt = node ? Date.now() : 0; } function captureTriggerForRoute(routeKey: string): void { if (!Accessibility.isScreenReaderEnabledSync()) { return; } - if (!lastPressedTrigger) { + if (!lastPressedTrigger || Date.now() - lastPressedTriggerAt > PRESS_TRIGGER_TTL_MS) { return; } const ref: RefObject = {current: lastPressedTrigger}; @@ -171,6 +175,7 @@ function teardownNavigationFocusReturn(): void { prevState = undefined; triggerMap.clear(); lastPressedTrigger = null; + lastPressedTriggerAt = 0; skipNextRestore = false; stateUnsubscribe?.(); stateUnsubscribe = null; @@ -200,17 +205,12 @@ function shouldSkipAutoFocusDueToExistingFocus(): boolean { } function resetForTests(): void { - cancelPendingRestore(); - prevState = undefined; - triggerMap.clear(); - lastPressedTrigger = null; - skipNextRestore = false; - stateUnsubscribe?.(); - stateUnsubscribe = null; + teardownNavigationFocusReturn(); } function setLastPressedTriggerForTests(node: View | null): void { lastPressedTrigger = node; + lastPressedTriggerAt = node ? Date.now() : 0; } function getTriggerMapSizeForTests(): number { diff --git a/tests/unit/NavigationFocusReturnNativeTest.ts b/tests/unit/NavigationFocusReturnNativeTest.ts index c2361ee77c44..5995600a3598 100644 --- a/tests/unit/NavigationFocusReturnNativeTest.ts +++ b/tests/unit/NavigationFocusReturnNativeTest.ts @@ -188,6 +188,21 @@ describe('notifyPressedTrigger', () => { 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 before = Date.now(); + jest.setSystemTime(before); + notifyPressedTrigger(fakeView('non-nav-toggle')); + jest.setSystemTime(before + 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); + }); }); describe('handleStateChange — forward', () => { From 6562712e32fae6f17c27f7146cb22e88f0838de5 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Wed, 27 May 2026 11:36:40 +0300 Subject: [PATCH 03/89] fix: Android forward auto-focus for mid-session TalkBack; consolidate SR listener --- src/components/HeaderWithBackButton/index.tsx | 14 +++++++- .../fireFocusEvent/index.android.ts | 11 ++++++ .../Accessibility/fireFocusEvent/index.ios.ts | 9 +++++ .../Accessibility/fireFocusEvent/index.ts | 8 +++++ .../forwardAutoFocus/index.android.ts | 31 ++++++++++++++++ .../Accessibility/forwardAutoFocus/index.ts | 10 ++++++ src/libs/Accessibility/index.ts | 35 ++++++++++--------- src/libs/NavigationFocusReturn.native.ts | 14 ++++---- src/libs/NavigationFocusReturn.ts | 8 ++++- tests/unit/NavigationFocusReturnNativeTest.ts | 27 +++++++++----- tests/unit/fireFocusEventAndroidTest.ts | 20 +++++++++++ 11 files changed, 154 insertions(+), 33 deletions(-) create mode 100644 src/libs/Accessibility/fireFocusEvent/index.android.ts create mode 100644 src/libs/Accessibility/fireFocusEvent/index.ios.ts create mode 100644 src/libs/Accessibility/fireFocusEvent/index.ts create mode 100644 src/libs/Accessibility/forwardAutoFocus/index.android.ts create mode 100644 src/libs/Accessibility/forwardAutoFocus/index.ts create mode 100644 tests/unit/fireFocusEventAndroidTest.ts diff --git a/src/components/HeaderWithBackButton/index.tsx b/src/components/HeaderWithBackButton/index.tsx index 18deb1a7e68e..e289c2115378 100755 --- a/src/components/HeaderWithBackButton/index.tsx +++ b/src/components/HeaderWithBackButton/index.tsx @@ -1,5 +1,7 @@ -import React, {useMemo} from 'react'; +import React, {useCallback, useMemo} from 'react'; import {Keyboard, StyleSheet, View} from 'react-native'; +// eslint-disable-next-line no-restricted-imports -- type-only; needed to match PressableRef's cross-platform union for the back-button callback ref. +import type {Text as RNText, View as RNView} from 'react-native'; import type {SvgProps} from 'react-native-svg'; import ActivityIndicator from '@components/ActivityIndicator'; import Avatar from '@components/Avatar'; @@ -22,6 +24,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useThrottledButtonState from '@hooks/useThrottledButtonState'; import getButtonState from '@libs/getButtonState'; import Navigation from '@libs/Navigation/Navigation'; +import {notifyBackButtonMounted} from '@libs/NavigationFocusReturn'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -90,6 +93,14 @@ function HeaderWithBackButton({ const [isDownloadButtonActive, temporarilyDisableDownloadButton] = useThrottledButtonState(); const {translate} = useLocalize(); const isInLandscapeMode = useIsInLandscapeMode(); + // Param signature mirrors PressableRef's cross-platform union; the guard rejects HTMLDivElement so only a native host instance reaches notifyBackButtonMounted. + const backButtonRefCallback = useCallback((node: RNView | HTMLDivElement | RNText | null | undefined) => { + if (!node || !('measure' in node) || 'innerHTML' in node) { + notifyBackButtonMounted(null); + return; + } + notifyBackButtonMounted(node); + }, []); const downloadReasonAttributes = useMemo( () => ({ @@ -244,6 +255,7 @@ function HeaderWithBackButton({ {shouldShowBackButton && ( { if (Keyboard.isVisible()) { Keyboard.dismiss(); diff --git a/src/libs/Accessibility/fireFocusEvent/index.android.ts b/src/libs/Accessibility/fireFocusEvent/index.android.ts new file mode 100644 index 000000000000..cddacdcdec29 --- /dev/null +++ b/src/libs/Accessibility/fireFocusEvent/index.android.ts @@ -0,0 +1,11 @@ +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'; + +function fireFocusEvent(view: View | RNText): void { + AccessibilityInfo.sendAccessibilityEvent(view, 'focus'); + // 'viewHoverEnter' (TYPE_VIEW_HOVER_ENTER) is the explore-by-touch signal TalkBack always honours; pure 'focus' (TYPE_VIEW_FOCUSED) is conditional and doesn't move accessibility focus reliably. + AccessibilityInfo.sendAccessibilityEvent(view, 'viewHoverEnter'); +} + +export default fireFocusEvent; diff --git a/src/libs/Accessibility/fireFocusEvent/index.ios.ts b/src/libs/Accessibility/fireFocusEvent/index.ios.ts new file mode 100644 index 000000000000..5e4bba351ec2 --- /dev/null +++ b/src/libs/Accessibility/fireFocusEvent/index.ios.ts @@ -0,0 +1,9 @@ +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'; + +function fireFocusEvent(view: View | RNText): void { + AccessibilityInfo.sendAccessibilityEvent(view, 'focus'); +} + +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..7549239993a7 --- /dev/null +++ b/src/libs/Accessibility/fireFocusEvent/index.ts @@ -0,0 +1,8 @@ +// Web: focus is moved by `element.focus()` directly elsewhere; this helper is a no-op so cross-platform consumers can call it unconditionally. +// 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/forwardAutoFocus/index.android.ts b/src/libs/Accessibility/forwardAutoFocus/index.android.ts new file mode 100644 index 000000000000..69a36149e212 --- /dev/null +++ b/src/libs/Accessibility/forwardAutoFocus/index.android.ts @@ -0,0 +1,31 @@ +// 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'; +// eslint-disable-next-line no-restricted-imports -- focus-return is a sibling primitive to TransitionTracker; the exact transitionEnd signal is what we need. +import TransitionTracker from '@libs/Navigation/TransitionTracker'; +// eslint-disable-next-line no-restricted-imports -- this is android-only code; the rule guards web bundles from pulling the native node-handle stub. +import findNodeHandle from '@src/utils/findNodeHandle'; +import Accessibility from '..'; + +let registeredBackButton: View | RNText | null = null; + +function notifyBackButtonMounted(view: View | RNText | null): void { + registeredBackButton = view; +} + +function scheduleForwardAutoFocus(): void { + if (!Accessibility.isScreenReaderEnabledSync()) { + return; + } + TransitionTracker.runAfterTransitions({ + callback: () => { + const view = registeredBackButton; + if (!view || findNodeHandle(view) == null) { + return; + } + fireFocusEvent(view); + }, + }); +} + +export {notifyBackButtonMounted, scheduleForwardAutoFocus}; diff --git a/src/libs/Accessibility/forwardAutoFocus/index.ts b/src/libs/Accessibility/forwardAutoFocus/index.ts new file mode 100644 index 000000000000..9022d661e52b --- /dev/null +++ b/src/libs/Accessibility/forwardAutoFocus/index.ts @@ -0,0 +1,10 @@ +// Web: the browser handles window-state focus on its own. iOS: VoiceOver auto-focuses on screen-stack push without intervention. Both are no-ops here. +/* eslint-disable @typescript-eslint/no-unused-vars */ +// 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'; + +function notifyBackButtonMounted(_view: View | RNText | null): void {} +function scheduleForwardAutoFocus(): void {} +/* eslint-enable @typescript-eslint/no-unused-vars */ + +export {notifyBackButtonMounted, scheduleForwardAutoFocus}; diff --git a/src/libs/Accessibility/index.ts b/src/libs/Accessibility/index.ts index 940863010502..a0e6f743d217 100644 --- a/src/libs/Accessibility/index.ts +++ b/src/libs/Accessibility/index.ts @@ -16,24 +16,27 @@ function warmCache(label: string, fetch: () => Promise, apply: (value: T) } let cachedScreenReaderValue = false; +const screenReaderSubscribers = new Set<() => void>(); + +function setScreenReaderValue(value: boolean): void { + if (cachedScreenReaderValue === value) { + return; + } + cachedScreenReaderValue = value; + for (const cb of screenReaderSubscribers) { + cb(); + } +} -// Warm the cache at module load so the sync read is meaningful before any hook subscribes. -warmCache('screen-reader', isScreenReaderEnabled, (enabled) => { - cachedScreenReaderValue = enabled; -}); +// Single always-on listener. Decouples cache freshness from React subscriber lifecycle — toggling TalkBack mid-session updates the cache even if no `useScreenReaderStatus` consumer is currently mounted. +AccessibilityInfo.addEventListener('screenReaderChanged', setScreenReaderValue); +warmCache('screen-reader', isScreenReaderEnabled, setScreenReaderValue); -function subscribeScreenReader(callback: () => void) { - const subscription = AccessibilityInfo.addEventListener('screenReaderChanged', (enabled) => { - cachedScreenReaderValue = enabled; - callback(); - }); - - warmCache('screen-reader', isScreenReaderEnabled, (enabled) => { - cachedScreenReaderValue = enabled; - callback(); - }); - - return () => subscription?.remove(); +function subscribeScreenReader(callback: () => void): () => void { + screenReaderSubscribers.add(callback); + return () => { + screenReaderSubscribers.delete(callback); + }; } function getScreenReaderSnapshot() { diff --git a/src/libs/NavigationFocusReturn.native.ts b/src/libs/NavigationFocusReturn.native.ts index b6e222a3d54a..b2a227ebd546 100644 --- a/src/libs/NavigationFocusReturn.native.ts +++ b/src/libs/NavigationFocusReturn.native.ts @@ -1,10 +1,11 @@ import type {NavigationState} from '@react-navigation/native'; import type {RefObject} from 'react'; -import {AccessibilityInfo} from 'react-native'; import type {View} from 'react-native'; // eslint-disable-next-line no-restricted-imports -- .native.ts only; the rule guards web bundles from pulling the native stub. import findNodeHandle from '@src/utils/findNodeHandle'; import Accessibility from './Accessibility'; +import fireFocusEvent from './Accessibility/fireFocusEvent'; +import {notifyBackButtonMounted, scheduleForwardAutoFocus} from './Accessibility/forwardAutoFocus'; import navigationRef from './Navigation/navigationRef'; // eslint-disable-next-line no-restricted-imports -- focus-return is a sibling primitive to TransitionTracker; the exact transitionEnd signal is what we need to avoid focus-restore races with the OS. import TransitionTracker from './Navigation/TransitionTracker'; @@ -60,12 +61,11 @@ function captureTriggerForRoute(routeKey: string): void { setTriggerEntry(routeKey, {ref}); } -function fireFocusEvent(view: View): boolean { - // Truthy ref can still point to a detached View; findNodeHandle returns null then. +function tryFireFocusEvent(view: View): boolean { if (findNodeHandle(view) == null) { return false; } - AccessibilityInfo.sendAccessibilityEvent(view, 'focus'); + fireFocusEvent(view); return true; } @@ -78,7 +78,7 @@ function restoreTriggerForRoute(routeKey: string): View | null { if (!view) { return null; } - return fireFocusEvent(view) ? view : null; + return tryFireFocusEvent(view) ? view : null; } function cancelPendingRestore(): void { @@ -104,7 +104,7 @@ function scheduleRestore(routeKey: string): void { if (cancelled) { return; } - fireFocusEvent(view); + tryFireFocusEvent(view); triggerMap.delete(routeKey); pendingRestore = null; }, RESTORE_RETRY_MS); @@ -133,6 +133,7 @@ function handleStateChange(newState: NavigationState | undefined): void { cancelPendingRestore(); captureTriggerForRoute(action.captureKey); lastPressedTrigger = null; + scheduleForwardAutoFocus(); } else if (action.type === 'backward') { if (skipNextRestore) { skipNextRestore = false; @@ -226,6 +227,7 @@ export { notifyPushParamsBackward, cancelPendingFocusRestore, skipNextFocusRestore, + notifyBackButtonMounted, isFocusRestoreInProgress, shouldSkipAutoFocusDueToExistingFocus, resetForTests, diff --git a/src/libs/NavigationFocusReturn.ts b/src/libs/NavigationFocusReturn.ts index b5592bf3f21c..aa050123fea1 100644 --- a/src/libs/NavigationFocusReturn.ts +++ b/src/libs/NavigationFocusReturn.ts @@ -1,7 +1,8 @@ import type {NavigationState} from '@react-navigation/native'; // eslint-disable-next-line no-restricted-imports -- idiomatic defer primitive past navigation transitions. import {InteractionManager} from 'react-native'; -import type {View} from 'react-native'; +// eslint-disable-next-line no-restricted-imports -- type-only; mirrors PressableRef's cross-platform union for the back-button-mounted callback signature. +import type {Text as RNText, View} from 'react-native'; import compoundParamsKey, {COMPOUND_KEY_DELIMITER} from './compoundParamsKey'; import FOCUSABLE_SELECTOR from './focusableSelector'; import hasFocusableAttributes from './focusGuards'; @@ -103,6 +104,10 @@ function skipNextFocusRestore(): void { // eslint-disable-next-line @typescript-eslint/no-unused-vars function notifyPressedTrigger(_node: View | null): void {} +/** Native-only. Web doesn't need it — browser handles window-state focus on its own. */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function notifyBackButtonMounted(_node: View | RNText | null): void {} + /** 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; @@ -482,6 +487,7 @@ export { cancelPendingFocusRestore, skipNextFocusRestore, notifyPressedTrigger, + notifyBackButtonMounted, isFocusRestoreInProgress, compoundParamsKey, shouldSkipAutoFocusDueToExistingFocus, diff --git a/tests/unit/NavigationFocusReturnNativeTest.ts b/tests/unit/NavigationFocusReturnNativeTest.ts index 5995600a3598..a37095a3df35 100644 --- a/tests/unit/NavigationFocusReturnNativeTest.ts +++ b/tests/unit/NavigationFocusReturnNativeTest.ts @@ -11,6 +11,7 @@ type NavState = { history: unknown[]; }; +const mockFireFocusEvent = jest.fn(); const mockSendAccessibilityEvent = jest.fn(); let mockScreenReaderEnabled = true; @@ -24,6 +25,13 @@ jest.mock('../../src/libs/Accessibility', () => ({ }, })); +jest.mock('../../src/libs/Accessibility/fireFocusEvent', () => ({ + __esModule: true, + default: (view: unknown): void => { + mockFireFocusEvent(view); + }, +})); + AccessibilityInfo.sendAccessibilityEvent = mockSendAccessibilityEvent; // jsdom resolves the web stub (returns null). Treat fake views as attached unless they carry `detached: true`. @@ -138,6 +146,7 @@ function fakeView(label = 'view'): {label: string} { beforeEach(() => { jest.useFakeTimers(); mockSendAccessibilityEvent.mockClear(); + mockFireFocusEvent.mockClear(); mockScreenReaderEnabled = true; mockStateListeners = []; mockNavigationRefState = undefined; @@ -246,12 +255,12 @@ describe('handleStateChange — backward', () => { handleStateChange(forward); handleStateChange(back); flushTransitions(); - expect(mockSendAccessibilityEvent).toHaveBeenCalledTimes(1); - expect(mockSendAccessibilityEvent).toHaveBeenNthCalledWith(1, view, 'focus'); + expect(mockFireFocusEvent).toHaveBeenCalledTimes(1); + expect(mockFireFocusEvent).toHaveBeenNthCalledWith(1, view); jest.runAllTimers(); - expect(mockSendAccessibilityEvent).toHaveBeenCalledTimes(2); - expect(mockSendAccessibilityEvent).toHaveBeenNthCalledWith(2, view, 'focus'); + expect(mockFireFocusEvent).toHaveBeenCalledTimes(2); + expect(mockFireFocusEvent).toHaveBeenNthCalledWith(2, view); }); it('does NOT restore when skipNextFocusRestore was called before goBack (form-submit path)', () => { @@ -270,7 +279,7 @@ describe('handleStateChange — backward', () => { handleStateChange(back); flushTransitions(); - expect(mockSendAccessibilityEvent).not.toHaveBeenCalled(); + expect(mockFireFocusEvent).not.toHaveBeenCalled(); }); it('does NOT call sendAccessibilityEvent when no trigger was staged before the forward navigation', () => { @@ -286,7 +295,7 @@ describe('handleStateChange — backward', () => { handleStateChange(back); flushTransitions(); - expect(mockSendAccessibilityEvent).not.toHaveBeenCalled(); + expect(mockFireFocusEvent).not.toHaveBeenCalled(); }); it('does NOT call sendAccessibilityEvent when the captured view has been detached (parent screen replaced)', () => { @@ -304,7 +313,7 @@ describe('handleStateChange — backward', () => { handleStateChange(back); flushTransitions(); - expect(mockSendAccessibilityEvent).not.toHaveBeenCalled(); + expect(mockFireFocusEvent).not.toHaveBeenCalled(); }); it('cleans the trigger entry from the map after a successful restore', () => { @@ -357,7 +366,7 @@ describe('handleStateChange — lateral & cleanup', () => { handleStateChange(lateralAfter); flushTransitions(); - expect(mockSendAccessibilityEvent).not.toHaveBeenCalled(); + expect(mockFireFocusEvent).not.toHaveBeenCalled(); }); it('drops trigger entries for routes removed from the stack', () => { @@ -391,7 +400,7 @@ describe('handleStateChange — lateral & cleanup', () => { cancelPendingFocusRestore(); flushTransitions(); - expect(mockSendAccessibilityEvent).not.toHaveBeenCalled(); + expect(mockFireFocusEvent).not.toHaveBeenCalled(); }); }); diff --git a/tests/unit/fireFocusEventAndroidTest.ts b/tests/unit/fireFocusEventAndroidTest.ts new file mode 100644 index 000000000000..af94dfd55920 --- /dev/null +++ b/tests/unit/fireFocusEventAndroidTest.ts @@ -0,0 +1,20 @@ +import {AccessibilityInfo} from 'react-native'; + +const fireFocusEvent = require<{default: (view: unknown) => void}>('../../src/libs/Accessibility/fireFocusEvent/index.android').default; + +const mockSendAccessibilityEvent = jest.fn(); +AccessibilityInfo.sendAccessibilityEvent = mockSendAccessibilityEvent; + +beforeEach(() => { + mockSendAccessibilityEvent.mockClear(); +}); + +describe('fireFocusEvent (Android)', () => { + it('fires both `focus` and `viewHoverEnter` so TalkBack honours the focus move', () => { + const view = {label: 'back-button'}; + fireFocusEvent(view); + expect(mockSendAccessibilityEvent).toHaveBeenCalledTimes(2); + expect(mockSendAccessibilityEvent).toHaveBeenNthCalledWith(1, view, 'focus'); + expect(mockSendAccessibilityEvent).toHaveBeenNthCalledWith(2, view, 'viewHoverEnter'); + }); +}); From e4f3f8e7323b1af608466ae84079e3914ae1fb26 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Wed, 27 May 2026 12:01:05 +0300 Subject: [PATCH 04/89] fix: capture/restore focus on native PUSH_PARAMS via compound key --- src/libs/NavigationFocusReturn.native.ts | 22 +++++++--- tests/unit/NavigationFocusReturnNativeTest.ts | 42 +++++++++++++++++++ 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/src/libs/NavigationFocusReturn.native.ts b/src/libs/NavigationFocusReturn.native.ts index b2a227ebd546..ae77685f5e84 100644 --- a/src/libs/NavigationFocusReturn.native.ts +++ b/src/libs/NavigationFocusReturn.native.ts @@ -6,6 +6,7 @@ import findNodeHandle from '@src/utils/findNodeHandle'; import Accessibility from './Accessibility'; import fireFocusEvent from './Accessibility/fireFocusEvent'; import {notifyBackButtonMounted, scheduleForwardAutoFocus} from './Accessibility/forwardAutoFocus'; +import compoundParamsKey, {COMPOUND_KEY_DELIMITER} from './compoundParamsKey'; import navigationRef from './Navigation/navigationRef'; // eslint-disable-next-line no-restricted-imports -- focus-return is a sibling primitive to TransitionTracker; the exact transitionEnd signal is what we need to avoid focus-restore races with the OS. import TransitionTracker from './Navigation/TransitionTracker'; @@ -148,6 +149,13 @@ function handleStateChange(newState: NavigationState | undefined): void { for (const key of removedKeys) { triggerMap.delete(key); + // Map iteration is safe under in-loop delete. + const compoundPrefix = `${key}${COMPOUND_KEY_DELIMITER}`; + for (const mapKey of triggerMap.keys()) { + if (mapKey.startsWith(compoundPrefix)) { + triggerMap.delete(mapKey); + } + } } prevState = newState; } @@ -187,11 +195,15 @@ function skipNextFocusRestore(): void { skipNextRestore = true; } -/* eslint-disable @typescript-eslint/no-unused-vars */ -// Web-only stubs (PUSH_PARAMS, list restore-in-progress flag, AUTO-skip guard). -function notifyPushParamsForward(_routeKey: string, _prevParams: unknown): void {} -function notifyPushParamsBackward(_routeKey: string, _targetParams: unknown): void {} -/* eslint-enable @typescript-eslint/no-unused-vars */ +/** PUSH_PARAMS changes route params without changing the focused key, so `diffNavigationState` sees it as `noop`; capture/restore against the compound key (route + params) directly. */ +function notifyPushParamsForward(routeKey: string, prevParams: unknown): void { + cancelPendingRestore(); + captureTriggerForRoute(compoundParamsKey(routeKey, prevParams)); +} + +function notifyPushParamsBackward(routeKey: string, targetParams: unknown): void { + scheduleRestore(compoundParamsKey(routeKey, targetParams)); +} function cancelPendingFocusRestore(): void { cancelPendingRestore(); diff --git a/tests/unit/NavigationFocusReturnNativeTest.ts b/tests/unit/NavigationFocusReturnNativeTest.ts index a37095a3df35..4d3f266626ca 100644 --- a/tests/unit/NavigationFocusReturnNativeTest.ts +++ b/tests/unit/NavigationFocusReturnNativeTest.ts @@ -95,6 +95,8 @@ const { teardownNavigationFocusReturn, handleStateChange, notifyPressedTrigger, + notifyPushParamsForward, + notifyPushParamsBackward, cancelPendingFocusRestore, skipNextFocusRestore, isFocusRestoreInProgress, @@ -107,6 +109,8 @@ const { teardownNavigationFocusReturn: () => void; handleStateChange: (state: unknown) => void; notifyPressedTrigger: (node: unknown) => void; + notifyPushParamsForward: (routeKey: string, prevParams: unknown) => void; + notifyPushParamsBackward: (routeKey: string, targetParams: unknown) => void; cancelPendingFocusRestore: () => void; skipNextFocusRestore: () => void; isFocusRestoreInProgress: () => boolean; @@ -428,6 +432,44 @@ describe('setup / teardown', () => { }); }); +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 trigger = fakeView('search-tab-expense'); + setLastPressedTriggerForTests(trigger); + + notifyPushParamsForward(ROUTE_KEY, {q: 'old'}); + expect(getTriggerMapSizeForTests()).toBe(1); + + notifyPushParamsBackward(ROUTE_KEY, {q: 'old'}); + flushTransitions(); + jest.runAllTimers(); + expect(mockFireFocusEvent).toHaveBeenCalledWith(trigger); + }); + + it('does NOT restore when the back targets a different params hash than the captured one', () => { + const trigger = fakeView('search-tab-expense'); + setLastPressedTriggerForTests(trigger); + + notifyPushParamsForward(ROUTE_KEY, {q: 'old'}); + notifyPushParamsBackward(ROUTE_KEY, {q: 'unrelated'}); + flushTransitions(); + jest.runAllTimers(); + expect(mockFireFocusEvent).not.toHaveBeenCalled(); + }); + + it('drops compound entries when the route is removed from the tree', () => { + setLastPressedTriggerForTests(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); + }); +}); + describe('web-only stubs return constant values on native', () => { it('isFocusRestoreInProgress always returns false', () => { expect(isFocusRestoreInProgress()).toBe(false); From 394f13fc018f9e2b531be7ea595a7700eceb9e5a Mon Sep 17 00:00:00 2001 From: TaduJR Date: Wed, 27 May 2026 16:14:17 +0300 Subject: [PATCH 05/89] fix: deterministic Android backward focus via native ACTION_ACCESSIBILITY_FOCUS --- .../chat/AccessibilityFocusModule.kt | 41 ++++++++++++++++ .../expensify/chat/ExpensifyAppPackage.java | 1 + src/components/HeaderWithBackButton/index.tsx | 14 +----- .../fireFocusEvent/index.android.ts | 27 ++++++++-- .../Accessibility/fireFocusEvent/index.ts | 2 +- .../forwardAutoFocus/index.android.ts | 31 ------------ .../Accessibility/forwardAutoFocus/index.ts | 10 ---- src/libs/Accessibility/index.ts | 35 ++++++------- src/libs/NavigationFocusReturn.native.ts | 49 ++++--------------- src/libs/NavigationFocusReturn.ts | 9 +--- src/libs/navigationStateDiff.ts | 2 - tests/unit/NavigationFocusReturnNativeTest.ts | 9 +--- tests/unit/fireFocusEventAndroidTest.ts | 20 -------- 13 files changed, 97 insertions(+), 153 deletions(-) create mode 100644 android/app/src/main/java/com/expensify/chat/AccessibilityFocusModule.kt delete mode 100644 src/libs/Accessibility/forwardAutoFocus/index.android.ts delete mode 100644 src/libs/Accessibility/forwardAutoFocus/index.ts delete mode 100644 tests/unit/fireFocusEventAndroidTest.ts diff --git a/android/app/src/main/java/com/expensify/chat/AccessibilityFocusModule.kt b/android/app/src/main/java/com/expensify/chat/AccessibilityFocusModule.kt new file mode 100644 index 000000000000..16820f8c5328 --- /dev/null +++ b/android/app/src/main/java/com/expensify/chat/AccessibilityFocusModule.kt @@ -0,0 +1,41 @@ +package com.expensify.chat + +import android.view.View +import android.view.accessibility.AccessibilityNodeInfo +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.bridge.UiThreadUtil +import com.facebook.react.uimanager.UIManagerHelper + +class AccessibilityFocusModule( + private val reactContext: ReactApplicationContext, +) : ReactContextBaseJavaModule(reactContext) { + override fun getName(): String = NAME + + /* + * `ACTION_ACCESSIBILITY_FOCUS` is the only API that moves TalkBack focus; `sendAccessibilityEvent` + * dispatches a notification that TalkBack ignores when it has a competing claim. The +300ms re-fire + * beats `TYPE_WINDOW_STATE_CHANGED` from clobbering the first action (facebook/react-native#30097). + */ + @ReactMethod + fun setAccessibilityFocus(reactTag: Double) { + val tag = reactTag.toInt() + UiThreadUtil.runOnUiThread { + // Paper/Fabric-aware resolver; decorView fallback for older RN versions. + val view: View = + UIManagerHelper.getUIManagerForReactTag(reactContext, tag)?.resolveView(tag) as? View + ?: reactContext.currentActivity?.window?.decorView?.findViewById(tag) + ?: return@runOnUiThread + view.performAccessibilityAction(AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS, null) + view.postDelayed({ + view.performAccessibilityAction(AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS, null) + }, REFOCUS_DELAY_MS) + } + } + + companion object { + const val NAME = "RNAccessibilityFocus" + private const val REFOCUS_DELAY_MS = 300L + } +} diff --git a/android/app/src/main/java/com/expensify/chat/ExpensifyAppPackage.java b/android/app/src/main/java/com/expensify/chat/ExpensifyAppPackage.java index d8ef60e1d98d..1d4c7725c0b8 100644 --- a/android/app/src/main/java/com/expensify/chat/ExpensifyAppPackage.java +++ b/android/app/src/main/java/com/expensify/chat/ExpensifyAppPackage.java @@ -23,6 +23,7 @@ public List createNativeModules( modules.add(new ShareActionHandlerModule(reactContext)); modules.add(new AppStateTrackerModule(reactContext)); + modules.add(new AccessibilityFocusModule(reactContext)); return modules; } diff --git a/src/components/HeaderWithBackButton/index.tsx b/src/components/HeaderWithBackButton/index.tsx index e289c2115378..18deb1a7e68e 100755 --- a/src/components/HeaderWithBackButton/index.tsx +++ b/src/components/HeaderWithBackButton/index.tsx @@ -1,7 +1,5 @@ -import React, {useCallback, useMemo} from 'react'; +import React, {useMemo} from 'react'; import {Keyboard, StyleSheet, View} from 'react-native'; -// eslint-disable-next-line no-restricted-imports -- type-only; needed to match PressableRef's cross-platform union for the back-button callback ref. -import type {Text as RNText, View as RNView} from 'react-native'; import type {SvgProps} from 'react-native-svg'; import ActivityIndicator from '@components/ActivityIndicator'; import Avatar from '@components/Avatar'; @@ -24,7 +22,6 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useThrottledButtonState from '@hooks/useThrottledButtonState'; import getButtonState from '@libs/getButtonState'; import Navigation from '@libs/Navigation/Navigation'; -import {notifyBackButtonMounted} from '@libs/NavigationFocusReturn'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -93,14 +90,6 @@ function HeaderWithBackButton({ const [isDownloadButtonActive, temporarilyDisableDownloadButton] = useThrottledButtonState(); const {translate} = useLocalize(); const isInLandscapeMode = useIsInLandscapeMode(); - // Param signature mirrors PressableRef's cross-platform union; the guard rejects HTMLDivElement so only a native host instance reaches notifyBackButtonMounted. - const backButtonRefCallback = useCallback((node: RNView | HTMLDivElement | RNText | null | undefined) => { - if (!node || !('measure' in node) || 'innerHTML' in node) { - notifyBackButtonMounted(null); - return; - } - notifyBackButtonMounted(node); - }, []); const downloadReasonAttributes = useMemo( () => ({ @@ -255,7 +244,6 @@ function HeaderWithBackButton({ {shouldShowBackButton && ( { if (Keyboard.isVisible()) { Keyboard.dismiss(); diff --git a/src/libs/Accessibility/fireFocusEvent/index.android.ts b/src/libs/Accessibility/fireFocusEvent/index.android.ts index cddacdcdec29..e3bdaf61ccca 100644 --- a/src/libs/Accessibility/fireFocusEvent/index.android.ts +++ b/src/libs/Accessibility/fireFocusEvent/index.android.ts @@ -1,11 +1,32 @@ -import {AccessibilityInfo} from 'react-native'; +import {AccessibilityInfo, findNodeHandle, NativeModules} 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'; +type AccessibilityFocusBridge = { + setAccessibilityFocus: (reactTag: number) => void; +}; + +declare module 'react-native' { + interface NativeModulesStatic { + RNAccessibilityFocus?: AccessibilityFocusBridge; + } +} + +/* + * `sendAccessibilityEvent('focus')` only dispatches a notification on Android; TalkBack ignores it + * when it already has a focus claim. The native module performs `ACTION_ACCESSIBILITY_FOCUS`, the + * only API that actually moves focus. JS fallback covers pre-rebuild dev bundles. + */ function fireFocusEvent(view: View | RNText): void { + const handle = findNodeHandle(view); + if (handle == null) { + return; + } + if (NativeModules.RNAccessibilityFocus) { + NativeModules.RNAccessibilityFocus.setAccessibilityFocus(handle); + return; + } AccessibilityInfo.sendAccessibilityEvent(view, 'focus'); - // 'viewHoverEnter' (TYPE_VIEW_HOVER_ENTER) is the explore-by-touch signal TalkBack always honours; pure 'focus' (TYPE_VIEW_FOCUSED) is conditional and doesn't move accessibility focus reliably. - AccessibilityInfo.sendAccessibilityEvent(view, 'viewHoverEnter'); } export default fireFocusEvent; diff --git a/src/libs/Accessibility/fireFocusEvent/index.ts b/src/libs/Accessibility/fireFocusEvent/index.ts index 7549239993a7..1e1ea07e53e3 100644 --- a/src/libs/Accessibility/fireFocusEvent/index.ts +++ b/src/libs/Accessibility/fireFocusEvent/index.ts @@ -1,4 +1,4 @@ -// Web: focus is moved by `element.focus()` directly elsewhere; this helper is a no-op so cross-platform consumers can call it unconditionally. +// Web uses `element.focus()` directly; this is the cross-platform no-op. // 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'; diff --git a/src/libs/Accessibility/forwardAutoFocus/index.android.ts b/src/libs/Accessibility/forwardAutoFocus/index.android.ts deleted file mode 100644 index 69a36149e212..000000000000 --- a/src/libs/Accessibility/forwardAutoFocus/index.android.ts +++ /dev/null @@ -1,31 +0,0 @@ -// 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'; -// eslint-disable-next-line no-restricted-imports -- focus-return is a sibling primitive to TransitionTracker; the exact transitionEnd signal is what we need. -import TransitionTracker from '@libs/Navigation/TransitionTracker'; -// eslint-disable-next-line no-restricted-imports -- this is android-only code; the rule guards web bundles from pulling the native node-handle stub. -import findNodeHandle from '@src/utils/findNodeHandle'; -import Accessibility from '..'; - -let registeredBackButton: View | RNText | null = null; - -function notifyBackButtonMounted(view: View | RNText | null): void { - registeredBackButton = view; -} - -function scheduleForwardAutoFocus(): void { - if (!Accessibility.isScreenReaderEnabledSync()) { - return; - } - TransitionTracker.runAfterTransitions({ - callback: () => { - const view = registeredBackButton; - if (!view || findNodeHandle(view) == null) { - return; - } - fireFocusEvent(view); - }, - }); -} - -export {notifyBackButtonMounted, scheduleForwardAutoFocus}; diff --git a/src/libs/Accessibility/forwardAutoFocus/index.ts b/src/libs/Accessibility/forwardAutoFocus/index.ts deleted file mode 100644 index 9022d661e52b..000000000000 --- a/src/libs/Accessibility/forwardAutoFocus/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -// Web: the browser handles window-state focus on its own. iOS: VoiceOver auto-focuses on screen-stack push without intervention. Both are no-ops here. -/* eslint-disable @typescript-eslint/no-unused-vars */ -// 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'; - -function notifyBackButtonMounted(_view: View | RNText | null): void {} -function scheduleForwardAutoFocus(): void {} -/* eslint-enable @typescript-eslint/no-unused-vars */ - -export {notifyBackButtonMounted, scheduleForwardAutoFocus}; diff --git a/src/libs/Accessibility/index.ts b/src/libs/Accessibility/index.ts index a0e6f743d217..940863010502 100644 --- a/src/libs/Accessibility/index.ts +++ b/src/libs/Accessibility/index.ts @@ -16,27 +16,24 @@ function warmCache(label: string, fetch: () => Promise, apply: (value: T) } let cachedScreenReaderValue = false; -const screenReaderSubscribers = new Set<() => void>(); - -function setScreenReaderValue(value: boolean): void { - if (cachedScreenReaderValue === value) { - return; - } - cachedScreenReaderValue = value; - for (const cb of screenReaderSubscribers) { - cb(); - } -} -// Single always-on listener. Decouples cache freshness from React subscriber lifecycle — toggling TalkBack mid-session updates the cache even if no `useScreenReaderStatus` consumer is currently mounted. -AccessibilityInfo.addEventListener('screenReaderChanged', setScreenReaderValue); -warmCache('screen-reader', isScreenReaderEnabled, setScreenReaderValue); +// Warm the cache at module load so the sync read is meaningful before any hook subscribes. +warmCache('screen-reader', isScreenReaderEnabled, (enabled) => { + cachedScreenReaderValue = enabled; +}); -function subscribeScreenReader(callback: () => void): () => void { - screenReaderSubscribers.add(callback); - return () => { - screenReaderSubscribers.delete(callback); - }; +function subscribeScreenReader(callback: () => void) { + const subscription = AccessibilityInfo.addEventListener('screenReaderChanged', (enabled) => { + cachedScreenReaderValue = enabled; + callback(); + }); + + warmCache('screen-reader', isScreenReaderEnabled, (enabled) => { + cachedScreenReaderValue = enabled; + callback(); + }); + + return () => subscription?.remove(); } function getScreenReaderSnapshot() { diff --git a/src/libs/NavigationFocusReturn.native.ts b/src/libs/NavigationFocusReturn.native.ts index ae77685f5e84..b079b55cbd81 100644 --- a/src/libs/NavigationFocusReturn.native.ts +++ b/src/libs/NavigationFocusReturn.native.ts @@ -5,20 +5,15 @@ import type {View} from 'react-native'; import findNodeHandle from '@src/utils/findNodeHandle'; import Accessibility from './Accessibility'; import fireFocusEvent from './Accessibility/fireFocusEvent'; -import {notifyBackButtonMounted, scheduleForwardAutoFocus} from './Accessibility/forwardAutoFocus'; import compoundParamsKey, {COMPOUND_KEY_DELIMITER} from './compoundParamsKey'; import navigationRef from './Navigation/navigationRef'; // eslint-disable-next-line no-restricted-imports -- focus-return is a sibling primitive to TransitionTracker; the exact transitionEnd signal is what we need to avoid focus-restore races with the OS. import TransitionTracker from './Navigation/TransitionTracker'; import {diffNavigationState} from './navigationStateDiff'; -/** Press-driven capture of the triggering View on forward nav; restore via `AccessibilityInfo.sendAccessibilityEvent` on backward. */ - type TriggerEntry = {ref: RefObject}; const TRIGGER_MAP_MAX = 64; -// The first focus event can lose to the OS's own window-change auto-focus; a delayed second call lands after the OS settles. -const RESTORE_RETRY_MS = 300; // A press long before a delayed nav (timer / deeplink / async redirect) shouldn't be captured as that nav's trigger. const PRESS_TRIGGER_TTL_MS = 3_000; @@ -62,24 +57,17 @@ function captureTriggerForRoute(routeKey: string): void { setTriggerEntry(routeKey, {ref}); } -function tryFireFocusEvent(view: View): boolean { - if (findNodeHandle(view) == null) { - return false; - } - fireFocusEvent(view); - return true; -} - -function restoreTriggerForRoute(routeKey: string): View | null { +function restoreTriggerForRoute(routeKey: string): void { const entry = triggerMap.get(routeKey); if (!entry) { - return null; + return; } const view = entry.ref.current; - if (!view) { - return null; + // Truthy ref can still point to a detached View; findNodeHandle returns null then. + if (!view || findNodeHandle(view) == null) { + return; } - return tryFireFocusEvent(view) ? view : null; + fireFocusEvent(view); } function cancelPendingRestore(): void { @@ -90,25 +78,14 @@ function cancelPendingRestore(): void { function scheduleRestore(routeKey: string): void { cancelPendingRestore(); let cancelled = false; - let retryTimerId: ReturnType | undefined; const handle = TransitionTracker.runAfterTransitions({ callback: () => { if (cancelled) { return; } - const view = restoreTriggerForRoute(routeKey); - if (!view) { - pendingRestore = null; - return; - } - retryTimerId = setTimeout(() => { - if (cancelled) { - return; - } - tryFireFocusEvent(view); - triggerMap.delete(routeKey); - pendingRestore = null; - }, RESTORE_RETRY_MS); + restoreTriggerForRoute(routeKey); + triggerMap.delete(routeKey); + pendingRestore = null; }, }); @@ -116,9 +93,6 @@ function scheduleRestore(routeKey: string): void { cancel: () => { cancelled = true; handle.cancel(); - if (retryTimerId !== undefined) { - clearTimeout(retryTimerId); - } }, }; } @@ -134,7 +108,6 @@ function handleStateChange(newState: NavigationState | undefined): void { cancelPendingRestore(); captureTriggerForRoute(action.captureKey); lastPressedTrigger = null; - scheduleForwardAutoFocus(); } else if (action.type === 'backward') { if (skipNextRestore) { skipNextRestore = false; @@ -149,7 +122,6 @@ function handleStateChange(newState: NavigationState | undefined): void { for (const key of removedKeys) { triggerMap.delete(key); - // Map iteration is safe under in-loop delete. const compoundPrefix = `${key}${COMPOUND_KEY_DELIMITER}`; for (const mapKey of triggerMap.keys()) { if (mapKey.startsWith(compoundPrefix)) { @@ -195,7 +167,7 @@ function skipNextFocusRestore(): void { skipNextRestore = true; } -/** PUSH_PARAMS changes route params without changing the focused key, so `diffNavigationState` sees it as `noop`; capture/restore against the compound key (route + params) directly. */ +/** PUSH_PARAMS reuses the focused key, so `diffNavigationState` reports `noop`; key against `routeKey + params`. */ function notifyPushParamsForward(routeKey: string, prevParams: unknown): void { cancelPendingRestore(); captureTriggerForRoute(compoundParamsKey(routeKey, prevParams)); @@ -239,7 +211,6 @@ export { notifyPushParamsBackward, cancelPendingFocusRestore, skipNextFocusRestore, - notifyBackButtonMounted, isFocusRestoreInProgress, shouldSkipAutoFocusDueToExistingFocus, resetForTests, diff --git a/src/libs/NavigationFocusReturn.ts b/src/libs/NavigationFocusReturn.ts index aa050123fea1..c3b099860cf1 100644 --- a/src/libs/NavigationFocusReturn.ts +++ b/src/libs/NavigationFocusReturn.ts @@ -1,8 +1,8 @@ import type {NavigationState} from '@react-navigation/native'; // eslint-disable-next-line no-restricted-imports -- idiomatic defer primitive past navigation transitions. import {InteractionManager} from 'react-native'; -// eslint-disable-next-line no-restricted-imports -- type-only; mirrors PressableRef's cross-platform union for the back-button-mounted callback signature. -import type {Text as RNText, View} from 'react-native'; +// eslint-disable-next-line no-restricted-imports -- type-only; matches notifyPressedTrigger's native-host param. +import type {View} from 'react-native'; import compoundParamsKey, {COMPOUND_KEY_DELIMITER} from './compoundParamsKey'; import FOCUSABLE_SELECTOR from './focusableSelector'; import hasFocusableAttributes from './focusGuards'; @@ -104,10 +104,6 @@ function skipNextFocusRestore(): void { // eslint-disable-next-line @typescript-eslint/no-unused-vars function notifyPressedTrigger(_node: View | null): void {} -/** Native-only. Web doesn't need it — browser handles window-state focus on its own. */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function notifyBackButtonMounted(_node: View | RNText | null): void {} - /** 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; @@ -487,7 +483,6 @@ export { cancelPendingFocusRestore, skipNextFocusRestore, notifyPressedTrigger, - notifyBackButtonMounted, isFocusRestoreInProgress, compoundParamsKey, shouldSkipAutoFocusDueToExistingFocus, diff --git a/src/libs/navigationStateDiff.ts b/src/libs/navigationStateDiff.ts index cca640f6e51b..8377c71973d7 100644 --- a/src/libs/navigationStateDiff.ts +++ b/src/libs/navigationStateDiff.ts @@ -1,8 +1,6 @@ import {findFocusedRoute} from '@react-navigation/core'; import type {NavigationState, PartialState} from '@react-navigation/native'; -/** Classifies a navigation state transition as forward / backward / lateral / noop and reports removed route keys. */ - type AnyState = NavigationState | PartialState | undefined; type DiffAction = {type: 'forward'; captureKey: string} | {type: 'backward'; restoreKey: string} | {type: 'lateral'} | {type: 'noop'}; diff --git a/tests/unit/NavigationFocusReturnNativeTest.ts b/tests/unit/NavigationFocusReturnNativeTest.ts index 4d3f266626ca..fcea16234c08 100644 --- a/tests/unit/NavigationFocusReturnNativeTest.ts +++ b/tests/unit/NavigationFocusReturnNativeTest.ts @@ -260,11 +260,7 @@ describe('handleStateChange — backward', () => { handleStateChange(back); flushTransitions(); expect(mockFireFocusEvent).toHaveBeenCalledTimes(1); - expect(mockFireFocusEvent).toHaveBeenNthCalledWith(1, view); - - jest.runAllTimers(); - expect(mockFireFocusEvent).toHaveBeenCalledTimes(2); - expect(mockFireFocusEvent).toHaveBeenNthCalledWith(2, view); + expect(mockFireFocusEvent).toHaveBeenCalledWith(view); }); it('does NOT restore when skipNextFocusRestore was called before goBack (form-submit path)', () => { @@ -334,7 +330,6 @@ describe('handleStateChange — backward', () => { expect(getTriggerMapSizeForTests()).toBe(1); handleStateChange(back); flushTransitions(); - jest.runAllTimers(); expect(getTriggerMapSizeForTests()).toBe(0); }); }); @@ -444,7 +439,6 @@ describe('PUSH_PARAMS — same-route param change', () => { notifyPushParamsBackward(ROUTE_KEY, {q: 'old'}); flushTransitions(); - jest.runAllTimers(); expect(mockFireFocusEvent).toHaveBeenCalledWith(trigger); }); @@ -455,7 +449,6 @@ describe('PUSH_PARAMS — same-route param change', () => { notifyPushParamsForward(ROUTE_KEY, {q: 'old'}); notifyPushParamsBackward(ROUTE_KEY, {q: 'unrelated'}); flushTransitions(); - jest.runAllTimers(); expect(mockFireFocusEvent).not.toHaveBeenCalled(); }); diff --git a/tests/unit/fireFocusEventAndroidTest.ts b/tests/unit/fireFocusEventAndroidTest.ts deleted file mode 100644 index af94dfd55920..000000000000 --- a/tests/unit/fireFocusEventAndroidTest.ts +++ /dev/null @@ -1,20 +0,0 @@ -import {AccessibilityInfo} from 'react-native'; - -const fireFocusEvent = require<{default: (view: unknown) => void}>('../../src/libs/Accessibility/fireFocusEvent/index.android').default; - -const mockSendAccessibilityEvent = jest.fn(); -AccessibilityInfo.sendAccessibilityEvent = mockSendAccessibilityEvent; - -beforeEach(() => { - mockSendAccessibilityEvent.mockClear(); -}); - -describe('fireFocusEvent (Android)', () => { - it('fires both `focus` and `viewHoverEnter` so TalkBack honours the focus move', () => { - const view = {label: 'back-button'}; - fireFocusEvent(view); - expect(mockSendAccessibilityEvent).toHaveBeenCalledTimes(2); - expect(mockSendAccessibilityEvent).toHaveBeenNthCalledWith(1, view, 'focus'); - expect(mockSendAccessibilityEvent).toHaveBeenNthCalledWith(2, view, 'viewHoverEnter'); - }); -}); From 75b0a428eebdedde6178558162abecd27fed6015 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Wed, 27 May 2026 16:50:23 +0300 Subject: [PATCH 06/89] fix: revert Android focus to sendAccessibilityEvent + idle-callback refocus --- .../chat/AccessibilityFocusModule.kt | 41 ------------------- .../expensify/chat/ExpensifyAppPackage.java | 1 - .../fireFocusEvent/index.android.ts | 26 ++---------- src/libs/Accessibility/index.ts | 4 +- .../scheduleRefocus/index.android.ts | 18 ++++++++ .../scheduleRefocus/index.ios.ts | 13 ++++++ .../Accessibility/scheduleRefocus/index.ts | 9 ++++ src/libs/NavigationFocusReturn.native.ts | 22 +++++++--- src/libs/navigationStateDiff.ts | 2 +- tests/unit/NavigationFocusReturnNativeTest.ts | 3 ++ tests/unit/fireFocusEventAndroidTest.ts | 34 +++++++++++++++ 11 files changed, 101 insertions(+), 72 deletions(-) delete mode 100644 android/app/src/main/java/com/expensify/chat/AccessibilityFocusModule.kt create mode 100644 src/libs/Accessibility/scheduleRefocus/index.android.ts create mode 100644 src/libs/Accessibility/scheduleRefocus/index.ios.ts create mode 100644 src/libs/Accessibility/scheduleRefocus/index.ts create mode 100644 tests/unit/fireFocusEventAndroidTest.ts diff --git a/android/app/src/main/java/com/expensify/chat/AccessibilityFocusModule.kt b/android/app/src/main/java/com/expensify/chat/AccessibilityFocusModule.kt deleted file mode 100644 index 16820f8c5328..000000000000 --- a/android/app/src/main/java/com/expensify/chat/AccessibilityFocusModule.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.expensify.chat - -import android.view.View -import android.view.accessibility.AccessibilityNodeInfo -import com.facebook.react.bridge.ReactApplicationContext -import com.facebook.react.bridge.ReactContextBaseJavaModule -import com.facebook.react.bridge.ReactMethod -import com.facebook.react.bridge.UiThreadUtil -import com.facebook.react.uimanager.UIManagerHelper - -class AccessibilityFocusModule( - private val reactContext: ReactApplicationContext, -) : ReactContextBaseJavaModule(reactContext) { - override fun getName(): String = NAME - - /* - * `ACTION_ACCESSIBILITY_FOCUS` is the only API that moves TalkBack focus; `sendAccessibilityEvent` - * dispatches a notification that TalkBack ignores when it has a competing claim. The +300ms re-fire - * beats `TYPE_WINDOW_STATE_CHANGED` from clobbering the first action (facebook/react-native#30097). - */ - @ReactMethod - fun setAccessibilityFocus(reactTag: Double) { - val tag = reactTag.toInt() - UiThreadUtil.runOnUiThread { - // Paper/Fabric-aware resolver; decorView fallback for older RN versions. - val view: View = - UIManagerHelper.getUIManagerForReactTag(reactContext, tag)?.resolveView(tag) as? View - ?: reactContext.currentActivity?.window?.decorView?.findViewById(tag) - ?: return@runOnUiThread - view.performAccessibilityAction(AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS, null) - view.postDelayed({ - view.performAccessibilityAction(AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS, null) - }, REFOCUS_DELAY_MS) - } - } - - companion object { - const val NAME = "RNAccessibilityFocus" - private const val REFOCUS_DELAY_MS = 300L - } -} diff --git a/android/app/src/main/java/com/expensify/chat/ExpensifyAppPackage.java b/android/app/src/main/java/com/expensify/chat/ExpensifyAppPackage.java index 1d4c7725c0b8..d8ef60e1d98d 100644 --- a/android/app/src/main/java/com/expensify/chat/ExpensifyAppPackage.java +++ b/android/app/src/main/java/com/expensify/chat/ExpensifyAppPackage.java @@ -23,7 +23,6 @@ public List createNativeModules( modules.add(new ShareActionHandlerModule(reactContext)); modules.add(new AppStateTrackerModule(reactContext)); - modules.add(new AccessibilityFocusModule(reactContext)); return modules; } diff --git a/src/libs/Accessibility/fireFocusEvent/index.android.ts b/src/libs/Accessibility/fireFocusEvent/index.android.ts index e3bdaf61ccca..b756746c1c5d 100644 --- a/src/libs/Accessibility/fireFocusEvent/index.android.ts +++ b/src/libs/Accessibility/fireFocusEvent/index.android.ts @@ -1,29 +1,11 @@ -import {AccessibilityInfo, findNodeHandle, NativeModules} from 'react-native'; +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'; +// eslint-disable-next-line no-restricted-imports -- .android.ts only; the wrapper resolves to the native findNodeHandle, and the rule guards web bundles. +import findNodeHandle from '@src/utils/findNodeHandle'; -type AccessibilityFocusBridge = { - setAccessibilityFocus: (reactTag: number) => void; -}; - -declare module 'react-native' { - interface NativeModulesStatic { - RNAccessibilityFocus?: AccessibilityFocusBridge; - } -} - -/* - * `sendAccessibilityEvent('focus')` only dispatches a notification on Android; TalkBack ignores it - * when it already has a focus claim. The native module performs `ACTION_ACCESSIBILITY_FOCUS`, the - * only API that actually moves focus. JS fallback covers pre-rebuild dev bundles. - */ function fireFocusEvent(view: View | RNText): void { - const handle = findNodeHandle(view); - if (handle == null) { - return; - } - if (NativeModules.RNAccessibilityFocus) { - NativeModules.RNAccessibilityFocus.setAccessibilityFocus(handle); + if (findNodeHandle(view) == null) { return; } AccessibilityInfo.sendAccessibilityEvent(view, 'focus'); diff --git a/src/libs/Accessibility/index.ts b/src/libs/Accessibility/index.ts index 940863010502..402afbc20879 100644 --- a/src/libs/Accessibility/index.ts +++ b/src/libs/Accessibility/index.ts @@ -17,7 +17,9 @@ function warmCache(label: string, fetch: () => Promise, apply: (value: T) let cachedScreenReaderValue = false; -// Warm the cache at module load so the sync read is meaningful before any hook subscribes. +/* + * Warm the cache at module load so the sync read is meaningful before any hook subscribes. + */ warmCache('screen-reader', isScreenReaderEnabled, (enabled) => { cachedScreenReaderValue = enabled; }); diff --git a/src/libs/Accessibility/scheduleRefocus/index.android.ts b/src/libs/Accessibility/scheduleRefocus/index.android.ts new file mode 100644 index 000000000000..88f2e948ea6c --- /dev/null +++ b/src/libs/Accessibility/scheduleRefocus/index.android.ts @@ -0,0 +1,18 @@ +// 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(view: View | RNText): {cancel: () => void} { + const id = requestIdleCallback(() => fireFocusEvent(view), {timeout: REFOCUS_TIMEOUT_MS}); + return { + cancel: () => cancelIdleCallback(id), + }; +} + +export default scheduleRefocus; diff --git a/src/libs/Accessibility/scheduleRefocus/index.ios.ts b/src/libs/Accessibility/scheduleRefocus/index.ios.ts new file mode 100644 index 000000000000..18ea190e38a6 --- /dev/null +++ b/src/libs/Accessibility/scheduleRefocus/index.ios.ts @@ -0,0 +1,13 @@ +// 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'; + +/* + * iOS uses UIAccessibilityLayoutChangedNotification (synchronous) — VoiceOver honours the first + * call, so no race-mitigation re-fire is needed. + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function scheduleRefocus(_view: View | RNText): {cancel: () => void} { + return {cancel: () => {}}; +} + +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..7a717b89877e --- /dev/null +++ b/src/libs/Accessibility/scheduleRefocus/index.ts @@ -0,0 +1,9 @@ +// 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(_view: View | RNText): {cancel: () => void} { + return {cancel: () => {}}; +} + +export default scheduleRefocus; diff --git a/src/libs/NavigationFocusReturn.native.ts b/src/libs/NavigationFocusReturn.native.ts index b079b55cbd81..a14168287b39 100644 --- a/src/libs/NavigationFocusReturn.native.ts +++ b/src/libs/NavigationFocusReturn.native.ts @@ -5,6 +5,7 @@ import type {View} from 'react-native'; import findNodeHandle from '@src/utils/findNodeHandle'; import Accessibility from './Accessibility'; import fireFocusEvent from './Accessibility/fireFocusEvent'; +import scheduleRefocus from './Accessibility/scheduleRefocus'; import compoundParamsKey, {COMPOUND_KEY_DELIMITER} from './compoundParamsKey'; import navigationRef from './Navigation/navigationRef'; // eslint-disable-next-line no-restricted-imports -- focus-return is a sibling primitive to TransitionTracker; the exact transitionEnd signal is what we need to avoid focus-restore races with the OS. @@ -14,7 +15,7 @@ import {diffNavigationState} from './navigationStateDiff'; type TriggerEntry = {ref: RefObject}; const TRIGGER_MAP_MAX = 64; -// A press long before a delayed nav (timer / deeplink / async redirect) shouldn't be captured as that nav's trigger. +// Drop stale presses so a delayed nav (timer / deeplink / async redirect) doesn't capture an unrelated trigger. const PRESS_TRIGGER_TTL_MS = 3_000; let lastPressedTrigger: View | null = null; @@ -57,17 +58,18 @@ function captureTriggerForRoute(routeKey: string): void { setTriggerEntry(routeKey, {ref}); } -function restoreTriggerForRoute(routeKey: string): void { +function restoreTriggerForRoute(routeKey: string): View | null { const entry = triggerMap.get(routeKey); if (!entry) { - return; + return null; } const view = entry.ref.current; // Truthy ref can still point to a detached View; findNodeHandle returns null then. if (!view || findNodeHandle(view) == null) { - return; + return null; } fireFocusEvent(view); + return view; } function cancelPendingRestore(): void { @@ -78,14 +80,19 @@ function cancelPendingRestore(): void { function scheduleRestore(routeKey: string): void { cancelPendingRestore(); let cancelled = false; + let refocusHandle: {cancel: () => void} | null = null; const handle = TransitionTracker.runAfterTransitions({ callback: () => { if (cancelled) { return; } - restoreTriggerForRoute(routeKey); + const view = restoreTriggerForRoute(routeKey); triggerMap.delete(routeKey); - pendingRestore = null; + if (!view) { + pendingRestore = null; + return; + } + refocusHandle = scheduleRefocus(view); }, }); @@ -93,6 +100,7 @@ function scheduleRestore(routeKey: string): void { cancel: () => { cancelled = true; handle.cancel(); + refocusHandle?.cancel(); }, }; } @@ -181,10 +189,12 @@ 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; } diff --git a/src/libs/navigationStateDiff.ts b/src/libs/navigationStateDiff.ts index 8377c71973d7..a30078457a71 100644 --- a/src/libs/navigationStateDiff.ts +++ b/src/libs/navigationStateDiff.ts @@ -22,7 +22,7 @@ function collectRouteKeys(state: AnyState, out = new Set()): Set 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 prevFocusedKey = prev ? findFocusedRoute(prev)?.key : undefined; const prevKeys = collectRouteKeys(prev); const newKeys = collectRouteKeys(next); diff --git a/tests/unit/NavigationFocusReturnNativeTest.ts b/tests/unit/NavigationFocusReturnNativeTest.ts index fcea16234c08..7e2019b704dd 100644 --- a/tests/unit/NavigationFocusReturnNativeTest.ts +++ b/tests/unit/NavigationFocusReturnNativeTest.ts @@ -259,6 +259,9 @@ describe('handleStateChange — backward', () => { 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); }); diff --git a/tests/unit/fireFocusEventAndroidTest.ts b/tests/unit/fireFocusEventAndroidTest.ts new file mode 100644 index 000000000000..01f6524566b5 --- /dev/null +++ b/tests/unit/fireFocusEventAndroidTest.ts @@ -0,0 +1,34 @@ +import {AccessibilityInfo} from 'react-native'; + +const mockSendAccessibilityEvent = jest.fn(); +let mockHandle: number | null = 1; + +jest.mock('@src/utils/findNodeHandle', () => ({ + __esModule: true, + default: () => mockHandle, +})); + +AccessibilityInfo.sendAccessibilityEvent = mockSendAccessibilityEvent; + +// eslint-disable-next-line import/extensions +const fireFocusEvent = require<{default: (view: unknown) => void}>('../../src/libs/Accessibility/fireFocusEvent/index.android').default; + +beforeEach(() => { + mockSendAccessibilityEvent.mockClear(); + mockHandle = 1; +}); + +describe('fireFocusEvent (Android)', () => { + it('dispatches sendAccessibilityEvent with `focus` when the view is attached', () => { + const view = {label: 'pressable'}; + fireFocusEvent(view); + expect(mockSendAccessibilityEvent).toHaveBeenCalledTimes(1); + expect(mockSendAccessibilityEvent).toHaveBeenCalledWith(view, 'focus'); + }); + + it('skips when findNodeHandle returns null (detached view) — no event dispatched', () => { + mockHandle = null; + fireFocusEvent({label: 'detached'}); + expect(mockSendAccessibilityEvent).not.toHaveBeenCalled(); + }); +}); From e3839074f8e6644540e7899f837e5509ae73c82b Mon Sep 17 00:00:00 2001 From: TaduJR Date: Wed, 27 May 2026 16:58:02 +0300 Subject: [PATCH 07/89] chore: remove unused eslint-disable directives --- src/libs/NavigationFocusReturn.ts | 1 - tests/unit/fireFocusEventAndroidTest.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/src/libs/NavigationFocusReturn.ts b/src/libs/NavigationFocusReturn.ts index c3b099860cf1..b5592bf3f21c 100644 --- a/src/libs/NavigationFocusReturn.ts +++ b/src/libs/NavigationFocusReturn.ts @@ -1,7 +1,6 @@ import type {NavigationState} from '@react-navigation/native'; // eslint-disable-next-line no-restricted-imports -- idiomatic defer primitive past navigation transitions. import {InteractionManager} from 'react-native'; -// eslint-disable-next-line no-restricted-imports -- type-only; matches notifyPressedTrigger's native-host param. import type {View} from 'react-native'; import compoundParamsKey, {COMPOUND_KEY_DELIMITER} from './compoundParamsKey'; import FOCUSABLE_SELECTOR from './focusableSelector'; diff --git a/tests/unit/fireFocusEventAndroidTest.ts b/tests/unit/fireFocusEventAndroidTest.ts index 01f6524566b5..eec79ffc4a1d 100644 --- a/tests/unit/fireFocusEventAndroidTest.ts +++ b/tests/unit/fireFocusEventAndroidTest.ts @@ -10,7 +10,6 @@ jest.mock('@src/utils/findNodeHandle', () => ({ AccessibilityInfo.sendAccessibilityEvent = mockSendAccessibilityEvent; -// eslint-disable-next-line import/extensions const fireFocusEvent = require<{default: (view: unknown) => void}>('../../src/libs/Accessibility/fireFocusEvent/index.android').default; beforeEach(() => { From 5645674f37e1506a7d145affb4e83b2944909127 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Wed, 27 May 2026 17:27:54 +0300 Subject: [PATCH 08/89] fix: clear trigger entry when skipNextFocusRestore is honoured (web + native) --- src/libs/NavigationFocusReturn.native.ts | 1 + src/libs/NavigationFocusReturn.ts | 1 + tests/unit/NavigationFocusReturnNativeTest.ts | 32 +++++++++++++++ tests/unit/NavigationFocusReturnTest.ts | 39 ++++++++++++++++++- 4 files changed, 72 insertions(+), 1 deletion(-) diff --git a/src/libs/NavigationFocusReturn.native.ts b/src/libs/NavigationFocusReturn.native.ts index a14168287b39..7973d2906a31 100644 --- a/src/libs/NavigationFocusReturn.native.ts +++ b/src/libs/NavigationFocusReturn.native.ts @@ -120,6 +120,7 @@ function handleStateChange(newState: NavigationState | undefined): void { if (skipNextRestore) { skipNextRestore = false; cancelPendingRestore(); + triggerMap.delete(action.restoreKey); } else { scheduleRestore(action.restoreKey); } diff --git a/src/libs/NavigationFocusReturn.ts b/src/libs/NavigationFocusReturn.ts index b5592bf3f21c..77f5c7d407a5 100644 --- a/src/libs/NavigationFocusReturn.ts +++ b/src/libs/NavigationFocusReturn.ts @@ -342,6 +342,7 @@ function handleStateChange(newState: NavigationState | undefined): void { if (skipNextRestore) { skipNextRestore = false; cancelPendingRestore(); + triggerMap.delete(action.restoreKey); } else { scheduleRestore(action.restoreKey); } diff --git a/tests/unit/NavigationFocusReturnNativeTest.ts b/tests/unit/NavigationFocusReturnNativeTest.ts index 7e2019b704dd..7bd56d1f3ff0 100644 --- a/tests/unit/NavigationFocusReturnNativeTest.ts +++ b/tests/unit/NavigationFocusReturnNativeTest.ts @@ -285,6 +285,38 @@ describe('handleStateChange — backward', () => { expect(mockFireFocusEvent).not.toHaveBeenCalled(); }); + it('clears the trigger entry when skipNextFocusRestore suppresses the restore, so a later back to the same route cannot inherit the skipped trigger', () => { + const skippedTrigger = fakeView('display-name'); + setLastPressedTriggerForTests(skippedTrigger); + const profile = stackState(0, [{key: 'profile', name: 'Profile'}]); + const intoDisplayName = stackState(1, [ + {key: 'profile', name: 'Profile'}, + {key: 'display-name-page', name: 'DisplayName'}, + ]); + const backToProfile = stackState(0, [{key: 'profile', name: 'Profile'}]); + + handleStateChange(profile); + handleStateChange(intoDisplayName); + expect(getTriggerMapSizeForTests()).toBe(1); + + // Form-submit goBack: skipNextFocusRestore + backward → entry must be cleared, not left dangling. + skipNextFocusRestore(); + handleStateChange(backToProfile); + flushTransitions(); + expect(mockFireFocusEvent).not.toHaveBeenCalled(); + expect(getTriggerMapSizeForTests()).toBe(0); + + // Simulate a later deeplink-style forward (no fresh staged trigger) + back to "profile". + const intoNewScreen = stackState(1, [ + {key: 'profile', name: 'Profile'}, + {key: 'new-screen', name: 'NewScreen'}, + ]); + handleStateChange(intoNewScreen); + handleStateChange(backToProfile); + 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, [ diff --git a/tests/unit/NavigationFocusReturnTest.ts b/tests/unit/NavigationFocusReturnTest.ts index 29f2a1f08d5e..f9f9d82c73a7 100644 --- a/tests/unit/NavigationFocusReturnTest.ts +++ b/tests/unit/NavigationFocusReturnTest.ts @@ -1472,7 +1472,8 @@ describe('handleStateChange integration', () => { jest.runAllTimers(); expect(spy).not.toHaveBeenCalled(); - // The flag is one-shot: a subsequent Back-button dismissal restores normally. + // The flag is one-shot; the skipped entry was cleared, so the user must re-focus before forward to re-capture. + fireFocusIn(trigger); handleStateChange(onAB); trigger.blur(); handleStateChange(onA); @@ -1481,6 +1482,42 @@ describe('handleStateChange integration', () => { }); }); + it('skipNextFocusRestore drops the captured entry, so a later back to the same route cannot inherit the skipped trigger', () => { + withFakeTimers(() => { + simulateTab(); + const onA = stackState(0, [{key: 'a', name: 'A'}]); + const onAB = stackState(1, [ + {key: 'a', name: 'A'}, + {key: 'b', name: 'B'}, + ]); + const onAC = stackState(1, [ + {key: 'a', name: 'A'}, + {key: 'c', name: 'C'}, + ]); + handleStateChange(onA); + + const trigger = appendButton(); + fireFocusIn(trigger); + handleStateChange(onAB); + trigger.blur(); + + // Form-submit goBack: skipNextFocusRestore + backward → must clear the captured entry. + skipNextFocusRestore(); + const spy = jest.spyOn(trigger, 'focus'); + handleStateChange(onA); + jest.runAllTimers(); + expect(spy).not.toHaveBeenCalled(); + + // Deeplink-style forward (no fresh trigger) → capture bails, so the stale entry must not resurface on the next back. + setLastInteractiveElementForTests(null); + setLastMouseTriggerForTests(null); + handleStateChange(onAC); + handleStateChange(onA); + jest.runAllTimers(); + expect(spy).not.toHaveBeenCalled(); + }); + }); + it('skipNextFocusRestore flag is cleared by an intervening forward nav so it cannot leak into a later backward', () => { withFakeTimers(() => { simulateTab(); From 74f5b902d648b827f5f3cc7ed921a0dcc8990d19 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Wed, 27 May 2026 18:32:44 +0300 Subject: [PATCH 09/89] fix: capture trigger via ref pass-through; drop findNodeHandle usages --- .../implementation/BaseGenericPressable.tsx | 2 +- .../fireFocusEvent/index.android.ts | 5 -- .../scheduleRefocus/index.android.ts | 18 +++-- .../scheduleRefocus/index.ios.ts | 3 +- .../Accessibility/scheduleRefocus/index.ts | 3 +- src/libs/NavigationFocusReturn.native.ts | 41 +++++----- src/libs/NavigationFocusReturn.ts | 3 +- tests/unit/NavigationFocusReturnNativeTest.ts | 76 ++++++++----------- tests/unit/NavigationFocusReturnTest.ts | 5 -- tests/unit/fireFocusEventAndroidTest.ts | 16 +--- 10 files changed, 71 insertions(+), 101 deletions(-) diff --git a/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx b/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx index ebfa810d4eff..5919567e556d 100644 --- a/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx +++ b/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx @@ -126,7 +126,7 @@ function GenericPressable({ ref.current?.blur(); Accessibility.moveAccessibilityFocus(nextFocusRef); } - notifyPressedTrigger(internalRef.current); + notifyPressedTrigger(internalRef); return onPress(event); }, [shouldUseHapticsOnPress, onPress, nextFocusRef, ref, isDisabled, interactive], diff --git a/src/libs/Accessibility/fireFocusEvent/index.android.ts b/src/libs/Accessibility/fireFocusEvent/index.android.ts index b756746c1c5d..5e4bba351ec2 100644 --- a/src/libs/Accessibility/fireFocusEvent/index.android.ts +++ b/src/libs/Accessibility/fireFocusEvent/index.android.ts @@ -1,13 +1,8 @@ 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'; -// eslint-disable-next-line no-restricted-imports -- .android.ts only; the wrapper resolves to the native findNodeHandle, and the rule guards web bundles. -import findNodeHandle from '@src/utils/findNodeHandle'; function fireFocusEvent(view: View | RNText): void { - if (findNodeHandle(view) == null) { - return; - } AccessibilityInfo.sendAccessibilityEvent(view, 'focus'); } diff --git a/src/libs/Accessibility/scheduleRefocus/index.android.ts b/src/libs/Accessibility/scheduleRefocus/index.android.ts index 88f2e948ea6c..7a91910cb7c1 100644 --- a/src/libs/Accessibility/scheduleRefocus/index.android.ts +++ b/src/libs/Accessibility/scheduleRefocus/index.android.ts @@ -1,3 +1,4 @@ +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'; @@ -8,11 +9,18 @@ import fireFocusEvent from '@libs/Accessibility/fireFocusEvent'; */ const REFOCUS_TIMEOUT_MS = 300; -function scheduleRefocus(view: View | RNText): {cancel: () => void} { - const id = requestIdleCallback(() => fireFocusEvent(view), {timeout: REFOCUS_TIMEOUT_MS}); - return { - cancel: () => cancelIdleCallback(id), - }; +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.ios.ts b/src/libs/Accessibility/scheduleRefocus/index.ios.ts index 18ea190e38a6..7021ea105ded 100644 --- a/src/libs/Accessibility/scheduleRefocus/index.ios.ts +++ b/src/libs/Accessibility/scheduleRefocus/index.ios.ts @@ -1,3 +1,4 @@ +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'; @@ -6,7 +7,7 @@ import type {Text as RNText, View} from 'react-native'; * call, so no race-mitigation re-fire is needed. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars -function scheduleRefocus(_view: View | RNText): {cancel: () => void} { +function scheduleRefocus(_ref: RefObject): {cancel: () => void} { return {cancel: () => {}}; } diff --git a/src/libs/Accessibility/scheduleRefocus/index.ts b/src/libs/Accessibility/scheduleRefocus/index.ts index 7a717b89877e..ffdf71bd8c16 100644 --- a/src/libs/Accessibility/scheduleRefocus/index.ts +++ b/src/libs/Accessibility/scheduleRefocus/index.ts @@ -1,8 +1,9 @@ +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(_view: View | RNText): {cancel: () => void} { +function scheduleRefocus(_ref: RefObject): {cancel: () => void} { return {cancel: () => {}}; } diff --git a/src/libs/NavigationFocusReturn.native.ts b/src/libs/NavigationFocusReturn.native.ts index 7973d2906a31..837549a109b1 100644 --- a/src/libs/NavigationFocusReturn.native.ts +++ b/src/libs/NavigationFocusReturn.native.ts @@ -1,8 +1,6 @@ import type {NavigationState} from '@react-navigation/native'; import type {RefObject} from 'react'; import type {View} from 'react-native'; -// eslint-disable-next-line no-restricted-imports -- .native.ts only; the rule guards web bundles from pulling the native stub. -import findNodeHandle from '@src/utils/findNodeHandle'; import Accessibility from './Accessibility'; import fireFocusEvent from './Accessibility/fireFocusEvent'; import scheduleRefocus from './Accessibility/scheduleRefocus'; @@ -18,7 +16,7 @@ const TRIGGER_MAP_MAX = 64; // Drop stale presses so a delayed nav (timer / deeplink / async redirect) doesn't capture an unrelated trigger. const PRESS_TRIGGER_TTL_MS = 3_000; -let lastPressedTrigger: View | null = null; +let lastPressedTriggerRef: RefObject | null = null; let lastPressedTriggerAt = 0; const triggerMap = new Map(); let prevState: NavigationState | undefined; @@ -39,37 +37,36 @@ function setTriggerEntry(routeKey: string, entry: TriggerEntry): void { } } -function notifyPressedTrigger(node: View | null): void { +function notifyPressedTrigger(ref: RefObject | null): void { if (!Accessibility.isScreenReaderEnabledSync()) { return; } - lastPressedTrigger = node; - lastPressedTriggerAt = node ? Date.now() : 0; + lastPressedTriggerRef = ref; + lastPressedTriggerAt = ref ? Date.now() : 0; } function captureTriggerForRoute(routeKey: string): void { if (!Accessibility.isScreenReaderEnabledSync()) { return; } - if (!lastPressedTrigger || Date.now() - lastPressedTriggerAt > PRESS_TRIGGER_TTL_MS) { + if (!lastPressedTriggerRef || Date.now() - lastPressedTriggerAt > PRESS_TRIGGER_TTL_MS) { return; } - const ref: RefObject = {current: lastPressedTrigger}; - setTriggerEntry(routeKey, {ref}); + setTriggerEntry(routeKey, {ref: lastPressedTriggerRef}); } -function restoreTriggerForRoute(routeKey: string): View | null { +function restoreTriggerForRoute(routeKey: string): RefObject | null { const entry = triggerMap.get(routeKey); if (!entry) { return null; } const view = entry.ref.current; - // Truthy ref can still point to a detached View; findNodeHandle returns null then. - if (!view || findNodeHandle(view) == null) { + // `mergeRefs` nulls `.current` on Pressable unmount, so non-null here means still in React's tree. + if (!view) { return null; } fireFocusEvent(view); - return view; + return entry.ref; } function cancelPendingRestore(): void { @@ -86,13 +83,13 @@ function scheduleRestore(routeKey: string): void { if (cancelled) { return; } - const view = restoreTriggerForRoute(routeKey); + const ref = restoreTriggerForRoute(routeKey); triggerMap.delete(routeKey); - if (!view) { + if (!ref) { pendingRestore = null; return; } - refocusHandle = scheduleRefocus(view); + refocusHandle = scheduleRefocus(ref); }, }); @@ -115,7 +112,7 @@ function handleStateChange(newState: NavigationState | undefined): void { skipNextRestore = false; cancelPendingRestore(); captureTriggerForRoute(action.captureKey); - lastPressedTrigger = null; + lastPressedTriggerRef = null; } else if (action.type === 'backward') { if (skipNextRestore) { skipNextRestore = false; @@ -164,7 +161,7 @@ function teardownNavigationFocusReturn(): void { cancelPendingRestore(); prevState = undefined; triggerMap.clear(); - lastPressedTrigger = null; + lastPressedTriggerRef = null; lastPressedTriggerAt = 0; skipNextRestore = false; stateUnsubscribe?.(); @@ -204,9 +201,9 @@ function resetForTests(): void { teardownNavigationFocusReturn(); } -function setLastPressedTriggerForTests(node: View | null): void { - lastPressedTrigger = node; - lastPressedTriggerAt = node ? Date.now() : 0; +function setLastPressedTriggerRefForTests(ref: RefObject | null): void { + lastPressedTriggerRef = ref; + lastPressedTriggerAt = ref ? Date.now() : 0; } function getTriggerMapSizeForTests(): number { @@ -225,6 +222,6 @@ export { isFocusRestoreInProgress, shouldSkipAutoFocusDueToExistingFocus, resetForTests, - setLastPressedTriggerForTests, + setLastPressedTriggerRefForTests, getTriggerMapSizeForTests, }; diff --git a/src/libs/NavigationFocusReturn.ts b/src/libs/NavigationFocusReturn.ts index 77f5c7d407a5..2d4551dff27a 100644 --- a/src/libs/NavigationFocusReturn.ts +++ b/src/libs/NavigationFocusReturn.ts @@ -1,4 +1,5 @@ import type {NavigationState} from '@react-navigation/native'; +import type {RefObject} from 'react'; // eslint-disable-next-line no-restricted-imports -- idiomatic defer primitive past navigation transitions. import {InteractionManager} from 'react-native'; import type {View} from 'react-native'; @@ -101,7 +102,7 @@ function skipNextFocusRestore(): void { /** 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(_node: View | null): void {} +function notifyPressedTrigger(_ref: RefObject | null): void {} /** 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 { diff --git a/tests/unit/NavigationFocusReturnNativeTest.ts b/tests/unit/NavigationFocusReturnNativeTest.ts index 7bd56d1f3ff0..0e2d49828ad1 100644 --- a/tests/unit/NavigationFocusReturnNativeTest.ts +++ b/tests/unit/NavigationFocusReturnNativeTest.ts @@ -34,20 +34,6 @@ jest.mock('../../src/libs/Accessibility/fireFocusEvent', () => ({ AccessibilityInfo.sendAccessibilityEvent = mockSendAccessibilityEvent; -// jsdom resolves the web stub (returns null). Treat fake views as attached unless they carry `detached: true`. -jest.mock('@src/utils/findNodeHandle', () => ({ - __esModule: true, - default: (view: unknown): number | null => { - if (view == null) { - return null; - } - if (typeof view === 'object' && (view as {detached?: boolean}).detached) { - return null; - } - return 1; - }, -})); - type TtEntry = {cb: () => void; cancelled: boolean}; let mockTtQueue: TtEntry[] = []; jest.mock('../../src/libs/Navigation/TransitionTracker', () => ({ @@ -102,13 +88,13 @@ const { isFocusRestoreInProgress, shouldSkipAutoFocusDueToExistingFocus, resetForTests, - setLastPressedTriggerForTests, + setLastPressedTriggerRefForTests, getTriggerMapSizeForTests, } = require<{ setupNavigationFocusReturn: () => void; teardownNavigationFocusReturn: () => void; handleStateChange: (state: unknown) => void; - notifyPressedTrigger: (node: unknown) => void; + notifyPressedTrigger: (ref: unknown) => void; notifyPushParamsForward: (routeKey: string, prevParams: unknown) => void; notifyPushParamsBackward: (routeKey: string, targetParams: unknown) => void; cancelPendingFocusRestore: () => void; @@ -116,7 +102,7 @@ const { isFocusRestoreInProgress: () => boolean; shouldSkipAutoFocusDueToExistingFocus: () => boolean; resetForTests: () => void; - setLastPressedTriggerForTests: (node: unknown) => void; + setLastPressedTriggerRefForTests: (ref: unknown) => void; getTriggerMapSizeForTests: () => number; }>('../../src/libs/NavigationFocusReturn.native.ts'); /* eslint-enable import/extensions */ @@ -147,6 +133,10 @@ function fakeView(label = 'view'): {label: string} { return {label}; } +function fakeRef(view: unknown): {current: unknown} { + return {current: view}; +} + beforeEach(() => { jest.useFakeTimers(); mockSendAccessibilityEvent.mockClear(); @@ -165,7 +155,7 @@ afterEach(() => { describe('notifyPressedTrigger', () => { it('is a no-op when the screen reader is off — non-AT users pay zero capture cost', () => { mockScreenReaderEnabled = false; - notifyPressedTrigger(fakeView('button')); + notifyPressedTrigger(fakeRef(fakeView('button'))); const prev = stackState(0, [{key: 'a', name: 'A'}]); const next = stackState(1, [ {key: 'a', name: 'A'}, @@ -176,9 +166,8 @@ describe('notifyPressedTrigger', () => { expect(getTriggerMapSizeForTests()).toBe(0); }); - it('stores the most recently pressed view when the screen reader is on', () => { - const view = fakeView('button-1'); - notifyPressedTrigger(view); + 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'}, @@ -190,8 +179,8 @@ describe('notifyPressedTrigger', () => { }); it('overwrites the staged trigger on each press so the freshest tap wins', () => { - notifyPressedTrigger(fakeView('button-1')); - notifyPressedTrigger(fakeView('button-2')); + 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'}, @@ -205,7 +194,7 @@ describe('notifyPressedTrigger', () => { it('drops a stale press so a much-later forward nav (deeplink, timer) does not capture an unrelated trigger', () => { const before = Date.now(); jest.setSystemTime(before); - notifyPressedTrigger(fakeView('non-nav-toggle')); + notifyPressedTrigger(fakeRef(fakeView('non-nav-toggle'))); jest.setSystemTime(before + 4_000); const prev = stackState(0, [{key: 'a', name: 'A'}]); const next = stackState(1, [ @@ -220,8 +209,7 @@ describe('notifyPressedTrigger', () => { describe('handleStateChange — forward', () => { it('captures the staged trigger against the outgoing route key', () => { - const view = fakeView('display-name'); - setLastPressedTriggerForTests(view); + setLastPressedTriggerRefForTests(fakeRef(fakeView('display-name'))); const prev = stackState(0, [{key: 'profile', name: 'Profile'}]); const next = stackState(1, [ {key: 'profile', name: 'Profile'}, @@ -247,7 +235,7 @@ describe('handleStateChange — forward', () => { describe('handleStateChange — backward', () => { it('restores accessibility focus to the captured view after transitions flush', () => { const view = fakeView('display-name'); - setLastPressedTriggerForTests(view); + setLastPressedTriggerRefForTests(fakeRef(view)); const prev = stackState(0, [{key: 'profile', name: 'Profile'}]); const forward = stackState(1, [ {key: 'profile', name: 'Profile'}, @@ -267,8 +255,7 @@ describe('handleStateChange — backward', () => { }); it('does NOT restore when skipNextFocusRestore was called before goBack (form-submit path)', () => { - const view = fakeView('display-name'); - setLastPressedTriggerForTests(view); + setLastPressedTriggerRefForTests(fakeRef(fakeView('display-name'))); const prev = stackState(0, [{key: 'profile', name: 'Profile'}]); const forward = stackState(1, [ {key: 'profile', name: 'Profile'}, @@ -286,8 +273,7 @@ describe('handleStateChange — backward', () => { }); it('clears the trigger entry when skipNextFocusRestore suppresses the restore, so a later back to the same route cannot inherit the skipped trigger', () => { - const skippedTrigger = fakeView('display-name'); - setLastPressedTriggerForTests(skippedTrigger); + setLastPressedTriggerRefForTests(fakeRef(fakeView('display-name'))); const profile = stackState(0, [{key: 'profile', name: 'Profile'}]); const intoDisplayName = stackState(1, [ {key: 'profile', name: 'Profile'}, @@ -333,9 +319,10 @@ describe('handleStateChange — backward', () => { expect(mockFireFocusEvent).not.toHaveBeenCalled(); }); - it('does NOT call sendAccessibilityEvent when the captured view has been detached (parent screen replaced)', () => { - const view = {label: 'display-name', detached: true}; - setLastPressedTriggerForTests(view); + it('does NOT call sendAccessibilityEvent when the captured ref has been nulled (Pressable unmounted)', () => { + // The ref's `.current` going null is the ref-pass-through analog of a detached view. + const detachedRef = fakeRef(null); + setLastPressedTriggerRefForTests(detachedRef); const prev = stackState(0, [{key: 'profile', name: 'Profile'}]); const forward = stackState(1, [ {key: 'profile', name: 'Profile'}, @@ -352,7 +339,7 @@ describe('handleStateChange — backward', () => { }); it('cleans the trigger entry from the map after a successful restore', () => { - setLastPressedTriggerForTests(fakeView('display-name')); + setLastPressedTriggerRefForTests(fakeRef(fakeView('display-name'))); const prev = stackState(0, [{key: 'profile', name: 'Profile'}]); const forward = stackState(1, [ {key: 'profile', name: 'Profile'}, @@ -371,7 +358,7 @@ describe('handleStateChange — backward', () => { describe('handleStateChange — lateral & cleanup', () => { it('cancels a pending restore on a subsequent lateral tab switch', () => { - setLastPressedTriggerForTests(fakeView('display-name')); + setLastPressedTriggerRefForTests(fakeRef(fakeView('display-name'))); const initial = stackState(0, [{key: 'profile', name: 'Profile'}]); const forward = stackState(1, [ {key: 'profile', name: 'Profile'}, @@ -404,7 +391,7 @@ describe('handleStateChange — lateral & cleanup', () => { }); it('drops trigger entries for routes removed from the stack', () => { - setLastPressedTriggerForTests(fakeView('row-a')); + setLastPressedTriggerRefForTests(fakeRef(fakeView('row-a'))); const initial = stackState(0, [{key: 'profile', name: 'Profile'}]); const intoA = stackState(1, [ {key: 'profile', name: 'Profile'}, @@ -420,7 +407,7 @@ describe('handleStateChange — lateral & cleanup', () => { }); it('cancelPendingFocusRestore drops any queued restore', () => { - setLastPressedTriggerForTests(fakeView('display-name')); + setLastPressedTriggerRefForTests(fakeRef(fakeView('display-name'))); const initial = stackState(0, [{key: 'profile', name: 'Profile'}]); const forward = stackState(1, [ {key: 'profile', name: 'Profile'}, @@ -448,7 +435,7 @@ describe('setup / teardown', () => { }); it('teardown clears triggerMap and the staged trigger', () => { - setLastPressedTriggerForTests(fakeView('row')); + setLastPressedTriggerRefForTests(fakeRef(fakeView('row'))); handleStateChange(stackState(0, [{key: 'profile', name: 'Profile'}])); handleStateChange( stackState(1, [ @@ -466,20 +453,19 @@ 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 trigger = fakeView('search-tab-expense'); - setLastPressedTriggerForTests(trigger); + const view = fakeView('search-tab-expense'); + setLastPressedTriggerRefForTests(fakeRef(view)); notifyPushParamsForward(ROUTE_KEY, {q: 'old'}); expect(getTriggerMapSizeForTests()).toBe(1); notifyPushParamsBackward(ROUTE_KEY, {q: 'old'}); flushTransitions(); - expect(mockFireFocusEvent).toHaveBeenCalledWith(trigger); + expect(mockFireFocusEvent).toHaveBeenCalledWith(view); }); it('does NOT restore when the back targets a different params hash than the captured one', () => { - const trigger = fakeView('search-tab-expense'); - setLastPressedTriggerForTests(trigger); + setLastPressedTriggerRefForTests(fakeRef(fakeView('search-tab-expense'))); notifyPushParamsForward(ROUTE_KEY, {q: 'old'}); notifyPushParamsBackward(ROUTE_KEY, {q: 'unrelated'}); @@ -488,7 +474,7 @@ describe('PUSH_PARAMS — same-route param change', () => { }); it('drops compound entries when the route is removed from the tree', () => { - setLastPressedTriggerForTests(fakeView('search-tab-expense')); + setLastPressedTriggerRefForTests(fakeRef(fakeView('search-tab-expense'))); notifyPushParamsForward(ROUTE_KEY, {q: 'old'}); expect(getTriggerMapSizeForTests()).toBe(1); diff --git a/tests/unit/NavigationFocusReturnTest.ts b/tests/unit/NavigationFocusReturnTest.ts index f9f9d82c73a7..e1ff0a737860 100644 --- a/tests/unit/NavigationFocusReturnTest.ts +++ b/tests/unit/NavigationFocusReturnTest.ts @@ -1485,11 +1485,6 @@ describe('handleStateChange integration', () => { it('skipNextFocusRestore drops the captured entry, so a later back to the same route cannot inherit the skipped trigger', () => { withFakeTimers(() => { simulateTab(); - const onA = stackState(0, [{key: 'a', name: 'A'}]); - const onAB = stackState(1, [ - {key: 'a', name: 'A'}, - {key: 'b', name: 'B'}, - ]); const onAC = stackState(1, [ {key: 'a', name: 'A'}, {key: 'c', name: 'C'}, diff --git a/tests/unit/fireFocusEventAndroidTest.ts b/tests/unit/fireFocusEventAndroidTest.ts index eec79ffc4a1d..750803d00ef5 100644 --- a/tests/unit/fireFocusEventAndroidTest.ts +++ b/tests/unit/fireFocusEventAndroidTest.ts @@ -1,33 +1,19 @@ import {AccessibilityInfo} from 'react-native'; const mockSendAccessibilityEvent = jest.fn(); -let mockHandle: number | null = 1; - -jest.mock('@src/utils/findNodeHandle', () => ({ - __esModule: true, - default: () => mockHandle, -})); - AccessibilityInfo.sendAccessibilityEvent = mockSendAccessibilityEvent; const fireFocusEvent = require<{default: (view: unknown) => void}>('../../src/libs/Accessibility/fireFocusEvent/index.android').default; beforeEach(() => { mockSendAccessibilityEvent.mockClear(); - mockHandle = 1; }); describe('fireFocusEvent (Android)', () => { - it('dispatches sendAccessibilityEvent with `focus` when the view is attached', () => { + 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('skips when findNodeHandle returns null (detached view) — no event dispatched', () => { - mockHandle = null; - fireFocusEvent({label: 'detached'}); - expect(mockSendAccessibilityEvent).not.toHaveBeenCalled(); - }); }); From 801c6e7815617a0e9a38d31e79f79585e4cbdb05 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Wed, 27 May 2026 19:49:48 +0300 Subject: [PATCH 10/89] fix: initial focus on back button on screen mount --- src/components/HeaderWithBackButton/index.tsx | 12 +- src/hooks/useAccessibilityFocus/index.ts | 5 +- src/hooks/useDialogContainerFocus/index.ts | 28 +-- .../useScreenInitialFocus/index.native.ts | 6 + src/hooks/useScreenInitialFocus/index.ts | 62 ++++++ src/hooks/useScreenInitialFocus/types.ts | 5 + src/libs/isHTMLElement.ts | 6 + tests/unit/useScreenInitialFocusTest.tsx | 203 ++++++++++++++++++ 8 files changed, 302 insertions(+), 25 deletions(-) create mode 100644 src/hooks/useScreenInitialFocus/index.native.ts create mode 100644 src/hooks/useScreenInitialFocus/index.ts create mode 100644 src/hooks/useScreenInitialFocus/types.ts create mode 100644 src/libs/isHTMLElement.ts create mode 100644 tests/unit/useScreenInitialFocusTest.tsx diff --git a/src/components/HeaderWithBackButton/index.tsx b/src/components/HeaderWithBackButton/index.tsx index 18deb1a7e68e..1945ad1c3ca9 100755 --- a/src/components/HeaderWithBackButton/index.tsx +++ b/src/components/HeaderWithBackButton/index.tsx @@ -1,5 +1,7 @@ -import React, {useMemo} from 'react'; +import React, {useCallback, useMemo, useRef} from 'react'; import {Keyboard, StyleSheet, View} from 'react-native'; +// eslint-disable-next-line no-restricted-imports -- type-only; matches PressableRef's host-instance union for the back-button callback ref. +import type {Text as RNText} from 'react-native'; import type {SvgProps} from 'react-native-svg'; import ActivityIndicator from '@components/ActivityIndicator'; import Avatar from '@components/Avatar'; @@ -16,11 +18,13 @@ import useDialogLabelRegistration from '@hooks/useDialogLabelRegistration'; import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import useScreenInitialFocus from '@hooks/useScreenInitialFocus'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useThrottledButtonState from '@hooks/useThrottledButtonState'; import getButtonState from '@libs/getButtonState'; +import isHTMLElement from '@libs/isHTMLElement'; import Navigation from '@libs/Navigation/Navigation'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import variables from '@styles/variables'; @@ -90,6 +94,11 @@ function HeaderWithBackButton({ const [isDownloadButtonActive, temporarilyDisableDownloadButton] = useThrottledButtonState(); const {translate} = useLocalize(); const isInLandscapeMode = useIsInLandscapeMode(); + const backButtonRef = useRef(null); + const setBackButtonRef = useCallback((node: HTMLDivElement | View | RNText | null | undefined) => { + backButtonRef.current = isHTMLElement(node) ? node : null; + }, []); + useScreenInitialFocus(backButtonRef); const downloadReasonAttributes = useMemo( () => ({ @@ -244,6 +253,7 @@ function HeaderWithBackButton({ {shouldShowBackButton && ( { if (Keyboard.isVisible()) { Keyboard.dismiss(); diff --git a/src/hooks/useAccessibilityFocus/index.ts b/src/hooks/useAccessibilityFocus/index.ts index 7d764533546a..91c6694f1835 100644 --- a/src/hooks/useAccessibilityFocus/index.ts +++ b/src/hooks/useAccessibilityFocus/index.ts @@ -1,13 +1,10 @@ import {useEffect} from 'react'; +import isHTMLElement from '@libs/isHTMLElement'; import type UseAccessibilityFocus from './type'; const FOCUSABLE_ELEMENTS_SELECTOR = 'button, [href], [role="button"], [role="link"], [tabindex]:not([tabindex="-1"])'; const PROGRAMMATIC_FOCUS_DATA_ATTRIBUTE = 'data-programmatic-focus'; -function isHTMLElement(value: unknown): value is HTMLElement { - return typeof HTMLElement !== 'undefined' && value instanceof HTMLElement; -} - const useAccessibilityFocus: UseAccessibilityFocus = ({didScreenTransitionEnd, isFocused, ref, shouldMoveAccessibilityFocus}) => { useEffect(() => { if (!shouldMoveAccessibilityFocus || !didScreenTransitionEnd || !isFocused) { diff --git a/src/hooks/useDialogContainerFocus/index.ts b/src/hooks/useDialogContainerFocus/index.ts index b7678226c3a8..0b61b04150a8 100644 --- a/src/hooks/useDialogContainerFocus/index.ts +++ b/src/hooks/useDialogContainerFocus/index.ts @@ -1,9 +1,10 @@ import {useEffect} from 'react'; -// eslint-disable-next-line no-restricted-imports -- idiomatic defer primitive past navigation transitions. -import {InteractionManager} from 'react-native'; import FOCUSABLE_SELECTOR from '@libs/focusableSelector'; import hasFocusableAttributes from '@libs/focusGuards'; import getHadTabNavigation from '@libs/hadTabNavigation'; +import isHTMLElement from '@libs/isHTMLElement'; +// eslint-disable-next-line no-restricted-imports -- dialog initial-focus is a sibling primitive to TransitionTracker; we need the exact transitionEnd signal to land focus after the RHP slide completes. +import TransitionTracker from '@libs/Navigation/TransitionTracker'; import {Priorities, tryClaim} from '@libs/ScreenFocusArbiter'; import type UseDialogContainerFocus from './types'; @@ -30,26 +31,13 @@ const useDialogContainerFocus: UseDialogContainerFocus = (ref, isReady, claimIni if (!isReady || !claimInitialFocus?.()) { return; } - let cancelled = false; - let frameId: number; - // Deferred past useAutoFocusInput's InteractionManager + Promise chain. - const interactionHandle = InteractionManager.runAfterInteractions(() => { - if (cancelled) { - return; - } - frameId = requestAnimationFrame(() => { - if (cancelled) { - return; - } - const container = ref.current as unknown as HTMLElement | null; + const handle = TransitionTracker.runAfterTransitions({ + callback: () => { + const container = isHTMLElement(ref.current) ? ref.current : null; focusFirstInteractiveElement(container); - }); + }, }); - return () => { - cancelled = true; - interactionHandle.cancel(); - cancelAnimationFrame(frameId); - }; + return () => handle.cancel(); }, [isReady, ref, claimInitialFocus]); }; 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..757584a40f3a --- /dev/null +++ b/src/hooks/useScreenInitialFocus/index.ts @@ -0,0 +1,62 @@ +import {useContext, useEffect, useRef} from 'react'; +import ScreenWrapperStatusContext from '@components/ScreenWrapper/ScreenWrapperStatusContext'; +import hasHoverSupport from '@libs/DeviceCapabilities/hasHoverSupport'; +import getHadTabNavigation from '@libs/hadTabNavigation'; +import {Priorities, tryClaim} from '@libs/ScreenFocusArbiter'; +import type UseScreenInitialFocus from './types'; + +/* + * Pressables transformed off-screen (Growl notifications, drawer items pre-animation) still pass + * attribute checks; only geometry rules them out. Cheap inline test — no shared helper since this + * is the only consumer. + */ +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; +} + +/* + * Focuses the given element once per screen mount, after `didScreenTransitionEnd`. Used by + * `HeaderWithBackButton` to land focus on the back button when a screen opens — covers narrow + * mobile-web layouts (Chrome / Safari) where the RHP-only `useDialogContainerFocus` doesn't run. + * + * Hover-capable devices gate on Tab (WCAG 2.4.7 — mouse nav must not show focus rings); + * touch-primary devices bypass since Tab is unavailable and screen-reader users need the back + * button focused on mount. + */ +const useScreenInitialFocus: UseScreenInitialFocus = (ref) => { + const status = useContext(ScreenWrapperStatusContext); + const claimedRef = useRef(false); + + useEffect(() => { + if (!status?.didScreenTransitionEnd || claimedRef.current) { + return; + } + if (hasHoverSupport() && !getHadTabNavigation()) { + return; + } + if (document.activeElement && document.activeElement !== document.body) { + return; + } + const el = ref.current; + if (!el || !isOnScreen(el)) { + return; + } + if (!tryClaim(Priorities.INITIAL)) { + return; + } + claimedRef.current = true; + el.focus({preventScroll: true, focusVisible: true}); + }, [status?.didScreenTransitionEnd, ref]); +}; + +export default useScreenInitialFocus; diff --git a/src/hooks/useScreenInitialFocus/types.ts b/src/hooks/useScreenInitialFocus/types.ts new file mode 100644 index 000000000000..d295fd4ea70e --- /dev/null +++ b/src/hooks/useScreenInitialFocus/types.ts @@ -0,0 +1,5 @@ +import type {RefObject} from 'react'; + +type UseScreenInitialFocus = (ref: RefObject) => void; + +export default UseScreenInitialFocus; diff --git a/src/libs/isHTMLElement.ts b/src/libs/isHTMLElement.ts new file mode 100644 index 000000000000..518466f6e85a --- /dev/null +++ b/src/libs/isHTMLElement.ts @@ -0,0 +1,6 @@ +/** Typed guard. `typeof HTMLElement` check keeps it safe on native, where the global is undefined. */ +function isHTMLElement(value: unknown): value is HTMLElement { + return typeof HTMLElement !== 'undefined' && value instanceof HTMLElement; +} + +export default isHTMLElement; diff --git a/tests/unit/useScreenInitialFocusTest.tsx b/tests/unit/useScreenInitialFocusTest.tsx new file mode 100644 index 000000000000..164b7532dceb --- /dev/null +++ b/tests/unit/useScreenInitialFocusTest.tsx @@ -0,0 +1,203 @@ +import {render} from '@testing-library/react-native'; +import React, {useMemo, useRef} from 'react'; +import ScreenWrapperStatusContext from '@components/ScreenWrapper/ScreenWrapperStatusContext'; + +let mockHasHoverSupport = true; +jest.mock('@libs/DeviceCapabilities/hasHoverSupport', () => ({ + __esModule: true, + default: () => mockHasHoverSupport, +})); + +/* eslint-disable import/extensions */ +const {default: useScreenInitialFocus} = require<{default: (ref: React.RefObject) => 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} = require<{ + teardownHadTabNavigation: () => void; + setupHadTabNavigation: () => 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}; + +function MountedHarness({target, didScreenTransitionEnd}: HarnessProps) { + const contextValue = useMemo(() => ({didScreenTransitionEnd, isSafeAreaTopPaddingApplied: false, isSafeAreaBottomPaddingApplied: false}), [didScreenTransitionEnd]); + return ( + + + + ); +} +function Inner({target}: {target: HTMLElement | null}) { + const ref = useRef(target); + useScreenInitialFocus(ref); + 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; + teardownHadTabNavigation(); + 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}); + }); + + it('focuses the ref on touch-primary devices (no hover) regardless of Tab', () => { + mockHasHoverSupport = false; + simulatePointer(); + const button = makeButton(); + const spy = jest.spyOn(button, 'focus'); + render( + , + ); + expect(spy).toHaveBeenCalledWith({preventScroll: true, focusVisible: 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('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); + }); +}); From 4a372a4f92625cddcd8fa52947128a310f106ca3 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Wed, 27 May 2026 21:21:21 +0300 Subject: [PATCH 11/89] fix: registry-based focus return survives react-native-screens detach/reattach --- .../implementation/BaseGenericPressable.tsx | 19 +++- src/hooks/useScreenInitialFocus/index.ts | 14 +-- src/libs/NavigationFocusReturn.native.ts | 86 ++++++++++++++--- src/libs/NavigationFocusReturn.ts | 9 +- tests/unit/NavigationFocusReturnNativeTest.ts | 94 ++++++++++++++++++- 5 files changed, 194 insertions(+), 28 deletions(-) diff --git a/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx b/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx index 5919567e556d..1b866229d84a 100644 --- a/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx +++ b/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx @@ -1,4 +1,5 @@ -import React, {useCallback, useMemo, useRef, useState} from 'react'; +import {NavigationRouteContext} from '@react-navigation/native'; +import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; import type {GestureResponderEvent, View} from 'react-native'; // eslint-disable-next-line no-restricted-imports import {Pressable} from 'react-native'; @@ -11,7 +12,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import Accessibility from '@libs/Accessibility'; import HapticFeedback from '@libs/HapticFeedback'; import mergeRefs from '@libs/mergeRefs'; -import {notifyPressedTrigger} from '@libs/NavigationFocusReturn'; +import {notifyPressedTrigger, registerPressable} from '@libs/NavigationFocusReturn'; import CONST from '@src/CONST'; function GenericPressable({ @@ -53,6 +54,16 @@ function GenericPressable({ const isRoleButton = [rest.accessibilityRole, rest.role].includes(CONST.ROLE.BUTTON); const internalRef = useRef(null); const composedRef = useMemo(() => mergeRefs(ref, internalRef), [ref]); + const routeKey = useContext(NavigationRouteContext)?.key ?? null; + const focusIdentifier = rest.accessibilityLabel ?? undefined; + + // Survives react-native-screens detach/reattach: each remount re-registers the live ref under the same identifier. + useEffect(() => { + if (!routeKey || !focusIdentifier) { + return; + } + return registerPressable(routeKey, focusIdentifier, internalRef); + }, [routeKey, focusIdentifier]); const isDisabled = useMemo(() => { let shouldBeDisabledByScreenReader = false; @@ -126,10 +137,10 @@ function GenericPressable({ ref.current?.blur(); Accessibility.moveAccessibilityFocus(nextFocusRef); } - notifyPressedTrigger(internalRef); + notifyPressedTrigger(internalRef, focusIdentifier); return onPress(event); }, - [shouldUseHapticsOnPress, onPress, nextFocusRef, ref, isDisabled, interactive], + [shouldUseHapticsOnPress, onPress, nextFocusRef, ref, isDisabled, interactive, focusIdentifier], ); const voidOnPressHandler = useCallback( diff --git a/src/hooks/useScreenInitialFocus/index.ts b/src/hooks/useScreenInitialFocus/index.ts index 757584a40f3a..6ce455af0878 100644 --- a/src/hooks/useScreenInitialFocus/index.ts +++ b/src/hooks/useScreenInitialFocus/index.ts @@ -6,9 +6,7 @@ import {Priorities, tryClaim} from '@libs/ScreenFocusArbiter'; import type UseScreenInitialFocus from './types'; /* - * Pressables transformed off-screen (Growl notifications, drawer items pre-animation) still pass - * attribute checks; only geometry rules them out. Cheap inline test — no shared helper since this - * is the only consumer. + * Off-screen Pressables (Growls, pre-animation drawers) pass attribute checks; geometry rules them out. */ function isOnScreen(el: HTMLElement): boolean { const rect = el.getBoundingClientRect(); @@ -25,13 +23,9 @@ function isOnScreen(el: HTMLElement): boolean { } /* - * Focuses the given element once per screen mount, after `didScreenTransitionEnd`. Used by - * `HeaderWithBackButton` to land focus on the back button when a screen opens — covers narrow - * mobile-web layouts (Chrome / Safari) where the RHP-only `useDialogContainerFocus` doesn't run. - * - * Hover-capable devices gate on Tab (WCAG 2.4.7 — mouse nav must not show focus rings); - * touch-primary devices bypass since Tab is unavailable and screen-reader users need the back - * button focused on mount. + * Focuses `ref` once after `didScreenTransitionEnd` — mobile-web counterpart to the RHP-only + * `useDialogContainerFocus`. Hover-capable devices gate on Tab (WCAG 2.4.7 — mouse nav must not + * show focus rings); touch-primary devices bypass. */ const useScreenInitialFocus: UseScreenInitialFocus = (ref) => { const status = useContext(ScreenWrapperStatusContext); diff --git a/src/libs/NavigationFocusReturn.native.ts b/src/libs/NavigationFocusReturn.native.ts index 837549a109b1..83fe7be2db85 100644 --- a/src/libs/NavigationFocusReturn.native.ts +++ b/src/libs/NavigationFocusReturn.native.ts @@ -10,15 +10,17 @@ import navigationRef from './Navigation/navigationRef'; import TransitionTracker from './Navigation/TransitionTracker'; import {diffNavigationState} from './navigationStateDiff'; -type TriggerEntry = {ref: RefObject}; +type TriggerEntry = {ref: RefObject; identifier?: string}; const TRIGGER_MAP_MAX = 64; // Drop stale presses so a delayed nav (timer / deeplink / async redirect) doesn't capture an unrelated trigger. const PRESS_TRIGGER_TTL_MS = 3_000; 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; @@ -37,14 +39,37 @@ function setTriggerEntry(routeKey: string, entry: TriggerEntry): void { } } -function notifyPressedTrigger(ref: RefObject | null): void { +function notifyPressedTrigger(ref: RefObject | null, identifier?: string): void { if (!Accessibility.isScreenReaderEnabledSync()) { return; } lastPressedTriggerRef = ref; + lastPressedTriggerIdentifier = identifier ?? null; lastPressedTriggerAt = ref ? Date.now() : 0; } +function registerPressable(routeKey: string, identifier: string, ref: RefObject): () => void { + let routeMap = pressableRegistry.get(routeKey); + if (!routeMap) { + routeMap = new Map(); + pressableRegistry.set(routeKey, routeMap); + } + routeMap.set(identifier, ref); + return () => { + const map = pressableRegistry.get(routeKey); + if (!map) { + return; + } + // Guard against deregister-after-replace: a newer Pressable may have overwritten this identifier. + if (map.get(identifier) === ref) { + map.delete(identifier); + } + if (map.size === 0) { + pressableRegistry.delete(routeKey); + } + }; +} + function captureTriggerForRoute(routeKey: string): void { if (!Accessibility.isScreenReaderEnabledSync()) { return; @@ -52,21 +77,29 @@ function captureTriggerForRoute(routeKey: string): void { if (!lastPressedTriggerRef || Date.now() - lastPressedTriggerAt > PRESS_TRIGGER_TTL_MS) { return; } - setTriggerEntry(routeKey, {ref: lastPressedTriggerRef}); + setTriggerEntry(routeKey, {ref: lastPressedTriggerRef, identifier: lastPressedTriggerIdentifier ?? undefined}); } +// Fast path = captured ref still alive. Fallback = ref nulled by `react-native-screens` detach; resolve via the registry's live re-registration. function restoreTriggerForRoute(routeKey: string): RefObject | null { const entry = triggerMap.get(routeKey); if (!entry) { return null; } - const view = entry.ref.current; - // `mergeRefs` nulls `.current` on Pressable unmount, so non-null here means still in React's tree. + let ref: RefObject = entry.ref; + let view = ref.current; + if (!view && entry.identifier) { + const liveRef = pressableRegistry.get(routeKey)?.get(entry.identifier); + if (liveRef?.current) { + ref = liveRef; + view = liveRef.current; + } + } if (!view) { return null; } fireFocusEvent(view); - return entry.ref; + return ref; } function cancelPendingRestore(): void { @@ -78,18 +111,31 @@ function scheduleRestore(routeKey: string): void { cancelPendingRestore(); let cancelled = false; let refocusHandle: {cancel: () => void} | null = null; + let rafHandle: number | null = null; const handle = TransitionTracker.runAfterTransitions({ callback: () => { if (cancelled) { return; } const ref = restoreTriggerForRoute(routeKey); - triggerMap.delete(routeKey); - if (!ref) { - pendingRestore = null; + if (ref) { + triggerMap.delete(routeKey); + refocusHandle = scheduleRefocus(ref); return; } - refocusHandle = scheduleRefocus(ref); + // Re-attach can lag transitionEnd by a frame; the new Pressable's mount effect will have populated the registry by next rAF. + rafHandle = requestAnimationFrame(() => { + if (cancelled) { + return; + } + const retryRef = restoreTriggerForRoute(routeKey); + triggerMap.delete(routeKey); + if (!retryRef) { + pendingRestore = null; + return; + } + refocusHandle = scheduleRefocus(retryRef); + }); }, }); @@ -98,6 +144,9 @@ function scheduleRestore(routeKey: string): void { cancelled = true; handle.cancel(); refocusHandle?.cancel(); + if (rafHandle !== null) { + cancelAnimationFrame(rafHandle); + } }, }; } @@ -113,6 +162,7 @@ function handleStateChange(newState: NavigationState | undefined): void { cancelPendingRestore(); captureTriggerForRoute(action.captureKey); lastPressedTriggerRef = null; + lastPressedTriggerIdentifier = null; } else if (action.type === 'backward') { if (skipNextRestore) { skipNextRestore = false; @@ -128,6 +178,7 @@ function handleStateChange(newState: NavigationState | undefined): void { 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)) { @@ -161,7 +212,9 @@ function teardownNavigationFocusReturn(): void { cancelPendingRestore(); prevState = undefined; triggerMap.clear(); + pressableRegistry.clear(); lastPressedTriggerRef = null; + lastPressedTriggerIdentifier = null; lastPressedTriggerAt = 0; skipNextRestore = false; stateUnsubscribe?.(); @@ -201,8 +254,9 @@ function resetForTests(): void { teardownNavigationFocusReturn(); } -function setLastPressedTriggerRefForTests(ref: RefObject | null): void { +function setLastPressedTriggerRefForTests(ref: RefObject | null, identifier?: string): void { lastPressedTriggerRef = ref; + lastPressedTriggerIdentifier = identifier ?? null; lastPressedTriggerAt = ref ? Date.now() : 0; } @@ -210,11 +264,20 @@ function getTriggerMapSizeForTests(): number { return triggerMap.size; } +function getRegistrySizeForTests(): number { + let total = 0; + for (const m of pressableRegistry.values()) { + total += m.size; + } + return total; +} + export { setupNavigationFocusReturn, teardownNavigationFocusReturn, handleStateChange, notifyPressedTrigger, + registerPressable, notifyPushParamsForward, notifyPushParamsBackward, cancelPendingFocusRestore, @@ -224,4 +287,5 @@ export { resetForTests, setLastPressedTriggerRefForTests, getTriggerMapSizeForTests, + getRegistrySizeForTests, }; diff --git a/src/libs/NavigationFocusReturn.ts b/src/libs/NavigationFocusReturn.ts index 2d4551dff27a..3b78c5d410f9 100644 --- a/src/libs/NavigationFocusReturn.ts +++ b/src/libs/NavigationFocusReturn.ts @@ -102,7 +102,13 @@ function skipNextFocusRestore(): void { /** 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): void {} +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 { @@ -484,6 +490,7 @@ export { cancelPendingFocusRestore, skipNextFocusRestore, notifyPressedTrigger, + registerPressable, isFocusRestoreInProgress, compoundParamsKey, shouldSkipAutoFocusDueToExistingFocus, diff --git a/tests/unit/NavigationFocusReturnNativeTest.ts b/tests/unit/NavigationFocusReturnNativeTest.ts index 0e2d49828ad1..fc2e0e0b72e5 100644 --- a/tests/unit/NavigationFocusReturnNativeTest.ts +++ b/tests/unit/NavigationFocusReturnNativeTest.ts @@ -81,6 +81,7 @@ const { teardownNavigationFocusReturn, handleStateChange, notifyPressedTrigger, + registerPressable, notifyPushParamsForward, notifyPushParamsBackward, cancelPendingFocusRestore, @@ -90,11 +91,13 @@ const { resetForTests, setLastPressedTriggerRefForTests, getTriggerMapSizeForTests, + getRegistrySizeForTests, } = require<{ setupNavigationFocusReturn: () => void; teardownNavigationFocusReturn: () => void; handleStateChange: (state: unknown) => void; - notifyPressedTrigger: (ref: 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; @@ -102,8 +105,9 @@ const { isFocusRestoreInProgress: () => boolean; shouldSkipAutoFocusDueToExistingFocus: () => boolean; resetForTests: () => void; - setLastPressedTriggerRefForTests: (ref: unknown) => void; + setLastPressedTriggerRefForTests: (ref: unknown, identifier?: string) => void; getTriggerMapSizeForTests: () => number; + getRegistrySizeForTests: () => number; }>('../../src/libs/NavigationFocusReturn.native.ts'); /* eslint-enable import/extensions */ @@ -484,6 +488,92 @@ describe('PUSH_PARAMS — same-route param change', () => { }); }); +describe('pressable registry — identifier-based fallback', () => { + it('registers and deregisters by routeKey + identifier', () => { + const ref = fakeRef(fakeView('row')); + const deregister = registerPressable('A', 'Members', ref); + expect(getRegistrySizeForTests()).toBe(1); + deregister(); + expect(getRegistrySizeForTests()).toBe(0); + }); + + it('deregister is a no-op once another Pressable has overwritten the same identifier (remount race)', () => { + const oldRef = fakeRef(fakeView('old')); + const newRef = fakeRef(fakeView('new')); + const deregisterOld = registerPressable('A', 'Members', oldRef); + registerPressable('A', 'Members', newRef); + 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')); + setLastPressedTriggerRefForTests(detachedRef, 'Members'); + + const prev = stackState(0, [{key: 'A', name: 'WorkspaceInitial'}]); + const forward = stackState(1, [ + {key: 'A', name: 'WorkspaceInitial'}, + {key: 'B', name: 'WorkspaceMembers'}, + ]); + handleStateChange(prev); + handleStateChange(forward); + + // Detach simulation: react-native-screens nulls the captured View ref. + detachedRef.current = null; + // Re-attach simulation: a new Pressable mounts on the sidebar with the same identifier and a live View. + const liveView = fakeView('row-remount'); + const liveRef = fakeRef(liveView); + registerPressable('A', 'Members', liveRef); + + const back = stackState(0, [{key: 'A', name: 'WorkspaceInitial'}]); + handleStateChange(back); + flushTransitions(); + + expect(mockFireFocusEvent).toHaveBeenCalledWith(liveView); + }); + + it('rAF retry rescues focus when re-attach lags transitionEnd', () => { + const detachedRef = fakeRef(fakeView('row')); + setLastPressedTriggerRefForTests(detachedRef, 'Members'); + + const prev = stackState(0, [{key: 'A', name: 'WorkspaceInitial'}]); + const forward = stackState(1, [ + {key: 'A', name: 'WorkspaceInitial'}, + {key: 'B', name: 'WorkspaceMembers'}, + ]); + handleStateChange(prev); + handleStateChange(forward); + + detachedRef.current = null; + handleStateChange(stackState(0, [{key: 'A', name: 'WorkspaceInitial'}])); + flushTransitions(); + // No registry hit on transitionEnd: nothing fires yet. + expect(mockFireFocusEvent).not.toHaveBeenCalled(); + + // The new Pressable mounts a frame later. + const liveView = fakeView('row-remount'); + registerPressable('A', 'Members', fakeRef(liveView)); + jest.advanceTimersByTime(20); + + expect(mockFireFocusEvent).toHaveBeenCalledWith(liveView); + }); + + it('clears the registry for a route key when that route is removed from the navigation tree', () => { + registerPressable('B', 'Member-Row', fakeRef(fakeView('row'))); + expect(getRegistrySizeForTests()).toBe(1); + + handleStateChange( + stackState(1, [ + {key: 'A', name: 'WorkspaceInitial'}, + {key: 'B', name: 'WorkspaceMembers'}, + ]), + ); + handleStateChange(stackState(0, [{key: 'A', name: 'WorkspaceInitial'}])); + + expect(getRegistrySizeForTests()).toBe(0); + }); +}); + describe('web-only stubs return constant values on native', () => { it('isFocusRestoreInProgress always returns false', () => { expect(isFocusRestoreInProgress()).toBe(false); From 43d16d8bf229947d553f8e6b920c4e2b3131de15 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Wed, 27 May 2026 21:53:55 +0300 Subject: [PATCH 12/89] fix: disambiguate registry by id/nativeID/testID before accessibilityLabel --- .../implementation/BaseGenericPressable.tsx | 4 +-- tests/unit/NavigationFocusReturnNativeTest.ts | 35 +++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx b/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx index 1b866229d84a..5365e172fa6d 100644 --- a/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx +++ b/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx @@ -55,9 +55,9 @@ function GenericPressable({ const internalRef = useRef(null); const composedRef = useMemo(() => mergeRefs(ref, internalRef), [ref]); const routeKey = useContext(NavigationRouteContext)?.key ?? null; - const focusIdentifier = rest.accessibilityLabel ?? undefined; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- `||` is intentional: `BaseListItem` passes `id={keyForList ?? ''}` so empty-string ids must fall through. + const focusIdentifier = rest.id || rest.nativeID || rest.testID || rest.accessibilityLabel || undefined; - // Survives react-native-screens detach/reattach: each remount re-registers the live ref under the same identifier. useEffect(() => { if (!routeKey || !focusIdentifier) { return; diff --git a/tests/unit/NavigationFocusReturnNativeTest.ts b/tests/unit/NavigationFocusReturnNativeTest.ts index fc2e0e0b72e5..2b348e899054 100644 --- a/tests/unit/NavigationFocusReturnNativeTest.ts +++ b/tests/unit/NavigationFocusReturnNativeTest.ts @@ -558,6 +558,41 @@ describe('pressable registry — identifier-based fallback', () => { expect(mockFireFocusEvent).toHaveBeenCalledWith(liveView); }); + it('stores two same-route entries under distinct identifiers — duplicate-label rows do NOT collide when distinct ids exist', () => { + const rowAEdit = fakeRef(fakeView('edit-a')); + const rowBEdit = fakeRef(fakeView('edit-b')); + registerPressable('A', 'base-list-item-natu26+305', rowAEdit); + registerPressable('A', 'base-list-item-natu26+306', rowBEdit); + expect(getRegistrySizeForTests()).toBe(2); + }); + + it('fallback resolves the captured identifier even when other same-label registry entries exist for the route', () => { + // Simulate two rows with the same accessibilityLabel "Edit" but distinct ids (the per-pressable disambiguation we get for free via the fallback chain). + const pressedRef = fakeRef(fakeView('edit-on-row-A')); + const otherRef = fakeRef(fakeView('edit-on-row-B')); + setLastPressedTriggerRefForTests(pressedRef, 'base-list-item-natu26+305'); + + handleStateChange(stackState(0, [{key: 'A', name: 'WorkspaceMembers'}])); + handleStateChange( + stackState(1, [ + {key: 'A', name: 'WorkspaceMembers'}, + {key: 'B', name: 'MemberDetails'}, + ]), + ); + + // Detach + re-register under both ids. Only the pressed-row's identifier should resolve. + pressedRef.current = null; + const liveA = fakeView('edit-on-row-A-remount'); + registerPressable('A', 'base-list-item-natu26+305', fakeRef(liveA)); + registerPressable('A', 'base-list-item-natu26+306', otherRef); + + handleStateChange(stackState(0, [{key: 'A', name: 'WorkspaceMembers'}])); + flushTransitions(); + + expect(mockFireFocusEvent).toHaveBeenCalledWith(liveA); + expect(mockFireFocusEvent).not.toHaveBeenCalledWith(otherRef.current); + }); + it('clears the registry for a route key when that route is removed from the navigation tree', () => { registerPressable('B', 'Member-Row', fakeRef(fakeView('row'))); expect(getRegistrySizeForTests()).toBe(1); From f688ab06458667823e08263e97e713167e548617 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Wed, 27 May 2026 22:28:39 +0300 Subject: [PATCH 13/89] fix: distinguish programmatic focus via DOM attribute --- .../ActiveElementRoleProvider/index.tsx | 16 +++++- src/hooks/useAccessibilityFocus/index.ts | 12 ++--- src/libs/NavigationFocusReturn.native.ts | 19 +------ src/libs/NavigationFocusReturn.ts | 22 ++------ src/libs/programmaticFocus.ts | 17 ++++++ .../request/step/IOURequestStepMerchant.tsx | 3 -- tests/unit/NavigationFocusReturnNativeTest.ts | 51 ------------------ tests/unit/NavigationFocusReturnTest.ts | 53 +++---------------- 8 files changed, 45 insertions(+), 148 deletions(-) create mode 100644 src/libs/programmaticFocus.ts diff --git a/src/components/ActiveElementRoleProvider/index.tsx b/src/components/ActiveElementRoleProvider/index.tsx index 630af8618c08..e9a5966792d2 100644 --- a/src/components/ActiveElementRoleProvider/index.tsx +++ b/src/components/ActiveElementRoleProvider/index.tsx @@ -1,15 +1,27 @@ import React, {useEffect, useState} from 'react'; +import {PROGRAMMATIC_FOCUS_DATA_ATTRIBUTE} from '@libs/programmaticFocus'; import type {ActiveElementRoleContextValue, ActiveElementRoleProps} from './types'; const ActiveElementRoleContext = React.createContext({ role: null, }); +/* + * Suppress the role on a11y-restored elements so role-based consumers + * (`Button` enter-shortcut suppression) don't react to a programmatic focus. + */ +function getRoleForActive(el: Element | null): string | null { + if (el?.getAttribute(PROGRAMMATIC_FOCUS_DATA_ATTRIBUTE) === 'true') { + return null; + } + return el?.role ?? null; +} + function ActiveElementRoleProvider({children}: ActiveElementRoleProps) { - const [activeRoleRef, setRole] = useState(document?.activeElement?.role ?? null); + const [activeRoleRef, setRole] = useState(() => getRoleForActive(document?.activeElement ?? null)); const handleFocusIn = () => { - setRole(document?.activeElement?.role ?? null); + setRole(getRoleForActive(document?.activeElement ?? null)); }; const handleFocusOut = () => { diff --git a/src/hooks/useAccessibilityFocus/index.ts b/src/hooks/useAccessibilityFocus/index.ts index 91c6694f1835..0c2b0f10118b 100644 --- a/src/hooks/useAccessibilityFocus/index.ts +++ b/src/hooks/useAccessibilityFocus/index.ts @@ -1,9 +1,9 @@ import {useEffect} from 'react'; import isHTMLElement from '@libs/isHTMLElement'; +import {markProgrammaticFocus} from '@libs/programmaticFocus'; import type UseAccessibilityFocus from './type'; const FOCUSABLE_ELEMENTS_SELECTOR = 'button, [href], [role="button"], [role="link"], [tabindex]:not([tabindex="-1"])'; -const PROGRAMMATIC_FOCUS_DATA_ATTRIBUTE = 'data-programmatic-focus'; const useAccessibilityFocus: UseAccessibilityFocus = ({didScreenTransitionEnd, isFocused, ref, shouldMoveAccessibilityFocus}) => { useEffect(() => { @@ -36,12 +36,7 @@ const useAccessibilityFocus: UseAccessibilityFocus = ({didScreenTransitionEnd, i return; } - const removeProgrammaticFocusAttr = () => { - focusTarget.removeAttribute(PROGRAMMATIC_FOCUS_DATA_ATTRIBUTE); - }; - - focusTarget.setAttribute(PROGRAMMATIC_FOCUS_DATA_ATTRIBUTE, 'true'); - focusTarget.addEventListener('blur', removeProgrammaticFocusAttr, {once: true}); + const unmarkProgrammaticFocus = markProgrammaticFocus(focusTarget); focusTarget.focus(); const focusedElement = document.activeElement; @@ -49,8 +44,7 @@ const useAccessibilityFocus: UseAccessibilityFocus = ({didScreenTransitionEnd, i return; } - focusTarget.removeEventListener('blur', removeProgrammaticFocusAttr); - removeProgrammaticFocusAttr(); + unmarkProgrammaticFocus(); } }, [didScreenTransitionEnd, isFocused, ref, shouldMoveAccessibilityFocus]); }; diff --git a/src/libs/NavigationFocusReturn.native.ts b/src/libs/NavigationFocusReturn.native.ts index 83fe7be2db85..1a111b8748e8 100644 --- a/src/libs/NavigationFocusReturn.native.ts +++ b/src/libs/NavigationFocusReturn.native.ts @@ -13,7 +13,6 @@ import {diffNavigationState} from './navigationStateDiff'; type TriggerEntry = {ref: RefObject; identifier?: string}; const TRIGGER_MAP_MAX = 64; -// Drop stale presses so a delayed nav (timer / deeplink / async redirect) doesn't capture an unrelated trigger. const PRESS_TRIGGER_TTL_MS = 3_000; let lastPressedTriggerRef: RefObject | null = null; @@ -23,7 +22,6 @@ 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; // Delete-then-set so a re-set moves the key to the tail and FIFO eviction drops the truly oldest. @@ -158,21 +156,13 @@ function handleStateChange(newState: NavigationState | undefined): void { const {action, removedKeys} = diffNavigationState(prevState, newState); if (action.type === 'forward') { - skipNextRestore = false; cancelPendingRestore(); captureTriggerForRoute(action.captureKey); lastPressedTriggerRef = null; lastPressedTriggerIdentifier = null; } else if (action.type === 'backward') { - if (skipNextRestore) { - skipNextRestore = false; - cancelPendingRestore(); - triggerMap.delete(action.restoreKey); - } else { - scheduleRestore(action.restoreKey); - } + scheduleRestore(action.restoreKey); } else if (action.type === 'lateral') { - skipNextRestore = false; cancelPendingRestore(); } @@ -216,16 +206,10 @@ function teardownNavigationFocusReturn(): void { lastPressedTriggerRef = null; lastPressedTriggerIdentifier = null; lastPressedTriggerAt = 0; - skipNextRestore = false; stateUnsubscribe?.(); stateUnsubscribe = null; } -/** Skip the next backward restore; call before a form-submit goBack. */ -function skipNextFocusRestore(): void { - skipNextRestore = true; -} - /** PUSH_PARAMS reuses the focused key, so `diffNavigationState` reports `noop`; key against `routeKey + params`. */ function notifyPushParamsForward(routeKey: string, prevParams: unknown): void { cancelPendingRestore(); @@ -281,7 +265,6 @@ export { notifyPushParamsForward, notifyPushParamsBackward, cancelPendingFocusRestore, - skipNextFocusRestore, isFocusRestoreInProgress, shouldSkipAutoFocusDueToExistingFocus, resetForTests, diff --git a/src/libs/NavigationFocusReturn.ts b/src/libs/NavigationFocusReturn.ts index 3b78c5d410f9..da92dc140a23 100644 --- a/src/libs/NavigationFocusReturn.ts +++ b/src/libs/NavigationFocusReturn.ts @@ -10,6 +10,7 @@ import getHadTabNavigation from './hadTabNavigation'; import {consumeLauncher, pickLauncher, resetLauncherStackForTests} from './LauncherStack'; import navigationRef from './Navigation/navigationRef'; import {collectRouteKeys, diffNavigationState} from './navigationStateDiff'; +import {markProgrammaticFocus} from './programmaticFocus'; import {isCycleIdle, Priorities, resetCycle, tryClaim} from './ScreenFocusArbiter'; /** focusin tracks the last keyboard-focused element; a nav state listener captures it against the outgoing route and restores it on backward nav. */ @@ -43,7 +44,6 @@ function setTriggerEntry(routeKey: string, entry: TriggerEntry): void { let prevState: NavigationState | undefined; let pendingRestore: {cancel: () => void} | null = null; -let skipNextRestore = false; let isRestoringFocus = false; let focusinHandler: ((e: FocusEvent) => void) | null = null; let mouseActivationHandler: ((e: MouseEvent) => void) | null = null; @@ -95,11 +95,6 @@ function notifyPushParamsBackward(routeKey: string, targetParams: unknown): void scheduleRestore(compoundParamsKey(routeKey, targetParams)); } -/** 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 {} @@ -238,6 +233,7 @@ function restoreTriggerForRoute(routeKey: string): boolean { for (const candidate of candidates) { const before = document.activeElement; isRestoringFocus = true; + const unmarkProgrammaticFocus = markProgrammaticFocus(candidate); try { candidate.focus(focusOptions); } finally { @@ -257,6 +253,7 @@ function restoreTriggerForRoute(routeKey: string): boolean { scheduleReturnHoldRelease(); return true; } + unmarkProgrammaticFocus(); } // Silent no-op (transient display:none / visibility:hidden ancestor) — leave the entry for scheduleRestore to retry; release the cycle so AUTO/INITIAL aren't blocked during the window. @@ -338,7 +335,6 @@ function handleStateChange(newState: NavigationState | undefined): void { } if (action.type === 'forward') { - skipNextRestore = false; cancelPendingRestore(); captureTriggerForRoute(action.captureKey); // Loose refs would pin detached unmounted nodes; triggerMap holds the captured copy. @@ -346,15 +342,8 @@ function handleStateChange(newState: NavigationState | undefined): void { lastMouseTrigger = null; lastMouseTriggerAt = 0; } else if (action.type === 'backward') { - if (skipNextRestore) { - skipNextRestore = false; - cancelPendingRestore(); - triggerMap.delete(action.restoreKey); - } else { - scheduleRestore(action.restoreKey); - } + scheduleRestore(action.restoreKey); } else if (action.type === 'lateral') { - skipNextRestore = false; // Stale restore would steal focus back on sibling nav. cancelPendingRestore(); } @@ -437,7 +426,6 @@ function teardownNavigationFocusReturn(): void { lastInteractiveElement = null; lastMouseTrigger = null; lastMouseTriggerAt = 0; - skipNextRestore = false; if (typeof document !== 'undefined') { if (focusinHandler) { document.removeEventListener('focusin', focusinHandler, true); @@ -465,7 +453,6 @@ function resetForTests(): void { lastMouseTrigger = null; lastMouseTriggerAt = 0; lastRestoreTarget = null; - skipNextRestore = false; } function setLastInteractiveElementForTests(element: HTMLElement | null): void { @@ -488,7 +475,6 @@ export { notifyPushParamsForward, notifyPushParamsBackward, cancelPendingFocusRestore, - skipNextFocusRestore, notifyPressedTrigger, registerPressable, isFocusRestoreInProgress, diff --git a/src/libs/programmaticFocus.ts b/src/libs/programmaticFocus.ts new file mode 100644 index 000000000000..6de8c5fbdaff --- /dev/null +++ b/src/libs/programmaticFocus.ts @@ -0,0 +1,17 @@ +const PROGRAMMATIC_FOCUS_DATA_ATTRIBUTE = 'data-programmatic-focus'; + +/** + * Marks `el` as receiving programmatic (a11y-restore) focus so role-based consumers (`Button` enter-shortcut suppression) skip it on a later keypress. + * 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 {PROGRAMMATIC_FOCUS_DATA_ATTRIBUTE, markProgrammaticFocus}; diff --git a/src/pages/iou/request/step/IOURequestStepMerchant.tsx b/src/pages/iou/request/step/IOURequestStepMerchant.tsx index 5e1787752074..eecf26d778e5 100644 --- a/src/pages/iou/request/step/IOURequestStepMerchant.tsx +++ b/src/pages/iou/request/step/IOURequestStepMerchant.tsx @@ -17,7 +17,6 @@ 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'; @@ -85,8 +84,6 @@ function IOURequestStepMerchant({ return; } shouldNavigateAfterSaveRef.current = false; - // Only on the save path. The Back button (onBackButtonPress) should still restore focus. - skipNextFocusRestore(); navigateBack(); }, [isSaved, navigateBack]); diff --git a/tests/unit/NavigationFocusReturnNativeTest.ts b/tests/unit/NavigationFocusReturnNativeTest.ts index 2b348e899054..4597383ee320 100644 --- a/tests/unit/NavigationFocusReturnNativeTest.ts +++ b/tests/unit/NavigationFocusReturnNativeTest.ts @@ -85,7 +85,6 @@ const { notifyPushParamsForward, notifyPushParamsBackward, cancelPendingFocusRestore, - skipNextFocusRestore, isFocusRestoreInProgress, shouldSkipAutoFocusDueToExistingFocus, resetForTests, @@ -101,7 +100,6 @@ const { notifyPushParamsForward: (routeKey: string, prevParams: unknown) => void; notifyPushParamsBackward: (routeKey: string, targetParams: unknown) => void; cancelPendingFocusRestore: () => void; - skipNextFocusRestore: () => void; isFocusRestoreInProgress: () => boolean; shouldSkipAutoFocusDueToExistingFocus: () => boolean; resetForTests: () => void; @@ -258,55 +256,6 @@ describe('handleStateChange — backward', () => { expect(mockFireFocusEvent).toHaveBeenCalledWith(view); }); - it('does NOT restore when skipNextFocusRestore was called before goBack (form-submit path)', () => { - setLastPressedTriggerRefForTests(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); - skipNextFocusRestore(); - handleStateChange(back); - flushTransitions(); - - expect(mockFireFocusEvent).not.toHaveBeenCalled(); - }); - - it('clears the trigger entry when skipNextFocusRestore suppresses the restore, so a later back to the same route cannot inherit the skipped trigger', () => { - setLastPressedTriggerRefForTests(fakeRef(fakeView('display-name'))); - const profile = stackState(0, [{key: 'profile', name: 'Profile'}]); - const intoDisplayName = stackState(1, [ - {key: 'profile', name: 'Profile'}, - {key: 'display-name-page', name: 'DisplayName'}, - ]); - const backToProfile = stackState(0, [{key: 'profile', name: 'Profile'}]); - - handleStateChange(profile); - handleStateChange(intoDisplayName); - expect(getTriggerMapSizeForTests()).toBe(1); - - // Form-submit goBack: skipNextFocusRestore + backward → entry must be cleared, not left dangling. - skipNextFocusRestore(); - handleStateChange(backToProfile); - flushTransitions(); - expect(mockFireFocusEvent).not.toHaveBeenCalled(); - expect(getTriggerMapSizeForTests()).toBe(0); - - // Simulate a later deeplink-style forward (no fresh staged trigger) + back to "profile". - const intoNewScreen = stackState(1, [ - {key: 'profile', name: 'Profile'}, - {key: 'new-screen', name: 'NewScreen'}, - ]); - handleStateChange(intoNewScreen); - handleStateChange(backToProfile); - 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, [ diff --git a/tests/unit/NavigationFocusReturnTest.ts b/tests/unit/NavigationFocusReturnTest.ts index e1ff0a737860..514e64dd9797 100644 --- a/tests/unit/NavigationFocusReturnTest.ts +++ b/tests/unit/NavigationFocusReturnTest.ts @@ -21,7 +21,6 @@ const { notifyPushParamsForward, notifyPushParamsBackward, cancelPendingFocusRestore, - skipNextFocusRestore, isFocusRestoreInProgress, compoundParamsKey, shouldSkipAutoFocusDueToExistingFocus, @@ -39,7 +38,6 @@ const { notifyPushParamsForward: (routeKey: string, prevParams: unknown) => void; notifyPushParamsBackward: (routeKey: string, targetParams: unknown) => void; cancelPendingFocusRestore: () => void; - skipNextFocusRestore: () => void; isFocusRestoreInProgress: () => boolean; compoundParamsKey: (routeKey: string, params: unknown) => string; shouldSkipAutoFocusDueToExistingFocus: () => boolean; @@ -1456,7 +1454,7 @@ describe('handleStateChange integration', () => { }); }); - it('skipNextFocusRestore suppresses the restore for the next backward nav only (form-submit goBack), then resumes', () => { + it('marks the restored element with `data-programmatic-focus` so consumers (Button enter-shortcut suppression, list cursor-sync) can tell a restore apart from a user-driven Tab', () => { withFakeTimers(() => { simulateTab(); handleStateChange(onA); @@ -1466,29 +1464,16 @@ describe('handleStateChange integration', () => { handleStateChange(onAB); trigger.blur(); - skipNextFocusRestore(); - const spy = jest.spyOn(trigger, 'focus'); handleStateChange(onA); jest.runAllTimers(); - expect(spy).not.toHaveBeenCalled(); - // The flag is one-shot; the skipped entry was cleared, so the user must re-focus before forward to re-capture. - fireFocusIn(trigger); - handleStateChange(onAB); - trigger.blur(); - handleStateChange(onA); - jest.runAllTimers(); - expect(spy).toHaveBeenCalled(); + expect(trigger.getAttribute('data-programmatic-focus')).toBe('true'); }); }); - it('skipNextFocusRestore drops the captured entry, so a later back to the same route cannot inherit the skipped trigger', () => { + it("clears the `data-programmatic-focus` marker on the restored element's blur so the next user-driven focus is not misclassified", () => { withFakeTimers(() => { simulateTab(); - const onAC = stackState(1, [ - {key: 'a', name: 'A'}, - {key: 'c', name: 'C'}, - ]); handleStateChange(onA); const trigger = appendButton(); @@ -1496,39 +1481,13 @@ describe('handleStateChange integration', () => { handleStateChange(onAB); trigger.blur(); - // Form-submit goBack: skipNextFocusRestore + backward → must clear the captured entry. - skipNextFocusRestore(); - const spy = jest.spyOn(trigger, 'focus'); handleStateChange(onA); jest.runAllTimers(); - expect(spy).not.toHaveBeenCalled(); - - // Deeplink-style forward (no fresh trigger) → capture bails, so the stale entry must not resurface on the next back. - setLastInteractiveElementForTests(null); - setLastMouseTriggerForTests(null); - handleStateChange(onAC); - handleStateChange(onA); - jest.runAllTimers(); - expect(spy).not.toHaveBeenCalled(); - }); - }); - - it('skipNextFocusRestore flag is cleared by an intervening forward nav so it cannot leak into a later backward', () => { - withFakeTimers(() => { - simulateTab(); - handleStateChange(onA); - - const trigger = appendButton(); - fireFocusIn(trigger); - - skipNextFocusRestore(); - handleStateChange(onAB); + expect(trigger.getAttribute('data-programmatic-focus')).toBe('true'); + // User Tab moves focus away → blur fires on the restored element → marker is removed. trigger.blur(); - const spy = jest.spyOn(trigger, 'focus'); - handleStateChange(onA); - jest.runAllTimers(); - expect(spy).toHaveBeenCalled(); + expect(trigger.getAttribute('data-programmatic-focus')).toBeNull(); }); }); From c346334753cd0819dc8e6e7b02c47165c54abd86 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Wed, 27 May 2026 23:15:10 +0300 Subject: [PATCH 14/89] chore: tighten focus-return registry test fixtures --- tests/unit/NavigationFocusReturnNativeTest.ts | 91 ++++++++----------- 1 file changed, 39 insertions(+), 52 deletions(-) diff --git a/tests/unit/NavigationFocusReturnNativeTest.ts b/tests/unit/NavigationFocusReturnNativeTest.ts index 4597383ee320..293ad8f046cc 100644 --- a/tests/unit/NavigationFocusReturnNativeTest.ts +++ b/tests/unit/NavigationFocusReturnNativeTest.ts @@ -439,43 +439,36 @@ describe('PUSH_PARAMS — same-route param change', () => { describe('pressable registry — identifier-based fallback', () => { it('registers and deregisters by routeKey + identifier', () => { - const ref = fakeRef(fakeView('row')); - const deregister = registerPressable('A', 'Members', ref); + const deregister = registerPressable('A', 'row', fakeRef(fakeView('row'))); expect(getRegistrySizeForTests()).toBe(1); deregister(); expect(getRegistrySizeForTests()).toBe(0); }); it('deregister is a no-op once another Pressable has overwritten the same identifier (remount race)', () => { - const oldRef = fakeRef(fakeView('old')); - const newRef = fakeRef(fakeView('new')); - const deregisterOld = registerPressable('A', 'Members', oldRef); - registerPressable('A', 'Members', newRef); + 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')); - setLastPressedTriggerRefForTests(detachedRef, 'Members'); + setLastPressedTriggerRefForTests(detachedRef, 'row'); - const prev = stackState(0, [{key: 'A', name: 'WorkspaceInitial'}]); - const forward = stackState(1, [ - {key: 'A', name: 'WorkspaceInitial'}, - {key: 'B', name: 'WorkspaceMembers'}, - ]); - handleStateChange(prev); - handleStateChange(forward); + handleStateChange(stackState(0, [{key: 'A', name: 'A'}])); + handleStateChange( + stackState(1, [ + {key: 'A', name: 'A'}, + {key: 'B', name: 'B'}, + ]), + ); - // Detach simulation: react-native-screens nulls the captured View ref. detachedRef.current = null; - // Re-attach simulation: a new Pressable mounts on the sidebar with the same identifier and a live View. const liveView = fakeView('row-remount'); - const liveRef = fakeRef(liveView); - registerPressable('A', 'Members', liveRef); + registerPressable('A', 'row', fakeRef(liveView)); - const back = stackState(0, [{key: 'A', name: 'WorkspaceInitial'}]); - handleStateChange(back); + handleStateChange(stackState(0, [{key: 'A', name: 'A'}])); flushTransitions(); expect(mockFireFocusEvent).toHaveBeenCalledWith(liveView); @@ -483,59 +476,53 @@ describe('pressable registry — identifier-based fallback', () => { it('rAF retry rescues focus when re-attach lags transitionEnd', () => { const detachedRef = fakeRef(fakeView('row')); - setLastPressedTriggerRefForTests(detachedRef, 'Members'); + setLastPressedTriggerRefForTests(detachedRef, 'row'); - const prev = stackState(0, [{key: 'A', name: 'WorkspaceInitial'}]); - const forward = stackState(1, [ - {key: 'A', name: 'WorkspaceInitial'}, - {key: 'B', name: 'WorkspaceMembers'}, - ]); - handleStateChange(prev); - handleStateChange(forward); + 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: 'WorkspaceInitial'}])); + handleStateChange(stackState(0, [{key: 'A', name: 'A'}])); flushTransitions(); - // No registry hit on transitionEnd: nothing fires yet. expect(mockFireFocusEvent).not.toHaveBeenCalled(); - // The new Pressable mounts a frame later. const liveView = fakeView('row-remount'); - registerPressable('A', 'Members', fakeRef(liveView)); + registerPressable('A', 'row', fakeRef(liveView)); jest.advanceTimersByTime(20); expect(mockFireFocusEvent).toHaveBeenCalledWith(liveView); }); it('stores two same-route entries under distinct identifiers — duplicate-label rows do NOT collide when distinct ids exist', () => { - const rowAEdit = fakeRef(fakeView('edit-a')); - const rowBEdit = fakeRef(fakeView('edit-b')); - registerPressable('A', 'base-list-item-natu26+305', rowAEdit); - registerPressable('A', 'base-list-item-natu26+306', rowBEdit); + 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', () => { - // Simulate two rows with the same accessibilityLabel "Edit" but distinct ids (the per-pressable disambiguation we get for free via the fallback chain). - const pressedRef = fakeRef(fakeView('edit-on-row-A')); - const otherRef = fakeRef(fakeView('edit-on-row-B')); - setLastPressedTriggerRefForTests(pressedRef, 'base-list-item-natu26+305'); + const pressedRef = fakeRef(fakeView('a')); + const otherRef = fakeRef(fakeView('b')); + setLastPressedTriggerRefForTests(pressedRef, 'row-a'); - handleStateChange(stackState(0, [{key: 'A', name: 'WorkspaceMembers'}])); + handleStateChange(stackState(0, [{key: 'A', name: 'List'}])); handleStateChange( stackState(1, [ - {key: 'A', name: 'WorkspaceMembers'}, - {key: 'B', name: 'MemberDetails'}, + {key: 'A', name: 'List'}, + {key: 'B', name: 'Detail'}, ]), ); - // Detach + re-register under both ids. Only the pressed-row's identifier should resolve. pressedRef.current = null; - const liveA = fakeView('edit-on-row-A-remount'); - registerPressable('A', 'base-list-item-natu26+305', fakeRef(liveA)); - registerPressable('A', 'base-list-item-natu26+306', otherRef); + const liveA = fakeView('a-remount'); + registerPressable('A', 'row-a', fakeRef(liveA)); + registerPressable('A', 'row-b', otherRef); - handleStateChange(stackState(0, [{key: 'A', name: 'WorkspaceMembers'}])); + handleStateChange(stackState(0, [{key: 'A', name: 'List'}])); flushTransitions(); expect(mockFireFocusEvent).toHaveBeenCalledWith(liveA); @@ -543,16 +530,16 @@ describe('pressable registry — identifier-based fallback', () => { }); it('clears the registry for a route key when that route is removed from the navigation tree', () => { - registerPressable('B', 'Member-Row', fakeRef(fakeView('row'))); + registerPressable('B', 'row', fakeRef(fakeView('row'))); expect(getRegistrySizeForTests()).toBe(1); handleStateChange( stackState(1, [ - {key: 'A', name: 'WorkspaceInitial'}, - {key: 'B', name: 'WorkspaceMembers'}, + {key: 'A', name: 'A'}, + {key: 'B', name: 'B'}, ]), ); - handleStateChange(stackState(0, [{key: 'A', name: 'WorkspaceInitial'}])); + handleStateChange(stackState(0, [{key: 'A', name: 'A'}])); expect(getRegistrySizeForTests()).toBe(0); }); From 57e38af3225c9d3aef2ded03bf4ddca404c6f055 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Wed, 27 May 2026 23:22:31 +0300 Subject: [PATCH 15/89] test: lock in data-programmatic-focus role suppression --- .../ActiveElementRoleProvider/index.tsx | 2 +- tests/unit/ActiveElementRoleProviderTest.ts | 42 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 tests/unit/ActiveElementRoleProviderTest.ts diff --git a/src/components/ActiveElementRoleProvider/index.tsx b/src/components/ActiveElementRoleProvider/index.tsx index e9a5966792d2..72d3e34f63a1 100644 --- a/src/components/ActiveElementRoleProvider/index.tsx +++ b/src/components/ActiveElementRoleProvider/index.tsx @@ -49,4 +49,4 @@ function ActiveElementRoleProvider({children}: ActiveElementRoleProps) { } export default ActiveElementRoleProvider; -export {ActiveElementRoleContext}; +export {ActiveElementRoleContext, getRoleForActive}; diff --git a/tests/unit/ActiveElementRoleProviderTest.ts b/tests/unit/ActiveElementRoleProviderTest.ts new file mode 100644 index 000000000000..f429f8ffa1b7 --- /dev/null +++ b/tests/unit/ActiveElementRoleProviderTest.ts @@ -0,0 +1,42 @@ +/** + * @jest-environment jsdom + */ +/* eslint-disable import/extensions */ +const {getRoleForActive} = require<{ + getRoleForActive: (el: Element | null) => string | null; +}>('../../src/components/ActiveElementRoleProvider/index.tsx'); +/* eslint-enable import/extensions */ + +// jsdom v20 doesn't reflect ARIAMixin's `role` to a property; stub it for parity with real browsers. +function withRole(el: Element, role: string | null): Element { + Object.defineProperty(el, 'role', {value: role, configurable: true}); + return el; +} + +describe('getRoleForActive', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('returns null for a null element', () => { + expect(getRoleForActive(null)).toBeNull(); + }); + + it('returns the element role when no programmatic-focus marker is present', () => { + expect(getRoleForActive(withRole(document.createElement('div'), 'button'))).toBe('button'); + }); + + it('returns null when the element carries `data-programmatic-focus="true"` — Button.shouldDisableEnterShortcut therefore stays inactive after a restore (#90838 regression guard)', () => { + const el = withRole(document.createElement('div'), 'button'); + el.setAttribute('data-programmatic-focus', 'true'); + expect(getRoleForActive(el)).toBeNull(); + }); + + it('returns the element role once the marker is removed (post-blur)', () => { + const el = withRole(document.createElement('div'), 'button'); + el.setAttribute('data-programmatic-focus', 'true'); + expect(getRoleForActive(el)).toBeNull(); + el.removeAttribute('data-programmatic-focus'); + expect(getRoleForActive(el)).toBe('button'); + }); +}); From 94f1325149c4c90ccfc2178a89ca04ef4cf66b3c Mon Sep 17 00:00:00 2001 From: TaduJR Date: Wed, 27 May 2026 23:47:07 +0300 Subject: [PATCH 16/89] fix: fallback route context so partial @react-navigation/native mocks don't crash useContext --- .../implementation/BaseGenericPressable.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx b/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx index 5365e172fa6d..e3528f9b4716 100644 --- a/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx +++ b/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx @@ -1,5 +1,5 @@ import {NavigationRouteContext} from '@react-navigation/native'; -import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; +import React, {createContext, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; import type {GestureResponderEvent, View} from 'react-native'; // eslint-disable-next-line no-restricted-imports import {Pressable} from 'react-native'; @@ -15,6 +15,9 @@ import mergeRefs from '@libs/mergeRefs'; import {notifyPressedTrigger, registerPressable} from '@libs/NavigationFocusReturn'; import CONST from '@src/CONST'; +const FALLBACK_ROUTE_CONTEXT = createContext<{key?: string} | undefined>(undefined); +const ROUTE_CONTEXT = NavigationRouteContext ?? FALLBACK_ROUTE_CONTEXT; + function GenericPressable({ children, onPress, @@ -54,7 +57,7 @@ function GenericPressable({ const isRoleButton = [rest.accessibilityRole, rest.role].includes(CONST.ROLE.BUTTON); const internalRef = useRef(null); const composedRef = useMemo(() => mergeRefs(ref, internalRef), [ref]); - const routeKey = useContext(NavigationRouteContext)?.key ?? null; + const routeKey = useContext(ROUTE_CONTEXT)?.key ?? null; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- `||` is intentional: `BaseListItem` passes `id={keyForList ?? ''}` so empty-string ids must fall through. const focusIdentifier = rest.id || rest.nativeID || rest.testID || rest.accessibilityLabel || undefined; From 71d61036ddacb2e5b709e4768ee3294cc3edb18f Mon Sep 17 00:00:00 2001 From: TaduJR Date: Wed, 27 May 2026 23:56:26 +0300 Subject: [PATCH 17/89] test: spread requireActual in @react-navigation/native mocks to preserve NavigationRouteContext --- .../implementation/BaseGenericPressable.tsx | 7 ++----- tests/perf-test/SelectionList.perf-test.tsx | 1 + tests/ui/IOURequestStepDistanceTest.tsx | 1 + tests/ui/IOURequestStepTimeRateTest.tsx | 1 + tests/ui/components/IOURequestStepConfirmationPageTest.tsx | 1 + tests/ui/components/IOURequestStepDistanceTest.tsx | 1 + tests/ui/components/SettlementButtonTest.tsx | 1 + 7 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx b/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx index e3528f9b4716..5365e172fa6d 100644 --- a/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx +++ b/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx @@ -1,5 +1,5 @@ import {NavigationRouteContext} from '@react-navigation/native'; -import React, {createContext, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; import type {GestureResponderEvent, View} from 'react-native'; // eslint-disable-next-line no-restricted-imports import {Pressable} from 'react-native'; @@ -15,9 +15,6 @@ import mergeRefs from '@libs/mergeRefs'; import {notifyPressedTrigger, registerPressable} from '@libs/NavigationFocusReturn'; import CONST from '@src/CONST'; -const FALLBACK_ROUTE_CONTEXT = createContext<{key?: string} | undefined>(undefined); -const ROUTE_CONTEXT = NavigationRouteContext ?? FALLBACK_ROUTE_CONTEXT; - function GenericPressable({ children, onPress, @@ -57,7 +54,7 @@ function GenericPressable({ const isRoleButton = [rest.accessibilityRole, rest.role].includes(CONST.ROLE.BUTTON); const internalRef = useRef(null); const composedRef = useMemo(() => mergeRefs(ref, internalRef), [ref]); - const routeKey = useContext(ROUTE_CONTEXT)?.key ?? null; + const routeKey = useContext(NavigationRouteContext)?.key ?? null; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- `||` is intentional: `BaseListItem` passes `id={keyForList ?? ''}` so empty-string ids must fall through. const focusIdentifier = rest.id || rest.nativeID || rest.testID || rest.accessibilityLabel || undefined; diff --git a/tests/perf-test/SelectionList.perf-test.tsx b/tests/perf-test/SelectionList.perf-test.tsx index c523436645a3..5eca0907173a 100644 --- a/tests/perf-test/SelectionList.perf-test.tsx +++ b/tests/perf-test/SelectionList.perf-test.tsx @@ -61,6 +61,7 @@ jest.mock('@react-navigation/stack', () => ({ })); jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual>('@react-navigation/native'), useFocusEffect: () => {}, useIsFocused: () => true, createNavigationContainerRef: jest.fn(), diff --git a/tests/ui/IOURequestStepDistanceTest.tsx b/tests/ui/IOURequestStepDistanceTest.tsx index 9c99b46c5a3e..b895b20f0ffb 100644 --- a/tests/ui/IOURequestStepDistanceTest.tsx +++ b/tests/ui/IOURequestStepDistanceTest.tsx @@ -153,6 +153,7 @@ jest.mock('@react-navigation/native', () => { getState: jest.fn(() => ({})), }; return { + ...jest.requireActual>('@react-navigation/native'), 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..509169a226a0 100644 --- a/tests/ui/IOURequestStepTimeRateTest.tsx +++ b/tests/ui/IOURequestStepTimeRateTest.tsx @@ -29,6 +29,7 @@ jest.mock('@react-navigation/native', () => { getState: jest.fn(() => ({})), }; return { + ...jest.requireActual>('@react-navigation/native'), createNavigationContainerRef: jest.fn(() => mockRef), useIsFocused: () => true, useNavigation: () => ({ diff --git a/tests/ui/components/IOURequestStepConfirmationPageTest.tsx b/tests/ui/components/IOURequestStepConfirmationPageTest.tsx index 5664e457666a..991a27186377 100644 --- a/tests/ui/components/IOURequestStepConfirmationPageTest.tsx +++ b/tests/ui/components/IOURequestStepConfirmationPageTest.tsx @@ -141,6 +141,7 @@ jest.mock('@react-navigation/native', () => { getState: jest.fn(() => ({})), }; return { + ...jest.requireActual>('@react-navigation/native'), 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..ee9a0682f0a0 100644 --- a/tests/ui/components/IOURequestStepDistanceTest.tsx +++ b/tests/ui/components/IOURequestStepDistanceTest.tsx @@ -107,6 +107,7 @@ jest.mock('@react-navigation/native', () => { getState: jest.fn(() => ({})), }; return { + ...jest.requireActual>('@react-navigation/native'), createNavigationContainerRef: jest.fn(() => mockRef), useIsFocused: () => true, useNavigation: () => ({navigate: jest.fn(), addListener: jest.fn()}), diff --git a/tests/ui/components/SettlementButtonTest.tsx b/tests/ui/components/SettlementButtonTest.tsx index 46c666283677..3e761182a7b3 100644 --- a/tests/ui/components/SettlementButtonTest.tsx +++ b/tests/ui/components/SettlementButtonTest.tsx @@ -56,6 +56,7 @@ jest.mock('@react-navigation/native', () => { getState: jest.fn(() => ({})), }; return { + ...jest.requireActual>('@react-navigation/native'), createNavigationContainerRef: jest.fn(() => mockRef), useIsFocused: () => true, useNavigation: () => ({navigate: jest.fn(), addListener: jest.fn()}), From b1467dbc99c8309c01a5bf4e85d1881be3f5ce97 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Thu, 28 May 2026 00:20:37 +0300 Subject: [PATCH 18/89] chore: convert single-line comments to /* */ block format --- src/components/ActiveElementRoleProvider/index.tsx | 3 +-- src/hooks/useDialogContainerFocus/index.ts | 2 +- src/hooks/useScreenInitialFocus/index.ts | 5 ++--- src/libs/NavigationFocusReturn.native.ts | 10 +++++++--- tests/unit/ActiveElementRoleProviderTest.ts | 4 +++- 5 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/components/ActiveElementRoleProvider/index.tsx b/src/components/ActiveElementRoleProvider/index.tsx index 72d3e34f63a1..8257bd35ced7 100644 --- a/src/components/ActiveElementRoleProvider/index.tsx +++ b/src/components/ActiveElementRoleProvider/index.tsx @@ -7,8 +7,7 @@ const ActiveElementRoleContext = React.createContext { const status = useContext(ScreenWrapperStatusContext); diff --git a/src/libs/NavigationFocusReturn.native.ts b/src/libs/NavigationFocusReturn.native.ts index 1a111b8748e8..1b9f5f811412 100644 --- a/src/libs/NavigationFocusReturn.native.ts +++ b/src/libs/NavigationFocusReturn.native.ts @@ -6,7 +6,7 @@ import fireFocusEvent from './Accessibility/fireFocusEvent'; import scheduleRefocus from './Accessibility/scheduleRefocus'; import compoundParamsKey, {COMPOUND_KEY_DELIMITER} from './compoundParamsKey'; import navigationRef from './Navigation/navigationRef'; -// eslint-disable-next-line no-restricted-imports -- focus-return is a sibling primitive to TransitionTracker; the exact transitionEnd signal is what we need to avoid focus-restore races with the OS. +// eslint-disable-next-line no-restricted-imports -- sibling primitive to TransitionTracker; needs the exact transitionEnd signal to avoid OS focus-restore races. import TransitionTracker from './Navigation/TransitionTracker'; import {diffNavigationState} from './navigationStateDiff'; @@ -24,7 +24,9 @@ let prevState: NavigationState | undefined; let pendingRestore: {cancel: () => void} | null = null; let stateUnsubscribe: (() => void) | null = null; -// Delete-then-set so a re-set moves the key to the tail and FIFO eviction drops the truly oldest. +/* + * Delete-then-set so a re-set moves the key to the tail and FIFO eviction drops the truly oldest. + */ function setTriggerEntry(routeKey: string, entry: TriggerEntry): void { triggerMap.delete(routeKey); triggerMap.set(routeKey, entry); @@ -78,7 +80,9 @@ function captureTriggerForRoute(routeKey: string): void { setTriggerEntry(routeKey, {ref: lastPressedTriggerRef, identifier: lastPressedTriggerIdentifier ?? undefined}); } -// Fast path = captured ref still alive. Fallback = ref nulled by `react-native-screens` detach; resolve via the registry's live re-registration. +/* + * Fast path = captured ref still alive. Fallback = ref nulled by `react-native-screens` detach; resolve via the registry's live re-registration. + */ function restoreTriggerForRoute(routeKey: string): RefObject | null { const entry = triggerMap.get(routeKey); if (!entry) { diff --git a/tests/unit/ActiveElementRoleProviderTest.ts b/tests/unit/ActiveElementRoleProviderTest.ts index f429f8ffa1b7..37a398d8fc70 100644 --- a/tests/unit/ActiveElementRoleProviderTest.ts +++ b/tests/unit/ActiveElementRoleProviderTest.ts @@ -7,7 +7,9 @@ const {getRoleForActive} = require<{ }>('../../src/components/ActiveElementRoleProvider/index.tsx'); /* eslint-enable import/extensions */ -// jsdom v20 doesn't reflect ARIAMixin's `role` to a property; stub it for parity with real browsers. +/* + * jsdom v20 doesn't reflect ARIAMixin's `role` to a property; stub it for parity with real browsers. + */ function withRole(el: Element, role: string | null): Element { Object.defineProperty(el, 'role', {value: role, configurable: true}); return el; From 954704592453daf60407bc5bc2ea604f78b1fd73 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Thu, 28 May 2026 00:38:49 +0300 Subject: [PATCH 19/89] chore: model programmatic focus as {role, isProgrammatic} context instead of nulling role --- .../index.native.tsx | 15 ++---- .../ActiveElementRoleProvider/index.tsx | 39 ++++++-------- .../ActiveElementRoleProvider/types.ts | 1 + src/hooks/useActiveElementRole/index.ts | 9 ++-- src/libs/NavigationFocusReturn.native.ts | 4 ++ src/libs/NavigationFocusReturn.ts | 20 +++++++- .../request/step/IOURequestStepMerchant.tsx | 3 ++ tests/unit/ActiveElementRoleProviderTest.ts | 24 ++++----- tests/unit/NavigationFocusReturnTest.ts | 51 ++++++++++++++++++- 9 files changed, 112 insertions(+), 54 deletions(-) diff --git a/src/components/ActiveElementRoleProvider/index.native.tsx b/src/components/ActiveElementRoleProvider/index.native.tsx index 4a9f2290b2b0..e4761bbd70c1 100644 --- a/src/components/ActiveElementRoleProvider/index.native.tsx +++ b/src/components/ActiveElementRoleProvider/index.native.tsx @@ -1,19 +1,12 @@ import React from 'react'; import type {ActiveElementRoleContextValue, ActiveElementRoleProps} from './types'; -const ActiveElementRoleContext = React.createContext({ - role: null, -}); +const EMPTY: ActiveElementRoleContextValue = {role: null, isProgrammatic: false}; -function ActiveElementRoleProvider({children}: ActiveElementRoleProps) { - const value = React.useMemo( - () => ({ - role: null, - }), - [], - ); +const ActiveElementRoleContext = React.createContext(EMPTY); - return {children}; +function ActiveElementRoleProvider({children}: ActiveElementRoleProps) { + return {children}; } export default ActiveElementRoleProvider; diff --git a/src/components/ActiveElementRoleProvider/index.tsx b/src/components/ActiveElementRoleProvider/index.tsx index 8257bd35ced7..2e86cc5d7c52 100644 --- a/src/components/ActiveElementRoleProvider/index.tsx +++ b/src/components/ActiveElementRoleProvider/index.tsx @@ -2,29 +2,29 @@ import React, {useEffect, useState} from 'react'; import {PROGRAMMATIC_FOCUS_DATA_ATTRIBUTE} from '@libs/programmaticFocus'; import type {ActiveElementRoleContextValue, ActiveElementRoleProps} from './types'; -const ActiveElementRoleContext = React.createContext({ - role: null, -}); - -/* - * Suppress the role on a11y-restored elements so role-based consumers (`Button` enter-shortcut suppression) don't react to a programmatic focus. - */ -function getRoleForActive(el: Element | null): string | null { - if (el?.getAttribute(PROGRAMMATIC_FOCUS_DATA_ATTRIBUTE) === 'true') { - return null; +const EMPTY: ActiveElementRoleContextValue = {role: null, isProgrammatic: false}; + +const ActiveElementRoleContext = React.createContext(EMPTY); + +function getActiveElementInfo(el: Element | null): ActiveElementRoleContextValue { + if (!el) { + return EMPTY; } - return el?.role ?? null; + return { + role: el.role ?? null, + isProgrammatic: el.getAttribute(PROGRAMMATIC_FOCUS_DATA_ATTRIBUTE) === 'true', + }; } function ActiveElementRoleProvider({children}: ActiveElementRoleProps) { - const [activeRoleRef, setRole] = useState(() => getRoleForActive(document?.activeElement ?? null)); + const [info, setInfo] = useState(() => getActiveElementInfo(document?.activeElement ?? null)); const handleFocusIn = () => { - setRole(getRoleForActive(document?.activeElement ?? null)); + setInfo(getActiveElementInfo(document?.activeElement ?? null)); }; const handleFocusOut = () => { - setRole(null); + setInfo(EMPTY); }; useEffect(() => { @@ -37,15 +37,8 @@ function ActiveElementRoleProvider({children}: ActiveElementRoleProps) { }; }, []); - const value = React.useMemo( - () => ({ - role: activeRoleRef, - }), - [activeRoleRef], - ); - - return {children}; + return {children}; } export default ActiveElementRoleProvider; -export {ActiveElementRoleContext, getRoleForActive}; +export {ActiveElementRoleContext, getActiveElementInfo}; diff --git a/src/components/ActiveElementRoleProvider/types.ts b/src/components/ActiveElementRoleProvider/types.ts index f22343b12550..9f904a64d93e 100644 --- a/src/components/ActiveElementRoleProvider/types.ts +++ b/src/components/ActiveElementRoleProvider/types.ts @@ -1,5 +1,6 @@ type ActiveElementRoleContextValue = { role: string | null; + isProgrammatic: boolean; }; type ActiveElementRoleProps = { diff --git a/src/hooks/useActiveElementRole/index.ts b/src/hooks/useActiveElementRole/index.ts index 98ae285f92b0..768885977fc5 100644 --- a/src/hooks/useActiveElementRole/index.ts +++ b/src/hooks/useActiveElementRole/index.ts @@ -3,13 +3,12 @@ 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. + * Returns the focused element's role, or null if the focus is programmatic (a11y restore). + * Role-based consumers (e.g., `Button` enter-shortcut suppression) want to react to user-driven focus only. */ const useActiveElementRole: UseActiveElementRole = () => { - const {role} = useContext(ActiveElementRoleContext); - - return role; + const {role, isProgrammatic} = useContext(ActiveElementRoleContext); + return isProgrammatic ? null : role; }; export default useActiveElementRole; diff --git a/src/libs/NavigationFocusReturn.native.ts b/src/libs/NavigationFocusReturn.native.ts index 1b9f5f811412..c3c1d1de521e 100644 --- a/src/libs/NavigationFocusReturn.native.ts +++ b/src/libs/NavigationFocusReturn.native.ts @@ -48,6 +48,9 @@ function notifyPressedTrigger(ref: RefObject | null, identifier?: s lastPressedTriggerAt = ref ? Date.now() : 0; } +/** Web-only signal. Native uses TalkBack/VoiceOver's announcement model — there's no per-button Enter-shortcut to suppress. No-op for cross-platform signature parity. */ +function markNextRestoreAsProgrammatic(): void {} + function registerPressable(routeKey: string, identifier: string, ref: RefObject): () => void { let routeMap = pressableRegistry.get(routeKey); if (!routeMap) { @@ -269,6 +272,7 @@ export { notifyPushParamsForward, notifyPushParamsBackward, cancelPendingFocusRestore, + markNextRestoreAsProgrammatic, isFocusRestoreInProgress, shouldSkipAutoFocusDueToExistingFocus, resetForTests, diff --git a/src/libs/NavigationFocusReturn.ts b/src/libs/NavigationFocusReturn.ts index da92dc140a23..c9dd4c0861a7 100644 --- a/src/libs/NavigationFocusReturn.ts +++ b/src/libs/NavigationFocusReturn.ts @@ -45,6 +45,7 @@ function setTriggerEntry(routeKey: string, entry: TriggerEntry): void { let prevState: NavigationState | undefined; let pendingRestore: {cancel: () => void} | null = null; let isRestoringFocus = false; +let nextRestoreIsProgrammatic = false; let focusinHandler: ((e: FocusEvent) => void) | null = null; let mouseActivationHandler: ((e: MouseEvent) => void) | null = null; let stateUnsubscribe: (() => void) | null = null; @@ -95,6 +96,17 @@ function notifyPushParamsBackward(routeKey: string, targetParams: unknown): void scheduleRestore(compoundParamsKey(routeKey, targetParams)); } +/** Marks the next backward restore as programmatic — the restored element gets `data-programmatic-focus` so role-based consumers (Button enter-shortcut suppression) treat the restore as system-driven. Call before a form-submit `goBack`. One-shot; cleared on consume/cancel/teardown. */ +function markNextRestoreAsProgrammatic(): void { + nextRestoreIsProgrammatic = true; +} + +function consumeProgrammaticFlag(): boolean { + const v = nextRestoreIsProgrammatic; + nextRestoreIsProgrammatic = false; + return v; +} + /** 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 {} @@ -230,10 +242,11 @@ function restoreTriggerForRoute(routeKey: string): boolean { } const focusOptions: FocusOptions = {preventScroll: true, focusVisible: getHadTabNavigation()}; + const shouldMarkProgrammatic = consumeProgrammaticFlag(); for (const candidate of candidates) { const before = document.activeElement; isRestoringFocus = true; - const unmarkProgrammaticFocus = markProgrammaticFocus(candidate); + const unmarkProgrammaticFocus = shouldMarkProgrammatic ? markProgrammaticFocus(candidate) : null; try { candidate.focus(focusOptions); } finally { @@ -253,7 +266,7 @@ function restoreTriggerForRoute(routeKey: string): boolean { scheduleReturnHoldRelease(); return true; } - unmarkProgrammaticFocus(); + unmarkProgrammaticFocus?.(); } // Silent no-op (transient display:none / visibility:hidden ancestor) — leave the entry for scheduleRestore to retry; release the cycle so AUTO/INITIAL aren't blocked during the window. @@ -426,6 +439,7 @@ function teardownNavigationFocusReturn(): void { lastInteractiveElement = null; lastMouseTrigger = null; lastMouseTriggerAt = 0; + nextRestoreIsProgrammatic = false; if (typeof document !== 'undefined') { if (focusinHandler) { document.removeEventListener('focusin', focusinHandler, true); @@ -453,6 +467,7 @@ function resetForTests(): void { lastMouseTrigger = null; lastMouseTriggerAt = 0; lastRestoreTarget = null; + nextRestoreIsProgrammatic = false; } function setLastInteractiveElementForTests(element: HTMLElement | null): void { @@ -475,6 +490,7 @@ export { notifyPushParamsForward, notifyPushParamsBackward, cancelPendingFocusRestore, + markNextRestoreAsProgrammatic, notifyPressedTrigger, registerPressable, isFocusRestoreInProgress, diff --git a/src/pages/iou/request/step/IOURequestStepMerchant.tsx b/src/pages/iou/request/step/IOURequestStepMerchant.tsx index eecf26d778e5..13201ba758e2 100644 --- a/src/pages/iou/request/step/IOURequestStepMerchant.tsx +++ b/src/pages/iou/request/step/IOURequestStepMerchant.tsx @@ -17,6 +17,7 @@ import useShowNotFoundPageInIOUStep from '@hooks/useShowNotFoundPageInIOUStep'; import useThemeStyles from '@hooks/useThemeStyles'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Navigation from '@libs/Navigation/Navigation'; +import {markNextRestoreAsProgrammatic} from '@libs/NavigationFocusReturn'; import {getTransactionDetails, isExpenseRequest, isPolicyExpenseChat} from '@libs/ReportUtils'; import {hasReceipt} from '@libs/TransactionUtils'; import {isInvalidMerchantValue, isValidInputLength} from '@libs/ValidationUtils'; @@ -84,6 +85,8 @@ function IOURequestStepMerchant({ return; } shouldNavigateAfterSaveRef.current = false; + // Save-by-Enter: suppress the restored Merchant row from claiming Enter so the page's "Create expense" shortcut fires on a follow-up Enter. Back/Esc don't call this — restored focus claims Enter normally there. + markNextRestoreAsProgrammatic(); navigateBack(); }, [isSaved, navigateBack]); diff --git a/tests/unit/ActiveElementRoleProviderTest.ts b/tests/unit/ActiveElementRoleProviderTest.ts index 37a398d8fc70..9bb36a02365a 100644 --- a/tests/unit/ActiveElementRoleProviderTest.ts +++ b/tests/unit/ActiveElementRoleProviderTest.ts @@ -2,8 +2,8 @@ * @jest-environment jsdom */ /* eslint-disable import/extensions */ -const {getRoleForActive} = require<{ - getRoleForActive: (el: Element | null) => string | null; +const {getActiveElementInfo} = require<{ + getActiveElementInfo: (el: Element | null) => {role: string | null; isProgrammatic: boolean}; }>('../../src/components/ActiveElementRoleProvider/index.tsx'); /* eslint-enable import/extensions */ @@ -15,30 +15,30 @@ function withRole(el: Element, role: string | null): Element { return el; } -describe('getRoleForActive', () => { +describe('getActiveElementInfo', () => { afterEach(() => { document.body.innerHTML = ''; }); - it('returns null for a null element', () => { - expect(getRoleForActive(null)).toBeNull(); + it('returns null role + isProgrammatic=false for a null element', () => { + expect(getActiveElementInfo(null)).toEqual({role: null, isProgrammatic: false}); }); - it('returns the element role when no programmatic-focus marker is present', () => { - expect(getRoleForActive(withRole(document.createElement('div'), 'button'))).toBe('button'); + it('returns the element role + isProgrammatic=false when no programmatic-focus marker is present', () => { + expect(getActiveElementInfo(withRole(document.createElement('div'), 'button'))).toEqual({role: 'button', isProgrammatic: false}); }); - it('returns null when the element carries `data-programmatic-focus="true"` — Button.shouldDisableEnterShortcut therefore stays inactive after a restore (#90838 regression guard)', () => { + it('reports isProgrammatic=true (preserving the real role) when `data-programmatic-focus="true"` — `useActiveElementRole` filters this to null for `Button.shouldDisableEnterShortcut` (#90838 regression guard)', () => { const el = withRole(document.createElement('div'), 'button'); el.setAttribute('data-programmatic-focus', 'true'); - expect(getRoleForActive(el)).toBeNull(); + expect(getActiveElementInfo(el)).toEqual({role: 'button', isProgrammatic: true}); }); - it('returns the element role once the marker is removed (post-blur)', () => { + it('flips isProgrammatic back to false once the marker is removed (post-blur)', () => { const el = withRole(document.createElement('div'), 'button'); el.setAttribute('data-programmatic-focus', 'true'); - expect(getRoleForActive(el)).toBeNull(); + expect(getActiveElementInfo(el).isProgrammatic).toBe(true); el.removeAttribute('data-programmatic-focus'); - expect(getRoleForActive(el)).toBe('button'); + expect(getActiveElementInfo(el)).toEqual({role: 'button', isProgrammatic: false}); }); }); diff --git a/tests/unit/NavigationFocusReturnTest.ts b/tests/unit/NavigationFocusReturnTest.ts index 514e64dd9797..3f7f3a8ff00f 100644 --- a/tests/unit/NavigationFocusReturnTest.ts +++ b/tests/unit/NavigationFocusReturnTest.ts @@ -21,6 +21,7 @@ const { notifyPushParamsForward, notifyPushParamsBackward, cancelPendingFocusRestore, + markNextRestoreAsProgrammatic, isFocusRestoreInProgress, compoundParamsKey, shouldSkipAutoFocusDueToExistingFocus, @@ -38,6 +39,7 @@ const { notifyPushParamsForward: (routeKey: string, prevParams: unknown) => void; notifyPushParamsBackward: (routeKey: string, targetParams: unknown) => void; cancelPendingFocusRestore: () => void; + markNextRestoreAsProgrammatic: () => void; isFocusRestoreInProgress: () => boolean; compoundParamsKey: (routeKey: string, params: unknown) => string; shouldSkipAutoFocusDueToExistingFocus: () => boolean; @@ -1454,7 +1456,7 @@ describe('handleStateChange integration', () => { }); }); - it('marks the restored element with `data-programmatic-focus` so consumers (Button enter-shortcut suppression, list cursor-sync) can tell a restore apart from a user-driven Tab', () => { + it('marks the restored element with `data-programmatic-focus` ONLY when the form-submit path opts in via `markNextRestoreAsProgrammatic()`', () => { withFakeTimers(() => { simulateTab(); handleStateChange(onA); @@ -1464,6 +1466,7 @@ describe('handleStateChange integration', () => { handleStateChange(onAB); trigger.blur(); + markNextRestoreAsProgrammatic(); handleStateChange(onA); jest.runAllTimers(); @@ -1471,6 +1474,24 @@ describe('handleStateChange integration', () => { }); }); + it('does NOT mark the restored element on an ordinary Back navigation — focused button claims Enter shortcut normally (#90838 regression guard: only Save path opts in)', () => { + withFakeTimers(() => { + simulateTab(); + handleStateChange(onA); + + const trigger = appendButton(); + fireFocusIn(trigger); + handleStateChange(onAB); + trigger.blur(); + + // No markNextRestoreAsProgrammatic() — this models the Back/Esc dismissal path. + handleStateChange(onA); + jest.runAllTimers(); + + expect(trigger.getAttribute('data-programmatic-focus')).toBeNull(); + }); + }); + it("clears the `data-programmatic-focus` marker on the restored element's blur so the next user-driven focus is not misclassified", () => { withFakeTimers(() => { simulateTab(); @@ -1481,6 +1502,7 @@ describe('handleStateChange integration', () => { handleStateChange(onAB); trigger.blur(); + markNextRestoreAsProgrammatic(); handleStateChange(onA); jest.runAllTimers(); expect(trigger.getAttribute('data-programmatic-focus')).toBe('true'); @@ -1491,6 +1513,33 @@ describe('handleStateChange integration', () => { }); }); + it('`markNextRestoreAsProgrammatic()` is one-shot — a subsequent unmarked Back restore does NOT carry the marker', () => { + withFakeTimers(() => { + simulateTab(); + handleStateChange(onA); + + // First trip: Save path with the marker. + const triggerA = appendButton(); + fireFocusIn(triggerA); + handleStateChange(onAB); + triggerA.blur(); + markNextRestoreAsProgrammatic(); + handleStateChange(onA); + jest.runAllTimers(); + expect(triggerA.getAttribute('data-programmatic-focus')).toBe('true'); + triggerA.blur(); + + // Second trip: ordinary Back, no opt-in. + const triggerB = appendButton(); + fireFocusIn(triggerB); + handleStateChange(onAB); + triggerB.blur(); + handleStateChange(onA); + jest.runAllTimers(); + expect(triggerB.getAttribute('data-programmatic-focus')).toBeNull(); + }); + }); + it('should cancel a queued restore when a lateral tab switch arrives before it fires', () => { withFakeTimers(() => { simulateTab(); From 74e06bcd0c05c6673b757489dbb5911b22287bab Mon Sep 17 00:00:00 2001 From: TaduJR Date: Thu, 28 May 2026 11:06:36 +0300 Subject: [PATCH 20/89] refactor: SR-gate registry registration; rename to isRoleSuppressed --- .../index.native.tsx | 2 +- .../ActiveElementRoleProvider/index.tsx | 6 ++-- .../ActiveElementRoleProvider/types.ts | 2 +- .../implementation/BaseGenericPressable.tsx | 4 +-- src/hooks/useActiveElementRole/index.ts | 4 +-- src/libs/NavigationFocusReturn.native.ts | 2 ++ src/libs/NavigationFocusReturn.ts | 16 ++++++---- src/libs/programmaticFocus.ts | 26 +++++++++++----- tests/unit/ActiveElementRoleProviderTest.ts | 30 +++++++++++-------- tests/unit/NavigationFocusReturnTest.ts | 22 +++++++------- 10 files changed, 68 insertions(+), 46 deletions(-) diff --git a/src/components/ActiveElementRoleProvider/index.native.tsx b/src/components/ActiveElementRoleProvider/index.native.tsx index e4761bbd70c1..bea4802d4bca 100644 --- a/src/components/ActiveElementRoleProvider/index.native.tsx +++ b/src/components/ActiveElementRoleProvider/index.native.tsx @@ -1,7 +1,7 @@ import React from 'react'; import type {ActiveElementRoleContextValue, ActiveElementRoleProps} from './types'; -const EMPTY: ActiveElementRoleContextValue = {role: null, isProgrammatic: false}; +const EMPTY: ActiveElementRoleContextValue = {role: null, isRoleSuppressed: false}; const ActiveElementRoleContext = React.createContext(EMPTY); diff --git a/src/components/ActiveElementRoleProvider/index.tsx b/src/components/ActiveElementRoleProvider/index.tsx index 2e86cc5d7c52..9954dfca85a3 100644 --- a/src/components/ActiveElementRoleProvider/index.tsx +++ b/src/components/ActiveElementRoleProvider/index.tsx @@ -1,8 +1,8 @@ import React, {useEffect, useState} from 'react'; -import {PROGRAMMATIC_FOCUS_DATA_ATTRIBUTE} from '@libs/programmaticFocus'; +import {SUPPRESS_ACTIVE_ROLE_DATA_ATTRIBUTE} from '@libs/programmaticFocus'; import type {ActiveElementRoleContextValue, ActiveElementRoleProps} from './types'; -const EMPTY: ActiveElementRoleContextValue = {role: null, isProgrammatic: false}; +const EMPTY: ActiveElementRoleContextValue = {role: null, isRoleSuppressed: false}; const ActiveElementRoleContext = React.createContext(EMPTY); @@ -12,7 +12,7 @@ function getActiveElementInfo(el: Element | null): ActiveElementRoleContextValue } return { role: el.role ?? null, - isProgrammatic: el.getAttribute(PROGRAMMATIC_FOCUS_DATA_ATTRIBUTE) === 'true', + isRoleSuppressed: el.getAttribute(SUPPRESS_ACTIVE_ROLE_DATA_ATTRIBUTE) === 'true', }; } diff --git a/src/components/ActiveElementRoleProvider/types.ts b/src/components/ActiveElementRoleProvider/types.ts index 9f904a64d93e..a3947f827531 100644 --- a/src/components/ActiveElementRoleProvider/types.ts +++ b/src/components/ActiveElementRoleProvider/types.ts @@ -1,6 +1,6 @@ type ActiveElementRoleContextValue = { role: string | null; - isProgrammatic: boolean; + isRoleSuppressed: boolean; }; type ActiveElementRoleProps = { diff --git a/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx b/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx index 5365e172fa6d..bbabdfb1372d 100644 --- a/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx +++ b/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx @@ -59,11 +59,11 @@ function GenericPressable({ const focusIdentifier = rest.id || rest.nativeID || rest.testID || rest.accessibilityLabel || undefined; useEffect(() => { - if (!routeKey || !focusIdentifier) { + if (!isScreenReaderActive || !routeKey || !focusIdentifier) { return; } return registerPressable(routeKey, focusIdentifier, internalRef); - }, [routeKey, focusIdentifier]); + }, [isScreenReaderActive, routeKey, focusIdentifier]); const isDisabled = useMemo(() => { let shouldBeDisabledByScreenReader = false; diff --git a/src/hooks/useActiveElementRole/index.ts b/src/hooks/useActiveElementRole/index.ts index 768885977fc5..f980be1fea44 100644 --- a/src/hooks/useActiveElementRole/index.ts +++ b/src/hooks/useActiveElementRole/index.ts @@ -7,8 +7,8 @@ import type UseActiveElementRole from './types'; * Role-based consumers (e.g., `Button` enter-shortcut suppression) want to react to user-driven focus only. */ const useActiveElementRole: UseActiveElementRole = () => { - const {role, isProgrammatic} = useContext(ActiveElementRoleContext); - return isProgrammatic ? null : role; + const {role, isRoleSuppressed} = useContext(ActiveElementRoleContext); + return isRoleSuppressed ? null : role; }; export default useActiveElementRole; diff --git a/src/libs/NavigationFocusReturn.native.ts b/src/libs/NavigationFocusReturn.native.ts index c3c1d1de521e..83570815eb7b 100644 --- a/src/libs/NavigationFocusReturn.native.ts +++ b/src/libs/NavigationFocusReturn.native.ts @@ -118,6 +118,8 @@ function scheduleRestore(routeKey: string): void { let refocusHandle: {cancel: () => void} | null = null; let rafHandle: number | null = null; const handle = TransitionTracker.runAfterTransitions({ + // We're called from the nav state listener — the back transition may not have registered yet, so wait for it to start, then run after it ends (avoids restoring while the outgoing screen is still active). + waitForUpcomingTransition: true, callback: () => { if (cancelled) { return; diff --git a/src/libs/NavigationFocusReturn.ts b/src/libs/NavigationFocusReturn.ts index c9dd4c0861a7..ea850fabcf74 100644 --- a/src/libs/NavigationFocusReturn.ts +++ b/src/libs/NavigationFocusReturn.ts @@ -10,7 +10,7 @@ import getHadTabNavigation from './hadTabNavigation'; import {consumeLauncher, pickLauncher, resetLauncherStackForTests} from './LauncherStack'; import navigationRef from './Navigation/navigationRef'; import {collectRouteKeys, diffNavigationState} from './navigationStateDiff'; -import {markProgrammaticFocus} from './programmaticFocus'; +import {suppressActiveRole} from './programmaticFocus'; import {isCycleIdle, Priorities, resetCycle, tryClaim} from './ScreenFocusArbiter'; /** focusin tracks the last keyboard-focused element; a nav state listener captures it against the outgoing route and restores it on backward nav. */ @@ -207,7 +207,7 @@ function cancelPendingFocusRestore(): void { } } -function restoreTriggerForRoute(routeKey: string): boolean { +function restoreTriggerForRoute(routeKey: string, {suppressRole = false}: {suppressRole?: boolean} = {}): boolean { if (typeof document === 'undefined') { return false; } @@ -242,11 +242,10 @@ function restoreTriggerForRoute(routeKey: string): boolean { } const focusOptions: FocusOptions = {preventScroll: true, focusVisible: getHadTabNavigation()}; - const shouldMarkProgrammatic = consumeProgrammaticFlag(); for (const candidate of candidates) { const before = document.activeElement; isRestoringFocus = true; - const unmarkProgrammaticFocus = shouldMarkProgrammatic ? markProgrammaticFocus(candidate) : null; + const unsuppressRole = suppressRole ? suppressActiveRole(candidate) : null; try { candidate.focus(focusOptions); } finally { @@ -266,7 +265,7 @@ function restoreTriggerForRoute(routeKey: string): boolean { scheduleReturnHoldRelease(); return true; } - unmarkProgrammaticFocus?.(); + unsuppressRole?.(); } // Silent no-op (transient display:none / visibility:hidden ancestor) — leave the entry for scheduleRestore to retry; release the cycle so AUTO/INITIAL aren't blocked during the window. @@ -284,6 +283,8 @@ const RESTORE_RETRY_MS = 50; function scheduleRestore(routeKey: string): void { cancelPendingRestore(); + // Consume once at schedule time so an abandoned/cancelled restore can't leak the flag into a later unrelated Back; retries below reuse the captured value. + const shouldSuppressRole = consumeProgrammaticFlag(); // `cancelled` flag in case a primitive's cancel races a queued callback. let cancelled = false; let attempts = 0; @@ -303,7 +304,7 @@ function scheduleRestore(routeKey: string): void { return; } attempts += 1; - const restored = restoreTriggerForRoute(routeKey); + const restored = restoreTriggerForRoute(routeKey, {suppressRole: shouldSuppressRole}); if (restored || !triggerMap.has(routeKey)) { pendingRestore = null; return; @@ -354,11 +355,14 @@ function handleStateChange(newState: NavigationState | undefined): void { lastInteractiveElement = null; lastMouseTrigger = null; lastMouseTriggerAt = 0; + // A non-backward nav after a form-submit mark means the mark is stale — drop it so it can't taint a later Back. + consumeProgrammaticFlag(); } else if (action.type === 'backward') { scheduleRestore(action.restoreKey); } else if (action.type === 'lateral') { // Stale restore would steal focus back on sibling nav. cancelPendingRestore(); + consumeProgrammaticFlag(); } for (const key of removedKeys) { diff --git a/src/libs/programmaticFocus.ts b/src/libs/programmaticFocus.ts index 6de8c5fbdaff..3f8c3c4fd40e 100644 --- a/src/libs/programmaticFocus.ts +++ b/src/libs/programmaticFocus.ts @@ -1,12 +1,12 @@ +// Suppresses the `:focus-visible` ring (see web/index.html) on programmatically-focused elements — used for a11y autofocus and focus restore so mouse users don't see a ring (WCAG 2.4.7). const PROGRAMMATIC_FOCUS_DATA_ATTRIBUTE = 'data-programmatic-focus'; +// Read by ActiveElementRoleProvider to report no active role — keeps page-level Enter shortcuts active after a form-submit restore. Distinct from the ring marker so generic autofocus doesn't suppress role. +const SUPPRESS_ACTIVE_ROLE_DATA_ATTRIBUTE = 'data-suppress-active-role'; -/** - * Marks `el` as receiving programmatic (a11y-restore) focus so role-based consumers (`Button` enter-shortcut suppression) skip it on a later keypress. - * 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); +// Sets `attribute` on `el`, auto-clearing on blur. Returns a function that clears synchronously (used when the focus call never landed). +function markWithAttribute(el: HTMLElement, attribute: string): () => void { + el.setAttribute(attribute, 'true'); + const clear = () => el.removeAttribute(attribute); el.addEventListener('blur', clear, {once: true}); return () => { el.removeEventListener('blur', clear); @@ -14,4 +14,14 @@ function markProgrammaticFocus(el: HTMLElement): () => void { }; } -export {PROGRAMMATIC_FOCUS_DATA_ATTRIBUTE, markProgrammaticFocus}; +/** Suppresses the focus-visible ring on a programmatically-focused element (a11y autofocus / restore). */ +function markProgrammaticFocus(el: HTMLElement): () => void { + return markWithAttribute(el, PROGRAMMATIC_FOCUS_DATA_ATTRIBUTE); +} + +/** Marks a restored element so `ActiveElementRoleProvider` reports no active role — only the form-submit restore path opts in. */ +function suppressActiveRole(el: HTMLElement): () => void { + return markWithAttribute(el, SUPPRESS_ACTIVE_ROLE_DATA_ATTRIBUTE); +} + +export {PROGRAMMATIC_FOCUS_DATA_ATTRIBUTE, SUPPRESS_ACTIVE_ROLE_DATA_ATTRIBUTE, markProgrammaticFocus, suppressActiveRole}; diff --git a/tests/unit/ActiveElementRoleProviderTest.ts b/tests/unit/ActiveElementRoleProviderTest.ts index 9bb36a02365a..492b605ac803 100644 --- a/tests/unit/ActiveElementRoleProviderTest.ts +++ b/tests/unit/ActiveElementRoleProviderTest.ts @@ -3,7 +3,7 @@ */ /* eslint-disable import/extensions */ const {getActiveElementInfo} = require<{ - getActiveElementInfo: (el: Element | null) => {role: string | null; isProgrammatic: boolean}; + getActiveElementInfo: (el: Element | null) => {role: string | null; isRoleSuppressed: boolean}; }>('../../src/components/ActiveElementRoleProvider/index.tsx'); /* eslint-enable import/extensions */ @@ -20,25 +20,31 @@ describe('getActiveElementInfo', () => { document.body.innerHTML = ''; }); - it('returns null role + isProgrammatic=false for a null element', () => { - expect(getActiveElementInfo(null)).toEqual({role: null, isProgrammatic: false}); + it('returns null role + isRoleSuppressed=false for a null element', () => { + expect(getActiveElementInfo(null)).toEqual({role: null, isRoleSuppressed: false}); }); - it('returns the element role + isProgrammatic=false when no programmatic-focus marker is present', () => { - expect(getActiveElementInfo(withRole(document.createElement('div'), 'button'))).toEqual({role: 'button', isProgrammatic: false}); + it('returns the element role + isRoleSuppressed=false when no suppression marker is present', () => { + expect(getActiveElementInfo(withRole(document.createElement('div'), 'button'))).toEqual({role: 'button', isRoleSuppressed: false}); }); - it('reports isProgrammatic=true (preserving the real role) when `data-programmatic-focus="true"` — `useActiveElementRole` filters this to null for `Button.shouldDisableEnterShortcut` (#90838 regression guard)', () => { + it('reports isRoleSuppressed=true while preserving the real role when the element carries data-suppress-active-role', () => { const el = withRole(document.createElement('div'), 'button'); - el.setAttribute('data-programmatic-focus', 'true'); - expect(getActiveElementInfo(el)).toEqual({role: 'button', isProgrammatic: true}); + el.setAttribute('data-suppress-active-role', 'true'); + expect(getActiveElementInfo(el)).toEqual({role: 'button', isRoleSuppressed: true}); }); - it('flips isProgrammatic back to false once the marker is removed (post-blur)', () => { + it('ignores the ring-only data-programmatic-focus marker so generic autofocus keeps its role', () => { const el = withRole(document.createElement('div'), 'button'); el.setAttribute('data-programmatic-focus', 'true'); - expect(getActiveElementInfo(el).isProgrammatic).toBe(true); - el.removeAttribute('data-programmatic-focus'); - expect(getActiveElementInfo(el)).toEqual({role: 'button', isProgrammatic: false}); + expect(getActiveElementInfo(el)).toEqual({role: 'button', isRoleSuppressed: false}); + }); + + it('flips isRoleSuppressed back to false once the marker is removed (post-blur)', () => { + const el = withRole(document.createElement('div'), 'button'); + el.setAttribute('data-suppress-active-role', 'true'); + expect(getActiveElementInfo(el).isRoleSuppressed).toBe(true); + el.removeAttribute('data-suppress-active-role'); + expect(getActiveElementInfo(el)).toEqual({role: 'button', isRoleSuppressed: false}); }); }); diff --git a/tests/unit/NavigationFocusReturnTest.ts b/tests/unit/NavigationFocusReturnTest.ts index 3f7f3a8ff00f..9764990b4956 100644 --- a/tests/unit/NavigationFocusReturnTest.ts +++ b/tests/unit/NavigationFocusReturnTest.ts @@ -31,7 +31,7 @@ const { 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; + restoreTriggerForRoute: (routeKey: string, options?: {suppressRole?: boolean}) => boolean; handleStateChange: (state: unknown) => void; resetForTests: () => void; setLastInteractiveElementForTests: (element: HTMLElement | null) => void; @@ -1456,7 +1456,7 @@ describe('handleStateChange integration', () => { }); }); - it('marks the restored element with `data-programmatic-focus` ONLY when the form-submit path opts in via `markNextRestoreAsProgrammatic()`', () => { + it('suppresses the restored element role only when the form-submit path opts in', () => { withFakeTimers(() => { simulateTab(); handleStateChange(onA); @@ -1470,11 +1470,11 @@ describe('handleStateChange integration', () => { handleStateChange(onA); jest.runAllTimers(); - expect(trigger.getAttribute('data-programmatic-focus')).toBe('true'); + expect(trigger.getAttribute('data-suppress-active-role')).toBe('true'); }); }); - it('does NOT mark the restored element on an ordinary Back navigation — focused button claims Enter shortcut normally (#90838 regression guard: only Save path opts in)', () => { + it('does not suppress the restored element role on an ordinary Back navigation', () => { withFakeTimers(() => { simulateTab(); handleStateChange(onA); @@ -1488,11 +1488,11 @@ describe('handleStateChange integration', () => { handleStateChange(onA); jest.runAllTimers(); - expect(trigger.getAttribute('data-programmatic-focus')).toBeNull(); + expect(trigger.getAttribute('data-suppress-active-role')).toBeNull(); }); }); - it("clears the `data-programmatic-focus` marker on the restored element's blur so the next user-driven focus is not misclassified", () => { + it("clears the role-suppression marker on the restored element's blur", () => { withFakeTimers(() => { simulateTab(); handleStateChange(onA); @@ -1505,15 +1505,15 @@ describe('handleStateChange integration', () => { markNextRestoreAsProgrammatic(); handleStateChange(onA); jest.runAllTimers(); - expect(trigger.getAttribute('data-programmatic-focus')).toBe('true'); + expect(trigger.getAttribute('data-suppress-active-role')).toBe('true'); // User Tab moves focus away → blur fires on the restored element → marker is removed. trigger.blur(); - expect(trigger.getAttribute('data-programmatic-focus')).toBeNull(); + expect(trigger.getAttribute('data-suppress-active-role')).toBeNull(); }); }); - it('`markNextRestoreAsProgrammatic()` is one-shot — a subsequent unmarked Back restore does NOT carry the marker', () => { + it('consumes the opt-in once, so a later Back restore is not suppressed', () => { withFakeTimers(() => { simulateTab(); handleStateChange(onA); @@ -1526,7 +1526,7 @@ describe('handleStateChange integration', () => { markNextRestoreAsProgrammatic(); handleStateChange(onA); jest.runAllTimers(); - expect(triggerA.getAttribute('data-programmatic-focus')).toBe('true'); + expect(triggerA.getAttribute('data-suppress-active-role')).toBe('true'); triggerA.blur(); // Second trip: ordinary Back, no opt-in. @@ -1536,7 +1536,7 @@ describe('handleStateChange integration', () => { triggerB.blur(); handleStateChange(onA); jest.runAllTimers(); - expect(triggerB.getAttribute('data-programmatic-focus')).toBeNull(); + expect(triggerB.getAttribute('data-suppress-active-role')).toBeNull(); }); }); From 62a8d048563ef3ec5345f916851d1542ac6b4da4 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Thu, 28 May 2026 11:39:01 +0300 Subject: [PATCH 21/89] fix: skip upcoming-transition wait for PUSH_PARAMS focus restore --- src/libs/NavigationFocusReturn.native.ts | 8 +++---- tests/unit/NavigationFocusReturnNativeTest.ts | 22 ++++++++++++++++--- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/libs/NavigationFocusReturn.native.ts b/src/libs/NavigationFocusReturn.native.ts index 83570815eb7b..fecad3d727c4 100644 --- a/src/libs/NavigationFocusReturn.native.ts +++ b/src/libs/NavigationFocusReturn.native.ts @@ -112,14 +112,14 @@ function cancelPendingRestore(): void { pendingRestore = null; } -function scheduleRestore(routeKey: string): void { +function scheduleRestore(routeKey: string, {waitForUpcomingTransition = true}: {waitForUpcomingTransition?: boolean} = {}): void { cancelPendingRestore(); let cancelled = false; let refocusHandle: {cancel: () => void} | null = null; let rafHandle: number | null = null; const handle = TransitionTracker.runAfterTransitions({ - // We're called from the nav state listener — the back transition may not have registered yet, so wait for it to start, then run after it ends (avoids restoring while the outgoing screen is still active). - waitForUpcomingTransition: true, + // Stack pops dispatch from the nav state listener before their transition registers, so wait for the upcoming one. PUSH_PARAMS (same-route param change) emits no transition — waiting would stall on the 1s timeout, so the caller opts out. + waitForUpcomingTransition, callback: () => { if (cancelled) { return; @@ -226,7 +226,7 @@ function notifyPushParamsForward(routeKey: string, prevParams: unknown): void { } function notifyPushParamsBackward(routeKey: string, targetParams: unknown): void { - scheduleRestore(compoundParamsKey(routeKey, targetParams)); + scheduleRestore(compoundParamsKey(routeKey, targetParams), {waitForUpcomingTransition: false}); } function cancelPendingFocusRestore(): void { diff --git a/tests/unit/NavigationFocusReturnNativeTest.ts b/tests/unit/NavigationFocusReturnNativeTest.ts index 293ad8f046cc..a97be126ad5d 100644 --- a/tests/unit/NavigationFocusReturnNativeTest.ts +++ b/tests/unit/NavigationFocusReturnNativeTest.ts @@ -34,15 +34,15 @@ jest.mock('../../src/libs/Accessibility/fireFocusEvent', () => ({ AccessibilityInfo.sendAccessibilityEvent = mockSendAccessibilityEvent; -type TtEntry = {cb: () => void; cancelled: boolean}; +type TtEntry = {cb: () => void; cancelled: boolean; waitForUpcomingTransition: boolean}; let mockTtQueue: TtEntry[] = []; jest.mock('../../src/libs/Navigation/TransitionTracker', () => ({ __esModule: true, default: { startTransition: jest.fn(), endTransition: jest.fn(), - runAfterTransitions: ({callback}: {callback: () => void}) => { - const entry: TtEntry = {cb: callback, cancelled: false}; + runAfterTransitions: ({callback, waitForUpcomingTransition = false}: {callback: () => void; waitForUpcomingTransition?: boolean}) => { + const entry: TtEntry = {cb: callback, cancelled: false, waitForUpcomingTransition}; mockTtQueue.push(entry); return { cancel: () => { @@ -256,6 +256,20 @@ describe('handleStateChange — backward', () => { expect(mockFireFocusEvent).toHaveBeenCalledWith(view); }); + it('waits for the upcoming transition on a stack pop', () => { + setLastPressedTriggerRefForTests(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(true); + }); + 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, [ @@ -413,6 +427,8 @@ describe('PUSH_PARAMS — same-route param change', () => { 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(); expect(mockFireFocusEvent).toHaveBeenCalledWith(view); }); From e7f413ef82be831f463fbc59a1f48f93bd077989 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Thu, 28 May 2026 12:16:34 +0300 Subject: [PATCH 22/89] fix: revert save-path to skipNextFocusRestore --- .../index.native.tsx | 15 ++-- .../ActiveElementRoleProvider/index.tsx | 34 ++++------ .../ActiveElementRoleProvider/types.ts | 1 - src/hooks/useActiveElementRole/index.ts | 10 ++- src/libs/NavigationFocusReturn.native.ts | 20 ++++-- src/libs/NavigationFocusReturn.ts | 41 +++++------ src/libs/programmaticFocus.ts | 24 ++----- .../request/step/IOURequestStepMerchant.tsx | 6 +- tests/unit/ActiveElementRoleProviderTest.ts | 50 -------------- tests/unit/NavigationFocusReturnNativeTest.ts | 46 +++++++++++++ tests/unit/NavigationFocusReturnTest.ts | 68 ++++--------------- 11 files changed, 132 insertions(+), 183 deletions(-) delete mode 100644 tests/unit/ActiveElementRoleProviderTest.ts diff --git a/src/components/ActiveElementRoleProvider/index.native.tsx b/src/components/ActiveElementRoleProvider/index.native.tsx index bea4802d4bca..4a9f2290b2b0 100644 --- a/src/components/ActiveElementRoleProvider/index.native.tsx +++ b/src/components/ActiveElementRoleProvider/index.native.tsx @@ -1,12 +1,19 @@ import React from 'react'; import type {ActiveElementRoleContextValue, ActiveElementRoleProps} from './types'; -const EMPTY: ActiveElementRoleContextValue = {role: null, isRoleSuppressed: false}; - -const ActiveElementRoleContext = React.createContext(EMPTY); +const ActiveElementRoleContext = React.createContext({ + role: null, +}); function ActiveElementRoleProvider({children}: ActiveElementRoleProps) { - return {children}; + const value = React.useMemo( + () => ({ + role: null, + }), + [], + ); + + return {children}; } export default ActiveElementRoleProvider; diff --git a/src/components/ActiveElementRoleProvider/index.tsx b/src/components/ActiveElementRoleProvider/index.tsx index 9954dfca85a3..583b1d89d7d9 100644 --- a/src/components/ActiveElementRoleProvider/index.tsx +++ b/src/components/ActiveElementRoleProvider/index.tsx @@ -1,30 +1,19 @@ import React, {useEffect, useState} from 'react'; -import {SUPPRESS_ACTIVE_ROLE_DATA_ATTRIBUTE} from '@libs/programmaticFocus'; import type {ActiveElementRoleContextValue, ActiveElementRoleProps} from './types'; -const EMPTY: ActiveElementRoleContextValue = {role: null, isRoleSuppressed: false}; - -const ActiveElementRoleContext = React.createContext(EMPTY); - -function getActiveElementInfo(el: Element | null): ActiveElementRoleContextValue { - if (!el) { - return EMPTY; - } - return { - role: el.role ?? null, - isRoleSuppressed: el.getAttribute(SUPPRESS_ACTIVE_ROLE_DATA_ATTRIBUTE) === 'true', - }; -} +const ActiveElementRoleContext = React.createContext({ + role: null, +}); function ActiveElementRoleProvider({children}: ActiveElementRoleProps) { - const [info, setInfo] = useState(() => getActiveElementInfo(document?.activeElement ?? null)); + const [activeRoleRef, setRole] = useState(() => document?.activeElement?.role ?? null); const handleFocusIn = () => { - setInfo(getActiveElementInfo(document?.activeElement ?? null)); + setRole(document?.activeElement?.role ?? null); }; const handleFocusOut = () => { - setInfo(EMPTY); + setRole(null); }; useEffect(() => { @@ -37,8 +26,15 @@ function ActiveElementRoleProvider({children}: ActiveElementRoleProps) { }; }, []); - return {children}; + const value = React.useMemo( + () => ({ + role: activeRoleRef, + }), + [activeRoleRef], + ); + + return {children}; } export default ActiveElementRoleProvider; -export {ActiveElementRoleContext, getActiveElementInfo}; +export {ActiveElementRoleContext}; diff --git a/src/components/ActiveElementRoleProvider/types.ts b/src/components/ActiveElementRoleProvider/types.ts index a3947f827531..f22343b12550 100644 --- a/src/components/ActiveElementRoleProvider/types.ts +++ b/src/components/ActiveElementRoleProvider/types.ts @@ -1,6 +1,5 @@ type ActiveElementRoleContextValue = { role: string | null; - isRoleSuppressed: boolean; }; type ActiveElementRoleProps = { diff --git a/src/hooks/useActiveElementRole/index.ts b/src/hooks/useActiveElementRole/index.ts index f980be1fea44..8685b600fe95 100644 --- a/src/hooks/useActiveElementRole/index.ts +++ b/src/hooks/useActiveElementRole/index.ts @@ -2,13 +2,11 @@ import {useContext} from 'react'; import {ActiveElementRoleContext} from '@components/ActiveElementRoleProvider'; import type UseActiveElementRole from './types'; -/** - * Returns the focused element's role, or null if the focus is programmatic (a11y restore). - * Role-based consumers (e.g., `Button` enter-shortcut suppression) want to react to user-driven focus only. - */ +/** 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, isRoleSuppressed} = useContext(ActiveElementRoleContext); - return isRoleSuppressed ? null : role; + const {role} = useContext(ActiveElementRoleContext); + + return role; }; export default useActiveElementRole; diff --git a/src/libs/NavigationFocusReturn.native.ts b/src/libs/NavigationFocusReturn.native.ts index fecad3d727c4..c0052bbdba58 100644 --- a/src/libs/NavigationFocusReturn.native.ts +++ b/src/libs/NavigationFocusReturn.native.ts @@ -22,6 +22,7 @@ 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; /* @@ -48,8 +49,10 @@ function notifyPressedTrigger(ref: RefObject | null, identifier?: s lastPressedTriggerAt = ref ? Date.now() : 0; } -/** Web-only signal. Native uses TalkBack/VoiceOver's announcement model — there's no per-button Enter-shortcut to suppress. No-op for cross-platform signature parity. */ -function markNextRestoreAsProgrammatic(): void {} +/** 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); @@ -165,13 +168,21 @@ function handleStateChange(newState: NavigationState | undefined): void { const {action, removedKeys} = diffNavigationState(prevState, newState); if (action.type === 'forward') { + skipNextRestore = false; cancelPendingRestore(); captureTriggerForRoute(action.captureKey); lastPressedTriggerRef = null; lastPressedTriggerIdentifier = null; } else if (action.type === 'backward') { - scheduleRestore(action.restoreKey); + if (skipNextRestore) { + skipNextRestore = false; + cancelPendingRestore(); + triggerMap.delete(action.restoreKey); + } else { + scheduleRestore(action.restoreKey); + } } else if (action.type === 'lateral') { + skipNextRestore = false; cancelPendingRestore(); } @@ -215,6 +226,7 @@ function teardownNavigationFocusReturn(): void { lastPressedTriggerRef = null; lastPressedTriggerIdentifier = null; lastPressedTriggerAt = 0; + skipNextRestore = false; stateUnsubscribe?.(); stateUnsubscribe = null; } @@ -274,7 +286,7 @@ export { notifyPushParamsForward, notifyPushParamsBackward, cancelPendingFocusRestore, - markNextRestoreAsProgrammatic, + skipNextFocusRestore, isFocusRestoreInProgress, shouldSkipAutoFocusDueToExistingFocus, resetForTests, diff --git a/src/libs/NavigationFocusReturn.ts b/src/libs/NavigationFocusReturn.ts index ea850fabcf74..be6a8e70fba7 100644 --- a/src/libs/NavigationFocusReturn.ts +++ b/src/libs/NavigationFocusReturn.ts @@ -10,7 +10,6 @@ import getHadTabNavigation from './hadTabNavigation'; import {consumeLauncher, pickLauncher, resetLauncherStackForTests} from './LauncherStack'; import navigationRef from './Navigation/navigationRef'; import {collectRouteKeys, diffNavigationState} from './navigationStateDiff'; -import {suppressActiveRole} from './programmaticFocus'; import {isCycleIdle, Priorities, resetCycle, tryClaim} from './ScreenFocusArbiter'; /** focusin tracks the last keyboard-focused element; a nav state listener captures it against the outgoing route and restores it on backward nav. */ @@ -45,7 +44,7 @@ function setTriggerEntry(routeKey: string, entry: TriggerEntry): void { let prevState: NavigationState | undefined; let pendingRestore: {cancel: () => void} | null = null; let isRestoringFocus = false; -let nextRestoreIsProgrammatic = false; +let skipNextRestore = false; let focusinHandler: ((e: FocusEvent) => void) | null = null; let mouseActivationHandler: ((e: MouseEvent) => void) | null = null; let stateUnsubscribe: (() => void) | null = null; @@ -96,15 +95,9 @@ function notifyPushParamsBackward(routeKey: string, targetParams: unknown): void scheduleRestore(compoundParamsKey(routeKey, targetParams)); } -/** Marks the next backward restore as programmatic — the restored element gets `data-programmatic-focus` so role-based consumers (Button enter-shortcut suppression) treat the restore as system-driven. Call before a form-submit `goBack`. One-shot; cleared on consume/cancel/teardown. */ -function markNextRestoreAsProgrammatic(): void { - nextRestoreIsProgrammatic = true; -} - -function consumeProgrammaticFlag(): boolean { - const v = nextRestoreIsProgrammatic; - nextRestoreIsProgrammatic = false; - return v; +/** 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. */ @@ -207,7 +200,7 @@ function cancelPendingFocusRestore(): void { } } -function restoreTriggerForRoute(routeKey: string, {suppressRole = false}: {suppressRole?: boolean} = {}): boolean { +function restoreTriggerForRoute(routeKey: string): boolean { if (typeof document === 'undefined') { return false; } @@ -245,7 +238,6 @@ function restoreTriggerForRoute(routeKey: string, {suppressRole = false}: {suppr for (const candidate of candidates) { const before = document.activeElement; isRestoringFocus = true; - const unsuppressRole = suppressRole ? suppressActiveRole(candidate) : null; try { candidate.focus(focusOptions); } finally { @@ -265,7 +257,6 @@ function restoreTriggerForRoute(routeKey: string, {suppressRole = false}: {suppr scheduleReturnHoldRelease(); return true; } - unsuppressRole?.(); } // Silent no-op (transient display:none / visibility:hidden ancestor) — leave the entry for scheduleRestore to retry; release the cycle so AUTO/INITIAL aren't blocked during the window. @@ -283,8 +274,6 @@ const RESTORE_RETRY_MS = 50; function scheduleRestore(routeKey: string): void { cancelPendingRestore(); - // Consume once at schedule time so an abandoned/cancelled restore can't leak the flag into a later unrelated Back; retries below reuse the captured value. - const shouldSuppressRole = consumeProgrammaticFlag(); // `cancelled` flag in case a primitive's cancel races a queued callback. let cancelled = false; let attempts = 0; @@ -304,7 +293,7 @@ function scheduleRestore(routeKey: string): void { return; } attempts += 1; - const restored = restoreTriggerForRoute(routeKey, {suppressRole: shouldSuppressRole}); + const restored = restoreTriggerForRoute(routeKey); if (restored || !triggerMap.has(routeKey)) { pendingRestore = null; return; @@ -349,20 +338,24 @@ function handleStateChange(newState: NavigationState | undefined): void { } if (action.type === 'forward') { + skipNextRestore = false; cancelPendingRestore(); captureTriggerForRoute(action.captureKey); // Loose refs would pin detached unmounted nodes; triggerMap holds the captured copy. lastInteractiveElement = null; lastMouseTrigger = null; lastMouseTriggerAt = 0; - // A non-backward nav after a form-submit mark means the mark is stale — drop it so it can't taint a later Back. - consumeProgrammaticFlag(); } else if (action.type === 'backward') { - scheduleRestore(action.restoreKey); + if (skipNextRestore) { + skipNextRestore = false; + cancelPendingRestore(); + } else { + scheduleRestore(action.restoreKey); + } } else if (action.type === 'lateral') { + skipNextRestore = false; // Stale restore would steal focus back on sibling nav. cancelPendingRestore(); - consumeProgrammaticFlag(); } for (const key of removedKeys) { @@ -443,7 +436,7 @@ function teardownNavigationFocusReturn(): void { lastInteractiveElement = null; lastMouseTrigger = null; lastMouseTriggerAt = 0; - nextRestoreIsProgrammatic = false; + skipNextRestore = false; if (typeof document !== 'undefined') { if (focusinHandler) { document.removeEventListener('focusin', focusinHandler, true); @@ -471,7 +464,7 @@ function resetForTests(): void { lastMouseTrigger = null; lastMouseTriggerAt = 0; lastRestoreTarget = null; - nextRestoreIsProgrammatic = false; + skipNextRestore = false; } function setLastInteractiveElementForTests(element: HTMLElement | null): void { @@ -494,7 +487,7 @@ export { notifyPushParamsForward, notifyPushParamsBackward, cancelPendingFocusRestore, - markNextRestoreAsProgrammatic, + skipNextFocusRestore, notifyPressedTrigger, registerPressable, isFocusRestoreInProgress, diff --git a/src/libs/programmaticFocus.ts b/src/libs/programmaticFocus.ts index 3f8c3c4fd40e..24fbe73be788 100644 --- a/src/libs/programmaticFocus.ts +++ b/src/libs/programmaticFocus.ts @@ -1,12 +1,10 @@ -// Suppresses the `:focus-visible` ring (see web/index.html) on programmatically-focused elements — used for a11y autofocus and focus restore so mouse users don't see a ring (WCAG 2.4.7). +// 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'; -// Read by ActiveElementRoleProvider to report no active role — keeps page-level Enter shortcuts active after a form-submit restore. Distinct from the ring marker so generic autofocus doesn't suppress role. -const SUPPRESS_ACTIVE_ROLE_DATA_ATTRIBUTE = 'data-suppress-active-role'; -// Sets `attribute` on `el`, auto-clearing on blur. Returns a function that clears synchronously (used when the focus call never landed). -function markWithAttribute(el: HTMLElement, attribute: string): () => void { - el.setAttribute(attribute, 'true'); - const clear = () => el.removeAttribute(attribute); +/** 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); @@ -14,14 +12,4 @@ function markWithAttribute(el: HTMLElement, attribute: string): () => void { }; } -/** Suppresses the focus-visible ring on a programmatically-focused element (a11y autofocus / restore). */ -function markProgrammaticFocus(el: HTMLElement): () => void { - return markWithAttribute(el, PROGRAMMATIC_FOCUS_DATA_ATTRIBUTE); -} - -/** Marks a restored element so `ActiveElementRoleProvider` reports no active role — only the form-submit restore path opts in. */ -function suppressActiveRole(el: HTMLElement): () => void { - return markWithAttribute(el, SUPPRESS_ACTIVE_ROLE_DATA_ATTRIBUTE); -} - -export {PROGRAMMATIC_FOCUS_DATA_ATTRIBUTE, SUPPRESS_ACTIVE_ROLE_DATA_ATTRIBUTE, markProgrammaticFocus, suppressActiveRole}; +export {PROGRAMMATIC_FOCUS_DATA_ATTRIBUTE, markProgrammaticFocus}; diff --git a/src/pages/iou/request/step/IOURequestStepMerchant.tsx b/src/pages/iou/request/step/IOURequestStepMerchant.tsx index 13201ba758e2..b0b793568048 100644 --- a/src/pages/iou/request/step/IOURequestStepMerchant.tsx +++ b/src/pages/iou/request/step/IOURequestStepMerchant.tsx @@ -17,7 +17,7 @@ import useShowNotFoundPageInIOUStep from '@hooks/useShowNotFoundPageInIOUStep'; import useThemeStyles from '@hooks/useThemeStyles'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Navigation from '@libs/Navigation/Navigation'; -import {markNextRestoreAsProgrammatic} from '@libs/NavigationFocusReturn'; +import {skipNextFocusRestore} from '@libs/NavigationFocusReturn'; import {getTransactionDetails, isExpenseRequest, isPolicyExpenseChat} from '@libs/ReportUtils'; import {hasReceipt} from '@libs/TransactionUtils'; import {isInvalidMerchantValue, isValidInputLength} from '@libs/ValidationUtils'; @@ -85,8 +85,8 @@ function IOURequestStepMerchant({ return; } shouldNavigateAfterSaveRef.current = false; - // Save-by-Enter: suppress the restored Merchant row from claiming Enter so the page's "Create expense" shortcut fires on a follow-up Enter. Back/Esc don't call this — restored focus claims Enter normally there. - markNextRestoreAsProgrammatic(); + // Skip focus-restore on the save path so the re-focused Merchant row doesn't consume the next Enter (which should hit "Create expense"). Back/Esc still restore. + skipNextFocusRestore(); navigateBack(); }, [isSaved, navigateBack]); diff --git a/tests/unit/ActiveElementRoleProviderTest.ts b/tests/unit/ActiveElementRoleProviderTest.ts deleted file mode 100644 index 492b605ac803..000000000000 --- a/tests/unit/ActiveElementRoleProviderTest.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * @jest-environment jsdom - */ -/* eslint-disable import/extensions */ -const {getActiveElementInfo} = require<{ - getActiveElementInfo: (el: Element | null) => {role: string | null; isRoleSuppressed: boolean}; -}>('../../src/components/ActiveElementRoleProvider/index.tsx'); -/* eslint-enable import/extensions */ - -/* - * jsdom v20 doesn't reflect ARIAMixin's `role` to a property; stub it for parity with real browsers. - */ -function withRole(el: Element, role: string | null): Element { - Object.defineProperty(el, 'role', {value: role, configurable: true}); - return el; -} - -describe('getActiveElementInfo', () => { - afterEach(() => { - document.body.innerHTML = ''; - }); - - it('returns null role + isRoleSuppressed=false for a null element', () => { - expect(getActiveElementInfo(null)).toEqual({role: null, isRoleSuppressed: false}); - }); - - it('returns the element role + isRoleSuppressed=false when no suppression marker is present', () => { - expect(getActiveElementInfo(withRole(document.createElement('div'), 'button'))).toEqual({role: 'button', isRoleSuppressed: false}); - }); - - it('reports isRoleSuppressed=true while preserving the real role when the element carries data-suppress-active-role', () => { - const el = withRole(document.createElement('div'), 'button'); - el.setAttribute('data-suppress-active-role', 'true'); - expect(getActiveElementInfo(el)).toEqual({role: 'button', isRoleSuppressed: true}); - }); - - it('ignores the ring-only data-programmatic-focus marker so generic autofocus keeps its role', () => { - const el = withRole(document.createElement('div'), 'button'); - el.setAttribute('data-programmatic-focus', 'true'); - expect(getActiveElementInfo(el)).toEqual({role: 'button', isRoleSuppressed: false}); - }); - - it('flips isRoleSuppressed back to false once the marker is removed (post-blur)', () => { - const el = withRole(document.createElement('div'), 'button'); - el.setAttribute('data-suppress-active-role', 'true'); - expect(getActiveElementInfo(el).isRoleSuppressed).toBe(true); - el.removeAttribute('data-suppress-active-role'); - expect(getActiveElementInfo(el)).toEqual({role: 'button', isRoleSuppressed: false}); - }); -}); diff --git a/tests/unit/NavigationFocusReturnNativeTest.ts b/tests/unit/NavigationFocusReturnNativeTest.ts index a97be126ad5d..10e6b1709064 100644 --- a/tests/unit/NavigationFocusReturnNativeTest.ts +++ b/tests/unit/NavigationFocusReturnNativeTest.ts @@ -85,6 +85,7 @@ const { notifyPushParamsForward, notifyPushParamsBackward, cancelPendingFocusRestore, + skipNextFocusRestore, isFocusRestoreInProgress, shouldSkipAutoFocusDueToExistingFocus, resetForTests, @@ -100,6 +101,7 @@ const { notifyPushParamsForward: (routeKey: string, prevParams: unknown) => void; notifyPushParamsBackward: (routeKey: string, targetParams: unknown) => void; cancelPendingFocusRestore: () => void; + skipNextFocusRestore: () => void; isFocusRestoreInProgress: () => boolean; shouldSkipAutoFocusDueToExistingFocus: () => boolean; resetForTests: () => void; @@ -270,6 +272,50 @@ describe('handleStateChange — backward', () => { expect(mockTtQueue.at(-1)?.waitForUpcomingTransition).toBe(true); }); + it('does NOT restore when skipNextFocusRestore was called before goBack (form-submit path)', () => { + setLastPressedTriggerRefForTests(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', () => { + setLastPressedTriggerRefForTests(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(); + + // Deeplink-style forward (no fresh trigger) + back: the skipped entry must not resurface. + 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, [ diff --git a/tests/unit/NavigationFocusReturnTest.ts b/tests/unit/NavigationFocusReturnTest.ts index 9764990b4956..29f2a1f08d5e 100644 --- a/tests/unit/NavigationFocusReturnTest.ts +++ b/tests/unit/NavigationFocusReturnTest.ts @@ -21,7 +21,7 @@ const { notifyPushParamsForward, notifyPushParamsBackward, cancelPendingFocusRestore, - markNextRestoreAsProgrammatic, + skipNextFocusRestore, isFocusRestoreInProgress, compoundParamsKey, shouldSkipAutoFocusDueToExistingFocus, @@ -31,7 +31,7 @@ const { 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, options?: {suppressRole?: boolean}) => boolean; + restoreTriggerForRoute: (routeKey: string) => boolean; handleStateChange: (state: unknown) => void; resetForTests: () => void; setLastInteractiveElementForTests: (element: HTMLElement | null) => void; @@ -39,7 +39,7 @@ const { notifyPushParamsForward: (routeKey: string, prevParams: unknown) => void; notifyPushParamsBackward: (routeKey: string, targetParams: unknown) => void; cancelPendingFocusRestore: () => void; - markNextRestoreAsProgrammatic: () => void; + skipNextFocusRestore: () => void; isFocusRestoreInProgress: () => boolean; compoundParamsKey: (routeKey: string, params: unknown) => string; shouldSkipAutoFocusDueToExistingFocus: () => boolean; @@ -1456,7 +1456,7 @@ describe('handleStateChange integration', () => { }); }); - it('suppresses the restored element role only when the form-submit path opts in', () => { + it('skipNextFocusRestore suppresses the restore for the next backward nav only (form-submit goBack), then resumes', () => { withFakeTimers(() => { simulateTab(); handleStateChange(onA); @@ -1466,77 +1466,37 @@ describe('handleStateChange integration', () => { handleStateChange(onAB); trigger.blur(); - markNextRestoreAsProgrammatic(); + skipNextFocusRestore(); + const spy = jest.spyOn(trigger, 'focus'); handleStateChange(onA); jest.runAllTimers(); + expect(spy).not.toHaveBeenCalled(); - expect(trigger.getAttribute('data-suppress-active-role')).toBe('true'); - }); - }); - - it('does not suppress the restored element role on an ordinary Back navigation', () => { - withFakeTimers(() => { - simulateTab(); - handleStateChange(onA); - - const trigger = appendButton(); - fireFocusIn(trigger); + // The flag is one-shot: a subsequent Back-button dismissal restores normally. handleStateChange(onAB); trigger.blur(); - - // No markNextRestoreAsProgrammatic() — this models the Back/Esc dismissal path. handleStateChange(onA); jest.runAllTimers(); - - expect(trigger.getAttribute('data-suppress-active-role')).toBeNull(); + expect(spy).toHaveBeenCalled(); }); }); - it("clears the role-suppression marker on the restored element's blur", () => { + it('skipNextFocusRestore flag is cleared by an intervening forward nav so it cannot leak into a later backward', () => { withFakeTimers(() => { simulateTab(); handleStateChange(onA); const trigger = appendButton(); fireFocusIn(trigger); - handleStateChange(onAB); - trigger.blur(); - markNextRestoreAsProgrammatic(); - handleStateChange(onA); - jest.runAllTimers(); - expect(trigger.getAttribute('data-suppress-active-role')).toBe('true'); - - // User Tab moves focus away → blur fires on the restored element → marker is removed. - trigger.blur(); - expect(trigger.getAttribute('data-suppress-active-role')).toBeNull(); - }); - }); - - it('consumes the opt-in once, so a later Back restore is not suppressed', () => { - withFakeTimers(() => { - simulateTab(); - handleStateChange(onA); - - // First trip: Save path with the marker. - const triggerA = appendButton(); - fireFocusIn(triggerA); + skipNextFocusRestore(); handleStateChange(onAB); - triggerA.blur(); - markNextRestoreAsProgrammatic(); - handleStateChange(onA); - jest.runAllTimers(); - expect(triggerA.getAttribute('data-suppress-active-role')).toBe('true'); - triggerA.blur(); - // Second trip: ordinary Back, no opt-in. - const triggerB = appendButton(); - fireFocusIn(triggerB); - handleStateChange(onAB); - triggerB.blur(); + trigger.blur(); + const spy = jest.spyOn(trigger, 'focus'); handleStateChange(onA); jest.runAllTimers(); - expect(triggerB.getAttribute('data-suppress-active-role')).toBeNull(); + expect(spy).toHaveBeenCalled(); }); }); From 4a4ff477f9a1d6d1505095ae195b6eca7b6b353f Mon Sep 17 00:00:00 2001 From: TaduJR Date: Thu, 28 May 2026 13:19:01 +0300 Subject: [PATCH 23/89] test: fix NavigationRouteContext mock crash --- src/components/ActiveElementRoleProvider/index.tsx | 2 +- tests/ui/IOURequestStepAmountDraftTest.tsx | 1 + tests/ui/TimeExpenseConfirmationTest.tsx | 1 + tests/ui/components/HoldReasonFormViewTest.tsx | 1 + tests/ui/components/IOURequestStepReportTest.tsx | 1 + tests/ui/components/SearchAutocompleteListTest.tsx | 1 + 6 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/ActiveElementRoleProvider/index.tsx b/src/components/ActiveElementRoleProvider/index.tsx index 583b1d89d7d9..630af8618c08 100644 --- a/src/components/ActiveElementRoleProvider/index.tsx +++ b/src/components/ActiveElementRoleProvider/index.tsx @@ -6,7 +6,7 @@ const ActiveElementRoleContext = React.createContext(() => document?.activeElement?.role ?? null); + const [activeRoleRef, setRole] = useState(document?.activeElement?.role ?? null); const handleFocusIn = () => { setRole(document?.activeElement?.role ?? null); diff --git a/tests/ui/IOURequestStepAmountDraftTest.tsx b/tests/ui/IOURequestStepAmountDraftTest.tsx index 125206e95eaa..707f5ce2cf29 100644 --- a/tests/ui/IOURequestStepAmountDraftTest.tsx +++ b/tests/ui/IOURequestStepAmountDraftTest.tsx @@ -104,6 +104,7 @@ jest.mock('@react-navigation/native', () => { getState: jest.fn(() => ({})), }; return { + ...jest.requireActual('@react-navigation/native'), createNavigationContainerRef: jest.fn(() => mockRef), useIsFocused: () => true, useNavigation: () => ({navigate: jest.fn(), addListener: jest.fn()}), diff --git a/tests/ui/TimeExpenseConfirmationTest.tsx b/tests/ui/TimeExpenseConfirmationTest.tsx index 7a2bc8376319..36e263e51cde 100644 --- a/tests/ui/TimeExpenseConfirmationTest.tsx +++ b/tests/ui/TimeExpenseConfirmationTest.tsx @@ -106,6 +106,7 @@ jest.mock('@react-navigation/native', () => { getState: jest.fn(() => ({})), }; return { + ...jest.requireActual('@react-navigation/native'), 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..7f78c92d128d 100644 --- a/tests/ui/components/HoldReasonFormViewTest.tsx +++ b/tests/ui/components/HoldReasonFormViewTest.tsx @@ -12,6 +12,7 @@ jest.mock('@libs/Navigation/Navigation', () => ({ navigate: jest.fn(), })); jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), createNavigationContainerRef: jest.fn(), useIsFocused: () => true, useNavigation: () => ({navigate: jest.fn(), addListener: jest.fn(), goBack: jest.fn(), isFocused: () => true}), diff --git a/tests/ui/components/IOURequestStepReportTest.tsx b/tests/ui/components/IOURequestStepReportTest.tsx index be407b9d47de..5476521a923a 100644 --- a/tests/ui/components/IOURequestStepReportTest.tsx +++ b/tests/ui/components/IOURequestStepReportTest.tsx @@ -56,6 +56,7 @@ jest.mock('@react-navigation/native', () => { getState: jest.fn(() => ({})), }; return { + ...jest.requireActual('@react-navigation/native'), 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 95a8d4cf7042..c53e1946fc66 100644 --- a/tests/ui/components/SearchAutocompleteListTest.tsx +++ b/tests/ui/components/SearchAutocompleteListTest.tsx @@ -13,6 +13,7 @@ import waitForBatchedUpdatesWithAct from '../../utils/waitForBatchedUpdatesWithA jest.mock('@src/components/ConfirmedRoute.tsx'); jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), useIsFocused: jest.fn(), useRoute: jest.fn(), usePreventRemove: jest.fn(), From b960997d6cba7f7070c357c524e3300885e8ffc6 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Thu, 28 May 2026 14:15:01 +0300 Subject: [PATCH 24/89] fix: type react-navigation mock spread --- tests/ui/IOURequestStepAmountDraftTest.tsx | 2 +- tests/ui/TimeExpenseConfirmationTest.tsx | 2 +- tests/ui/components/HoldReasonFormViewTest.tsx | 2 +- tests/ui/components/IOURequestStepReportTest.tsx | 2 +- tests/ui/components/SearchAutocompleteListTest.tsx | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/ui/IOURequestStepAmountDraftTest.tsx b/tests/ui/IOURequestStepAmountDraftTest.tsx index 707f5ce2cf29..97f49d18f6ad 100644 --- a/tests/ui/IOURequestStepAmountDraftTest.tsx +++ b/tests/ui/IOURequestStepAmountDraftTest.tsx @@ -104,7 +104,7 @@ jest.mock('@react-navigation/native', () => { getState: jest.fn(() => ({})), }; return { - ...jest.requireActual('@react-navigation/native'), + ...jest.requireActual>('@react-navigation/native'), createNavigationContainerRef: jest.fn(() => mockRef), useIsFocused: () => true, useNavigation: () => ({navigate: jest.fn(), addListener: jest.fn()}), diff --git a/tests/ui/TimeExpenseConfirmationTest.tsx b/tests/ui/TimeExpenseConfirmationTest.tsx index 36e263e51cde..db5e9fb6ff87 100644 --- a/tests/ui/TimeExpenseConfirmationTest.tsx +++ b/tests/ui/TimeExpenseConfirmationTest.tsx @@ -106,7 +106,7 @@ jest.mock('@react-navigation/native', () => { getState: jest.fn(() => ({})), }; return { - ...jest.requireActual('@react-navigation/native'), + ...jest.requireActual>('@react-navigation/native'), 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 7f78c92d128d..83208ace7e83 100644 --- a/tests/ui/components/HoldReasonFormViewTest.tsx +++ b/tests/ui/components/HoldReasonFormViewTest.tsx @@ -12,7 +12,7 @@ jest.mock('@libs/Navigation/Navigation', () => ({ navigate: jest.fn(), })); jest.mock('@react-navigation/native', () => ({ - ...jest.requireActual('@react-navigation/native'), + ...jest.requireActual>('@react-navigation/native'), createNavigationContainerRef: jest.fn(), useIsFocused: () => true, useNavigation: () => ({navigate: jest.fn(), addListener: jest.fn(), goBack: jest.fn(), isFocused: () => true}), diff --git a/tests/ui/components/IOURequestStepReportTest.tsx b/tests/ui/components/IOURequestStepReportTest.tsx index 5476521a923a..1986a76def73 100644 --- a/tests/ui/components/IOURequestStepReportTest.tsx +++ b/tests/ui/components/IOURequestStepReportTest.tsx @@ -56,7 +56,7 @@ jest.mock('@react-navigation/native', () => { getState: jest.fn(() => ({})), }; return { - ...jest.requireActual('@react-navigation/native'), + ...jest.requireActual>('@react-navigation/native'), 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 c53e1946fc66..36571643476a 100644 --- a/tests/ui/components/SearchAutocompleteListTest.tsx +++ b/tests/ui/components/SearchAutocompleteListTest.tsx @@ -13,7 +13,7 @@ import waitForBatchedUpdatesWithAct from '../../utils/waitForBatchedUpdatesWithA jest.mock('@src/components/ConfirmedRoute.tsx'); jest.mock('@react-navigation/native', () => ({ - ...jest.requireActual('@react-navigation/native'), + ...jest.requireActual>('@react-navigation/native'), useIsFocused: jest.fn(), useRoute: jest.fn(), usePreventRemove: jest.fn(), From 9417a844f2df83229653b0660b1a6bd4ce0eefb8 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Thu, 28 May 2026 14:52:57 +0300 Subject: [PATCH 25/89] chore: trim scheduleRestore comment --- src/libs/NavigationFocusReturn.native.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/NavigationFocusReturn.native.ts b/src/libs/NavigationFocusReturn.native.ts index c0052bbdba58..4e62475e78b2 100644 --- a/src/libs/NavigationFocusReturn.native.ts +++ b/src/libs/NavigationFocusReturn.native.ts @@ -121,7 +121,7 @@ function scheduleRestore(routeKey: string, {waitForUpcomingTransition = true}: { let refocusHandle: {cancel: () => void} | null = null; let rafHandle: number | null = null; const handle = TransitionTracker.runAfterTransitions({ - // Stack pops dispatch from the nav state listener before their transition registers, so wait for the upcoming one. PUSH_PARAMS (same-route param change) emits no transition — waiting would stall on the 1s timeout, so the caller opts out. + // 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: () => { if (cancelled) { From af0a89856bb96928b5be482308f49c7388d5b5f1 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Thu, 28 May 2026 18:29:57 +0300 Subject: [PATCH 26/89] refactor: harden screen-reader focus restoration and tidy module layout --- .../implementation/BaseGenericPressable.tsx | 9 +- src/hooks/useDialogContainerFocus/index.ts | 33 ++--- src/hooks/useNavigateBackOnSave/index.ts | 34 +++++ src/hooks/useScreenInitialFocus/index.ts | 35 ++++-- .../Accessibility/fireFocusEvent/index.ios.ts | 9 -- .../{index.android.ts => index.native.ts} | 0 .../scheduleRefocus/index.ios.ts | 14 --- .../index.native.ts} | 73 ++++++----- .../index.ts} | 28 ++--- src/libs/claimInitialFocus.ts | 24 ++++ .../step/IOURequestStepDescription.tsx | 23 +--- .../request/step/IOURequestStepMerchant.tsx | 26 +--- tests/perf-test/SelectionList.perf-test.tsx | 3 +- tests/ui/IOURequestStepAmountDraftTest.tsx | 3 +- tests/ui/IOURequestStepDistanceTest.tsx | 3 +- tests/ui/IOURequestStepTimeRateTest.tsx | 3 +- tests/ui/TimeExpenseConfirmationTest.tsx | 3 +- .../ui/components/HoldReasonFormViewTest.tsx | 3 +- .../IOURequestStepConfirmationPageTest.tsx | 3 +- .../components/IOURequestStepDistanceTest.tsx | 3 +- .../components/IOURequestStepReportTest.tsx | 3 +- .../components/SearchAutocompleteListTest.tsx | 3 +- tests/ui/components/SettlementButtonTest.tsx | 3 +- tests/unit/NavigationFocusReturnNativeTest.ts | 117 +++++++++++++++--- tests/unit/NavigationFocusReturnTest.ts | 2 +- tests/unit/fireFocusEventAndroidTest.ts | 4 +- tests/unit/useScreenInitialFocusTest.tsx | 39 +++++- tests/utils/mockReactNavigationNative.ts | 14 +++ 28 files changed, 337 insertions(+), 180 deletions(-) create mode 100644 src/hooks/useNavigateBackOnSave/index.ts delete mode 100644 src/libs/Accessibility/fireFocusEvent/index.ios.ts rename src/libs/Accessibility/fireFocusEvent/{index.android.ts => index.native.ts} (100%) delete mode 100644 src/libs/Accessibility/scheduleRefocus/index.ios.ts rename src/libs/{NavigationFocusReturn.native.ts => NavigationFocusReturn/index.native.ts} (83%) rename src/libs/{NavigationFocusReturn.ts => NavigationFocusReturn/index.ts} (97%) create mode 100644 src/libs/claimInitialFocus.ts create mode 100644 tests/utils/mockReactNavigationNative.ts diff --git a/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx b/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx index bbabdfb1372d..045260a4319f 100644 --- a/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx +++ b/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx @@ -1,5 +1,5 @@ import {NavigationRouteContext} from '@react-navigation/native'; -import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useContext, useEffect, useId, useMemo, useRef, useState} from 'react'; import type {GestureResponderEvent, View} from 'react-native'; // eslint-disable-next-line no-restricted-imports import {Pressable} from 'react-native'; @@ -55,11 +55,12 @@ function GenericPressable({ const internalRef = useRef(null); const composedRef = useMemo(() => mergeRefs(ref, internalRef), [ref]); const routeKey = useContext(NavigationRouteContext)?.key ?? null; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- `||` is intentional: `BaseListItem` passes `id={keyForList ?? ''}` so empty-string ids must fall through. - const focusIdentifier = rest.id || rest.nativeID || rest.testID || rest.accessibilityLabel || undefined; + const generatedId = useId(); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- `||` lets empty-string ids fall through to the unique generated id; accessibilityLabel is excluded since it collides within a route. + const focusIdentifier = rest.id || rest.nativeID || rest.testID || generatedId; useEffect(() => { - if (!isScreenReaderActive || !routeKey || !focusIdentifier) { + if (!isScreenReaderActive || !routeKey) { return; } return registerPressable(routeKey, focusIdentifier, internalRef); diff --git a/src/hooks/useDialogContainerFocus/index.ts b/src/hooks/useDialogContainerFocus/index.ts index 56b8328eb9bf..09e7bd26bc31 100644 --- a/src/hooks/useDialogContainerFocus/index.ts +++ b/src/hooks/useDialogContainerFocus/index.ts @@ -1,15 +1,16 @@ 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'; // eslint-disable-next-line no-restricted-imports -- sibling primitive to TransitionTracker; needs the exact transitionEnd signal. 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); @@ -17,28 +18,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) => { +const useDialogContainerFocus: UseDialogContainerFocus = (ref, isReady, claimInitialFocusGate) => { useEffect(() => { - if (!isReady || !claimInitialFocus?.()) { + if (!isReady || !claimInitialFocusGate?.()) { return; } + let rafId: number | null = null; const handle = TransitionTracker.runAfterTransitions({ callback: () => { - const container = isHTMLElement(ref.current) ? ref.current : null; - focusFirstInteractiveElement(container); + // 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 () => handle.cancel(); - }, [isReady, ref, claimInitialFocus]); + return () => { + handle.cancel(); + if (rafId !== null) { + cancelAnimationFrame(rafId); + } + }; + }, [isReady, ref, claimInitialFocusGate]); }; export default useDialogContainerFocus; diff --git a/src/hooks/useNavigateBackOnSave/index.ts b/src/hooks/useNavigateBackOnSave/index.ts new file mode 100644 index 000000000000..6bb106cd1553 --- /dev/null +++ b/src/hooks/useNavigateBackOnSave/index.ts @@ -0,0 +1,34 @@ +import {useCallback, useEffect, useRef} from 'react'; +import Navigation from '@libs/Navigation/Navigation'; +import {skipNextFocusRestore} from '@libs/NavigationFocusReturn'; +import type {Route} from '@src/ROUTES'; + +/** + * Save-and-close flow for IOU step forms. `armNavigateBack()` from a submit handler navigates back once `isSaved` flips, + * skipping focus-restore so the re-focused row doesn't swallow the next Enter. The returned `navigateBack` (for the Back + * button) keeps restore intact. + */ +function useNavigateBackOnSave(isSaved: boolean, backTo: Route | undefined): {navigateBack: () => void; armNavigateBack: () => void} { + const shouldNavigateAfterSaveRef = useRef(false); + + const navigateBack = useCallback(() => { + Navigation.goBack(backTo); + }, [backTo]); + + const armNavigateBack = useCallback(() => { + shouldNavigateAfterSaveRef.current = true; + }, []); + + useEffect(() => { + if (!isSaved || !shouldNavigateAfterSaveRef.current) { + return; + } + shouldNavigateAfterSaveRef.current = false; + skipNextFocusRestore(); + navigateBack(); + }, [isSaved, navigateBack]); + + return {navigateBack, armNavigateBack}; +} + +export default useNavigateBackOnSave; diff --git a/src/hooks/useScreenInitialFocus/index.ts b/src/hooks/useScreenInitialFocus/index.ts index 55460fd39bcc..b65a0cade9c1 100644 --- a/src/hooks/useScreenInitialFocus/index.ts +++ b/src/hooks/useScreenInitialFocus/index.ts @@ -1,8 +1,8 @@ import {useContext, useEffect, useRef} from 'react'; import ScreenWrapperStatusContext from '@components/ScreenWrapper/ScreenWrapperStatusContext'; +import claimInitialFocus from '@libs/claimInitialFocus'; import hasHoverSupport from '@libs/DeviceCapabilities/hasHoverSupport'; import getHadTabNavigation from '@libs/hadTabNavigation'; -import {Priorities, tryClaim} from '@libs/ScreenFocusArbiter'; import type UseScreenInitialFocus from './types'; /* @@ -37,18 +37,27 @@ const useScreenInitialFocus: UseScreenInitialFocus = (ref) => { if (hasHoverSupport() && !getHadTabNavigation()) { return; } - if (document.activeElement && document.activeElement !== document.body) { - return; - } - const el = ref.current; - if (!el || !isOnScreen(el)) { - return; - } - if (!tryClaim(Priorities.INITIAL)) { - return; - } - claimedRef.current = true; - el.focus({preventScroll: true, focusVisible: true}); + let rafId: number | null = null; + const attempt = (retry: boolean) => { + const el = ref.current; + if (!el || !isOnScreen(el)) { + // The target can attach a frame after the transition ends; retry once on the next frame. + if (retry) { + rafId = requestAnimationFrame(() => attempt(false)); + } + return; + } + if (claimInitialFocus(el, {focusVisible: getHadTabNavigation()})) { + claimedRef.current = true; + } + }; + attempt(true); + return () => { + if (rafId === null) { + return; + } + cancelAnimationFrame(rafId); + }; }, [status?.didScreenTransitionEnd, ref]); }; diff --git a/src/libs/Accessibility/fireFocusEvent/index.ios.ts b/src/libs/Accessibility/fireFocusEvent/index.ios.ts deleted file mode 100644 index 5e4bba351ec2..000000000000 --- a/src/libs/Accessibility/fireFocusEvent/index.ios.ts +++ /dev/null @@ -1,9 +0,0 @@ -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'; - -function fireFocusEvent(view: View | RNText): void { - AccessibilityInfo.sendAccessibilityEvent(view, 'focus'); -} - -export default fireFocusEvent; diff --git a/src/libs/Accessibility/fireFocusEvent/index.android.ts b/src/libs/Accessibility/fireFocusEvent/index.native.ts similarity index 100% rename from src/libs/Accessibility/fireFocusEvent/index.android.ts rename to src/libs/Accessibility/fireFocusEvent/index.native.ts diff --git a/src/libs/Accessibility/scheduleRefocus/index.ios.ts b/src/libs/Accessibility/scheduleRefocus/index.ios.ts deleted file mode 100644 index 7021ea105ded..000000000000 --- a/src/libs/Accessibility/scheduleRefocus/index.ios.ts +++ /dev/null @@ -1,14 +0,0 @@ -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'; - -/* - * iOS uses UIAccessibilityLayoutChangedNotification (synchronous) — VoiceOver honours the first - * call, so no race-mitigation re-fire is needed. - */ -// 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/NavigationFocusReturn.native.ts b/src/libs/NavigationFocusReturn/index.native.ts similarity index 83% rename from src/libs/NavigationFocusReturn.native.ts rename to src/libs/NavigationFocusReturn/index.native.ts index 4e62475e78b2..676225401f14 100644 --- a/src/libs/NavigationFocusReturn.native.ts +++ b/src/libs/NavigationFocusReturn/index.native.ts @@ -1,19 +1,20 @@ import type {NavigationState} from '@react-navigation/native'; import type {RefObject} from 'react'; import type {View} from 'react-native'; -import Accessibility from './Accessibility'; -import fireFocusEvent from './Accessibility/fireFocusEvent'; -import scheduleRefocus from './Accessibility/scheduleRefocus'; -import compoundParamsKey, {COMPOUND_KEY_DELIMITER} from './compoundParamsKey'; -import navigationRef from './Navigation/navigationRef'; +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 navigationRef from '@libs/Navigation/navigationRef'; // eslint-disable-next-line no-restricted-imports -- sibling primitive to TransitionTracker; needs the exact transitionEnd signal to avoid OS focus-restore races. -import TransitionTracker from './Navigation/TransitionTracker'; -import {diffNavigationState} from './navigationStateDiff'; +import TransitionTracker from '@libs/Navigation/TransitionTracker'; +import {diffNavigationState} from '@libs/navigationStateDiff'; type TriggerEntry = {ref: RefObject; identifier?: string}; const TRIGGER_MAP_MAX = 64; const PRESS_TRIGGER_TTL_MS = 3_000; +const MAX_RESTORE_FRAMES = 5; let lastPressedTriggerRef: RefObject | null = null; let lastPressedTriggerIdentifier: string | null = null; @@ -49,6 +50,13 @@ function notifyPressedTrigger(ref: RefObject | null, identifier?: s lastPressedTriggerAt = ref ? Date.now() : 0; } +/* Single-use: clear after capture 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; @@ -97,7 +105,9 @@ function restoreTriggerForRoute(routeKey: string): RefObject | null let ref: RefObject = entry.ref; let view = ref.current; if (!view && entry.identifier) { - const liveRef = pressableRegistry.get(routeKey)?.get(entry.identifier); + // Pressables register under the raw route key; PUSH_PARAMS restores arrive under the compound key, so strip the suffix to match. + const rawRouteKey = routeKey.split(COMPOUND_KEY_DELIMITER).at(0) ?? routeKey; + const liveRef = pressableRegistry.get(rawRouteKey)?.get(entry.identifier); if (liveRef?.current) { ref = liveRef; view = liveRef.current; @@ -124,28 +134,26 @@ function scheduleRestore(routeKey: string, {waitForUpcomingTransition = true}: { // 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: () => { - if (cancelled) { - return; - } - const ref = restoreTriggerForRoute(routeKey); - if (ref) { - triggerMap.delete(routeKey); - refocusHandle = scheduleRefocus(ref); - return; - } - // Re-attach can lag transitionEnd by a frame; the new Pressable's mount effect will have populated the registry by next rAF. - rafHandle = requestAnimationFrame(() => { + // 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 retryRef = restoreTriggerForRoute(routeKey); - triggerMap.delete(routeKey); - if (!retryRef) { - pendingRestore = null; + const ref = restoreTriggerForRoute(routeKey); + if (ref) { + triggerMap.delete(routeKey); + refocusHandle = scheduleRefocus(ref); return; } - refocusHandle = scheduleRefocus(retryRef); - }); + framesLeft -= 1; + if (framesLeft <= 0) { + triggerMap.delete(routeKey); + return; + } + rafHandle = requestAnimationFrame(attempt); + }; + attempt(); }, }); @@ -171,8 +179,7 @@ function handleStateChange(newState: NavigationState | undefined): void { skipNextRestore = false; cancelPendingRestore(); captureTriggerForRoute(action.captureKey); - lastPressedTriggerRef = null; - lastPressedTriggerIdentifier = null; + clearStagedPress(); } else if (action.type === 'backward') { if (skipNextRestore) { skipNextRestore = false; @@ -223,9 +230,7 @@ function teardownNavigationFocusReturn(): void { prevState = undefined; triggerMap.clear(); pressableRegistry.clear(); - lastPressedTriggerRef = null; - lastPressedTriggerIdentifier = null; - lastPressedTriggerAt = 0; + clearStagedPress(); skipNextRestore = false; stateUnsubscribe?.(); stateUnsubscribe = null; @@ -235,6 +240,7 @@ function teardownNavigationFocusReturn(): void { function notifyPushParamsForward(routeKey: string, prevParams: unknown): void { cancelPendingRestore(); captureTriggerForRoute(compoundParamsKey(routeKey, prevParams)); + clearStagedPress(); } function notifyPushParamsBackward(routeKey: string, targetParams: unknown): void { @@ -259,12 +265,6 @@ function resetForTests(): void { teardownNavigationFocusReturn(); } -function setLastPressedTriggerRefForTests(ref: RefObject | null, identifier?: string): void { - lastPressedTriggerRef = ref; - lastPressedTriggerIdentifier = identifier ?? null; - lastPressedTriggerAt = ref ? Date.now() : 0; -} - function getTriggerMapSizeForTests(): number { return triggerMap.size; } @@ -290,7 +290,6 @@ export { isFocusRestoreInProgress, shouldSkipAutoFocusDueToExistingFocus, resetForTests, - setLastPressedTriggerRefForTests, getTriggerMapSizeForTests, getRegistrySizeForTests, }; diff --git a/src/libs/NavigationFocusReturn.ts b/src/libs/NavigationFocusReturn/index.ts similarity index 97% rename from src/libs/NavigationFocusReturn.ts rename to src/libs/NavigationFocusReturn/index.ts index be6a8e70fba7..33f9bfb15903 100644 --- a/src/libs/NavigationFocusReturn.ts +++ b/src/libs/NavigationFocusReturn/index.ts @@ -3,14 +3,14 @@ import type {RefObject} from 'react'; // eslint-disable-next-line no-restricted-imports -- idiomatic defer primitive past navigation transitions. import {InteractionManager} from 'react-native'; import type {View} 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 {collectRouteKeys, diffNavigationState} from './navigationStateDiff'; -import {isCycleIdle, Priorities, resetCycle, tryClaim} from './ScreenFocusArbiter'; +import compoundParamsKey, {COMPOUND_KEY_DELIMITER} from '@libs/compoundParamsKey'; +import FOCUSABLE_SELECTOR from '@libs/focusableSelector'; +import hasFocusableAttributes from '@libs/focusGuards'; +import getHadTabNavigation from '@libs/hadTabNavigation'; +import {consumeLauncher, pickLauncher, resetLauncherStackForTests} from '@libs/LauncherStack'; +import navigationRef from '@libs/Navigation/navigationRef'; +import {collectRouteKeys, diffNavigationState} from '@libs/navigationStateDiff'; +import {isCycleIdle, Priorities, resetCycle, tryClaim} from '@libs/ScreenFocusArbiter'; /** focusin tracks the last keyboard-focused element; a nav state listener captures it against the outgoing route and restores it on backward nav. */ @@ -19,14 +19,15 @@ 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; +// 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(); +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 { @@ -49,9 +50,6 @@ 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 captureTriggerForRoute(routeKey: string): void { if (typeof document === 'undefined') { return; diff --git a/src/libs/claimInitialFocus.ts b/src/libs/claimInitialFocus.ts new file mode 100644 index 000000000000..ae97e817d300 --- /dev/null +++ b/src/libs/claimInitialFocus.ts @@ -0,0 +1,24 @@ +import {markProgrammaticFocus} from './programmaticFocus'; +import {Priorities, tryClaim} from './ScreenFocusArbiter'; + +/** + * Shared tail for the web initial-focus hooks. `focusVisible: true` shows the ring (keyboard, WCAG 2.4.7); + * false suppresses it for touch via `markProgrammaticFocus`. + */ +function claimInitialFocus(el: HTMLElement, {focusVisible}: {focusVisible: boolean}): boolean { + if (document.activeElement && document.activeElement !== document.body) { + return false; + } + if (!tryClaim(Priorities.INITIAL)) { + return false; + } + if (focusVisible) { + el.focus({preventScroll: true, focusVisible: true}); + } else { + markProgrammaticFocus(el); + el.focus({preventScroll: true}); + } + return true; +} + +export default claimInitialFocus; diff --git a/src/pages/iou/request/step/IOURequestStepDescription.tsx b/src/pages/iou/request/step/IOURequestStepDescription.tsx index 92612bdd195d..ec4f41c2d105 100644 --- a/src/pages/iou/request/step/IOURequestStepDescription.tsx +++ b/src/pages/iou/request/step/IOURequestStepDescription.tsx @@ -1,5 +1,5 @@ import lodashIsEmpty from 'lodash/isEmpty'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useMemo, useState} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import FormProvider from '@components/Form/FormProvider'; @@ -11,6 +11,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 useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import usePolicy from '@hooks/usePolicy'; @@ -20,7 +21,6 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {addErrorMessage} from '@libs/ErrorUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {shouldUseTransactionDraft} from '@libs/IOUUtils'; -import Navigation from '@libs/Navigation/Navigation'; import Parser from '@libs/Parser'; import {hasReceipt} from '@libs/TransactionUtils'; import variables from '@styles/variables'; @@ -78,7 +78,6 @@ function IOURequestStepDescription({ const [currentDescription, setCurrentDescription] = useState(currentDescriptionInMarkdown); const [isSaved, setIsSaved] = useState(false); const [isDiscardModalVisible, setIsDiscardModalVisible] = useState(false); - const shouldNavigateAfterSaveRef = useRef(false); useRestartOnReceiptFailure(transaction, reportID, iouType, action); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const currentUserAccountIDParam = currentUserPersonalDetails.accountID; @@ -103,17 +102,7 @@ function IOURequestStepDescription({ [translate], ); - const navigateBack = useCallback(() => { - Navigation.goBack(backTo); - }, [backTo]); - - useEffect(() => { - if (!isSaved || !shouldNavigateAfterSaveRef.current) { - return; - } - shouldNavigateAfterSaveRef.current = false; - navigateBack(); - }, [isSaved, navigateBack]); + const {navigateBack, armNavigateBack} = useNavigateBackOnSave(isSaved, backTo); 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 b0b793568048..1c9a204bf532 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 useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import usePolicy from '@hooks/usePolicy'; @@ -16,8 +17,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'; @@ -66,7 +65,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; @@ -76,19 +74,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; - // Skip focus-restore on the save path so the re-focused Merchant row doesn't consume the next Enter (which should hit "Create expense"). Back/Esc still restore. - skipNextFocusRestore(); - navigateBack(); - }, [isSaved, navigateBack]); + const {navigateBack, armNavigateBack} = useNavigateBackOnSave(isSaved, backTo); const validate = useCallback( (value: FormOnyxValues) => { @@ -119,13 +105,13 @@ function IOURequestStepMerchant({ if (isEditingSplitBill) { setDraftSplitTransaction(transactionID, splitDraftTransaction, {merchant: newMerchant}); setIsSaved(true); - shouldNavigateAfterSaveRef.current = true; + armNavigateBack(); return; } if (newMerchant === merchant || (newMerchant === '' && merchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT)) { setIsSaved(true); - shouldNavigateAfterSaveRef.current = true; + armNavigateBack(); return; } // updateMoneyRequestMerchant's optimisticData already sets merchant on TRANSACTION{id}, @@ -150,7 +136,7 @@ function IOURequestStepMerchant({ setMoneyRequestMerchant(transactionID, newMerchant || CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, true, hasReceipt(transaction)); } setIsSaved(true); - shouldNavigateAfterSaveRef.current = true; + armNavigateBack(); }; useDiscardChangesConfirmation({ diff --git a/tests/perf-test/SelectionList.perf-test.tsx b/tests/perf-test/SelectionList.perf-test.tsx index 5eca0907173a..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,7 +62,7 @@ jest.mock('@react-navigation/stack', () => ({ })); jest.mock('@react-navigation/native', () => ({ - ...jest.requireActual>('@react-navigation/native'), + ...mockReactNavigationNative(), useFocusEffect: () => {}, useIsFocused: () => true, createNavigationContainerRef: jest.fn(), diff --git a/tests/ui/IOURequestStepAmountDraftTest.tsx b/tests/ui/IOURequestStepAmountDraftTest.tsx index 97f49d18f6ad..c521d423bc81 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'; @@ -104,7 +105,7 @@ jest.mock('@react-navigation/native', () => { getState: jest.fn(() => ({})), }; return { - ...jest.requireActual>('@react-navigation/native'), + ...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 b895b20f0ffb..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,7 +154,7 @@ jest.mock('@react-navigation/native', () => { getState: jest.fn(() => ({})), }; return { - ...jest.requireActual>('@react-navigation/native'), + ...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 509169a226a0..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,7 +30,7 @@ jest.mock('@react-navigation/native', () => { getState: jest.fn(() => ({})), }; return { - ...jest.requireActual>('@react-navigation/native'), + ...mockReactNavigationNative(), createNavigationContainerRef: jest.fn(() => mockRef), useIsFocused: () => true, useNavigation: () => ({ diff --git a/tests/ui/TimeExpenseConfirmationTest.tsx b/tests/ui/TimeExpenseConfirmationTest.tsx index db5e9fb6ff87..2468bf6b47d7 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'; @@ -106,7 +107,7 @@ jest.mock('@react-navigation/native', () => { getState: jest.fn(() => ({})), }; return { - ...jest.requireActual>('@react-navigation/native'), + ...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 83208ace7e83..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,7 +13,7 @@ jest.mock('@libs/Navigation/Navigation', () => ({ navigate: jest.fn(), })); jest.mock('@react-navigation/native', () => ({ - ...jest.requireActual>('@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 991a27186377..1e71cedbdbff 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'; @@ -141,7 +142,7 @@ jest.mock('@react-navigation/native', () => { getState: jest.fn(() => ({})), }; return { - ...jest.requireActual>('@react-navigation/native'), + ...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 ee9a0682f0a0..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,7 +108,7 @@ jest.mock('@react-navigation/native', () => { getState: jest.fn(() => ({})), }; return { - ...jest.requireActual>('@react-navigation/native'), + ...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 1986a76def73..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,7 +57,7 @@ jest.mock('@react-navigation/native', () => { getState: jest.fn(() => ({})), }; return { - ...jest.requireActual>('@react-navigation/native'), + ...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 36571643476a..cb89e098f5c3 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,7 +14,7 @@ import waitForBatchedUpdatesWithAct from '../../utils/waitForBatchedUpdatesWithA jest.mock('@src/components/ConfirmedRoute.tsx'); jest.mock('@react-navigation/native', () => ({ - ...jest.requireActual>('@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 3e761182a7b3..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,7 +57,7 @@ jest.mock('@react-navigation/native', () => { getState: jest.fn(() => ({})), }; return { - ...jest.requireActual>('@react-navigation/native'), + ...mockReactNavigationNative(), createNavigationContainerRef: jest.fn(() => mockRef), useIsFocused: () => true, useNavigation: () => ({navigate: jest.fn(), addListener: jest.fn()}), diff --git a/tests/unit/NavigationFocusReturnNativeTest.ts b/tests/unit/NavigationFocusReturnNativeTest.ts index 10e6b1709064..84c462b3abe7 100644 --- a/tests/unit/NavigationFocusReturnNativeTest.ts +++ b/tests/unit/NavigationFocusReturnNativeTest.ts @@ -89,7 +89,6 @@ const { isFocusRestoreInProgress, shouldSkipAutoFocusDueToExistingFocus, resetForTests, - setLastPressedTriggerRefForTests, getTriggerMapSizeForTests, getRegistrySizeForTests, } = require<{ @@ -105,10 +104,9 @@ const { isFocusRestoreInProgress: () => boolean; shouldSkipAutoFocusDueToExistingFocus: () => boolean; resetForTests: () => void; - setLastPressedTriggerRefForTests: (ref: unknown, identifier?: string) => void; getTriggerMapSizeForTests: () => number; getRegistrySizeForTests: () => number; -}>('../../src/libs/NavigationFocusReturn.native.ts'); +}>('../../src/libs/NavigationFocusReturn/index.native.ts'); /* eslint-enable import/extensions */ function stackState(focused: number, routes: Array<{key: string; name: string; state?: unknown}>): NavState { @@ -213,7 +211,7 @@ describe('notifyPressedTrigger', () => { describe('handleStateChange — forward', () => { it('captures the staged trigger against the outgoing route key', () => { - setLastPressedTriggerRefForTests(fakeRef(fakeView('display-name'))); + notifyPressedTrigger(fakeRef(fakeView('display-name'))); const prev = stackState(0, [{key: 'profile', name: 'Profile'}]); const next = stackState(1, [ {key: 'profile', name: 'Profile'}, @@ -239,7 +237,7 @@ describe('handleStateChange — forward', () => { describe('handleStateChange — backward', () => { it('restores accessibility focus to the captured view after transitions flush', () => { const view = fakeView('display-name'); - setLastPressedTriggerRefForTests(fakeRef(view)); + notifyPressedTrigger(fakeRef(view)); const prev = stackState(0, [{key: 'profile', name: 'Profile'}]); const forward = stackState(1, [ {key: 'profile', name: 'Profile'}, @@ -259,7 +257,7 @@ describe('handleStateChange — backward', () => { }); it('waits for the upcoming transition on a stack pop', () => { - setLastPressedTriggerRefForTests(fakeRef(fakeView('display-name'))); + notifyPressedTrigger(fakeRef(fakeView('display-name'))); handleStateChange(stackState(0, [{key: 'profile', name: 'Profile'}])); handleStateChange( stackState(1, [ @@ -273,7 +271,7 @@ describe('handleStateChange — backward', () => { }); it('does NOT restore when skipNextFocusRestore was called before goBack (form-submit path)', () => { - setLastPressedTriggerRefForTests(fakeRef(fakeView('display-name'))); + notifyPressedTrigger(fakeRef(fakeView('display-name'))); handleStateChange(stackState(0, [{key: 'profile', name: 'Profile'}])); handleStateChange( stackState(1, [ @@ -291,7 +289,7 @@ describe('handleStateChange — backward', () => { }); it('clears the skipped entry so a later deeplink Back to the same route cannot inherit it', () => { - setLastPressedTriggerRefForTests(fakeRef(fakeView('display-name'))); + notifyPressedTrigger(fakeRef(fakeView('display-name'))); handleStateChange(stackState(0, [{key: 'profile', name: 'Profile'}])); handleStateChange( stackState(1, [ @@ -335,7 +333,7 @@ describe('handleStateChange — backward', () => { it('does NOT call sendAccessibilityEvent when the captured ref has been nulled (Pressable unmounted)', () => { // The ref's `.current` going null is the ref-pass-through analog of a detached view. const detachedRef = fakeRef(null); - setLastPressedTriggerRefForTests(detachedRef); + notifyPressedTrigger(detachedRef); const prev = stackState(0, [{key: 'profile', name: 'Profile'}]); const forward = stackState(1, [ {key: 'profile', name: 'Profile'}, @@ -352,7 +350,7 @@ describe('handleStateChange — backward', () => { }); it('cleans the trigger entry from the map after a successful restore', () => { - setLastPressedTriggerRefForTests(fakeRef(fakeView('display-name'))); + notifyPressedTrigger(fakeRef(fakeView('display-name'))); const prev = stackState(0, [{key: 'profile', name: 'Profile'}]); const forward = stackState(1, [ {key: 'profile', name: 'Profile'}, @@ -371,7 +369,7 @@ describe('handleStateChange — backward', () => { describe('handleStateChange — lateral & cleanup', () => { it('cancels a pending restore on a subsequent lateral tab switch', () => { - setLastPressedTriggerRefForTests(fakeRef(fakeView('display-name'))); + notifyPressedTrigger(fakeRef(fakeView('display-name'))); const initial = stackState(0, [{key: 'profile', name: 'Profile'}]); const forward = stackState(1, [ {key: 'profile', name: 'Profile'}, @@ -404,7 +402,7 @@ describe('handleStateChange — lateral & cleanup', () => { }); it('drops trigger entries for routes removed from the stack', () => { - setLastPressedTriggerRefForTests(fakeRef(fakeView('row-a'))); + notifyPressedTrigger(fakeRef(fakeView('row-a'))); const initial = stackState(0, [{key: 'profile', name: 'Profile'}]); const intoA = stackState(1, [ {key: 'profile', name: 'Profile'}, @@ -420,7 +418,7 @@ describe('handleStateChange — lateral & cleanup', () => { }); it('cancelPendingFocusRestore drops any queued restore', () => { - setLastPressedTriggerRefForTests(fakeRef(fakeView('display-name'))); + notifyPressedTrigger(fakeRef(fakeView('display-name'))); const initial = stackState(0, [{key: 'profile', name: 'Profile'}]); const forward = stackState(1, [ {key: 'profile', name: 'Profile'}, @@ -448,7 +446,7 @@ describe('setup / teardown', () => { }); it('teardown clears triggerMap and the staged trigger', () => { - setLastPressedTriggerRefForTests(fakeRef(fakeView('row'))); + notifyPressedTrigger(fakeRef(fakeView('row'))); handleStateChange(stackState(0, [{key: 'profile', name: 'Profile'}])); handleStateChange( stackState(1, [ @@ -467,7 +465,7 @@ describe('PUSH_PARAMS — same-route param change', () => { it('captures against the compound key on forward, restores on backward', () => { const view = fakeView('search-tab-expense'); - setLastPressedTriggerRefForTests(fakeRef(view)); + notifyPressedTrigger(fakeRef(view)); notifyPushParamsForward(ROUTE_KEY, {q: 'old'}); expect(getTriggerMapSizeForTests()).toBe(1); @@ -479,8 +477,38 @@ describe('PUSH_PARAMS — same-route param change', () => { 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('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(); + expect(mockFireFocusEvent).toHaveBeenCalledWith(liveView); + }); + it('does NOT restore when the back targets a different params hash than the captured one', () => { - setLastPressedTriggerRefForTests(fakeRef(fakeView('search-tab-expense'))); + notifyPressedTrigger(fakeRef(fakeView('search-tab-expense'))); notifyPushParamsForward(ROUTE_KEY, {q: 'old'}); notifyPushParamsBackward(ROUTE_KEY, {q: 'unrelated'}); @@ -489,7 +517,7 @@ describe('PUSH_PARAMS — same-route param change', () => { }); it('drops compound entries when the route is removed from the tree', () => { - setLastPressedTriggerRefForTests(fakeRef(fakeView('search-tab-expense'))); + notifyPressedTrigger(fakeRef(fakeView('search-tab-expense'))); notifyPushParamsForward(ROUTE_KEY, {q: 'old'}); expect(getTriggerMapSizeForTests()).toBe(1); @@ -516,7 +544,7 @@ describe('pressable registry — identifier-based fallback', () => { it('restoreTriggerForRoute falls back to the registry when the captured ref was nulled by detach', () => { const detachedRef = fakeRef(fakeView('row')); - setLastPressedTriggerRefForTests(detachedRef, 'row'); + notifyPressedTrigger(detachedRef, 'row'); handleStateChange(stackState(0, [{key: 'A', name: 'A'}])); handleStateChange( @@ -538,7 +566,7 @@ describe('pressable registry — identifier-based fallback', () => { it('rAF retry rescues focus when re-attach lags transitionEnd', () => { const detachedRef = fakeRef(fakeView('row')); - setLastPressedTriggerRefForTests(detachedRef, 'row'); + notifyPressedTrigger(detachedRef, 'row'); handleStateChange(stackState(0, [{key: 'A', name: 'A'}])); handleStateChange( @@ -560,6 +588,55 @@ describe('pressable registry — identifier-based fallback', () => { 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(); + + // Two frames pass with the registry still empty — a single-frame retry would already have dropped the entry. + 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'))); @@ -569,7 +646,7 @@ describe('pressable registry — identifier-based fallback', () => { 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')); - setLastPressedTriggerRefForTests(pressedRef, 'row-a'); + notifyPressedTrigger(pressedRef, 'row-a'); handleStateChange(stackState(0, [{key: 'A', name: 'List'}])); handleStateChange( diff --git a/tests/unit/NavigationFocusReturnTest.ts b/tests/unit/NavigationFocusReturnTest.ts index 29f2a1f08d5e..dff4241e0012 100644 --- a/tests/unit/NavigationFocusReturnTest.ts +++ b/tests/unit/NavigationFocusReturnTest.ts @@ -45,7 +45,7 @@ const { shouldSkipAutoFocusDueToExistingFocus: () => boolean; setupNavigationFocusReturn: () => void; teardownNavigationFocusReturn: () => void; -}>('../../src/libs/NavigationFocusReturn.ts'); +}>('../../src/libs/NavigationFocusReturn/index.ts'); const {setActivePopoverLauncher, scheduleClearActivePopoverLauncher} = require<{ setActivePopoverLauncher: (element: HTMLElement) => void; scheduleClearActivePopoverLauncher: (element?: HTMLElement) => void; diff --git a/tests/unit/fireFocusEventAndroidTest.ts b/tests/unit/fireFocusEventAndroidTest.ts index 750803d00ef5..adbafbedd3e6 100644 --- a/tests/unit/fireFocusEventAndroidTest.ts +++ b/tests/unit/fireFocusEventAndroidTest.ts @@ -3,13 +3,13 @@ import {AccessibilityInfo} from 'react-native'; const mockSendAccessibilityEvent = jest.fn(); AccessibilityInfo.sendAccessibilityEvent = mockSendAccessibilityEvent; -const fireFocusEvent = require<{default: (view: unknown) => void}>('../../src/libs/Accessibility/fireFocusEvent/index.android').default; +const fireFocusEvent = require<{default: (view: unknown) => void}>('../../src/libs/Accessibility/fireFocusEvent/index.native').default; beforeEach(() => { mockSendAccessibilityEvent.mockClear(); }); -describe('fireFocusEvent (Android)', () => { +describe('fireFocusEvent (native)', () => { it('dispatches sendAccessibilityEvent with `focus` for the given view', () => { const view = {label: 'pressable'}; fireFocusEvent(view); diff --git a/tests/unit/useScreenInitialFocusTest.tsx b/tests/unit/useScreenInitialFocusTest.tsx index 164b7532dceb..6cbfe4479a32 100644 --- a/tests/unit/useScreenInitialFocusTest.tsx +++ b/tests/unit/useScreenInitialFocusTest.tsx @@ -1,4 +1,4 @@ -import {render} from '@testing-library/react-native'; +import {act, render} from '@testing-library/react-native'; import React, {useMemo, useRef} from 'react'; import ScreenWrapperStatusContext from '@components/ScreenWrapper/ScreenWrapperStatusContext'; @@ -46,6 +46,19 @@ function Inner({target}: {target: HTMLElement | null}) { return null; } +function MountedHarnessWithRefObject({refObject}: {refObject: React.RefObject}) { + const contextValue = useMemo(() => ({didScreenTransitionEnd: true, isSafeAreaTopPaddingApplied: false, isSafeAreaBottomPaddingApplied: false}), []); + return ( + + + + ); +} +function InnerWithRefObject({refObject}: {refObject: React.RefObject}) { + useScreenInitialFocus(refObject); + return null; +} + function makeButton(): HTMLElement { const b = document.createElement('button'); document.body.appendChild(b); @@ -83,9 +96,11 @@ describe('useScreenInitialFocus', () => { />, ); 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) regardless of Tab', () => { + it('focuses the ref on touch-primary devices (no hover) but suppresses the ring (no focusVisible, data-programmatic-focus set)', () => { mockHasHoverSupport = false; simulatePointer(); const button = makeButton(); @@ -96,7 +111,8 @@ describe('useScreenInitialFocus', () => { didScreenTransitionEnd />, ); - expect(spy).toHaveBeenCalledWith({preventScroll: true, focusVisible: true}); + expect(spy).toHaveBeenCalledWith({preventScroll: true}); + expect(button.getAttribute('data-programmatic-focus')).toBe('true'); }); it('does NOT focus on desktop mouse modality (hasHoverSupport && !hadTab) — WCAG 2.4.7', () => { @@ -181,6 +197,23 @@ describe('useScreenInitialFocus', () => { expect(spy).not.toHaveBeenCalled(); }); + it('retries focus on the next frame when the target ref is not yet attached at transition end', async () => { + simulateTab(); + const refObject: React.RefObject = {current: null}; + const button = makeButton(); + const spy = jest.spyOn(button, 'focus'); + render(); + expect(spy).not.toHaveBeenCalled(); + + refObject.current = button; + await act(async () => { + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); + }); + 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(); 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; From 49ba12b1690212639b032f58e5381fb84e8210c9 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Thu, 28 May 2026 20:09:38 +0300 Subject: [PATCH 27/89] fix: keep screen-reader focus on save by keying menu rows on a stable label, not the edited value --- .../MoneyRequestConfirmationList/sections/TaxFields.tsx | 4 ++-- .../implementation/BaseGenericPressable.tsx | 9 ++++----- .../SpendRules/configuration/SpendRuleMerchantsBase.tsx | 2 +- src/components/SubStepForms/ConfirmationStep.tsx | 2 +- src/libs/ScreenFocusArbiter.ts | 1 - src/pages/settings/Profile/ProfilePage.tsx | 4 ++-- .../subPages/Confirmation.tsx | 2 +- src/pages/workspace/WorkspacesListPage.tsx | 2 +- .../expensifyCard/WorkspaceExpensifyCardListPage.tsx | 4 ++-- .../workspace/members/WorkspaceMemberDetailsPage.tsx | 2 +- 10 files changed, 15 insertions(+), 17 deletions(-) diff --git a/src/components/MoneyRequestConfirmationList/sections/TaxFields.tsx b/src/components/MoneyRequestConfirmationList/sections/TaxFields.tsx index 8dcf15cffdad..fe95a4b99879 100644 --- a/src/components/MoneyRequestConfirmationList/sections/TaxFields.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/TaxFields.tsx @@ -47,7 +47,7 @@ function TaxFields({policy, policyForMovingExpenses, iouCurrencyCode, canModifyT return ( <> (null); const composedRef = useMemo(() => mergeRefs(ref, internalRef), [ref]); const routeKey = useContext(NavigationRouteContext)?.key ?? null; - const generatedId = useId(); - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- `||` lets empty-string ids fall through to the unique generated id; accessibilityLabel is excluded since it collides within a route. - const focusIdentifier = rest.id || rest.nativeID || rest.testID || generatedId; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- `||` lets empty-string ids fall through; the identifier must be content-stable to survive a remount for the registry rescue. + const focusIdentifier = rest.id || rest.nativeID || rest.testID || rest.accessibilityLabel || undefined; useEffect(() => { - if (!isScreenReaderActive || !routeKey) { + if (!isScreenReaderActive || !routeKey || !focusIdentifier) { return; } return registerPressable(routeKey, focusIdentifier, internalRef); diff --git a/src/components/SpendRules/configuration/SpendRuleMerchantsBase.tsx b/src/components/SpendRules/configuration/SpendRuleMerchantsBase.tsx index 3db6f730f0b3..a84bc6fa160f 100644 --- a/src/components/SpendRules/configuration/SpendRuleMerchantsBase.tsx +++ b/src/components/SpendRules/configuration/SpendRuleMerchantsBase.tsx @@ -77,7 +77,7 @@ function SpendRuleMerchantsBase({policyID, action, merchantMatchTypes, merchantN merchantNames.map((merchantName, index) => ( {pageTitle} {summaryItems.map(({description, title, shouldShowRightIcon, onPress, brickRoadIndicator, errorText}) => ( ( {translate('addPersonalBankAccount.confirmationStepSubHeader')} {summaryItems.map(({description, title, shouldShowRightIcon, interactive, disabled, onPress}) => ( 0; const shouldShowBulkSelection = filteredSortedCards.length > 0; - const renderItem = ({item, index}: ListRenderItemInfo) => { + const renderItem = ({item}: ListRenderItemInfo) => { const frozenByDisplayName = item.nameValuePairs?.frozen?.byAccountID ? getDisplayNameOrDefault(personalDetails?.[item.nameValuePairs.frozen.byAccountID], '', false) || undefined : undefined; @@ -232,7 +232,7 @@ function WorkspaceExpensifyCardListPage({route, cardsList, fundID}: WorkspaceExp return ( Date: Thu, 28 May 2026 21:30:56 +0300 Subject: [PATCH 28/89] fix: run web focus restore on TransitionTracker and end the 1s back-nav stall --- src/libs/Navigation/TransitionTracker.ts | 9 +- .../NavigationFocusReturn/index.native.ts | 4 +- src/libs/NavigationFocusReturn/index.ts | 71 ++++++-------- .../unit/Navigation/TransitionTrackerTest.ts | 10 ++ tests/unit/NavigationFocusReturnTest.ts | 61 +++++++++--- tests/unit/scheduleRefocusAndroidTest.ts | 68 +++++++++++++ tests/unit/useNavigateBackOnSaveTest.ts | 96 +++++++++++++++++++ 7 files changed, 259 insertions(+), 60 deletions(-) create mode 100644 tests/unit/scheduleRefocusAndroidTest.ts create mode 100644 tests/unit/useNavigateBackOnSaveTest.ts diff --git a/src/libs/Navigation/TransitionTracker.ts b/src/libs/Navigation/TransitionTracker.ts index e5bd76bc746e..2e93558705e0 100644 --- a/src/libs/Navigation/TransitionTracker.ts +++ b/src/libs/Navigation/TransitionTracker.ts @@ -12,9 +12,7 @@ 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. */ + /** Wait for a transition before running the callback — the next one to start if none is active yet, else the active one to end. Defaults to false. */ waitForUpcomingTransition?: boolean; }; @@ -105,11 +103,12 @@ function endTransition(handle: TransitionHandle): void { * @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 a transition is already active (web fires transitionStart before the navigation state event), wait for it to end rather than a next start that never comes — which would hit the timeout. + if (waitForUpcomingTransition && activeTransitions.size === 0) { let cancelled = false; let innerHandle: CancelHandle | null = null; diff --git a/src/libs/NavigationFocusReturn/index.native.ts b/src/libs/NavigationFocusReturn/index.native.ts index 676225401f14..b55e2c852a80 100644 --- a/src/libs/NavigationFocusReturn/index.native.ts +++ b/src/libs/NavigationFocusReturn/index.native.ts @@ -125,7 +125,7 @@ function cancelPendingRestore(): void { pendingRestore = null; } -function scheduleRestore(routeKey: string, {waitForUpcomingTransition = true}: {waitForUpcomingTransition?: boolean} = {}): void { +function scheduleRestore(routeKey: string, {waitForUpcomingTransition}: {waitForUpcomingTransition: boolean}): void { cancelPendingRestore(); let cancelled = false; let refocusHandle: {cancel: () => void} | null = null; @@ -186,7 +186,7 @@ function handleStateChange(newState: NavigationState | undefined): void { cancelPendingRestore(); triggerMap.delete(action.restoreKey); } else { - scheduleRestore(action.restoreKey); + scheduleRestore(action.restoreKey, {waitForUpcomingTransition: true}); } } else if (action.type === 'lateral') { skipNextRestore = false; diff --git a/src/libs/NavigationFocusReturn/index.ts b/src/libs/NavigationFocusReturn/index.ts index 33f9bfb15903..e5c4768e8eb5 100644 --- a/src/libs/NavigationFocusReturn/index.ts +++ b/src/libs/NavigationFocusReturn/index.ts @@ -1,7 +1,5 @@ import type {NavigationState} from '@react-navigation/native'; import type {RefObject} from 'react'; -// eslint-disable-next-line no-restricted-imports -- idiomatic defer primitive past navigation transitions. -import {InteractionManager} from 'react-native'; import type {View} from 'react-native'; import compoundParamsKey, {COMPOUND_KEY_DELIMITER} from '@libs/compoundParamsKey'; import FOCUSABLE_SELECTOR from '@libs/focusableSelector'; @@ -9,6 +7,8 @@ import hasFocusableAttributes from '@libs/focusGuards'; import getHadTabNavigation from '@libs/hadTabNavigation'; import {consumeLauncher, pickLauncher, resetLauncherStackForTests} from '@libs/LauncherStack'; import navigationRef from '@libs/Navigation/navigationRef'; +// eslint-disable-next-line no-restricted-imports -- sibling primitive to TransitionTracker; needs the exact transitionEnd signal to defer past the navigation transition. +import TransitionTracker from '@libs/Navigation/TransitionTracker'; import {collectRouteKeys, diffNavigationState} from '@libs/navigationStateDiff'; import {isCycleIdle, Priorities, resetCycle, tryClaim} from '@libs/ScreenFocusArbiter'; @@ -90,7 +90,7 @@ function notifyPushParamsForward(routeKey: string, prevParams: unknown): void { } function notifyPushParamsBackward(routeKey: string, targetParams: unknown): void { - scheduleRestore(compoundParamsKey(routeKey, targetParams)); + scheduleRestore(compoundParamsKey(routeKey, targetParams), {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. */ @@ -267,59 +267,50 @@ function cancelPendingRestore(): void { pendingRestore = null; } -const MAX_RESTORE_ATTEMPTS = 2; -const RESTORE_RETRY_MS = 50; +const MAX_RESTORE_FRAMES = 5; -function scheduleRestore(routeKey: string): void { +function scheduleRestore(routeKey: string, {waitForUpcomingTransition}: {waitForUpcomingTransition: boolean}): void { 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: () => { + // restoreTriggerForRoute preserves the entry on a transient miss and drops it when truly gone, so retry across a few frames and stop as soon as it resolves either way. + let framesLeft = MAX_RESTORE_FRAMES; + const attempt = () => { if (cancelled) { return; } - attempts += 1; const restored = restoreTriggerForRoute(routeKey); if (restored || !triggerMap.has(routeKey)) { pendingRestore = null; return; } - if (attempts >= MAX_RESTORE_ATTEMPTS) { + framesLeft -= 1; + if (framesLeft <= 0) { 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); + }; + attempt(); }, - }; - - attempt(); + }); } function handleStateChange(newState: NavigationState | undefined): void { @@ -348,7 +339,7 @@ function handleStateChange(newState: NavigationState | undefined): void { skipNextRestore = false; cancelPendingRestore(); } else { - scheduleRestore(action.restoreKey); + scheduleRestore(action.restoreKey, {waitForUpcomingTransition: true}); } } else if (action.type === 'lateral') { skipNextRestore = false; diff --git a/tests/unit/Navigation/TransitionTrackerTest.ts b/tests/unit/Navigation/TransitionTrackerTest.ts index d7a7ae9cfbc2..9f69044488e0 100644 --- a/tests/unit/Navigation/TransitionTrackerTest.ts +++ b/tests/unit/Navigation/TransitionTrackerTest.ts @@ -87,6 +87,16 @@ describe('TransitionTracker', () => { drainTransitions(); }); + it('waitForUpcomingTransition waits for an already-active transition to end (web order: transitionStart before the call) instead of a phantom next start', () => { + const callback = jest.fn(); + const handle = TransitionTracker.startTransition(); + TransitionTracker.runAfterTransitions({callback, waitForUpcomingTransition: true}); + expect(callback).not.toHaveBeenCalled(); + TransitionTracker.endTransition(handle); + 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}); diff --git a/tests/unit/NavigationFocusReturnTest.ts b/tests/unit/NavigationFocusReturnTest.ts index dff4241e0012..0de423a9df38 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}; +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}) => { + 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; @@ -119,6 +140,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 +159,7 @@ beforeEach(() => { resetForTests(); resetArbiter(); resetHadTabNavigation(); + mockTtQueue = []; document.body.innerHTML = ''; }); @@ -1102,7 +1136,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 +1161,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. @@ -1451,7 +1485,7 @@ describe('handleStateChange integration', () => { handleStateChange(onA); const spy = jest.spyOn(trigger, 'focus'); - jest.runAllTimers(); + flushTransitions(); expect(spy).toHaveBeenCalled(); }); }); @@ -1469,14 +1503,14 @@ 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. handleStateChange(onAB); trigger.blur(); handleStateChange(onA); - jest.runAllTimers(); + flushTransitions(); expect(spy).toHaveBeenCalled(); }); }); @@ -1495,7 +1529,7 @@ describe('handleStateChange integration', () => { trigger.blur(); const spy = jest.spyOn(trigger, 'focus'); handleStateChange(onA); - jest.runAllTimers(); + flushTransitions(); expect(spy).toHaveBeenCalled(); }); }); @@ -1536,7 +1570,7 @@ describe('handleStateChange integration', () => { handleStateChange(onTab2); const spy = jest.spyOn(trigger, 'focus'); - jest.runAllTimers(); + flushTransitions(); expect(spy).not.toHaveBeenCalled(); }); }); @@ -1562,7 +1596,7 @@ describe('handleStateChange integration', () => { handleStateChange(onAC); const spy = jest.spyOn(trigger, 'focus'); - jest.runAllTimers(); + flushTransitions(); expect(spy).not.toHaveBeenCalled(); }); }); @@ -1581,7 +1615,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 +1630,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,7 +1718,7 @@ describe('PUSH_PARAMS notifications', () => { const spy = jest.spyOn(trigger, 'focus'); notifyPushParamsBackward('search-x', {q: 'foo'}); - jest.runAllTimers(); + flushTransitions(); expect(spy).toHaveBeenCalled(); }); }); @@ -1712,7 +1747,7 @@ describe('PUSH_PARAMS notifications', () => { const spy = jest.spyOn(trigger, 'focus'); notifyPushParamsBackward('search-x', {q: 'baz'}); - jest.runAllTimers(); + flushTransitions(); expect(spy).not.toHaveBeenCalled(); }); }); @@ -1790,7 +1825,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/scheduleRefocusAndroidTest.ts b/tests/unit/scheduleRefocusAndroidTest.ts new file mode 100644 index 000000000000..12b383e79b70 --- /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: () => void, options?: {timeout?: number}) => { + capturedIdleCallback = callback; + capturedIdleOptions = options; + return FAKE_IDLE_ID; + }) as unknown as typeof requestIdleCallback; + global.cancelIdleCallback = mockCancelIdleCallback as unknown as typeof cancelIdleCallback; +}); + +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/useNavigateBackOnSaveTest.ts b/tests/unit/useNavigateBackOnSaveTest.ts new file mode 100644 index 000000000000..c9fa0a11d78b --- /dev/null +++ b/tests/unit/useNavigateBackOnSaveTest.ts @@ -0,0 +1,96 @@ +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 = Navigation.goBack as jest.Mock; +const mockSkip = skipNextFocusRestore as jest.Mock; + +const BACK_TO = 'settings/profile' as Route; + +function renderSave(initialIsSaved = false, backTo: Route | undefined = BACK_TO) { + return renderHook(({isSaved}: {isSaved: boolean}) => useNavigateBackOnSave(isSaved, backTo), {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 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); + }); +}); From 951a5377efd2e6078c2dd725c3b0b63154c8ee43 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Thu, 28 May 2026 22:07:45 +0300 Subject: [PATCH 29/89] fix: recover focus when a trigger remounts mid-restore --- src/libs/NavigationFocusReturn/index.ts | 28 ++++++++++--------------- tests/unit/NavigationFocusReturnTest.ts | 23 ++++++++++++++++++++ 2 files changed, 34 insertions(+), 17 deletions(-) diff --git a/src/libs/NavigationFocusReturn/index.ts b/src/libs/NavigationFocusReturn/index.ts index e5c4768e8eb5..99af7402bd3c 100644 --- a/src/libs/NavigationFocusReturn/index.ts +++ b/src/libs/NavigationFocusReturn/index.ts @@ -113,24 +113,22 @@ 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'; +type RestorePick = {target: HTMLElement; source: 'primary' | 'fallback'}; -function pickRestoreTarget(entry: TriggerEntry): RestorePick { +/* + * null = nothing focusable yet (unfocusable, or detached mid-remount). Detached is NOT "gone": the caller keeps + * the entry so scheduleRestore's budget can recover a remount — only that budget deletes (on success/exhaustion). + */ +function pickRestoreTarget(entry: TriggerEntry): RestorePick | null { const {primary, fallback} = entry; - const primaryInDom = document.contains(primary); - const fallbackInDom = !!fallback && document.contains(fallback); - if (primaryInDom && hasFocusableAttributes(primary)) { + if (document.contains(primary) && hasFocusableAttributes(primary)) { return {target: primary, source: 'primary'}; } - if (fallbackInDom && fallback && hasFocusableAttributes(fallback)) { + if (fallback && document.contains(fallback) && hasFocusableAttributes(fallback)) { return {target: fallback, source: 'fallback'}; } - if (primaryInDom || fallbackInDom) { - return 'retry'; - } - return 'gone'; + return null; } // Grace window after a successful restore: vetoes in-flight AUTO/INITIAL, then releases so unrelated later claimers aren't blocked for CYCLE_TIMEOUT_MS. @@ -208,11 +206,7 @@ function restoreTriggerForRoute(routeKey: string): boolean { } const pick = pickRestoreTarget(entry); - if (pick === 'retry') { - return false; - } - if (pick === 'gone') { - triggerMap.delete(routeKey); + if (!pick) { return false; } @@ -289,7 +283,7 @@ function scheduleRestore(routeKey: string, {waitForUpcomingTransition}: {waitFor // 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: () => { - // restoreTriggerForRoute preserves the entry on a transient miss and drops it when truly gone, so retry across a few frames and stop as soon as it resolves either way. + // 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) { diff --git a/tests/unit/NavigationFocusReturnTest.ts b/tests/unit/NavigationFocusReturnTest.ts index 0de423a9df38..18279a0c4d66 100644 --- a/tests/unit/NavigationFocusReturnTest.ts +++ b/tests/unit/NavigationFocusReturnTest.ts @@ -1723,6 +1723,29 @@ describe('PUSH_PARAMS notifications', () => { }); }); + 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'}); + + // Param re-render unmounts the captured row before the backward restore runs. + trigger.remove(); + + const spy = jest.spyOn(trigger, 'focus'); + notifyPushParamsBackward('search-x', {q: 'foo'}); + + // First attempt is detached — the entry must be preserved, not dropped. + flushTransitions(); + expect(spy).not.toHaveBeenCalled(); + + // Row remounts; the retry budget recovers focus instead of giving up on the first miss. + document.body.appendChild(trigger); + jest.runAllTimers(); + expect(spy).toHaveBeenCalled(); + }); + }); + it('should drop compound entries when their bare route is removed from the tree', () => { const trigger = appendInput(); fireFocusIn(trigger); From 973c81d31c7ac706afdb8d763136ea2c07d3edde Mon Sep 17 00:00:00 2001 From: TaduJR Date: Thu, 28 May 2026 22:34:19 +0300 Subject: [PATCH 30/89] fix: don't restore focus to the wrong row when list items share an accessibility label --- .../NavigationFocusReturn/index.native.ts | 29 +++++++++++++------ src/libs/NavigationFocusReturn/index.ts | 2 +- tests/unit/NavigationFocusReturnNativeTest.ts | 28 +++++++++++++++++- 3 files changed, 48 insertions(+), 11 deletions(-) diff --git a/src/libs/NavigationFocusReturn/index.native.ts b/src/libs/NavigationFocusReturn/index.native.ts index b55e2c852a80..490a5c129f43 100644 --- a/src/libs/NavigationFocusReturn/index.native.ts +++ b/src/libs/NavigationFocusReturn/index.native.ts @@ -20,7 +20,7 @@ let lastPressedTriggerRef: RefObject | null = null; let lastPressedTriggerIdentifier: string | null = null; let lastPressedTriggerAt = 0; const triggerMap = new Map(); -const pressableRegistry = new Map>>(); +const pressableRegistry = new Map>>>(); let prevState: NavigationState | undefined; let pendingRestore: {cancel: () => void} | null = null; let skipNextRestore = false; @@ -68,14 +68,21 @@ function registerPressable(routeKey: string, identifier: string, ref: RefObject< routeMap = new Map(); pressableRegistry.set(routeKey, routeMap); } - routeMap.set(identifier, ref); + // 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); - if (!map) { + const set = map?.get(identifier); + if (!map || !set) { return; } - // Guard against deregister-after-replace: a newer Pressable may have overwritten this identifier. - if (map.get(identifier) === ref) { + set.delete(ref); + if (set.size === 0) { map.delete(identifier); } if (map.size === 0) { @@ -107,8 +114,10 @@ function restoreTriggerForRoute(routeKey: string): RefObject | null if (!view && entry.identifier) { // Pressables register under the raw route key; PUSH_PARAMS restores arrive under the compound key, so strip the suffix to match. const rawRouteKey = routeKey.split(COMPOUND_KEY_DELIMITER).at(0) ?? routeKey; - const liveRef = pressableRegistry.get(rawRouteKey)?.get(entry.identifier); - if (liveRef?.current) { + const liveRefs = Array.from(pressableRegistry.get(rawRouteKey)?.get(entry.identifier) ?? []).filter((candidate) => candidate.current); + // A colliding label (e.g. every row's "Edit") is ambiguous — decline rather than focus the wrong row. + const liveRef = liveRefs.length === 1 ? liveRefs.at(0) : undefined; + if (liveRef) { ref = liveRef; view = liveRef.current; } @@ -271,8 +280,10 @@ function getTriggerMapSizeForTests(): number { function getRegistrySizeForTests(): number { let total = 0; - for (const m of pressableRegistry.values()) { - total += m.size; + for (const routeMap of pressableRegistry.values()) { + for (const refs of routeMap.values()) { + total += refs.size; + } } return total; } diff --git a/src/libs/NavigationFocusReturn/index.ts b/src/libs/NavigationFocusReturn/index.ts index 99af7402bd3c..cafcaab843a0 100644 --- a/src/libs/NavigationFocusReturn/index.ts +++ b/src/libs/NavigationFocusReturn/index.ts @@ -116,7 +116,7 @@ function isFocusRestoreInProgress(): boolean { type RestorePick = {target: HTMLElement; source: 'primary' | 'fallback'}; /* - * null = nothing focusable yet (unfocusable, or detached mid-remount). Detached is NOT "gone": the caller keeps + * null = nothing focusable yet (mounted but not focusable, or detached mid-remount). Detached is NOT "gone": the caller keeps * the entry so scheduleRestore's budget can recover a remount — only that budget deletes (on success/exhaustion). */ function pickRestoreTarget(entry: TriggerEntry): RestorePick | null { diff --git a/tests/unit/NavigationFocusReturnNativeTest.ts b/tests/unit/NavigationFocusReturnNativeTest.ts index 84c462b3abe7..1c40ee44ee8f 100644 --- a/tests/unit/NavigationFocusReturnNativeTest.ts +++ b/tests/unit/NavigationFocusReturnNativeTest.ts @@ -535,7 +535,7 @@ describe('pressable registry — identifier-based fallback', () => { expect(getRegistrySizeForTests()).toBe(0); }); - it('deregister is a no-op once another Pressable has overwritten the same identifier (remount race)', () => { + 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(); @@ -668,6 +668,32 @@ describe('pressable registry — identifier-based fallback', () => { 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'}, + ]), + ); + + // Screen detaches (captured ref nulled), then remounts with several rows sharing the same "Edit" label. + 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); + + // Ambiguous identifier → focus nothing rather than an arbitrary row; the entry is dropped after the budget. + expect(mockFireFocusEvent).not.toHaveBeenCalled(); + expect(getTriggerMapSizeForTests()).toBe(0); + }); + 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); From 1d3e8d23f6648f32c35f259e0959a0982bc02622 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Thu, 28 May 2026 23:02:01 +0300 Subject: [PATCH 31/89] fix: stop a stale Back/Save press from leaking into a later focus restore --- .../NavigationFocusReturn/index.native.ts | 9 ++++- tests/unit/NavigationFocusReturnNativeTest.ts | 40 +++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/libs/NavigationFocusReturn/index.native.ts b/src/libs/NavigationFocusReturn/index.native.ts index 490a5c129f43..e911f67272cc 100644 --- a/src/libs/NavigationFocusReturn/index.native.ts +++ b/src/libs/NavigationFocusReturn/index.native.ts @@ -50,7 +50,7 @@ function notifyPressedTrigger(ref: RefObject | null, identifier?: s lastPressedTriggerAt = ref ? Date.now() : 0; } -/* Single-use: clear after capture so a later press-less forward can't reuse a stale ref within the TTL. */ +/* 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; @@ -188,7 +188,6 @@ function handleStateChange(newState: NavigationState | undefined): void { skipNextRestore = false; cancelPendingRestore(); captureTriggerForRoute(action.captureKey); - clearStagedPress(); } else if (action.type === 'backward') { if (skipNextRestore) { skipNextRestore = false; @@ -202,6 +201,11 @@ function handleStateChange(newState: NavigationState | undefined): void { cancelPendingRestore(); } + const isRealNavigation = action.type !== 'noop'; + if (isRealNavigation) { + clearStagedPress(); + } + for (const key of removedKeys) { triggerMap.delete(key); pressableRegistry.delete(key); @@ -254,6 +258,7 @@ function notifyPushParamsForward(routeKey: string, prevParams: unknown): void { function notifyPushParamsBackward(routeKey: string, targetParams: unknown): void { scheduleRestore(compoundParamsKey(routeKey, targetParams), {waitForUpcomingTransition: false}); + clearStagedPress(); } function cancelPendingFocusRestore(): void { diff --git a/tests/unit/NavigationFocusReturnNativeTest.ts b/tests/unit/NavigationFocusReturnNativeTest.ts index 1c40ee44ee8f..4b6960c293a7 100644 --- a/tests/unit/NavigationFocusReturnNativeTest.ts +++ b/tests/unit/NavigationFocusReturnNativeTest.ts @@ -365,6 +365,29 @@ describe('handleStateChange — backward', () => { flushTransitions(); expect(getTriggerMapSizeForTests()).toBe(0); }); + + 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'}, + ]), + ); + + // User presses Back on B (stages the back-button ref), then navigates back. + notifyPressedTrigger(fakeRef(fakeView('back-button')), 'Back'); + handleStateChange(stackState(0, [{key: 'A', name: 'A'}])); + + // A press-less forward within the TTL must capture nothing — the Back press was consumed by the backward nav. + handleStateChange( + stackState(1, [ + {key: 'A', name: 'A'}, + {key: 'C', name: 'C'}, + ]), + ); + expect(getTriggerMapSizeForTests()).toBe(0); + }); }); describe('handleStateChange — lateral & cleanup', () => { @@ -493,6 +516,23 @@ describe('PUSH_PARAMS — same-route param change', () => { 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'}])); + + // User presses Back/Save (stages the ref), then a PUSH_PARAMS back reverts params. + notifyPressedTrigger(fakeRef(fakeView('back-button')), 'Back'); + notifyPushParamsBackward('A', {q: 'old'}); + + // A press-less forward within the TTL must capture nothing. + 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'); From c1eabc0cba389041348782c5648a16490f269ea0 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Fri, 29 May 2026 11:13:42 +0300 Subject: [PATCH 32/89] fix: key Corpay confirmation rows by field id so duplicate section labels don't collide --- .../subPages/Confirmation.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/subPages/Confirmation.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/subPages/Confirmation.tsx index b9408e26d5e7..56141b6e5afb 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,9 @@ 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}) => ( Date: Fri, 29 May 2026 11:15:58 +0300 Subject: [PATCH 33/89] chore: CI restart From 21c223f1c51560384155f6ac4594649bab72be0f Mon Sep 17 00:00:00 2001 From: TaduJR Date: Fri, 29 May 2026 11:48:00 +0300 Subject: [PATCH 34/89] fix: use a stable focus-return id for Corpay confirmation rows --- .../Wallet/InternationalDepositAccount/subPages/Confirmation.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/subPages/Confirmation.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/subPages/Confirmation.tsx index 56141b6e5afb..cbca9e8f21cf 100644 --- a/src/pages/settings/Wallet/InternationalDepositAccount/subPages/Confirmation.tsx +++ b/src/pages/settings/Wallet/InternationalDepositAccount/subPages/Confirmation.tsx @@ -180,6 +180,7 @@ function Confirmation({onNext, onMove, formValues, fieldsMap}: CustomSubPageProp {summaryItems.map(({id, description, title, shouldShowRightIcon, interactive, disabled, onPress}) => ( Date: Fri, 29 May 2026 14:10:23 +0300 Subject: [PATCH 35/89] fix: return focus to the edited field when saving an existing expense --- src/hooks/useNavigateBackOnSave/index.ts | 18 ++++++++++++------ src/libs/NavigationFocusReturn/index.ts | 5 ++++- .../request/step/IOURequestStepDescription.tsx | 2 +- .../request/step/IOURequestStepMerchant.tsx | 2 +- tests/unit/useNavigateBackOnSaveTest.ts | 14 ++++++++++++-- 5 files changed, 30 insertions(+), 11 deletions(-) diff --git a/src/hooks/useNavigateBackOnSave/index.ts b/src/hooks/useNavigateBackOnSave/index.ts index 6bb106cd1553..a022a9186633 100644 --- a/src/hooks/useNavigateBackOnSave/index.ts +++ b/src/hooks/useNavigateBackOnSave/index.ts @@ -4,11 +4,15 @@ import {skipNextFocusRestore} from '@libs/NavigationFocusReturn'; import type {Route} from '@src/ROUTES'; /** - * Save-and-close flow for IOU step forms. `armNavigateBack()` from a submit handler navigates back once `isSaved` flips, - * skipping focus-restore so the re-focused row doesn't swallow the next Enter. The returned `navigateBack` (for the Back - * button) keeps restore intact. + * Save-and-close flow for IOU step forms: `armNavigateBack()` navigates back once `isSaved` flips. Pass + * `shouldSkipFocusRestore` true only when the destination has a submit Enter a re-focused row would hijack (create flow); + * editing an existing expense passes false so focus returns. `navigateBack` (the Back button) always restores. */ -function useNavigateBackOnSave(isSaved: boolean, backTo: Route | undefined): {navigateBack: () => void; armNavigateBack: () => void} { +function useNavigateBackOnSave( + isSaved: boolean, + backTo: Route | undefined, + {shouldSkipFocusRestore}: {shouldSkipFocusRestore: boolean}, +): {navigateBack: () => void; armNavigateBack: () => void} { const shouldNavigateAfterSaveRef = useRef(false); const navigateBack = useCallback(() => { @@ -24,9 +28,11 @@ function useNavigateBackOnSave(isSaved: boolean, backTo: Route | undefined): {na return; } shouldNavigateAfterSaveRef.current = false; - skipNextFocusRestore(); + if (shouldSkipFocusRestore) { + skipNextFocusRestore(); + } navigateBack(); - }, [isSaved, navigateBack]); + }, [isSaved, navigateBack, shouldSkipFocusRestore]); return {navigateBack, armNavigateBack}; } diff --git a/src/libs/NavigationFocusReturn/index.ts b/src/libs/NavigationFocusReturn/index.ts index cafcaab843a0..c81c34ced023 100644 --- a/src/libs/NavigationFocusReturn/index.ts +++ b/src/libs/NavigationFocusReturn/index.ts @@ -93,7 +93,10 @@ function notifyPushParamsBackward(routeKey: string, targetParams: unknown): void scheduleRestore(compoundParamsKey(routeKey, targetParams), {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; } diff --git a/src/pages/iou/request/step/IOURequestStepDescription.tsx b/src/pages/iou/request/step/IOURequestStepDescription.tsx index ec4f41c2d105..722411b37fbd 100644 --- a/src/pages/iou/request/step/IOURequestStepDescription.tsx +++ b/src/pages/iou/request/step/IOURequestStepDescription.tsx @@ -102,7 +102,7 @@ function IOURequestStepDescription({ [translate], ); - const {navigateBack, armNavigateBack} = useNavigateBackOnSave(isSaved, backTo); + const {navigateBack, armNavigateBack} = useNavigateBackOnSave(isSaved, backTo, {shouldSkipFocusRestore: !isEditing}); const updateDescriptionRef = (value: string) => { setCurrentDescription(value); diff --git a/src/pages/iou/request/step/IOURequestStepMerchant.tsx b/src/pages/iou/request/step/IOURequestStepMerchant.tsx index 1c9a204bf532..d6605e1416e7 100644 --- a/src/pages/iou/request/step/IOURequestStepMerchant.tsx +++ b/src/pages/iou/request/step/IOURequestStepMerchant.tsx @@ -74,7 +74,7 @@ function IOURequestStepMerchant({ const isMerchantRequired = isPolicyExpenseChat(report) || isExpenseRequest(report) || transaction?.participants?.some((participant) => !!participant.isPolicyExpenseChat); - const {navigateBack, armNavigateBack} = useNavigateBackOnSave(isSaved, backTo); + const {navigateBack, armNavigateBack} = useNavigateBackOnSave(isSaved, backTo, {shouldSkipFocusRestore: !isEditing}); const validate = useCallback( (value: FormOnyxValues) => { diff --git a/tests/unit/useNavigateBackOnSaveTest.ts b/tests/unit/useNavigateBackOnSaveTest.ts index c9fa0a11d78b..5dfa641a7e9a 100644 --- a/tests/unit/useNavigateBackOnSaveTest.ts +++ b/tests/unit/useNavigateBackOnSaveTest.ts @@ -19,8 +19,8 @@ const mockSkip = skipNextFocusRestore as jest.Mock; const BACK_TO = 'settings/profile' as Route; -function renderSave(initialIsSaved = false, backTo: Route | undefined = BACK_TO) { - return renderHook(({isSaved}: {isSaved: boolean}) => useNavigateBackOnSave(isSaved, backTo), {initialProps: {isSaved: initialIsSaved}}); +function renderSave(initialIsSaved = false, backTo: Route | undefined = BACK_TO, shouldSkipFocusRestore = true) { + return renderHook(({isSaved}: {isSaved: boolean}) => useNavigateBackOnSave(isSaved, backTo, {shouldSkipFocusRestore}), {initialProps: {isSaved: initialIsSaved}}); } beforeEach(() => { @@ -52,6 +52,16 @@ describe('useNavigateBackOnSave', () => { 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}); From 59d62175142893cf039fe10ce91fdd4ea4f98b50 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Fri, 29 May 2026 19:54:09 +0300 Subject: [PATCH 36/89] fix: honor focus-restore skip on PUSH_PARAMS reverts and harden initial focus --- src/hooks/useScreenInitialFocus/index.ts | 21 ++++++----- .../NavigationFocusReturn/index.native.ts | 35 ++++++++++++------- src/libs/NavigationFocusReturn/index.ts | 6 ++++ src/libs/claimInitialFocus.ts | 14 ++++++-- tests/unit/useScreenInitialFocusTest.tsx | 16 +++++++++ 5 files changed, 67 insertions(+), 25 deletions(-) diff --git a/src/hooks/useScreenInitialFocus/index.ts b/src/hooks/useScreenInitialFocus/index.ts index b65a0cade9c1..61f17872391c 100644 --- a/src/hooks/useScreenInitialFocus/index.ts +++ b/src/hooks/useScreenInitialFocus/index.ts @@ -22,6 +22,8 @@ function isOnScreen(el: HTMLElement): boolean { return true; } +const MAX_INITIAL_FOCUS_FRAMES = 5; + /* * Mobile-web counterpart to `useDialogContainerFocus` (RHP-only): focuses `ref` once after `didScreenTransitionEnd`. * Hover-capable devices gate on Tab (WCAG 2.4.7); touch-primary devices bypass. @@ -38,20 +40,21 @@ const useScreenInitialFocus: UseScreenInitialFocus = (ref) => { return; } let rafId: number | null = null; - const attempt = (retry: boolean) => { + let framesLeft = MAX_INITIAL_FOCUS_FRAMES; + const attempt = () => { const el = ref.current; - if (!el || !isOnScreen(el)) { - // The target can attach a frame after the transition ends; retry once on the next frame. - if (retry) { - rafId = requestAnimationFrame(() => attempt(false)); - } + if (el && isOnScreen(el) && claimInitialFocus(el, {focusVisible: getHadTabNavigation()})) { + claimedRef.current = true; return; } - if (claimInitialFocus(el, {focusVisible: getHadTabNavigation()})) { - claimedRef.current = true; + // The target can attach, or its ancestors settle so focus lands, a few frames after the transition ends. + framesLeft -= 1; + if (framesLeft <= 0) { + return; } + rafId = requestAnimationFrame(attempt); }; - attempt(true); + attempt(); return () => { if (rafId === null) { return; diff --git a/src/libs/NavigationFocusReturn/index.native.ts b/src/libs/NavigationFocusReturn/index.native.ts index e911f67272cc..0f4b730dcc97 100644 --- a/src/libs/NavigationFocusReturn/index.native.ts +++ b/src/libs/NavigationFocusReturn/index.native.ts @@ -139,7 +139,21 @@ function scheduleRestore(routeKey: string, {waitForUpcomingTransition}: {waitFor let cancelled = false; let refocusHandle: {cancel: () => void} | null = null; let rafHandle: number | null = null; - const handle = TransitionTracker.runAfterTransitions({ + let handle: {cancel: () => void} | null = null; + + // Assign pendingRestore before runAfterTransitions: with waitForUpcomingTransition false the callback can fire synchronously, so a re-entrant cancel must already see this handle to abort the just-scheduled rAF retry. + pendingRestore = { + cancel: () => { + cancelled = true; + handle?.cancel(); + refocusHandle?.cancel(); + if (rafHandle !== null) { + cancelAnimationFrame(rafHandle); + } + }, + }; + + 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: () => { @@ -165,17 +179,6 @@ function scheduleRestore(routeKey: string, {waitForUpcomingTransition}: {waitFor attempt(); }, }); - - pendingRestore = { - cancel: () => { - cancelled = true; - handle.cancel(); - refocusHandle?.cancel(); - if (rafHandle !== null) { - cancelAnimationFrame(rafHandle); - } - }, - }; } function handleStateChange(newState: NavigationState | undefined): void { @@ -251,13 +254,19 @@ function teardownNavigationFocusReturn(): void { /** 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 { - scheduleRestore(compoundParamsKey(routeKey, targetParams), {waitForUpcomingTransition: false}); + // Honor a one-shot skip on this param-revert too (form-submit goBack can land as PUSH_PARAMS, not a stack pop). + if (skipNextRestore) { + skipNextRestore = false; + } else { + scheduleRestore(compoundParamsKey(routeKey, targetParams), {waitForUpcomingTransition: false}); + } clearStagedPress(); } diff --git a/src/libs/NavigationFocusReturn/index.ts b/src/libs/NavigationFocusReturn/index.ts index c81c34ced023..b4114509e7d6 100644 --- a/src/libs/NavigationFocusReturn/index.ts +++ b/src/libs/NavigationFocusReturn/index.ts @@ -85,11 +85,17 @@ function captureTriggerForRoute(routeKey: string): void { 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)); } 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). + if (skipNextRestore) { + skipNextRestore = false; + return; + } scheduleRestore(compoundParamsKey(routeKey, targetParams), {waitForUpcomingTransition: false}); } diff --git a/src/libs/claimInitialFocus.ts b/src/libs/claimInitialFocus.ts index ae97e817d300..29c7403e6285 100644 --- a/src/libs/claimInitialFocus.ts +++ b/src/libs/claimInitialFocus.ts @@ -1,9 +1,10 @@ import {markProgrammaticFocus} from './programmaticFocus'; -import {Priorities, tryClaim} from './ScreenFocusArbiter'; +import {Priorities, resetCycle, tryClaim} from './ScreenFocusArbiter'; /** * Shared tail for the web initial-focus hooks. `focusVisible: true` shows the ring (keyboard, WCAG 2.4.7); - * false suppresses it for touch via `markProgrammaticFocus`. + * false suppresses it for touch via `markProgrammaticFocus`. Returns false — releasing the cycle and the ring + * marker — when focus doesn't actually land, so a silent no-op neither holds INITIAL nor blocks the caller's retry. */ function claimInitialFocus(el: HTMLElement, {focusVisible}: {focusVisible: boolean}): boolean { if (document.activeElement && document.activeElement !== document.body) { @@ -12,12 +13,19 @@ function claimInitialFocus(el: HTMLElement, {focusVisible}: {focusVisible: boole if (!tryClaim(Priorities.INITIAL)) { return false; } + let clearProgrammaticFocus: (() => void) | undefined; if (focusVisible) { el.focus({preventScroll: true, focusVisible: true}); } else { - markProgrammaticFocus(el); + clearProgrammaticFocus = markProgrammaticFocus(el); el.focus({preventScroll: true}); } + // Focus silently no-ops when an inert / visibility:hidden ancestor passes the focusable + geometry checks. + if (document.activeElement !== el) { + clearProgrammaticFocus?.(); + resetCycle(); + return false; + } return true; } diff --git a/tests/unit/useScreenInitialFocusTest.tsx b/tests/unit/useScreenInitialFocusTest.tsx index 6cbfe4479a32..5bd2c5c755e4 100644 --- a/tests/unit/useScreenInitialFocusTest.tsx +++ b/tests/unit/useScreenInitialFocusTest.tsx @@ -115,6 +115,22 @@ describe('useScreenInitialFocus', () => { expect(button.getAttribute('data-programmatic-focus')).toBe('true'); }); + it('does not leave data-programmatic-focus set when focus silently fails to land (touch)', () => { + mockHasHoverSupport = false; + simulatePointer(); + const button = makeButton(); + // Simulate an inert/visibility:hidden-ancestor case: focus() is a no-op, so activeElement never becomes the button. + jest.spyOn(button, 'focus').mockImplementation(() => {}); + render( + , + ); + // claimInitialFocus must detect the non-landing and run markProgrammaticFocus's cleanup, not leak the ring-suppression attribute. + expect(button.getAttribute('data-programmatic-focus')).toBeNull(); + }); + it('does NOT focus on desktop mouse modality (hasHoverSupport && !hadTab) — WCAG 2.4.7', () => { simulatePointer(); const button = makeButton(); From 7ef676aac9acb791716497ece3d4c1cc6dec933d Mon Sep 17 00:00:00 2001 From: TaduJR Date: Fri, 29 May 2026 21:26:49 +0300 Subject: [PATCH 37/89] fix: correct mobile-web screen-reader focus issues --- src/libs/NavigationFocusReturn/index.ts | 12 ++++++++---- src/libs/claimInitialFocus.ts | 15 +++------------ tests/unit/useScreenInitialFocusTest.tsx | 13 ++++++------- 3 files changed, 17 insertions(+), 23 deletions(-) diff --git a/src/libs/NavigationFocusReturn/index.ts b/src/libs/NavigationFocusReturn/index.ts index b4114509e7d6..1a0ce0eacdc5 100644 --- a/src/libs/NavigationFocusReturn/index.ts +++ b/src/libs/NavigationFocusReturn/index.ts @@ -205,7 +205,7 @@ function cancelPendingFocusRestore(): void { } } -function restoreTriggerForRoute(routeKey: string): boolean { +function restoreTriggerForRoute(routeKey: string, restoreBaseline: Element | null = null): boolean { if (typeof document === 'undefined') { return false; } @@ -219,8 +219,10 @@ function restoreTriggerForRoute(routeKey: string): boolean { 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; } @@ -273,6 +275,8 @@ function cancelPendingRestore(): void { const MAX_RESTORE_FRAMES = 5; function scheduleRestore(routeKey: string, {waitForUpcomingTransition}: {waitForUpcomingTransition: boolean}): 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(); let cancelled = false; let rafId: number | undefined; @@ -298,7 +302,7 @@ function scheduleRestore(routeKey: string, {waitForUpcomingTransition}: {waitFor if (cancelled) { return; } - const restored = restoreTriggerForRoute(routeKey); + const restored = restoreTriggerForRoute(routeKey, restoreBaseline); if (restored || !triggerMap.has(routeKey)) { pendingRestore = null; return; diff --git a/src/libs/claimInitialFocus.ts b/src/libs/claimInitialFocus.ts index 29c7403e6285..71c980e3f469 100644 --- a/src/libs/claimInitialFocus.ts +++ b/src/libs/claimInitialFocus.ts @@ -1,10 +1,8 @@ -import {markProgrammaticFocus} from './programmaticFocus'; import {Priorities, resetCycle, tryClaim} from './ScreenFocusArbiter'; /** - * Shared tail for the web initial-focus hooks. `focusVisible: true` shows the ring (keyboard, WCAG 2.4.7); - * false suppresses it for touch via `markProgrammaticFocus`. Returns false — releasing the cycle and the ring - * marker — when focus doesn't actually land, so a silent no-op neither holds INITIAL nor blocks the caller's retry. + * 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) { @@ -13,16 +11,9 @@ function claimInitialFocus(el: HTMLElement, {focusVisible}: {focusVisible: boole if (!tryClaim(Priorities.INITIAL)) { return false; } - let clearProgrammaticFocus: (() => void) | undefined; - if (focusVisible) { - el.focus({preventScroll: true, focusVisible: true}); - } else { - clearProgrammaticFocus = markProgrammaticFocus(el); - el.focus({preventScroll: true}); - } + el.focus({preventScroll: true, focusVisible}); // Focus silently no-ops when an inert / visibility:hidden ancestor passes the focusable + geometry checks. if (document.activeElement !== el) { - clearProgrammaticFocus?.(); resetCycle(); return false; } diff --git a/tests/unit/useScreenInitialFocusTest.tsx b/tests/unit/useScreenInitialFocusTest.tsx index 5bd2c5c755e4..8f101f52c8f0 100644 --- a/tests/unit/useScreenInitialFocusTest.tsx +++ b/tests/unit/useScreenInitialFocusTest.tsx @@ -100,7 +100,7 @@ describe('useScreenInitialFocus', () => { expect(button.getAttribute('data-programmatic-focus')).toBeNull(); }); - it('focuses the ref on touch-primary devices (no hover) but suppresses the ring (no focusVisible, data-programmatic-focus set)', () => { + 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(); @@ -111,15 +111,15 @@ describe('useScreenInitialFocus', () => { didScreenTransitionEnd />, ); - expect(spy).toHaveBeenCalledWith({preventScroll: true}); - expect(button.getAttribute('data-programmatic-focus')).toBe('true'); + expect(spy).toHaveBeenCalledWith({preventScroll: true, focusVisible: false}); + expect(button.getAttribute('data-programmatic-focus')).toBeNull(); }); - it('does not leave data-programmatic-focus set when focus silently fails to land (touch)', () => { + 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(); - // Simulate an inert/visibility:hidden-ancestor case: focus() is a no-op, so activeElement never becomes the button. + // focus() no-ops (e.g. inert / visibility:hidden ancestor), so activeElement never becomes the button. jest.spyOn(button, 'focus').mockImplementation(() => {}); render( { didScreenTransitionEnd />, ); - // claimInitialFocus must detect the non-landing and run markProgrammaticFocus's cleanup, not leak the ring-suppression attribute. - expect(button.getAttribute('data-programmatic-focus')).toBeNull(); + expect(arbiterClaim(arbiterPriorities.INITIAL)).toBe(true); }); it('does NOT focus on desktop mouse modality (hasHoverSupport && !hadTab) — WCAG 2.4.7', () => { From 393b0ec88be1e02b85aaa094c5d60d3209b453c0 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Fri, 29 May 2026 22:02:53 +0300 Subject: [PATCH 38/89] fix: key native focus-restore on identity props, not the a11y label --- .../GenericPressable/implementation/BaseGenericPressable.tsx | 4 ++-- .../InternationalDepositAccount/subPages/Confirmation.tsx | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx b/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx index 9cb391a4d6e7..07976a69eb91 100644 --- a/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx +++ b/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx @@ -55,8 +55,8 @@ function GenericPressable({ const internalRef = useRef(null); const composedRef = useMemo(() => mergeRefs(ref, internalRef), [ref]); const routeKey = useContext(NavigationRouteContext)?.key ?? null; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- `||` lets empty-string ids fall through; the identifier must be content-stable to survive a remount for the registry rescue. - const focusIdentifier = rest.id || rest.nativeID || rest.testID || rest.accessibilityLabel || undefined; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- `||` falls empty-string ids through to the next identity prop; the registry rescue must key off a stable identity prop, never the (often value-derived) accessibility label. + const focusIdentifier = rest.id || rest.nativeID || rest.testID || undefined; useEffect(() => { if (!isScreenReaderActive || !routeKey || !focusIdentifier) { diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/subPages/Confirmation.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/subPages/Confirmation.tsx index cbca9e8f21cf..56141b6e5afb 100644 --- a/src/pages/settings/Wallet/InternationalDepositAccount/subPages/Confirmation.tsx +++ b/src/pages/settings/Wallet/InternationalDepositAccount/subPages/Confirmation.tsx @@ -180,7 +180,6 @@ function Confirmation({onNext, onMove, formValues, fieldsMap}: CustomSubPageProp {summaryItems.map(({id, description, title, shouldShowRightIcon, interactive, disabled, onPress}) => ( Date: Sat, 30 May 2026 13:43:10 +0300 Subject: [PATCH 39/89] perf: warm AccessibilityInfo once across all subscribers, not per mount --- src/libs/Accessibility/index.ts | 67 ++++++++++++++++++++------------- 1 file changed, 40 insertions(+), 27 deletions(-) diff --git a/src/libs/Accessibility/index.ts b/src/libs/Accessibility/index.ts index 402afbc20879..92cca6134523 100644 --- a/src/libs/Accessibility/index.ts +++ b/src/libs/Accessibility/index.ts @@ -7,35 +7,41 @@ import moveAccessibilityFocus from './moveAccessibilityFocus'; type HitSlop = {x: number; y: number}; -function warmCache(label: string, fetch: () => Promise, apply: (value: T) => void): void { - fetch() - .then(apply) - .catch((error: unknown) => { - Log.warn(`[Accessibility] Failed to warm ${label} cache`, {error}); - }); +/** Memoized warmer: one fetch, shared Promise. Subscribers `.then()` it to catch the boot-race — the platform listener only fires on toggles, never on the initial state. */ +function makeWarmCache(label: string, fetch: () => Promise, apply: (value: T) => void): () => Promise { + let warm: Promise | null = null; + return () => { + warm ??= fetch() + .then(apply) + .catch((error: unknown) => { + Log.warn(`[Accessibility] Failed to warm ${label} cache`, {error}); + }); + return warm; + }; } let cachedScreenReaderValue = false; - -/* - * Warm the cache at module load so the sync read is meaningful before any hook subscribes. - */ -warmCache('screen-reader', isScreenReaderEnabled, (enabled) => { +const ensureScreenReaderWarm = makeWarmCache('screen-reader', isScreenReaderEnabled, (enabled) => { cachedScreenReaderValue = enabled; }); +ensureScreenReaderWarm(); function subscribeScreenReader(callback: () => void) { const subscription = AccessibilityInfo.addEventListener('screenReaderChanged', (enabled) => { cachedScreenReaderValue = enabled; callback(); }); - - warmCache('screen-reader', isScreenReaderEnabled, (enabled) => { - cachedScreenReaderValue = enabled; + let cancelled = false; + ensureScreenReaderWarm().then(() => { + if (cancelled) { + return; + } callback(); }); - - return () => subscription?.remove(); + return () => { + cancelled = true; + subscription?.remove(); + }; } function getScreenReaderSnapshot() { @@ -49,23 +55,30 @@ function isScreenReaderEnabledSync(): boolean { } let cachedReduceMotionValue = false; +const ensureReduceMotionWarm = makeWarmCache( + 'reduce-motion', + () => AccessibilityInfo.isReduceMotionEnabled(), + (enabled) => { + cachedReduceMotionValue = enabled; + }, +); function subscribeReduceMotion(callback: () => void) { const subscription = AccessibilityInfo.addEventListener('reduceMotionChanged', (enabled) => { cachedReduceMotionValue = enabled; callback(); }); - - warmCache( - 'reduce-motion', - () => AccessibilityInfo.isReduceMotionEnabled(), - (enabled) => { - cachedReduceMotionValue = enabled; - callback(); - }, - ); - - return () => subscription?.remove(); + let cancelled = false; + ensureReduceMotionWarm().then(() => { + if (cancelled) { + return; + } + callback(); + }); + return () => { + cancelled = true; + subscription?.remove(); + }; } function getReduceMotionSnapshot() { From bf2af1bfb91c9775d389438386dd84093e1a7976 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Sat, 30 May 2026 14:07:35 +0300 Subject: [PATCH 40/89] fix: clear AccessibilityInfo memoized warm on rejection so the next caller retries --- src/libs/Accessibility/index.ts | 39 +++++++++++++++++++++-------- tests/unit/useReducedMotionTest.tsx | 3 ++- 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/src/libs/Accessibility/index.ts b/src/libs/Accessibility/index.ts index 92cca6134523..3331100b477b 100644 --- a/src/libs/Accessibility/index.ts +++ b/src/libs/Accessibility/index.ts @@ -7,21 +7,30 @@ import moveAccessibilityFocus from './moveAccessibilityFocus'; type HitSlop = {x: number; y: number}; -/** Memoized warmer: one fetch, shared Promise. Subscribers `.then()` it to catch the boot-race — the platform listener only fires on toggles, never on the initial state. */ -function makeWarmCache(label: string, fetch: () => Promise, apply: (value: T) => void): () => Promise { +/** + * 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. + */ +function makeWarmCache(label: string, fetch: () => Promise, apply: (value: T) => void): {ensure: () => Promise; reset: () => void} { let warm: Promise | null = null; - return () => { - warm ??= fetch() - .then(apply) - .catch((error: unknown) => { - Log.warn(`[Accessibility] Failed to warm ${label} cache`, {error}); - }); - return warm; + return { + ensure: () => { + warm ??= fetch() + .then(apply) + .catch((error: unknown) => { + Log.warn(`[Accessibility] Failed to warm ${label} cache`, {error}); + warm = null; + }); + return warm; + }, + reset: () => { + warm = null; + }, }; } let cachedScreenReaderValue = false; -const ensureScreenReaderWarm = makeWarmCache('screen-reader', isScreenReaderEnabled, (enabled) => { +const {ensure: ensureScreenReaderWarm, reset: resetScreenReaderWarm} = makeWarmCache('screen-reader', isScreenReaderEnabled, (enabled) => { cachedScreenReaderValue = enabled; }); ensureScreenReaderWarm(); @@ -55,7 +64,7 @@ function isScreenReaderEnabledSync(): boolean { } let cachedReduceMotionValue = false; -const ensureReduceMotionWarm = makeWarmCache( +const {ensure: ensureReduceMotionWarm, reset: resetReduceMotionWarm} = makeWarmCache( 'reduce-motion', () => AccessibilityInfo.isReduceMotionEnabled(), (enabled) => { @@ -63,6 +72,13 @@ const ensureReduceMotionWarm = makeWarmCache( }, ); +function resetForTests() { + cachedScreenReaderValue = false; + cachedReduceMotionValue = false; + resetScreenReaderWarm(); + resetReduceMotionWarm(); +} + function subscribeReduceMotion(callback: () => void) { const subscription = AccessibilityInfo.addEventListener('reduceMotionChanged', (enabled) => { cachedReduceMotionValue = enabled; @@ -115,6 +131,7 @@ const useAutoHitSlop = () => { return [getHitSlopForSize(frameSize), onLayout] as const; }; +export {resetForTests}; export default { moveAccessibilityFocus, useScreenReaderStatus, 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(() => { From 6edc70c26b97ed3b30ca7f36aafb1ef0324e9614 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Wed, 3 Jun 2026 11:11:00 +0300 Subject: [PATCH 41/89] refactor: default-export markProgrammaticFocus --- src/hooks/useAccessibilityFocus/index.ts | 2 +- src/libs/programmaticFocus.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hooks/useAccessibilityFocus/index.ts b/src/hooks/useAccessibilityFocus/index.ts index 0c2b0f10118b..182a1db70675 100644 --- a/src/hooks/useAccessibilityFocus/index.ts +++ b/src/hooks/useAccessibilityFocus/index.ts @@ -1,6 +1,6 @@ import {useEffect} from 'react'; import isHTMLElement from '@libs/isHTMLElement'; -import {markProgrammaticFocus} from '@libs/programmaticFocus'; +import markProgrammaticFocus from '@libs/programmaticFocus'; import type UseAccessibilityFocus from './type'; const FOCUSABLE_ELEMENTS_SELECTOR = 'button, [href], [role="button"], [role="link"], [tabindex]:not([tabindex="-1"])'; diff --git a/src/libs/programmaticFocus.ts b/src/libs/programmaticFocus.ts index 6f4f55e5f13a..53e182046577 100644 --- a/src/libs/programmaticFocus.ts +++ b/src/libs/programmaticFocus.ts @@ -12,4 +12,4 @@ function markProgrammaticFocus(el: HTMLElement): () => void { }; } -export {markProgrammaticFocus}; +export default markProgrammaticFocus; From c190746ca431e291c9c50d59fc5f61e0ad7da6ad Mon Sep 17 00:00:00 2001 From: TaduJR Date: Wed, 3 Jun 2026 12:03:44 +0300 Subject: [PATCH 42/89] fix: drop the trigger entry on all skipNextFocusRestore paths --- .../NavigationFocusReturn/index.native.ts | 16 +++++++---- src/libs/NavigationFocusReturn/index.ts | 15 ++++++++--- tests/unit/NavigationFocusReturnTest.ts | 27 ++++++++++++++++++- 3 files changed, 48 insertions(+), 10 deletions(-) diff --git a/src/libs/NavigationFocusReturn/index.native.ts b/src/libs/NavigationFocusReturn/index.native.ts index 0f4b730dcc97..8718dfb7bdb1 100644 --- a/src/libs/NavigationFocusReturn/index.native.ts +++ b/src/libs/NavigationFocusReturn/index.native.ts @@ -134,6 +134,13 @@ function cancelPendingRestore(): void { 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: boolean}): void { cancelPendingRestore(); let cancelled = false; @@ -193,9 +200,7 @@ function handleStateChange(newState: NavigationState | undefined): void { captureTriggerForRoute(action.captureKey); } else if (action.type === 'backward') { if (skipNextRestore) { - skipNextRestore = false; - cancelPendingRestore(); - triggerMap.delete(action.restoreKey); + applySkippedRestore(action.restoreKey); } else { scheduleRestore(action.restoreKey, {waitForUpcomingTransition: true}); } @@ -262,10 +267,11 @@ function notifyPushParamsForward(routeKey: string, prevParams: unknown): void { 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) { - skipNextRestore = false; + applySkippedRestore(compoundKey); } else { - scheduleRestore(compoundParamsKey(routeKey, targetParams), {waitForUpcomingTransition: false}); + scheduleRestore(compoundKey, {waitForUpcomingTransition: false}); } clearStagedPress(); } diff --git a/src/libs/NavigationFocusReturn/index.ts b/src/libs/NavigationFocusReturn/index.ts index 1a0ce0eacdc5..4c1e8fda1432 100644 --- a/src/libs/NavigationFocusReturn/index.ts +++ b/src/libs/NavigationFocusReturn/index.ts @@ -92,11 +92,12 @@ function notifyPushParamsForward(routeKey: string, prevParams: unknown): void { 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) { - skipNextRestore = false; + applySkippedRestore(compoundKey); return; } - scheduleRestore(compoundParamsKey(routeKey, targetParams), {waitForUpcomingTransition: false}); + scheduleRestore(compoundKey, {waitForUpcomingTransition: false}); } /* @@ -272,6 +273,13 @@ function cancelPendingRestore(): void { 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); +} + const MAX_RESTORE_FRAMES = 5; function scheduleRestore(routeKey: string, {waitForUpcomingTransition}: {waitForUpcomingTransition: boolean}): void { @@ -343,8 +351,7 @@ function handleStateChange(newState: NavigationState | undefined): void { lastMouseTriggerAt = 0; } else if (action.type === 'backward') { if (skipNextRestore) { - skipNextRestore = false; - cancelPendingRestore(); + applySkippedRestore(action.restoreKey); } else { scheduleRestore(action.restoreKey, {waitForUpcomingTransition: true}); } diff --git a/tests/unit/NavigationFocusReturnTest.ts b/tests/unit/NavigationFocusReturnTest.ts index 18279a0c4d66..bc94046dd862 100644 --- a/tests/unit/NavigationFocusReturnTest.ts +++ b/tests/unit/NavigationFocusReturnTest.ts @@ -1506,7 +1506,8 @@ describe('handleStateChange integration', () => { 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); @@ -1515,6 +1516,30 @@ describe('handleStateChange integration', () => { }); }); + 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(); + + // No fresh capture between the skipped revert and the next back: the entry must be gone, no stale replay. + 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(); From cb4b03670dd050c6766418e63a7c9deb03be61d5 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Wed, 3 Jun 2026 12:31:31 +0300 Subject: [PATCH 43/89] chore: CI restart From 78a0fbea2fd4b638ef07bd94baea8d96953cc39d Mon Sep 17 00:00:00 2001 From: TaduJR Date: Wed, 3 Jun 2026 20:50:12 +0300 Subject: [PATCH 44/89] fix: pass isOffline to updateMoneyRequestMerchant in the merchant step --- src/pages/iou/request/step/IOURequestStepMerchant.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/pages/iou/request/step/IOURequestStepMerchant.tsx b/src/pages/iou/request/step/IOURequestStepMerchant.tsx index d6605e1416e7..3186cc2afc2d 100644 --- a/src/pages/iou/request/step/IOURequestStepMerchant.tsx +++ b/src/pages/iou/request/step/IOURequestStepMerchant.tsx @@ -10,6 +10,7 @@ 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'; import usePolicy from '@hooks/usePolicy'; @@ -71,6 +72,7 @@ function IOURequestStepMerchant({ const currentUserEmailParam = currentUserPersonalDetails.login ?? ''; const {isBetaEnabled} = usePermissions(); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); + const {isOffline} = useNetwork(); const isMerchantRequired = isPolicyExpenseChat(report) || isExpenseRequest(report) || transaction?.participants?.some((participant) => !!participant.isPolicyExpenseChat); @@ -130,6 +132,7 @@ function IOURequestStepMerchant({ currentUserEmailParam, isASAPSubmitBetaEnabled, parentReportNextStep, + isOffline, delegateAccountID, }); } else { From ed43253cff9f087708eed983b869559bdb5dd2dd Mon Sep 17 00:00:00 2001 From: TaduJR Date: Mon, 8 Jun 2026 17:51:09 +0300 Subject: [PATCH 45/89] chore: CI restart From effc8bcfa961ebe89b5063120731b4e44fe3a30e Mon Sep 17 00:00:00 2001 From: TaduJR Date: Mon, 8 Jun 2026 17:59:21 +0300 Subject: [PATCH 46/89] fix: use armNavigateBack() in the clear-merchant branch --- src/pages/iou/request/step/IOURequestStepMerchant.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/iou/request/step/IOURequestStepMerchant.tsx b/src/pages/iou/request/step/IOURequestStepMerchant.tsx index e949f62b30b6..46b64db90309 100644 --- a/src/pages/iou/request/step/IOURequestStepMerchant.tsx +++ b/src/pages/iou/request/step/IOURequestStepMerchant.tsx @@ -113,7 +113,7 @@ function IOURequestStepMerchant({ if (newMerchant === '' && isInvalidMerchantValue(merchant)) { setIsSaved(true); - shouldNavigateAfterSaveRef.current = true; + armNavigateBack(); clearMoneyRequestMerchant(transactionID); return; } From 7d45fc252dd5c0fad519ef1e1a47f1c1b70e04e8 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Wed, 10 Jun 2026 14:02:37 +0300 Subject: [PATCH 47/89] chore: replace unsafe type assertions with typed mocks --- src/libs/navigationStateDiff.ts | 2 +- tests/unit/scheduleRefocusAndroidTest.ts | 10 +++++----- tests/unit/useNavigateBackOnSaveTest.ts | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/libs/navigationStateDiff.ts b/src/libs/navigationStateDiff.ts index b7280261c456..6f625c652ac0 100644 --- a/src/libs/navigationStateDiff.ts +++ b/src/libs/navigationStateDiff.ts @@ -14,7 +14,7 @@ function collectRouteKeys(state: AnyState, out = new Set()): Set out.add(route.key); } if (route.state) { - collectRouteKeys(route.state as PartialState, out); + collectRouteKeys(route.state, out); } } return out; diff --git a/tests/unit/scheduleRefocusAndroidTest.ts b/tests/unit/scheduleRefocusAndroidTest.ts index 12b383e79b70..e308573709aa 100644 --- a/tests/unit/scheduleRefocusAndroidTest.ts +++ b/tests/unit/scheduleRefocusAndroidTest.ts @@ -9,7 +9,7 @@ jest.mock('@libs/Accessibility/fireFocusEvent', () => ({ const FAKE_IDLE_ID = 4242; let capturedIdleCallback: (() => void) | null = null; let capturedIdleOptions: {timeout?: number} | undefined; -const mockCancelIdleCallback = jest.fn(); +const mockCancelIdleCallback = jest.fn(); const originalRequestIdleCallback = global.requestIdleCallback; const originalCancelIdleCallback = global.cancelIdleCallback; @@ -22,12 +22,12 @@ beforeEach(() => { 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: () => void, options?: {timeout?: number}) => { - capturedIdleCallback = callback; + global.requestIdleCallback = jest.fn((callback, options) => { + capturedIdleCallback = () => callback({didTimeout: false, timeRemaining: () => 50}); capturedIdleOptions = options; return FAKE_IDLE_ID; - }) as unknown as typeof requestIdleCallback; - global.cancelIdleCallback = mockCancelIdleCallback as unknown as typeof cancelIdleCallback; + }); + global.cancelIdleCallback = mockCancelIdleCallback; }); afterEach(() => { diff --git a/tests/unit/useNavigateBackOnSaveTest.ts b/tests/unit/useNavigateBackOnSaveTest.ts index 5dfa641a7e9a..abbfad20c2c6 100644 --- a/tests/unit/useNavigateBackOnSaveTest.ts +++ b/tests/unit/useNavigateBackOnSaveTest.ts @@ -14,8 +14,8 @@ jest.mock('@libs/NavigationFocusReturn', () => ({ skipNextFocusRestore: jest.fn(), })); -const mockGoBack = Navigation.goBack as jest.Mock; -const mockSkip = skipNextFocusRestore as jest.Mock; +const mockGoBack = jest.mocked(Navigation.goBack); +const mockSkip = jest.mocked(skipNextFocusRestore); const BACK_TO = 'settings/profile' as Route; From a66e2ec140e85b0896ef43841ffd82bae6816fb9 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Sat, 13 Jun 2026 09:08:53 +0300 Subject: [PATCH 48/89] fix: claim initial focus on the chat back button and the modal launcher --- .../FocusTrap/FocusTrapForModal/index.web.tsx | 14 +++++--------- .../Search/SearchRouter/SearchButton.tsx | 8 ++------ src/hooks/useDialogContainerFocus/index.ts | 1 - src/libs/NavigationFocusReturn/index.native.ts | 1 - src/libs/NavigationFocusReturn/index.ts | 1 - src/pages/inbox/HeaderView.tsx | 10 +++++++++- 6 files changed, 16 insertions(+), 19 deletions(-) diff --git a/src/components/FocusTrap/FocusTrapForModal/index.web.tsx b/src/components/FocusTrap/FocusTrapForModal/index.web.tsx index 466855547409..27d76071f39a 100644 --- a/src/components/FocusTrap/FocusTrapForModal/index.web.tsx +++ b/src/components/FocusTrap/FocusTrapForModal/index.web.tsx @@ -2,6 +2,7 @@ import {FocusTrap} from 'focus-trap-react'; import React, {useRef} from 'react'; import sharedTrapStack from '@components/FocusTrap/sharedTrapStack'; import blurActiveElement from '@libs/Accessibility/blurActiveElement'; +import getHadTabNavigation from '@libs/hadTabNavigation'; import {scheduleClearActivePopoverLauncher, setActivePopoverLauncher} from '@libs/LauncherStack'; import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; import type FocusTrapForModalProps from './FocusTrapForModalProps'; @@ -28,6 +29,9 @@ function FocusTrapForModal({children, active, initialFocus = false, shouldPreven if (!launcher) { return; } + if (shouldReturnFocus && !ReportActionComposeFocusManager.isFocused() && document.contains(launcher)) { + launcher.focus({preventScroll: true, focusVisible: getHadTabNavigation()}); + } // Deferred so popover paths that navigate after modal-hide can still consume. scheduleClearActivePopoverLauncher(launcher); }, @@ -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/Search/SearchRouter/SearchButton.tsx b/src/components/Search/SearchRouter/SearchButton.tsx index dc1e92a48c74..313d56dcf0ae 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'; @@ -23,13 +23,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, @@ -47,7 +44,6 @@ function SearchButton({style, shouldUseAutoHitSlop = false}: SearchButtonProps) return ( (null); + const setBackButtonRef = useCallback((node: unknown) => { + backButtonRef.current = isHTMLElement(node) ? node : null; + }, []); + useScreenInitialFocus(backButtonRef); const isSelfDM = isSelfDMReportUtils(report); const isGroupChat = isGroupChatReportUtils(report) || isDeprecatedGroupDM(report, isReportArchived); const isConciergeChat = isConciergeChatReport(report, conciergeReportID); @@ -290,6 +297,7 @@ function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) {shouldShowBackButton && ( Date: Sat, 13 Jun 2026 09:17:20 +0300 Subject: [PATCH 49/89] refactor: extract useInitialFocusRef and restoreFocusWithModality primitives --- .../FocusTrap/FocusTrapForModal/index.web.tsx | 4 ++-- src/components/HeaderWithBackButton/index.tsx | 13 +++---------- src/hooks/useInitialFocusRef/index.ts | 15 +++++++++++++++ src/libs/NavigationFocusReturn/index.ts | 4 ++-- src/libs/restoreFocusWithModality.ts | 8 ++++++++ src/pages/inbox/HeaderView.tsx | 11 +++-------- 6 files changed, 33 insertions(+), 22 deletions(-) create mode 100644 src/hooks/useInitialFocusRef/index.ts create mode 100644 src/libs/restoreFocusWithModality.ts diff --git a/src/components/FocusTrap/FocusTrapForModal/index.web.tsx b/src/components/FocusTrap/FocusTrapForModal/index.web.tsx index 27d76071f39a..758632ec7d65 100644 --- a/src/components/FocusTrap/FocusTrapForModal/index.web.tsx +++ b/src/components/FocusTrap/FocusTrapForModal/index.web.tsx @@ -2,9 +2,9 @@ import {FocusTrap} from 'focus-trap-react'; import React, {useRef} from 'react'; import sharedTrapStack from '@components/FocusTrap/sharedTrapStack'; import blurActiveElement from '@libs/Accessibility/blurActiveElement'; -import getHadTabNavigation from '@libs/hadTabNavigation'; import {scheduleClearActivePopoverLauncher, setActivePopoverLauncher} from '@libs/LauncherStack'; import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; +import restoreFocusWithModality from '@libs/restoreFocusWithModality'; import type FocusTrapForModalProps from './FocusTrapForModalProps'; function FocusTrapForModal({children, active, initialFocus = false, shouldPreventScroll = false, shouldReturnFocus = true}: FocusTrapForModalProps) { @@ -30,7 +30,7 @@ function FocusTrapForModal({children, active, initialFocus = false, shouldPreven return; } if (shouldReturnFocus && !ReportActionComposeFocusManager.isFocused() && document.contains(launcher)) { - launcher.focus({preventScroll: true, focusVisible: getHadTabNavigation()}); + restoreFocusWithModality(launcher); } // Deferred so popover paths that navigate after modal-hide can still consume. scheduleClearActivePopoverLauncher(launcher); diff --git a/src/components/HeaderWithBackButton/index.tsx b/src/components/HeaderWithBackButton/index.tsx index 8d3ef3033612..6993d680235d 100755 --- a/src/components/HeaderWithBackButton/index.tsx +++ b/src/components/HeaderWithBackButton/index.tsx @@ -1,7 +1,5 @@ -import React, {useCallback, useMemo, useRef} from 'react'; +import React, {useMemo} from 'react'; import {Keyboard, StyleSheet, View} from 'react-native'; -// eslint-disable-next-line no-restricted-imports -- type-only; matches PressableRef's host-instance union for the back-button callback ref. -import type {Text as RNText} from 'react-native'; import type {SvgProps} from 'react-native-svg'; import ActivityIndicator from '@components/ActivityIndicator'; import Avatar from '@components/Avatar'; @@ -15,16 +13,15 @@ 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'; -import useScreenInitialFocus from '@hooks/useScreenInitialFocus'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useThrottledButtonState from '@hooks/useThrottledButtonState'; import getButtonState from '@libs/getButtonState'; -import isHTMLElement from '@libs/isHTMLElement'; import Navigation from '@libs/Navigation/Navigation'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import variables from '@styles/variables'; @@ -93,11 +90,7 @@ function HeaderWithBackButton({ const [isDownloadButtonActive, temporarilyDisableDownloadButton] = useThrottledButtonState(); const {translate} = useLocalize(); const isInLandscapeMode = useIsInLandscapeMode(); - const backButtonRef = useRef(null); - const setBackButtonRef = useCallback((node: HTMLDivElement | View | RNText | null | undefined) => { - backButtonRef.current = isHTMLElement(node) ? node : null; - }, []); - useScreenInitialFocus(backButtonRef); + const setBackButtonRef = useInitialFocusRef(); const downloadReasonAttributes = useMemo( () => ({ diff --git a/src/hooks/useInitialFocusRef/index.ts b/src/hooks/useInitialFocusRef/index.ts new file mode 100644 index 000000000000..499f6f5e31b8 --- /dev/null +++ b/src/hooks/useInitialFocusRef/index.ts @@ -0,0 +1,15 @@ +import {useCallback, useRef} from 'react'; +import useScreenInitialFocus from '@hooks/useScreenInitialFocus'; +import isHTMLElement from '@libs/isHTMLElement'; + +/** Returns a ref-callback to attach to the element that should claim focus when its screen mounts (e.g. a back button on a screen header). */ +function useInitialFocusRef(): (node: unknown) => void { + const ref = useRef(null); + const setRef = useCallback((node: unknown) => { + ref.current = isHTMLElement(node) ? node : null; + }, []); + useScreenInitialFocus(ref); + return setRef; +} + +export default useInitialFocusRef; diff --git a/src/libs/NavigationFocusReturn/index.ts b/src/libs/NavigationFocusReturn/index.ts index 22d0975d4242..5f217c8e4544 100644 --- a/src/libs/NavigationFocusReturn/index.ts +++ b/src/libs/NavigationFocusReturn/index.ts @@ -9,6 +9,7 @@ import {consumeLauncher, pickLauncher, resetLauncherStackForTests} from '@libs/L import navigationRef from '@libs/Navigation/navigationRef'; import TransitionTracker from '@libs/Navigation/TransitionTracker'; import {collectRouteKeys, diffNavigationState} from '@libs/navigationStateDiff'; +import restoreFocusWithModality from '@libs/restoreFocusWithModality'; import {isCycleIdle, Priorities, resetCycle, tryClaim} from '@libs/ScreenFocusArbiter'; /** focusin tracks the last keyboard-focused element; a nav state listener captures it against the outgoing route and restores it on backward nav. */ @@ -237,12 +238,11 @@ function restoreTriggerForRoute(routeKey: string, restoreBaseline: Element | nul 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; } diff --git a/src/libs/restoreFocusWithModality.ts b/src/libs/restoreFocusWithModality.ts new file mode 100644 index 000000000000..01865020cd12 --- /dev/null +++ b/src/libs/restoreFocusWithModality.ts @@ -0,0 +1,8 @@ +import getHadTabNavigation from './hadTabNavigation'; + +/** Restores focus with the keyboard ring matched to the current input modality: visible for keyboard (WCAG 2.4.7), suppressed for touch. */ +function restoreFocusWithModality(el: HTMLElement): void { + el.focus({preventScroll: true, focusVisible: getHadTabNavigation()}); +} + +export default restoreFocusWithModality; diff --git a/src/pages/inbox/HeaderView.tsx b/src/pages/inbox/HeaderView.tsx index 1da26c0150c6..e37470411579 100644 --- a/src/pages/inbox/HeaderView.tsx +++ b/src/pages/inbox/HeaderView.tsx @@ -2,7 +2,7 @@ import {useRoute} from '@react-navigation/native'; import {accountGuideDetailsSelector} from '@selectors/Account'; import {pendingChatMembersSelector} from '@selectors/ReportMetaData'; import {isPast} from 'date-fns'; -import React, {useCallback, useMemo, useRef} from 'react'; +import React, {useMemo} from 'react'; import {Keyboard, View} from 'react-native'; import Button from '@components/Button'; import CaretWrapper from '@components/CaretWrapper'; @@ -23,6 +23,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'; @@ -32,12 +33,10 @@ import usePolicy from '@hooks/usePolicy'; import useReportAttributes from '@hooks/useReportAttributes'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useScreenInitialFocus from '@hooks/useScreenInitialFocus'; import useSubscriptionPlan from '@hooks/useSubscriptionPlan'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import isHTMLElement from '@libs/isHTMLElement'; import {getPersonalDetailsForAccountIDs} from '@libs/OptionsListUtils'; import Parser from '@libs/Parser'; import {getHumanAgentAccountIDFromReportAction, getHumanAgentFirstName} from '@libs/ReportActionsUtils'; @@ -126,11 +125,7 @@ function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) const {translate, localeCompare, formatPhoneNumber} = useLocalize(); const theme = useTheme(); const styles = useThemeStyles(); - const backButtonRef = useRef(null); - const setBackButtonRef = useCallback((node: unknown) => { - backButtonRef.current = isHTMLElement(node) ? node : null; - }, []); - useScreenInitialFocus(backButtonRef); + const setBackButtonRef = useInitialFocusRef(); const isSelfDM = isSelfDMReportUtils(report); const isGroupChat = isGroupChatReportUtils(report) || isDeprecatedGroupDM(report, isReportArchived); const isConciergeChat = isConciergeChatReport(report, conciergeReportID); From 669b046827a6d38979c88fbccc8f70301d48100d Mon Sep 17 00:00:00 2001 From: TaduJR Date: Sat, 13 Jun 2026 09:38:24 +0300 Subject: [PATCH 50/89] refactor: drop useCallback from useInitialFocusRef --- src/hooks/useInitialFocusRef/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/hooks/useInitialFocusRef/index.ts b/src/hooks/useInitialFocusRef/index.ts index 499f6f5e31b8..26a2ee361f12 100644 --- a/src/hooks/useInitialFocusRef/index.ts +++ b/src/hooks/useInitialFocusRef/index.ts @@ -1,13 +1,13 @@ -import {useCallback, useRef} from 'react'; +import {useRef} from 'react'; import useScreenInitialFocus from '@hooks/useScreenInitialFocus'; import isHTMLElement from '@libs/isHTMLElement'; /** Returns a ref-callback to attach to the element that should claim focus when its screen mounts (e.g. a back button on a screen header). */ function useInitialFocusRef(): (node: unknown) => void { const ref = useRef(null); - const setRef = useCallback((node: unknown) => { + const setRef = (node: unknown) => { ref.current = isHTMLElement(node) ? node : null; - }, []); + }; useScreenInitialFocus(ref); return setRef; } From 4c9431122da14cd1d75e65c2b58b4b2e95ca4e91 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Sat, 13 Jun 2026 14:42:30 +0300 Subject: [PATCH 51/89] fix: tighten focus-claim/restore invariants on PUSH_PARAMS, re-presentation, and dialog races --- src/hooks/useScreenInitialFocus/index.ts | 13 +++++++++++-- src/libs/NavigationFocusReturn/index.ts | 3 +++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/hooks/useScreenInitialFocus/index.ts b/src/hooks/useScreenInitialFocus/index.ts index 61f17872391c..158d98070b7b 100644 --- a/src/hooks/useScreenInitialFocus/index.ts +++ b/src/hooks/useScreenInitialFocus/index.ts @@ -1,4 +1,5 @@ import {useContext, useEffect, useRef} from 'react'; +import {useDialogLabelData} from '@components/DialogLabelContext'; import ScreenWrapperStatusContext from '@components/ScreenWrapper/ScreenWrapperStatusContext'; import claimInitialFocus from '@libs/claimInitialFocus'; import hasHoverSupport from '@libs/DeviceCapabilities/hasHoverSupport'; @@ -30,10 +31,18 @@ const MAX_INITIAL_FOCUS_FRAMES = 5; */ const useScreenInitialFocus: UseScreenInitialFocus = (ref) => { const status = useContext(ScreenWrapperStatusContext); + const {isInsideDialog} = useDialogLabelData(); const claimedRef = useRef(false); useEffect(() => { - if (!status?.didScreenTransitionEnd || claimedRef.current) { + if (isInsideDialog) { + return; + } + if (!status?.didScreenTransitionEnd) { + claimedRef.current = false; + return; + } + if (claimedRef.current) { return; } if (hasHoverSupport() && !getHadTabNavigation()) { @@ -61,7 +70,7 @@ const useScreenInitialFocus: UseScreenInitialFocus = (ref) => { } cancelAnimationFrame(rafId); }; - }, [status?.didScreenTransitionEnd, ref]); + }, [isInsideDialog, status?.didScreenTransitionEnd, ref]); }; export default useScreenInitialFocus; diff --git a/src/libs/NavigationFocusReturn/index.ts b/src/libs/NavigationFocusReturn/index.ts index 5f217c8e4544..1969725937d7 100644 --- a/src/libs/NavigationFocusReturn/index.ts +++ b/src/libs/NavigationFocusReturn/index.ts @@ -88,6 +88,9 @@ function notifyPushParamsForward(routeKey: string, prevParams: unknown): void { skipNextRestore = false; cancelPendingFocusRestore(); captureTriggerForRoute(compoundParamsKey(routeKey, prevParams)); + lastInteractiveElement = null; + lastMouseTrigger = null; + lastMouseTriggerAt = 0; } function notifyPushParamsBackward(routeKey: string, targetParams: unknown): void { From 9cde887ea5a8a6bc4d1db2a577cb38b50b28585f Mon Sep 17 00:00:00 2001 From: TaduJR Date: Sat, 13 Jun 2026 14:55:37 +0300 Subject: [PATCH 52/89] fix: tie-break the registry rescue on back-button collisions --- .../NavigationFocusReturn/index.native.ts | 6 +++-- tests/unit/NavigationFocusReturnNativeTest.ts | 25 +++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/libs/NavigationFocusReturn/index.native.ts b/src/libs/NavigationFocusReturn/index.native.ts index 1555a269e864..d6bb66290122 100644 --- a/src/libs/NavigationFocusReturn/index.native.ts +++ b/src/libs/NavigationFocusReturn/index.native.ts @@ -8,6 +8,7 @@ import compoundParamsKey, {COMPOUND_KEY_DELIMITER} from '@libs/compoundParamsKey import navigationRef from '@libs/Navigation/navigationRef'; import TransitionTracker from '@libs/Navigation/TransitionTracker'; import {diffNavigationState} from '@libs/navigationStateDiff'; +import CONST from '@src/CONST'; type TriggerEntry = {ref: RefObject; identifier?: string}; @@ -114,8 +115,9 @@ function restoreTriggerForRoute(routeKey: string): RefObject | null // Pressables register under the raw route key; PUSH_PARAMS restores arrive under the compound key, so strip the suffix to match. const rawRouteKey = routeKey.split(COMPOUND_KEY_DELIMITER).at(0) ?? routeKey; const liveRefs = Array.from(pressableRegistry.get(rawRouteKey)?.get(entry.identifier) ?? []).filter((candidate) => candidate.current); - // A colliding label (e.g. every row's "Edit") is ambiguous — decline rather than focus the wrong row. - const liveRef = liveRefs.length === 1 ? liveRefs.at(0) : undefined; + // Decline on row collision (would focus wrong row); accept on back-button collision (dual-header — any is correct). + const acceptCollision = entry.identifier === CONST.BACK_BUTTON_NATIVE_ID; + const liveRef = liveRefs.length === 1 || (acceptCollision && liveRefs.length > 1) ? liveRefs.at(0) : undefined; if (liveRef) { ref = liveRef; view = liveRef.current; diff --git a/tests/unit/NavigationFocusReturnNativeTest.ts b/tests/unit/NavigationFocusReturnNativeTest.ts index 4b6960c293a7..5e81eda24e3c 100644 --- a/tests/unit/NavigationFocusReturnNativeTest.ts +++ b/tests/unit/NavigationFocusReturnNativeTest.ts @@ -734,6 +734,31 @@ describe('pressable registry — identifier-based fallback', () => { 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'}, + ]), + ); + + // Captured ref dies, dual-header layout registers two back buttons under one route. + 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); From a0720dc53862f12099eaabf4b07b93980f729973 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Sat, 13 Jun 2026 15:28:50 +0300 Subject: [PATCH 53/89] chore: thread stable pressableTestID through value-displaying rows --- .../MoneyRequestConfirmationList/sections/TaxFields.tsx | 2 ++ .../SpendRules/configuration/SpendRuleMerchantsBase.tsx | 1 + src/components/SubStepForms/ConfirmationStep.tsx | 4 +++- src/pages/settings/Profile/ProfilePage.tsx | 7 +++++++ .../InternationalDepositAccount/subPages/Confirmation.tsx | 1 + .../expensifyCard/WorkspaceExpensifyCardListPage.tsx | 1 + src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx | 5 +++++ 7 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/components/MoneyRequestConfirmationList/sections/TaxFields.tsx b/src/components/MoneyRequestConfirmationList/sections/TaxFields.tsx index bf836cfaa3fb..0b0fe5648ab6 100644 --- a/src/components/MoneyRequestConfirmationList/sections/TaxFields.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/TaxFields.tsx @@ -107,6 +107,7 @@ function TaxFields({policy, policyForMovingExpenses, iouCurrencyCode, canModifyT <> void; brickRoadIndicator?: BrickRoad; errorText?: string; + testID?: string; }; type ConfirmationStepProps = SubStepProps & @@ -70,9 +71,10 @@ function ConfirmationStep({ contentContainerStyle={[styles.flexGrow1, shouldApplySafeAreaPaddingBottom && {paddingBottom: safeAreaInsetPaddingBottom + styles.pb5.paddingBottom}]} > {pageTitle} - {summaryItems.map(({description, title, shouldShowRightIcon, onPress, brickRoadIndicator, errorText}) => ( + {summaryItems.map(({description, title, shouldShowRightIcon, onPress, brickRoadIndicator, errorText, testID}) => ( 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), }, @@ -305,6 +311,7 @@ function ProfilePage() { wrapperStyle={styles.sectionMenuItemTopDescription} onPress={detail.action} brickRoadIndicator={detail.brickRoadIndicator} + pressableTestID={detail?.testID} sentryLabel={detail.sentryLabel} /> ))} diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/subPages/Confirmation.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/subPages/Confirmation.tsx index 56141b6e5afb..cbca9e8f21cf 100644 --- a/src/pages/settings/Wallet/InternationalDepositAccount/subPages/Confirmation.tsx +++ b/src/pages/settings/Wallet/InternationalDepositAccount/subPages/Confirmation.tsx @@ -180,6 +180,7 @@ function Confirmation({onNext, onMove, formValues, fieldsMap}: CustomSubPageProp {summaryItems.map(({id, description, title, shouldShowRightIcon, interactive, disabled, onPress}) => ( Navigation.navigate(ROUTES.WORKSPACE_EXPENSIFY_CARD_DETAILS.getRoute(policyID, item.cardID.toString()))} diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx index 312689108191..9e17707a236c 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx @@ -338,6 +338,7 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM interactive={!isReimburser} description={translate('common.role')} shouldShowRightIcon={!isReimburser} + pressableTestID="member-role-menu-item" onPress={() => { if ( tryNavigateToSubmitWorkspaceUpgrade( @@ -362,6 +363,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" /> @@ -370,6 +372,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" /> @@ -380,6 +383,7 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM icon={icons.Info} onPress={navigateToProfile} shouldShowRightIcon + pressableTestID="member-profile-menu-item" /> {memberCards.length > 0 && ( <> @@ -404,6 +408,7 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM > Date: Sat, 13 Jun 2026 15:46:56 +0300 Subject: [PATCH 54/89] fix: react to late node attachment in useScreenInitialFocus --- src/hooks/useInitialFocusRef/index.ts | 14 +++--- src/hooks/useScreenInitialFocus/index.ts | 14 +++--- src/hooks/useScreenInitialFocus/types.ts | 4 +- .../members/WorkspaceMemberDetailsPage.tsx | 2 +- tests/unit/useScreenInitialFocusTest.tsx | 44 +++++++------------ 5 files changed, 34 insertions(+), 44 deletions(-) diff --git a/src/hooks/useInitialFocusRef/index.ts b/src/hooks/useInitialFocusRef/index.ts index 26a2ee361f12..a725faf2b0d2 100644 --- a/src/hooks/useInitialFocusRef/index.ts +++ b/src/hooks/useInitialFocusRef/index.ts @@ -1,15 +1,15 @@ -import {useRef} from 'react'; +import {useState} from 'react'; import useScreenInitialFocus from '@hooks/useScreenInitialFocus'; import isHTMLElement from '@libs/isHTMLElement'; -/** Returns a ref-callback to attach to the element that should claim focus when its screen mounts (e.g. a back button on a screen header). */ +/** Returns a ref-callback to attach to the element that should claim focus when its screen mounts (e.g. a back button on a screen header). Late attachment re-triggers the claim. */ function useInitialFocusRef(): (node: unknown) => void { - const ref = useRef(null); - const setRef = (node: unknown) => { - ref.current = isHTMLElement(node) ? node : null; + const [node, setNode] = useState(null); + useScreenInitialFocus(node); + return (newNode: unknown) => { + const next = isHTMLElement(newNode) ? newNode : null; + setNode((prev) => (prev === next ? prev : next)); }; - useScreenInitialFocus(ref); - return setRef; } export default useInitialFocusRef; diff --git a/src/hooks/useScreenInitialFocus/index.ts b/src/hooks/useScreenInitialFocus/index.ts index 158d98070b7b..3b7685119080 100644 --- a/src/hooks/useScreenInitialFocus/index.ts +++ b/src/hooks/useScreenInitialFocus/index.ts @@ -26,10 +26,11 @@ function isOnScreen(el: HTMLElement): boolean { const MAX_INITIAL_FOCUS_FRAMES = 5; /* - * Mobile-web counterpart to `useDialogContainerFocus` (RHP-only): focuses `ref` once after `didScreenTransitionEnd`. + * Mobile-web counterpart to `useDialogContainerFocus` (RHP-only): focuses `node` once after `didScreenTransitionEnd`. + * Takes the attached node (not a ref) so late attachment — skeleton → real header, Suspense — re-runs the effect. * Hover-capable devices gate on Tab (WCAG 2.4.7); touch-primary devices bypass. */ -const useScreenInitialFocus: UseScreenInitialFocus = (ref) => { +const useScreenInitialFocus: UseScreenInitialFocus = (node) => { const status = useContext(ScreenWrapperStatusContext); const {isInsideDialog} = useDialogLabelData(); const claimedRef = useRef(false); @@ -45,18 +46,19 @@ const useScreenInitialFocus: UseScreenInitialFocus = (ref) => { if (claimedRef.current) { return; } + if (!node) { + return; + } if (hasHoverSupport() && !getHadTabNavigation()) { return; } let rafId: number | null = null; let framesLeft = MAX_INITIAL_FOCUS_FRAMES; const attempt = () => { - const el = ref.current; - if (el && isOnScreen(el) && claimInitialFocus(el, {focusVisible: getHadTabNavigation()})) { + if (isOnScreen(node) && claimInitialFocus(node, {focusVisible: getHadTabNavigation()})) { claimedRef.current = true; return; } - // The target can attach, or its ancestors settle so focus lands, a few frames after the transition ends. framesLeft -= 1; if (framesLeft <= 0) { return; @@ -70,7 +72,7 @@ const useScreenInitialFocus: UseScreenInitialFocus = (ref) => { } cancelAnimationFrame(rafId); }; - }, [isInsideDialog, status?.didScreenTransitionEnd, ref]); + }, [isInsideDialog, status?.didScreenTransitionEnd, node]); }; export default useScreenInitialFocus; diff --git a/src/hooks/useScreenInitialFocus/types.ts b/src/hooks/useScreenInitialFocus/types.ts index d295fd4ea70e..4b8cb95c1591 100644 --- a/src/hooks/useScreenInitialFocus/types.ts +++ b/src/hooks/useScreenInitialFocus/types.ts @@ -1,5 +1,3 @@ -import type {RefObject} from 'react'; - -type UseScreenInitialFocus = (ref: RefObject) => void; +type UseScreenInitialFocus = (node: HTMLElement | null) => void; export default UseScreenInitialFocus; diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx index 9e17707a236c..99b0e72b7293 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx @@ -408,7 +408,7 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM > ({ })); /* eslint-disable import/extensions */ -const {default: useScreenInitialFocus} = require<{default: (ref: React.RefObject) => void}>('../../src/hooks/useScreenInitialFocus/index.ts'); +const {default: useScreenInitialFocus} = require<{default: (node: HTMLElement | null) => void}>('../../src/hooks/useScreenInitialFocus/index.ts'); const {resetCycle: resetArbiter, tryClaim: arbiterClaim, Priorities: arbiterPriorities} = require<{ resetCycle: () => void; tryClaim: (priority: 1 | 2 | 3) => boolean; @@ -41,21 +41,7 @@ function MountedHarness({target, didScreenTransitionEnd}: HarnessProps) { ); } function Inner({target}: {target: HTMLElement | null}) { - const ref = useRef(target); - useScreenInitialFocus(ref); - return null; -} - -function MountedHarnessWithRefObject({refObject}: {refObject: React.RefObject}) { - const contextValue = useMemo(() => ({didScreenTransitionEnd: true, isSafeAreaTopPaddingApplied: false, isSafeAreaBottomPaddingApplied: false}), []); - return ( - - - - ); -} -function InnerWithRefObject({refObject}: {refObject: React.RefObject}) { - useScreenInitialFocus(refObject); + useScreenInitialFocus(target); return null; } @@ -212,20 +198,24 @@ describe('useScreenInitialFocus', () => { expect(spy).not.toHaveBeenCalled(); }); - it('retries focus on the next frame when the target ref is not yet attached at transition end', async () => { + it('claims focus when the target attaches after transition end (skeleton → real header, Suspense, conditional render)', () => { simulateTab(); - const refObject: React.RefObject = {current: null}; const button = makeButton(); const spy = jest.spyOn(button, 'focus'); - render(); + const {rerender} = render( + , + ); expect(spy).not.toHaveBeenCalled(); - refObject.current = button; - await act(async () => { - await new Promise((resolve) => { - requestAnimationFrame(() => resolve()); - }); - }); + rerender( + , + ); expect(spy).toHaveBeenCalledWith({preventScroll: true, focusVisible: true}); }); From e3f2a8dcf22b2250c3e3a74edd51efb737a42e53 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Wed, 17 Jun 2026 12:37:18 +0300 Subject: [PATCH 55/89] fix: pause parent focus-trap during launcher restore to block checkFocusIn yank --- src/libs/restoreFocusWithModality.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/libs/restoreFocusWithModality.ts b/src/libs/restoreFocusWithModality.ts index 01865020cd12..33c9e3bd7d34 100644 --- a/src/libs/restoreFocusWithModality.ts +++ b/src/libs/restoreFocusWithModality.ts @@ -1,8 +1,15 @@ +import sharedTrapStack from '@components/FocusTrap/sharedTrapStack'; import getHadTabNavigation from './hadTabNavigation'; -/** Restores focus with the keyboard ring matched to the current input modality: visible for keyboard (WCAG 2.4.7), suppressed for touch. */ +/** Pauses the topmost focus-trap during the focus call so its `checkFocusIn` doesn't yank focus back — focus-trap auto-unpauses the parent on deactivate. */ function restoreFocusWithModality(el: HTMLElement): void { - el.focus({preventScroll: true, focusVisible: getHadTabNavigation()}); + const parentTrap = sharedTrapStack.at(-1); + parentTrap?.pause(); + try { + el.focus({preventScroll: true, focusVisible: getHadTabNavigation()}); + } finally { + parentTrap?.unpause(); + } } export default restoreFocusWithModality; From f38900cf761f51ba322405c72f22d3d0fec136ac Mon Sep 17 00:00:00 2001 From: TaduJR Date: Wed, 17 Jun 2026 15:53:08 +0300 Subject: [PATCH 56/89] refactor: tighten contracts and clean up the subsystem --- .../FocusTrap/FocusTrapForModal/index.web.tsx | 4 +- src/components/HeaderWithBackButton/index.tsx | 2 +- src/hooks/useAccessibilityFocus/index.ts | 12 +- src/hooks/useInitialFocusRef/index.ts | 5 +- src/hooks/useScreenInitialFocus/index.ts | 7 +- src/hooks/useScreenInitialFocus/types.ts | 8 +- src/libs/LauncherStack.ts | 4 +- src/libs/Navigation/Navigation.ts | 4 +- .../NavigationFocusReturn/index.native.ts | 8 +- src/libs/NavigationFocusReturn/index.ts | 7 +- tests/unit/FocusTrapForModalTest.tsx | 12 +- tests/unit/LauncherStackTest.ts | 28 ++--- tests/unit/NavigationFocusReturnTest.ts | 17 ++- tests/unit/stableKeyContractTest.tsx | 92 ++++++++++++++ tests/unit/useAccessibilityFocusTest.tsx | 115 ++++++++++++++++++ tests/unit/useScreenInitialFocusTest.tsx | 29 ++++- 16 files changed, 296 insertions(+), 58 deletions(-) create mode 100644 tests/unit/stableKeyContractTest.tsx create mode 100644 tests/unit/useAccessibilityFocusTest.tsx diff --git a/src/components/FocusTrap/FocusTrapForModal/index.web.tsx b/src/components/FocusTrap/FocusTrapForModal/index.web.tsx index 758632ec7d65..516b0fffb7fe 100644 --- a/src/components/FocusTrap/FocusTrapForModal/index.web.tsx +++ b/src/components/FocusTrap/FocusTrapForModal/index.web.tsx @@ -2,7 +2,7 @@ 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 type FocusTrapForModalProps from './FocusTrapForModalProps'; @@ -33,7 +33,7 @@ function FocusTrapForModal({children, active, initialFocus = false, shouldPreven restoreFocusWithModality(launcher); } // Deferred so popover paths that navigate after modal-hide can still consume. - scheduleClearActivePopoverLauncher(launcher); + markActivePopoverLauncherDeactivated(launcher); }, preventScroll: shouldPreventScroll, trapStack: sharedTrapStack, diff --git a/src/components/HeaderWithBackButton/index.tsx b/src/components/HeaderWithBackButton/index.tsx index f86161938871..f5be9ecac637 100755 --- a/src/components/HeaderWithBackButton/index.tsx +++ b/src/components/HeaderWithBackButton/index.tsx @@ -91,7 +91,7 @@ function HeaderWithBackButton({ const [isDownloadButtonActive, temporarilyDisableDownloadButton] = useThrottledButtonState(); const {translate} = useLocalize(); const isInLandscapeMode = useIsInLandscapeMode(); - const setBackButtonRef = useInitialFocusRef(); + const setBackButtonRef = useInitialFocusRef({skip: shouldSkipFocusAfterTransition}); const downloadReasonAttributes = useMemo( () => ({ diff --git a/src/hooks/useAccessibilityFocus/index.ts b/src/hooks/useAccessibilityFocus/index.ts index 182a1db70675..deec3a4b859b 100644 --- a/src/hooks/useAccessibilityFocus/index.ts +++ b/src/hooks/useAccessibilityFocus/index.ts @@ -1,10 +1,10 @@ import {useEffect} from 'react'; +import FOCUSABLE_SELECTOR from '@libs/focusableSelector'; import isHTMLElement from '@libs/isHTMLElement'; import markProgrammaticFocus from '@libs/programmaticFocus'; +import {Priorities, resetCycle, tryClaim} from '@libs/ScreenFocusArbiter'; import type UseAccessibilityFocus from './type'; -const FOCUSABLE_ELEMENTS_SELECTOR = 'button, [href], [role="button"], [role="link"], [tabindex]:not([tabindex="-1"])'; - const useAccessibilityFocus: UseAccessibilityFocus = ({didScreenTransitionEnd, isFocused, ref, shouldMoveAccessibilityFocus}) => { useEffect(() => { if (!shouldMoveAccessibilityFocus || !didScreenTransitionEnd || !isFocused) { @@ -25,7 +25,11 @@ const useAccessibilityFocus: UseAccessibilityFocus = ({didScreenTransitionEnd, i return; } - const focusTargets = element.querySelectorAll(FOCUSABLE_ELEMENTS_SELECTOR); + if (!tryClaim(Priorities.AUTO)) { + return; + } + + 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') { @@ -46,6 +50,8 @@ const useAccessibilityFocus: UseAccessibilityFocus = ({didScreenTransitionEnd, i unmarkProgrammaticFocus(); } + // No target accepted focus — release so a later claim isn't blocked. + resetCycle(); }, [didScreenTransitionEnd, isFocused, ref, shouldMoveAccessibilityFocus]); }; diff --git a/src/hooks/useInitialFocusRef/index.ts b/src/hooks/useInitialFocusRef/index.ts index a725faf2b0d2..8d8bec09878c 100644 --- a/src/hooks/useInitialFocusRef/index.ts +++ b/src/hooks/useInitialFocusRef/index.ts @@ -1,11 +1,12 @@ import {useState} from 'react'; import useScreenInitialFocus from '@hooks/useScreenInitialFocus'; +import type {UseScreenInitialFocusOptions} from '@hooks/useScreenInitialFocus/types'; import isHTMLElement from '@libs/isHTMLElement'; /** Returns a ref-callback to attach to the element that should claim focus when its screen mounts (e.g. a back button on a screen header). Late attachment re-triggers the claim. */ -function useInitialFocusRef(): (node: unknown) => void { +function useInitialFocusRef(options?: UseScreenInitialFocusOptions): (node: unknown) => void { const [node, setNode] = useState(null); - useScreenInitialFocus(node); + useScreenInitialFocus(node, options); return (newNode: unknown) => { const next = isHTMLElement(newNode) ? newNode : null; setNode((prev) => (prev === next ? prev : next)); diff --git a/src/hooks/useScreenInitialFocus/index.ts b/src/hooks/useScreenInitialFocus/index.ts index 3b7685119080..fa73683dcb5e 100644 --- a/src/hooks/useScreenInitialFocus/index.ts +++ b/src/hooks/useScreenInitialFocus/index.ts @@ -30,13 +30,14 @@ const MAX_INITIAL_FOCUS_FRAMES = 5; * Takes the attached node (not a ref) so late attachment — skeleton → real header, Suspense — re-runs the effect. * Hover-capable devices gate on Tab (WCAG 2.4.7); touch-primary devices bypass. */ -const useScreenInitialFocus: UseScreenInitialFocus = (node) => { +const useScreenInitialFocus: UseScreenInitialFocus = (node, options) => { const status = useContext(ScreenWrapperStatusContext); const {isInsideDialog} = useDialogLabelData(); const claimedRef = useRef(false); + const skip = options?.skip ?? false; useEffect(() => { - if (isInsideDialog) { + if (skip || isInsideDialog) { return; } if (!status?.didScreenTransitionEnd) { @@ -72,7 +73,7 @@ const useScreenInitialFocus: UseScreenInitialFocus = (node) => { } cancelAnimationFrame(rafId); }; - }, [isInsideDialog, status?.didScreenTransitionEnd, node]); + }, [skip, isInsideDialog, status?.didScreenTransitionEnd, node]); }; export default useScreenInitialFocus; diff --git a/src/hooks/useScreenInitialFocus/types.ts b/src/hooks/useScreenInitialFocus/types.ts index 4b8cb95c1591..a8d7b06e5b18 100644 --- a/src/hooks/useScreenInitialFocus/types.ts +++ b/src/hooks/useScreenInitialFocus/types.ts @@ -1,3 +1,9 @@ -type UseScreenInitialFocus = (node: HTMLElement | null) => void; +type UseScreenInitialFocusOptions = { + /** Opts the screen out of post-transition initial focus (e.g. when Enter must trigger a form submit, not the Back button). */ + skip?: boolean; +}; + +type UseScreenInitialFocus = (node: HTMLElement | null, options?: UseScreenInitialFocusOptions) => void; export default UseScreenInitialFocus; +export type {UseScreenInitialFocusOptions}; diff --git a/src/libs/LauncherStack.ts b/src/libs/LauncherStack.ts index 963cfafb0b0b..b040390a1ff7 100644 --- a/src/libs/LauncherStack.ts +++ b/src/libs/LauncherStack.ts @@ -81,7 +81,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 +100,4 @@ function resetLauncherStackForTests(): void { hasWarnedAboutOverflow = false; } -export {pickLauncher, consumeLauncher, setActivePopoverLauncher, scheduleClearActivePopoverLauncher, resetLauncherStackForTests}; +export {pickLauncher, consumeLauncher, setActivePopoverLauncher, markActivePopoverLauncherDeactivated, resetLauncherStackForTests}; diff --git a/src/libs/Navigation/Navigation.ts b/src/libs/Navigation/Navigation.ts index 0eda26ab907b..2bb2d7fce4d0 100644 --- a/src/libs/Navigation/Navigation.ts +++ b/src/libs/Navigation/Navigation.ts @@ -15,7 +15,6 @@ import clearSelectedTextIfComposerBlurred from '@libs/clearSelectedTextIfCompose import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import {setupHadTabNavigation} from '@libs/hadTabNavigation'; import Log from '@libs/Log'; -import {setupNavigationFocusReturn} from '@libs/NavigationFocusReturn'; import {shallowCompare} from '@libs/ObjectUtils'; import {getSpan, startSpan} from '@libs/telemetry/activeSpans'; import variables from '@styles/variables'; @@ -63,9 +62,8 @@ type FocusedScreen = { params?: Record; }; -// Installs the modality flag (keydown/mousedown) and focus-return listeners (focusin/click); NavigationRoot.onReady attaches the state listener once live. +// Modality must be tracked from the first interaction; the focus-return listeners install under NavigationRoot's lifecycle so they can be torn down. 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/NavigationFocusReturn/index.native.ts b/src/libs/NavigationFocusReturn/index.native.ts index d6bb66290122..46b58196a0f5 100644 --- a/src/libs/NavigationFocusReturn/index.native.ts +++ b/src/libs/NavigationFocusReturn/index.native.ts @@ -5,6 +5,7 @@ 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 Log from '@libs/Log'; import navigationRef from '@libs/Navigation/navigationRef'; import TransitionTracker from '@libs/Navigation/TransitionTracker'; import {diffNavigationState} from '@libs/navigationStateDiff'; @@ -16,6 +17,8 @@ const TRIGGER_MAP_MAX = 64; const PRESS_TRIGGER_TTL_MS = 3_000; const MAX_RESTORE_FRAMES = 5; +const COLLISION_TOLERANT_IDENTIFIERS = new Set([CONST.BACK_BUTTON_NATIVE_ID]); + let lastPressedTriggerRef: RefObject | null = null; let lastPressedTriggerIdentifier: string | null = null; let lastPressedTriggerAt = 0; @@ -115,8 +118,7 @@ function restoreTriggerForRoute(routeKey: string): RefObject | null // Pressables register under the raw route key; PUSH_PARAMS restores arrive under the compound key, so strip the suffix to match. const rawRouteKey = routeKey.split(COMPOUND_KEY_DELIMITER).at(0) ?? routeKey; const liveRefs = Array.from(pressableRegistry.get(rawRouteKey)?.get(entry.identifier) ?? []).filter((candidate) => candidate.current); - // Decline on row collision (would focus wrong row); accept on back-button collision (dual-header — any is correct). - const acceptCollision = entry.identifier === CONST.BACK_BUTTON_NATIVE_ID; + const acceptCollision = COLLISION_TOLERANT_IDENTIFIERS.has(entry.identifier); const liveRef = liveRefs.length === 1 || (acceptCollision && liveRefs.length > 1) ? liveRefs.at(0) : undefined; if (liveRef) { ref = liveRef; @@ -179,6 +181,8 @@ function scheduleRestore(routeKey: string, {waitForUpcomingTransition}: {waitFor } 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; } diff --git a/src/libs/NavigationFocusReturn/index.ts b/src/libs/NavigationFocusReturn/index.ts index 1969725937d7..656829fbc67f 100644 --- a/src/libs/NavigationFocusReturn/index.ts +++ b/src/libs/NavigationFocusReturn/index.ts @@ -6,9 +6,10 @@ import FOCUSABLE_SELECTOR from '@libs/focusableSelector'; import hasFocusableAttributes from '@libs/focusGuards'; import getHadTabNavigation from '@libs/hadTabNavigation'; 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 {collectRouteKeys, diffNavigationState} from '@libs/navigationStateDiff'; +import {diffNavigationState} from '@libs/navigationStateDiff'; import restoreFocusWithModality from '@libs/restoreFocusWithModality'; import {isCycleIdle, Priorities, resetCycle, tryClaim} from '@libs/ScreenFocusArbiter'; @@ -319,6 +320,7 @@ function scheduleRestore(routeKey: string, {waitForUpcomingTransition}: {waitFor } framesLeft -= 1; if (framesLeft <= 0) { + Log.warn('[NavigationFocusReturn] restore budget exhausted', {routeKey, frames: MAX_RESTORE_FRAMES}); triggerMap.delete(routeKey); pendingRestore = null; return; @@ -485,8 +487,6 @@ export { setupNavigationFocusReturn, teardownNavigationFocusReturn, handleStateChange, - diffNavigationState, - collectRouteKeys, captureTriggerForRoute, restoreTriggerForRoute, notifyPushParamsForward, @@ -496,7 +496,6 @@ export { notifyPressedTrigger, registerPressable, isFocusRestoreInProgress, - compoundParamsKey, shouldSkipAutoFocusDueToExistingFocus, resetForTests, setLastInteractiveElementForTests, diff --git a/tests/unit/FocusTrapForModalTest.tsx b/tests/unit/FocusTrapForModalTest.tsx index e37ab4ef5611..cc9112637227 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; @@ -36,7 +36,7 @@ describe('FocusTrapForModal — launcher capture', () => { beforeEach(() => { capturedOptions = null; (setActivePopoverLauncher as jest.Mock).mockClear(); - (scheduleClearActivePopoverLauncher as jest.Mock).mockClear(); + (markActivePopoverLauncherDeactivated as jest.Mock).mockClear(); document.body.innerHTML = ''; }); @@ -52,7 +52,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 +75,7 @@ describe('FocusTrapForModal — launcher capture', () => { }); expect(setActivePopoverLauncher).toHaveBeenCalledWith(launcher); - expect(scheduleClearActivePopoverLauncher).toHaveBeenCalled(); + expect(markActivePopoverLauncherDeactivated).toHaveBeenCalled(); }); it('skips launcher capture when activeElement is document.body (nothing to capture)', () => { @@ -87,6 +87,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/NavigationFocusReturnTest.ts b/tests/unit/NavigationFocusReturnTest.ts index bc94046dd862..34207f1004a2 100644 --- a/tests/unit/NavigationFocusReturnTest.ts +++ b/tests/unit/NavigationFocusReturnTest.ts @@ -31,8 +31,6 @@ const {resetForTests: resetHadTabNavigation, setupHadTabNavigation} = require<{ setupHadTabNavigation: () => void; }>('../../src/libs/hadTabNavigation.ts'); const { - diffNavigationState, - collectRouteKeys, captureTriggerForRoute, restoreTriggerForRoute, handleStateChange, @@ -44,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; @@ -62,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/index.ts'); -const {setActivePopoverLauncher, scheduleClearActivePopoverLauncher} = require<{ +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; @@ -466,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(); 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..50cb8b6a68bc --- /dev/null +++ b/tests/unit/useAccessibilityFocusTest.tsx @@ -0,0 +1,115 @@ +import {render} from '@testing-library/react-native'; +import React, {useRef} from 'react'; + +/* 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(); +}); + +describe('useAccessibilityFocus — arbiter integration', () => { + it('claims AUTO before focusing so an in-flight RETURN can preempt and a later INITIAL is vetoed', () => { + const {container, button} = makeContainer(); + const spy = jest.spyOn(button, 'focus'); + render( + , + ); + expect(spy).toHaveBeenCalled(); + expect(tryClaim(Priorities.INITIAL)).toBe(false); + }); + + 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); + }); +}); diff --git a/tests/unit/useScreenInitialFocusTest.tsx b/tests/unit/useScreenInitialFocusTest.tsx index 6ca21e1b162e..65cfa4581e31 100644 --- a/tests/unit/useScreenInitialFocusTest.tsx +++ b/tests/unit/useScreenInitialFocusTest.tsx @@ -9,7 +9,7 @@ jest.mock('@libs/DeviceCapabilities/hasHoverSupport', () => ({ })); /* eslint-disable import/extensions */ -const {default: useScreenInitialFocus} = require<{default: (node: HTMLElement | null) => void}>('../../src/hooks/useScreenInitialFocus/index.ts'); +const {default: useScreenInitialFocus} = require<{default: (node: HTMLElement | null, options?: {skip?: boolean}) => void}>('../../src/hooks/useScreenInitialFocus/index.ts'); const {resetCycle: resetArbiter, tryClaim: arbiterClaim, Priorities: arbiterPriorities} = require<{ resetCycle: () => void; tryClaim: (priority: 1 | 2 | 3) => boolean; @@ -30,18 +30,21 @@ function simulatePointer() { document.dispatchEvent(new Event('pointerdown', {bubbles: true})); } -type HarnessProps = {target: HTMLElement | null; didScreenTransitionEnd: boolean}; +type HarnessProps = {target: HTMLElement | null; didScreenTransitionEnd: boolean; skip?: boolean}; -function MountedHarness({target, didScreenTransitionEnd}: HarnessProps) { +function MountedHarness({target, didScreenTransitionEnd, skip}: HarnessProps) { const contextValue = useMemo(() => ({didScreenTransitionEnd, isSafeAreaTopPaddingApplied: false, isSafeAreaBottomPaddingApplied: false}), [didScreenTransitionEnd]); return ( - + ); } -function Inner({target}: {target: HTMLElement | null}) { - useScreenInitialFocus(target); +function Inner({target, skip}: {target: HTMLElement | null; skip?: boolean}) { + useScreenInitialFocus(target, skip === undefined ? undefined : {skip}); return null; } @@ -238,4 +241,18 @@ describe('useScreenInitialFocus', () => { ); expect(spy).toHaveBeenCalledTimes(1); }); + + it('bails when skip=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(); + }); }); From cd3ba1466f56523207d78cb564da3464d91400f9 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Wed, 17 Jun 2026 20:11:02 +0300 Subject: [PATCH 57/89] chore: a11y label, helper extractions, constants --- src/components/DialogLabelContext.tsx | 12 +++- .../implementation/BaseGenericPressable.tsx | 6 +- src/hooks/useInitialFocusRef/index.ts | 2 +- src/hooks/useNavigateBackOnSave/index.ts | 5 +- src/hooks/useRouteKey.ts | 9 +++ src/hooks/useScreenInitialFocus/index.ts | 3 +- src/hooks/useScreenInitialFocus/types.ts | 2 +- src/libs/LauncherStack.ts | 5 +- src/libs/Navigation/Navigation.ts | 2 +- .../NavigationFocusReturn/index.native.ts | 5 +- src/libs/NavigationFocusReturn/index.ts | 70 +++++++------------ src/libs/focusReturnTimings.ts | 25 +++++++ src/libs/isEffectivelyVisible.ts | 15 ++++ src/libs/restoreFocusWithModality.ts | 5 +- 14 files changed, 98 insertions(+), 68 deletions(-) create mode 100644 src/hooks/useRouteKey.ts create mode 100644 src/libs/focusReturnTimings.ts create mode 100644 src/libs/isEffectivelyVisible.ts diff --git a/src/components/DialogLabelContext.tsx b/src/components/DialogLabelContext.tsx index 5ada08ee786f..c462ae6fbfc3 100644 --- a/src/components/DialogLabelContext.tsx +++ b/src/components/DialogLabelContext.tsx @@ -1,5 +1,6 @@ import React, {createContext, useContext, useRef} from 'react'; import type {View} from 'react-native'; +import isHTMLElement from '@libs/isHTMLElement'; type LabelEntry = {id: number; text: string}; @@ -30,6 +31,7 @@ type DialogLabelProviderProps = { containerRef: React.RefObject; }; +// 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, containerRef}: DialogLabelProviderProps) { const nextIdRef = useRef(0); const labelStackRef = useRef([]); @@ -37,8 +39,14 @@ function DialogLabelProvider({children, containerRef}: DialogLabelProviderProps) const updateContainerLabel = () => { 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 roleless element is ignored by screen readers; skip the set on mobile (where the RHP container 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) { diff --git a/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx b/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx index 07976a69eb91..da250e6c6671 100644 --- a/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx +++ b/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx @@ -1,11 +1,11 @@ -import {NavigationRouteContext} from '@react-navigation/native'; -import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import type {GestureResponderEvent, View} from 'react-native'; // eslint-disable-next-line no-restricted-imports import {Pressable} from 'react-native'; import type {ValueOf} from 'type-fest'; import type PressableProps from '@components/Pressable/GenericPressable/types'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; +import useRouteKey from '@hooks/useRouteKey'; import useSingleExecution from '@hooks/useSingleExecution'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -54,7 +54,7 @@ function GenericPressable({ const isRoleButton = [rest.accessibilityRole, rest.role].includes(CONST.ROLE.BUTTON); const internalRef = useRef(null); const composedRef = useMemo(() => mergeRefs(ref, internalRef), [ref]); - const routeKey = useContext(NavigationRouteContext)?.key ?? null; + const routeKey = useRouteKey(); // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- `||` falls empty-string ids through to the next identity prop; the registry rescue must key off a stable identity prop, never the (often value-derived) accessibility label. const focusIdentifier = rest.id || rest.nativeID || rest.testID || undefined; diff --git a/src/hooks/useInitialFocusRef/index.ts b/src/hooks/useInitialFocusRef/index.ts index 8d8bec09878c..95b64da0100a 100644 --- a/src/hooks/useInitialFocusRef/index.ts +++ b/src/hooks/useInitialFocusRef/index.ts @@ -3,7 +3,7 @@ import useScreenInitialFocus from '@hooks/useScreenInitialFocus'; import type {UseScreenInitialFocusOptions} from '@hooks/useScreenInitialFocus/types'; import isHTMLElement from '@libs/isHTMLElement'; -/** Returns a ref-callback to attach to the element that should claim focus when its screen mounts (e.g. a back button on a screen header). Late attachment re-triggers the claim. */ +/** 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); diff --git a/src/hooks/useNavigateBackOnSave/index.ts b/src/hooks/useNavigateBackOnSave/index.ts index a022a9186633..074a97a10c80 100644 --- a/src/hooks/useNavigateBackOnSave/index.ts +++ b/src/hooks/useNavigateBackOnSave/index.ts @@ -4,9 +4,8 @@ import {skipNextFocusRestore} from '@libs/NavigationFocusReturn'; import type {Route} from '@src/ROUTES'; /** - * Save-and-close flow for IOU step forms: `armNavigateBack()` navigates back once `isSaved` flips. Pass - * `shouldSkipFocusRestore` true only when the destination has a submit Enter a re-focused row would hijack (create flow); - * editing an existing expense passes false so focus returns. `navigateBack` (the Back button) always restores. + * `navigateBack` — direct goBack(), focus restores. `armNavigateBack` — arms the next `isSaved` transition to dispatch goBack once; + * does not navigate immediately. Pass `shouldSkipFocusRestore: true` only when the destination has a submit Enter a re-focused row would hijack. */ function useNavigateBackOnSave( isSaved: boolean, 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.ts b/src/hooks/useScreenInitialFocus/index.ts index fa73683dcb5e..a96a5c40491b 100644 --- a/src/hooks/useScreenInitialFocus/index.ts +++ b/src/hooks/useScreenInitialFocus/index.ts @@ -3,6 +3,7 @@ import {useDialogLabelData} from '@components/DialogLabelContext'; import ScreenWrapperStatusContext from '@components/ScreenWrapper/ScreenWrapperStatusContext'; 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'; @@ -23,8 +24,6 @@ function isOnScreen(el: HTMLElement): boolean { return true; } -const MAX_INITIAL_FOCUS_FRAMES = 5; - /* * Mobile-web counterpart to `useDialogContainerFocus` (RHP-only): focuses `node` once after `didScreenTransitionEnd`. * Takes the attached node (not a ref) so late attachment — skeleton → real header, Suspense — re-runs the effect. diff --git a/src/hooks/useScreenInitialFocus/types.ts b/src/hooks/useScreenInitialFocus/types.ts index a8d7b06e5b18..c7ddcaeaccbe 100644 --- a/src/hooks/useScreenInitialFocus/types.ts +++ b/src/hooks/useScreenInitialFocus/types.ts @@ -1,5 +1,5 @@ type UseScreenInitialFocusOptions = { - /** Opts the screen out of post-transition initial focus (e.g. when Enter must trigger a form submit, not the Back button). */ + /** Opts the screen out of post-transition initial focus. */ skip?: boolean; }; diff --git a/src/libs/LauncherStack.ts b/src/libs/LauncherStack.ts index b040390a1ff7..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; diff --git a/src/libs/Navigation/Navigation.ts b/src/libs/Navigation/Navigation.ts index 2bb2d7fce4d0..9dfe0d819c56 100644 --- a/src/libs/Navigation/Navigation.ts +++ b/src/libs/Navigation/Navigation.ts @@ -62,7 +62,7 @@ type FocusedScreen = { params?: Record; }; -// Modality must be tracked from the first interaction; the focus-return listeners install under NavigationRoot's lifecycle so they can be torn down. +// Modality is module-load (must catch the first interaction); focus-return runs under NavigationRoot (needs navigationRef + a teardown point). setupHadTabNavigation(); // Screens which are part of the 2FA setup flow - used to determine when to hide the RequireTwoFactorAuthOverlay diff --git a/src/libs/NavigationFocusReturn/index.native.ts b/src/libs/NavigationFocusReturn/index.native.ts index 46b58196a0f5..d40b65acd258 100644 --- a/src/libs/NavigationFocusReturn/index.native.ts +++ b/src/libs/NavigationFocusReturn/index.native.ts @@ -5,6 +5,7 @@ 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'; @@ -13,10 +14,6 @@ import CONST from '@src/CONST'; type TriggerEntry = {ref: RefObject; identifier?: string}; -const TRIGGER_MAP_MAX = 64; -const PRESS_TRIGGER_TTL_MS = 3_000; -const MAX_RESTORE_FRAMES = 5; - const COLLISION_TOLERANT_IDENTIFIERS = new Set([CONST.BACK_BUTTON_NATIVE_ID]); let lastPressedTriggerRef: RefObject | null = null; diff --git a/src/libs/NavigationFocusReturn/index.ts b/src/libs/NavigationFocusReturn/index.ts index 656829fbc67f..6ab70b789e72 100644 --- a/src/libs/NavigationFocusReturn/index.ts +++ b/src/libs/NavigationFocusReturn/index.ts @@ -4,7 +4,9 @@ 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'; @@ -18,10 +20,6 @@ import {isCycleIdle, Priorities, resetCycle, tryClaim} from '@libs/ScreenFocusAr // 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; -// 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(); const MOUSE_ACTIVATION_EVENTS = ['pointerdown', 'mousedown', 'click'] as const; @@ -84,14 +82,19 @@ 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)); - lastInteractiveElement = null; - lastMouseTrigger = null; - lastMouseTriggerAt = 0; + clearTransientCaptures(); } function notifyPushParamsBackward(routeKey: string, targetParams: unknown): void { @@ -127,26 +130,19 @@ function isFocusRestoreInProgress(): boolean { return isRestoringFocus; } -type RestorePick = {target: HTMLElement; source: 'primary' | 'fallback'}; - -/* - * null = nothing focusable yet (mounted but not focusable, or detached mid-remount). Detached is NOT "gone": the caller keeps - * the entry so scheduleRestore's budget can recover a remount — only that budget deletes (on success/exhaustion). - */ -function pickRestoreTarget(entry: TriggerEntry): RestorePick | null { - const {primary, fallback} = entry; - - if (document.contains(primary) && hasFocusableAttributes(primary)) { - return {target: primary, source: 'primary'}; +/* 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 (fallback && document.contains(fallback) && hasFocusableAttributes(fallback)) { - return {target: fallback, source: 'fallback'}; + if (entry.fallback && document.contains(entry.fallback) && hasFocusableAttributes(entry.fallback)) { + candidates.push(entry.fallback); } - return null; + 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; @@ -162,16 +158,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; } @@ -219,8 +207,8 @@ function restoreTriggerForRoute(routeKey: string, restoreBaseline: Element | nul return false; } - const pick = pickRestoreTarget(entry); - if (!pick) { + const candidates = pickRestoreCandidates(entry); + if (candidates.length === 0) { return false; } @@ -237,11 +225,6 @@ function restoreTriggerForRoute(routeKey: string, restoreBaseline: Element | nul } // 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); - } - for (const candidate of candidates) { const before = document.activeElement; isRestoringFocus = true; @@ -283,8 +266,6 @@ function applySkippedRestore(restoreKey: string): void { triggerMap.delete(restoreKey); } -const MAX_RESTORE_FRAMES = 5; - function scheduleRestore(routeKey: string, {waitForUpcomingTransition}: {waitForUpcomingTransition: boolean}): 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; @@ -349,10 +330,7 @@ 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) { applySkippedRestore(action.restoreKey); 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/restoreFocusWithModality.ts b/src/libs/restoreFocusWithModality.ts index 33c9e3bd7d34..641fc5dae9fe 100644 --- a/src/libs/restoreFocusWithModality.ts +++ b/src/libs/restoreFocusWithModality.ts @@ -1,7 +1,10 @@ import sharedTrapStack from '@components/FocusTrap/sharedTrapStack'; import getHadTabNavigation from './hadTabNavigation'; -/** Pauses the topmost focus-trap during the focus call so its `checkFocusIn` doesn't yank focus back — focus-trap auto-unpauses the parent on deactivate. */ +/** + * 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. + */ function restoreFocusWithModality(el: HTMLElement): void { const parentTrap = sharedTrapStack.at(-1); parentTrap?.pause(); From 2d6236cc3eb4174d05c3f86b90cc63b18587464d Mon Sep 17 00:00:00 2001 From: TaduJR Date: Wed, 17 Jun 2026 20:23:02 +0300 Subject: [PATCH 58/89] test: pin aria-label dialog-semantics contract --- src/components/DialogLabelContext.tsx | 2 +- tests/unit/DialogLabelContextTest.tsx | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/components/DialogLabelContext.tsx b/src/components/DialogLabelContext.tsx index c462ae6fbfc3..3a47b57b9141 100644 --- a/src/components/DialogLabelContext.tsx +++ b/src/components/DialogLabelContext.tsx @@ -43,7 +43,7 @@ function DialogLabelProvider({children, containerRef}: DialogLabelProviderProps) if (!isHTMLElement(node)) { return; } - // aria-label on a roleless element is ignored by screen readers; skip the set on mobile (where the RHP container has no dialog role). + // aria-label on a container without dialog semantics is ignored by screen readers; 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'); diff --git a/tests/unit/DialogLabelContextTest.tsx b/tests/unit/DialogLabelContextTest.tsx index bfb45f423ded..02b1d8a28456 100644 --- a/tests/unit/DialogLabelContextTest.tsx +++ b/tests/unit/DialogLabelContextTest.tsx @@ -35,6 +35,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 +45,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}); @@ -58,6 +71,7 @@ describe('DialogLabelContext', () => { 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 +102,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; From 514de4116ff9b20e31b1943fa823113a883b2228 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Sun, 21 Jun 2026 22:33:44 +0300 Subject: [PATCH 59/89] fix: bail native scheduleRestore early; preserve parent-trap pause state --- .../NavigationFocusReturn/index.native.ts | 4 + src/libs/restoreFocusWithModality.ts | 12 +- tests/unit/NavigationFocusReturnNativeTest.ts | 32 ++++++ tests/unit/restoreFocusWithModalityTest.ts | 106 ++++++++++++++++++ 4 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 tests/unit/restoreFocusWithModalityTest.ts diff --git a/src/libs/NavigationFocusReturn/index.native.ts b/src/libs/NavigationFocusReturn/index.native.ts index d40b65acd258..99fd44f5256f 100644 --- a/src/libs/NavigationFocusReturn/index.native.ts +++ b/src/libs/NavigationFocusReturn/index.native.ts @@ -142,6 +142,10 @@ function applySkippedRestore(restoreKey: string): void { } function scheduleRestore(routeKey: string, {waitForUpcomingTransition}: {waitForUpcomingTransition: boolean}): void { + // Capture is gated on the screen reader; non-SR users would otherwise pay a transition wait + the rAF retry loop + a warn on every back nav. + if (!triggerMap.has(routeKey)) { + return; + } cancelPendingRestore(); let cancelled = false; let refocusHandle: {cancel: () => void} | null = null; diff --git a/src/libs/restoreFocusWithModality.ts b/src/libs/restoreFocusWithModality.ts index 641fc5dae9fe..3de6bddb04b1 100644 --- a/src/libs/restoreFocusWithModality.ts +++ b/src/libs/restoreFocusWithModality.ts @@ -3,15 +3,21 @@ import getHadTabNavigation from './hadTabNavigation'; /** * 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. + * `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): void { const parentTrap = sharedTrapStack.at(-1); - parentTrap?.pause(); + const wasAlreadyPaused = parentTrap?.paused ?? false; + if (parentTrap && !wasAlreadyPaused) { + parentTrap.pause(); + } try { el.focus({preventScroll: true, focusVisible: getHadTabNavigation()}); } finally { - parentTrap?.unpause(); + if (parentTrap && !wasAlreadyPaused) { + parentTrap.unpause(); + } } } diff --git a/tests/unit/NavigationFocusReturnNativeTest.ts b/tests/unit/NavigationFocusReturnNativeTest.ts index 5e81eda24e3c..6cc2958f95e5 100644 --- a/tests/unit/NavigationFocusReturnNativeTest.ts +++ b/tests/unit/NavigationFocusReturnNativeTest.ts @@ -13,8 +13,22 @@ type NavState = { const mockFireFocusEvent = jest.fn(); const mockSendAccessibilityEvent = jest.fn(); +const mockLogWarn = jest.fn(); let mockScreenReaderEnabled = 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: { @@ -143,6 +157,7 @@ beforeEach(() => { jest.useFakeTimers(); mockSendAccessibilityEvent.mockClear(); mockFireFocusEvent.mockClear(); + mockLogWarn.mockClear(); mockScreenReaderEnabled = true; mockStateListeners = []; mockNavigationRefState = undefined; @@ -366,6 +381,23 @@ describe('handleStateChange — backward', () => { expect(getTriggerMapSizeForTests()).toBe(0); }); + it('back navigation without a captured trigger is a no-op — no rAF/transition wait, no budget-exhausted warn', () => { + mockScreenReaderEnabled = false; + 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(mockTtQueue).toHaveLength(0); + expect(mockLogWarn).not.toHaveBeenCalled(); + expect(mockFireFocusEvent).not.toHaveBeenCalled(); + }); + 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( diff --git a/tests/unit/restoreFocusWithModalityTest.ts b/tests/unit/restoreFocusWithModalityTest.ts new file mode 100644 index 000000000000..f0ee021f2a44 --- /dev/null +++ b/tests/unit/restoreFocusWithModalityTest.ts @@ -0,0 +1,106 @@ +type MockTrap = {paused: boolean; pause: jest.Mock; unpause: jest.Mock}; + +let mockHadTabNavigation = true; + +jest.mock('@libs/hadTabNavigation', () => ({ + __esModule: true, + default: () => mockHadTabNavigation, +})); + +jest.mock('@components/FocusTrap/sharedTrapStack', () => ({ + __esModule: true, + default: [], +})); + +const mockTrapStack = require<{default: MockTrap[]}>('@components/FocusTrap/sharedTrapStack').default; +const restoreFocusWithModality = require<{default: (el: HTMLElement) => 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('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); + }); +}); From 8a20de7cc5f8dd3d6410535acdf9eb97db0a240d Mon Sep 17 00:00:00 2001 From: TaduJR Date: Sun, 21 Jun 2026 23:01:45 +0300 Subject: [PATCH 60/89] fix: re-warm SR/RM caches on app foreground to recover from missed change events --- src/libs/Accessibility/index.ts | 81 ++++++++++--- .../unit/libs/Accessibility/warmCacheTest.ts | 108 ++++++++++++++++++ 2 files changed, 175 insertions(+), 14 deletions(-) create mode 100644 tests/unit/libs/Accessibility/warmCacheTest.ts diff --git a/src/libs/Accessibility/index.ts b/src/libs/Accessibility/index.ts index 3331100b477b..c825395c74c5 100644 --- a/src/libs/Accessibility/index.ts +++ b/src/libs/Accessibility/index.ts @@ -1,6 +1,6 @@ import {useCallback, useState, useSyncExternalStore} from 'react'; -import type {LayoutChangeEvent} from 'react-native'; -import {AccessibilityInfo} from 'react-native'; +import type {AppStateStatus, LayoutChangeEvent} from 'react-native'; +import {AccessibilityInfo, AppState} from 'react-native'; import Log from '@libs/Log'; import isScreenReaderEnabled from './isScreenReaderEnabled'; import moveAccessibilityFocus from './moveAccessibilityFocus'; @@ -10,32 +10,44 @@ 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} { +function makeWarmCache(label: string, fetch: () => Promise, apply: (value: T) => void): {ensure: () => Promise; reset: () => void; refresh: () => Promise} { let warm: Promise | null = null; + const ensure = () => { + warm ??= fetch() + .then(apply) + .catch((error: unknown) => { + Log.warn(`[Accessibility] Failed to warm ${label} cache`, {error}); + warm = null; + }); + return warm; + }; return { - ensure: () => { - warm ??= fetch() - .then(apply) - .catch((error: unknown) => { - Log.warn(`[Accessibility] Failed to warm ${label} cache`, {error}); - warm = null; - }); - return warm; - }, + ensure, reset: () => { warm = null; }, + refresh: () => { + warm = null; + return ensure(); + }, }; } let cachedScreenReaderValue = false; -const {ensure: ensureScreenReaderWarm, reset: resetScreenReaderWarm} = makeWarmCache('screen-reader', isScreenReaderEnabled, (enabled) => { +const screenReaderSubscribers = new Set<() => void>(); +const { + ensure: ensureScreenReaderWarm, + reset: resetScreenReaderWarm, + refresh: refreshScreenReaderWarm, +} = makeWarmCache('screen-reader', isScreenReaderEnabled, (enabled) => { cachedScreenReaderValue = enabled; }); ensureScreenReaderWarm(); function subscribeScreenReader(callback: () => void) { + screenReaderSubscribers.add(callback); const subscription = AccessibilityInfo.addEventListener('screenReaderChanged', (enabled) => { cachedScreenReaderValue = enabled; callback(); @@ -49,6 +61,7 @@ function subscribeScreenReader(callback: () => void) { }); return () => { cancelled = true; + screenReaderSubscribers.delete(callback); subscription?.remove(); }; } @@ -64,7 +77,12 @@ function isScreenReaderEnabledSync(): boolean { } let cachedReduceMotionValue = false; -const {ensure: ensureReduceMotionWarm, reset: resetReduceMotionWarm} = makeWarmCache( +const reduceMotionSubscribers = new Set<() => void>(); +const { + ensure: ensureReduceMotionWarm, + reset: resetReduceMotionWarm, + refresh: refreshReduceMotionWarm, +} = makeWarmCache( 'reduce-motion', () => AccessibilityInfo.isReduceMotionEnabled(), (enabled) => { @@ -77,9 +95,12 @@ function resetForTests() { cachedReduceMotionValue = false; resetScreenReaderWarm(); resetReduceMotionWarm(); + screenReaderSubscribers.clear(); + reduceMotionSubscribers.clear(); } function subscribeReduceMotion(callback: () => void) { + reduceMotionSubscribers.add(callback); const subscription = AccessibilityInfo.addEventListener('reduceMotionChanged', (enabled) => { cachedReduceMotionValue = enabled; callback(); @@ -93,10 +114,42 @@ function subscribeReduceMotion(callback: () => void) { }); return () => { cancelled = true; + reduceMotionSubscribers.delete(callback); subscription?.remove(); }; } +/* + * `screenReaderChanged`/`reduceMotionChanged` events fired while the JS thread was suspended (or while no JS listener was attached) are not + * reliably delivered on resume, so re-warm both caches on every background→active transition and notify subscribers if the value flipped. + */ +let previousAppStateStatus: AppStateStatus = AppState.currentState ?? 'active'; +AppState.addEventListener('change', (status) => { + const wasInactive = previousAppStateStatus === 'inactive' || previousAppStateStatus === 'background'; + previousAppStateStatus = status; + if (!wasInactive || status !== 'active') { + return; + } + const prevScreenReader = cachedScreenReaderValue; + refreshScreenReaderWarm().then(() => { + if (cachedScreenReaderValue === prevScreenReader) { + return; + } + for (const cb of screenReaderSubscribers) { + cb(); + } + }); + const prevReduceMotion = cachedReduceMotionValue; + refreshReduceMotionWarm().then(() => { + if (cachedReduceMotionValue === prevReduceMotion) { + return; + } + for (const cb of reduceMotionSubscribers) { + cb(); + } + }); +}); + function getReduceMotionSnapshot() { return cachedReduceMotionValue; } diff --git a/tests/unit/libs/Accessibility/warmCacheTest.ts b/tests/unit/libs/Accessibility/warmCacheTest.ts new file mode 100644 index 000000000000..c7e59bf7693d --- /dev/null +++ b/tests/unit/libs/Accessibility/warmCacheTest.ts @@ -0,0 +1,108 @@ +type AppStateChangeListener = (status: string) => void; +type AccessibilityModule = { + default: { + isScreenReaderEnabledSync: () => boolean; + }; +}; + +const appStateListeners: AppStateChangeListener[] = []; + +let mockScreenReaderValue = false; +let mockReduceMotionValue = false; +let mockReduceMotionFetchCount = 0; + +jest.mock('@libs/Log'); +jest.mock('@libs/Accessibility/isScreenReaderEnabled', () => ({ + __esModule: true, + default: () => 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()}; + }), + currentState: 'active', + }, +})); + +beforeEach(() => { + jest.resetModules(); + appStateListeners.length = 0; + mockScreenReaderValue = false; + mockReduceMotionValue = false; + mockReduceMotionFetchCount = 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); + + // OS toggle happens while the app is backgrounded — no `screenReaderChanged` event reaches us. + mockScreenReaderValue = true; + emitAppState('background'); + 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('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); + }); +}); From 264f691e607a604f928c508b6e3c0c2311b78bf0 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Sun, 21 Jun 2026 23:53:57 +0300 Subject: [PATCH 61/89] fix: arbiter release, accessibility cache, monotonic TTL --- src/hooks/useAccessibilityFocus/index.ts | 45 ++++++++++--------- src/libs/Accessibility/index.ts | 37 +++++++-------- .../NavigationFocusReturn/index.native.ts | 9 ++-- tests/unit/NavigationFocusReturnNativeTest.ts | 7 +-- tests/unit/useAccessibilityFocusTest.tsx | 4 +- 5 files changed, 53 insertions(+), 49 deletions(-) diff --git a/src/hooks/useAccessibilityFocus/index.ts b/src/hooks/useAccessibilityFocus/index.ts index deec3a4b859b..be2d5aef6ccb 100644 --- a/src/hooks/useAccessibilityFocus/index.ts +++ b/src/hooks/useAccessibilityFocus/index.ts @@ -29,29 +29,32 @@ const useAccessibilityFocus: UseAccessibilityFocus = ({didScreenTransitionEnd, i return; } - 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; + // Release after the focus call so a same-tree sub-modal's INITIAL claim isn't blocked when no nav state change runs handleStateChange's resetCycle. + 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; + } + + if (focusTarget === activeElement) { + return; + } + + const unmarkProgrammaticFocus = markProgrammaticFocus(focusTarget); + focusTarget.focus(); + + const focusedElement = document.activeElement; + if (focusedElement === focusTarget || (focusedElement && focusTarget.contains(focusedElement))) { + return; + } + + unmarkProgrammaticFocus(); } - - if (focusTarget === activeElement) { - return; - } - - const unmarkProgrammaticFocus = markProgrammaticFocus(focusTarget); - focusTarget.focus(); - - const focusedElement = document.activeElement; - if (focusedElement === focusTarget || (focusedElement && focusTarget.contains(focusedElement))) { - return; - } - - unmarkProgrammaticFocus(); + } finally { + resetCycle(); } - // No target accepted focus — release so a later claim isn't blocked. - resetCycle(); }, [didScreenTransitionEnd, isFocused, ref, shouldMoveAccessibilityFocus]); }; diff --git a/src/libs/Accessibility/index.ts b/src/libs/Accessibility/index.ts index c825395c74c5..c44416f1a6b2 100644 --- a/src/libs/Accessibility/index.ts +++ b/src/libs/Accessibility/index.ts @@ -89,6 +89,9 @@ const { cachedReduceMotionValue = enabled; }, ); +ensureReduceMotionWarm(); + +let appStateSubscription: ReturnType | null = null; function resetForTests() { cachedScreenReaderValue = false; @@ -97,6 +100,8 @@ function resetForTests() { resetReduceMotionWarm(); screenReaderSubscribers.clear(); reduceMotionSubscribers.clear(); + appStateSubscription?.remove(); + appStateSubscription = null; } function subscribeReduceMotion(callback: () => void) { @@ -120,32 +125,28 @@ function subscribeReduceMotion(callback: () => void) { } /* - * `screenReaderChanged`/`reduceMotionChanged` events fired while the JS thread was suspended (or while no JS listener was attached) are not - * reliably delivered on resume, so re-warm both caches on every background→active transition and notify subscribers if the value flipped. + * Re-warm both caches on background→active because change events fired while the JS thread was suspended aren't reliably delivered on resume. + * Skip iOS 'inactive' transitions (Notification Center, Control Center, banners) — those don't suspend JS. */ let previousAppStateStatus: AppStateStatus = AppState.currentState ?? 'active'; -AppState.addEventListener('change', (status) => { - const wasInactive = previousAppStateStatus === 'inactive' || previousAppStateStatus === 'background'; +appStateSubscription = AppState.addEventListener('change', (status) => { + const wasBackgrounded = previousAppStateStatus === 'background'; previousAppStateStatus = status; - if (!wasInactive || status !== 'active') { + if (!wasBackgrounded || status !== 'active') { return; } const prevScreenReader = cachedScreenReaderValue; - refreshScreenReaderWarm().then(() => { - if (cachedScreenReaderValue === prevScreenReader) { - return; - } - for (const cb of screenReaderSubscribers) { - cb(); - } - }); const prevReduceMotion = cachedReduceMotionValue; - refreshReduceMotionWarm().then(() => { - if (cachedReduceMotionValue === prevReduceMotion) { - return; + Promise.all([refreshScreenReaderWarm(), refreshReduceMotionWarm()]).then(() => { + if (cachedScreenReaderValue !== prevScreenReader) { + for (const cb of screenReaderSubscribers) { + cb(); + } } - for (const cb of reduceMotionSubscribers) { - cb(); + if (cachedReduceMotionValue !== prevReduceMotion) { + for (const cb of reduceMotionSubscribers) { + cb(); + } } }); }); diff --git a/src/libs/NavigationFocusReturn/index.native.ts b/src/libs/NavigationFocusReturn/index.native.ts index 99fd44f5256f..52325afa5ee0 100644 --- a/src/libs/NavigationFocusReturn/index.native.ts +++ b/src/libs/NavigationFocusReturn/index.native.ts @@ -41,13 +41,12 @@ function setTriggerEntry(routeKey: string, entry: TriggerEntry): void { } } +// Recorded unconditionally so first-press registrations aren't dropped during the SR-cache cold-start window; captureTriggerForRoute +// still gates on SR so non-SR users do no work past this write. performance.now is monotonic — Date.now would corrupt the TTL on clock jumps. function notifyPressedTrigger(ref: RefObject | null, identifier?: string): void { - if (!Accessibility.isScreenReaderEnabledSync()) { - return; - } lastPressedTriggerRef = ref; lastPressedTriggerIdentifier = identifier ?? null; - lastPressedTriggerAt = ref ? Date.now() : 0; + 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. */ @@ -95,7 +94,7 @@ function captureTriggerForRoute(routeKey: string): void { if (!Accessibility.isScreenReaderEnabledSync()) { return; } - if (!lastPressedTriggerRef || Date.now() - lastPressedTriggerAt > PRESS_TRIGGER_TTL_MS) { + if (!lastPressedTriggerRef || performance.now() - lastPressedTriggerAt > PRESS_TRIGGER_TTL_MS) { return; } setTriggerEntry(routeKey, {ref: lastPressedTriggerRef, identifier: lastPressedTriggerIdentifier ?? undefined}); diff --git a/tests/unit/NavigationFocusReturnNativeTest.ts b/tests/unit/NavigationFocusReturnNativeTest.ts index 6cc2958f95e5..a26d0d3f3640 100644 --- a/tests/unit/NavigationFocusReturnNativeTest.ts +++ b/tests/unit/NavigationFocusReturnNativeTest.ts @@ -209,10 +209,10 @@ describe('notifyPressedTrigger', () => { }); it('drops a stale press so a much-later forward nav (deeplink, timer) does not capture an unrelated trigger', () => { - const before = Date.now(); - jest.setSystemTime(before); + const nowSpy = jest.spyOn(performance, 'now'); + nowSpy.mockReturnValue(0); notifyPressedTrigger(fakeRef(fakeView('non-nav-toggle'))); - jest.setSystemTime(before + 4_000); + nowSpy.mockReturnValue(4_000); const prev = stackState(0, [{key: 'a', name: 'A'}]); const next = stackState(1, [ {key: 'a', name: 'A'}, @@ -221,6 +221,7 @@ describe('notifyPressedTrigger', () => { handleStateChange(prev); handleStateChange(next); expect(getTriggerMapSizeForTests()).toBe(0); + nowSpy.mockRestore(); }); }); diff --git a/tests/unit/useAccessibilityFocusTest.tsx b/tests/unit/useAccessibilityFocusTest.tsx index 50cb8b6a68bc..c542703e9537 100644 --- a/tests/unit/useAccessibilityFocusTest.tsx +++ b/tests/unit/useAccessibilityFocusTest.tsx @@ -43,7 +43,7 @@ beforeEach(() => { }); describe('useAccessibilityFocus — arbiter integration', () => { - it('claims AUTO before focusing so an in-flight RETURN can preempt and a later INITIAL is vetoed', () => { + 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( @@ -54,7 +54,7 @@ describe('useAccessibilityFocus — arbiter integration', () => { />, ); expect(spy).toHaveBeenCalled(); - expect(tryClaim(Priorities.INITIAL)).toBe(false); + expect(tryClaim(Priorities.INITIAL)).toBe(true); }); it('yields to an in-flight RETURN restore', () => { From 063554b27528331b3c12ebb360cc655e7d5dd4ce Mon Sep 17 00:00:00 2001 From: TaduJR Date: Mon, 22 Jun 2026 00:14:30 +0300 Subject: [PATCH 62/89] fix: gate SR at restore time so cold-start press survives capture --- .../NavigationFocusReturn/index.native.ts | 8 ++--- tests/unit/NavigationFocusReturnNativeTest.ts | 31 ++++++++++++++++--- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/src/libs/NavigationFocusReturn/index.native.ts b/src/libs/NavigationFocusReturn/index.native.ts index 52325afa5ee0..66bfcb07dfdd 100644 --- a/src/libs/NavigationFocusReturn/index.native.ts +++ b/src/libs/NavigationFocusReturn/index.native.ts @@ -90,10 +90,8 @@ function registerPressable(routeKey: string, identifier: string, ref: RefObject< }; } +// SR gate lives at restore time, not here, so a cold-start press captured before ensureScreenReaderWarm resolves still survives. function captureTriggerForRoute(routeKey: string): void { - if (!Accessibility.isScreenReaderEnabledSync()) { - return; - } if (!lastPressedTriggerRef || performance.now() - lastPressedTriggerAt > PRESS_TRIGGER_TTL_MS) { return; } @@ -141,8 +139,8 @@ function applySkippedRestore(restoreKey: string): void { } function scheduleRestore(routeKey: string, {waitForUpcomingTransition}: {waitForUpcomingTransition: boolean}): void { - // Capture is gated on the screen reader; non-SR users would otherwise pay a transition wait + the rAF retry loop + a warn on every back nav. - if (!triggerMap.has(routeKey)) { + // Skip all expensive work for non-SR users and when nothing was captured. Capture upstream is unconditional so cold-start presses aren't dropped here. + if (!Accessibility.isScreenReaderEnabledSync() || !triggerMap.has(routeKey)) { return; } cancelPendingRestore(); diff --git a/tests/unit/NavigationFocusReturnNativeTest.ts b/tests/unit/NavigationFocusReturnNativeTest.ts index a26d0d3f3640..19813eff0dce 100644 --- a/tests/unit/NavigationFocusReturnNativeTest.ts +++ b/tests/unit/NavigationFocusReturnNativeTest.ts @@ -170,7 +170,7 @@ afterEach(() => { }); describe('notifyPressedTrigger', () => { - it('is a no-op when the screen reader is off — non-AT users pay zero capture cost', () => { + it('captures regardless of screen-reader state so a cold-start press before the SR cache warms is not dropped', () => { mockScreenReaderEnabled = false; notifyPressedTrigger(fakeRef(fakeView('button'))); const prev = stackState(0, [{key: 'a', name: 'A'}]); @@ -180,7 +180,7 @@ describe('notifyPressedTrigger', () => { ]); handleStateChange(prev); handleStateChange(next); - expect(getTriggerMapSizeForTests()).toBe(0); + expect(getTriggerMapSizeForTests()).toBe(1); }); it('stores the most recently pressed ref when the screen reader is on', () => { @@ -382,8 +382,9 @@ describe('handleStateChange — backward', () => { expect(getTriggerMapSizeForTests()).toBe(0); }); - it('back navigation without a captured trigger is a no-op — no rAF/transition wait, no budget-exhausted warn', () => { - mockScreenReaderEnabled = false; + it('back navigation skips the restore work when the screen reader is off, even when a trigger was captured', () => { + const view = fakeView('display-name'); + notifyPressedTrigger(fakeRef(view)); handleStateChange(stackState(0, [{key: 'profile', name: 'Profile'}])); handleStateChange( stackState(1, [ @@ -391,6 +392,8 @@ describe('handleStateChange — backward', () => { {key: 'display-name-page', name: 'DisplayName'}, ]), ); + + mockScreenReaderEnabled = false; handleStateChange(stackState(0, [{key: 'profile', name: 'Profile'}])); flushTransitions(); @@ -399,6 +402,26 @@ describe('handleStateChange — backward', () => { expect(mockFireFocusEvent).not.toHaveBeenCalled(); }); + it('cold-start race — press happens before SR cache warms, but back navigation after the cache warms still restores', () => { + mockScreenReaderEnabled = 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); + + mockScreenReaderEnabled = true; + 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( From 8ed283efb15e0ebb3a2455d53e722e7df646a59a Mon Sep 17 00:00:00 2001 From: TaduJR Date: Mon, 22 Jun 2026 14:23:01 +0300 Subject: [PATCH 63/89] fix: distinguish SR-unknown from SR-off; drop try/finally for RC --- src/hooks/useAccessibilityFocus/index.ts | 43 +++++++++---------- src/libs/Accessibility/index.ts | 9 ++++ .../NavigationFocusReturn/index.native.ts | 9 ++-- tests/unit/NavigationFocusReturnNativeTest.ts | 27 ++++++++++-- .../unit/libs/Accessibility/warmCacheTest.ts | 18 ++++++++ 5 files changed, 76 insertions(+), 30 deletions(-) diff --git a/src/hooks/useAccessibilityFocus/index.ts b/src/hooks/useAccessibilityFocus/index.ts index be2d5aef6ccb..f07aa80e05e6 100644 --- a/src/hooks/useAccessibilityFocus/index.ts +++ b/src/hooks/useAccessibilityFocus/index.ts @@ -30,31 +30,28 @@ const useAccessibilityFocus: UseAccessibilityFocus = ({didScreenTransitionEnd, i } // Release after the focus call so a same-tree sub-modal's INITIAL claim isn't blocked when no nav state change runs handleStateChange's resetCycle. - 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; - } - - if (focusTarget === activeElement) { - return; - } - - const unmarkProgrammaticFocus = markProgrammaticFocus(focusTarget); - focusTarget.focus(); - - const focusedElement = document.activeElement; - if (focusedElement === focusTarget || (focusedElement && focusTarget.contains(focusedElement))) { - return; - } - - unmarkProgrammaticFocus(); + 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; } - } finally { - resetCycle(); + + if (focusTarget === activeElement) { + break; + } + + const unmarkProgrammaticFocus = markProgrammaticFocus(focusTarget); + focusTarget.focus(); + + const focusedElement = document.activeElement; + if (focusedElement === focusTarget || (focusedElement && focusTarget.contains(focusedElement))) { + break; + } + + unmarkProgrammaticFocus(); } + resetCycle(); }, [didScreenTransitionEnd, isFocused, ref, shouldMoveAccessibilityFocus]); }; diff --git a/src/libs/Accessibility/index.ts b/src/libs/Accessibility/index.ts index c44416f1a6b2..ec189d26913d 100644 --- a/src/libs/Accessibility/index.ts +++ b/src/libs/Accessibility/index.ts @@ -36,6 +36,7 @@ function makeWarmCache(label: string, fetch: () => Promise, apply: (value: } let cachedScreenReaderValue = false; +let screenReaderCacheWarmed = false; const screenReaderSubscribers = new Set<() => void>(); const { ensure: ensureScreenReaderWarm, @@ -43,6 +44,7 @@ const { refresh: refreshScreenReaderWarm, } = makeWarmCache('screen-reader', isScreenReaderEnabled, (enabled) => { cachedScreenReaderValue = enabled; + screenReaderCacheWarmed = true; }); ensureScreenReaderWarm(); @@ -76,6 +78,11 @@ function isScreenReaderEnabledSync(): boolean { return cachedScreenReaderValue; } +// True only after the platform query has resolved with false; returns false while warm-up is in-flight so 'unknown' is treated as 'might be on'. +function isScreenReaderKnownOff(): boolean { + return screenReaderCacheWarmed && !cachedScreenReaderValue; +} + let cachedReduceMotionValue = false; const reduceMotionSubscribers = new Set<() => void>(); const { @@ -96,6 +103,7 @@ let appStateSubscription: ReturnType | null = function resetForTests() { cachedScreenReaderValue = false; cachedReduceMotionValue = false; + screenReaderCacheWarmed = false; resetScreenReaderWarm(); resetReduceMotionWarm(); screenReaderSubscribers.clear(); @@ -192,4 +200,5 @@ export default { useAutoHitSlop, useReducedMotion, isScreenReaderEnabledSync, + isScreenReaderKnownOff, }; diff --git a/src/libs/NavigationFocusReturn/index.native.ts b/src/libs/NavigationFocusReturn/index.native.ts index 66bfcb07dfdd..1eb203f00c6e 100644 --- a/src/libs/NavigationFocusReturn/index.native.ts +++ b/src/libs/NavigationFocusReturn/index.native.ts @@ -90,8 +90,11 @@ function registerPressable(routeKey: string, identifier: string, ref: RefObject< }; } -// SR gate lives at restore time, not here, so a cold-start press captured before ensureScreenReaderWarm resolves still survives. +// Gate on known-off so the warm-up window (cold start, AppState resume) still captures defensively. function captureTriggerForRoute(routeKey: string): void { + if (Accessibility.isScreenReaderKnownOff()) { + return; + } if (!lastPressedTriggerRef || performance.now() - lastPressedTriggerAt > PRESS_TRIGGER_TTL_MS) { return; } @@ -139,8 +142,8 @@ function applySkippedRestore(restoreKey: string): void { } function scheduleRestore(routeKey: string, {waitForUpcomingTransition}: {waitForUpcomingTransition: boolean}): void { - // Skip all expensive work for non-SR users and when nothing was captured. Capture upstream is unconditional so cold-start presses aren't dropped here. - if (!Accessibility.isScreenReaderEnabledSync() || !triggerMap.has(routeKey)) { + // Known-off bails (sendAccessibilityEvent has no consumer); unknown proceeds so the warm-up window doesn't drop the first restore. + if (Accessibility.isScreenReaderKnownOff() || !triggerMap.has(routeKey)) { return; } cancelPendingRestore(); diff --git a/tests/unit/NavigationFocusReturnNativeTest.ts b/tests/unit/NavigationFocusReturnNativeTest.ts index 19813eff0dce..ed3a35e2ba03 100644 --- a/tests/unit/NavigationFocusReturnNativeTest.ts +++ b/tests/unit/NavigationFocusReturnNativeTest.ts @@ -15,6 +15,7 @@ const mockFireFocusEvent = jest.fn(); const mockSendAccessibilityEvent = jest.fn(); const mockLogWarn = jest.fn(); let mockScreenReaderEnabled = true; +let mockScreenReaderCacheWarmed = true; jest.mock('@libs/Log', () => ({ __esModule: true, @@ -34,6 +35,7 @@ jest.mock('../../src/libs/Accessibility', () => ({ default: { moveAccessibilityFocus: jest.fn(), isScreenReaderEnabledSync: () => mockScreenReaderEnabled, + isScreenReaderKnownOff: () => mockScreenReaderCacheWarmed && !mockScreenReaderEnabled, useScreenReaderStatus: () => mockScreenReaderEnabled, useReducedMotion: () => false, }, @@ -159,6 +161,7 @@ beforeEach(() => { mockFireFocusEvent.mockClear(); mockLogWarn.mockClear(); mockScreenReaderEnabled = true; + mockScreenReaderCacheWarmed = true; mockStateListeners = []; mockNavigationRefState = undefined; mockTtQueue = []; @@ -170,8 +173,23 @@ afterEach(() => { }); describe('notifyPressedTrigger', () => { - it('captures regardless of screen-reader state so a cold-start press before the SR cache warms is not dropped', () => { + 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, [ @@ -382,7 +400,7 @@ describe('handleStateChange — backward', () => { expect(getTriggerMapSizeForTests()).toBe(0); }); - it('back navigation skips the restore work when the screen reader is off, even when a trigger was captured', () => { + it('back navigation skips the restore work when the screen reader is known off, even when a trigger was captured', () => { const view = fakeView('display-name'); notifyPressedTrigger(fakeRef(view)); handleStateChange(stackState(0, [{key: 'profile', name: 'Profile'}])); @@ -394,6 +412,7 @@ describe('handleStateChange — backward', () => { ); mockScreenReaderEnabled = false; + mockScreenReaderCacheWarmed = true; handleStateChange(stackState(0, [{key: 'profile', name: 'Profile'}])); flushTransitions(); @@ -402,8 +421,9 @@ describe('handleStateChange — backward', () => { expect(mockFireFocusEvent).not.toHaveBeenCalled(); }); - it('cold-start race — press happens before SR cache warms, but back navigation after the cache warms still restores', () => { + 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'}])); @@ -415,7 +435,6 @@ describe('handleStateChange — backward', () => { ); expect(getTriggerMapSizeForTests()).toBe(1); - mockScreenReaderEnabled = true; handleStateChange(stackState(0, [{key: 'profile', name: 'Profile'}])); flushTransitions(); diff --git a/tests/unit/libs/Accessibility/warmCacheTest.ts b/tests/unit/libs/Accessibility/warmCacheTest.ts index c7e59bf7693d..6efb6278eb7b 100644 --- a/tests/unit/libs/Accessibility/warmCacheTest.ts +++ b/tests/unit/libs/Accessibility/warmCacheTest.ts @@ -2,6 +2,7 @@ type AppStateChangeListener = (status: string) => void; type AccessibilityModule = { default: { isScreenReaderEnabledSync: () => boolean; + isScreenReaderKnownOff: () => boolean; }; }; @@ -92,6 +93,23 @@ describe('Accessibility warm cache — AppState refresh', () => { expect(mockReduceMotionFetchCount).toBe(initialReduceMotionFetches); }); + it('isScreenReaderKnownOff returns false before warm resolves and true only after a false-resolution', async () => { + mockScreenReaderValue = false; + const Accessibility = loadModule(); + // Synchronously after module load the warm promise has not resolved — unknown state must not report known-off. + expect(Accessibility.default.isScreenReaderKnownOff()).toBe(false); + await flushPromises(); + // Warm resolved with false — now known-off. + expect(Accessibility.default.isScreenReaderKnownOff()).toBe(true); + }); + + it('isScreenReaderKnownOff returns false after warm resolves with SR enabled', async () => { + mockScreenReaderValue = true; + const Accessibility = loadModule(); + await flushPromises(); + expect(Accessibility.default.isScreenReaderKnownOff()).toBe(false); + }); + it('re-fetches the reduce-motion value on background→active transition', async () => { mockReduceMotionValue = false; loadModule(); From 690f47cadb52fd4e2753c56e4ef81e75e8109df7 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Mon, 22 Jun 2026 17:02:36 +0300 Subject: [PATCH 64/89] fix: cancel pending restore before scheduleRestore's early-bail --- .../NavigationFocusReturn/index.native.ts | 3 +- tests/unit/NavigationFocusReturnNativeTest.ts | 37 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/libs/NavigationFocusReturn/index.native.ts b/src/libs/NavigationFocusReturn/index.native.ts index 1eb203f00c6e..d495859411c8 100644 --- a/src/libs/NavigationFocusReturn/index.native.ts +++ b/src/libs/NavigationFocusReturn/index.native.ts @@ -142,11 +142,12 @@ function applySkippedRestore(restoreKey: string): void { } function scheduleRestore(routeKey: string, {waitForUpcomingTransition}: {waitForUpcomingTransition: boolean}): void { + // Cancel first so a stale prior restore can't fire on the prior route after the user moved on (rapid double-back). + cancelPendingRestore(); // Known-off bails (sendAccessibilityEvent has no consumer); unknown proceeds so the warm-up window doesn't drop the first restore. if (Accessibility.isScreenReaderKnownOff() || !triggerMap.has(routeKey)) { return; } - cancelPendingRestore(); let cancelled = false; let refocusHandle: {cancel: () => void} | null = null; let rafHandle: number | null = null; diff --git a/tests/unit/NavigationFocusReturnNativeTest.ts b/tests/unit/NavigationFocusReturnNativeTest.ts index ed3a35e2ba03..66477c5908dd 100644 --- a/tests/unit/NavigationFocusReturnNativeTest.ts +++ b/tests/unit/NavigationFocusReturnNativeTest.ts @@ -421,6 +421,43 @@ describe('handleStateChange — backward', () => { expect(mockFireFocusEvent).not.toHaveBeenCalled(); }); + 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; From 04a56fb36bc98d239cc173315bfa16a653b0a51e Mon Sep 17 00:00:00 2001 From: TaduJR Date: Mon, 22 Jun 2026 17:17:14 +0300 Subject: [PATCH 65/89] fix: encapsulate warmed flag in makeWarmCache; reset on refresh --- src/libs/Accessibility/index.ts | 24 +++++++++++++------ .../unit/libs/Accessibility/warmCacheTest.ts | 18 ++++++++++++++ 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/src/libs/Accessibility/index.ts b/src/libs/Accessibility/index.ts index ec189d26913d..92c848007856 100644 --- a/src/libs/Accessibility/index.ts +++ b/src/libs/Accessibility/index.ts @@ -12,11 +12,19 @@ type HitSlop = {x: number; y: number}; * 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} { +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; const ensure = () => { warm ??= fetch() - .then(apply) + .then((value) => { + warmed = true; + apply(value); + }) .catch((error: unknown) => { Log.warn(`[Accessibility] Failed to warm ${label} cache`, {error}); warm = null; @@ -25,26 +33,29 @@ function makeWarmCache(label: string, fetch: () => Promise, apply: (value: }; return { ensure, + // reset/refresh invalidate warmed so any in-flight refresh (cold start or AppState resume) is treated as unknown until the new value resolves. reset: () => { warm = null; + warmed = false; }, refresh: () => { warm = null; + warmed = false; return ensure(); }, + isWarm: () => warmed, }; } let cachedScreenReaderValue = false; -let screenReaderCacheWarmed = false; const screenReaderSubscribers = new Set<() => void>(); const { ensure: ensureScreenReaderWarm, reset: resetScreenReaderWarm, refresh: refreshScreenReaderWarm, + isWarm: isScreenReaderCacheWarm, } = makeWarmCache('screen-reader', isScreenReaderEnabled, (enabled) => { cachedScreenReaderValue = enabled; - screenReaderCacheWarmed = true; }); ensureScreenReaderWarm(); @@ -78,9 +89,9 @@ function isScreenReaderEnabledSync(): boolean { return cachedScreenReaderValue; } -// True only after the platform query has resolved with false; returns false while warm-up is in-flight so 'unknown' is treated as 'might be on'. +// True only after the platform query has resolved with false; returns false while warm-up is in-flight (cold start OR AppState resume) so 'unknown' is treated as 'might be on'. function isScreenReaderKnownOff(): boolean { - return screenReaderCacheWarmed && !cachedScreenReaderValue; + return isScreenReaderCacheWarm() && !cachedScreenReaderValue; } let cachedReduceMotionValue = false; @@ -103,7 +114,6 @@ let appStateSubscription: ReturnType | null = function resetForTests() { cachedScreenReaderValue = false; cachedReduceMotionValue = false; - screenReaderCacheWarmed = false; resetScreenReaderWarm(); resetReduceMotionWarm(); screenReaderSubscribers.clear(); diff --git a/tests/unit/libs/Accessibility/warmCacheTest.ts b/tests/unit/libs/Accessibility/warmCacheTest.ts index 6efb6278eb7b..194b6cd969b7 100644 --- a/tests/unit/libs/Accessibility/warmCacheTest.ts +++ b/tests/unit/libs/Accessibility/warmCacheTest.ts @@ -110,6 +110,24 @@ describe('Accessibility warm cache — AppState refresh', () => { expect(Accessibility.default.isScreenReaderKnownOff()).toBe(false); }); + it('isScreenReaderKnownOff returns false during a resume refresh (warmed flag invalidates until the new value resolves)', async () => { + mockScreenReaderValue = false; + const Accessibility = loadModule(); + await flushPromises(); + expect(Accessibility.default.isScreenReaderKnownOff()).toBe(true); + + // Background → active begins the async refresh; user enabled SR while backgrounded but the bridge hasn't replied yet. + mockScreenReaderValue = true; + emitAppState('background'); + emitAppState('active'); + // Synchronously after the AppState callback fires, warmed must already be invalidated. + expect(Accessibility.default.isScreenReaderKnownOff()).toBe(false); + + await flushPromises(); + // Refresh resolved with true — still not known-off. + expect(Accessibility.default.isScreenReaderKnownOff()).toBe(false); + }); + it('re-fetches the reduce-motion value on background→active transition', async () => { mockReduceMotionValue = false; loadModule(); From 640792d1628e95d7675539a1b3a1e27906eadbdd Mon Sep 17 00:00:00 2001 From: TaduJR Date: Mon, 22 Jun 2026 17:34:11 +0300 Subject: [PATCH 66/89] fix: generation-token warm cache; consume known-off trigger; drop manual memo --- src/hooks/useNavigateBackOnSave/index.ts | 10 +++--- src/libs/Accessibility/index.ts | 19 ++++++++-- .../NavigationFocusReturn/index.native.ts | 8 +++-- tests/unit/NavigationFocusReturnNativeTest.ts | 4 ++- .../unit/libs/Accessibility/warmCacheTest.ts | 35 ++++++++++++++++++- 5 files changed, 64 insertions(+), 12 deletions(-) diff --git a/src/hooks/useNavigateBackOnSave/index.ts b/src/hooks/useNavigateBackOnSave/index.ts index 074a97a10c80..1e7131b068ef 100644 --- a/src/hooks/useNavigateBackOnSave/index.ts +++ b/src/hooks/useNavigateBackOnSave/index.ts @@ -1,4 +1,4 @@ -import {useCallback, useEffect, useRef} from 'react'; +import {useEffect, useRef} from 'react'; import Navigation from '@libs/Navigation/Navigation'; import {skipNextFocusRestore} from '@libs/NavigationFocusReturn'; import type {Route} from '@src/ROUTES'; @@ -14,13 +14,13 @@ function useNavigateBackOnSave( ): {navigateBack: () => void; armNavigateBack: () => void} { const shouldNavigateAfterSaveRef = useRef(false); - const navigateBack = useCallback(() => { + const navigateBack = () => { Navigation.goBack(backTo); - }, [backTo]); + }; - const armNavigateBack = useCallback(() => { + const armNavigateBack = () => { shouldNavigateAfterSaveRef.current = true; - }, []); + }; useEffect(() => { if (!isSaved || !shouldNavigateAfterSaveRef.current) { diff --git a/src/libs/Accessibility/index.ts b/src/libs/Accessibility/index.ts index 92c848007856..90a826d0d269 100644 --- a/src/libs/Accessibility/index.ts +++ b/src/libs/Accessibility/index.ts @@ -19,28 +19,41 @@ function makeWarmCache( ): {ensure: () => Promise; reset: () => void; refresh: () => Promise; isWarm: () => boolean} { let warm: Promise | null = null; let warmed = false; + let generation = 0; const ensure = () => { - warm ??= fetch() + 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}); - warm = null; + if (myGeneration === generation) { + warm = null; + } }); return warm; }; return { ensure, - // reset/refresh invalidate warmed so any in-flight refresh (cold start or AppState resume) is treated as unknown until the new value resolves. + // 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, diff --git a/src/libs/NavigationFocusReturn/index.native.ts b/src/libs/NavigationFocusReturn/index.native.ts index d495859411c8..caa7a475d007 100644 --- a/src/libs/NavigationFocusReturn/index.native.ts +++ b/src/libs/NavigationFocusReturn/index.native.ts @@ -144,8 +144,12 @@ function applySkippedRestore(restoreKey: string): void { function scheduleRestore(routeKey: string, {waitForUpcomingTransition}: {waitForUpcomingTransition: boolean}): void { // Cancel first so a stale prior restore can't fire on the prior route after the user moved on (rapid double-back). cancelPendingRestore(); - // Known-off bails (sendAccessibilityEvent has no consumer); unknown proceeds so the warm-up window doesn't drop the first restore. - if (Accessibility.isScreenReaderKnownOff() || !triggerMap.has(routeKey)) { + // Consume the entry so a later SR re-enable + press-less nav can't replay this stale capture. + if (Accessibility.isScreenReaderKnownOff()) { + triggerMap.delete(routeKey); + return; + } + if (!triggerMap.has(routeKey)) { return; } let cancelled = false; diff --git a/tests/unit/NavigationFocusReturnNativeTest.ts b/tests/unit/NavigationFocusReturnNativeTest.ts index 66477c5908dd..7638e882c741 100644 --- a/tests/unit/NavigationFocusReturnNativeTest.ts +++ b/tests/unit/NavigationFocusReturnNativeTest.ts @@ -400,7 +400,7 @@ describe('handleStateChange — backward', () => { expect(getTriggerMapSizeForTests()).toBe(0); }); - it('back navigation skips the restore work when the screen reader is known off, even when a trigger was captured', () => { + 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'}])); @@ -410,6 +410,7 @@ describe('handleStateChange — backward', () => { {key: 'display-name-page', name: 'DisplayName'}, ]), ); + expect(getTriggerMapSizeForTests()).toBe(1); mockScreenReaderEnabled = false; mockScreenReaderCacheWarmed = true; @@ -419,6 +420,7 @@ describe('handleStateChange — backward', () => { 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)', () => { diff --git a/tests/unit/libs/Accessibility/warmCacheTest.ts b/tests/unit/libs/Accessibility/warmCacheTest.ts index 194b6cd969b7..42151b3aad93 100644 --- a/tests/unit/libs/Accessibility/warmCacheTest.ts +++ b/tests/unit/libs/Accessibility/warmCacheTest.ts @@ -11,11 +11,20 @@ const appStateListeners: AppStateChangeListener[] = []; let mockScreenReaderValue = false; let mockReduceMotionValue = false; let mockReduceMotionFetchCount = 0; +let mockScreenReaderDeferred = false; +const mockScreenReaderResolvers: Array<(value: boolean) => void> = []; jest.mock('@libs/Log'); jest.mock('@libs/Accessibility/isScreenReaderEnabled', () => ({ __esModule: true, - default: () => Promise.resolve(mockScreenReaderValue), + default: () => { + if (mockScreenReaderDeferred) { + return new Promise((resolve) => { + mockScreenReaderResolvers.push(resolve); + }); + } + return Promise.resolve(mockScreenReaderValue); + }, })); jest.mock('react-native', () => ({ @@ -44,6 +53,8 @@ beforeEach(() => { mockScreenReaderValue = false; mockReduceMotionValue = false; mockReduceMotionFetchCount = 0; + mockScreenReaderDeferred = false; + mockScreenReaderResolvers.length = 0; }); function loadModule(): AccessibilityModule { @@ -110,6 +121,28 @@ describe('Accessibility warm cache — AppState refresh', () => { expect(Accessibility.default.isScreenReaderKnownOff()).toBe(false); }); + 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.isScreenReaderKnownOff()).toBe(false); + + // 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.isScreenReaderKnownOff()).toBe(false); + }); + it('isScreenReaderKnownOff returns false during a resume refresh (warmed flag invalidates until the new value resolves)', async () => { mockScreenReaderValue = false; const Accessibility = loadModule(); From 960baa12f283643f4c68b51363d9f13359308e3a Mon Sep 17 00:00:00 2001 From: TaduJR Date: Tue, 23 Jun 2026 11:48:19 +0300 Subject: [PATCH 67/89] fix: tag navigation transitions distinct from keyboard/modal --- src/hooks/useScreenInitialFocus/index.ts | 9 ++-- .../Accessibility/fireFocusEvent/index.ts | 1 - .../PlatformStackNavigation/ScreenLayout.tsx | 2 +- src/libs/Navigation/TransitionTracker.ts | 46 ++++++++++++------- .../NavigationFocusReturn/index.native.ts | 5 +- .../unit/Navigation/TransitionTrackerTest.ts | 33 ++++++++++--- tests/unit/NavigationFocusReturnNativeTest.ts | 10 ---- tests/unit/NavigationFocusReturnTest.ts | 4 -- .../unit/libs/Accessibility/warmCacheTest.ts | 6 --- 9 files changed, 63 insertions(+), 53 deletions(-) diff --git a/src/hooks/useScreenInitialFocus/index.ts b/src/hooks/useScreenInitialFocus/index.ts index a96a5c40491b..3092d6deec34 100644 --- a/src/hooks/useScreenInitialFocus/index.ts +++ b/src/hooks/useScreenInitialFocus/index.ts @@ -7,9 +7,7 @@ import {MAX_INITIAL_FOCUS_FRAMES} from '@libs/focusReturnTimings'; import getHadTabNavigation from '@libs/hadTabNavigation'; import type UseScreenInitialFocus from './types'; -/* - * Off-screen Pressables (Growls, pre-animation drawers) pass attribute checks; geometry rules them out. - */ +/** 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) { @@ -24,9 +22,8 @@ function isOnScreen(el: HTMLElement): boolean { return true; } -/* - * Mobile-web counterpart to `useDialogContainerFocus` (RHP-only): focuses `node` once after `didScreenTransitionEnd`. - * Takes the attached node (not a ref) so late attachment — skeleton → real header, Suspense — re-runs the effect. +/** + * 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) => { diff --git a/src/libs/Accessibility/fireFocusEvent/index.ts b/src/libs/Accessibility/fireFocusEvent/index.ts index 1e1ea07e53e3..f4658f740373 100644 --- a/src/libs/Accessibility/fireFocusEvent/index.ts +++ b/src/libs/Accessibility/fireFocusEvent/index.ts @@ -1,4 +1,3 @@ -// Web uses `element.focus()` directly; this is the cross-platform no-op. // 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'; diff --git a/src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx b/src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx index f9ed7c92bccc..80da214473d0 100644 --- a/src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx +++ b/src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx @@ -23,7 +23,7 @@ function ScreenLayout({ useLayoutEffect(() => { const transitionStartListener = navigation.addListener('transitionStart', () => { - transitionHandleRef.current = TransitionTracker.startTransition(); + transitionHandleRef.current = TransitionTracker.startTransition('navigation'); }); const transitionEndListener = navigation.addListener('transitionEnd', () => { if (!transitionHandleRef.current) { diff --git a/src/libs/Navigation/TransitionTracker.ts b/src/libs/Navigation/TransitionTracker.ts index c5b41ebbed11..789e4a7fab46 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,11 +14,12 @@ type RunAfterTransitionsOptions = { /** If true, the callback fires synchronously regardless of any active transitions. Defaults to false. */ runImmediately?: boolean; - /** Wait for a transition before running the callback — the next one to start if none is active yet, else the active one to end. Defaults to false. */ + /** Wait for a navigation transition before running the callback — the next one to start if none is active yet, else the active one to end. Defaults to false. */ waitForUpcomingTransition?: boolean; }; -const activeTransitions = new Map>(); +const activeTransitions = new Map; kind: TransitionKind}>(); +let activeNavigationCount = 0; let pendingCallbacks: Array<() => void | Promise> = []; @@ -61,25 +64,33 @@ 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) { - nextTransitionStartResolve = null; - promiseForNextTransitionStart = new Promise((r) => { - nextTransitionStartResolve = r; - }); - resolve(); + if (kind === 'navigation') { + const resolve = nextTransitionStartResolve; + if (resolve) { + nextTransitionStartResolve = null; + promiseForNextTransitionStart = new Promise((r) => { + nextTransitionStartResolve = r; + }); + resolve(); + } + 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; } @@ -91,13 +102,16 @@ 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(); } @@ -112,8 +126,8 @@ function endTransition(handle: TransitionHandle): void { * @returns A handle with a `cancel` method to prevent the callback from firing. */ function runAfterTransitions({callback, runImmediately = false, waitForUpcomingTransition = false}: RunAfterTransitionsOptions): CancelHandle { - // If a transition is already active (web fires transitionStart before the navigation state event), wait for it to end rather than a next start that never comes — which would hit the timeout. - if (waitForUpcomingTransition && activeTransitions.size === 0) { + // If a navigation transition is already active (web fires transitionStart before the navigation state event), wait for it to end rather than a next start that never comes. Non-nav transitions are excluded — their end could flush callbacks before the nav transition starts. + if (waitForUpcomingTransition && activeNavigationCount === 0) { let cancelled = false; let innerHandle: CancelHandle | null = null; diff --git a/src/libs/NavigationFocusReturn/index.native.ts b/src/libs/NavigationFocusReturn/index.native.ts index caa7a475d007..a38087b12c0e 100644 --- a/src/libs/NavigationFocusReturn/index.native.ts +++ b/src/libs/NavigationFocusReturn/index.native.ts @@ -41,8 +41,7 @@ function setTriggerEntry(routeKey: string, entry: TriggerEntry): void { } } -// Recorded unconditionally so first-press registrations aren't dropped during the SR-cache cold-start window; captureTriggerForRoute -// still gates on SR so non-SR users do no work past this write. performance.now is monotonic — Date.now would corrupt the TTL on clock jumps. +// 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; @@ -157,7 +156,7 @@ function scheduleRestore(routeKey: string, {waitForUpcomingTransition}: {waitFor let rafHandle: number | null = null; let handle: {cancel: () => void} | null = null; - // Assign pendingRestore before runAfterTransitions: with waitForUpcomingTransition false the callback can fire synchronously, so a re-entrant cancel must already see this handle to abort the just-scheduled rAF retry. + // 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; diff --git a/tests/unit/Navigation/TransitionTrackerTest.ts b/tests/unit/Navigation/TransitionTrackerTest.ts index 9f69044488e0..738e5bc453fe 100644 --- a/tests/unit/Navigation/TransitionTrackerTest.ts +++ b/tests/unit/Navigation/TransitionTrackerTest.ts @@ -73,11 +73,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,9 +87,9 @@ describe('TransitionTracker', () => { drainTransitions(); }); - it('waitForUpcomingTransition waits for an already-active transition to end (web order: transitionStart before the call) instead of a phantom next start', () => { + 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(); + const handle = TransitionTracker.startTransition('navigation'); TransitionTracker.runAfterTransitions({callback, waitForUpcomingTransition: true}); expect(callback).not.toHaveBeenCalled(); TransitionTracker.endTransition(handle); @@ -97,6 +97,27 @@ describe('TransitionTracker', () => { drainTransitions(); }); + it('waitForUpcomingTransition ignores non-navigation transitions and waits for an upcoming navigation start (keyboard / modal concurrent with back-nav)', async () => { + const callback = jest.fn(); + // Keyboard or modal animation is active when the back-nav fires its scheduleRestore. + const otherHandle = TransitionTracker.startTransition(); + TransitionTracker.runAfterTransitions({callback, waitForUpcomingTransition: true}); + + // The non-nav transition ends before the nav transition has even started — callback must NOT fire yet. + TransitionTracker.endTransition(otherHandle); + await Promise.resolve(); + expect(callback).not.toHaveBeenCalled(); + + // Nav transition starts, then ends — only now does the callback fire. + 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}); @@ -111,7 +132,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(); @@ -122,7 +143,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 index 7638e882c741..76763f92b2af 100644 --- a/tests/unit/NavigationFocusReturnNativeTest.ts +++ b/tests/unit/NavigationFocusReturnNativeTest.ts @@ -336,7 +336,6 @@ describe('handleStateChange — backward', () => { handleStateChange(stackState(0, [{key: 'profile', name: 'Profile'}])); flushTransitions(); - // Deeplink-style forward (no fresh trigger) + back: the skipped entry must not resurface. handleStateChange( stackState(1, [ {key: 'profile', name: 'Profile'}, @@ -365,7 +364,6 @@ describe('handleStateChange — backward', () => { }); it('does NOT call sendAccessibilityEvent when the captured ref has been nulled (Pressable unmounted)', () => { - // The ref's `.current` going null is the ref-pass-through analog of a detached view. const detachedRef = fakeRef(null); notifyPressedTrigger(detachedRef); const prev = stackState(0, [{key: 'profile', name: 'Profile'}]); @@ -489,11 +487,9 @@ describe('handleStateChange — backward', () => { ]), ); - // User presses Back on B (stages the back-button ref), then navigates back. notifyPressedTrigger(fakeRef(fakeView('back-button')), 'Back'); handleStateChange(stackState(0, [{key: 'A', name: 'A'}])); - // A press-less forward within the TTL must capture nothing — the Back press was consumed by the backward nav. handleStateChange( stackState(1, [ {key: 'A', name: 'A'}, @@ -633,11 +629,9 @@ describe('PUSH_PARAMS — same-route param change', () => { 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'}])); - // User presses Back/Save (stages the ref), then a PUSH_PARAMS back reverts params. notifyPressedTrigger(fakeRef(fakeView('back-button')), 'Back'); notifyPushParamsBackward('A', {q: 'old'}); - // A press-less forward within the TTL must capture nothing. handleStateChange( stackState(1, [ {key: 'A', name: 'A'}, @@ -758,7 +752,6 @@ describe('pressable registry — identifier-based fallback', () => { handleStateChange(stackState(0, [{key: 'A', name: 'A'}])); flushTransitions(); - // Two frames pass with the registry still empty — a single-frame retry would already have dropped the entry. jest.advanceTimersByTime(20); jest.advanceTimersByTime(20); expect(mockFireFocusEvent).not.toHaveBeenCalled(); @@ -834,7 +827,6 @@ describe('pressable registry — identifier-based fallback', () => { ]), ); - // Screen detaches (captured ref nulled), then remounts with several rows sharing the same "Edit" label. detachedRef.current = null; registerPressable('A', 'Edit', fakeRef(fakeView('row-1-edit'))); registerPressable('A', 'Edit', fakeRef(fakeView('row-2-edit'))); @@ -843,7 +835,6 @@ describe('pressable registry — identifier-based fallback', () => { flushTransitions(); jest.advanceTimersByTime(200); - // Ambiguous identifier → focus nothing rather than an arbitrary row; the entry is dropped after the budget. expect(mockFireFocusEvent).not.toHaveBeenCalled(); expect(getTriggerMapSizeForTests()).toBe(0); }); @@ -860,7 +851,6 @@ describe('pressable registry — identifier-based fallback', () => { ]), ); - // Captured ref dies, dual-header layout registers two back buttons under one route. detachedRef.current = null; const liveView = fakeView('backButton-1'); registerPressable('A', 'backButton', fakeRef(liveView)); diff --git a/tests/unit/NavigationFocusReturnTest.ts b/tests/unit/NavigationFocusReturnTest.ts index 34207f1004a2..b2f4f750f76e 100644 --- a/tests/unit/NavigationFocusReturnTest.ts +++ b/tests/unit/NavigationFocusReturnTest.ts @@ -1529,7 +1529,6 @@ describe('handleStateChange integration', () => { handleStateChange(onA); flushTransitions(); - // No fresh capture between the skipped revert and the next back: the entry must be gone, no stale replay. const spy = jest.spyOn(trigger, 'focus'); handleStateChange(onAB); trigger.blur(); @@ -1753,17 +1752,14 @@ describe('PUSH_PARAMS notifications', () => { fireFocusIn(trigger); notifyPushParamsForward('search-x', {q: 'foo'}); - // Param re-render unmounts the captured row before the backward restore runs. trigger.remove(); const spy = jest.spyOn(trigger, 'focus'); notifyPushParamsBackward('search-x', {q: 'foo'}); - // First attempt is detached — the entry must be preserved, not dropped. flushTransitions(); expect(spy).not.toHaveBeenCalled(); - // Row remounts; the retry budget recovers focus instead of giving up on the first miss. document.body.appendChild(trigger); jest.runAllTimers(); expect(spy).toHaveBeenCalled(); diff --git a/tests/unit/libs/Accessibility/warmCacheTest.ts b/tests/unit/libs/Accessibility/warmCacheTest.ts index 42151b3aad93..d8352099c0d1 100644 --- a/tests/unit/libs/Accessibility/warmCacheTest.ts +++ b/tests/unit/libs/Accessibility/warmCacheTest.ts @@ -80,7 +80,6 @@ describe('Accessibility warm cache — AppState refresh', () => { await flushPromises(); expect(Accessibility.default.isScreenReaderEnabledSync()).toBe(false); - // OS toggle happens while the app is backgrounded — no `screenReaderChanged` event reaches us. mockScreenReaderValue = true; emitAppState('background'); emitAppState('active'); @@ -107,10 +106,8 @@ describe('Accessibility warm cache — AppState refresh', () => { it('isScreenReaderKnownOff returns false before warm resolves and true only after a false-resolution', async () => { mockScreenReaderValue = false; const Accessibility = loadModule(); - // Synchronously after module load the warm promise has not resolved — unknown state must not report known-off. expect(Accessibility.default.isScreenReaderKnownOff()).toBe(false); await flushPromises(); - // Warm resolved with false — now known-off. expect(Accessibility.default.isScreenReaderKnownOff()).toBe(true); }); @@ -149,15 +146,12 @@ describe('Accessibility warm cache — AppState refresh', () => { await flushPromises(); expect(Accessibility.default.isScreenReaderKnownOff()).toBe(true); - // Background → active begins the async refresh; user enabled SR while backgrounded but the bridge hasn't replied yet. mockScreenReaderValue = true; emitAppState('background'); emitAppState('active'); - // Synchronously after the AppState callback fires, warmed must already be invalidated. expect(Accessibility.default.isScreenReaderKnownOff()).toBe(false); await flushPromises(); - // Refresh resolved with true — still not known-off. expect(Accessibility.default.isScreenReaderKnownOff()).toBe(false); }); From fb2481170b9743a64a3b3a09660439c306e33cf0 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Tue, 23 Jun 2026 12:16:14 +0300 Subject: [PATCH 68/89] fix: preserve any-transition waiters; thread preventScroll through restoreFocusWithModality --- .../FocusTrap/FocusTrapForModal/index.web.tsx | 2 +- src/libs/Navigation/TransitionTracker.ts | 44 ++++++++++++------- .../NavigationFocusReturn/index.native.ts | 4 +- src/libs/NavigationFocusReturn/index.ts | 4 +- src/libs/restoreFocusWithModality.ts | 4 +- .../unit/Navigation/TransitionTrackerTest.ts | 20 ++++++--- tests/unit/NavigationFocusReturnNativeTest.ts | 6 +-- tests/unit/NavigationFocusReturnTest.ts | 4 +- tests/unit/restoreFocusWithModalityTest.ts | 11 ++++- 9 files changed, 66 insertions(+), 33 deletions(-) diff --git a/src/components/FocusTrap/FocusTrapForModal/index.web.tsx b/src/components/FocusTrap/FocusTrapForModal/index.web.tsx index 516b0fffb7fe..c4ae9bf383f6 100644 --- a/src/components/FocusTrap/FocusTrapForModal/index.web.tsx +++ b/src/components/FocusTrap/FocusTrapForModal/index.web.tsx @@ -30,7 +30,7 @@ function FocusTrapForModal({children, active, initialFocus = false, shouldPreven return; } if (shouldReturnFocus && !ReportActionComposeFocusManager.isFocused() && document.contains(launcher)) { - restoreFocusWithModality(launcher); + restoreFocusWithModality(launcher, {preventScroll: shouldPreventScroll}); } // Deferred so popover paths that navigate after modal-hide can still consume. markActivePopoverLauncherDeactivated(launcher); diff --git a/src/libs/Navigation/TransitionTracker.ts b/src/libs/Navigation/TransitionTracker.ts index 789e4a7fab46..f05bc27bb00d 100644 --- a/src/libs/Navigation/TransitionTracker.ts +++ b/src/libs/Navigation/TransitionTracker.ts @@ -14,8 +14,8 @@ type RunAfterTransitionsOptions = { /** If true, the callback fires synchronously regardless of any active transitions. Defaults to false. */ runImmediately?: boolean; - /** Wait for a navigation transition before running the callback — the next one to start if none is active yet, else the active one to end. 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; kind: TransitionKind}>(); @@ -28,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. @@ -69,14 +74,24 @@ function decrementAndFlush(): void { function startTransition(kind: TransitionKind = 'other'): TransitionHandle { const handle: TransitionHandle = Symbol('transition'); + // 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; + }); + resolveAny(); + } + if (kind === 'navigation') { - const resolve = nextTransitionStartResolve; - if (resolve) { - nextTransitionStartResolve = null; - promiseForNextTransitionStart = new Promise((r) => { - nextTransitionStartResolve = r; + const resolveNav = nextNavigationTransitionStartResolve; + if (resolveNav) { + nextNavigationTransitionStartResolve = null; + promiseForNextNavigationTransitionStart = new Promise((r) => { + nextNavigationTransitionStartResolve = r; }); - resolve(); + resolveNav(); } activeNavigationCount += 1; } @@ -126,22 +141,21 @@ function endTransition(handle: TransitionHandle): void { * @returns A handle with a `cancel` method to prevent the callback from firing. */ function runAfterTransitions({callback, runImmediately = false, waitForUpcomingTransition = false}: RunAfterTransitionsOptions): CancelHandle { - // If a navigation transition is already active (web fires transitionStart before the navigation state event), wait for it to end rather than a next start that never comes. Non-nav transitions are excluded — their end could flush callbacks before the nav transition starts. - if (waitForUpcomingTransition && activeNavigationCount === 0) { + const waitForNavigationOnly = waitForUpcomingTransition === 'navigation'; + const activeRelevantCount = waitForNavigationOnly ? activeNavigationCount : activeTransitions.size; + // If a transition of the awaited kind is already active (web fires transitionStart before the navigation state event), wait for it to end rather than a next start that never comes. + if (waitForUpcomingTransition && activeRelevantCount === 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) { diff --git a/src/libs/NavigationFocusReturn/index.native.ts b/src/libs/NavigationFocusReturn/index.native.ts index a38087b12c0e..7cf831a2c8b6 100644 --- a/src/libs/NavigationFocusReturn/index.native.ts +++ b/src/libs/NavigationFocusReturn/index.native.ts @@ -140,7 +140,7 @@ function applySkippedRestore(restoreKey: string): void { triggerMap.delete(restoreKey); } -function scheduleRestore(routeKey: string, {waitForUpcomingTransition}: {waitForUpcomingTransition: boolean}): void { +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. @@ -212,7 +212,7 @@ function handleStateChange(newState: NavigationState | undefined): void { if (skipNextRestore) { applySkippedRestore(action.restoreKey); } else { - scheduleRestore(action.restoreKey, {waitForUpcomingTransition: true}); + scheduleRestore(action.restoreKey, {waitForUpcomingTransition: 'navigation'}); } } else if (action.type === 'lateral') { skipNextRestore = false; diff --git a/src/libs/NavigationFocusReturn/index.ts b/src/libs/NavigationFocusReturn/index.ts index 6ab70b789e72..9b56d5e29e92 100644 --- a/src/libs/NavigationFocusReturn/index.ts +++ b/src/libs/NavigationFocusReturn/index.ts @@ -266,7 +266,7 @@ function applySkippedRestore(restoreKey: string): void { triggerMap.delete(restoreKey); } -function scheduleRestore(routeKey: string, {waitForUpcomingTransition}: {waitForUpcomingTransition: boolean}): 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(); @@ -335,7 +335,7 @@ function handleStateChange(newState: NavigationState | undefined): void { if (skipNextRestore) { applySkippedRestore(action.restoreKey); } else { - scheduleRestore(action.restoreKey, {waitForUpcomingTransition: true}); + scheduleRestore(action.restoreKey, {waitForUpcomingTransition: 'navigation'}); } } else if (action.type === 'lateral') { skipNextRestore = false; diff --git a/src/libs/restoreFocusWithModality.ts b/src/libs/restoreFocusWithModality.ts index 3de6bddb04b1..c9a382ec029b 100644 --- a/src/libs/restoreFocusWithModality.ts +++ b/src/libs/restoreFocusWithModality.ts @@ -6,14 +6,14 @@ import getHadTabNavigation from './hadTabNavigation'; * `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): void { +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: true, focusVisible: getHadTabNavigation()}); + el.focus({preventScroll, focusVisible: getHadTabNavigation()}); } finally { if (parentTrap && !wasAlreadyPaused) { parentTrap.unpause(); diff --git a/tests/unit/Navigation/TransitionTrackerTest.ts b/tests/unit/Navigation/TransitionTrackerTest.ts index 738e5bc453fe..f513d21133d6 100644 --- a/tests/unit/Navigation/TransitionTrackerTest.ts +++ b/tests/unit/Navigation/TransitionTrackerTest.ts @@ -97,18 +97,15 @@ describe('TransitionTracker', () => { drainTransitions(); }); - it('waitForUpcomingTransition ignores non-navigation transitions and waits for an upcoming navigation start (keyboard / modal concurrent with back-nav)', async () => { + it("waitForUpcomingTransition: 'navigation' ignores non-navigation transitions and waits for an upcoming navigation start", async () => { const callback = jest.fn(); - // Keyboard or modal animation is active when the back-nav fires its scheduleRestore. const otherHandle = TransitionTracker.startTransition(); - TransitionTracker.runAfterTransitions({callback, waitForUpcomingTransition: true}); + TransitionTracker.runAfterTransitions({callback, waitForUpcomingTransition: 'navigation'}); - // The non-nav transition ends before the nav transition has even started — callback must NOT fire yet. TransitionTracker.endTransition(otherHandle); await Promise.resolve(); expect(callback).not.toHaveBeenCalled(); - // Nav transition starts, then ends — only now does the callback fire. const navHandle = TransitionTracker.startTransition('navigation'); await Promise.resolve(); await Promise.resolve(); @@ -118,6 +115,19 @@ describe('TransitionTracker', () => { 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 fires callback after timeout if transitionStart never arrives', async () => { const callback = jest.fn(); TransitionTracker.runAfterTransitions({callback, waitForUpcomingTransition: true}); diff --git a/tests/unit/NavigationFocusReturnNativeTest.ts b/tests/unit/NavigationFocusReturnNativeTest.ts index 76763f92b2af..776a438decbf 100644 --- a/tests/unit/NavigationFocusReturnNativeTest.ts +++ b/tests/unit/NavigationFocusReturnNativeTest.ts @@ -50,14 +50,14 @@ jest.mock('../../src/libs/Accessibility/fireFocusEvent', () => ({ AccessibilityInfo.sendAccessibilityEvent = mockSendAccessibilityEvent; -type TtEntry = {cb: () => void; cancelled: boolean; waitForUpcomingTransition: boolean}; +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}) => { + runAfterTransitions: ({callback, waitForUpcomingTransition = false}: {callback: () => void; waitForUpcomingTransition?: boolean | 'navigation'}) => { const entry: TtEntry = {cb: callback, cancelled: false, waitForUpcomingTransition}; mockTtQueue.push(entry); return { @@ -301,7 +301,7 @@ describe('handleStateChange — backward', () => { ); handleStateChange(stackState(0, [{key: 'profile', name: 'Profile'}])); - expect(mockTtQueue.at(-1)?.waitForUpcomingTransition).toBe(true); + expect(mockTtQueue.at(-1)?.waitForUpcomingTransition).toBe('navigation'); }); it('does NOT restore when skipNextFocusRestore was called before goBack (form-submit path)', () => { diff --git a/tests/unit/NavigationFocusReturnTest.ts b/tests/unit/NavigationFocusReturnTest.ts index b2f4f750f76e..87578787d1e3 100644 --- a/tests/unit/NavigationFocusReturnTest.ts +++ b/tests/unit/NavigationFocusReturnTest.ts @@ -1,14 +1,14 @@ // 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}; +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}) => { + runAfterTransitions: ({callback, waitForUpcomingTransition = false}: {callback: () => void; waitForUpcomingTransition?: boolean | 'navigation'}) => { const entry: TtEntry = {cb: callback, cancelled: false, waitForUpcomingTransition}; mockTtQueue.push(entry); return { diff --git a/tests/unit/restoreFocusWithModalityTest.ts b/tests/unit/restoreFocusWithModalityTest.ts index f0ee021f2a44..1c4ae75a2f13 100644 --- a/tests/unit/restoreFocusWithModalityTest.ts +++ b/tests/unit/restoreFocusWithModalityTest.ts @@ -13,7 +13,7 @@ jest.mock('@components/FocusTrap/sharedTrapStack', () => ({ })); const mockTrapStack = require<{default: MockTrap[]}>('@components/FocusTrap/sharedTrapStack').default; -const restoreFocusWithModality = require<{default: (el: HTMLElement) => void}>('@libs/restoreFocusWithModality').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()}; @@ -53,6 +53,15 @@ describe('restoreFocusWithModality', () => { 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'); From 48a61adc721fa99fe98a7dcc5b966ac429120e2c Mon Sep 17 00:00:00 2001 From: TaduJR Date: Tue, 23 Jun 2026 12:42:20 +0300 Subject: [PATCH 69/89] fix: gate fall-through on nav-active only; stable IDs for confirmation rows & merchant list --- .../configuration/SpendRuleMerchantsBase.tsx | 5 ++--- .../SubStepForms/ConfirmationStep.tsx | 2 +- src/libs/Navigation/TransitionTracker.ts | 5 ++--- tests/unit/Navigation/TransitionTrackerTest.ts | 18 ++++++++++++++++++ 4 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/components/SpendRules/configuration/SpendRuleMerchantsBase.tsx b/src/components/SpendRules/configuration/SpendRuleMerchantsBase.tsx index d4b89198055f..fcaa2ac80476 100644 --- a/src/components/SpendRules/configuration/SpendRuleMerchantsBase.tsx +++ b/src/components/SpendRules/configuration/SpendRuleMerchantsBase.tsx @@ -79,9 +79,8 @@ function SpendRuleMerchantsBase({policyID, action, merchantMatchTypes, merchantN {merchantNames.length > 0 ? ( merchantNames.map((merchantName, index) => ( ( { 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}); From 640ce5fe4368c95c4d1736bb0d4984bc74047ec5 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Tue, 23 Jun 2026 14:50:12 +0300 Subject: [PATCH 70/89] fix: iOS resume, duplicates, exception safety --- .../FocusTrap/FocusTrapForModal/index.web.tsx | 4 +- .../configuration/SpendRuleMerchantsBase.tsx | 47 ++++++++++-------- .../SubStepForms/ConfirmationStep.tsx | 30 +++++++----- src/hooks/useAccessibilityFocus/index.ts | 49 ++++++++++++------- src/libs/Accessibility/index.ts | 43 +++++++++------- src/libs/Navigation/TransitionTracker.ts | 8 ++- src/libs/restoreFocusWithModality.ts | 1 + .../spendRules/SpendRuleMerchantsPage.tsx | 4 +- .../SpendRules/SpendRuleMerchantsPage.tsx | 4 +- tests/unit/FocusTrapForModalTest.tsx | 28 +++++++++++ .../unit/Navigation/TransitionTrackerTest.ts | 6 +++ .../unit/libs/Accessibility/warmCacheTest.ts | 34 ++++++++++++- tests/unit/restoreFocusWithModalityTest.ts | 14 ++++++ tests/unit/useAccessibilityFocusTest.tsx | 36 ++++++++++++++ 14 files changed, 232 insertions(+), 76 deletions(-) diff --git a/src/components/FocusTrap/FocusTrapForModal/index.web.tsx b/src/components/FocusTrap/FocusTrapForModal/index.web.tsx index c4ae9bf383f6..5f85784a80aa 100644 --- a/src/components/FocusTrap/FocusTrapForModal/index.web.tsx +++ b/src/components/FocusTrap/FocusTrapForModal/index.web.tsx @@ -29,11 +29,11 @@ function FocusTrapForModal({children, active, initialFocus = false, shouldPreven if (!launcher) { return; } + // 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}); } - // Deferred so popover paths that navigate after modal-hide can still consume. - markActivePopoverLauncherDeactivated(launcher); }, preventScroll: shouldPreventScroll, trapStack: sharedTrapStack, diff --git a/src/components/SpendRules/configuration/SpendRuleMerchantsBase.tsx b/src/components/SpendRules/configuration/SpendRuleMerchantsBase.tsx index fcaa2ac80476..d79e1c551bca 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,26 @@ 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) => { + const rowId = `merchant-${index}-${matchType ?? 'NONE'}-${name}`; + return ( + navigateToMerchantEdit(String(index))} + shouldShowRightIcon + title={name} + titleStyle={styles.flex1} + sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.MERCHANT_RULE_SECTION_ITEM} + /> + ); + }) ) : ( {pageTitle} - {summaryItems.map(({description, title, shouldShowRightIcon, onPress, brickRoadIndicator, errorText, testID}) => ( - - ))} + {summaryItems.map(({id, description, title, shouldShowRightIcon, onPress, brickRoadIndicator, errorText, testID}) => { + const stableId = id ?? `${title}_${description}`; + return ( + + ); + })} {showOnfidoLinks && ( diff --git a/src/hooks/useAccessibilityFocus/index.ts b/src/hooks/useAccessibilityFocus/index.ts index f07aa80e05e6..974364ff7c91 100644 --- a/src/hooks/useAccessibilityFocus/index.ts +++ b/src/hooks/useAccessibilityFocus/index.ts @@ -1,6 +1,7 @@ import {useEffect} from 'react'; import FOCUSABLE_SELECTOR from '@libs/focusableSelector'; import isHTMLElement from '@libs/isHTMLElement'; +import Log from '@libs/Log'; import markProgrammaticFocus from '@libs/programmaticFocus'; import {Priorities, resetCycle, tryClaim} from '@libs/ScreenFocusArbiter'; import type UseAccessibilityFocus from './type'; @@ -29,27 +30,41 @@ const useAccessibilityFocus: UseAccessibilityFocus = ({didScreenTransitionEnd, i return; } - // Release after the focus call so a same-tree sub-modal's INITIAL claim isn't blocked when no nav state change runs handleStateChange's resetCycle. - 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; - } + // 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; + } - if (focusTarget === activeElement) { - break; - } + if (focusTarget === activeElement) { + break; + } - const unmarkProgrammaticFocus = markProgrammaticFocus(focusTarget); - 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))) { - break; - } + const focusedElement = document.activeElement; + if (focusedElement === focusTarget || (focusedElement && focusTarget.contains(focusedElement))) { + break; + } - unmarkProgrammaticFocus(); + unmarkProgrammaticFocus(); + } + } catch (error) { + Log.warn('[useAccessibilityFocus] focus walk threw', {error}); } resetCycle(); }, [didScreenTransitionEnd, isFocused, ref, shouldMoveAccessibilityFocus]); diff --git a/src/libs/Accessibility/index.ts b/src/libs/Accessibility/index.ts index 90a826d0d269..0bfedc792cfb 100644 --- a/src/libs/Accessibility/index.ts +++ b/src/libs/Accessibility/index.ts @@ -1,5 +1,5 @@ import {useCallback, useState, useSyncExternalStore} from 'react'; -import type {AppStateStatus, LayoutChangeEvent} from 'react-native'; +import type {LayoutChangeEvent} from 'react-native'; import {AccessibilityInfo, AppState} from 'react-native'; import Log from '@libs/Log'; import isScreenReaderEnabled from './isScreenReaderEnabled'; @@ -123,6 +123,8 @@ const { 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; @@ -133,6 +135,7 @@ function resetForTests() { reduceMotionSubscribers.clear(); appStateSubscription?.remove(); appStateSubscription = null; + wasBackgroundedSinceLastActive = false; } function subscribeReduceMotion(callback: () => void) { @@ -156,30 +159,36 @@ function subscribeReduceMotion(callback: () => void) { } /* - * Re-warm both caches on background→active because change events fired while the JS thread was suspended aren't reliably delivered on resume. - * Skip iOS 'inactive' transitions (Notification Center, Control Center, banners) — those don't suspend JS. + * 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. */ -let previousAppStateStatus: AppStateStatus = AppState.currentState ?? 'active'; appStateSubscription = AppState.addEventListener('change', (status) => { - const wasBackgrounded = previousAppStateStatus === 'background'; - previousAppStateStatus = status; - if (!wasBackgrounded || status !== 'active') { + if (status === 'background') { + wasBackgroundedSinceLastActive = true; return; } + if (status !== 'active' || !wasBackgroundedSinceLastActive) { + return; + } + wasBackgroundedSinceLastActive = false; const prevScreenReader = cachedScreenReaderValue; const prevReduceMotion = cachedReduceMotionValue; - Promise.all([refreshScreenReaderWarm(), refreshReduceMotionWarm()]).then(() => { - if (cachedScreenReaderValue !== prevScreenReader) { - for (const cb of screenReaderSubscribers) { - cb(); + Promise.all([refreshScreenReaderWarm(), refreshReduceMotionWarm()]) + .then(() => { + if (cachedScreenReaderValue !== prevScreenReader) { + for (const cb of screenReaderSubscribers) { + cb(); + } } - } - if (cachedReduceMotionValue !== prevReduceMotion) { - for (const cb of reduceMotionSubscribers) { - cb(); + if (cachedReduceMotionValue !== prevReduceMotion) { + for (const cb of reduceMotionSubscribers) { + cb(); + } } - } - }); + }) + .catch((error: unknown) => { + Log.warn('[Accessibility] AppState refresh notify threw', {error}); + }); }); function getReduceMotionSnapshot() { diff --git a/src/libs/Navigation/TransitionTracker.ts b/src/libs/Navigation/TransitionTracker.ts index 92ac6c1715d5..5a6b2becfe81 100644 --- a/src/libs/Navigation/TransitionTracker.ts +++ b/src/libs/Navigation/TransitionTracker.ts @@ -132,7 +132,7 @@ function endTransition(handle: TransitionHandle): void { /** * 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. @@ -141,6 +141,10 @@ function endTransition(handle: TransitionHandle): void { * @returns A handle with a `cancel` method to prevent the callback from firing. */ function runAfterTransitions({callback, runImmediately = false, waitForUpcomingTransition = false}: RunAfterTransitionsOptions): CancelHandle { + 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) { @@ -171,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/restoreFocusWithModality.ts b/src/libs/restoreFocusWithModality.ts index c9a382ec029b..08beeebaaee9 100644 --- a/src/libs/restoreFocusWithModality.ts +++ b/src/libs/restoreFocusWithModality.ts @@ -15,6 +15,7 @@ function restoreFocusWithModality(el: HTMLElement, {preventScroll = true}: {prev try { el.focus({preventScroll, focusVisible: getHadTabNavigation()}); } finally { + // Unconditional because focus-trap clears `manuallyPaused = false` even when not topmost — the next trap's deactivate auto-unwind depends on it. if (parentTrap && !wasAlreadyPaused) { parentTrap.unpause(); } diff --git a/src/pages/workspace/expensifyCard/issueNew/spendRules/SpendRuleMerchantsPage.tsx b/src/pages/workspace/expensifyCard/issueNew/spendRules/SpendRuleMerchantsPage.tsx index de812a119d02..13215597343b 100644 --- a/src/pages/workspace/expensifyCard/issueNew/spendRules/SpendRuleMerchantsPage.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/spendRules/SpendRuleMerchantsPage.tsx @@ -19,6 +19,7 @@ export default function SpendRuleMerchantsPage({route}: SpendRuleMerchantsPagePr const merchantNames = issueNewCardForm?.data.spendRuleValue?.merchantNames ?? []; const merchantMatchTypes = issueNewCardForm?.data.spendRuleValue?.merchantMatchTypes ?? []; const restrictionAction = issueNewCardForm?.data.spendRuleValue?.restrictionAction ?? CONST.SPEND_RULES.ACTION.ALLOW; + const merchants = merchantNames.map((name, index) => ({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/rules/SpendRules/SpendRuleMerchantsPage.tsx b/src/pages/workspace/rules/SpendRules/SpendRuleMerchantsPage.tsx index 29c549f065d6..ce524022c519 100644 --- a/src/pages/workspace/rules/SpendRules/SpendRuleMerchantsPage.tsx +++ b/src/pages/workspace/rules/SpendRules/SpendRuleMerchantsPage.tsx @@ -17,13 +17,13 @@ function SpendRuleMerchantsPage({route}: SpendRuleMerchantsPageProps) { const merchantNames = spendRuleForm?.merchantNames ?? []; const merchantMatchTypes = spendRuleForm?.merchantMatchTypes ?? []; const restrictionAction = spendRuleForm?.restrictionAction ?? CONST.SPEND_RULES.ACTION.ALLOW; + const merchants = merchantNames.map((name, index) => ({name, matchType: merchantMatchTypes.at(index)})); return ( ROUTES.RULES_SPEND_MERCHANT_EDIT.getRoute(policyID, ruleID, merchantIndex)} /> ); diff --git a/tests/unit/FocusTrapForModalTest.tsx b/tests/unit/FocusTrapForModalTest.tsx index cc9112637227..117304d99ea5 100644 --- a/tests/unit/FocusTrapForModalTest.tsx +++ b/tests/unit/FocusTrapForModalTest.tsx @@ -19,6 +19,12 @@ 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[]) => 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'); @@ -37,6 +43,7 @@ describe('FocusTrapForModal — launcher capture', () => { capturedOptions = null; (setActivePopoverLauncher as jest.Mock).mockClear(); (markActivePopoverLauncherDeactivated as jest.Mock).mockClear(); + mockRestoreFocusWithModality.mockReset(); document.body.innerHTML = ''; }); @@ -78,6 +85,27 @@ describe('FocusTrapForModal — launcher capture', () => { 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)', () => { render({null}); diff --git a/tests/unit/Navigation/TransitionTrackerTest.ts b/tests/unit/Navigation/TransitionTrackerTest.ts index 2fb70c4c3ff2..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(); diff --git a/tests/unit/libs/Accessibility/warmCacheTest.ts b/tests/unit/libs/Accessibility/warmCacheTest.ts index d8352099c0d1..341e388d96c6 100644 --- a/tests/unit/libs/Accessibility/warmCacheTest.ts +++ b/tests/unit/libs/Accessibility/warmCacheTest.ts @@ -12,6 +12,7 @@ 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'); @@ -43,7 +44,9 @@ jest.mock('react-native', () => ({ } return {remove: jest.fn()}; }), - currentState: 'active', + get currentState() { + return mockInitialAppState; + }, }, })); @@ -54,6 +57,7 @@ beforeEach(() => { mockReduceMotionValue = false; mockReduceMotionFetchCount = 0; mockScreenReaderDeferred = false; + mockInitialAppState = 'active'; mockScreenReaderResolvers.length = 0; }); @@ -88,6 +92,34 @@ describe('Accessibility warm cache — AppState refresh', () => { 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(); diff --git a/tests/unit/restoreFocusWithModalityTest.ts b/tests/unit/restoreFocusWithModalityTest.ts index 1c4ae75a2f13..95747a720ae7 100644 --- a/tests/unit/restoreFocusWithModalityTest.ts +++ b/tests/unit/restoreFocusWithModalityTest.ts @@ -112,4 +112,18 @@ describe('restoreFocusWithModality', () => { 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/useAccessibilityFocusTest.tsx b/tests/unit/useAccessibilityFocusTest.tsx index c542703e9537..d97374b98892 100644 --- a/tests/unit/useAccessibilityFocusTest.tsx +++ b/tests/unit/useAccessibilityFocusTest.tsx @@ -1,6 +1,20 @@ 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; @@ -40,6 +54,7 @@ function Harness({ beforeEach(() => { document.body.innerHTML = ''; resetCycle(); + mockLogWarn.mockClear(); }); describe('useAccessibilityFocus — arbiter integration', () => { @@ -112,4 +127,25 @@ describe('useAccessibilityFocus — arbiter integration', () => { 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); + }); }); From 1f78b41c666c01748c98b6d762a24d00ff1a85e5 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Tue, 23 Jun 2026 16:19:11 +0300 Subject: [PATCH 71/89] fix: defer PUSH_PARAMS attempt; decouple row IDs from edited fields --- .../configuration/SpendRuleMerchantsBase.tsx | 3 +- .../SubStepForms/ConfirmationStep.tsx | 5 +- .../NavigationFocusReturn/index.native.ts | 7 +- src/libs/NavigationFocusReturn/index.ts | 7 +- .../SpendRules/SpendRuleMerchantsBaseTest.tsx | 82 ++++++++++++++ .../SubStepForms/ConfirmationStepTest.tsx | 102 ++++++++++++++++++ tests/unit/NavigationFocusReturnNativeTest.ts | 40 +++++++ tests/unit/NavigationFocusReturnTest.ts | 71 ++++++++++++ 8 files changed, 312 insertions(+), 5 deletions(-) create mode 100644 tests/ui/components/SpendRules/SpendRuleMerchantsBaseTest.tsx create mode 100644 tests/ui/components/SubStepForms/ConfirmationStepTest.tsx diff --git a/src/components/SpendRules/configuration/SpendRuleMerchantsBase.tsx b/src/components/SpendRules/configuration/SpendRuleMerchantsBase.tsx index d79e1c551bca..772600fd6ed6 100644 --- a/src/components/SpendRules/configuration/SpendRuleMerchantsBase.tsx +++ b/src/components/SpendRules/configuration/SpendRuleMerchantsBase.tsx @@ -82,7 +82,8 @@ function SpendRuleMerchantsBase({policyID, action, merchants, getEditMerchantRou /> {merchants.length > 0 ? ( merchants.map(({name, matchType}, index) => { - const rowId = `merchant-${index}-${matchType ?? 'NONE'}-${name}`; + // `name`/`matchType` are edited on the detail screen — index is the stable identity (already what the edit route uses). + const rowId = `merchant-${index}`; return ( {pageTitle} - {summaryItems.map(({id, description, title, shouldShowRightIcon, onPress, brickRoadIndicator, errorText, testID}) => { - const stableId = id ?? `${title}_${description}`; + {summaryItems.map(({id, description, title, shouldShowRightIcon, onPress, brickRoadIndicator, errorText, testID}, index) => { + // `title` is the edited value — embedding it would shift the identifier across edits and miss the focus-return lookup. `description` is stable; index disambiguates. + const stableId = id ?? `${description}-${index}`; return ( ; +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/NavigationFocusReturnNativeTest.ts b/tests/unit/NavigationFocusReturnNativeTest.ts index 776a438decbf..ec2509d4f531 100644 --- a/tests/unit/NavigationFocusReturnNativeTest.ts +++ b/tests/unit/NavigationFocusReturnNativeTest.ts @@ -290,6 +290,21 @@ describe('handleStateChange — backward', () => { 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'}])); @@ -607,6 +622,7 @@ describe('PUSH_PARAMS — same-route param change', () => { // 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); }); @@ -652,6 +668,7 @@ describe('PUSH_PARAMS — same-route param change', () => { notifyPushParamsBackward(ROUTE_KEY, {q: 'old'}); flushTransitions(); + jest.advanceTimersByTime(20); expect(mockFireFocusEvent).toHaveBeenCalledWith(liveView); }); @@ -673,6 +690,29 @@ describe('PUSH_PARAMS — same-route param change', () => { 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', () => { diff --git a/tests/unit/NavigationFocusReturnTest.ts b/tests/unit/NavigationFocusReturnTest.ts index 87578787d1e3..41981cbffa39 100644 --- a/tests/unit/NavigationFocusReturnTest.ts +++ b/tests/unit/NavigationFocusReturnTest.ts @@ -1176,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); @@ -1742,6 +1762,7 @@ describe('PUSH_PARAMS notifications', () => { const spy = jest.spyOn(trigger, 'focus'); notifyPushParamsBackward('search-x', {q: 'foo'}); flushTransitions(); + jest.runAllTimers(); expect(spy).toHaveBeenCalled(); }); }); @@ -1794,6 +1815,56 @@ describe('PUSH_PARAMS notifications', () => { 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(); + }); + }); }); describe('teardown / setup lifecycle', () => { From 74424d63722d77b2839e7dca61cd93f139fbbe59 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Tue, 23 Jun 2026 16:54:15 +0300 Subject: [PATCH 72/89] chore: CI restart From cf697bb6b7f880c80c312837003986e54f87ccab Mon Sep 17 00:00:00 2001 From: TaduJR Date: Tue, 23 Jun 2026 16:54:15 +0300 Subject: [PATCH 73/89] chore: CI restart --- config/eslint/eslint.seatbelt.tsv | 1 - src/components/DialogLabelContext.tsx | 4 ++++ src/libs/isHTMLElement.ts | 4 ++-- tests/unit/DialogLabelContextTest.tsx | 23 +++++++++++++++++++++++ 4 files changed, 29 insertions(+), 3 deletions(-) diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index b831a4444f22..dbcbeb70b6da 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -694,7 +694,6 @@ "../../src/libs/Navigation/linkingConfig/RELATIONS/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/libs/Navigation/linkingConfig/config.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/Navigation/navigateAfterInteraction/index.ios.ts" "@typescript-eslint/no-deprecated/InteractionManager.runAfterInteractions" 1 -"../../src/libs/NavigationFocusReturn.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/Network/LoadTestState.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/Network/SequentialQueue.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/Network/enhanceParameters.ts" "no-restricted-syntax" 1 diff --git a/src/components/DialogLabelContext.tsx b/src/components/DialogLabelContext.tsx index 3a47b57b9141..55dfec4d20a2 100644 --- a/src/components/DialogLabelContext.tsx +++ b/src/components/DialogLabelContext.tsx @@ -38,6 +38,10 @@ function DialogLabelProvider({children, containerRef}: DialogLabelProviderProps) const initialFocusClaimedRef = useRef(false); const updateContainerLabel = () => { + // `aria-label` is a DOM contract; bail before any node access on native. + if (typeof document === 'undefined') { + return; + } const top = labelStackRef.current.at(-1); const node = containerRef.current; if (!isHTMLElement(node)) { diff --git a/src/libs/isHTMLElement.ts b/src/libs/isHTMLElement.ts index 518466f6e85a..24ce49fe17a7 100644 --- a/src/libs/isHTMLElement.ts +++ b/src/libs/isHTMLElement.ts @@ -1,6 +1,6 @@ -/** Typed guard. `typeof HTMLElement` check keeps it safe on native, where the global is undefined. */ +/** 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; + return typeof HTMLElement !== 'undefined' && value instanceof HTMLElement && typeof value.getAttribute === 'function'; } export default isHTMLElement; diff --git a/tests/unit/DialogLabelContextTest.tsx b/tests/unit/DialogLabelContextTest.tsx index 02b1d8a28456..3c4c0e1c8876 100644 --- a/tests/unit/DialogLabelContextTest.tsx +++ b/tests/unit/DialogLabelContextTest.tsx @@ -68,6 +68,29 @@ 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'); From c5357af9532bb90602bb0078c3771845dc76c8e9 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Tue, 23 Jun 2026 19:07:29 +0300 Subject: [PATCH 74/89] fix: annotate restoreFocusWithModality mock return as void --- tests/unit/FocusTrapForModalTest.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/unit/FocusTrapForModalTest.tsx b/tests/unit/FocusTrapForModalTest.tsx index 117304d99ea5..77a91023476e 100644 --- a/tests/unit/FocusTrapForModalTest.tsx +++ b/tests/unit/FocusTrapForModalTest.tsx @@ -22,7 +22,9 @@ jest.mock('@libs/Accessibility/blurActiveElement', () => ({__esModule: true, def const mockRestoreFocusWithModality = jest.fn(); jest.mock('@libs/restoreFocusWithModality', () => ({ __esModule: true, - default: (...args: unknown[]) => mockRestoreFocusWithModality(...args), + default: (...args: unknown[]): void => { + mockRestoreFocusWithModality(...args); + }, })); // document.activeElement isn't settable under the RN-web test harness — stub via Document.prototype descriptor. From f33b542ab372f35817693598ecaad933ed4bacfe Mon Sep 17 00:00:00 2001 From: TaduJR Date: Wed, 24 Jun 2026 09:19:43 +0300 Subject: [PATCH 75/89] fix: catch stale-handle focus throws; gate chat header initial focus to SR --- src/hooks/useScreenInitialFocus/index.ts | 7 +- src/hooks/useScreenInitialFocus/types.ts | 2 + .../fireFocusEvent/index.native.ts | 8 ++- src/pages/inbox/HeaderView.tsx | 2 +- tests/unit/fireFocusEventAndroidTest.ts | 25 +++++++ tests/unit/useScreenInitialFocusTest.tsx | 71 +++++++++++++++++-- 6 files changed, 107 insertions(+), 8 deletions(-) diff --git a/src/hooks/useScreenInitialFocus/index.ts b/src/hooks/useScreenInitialFocus/index.ts index 3092d6deec34..aad219f2566a 100644 --- a/src/hooks/useScreenInitialFocus/index.ts +++ b/src/hooks/useScreenInitialFocus/index.ts @@ -1,6 +1,7 @@ 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'; @@ -31,6 +32,7 @@ const useScreenInitialFocus: UseScreenInitialFocus = (node, options) => { const {isInsideDialog} = useDialogLabelData(); const claimedRef = useRef(false); const skip = options?.skip ?? false; + const claimOnlyForScreenReader = options?.claimOnlyForScreenReader ?? false; useEffect(() => { if (skip || isInsideDialog) { @@ -49,6 +51,9 @@ const useScreenInitialFocus: UseScreenInitialFocus = (node, options) => { if (hasHoverSupport() && !getHadTabNavigation()) { return; } + if (claimOnlyForScreenReader && Accessibility.isScreenReaderKnownOff()) { + return; + } let rafId: number | null = null; let framesLeft = MAX_INITIAL_FOCUS_FRAMES; const attempt = () => { @@ -69,7 +74,7 @@ const useScreenInitialFocus: UseScreenInitialFocus = (node, options) => { } cancelAnimationFrame(rafId); }; - }, [skip, isInsideDialog, status?.didScreenTransitionEnd, node]); + }, [skip, claimOnlyForScreenReader, isInsideDialog, status?.didScreenTransitionEnd, node]); }; export default useScreenInitialFocus; diff --git a/src/hooks/useScreenInitialFocus/types.ts b/src/hooks/useScreenInitialFocus/types.ts index c7ddcaeaccbe..bb625835a500 100644 --- a/src/hooks/useScreenInitialFocus/types.ts +++ b/src/hooks/useScreenInitialFocus/types.ts @@ -1,6 +1,8 @@ type UseScreenInitialFocusOptions = { /** Opts the screen out of post-transition initial focus. */ skip?: 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. */ + claimOnlyForScreenReader?: boolean; }; type UseScreenInitialFocus = (node: HTMLElement | null, options?: UseScreenInitialFocusOptions) => void; diff --git a/src/libs/Accessibility/fireFocusEvent/index.native.ts b/src/libs/Accessibility/fireFocusEvent/index.native.ts index 5e4bba351ec2..8511b30b0e73 100644 --- a/src/libs/Accessibility/fireFocusEvent/index.native.ts +++ b/src/libs/Accessibility/fireFocusEvent/index.native.ts @@ -1,9 +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'; +/** A non-null JS ref doesn't mean the native handle is alive (react-native-screens detach). Catch + log so a stale-handle throw doesn't silently abort the restore. */ function fireFocusEvent(view: View | RNText): void { - AccessibilityInfo.sendAccessibilityEvent(view, 'focus'); + try { + AccessibilityInfo.sendAccessibilityEvent(view, 'focus'); + } catch (error: unknown) { + Log.warn('[fireFocusEvent] sendAccessibilityEvent threw', {error}); + } } export default fireFocusEvent; diff --git a/src/pages/inbox/HeaderView.tsx b/src/pages/inbox/HeaderView.tsx index 61745ad3b138..53c19fdaea80 100644 --- a/src/pages/inbox/HeaderView.tsx +++ b/src/pages/inbox/HeaderView.tsx @@ -126,7 +126,7 @@ function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) const {translate, localeCompare, formatPhoneNumber} = useLocalize(); const theme = useTheme(); const styles = useThemeStyles(); - const setBackButtonRef = useInitialFocusRef(); + const setBackButtonRef = useInitialFocusRef({claimOnlyForScreenReader: true}); const isSelfDM = isSelfDMReportUtils(report); const isGroupChat = isGroupChatReportUtils(report) || isDeprecatedGroupDM(report, isReportArchived); const isConciergeChat = isConciergeChatReport(report, conciergeReportID); diff --git a/tests/unit/fireFocusEventAndroidTest.ts b/tests/unit/fireFocusEventAndroidTest.ts index adbafbedd3e6..75ffa84fd27f 100644 --- a/tests/unit/fireFocusEventAndroidTest.ts +++ b/tests/unit/fireFocusEventAndroidTest.ts @@ -1,5 +1,19 @@ 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; @@ -7,6 +21,7 @@ const fireFocusEvent = require<{default: (view: unknown) => void}>('../../src/li beforeEach(() => { mockSendAccessibilityEvent.mockClear(); + mockLogWarn.mockClear(); }); describe('fireFocusEvent (native)', () => { @@ -16,4 +31,14 @@ describe('fireFocusEvent (native)', () => { 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/useScreenInitialFocusTest.tsx b/tests/unit/useScreenInitialFocusTest.tsx index 65cfa4581e31..e348b703e853 100644 --- a/tests/unit/useScreenInitialFocusTest.tsx +++ b/tests/unit/useScreenInitialFocusTest.tsx @@ -8,8 +8,22 @@ jest.mock('@libs/DeviceCapabilities/hasHoverSupport', () => ({ default: () => mockHasHoverSupport, })); +let mockIsScreenReaderKnownOff = false; +jest.mock('@libs/Accessibility', () => ({ + __esModule: true, + default: { + isScreenReaderKnownOff: () => mockIsScreenReaderKnownOff, + isScreenReaderEnabledSync: () => !mockIsScreenReaderKnownOff, + useScreenReaderStatus: () => !mockIsScreenReaderKnownOff, + useReducedMotion: () => false, + moveAccessibilityFocus: jest.fn(), + }, +})); + /* eslint-disable import/extensions */ -const {default: useScreenInitialFocus} = require<{default: (node: HTMLElement | null, options?: {skip?: boolean}) => void}>('../../src/hooks/useScreenInitialFocus/index.ts'); +const {default: useScreenInitialFocus} = require<{ + default: (node: HTMLElement | null, options?: {skip?: boolean; claimOnlyForScreenReader?: boolean}) => void; +}>('../../src/hooks/useScreenInitialFocus/index.ts'); const {resetCycle: resetArbiter, tryClaim: arbiterClaim, Priorities: arbiterPriorities} = require<{ resetCycle: () => void; tryClaim: (priority: 1 | 2 | 3) => boolean; @@ -30,21 +44,23 @@ function simulatePointer() { document.dispatchEvent(new Event('pointerdown', {bubbles: true})); } -type HarnessProps = {target: HTMLElement | null; didScreenTransitionEnd: boolean; skip?: boolean}; +type HarnessProps = {target: HTMLElement | null; didScreenTransitionEnd: boolean; skip?: boolean; claimOnlyForScreenReader?: boolean}; -function MountedHarness({target, didScreenTransitionEnd, skip}: HarnessProps) { +function MountedHarness({target, didScreenTransitionEnd, skip, claimOnlyForScreenReader}: HarnessProps) { const contextValue = useMemo(() => ({didScreenTransitionEnd, isSafeAreaTopPaddingApplied: false, isSafeAreaBottomPaddingApplied: false}), [didScreenTransitionEnd]); return ( ); } -function Inner({target, skip}: {target: HTMLElement | null; skip?: boolean}) { - useScreenInitialFocus(target, skip === undefined ? undefined : {skip}); +function Inner({target, skip, claimOnlyForScreenReader}: {target: HTMLElement | null; skip?: boolean; claimOnlyForScreenReader?: boolean}) { + const options = skip === undefined && claimOnlyForScreenReader === undefined ? undefined : {skip, claimOnlyForScreenReader}; + useScreenInitialFocus(target, options); return null; } @@ -69,6 +85,7 @@ beforeEach(() => { document.body.innerHTML = ''; resetArbiter(); mockHasHoverSupport = true; + mockIsScreenReaderKnownOff = false; teardownHadTabNavigation(); setupHadTabNavigation(); }); @@ -255,4 +272,48 @@ describe('useScreenInitialFocus', () => { ); expect(spy).not.toHaveBeenCalled(); }); + + it('claimOnlyForScreenReader=true + SR known-off → bails (keyboard user does not see a ring flash before the screen auto-focuses its own target)', () => { + mockIsScreenReaderKnownOff = true; + simulateTab(); + const button = makeButton(); + const spy = jest.spyOn(button, 'focus'); + render( + , + ); + expect(spy).not.toHaveBeenCalled(); + }); + + it('claimOnlyForScreenReader=true + SR on → claims (TalkBack/VoiceOver needs back-button orientation while the composer is delayed)', () => { + mockIsScreenReaderKnownOff = false; + simulateTab(); + const button = makeButton(); + const spy = jest.spyOn(button, 'focus'); + render( + , + ); + expect(spy).toHaveBeenCalledWith({preventScroll: true, focusVisible: true}); + }); + + it('claimOnlyForScreenReader=false (default) preserves the unconditional claim path so non-chat headers still focus for keyboard users', () => { + mockIsScreenReaderKnownOff = true; + simulateTab(); + const button = makeButton(); + const spy = jest.spyOn(button, 'focus'); + render( + , + ); + expect(spy).toHaveBeenCalledTimes(1); + }); }); From 8db84abec1fafcbca5aa9199e684f66f97a4e6d3 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Wed, 24 Jun 2026 09:53:32 +0300 Subject: [PATCH 76/89] fix: register pressable during SR warm-up to match capture-side gate --- .../implementation/BaseGenericPressable.tsx | 5 +- src/libs/Accessibility/index.ts | 8 ++ .../BaseGenericPressableRegistryGateTest.tsx | 113 ++++++++++++++++ .../useIsScreenReaderKnownOffTest.tsx | 121 ++++++++++++++++++ 4 files changed, 245 insertions(+), 2 deletions(-) create mode 100644 tests/unit/BaseGenericPressableRegistryGateTest.tsx create mode 100644 tests/unit/libs/Accessibility/useIsScreenReaderKnownOffTest.tsx diff --git a/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx b/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx index da250e6c6671..b2f9f6bf067d 100644 --- a/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx +++ b/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx @@ -49,6 +49,7 @@ function GenericPressable({ const StyleUtils = useStyleUtils(); const {isExecuting, singleExecution} = useSingleExecution(); const isScreenReaderActive = Accessibility.useScreenReaderStatus(); + const isScreenReaderKnownOff = Accessibility.useIsScreenReaderKnownOff(); const [hitSlop, onLayout] = Accessibility.useAutoHitSlop(); const [isHovered, setIsHovered] = useState(false); const isRoleButton = [rest.accessibilityRole, rest.role].includes(CONST.ROLE.BUTTON); @@ -59,11 +60,11 @@ function GenericPressable({ const focusIdentifier = rest.id || rest.nativeID || rest.testID || undefined; useEffect(() => { - if (!isScreenReaderActive || !routeKey || !focusIdentifier) { + if (isScreenReaderKnownOff || !routeKey || !focusIdentifier) { return; } return registerPressable(routeKey, focusIdentifier, internalRef); - }, [isScreenReaderActive, routeKey, focusIdentifier]); + }, [isScreenReaderKnownOff, routeKey, focusIdentifier]); const isDisabled = useMemo(() => { let shouldBeDisabledByScreenReader = false; diff --git a/src/libs/Accessibility/index.ts b/src/libs/Accessibility/index.ts index 0bfedc792cfb..ed4126327fa0 100644 --- a/src/libs/Accessibility/index.ts +++ b/src/libs/Accessibility/index.ts @@ -107,6 +107,13 @@ function isScreenReaderKnownOff(): boolean { return isScreenReaderCacheWarm() && !cachedScreenReaderValue; } +function getIsScreenReaderKnownOffSnapshot(): boolean { + return isScreenReaderKnownOff(); +} + +/** Reactive variant of {@link isScreenReaderKnownOff} — for effects that need the tri-state predicate (warm-up = unknown). */ +const useIsScreenReaderKnownOff = (): boolean => useSyncExternalStore(subscribeScreenReader, getIsScreenReaderKnownOffSnapshot, () => false); + let cachedReduceMotionValue = false; const reduceMotionSubscribers = new Set<() => void>(); const { @@ -229,6 +236,7 @@ export {resetForTests}; export default { moveAccessibilityFocus, useScreenReaderStatus, + useIsScreenReaderKnownOff, useAutoHitSlop, useReducedMotion, isScreenReaderEnabledSync, diff --git a/tests/unit/BaseGenericPressableRegistryGateTest.tsx b/tests/unit/BaseGenericPressableRegistryGateTest.tsx new file mode 100644 index 000000000000..ee448af9fb9c --- /dev/null +++ b/tests/unit/BaseGenericPressableRegistryGateTest.tsx @@ -0,0 +1,113 @@ +import {NavigationRouteContext} from '@react-navigation/native'; +import {render} from '@testing-library/react-native'; +import React from 'react'; + +const mockRegisterPressable = jest.fn<() => void, [string, string, {current: unknown}]>(); +const mockNotifyPressedTrigger = jest.fn(); + +let mockIsScreenReaderKnownOff = false; +let mockIsScreenReaderActive = false; + +jest.mock('@libs/NavigationFocusReturn', () => ({ + __esModule: true, + registerPressable: (...args: unknown[]) => { + mockRegisterPressable(...args); + return () => {}; + }, + notifyPressedTrigger: (...args: unknown[]) => { + mockNotifyPressedTrigger(...args); + }, +})); + +jest.mock('@libs/Accessibility', () => ({ + __esModule: true, + default: { + useScreenReaderStatus: () => mockIsScreenReaderActive, + useIsScreenReaderKnownOff: () => mockIsScreenReaderKnownOff, + 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; 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(); + mockIsScreenReaderKnownOff = false; + mockIsScreenReaderActive = false; +}); + +describe('BaseGenericPressable — focus-return registry gate', () => { + it('registers during the SR warm-up window (`isScreenReaderKnownOff` is false) — symmetric with the capture-side `isScreenReaderKnownOff()` bail, so cold-start press → detach → back has a fallback target', () => { + mockIsScreenReaderKnownOff = false; + 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', () => { + mockIsScreenReaderKnownOff = true; + renderInsideRoute( + {}} + />, + ); + expect(mockRegisterPressable).not.toHaveBeenCalled(); + }); + + it('registers when SR is known-on', () => { + mockIsScreenReaderKnownOff = false; + 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)', () => { + mockIsScreenReaderKnownOff = false; + renderInsideRoute( {}} />); + expect(mockRegisterPressable).not.toHaveBeenCalled(); + }); + + it('does not register when routeKey is null (consumer outside a navigator)', () => { + mockIsScreenReaderKnownOff = false; + render( + {}} + />, + ); + expect(mockRegisterPressable).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/libs/Accessibility/useIsScreenReaderKnownOffTest.tsx b/tests/unit/libs/Accessibility/useIsScreenReaderKnownOffTest.tsx new file mode 100644 index 000000000000..09eee2eb7a0c --- /dev/null +++ b/tests/unit/libs/Accessibility/useIsScreenReaderKnownOffTest.tsx @@ -0,0 +1,121 @@ +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', + }, +})); + +const Accessibility = require<{ + default: { + useIsScreenReaderKnownOff: () => boolean; + isScreenReaderKnownOff: () => boolean; + }; + resetForTests: () => void; +}>('@libs/Accessibility'); + +async function flushPromises(): Promise { + await new Promise((resolve) => { + setImmediate(resolve); + }); +} + +beforeEach(() => { + Accessibility.resetForTests(); + mockScreenReaderResolvers.length = 0; +}); + +describe('useIsScreenReaderKnownOff', () => { + it('returns false during the warm-up window (cache not yet resolved) so callers register/capture defensively', () => { + const {result, unmount} = renderHook(() => Accessibility.default.useIsScreenReaderKnownOff()); + expect(result.current).toBe(false); + unmount(); + }); + + it('re-renders to true 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.useIsScreenReaderKnownOff(); + }); + expect(result.current).toBe(false); + const initialRenderCount = renderCount; + + await act(async () => { + mockScreenReaderResolvers[0]?.(false); + await flushPromises(); + }); + expect(result.current).toBe(true); + expect(renderCount).toBeGreaterThan(initialRenderCount); + unmount(); + }); + + it('stays false after warm resolves with SR-on — known-off is true ONLY for known-off', async () => { + const {result, unmount} = renderHook(() => Accessibility.default.useIsScreenReaderKnownOff()); + expect(result.current).toBe(false); + + await act(async () => { + mockScreenReaderResolvers[0]?.(true); + await flushPromises(); + }); + expect(result.current).toBe(false); + unmount(); + }); + + it('snapshot stays consistent with `isScreenReaderKnownOff()` across the warm-up transition', async () => { + expect(Accessibility.default.isScreenReaderKnownOff()).toBe(false); + const {result, unmount} = renderHook(() => Accessibility.default.useIsScreenReaderKnownOff()); + expect(result.current).toBe(Accessibility.default.isScreenReaderKnownOff()); + + await act(async () => { + mockScreenReaderResolvers[0]?.(false); + await flushPromises(); + }); + expect(result.current).toBe(Accessibility.default.isScreenReaderKnownOff()); + expect(result.current).toBe(true); + 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 knownOff = Accessibility.default.useIsScreenReaderKnownOff(); + const isFirstRunRef = useRef(true); + useEffect(() => { + sideEffect(knownOff); + if (isFirstRunRef.current) { + isFirstRunRef.current = false; + } + }, [knownOff]); + return knownOff; + }); + expect(sideEffect).toHaveBeenCalledTimes(1); + expect(sideEffect).toHaveBeenLastCalledWith(false); + + await act(async () => { + mockScreenReaderResolvers[0]?.(false); + await flushPromises(); + }); + expect(sideEffect).toHaveBeenCalledTimes(2); + expect(sideEffect).toHaveBeenLastCalledWith(true); + unmount(); + }); +}); From 7817c62cdd61325109aa50ef0e17e884e15ccd49 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Wed, 24 Jun 2026 10:37:49 +0300 Subject: [PATCH 77/89] fix: notify SR subscribers when AppState resume invalidates warm flag --- src/libs/Accessibility/index.ts | 25 +++++++++++-------- .../BaseGenericPressableRegistryGateTest.tsx | 4 +-- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/src/libs/Accessibility/index.ts b/src/libs/Accessibility/index.ts index ed4126327fa0..390eafed9439 100644 --- a/src/libs/Accessibility/index.ts +++ b/src/libs/Accessibility/index.ts @@ -178,19 +178,22 @@ appStateSubscription = AppState.addEventListener('change', (status) => { return; } wasBackgroundedSinceLastActive = false; - const prevScreenReader = cachedScreenReaderValue; - const prevReduceMotion = cachedReduceMotionValue; - Promise.all([refreshScreenReaderWarm(), refreshReduceMotionWarm()]) + // 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(() => { - if (cachedScreenReaderValue !== prevScreenReader) { - for (const cb of screenReaderSubscribers) { - cb(); - } + // Re-notify unconditionally: `warmed` flips back true even when value unchanged. + for (const cb of screenReaderSubscribers) { + cb(); } - if (cachedReduceMotionValue !== prevReduceMotion) { - for (const cb of reduceMotionSubscribers) { - cb(); - } + for (const cb of reduceMotionSubscribers) { + cb(); } }) .catch((error: unknown) => { diff --git a/tests/unit/BaseGenericPressableRegistryGateTest.tsx b/tests/unit/BaseGenericPressableRegistryGateTest.tsx index ee448af9fb9c..2bf8901e132b 100644 --- a/tests/unit/BaseGenericPressableRegistryGateTest.tsx +++ b/tests/unit/BaseGenericPressableRegistryGateTest.tsx @@ -10,8 +10,8 @@ let mockIsScreenReaderActive = false; jest.mock('@libs/NavigationFocusReturn', () => ({ __esModule: true, - registerPressable: (...args: unknown[]) => { - mockRegisterPressable(...args); + registerPressable: (routeKey: string, identifier: string, ref: {current: unknown}) => { + mockRegisterPressable(routeKey, identifier, ref); return () => {}; }, notifyPressedTrigger: (...args: unknown[]) => { From 43c08c25b8dd1003ddfa35b55dda18608c6161e5 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Wed, 24 Jun 2026 11:16:30 +0300 Subject: [PATCH 78/89] fix: fall through to registry rescue when stale native handle throws --- .../fireFocusEvent/index.native.ts | 6 ++- .../Accessibility/fireFocusEvent/index.ts | 4 +- .../NavigationFocusReturn/index.native.ts | 42 ++++++++++++------- tests/unit/NavigationFocusReturnNativeTest.ts | 29 ++++++++++++- tests/unit/fireFocusEventAndroidTest.ts | 10 ++--- 5 files changed, 66 insertions(+), 25 deletions(-) diff --git a/src/libs/Accessibility/fireFocusEvent/index.native.ts b/src/libs/Accessibility/fireFocusEvent/index.native.ts index 8511b30b0e73..a599b66cd802 100644 --- a/src/libs/Accessibility/fireFocusEvent/index.native.ts +++ b/src/libs/Accessibility/fireFocusEvent/index.native.ts @@ -3,12 +3,14 @@ import {AccessibilityInfo} from 'react-native'; import type {Text as RNText, View} from 'react-native'; import Log from '@libs/Log'; -/** A non-null JS ref doesn't mean the native handle is alive (react-native-screens detach). Catch + log so a stale-handle throw doesn't silently abort the restore. */ -function fireFocusEvent(view: View | RNText): void { +/** Returns false when `sendAccessibilityEvent` throws on a stale native handle — react-native-screens detach can leave the JS ref non-null while the RCTView is dead. */ +function fireFocusEvent(view: View | RNText): boolean { try { AccessibilityInfo.sendAccessibilityEvent(view, 'focus'); + return true; } catch (error: unknown) { Log.warn('[fireFocusEvent] sendAccessibilityEvent threw', {error}); + return false; } } diff --git a/src/libs/Accessibility/fireFocusEvent/index.ts b/src/libs/Accessibility/fireFocusEvent/index.ts index f4658f740373..e1e1e8f67952 100644 --- a/src/libs/Accessibility/fireFocusEvent/index.ts +++ b/src/libs/Accessibility/fireFocusEvent/index.ts @@ -2,6 +2,8 @@ import type {Text as RNText, View} from 'react-native'; // eslint-disable-next-line @typescript-eslint/no-unused-vars -function fireFocusEvent(_view: View | RNText): void {} +function fireFocusEvent(_view: View | RNText): boolean { + return true; +} export default fireFocusEvent; diff --git a/src/libs/NavigationFocusReturn/index.native.ts b/src/libs/NavigationFocusReturn/index.native.ts index c25ca6ebf38a..87d6a017e958 100644 --- a/src/libs/NavigationFocusReturn/index.native.ts +++ b/src/libs/NavigationFocusReturn/index.native.ts @@ -101,31 +101,41 @@ function captureTriggerForRoute(routeKey: string): void { } /* - * Fast path = captured ref still alive. Fallback = ref nulled by `react-native-screens` detach; resolve via the registry's live re-registration. + * Fast path = captured ref still alive AND `fireFocusEvent` succeeds. Fallback = ref nulled or `fireFocusEvent` returned false on a stale handle; resolve via the registry's live re-registration. */ +function resolveLiveRefFromRegistry(routeKey: string, identifier: string): RefObject | null { + // Pressables register under the raw route key; PUSH_PARAMS restores arrive under the compound key, so strip the suffix to match. + const rawRouteKey = routeKey.split(COMPOUND_KEY_DELIMITER).at(0) ?? routeKey; + const liveRefs = Array.from(pressableRegistry.get(rawRouteKey)?.get(identifier) ?? []).filter((candidate) => candidate.current); + const acceptCollision = COLLISION_TOLERANT_IDENTIFIERS.has(identifier); + return liveRefs.length === 1 || (acceptCollision && liveRefs.length > 1) ? (liveRefs.at(0) ?? null) : null; +} + function restoreTriggerForRoute(routeKey: string): RefObject | null { const entry = triggerMap.get(routeKey); if (!entry) { return null; } - let ref: RefObject = entry.ref; - let view = ref.current; - if (!view && entry.identifier) { - // Pressables register under the raw route key; PUSH_PARAMS restores arrive under the compound key, so strip the suffix to match. - const rawRouteKey = routeKey.split(COMPOUND_KEY_DELIMITER).at(0) ?? routeKey; - const liveRefs = Array.from(pressableRegistry.get(rawRouteKey)?.get(entry.identifier) ?? []).filter((candidate) => candidate.current); - const acceptCollision = COLLISION_TOLERANT_IDENTIFIERS.has(entry.identifier); - const liveRef = liveRefs.length === 1 || (acceptCollision && liveRefs.length > 1) ? liveRefs.at(0) : undefined; - if (liveRef) { - ref = liveRef; - view = liveRef.current; - } + const fastView = entry.ref.current; + if (fastView && fireFocusEvent(fastView)) { + return entry.ref; + } + if (!entry.identifier) { + return null; + } + const liveRef = resolveLiveRefFromRegistry(routeKey, entry.identifier); + const liveView = liveRef?.current; + if (!liveRef || !liveView) { + return null; + } + // Same ref re-registered (registration ran before detach nulled the JS ref) — skip so the retry waits for the actual remount. + if (liveRef === entry.ref) { + return null; } - if (!view) { + if (!fireFocusEvent(liveView)) { return null; } - fireFocusEvent(view); - return ref; + return liveRef; } function cancelPendingRestore(): void { diff --git a/tests/unit/NavigationFocusReturnNativeTest.ts b/tests/unit/NavigationFocusReturnNativeTest.ts index ec2509d4f531..4cc37f99b3c9 100644 --- a/tests/unit/NavigationFocusReturnNativeTest.ts +++ b/tests/unit/NavigationFocusReturnNativeTest.ts @@ -12,6 +12,7 @@ type NavState = { }; const mockFireFocusEvent = jest.fn(); +const mockFireFocusEventFailingViews = new Set(); const mockSendAccessibilityEvent = jest.fn(); const mockLogWarn = jest.fn(); let mockScreenReaderEnabled = true; @@ -43,8 +44,9 @@ jest.mock('../../src/libs/Accessibility', () => ({ jest.mock('../../src/libs/Accessibility/fireFocusEvent', () => ({ __esModule: true, - default: (view: unknown): void => { + default: (view: unknown): boolean => { mockFireFocusEvent(view); + return !mockFireFocusEventFailingViews.has(view); }, })); @@ -159,6 +161,7 @@ beforeEach(() => { jest.useFakeTimers(); mockSendAccessibilityEvent.mockClear(); mockFireFocusEvent.mockClear(); + mockFireFocusEventFailingViews.clear(); mockLogWarn.mockClear(); mockScreenReaderEnabled = true; mockScreenReaderCacheWarmed = true; @@ -752,6 +755,30 @@ describe('pressable registry — identifier-based fallback', () => { expect(mockFireFocusEvent).toHaveBeenCalledWith(liveView); }); + it('falls through to the registry when the JS ref is non-null but `fireFocusEvent` returned false (stale native handle — react-native-screens detach can leave the JS ref alive while sendAccessibilityEvent throws)', () => { + const staleView = fakeView('row'); + const staleRef = fakeRef(staleView); + notifyPressedTrigger(staleRef, 'row'); + + handleStateChange(stackState(0, [{key: 'A', name: 'A'}])); + handleStateChange( + stackState(1, [ + {key: 'A', name: 'A'}, + {key: 'B', name: 'B'}, + ]), + ); + + mockFireFocusEventFailingViews.add(staleView); + const liveView = fakeView('row-remount'); + registerPressable('A', 'row', fakeRef(liveView)); + + handleStateChange(stackState(0, [{key: 'A', name: 'A'}])); + flushTransitions(); + + expect(mockFireFocusEvent).toHaveBeenCalledWith(staleView); + expect(mockFireFocusEvent).toHaveBeenLastCalledWith(liveView); + }); + it('rAF retry rescues focus when re-attach lags transitionEnd', () => { const detachedRef = fakeRef(fakeView('row')); notifyPressedTrigger(detachedRef, 'row'); diff --git a/tests/unit/fireFocusEventAndroidTest.ts b/tests/unit/fireFocusEventAndroidTest.ts index 75ffa84fd27f..085dc228e7f7 100644 --- a/tests/unit/fireFocusEventAndroidTest.ts +++ b/tests/unit/fireFocusEventAndroidTest.ts @@ -17,7 +17,7 @@ jest.mock('@libs/Log', () => ({ const mockSendAccessibilityEvent = jest.fn(); AccessibilityInfo.sendAccessibilityEvent = mockSendAccessibilityEvent; -const fireFocusEvent = require<{default: (view: unknown) => void}>('../../src/libs/Accessibility/fireFocusEvent/index.native').default; +const fireFocusEvent = require<{default: (view: unknown) => boolean}>('../../src/libs/Accessibility/fireFocusEvent/index.native').default; beforeEach(() => { mockSendAccessibilityEvent.mockClear(); @@ -25,20 +25,20 @@ beforeEach(() => { }); describe('fireFocusEvent (native)', () => { - it('dispatches sendAccessibilityEvent with `focus` for the given view', () => { + it('dispatches sendAccessibilityEvent with `focus` for the given view and returns true on success', () => { const view = {label: 'pressable'}; - fireFocusEvent(view); + expect(fireFocusEvent(view)).toBe(true); expect(mockSendAccessibilityEvent).toHaveBeenCalledTimes(1); expect(mockSendAccessibilityEvent).toHaveBeenCalledWith(view, 'focus'); }); - it('catches and logs (does not rethrow) when sendAccessibilityEvent throws on a stale native handle', () => { + it('catches and logs (does not rethrow) AND returns false when sendAccessibilityEvent throws on a stale native handle — callers must fall through to the registry fallback', () => { mockSendAccessibilityEvent.mockImplementationOnce(() => { throw new Error('View has been removed'); }); const view = {label: 'detached'}; - expect(() => fireFocusEvent(view)).not.toThrow(); + expect(fireFocusEvent(view)).toBe(false); expect(mockLogWarn).toHaveBeenCalled(); }); }); From 02bde58cdf808c7f137112ac3bad32f7c769a691 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Wed, 24 Jun 2026 11:45:03 +0300 Subject: [PATCH 79/89] fix: fire fast + registry-rescue in parallel; drop boolean stale-handle probe --- .../fireFocusEvent/index.native.ts | 6 +-- .../Accessibility/fireFocusEvent/index.ts | 4 +- .../NavigationFocusReturn/index.native.ts | 38 ++++++++----------- tests/unit/NavigationFocusReturnNativeTest.ts | 34 +++++++++++++---- tests/unit/fireFocusEventAndroidTest.ts | 10 ++--- 5 files changed, 49 insertions(+), 43 deletions(-) diff --git a/src/libs/Accessibility/fireFocusEvent/index.native.ts b/src/libs/Accessibility/fireFocusEvent/index.native.ts index a599b66cd802..77841bb0ce42 100644 --- a/src/libs/Accessibility/fireFocusEvent/index.native.ts +++ b/src/libs/Accessibility/fireFocusEvent/index.native.ts @@ -3,14 +3,12 @@ import {AccessibilityInfo} from 'react-native'; import type {Text as RNText, View} from 'react-native'; import Log from '@libs/Log'; -/** Returns false when `sendAccessibilityEvent` throws on a stale native handle — react-native-screens detach can leave the JS ref non-null while the RCTView is dead. */ -function fireFocusEvent(view: View | RNText): boolean { +/** Catches stale-handle throws (Android-only — iOS silently no-ops) so the orchestrator isn't aborted; not a success signal — recovery is via the parallel registry-rescue in `restoreTriggerForRoute`. */ +function fireFocusEvent(view: View | RNText): void { try { AccessibilityInfo.sendAccessibilityEvent(view, 'focus'); - return true; } catch (error: unknown) { Log.warn('[fireFocusEvent] sendAccessibilityEvent threw', {error}); - return false; } } diff --git a/src/libs/Accessibility/fireFocusEvent/index.ts b/src/libs/Accessibility/fireFocusEvent/index.ts index e1e1e8f67952..f4658f740373 100644 --- a/src/libs/Accessibility/fireFocusEvent/index.ts +++ b/src/libs/Accessibility/fireFocusEvent/index.ts @@ -2,8 +2,6 @@ import type {Text as RNText, View} from 'react-native'; // eslint-disable-next-line @typescript-eslint/no-unused-vars -function fireFocusEvent(_view: View | RNText): boolean { - return true; -} +function fireFocusEvent(_view: View | RNText): void {} export default fireFocusEvent; diff --git a/src/libs/NavigationFocusReturn/index.native.ts b/src/libs/NavigationFocusReturn/index.native.ts index 87d6a017e958..14efb1802292 100644 --- a/src/libs/NavigationFocusReturn/index.native.ts +++ b/src/libs/NavigationFocusReturn/index.native.ts @@ -100,42 +100,34 @@ function captureTriggerForRoute(routeKey: string): void { setTriggerEntry(routeKey, {ref: lastPressedTriggerRef, identifier: lastPressedTriggerIdentifier ?? undefined}); } -/* - * Fast path = captured ref still alive AND `fireFocusEvent` succeeds. Fallback = ref nulled or `fireFocusEvent` returned false on a stale handle; resolve via the registry's live re-registration. - */ -function resolveLiveRefFromRegistry(routeKey: string, identifier: string): RefObject | null { - // Pressables register under the raw route key; PUSH_PARAMS restores arrive under the compound key, so strip the suffix to match. +/* Strip the compound-key suffix because pressables register under the raw route key. `excludeRef` filters the captured ref so the rescue can't re-pick it. */ +function resolveLiveRefFromRegistry(routeKey: string, identifier: string, excludeRef: RefObject): RefObject | null { const rawRouteKey = routeKey.split(COMPOUND_KEY_DELIMITER).at(0) ?? routeKey; - const liveRefs = Array.from(pressableRegistry.get(rawRouteKey)?.get(identifier) ?? []).filter((candidate) => candidate.current); + const liveRefs = Array.from(pressableRegistry.get(rawRouteKey)?.get(identifier) ?? []).filter((candidate) => candidate.current && candidate !== excludeRef); const acceptCollision = COLLISION_TOLERANT_IDENTIFIERS.has(identifier); return liveRefs.length === 1 || (acceptCollision && liveRefs.length > 1) ? (liveRefs.at(0) ?? null) : null; } +/* Fires fast + registry-rescue in parallel when a different live ref exists: iOS stale-handle silently no-ops (Android throws), so no return-value probe; screen readers process focus events idempotently. */ function restoreTriggerForRoute(routeKey: string): RefObject | null { const entry = triggerMap.get(routeKey); if (!entry) { return null; } const fastView = entry.ref.current; - if (fastView && fireFocusEvent(fastView)) { - return entry.ref; - } - if (!entry.identifier) { - return null; - } - const liveRef = resolveLiveRefFromRegistry(routeKey, entry.identifier); - const liveView = liveRef?.current; - if (!liveRef || !liveView) { - return null; - } - // Same ref re-registered (registration ran before detach nulled the JS ref) — skip so the retry waits for the actual remount. - if (liveRef === entry.ref) { - return null; + if (fastView) { + fireFocusEvent(fastView); } - if (!fireFocusEvent(liveView)) { - return null; + if (entry.identifier) { + const liveRef = resolveLiveRefFromRegistry(routeKey, entry.identifier, entry.ref); + const liveView = liveRef?.current; + if (liveRef && liveView) { + fireFocusEvent(liveView); + // Prefer the live ref so the Android `scheduleRefocus` re-fire lands on the fresh handle. + return liveRef; + } } - return liveRef; + return fastView ? entry.ref : null; } function cancelPendingRestore(): void { diff --git a/tests/unit/NavigationFocusReturnNativeTest.ts b/tests/unit/NavigationFocusReturnNativeTest.ts index 4cc37f99b3c9..fcd7aa0f7117 100644 --- a/tests/unit/NavigationFocusReturnNativeTest.ts +++ b/tests/unit/NavigationFocusReturnNativeTest.ts @@ -12,7 +12,6 @@ type NavState = { }; const mockFireFocusEvent = jest.fn(); -const mockFireFocusEventFailingViews = new Set(); const mockSendAccessibilityEvent = jest.fn(); const mockLogWarn = jest.fn(); let mockScreenReaderEnabled = true; @@ -44,9 +43,8 @@ jest.mock('../../src/libs/Accessibility', () => ({ jest.mock('../../src/libs/Accessibility/fireFocusEvent', () => ({ __esModule: true, - default: (view: unknown): boolean => { + default: (view: unknown): void => { mockFireFocusEvent(view); - return !mockFireFocusEventFailingViews.has(view); }, })); @@ -161,7 +159,6 @@ beforeEach(() => { jest.useFakeTimers(); mockSendAccessibilityEvent.mockClear(); mockFireFocusEvent.mockClear(); - mockFireFocusEventFailingViews.clear(); mockLogWarn.mockClear(); mockScreenReaderEnabled = true; mockScreenReaderCacheWarmed = true; @@ -755,7 +752,7 @@ describe('pressable registry — identifier-based fallback', () => { expect(mockFireFocusEvent).toHaveBeenCalledWith(liveView); }); - it('falls through to the registry when the JS ref is non-null but `fireFocusEvent` returned false (stale native handle — react-native-screens detach can leave the JS ref alive while sendAccessibilityEvent throws)', () => { + it('fires fast AND registry-rescue in parallel when a different live ref exists — covers iOS silent no-op + Android stale-handle throw without depending on a return-value probe', () => { const staleView = fakeView('row'); const staleRef = fakeRef(staleView); notifyPressedTrigger(staleRef, 'row'); @@ -768,15 +765,36 @@ describe('pressable registry — identifier-based fallback', () => { ]), ); - mockFireFocusEventFailingViews.add(staleView); const liveView = fakeView('row-remount'); registerPressable('A', 'row', fakeRef(liveView)); handleStateChange(stackState(0, [{key: 'A', name: 'A'}])); flushTransitions(); - expect(mockFireFocusEvent).toHaveBeenCalledWith(staleView); - expect(mockFireFocusEvent).toHaveBeenLastCalledWith(liveView); + expect(mockFireFocusEvent).toHaveBeenCalledTimes(2); + expect(mockFireFocusEvent).toHaveBeenNthCalledWith(1, staleView); + expect(mockFireFocusEvent).toHaveBeenNthCalledWith(2, liveView); + }); + + it('excludes `entry.ref` from registry candidates so the rescue cannot re-pick the captured stale ref (insertion-order Set would otherwise return it for collision-tolerant identifiers)', () => { + const capturedView = fakeView('back-button'); + const capturedRef = fakeRef(capturedView); + notifyPressedTrigger(capturedRef, 'backButton'); + registerPressable('A', 'backButton', capturedRef); + + 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).toHaveBeenCalledTimes(1); + expect(mockFireFocusEvent).toHaveBeenCalledWith(capturedView); }); it('rAF retry rescues focus when re-attach lags transitionEnd', () => { diff --git a/tests/unit/fireFocusEventAndroidTest.ts b/tests/unit/fireFocusEventAndroidTest.ts index 085dc228e7f7..75ffa84fd27f 100644 --- a/tests/unit/fireFocusEventAndroidTest.ts +++ b/tests/unit/fireFocusEventAndroidTest.ts @@ -17,7 +17,7 @@ jest.mock('@libs/Log', () => ({ const mockSendAccessibilityEvent = jest.fn(); AccessibilityInfo.sendAccessibilityEvent = mockSendAccessibilityEvent; -const fireFocusEvent = require<{default: (view: unknown) => boolean}>('../../src/libs/Accessibility/fireFocusEvent/index.native').default; +const fireFocusEvent = require<{default: (view: unknown) => void}>('../../src/libs/Accessibility/fireFocusEvent/index.native').default; beforeEach(() => { mockSendAccessibilityEvent.mockClear(); @@ -25,20 +25,20 @@ beforeEach(() => { }); describe('fireFocusEvent (native)', () => { - it('dispatches sendAccessibilityEvent with `focus` for the given view and returns true on success', () => { + it('dispatches sendAccessibilityEvent with `focus` for the given view', () => { const view = {label: 'pressable'}; - expect(fireFocusEvent(view)).toBe(true); + fireFocusEvent(view); expect(mockSendAccessibilityEvent).toHaveBeenCalledTimes(1); expect(mockSendAccessibilityEvent).toHaveBeenCalledWith(view, 'focus'); }); - it('catches and logs (does not rethrow) AND returns false when sendAccessibilityEvent throws on a stale native handle — callers must fall through to the registry fallback', () => { + 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)).toBe(false); + expect(() => fireFocusEvent(view)).not.toThrow(); expect(mockLogWarn).toHaveBeenCalled(); }); }); From 8d402704c0236775ec33491ec01ab870124f8c50 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Wed, 24 Jun 2026 14:39:06 +0300 Subject: [PATCH 80/89] fix: fall through to registry rescue when stale native handle throws --- .../NavigationFocusReturn/index.native.ts | 35 ++++++++------- tests/unit/NavigationFocusReturnNativeTest.ts | 45 ------------------- 2 files changed, 19 insertions(+), 61 deletions(-) diff --git a/src/libs/NavigationFocusReturn/index.native.ts b/src/libs/NavigationFocusReturn/index.native.ts index 14efb1802292..7b2daf58dfde 100644 --- a/src/libs/NavigationFocusReturn/index.native.ts +++ b/src/libs/NavigationFocusReturn/index.native.ts @@ -100,34 +100,37 @@ function captureTriggerForRoute(routeKey: string): void { setTriggerEntry(routeKey, {ref: lastPressedTriggerRef, identifier: lastPressedTriggerIdentifier ?? undefined}); } -/* Strip the compound-key suffix because pressables register under the raw route key. `excludeRef` filters the captured ref so the rescue can't re-pick it. */ -function resolveLiveRefFromRegistry(routeKey: string, identifier: string, excludeRef: RefObject): RefObject | null { +// Pressables register under the raw route key; PUSH_PARAMS restores arrive under the compound key, so strip the suffix to match. +function resolveLiveRefFromRegistry(routeKey: string, identifier: string): RefObject | null { const rawRouteKey = routeKey.split(COMPOUND_KEY_DELIMITER).at(0) ?? routeKey; - const liveRefs = Array.from(pressableRegistry.get(rawRouteKey)?.get(identifier) ?? []).filter((candidate) => candidate.current && candidate !== excludeRef); + const liveRefs = Array.from(pressableRegistry.get(rawRouteKey)?.get(identifier) ?? []).filter((candidate) => candidate.current); const acceptCollision = COLLISION_TOLERANT_IDENTIFIERS.has(identifier); return liveRefs.length === 1 || (acceptCollision && liveRefs.length > 1) ? (liveRefs.at(0) ?? null) : null; } -/* Fires fast + registry-rescue in parallel when a different live ref exists: iOS stale-handle silently no-ops (Android throws), so no return-value probe; screen readers process focus events idempotently. */ +/* + * Fast path = captured ref still alive. Rescue = ref nulled by `react-native-screens` detach; resolve via the registry's live re-registration. + * Assumes detach nulls the JS ref — if a future RN/screens version detaches without nulling, the captured handle stays truthy + stale, no rescue runs. Re-verify on-device after react-native-screens upgrades. + */ function restoreTriggerForRoute(routeKey: string): RefObject | null { const entry = triggerMap.get(routeKey); if (!entry) { return null; } - const fastView = entry.ref.current; - if (fastView) { - fireFocusEvent(fastView); - } - if (entry.identifier) { - const liveRef = resolveLiveRefFromRegistry(routeKey, entry.identifier, entry.ref); - const liveView = liveRef?.current; - if (liveRef && liveView) { - fireFocusEvent(liveView); - // Prefer the live ref so the Android `scheduleRefocus` re-fire lands on the fresh handle. - return liveRef; + let ref: RefObject = entry.ref; + let view = ref.current; + if (!view && entry.identifier) { + const liveRef = resolveLiveRefFromRegistry(routeKey, entry.identifier); + if (liveRef?.current) { + ref = liveRef; + view = liveRef.current; } } - return fastView ? entry.ref : null; + if (!view) { + return null; + } + fireFocusEvent(view); + return ref; } function cancelPendingRestore(): void { diff --git a/tests/unit/NavigationFocusReturnNativeTest.ts b/tests/unit/NavigationFocusReturnNativeTest.ts index fcd7aa0f7117..ec2509d4f531 100644 --- a/tests/unit/NavigationFocusReturnNativeTest.ts +++ b/tests/unit/NavigationFocusReturnNativeTest.ts @@ -752,51 +752,6 @@ describe('pressable registry — identifier-based fallback', () => { expect(mockFireFocusEvent).toHaveBeenCalledWith(liveView); }); - it('fires fast AND registry-rescue in parallel when a different live ref exists — covers iOS silent no-op + Android stale-handle throw without depending on a return-value probe', () => { - const staleView = fakeView('row'); - const staleRef = fakeRef(staleView); - notifyPressedTrigger(staleRef, 'row'); - - handleStateChange(stackState(0, [{key: 'A', name: 'A'}])); - handleStateChange( - stackState(1, [ - {key: 'A', name: 'A'}, - {key: 'B', name: 'B'}, - ]), - ); - - const liveView = fakeView('row-remount'); - registerPressable('A', 'row', fakeRef(liveView)); - - handleStateChange(stackState(0, [{key: 'A', name: 'A'}])); - flushTransitions(); - - expect(mockFireFocusEvent).toHaveBeenCalledTimes(2); - expect(mockFireFocusEvent).toHaveBeenNthCalledWith(1, staleView); - expect(mockFireFocusEvent).toHaveBeenNthCalledWith(2, liveView); - }); - - it('excludes `entry.ref` from registry candidates so the rescue cannot re-pick the captured stale ref (insertion-order Set would otherwise return it for collision-tolerant identifiers)', () => { - const capturedView = fakeView('back-button'); - const capturedRef = fakeRef(capturedView); - notifyPressedTrigger(capturedRef, 'backButton'); - registerPressable('A', 'backButton', capturedRef); - - 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).toHaveBeenCalledTimes(1); - expect(mockFireFocusEvent).toHaveBeenCalledWith(capturedView); - }); - it('rAF retry rescues focus when re-attach lags transitionEnd', () => { const detachedRef = fakeRef(fakeView('row')); notifyPressedTrigger(detachedRef, 'row'); From 5c22fbd99425a432f23219fa40d02e209a9415e3 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Wed, 24 Jun 2026 15:44:44 +0300 Subject: [PATCH 81/89] fix: harden focus-return dialog viewport observer, save-arm count, handle leak, focusKey prop --- src/components/DialogLabelContext.tsx | 18 ++++++- .../implementation/BaseGenericPressable.tsx | 6 ++- .../Pressable/GenericPressable/types.ts | 3 ++ .../configuration/SpendRuleMerchantsBase.tsx | 2 +- .../SubStepForms/ConfirmationStep.tsx | 2 +- src/hooks/useDialogContainerFocus/index.ts | 2 +- src/hooks/useNavigateBackOnSave/index.ts | 16 +++--- .../PlatformStackNavigation/ScreenLayout.tsx | 8 +++ src/libs/NavigationFocusReturn/fifoMap.ts | 14 +++++ .../NavigationFocusReturn/index.native.ts | 14 +---- src/libs/NavigationFocusReturn/index.ts | 12 +---- .../BaseGenericPressableRegistryGateTest.tsx | 53 ++++++++++++++++++- tests/unit/DialogLabelContextTest.tsx | 47 ++++++++++++++++ .../libs/NavigationFocusReturn/fifoMapTest.ts | 50 +++++++++++++++++ tests/unit/useDialogContainerFocusTest.tsx | 50 +++++++++++++++++ tests/unit/useNavigateBackOnSaveTest.ts | 19 +++++++ tests/unit/useScreenInitialFocusTest.tsx | 4 +- 17 files changed, 282 insertions(+), 38 deletions(-) create mode 100644 src/libs/NavigationFocusReturn/fifoMap.ts create mode 100644 tests/unit/libs/NavigationFocusReturn/fifoMapTest.ts create mode 100644 tests/unit/useDialogContainerFocusTest.tsx diff --git a/src/components/DialogLabelContext.tsx b/src/components/DialogLabelContext.tsx index 55dfec4d20a2..ab40d8d33756 100644 --- a/src/components/DialogLabelContext.tsx +++ b/src/components/DialogLabelContext.tsx @@ -1,4 +1,4 @@ -import React, {createContext, useContext, useRef} from 'react'; +import React, {createContext, useContext, useEffect, useRef} from 'react'; import type {View} from 'react-native'; import isHTMLElement from '@libs/isHTMLElement'; @@ -73,6 +73,22 @@ function DialogLabelProvider({children, containerRef}: DialogLabelProviderProps) updateContainerLabel(); }; + // Re-apply on `role`/`aria-modal` change — viewport resize flips dialog semantics mid-session. + useEffect(() => { + if (typeof MutationObserver === 'undefined') { + return; + } + const node = containerRef.current; + if (!isHTMLElement(node)) { + return; + } + const observer = new MutationObserver(updateContainerLabel); + observer.observe(node, {attributes: true, attributeFilter: ['role', 'aria-modal']}); + return () => { + observer.disconnect(); + }; + }, [containerRef, updateContainerLabel]); + const claimInitialFocus = (): boolean => { if (initialFocusClaimedRef.current) { return false; diff --git a/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx b/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx index b2f9f6bf067d..1eaa85baedec 100644 --- a/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx +++ b/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx @@ -18,6 +18,7 @@ import CONST from '@src/CONST'; function GenericPressable({ children, onPress, + focusKey, onLongPress, onKeyDown, disabled, @@ -56,8 +57,9 @@ function GenericPressable({ const internalRef = useRef(null); const composedRef = useMemo(() => mergeRefs(ref, internalRef), [ref]); const routeKey = useRouteKey(); - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- `||` falls empty-string ids through to the next identity prop; the registry rescue must key off a stable identity prop, never the (often value-derived) accessibility label. - const focusIdentifier = rest.id || rest.nativeID || rest.testID || undefined; + // `focusKey` wins; identity props fall through. `||` so empty strings skip — never key off an empty prop. + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const focusIdentifier = focusKey || rest.id || rest.nativeID || rest.testID || undefined; useEffect(() => { if (isScreenReaderKnownOff || !routeKey || !focusIdentifier) { diff --git a/src/components/Pressable/GenericPressable/types.ts b/src/components/Pressable/GenericPressable/types.ts index 44968328e0ec..191999ee8755 100644 --- a/src/components/Pressable/GenericPressable/types.ts +++ b/src/components/Pressable/GenericPressable/types.ts @@ -47,6 +47,9 @@ type PressableProps = RNPressableProps & */ onPress?: (event?: GestureResponderEvent | KeyboardEvent) => void | Promise; + /** Stable identifier for the native focus-return registry rescue, independent of `id`/`nativeID`/`testID`; falls back to those when omitted. */ + focusKey?: string; + /** * Specifies keyboard shortcut to trigger onPressHandler * @example {shortcutKey: 'a', modifiers: ['ctrl', 'shift'], descriptionKey: 'keyboardShortcut.description'} diff --git a/src/components/SpendRules/configuration/SpendRuleMerchantsBase.tsx b/src/components/SpendRules/configuration/SpendRuleMerchantsBase.tsx index 772600fd6ed6..03ba8ff07182 100644 --- a/src/components/SpendRules/configuration/SpendRuleMerchantsBase.tsx +++ b/src/components/SpendRules/configuration/SpendRuleMerchantsBase.tsx @@ -82,7 +82,7 @@ function SpendRuleMerchantsBase({policyID, action, merchants, getEditMerchantRou /> {merchants.length > 0 ? ( merchants.map(({name, matchType}, index) => { - // `name`/`matchType` are edited on the detail screen — index is the stable identity (already what the edit route uses). + // Index is the stable identity (`name`/`matchType` are edited on the detail screen). Limit: delete + back-nav can focus the adjacent row — no per-merchant backend ID. const rowId = `merchant-${index}`; return ( {pageTitle} {summaryItems.map(({id, description, title, shouldShowRightIcon, onPress, brickRoadIndicator, errorText, testID}, index) => { - // `title` is the edited value — embedding it would shift the identifier across edits and miss the focus-return lookup. `description` is stable; index disambiguates. + // `description`+index is stable across edits (don't embed `title`). Limit: conditional row visibility re-keys items below — callers with reorderable rows should pass an explicit `id` (e.g. `INPUT_IDS.*`). const stableId = id ?? `${description}-${index}`; return ( { useEffect(() => { - if (!isReady || !claimInitialFocusGate?.() || skipDialogContainerFocus) { + if (!isReady || skipDialogContainerFocus || !claimInitialFocusGate?.()) { return; } let rafId: number | null = null; diff --git a/src/hooks/useNavigateBackOnSave/index.ts b/src/hooks/useNavigateBackOnSave/index.ts index 1e7131b068ef..9ffb0f4ae530 100644 --- a/src/hooks/useNavigateBackOnSave/index.ts +++ b/src/hooks/useNavigateBackOnSave/index.ts @@ -1,37 +1,37 @@ -import {useEffect, useRef} from 'react'; +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(), focus restores. `armNavigateBack` — arms the next `isSaved` transition to dispatch goBack once; - * does not navigate immediately. Pass `shouldSkipFocusRestore: true` only when the destination has a submit Enter a re-focused row would hijack. + * `navigateBack` = direct goBack. `armNavigateBack` = goBack on next save (or immediately if already saved). `shouldSkipFocusRestore: true` only when the destination's Enter shortcut would be hijacked by a re-focused row. */ function useNavigateBackOnSave( isSaved: boolean, backTo: Route | undefined, {shouldSkipFocusRestore}: {shouldSkipFocusRestore: boolean}, ): {navigateBack: () => void; armNavigateBack: () => void} { - const shouldNavigateAfterSaveRef = useRef(false); + const [armCount, setArmCount] = useState(0); + const lastProcessedArmRef = useRef(0); const navigateBack = () => { Navigation.goBack(backTo); }; const armNavigateBack = () => { - shouldNavigateAfterSaveRef.current = true; + setArmCount((count) => count + 1); }; useEffect(() => { - if (!isSaved || !shouldNavigateAfterSaveRef.current) { + if (!isSaved || armCount === lastProcessedArmRef.current) { return; } - shouldNavigateAfterSaveRef.current = false; + lastProcessedArmRef.current = armCount; if (shouldSkipFocusRestore) { skipNextFocusRestore(); } navigateBack(); - }, [isSaved, navigateBack, shouldSkipFocusRestore]); + }, [isSaved, armCount, navigateBack, shouldSkipFocusRestore]); return {navigateBack, armNavigateBack}; } diff --git a/src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx b/src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx index 80da214473d0..9d3217f73e13 100644 --- a/src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx +++ b/src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx @@ -23,6 +23,10 @@ function ScreenLayout({ useLayoutEffect(() => { const transitionStartListener = navigation.addListener('transitionStart', () => { + // End prior handle first — rapid back/forward fires `transitionStart` again before `transitionEnd`, stalling focus-return for up to 1s. + if (transitionHandleRef.current) { + TransitionTracker.endTransition(transitionHandleRef.current); + } transitionHandleRef.current = TransitionTracker.startTransition('navigation'); }); const transitionEndListener = navigation.addListener('transitionEnd', () => { @@ -36,6 +40,10 @@ function ScreenLayout({ return () => { transitionStartListener(); transitionEndListener(); + if (transitionHandleRef.current) { + TransitionTracker.endTransition(transitionHandleRef.current); + transitionHandleRef.current = null; + } }; }, [navigation]); diff --git a/src/libs/NavigationFocusReturn/fifoMap.ts b/src/libs/NavigationFocusReturn/fifoMap.ts new file mode 100644 index 000000000000..ca6e4db7c6dd --- /dev/null +++ b/src/libs/NavigationFocusReturn/fifoMap.ts @@ -0,0 +1,14 @@ +/** 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) { + const oldest = map.keys().next().value; + if (oldest === undefined) { + break; + } + map.delete(oldest); + } +} + +export default setFifoEntry; diff --git a/src/libs/NavigationFocusReturn/index.native.ts b/src/libs/NavigationFocusReturn/index.native.ts index 7b2daf58dfde..50ae097f261e 100644 --- a/src/libs/NavigationFocusReturn/index.native.ts +++ b/src/libs/NavigationFocusReturn/index.native.ts @@ -11,6 +11,7 @@ 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}; @@ -26,19 +27,8 @@ let pendingRestore: {cancel: () => void} | null = null; let skipNextRestore = false; let stateUnsubscribe: (() => void) | null = null; -/* - * Delete-then-set so a re-set moves the key to the tail and FIFO eviction drops the truly oldest. - */ 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); } // Recorded unconditionally so cold-start presses survive the warm-up window. performance.now is monotonic — Date.now would corrupt the TTL on clock jumps. diff --git a/src/libs/NavigationFocusReturn/index.ts b/src/libs/NavigationFocusReturn/index.ts index bb5ef425e456..6600534c283d 100644 --- a/src/libs/NavigationFocusReturn/index.ts +++ b/src/libs/NavigationFocusReturn/index.ts @@ -14,6 +14,7 @@ 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. */ @@ -28,17 +29,8 @@ let lastMouseTrigger: HTMLElement | null = null; let lastInteractiveElement: HTMLElement | null = null; let lastMouseTriggerAt = 0; -// 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; diff --git a/tests/unit/BaseGenericPressableRegistryGateTest.tsx b/tests/unit/BaseGenericPressableRegistryGateTest.tsx index 2bf8901e132b..ceb39d7b7b7a 100644 --- a/tests/unit/BaseGenericPressableRegistryGateTest.tsx +++ b/tests/unit/BaseGenericPressableRegistryGateTest.tsx @@ -39,7 +39,7 @@ jest.mock('@hooks/useSingleExecution', () => ({ })); const GenericPressable = require<{ - default: React.ComponentType<{id?: string; testID?: string; onPress?: () => void; children?: React.ReactNode}>; + default: React.ComponentType<{id?: string; nativeID?: string; testID?: string; focusKey?: string; onPress?: () => void; children?: React.ReactNode}>; }>('../../src/components/Pressable/GenericPressable/implementation/BaseGenericPressable').default; const ROUTE_KEY = 'route-A'; @@ -110,4 +110,55 @@ describe('BaseGenericPressable — focus-return registry gate', () => { ); expect(mockRegisterPressable).not.toHaveBeenCalled(); }); + + it('uses explicit `focusKey` over `id`/`testID` so production focus identity is independent of test infrastructure', () => { + mockIsScreenReaderKnownOff = false; + renderInsideRoute( + {}} + />, + ); + expect(mockRegisterPressable).toHaveBeenCalledTimes(1); + const callArgs = mockRegisterPressable.mock.calls.at(0); + expect(callArgs?.[1]).toBe('legal-name-row'); + }); + + it('falls through `id → nativeID → testID` when `focusKey` is absent (backward-compat with existing callers)', () => { + mockIsScreenReaderKnownOff = false; + renderInsideRoute( + {}} + />, + ); + expect(mockRegisterPressable.mock.calls.at(0)?.[1]).toBe('prefer-id'); + }); + + it('uses `nativeID` when `id` is absent', () => { + mockIsScreenReaderKnownOff = false; + renderInsideRoute( + {}} + />, + ); + expect(mockRegisterPressable.mock.calls.at(0)?.[1]).toBe('native-id-only'); + }); + + it('uses `testID` when `id` and `nativeID` are absent', () => { + mockIsScreenReaderKnownOff = false; + renderInsideRoute( + {}} + />, + ); + expect(mockRegisterPressable.mock.calls.at(0)?.[1]).toBe('test-id-only'); + }); }); diff --git a/tests/unit/DialogLabelContextTest.tsx b/tests/unit/DialogLabelContextTest.tsx index 3c4c0e1c8876..a190d31b764a 100644 --- a/tests/unit/DialogLabelContextTest.tsx +++ b/tests/unit/DialogLabelContextTest.tsx @@ -177,6 +177,53 @@ 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 () => { + // Set container before mount so the observer attaches on first commit. + const mockElement = document.createElement('div'); + (testContainerRef as {current: unknown}).current = 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(); + (testContainerRef as {current: unknown}).current = null; + }); + + 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'); + (testContainerRef as {current: unknown}).current = 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(); + (testContainerRef as {current: unknown}).current = null; + }); + it('assigns unique IDs to each pushed label', () => { const {result} = renderHook(() => useDialogLabelActions(), {wrapper}); diff --git a/tests/unit/libs/NavigationFocusReturn/fifoMapTest.ts b/tests/unit/libs/NavigationFocusReturn/fifoMapTest.ts new file mode 100644 index 000000000000..cb5f6dfce0d5 --- /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 single-element cap, evicting on every insert', () => { + 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/useDialogContainerFocusTest.tsx b/tests/unit/useDialogContainerFocusTest.tsx new file mode 100644 index 000000000000..328b861f547f --- /dev/null +++ b/tests/unit/useDialogContainerFocusTest.tsx @@ -0,0 +1,50 @@ +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 */ + +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(); + }); +}); diff --git a/tests/unit/useNavigateBackOnSaveTest.ts b/tests/unit/useNavigateBackOnSaveTest.ts index abbfad20c2c6..f15be3ebe95a 100644 --- a/tests/unit/useNavigateBackOnSaveTest.ts +++ b/tests/unit/useNavigateBackOnSaveTest.ts @@ -103,4 +103,23 @@ describe('useNavigateBackOnSave', () => { 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('arms twice across two save cycles → navigates twice (monotonic arm counter consumes each arm exactly once)', () => { + 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); + }); }); diff --git a/tests/unit/useScreenInitialFocusTest.tsx b/tests/unit/useScreenInitialFocusTest.tsx index e348b703e853..fb54f67385d1 100644 --- a/tests/unit/useScreenInitialFocusTest.tsx +++ b/tests/unit/useScreenInitialFocusTest.tsx @@ -29,9 +29,10 @@ const {resetCycle: resetArbiter, tryClaim: arbiterClaim, Priorities: arbiterPrio tryClaim: (priority: 1 | 2 | 3) => boolean; Priorities: {INITIAL: 1; AUTO: 2; RETURN: 3}; }>('../../src/libs/ScreenFocusArbiter.ts'); -const {teardownHadTabNavigation, setupHadTabNavigation} = require<{ +const {teardownHadTabNavigation, setupHadTabNavigation, resetForTests: resetHadTabNavigation} = require<{ teardownHadTabNavigation: () => void; setupHadTabNavigation: () => void; + resetForTests: () => void; }>('../../src/libs/hadTabNavigation.ts'); /* eslint-enable import/extensions */ @@ -87,6 +88,7 @@ beforeEach(() => { mockHasHoverSupport = true; mockIsScreenReaderKnownOff = false; teardownHadTabNavigation(); + resetHadTabNavigation(); setupHadTabNavigation(); }); From 0a68193c9da95c5a1b4b97cfc33bad4c6e11d3f0 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Wed, 24 Jun 2026 17:12:45 +0300 Subject: [PATCH 82/89] refactor: harden focus-return rescue, list keys, and transition handles --- src/components/DialogLabelContext.tsx | 24 +++++++------- .../configuration/SpendRuleMerchantsBase.tsx | 8 ++--- .../SubStepForms/ConfirmationStep.tsx | 6 ++-- src/hooks/useNavigateBackOnSave/index.ts | 17 +++++----- .../fireFocusEvent/index.native.ts | 2 +- .../Navigators/RightModalNavigator.tsx | 13 +++++--- .../PlatformStackNavigation/ScreenLayout.tsx | 31 ++++++++++++------- src/libs/NavigationFocusReturn/fifoMap.ts | 7 +++-- .../NavigationFocusReturn/index.native.ts | 22 ++++--------- tests/unit/DialogLabelContextTest.tsx | 20 ++++++------ .../libs/NavigationFocusReturn/fifoMapTest.ts | 2 +- tests/unit/useDialogContainerFocusTest.tsx | 4 +++ tests/unit/useNavigateBackOnSaveTest.ts | 13 ++++++-- 13 files changed, 95 insertions(+), 74 deletions(-) diff --git a/src/components/DialogLabelContext.tsx b/src/components/DialogLabelContext.tsx index ab40d8d33756..bf67e0e6dd96 100644 --- a/src/components/DialogLabelContext.tsx +++ b/src/components/DialogLabelContext.tsx @@ -1,4 +1,4 @@ -import React, {createContext, useContext, useEffect, useRef} from 'react'; +import React, {createContext, useContext, useEffect, useLayoutEffect, useRef} from 'react'; import type {View} from 'react-native'; import isHTMLElement from '@libs/isHTMLElement'; @@ -28,14 +28,20 @@ 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; }; // 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, containerRef}: DialogLabelProviderProps) { +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 = () => { // `aria-label` is a DOM contract; bail before any node access on native. @@ -73,21 +79,17 @@ function DialogLabelProvider({children, containerRef}: DialogLabelProviderProps) updateContainerLabel(); }; - // Re-apply on `role`/`aria-modal` change — viewport resize flips dialog semantics mid-session. + // Re-apply on `role`/`aria-modal` change — viewport resize flips dialog semantics mid-session. Re-runs on node swap so a fresh Animated.View gets a fresh observer. useEffect(() => { - if (typeof MutationObserver === 'undefined') { - return; - } - const node = containerRef.current; - if (!isHTMLElement(node)) { + if (typeof MutationObserver === 'undefined' || !isHTMLElement(containerNode)) { return; } const observer = new MutationObserver(updateContainerLabel); - observer.observe(node, {attributes: true, attributeFilter: ['role', 'aria-modal']}); + observer.observe(containerNode, {attributes: true, attributeFilter: ['role', 'aria-modal']}); return () => { observer.disconnect(); }; - }, [containerRef, updateContainerLabel]); + }, [containerNode, updateContainerLabel]); const claimInitialFocus = (): boolean => { if (initialFocusClaimedRef.current) { diff --git a/src/components/SpendRules/configuration/SpendRuleMerchantsBase.tsx b/src/components/SpendRules/configuration/SpendRuleMerchantsBase.tsx index 03ba8ff07182..9c55a4be9075 100644 --- a/src/components/SpendRules/configuration/SpendRuleMerchantsBase.tsx +++ b/src/components/SpendRules/configuration/SpendRuleMerchantsBase.tsx @@ -82,12 +82,12 @@ function SpendRuleMerchantsBase({policyID, action, merchants, getEditMerchantRou /> {merchants.length > 0 ? ( merchants.map(({name, matchType}, index) => { - // Index is the stable identity (`name`/`matchType` are edited on the detail screen). Limit: delete + back-nav can focus the adjacent row — no per-merchant backend ID. - const rowId = `merchant-${index}`; + // Content-bound key (no array index) so delete-in-middle unmounts the right instance and the focus-return rescue can't mis-target a shifted neighbour. + const rowKey = `merchant-${matchType ?? 'unset'}-${name}`; return ( {pageTitle} - {summaryItems.map(({id, description, title, shouldShowRightIcon, onPress, brickRoadIndicator, errorText, testID}, index) => { - // `description`+index is stable across edits (don't embed `title`). Limit: conditional row visibility re-keys items below — callers with reorderable rows should pass an explicit `id` (e.g. `INPUT_IDS.*`). - const stableId = id ?? `${description}-${index}`; + {summaryItems.map(({id, description, title, shouldShowRightIcon, onPress, brickRoadIndicator, errorText, testID}) => { + // Content-bound key (no array index) so a conditionally-hidden row above doesn't re-key items below. Duplicate descriptions collide — pass an explicit `id` (e.g. `INPUT_IDS.*`). + const stableId = id ?? description; return ( void; armNavigateBack: () => void} { - const [armCount, setArmCount] = useState(0); - const lastProcessedArmRef = useRef(0); + const [isArmed, setIsArmed] = useState(false); + const hasNavigatedRef = useRef(false); const navigateBack = () => { Navigation.goBack(backTo); }; const armNavigateBack = () => { - setArmCount((count) => count + 1); + if (hasNavigatedRef.current) { + return; + } + setIsArmed(true); }; useEffect(() => { - if (!isSaved || armCount === lastProcessedArmRef.current) { + if (!isArmed || !isSaved || hasNavigatedRef.current) { return; } - lastProcessedArmRef.current = armCount; + hasNavigatedRef.current = true; if (shouldSkipFocusRestore) { skipNextFocusRestore(); } navigateBack(); - }, [isSaved, armCount, navigateBack, shouldSkipFocusRestore]); + }, [isArmed, isSaved, navigateBack, shouldSkipFocusRestore]); return {navigateBack, armNavigateBack}; } diff --git a/src/libs/Accessibility/fireFocusEvent/index.native.ts b/src/libs/Accessibility/fireFocusEvent/index.native.ts index 77841bb0ce42..5b8a4c4a2914 100644 --- a/src/libs/Accessibility/fireFocusEvent/index.native.ts +++ b/src/libs/Accessibility/fireFocusEvent/index.native.ts @@ -3,7 +3,7 @@ import {AccessibilityInfo} from 'react-native'; import type {Text as RNText, View} from 'react-native'; import Log from '@libs/Log'; -/** Catches stale-handle throws (Android-only — iOS silently no-ops) so the orchestrator isn't aborted; not a success signal — recovery is via the parallel registry-rescue in `restoreTriggerForRoute`. */ +/** 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'); diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx index c64869214283..44830fbf0acb 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx @@ -1,7 +1,8 @@ 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'; // eslint-disable-next-line no-restricted-imports +import type {View} from 'react-native'; import {Animated, DeviceEventEmitter} from 'react-native'; import {DialogLabelProvider} from '@components/DialogLabelContext'; import NoDropZone from '@components/DragAndDrop/NoDropZone'; @@ -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 MutationObserver re-attaches when Animated.View remounts across the breakpoint. + const [containerNode, setContainerNode] = useState(null); + const containerCallbackRef = useCallback((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 */} - + >) { 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', () => { - // End prior handle first — rapid back/forward fires `transitionStart` again before `transitionEnd`, stalling focus-return for up to 1s. - if (transitionHandleRef.current) { - TransitionTracker.endTransition(transitionHandleRef.current); + pendingTransitionsRef.current += 1; + if (!transitionHandleRef.current) { + transitionHandleRef.current = TransitionTracker.startTransition('navigation'); } - 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(); - if (transitionHandleRef.current) { - TransitionTracker.endTransition(transitionHandleRef.current); - transitionHandleRef.current = null; + 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/NavigationFocusReturn/fifoMap.ts b/src/libs/NavigationFocusReturn/fifoMap.ts index ca6e4db7c6dd..2ac707055242 100644 --- a/src/libs/NavigationFocusReturn/fifoMap.ts +++ b/src/libs/NavigationFocusReturn/fifoMap.ts @@ -3,11 +3,12 @@ function setFifoEntry(map: Map, key: K, value: V, maxSize: number): map.delete(key); map.set(key, value); while (map.size > maxSize) { - const oldest = map.keys().next().value; - if (oldest === undefined) { + // `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(oldest); + map.delete(next.value); } } diff --git a/src/libs/NavigationFocusReturn/index.native.ts b/src/libs/NavigationFocusReturn/index.native.ts index 50ae097f261e..ecccb2df621e 100644 --- a/src/libs/NavigationFocusReturn/index.native.ts +++ b/src/libs/NavigationFocusReturn/index.native.ts @@ -27,10 +27,6 @@ let pendingRestore: {cancel: () => void} | null = null; let skipNextRestore = false; let stateUnsubscribe: (() => void) | null = null; -function setTriggerEntry(routeKey: string, entry: TriggerEntry): void { - setFifoEntry(triggerMap, routeKey, entry, TRIGGER_MAP_MAX); -} - // 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; @@ -87,7 +83,7 @@ function captureTriggerForRoute(routeKey: string): void { if (!lastPressedTriggerRef || performance.now() - lastPressedTriggerAt > PRESS_TRIGGER_TTL_MS) { return; } - setTriggerEntry(routeKey, {ref: lastPressedTriggerRef, identifier: lastPressedTriggerIdentifier ?? undefined}); + setFifoEntry(triggerMap, routeKey, {ref: lastPressedTriggerRef, identifier: lastPressedTriggerIdentifier ?? undefined}, TRIGGER_MAP_MAX); } // Pressables register under the raw route key; PUSH_PARAMS restores arrive under the compound key, so strip the suffix to match. @@ -99,23 +95,17 @@ function resolveLiveRefFromRegistry(routeKey: string, identifier: string): RefOb } /* - * Fast path = captured ref still alive. Rescue = ref nulled by `react-native-screens` detach; resolve via the registry's live re-registration. - * Assumes detach nulls the JS ref — if a future RN/screens version detaches without nulling, the captured handle stays truthy + stale, no rescue runs. Re-verify on-device after react-native-screens upgrades. + * 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): RefObject | null { const entry = triggerMap.get(routeKey); if (!entry) { return null; } - let ref: RefObject = entry.ref; - let view = ref.current; - if (!view && entry.identifier) { - const liveRef = resolveLiveRefFromRegistry(routeKey, entry.identifier); - if (liveRef?.current) { - ref = liveRef; - view = liveRef.current; - } - } + const liveRef = entry.identifier ? resolveLiveRefFromRegistry(routeKey, entry.identifier) : null; + const ref = liveRef ?? entry.ref; + const view = ref.current; if (!view) { return null; } diff --git a/tests/unit/DialogLabelContextTest.tsx b/tests/unit/DialogLabelContextTest.tsx index a190d31b764a..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', () => { @@ -178,10 +181,8 @@ describe('DialogLabelContext', () => { }); it('re-applies aria-label when the container gains dialog semantics on viewport resize (MutationObserver path)', async () => { - // Set container before mount so the observer attaches on first commit. const mockElement = document.createElement('div'); - (testContainerRef as {current: unknown}).current = mockElement; - + currentContainerNode = mockElement; const {result, unmount} = renderHook(() => ({...useDialogLabelData(), ...useDialogLabelActions()}), {wrapper}); act(() => { @@ -197,15 +198,13 @@ describe('DialogLabelContext', () => { expect(mockElement.getAttribute('aria-label')).toBe('Settings'); unmount(); - (testContainerRef as {current: unknown}).current = null; }); 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'); - (testContainerRef as {current: unknown}).current = mockElement; - + currentContainerNode = mockElement; const {result, unmount} = renderHook(() => ({...useDialogLabelData(), ...useDialogLabelActions()}), {wrapper}); act(() => { @@ -221,7 +220,6 @@ describe('DialogLabelContext', () => { expect(mockElement.hasAttribute('aria-label')).toBe(false); unmount(); - (testContainerRef as {current: unknown}).current = null; }); it('assigns unique IDs to each pushed label', () => { diff --git a/tests/unit/libs/NavigationFocusReturn/fifoMapTest.ts b/tests/unit/libs/NavigationFocusReturn/fifoMapTest.ts index cb5f6dfce0d5..916845baede2 100644 --- a/tests/unit/libs/NavigationFocusReturn/fifoMapTest.ts +++ b/tests/unit/libs/NavigationFocusReturn/fifoMapTest.ts @@ -40,7 +40,7 @@ describe('setFifoEntry', () => { expect(map.has('b')).toBe(false); }); - it('treats maxSize <= 0 as a single-element cap, evicting on every insert', () => { + 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); diff --git a/tests/unit/useDialogContainerFocusTest.tsx b/tests/unit/useDialogContainerFocusTest.tsx index 328b861f547f..80da51a1772a 100644 --- a/tests/unit/useDialogContainerFocusTest.tsx +++ b/tests/unit/useDialogContainerFocusTest.tsx @@ -16,6 +16,10 @@ const useDialogContainerFocus = require<{ }>('../../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(); diff --git a/tests/unit/useNavigateBackOnSaveTest.ts b/tests/unit/useNavigateBackOnSaveTest.ts index f15be3ebe95a..6177be831258 100644 --- a/tests/unit/useNavigateBackOnSaveTest.ts +++ b/tests/unit/useNavigateBackOnSaveTest.ts @@ -111,7 +111,7 @@ describe('useNavigateBackOnSave', () => { expect(mockGoBack).toHaveBeenCalledWith(BACK_TO); }); - it('arms twice across two save cycles → navigates twice (monotonic arm counter consumes each arm exactly once)', () => { + it('re-arming after a successful navigate is a no-op (one-shot — the component is unmounting; further arms must not pop past the destination)', () => { const {result, rerender} = renderSave(); act(() => result.current.armNavigateBack()); rerender({isSaved: true}); @@ -120,6 +120,15 @@ describe('useNavigateBackOnSave', () => { rerender({isSaved: false}); act(() => result.current.armNavigateBack()); rerender({isSaved: true}); - expect(mockGoBack).toHaveBeenCalledTimes(2); + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + + it('double-tap on save while already saved navigates exactly once (defends the one-shot invariant against rapid repeat arms)', () => { + const {result} = renderSave(true); + act(() => { + result.current.armNavigateBack(); + result.current.armNavigateBack(); + }); + expect(mockGoBack).toHaveBeenCalledTimes(1); }); }); From 01ea0b9018c46d134adb32c700aed8a7528f5f81 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Wed, 24 Jun 2026 18:23:19 +0300 Subject: [PATCH 83/89] fix: revert list keys; back-fill dialog label; re-arm save-nav --- src/components/DialogLabelContext.tsx | 6 ++-- .../implementation/BaseGenericPressable.tsx | 5 ++- .../Pressable/GenericPressable/types.ts | 3 -- .../SpendRuleMerchantEditBase.tsx | 2 ++ .../configuration/SpendRuleMerchantsBase.tsx | 8 ++--- .../SubStepForms/ConfirmationStep.tsx | 6 ++-- src/hooks/useNavigateBackOnSave/index.ts | 25 +++++++++----- .../Navigators/RightModalNavigator.tsx | 4 +-- .../BaseGenericPressableRegistryGateTest.tsx | 19 ++--------- tests/unit/useNavigateBackOnSaveTest.ts | 34 +++++++++++++++++-- 10 files changed, 65 insertions(+), 47 deletions(-) diff --git a/src/components/DialogLabelContext.tsx b/src/components/DialogLabelContext.tsx index bf67e0e6dd96..e3c906813512 100644 --- a/src/components/DialogLabelContext.tsx +++ b/src/components/DialogLabelContext.tsx @@ -44,7 +44,6 @@ function DialogLabelProvider({children, containerNode}: DialogLabelProviderProps }, [containerNode]); const updateContainerLabel = () => { - // `aria-label` is a DOM contract; bail before any node access on native. if (typeof document === 'undefined') { return; } @@ -53,7 +52,7 @@ function DialogLabelProvider({children, containerNode}: DialogLabelProviderProps if (!isHTMLElement(node)) { return; } - // aria-label on a container without dialog semantics is ignored by screen readers; skip the set on mobile where the RHP has no dialog role. + // 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'); @@ -79,13 +78,14 @@ function DialogLabelProvider({children, containerNode}: DialogLabelProviderProps updateContainerLabel(); }; - // Re-apply on `role`/`aria-modal` change — viewport resize flips dialog semantics mid-session. Re-runs on node swap so a fresh Animated.View gets a fresh observer. + // 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(); }; diff --git a/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx b/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx index 1eaa85baedec..8a0749aa5a29 100644 --- a/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx +++ b/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx @@ -18,7 +18,6 @@ import CONST from '@src/CONST'; function GenericPressable({ children, onPress, - focusKey, onLongPress, onKeyDown, disabled, @@ -57,9 +56,9 @@ function GenericPressable({ const internalRef = useRef(null); const composedRef = useMemo(() => mergeRefs(ref, internalRef), [ref]); const routeKey = useRouteKey(); - // `focusKey` wins; identity props fall through. `||` so empty strings skip — never key off an empty prop. + // `||` so empty strings skip — never key off an empty prop. // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const focusIdentifier = focusKey || rest.id || rest.nativeID || rest.testID || undefined; + const focusIdentifier = rest.id || rest.nativeID || rest.testID || undefined; useEffect(() => { if (isScreenReaderKnownOff || !routeKey || !focusIdentifier) { diff --git a/src/components/Pressable/GenericPressable/types.ts b/src/components/Pressable/GenericPressable/types.ts index 191999ee8755..44968328e0ec 100644 --- a/src/components/Pressable/GenericPressable/types.ts +++ b/src/components/Pressable/GenericPressable/types.ts @@ -47,9 +47,6 @@ type PressableProps = RNPressableProps & */ onPress?: (event?: GestureResponderEvent | KeyboardEvent) => void | Promise; - /** Stable identifier for the native focus-return registry rescue, independent of `id`/`nativeID`/`testID`; falls back to those when omitted. */ - focusKey?: string; - /** * Specifies keyboard shortcut to trigger onPressHandler * @example {shortcutKey: 'a', modifiers: ['ctrl', 'shift'], descriptionKey: 'keyboardShortcut.description'} diff --git a/src/components/SpendRules/configuration/SpendRuleMerchantEditBase.tsx b/src/components/SpendRules/configuration/SpendRuleMerchantEditBase.tsx index 5134cf8864ca..f43e712f3d16 100644 --- a/src/components/SpendRules/configuration/SpendRuleMerchantEditBase.tsx +++ b/src/components/SpendRules/configuration/SpendRuleMerchantEditBase.tsx @@ -15,6 +15,7 @@ import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useCanWriteCardSpendRules from '@hooks/useCanWriteCardSpendRules'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import {skipNextFocusRestore} from '@libs/NavigationFocusReturn'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -75,6 +76,7 @@ function SpendRuleMerchantEditBase({policyID, merchantIndex, merchantMatchTypes, const updatedMerchantNames = merchantNames.filter((_, merchantArrayIndex) => merchantArrayIndex !== index); const updatedMerchantMatchTypes = merchantMatchTypes.filter((_, merchantArrayIndex) => merchantArrayIndex !== index); onMerchantDataChange(updatedMerchantNames, updatedMerchantMatchTypes); + skipNextFocusRestore(); } goBack(); return; diff --git a/src/components/SpendRules/configuration/SpendRuleMerchantsBase.tsx b/src/components/SpendRules/configuration/SpendRuleMerchantsBase.tsx index 9c55a4be9075..b03186a4cfe7 100644 --- a/src/components/SpendRules/configuration/SpendRuleMerchantsBase.tsx +++ b/src/components/SpendRules/configuration/SpendRuleMerchantsBase.tsx @@ -82,12 +82,12 @@ function SpendRuleMerchantsBase({policyID, action, merchants, getEditMerchantRou /> {merchants.length > 0 ? ( merchants.map(({name, matchType}, index) => { - // Content-bound key (no array index) so delete-in-middle unmounts the right instance and the focus-return rescue can't mis-target a shifted neighbour. - const rowKey = `merchant-${matchType ?? 'unset'}-${name}`; + // `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 ( {pageTitle} - {summaryItems.map(({id, description, title, shouldShowRightIcon, onPress, brickRoadIndicator, errorText, testID}) => { - // Content-bound key (no array index) so a conditionally-hidden row above doesn't re-key items below. Duplicate descriptions collide — pass an explicit `id` (e.g. `INPUT_IDS.*`). - const stableId = id ?? description; + {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 ( void; armNavigateBack: () => void} { const [isArmed, setIsArmed] = useState(false); - const hasNavigatedRef = useRef(false); + const hasNavigatedThisCycleRef = useRef(false); + const armedBackToRef = useRef(undefined); + const prevSavedRef = useRef(isSaved); const navigateBack = () => { Navigation.goBack(backTo); }; const armNavigateBack = () => { - if (hasNavigatedRef.current) { - return; - } + armedBackToRef.current = backTo; setIsArmed(true); }; useEffect(() => { - if (!isArmed || !isSaved || hasNavigatedRef.current) { + // 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 || hasNavigatedThisCycleRef.current) { return; } - hasNavigatedRef.current = true; + hasNavigatedThisCycleRef.current = true; + setIsArmed(false); if (shouldSkipFocusRestore) { skipNextFocusRestore(); } - navigateBack(); - }, [isArmed, isSaved, navigateBack, shouldSkipFocusRestore]); + Navigation.goBack(armedBackToRef.current); + }, [isArmed, isSaved, shouldSkipFocusRestore]); return {navigateBack, armNavigateBack}; } diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx index 44830fbf0acb..a8018d4a2714 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx @@ -1,8 +1,8 @@ import type {NavigatorScreenParams} from '@react-navigation/native'; import {useFocusEffect} from '@react-navigation/native'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; -// eslint-disable-next-line no-restricted-imports import type {View} from 'react-native'; +// eslint-disable-next-line no-restricted-imports import {Animated, DeviceEventEmitter} from 'react-native'; import {DialogLabelProvider} from '@components/DialogLabelContext'; import NoDropZone from '@components/DragAndDrop/NoDropZone'; @@ -112,7 +112,7 @@ const loadSearchSavePage = () => require('../../../../page function RightModalNavigator({navigation, route}: RightModalNavigatorProps) { // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout(); - // Callback ref so DialogLabelProvider's MutationObserver re-attaches when Animated.View remounts across the breakpoint. + // Callback ref so DialogLabelProvider's observer re-attaches if Animated.View remounts across the breakpoint. const [containerNode, setContainerNode] = useState(null); const containerCallbackRef = useCallback((node: View | null) => { setContainerNode(node); diff --git a/tests/unit/BaseGenericPressableRegistryGateTest.tsx b/tests/unit/BaseGenericPressableRegistryGateTest.tsx index ceb39d7b7b7a..8d10f4234b31 100644 --- a/tests/unit/BaseGenericPressableRegistryGateTest.tsx +++ b/tests/unit/BaseGenericPressableRegistryGateTest.tsx @@ -39,7 +39,7 @@ jest.mock('@hooks/useSingleExecution', () => ({ })); const GenericPressable = require<{ - default: React.ComponentType<{id?: string; nativeID?: string; testID?: string; focusKey?: string; onPress?: () => void; children?: React.ReactNode}>; + 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'; @@ -111,22 +111,7 @@ describe('BaseGenericPressable — focus-return registry gate', () => { expect(mockRegisterPressable).not.toHaveBeenCalled(); }); - it('uses explicit `focusKey` over `id`/`testID` so production focus identity is independent of test infrastructure', () => { - mockIsScreenReaderKnownOff = false; - renderInsideRoute( - {}} - />, - ); - expect(mockRegisterPressable).toHaveBeenCalledTimes(1); - const callArgs = mockRegisterPressable.mock.calls.at(0); - expect(callArgs?.[1]).toBe('legal-name-row'); - }); - - it('falls through `id → nativeID → testID` when `focusKey` is absent (backward-compat with existing callers)', () => { + it('prefers `id` when `id`/`nativeID`/`testID` are all set', () => { mockIsScreenReaderKnownOff = false; renderInsideRoute( { expect(mockGoBack).toHaveBeenCalledWith(BACK_TO); }); - it('re-arming after a successful navigate is a no-op (one-shot — the component is unmounting; further arms must not pop past the destination)', () => { + 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}); @@ -120,10 +120,10 @@ describe('useNavigateBackOnSave', () => { rerender({isSaved: false}); act(() => result.current.armNavigateBack()); rerender({isSaved: true}); - expect(mockGoBack).toHaveBeenCalledTimes(1); + expect(mockGoBack).toHaveBeenCalledTimes(2); }); - it('double-tap on save while already saved navigates exactly once (defends the one-shot invariant against rapid repeat arms)', () => { + 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(); @@ -131,4 +131,32 @@ describe('useNavigateBackOnSave', () => { }); 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('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); + }); }); From 91de9cadd60fde3a135ff97e5786d4d11ab1f217 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Wed, 24 Jun 2026 19:34:12 +0300 Subject: [PATCH 84/89] fix: clear stale arm + skip-before-setter --- .../SpendRuleMerchantEditBase.tsx | 2 +- src/hooks/useNavigateBackOnSave/index.ts | 7 ++++- .../Navigators/RightModalNavigator.tsx | 6 ++-- .../NavigationFocusReturn/index.native.ts | 31 +++++++++++++++++-- tests/unit/useDialogContainerFocusTest.tsx | 11 +++++++ tests/unit/useNavigateBackOnSaveTest.ts | 16 ++++++++++ 6 files changed, 65 insertions(+), 8 deletions(-) diff --git a/src/components/SpendRules/configuration/SpendRuleMerchantEditBase.tsx b/src/components/SpendRules/configuration/SpendRuleMerchantEditBase.tsx index f43e712f3d16..d21f4bf0623e 100644 --- a/src/components/SpendRules/configuration/SpendRuleMerchantEditBase.tsx +++ b/src/components/SpendRules/configuration/SpendRuleMerchantEditBase.tsx @@ -73,10 +73,10 @@ function SpendRuleMerchantEditBase({policyID, merchantIndex, merchantMatchTypes, if (!trimmedMerchantName) { if (!isNew) { + skipNextFocusRestore(); const updatedMerchantNames = merchantNames.filter((_, merchantArrayIndex) => merchantArrayIndex !== index); const updatedMerchantMatchTypes = merchantMatchTypes.filter((_, merchantArrayIndex) => merchantArrayIndex !== index); onMerchantDataChange(updatedMerchantNames, updatedMerchantMatchTypes); - skipNextFocusRestore(); } goBack(); return; diff --git a/src/hooks/useNavigateBackOnSave/index.ts b/src/hooks/useNavigateBackOnSave/index.ts index 8cf8ba0232e2..9ee264d11f18 100644 --- a/src/hooks/useNavigateBackOnSave/index.ts +++ b/src/hooks/useNavigateBackOnSave/index.ts @@ -32,7 +32,12 @@ function useNavigateBackOnSave( } prevSavedRef.current = isSaved; - if (!isArmed || !isSaved || hasNavigatedThisCycleRef.current) { + 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; diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx index a8018d4a2714..18dc5edc8a63 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx @@ -114,9 +114,9 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) { const {isSmallScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout(); // Callback ref so DialogLabelProvider's observer re-attaches if Animated.View remounts across the breakpoint. const [containerNode, setContainerNode] = useState(null); - const containerCallbackRef = useCallback((node: View | null) => { + const setContainerNodeFromRef = (node: View | null) => { setContainerNode(node); - }, []); + }; const isExecutingRef = useRef(false); const screenOptions = useRHPScreenOptions(); const {superWideRHPRouteKeys, wideRHPRouteKeys, shouldRenderTertiaryOverlay} = useWideRHPState(); @@ -229,7 +229,7 @@ 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 */} | null { const rawRouteKey = routeKey.split(COMPOUND_KEY_DELIMITER).at(0) ?? routeKey; - const liveRefs = Array.from(pressableRegistry.get(rawRouteKey)?.get(identifier) ?? []).filter((candidate) => candidate.current); - const acceptCollision = COLLISION_TOLERANT_IDENTIFIERS.has(identifier); - return liveRefs.length === 1 || (acceptCollision && liveRefs.length > 1) ? (liveRefs.at(0) ?? null) : 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; } /* diff --git a/tests/unit/useDialogContainerFocusTest.tsx b/tests/unit/useDialogContainerFocusTest.tsx index 80da51a1772a..28aefe8f586d 100644 --- a/tests/unit/useDialogContainerFocusTest.tsx +++ b/tests/unit/useDialogContainerFocusTest.tsx @@ -51,4 +51,15 @@ describe('useDialogContainerFocus — short-circuit order', () => { 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); + // eslint-disable-next-line import/extensions + 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(); + }); }); diff --git a/tests/unit/useNavigateBackOnSaveTest.ts b/tests/unit/useNavigateBackOnSaveTest.ts index 765bdb0a921b..8afb12ef5262 100644 --- a/tests/unit/useNavigateBackOnSaveTest.ts +++ b/tests/unit/useNavigateBackOnSaveTest.ts @@ -143,6 +143,22 @@ describe('useNavigateBackOnSave', () => { 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 `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; From 96a6a0cd3b76e9ef421b259f97120eac2752b058 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Thu, 25 Jun 2026 07:37:01 +0300 Subject: [PATCH 85/89] fix: symmetric arm-time snapshot; hoist split out of retry loop --- src/hooks/useNavigateBackOnSave/index.ts | 8 +++++--- src/libs/NavigationFocusReturn/index.native.ts | 14 ++++++++------ tests/unit/useDialogContainerFocusTest.tsx | 1 - tests/unit/useNavigateBackOnSaveTest.ts | 17 +++++++++++++++++ 4 files changed, 30 insertions(+), 10 deletions(-) diff --git a/src/hooks/useNavigateBackOnSave/index.ts b/src/hooks/useNavigateBackOnSave/index.ts index 9ee264d11f18..d82005d322d9 100644 --- a/src/hooks/useNavigateBackOnSave/index.ts +++ b/src/hooks/useNavigateBackOnSave/index.ts @@ -4,7 +4,7 @@ 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. `backTo` is snapshotted at arm time so a parent clearing route params between arm and save can't strand the user. `shouldSkipFocusRestore: true` only when the destination's Enter shortcut would be hijacked by a re-focused row. + * `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, @@ -14,6 +14,7 @@ function useNavigateBackOnSave( const [isArmed, setIsArmed] = useState(false); const hasNavigatedThisCycleRef = useRef(false); const armedBackToRef = useRef(undefined); + const armedShouldSkipRef = useRef(false); const prevSavedRef = useRef(isSaved); const navigateBack = () => { @@ -22,6 +23,7 @@ function useNavigateBackOnSave( const armNavigateBack = () => { armedBackToRef.current = backTo; + armedShouldSkipRef.current = shouldSkipFocusRestore; setIsArmed(true); }; @@ -42,11 +44,11 @@ function useNavigateBackOnSave( } hasNavigatedThisCycleRef.current = true; setIsArmed(false); - if (shouldSkipFocusRestore) { + if (armedShouldSkipRef.current) { skipNextFocusRestore(); } Navigation.goBack(armedBackToRef.current); - }, [isArmed, isSaved, shouldSkipFocusRestore]); + }, [isArmed, isSaved]); return {navigateBack, armNavigateBack}; } diff --git a/src/libs/NavigationFocusReturn/index.native.ts b/src/libs/NavigationFocusReturn/index.native.ts index 081505e3119f..7351d322a2c1 100644 --- a/src/libs/NavigationFocusReturn/index.native.ts +++ b/src/libs/NavigationFocusReturn/index.native.ts @@ -86,9 +86,8 @@ function captureTriggerForRoute(routeKey: string): void { setFifoEntry(triggerMap, routeKey, {ref: lastPressedTriggerRef, identifier: lastPressedTriggerIdentifier ?? undefined}, TRIGGER_MAP_MAX); } -// Pressables register under the raw route key; PUSH_PARAMS restores arrive under the compound key, so strip the suffix to match. -function resolveLiveRefFromRegistry(routeKey: string, identifier: string): RefObject | null { - const rawRouteKey = routeKey.split(COMPOUND_KEY_DELIMITER).at(0) ?? routeKey; +// 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; @@ -123,12 +122,12 @@ function resolveLiveRefFromRegistry(routeKey: string, identifier: string): RefOb * 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): RefObject | null { +function restoreTriggerForRoute(routeKey: string, rawRouteKey: string): RefObject | null { const entry = triggerMap.get(routeKey); if (!entry) { return null; } - const liveRef = entry.identifier ? resolveLiveRefFromRegistry(routeKey, entry.identifier) : null; + const liveRef = entry.identifier ? resolveLiveRefFromRegistry(rawRouteKey, entry.identifier) : null; const ref = liveRef ?? entry.ref; const view = ref.current; if (!view) { @@ -178,6 +177,9 @@ function scheduleRestore(routeKey: string, {waitForUpcomingTransition}: {waitFor }, }; + // 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, @@ -188,7 +190,7 @@ function scheduleRestore(routeKey: string, {waitForUpcomingTransition}: {waitFor if (cancelled) { return; } - const ref = restoreTriggerForRoute(routeKey); + const ref = restoreTriggerForRoute(routeKey, rawRouteKey); if (ref) { triggerMap.delete(routeKey); refocusHandle = scheduleRefocus(ref); diff --git a/tests/unit/useDialogContainerFocusTest.tsx b/tests/unit/useDialogContainerFocusTest.tsx index 28aefe8f586d..0cac2f012013 100644 --- a/tests/unit/useDialogContainerFocusTest.tsx +++ b/tests/unit/useDialogContainerFocusTest.tsx @@ -55,7 +55,6 @@ describe('useDialogContainerFocus — short-circuit order', () => { 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); - // eslint-disable-next-line import/extensions const TransitionTracker = require<{default: {runAfterTransitions: jest.Mock}}>('../../src/libs/Navigation/TransitionTracker').default; renderHook(() => useDialogContainerFocus(ref, true, gate, false)); diff --git a/tests/unit/useNavigateBackOnSaveTest.ts b/tests/unit/useNavigateBackOnSaveTest.ts index 8afb12ef5262..4f2d38d6b60c 100644 --- a/tests/unit/useNavigateBackOnSaveTest.ts +++ b/tests/unit/useNavigateBackOnSaveTest.ts @@ -159,6 +159,23 @@ describe('useNavigateBackOnSave', () => { 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 `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; From 9da0fc2c61219e4a597fca924ba8cafce74ad32b Mon Sep 17 00:00:00 2001 From: TaduJR Date: Thu, 25 Jun 2026 15:28:32 +0300 Subject: [PATCH 86/89] chore: add comments and some tests --- .../FocusTrap/FocusTrapForModal/index.web.tsx | 2 +- .../FocusTrapForScreen/index.web.tsx | 2 +- .../SpendRuleMerchantEditBase.tsx | 4 +- src/libs/restoreFocusWithModality.ts | 4 +- .../FocusTrap => libs}/sharedTrapStack.ts | 0 .../SpendRuleMerchantEditBaseTest.tsx | 149 ++++++++++++++++++ .../BaseGenericPressableRegistryGateTest.tsx | 24 ++- tests/unit/restoreFocusWithModalityTest.ts | 4 +- tests/unit/useDialogContainerFocusTest.tsx | 14 ++ tests/unit/useNavigateBackOnSaveTest.ts | 17 ++ 10 files changed, 211 insertions(+), 9 deletions(-) rename src/{components/FocusTrap => libs}/sharedTrapStack.ts (100%) create mode 100644 tests/ui/components/SpendRules/SpendRuleMerchantEditBaseTest.tsx diff --git a/src/components/FocusTrap/FocusTrapForModal/index.web.tsx b/src/components/FocusTrap/FocusTrapForModal/index.web.tsx index 5f85784a80aa..fb0260912ac7 100644 --- a/src/components/FocusTrap/FocusTrapForModal/index.web.tsx +++ b/src/components/FocusTrap/FocusTrapForModal/index.web.tsx @@ -1,10 +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 {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) { 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/SpendRules/configuration/SpendRuleMerchantEditBase.tsx b/src/components/SpendRules/configuration/SpendRuleMerchantEditBase.tsx index d21f4bf0623e..5d4370bdb02a 100644 --- a/src/components/SpendRules/configuration/SpendRuleMerchantEditBase.tsx +++ b/src/components/SpendRules/configuration/SpendRuleMerchantEditBase.tsx @@ -73,11 +73,12 @@ function SpendRuleMerchantEditBase({policyID, merchantIndex, merchantMatchTypes, if (!trimmedMerchantName) { if (!isNew) { - skipNextFocusRestore(); const updatedMerchantNames = merchantNames.filter((_, merchantArrayIndex) => merchantArrayIndex !== index); const updatedMerchantMatchTypes = merchantMatchTypes.filter((_, merchantArrayIndex) => 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; } @@ -91,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/libs/restoreFocusWithModality.ts b/src/libs/restoreFocusWithModality.ts index 08beeebaaee9..eb7c861746d7 100644 --- a/src/libs/restoreFocusWithModality.ts +++ b/src/libs/restoreFocusWithModality.ts @@ -1,5 +1,5 @@ -import sharedTrapStack from '@components/FocusTrap/sharedTrapStack'; 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 @@ -15,7 +15,7 @@ function restoreFocusWithModality(el: HTMLElement, {preventScroll = true}: {prev try { el.focus({preventScroll, focusVisible: getHadTabNavigation()}); } finally { - // Unconditional because focus-trap clears `manuallyPaused = false` even when not topmost — the next trap's deactivate auto-unwind depends on it. + // 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(); } 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/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/unit/BaseGenericPressableRegistryGateTest.tsx b/tests/unit/BaseGenericPressableRegistryGateTest.tsx index 8d10f4234b31..c97823265d3a 100644 --- a/tests/unit/BaseGenericPressableRegistryGateTest.tsx +++ b/tests/unit/BaseGenericPressableRegistryGateTest.tsx @@ -1,9 +1,9 @@ import {NavigationRouteContext} from '@react-navigation/native'; -import {render} from '@testing-library/react-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(); +const mockNotifyPressedTrigger = jest.fn(); let mockIsScreenReaderKnownOff = false; let mockIsScreenReaderActive = false; @@ -146,4 +146,24 @@ describe('BaseGenericPressable — focus-return registry gate', () => { ); 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', () => { + mockIsScreenReaderKnownOff = false; + 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/restoreFocusWithModalityTest.ts b/tests/unit/restoreFocusWithModalityTest.ts index 95747a720ae7..963592c135c5 100644 --- a/tests/unit/restoreFocusWithModalityTest.ts +++ b/tests/unit/restoreFocusWithModalityTest.ts @@ -7,12 +7,12 @@ jest.mock('@libs/hadTabNavigation', () => ({ default: () => mockHadTabNavigation, })); -jest.mock('@components/FocusTrap/sharedTrapStack', () => ({ +jest.mock('@libs/sharedTrapStack', () => ({ __esModule: true, default: [], })); -const mockTrapStack = require<{default: MockTrap[]}>('@components/FocusTrap/sharedTrapStack').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 { diff --git a/tests/unit/useDialogContainerFocusTest.tsx b/tests/unit/useDialogContainerFocusTest.tsx index 0cac2f012013..ffabb93b314b 100644 --- a/tests/unit/useDialogContainerFocusTest.tsx +++ b/tests/unit/useDialogContainerFocusTest.tsx @@ -61,4 +61,18 @@ describe('useDialogContainerFocus — short-circuit order', () => { 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 index 4f2d38d6b60c..a079fe6c045a 100644 --- a/tests/unit/useNavigateBackOnSaveTest.ts +++ b/tests/unit/useNavigateBackOnSaveTest.ts @@ -176,6 +176,23 @@ describe('useNavigateBackOnSave', () => { 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; From 4fd277c73c3910a59434180d222b68c9c783e43d Mon Sep 17 00:00:00 2001 From: TaduJR Date: Thu, 25 Jun 2026 15:46:21 +0300 Subject: [PATCH 87/89] chore: knip --- knip.json | 1 + tests/unit/BaseGenericPressableRegistryGateTest.tsx | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) 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/tests/unit/BaseGenericPressableRegistryGateTest.tsx b/tests/unit/BaseGenericPressableRegistryGateTest.tsx index c97823265d3a..42f4cf4e3ee1 100644 --- a/tests/unit/BaseGenericPressableRegistryGateTest.tsx +++ b/tests/unit/BaseGenericPressableRegistryGateTest.tsx @@ -14,8 +14,8 @@ jest.mock('@libs/NavigationFocusReturn', () => ({ mockRegisterPressable(routeKey, identifier, ref); return () => {}; }, - notifyPressedTrigger: (...args: unknown[]) => { - mockNotifyPressedTrigger(...args); + notifyPressedTrigger: (ref: {current: unknown} | null, identifier?: string) => { + mockNotifyPressedTrigger(ref, identifier); }, })); From e7adc29f7a4229e0fe981938c75ea1d6a3d2bcae Mon Sep 17 00:00:00 2001 From: TaduJR Date: Fri, 26 Jun 2026 19:17:59 +0300 Subject: [PATCH 88/89] fix: defensive focus-return during screen-reader warm-up window --- src/components/HeaderWithBackButton/index.tsx | 2 +- .../implementation/BaseGenericPressable.tsx | 6 +- .../index.ts => useInitialFocusRef.ts} | 4 +- src/hooks/useScreenInitialFocus/index.ts | 10 ++-- src/hooks/useScreenInitialFocus/types.ts | 4 +- src/libs/Accessibility/index.ts | 22 +++---- .../NavigationFocusReturn/index.native.ts | 6 +- src/pages/inbox/HeaderView.tsx | 2 +- .../BaseGenericPressableRegistryGateTest.tsx | 26 ++++----- tests/unit/NavigationFocusReturnNativeTest.ts | 7 ++- ...fTest.tsx => useScreenReaderStateTest.tsx} | 58 ++++++++++--------- .../unit/libs/Accessibility/warmCacheTest.ts | 25 ++++---- tests/unit/useScreenInitialFocusTest.tsx | 44 +++++++------- 13 files changed, 113 insertions(+), 103 deletions(-) rename src/hooks/{useInitialFocusRef/index.ts => useInitialFocusRef.ts} (80%) rename tests/unit/libs/Accessibility/{useIsScreenReaderKnownOffTest.tsx => useScreenReaderStateTest.tsx} (57%) diff --git a/src/components/HeaderWithBackButton/index.tsx b/src/components/HeaderWithBackButton/index.tsx index f5be9ecac637..4d116bb8f619 100755 --- a/src/components/HeaderWithBackButton/index.tsx +++ b/src/components/HeaderWithBackButton/index.tsx @@ -91,7 +91,7 @@ function HeaderWithBackButton({ const [isDownloadButtonActive, temporarilyDisableDownloadButton] = useThrottledButtonState(); const {translate} = useLocalize(); const isInLandscapeMode = useIsInLandscapeMode(); - const setBackButtonRef = useInitialFocusRef({skip: shouldSkipFocusAfterTransition}); + const setBackButtonRef = useInitialFocusRef({shouldSkip: shouldSkipFocusAfterTransition}); const downloadReasonAttributes = useMemo( () => ({ diff --git a/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx b/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx index 8a0749aa5a29..70635efd0af8 100644 --- a/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx +++ b/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx @@ -49,7 +49,7 @@ function GenericPressable({ const StyleUtils = useStyleUtils(); const {isExecuting, singleExecution} = useSingleExecution(); const isScreenReaderActive = Accessibility.useScreenReaderStatus(); - const isScreenReaderKnownOff = Accessibility.useIsScreenReaderKnownOff(); + const screenReaderState = Accessibility.useScreenReaderState(); const [hitSlop, onLayout] = Accessibility.useAutoHitSlop(); const [isHovered, setIsHovered] = useState(false); const isRoleButton = [rest.accessibilityRole, rest.role].includes(CONST.ROLE.BUTTON); @@ -61,11 +61,11 @@ function GenericPressable({ const focusIdentifier = rest.id || rest.nativeID || rest.testID || undefined; useEffect(() => { - if (isScreenReaderKnownOff || !routeKey || !focusIdentifier) { + if (screenReaderState === 'disabled' || !routeKey || !focusIdentifier) { return; } return registerPressable(routeKey, focusIdentifier, internalRef); - }, [isScreenReaderKnownOff, routeKey, focusIdentifier]); + }, [screenReaderState, routeKey, focusIdentifier]); const isDisabled = useMemo(() => { let shouldBeDisabledByScreenReader = false; diff --git a/src/hooks/useInitialFocusRef/index.ts b/src/hooks/useInitialFocusRef.ts similarity index 80% rename from src/hooks/useInitialFocusRef/index.ts rename to src/hooks/useInitialFocusRef.ts index 95b64da0100a..18689ac21d93 100644 --- a/src/hooks/useInitialFocusRef/index.ts +++ b/src/hooks/useInitialFocusRef.ts @@ -1,7 +1,7 @@ import {useState} from 'react'; -import useScreenInitialFocus from '@hooks/useScreenInitialFocus'; -import type {UseScreenInitialFocusOptions} from '@hooks/useScreenInitialFocus/types'; 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 { diff --git a/src/hooks/useScreenInitialFocus/index.ts b/src/hooks/useScreenInitialFocus/index.ts index aad219f2566a..f968b7c91c92 100644 --- a/src/hooks/useScreenInitialFocus/index.ts +++ b/src/hooks/useScreenInitialFocus/index.ts @@ -31,11 +31,11 @@ const useScreenInitialFocus: UseScreenInitialFocus = (node, options) => { const status = useContext(ScreenWrapperStatusContext); const {isInsideDialog} = useDialogLabelData(); const claimedRef = useRef(false); - const skip = options?.skip ?? false; - const claimOnlyForScreenReader = options?.claimOnlyForScreenReader ?? false; + const shouldSkip = options?.shouldSkip ?? false; + const shouldClaimOnlyForScreenReader = options?.shouldClaimOnlyForScreenReader ?? false; useEffect(() => { - if (skip || isInsideDialog) { + if (shouldSkip || isInsideDialog) { return; } if (!status?.didScreenTransitionEnd) { @@ -51,7 +51,7 @@ const useScreenInitialFocus: UseScreenInitialFocus = (node, options) => { if (hasHoverSupport() && !getHadTabNavigation()) { return; } - if (claimOnlyForScreenReader && Accessibility.isScreenReaderKnownOff()) { + if (shouldClaimOnlyForScreenReader && Accessibility.getScreenReaderState() === 'disabled') { return; } let rafId: number | null = null; @@ -74,7 +74,7 @@ const useScreenInitialFocus: UseScreenInitialFocus = (node, options) => { } cancelAnimationFrame(rafId); }; - }, [skip, claimOnlyForScreenReader, isInsideDialog, status?.didScreenTransitionEnd, node]); + }, [shouldSkip, shouldClaimOnlyForScreenReader, isInsideDialog, status?.didScreenTransitionEnd, node]); }; export default useScreenInitialFocus; diff --git a/src/hooks/useScreenInitialFocus/types.ts b/src/hooks/useScreenInitialFocus/types.ts index bb625835a500..5657e18b1668 100644 --- a/src/hooks/useScreenInitialFocus/types.ts +++ b/src/hooks/useScreenInitialFocus/types.ts @@ -1,8 +1,8 @@ type UseScreenInitialFocusOptions = { /** Opts the screen out of post-transition initial focus. */ - skip?: boolean; + 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. */ - claimOnlyForScreenReader?: boolean; + shouldClaimOnlyForScreenReader?: boolean; }; type UseScreenInitialFocus = (node: HTMLElement | null, options?: UseScreenInitialFocusOptions) => void; diff --git a/src/libs/Accessibility/index.ts b/src/libs/Accessibility/index.ts index 390eafed9439..af37295b37aa 100644 --- a/src/libs/Accessibility/index.ts +++ b/src/libs/Accessibility/index.ts @@ -102,17 +102,18 @@ function isScreenReaderEnabledSync(): boolean { return cachedScreenReaderValue; } -// True only after the platform query has resolved with false; returns false while warm-up is in-flight (cold start OR AppState resume) so 'unknown' is treated as 'might be on'. -function isScreenReaderKnownOff(): boolean { - return isScreenReaderCacheWarm() && !cachedScreenReaderValue; -} +type ScreenReaderState = 'enabled' | 'disabled' | 'unknown'; -function getIsScreenReaderKnownOffSnapshot(): boolean { - return isScreenReaderKnownOff(); +/** 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 isScreenReaderKnownOff} — for effects that need the tri-state predicate (warm-up = unknown). */ -const useIsScreenReaderKnownOff = (): boolean => useSyncExternalStore(subscribeScreenReader, getIsScreenReaderKnownOffSnapshot, () => false); +/** 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>(); @@ -236,12 +237,13 @@ const useAutoHitSlop = () => { }; export {resetForTests}; +export type {ScreenReaderState}; export default { moveAccessibilityFocus, useScreenReaderStatus, - useIsScreenReaderKnownOff, + useScreenReaderState, useAutoHitSlop, useReducedMotion, isScreenReaderEnabledSync, - isScreenReaderKnownOff, + getScreenReaderState, }; diff --git a/src/libs/NavigationFocusReturn/index.native.ts b/src/libs/NavigationFocusReturn/index.native.ts index 7351d322a2c1..6a2dc9550ba5 100644 --- a/src/libs/NavigationFocusReturn/index.native.ts +++ b/src/libs/NavigationFocusReturn/index.native.ts @@ -75,9 +75,9 @@ function registerPressable(routeKey: string, identifier: string, ref: RefObject< }; } -// Gate on known-off so the warm-up window (cold start, AppState resume) still captures defensively. +// 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.isScreenReaderKnownOff()) { + if (Accessibility.getScreenReaderState() === 'disabled') { return; } if (!lastPressedTriggerRef || performance.now() - lastPressedTriggerAt > PRESS_TRIGGER_TTL_MS) { @@ -153,7 +153,7 @@ function scheduleRestore(routeKey: string, {waitForUpcomingTransition}: {waitFor // 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.isScreenReaderKnownOff()) { + if (Accessibility.getScreenReaderState() === 'disabled') { triggerMap.delete(routeKey); return; } diff --git a/src/pages/inbox/HeaderView.tsx b/src/pages/inbox/HeaderView.tsx index 53c19fdaea80..b5f350d801b8 100644 --- a/src/pages/inbox/HeaderView.tsx +++ b/src/pages/inbox/HeaderView.tsx @@ -126,7 +126,7 @@ function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) const {translate, localeCompare, formatPhoneNumber} = useLocalize(); const theme = useTheme(); const styles = useThemeStyles(); - const setBackButtonRef = useInitialFocusRef({claimOnlyForScreenReader: true}); + const setBackButtonRef = useInitialFocusRef({shouldClaimOnlyForScreenReader: true}); const isSelfDM = isSelfDMReportUtils(report); const isGroupChat = isGroupChatReportUtils(report) || isDeprecatedGroupDM(report, isReportArchived); const isConciergeChat = isConciergeChatReport(report, conciergeReportID); diff --git a/tests/unit/BaseGenericPressableRegistryGateTest.tsx b/tests/unit/BaseGenericPressableRegistryGateTest.tsx index 42f4cf4e3ee1..9e0e9e5debfa 100644 --- a/tests/unit/BaseGenericPressableRegistryGateTest.tsx +++ b/tests/unit/BaseGenericPressableRegistryGateTest.tsx @@ -5,7 +5,7 @@ import React from 'react'; const mockRegisterPressable = jest.fn<() => void, [string, string, {current: unknown}]>(); const mockNotifyPressedTrigger = jest.fn(); -let mockIsScreenReaderKnownOff = false; +let mockScreenReaderState: 'enabled' | 'disabled' | 'unknown' = 'unknown'; let mockIsScreenReaderActive = false; jest.mock('@libs/NavigationFocusReturn', () => ({ @@ -23,7 +23,7 @@ jest.mock('@libs/Accessibility', () => ({ __esModule: true, default: { useScreenReaderStatus: () => mockIsScreenReaderActive, - useIsScreenReaderKnownOff: () => mockIsScreenReaderKnownOff, + useScreenReaderState: () => mockScreenReaderState, useAutoHitSlop: () => [undefined, jest.fn()], moveAccessibilityFocus: jest.fn(), }, @@ -51,13 +51,13 @@ function renderInsideRoute(node: React.ReactElement) { beforeEach(() => { mockRegisterPressable.mockClear(); mockNotifyPressedTrigger.mockClear(); - mockIsScreenReaderKnownOff = false; + mockScreenReaderState = 'unknown'; mockIsScreenReaderActive = false; }); describe('BaseGenericPressable — focus-return registry gate', () => { - it('registers during the SR warm-up window (`isScreenReaderKnownOff` is false) — symmetric with the capture-side `isScreenReaderKnownOff()` bail, so cold-start press → detach → back has a fallback target', () => { - mockIsScreenReaderKnownOff = false; + 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( { }); it('skips registration when SR is known-off (cache warm + value false) so sighted users pay zero registry cost', () => { - mockIsScreenReaderKnownOff = true; + mockScreenReaderState = 'disabled'; renderInsideRoute( { }); it('registers when SR is known-on', () => { - mockIsScreenReaderKnownOff = false; + mockScreenReaderState = 'unknown'; mockIsScreenReaderActive = true; renderInsideRoute( { }); it('does not register when no focusIdentifier is available (no id / nativeID / testID — the registry rescue has no key to use)', () => { - mockIsScreenReaderKnownOff = false; + mockScreenReaderState = 'unknown'; renderInsideRoute( {}} />); expect(mockRegisterPressable).not.toHaveBeenCalled(); }); it('does not register when routeKey is null (consumer outside a navigator)', () => { - mockIsScreenReaderKnownOff = false; + mockScreenReaderState = 'unknown'; render( { }); it('prefers `id` when `id`/`nativeID`/`testID` are all set', () => { - mockIsScreenReaderKnownOff = false; + mockScreenReaderState = 'unknown'; renderInsideRoute( { }); it('uses `nativeID` when `id` is absent', () => { - mockIsScreenReaderKnownOff = false; + mockScreenReaderState = 'unknown'; renderInsideRoute( { }); it('uses `testID` when `id` and `nativeID` are absent', () => { - mockIsScreenReaderKnownOff = false; + mockScreenReaderState = 'unknown'; renderInsideRoute( { }); it('calls notifyPressedTrigger(internalRef, identifier) on press so the focus-return capture has a fresh trigger', () => { - mockIsScreenReaderKnownOff = false; + mockScreenReaderState = 'unknown'; const onPress = jest.fn(); renderInsideRoute( ({ default: { moveAccessibilityFocus: jest.fn(), isScreenReaderEnabledSync: () => mockScreenReaderEnabled, - isScreenReaderKnownOff: () => mockScreenReaderCacheWarmed && !mockScreenReaderEnabled, + getScreenReaderState: () => { + if (!mockScreenReaderCacheWarmed) { + return 'unknown'; + } + return mockScreenReaderEnabled ? 'enabled' : 'disabled'; + }, useScreenReaderStatus: () => mockScreenReaderEnabled, useReducedMotion: () => false, }, diff --git a/tests/unit/libs/Accessibility/useIsScreenReaderKnownOffTest.tsx b/tests/unit/libs/Accessibility/useScreenReaderStateTest.tsx similarity index 57% rename from tests/unit/libs/Accessibility/useIsScreenReaderKnownOffTest.tsx rename to tests/unit/libs/Accessibility/useScreenReaderStateTest.tsx index 09eee2eb7a0c..aec94c35a5cc 100644 --- a/tests/unit/libs/Accessibility/useIsScreenReaderKnownOffTest.tsx +++ b/tests/unit/libs/Accessibility/useScreenReaderStateTest.tsx @@ -24,10 +24,12 @@ jest.mock('react-native', () => ({ }, })); +type ScreenReaderState = 'enabled' | 'disabled' | 'unknown'; + const Accessibility = require<{ default: { - useIsScreenReaderKnownOff: () => boolean; - isScreenReaderKnownOff: () => boolean; + useScreenReaderState: () => ScreenReaderState; + getScreenReaderState: () => ScreenReaderState; }; resetForTests: () => void; }>('@libs/Accessibility'); @@ -43,79 +45,79 @@ beforeEach(() => { mockScreenReaderResolvers.length = 0; }); -describe('useIsScreenReaderKnownOff', () => { - it('returns false during the warm-up window (cache not yet resolved) so callers register/capture defensively', () => { - const {result, unmount} = renderHook(() => Accessibility.default.useIsScreenReaderKnownOff()); - expect(result.current).toBe(false); +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 true after warm resolves with SR-off — the load-bearing reactivity a Pressable mounted mid-warm-up relies on', async () => { + 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.useIsScreenReaderKnownOff(); + return Accessibility.default.useScreenReaderState(); }); - expect(result.current).toBe(false); + expect(result.current).toBe('unknown'); const initialRenderCount = renderCount; await act(async () => { mockScreenReaderResolvers[0]?.(false); await flushPromises(); }); - expect(result.current).toBe(true); + expect(result.current).toBe('disabled'); expect(renderCount).toBeGreaterThan(initialRenderCount); unmount(); }); - it('stays false after warm resolves with SR-on — known-off is true ONLY for known-off', async () => { - const {result, unmount} = renderHook(() => Accessibility.default.useIsScreenReaderKnownOff()); - expect(result.current).toBe(false); + 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(false); + expect(result.current).toBe('enabled'); unmount(); }); - it('snapshot stays consistent with `isScreenReaderKnownOff()` across the warm-up transition', async () => { - expect(Accessibility.default.isScreenReaderKnownOff()).toBe(false); - const {result, unmount} = renderHook(() => Accessibility.default.useIsScreenReaderKnownOff()); - expect(result.current).toBe(Accessibility.default.isScreenReaderKnownOff()); + 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.isScreenReaderKnownOff()); - expect(result.current).toBe(true); + 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(); + 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 knownOff = Accessibility.default.useIsScreenReaderKnownOff(); + const state = Accessibility.default.useScreenReaderState(); const isFirstRunRef = useRef(true); useEffect(() => { - sideEffect(knownOff); + sideEffect(state); if (isFirstRunRef.current) { isFirstRunRef.current = false; } - }, [knownOff]); - return knownOff; + }, [state]); + return state; }); expect(sideEffect).toHaveBeenCalledTimes(1); - expect(sideEffect).toHaveBeenLastCalledWith(false); + expect(sideEffect).toHaveBeenLastCalledWith('unknown'); await act(async () => { mockScreenReaderResolvers[0]?.(false); await flushPromises(); }); expect(sideEffect).toHaveBeenCalledTimes(2); - expect(sideEffect).toHaveBeenLastCalledWith(true); + expect(sideEffect).toHaveBeenLastCalledWith('disabled'); unmount(); }); }); diff --git a/tests/unit/libs/Accessibility/warmCacheTest.ts b/tests/unit/libs/Accessibility/warmCacheTest.ts index 341e388d96c6..5156479b7b4c 100644 --- a/tests/unit/libs/Accessibility/warmCacheTest.ts +++ b/tests/unit/libs/Accessibility/warmCacheTest.ts @@ -1,8 +1,9 @@ type AppStateChangeListener = (status: string) => void; +type ScreenReaderState = 'enabled' | 'disabled' | 'unknown'; type AccessibilityModule = { default: { isScreenReaderEnabledSync: () => boolean; - isScreenReaderKnownOff: () => boolean; + getScreenReaderState: () => ScreenReaderState; }; }; @@ -135,19 +136,19 @@ describe('Accessibility warm cache — AppState refresh', () => { expect(mockReduceMotionFetchCount).toBe(initialReduceMotionFetches); }); - it('isScreenReaderKnownOff returns false before warm resolves and true only after a false-resolution', async () => { + it("getScreenReaderState returns 'unknown' before warm resolves and 'disabled' only after a false-resolution", async () => { mockScreenReaderValue = false; const Accessibility = loadModule(); - expect(Accessibility.default.isScreenReaderKnownOff()).toBe(false); + expect(Accessibility.default.getScreenReaderState()).toBe('unknown'); await flushPromises(); - expect(Accessibility.default.isScreenReaderKnownOff()).toBe(true); + expect(Accessibility.default.getScreenReaderState()).toBe('disabled'); }); - it('isScreenReaderKnownOff returns false after warm resolves with SR enabled', async () => { + it("getScreenReaderState returns 'enabled' after warm resolves with SR enabled", async () => { mockScreenReaderValue = true; const Accessibility = loadModule(); await flushPromises(); - expect(Accessibility.default.isScreenReaderKnownOff()).toBe(false); + expect(Accessibility.default.getScreenReaderState()).toBe('enabled'); }); it('discards a superseded in-flight warm fetch on out-of-order resolution (newer value wins)', async () => { @@ -163,28 +164,28 @@ describe('Accessibility warm cache — AppState refresh', () => { mockScreenReaderResolvers[1](true); await flushPromises(); expect(Accessibility.default.isScreenReaderEnabledSync()).toBe(true); - expect(Accessibility.default.isScreenReaderKnownOff()).toBe(false); + 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.isScreenReaderKnownOff()).toBe(false); + expect(Accessibility.default.getScreenReaderState()).toBe('enabled'); }); - it('isScreenReaderKnownOff returns false during a resume refresh (warmed flag invalidates until the new value resolves)', async () => { + 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.isScreenReaderKnownOff()).toBe(true); + expect(Accessibility.default.getScreenReaderState()).toBe('disabled'); mockScreenReaderValue = true; emitAppState('background'); emitAppState('active'); - expect(Accessibility.default.isScreenReaderKnownOff()).toBe(false); + expect(Accessibility.default.getScreenReaderState()).toBe('unknown'); await flushPromises(); - expect(Accessibility.default.isScreenReaderKnownOff()).toBe(false); + expect(Accessibility.default.getScreenReaderState()).toBe('enabled'); }); it('re-fetches the reduce-motion value on background→active transition', async () => { diff --git a/tests/unit/useScreenInitialFocusTest.tsx b/tests/unit/useScreenInitialFocusTest.tsx index fb54f67385d1..b32eb3c6a4e5 100644 --- a/tests/unit/useScreenInitialFocusTest.tsx +++ b/tests/unit/useScreenInitialFocusTest.tsx @@ -8,13 +8,13 @@ jest.mock('@libs/DeviceCapabilities/hasHoverSupport', () => ({ default: () => mockHasHoverSupport, })); -let mockIsScreenReaderKnownOff = false; +let mockScreenReaderState: 'enabled' | 'disabled' | 'unknown' = 'enabled'; jest.mock('@libs/Accessibility', () => ({ __esModule: true, default: { - isScreenReaderKnownOff: () => mockIsScreenReaderKnownOff, - isScreenReaderEnabledSync: () => !mockIsScreenReaderKnownOff, - useScreenReaderStatus: () => !mockIsScreenReaderKnownOff, + getScreenReaderState: () => mockScreenReaderState, + isScreenReaderEnabledSync: () => mockScreenReaderState === 'enabled', + useScreenReaderStatus: () => mockScreenReaderState === 'enabled', useReducedMotion: () => false, moveAccessibilityFocus: jest.fn(), }, @@ -22,7 +22,7 @@ jest.mock('@libs/Accessibility', () => ({ /* eslint-disable import/extensions */ const {default: useScreenInitialFocus} = require<{ - default: (node: HTMLElement | null, options?: {skip?: boolean; claimOnlyForScreenReader?: boolean}) => void; + 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; @@ -45,22 +45,22 @@ function simulatePointer() { document.dispatchEvent(new Event('pointerdown', {bubbles: true})); } -type HarnessProps = {target: HTMLElement | null; didScreenTransitionEnd: boolean; skip?: boolean; claimOnlyForScreenReader?: boolean}; +type HarnessProps = {target: HTMLElement | null; didScreenTransitionEnd: boolean; shouldSkip?: boolean; shouldClaimOnlyForScreenReader?: boolean}; -function MountedHarness({target, didScreenTransitionEnd, skip, claimOnlyForScreenReader}: HarnessProps) { +function MountedHarness({target, didScreenTransitionEnd, shouldSkip, shouldClaimOnlyForScreenReader}: HarnessProps) { const contextValue = useMemo(() => ({didScreenTransitionEnd, isSafeAreaTopPaddingApplied: false, isSafeAreaBottomPaddingApplied: false}), [didScreenTransitionEnd]); return ( ); } -function Inner({target, skip, claimOnlyForScreenReader}: {target: HTMLElement | null; skip?: boolean; claimOnlyForScreenReader?: boolean}) { - const options = skip === undefined && claimOnlyForScreenReader === undefined ? undefined : {skip, claimOnlyForScreenReader}; +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; } @@ -86,7 +86,7 @@ beforeEach(() => { document.body.innerHTML = ''; resetArbiter(); mockHasHoverSupport = true; - mockIsScreenReaderKnownOff = false; + mockScreenReaderState = 'enabled'; teardownHadTabNavigation(); resetHadTabNavigation(); setupHadTabNavigation(); @@ -261,7 +261,7 @@ describe('useScreenInitialFocus', () => { expect(spy).toHaveBeenCalledTimes(1); }); - it('bails when skip=true so screens that opt out of post-transition focus', () => { + it('bails when shouldSkip=true so screens that opt out of post-transition focus', () => { simulateTab(); const button = makeButton(); const spy = jest.spyOn(button, 'focus'); @@ -269,14 +269,14 @@ describe('useScreenInitialFocus', () => { , ); expect(spy).not.toHaveBeenCalled(); }); - it('claimOnlyForScreenReader=true + SR known-off → bails (keyboard user does not see a ring flash before the screen auto-focuses its own target)', () => { - mockIsScreenReaderKnownOff = true; + 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'); @@ -284,14 +284,14 @@ describe('useScreenInitialFocus', () => { , ); expect(spy).not.toHaveBeenCalled(); }); - it('claimOnlyForScreenReader=true + SR on → claims (TalkBack/VoiceOver needs back-button orientation while the composer is delayed)', () => { - mockIsScreenReaderKnownOff = false; + 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'); @@ -299,14 +299,14 @@ describe('useScreenInitialFocus', () => { , ); expect(spy).toHaveBeenCalledWith({preventScroll: true, focusVisible: true}); }); - it('claimOnlyForScreenReader=false (default) preserves the unconditional claim path so non-chat headers still focus for keyboard users', () => { - mockIsScreenReaderKnownOff = 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'); From 5d1d2275755d61b9d756a25bd33239e4be555ff6 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Fri, 26 Jun 2026 19:39:18 +0300 Subject: [PATCH 89/89] chore: drop unused ScreenReaderState type export --- src/libs/Accessibility/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/libs/Accessibility/index.ts b/src/libs/Accessibility/index.ts index af37295b37aa..0f9e01312231 100644 --- a/src/libs/Accessibility/index.ts +++ b/src/libs/Accessibility/index.ts @@ -237,7 +237,6 @@ const useAutoHitSlop = () => { }; export {resetForTests}; -export type {ScreenReaderState}; export default { moveAccessibilityFocus, useScreenReaderStatus,