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
[Due for payment 2026-06-17] [$500] Drag-and-Drop SmartScan Shows Incorrect Flow Order (Confirm Details First, Then Create Expense) #89800
Version Number: 9.3.67-11 Reproducible in staging?: Y Reproducible in production?: Y If this was caught during regression testing, add the test name, ID and link from BrowserStack: Email or phone of affected tester (no customers): Logs:https://stackoverflow.com/c/expensify/questions/4856 Expensify/Expensify Issue URL: Issue reported by:@coleaeason Slack conversation (hyperlinked to channel name): #Expensify QA
Action Performed:
Open New Expensify (Web)
Navigate to a Workspace chat
Drag and drop a receipt file into the Scan section
Observe the screen shown, Complete the flow
Drag and drop another receipt file
Observe the screen again
Expected Result:
Follow normal SmartScan flow → Confirm Details after every drop
Actual Result:
First drop:
Confirm Details screen appears
Second drop:
Create Expense screen appears
Workaround:
Unknown
Platforms:
Select the officially supported platforms where the issue was reproduced:
Race condition between async Onyx.set and useOnyx's loading semantics on the second drag-and-drop.
All receipt-drop flows share a single hardcoded draft key TRANSACTION_DRAFT['1'] (CONST.OPTIMISTIC_TRANSACTION_ID). When a receipt is dropped, useReceiptDrop calls initMoneyRequest (which fires an asyncOnyx.set at IOU/index.ts:682) then synchronously navigates to the confirmation page.
On the confirmation page, MoneyRequestInitializer checks isLoadingTransaction — if true, it waits; if false and the transaction is null, it calls startMoneyRequest which redirects to the "Create Expense" page.
First drop:useOnyx has never read TRANSACTION_DRAFT['1'], so isLoadingTransaction = true on mount → MoneyRequestInitializer waits → Onyx.set resolves → transaction populates → "Confirm Details" shows correctly.
Second drop: After the first expense completes, the draft is cleaned up (set to null). Now useOnyx has already resolved this key, so on the second mount isLoadingTransaction = false with value = null — even though the new Onyx.set is still pending. MoneyRequestInitializer sees null transaction → falls through → calls startMoneyRequest which clears the draft and navigates to "Create Expense".
What changes do you think we should make in order to solve the problem?
initMoneyRequest should await the Onyx.set call before navigation occurs. In useReceiptDrop.ts, make onFilesValidated async and await initMoneyRequest. Similarly, make initMoneyRequest in IOU/index.ts:682 return the Onyx.set promise (it already does, since Onyx.set returns a promise — just needs await at the call site). This ensures TRANSACTION_DRAFT['1'] is populated before the navigation to the confirmation page, regardless of useOnyx's loading state.
What alternative solutions did you explore? (Optional)
An alternative is to make MoneyRequestInitializer more defensive — e.g., add a short delay or a "pending" flag passed via route params to indicate the transaction draft is being written. However, this is a workaround; the cleaner fix is ensuring the data is written before navigating to the page that reads it.
Related issues
Expensify/App#86671 — Bulk receipt upload via SmartScan creates partial expenses (related to the same OPTIMISTIC_TRANSACTION_ID shared key and multi-file drop handling)
Next Steps for Contributor+ team: Reply with @MelvinBot implement this to create a draft PR, @MelvinBot <your feedback> to refine this analysis, or explain why you are rejecting Melvin's proposal.
Please re-state the problem that we are trying to solve in this issue.
When a user drags and drops a receipt onto the SmartScan section of a workspace chat, the first drop correctly shows the Confirm Details screen. Every subsequent drop on the same page load shows the "Create Expense" screen instead, breaking the expected SmartScan flow.
What is the root cause of that problem?
The bug is a sequencing problem between clearMoneyRequest nullifying the OPTIMISTIC_TRANSACTION_ID draft and the async Onyx.set() in initMoneyRequest racing against the Confirmation screen mounting.
After the first drag-and-drop completes (whether the user submits the expense or cancels), clearMoneyRequest is invoked. Inside clearMoneyRequest, getRemoveDraftTransactionsByIDsData(draftTransactionIDs) is called without shouldExcludeInitialTransaction = true, so OPTIMISTIC_TRANSACTION_ID is included in the multi-set and explicitly set to null in Onyx. This is intentional cleanup, but it leaves the draft key present in Onyx with a null value rather than absent/undefined.
On the second drop, useReceiptDrop.onFilesValidated calls initMoneyRequest, which schedules an async Onyx.set() to write the new transaction draft, then synchronously calls Navigation.navigate to MONEY_REQUEST_STEP_CONFIRMATION. The Confirmation screen mounts before the async Onyx write completes. Inside IOURequestStepConfirmation, the MoneyRequestInitializer component runs its useEffect (which depends only on isLoadingTransaction) immediately. Because the draft key exists in Onyx as null (not undefined/unhydrated), isLoadingTransaction is false from the start and transaction is null. The effect condition evaluates to false, so startMoneyRequest is called without a requestType, which navigates to ROUTES.MONEY_REQUEST_CREATE — the generic "Create Expense" screen — overriding the intended Confirmation destination.
The first drop works correctly because at that point the draft key does not exist in Onyx yet (or its loading state is true during initial hydration), so MoneyRequestInitializer waits until the draft arrives before acting.
// When starting to create an expense from the global FAB, If there is not an existing report yet, a random optimistic reportID is generated and used
// for all of the routes in the creation flow.
reportID??generateReportID(),
draftTransactionIDs,
);
// eslint-disable-next-line react-hooks/exhaustive-deps -- we don't want this effect to run again
},[isLoadingTransaction]);
What changes do you think we should make in order to solve the problem?
The fix should ensure that when navigating to the Confirmation screen from the drag-and-drop path, the transaction draft is already written to Onyx before the screen mounts and MoneyRequestInitializer evaluates. The most targeted change is in useReceiptDrop.ts: instead of calling Navigation.navigate synchronously after initMoneyRequest, the navigation should happen inside a .then() callback after the Onyx write that initMoneyRequest schedules. Since Onyx.set() returns a Promise, initMoneyRequest can be updated to return that Promise (or a separate helper can await Onyx.set directly before navigating). This guarantees the draft exists by the time Confirmation mounts.
An alternative is to change clearMoneyRequest in src/libs/actions/IOU/index.ts to pass shouldExcludeInitialTransaction = true when calling getRemoveDraftTransactionsByIDsData, so that the OPTIMISTIC_TRANSACTION_ID draft is never set to null — it is only ever overwritten by initMoneyRequest. This removes the window where the draft is null rather than loading, which is the exact condition that causes MoneyRequestInitializer to fire prematurely. The OPTIMISTIC_TRANSACTION_ID draft is always rewritten by initMoneyRequest at the start of every create flow anyway, so preserving the previous stale value between flows has no negative effect on the first-time render.
The second approach is the simpler one-line change and does not require making initMoneyRequest asynchronous, but care should be taken to confirm that no caller of clearMoneyRequest relies on the OPTIMISTIC_TRANSACTION_ID draft being explicitly nulled (for example, the share tab context in ShareTabParticipantsSelector). If any caller does, that specific call site can pass an extra flag rather than changing the default.
The workspace-chat receipt drop handler decides between "create a new SmartScan expense" and "replace the existing receipt" based on useShouldAddOrReplaceReceipt, which flips after the first drop completes.
useShouldAddOrReplaceReceipt looks at the dropped-on report's transactions and returns shouldAddOrReplaceReceipt: true when the report has exactly one transaction (isSingleTransactionView):
First drop on the workspace chat (zero transactions).reportTransactions.length === 0, so isSingleTransactionView is false and shouldAddOrReplaceReceipt is false. useReceiptDrop.onFilesValidated takes the create new expense branch and navigates to MONEY_REQUEST_STEP_CONFIRMATION (the Confirm Details screen). ✅
After the user completes the confirmation, the workspace chat now contains exactly one expense.
Second drop on the workspace chat.reportTransactions.length === 1, so isSingleTransactionView flips to true → shouldAddOrReplaceReceipt becomes true, and transactionID resolves to the previously-created expense's transaction. useReceiptDrop.onReceiptDropped and onFilesValidated then take the replace receipt branch:
So the second drop never starts a fresh SmartScan flow — it overwrites the receipt on the existing expense and lands the user on that expense's RHP ("Create Expense"-style view of the existing transaction) instead of the Confirm Details flow.
The "single transaction view" treatment is correct for transaction-thread reports (where the screen really is showing one expense, e.g. RHP), but a workspace chat (policy expense chat) is a parent chat — receipt drops there should always start a new expense, regardless of how many expenses currently live under it.
What changes do you think we should make in order to solve the problem?
I'd gate the isSingleTransactionView branch so it only applies on actual single-expense screens (transaction threads / IOU / Expense reports), not on policy expense chats. The simplest fix is in useShouldAddOrReplaceReceipt:
(import isPolicyExpenseChat from @libs/ReportUtils.)
After the fix:
1st drop on workspace chat → shouldAddOrReplaceReceipt = false → Confirm Details. ✅
2nd drop on workspace chat → still shouldAddOrReplaceReceipt = false (because isPolicyExpenseChat is true) → Confirm Details. ✅
Drop on a real single-transaction-view report (RHP/transaction thread) → still replaces the receipt as today. ✅
What alternative solutions did you explore? (Optional)
Force the new-expense branch directly in useReceiptDrop.onReceiptDropped/onFilesValidated by ignoring shouldAddOrReplaceReceipt when the drop target is a policy expense chat. This works but spreads the workspace-chat assumption across two files; gating it once at the source (useShouldAddOrReplaceReceipt) keeps the logic centralized and also fixes the DualDropZone UI label, which currently switches the SmartScan tile to "Replace receipt" in this same scenario.
Compute isSingleTransactionView from the navigation route name instead of the report type — but the hook is already report-aware, so the report-type check is a smaller change with the same effect.
changed the title [-]Drag-and-Drop SmartScan Shows Incorrect Flow Order (Confirm Details First, Then Create Expense)[/-][+][$250] Drag-and-Drop SmartScan Shows Incorrect Flow Order (Confirm Details First, Then Create Expense)[/+]on May 6, 2026
🚨 Edited by proposal-police: This proposal was edited at 2026-05-06 19:05:23 UTC.
Replaces my earlier proposal. The prior root cause framed this as only the participant auto-assignment merge. Current code and the issue media show the failing second drop is the composer new-receipt path racing the confirmation screen against the whole draft-write sequence (Onyx.set plus receipt and participant merges), so the root cause and alternatives are corrected while keeping the same local fix boundary.
Proposal
Please re-state the problem that we are trying to solve in this issue.
When a receipt is dragged into a workspace chat's Scan receipts drop zone on desktop web, the first dropped receipt opens Confirm details. After that expense is completed, dropping another receipt into the same Scan receipts drop zone can land on the generic Create expense Scan tab instead of opening Confirm details for the newly dropped SmartScan receipt.
What is the root cause of that problem?
The failing path is the composer new-receipt branch, not the replace-receipt branch. The media shows the second drag targets the visible Scan receipts drop zone, then the app opens /create/submit/start/.../scan with the Create expense title and Upload receipts empty state.
In useReceiptDrop, the new-receipt branch initializes the draft, writes the receipt, starts participant auto-assignment, and then immediately navigates to confirmation without waiting for those Onyx writes to be visible:
The receipt and participant updates are also Onyx merges; setMoneyRequestParticipantsFromReport() is the final merge in this sequence and returns the promise for that write:
On the second drop, that draft key has already been loaded and cleaned, so the confirmation screen can mount with isLoadingTransaction=false before the new draft write sequence has reached Onyx. MoneyRequestInitializer then sees no valid loaded transaction and calls startMoneyRequest():
// When starting to create an expense from the global FAB, If there is not an existing report yet, a random optimistic reportID is generated and used
// for all of the routes in the creation flow.
reportID??generateReportID(),
draftTransactionIDs,
);
// eslint-disable-next-line react-hooks/exhaustive-deps -- we don't want this effect to run again
},[isLoadingTransaction]);
Because that restart does not pass a request type, startMoneyRequest() falls through to the default MONEY_REQUEST_CREATE route instead of the scan confirmation route:
What changes do you think we should make in order to solve the problem?
In src/pages/inbox/report/ReportActionCompose/useReceiptDrop.ts, keep the existing replace-receipt branch unchanged. In the new-receipt onFilesValidated path, collect the promises returned by setMoneyRequestParticipantsFromReport() for every dropped receipt and move the existing Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION...) call into Promise.all(setParticipantsPromises).then(...).
This waits for the final Onyx merge in the draft-write sequence before the confirmation screen mounts, so MoneyRequestInitializer receives a loaded draft transaction instead of the previously cleaned null state. It also preserves the synchronous return value of initMoneyRequest(), which this path uses as the base transaction object.
This matches the current project pattern. useReceiptScanDrop() writes receipts, collects setMoneyRequestParticipantsFromReport() promises, waits for Promise.all(...), and only then navigates to confirmation:
Scope boundaries: touch src/pages/inbox/report/ReportActionCompose/useReceiptDrop.ts only, in the new-receipt onFilesValidated path around lines 52-85. Platforms affected: desktop web drag/drop in report/workspace chat composer drop zones. Do not change the replace-receipt branch, useShouldAddOrReplaceReceipt, global Search/Home receipt drop, native camera flows, scan-step upload UI, API contracts, or backend behavior.
What alternative solutions did you explore? (Optional)
MelvinBot and MobileMage also point at the draft-write/confirmation race. I agree with that mechanism, but I would not make initMoneyRequest() async globally because current callers use its synchronous draft object immediately. Waiting at the composer call site on the existing participant-merge promise is narrower and matches the existing scan-drop patterns above.
I ruled out the replace-receipt explanation from yusufdeveloper2903 for the captured failure. If shouldAddOrReplaceReceipt were true, DualDropZone would pass the single-receipt mode and show the add/replace receipt copy instead of the Scan receipts copy; the media shows Scan receipts, and the failing navigation happens after the new-receipt branch, not the replaceReceipt() branch.
I also ruled out the later global-drop proposal from aswin-s because this issue's repro and media use the workspace chat composer drop zone, while useReceiptScanDrop() is the Search/Home global drop hook and already waits for participant writes before navigating.
Changing MoneyRequestInitializer to pass CONST.IOU.REQUEST_TYPE.SCAN when it restarts would only change the fallback after confirmation has already mounted with a missing draft. The root fix is to satisfy confirmation's existing contract by waiting for the composer drop path's draft writes before navigating there.
Please re-state the problem that we are trying to solve in this issue.
When a user drags and drops a receipt file onto the SmartScan drop zone while having an active paid group policy with expense chat enabled, the app navigates directly to the Confirm Details screen, skipping the Create Expense (Participants) screen entirely. On other policies, the same drag-and-drop action correctly shows the Create Expense screen first. This inconsistency breaks the expected flow and prevents users from modifying participants or policy assignment before confirming the expense.
What is the root cause of that problem?
The bug originates in src/hooks/useReceiptScanDrop.tsx in the saveFileAndInitMoneyRequest function (lines 84–101). When a paid group policy with expense chat is active and the user is not billing-restricted, the code bypasses the participants step entirely by calling Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(...)) directly. This creates an inconsistent navigation stack compared to the else branch (lines 102–103), which correctly calls navigateToParticipantPage(CONST.IOU.TYPE.CREATE, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, newReportID).
The working analog is src/libs/actions/IOU/MoneyRequest.ts in the handleMoneyRequestStepScanParticipants function, which demonstrates the correct pattern: navigate to the participants step first, let it handle policy-aware auto-assignment, and allow it to auto-navigate to confirmation when participants are auto-assigned.
What changes do you think we should make in order to solve the problem?
File:src/hooks/useReceiptScanDrop.tsx Function:saveFileAndInitMoneyRequest (closure inside useReceiptScanDrop) Change: Remove the entire paid-group-policy fast-path conditional block (lines 84–101). Replace it with a single call to navigateToParticipantPage(CONST.IOU.TYPE.CREATE, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, newReportID) — identical to what the current else branch already does. This ensures all drag-and-drop flows navigate through the Participants step first, where policy-aware auto-assignment is already handled, and then auto-navigate to Confirmation. No new imports are required.
Blast radius: Isolated to the saveFileAndInitMoneyRequest closure within useReceiptScanDrop. The hook is called from SearchPage and HomePage; both benefit from the consistent navigation stack. No shared helpers are modified, and the Participants step already contains all the logic needed for policy assignment and auto-advancement.
What alternative solutions did you explore?
Location:src/pages/iou/request/step/IOURequestStepConfirmation.tsx — navigateBack function Rejection reason: Patching the navigateBack logic to detect when a user arrived at Confirmation without passing through Participants and synthesizing a back target would be too broad. This approach would alter back-navigation behavior for every entry point to the Confirmation screen, not just the drag-and-drop flow, and it treats the symptom rather than addressing the root cause in the navigation initialization.
The user-facing bug: dropping a receipt onto a Workspace chat opens Confirm Details the first time but Create Expense the second time, because the second navigation reads stale Onyx data.
Root cause
The drag-and-drop scan flow in src/pages/inbox/report/ReportActionCompose/useReceiptDrop.ts:41-86 writes the draft transaction asynchronously to Onyx and then navigates synchronously, racing with the destination screen's useOnyx cache. There are two interacting components that conspire to reproduce the bug only on the second drop.
1 - Async write + sync navigation in useReceiptDrop:
initMoneyRequest performs Onyx.set (no await) and returns synchronously. setMoneyRequestReceipt and setMoneyRequestParticipantsFromReport likewise fire-and-forget. After all of these, we synchronously Navigation.navigate to the confirmation page using the hardcoded OPTIMISTIC_TRANSACTION_ID = '1'.
On the confirmation route, withFullTransactionOrNotFound.tsx:74-76 derives isLoadingTransaction from isLoadingOnyxValue(transactionResult, transactionDraftResult). useOnyx only reports isLoading=true on the very first read of a key; once it has resolved that key in the current session, every subsequent mount sees isLoading=false immediately, with whatever value it had previously cached.
startMoneyRequest (src/libs/actions/IOU/index.ts:763-803) clears the TRANSACTION_DRAFT['1'] key via clearMoneyRequest and navigates to MONEY_REQUEST_CREATE_TAB_SCAN (the "Create Expense" page). That path is meant for the FAB / global-create flow — never for receipt-drop.
Putting it together:
First drop:useOnyx has never read TRANSACTION_DRAFT['1'] in this session → isLoadingTransaction = true on mount → effect short-circuits → the pending Onyx.set lands → re-render with the populated transaction → Confirm Details renders. Correct.
Second drop (after first expense submits and removeDraftTransactionsByIDs nulls out TRANSACTION_DRAFT['1']):useOnyx has the key cached as null → isLoadingTransaction = falseimmediately on the new mount, with transaction still observed as null → effect runs once, sees !isLoadingTransaction && !transaction?.transactionID, calls startMoneyRequest, which wipes the draft we just wrote and routes to "Create Expense". By the time the new draft actually lands in Onyx, MoneyRequestInitializer has already navigated away.
Proposed fix
Mirror the precedent already used in src/hooks/useReceiptScanDrop.tsx:91-104 (the SearchPage / HomePage drop flow): collect the participant-merge promises, and only navigate once Onyx has flushed them. Onyx serializes operations on the same key in FIFO order, so awaiting the final setMoneyRequestParticipantsFromReport (which returns its Onyx.merge promise) guarantees the preceding initMoneyRequest's Onyx.set and setMoneyRequestReceipt's Onyx.merge have also flushed before navigation.
Replace the loop + navigate at src/pages/inbox/report/ReportActionCompose/useReceiptDrop.ts:64-85 with:
// src/pages/inbox/report/ReportActionCompose/useReceiptDrop.ts (fixed)constsetParticipantsPromises: Array<Promise<void>>=[];for(const[index,file]offiles.entries()){constsource=URL.createObjectURL(fileasBlob);constnewTransaction=index===0
? (initialTransactionasPartial<OnyxTypes.Transaction>)
: buildOptimisticTransactionAndCreateDraft({initialTransaction: initialTransactionasPartial<OnyxTypes.Transaction>,
currentUserPersonalDetails,
reportID,});constnewTransactionID=newTransaction?.transactionID??CONST.IOU.OPTIMISTIC_TRANSACTION_ID;setMoneyRequestReceipt(newTransactionID,source,file.name??'',true,file.type);setParticipantsPromises.push(setMoneyRequestParticipantsFromReport(newTransactionID,report,currentUserPersonalDetails.accountID),);}// Wait for the draft writes to flush so the confirmation screen's useOnyx// observes the populated draft on its first read instead of the cached null// left over from the previously submitted expense.Promise.all(setParticipantsPromises).then(()=>{Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(CONST.IOU.ACTION.CREATE,isSelfDM(report) ? CONST.IOU.TYPE.TRACK : CONST.IOU.TYPE.SUBMIT,CONST.IOU.OPTIMISTIC_TRANSACTION_ID,reportID,),);});
This is a minimal, surgical change to the one call site with the bug. setMoneyRequestParticipantsFromReport already returns its Onyx.merge promise (src/libs/actions/IOU/index.ts:2459-2465), so no signature changes are needed.
Why this approach
The exact same pattern is the canonical solution already adopted in src/hooks/useReceiptScanDrop.tsx:91-104 for the SearchPage and HomePage drop flows — which do not exhibit this bug, precisely because they Promise.all(setParticipantsPromises).then(() => Navigation.navigate(...)). Bringing useReceiptDrop.ts in line with that hook removes the divergence and fixes the user-facing race.
Alternatives considered and rejected:
Patching MoneyRequestInitializer to ignore the first effect run when transaction === null. This trades one race for another: any legitimately-cleared draft (e.g. user revisiting MONEY_REQUEST_STEP_CONFIRMATION directly) would silently skip the startMoneyRequest redirect. It also leaves every other call site that navigates synchronously after initMoneyRequest in a fragile state.
Generating a fresh transactionID per drop instead of reusing OPTIMISTIC_TRANSACTION_ID = '1'. That changes a constant threaded through dozens of files (src/CONST/index.ts:3364 and most of src/libs/actions/IOU), with much larger blast radius than the bug warrants.
Making setMoneyRequestReceipt return its Onyx.merge promise. Useful but not necessary — Onyx already serializes writes on the same key in FIFO order, so awaiting the last merge implicitly awaits the earlier ones.
I think this makes sense The fix is not obvious at first glance, but the fix is quite complex Which will help prevent similar Onyx issues in the future
changed the title [-][Due for payment 2026-06-17] [$250] Drag-and-Drop SmartScan Shows Incorrect Flow Order (Confirm Details First, Then Create Expense)[/-][+][Due for payment 2026-06-17] [$500] Drag-and-Drop SmartScan Shows Incorrect Flow Order (Confirm Details First, Then Create Expense)[/+]on Jun 18, 2026
Activity
MelvinBot commentedon May 6, 2026
Proposal
What is the root cause of that problem?
Race condition between async
Onyx.setanduseOnyx's loading semantics on the second drag-and-drop.All receipt-drop flows share a single hardcoded draft key
TRANSACTION_DRAFT['1'](CONST.OPTIMISTIC_TRANSACTION_ID). When a receipt is dropped,useReceiptDropcallsinitMoneyRequest(which fires an asyncOnyx.setatIOU/index.ts:682) then synchronously navigates to the confirmation page.On the confirmation page,
MoneyRequestInitializerchecksisLoadingTransaction— if true, it waits; if false and the transaction is null, it callsstartMoneyRequestwhich redirects to the "Create Expense" page.First drop:
useOnyxhas never readTRANSACTION_DRAFT['1'], soisLoadingTransaction = trueon mount →MoneyRequestInitializerwaits →Onyx.setresolves → transaction populates → "Confirm Details" shows correctly.Second drop: After the first expense completes, the draft is cleaned up (set to
null). NowuseOnyxhas already resolved this key, so on the second mountisLoadingTransaction = falsewithvalue = null— even though the newOnyx.setis still pending.MoneyRequestInitializersees null transaction → falls through → callsstartMoneyRequestwhich clears the draft and navigates to "Create Expense".What changes do you think we should make in order to solve the problem?
initMoneyRequestshouldawaittheOnyx.setcall before navigation occurs. InuseReceiptDrop.ts, makeonFilesValidatedasync and awaitinitMoneyRequest. Similarly, makeinitMoneyRequestinIOU/index.ts:682return theOnyx.setpromise (it already does, sinceOnyx.setreturns a promise — just needsawaitat the call site). This ensuresTRANSACTION_DRAFT['1']is populated before the navigation to the confirmation page, regardless ofuseOnyx's loading state.What alternative solutions did you explore? (Optional)
An alternative is to make
MoneyRequestInitializermore defensive — e.g., add a short delay or a "pending" flag passed via route params to indicate the transaction draft is being written. However, this is a workaround; the cleaner fix is ensuring the data is written before navigating to the page that reads it.Related issues
OPTIMISTIC_TRANSACTION_IDshared key and multi-file drop handling)Next Steps for Contributor+ team: Reply with
@MelvinBot implement thisto create a draft PR,@MelvinBot <your feedback>to refine this analysis, or explain why you are rejecting Melvin's proposal.melvin-bot commentedon May 6, 2026
Triggered auto assignment to Contributor-plus team member for initial proposal review - @ZhenjaHorbach (
External)MobileMage commentedon May 6, 2026
Proposal
Please re-state the problem that we are trying to solve in this issue.
When a user drags and drops a receipt onto the SmartScan section of a workspace chat, the first drop correctly shows the Confirm Details screen. Every subsequent drop on the same page load shows the "Create Expense" screen instead, breaking the expected SmartScan flow.
What is the root cause of that problem?
The bug is a sequencing problem between
clearMoneyRequestnullifying the OPTIMISTIC_TRANSACTION_ID draft and the asyncOnyx.set()ininitMoneyRequestracing against the Confirmation screen mounting.After the first drag-and-drop completes (whether the user submits the expense or cancels),
clearMoneyRequestis invoked. InsideclearMoneyRequest,getRemoveDraftTransactionsByIDsData(draftTransactionIDs)is called withoutshouldExcludeInitialTransaction = true, soOPTIMISTIC_TRANSACTION_IDis included in the multi-set and explicitly set tonullin Onyx. This is intentional cleanup, but it leaves the draft key present in Onyx with anullvalue rather than absent/undefined.On the second drop,
useReceiptDrop.onFilesValidatedcallsinitMoneyRequest, which schedules an asyncOnyx.set()to write the new transaction draft, then synchronously callsNavigation.navigatetoMONEY_REQUEST_STEP_CONFIRMATION. The Confirmation screen mounts before the async Onyx write completes. InsideIOURequestStepConfirmation, theMoneyRequestInitializercomponent runs itsuseEffect(which depends only onisLoadingTransaction) immediately. Because the draft key exists in Onyx asnull(not undefined/unhydrated),isLoadingTransactionisfalsefrom the start andtransactionisnull. The effect condition evaluates tofalse, sostartMoneyRequestis called without arequestType, which navigates toROUTES.MONEY_REQUEST_CREATE— the generic "Create Expense" screen — overriding the intended Confirmation destination.The first drop works correctly because at that point the draft key does not exist in Onyx yet (or its loading state is
trueduring initial hydration), soMoneyRequestInitializerwaits until the draft arrives before acting.App/src/pages/iou/request/step/confirmation/MoneyRequestInitializer.tsx
Lines 23 to 37 in db26f8e
What changes do you think we should make in order to solve the problem?
The fix should ensure that when navigating to the Confirmation screen from the drag-and-drop path, the transaction draft is already written to Onyx before the screen mounts and
MoneyRequestInitializerevaluates. The most targeted change is inuseReceiptDrop.ts: instead of callingNavigation.navigatesynchronously afterinitMoneyRequest, the navigation should happen inside a.then()callback after the Onyx write thatinitMoneyRequestschedules. SinceOnyx.set()returns a Promise,initMoneyRequestcan be updated to return that Promise (or a separate helper can awaitOnyx.setdirectly before navigating). This guarantees the draft exists by the time Confirmation mounts.An alternative is to change
clearMoneyRequestinsrc/libs/actions/IOU/index.tsto passshouldExcludeInitialTransaction = truewhen callinggetRemoveDraftTransactionsByIDsData, so that the OPTIMISTIC_TRANSACTION_ID draft is never set tonull— it is only ever overwritten byinitMoneyRequest. This removes the window where the draft isnullrather than loading, which is the exact condition that causesMoneyRequestInitializerto fire prematurely. The OPTIMISTIC_TRANSACTION_ID draft is always rewritten byinitMoneyRequestat the start of every create flow anyway, so preserving the previous stale value between flows has no negative effect on the first-time render.The second approach is the simpler one-line change and does not require making
initMoneyRequestasynchronous, but care should be taken to confirm that no caller ofclearMoneyRequestrelies on the OPTIMISTIC_TRANSACTION_ID draft being explicitly nulled (for example, the share tab context inShareTabParticipantsSelector). If any caller does, that specific call site can pass an extra flag rather than changing the default.App/src/libs/actions/IOU/index.ts
Lines 699 to 705 in db26f8e
What alternative solutions did you explore? (Optional)
N/A
yusufdeveloper2903 commentedon May 6, 2026
Proposal
What is the root cause of that problem?
The workspace-chat receipt drop handler decides between "create a new SmartScan expense" and "replace the existing receipt" based on
useShouldAddOrReplaceReceipt, which flips after the first drop completes.useShouldAddOrReplaceReceiptlooks at the dropped-on report's transactions and returnsshouldAddOrReplaceReceipt: truewhen the report has exactly one transaction (isSingleTransactionView):https://github.com/Expensify/App/blob/main/src/pages/inbox/report/ReportActionCompose/useShouldAddOrReplaceReceipt.ts#L24-L41
Repro flow:
reportTransactions.length === 0, soisSingleTransactionViewisfalseandshouldAddOrReplaceReceiptisfalse.useReceiptDrop.onFilesValidatedtakes the create new expense branch and navigates toMONEY_REQUEST_STEP_CONFIRMATION(the Confirm Details screen). ✅reportTransactions.length === 1, soisSingleTransactionViewflips totrue→shouldAddOrReplaceReceiptbecomestrue, andtransactionIDresolves to the previously-created expense's transaction.useReceiptDrop.onReceiptDroppedandonFilesValidatedthen take the replace receipt branch:https://github.com/Expensify/App/blob/main/src/pages/inbox/report/ReportActionCompose/useReceiptDrop.ts#L41-L86
So the second drop never starts a fresh SmartScan flow — it overwrites the receipt on the existing expense and lands the user on that expense's RHP ("Create Expense"-style view of the existing transaction) instead of the Confirm Details flow.
The "single transaction view" treatment is correct for transaction-thread reports (where the screen really is showing one expense, e.g. RHP), but a workspace chat (policy expense chat) is a parent chat — receipt drops there should always start a new expense, regardless of how many expenses currently live under it.
What changes do you think we should make in order to solve the problem?
I'd gate the
isSingleTransactionViewbranch so it only applies on actual single-expense screens (transaction threads / IOU / Expense reports), not on policy expense chats. The simplest fix is inuseShouldAddOrReplaceReceipt:(import
isPolicyExpenseChatfrom@libs/ReportUtils.)After the fix:
shouldAddOrReplaceReceipt = false→ Confirm Details. ✅shouldAddOrReplaceReceipt = false(becauseisPolicyExpenseChatis true) → Confirm Details. ✅What alternative solutions did you explore? (Optional)
useReceiptDrop.onReceiptDropped/onFilesValidatedby ignoringshouldAddOrReplaceReceiptwhen the drop target is a policy expense chat. This works but spreads the workspace-chat assumption across two files; gating it once at the source (useShouldAddOrReplaceReceipt) keeps the logic centralized and also fixes theDualDropZoneUI label, which currently switches the SmartScan tile to "Replace receipt" in this same scenario.isSingleTransactionViewfrom the navigation route name instead of the report type — but the hook is already report-aware, so the report-type check is a smaller change with the same effect.[-]Drag-and-Drop SmartScan Shows Incorrect Flow Order (Confirm Details First, Then Create Expense)[/-][+][$250] Drag-and-Drop SmartScan Shows Incorrect Flow Order (Confirm Details First, Then Create Expense)[/+]melvin-bot commentedon May 6, 2026
Job added to Upwork: https://www.upwork.com/jobs/~022052096017145957985
KJ21-ENG commentedon May 6, 2026
🚨 Edited by proposal-police: This proposal was edited at 2026-05-06 19:05:23 UTC.
Proposal
Please re-state the problem that we are trying to solve in this issue.
When a receipt is dragged into a workspace chat's Scan receipts drop zone on desktop web, the first dropped receipt opens Confirm details. After that expense is completed, dropping another receipt into the same Scan receipts drop zone can land on the generic Create expense Scan tab instead of opening Confirm details for the newly dropped SmartScan receipt.
What is the root cause of that problem?
The failing path is the composer new-receipt branch, not the replace-receipt branch. The media shows the second drag targets the visible
Scan receiptsdrop zone, then the app opens/create/submit/start/.../scanwith theCreate expensetitle andUpload receiptsempty state.In
useReceiptDrop, the new-receipt branch initializes the draft, writes the receipt, starts participant auto-assignment, and then immediately navigates to confirmation without waiting for those Onyx writes to be visible:App/src/pages/inbox/report/ReportActionCompose/useReceiptDrop.ts
Lines 52 to 85 in c1b4b98
The first draft write is asynchronous because
initMoneyRequest()callsOnyx.set(...)and returns the draft object, not the Onyx promise:App/src/libs/actions/IOU/index.ts
Lines 680 to 684 in c1b4b98
The receipt and participant updates are also Onyx merges;
setMoneyRequestParticipantsFromReport()is the final merge in this sequence and returns the promise for that write:App/src/libs/actions/IOU/Receipt.ts
Lines 293 to 306 in c1b4b98
App/src/libs/actions/IOU/index.ts
Lines 2410 to 2416 in c1b4b98
After the first expense is submitted, the request-money path schedules draft cleanup, and draft cleanup writes
nullinto the draft keys:App/src/libs/actions/IOU/TrackExpense.ts
Lines 1834 to 1840 in c1b4b98
App/src/libs/actions/TransactionEdit.ts
Lines 114 to 135 in c1b4b98
On the second drop, that draft key has already been loaded and cleaned, so the confirmation screen can mount with
isLoadingTransaction=falsebefore the new draft write sequence has reached Onyx.MoneyRequestInitializerthen sees no valid loaded transaction and callsstartMoneyRequest():App/src/pages/iou/request/step/confirmation/MoneyRequestInitializer.tsx
Lines 23 to 37 in c1b4b98
Because that restart does not pass a request type,
startMoneyRequest()falls through to the defaultMONEY_REQUEST_CREATEroute instead of the scan confirmation route:App/src/libs/actions/IOU/index.ts
Lines 727 to 745 in c1b4b98
What changes do you think we should make in order to solve the problem?
In
src/pages/inbox/report/ReportActionCompose/useReceiptDrop.ts, keep the existing replace-receipt branch unchanged. In the new-receiptonFilesValidatedpath, collect the promises returned bysetMoneyRequestParticipantsFromReport()for every dropped receipt and move the existingNavigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION...)call intoPromise.all(setParticipantsPromises).then(...).This waits for the final Onyx merge in the draft-write sequence before the confirmation screen mounts, so
MoneyRequestInitializerreceives a loaded draft transaction instead of the previously cleanednullstate. It also preserves the synchronous return value ofinitMoneyRequest(), which this path uses as the base transaction object.This matches the current project pattern.
useReceiptScanDrop()writes receipts, collectssetMoneyRequestParticipantsFromReport()promises, waits forPromise.all(...), and only then navigates to confirmation:App/src/hooks/useReceiptScanDrop.tsx
Lines 91 to 104 in c1b4b98
The money-request scan and amount-step flows use the same "set participants, then navigate in
.then(...)" boundary:App/src/libs/actions/IOU/MoneyRequest.ts
Lines 546 to 552 in c1b4b98
App/src/pages/iou/request/step/IOURequestStepAmount.tsx
Lines 344 to 346 in c1b4b98
Scope boundaries: touch
src/pages/inbox/report/ReportActionCompose/useReceiptDrop.tsonly, in the new-receiptonFilesValidatedpath around lines 52-85. Platforms affected: desktop web drag/drop in report/workspace chat composer drop zones. Do not change the replace-receipt branch,useShouldAddOrReplaceReceipt, global Search/Home receipt drop, native camera flows, scan-step upload UI, API contracts, or backend behavior.What alternative solutions did you explore? (Optional)
MelvinBot and MobileMage also point at the draft-write/confirmation race. I agree with that mechanism, but I would not make
initMoneyRequest()async globally because current callers use its synchronous draft object immediately. Waiting at the composer call site on the existing participant-merge promise is narrower and matches the existing scan-drop patterns above.I ruled out the replace-receipt explanation from yusufdeveloper2903 for the captured failure. If
shouldAddOrReplaceReceiptwere true,DualDropZonewould pass the single-receipt mode and show the add/replace receipt copy instead of theScan receiptscopy; the media showsScan receipts, and the failing navigation happens after the new-receipt branch, not thereplaceReceipt()branch.App/src/components/DropZone/DualDropZone.tsx
Lines 40 to 74 in c1b4b98
App/src/pages/inbox/report/ReportActionCompose/useReceiptDrop.ts
Lines 46 to 49 in c1b4b98
I also ruled out the later global-drop proposal from aswin-s because this issue's repro and media use the workspace chat composer drop zone, while
useReceiptScanDrop()is the Search/Home global drop hook and already waits for participant writes before navigating.Changing
MoneyRequestInitializerto passCONST.IOU.REQUEST_TYPE.SCANwhen it restarts would only change the fallback after confirmation has already mounted with a missing draft. The root fix is to satisfy confirmation's existing contract by waiting for the composer drop path's draft writes before navigating there.aswin-s commentedon May 6, 2026
Proposal
Please re-state the problem that we are trying to solve in this issue.
When a user drags and drops a receipt file onto the SmartScan drop zone while having an active paid group policy with expense chat enabled, the app navigates directly to the Confirm Details screen, skipping the Create Expense (Participants) screen entirely. On other policies, the same drag-and-drop action correctly shows the Create Expense screen first. This inconsistency breaks the expected flow and prevents users from modifying participants or policy assignment before confirming the expense.
What is the root cause of that problem?
The bug originates in
src/hooks/useReceiptScanDrop.tsxin thesaveFileAndInitMoneyRequestfunction (lines 84–101). When a paid group policy with expense chat is active and the user is not billing-restricted, the code bypasses the participants step entirely by callingNavigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(...))directly. This creates an inconsistent navigation stack compared to theelsebranch (lines 102–103), which correctly callsnavigateToParticipantPage(CONST.IOU.TYPE.CREATE, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, newReportID).The working analog is
src/libs/actions/IOU/MoneyRequest.tsin thehandleMoneyRequestStepScanParticipantsfunction, which demonstrates the correct pattern: navigate to the participants step first, let it handle policy-aware auto-assignment, and allow it to auto-navigate to confirmation when participants are auto-assigned.App/src/hooks/useReceiptScanDrop.tsx
Lines 84 to 101 in 3caeca4
App/src/libs/actions/IOU/MoneyRequest.ts
Lines 1 to 50 in 3caeca4
What changes do you think we should make in order to solve the problem?
src/hooks/useReceiptScanDrop.tsxFunction:
saveFileAndInitMoneyRequest(closure insideuseReceiptScanDrop)Change: Remove the entire paid-group-policy fast-path conditional block (lines 84–101). Replace it with a single call to
navigateToParticipantPage(CONST.IOU.TYPE.CREATE, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, newReportID)— identical to what the currentelsebranch already does. This ensures all drag-and-drop flows navigate through the Participants step first, where policy-aware auto-assignment is already handled, and then auto-navigate to Confirmation. No new imports are required.Blast radius: Isolated to the
saveFileAndInitMoneyRequestclosure withinuseReceiptScanDrop. The hook is called from SearchPage and HomePage; both benefit from the consistent navigation stack. No shared helpers are modified, and the Participants step already contains all the logic needed for policy assignment and auto-advancement.What alternative solutions did you explore?
src/pages/iou/request/step/IOURequestStepConfirmation.tsx—navigateBackfunctionRejection reason: Patching the
navigateBacklogic to detect when a user arrived at Confirmation without passing through Participants and synthesizing a back target would be too broad. This approach would alter back-navigation behavior for every entry point to the Confirmation screen, not just the drag-and-drop flow, and it treats the symptom rather than addressing the root cause in the navigation initialization.trasnake87 commentedon May 6, 2026
Proposal
The user-facing bug: dropping a receipt onto a Workspace chat opens Confirm Details the first time but Create Expense the second time, because the second navigation reads stale Onyx data.
Root cause
The drag-and-drop scan flow in
src/pages/inbox/report/ReportActionCompose/useReceiptDrop.ts:41-86writes the draft transaction asynchronously to Onyx and then navigates synchronously, racing with the destination screen'suseOnyxcache. There are two interacting components that conspire to reproduce the bug only on the second drop.1 - Async write + sync navigation in
useReceiptDrop:initMoneyRequestperformsOnyx.set(no await) and returns synchronously.setMoneyRequestReceiptandsetMoneyRequestParticipantsFromReportlikewise fire-and-forget. After all of these, we synchronouslyNavigation.navigateto the confirmation page using the hardcodedOPTIMISTIC_TRANSACTION_ID = '1'.Onyx.setinsideinitMoneyRequest(src/libs/actions/IOU/index.ts:738) is async, so the navigation runs before the write resolves.2 -
useOnyxcache +MoneyRequestInitializerfallback:On the confirmation route,
withFullTransactionOrNotFound.tsx:74-76derivesisLoadingTransactionfromisLoadingOnyxValue(transactionResult, transactionDraftResult).useOnyxonly reportsisLoading=trueon the very first read of a key; once it has resolved that key in the current session, every subsequent mount seesisLoading=falseimmediately, with whatever value it had previously cached.Then
MoneyRequestInitializer.tsx:23-37runs:startMoneyRequest(src/libs/actions/IOU/index.ts:763-803) clears theTRANSACTION_DRAFT['1']key viaclearMoneyRequestand navigates toMONEY_REQUEST_CREATE_TAB_SCAN(the "Create Expense" page). That path is meant for the FAB / global-create flow — never for receipt-drop.Putting it together:
useOnyxhas never readTRANSACTION_DRAFT['1']in this session →isLoadingTransaction = trueon mount → effect short-circuits → the pendingOnyx.setlands → re-render with the populated transaction → Confirm Details renders. Correct.removeDraftTransactionsByIDsnulls outTRANSACTION_DRAFT['1']):useOnyxhas the key cached asnull→isLoadingTransaction = falseimmediately on the new mount, withtransactionstill observed asnull→ effect runs once, sees!isLoadingTransaction && !transaction?.transactionID, callsstartMoneyRequest, which wipes the draft we just wrote and routes to "Create Expense". By the time the new draft actually lands in Onyx,MoneyRequestInitializerhas already navigated away.Proposed fix
Mirror the precedent already used in
src/hooks/useReceiptScanDrop.tsx:91-104(the SearchPage / HomePage drop flow): collect the participant-merge promises, and only navigate once Onyx has flushed them. Onyx serializes operations on the same key in FIFO order, so awaiting the finalsetMoneyRequestParticipantsFromReport(which returns itsOnyx.mergepromise) guarantees the precedinginitMoneyRequest'sOnyx.setandsetMoneyRequestReceipt'sOnyx.mergehave also flushed before navigation.Replace the loop + navigate at
src/pages/inbox/report/ReportActionCompose/useReceiptDrop.ts:64-85with:This is a minimal, surgical change to the one call site with the bug.
setMoneyRequestParticipantsFromReportalready returns itsOnyx.mergepromise (src/libs/actions/IOU/index.ts:2459-2465), so no signature changes are needed.Why this approach
The exact same pattern is the canonical solution already adopted in
src/hooks/useReceiptScanDrop.tsx:91-104for the SearchPage and HomePage drop flows — which do not exhibit this bug, precisely because theyPromise.all(setParticipantsPromises).then(() => Navigation.navigate(...)). BringinguseReceiptDrop.tsin line with that hook removes the divergence and fixes the user-facing race.Alternatives considered and rejected:
MoneyRequestInitializerto ignore the first effect run whentransaction === null. This trades one race for another: any legitimately-cleared draft (e.g. user revisitingMONEY_REQUEST_STEP_CONFIRMATIONdirectly) would silently skip thestartMoneyRequestredirect. It also leaves every other call site that navigates synchronously afterinitMoneyRequestin a fragile state.OPTIMISTIC_TRANSACTION_ID = '1'. That changes a constant threaded through dozens of files (src/CONST/index.ts:3364and most ofsrc/libs/actions/IOU), with much larger blast radius than the bug warrants.setMoneyRequestReceiptreturn itsOnyx.mergepromise. Useful but not necessary — Onyx already serializes writes on the same key in FIFO order, so awaiting the last merge implicitly awaits the earlier ones.81 remaining items
melvin-bot commentedon Jun 16, 2026
Triggered auto assignment to @mallenexpensify (
Awaiting Payment)melvin-bot commentedon Jun 16, 2026
Payment Summary
Upwork Job
BugZero Checklist (@mallenexpensify)
ZhenjaHorbach commentedon Jun 17, 2026
@blimpich
Can you share your thoughts, please?
blimpich commentedon Jun 18, 2026
Pay bump sounds fine to me in this scenario 👍. This is a genuinely complex issue.
[-][Due for payment 2026-06-17] [$250] Drag-and-Drop SmartScan Shows Incorrect Flow Order (Confirm Details First, Then Create Expense)[/-][+][Due for payment 2026-06-17] [$500] Drag-and-Drop SmartScan Shows Incorrect Flow Order (Confirm Details First, Then Create Expense)[/+]melvin-bot commentedon Jun 18, 2026
Upwork job price has been updated to $500
mallenexpensify commentedon Jun 19, 2026
Payment Summary
Contributor: @nkdengineer due $500 via NewDot
Contributor+: @ZhenjaHorbach due $500 via NewDot
^ Test case created. Thx
garrettmknight commentedon Jun 19, 2026
$500 approved for @ZhenjaHorbach
twisterdotcom commentedon Jul 15, 2026
$500 approved for @nkdengineer in Report ID: R00JRhw1xXfR.
Zapier Logs
Run ID: 00040eee-ab7c-a813-8912-7e77ac421af4SO: https://stackoverflowteams.com/c/expensify/questions/7582