Skip to content

[Due for payment 2026-06-17] [$500] Drag-and-Drop SmartScan Shows Incorrect Flow Order (Confirm Details First, Then Create Expense) #89800

Closed

Description

@m-natarajan

If you haven’t already, check out our contributing guidelines for onboarding and email [email protected] to request to join our Slack channel!


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:

  1. Open New Expensify (Web)
  2. Navigate to a Workspace chat
  3. Drag and drop a receipt file into the Scan section
  4. Observe the screen shown, Complete the flow
  5. Drag and drop another receipt file
  6. 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:

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

Screenshots/Videos

Recording.2831.mp4

View all open jobs on GitHub

Upwork Automation - Do Not Edit
  • Upwork Job URL: https://www.upwork.com/jobs/~022052096017145957985
  • Upwork Job ID: 2052096017145957985
  • Last Price Increase: 2026-06-18
Issue OwnerCurrent Issue Owner: @mallenexpensify

Activity

  1. added
    BugSomething is broken. Auto assigns a BugZero manager.
    on May 6, 2026
  2. MelvinBot commented on May 6, 2026

    @MelvinBot
    Contributor

    Proposal

    What is the root cause of that problem?

    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 async Onyx.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.

  3. added
    ExternalAdded to denote the issue can be worked on by a contributor
    on May 6, 2026
  4. added
    Help WantedApply this label when an issue is open to proposals by contributors
    on May 6, 2026
  5. melvin-bot commented on May 6, 2026

    @melvin-bot

    Triggered auto assignment to Contributor-plus team member for initial proposal review - @ZhenjaHorbach (External)

  6. MobileMage commented on May 6, 2026

    @MobileMage
    Contributor

    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.

    useEffect(() => {
    // Exit early if the transaction is still loading
    if (!!isLoadingTransaction || (transaction?.transactionID && (!transaction?.isFromGlobalCreate || !isEmptyObject(transaction?.participants)))) {
    return;
    }
    startMoneyRequest(
    iouType ?? CONST.IOU.TYPE.CREATE,
    // 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.

    function clearMoneyRequest(transactionID: string, draftTransactionIDs: string[] | undefined, skipConfirmation = false) {
    const onyxData: Record<string, null | boolean> = {
    ...getRemoveDraftTransactionsByIDsData(draftTransactionIDs),
    [`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID}`]: skipConfirmation,
    };
    Onyx.multiSet(onyxData as Parameters<typeof Onyx.multiSet>[0]);
    }

    What alternative solutions did you explore? (Optional)

    N/A

  7. yusufdeveloper2903 commented on May 6, 2026

    @yusufdeveloper2903
    Contributor

    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.

    useShouldAddOrReplaceReceipt looks at the dropped-on report's transactions and returns shouldAddOrReplaceReceipt: true when the report has exactly one transaction (isSingleTransactionView):

    https://github.com/Expensify/App/blob/main/src/pages/inbox/report/ReportActionCompose/useShouldAddOrReplaceReceipt.ts#L24-L41

    const reportTransactions = getAllNonDeletedTransactions(allReportTransactions, filteredReportActions, isOffline, true);
    const isExpensesReport = reportTransactions && reportTransactions.length > 1;
    // ...
    const isSingleTransactionView = !!transaction && !!reportTransactions && reportTransactions.length === 1;
    // ...
    const shouldAddOrReplaceReceipt = (isTransactionThreadView || isSingleTransactionView) && canEditReceipt;

    Repro flow:

    1. 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). ✅
    2. After the user completes the confirmation, the workspace chat now contains exactly one expense.
    3. 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:

    https://github.com/Expensify/App/blob/main/src/pages/inbox/report/ReportActionCompose/useReceiptDrop.ts#L41-L86

    if (shouldAddOrReplaceReceipt && transactionID) {
        const source = URL.createObjectURL(files.at(0) as Blob);
        replaceReceipt({transactionID, file: files.at(0) as File, source, transactionPolicy: policy, transactionPolicyCategories: policyCategories});
        return;
    }

    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:

    - const isSingleTransactionView = !!transaction && !!reportTransactions && reportTransactions.length === 1;
    + const isPolicyExpenseChat = isPolicyExpenseChatReportUtil(report);
    + const isSingleTransactionView = !isPolicyExpenseChat && !!transaction && !!reportTransactions && reportTransactions.length === 1;

    (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.
  8. 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
  9. melvin-bot commented on May 6, 2026

    @melvin-bot
  10. KJ21-ENG commented on May 6, 2026

    @KJ21-ENG
    Contributor

    🚨 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:

    const initialTransaction = initMoneyRequest({
    reportID,
    personalPolicy,
    newIouRequestType: CONST.IOU.REQUEST_TYPE.SCAN,
    report,
    parentReport: newParentReport,
    currentDate,
    currentUserPersonalDetails,
    hasOnlyPersonalPolicies,
    draftTransactionIDs,
    });
    for (const [index, file] of files.entries()) {
    const source = URL.createObjectURL(file as Blob);
    const newTransaction =
    index === 0
    ? (initialTransaction as Partial<OnyxTypes.Transaction>)
    : buildOptimisticTransactionAndCreateDraft({
    initialTransaction: initialTransaction as Partial<OnyxTypes.Transaction>,
    currentUserPersonalDetails,
    reportID,
    });
    const newTransactionID = newTransaction?.transactionID ?? CONST.IOU.OPTIMISTIC_TRANSACTION_ID;
    setMoneyRequestReceipt(newTransactionID, source, file.name ?? '', true, file.type);
    setMoneyRequestParticipantsFromReport(newTransactionID, report, currentUserPersonalDetails.accountID);
    }
    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,
    ),
    );

    The first draft write is asynchronous because initMoneyRequest() calls Onyx.set(...) and returns the draft object, not the Onyx promise:

    // Store the transaction in Onyx and mark it as not saved so it can be cleaned up later
    // Use set() here so that there is no way that data will be leaked between objects when it gets reset
    Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${newTransactionID}`, newTransaction);
    return newTransaction;

    The receipt and participant updates are also Onyx merges; setMoneyRequestParticipantsFromReport() is the final merge in this sequence and returns the promise for that write:

    function setMoneyRequestReceipt(
    transactionID: string,
    source: string,
    filename: string,
    isDraft: boolean,
    type?: string,
    isTestReceipt = false,
    isTestDriveReceipt = false,
    thumbnail?: string,
    ) {
    Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {
    // isTestReceipt = false and isTestDriveReceipt = false are being converted to null because we don't really need to store it in Onyx in those cases
    receipt: {source, filename, type: type ?? '', isTestReceipt: isTestReceipt ? true : null, isTestDriveReceipt: isTestDriveReceipt ? true : null, thumbnail},
    });

    function setMoneyRequestParticipantsFromReport(transactionID: string, report: OnyxEntry<OnyxTypes.Report>, currentUserAccountID?: number, participantsAutoAssigned = true) {
    const participants = getMoneyRequestParticipantsFromReport(report, currentUserAccountID);
    return Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {
    participants,
    participantsAutoAssigned,
    });
    }

    After the first expense is submitted, the request-money path schedules draft cleanup, and draft cleanup writes null into the draft keys:

    // queueMicrotask defers past the synchronous multi-scan batch loop so that
    // every iteration can read its draft before any cleanup runs. Without it,
    // TransitionTracker.runAfterTransitions fires synchronously when no transition
    // is active (the dismiss already completed), removing drafts mid-loop.
    queueMicrotask(() => {
    TransitionTracker.runAfterTransitions({callback: () => removeDraftTransactionsByIDs(draftTransactionIDs)});
    });

    function getRemoveDraftTransactionsByIDsData(transactionIDs: string[] | undefined, shouldExcludeInitialTransaction = false): Record<string, null> {
    if (!transactionIDs?.length) {
    return {};
    }
    return transactionIDs.reduce(
    (acc, transactionID) => {
    if (shouldExcludeInitialTransaction && transactionID === CONST.IOU.OPTIMISTIC_TRANSACTION_ID) {
    return acc;
    }
    acc[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`] = null;
    return acc;
    },
    {} as Record<string, null>,
    );
    }
    function removeDraftTransactionsByIDs(transactionIDs: string[] | undefined, shouldExcludeInitialTransaction = false) {
    if (!transactionIDs?.length) {
    return;
    }
    Onyx.multiSet(getRemoveDraftTransactionsByIDsData(transactionIDs, shouldExcludeInitialTransaction));

    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():

    useEffect(() => {
    // Exit early if the transaction is still loading
    if (!!isLoadingTransaction || (transaction?.transactionID && (!transaction?.isFromGlobalCreate || !isEmptyObject(transaction?.participants)))) {
    return;
    }
    startMoneyRequest(
    iouType ?? CONST.IOU.TYPE.CREATE,
    // 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:

    clearMoneyRequest(CONST.IOU.OPTIMISTIC_TRANSACTION_ID, draftTransactionIDs, skipConfirmation);
    if (isFromFloatingActionButton) {
    Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`, {isFromFloatingActionButton});
    }
    switch (requestType) {
    case CONST.IOU.REQUEST_TYPE.MANUAL:
    Navigation.navigate(ROUTES.MONEY_REQUEST_CREATE_TAB_MANUAL.getRoute(CONST.IOU.ACTION.CREATE, iouType, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, reportID, backToReport));
    return;
    case CONST.IOU.REQUEST_TYPE.SCAN:
    Navigation.navigate(ROUTES.MONEY_REQUEST_CREATE_TAB_SCAN.getRoute(CONST.IOU.ACTION.CREATE, iouType, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, reportID, backToReport));
    return;
    case CONST.IOU.REQUEST_TYPE.DISTANCE:
    Navigation.navigate(ROUTES.MONEY_REQUEST_CREATE_TAB_DISTANCE.getRoute(CONST.IOU.ACTION.CREATE, iouType, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, reportID, backToReport));
    return;
    case CONST.IOU.REQUEST_TYPE.TIME:
    Navigation.navigate(ROUTES.MONEY_REQUEST_CREATE_TAB_TIME.getRoute(CONST.IOU.ACTION.CREATE, iouType, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, reportID, backToReport));
    return;
    default:
    Navigation.navigate(ROUTES.MONEY_REQUEST_CREATE.getRoute(CONST.IOU.ACTION.CREATE, iouType, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, reportID, backToReport));

    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:

    const setParticipantsPromises = newReceiptFiles.map((receiptFile) => {
    setTransactionReport(receiptFile.transactionID, {reportID: transactionReportID}, true);
    return setMoneyRequestParticipantsFromReport(receiptFile.transactionID, report, currentUserPersonalDetails.accountID);
    });
    Promise.all(setParticipantsPromises).then(() =>
    Navigation.navigate(
    ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(
    CONST.IOU.ACTION.CREATE,
    iouTypeTrackOrSubmit,
    initialTransaction?.transactionID ?? CONST.IOU.OPTIMISTIC_TRANSACTION_ID,
    report?.reportID,
    ),
    ),
    );

    The money-request scan and amount-step flows use the same "set participants, then navigate in .then(...)" boundary:

    const setParticipantsPromises = files.map((receiptFile) => {
    setTransactionReport(receiptFile.transactionID, {reportID: transactionReportID}, true);
    return setMoneyRequestParticipantsFromReport(receiptFile.transactionID, targetReport, currentUserAccountID);
    });
    Promise.all(setParticipantsPromises).then(() =>
    Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(CONST.IOU.ACTION.CREATE, iouTypeTrackOrSubmit, initialTransaction.transactionID, targetReport?.reportID)),
    );

    setMoneyRequestParticipantsFromReport(transactionID, report, currentUserPersonalDetails.accountID).then(() => {
    navigateToConfirmationPage(iouType, transactionID, reportID, backToReport);
    });

    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.

    const scanReceiptsText = shouldAcceptSingleReceipt ? 'dropzone.addReceipt' : 'dropzone.scanReceipts';
    const shouldStackRevertHorizontally = isWideRHPFocused;
    let flexStyle: ViewStyle = styles.flexRow;
    if (shouldStackVertically) {
    flexStyle = styles.flexColumn;
    } else if (shouldStackRevertHorizontally) {
    flexStyle = styles.flexRowReverse;
    }
    return (
    <DragAndDropConsumer onDrop={() => {}}>
    <View style={[flexStyle, styles.w100, styles.h100]}>
    <DropZoneWrapper onDrop={onAttachmentDrop}>
    {({isDraggingOver}) => (
    <DropZoneUI
    icon={icons.MessageInABottle}
    dropTitle={translate('dropzone.addAttachments')}
    dropStyles={styles.attachmentDropOverlay(isDraggingOver)}
    dropTextStyles={styles.attachmentDropText}
    dropWrapperStyles={shouldStackVertically ? styles.pb0 : styles.pr0}
    dashedBorderStyles={[
    styles.dropzoneArea,
    styles.easeInOpacityTransition,
    styles.activeDropzoneDashedBorder(theme.attachmentDropBorderColorActive, isDraggingOver),
    ]}
    />
    )}
    </DropZoneWrapper>
    <Animated.View style={isWideRHPFocused ? styles.wideRHPDropZoneContainer : [styles.flex1, styles.h100]}>
    <DropZoneWrapper onDrop={onReceiptDrop}>
    {({isDraggingOver}) => (
    <DropZoneUI
    icon={isEditing ? icons.ReplaceReceipt : icons.SmartScan}
    dropTitle={translate(isEditing ? 'dropzone.replaceReceipt' : scanReceiptsText)}
    dropStyles={styles.receiptDropOverlay(isDraggingOver)}

    if (shouldAddOrReplaceReceipt && transactionID) {
    const source = URL.createObjectURL(files.at(0) as Blob);
    replaceReceipt({transactionID, file: files.at(0) as File, source, transactionPolicy: policy, transactionPolicyCategories: policyCategories});
    return;

    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.

  11. aswin-s commented on May 6, 2026

    @aswin-s
    Contributor

    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.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.

    activePolicy?.isPolicyExpenseChatEnabled &&
    !shouldRestrictUserBillableActions(activePolicy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, currentUserPersonalDetails.accountID)
    ) {
    const shouldAutoReport = !!activePolicy?.autoReporting || !!personalPolicy?.autoReporting;
    const report = shouldAutoReport ? getPolicyExpenseChat(currentUserPersonalDetails.accountID, activePolicy?.id) : selfDMReport;
    const transactionReportID = isSelfDM(report) ? CONST.REPORT.UNREPORTED_REPORT_ID : report?.reportID;
    const iouTypeTrackOrSubmit = transactionReportID === CONST.REPORT.UNREPORTED_REPORT_ID ? CONST.IOU.TYPE.TRACK : CONST.IOU.TYPE.SUBMIT;
    const setParticipantsPromises = newReceiptFiles.map((receiptFile) => {
    setTransactionReport(receiptFile.transactionID, {reportID: transactionReportID}, true);
    return setMoneyRequestParticipantsFromReport(receiptFile.transactionID, report, currentUserPersonalDetails.accountID);
    });
    Promise.all(setParticipantsPromises).then(() =>
    Navigation.navigate(
    ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(
    CONST.IOU.ACTION.CREATE,
    iouTypeTrackOrSubmit,
    initialTransaction?.transactionID ?? CONST.IOU.OPTIMISTIC_TRANSACTION_ID,
    report?.reportID,

    import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
    import {getCurrencySymbol} from '@libs/CurrencyUtils';
    import DistanceRequestUtils from '@libs/DistanceRequestUtils';
    import getCurrentPosition from '@libs/getCurrentPosition';
    import {calculateDefaultReimbursable, getExistingTransactionID, navigateToConfirmationPage, navigateToParticipantPage} from '@libs/IOUUtils';
    import {toLocaleDigit} from '@libs/LocaleDigitUtils';
    import Log from '@libs/Log';
    import Navigation from '@libs/Navigation/Navigation';
    import {roundToTwoDecimalPlaces} from '@libs/NumberUtils';
    import {getManagerMcTestParticipant, getParticipantsOption, getReportOption} from '@libs/OptionsListUtils';
    import {generateReportID, getPolicyExpenseChat, isSelfDM} from '@libs/ReportUtils';
    import type {OptionData} from '@libs/ReportUtils';
    import shouldUseDefaultExpensePolicy from '@libs/shouldUseDefaultExpensePolicy';
    import {cancelSpan} from '@libs/telemetry/activeSpans';
    import {getDefaultTaxCode, getValidWaypoints} from '@libs/TransactionUtils';
    import type {ReceiptFile} from '@pages/iou/request/step/IOURequestStepScan/types';
    import {setTransactionReport} from '@userActions/Transaction';
    import type {IOUType} from '@src/CONST';
    import CONST from '@src/CONST';
    import IntlStore from '@src/languages/IntlStore';
    import type {TranslationParameters, TranslationPaths} from '@src/languages/types';
    import type {Route} from '@src/ROUTES';
    import ROUTES from '@src/ROUTES';
    import type {
    Beta,
    BillingGraceEndPeriod,
    IntroSelected,
    LastSelectedDistanceRates,
    OdometerDraft,
    PersonalDetailsList,
    Policy,
    PolicyTagLists,
    QuickAction,
    RecentWaypoint,
    Report,
    Transaction,
    TransactionViolation,
    } from '@src/types/onyx';
    import type {ReportAttributes, ReportAttributesDerivedValue} from '@src/types/onyx/DerivedValues';
    import type {Participant} from '@src/types/onyx/IOU';
    import type {Unit} from '@src/types/onyx/Policy';
    import type {Receipt, WaypointCollection} from '@src/types/onyx/Transaction';
    import type {GpsPoint} from './index';
    import {
    getMoneyRequestParticipantsFromReport,
    setCustomUnitRateID,
    setMoneyRequestDistance,
    setMoneyRequestMerchant,
    setMoneyRequestParticipants,
    setMoneyRequestParticipantsFromReport,

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

    1. 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?

    1. 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.
  12. trasnake87 commented on May 6, 2026

    @trasnake87
    Contributor

    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-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'.

    // src/pages/inbox/report/ReportActionCompose/useReceiptDrop.ts:52-85 (current)
    const initialTransaction = initMoneyRequest({
        reportID, personalPolicy, newIouRequestType: CONST.IOU.REQUEST_TYPE.SCAN,
        report, parentReport: newParentReport, currentDate, currentUserPersonalDetails,
        hasOnlyPersonalPolicies, draftTransactionIDs,
    });
    
    for (const [index, file] of files.entries()) {
        const source = URL.createObjectURL(file as Blob);
        const newTransaction = index === 0
            ? (initialTransaction as Partial<OnyxTypes.Transaction>)
            : buildOptimisticTransactionAndCreateDraft({...});
        const newTransactionID = newTransaction?.transactionID ?? CONST.IOU.OPTIMISTIC_TRANSACTION_ID;
        setMoneyRequestReceipt(newTransactionID, source, file.name ?? '', true, file.type);
        setMoneyRequestParticipantsFromReport(newTransactionID, report, currentUserPersonalDetails.accountID);
    }
    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,
        ),
    );

    Onyx.set inside initMoneyRequest (src/libs/actions/IOU/index.ts:738) is async, so the navigation runs before the write resolves.

    2 - useOnyx cache + MoneyRequestInitializer fallback:

    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.

    Then MoneyRequestInitializer.tsx:23-37 runs:

    // src/pages/iou/request/step/confirmation/MoneyRequestInitializer.tsx:23-37
    useEffect(() => {
        if (!!isLoadingTransaction || (transaction?.transactionID && (!transaction?.isFromGlobalCreate || !isEmptyObject(transaction?.participants)))) {
            return;
        }
        startMoneyRequest(
            iouType ?? CONST.IOU.TYPE.CREATE,
            reportID ?? generateReportID(),
            draftTransactionIDs,
        );
    }, [isLoadingTransaction]);

    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 = false immediately 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)
    const setParticipantsPromises: Array<Promise<void>> = [];
    for (const [index, file] of files.entries()) {
        const source = URL.createObjectURL(file as Blob);
        const newTransaction =
            index === 0
                ? (initialTransaction as Partial<OnyxTypes.Transaction>)
                : buildOptimisticTransactionAndCreateDraft({
                      initialTransaction: initialTransaction as Partial<OnyxTypes.Transaction>,
                      currentUserPersonalDetails,
                      reportID,
                  });
        const newTransactionID = 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.
  13. 81 remaining items

  14. melvin-bot commented on Jun 16, 2026

    @melvin-bot

    Triggered auto assignment to @mallenexpensify (Awaiting Payment)

  15. melvin-bot commented on Jun 16, 2026

    @melvin-bot

    Payment Summary

    Upwork Job

    BugZero Checklist (@mallenexpensify)

    • I have verified the correct assignees and roles are listed above and updated the necessary manual offers
    • I have verified that there are no duplicate or incorrect contracts on Upwork for this job (https://www.upwork.com/ab/applicants/2052096017145957985/hired)
    • I have verified the PR was not reverted
    • I have applied any discounts due to bugs/regressions introduced by this PR
    • I have paid out the Upwork contracts or cancelled the ones that are incorrect
    • I have verified the payment summary above is correct
  16. ZhenjaHorbach commented on Jun 17, 2026

    @ZhenjaHorbach
    Contributor

    @mallenexpensify, please check my increased bounty request for this issue when the payment is due.

    Suggestion price: $500

    Reason:

    1. Required multiple PRs
    1. The change looks simple, but it's hard to find the deep RCA in the react-native-onyx lib and find a solution for this without regression.

    cc @ZhenjaHorbach

    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

    @blimpich
    Can you share your thoughts, please?

  17. blimpich commented on Jun 18, 2026

    @blimpich
    Contributor

    Pay bump sounds fine to me in this scenario 👍. This is a genuinely complex issue.

  18. 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
  19. melvin-bot commented on Jun 18, 2026

    @melvin-bot

    Upwork job price has been updated to $500

  20. mallenexpensify commented on Jun 19, 2026

    @mallenexpensify
    Contributor

    Payment Summary

    Contributor: @nkdengineer due $500 via NewDot
    Contributor+: @ZhenjaHorbach due $500 via NewDot

    ^ Test case created. Thx

  21. garrettmknight commented on Jun 19, 2026

    @garrettmknight
    Contributor

    $500 approved for @ZhenjaHorbach

  22. twisterdotcom commented on Jul 15, 2026

    @twisterdotcom
    Contributor

    $500 approved for @nkdengineer in Report ID: R00JRhw1xXfR.

    Zapier Logs Run ID: 00040eee-ab7c-a813-8912-7e77ac421af4

    SO: https://stackoverflowteams.com/c/expensify/questions/7582

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

Metadata

Metadata

Labels

Awaiting PaymentAuto-added when associated PR is deployed to productionBugSomething is broken. Auto assigns a BugZero manager.DailyKSv2ExternalAdded to denote the issue can be worked on by a contributor

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions