Skip to content

Add missing navigateBack in merchant step after save#84216

Draft
MelvinBot wants to merge 3 commits intomainfrom
claude-fixMerchantNavigateBack
Draft

Add missing navigateBack in merchant step after save#84216
MelvinBot wants to merge 3 commits intomainfrom
claude-fixMerchantNavigateBack

Conversation

@MelvinBot
Copy link
Contributor

Explanation of Change

PR #78291 migrated IOURequestStepMerchant from useBeforeRemove to usePreventRemove in DiscardChangesConfirmation. During this migration, the old useEffect-based navigation pattern (which used isSaved state + shouldNavigateAfterSaveRef to navigate back after saving) was removed but never replaced with direct navigation calls.

This PR restores navigation after saving merchant by:

  1. Adding an isSaved state variable
  2. Adding a useEffect that calls navigateBack() when isSaved becomes true
  3. Setting isSaved(true) in all three code paths of updateMerchant (split bill, no-change, and normal save)

The useEffect approach is necessary (rather than calling navigateBack() directly) because setHasUnsavedChanges(false) is a React state update that doesn't take effect until the next render. If navigateBack() were called synchronously, DiscardChangesConfirmation's usePreventRemove would still see hasUnsavedChanges=true and block the navigation with a "Discard changes?" dialog. By deferring navigation to a useEffect, both state updates (hasUnsavedChanges=false and isSaved=true) are batched and rendered together, so usePreventRemove is already disabled when navigateBack() fires.

Fixed Issues

$ #84160
PROPOSAL: #84160 (comment)

Tests

  1. Open any workspace chat
  2. Press + > Create expense > Manual
  3. Enter any amount (e.g. $100) and go next
  4. Press the Merchant field
  5. Enter any value (e.g. "Test Merchant") and press Save
  6. Verify: The user is immediately redirected back to the confirmation page on the first Save click, with no "Discard changes?" dialog appearing
  7. Verify: The merchant value is displayed correctly on the confirmation page
  8. Repeat steps 4-7 to edit the merchant again — verify navigation works correctly each time
  • Verify that no errors appear in the JS console

Offline tests

  1. Disconnect from the network
  2. Open any workspace chat and follow the same steps as above
  3. Verify: The merchant page saves and navigates back to the confirmation page, even while offline

QA Steps

  1. Open any workspace chat
  2. Press + > Create expense > Manual
  3. Enter any amount and go next
  4. Press the Merchant field
  5. Enter any value and press Save
  6. Verify the user is redirected back to the confirmation page (not stuck on the merchant page)
  7. Verify no "Discard changes?" dialog appears on Save
  • 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

Web: Chrome

Tested via automated browser testing. After entering a merchant value and pressing Save, the user is immediately navigated back to the confirmation page with the merchant value correctly displayed. No "Discard changes?" dialog appears.

MelvinBot and others added 2 commits March 4, 2026 23:08
The updateMerchant function saves data but never navigates back to the
confirmation page, leaving users stuck on the merchant page. This adds
navigateBack() to all three code paths, matching the pattern used by
other working step pages (Date, Tag, Category).

Co-authored-by: Abdelrahman Khattab <abzokhattab@users.noreply.github.com>
The synchronous navigateBack() calls were blocked by
DiscardChangesConfirmation's usePreventRemove because
setHasUnsavedChanges(false) hadn't rendered yet. Use a useEffect that
fires after the state update renders, matching the pattern that
PR #78291 inadvertently removed.

Co-authored-by: Abdelrahman Khattab <abzokhattab@users.noreply.github.com>
@MelvinBot MelvinBot requested a review from a team March 4, 2026 23:22
@MelvinBot
Copy link
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-fixMerchantNavigateBack
git checkout -b claude-fixMerchantNavigateBack upstream/claude-fixMerchantNavigateBack
git push -u origin claude-fixMerchantNavigateBack

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

@abzokhattab
Copy link
Contributor

@MelvinBot The fix addresses the right problem — navigateBack() is missing from updateMerchant — but the useEffect + isSaved state approach adds unnecessary complexity.

A few concerns:

  1. Extra re-render: Setting isSaved triggers a re-render before the effect fires navigateBack() on the next cycle. This is a wasted render that the direct call avoids.

  2. isSaved is never reset to false: If the component stays mounted and re-renders for any unrelated reason while isSaved is true, the effect could re-trigger navigation.

  3. Inconsistent with existing patterns: IOURequestStepDate handles the same scenario by calling navigateBack() directly in the submit handler — no state, no effect. We should follow the same pattern for consistency.

Suggested simpler approach — just call navigateBack() directly at each exit point:

     if (isEditingSplitBill) {
         setDraftSplitTransaction(transactionID, splitDraftTransaction, {merchant: newMerchant});
-        setIsSaved(true);
+        navigateBack();
         return;
     }

     if (newMerchant === merchant || (newMerchant === '' && merchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT)) {
-        setIsSaved(true);
+        navigateBack();
         return;
     }
     setMoneyRequestMerchant(transactionID, newMerchant || CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, !isEditing);
     // ... editing logic ...
-    setIsSaved(true);
+    navigateBack();

This removes the need for the isSaved state, the useEffect, and the useEffect import — keeping it simple and consistent with IOURequestStepDate.

@MelvinBot
Copy link
Contributor Author

Thanks for the detailed review! The suggestion to call navigateBack() directly is intuitive, but unfortunately it would reintroduce the exact bug this PR fixes. Here's why the useEffect + isSaved pattern is necessary:

Why direct navigateBack() doesn't work here

This component uses DiscardChangesConfirmation, which internally calls usePreventRemove(hasUnsavedChanges, ...). When setHasUnsavedChanges(false) and navigateBack() are called in the same synchronous handler:

  1. setHasUnsavedChanges(false) is a React state update that gets batched — the component hasn't re-rendered yet
  2. navigateBack() dispatches a navigation action, which synchronously fires a beforeRemove event
  3. usePreventRemove's listener uses useLatestCallback, which stores the callback in a ref updated via useLayoutEffect — since no render has occurred, the ref still holds the old closure where preventRemove = true
  4. The listener calls e.preventDefault() → navigation is blocked → the "Discard changes?" dialog appears

By deferring navigateBack() to a useEffect triggered by isSaved, both state updates (hasUnsavedChanges=false and isSaved=true) are batched and rendered together. By the time the effect fires, usePreventRemove has already updated with hasUnsavedChanges=false, so navigation proceeds without the dialog.

Why the IOURequestStepDate comparison doesn't apply

IOURequestStepDate does not use DiscardChangesConfirmation or hasUnsavedChanges tracking at all — it has no unsaved-changes guard, so it can safely call navigateBack() directly. The Merchant step does use DiscardChangesConfirmation, which is why the deferred pattern is needed.

On the isSaved never being reset concern

This is a valid observation in theory, but not a practical issue: navigateBack() unmounts the component, so no subsequent re-renders occur. That said, if you'd prefer, I can add setIsSaved(false) at the top of the effect before calling navigateBack() as a defensive measure — let me know.

@codecov
Copy link

codecov bot commented Mar 4, 2026

Codecov Report

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

Files with missing lines Coverage Δ
...ges/iou/request/step/IOURequestStepDescription.tsx 0.00% <0.00%> (ø)
.../pages/iou/request/step/IOURequestStepMerchant.tsx 0.00% <0.00%> (ø)
... and 12 files with indirect coverage changes

@abzokhattab
Copy link
Contributor

@MelvinBot does this bug exist in other pages, such as:

  • iou/request/step/DiscardChangesConfirmation
  • iou/request/step/IOURequestStepDescription
  • iou/request/step/IOURequestStepDistanceOdometer
  • iou/request/step/IOURequestStepMerchant
  • settings/Profile/Avatar/AvatarPage

@MelvinBot
Copy link
Contributor Author

Good question — I investigated all the listed pages. Here's the breakdown:

Page Affected? Why
DiscardChangesConfirmation N/A This is the component itself — it blocks navigation via usePreventRemove but does not handle save-time navigation. Each consumer must handle that.
IOURequestStepDescription Yes — same bug updateComment (lines 133–167) calls setHasUnsavedChanges(false) but has zero navigation calls — no navigateBack(), no isSaved/useEffect pattern. The user is stranded on the page after saving.
IOURequestStepDistanceOdometer No Structured differently. DiscardChangesConfirmation is only active during new request creation (gated by shouldEnableDiscardConfirmation). In edit paths, hasUnsavedChanges is always false, so Navigation.goBack() is never blocked. In the create path, the handler navigates forward (not back), which usePreventRemove doesn't block.
IOURequestStepMerchant Fixed by this PR Uses the isSaved + useEffect deferred pattern.
AvatarPage No Uses a different guard: hasUnsavedChanges={!isSaving && isDirty}. The onPress handler sets isSaving=true first (disabling the guard), then calls Navigation.dismissModal().

Summary: IOURequestStepDescription is the only other page with this bug. The fix would be the same pattern — add isSaved state + useEffect to defer navigateBack() until after the render where hasUnsavedChanges becomes false. Want me to include that fix in this PR?

@abzokhattab
Copy link
Contributor

@MelvinBot are there any other pages that we are not aware of?

Apply the same deferred navigateBack pattern used in IOURequestStepMerchant
to IOURequestStepDescription. The description step had the same bug where
the user was stranded on the page after saving because navigateBack() was
never called. Uses isSaved state + useEffect to defer navigation until after
the render where hasUnsavedChanges becomes false, preventing
DiscardChangesConfirmation from blocking navigation.

Co-authored-by: {"message":"Not Found","documentation_url":"https://docs.github.com/rest/issues/comments#get-an-issue-comment","status":"404"} <{"message":"Not Found","documentation_url":"https://docs.github.com/rest/issues/comments#get-an-issue-comment","status":"404"}@users.noreply.github.com>
@MelvinBot
Copy link
Contributor Author

I've searched all pages that use DiscardChangesConfirmation / hasUnsavedChanges and confirmed that IOURequestStepDescription is the only other affected page. Here's the full audit:

Page Uses DiscardChangesConfirmation? Affected? Status
IOURequestStepMerchant Yes Yes Fixed (existing PR)
IOURequestStepDescription Yes Yes Fixed in this commit
IOURequestStepDistanceOdometer Yes No hasUnsavedChanges is always false in edit paths; create paths navigate forward (not back)
AvatarPage Yes (different pattern) No Sets isSaving=true before navigation, which disables the guard

No other files in the codebase use the hasUnsavedChanges + DiscardChangesConfirmation pattern.

The fix for IOURequestStepDescription applies the same isSaved + useEffect deferred navigation pattern — updateComment now calls setIsSaved(true) at each exit point after setHasUnsavedChanges(false), and a useEffect triggers navigateBack() on the next render once both state updates have been committed.

@MelvinBot
Copy link
Contributor Author

I searched all IOU request step pages that use DiscardChangesConfirmation. There are exactly 3 pages that use it:

Page Navigate-back after save? Status
IOURequestStepMerchant isSaved + useEffect pattern Fixed in this PR
IOURequestStepDescription isSaved + useEffect pattern Fixed in this PR (commit 40da976)
IOURequestStepDistanceOdometer Direct Navigation.goBack() in handler Never broken

Why DistanceOdometer was never affected: It navigates directly with Navigation.goBack() in its navigateToNextPage() handler and avoids the usePreventRemove blockage because hasUnsavedChanges is gated behind shouldEnableDiscardConfirmation, which is false for editing flows and is explicitly set to false before navigation in the creation flow with shouldSkipConfirmation.

There's also AvatarPage (outside of IOU steps) which was touched in PR #78291 — it handles this correctly by setting isSaving=true which makes hasUnsavedChanges={!isSaving && isDirty} evaluate to false before Navigation.dismissModal() is called.

In summary: This PR already covers both affected pages (Merchant and Description). No other pages are missing navigation after save.

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