From 09fa9b82c043bc7aef43fa7c2d5727610ec438f5 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Mon, 6 Jul 2026 15:48:04 +0200 Subject: [PATCH 01/23] migrate last InteractionManager --- src/hooks/useSingleExecution/index.native.ts | 17 ++++++++++++----- src/libs/Navigation/TransitionTracker.ts | 10 ++++++++++ 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/hooks/useSingleExecution/index.native.ts b/src/hooks/useSingleExecution/index.native.ts index d8aa1770d541..c73013e1b96c 100644 --- a/src/hooks/useSingleExecution/index.native.ts +++ b/src/hooks/useSingleExecution/index.native.ts @@ -1,9 +1,16 @@ +import TransitionTracker from '@libs/Navigation/TransitionTracker'; + import {useCallback, useRef, useState} from 'react'; -// eslint-disable-next-line no-restricted-imports -import {InteractionManager} from 'react-native'; type Action = (...params: T) => void | Promise; +/** + * `action()` may itself kick off a navigation/modal transition, but TransitionTracker won't have + * registered it yet in this same tick. This delay is a guess at how long that registration takes, + * so `isExecuting` isn't cleared before the transition it triggered has actually started. + */ +const TRANSITION_START_GUARD_DELAY_MS = 500; + /** * With any action passed in, it will only allow 1 such action to occur at a time. */ @@ -16,7 +23,7 @@ export default function useSingleExecution() { const singleExecution = useCallback( (action: Action) => (...params: T) => { - if (isExecutingRef.current) { + if (isExecutingRef.current || TransitionTracker.hasActiveTransitions()) { return; } @@ -24,7 +31,7 @@ export default function useSingleExecution() { isExecutingRef.current = true; const execution = action(...params); - InteractionManager.runAfterInteractions(() => { + setTimeout(() => { if (!(execution instanceof Promise)) { setIsExecuting(false); return; @@ -32,7 +39,7 @@ export default function useSingleExecution() { execution.finally(() => { setIsExecuting(false); }); - }); + }, TRANSITION_START_GUARD_DELAY_MS); }, [], ); diff --git a/src/libs/Navigation/TransitionTracker.ts b/src/libs/Navigation/TransitionTracker.ts index 1cc6c932c95d..9a535e333650 100644 --- a/src/libs/Navigation/TransitionTracker.ts +++ b/src/libs/Navigation/TransitionTracker.ts @@ -87,6 +87,15 @@ function startTransition(): TransitionHandle { return handle; } +/** + * Returns whether any transition is currently in flight. + * Useful for synchronous guards that must decide *before* starting new work, as opposed to + * {@link runAfterTransitions}, which schedules a callback for *after* transitions settle. + */ +function hasActiveTransitions(): boolean { + return activeTransitions.size !== 0; +} + /** * Ends the transition identified by {@link handle}. * Clears the corresponding safety timeout since the transition ended normally. @@ -167,6 +176,7 @@ const TransitionTracker = { startTransition, endTransition, runAfterTransitions, + hasActiveTransitions, }; export default TransitionTracker; From 6ed1b5439131886f01b73d88b6ea7a3a824648c9 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Mon, 6 Jul 2026 17:51:48 +0200 Subject: [PATCH 02/23] small refactor without global use of transitionTracker --- src/CONST/index.ts | 1 + src/hooks/useSingleExecution/index.native.ts | 29 +++++++++---------- src/libs/Navigation/TransitionTracker.ts | 10 ------- tests/ui/ProfilePageTest.tsx | 14 +++++++++ .../WorkspaceTravelInvoicingSectionTest.tsx | 14 +++++++++ .../SearchActionsBarCreateButtonTest.tsx | 13 +++++++++ tests/unit/ExportDownloadStatusModalTest.tsx | 13 +++++++++ 7 files changed, 69 insertions(+), 25 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 02832f5bc34f..f509ba472811 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2062,6 +2062,7 @@ const CONST = { LOCATION_UPDATE_INTERVAL: 5000, PLAY_SOUND_MESSAGE_DEBOUNCE_TIME: 500, NOTIFY_NEW_ACTION_DELAY: 700, + SINGLE_EXECUTION_DEBOUNCE_TIME: 500, SKELETON_ANIMATION_SPEED: 3, SHOW_HOVER_PREVIEW_DELAY: 270, SHOW_HOVER_PREVIEW_ANIMATION_DURATION: 250, diff --git a/src/hooks/useSingleExecution/index.native.ts b/src/hooks/useSingleExecution/index.native.ts index c73013e1b96c..06c05f1aee66 100644 --- a/src/hooks/useSingleExecution/index.native.ts +++ b/src/hooks/useSingleExecution/index.native.ts @@ -1,16 +1,11 @@ import TransitionTracker from '@libs/Navigation/TransitionTracker'; +import CONST from '@src/CONST'; + import {useCallback, useRef, useState} from 'react'; type Action = (...params: T) => void | Promise; -/** - * `action()` may itself kick off a navigation/modal transition, but TransitionTracker won't have - * registered it yet in this same tick. This delay is a guess at how long that registration takes, - * so `isExecuting` isn't cleared before the transition it triggered has actually started. - */ -const TRANSITION_START_GUARD_DELAY_MS = 500; - /** * With any action passed in, it will only allow 1 such action to occur at a time. */ @@ -23,7 +18,7 @@ export default function useSingleExecution() { const singleExecution = useCallback( (action: Action) => (...params: T) => { - if (isExecutingRef.current || TransitionTracker.hasActiveTransitions()) { + if (isExecutingRef.current) { return; } @@ -32,14 +27,18 @@ export default function useSingleExecution() { const execution = action(...params); setTimeout(() => { - if (!(execution instanceof Promise)) { - setIsExecuting(false); - return; - } - execution.finally(() => { - setIsExecuting(false); + TransitionTracker.runAfterTransitions({ + callback: () => { + if (!(execution instanceof Promise)) { + setIsExecuting(false); + return; + } + execution.finally(() => { + setIsExecuting(false); + }); + }, }); - }, TRANSITION_START_GUARD_DELAY_MS); + }, CONST.TIMING.SINGLE_EXECUTION_DEBOUNCE_TIME); }, [], ); diff --git a/src/libs/Navigation/TransitionTracker.ts b/src/libs/Navigation/TransitionTracker.ts index 9a535e333650..1cc6c932c95d 100644 --- a/src/libs/Navigation/TransitionTracker.ts +++ b/src/libs/Navigation/TransitionTracker.ts @@ -87,15 +87,6 @@ function startTransition(): TransitionHandle { return handle; } -/** - * Returns whether any transition is currently in flight. - * Useful for synchronous guards that must decide *before* starting new work, as opposed to - * {@link runAfterTransitions}, which schedules a callback for *after* transitions settle. - */ -function hasActiveTransitions(): boolean { - return activeTransitions.size !== 0; -} - /** * Ends the transition identified by {@link handle}. * Clears the corresponding safety timeout since the transition ended normally. @@ -176,7 +167,6 @@ const TransitionTracker = { startTransition, endTransition, runAfterTransitions, - hasActiveTransitions, }; export default TransitionTracker; diff --git a/tests/ui/ProfilePageTest.tsx b/tests/ui/ProfilePageTest.tsx index 4b5115766d16..49539c6597b1 100644 --- a/tests/ui/ProfilePageTest.tsx +++ b/tests/ui/ProfilePageTest.tsx @@ -32,6 +32,20 @@ import * as TestHelper from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; +// This page presses buttons and asserts on the resulting side effects/disabled state, not on +// the double-tap-prevention mechanism itself, which relies on a real (non-fake) timer to clear +// `isExecuting` and won't resolve within this test's lifetime. +jest.mock('@hooks/useSingleExecution', () => ({ + __esModule: true, + default: () => ({ + isExecuting: false, + singleExecution: + (action?: (...params: T) => void | Promise) => + (...params: T) => + action?.(...params), + }), +})); + jest.mock('@libs/Navigation/Navigation', () => ({ navigate: jest.fn(), goBack: jest.fn(), diff --git a/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx b/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx index d4d92b9f7e5c..74c4cb678472 100644 --- a/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx +++ b/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx @@ -32,6 +32,20 @@ const WORKSPACE_ACCOUNT_ID = 999888; // Therefore, they cannot reference variables like POLICY_ID or WORKSPACE_ACCOUNT_ID. // We use literal values that match the constants above. +// This component presses buttons and asserts on the resulting side effects, not on the +// double-tap-prevention mechanism itself, which relies on a real (non-fake) timer to clear +// `isExecuting` and won't resolve within this test's lifetime. +jest.mock('@hooks/useSingleExecution', () => ({ + __esModule: true, + default: () => ({ + isExecuting: false, + singleExecution: + (action?: (...params: T) => void | Promise) => + (...params: T) => + action?.(...params), + }), +})); + jest.mock('@react-navigation/native', () => { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const actualNav = jest.requireActual('@react-navigation/native'); diff --git a/tests/ui/components/SearchActionsBarCreateButtonTest.tsx b/tests/ui/components/SearchActionsBarCreateButtonTest.tsx index 35edcd4342c1..91464ce75645 100644 --- a/tests/ui/components/SearchActionsBarCreateButtonTest.tsx +++ b/tests/ui/components/SearchActionsBarCreateButtonTest.tsx @@ -24,6 +24,19 @@ import Onyx from 'react-native-onyx'; import {translateLocal} from '../../utils/TestHelper'; import waitForBatchedUpdatesWithAct from '../../utils/waitForBatchedUpdatesWithAct'; +// This component presses buttons and asserts on the resulting side effects, not on the +// double-tap-prevention mechanism itself, which relies on a real (non-fake) timer to clear +// `isExecuting` and won't resolve within this test's lifetime. +jest.mock('@hooks/useSingleExecution', () => ({ + __esModule: true, + default: () => ({ + isExecuting: false, + singleExecution: + (action?: (...params: T) => void | Promise) => + (...params: T) => + action?.(...params), + }), +})); jest.mock('@libs/Navigation/Navigation', () => ({ navigate: jest.fn(), setNavigationActionToMicrotaskQueue: jest.fn((cb: () => void) => cb()), diff --git a/tests/unit/ExportDownloadStatusModalTest.tsx b/tests/unit/ExportDownloadStatusModalTest.tsx index 520a01d61a28..7b86b23564c0 100644 --- a/tests/unit/ExportDownloadStatusModalTest.tsx +++ b/tests/unit/ExportDownloadStatusModalTest.tsx @@ -13,6 +13,19 @@ import Onyx from 'react-native-onyx'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; +// This modal presses buttons and asserts on the resulting side effects, not on the +// double-tap-prevention mechanism itself, which relies on a real (non-fake) timer to clear +// `isExecuting` and won't resolve within this test's lifetime. +jest.mock('@hooks/useSingleExecution', () => ({ + __esModule: true, + default: () => ({ + isExecuting: false, + singleExecution: + (action?: (...params: T) => void | Promise) => + (...params: T) => + action?.(...params), + }), +})); jest.mock('@libs/fileDownload'); jest.mock('@components/RenderHTML', () => { function MockRenderHTML({html}: {html: string}) { From c54c3123cde73a5946983589eb35fa579a50f913 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Mon, 6 Jul 2026 17:59:57 +0200 Subject: [PATCH 03/23] encapsulate mockUseSingleExecution and reuse it --- tests/ui/ProfilePageTest.tsx | 16 ++++--------- .../WorkspaceTravelInvoicingSectionTest.tsx | 16 ++++--------- .../SearchActionsBarCreateButtonTest.tsx | 16 ++++--------- tests/unit/ExportDownloadStatusModalTest.tsx | 16 ++++--------- tests/utils/mockUseSingleExecution.ts | 24 +++++++++++++++++++ 5 files changed, 40 insertions(+), 48 deletions(-) create mode 100644 tests/utils/mockUseSingleExecution.ts diff --git a/tests/ui/ProfilePageTest.tsx b/tests/ui/ProfilePageTest.tsx index 49539c6597b1..0ee34a0bc189 100644 --- a/tests/ui/ProfilePageTest.tsx +++ b/tests/ui/ProfilePageTest.tsx @@ -28,23 +28,15 @@ import {NavigationContainer} from '@react-navigation/native'; import React from 'react'; import Onyx from 'react-native-onyx'; +import type * as MockUseSingleExecution from '../utils/mockUseSingleExecution'; + import * as TestHelper from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; // This page presses buttons and asserts on the resulting side effects/disabled state, not on -// the double-tap-prevention mechanism itself, which relies on a real (non-fake) timer to clear -// `isExecuting` and won't resolve within this test's lifetime. -jest.mock('@hooks/useSingleExecution', () => ({ - __esModule: true, - default: () => ({ - isExecuting: false, - singleExecution: - (action?: (...params: T) => void | Promise) => - (...params: T) => - action?.(...params), - }), -})); +// the double-tap-prevention mechanism itself (see tests/utils/mockUseSingleExecution.ts). +jest.mock('@hooks/useSingleExecution', () => jest.requireActual('../utils/mockUseSingleExecution')); jest.mock('@libs/Navigation/Navigation', () => ({ navigate: jest.fn(), diff --git a/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx b/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx index 74c4cb678472..5901e039caec 100644 --- a/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx +++ b/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx @@ -20,6 +20,8 @@ import type {Policy} from '@src/types/onyx'; import React from 'react'; import Onyx from 'react-native-onyx'; +import type * as MockUseSingleExecution from '../utils/mockUseSingleExecution'; + import createRandomPolicy from '../utils/collections/policies'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; @@ -33,18 +35,8 @@ const WORKSPACE_ACCOUNT_ID = 999888; // We use literal values that match the constants above. // This component presses buttons and asserts on the resulting side effects, not on the -// double-tap-prevention mechanism itself, which relies on a real (non-fake) timer to clear -// `isExecuting` and won't resolve within this test's lifetime. -jest.mock('@hooks/useSingleExecution', () => ({ - __esModule: true, - default: () => ({ - isExecuting: false, - singleExecution: - (action?: (...params: T) => void | Promise) => - (...params: T) => - action?.(...params), - }), -})); +// double-tap-prevention mechanism itself (see tests/utils/mockUseSingleExecution.ts). +jest.mock('@hooks/useSingleExecution', () => jest.requireActual('../utils/mockUseSingleExecution')); jest.mock('@react-navigation/native', () => { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment diff --git a/tests/ui/components/SearchActionsBarCreateButtonTest.tsx b/tests/ui/components/SearchActionsBarCreateButtonTest.tsx index 91464ce75645..e09d75a54653 100644 --- a/tests/ui/components/SearchActionsBarCreateButtonTest.tsx +++ b/tests/ui/components/SearchActionsBarCreateButtonTest.tsx @@ -21,22 +21,14 @@ import {getUnixTime, subDays} from 'date-fns'; import React from 'react'; import Onyx from 'react-native-onyx'; +import type * as MockUseSingleExecution from '../../utils/mockUseSingleExecution'; + import {translateLocal} from '../../utils/TestHelper'; import waitForBatchedUpdatesWithAct from '../../utils/waitForBatchedUpdatesWithAct'; // This component presses buttons and asserts on the resulting side effects, not on the -// double-tap-prevention mechanism itself, which relies on a real (non-fake) timer to clear -// `isExecuting` and won't resolve within this test's lifetime. -jest.mock('@hooks/useSingleExecution', () => ({ - __esModule: true, - default: () => ({ - isExecuting: false, - singleExecution: - (action?: (...params: T) => void | Promise) => - (...params: T) => - action?.(...params), - }), -})); +// double-tap-prevention mechanism itself (see tests/utils/mockUseSingleExecution.ts). +jest.mock('@hooks/useSingleExecution', () => jest.requireActual('../../utils/mockUseSingleExecution')); jest.mock('@libs/Navigation/Navigation', () => ({ navigate: jest.fn(), setNavigationActionToMicrotaskQueue: jest.fn((cb: () => void) => cb()), diff --git a/tests/unit/ExportDownloadStatusModalTest.tsx b/tests/unit/ExportDownloadStatusModalTest.tsx index 7b86b23564c0..d433c0a2307b 100644 --- a/tests/unit/ExportDownloadStatusModalTest.tsx +++ b/tests/unit/ExportDownloadStatusModalTest.tsx @@ -11,21 +11,13 @@ import ONYXKEYS from '@src/ONYXKEYS'; import React from 'react'; import Onyx from 'react-native-onyx'; +import type * as MockUseSingleExecution from '../utils/mockUseSingleExecution'; + import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; // This modal presses buttons and asserts on the resulting side effects, not on the -// double-tap-prevention mechanism itself, which relies on a real (non-fake) timer to clear -// `isExecuting` and won't resolve within this test's lifetime. -jest.mock('@hooks/useSingleExecution', () => ({ - __esModule: true, - default: () => ({ - isExecuting: false, - singleExecution: - (action?: (...params: T) => void | Promise) => - (...params: T) => - action?.(...params), - }), -})); +// double-tap-prevention mechanism itself (see tests/utils/mockUseSingleExecution.ts). +jest.mock('@hooks/useSingleExecution', () => jest.requireActual('../utils/mockUseSingleExecution')); jest.mock('@libs/fileDownload'); jest.mock('@components/RenderHTML', () => { function MockRenderHTML({html}: {html: string}) { diff --git a/tests/utils/mockUseSingleExecution.ts b/tests/utils/mockUseSingleExecution.ts new file mode 100644 index 000000000000..53c951bacd55 --- /dev/null +++ b/tests/utils/mockUseSingleExecution.ts @@ -0,0 +1,24 @@ +/** + * Test-only replacement for `useSingleExecution` that calls the action synchronously and never + * reports `isExecuting`. Use via: + * + * jest.mock('@hooks/useSingleExecution', () => + * jest.requireActual('.../mockUseSingleExecution'), + * ); + * + * in tests that press a button and assert on the resulting side effect, but aren't testing the + * double-tap-prevention mechanism itself. That mechanism relies on a real (non-fake) timer to + * clear `isExecuting` (see CONST.TIMING.SINGLE_EXECUTION_DEBOUNCE_TIME), so it won't resolve + * within a normal test's lifetime and would otherwise leave buttons stuck disabled. + */ +function useSingleExecution() { + return { + isExecuting: false, + singleExecution: + (action?: (...params: T) => void | Promise) => + (...params: T) => + action?.(...params), + }; +} + +export default useSingleExecution; From 6c22f72a42a121330102097ac95ba432a309a14b Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Tue, 7 Jul 2026 13:44:44 +0200 Subject: [PATCH 04/23] additional adjustments + cleaning timeout --- src/hooks/useSingleExecution/index.native.ts | 21 +++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/hooks/useSingleExecution/index.native.ts b/src/hooks/useSingleExecution/index.native.ts index 06c05f1aee66..d375e8760d3b 100644 --- a/src/hooks/useSingleExecution/index.native.ts +++ b/src/hooks/useSingleExecution/index.native.ts @@ -1,8 +1,9 @@ import TransitionTracker from '@libs/Navigation/TransitionTracker'; +import type {CancelHandle} from '@libs/Navigation/TransitionTracker'; import CONST from '@src/CONST'; -import {useCallback, useRef, useState} from 'react'; +import {useCallback, useEffect, useRef, useState} from 'react'; type Action = (...params: T) => void | Promise; @@ -12,9 +13,21 @@ type Action = (...params: T) => void | Promise; export default function useSingleExecution() { const [isExecuting, setIsExecuting] = useState(false); const isExecutingRef = useRef(undefined); + const timeoutRef = useRef(null); + const transitionHandleRef = useRef(null); isExecutingRef.current = isExecuting; + useEffect( + () => () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + transitionHandleRef.current?.cancel(); + }, + [], + ); + const singleExecution = useCallback( (action: Action) => (...params: T) => { @@ -26,8 +39,10 @@ export default function useSingleExecution() { isExecutingRef.current = true; const execution = action(...params); - setTimeout(() => { - TransitionTracker.runAfterTransitions({ + // The timeout is a minimum debounce applied to every press; checking TransitionTracker afterwards + // is an extra safety net in case a transition is still in progress once the debounce ends. + timeoutRef.current = setTimeout(() => { + transitionHandleRef.current = TransitionTracker.runAfterTransitions({ callback: () => { if (!(execution instanceof Promise)) { setIsExecuting(false); From ecf9ca1fc1a2a6a05732f785b4cbe28df6198097 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Thu, 9 Jul 2026 16:20:51 +0200 Subject: [PATCH 05/23] add shouldUseSingleExecution prop to genericPressable and disable it when it has UX regression because of debounce --- src/components/BigNumberPad.tsx | 1 + src/components/Button/index.tsx | 10 ++++++++++ src/components/DatePicker/CalendarPicker/index.tsx | 5 +++++ .../implementation/BaseGenericPressable.tsx | 5 ++++- src/components/Pressable/GenericPressable/types.ts | 8 ++++++++ src/components/PrevNextButtons.tsx | 2 ++ src/components/TabSelector/TabSelectorItem.tsx | 1 + 7 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/components/BigNumberPad.tsx b/src/components/BigNumberPad.tsx index 25f053bf90e2..0eb7179f186c 100644 --- a/src/components/BigNumberPad.tsx +++ b/src/components/BigNumberPad.tsx @@ -89,6 +89,7 @@ function BigNumberPad({numberPressed, longPressHandlerStateChanged = () => {}, i icon={column === '<' ? icons.BackArrow : undefined} onLongPress={() => handleLongPress(column)} onPress={() => numberPressed(column)} + shouldUseSingleExecution={false} onPressIn={ControlSelection.block} onPressOut={() => { if (timer) { diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index 1df5787c2518..f1d1c1da4947 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -196,6 +196,14 @@ type ButtonProps = Partial & * Whether the button should stay visually normal even when disabled. */ shouldStayNormalOnDisable?: boolean; + + /** + * Whether the single-execution guard (native double-tap/duplicate-navigation debounce) should be applied to this button's press. + * Set to false for buttons that intentionally expect rapid repeated presses and never trigger a navigation/modal transition + * (e.g. numeric keypad digits), since the guard's fixed debounce would otherwise rate-limit them for no benefit. + * @default true + */ + shouldUseSingleExecution?: boolean; }; type KeyboardShortcutComponentProps = Pick; @@ -302,6 +310,7 @@ function Button({ secondLineText = '', shouldBlendOpacity = false, shouldStayNormalOnDisable = false, + shouldUseSingleExecution = true, accessibilityState, sentryLabel, ref, @@ -535,6 +544,7 @@ function Button({ onPressOut={onPressOut} onMouseDown={onMouseDown} shouldBlendOpacity={shouldBlendOpacity} + shouldUseSingleExecution={shouldUseSingleExecution} disabled={isLoading || isDisabled} wrapperStyle={[ isDisabled && !shouldStayNormalOnDisable ? {...styles.cursorDisabled, ...styles.noSelect} : {}, diff --git a/src/components/DatePicker/CalendarPicker/index.tsx b/src/components/DatePicker/CalendarPicker/index.tsx index 7ff029cc1e77..7d53dfdf8351 100644 --- a/src/components/DatePicker/CalendarPicker/index.tsx +++ b/src/components/DatePicker/CalendarPicker/index.tsx @@ -265,6 +265,7 @@ function CalendarPicker({ accessibilityLabel={translate('common.previousMonth')} role={CONST.ROLE.BUTTON} sentryLabel={CONST.SENTRY_LABEL.CALENDAR_PICKER.PREV_MONTH} + shouldUseSingleExecution={false} > @@ -318,6 +320,7 @@ function CalendarPicker({ accessibilityLabel={translate('common.previousYear')} role={CONST.ROLE.BUTTON} sentryLabel={CONST.SENTRY_LABEL.CALENDAR_PICKER.PREV_YEAR} + shouldUseSingleExecution={false} > @@ -416,6 +420,7 @@ function CalendarPicker({ dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}} role={CONST.ROLE.BUTTON} sentryLabel={CONST.SENTRY_LABEL.CALENDAR_PICKER.DAY} + shouldUseSingleExecution={false} > {({hovered, pressed}) => ( { onPressHandler(event); @@ -183,7 +186,7 @@ function GenericPressable({ onLayout={shouldUseAutoHitSlop ? onLayout : undefined} ref={ref as ForwardedRef} disabled={fullDisabled || undefined} - onPress={!isDisabled ? singleExecution(onPressHandler) : undefined} + onPress={!isDisabled ? conditionalOnPressHandler : undefined} onLongPress={!isDisabled && onLongPress ? onLongPressHandler : undefined} onKeyDown={!isDisabled ? handleKeyDown : undefined} onPressIn={!isDisabled ? onPressIn : undefined} diff --git a/src/components/Pressable/GenericPressable/types.ts b/src/components/Pressable/GenericPressable/types.ts index 1b44e6010ad9..d7def6e9ef30 100644 --- a/src/components/Pressable/GenericPressable/types.ts +++ b/src/components/Pressable/GenericPressable/types.ts @@ -160,6 +160,14 @@ type PressableProps = RNPressableProps & */ isNested?: boolean; + /** + * Whether the single-execution guard (native double-tap/duplicate-navigation debounce) should be applied to this press. + * Set to false for presses that intentionally expect rapid repeated input and never trigger a navigation/modal transition + * (e.g. keypad digits, calendar day cells), since the guard's fixed debounce would otherwise rate-limit them for no benefit. + * @default true + */ + shouldUseSingleExecution?: boolean; + /** * Reference to the outer element. */ diff --git a/src/components/PrevNextButtons.tsx b/src/components/PrevNextButtons.tsx index 8f78fa44c6f0..5b7830ed7c08 100644 --- a/src/components/PrevNextButtons.tsx +++ b/src/components/PrevNextButtons.tsx @@ -43,6 +43,7 @@ function PrevNextButtons({isPrevButtonDisabled, isNextButtonDisabled, onNext, on style={[styles.h7, styles.mr1, styles.alignItemsCenter, styles.justifyContentCenter]} onPress={onPrevious} sentryLabel={CONST.SENTRY_LABEL.PREV_NEXT_BUTTONS.PREV_BUTTON} + shouldUseSingleExecution={false} > Date: Thu, 9 Jul 2026 16:37:31 +0200 Subject: [PATCH 06/23] update TransitionTracker logic --- src/hooks/useSingleExecution/index.native.ts | 34 +++++++++----------- src/libs/Navigation/TransitionTracker.ts | 15 +++++++-- 2 files changed, 27 insertions(+), 22 deletions(-) diff --git a/src/hooks/useSingleExecution/index.native.ts b/src/hooks/useSingleExecution/index.native.ts index d375e8760d3b..7d3c202e2c66 100644 --- a/src/hooks/useSingleExecution/index.native.ts +++ b/src/hooks/useSingleExecution/index.native.ts @@ -13,16 +13,12 @@ type Action = (...params: T) => void | Promise; export default function useSingleExecution() { const [isExecuting, setIsExecuting] = useState(false); const isExecutingRef = useRef(undefined); - const timeoutRef = useRef(null); const transitionHandleRef = useRef(null); isExecutingRef.current = isExecuting; useEffect( () => () => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } transitionHandleRef.current?.cancel(); }, [], @@ -39,21 +35,21 @@ export default function useSingleExecution() { isExecutingRef.current = true; const execution = action(...params); - // The timeout is a minimum debounce applied to every press; checking TransitionTracker afterwards - // is an extra safety net in case a transition is still in progress once the debounce ends. - timeoutRef.current = setTimeout(() => { - transitionHandleRef.current = TransitionTracker.runAfterTransitions({ - callback: () => { - if (!(execution instanceof Promise)) { - setIsExecuting(false); - return; - } - execution.finally(() => { - setIsExecuting(false); - }); - }, - }); - }, CONST.TIMING.SINGLE_EXECUTION_DEBOUNCE_TIME); + // The capped waitForUpcomingTransition wait is a minimum debounce applied to every press, TransitionTracker's + // active-transition check afterwards is an extra safety net in case a transition is still in progress once the wait ends. + transitionHandleRef.current = TransitionTracker.runAfterTransitions({ + waitForUpcomingTransition: true, + maxWaitForUpcomingTransitionMs: CONST.TIMING.SINGLE_EXECUTION_DEBOUNCE_TIME, + callback: () => { + if (!(execution instanceof Promise)) { + setIsExecuting(false); + return; + } + execution.finally(() => { + setIsExecuting(false); + }); + }, + }); }, [], ); diff --git a/src/libs/Navigation/TransitionTracker.ts b/src/libs/Navigation/TransitionTracker.ts index 5e65146bf935..336c207eb88f 100644 --- a/src/libs/Navigation/TransitionTracker.ts +++ b/src/libs/Navigation/TransitionTracker.ts @@ -17,6 +17,9 @@ type RunAfterTransitionsOptions = { * Useful when a navigation action has just been dispatched but the transition has not yet been registered. * Defaults to false. */ waitForUpcomingTransition?: boolean; + + /** Maximum time to wait for the upcoming transition to start. Used only when waitForUpcomingTransition is true. */ + maxWaitForUpcomingTransitionMs?: number; }; const activeTransitions = new Map>(); @@ -116,9 +119,15 @@ function endTransition(handle: TransitionHandle): void { * @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.maxWaitForUpcomingTransitionMs - Maximum time to wait for the upcoming transition to start. Defaults to {@link CONST.MAX_TRANSITION_START_WAIT_MS}. * @returns A handle with a `cancel` method to prevent the callback from firing. */ -function runAfterTransitions({callback, runImmediately = false, waitForUpcomingTransition = false}: RunAfterTransitionsOptions): CancelHandle { +function runAfterTransitions({ + callback, + runImmediately = false, + waitForUpcomingTransition = false, + maxWaitForUpcomingTransitionMs = CONST.MAX_TRANSITION_START_WAIT_MS, +}: RunAfterTransitionsOptions): CancelHandle { if (waitForUpcomingTransition) { let cancelled = false; let innerHandle: CancelHandle | null = null; @@ -133,7 +142,7 @@ function runAfterTransitions({callback, runImmediately = false, waitForUpcomingT transitionStartTimeoutId = setTimeout(() => { didTimeout = true; resolve(); - }, CONST.MAX_TRANSITION_START_WAIT_MS); + }, maxWaitForUpcomingTransitionMs); }); (async () => { @@ -141,7 +150,7 @@ function runAfterTransitions({callback, runImmediately = false, waitForUpcomingT clearTimeout(transitionStartTimeoutId); if (didTimeout && !cancelled) { - Log.info('[TransitionTracker] waitForUpcomingTransition timed out before a transition started', false, {timeoutMs: CONST.MAX_TRANSITION_START_WAIT_MS}); + Log.info('[TransitionTracker] waitForUpcomingTransition timed out before a transition started', false, {timeoutMs: maxWaitForUpcomingTransitionMs}); } if (!cancelled) { From 8e0f630d368602cb828ab134e18e6a29072c2e15 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Tue, 14 Jul 2026 16:59:24 +0200 Subject: [PATCH 07/23] discard changes with new prop for generic pressable --- src/components/BigNumberPad.tsx | 1 - src/components/Button/index.tsx | 10 ------ .../DatePicker/CalendarPicker/index.tsx | 5 --- .../implementation/BaseGenericPressable.tsx | 5 +-- .../Pressable/GenericPressable/types.ts | 8 ----- src/components/PrevNextButtons.tsx | 2 -- .../TabSelector/TabSelectorItem.tsx | 1 - src/hooks/useSingleExecution/index.native.ts | 34 +++++++++++-------- src/libs/Navigation/TransitionTracker.ts | 15 ++------ 9 files changed, 23 insertions(+), 58 deletions(-) diff --git a/src/components/BigNumberPad.tsx b/src/components/BigNumberPad.tsx index 0eb7179f186c..25f053bf90e2 100644 --- a/src/components/BigNumberPad.tsx +++ b/src/components/BigNumberPad.tsx @@ -89,7 +89,6 @@ function BigNumberPad({numberPressed, longPressHandlerStateChanged = () => {}, i icon={column === '<' ? icons.BackArrow : undefined} onLongPress={() => handleLongPress(column)} onPress={() => numberPressed(column)} - shouldUseSingleExecution={false} onPressIn={ControlSelection.block} onPressOut={() => { if (timer) { diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index 7dba60a2cadb..65b00723de4a 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -196,14 +196,6 @@ type ButtonProps = Partial & * Whether the button should stay visually normal even when disabled. */ shouldStayNormalOnDisable?: boolean; - - /** - * Whether the single-execution guard (native double-tap/duplicate-navigation debounce) should be applied to this button's press. - * Set to false for buttons that intentionally expect rapid repeated presses and never trigger a navigation/modal transition - * (e.g. numeric keypad digits), since the guard's fixed debounce would otherwise rate-limit them for no benefit. - * @default true - */ - shouldUseSingleExecution?: boolean; }; type KeyboardShortcutComponentProps = Pick; @@ -310,7 +302,6 @@ function Button({ secondLineText = '', shouldBlendOpacity = false, shouldStayNormalOnDisable = false, - shouldUseSingleExecution = true, accessibilityState, sentryLabel, ref, @@ -552,7 +543,6 @@ function Button({ onPressOut={onPressOut} onMouseDown={onMouseDown} shouldBlendOpacity={shouldBlendOpacity} - shouldUseSingleExecution={shouldUseSingleExecution} disabled={isLoading || isDisabled} wrapperStyle={[ isDisabled && !shouldStayNormalOnDisable ? {...styles.cursorDisabled, ...styles.noSelect} : {}, diff --git a/src/components/DatePicker/CalendarPicker/index.tsx b/src/components/DatePicker/CalendarPicker/index.tsx index 7d53dfdf8351..7ff029cc1e77 100644 --- a/src/components/DatePicker/CalendarPicker/index.tsx +++ b/src/components/DatePicker/CalendarPicker/index.tsx @@ -265,7 +265,6 @@ function CalendarPicker({ accessibilityLabel={translate('common.previousMonth')} role={CONST.ROLE.BUTTON} sentryLabel={CONST.SENTRY_LABEL.CALENDAR_PICKER.PREV_MONTH} - shouldUseSingleExecution={false} > @@ -320,7 +318,6 @@ function CalendarPicker({ accessibilityLabel={translate('common.previousYear')} role={CONST.ROLE.BUTTON} sentryLabel={CONST.SENTRY_LABEL.CALENDAR_PICKER.PREV_YEAR} - shouldUseSingleExecution={false} > @@ -420,7 +416,6 @@ function CalendarPicker({ dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}} role={CONST.ROLE.BUTTON} sentryLabel={CONST.SENTRY_LABEL.CALENDAR_PICKER.DAY} - shouldUseSingleExecution={false} > {({hovered, pressed}) => ( { onPressHandler(event); @@ -186,7 +183,7 @@ function GenericPressable({ onLayout={shouldUseAutoHitSlop ? onLayout : undefined} ref={ref as ForwardedRef} disabled={fullDisabled || undefined} - onPress={!isDisabled ? conditionalOnPressHandler : undefined} + onPress={!isDisabled ? singleExecution(onPressHandler) : undefined} onLongPress={!isDisabled && onLongPress ? onLongPressHandler : undefined} onKeyDown={!isDisabled ? handleKeyDown : undefined} onPressIn={!isDisabled ? onPressIn : undefined} diff --git a/src/components/Pressable/GenericPressable/types.ts b/src/components/Pressable/GenericPressable/types.ts index d7def6e9ef30..1b44e6010ad9 100644 --- a/src/components/Pressable/GenericPressable/types.ts +++ b/src/components/Pressable/GenericPressable/types.ts @@ -160,14 +160,6 @@ type PressableProps = RNPressableProps & */ isNested?: boolean; - /** - * Whether the single-execution guard (native double-tap/duplicate-navigation debounce) should be applied to this press. - * Set to false for presses that intentionally expect rapid repeated input and never trigger a navigation/modal transition - * (e.g. keypad digits, calendar day cells), since the guard's fixed debounce would otherwise rate-limit them for no benefit. - * @default true - */ - shouldUseSingleExecution?: boolean; - /** * Reference to the outer element. */ diff --git a/src/components/PrevNextButtons.tsx b/src/components/PrevNextButtons.tsx index 5b7830ed7c08..8f78fa44c6f0 100644 --- a/src/components/PrevNextButtons.tsx +++ b/src/components/PrevNextButtons.tsx @@ -43,7 +43,6 @@ function PrevNextButtons({isPrevButtonDisabled, isNextButtonDisabled, onNext, on style={[styles.h7, styles.mr1, styles.alignItemsCenter, styles.justifyContentCenter]} onPress={onPrevious} sentryLabel={CONST.SENTRY_LABEL.PREV_NEXT_BUTTONS.PREV_BUTTON} - shouldUseSingleExecution={false} > = (...params: T) => void | Promise; export default function useSingleExecution() { const [isExecuting, setIsExecuting] = useState(false); const isExecutingRef = useRef(undefined); + const timeoutRef = useRef(null); const transitionHandleRef = useRef(null); isExecutingRef.current = isExecuting; useEffect( () => () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } transitionHandleRef.current?.cancel(); }, [], @@ -35,21 +39,21 @@ export default function useSingleExecution() { isExecutingRef.current = true; const execution = action(...params); - // The capped waitForUpcomingTransition wait is a minimum debounce applied to every press, TransitionTracker's - // active-transition check afterwards is an extra safety net in case a transition is still in progress once the wait ends. - transitionHandleRef.current = TransitionTracker.runAfterTransitions({ - waitForUpcomingTransition: true, - maxWaitForUpcomingTransitionMs: CONST.TIMING.SINGLE_EXECUTION_DEBOUNCE_TIME, - callback: () => { - if (!(execution instanceof Promise)) { - setIsExecuting(false); - return; - } - execution.finally(() => { - setIsExecuting(false); - }); - }, - }); + // The timeout is a minimum debounce applied to every press; checking TransitionTracker afterwards + // is an extra safety net in case a transition is still in progress once the debounce ends. + timeoutRef.current = setTimeout(() => { + transitionHandleRef.current = TransitionTracker.runAfterTransitions({ + callback: () => { + if (!(execution instanceof Promise)) { + setIsExecuting(false); + return; + } + execution.finally(() => { + setIsExecuting(false); + }); + }, + }); + }, CONST.TIMING.SINGLE_EXECUTION_DEBOUNCE_TIME); }, [], ); diff --git a/src/libs/Navigation/TransitionTracker.ts b/src/libs/Navigation/TransitionTracker.ts index 336c207eb88f..5e65146bf935 100644 --- a/src/libs/Navigation/TransitionTracker.ts +++ b/src/libs/Navigation/TransitionTracker.ts @@ -17,9 +17,6 @@ type RunAfterTransitionsOptions = { * Useful when a navigation action has just been dispatched but the transition has not yet been registered. * Defaults to false. */ waitForUpcomingTransition?: boolean; - - /** Maximum time to wait for the upcoming transition to start. Used only when waitForUpcomingTransition is true. */ - maxWaitForUpcomingTransitionMs?: number; }; const activeTransitions = new Map>(); @@ -119,15 +116,9 @@ function endTransition(handle: TransitionHandle): void { * @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.maxWaitForUpcomingTransitionMs - Maximum time to wait for the upcoming transition to start. Defaults to {@link CONST.MAX_TRANSITION_START_WAIT_MS}. * @returns A handle with a `cancel` method to prevent the callback from firing. */ -function runAfterTransitions({ - callback, - runImmediately = false, - waitForUpcomingTransition = false, - maxWaitForUpcomingTransitionMs = CONST.MAX_TRANSITION_START_WAIT_MS, -}: RunAfterTransitionsOptions): CancelHandle { +function runAfterTransitions({callback, runImmediately = false, waitForUpcomingTransition = false}: RunAfterTransitionsOptions): CancelHandle { if (waitForUpcomingTransition) { let cancelled = false; let innerHandle: CancelHandle | null = null; @@ -142,7 +133,7 @@ function runAfterTransitions({ transitionStartTimeoutId = setTimeout(() => { didTimeout = true; resolve(); - }, maxWaitForUpcomingTransitionMs); + }, CONST.MAX_TRANSITION_START_WAIT_MS); }); (async () => { @@ -150,7 +141,7 @@ function runAfterTransitions({ clearTimeout(transitionStartTimeoutId); if (didTimeout && !cancelled) { - Log.info('[TransitionTracker] waitForUpcomingTransition timed out before a transition started', false, {timeoutMs: maxWaitForUpcomingTransitionMs}); + Log.info('[TransitionTracker] waitForUpcomingTransition timed out before a transition started', false, {timeoutMs: CONST.MAX_TRANSITION_START_WAIT_MS}); } if (!cancelled) { From d9ae7a4276240dd75c3bd8a4e9019f82b7fd8cba Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Wed, 15 Jul 2026 17:16:19 +0200 Subject: [PATCH 08/23] fix bugs in navigation transition tracking --- .../Navigators/TabNavigator.native.tsx | 2 + .../AppNavigator/Navigators/TabNavigator.tsx | 2 + .../PlatformStackNavigation/ScreenLayout.tsx | 56 ++++++++++++++++--- 3 files changed, 53 insertions(+), 7 deletions(-) diff --git a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx index e7e76bcd81c0..a442bcd5f241 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx @@ -2,6 +2,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import {getPreservedNavigatorState, setPreservedNavigatorState} from '@libs/Navigation/AppNavigator/createSplitNavigator/usePreserveNavigatorState'; +import {bottomTabScreenLayoutWrapper} from '@libs/Navigation/PlatformStackNavigation/ScreenLayout'; import type {TabNavigatorParamList} from '@libs/Navigation/types'; import HomePage from '@pages/home/HomePage'; @@ -95,6 +96,7 @@ function TabNavigator() { backBehavior="fullHistory" tabBar={renderTabBar} screenOptions={screenOptions} + screenLayout={bottomTabScreenLayoutWrapper} UNSTABLE_router={tabRouterOverride} > void): () => void; +}; // screenLayout is invoked as a render function (not JSX), so we need this wrapper to create a proper React component boundary for hooks. function screenLayoutWrapper({navigation, ...rest}: ScreenLayoutArgs) { @@ -13,19 +22,43 @@ function screenLayoutWrapper({navigation, ...rest}: ScreenLayoutArgs} + navigation={navigation as unknown as TransitionAwareNavigation} /> ); } -function ScreenLayout({ - children, - navigation, -}: ScreenLayoutArgs>) { +// Same as screenLayoutWrapper above, but for bottom-tab navigators. No cast needed here - `navigation` is already +// properly typed as BottomTabNavigationProp, and its `addListener` structurally satisfies TransitionAwareNavigation. +function bottomTabScreenLayoutWrapper({navigation, ...rest}: ScreenLayoutArgs>) { + return ( + + ); +} + +type ScreenLayoutProps = ScreenLayoutArgs< + ParamListBase, + string, + PlatformSpecificNavigationOptions | PlatformStackNavigationOptions | BottomTabNavigationOptions, + TransitionAwareNavigation +> & { + /** Whether this instance belongs to a bottom-tab navigator's screenLayout. Tab routes can host their own + * nested navigator, so only the currently focused leaf route inside it should register with TransitionTracker - + * the tab route's own transitionStart/transitionEnd would otherwise be redundant with the nested screen's. */ + isTabScreen?: boolean; +}; + +function ScreenLayout({children, navigation, route, isTabScreen}: ScreenLayoutProps) { const transitionHandleRef = useRef(null); useLayoutEffect(() => { const transitionStartListener = navigation.addListener('transitionStart', () => { + if (isTabScreen && navigationRef.isReady() && navigationRef.getCurrentRoute()?.key !== route.key) { + return; + } transitionHandleRef.current = TransitionTracker.startTransition(); }); const transitionEndListener = navigation.addListener('transitionEnd', () => { @@ -39,10 +72,19 @@ function ScreenLayout({ return () => { transitionStartListener(); transitionEndListener(); + + // If this screen unmounts before its own transitionEnd fires (e.g. it was popped/reset + // away mid-transition), the handle would otherwise sit in TransitionTracker until its + // safety timeout, needlessly delaying anything waiting via runAfterTransitions. + if (transitionHandleRef.current) { + TransitionTracker.endTransition(transitionHandleRef.current); + transitionHandleRef.current = null; + } }; - }, [navigation]); + }, [navigation, route.key, isTabScreen]); return children; } export default screenLayoutWrapper; +export {bottomTabScreenLayoutWrapper}; From 6cea635a578e63d798ebd3d60d025e02872e9ad9 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Wed, 15 Jul 2026 19:29:38 +0200 Subject: [PATCH 09/23] add new heuristic-based runAfterInteractions that predicts transition --- src/CONST/index.ts | 2 +- src/hooks/useSingleExecution/index.native.ts | 32 ++--- src/libs/Navigation/TransitionTracker.ts | 18 +++ .../Navigation/runAfterPredictedTransition.ts | 125 ++++++++++++++++++ 4 files changed, 155 insertions(+), 22 deletions(-) create mode 100644 src/libs/Navigation/runAfterPredictedTransition.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 56f46216af81..19274391d3a4 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -260,6 +260,7 @@ const CONST = { COMPOSER_FOCUS_DELAY: 150, MAX_TRANSITION_DURATION_MS: 1000, MAX_TRANSITION_START_WAIT_MS: 1000, + NAVIGATION_PREDICTION_WINDOW_MS: 150, EXPENSE_REPORT_DELETE_DELAY_MS: 300, ELEMENT_NAME: { INPUT: 'INPUT', @@ -2089,7 +2090,6 @@ const CONST = { LOCATION_UPDATE_INTERVAL: 5000, PLAY_SOUND_MESSAGE_DEBOUNCE_TIME: 500, NOTIFY_NEW_ACTION_DELAY: 700, - SINGLE_EXECUTION_DEBOUNCE_TIME: 500, SKELETON_ANIMATION_SPEED: 3, SHOW_HOVER_PREVIEW_DELAY: 270, SHOW_HOVER_PREVIEW_ANIMATION_DURATION: 250, diff --git a/src/hooks/useSingleExecution/index.native.ts b/src/hooks/useSingleExecution/index.native.ts index d375e8760d3b..2cd703367408 100644 --- a/src/hooks/useSingleExecution/index.native.ts +++ b/src/hooks/useSingleExecution/index.native.ts @@ -1,8 +1,6 @@ -import TransitionTracker from '@libs/Navigation/TransitionTracker'; +import runAfterPredictedTransition from '@libs/Navigation/runAfterPredictedTransition'; import type {CancelHandle} from '@libs/Navigation/TransitionTracker'; -import CONST from '@src/CONST'; - import {useCallback, useEffect, useRef, useState} from 'react'; type Action = (...params: T) => void | Promise; @@ -13,16 +11,12 @@ type Action = (...params: T) => void | Promise; export default function useSingleExecution() { const [isExecuting, setIsExecuting] = useState(false); const isExecutingRef = useRef(undefined); - const timeoutRef = useRef(null); const transitionHandleRef = useRef(null); isExecutingRef.current = isExecuting; useEffect( () => () => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } transitionHandleRef.current?.cancel(); }, [], @@ -39,21 +33,17 @@ export default function useSingleExecution() { isExecutingRef.current = true; const execution = action(...params); - // The timeout is a minimum debounce applied to every press; checking TransitionTracker afterwards - // is an extra safety net in case a transition is still in progress once the debounce ends. - timeoutRef.current = setTimeout(() => { - transitionHandleRef.current = TransitionTracker.runAfterTransitions({ - callback: () => { - if (!(execution instanceof Promise)) { - setIsExecuting(false); - return; - } - execution.finally(() => { - setIsExecuting(false); - }); - }, + // Re-enables the button once the predicted (or actual) transition triggered by this press + // ends - or immediately, if the press wasn't predicted to cause one. + transitionHandleRef.current = runAfterPredictedTransition(() => { + if (!(execution instanceof Promise)) { + setIsExecuting(false); + return; + } + execution.finally(() => { + setIsExecuting(false); }); - }, CONST.TIMING.SINGLE_EXECUTION_DEBOUNCE_TIME); + }); }, [], ); diff --git a/src/libs/Navigation/TransitionTracker.ts b/src/libs/Navigation/TransitionTracker.ts index 5e65146bf935..b7f44366cbcb 100644 --- a/src/libs/Navigation/TransitionTracker.ts +++ b/src/libs/Navigation/TransitionTracker.ts @@ -21,6 +21,8 @@ type RunAfterTransitionsOptions = { const activeTransitions = new Map>(); +const transitionStartListeners = new Set<() => void>(); + let pendingCallbacks: Array<() => void | Promise> = []; let nextTransitionStartResolve: (() => void) | null = null; @@ -81,6 +83,10 @@ function startTransition(): TransitionHandle { resolve(); } + for (const listener of transitionStartListeners) { + listener(); + } + const timeout = setTimeout(() => { activeTransitions.delete(handle); decrementAndFlush(); @@ -175,10 +181,22 @@ function runAfterTransitions({callback, runImmediately = false, waitForUpcomingT }; } +/** + * Subscribes to be notified synchronously whenever a new transition starts (via {@link startTransition}). + * Returns an unsubscribe function. + */ +function onTransitionStart(listener: () => void): () => void { + transitionStartListeners.add(listener); + return () => { + transitionStartListeners.delete(listener); + }; +} + const TransitionTracker = { startTransition, endTransition, runAfterTransitions, + onTransitionStart, }; export default TransitionTracker; diff --git a/src/libs/Navigation/runAfterPredictedTransition.ts b/src/libs/Navigation/runAfterPredictedTransition.ts new file mode 100644 index 000000000000..a59818c51962 --- /dev/null +++ b/src/libs/Navigation/runAfterPredictedTransition.ts @@ -0,0 +1,125 @@ +import CONST from '@src/CONST'; + +import {findFocusedRoute} from '@react-navigation/native'; + +import type {CancelHandle} from './TransitionTracker'; + +import navigationRef from './navigationRef'; +import TransitionTracker from './TransitionTracker'; + +// Action types that never move the focused route, so they can never start a visual transition. +const ACTION_TYPES_WITHOUT_TRANSITION: ReadonlySet = new Set(['SET_PARAMS', 'REPLACE_PARAMS', 'PRELOAD', 'JUMP_TO']); + +let isTransitionPending = false; +let pendingClearTimeout: ReturnType | null = null; +let lastFocusedRouteKey: string | undefined; +let hasConfirmedFocusMove = false; +let settleWaiters: Array<(shouldWait: boolean) => void> = []; + +function notifySettle(shouldWait: boolean): void { + const waiters = settleWaiters; + settleWaiters = []; + for (const resolve of waiters) { + resolve(shouldWait); + } +} + +function clearPending(): void { + if (pendingClearTimeout) { + clearTimeout(pendingClearTimeout); + pendingClearTimeout = null; + } + isTransitionPending = false; + hasConfirmedFocusMove = false; + notifySettle(false); +} + +function waitForPredictionSettled(): Promise { + if (!isTransitionPending) { + return Promise.resolve(false); + } + if (hasConfirmedFocusMove) { + return Promise.resolve(true); + } + return new Promise((resolve) => { + settleWaiters.push(resolve); + }); +} + +navigationRef.addListener('__unsafe_action__', (event) => { + if (event.data.noop || ACTION_TYPES_WITHOUT_TRANSITION.has(event.data.action.type)) { + return; + } + + isTransitionPending = true; + if (hasConfirmedFocusMove) { + return; + } + + if (pendingClearTimeout) { + clearTimeout(pendingClearTimeout); + } + pendingClearTimeout = setTimeout(clearPending, CONST.NAVIGATION_PREDICTION_WINDOW_MS); +}); + +navigationRef.addListener('state', (event) => { + const focusedRouteKey = event.data.state ? findFocusedRoute(event.data.state)?.key : undefined; + const didFocusedRouteChange = focusedRouteKey !== lastFocusedRouteKey; + lastFocusedRouteKey = focusedRouteKey; + + if (!isTransitionPending || hasConfirmedFocusMove) { + return; + } + + if (!didFocusedRouteChange) { + clearPending(); + return; + } + + hasConfirmedFocusMove = true; + notifySettle(true); + if (pendingClearTimeout) { + clearTimeout(pendingClearTimeout); + } + pendingClearTimeout = setTimeout(clearPending, CONST.MAX_TRANSITION_START_WAIT_MS); +}); + +TransitionTracker.onTransitionStart(clearPending); + +function settle(): Promise { + return new Promise((resolve) => { + setTimeout(resolve, 0); + }); +} + +/** + * Heuristic-aware drop-in for `TransitionTracker.runAfterTransitions({callback, waitForUpcomingTransition: true})`. + * Call this right after or right before the navigation call whose transition should be waited on. + * + * ponytail: best-effort guess based on actions dispatched through `navigationRef` shortly around this call, + * not a guarantee. A transition from a disconnected call stack (native gesture, unrelated code) is invisible + * here, same as calling `TransitionTracker.runAfterTransitions` with no `waitForUpcomingTransition`. + */ +export default function runAfterPredictedTransition(callback: () => void | Promise): CancelHandle { + let cancelled = false; + let innerHandle: CancelHandle | null = null; + + settle() + .then(() => waitForPredictionSettled()) + .then((shouldWait) => { + if (cancelled) { + return; + } + innerHandle = TransitionTracker.runAfterTransitions({ + callback, + waitForUpcomingTransition: shouldWait, + }); + }); + + return { + cancel: () => { + cancelled = true; + innerHandle?.cancel(); + }, + }; +} From 7cf7aa8c406cd6d31ce3176376c763a318a26418 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Wed, 15 Jul 2026 19:31:07 +0200 Subject: [PATCH 10/23] fix comment --- tests/utils/mockUseSingleExecution.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/utils/mockUseSingleExecution.ts b/tests/utils/mockUseSingleExecution.ts index 53c951bacd55..66b9c6e8f511 100644 --- a/tests/utils/mockUseSingleExecution.ts +++ b/tests/utils/mockUseSingleExecution.ts @@ -7,8 +7,8 @@ * ); * * in tests that press a button and assert on the resulting side effect, but aren't testing the - * double-tap-prevention mechanism itself. That mechanism relies on a real (non-fake) timer to - * clear `isExecuting` (see CONST.TIMING.SINGLE_EXECUTION_DEBOUNCE_TIME), so it won't resolve + * double-tap-prevention mechanism itself. That mechanism relies on real (non-fake) timers, via + * `runAfterPredictedTransition`/`TransitionTracker`, to clear `isExecuting`, so it won't resolve * within a normal test's lifetime and would otherwise leave buttons stuck disabled. */ function useSingleExecution() { From 179c97c98e1e0a10d28700d2e78dcdb97d7b0b19 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Wed, 15 Jul 2026 19:44:20 +0200 Subject: [PATCH 11/23] revert the changes --- src/libs/Navigation/TransitionTracker.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/libs/Navigation/TransitionTracker.ts b/src/libs/Navigation/TransitionTracker.ts index b7f44366cbcb..a2c1c74224ef 100644 --- a/src/libs/Navigation/TransitionTracker.ts +++ b/src/libs/Navigation/TransitionTracker.ts @@ -17,6 +17,9 @@ type RunAfterTransitionsOptions = { * Useful when a navigation action has just been dispatched but the transition has not yet been registered. * Defaults to false. */ waitForUpcomingTransition?: boolean; + + /** Maximum time to wait for the upcoming transition to start. Defaults to {@link CONST.MAX_TRANSITION_START_WAIT_MS}. */ + maxWaitForUpcomingTransitionMs?: number; }; const activeTransitions = new Map>(); @@ -122,9 +125,15 @@ function endTransition(handle: TransitionHandle): void { * @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.maxWaitForUpcomingTransitionMs - Maximum time to wait for the upcoming transition to start. Defaults to {@link CONST.MAX_TRANSITION_START_WAIT_MS}. * @returns A handle with a `cancel` method to prevent the callback from firing. */ -function runAfterTransitions({callback, runImmediately = false, waitForUpcomingTransition = false}: RunAfterTransitionsOptions): CancelHandle { +function runAfterTransitions({ + callback, + runImmediately = false, + waitForUpcomingTransition = false, + maxWaitForUpcomingTransitionMs = CONST.MAX_TRANSITION_START_WAIT_MS, +}: RunAfterTransitionsOptions): CancelHandle { if (waitForUpcomingTransition) { let cancelled = false; let innerHandle: CancelHandle | null = null; @@ -139,7 +148,7 @@ function runAfterTransitions({callback, runImmediately = false, waitForUpcomingT transitionStartTimeoutId = setTimeout(() => { didTimeout = true; resolve(); - }, CONST.MAX_TRANSITION_START_WAIT_MS); + }, maxWaitForUpcomingTransitionMs); }); (async () => { @@ -147,7 +156,7 @@ function runAfterTransitions({callback, runImmediately = false, waitForUpcomingT clearTimeout(transitionStartTimeoutId); if (didTimeout && !cancelled) { - Log.info('[TransitionTracker] waitForUpcomingTransition timed out before a transition started', false, {timeoutMs: CONST.MAX_TRANSITION_START_WAIT_MS}); + Log.info('[TransitionTracker] waitForUpcomingTransition timed out before a transition started', false, {timeoutMs: maxWaitForUpcomingTransitionMs}); } if (!cancelled) { From 6891682fd89fc8a98417d0c7c6d3e1496e35240c Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Thu, 16 Jul 2026 10:24:31 +0200 Subject: [PATCH 12/23] fix tests --- jest/setupAfterEnv.ts | 11 +++++++++++ tests/ui/ProfilePageTest.tsx | 6 ------ tests/ui/WorkspaceTravelInvoicingSectionTest.tsx | 6 ------ .../SearchActionsBarCreateButtonTest.tsx | 5 ----- tests/utils/mockUseSingleExecution.ts | 15 ++++++--------- 5 files changed, 17 insertions(+), 26 deletions(-) diff --git a/jest/setupAfterEnv.ts b/jest/setupAfterEnv.ts index 905758dfc8b4..49701c9c77bb 100644 --- a/jest/setupAfterEnv.ts +++ b/jest/setupAfterEnv.ts @@ -6,6 +6,8 @@ import type {KeyboardEventName} from 'react-native'; import {Keyboard} from 'react-native'; import Onyx from 'react-native-onyx'; +import mockUseSingleExecution from '../tests/utils/mockUseSingleExecution'; + jest.useRealTimers(); jest.mock('@hooks/useAIFeaturesPromoModal', () => ({ @@ -13,6 +15,15 @@ jest.mock('@hooks/useAIFeaturesPromoModal', () => ({ default: jest.fn(), })); +// The real hook relies on real navigation transitions to settle `isExecuting` (see +// `runAfterPredictedTransition`/`TransitionTracker`), which don't happen in unit tests and would +// otherwise leave buttons stuck disabled. Mocked globally so no test needs to import the navigation +// listener machinery just because it renders a Pressable-based component (Button, MenuItem, etc.). +jest.mock('@hooks/useSingleExecution', () => ({ + __esModule: true, + default: mockUseSingleExecution, +})); + // Patch Keyboard.addListener to return a subscription object with .remove() so that // @react-navigation/bottom-tabs useIsKeyboardShown hook doesn't crash on cleanup. if (Keyboard && typeof Keyboard.addListener === 'function') { diff --git a/tests/ui/ProfilePageTest.tsx b/tests/ui/ProfilePageTest.tsx index 30d3a9cf4137..afbb6f1f295d 100644 --- a/tests/ui/ProfilePageTest.tsx +++ b/tests/ui/ProfilePageTest.tsx @@ -28,16 +28,10 @@ import {NavigationContainer} from '@react-navigation/native'; import React from 'react'; import Onyx from 'react-native-onyx'; -import type * as MockUseSingleExecution from '../utils/mockUseSingleExecution'; - import * as TestHelper from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; -// This page presses buttons and asserts on the resulting side effects/disabled state, not on -// the double-tap-prevention mechanism itself (see tests/utils/mockUseSingleExecution.ts). -jest.mock('@hooks/useSingleExecution', () => jest.requireActual('../utils/mockUseSingleExecution')); - jest.mock('@libs/Navigation/Navigation', () => ({ navigate: jest.fn(), goBack: jest.fn(), diff --git a/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx b/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx index 5901e039caec..d4d92b9f7e5c 100644 --- a/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx +++ b/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx @@ -20,8 +20,6 @@ import type {Policy} from '@src/types/onyx'; import React from 'react'; import Onyx from 'react-native-onyx'; -import type * as MockUseSingleExecution from '../utils/mockUseSingleExecution'; - import createRandomPolicy from '../utils/collections/policies'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; @@ -34,10 +32,6 @@ const WORKSPACE_ACCOUNT_ID = 999888; // Therefore, they cannot reference variables like POLICY_ID or WORKSPACE_ACCOUNT_ID. // We use literal values that match the constants above. -// This component presses buttons and asserts on the resulting side effects, not on the -// double-tap-prevention mechanism itself (see tests/utils/mockUseSingleExecution.ts). -jest.mock('@hooks/useSingleExecution', () => jest.requireActual('../utils/mockUseSingleExecution')); - jest.mock('@react-navigation/native', () => { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const actualNav = jest.requireActual('@react-navigation/native'); diff --git a/tests/ui/components/SearchActionsBarCreateButtonTest.tsx b/tests/ui/components/SearchActionsBarCreateButtonTest.tsx index 908147b9516d..3196941fd6c6 100644 --- a/tests/ui/components/SearchActionsBarCreateButtonTest.tsx +++ b/tests/ui/components/SearchActionsBarCreateButtonTest.tsx @@ -21,14 +21,9 @@ import {getUnixTime, subDays} from 'date-fns'; import React from 'react'; import Onyx from 'react-native-onyx'; -import type * as MockUseSingleExecution from '../../utils/mockUseSingleExecution'; - import {translateLocal} from '../../utils/TestHelper'; import waitForBatchedUpdatesWithAct from '../../utils/waitForBatchedUpdatesWithAct'; -// This component presses buttons and asserts on the resulting side effects, not on the -// double-tap-prevention mechanism itself (see tests/utils/mockUseSingleExecution.ts). -jest.mock('@hooks/useSingleExecution', () => jest.requireActual('../../utils/mockUseSingleExecution')); jest.mock('@libs/Navigation/Navigation', () => ({ navigate: jest.fn(), setNavigationActionToMicrotaskQueue: jest.fn((cb: () => void) => cb()), diff --git a/tests/utils/mockUseSingleExecution.ts b/tests/utils/mockUseSingleExecution.ts index 66b9c6e8f511..2a8d733809fe 100644 --- a/tests/utils/mockUseSingleExecution.ts +++ b/tests/utils/mockUseSingleExecution.ts @@ -1,15 +1,12 @@ /** * Test-only replacement for `useSingleExecution` that calls the action synchronously and never - * reports `isExecuting`. Use via: + * reports `isExecuting`. Applied globally for all tests in `jest/setupAfterEnv.ts`, so no test + * needs to mock it individually. * - * jest.mock('@hooks/useSingleExecution', () => - * jest.requireActual('.../mockUseSingleExecution'), - * ); - * - * in tests that press a button and assert on the resulting side effect, but aren't testing the - * double-tap-prevention mechanism itself. That mechanism relies on real (non-fake) timers, via - * `runAfterPredictedTransition`/`TransitionTracker`, to clear `isExecuting`, so it won't resolve - * within a normal test's lifetime and would otherwise leave buttons stuck disabled. + * The real hook relies on real navigation transitions, via `runAfterPredictedTransition`/ + * `TransitionTracker`, to clear `isExecuting`. Those don't happen in unit tests and would + * otherwise leave buttons stuck disabled - and pull in the navigation listener machinery just + * because a test renders a Pressable-based component (Button, MenuItem, etc.). */ function useSingleExecution() { return { From 3746a6083f96378ccb2aa94d0aa0d1544d2485d2 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Thu, 16 Jul 2026 10:25:05 +0200 Subject: [PATCH 13/23] remove dead code --- tests/unit/ExportDownloadStatusModalTest.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tests/unit/ExportDownloadStatusModalTest.tsx b/tests/unit/ExportDownloadStatusModalTest.tsx index d433c0a2307b..520a01d61a28 100644 --- a/tests/unit/ExportDownloadStatusModalTest.tsx +++ b/tests/unit/ExportDownloadStatusModalTest.tsx @@ -11,13 +11,8 @@ import ONYXKEYS from '@src/ONYXKEYS'; import React from 'react'; import Onyx from 'react-native-onyx'; -import type * as MockUseSingleExecution from '../utils/mockUseSingleExecution'; - import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; -// This modal presses buttons and asserts on the resulting side effects, not on the -// double-tap-prevention mechanism itself (see tests/utils/mockUseSingleExecution.ts). -jest.mock('@hooks/useSingleExecution', () => jest.requireActual('../utils/mockUseSingleExecution')); jest.mock('@libs/fileDownload'); jest.mock('@components/RenderHTML', () => { function MockRenderHTML({html}: {html: string}) { From a8dc987039c76252dd150d28e7bc462bed859bc6 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Thu, 16 Jul 2026 11:07:34 +0200 Subject: [PATCH 14/23] make TransitionTracker listeners safer --- src/libs/Navigation/TransitionTracker.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/libs/Navigation/TransitionTracker.ts b/src/libs/Navigation/TransitionTracker.ts index a2c1c74224ef..b9692f1a8c2a 100644 --- a/src/libs/Navigation/TransitionTracker.ts +++ b/src/libs/Navigation/TransitionTracker.ts @@ -18,7 +18,7 @@ type RunAfterTransitionsOptions = { * Defaults to false. */ waitForUpcomingTransition?: boolean; - /** Maximum time to wait for the upcoming transition to start. Defaults to {@link CONST.MAX_TRANSITION_START_WAIT_MS}. */ + /** Maximum time to wait for the upcoming transition to start. Used only when waitForUpcomingTransition is true. */ maxWaitForUpcomingTransitionMs?: number; }; @@ -33,16 +33,16 @@ let promiseForNextTransitionStart = new Promise((resolve) => { nextTransitionStartResolve = resolve; }); -function runCallback(callback: () => void | Promise): void { +function invokeSafely(fn: () => void | Promise): void { try { - const result = callback(); + const result = fn(); if (result instanceof Promise) { result.catch((error) => { - Log.warn('[TransitionTracker] A pending async callback threw an error', {error}); + Log.warn(`[TransitionTracker] An async callback/listener threw an error`, {error}); }); } } catch (error) { - Log.warn('[TransitionTracker] A pending callback threw an error', {error}); + Log.warn(`[TransitionTracker] A callback/listener threw an error`, {error}); } } @@ -54,7 +54,7 @@ function flushCallbacks(): void { const callbacks = pendingCallbacks; pendingCallbacks = []; for (const callback of callbacks) { - runCallback(callback); + invokeSafely(callback); } } @@ -86,10 +86,6 @@ function startTransition(): TransitionHandle { resolve(); } - for (const listener of transitionStartListeners) { - listener(); - } - const timeout = setTimeout(() => { activeTransitions.delete(handle); decrementAndFlush(); @@ -97,6 +93,10 @@ function startTransition(): TransitionHandle { activeTransitions.set(handle, timeout); + for (const listener of transitionStartListeners) { + invokeSafely(listener); + } + return handle; } @@ -174,7 +174,7 @@ function runAfterTransitions({ } if (activeTransitions.size === 0 || runImmediately) { - runCallback(callback); + invokeSafely(callback); return {cancel: () => {}}; } From 88e52e86bc1e67fca17d212ab60052244d43054f Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Thu, 16 Jul 2026 11:21:27 +0200 Subject: [PATCH 15/23] make runAfterPredictedTransition less async --- .../Navigation/runAfterPredictedTransition.ts | 33 +++++++++---------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/src/libs/Navigation/runAfterPredictedTransition.ts b/src/libs/Navigation/runAfterPredictedTransition.ts index a59818c51962..81c5bffa7605 100644 --- a/src/libs/Navigation/runAfterPredictedTransition.ts +++ b/src/libs/Navigation/runAfterPredictedTransition.ts @@ -7,7 +7,9 @@ import type {CancelHandle} from './TransitionTracker'; import navigationRef from './navigationRef'; import TransitionTracker from './TransitionTracker'; -// Action types that never move the focused route, so they can never start a visual transition. +// Action types to ignore when predicting an upcoming visual transition. SET_PARAMS/REPLACE_PARAMS/PRELOAD +// never move the focused route. JUMP_TO can, but only tab navigators use it and they do not emit +// transitionStart/transitionEnd, so waiting on TransitionTracker would never settle. const ACTION_TYPES_WITHOUT_TRANSITION: ReadonlySet = new Set(['SET_PARAMS', 'REPLACE_PARAMS', 'PRELOAD', 'JUMP_TO']); let isTransitionPending = false; @@ -19,8 +21,8 @@ let settleWaiters: Array<(shouldWait: boolean) => void> = []; function notifySettle(shouldWait: boolean): void { const waiters = settleWaiters; settleWaiters = []; - for (const resolve of waiters) { - resolve(shouldWait); + for (const onSettled of waiters) { + onSettled(shouldWait); } } @@ -34,16 +36,16 @@ function clearPending(): void { notifySettle(false); } -function waitForPredictionSettled(): Promise { +function whenPredictionSettled(onSettled: (shouldWait: boolean) => void): void { if (!isTransitionPending) { - return Promise.resolve(false); + onSettled(false); + return; } if (hasConfirmedFocusMove) { - return Promise.resolve(true); + onSettled(true); + return; } - return new Promise((resolve) => { - settleWaiters.push(resolve); - }); + settleWaiters.push(onSettled); } navigationRef.addListener('__unsafe_action__', (event) => { @@ -86,12 +88,6 @@ navigationRef.addListener('state', (event) => { TransitionTracker.onTransitionStart(clearPending); -function settle(): Promise { - return new Promise((resolve) => { - setTimeout(resolve, 0); - }); -} - /** * Heuristic-aware drop-in for `TransitionTracker.runAfterTransitions({callback, waitForUpcomingTransition: true})`. * Call this right after or right before the navigation call whose transition should be waited on. @@ -104,9 +100,9 @@ export default function runAfterPredictedTransition(callback: () => void | Promi let cancelled = false; let innerHandle: CancelHandle | null = null; - settle() - .then(() => waitForPredictionSettled()) - .then((shouldWait) => { + // Yield one macrotask so a navigation dispatch in the same press handler can register first. + setTimeout(() => { + whenPredictionSettled((shouldWait) => { if (cancelled) { return; } @@ -115,6 +111,7 @@ export default function runAfterPredictedTransition(callback: () => void | Promi waitForUpcomingTransition: shouldWait, }); }); + }, 0); return { cancel: () => { From 0b7dcf584863147a91739ee1d0c14645eb75cea1 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Thu, 16 Jul 2026 13:54:10 +0200 Subject: [PATCH 16/23] fix tests --- tests/unit/Navigation/TransitionTrackerTest.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/Navigation/TransitionTrackerTest.ts b/tests/unit/Navigation/TransitionTrackerTest.ts index c65e24465231..e4a32d54052e 100644 --- a/tests/unit/Navigation/TransitionTrackerTest.ts +++ b/tests/unit/Navigation/TransitionTrackerTest.ts @@ -47,7 +47,7 @@ describe('TransitionTracker', () => { }); }).not.toThrow(); - expect(mockLogWarn).toHaveBeenCalledWith('[TransitionTracker] A pending callback threw an error', {error}); + expect(mockLogWarn).toHaveBeenCalledWith('[TransitionTracker] A callback/listener threw an error', {error}); drainTransitions(); }); @@ -60,7 +60,7 @@ describe('TransitionTracker', () => { await Promise.resolve(); - expect(mockLogWarn).toHaveBeenCalledWith('[TransitionTracker] A pending async callback threw an error', {error}); + expect(mockLogWarn).toHaveBeenCalledWith('[TransitionTracker] An async callback/listener threw an error', {error}); drainTransitions(); }); From e0dd7fb8e465e32acf16a41a50be045e8d243a2a Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Thu, 16 Jul 2026 15:48:35 +0200 Subject: [PATCH 17/23] add test --- .../runAfterPredictedTransitionTest.ts | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 tests/unit/Navigation/runAfterPredictedTransitionTest.ts diff --git a/tests/unit/Navigation/runAfterPredictedTransitionTest.ts b/tests/unit/Navigation/runAfterPredictedTransitionTest.ts new file mode 100644 index 000000000000..4f799d477122 --- /dev/null +++ b/tests/unit/Navigation/runAfterPredictedTransitionTest.ts @@ -0,0 +1,190 @@ +import runAfterPredictedTransition from '@libs/Navigation/runAfterPredictedTransition'; +import TransitionTracker from '@libs/Navigation/TransitionTracker'; + +import CONST from '@src/CONST'; + +type NavigationListener = (event: {data: Record}) => void; + +const UNSAFE_ACTION_EVENT = '__unsafe_action__'; +const navigationListeners: Partial> = {}; + +jest.mock('@libs/Navigation/navigationRef', () => ({ + __esModule: true, + default: { + addListener: (event: string, listener: NavigationListener) => { + navigationListeners[event] ??= []; + navigationListeners[event].push(listener); + return () => { + navigationListeners[event] = navigationListeners[event]?.filter((cb) => cb !== listener); + }; + }, + }, +})); + +function createNavState(focusedRouteKey: string) { + return { + stale: false, + type: 'stack', + key: 'root', + index: 0, + routeNames: ['Screen'], + routes: [{key: focusedRouteKey, name: 'Screen', params: {}}], + }; +} + +function emitAction(type: string, noop = false): void { + for (const listener of navigationListeners[UNSAFE_ACTION_EVENT] ?? []) { + listener({data: {noop, action: {type}}}); + } +} + +function emitState(focusedRouteKey: string): void { + for (const listener of navigationListeners.state ?? []) { + listener({data: {state: createNavState(focusedRouteKey)}}); + } +} + +describe('runAfterPredictedTransition', () => { + let runAfterTransitionsSpy: jest.SpiedFunction; + + beforeEach(() => { + jest.useFakeTimers(); + runAfterTransitionsSpy = jest.spyOn(TransitionTracker, 'runAfterTransitions'); + resetPredictionModuleState(); + }); + + afterEach(() => { + runAfterTransitionsSpy.mockRestore(); + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + function resetPredictionModuleState(): void { + jest.advanceTimersByTime(Math.max(CONST.NAVIGATION_PREDICTION_WINDOW_MS, CONST.MAX_TRANSITION_START_WAIT_MS, CONST.MAX_TRANSITION_DURATION_MS)); + emitState('reset-route'); + const transitionHandle = TransitionTracker.startTransition(); + TransitionTracker.endTransition(transitionHandle); + jest.advanceTimersByTime(CONST.MAX_TRANSITION_DURATION_MS); + } + + function flushPredictionTick(): void { + jest.advanceTimersByTime(0); + } + + it('runs the callback immediately when no navigation action was dispatched', () => { + const callback = jest.fn(); + runAfterPredictedTransition(callback); + flushPredictionTick(); + + expect(runAfterTransitionsSpy).toHaveBeenCalledWith(expect.objectContaining({callback, waitForUpcomingTransition: false})); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('waits for an upcoming transition when an action is followed by a focus change', async () => { + emitState('route-a'); + emitAction('NAVIGATE'); + emitState('route-b'); + + const callback = jest.fn(); + runAfterPredictedTransition(callback); + flushPredictionTick(); + + expect(runAfterTransitionsSpy).toHaveBeenCalledWith(expect.objectContaining({callback, waitForUpcomingTransition: true})); + expect(callback).not.toHaveBeenCalled(); + + const transitionHandle = TransitionTracker.startTransition(); + await Promise.resolve(); + await Promise.resolve(); + expect(callback).not.toHaveBeenCalled(); + + TransitionTracker.endTransition(transitionHandle); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('does not wait when an action does not change the focused route', () => { + emitState('route-a'); + emitAction('NAVIGATE'); + emitState('route-a'); + + const callback = jest.fn(); + runAfterPredictedTransition(callback); + flushPredictionTick(); + + expect(runAfterTransitionsSpy).toHaveBeenCalledWith(expect.objectContaining({callback, waitForUpcomingTransition: false})); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it.each(['SET_PARAMS', 'REPLACE_PARAMS', 'PRELOAD', 'JUMP_TO'])('ignores %s actions', (actionType) => { + emitState('route-a'); + emitAction(actionType); + + const callback = jest.fn(); + runAfterPredictedTransition(callback); + flushPredictionTick(); + + expect(runAfterTransitionsSpy).toHaveBeenCalledWith(expect.objectContaining({callback, waitForUpcomingTransition: false})); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('clears prediction when a real transition starts before the prediction tick', () => { + emitState('route-a'); + emitAction('NAVIGATE'); + emitState('route-b'); + + const transitionHandle = TransitionTracker.startTransition(); + + const callback = jest.fn(); + runAfterPredictedTransition(callback); + flushPredictionTick(); + + expect(runAfterTransitionsSpy).toHaveBeenCalledWith(expect.objectContaining({callback, waitForUpcomingTransition: false})); + expect(callback).not.toHaveBeenCalled(); + + TransitionTracker.endTransition(transitionHandle); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('stops waiting after the prediction window expires without a focus change', () => { + emitAction('NAVIGATE'); + + const callback = jest.fn(); + runAfterPredictedTransition(callback); + flushPredictionTick(); + + expect(callback).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(CONST.NAVIGATION_PREDICTION_WINDOW_MS); + flushPredictionTick(); + + expect(runAfterTransitionsSpy).toHaveBeenCalledWith(expect.objectContaining({callback, waitForUpcomingTransition: false})); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('cancel before the prediction tick prevents the callback from running', () => { + emitAction('NAVIGATE'); + emitState('route-b'); + + const callback = jest.fn(); + const cancelHandle = runAfterPredictedTransition(callback); + cancelHandle.cancel(); + flushPredictionTick(); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('cancel after registering with TransitionTracker prevents the callback from running', () => { + emitState('route-a'); + emitAction('NAVIGATE'); + emitState('route-b'); + + const callback = jest.fn(); + const cancelHandle = runAfterPredictedTransition(callback); + flushPredictionTick(); + + cancelHandle.cancel(); + const transitionHandle = TransitionTracker.startTransition(); + TransitionTracker.endTransition(transitionHandle); + + expect(callback).not.toHaveBeenCalled(); + }); +}); From 8cd95fd5fbf4b8d6a77b3db97a6ac602602eb12b Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Thu, 16 Jul 2026 18:08:35 +0200 Subject: [PATCH 18/23] fix prediction using hydrated focused route key from navigationRef --- .../Navigation/runAfterPredictedTransition.ts | 22 ++++- .../runAfterPredictedTransitionTest.ts | 89 +++++++++++++------ 2 files changed, 84 insertions(+), 27 deletions(-) diff --git a/src/libs/Navigation/runAfterPredictedTransition.ts b/src/libs/Navigation/runAfterPredictedTransition.ts index 81c5bffa7605..695ba459a340 100644 --- a/src/libs/Navigation/runAfterPredictedTransition.ts +++ b/src/libs/Navigation/runAfterPredictedTransition.ts @@ -64,8 +64,26 @@ navigationRef.addListener('__unsafe_action__', (event) => { pendingClearTimeout = setTimeout(clearPending, CONST.NAVIGATION_PREDICTION_WINDOW_MS); }); -navigationRef.addListener('state', (event) => { - const focusedRouteKey = event.data.state ? findFocusedRoute(event.data.state)?.key : undefined; +/** + * Container `state` events can carry a partial root state with route keys omitted. + * Read the hydrated focused key from the ref instead of `event.data.state`. + */ +function getHydratedFocusedRouteKey(): string | undefined { + if (!navigationRef.isReady()) { + return undefined; + } + + return navigationRef.getCurrentRoute()?.key ?? findFocusedRoute(navigationRef.getRootState())?.key; +} + +navigationRef.addListener('state', () => { + const focusedRouteKey = getHydratedFocusedRouteKey(); + + // Missing keys are inconclusive - do not confirm a focus move or clear a pending prediction. + if (focusedRouteKey === undefined) { + return; + } + const didFocusedRouteChange = focusedRouteKey !== lastFocusedRouteKey; lastFocusedRouteKey = focusedRouteKey; diff --git a/tests/unit/Navigation/runAfterPredictedTransitionTest.ts b/tests/unit/Navigation/runAfterPredictedTransitionTest.ts index 4f799d477122..3196d6c6e9ae 100644 --- a/tests/unit/Navigation/runAfterPredictedTransitionTest.ts +++ b/tests/unit/Navigation/runAfterPredictedTransitionTest.ts @@ -1,46 +1,67 @@ -import runAfterPredictedTransition from '@libs/Navigation/runAfterPredictedTransition'; import TransitionTracker from '@libs/Navigation/TransitionTracker'; +import type {CancelHandle} from '@libs/Navigation/TransitionTracker'; import CONST from '@src/CONST'; type NavigationListener = (event: {data: Record}) => void; +type RunAfterPredictedTransitionModule = { + default: (callback: () => void | Promise) => CancelHandle; +}; const UNSAFE_ACTION_EVENT = '__unsafe_action__'; -const navigationListeners: Partial> = {}; + +const mockNavigationRef = { + currentRouteKey: undefined as string | undefined, + listeners: {} as Partial>, + isReady: () => true, + getCurrentRoute: () => (mockNavigationRef.currentRouteKey ? {key: mockNavigationRef.currentRouteKey, name: 'Screen'} : undefined), + getRootState: () => + mockNavigationRef.currentRouteKey + ? { + stale: false, + type: 'stack', + key: 'root', + index: 0, + routeNames: ['Screen'], + routes: [{key: mockNavigationRef.currentRouteKey, name: 'Screen', params: {}}], + } + : undefined, + addListener: (event: string, listener: NavigationListener) => { + mockNavigationRef.listeners[event] ??= []; + mockNavigationRef.listeners[event]?.push(listener); + return () => { + mockNavigationRef.listeners[event] = mockNavigationRef.listeners[event]?.filter((cb) => cb !== listener); + }; + }, +}; jest.mock('@libs/Navigation/navigationRef', () => ({ __esModule: true, - default: { - addListener: (event: string, listener: NavigationListener) => { - navigationListeners[event] ??= []; - navigationListeners[event].push(listener); - return () => { - navigationListeners[event] = navigationListeners[event]?.filter((cb) => cb !== listener); - }; - }, - }, + default: mockNavigationRef, })); -function createNavState(focusedRouteKey: string) { - return { - stale: false, - type: 'stack', - key: 'root', - index: 0, - routeNames: ['Screen'], - routes: [{key: focusedRouteKey, name: 'Screen', params: {}}], - }; -} +// Load after the mock object exists - module side effects register navigation listeners. +const {default: runAfterPredictedTransition} = jest.requireActual('@libs/Navigation/runAfterPredictedTransition'); function emitAction(type: string, noop = false): void { - for (const listener of navigationListeners[UNSAFE_ACTION_EVENT] ?? []) { + for (const listener of mockNavigationRef.listeners[UNSAFE_ACTION_EVENT] ?? []) { listener({data: {noop, action: {type}}}); } } -function emitState(focusedRouteKey: string): void { - for (const listener of navigationListeners.state ?? []) { - listener({data: {state: createNavState(focusedRouteKey)}}); +function emitState(focusedRouteKey: string | undefined): void { + mockNavigationRef.currentRouteKey = focusedRouteKey; + for (const listener of mockNavigationRef.listeners.state ?? []) { + // Event payload may be partial (keys omitted), the helper must use the hydrated ref instead. + listener({ + data: { + state: { + stale: true, + routes: [{name: 'Screen'}], + index: 0, + }, + }, + }); } } @@ -49,6 +70,7 @@ describe('runAfterPredictedTransition', () => { beforeEach(() => { jest.useFakeTimers(); + mockNavigationRef.currentRouteKey = undefined; runAfterTransitionsSpy = jest.spyOn(TransitionTracker, 'runAfterTransitions'); resetPredictionModuleState(); }); @@ -101,6 +123,23 @@ describe('runAfterPredictedTransition', () => { expect(callback).toHaveBeenCalledTimes(1); }); + it('does not treat a missing hydrated route key as an unchanged focus', () => { + emitState('route-a'); + emitAction('NAVIGATE'); + emitState(undefined); + + const callback = jest.fn(); + runAfterPredictedTransition(callback); + flushPredictionTick(); + + expect(callback).not.toHaveBeenCalled(); + + emitState('route-b'); + flushPredictionTick(); + + expect(runAfterTransitionsSpy).toHaveBeenCalledWith(expect.objectContaining({callback, waitForUpcomingTransition: true})); + }); + it('does not wait when an action does not change the focused route', () => { emitState('route-a'); emitAction('NAVIGATE'); From f9ab8178450ea44d28a48e8150cb36dfc47a64c0 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Fri, 17 Jul 2026 13:22:59 +0200 Subject: [PATCH 19/23] Fix tab TransitionTracker registration and prediction window race --- .../PlatformStackNavigation/ScreenLayout.tsx | 21 +--- .../Navigation/runAfterPredictedTransition.ts | 108 ++++++++++++++---- .../runAfterPredictedTransitionTest.ts | 29 +++++ 3 files changed, 120 insertions(+), 38 deletions(-) diff --git a/src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx b/src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx index 3eb3158a98a4..8a7b2c16d740 100644 --- a/src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx +++ b/src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx @@ -1,4 +1,3 @@ -import navigationRef from '@libs/Navigation/navigationRef'; import TransitionTracker from '@libs/Navigation/TransitionTracker'; import type {TransitionHandle} from '@libs/Navigation/TransitionTracker'; @@ -34,31 +33,17 @@ function bottomTabScreenLayoutWrapper({navigation, ...rest}: ScreenLayoutArgs ); } -type ScreenLayoutProps = ScreenLayoutArgs< - ParamListBase, - string, - PlatformSpecificNavigationOptions | PlatformStackNavigationOptions | BottomTabNavigationOptions, - TransitionAwareNavigation -> & { - /** Whether this instance belongs to a bottom-tab navigator's screenLayout. Tab routes can host their own - * nested navigator, so only the currently focused leaf route inside it should register with TransitionTracker - - * the tab route's own transitionStart/transitionEnd would otherwise be redundant with the nested screen's. */ - isTabScreen?: boolean; -}; +type ScreenLayoutProps = ScreenLayoutArgs; -function ScreenLayout({children, navigation, route, isTabScreen}: ScreenLayoutProps) { +function ScreenLayout({children, navigation}: ScreenLayoutProps) { const transitionHandleRef = useRef(null); useLayoutEffect(() => { const transitionStartListener = navigation.addListener('transitionStart', () => { - if (isTabScreen && navigationRef.isReady() && navigationRef.getCurrentRoute()?.key !== route.key) { - return; - } transitionHandleRef.current = TransitionTracker.startTransition(); }); const transitionEndListener = navigation.addListener('transitionEnd', () => { @@ -81,7 +66,7 @@ function ScreenLayout({children, navigation, route, isTabScreen}: ScreenLayoutPr transitionHandleRef.current = null; } }; - }, [navigation, route.key, isTabScreen]); + }, [navigation]); return children; } diff --git a/src/libs/Navigation/runAfterPredictedTransition.ts b/src/libs/Navigation/runAfterPredictedTransition.ts index 695ba459a340..7c29435176cb 100644 --- a/src/libs/Navigation/runAfterPredictedTransition.ts +++ b/src/libs/Navigation/runAfterPredictedTransition.ts @@ -8,16 +8,40 @@ import navigationRef from './navigationRef'; import TransitionTracker from './TransitionTracker'; // Action types to ignore when predicting an upcoming visual transition. SET_PARAMS/REPLACE_PARAMS/PRELOAD -// never move the focused route. JUMP_TO can, but only tab navigators use it and they do not emit +// never move the focused route. JUMP_TO can, but only Onyx tab navigators use it and they do not emit // transitionStart/transitionEnd, so waiting on TransitionTracker would never settle. const ACTION_TYPES_WITHOUT_TRANSITION: ReadonlySet = new Set(['SET_PARAMS', 'REPLACE_PARAMS', 'PRELOAD', 'JUMP_TO']); +/** + * True after a navigable `__unsafe_action__` until we either confirm/deny a focus move, the + * prediction window expires, or a real transition starts. + */ let isTransitionPending = false; + +/** + * Timeout that clears a pending prediction. While awaiting a focus-key change it uses + * {@link CONST.NAVIGATION_PREDICTION_WINDOW_MS}, after a confirmed focus move it uses + * {@link CONST.MAX_TRANSITION_START_WAIT_MS} as a safety net until transitionStart clears it. + */ let pendingClearTimeout: ReturnType | null = null; + +/** Last known focused route key from a hydrated `state` event (undefined until first valid key). */ let lastFocusedRouteKey: string | undefined; + +/** + * True once a pending action was followed by a focused-route key change. Callers should wait for + * the upcoming transition (`waitForUpcomingTransition: true`). + */ let hasConfirmedFocusMove = false; + +/** + * Callbacks waiting for the current prediction to settle. Each receives `shouldWait`: + * true - confirmed focus move, wait for the upcoming transition + * false - no transition expected (idle, false alarm, window expired, or transition already started) + */ let settleWaiters: Array<(shouldWait: boolean) => void> = []; +/** Invokes and clears every waiter with the settled `shouldWait` value. */ function notifySettle(shouldWait: boolean): void { const waiters = settleWaiters; settleWaiters = []; @@ -26,6 +50,10 @@ function notifySettle(shouldWait: boolean): void { } } +/** + * Resets all prediction state and wakes waiters with `shouldWait = false`. + * Used for false alarms, window expiry with no focus move, and when a real transitionStart makes prediction moot. + */ function clearPending(): void { if (pendingClearTimeout) { clearTimeout(pendingClearTimeout); @@ -36,6 +64,57 @@ function clearPending(): void { notifySettle(false); } +/** + * Container `state` events can carry a partial root state with route keys omitted. + * Read the hydrated focused key from the ref instead of `event.data.state`. + */ +function getHydratedFocusedRouteKey(): string | undefined { + if (!navigationRef.isReady()) { + return undefined; + } + + return navigationRef.getCurrentRoute()?.key ?? findFocusedRoute(navigationRef.getRootState())?.key; +} + +/** + * Confirms an upcoming visual transition after a focused-route key change. + * Keeps prediction pending until transitionStart (or max wait) clears it. + */ +function confirmFocusMove(focusedRouteKey: string): void { + lastFocusedRouteKey = focusedRouteKey; + hasConfirmedFocusMove = true; + notifySettle(true); + if (pendingClearTimeout) { + clearTimeout(pendingClearTimeout); + } + pendingClearTimeout = setTimeout(clearPending, CONST.MAX_TRANSITION_START_WAIT_MS); +} + +/** + * Prediction-window timer callback. Re-checks the hydrated ref before clearing: the sync store may + * already show a new focused key while the container `state` effect is still delayed. + */ +function onPredictionWindowExpired(): void { + pendingClearTimeout = null; + if (!isTransitionPending || hasConfirmedFocusMove) { + return; + } + + const focusedRouteKey = getHydratedFocusedRouteKey(); + if (focusedRouteKey !== undefined && focusedRouteKey !== lastFocusedRouteKey) { + confirmFocusMove(focusedRouteKey); + return; + } + + clearPending(); +} + +/** + * Resolves whether callers should wait for an upcoming transition. + * Idle - settle immediately with false. + * Already confirmed - settle immediately with true. + * Pending but unconfirmed - queue until confirm, clear, or timeout. + */ function whenPredictionSettled(onSettled: (shouldWait: boolean) => void): void { if (!isTransitionPending) { onSettled(false); @@ -48,12 +127,14 @@ function whenPredictionSettled(onSettled: (shouldWait: boolean) => void): void { settleWaiters.push(onSettled); } +// Phase 1: a navigable action opens a short prediction window. navigationRef.addListener('__unsafe_action__', (event) => { if (event.data.noop || ACTION_TYPES_WITHOUT_TRANSITION.has(event.data.action.type)) { return; } isTransitionPending = true; + // Already confirmed for a prior action in this window - keep that decision, do not restart the short timer. if (hasConfirmedFocusMove) { return; } @@ -61,21 +142,11 @@ navigationRef.addListener('__unsafe_action__', (event) => { if (pendingClearTimeout) { clearTimeout(pendingClearTimeout); } - pendingClearTimeout = setTimeout(clearPending, CONST.NAVIGATION_PREDICTION_WINDOW_MS); + // If no focus move is visible within this window (via state event or hydrated-ref recheck), clear. + pendingClearTimeout = setTimeout(onPredictionWindowExpired, CONST.NAVIGATION_PREDICTION_WINDOW_MS); }); -/** - * Container `state` events can carry a partial root state with route keys omitted. - * Read the hydrated focused key from the ref instead of `event.data.state`. - */ -function getHydratedFocusedRouteKey(): string | undefined { - if (!navigationRef.isReady()) { - return undefined; - } - - return navigationRef.getCurrentRoute()?.key ?? findFocusedRoute(navigationRef.getRootState())?.key; -} - +// Phase 2: confirm or deny the pending prediction by comparing focused route keys. navigationRef.addListener('state', () => { const focusedRouteKey = getHydratedFocusedRouteKey(); @@ -91,19 +162,16 @@ navigationRef.addListener('state', () => { return; } + // Action ran but focus did not move - no visual transition expected. if (!didFocusedRouteChange) { clearPending(); return; } - hasConfirmedFocusMove = true; - notifySettle(true); - if (pendingClearTimeout) { - clearTimeout(pendingClearTimeout); - } - pendingClearTimeout = setTimeout(clearPending, CONST.MAX_TRANSITION_START_WAIT_MS); + confirmFocusMove(focusedRouteKey); }); +// Phase 3: a real transition started - prediction state is obsolete. TransitionTracker.onTransitionStart(clearPending); /** diff --git a/tests/unit/Navigation/runAfterPredictedTransitionTest.ts b/tests/unit/Navigation/runAfterPredictedTransitionTest.ts index 3196d6c6e9ae..0e8eb58a9ebd 100644 --- a/tests/unit/Navigation/runAfterPredictedTransitionTest.ts +++ b/tests/unit/Navigation/runAfterPredictedTransitionTest.ts @@ -115,6 +115,7 @@ describe('runAfterPredictedTransition', () => { expect(callback).not.toHaveBeenCalled(); const transitionHandle = TransitionTracker.startTransition(); + // Two ticks: one for promiseForNextTransitionStart, one for Promise.race wrapper await Promise.resolve(); await Promise.resolve(); expect(callback).not.toHaveBeenCalled(); @@ -199,6 +200,34 @@ describe('runAfterPredictedTransition', () => { expect(callback).toHaveBeenCalledTimes(1); }); + it('confirms a focus move on prediction-window expiry when the hydrated ref already changed (delayed state event)', async () => { + // React Navigation updates its sync store during dispatch, but the container `state` event + // only fires from useEffect after commit - on a jammed JS thread the effect can lag the + // prediction-window timer. The timer must re-check getCurrentRoute before clearing. + emitState('route-a'); + emitAction('NAVIGATE'); + mockNavigationRef.currentRouteKey = 'route-b'; + + const callback = jest.fn(); + runAfterPredictedTransition(callback); + flushPredictionTick(); + + expect(callback).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(CONST.NAVIGATION_PREDICTION_WINDOW_MS); + flushPredictionTick(); + + expect(runAfterTransitionsSpy).toHaveBeenCalledWith(expect.objectContaining({waitForUpcomingTransition: true})); + expect(callback).not.toHaveBeenCalled(); + + const transitionHandle = TransitionTracker.startTransition(); + // Two ticks: one for promiseForNextTransitionStart, one for Promise.race wrapper + await Promise.resolve(); + await Promise.resolve(); + TransitionTracker.endTransition(transitionHandle); + expect(callback).toHaveBeenCalledTimes(1); + }); + it('cancel before the prediction tick prevents the callback from running', () => { emitAction('NAVIGATE'); emitState('route-b'); From 925dd4caebc7625a4d19117635ddb2ae14d8b738 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Fri, 17 Jul 2026 15:05:28 +0200 Subject: [PATCH 20/23] fix comment --- src/libs/Navigation/runAfterPredictedTransition.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/Navigation/runAfterPredictedTransition.ts b/src/libs/Navigation/runAfterPredictedTransition.ts index 7c29435176cb..daad07cce62d 100644 --- a/src/libs/Navigation/runAfterPredictedTransition.ts +++ b/src/libs/Navigation/runAfterPredictedTransition.ts @@ -178,7 +178,7 @@ TransitionTracker.onTransitionStart(clearPending); * Heuristic-aware drop-in for `TransitionTracker.runAfterTransitions({callback, waitForUpcomingTransition: true})`. * Call this right after or right before the navigation call whose transition should be waited on. * - * ponytail: best-effort guess based on actions dispatched through `navigationRef` shortly around this call, + * note: best-effort guess based on actions dispatched through `navigationRef` shortly around this call, * not a guarantee. A transition from a disconnected call stack (native gesture, unrelated code) is invisible * here, same as calling `TransitionTracker.runAfterTransitions` with no `waitForUpcomingTransition`. */ From 7f3ec4d4112a3a602be3e89714a2ee281c5563fa Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Mon, 20 Jul 2026 12:04:57 +0200 Subject: [PATCH 21/23] add small adjustments and tests to runAfterPredictedTransition --- .../Navigation/runAfterPredictedTransition.ts | 29 +++++++++-- .../unit/Navigation/TransitionTrackerTest.ts | 48 +++++++++++++++++++ .../runAfterPredictedTransitionTest.ts | 20 ++++++++ 3 files changed, 94 insertions(+), 3 deletions(-) diff --git a/src/libs/Navigation/runAfterPredictedTransition.ts b/src/libs/Navigation/runAfterPredictedTransition.ts index daad07cce62d..d294c6fad4ad 100644 --- a/src/libs/Navigation/runAfterPredictedTransition.ts +++ b/src/libs/Navigation/runAfterPredictedTransition.ts @@ -28,6 +28,28 @@ let pendingClearTimeout: ReturnType | null = null; /** Last known focused route key from a hydrated `state` event (undefined until first valid key). */ let lastFocusedRouteKey: string | undefined; +/** + * True once the first hydrated focused-route key has been observed. Until then, `lastFocusedRouteKey` + * being `undefined` is just "no baseline yet", not a real key to diff against - without this flag, the + * very first `state`/ref read on a cold start would look like a focus change (`undefined !== 'route-a'`) + * even though no navigation actually moved focus. + */ +let hasCapturedBaselineFocus = false; + +/** + * Compares a freshly read focused-route key against the last known one, treating the first-ever + * observation as a baseline capture rather than a change. Callers are still responsible for + * updating {@link lastFocusedRouteKey} when this returns `false` for a non-baseline comparison. + */ +function didFocusMoveFrom(focusedRouteKey: string): boolean { + if (!hasCapturedBaselineFocus) { + hasCapturedBaselineFocus = true; + lastFocusedRouteKey = focusedRouteKey; + return false; + } + return focusedRouteKey !== lastFocusedRouteKey; +} + /** * True once a pending action was followed by a focused-route key change. Callers should wait for * the upcoming transition (`waitForUpcomingTransition: true`). @@ -101,7 +123,7 @@ function onPredictionWindowExpired(): void { } const focusedRouteKey = getHydratedFocusedRouteKey(); - if (focusedRouteKey !== undefined && focusedRouteKey !== lastFocusedRouteKey) { + if (focusedRouteKey !== undefined && didFocusMoveFrom(focusedRouteKey)) { confirmFocusMove(focusedRouteKey); return; } @@ -155,7 +177,7 @@ navigationRef.addListener('state', () => { return; } - const didFocusedRouteChange = focusedRouteKey !== lastFocusedRouteKey; + const didFocusedRouteChange = didFocusMoveFrom(focusedRouteKey); lastFocusedRouteKey = focusedRouteKey; if (!isTransitionPending || hasConfirmedFocusMove) { @@ -187,7 +209,7 @@ export default function runAfterPredictedTransition(callback: () => void | Promi let innerHandle: CancelHandle | null = null; // Yield one macrotask so a navigation dispatch in the same press handler can register first. - setTimeout(() => { + const outerTimeout = setTimeout(() => { whenPredictionSettled((shouldWait) => { if (cancelled) { return; @@ -202,6 +224,7 @@ export default function runAfterPredictedTransition(callback: () => void | Promi return { cancel: () => { cancelled = true; + clearTimeout(outerTimeout); innerHandle?.cancel(); }, }; diff --git a/tests/unit/Navigation/TransitionTrackerTest.ts b/tests/unit/Navigation/TransitionTrackerTest.ts index e4a32d54052e..f1805de58bcd 100644 --- a/tests/unit/Navigation/TransitionTrackerTest.ts +++ b/tests/unit/Navigation/TransitionTrackerTest.ts @@ -248,4 +248,52 @@ describe('TransitionTracker', () => { drainTransitions(); }); }); + + describe('onTransitionStart', () => { + it('notifies subscribers synchronously when a new transition starts', () => { + const listener = jest.fn(); + const unsubscribe = TransitionTracker.onTransitionStart(listener); + + const handle = TransitionTracker.startTransition(); + + expect(listener).toHaveBeenCalledTimes(1); + + unsubscribe(); + TransitionTracker.endTransition(handle); + drainTransitions(); + }); + + it('stops notifying a listener after it unsubscribes', () => { + const listener = jest.fn(); + const unsubscribe = TransitionTracker.onTransitionStart(listener); + unsubscribe(); + + const handle = TransitionTracker.startTransition(); + + expect(listener).not.toHaveBeenCalled(); + TransitionTracker.endTransition(handle); + drainTransitions(); + }); + + it('isolates a throwing listener from other subscribers', () => { + const error = new Error('listener boom'); + const throwingListener = jest.fn(() => { + throw error; + }); + const otherListener = jest.fn(); + const unsubscribeThrowing = TransitionTracker.onTransitionStart(throwingListener); + const unsubscribeOther = TransitionTracker.onTransitionStart(otherListener); + + const handle = TransitionTracker.startTransition(); + + expect(throwingListener).toHaveBeenCalledTimes(1); + expect(otherListener).toHaveBeenCalledTimes(1); + expect(mockLogWarn).toHaveBeenCalledWith('[TransitionTracker] A callback/listener threw an error', {error}); + + unsubscribeThrowing(); + unsubscribeOther(); + TransitionTracker.endTransition(handle); + drainTransitions(); + }); + }); }); diff --git a/tests/unit/Navigation/runAfterPredictedTransitionTest.ts b/tests/unit/Navigation/runAfterPredictedTransitionTest.ts index 0e8eb58a9ebd..bab6df27c2b2 100644 --- a/tests/unit/Navigation/runAfterPredictedTransitionTest.ts +++ b/tests/unit/Navigation/runAfterPredictedTransitionTest.ts @@ -228,6 +228,26 @@ describe('runAfterPredictedTransition', () => { expect(callback).toHaveBeenCalledTimes(1); }); + it('runs the callback after the fallback timeout when a confirmed focus move never gets a real transitionStart', async () => { + emitState('route-a'); + emitAction('NAVIGATE'); + emitState('route-b'); + + const callback = jest.fn(); + runAfterPredictedTransition(callback); + flushPredictionTick(); + + expect(runAfterTransitionsSpy).toHaveBeenCalledWith(expect.objectContaining({callback, waitForUpcomingTransition: true})); + expect(callback).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(CONST.MAX_TRANSITION_START_WAIT_MS); + // Two ticks: one for the race against promiseForNextTransitionStart, one for the wrapper. + await Promise.resolve(); + await Promise.resolve(); + + expect(callback).toHaveBeenCalledTimes(1); + }); + it('cancel before the prediction tick prevents the callback from running', () => { emitAction('NAVIGATE'); emitState('route-b'); From 274acb8e8be82e0018b146ea114fb0b34637d709 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Mon, 20 Jul 2026 12:18:26 +0200 Subject: [PATCH 22/23] add useSingleExecution tests --- .../hooks/useSingleExecutionTest.native.ts | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 tests/unit/hooks/useSingleExecutionTest.native.ts diff --git a/tests/unit/hooks/useSingleExecutionTest.native.ts b/tests/unit/hooks/useSingleExecutionTest.native.ts new file mode 100644 index 000000000000..540dc3805052 --- /dev/null +++ b/tests/unit/hooks/useSingleExecutionTest.native.ts @@ -0,0 +1,118 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import TransitionTracker from '@libs/Navigation/TransitionTracker'; + +type NavigationListener = (event: {data: Record}) => void; +type UseSingleExecution = () => { + isExecuting: boolean; + singleExecution: (action: (...params: T) => void | Promise) => (...params: T) => void; +}; + +const mockNavigationRef = { + currentRouteKey: 'route-a' as string | undefined, + listeners: {} as Partial>, + isReady: () => true, + getCurrentRoute: () => (mockNavigationRef.currentRouteKey ? {key: mockNavigationRef.currentRouteKey, name: 'Screen'} : undefined), + getRootState: () => undefined, + addListener: (event: string, listener: NavigationListener) => { + mockNavigationRef.listeners[event] ??= []; + mockNavigationRef.listeners[event]?.push(listener); + return () => { + mockNavigationRef.listeners[event] = mockNavigationRef.listeners[event]?.filter((cb) => cb !== listener); + }; + }, +}; + +jest.mock('@libs/Navigation/navigationRef', () => ({ + __esModule: true, + default: mockNavigationRef, +})); + +const {default: useSingleExecution} = jest.requireActual<{default: UseSingleExecution}>('@hooks/useSingleExecution'); + +describe('useSingleExecution (native)', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + it('clears isExecuting after a synchronous action when no transition is predicted', () => { + const {result} = renderHook(() => useSingleExecution()); + const action = jest.fn(); + + act(() => { + result.current.singleExecution(action)(); + }); + + expect(action).toHaveBeenCalledTimes(1); + expect(result.current.isExecuting).toBe(true); + + act(() => { + jest.advanceTimersByTime(0); + }); + + expect(result.current.isExecuting).toBe(false); + }); + + it('keeps isExecuting until an async action settles, then clears it', async () => { + const {result} = renderHook(() => useSingleExecution()); + let resolveAction: () => void = () => {}; + const action = jest.fn( + () => + new Promise((resolve) => { + resolveAction = resolve; + }), + ); + + act(() => { + result.current.singleExecution(action)(); + }); + + act(() => { + jest.advanceTimersByTime(0); + }); + + expect(result.current.isExecuting).toBe(true); + + await act(async () => { + resolveAction(); + await Promise.resolve(); + }); + + expect(result.current.isExecuting).toBe(false); + }); + + it('ignores a second invocation while the first is still executing', () => { + const {result} = renderHook(() => useSingleExecution()); + const action = jest.fn(); + + act(() => { + const run = result.current.singleExecution(action); + run(); + run(); + }); + + expect(action).toHaveBeenCalledTimes(1); + }); + + it('cancels the pending transition handle on unmount', () => { + const cancelSpy = jest.fn(); + const runAfterTransitionsSpy = jest.spyOn(TransitionTracker, 'runAfterTransitions').mockReturnValue({cancel: cancelSpy}); + + const {result, unmount} = renderHook(() => useSingleExecution()); + + act(() => { + result.current.singleExecution(jest.fn())(); + jest.advanceTimersByTime(0); + }); + + unmount(); + + expect(cancelSpy).toHaveBeenCalledTimes(1); + runAfterTransitionsSpy.mockRestore(); + }); +}); From c039c31a01036d152895d2e87ff923ff6abbe7ae Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Mon, 20 Jul 2026 12:54:28 +0200 Subject: [PATCH 23/23] add simpler way to register the first route --- .../Navigation/runAfterPredictedTransition.ts | 34 ++++++------------- 1 file changed, 10 insertions(+), 24 deletions(-) diff --git a/src/libs/Navigation/runAfterPredictedTransition.ts b/src/libs/Navigation/runAfterPredictedTransition.ts index d294c6fad4ad..0c62fbbc9545 100644 --- a/src/libs/Navigation/runAfterPredictedTransition.ts +++ b/src/libs/Navigation/runAfterPredictedTransition.ts @@ -25,30 +25,10 @@ let isTransitionPending = false; */ let pendingClearTimeout: ReturnType | null = null; -/** Last known focused route key from a hydrated `state` event (undefined until first valid key). */ -let lastFocusedRouteKey: string | undefined; - -/** - * True once the first hydrated focused-route key has been observed. Until then, `lastFocusedRouteKey` - * being `undefined` is just "no baseline yet", not a real key to diff against - without this flag, the - * very first `state`/ref read on a cold start would look like a focus change (`undefined !== 'route-a'`) - * even though no navigation actually moved focus. - */ -let hasCapturedBaselineFocus = false; - /** - * Compares a freshly read focused-route key against the last known one, treating the first-ever - * observation as a baseline capture rather than a change. Callers are still responsible for - * updating {@link lastFocusedRouteKey} when this returns `false` for a non-baseline comparison. + * Last known focused route key. Seeded from the pre-action hydrated ref the first time `__unsafe_action__` fires */ -function didFocusMoveFrom(focusedRouteKey: string): boolean { - if (!hasCapturedBaselineFocus) { - hasCapturedBaselineFocus = true; - lastFocusedRouteKey = focusedRouteKey; - return false; - } - return focusedRouteKey !== lastFocusedRouteKey; -} +let lastFocusedRouteKey: string | undefined; /** * True once a pending action was followed by a focused-route key change. Callers should wait for @@ -123,7 +103,7 @@ function onPredictionWindowExpired(): void { } const focusedRouteKey = getHydratedFocusedRouteKey(); - if (focusedRouteKey !== undefined && didFocusMoveFrom(focusedRouteKey)) { + if (focusedRouteKey !== undefined && focusedRouteKey !== lastFocusedRouteKey) { confirmFocusMove(focusedRouteKey); return; } @@ -151,6 +131,12 @@ function whenPredictionSettled(onSettled: (shouldWait: boolean) => void): void { // Phase 1: a navigable action opens a short prediction window. navigationRef.addListener('__unsafe_action__', (event) => { + // `__unsafe_action__` only fires once the container is mounted with a real focused route, + // so this always resolves to a defined key the first time + if (lastFocusedRouteKey === undefined) { + lastFocusedRouteKey = getHydratedFocusedRouteKey(); + } + if (event.data.noop || ACTION_TYPES_WITHOUT_TRANSITION.has(event.data.action.type)) { return; } @@ -177,7 +163,7 @@ navigationRef.addListener('state', () => { return; } - const didFocusedRouteChange = didFocusMoveFrom(focusedRouteKey); + const didFocusedRouteChange = focusedRouteKey !== lastFocusedRouteKey; lastFocusedRouteKey = focusedRouteKey; if (!isTransitionPending || hasConfirmedFocusMove) {