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/src/CONST/index.ts b/src/CONST/index.ts index 7d37ca62ac30..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', diff --git a/src/hooks/useSingleExecution/index.native.ts b/src/hooks/useSingleExecution/index.native.ts index d8aa1770d541..2cd703367408 100644 --- a/src/hooks/useSingleExecution/index.native.ts +++ b/src/hooks/useSingleExecution/index.native.ts @@ -1,6 +1,7 @@ -import {useCallback, useRef, useState} from 'react'; -// eslint-disable-next-line no-restricted-imports -import {InteractionManager} from 'react-native'; +import runAfterPredictedTransition from '@libs/Navigation/runAfterPredictedTransition'; +import type {CancelHandle} from '@libs/Navigation/TransitionTracker'; + +import {useCallback, useEffect, useRef, useState} from 'react'; type Action = (...params: T) => void | Promise; @@ -10,9 +11,17 @@ type Action = (...params: T) => void | Promise; export default function useSingleExecution() { const [isExecuting, setIsExecuting] = useState(false); const isExecutingRef = useRef(undefined); + const transitionHandleRef = useRef(null); isExecutingRef.current = isExecuting; + useEffect( + () => () => { + transitionHandleRef.current?.cancel(); + }, + [], + ); + const singleExecution = useCallback( (action: Action) => (...params: T) => { @@ -24,7 +33,9 @@ export default function useSingleExecution() { isExecutingRef.current = true; const execution = action(...params); - InteractionManager.runAfterInteractions(() => { + // 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; 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,15 +21,25 @@ 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; + +function ScreenLayout({children, navigation}: ScreenLayoutProps) { const transitionHandleRef = useRef(null); useLayoutEffect(() => { @@ -39,6 +57,14 @@ 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]); @@ -46,3 +72,4 @@ function ScreenLayout({ } export default screenLayoutWrapper; +export {bottomTabScreenLayoutWrapper}; diff --git a/src/libs/Navigation/TransitionTracker.ts b/src/libs/Navigation/TransitionTracker.ts index 336c207eb88f..b9692f1a8c2a 100644 --- a/src/libs/Navigation/TransitionTracker.ts +++ b/src/libs/Navigation/TransitionTracker.ts @@ -24,6 +24,8 @@ type RunAfterTransitionsOptions = { const activeTransitions = new Map>(); +const transitionStartListeners = new Set<() => void>(); + let pendingCallbacks: Array<() => void | Promise> = []; let nextTransitionStartResolve: (() => void) | null = null; @@ -31,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}); } } @@ -52,7 +54,7 @@ function flushCallbacks(): void { const callbacks = pendingCallbacks; pendingCallbacks = []; for (const callback of callbacks) { - runCallback(callback); + invokeSafely(callback); } } @@ -91,6 +93,10 @@ function startTransition(): TransitionHandle { activeTransitions.set(handle, timeout); + for (const listener of transitionStartListeners) { + invokeSafely(listener); + } + return handle; } @@ -168,7 +174,7 @@ function runAfterTransitions({ } if (activeTransitions.size === 0 || runImmediately) { - runCallback(callback); + invokeSafely(callback); return {cancel: () => {}}; } @@ -184,10 +190,22 @@ function runAfterTransitions({ }; } +/** + * 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..0c62fbbc9545 --- /dev/null +++ b/src/libs/Navigation/runAfterPredictedTransition.ts @@ -0,0 +1,217 @@ +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 to ignore when predicting an upcoming visual transition. SET_PARAMS/REPLACE_PARAMS/PRELOAD +// 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. Seeded from the pre-action hydrated ref the first time `__unsafe_action__` fires + */ +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 = []; + for (const onSettled of waiters) { + onSettled(shouldWait); + } +} + +/** + * 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); + pendingClearTimeout = null; + } + isTransitionPending = false; + hasConfirmedFocusMove = false; + 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); + return; + } + if (hasConfirmedFocusMove) { + onSettled(true); + return; + } + settleWaiters.push(onSettled); +} + +// 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; + } + + isTransitionPending = true; + // Already confirmed for a prior action in this window - keep that decision, do not restart the short timer. + if (hasConfirmedFocusMove) { + return; + } + + if (pendingClearTimeout) { + clearTimeout(pendingClearTimeout); + } + // 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); +}); + +// Phase 2: confirm or deny the pending prediction by comparing focused route keys. +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; + + if (!isTransitionPending || hasConfirmedFocusMove) { + return; + } + + // Action ran but focus did not move - no visual transition expected. + if (!didFocusedRouteChange) { + clearPending(); + return; + } + + confirmFocusMove(focusedRouteKey); +}); + +// Phase 3: a real transition started - prediction state is obsolete. +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. + * + * 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`. + */ +export default function runAfterPredictedTransition(callback: () => void | Promise): CancelHandle { + let cancelled = false; + let innerHandle: CancelHandle | null = null; + + // Yield one macrotask so a navigation dispatch in the same press handler can register first. + const outerTimeout = setTimeout(() => { + whenPredictionSettled((shouldWait) => { + if (cancelled) { + return; + } + innerHandle = TransitionTracker.runAfterTransitions({ + callback, + waitForUpcomingTransition: shouldWait, + }); + }); + }, 0); + + return { + cancel: () => { + cancelled = true; + clearTimeout(outerTimeout); + innerHandle?.cancel(); + }, + }; +} diff --git a/tests/unit/Navigation/TransitionTrackerTest.ts b/tests/unit/Navigation/TransitionTrackerTest.ts index c65e24465231..f1805de58bcd 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(); }); @@ -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 new file mode 100644 index 000000000000..bab6df27c2b2 --- /dev/null +++ b/tests/unit/Navigation/runAfterPredictedTransitionTest.ts @@ -0,0 +1,278 @@ +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 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: mockNavigationRef, +})); + +// 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 mockNavigationRef.listeners[UNSAFE_ACTION_EVENT] ?? []) { + listener({data: {noop, action: {type}}}); + } +} + +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, + }, + }, + }); + } +} + +describe('runAfterPredictedTransition', () => { + let runAfterTransitionsSpy: jest.SpiedFunction; + + beforeEach(() => { + jest.useFakeTimers(); + mockNavigationRef.currentRouteKey = undefined; + 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(); + // Two ticks: one for promiseForNextTransitionStart, one for Promise.race wrapper + await Promise.resolve(); + await Promise.resolve(); + expect(callback).not.toHaveBeenCalled(); + + TransitionTracker.endTransition(transitionHandle); + 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'); + 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('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('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'); + + 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(); + }); +}); 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(); + }); +}); diff --git a/tests/utils/mockUseSingleExecution.ts b/tests/utils/mockUseSingleExecution.ts new file mode 100644 index 000000000000..2a8d733809fe --- /dev/null +++ b/tests/utils/mockUseSingleExecution.ts @@ -0,0 +1,21 @@ +/** + * Test-only replacement for `useSingleExecution` that calls the action synchronously and never + * reports `isExecuting`. Applied globally for all tests in `jest/setupAfterEnv.ts`, so no test + * needs to mock it individually. + * + * 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 { + isExecuting: false, + singleExecution: + (action?: (...params: T) => void | Promise) => + (...params: T) => + action?.(...params), + }; +} + +export default useSingleExecution;