Gate deep-link navigation on TabNavigator mount via lifecycle signal - #92482
Conversation
TAB_NAVIGATOR is declared on the root navigator before its lazily-loaded child router mounts. A deep link or notification that dispatches a nested NAVIGATE during that window is unhandled and silently dropped. Replace the prior state-shape inference (which could hang on a quiet cold start) with a lifecycle-driven readiness signal that TabNavigator flips on mount and resets on unmount, so it re-arms across a logout/login cycle. Gate the deep-link navigate in openReportFromDeepLink and defer (don't drop) URLs in the linking subscription until the tab router mounts, leaving the shared waitForProtectedRoutes primitive untouched. Co-authored-by: daledah <daledah@users.noreply.github.com>
|
I created this PR from an upstream branch since I don't have push access to your fork. To take ownership of this branch and be able to push updates, run: Then you can close this PR and open a new one from your fork. |
|
I checked the CI — there's no code fix needed. Every automated check on this PR passes; the only red check is the PR Author Checklist, which is the human-only checklist (it fails simply because the boxes aren't ticked yet). I verified the functional checks both in CI and locally on this branch (
What's left (human action, not a code fix): before marking the PR ready for review, the co-author needs to fill out the |
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.
|
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2323a6dc02
ℹ️ 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".
| function setTabNavigatorUnmounted() { | ||
| if (!isMounted) { | ||
| return; | ||
| } | ||
| isMounted = false; | ||
| resetReadyPromise(); |
There was a problem hiding this comment.
Track mounted TabNavigators instead of a single boolean
This reset runs for any TabNavigator unmount, but the root stack can contain more than one TAB_NAVIGATOR at once (e.g. Navigation.ts documents that cross-tab PUSH stacks a new TAB_NAVIGATOR, and the workspace/domain split handler pushes one). If a pushed tab navigator is later popped while an underlying tab navigator is still mounted, this cleanup sets isMounted to false and creates a fresh unresolved promise; future deep links/notifications that call whenTabNavigatorReady() will wait indefinitely because the remaining mounted navigator will not mount again to resolve it. This needs to be ref-counted or tied to the currently mounted instances rather than a single boolean.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
@MelvinBot this makes sense, please update as the comment above
There was a problem hiding this comment.
Done in 26b8471. Replaced the single isMounted boolean with a ref count so the signal tracks every mounted TAB_NAVIGATOR instead of just the latest one:
tabNavigatorReadiness.ts—setTabNavigatorMounted()increments and only resolves the promise on the0 → 1transition;setTabNavigatorUnmounted()decrements and only re-arms the promise on the1 → 0transition (with a guard against going negative on an unbalanced call).isTabNavigatorMounted()/whenTabNavigatorReady()now report ready whilemountedCount > 0.
So popping a pushed TAB_NAVIGATOR while another is still mounted keeps the signal ready, and a deep link / notification that calls whenTabNavigatorReady() resolves immediately instead of waiting forever.
Added unit coverage for both: a stacked mount → pop-one stays ready → pop-last re-arms, and an unbalanced unmount that must not push the count below zero.
Verification
npm test -- tests/unit/tabNavigatorReadinessTest.ts— 6/6 passeslinton both changed files — cleanprettier— cleantypecheck-tsgo— clean
|
@MelvinBot merge main |
…rDeepLinkRaceV2 Co-authored-by: daledah <daledah@users.noreply.github.com>
|
🤖 Merged latest Merge commit: |
|
🤖 I analyzed all the CI checks on this PR — there are no code-related failures to fix. Every code check passed: TypeScript, Jest unit tests (all 8 jobs), ESLint, Prettier, Knip, spell check, React Compiler Compliance, and the Android/iOS builds. The only two failing checks are:
These fail only because their checkboxes aren't ticked yet — they're completed by a human, not by code. The PR body itself notes that the human co-author must fill out the (The Reassure performance tests were still running when I checked — that's an informational benchmark comparison, not a code-fixable check.) ✅ No code fix is needed. Once the human co-author fills out the checklist and ticks the boxes, those checks will pass. |
|
@MelvinBot please add these issues to the and @MelvinBot update your checklist:
|
|
🤖 Updated the PR description as requested:
|
The readiness signal used a single boolean, but multiple TAB_NAVIGATORs can be mounted on the root stack at once (cross-tab PUSH / workspace split). Popping a pushed navigator reset the signal while another stayed mounted, so future deep links waited on a promise that never resolved. Track mounted instances with a ref count so the signal stays ready until the last instance unmounts. Co-authored-by: daledah <daledah@users.noreply.github.com>
|
@MelvinBot mark this PR as ready for review |
|
@sobitneupane Please copy/paste the Reviewer Checklist from here into a new comment on this PR and complete it. If you have the K2 extension, you can simply click: [this button] |
|
🤖 Done — this PR is now marked Ready for review. Heads up: the |
JmillsExpensify
left a comment
There was a problem hiding this comment.
No product review required.
|
PR still in progress |
Reviewer Checklist
Screenshots/VideosAndroid: HybridAppandroid.movAndroid: mWeb ChromeN/A iOS: HybridAppios-resize.mp4iOS: mWeb SafariN/A MacOS: Chrome / Safariweb-resize.mp4Screen.Recording.2026-06-10.at.13.29.34.movweb-resize.mp4web-resize.mp4 |
|
@MelvinBot please merge main |
…rDeepLinkRaceV2 Co-authored-by: daledah <daledah@users.noreply.github.com>
|
@daledah bump, please. |
|
I think we're waiting for @situchan to finish his review. |
|
@MelvinBot pull main and fix conflict |
…rDeepLinkRaceV2 Co-authored-by: daledah <daledah@users.noreply.github.com> # Conflicts: # src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx # src/libs/Navigation/AppNavigator/Navigators/TabNavigator.tsx # src/libs/actions/Link.ts
|
🤖 Merged Conflicts resolved (3 files):
Verification:
Please re-run the full CI suite / re-review to confirm everything is green. |
|
@MelvinBot please check this comment #92482 (review) |
|
🤖 Checked Verdicts🔴 #1 — Permanent hang / subscription leak for Concierge / Track / Submit deep links → does NOT occur. The claim's mechanism is accurate in isolation: But the conclusion misses the state adapter: 🟠 #2 —
🟡 #3 — Stale-URL across a session change → valid, low probability. 🟡 #4 — No timeout / escape hatch → valid. Both consumers chain on an unbounded promise. Given navigation edge-cases are hard to fully enumerate (e.g. a raw Suggested solution (small, defensive — no change to shared primitives)Rather than a behavioral fix (none is strictly required for #1/#2), harden the readiness helper so any unmet assumption degrades gracefully instead of stalling, which covers #3 and #4 at once:
Steps 1–2 are low-risk and directly answer the reviewer's
Verified by reading the state adapter, RootStack declaration, and both consumers directly (high confidence). This is a code-path/state-shape question, so no production logs were needed. |
|
@situchan what do you think about Melvin's comment above? |
Regression scanI reviewed the diff specifically for the failure class that got #91796 reverted — a readiness promise that never resolves and hangs a flow. Good news: the two most dangerous candidates are safe, and the blast radius is far smaller than the reverted PR because nothing here touches the shared ✅ Cleared (not regressions)
|
Screenshots/VideosAndroid: HybridAppandroid.movAndroid: mWeb ChromeiOS: HybridAppios.moviOS: mWeb SafariMacOS: Chrome / Safariweb.mov |
Regression tests9202392023.mov9203092030.mov9203992039.mov9204192041.mov |
92030-prod.movI reproduced #92030 in |
|
Melvin's comments are non-blockers |
|
🚀 Deployed to staging by https://github.com/yuwenmemon in version: 9.4.33-0 🚀
|
Help site review — no changes requiredI reviewed this PR against the help site content in Why: This is an internal engineering fix for a navigation race condition — it gates deep-link/push-notification navigation on The changes are entirely under the hood:
Since the help site documents product features and user-facing behavior, there's nothing here to add or update, so I did not open a docs PR. @daledah, please confirm you agree no help site changes are required. If you think a specific article should be updated, let me know which flow and I'll draft it. |
|
We are seeing a few console errors while executing this PR, but they do not seem to be the ones mentioned in the steps. Can you please double check this is PASS Screen.Recording.2026-07-11.at.11.14.09.AM.mov |
|
Chrome / v9.4.33-1 PR:95942 bandicam.2026-07-13.16-20-07-690.mp4 |
|
🚀 Deployed to production by https://github.com/lakchote in version: 9.4.33-8 🚀
Bundle Size Analysis (Sentry): |
Explanation of Change
This re-does the fix from #91796 (which was reverted for causing regressions) without touching the shared navigation primitive that caused the blast radius.
Background:
TAB_NAVIGATORis declared on the root navigator as soon as the authenticatedRootStackrenders, but it is loaded lazily (getComponent), so there is a window where the screen is declared while its child router has not runuseNavigationBuilderyet. A deep link or push notification that dispatches a nestedNAVIGATEduring that window is "not handled by any navigator" and is silently dropped.Why #91796 was reverted: it gated the shared
waitForProtectedRoutes()primitive inNavigation.tson a state-shape inference (isTabNavigatorReady). On a quiet cold start the tab's non-stale nested state was never pushed up to the root navigator's own state object and no further rootstateevent fired, so the promise never resolved and navigation hung. BecausewaitForProtectedRoutes()is shared by many flows, that hang surfaced as multiple regressions.This PR's approach:
tabNavigatorReadiness.ts— a lifecycle-driven readiness signal.TabNavigatorflips it on mount and resets it on unmount, so it reliably reflects the current mount and re-arms across a logout → login cycle. It can't get stuck waiting on a state event that never arrives.TabNavigator.tsxandTabNavigator.native.tsx.openReportFromDeepLink(Link.ts) onwhenTabNavigatorReady()afterwaitForProtectedRoutes(), instead of gating inside the shared primitive. The user is over the auth wall here, soTAB_NAVIGATORis guaranteed to mount.subscribe.ts), defer (don't drop) the URL until the tab router mounts. On public screensTAB_NAVIGATORisn't declared, so the URL is forwarded immediately.Navigation.tsis intentionally left untouched.Added a unit test for the readiness helper (mount resolves a pending promise, immediate resolve once mounted, re-arm after unmount, idempotent mount, no-op unmount).
Fixed Issues
$ #91777
$ #92023
$ #92039
$ #92030
$ #92041
PROPOSAL: #91777 (comment)
Tests
Same as QA Tests.
Offline tests
Same as QA Tests.
QA Steps
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
Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari