From 89984395e350b79af8be95a19fb06af72be132e1 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Thu, 8 Oct 2026 17:01:20 +0200 Subject: [PATCH 1/3] Replace NON_PERSONAL_AND_WORKSPACE_CARD_LIST derived value with a memoized useOnyxState hook --- __mocks__/react-native-onyx.ts | 4 +- src/ONYXKEYS.ts | 2 - .../MoneyRequestReportTransactionList.tsx | 3 +- .../ReportActionItem/MoneyRequestView.tsx | 3 +- src/components/Search/SearchQueryProvider.tsx | 3 +- .../Search/hooks/useSearchSnapshot.ts | 3 +- src/components/Search/index.tsx | 3 +- src/hooks/useCardFeedsForDisplay.ts | 3 +- .../useNonPersonalAndWorkspaceCardList.ts | 42 ++++++ src/hooks/useSearchBulkActions.ts | 3 +- src/hooks/useSearchSections.ts | 3 +- src/hooks/useSearchTypeMenuSections.ts | 3 +- .../OnyxDerived/ONYX_DERIVED_VALUES.ts | 2 - .../nonPersonalAndWorkspaceCardList.ts | 14 -- .../Search/SearchTransactionsChangeReport.tsx | 3 +- .../InsightsSection/useHomeInsightConfigs.ts | 3 +- src/selectors/Card.ts | 48 ++++++- src/types/onyx/DerivedValues.ts | 6 - src/types/onyx/index.ts | 2 - tests/unit/OnyxDerivedTest.tsx | 108 --------------- tests/unit/selectors/CardTest.ts | 103 +++++++++++++- ...useNonPersonalAndWorkspaceCardListTest.tsx | 130 ++++++++++++++++++ 22 files changed, 342 insertions(+), 152 deletions(-) create mode 100644 src/hooks/useNonPersonalAndWorkspaceCardList.ts delete mode 100644 src/libs/actions/OnyxDerived/configs/nonPersonalAndWorkspaceCardList.ts create mode 100644 tests/unit/useNonPersonalAndWorkspaceCardListTest.tsx diff --git a/__mocks__/react-native-onyx.ts b/__mocks__/react-native-onyx.ts index 6db22ac1b627..f5b09f2949da 100644 --- a/__mocks__/react-native-onyx.ts +++ b/__mocks__/react-native-onyx.ts @@ -5,7 +5,7 @@ import type {ConnectOptions, OnyxKey} from 'react-native-onyx'; // eslint-disable-next-line no-restricted-imports -import Onyx, {useOnyx} from 'react-native-onyx'; +import Onyx, {useOnyx, useOnyxState} from 'react-native-onyx'; let connectCallbackDelay = 0; function addDelayToConnectCallback(delay: number) { @@ -41,4 +41,4 @@ const reactNativeOnyxMock: ReactNativeOnyxMock = { export default reactNativeOnyxMock; -export {useOnyx}; +export {useOnyx, useOnyxState}; diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index d2b16572d106..f1a3a4c1b316 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -1406,7 +1406,6 @@ const ONYXKEYS = { REPORT_TRANSACTIONS_AND_VIOLATIONS: 'reportTransactionsAndViolations', OUTSTANDING_REPORTS_BY_POLICY_ID: 'outstandingReportsByPolicyID', VISIBLE_REPORT_ACTIONS: 'visibleReportActions', - NON_PERSONAL_AND_WORKSPACE_CARD_LIST: 'nonPersonalAndWorkspaceCardList', PERSONAL_AND_WORKSPACE_CARD_LIST: 'personalAndWorkspaceCardList', CARD_FEED_ERRORS: 'cardFeedErrors', RAM_ONLY_SORTED_REPORT_ACTIONS: 'sortedReportActions', @@ -1977,7 +1976,6 @@ type OnyxDerivedValuesMapping = { [ONYXKEYS.DERIVED.REPORT_TRANSACTIONS_AND_VIOLATIONS]: OnyxTypes.ReportTransactionsAndViolationsDerivedValue; [ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID]: OnyxTypes.OutstandingReportsByPolicyIDDerivedValue; [ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS]: OnyxTypes.VisibleReportActionsDerivedValue; - [ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST]: OnyxTypes.NonPersonalAndWorkspaceCardListDerivedValue; [ONYXKEYS.DERIVED.PERSONAL_AND_WORKSPACE_CARD_LIST]: OnyxTypes.PersonalAndWorkspaceCardListDerivedValue; [ONYXKEYS.DERIVED.CARD_FEED_ERRORS]: OnyxTypes.CardFeedErrorsDerivedValue; [ONYXKEYS.DERIVED.RAM_ONLY_SORTED_REPORT_ACTIONS]: OnyxTypes.SortedReportActionsDerivedValue; diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index 39cfa7edcbef..90f6b09465d1 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -6,6 +6,7 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNavigateToTransactionThread from '@hooks/useNavigateToTransactionThread'; import useNetwork from '@hooks/useNetwork'; +import useNonPersonalAndWorkspaceCardList from '@hooks/useNonPersonalAndWorkspaceCardList'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP'; @@ -210,7 +211,7 @@ function MoneyRequestReportTransactionList({ const navigateToTransactionThread = useNavigateToTransactionThread(); const longPressModalRef = useRef(null); const {isOffline} = useNetwork(); - const [nonPersonalAndWorkspaceCards] = useOnyx(ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST); + const nonPersonalAndWorkspaceCards = useNonPersonalAndWorkspaceCardList(); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${report?.policyID}`); const [policyTagLists] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${report?.policyID}`); diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 3fff41e1f9d3..986f38af5c42 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -31,6 +31,7 @@ import useHasMultipleSplitChildren from '@hooks/useHasMultipleSplitChildren'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; +import useNonPersonalAndWorkspaceCardList from '@hooks/useNonPersonalAndWorkspaceCardList'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import {usePersonalDetail} from '@hooks/usePersonalDetails'; @@ -278,7 +279,7 @@ function MoneyRequestView({ const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getNonEmptyStringOnyxID(policyID)}`); const targetPolicyID = updatedTransaction?.reportID ? parentReport?.policyID : policyID; const [policyTagList] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(targetPolicyID)}`); - const [nonPersonalAndWorkspaceCards] = useOnyx(ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST); + const nonPersonalAndWorkspaceCards = useNonPersonalAndWorkspaceCardList(); const transactionCard = transaction?.cardID ? nonPersonalAndWorkspaceCards?.[transaction.cardID] : undefined; const [cardList] = useOnyx(ONYXKEYS.CARD_LIST); const [cardFeedsForTransactionCard] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${getNonEmptyStringOnyxID(transactionCard?.fundID)}`); diff --git a/src/components/Search/SearchQueryProvider.tsx b/src/components/Search/SearchQueryProvider.tsx index 5cc94bf40967..0d031cd9773c 100644 --- a/src/components/Search/SearchQueryProvider.tsx +++ b/src/components/Search/SearchQueryProvider.tsx @@ -1,6 +1,7 @@ import useCardFeedsForDisplay from '@hooks/useCardFeedsForDisplay'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLoadSearchCategoryData from '@hooks/useLoadSearchCategoryData'; +import useNonPersonalAndWorkspaceCardList from '@hooks/useNonPersonalAndWorkspaceCardList'; import useOnyx from '@hooks/useOnyx'; import usePreviousDefined from '@hooks/usePreviousDefined'; import useRootNavigationState from '@hooks/useRootNavigationState'; @@ -59,7 +60,7 @@ function SearchQueryProvider({children}: SearchQueryProviderProps) { useLoadSearchCategoryData({shouldLoad: shouldLoadCategoryData}); const {defaultCardFeed, activeExpensifyCardFeedID} = useCardFeedsForDisplay(); - const [defaultExpensifyCardID] = useOnyx(ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST, {selector: (card) => defaultExpensifyCardSelector(card)?.id}); + const defaultExpensifyCardID = useNonPersonalAndWorkspaceCardList((card) => defaultExpensifyCardSelector(card)?.id); const {accountID, email} = useCurrentUserPersonalDetails(); const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const defaultCardFeedID = defaultCardFeed?.id; diff --git a/src/components/Search/hooks/useSearchSnapshot.ts b/src/components/Search/hooks/useSearchSnapshot.ts index e889874c1b1d..986f3f504b8b 100644 --- a/src/components/Search/hooks/useSearchSnapshot.ts +++ b/src/components/Search/hooks/useSearchSnapshot.ts @@ -8,6 +8,7 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useLocalize from '@hooks/useLocalize'; import useMultipleSnapshots from '@hooks/useMultipleSnapshots'; import useNetwork from '@hooks/useNetwork'; +import useNonPersonalAndWorkspaceCardList from '@hooks/useNonPersonalAndWorkspaceCardList'; import useOnyx from '@hooks/useOnyx'; import {useAllPersonalDetails} from '@hooks/usePersonalDetails'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; @@ -140,7 +141,7 @@ function useSearchSnapshot({queryJSON, searchResults, transactions, reportAction const [onyxPersonalDetailsList] = useAllPersonalDetails(); const [cardFeeds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER); const [personalAndWorkspaceCards] = useOnyx(ONYXKEYS.DERIVED.PERSONAL_AND_WORKSPACE_CARD_LIST); - const [nonPersonalAndWorkspaceCards] = useOnyx(ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST); + const nonPersonalAndWorkspaceCards = useNonPersonalAndWorkspaceCardList(); const [customCardNames] = useOnyx(ONYXKEYS.NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [policyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES); diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index aec4f8946e52..ab094d4a906b 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -11,6 +11,7 @@ import useDelegateAccountID from '@hooks/useDelegateAccountID'; import type {ActionHandledType} from '@hooks/useHoldMenuSubmit'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; +import useNonPersonalAndWorkspaceCardList from '@hooks/useNonPersonalAndWorkspaceCardList'; import useOnyx from '@hooks/useOnyx'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import usePrevious from '@hooks/usePrevious'; @@ -193,7 +194,7 @@ function Search({ const {accountID, email} = useCurrentUserPersonalDetails(); const personalDetails = usePersonalDetails(); const isActionLoadingSet = useActionLoadingReportIDs(); - const [nonPersonalAndWorkspaceCards] = useOnyx(ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST); + const nonPersonalAndWorkspaceCards = useNonPersonalAndWorkspaceCardList(); const isExpenseReportType = type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; diff --git a/src/hooks/useCardFeedsForDisplay.ts b/src/hooks/useCardFeedsForDisplay.ts index d8659b3fd550..70c9105f1065 100644 --- a/src/hooks/useCardFeedsForDisplay.ts +++ b/src/hooks/useCardFeedsForDisplay.ts @@ -16,6 +16,7 @@ import {expensifyCardFeedsForDisplaySelector} from '@selectors/Card'; import useExpensifyCardFeedsForFeedSelector from './useExpensifyCardFeedsForFeedSelector'; import useFeedKeysWithAssignedCards from './useFeedKeysWithAssignedCards'; import useLocalize from './useLocalize'; +import useNonPersonalAndWorkspaceCardList from './useNonPersonalAndWorkspaceCardList'; import useOnyx from './useOnyx'; const eligiblePoliciesSelector = (policies: OnyxCollection): string[] => { @@ -108,7 +109,7 @@ const useCardFeedsForDisplay = () => { const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const feedKeysWithCards = useFeedKeysWithAssignedCards(); const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); - const [expensifyCardFeeds] = useOnyx(ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST, {selector: expensifyCardFeedsForDisplaySelector}); + const expensifyCardFeeds = useNonPersonalAndWorkspaceCardList(expensifyCardFeedsForDisplaySelector); const eligiblePoliciesIDsArray = eligiblePoliciesSelector(allPolicies); const cardFeedsByPolicy = getCardFeedsForDisplayPerPolicy(allFeeds, translate, feedKeysWithCards, allPolicies); diff --git a/src/hooks/useNonPersonalAndWorkspaceCardList.ts b/src/hooks/useNonPersonalAndWorkspaceCardList.ts new file mode 100644 index 000000000000..f19c0e943306 --- /dev/null +++ b/src/hooks/useNonPersonalAndWorkspaceCardList.ts @@ -0,0 +1,42 @@ +import ONYXKEYS from '@src/ONYXKEYS'; +import type {CardList, WorkspaceCardsList} from '@src/types/onyx'; + +import type {OnyxCollection} from 'react-native-onyx'; + +import {nonPersonalAndWorkspaceCardListSelector} from '@selectors/Card'; +import {useOnyxState} from 'react-native-onyx'; + +type CardListSelector = (cardList: CardList) => TReturn; + +const dependencies = [ONYXKEYS.CARD_LIST, ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST]; + +// useOnyxState runs the selector on every write to either key, so each selector's output is kept per merged list +const selectedValues = new WeakMap, {cardList: CardList; value: unknown}>(); + +function select(selector: CardListSelector, cardList: CardList): TReturn { + const cached = selectedValues.get(selector); + if (cached?.cardList === cardList) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return cached.value as TReturn; + } + const value = selector(cardList); + selectedValues.set(selector, {cardList, value}); + return value; +} + +function useNonPersonalAndWorkspaceCardList(): CardList; +function useNonPersonalAndWorkspaceCardList(selector: CardListSelector): TReturn; +function useNonPersonalAndWorkspaceCardList(selector?: CardListSelector) { + return useOnyxState( + (state) => { + // useOnyxState's state view types a collection key as one member, but at runtime it returns the whole collection + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const workspaceCardFeeds = state[ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST] as OnyxCollection; + const cardList = nonPersonalAndWorkspaceCardListSelector(state[ONYXKEYS.CARD_LIST], workspaceCardFeeds); + return selector ? select(selector, cardList) : cardList; + }, + {dependencies}, + ); +} + +export default useNonPersonalAndWorkspaceCardList; diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 62e47e273ace..29569b322a56 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -150,6 +150,7 @@ import {useMemoizedLazyExpensifyIcons} from './useLazyAsset'; import useLoadSearchCardData from './useLoadSearchCardData'; import useLocalize from './useLocalize'; import useNetwork from './useNetwork'; +import useNonPersonalAndWorkspaceCardList from './useNonPersonalAndWorkspaceCardList'; import useOnyx from './useOnyx'; import {getParticipantsInvoiceReport} from './useParticipantsInvoiceReport'; import usePaymentContext from './usePaymentContext'; @@ -685,7 +686,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const [cardList] = useOnyx(ONYXKEYS.CARD_LIST); - const [nonPersonalAndWorkspaceCards] = useOnyx(ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST); + const nonPersonalAndWorkspaceCards = useNonPersonalAndWorkspaceCardList(); // "Auto report" cannot read a card's absence as no access until every card has arrived. const {areCardsLoaded: isSearchCardListComplete} = useLoadSearchCardData(); diff --git a/src/hooks/useSearchSections.ts b/src/hooks/useSearchSections.ts index 19733b0ac474..c92b56887325 100644 --- a/src/hooks/useSearchSections.ts +++ b/src/hooks/useSearchSections.ts @@ -10,6 +10,7 @@ import {useCurrencyListActions} from './useCurrencyList'; import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; import useFilterPendingDeleteReports from './useFilterPendingDeleteReports'; import useLocalize from './useLocalize'; +import useNonPersonalAndWorkspaceCardList from './useNonPersonalAndWorkspaceCardList'; import useOnyx from './useOnyx'; import useReportAttributes from './useReportAttributes'; @@ -29,7 +30,7 @@ function useSearchSections(): UseSearchSectionsResult { const [cardFeeds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER); const [personalAndWorkspaceCards] = useOnyx(ONYXKEYS.DERIVED.PERSONAL_AND_WORKSPACE_CARD_LIST); - const [nonPersonalAndWorkspaceCards] = useOnyx(ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST); + const nonPersonalAndWorkspaceCards = useNonPersonalAndWorkspaceCardList(); const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); diff --git a/src/hooks/useSearchTypeMenuSections.ts b/src/hooks/useSearchTypeMenuSections.ts index 40d372f51e96..b1d80f1b929a 100644 --- a/src/hooks/useSearchTypeMenuSections.ts +++ b/src/hooks/useSearchTypeMenuSections.ts @@ -16,6 +16,7 @@ import useCreateEmptyReportConfirmation from './useCreateEmptyReportConfirmation import useHasReportAwaitingApproval from './useHasReportAwaitingApproval'; import useMappedPolicies from './useMappedPolicies'; import useNetwork from './useNetwork'; +import useNonPersonalAndWorkspaceCardList from './useNonPersonalAndWorkspaceCardList'; import useOnyx from './useOnyx'; import usePermissions from './usePermissions'; @@ -58,7 +59,7 @@ const currentUserLoginAndAccountIDSelector = (session: OnyxEntry) => ({ * reliably, so this hook never depends on a navigation context itself. */ const useSearchTypeMenuSections = (isScreenFocused = true) => { - const [defaultExpensifyCard] = useOnyx(ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST, {selector: defaultExpensifyCardSelector}); + const defaultExpensifyCard = useNonPersonalAndWorkspaceCardList(defaultExpensifyCardSelector); const {defaultCardFeed, cardFeedsByPolicy, activeExpensifyCardFeedID} = useCardFeedsForDisplay(); diff --git a/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts b/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts index 4ea4fabd9560..a614e10e875d 100644 --- a/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts +++ b/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts @@ -7,7 +7,6 @@ import type {OnyxDerivedValueConfig} from './types'; import cardFeedErrorsConfig from './configs/cardFeedErrors'; import guideAccountIDsConfig from './configs/guideAccountIDs'; import loginToAccountIDMapConfig from './configs/loginToAccountIDMap'; -import nonPersonalAndWorkspaceCardListConfig from './configs/nonPersonalAndWorkspaceCardList'; import oneOnOneChatReportIDsConfig from './configs/oneOnOneChatReportIDs'; import outstandingReportsByPolicyIDConfig from './configs/outstandingReportsByPolicyID'; import personalAndWorkspaceCardListConfig from './configs/personalAndWorkspaceCardList'; @@ -26,7 +25,6 @@ const ONYX_DERIVED_VALUES = { [ONYXKEYS.DERIVED.REPORT_TRANSACTIONS_AND_VIOLATIONS]: reportTransactionsAndViolationsConfig, [ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID]: outstandingReportsByPolicyIDConfig, [ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS]: visibleReportActionsConfig, - [ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST]: nonPersonalAndWorkspaceCardListConfig, [ONYXKEYS.DERIVED.PERSONAL_AND_WORKSPACE_CARD_LIST]: personalAndWorkspaceCardListConfig, [ONYXKEYS.DERIVED.CARD_FEED_ERRORS]: cardFeedErrorsConfig, [ONYXKEYS.DERIVED.RAM_ONLY_SORTED_REPORT_ACTIONS]: sortedReportActionsConfig, diff --git a/src/libs/actions/OnyxDerived/configs/nonPersonalAndWorkspaceCardList.ts b/src/libs/actions/OnyxDerived/configs/nonPersonalAndWorkspaceCardList.ts deleted file mode 100644 index d37287da1c07..000000000000 --- a/src/libs/actions/OnyxDerived/configs/nonPersonalAndWorkspaceCardList.ts +++ /dev/null @@ -1,14 +0,0 @@ -import {mergeCardListWithWorkspaceFeeds} from '@libs/CardUtils'; - -import createOnyxDerivedValueConfig from '@userActions/OnyxDerived/createOnyxDerivedValueConfig'; - -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; - -export default createOnyxDerivedValueConfig({ - key: ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST, - dependencies: [ONYXKEYS.CARD_LIST, ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST], - compute: ([cardList, workspaceCardFeeds]) => { - return mergeCardListWithWorkspaceFeeds(workspaceCardFeeds ?? CONST.EMPTY_OBJECT, cardList, true); - }, -}); diff --git a/src/pages/Search/SearchTransactionsChangeReport.tsx b/src/pages/Search/SearchTransactionsChangeReport.tsx index 17926abcaccc..e9c4b391cb0d 100644 --- a/src/pages/Search/SearchTransactionsChangeReport.tsx +++ b/src/pages/Search/SearchTransactionsChangeReport.tsx @@ -12,6 +12,7 @@ import useHydrateReportsFromSnapshot from '@hooks/useHydrateReportsFromSnapshot' import useLoadSearchCardData from '@hooks/useLoadSearchCardData'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; +import useNonPersonalAndWorkspaceCardList from '@hooks/useNonPersonalAndWorkspaceCardList'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import usePersonalPolicy from '@hooks/usePersonalPolicy'; @@ -91,7 +92,7 @@ function SearchTransactionsChangeReport() { const managedCardTransactionID = transactions.find((transaction) => isUnreportedManagedCardTransaction(transaction))?.transactionID; const hasUnreportedManagedCardTransactions = !!managedCardTransactionID; const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); - const [nonPersonalAndWorkspaceCards] = useOnyx(ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST); + const nonPersonalAndWorkspaceCards = useNonPersonalAndWorkspaceCardList(); // Asked for here too, so opening this screen retries a fetch that failed earlier. const {areCardsLoaded: isSearchCardListComplete} = useLoadSearchCardData(); const reports = useChangeTransactionsReportReports(transactions, undefined); diff --git a/src/pages/home/InsightsSection/useHomeInsightConfigs.ts b/src/pages/home/InsightsSection/useHomeInsightConfigs.ts index 43045f86e6e4..2d2c4995c6e1 100644 --- a/src/pages/home/InsightsSection/useHomeInsightConfigs.ts +++ b/src/pages/home/InsightsSection/useHomeInsightConfigs.ts @@ -1,4 +1,5 @@ import useCardFeedsForDisplay from '@hooks/useCardFeedsForDisplay'; +import useNonPersonalAndWorkspaceCardList from '@hooks/useNonPersonalAndWorkspaceCardList'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; @@ -51,7 +52,7 @@ function buildInsightConfigFromChart(chart: InsightsChartSpec, filters: Insights function useHomeInsightConfigs(): {configs: SearchTypeMenuItem[]; isResolved: boolean} { const [session] = useOnyx(ONYXKEYS.SESSION); const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); - const [defaultExpensifyCard] = useOnyx(ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST, {selector: defaultExpensifyCardSelector}); + const defaultExpensifyCard = useNonPersonalAndWorkspaceCardList(defaultExpensifyCardSelector); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); const {defaultCardFeed, cardFeedsByPolicy} = useCardFeedsForDisplay(); const {isBetaEnabled} = usePermissions(); diff --git a/src/selectors/Card.ts b/src/selectors/Card.ts index 7a75744ff56c..35a4b3486a87 100644 --- a/src/selectors/Card.ts +++ b/src/selectors/Card.ts @@ -1,13 +1,25 @@ import {getExpensifyCardFeedsForDisplay} from '@libs/CardFeedUtils'; -import {hasActiveExpensifyCard, hasAssignedCardMatching, isActiveCard, isCard, isCardHiddenFromSearch, isCSVFeedOrExpensifyCard, isExpensifyCard, isPersonalCard} from '@libs/CardUtils'; +import { + hasActiveExpensifyCard, + hasAssignedCardMatching, + isActiveCard, + isCard, + isCardHiddenFromSearch, + isCSVFeedOrExpensifyCard, + isExpensifyCard, + isPersonalCard, + mergeCardListWithWorkspaceFeeds, +} from '@libs/CardUtils'; import {filterObject} from '@libs/ObjectUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {CardFeeds, CardList, NonPersonalAndWorkspaceCardListDerivedValue, WorkspaceCardsList} from '@src/types/onyx'; +import type {CardFeeds, CardList, WorkspaceCardsList} from '@src/types/onyx'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import {deepEqual} from 'fast-equals'; + /** * Builds a lightweight map of "${domainID}_${feedName}" keys that have card entries. * A feed counts as having cards when: @@ -77,16 +89,43 @@ const getBankLinkedPersonalCards = (cards: OnyxEntry): CardList => { * matching a feed's `fundID` against that workspace's candidate fund IDs — its primary feed (`linkedPolicyIDs`), a * `preferredPolicy`-linked feed, or the policy's `policyAccountID` — via `getActiveExpensifyCardFeedID`. */ -const expensifyCardFeedsForDisplaySelector = (allCards: OnyxEntry) => +const expensifyCardFeedsForDisplaySelector = (allCards: OnyxEntry) => Object.values(getExpensifyCardFeedsForDisplay(allCards ?? undefined, undefined)).filter((feed) => feed.country !== CONST.TRAVEL.PROGRAM_TRAVEL_US); /** * Selects the Expensify Card feed from the card list and returns the first regular (non-travel) one. */ -const defaultExpensifyCardSelector = (allCards: OnyxEntry) => { +const defaultExpensifyCardSelector = (allCards: OnyxEntry) => { return expensifyCardFeedsForDisplaySelector(allCards).at(0); }; +/** + * Merges the user's card list with every workspace card feed. The last pair of inputs and its result are kept, so + * every mounted consumer shares one merge per write. A merge equal to the last one returns the last object, so a + * write that leaves the merged list unchanged doesn't make consumers re-run their selectors. + */ +const createCardListWithWorkspaceFeedsSelector = (shouldFilterOutPersonalCards: boolean) => { + let lastCardList: OnyxEntry; + let lastWorkspaceCardFeeds: OnyxCollection; + let lastResult: CardList | undefined; + + return (cardList: OnyxEntry, workspaceCardFeeds: OnyxCollection): CardList => { + if (lastResult && cardList === lastCardList && workspaceCardFeeds === lastWorkspaceCardFeeds) { + return lastResult; + } + lastCardList = cardList; + lastWorkspaceCardFeeds = workspaceCardFeeds; + const result = mergeCardListWithWorkspaceFeeds(workspaceCardFeeds ?? CONST.EMPTY_OBJECT, cardList, shouldFilterOutPersonalCards); + if (!lastResult || !deepEqual(result, lastResult)) { + lastResult = result; + } + return lastResult; + }; +}; + +/** The user's non-personal cards plus every workspace feed card. */ +const nonPersonalAndWorkspaceCardListSelector = createCardListWithWorkspaceFeedsSelector(true); + /** * Returns a selector that picks a single card from the card list by card ID. */ @@ -135,4 +174,5 @@ export { companyCardCustomNamesSelector, hasIssuedExpensifyCardSelector, hasActiveExpensifyCardSelector, + nonPersonalAndWorkspaceCardListSelector, }; diff --git a/src/types/onyx/DerivedValues.ts b/src/types/onyx/DerivedValues.ts index 3f5e2a136157..9099ba727682 100644 --- a/src/types/onyx/DerivedValues.ts +++ b/src/types/onyx/DerivedValues.ts @@ -189,11 +189,6 @@ type CardFeedErrors = { */ type CardFeedErrorsDerivedValue = CardFeedErrors; -/** - * The derived value for merged non-personal and workspace card feeds. - */ -type NonPersonalAndWorkspaceCardListDerivedValue = CardList; - /** * The derived value for sorted report actions, last report actions, and cached transaction thread report IDs. */ @@ -252,7 +247,6 @@ export type { OutstandingReportsByPolicyIDDerivedValue, VisibleReportActionsDerivedValue, SortedReportActionsDerivedValue, - NonPersonalAndWorkspaceCardListDerivedValue, PersonalAndWorkspaceCardListDerivedValue, CardFeedErrorsDerivedValue, LoginToAccountIDMapDerivedValue, diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index fad7cf8b5ab5..04735fac8dab 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -63,7 +63,6 @@ import type { GuideAccountIDsDerivedValue, SpendDataSignatureDerivedValue, LoginToAccountIDMapDerivedValue, - NonPersonalAndWorkspaceCardListDerivedValue, OneOnOneChatReportIDsDerivedValue, OutstandingReportsByPolicyIDDerivedValue, PersonalAndWorkspaceCardListDerivedValue, @@ -458,7 +457,6 @@ export type { OutstandingReportsByPolicyIDDerivedValue, VisibleReportActionsDerivedValue, SortedReportActionsDerivedValue, - NonPersonalAndWorkspaceCardListDerivedValue, PersonalAndWorkspaceCardListDerivedValue, CardFeedErrorsDerivedValue, LoginToAccountIDMapDerivedValue, diff --git a/tests/unit/OnyxDerivedTest.tsx b/tests/unit/OnyxDerivedTest.tsx index cb5d50da8939..83af7fc63de1 100644 --- a/tests/unit/OnyxDerivedTest.tsx +++ b/tests/unit/OnyxDerivedTest.tsx @@ -808,114 +808,6 @@ describe('OnyxDerived', () => { }); }); - describe('nonPersonalAndWorkspaceCardList', () => { - beforeAll(async () => { - // Initialize dependency keys so Onyx.clear() in beforeEach triggers derived value recomputation - await Onyx.set(ONYXKEYS.CARD_LIST, {}); - await waitForBatchedUpdates(); - }); - - it('returns empty object when dependencies are not set', async () => { - await waitForBatchedUpdates(); - const derivedCardList = await OnyxUtils.get(ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST); - expect(derivedCardList).toEqual({}); - }); - - it('merges cardList and workspaceCardFeeds when dependencies are set', async () => { - // Non-personal cards (fundID !== '0') from cardList are kept, workspace cards are always included - const nonPersonalCard1 = createRandomExpensifyCard(1, {fundID: '123'}); - const nonPersonalCard2 = createRandomExpensifyCard(2, {fundID: '456'}); - const workspaceCard3 = createRandomCompanyCard(3, {bank: 'vcf'}); - - await Onyx.set(ONYXKEYS.CARD_LIST, { - '1': nonPersonalCard1, - '2': nonPersonalCard2, - }); - await Onyx.set(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}workspace_123`, { - '3': workspaceCard3, - }); - await waitForBatchedUpdates(); - const derivedCardList = await OnyxUtils.get(ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST); - - expect(derivedCardList).toMatchObject({ - '1': expect.objectContaining({cardID: 1}), - '2': expect.objectContaining({cardID: 2}), - '3': expect.objectContaining({cardID: 3}), - }); - }); - - it('filters out personal cards from cardList when dependencies are set', async () => { - const nonPersonalCard = createRandomExpensifyCard(1, {fundID: '123'}); - const personalCard = createRandomExpensifyCard(2, {fundID: '0'}); - const workspaceCard = createRandomCompanyCard(3, {bank: 'vcf'}); - - await Onyx.set(ONYXKEYS.CARD_LIST, { - '1': nonPersonalCard, - '2': personalCard, - }); - await Onyx.set(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}workspace_123`, { - '3': workspaceCard, - }); - await waitForBatchedUpdates(); - const derivedCardList = await OnyxUtils.get(ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST); - - expect(derivedCardList?.['1']).toBeDefined(); - expect(derivedCardList?.['1']).toMatchObject({cardID: 1}); - expect(derivedCardList?.['2']).toBeUndefined(); - expect(derivedCardList?.['3']).toBeDefined(); - expect(derivedCardList?.['3']).toMatchObject({cardID: 3}); - }); - - it('handles empty cardList when workspaceCardFeeds are set', async () => { - const workspaceCard = createRandomCompanyCard(1, {bank: 'vcf'}); - - await Onyx.set(ONYXKEYS.CARD_LIST, {}); - await Onyx.set(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}workspace_123`, { - '1': workspaceCard, - }); - await waitForBatchedUpdates(); - const derivedCardList = await OnyxUtils.get(ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST); - - expect(derivedCardList).toMatchObject({ - '1': expect.objectContaining({cardID: 1}), - }); - }); - - it('handles empty workspaceCardFeeds when cardList is set', async () => { - const nonPersonalCard = createRandomExpensifyCard(1, {fundID: '123'}); - - await Onyx.set(ONYXKEYS.CARD_LIST, { - '1': nonPersonalCard, - }); - await waitForBatchedUpdates(); - const derivedCardList = await OnyxUtils.get(ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST); - - expect(derivedCardList).toMatchObject({ - '1': expect.objectContaining({cardID: 1}), - }); - }); - - it('includes cards from multiple workspace feeds when dependencies are set', async () => { - const card1 = createRandomCompanyCard(1, {bank: 'vcf'}); - const card2 = createRandomCompanyCard(2, {bank: 'stripe'}); - - await Onyx.set(ONYXKEYS.CARD_LIST, {}); - await Onyx.set(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}workspace_123`, { - '1': card1, - }); - await Onyx.set(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}workspace_456`, { - '2': card2, - }); - await waitForBatchedUpdates(); - const derivedCardList = await OnyxUtils.get(ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST); - - expect(derivedCardList).toMatchObject({ - '1': expect.objectContaining({cardID: 1}), - '2': expect.objectContaining({cardID: 2}), - }); - }); - }); - describe('personalAndWorkspaceCardList', () => { it('merges cardList and workspaceCardFeeds when dependencies are set', async () => { // Non-personal cards (fundID !== '0') from cardList are kept, workspace cards are always included diff --git a/tests/unit/selectors/CardTest.ts b/tests/unit/selectors/CardTest.ts index 836a9a0c81ce..588025374c05 100644 --- a/tests/unit/selectors/CardTest.ts +++ b/tests/unit/selectors/CardTest.ts @@ -1,9 +1,10 @@ import {isCard, isCardPendingActivate, isCardPendingIssue, isCardWithPotentialFraud, isExpensifyCard} from '@libs/CardUtils'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import type {Card, CardList, WorkspaceCardsList} from '@src/types/onyx'; -import type {OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; /* eslint-disable @typescript-eslint/naming-convention */ @@ -14,6 +15,7 @@ import { filterOutPersonalCards, hasActiveExpensifyCardSelector, hasIssuedExpensifyCardSelector, + nonPersonalAndWorkspaceCardListSelector, } from '@selectors/Card'; import createRandomCard, {createRandomCompanyCard, createRandomExpensifyCard} from '../../utils/collections/card'; @@ -1000,3 +1002,102 @@ describe('hasActiveExpensifyCardSelector', () => { expect(hasActiveExpensifyCardSelector(undefined)).toBe(false); }); }); + +describe('nonPersonalAndWorkspaceCardListSelector', () => { + const workspaceFeedKey = `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}workspace_123`; + const otherWorkspaceFeedKey = `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}workspace_456`; + + it('should return an empty list when neither input is set', () => { + // Given no card list and no workspace feeds, as before they are loaded + // When the merged list is selected + // Then it is an empty list rather than undefined, so consumers can look cards up right away + expect(nonPersonalAndWorkspaceCardListSelector(undefined, undefined)).toEqual({}); + }); + + it('should merge the card list with the workspace feed cards', () => { + // Given two non-personal cards in the card list and a third card in a workspace feed + const cardList: CardList = { + '1': createRandomExpensifyCard(1, {fundID: '123'}), + '2': createRandomExpensifyCard(2, {fundID: '456'}), + }; + const workspaceCardFeeds: OnyxCollection = {[workspaceFeedKey]: {'3': createRandomCompanyCard(3, {bank: 'vcf'})}}; + + // When the merged list is selected + // Then every card from both sources is in it + expect(nonPersonalAndWorkspaceCardListSelector(cardList, workspaceCardFeeds)).toMatchObject({ + '1': expect.objectContaining({cardID: 1}), + '2': expect.objectContaining({cardID: 2}), + '3': expect.objectContaining({cardID: 3}), + }); + }); + + it('should leave out the personal cards of the card list', () => { + // Given a non-personal card, a personal card (fundID '0') and a workspace feed card + const cardList: CardList = { + '1': createRandomExpensifyCard(1, {fundID: '123'}), + '2': createRandomExpensifyCard(2, {fundID: '0'}), + }; + const workspaceCardFeeds: OnyxCollection = {[workspaceFeedKey]: {'3': createRandomCompanyCard(3, {bank: 'vcf'})}}; + + // When the merged list is selected + const result = nonPersonalAndWorkspaceCardListSelector(cardList, workspaceCardFeeds); + + // Then only the personal card is dropped, since this list is for company and workspace cards + expect(result['1']).toMatchObject({cardID: 1}); + expect(result['2']).toBeUndefined(); + expect(result['3']).toMatchObject({cardID: 3}); + }); + + it('should keep the workspace feed cards when the card list is empty', () => { + // Given an empty card list and one workspace feed card + const workspaceCardFeeds: OnyxCollection = {[workspaceFeedKey]: {'1': createRandomCompanyCard(1, {bank: 'vcf'})}}; + + // When the merged list is selected + // Then the workspace card is still there, since the two sources are independent + expect(nonPersonalAndWorkspaceCardListSelector({}, workspaceCardFeeds)).toMatchObject({'1': expect.objectContaining({cardID: 1})}); + }); + + it('should keep the card list cards when there are no workspace feeds', () => { + // Given one non-personal card and no workspace feeds + const cardList: CardList = {'1': createRandomExpensifyCard(1, {fundID: '123'})}; + + // When the merged list is selected + // Then the card is there, since a missing feed collection must not hide the user's own cards + expect(nonPersonalAndWorkspaceCardListSelector(cardList, undefined)).toMatchObject({'1': expect.objectContaining({cardID: 1})}); + }); + + it('should include the cards of every workspace feed', () => { + // Given two workspace feeds with one card each + const workspaceCardFeeds: OnyxCollection = { + [workspaceFeedKey]: {'1': createRandomCompanyCard(1, {bank: 'vcf'})}, + [otherWorkspaceFeedKey]: {'2': createRandomCompanyCard(2, {bank: 'stripe'})}, + }; + + // When the merged list is selected + // Then cards from both feeds are in it + expect(nonPersonalAndWorkspaceCardListSelector({}, workspaceCardFeeds)).toMatchObject({ + '1': expect.objectContaining({cardID: 1}), + '2': expect.objectContaining({cardID: 2}), + }); + }); + + it('should return the same list while the merged content is unchanged and a new one when it changes', () => { + // Given a card list and a workspace feed collection + const cardList: CardList = {'1': createRandomExpensifyCard(1, {fundID: '123'})}; + const workspaceCardFeeds: OnyxCollection = {[workspaceFeedKey]: {'2': createRandomCompanyCard(2, {bank: 'vcf'})}}; + const first = nonPersonalAndWorkspaceCardListSelector(cardList, workspaceCardFeeds); + + // When the list is selected again from the same inputs, from a new card list that only adds a personal card, + // and from a new card list with another non-personal card + const fromSameInputs = nonPersonalAndWorkspaceCardListSelector(cardList, workspaceCardFeeds); + const afterPersonalCardAdded = nonPersonalAndWorkspaceCardListSelector({...cardList, '3': createRandomExpensifyCard(3, {fundID: '0'})}, workspaceCardFeeds); + const afterCardAdded = nonPersonalAndWorkspaceCardListSelector({...cardList, '4': createRandomExpensifyCard(4, {fundID: '123'})}, workspaceCardFeeds); + + // Then the first two reuse the same object, so consumers skip their selectors and re-renders, + // and only a real change gives a new list + expect(fromSameInputs).toBe(first); + expect(afterPersonalCardAdded).toBe(first); + expect(afterCardAdded).not.toBe(first); + expect(afterCardAdded['4']).toMatchObject({cardID: 4}); + }); +}); diff --git a/tests/unit/useNonPersonalAndWorkspaceCardListTest.tsx b/tests/unit/useNonPersonalAndWorkspaceCardListTest.tsx new file mode 100644 index 000000000000..ea0777fde16c --- /dev/null +++ b/tests/unit/useNonPersonalAndWorkspaceCardListTest.tsx @@ -0,0 +1,130 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import useNonPersonalAndWorkspaceCardList from '@hooks/useNonPersonalAndWorkspaceCardList'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import type {CardList} from '@src/types/onyx'; + +/* eslint-disable @typescript-eslint/naming-convention */ +import Onyx from 'react-native-onyx'; + +import {createRandomCompanyCard, createRandomExpensifyCard} from '../utils/collections/card'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +const WORKSPACE_FEED_KEY = `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}workspace_123` as const; + +describe('useNonPersonalAndWorkspaceCardList', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await act(async () => { + await Onyx.clear(); + }); + }); + + it('should follow writes to the card list and the workspace feeds', async () => { + // Given a non-personal card in the card list + await act(async () => { + await Onyx.set(ONYXKEYS.CARD_LIST, {'1': createRandomExpensifyCard(1, {fundID: '123'})}); + }); + const {result} = renderHook(() => useNonPersonalAndWorkspaceCardList()); + await waitForBatchedUpdatesWithAct(); + expect(Object.keys(result.current)).toEqual(['1']); + + // When a workspace feed with another card arrives + await act(async () => { + await Onyx.set(WORKSPACE_FEED_KEY, {'2': createRandomCompanyCard(2, {bank: 'vcf'})}); + }); + await waitForBatchedUpdatesWithAct(); + + // Then the hook returns both cards, since it reads its inputs live instead of a stored derived value + expect(Object.keys(result.current).sort()).toEqual(['1', '2']); + }); + + it('should not re-render when a write leaves the merged list unchanged', async () => { + // Given a non-personal card and a workspace feed card + await act(async () => { + await Onyx.set(ONYXKEYS.CARD_LIST, {'1': createRandomExpensifyCard(1, {fundID: '123'})}); + await Onyx.set(WORKSPACE_FEED_KEY, {'2': createRandomCompanyCard(2, {bank: 'vcf'})}); + }); + let renderCount = 0; + const {result} = renderHook(() => { + renderCount++; + return useNonPersonalAndWorkspaceCardList(); + }); + await waitForBatchedUpdatesWithAct(); + const listBeforeWrite = result.current; + const renderCountBeforeWrite = renderCount; + + // When a personal card is added, which this list filters out + await act(async () => { + await Onyx.merge(ONYXKEYS.CARD_LIST, {'3': createRandomExpensifyCard(3, {fundID: '0'})}); + }); + await waitForBatchedUpdatesWithAct(); + + // Then the consumer keeps the same list and does not re-render + expect(result.current).toBe(listBeforeWrite); + expect(renderCount).toBe(renderCountBeforeWrite); + }); + + it('should give every mounted consumer the same list', async () => { + // Given two consumers mounted over the same inputs + await act(async () => { + await Onyx.set(ONYXKEYS.CARD_LIST, {'1': createRandomExpensifyCard(1, {fundID: '123'})}); + }); + const {result: first} = renderHook(() => useNonPersonalAndWorkspaceCardList()); + const {result: second} = renderHook(() => useNonPersonalAndWorkspaceCardList()); + await waitForBatchedUpdatesWithAct(); + + // When a workspace feed card arrives + await act(async () => { + await Onyx.set(WORKSPACE_FEED_KEY, {'2': createRandomCompanyCard(2, {bank: 'vcf'})}); + }); + await waitForBatchedUpdatesWithAct(); + + // Then both get the same object, because the merge runs once per pair of inputs, not once per consumer + expect(second.current).toBe(first.current); + }); + + it('should apply the selector to the merged list', async () => { + // Given a non-personal card and a workspace feed card + await act(async () => { + await Onyx.set(ONYXKEYS.CARD_LIST, {'1': createRandomExpensifyCard(1, {fundID: '123'})}); + await Onyx.set(WORKSPACE_FEED_KEY, {'2': createRandomCompanyCard(2, {bank: 'vcf'})}); + }); + + // When a consumer selects only the card count + const {result} = renderHook(() => useNonPersonalAndWorkspaceCardList((cardList) => Object.keys(cardList).length)); + await waitForBatchedUpdatesWithAct(); + + // Then it gets the count of the merged list, so selector consumers see the same cards as everyone else + expect(result.current).toBe(2); + }); + + it('should run each selector once per merged list, however many consumers use it', async () => { + // Given two consumers mounted with the same selector + await act(async () => { + await Onyx.set(ONYXKEYS.CARD_LIST, {'1': createRandomExpensifyCard(1, {fundID: '123'})}); + }); + const countCards = jest.fn((cardList: CardList) => Object.keys(cardList).length); + renderHook(() => useNonPersonalAndWorkspaceCardList(countCards)); + renderHook(() => useNonPersonalAndWorkspaceCardList(countCards)); + await waitForBatchedUpdatesWithAct(); + countCards.mockClear(); + + // When a workspace feed card arrives, and then a personal card that leaves the merged list unchanged + await act(async () => { + await Onyx.set(WORKSPACE_FEED_KEY, {'2': createRandomCompanyCard(2, {bank: 'vcf'})}); + }); + await waitForBatchedUpdatesWithAct(); + await act(async () => { + await Onyx.merge(ONYXKEYS.CARD_LIST, {'3': createRandomExpensifyCard(3, {fundID: '0'})}); + }); + await waitForBatchedUpdatesWithAct(); + + // Then the selector ran once for the one new merged list, since a big card list makes every run cost a full scan + expect(countCards).toHaveBeenCalledTimes(1); + }); +}); From d2f7a497e66d1187c201f91e064d3d8e241b691d Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Thu, 8 Oct 2026 17:19:40 +0200 Subject: [PATCH 2/3] Add reassure test for the non-personal card list on card writes --- ...PersonalAndWorkspaceCardList.perf-test.tsx | 367 ++++++++++++++++++ 1 file changed, 367 insertions(+) create mode 100644 tests/perf-test/NonPersonalAndWorkspaceCardList.perf-test.tsx diff --git a/tests/perf-test/NonPersonalAndWorkspaceCardList.perf-test.tsx b/tests/perf-test/NonPersonalAndWorkspaceCardList.perf-test.tsx new file mode 100644 index 000000000000..97d12a05d51b --- /dev/null +++ b/tests/perf-test/NonPersonalAndWorkspaceCardList.perf-test.tsx @@ -0,0 +1,367 @@ +import {render, screen} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import MoneyRequestView from '@components/ReportActionItem/MoneyRequestView'; +import ScreenWrapperStatusContext from '@components/ScreenWrapper/ScreenWrapperStatusContext'; + +import useCardFeedsForDisplay from '@hooks/useCardFeedsForDisplay'; +import useSearchTypeMenuSections from '@hooks/useSearchTypeMenuSections'; + +import useHomeInsightConfigs from '@pages/home/InsightsSection/useHomeInsightConfigs'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Card, CardList, Policy, WorkspaceCardsList} from '@src/types/onyx'; + +import type * as Navigation from '@react-navigation/native'; + +import React from 'react'; +import {View} from 'react-native'; +import Onyx from 'react-native-onyx'; +import {measureAsyncFunction, measureRenders} from 'reassure'; + +import createMock from '../utils/createMock'; +import * as LHNTestUtils from '../utils/LHNTestUtils'; +import * as TestHelper from '../utils/TestHelper'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; +import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatchedUpdates'; + +jest.mock('@libs/Permissions'); +jest.mock('../../src/libs/Navigation/Navigation', () => ({ + navigate: jest.fn(), + isActiveRoute: jest.fn(), + getTopmostReportId: jest.fn(), + getActiveRoute: jest.fn(), + getTopmostReportActionId: jest.fn(), + isNavigationReady: jest.fn(() => Promise.resolve()), + isDisplayedInModal: jest.fn(() => false), + getActiveRouteWithoutParams: jest.fn(() => ''), +})); +jest.mock('../../src/libs/Navigation/navigationRef', () => ({ + getState: () => ({ + routes: [{name: 'Report'}], + }), + getRootState: () => ({ + routes: [], + }), + addListener: () => () => {}, + isReady: () => true, +})); +jest.mock('@react-navigation/native', () => { + const actualNav = jest.requireActual('@react-navigation/native'); + return { + ...actualNav, + useNavigationState: () => true, + useRoute: jest.fn(() => ({key: '', name: '', params: {reportID: '1'}})), + useFocusEffect: jest.fn(), + useIsFocused: () => true, + useNavigation: () => ({ + navigate: jest.fn(), + addListener: jest.fn(() => jest.fn()), + }), + createNavigationContainerRef: jest.fn(), + }; +}); +jest.mock('@components/ReportActionItem/MoneyRequestReceiptView', () => { + const RN = jest.requireActual>>('react-native'); + return () => ; +}); +jest.mock('@pages/inbox/report/AnimatedEmptyStateBackground', () => { + const RN = jest.requireActual>>('react-native'); + return () => ; +}); + +const CURRENT_USER_ACCOUNT_ID = 1; +const CURRENT_USER_EMAIL = 'email1@test.com'; +const POLICY_ID = 'policy_cards_perf'; +const WORKSPACE_ACCOUNT_ID = 777; +const EXPENSE_REPORT_ID = 'expense_cards_perf'; +const MONEY_REQUEST_VIEW_COUNT = 3; +const HOOK_CONSUMER_COUNT = 3; +const SMALL_CARD_COUNT = 20; +const LARGE_CARD_COUNT = 3000; +const WRITES_PER_SCENARIO = 10; +const FIRST_PERSONAL_CARD_ID = 900000; +const RENAMED_CARD_ID = 200001; + +const WORKSPACE_FEED_KEYS = [ + `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${WORKSPACE_ACCOUNT_ID}_${CONST.EXPENSIFY_CARD.BANK}`, + `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${WORKSPACE_ACCOUNT_ID}_${CONST.COMPANY_CARD.FEED_BANK_NAME.VISA}`, +] as const; + +function createCard(cardID: number, bank: Card['bank'], fundID: string | undefined): Card { + return createMock({ + cardID, + bank, + fundID, + state: CONST.EXPENSIFY_CARD.STATE.OPEN, + lastFourPAN: String(1000 + (cardID % 9000)), + cardName: `Card ${cardID}`, + accountID: CURRENT_USER_ACCOUNT_ID, + domainName: 'expensify-policy.exfy', + }); +} + +function buildCards(count: number) { + const cardList: CardList = {}; + const expensifyFeed: WorkspaceCardsList = {}; + const companyFeed: WorkspaceCardsList = {}; + for (let index = 0; index < count; index++) { + const userCardID = index + 1; + cardList[userCardID] = createCard(userCardID, CONST.EXPENSIFY_CARD.BANK, String(5000 + (index % 3))); + const expensifyCardID = 100000 + index; + expensifyFeed[expensifyCardID] = createCard(expensifyCardID, CONST.EXPENSIFY_CARD.BANK, '5000'); + const companyCardID = 200000 + index; + companyFeed[companyCardID] = createCard(companyCardID, CONST.COMPANY_CARD.FEED_BANK_NAME.VISA, undefined); + } + return {cardList, workspaceFeeds: [expensifyFeed, companyFeed]}; +} + +const smallCards = buildCards(SMALL_CARD_COUNT); +const largeCards = buildCards(LARGE_CARD_COUNT); + +const policy = createMock({ + id: POLICY_ID, + type: CONST.POLICY.TYPE.TEAM, + role: CONST.POLICY.ROLE.ADMIN, + name: 'Cards Policy', + owner: CURRENT_USER_EMAIL, + outputCurrency: CONST.CURRENCY.USD, +}); + +const SCREEN_WRAPPER_STATUS = {didScreenTransitionEnd: true, shouldUseNarrowLayoutOnWideRHP: false, isSafeAreaTopPaddingApplied: true, isSafeAreaBottomPaddingApplied: true}; + +function TypeMenuSectionsConsumer() { + useSearchTypeMenuSections(); + return ; +} + +function CardFeedsConsumer() { + useCardFeedsForDisplay(); + return ; +} + +function InsightConfigsConsumer() { + useHomeInsightConfigs(); + return ; +} + +/** Search and Home surfaces that read the card list through selectors, without the report attributes, so their re-renders isolate card list churn */ +function SearchCardConsumers() { + return ( + <> + {Array.from({length: HOOK_CONSUMER_COUNT}, (value, index) => ( + + + + + + ))} + + ); +} + +/** Expense views that read the whole merged card list next to the Search surfaces, as when an expense is open over Search */ +function AllCardConsumers() { + return ( + + + {Array.from({length: MONEY_REQUEST_VIEW_COUNT}, (value, index) => ( + + ))} + + + + ); +} + +async function seed(cards: ReturnType) { + await Onyx.multiSet({ + [ONYXKEYS.CARD_LIST]: cards.cardList, + [WORKSPACE_FEED_KEYS[0]]: cards.workspaceFeeds.at(0), + [WORKSPACE_FEED_KEYS[1]]: cards.workspaceFeeds.at(1), + [`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}` as const]: policy, + [`${ONYXKEYS.COLLECTION.REPORT}${EXPENSE_REPORT_ID}` as const]: { + reportID: EXPENSE_REPORT_ID, + type: CONST.REPORT.TYPE.EXPENSE, + policyID: POLICY_ID, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + managerID: CURRENT_USER_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${EXPENSE_REPORT_ID}` as const]: { + // eslint-disable-next-line @typescript-eslint/naming-convention + parent_action: { + ...LHNTestUtils.getFakeReportAction(), + reportActionID: 'parent_action', + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + actorAccountID: CURRENT_USER_ACCOUNT_ID, + originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, IOUTransactionID: 'txn_cards_perf', amount: 5000, currency: CONST.CURRENCY.USD}, + }, + }, + [`${ONYXKEYS.COLLECTION.TRANSACTION}txn_cards_perf` as const]: { + transactionID: 'txn_cards_perf', + reportID: EXPENSE_REPORT_ID, + amount: 5000, + currency: CONST.CURRENCY.USD, + created: '2026-06-01', + merchant: 'Card merchant', + cardID: RENAMED_CARD_ID, + comment: {}, + }, + }); + await waitForBatchedUpdates(); +} + +/** Writes to a workspace feed's list of cards still to assign, which are not cards, so the merged list keeps its content */ +async function writeUnassignedCards(label: string) { + for (let index = 0; index < WRITES_PER_SCENARIO; index++) { + await Onyx.merge(WORKSPACE_FEED_KEYS[1], {cardList: {[`account ${label}-${index}`]: 'encrypted'}}); + await waitForBatchedUpdates(); + } +} + +describe('Non-personal and workspace card list consumers on card writes', () => { + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + evictableKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS], + }); + // Required lazily so the navigation mocks above are registered before the engine's imports load it + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + require('@userActions/OnyxDerived').default(); + }); + + beforeEach(async () => { + global.fetch = TestHelper.getGlobalFetchMock(); + wrapOnyxWithWaitForBatchedUpdates(Onyx); + await TestHelper.signInWithTestUser(CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, undefined, 'One'); + await waitForBatchedUpdates(); + }); + + afterEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + test('[NonPersonalCardList] should re-render consumers when unassigned workspace cards change', async () => { + // Given expense views and Search surfaces mounted over a small card list + await seed(smallCards); + + // Reassure runs the scenario several times against one seeded Onyx, so every run writes keys that don't exist yet + let run = 0; + const scenario = async () => { + await screen.findAllByTestId('card-feeds-consumer'); + + // When the unassigned cards of a workspace feed change repeatedly, which leaves the merged list unchanged + await writeUnassignedCards(`small ${run}`); + run++; + }; + + // Then reassure measures what the unchanged merges cost the consumers + await measureRenders(, {scenario}); + }); + + test('[NonPersonalCardList] should not re-render Search surfaces when a personal card changes', async () => { + // Given only the Search and Home surfaces, which read the merged list through selectors and nothing else of the cards + await seed(smallCards); + + let run = 0; + const scenario = async () => { + await screen.findAllByTestId('card-feeds-consumer'); + + // When personal cards are added to the card list, which the merged list filters out + for (let index = 0; index < WRITES_PER_SCENARIO; index++) { + const cardID = FIRST_PERSONAL_CARD_ID + run * WRITES_PER_SCENARIO + index; + await Onyx.merge(ONYXKEYS.CARD_LIST, {[cardID]: createCard(cardID, CONST.COMPANY_CARD.FEED_BANK_NAME.VISA, '0')}); + await waitForBatchedUpdates(); + } + run++; + }; + + // Then every render here comes from the merged list or a selected value changing reference without changing content + await measureRenders(, {scenario}); + }); + + test('[NonPersonalCardList] should re-render consumers when a company card is renamed', async () => { + // Given expense views and Search surfaces mounted over a small card list + await seed(smallCards); + + let run = 0; + const scenario = async () => { + await screen.findAllByTestId('card-feeds-consumer'); + + // When a workspace company card is renamed on every write, so the merged list really changes + for (let index = 0; index < WRITES_PER_SCENARIO; index++) { + await Onyx.merge(WORKSPACE_FEED_KEYS[1], {[RENAMED_CARD_ID]: {cardName: `Renamed ${run}-${index}`}}); + await waitForBatchedUpdates(); + } + run++; + }; + + // Then reassure measures the cost of a real change, which both versions have to pay + await measureRenders(, {scenario}); + }); + + test('[NonPersonalCardList] should re-render consumers when unassigned workspace cards change in a large card list', async () => { + // Given the same consumers over 9000 cards + await seed(largeCards); + + let run = 0; + const scenario = async () => { + await screen.findAllByTestId('card-feeds-consumer'); + + // When the unassigned cards of a workspace feed change repeatedly, so every write merges the whole list again + await writeUnassignedCards(`large ${run}`); + run++; + }; + + // Then reassure measures the render cost, which grows if each consumer merges the list during render + await measureRenders(, {scenario}); + }); + + test('[NonPersonalCardList] should process unassigned workspace card writes in a large card list', async () => { + // Given the consumers mounted over 9000 cards + await seed(largeCards); + render(); + await screen.findAllByTestId('card-feeds-consumer'); + + // When the unassigned cards of a workspace feed change repeatedly + let run = 0; + const writeLoop = async () => { + await writeUnassignedCards(`timed ${run}`); + run++; + }; + + // Then reassure times the whole write loop, including work done outside React renders such as derived computes and their writes + await measureAsyncFunction(writeLoop); + }); + + test('[NonPersonalCardList] should process company card renames in a large card list', async () => { + // Given the consumers mounted over 9000 cards + await seed(largeCards); + render(); + await screen.findAllByTestId('card-feeds-consumer'); + + // When a workspace company card is renamed on every write + let run = 0; + const writeLoop = async () => { + for (let index = 0; index < WRITES_PER_SCENARIO; index++) { + await Onyx.merge(WORKSPACE_FEED_KEYS[1], {[RENAMED_CARD_ID]: {cardName: `Timed rename ${run}-${index}`}}); + await waitForBatchedUpdates(); + } + run++; + }; + + // Then reassure times the loop when both versions must merge and deliver a new list + await measureAsyncFunction(writeLoop); + }); +}); From 9ef3c3a699283cfcdfa3c3b4af17467a851c6298 Mon Sep 17 00:00:00 2001 From: truph01 Date: Thu, 15 Oct 2026 02:24:34 +0700 Subject: [PATCH 3/3] Show empty state instead of No results when default view hides all rows --- src/components/Table/TableBody.tsx | 7 +++- .../TableEmptyStates/TableNoResultsState.tsx | 5 ++- tests/ui/TableTest.tsx | 38 +++++++++++++++++++ 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/src/components/Table/TableBody.tsx b/src/components/Table/TableBody.tsx index 9a4db2f7aead..faa96b4da20f 100644 --- a/src/components/Table/TableBody.tsx +++ b/src/components/Table/TableBody.tsx @@ -164,6 +164,7 @@ function TableBodyList({contentContainerStyle, emptyMessage, onLayout, style, .. noResultsStateElement, tableListMetadata, isEmptyResult, + isDefaultViewEmpty, scrollWidth, tableWidth, } = useTableContext(); @@ -337,8 +338,10 @@ function TableBodyList({contentContainerStyle, emptyMessage, onLayout, style, .. ); - const emptyStateContent = - tableListMetadata.hasPageHeader && isEmptyResult ? (noResultsStateElement ?? EmptyResultComponent) : (emptyStateElement ?? renderListComponent(ListEmptyComponent)); + // When the default view hides every row (e.g. all workspaces are archived), no search or filter is active, + // so show the empty state rather than "No results". + const shouldShowNoResultsState = tableListMetadata.hasPageHeader && isEmptyResult && !isDefaultViewEmpty; + const emptyStateContent = shouldShowNoResultsState ? (noResultsStateElement ?? EmptyResultComponent) : (emptyStateElement ?? renderListComponent(ListEmptyComponent)); const footerElement = renderListComponent(ListFooterComponent); // Consumer footer flex growth is useful below normal rows, but inside the combined empty-state // footer it can expand over the page header. Preserve the remaining style while disabling growth. diff --git a/src/components/Table/TableEmptyStates/TableNoResultsState.tsx b/src/components/Table/TableEmptyStates/TableNoResultsState.tsx index 60a93ca4a2c6..30c49a66fe35 100644 --- a/src/components/Table/TableEmptyStates/TableNoResultsState.tsx +++ b/src/components/Table/TableEmptyStates/TableNoResultsState.tsx @@ -21,10 +21,11 @@ type TableNoResultsStateProps = Omit { expect(screen.queryByTestId('row-2')).toBeNull(); expect(screen.getByTestId('generic-empty-state')).toBeTruthy(); }); + + it('renders the empty state instead of "No results" when a page-header table hides every row by default', () => { + // Given a page-header table (like the Workspaces list) whose default filter hides every row, with both + // a no-results state and an empty state declared + const props = createDefaultProps(); + const data: TestItem[] = [ + {keyForList: '1', id: '1', name: 'Archived A', category: 'archived', value: 1}, + {keyForList: '2', id: '2', name: 'Archived B', category: 'archived', value: 2}, + ]; + + // When the table renders with no search or filter selected + render( + + data={data} + columns={props.columns} + renderItem={props.renderItem} + keyExtractor={props.keyExtractor} + filters={statusFilterConfig} + isItemInFilter={isItemInStatusFilter} + > + + Page header + + + + + + , + ); + + // Then the empty state shows inside the list with the page header still visible, because the user did + // not search or filter, so "No results found" would be misleading + expect(screen.getByTestId('flash-list')).toBeTruthy(); + expect(screen.getByTestId('table-header-component')).toBeTruthy(); + expect(screen.getAllByTestId('generic-empty-state')).toHaveLength(1); + expect(screen.getByText('No workspaces')).toBeTruthy(); + expect(screen.queryByText('common.noResultsFound')).toBeNull(); + }); }); describe('immediate filter', () => {