You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[$250] Android/iOS-Offline-Navigation arrows disappear after deleted expense syncs online #103403
Version Number: 9.5.5-0 Reproducible in staging?: Yes Reproducible in production?: Yes If this was caught during regression testing, add the test name, ID and link from BrowserStack:#103109 Email or phone of affected tester (no customers):[email protected] Issue reported by: Applause Internal Team Bug source: Pull Request QA execution Device used: Samsung Galaxy S21/Android 15, iPhone 13/18.6.2 App Component: Other
Action Performed:
Launch Expensify app
Log in with valid credentials
Open Home and make sure the Recently added section shows at least 3 expenses.
Open dev tools and turn on Force offline.
Open the first (newest) expense, then select More > Delete.
Go back to Home and open the expense right under the deleted one.
Turn off Force offline and wait for the delete to sync, keeping the expense open.
Expected Result:
The deleted expense is removed from Recently added, and the navigation arrows remain visible and allow navigation between expenses that were not deleted.
Actual Result:
After the delete syncs while the expense is open, the navigation arrows disappear.
The header remounts while the Test Tools modal covers the expense, and its unmount cleanup wipes the arrow list. The delete sync only coincides with this. Keep pending-delete expenses out of the Recently added carousel did not cause it, and the issue already reproduces in production.
On native, deleting expense A leaves nvp_deleteTransactionNavigateBackURL set. The native stack unmounts A's screen right away, which cancels the pending clear in DeleteTransactionNavigateBackHandler.tsx:22-37.
You open expense B, then open Test Tools to turn off Force offline. B loses focus, so B's own handler clears the URL.
ReportNotFoundGuard.tsx:127-131 switches from bare children to <ReportNotFoundInnerGuard>. React then remounts the whole header.
The new component reads an empty list and renders no arrows. Nothing seeds the list again.
Web is not affected because the JS stack keeps the closing screen mounted through the animation, so step 1 clears the URL normally.
What changes do you think we should make in order to solve the problem?
In the unmount cleanup of MoneyRequestReportTransactionsNavigation, skip clearActiveTransactionIDs() while the component's own route key (useRoute().key) is still in the navigation state. If the route is still there, the header remounted inside an open screen and the user did not leave. When the user really leaves, the route is gone from the state, so the list still clears. Use extractNavigationKeys from extractNavigationKeys.ts for the check.
Add a unit test in MoneyRequestReportTransactionsNavigation.test.tsx with a useRoute mock. The test should expect no clear when the route is still in the state while Test Tools is focused.
What alternative solutions did you explore? (Optional)
Stop ReportNotFoundGuard from changing its root element type, for example by always rendering the inner guard with an enabled flag. This prevents the remount, but the inner guard was split out for performance.
Clear the URL when the deleted screen leaves the stack on native. This fixes the leak, which also disables the deleted-parent "not found" check on the next expense you open. On its own it does not protect the arrows from other remounts.
Confidence and verification
Confidence is medium. This comes from reading the code. It has not been tested on a device, and the web tester can't reproduce a native-only bug. Two predictions would confirm it on Android/iOS:
After steps 1–6, opening and closing Test Tools without going online also hides the arrows.
Going online with airplane mode instead of Test Tools keeps the arrows.
The hook itself still returns the right siblings after the sync (useCarouselTransactionIDs.ts:26-47). The list goes empty only because the stored list was cleared.
Next Steps for Contributor+ team: To accept:@MelvinBot implement [this](https://github.com/Expensify/App/issues/103403) to create a draft PR. To refine:@MelvinBot <your feedback> To reject: Explain why you are rejecting Melvin's proposal.
changed the title [-]Android/iOS-Offline-Navigation arrows disappear after deleted expense syncs online[/-][+][$175] Android/iOS-Offline-Navigation arrows disappear after deleted expense syncs online[/+]on Oct 8, 2026
Please re-state the problem that we are trying to solve in this issue.
In Android/iOS, after an expense is deleted while offline, opening an adjacent expense from the carousel and waiting for the offline delete to sync causes the navigation arrows in the header (MoneyRequestReportTransactionsNavigation) to disappear.
What is the root cause of that problem?
Unnecessary Component Remounting in ReportNotFoundGuard.tsx:
In src/pages/inbox/ReportNotFoundGuard.tsx:127-131:
When an expense is deleted offline, deleteTransactionNavigateBackUrl is set in Onyx (ONYXKEYS.NVP_DELETE_TRANSACTION_NAVIGATE_BACK_URL). While it is set, ReportNotFoundGuard renders {children} directly. When the delete syncs and DeleteTransactionNavigateBackHandler clears deleteTransactionNavigateBackUrl, ReportNotFoundGuard switches from bare {children} to <ReportNotFoundInnerGuard>{children}</ReportNotFoundInnerGuard>. Because the element type changes at the guard root, React unmounts the entire children tree (including MoneyRequestHeader and MoneyRequestReportTransactionsNavigation) and mounts a new one.
Premature Clearing in MoneyRequestReportTransactionsNavigation.tsx:
In src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx:209-217:
If the user has opened the Test Tools modal to toggle offline mode, findFocusedRoute returns TEST_TOOLS_MODAL.ROOT (or SCREENS.RIGHT_MODAL.TEST_TOOLS), which is not in CAROUSEL_PRESERVING_SCREENS.
Checking only focusedRoute.name fails to distinguish between the user actually navigating away from the screen versus a modal being focused or an internal remount where the screen route is still intact in the navigation state.
Consequently, clearActiveTransactionIDs() is called, wiping ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS. When the re-mounted MoneyRequestReportTransactionsNavigation evaluates useCarouselTransactionIDs(), transactionIDsList is empty, causing lines 426-429 to return early and hide the arrows.
What changes do you think we should make in order to solve the problem?
Stabilize ReportNotFoundGuard.tsx to prevent tree remounts:
Instead of conditionally swapping between {children} and <ReportNotFoundInnerGuard>{children}</ReportNotFoundInnerGuard>, always render <ReportNotFoundInnerGuard> for transaction threads and pass a condition (or check !deleteTransactionNavigateBackUrl internally in ReportNotFoundInnerGuard before determining shouldShowNotFoundPage):
In ReportNotFoundInnerGuard, if shouldCheckParent is false, set shouldShowNotFoundPage = false. This guarantees the component tree never unmounts due to deleteTransactionNavigateBackUrl updates.
Harden MoneyRequestReportTransactionsNavigation.tsx unmount cleanup:
In src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx, check whether the component's route key (useRoute().key) is still in the navigation state before clearing:
Additionally, include SCREENS.RIGHT_MODAL.TEST_TOOLS in CAROUSEL_PRESERVING_SCREENS.
What alternative solutions did you explore? (Optional)
Only updating CAROUSEL_PRESERVING_SCREENS: Does not solve cases where other overlays or transient state transitions cause a remount while the screen route remains alive. Combining route key verification with ReportNotFoundGuard stabilization provides complete stability across both native and web platforms.
What specific scenarios should we cover in automated tests?
In tests/unit/components/MoneyRequestReportTransactionsNavigation.test.tsx:
Verify that when MoneyRequestReportTransactionsNavigation unmounts while its route key is still in navigation state (or while a modal is focused), clearActiveTransactionIDs is not called.
In tests/unit/ReportNotFoundGuardTest.tsx:
Verify that toggling deleteTransactionNavigateBackUrl does not cause children to unmount and remount.
Please re-state the problem that we are trying to solve in this issue.
In Android/iOS, after an expense is deleted while offline, opening an adjacent expense from the carousel and waiting for the offline delete to sync causes the navigation arrows in the header (MoneyRequestReportTransactionsNavigation) to disappear.
What is the root cause of that problem?
The bug is caused by a race condition between a component tree remount in ReportNotFoundGuard and premature cache clearing in MoneyRequestReportTransactionsNavigation:
Tree Remount in ReportNotFoundGuard.tsx:
In src/pages/inbox/ReportNotFoundGuard.tsx:127-131:
On native platforms, deleting an expense leaves deleteTransactionNavigateBackUrl set in Onyx because the native stack immediately unmounts the deleted expense screen, aborting the pending clear in DeleteTransactionNavigateBackHandler.
When the user opens a sibling expense from the carousel, deleteTransactionNavigateBackUrl is initially truthy, so ReportNotFoundGuard renders {children} directly. When the user opens Test Tools or the offline action syncs, DeleteTransactionNavigateBackHandler clears deleteTransactionNavigateBackUrl. This causes ReportNotFoundGuard to switch from bare {children} to <ReportNotFoundInnerGuard>{children}</ReportNotFoundInnerGuard>. Because the root wrapper component type changes, React completely unmounts the existing {children} tree (including MoneyRequestHeader and MoneyRequestReportTransactionsNavigation) and mounts a new one.
Premature Cache Clearing in MoneyRequestReportTransactionsNavigation.tsx:
In src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx:209-217:
When the unmount cleanup fires during the tree remount:
findFocusedRoute inspects the top of the stack. If Test Tools or another modal is open, its name is not in CAROUSEL_PRESERVING_SCREENS.
The cleanup assumes the user navigated away from the screen entirely and calls clearActiveTransactionIDs(), wiping ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS.
When the re-mounted MoneyRequestReportTransactionsNavigation evaluates useCarouselTransactionIDs(), the stored IDs list is empty, causing the component to return early and hide the arrows.
What changes do you think we should make in order to solve the problem?
We should apply a two-layer fix to eliminate both the spurious tree remount and the unsafe unmount cleanup:
In src/pages/inbox/ReportNotFoundGuard.tsx:
Keep the component structure stable so deleteTransactionNavigateBackUrl changes do not unmount and remount {children}:
Inside ReportNotFoundInnerGuard, if shouldCheck is false, bypass the parent report action check (setting shouldShowNotFoundPage = false). This guarantees {children} remains mounted without unmounting when deleteTransactionNavigateBackUrl updates.
In src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx:
Harden the unmount cleanup so it verifies whether the screen's route key is still in the active navigation state using extractNavigationKeys:
constroute=useRoute();useEffect(()=>{return()=>{constrootState=navigationRef.getRootState();constactiveKeys=extractNavigationKeys(rootState?.routes);// If the route key is still present in the navigation hierarchy, the screen is still active (remount or modal overlay)if(activeKeys.has(route.key)){return;}constfocusedRoute=findFocusedRoute(rootState);if(focusedRoute?.name&&(CAROUSEL_PRESERVING_SCREENSasreadonlystring[]).includes(focusedRoute.name)){return;}clearActiveTransactionIDs();};},[route.key]);
What specific scenarios should we cover in automated tests?
In tests/unit/ReportNotFoundGuardTest.tsx:
Verify that updating deleteTransactionNavigateBackUrl from a URL to null does not trigger an unmount/remount of children.
In tests/unit/components/MoneyRequestReportTransactionsNavigation.test.tsx:
Verify that when MoneyRequestReportTransactionsNavigation unmounts while its route key exists in extractNavigationKeys, clearActiveTransactionIDs is not invoked.
Verify that when the route key is removed from navigation state, clearActiveTransactionIDs is called as expected.
Please re-state the problem that we are trying to solve in this issue.
On Android/iOS, when an expense is deleted while offline and the user opens an adjacent expense from the carousel, waiting for the delete to sync online causes the header navigation arrows to disappear.
What is the root cause of that problem?
The root cause consists of two intertwined issues in ReportNotFoundGuard and MoneyRequestReportTransactionsNavigation:
Unnecessary Component Remounting in ReportNotFoundGuard.tsx: ReportNotFoundGuard conditionally wraps {children} in <ReportNotFoundInnerGuard> only when !deleteTransactionNavigateBackUrl && isReportTransactionThread(report). When deleteTransactionNavigateBackUrl is present, it renders bare {children}.
When an offline delete finishes syncing and clears deleteTransactionNavigateBackUrl, the JSX tree switches component types from {children} to <ReportNotFoundInnerGuard>{children}</ReportNotFoundInnerGuard>. In React's reconciliation engine, changing the wrapper component type destroys and remounts the entire subtree, unmounting MoneyRequestReportTransactionsNavigation.
Premature Cache Eviction in MoneyRequestReportTransactionsNavigation.tsx:
During unmount, the cleanup effect in MoneyRequestReportTransactionsNavigation checks findFocusedRoute. If a modal (such as Test Tools or offline sync overlay) is active, or during this transient remount, it concludes navigation has exited the screen and unconditionally invokes clearActiveTransactionIDs(), wiping ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS. When the component remounts, the carousel transaction ID list in Onyx is empty, causing the component to render nothing and hiding the navigation arrows.
What changes do you think we should make in order to solve the problem?
In src/pages/inbox/ReportNotFoundGuard.tsx:
Stabilize the component hierarchy so that updating deleteTransactionNavigateBackUrl does not alter the wrapper component structure. Always render ReportNotFoundInnerGuard for transaction threads and pass a boolean flag (e.g. shouldCheckParent={!deleteTransactionNavigateBackUrl}) into ReportNotFoundInnerGuard. When shouldCheckParent is false, bypass the parent report action check (setting shouldShowNotFoundPage = false). This prevents React from unmounting {children} when deleteTransactionNavigateBackUrl updates.
In src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx:
Harden the unmount cleanup hook so it does not clear transaction IDs when the screen remains active in navigation state. Check whether the current screen's route key (useRoute().key) still exists within navigation state (navigationRef.getRootState()) using extractNavigationKeys. Only execute clearActiveTransactionIDs() when the route has truly been popped from navigation.
What alternative solutions did you explore? (Optional)
Only updating CAROUSEL_PRESERVING_SCREENS: Adding individual modal screens does not prevent the full unmount/remount cycle in ReportNotFoundGuard, leaving other overlays vulnerable to arrow loss. Stabilizing the component hierarchy at the source is the cleaner and more robust fix.
What specific scenarios should we cover in automated tests?
In tests/unit/ReportNotFoundGuardTest.tsx:
Verify that transitioning deleteTransactionNavigateBackUrl from a string to undefined/null maintains component stability without unmounting children.
In tests/unit/components/MoneyRequestReportTransactionsNavigation.test.tsx:
Verify that unmounting while the route key is still in navigation state does not call clearActiveTransactionIDs.
Verify that navigating away (route key removed from navigation state) correctly calls clearActiveTransactionIDs.
Reading this as a Test Tools problem - and fixing it by adding the Test Tools screens to CAROUSEL_PRESERVING_SCREENS - leaves the bug live. Nothing in the chain depends on which screen gets pushed, only that something is pushed over the open expense while the delete-back NVP is still set. Two checkable consequences: with the NVP leaked, opening expense B's Description row (Dynamic_Money_Request_Step_Description) or tapping its receipt (ReportAttachments) wipes the arrows the same way, and neither is - or should become - an allowlist entry; and no later carousel overwrites the wiped list, because nothing reseeds it while the user stays on B (step 4).
Deleting expense A offline sets the global nvp_deleteTransactionNavigateBackURL. The handler that clears it only schedules the clear while its own screen is blurred, and cancels the pending clear on unmount - so A's screen being destroyed on the way back to Home leaves the NVP set:
Expense B is then opened from Home with that NVP still set, so ReportNotFoundGuard returns bare children. When B blurs under the pushed route, B's own handler clears the NVP and the guard swaps to <ReportNotFoundInnerGuard>{children}</ReportNotFoundInnerGuard>. The element type at that position changes, so React discards the entire report subtree - MoneyRequestHeader and its carousel with it - and mounts a fresh one:
CAROUSEL_PRESERVING_SCREENS was built for the pop case the unit test documents (#90366): this route is gone and a screen below it in the RHP stack is now focused. The remount is a different event - B's route is still in the navigation state and still the screen the user is looking at, only its React subtree was replaced. A focused-route name cannot separate the two, so the cleanup runs clearActiveTransactionIDs() and wipes the list Home seeded.
Nothing puts it back. RecentlyAddedSection writes the carousel only inside its row-press handler, so there is no effect to re-run when the delete later syncs:
To reproduce with no sync and no Test Tools: offline, delete the newest Home expense, open the one under it, then open its Description row.
Step 1 is a teardown race - the clear is queued behind runAfterTransitions and the unmount cancels it - so whether the NVP leaks at all depends on how fast the closing screen is torn down. That is a stack-dependent outcome, matching the reporter ticking only the two native rows.
What changes do you think we should make in order to solve the problem?
Make the cleanup confirm the carousel's own route is actually gone before concluding the user left it. extractNavigationKeys already does the recursive key walk, so this is a short-circuit in front of the existing check, in MoneyRequestReportTransactionsNavigation.tsx:
constrouteKey=useRoute().key;
...
useEffect(()=>{return()=>{constrootState=navigationRef.getRootState();// An in-place teardown (ReportNotFoundGuard swapping its wrapper when the delete-back URL clears)// leaves this route mounted, so the list still belongs to the expense on screen - keep it.if(extractNavigationKeys(rootState?.routes).has(routeKey)){return;}constfocusedRoute=findFocusedRoute(rootState);if(focusedRoute?.name&&(CAROUSEL_PRESERVING_SCREENSasreadonlystring[]).includes(focusedRoute.name)){return;}clearActiveTransactionIDs();};},[routeKey]);
This widens rather than removes: the pop case drops this route from the state first, so CAROUSEL_PRESERVING_SCREENS stays and keeps covering #90366 - and no new entry is needed per screen that can be pushed over an expense. Keep the clear too - a leaked descriptor-backed carousel would permanently stop a report's own transaction list from seeding its arrows:
It also matches the writer side of the same module, which already refuses to clear on unmount unless it still owns what it is clearing (clearActiveTransactionIDsForSource) instead of reading the focused route.
What specific scenarios should we cover in automated tests?
In the existing clearing the carousel on unmount block of tests/unit/components/MoneyRequestReportTransactionsNavigation.test.tsx: with an unrelated screen focused, unmounting must not call clearActiveTransactionIDs while the component's route key is still present in the mocked root state, and must still call it when that key is absent - the pop case the existing tests cover.
What alternative solutions did you explore?
Always mounting ReportNotFoundInnerGuard and gating it with a prop. That removes this one remount trigger, but it needs a new prop just to keep the not-found page suppressed while the delete-back URL is set, pulls the parent-action subscriptions the guard was split to defer into that window, and leaves the cleanup's focused-route heuristic wrong for any other in-place teardown. Less coverage for more surface.
tests/unit/components/MoneyRequestReportTransactionsNavigation.test.tsx, run with npx jest tests/unit/components/MoneyRequestReportTransactionsNavigation.test.tsx:
without the change: Tests: 1 failed, 30 passed, 31 total
Please re-state the problem that we are trying to solve in this issue.
On Android and iOS, after you delete one expense from Home's Recently added section and open the expense under it, the prev/next arrows disappear the first time anything opens on top of that expense. In the reported steps that something is Test Tools, used to turn Force offline off. The arrows should stay, and they should still move between the expenses that were not deleted. They stay gone until the expense is opened from Home again.
What is the root cause of that problem?
The delete sync does not hide the arrows. Opening Test Tools does, and it does it while the app is still offline. The arrows are already gone in the frame the modal starts to open. Going back online only happens to be the next step in the report.
Home seeds one global sibling list when the row is pressed, then opens the expense:
Nothing else writes that list while you stay on the expense. Filtering out the deleted expense still leaves the other siblings, so two or more entries remain and the arrows should keep rendering:
The list goes empty because the header remounts and its unmount cleanup wipes it. Four steps:
Deleting an expense sets nvp_deleteTransactionNavigateBackURL, then pops the screen. The only clear waits for that screen to render unfocused, then runs after the transition:
On native that unfocused render never happens. native-stack 7.19.2 only mounts state.routes (plus preloaded routes), so a popped screen leaves the tree in the same commit that removes it from state. It never renders with isFocused === false, the effect returns on its last focused render, and the URL stays set:
Web is unaffected because the JS stack keeps the closing screen mounted through the animation, so it does render unfocused and the clear runs. Home is not a report screen, so nothing clears the URL there either. The next expense therefore mounts with the URL still set. The same blur effect is copied in SearchMoneyRequestReportPage.tsx (lines 105 to 120), so a delete from the super-wide expense report leaks the same way.
While the URL is set, the open expense's ReportNotFoundGuard returns its children directly. The first time that expense blurs, its own handler clears the URL, and the guard wraps the same children in ReportNotFoundInnerGuard. The element type at that spot changes, so React throws away the whole report subtree, header included, and mounts a new one:
That condition used to live inside the inner guard, so toggling the URL only changed a boolean. PR #87559's review follow-up (2e33a14422f) moved it onto the outer if so the inner guard would not subscribe while the URL was set. That is what turns a URL change into a remount. Test Tools is just the first blur in this repro. Opening Description, the receipt, or any other screen on top does the same thing.
The old carousel's cleanup treats "I unmounted" as "the user left". It only looks at the focused route name:
CAROUSEL_PRESERVING_SCREENS was added for the pop case in #90366: this route is gone, and a screen under it in the RHP is now focused, so the list must survive. A remount is a different event. The expense route is still in the stack. With Test Tools open, findFocusedRoute returns TEST_TOOLS_MODAL.ROOT, which is not in the list, so the cleanup calls clearActiveTransactionIDs().
The new header reads the empty list and renders nothing. Only a Home row press seeds the list again, which is why reopening the expense brings the arrows back:
The same component is what MoneyReportHeader renders for a report-level carousel, so a cleared list drops those arrows too.
What changes do you think we should make in order to solve the problem?
Make the cleanup check that this screen has actually left before it clears the list. The carousel already sits inside the report screen, and useRouteKey already reads that screen's route key (it returns null outside a navigator, which is what the existing unit tests render):
extractNavigationKeys already walks nested navigators. That matters because the report route sits under RightModalNavigator, with Test Tools as a sibling navigator on top:
In MoneyRequestReportTransactionsNavigation.tsx, keep the list when that key is still anywhere in the root state. Keep the existing focused-screen check behind it, for #90366, where the route really is gone:
constrouteKey=useRouteKey();useEffect(()=>{return()=>{constrootState=navigationRef.getRootState();// A remount in place leaves this route in the stack, so the list still belongs to the expense on screen.if(routeKey&&extractNavigationKeys(rootState?.routes).has(routeKey)){return;}constfocusedRoute=findFocusedRoute(rootState);if(focusedRoute?.name&&(CAROUSEL_PRESERVING_SCREENSasreadonlystring[]).includes(focusedRoute.name)){return;}clearActiveTransactionIDs();};},[routeKey]);
This covers every screen that can cover the expense, not just Test Tools, and it covers any later remount of this component while the expense is still open. ReportHeader swaps between MoneyRequestHeader and MoneyReportHeader when isTransactionThreadView flips (lines 75 to 89), which unmounts this same component. A focused-route allowlist cannot name all of those.
A real exit still clears. On both native and web the route is already removed from navigationRef state before this cleanup runs, so the key is absent and the clear still happens. Going back to Home, closing the RHP, and paging to a sibling all behave as they do today. The only new case is an unmount whose own route is still mounted, which today can only be a remount.
I ran tests/unit/components/MoneyRequestReportTransactionsNavigation.test.tsx against this change. With the new tests and without the change: 1 failed, 31 passed, 32 total. The failing test is the remount under Test Tools, and it fails because clearActiveTransactionIDs is called once. With the change: 32 passed. The existing allowlist tests and the unrelated-screen test still pass, and a new case where Test Tools is focused but the expense route is already gone still clears.
What alternative solutions did you explore? (Optional)
Add TEST_TOOLS_MODAL.ROOT and TEST_TOOLS_MODAL.SERVER to CAROUSEL_PRESERVING_SCREENS. This is the smallest change for the recorded steps, and it is what the updated contributor-plus note asks to focus on. It leaves the bug in place for every other screen pushed over the expense (Description, the receipt, a confirm modal), and for a header remount that lands while any of those is in front. It also keeps the list whenever Test Tools is focused, even if the expense route has really been removed. The route-key check only skips the clear when the screen that owns the carousel is still there.
Always render ReportNotFoundInnerGuard for a transaction thread and pass the URL in as a flag, which is how PR [No QA] add two-level gate pattern to ReportNotFoundGuard #87559 gated this before 2e33a14422f. That removes this one remount, and the children stay mounted, so this repro goes away. It does not fix the cleanup, so the next in-place remount under a covering screen wipes the list again. It also mounts the inner guard's parent-report subscriptions for the whole time the URL is set, which is the cost that review change was avoiding. I would rather leave the guard alone.
Clear the URL when the deleted screen unmounts on native, reading the key at fire time because the pop can commit before Onyx.set reaches the component. That is a real bug on its own: after any delete on native, no report shows "not found" until some report screen blurs. It removes this repro's trigger. It does not help the next remount of the header under a covering screen, so it is a follow-up rather than this fix.
Keep the list whenever any CAROUSEL_PRESERVING_SCREENS name is still somewhere in the navigation tree. A preserving screen left in a background navigator would then block a clear that should happen. Checking this component's own route key does not have that problem.
What specific scenarios should we cover in automated tests?
In the existing clearing the carousel on unmount block of tests/unit/components/MoneyRequestReportTransactionsNavigation.test.tsx, with useRouteKey mocked and the root state shaped as RightModalNavigator > SEARCH_REPORT under a focused TestToolsModalNavigator > TEST_TOOLS_MODAL.ROOT:
Unmounting while the component's route key is still in that state does not call clearActiveTransactionIDs. This fails on main.
Unmounting after that route key has been removed, with Test Tools still focused, does call clearActiveTransactionIDs.
The four existing allowlist cases still keep the list, and unmounting onto an unrelated screen still clears it.
I reproduced it on Android with the issue's steps. The arrows disappear the moment Test Tools opens over the next expense, while still offline, before the delete syncs.
Before:
revert-android-2x.mp4
After (with the ReportNotFoundGuard change from the proposal), the arrows stay after Test Tools and after opening Description:
Please re-state the problem that we are trying to solve in this issue.
On Android/iOS offline, when user deletes an expense and then comes online, the navigation arrows (prev/next) in the expense RHP disappear after sync. User cannot navigate between expenses anymore, even though other expenses exist in the report.
What is the root cause of that problem?
The navigation arrows are controlled by Onyx key TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS which stores sibling transaction IDs. When expense is deleted offline, the optimistic delete removes ID from list. When it syncs online, the sync re-validates the list but does not restore the navigation context. The shouldUpdateLastSearchParams flag is not persisted, so background calls overwrite navigation context and arrows hide.
What changes do you think we should make in order to solve the problem?
Persist lastSearchParams explicitly when user selects an expense report row (onSelectRow) and when arrows prefetch more results (goToNextReport). Ensure offline delete sync calls dismiss and re-set TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS instead of clearing it. In MoneyRequestReportTransactionsNavigation.tsx, add check to keep navigation context if deleted ID was the current one, and re-calculate sibling IDs from reportTransactions after sync.
What alternative solutions did you explore? (Optional)
Considered forcing full report re-fetch on delete sync — too heavy. Considered hiding arrows permanently — wrong UX. Persisting params at select and prefetch points is same fix used in PR #88118 for similar arrows disappearing bug, low risk.
Please re-state the problem that we are trying to solve in this issue.
On Android and iOS, after deleting an expense from Home while offline and opening another expense from the same section, opening any overlay screen (such as Test Tools or an edit step) causes the prev/next navigation arrows in the expense header to disappear.
What is the root cause of that problem?
When the first expense is deleted offline, nvp_deleteTransactionNavigateBackURL is set. DeleteTransactionNavigateBackHandler (src/pages/inbox/DeleteTransactionNavigateBackHandler.tsx) only schedules clearing that NVP when its screen blurs and cancels the pending clear if the screen unmounts before the callback runs. Because the deleted expense screen unmounts on navigation back to Home, nvp_deleteTransactionNavigateBackURL remains set in Onyx.
When the user opens the next expense from Home (src/pages/home/RecentlyAddedSection/index.tsx), TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS is seeded with the sibling IDs. However, as soon as another screen is pushed on top of that expense while the stale nvp_deleteTransactionNavigateBackURL is still present, the navigation context cleanup triggers and clears TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS, hiding the prev/next arrows.
What changes do you think we should make in order to solve the problem?
In src/pages/inbox/DeleteTransactionNavigateBackHandler.tsx, ensure clearDeleteTransactionNavigateBackURL() is executed immediately when consuming the back-navigation target or on unmount after the navigation action is dispatched, rather than canceling the cleanup when the deleted report screen unmounts.
In src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx, keep the remaining valid sibling IDs in TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS when a single deleted transaction ID is removed instead of wiping the entire list.
What alternative solutions did you explore? (Optional)
Adding Test Tools routes to CAROUSEL_PRESERVING_SCREENS would only mask the issue for Test Tools while leaving the same bug reproducible when opening Description or attachment modals.
Please re-state the problem that we are trying to solve in this issue.
On Android/iOS, after an expense is deleted offline, the prev/next arrows on the next expense disappear as soon as a screen opens over it (Test Tools in the repro), and they don't come back.
What is the root cause of that problem?
When the stale delete-back URL clears, ReportNotFoundGuard changes its wrapper, and the header remounts in place. The old MoneyRequestReportTransactionsNavigation unmount effect sees the covering screen focused and calls clearActiveTransactionIDs() right away. Nothing reseeds the list afterward, so an in-place remount is treated as "the user left".
What changes do you think we should make in order to solve the problem?
Make the unmount clear deferred and cancellable:
On unmount, record the current getActiveTransactionIDs().ids reference and schedule the clear (TransitionTracker.runAfterTransitions / requestAnimationFrame) in a module-level pending handle.
A new mount of the component cancels the pending clear.
When the clear runs, it only clears if the list is still the same reference (nobody re-seeded it in the meantime), following the identity contract setActiveTransactionIDs already documents. This mirrors the pattern already used in TransactionThreadNavigation.ts.
Any remount from any trigger keeps the arrows, a real exit still clears, and ReportNotFoundGuard and the allowlist stay as they are.
What alternative solutions did you explore? (Optional)
Route-key check on unmount (depends on nav state at unmount time).
Stable guard tree (covers this trigger only).
Adding Test Tools to the preserving allowlist (narrow).
Tests
In MoneyRequestReportTransactionsNavigation.test.tsx: unmount then remount keeps the IDs; unmount with no remount clears them; a re-seed between unmount and flush is not wiped.
I'll open a clean PR with these unit tests and before/after videos for Android and iOS. Could I be assigned?
Please re-state the problem that we are trying to solve in this issue.
After deleting an expense offline from Home, opening the next expense and then toggling offline mode (or opening another overlay like Test Tools) causes the next/prev navigation arrows in the header to disappear.
What is the root cause of that problem?
When an expense is deleted offline from Home, nvp_deleteTransactionNavigateBackURL is set to manage back navigation. In DeleteTransactionNavigateBackHandler.tsx (lines 22–37), clearing this key is scheduled inside runAfterTransitions when the screen blurs. But navigating back to Home unmounts that screen, which cancels the transition task and leaves the NVP uncleared in Onyx.
When the user opens the next expense from Home, ReportNotFoundGuard.tsx (lines 127–131) sees that NVP still present, so it renders bare {children} without the <ReportNotFoundInnerGuard> wrapper.
Later, when the user opens Test Tools (or any other modal/step) over that expense, the expense screen blurs. That blur finally allows DeleteTransactionNavigateBackHandler to run and clear the leaked NVP. But once the NVP clears, ReportNotFoundGuard switches its render to <ReportNotFoundInnerGuard>{children}</ReportNotFoundInnerGuard>. Because the element wrapper type changes, React unmounts and remounts the whole report subtree, including MoneyRequestReportTransactionsNavigation.
During that unmount, line 211 of MoneyRequestReportTransactionsNavigation.tsx checks findFocusedRoute(navigationRef.getRootState()). At that moment, the focused route is the modal (SCREENS.TEST_TOOLS_MODAL.ROOT). Because Test Tools isn't in CAROUSEL_PRESERVING_SCREENS, the cleanup assumes the user navigated away from the expense and calls clearActiveTransactionIDs(). That wipes ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS, so when the modal closes, useCarouselTransactionIDs() gets an empty list and the arrows disappear.
What changes do you think we should make in order to solve the problem?
Instead of adding individual screens to CAROUSEL_PRESERVING_SCREENS (which breaks again whenever someone opens Description, receipt attachments, etc.), we should check if the expense screen itself is still in the navigation stack before clearing:
In MoneyRequestReportTransactionsNavigation.tsx:
Grab the current route key with const routeKey = useRoute().key.
In the unmount cleanup effect, check if this route is still present in the navigation state:
If the route key is still in rootState, it was just an in-place remount (like the guard swapping its wrapper), so we keep the transaction IDs. If the key is gone (user actually popped the screen), we fall through to the existing CAROUSEL_PRESERVING_SCREENS check for pop navigation (#90366) and clear as normal.
What specific scenarios should we cover in automated tests?
In tests/unit/components/MoneyRequestReportTransactionsNavigation.test.tsx under the unmount cleanup tests:
When an unrelated screen is focused but the component's routeKey is still present in navigationRef.getRootState(), unmounting should NOT call clearActiveTransactionIDs.
When the route key is absent from the navigation state and the focused screen is unrelated, unmounting SHOULD call clearActiveTransactionIDs.
What alternative solutions did you explore? (Optional)
Adding SCREENS.TEST_TOOLS_MODAL.ROOT to CAROUSEL_PRESERVING_SCREENS: This only patches the symptom when using Test Tools. Opening any other overlay (like editing description or opening attachments) triggers the exact same bug. Checking route presence is cleaner and covers all overlays without maintaining an allowlist.
Unconditionally mounting ReportNotFoundInnerGuard: Removes the remount trigger here, but pulling parent-action subscriptions into that early phase defeats why the guard was split in the first place, and leaves the cleanup's focused-route heuristic vulnerable to any future remounts.
📣 @nsunilkumarnani-sudo! 📣
Hey, it seems we don’t have your contributor details yet! You'll only have to do this once, and this is how we'll hire you on Upwork.
Please follow these steps:
Get the email address used to login to your Expensify account. If you don't already have an Expensify account, create one here. If you have multiple accounts (e.g. one for testing), please use your main account email.
Get the link to your Upwork profile. It's necessary because we only pay via Upwork. You can access it by logging in, and then clicking on your name. It'll look like this. If you don't already have an account, sign up for one here.
Copy the format below and paste it in a comment on this issue. Replace the placeholder text with your actual details.
Format:
Contributor details
Your Expensify account email: <REPLACE EMAIL HERE>
Upwork Profile Link: <REPLACE LINK HERE>
⚠️@nsunilkumarnani-sudo Thanks for your interest. To be considered for this job, please post a proposal following the proposal template (note the mandatory sections). Comments claiming the job without one are not reviewed.
Activity
applause-bot commentedon Oct 8, 2026
While Applause doesn't embed attachments for this bug, here are the links for quick view:
https://platform.applause.com/services/links/v1/external/991fcc884615f7f34162ec2bb59523f8cd4800b9fe29cc80ee67921d351a60e3
MelvinBot commentedon Oct 8, 2026
Proposal
What is the root cause of that problem?
The header remounts while the Test Tools modal covers the expense, and its unmount cleanup wipes the arrow list. The delete sync only coincides with this. Keep pending-delete expenses out of the Recently added carousel did not cause it, and the issue already reproduces in production.
nvp_deleteTransactionNavigateBackURLset. The native stack unmounts A's screen right away, which cancels the pending clear inDeleteTransactionNavigateBackHandler.tsx:22-37.ReportNotFoundGuard.tsx:127-131switches from barechildrento<ReportNotFoundInnerGuard>. React then remounts the whole header.MoneyRequestReportTransactionsNavigation.tsx:209-217seesTEST_TOOLS_MODAL.ROOTfocused. That screen is not inCAROUSEL_PRESERVING_SCREENS, so the cleanup callsclearActiveTransactionIDs().Web is not affected because the JS stack keeps the closing screen mounted through the animation, so step 1 clears the URL normally.
What changes do you think we should make in order to solve the problem?
In the unmount cleanup of
MoneyRequestReportTransactionsNavigation, skipclearActiveTransactionIDs()while the component's own route key (useRoute().key) is still in the navigation state. If the route is still there, the header remounted inside an open screen and the user did not leave. When the user really leaves, the route is gone from the state, so the list still clears. UseextractNavigationKeysfromextractNavigationKeys.tsfor the check.Add a unit test in
MoneyRequestReportTransactionsNavigation.test.tsxwith auseRoutemock. The test should expect no clear when the route is still in the state while Test Tools is focused.What alternative solutions did you explore? (Optional)
ReportNotFoundGuardfrom changing its root element type, for example by always rendering the inner guard with an enabled flag. This prevents the remount, but the inner guard was split out for performance.Confidence and verification
Confidence is medium. This comes from reading the code. It has not been tested on a device, and the web tester can't reproduce a native-only bug. Two predictions would confirm it on Android/iOS:
The hook itself still returns the right siblings after the sync (
useCarouselTransactionIDs.ts:26-47). The list goes empty only because the stored list was cleared.Next Steps for Contributor+ team:
To accept:
@MelvinBot implement [this](https://github.com/Expensify/App/issues/103403)to create a draft PR.To refine:
@MelvinBot <your feedback>To reject: Explain why you are rejecting Melvin's proposal.
view run
melvin-bot commentedon Oct 8, 2026
Triggered auto assignment to Contributor-plus team member for initial proposal review - @shubham1206agra (
External)[-]Android/iOS-Offline-Navigation arrows disappear after deleted expense syncs online[/-][+][$175] Android/iOS-Offline-Navigation arrows disappear after deleted expense syncs online[/+]melvin-bot commentedon Oct 8, 2026
Job added to Upwork: https://www.upwork.com/jobs/~022108002387844305757
jloa-dev commentedon Oct 8, 2026
Contributor details
Your Expensify account email: [email protected]
Upwork Profile Link: https://www.upwork.com/freelancers/~0163955a2ba22190aa
Proposal
Please re-state the problem that we are trying to solve in this issue.
In Android/iOS, after an expense is deleted while offline, opening an adjacent expense from the carousel and waiting for the offline delete to sync causes the navigation arrows in the header (
MoneyRequestReportTransactionsNavigation) to disappear.What is the root cause of that problem?
Unnecessary Component Remounting in
ReportNotFoundGuard.tsx:In
src/pages/inbox/ReportNotFoundGuard.tsx:127-131:When an expense is deleted offline,
deleteTransactionNavigateBackUrlis set in Onyx (ONYXKEYS.NVP_DELETE_TRANSACTION_NAVIGATE_BACK_URL). While it is set,ReportNotFoundGuardrenders{children}directly. When the delete syncs andDeleteTransactionNavigateBackHandlerclearsdeleteTransactionNavigateBackUrl,ReportNotFoundGuardswitches from bare{children}to<ReportNotFoundInnerGuard>{children}</ReportNotFoundInnerGuard>. Because the element type changes at the guard root, React unmounts the entire children tree (includingMoneyRequestHeaderandMoneyRequestReportTransactionsNavigation) and mounts a new one.Premature Clearing in
MoneyRequestReportTransactionsNavigation.tsx:In
src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx:209-217:When the unmount cleanup fires:
findFocusedRoutereturnsTEST_TOOLS_MODAL.ROOT(orSCREENS.RIGHT_MODAL.TEST_TOOLS), which is not inCAROUSEL_PRESERVING_SCREENS.focusedRoute.namefails to distinguish between the user actually navigating away from the screen versus a modal being focused or an internal remount where the screen route is still intact in the navigation state.clearActiveTransactionIDs()is called, wipingONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS. When the re-mountedMoneyRequestReportTransactionsNavigationevaluatesuseCarouselTransactionIDs(),transactionIDsListis empty, causing lines 426-429 to return early and hide the arrows.What changes do you think we should make in order to solve the problem?
Stabilize
ReportNotFoundGuard.tsxto prevent tree remounts:Instead of conditionally swapping between
{children}and<ReportNotFoundInnerGuard>{children}</ReportNotFoundInnerGuard>, always render<ReportNotFoundInnerGuard>for transaction threads and pass a condition (or check!deleteTransactionNavigateBackUrlinternally inReportNotFoundInnerGuardbefore determiningshouldShowNotFoundPage):In
ReportNotFoundInnerGuard, ifshouldCheckParentisfalse, setshouldShowNotFoundPage = false. This guarantees the component tree never unmounts due todeleteTransactionNavigateBackUrlupdates.Harden
MoneyRequestReportTransactionsNavigation.tsxunmount cleanup:In
src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx, check whether the component's route key (useRoute().key) is still in the navigation state before clearing:Additionally, include
SCREENS.RIGHT_MODAL.TEST_TOOLSinCAROUSEL_PRESERVING_SCREENS.What alternative solutions did you explore? (Optional)
CAROUSEL_PRESERVING_SCREENS: Does not solve cases where other overlays or transient state transitions cause a remount while the screen route remains alive. Combining route key verification withReportNotFoundGuardstabilization provides complete stability across both native and web platforms.What specific scenarios should we cover in automated tests?
tests/unit/components/MoneyRequestReportTransactionsNavigation.test.tsx:MoneyRequestReportTransactionsNavigationunmounts while its route key is still in navigation state (or while a modal is focused),clearActiveTransactionIDsis not called.tests/unit/ReportNotFoundGuardTest.tsx:deleteTransactionNavigateBackUrldoes not cause children to unmount and remount.melvin-bot commentedon Oct 8, 2026
✅ Contributor details stored successfully. Thank you for contributing to Expensify!
ashusatyarthi-wq commentedon Oct 8, 2026
Contributor details
Your Expensify account email: [email protected]
Upwork Profile Link: https://www.upwork.com/freelancers/~010e53044b5828fe60
Proposal
Please re-state the problem that we are trying to solve in this issue.
In Android/iOS, after an expense is deleted while offline, opening an adjacent expense from the carousel and waiting for the offline delete to sync causes the navigation arrows in the header (
MoneyRequestReportTransactionsNavigation) to disappear.What is the root cause of that problem?
The bug is caused by a race condition between a component tree remount in
ReportNotFoundGuardand premature cache clearing inMoneyRequestReportTransactionsNavigation:Tree Remount in
ReportNotFoundGuard.tsx:In
src/pages/inbox/ReportNotFoundGuard.tsx:127-131:On native platforms, deleting an expense leaves
deleteTransactionNavigateBackUrlset in Onyx because the native stack immediately unmounts the deleted expense screen, aborting the pending clear inDeleteTransactionNavigateBackHandler.When the user opens a sibling expense from the carousel,
deleteTransactionNavigateBackUrlis initially truthy, soReportNotFoundGuardrenders{children}directly. When the user opens Test Tools or the offline action syncs,DeleteTransactionNavigateBackHandlerclearsdeleteTransactionNavigateBackUrl. This causesReportNotFoundGuardto switch from bare{children}to<ReportNotFoundInnerGuard>{children}</ReportNotFoundInnerGuard>. Because the root wrapper component type changes, React completely unmounts the existing{children}tree (includingMoneyRequestHeaderandMoneyRequestReportTransactionsNavigation) and mounts a new one.Premature Cache Clearing in
MoneyRequestReportTransactionsNavigation.tsx:In
src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx:209-217:When the unmount cleanup fires during the tree remount:
findFocusedRouteinspects the top of the stack. If Test Tools or another modal is open, its name is not inCAROUSEL_PRESERVING_SCREENS.clearActiveTransactionIDs(), wipingONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS.MoneyRequestReportTransactionsNavigationevaluatesuseCarouselTransactionIDs(), the stored IDs list is empty, causing the component to return early and hide the arrows.What changes do you think we should make in order to solve the problem?
We should apply a two-layer fix to eliminate both the spurious tree remount and the unsafe unmount cleanup:
In
src/pages/inbox/ReportNotFoundGuard.tsx:Keep the component structure stable so
deleteTransactionNavigateBackUrlchanges do not unmount and remount{children}:Inside
ReportNotFoundInnerGuard, ifshouldCheckisfalse, bypass the parent report action check (settingshouldShowNotFoundPage = false). This guarantees{children}remains mounted without unmounting whendeleteTransactionNavigateBackUrlupdates.In
src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx:Harden the unmount cleanup so it verifies whether the screen's route key is still in the active navigation state using
extractNavigationKeys:What specific scenarios should we cover in automated tests?
tests/unit/ReportNotFoundGuardTest.tsx:deleteTransactionNavigateBackUrlfrom a URL tonulldoes not trigger an unmount/remount ofchildren.tests/unit/components/MoneyRequestReportTransactionsNavigation.test.tsx:MoneyRequestReportTransactionsNavigationunmounts while its route key exists inextractNavigationKeys,clearActiveTransactionIDsis not invoked.clearActiveTransactionIDsis called as expected.melvin-bot commentedon Oct 8, 2026
✅ Contributor details stored successfully. Thank you for contributing to Expensify!
bonnhatnguyen commentedon Oct 8, 2026
Proposal
Please re-state the problem that we are trying to solve in this issue.
On Android/iOS, when an expense is deleted while offline and the user opens an adjacent expense from the carousel, waiting for the delete to sync online causes the header navigation arrows to disappear.
What is the root cause of that problem?
The root cause consists of two intertwined issues in
ReportNotFoundGuardandMoneyRequestReportTransactionsNavigation:Unnecessary Component Remounting in
ReportNotFoundGuard.tsx:ReportNotFoundGuardconditionally wraps{children}in<ReportNotFoundInnerGuard>only when!deleteTransactionNavigateBackUrl && isReportTransactionThread(report). WhendeleteTransactionNavigateBackUrlis present, it renders bare{children}.When an offline delete finishes syncing and clears
deleteTransactionNavigateBackUrl, the JSX tree switches component types from{children}to<ReportNotFoundInnerGuard>{children}</ReportNotFoundInnerGuard>. In React's reconciliation engine, changing the wrapper component type destroys and remounts the entire subtree, unmountingMoneyRequestReportTransactionsNavigation.Premature Cache Eviction in
MoneyRequestReportTransactionsNavigation.tsx:During unmount, the cleanup effect in
MoneyRequestReportTransactionsNavigationchecksfindFocusedRoute. If a modal (such as Test Tools or offline sync overlay) is active, or during this transient remount, it concludes navigation has exited the screen and unconditionally invokesclearActiveTransactionIDs(), wipingONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS. When the component remounts, the carousel transaction ID list in Onyx is empty, causing the component to render nothing and hiding the navigation arrows.What changes do you think we should make in order to solve the problem?
In
src/pages/inbox/ReportNotFoundGuard.tsx:Stabilize the component hierarchy so that updating
deleteTransactionNavigateBackUrldoes not alter the wrapper component structure. Always renderReportNotFoundInnerGuardfor transaction threads and pass a boolean flag (e.g.shouldCheckParent={!deleteTransactionNavigateBackUrl}) intoReportNotFoundInnerGuard. WhenshouldCheckParentis false, bypass the parent report action check (settingshouldShowNotFoundPage = false). This prevents React from unmounting{children}whendeleteTransactionNavigateBackUrlupdates.In
src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx:Harden the unmount cleanup hook so it does not clear transaction IDs when the screen remains active in navigation state. Check whether the current screen's route key (
useRoute().key) still exists within navigation state (navigationRef.getRootState()) usingextractNavigationKeys. Only executeclearActiveTransactionIDs()when the route has truly been popped from navigation.What alternative solutions did you explore? (Optional)
CAROUSEL_PRESERVING_SCREENS: Adding individual modal screens does not prevent the full unmount/remount cycle inReportNotFoundGuard, leaving other overlays vulnerable to arrow loss. Stabilizing the component hierarchy at the source is the cleaner and more robust fix.What specific scenarios should we cover in automated tests?
tests/unit/ReportNotFoundGuardTest.tsx:deleteTransactionNavigateBackUrlfrom a string to undefined/null maintains component stability without unmounting children.tests/unit/components/MoneyRequestReportTransactionsNavigation.test.tsx:clearActiveTransactionIDs.clearActiveTransactionIDs.Contributor details
Your Expensify account email: [email protected]
Upwork Profile Link: https://www.upwork.com/freelancers/~0139ed4098bdc50182
24 remaining items
trasnake87 commentedon Oct 19, 2026
Proposal
What is the root cause of that problem?
Reading this as a Test Tools problem - and fixing it by adding the Test Tools screens to
CAROUSEL_PRESERVING_SCREENS- leaves the bug live. Nothing in the chain depends on which screen gets pushed, only that something is pushed over the open expense while the delete-back NVP is still set. Two checkable consequences: with the NVP leaked, opening expense B's Description row (Dynamic_Money_Request_Step_Description) or tapping its receipt (ReportAttachments) wipes the arrows the same way, and neither is - or should become - an allowlist entry; and no later carousel overwrites the wiped list, because nothing reseeds it while the user stays on B (step 4).nvp_deleteTransactionNavigateBackURL. The handler that clears it only schedules the clear while its own screen is blurred, and cancels the pending clear on unmount - so A's screen being destroyed on the way back to Home leaves the NVP set:App/src/pages/inbox/DeleteTransactionNavigateBackHandler.tsx
Lines 22 to 37 in adb2c02
ReportNotFoundGuardreturns barechildren. When B blurs under the pushed route, B's own handler clears the NVP and the guard swaps to<ReportNotFoundInnerGuard>{children}</ReportNotFoundInnerGuard>. The element type at that position changes, so React discards the entire report subtree -MoneyRequestHeaderand its carousel with it - and mounts a fresh one:App/src/pages/inbox/ReportNotFoundGuard.tsx
Lines 127 to 131 in adb2c02
App/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx
Lines 209 to 217 in adb2c02
CAROUSEL_PRESERVING_SCREENSwas built for the pop case the unit test documents (#90366): this route is gone and a screen below it in the RHP stack is now focused. The remount is a different event - B's route is still in the navigation state and still the screen the user is looking at, only its React subtree was replaced. A focused-route name cannot separate the two, so the cleanup runsclearActiveTransactionIDs()and wipes the list Home seeded.RecentlyAddedSectionwrites the carousel only inside its row-press handler, so there is no effect to re-run when the delete later syncs:App/src/pages/home/RecentlyAddedSection/index.tsx
Lines 92 to 97 in adb2c02
To reproduce with no sync and no Test Tools: offline, delete the newest Home expense, open the one under it, then open its Description row.
Step 1 is a teardown race - the clear is queued behind
runAfterTransitionsand the unmount cancels it - so whether the NVP leaks at all depends on how fast the closing screen is torn down. That is a stack-dependent outcome, matching the reporter ticking only the two native rows.What changes do you think we should make in order to solve the problem?
Make the cleanup confirm the carousel's own route is actually gone before concluding the user left it.
extractNavigationKeysalready does the recursive key walk, so this is a short-circuit in front of the existing check, inMoneyRequestReportTransactionsNavigation.tsx:This widens rather than removes: the pop case drops this route from the state first, so
CAROUSEL_PRESERVING_SCREENSstays and keeps covering #90366 - and no new entry is needed per screen that can be pushed over an expense. Keep the clear too - a leaked descriptor-backed carousel would permanently stop a report's own transaction list from seeding its arrows:App/src/components/MoneyRequestReportView/useMoneyRequestReportActiveTransactionIDs.ts
Lines 25 to 28 in adb2c02
It also matches the writer side of the same module, which already refuses to clear on unmount unless it still owns what it is clearing (
clearActiveTransactionIDsForSource) instead of reading the focused route.What specific scenarios should we cover in automated tests?
In the existing
clearing the carousel on unmountblock oftests/unit/components/MoneyRequestReportTransactionsNavigation.test.tsx: with an unrelated screen focused, unmounting must not callclearActiveTransactionIDswhile the component's route key is still present in the mocked root state, and must still call it when that key is absent - the pop case the existing tests cover.What alternative solutions did you explore?
Always mounting
ReportNotFoundInnerGuardand gating it with a prop. That removes this one remount trigger, but it needs a new prop just to keep the not-found page suppressed while the delete-back URL is set, pulls the parent-action subscriptions the guard was split to defer into that window, and leaves the cleanup's focused-route heuristic wrong for any other in-place teardown. Less coverage for more surface.Test branch: main...trasnake87:App:fix/103403
Regression test
tests/unit/components/MoneyRequestReportTransactionsNavigation.test.tsx, run withnpx jest tests/unit/components/MoneyRequestReportTransactionsNavigation.test.tsx:Tests: 1 failed, 30 passed, 31 totalTests: 31 passed, 31 totaltrasnake87 commentedon Oct 19, 2026
Proposal updated: added a test branch.
neerajbachani commentedon Oct 19, 2026
Proposal
Please re-state the problem that we are trying to solve in this issue.
On Android and iOS, after you delete one expense from Home's Recently added section and open the expense under it, the prev/next arrows disappear the first time anything opens on top of that expense. In the reported steps that something is Test Tools, used to turn Force offline off. The arrows should stay, and they should still move between the expenses that were not deleted. They stay gone until the expense is opened from Home again.
What is the root cause of that problem?
The delete sync does not hide the arrows. Opening Test Tools does, and it does it while the app is still offline. The arrows are already gone in the frame the modal starts to open. Going back online only happens to be the next step in the report.
Home seeds one global sibling list when the row is pressed, then opens the expense:
App/src/pages/home/RecentlyAddedSection/index.tsx
Lines 92 to 97 in adb2c02
Nothing else writes that list while you stay on the expense. Filtering out the deleted expense still leaves the other siblings, so two or more entries remain and the arrows should keep rendering:
App/src/hooks/useCarouselTransactionIDs.ts
Lines 26 to 43 in adb2c02
The list goes empty because the header remounts and its unmount cleanup wipes it. Four steps:
nvp_deleteTransactionNavigateBackURL, then pops the screen. The only clear waits for that screen to render unfocused, then runs after the transition:App/src/pages/inbox/DeleteTransactionNavigateBackHandler.tsx
Lines 22 to 37 in adb2c02
On native that unfocused render never happens.
native-stack7.19.2 only mountsstate.routes(plus preloaded routes), so a popped screen leaves the tree in the same commit that removes it from state. It never renders withisFocused === false, the effect returns on its last focused render, and the URL stays set:https://github.com/react-navigation/react-navigation/blob/%40react-navigation/native-stack%407.19.2/packages/native-stack/src/views/NativeStackView.native.tsx#L533
Web is unaffected because the JS stack keeps the closing screen mounted through the animation, so it does render unfocused and the clear runs. Home is not a report screen, so nothing clears the URL there either. The next expense therefore mounts with the URL still set. The same blur effect is copied in
SearchMoneyRequestReportPage.tsx(lines 105 to 120), so a delete from the super-wide expense report leaks the same way.ReportNotFoundGuardreturns its children directly. The first time that expense blurs, its own handler clears the URL, and the guard wraps the same children inReportNotFoundInnerGuard. The element type at that spot changes, so React throws away the whole report subtree, header included, and mounts a new one:App/src/pages/inbox/ReportNotFoundGuard.tsx
Lines 127 to 131 in adb2c02
That condition used to live inside the inner guard, so toggling the URL only changed a boolean. PR #87559's review follow-up (
2e33a14422f) moved it onto the outerifso the inner guard would not subscribe while the URL was set. That is what turns a URL change into a remount. Test Tools is just the first blur in this repro. Opening Description, the receipt, or any other screen on top does the same thing.App/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx
Lines 38 to 43 in adb2c02
App/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx
Lines 209 to 217 in adb2c02
CAROUSEL_PRESERVING_SCREENSwas added for the pop case in #90366: this route is gone, and a screen under it in the RHP is now focused, so the list must survive. A remount is a different event. The expense route is still in the stack. With Test Tools open,findFocusedRoutereturnsTEST_TOOLS_MODAL.ROOT, which is not in the list, so the cleanup callsclearActiveTransactionIDs().App/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx
Lines 432 to 437 in adb2c02
The same component is what
MoneyReportHeaderrenders for a report-level carousel, so a cleared list drops those arrows too.What changes do you think we should make in order to solve the problem?
Make the cleanup check that this screen has actually left before it clears the list. The carousel already sits inside the report screen, and
useRouteKeyalready reads that screen's route key (it returns null outside a navigator, which is what the existing unit tests render):App/src/hooks/useRouteKey.ts
Lines 4 to 7 in adb2c02
extractNavigationKeysalready walks nested navigators. That matters because the report route sits underRightModalNavigator, with Test Tools as a sibling navigator on top:App/src/libs/Navigation/helpers/extractNavigationKeys.ts
Lines 29 to 33 in adb2c02
In
MoneyRequestReportTransactionsNavigation.tsx, keep the list when that key is still anywhere in the root state. Keep the existing focused-screen check behind it, for #90366, where the route really is gone:This covers every screen that can cover the expense, not just Test Tools, and it covers any later remount of this component while the expense is still open.
ReportHeaderswaps betweenMoneyRequestHeaderandMoneyReportHeaderwhenisTransactionThreadViewflips (lines 75 to 89), which unmounts this same component. A focused-route allowlist cannot name all of those.A real exit still clears. On both native and web the route is already removed from
navigationRefstate before this cleanup runs, so the key is absent and the clear still happens. Going back to Home, closing the RHP, and paging to a sibling all behave as they do today. The only new case is an unmount whose own route is still mounted, which today can only be a remount.I ran
tests/unit/components/MoneyRequestReportTransactionsNavigation.test.tsxagainst this change. With the new tests and without the change: 1 failed, 31 passed, 32 total. The failing test is the remount under Test Tools, and it fails becauseclearActiveTransactionIDsis called once. With the change: 32 passed. The existing allowlist tests and the unrelated-screen test still pass, and a new case where Test Tools is focused but the expense route is already gone still clears.What alternative solutions did you explore? (Optional)
Add
TEST_TOOLS_MODAL.ROOTandTEST_TOOLS_MODAL.SERVERtoCAROUSEL_PRESERVING_SCREENS. This is the smallest change for the recorded steps, and it is what the updated contributor-plus note asks to focus on. It leaves the bug in place for every other screen pushed over the expense (Description, the receipt, a confirm modal), and for a header remount that lands while any of those is in front. It also keeps the list whenever Test Tools is focused, even if the expense route has really been removed. The route-key check only skips the clear when the screen that owns the carousel is still there.Always render
ReportNotFoundInnerGuardfor a transaction thread and pass the URL in as a flag, which is how PR [No QA] add two-level gate pattern to ReportNotFoundGuard #87559 gated this before2e33a14422f. That removes this one remount, and the children stay mounted, so this repro goes away. It does not fix the cleanup, so the next in-place remount under a covering screen wipes the list again. It also mounts the inner guard's parent-report subscriptions for the whole time the URL is set, which is the cost that review change was avoiding. I would rather leave the guard alone.Clear the URL when the deleted screen unmounts on native, reading the key at fire time because the pop can commit before
Onyx.setreaches the component. That is a real bug on its own: after any delete on native, no report shows "not found" until some report screen blurs. It removes this repro's trigger. It does not help the next remount of the header under a covering screen, so it is a follow-up rather than this fix.Keep the list whenever any
CAROUSEL_PRESERVING_SCREENSname is still somewhere in the navigation tree. A preserving screen left in a background navigator would then block a clear that should happen. Checking this component's own route key does not have that problem.What specific scenarios should we cover in automated tests?
In the existing
clearing the carousel on unmountblock oftests/unit/components/MoneyRequestReportTransactionsNavigation.test.tsx, withuseRouteKeymocked and the root state shaped asRightModalNavigator > SEARCH_REPORTunder a focusedTestToolsModalNavigator > TEST_TOOLS_MODAL.ROOT:clearActiveTransactionIDs. This fails on main.clearActiveTransactionIDs.Video demo
Screen.Recording.2026-10-19.at.11.46.14.AM.online-video-cutter.com.mp4
mukhrr commentedon Oct 19, 2026
added before/after videos in proposal
yusufdeveloper2903 commentedon Oct 19, 2026
@shubham1206agra
Adding the Android videos for my proposal here: #103403 (comment)
I reproduced it on Android with the issue's steps. The arrows disappear the moment Test Tools opens over the next expense, while still offline, before the delete syncs.
Before:
revert-android-2x.mp4
After (with the
ReportNotFoundGuardchange from the proposal), the arrows stay after Test Tools and after opening Description:after-android-3x.mp4
lopezpope commentedon Oct 19, 2026
Proposal
Please re-state the problem that we are trying to solve in this issue.
On Android/iOS offline, when user deletes an expense and then comes online, the navigation arrows (prev/next) in the expense RHP disappear after sync. User cannot navigate between expenses anymore, even though other expenses exist in the report.
What is the root cause of that problem?
The navigation arrows are controlled by Onyx key TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS which stores sibling transaction IDs. When expense is deleted offline, the optimistic delete removes ID from list. When it syncs online, the sync re-validates the list but does not restore the navigation context. The shouldUpdateLastSearchParams flag is not persisted, so background calls overwrite navigation context and arrows hide.
What changes do you think we should make in order to solve the problem?
Persist lastSearchParams explicitly when user selects an expense report row (onSelectRow) and when arrows prefetch more results (goToNextReport). Ensure offline delete sync calls dismiss and re-set TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS instead of clearing it. In MoneyRequestReportTransactionsNavigation.tsx, add check to keep navigation context if deleted ID was the current one, and re-calculate sibling IDs from reportTransactions after sync.
What alternative solutions did you explore? (Optional)
Considered forcing full report re-fetch on delete sync — too heavy. Considered hiding arrows permanently — wrong UX. Persisting params at select and prefetch points is same fix used in PR #88118 for similar arrows disappearing bug, low risk.
open5588 commentedon Oct 19, 2026
Proposal
Please re-state the problem that we are trying to solve in this issue.
On Android and iOS, after deleting an expense from Home while offline and opening another expense from the same section, opening any overlay screen (such as Test Tools or an edit step) causes the prev/next navigation arrows in the expense header to disappear.
What is the root cause of that problem?
nvp_deleteTransactionNavigateBackURLis set.DeleteTransactionNavigateBackHandler(src/pages/inbox/DeleteTransactionNavigateBackHandler.tsx) only schedules clearing that NVP when its screen blurs and cancels the pending clear if the screen unmounts before the callback runs. Because the deleted expense screen unmounts on navigation back to Home,nvp_deleteTransactionNavigateBackURLremains set in Onyx.src/pages/home/RecentlyAddedSection/index.tsx),TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDSis seeded with the sibling IDs. However, as soon as another screen is pushed on top of that expense while the stalenvp_deleteTransactionNavigateBackURLis still present, the navigation context cleanup triggers and clearsTRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS, hiding the prev/next arrows.What changes do you think we should make in order to solve the problem?
src/pages/inbox/DeleteTransactionNavigateBackHandler.tsx, ensureclearDeleteTransactionNavigateBackURL()is executed immediately when consuming the back-navigation target or on unmount after the navigation action is dispatched, rather than canceling the cleanup when the deleted report screen unmounts.src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx, keep the remaining valid sibling IDs inTRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDSwhen a single deleted transaction ID is removed instead of wiping the entire list.What alternative solutions did you explore? (Optional)
Adding Test Tools routes to
CAROUSEL_PRESERVING_SCREENSwould only mask the issue for Test Tools while leaving the same bug reproducible when opening Description or attachment modals.Contributor details
Your Expensify account email: [email protected]
Upwork Profile Link: https://www.upwork.com/freelancers/~0159c00273dd3bd644
melvin-bot commentedon Oct 19, 2026
✅ Contributor details stored successfully. Thank you for contributing to Expensify!
Mannerless commentedon Oct 19, 2026
Proposal
Please re-state the problem that we are trying to solve in this issue.
On Android/iOS, after an expense is deleted offline, the prev/next arrows on the next expense disappear as soon as a screen opens over it (Test Tools in the repro), and they don't come back.
What is the root cause of that problem?
When the stale delete-back URL clears,
ReportNotFoundGuardchanges its wrapper, and the header remounts in place. The oldMoneyRequestReportTransactionsNavigationunmount effect sees the covering screen focused and callsclearActiveTransactionIDs()right away. Nothing reseeds the list afterward, so an in-place remount is treated as "the user left".What changes do you think we should make in order to solve the problem?
Make the unmount clear deferred and cancellable:
getActiveTransactionIDs().idsreference and schedule the clear (TransitionTracker.runAfterTransitions/requestAnimationFrame) in a module-level pending handle.setActiveTransactionIDsalready documents. This mirrors the pattern already used inTransactionThreadNavigation.ts.Any remount from any trigger keeps the arrows, a real exit still clears, and
ReportNotFoundGuardand the allowlist stay as they are.What alternative solutions did you explore? (Optional)
Tests
In
MoneyRequestReportTransactionsNavigation.test.tsx: unmount then remount keeps the IDs; unmount with no remount clears them; a re-seed between unmount and flush is not wiped.I'll open a clean PR with these unit tests and before/after videos for Android and iOS. Could I be assigned?
Contributor details
Your Expensify account email: [email protected]
Upwork Profile Link: https://www.upwork.com/freelancers/~0124daa6abc73d9d18
melvin-bot commentedon Oct 19, 2026
✅ Contributor details stored successfully. Thank you for contributing to Expensify!
nsunilkumarnani-sudo commentedon Oct 20, 2026
Proposal
Please re-state the problem that we are trying to solve in this issue.
After deleting an expense offline from Home, opening the next expense and then toggling offline mode (or opening another overlay like Test Tools) causes the next/prev navigation arrows in the header to disappear.
What is the root cause of that problem?
When an expense is deleted offline from Home,
nvp_deleteTransactionNavigateBackURLis set to manage back navigation. InDeleteTransactionNavigateBackHandler.tsx(lines 22–37), clearing this key is scheduled insiderunAfterTransitionswhen the screen blurs. But navigating back to Home unmounts that screen, which cancels the transition task and leaves the NVP uncleared in Onyx.When the user opens the next expense from Home,
ReportNotFoundGuard.tsx(lines 127–131) sees that NVP still present, so it renders bare{children}without the<ReportNotFoundInnerGuard>wrapper.Later, when the user opens Test Tools (or any other modal/step) over that expense, the expense screen blurs. That blur finally allows
DeleteTransactionNavigateBackHandlerto run and clear the leaked NVP. But once the NVP clears,ReportNotFoundGuardswitches its render to<ReportNotFoundInnerGuard>{children}</ReportNotFoundInnerGuard>. Because the element wrapper type changes, React unmounts and remounts the whole report subtree, includingMoneyRequestReportTransactionsNavigation.During that unmount, line 211 of
MoneyRequestReportTransactionsNavigation.tsxchecksfindFocusedRoute(navigationRef.getRootState()). At that moment, the focused route is the modal (SCREENS.TEST_TOOLS_MODAL.ROOT). Because Test Tools isn't inCAROUSEL_PRESERVING_SCREENS, the cleanup assumes the user navigated away from the expense and callsclearActiveTransactionIDs(). That wipesONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS, so when the modal closes,useCarouselTransactionIDs()gets an empty list and the arrows disappear.What changes do you think we should make in order to solve the problem?
Instead of adding individual screens to
CAROUSEL_PRESERVING_SCREENS(which breaks again whenever someone opens Description, receipt attachments, etc.), we should check if the expense screen itself is still in the navigation stack before clearing:MoneyRequestReportTransactionsNavigation.tsx:Grab the current route key with
const routeKey = useRoute().key.In the unmount cleanup effect, check if this route is still present in the navigation state:
If the route key is still in
rootState, it was just an in-place remount (like the guard swapping its wrapper), so we keep the transaction IDs. If the key is gone (user actually popped the screen), we fall through to the existingCAROUSEL_PRESERVING_SCREENScheck for pop navigation (#90366) and clear as normal.What specific scenarios should we cover in automated tests?
In
tests/unit/components/MoneyRequestReportTransactionsNavigation.test.tsxunder the unmount cleanup tests:routeKeyis still present innavigationRef.getRootState(), unmounting should NOT callclearActiveTransactionIDs.clearActiveTransactionIDs.What alternative solutions did you explore? (Optional)
SCREENS.TEST_TOOLS_MODAL.ROOTtoCAROUSEL_PRESERVING_SCREENS: This only patches the symptom when using Test Tools. Opening any other overlay (like editing description or opening attachments) triggers the exact same bug. Checking route presence is cleaner and covers all overlays without maintaining an allowlist.ReportNotFoundInnerGuard: Removes the remount trigger here, but pulling parent-action subscriptions into that early phase defeats why the guard was split in the first place, and leaves the cleanup's focused-route heuristic vulnerable to any future remounts.melvin-bot commentedon Oct 20, 2026
📣 @nsunilkumarnani-sudo! 📣
Hey, it seems we don’t have your contributor details yet! You'll only have to do this once, and this is how we'll hire you on Upwork.
Please follow these steps:
Format:
nsunilkumarnani-sudo commentedon Oct 20, 2026
melvin-bot commentedon Oct 20, 2026
github-actions commentedon Oct 20, 2026