Migration navigation from InteractionManager to TransitionTracker V3#85759
Conversation
…s/collectioneur/transition-tracker-v2" This reverts commit a6b928b.
Codecov Report❌ Looks like you've decreased code coverage for some files. Please write tests to increase, or at least maintain, the existing level of code coverage. See our documentation here for how to interpret this table.
|
|
@srikarparsi can you plz trigger the adhoc build here |
|
🚧 @srikarparsi has triggered a test Expensify/App build. You can view the workflow run here. |
This comment has been minimized.
This comment has been minimized.
|
@collectioneur I still face the issue and happening on first chat selection too. Record_2026-03-25-14-28-02.mp4 |
|
Thanks for retesting this! I'm a bit tied up with an urgent task at the moment, but I'll make sure to dive into it next week 😄 |
|
Hi, today I was investigating the last two regressions in The problem was that in the hybrid Android build, when we push a new navigator to the stack, react-navigation doesn’t emit the I already have a fix, but I need to discuss it with the maintainer first to check if it's an acceptable solution |
…ismissed in HybridApp
|
|
|
Hi, Since their next release isn't expected for about 3 weeks, I created a temporary patch that mirrors the code changes. We can simply remove this patch once we bump the Additionally, I added some fallback logic to @Pujan92 , would you mind testing these changes and taking another look at the code when you have a chance? I'd really appreciate it! 😄 |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a5aef119ab
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| // eslint-disable-next-line @typescript-eslint/no-deprecated | ||
| InteractionManager.clearInteractionHandle(handleRef.current); | ||
| } | ||
| TransitionTracker.endTransition(); |
There was a problem hiding this comment.
Avoid ending transitions on unmount without a matching start
ReanimatedModal now calls TransitionTracker.endTransition() unconditionally in its unmount cleanup, even when this instance never called startTransition(). If a screen containing this modal unmounts during an unrelated navigation transition, this decrements the global tracker count for that other transition and can flush queued afterTransition work before the active animation actually ends. This breaks the ordering guarantee that callers rely on for post-transition navigation/state updates.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
The unconditional endTransition() in the unmount cleanup can only cause a spurious decrement if a screen containing an idle ReanimatedModal unmounts while a separate navigation transition is in-flight, but in our navigation, previous screens stay mounted (they're hidden, not destroyed), so this timing collision doesn't occur in practice.
| const timeout = activeTimeouts.shift(); | ||
| if (timeout !== undefined) { | ||
| clearTimeout(timeout); |
There was a problem hiding this comment.
Match transition end with its own timeout
endTransition() removes timers with activeTimeouts.shift(), which assumes transitions always end in start order. With overlapping transitions that complete out of order, this clears the wrong timeout and leaves a stale timeout attached to an already-ended transition. That stale timer can later decrement the global count at the wrong moment, causing runAfterTransitions callbacks to fire too early or too late when transitions overlap.
Useful? React with 👍 / 👎.
|
@Pujan92 This one is yours to review based on this, it's the confirmed rework coming from this reverted PR: |
Reviewer Checklist
Screenshots/VideosAndroid: HybridAppm1.webmp2.webmp3.webmAndroid: mWeb ChromeiOS: HybridAppSimulator.Screen.Recording.-.iPhone.15.Pro.-.2026-03-16.at.23.27.18.movSimulator.Screen.Recording.-.iPhone.15.Pro.-.2026-03-16.at.23.32.43.movSimulator.Screen.Recording.-.iPhone.15.Pro.-.2026-03-16.at.23.35.37.moviOS: mWeb SafariSimulator.Screen.Recording.-.iPhone.15.Pro.-.2026-03-16.at.23.38.15.movSimulator.Screen.Recording.-.iPhone.15.Pro.-.2026-03-16.at.23.41.19.movMacOS: Chrome / SafariScreen.Recording.2026-03-16.at.23.43.54.mov |
|
@collectioneur Plz fix the conflicts |
|
@Pujan92 Conflicts resolved ✅ |
| import omit from 'lodash/omit'; | ||
| import {nanoid} from 'nanoid/non-secure'; | ||
| import {DeviceEventEmitter, Dimensions, InteractionManager} from 'react-native'; | ||
| import {Dimensions} from 'react-native'; |
There was a problem hiding this comment.
Seems DeviceEventEmitter is used in the latest code and we removed it from the import causing the issue
There was a problem hiding this comment.
Yeah, fixing it
roryabraham
left a comment
There was a problem hiding this comment.
Claude implementation of my feedback
diff --git a/Mobile-Expensify b/Mobile-Expensify
index 08ff3fc6bc0..25f65b61731 160000
--- a/Mobile-Expensify
+++ b/Mobile-Expensify
@@ -1 +1 @@
-Subproject commit 08ff3fc6bc036b9db717157fcfaaa72dc7b18bb8
+Subproject commit 25f65b6173118b5b726db6e785fc105db35c1b99
diff --git a/src/components/Modal/ReanimatedModal/index.tsx b/src/components/Modal/ReanimatedModal/index.tsx
index 8458f38c316..cbd701225e9 100644
--- a/src/components/Modal/ReanimatedModal/index.tsx
+++ b/src/components/Modal/ReanimatedModal/index.tsx
@@ -10,6 +10,7 @@ import useWindowDimensions from '@hooks/useWindowDimensions';
import blurActiveElement from '@libs/Accessibility/blurActiveElement';
import getPlatform from '@libs/getPlatform';
import TransitionTracker from '@libs/Navigation/TransitionTracker';
+import type {TransitionHandle} from '@libs/Navigation/TransitionTracker';
import variables from '@styles/variables';
import CONST from '@src/CONST';
import Backdrop from './Backdrop';
@@ -58,6 +59,7 @@ function ReanimatedModal({
const backHandlerListener = useRef<NativeEventSubscription | null>(null);
const handleRef = useRef<number | undefined>(undefined);
+ const transitionHandleRef = useRef<TransitionHandle | null>(null);
const styles = useThemeStyles();
@@ -104,7 +106,10 @@ function ReanimatedModal({
// eslint-disable-next-line @typescript-eslint/no-deprecated
InteractionManager.clearInteractionHandle(handleRef.current);
}
- TransitionTracker.endTransition();
+ if (transitionHandleRef.current) {
+ TransitionTracker.endTransition(transitionHandleRef.current);
+ transitionHandleRef.current = null;
+ }
setIsVisibleState(false);
setIsContainerOpen(false);
@@ -117,7 +122,7 @@ function ReanimatedModal({
if (isVisible && !isContainerOpen && !isTransitioning) {
// eslint-disable-next-line @typescript-eslint/no-deprecated
handleRef.current = InteractionManager.createInteractionHandle();
- TransitionTracker.startTransition();
+ transitionHandleRef.current = TransitionTracker.startTransition();
onModalWillShow();
setIsVisibleState(true);
@@ -125,7 +130,7 @@ function ReanimatedModal({
} else if (!isVisible && isContainerOpen && !isTransitioning) {
// eslint-disable-next-line @typescript-eslint/no-deprecated
handleRef.current = InteractionManager.createInteractionHandle();
- TransitionTracker.startTransition();
+ transitionHandleRef.current = TransitionTracker.startTransition();
onModalWillHide();
blurActiveElement();
@@ -146,7 +151,10 @@ function ReanimatedModal({
// eslint-disable-next-line @typescript-eslint/no-deprecated
InteractionManager.clearInteractionHandle(handleRef.current);
}
- TransitionTracker.endTransition();
+ if (transitionHandleRef.current) {
+ TransitionTracker.endTransition(transitionHandleRef.current);
+ transitionHandleRef.current = null;
+ }
onModalShow();
}, [onModalShow]);
@@ -157,7 +165,10 @@ function ReanimatedModal({
// eslint-disable-next-line @typescript-eslint/no-deprecated
InteractionManager.clearInteractionHandle(handleRef.current);
}
- TransitionTracker.endTransition();
+ if (transitionHandleRef.current) {
+ TransitionTracker.endTransition(transitionHandleRef.current);
+ transitionHandleRef.current = null;
+ }
// Because on Android, the Modal's onDismiss callback does not work reliably. There's a reported issue at:
// https://stackoverflow.com/questions/58937956/react-native-modal-ondismiss-not-invoked
diff --git a/src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx b/src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx
index 4fd130261ef..5ee2e3abe34 100644
--- a/src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx
+++ b/src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx
@@ -1,6 +1,7 @@
import type {ParamListBase, ScreenLayoutArgs} from '@react-navigation/native';
-import React, {useLayoutEffect} from 'react';
+import React, {useLayoutEffect, useRef} from 'react';
import TransitionTracker from '@libs/Navigation/TransitionTracker';
+import type {TransitionHandle} from '@libs/Navigation/TransitionTracker';
import type {PlatformSpecificNavigationOptions, PlatformStackNavigationOptions, PlatformStackNavigationProp} from './types';
// screenLayout is invoked as a render function (not JSX), so we need this wrapper to create a proper React component boundary for hooks.
@@ -19,12 +20,18 @@ function ScreenLayout({
children,
navigation,
}: ScreenLayoutArgs<ParamListBase, string, PlatformSpecificNavigationOptions | PlatformStackNavigationOptions, PlatformStackNavigationProp<ParamListBase>>) {
+ const transitionHandleRef = useRef<TransitionHandle | null>(null);
+
useLayoutEffect(() => {
const transitionStartListener = navigation.addListener('transitionStart', () => {
- TransitionTracker.startTransition();
+ transitionHandleRef.current = TransitionTracker.startTransition();
});
const transitionEndListener = navigation.addListener('transitionEnd', () => {
- TransitionTracker.endTransition();
+ if (!transitionHandleRef.current) {
+ return;
+ }
+ TransitionTracker.endTransition(transitionHandleRef.current);
+ transitionHandleRef.current = null;
});
return () => {
diff --git a/src/libs/Navigation/TransitionTracker.ts b/src/libs/Navigation/TransitionTracker.ts
index 47634672e67..5a8e1d6a7f1 100644
--- a/src/libs/Navigation/TransitionTracker.ts
+++ b/src/libs/Navigation/TransitionTracker.ts
@@ -1,5 +1,8 @@
+import Log from '@libs/Log';
import CONST from '@src/CONST';
+type TransitionHandle = symbol;
+
type CancelHandle = {cancel: () => void};
type RunAfterTransitionsOptions = {
@@ -15,9 +18,7 @@ type RunAfterTransitionsOptions = {
waitForUpcomingTransition?: boolean;
};
-let activeCount = 0;
-
-const activeTimeouts: Array<ReturnType<typeof setTimeout>> = [];
+const activeTransitions = new Map<TransitionHandle, ReturnType<typeof setTimeout>>();
let pendingCallbacks: Array<() => void> = [];
@@ -28,12 +29,17 @@ let promiseForNextTransitionStart = new Promise<void>((resolve) => {
/**
* Invokes and removes all pending callbacks.
+ * Each callback is isolated so that one exception does not prevent the rest from running.
*/
function flushCallbacks(): void {
const callbacks = pendingCallbacks;
pendingCallbacks = [];
for (const callback of callbacks) {
- callback();
+ try {
+ callback();
+ } catch (error) {
+ Log.warn('[TransitionTracker] A pending callback threw an error', {error});
+ }
}
}
@@ -42,20 +48,19 @@ function flushCallbacks(): void {
* Shared by {@link endTransition} (manual) and the auto-timeout.
*/
function decrementAndFlush(): void {
- activeCount = Math.max(0, activeCount - 1);
-
- if (activeCount === 0) {
- flushCallbacks();
+ if (activeTransitions.size !== 0) {
+ return;
}
+ flushCallbacks();
}
/**
- * Increments the active transition count.
- * Multiple overlapping transitions are counted.
- * Each transition automatically ends after {@link MAX_TRANSITION_DURATION_MS} as a safety net.
+ * Increments the active transition count and returns a handle that must be passed to {@link endTransition}.
+ * Multiple overlapping transitions are tracked independently.
+ * Each transition automatically ends after {@link CONST.MAX_TRANSITION_DURATION_MS} as a safety net.
*/
-function startTransition(): void {
- activeCount += 1;
+function startTransition(): TransitionHandle {
+ const handle: TransitionHandle = Symbol('transition');
const resolve = nextTransitionStartResolve;
if (resolve) {
@@ -67,27 +72,29 @@ function startTransition(): void {
}
const timeout = setTimeout(() => {
- const idx = activeTimeouts.indexOf(timeout);
- if (idx !== -1) {
- activeTimeouts.splice(idx, 1);
- }
+ activeTransitions.delete(handle);
decrementAndFlush();
}, CONST.MAX_TRANSITION_DURATION_MS);
- activeTimeouts.push(timeout);
+ activeTransitions.set(handle, timeout);
+
+ return handle;
}
/**
- * Decrements the active transition count.
- * Clears the corresponding auto-timeout since the transition ended normally.
- * When the count reaches zero, flushes all pending callbacks.
+ * Ends the transition identified by {@link handle}.
+ * Clears the corresponding safety timeout since the transition ended normally.
+ * When no active transitions remain, flushes all pending callbacks.
+ * If the handle is unknown (already ended or already expired via safety timeout), this is a no-op.
*/
-function endTransition(): void {
- const timeout = activeTimeouts.shift();
- if (timeout !== undefined) {
- clearTimeout(timeout);
+function endTransition(handle: TransitionHandle): void {
+ const timeout = activeTransitions.get(handle);
+ if (timeout === undefined) {
+ return;
}
+ clearTimeout(timeout);
+ activeTransitions.delete(handle);
decrementAndFlush();
}
@@ -127,12 +134,13 @@ function runAfterTransitions({callback, runImmediately = false, waitForUpcomingT
return {
cancel: () => {
cancelled = true;
+ clearTimeout(transitionStartTimeoutId);
innerHandle?.cancel();
},
};
}
- if (activeCount === 0 || runImmediately) {
+ if (activeTransitions.size === 0 || runImmediately) {
callback();
return {cancel: () => {}};
}
@@ -156,4 +164,4 @@ const TransitionTracker = {
};
export default TransitionTracker;
-export type {CancelHandle};
+export type {CancelHandle, TransitionHandle};
diff --git a/src/utils/keyboard/index.android.ts b/src/utils/keyboard/index.android.ts
index 9dd0dbfbf66..c0e470b4f2c 100644
--- a/src/utils/keyboard/index.android.ts
+++ b/src/utils/keyboard/index.android.ts
@@ -32,13 +32,12 @@ const dismiss = (options?: DismissKeyboardOptions): Promise<void> => {
return;
}
+ const transitionHandle = TransitionTracker.startTransition();
const subscription = Keyboard.addListener('keyboardDidHide', () => {
resolve();
- TransitionTracker.endTransition();
+ TransitionTracker.endTransition(transitionHandle);
subscription.remove();
});
-
- TransitionTracker.startTransition();
Keyboard.dismiss();
if (options?.afterTransition) {
diff --git a/src/utils/keyboard/index.ts b/src/utils/keyboard/index.ts
index f8d35ab4a99..83243968af9 100644
--- a/src/utils/keyboard/index.ts
+++ b/src/utils/keyboard/index.ts
@@ -31,13 +31,12 @@ const dismiss = (options?: DismissKeyboardOptions): Promise<void> => {
return;
}
+ const transitionHandle = TransitionTracker.startTransition();
const subscription = Keyboard.addListener('keyboardDidHide', () => {
resolve();
- TransitionTracker.endTransition();
+ TransitionTracker.endTransition(transitionHandle);
subscription.remove();
});
-
- TransitionTracker.startTransition();
Keyboard.dismiss();
if (options?.afterTransition) {
diff --git a/src/utils/keyboard/index.website.ts b/src/utils/keyboard/index.website.ts
index cef64dbbae7..ecf12f7f261 100644
--- a/src/utils/keyboard/index.website.ts
+++ b/src/utils/keyboard/index.website.ts
@@ -47,6 +47,8 @@ const dismiss = (options?: DismissKeyboardOptions): Promise<void> => {
return;
}
+ const transitionHandle = TransitionTracker.startTransition();
+
const handleDismissResize = () => {
const viewportHeight = window?.visualViewport?.height;
@@ -60,13 +62,11 @@ const dismiss = (options?: DismissKeyboardOptions): Promise<void> => {
}
window.visualViewport?.removeEventListener('resize', handleDismissResize);
- TransitionTracker.endTransition();
+ TransitionTracker.endTransition(transitionHandle);
return resolve();
};
window.visualViewport?.addEventListener('resize', handleDismissResize);
-
- TransitionTracker.startTransition();
Keyboard.dismiss();
if (options?.afterTransition) {
diff --git a/tests/unit/Navigation/TransitionTrackerTest.ts b/tests/unit/Navigation/TransitionTrackerTest.ts
index 6c18368fb85..d7a7ae9cfbc 100644
--- a/tests/unit/Navigation/TransitionTrackerTest.ts
+++ b/tests/unit/Navigation/TransitionTrackerTest.ts
@@ -23,42 +23,42 @@ describe('TransitionTracker', () => {
});
it('runs callback immediately when runImmediately is true even with active transition', () => {
- TransitionTracker.startTransition();
+ const handle = TransitionTracker.startTransition();
const callback = jest.fn();
TransitionTracker.runAfterTransitions({callback, runImmediately: true});
expect(callback).toHaveBeenCalledTimes(1);
- TransitionTracker.endTransition();
+ TransitionTracker.endTransition(handle);
drainTransitions();
});
it('queues callback when transition is active and runs it after endTransition', () => {
const callback = jest.fn();
- TransitionTracker.startTransition();
+ const handle = TransitionTracker.startTransition();
TransitionTracker.runAfterTransitions({callback});
expect(callback).not.toHaveBeenCalled();
- TransitionTracker.endTransition();
+ TransitionTracker.endTransition(handle);
expect(callback).toHaveBeenCalledTimes(1);
drainTransitions();
});
it('runs queued callbacks only when all overlapping transitions end', () => {
const callback = jest.fn();
- TransitionTracker.startTransition();
- TransitionTracker.startTransition();
+ const handleA = TransitionTracker.startTransition();
+ const handleB = TransitionTracker.startTransition();
TransitionTracker.runAfterTransitions({callback});
- TransitionTracker.endTransition();
+ TransitionTracker.endTransition(handleA);
expect(callback).not.toHaveBeenCalled();
- TransitionTracker.endTransition();
+ TransitionTracker.endTransition(handleB);
expect(callback).toHaveBeenCalledTimes(1);
drainTransitions();
});
it('cancel prevents queued callback from running', () => {
const callback = jest.fn();
- TransitionTracker.startTransition();
- const handle = TransitionTracker.runAfterTransitions({callback});
- handle.cancel();
- TransitionTracker.endTransition();
+ const transitionHandle = TransitionTracker.startTransition();
+ const cancelHandle = TransitionTracker.runAfterTransitions({callback});
+ cancelHandle.cancel();
+ TransitionTracker.endTransition(transitionHandle);
expect(callback).not.toHaveBeenCalled();
drainTransitions();
});
@@ -77,12 +77,12 @@ describe('TransitionTracker', () => {
const callback = jest.fn();
TransitionTracker.runAfterTransitions({callback, waitForUpcomingTransition: true});
expect(callback).not.toHaveBeenCalled();
- TransitionTracker.startTransition();
+ const handle = TransitionTracker.startTransition();
// Two ticks: one for promiseForNextTransitionStart, one for Promise.race wrapper
await Promise.resolve();
await Promise.resolve();
expect(callback).not.toHaveBeenCalled();
- TransitionTracker.endTransition();
+ TransitionTracker.endTransition(handle);
expect(callback).toHaveBeenCalledTimes(1);
drainTransitions();
});
@@ -100,21 +100,85 @@ describe('TransitionTracker', () => {
it('cancel prevents waitForUpcomingTransition callback from running after transition starts', () => {
const callback = jest.fn();
- const handle = TransitionTracker.runAfterTransitions({callback, waitForUpcomingTransition: true});
- TransitionTracker.startTransition();
- handle.cancel();
- TransitionTracker.endTransition();
+ const cancelHandle = TransitionTracker.runAfterTransitions({callback, waitForUpcomingTransition: true});
+ const transitionHandle = TransitionTracker.startTransition();
+ cancelHandle.cancel();
+ TransitionTracker.endTransition(transitionHandle);
expect(callback).not.toHaveBeenCalled();
drainTransitions();
});
it('cancel before transition starts prevents waitForUpcomingTransition callback from running', () => {
const callback = jest.fn();
- const handle = TransitionTracker.runAfterTransitions({callback, waitForUpcomingTransition: true});
- handle.cancel();
- TransitionTracker.startTransition();
- TransitionTracker.endTransition();
+ const cancelHandle = TransitionTracker.runAfterTransitions({callback, waitForUpcomingTransition: true});
+ cancelHandle.cancel();
+ const transitionHandle = TransitionTracker.startTransition();
+ TransitionTracker.endTransition(transitionHandle);
+ expect(callback).not.toHaveBeenCalled();
+ drainTransitions();
+ });
+ });
+
+ describe('handle-based pairing', () => {
+ it('out-of-order end: transitions ended in reverse order still flush correctly', () => {
+ const callback = jest.fn();
+ const handleA = TransitionTracker.startTransition();
+ const handleB = TransitionTracker.startTransition();
+ TransitionTracker.runAfterTransitions({callback});
+ TransitionTracker.endTransition(handleB);
+ expect(callback).not.toHaveBeenCalled();
+ TransitionTracker.endTransition(handleA);
+ expect(callback).toHaveBeenCalledTimes(1);
+ drainTransitions();
+ });
+
+ it('double-end with same handle is a no-op and does not corrupt the count', () => {
+ const callback = jest.fn();
+ const handleA = TransitionTracker.startTransition();
+ const handleB = TransitionTracker.startTransition();
+ TransitionTracker.runAfterTransitions({callback});
+
+ TransitionTracker.endTransition(handleA);
+ TransitionTracker.endTransition(handleA);
expect(callback).not.toHaveBeenCalled();
+
+ TransitionTracker.endTransition(handleB);
+ expect(callback).toHaveBeenCalledTimes(1);
+ drainTransitions();
+ });
+
+ it('safety timeout fires then manual endTransition is a no-op — no double-decrement', () => {
+ const callback = jest.fn();
+ const handle = TransitionTracker.startTransition();
+ TransitionTracker.runAfterTransitions({callback});
+
+ jest.advanceTimersByTime(CONST.MAX_TRANSITION_DURATION_MS);
+ expect(callback).toHaveBeenCalledTimes(1);
+
+ TransitionTracker.endTransition(handle);
+ expect(callback).toHaveBeenCalledTimes(1);
+
+ const laterCallback = jest.fn();
+ const laterHandle = TransitionTracker.startTransition();
+ TransitionTracker.runAfterTransitions({callback: laterCallback});
+ expect(laterCallback).not.toHaveBeenCalled();
+ TransitionTracker.endTransition(laterHandle);
+ expect(laterCallback).toHaveBeenCalledTimes(1);
+ drainTransitions();
+ });
+
+ it('exception in one callback does not prevent subsequent callbacks from running', () => {
+ const handle = TransitionTracker.startTransition();
+ const callbackA = jest.fn(() => {
+ throw new Error('boom');
+ });
+ const callbackB = jest.fn();
+ TransitionTracker.runAfterTransitions({callback: callbackA});
+ TransitionTracker.runAfterTransitions({callback: callbackB});
+
+ TransitionTracker.endTransition(handle);
+ expect(callbackA).toHaveBeenCalledTimes(1);
+ expect(callbackB).toHaveBeenCalledTimes(1);
drainTransitions();
});
});…d dismiss logic to start transition before subscribing to listeners
|
✋ This PR was not deployed to staging yet because QA is ongoing. It will be automatically deployed to staging after the next production release. |
|
🚧 @roryabraham has triggered a test Expensify/App build. You can view the workflow run here. |
|
🧪🧪 Use the links below to test this adhoc build on Android, iOS, and Web. Happy testing! 🧪🧪
|
|
🚀 Deployed to staging by https://github.com/roryabraham in version: 9.3.60-0 🚀
Bundle Size Analysis (Sentry): |
|
No help site changes are required for this PR. This is an internal code infrastructure change (introducing |
|
🚀 Deployed to production by https://github.com/mountiny in version: 9.3.60-22 🚀
|
Explanation of Change
This PR introduces the TransitionTracker and migrates the first batch of
InteractionManagerusages in navigation code to use it.TransitionTracker (
src/libs/Navigation/TransitionTracker.ts) is a new module that explicitly tracks active transitions (navigation, modal, keyboard, focus) using a counted map. It exposesstartTransition/endTransitionto mark transition boundaries, andrunAfterTransitionsto queue callbacks that fire once all transitions are idle. A safety timeout (1s) auto-ends transitions that are never explicitly closed.On top of TransitionTracker, existing APIs gain transition-aware options:
*
Navigation.navigate,goBack, anddismissModalnow acceptwaitForTransition(defer the navigation until ongoing transitions finish) andafterTransition(run a callback after the triggered transition completes). This replaces the old pattern of wrapping navigation calls inInteractionManager.runAfterInteractions.dismissModalWithReportusesafterTransitioninstead of the oldDeviceEventEmitter-basedMODAL_EVENTS.CLOSEDcallback pattern. TheMODAL_EVENTS.CLOSEDconstant and its associatedDeviceEventEmitteremissions inBaseModalandRightModalNavigatorare removed.KeyboardUtils.dismissnow accepts{ afterTransition, shouldSkipSafari }instead of a bare boolean, and tracks keyboard show/hide as a transition type.ScreenLayout(src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx) is a new component wired into the web stack navigator viascreenLayoutprop. It listens totransitionStart/transitionEndevents to feed TransitionTracker.ReanimatedModalnow callsTransitionTracker.startTransition('modal')/endTransition('modal')alongside the existingInteractionManagerhandles.Concrete migration examples in this PR:
WorkspaceCategoriesSettingsPageandWorkspaceCreateTagPagereplaceKeyboard.dismiss()+InteractionManagerwithKeyboardUtils.dismiss({ afterTransition }).WorkspaceInviteMessageComponent,NewChatPage,TransactionReceiptModalContent, andReport/index.tsreplacedismissModal({ callback })withdismissModal({ afterTransition }).Fixed Issues
$ #71913
$ #85696
$ #85687
Tests
Offline tests
N/A
QA Steps
Same as tests
PR Author Checklist
### Fixed Issuessection aboveTestssectionOffline stepssectionQA stepssectiontoggleReportand notonIconClick)src/languages/*files and using the translation methodSTYLE.md) were followedAvatar, I verified the components usingAvatarare working as expected)StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))npm run compress-svg)Avataris modified, I verified thatAvataris working as expected in all cases)Designlabel and/or tagged@Expensify/designso the design team can review the changes.ScrollViewcomponent to make it scrollable when more elements are added to the page.mainbranch was merged into this PR after a review, I tested again and verified the outcome was still expected according to theTeststeps.Screenshots/Videos
Start new chat:
Screen.Recording.2026-03-03.at.16.17.56.mov
Workspace invite:
Screen.Recording.2026-03-03.at.16.19.09.mov
Create group:
Screen.Recording.2026-03-03.at.16.21.00.mov
Replace receipt:
Screen.Recording.2026-03-03.at.16.14.02.mov
Keyboard behaviour:
Screen.Recording.2026-03-03.at.16.15.22.mov
MacOS: Chrome / Safari
Start new chat:
Screen.Recording.2026-03-03.at.16.41.24.mov
Workspace invite:
Screen.Recording.2026-03-03.at.16.42.14.mov
Create group:
Screen.Recording.2026-03-03.at.16.43.08.mov
Replace receipt:
Screen.Recording.2026-03-03.at.16.43.50.mov