Skip to content

[$175] Spend-Expense appears in Spend results despite not matching the applied merchant filter #104147

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: 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): [email protected]
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:

  1. Launch app
  2. Go to spend
  3. Create expense with merchant I
  4. Tap filter - merchant - Enter I and apply filter
  5. Create expense with merchant Gj
  6. 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.

Workaround:

Unknown

Platforms:

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

Screenshots/Videos

Bug7287444_1792086581034.Screenrecorder-2026-10-15-23-12-31-89.1.mp4

View all open jobs on GitHub

Applause Internal Information
Exported by: Julia Ponikarchuk
Bug ID: 7287444

Issue OwnerCurrent Issue Owner: @truph01
Upwork Automation - Do Not Edit

Activity

  1. added
    DeployBlockerCashThis issue or pull request should block deployment
    BugSomething is broken. Auto assigns a BugZero manager.
    on Oct 15, 2026
  2. applause-bot commented on Oct 15, 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/2d4fecad0bbaefe3187131a39309fd754518f0bc2ae8897f1a73977c36c402ad

  3. melvin-bot commented on Oct 15, 2026

    @melvin-bot

    You have been assigned to this deploy blocker because you recently merged this PR: #83733

  4. melvin-bot commented on Oct 15, 2026

    @melvin-bot

    @neil-marcellini @truph01 you have been assigned to this deploy blocker because you reviewed the offending PR: #83733

  5. melvin-bot commented on Oct 15, 2026

    @melvin-bot

    💬 A slack conversation has been started in #expensify-open-source

  6. github-actions commented on Oct 15, 2026

    @github-actions
    Contributor

    👋 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:

    1. Identify the pull request that introduced this issue and revert it.
    2. Find someone who can quickly fix the issue.
    3. Fix the issue yourself.
  7. neil-marcellini commented on Oct 15, 2026

    @neil-marcellini
    Contributor

    @MelvinBot what's the root cause of this and how might we fix it?

  8. neil-marcellini commented on Oct 15, 2026

    @neil-marcellini
    Contributor

    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
  9. 2 remaining items

  10. MelvinBot commented on Oct 15, 2026

    @MelvinBot
    Contributor

    Deploy Blocker Investigation

    Verdict: [Payment due @truph01] fix: Add Contains / Equal to match type for the Merchant filter [80823] is not the cause, so you don't need to revert it. This is an older bug in the client-side optimistic Search tracking. It shipped with [Performance] Dismiss-first navigation for all remaining expense submission paths, merged 2026-05-18.

    Root cause: After you create an expense from Spend, the Search page adds the new transaction to the list on screen. It never checks whether the transaction matches the active filters (useOptimisticSearchTracking.ts:145-185). The server's filtered results correctly leave the "Gj" expense out, so tracking never clears and the row stays. This affects any restrictive filter, not just merchant.

    Recommended fix (forward-fix):

    1. In searchDataWithOptimisticTransaction, inject the transaction only when it matches the active queryJSON. Move the matching logic in shouldOptimisticallyUpdateSearch (SearchUpdate.ts:105-128) into a shared helper and call it from both places.
    2. In useStableOptimisticSortedData.ts:113-139, call clearOptimisticTracking() when the transaction doesn't match, so the cached row isn't added back.
    3. Add a test: with merchant:I active, creating a "Gj" expense from Spend adds no row.

    Suggested triage: Ask QA to reproduce this on production with any filter. The merchant filter already exists there; only the Contains/Equal to choice is new. If it reproduces, remove DeployBlockerCash and handle this as a regular bug. 91350-Withdrawn-Last created in Spend expense appears after applying Withdrawn filter reported the same symptom in production in May. It was closed as stale.

    Investigation details

    Next Steps: Reply with @MelvinBot implement this to create a draft PR, or @MelvinBot <your feedback> to refine this analysis.


    view run

  11. truph01 commented on Oct 15, 2026

    @truph01
    Contributor

    @MelvinBot Post proposal

  12. MelvinBot commented on Oct 15, 2026

    @MelvinBot
    Contributor

    neil-marcellini, I answered this in my earlier comment. Your production repro matches what I found there.

    Root cause: After you create an expense from Spend, Search adds it to the list on screen without checking the active filters (useOptimisticSearchTracking.ts:145-185). The server leaves the expense out of the filtered results, so tracking never clears and the row stays. This came from [Performance] Dismiss-first navigation for all remaining expense submission paths, not the merchant filter PR.

    Fix:

    1. Inject the optimistic transaction only when it matches the active query. Reuse the matching logic from shouldOptimisticallyUpdateSearch (SearchUpdate.ts:105-128) through a shared helper.
    2. Call clearOptimisticTracking() in useStableOptimisticSortedData.ts:113-139 when the transaction doesn't match.
    3. Add a test: with merchant:I active, creating a "Gj" expense adds no row.

    Next Steps: Reply with @MelvinBot implement this to create a draft PR, or @MelvinBot <your feedback> to refine this analysis.


    view run

  13. added
    ExternalAdded to denote the issue can be worked on by a contributor
    on Oct 15, 2026
  14. melvin-bot commented on Oct 15, 2026

    @melvin-bot

    Current assignee @truph01 is eligible for the External assigner, not assigning anyone new.

  15. changed the title [-]Spend-Expense appears in Spend results despite not matching the applied merchant filter[/-] [+][$175] Spend-Expense appears in Spend results despite not matching the applied merchant filter[/+] on Oct 15, 2026
  16. melvin-bot commented on Oct 15, 2026

    @melvin-bot
  17. MelvinBot commented on Oct 15, 2026

    @MelvinBot
    Contributor

    Proposal

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

    An expense created from Spend shows up in a filtered Search list even when it doesn't match the filters. For example, a "Gj" expense appears under merchant:I.

    What is the root cause of that problem?

    Search adds the new expense to the list on screen right away, before the server responds. It does this without checking the active query.

    • searchDataWithOptimisticTransaction in useOptimisticSearchTracking.ts:145-185 only checks the search type and whether tracking is active. It never compares the transaction with queryJSON.

    • useStableOptimisticSortedData.ts:113-139 ends tracking in two cases only:

      • The item appears in the server snapshot. The server correctly filters it out, so this never happens.
      • The item drops out of the list. The injection keeps adding it back, so this never happens either.

      As a result, the row stays until Search remounts.

    The Onyx snapshot update already has this check: shouldOptimisticallyUpdateSearch returns false for restrictive filters like merchant. The tracking hook bypasses it. The bug came from [Performance] Dismiss-first navigation for all remaining expense submission paths and already exists on production.

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

    1. Use the existing shouldOptimisticallyUpdateSearch check in useOptimisticSearchTracking. Look up the report through optimisticTransaction.reportID. If the transaction doesn't match the current queryJSON, return searchData unchanged instead of injecting it.
    2. In the same case, call clearOptimisticTracking() so the pending-expense placeholder and cached row are dropped. useStableOptimisticSortedData then has nothing to re-add.
    3. Add a unit test: with merchant:I active, creating a "Gj" expense from Spend adds no row. With no filters, the row still appears right away.

    What alternative solutions did you explore? (Optional)

    Copy only the restrictive-filter part of shouldOptimisticallyUpdateSearch into a small shared helper, such as doesQueryAllowOptimisticTransaction, and call it from both places. This avoids passing the invoice flag and current account ID into the hook, but it adds a second entry point that could drift from the first.


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

  18. janiya2 commented on Oct 15, 2026

    @janiya2

    Contributor details
    Your Expensify account email: [email protected]
    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:

        // Augment search data with the optimistic transaction (before it appears in server snapshot).
        const searchDataWithOptimisticTransaction = (() => {
            const searchData = searchResults?.data;
            if (!searchData || !isTransactionSearchType(type) || !optimisticWatchKey || isOptimisticTrackingCleared) {
                return searchData;
            }
    
            const optimisticTransactionKey = optimisticWatchKey.startsWith(ONYXKEYS.COLLECTION.TRANSACTION)
                ? (optimisticWatchKey as `${typeof ONYXKEYS.COLLECTION.TRANSACTION}${string}`)
                : undefined;
            const optimisticTransaction = optimisticTransactionKey ? transactions?.[optimisticTransactionKey] : undefined;
            if (!optimisticTransactionKey || !optimisticTransaction?.transactionID || searchData[optimisticTransactionKey] || optimisticTransaction.reportID === CONST.REPORT.SPLIT_REPORT_ID) {
                return searchData;
            }
    
            const nextSearchData = {
                ...searchData,
                [optimisticTransactionKey]: optimisticTransaction,
            } as SearchResults['data'];
    ...
    1. 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.).
    2. 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.

    diff --git a/src/libs/actions/IOU/SearchUpdate.ts b/src/libs/actions/IOU/SearchUpdate.ts
    index b182a34..c98124f 100644
    --- a/src/libs/actions/IOU/SearchUpdate.ts
    +++ b/src/libs/actions/IOU/SearchUpdate.ts
    @@ -434,4 +434,5 @@ function getSearchOnyxUpdate(
     export {
         getSearchOnyxUpdate,
         shouldOptimisticallyUpdateSearch,
    +    getDefaultSearchQueriesByHash,
     };

    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.

  19. melvin-bot commented on Oct 15, 2026

    @melvin-bot

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

  20. emkhalid commented on Oct 16, 2026

    @emkhalid
    Contributor

    @truph01 I was able to reproduce this and identified two critical issues in MelvinBot's proposal:

    1. 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.
    2. 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?

    Fix demo

    FixedVedio104147.webm
  21. jloa-dev commented on Oct 17, 2026

    @jloa-dev

    Contributor details
    Your Expensify account email: [email protected]
    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 optimisticTransaction into searchDataWithOptimisticTransaction:

    const nextSearchData = {
        ...searchData,
        [optimisticTransactionKey]: optimisticTransaction,
    } as SearchResults['data'];
    1. 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).
    2. 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.
    3. 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?

    1. Add a query matching helper canOptimisticTransactionMatchQuery(transaction, queryJSON) in src/libs/SearchQueryUtils.ts (or SearchUIUtils.ts):
    function canOptimisticTransactionMatchQuery(transaction: OnyxEntry<Transaction>, queryJSON: SearchQueryJSON): boolean {
        if (!transaction) {
            return false;
        }
    
        const {flatFilters} = queryJSON;
        if (!flatFilters || flatFilters.length === 0) {
            return true;
        }
    
        const merchantFilter = flatFilters.find((f) => f.key === CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT);
        if (merchantFilter) {
            const transactionMerchant = (transaction.modifiedMerchant ?? transaction.merchant ?? '').toLowerCase();
            const matchesMerchant = merchantFilter.filters.some((filter) => {
                const filterValue = (filter.value ?? '').toLowerCase();
                if (filter.operator === CONST.SEARCH.OPERATOR_TYPES.EQUAL) {
                    return transactionMerchant === filterValue;
                }
                return transactionMerchant.includes(filterValue);
            });
            if (!matchesMerchant) {
                return false;
            }
        }
    
        const categoryFilter = flatFilters.find((f) => f.key === CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY);
        if (categoryFilter) {
            const transactionCategory = (transaction.category ?? '').toLowerCase();
            const matchesCategory = categoryFilter.filters.some((filter) => (filter.value ?? '').toLowerCase() === transactionCategory);
            if (!matchesCategory) {
                return false;
            }
        }
    
        const currencyFilter = flatFilters.find((f) => f.key === CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY);
        if (currencyFilter) {
            const matchesCurrency = currencyFilter.filters.some((filter) => (filter.value ?? '').toUpperCase() === (transaction.currency ?? '').toUpperCase());
            if (!matchesCurrency) {
                return false;
            }
        }
    
        return true;
    }
    1. In src/components/Search/hooks/useOptimisticSearchTracking.ts:
    • In searchDataWithOptimisticTransaction:
    --- a/src/components/Search/hooks/useOptimisticSearchTracking.ts
    +++ b/src/components/Search/hooks/useOptimisticSearchTracking.ts
    @@ -156,7 +156,13 @@ function useOptimisticSearchTracking({searchResults, queryJSON, transactions, re
             if (!optimisticTransactionKey || !optimisticTransaction?.transactionID || searchData[optimisticTransactionKey] || optimisticTransaction.reportID === CONST.REPORT.SPLIT_REPORT_ID) {
                 return searchData;
             }
     
    +        if (!canOptimisticTransactionMatchQuery(optimisticTransaction, queryJSON)) {
    +            return searchData;
    +        }
    +
             const nextSearchData = {
    • In a reactive useEffect (cleanly disarming tracking when the created expense does not belong in this search without calling setState during render):
    useEffect(() => {
        if (!optimisticTransactionKey || !optimisticTransaction || !isTransactionSearchType(type) || isOptimisticTrackingCleared) {
            return;
        }
        if (!canOptimisticTransactionMatchQuery(optimisticTransaction, queryJSON)) {
            clearOptimisticTracking();
        }
    }, [optimisticTransactionKey, optimisticTransaction, queryJSON, type, isOptimisticTrackingCleared]);

    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.
  22. melvin-bot commented on Oct 17, 2026

    @melvin-bot

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

  23. melvin-bot commented on Oct 20, 2026

    @melvin-bot

    @truph01 Huh... This is 4 days overdue. Who can take care of this?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

BugSomething is broken. Auto assigns a BugZero manager.DailyKSv2EngineeringExternalAdded to denote the issue can be worked on by a contributorOverdue

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions