Skip to content

[Due for payment 2026-09-16] Android - Report - App crashes after keeping one duplicate expense. #97158

Closed

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.4.45-0
Reproducible in staging?: Yes
Reproducible in production?: N/A - new feature, doesn't exist in prod
If this was caught during regression testing, add the test name, ID and link from BrowserStack: #92546
Email or phone of affected tester (no customers): ibellicotest+2766@gmail.com
Issue reported by: Applause Internal Team
Bug source: Exploratory - Critical User Impact
Device used: Motorola MotoG60 / Android 12 (Hybrid app)
App Component: Money Requests

Action Performed:

Prerequisite: Account has at least one workspace.

  1. Open the Expensify app.
  2. Open any workspace chat.
  3. Create two similar expenses.
  4. Tap on preview of the second expense created, to navigate directly to that expense.
  5. Tap on "Review Duplicates"
  6. Select the other expense > "Keep Selected" > "Confirm"
  7. Note that app crashes after selection is confirmed.

Expected Result:

User should be able to keep only the selected expense after reviewing duplicates.

Actual Result:

App crashes after keeping on duplicate expense, if report was opened by tapping on preview of one specific expense.

Workaround:

Unknown

Platforms:

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

Screenshots/Videos

Bug7219171_1785184222789.Dup_Crash.mp4

logs1.txt

View all open jobs on GitHub

Applause Internal Information
Exported by: Svetlana Lazutkina
Bug ID: 7219171

Issue OwnerCurrent Issue Owner: @jayeshmangwani

Activity

  1. added
    DeployBlockerCashThis issue or pull request should block deployment
    BugSomething is broken. Auto assigns a BugZero manager.
    on Jul 27, 2026
  2. melvin-bot commented on Jul 27, 2026

    @melvin-bot

    You have been assigned to this deploy blocker because you recently merged this PR: #92546

  3. melvin-bot commented on Jul 27, 2026

    @melvin-bot

    💬 A slack conversation has been started in #expensify-open-source

  4. github-actions commented on Jul 27, 2026

    @github-actions
    Contributor

    👋 Friendly reminder that deploy blockers are time-sensitive ⏱ issues! Check out the open `StagingDeployCash` deploy checklist to see the list of PRs included in this release, then work quickly to do one of the following:

    1. Identify the pull request that introduced this issue and revert it.
    2. Find someone who can quickly fix the issue.
    3. Fix the issue yourself.
  5. MelvinBot commented on Jul 27, 2026

    @MelvinBot
    Contributor

    🔍 Investigation Summary

    Classification: Frontend bug (fix belongs in Expensify/App)
    Causing PR: #92546 - "Multi-expense report preview: open the pressed expense (grey View button)" by @wildan-m (High confidence)
    Related Issues: #91042 (the issue PR #92546 fixed)

    Recommendation: REVERT

    The crash is caused entirely by the new native navigation path introduced in PR #92546; the duplicate-merge confirmation flow it interacts with was not changed in this release. Reverting #92546 cleanly restores the previous (working) preview-tap behavior without touching the duplicate-review code.

    Assigned: @wildan-m (PR author), @MonilBhavsar and @jayeshmangwani (approving reviewers) from PR #92546
    Labels: No changes. DeployBlockerCash is kept (an App PR caused this, so the App deploy is correctly blocked). DeployBlocker (Web) is not present and none was added.

    📋 Detailed Analysis

    Evidence

    The attached logs1.txt ends with a hard native crash that terminates the process:

    E AndroidRuntime: FATAL EXCEPTION: main
    E AndroidRuntime: com.facebook.react.bridge.RetryableMountingLayerException: Unable to find viewState for tag 9542. Surface stopped: false
    E AndroidRuntime:   at com.facebook.react.fabric.mounting.SurfaceMountingManager.getViewState(SourceFile:1094)
    E AndroidRuntime:   at com.facebook.react.fabric.mounting.SurfaceMountingManager.updateState(SourceFile:903)
    E AndroidRuntime:   at com.facebook.react.fabric.mounting.mountitems.IntBufferBatchMountItem.execute(SourceFile:129)
    ...
    I ActivityManager: Process org.me.mobiexpensifyg (pid 28611) has died
    

    This is a Fabric (New Architecture) mounting-layer crash: the renderer tried to apply a updateState mount instruction to a view (tag 9542) that had already been removed from the surface. It is triggered when the navigation state of a still-mounted native stack is mutated out-of-band (a raw reset/dispatch) while React Native is mid-transition — the shadow node's backing view is destroyed underneath an in-flight state update.

    Timeline / scope:

    • Reproducible on staging only; the report explicitly notes this is a new feature that does not exist in production, which is consistent with a staging-only regression from a PR in the current StagingDeployCash (release 9.4.45-0).
    • PR Navigate to the clicked expense in a multi-expense report preview #92546 is listed in the current StagingDeployCash for 9.4.45-0 and merged 2026-07-27, immediately before this blocker was filed.

    Reproduction step 4 — "Tap on preview of the second expense created, to navigate directly to that expense" — is the exact behavior PR #92546 adds. Before #92546, tapping any card in a multi-expense report preview opened the parent report via a single shared handler; #92546 changed each card to open its own specific expense. The crash only reproduces when the report is reached through this new deep-linked preview tap ("if report was opened by tapping on preview of one specific expense"), which is the tell that the new navigation path — not the merge flow itself — is the trigger.

    Verification

    Files changed by PR #92546 (confirmed via gh pr view 92546 --json files):

    • src/components/ReportActionItem/MoneyRequestReportPreview/index.tsx
    • src/libs/Navigation/Navigation.ts
    • src/components/ReportActionItem/MoneyRequestReportPreview/ReportPreviewActionButton.tsx
    • tests/ui/*, cspell.json

    On a narrow (Android) layout, tapping an expense card routes into the new native-only helper:

    • MoneyRequestReportPreview/index.tsx — navigateToExpense sends narrow-layout native taps to Navigation.openExpenseOverParentReport(...):

      if (isSmallScreenWidth) {
      const backTo = Navigation.getActiveRoute();
      if (!iouReportID) {
      Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(childReportID, undefined, undefined, backTo));
      return;
      }
      if (getPlatform() === CONST.PLATFORM.WEB) {
      // On mobile web, push the parent report first so it gets its own browser-history entry (back stops
      // on the report, then the chat), then open the pressed expense in the RHP over it one microtask
      // later so the two navigations don't batch into a single history push. Mirrors native's
      // back -> report -> chat.
      const reportRoute = ROUTES.REPORT_WITH_ID.getRoute(iouReportID, undefined, undefined, backTo);
      setActiveTransactionIDs(transactions.map((transaction) => transaction.transactionID));
      Navigation.navigate(reportRoute);
      setNavigationActionToMicrotaskQueue(() => {
      // The user may have left the report during the microtask defer; don't open the expense over
      // whatever screen is now active.
      if (!Navigation.isActiveRoute(reportRoute)) {
      return;
      }
      Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: childReportID, backTo: reportRoute}));
      });
      return;
      }
      // On native the wide RHP is unavailable and there is a swipe-back gesture, so open the expense with the
      // parent report as a real stack entry underneath as a single forward slide.
      Navigation.openExpenseOverParentReport(iouReportID, childReportID, backTo);
      return;

    • Navigation.ts — openExpenseOverParentReport pushes the expense, then on the next microtask manually splices the parent report beneath it with a raw CommonActions.reset dispatch targeted at the live ReportsSplitNavigator:

      function openExpenseOverParentReport(parentReportID: string, childReportID: string, backTo: string) {
      const reportRoute = ROUTES.REPORT_WITH_ID.getRoute(parentReportID, undefined, undefined, backTo);
      const expenseRoute = ROUTES.REPORT_WITH_ID.getRoute(childReportID, undefined, undefined, reportRoute);
      // Native only -- the caller (navigateToExpense) routes web to an RHP over the report instead, because a
      // frozen report screen under the expense drops the first tap on hard-back, and web has no swipe-back
      // gesture that would need a live report entry underneath.
      navigate(expenseRoute);
      setNavigationActionToMicrotaskQueue(() => {
      const rootState = navigationRef.getRootState();
      const tabNavigator = rootState?.routes.findLast((route) => route.name === NAVIGATORS.TAB_NAVIGATOR);
      const reportsSplitNavigator = tabNavigator?.state?.routes.findLast((route) => route.name === NAVIGATORS.REPORTS_SPLIT_NAVIGATOR);
      const splitState = reportsSplitNavigator?.state;
      const topRoute = splitState?.routes.at(-1);
      if (!splitState?.key || !topRoute) {
      return;
      }
      const topParams = topRoute.params;
      const topReportID = topRoute.name === SCREENS.REPORT && !!topParams && 'reportID' in topParams ? topParams.reportID : undefined;
      const belowRoute = splitState.routes.at(-2);
      const belowParams = belowRoute?.params;
      const belowReportID = belowRoute?.name === SCREENS.REPORT && !!belowParams && 'reportID' in belowParams ? belowParams.reportID : undefined;
      // Only insert once the expense we pushed is actually on top, and not if the parent is already beneath it.
      if (topReportID !== childReportID || belowReportID === parentReportID) {
      return;
      }
      const routes = [...splitState.routes.slice(0, -1), {name: SCREENS.REPORT, params: {reportID: parentReportID, backTo}}, topRoute];
      navigationRef.dispatch({
      // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the inserted parent route is keyless; react-navigation mints its key on reset while every existing route (incl. the expense kept on top) retains its key, so nothing underneath remounts
      ...CommonActions.reset({...splitState, routes, index: routes.length - 1} as Parameters<typeof CommonActions.reset>[0]),
      target: splitState.key,
      });
      });
      }

    The duplicate-merge confirmation this bug surfaces through was not modified in this release (git log shows no functional change to src/pages/TransactionDuplicate/Confirmation.tsx), so it is not the cause. On native it takes this exit (super-wide RHP is a wide-layout-only concept; WideRHPContextProvider is a no-op on native via index.native.tsx, so isSuperWideRHPDisplayed is always false):

    const handleMergeDuplicates = useCallback(() => {
    const transactionThreadReportID = childReportID ?? generateReportID();
    const mergeParams = !childReportID ? {...transactionsMergeParams, transactionThreadReportID} : transactionsMergeParams;
    // Suppress the NotFound guard for the discarded thread the server tears down on merge.
    const keptReportRoute = ROUTES.REPORT_WITH_ID.getRoute(mergeParams.reportID);
    setDeleteTransactionNavigateBackUrl(keptReportRoute);
    mergeDuplicates({...mergeParams, ...taxData, currentUserAccountID, currentUserLogin: currentUserLogin ?? '', allTransactionViolations, allReportActionsList: allReportActions});
    if (isSuperWideRHPDisplayed) {
    Navigation.dismissToSuperWideRHP();
    return;
    }
    // From Search, dismiss back to the results (backTo is always set here, so it can't detect Search).
    if (isSearchTopmostFullScreenRoute()) {
    Navigation.dismissModal();
    return;
    }
    // From the discarded thread: pop the modal entries, then replace the dead thread with the kept report.
    Navigation.dismissModal({
    afterTransition: () => Navigation.navigate(keptReportRoute, {forceReplace: true}),
    });
    }, [childReportID, transactionsMergeParams, taxData, currentUserAccountID, currentUserLogin, isSuperWideRHPDisplayed, allTransactionViolations, allReportActions]);

    Navigation.dismissModal({
        afterTransition: () => Navigation.navigate(keptReportRoute, {forceReplace: true}),
    });

    Root Cause

    PR #92546 constructs the navigation stack for the tapped expense on native by hand: it pushes the transaction-thread (expense) screen, then splices the parent report underneath the top screen using a direct navigationRef.dispatch(CommonActions.reset(...)) against the ReportsSplitNavigator's internal state (Navigation.ts L1300-L1305). This leaves a manually assembled stack [..., chat, report, expense] whose route objects were re-created outside the normal navigation lifecycle.

    When the user then confirms the duplicate merge from that expense's thread, handleMergeDuplicates (Confirmation.tsx L121-L141) tears down the modal and, in the same transition window, replaces the underlying dead thread route via dismissModal({ afterTransition: () => navigate(keptReportRoute, {forceReplace: true}) }). The merge also optimistically deletes/tears down the discarded transaction thread that was spliced into the stack by #92546.

    The combination — a stack whose entries were injected via a raw reset, plus a dismissModal + forceReplace that removes/replaces those same entries mid-transition — leaves Fabric applying a queued updateState to a shadow node whose native view has already been destroyed, producing RetryableMountingLayerException: Unable to find viewState for tag ... and a fatal crash. This path only exists on the native narrow layout (the openExpenseOverParentReport branch), which matches the Android-only report and the "opened by tapping the preview of one specific expense" condition. Before #92546, the preview tap opened the parent report through the standard navigation lifecycle and this crash could not occur.

    Because the defect is the new manual stack manipulation, a revert of #92546 is the safest resolution. If a roll-forward is preferred, the targeted fix would be to stop hand-splicing the ReportsSplitNavigator state in openExpenseOverParentReport (use a supported navigation primitive to place the report beneath the expense) so the stack tears down through the normal lifecycle during the merge dismissal.

  6. MelvinBot commented on Jul 27, 2026

    @MelvinBot
    Contributor

    Issue Analysis

    Root cause: The Android crash is a Fabric mount-layer fatal (RetryableMountingLayerException: Unable to find viewState for tag …) caused by PR #92546. On native narrow layouts, tapping an expense preview now routes through openExpenseOverParentReport, which hand-splices the parent report beneath the expense using a raw CommonActions.reset dispatched at the live ReportsSplitNavigator (Navigation.ts:1300-1305). When the user then keeps a duplicate, handleMergeDuplicates tears down the modal and replaces that manually-injected route mid-transition (dismissModal({ afterTransition: () => navigate(keptReportRoute, {forceReplace: true}) })), leaving Fabric applying a queued updateState to an already-destroyed view → crash. This path exists only on the native narrow layout, matching the Android-only, "opened by tapping the preview of one specific expense" reproduction.

    Recommended fix — REVERT #92546. This is a deploy blocker for 9.4.45-0; the defect is the new manual stack splicing, and reverting cleanly restores the prior (working) preview-tap behavior without touching the duplicate-review flow. If a roll-forward is preferred over a revert, replace the raw CommonActions.reset in openExpenseOverParentReport with a supported navigation primitive so the spliced report tears down through the normal lifecycle during the merge dismissal.

    Proposal

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

    On Android (Hybrid), the app hard-crashes after keeping one of two duplicate expenses, but only when the expense report was reached by tapping the preview of a specific expense (not via the normal report open).

    What is the root cause of that problem?

    PR #92546 (in the current StagingDeployCash for 9.4.45-0, merged just before this blocker was filed) added a native-only navigation path for multi-expense report previews. Tapping a card calls navigateToExpense → Navigation.openExpenseOverParentReport, which pushes the expense screen and then, on the next microtask, splices the parent report underneath the top screen via a raw navigationRef.dispatch(CommonActions.reset(...)) targeted at the live ReportsSplitNavigator (Navigation.ts:1300-1305).

    Confirming a duplicate merge from that thread runs Confirmation.tsx handleMergeDuplicates. On native this always takes the final branch (super-wide RHP is wide-layout-only and is a no-op on native; not from Search), which does:

    Navigation.dismissModal({
        afterTransition: () => Navigation.navigate(keptReportRoute, {forceReplace: true}),
    });

    The merge also optimistically tears down the discarded transaction thread that was spliced into the stack. The combination — entries injected via a raw reset + a dismissModal and forceReplace that remove/replace those same entries while a transition is in flight — leaves Fabric applying a queued updateState mount instruction to a shadow node whose native view has already been destroyed:

    FATAL EXCEPTION: main
    com.facebook.react.bridge.RetryableMountingLayerException: Unable to find viewState for tag 9542. Surface stopped: false
        at com.facebook.react.fabric.mounting.SurfaceMountingManager.getViewState
        at com.facebook.react.fabric.mounting.SurfaceMountingManager.updateState
    

    The openExpenseOverParentReport reset carries a comment asserting the keyed reset means "nothing underneath remounts" — that assumption holds for the plain preview tap, but breaks once the merge dismissal removes/replaces the spliced routes mid-transition. Before #92546, the preview tap opened the report through the standard lifecycle and this crash could not occur. The duplicate-merge screen itself was not changed this release, so it is not the cause — it merely triggers the fragile stack.

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

    Revert PR #92546. It is the sole source of the manual native stack splicing that this crash depends on, and reverting restores known-good behavior without any change to the duplicate-review code.

    What alternative solutions did you explore?

    Roll forward instead of reverting: in openExpenseOverParentReport, stop hand-splicing ReportsSplitNavigator state with CommonActions.reset and instead place the parent report beneath the expense using a supported navigation primitive, so the stack tears down through the normal lifecycle during the merge dismissal. This is riskier for a deploy blocker than a straight revert and should be validated on an Android native narrow-layout build against this exact repro before shipping.


    Next Steps for Contributor+ team:
    To accept: @MelvinBot implement [this](https://github.com/Expensify/App/issues/97158) to create a draft PR.
    To refine: @MelvinBot <your feedback>
    To reject: Explain why you are rejecting Melvin's proposal.

  7. IuliiaHerets commented on Jul 28, 2026

    @IuliiaHerets

    Not repro on Samsung Galaxy A40/Android 11/App/Build  v9.4.45-1

    mobizen_20260728_114622.mp4
  8. melvin-bot commented on Jul 28, 2026

    @melvin-bot

    ⚠️ Looks like this issue was linked to a Deploy Blocker here

    If you are the assigned CME please investigate whether the linked PR caused a regression and leave a comment with the results.

    If a regression has occurred and you are the assigned CM follow the instructions here.

    If this regression could have been avoided please consider also proposing a recommendation to the PR checklist so that we can avoid it in the future.

  9. melvin-bot commented on Jul 28, 2026

    @melvin-bot

    ⚠️ Looks like this issue was linked to a Deploy Blocker here

    If you are the assigned CME please investigate whether the linked PR caused a regression and leave a comment with the results.

    If a regression has occurred and you are the assigned CM follow the instructions here.

    If this regression could have been avoided please consider also proposing a recommendation to the PR checklist so that we can avoid it in the future.

  10. changed the title [-]Android - Report - App crashes after keeping one duplicate expense.[/-] [+][Due for payment 2026-08-05] Android - Report - App crashes after keeping one duplicate expense.[/+] on Jul 29, 2026
  11. melvin-bot commented on Jul 29, 2026

    @melvin-bot

    @MonilBhavsar @jayeshmangwani

    The solution for this issue has been 🚀 deployed to production 🚀 in version 9.4.45-14 and is now subject to a 7-day regression period 📆. Here is the list of pull requests that resolve this issue:

    If no regressions arise, payment will be issued on 2026-08-05. 🎊

    The following checklist (instructions) will need to be completed before the issue can be closed. Please copy/paste the BugZero Checklist from here into a new comment on this GH and complete it. If you have the K2 extension, you can simply click: [this button]. If no checklist is needed for this issue, you can click: [no checklist button]

  12. jponikarchuk commented on Sep 9, 2026

    @jponikarchuk

    Deploy Blocker #100673 was identified to be related to the PR #97308 that attempted to fix this issue.

  13. changed the title [-][Due for payment 2026-08-05] Android - Report - App crashes after keeping one duplicate expense.[/-] [+][Due for payment 2026-09-16] Android - Report - App crashes after keeping one duplicate expense.[/+] on Sep 9, 2026
  14. melvin-bot commented on Sep 9, 2026

    @melvin-bot

    @jayeshmangwani

    The solution for this issue has been 🚀 deployed to production 🚀 in version 9.4.74-2 and is now subject to a 7-day regression period 📆. Here is the list of pull requests that resolve this issue:

    If no regressions arise, payment will be issued on 2026-09-16. 🎊

    The following checklist (instructions) will need to be completed before the issue can be closed. Please copy/paste the Contributor+ Checklist from here into a new comment on this GH and complete it. If you have the K2 extension, you can simply click: [this button]. If no checklist is needed for this issue, you can click: [no checklist button]

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

Metadata

Metadata

Labels

BugSomething is broken. Auto assigns a BugZero manager.DailyKSv2Engineering

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions