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] Search - An asterisk (*) is added after merchant in the search query #104299
The router fills the input from buildUserReadableQueryString when it reopens: SearchRouter.tsx:140-179
Merchant is the only filter affected. *: on any other key falls back to a keyword search.
What changes do you think we should make in order to solve the problem?
Write merchant "Contains" as merchant: instead of merchant*: when building query text.
In buildFilterValuesString, use : when the filter is merchant and the operator is contains.
In buildMerchantFilterQuery, use : for the Contains match type.
Keep *: in the grammar, so typed merchant*:x still works. It will be shown as merchant:x, which means the same thing.
Update the unit tests in tests/unit/Search/SearchQueryUtilsTest.ts that expect merchant*:.
The round trip still works, because merchant: already parses as Contains and merchant= stays exact. The query you see then matches what you typed. Saved-search hashes for merchant queries also return to their values from before the PR.
What alternative solutions did you explore? (Optional)
Change only the display. Keep merchant*: as the stored form, and show merchant: only in buildUserReadableQueryString. This keeps the current hashes, but the URL would still show merchant*:.
Next Steps for Contributor+ team: To accept:@MelvinBot implement [this](#proposal) to create a draft PR. To refine:@MelvinBot <your feedback> To reject: Explain why you are rejecting Melvin's proposal.
VERDICT: POST
ROOT_CAUSE_ONELINER: buildFilterValuesString in SearchQueryUtils.ts serializes the merchant + CONTAINS operator as merchant*:coffee (using operatorToCharMap[CONTAINS] = *:), but the user-facing legacy syntax for merchant contains is merchant: (no asterisk) — so every round-trip through parse→display adds the asterisk.
PROPOSAL_TYPE: FIRST
AUTOPOST_SAFE: YES
changed the title [-]Search - An asterisk (*) is added after merchant in the search query[/-][+][$175] Search - An asterisk (*) is added after merchant in the search query[/+]on Oct 17, 2026
Please re-state the problem that we are trying to solve in this issue.
When searching for an expense using the merchant filter (e.g., merchant:coffee), reopening or re-submitting the search query in the search bar automatically appends an asterisk (*) to the merchant filter, changing it to merchant*:coffee.
What is the root cause of that problem?
In PR #83733, operatorToCharMap[CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS] was defined as *: in src/libs/SearchQueryUtils.ts:99.
The search parser correctly treats merchant:coffee as a positive contains filter.
However, when rebuilding the user-facing query string in buildFilterValuesString (SearchQueryUtils.ts:676-680) and buildMerchantFilterQuery (SearchQueryUtils.ts:156), the serialization logic pulls operatorToCharMap[queryFilter.operator] for CONTAINS, outputting merchant*: instead of the standard canonical merchant prefix merchant:.
When the search router reopens, SearchRouter.tsx reconstructs the input text via buildUserReadableQueryString, reading the serialized string and displaying merchant*:coffee.
What changes do you think we should make in order to solve the problem?
In src/libs/SearchQueryUtils.ts:
In buildMerchantFilterQuery (line 156), write the default positive CONTAINS operator as ::
Keep *: in src/libs/SearchParser/searchParser.peggy for backward compatibility so typed queries containing *: continue to parse as contains, while ensuring serialization always outputs the clean merchant: syntax.
Update unit test assertions in tests/unit/Search/SearchQueryUtilsTest.ts.
What alternative solutions did you explore? (Optional)
Display-only conversion in buildUserReadableQueryString: rejected because keeping merchant*: in the internal serialized query string would still leak the asterisk into the URL route and break saved search query hashes.
Please re-state the problem that we are trying to solve in this issue.
After searching merchant:coffee, reopening the search router shows merchant*:coffee. The query the user typed gets rewritten with an asterisk they never entered.
What is the root cause of that problem?
I agree with the regression source (#83733). I want to point out where the *: actually comes from, because it reaches more than the router text.
The parser keeps the legacy meaning of a positive merchant: by turning it into contains (searchParser.peggy:246-249).
Every place that turns a contains filter back into text reads one map, operatorToCharMap, and that map says CONTAINS: '*:' (SearchQueryUtils.ts:97-100). It is used by buildFilterValuesString (L676-683) and buildMerchantFilterQuery (L154-157).
buildFilterValuesString isn't only used for display. getQueryHashes and getQueryHashWithoutFilters build the hash string with it too (L946-1057). So the asterisk also changed the hash of every existing merchant: query. That hash keys saved searches and search snapshots.
You can see this in the test that guards hash stability. Before #83733, buildSearchQueryJSON('type:expense merchant:Amazon').hash was pinned to 1692688529 (parent commit a4e971f, SearchQueryUtilsTest.ts:2739). #83733 changed the expectation to 1712664595 and added the comment "merchant: parses as contains and hashes as merchant*:" (SearchQueryUtilsTest.ts:2948-2954). I rebuilt the hash input with the same hashCode function. The two values come from type:expense status: groupBy:undefined merchant:Amazon sortBy:date sortOrder:desc columns:undefined, with and without the *. So the extra * is the only reason the hash changed.
What changes do you think we should make in order to solve the problem?
Fix it where the *: comes from, in operatorToCharMap, instead of adding merchant special cases at each place that writes the query:
const operatorToCharMap = {
[CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO]: ':' as const,
- [CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS]: '*:' as const,+ // A positive `merchant:` already parses as contains (`merchant=` is the exact match), so write contains as `:`.+ // `*:` stays accepted by the parser for queries and URLs saved since #83733.+ [CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS]: ':' as const,
Why this is safe:
Only merchant can hold contains in a parsed query. The parser turns contains on any other key, or a negated one, into a keyword (peggy:219-222, 251-254). Description's eq to contains swap happens only in serializeQueryJSONForBackend, which never writes text. So the change can't alter any other filter.
Exact match still writes =, through the EXPLICIT_EQUAL_TO_OPERATOR branch, so merchant= and merchant: still round-trip as two different filters.
Both branches of buildMerchantFilterQuery (the new-clause one at L154-157 and the clause-rewrite one that calls buildFilterValuesString) pick up : automatically. The router text, URL q, pills, hashes, and the Merchant filter form all agree with no extra branches.
The *: token stays in the grammar. Links, recent searches, and saved-search queries written as merchant*:x between 10/14 and the fix still parse as contains, and they show up as merchant:x.
Then update the merchant*: output expectations in tests/unit/Search/SearchQueryUtilsTest.ts to merchant:. Inputs that use merchant*: stay as they are, because they still parse.
What specific scenarios should we cover in automated tests?
merchant*:coffee still parses as contains and comes back as merchant:coffee, with the same hash as merchant:coffee.
merchant=coffee still comes back as merchant=coffee, with a different hash from merchant:coffee.
Merchant filter form set to "Contains" builds merchant:coffee, and "Exactly matches" builds merchant=coffee.
What alternative solutions did you explore? (Optional)
Special-casing merchant contains inside buildFilterValuesString and buildMerchantFilterQuery, as suggested above. It works, but it leaves the map saying contains is *:, so any future serializer that reads the map brings the bug back. It also needs the same fix in two places.
Fixing only buildUserReadableQueryString or the router. That leaves * in the URL and in the hashes, so the hash drift stays.
@rojiphil! I was able to reproduce this issue on both macOS Chrome and iOS Native, and have verified a clean 1-line fix for it. Before posting a full proposal, I wanted to point out why MelvinBot's proposed solution is suboptimal.
Why MelvinBot's Solution Isn't Ideal
MelvinBot suggests leaving operatorToCharMap[CONTAINS] set to '*:' and adding duplicate if (merchant && contains) special-case checks inside multiple helper functions (buildFilterValuesString and buildMerchantFilterQuery).
This leaves the central operator map inaccurate, duplicates logic, and risks future regressions if other query builders read from the map.
The Cleaner Approach (1-Line Fix)
The canonical user-facing query character for merchant contains has always been : (exact match explicitly uses =).
The root issue is simply that operatorToCharMap[CONTAINS] was mapped to '*:'. Changing that single entry to ':' in src/libs/SearchQueryUtils.ts:
Automatically fixes all serialization paths (search router, URL parameter, and filter popups) with zero special-case branching.
Automatically restores saved search query hash stability to its pre-regression state.
Preserves backward compatibility because baseRules.peggy still accepts *: for any existing saved queries.
Demo Videos
macOS Web Chrome (Repro & Fix):
Repro:
merchant-mac-repro.mov
Fix:
merchant-mac-fix.mov
iOS Native (Repro & Fix):
Repro:
merchant-ios-repro.mov
Fix:
merchant-ios-fix.mov
Would you like me to submit the formal proposal with these demo videos and test cases?
To keep the solution as minimal and clean as possible with zero branching or helper changes, the cleanest approach is updating operatorToCharMap[CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS] directly to ':' in src/libs/SearchQueryUtils.ts:
Since CONTAINS is used for partial text matching (which canonically uses the : operator across user-facing search syntax while exact matches use =), this 1-line update cleanly preserves merchant:coffee across all serialization paths with zero redundant conditionals.
Please re-state the problem that we are trying to solve in this issue.
When searching for an expense using the search query merchant:coffee and clicking the search icon again, an asterisk (*) is automatically appended to the search query (becoming merchant:coffee*).
What is the root cause of that problem?
In src/libs/SearchQueryUtils.ts:
When building the search query string from autocomplete suggestions or re-serializing the query for the search input bar, the query serializer checks if the merchant filter was matched via partial string matching. In buildSearchQueryString, wildcard asterisks are injected for partial matches. This logic was intended for background autocomplete calls to the backend, but it erroneously leaked into the user-facing search query input component, mutating the user's explicit input string upon subsequent clicks or focuses.
What changes do you think we should make in order to solve the problem?
In src/libs/SearchQueryUtils.ts, ensure that user-entered search queries in the search input retain their literal string without automatically appending asterisks:
Please re-state the problem that we are trying to solve in this issue.
When searching for an expense using the merchant filter (e.g., merchant:coffee), reopening or re-submitting the search query in the search bar automatically appends an asterisk (*) to the merchant filter, rewriting it as merchant*:coffee.
What is the root cause of that problem?
In PR #83733, operatorToCharMap[CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS] was mapped to '*:' in src/libs/SearchQueryUtils.ts:99.
The search parser correctly treats merchant:coffee as a positive contains filter.
However, when rebuilding/serializing the user-readable query string in buildFilterValuesString (SearchQueryUtils.ts:676-680) and buildMerchantFilterQuery (SearchQueryUtils.ts:156), the serialization logic reads operatorToCharMap[queryFilter.operator], which outputs *: instead of the standard canonical merchant prefix :.
Consequently, whenever the search router reopens, SearchRouter.tsx reconstructs the input text via buildUserReadableQueryString and displays merchant*:coffee.
What changes do you think we should make in order to solve the problem?
The canonical user-facing query character for merchant contains is : (exact match explicitly uses =).
In src/libs/SearchQueryUtils.ts:
Update operatorToCharMap[CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS] directly from '*:' to ':':
Automatically fixes all serialization paths (search router input, URL q parameter, and filter popups) without any redundant special-casing or duplicate branches.
Restores saved search query hash stability back to its pre-regression state.
Backward-compatible: baseRules.peggy still accepts *: for existing saved queries while serialization always outputs the clean canonical merchant: syntax.
Update unit tests in tests/unit/Search/SearchQueryUtilsTest.ts to expect merchant: instead of merchant*:.
What specific scenarios should we cover in automated tests?
hi @rojiphil@neil-marcellini, I'm the author of #83733 and this bug comes from my PR, sorry for that. Can I take it as a follow-up PR? No payment needed.
In #83733 I made a plain merchant: mean contains, and kept merchant*: as another way to type it. But the serializer still maps contains to *:, so whenever the app rebuilds the query string, the user's merchant:coffee turns into merchant*:coffee. I missed this case in that PR.
I'll go with the one-line change that was already suggested above: map contains to : in operatorToCharMap. The grammar is not touched, so a query with merchant*: is still valid, it is just displayed without the * after that. merchant=coffee (exact match) is not affected. This also reverts the hash value I had to update in the hash test in #83733.
One other behavior change from this, could you confirm it's OK? Picking Contains in the Merchant filter will write merchant:Coffee instead of merchant*:Coffee. The results are the same.
The fix and the test updates are ready on my side. I also checked it on web:
merchant:coffee stays the same when I open the router again
Contains and Equal to return the right expenses
the Top merchants drilldown still uses exact match
Could you please confirm so I can open the PR? Thank you
Please re-state the problem that we are trying to solve in this issue.
When searching for expenses using the merchant filter (e.g. typing merchant:coffee), upon submitting the search and reopening the Search Router bar, the query automatically mutates and appends an unwanted asterisk * after merchant, displaying merchant*:coffee instead of merchant:coffee.
In src/libs/SearchParser/searchParser.peggy (lines 246–249), the parser parses standard merchant:value as operator: 'contains'.
When the search query is executed, SearchRouter.tsx reconstructs and serializes the user-readable query string by invoking buildUserReadableQueryString and buildFilterValuesString.
Because queryFilter.operator is CONTAINS, operatorToCharMap[CONTAINS] blindly injects *:, transforming merchant:coffee into merchant*:coffee.
Likewise, in buildMerchantFilterQuery (SearchQueryUtils.ts:154-158), the Contains match type serializes using operatorToCharMap[selectedMerchantOperator], emitting merchant*:.
Reopening Search Router:
When the user reopens the Search Router (SearchRouter.tsx:140-179), the input value is prefilled from the rebuilt query string (buildUserReadableQueryString(currentSearchQuery)), exposing the unwanted merchant*:coffee text.
What changes do you think we should make in order to solve the problem?
In src/libs/SearchQueryUtils.ts:
Serialize Merchant Contains as Standard : in buildFilterValuesString:
When serializing the merchant filter, the default positive CONTAINS operator should format using standard : (operatorToCharMap[EQUAL_TO] which is :), keeping *: reserved only if an explicit non-default syntax is specified, or normalizing all positive merchant contains queries to the clean : prefix:
Backward Compatibility:
Keep *: in searchParser.peggy so that if a user or bookmark already has merchant*:, it still parses cleanly as CONTAINS. The round-trip rebuild will simply format it cleanly as merchant:.
Unit Tests:
Update unit tests in tests/unit/Search/SearchQueryUtilsTest.ts to assert that merchant:value preserves the : prefix on round-trip serialization and router reopening.
What alternative solutions did you explore? (Optional)
Modifying only buildUserReadableQueryString for display: Rejected because keeping merchant*: in the internal query string leaves the asterisk in the URL route and breaks saved search query hash consistency.
4. Proposals must only be posted after the `Help Wanted` label is added. Any proposals submitted beforehand will be ignored and not reviewed. Do not post proposals in Slack. MelvinBot is an exception: it may post a proposal before `Help Wanted` is applied.
Activity
applause-bot commentedon Oct 17, 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/2494367aa2d9cfb3d6e8627ad7798848b142c944243a6169d06866215fdeef20
MelvinBot commentedon Oct 17, 2026
Proposal
What is the root cause of that problem?
Add Contains / Equal to match type for the Merchant filter (merged 2026-10-14) introduced this regression. It added
merchant*:as the syntax for a "Contains" match.merchant:coffeeas a "Contains" filter. It does not keep the:the user typed.*:.So
merchant:coffeecomes back asmerchant*:coffee. I reproduced this on web: the reopened router showstype:expense merchant*:coffee.Code references
merchant:intocontains: searchParser.peggy:246-249containsmaps to*:: SearchQueryUtils.ts:99buildFilterValuesStringwritesmerchant*:. Every query-to-text path calls it: SearchQueryUtils.ts:676-683merchant*:for Contains: SearchQueryUtils.ts:154-158buildUserReadableQueryStringwhen it reopens: SearchRouter.tsx:140-179Merchant is the only filter affected.
*:on any other key falls back to a keyword search.What changes do you think we should make in order to solve the problem?
Write merchant "Contains" as
merchant:instead ofmerchant*:when building query text.buildFilterValuesString, use:when the filter is merchant and the operator iscontains.buildMerchantFilterQuery, use:for the Contains match type.*:in the grammar, so typedmerchant*:xstill works. It will be shown asmerchant:x, which means the same thing.tests/unit/Search/SearchQueryUtilsTest.tsthat expectmerchant*:.The round trip still works, because
merchant:already parses as Contains andmerchant=stays exact. The query you see then matches what you typed. Saved-search hashes for merchant queries also return to their values from before the PR.What alternative solutions did you explore? (Optional)
Change only the display. Keep
merchant*:as the stored form, and showmerchant:only inbuildUserReadableQueryString. This keeps the current hashes, but the URL would still showmerchant*:.Next Steps for Contributor+ team:
To accept:
@MelvinBot implement [this](#proposal)to create a draft PR.To refine:
@MelvinBot <your feedback>To reject: Explain why you are rejecting Melvin's proposal.
view run · view recording
j3ffffff commentedon Oct 17, 2026
VERDICT: POST
ROOT_CAUSE_ONELINER:
buildFilterValuesStringinSearchQueryUtils.tsserializes themerchant+CONTAINSoperator asmerchant*:coffee(usingoperatorToCharMap[CONTAINS]=*:), but the user-facing legacy syntax for merchant contains ismerchant:(no asterisk) — so every round-trip through parse→display adds the asterisk.PROPOSAL_TYPE: FIRST
AUTOPOST_SAFE: YES
melvin-bot commentedon Oct 17, 2026
Triggered auto assignment to Contributor-plus team member for initial proposal review - @rojiphil (
External)[-]Search - An asterisk (*) is added after merchant in the search query[/-][+][$175] Search - An asterisk (*) is added after merchant in the search query[/+]melvin-bot commentedon Oct 17, 2026
Job added to Upwork: https://www.upwork.com/jobs/~022111511880257919766
camesenin commentedon Oct 17, 2026
Contributor details
Your Expensify account email: [email protected]
Upwork Profile Link: https://www.upwork.com/freelancers/~01b60d704ba377be83
Proposal
Please re-state the problem that we are trying to solve in this issue.
When searching for an expense using the merchant filter (e.g.,
merchant:coffee), reopening or re-submitting the search query in the search bar automatically appends an asterisk (*) to the merchant filter, changing it tomerchant*:coffee.What is the root cause of that problem?
In PR #83733,
operatorToCharMap[CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS]was defined as*:insrc/libs/SearchQueryUtils.ts:99.merchant:coffeeas a positivecontainsfilter.buildFilterValuesString(SearchQueryUtils.ts:676-680) andbuildMerchantFilterQuery(SearchQueryUtils.ts:156), the serialization logic pullsoperatorToCharMap[queryFilter.operator]forCONTAINS, outputtingmerchant*:instead of the standard canonical merchant prefixmerchant:.SearchRouter.tsxreconstructs the input text viabuildUserReadableQueryString, reading the serialized string and displayingmerchant*:coffee.What changes do you think we should make in order to solve the problem?
In
src/libs/SearchQueryUtils.ts:buildMerchantFilterQuery(line 156), write the default positiveCONTAINSoperator as::buildFilterValuesString(lines 676-680), serialize merchantCONTAINSas:instead of*::*:insrc/libs/SearchParser/searchParser.peggyfor backward compatibility so typed queries containing*:continue to parse ascontains, while ensuring serialization always outputs the cleanmerchant:syntax.tests/unit/Search/SearchQueryUtilsTest.ts.What alternative solutions did you explore? (Optional)
Display-only conversion in
buildUserReadableQueryString: rejected because keepingmerchant*:in the internal serialized query string would still leak the asterisk into the URL route and break saved search query hashes.melvin-bot commentedon Oct 17, 2026
✅ Contributor details stored successfully. Thank you for contributing to Expensify!
zackthompson317 commentedon Oct 18, 2026
Proposal
Please re-state the problem that we are trying to solve in this issue.
After searching
merchant:coffee, reopening the search router showsmerchant*:coffee. The query the user typed gets rewritten with an asterisk they never entered.What is the root cause of that problem?
I agree with the regression source (#83733). I want to point out where the
*:actually comes from, because it reaches more than the router text.merchant:by turning it intocontains(searchParser.peggy:246-249).containsfilter back into text reads one map,operatorToCharMap, and that map saysCONTAINS: '*:'(SearchQueryUtils.ts:97-100). It is used bybuildFilterValuesString(L676-683) andbuildMerchantFilterQuery(L154-157).buildFilterValuesStringisn't only used for display.getQueryHashesandgetQueryHashWithoutFiltersbuild the hash string with it too (L946-1057). So the asterisk also changed the hash of every existingmerchant:query. That hash keys saved searches and search snapshots.You can see this in the test that guards hash stability. Before #83733,
buildSearchQueryJSON('type:expense merchant:Amazon').hashwas pinned to1692688529(parent commit a4e971f, SearchQueryUtilsTest.ts:2739). #83733 changed the expectation to1712664595and added the comment "merchant:parses as contains and hashes asmerchant*:" (SearchQueryUtilsTest.ts:2948-2954). I rebuilt the hash input with the samehashCodefunction. The two values come fromtype:expense status: groupBy:undefined merchant:Amazon sortBy:date sortOrder:desc columns:undefined, with and without the*. So the extra*is the only reason the hash changed.What changes do you think we should make in order to solve the problem?
Fix it where the
*:comes from, inoperatorToCharMap, instead of adding merchant special cases at each place that writes the query:const operatorToCharMap = { [CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO]: ':' as const, - [CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS]: '*:' as const, + // A positive `merchant:` already parses as contains (`merchant=` is the exact match), so write contains as `:`. + // `*:` stays accepted by the parser for queries and URLs saved since #83733. + [CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS]: ':' as const,Why this is safe:
containsin a parsed query. The parser turnscontainson any other key, or a negated one, into a keyword (peggy:219-222, 251-254). Description'seqtocontainsswap happens only inserializeQueryJSONForBackend, which never writes text. So the change can't alter any other filter.=, through theEXPLICIT_EQUAL_TO_OPERATORbranch, somerchant=andmerchant:still round-trip as two different filters.buildMerchantFilterQuery(the new-clause one at L154-157 and the clause-rewrite one that callsbuildFilterValuesString) pick up:automatically. The router text, URLq, pills, hashes, and the Merchant filter form all agree with no extra branches.*:token stays in the grammar. Links, recent searches, and saved-search queries written asmerchant*:xbetween 10/14 and the fix still parse as contains, and they show up asmerchant:x.merchant:queries go back to their pre-[Payment due @truph01] fix: Add Contains / Equal to match type for the Merchant filter [80823] #83733 values. That is exactly what the hash-stability test is meant to guarantee, because these hashes key saved searches and snapshots.Then update the
merchant*:output expectations intests/unit/Search/SearchQueryUtilsTest.tstomerchant:. Inputs that usemerchant*:stay as they are, because they still parse.What specific scenarios should we cover in automated tests?
expect(buildSearchQueryJSON('type:expense merchant:Amazon')?.hash).toEqual(1692688529)(the pre-[Payment due @truph01] fix: Add Contains / Equal to match type for the Merchant filter [80823] #83733 value) and drop the drift comment.buildUserReadableQueryString(buildSearchQueryJSON('type:expense merchant:coffee'))returnstype:expense merchant:coffee.getQueryWithUpdatedValues('merchant:coffee')contains no*.merchant*:coffeestill parses ascontainsand comes back asmerchant:coffee, with the same hash asmerchant:coffee.merchant=coffeestill comes back asmerchant=coffee, with a different hash frommerchant:coffee.merchant:coffee, and "Exactly matches" buildsmerchant=coffee.What alternative solutions did you explore? (Optional)
containsinsidebuildFilterValuesStringandbuildMerchantFilterQuery, as suggested above. It works, but it leaves the map saying contains is*:, so any future serializer that reads the map brings the bug back. It also needs the same fix in two places.buildUserReadableQueryStringor the router. That leaves*in the URL and in the hashes, so the hash drift stays.Contributor details
Your Expensify account email: [email protected]
Upwork Profile Link: https://www.upwork.com/freelancers/~010ec78449791d4046
melvin-bot commentedon Oct 18, 2026
✅ Contributor details stored successfully. Thank you for contributing to Expensify!
1 remaining item
emkhalid commentedon Oct 18, 2026
@rojiphil! I was able to reproduce this issue on both macOS Chrome and iOS Native, and have verified a clean 1-line fix for it. Before posting a full proposal, I wanted to point out why MelvinBot's proposed solution is suboptimal.
Why MelvinBot's Solution Isn't Ideal
MelvinBot suggests leaving
operatorToCharMap[CONTAINS]set to'*:'and adding duplicateif (merchant && contains)special-case checks inside multiple helper functions (buildFilterValuesStringandbuildMerchantFilterQuery).This leaves the central operator map inaccurate, duplicates logic, and risks future regressions if other query builders read from the map.
The Cleaner Approach (1-Line Fix)
The canonical user-facing query character for merchant contains has always been
:(exact match explicitly uses=).The root issue is simply that
operatorToCharMap[CONTAINS]was mapped to'*:'. Changing that single entry to':'insrc/libs/SearchQueryUtils.ts:baseRules.peggystill accepts*:for any existing saved queries.Demo Videos
macOS Web Chrome (Repro & Fix):
Repro:
merchant-mac-repro.mov
Fix:
merchant-mac-fix.mov
iOS Native (Repro & Fix):
Repro:
merchant-ios-repro.mov
Fix:
merchant-ios-fix.mov
Would you like me to submit the formal proposal with these demo videos and test cases?
sijo0218 commentedon Oct 18, 2026
@rojiphil Following up on my proposal above:
To keep the solution as minimal and clean as possible with zero branching or helper changes, the cleanest approach is updating
operatorToCharMap[CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS]directly to':'insrc/libs/SearchQueryUtils.ts:Since
CONTAINSis used for partial text matching (which canonically uses the:operator across user-facing search syntax while exact matches use=), this 1-line update cleanly preservesmerchant:coffeeacross all serialization paths with zero redundant conditionals.jloa-dev commentedon Oct 19, 2026
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 searching for an expense using the search query
merchant:coffeeand clicking the search icon again, an asterisk (*) is automatically appended to the search query (becomingmerchant:coffee*).What is the root cause of that problem?
In
src/libs/SearchQueryUtils.ts:When building the search query string from autocomplete suggestions or re-serializing the query for the search input bar, the query serializer checks if the merchant filter was matched via partial string matching. In
buildSearchQueryString, wildcard asterisks are injected for partial matches. This logic was intended for background autocomplete calls to the backend, but it erroneously leaked into the user-facing search query input component, mutating the user's explicit input string upon subsequent clicks or focuses.What changes do you think we should make in order to solve the problem?
In
src/libs/SearchQueryUtils.ts, ensure that user-entered search queries in the search input retain their literal string without automatically appending asterisks:And in
SearchAutocompleteInput.tsx, ensure the display text only displays the exact sanitized user value.What specific scenarios should we cover in automated tests?
In
tests/unit/SearchQueryUtilsTest.ts:merchant:coffeeand re-rendering the search bar maintainsmerchant:coffeewithout appending*.melvin-bot commentedon Oct 19, 2026
✅ Contributor details stored successfully. Thank you for contributing to Expensify!
azhumide commentedon Oct 19, 2026
Contributor details
Your Expensify account email: [email protected]
Upwork Profile Link: https://www.upwork.com/freelancers/~01096eb540e2a8e9a5
Proposal
Please re-state the problem that we are trying to solve in this issue.
When searching for an expense using the merchant filter (e.g.,
merchant:coffee), reopening or re-submitting the search query in the search bar automatically appends an asterisk (*) to the merchant filter, rewriting it asmerchant*:coffee.What is the root cause of that problem?
In PR #83733,
operatorToCharMap[CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS]was mapped to'*:'insrc/libs/SearchQueryUtils.ts:99.merchant:coffeeas a positivecontainsfilter.buildFilterValuesString(SearchQueryUtils.ts:676-680) andbuildMerchantFilterQuery(SearchQueryUtils.ts:156), the serialization logic readsoperatorToCharMap[queryFilter.operator], which outputs*:instead of the standard canonical merchant prefix:.SearchRouter.tsxreconstructs the input text viabuildUserReadableQueryStringand displaysmerchant*:coffee.What changes do you think we should make in order to solve the problem?
The canonical user-facing query character for merchant contains is
:(exact match explicitly uses=).In
src/libs/SearchQueryUtils.ts:Update
operatorToCharMap[CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS]directly from'*:'to':':Why this is the cleanest, minimal solution:
qparameter, and filter popups) without any redundant special-casing or duplicate branches.baseRules.peggystill accepts*:for existing saved queries while serialization always outputs the clean canonicalmerchant:syntax.tests/unit/Search/SearchQueryUtilsTest.tsto expectmerchant:instead ofmerchant*:.What specific scenarios should we cover in automated tests?
In
tests/unit/Search/SearchQueryUtilsTest.ts:buildUserReadableQueryString(buildSearchQueryJSON('type:expense merchant:coffee'))returnstype:expense merchant:coffee.merchant*:coffeestill parses ascontainsand normalizes tomerchant:coffee.merchant=coffeestill produces exact match without regression.melvin-bot commentedon Oct 19, 2026
azhumide commentedon Oct 19, 2026
Contributor details
Your Expensify account email: [email protected]
Upwork Profile Link: https://www.upwork.com/freelancers/~01096eb540e2a8e9a5
melvin-bot commentedon Oct 19, 2026
✅ Contributor details stored successfully. Thank you for contributing to Expensify!
HiepPP commentedon Oct 19, 2026
hi @rojiphil @neil-marcellini, I'm the author of #83733 and this bug comes from my PR, sorry for that. Can I take it as a follow-up PR? No payment needed.
In #83733 I made a plain
merchant:mean contains, and keptmerchant*:as another way to type it. But the serializer still maps contains to*:, so whenever the app rebuilds the query string, the user'smerchant:coffeeturns intomerchant*:coffee. I missed this case in that PR.I'll go with the one-line change that was already suggested above: map contains to
:inoperatorToCharMap. The grammar is not touched, so a query withmerchant*:is still valid, it is just displayed without the*after that.merchant=coffee(exact match) is not affected. This also reverts the hash value I had to update in the hash test in #83733.One other behavior change from this, could you confirm it's OK? Picking Contains in the Merchant filter will write
merchant:Coffeeinstead ofmerchant*:Coffee. The results are the same.The fix and the test updates are ready on my side. I also checked it on web:
merchant:coffeestays the same when I open the router againCould you please confirm so I can open the PR? Thank you
bonnhatnguyen commentedon Oct 19, 2026
Proposal
Please re-state the problem that we are trying to solve in this issue.
When searching for expenses using the merchant filter (e.g. typing
merchant:coffee), upon submitting the search and reopening the Search Router bar, the query automatically mutates and appends an unwanted asterisk*aftermerchant, displayingmerchant*:coffeeinstead ofmerchant:coffee.What is the root cause of that problem?
Introduction of
*:syntax in PR [Payment due @truph01] fix: Add Contains / Equal to match type for the Merchant filter [80823] #83733:In PR #83733 ("Add Contains / Equal to match type for the Merchant filter"),
*:was introduced intooperatorToCharMapto represent theCONTAINSoperator:In
src/libs/SearchQueryUtils.ts(line 99):Parser vs Serializer Asymmetry:
src/libs/SearchParser/searchParser.peggy(lines 246–249), the parser parses standardmerchant:valueasoperator: 'contains'.SearchRouter.tsxreconstructs and serializes the user-readable query string by invokingbuildUserReadableQueryStringandbuildFilterValuesString.src/libs/SearchQueryUtils.ts(lines 676–683):queryFilter.operatorisCONTAINS,operatorToCharMap[CONTAINS]blindly injects*:, transformingmerchant:coffeeintomerchant*:coffee.buildMerchantFilterQuery(SearchQueryUtils.ts:154-158), the Contains match type serializes usingoperatorToCharMap[selectedMerchantOperator], emittingmerchant*:.Reopening Search Router:
When the user reopens the Search Router (
SearchRouter.tsx:140-179), the input value is prefilled from the rebuilt query string (buildUserReadableQueryString(currentSearchQuery)), exposing the unwantedmerchant*:coffeetext.What changes do you think we should make in order to solve the problem?
In
src/libs/SearchQueryUtils.ts:Serialize Merchant Contains as Standard
:inbuildFilterValuesString:When serializing the
merchantfilter, the default positiveCONTAINSoperator should format using standard:(operatorToCharMap[EQUAL_TO]which is:), keeping*:reserved only if an explicit non-default syntax is specified, or normalizing all positive merchant contains queries to the clean:prefix:Align
buildMerchantFilterQuery(SearchQueryUtils.ts):Backward Compatibility:
Keep
*:insearchParser.peggyso that if a user or bookmark already hasmerchant*:, it still parses cleanly asCONTAINS. The round-trip rebuild will simply format it cleanly asmerchant:.Unit Tests:
Update unit tests in
tests/unit/Search/SearchQueryUtilsTest.tsto assert thatmerchant:valuepreserves the:prefix on round-trip serialization and router reopening.What alternative solutions did you explore? (Optional)
buildUserReadableQueryStringfor display: Rejected because keepingmerchant*:in the internal query string leaves the asterisk in the URL route and breaks saved search query hash consistency.Contributor details
Your Expensify account email: [email protected]
Upwork Profile Link: https://www.upwork.com/freelancers/~0139ed4098bdc50182
melvin-bot commentedon Oct 19, 2026
✅ Contributor details stored successfully. Thank you for contributing to Expensify!
emkhalid commentedon Oct 20, 2026
@camesenin, @zackthompson317, @j3ffffff, @sijo0218, @jloa-dev, @azhumide and @bonnhatnguyen
If you're human contributors and not automation bots, please read the Contributing Guidelines and wait for the
Help Wantedlabel before posting proposals.App/contributingGuides/CONTRIBUTING.md
Line 164 in ccc47b1
Find issues actually open for proposals here:
https://github.com/Expensify/App/issues?q=is%3Aissue+state%3Aopen+label%3A%22Help+Wanted%22
rojiphil commentedon Oct 20, 2026
Not due yet.
Awaiting clarity at #80823 (comment) on whether this is a regression or an improvement.