From 245c7f037dd1dea632b83b0ab344f28979d602dd Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Thu, 8 Oct 2026 17:24:34 +0200 Subject: [PATCH 1/3] Replace OUTSTANDING_REPORTS_BY_POLICY_ID derived value with a memoized useOnyxState hook --- __mocks__/react-native-onyx.ts | 4 +- config/eslint/eslint.seatbelt.tsv | 1 - src/ONYXKEYS.ts | 2 - .../MoneyReportHeaderSecondaryActions.tsx | 3 +- .../MoneyReportHeaderSelectionDropdown.tsx | 3 +- .../sections/ReportField.tsx | 4 +- .../MoneyRequestHeaderSecondaryActions.tsx | 3 +- .../ReportActionItem/MoneyRequestView.tsx | 3 +- .../Search/SearchWriteActionsProvider.tsx | 10 +- src/components/Search/selectionBuilders.ts | 11 +- src/hooks/useExpenseActions.ts | 3 +- ...useIsApproverOfOutstandingPolicyReports.ts | 10 +- src/hooks/useOutstandingReports.ts | 3 +- src/hooks/useOutstandingReportsByPolicyID.ts | 29 ++++ src/hooks/useSelectedTransactionsActions.ts | 3 +- src/hooks/useSelectionModeReportActions.ts | 3 +- src/libs/ReportSecondaryActionUtils.ts | 19 +-- src/libs/ReportUtils.ts | 6 +- .../OnyxDerived/ONYX_DERIVED_VALUES.ts | 2 - .../configs/outstandingReportsByPolicyID.ts | 43 ------ src/pages/workspace/WorkspaceMembersPage.tsx | 5 +- .../members/WorkspaceMemberDetailsPage.tsx | 4 +- src/selectors/Report.ts | 81 ++++++++-- src/types/onyx/DerivedValues.ts | 8 - src/types/onyx/index.ts | 2 - tests/unit/ReportSelectorTest.ts | 126 +++++++++++++++- tests/unit/canEditFieldOfMoneyRequestTest.ts | 17 ++- .../useOutstandingReportsByPolicyIDTest.tsx | 140 ++++++++++++++++++ 28 files changed, 421 insertions(+), 127 deletions(-) create mode 100644 src/hooks/useOutstandingReportsByPolicyID.ts delete mode 100644 src/libs/actions/OnyxDerived/configs/outstandingReportsByPolicyID.ts create mode 100644 tests/unit/hooks/useOutstandingReportsByPolicyIDTest.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/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index 82b9285649ee..60bd60b2f6ff 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -1844,7 +1844,6 @@ "../../tests/ui/UnreadIndicatorsTest.tsx" "@typescript-eslint/no-restricted-imports" 1 "../../tests/ui/WorkspaceMoreFeaturesPageTest.tsx" "no-restricted-imports" 1 "../../tests/unit/canEditFieldOfMoneyRequestTest.ts" "@typescript-eslint/no-deprecated/randomReportAction.originalMessage" 3 -"../../tests/unit/canEditFieldOfMoneyRequestTest.ts" "@typescript-eslint/no-restricted-imports" 1 "../../tests/unit/components/HTMLEngineProvider/VictoryChartAttributesParsers.test.ts" "rulesdir/no-raw-typography" 1 "../../tests/unit/FormulaTest.ts" "no-restricted-imports" 1 "../../tests/unit/HomePage/YourSpendSection/useYourSpendDataTest.ts" "no-restricted-imports" 1 diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index d2b16572d106..717968cbe3fb 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -1404,7 +1404,6 @@ const ONYXKEYS = { DERIVED: { REPORT_ATTRIBUTES: 'reportAttributes', 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', @@ -1975,7 +1974,6 @@ type OnyxValuesMapping = { type OnyxDerivedValuesMapping = { [ONYXKEYS.DERIVED.REPORT_ATTRIBUTES]: OnyxTypes.ReportAttributesDerivedValue; [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; diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx index 9ee62eade7e4..37e258b8bfe3 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx @@ -25,6 +25,7 @@ import useLifecycleActions from '@hooks/useLifecycleActions'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; +import useOutstandingReportsByPolicyID from '@hooks/useOutstandingReportsByPolicyID'; import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; import useParticipantsInvoiceReport from '@hooks/useParticipantsInvoiceReport'; import usePayChatReportActions from '@hooks/usePayChatReportActions'; @@ -110,7 +111,7 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo const [submitterLogin] = usePersonalDetail(moneyRequestReport?.ownerAccountID, loginSelector); const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); const [reportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${moneyRequestReport?.reportID}`); - const [outstandingReportsByPolicyID] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID); + const outstandingReportsByPolicyID = useOutstandingReportsByPolicyID(); const [moveExpenseReportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, { selector: createMoveExpenseReportNVPSelector(outstandingReportsByPolicyID, moneyRequestReport?.reportID), }); diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx index 312e84d68a57..bf4d57377424 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx @@ -18,6 +18,7 @@ import useLifecycleActions from '@hooks/useLifecycleActions'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; +import useOutstandingReportsByPolicyID from '@hooks/useOutstandingReportsByPolicyID'; import {usePersonalDetail} from '@hooks/usePersonalDetails'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useSelectedTransactionsActions from '@hooks/useSelectedTransactionsActions'; @@ -81,7 +82,7 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn const [cardList] = useOnyx(ONYXKEYS.CARD_LIST); const [reportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${getNonEmptyStringOnyxID(moneyRequestReport?.reportID)}`); const [dismissedRejectUseExplanation] = useOnyx(ONYXKEYS.NVP_DISMISSED_REJECT_USE_EXPLANATION); - const [outstandingReportsByPolicyID] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID); + const outstandingReportsByPolicyID = useOutstandingReportsByPolicyID(); const [moveExpenseReportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, { selector: createMoveExpenseReportNVPSelector(outstandingReportsByPolicyID, moneyRequestReport?.reportID), }); diff --git a/src/components/MoneyRequestConfirmationList/sections/ReportField.tsx b/src/components/MoneyRequestConfirmationList/sections/ReportField.tsx index 941d0e3a8331..5c61e8f4d9fe 100644 --- a/src/components/MoneyRequestConfirmationList/sections/ReportField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/ReportField.tsx @@ -3,6 +3,7 @@ import MenuItemFieldHTML from '@components/MenuItem/presets/MenuItemFieldHTML'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useOutstandingReports from '@hooks/useOutstandingReports'; +import {useOutstandingReportsForPolicy} from '@hooks/useOutstandingReportsByPolicyID'; import {useDerivedReportNameByReportID} from '@hooks/useReportAttributes'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; @@ -20,7 +21,6 @@ import type {Participant} from '@src/types/onyx/IOU'; import type {OnyxEntry} from 'react-native-onyx'; -import {createOutstandingReportsForPolicySelector} from '@selectors/Report'; import React from 'react'; import ExpenseFieldRow from './ExpenseFieldRow'; @@ -47,7 +47,7 @@ function ReportField({selectedParticipants, iouType, reportID, reportActionID, a const {translate, localeCompare} = useLocalize(); const policyID = selectedParticipants?.at(0)?.policyID; - const [outstandingReportsForPolicy] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID, {selector: createOutstandingReportsForPolicySelector(policyID)}); + const outstandingReportsForPolicy = useOutstandingReportsForPolicy(policyID); const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); diff --git a/src/components/MoneyRequestHeaderSecondaryActions.tsx b/src/components/MoneyRequestHeaderSecondaryActions.tsx index f4375322be1f..65754d58151f 100644 --- a/src/components/MoneyRequestHeaderSecondaryActions.tsx +++ b/src/components/MoneyRequestHeaderSecondaryActions.tsx @@ -15,6 +15,7 @@ import useLocalize from '@hooks/useLocalize'; import useMoneyRequestPolicyTagsForReport from '@hooks/useMoneyRequestPolicyTagsForReport'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; +import useOutstandingReportsByPolicyID from '@hooks/useOutstandingReportsByPolicyID'; import useParticipantsPolicyTags from '@hooks/useParticipantsPolicyTags'; import usePermissions from '@hooks/usePermissions'; import {usePersonalDetail} from '@hooks/usePersonalDetails'; @@ -173,7 +174,7 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money const [policyRecentlyUsedCurrencies] = useOnyx(ONYXKEYS.RECENTLY_USED_CURRENCIES); const [dismissedRejectUseExplanation] = useOnyx(ONYXKEYS.NVP_DISMISSED_REJECT_USE_EXPLANATION); const [dismissedHoldUseExplanation] = useOnyx(ONYXKEYS.NVP_DISMISSED_HOLD_USE_EXPLANATION); - const [outstandingReportsByPolicyID] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID); + const outstandingReportsByPolicyID = useOutstandingReportsByPolicyID(); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [recentWaypoints] = useOnyx(ONYXKEYS.NVP_RECENT_WAYPOINTS); const [shouldFailAllRequests] = useOnyx(ONYXKEYS.NETWORK, {selector: shouldFailAllRequestsSelector}); diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 3fff41e1f9d3..d8d1cbab872e 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -32,6 +32,7 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; +import useOutstandingReportsByPolicyID from '@hooks/useOutstandingReportsByPolicyID'; import usePermissions from '@hooks/usePermissions'; import {usePersonalDetail} from '@hooks/usePersonalDetails'; import usePersonalPolicy from '@hooks/usePersonalPolicy'; @@ -287,7 +288,7 @@ function MoneyRequestView({ const [transactionBackup] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${getNonEmptyStringOnyxID(linkedTransactionID)}`); const transactionViolations = useTransactionViolations(transaction?.transactionID, false, distanceOriginalPolicy ?? policy); const allTransactionViolations = useAllTransactionViolations(transaction?.transactionID); - const [outstandingReportsByPolicyID] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID); + const outstandingReportsByPolicyID = useOutstandingReportsByPolicyID(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const delegateAccountID = useDelegateAccountID(); const personalDetailsList = usePersonalDetails(); diff --git a/src/components/Search/SearchWriteActionsProvider.tsx b/src/components/Search/SearchWriteActionsProvider.tsx index 7c282c7b0605..130a78758100 100644 --- a/src/components/Search/SearchWriteActionsProvider.tsx +++ b/src/components/Search/SearchWriteActionsProvider.tsx @@ -1,6 +1,7 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; +import useOutstandingReportsByPolicyID from '@hooks/useOutstandingReportsByPolicyID'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSelfDMReport from '@hooks/useSelfDMReport'; import useShiftRangeSelection from '@hooks/useShiftRangeSelection'; @@ -21,10 +22,11 @@ import {isTransactionPendingDelete} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {OutstandingReportsByPolicyIDDerivedValue, Report, ReportNameValuePairs, SearchResults, Transaction} from '@src/types/onyx'; +import type {Report, ReportNameValuePairs, SearchResults, Transaction} from '@src/types/onyx'; import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; import {getEmptyObject, isEmptyObject} from '@src/types/utils/EmptyObject'; +import type {OutstandingReportsByPolicyID} from '@selectors/Report'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {useIsFocused} from '@react-navigation/native'; @@ -120,8 +122,8 @@ type ReconcileSelectionParams = { /** Report name-value pairs collection, used for the change-report eligibility archived check */ reportNameValuePairs: OnyxCollection; - /** Derived outstanding reports per policy, used for the change-report eligibility check */ - outstandingReportsByPolicyID: OutstandingReportsByPolicyIDDerivedValue | undefined; + /** Outstanding reports per policy, used for the change-report eligibility check */ + outstandingReportsByPolicyID: OutstandingReportsByPolicyID | undefined; /** Whether the current snapshot is settled and can safely refresh/prune exclusions */ shouldReconcileExcludedTransactions: boolean; @@ -535,7 +537,7 @@ function SearchWriteActionsProvider({ const {accountID, email, login} = useCurrentUserPersonalDetails(); const selfDMReport = useSelfDMReport(); const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); - const [outstandingReportsByPolicyID] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID); + const outstandingReportsByPolicyID = useOutstandingReportsByPolicyID(); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const {applySelection, getSelectedTransactions, getExcludedTransactions, getAreAllMatchingItemsSelected} = useSearchSelectionActions(); diff --git a/src/components/Search/selectionBuilders.ts b/src/components/Search/selectionBuilders.ts index 2438c60de9df..d452877dd987 100644 --- a/src/components/Search/selectionBuilders.ts +++ b/src/components/Search/selectionBuilders.ts @@ -5,9 +5,10 @@ import type {ShiftRangeBatch} from '@libs/shiftRangeSelection'; import {getOriginalTransactionWithSplitInfo, hasValidModifiedAmount, isExpenseUnreported, isOnHold, isTransactionPendingDelete} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; -import type {OutstandingReportsByPolicyIDDerivedValue, Report, ReportNameValuePairs, Rule, Transaction} from '@src/types/onyx'; +import type {Report, ReportNameValuePairs, Rule, Transaction} from '@src/types/onyx'; import type {SearchGroupBase, SearchResultDataType} from '@src/types/onyx/SearchResults'; +import type {OutstandingReportsByPolicyID} from '@selectors/Report'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {deepEqual} from 'fast-equals'; @@ -119,8 +120,8 @@ type MapTransactionItemToSelectedEntryParams = { /** Report name-value pairs collection, used for the change-report eligibility archived check */ reportNameValuePairs: OnyxCollection; - /** Derived outstanding reports per policy, used for the change-report eligibility check */ - outstandingReportsByPolicyID: OutstandingReportsByPolicyIDDerivedValue | undefined; + /** Outstanding reports per policy, used for the change-report eligibility check */ + outstandingReportsByPolicyID: OutstandingReportsByPolicyID | undefined; /** The current user's self-DM report, used as the parent for unreported (track) expenses */ selfDMReport: OnyxEntry; @@ -272,8 +273,8 @@ type PrepareTransactionsListParams = { /** Report name-value pairs collection, used for the change-report eligibility archived check */ reportNameValuePairs: OnyxCollection; - /** Derived outstanding reports per policy, used for the change-report eligibility check */ - outstandingReportsByPolicyID: OutstandingReportsByPolicyIDDerivedValue | undefined; + /** Outstanding reports per policy, used for the change-report eligibility check */ + outstandingReportsByPolicyID: OutstandingReportsByPolicyID | undefined; /** The current user's self-DM report, used as the parent for unreported (track) expenses */ selfDMReport: OnyxEntry; diff --git a/src/hooks/useExpenseActions.ts b/src/hooks/useExpenseActions.ts index fde6f28bd86e..4a93204aadcf 100644 --- a/src/hooks/useExpenseActions.ts +++ b/src/hooks/useExpenseActions.ts @@ -70,6 +70,7 @@ import {useMemoizedLazyExpensifyIcons} from './useLazyAsset'; import useLocalize from './useLocalize'; import useMoneyRequestPolicyTagsForReport from './useMoneyRequestPolicyTagsForReport'; import useOnyx from './useOnyx'; +import useOutstandingReportsByPolicyID from './useOutstandingReportsByPolicyID'; import useParentReportAction from './useParentReportAction'; import useParticipantsPolicyTags, {getPolicyTagsSelector} from './useParticipantsPolicyTags'; import usePermissions from './usePermissions'; @@ -154,7 +155,7 @@ function useExpenseActions({reportID, isReportInSearch = false, backTo, onDuplic const [policyRecentlyUsedCurrencies] = useOnyx(ONYXKEYS.RECENTLY_USED_CURRENCIES); const [selfDMReportID] = useOnyx(ONYXKEYS.SELF_DM_REPORT_ID); const [selfDMReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReportID}`); - const [outstandingReportsByPolicyID] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID); + const outstandingReportsByPolicyID = useOutstandingReportsByPolicyID(); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); diff --git a/src/hooks/useIsApproverOfOutstandingPolicyReports.ts b/src/hooks/useIsApproverOfOutstandingPolicyReports.ts index b64a41db6974..688c1b6dd585 100644 --- a/src/hooks/useIsApproverOfOutstandingPolicyReports.ts +++ b/src/hooks/useIsApproverOfOutstandingPolicyReports.ts @@ -1,10 +1,7 @@ import {isApproverOfOutstandingPolicyReports} from '@libs/ReportUtils'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; - import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; -import useOnyx from './useOnyx'; +import {useOutstandingReportsForPolicy} from './useOutstandingReportsByPolicyID'; import usePrivateIsArchivedMap from './usePrivateIsArchivedMap'; /** @@ -13,11 +10,10 @@ import usePrivateIsArchivedMap from './usePrivateIsArchivedMap'; */ function useIsApproverOfOutstandingPolicyReports(policyID: string | undefined): boolean { const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - // Subscribing without a selector keeps the change detection a reference compare, and the reports are reduced to a boolean below. - const [outstandingReportsByPolicyID] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID); + const outstandingReportsForPolicy = useOutstandingReportsForPolicy(policyID); const privateIsArchivedMap = usePrivateIsArchivedMap(); - return isApproverOfOutstandingPolicyReports(currentUserPersonalDetails.accountID, outstandingReportsByPolicyID?.[policyID ?? CONST.DEFAULT_NUMBER_ID], privateIsArchivedMap); + return isApproverOfOutstandingPolicyReports(currentUserPersonalDetails.accountID, outstandingReportsForPolicy, privateIsArchivedMap); } export default useIsApproverOfOutstandingPolicyReports; diff --git a/src/hooks/useOutstandingReports.ts b/src/hooks/useOutstandingReports.ts index 6910c702fe8e..bc0ff302ee6b 100644 --- a/src/hooks/useOutstandingReports.ts +++ b/src/hooks/useOutstandingReports.ts @@ -10,11 +10,12 @@ import type {OnyxEntry} from 'react-native-onyx'; import useMappedPolicies from './useMappedPolicies'; import useOnyx from './useOnyx'; +import useOutstandingReportsByPolicyID from './useOutstandingReportsByPolicyID'; const policyIdMapper = (policy: OnyxEntry) => policy?.id; export default function useOutstandingReports(selectedReportID: string | undefined, selectedPolicyID: string | undefined, ownerAccountID: number | undefined, isEditing: boolean) { - const [outstandingReportsByPolicyID] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID); + const outstandingReportsByPolicyID = useOutstandingReportsByPolicyID(); const [personalPolicyID] = useOnyx(ONYXKEYS.PERSONAL_POLICY_ID); const [allPoliciesID] = useMappedPolicies(policyIdMapper); const [selectedReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${selectedReportID}`); diff --git a/src/hooks/useOutstandingReportsByPolicyID.ts b/src/hooks/useOutstandingReportsByPolicyID.ts new file mode 100644 index 000000000000..a74ccb12efa8 --- /dev/null +++ b/src/hooks/useOutstandingReportsByPolicyID.ts @@ -0,0 +1,29 @@ +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report} from '@src/types/onyx'; + +import type {OutstandingReportsByPolicyID} from '@selectors/Report'; +import type {OnyxCollection} from 'react-native-onyx'; + +import {outstandingReportsByPolicyIDSelector} from '@selectors/Report'; +// useOnyxState reads the store directly, so Search surfaces get the live reports instead of the snapshot @hooks/useOnyx swaps in +import {useOnyxState} from 'react-native-onyx'; + +const dependencies = [ONYXKEYS.COLLECTION.REPORT]; + +function selectOutstandingReportsByPolicyID(reports: unknown): OutstandingReportsByPolicyID { + // 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 + return outstandingReportsByPolicyIDSelector(reports as OnyxCollection); +} + +function useOutstandingReportsByPolicyID(): OutstandingReportsByPolicyID { + return useOnyxState((state) => selectOutstandingReportsByPolicyID(state[ONYXKEYS.COLLECTION.REPORT]), {dependencies}); +} + +function useOutstandingReportsForPolicy(policyID: string | undefined): OnyxCollection { + return useOnyxState((state) => selectOutstandingReportsByPolicyID(state[ONYXKEYS.COLLECTION.REPORT])[policyID ?? CONST.DEFAULT_NUMBER_ID], {dependencies}); +} + +export {useOutstandingReportsForPolicy}; +export default useOutstandingReportsByPolicyID; diff --git a/src/hooks/useSelectedTransactionsActions.ts b/src/hooks/useSelectedTransactionsActions.ts index 2281a62f907c..7cc46a04a800 100644 --- a/src/hooks/useSelectedTransactionsActions.ts +++ b/src/hooks/useSelectedTransactionsActions.ts @@ -54,6 +54,7 @@ import {useMemoizedLazyExpensifyIcons} from './useLazyAsset'; import useLocalize from './useLocalize'; import useNetworkWithOfflineStatus from './useNetworkWithOfflineStatus'; import useOnyx from './useOnyx'; +import useOutstandingReportsByPolicyID from './useOutstandingReportsByPolicyID'; import usePermissions from './usePermissions'; import {useAllPersonalDetails} from './usePersonalDetails'; import usePersonalPolicy from './usePersonalPolicy'; @@ -99,7 +100,7 @@ function useSelectedTransactionsActions({ const [allReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [cardList] = useOnyx(ONYXKEYS.CARD_LIST); - const [outstandingReportsByPolicyID] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID); + const outstandingReportsByPolicyID = useOutstandingReportsByPolicyID(); const [integrationsExportTemplates] = useOnyx(ONYXKEYS.NVP_INTEGRATION_SERVER_EXPORT_TEMPLATES); const [csvExportLayouts] = useOnyx(ONYXKEYS.NVP_CSV_EXPORT_LAYOUTS); const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); diff --git a/src/hooks/useSelectionModeReportActions.ts b/src/hooks/useSelectionModeReportActions.ts index 46aca1bd577f..2b9dfef528d4 100644 --- a/src/hooks/useSelectionModeReportActions.ts +++ b/src/hooks/useSelectionModeReportActions.ts @@ -32,6 +32,7 @@ import useLifecycleActions from './useLifecycleActions'; import useLocalize from './useLocalize'; import useNetwork from './useNetwork'; import useOnyx from './useOnyx'; +import useOutstandingReportsByPolicyID from './useOutstandingReportsByPolicyID'; import {usePersonalDetail} from './usePersonalDetails'; import useReportIsArchived from './useReportIsArchived'; import useSelectionModePayment from './useSelectionModePayment'; @@ -66,7 +67,7 @@ function useSelectionModeReportActions({ const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); - const [outstandingReportsByPolicyID] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID); + const outstandingReportsByPolicyID = useOutstandingReportsByPolicyID(); const [moveExpenseReportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, { selector: createMoveExpenseReportNVPSelector(outstandingReportsByPolicyID, report?.reportID), }); diff --git a/src/libs/ReportSecondaryActionUtils.ts b/src/libs/ReportSecondaryActionUtils.ts index 8bc716e0f830..52b430878cd7 100644 --- a/src/libs/ReportSecondaryActionUtils.ts +++ b/src/libs/ReportSecondaryActionUtils.ts @@ -1,19 +1,8 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type { - BankAccountList, - CardList, - OutstandingReportsByPolicyIDDerivedValue, - Policy, - Report, - ReportAction, - ReportMetadata, - ReportNameValuePairs, - Rule, - Transaction, - TransactionViolation, -} from '@src/types/onyx'; +import type {BankAccountList, CardList, Policy, Report, ReportAction, ReportMetadata, ReportNameValuePairs, Rule, Transaction, TransactionViolation} from '@src/types/onyx'; +import type {OutstandingReportsByPolicyID} from '@selectors/Report'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; @@ -1031,7 +1020,7 @@ function getSecondaryReportActions({ reportActions?: ReportAction[]; reportMetadata?: OnyxEntry; policies?: OnyxCollection; - outstandingReportsByPolicyID?: OutstandingReportsByPolicyIDDerivedValue; + outstandingReportsByPolicyID?: OutstandingReportsByPolicyID; canUseNewDotSplits?: boolean; isChatReportArchived?: boolean; parentReport?: OnyxEntry; @@ -1270,7 +1259,7 @@ function getSecondaryTransactionThreadActions({ originalTransaction: OnyxEntry; policy: OnyxEntry; transactionThreadReport?: OnyxEntry; - outstandingReportsByPolicyID?: OutstandingReportsByPolicyIDDerivedValue; + outstandingReportsByPolicyID?: OutstandingReportsByPolicyID; reportNameValuePairs?: OnyxCollection; isChatReportArchived: boolean; grandParentReport?: OnyxEntry; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 439fa74a2dea..b75062e91fa9 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -30,7 +30,6 @@ import type { GuideAccountIDsDerivedValue, IntroSelected, OnyxInputOrEntry, - OutstandingReportsByPolicyIDDerivedValue, PersonalDetails, PersonalDetailsList, Policy, @@ -78,6 +77,7 @@ import type {EmptyObject} from '@src/types/utils/EmptyObject'; import {isEmptyObject, isEmptyValueObject} from '@src/types/utils/EmptyObject'; import type IconAsset from '@src/types/utils/IconAsset'; +import type {OutstandingReportsByPolicyID} from '@selectors/Report'; import type {Locale as DateFnsLocale} from 'date-fns'; import type {ColorValue} from 'react-native'; import type {NullishDeep, OnyxCollection, OnyxEntry, OnyxInputValue, OnyxUpdate} from 'react-native-onyx'; @@ -5592,7 +5592,7 @@ function canEditFieldOfMoneyRequest({ fieldToEdit: ValueOf; isDeleteAction?: boolean; isChatReportArchived?: boolean; - outstandingReportsByPolicyID?: OutstandingReportsByPolicyIDDerivedValue; + outstandingReportsByPolicyID?: OutstandingReportsByPolicyID; transaction: OnyxEntry; report?: OnyxInputOrEntry; policy?: OnyxEntry; @@ -12584,7 +12584,7 @@ function getNewestOutstandingReportForUser( * Whether the member is the approver of any of the policy's reports that are waiting for their approval. * An approver assigned through "Change approver" is only stored in the report's `managerID`, so they are not covered by `isPolicyApprover`. * @param accountID - The accountID of the member to check - * @param outstandingReportsForPolicy - The policy's outstanding reports, from the OUTSTANDING_REPORTS_BY_POLICY_ID derived value + * @param outstandingReportsForPolicy - The policy's outstanding reports, from useOutstandingReportsForPolicy * @param privateIsArchivedMap - The archived state of every report, from usePrivateIsArchivedMap */ function isApproverOfOutstandingPolicyReports( diff --git a/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts b/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts index 4ea4fabd9560..28add5b7a09d 100644 --- a/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts +++ b/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts @@ -9,7 +9,6 @@ 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'; import reportAttributesConfig from './configs/reportAttributes'; import reportTransactionsAndViolationsConfig from './configs/reportTransactionsAndViolations'; @@ -24,7 +23,6 @@ import visibleReportActionsConfig from './configs/visibleReportActions'; const ONYX_DERIVED_VALUES = { [ONYXKEYS.DERIVED.REPORT_ATTRIBUTES]: reportAttributesConfig, [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, diff --git a/src/libs/actions/OnyxDerived/configs/outstandingReportsByPolicyID.ts b/src/libs/actions/OnyxDerived/configs/outstandingReportsByPolicyID.ts deleted file mode 100644 index ff6aa2a0a0d9..000000000000 --- a/src/libs/actions/OnyxDerived/configs/outstandingReportsByPolicyID.ts +++ /dev/null @@ -1,43 +0,0 @@ -import {isExpenseReport} from '@libs/ReportUtils'; - -import createOnyxDerivedValueConfig from '@userActions/OnyxDerived/createOnyxDerivedValueConfig'; - -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {OutstandingReportsByPolicyIDDerivedValue} from '@src/types/onyx'; - -export default createOnyxDerivedValueConfig({ - key: ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID, - dependencies: [ONYXKEYS.COLLECTION.REPORT], - compute: ([reports]) => { - if (!reports) { - return {}; - } - const outstandingReportsByPolicyID: OutstandingReportsByPolicyIDDerivedValue = {}; - for (const reportID of Object.keys(reports)) { - const report = reports[reportID]; - if (!report) { - continue; - } - - // Get all reports, which are the ones that are: - // - Expense reports - // - Are either open or submitted - // - Are not pending delete - // - Belong to a workspace - // This condition is similar to getOutstandingReportsForUser function - if ( - isExpenseReport(report) && - report.policyID && - report?.pendingFields?.preview !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && - (report.stateNum ?? CONST.REPORT.STATE_NUM.OPEN) <= CONST.REPORT.STATE_NUM.SUBMITTED - ) { - const reportsForPolicy = outstandingReportsByPolicyID[report.policyID] ?? {}; - reportsForPolicy[reportID] = report; - outstandingReportsByPolicyID[report.policyID] = reportsForPolicy; - } - } - - return outstandingReportsByPolicyID; - }, -}); diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index 570744f6ce47..bbb5709f093a 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -21,6 +21,7 @@ import useMergeSyncResultsPage from '@hooks/useMergeSyncResultsPage'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; +import {useOutstandingReportsForPolicy} from '@hooks/useOutstandingReportsByPolicyID'; import {usePersonalDetailsByLogins} from '@hooks/usePersonalDetailByLogin'; import usePrevious from '@hooks/usePrevious'; import usePrivateIsArchivedMap from '@hooks/usePrivateIsArchivedMap'; @@ -96,7 +97,6 @@ import {isEmptyObject, isEmptyValueObject} from '@src/types/utils/EmptyObject'; import type {ValueOf} from 'type-fest'; import {useIsFocused} from '@react-navigation/native'; -import {createOutstandingReportsForPolicySelector} from '@selectors/Report'; import React, {useCallback, useEffect, useEffectEvent, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; @@ -160,8 +160,7 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers const isFocused = useIsFocused(); const policyID = route.params.policyID; const [connectionSyncProgress] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policyID}`); - const outstandingReportsForPolicySelector = useMemo(() => createOutstandingReportsForPolicySelector(policyID), [policyID]); - const [outstandingReportsForPolicy] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID, {selector: outstandingReportsForPolicySelector}); + const outstandingReportsForPolicy = useOutstandingReportsForPolicy(policyID); const privateIsArchivedMap = usePrivateIsArchivedMap(); const [invitedEmailsToAccountIDsDraft] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT}${policyID}`); diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx index 8df2f6712c50..3e89e5411c14 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx @@ -22,6 +22,7 @@ import useExpensifyCardFeeds from '@hooks/useExpensifyCardFeeds'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import {useOutstandingReportsForPolicy} from '@hooks/useOutstandingReportsByPolicyID'; import usePermissions from '@hooks/usePermissions'; import usePersonalDetailByLogin from '@hooks/usePersonalDetailByLogin'; import usePrevious from '@hooks/usePrevious'; @@ -78,7 +79,6 @@ import type {CompanyCardFeed, CompanyCardFeedWithDomainID, Card as MemberCard, P import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; -import {createOutstandingReportsForPolicySelector} from '@selectors/Report'; import {Str} from 'expensify-common'; import React, {useEffect} from 'react'; import {View} from 'react-native'; @@ -130,7 +130,7 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM const [cardList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}`); const [customCardNames] = useOnyx(ONYXKEYS.NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES); const [fundList] = useOnyx(ONYXKEYS.FUND_LIST); - const [outstandingReportsForPolicy] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID, {selector: createOutstandingReportsForPolicySelector(policyID)}); + const outstandingReportsForPolicy = useOutstandingReportsForPolicy(policyID); const privateIsArchivedMap = usePrivateIsArchivedMap(); const expensifyCardSettings = useExpensifyCardFeeds(policyID); const {showConfirmModal} = useConfirmModal(); diff --git a/src/selectors/Report.ts b/src/selectors/Report.ts index 9f83eb430485..5ce482747082 100644 --- a/src/selectors/Report.ts +++ b/src/selectors/Report.ts @@ -5,6 +5,7 @@ import { isArchivedReport, isChatRoom, isClosedReport, + isExpenseReport, isOpenExpenseReport, isPolicyExpenseChat, isThread, @@ -12,7 +13,7 @@ import { import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {OutstandingReportsByPolicyIDDerivedValue, PersonalDetailsList, Report, ReportActions, ReportNameValuePairs, Transaction} from '@src/types/onyx'; +import type {PersonalDetailsList, Report, ReportActions, ReportNameValuePairs, Transaction} from '@src/types/onyx'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {TupleToUnion, ValueOf} from 'type-fest'; @@ -21,6 +22,8 @@ import {getLastClosedReportAction} from './ReportAction'; type OpenExpenseReportIDMap = Record; +type OutstandingReportsByPolicyID = Record>; + function getArchiveReason(reportActions: OnyxEntry): ValueOf | undefined { const lastClosedReportAction = getLastClosedReportAction(reportActions); @@ -170,17 +173,77 @@ const policyChatRoomsSelector = return list; }; -/** Selects the outstanding reports that belong to the given policy. */ -const createOutstandingReportsForPolicySelector = - (policyID: string | undefined) => - (outstandingReportsByPolicyID: OnyxEntry): OnyxCollection => - outstandingReportsByPolicyID?.[policyID ?? CONST.DEFAULT_NUMBER_ID]; +const EMPTY_OUTSTANDING_REPORTS_BY_POLICY_ID: OutstandingReportsByPolicyID = {}; +const outstandingReportsByPolicyIDCache = new WeakMap>, OutstandingReportsByPolicyID>(); +let lastOutstandingReportsByPolicyID: OutstandingReportsByPolicyID = EMPTY_OUTSTANDING_REPORTS_BY_POLICY_ID; + +function hasSameReports(reportsForPolicy: NonNullable>, previousReportsForPolicy: OnyxCollection): boolean { + if (!previousReportsForPolicy) { + return false; + } + const reportKeys = Object.keys(reportsForPolicy); + if (reportKeys.length !== Object.keys(previousReportsForPolicy).length) { + return false; + } + return reportKeys.every((reportKey) => reportsForPolicy[reportKey] === previousReportsForPolicy[reportKey]); +} + +/** + * Groups the open or submitted expense reports by policy, with the same filter as getOutstandingReportsForUser. + * Cached on the collection reference so every mounted consumer shares one build per write. A policy whose reports are + * all unchanged keeps its previous object, and so does the whole map when no policy changed. + */ +function outstandingReportsByPolicyIDSelector(reports: OnyxCollection): OutstandingReportsByPolicyID { + if (!reports) { + return EMPTY_OUTSTANDING_REPORTS_BY_POLICY_ID; + } + + const cachedOutstandingReportsByPolicyID = outstandingReportsByPolicyIDCache.get(reports); + if (cachedOutstandingReportsByPolicyID) { + return cachedOutstandingReportsByPolicyID; + } + + const outstandingReportsByPolicyID: OutstandingReportsByPolicyID = {}; + for (const reportKey of Object.keys(reports)) { + const report = reports[reportKey]; + if ( + !report || + !isExpenseReport(report) || + !report.policyID || + report.pendingFields?.preview === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || + (report.stateNum ?? CONST.REPORT.STATE_NUM.OPEN) > CONST.REPORT.STATE_NUM.SUBMITTED + ) { + continue; + } + const reportsForPolicy = outstandingReportsByPolicyID[report.policyID] ?? {}; + reportsForPolicy[reportKey] = report; + outstandingReportsByPolicyID[report.policyID] = reportsForPolicy; + } + + const previousOutstandingReportsByPolicyID = lastOutstandingReportsByPolicyID; + const policyIDs = Object.keys(outstandingReportsByPolicyID); + let hasChangedPolicy = policyIDs.length !== Object.keys(previousOutstandingReportsByPolicyID).length; + for (const policyID of policyIDs) { + const reportsForPolicy = outstandingReportsByPolicyID[policyID]; + const previousReportsForPolicy = previousOutstandingReportsByPolicyID[policyID]; + if (reportsForPolicy && hasSameReports(reportsForPolicy, previousReportsForPolicy)) { + outstandingReportsByPolicyID[policyID] = previousReportsForPolicy; + } else { + hasChangedPolicy = true; + } + } + + const result = hasChangedPolicy ? outstandingReportsByPolicyID : previousOutstandingReportsByPolicyID; + outstandingReportsByPolicyIDCache.set(reports, result); + lastOutstandingReportsByPolicyID = result; + return result; +} /** * Selects archived report NVPs for the current report and possible "Move expense" destination reports. * This limits updates to data used to determine whether each destination report is archived. */ -const createMoveExpenseReportNVPSelector = (outstandingReportsByPolicyID: OnyxEntry, currentReportID: string | undefined) => { +const createMoveExpenseReportNVPSelector = (outstandingReportsByPolicyID: OnyxEntry, currentReportID: string | undefined) => { const moveExpenseReportIDs = new Set(); if (currentReportID) { moveExpenseReportIDs.add(currentReportID); @@ -393,11 +456,11 @@ export { reportAvatarKindSelector, reportPolicyFieldsSelector, createMoveExpenseReportNVPSelector, - createOutstandingReportsForPolicySelector, + outstandingReportsByPolicyIDSelector, openExpenseReportIDsSelector, getStableReportSelector, isDraftReportSelector, reportsByIDsSelector, }; -export type {ReportAvatarFields, StableReport}; +export type {OutstandingReportsByPolicyID, ReportAvatarFields, StableReport}; diff --git a/src/types/onyx/DerivedValues.ts b/src/types/onyx/DerivedValues.ts index 3f5e2a136157..3b4205786763 100644 --- a/src/types/onyx/DerivedValues.ts +++ b/src/types/onyx/DerivedValues.ts @@ -1,13 +1,11 @@ import type CONST from '@src/CONST'; -import type {OnyxCollection} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import type {Card, ReportAction} from '.'; import type {CardList} from './Card'; import type {CardFeedWithDomainID} from './CardFeeds'; import type {Errors} from './OnyxCommon'; -import type Report from './Report'; import type Transaction from './Transaction'; import type TransactionViolations from './TransactionViolation'; @@ -78,11 +76,6 @@ type ReportTransactionsAndViolations = { */ type ReportTransactionsAndViolationsDerivedValue = Record; -/** - * The derived value for report outstanding reports. - */ -type OutstandingReportsByPolicyIDDerivedValue = Record>; - /** * The derived value for visible report actions. */ @@ -249,7 +242,6 @@ export type { ReportAttributesDerivedValue, ReportTransactionsAndViolationsDerivedValue, ReportTransactionsAndViolations, - OutstandingReportsByPolicyIDDerivedValue, VisibleReportActionsDerivedValue, SortedReportActionsDerivedValue, NonPersonalAndWorkspaceCardListDerivedValue, diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index fad7cf8b5ab5..d8f1517cfb72 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -65,7 +65,6 @@ import type { LoginToAccountIDMapDerivedValue, NonPersonalAndWorkspaceCardListDerivedValue, OneOnOneChatReportIDsDerivedValue, - OutstandingReportsByPolicyIDDerivedValue, PersonalAndWorkspaceCardListDerivedValue, ReportAttributesDerivedValue, ReportTransactionsAndViolationsDerivedValue, @@ -455,7 +454,6 @@ export type { ReportAttributesDerivedValue, LastSearchParams, ReportTransactionsAndViolationsDerivedValue, - OutstandingReportsByPolicyIDDerivedValue, VisibleReportActionsDerivedValue, SortedReportActionsDerivedValue, NonPersonalAndWorkspaceCardListDerivedValue, diff --git a/tests/unit/ReportSelectorTest.ts b/tests/unit/ReportSelectorTest.ts index 86c8d51a3e67..c5fe8ebb64ea 100644 --- a/tests/unit/ReportSelectorTest.ts +++ b/tests/unit/ReportSelectorTest.ts @@ -1,6 +1,6 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import {createMoveExpenseReportNVPSelector, getStableReportSelector, policyChatRoomsSelector} from '@src/selectors/Report'; +import {createMoveExpenseReportNVPSelector, getStableReportSelector, outstandingReportsByPolicyIDSelector, policyChatRoomsSelector} from '@src/selectors/Report'; import type {Report} from '@src/types/onyx'; describe('policyChatRoomsSelector', () => { @@ -128,6 +128,130 @@ describe('createMoveExpenseReportNVPSelector', () => { }); }); +describe('outstandingReportsByPolicyIDSelector', () => { + const REPORT_KEY_PREFIX = ONYXKEYS.COLLECTION.REPORT; + const policyID = 'policy1'; + const otherPolicyID = 'policy2'; + + const openReport = {reportID: '1', policyID, type: CONST.REPORT.TYPE.EXPENSE, stateNum: CONST.REPORT.STATE_NUM.OPEN} as Report; + const submittedReport = {reportID: '2', policyID, type: CONST.REPORT.TYPE.EXPENSE, stateNum: CONST.REPORT.STATE_NUM.SUBMITTED} as Report; + const reportWithoutState = {reportID: '3', policyID, type: CONST.REPORT.TYPE.EXPENSE} as Report; + const otherPolicyReport = {reportID: '4', policyID: otherPolicyID, type: CONST.REPORT.TYPE.EXPENSE, stateNum: CONST.REPORT.STATE_NUM.OPEN} as Report; + const approvedReport = {reportID: '5', policyID, type: CONST.REPORT.TYPE.EXPENSE, stateNum: CONST.REPORT.STATE_NUM.APPROVED} as Report; + const deletingReport = { + reportID: '6', + policyID, + type: CONST.REPORT.TYPE.EXPENSE, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + pendingFields: {preview: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}, + } as Report; + const iouReport = {reportID: '7', policyID, type: CONST.REPORT.TYPE.IOU, stateNum: CONST.REPORT.STATE_NUM.OPEN} as Report; + const reportWithoutPolicy = {reportID: '8', type: CONST.REPORT.TYPE.EXPENSE, stateNum: CONST.REPORT.STATE_NUM.OPEN} as Report; + const chatReport = {reportID: '9', policyID, chatType: CONST.REPORT.CHAT_TYPE.POLICY_ROOM, reportName: 'Before'} as Report; + + const buildReports = (overrides: Record = {}) => ({ + [`${REPORT_KEY_PREFIX}1`]: openReport, + [`${REPORT_KEY_PREFIX}2`]: submittedReport, + [`${REPORT_KEY_PREFIX}3`]: reportWithoutState, + [`${REPORT_KEY_PREFIX}4`]: otherPolicyReport, + [`${REPORT_KEY_PREFIX}5`]: approvedReport, + [`${REPORT_KEY_PREFIX}6`]: deletingReport, + [`${REPORT_KEY_PREFIX}7`]: iouReport, + [`${REPORT_KEY_PREFIX}8`]: reportWithoutPolicy, + [`${REPORT_KEY_PREFIX}9`]: chatReport, + ...overrides, + }); + + it('should return an empty map when there are no reports', () => { + // Given no report collection, or an empty one + // When the outstanding reports are selected + // Then no policy has outstanding reports + expect(outstandingReportsByPolicyIDSelector(undefined)).toEqual({}); + expect(outstandingReportsByPolicyIDSelector({})).toEqual({}); + }); + + it('should group open and submitted expense reports by policy with the same filter as getOutstandingReportsForUser', () => { + // Given expense reports in every state next to reports that are not outstanding for other reasons + const reports = buildReports(); + + // When the outstanding reports are selected + // Then only open or submitted expense reports with a policy that are not being deleted are kept, keyed by their collection key under their policy + expect(outstandingReportsByPolicyIDSelector(reports)).toEqual({ + [policyID]: { + [`${REPORT_KEY_PREFIX}1`]: openReport, + [`${REPORT_KEY_PREFIX}2`]: submittedReport, + [`${REPORT_KEY_PREFIX}3`]: reportWithoutState, + }, + [otherPolicyID]: {[`${REPORT_KEY_PREFIX}4`]: otherPolicyReport}, + }); + }); + + it('should return the same map for the same report collection', () => { + // Given one report collection + const reports = buildReports(); + + // When the outstanding reports are selected twice, as two mounted consumers do after the same write + // Then both get the same object, so the map is built once per collection + expect(outstandingReportsByPolicyIDSelector(reports)).toBe(outstandingReportsByPolicyIDSelector(reports)); + }); + + it('should return the previous map when a write leaves every outstanding report unchanged', () => { + // Given the map built from a report collection + const before = outstandingReportsByPolicyIDSelector(buildReports()); + + // When a new collection only renames a chat and replaces the approved report + const after = outstandingReportsByPolicyIDSelector( + buildReports({ + [`${REPORT_KEY_PREFIX}9`]: {...chatReport, reportName: 'After'}, + [`${REPORT_KEY_PREFIX}5`]: {...approvedReport}, + }), + ); + + // Then consumers get the same object back, so they skip the re-render without a deep compare + expect(after).toBe(before); + }); + + it('should keep the reports of a policy whose outstanding reports did not change', () => { + // Given the map built from a report collection + const before = outstandingReportsByPolicyIDSelector(buildReports()); + + // When an outstanding report of the other policy changes + const changedOtherPolicyReport = {...otherPolicyReport, total: 100}; + const after = outstandingReportsByPolicyIDSelector(buildReports({[`${REPORT_KEY_PREFIX}4`]: changedOtherPolicyReport})); + + // Then only that policy gets new reports, so a consumer that reads one policy keeps its reference + expect(after).not.toBe(before); + expect(after[policyID]).toBe(before[policyID]); + expect(after[otherPolicyID]).toEqual({[`${REPORT_KEY_PREFIX}4`]: changedOtherPolicyReport}); + }); + + it('should drop a report from its policy once it is approved', () => { + // Given the map built from a report collection + const before = outstandingReportsByPolicyIDSelector(buildReports()); + + // When the submitted report is approved + const after = outstandingReportsByPolicyIDSelector(buildReports({[`${REPORT_KEY_PREFIX}2`]: {...submittedReport, stateNum: CONST.REPORT.STATE_NUM.APPROVED}})); + + // Then its policy loses it and the other policy keeps its reports + expect(after[policyID]).toEqual({ + [`${REPORT_KEY_PREFIX}1`]: openReport, + [`${REPORT_KEY_PREFIX}3`]: reportWithoutState, + }); + expect(after[otherPolicyID]).toBe(before[otherPolicyID]); + }); + + it('should remove a policy whose last outstanding report is deleted', () => { + // Given the map built from a report collection + outstandingReportsByPolicyIDSelector(buildReports()); + + // When the only outstanding report of the other policy is removed + const after = outstandingReportsByPolicyIDSelector(buildReports({[`${REPORT_KEY_PREFIX}4`]: undefined})); + + // Then that policy is gone from the map + expect(after).not.toHaveProperty(otherPolicyID); + }); +}); + describe('getStableReportSelector', () => { const {READ, WRITE, SHARE} = CONST.REPORT.PERMISSIONS; diff --git a/tests/unit/canEditFieldOfMoneyRequestTest.ts b/tests/unit/canEditFieldOfMoneyRequestTest.ts index 21bd951d557e..5d9f02590149 100644 --- a/tests/unit/canEditFieldOfMoneyRequestTest.ts +++ b/tests/unit/canEditFieldOfMoneyRequestTest.ts @@ -7,13 +7,14 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, Report, ReportAction} from '@src/types/onyx'; import {toCollectionDataSet} from '@src/types/utils/CollectionDataSet'; +import {outstandingReportsByPolicyIDSelector} from '@selectors/Report'; import Onyx from 'react-native-onyx'; -import OnyxUtils from 'react-native-onyx/dist/OnyxUtils'; import createRandomPolicy from '../utils/collections/policies'; import createRandomReportAction from '../utils/collections/reportActions'; import {createExpenseReport, createInvoiceReport} from '../utils/collections/reports'; import createRandomTransaction from '../utils/collections/transaction'; +import getOnyxValue from '../utils/getOnyxValue'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; const currentUserAccountID = 5; @@ -107,7 +108,7 @@ describe('canEditFieldOfMoneyRequest', () => { }); it('should return false for invoice report action if it is not outstanding report', async () => { - const outstandingReportsByPolicyID = await OnyxUtils.get(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID); + const outstandingReportsByPolicyID = outstandingReportsByPolicyIDSelector(await getOnyxValue(ONYXKEYS.COLLECTION.REPORT)); const canEditReportField = canEditFieldOfMoneyRequest({ rules: undefined, @@ -123,7 +124,7 @@ describe('canEditFieldOfMoneyRequest', () => { it('should return true for invoice report action when there are outstanding reports', async () => { await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${IOUReportID}`, outstandingExpenseReport); await waitForBatchedUpdates(); - const outstandingReportsByPolicyID = await OnyxUtils.get(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID); + const outstandingReportsByPolicyID = outstandingReportsByPolicyIDSelector(await getOnyxValue(ONYXKEYS.COLLECTION.REPORT)); const canEditReportField = canEditFieldOfMoneyRequest({ rules: undefined, @@ -319,7 +320,7 @@ describe('canEditFieldOfMoneyRequest', () => { }, }); await waitForBatchedUpdates(); - const outstandingReportsByPolicyID = await OnyxUtils.get(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID); + const outstandingReportsByPolicyID = outstandingReportsByPolicyIDSelector(await getOnyxValue(ONYXKEYS.COLLECTION.REPORT)); // When the submitter tries to move an expense between reports const canEditReportField = canEditFieldOfMoneyRequest({ @@ -347,7 +348,7 @@ describe('canEditFieldOfMoneyRequest', () => { }, }); await waitForBatchedUpdates(); - const outstandingReportsByPolicyID = await OnyxUtils.get(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID); + const outstandingReportsByPolicyID = outstandingReportsByPolicyIDSelector(await getOnyxValue(ONYXKEYS.COLLECTION.REPORT)); const canEditReportField = canEditFieldOfMoneyRequest({ rules: undefined, @@ -378,7 +379,7 @@ describe('canEditFieldOfMoneyRequest', () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${EXPENSE_OUTSTANDING_REPORT_1_ID}`, outstandingExpenseReport1); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${EXPENSE_OUTSTANDING_REPORT_2_ID}`, outstandingExpenseReport2); await waitForBatchedUpdates(); - const outstandingReportsByPolicyID = await OnyxUtils.get(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID); + const outstandingReportsByPolicyID = outstandingReportsByPolicyIDSelector(await getOnyxValue(ONYXKEYS.COLLECTION.REPORT)); // When a user tries to move an expense between reports const canEditReportField = canEditFieldOfMoneyRequest({ @@ -413,7 +414,7 @@ describe('canEditFieldOfMoneyRequest', () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${EXPENSE_OUTSTANDING_REPORT_1_ID}`, approvedReport1); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${EXPENSE_OUTSTANDING_REPORT_2_ID}`, reimbursedReport2); await waitForBatchedUpdates(); - const outstandingReportsByPolicyID = await OnyxUtils.get(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID); + const outstandingReportsByPolicyID = outstandingReportsByPolicyIDSelector(await getOnyxValue(ONYXKEYS.COLLECTION.REPORT)); // When trying to move an expense between reports const canEditReportField = canEditFieldOfMoneyRequest({ @@ -435,7 +436,7 @@ describe('canEditFieldOfMoneyRequest', () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${EXPENSE_OUTSTANDING_REPORT_1_ID}`, outstandingExpenseReport1); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${EXPENSE_OUTSTANDING_REPORT_2_ID}`, outstandingExpenseReport2); await waitForBatchedUpdates(); - const outstandingReportsByPolicyID = await OnyxUtils.get(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID); + const outstandingReportsByPolicyID = outstandingReportsByPolicyIDSelector(await getOnyxValue(ONYXKEYS.COLLECTION.REPORT)); // When the submitter tries to move an expense between reports const canEditReportField = canEditFieldOfMoneyRequest({ diff --git a/tests/unit/hooks/useOutstandingReportsByPolicyIDTest.tsx b/tests/unit/hooks/useOutstandingReportsByPolicyIDTest.tsx new file mode 100644 index 000000000000..dbfb2ba25894 --- /dev/null +++ b/tests/unit/hooks/useOutstandingReportsByPolicyIDTest.tsx @@ -0,0 +1,140 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import useOutstandingReportsByPolicyID, {useOutstandingReportsForPolicy} from '@hooks/useOutstandingReportsByPolicyID'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report} from '@src/types/onyx'; + +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdatesWithAct from '../../utils/waitForBatchedUpdatesWithAct'; + +const POLICY_ID = 'policy1'; +const OTHER_POLICY_ID = 'policy2'; +const OPEN_REPORT_KEY = `${ONYXKEYS.COLLECTION.REPORT}1` as const; +const OTHER_POLICY_REPORT_KEY = `${ONYXKEYS.COLLECTION.REPORT}2` as const; +const CHAT_REPORT_KEY = `${ONYXKEYS.COLLECTION.REPORT}3` as const; +const NEW_REPORT_KEY = `${ONYXKEYS.COLLECTION.REPORT}4` as const; + +const openReport: Report = {reportID: '1', policyID: POLICY_ID, type: CONST.REPORT.TYPE.EXPENSE, stateNum: CONST.REPORT.STATE_NUM.OPEN}; +const otherPolicyReport: Report = {reportID: '2', policyID: OTHER_POLICY_ID, type: CONST.REPORT.TYPE.EXPENSE, stateNum: CONST.REPORT.STATE_NUM.OPEN}; +const chatReport: Report = {reportID: '3', policyID: POLICY_ID, chatType: CONST.REPORT.CHAT_TYPE.POLICY_ROOM, reportName: 'Before'}; + +async function seedReports() { + await act(async () => { + await Onyx.multiSet({ + [OPEN_REPORT_KEY]: openReport, + [OTHER_POLICY_REPORT_KEY]: otherPolicyReport, + [CHAT_REPORT_KEY]: chatReport, + }); + }); +} + +describe('useOutstandingReportsByPolicyID', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await act(async () => { + await Onyx.clear(); + }); + }); + + it('should follow writes to the reports', async () => { + // Given an open expense report in each of two policies + await seedReports(); + const {result} = renderHook(() => useOutstandingReportsByPolicyID()); + await waitForBatchedUpdatesWithAct(); + expect(Object.keys(result.current[POLICY_ID] ?? {})).toEqual([OPEN_REPORT_KEY]); + + // When another open expense report arrives in the first policy + await act(async () => { + await Onyx.set(NEW_REPORT_KEY, {reportID: '4', policyID: POLICY_ID, type: CONST.REPORT.TYPE.EXPENSE, stateNum: CONST.REPORT.STATE_NUM.SUBMITTED}); + }); + await waitForBatchedUpdatesWithAct(); + + // Then the hook lists it, since it reads the reports live instead of a stored derived value + expect(Object.keys(result.current[POLICY_ID] ?? {}).sort()).toEqual([OPEN_REPORT_KEY, NEW_REPORT_KEY]); + }); + + it('should not re-render when a write leaves every outstanding report unchanged', async () => { + // Given open expense reports and a chat + await seedReports(); + let renderCount = 0; + const {result} = renderHook(() => { + renderCount++; + return useOutstandingReportsByPolicyID(); + }); + await waitForBatchedUpdatesWithAct(); + const mapBeforeWrite = result.current; + const renderCountBeforeWrite = renderCount; + + // When the chat is renamed, which is not an outstanding report + await act(async () => { + await Onyx.merge(CHAT_REPORT_KEY, {reportName: 'After'}); + }); + await waitForBatchedUpdatesWithAct(); + + // Then the consumer keeps the same map and does not re-render + expect(result.current).toBe(mapBeforeWrite); + expect(renderCount).toBe(renderCountBeforeWrite); + }); + + it('should give every mounted consumer the same map', async () => { + // Given two consumers mounted over the same reports + await seedReports(); + const {result: first} = renderHook(() => useOutstandingReportsByPolicyID()); + const {result: second} = renderHook(() => useOutstandingReportsByPolicyID()); + await waitForBatchedUpdatesWithAct(); + + // When an outstanding report changes + await act(async () => { + await Onyx.merge(OPEN_REPORT_KEY, {total: 100}); + }); + await waitForBatchedUpdatesWithAct(); + + // Then both get the same object, because the map is built once per report collection, not once per consumer + expect(second.current).toBe(first.current); + expect(first.current[POLICY_ID]?.[OPEN_REPORT_KEY]?.total).toBe(100); + }); + + it('should not re-render a single policy consumer when only another policy changes', async () => { + // Given a consumer of the first policy's outstanding reports + await seedReports(); + let renderCount = 0; + const {result} = renderHook(() => { + renderCount++; + return useOutstandingReportsForPolicy(POLICY_ID); + }); + await waitForBatchedUpdatesWithAct(); + const reportsBeforeWrite = result.current; + const renderCountBeforeWrite = renderCount; + expect(Object.keys(reportsBeforeWrite ?? {})).toEqual([OPEN_REPORT_KEY]); + + // When the other policy's outstanding report changes + await act(async () => { + await Onyx.merge(OTHER_POLICY_REPORT_KEY, {total: 100}); + }); + await waitForBatchedUpdatesWithAct(); + + // Then the consumer keeps the same reports and does not re-render + expect(result.current).toBe(reportsBeforeWrite); + expect(renderCount).toBe(renderCountBeforeWrite); + }); + + it('should return nothing for a policy without outstanding reports', async () => { + // Given open expense reports in two policies + await seedReports(); + + // When a consumer reads a policy that has none, or no policy at all + const {result: unknownPolicy} = renderHook(() => useOutstandingReportsForPolicy('policy3')); + const {result: noPolicy} = renderHook(() => useOutstandingReportsForPolicy(undefined)); + await waitForBatchedUpdatesWithAct(); + + // Then both get undefined, as the per-policy selector on the derived value returned + expect(unknownPolicy.current).toBeUndefined(); + expect(noPolicy.current).toBeUndefined(); + }); +}); From 5d518cc854df36be06a8454d2bf866f9042c79cb Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Thu, 8 Oct 2026 17:24:35 +0200 Subject: [PATCH 2/3] Add reassure test for outstanding reports consumers on report writes --- ...OutstandingReportsByPolicyID.perf-test.tsx | 210 ++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 tests/perf-test/OutstandingReportsByPolicyID.perf-test.tsx diff --git a/tests/perf-test/OutstandingReportsByPolicyID.perf-test.tsx b/tests/perf-test/OutstandingReportsByPolicyID.perf-test.tsx new file mode 100644 index 000000000000..0ceab0dae7f0 --- /dev/null +++ b/tests/perf-test/OutstandingReportsByPolicyID.perf-test.tsx @@ -0,0 +1,210 @@ +import {render, screen} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import Text from '@components/Text'; + +import useIsApproverOfOutstandingPolicyReports from '@hooks/useIsApproverOfOutstandingPolicyReports'; +import useOutstandingReports from '@hooks/useOutstandingReports'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report} from '@src/types/onyx'; + +import React from 'react'; +import {View} from 'react-native'; +import Onyx from 'react-native-onyx'; +import {measureAsyncFunction, measureRenders} from 'reassure'; + +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +const CONSUMER_COUNT = 10; +const WRITES_PER_SCENARIO = 10; +const REPORT_COUNT = 5000; +const POLICY_COUNT = 50; +const CURRENT_USER_ACCOUNT_ID = 1; +const APPROVER_ACCOUNT_ID = 2; +const POLICY_ID = 'policy0'; + +// Report IDs by role in the generated collection: even IDs are chats, odd IDs are expense reports spread over the policies in turn +const CHAT_REPORT_ID = 2; +const OTHER_POLICY_OUTSTANDING_REPORT_ID = 5; +const OUTSTANDING_REPORT_ID = 101; + +function createReport(reportID: number): Report { + if (reportID % 2 === 0) { + return { + reportID: String(reportID), + type: CONST.REPORT.TYPE.CHAT, + chatType: CONST.REPORT.CHAT_TYPE.POLICY_ROOM, + policyID: `policy${Math.floor(reportID / 2) % POLICY_COUNT}`, + reportName: `Chat ${reportID}`, + }; + } + const isApproved = reportID % 3 === 0; + return { + reportID: String(reportID), + type: CONST.REPORT.TYPE.EXPENSE, + policyID: `policy${Math.floor(reportID / 2) % POLICY_COUNT}`, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + managerID: APPROVER_ACCOUNT_ID, + stateNum: isApproved ? CONST.REPORT.STATE_NUM.APPROVED : CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: isApproved ? CONST.REPORT.STATUS_NUM.APPROVED : CONST.REPORT.STATUS_NUM.SUBMITTED, + reportName: `Expense ${reportID}`, + }; +} + +const reports = Object.fromEntries(Array.from({length: REPORT_COUNT}, (value, index) => [`${ONYXKEYS.COLLECTION.REPORT}${index + 1}`, createReport(index + 1)])); + +/** Passed as the reassure wrapper so the providers sit outside the profiler and render counts are the consumers' own */ +function Providers({children}: {children: React.ReactNode}) { + return {children}; +} + +/** Reads every policy's outstanding reports, like the move-expense report picker and the expense header actions */ +function MoveExpenseConsumer() { + const outstandingReports = useOutstandingReports(undefined, POLICY_ID, CURRENT_USER_ACCOUNT_ID, false); + return {outstandingReports.length}; +} + +/** Reads one policy's outstanding reports, like the workspace overview and leave-workspace checks */ +function ApproverConsumer() { + const isApprover = useIsApproverOfOutstandingPolicyReports(POLICY_ID); + return {isApprover ? 'approver' : 'member'}; +} + +/** Neither consumer reads the report attributes, so their re-renders isolate outstanding reports churn */ +function AllConsumers() { + return ( + + {Array.from({length: CONSUMER_COUNT}, (value, index) => ( + + + + + ))} + + ); +} + +function SinglePolicyConsumers() { + return ( + + {Array.from({length: CONSUMER_COUNT}, (value, index) => ( + + ))} + + ); +} + +async function seed() { + await Onyx.multiSet({ + ...reports, + [ONYXKEYS.SESSION]: {accountID: APPROVER_ACCOUNT_ID, email: 'approver@test.com'}, + }); + await waitForBatchedUpdates(); +} + +async function renameChat(label: string) { + for (let index = 0; index < WRITES_PER_SCENARIO; index++) { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${CHAT_REPORT_ID}`, {reportName: `Chat ${label}-${index}`}); + await waitForBatchedUpdates(); + } +} + +describe('Outstanding reports consumers on report writes', () => { + beforeAll(async () => { + Onyx.init({keys: ONYXKEYS}); + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + require('@userActions/OnyxDerived').default(); + await waitForBatchedUpdates(); + }); + + afterEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + test('[OutstandingReportsByPolicyID] should re-render consumers when a chat is renamed among 5000 reports', async () => { + // Given 10 consumers of every policy and 10 of one policy over 5000 reports + await seed(); + + // Reassure runs the scenario several times against one seeded Onyx, so every run writes a name that doesn't exist yet + let run = 0; + const scenario = async () => { + await screen.findAllByTestId('ApproverConsumer'); + + // When a chat is renamed, which leaves every policy's outstanding reports unchanged + await renameChat(`renders ${run}`); + run++; + }; + + // Then reassure measures how many re-renders the unrelated writes cause + await measureRenders(, {scenario, wrapper: Providers}); + }); + + test('[OutstandingReportsByPolicyID] should re-render single policy consumers when another policy outstanding report changes', async () => { + // Given 10 consumers of one policy over 5000 reports + await seed(); + + let run = 0; + const scenario = async () => { + await screen.findAllByTestId('ApproverConsumer'); + + // When an outstanding report of another policy changes, which leaves this policy's outstanding reports unchanged + for (let index = 0; index < WRITES_PER_SCENARIO; index++) { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${OTHER_POLICY_OUTSTANDING_REPORT_ID}`, {total: run * WRITES_PER_SCENARIO + index + 1}); + await waitForBatchedUpdates(); + } + run++; + }; + + // Then reassure measures how many re-renders the other policy's writes cause + await measureRenders(, {scenario, wrapper: Providers}); + }); + + test('[OutstandingReportsByPolicyID] should re-render consumers when a report of their policy is approved and reopened', async () => { + // Given 10 consumers of every policy and 10 of one policy over 5000 reports + await seed(); + + let isApproved = false; + const scenario = async () => { + await screen.findAllByTestId('ApproverConsumer'); + + // When an outstanding report of the consumers' policy is approved and reopened in turn, so it leaves the outstanding reports and comes back + for (let index = 0; index < WRITES_PER_SCENARIO; index++) { + isApproved = !isApproved; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${OUTSTANDING_REPORT_ID}`, { + stateNum: isApproved ? CONST.REPORT.STATE_NUM.APPROVED : CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: isApproved ? CONST.REPORT.STATUS_NUM.APPROVED : CONST.REPORT.STATUS_NUM.SUBMITTED, + }); + await waitForBatchedUpdates(); + } + }; + + // Then reassure measures the cost of a real change, which both versions have to pay + await measureRenders(, {scenario, wrapper: Providers}); + }); + + test('[OutstandingReportsByPolicyID] should process chat renames among 5000 reports', async () => { + // Given every consumer mounted over 5000 reports + await seed(); + render( + + + , + ); + await screen.findAllByTestId('ApproverConsumer'); + + // When a chat is renamed repeatedly + let run = 0; + const writeRenames = async () => { + await renameChat(`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(writeRenames); + }); +}); 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', () => {