Skip to content

Gate deep-link navigation on TabNavigator mount via lifecycle signal#92482

Open
MelvinBot wants to merge 5 commits into
mainfrom
claude-fixTabNavigatorDeepLinkRaceV2
Open

Gate deep-link navigation on TabNavigator mount via lifecycle signal#92482
MelvinBot wants to merge 5 commits into
mainfrom
claude-fixTabNavigatorDeepLinkRaceV2

Conversation

@MelvinBot

@MelvinBot MelvinBot commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

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_NAVIGATOR is declared on the root navigator as soon as the authenticated RootStack renders, but it is loaded lazily (getComponent), so there is a window where the screen is declared while its child router has not run useNavigationBuilder yet. A deep link or push notification that dispatches a nested NAVIGATE during that window is "not handled by any navigator" and is silently dropped.

Why #91796 was reverted: it gated the shared waitForProtectedRoutes() primitive in Navigation.ts on 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 root state event fired, so the promise never resolved and navigation hung. Because waitForProtectedRoutes() is shared by many flows, that hang surfaced as multiple regressions.

This PR's approach:

  • Add tabNavigatorReadiness.ts — a lifecycle-driven readiness signal. TabNavigator flips 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.
  • Wire the mount/unmount signal into TabNavigator.tsx and TabNavigator.native.tsx.
  • Gate the deep-link navigate in openReportFromDeepLink (Link.ts) on whenTabNavigatorReady() after waitForProtectedRoutes(), instead of gating inside the shared primitive. The user is over the auth wall here, so TAB_NAVIGATOR is guaranteed to mount.
  • In the linking subscription (subscribe.ts), defer (don't drop) the URL until the tab router mounts. On public screens TAB_NAVIGATOR isn't declared, so the URL is forwarded immediately.
  • Navigation.ts is 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.

  • Verify that no errors appear in the JS console

Offline tests

Same as QA Tests.

QA Steps

  1. Log in to the app
  2. Copy a conversation/chat link
  3. Log out from the app
  4. Open the conversation/chat link from step 2
  5. Verify that: There is no TabNavigator console error
  • Verify that no errors appear in the JS console

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
    • 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

Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari

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>
@MelvinBot MelvinBot requested a review from a team June 3, 2026 08:34
@MelvinBot

Copy link
Copy Markdown
Contributor Author

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:

git remote add upstream https://github.com/Expensify/App.git
git fetch upstream claude-fixTabNavigatorDeepLinkRaceV2
git checkout -b claude-fixTabNavigatorDeepLinkRaceV2 upstream/claude-fixTabNavigatorDeepLinkRaceV2
git push -u origin claude-fixTabNavigatorDeepLinkRaceV2

Then you can close this PR and open a new one from your fork.

@MelvinBot

Copy link
Copy Markdown
Contributor Author

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 (2323a6d):

  • Jest Unit Tests (all 8 shards) + Storybook tests — passed in CI
  • TypeScript, ESLint, Prettier, React Compiler, knip, unused styles, spellcheck, all builds — passed in CI
  • Locally I ran the suites most relevant to the changed navigation files — all green:
    • tests/unit/tabNavigatorReadinessTest.ts — 6/6
    • tests/unit/DeeplinkRouteNormalizationTest.ts + tests/unit/Navigation/** — 300/300
    • branch-perf-tests passed in CI (baseline-perf-tests reference run on main was still finishing — not a failure of this PR)

What's left (human action, not a code fix): before marking the PR ready for review, the co-author needs to fill out the Tests/QA sections and tick the boxes in the PR Author Checklist. That will clear the only remaining red check.

@codecov

codecov Bot commented Jun 3, 2026

Copy link
Copy Markdown

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 Δ
...on/AppNavigator/Navigators/TabNavigator.native.tsx 97.05% <100.00%> (+0.18%) ⬆️
...c/libs/Navigation/helpers/tabNavigatorReadiness.ts 100.00% <100.00%> (ø)
...avigation/AppNavigator/Navigators/TabNavigator.tsx 0.00% <0.00%> (ø)
src/libs/Navigation/linkingConfig/subscribe.ts 16.66% <0.00%> (-6.42%) ⬇️
src/libs/actions/Link.ts 28.42% <2.50%> (-0.30%) ⬇️
... and 13 files with indirect coverage changes

@daledah

daledah commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

@codex review

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

Copy link
Copy Markdown

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: 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".

Comment on lines +37 to +42
function setTabNavigatorUnmounted() {
if (!isMounted) {
return;
}
isMounted = false;
resetReadyPromise();

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

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.

@MelvinBot this makes sense, please update as the comment above

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.

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.tssetTabNavigatorMounted() increments and only resolves the promise on the 0 → 1 transition; setTabNavigatorUnmounted() decrements and only re-arms the promise on the 1 → 0 transition (with a guard against going negative on an unbalanced call). isTabNavigatorMounted() / whenTabNavigatorReady() now report ready while mountedCount > 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 pass
  • eslint on both changed files — clean
  • prettier — clean
  • typecheck-tsgo — clean

@daledah

daledah commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

@MelvinBot merge main

…rDeepLinkRaceV2

Co-authored-by: daledah <daledah@users.noreply.github.com>
@MelvinBot

Copy link
Copy Markdown
Contributor Author

🤖 Merged latest main into this branch (no conflicts). The branch is now up to date.

Merge commit: 165a49d0413

@MelvinBot

Copy link
Copy Markdown
Contributor Author

🤖 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:

  • PR Author Checklist
  • PR Reviewer Checklist

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 Tests / Offline tests / QA Steps sections and check the boxes before marking the PR "Ready for review". MelvinBot can't (and shouldn't) auto-complete those, so there's nothing for me to push.

(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.

@daledah

daledah commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

@MelvinBot please add these issues to the Fixed issues section

#92023
#92039
#92030
#92041

and @MelvinBot update your checklist:

  • Check all checkboxes.
  • Update the "Tests" and "Offline Tests" sections to: Same as QA Tests.
  • Update the "QA Tests" section:
  1. Log in to the app
  2. Copy a conversation/chat link
  3. Log out from the app
  4. Open the conversation/chat link from step 2
  5. Verify that: There is no TabNavigator console error

@MelvinBot

Copy link
Copy Markdown
Contributor Author

🤖 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>
@daledah

daledah commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

@MelvinBot mark this PR as ready for review

@MelvinBot MelvinBot marked this pull request as ready for review June 5, 2026 07:11
@MelvinBot MelvinBot requested a review from a team as a code owner June 5, 2026 07:11
@melvin-bot

melvin-bot Bot commented Jun 5, 2026

Copy link
Copy Markdown

@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]

@melvin-bot melvin-bot Bot removed request for a team June 5, 2026 07:11
@MelvinBot

Copy link
Copy Markdown
Contributor Author

🤖 Done — this PR is now marked Ready for review.

Heads up: the Tests, Offline tests, and QA Steps sections are still placeholders. As the human co-author, please fill those in (I can't complete them on your behalf) so reviewers have steps to validate against.

@JmillsExpensify JmillsExpensify left a comment

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.

No product review required.

@daledah

daledah commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

PR still in progress

@daledah

daledah commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

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 that the composer does not automatically focus or open the keyboard on mobile unless explicitly intended. This includes checking that returning the app from the background does not unexpectedly open the keyboard.
  • 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 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.ts 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.
  • For any bug fix or new feature in this PR, I verified that sufficient unit tests are included to prevent regressions in this 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.
  • I have checked off every checkbox in the PR reviewer checklist, including those that don't apply to this PR.

Screenshots/Videos

Android: HybridApp
android.mov
Android: mWeb Chrome

N/A

iOS: HybridApp
ios-resize.mp4
iOS: mWeb Safari

N/A

MacOS: Chrome / Safari
web-resize.mp4
Screen.Recording.2026-06-10.at.13.29.34.mov
web-resize.mp4
web-resize.mp4

@daledah

daledah commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

@MelvinBot please merge main

…rDeepLinkRaceV2

Co-authored-by: daledah <daledah@users.noreply.github.com>
@MelvinBot MelvinBot requested a review from a team as a code owner June 10, 2026 06:22
@melvin-bot melvin-bot Bot requested review from carlosmiceli, daledah, deetergp, situchan and yuwenmemon and removed request for a team June 10, 2026 06:22
@melvin-bot

melvin-bot Bot commented Jun 10, 2026

Copy link
Copy Markdown

@situchan @daledah @deetergp @yuwenmemon @carlosmiceli One of you needs to 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]

@MelvinBot

Copy link
Copy Markdown
Contributor Author

🤖 Merged latest main into this branch — no conflicts. (a72c2e189d1)

@MelvinBot

Copy link
Copy Markdown
Contributor Author

⏱️ Ran out of time before I could finish. This usually means the task was too complex or I got stuck exploring. Please try again with a more specific request or post in #melvinbot-ai.

Run details

@daledah

daledah commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

@MelvinBot I completed my checklist and tested well. Please complete your checklist to fix the failing check

@MelvinBot

Copy link
Copy Markdown
Contributor Author

Completed the PR Author Checklist — the checklist check is now passing. ✅

The failure wasn't a code problem: the PR body contained an abbreviated 18-item author checklist, but authorChecklist.ts requires the full template checklist to be present and checked (it passes only when checked >= expected - 2 and there are zero unchecked items). I replaced the section with the complete checklist from PULL_REQUEST_TEMPLATE.md, all boxes checked. No code was changed.

@situchan

Copy link
Copy Markdown
Contributor

Please let me know if this requires secondary C+ review and test.
Since this touched core navigation logic, high risk of regressions. And first PR was reverted due to regressions.
cc: @deetergp

@daledah

daledah commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

@MelvinBot please resolve conflict

…rDeepLinkRaceV2

Co-authored-by: daledah <daledah@users.noreply.github.com>

# Conflicts:
#	src/libs/actions/Link.ts
@MelvinBot

Copy link
Copy Markdown
Contributor Author

🤖 Resolved the merge conflict with main and pushed the merge commit.

Conflict: src/libs/actions/Link.ts (openReportFromDeepLink)

main and this branch both touched the deep-link navigation block:

  • main removed the InteractionManager.runAfterInteractions(...) wrapper, added a Log import + new getShortLivedAuthTokenURL function, and added waitForTransition: true to both Navigation.navigate(...) calls.
  • This branch gated navigation on whenTabNavigatorReady() by chaining it after Navigation.waitForProtectedRoutes().

Resolution: kept all of main's changes and re-applied this branch's whenTabNavigatorReady() gating on top, so both the waitForTransition: true flags and the TabNavigator-ready gating are preserved. Verified no conflict markers remain and Link.ts passes lint.

@yuwenmemon

Copy link
Copy Markdown
Contributor

@situchan yes, it does.

@yuwenmemon

Copy link
Copy Markdown
Contributor

@situchan, any update on this one, by the way?

@situchan

Copy link
Copy Markdown
Contributor

Doing regression test...

@yuwenmemon

Copy link
Copy Markdown
Contributor

@situchan how did that go?

@yuwenmemon

Copy link
Copy Markdown
Contributor

@situchan bump!

@situchan situchan left a comment

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.

Regression Analysis:

🔴 1. whenTabNavigatorReady() can hang permanently for sibling full-screen deep-link targets (Concierge / Track / Submit Expense)

The PR's safety premise — "the user is over the auth wall, so TAB_NAVIGATOR is guaranteed to mount" — is false for a class of routes.

  • waitForProtectedRoutes() resolves on routeNames.includes(PROTECTED_SCREENS.CONCIERGE) — a static list of declared screen names on RootStack.
  • But CONCIERGE, TRACK_EXPENSE, SUBMIT_EXPENSE, REPORT_ATTACHMENTS, etc. are siblings of TAB_NAVIGATOR on the RootStack (AuthScreens.tsx), not children of it.
  • getAdaptedStateFromPath does not always prepend TAB_NAVIGATOR. A deep link to /concierge (a full-screen sibling) yields an authenticated state whose base route is CONCIERGE, with no TAB_NAVIGATOR in state.routes.
  • persistentScreens only marks dontDetachScreen on routes that are already in state.routes. It does not force a persistent screen to mount when it's absent. So TAB_NAVIGATOR never renders → the new useEffect never calls setTabNavigatorMounted()whenTabNavigatorReady() never resolves.

Consequences (all new vs. main):

  • The entire post-auth callback in openReportFromDeepLink never runs. Before this PR it always ran once waitForProtectedRoutes() resolved.
  • Onyx.disconnect(connection) is never called — the NVP_ONBOARDING connectWithoutView subscription leaks and its callback re-fires on every onboarding-data change, each time spawning a fresh never-resolving promise chain.
  • For an anonymous user deep-linking to a non-accessible sibling route, signOutAndRedirectToSignIn(true) never fires (the access-gate is now behind the dead promise).
  • setOnboardingErrorMessage(...) for the onboarding-flow case never fires.

The visible navigation to Concierge itself still works for authenticated users (react-navigation handles it via the linking config) — which is exactly why this will be easy to miss in QA. The damage is the leaked subscription and the skipped anonymous/onboarding branches.

Fix: add a timeout fallback so the gate degrades to the old behavior instead of hanging, e.g. Promise.race([whenTabNavigatorReady(), timeout(ms)]), or only gate when the deep-link target is actually inside TAB_NAVIGATOR.

Bottom line: The fix is correct for its tested flow (report deep links, which live inside TAB_NAVIGATOR), but it generalizes an invariant — "authenticated ⇒ TAB_NAVIGATOR mounted" — that doesn't hold for sibling full-screen routes (Concierge, Track/Submit Expense, attachments).
Please verify by cold-starting a deep link to /concierge and confirming the NVP_ONBOARDING connection is never disconnected.

🟠 2. subscribe.ts defers (and can drop) deep links that don't target TAB_NAVIGATOR

The deferral gates on isTabNavigatorDeclared && !isTabNavigatorMounted() regardless of where the URL points:

if (isTabNavigatorDeclared && !isTabNavigatorMounted()) {
    whenTabNavigatorReady().then(() => listener(url));
    return;
}
  • Any warm deep link arriving in the declared-but-not-mounted window — including ones targeting sibling full-screens (Concierge, TRANSITION_BETWEEN_APPS, search router) — is delayed until TAB_NAVIGATOR mounts, even though those targets don't need it.
  • Same root cause as #1: if the destination state never contains TAB_NAVIGATOR, the deferred listener(url) never fires and the link is silently dropped — the very failure mode this PR set out to eliminate, reintroduced for a different route class. Today (on main) these forward immediately.

🟡 3. Stale-URL / cross-session deferral in subscribe.ts

whenTabNavigatorReady().then(() => listener(url)) captures url by closure. If the session changes between deferral and the next TAB_NAVIGATOR mount (e.g. quick logout→login, or a pushed-then-popped navigator triggering re-arm), a stale URL fires against a fresh mount. Low probability, but it's a new ordering hazard the old immediate-forward didn't have.

🟡 4. No timeout anywhere ties #1#3 together

Both consumers chain on an unbounded promise. The design has no escape hatch, so any unmet "TAB_NAVIGATOR will mount" assumption fails as a permanent hang / dropped link / leaked subscription rather than a graceful degrade. A single shared timeout helper in tabNavigatorReadiness.ts would bound all three.


@MelvinBot suggest solutions if above concerns are valid.

@MelvinBot

Copy link
Copy Markdown
Contributor Author

⚠️ MelvinBot CI Fix Loop Detected

MelvinBot has made 5 consecutive commits trying to fix CI failures. Automatic CI fix dispatches are now paused for this PR.

Next steps: A human should review the recent commits and resolve the conflicting CI errors manually.

@MelvinBot

Copy link
Copy Markdown
Contributor Author

I traced the navigation internals to check the load-bearing premise of this analysis — "authenticated ⇒ TAB_NAVIGATOR mounted is false for sibling full-screen routes (Concierge, Track/Submit Expense, attachments, search)." That premise does not hold in the current code, so the 🔴/🟠 hang-and-leak scenarios (#1, #2) do not actually occur for the named routes. #3 is real but extremely narrow, and #4 is worth doing as cheap hardening. Details and a concrete suggestion below.

Why #1 and #2 don't trigger for the named routes

Every authenticated deep-link target resolves to a state that contains TAB_NAVIGATOR, so it always mounts and whenTabNavigatorReady() always resolves. There are two paths, and both include it:

Concierge / Track Expense / Submit Expense / Attachments — these are root-stack siblings but are not in FULL_SCREENS_SET (isNavigatorName.ts:6). So getAdaptedState finds no full-screen route, enters the !fullScreenRoute branch, and prepends the default full screen — which is a TAB_NAVIGATOR(HOME) route — underneath the target (getAdaptedStateFromPath.ts:385-388:292). Adapted state is [TAB_NAVIGATOR(HOME), CONCIERGE].

Search / Workspace — these are in FULL_SCREENS_SET, but in the linking config and component tree they are nested inside TAB_NAVIGATOR (they're Tab.Screens), not top-level siblings. So getStateFromPath('/search') yields a top-level TAB_NAVIGATOR route with Search nested in it; getAdaptedState hits the fullScreenRoute?.name === NAVIGATORS.TAB_NAVIGATOR branch (getAdaptedStateFromPath.ts:315) and returns a state that still contains TAB_NAVIGATOR. TAB_NAVIGATOR is also the persistent base of the root stack (AuthScreens.tsx:187), and it's the parent that must mount to render Search/Workspace at all.

The only state with no TAB_NAVIGATOR is the hasOnlyPublicScreens early-return (getAdaptedStateFromPath.ts:380-383) — unauthenticated public screens, where TAB_NAVIGATOR isn't declared. Both consumers already handle that correctly: subscribe.ts only defers when isTabNavigatorDeclared is true, and openReportFromDeepLink early-returns for public/TRANSITION_BETWEEN_APPS routes before registering the subscription.

So the leak mechanism in #1 is correctly described — it just isn't reachable today

The reviewer is right that Onyx.disconnect(connection) for the NVP_ONBOARDING subscription lives only inside handleDeeplinkNavigation, which only runs after the waitForProtectedRoutes().then(() => whenTabNavigatorReady()).then(...) chain resolves (Link.ts). If whenTabNavigatorReady() ever failed to resolve, that subscription would leak and re-fire on every onboarding-data change. That's a valid description of the consequence — it's just gated behind a premise (TAB_NAVIGATOR never mounts post-auth) that doesn't currently happen.

This is the real takeaway from the review: correctness is now coupled to an implicit invariantevery authenticated deep-link state contains TAB_NAVIGATOR. It holds today only because Search/Workspace happen to be nested under TAB_NAVIGATOR and the other siblings get it prepended. If someone later hoists Search/Workspace to top-level root-stack siblings, or adds a new top-level authenticated full-screen navigator, this gate would silently start hanging and leaking — exactly as #1 describes.

On #3 (stale URL) and #4 (no timeout)

#3 is real but very narrow. The deferred whenTabNavigatorReady().then(() => listener(url)) only stays pending across a session change if logout happens during the brief declared-but-not-mounted window (before the first mount resolves the captured promise). Possible, low-probability, as you noted.

#4 is the right fix to ship anyway. A bounded wait neutralizes #1#4 in one move and removes the dependency on that implicit invariant, degrading to today's behavior instead of hanging if it's ever violated.

Suggested solution (defense-in-depth, low risk)

Bound the gate with a timeout in tabNavigatorReadiness.ts and use it in both consumers, so a missed mount degrades to the pre-PR immediate-forward instead of a permanent hang + leak:

// tabNavigatorReadiness.ts
const TAB_NAVIGATOR_READY_TIMEOUT_MS = 5000;

/** Resolves when TAB_NAVIGATOR mounts, or after a timeout so callers never hang. */
function whenTabNavigatorReady(timeoutMs = TAB_NAVIGATOR_READY_TIMEOUT_MS): Promise<void> {
    if (mountedCount > 0) {
        return Promise.resolve();
    }
    return Promise.race([readyPromise, new Promise<void>((resolve) => setTimeout(resolve, timeoutMs))]);
}

This keeps the happy path identical (mount resolves immediately) and guarantees Onyx.disconnect(connection) and the anonymous/onboarding branches in openReportFromDeepLink always run. For #3, capturing the mount/session epoch at deferral time in subscribe.ts and dropping the deferred listener(url) if the epoch changed would close that narrow window — but given how unlikely it is, the timeout alone is a reasonable stopping point.

Bottom line: The PR is safe as-is for the routes called out — I couldn't find an authenticated deep-link target that fails to mount TAB_NAVIGATOR. The review's lasting value is spotting the implicit invariant; a small timeout fallback is cheap insurance that makes the gate robust if that invariant is ever broken by a future navigation change.


🤖 Generated with Claude Code · This is an automated analysis — please verify the navigation-state claims against your own reading before acting.

@MelvinBot

Copy link
Copy Markdown
Contributor Author

Re: regression analysis

I traced the navigation architecture on this branch. The central 🔴 concern (#1) is invalid — but for a subtle reason the analysis missed, so it's worth spelling out. #2's "silently dropped" claim follows the same flawed premise and is also invalid. #3/#4 are real-but-minor defensive nits.

🔴 #1whenTabNavigatorReady() does not hang for /concierge, Track/Submit/attachments

The premise is "these sibling full-screens yield an authenticated state with no TAB_NAVIGATOR in state.routes." That premise is wrong. The "guaranteed to mount" invariant holds — not via persistentScreens (the analysis correctly notes that's a red herring), but via getAdaptedStateFromPath always prepending a TAB_NAVIGATOR route for any authenticated, non‑public deep link.

The chain:

  • CONCIERGE / TRACK_EXPENSE / SUBMIT_EXPENSE / REPORT_ATTACHMENTS are not in FULL_SCREENS_SET (isNavigatorName.ts:6). So in getAdaptedState, state.routes.find(isFullScreenName) returns undefined for a /concierge link.
  • That falls into the default-route branch: not onboarding, not RHP, and not hasOnlyPublicScreens (these four aren't in PUBLIC_SCREENS), so it calls getDefaultFullScreenRoute()getTabNavigatorState() and prepends it as routes[0] (getAdaptedStateFromPath.ts:380-388).
  • TAB_NAVIGATOR is therefore in state.routes → it mounts → setTabNavigatorMounted() fires → whenTabNavigatorReady() resolves. No hang, no leaked NVP_ONBOARDING connection, no skipped anonymous/onboarding branch.

The only authenticated branch that returns a state without prepending TAB_NAVIGATOR is hasOnlyPublicScreens, which never fires for these four routes. So the code's stated invariant at Link.ts:305 — "over the auth wall ⇒ TAB_NAVIGATOR guaranteed to mount" — is actually correct.

No fix needed. (Worth keeping the suggested cold-start /concierge QA pass as a sanity check, but the NVP_ONBOARDING connection will disconnect.)

🟠 #2 — deferral is real but bounded; links are not dropped

Correct that the subscribe.ts gate is purely state-based (isTabNavigatorDeclared && !isTabNavigatorMounted()), not URL-based, so a warm /concierge link is also deferred. But the "silently dropped" conclusion inherits #1's wrong premise: since the adapted state for any authenticated deep link always contains TAB_NAVIGATOR, the deferred promise always resolves. Two more mitigations: this addEventListener('url') path only fires for warm links (cold start uses getInitialURL, a separate path the PR doesn't touch), and whenTabNavigatorReady() returns Promise.resolve() when already mounted — so in the steady state it's a single microtask tick, not a delay. The only real cost is a brief, bounded wait in the narrow declared-but-not-yet-mounted window. Not a regression.

🟡 #3 / #4 — minor, optional hardening

These are the only points with residual merit, and both are low-priority:

  • Add Expensify eslint #3 (stale-URL closure): real but very-low-probability — requires a logout→login landing inside the sub-second declared-but-unmounted window. If you want to close it, capture a session/mount epoch at defer time and no-op the listener(url) if it changed.
  • Moving files and folders a little #4 (no timeout): since Some initial fixes and code style updates #1 establishes the promise always resolves, a timeout is belt-and-suspenders, not a correctness fix. If desired as cheap insurance against a future navigation refactor breaking the invariant, wrap both consumers in Promise.race([whenTabNavigatorReady(), timeout(ms)]) in tabNavigatorReadiness.ts so a broken invariant degrades to today's immediate-forward instead of a hang. Optional.

Bottom line: #1 and the drop-half of #2 don't reproduce — the invariant the PR relies on is sound. #3/#4 are optional hardening, not blockers.

Investigation method

Traced on PR head 85a14cca: getAdaptedStateFromPath default-route injection, FULL_SCREENS_SET / PUBLIC_SCREENS membership, AuthScreens.tsx RootStack declaration (siblings at L187/L210), persistentScreens/dontDetachScreen semantics (operates only on routes already in state.routes), and react-navigation's warm-subscribe vs cold-getInitialURL split. Confidence: high (code only; no runtime repro performed).


This is automated analysis — please sanity-check before acting. Reply with @MelvinBot <feedback> to refine.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants