Skip to content

[$175] iOS - Log in - Online magic link does not open the linked report when signed out #105778

Open

Description

@applause-bot

If you haven’t already, check out our contributing guidelines for onboarding. To join our Slack channel, fill out this form.


Version Number: 9.5.28-5
Reproducible in staging?: Yes
Reproducible in production?: Yes
If this was caught during regression testing, add the test name, ID and link from BrowserStack: #102261
Email or phone of affected tester (no customers): jose.sat94+ndndjul@gmail.com
Issue reported by: Applause Internal Team
Bug source: Pull Request QA execution
Device used: iPhone 13/18.6.2
App Component: Other

Action Performed:

  1. Launch Expensify app
  2. Log in with valid credentials
  3. Sign out of New Expensify.
  4. Open a real Expensify email containing an action link, such as an expense report email.
  5. Tap View on the expense report email.

Expected Result:

The app signs in and opens the linked report without displaying the offline message.

Actual Result:

Tapping the action link does not successfully sign in and open the linked report.

Workaround:

Unknown

Platforms:

  • Android: App
  • Android: mWeb Chrome
  • iOS: App
  • iOS: mWeb Safari
  • iOS: mWeb Chrome
  • Windows: Chrome
  • MacOS: Chrome Safari

Screenshots/Videos

Bug7303725_1793717729861.RPReplay_Final1793667871.mp4

View all open jobs on GitHub

Applause Internal Information
Exported by: Julia Ponikarchuk
Bug ID: 7303725

Issue OwnerCurrent Issue Owner: @mkhutornyi
Upwork Automation - Do Not Edit

Activity

  1. added
    BugSomething is broken. Auto assigns a BugZero manager.
    on Nov 3, 2026
  2. applause-bot commented on Nov 3, 2026

    @applause-bot
    Author

    While Applause doesn't embed attachments for this bug, here are the links for quick view:
    https://platform.applause.com/services/links/v1/external/5ae127757e27b487e823afd7b03f1bfab578cd4023748d7d35eda5525a560882

  3. self-assigned this
    on Nov 3, 2026
  4. mkhutornyi commented on Nov 3, 2026

    @mkhutornyi
    Contributor

    This PR failing because of the issue #105778 This issue is reproducible in: iOS app

    Investigating...

  5. mkhutornyi commented on Nov 3, 2026

    @mkhutornyi
    Contributor

    I reverted #102261 locally and the bug is still reproducible. So it's not the offending PR.

  6. MelvinBot commented on Nov 3, 2026

    @MelvinBot
    Contributor

    ⚠️ Preliminary analysis (low confidence) — treat as a starting point, not a conclusion.

    This bug is not caused by Show the offline message instead of a bare spinner on the magic link page. That PR is on staging but not production, and this issue also reproduces on production. The PR only changes what renders while offline. Online, FullPageOfflineBlockingView.tsx:37 returns the same spinner as before. The sign-in effects in ValidateLoginPage are unchanged.

    I think the HybridApp sign-in gate is the likely cause. On HybridApp, AuthScreens only mount when hybridApp.readyToShowAuthScreens is true (AppNavigator.native.tsx:17). That flag is only set after credentials.autoGeneratedLogin, autoGeneratedPassword, account.requiresTwoFactorAuth, needsTwoFactorAuthSetup, and nvp_tryNewDot all arrive in Onyx (HybridApp.ts:98-125). A magic-link sign-in skips the normal email and code steps. If the SigninUserWithLink response leaves out any of those values, the app stays on the public screens, and the exitTo navigation never runs. Separately, if nvp_tryNewDot sends the user to Classic, NewDot closes without opening the report (HybridApp.ts:120-121).

    That gate is shared with Android, so the iOS-only part may be in the native iOS HybridApp layer, outside the App repo. For example, the universal link may not reach NewDot while it is signed out.

    I wasn't able to verify this because log search was unavailable in this run and I couldn't review the video. This bug only happens in the iOS app, so I didn't test it on web.

    To confirm, check the logs for the affected tester around the repro time:

    1. Was SigninUserWithLink sent, and did it succeed? If it wasn't sent, iOS never handed the link to NewDot.
    2. If it succeeded, does [HybridApp] Performing sign-in on OldDot side follow? If not, the gate above is blocking.
    3. Does the video end on the sign-in page, a spinner, Classic, or Safari?
    Smaller issues found along the way
    • isPublicScreenRoute.ts builds an anchored regex such as ^v/\w+/\w+$. A v/...?exitTo=... route fails that match, so openReportFromDeepLink (Link.ts:489-510) also calls waitForUserSignIn().
    • waitForUserSignIn() keeps only one resolver (Session/index.ts:100). With two callers, one promise never resolves. That may drop the exitTo navigation from handleExitToNavigation. It is fragile, but probably not enough to cause this bug alone.

    Next Steps: Reply with @MelvinBot <your feedback> to refine this analysis.


    view run

  7. added
    ExternalAdded to denote the issue can be worked on by a contributor
    on Nov 3, 2026
  8. melvin-bot commented on Nov 3, 2026

    @melvin-bot

    Current assignee @mkhutornyi is eligible for the External assigner, not assigning anyone new.

  9. changed the title [-]iOS - Log in - Online magic link does not open the linked report when signed out[/-] [+][$175] iOS - Log in - Online magic link does not open the linked report when signed out[/+] on Nov 3, 2026
  10. melvin-bot commented on Nov 3, 2026

    @melvin-bot
  11. mkhutornyi commented on Nov 3, 2026

    @mkhutornyi
    Contributor

    Confirmed this is reproducible in iOS production app downloaded from appstore

    bug.repro.mov
  12. camesenin commented on Nov 3, 2026

    @camesenin

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    After signing out on iOS, opening a valid report action link from an Expensify email reportedly fails to complete sign-in and display the linked report. The required behavior is successful authentication followed by navigation to that specific report while online.

    What is the root cause of that problem?

    Unverified. This case does not yet meet the required evidence standard for a submission-ready fix.

    The supplied material contains no implementation, authentication trace, sanitized link structure, or failing automated test. No Expensify checkout exists in the workspace, and attempts to retrieve the relevant source and PR diff failed.

    The official release notes identify PR #102261 as changing the magic-link page’s offline presentation. That establishes an investigation lead, not proof that it caused the authentication or navigation failure. Expensify release notes

    The following remain unresolved:

    Exact file paths, line numbers, and a causal explanation cannot responsibly be asserted without inspecting the affected revision.

    What changes do you think we should make in order to solve the problem?

    No defensible TypeScript diff can be specified from the available evidence. Inventing function names or changing an assumed connectivity guard would violate the audit requirements.

    Before selecting a patch:

    1. Reproduce on version 9.5.28-5 with a fresh, valid link and record whether the app was running or terminated.
    2. Capture the sanitized URL structure, connectivity transitions, validation request/result, session transition, and final navigation destination. Redact authentication secrets.
    3. Compare the affected revision with PR Show the offline message instead of a bare spinner on the magic link page #102261’s parent using equivalent fresh links.
    4. Encode the observed failure in a regression test, then change only the responsible branch.

    A fix must demonstrate both successful authentication and navigation to the linked report. Removing an offline message alone does not establish either outcome.

    What alternative solutions did you explore? (Optional)

    No MelvinBot proposal was supplied, so there is no proposal to dismantle. Its authorship of the referenced PR does not establish a defect.

    Reject these unsupported approaches:

    • Remove the offline UI: could conceal the failure while leaving authentication stalled.
    • Force connectivity to online: could break legitimate offline handling.
    • Add unconditional retries: could duplicate validation attempts; token-consumption behavior must first be established.
    • Navigate immediately: does not prove authentication or report access succeeded.
    • Rewrite authentication or deep-link routing: introduces unjustified regression risk before the failing boundary is identified.

    Duplicate and existing-fix status also remain unverified because the issue URL and current discussion were not supplied or retrieved.

    What automated tests can you add to prevent this from re-occurring?

    Proposed regression contract, not implemented or executed:

    it('authenticates a signed-out user from a valid online report link and opens that report')

    Using the affected revision’s existing Jest navigation/authentication harness:

    1. Initialize signed-out state after the normal sign-out flow.
    2. Deliver a report action-link fixture matching the real URL structure.
    3. Provide online connectivity and controlled successful authentication responses.
    4. Assert validation executes, the session becomes authenticated, and navigation resolves to the exact report ID.
    5. Assert no offline message appears while connectivity remains online.

    Add cases for offline-to-online recovery, expired links, duplicate URL delivery, and authenticated users. The principal regression test must fail before the patch and pass afterward.

    An iOS device reproduction is additionally required to verify universal-link delivery and app lifecycle behavior that mocked Jest tests cannot establish.

    Contributor details
    Your Expensify account email: camesenin@gmail.com
    Upwork Profile Link: https://www.upwork.com/freelancers/~01b60d704ba377be83

  13. melvin-bot commented on Nov 3, 2026

    @melvin-bot

    ✅ Contributor details stored successfully. Thank you for contributing to Expensify!

  14. Enes2553 commented on Nov 3, 2026

    @Enes2553

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    On the iOS app, after logging in and signing out of New Expensify, tapping View on a report action magic link from an email (.../v/<accountID>/<validateCode>?exitTo=r/<reportID>) does not complete navigation to the linked report after signing in.

    What is the root cause of that problem?

    When a signed-out user opens a magic link containing an exitTo query parameter (e.g. v/<accountID>/<validateCode>?exitTo=r/<reportID>), two bugs in the deep-link and session sign-in flow collide:

    1. isPublicScreenRoute fails on public routes with query parameters (src/libs/isPublicScreenRoute.ts:3-8):
      When the deep link arrives via DeepLinkHandler.tsx, openReportFromDeepLink (src/libs/actions/Link.ts:484-500) extracts route = getRouteFromLink(url) ("v/<accountID>/<validateCode>?exitTo=r/<reportID>") and checks:

      if (!isAuthenticated && isPublicScreenRoute(route)) {
          return;
      }

      However, isPublicScreenRoute builds an end-anchored regex without stripping query strings or hashes:

      const routeRegex = new RegExp(`^${screenRoute.replaceAll(/:\w+/g, '\\w+')}$`);
      return routeRegex.test(route);

      Because PUBLIC_SCREENS_ROUTES.VALIDATE_LOGIN is 'v/:accountID/:validateCode', ^v/\w+/\w+$ fails to match 'v/<accountID>/<validateCode>?exitTo=r/<reportID>'. As a result, openReportFromDeepLink does not return early at line 499 and instead proceeds to line 519 (waitForUserSignIn().then(...)).

    2. waitForUserSignIn() stores only a single resolver (src/libs/actions/Session/index.ts:100, 1657-1665):
      At the same time, ValidateLoginPage (src/pages/ValidateLoginPage/index.tsx:37) calls signInWithValidateCodeAndNavigate(Number(accountID), validateCode, preferredLocale, exitTo), which invokes handleExitToNavigation(exitTo) (src/libs/actions/Session/index.ts:1667-1677) and calls waitForUserSignIn().
      Because Session/index.ts:100 stores only a single callback (let authPromiseResolver: ((value: boolean) => void) | null = null), having both openReportFromDeepLink and handleExitToNavigation call waitForUserSignIn() while signed out causes the second caller to overwrite authPromiseResolver:

      • If openReportFromDeepLink overwrites authPromiseResolver (either on initial URL handling or when DeepLinkHandler's effect re-runs as conciergeReportID / introSelected hydrate during sign-in), handleExitToNavigation(exitTo)'s promise never resolves and exitTo (r/<reportID>) is never opened.
      • Instead, openReportFromDeepLink's waiter runs handleDeeplinkNavigation() with deeplinkRoute = "v/<accountID>/<validateCode>?exitTo=r/<reportID>" (a public route rather than the target report).

    What changes do you think we should make in order to solve the problem?

    1. Strip query parameters and hash fragments in src/libs/isPublicScreenRoute.ts:
      Normalize the route path before matching against PUBLIC_SCREENS_ROUTES so v/:accountID/:validateCode?exitTo=... is properly recognized as a public screen route and openReportFromDeepLink exits early as intended:

      import {PUBLIC_SCREENS_ROUTES} from '@src/ROUTES';
      
      export default function isPublicScreenRoute(route: string) {
          const routeWithoutQuery = route.split(/[?#]/).at(0) ?? '';
          return Object.values(PUBLIC_SCREENS_ROUTES).some((screenRoute) => {
              const routeRegex = new RegExp(`^${screenRoute.replaceAll(/:\w+/g, '\\w+')}$`);
              return routeRegex.test(routeWithoutQuery);
          });
      }
    2. Support multiple concurrent callers in waitForUserSignIn() (src/libs/actions/Session/index.ts):
      Replace the single authPromiseResolver variable with a Set of resolvers so concurrent callers (such as handleExitToNavigation and any deep-link listeners) all resolve when deprecatedSession.authToken becomes truthy:

      const authPromiseResolvers = new Set<(value: boolean) => void>();

      In the ONYXKEYS.SESSION callback (src/libs/actions/Session/index.ts:113-116):

      if (deprecatedSession.authToken && authPromiseResolvers.size > 0) {
          for (const resolve of authPromiseResolvers) {
              resolve(true);
          }
          authPromiseResolvers.clear();
      }

      And in waitForUserSignIn() (src/libs/actions/Session/index.ts:1657-1665):

      function waitForUserSignIn(): Promise<boolean> {
          return new Promise<boolean>((resolve) => {
              if (deprecatedSession.authToken) {
                  resolve(true);
              } else {
                  authPromiseResolvers.add(resolve);
              }
          });
      }

    What alternative solutions did you explore? (Optional)

    • Extracting exitTo inside openReportFromDeepLink: rejected because ValidateLoginPage already owns magic-link sign-in and exitTo navigation via signInWithValidateCodeAndNavigate / handleExitToNavigation; openReportFromDeepLink explicitly intends to skip public routes at line 498.

    What automated tests can you add to prevent this from re-occurring?

    1. Add unit tests for isPublicScreenRoute verifying that v/12345/67890?exitTo=r/98765 returns true.
    2. Add a unit test in tests/unit/SessionTest.ts verifying that multiple concurrent calls to waitForUserSignIn() all resolve when ONYXKEYS.SESSION receives an authToken.
  15. ayolyn commented on Nov 3, 2026

    @ayolyn

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    On iOS, when a signed-out user taps an email action link with an exitTo parameter (such as viewing an expense report: v/<accountID>/<validateCode>?exitTo=r/<reportID>), the app signs in with the magic link but fails to open the linked report.

    What is the root cause of that problem?

    There are two interacting defects in isPublicScreenRoute.ts and Session/index.ts:

    1. isPublicScreenRoute fails to match URLs with Query Parameters (src/libs/isPublicScreenRoute.ts:3-8):
      In src/libs/actions/Link.ts (openReportFromDeepLink):

      if (!isAuthenticated && isPublicScreenRoute(route)) {
          return;
      }

      openReportFromDeepLink intends to let ValidateLoginPage handle the magic link and subsequent exitTo navigation when the user is not authenticated.
      However, isPublicScreenRoute constructs an end-anchored regex without stripping query strings:

      const routeRegex = new RegExp(`^${screenRoute.replaceAll(/:\w+/g, '\\w+')}$`);
      return routeRegex.test(route);

      For v/<accountID>/<validateCode>?exitTo=r/<reportID>, the regex ^v/\w+/\w+$ fails because of ?exitTo=.... As a result, openReportFromDeepLink fails to exit early and proceeds to invoke waitForUserSignIn().

    2. Single-Resolver Race in waitForUserSignIn() (src/libs/actions/Session/index.ts:100, 1657-1665):
      Simultaneously, ValidateLoginPage processes the magic link and calls handleExitToNavigation(exitTo) (Session/index.ts:1667), which also calls waitForUserSignIn().
      In Session/index.ts:

      let authPromiseResolver: ((value: boolean) => void) | null = null;

      Because only a single callback variable is maintained, concurrent calls to waitForUserSignIn() from openReportFromDeepLink and handleExitToNavigation overwrite authPromiseResolver.
      When deprecatedSession.authToken becomes truthy, only one promise resolves: handleExitToNavigation's promise is dropped, so the user is never navigated to the linked report (r/<reportID>). Instead, openReportFromDeepLink attempts to navigate to the magic-link route itself.

    What changes do you think we should make in order to solve the problem?

    1. Normalize Route in src/libs/isPublicScreenRoute.ts:
      Strip query parameters and hash fragments before testing against PUBLIC_SCREENS_ROUTES:

      import {PUBLIC_SCREENS_ROUTES} from '@src/ROUTES';
      
      export default function isPublicScreenRoute(route: string) {
          const cleanRoute = route.split(/[?#]/)[0] ?? '';
          return Object.values(PUBLIC_SCREENS_ROUTES).some((screenRoute) => {
              const routeRegex = new RegExp(`^${screenRoute.replaceAll(/:\w+/g, '\\w+')}$`);
              return routeRegex.test(cleanRoute);
          });
      }
    2. Support Concurrent Listeners in src/libs/actions/Session/index.ts:
      Replace the singleton authPromiseResolver with a Set of callbacks:

      const authPromiseResolvers = new Set<(value: boolean) => void>();
      
      // In ONYXKEYS.SESSION listener:
      if (deprecatedSession.authToken && authPromiseResolvers.size > 0) {
          authPromiseResolvers.forEach((resolve) => resolve(true));
          authPromiseResolvers.clear();
      }
      
      // In waitForUserSignIn:
      function waitForUserSignIn(): Promise<boolean> {
          return new Promise<boolean>((resolve) => {
              if (deprecatedSession.authToken) {
                  resolve(true);
                  return;
              }
              authPromiseResolvers.add(resolve);
          });
      }

    What alternative solutions did you explore? (Optional)

    • Parsing exitTo inside openReportFromDeepLink: Handling exitTo in openReportFromDeepLink would duplicate the authentication handoff logic that ValidateLoginPage already manages via signInWithValidateCodeAndNavigate. Allowing isPublicScreenRoute to recognize routes with query parameters cleanly delegates magic-link authentication to ValidateLoginPage as originally architected.

    Automated Tests

    1. Unit tests for isPublicScreenRoute:
      • Verify v/12345/code?exitTo=r/999 returns true.
      • Verify routes with query parameters and hash fragments (v/123/code#test) return true.
    2. Unit tests in tests/unit/SessionTest.ts:
      • Verify that multiple concurrent calls to waitForUserSignIn() while signed out all resolve successfully when authToken is populated in Onyx.

    Manual Tests

    1. On iOS, sign in to New Expensify and then explicitly sign out.
    2. Open an Expensify email containing a report action link (e.g. "View report").
    3. Tap the link to open the Expensify app.
    4. Verify that the app signs in and immediately opens the linked expense report.

    Contributor details

    Your Expensify account email: juliusayolyn@gmail.com
    Upwork Profile Link: https://www.upwork.com/freelancers/~0105126232377c4c5c

  16. melvin-bot commented on Nov 3, 2026

    @melvin-bot

    ✅ Contributor details stored successfully. Thank you for contributing to Expensify!

  17. jaharoni commented on Nov 4, 2026

    @jaharoni

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    On iOS, when the user is signed out and taps a report link from an email notification (or a /v/<accountID>/<validateCode>?exitTo=r/<reportID> magic link), the app opens the sign-in screen or fails to land on the linked expense report.

    What is the root cause of that problem?

    Inspecting both attached screen recordings alongside the deep-link and HybridApp sign-in code reveals two distinct layers to this issue:

    1. What happens in both attached videos (direct /r/<reportID> links vs /v/<accountID>/<validateCode> magic links):

      • In the Applause recording (0:17 to 0:26), the tester copies the "open it in Expensify" link from the "Please approve ARS 3,756.00 expense report" email and pastes it into Apple Notes. That email link is https://new.expensify.com/r/7454297444119905 (a direct /r/<reportID> deep link with no /v/<accountID>/<validateCode> credentials). Because the user is signed out and the expense report is not a public room, openReportFromDeepLink (src/libs/actions/Link.ts:453-478) calls openReport, unblocks the splash screen onto SignInPage, and waits at waitForUserSignIn() (line 519) for the user to sign in before opening the report.
      • Similarly, in @mkhutornyi's Apple Notes recording (85fc0e8f...), the top URL (https://new.expensify.com/v/23472625/SNWFEYJTO?exitTo=/r/84514393167816) contains a line break before SNWFEYJTO, so iOS Notes only linkified the second URL (https://new.expensify.com//r/84514393167816/8385869340041982305), which was tapped at 0:01 and has no validateCode.
    2. Why a true /v/<accountID>/<validateCode>?exitTo=/r/<reportID> magic link also fails to open the report when signed out on iOS:

      • Unstripped query string in isPublicScreenRoute (src/libs/isPublicScreenRoute.ts:3-8): isPublicScreenRoute(route) tests new RegExp('^v/\\w+/\\w+$') against the full route including ?exitTo=/r/<reportID>. Because ?exitTo=... fails the $ anchor, isPublicScreenRoute returns false, so openReportFromDeepLink (src/libs/actions/Link.ts:498) does not return early.
      • Singleton authPromiseResolver overwrite + stale onboardingSubscription in Link.ts (src/libs/actions/Session/index.ts:100, 1657-1677 and src/libs/actions/Link.ts:519-649): Because openReportFromDeepLink does not return early at line 498, both openReportFromDeepLink (Link.ts:519) and ValidateLoginPage -> handleExitToNavigation (Session/index.ts:1668) call waitForUserSignIn(). Session/index.ts:100 stores only a single authPromiseResolver, so the second call overwrites the first resolver. Even if openReportFromDeepLink wins the resolver, its onboardingSubscription (Link.ts:570-648) subsequently executes Navigation.navigate(deeplinkRoute) where deeplinkRoute is the /v/<accountID>/<validateCode>?exitTo=... route itself rather than the exitTo report route.
      • HybridApp readyToShowAuthScreens timing (src/libs/Navigation/AppNavigator/AppNavigator.native.tsx:16-19 and src/libs/HybridApp.ts:118-125): On iOS HybridApp, signing out calls resetSignInFlow(), which sets hybridApp.useNewDotSignInPage = true and hybridApp.readyToShowAuthScreens = false. When SigninUserWithLink succeeds, session.authToken updates in Onyx before HybridApp.ts:119-124 (setUseNewDotSignInPage(false).then(() => setReadyToShowAuthScreens(true))) completes its chained Onyx.merge calls, delaying <AuthScreens /> mount if tryNewDot or account fields arrive in a later Onyx update.

    What changes do you think we should make in order to solve the problem?

    1. In src/libs/isPublicScreenRoute.ts, strip query parameters (?) and hash fragments (#) (and normalize leading/trailing slashes) before matching against PUBLIC_SCREENS_ROUTES so v/<accountID>/<validateCode>?exitTo=... returns true and openReportFromDeepLink (src/libs/actions/Link.ts:498) exits early, leaving ValidateLoginPage as the sole handler of magic-link authentication and exitTo navigation.
    2. In src/libs/actions/Session/index.ts, replace the singleton let authPromiseResolver with a Set<(value: boolean) => void>() (matching the note in Session/index.ts:942) so concurrent callers of waitForUserSignIn() never drop each other's resolution.
    3. In src/libs/HybridApp.ts (signInToOldDotAndChooseExperience), merge {useNewDotSignInPage: false, readyToShowAuthScreens: true} in a single atomic Onyx.merge(ONYXKEYS.HYBRID_APP, ...) call when staying in NewDot instead of chaining setUseNewDotSignInPage(false).then(() => setReadyToShowAuthScreens(true)).

    What alternative solutions did you explore? (Optional)

    • Having openReportFromDeepLink parse exitTo directly was considered, but ValidateLoginPage already owns signInWithValidateCodeAndNavigate(accountID, validateCode, preferredLocale, exitTo). Making isPublicScreenRoute query-aware prevents openReportFromDeepLink from competing with ValidateLoginPage.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

BugSomething is broken. Auto assigns a BugZero manager.DailyKSv2ExternalAdded to denote the issue can be worked on by a contributor

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions