Skip to content

[$175] iOS - Expense - App freezes after tapping Submit button twice on the preview #104776

Open

Description

@applause-bot

If you haven’t already, check out our contributing guidelines for onboarding. To join our Slack channel, fill out this form.


Version Number: 9.5.16-1
Reproducible in staging?: Yes
Reproducible in production?: Yes
If this was caught during regression testing, add the test name, ID and link from BrowserStack: https://test-management.browserstack.com/projects/2219752/test-runs/79916141/folder/57605203/41237091/1896353462?q=violation&issue_type=jira&sort=-identifier
Email or phone of affected tester (no customers): jsoidjndoiniodnsjojd@gmail.com
Issue reported by: Applause Internal Team
Bug source: Regression TC Execution
Device used: iPhone 17 Pro Max / iOS 27.0.1
App Component: Money Requests

Action Performed:

Precondition:

  • Enable Rules.
  • Set require receipt above amount $25.
  1. Launch Expensify app.
  2. Go to workspace chat.
  3. Create an expense with amount over $25.
  4. Tap Submit twice on the preview.

Expected Result:

App will not freeze after tapping Submit button twice on the preview.

Actual Result:

App freezes after tapping Submit button twice on the preview.

Workaround:

Unknown

Platforms:

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

Screenshots/Videos

Bug7293529_1792686944882.ScreenRecording_10-23-2026_00-32-42_1.mp4

View all open jobs on GitHub

Applause Internal Information
Exported by: Svetlana Lazutkina
Bug ID: 7293529

Upwork Automation - Do Not Edit

Activity

  1. added
    BugSomething is broken. Auto assigns a BugZero manager.
    on Oct 22, 2026
  2. applause-bot commented on Oct 22, 2026

    @applause-bot
    Author

    While Applause doesn't embed attachments for this bug, here are the links for quick view:
    https://platform.applause.com/services/links/v1/external/1d8187ed630d1e92b6fd6159ddc413628718376c87057ffcbf388b24c9a7e14b

  3. MelvinBot commented on Oct 22, 2026

    @MelvinBot
    Contributor

    Proposal

    What is the root cause of that problem?

    Prevent resubmission until report violations are acknowledged caused this. It added a "Submit report?" violations modal to the preview's Submit button. It opens that modal through showConfirmModalAfterMoreMenuDismiss. That helper is meant for modals opened from a popover menu.

    On iOS only, the helper waits up to 1 second for a transition before it shows the modal. A plain button tap starts no transition, so the modal appears late. During that wait, the Submit button stays enabled and nothing covers it. A second tap queues a second modal.

    The global modal stack only renders the top modal. The second modal replaces the first just as the first finishes presenting. On iOS, that overlap likely leaves an invisible full-screen modal that blocks all touches, so the app looks frozen.

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

    1. In showSubmitViolationsConfirmModal, call showConfirmModal directly instead of showConfirmModalAfterMoreMenuDismiss. The modal then opens at once, and its backdrop catches the second tap, as it does on web and Android. Keep the deferral only for callers that open from a popover menu, for example through an optional shouldDeferForPopoverDismiss param.
    2. Add a re-entrancy guard in useConfirmSubmitReportViolations. Use a ref that is set while the modal is pending and cleared when it resolves. A second call returns early while the ref is set. This also protects the report header's Submit button, which uses the same path with no guard.

    What alternative solutions did you explore? (Optional)

    Pass a fixed id (for example, submit-violations-<reportID>) to showConfirmModal so the modal stack reuses the open entry. Both callers would then share one promise and run onProceed twice after one confirm, so this still needs the guard from step 2.

    Investigation details

    Code path (App at ecccc54):

    • Preview Submit handler, with no double-press guard: SubmitActionButton.tsx:130-179. The button only disables once isSubmittingAnimationRunning is true. That happens after submitReport, which runs after the user confirms the modal.
    • A missing receipt counts as a violation, so every tap leads to the modal: confirmSubmitViolationsThenProceed.ts:48.
    • The iOS wait is capped by MAX_TRANSITION_START_WAIT_MS: 1000. Both taps wait on the same "next transition" promise. When modal A mounts and starts its transition, tap 2's callback wakes and pushes modal B as soon as A finishes opening.

    Why only iOS: on other platforms, the helper calls showConfirmModal right away. The first modal's backdrop takes the second tap.

    Web test: I ran the same steps on web: Rules on, receipt required over $25, a $50 expense with no receipt, then a double-click on Submit. The "Submit report?" modal opened right away. The second click hit the backdrop and closed it. The app stayed responsive. This matches the explanation. I haven't confirmed the native iOS failure on a device.

    Before the PR: a missing-receipt report went straight to submitReport, which disabled the button during the submit animation.

    Other causes I ruled out:

    • A double submitReport call: onProceed only runs after a confirm.
    • A stuck transition: the 1-second timeout always flushes the queue.

    Next Steps for Contributor+ team:
    To accept: @MelvinBot implement [this](https://github.com/Expensify/App/issues/104776) to create a draft PR.
    To refine: @MelvinBot <your feedback>
    To reject: Explain why you are rejecting Melvin's proposal.


    view run · view recording

  4. added
    ExternalAdded to denote the issue can be worked on by a contributor
    on Oct 22, 2026
  5. Abdallah-1551 commented on Oct 22, 2026

    @Abdallah-1551

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    On iOS, a workspace expense over the "require receipt above $25" threshold has no receipt, so it carries a receipt-required violation. If the user taps Submit twice quickly on the expense/report preview in the chat, the app freezes and has to be killed. A double tap on Submit should either be ignored or submit once, and never hang the app.

    What is the root cause of that problem?

    Kimi's analysis was empty and the thread has no logs, so this is based on the repro and the code path, not a confirmed trace.

    • The Submit button on the preview (MoneyRequestReportPreviewContent / ReportPreview → submitReport) is not protected against re-entry. The first tap starts the "submitting" animation and fires the optimistic update. In the violation case, the report/transaction state is also being rewritten at that moment.
    • The button stays enabled and pressable until Onyx re-renders it away. The second tap runs the same handler again on already-changed data. That produces a duplicate submitReport call, duplicate optimistic data and possibly duplicate navigation or modal presentation.
    • On iOS this overlap is what freezes the app. Either the preview and Onyx keep re-triggering each other in a render loop, or two native screen or modal transitions are queued on top of each other and block the UI thread.
    • The action layer (IOU.submitReport) also has no guard against being called on a report that is already submitted or being submitted.

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

    1. Add a re-entrancy guard in the preview's Submit handler. Use a ref, not state, so it blocks synchronously before the next render:
    const isSubmitInProgressRef = useRef(false);
    
    const handleSubmit = useCallback(() => {
        if (isSubmitInProgressRef.current || isSubmittingAnimationRunning) {
            return;
        }
        if (isDelegateAccessRestricted) {
            showDelegateNoAccessModal();
            return;
        }
        isSubmitInProgressRef.current = true;
        startSubmittingAnimation();
        submitReport(iouReport, policy, currentUserEmail, ...);
    }, [...]);
    
    // Release the guard once the report is no longer submittable
    useEffect(() => {
        if (!shouldShowSubmitButton) {
            isSubmitInProgressRef.current = false;
        }
    }, [shouldShowSubmitButton]);

    Wrap the handler in useSingleExecution / useThrottledButtonState if it is already used elsewhere in the same component. Also pass isDisabled={isSubmitInProgressRef.current || isSubmittingAnimationRunning} and isLoading to the Button so the tap is visibly blocked.

    1. Make the action idempotent. At the top of submitReport (in libs/actions/IOU), return early if the report is already submitted or processing:
    if (!expenseReport || expenseReport.stateNum !== CONST.REPORT.STATE_NUM.OPEN ||
        expenseReport.statusNum !== CONST.REPORT.STATUS_NUM.OPEN) {
        return;
    }

    This protects every entry point (preview, header, search), not only the preview.

    1. Guard any navigation or modal opened from this path. If the violation case navigates (for example to the report or a "missing receipt" flow), use the existing Navigation.navigate de-dupe or isNavigating guard so a second tap cannot push the same route twice.

    2. If the freeze still reproduces after steps 1–3, profile the preview for a render loop. Look for a useEffect that sets state or calls an action based on violations or isSubmittingAnimationRunning, and make its dependencies stable and its condition idempotent.

    What alternative solutions did you explore? (Optional)

    • Debouncing the button with a timer. Rejected: it is timing-dependent, it doesn't fix the non-idempotent action, and it still lets slow devices double-submit.
    • Relying only on the isDisabled prop driven by state. Rejected: the state update is asynchronous, so two taps can land in the same frame before the re-render. A ref guard is required.
    • Hiding the Submit button when violations exist. Rejected: it changes product behavior, and Submit is intended to be available (with violations shown on the report).

    Testing

    1. Set up a workspace with Rules enabled and "Require receipt above" set to $25. Create a manual expense over $25 with no receipt in the workspace chat.
    2. On iOS (iPhone, a release or a throttled debug build), double-tap and rapid-multi-tap Submit on the preview. Verify:
      • The app does not freeze.
      • The report is submitted exactly once (one SubmitReport request in the network log and one submitted action in the chat).
      • The submitting animation plays once.
    3. Repeat with a single tap, and with an expense under $25 or one that has a receipt, to confirm there is no regression.
    4. Repeat on Android, web and desktop to confirm the same behavior.
    5. Add a unit test for submitReport showing that calling it twice on the same report issues only one API write and one set of optimistic updates. Add a component test showing that firing press twice on the Submit button calls submitReport once.
  6. melvin-bot commented on Oct 22, 2026

    @melvin-bot

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

  7. changed the title [-]iOS - Expense - App freezes after tapping Submit button twice on the preview[/-] [+][$175] iOS - Expense - App freezes after tapping Submit button twice on the preview[/+] on Oct 22, 2026
  8. melvin-bot commented on Oct 22, 2026

    @melvin-bot
  9. magomedta commented on Oct 22, 2026

    @magomedta

    Contributor details
    Your Expensify account email: magomedtaneev@gmail.com
    Upwork Profile Link: https://www.upwork.com/freelancers/~01777d047a06f0e470

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    On iOS, tapping the Submit button twice on an expense preview with policy violations (e.g. required receipt rule > $25) causes the app to freeze and stop responding to touches.

    What is the root cause of that problem?

    1. Unnecessary popover transition deferral on iOS:
      In src/libs/Violations/showSubmitViolationsConfirmModal.tsx:29:

      return showConfirmModalAfterMoreMenuDismiss(showConfirmModal, { ... });

      showConfirmModalAfterMoreMenuDismiss is designed specifically for popovers/menus to avoid transition clashes when a menu dismisses. On iOS (src/libs/showConfirmModalAfterMoreMenuDismiss.ts:18), it wraps the modal presentation in:

      TransitionTracker.runAfterTransitions({
          callback: () => {
              showConfirmModal(options).then(resolve);
          },
          waitForUpcomingTransition: true,
      });

      Because the Submit button on the preview is a plain button and not a popover menu, no transition is scheduled. On iOS, TransitionTracker sits waiting for the full 1000ms fallback timeout before presenting the modal.

    2. Touch window and concurrent modal collision:
      During this 1-second delay, the Submit button remains enabled and unblocked. When the user taps "Submit" a second time, a second confirmation modal is scheduled. When both promises execute near-simultaneously on iOS, the overlapping showConfirmModal presentations in ModalContext / native view controllers collide, leaving a touch-blocking backdrop view active with no interactive controls, freezing the UI.


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

    1. In src/libs/Violations/showSubmitViolationsConfirmModal.tsx:

    Directly call showConfirmModal instead of showConfirmModalAfterMoreMenuDismiss (or only defer if an explicit shouldDeferForPopover parameter is passed):

    --- a/src/libs/Violations/showSubmitViolationsConfirmModal.tsx
    +++ b/src/libs/Violations/showSubmitViolationsConfirmModal.tsx
    @@ -5,7 +5,7 @@ import SubmitViolationsList from '@components/SubmitViolationsList';
     
     import type {CurrencyListActionsContextType} from '@hooks/useCurrencyList';
     
    -import showConfirmModalAfterMoreMenuDismiss from '@libs/showConfirmModalAfterMoreMenuDismiss';
    +import type useConfirmModal from '@hooks/useConfirmModal';
     
     import CONST from '@src/CONST';
     
    @@ -13,7 +13,7 @@ import type {getReportSubmitViolationSummary} from './getReportSubmitViolationSu
     
     import {buildSubmitViolationBullets} from './getReportSubmitViolationSummary';
     
    -type ShowConfirmModal = Parameters<typeof showConfirmModalAfterMoreMenuDismiss>[0];
    +type ShowConfirmModal = ReturnType<typeof useConfirmModal>['showConfirmModal'];
     type ReportSubmitViolationSummary = ReturnType<typeof getReportSubmitViolationSummary>;
     
     type ShowSubmitViolationsConfirmModalParams = {
    @@ -26,7 +26,7 @@ type ShowSubmitViolationsConfirmModalParams = {
     function showSubmitViolationsConfirmModal({summary, showConfirmModal, translate, dateFnsLocale, convertToDisplayString, shouldShowMarkAsDoneCopy}: ShowSubmitViolationsConfirmModalParams) {
         const bullets = buildSubmitViolationBullets({summary, translate, dateFnsLocale, convertToDisplayString});
     
    -    return showConfirmModalAfterMoreMenuDismiss(showConfirmModal, {
    +    return showConfirmModal({
             title: translate(shouldShowMarkAsDoneCopy ? 'iou.confirmSubmitReportViolations.titleMarkAsDone' : 'iou.confirmSubmitReportViolations.title'),
             subtitle: translate(shouldShowMarkAsDoneCopy ? 'iou.confirmSubmitReportViolations.descriptionMarkAsDone' : 'iou.confirmSubmitReportViolations.description'),
             prompt: <SubmitViolationsList violations={bullets} />,

    This ensures the modal presents immediately on iOS, allowing its backdrop to catch subsequent taps (matching Web and Android).

    2. In src/hooks/useConfirmSubmitReportViolations.ts:

    Add a re-entrancy guard using a ref (isConfirmModalOpenRef) to guarantee that rapid double-taps before the modal renders cannot trigger multiple modal calls:

    --- a/src/hooks/useConfirmSubmitReportViolations.ts
    +++ b/src/hooks/useConfirmSubmitReportViolations.ts
    @@ -10,6 +10,7 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
     
    +import {useRef} from 'react';
     import useConfirmModal from './useConfirmModal';
     import {useCurrencyListActions} from './useCurrencyList';
     import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails';
    @@ -37,18 +38,28 @@ function useConfirmSubmitReportViolations({
         const {accountID: currentUserAccountID, email: currentUserEmail} = useCurrentUserPersonalDetails();
         const {transactions: ownedTransactions, violations: ownedViolationsCollection} = useTransactionsAndViolationsForReport(transactionsOverride ? undefined : reportID);
     
         const transactions = transactionsOverride ?? Object.values(ownedTransactions);
         const violationsCollection = violationsCollectionOverride ?? ownedViolationsCollection;
    +    const isConfirmingRef = useRef(false);
     
         return (onProceed: ConfirmSubmitReportViolationsOnProceed) => {
    +        if (isConfirmingRef.current) {
    +            return;
    +        }
             const summary = getReportSubmitViolationSummary(transactions, violationsCollection, report, policy, currentUserEmail ?? '', currentUserAccountID);
    +        isConfirmingRef.current = true;
             confirmSubmitViolationsThenProceed({
                 summary,
                 showConfirmModal,
                 translate,
                 dateFnsLocale,
                 convertToDisplayString,
                 shouldShowMarkAsDoneCopy,
    -            onProceed,
    +            onProceed: (shouldResolve) => {
    +                isConfirmingRef.current = false;
    +                onProceed(shouldResolve);
    +            },
    +            onCancel: () => {
    +                isConfirmingRef.current = false;
    +            },
             });
         };
     }

    What alternative solutions did you explore? (Optional)

    • Adding a submit-in-progress guard only in submitReport:
      Rejected because the freeze occurs before submitReport is ever called; the freeze is caused by duplicate modal presentations blocking touch events on iOS.
    • Throttling the button with setTimeout:
      Rejected because synthetic timeouts do not fix the root timing bug in showConfirmModalAfterMoreMenuDismiss and are prone to race conditions across different device speeds.

    What automated tests can you add to prevent this from re-occurring?

    In tests/ui/components/SubmitActionButtonTest.tsx and tests/unit/hooks/useConfirmSubmitReportViolations.test.ts:

    1. Render SubmitActionButton with violations present.
    2. Rapidly fire two press events on the Submit button.
    3. Assert that showConfirmModal is called exactly once.
    4. Verify that the confirmation modal presents immediately without waiting for transition timeouts.
  10. melvin-bot commented on Oct 22, 2026

    @melvin-bot

    ✅ Contributor details stored successfully. Thank you for contributing to Expensify!

  11. camesenin commented on Oct 22, 2026

    @camesenin

    Proposal

    Problem

    On iOS, tapping the Submit button twice in quick succession on an expense report preview containing policy violations causes the application to freeze, leaving the interface unresponsive to touch events.

    Root Cause

    1. In src/libs/Violations/showSubmitViolationsConfirmModal.tsx (line 32):
    return showConfirmModalAfterMoreMenuDismiss(showConfirmModal, {
        title: translate(...),
        ...
    });

    showSubmitViolationsConfirmModal invokes showConfirmModalAfterMoreMenuDismiss.
    2. As implemented in src/libs/showConfirmModalAfterMoreMenuDismiss.ts:

    function showConfirmModalAfterMoreMenuDismiss(showConfirmModal: ShowConfirmModal, options: ShowConfirmModalOptions): Promise<ShowConfirmModalResult> {
        if (getPlatform() !== CONST.PLATFORM.IOS) {
            return showConfirmModal(options);
        }
    
        return new Promise((resolve) => {
            TransitionTracker.runAfterTransitions({
                callback: () => {
                    showConfirmModal(options).then(resolve);
                },
                waitForUpcomingTransition: true,
            });
        });
    }

    On iOS, showConfirmModalAfterMoreMenuDismiss defers modal presentation until after navigation transitions finish (waitForUpcomingTransition: true), which introduces an intentional delay (up to 1,000ms) meant strictly for dismiss animations of 3-dot / popover menus.
    3. However, the preview's Submit button (SubmitActionButton.tsx / SubmitPrimaryAction.tsx) is a direct screen button, not a popover item. Because of this deferred presentation on iOS:

    • When the user taps Submit, the button remains active and unblocked for hundreds of milliseconds while waiting for the transition tracker.
    • When the user rapidly taps Submit a second time, a second confirmation modal is requested concurrently.
    • The global modal stack (ModalContext.tsx) only renders the top-most modal. The second modal presentation interrupts the first modal transition mid-presentation on iOS native view hierarchy, leaving the modal container in an orphaned / non-interactive backdrop state that intercepts all native window touches, giving the appearance of a hard freeze.
    1. Additionally, useConfirmSubmitReportViolations provides no re-entrancy prevention while modal presentation is pending.

    Solution

    1. In src/libs/Violations/showSubmitViolationsConfirmModal.tsx:
      Directly call showConfirmModal instead of showConfirmModalAfterMoreMenuDismiss. The violations modal is presented immediately upon button tap on iOS, exactly matching Web and Android, and its native backdrop immediately catches any trailing second tap:
    --- a/src/libs/Violations/showSubmitViolationsConfirmModal.tsx
    +++ b/src/libs/Violations/showSubmitViolationsConfirmModal.tsx
    @@ -26,7 +26,7 @@ type ShowSubmitViolationsConfirmModalParams = {
     function showSubmitViolationsConfirmModal({summary, showConfirmModal, translate, dateFnsLocale, convertToDisplayString, shouldShowMarkAsDoneCopy}: ShowSubmitViolationsConfirmModalParams) {
         const bullets = buildSubmitViolationBullets({summary, translate, dateFnsLocale, convertToDisplayString});
     
    -    return showConfirmModalAfterMoreMenuDismiss(showConfirmModal, {
    +    return showConfirmModal({
             title: translate(shouldShowMarkAsDoneCopy ? 'iou.confirmSubmitReportViolations.titleMarkAsDone' : 'iou.confirmSubmitReportViolations.title'),
             subtitle: translate(shouldShowMarkAsDoneCopy ? 'iou.confirmSubmitReportViolations.descriptionMarkAsDone' : 'iou.confirmSubmitReportViolations.description'),
    1. In src/hooks/useConfirmSubmitReportViolations.ts:
      Add a re-entrancy guard using a ref (isConfirmModalOpenRef) to ensure that even during rapid multi-touch before layout or render cycles, only a single confirmation modal workflow can be active:
        const isConfirmingRef = useRef(false);
    
        return (onProceed: ConfirmSubmitReportViolationsOnProceed) => {
            if (isConfirmingRef.current) {
                return;
            }
            isConfirmingRef.current = true;
            const summary = getReportSubmitViolationSummary(transactions, violationsCollection, report, policy, currentUserEmail ?? '', currentUserAccountID);
            confirmSubmitViolationsThenProceed({
                summary,
                showConfirmModal,
                translate,
                dateFnsLocale,
                convertToDisplayString,
                shouldShowMarkAsDoneCopy,
                onProceed: (shouldResolve) => {
                    isConfirmingRef.current = false;
                    onProceed(shouldResolve);
                },
                onCancel: () => {
                    isConfirmingRef.current = false;
                },
            });
        };

    And support optional onCancel?: () => void in confirmSubmitViolationsThenProceed.ts so isConfirmingRef is reset if the user dismisses or cancels the modal.

    Alternative Solutions Explored

    • Adding disabled state via local React state on SubmitActionButton: React state update is asynchronous and does not protect against near-instantaneous double taps within the same JS tick. Using useRef provides synchronous blocking.
    • Leaving showConfirmModalAfterMoreMenuDismiss: Deferring modal presentation with a timeout when no popover menu is actually dismissing is bad UX and the direct cause of the delay that enables the double-tap bug.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

BugSomething 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