Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
09fa9b8
migrate last InteractionManager
collectioneur Jul 6, 2026
6ed1b54
small refactor without global use of transitionTracker
collectioneur Jul 6, 2026
c54c312
encapsulate mockUseSingleExecution and reuse it
collectioneur Jul 6, 2026
6c22f72
additional adjustments + cleaning timeout
collectioneur Jul 7, 2026
781df22
Merge branch 'main' into collectioneur/transition-tracker-use-single-…
collectioneur Jul 7, 2026
ecf9ca1
add shouldUseSingleExecution prop to genericPressable and disable it …
collectioneur Jul 9, 2026
b873881
Merge branch 'main' into collectioneur/transition-tracker-use-single-…
collectioneur Jul 9, 2026
1730f13
update TransitionTracker logic
collectioneur Jul 9, 2026
f34e16e
Merge branch 'main' into collectioneur/transition-tracker-use-single-…
collectioneur Jul 14, 2026
8e0f630
discard changes with new prop for generic pressable
collectioneur Jul 14, 2026
98621de
Merge branch 'main' into collectioneur/transition-tracker-use-single-…
collectioneur Jul 15, 2026
d9ae7a4
fix bugs in navigation transition tracking
collectioneur Jul 15, 2026
6cea635
add new heuristic-based runAfterInteractions that predicts transition
collectioneur Jul 15, 2026
7cf7aa8
fix comment
collectioneur Jul 15, 2026
179c97c
revert the changes
collectioneur Jul 15, 2026
6891682
fix tests
collectioneur Jul 16, 2026
3746a60
remove dead code
collectioneur Jul 16, 2026
a8dc987
make TransitionTracker listeners safer
collectioneur Jul 16, 2026
88e52e8
make runAfterPredictedTransition less async
collectioneur Jul 16, 2026
0b7dcf5
fix tests
collectioneur Jul 16, 2026
e0dd7fb
add test
collectioneur Jul 16, 2026
8cd95fd
fix prediction using hydrated focused route key from navigationRef
collectioneur Jul 16, 2026
f9ab817
Fix tab TransitionTracker registration and prediction window race
collectioneur Jul 17, 2026
925dd4c
fix comment
collectioneur Jul 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions jest/setupAfterEnv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,24 @@ 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', () => ({
__esModule: true,
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', () => ({

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

globally mocking useSingleExecution makes sense for unit tests, but it means nothing in Jest exercises the native hook wiring end to end. The new runAfterPredictedTransition tests are thorough; a small native-hook test (even with mocked navigation listeners) would give extra confidence that isExecuting clears on both sync and async actions

__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') {
Expand Down
1 change: 1 addition & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd verify if 150ms is enough even on low-end android devices

@collectioneur collectioneur Jul 17, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’ve investigated runAfterPredictedTransition on a lower-end device (Motorola Moto G15), and the results are pretty interesting.

The registered 150ms safety timeout is blocked and does not run while the js thread is busy with react’s state update and commit. After react commits, that overdue timeout races the state listener: react navigation emits the new state from useEffect (after commit), so once the thread is free again the timeout can fire either before or after the listener sees the update.

I fixed this by adding an extra check when the 150ms timeout runs: before clearing the prediction, it checks whether the navigation ref already has the new focused route. That check is reliable for normal navigations, because the ref store is updated during react navigation setState, before commit, so the new route is already visible even if the state listener has not run yet.

EXPENSE_REPORT_DELETE_DELAY_MS: 300,
ELEMENT_NAME: {
INPUT: 'INPUT',
Expand Down
19 changes: 15 additions & 4 deletions src/hooks/useSingleExecution/index.native.ts
Original file line number Diff line number Diff line change
@@ -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<T extends unknown[]> = (...params: T) => void | Promise<void>;

Expand All @@ -10,9 +11,17 @@ type Action<T extends unknown[]> = (...params: T) => void | Promise<void>;
export default function useSingleExecution() {
const [isExecuting, setIsExecuting] = useState(false);
const isExecutingRef = useRef<boolean | undefined>(undefined);
const transitionHandleRef = useRef<CancelHandle | null>(null);

isExecutingRef.current = isExecuting;

useEffect(
() => () => {
transitionHandleRef.current?.cancel();
},
[],
);

const singleExecution = useCallback(
<T extends unknown[]>(action: Action<T>) =>
(...params: T) => {
Expand All @@ -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(() => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unmount cleanup cancels the transition handle, but if action() returned a Promise, execution.finally inside this callback can still call setIsExecuting(false) afterward. That is the same class of issue as the old uncancelled debounce timer. A small mounted/ref guard around the setIsExecuting(false) paths would prevent a post-unmount state update when the async action settles late

if (!(execution instanceof Promise)) {
setIsExecuting(false);
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -95,6 +96,7 @@ function TabNavigator() {
backBehavior="fullHistory"
tabBar={renderTabBar}
screenOptions={screenOptions}
screenLayout={bottomTabScreenLayoutWrapper}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the PR description mentions wiring this with a focus guard so nested tab routes do not double-register, but screenLayout is applied on Tab.Navigator for every tab. Nested tabs already get stack screenLayout, and tab switches use animation: 'none', so it is unclear what extra transitions this adds outside Home. If Home is the only tab that mounts a screen directly, consider scoping bottomTabScreenLayoutWrapper to the Home Tab.Screen instead of the whole navigator

UNSTABLE_router={tabRouterOverride}
>
<Tab.Screen
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';

import {bottomTabScreenLayoutWrapper} from '@libs/Navigation/PlatformStackNavigation/ScreenLayout';
import type {TabNavigatorParamList} from '@libs/Navigation/types';
import {getSpan} from '@libs/telemetry/activeSpans';

Expand Down Expand Up @@ -115,6 +116,7 @@ function TabNavigator() {
backBehavior="fullHistory"
tabBar={renderTabBar}
screenOptions={screenOptions}
screenLayout={bottomTabScreenLayoutWrapper}
>
<Tab.Screen
name={SCREENS.HOME}
Expand Down
39 changes: 33 additions & 6 deletions src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,45 @@
import TransitionTracker from '@libs/Navigation/TransitionTracker';
import type {TransitionHandle} from '@libs/Navigation/TransitionTracker';

import type {BottomTabNavigationOptions, BottomTabNavigationProp} from '@react-navigation/bottom-tabs';
import type {ParamListBase, ScreenLayoutArgs} from '@react-navigation/native';

import React, {useLayoutEffect, useRef} from 'react';

import type {PlatformSpecificNavigationOptions, PlatformStackNavigationOptions, PlatformStackNavigationProp} from './types';
import type {PlatformSpecificNavigationOptions, PlatformStackNavigationOptions} from './types';

// The only navigation capability ScreenLayout actually needs, regardless of which navigator (stack, bottom-tabs, ...)
// it's used with. Keeping this minimal means passing a real (properly-typed) navigation prop into it - e.g. from
// bottomTabScreenLayoutWrapper below - needs no unsafe cast, since every navigator's `addListener` structurally satisfies it.
type TransitionAwareNavigation = {
addListener(type: 'transitionStart' | 'transitionEnd', callback: () => 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<ParamListBase, string, PlatformSpecificNavigationOptions | PlatformStackNavigationOptions, string>) {
return (
<ScreenLayout
{...rest}
// The type cast is needed because useNavigationBuilder hardcodes the Navigation generic to `string`.
navigation={navigation as unknown as PlatformStackNavigationProp<ParamListBase>}
navigation={navigation as unknown as TransitionAwareNavigation}
/>
);
}

function ScreenLayout({
children,
navigation,
}: ScreenLayoutArgs<ParamListBase, string, PlatformSpecificNavigationOptions | PlatformStackNavigationOptions, PlatformStackNavigationProp<ParamListBase>>) {
// 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<ParamListBase, string, BottomTabNavigationOptions, BottomTabNavigationProp<ParamListBase>>) {
return (
<ScreenLayout
{...rest}
navigation={navigation}
/>
);
}

type ScreenLayoutProps = ScreenLayoutArgs<ParamListBase, string, PlatformSpecificNavigationOptions | PlatformStackNavigationOptions | BottomTabNavigationOptions, TransitionAwareNavigation>;

function ScreenLayout({children, navigation}: ScreenLayoutProps) {
const transitionHandleRef = useRef<TransitionHandle | null>(null);

useLayoutEffect(() => {
Expand All @@ -39,10 +57,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]);

return children;
}

export default screenLayoutWrapper;
export {bottomTabScreenLayoutWrapper};
30 changes: 24 additions & 6 deletions src/libs/Navigation/TransitionTracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,23 +24,25 @@ type RunAfterTransitionsOptions = {

const activeTransitions = new Map<TransitionHandle, ReturnType<typeof setTimeout>>();

const transitionStartListeners = new Set<() => void>();

let pendingCallbacks: Array<() => void | Promise<void>> = [];

let nextTransitionStartResolve: (() => void) | null = null;
let promiseForNextTransitionStart = new Promise<void>((resolve) => {
nextTransitionStartResolve = resolve;
});

function runCallback(callback: () => void | Promise<void>): void {
function invokeSafely(fn: () => void | Promise<void>): 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});
}
}

Expand All @@ -52,7 +54,7 @@ function flushCallbacks(): void {
const callbacks = pendingCallbacks;
pendingCallbacks = [];
for (const callback of callbacks) {
runCallback(callback);
invokeSafely(callback);
}
}

Expand Down Expand Up @@ -91,6 +93,10 @@ function startTransition(): TransitionHandle {

activeTransitions.set(handle, timeout);

for (const listener of transitionStartListeners) {
invokeSafely(listener);
}

return handle;
}

Expand Down Expand Up @@ -168,7 +174,7 @@ function runAfterTransitions({
}

if (activeTransitions.size === 0 || runImmediately) {
runCallback(callback);
invokeSafely(callback);
return {cancel: () => {}};
}

Expand All @@ -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 = {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

new onTransitionStart API drives prediction clearing in runAfterPredictedTransition, but TransitionTrackerTest does not cover listener notification or error isolation through this path (only the renamed invokeSafely messages changed). A small test that startTransition invokes subscribers would protect this hook from regressions

startTransition,
endTransition,
runAfterTransitions,
onTransitionStart,
};

export default TransitionTracker;
Expand Down
Loading
Loading