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] Spend-Expense appears in Spend results despite not matching the applied merchant filter #104147
Version Number: V9.5.10-0 Reproducible in staging?: Yes Reproducible in production?: N/A - new feature, doesn't exist in prod If this was caught during regression testing, add the test name, ID and link from BrowserStack:#83733 Email or phone of affected tester (no customers):dreamnitethedragon+vegst@gmail.com Issue reported by: Applause Internal Team Bug source: Exploratory - Significant User Experience Deterioration Device used: Redmi note 10s android 13 App Component: User Settings
Action Performed:
Launch app
Go to spend
Create expense with merchant I
Tap filter - merchant - Enter I and apply filter
Create expense with merchant Gj
Note expense with merchant Gj is shown under step 4 merchant equal to I filter results
Expected Result:
Only expenses matching the applied Merchant filter (I) should appear in the filtered results.
Actual Result:
The expense with merchant Gj appears in Spend results even though the Merchant filter is set to I.
👋 Friendly reminder that deploy blockers are time-sensitive ⏱ issues! Check out the open `StagingDeployCash` deploy checklist to see the list of PRs included in this release, then work quickly to do one of the following:
Identify the pull request that introduced this issue and revert it.
Please re-state the problem that we are trying to solve in this issue.
When a user on the Spend/Search page has an active restrictive filter applied (e.g. merchant:I), creating a new expense whose merchant does not match the filter (e.g. merchant "Gj") causes the non-matching expense to appear in the filtered search results list. Because the server returns only matching items, the newly created non-matching row remains stuck in the search results until the search page remounts.
// Augment search data with the optimistic transaction (before it appears in server snapshot).constsearchDataWithOptimisticTransaction=(()=>{constsearchData=searchResults?.data;if(!searchData||!isTransactionSearchType(type)||!optimisticWatchKey||isOptimisticTrackingCleared){returnsearchData;}constoptimisticTransactionKey=optimisticWatchKey.startsWith(ONYXKEYS.COLLECTION.TRANSACTION)
? (optimisticWatchKeyas `${typeofONYXKEYS.COLLECTION.TRANSACTION}${string}`)
: undefined;constoptimisticTransaction=optimisticTransactionKey ? transactions?.[optimisticTransactionKey] : undefined;if(!optimisticTransactionKey||!optimisticTransaction?.transactionID||searchData[optimisticTransactionKey]||optimisticTransaction.reportID===CONST.REPORT.SPLIT_REPORT_ID){returnsearchData;}constnextSearchData={
...searchData,[optimisticTransactionKey]: optimisticTransaction,}asSearchResults['data'];
...
Blind injection without filter validation: searchDataWithOptimisticTransaction blindly injects optimisticTransaction into nextSearchData solely based on whether isTransactionSearchType(type) is true and a watch key exists. It completely ignores queryJSON and its filters (such as merchant, category, tag, etc.).
Infinite retention in useStableOptimisticSortedData.ts:
In useStableOptimisticSortedData.ts:113-139, tracking is only cleared if the optimistic item appears in the server snapshot (searchResults.data[watchKey]). Because the server correctly filters out non-matching transactions, the item is never present in the server snapshot. Meanwhile, useOptimisticSearchTracking keeps re-injecting the item on every render, preventing it from ever dropping out of the list and triggering rollback.
While SearchUpdate.ts:shouldOptimisticallyUpdateSearch already enforces that searches with restrictive flat filters (like merchant) should not be optimistically updated, useOptimisticSearchTracking bypassed this check entirely.
What changes do you think we should make in order to solve the problem?
1. In src/libs/actions/IOU/SearchUpdate.ts:
Export shouldOptimisticallyUpdateSearch (or a helper doesQueryMatchOptimisticTransaction) so optimistic search tracking can validate the query against the optimistic transaction and report.
2. In src/components/Search/hooks/useOptimisticSearchTracking.ts:
Before augmenting searchData, check whether the query allows optimistic transaction updates. If the query has restrictive filters (such as merchant) that the optimistic transaction does not match, skip injecting optimisticTransaction and call clearOptimisticTracking() so pending placeholders and cached items are cleanly dismissed:
diff --git a/src/components/Search/hooks/useOptimisticSearchTracking.ts b/src/components/Search/hooks/useOptimisticSearchTracking.ts
index e98213a..d8123fa 100644
--- a/src/components/Search/hooks/useOptimisticSearchTracking.ts+++ b/src/components/Search/hooks/useOptimisticSearchTracking.ts@@ -4,6 +4,7 @@ import {flushPendingSearchWrite, getSearchWriteWatchKey, hasPendingSearchWrite}
import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils';
import {isSearchDataLoaded, isTransactionSearchType} from '@libs/SearchUIUtils';
import {getPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction';
+import {shouldOptimisticallyUpdateSearch} from '@libs/actions/IOU/SearchUpdate';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
@@ -21,6 +22,8 @@ type UseOptimisticSearchTrackingParams = {
searchResults: SearchResults | undefined;
/** Parsed query controlling the active search (type, filters, etc.). */
queryJSON: SearchQueryJSON;
+ /** Current user account ID. */+ currentUserAccountID: number;
/** Full transactions collection used to resolve optimistic watch keys. */
transactions: OnyxCollection<Transaction> | undefined;
/** Report actions collection used to augment search data with optimistic IOU actions. */
@@ -42,6 +45,7 @@ function useOptimisticSearchTracking({searchResults, queryJSON, transactions, re
const {type} = queryJSON;
+ const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT);
const hasPendingWriteOnMount = hasPendingSearchWrite();
const initialWatchKey = hasPendingWriteOnMount ? getSearchWriteWatchKey() : undefined;
@@ -158,6 +162,13 @@ function useOptimisticSearchTracking({searchResults, queryJSON, transactions, re
if (!optimisticTransactionKey || !optimisticTransaction?.transactionID || searchData[optimisticTransactionKey] || optimisticTransaction.reportID === CONST.REPORT.SPLIT_REPORT_ID) {
return searchData;
}
+ const iouReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${optimisticTransaction.reportID}`];+ if (!shouldOptimisticallyUpdateSearch(queryJSON, iouReport, false, currentUserAccountID, optimisticTransaction)) {+ clearOptimisticTracking();+ return searchData;+ }+
const nextSearchData = {
...searchData,
[optimisticTransactionKey]: optimisticTransaction,
3. Unit Tests:
Add a test in tests/unit/Search/useOptimisticSearchTrackingTest.ts:
Verify that when a restrictive filter like merchant:I is active in queryJSON, creating an optimistic transaction with merchant "Gj" does not augment searchData and triggers clearOptimisticTracking().
Verify that when no restrictive filters are present (e.g. default Spend tab type:expense), the optimistic transaction is augmented immediately as expected.
What alternative solutions did you explore? (Optional)
Extracting the filter validation logic into a dedicated helper matchesSearchQueryFilters(queryJSON, transaction, report) in SearchQueryUtils.ts. This would decouple optimistic tracking from SearchUpdate.ts and allow direct unit testing of search query filter evaluation against transactions.
@truph01 I was able to reproduce this and identified two critical issues in MelvinBot's proposal:
setState during render: MelvinBot suggests calling clearOptimisticTracking() where searchData is returned unchanged (searchDataWithOptimisticTransaction). Because this is an inline render-phase calculation, executing clearOptimisticTracking() triggers 3 state setters (setOptimisticWatchKey, setShowPendingExpensePlaceholder, setIsOptimisticTrackingCleared) during render, violating React rules and breaking React Compiler compliance.
Onyx subscription anti-pattern: MelvinBot suggests looking up the report via optimisticTransaction.reportID, but useOptimisticSearchTracking does not have access to reports. Subscribing to ONYXKEYS.COLLECTION.REPORT in Search is a known anti-pattern that causes the entire page to re-render on every chat message in the app. In reality, restrictive flat filters (like merchant) are evaluated purely from queryJSON and do not require reports.
The Clean Fix:
Keep searchDataWithOptimisticTransaction as a pure calculation returning unmodified searchData when ineligible.
Safely call clearOptimisticTracking() inside the existing watch-key useEffect where lifecycle cleanup belongs.
Pass currentUserAccountID from useSearchSnapshot (already in scope) without adding any new Onyx subscriptions.
Repro demo
ReproVedio104147.webm
I have tested this fix locally. Would you like me to post the full proposal?
Please re-state the problem that we are trying to solve in this issue.
When a user has an active restrictive filter applied on Search/Spend (e.g. merchant:I), creating a new expense whose merchant does not match the active filter (e.g. merchant "Gj") causes the non-matching expense to appear in the filtered search results. Because the backend search snapshot excludes the non-matching expense, the optimistic row remains stuck in the search results until the view is reloaded.
What is the root cause of that problem?
In src/components/Search/hooks/useOptimisticSearchTracking.ts:
Lines 146–164 unconditionally inject optimisticTransaction into searchDataWithOptimisticTransaction:
searchDataWithOptimisticTransaction only verifies isTransactionSearchType(type) and whether the tracking key exists. It never validates optimisticTransaction against the active query filters in queryJSON.flatFilters (such as merchant, category, tag, currency).
Calling clearOptimisticTracking() directly inside searchDataWithOptimisticTransaction during render (as proposed in earlier drafts) violates React rules and React Compiler invariants by triggering state updates (setIsOptimisticTrackingCleared, setShowPendingExpensePlaceholder, setOptimisticWatchKey) during render.
The clean place to guard insertion is:
Filter searchDataWithOptimisticTransaction so non-matching optimistic transactions are never injected into the data pipeline.
Clear optimistic tracking cleanly via useEffect without causing unmounted/re-render side effects.
What changes do you think we should make in order to solve the problem?
Add a query matching helper canOptimisticTransactionMatchQuery(transaction, queryJSON) in src/libs/SearchQueryUtils.ts (or SearchUIUtils.ts):
What alternative solutions did you explore? (Optional)
Calling clearOptimisticTracking() inside the searchDataWithOptimisticTransaction IIFE: Rejected because setting state during render violates React rules and breaks React Compiler memoization.
Subscribing to reports in useOptimisticSearchTracking: Rejected because useOptimisticSearchTracking deliberately avoids full report subscriptions to maintain 60 FPS performance on Search screens. Evaluating the transaction's own fields (merchant, category, currency) is fast, zero-overhead, and hermetic.
The PR follows my proposal. It also handles both points emkhalid raised:
No setState during render. The render-time calculation only skips the row. The existing watch-key effect calls clearOptimisticTracking().
No REPORT collection subscription. The hook reads only the new transaction's own report, which is one key.
Unit tests, typecheck, lint, spellcheck, and the React Compiler check all pass. The new test fails without the fix. I couldn't run a manual web check because the test browser showed a blank page. Please test the flow yourself before marking the PR ready.
@neil-marcellini@truph01 this issue was created 2 weeks ago. Are we close to approving a proposal? If not, what's blocking us from getting this issue assigned? Don't hesitate to create a thread in #expensify-open-source to align faster in real time. Thanks!
Activity
applause-bot commentedon Oct 15, 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/2d4fecad0bbaefe3187131a39309fd754518f0bc2ae8897f1a73977c36c402ad
melvin-bot commentedon Oct 15, 2026
You have been assigned to this deploy blocker because you recently merged this PR: #83733
melvin-bot commentedon Oct 15, 2026
@neil-marcellini @truph01 you have been assigned to this deploy blocker because you reviewed the offending PR: #83733
melvin-bot commentedon Oct 15, 2026
💬 A slack conversation has been started in #expensify-open-source
github-actions commentedon Oct 15, 2026
👋 Friendly reminder that deploy blockers are time-sensitive ⏱ issues! Check out the open `StagingDeployCash` deploy checklist to see the list of PRs included in this release, then work quickly to do one of the following:
neil-marcellini commentedon Oct 15, 2026
@MelvinBot what's the root cause of this and how might we fix it?
neil-marcellini commentedon Oct 15, 2026
I think we may need to revert that PR because this is the second deploy blocker and this bug looks pretty bad.
Oh wait, but this happens on production 🤔. I bet it's just a problem with how we optimistically add expenses or something. Not a blocker.
2026-10-15_12-07-10.mp4
10 remaining items
janiya2 commentedon Oct 15, 2026
Contributor details
Your Expensify account email: janithad2@gmail.com
Upwork Profile Link: https://www.upwork.com/freelancers/janithad
Proposal
Please re-state the problem that we are trying to solve in this issue.
When a user on the Spend/Search page has an active restrictive filter applied (e.g.
merchant:I), creating a new expense whose merchant does not match the filter (e.g. merchant "Gj") causes the non-matching expense to appear in the filtered search results list. Because the server returns only matching items, the newly created non-matching row remains stuck in the search results until the search page remounts.What is the root cause of that problem?
In
src/components/Search/hooks/useOptimisticSearchTracking.ts:searchDataWithOptimisticTransactionblindly injectsoptimisticTransactionintonextSearchDatasolely based on whetherisTransactionSearchType(type)is true and a watch key exists. It completely ignoresqueryJSONand its filters (such asmerchant,category,tag, etc.).useStableOptimisticSortedData.ts:In
useStableOptimisticSortedData.ts:113-139, tracking is only cleared if the optimistic item appears in the server snapshot (searchResults.data[watchKey]). Because the server correctly filters out non-matching transactions, the item is never present in the server snapshot. Meanwhile,useOptimisticSearchTrackingkeeps re-injecting the item on every render, preventing it from ever dropping out of the list and triggering rollback.While
SearchUpdate.ts:shouldOptimisticallyUpdateSearchalready enforces that searches with restrictive flat filters (likemerchant) should not be optimistically updated,useOptimisticSearchTrackingbypassed this check entirely.What changes do you think we should make in order to solve the problem?
1. In
src/libs/actions/IOU/SearchUpdate.ts:Export
shouldOptimisticallyUpdateSearch(or a helperdoesQueryMatchOptimisticTransaction) so optimistic search tracking can validate the query against the optimistic transaction and report.2. In
src/components/Search/hooks/useOptimisticSearchTracking.ts:Before augmenting
searchData, check whether the query allows optimistic transaction updates. If the query has restrictive filters (such asmerchant) that the optimistic transaction does not match, skip injectingoptimisticTransactionand callclearOptimisticTracking()so pending placeholders and cached items are cleanly dismissed:3. Unit Tests:
Add a test in
tests/unit/Search/useOptimisticSearchTrackingTest.ts:merchant:Iis active inqueryJSON, creating an optimistic transaction with merchant "Gj" does not augmentsearchDataand triggersclearOptimisticTracking().type:expense), the optimistic transaction is augmented immediately as expected.What alternative solutions did you explore? (Optional)
Extracting the filter validation logic into a dedicated helper
matchesSearchQueryFilters(queryJSON, transaction, report)inSearchQueryUtils.ts. This would decouple optimistic tracking fromSearchUpdate.tsand allow direct unit testing of search query filter evaluation against transactions.melvin-bot commentedon Oct 15, 2026
✅ Contributor details stored successfully. Thank you for contributing to Expensify!
emkhalid commentedon Oct 16, 2026
@truph01 I was able to reproduce this and identified two critical issues in MelvinBot's proposal:
setStateduring render: MelvinBot suggests callingclearOptimisticTracking()wheresearchDatais returned unchanged (searchDataWithOptimisticTransaction). Because this is an inline render-phase calculation, executingclearOptimisticTracking()triggers 3 state setters (setOptimisticWatchKey,setShowPendingExpensePlaceholder,setIsOptimisticTrackingCleared) during render, violating React rules and breaking React Compiler compliance.optimisticTransaction.reportID, butuseOptimisticSearchTrackingdoes not have access to reports. Subscribing toONYXKEYS.COLLECTION.REPORTin Search is a known anti-pattern that causes the entire page to re-render on every chat message in the app. In reality, restrictive flat filters (likemerchant) are evaluated purely fromqueryJSONand do not require reports.The Clean Fix:
searchDataWithOptimisticTransactionas a pure calculation returning unmodifiedsearchDatawhen ineligible.clearOptimisticTracking()inside the existing watch-keyuseEffectwhere lifecycle cleanup belongs.currentUserAccountIDfromuseSearchSnapshot(already in scope) without adding any new Onyx subscriptions.Repro demo
ReproVedio104147.webm
I have tested this fix locally. Would you like me to post the full proposal?
Fix demo
FixedVedio104147.webm
jloa-dev commentedon Oct 17, 2026
Contributor details
Your Expensify account email: jloa.dev@gmail.com
Upwork Profile Link: https://www.upwork.com/freelancers/~0163955a2ba22190aa
Proposal
Please re-state the problem that we are trying to solve in this issue.
When a user has an active restrictive filter applied on Search/Spend (e.g.
merchant:I), creating a new expense whose merchant does not match the active filter (e.g. merchant "Gj") causes the non-matching expense to appear in the filtered search results. Because the backend search snapshot excludes the non-matching expense, the optimistic row remains stuck in the search results until the view is reloaded.What is the root cause of that problem?
In
src/components/Search/hooks/useOptimisticSearchTracking.ts:Lines 146–164 unconditionally inject
optimisticTransactionintosearchDataWithOptimisticTransaction:searchDataWithOptimisticTransactiononly verifiesisTransactionSearchType(type)and whether the tracking key exists. It never validatesoptimisticTransactionagainst the active query filters inqueryJSON.flatFilters(such asmerchant,category,tag,currency).clearOptimisticTracking()directly insidesearchDataWithOptimisticTransactionduring render (as proposed in earlier drafts) violates React rules and React Compiler invariants by triggering state updates (setIsOptimisticTrackingCleared,setShowPendingExpensePlaceholder,setOptimisticWatchKey) during render.searchDataWithOptimisticTransactionso non-matching optimistic transactions are never injected into the data pipeline.useEffectwithout causing unmounted/re-render side effects.What changes do you think we should make in order to solve the problem?
canOptimisticTransactionMatchQuery(transaction, queryJSON)insrc/libs/SearchQueryUtils.ts(orSearchUIUtils.ts):src/components/Search/hooks/useOptimisticSearchTracking.ts:searchDataWithOptimisticTransaction:useEffect(cleanly disarming tracking when the created expense does not belong in this search without callingsetStateduring render):What alternative solutions did you explore? (Optional)
clearOptimisticTracking()inside thesearchDataWithOptimisticTransactionIIFE: Rejected because setting state during render violates React rules and breaks React Compiler memoization.useOptimisticSearchTracking: Rejected becauseuseOptimisticSearchTrackingdeliberately avoids full report subscriptions to maintain 60 FPS performance on Search screens. Evaluating the transaction's own fields (merchant,category,currency) is fast, zero-overhead, and hermetic.melvin-bot commentedon Oct 17, 2026
✅ Contributor details stored successfully. Thank you for contributing to Expensify!
melvin-bot commentedon Oct 20, 2026
@truph01 Huh... This is 4 days overdue. Who can take care of this?
melvin-bot commentedon Oct 22, 2026
@truph01 Still overdue 6 days?! Let's take care of this!
truph01 commentedon Oct 23, 2026
@MelvinBot Your solution looks promising. Could you raise a draft PR?
MelvinBot commentedon Oct 23, 2026
🤖 Draft PR created: Skip the optimistic Search row when a new expense doesn't match the active filters
Please mark it as "Ready for review" or ask me to do that for you when it is ready for review.
The PR follows my proposal. It also handles both points
emkhalidraised:setStateduring render. The render-time calculation only skips the row. The existing watch-key effect callsclearOptimisticTracking().REPORTcollection subscription. The hook reads only the new transaction's own report, which is one key.Unit tests, typecheck, lint, spellcheck, and the React Compiler check all pass. The new test fails without the fix. I couldn't run a manual web check because the test browser showed a blank page. Please test the flow yourself before marking the PR ready.
melvin-bot commentedon Oct 26, 2026
@truph01 Uh oh! This issue is overdue by 2 days. Don't forget to update your issues!
melvin-bot commentedon Oct 28, 2026
@truph01 Huh... This is 4 days overdue. Who can take care of this?
melvin-bot commentedon Oct 29, 2026
@neil-marcellini @truph01 this issue was created 2 weeks ago. Are we close to approving a proposal? If not, what's blocking us from getting this issue assigned? Don't hesitate to create a thread in #expensify-open-source to align faster in real time. Thanks!