Skip to content

Fix scan-error modal not displayed when retry fails#85042

Draft
MelvinBot wants to merge 4 commits intomainfrom
claude-fixScanErrorModalNotDisplayedOnRetryFailure
Draft

Fix scan-error modal not displayed when retry fails#85042
MelvinBot wants to merge 4 commits intomainfrom
claude-fixScanErrorModalNotDisplayedOnRetryFailure

Conversation

@MelvinBot
Copy link
Contributor

Explanation of Change

When a user creates a scan expense with "Simulate failing network requests" enabled and taps "Retry" after the error appears, the confirm modal does not appear and the app redirects to the workspace room.

Two issues caused this:

  1. Destructive dismiss during retry: handleFileRetry called dismissError() before dispatching the retry action. For pending ADD transactions, dismissError() triggers cleanUpMoneyRequest() which deletes the entire transaction/report and navigates the user away — before the retry can complete and surface errors. This is now replaced with Transaction.clearError() which only clears the error from the transaction without destroying it, keeping the user on the expense details page.

  2. Modal navigation back: The useConfirmModal hook defaults shouldHandleNavigationBack to true. When the error modal was shown via showConfirmModal in DotIndicatorMessage, dismissing it could trigger unwanted navigation. This is now explicitly set to false to match the original inline ConfirmModal behavior.

To support the non-destructive retry, transactionID is now threaded through the ReceiptError type so handleFileRetry can target the specific transaction for error clearing.

Fixed Issues

$ #83537
PROPOSAL: #83537 (comment)

Tests

  1. Open the Expensify app
  2. Go to Troubleshoot > Enable "Simulate failing network requests"
  3. Create a scan expense
  4. Go to the expense details
  5. Wait until the error message appears
  6. Tap "Retry" link
  7. Verify the user stays on the expense details page (not redirected to workspace room)
  8. Wait for the confirm modal with a generic error message and "OK" button to appear
  9. Verify the confirm modal appears correctly
  10. Tap "OK" to dismiss the modal
  11. Verify the user remains on the expense details page
  • Verify that no errors appear in the JS console

Offline tests

  1. Create a scan expense while online
  2. Go offline before the scan completes
  3. Tap "Retry" when the error appears
  4. Verify the user stays on the expense details page

QA Steps

  1. Open the Expensify app
  2. Go to Troubleshoot > Enable "Simulate failing network requests"
  3. Create a scan expense
  4. Go to the expense details
  5. Wait until the error message appears
  6. Tap "Retry" link
  7. Verify the user stays on the expense details page (not redirected to workspace room)
  8. Wait for the confirm modal
  9. Verify a confirm modal with a generic error message and "OK" button appears
  10. Tap "OK" to dismiss the modal
  11. Verify the user remains on the expense details page
  • 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
      • If any non-english text was added/modified, I used JaimeGPT to get English > Spanish translation. I then posted it in #expensify-open-source and it was approved by an internal Expensify engineer. Link to Slack message:
    • I verified all numbers, amounts, dates and phone numbers shown in the product are using the localization methods
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
    • I verified proper file naming conventions were followed for any new files or renamed files. All non-platform specific files are named after what they export and are not named "index.js". All platform-specific files are named for the platform the code supports as outlined in the README.
    • I verified the JSDocs style guidelines (in STYLE.md) were followed
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I followed the guidelines as stated in the Review Guidelines
  • I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar are working as expected)
  • I verified all code is DRY (the PR doesn't include any logic written more than once, with the exception of tests)
  • I verified any variables that can be defined as constants (ie. in CONST.ts or at the top of the file that uses the constant) are defined as such
  • I verified that if a function's arguments changed that all usages have also been updated correctly
  • If any new file was added I verified that:
    • The file has a description of what it does and/or why is needed at the top of the file if the code is not self explanatory
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))
  • If new assets were added or existing ones were modified, I verified that:
    • The assets are optimized and compressed (for SVG files, run npm run compress-svg)
    • The assets load correctly across all supported platforms.
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • If a new page is added, I verified it's using the ScrollView component to make it scrollable when more elements are added to the page.
  • I added unit tests for any new feature or bug fix in this PR to help automatically prevent regressions in this user flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.

Screenshots/Videos

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

Two issues prevented the scan-error modal from appearing on retry failure:

1. The retry flow in handleFileRetry called dismissError() which triggers
   cleanUpMoneyRequest() for pending ADD transactions. This destructively
   deletes the transaction/report and navigates the user away before the
   retry action can complete and surface errors.

2. The useConfirmModal hook defaults shouldHandleNavigationBack to true,
   which can cause unwanted navigation when the error modal is dismissed.

Changes:
- Replace dismissError() with Transaction.clearError() in handleFileRetry
  for retry cases, preserving the transaction context so the user stays
  on the expense details page
- Add transactionID to ReceiptError type and thread it through from
  getReceiptError callers
- Add shouldHandleNavigationBack: false to showConfirmModal in
  DotIndicatorMessage to match the old inline ConfirmModal behavior

Co-authored-by: DylanDylann <DylanDylann@users.noreply.github.com>
@MelvinBot
Copy link
Contributor Author

Fixed the failing Changed files ESLint check: the no-default-id-values rule was triggered by transactionID: transactionID ?? '' in getReceiptError(). Since transactionID is optional on the ReceiptError type, replaced it with a conditional spread ...(transactionID && {transactionID}) to avoid defaulting the ID to an empty string.

@MelvinBot
Copy link
Contributor Author

The only failing check is CLA / CLA, and it failed due to a transient DNS resolution error — not a code issue:

graphql call to get the committers details failed: HttpError: request to https://api.github.com/graphql failed, reason: getaddrinfo EAI_AGAIN api.github.com

This is an infrastructure flake (GitHub API was temporarily unreachable from the CI runner). All other checks are passing or still pending.

Fix: Re-run the failed CLA workflow. I wasn't able to trigger a re-run due to permissions — please re-run it manually from the Actions tab.

@codecov
Copy link

codecov bot commented Mar 12, 2026

Codecov Report

✅ Changes either increased or maintained existing code coverage, great job!

Files with missing lines Coverage Δ
src/components/DotIndicatorMessage.tsx 76.19% <ø> (ø)
src/libs/actions/IOU/Duplicate.ts 97.71% <ø> (ø)
src/libs/actions/IOU/index.ts 72.79% <100.00%> (+0.02%) ⬆️
.../libs/ReceiptUploadRetryHandler/handleFileRetry.ts 0.00% <0.00%> (ø)
... and 23 files with indirect coverage changes

@DylanDylann
Copy link
Contributor

@MelvinBot Please review again the expectation:

Expected Result:
A confirm modal with a generic error message and "OK" button appears.

Actual Result:
The confirm modal does not appear. After dismissing the error message, the app redirects to the workspace room.

@MelvinBot
Copy link
Contributor Author

I investigated the failing checks — both failures are infrastructure-related and not caused by code changes in this PR.

1. CLA / CLA — DNS resolution failure on the CI runner:

graphql call to get the committers details failed: HttpError: request to https://api.github.com/graphql failed, reason: getaddrinfo EAI_AGAIN api.github.com

2. build (New Expensify Dev, DebugDevelopment, false) — All build steps completed successfully, but the job itself failed at the "Complete job" stage (runner-level issue after running for 46 minutes). This is a runner/infrastructure timeout, not a build error.

Action needed: Please re-run the failed checks — they should pass on retry.

@MelvinBot
Copy link
Contributor Author

Thanks for flagging this, DylanDylann. After re-investigating the full retry flow, I found that this PR does not fully fix the stated expected behavior. Here's what's going on:


Problem 1: The confirm modal is only triggered on file reconstruction failure

The setShouldShowErrorModal callback (which triggers the confirm modal) is only called in the .catch() of the file fetch in handleRetryPress:

// src/libs/ReceiptUploadRetryHandler/index.ts:9-19
fetch(message.source)
    .then((res) => res.blob())
    .then((blob) => {
        // File reconstruction succeeded → handleFileRetry is called (no modal)
        handleFileRetry(message, reconstructedFile, dismissError, setShouldShowErrorModal);
    })
    .catch(() => {
        // File reconstruction failed → modal is shown
        setShouldShowErrorModal(true);
    });

With "Simulate failing network requests" enabled, only API calls through HttpUtils.ts fail — not local file fetches via fetch(message.source). Since the receipt source is a local file URI (the photo was captured locally but never uploaded due to the simulated failure), file reconstruction succeeds and handleFileRetry is called. The modal callback is never invoked.

The shouldHandleNavigationBack: false fix only affects the modal when it's shown, but the modal is never shown in this scenario.

Problem 2: Retry creates a brand new transaction

Inside handleFileRetry, after clearing errors with clearReceiptError(), IOU.requestMoney() is called. This calls getMoneyRequestInformation() which generates a new transactionID via NumberUtils.rand64() (at IOU/index.ts:3434):

const optimisticTransactionID = existingTransactionID ?? NumberUtils.rand64();

Since the retry params don't include existingTransactionDraft, existingTransactionID is undefined, so a new transaction is always created. When this new API call fails (again due to simulated failure), the failureData sets the receipt error on the new transaction's ID — not the one the user is viewing.

Result: the user sees the error disappear (cleared on the old transaction) but never sees the new error (it's on a different transaction they're not viewing).

Problem 3: Navigation side effects during retry

requestMoney() defaults shouldHandleNavigation: true. During retry, this isn't overridden, so navigation-related code still executes (e.g., Navigation.removeScreenByKey at IOU/index.ts:6638). While isRetry: true prevents the main handleNavigateAfterExpenseCreate call, other navigation side effects may still occur.


What needs to change

  1. Reuse the existing transaction during retry — Pass existingTransactionDraft (with the original transactionID from message.transactionID) so requestMoney/trackExpense reuses the same transaction ID. This way, when the retry fails, the error is set on the same transaction the user is viewing.

  2. Set shouldHandleNavigation: false during retry — Prevent any navigation side effects in the retry path.

  3. Clarify the expected behavior — After a retry where file reconstruction succeeds but the API call fails again, the correct behavior should be: the receipt error re-appears on the same transaction (showing the "Retry" / "Download" links again). The confirm modal is specifically for when file reconstruction fails (e.g., the local file was deleted from the device) — which is a different failure mode than what "Simulate failing network requests" produces.

I'll work on updating this PR with the additional fixes.

When retrying a failed scan expense, the retry flow was creating a brand new
transaction (with a new transactionID) instead of reusing the original. This
caused the retry failure error to appear on a different transaction than the
one the user was viewing, making the error invisible.

Changes:
- Pass existingTransactionDraft/existingTransaction with the original
  transactionID during retry so requestMoney/trackExpense reuse the same
  transaction instead of generating a new one
- Set shouldHandleNavigation: false during retry to prevent navigation
  side effects
- In trackExpense, fall back to existingTransaction.transactionID when
  computing existingTransactionID

Co-authored-by: DylanDylann <DylanDylann@users.noreply.github.com>
@MelvinBot
Copy link
Contributor Author

Pushed a fix in dc7244e that addresses the core issues:

What changed:

  1. Retry now reuses the existing transaction ID — For MONEY_REQUEST, the original transactionID is passed via existingTransactionDraft so requestMoney reuses the same transaction instead of generating a new one. Same for TRACK_EXPENSE via existingTransaction. This means when the retry API call fails again, the receipt error is set on the same transaction the user is viewing.

  2. shouldHandleNavigation: false during retry — Prevents navigation side effects (like Navigation.removeScreenByKey) from executing during the retry path.

  3. trackExpense now uses existingTransaction?.transactionID as fallback — When existingTransaction is provided (as in the retry case), its transactionID is used instead of generating a new random ID.

Expected behavior after this fix:

  • User creates scan expense → API fails → error with "Retry" link appears
  • User taps "Retry" → file is reconstructed locally (succeeds) → API is retried on the same transaction
  • If the retry also fails → receipt error re-appears on the same transaction → user stays on the expense details page and can retry again

Note on the confirm modal: The confirm modal (generic error + "OK" button) is specifically designed for when the local file reconstruction fails (e.g., the receipt photo was deleted from the device). With "Simulate failing network requests", only API calls fail — local file access still works. So the expected behavior is the receipt error re-appearing with the "Retry"/"Download" links, not the confirm modal.

The previous commit changed trackExpense to reuse existingTransaction.transactionID
for retries, but Duplicate.ts had a hardcoded placeholder transactionID: '1' in its
existingTransaction objects. This caused duplicated transactions to get the same ID
as the original. Replace the placeholder with NumberUtils.rand64() to generate proper
unique IDs.

Co-authored-by: DylanDylann <DylanDylann@users.noreply.github.com>
@MelvinBot
Copy link
Contributor Author

Fixed the failing check: The test (job 6) failure was in tests/actions/IOUTest/DuplicateTest.ts — the test should call trackExpense API when targetPolicy is not provided expected the duplicated transaction to have a different transactionID from the original, but both ended up with "1".

Root cause: The previous commit (dc7244e) changed trackExpense to pass existingTransaction?.transactionID as existingTransactionID (was undefined before) to support retry reusing the same transaction. However, Duplicate.ts builds its existingTransaction with a hardcoded placeholder transactionID: '1', which was previously harmless since it was ignored. After the change, this placeholder '1' became the actual transaction ID for the duplicate, colliding with the original mock transaction's ID.

Fix: Replaced the hardcoded transactionID: '1' with NumberUtils.rand64() in both existingTransaction objects in duplicateExpenseTransaction (track expense and distance request paths) so duplicated transactions always get a proper unique ID.

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.

2 participants