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

  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. 10 remaining items

  10. janiya2 commented on Oct 15, 2026

    @janiya2

    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:

        // 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.

  11. melvin-bot commented on Oct 15, 2026

    @melvin-bot

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

  12. 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
  13. jloa-dev commented on Oct 17, 2026

    @jloa-dev

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

    @melvin-bot

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

  15. melvin-bot commented on Oct 20, 2026

    @melvin-bot

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

  16. melvin-bot commented on Oct 22, 2026

    @melvin-bot

    @truph01 Still overdue 6 days?! Let's take care of this!

  17. truph01 commented on Oct 23, 2026

    @truph01
    Contributor

    @MelvinBot Your solution looks promising. Could you raise a draft PR?

  18. MelvinBot commented on Oct 23, 2026

    @MelvinBot
    Contributor

    🤖 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 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.

  19. melvin-bot commented on Oct 26, 2026

    @melvin-bot

    @truph01 Uh oh! This issue is overdue by 2 days. Don't forget to update your issues!

  20. melvin-bot commented on Oct 28, 2026

    @melvin-bot

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

  21. melvin-bot commented on Oct 29, 2026

    @melvin-bot

    @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!

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