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
[$175] iOS - Expense - App freezes after tapping Submit button twice on the preview #104776
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?
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.
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.
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.
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?
Add a re-entrancy guard in the preview's Submit handler. Use a ref, not state, so it blocks synchronously before the next render:
constisSubmitInProgressRef=useRef(false);consthandleSubmit=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 submittableuseEffect(()=>{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.
Make the action idempotent. At the top of submitReport (in libs/actions/IOU), return early if the report is already submitted or processing:
This protects every entry point (preview, header, search), not only the preview.
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.
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
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.
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.
Repeat with a single tap, and with an expense under $25 or one that has a receipt, to confirm there is no regression.
Repeat on Android, web and desktop to confirm the same behavior.
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.
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
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?
Unnecessary popover transition deferral on iOS:
In src/libs/Violations/showSubmitViolationsConfirmModal.tsx:29:
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:
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.
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:
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:
Render SubmitActionButton with violations present.
Rapidly fire two press events on the Submit button.
Assert that showConfirmModal is called exactly once.
Verify that the confirmation modal presents immediately without waiting for transition timeouts.
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
In src/libs/Violations/showSubmitViolationsConfirmModal.tsx (line 32):
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.
Additionally, useConfirmSubmitReportViolations provides no re-entrancy prevention while modal presentation is pending.
Solution
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:
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:
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.
Activity
applause-bot commentedon Oct 22, 2026
While Applause doesn't embed attachments for this bug, here are the links for quick view:
https://platform.applause.com/services/links/v1/external/1d8187ed630d1e92b6fd6159ddc413628718376c87057ffcbf388b24c9a7e14b
MelvinBot commentedon Oct 22, 2026
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?
showSubmitViolationsConfirmModal, callshowConfirmModaldirectly instead ofshowConfirmModalAfterMoreMenuDismiss. 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 optionalshouldDeferForPopoverDismissparam.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>) toshowConfirmModalso the modal stack reuses the open entry. Both callers would then share one promise and runonProceedtwice after one confirm, so this still needs the guard from step 2.Investigation details
Code path (App at
ecccc54):SubmitActionButton.tsx:130-179. The button only disables onceisSubmittingAnimationRunningis true. That happens aftersubmitReport, which runs after the user confirms the modal.confirmSubmitViolationsThenProceed.ts:48.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
showConfirmModalright 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:
submitReportcall:onProceedonly runs after a confirm.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
Abdallah-1551 commentedon Oct 22, 2026
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.
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.submitReportcall, duplicate optimistic data and possibly duplicate navigation or modal presentation.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?
Wrap the handler in
useSingleExecution/useThrottledButtonStateif it is already used elsewhere in the same component. Also passisDisabled={isSubmitInProgressRef.current || isSubmittingAnimationRunning}andisLoadingto theButtonso the tap is visibly blocked.submitReport(inlibs/actions/IOU), return early if the report is already submitted or processing:This protects every entry point (preview, header, search), not only the preview.
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.navigatede-dupe orisNavigatingguard so a second tap cannot push the same route twice.If the freeze still reproduces after steps 1–3, profile the preview for a render loop. Look for a
useEffectthat sets state or calls an action based on violations orisSubmittingAnimationRunning, and make its dependencies stable and its condition idempotent.What alternative solutions did you explore? (Optional)
isDisabledprop 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.Testing
SubmitReportrequest in the network log and one submitted action in the chat).submitReportshowing 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 firingpresstwice on the Submit button callssubmitReportonce.melvin-bot commentedon Oct 22, 2026
Triggered auto assignment to Contributor-plus team member for initial proposal review - @ChavdaSachin (
External)[-]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[/+]melvin-bot commentedon Oct 22, 2026
Job added to Upwork: https://www.upwork.com/jobs/~022113315152225129362
magomedta commentedon Oct 22, 2026
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?
Unnecessary popover transition deferral on iOS:
In
src/libs/Violations/showSubmitViolationsConfirmModal.tsx:29:showConfirmModalAfterMoreMenuDismissis 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:Because the Submit button on the preview is a plain button and not a popover menu, no transition is scheduled. On iOS,
TransitionTrackersits waiting for the full 1000ms fallback timeout before presenting the modal.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
showConfirmModalpresentations inModalContext/ 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
showConfirmModalinstead ofshowConfirmModalAfterMoreMenuDismiss(or only defer if an explicitshouldDeferForPopoverparameter is passed):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:What alternative solutions did you explore? (Optional)
submitReport:Rejected because the freeze occurs before
submitReportis ever called; the freeze is caused by duplicate modal presentations blocking touch events on iOS.setTimeout:Rejected because synthetic timeouts do not fix the root timing bug in
showConfirmModalAfterMoreMenuDismissand 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.tsxandtests/unit/hooks/useConfirmSubmitReportViolations.test.ts:SubmitActionButtonwith violations present.pressevents on the Submit button.showConfirmModalis called exactly once.melvin-bot commentedon Oct 22, 2026
✅ Contributor details stored successfully. Thank you for contributing to Expensify!
camesenin commentedon Oct 22, 2026
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
src/libs/Violations/showSubmitViolationsConfirmModal.tsx(line 32):showSubmitViolationsConfirmModalinvokesshowConfirmModalAfterMoreMenuDismiss.2. As implemented in
src/libs/showConfirmModalAfterMoreMenuDismiss.ts:On iOS,
showConfirmModalAfterMoreMenuDismissdefers 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: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.useConfirmSubmitReportViolationsprovides no re-entrancy prevention while modal presentation is pending.Solution
src/libs/Violations/showSubmitViolationsConfirmModal.tsx:Directly call
showConfirmModalinstead ofshowConfirmModalAfterMoreMenuDismiss. 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: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:And support optional
onCancel?: () => voidinconfirmSubmitViolationsThenProceed.tssoisConfirmingRefis reset if the user dismisses or cancels the modal.Alternative Solutions Explored
disabledstate via local React state onSubmitActionButton: React state update is asynchronous and does not protect against near-instantaneous double taps within the same JS tick. UsinguseRefprovides synchronous blocking.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.