From f9baccf0ebd97a4a079bdf160dcef6457de567d5 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Fri, 6 Nov 2026 11:48:11 +0100 Subject: [PATCH] Replace VISIBLE_REPORT_ACTIONS derived value with useOnyxState hooks --- src/ONYXKEYS.ts | 2 - .../AttachmentCarousel/extractAttachments.ts | 4 +- .../OptionRowLHN/OptionRowLHNData.tsx | 21 +- .../useMoneyRequestReportVisibleActions.ts | 7 +- src/components/ParentNavigationSubtitle.tsx | 4 +- .../Search/SearchAutocompleteList.tsx | 3 +- .../SearchRouterOptionsWarmer.tsx | 3 +- src/hooks/useAllVisibleReportActions.ts | 23 ++ src/hooks/useAutocompleteSuggestions.ts | 6 +- src/hooks/useReportActionsVisibility.ts | 7 +- src/hooks/useSearchSelector/base.ts | 3 +- src/hooks/useVisibleReportActions.ts | 38 +++ src/libs/OptionsListUtils/index.ts | 24 +- src/libs/OptionsListUtils/types.ts | 6 +- src/libs/ReportActionsUtils.ts | 20 +- src/libs/ReportAlternateTextUtils.ts | 12 +- src/libs/ReportUtils.ts | 11 +- src/libs/SearchUIUtils.ts | 4 +- src/libs/SidebarUtils.ts | 4 +- .../OnyxDerived/ONYX_DERIVED_VALUES.ts | 2 - .../configs/visibleReportActions.ts | 106 ------- src/libs/actions/Report/index.ts | 4 +- src/pages/Share/ShareTab.tsx | 3 +- src/pages/inbox/LinkedActionNotFoundGuard.tsx | 3 +- .../PopoverReportActionContextMenu.tsx | 5 +- src/selectors/ReportAction.ts | 10 - src/selectors/VisibleReportActions.ts | 173 +++++++++++ src/types/onyx/DerivedValues.ts | 6 - src/types/onyx/ReportAction.ts | 5 +- src/types/onyx/index.ts | 5 +- .../LinkedActionNotFoundGuardTest.tsx | 10 +- .../VisibleReportActions.perf-test.tsx | 279 ++++++++++++++++++ tests/ui/ParentNavigationSubtitleTest.tsx | 35 ++- tests/ui/components/LHNOptionsListTest.tsx | 10 - .../OnyxDerived/visibleReportActionsTest.ts | 83 ------ tests/unit/OnyxDerivedTest.tsx | 127 -------- tests/unit/SidebarUtilsTest.ts | 6 - .../selectors/VisibleReportActionsTest.ts | 166 +++++++++++ tests/unit/useAllVisibleReportActionsTest.tsx | 233 +++++++++++++++ ...seMoneyRequestReportVisibleActionsTest.tsx | 5 +- tests/unit/useReportActionsVisibilityTest.tsx | 81 +---- tests/unit/useVisibleReportActionsTest.tsx | 156 ++++++++++ 42 files changed, 1170 insertions(+), 545 deletions(-) create mode 100644 src/hooks/useAllVisibleReportActions.ts create mode 100644 src/hooks/useVisibleReportActions.ts delete mode 100644 src/libs/actions/OnyxDerived/configs/visibleReportActions.ts create mode 100644 src/selectors/VisibleReportActions.ts create mode 100644 tests/perf-test/VisibleReportActions.perf-test.tsx delete mode 100644 tests/unit/OnyxDerived/visibleReportActionsTest.ts create mode 100644 tests/unit/selectors/VisibleReportActionsTest.ts create mode 100644 tests/unit/useAllVisibleReportActionsTest.tsx create mode 100644 tests/unit/useVisibleReportActionsTest.tsx diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index efee8f5cc31a..0c117fcb5c8e 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -1487,7 +1487,6 @@ const ONYXKEYS = { DERIVED: { 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', @@ -2090,7 +2089,6 @@ type OnyxValuesMapping = { 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; diff --git a/src/components/Attachments/AttachmentCarousel/extractAttachments.ts b/src/components/Attachments/AttachmentCarousel/extractAttachments.ts index bc9927bc8044..5167ad6396e2 100644 --- a/src/components/Attachments/AttachmentCarousel/extractAttachments.ts +++ b/src/components/Attachments/AttachmentCarousel/extractAttachments.ts @@ -6,7 +6,7 @@ import {canUserPerformWriteAction} from '@libs/ReportUtils'; import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot'; import CONST from '@src/CONST'; -import type {Report, ReportAction, ReportActions, VisibleReportActionsDerivedValue} from '@src/types/onyx'; +import type {Report, ReportAction, ReportActions, VisibleReportActions} from '@src/types/onyx'; import type {Note} from '@src/types/onyx/Report'; import type {OnyxEntry} from 'react-native-onyx'; @@ -34,7 +34,7 @@ function extractAttachments( reportActions?: OnyxEntry; report: OnyxEntry; isReportArchived: boolean | undefined; - visibleReportActionsData?: VisibleReportActionsDerivedValue; + visibleReportActionsData?: VisibleReportActions; }, ) { const targetNote = privateNotes?.[Number(accountID)]?.note ?? ''; diff --git a/src/components/LHNOptionsList/OptionRowLHN/OptionRowLHNData.tsx b/src/components/LHNOptionsList/OptionRowLHN/OptionRowLHNData.tsx index 11b39961c47e..1e91284e8a8d 100644 --- a/src/components/LHNOptionsList/OptionRowLHN/OptionRowLHNData.tsx +++ b/src/components/LHNOptionsList/OptionRowLHN/OptionRowLHNData.tsx @@ -10,6 +10,7 @@ import useOnyx from '@hooks/useOnyx'; import usePersonalDetailByLogin from '@hooks/usePersonalDetailByLogin'; import {usePersonalDetailsByIDs} from '@hooks/usePersonalDetails'; import {useReportAttributesEntry, useReportNameEntries} from '@hooks/useReportAttributes'; +import useVisibleReportActions from '@hooks/useVisibleReportActions'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import { @@ -31,7 +32,6 @@ import CONST from '@src/CONST'; import {getMovedReportID} from '@src/libs/ModifiedExpenseMessage'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetailsList, Report, ReportAction, ReportActions as ReportActionsType} from '@src/types/onyx'; -import type {VisibleReportActionsDerivedValue} from '@src/types/onyx/DerivedValues'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; @@ -137,27 +137,10 @@ function OptionRowLHNData({isOptionFocused = false, viewMode = 'default', report const isInvoice = fullReport?.type === CONST.REPORT.TYPE.INVOICE; const isIOUReport = fullReport?.type === CONST.REPORT.TYPE.IOU; - const visibleActionsSelector = (data: VisibleReportActionsDerivedValue | undefined) => { - if (!data) { - return undefined; - } - const result: VisibleReportActionsDerivedValue = {}; - const reportEntry = data[reportID]; - if (reportEntry) { - result[reportID] = reportEntry; - } - if (reportAttributes?.oneTransactionThreadReportID) { - const txThreadEntry = data[reportAttributes.oneTransactionThreadReportID]; - if (txThreadEntry) { - result[reportAttributes.oneTransactionThreadReportID] = txThreadEntry; - } - } - return result; - }; const [oneTransactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportAttributes?.oneTransactionThreadReportID)}`); const [transactionThreadReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(reportAttributes?.oneTransactionThreadReportID)}`); - const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS, {selector: visibleActionsSelector}); + const visibleReportActionsData = useVisibleReportActions(reportID, reportAttributes?.oneTransactionThreadReportID); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(fullReport?.policyID)}`); const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${fullReport?.policyID}`); const reimburserAccountID = usePersonalDetailByLogin(policy?.reimburser ?? policy?.achAccount?.reimburser, accountIDSelector); diff --git a/src/components/MoneyRequestReportView/useMoneyRequestReportVisibleActions.ts b/src/components/MoneyRequestReportView/useMoneyRequestReportVisibleActions.ts index 993e4caed230..1910540e8ab6 100644 --- a/src/components/MoneyRequestReportView/useMoneyRequestReportVisibleActions.ts +++ b/src/components/MoneyRequestReportView/useMoneyRequestReportVisibleActions.ts @@ -8,17 +8,14 @@ * non-inverted unified list, newest-first for the shared unread/mark-as-read hooks - so callers never * re-derive or re-sort them. */ -import useOnyx from '@hooks/useOnyx'; +import useVisibleReportActions from '@hooks/useVisibleReportActions'; import {isActionVisibleOnMoneyRequestReport} from '@libs/MoneyRequestReportUtils'; import {getFirstVisibleReportActionID, isDeletedParentAction, isIOUActionMatchingTransactionList, isReportActionVisible, shouldHidePayAction} from '@libs/ReportActionsUtils'; import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; -import {reportVisibleActionsSelector} from '@selectors/ReportAction'; - type UseMoneyRequestReportVisibleActionsParams = { /** The report whose actions are being displayed */ reportID: string | undefined; @@ -70,7 +67,7 @@ function useMoneyRequestReportVisibleActions({ isOffline, isWaitingOnBankAccount = false, }: UseMoneyRequestReportVisibleActionsParams): UseMoneyRequestReportVisibleActionsResult { - const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS, {selector: reportVisibleActionsSelector(reportID)}); + const visibleReportActionsData = useVisibleReportActions(reportID); const reportActionsWithVisiblePayments = isWaitingOnBankAccount ? reportActions.filter((action) => !shouldHidePayAction(action, true)) : reportActions; const visibleReportActionsNewestFirst = reportActionsWithVisiblePayments.filter((reportAction) => { diff --git a/src/components/ParentNavigationSubtitle.tsx b/src/components/ParentNavigationSubtitle.tsx index 0f7067f1488f..071cdc7eefc6 100644 --- a/src/components/ParentNavigationSubtitle.tsx +++ b/src/components/ParentNavigationSubtitle.tsx @@ -7,6 +7,7 @@ import useRootNavigationState from '@hooks/useRootNavigationState'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import useVisibleReportActions from '@hooks/useVisibleReportActions'; import {isFullScreenName} from '@libs/Navigation/helpers/isNavigatorName'; import Navigation from '@libs/Navigation/Navigation'; @@ -25,7 +26,6 @@ import type {ColorValue, StyleProp, TextStyle, ViewStyle} from 'react-native'; import {useRoute} from '@react-navigation/native'; import {getReportHasParentAccess} from '@selectors/Report'; -import {reportVisibleActionsSelector} from '@selectors/ReportAction'; import React from 'react'; import {View} from 'react-native'; @@ -102,7 +102,7 @@ function ParentNavigationSubtitle({ const {translate} = useLocalize(); const [hasParentAccess] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {selector: getReportHasParentAccess}); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`); - const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS, {selector: reportVisibleActionsSelector(parentReportID)}); + const visibleReportActionsData = useVisibleReportActions(parentReportID); const isReportArchived = useReportIsArchived(report?.reportID); const canUserPerformWriteAction = canUserPerformWriteActionReportUtils(report, isReportArchived); const hasAccessToParentReport = hasParentAccess !== false; diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index e26cb1e460fd..d41da71ceba0 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -7,6 +7,7 @@ import type {ListItem as NewListItem, ListItemProps} from '@components/Selection import SelectionListWithSections from '@components/SelectionList/SelectionListWithSections'; import type {Section, SelectionListWithSectionsHandle} from '@components/SelectionList/SelectionListWithSections/types'; +import useAllVisibleReportActions from '@hooks/useAllVisibleReportActions'; import useAutocompleteSuggestions from '@hooks/useAutocompleteSuggestions'; import useBottomSafeSafeAreaPaddingStyle from '@hooks/useBottomSafeSafeAreaPaddingStyle'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; @@ -216,7 +217,7 @@ function SearchAutocompleteList({ }); const [policies = getEmptyObject>>()] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [allPolicyTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS); - const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); + const visibleReportActionsData = useAllVisibleReportActions(); const {activePolicyID} = useActivePolicyContext(); const sortedReportActionsData = useSortedReportActionsData(); const sortedActions = sortedReportActionsData?.sortedActions; diff --git a/src/components/Search/SearchRouter/SearchRouterOptionsWarmer.tsx b/src/components/Search/SearchRouter/SearchRouterOptionsWarmer.tsx index 87c0eda28a5a..cf09b5174597 100644 --- a/src/components/Search/SearchRouter/SearchRouterOptionsWarmer.tsx +++ b/src/components/Search/SearchRouter/SearchRouterOptionsWarmer.tsx @@ -1,3 +1,4 @@ +import useAllVisibleReportActions from '@hooks/useAllVisibleReportActions'; import useFilteredOptions from '@hooks/useFilteredOptions'; import useOnyx from '@hooks/useOnyx'; import {useAllPersonalDetails} from '@hooks/usePersonalDetails'; @@ -38,7 +39,7 @@ function SearchRouterOptionsWarmer({onDone}: SearchRouterOptionsWarmerProps) { const reportAttributes = useAllReportAttributes(); const sortedReportActionsData = useSortedReportActionsData(); const [allPolicyTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS); - const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); + const visibleReportActionsData = useAllVisibleReportActions(); const churnVersionRef = useRef(0); useEffect(() => { diff --git a/src/hooks/useAllVisibleReportActions.ts b/src/hooks/useAllVisibleReportActions.ts new file mode 100644 index 000000000000..75aca5ca316d --- /dev/null +++ b/src/hooks/useAllVisibleReportActions.ts @@ -0,0 +1,23 @@ +import ONYXKEYS from '@src/ONYXKEYS'; +import type {VisibleReportActions} from '@src/types/onyx'; + +import {getAllVisibleReportActions} from '@selectors/VisibleReportActions'; +import {useOnyxState} from 'react-native-onyx'; + +const dependencies = [ONYXKEYS.COLLECTION.REPORT_ACTIONS, ONYXKEYS.COLLECTION.REPORT, ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, ONYXKEYS.SESSION] as const; + +/** The cached visibility of every report's actions, keyed by reportID. Reads live data, also inside the Search scope. */ +function useAllVisibleReportActions(): VisibleReportActions { + return useOnyxState( + (state) => + getAllVisibleReportActions( + state[ONYXKEYS.COLLECTION.REPORT_ACTIONS], + state[ONYXKEYS.COLLECTION.REPORT], + state[ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS], + state[ONYXKEYS.SESSION]?.accountID, + ), + {dependencies}, + ); +} + +export default useAllVisibleReportActions; diff --git a/src/hooks/useAutocompleteSuggestions.ts b/src/hooks/useAutocompleteSuggestions.ts index aad9b76b914c..71dada1e12f0 100644 --- a/src/hooks/useAutocompleteSuggestions.ts +++ b/src/hooks/useAutocompleteSuggestions.ts @@ -24,8 +24,8 @@ import {getDatePresets, getHasOptions} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {CardFeeds, CardList, PersonalDetailsList, Policy} from '@src/types/onyx'; -import type {ReportAttributesDerivedValue, VisibleReportActionsDerivedValue} from '@src/types/onyx/DerivedValues'; +import type {CardFeeds, CardList, PersonalDetailsList, Policy, VisibleReportActions} from '@src/types/onyx'; +import type {ReportAttributesDerivedValue} from '@src/types/onyx/DerivedValues'; import type {Icon} from '@src/types/onyx/OnyxCommon'; import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; import getEmptyArray from '@src/types/utils/getEmptyArray'; @@ -64,7 +64,7 @@ type UseAutocompleteSuggestionsParams = { countryCode: OnyxEntry; loginList: OnyxEntry>; policies: NonNullable>; - visibleReportActionsData?: VisibleReportActionsDerivedValue; + visibleReportActionsData?: VisibleReportActions; reportAttributesDerived?: ReportAttributesDerivedValue['reports']; currentUserAccountID: number; currentUserEmail: string; diff --git a/src/hooks/useReportActionsVisibility.ts b/src/hooks/useReportActionsVisibility.ts index 0ec24550afc0..f45bf828334e 100644 --- a/src/hooks/useReportActionsVisibility.ts +++ b/src/hooks/useReportActionsVisibility.ts @@ -6,8 +6,6 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {ReportAction} from '@src/types/onyx'; -import {reportVisibleActionsSelector} from '@selectors/ReportAction'; - import useConciergeSidePanelReportActions from './useConciergeSidePanelReportActions'; import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; import useIsInSidePanel from './useIsInSidePanel'; @@ -16,6 +14,7 @@ import useNetwork from './useNetwork'; import useOnyx from './useOnyx'; import useSidePanelState from './useSidePanelState'; import useTransactionsAndViolationsForReport from './useTransactionsAndViolationsForReport'; +import useVisibleReportActions from './useVisibleReportActions'; type UseReportActionsVisibilityParams = { reportID: string | undefined; @@ -53,9 +52,7 @@ function useReportActionsVisibility({ const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); - const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS, { - selector: reportVisibleActionsSelector(reportID), - }); + const visibleReportActionsData = useVisibleReportActions(reportID); const isInSidePanel = useIsInSidePanel(); const isConciergeSidePanel = isInSidePanel && isConciergeChatReport(report, conciergeReportID); diff --git a/src/hooks/useSearchSelector/base.ts b/src/hooks/useSearchSelector/base.ts index 9fb899b11c18..bc636f15cdd1 100644 --- a/src/hooks/useSearchSelector/base.ts +++ b/src/hooks/useSearchSelector/base.ts @@ -1,5 +1,6 @@ import {usePersonalDetails} from '@components/OnyxListItemProvider'; +import useAllVisibleReportActions from '@hooks/useAllVisibleReportActions'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDebounce from '@hooks/useDebounce'; @@ -202,7 +203,7 @@ function useSearchSelectorBase({ const [loginList] = useOnyx(ONYXKEYS.LOGINS, {selector: expensifyLoginsSelector}); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [draftComments] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT); - const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); + const visibleReportActionsData = useAllVisibleReportActions(); const sortedReportActionsData = useSortedReportActionsData(); const sortedActions = sortedReportActionsData?.sortedActions; const transactionThreadIDs = sortedReportActionsData?.transactionThreadIDs; diff --git a/src/hooks/useVisibleReportActions.ts b/src/hooks/useVisibleReportActions.ts new file mode 100644 index 000000000000..ac2291b2b2f1 --- /dev/null +++ b/src/hooks/useVisibleReportActions.ts @@ -0,0 +1,38 @@ +import ONYXKEYS from '@src/ONYXKEYS'; +import type {VisibleReportActions} from '@src/types/onyx'; + +import {getVisibleReportActionsForReports} from '@selectors/VisibleReportActions'; +import {useOnyxState} from 'react-native-onyx'; + +/** + * The cached visibility of one report's actions, plus its transaction thread's when given, in the `{[reportID]: visibility}` + * shape `isReportActionVisible` expects. It depends only on those reports' actions, ticket state and the user, so writes to other reports don't re-run it. + */ +function useVisibleReportActions(reportID: string | undefined, transactionThreadReportID?: string): VisibleReportActions | undefined { + const reportIDs = [reportID, transactionThreadReportID].filter((id): id is string => !!id); + + return useOnyxState( + (state) => + getVisibleReportActionsForReports( + reportIDs.map((id) => ({ + reportID: id, + reportActions: state[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${id}`], + report: state[`${ONYXKEYS.COLLECTION.REPORT}${id}`], + reportNameValuePairs: state[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${id}`], + })), + state[ONYXKEYS.SESSION]?.accountID, + ), + { + dependencies: [ + ...reportIDs.flatMap((id) => [ + `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${id}` as const, + `${ONYXKEYS.COLLECTION.REPORT}${id}` as const, + `${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${id}` as const, + ]), + ONYXKEYS.SESSION, + ], + }, + ); +} + +export default useVisibleReportActions; diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index f14298dfb227..c5b96659c314 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -91,7 +91,7 @@ import type { ReportAction, ReportAttributesDerivedValue, Rule, - VisibleReportActionsDerivedValue, + VisibleReportActions, } from '@src/types/onyx'; import type {Participant} from '@src/types/onyx/IOU'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -217,7 +217,7 @@ type GetAlternateTextConfig = { // We'll make it required in the next PR. Ref: https://github.com/Expensify/App/issues/66415 policy?: OnyxEntry; invoiceReceiverPolicy?: OnyxEntry; - visibleReportActionsData?: VisibleReportActionsDerivedValue; + visibleReportActionsData?: VisibleReportActions; translate?: LocalizedTranslate; reportAttributesDerived?: ReportAttributesDerivedValue['reports']; policyTags?: OnyxEntry; @@ -382,7 +382,7 @@ type CreateOptionParams = { config?: PreviewConfig; reportAttributesDerived?: ReportAttributesDerivedValue['reports']; policyTags?: OnyxEntry; - visibleReportActionsData?: VisibleReportActionsDerivedValue; + visibleReportActionsData?: VisibleReportActions; translate?: LocalizedTranslate; convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; convertToDisplayStringWithoutCurrency?: CurrencyListActionsContextType['convertToDisplayStringWithoutCurrency']; @@ -704,7 +704,7 @@ type GetReportDisplayOptionParams = { currentUserAccountID: number; reportAttributesDerived?: ReportAttributesDerivedValue['reports']; policyTags?: OnyxEntry; - visibleReportActionsData?: VisibleReportActionsDerivedValue; + visibleReportActionsData?: VisibleReportActions; convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; formatPhoneNumber: LocaleContextProps['formatPhoneNumber']; pendingDeleteMemberAccountIDs: string[] | undefined; @@ -799,7 +799,7 @@ function getPolicyExpenseReportOption( rules: OnyxCollection, reportAttributesDerived?: ReportAttributesDerivedValue['reports'], policyTags?: OnyxEntry, - visibleReportActionsData: VisibleReportActionsDerivedValue = {}, + visibleReportActionsData: VisibleReportActions = {}, ): SearchOptionData { const {translate, dateFnsLocale, convertToDisplayString, formatPhoneNumber} = localize; const visibleParticipantAccountIDs = Object.entries(expenseReport?.participants ?? {}) @@ -964,7 +964,7 @@ function processReport( convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; reportAttributesDerived?: ReportAttributesDerivedValue['reports']; policyTags?: OnyxEntry; - visibleReportActionsData?: VisibleReportActionsDerivedValue; + visibleReportActionsData?: VisibleReportActions; isTrackIntentUser?: boolean; // TODO: Remove optional (?) once all callers pass sortedActions. Refactor issue: https://github.com/Expensify/App/issues/66381 sortedActions?: Record; @@ -1043,7 +1043,7 @@ const reportSortComparator = (report: Report, privateIsArchivedMap: PrivateIsArc /** Builds an option list with pre-filtered reports and lazy contact shells. */ // Stable default used by the cache when no report-action data is provided. -const EMPTY_VISIBLE_REPORT_ACTIONS: VisibleReportActionsDerivedValue = {}; +const EMPTY_VISIBLE_REPORT_ACTIONS: VisibleReportActions = {}; // Each configuration gets its own entry and is reused while its Onyx inputs are unchanged. const filteredOptionListCache = new Map(); @@ -1236,7 +1236,7 @@ function createFilteredOptionList( }, rules: OnyxCollection, policyTags?: OnyxCollection, - visibleReportActionsData: VisibleReportActionsDerivedValue = EMPTY_VISIBLE_REPORT_ACTIONS, + visibleReportActionsData: VisibleReportActions = EMPTY_VISIBLE_REPORT_ACTIONS, isTrackIntentUser?: boolean, // TODO: Remove optional (?) once all callers pass sortedActions. Refactor issue: https://github.com/Expensify/App/issues/66381 sortedActions?: Record, @@ -1461,7 +1461,7 @@ type CreateOptionFromReportParams = { reportAttributesDerived?: ReportAttributesDerivedValue['reports']; config?: PreviewConfig; policyTags?: OnyxEntry; - visibleReportActionsData?: VisibleReportActionsDerivedValue; + visibleReportActionsData?: VisibleReportActions; isTrackIntentUser?: boolean; convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; convertToDisplayStringWithoutCurrency?: CurrencyListActionsContextType['convertToDisplayStringWithoutCurrency']; @@ -1850,7 +1850,7 @@ function getUserToInviteOption({ visibleReportActionsData = {}, rules, formatPhoneNumber, -}: GetUserToInviteConfig & {visibleReportActionsData?: VisibleReportActionsDerivedValue; dateFnsLocale: DateFnsLocale | undefined; rules: OnyxCollection}): SearchOptionData | null { +}: GetUserToInviteConfig & {visibleReportActionsData?: VisibleReportActions; dateFnsLocale: DateFnsLocale | undefined; rules: OnyxCollection}): SearchOptionData | null { if (!searchValue) { return null; } @@ -2174,7 +2174,7 @@ function prepareReportOptionsForDisplay( conciergeReportID: string | undefined, sortedActions: Record | undefined, rules: OnyxCollection, - visibleReportActionsData: VisibleReportActionsDerivedValue = EMPTY_VISIBLE_REPORT_ACTIONS, + visibleReportActionsData: VisibleReportActions = EMPTY_VISIBLE_REPORT_ACTIONS, reportAttributesDerived?: ReportAttributesDerivedValue['reports'], policyTags?: OnyxCollection, isTrackIntentUser?: boolean, @@ -2757,7 +2757,7 @@ type SearchOptionsConfig = { shouldShowGBR?: boolean; shouldUnreadBeBold?: boolean; loginList: OnyxEntry; - visibleReportActionsData?: VisibleReportActionsDerivedValue; + visibleReportActionsData?: VisibleReportActions; policyCollection: OnyxCollection; currentUserAccountID: number; currentUserEmail: string; diff --git a/src/libs/OptionsListUtils/types.ts b/src/libs/OptionsListUtils/types.ts index ad09277a1994..4a08e82f6cb6 100644 --- a/src/libs/OptionsListUtils/types.ts +++ b/src/libs/OptionsListUtils/types.ts @@ -19,7 +19,7 @@ import type { ReportActions, ReportAttributesDerivedValue, TransactionViolation, - VisibleReportActionsDerivedValue, + VisibleReportActions, } from '@src/types/onyx'; import type {Icon, PendingAction} from '@src/types/onyx/OnyxCommon'; @@ -108,7 +108,7 @@ type LazyHydrationContext = { policiesCollection: OnyxCollection; reportAttributesDerived: ReportAttributesDerivedValue['reports'] | undefined; policyTags: OnyxCollection; - visibleReportActionsData: VisibleReportActionsDerivedValue; + visibleReportActionsData: VisibleReportActions; privateIsArchivedMap: PrivateIsArchivedMap; conciergeReportID: string | undefined; currentUserAccountID: number; @@ -292,7 +292,7 @@ type GetOptionsConfig = { includeUserToInvite?: boolean; shouldAcceptName?: boolean; countryCode?: number; - visibleReportActionsData?: VisibleReportActionsDerivedValue; + visibleReportActionsData?: VisibleReportActions; reportAttributesDerived?: ReportAttributesDerivedValue['reports']; sortedActions?: Record; transactionThreadIDs?: Record; diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 4fd448d0034d..1561225447a0 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -24,7 +24,7 @@ import type { ReportMetadata, ReportNameValuePairs, Transaction, - VisibleReportActionsDerivedValue, + VisibleReportActions, } from '@src/types/onyx'; import type { DecisionName, @@ -1809,14 +1809,14 @@ function shouldHideNewMarker(reportAction: OnyxEntry, isOffline: b } /** - * Checks if a report action is visible using the pre-computed derived value when available, + * Checks if a report action is visible using the cached visibility when available, * falling back to runtime calculation if not. */ function isReportActionVisible( reportAction: OnyxEntry, reportID: string | undefined, canUserPerformWriteAction?: boolean, - visibleReportActions?: VisibleReportActionsDerivedValue, + visibleReportActions?: VisibleReportActions, // TODO: Remove optional (?) once all callers pass currentUserAccountID. Refactor issue: https://github.com/Expensify/App/issues/66408 currentUserAccountID?: number, reportActions?: OnyxEntry, @@ -1840,7 +1840,7 @@ function isReportActionVisible( return shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canUserPerformWriteAction, currentUserAccountID, reportID, isSupportTicketReport, reportActions); } const staticVisibility = reportCache[reportAction.reportActionID]; - // If action is not in derived value cache, fall back to runtime calculation + // If action is not in the visibility cache, fall back to runtime calculation if (staticVisibility === undefined) { return shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canUserPerformWriteAction, currentUserAccountID, reportID, isSupportTicketReport, reportActions); } @@ -1856,13 +1856,13 @@ function isReportActionVisible( } /** - * Checks if a report action is visible as last action using the pre-computed derived value when available, + * Checks if a report action is visible as last action using the cached visibility when available, * falling back to runtime calculation if not. */ function isReportActionVisibleAsLastAction( reportAction: OnyxInputOrEntry, canUserPerformWriteAction?: boolean, - visibleReportActions?: VisibleReportActionsDerivedValue, + visibleReportActions?: VisibleReportActions, reportID?: string, // TODO: Remove optional (?) once all callers pass currentUserAccountID. Refactor issue: https://github.com/Expensify/App/issues/66408 currentUserAccountID?: number, @@ -1920,7 +1920,7 @@ function getLastVisibleAction( canUserPerformWriteAction?: boolean, actionsToMerge: Record | null> = {}, reportActionsParam: OnyxCollection = allReportActions, - visibleReportActionsData?: VisibleReportActionsDerivedValue, + visibleReportActionsData?: VisibleReportActions, // TODO: Remove optional (?) once all callers pass currentUserAccountID. Refactor issue: https://github.com/Expensify/App/issues/66408 currentUserAccountID?: number, ): OnyxEntry { @@ -1954,7 +1954,7 @@ function getLastVisibleActionIncludingTransactionThread( reportID: string | undefined, canUserPerformWriteAction?: boolean, reportActionsParam: OnyxCollection = allReportActions, - visibleReportActionsData?: VisibleReportActionsDerivedValue, + visibleReportActionsData?: VisibleReportActions, transactionThreadReportID?: string, // TODO: Remove optional (?) once all callers pass currentUserAccountID. Refactor issue: https://github.com/Expensify/App/issues/66408 currentUserAccountID?: number, @@ -2000,7 +2000,7 @@ function getLastVisibleMessage( canUserPerformWriteAction?: boolean, actionsToMerge: Record | null> = {}, reportAction: OnyxInputOrEntry | undefined = undefined, - visibleReportActionsData?: VisibleReportActionsDerivedValue, + visibleReportActionsData?: VisibleReportActions, // TODO: Remove optional (?) once all callers pass currentUserAccountID. Refactor issue: https://github.com/Expensify/App/issues/66408 currentUserAccountID?: number, ): LastVisibleMessage { @@ -2108,7 +2108,7 @@ function getSortedReportActionsForDisplay( reportActions: OnyxEntry | ReportAction[], canUserPerformWriteAction?: boolean, shouldIncludeInvisibleActions = false, - visibleReportActionsData?: VisibleReportActionsDerivedValue, + visibleReportActionsData?: VisibleReportActions, reportID?: string, ): ReportAction[] { let filteredReportActions: ReportAction[] = []; diff --git a/src/libs/ReportAlternateTextUtils.ts b/src/libs/ReportAlternateTextUtils.ts index a461127bf2d5..aa7c95e7341b 100644 --- a/src/libs/ReportAlternateTextUtils.ts +++ b/src/libs/ReportAlternateTextUtils.ts @@ -18,7 +18,7 @@ import type { ReportMetadata, Rule, Transaction, - VisibleReportActionsDerivedValue, + VisibleReportActions, WorkspaceCardsList, } from '@src/types/onyx'; import type {ReportNamesDerivedValue} from '@src/types/onyx/DerivedValues'; @@ -382,7 +382,7 @@ function getLatestVisibleMoneyRequestAction( reportID: string, canUserPerformWrite: boolean | undefined, sortedReportActions: ReportAction[] = [], - visibleReportActionsData?: VisibleReportActionsDerivedValue, + visibleReportActionsData?: VisibleReportActions, ): OnyxEntry> { return sortedReportActions.find( (reportAction): reportAction is ReportAction => @@ -425,7 +425,7 @@ type LastActionContext = { function resolveLastActionContext( report: Report, isReportArchived: boolean | undefined, - visibleReportActionsData: VisibleReportActionsDerivedValue | undefined, + visibleReportActionsData: VisibleReportActions | undefined, oneTransactionThreadReportID?: string, ): LastActionContext { const canUserPerformWrite = canUserPerformWriteAction(report, isReportArchived); @@ -452,7 +452,7 @@ function getLastActorDisplayNameFromLastVisibleActions( personalDetails: OnyxEntry, privateIsArchived: boolean | undefined, translate: LocalizedTranslate, - visibleReportActionsData?: VisibleReportActionsDerivedValue, + visibleReportActionsData?: VisibleReportActions, lastAction?: OnyxEntry, oneTransactionThreadReportID?: string, ): string { @@ -522,7 +522,7 @@ function getLastMessageTextForReport({ isReportArchived?: boolean; policyForMovingExpensesID?: string; reportMetadata?: OnyxEntry; - visibleReportActionsDataParam?: VisibleReportActionsDerivedValue; + visibleReportActionsDataParam?: VisibleReportActions; lastAction?: OnyxEntry; reportAttributesDerived?: ReportNamesDerivedValue; policyTags?: OnyxEntry; @@ -1229,7 +1229,7 @@ type GetReportAlternateTextParams = { privateIsArchived: boolean; conciergeReportID: string | undefined; reportAttributesDerived?: ReportNamesDerivedValue; - visibleReportActionsData?: VisibleReportActionsDerivedValue; + visibleReportActionsData?: VisibleReportActions; sortedActions?: Record; oneTransactionThreadReportID?: string; lastOriginalAction?: OnyxEntry; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 46fcf05da0bd..a9324bd9c160 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -55,7 +55,7 @@ import type { TransactionViolation, TransactionViolations, ViolationName, - VisibleReportActionsDerivedValue, + VisibleReportActions, } from '@src/types/onyx'; import type {ReportTransactionsAndViolations} from '@src/types/onyx/DerivedValues'; import type {Attendee, Participant} from '@src/types/onyx/IOU'; @@ -10864,12 +10864,7 @@ function isReportNotFound(report: OnyxEntry): boolean { /** * Check if the report is the parent report of the currently viewed report or at least one child report has report action */ -function shouldHideReport( - report: OnyxEntry, - currentReportId: string | undefined, - isReportArchived: boolean | undefined, - visibleReportActionsData?: VisibleReportActionsDerivedValue, -): boolean { +function shouldHideReport(report: OnyxEntry, currentReportId: string | undefined, isReportArchived: boolean | undefined, visibleReportActionsData?: VisibleReportActions): boolean { const currentReport = getReportOrDraftReport(currentReportId); const parentReport = getParentReport(!isEmptyObject(currentReport) ? currentReport : undefined); const reportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.reportID}`] ?? {}; @@ -12142,7 +12137,7 @@ function navigateToLinkedReportAction( isInNarrowPaneModal: boolean, canUserPerformWrite: boolean | undefined, isOffline: boolean, - visibleReportActionsData?: VisibleReportActionsDerivedValue, + visibleReportActionsData?: VisibleReportActions, ) { if (isInNarrowPaneModal) { const rootState = navigationRef.current?.getRootState(); diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 6596fc8ee53b..9bdf1c21fb40 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -659,7 +659,7 @@ type GetSectionsParams = { cardList?: OnyxEntry; nonPersonalAndWorkspaceCardList?: OnyxEntry; customCardNames?: Record; - visibleReportActionsData?: OnyxTypes.VisibleReportActionsDerivedValue; + visibleReportActionsData?: OnyxTypes.VisibleReportActions; conciergeReportID: string | undefined; onyxPersonalDetailsList?: OnyxTypes.PersonalDetailsList; isAttendeesEnabledForMovingPolicy?: boolean; @@ -2673,7 +2673,7 @@ function createAndOpenSearchTransactionThread({ function getReportActionsSections( data: OnyxTypes.SearchResults['data'], reportAttributesDerivedValue: OnyxTypes.ReportAttributesDerivedValue['reports'] | undefined, - visibleReportActionsData?: OnyxTypes.VisibleReportActionsDerivedValue, + visibleReportActionsData?: OnyxTypes.VisibleReportActions, ): [ReportActionListItemType[], number] { const reportActionItems: ReportActionListItemType[] = []; diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 7b1135018262..0675a9877b86 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -18,7 +18,7 @@ import type { ReportNameValuePairs, Transaction, TransactionViolation, - VisibleReportActionsDerivedValue, + VisibleReportActions, } from '@src/types/onyx'; import type {ReportAttributes, ReportNamesDerivedValue} from '@src/types/onyx/DerivedValues'; import type {Errors} from '@src/types/onyx/OnyxCommon'; @@ -795,7 +795,7 @@ function getOptionData({ movedFromReport?: OnyxEntry; movedToReport?: OnyxEntry; currentUserAccountID: number; - visibleReportActionsData?: VisibleReportActionsDerivedValue; + visibleReportActionsData?: VisibleReportActions; reportAttributesDerived?: ReportNamesDerivedValue; policyTags?: OnyxEntry; currentUserLogin: string; diff --git a/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts b/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts index 53ea8a0ea641..a215d839925d 100644 --- a/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts +++ b/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts @@ -14,7 +14,6 @@ import personalAndWorkspaceCardListConfig from './configs/personalAndWorkspaceCa import reportTransactionsAndViolationsConfig from './configs/reportTransactionsAndViolations'; import sortedReportActionsConfig from './configs/sortedReportActions'; import spendDataSignatureConfig from './configs/spendDataSignature'; -import visibleReportActionsConfig from './configs/visibleReportActions'; /** * Global map of derived configs. @@ -23,7 +22,6 @@ import visibleReportActionsConfig from './configs/visibleReportActions'; 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, diff --git a/src/libs/actions/OnyxDerived/configs/visibleReportActions.ts b/src/libs/actions/OnyxDerived/configs/visibleReportActions.ts deleted file mode 100644 index 9d6173058431..000000000000 --- a/src/libs/actions/OnyxDerived/configs/visibleReportActions.ts +++ /dev/null @@ -1,106 +0,0 @@ -import {isActionableWhisperRequiringWritePermission, isActionOfType, isConciergeCategoryOptions, shouldReportActionBeVisible} from '@libs/ReportActionsUtils'; -import {isSupportTicket} from '@libs/ReportUtils'; - -import createOnyxDerivedValueConfig from '@userActions/OnyxDerived/createOnyxDerivedValueConfig'; - -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {ReportAction, ReportActions} from '@src/types/onyx'; -import type {VisibleReportActionsDerivedValue} from '@src/types/onyx/DerivedValues'; - -/** - * Returns true if the action's visibility depends on runtime context that can't be cached, - * such as write permissions, policy settings, or sibling actions. - */ -function shouldSkipCachingAction(action: ReportAction): boolean { - return isActionableWhisperRequiringWritePermission(action) || isConciergeCategoryOptions(action) || isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED); -} - -/** - * Builds a report's action-visibility map (keyed by `reportActionID`) from its full set of report - * actions. Rebuilding the whole map rather than updating individual entries keeps deletions correct: - * a removed action is absent from `reportActions`, so it drops out of the result. - */ -function computeReportVisibility( - reportActions: ReportActions, - currentUserAccountID: number | undefined, - isSupportTicketReport: boolean, - isReplacedSupportTicket: boolean, -): Record { - const reportVisibility: Record = {}; - - for (const [actionID, action] of Object.entries(reportActions)) { - if (!action) { - continue; - } - // Skip deprecated keys (e.g. sequenceNumber-keyed duplicates) so they - // cannot overwrite the canonical entry's visibility with false. - if (actionID !== action.reportActionID) { - continue; - } - if (shouldSkipCachingAction(action)) { - continue; - } - // A replacement ticket has its own active survey, so the original ticket's survey is stale. - if (isReplacedSupportTicket && isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.SUPPORT_SURVEY)) { - reportVisibility[action.reportActionID] = false; - continue; - } - reportVisibility[action.reportActionID] = shouldReportActionBeVisible(action, actionID, undefined, currentUserAccountID, undefined, isSupportTicketReport); - } - - return reportVisibility; -} - -export default createOnyxDerivedValueConfig({ - key: ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS, - // Note: REPORT dependency is needed both to trigger recompute when reports change - // (for UNREPORTED_TRANSACTION/MOVED_TRANSACTION visibility) AND to provide the current - // report collection to the visibility check, avoiding stale data from global connections. - // SESSION dependency is needed for whisper targeting when user changes. - dependencies: [ONYXKEYS.COLLECTION.REPORT_ACTIONS, ONYXKEYS.SESSION, ONYXKEYS.COLLECTION.REPORT, ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS], - compute: ([allReportActions, session, allReports, allReportNameValuePairs], {sourceValues, currentValue, triggeredKeys}): VisibleReportActionsDerivedValue => { - if (!allReportActions) { - return {}; - } - - const currentUserAccountID = session?.accountID; - const reportActionsUpdates = sourceValues?.[ONYXKEYS.COLLECTION.REPORT_ACTIONS]; - const reportNameValuePairsUpdates = sourceValues?.[ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS]; - - // Recompute only the reports whose actions or NVPs changed when we have a usable delta. - // Otherwise recompute everything: on first load, when there's no delta, or on a SESSION or - // REPORT change. SESSION is checked via triggeredKeys, not sourceValues, so a session cleared - // to `undefined` still forces the full recompute. - const isIncremental = - !!(reportActionsUpdates ?? reportNameValuePairsUpdates) && !triggeredKeys?.has(ONYXKEYS.SESSION) && !triggeredKeys?.has(ONYXKEYS.COLLECTION.REPORT) && !!currentValue; - - const result: VisibleReportActionsDerivedValue = isIncremental ? {...currentValue} : {}; - const reportIDsToProcess = isIncremental - ? new Set([ - ...Object.keys(reportActionsUpdates ?? {}).map((key) => key.replace(ONYXKEYS.COLLECTION.REPORT_ACTIONS, '')), - ...Object.keys(reportNameValuePairsUpdates ?? {}) - .map((key) => key.replace(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, '')) - .filter((reportID) => isSupportTicket(allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`])), - ]) - : new Set(Object.keys(allReportActions).map((key) => key.replace(ONYXKEYS.COLLECTION.REPORT_ACTIONS, ''))); - - for (const reportID of reportIDsToProcess) { - const reportActionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`; - const reportActions = allReportActions[reportActionsKey]; - - // The member was removed entirely — drop the report from the result. - if (!reportActions) { - delete result[reportID]; - continue; - } - - const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; - const isSupportTicketReport = isSupportTicket(report); - const isReplacedSupportTicket = isSupportTicketReport && !!allReportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`]?.reopenedAsReportID; - result[reportID] = computeReportVisibility(reportActions, currentUserAccountID, isSupportTicketReport, isReplacedSupportTicket); - } - - return result; - }, -}); diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index 7ba98ad786b7..7068e5549168 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -263,7 +263,7 @@ import type { Transaction, TransactionViolation, TransactionViolations, - VisibleReportActionsDerivedValue, + VisibleReportActions, } from '@src/types/onyx'; import type {DeferredAttachmentEdit} from '@src/types/onyx/DeferredAttachmentEdits'; import type {Decision} from '@src/types/onyx/OriginalMessage'; @@ -3526,7 +3526,7 @@ type DeleteReportCommentParams = { currentEmail: string; isOffline: boolean; currentUserAccountID: number; - visibleReportActionsData?: VisibleReportActionsDerivedValue; + visibleReportActionsData?: VisibleReportActions; }; /** Deletes a comment from the report, basically sets it as empty string */ diff --git a/src/pages/Share/ShareTab.tsx b/src/pages/Share/ShareTab.tsx index 87425214eadb..5d1322dda586 100644 --- a/src/pages/Share/ShareTab.tsx +++ b/src/pages/Share/ShareTab.tsx @@ -4,6 +4,7 @@ import SelectionList from '@components/SelectionList'; import InviteMemberListItem from '@components/SelectionList/ListItem/InviteMemberListItem'; import Text from '@components/Text'; +import useAllVisibleReportActions from '@hooks/useAllVisibleReportActions'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDebouncedState from '@hooks/useDebouncedState'; @@ -53,7 +54,7 @@ function ShareTab() { const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const {activePolicyID} = useActivePolicyContext(); const [draftComments] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT); - const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); + const visibleReportActionsData = useAllVisibleReportActions(); const sortedReportActionsData = useSortedReportActionsData(); const sortedActions = sortedReportActionsData?.sortedActions; const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); diff --git a/src/pages/inbox/LinkedActionNotFoundGuard.tsx b/src/pages/inbox/LinkedActionNotFoundGuard.tsx index f2445dc94b4c..d7e2910c83ca 100644 --- a/src/pages/inbox/LinkedActionNotFoundGuard.tsx +++ b/src/pages/inbox/LinkedActionNotFoundGuard.tsx @@ -1,6 +1,7 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useOnyx from '@hooks/useOnyx'; import useReportIsArchived from '@hooks/useReportIsArchived'; +import useVisibleReportActions from '@hooks/useVisibleReportActions'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Navigation from '@libs/Navigation/Navigation'; @@ -65,12 +66,12 @@ function LinkedActionNotFoundGate({reportActionIDFromRoute}: LinkedActionNotFoun const [linkedAction] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportIDFromRoute}`, { selector: (actions: OnyxEntry) => getReportActionByIDSelector(actions, reportActionIDFromRoute), }); - const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); const isReportArchived = useReportIsArchived(reportIDFromRoute); // --- Linked action status --- const actionReportID = linkedAction?.reportID ?? reportIDFromRoute; + const visibleReportActionsData = useVisibleReportActions(actionReportID); const hasNoActionReportID = !!linkedAction && !actionReportID; const isActionHidden = !!linkedAction && !!actionReportID && !isReportActionVisible(linkedAction, actionReportID, canUserPerformWriteAction(report, isReportArchived), visibleReportActionsData); diff --git a/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx b/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx index 763bd3dc7bac..8f4b1add05cb 100644 --- a/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx +++ b/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx @@ -17,6 +17,7 @@ import useParentReportAction from '@hooks/useParentReportAction'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; +import useVisibleReportActions from '@hooks/useVisibleReportActions'; import {deleteTrackExpense} from '@libs/actions/IOU/TrackExpense'; import {deleteAppReport, deleteReportComment} from '@libs/actions/Report'; @@ -101,7 +102,6 @@ function PopoverReportActionContextMenu({ref}: PopoverReportActionContextMenuPro const [shouldSwitchPositionIfOverflow, setShouldSwitchPositionIfOverflow] = useState(false); const [isWithoutOverlay, setIsWithoutOverlay] = useState(true); const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); - const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); const contentRef = useRef>(null); const anchorRef = useRef | HTMLDivElement | null>(null); @@ -349,6 +349,7 @@ function PopoverReportActionContextMenu({ref}: PopoverReportActionContextMenuPro const [iouTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionIDs.at(0) ?? ''}`); const [iouOriginalTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(iouTransaction?.comment?.originalTransactionID)}`); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportIDRef.current}`); + const visibleReportActionsData = useVisibleReportActions(report?.reportID); const [childReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportActionRef.current?.childReportID}`); const [childReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${childReport?.reportID}`); const [selfDMReportID] = useOnyx(ONYXKEYS.SELF_DM_REPORT_ID); @@ -440,7 +441,7 @@ function PopoverReportActionContextMenu({ref}: PopoverReportActionContextMenuPro currentEmail: email ?? '', isOffline, currentUserAccountID, - visibleReportActionsData: visibleReportActionsData ?? undefined, + visibleReportActionsData, }); }; } diff --git a/src/selectors/ReportAction.ts b/src/selectors/ReportAction.ts index c27d0c5824a4..9a45d1a0d234 100644 --- a/src/selectors/ReportAction.ts +++ b/src/selectors/ReportAction.ts @@ -12,7 +12,6 @@ import {hasReportBeenForwardedSinceLastSubmit} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {ReportAction, ReportActions} from '@src/types/onyx'; -import type {VisibleReportActionsDerivedValue} from '@src/types/onyx/DerivedValues'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; @@ -20,14 +19,6 @@ import lodashFindLast from 'lodash/findLast'; type NewestReportAction = Pick; -/** - * Scopes VISIBLE_REPORT_ACTIONS to one report, pre-wrapped in the `{[reportID]: slice}` shape - * `isReportActionVisible` expects. Built here (not inline in the hook) so the consumer has no computed-key - * literal, which the React Compiler won't memoize; `useOnyx` returns a stable ref while the slice is unchanged. - */ -const reportVisibleActionsSelector = (reportID: string | undefined) => (data: VisibleReportActionsDerivedValue | undefined) => - reportID && data?.[reportID] ? {[reportID]: data[reportID]} : undefined; - function getParentReportActionSelector(parentReportActions: OnyxEntry, parentReportActionID?: string): OnyxEntry { if (!parentReportActions || !parentReportActionID) { return; @@ -194,7 +185,6 @@ export { getReimbursedExpectedDateSelector, getReportActionByIDSelector, getReceiptScanFailedIOUActionDataSelector, - reportVisibleActionsSelector, forwardedSinceLastSubmitReportIDsSelector, transactionThreadReportIDSelector, getParentReportActionWithoutThreadRepliesSelector, diff --git a/src/selectors/VisibleReportActions.ts b/src/selectors/VisibleReportActions.ts new file mode 100644 index 000000000000..3c7a1470db50 --- /dev/null +++ b/src/selectors/VisibleReportActions.ts @@ -0,0 +1,173 @@ +import {isActionableWhisperRequiringWritePermission, isActionOfType, isConciergeCategoryOptions, shouldReportActionBeVisible} from '@libs/ReportActionsUtils'; +import {isSupportTicket} from '@libs/ReportUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report, ReportAction, ReportActions, ReportNameValuePairs, VisibleReportActions} from '@src/types/onyx'; + +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; + +/** + * Returns true if the action's visibility depends on runtime context that can't be cached, + * such as write permissions, policy settings, or sibling actions. + */ +function shouldSkipCachingAction(action: ReportAction): boolean { + return isActionableWhisperRequiringWritePermission(action) || isConciergeCategoryOptions(action) || isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED); +} + +/** + * Builds a report's action-visibility map (keyed by `reportActionID`) from its full set of report + * actions. Rebuilding the whole map rather than updating individual entries keeps deletions correct: + * a removed action is absent from `reportActions`, so it drops out of the result. + */ +function computeReportVisibility( + reportActions: ReportActions, + currentUserAccountID: number | undefined, + isSupportTicketReport: boolean, + isReplacedSupportTicket: boolean, +): Record { + const reportVisibility: Record = {}; + + for (const [actionID, action] of Object.entries(reportActions)) { + if (!action) { + continue; + } + // Skip deprecated keys (e.g. sequenceNumber-keyed duplicates) so they + // cannot overwrite the canonical entry's visibility with false. + if (actionID !== action.reportActionID) { + continue; + } + if (shouldSkipCachingAction(action)) { + continue; + } + // A replacement ticket has its own active survey, so the original ticket's survey is stale. + if (isReplacedSupportTicket && isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.SUPPORT_SURVEY)) { + reportVisibility[action.reportActionID] = false; + continue; + } + reportVisibility[action.reportActionID] = shouldReportActionBeVisible(action, actionID, undefined, currentUserAccountID, undefined, isSupportTicketReport); + } + + return reportVisibility; +} + +type ReportVisibilityInputs = { + currentUserAccountID: number | undefined; + isSupportTicketReport: boolean; + isReplacedSupportTicket: boolean; +}; + +// Onyx gives a report's actions a new object only when they change, so every consumer shares one compute per report, user and ticket state +const reportVisibilityCache = new WeakMap}>(); + +function isSameReportVisibility(reportVisibility: Record, otherReportVisibility: Record): boolean { + const actionIDs = Object.keys(reportVisibility); + if (actionIDs.length !== Object.keys(otherReportVisibility).length) { + return false; + } + return actionIDs.every((actionID) => reportVisibility[actionID] === otherReportVisibility[actionID]); +} + +function getReportVisibility( + reportActions: ReportActions, + currentUserAccountID: number | undefined, + report: OnyxEntry, + reportNameValuePairs: OnyxEntry, + previousReportVisibility?: Record, +): Record { + const isSupportTicketReport = isSupportTicket(report); + const isReplacedSupportTicket = isSupportTicketReport && !!reportNameValuePairs?.reopenedAsReportID; + const cached = reportVisibilityCache.get(reportActions); + const isCacheHit = + !!cached && + cached.currentUserAccountID === currentUserAccountID && + cached.isSupportTicketReport === isSupportTicketReport && + cached.isReplacedSupportTicket === isReplacedSupportTicket; + let reportVisibility = isCacheHit ? cached.reportVisibility : computeReportVisibility(reportActions, currentUserAccountID, isSupportTicketReport, isReplacedSupportTicket); + if (previousReportVisibility && reportVisibility !== previousReportVisibility && isSameReportVisibility(reportVisibility, previousReportVisibility)) { + reportVisibility = previousReportVisibility; + } + if (!isCacheHit || reportVisibility !== cached.reportVisibility) { + reportVisibilityCache.set(reportActions, {currentUserAccountID, isSupportTicketReport, isReplacedSupportTicket, reportVisibility}); + } + return reportVisibility; +} + +type ReportVisibilityEntry = { + reportID: string; + reportActions: OnyxEntry; + report: OnyxEntry; + reportNameValuePairs: OnyxEntry; +}; + +/** The visibility of the given reports' actions, keyed by reportID. Reports without actions are left out. */ +function getVisibleReportActionsForReports(entries: ReportVisibilityEntry[], currentUserAccountID: number | undefined): VisibleReportActions | undefined { + let result: VisibleReportActions | undefined; + for (const {reportID, reportActions, report, reportNameValuePairs} of entries) { + if (!reportActions) { + continue; + } + result ??= {}; + result[reportID] = getReportVisibility(reportActions, currentUserAccountID, report, reportNameValuePairs); + } + return result; +} + +let lastAllReportActions: OnyxCollection | undefined; +let lastAllReports: OnyxCollection | undefined; +let lastAllReportNameValuePairs: OnyxCollection | undefined; +let lastCurrentUserAccountID: number | undefined; +let lastAllVisibleReportActions: VisibleReportActions | undefined; + +/** + * The visibility of every report's actions, keyed by reportID. Every mounted consumer shares one walk per collection + * snapshot. A report whose visibility didn't change keeps its previous object, and so does the whole map when no report changed. + */ +function getAllVisibleReportActions( + allReportActions: OnyxCollection, + allReports: OnyxCollection, + allReportNameValuePairs: OnyxCollection, + currentUserAccountID: number | undefined, +): VisibleReportActions { + if ( + lastAllVisibleReportActions && + allReportActions === lastAllReportActions && + allReports === lastAllReports && + allReportNameValuePairs === lastAllReportNameValuePairs && + currentUserAccountID === lastCurrentUserAccountID + ) { + return lastAllVisibleReportActions; + } + + const previous = lastAllVisibleReportActions; + const result: VisibleReportActions = {}; + let reportCount = 0; + let isSameAsPrevious = !!previous; + for (const [reportActionsKey, reportActions] of Object.entries(allReportActions ?? {})) { + if (!reportActions) { + continue; + } + const reportID = reportActionsKey.replace(ONYXKEYS.COLLECTION.REPORT_ACTIONS, ''); + const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + const reportNameValuePairs = allReportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`]; + const reportVisibility = getReportVisibility(reportActions, currentUserAccountID, report, reportNameValuePairs, previous?.[reportID]); + result[reportID] = reportVisibility; + reportCount++; + if (isSameAsPrevious && previous?.[reportID] !== reportVisibility) { + isSameAsPrevious = false; + } + } + + lastAllReportActions = allReportActions; + lastAllReports = allReports; + lastAllReportNameValuePairs = allReportNameValuePairs; + lastCurrentUserAccountID = currentUserAccountID; + if (previous && isSameAsPrevious && Object.keys(previous).length === reportCount) { + return previous; + } + lastAllVisibleReportActions = result; + return result; +} + +export {computeReportVisibility, getAllVisibleReportActions, getVisibleReportActionsForReports}; +export type {ReportVisibilityEntry}; diff --git a/src/types/onyx/DerivedValues.ts b/src/types/onyx/DerivedValues.ts index 2f5b4ab76239..bf5e7b6157e9 100644 --- a/src/types/onyx/DerivedValues.ts +++ b/src/types/onyx/DerivedValues.ts @@ -94,11 +94,6 @@ type ReportTransactionsAndViolationsDerivedValue = Record>; -/** - * The derived value for visible report actions. - */ -type VisibleReportActionsDerivedValue = Record>; - /** * The errors of a card. */ @@ -263,7 +258,6 @@ export type { ReportTransactionsAndViolationsDerivedValue, ReportTransactionsAndViolations, OutstandingReportsByPolicyIDDerivedValue, - VisibleReportActionsDerivedValue, SortedReportActionsDerivedValue, NonPersonalAndWorkspaceCardListDerivedValue, PersonalAndWorkspaceCardListDerivedValue, diff --git a/src/types/onyx/ReportAction.ts b/src/types/onyx/ReportAction.ts index eb30b958c0be..5ea1fc09210b 100644 --- a/src/types/onyx/ReportAction.ts +++ b/src/types/onyx/ReportAction.ts @@ -284,6 +284,9 @@ type ReportAction = ReportActionB /** Record of report actions, indexed by report action ID */ type ReportActions = Record; +/** Cached visibility of report actions, indexed by reportID and then by report action ID */ +type VisibleReportActions = Record>; + /** Collection of mock report actions, indexed by reportActions_${reportID} */ type ReportActionsCollectionDataSet = CollectionDataSet; @@ -309,4 +312,4 @@ type PolicyChangeLogCopyReportActionNames = | typeof CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.COPY_TRAVEL; export default ReportAction; -export type {ReportActions, Message, LinkMetadata, OriginalMessage, ReportActionsCollectionDataSet, OldDotReportAction, PolicyChangeLogCopyReportActionNames}; +export type {ReportActions, VisibleReportActions, Message, LinkMetadata, OriginalMessage, ReportActionsCollectionDataSet, OldDotReportAction, PolicyChangeLogCopyReportActionNames}; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 0c8657d084f6..0360ee12651e 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -74,7 +74,6 @@ import type { ReportAttributesSnapshot, ReportTransactionsAndViolationsDerivedValue, SortedReportActionsDerivedValue, - VisibleReportActionsDerivedValue, } from './DerivedValues'; import type DeviceBiometrics from './DeviceBiometrics'; import type DismissedProductTraining from './DismissedProductTraining'; @@ -192,7 +191,7 @@ import type {RecentSearchItem} from './RecentSearch'; import type RecentWaypoint from './RecentWaypoint'; import type ReimbursementAccount from './ReimbursementAccount'; import type Report from './Report'; -import type {ReportActions} from './ReportAction'; +import type {ReportActions, VisibleReportActions} from './ReportAction'; import type ReportAction from './ReportAction'; import type ReportActionReactions from './ReportActionReactions'; import type ReportActionsDraft from './ReportActionsDraft'; @@ -521,7 +520,7 @@ export type { LastSearchParams, ReportTransactionsAndViolationsDerivedValue, OutstandingReportsByPolicyIDDerivedValue, - VisibleReportActionsDerivedValue, + VisibleReportActions, SortedReportActionsDerivedValue, NonPersonalAndWorkspaceCardListDerivedValue, PersonalAndWorkspaceCardListDerivedValue, diff --git a/tests/navigation/LinkedActionNotFoundGuardTest.tsx b/tests/navigation/LinkedActionNotFoundGuardTest.tsx index bb0bffd09b8c..f9bf19282a4e 100644 --- a/tests/navigation/LinkedActionNotFoundGuardTest.tsx +++ b/tests/navigation/LinkedActionNotFoundGuardTest.tsx @@ -71,7 +71,7 @@ jest.mock('@hooks/useResponsiveLayout', () => ({ let mockIsReportActionVisible = true; // The gate only re-renders when its Onyx subscriptions change, so the useOnyx mock notifies subscribers like Onyx does. -// Each update also replaces DERIVED.VISIBLE_REPORT_ACTIONS, which visibility is derived from. +// Each update also replaces the visible report actions, which visibility is derived from. const mockOnyxListeners = new Set<() => void>(); let mockOnyxVersion = 0; let mockVisibleReportActions: Record = {}; @@ -87,6 +87,11 @@ function updateOnyx(update: () => void) { }); } +jest.mock('@hooks/useVisibleReportActions', () => ({ + __esModule: true, + default: () => mockVisibleReportActions, +})); + jest.mock('@libs/ReportActionsUtils', () => ({ __esModule: true, isReportActionVisible: () => mockIsReportActionVisible, @@ -124,8 +129,7 @@ jest.mock('@hooks/useOnyx', () => { if (key.startsWith('report_')) { return [{reportID: '12345', type: 'chat'}, {status: 'loaded'}]; } - // DERIVED.VISIBLE_REPORT_ACTIONS - return [mockVisibleReportActions, {status: 'loaded'}]; + return [undefined, {status: 'loaded'}]; } return { diff --git a/tests/perf-test/VisibleReportActions.perf-test.tsx b/tests/perf-test/VisibleReportActions.perf-test.tsx new file mode 100644 index 000000000000..5ffb0385f050 --- /dev/null +++ b/tests/perf-test/VisibleReportActions.perf-test.tsx @@ -0,0 +1,279 @@ +import {render, screen} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import OptionRowLHNData from '@components/LHNOptionsList/OptionRowLHN/OptionRowLHNData'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import SearchRouterOptionsWarmer from '@components/Search/SearchRouter/SearchRouterOptionsWarmer'; + +import {setHasRadio} from '@libs/NetworkState'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report, ReportAction, ReportActions} 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 * as LHNTestUtils from '../utils/LHNTestUtils'; +import {getFakeReportAction} from '../utils/ReportTestUtils'; +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(() => ''), +})); + +// The warmer builds the whole option list once its inputs hold still; never building keeps the measurement on its subscriptions +jest.mock('@libs/Scheduler', () => ({ + Scheduler: {scheduleWhenIdle: () => ({cancel: () => {}})}, +})); + +const ROW_COUNT = 50; +const ACTIONS_PER_REPORT = 5; +const LARGE_EXTRA_REPORT_COUNT = 2000; +const WRITES_PER_SCENARIO = 10; +const FIRST_EXTRA_REPORT_ID = 20000; +const UNRELATED_REPORT_ID = '99999'; +const ROW_REPORT_ID = '1'; + +function buildReportActions(reportID: string): ReportActions { + const actions: ReportActions = {}; + for (let index = 0; index < ACTIONS_PER_REPORT; index++) { + const reportActionID = `${reportID}00${index}`; + actions[reportActionID] = getFakeReportAction(index, { + reportActionID, + reportID, + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + created: `2025-01-01 00:00:0${index}.000`, + }); + } + return actions; +} + +function buildComment(reportID: string, reportActionID: string): ReportAction { + return getFakeReportAction(1, {reportActionID, reportID, actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, created: '2025-06-01 00:00:00.000'}); +} + +const rowReports: Report[] = Array.from({length: ROW_COUNT}, (value, index) => ({...LHNTestUtils.getFakeReport([1, 2]), reportID: String(index + 1), lastMessageText: 'hey'})); + +const smallReportActions: Record = {}; +for (const report of rowReports) { + smallReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`] = buildReportActions(report.reportID); +} +smallReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${UNRELATED_REPORT_ID}`] = buildReportActions(UNRELATED_REPORT_ID); + +const largeReportActions: Record = {...smallReportActions}; +for (let index = 0; index < LARGE_EXTRA_REPORT_COUNT; index++) { + const reportID = String(FIRST_EXTRA_REPORT_ID + index); + largeReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`] = buildReportActions(reportID); +} + +/** Passed as the reassure wrapper so the providers sit outside the profiler and render counts are the rows' own */ +function Providers({children}: {children: React.ReactNode}) { + return {children}; +} + +/** LHN rows read their own data, so the list's props can't re-render them */ +function LHNRows() { + return ( + + {rowReports.map((report, index) => ( + + ))} + + ); +} + +/** The Search router's warmer reads the visibility of every report, as the other Search surfaces do */ +function SearchConsumer() { + return ( + + {}} /> + + ); +} + +async function seed(reportActions: Record) { + await Onyx.multiSet({ + [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails, + [ONYXKEYS.IS_LOADING_REPORT_DATA]: false, + ...Object.fromEntries(rowReports.map((report) => [`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report])), + ...reportActions, + }); + await waitForBatchedUpdates(); +} + +async function addComments(reportID: string, label: string) { + for (let index = 0; index < WRITES_PER_SCENARIO; index++) { + const reportActionID = `${label}-${index}`; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {[reportActionID]: buildComment(reportID, reportActionID)}); + await waitForBatchedUpdates(); + } +} + +describe('Visible report actions consumers on report action writes', () => { + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + evictableKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS], + }); + // Required lazily so LHNTestUtils registers its @react-navigation/native mock 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); + setHasRadio(true); + await TestHelper.signInWithTestUser(1, 'email1@test.com', undefined, undefined, 'One'); + await waitForBatchedUpdates(); + }); + + afterEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + test('[VisibleReportActions] should not re-render 50 LHN rows when comments are added to a report without a row', async () => { + // Given 50 LHN rows, 10 of them with a transaction thread + await seed(smallReportActions); + + // Reassure runs the scenario several times against one seeded Onyx, so every run adds actions that don't exist yet + let run = 0; + const scenario = async () => { + await screen.findByTestId('lhn-rows'); + + // When comments are added to a report that has no row + await addComments(UNRELATED_REPORT_ID, `unrelated-${run}`); + run++; + }; + + // Then reassure measures what the writes cost rows that don't show that report + await measureRenders(, {scenario, wrapper: Providers}); + }); + + test('[VisibleReportActions] should re-render only the LHN row whose report gets comments', async () => { + // Given 50 LHN rows + await seed(smallReportActions); + + let run = 0; + const scenario = async () => { + await screen.findByTestId('lhn-rows'); + + // When comments are added to the first row's report, which changes its last visible action + await addComments(ROW_REPORT_ID, `row-${run}`); + run++; + }; + + // Then reassure measures the cost of a real change, which both versions have to pay for that one row + await measureRenders(, {scenario, wrapper: Providers}); + }); + + test('[VisibleReportActions] should not re-render 50 LHN rows when comments are added to a report without a row in a large collection', async () => { + // Given the same rows, with 2000 more reports holding actions + await seed(largeReportActions); + + let run = 0; + const scenario = async () => { + await screen.findByTestId('lhn-rows'); + + // When comments are added to a report that has no row + await addComments(UNRELATED_REPORT_ID, `large-${run}`); + run++; + }; + + // Then reassure measures the render cost, which grows if a row's selector walks the whole visibility map + await measureRenders(, {scenario, wrapper: Providers}); + }); + + test('[VisibleReportActions] should re-render the Search consumer when comments are added in a large collection', async () => { + // Given the Search router's warmer mounted over 2050 reports with actions + await seed(largeReportActions); + + let run = 0; + const scenario = async () => { + await screen.findByTestId('search-consumer'); + + // When comments are added to one report + await addComments(UNRELATED_REPORT_ID, `search-${run}`); + run++; + }; + + // Then reassure measures what a consumer of every report's visibility pays per write + await measureRenders(, {scenario, wrapper: Providers}); + }); + + test('[VisibleReportActions] should process comment writes with only LHN rows mounted in a large collection', async () => { + // Given the LHN rows mounted over 2050 reports with actions, and no consumer of every report's visibility + await seed(largeReportActions); + render( + + + , + ); + await screen.findByTestId('lhn-rows'); + + // When comments are added to a report without a row repeatedly + let run = 0; + const writeComments = async () => { + await addComments(UNRELATED_REPORT_ID, `timed-lhn-${run}`); + run++; + }; + + // Then reassure times the whole write loop, including the derived compute and its write to Onyx and storage, which happen outside renders + await measureAsyncFunction(writeComments); + }); + + test('[VisibleReportActions] should process comment writes with the Search consumer mounted in a large collection', async () => { + // Given the Search router's warmer mounted over 2050 reports with actions + await seed(largeReportActions); + render( + + + , + ); + await screen.findByTestId('search-consumer'); + + // When comments are added to one report repeatedly + let run = 0; + const writeComments = async () => { + await addComments(UNRELATED_REPORT_ID, `timed-search-${run}`); + run++; + }; + + // Then reassure times the whole write loop, where every write rebuilds the map of every report's visibility + await measureAsyncFunction(writeComments); + }); + + test('[VisibleReportActions] should process comment writes with no consumer mounted in a large collection', async () => { + // Given 2050 reports with actions and nothing mounted that reads their visibility + await seed(largeReportActions); + + // When comments are added to one report repeatedly + let run = 0; + const writeComments = async () => { + await addComments(UNRELATED_REPORT_ID, `timed-idle-${run}`); + run++; + }; + + // Then reassure times the write loop alone, so any work done for the visibility without a reader shows up + await measureAsyncFunction(writeComments); + }); +}); diff --git a/tests/ui/ParentNavigationSubtitleTest.tsx b/tests/ui/ParentNavigationSubtitleTest.tsx index 013a8302df99..c2f2ba13bdeb 100644 --- a/tests/ui/ParentNavigationSubtitleTest.tsx +++ b/tests/ui/ParentNavigationSubtitleTest.tsx @@ -1,5 +1,7 @@ import {fireEvent, render, screen} from '@testing-library/react-native'; +import useVisibleReportActions from '@hooks/useVisibleReportActions'; + import Navigation from '@libs/Navigation/Navigation'; import {isReportActionVisible} from '@libs/ReportActionsUtils'; @@ -31,6 +33,11 @@ jest.mock('@libs/Navigation/Navigation', () => ({ }, })); +jest.mock('@hooks/useVisibleReportActions', () => ({ + __esModule: true, + default: jest.fn(), +})); + jest.mock('@libs/ReportActionsUtils', () => ({ getReportAction: jest.fn(() => undefined), isReportActionVisible: jest.fn(() => false), @@ -310,7 +317,6 @@ describe('ParentNavigationSubtitle', () => { const otherReportID = 'other-report'; const expenseReportID = 'expense-report'; const parentReportActionID = 'parent-action'; - const otherReportActionID = 'other-action'; const subtitleData = {reportName: 'Parent chat', workspaceName: 'Workspace'}; function pressSubtitleLink() { @@ -356,12 +362,10 @@ describe('ParentNavigationSubtitle', () => { expect(screen.getByText(subtitleData.reportName)).toBeTruthy(); }); - it('passes only the parent report visibility to isReportActionVisible', async () => { - // Given visibility data for the parent report and for an unrelated report - await Onyx.set(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS, { - [parentReportID]: {[parentReportActionID]: true}, - [otherReportID]: {[otherReportActionID]: true}, - }); + it('passes the parent report visibility to isReportActionVisible', async () => { + // Given visibility data for the parent report + const visibility = {[parentReportID]: {[parentReportActionID]: true}}; + jest.mocked(useVisibleReportActions).mockReturnValue(visibility); render( { // When the parent link is pressed pressSubtitleLink(); - // Then the visibility check receives only the parent report slice, because the component subscribes through - // reportVisibleActionsSelector instead of the whole derived map - expect(jest.mocked(isReportActionVisible).mock.calls.at(0)?.at(3)).toEqual({[parentReportID]: {[parentReportActionID]: true}}); + // Then the visibility check receives the parent report visibility, because the component reads it through useVisibleReportActions + expect(jest.mocked(useVisibleReportActions)).toHaveBeenCalledWith(parentReportID); + expect(jest.mocked(isReportActionVisible).mock.calls.at(0)?.at(3)).toBe(visibility); }); it('reads the new parent report visibility when parentReportID changes', async () => { - // Given visibility data for two reports and a subtitle pointing at the first one - await Onyx.set(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS, { - [parentReportID]: {[parentReportActionID]: true}, - [otherReportID]: {[parentReportActionID]: false}, - }); + // Given a subtitle pointing at the first parent report const {rerender} = render( { />, ); await waitForBatchedUpdates(); - pressSubtitleLink(); - // Then the visibility check uses the new parent's slice, because the selector is rebuilt for the new parentReportID - expect(jest.mocked(isReportActionVisible).mock.calls.at(0)?.at(3)).toEqual({[otherReportID]: {[parentReportActionID]: false}}); + // Then the visibility hook is read for the new parent report + expect(jest.mocked(useVisibleReportActions)).toHaveBeenLastCalledWith(otherReportID); }); }); }); diff --git a/tests/ui/components/LHNOptionsListTest.tsx b/tests/ui/components/LHNOptionsListTest.tsx index 71c19e52b9d2..1cc0e3dacb67 100644 --- a/tests/ui/components/LHNOptionsListTest.tsx +++ b/tests/ui/components/LHNOptionsListTest.tsx @@ -258,11 +258,6 @@ describe('LHNOptionsList', () => { pendingExpenseAction: CONST.EXPENSE_PENDING_ACTION.SUBMIT, }); - await Onyx.merge(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS, { - [reportID]: { - [submittedAction.reportActionID]: true, - }, - }); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { [submittedAction.reportActionID]: submittedAction, }); @@ -315,11 +310,6 @@ describe('LHNOptionsList', () => { pendingExpenseAction: CONST.EXPENSE_PENDING_ACTION.SUBMIT, }); - await Onyx.merge(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS, { - [reportID]: { - [commentAction.reportActionID]: true, - }, - }); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { [commentAction.reportActionID]: commentAction, }); diff --git a/tests/unit/OnyxDerived/visibleReportActionsTest.ts b/tests/unit/OnyxDerived/visibleReportActionsTest.ts deleted file mode 100644 index 0539db5c40c4..000000000000 --- a/tests/unit/OnyxDerived/visibleReportActionsTest.ts +++ /dev/null @@ -1,83 +0,0 @@ -import visibleReportActionsConfig from '@libs/actions/OnyxDerived/configs/visibleReportActions'; - -import CONST from '@src/CONST'; -import type {OnyxKey} from '@src/ONYXKEYS'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {Report, ReportActions, ReportNameValuePairs} from '@src/types/onyx'; -import type {VisibleReportActionsDerivedValue} from '@src/types/onyx/DerivedValues'; - -import type {OnyxCollection} from 'react-native-onyx'; - -describe('visibleReportActions', () => { - describe('SESSION detected via triggeredKeys', () => { - const reportActionsKeyA = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}A`; - // Only report A has report actions now; report B exists only in the previous derived value (currentValue). - const allReportActions: OnyxCollection = {[reportActionsKeyA]: {}}; - const args: Parameters[0] = [allReportActions, undefined, {} as OnyxCollection, {} as OnyxCollection]; - const currentValue: VisibleReportActionsDerivedValue = {A: {}, B: {}}; - const sourceValues: Parameters[1]['sourceValues'] = {[ONYXKEYS.COLLECTION.REPORT_ACTIONS]: {[reportActionsKeyA]: {}}}; - - it('recomputes incrementally (keeps a stale report) when only report actions triggered', () => { - const result = visibleReportActionsConfig.compute(args, {currentValue, sourceValues, triggeredKeys: new Set([ONYXKEYS.COLLECTION.REPORT_ACTIONS])}); - - // Incremental: report B (absent from allReportActions) is retained from the previous value. - expect(result.B).toBeDefined(); - }); - - it('does a full recompute (drops the stale report) when SESSION also triggered, even with no SESSION delta', () => { - const result = visibleReportActionsConfig.compute(args, {currentValue, sourceValues, triggeredKeys: new Set([ONYXKEYS.COLLECTION.REPORT_ACTIONS, ONYXKEYS.SESSION])}); - - // Full recompute: report B is not in allReportActions, so it is dropped. - expect(result.B).toBeUndefined(); - expect(result.A).toBeDefined(); - }); - }); - - it('hides a support survey when the ticket has been replaced', () => { - // Given a support survey that was visible before the replacement ticket arrived - const reportID = 'supportTicketReportID'; - const surveyActionID = 'surveyActionID'; - const reportActionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`; - const reportKey = `${ONYXKEYS.COLLECTION.REPORT}${reportID}`; - const reportNameValuePairsKey = `${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`; - const unrelatedReportID = 'unrelatedReportID'; - const unrelatedActionID = 'unrelatedActionID'; - const unrelatedReportKey = `${ONYXKEYS.COLLECTION.REPORT}${unrelatedReportID}`; - const unrelatedReportNameValuePairsKey = `${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${unrelatedReportID}`; - const allReportActions: OnyxCollection = { - [reportActionsKey]: { - [surveyActionID]: { - reportActionID: surveyActionID, - actionName: CONST.REPORT.ACTIONS.TYPE.SUPPORT_SURVEY, - created: '2026-10-13 12:00:00.000', - }, - }, - }; - const args: Parameters[0] = [ - allReportActions, - undefined, - {[reportKey]: {reportID, type: CONST.REPORT.TYPE.SUPPORT_TICKET}, [unrelatedReportKey]: {reportID: unrelatedReportID, type: CONST.REPORT.TYPE.CHAT}}, - {[reportNameValuePairsKey]: {reopenedAsReportID: 'replacementReportID'}}, - ]; - const currentValue: VisibleReportActionsDerivedValue = { - [reportID]: {[surveyActionID]: true}, - [unrelatedReportID]: {[unrelatedActionID]: true}, - }; - - // When the replacement and an unrelated report NVP trigger a recompute - const result = visibleReportActionsConfig.compute(args, { - currentValue, - sourceValues: { - [ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS]: { - [reportNameValuePairsKey]: {reopenedAsReportID: 'replacementReportID'}, - [unrelatedReportNameValuePairsKey]: {agentZeroProcessingRequestIndicator: 'processing'}, - }, - }, - triggeredKeys: new Set([ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS]), - }); - - // Then the shared visibility state hides the stale survey without recomputing unrelated reports - expect(result[reportID]?.[surveyActionID]).toBe(false); - expect(result[unrelatedReportID]).toBe(currentValue[unrelatedReportID]); - }); -}); diff --git a/tests/unit/OnyxDerivedTest.tsx b/tests/unit/OnyxDerivedTest.tsx index 5680f4527a62..f7567ba8e051 100644 --- a/tests/unit/OnyxDerivedTest.tsx +++ b/tests/unit/OnyxDerivedTest.tsx @@ -2,7 +2,6 @@ import {act, renderHook} from '@testing-library/react-native'; import useOnyx from '@hooks/useOnyx'; -import {isReportActionVisible} from '@libs/ReportActionsUtils'; import * as ActiveSpans from '@libs/telemetry/activeSpans'; import {KEYS_TO_PRESERVE_DELEGATE_ACCESS} from '@userActions/Delegate'; @@ -68,132 +67,6 @@ describe('OnyxDerived', () => { await Onyx.clear(); }); - describe('visibleReportActions', () => { - it.each([ - CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_DEQUEUED, - CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_ACH_CANCELED, - CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_ACH_BOUNCE, - CONST.REPORT.ACTIONS.TYPE.RETRACTED, - CONST.REPORT.ACTIONS.TYPE.REOPENED, - CONST.REPORT.ACTIONS.TYPE.UNAPPROVED, - ])('keeps a later reimbursement visible after %s even when the earlier PAY was cached', async (actionName) => { - const reportID = `reportWithHistoricalPay-${actionName}`; - const reportActionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}` as const; - const markedReimbursed = getFakeReportAction(1, { - reportID, - actionName: CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED, - created: '2025-01-01 00:00:00.000', - originalMessage: {}, - }); - const pay = getFakeReportAction(2, { - reportID, - actionName: CONST.REPORT.ACTIONS.TYPE.IOU, - created: '2025-01-01 00:00:01.000', - originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.PAY, IOUReportID: reportID, amount: 100, currency: CONST.CURRENCY.USD}, - }); - await Onyx.set(reportActionsKey, {[markedReimbursed.reportActionID]: markedReimbursed, [pay.reportActionID]: pay}); - await waitForBatchedUpdates(); - - const initialVisibility = await OnyxUtils.get(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); - expect(isReportActionVisible(markedReimbursed, reportID, true, initialVisibility)).toBe(false); - - const cancellation = getFakeReportAction(3, {reportID, actionName, created: '2025-01-02 00:00:00.000'}); - const laterReimbursement = {...markedReimbursed, reportActionID: '4', created: '2025-01-03 00:00:00.000'}; - // History can arrive out of order: initially the earlier payment appears to cover this action too. - await Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT_ACTIONS, {[reportActionsKey]: {[laterReimbursement.reportActionID]: laterReimbursement}}); - await waitForBatchedUpdates(); - const incompleteVisibility = await OnyxUtils.get(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); - expect(isReportActionVisible(laterReimbursement, reportID, true, incompleteVisibility)).toBe(false); - - await Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT_ACTIONS, { - [reportActionsKey]: {[cancellation.reportActionID]: cancellation}, - }); - await waitForBatchedUpdates(); - - const updatedVisibility = await OnyxUtils.get(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); - expect(isReportActionVisible(laterReimbursement, reportID, true, updatedVisibility)).toBe(true); - expect(isReportActionVisible(markedReimbursed, reportID, true, updatedVisibility)).toBe(false); - - // A PAY belonging to the new payment attempt should still hide its duplicate when it arrives later. - const laterPay = {...pay, reportActionID: '5', created: '2025-01-03 00:00:01.000'}; - await Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT_ACTIONS, {[reportActionsKey]: {[laterPay.reportActionID]: laterPay}}); - await waitForBatchedUpdates(); - - const finalVisibility = await OnyxUtils.get(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); - expect(isReportActionVisible(laterReimbursement, reportID, true, finalVisibility)).toBe(false); - }); - - it.each([CONST.REPORT.ACTIONS.TYPE.RETRACTED, CONST.REPORT.ACTIONS.TYPE.REOPENED, CONST.REPORT.ACTIONS.TYPE.UNAPPROVED])( - 'does not retroactively hide a standalone reimbursement when PAY arrives after %s', - async (actionName) => { - const reportID = `reportWithFuturePay-${actionName}`; - const reportActionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}` as const; - const markedReimbursed = getFakeReportAction(1, { - actionName: CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED, - created: '2025-01-01 00:00:00.000', - originalMessage: {}, - }); - await Onyx.set(reportActionsKey, {[markedReimbursed.reportActionID]: markedReimbursed}); - await waitForBatchedUpdates(); - const initialVisibility = await OnyxUtils.get(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); - expect(isReportActionVisible(markedReimbursed, reportID, true, initialVisibility)).toBe(true); - - const boundary = getFakeReportAction(2, {actionName, created: '2025-02-01 00:00:00.000'}); - const pay = getFakeReportAction(3, { - actionName: CONST.REPORT.ACTIONS.TYPE.IOU, - created: '2025-06-01 00:00:00.000', - originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.PAY, IOUReportID: reportID, amount: 100, currency: CONST.CURRENCY.USD}, - }); - const laterReimbursement = {...markedReimbursed, reportActionID: '4', created: '2025-06-01 00:00:01.000'}; - await Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT_ACTIONS, { - [reportActionsKey]: {[boundary.reportActionID]: boundary, [pay.reportActionID]: pay, [laterReimbursement.reportActionID]: laterReimbursement}, - }); - await waitForBatchedUpdates(); - - const updatedVisibility = await OnyxUtils.get(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); - expect(isReportActionVisible(markedReimbursed, reportID, true, updatedVisibility)).toBe(true); - expect(isReportActionVisible(laterReimbursement, reportID, true, updatedVisibility)).toBe(false); - }, - ); - - it('uses the collection reportID to hide MARKED_REIMBURSED when its sibling PAY arrives later', async () => { - const reportID = 'reportWithLatePaySibling'; - const reportActionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}` as const; - const markedReimbursedAction: ReportAction = { - actionName: CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED, - reportActionID: '1', - created: '2025-01-01 00:00:00', - message: [{type: 'TEXT', style: 'normal', text: 'Marked as reimbursed'}], - originalMessage: {}, - }; - const payAction = { - actionName: CONST.REPORT.ACTIONS.TYPE.IOU, - reportActionID: '2', - created: '2025-01-01 00:00:01', - message: [{type: 'TEXT', style: 'normal', text: 'paid'}], - originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.PAY, IOUReportID: reportID, amount: 100, currency: CONST.CURRENCY.USD}, - } as ReportAction; - - await Onyx.set(reportActionsKey, {[markedReimbursedAction.reportActionID]: markedReimbursedAction}); - await waitForBatchedUpdates(); - - const initialVisibility = await OnyxUtils.get(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); - expect(initialVisibility?.[reportID]).toBeDefined(); - expect(isReportActionVisible(markedReimbursedAction, reportID, true, initialVisibility)).toBe(true); - - // Only PAY is included in this update, so derived visibility must not retain a stale result for its sibling. - await Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT_ACTIONS, {[reportActionsKey]: {[payAction.reportActionID]: payAction}}); - await waitForBatchedUpdates(); - - const updatedVisibility = await OnyxUtils.get(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); - expect(updatedVisibility?.[reportID]?.[payAction.reportActionID]).toBe(true); - expect(isReportActionVisible(markedReimbursedAction, reportID, true, updatedVisibility)).toBe(false); - expect(isReportActionVisible(markedReimbursedAction, reportID, true)).toBe(false); - expect(isReportActionVisible(markedReimbursedAction, reportID, true, {})).toBe(false); - expect(isReportActionVisible({...markedReimbursedAction, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}, reportID, true, updatedVisibility)).toBe(false); - }); - }); - describe('coalescing', () => { // Each flush writes the derived value exactly once via setDerivedValue, so counting its calls for a key // counts how many times that value was recomputed. diff --git a/tests/unit/SidebarUtilsTest.ts b/tests/unit/SidebarUtilsTest.ts index 94886dee1cc8..feaff45b66ef 100644 --- a/tests/unit/SidebarUtilsTest.ts +++ b/tests/unit/SidebarUtilsTest.ts @@ -3771,12 +3771,6 @@ describe('SidebarUtils', () => { [linkedCreateAction.reportActionID]: linkedCreateAction, [lastAction.reportActionID]: lastAction, }); - await Onyx.set(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS, { - [iouReportR14932.reportID]: { - [linkedCreateAction.reportActionID]: true, - [lastAction.reportActionID]: true, - }, - }); }); const result = SidebarUtils.getOptionData({ diff --git a/tests/unit/selectors/VisibleReportActionsTest.ts b/tests/unit/selectors/VisibleReportActionsTest.ts new file mode 100644 index 000000000000..bd9025a8ba59 --- /dev/null +++ b/tests/unit/selectors/VisibleReportActionsTest.ts @@ -0,0 +1,166 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report, ReportAction, ReportActions, ReportNameValuePairs} from '@src/types/onyx'; + +import type {OnyxCollection} from 'react-native-onyx'; + +import {computeReportVisibility, getAllVisibleReportActions, getVisibleReportActionsForReports} from '@selectors/VisibleReportActions'; + +import {getFakeReportAction} from '../../utils/ReportTestUtils'; + +const CURRENT_USER_ACCOUNT_ID = 10; +const OTHER_USER_ACCOUNT_ID = 20; + +function createComment(reportActionID: number, overrides: Partial = {}): ReportAction { + return getFakeReportAction(reportActionID, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, ...overrides}); +} + +function toReportActions(...actions: ReportAction[]): ReportActions { + return Object.fromEntries(actions.map((action) => [action.reportActionID, action])); +} + +function toEntry(reportID: string, reportActions: ReportActions | undefined, report?: Report, reportNameValuePairs?: ReportNameValuePairs) { + return {reportID, reportActions, report, reportNameValuePairs}; +} + +function toCollection(reportActionsByReportID: Record): OnyxCollection { + return Object.fromEntries(Object.entries(reportActionsByReportID).map(([reportID, reportActions]) => [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, reportActions])); +} + +describe('computeReportVisibility', () => { + it('should skip deprecated keys and actions whose visibility depends on runtime context', () => { + // Given a visible comment, the same comment under a deprecated key, and a MARKED_REIMBURSED whose visibility depends on its siblings + const comment = createComment(1); + const markedReimbursed = createComment(2, {actionName: CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED, originalMessage: {}}); + const reportActions: ReportActions = {...toReportActions(comment, markedReimbursed), deprecatedKey: comment}; + + // When the report's visibility is computed + const visibility = computeReportVisibility(reportActions, CURRENT_USER_ACCOUNT_ID, false, false); + + // Then only the canonical comment is cached, so the other two fall back to the runtime check + expect(visibility).toEqual({[comment.reportActionID]: true}); + }); +}); + +describe('getVisibleReportActionsForReports', () => { + it('should reuse a report visibility for the same actions object and user and recompute for another user', () => { + // Given a report's actions + const reportActions = toReportActions(createComment(1)); + + // When the selector runs twice for the same user, then once for another user + const first = getVisibleReportActionsForReports([toEntry('1', reportActions)], CURRENT_USER_ACCOUNT_ID); + const second = getVisibleReportActionsForReports([toEntry('1', reportActions)], CURRENT_USER_ACCOUNT_ID); + const otherUser = getVisibleReportActionsForReports([toEntry('1', reportActions)], OTHER_USER_ACCOUNT_ID); + + // Then mounted LHN rows share one compute per report, while a user switch doesn't reuse a result for the previous user + expect(second?.['1']).toBe(first?.['1']); + expect(otherUser?.['1']).not.toBe(first?.['1']); + }); + + it('should leave out reports without actions', () => { + // Given a report with actions and a transaction thread without any + + // When the selector runs for both + const result = getVisibleReportActionsForReports([toEntry('1', toReportActions(createComment(1))), toEntry('2', undefined)], CURRENT_USER_ACCOUNT_ID); + + // Then only the report with actions has an entry, as in the derived value it replaces + expect(Object.keys(result ?? {})).toEqual(['1']); + }); +}); + +describe('getAllVisibleReportActions', () => { + it('should return the previous map when a write leaves every report visibility unchanged', () => { + // Given two reports + const comment = createComment(1); + const otherReportActions = toReportActions(createComment(2)); + const previous = getAllVisibleReportActions(toCollection({'1': toReportActions(comment), '2': otherReportActions}), {}, {}, CURRENT_USER_ACCOUNT_ID); + + // When the comment is edited, which gives the report's actions and the collection new objects without changing visibility + const next = getAllVisibleReportActions( + toCollection({'1': toReportActions({...comment, lastModified: '2025-01-01 00:00:00.000'}), '2': otherReportActions}), + {}, + {}, + CURRENT_USER_ACCOUNT_ID, + ); + + // Then the same map object comes back, so Search consumers pass Object.is without a deep walk over every report + expect(next).toBe(previous); + }); + + it('should keep untouched reports and drop removed ones when a report changes', () => { + // Given three reports + const otherReportActions = toReportActions(createComment(2)); + const previous = getAllVisibleReportActions( + toCollection({'1': toReportActions(createComment(1)), '2': otherReportActions, '3': toReportActions(createComment(3))}), + {}, + {}, + CURRENT_USER_ACCOUNT_ID, + ); + + // When a comment is added to the first report and the third report's actions are removed + const next = getAllVisibleReportActions(toCollection({'1': toReportActions(createComment(1), createComment(4)), '2': otherReportActions}), {}, {}, CURRENT_USER_ACCOUNT_ID); + + // Then the map is new, the untouched report keeps its object, and the removed report is gone + expect(next).not.toBe(previous); + expect(next).toEqual({'1': {'1': true, '4': true}, '2': {'2': true}}); + expect(next['2']).toBe(previous['2']); + }); +}); + +describe('support ticket visibility', () => { + const REPORT_ID = '1'; + const UNRELATED_REPORT_ID = '2'; + const SURVEY_ACTION_ID = '10'; + const supportTicket = {reportID: REPORT_ID, type: CONST.REPORT.TYPE.SUPPORT_TICKET} as Report; + const survey = createComment(Number(SURVEY_ACTION_ID), {actionName: CONST.REPORT.ACTIONS.TYPE.SUPPORT_SURVEY}); + + it('should hide the support survey when the ticket has been replaced', () => { + // Given a support ticket whose survey was visible before the replacement ticket arrived + const reportActions = toReportActions(survey); + const before = getVisibleReportActionsForReports([toEntry(REPORT_ID, reportActions, supportTicket)], CURRENT_USER_ACCOUNT_ID); + + // When the ticket is reopened as another report + const after = getVisibleReportActionsForReports([toEntry(REPORT_ID, reportActions, supportTicket, {reopenedAsReportID: '99'})], CURRENT_USER_ACCOUNT_ID); + + // Then the original ticket's survey is hidden, because the replacement has its own active survey + expect(before?.[REPORT_ID]?.[SURVEY_ACTION_ID]).toBe(true); + expect(after?.[REPORT_ID]?.[SURVEY_ACTION_ID]).toBe(false); + }); + + it('should not hide the survey of a report that is not a support ticket', () => { + // Given a regular report with a reopenedAsReportID + const reportActions = toReportActions(survey); + + // When its visibility is computed + const result = getVisibleReportActionsForReports( + [toEntry(REPORT_ID, reportActions, {reportID: REPORT_ID, type: CONST.REPORT.TYPE.CHAT} as Report, {reopenedAsReportID: '99'})], + CURRENT_USER_ACCOUNT_ID, + ); + + // Then the replacement rule does not apply + expect(result?.[REPORT_ID]?.[SURVEY_ACTION_ID]).not.toBe(false); + }); + + it('should recompute the whole map when a ticket is replaced and keep unrelated reports untouched', () => { + // Given a support ticket and an unrelated report + const reportActions = toReportActions(survey); + const otherReportActions = toReportActions(createComment(2)); + const allReportActions = toCollection({[REPORT_ID]: reportActions, [UNRELATED_REPORT_ID]: otherReportActions}); + const allReports = {[`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`]: supportTicket}; + const previous = getAllVisibleReportActions(allReportActions, allReports, {}, CURRENT_USER_ACCOUNT_ID); + + // When the ticket gets a replacement + const next = getAllVisibleReportActions( + allReportActions, + allReports, + {[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${REPORT_ID}`]: {reopenedAsReportID: '99'}}, + CURRENT_USER_ACCOUNT_ID, + ); + + // Then the survey is hidden and the unrelated report keeps its object + expect(previous[REPORT_ID]?.[SURVEY_ACTION_ID]).toBe(true); + expect(next[REPORT_ID]?.[SURVEY_ACTION_ID]).toBe(false); + expect(next[UNRELATED_REPORT_ID]).toBe(previous[UNRELATED_REPORT_ID]); + }); +}); diff --git a/tests/unit/useAllVisibleReportActionsTest.tsx b/tests/unit/useAllVisibleReportActionsTest.tsx new file mode 100644 index 000000000000..3a64a92850f3 --- /dev/null +++ b/tests/unit/useAllVisibleReportActionsTest.tsx @@ -0,0 +1,233 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import {act, renderHook} from '@testing-library/react-native'; + +import useAllVisibleReportActions from '@hooks/useAllVisibleReportActions'; + +import {isReportActionVisible} from '@libs/ReportActionsUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {ReportAction} from '@src/types/onyx'; + +import Onyx from 'react-native-onyx'; + +import {getFakeReportAction} from '../utils/ReportTestUtils'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +const REPORT_ID = '1'; +const OTHER_REPORT_ID = '3'; + +function createComment(reportActionID: number, reportID: string): ReportAction { + return getFakeReportAction(reportActionID, {reportID, actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}); +} + +async function setReportActions(reportID: string, actions: ReportAction[]) { + await act(async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, Object.fromEntries(actions.map((action) => [action.reportActionID, action]))); + }); + await waitForBatchedUpdatesWithAct(); +} + +async function mergeReportAction(reportID: string, action: ReportAction) { + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {[action.reportActionID]: action}); + }); + await waitForBatchedUpdatesWithAct(); +} + +/** Mounts the hook for one read, so every read goes through the module memo the way a newly opened Search surface does */ +async function getVisibleReportActions() { + const {result, unmount} = renderHook(() => useAllVisibleReportActions()); + await waitForBatchedUpdatesWithAct(); + const visibleReportActions = result.current; + unmount(); + return visibleReportActions; +} + +describe('useAllVisibleReportActions', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await Onyx.clear(); + }); + + it.each([ + CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_DEQUEUED, + CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_ACH_CANCELED, + CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_ACH_BOUNCE, + CONST.REPORT.ACTIONS.TYPE.RETRACTED, + CONST.REPORT.ACTIONS.TYPE.REOPENED, + CONST.REPORT.ACTIONS.TYPE.UNAPPROVED, + ])('should keep a later reimbursement visible after %s even when the earlier PAY was cached', async (actionName) => { + // Given a report whose MARKED_REIMBURSED is hidden by its PAY + const reportID = `reportWithHistoricalPay-${actionName}`; + const reportActionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}` as const; + const markedReimbursed = getFakeReportAction(1, { + reportID, + actionName: CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED, + created: '2025-01-01 00:00:00.000', + originalMessage: {}, + }); + const pay = getFakeReportAction(2, { + reportID, + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + created: '2025-01-01 00:00:01.000', + originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.PAY, IOUReportID: reportID, amount: 100, currency: CONST.CURRENCY.USD}, + }); + await Onyx.set(reportActionsKey, {[markedReimbursed.reportActionID]: markedReimbursed, [pay.reportActionID]: pay}); + await waitForBatchedUpdates(); + const initialVisibility = await getVisibleReportActions(); + expect(isReportActionVisible(markedReimbursed, reportID, true, initialVisibility)).toBe(false); + + // When a later reimbursement arrives before the action that cancels the first payment, as history can arrive out of order + const cancellation = getFakeReportAction(3, {reportID, actionName, created: '2025-01-02 00:00:00.000'}); + const laterReimbursement = {...markedReimbursed, reportActionID: '4', created: '2025-01-03 00:00:00.000'}; + await Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT_ACTIONS, {[reportActionsKey]: {[laterReimbursement.reportActionID]: laterReimbursement}}); + await waitForBatchedUpdates(); + const incompleteVisibility = await getVisibleReportActions(); + + // Then the earlier payment still appears to cover it + expect(isReportActionVisible(laterReimbursement, reportID, true, incompleteVisibility)).toBe(false); + + // When the cancellation arrives + await Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT_ACTIONS, {[reportActionsKey]: {[cancellation.reportActionID]: cancellation}}); + await waitForBatchedUpdates(); + const updatedVisibility = await getVisibleReportActions(); + + // Then the later reimbursement shows and the earlier one stays hidden, so no stale cached entry survives + expect(isReportActionVisible(laterReimbursement, reportID, true, updatedVisibility)).toBe(true); + expect(isReportActionVisible(markedReimbursed, reportID, true, updatedVisibility)).toBe(false); + + // When a PAY of the new payment attempt arrives later + const laterPay = {...pay, reportActionID: '5', created: '2025-01-03 00:00:01.000'}; + await Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT_ACTIONS, {[reportActionsKey]: {[laterPay.reportActionID]: laterPay}}); + await waitForBatchedUpdates(); + const finalVisibility = await getVisibleReportActions(); + + // Then it hides its duplicate reimbursement + expect(isReportActionVisible(laterReimbursement, reportID, true, finalVisibility)).toBe(false); + }); + + it.each([CONST.REPORT.ACTIONS.TYPE.RETRACTED, CONST.REPORT.ACTIONS.TYPE.REOPENED, CONST.REPORT.ACTIONS.TYPE.UNAPPROVED])( + 'should not retroactively hide a standalone reimbursement when PAY arrives after %s', + async (actionName) => { + // Given a report with a standalone MARKED_REIMBURSED, which is visible + const reportID = `reportWithFuturePay-${actionName}`; + const reportActionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}` as const; + const markedReimbursed = getFakeReportAction(1, { + actionName: CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED, + created: '2025-01-01 00:00:00.000', + originalMessage: {}, + }); + await Onyx.set(reportActionsKey, {[markedReimbursed.reportActionID]: markedReimbursed}); + await waitForBatchedUpdates(); + const initialVisibility = await getVisibleReportActions(); + expect(isReportActionVisible(markedReimbursed, reportID, true, initialVisibility)).toBe(true); + + // When a boundary action, a PAY and a later reimbursement arrive together + const boundary = getFakeReportAction(2, {actionName, created: '2025-02-01 00:00:00.000'}); + const pay = getFakeReportAction(3, { + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + created: '2025-06-01 00:00:00.000', + originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.PAY, IOUReportID: reportID, amount: 100, currency: CONST.CURRENCY.USD}, + }); + const laterReimbursement = {...markedReimbursed, reportActionID: '4', created: '2025-06-01 00:00:01.000'}; + await Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT_ACTIONS, { + [reportActionsKey]: {[boundary.reportActionID]: boundary, [pay.reportActionID]: pay, [laterReimbursement.reportActionID]: laterReimbursement}, + }); + await waitForBatchedUpdates(); + const updatedVisibility = await getVisibleReportActions(); + + // Then only the reimbursement after the boundary is hidden by the PAY + expect(isReportActionVisible(markedReimbursed, reportID, true, updatedVisibility)).toBe(true); + expect(isReportActionVisible(laterReimbursement, reportID, true, updatedVisibility)).toBe(false); + }, + ); + + it('should use the collection reportID to hide MARKED_REIMBURSED when its sibling PAY arrives later', async () => { + // Given a report with a MARKED_REIMBURSED and no PAY yet + const reportID = 'reportWithLatePaySibling'; + const reportActionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}` as const; + const markedReimbursedAction: ReportAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED, + reportActionID: '1', + created: '2025-01-01 00:00:00', + message: [{type: 'TEXT', style: 'normal', text: 'Marked as reimbursed'}], + originalMessage: {}, + }; + const payAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + reportActionID: '2', + created: '2025-01-01 00:00:01', + message: [{type: 'TEXT', style: 'normal', text: 'paid'}], + originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.PAY, IOUReportID: reportID, amount: 100, currency: CONST.CURRENCY.USD}, + } as ReportAction; + await Onyx.set(reportActionsKey, {[markedReimbursedAction.reportActionID]: markedReimbursedAction}); + await waitForBatchedUpdates(); + const initialVisibility = await getVisibleReportActions(); + expect(initialVisibility[reportID]).toBeDefined(); + expect(isReportActionVisible(markedReimbursedAction, reportID, true, initialVisibility)).toBe(true); + + // When only the PAY arrives + await Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT_ACTIONS, {[reportActionsKey]: {[payAction.reportActionID]: payAction}}); + await waitForBatchedUpdates(); + const updatedVisibility = await getVisibleReportActions(); + + // Then the cached visibility doesn't keep a stale result for its sibling, which is now hidden on every path + expect(updatedVisibility[reportID]?.[payAction.reportActionID]).toBe(true); + expect(isReportActionVisible(markedReimbursedAction, reportID, true, updatedVisibility)).toBe(false); + expect(isReportActionVisible(markedReimbursedAction, reportID, true)).toBe(false); + expect(isReportActionVisible(markedReimbursedAction, reportID, true, {})).toBe(false); + expect(isReportActionVisible({...markedReimbursedAction, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}, reportID, true, updatedVisibility)).toBe(false); + }); + + it('should keep the previous map and the untouched reports when a write leaves visibility unchanged', async () => { + // Given two reports with a comment each + const comment = createComment(1, REPORT_ID); + await setReportActions(REPORT_ID, [comment]); + await setReportActions(OTHER_REPORT_ID, [createComment(2, OTHER_REPORT_ID)]); + let renderCount = 0; + const {result} = renderHook(() => { + renderCount++; + return useAllVisibleReportActions(); + }); + await waitForBatchedUpdatesWithAct(); + const mapBeforeWrite = result.current; + const renderCountBeforeWrite = renderCount; + expect(mapBeforeWrite).toEqual({[REPORT_ID]: {'1': true}, [OTHER_REPORT_ID]: {'2': true}}); + + // When the comment is edited, which gives the report's actions a new object but leaves every action visible + await mergeReportAction(REPORT_ID, {...comment, lastModified: '2025-01-01 00:00:00.000'}); + + // Then the hook returns the same map object and doesn't re-render, so Search consumers keep their memoized work + expect(result.current).toBe(mapBeforeWrite); + expect(renderCount).toBe(renderCountBeforeWrite); + + // When a comment is added to the first report + await mergeReportAction(REPORT_ID, createComment(3, REPORT_ID)); + + // Then the map changes and the other report keeps its object + expect(result.current).toEqual({[REPORT_ID]: {'1': true, '3': true}, [OTHER_REPORT_ID]: {'2': true}}); + expect(result.current[OTHER_REPORT_ID]).toBe(mapBeforeWrite[OTHER_REPORT_ID]); + }); + + it('should drop a report whose actions are removed', async () => { + // Given two reports with a comment each + await setReportActions(REPORT_ID, [createComment(1, REPORT_ID)]); + await setReportActions(OTHER_REPORT_ID, [createComment(2, OTHER_REPORT_ID)]); + const {result} = renderHook(() => useAllVisibleReportActions()); + await waitForBatchedUpdatesWithAct(); + + // When the actions of one report are removed entirely + await act(async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${OTHER_REPORT_ID}`, null); + }); + await waitForBatchedUpdatesWithAct(); + + // Then the report leaves the map, as it left the derived value + expect(result.current).toEqual({[REPORT_ID]: {'1': true}}); + }); +}); diff --git a/tests/unit/useMoneyRequestReportVisibleActionsTest.tsx b/tests/unit/useMoneyRequestReportVisibleActionsTest.tsx index 43b84e3a25e5..ebfe442b695a 100644 --- a/tests/unit/useMoneyRequestReportVisibleActionsTest.tsx +++ b/tests/unit/useMoneyRequestReportVisibleActionsTest.tsx @@ -3,8 +3,6 @@ import {act, renderHook} from '@testing-library/react-native'; import useMoneyRequestReportVisibleActions from '@components/MoneyRequestReportView/useMoneyRequestReportVisibleActions'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; -import initOnyxDerivedValues from '@userActions/OnyxDerived'; - import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {ReportAction, ReportActions} from '@src/types/onyx'; @@ -63,7 +61,7 @@ function buildParams(reportActions: ReportAction[], overrides: Partial = }; } -/** Push the same actions into Onyx so the VISIBLE_REPORT_ACTIONS derived value the hook subscribes to is populated. */ +/** Push the same actions into Onyx so the cached visibility the hook reads is populated. */ function setReportActions(reportActions: ReportAction[]) { const actions: ReportActions = Object.fromEntries(reportActions.map((action) => [action.reportActionID, action])); return Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, actions); @@ -88,7 +86,6 @@ function getVisibleIDs(actions: ReportAction[]) { describe('useMoneyRequestReportVisibleActions', () => { beforeAll(() => { Onyx.init({keys: ONYXKEYS}); - initOnyxDerivedValues(); }); beforeEach(async () => { diff --git a/tests/unit/useReportActionsVisibilityTest.tsx b/tests/unit/useReportActionsVisibilityTest.tsx index 07eaee35c421..ff9f15f2e113 100644 --- a/tests/unit/useReportActionsVisibilityTest.tsx +++ b/tests/unit/useReportActionsVisibilityTest.tsx @@ -4,8 +4,6 @@ import OnyxListItemProvider from '@components/OnyxListItemProvider'; import useReportActionsVisibility from '@hooks/useReportActionsVisibility'; -import initOnyxDerivedValues from '@userActions/OnyxDerived'; - import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {ReportActions} from '@src/types/onyx'; @@ -14,16 +12,14 @@ import type {ReactNode} from 'react'; import Onyx from 'react-native-onyx'; -import getOnyxValue from '../utils/getOnyxValue'; import {getFakeReportAction} from '../utils/ReportTestUtils'; import * as TestHelper from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; /** - * Guards that `useReportActionsVisibility` subscribes to VISIBLE_REPORT_ACTIONS scoped to its own - * report via `reportVisibleActionsSelector`. The test renders the real hook and counts how often a - * consumer re-renders: with the per-report selector, an unrelated report's activity must not - * re-render this report's consumer. + * Guards that `useReportActionsVisibility` reads the cached visibility of its own report only, through + * `useVisibleReportActions`. The test renders the real hook and counts how often a consumer re-renders: + * an unrelated report's activity must not re-render this report's consumer. */ const REPORT_A = 'reportA'; @@ -38,16 +34,6 @@ function buildActions(...reportActionIDs: string[]): ReportActions { return Object.fromEntries(reportActionIDs.map((id) => [id, buildVisibleComment(id)])); } -/** Build an ADD_COMMENT action whispered to specific accounts (visible only to those accounts). */ -function buildWhisperedComment(reportActionID: string, whisperedTo: number[]) { - return getFakeReportAction(1, { - reportActionID, - actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, - message: [{html: 'hey', isDeletedParentAction: false, isEdited: false, text: 'test', type: 'COMMENT', whisperedTo}], - originalMessage: {whisperedTo}, - }); -} - function setReportActions(reportID: string, actions: ReportActions) { return Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, actions); } @@ -68,10 +54,9 @@ function buildHookProps(reportID: string, actions: ReportActions): HookProps { const wrapper = ({children}: {children: ReactNode}) => {children}; -describe('useReportActionsVisibility scopes VISIBLE_REPORT_ACTIONS per report', () => { +describe('useReportActionsVisibility scopes the visibility cache per report', () => { beforeAll(() => { Onyx.init({keys: ONYXKEYS}); - initOnyxDerivedValues(); }); beforeEach(async () => { @@ -81,7 +66,7 @@ describe('useReportActionsVisibility scopes VISIBLE_REPORT_ACTIONS per report', }); it('does not re-render the open report (A) when an unrelated report (B) actions change', async () => { - // Given reports A and B both have visible actions in the derived value, and the hook is mounted for A + // Given reports A and B both have visible actions, and the hook is mounted for A await setReportActions(REPORT_A, buildActions('a1', 'a2')); await setReportActions(REPORT_B, buildActions('b1')); await waitForBatchedUpdates(); @@ -140,59 +125,3 @@ describe('useReportActionsVisibility scopes VISIBLE_REPORT_ACTIONS per report', unmount(); }); }); - -describe('VISIBLE_REPORT_ACTIONS reflects deleted report actions', () => { - beforeAll(() => { - Onyx.init({keys: ONYXKEYS}); - initOnyxDerivedValues(); - }); - - beforeEach(async () => { - await Onyx.clear(); - TestHelper.signInWithTestUser(1, 'test@test.com'); - await waitForBatchedUpdates(); - }); - - it('recomputes visibility on a session change (account switch) even when no report action changed', async () => { - // Given report A holds a whisper targeted to account 1, and account 1 is signed in. - // (signInWithTestUser in beforeEach signs in accountID 1.) - await setReportActions(REPORT_A, {whisper: buildWhisperedComment('whisper', [1])}); - await waitForBatchedUpdates(); - - // The whisper is visible to its target (account 1). - const visibleAsTarget = await getOnyxValue(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); - expect(visibleAsTarget?.[REPORT_A]?.whisper).toBe(true); - - // When the session switches to a different user (account 2) without touching the report's actions - await act(async () => { - await Onyx.merge(ONYXKEYS.SESSION, {accountID: 2}); - await waitForBatchedUpdates(); - }); - - // Then the derived value is recomputed against the new user: the whisper now targets "others" and is hidden. - // This only flips if the SESSION delta forces a recompute despite REPORT_ACTIONS being unchanged. - const visibleAsOther = await getOnyxValue(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); - expect(visibleAsOther?.[REPORT_A]?.whisper).toBe(false); - }); - - it('drops a deleted action from the derived value via the incremental delta path', async () => { - // Given a report with two visible actions (so the derived value has a cached entry to update incrementally) - await setReportActions(REPORT_A, buildActions('a1', 'a2')); - await waitForBatchedUpdates(); - - const visibleBefore = await getOnyxValue(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); - expect(Object.keys(visibleBefore?.[REPORT_A] ?? {}).sort()).toEqual(['a1', 'a2']); - - // When one action is deleted (a null tombstone merge, exactly how report action deletion works) - await act(async () => { - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_A}`, {a1: null}); - await waitForBatchedUpdates(); - }); - - // Then the deleted action is gone from the derived visibility map (not left stale). - // The member-level delta no longer carries the per-action `null`, so this only passes - // because the changed report is rebuilt from its full current action set. - const visibleAfter = await getOnyxValue(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); - expect(Object.keys(visibleAfter?.[REPORT_A] ?? {})).toEqual(['a2']); - }); -}); diff --git a/tests/unit/useVisibleReportActionsTest.tsx b/tests/unit/useVisibleReportActionsTest.tsx new file mode 100644 index 000000000000..44e1a437c8ec --- /dev/null +++ b/tests/unit/useVisibleReportActionsTest.tsx @@ -0,0 +1,156 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import {act, renderHook} from '@testing-library/react-native'; + +import useVisibleReportActions from '@hooks/useVisibleReportActions'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {ReportAction} from '@src/types/onyx'; + +import type * as VisibleReportActionsSelectors from '@selectors/VisibleReportActions'; + +import {getVisibleReportActionsForReports} from '@selectors/VisibleReportActions'; +import Onyx from 'react-native-onyx'; + +import {getFakeReportAction} from '../utils/ReportTestUtils'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('@selectors/VisibleReportActions', () => { + const actual = jest.requireActual('@selectors/VisibleReportActions'); + return {...actual, getVisibleReportActionsForReports: jest.fn(actual.getVisibleReportActionsForReports)}; +}); + +const REPORT_ID = '1'; +const TRANSACTION_THREAD_REPORT_ID = '2'; +const OTHER_REPORT_ID = '3'; +const CURRENT_USER_ACCOUNT_ID = 10; +const OTHER_USER_ACCOUNT_ID = 20; + +const selectorMock = jest.mocked(getVisibleReportActionsForReports); + +function createComment(reportActionID: number, reportID: string): ReportAction { + return getFakeReportAction(reportActionID, {reportID, actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}); +} + +function createWhisper(reportActionID: number, reportID: string, whisperedToAccountID: number): ReportAction { + return getFakeReportAction(reportActionID, { + reportID, + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + message: [{html: 'whisper', text: 'whisper', type: 'COMMENT', whisperedTo: [whisperedToAccountID]}], + originalMessage: {html: 'whisper', whisperedTo: [whisperedToAccountID]}, + }); +} + +async function setReportActions(reportID: string, actions: ReportAction[]) { + await act(async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, Object.fromEntries(actions.map((action) => [action.reportActionID, action]))); + }); + await waitForBatchedUpdatesWithAct(); +} + +async function mergeReportAction(reportID: string, action: ReportAction) { + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {[action.reportActionID]: action}); + }); + await waitForBatchedUpdatesWithAct(); +} + +describe('useVisibleReportActions', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + selectorMock.mockClear(); + await act(async () => { + await Onyx.clear(); + await Onyx.set(ONYXKEYS.SESSION, {accountID: CURRENT_USER_ACCOUNT_ID}); + }); + await waitForBatchedUpdatesWithAct(); + }); + + it('should re-run only when its own report actions change', async () => { + // Given a report and another report, each with one comment, and the hook mounted for the first one + await setReportActions(REPORT_ID, [createComment(1, REPORT_ID)]); + await setReportActions(OTHER_REPORT_ID, [createComment(2, OTHER_REPORT_ID)]); + const {result} = renderHook(() => useVisibleReportActions(REPORT_ID)); + await waitForBatchedUpdatesWithAct(); + const visibilityBeforeWrites = result.current; + const runsBeforeWrites = selectorMock.mock.calls.length; + expect(visibilityBeforeWrites).toEqual({[REPORT_ID]: {'1': true}}); + + // When a comment is added to the other report, which is a write to another member of the same collection + await mergeReportAction(OTHER_REPORT_ID, createComment(3, OTHER_REPORT_ID)); + + // Then the selector doesn't run at all, because the hook depends on its report's member key, not the whole collection + expect(selectorMock.mock.calls.length).toBe(runsBeforeWrites); + expect(result.current).toBe(visibilityBeforeWrites); + + // When a comment is added to the hook's own report + await mergeReportAction(REPORT_ID, createComment(4, REPORT_ID)); + + // Then the selector runs and the new action shows up + expect(selectorMock.mock.calls.length).toBeGreaterThan(runsBeforeWrites); + expect(result.current).toEqual({[REPORT_ID]: {'1': true, '4': true}}); + }); + + it('should follow the transaction thread when one is given', async () => { + // Given a report with a transaction thread, as an LHN row of a one-transaction report sees it + await setReportActions(REPORT_ID, [createComment(1, REPORT_ID)]); + await setReportActions(TRANSACTION_THREAD_REPORT_ID, [createComment(2, TRANSACTION_THREAD_REPORT_ID)]); + const {result} = renderHook(() => useVisibleReportActions(REPORT_ID, TRANSACTION_THREAD_REPORT_ID)); + await waitForBatchedUpdatesWithAct(); + expect(result.current).toEqual({[REPORT_ID]: {'1': true}, [TRANSACTION_THREAD_REPORT_ID]: {'2': true}}); + + // When a comment is added to the transaction thread only + await mergeReportAction(TRANSACTION_THREAD_REPORT_ID, createComment(3, TRANSACTION_THREAD_REPORT_ID)); + + // Then the row sees it, since the thread's member key is a dependency too + expect(result.current?.[TRANSACTION_THREAD_REPORT_ID]).toEqual({'2': true, '3': true}); + }); + + it('should recompute when the session user changes', async () => { + // Given a whisper to the current user + await setReportActions(REPORT_ID, [createWhisper(1, REPORT_ID, CURRENT_USER_ACCOUNT_ID)]); + const {result} = renderHook(() => useVisibleReportActions(REPORT_ID)); + await waitForBatchedUpdatesWithAct(); + expect(result.current?.[REPORT_ID]?.['1']).toBe(true); + + // When another user signs in, which changes whisper targeting while the report actions stay the same + await act(async () => { + await Onyx.set(ONYXKEYS.SESSION, {accountID: OTHER_USER_ACCOUNT_ID}); + }); + await waitForBatchedUpdatesWithAct(); + + // Then the whisper is hidden, so a cached result for the previous user is not reused + expect(result.current?.[REPORT_ID]?.['1']).toBe(false); + }); + + it('should drop a deleted action', async () => { + // Given a report with two visible comments + await setReportActions(REPORT_ID, [createComment(1, REPORT_ID), createComment(2, REPORT_ID)]); + const {result} = renderHook(() => useVisibleReportActions(REPORT_ID)); + await waitForBatchedUpdatesWithAct(); + expect(result.current).toEqual({[REPORT_ID]: {'1': true, '2': true}}); + + // When one comment is deleted with a null tombstone merge, which is how report action deletion works + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, {'1': null}); + }); + await waitForBatchedUpdatesWithAct(); + + // Then the deleted action is gone instead of left stale, because the report is rebuilt from its current actions + expect(result.current).toEqual({[REPORT_ID]: {'2': true}}); + }); + + it('should return undefined when the report has no actions', async () => { + // Given no report actions at all + + // When the hook is mounted for a report + const {result} = renderHook(() => useVisibleReportActions(REPORT_ID)); + await waitForBatchedUpdatesWithAct(); + + // Then it returns undefined, so isReportActionVisible falls back to the runtime check as it did without a derived entry + expect(result.current).toBeUndefined(); + }); +});