From c06a440420e7df5f5a2878fafe70994b7b50e3f8 Mon Sep 17 00:00:00 2001 From: husamdaifalla01-cmyk Date: Fri, 6 Mar 2026 00:44:08 -0500 Subject: [PATCH 1/2] Optimize defaultExpensifyCardSelector by removing translate dependency Extract getExpensifyCardFeedsForDisplay from getCardFeedsForDisplay so the selector no longer needs the translate parameter. This eliminates the inline closure in useSearchTypeMenuSections that was causing unnecessary re-renders on every render cycle. Co-Authored-By: Claude Opus 4.6 --- src/hooks/useSearchTypeMenuSections.ts | 7 ++-- src/libs/CardFeedUtils.ts | 50 ++++++++++++++++---------- src/selectors/Card.ts | 12 ++++--- tests/unit/selectors/CardTest.ts | 15 ++++---- 4 files changed, 48 insertions(+), 36 deletions(-) diff --git a/src/hooks/useSearchTypeMenuSections.ts b/src/hooks/useSearchTypeMenuSections.ts index 5a0fafb6df478..51789fc2eb96e 100644 --- a/src/hooks/useSearchTypeMenuSections.ts +++ b/src/hooks/useSearchTypeMenuSections.ts @@ -4,11 +4,10 @@ import type {OnyxEntry} from 'react-native-onyx'; import {areAllGroupPoliciesExpenseChatDisabled} from '@libs/PolicyUtils'; import {createTypeMenuSections} from '@libs/SearchUIUtils'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {NonPersonalAndWorkspaceCardListDerivedValue, Policy, Session} from '@src/types/onyx'; +import type {Policy, Session} from '@src/types/onyx'; import useCardFeedsForDisplay from './useCardFeedsForDisplay'; import useCreateEmptyReportConfirmation from './useCreateEmptyReportConfirmation'; import {useMemoizedLazyExpensifyIcons} from './useLazyAsset'; -import useLocalize from './useLocalize'; import useMappedPolicies from './useMappedPolicies'; import useNetwork from './useNetwork'; import useOnyx from './useOnyx'; @@ -48,9 +47,7 @@ const currentUserLoginAndAccountIDSelector = (session: OnyxEntry) => ({ * currently focused search, based on the hash */ const useSearchTypeMenuSections = () => { - const {translate} = useLocalize(); - const cardSelector = (allCards: OnyxEntry) => defaultExpensifyCardSelector(allCards, translate); - const [defaultExpensifyCard] = useOnyx(ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST, {selector: cardSelector}, [cardSelector]); + const [defaultExpensifyCard] = useOnyx(ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST, {selector: defaultExpensifyCardSelector}); const {defaultCardFeed, cardFeedsByPolicy} = useCardFeedsForDisplay(); diff --git a/src/libs/CardFeedUtils.ts b/src/libs/CardFeedUtils.ts index d1847aa2a03c2..1ea54e5c35dba 100644 --- a/src/libs/CardFeedUtils.ts +++ b/src/libs/CardFeedUtils.ts @@ -435,6 +435,36 @@ const generateSelectedCards = ( return [...new Set([...selectedCards, ...(cards ?? [])])]; }; +/** + * Given a card list, return a map of Expensify Card feeds keyed by "${fundID}_${BANK}". + * This is extracted from getCardFeedsForDisplay so it can be called independently + * (e.g. from selectors that only need Expensify Card feeds). + */ +function getExpensifyCardFeedsForDisplay(allCards: CardList | undefined): CardFeedsForDisplay { + const result = {} as CardFeedsForDisplay; + + for (const card of Object.values(allCards ?? {})) { + if (card.bank !== CONST.EXPENSIFY_CARD.BANK || !card.fundID) { + continue; + } + + const id = `${card.fundID}_${CONST.EXPENSIFY_CARD.BANK}`; + + if (result[id]) { + continue; + } + + result[id] = { + id, + feed: CONST.EXPENSIFY_CARD.BANK, + fundID: card.fundID, + name: CONST.EXPENSIFY_CARD.BANK, + }; + } + + return result; +} + /** * Given a collection of card feeds, return formatted card feeds. * @@ -472,24 +502,7 @@ function getCardFeedsForDisplay( } } - for (const card of Object.values(allCards ?? {})) { - if (card.bank !== CONST.EXPENSIFY_CARD.BANK || !card.fundID) { - continue; - } - - const id = `${card.fundID}_${CONST.EXPENSIFY_CARD.BANK}`; - - if (cardFeedsForDisplay[id]) { - continue; - } - - cardFeedsForDisplay[id] = { - id, - feed: CONST.EXPENSIFY_CARD.BANK, - fundID: card.fundID, - name: CONST.EXPENSIFY_CARD.BANK, - }; - } + Object.assign(cardFeedsForDisplay, getExpensifyCardFeedsForDisplay(allCards)); return cardFeedsForDisplay; } @@ -618,6 +631,7 @@ export { generateDomainFeedData, getDomainFeedData, getCardFeedsForDisplay, + getExpensifyCardFeedsForDisplay, getCardFeedsForDisplayPerPolicy, getCombinedCardFeedsFromAllFeeds, getCardFeedStatus, diff --git a/src/selectors/Card.ts b/src/selectors/Card.ts index 0bea5c2d291f5..c48dc00d90fa1 100644 --- a/src/selectors/Card.ts +++ b/src/selectors/Card.ts @@ -1,6 +1,5 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; -import type {LocalizedTranslate} from '@components/LocaleContextProvider'; -import {getCardFeedsForDisplay} from '@libs/CardFeedUtils'; +import {getExpensifyCardFeedsForDisplay} from '@libs/CardFeedUtils'; import {isCard, isCardHiddenFromSearch, isExpensifyCard, isPersonalCard} from '@libs/CardUtils'; import {filterObject} from '@libs/ObjectUtils'; import CONST from '@src/CONST'; @@ -59,9 +58,12 @@ const filterOutPersonalCards = (cards: OnyxEntry): CardList => { /** * Selects the Expensify Card feed from the card list and returns the first one. */ -const defaultExpensifyCardSelector = (allCards: OnyxEntry, translate: LocalizedTranslate) => { - const cards = getCardFeedsForDisplay({}, allCards, translate); - return Object.values(cards)?.at(0); +const defaultExpensifyCardSelector = (allCards: OnyxEntry) => { + const cards = getExpensifyCardFeedsForDisplay(allCards ?? undefined); + for (const card of Object.values(cards)) { + return card; + } + return undefined; }; /** diff --git a/tests/unit/selectors/CardTest.ts b/tests/unit/selectors/CardTest.ts index 234b8ca4d068f..0a65b4aeaf52a 100644 --- a/tests/unit/selectors/CardTest.ts +++ b/tests/unit/selectors/CardTest.ts @@ -6,7 +6,6 @@ import {isCard, isCardPendingActivate, isCardPendingIssue, isCardWithPotentialFr import CONST from '@src/CONST'; import type {Card, CardList} from '@src/types/onyx'; import createRandomCard, {createRandomCompanyCard, createRandomExpensifyCard} from '../../utils/collections/card'; -import {translateLocal} from '../../utils/TestHelper'; /** * Test helper replicating the logic that was moved inline into useTimeSensitiveCards hook. @@ -160,8 +159,8 @@ describe('filterCardsHiddenFromSearch', () => { describe('defaultExpensifyCardSelector', () => { it('Should return undefined if allCards is undefined or empty', () => { - expect(defaultExpensifyCardSelector(undefined, translateLocal)).toBeUndefined(); - expect(defaultExpensifyCardSelector({}, translateLocal)).toBeUndefined(); + expect(defaultExpensifyCardSelector(undefined)).toBeUndefined(); + expect(defaultExpensifyCardSelector({})).toBeUndefined(); }); it('Should return undefined if cards do not have Expensify Card bank', () => { @@ -170,7 +169,7 @@ describe('defaultExpensifyCardSelector', () => { '2': createRandomCompanyCard(2, {bank: 'stripe'}), }; - expect(defaultExpensifyCardSelector(allCards, translateLocal)).toBeUndefined(); + expect(defaultExpensifyCardSelector(allCards)).toBeUndefined(); }); it('Should return undefined if Expensify Card does not have fundID', () => { @@ -179,7 +178,7 @@ describe('defaultExpensifyCardSelector', () => { '2': createRandomExpensifyCard(2, {fundID: ''}), }; - expect(defaultExpensifyCardSelector(allCards, translateLocal)).toBeUndefined(); + expect(defaultExpensifyCardSelector(allCards)).toBeUndefined(); }); it('Should return the first Expensify Card feed when multiple Expensify Cards exist', () => { @@ -187,7 +186,7 @@ describe('defaultExpensifyCardSelector', () => { '1': createRandomExpensifyCard(1, {fundID: '5555'}), '2': createRandomExpensifyCard(2, {fundID: '6666'}), }; - const result = defaultExpensifyCardSelector(allCards, translateLocal); + const result = defaultExpensifyCardSelector(allCards); expect(result).toEqual({ id: '5555_Expensify Card', feed: CONST.EXPENSIFY_CARD.BANK, @@ -203,7 +202,7 @@ describe('defaultExpensifyCardSelector', () => { '3': createRandomExpensifyCard(3, {fundID: '6666'}), }; - const result = defaultExpensifyCardSelector(allCards, translateLocal); + const result = defaultExpensifyCardSelector(allCards); expect(result).toEqual({ id: '5555_Expensify Card', feed: CONST.EXPENSIFY_CARD.BANK, @@ -217,7 +216,7 @@ describe('defaultExpensifyCardSelector', () => { '1': createRandomExpensifyCard(1, {fundID: undefined}), '2': createRandomExpensifyCard(2, {fundID: '5555'}), }; - const result = defaultExpensifyCardSelector(allCards, translateLocal); + const result = defaultExpensifyCardSelector(allCards); expect(result).toEqual({ id: '5555_Expensify Card', feed: CONST.EXPENSIFY_CARD.BANK, From e786d45fc142ea042a4c8a70a0f66320c98c23aa Mon Sep 17 00:00:00 2001 From: husamdaifalla01-cmyk Date: Fri, 6 Mar 2026 02:19:43 -0500 Subject: [PATCH 2/2] Add unit tests for getExpensifyCardFeedsForDisplay utility Co-Authored-By: Claude Opus 4.6 --- tests/unit/CardFeedUtilsTest.ts | 95 ++++++++++++++++++++++++++++++++- 1 file changed, 94 insertions(+), 1 deletion(-) diff --git a/tests/unit/CardFeedUtilsTest.ts b/tests/unit/CardFeedUtilsTest.ts index 9974d4eff459d..2cdc5d8c3494c 100644 --- a/tests/unit/CardFeedUtilsTest.ts +++ b/tests/unit/CardFeedUtilsTest.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/naming-convention */ import type {OnyxCollection} from 'react-native-onyx'; -import {getCardFeedNamesWithType, getCardFeedsForDisplay, getCardFeedsForDisplayPerPolicy, getSelectedCardsFromFeeds} from '@libs/CardFeedUtils'; +import {getCardFeedNamesWithType, getCardFeedsForDisplay, getCardFeedsForDisplayPerPolicy, getExpensifyCardFeedsForDisplay, getSelectedCardsFromFeeds} from '@libs/CardFeedUtils'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; import type {CardFeeds, CardList, CompanyCardFeed, WorkspaceCardsList} from '@src/types/onyx'; @@ -160,3 +160,96 @@ describe('Card Feed Utils', () => { }); }); }); + +describe('getExpensifyCardFeedsForDisplay', () => { + it('returns empty object when allCards is undefined', () => { + expect(getExpensifyCardFeedsForDisplay(undefined)).toEqual({}); + }); + + it('returns empty object when allCards is empty', () => { + expect(getExpensifyCardFeedsForDisplay({})).toEqual({}); + }); + + it('returns empty object when no cards have Expensify Card bank', () => { + const allCards = { + '1': {bank: 'vcf', fundID: '5555'}, + '2': {bank: 'stripe', fundID: '6666'}, + } as unknown as CardList; + + expect(getExpensifyCardFeedsForDisplay(allCards)).toEqual({}); + }); + + it('returns empty object when Expensify Cards have no fundID', () => { + const allCards = { + '1': {bank: CONST.EXPENSIFY_CARD.BANK, fundID: undefined}, + '2': {bank: CONST.EXPENSIFY_CARD.BANK, fundID: ''}, + } as unknown as CardList; + + expect(getExpensifyCardFeedsForDisplay(allCards)).toEqual({}); + }); + + it('returns a single feed entry for one Expensify Card with fundID', () => { + const allCards = { + '1': {bank: CONST.EXPENSIFY_CARD.BANK, fundID: '5555'}, + } as unknown as CardList; + + expect(getExpensifyCardFeedsForDisplay(allCards)).toEqual({ + '5555_Expensify Card': {id: '5555_Expensify Card', fundID: '5555', feed: CONST.EXPENSIFY_CARD.BANK, name: CONST.EXPENSIFY_CARD.BANK}, + }); + }); + + it('deduplicates cards with the same fundID', () => { + const allCards = { + '1': {bank: CONST.EXPENSIFY_CARD.BANK, fundID: '5555'}, + '2': {bank: CONST.EXPENSIFY_CARD.BANK, fundID: '5555'}, + '3': {bank: CONST.EXPENSIFY_CARD.BANK, fundID: '5555'}, + } as unknown as CardList; + + const result = getExpensifyCardFeedsForDisplay(allCards); + expect(Object.keys(result)).toHaveLength(1); + expect(result['5555_Expensify Card']).toEqual({id: '5555_Expensify Card', fundID: '5555', feed: CONST.EXPENSIFY_CARD.BANK, name: CONST.EXPENSIFY_CARD.BANK}); + }); + + it('returns separate entries for different fundIDs', () => { + const allCards = { + '1': {bank: CONST.EXPENSIFY_CARD.BANK, fundID: '5555'}, + '2': {bank: CONST.EXPENSIFY_CARD.BANK, fundID: '6666'}, + } as unknown as CardList; + + const result = getExpensifyCardFeedsForDisplay(allCards); + expect(Object.keys(result)).toHaveLength(2); + expect(result['5555_Expensify Card']).toEqual({id: '5555_Expensify Card', fundID: '5555', feed: CONST.EXPENSIFY_CARD.BANK, name: CONST.EXPENSIFY_CARD.BANK}); + expect(result['6666_Expensify Card']).toEqual({id: '6666_Expensify Card', fundID: '6666', feed: CONST.EXPENSIFY_CARD.BANK, name: CONST.EXPENSIFY_CARD.BANK}); + }); + + it('filters out non-Expensify cards from mixed card list', () => { + const allCards = { + '1': {bank: 'vcf', fundID: '5555'}, + '2': {bank: CONST.EXPENSIFY_CARD.BANK, fundID: '6666'}, + '3': {bank: 'stripe', fundID: '7777'}, + } as unknown as CardList; + + const result = getExpensifyCardFeedsForDisplay(allCards); + expect(Object.keys(result)).toHaveLength(1); + expect(result['6666_Expensify Card']).toBeDefined(); + }); + + it('skips Expensify Cards without fundID while keeping those with fundID', () => { + const allCards = { + '1': {bank: CONST.EXPENSIFY_CARD.BANK, fundID: undefined}, + '2': {bank: CONST.EXPENSIFY_CARD.BANK, fundID: ''}, + '3': {bank: CONST.EXPENSIFY_CARD.BANK, fundID: '8888'}, + } as unknown as CardList; + + const result = getExpensifyCardFeedsForDisplay(allCards); + expect(Object.keys(result)).toHaveLength(1); + expect(result['8888_Expensify Card']).toBeDefined(); + }); + + it('produces the same Expensify Card entries as getCardFeedsForDisplay', () => { + const result = getExpensifyCardFeedsForDisplay(cardListMock); + const fullResult = getCardFeedsForDisplay({}, cardListMock, translateLocal); + + expect(result).toEqual(fullResult); + }); +});