fix: Preserve all matching selection after deselecting an expense#95902
fix: Preserve all matching selection after deselecting an expense#95902emkhalid wants to merge 15 commits into
Conversation
|
@mkhutornyi Please copy/paste the Reviewer Checklist from here into a new comment on this PR and complete it. If you have the K2 extension, you can simply click: [this button] |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: dc68c95a59
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
@codex review |
|
🤖 Reviewed the exclusion-tracking approach. The core design — keeping 1. Count vs. total may disagree for partially-excluded expense reports — worth confirming 2. Pagination offset reset is the highest-risk change — please QA carefully 3. Negative-amount (refund/credit) rows — low priority 4. Confirm the backend honors None of these are blockers on their own, but 1 and 2 are the ones I'd verify with a manual pass before merging. |
|
🤖 Code-quality pass (separate from the correctness notes above — this is about readability/maintainability, not bugs). The PR is generally clean: types are documented, the deps-omission comments were kept in sync, and the new unit tests are well-structured. A few things worth tightening: 1. Duplicated — and subtly divergent — selection-count logic (worth consolidating)
Two counters that are supposed to agree on the same selection can drift apart. Extracting one shared helper (e.g. in 2. Pagination/totals orchestration in 3. 4. Redundant per-consumer default for 5. Minor: naming drift Items 1 and 2 are the ones with real maintenance value; the rest are polish. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f0b00c16ff
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
mkhutornyi
left a comment
There was a problem hiding this comment.
Please address AI review comments above
|
🤖 Regression hunt on this diff. Most of the change is correctly gated behind the new all-matching/exclusion state, so ordinary selection and pagination are largely untouched. The risks below are the spots where existing behavior changes: R1 — Group / expense-report rows now derive their checked state purely from child Normal per-item selection is fine — for non-empty groups/reports selection is keyed by child transaction ID, so R2 — R3 — Pagination offset can be pushed backward under all-matching R4 — R1 is the one I'd verify first, since it touches the existing select-all display and isn't behind the exclusion flag. |
|
🤖 Regression scan of PR #95902. These are behaviors that worked before and could break — none are confirmed at runtime (logic-derived), so I've ranked by likelihood and flagged what to test. 1. Group/report header checkbox can show unchecked under "Select all matching" (medium)
Previously both fell back to 2. Pagination offset can be pulled backward (medium) 3. Count/total no longer refreshed while paginating under all-matching (low) 4. Bulk-actions bar stays visible when all loaded rows are excluded (low) Item 1 is the one I'd verify first — it touches the most visible everyday flow (Select all in grouped/report views). |
|
@mkhutornyi I found an additional issue while testing this PR:
The backend returns Should we address this in the current PR? It appears the backend may need to return the total report count, possibly as a separate field. staging.mp4 |
|
The original scope is |
I verified all four points and found no regression within this PR’s scope:
No changes are required for these points. |
Addressed the safe, in-scope cleanup:
I did not change the other suggestions:
No functionality was changed. |
Verified all four concerns:
No broader pagination, totals, or context refactor was made. |
Reviewed all four concerns:
No further changes are needed. |
|
@mkhutornyi thanks for the review, I addressed your comments please review again. |
Reviewer Checklist
Screenshots/VideosAndroid: HybridAppAndroid: mWeb ChromeiOS: HybridAppiOS: mWeb SafariMacOS: Chrome / Safari |
mkhutornyi
left a comment
There was a problem hiding this comment.
Not bugs but left some questions to clarify
| @@ -1023,6 +1044,7 @@ function search({ | |||
| }) | |||
| .finally(() => { | |||
| inFlightSearchRequests.delete(dedupeKey); | |||
| return inFlightRequestState.pendingTotalsRequest?.(); | |||
There was a problem hiding this comment.
What does this change fix?
There was a problem hiding this comment.
This fixes a Slow 3G race. For example, if pagination with shouldCalculateTotals: false is still running when the user selects all, the new request with shouldCalculateTotals: true was treated as a duplicate and dropped. This queues that totals request and runs it after pagination finishes, so the correct count and footer load.
| // so later pagination requests can avoid recalculating totals. | ||
| if (areAllMatchingItemsSelected) { | ||
| return true; | ||
| return enabled; |
There was a problem hiding this comment.
This prevents totals from being recalculated on every page after Select all.
enabled is true when the count is missing, so selecting all still sends shouldCalculateTotals: true. Once the count is available, later pagination uses shouldCalculateTotals: false, keeping those requests lightweight.
|
|
||
| if (areAllMatchingItemsSelected) { | ||
| if (selectedTransactionsKeys.length === 0 || !hash) { | ||
| if (!hash) { |
There was a problem hiding this comment.
Why was this condition removed?
There was a problem hiding this comment.
All-matching selection does not require loaded transaction IDs. For example, if the user excludes every loaded row, selectedTransactionsKeys is empty but off-screen matching expenses are still selected.
The export uses the search query plus excludedTransactionIDList, so only the search hash is required here.
| continue; | ||
| } | ||
| if (reportKey && (reportKey in selectedTransactions || areAllMatchingItemsSelected)) { | ||
| if (reportKey && !Object.hasOwn(excludedTransactions, reportKey) && (reportKey in selectedTransactions || areAllMatchingItemsSelected)) { |
There was a problem hiding this comment.
Any reason for not using !excludedTransactions[reportKey]?
There was a problem hiding this comment.
excludedTransactions is a key-presence map, so Object.hasOwn() checks whether the report key is explicitly excluded instead of relying on the value’s truthiness. It also matches the other exclusion checks in this flow.
|
|
||
| for (const transactionItem of transactionGroup.transactions) { | ||
| const listKey = transactionItem.keyForList ?? transactionItem.transactionID; | ||
| const isExcluded = Object.hasOwn(excludedTransactions, listKey) || Object.hasOwn(excludedTransactions, transactionItem.transactionID); |
There was a problem hiding this comment.
Same here. Why Object.hasOwn used?
There was a problem hiding this comment.
For the same reason: exclusions are identified by key presence. The map may use either keyForList or transactionID, so Object.hasOwn() explicitly checks both possible keys and matches the other selection-map checks.
| if (isExpenseReportType) { | ||
| return new Set( | ||
| Object.values(transactions) | ||
| .map((transaction) => transaction.reportID) | ||
| .filter((reportID): reportID is string => !!reportID), | ||
| ).size; |
There was a problem hiding this comment.
As discussed, please make sure that this PR doesn't affect production behavior on Spend > Reports tab
There was a problem hiding this comment.
Addressed. I restored Spend > Reports to its existing main behavior and scoped the new exclusion handling to type:expense.
Reports no longer preserve all-matching mode or apply exclusion-aware counters, totals, pagination, bulk-bar, checkbox, or export behavior. The existing Reports count issue remains unchanged and will be handled separately.
| @@ -400,18 +412,36 @@ function Search({ | |||
| return; | |||
| } | |||
|
|
|||
| // Once a totals request for an already-loaded page completes, `shouldCalculateTotals` switches | |||
| // back to false. Do not immediately repeat that same page request without totals; the next real | |||
| // pagination offset change will trigger the appropriate request. | |||
| const didJustFinishAllMatchingTotalsRequest = | |||
| areAllMatchingItemsSelected && | |||
| previousShouldCalculateTotals === true && | |||
| !shouldCalculateTotals && | |||
| typeof searchResults?.search?.count === 'number' && | |||
| searchResults.search.offset === offset; | |||
| if (didJustFinishAllMatchingTotalsRequest) { | |||
| return; | |||
| } | |||
| const didEnableAllMatchingTotalsWithExistingCount = | |||
| areAllMatchingItemsSelected && previousShouldCalculateTotals === false && shouldCalculateTotals && typeof searchResults?.search?.count === 'number'; | |||
| if (didEnableAllMatchingTotalsWithExistingCount) { | |||
| return; | |||
| } | |||
|
|
|||
There was a problem hiding this comment.
What bug does this change fix?
There was a problem hiding this comment.
This prevents duplicate Search requests introduced by the lazy totals flow in this PR:
- When a totals response arrives,
shouldCalculateTotalschanges fromtruetofalse. Without the first guard, the same page is immediately requested again without totals. - When Select all is enabled and an authoritative count already exists, the second guard avoids requesting that same page again unnecessarily.
Both checks are scoped to the Expenses all-matching flow and prevent redundant requests caused by this fix.
|
There's a behavorial change. no loading and immediate "All matching items selected" in this branch: Screen.Recording.2026-07-19.at.4.59.57.PM.movloading and then "x selected" in production: Screen.Recording.2026-07-19.at.4.59.49.PM.mov |
|
Bug in offline mode: "All matching items selected" even when there are unselected items Screen.Recording.2026-07-19.at.5.04.03.PM.movScreen.Recording.2026-07-19.at.5.05.33.PM.mov |
|
@emkhalid please test & compare this branch with staging/production in both online/offline mode. |
Explanation of Change
This PR preserves the “all matching items” selection when individual expenses are unchecked.
It:
Fixed Issues
$ #94143
PROPOSAL: #94143 (comment)
Tests
Precondition: Use an account with more than 50 expenses.
Select all matching expenses and Exclude one expense
Preserve selection during pagination
Avoid recalculating totals during pagination
shouldCalculateTotals: false.Recheck an excluded expense
Exclude multiple expenses
Verify grouped and expense-report selection
Verify the selection footer
Offline tests
N/A
QA Steps
Same as Tests
// TODO: These must be filled out, or the issue title must include "[No QA]."
PR Author Checklist
### Fixed Issuessection aboveTestssectionOffline stepssectionQA stepssectionAvatar, I verified the components usingAvatarare working as expected)StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))npm run compress-svg)Avataris modified, I verified thatAvataris working as expected in all cases)Designlabel and/or tagged@Expensify/designso the design team can review the changes.mainbranch was merged into this PR after a review, I tested again and verified the outcome was still expected according to theTeststeps.Screenshots/Videos
Android: Native
android-native-Select.all.matching.expenses.and.Exclude.one.expense.mov
android-native-Preserve.selection.during.pagination.mp4
Not applicable.
android-native-Recheck.an.excluded.expense.mov
android-native-Exclude.multiple.expenses.mov
android-native-Verify.grouped.and.expense-report.selection.mp4
andoir-native-Verify.the.selection.footer.mov
Android: mWeb Chrome
android-web-Select.all.matching.expenses.and.Exclude.one.expense.mov
android-web-Preserve.selection.during.pagination.mp4
Not applicable.
android-web-Recheck.an.excluded.expense.mov
android-web-Exclude.multiple.expenses.mov
android-web-Verify.grouped.and.expense-report.selection.mp4
andoir-web-Verify.the.selection.footer.mov
iOS: Native
ios-native-Select.all.matching.expenses.and.Exclude.one.expense.mov
ios-native-Preserve.selection.during.pagination.mp4
Not applicable.
ios-native-Recheck.an.excluded.expense.mov
ios-native-Exclude.multiple.expenses.mov
ios-native-Verify.grouped.and.expense-report.selection.mov
ios-native-Verify.the.selection.footer.mov
iOS: mWeb Safari
ios-web-Select.all.matching.expenses.and.Exclude.one.expense.mov
ios-web-Preserve.selection.during.pagination.mp4
Not applicable.
ios-web-Recheck.an.excluded.expense.mov
ios-web-Exclude.multiple.expenses.mov
ios-web-Verify.grouped.and.expense-report.selection.mov
ios-web-Verify.the.selection.footer.mov
MacOS: Chrome / Safari
Select.all.matching.expenses.and.Exclude.one.expense.mov
Preserve.selection.during.pagination.mov
Avoid.recalculating.totals.during.pagination.mp4
Recheck.an.excluded.expense.mov
Exclude.multiple.expenses.mov
Verify.grouped.and.expense-report.selection.webm
Verify.the.selection.footer.mov