You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[Due for payment 2026-10-28] [$175] Migrate the Expensify Card statement PDF modal to the global modal stack #103508
The Expensify Card statement PDF flow mounts ExpensifyCardStatementPDFDownloadModal inline from Search bulk actions and owns its visibility and cleanup state in the caller. Move it to the imperative/global modal stack.
Scope
Migrate ExpensifyCardStatementPDFDownloadModal and the Search bulk-action flow.
Reuse the global PDF modal pattern established by the report PDF migration.
Pass statement parameters through the show API while preserving the asynchronously populated statement key.
Remove obsolete visibility state, inline mounting, and modal-hide plumbing from Search.
Acceptance criteria
Card statement PDF downloads open through the global modal stack.
The modal remains reactive while statement generation completes and downloads the correct file.
Selection cleanup still occurs after the modal has finished hiding.
The one-feed-at-a-time alert and existing error/cancel behavior remain unchanged.
Relevant Search and statement PDF tests are updated or added.
Please re-state the problem that we are trying to solve in this issue.
The Expensify Card statement PDF modal is mounted inline in the Search bulk-action bar. Search owns its visibility, params and hide cleanup. It should open through the global modal stack, like the report PDF modal does now.
useSearchBulkActions keeps isExpensifyCardStatementPDFModalVisible and expensifyCardStatementPDFParams. It merges statementKey into the params after GetExpensifyCardStatementPDF returns.
Selection is cleared in handleExpensifyCardStatementPDFModalHide. The cleanup waits for hide because the modal lives inside the selection-gated bar and would unmount if the selection cleared first.
statementKey only comes from the API response, not from Onyx. So the modal can't just read it after it opens, the way ReportPDFDownloadModal reads its filename.
What changes do you think we should make in order to solve the problem?
Add a wrapper. Create src/components/Modal/Global/ExpensifyCardStatementPDFDownloadModalWrapper.tsx, modeled on ReportPDFDownloadModalWrapper. It takes statementParams and keeps a local isVisible. onClose hides it, and onModalHide calls closeModal() only after it is hidden. The promise therefore resolves after the hide animation ends.
Add a show hook. Create useExpensifyCardStatementPDFDownloadModal returning showExpensifyCardStatementPDFDownloadModal({statementParams}) and closeExpensifyCardStatementPDFDownloadModal(). It calls showModal with a fixed id, CONST.MODAL.EXPENSIFY_CARD_STATEMENT_PDF or similar.
Push statementKey in place. In exportExpensifyCardStatementPDF, show the modal with the initial params. When the response returns, call the show function again with {...statementParams, statementKey}. showModal updates the props of an open modal with the same id instead of stacking a new one (ModalContext.tsx:81-91). The modal doesn't remount, so the Onyx-driven auto-download still fires once the file is ready.
Guard stale responses. Keep the existing requestID check. Also bump the request id when the modal's promise resolves. A late response then can't reopen a modal the user already closed, because a same-id call after close would push a new modal.
Clean up after hide. Chain .then(() => clearSelectedTransactions()) on the show promise. Selection still clears only after the modal has finished hiding.
Errors. In showStatementError, call closeModalByID(id) instead of the visibility setter, then show the existing download-failed modal. The one-feed-at-a-time alert and the offline check are unchanged.
Remove the old plumbing. Delete the visibility and params state, setIsExpensifyCardStatementPDFModalVisible, handleExpensifyCardStatementPDFModalHide, their return values, and the inline mount in SearchBulkActionsButton.
Tests.
Update the statement cases in tests/unit/hooks/useSearchBulkActionsDownloadPDFTest.ts to mock the new hook. Assert the first show args, the in-place update with statementKey, closeModalByID on error, stale-request handling, and that clearSelectedTransactions runs only after the show promise resolves.
Add a small wrapper test: closeModal must not run until onModalHide fires after close.
What alternative solutions did you explore? (Optional)
The wrapper could start GetExpensifyCardStatementPDF itself on mount and keep statementKey in local state. That removes the need for in-place updates and the request-id ref. However, it moves the API call and error handling out of Search and changes more behavior. The issue also asks to pass the statement params through the show API, so I prefer the approach above.
Next Steps for Contributor+ team: To accept:@MelvinBot implement [this](https://github.com/Expensify/App/issues/103508) to create a draft PR. To refine:@MelvinBot <your feedback> To reject: Explain why you are rejecting Melvin's proposal.
🤖 Draft PR created: #103708
Please mark it as "Ready for review" or ask me to do that for you when it is ready for review.
The PR follows the proposal. The card statement PDF modal now opens through the global modal stack. The statementKey is pushed into the open modal in place, and the selection clears after the modal hides. On failure the selection is kept, as before.
I couldn't push to your fork, so the branch is on upstream. Unit tests, typecheck and lint pass. No browser test was run, because the test account has no Expensify Card settlements to export. Please fill in the Tests and QA sections.
changed the title [-]Migrate the Expensify Card statement PDF modal to the global modal stack[/-][+][$175] Migrate the Expensify Card statement PDF modal to the global modal stack[/+]on Oct 15, 2026
[Contributor] The offending PR and associated issue have been commented on, pointing out the bug it caused and why, so the author and reviewers can learn from the mistake.
Link to the comment on the PR: N/A
[Contributor] If the regression was CRITICAL (e.g. interrupts a core flow) A discussion in #expensify-open-source has been started about whether any other steps should be taken (e.g. updating the PR review checklist) in order to catch this type of bug sooner.
Link to discussion:
[Contributor] If it was decided to create a regression test for the bug, please propose the regression test steps using the template below to ensure the same bug will not reach production again.
Regression Test Proposal
Precondition:
sign in as an admin of a workspace with the Expensify Card enabled and at least two Expensify Card settlements.
Test:
Download a statement
Go to Reports and search for type:expense group-by:withdrawal-id withdrawal-type:expensify-card.
Select one settlement.
Open the bulk actions menu and click Download statement.
Verify the "Download statement" modal opens and shows "Please wait while we generate the PDF."
Verify the message changes to "Your PDF has been generated!" and the PDF downloads automatically.
Close the modal.
Verify the modal closes and the settlement selection is cleared.
Close while generating
Select one settlement and click Download statement.
Close the modal right away, while it still says "Please wait while we generate the PDF."
Wait a few seconds.
Verify the modal does not reopen.
Select the settlement again and click Download statement.
Verify the modal opens and the PDF downloads.
Multiple feeds
Select settlements from two different Expensify Card feeds. Skip this test if the account has only one feed.
Click Download statement.
Verify the "Please select settlements from one Expensify Card feed at a time." alert appears and no statement modal opens.
Regression: report PDF
Search for type:expense-report, select one report, and click Download report in the bulk actions menu.
Verify the report PDF modal still opens and downloads the PDF.
Activity
daledah commentedon Oct 12, 2026
@MelvinBot please post a proposal to this issue
MelvinBot commentedon Oct 12, 2026
Proposal
Please re-state the problem that we are trying to solve in this issue.
The Expensify Card statement PDF modal is mounted inline in the Search bulk-action bar. Search owns its visibility, params and hide cleanup. It should open through the global modal stack, like the report PDF modal does now.
What is the root cause of that problem?
This flow was not part of the report PDF migration in Clear Search selection immediately when downloading a single report PDF. So it still uses the old pattern:
useSearchBulkActionskeepsisExpensifyCardStatementPDFModalVisibleandexpensifyCardStatementPDFParams. It mergesstatementKeyinto the params afterGetExpensifyCardStatementPDFreturns.SearchBulkActionsButtonrenders<ExpensifyCardStatementPDFDownloadModal>inline.handleExpensifyCardStatementPDFModalHide. The cleanup waits for hide because the modal lives inside the selection-gated bar and would unmount if the selection cleared first.statementKeyonly comes from the API response, not from Onyx. So the modal can't just read it after it opens, the wayReportPDFDownloadModalreads its filename.Code references
statementKeymerge: useSearchBulkActions.ts:931-987statementKeyread from the prop, whileisGeneratingand the filename come from Onyx: ExpensifyCardStatementPDFDownloadModal.tsx:23-34What changes do you think we should make in order to solve the problem?
src/components/Modal/Global/ExpensifyCardStatementPDFDownloadModalWrapper.tsx, modeled onReportPDFDownloadModalWrapper. It takesstatementParamsand keeps a localisVisible.onClosehides it, andonModalHidecallscloseModal()only after it is hidden. The promise therefore resolves after the hide animation ends.useExpensifyCardStatementPDFDownloadModalreturningshowExpensifyCardStatementPDFDownloadModal({statementParams})andcloseExpensifyCardStatementPDFDownloadModal(). It callsshowModalwith a fixed id,CONST.MODAL.EXPENSIFY_CARD_STATEMENT_PDFor similar.statementKeyin place. InexportExpensifyCardStatementPDF, show the modal with the initial params. When the response returns, call the show function again with{...statementParams, statementKey}.showModalupdates the props of an open modal with the same id instead of stacking a new one (ModalContext.tsx:81-91). The modal doesn't remount, so the Onyx-driven auto-download still fires once the file is ready.requestIDcheck. Also bump the request id when the modal's promise resolves. A late response then can't reopen a modal the user already closed, because a same-id call after close would push a new modal..then(() => clearSelectedTransactions())on the show promise. Selection still clears only after the modal has finished hiding.showStatementError, callcloseModalByID(id)instead of the visibility setter, then show the existing download-failed modal. The one-feed-at-a-time alert and the offline check are unchanged.setIsExpensifyCardStatementPDFModalVisible,handleExpensifyCardStatementPDFModalHide, their return values, and the inline mount inSearchBulkActionsButton.tests/unit/hooks/useSearchBulkActionsDownloadPDFTest.tsto mock the new hook. Assert the first show args, the in-place update withstatementKey,closeModalByIDon error, stale-request handling, and thatclearSelectedTransactionsruns only after the show promise resolves.closeModalmust not run untilonModalHidefires after close.Note on a related open PR
Use useDecisionModal for Search bulk actions and SelectionToolbar alerts is open. It edits the same files and moves the one-feed-at-a-time and download-failed alerts to
useDecisionModal(). This change should be based on it once it merges, so step 6 would call its error helper.What alternative solutions did you explore? (Optional)
The wrapper could start
GetExpensifyCardStatementPDFitself on mount and keepstatementKeyin local state. That removes the need for in-place updates and the request-id ref. However, it moves the API call and error handling out of Search and changes more behavior. The issue also asks to pass the statement params through the show API, so I prefer the approach above.Next Steps for Contributor+ team:
To accept:
@MelvinBot implement [this](https://github.com/Expensify/App/issues/103508)to create a draft PR.To refine:
@MelvinBot <your feedback>To reject: Explain why you are rejecting Melvin's proposal.
view run
daledah commentedon Oct 12, 2026
@MelvinBot implement this
MelvinBot commentedon Oct 12, 2026
🤖 Draft PR created: #103708
Please mark it as "Ready for review" or ask me to do that for you when it is ready for review.
The PR follows the proposal. The card statement PDF modal now opens through the global modal stack. The
statementKeyis pushed into the open modal in place, and the selection clears after the modal hides. On failure the selection is kept, as before.I couldn't push to your fork, so the branch is on upstream. Unit tests, typecheck and lint pass. No browser test was run, because the test account has no Expensify Card settlements to export. Please fill in the
TestsandQAsections.Use useDecisionModal for Search bulk actions and SelectionToolbar alerts edits the same files. Whichever PR merges second will need a small merge.
view run
[-]Migrate the Expensify Card statement PDF modal to the global modal stack[/-][+][$175] Migrate the Expensify Card statement PDF modal to the global modal stack[/+]melvin-bot commentedon Oct 15, 2026
Job added to Upwork: https://www.upwork.com/jobs/~022110638070580322345
7 remaining items
daledah commentedon Oct 23, 2026
Contributor+ Checklist:
[Contributor] The offending PR and associated issue have been commented on, pointing out the bug it caused and why, so the author and reviewers can learn from the mistake.
Link to the comment on the PR: N/A
[Contributor] If the regression was CRITICAL (e.g. interrupts a core flow) A discussion in #expensify-open-source has been started about whether any other steps should be taken (e.g. updating the PR review checklist) in order to catch this type of bug sooner.
Link to discussion:
[Contributor] If it was decided to create a regression test for the bug, please propose the regression test steps using the template below to ensure the same bug will not reach production again.
Regression Test Proposal
Precondition:
Test:
Download a statement
Close while generating
Multiple feeds
Regression: report PDF
Do we agree 👍 or 👎
melvin-bot commentedon Oct 27, 2026
Triggered auto assignment to @mallenexpensify (
Awaiting Payment)melvin-bot commentedon Oct 27, 2026
Payment Summary
Resolving PRs:
Move the Expensify Card statement PDF modal to the global modal stack #103708
Reviewer: @daledah owed $175 via NewDot
BugZero Checklist (@mallenexpensify)
mallenexpensify commentedon Oct 30, 2026
Payment Summary
Contributor+: @daledah due $175 via NewDot