Skip to content

Migration navigation from InteractionManager to TransitionTracker V3#85759

Merged
roryabraham merged 17 commits intoExpensify:mainfrom
software-mansion-labs:collectioneur/transition-tracker-v3
Apr 15, 2026
Merged

Migration navigation from InteractionManager to TransitionTracker V3#85759
roryabraham merged 17 commits intoExpensify:mainfrom
software-mansion-labs:collectioneur/transition-tracker-v3

Conversation

@collectioneur
Copy link
Copy Markdown
Contributor

@collectioneur collectioneur commented Mar 19, 2026

Explanation of Change

This PR introduces the TransitionTracker and migrates the first batch of InteractionManager usages 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 exposes startTransition/endTransition to mark transition boundaries, and runAfterTransitions to 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, and dismissModal now accept waitForTransition (defer the navigation until ongoing transitions finish) and afterTransition (run a callback after the triggered transition completes). This replaces the old pattern of wrapping navigation calls in InteractionManager.runAfterInteractions.

  • dismissModalWithReport uses afterTransition instead of the old DeviceEventEmitter-based MODAL_EVENTS.CLOSED callback pattern. The MODAL_EVENTS.CLOSED constant and its associated DeviceEventEmitter emissions in BaseModal and RightModalNavigator are removed.
  • KeyboardUtils.dismiss now 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 via screenLayout prop. It listens to transitionStart/transitionEnd events to feed TransitionTracker.
  • ReanimatedModal now calls TransitionTracker.startTransition('modal') / endTransition('modal') alongside the existing InteractionManager handles.

Concrete migration examples in this PR:

  • WorkspaceCategoriesSettingsPage and WorkspaceCreateTagPage replace Keyboard.dismiss() + InteractionManager with KeyboardUtils.dismiss({ afterTransition }).
  • WorkspaceInviteMessageComponent, NewChatPage, TransactionReceiptModalContent, and Report/index.ts replace dismissModal({ callback }) with dismissModal({ afterTransition }).

Fixed Issues

$ #71913
$ #85696
$ #85687

Tests

  1. Replace Reciept:
  • Tap on any expense.
  • Tap on the receipt.
  • Tap 3-dot menu.
  • Tap Replace.
  • Verify that attachment modal is closed as on production and then RHP is opened
  1. Workspace Invite
  • Create workspace
  • Go to overview page and click Invite button
  • On Confirm details page click 'Invite'
  • Verify that modal is closed and user is navigated to Members page afterwards
  1. Create group
  • Click on FAB > Start chat
  • choose a user and click 'Add to group'
  • On Group details page simply click 'Start group'
  • Verify that modal is closed and user is navigated to that report afterwards
  1. Navigate to a chat from FAB (RHP)
  • Click on FAB > Start chat
  • Choose a person (other than self and not the chat that is opened underneath)
  • Click on that person
  • Verify that modal is closed and user is navigated to that report afterwards

Offline tests

N/A

QA Steps

Same as tests

PR Author Checklist

  • I linked the correct issue in the ### Fixed Issues section above
  • I wrote clear testing steps that cover the changes made in this PR
    • I added steps for local testing in the Tests section
    • I added steps for the expected offline behavior in the Offline steps section
    • I added steps for Staging and/or Production testing in the QA steps section
    • I added steps to cover failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
    • I tested this PR with a High Traffic account against the staging or production API to ensure there are no regressions (e.g. long loading states that impact usability).
  • I included screenshots or videos for tests on all platforms
  • I ran the tests on all platforms & verified they passed on:
    • Android: Native
    • Android: mWeb Chrome
    • iOS: Native
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • I verified there are no console errors (if there's a console error not related to the PR, report it or open an issue for it to be fixed)
  • I followed proper code patterns (see Reviewing the code)
    • I verified that any callback methods that were added or modified are named for what the method does and never what callback they handle (i.e. toggleReport and not onIconClick)
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text shown in the product is localized by adding it to src/languages/* files and using the translation method
      • If any non-english text was added/modified, I used JaimeGPT to get English > Spanish translation. I then posted it in #expensify-open-source and it was approved by an internal Expensify engineer. Link to Slack message:
    • I verified all numbers, amounts, dates and phone numbers shown in the product are using the localization methods
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
    • I verified proper file naming conventions were followed for any new files or renamed files. All non-platform specific files are named after what they export and are not named "index.js". All platform-specific files are named for the platform the code supports as outlined in the README.
    • I verified the JSDocs style guidelines (in STYLE.md) were followed
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I followed the guidelines as stated in the Review Guidelines
  • I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar are working as expected)
  • I verified all code is DRY (the PR doesn't include any logic written more than once, with the exception of tests)
  • I verified any variables that can be defined as constants (ie. in CONST.ts or at the top of the file that uses the constant) are defined as such
  • I verified that if a function's arguments changed that all usages have also been updated correctly
  • If any new file was added I verified that:
    • The file has a description of what it does and/or why is needed at the top of the file if the code is not self explanatory
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))
  • If new assets were added or existing ones were modified, I verified that:
    • The assets are optimized and compressed (for SVG files, run npm run compress-svg)
    • The assets load correctly across all supported platforms.
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • If a new page is added, I verified it's using the ScrollView component to make it scrollable when more elements are added to the page.
  • I added unit tests for any new feature or bug fix in this PR to help automatically prevent regressions in this user flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.

Screenshots/Videos

iOS: Native

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

…s/collectioneur/transition-tracker-v2"

This reverts commit a6b928b.
@codecov
Copy link
Copy Markdown

codecov Bot commented Mar 19, 2026

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.

Files with missing lines Coverage Δ
src/CONST/index.ts 92.30% <ø> (ø)
src/components/Modal/BaseModal.tsx 86.92% <ø> (-0.10%) ⬇️
...on/AppNavigator/Navigators/RightModalNavigator.tsx 0.00% <ø> (ø)
...tePlatformStackNavigatorComponent/index.native.tsx 100.00% <ø> (ø)
...on/createPlatformStackNavigatorComponent/index.tsx 0.00% <ø> (ø)
src/libs/Navigation/TransitionTracker.ts 100.00% <100.00%> (ø)
src/libs/actions/Report/index.ts 68.49% <100.00%> (-0.14%) ⬇️
src/pages/NewChatPage.tsx 72.52% <ø> (ø)
src/utils/keyboard/index.ts 96.15% <100.00%> (+0.91%) ⬆️
src/components/EmojiPicker/EmojiPicker.tsx 45.83% <0.00%> (ø)
... and 8 more
... and 128 files with indirect coverage changes

@collectioneur collectioneur changed the title Reapply "Merge pull request #83899 from software-mansion-labs/collect… Migration navigation from InteractionManager to TransitionTracker V3 Mar 19, 2026
@collectioneur
Copy link
Copy Markdown
Contributor Author

collectioneur commented Mar 19, 2026

Hey @Pujan92! I’ve actually tested these two regressions on a physical device (Pixel 6) and couldn’t reproduce these bugs (85687, 85696). Could you please retest them on this PR? Thanks! 😄
85687:

screen-20260319-142212-1773926505175.mp4

85696:

screen-20260319-142259-1773926551539.mp4

@Pujan92
Copy link
Copy Markdown
Contributor

Pujan92 commented Mar 20, 2026

@srikarparsi can you plz trigger the adhoc build here

@github-actions
Copy link
Copy Markdown
Contributor

🚧 @srikarparsi has triggered a test Expensify/App build. You can view the workflow run here.

@github-actions

This comment has been minimized.

@Pujan92
Copy link
Copy Markdown
Contributor

Pujan92 commented Mar 25, 2026

@collectioneur I still face the issue and happening on first chat selection too.

Record_2026-03-25-14-28-02.mp4

@collectioneur
Copy link
Copy Markdown
Contributor Author

collectioneur commented Mar 26, 2026

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 😄

@collectioneur
Copy link
Copy Markdown
Contributor Author

Hi, today I was investigating the last two regressions in transitionTracker, and I found out that the bug is actually not on our side, but in the react-native-screens library.

The problem was that in the hybrid Android build, when we push a new navigator to the stack, react-navigation doesn’t emit the transitionStart and transitionEnd events. Because of that, transitionTracker couldn’t really tell if the transition (dismissModal) had ended or if we were still waiting for it, and it didn’t open the new screen (via the afterTransition callback). I found out that on Android, react-native-screens is responsible for emitting the transitionStart and transitionEnd events, but in some specific cases, it blocks them.

I already have a fix, but I need to discuss it with the maintainer first to check if it's an acceptable solution

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 9, 2026

⚠️ This PR is possibly changing native code and/or updating libraries, it may cause problems with HybridApp. Please check if any patch updates are required in the HybridApp repo and run an AdHoc build to verify that HybridApp will not break. Ask Contributor Plus for help if you are not sure how to handle this. ⚠️

@collectioneur
Copy link
Copy Markdown
Contributor Author

Hi,
It turns out it really was a bug in react-native-screens. I've already opened a PR in the library to fix it. A maintainer is supposed to review it today, and if everything looks good, it should get merged.

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 react-native-screens library to the upcoming release.

Additionally, I added some fallback logic to transitionTracker as a safeguard for callbacks waiting for transitionStart. Now, the callback will wait for 1 second, and if transitionStart doesn't fire (just in case we run into any other edge cases in the future), the callback will safely resolve on its own.

@Pujan92 , would you mind testing these changes and taking another look at the code when you have a chance? I'd really appreciate it! 😄

@collectioneur collectioneur marked this pull request as ready for review April 9, 2026 11:22
@collectioneur collectioneur requested review from a team as code owners April 9, 2026 11:22
@melvin-bot melvin-bot Bot requested review from heyjennahay and ikevin127 April 9, 2026 11:22
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 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();
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

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.

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.

Comment on lines +86 to +88
const timeout = activeTimeouts.shift();
if (timeout !== undefined) {
clearTimeout(timeout);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

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.

Already answered here

@ikevin127
Copy link
Copy Markdown
Contributor

@Pujan92 This one is yours to review based on this, it's the confirmed rework coming from this reverted PR:

@Pujan92
Copy link
Copy Markdown
Contributor

Pujan92 commented Apr 13, 2026

Reviewer Checklist

  • I have verified the author checklist is complete (all boxes are checked off).
  • I verified the correct issue is linked in the ### Fixed Issues section above
  • I verified testing steps are clear and they cover the changes made in this PR
    • I verified the steps for local testing are in the Tests section
    • I verified the steps for Staging and/or Production testing are in the QA steps section
    • I verified the steps cover any possible failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
  • I checked that screenshots or videos are included for tests on all platforms
  • I included screenshots or videos for tests on all platforms
  • I verified tests pass on all platforms & I tested again on:
    • Android: HybridApp
    • Android: mWeb Chrome
    • iOS: HybridApp
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • If there are any errors in the console that are unrelated to this PR, I either fixed them (preferred) or linked to where I reported them in Slack
  • I verified proper code patterns were followed (see Reviewing the code)
    • I verified that any callback methods that were added or modified are named for what the method does and never what callback they handle (i.e. toggleReport and not onIconClick).
    • I verified that the left part of a conditional rendering a React component is a boolean and NOT a string, e.g. myBool && <MyComponent />.
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text shown in the product is localized by adding it to src/languages/* files and using the translation method
    • I verified all numbers, amounts, dates and phone numbers shown in the product are using the localization methods
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
    • I verified proper file naming conventions were followed for any new files or renamed files. All non-platform specific files are named after what they export and are not named "index.js". All platform-specific files are named for the platform the code supports as outlined in the README.
    • I verified the JSDocs style guidelines (in STYLE.md) were followed
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I verified that this PR follows the guidelines as stated in the Review Guidelines
  • I verified other components that can be impacted by these changes have been tested, and I retested again (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar have been tested & I retested again)
  • I verified all code is DRY (the PR doesn't include any logic written more than once, with the exception of tests)
  • I verified any variables that can be defined as constants (ie. in CONST.js or at the top of the file that uses the constant) are defined as such
  • If a new component is created I verified that:
    • A similar component doesn't exist in the codebase
    • All props are defined accurately and each prop has a /** comment above it */
    • The file is named correctly
    • The component has a clear name that is non-ambiguous and the purpose of the component can be inferred from the name alone
    • The only data being stored in the state is data necessary for rendering and nothing else
    • For Class Components, any internal methods passed to components event handlers are bound to this properly so there are no scoping issues (i.e. for onClick={this.submit} the method this.submit should be bound to this in the constructor)
    • Any internal methods bound to this are necessary to be bound (i.e. avoid this.submit = this.submit.bind(this); if this.submit is never passed to a component event handler like onClick)
    • All JSX used for rendering exists in the render method
    • The component has the minimum amount of code necessary for its purpose, and it is broken down into smaller components in order to separate concerns and functions
  • If any new file was added I verified that:
    • The file has a description of what it does and/or why is needed at the top of the file if the code is not self explanatory
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG)
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • If a new page is added, I verified it's using the ScrollView component to make it scrollable when more elements are added to the page.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.
  • I have checked off every checkbox in the PR reviewer checklist, including those that don't apply to this PR.


Screenshots/Videos

Android: HybridApp
m1.webm
p2.webm
p3.webm
Android: mWeb Chrome
iOS: HybridApp
Simulator.Screen.Recording.-.iPhone.15.Pro.-.2026-03-16.at.23.27.18.mov
Simulator.Screen.Recording.-.iPhone.15.Pro.-.2026-03-16.at.23.32.43.mov
Simulator.Screen.Recording.-.iPhone.15.Pro.-.2026-03-16.at.23.35.37.mov
iOS: mWeb Safari
Simulator.Screen.Recording.-.iPhone.15.Pro.-.2026-03-16.at.23.38.15.mov
Simulator.Screen.Recording.-.iPhone.15.Pro.-.2026-03-16.at.23.41.19.mov
MacOS: Chrome / Safari
Screen.Recording.2026-03-16.at.23.43.54.mov

@Pujan92
Copy link
Copy Markdown
Contributor

Pujan92 commented Apr 13, 2026

@collectioneur Plz fix the conflicts

@collectioneur
Copy link
Copy Markdown
Contributor Author

@Pujan92 Conflicts resolved ✅

Comment thread src/libs/Navigation/Navigation.ts Outdated
import omit from 'lodash/omit';
import {nanoid} from 'nanoid/non-secure';
import {DeviceEventEmitter, Dimensions, InteractionManager} from 'react-native';
import {Dimensions} from 'react-native';
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.

Seems DeviceEventEmitter is used in the latest code and we removed it from the import causing the issue

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.

Yeah, fixing it

Copy link
Copy Markdown
Contributor

@Pujan92 Pujan92 left a comment

Choose a reason for hiding this comment

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

LGTM!

#85687 - Fixed

f1.mp4

#85696 - Fixed

f2.mp4

@melvin-bot melvin-bot Bot requested a review from srikarparsi April 13, 2026 13:03
Copy link
Copy Markdown
Contributor

@roryabraham roryabraham left a comment

Choose a reason for hiding this comment

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

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();
         });
     });

Comment thread src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx
Comment thread src/libs/Navigation/TransitionTracker.ts
Comment thread src/libs/Navigation/TransitionTracker.ts Outdated
Comment thread src/libs/Navigation/TransitionTracker.ts Outdated
@roryabraham roryabraham merged commit d28650c into Expensify:main Apr 15, 2026
40 checks passed
@OSBotify
Copy link
Copy Markdown
Contributor

✋ This PR was not deployed to staging yet because QA is ongoing. It will be automatically deployed to staging after the next production release.

@github-actions
Copy link
Copy Markdown
Contributor

🚧 @roryabraham has triggered a test Expensify/App build. You can view the workflow run here.

@OSBotify
Copy link
Copy Markdown
Contributor

🚀 Deployed to staging by https://github.com/roryabraham in version: 9.3.60-0 🚀

platform result
🕸 web 🕸 success ✅
🤖 android 🤖 success ✅
🍎 iOS 🍎 success ✅

Bundle Size Analysis (Sentry):

@MelvinBot
Copy link
Copy Markdown
Contributor

No help site changes are required for this PR.

This is an internal code infrastructure change (introducing TransitionTracker and migrating from InteractionManager). It doesn't add, remove, or rename any user-facing features, settings, buttons, or flows — the visible behavior remains identical, only the underlying transition handling implementation changed.

@OSBotify
Copy link
Copy Markdown
Contributor

🚀 Deployed to production by https://github.com/mountiny in version: 9.3.60-22 🚀

platform result
🕸 web 🕸 success ✅
🤖 android 🤖 success ✅
🍎 iOS 🍎 success ✅

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants