From ec5cf2abfd0ee81a427241f7141083c0d9600850 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Thu, 8 Oct 2026 18:24:31 +0200 Subject: [PATCH 1/3] Replace RAM_ONLY_SORTED_REPORT_ACTIONS derived value with a memoized useOnyxState hook --- __mocks__/react-native-onyx.ts | 4 +- src/ONYXKEYS.ts | 2 - src/hooks/useFilteredOptions.ts | 4 +- src/hooks/useSortedReportActionsData.ts | 28 +- src/libs/OptionsListUtils/index.ts | 2 +- src/libs/ReportAlternateTextUtils.ts | 15 -- .../OnyxDerived/ONYX_DERIVED_VALUES.ts | 2 - .../configs/sortedReportActions.ts | 125 --------- src/selectors/SortedReportActions.ts | 169 ++++++++++++ src/setup/index.ts | 1 - src/types/onyx/DerivedValues.ts | 15 +- src/types/onyx/index.ts | 2 - tests/unit/LhnSearchPreviewParityTest.ts | 5 +- .../unit/OnyxDerived/computeForReportTest.ts | 4 +- tests/unit/OnyxDerivedTest.tsx | 50 +--- tests/unit/useSearchSelectorTest.tsx | 81 ++++-- tests/unit/useSortedReportActionsDataTest.tsx | 255 ++++++++++++++++++ 17 files changed, 515 insertions(+), 249 deletions(-) delete mode 100644 src/libs/actions/OnyxDerived/configs/sortedReportActions.ts create mode 100644 src/selectors/SortedReportActions.ts create mode 100644 tests/unit/useSortedReportActionsDataTest.tsx diff --git a/__mocks__/react-native-onyx.ts b/__mocks__/react-native-onyx.ts index 6db22ac1b627..f5b09f2949da 100644 --- a/__mocks__/react-native-onyx.ts +++ b/__mocks__/react-native-onyx.ts @@ -5,7 +5,7 @@ import type {ConnectOptions, OnyxKey} from 'react-native-onyx'; // eslint-disable-next-line no-restricted-imports -import Onyx, {useOnyx} from 'react-native-onyx'; +import Onyx, {useOnyx, useOnyxState} from 'react-native-onyx'; let connectCallbackDelay = 0; function addDelayToConnectCallback(delay: number) { @@ -41,4 +41,4 @@ const reactNativeOnyxMock: ReactNativeOnyxMock = { export default reactNativeOnyxMock; -export {useOnyx}; +export {useOnyx, useOnyxState}; diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index d2b16572d106..46ddcaa9b321 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -1409,7 +1409,6 @@ const ONYXKEYS = { NON_PERSONAL_AND_WORKSPACE_CARD_LIST: 'nonPersonalAndWorkspaceCardList', PERSONAL_AND_WORKSPACE_CARD_LIST: 'personalAndWorkspaceCardList', CARD_FEED_ERRORS: 'cardFeedErrors', - RAM_ONLY_SORTED_REPORT_ACTIONS: 'sortedReportActions', LOGIN_TO_ACCOUNT_ID_MAP: 'loginToAccountIDMap', GUIDE_ACCOUNT_IDS: 'guideAccountIDs', SPEND_DATA_SIGNATURE: 'spendDataSignature', @@ -1980,7 +1979,6 @@ type OnyxDerivedValuesMapping = { [ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST]: OnyxTypes.NonPersonalAndWorkspaceCardListDerivedValue; [ONYXKEYS.DERIVED.PERSONAL_AND_WORKSPACE_CARD_LIST]: OnyxTypes.PersonalAndWorkspaceCardListDerivedValue; [ONYXKEYS.DERIVED.CARD_FEED_ERRORS]: OnyxTypes.CardFeedErrorsDerivedValue; - [ONYXKEYS.DERIVED.RAM_ONLY_SORTED_REPORT_ACTIONS]: OnyxTypes.SortedReportActionsDerivedValue; [ONYXKEYS.DERIVED.LOGIN_TO_ACCOUNT_ID_MAP]: OnyxTypes.LoginToAccountIDMapDerivedValue; [ONYXKEYS.DERIVED.SPEND_DATA_SIGNATURE]: OnyxTypes.SpendDataSignatureDerivedValue; [ONYXKEYS.DERIVED.GUIDE_ACCOUNT_IDS]: OnyxTypes.GuideAccountIDsDerivedValue; diff --git a/src/hooks/useFilteredOptions.ts b/src/hooks/useFilteredOptions.ts index 007a35a052fb..b497919fa2ee 100644 --- a/src/hooks/useFilteredOptions.ts +++ b/src/hooks/useFilteredOptions.ts @@ -107,8 +107,8 @@ function useFilteredOptions(config: UseFilteredOptionsConfig): UseFilteredOption const {preferredLocale, dateFnsLocale} = useLocalize(); const {convertToDisplayString} = useCurrencyListActions(); - // Sorted report actions from the RAM_ONLY_SORTED_REPORT_ACTIONS derived value; a new reference on - // every recompute, so it doubles as the report-actions invalidation signal for the option-list cache. + // A new reference only when a report's sorted actions changed, so it doubles as the report-actions + // invalidation signal for the option-list cache. const sortedReportActionsData = useSortedReportActionsData(); const sortedActions = sortedReportActionsData?.sortedActions; const transactionThreadIDs = sortedReportActionsData?.transactionThreadIDs; diff --git a/src/hooks/useSortedReportActionsData.ts b/src/hooks/useSortedReportActionsData.ts index fd3949534310..d0aedfb01115 100644 --- a/src/hooks/useSortedReportActionsData.ts +++ b/src/hooks/useSortedReportActionsData.ts @@ -1,10 +1,30 @@ +import {getIsOffline} from '@libs/NetworkState'; + import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report, ReportActions} from '@src/types/onyx'; + +import type {SortedReportActionsData} from '@selectors/SortedReportActions'; +import type {OnyxCollection} from 'react-native-onyx'; + +import {getSortedReportActionsData} from '@selectors/SortedReportActions'; +// useOnyxState reads the store directly, so Search surfaces get the live report actions instead of the snapshot @hooks/useOnyx swaps in +import {useOnyxState} from 'react-native-onyx'; -import useOnyx from './useOnyx'; +// NETWORK is a dependency only so the selector reruns when the offline state that getIsOffline() returns changes +const dependencies = [ONYXKEYS.COLLECTION.REPORT_ACTIONS, ONYXKEYS.COLLECTION.REPORT, ONYXKEYS.NETWORK]; -function useSortedReportActionsData() { - const [sortedReportActionsData] = useOnyx(ONYXKEYS.DERIVED.RAM_ONLY_SORTED_REPORT_ACTIONS); - return sortedReportActionsData; +function useSortedReportActionsData(): SortedReportActionsData { + return useOnyxState( + (state) => { + // useOnyxState's state view types a collection key as one member, but at runtime it returns the whole collection + /* eslint-disable @typescript-eslint/no-unsafe-type-assertion */ + const allReportActions = state[ONYXKEYS.COLLECTION.REPORT_ACTIONS] as OnyxCollection; + const allReports = state[ONYXKEYS.COLLECTION.REPORT] as OnyxCollection; + /* eslint-enable @typescript-eslint/no-unsafe-type-assertion */ + return getSortedReportActionsData(allReportActions, allReports, getIsOffline()); + }, + {dependencies}, + ); } export default useSortedReportActionsData; diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 65801e62558e..6eb4aee9189c 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -1263,7 +1263,7 @@ function createFilteredOptionList( // Money-request previews format amounts with this function; the provider memoizes it on the // currency list and locale, so a new reference signals the formatting inputs changed. options.convertToDisplayString, - // The RAM_ONLY_SORTED_REPORT_ACTIONS derived value produces a new object on every recompute, + // useSortedReportActionsData returns a new object only when a report's sorted actions changed, // so its reference signals that the underlying report actions changed. sortedActions, transactionThreadIDs, diff --git a/src/libs/ReportAlternateTextUtils.ts b/src/libs/ReportAlternateTextUtils.ts index cbb7f3a3ecc3..befb34148470 100644 --- a/src/libs/ReportAlternateTextUtils.ts +++ b/src/libs/ReportAlternateTextUtils.ts @@ -266,13 +266,9 @@ Onyx.connectWithoutView({ }, }); -/** @deprecated Use sortedReportActionsData from ONYXKEYS.DERIVED.RAM_ONLY_SORTED_REPORT_ACTIONS instead. Will be removed once all flows are migrated. */ const deprecatedLastReportActions: ReportActions = {}; -/** @deprecated Use sortedReportActionsData from ONYXKEYS.DERIVED.RAM_ONLY_SORTED_REPORT_ACTIONS instead. Will be removed once all flows are migrated. */ const deprecatedAllSortedReportActions: Record = {}; -/** @deprecated Use sortedReportActionsData from ONYXKEYS.DERIVED.RAM_ONLY_SORTED_REPORT_ACTIONS instead. Will be removed once all flows are migrated. */ const deprecatedCachedOneTransactionThreadReportIDs: Record = {}; -/** @deprecated Use sortedReportActionsData from ONYXKEYS.DERIVED.RAM_ONLY_SORTED_REPORT_ACTIONS instead. Will be removed once all flows are migrated. */ let deprecatedAllReportActions: OnyxCollection; // connectWithoutView is justified: this is module-level, non-render preview computation shared by LHN and Search; @@ -283,11 +279,9 @@ Onyx.connectWithoutView({ if (!actions) { return; } - // eslint-disable-next-line @typescript-eslint/no-deprecated deprecatedAllReportActions = actions ?? {}; // Iterate over the report actions to build the sorted report actions objects - // eslint-disable-next-line @typescript-eslint/no-deprecated for (const reportActions of Object.entries(deprecatedAllReportActions)) { const reportID = reportActions[0].split('_').at(1); if (!reportID) { @@ -296,7 +290,6 @@ Onyx.connectWithoutView({ const reportActionsArray = Object.values(reportActions[1] ?? {}); let sortedReportActions = getSortedReportActions(withDEWRoutedActionsArray(reportActionsArray), true); - // eslint-disable-next-line @typescript-eslint/no-deprecated deprecatedAllSortedReportActions[reportID] = sortedReportActions; const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; const chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`]; @@ -305,7 +298,6 @@ Onyx.connectWithoutView({ // to the transaction thread or the report itself. // Cache the result for O(1) lookup in renderItem. const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, actions[reportActions[0]], getIsOffline()); - // eslint-disable-next-line @typescript-eslint/no-deprecated deprecatedCachedOneTransactionThreadReportIDs[reportID] = transactionThreadReportID; if (transactionThreadReportID) { @@ -316,10 +308,8 @@ Onyx.connectWithoutView({ const firstReportAction = sortedReportActions.at(0); if (!firstReportAction) { - // eslint-disable-next-line @typescript-eslint/no-deprecated delete deprecatedLastReportActions[reportID]; } else { - // eslint-disable-next-line @typescript-eslint/no-deprecated deprecatedLastReportActions[reportID] = firstReportAction; } } @@ -425,7 +415,6 @@ function resolveLastActionContext( oneTransactionThreadReportID?: string, ): LastActionContext { const canUserPerformWrite = canUserPerformWriteAction(report, isReportArchived); - // eslint-disable-next-line @typescript-eslint/no-deprecated const resolvedOneTransactionThreadReportID = oneTransactionThreadReportID ?? deprecatedCachedOneTransactionThreadReportIDs[report.reportID]; const lastAction = getLastVisibleActionIncludingTransactionThread(report.reportID, canUserPerformWrite, undefined, visibleReportActionsData, resolvedOneTransactionThreadReportID); let lastActionReport: OnyxEntry; @@ -497,7 +486,6 @@ function getLastMessageTextForReport({ policyTags, currentUserLogin, isTrackIntentUser = false, - // eslint-disable-next-line @typescript-eslint/no-deprecated sortedActions = deprecatedAllSortedReportActions, currentUserAccountID, oneTransactionThreadReportID, @@ -535,7 +523,6 @@ function getLastMessageTextForReport({ const canUserPerformWrite = canUserPerformWriteAction(report, isReportArchived); let lastReportAction = lastAction ?? getLastVisibleAction(reportID, canUserPerformWrite, {}, undefined, visibleReportActionsDataParam); - // eslint-disable-next-line @typescript-eslint/no-deprecated const transactionThreadReportID = oneTransactionThreadReportID ?? (reportID ? deprecatedCachedOneTransactionThreadReportIDs[reportID] : undefined); if (reportID && !lastAction && transactionThreadReportID) { @@ -566,7 +553,6 @@ function getLastMessageTextForReport({ } // some types of actions are filtered out for lastReportAction, in some cases we need to check the actual last action - // eslint-disable-next-line @typescript-eslint/no-deprecated const lastOriginalReportAction = lastOriginalAction ?? (reportID ? deprecatedLastReportActions[reportID] : undefined); let lastMessageTextFromReport = ''; @@ -1241,7 +1227,6 @@ function getReportAlternateText({ const isAllowedToComment = canUserPerformWriteAction(report, isReportArchived); const isExpense = isExpenseReport(report); const reportMetadata = reportMetadataParam ?? getReportMetadata(report.reportID); - // eslint-disable-next-line @typescript-eslint/no-deprecated const resolvedOneTransactionThreadReportID = oneTransactionThreadReportID ?? deprecatedCachedOneTransactionThreadReportIDs[report.reportID]; const getParticipantPersonalDetailListExcludeCurrentUser = () => { if (participantPersonalDetailListExcludeCurrentUserParam) { diff --git a/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts b/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts index 4ea4fabd9560..a194ca4f9d27 100644 --- a/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts +++ b/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts @@ -13,7 +13,6 @@ import outstandingReportsByPolicyIDConfig from './configs/outstandingReportsByPo import personalAndWorkspaceCardListConfig from './configs/personalAndWorkspaceCardList'; import reportAttributesConfig from './configs/reportAttributes'; import reportTransactionsAndViolationsConfig from './configs/reportTransactionsAndViolations'; -import sortedReportActionsConfig from './configs/sortedReportActions'; import spendDataSignatureConfig from './configs/spendDataSignature'; import visibleReportActionsConfig from './configs/visibleReportActions'; @@ -29,7 +28,6 @@ const ONYX_DERIVED_VALUES = { [ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST]: nonPersonalAndWorkspaceCardListConfig, [ONYXKEYS.DERIVED.PERSONAL_AND_WORKSPACE_CARD_LIST]: personalAndWorkspaceCardListConfig, [ONYXKEYS.DERIVED.CARD_FEED_ERRORS]: cardFeedErrorsConfig, - [ONYXKEYS.DERIVED.RAM_ONLY_SORTED_REPORT_ACTIONS]: sortedReportActionsConfig, [ONYXKEYS.DERIVED.LOGIN_TO_ACCOUNT_ID_MAP]: loginToAccountIDMapConfig, [ONYXKEYS.DERIVED.GUIDE_ACCOUNT_IDS]: guideAccountIDsConfig, [ONYXKEYS.DERIVED.SPEND_DATA_SIGNATURE]: spendDataSignatureConfig, diff --git a/src/libs/actions/OnyxDerived/configs/sortedReportActions.ts b/src/libs/actions/OnyxDerived/configs/sortedReportActions.ts deleted file mode 100644 index 0a68f54a2479..000000000000 --- a/src/libs/actions/OnyxDerived/configs/sortedReportActions.ts +++ /dev/null @@ -1,125 +0,0 @@ -import {getIsOffline} from '@libs/NetworkState'; -import {getCombinedReportActions, getOneTransactionThreadReportID, getSortedReportActions, withDEWRoutedActionsArray} from '@libs/ReportActionsUtils'; - -import createOnyxDerivedValueConfig from '@userActions/OnyxDerived/createOnyxDerivedValueConfig'; - -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {Report, ReportAction, ReportActions} from '@src/types/onyx'; -import type {SortedReportActionsDerivedValue} from '@src/types/onyx/DerivedValues'; - -import type {OnyxCollection} from 'react-native-onyx'; - -const EMPTY_VALUE: SortedReportActionsDerivedValue = {sortedActions: {}, lastActions: {}, transactionThreadIDs: {}}; - -function computeForReport( - reportID: string, - actions: ReportActions, - allReportActions: OnyxCollection, - allReports: OnyxCollection, - isOffline: boolean, -): {sortedReportActions: ReportAction[]; transactionThreadReportID: string | undefined; lastAction: ReportAction | undefined} { - const reportActionsArray = Object.values(actions); - let sortedReportActions = getSortedReportActions(withDEWRoutedActionsArray(reportActionsArray), true); - - const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; - const chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`]; - - const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, actions, isOffline); - - if (transactionThreadReportID && allReportActions) { - const transactionThreadReportActionsArray = Object.values(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`] ?? {}); - const isSelfDM = report?.chatType === CONST.REPORT.CHAT_TYPE.SELF_DM; - sortedReportActions = getCombinedReportActions(sortedReportActions, transactionThreadReportID, transactionThreadReportActionsArray, isSelfDM); - } - - return { - sortedReportActions, - transactionThreadReportID, - lastAction: sortedReportActions.at(0), - }; -} - -export default createOnyxDerivedValueConfig({ - key: ONYXKEYS.DERIVED.RAM_ONLY_SORTED_REPORT_ACTIONS, - dependencies: [ONYXKEYS.COLLECTION.REPORT_ACTIONS, ONYXKEYS.COLLECTION.REPORT, ONYXKEYS.NETWORK], - compute: ([allReportActions, allReports], {sourceValues, currentValue, triggeredKeys}): SortedReportActionsDerivedValue => { - if (!allReportActions) { - return EMPTY_VALUE; - } - - // Read the in-memory offline state directly (NETWORK is a dependency so recompute still fires when it changes). - const isOffline = getIsOffline(); - - const reportActionsUpdates = sourceValues?.[ONYXKEYS.COLLECTION.REPORT_ACTIONS]; - - // The incremental branch only knows how to react to report-action changes; REPORT/NETWORK changes are - // handled by the full recompute below. Coalescing can batch REPORT_ACTIONS with REPORT/NETWORK in a - // single flush, so only go incremental when report actions are the sole trigger — otherwise a batched - // REPORT change (e.g. chatReportID -> transactionThreadReportID) would be silently dropped. - const reportActionsIsOnlyTrigger = triggeredKeys?.size === 1; - - // Incremental update: only recompute reports whose actions changed - if (reportActionsUpdates && currentValue && reportActionsIsOnlyTrigger) { - const sortedActions = {...currentValue.sortedActions}; - const lastActions = {...currentValue.lastActions}; - const transactionThreadIDs = {...currentValue.transactionThreadIDs}; - - for (const reportActionsKey of Object.keys(reportActionsUpdates)) { - const reportID = reportActionsKey.replace(ONYXKEYS.COLLECTION.REPORT_ACTIONS, ''); - if (!reportID) { - continue; - } - - const actions = allReportActions[reportActionsKey]; - if (!actions) { - delete sortedActions[reportID]; - delete lastActions[reportID]; - delete transactionThreadIDs[reportID]; - continue; - } - - const result = computeForReport(reportID, actions, allReportActions, allReports, isOffline); - sortedActions[reportID] = result.sortedReportActions; - transactionThreadIDs[reportID] = result.transactionThreadReportID; - if (result.lastAction) { - lastActions[reportID] = result.lastAction; - } else { - delete lastActions[reportID]; - } - } - - return {sortedActions, lastActions, transactionThreadIDs}; - } - - // Full recompute on first load or when reports change - const sortedActions: SortedReportActionsDerivedValue['sortedActions'] = {}; - const lastActions: SortedReportActionsDerivedValue['lastActions'] = {}; - const transactionThreadIDs: SortedReportActionsDerivedValue['transactionThreadIDs'] = {}; - - for (const key of Object.keys(allReportActions)) { - const actions = allReportActions[key]; - if (!actions) { - continue; - } - - const reportID = key.replace(ONYXKEYS.COLLECTION.REPORT_ACTIONS, ''); - if (!reportID) { - continue; - } - - const result = computeForReport(reportID, actions, allReportActions, allReports, isOffline); - sortedActions[reportID] = result.sortedReportActions; - transactionThreadIDs[reportID] = result.transactionThreadReportID; - if (result.lastAction) { - lastActions[reportID] = result.lastAction; - } else { - delete lastActions[reportID]; - } - } - - return {sortedActions, lastActions, transactionThreadIDs}; - }, -}); - -export {computeForReport}; diff --git a/src/selectors/SortedReportActions.ts b/src/selectors/SortedReportActions.ts new file mode 100644 index 000000000000..d15bbdfeb6ff --- /dev/null +++ b/src/selectors/SortedReportActions.ts @@ -0,0 +1,169 @@ +import {getCombinedReportActions, getOneTransactionThreadReportID, getSortedReportActions, withDEWRoutedActionsArray} from '@libs/ReportActionsUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report, ReportAction, ReportActions} from '@src/types/onyx'; + +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; + +type SortedReportActionsData = { + /** Sorted report actions keyed by report ID */ + sortedActions: Record; + + /** Last report action for each report, keyed by report ID */ + lastActions: Record; + + /** One-transaction thread report IDs keyed by report ID */ + transactionThreadIDs: Record; +}; + +type ReportResult = { + sortedReportActions: ReportAction[]; + transactionThreadReportID: string | undefined; + lastAction: ReportAction | undefined; +}; + +type ReportCacheEntry = { + report: OnyxEntry; + chatReport: OnyxEntry; + isOffline: boolean; + transactionThreadReportActions: OnyxEntry; + ownSortedReportActions: ReportAction[]; + result: ReportResult; +}; + +const EMPTY_SORTED_REPORT_ACTIONS_DATA: SortedReportActionsData = {sortedActions: {}, lastActions: {}, transactionThreadIDs: {}}; + +function sortOwnReportActions(actions: ReportActions): ReportAction[] { + return getSortedReportActions(withDEWRoutedActionsArray(Object.values(actions)), true); +} + +function combineWithTransactionThread( + ownSortedReportActions: ReportAction[], + report: OnyxEntry, + transactionThreadReportID: string | undefined, + allReportActions: OnyxCollection, +): ReportResult { + let sortedReportActions = ownSortedReportActions; + if (transactionThreadReportID && allReportActions) { + const transactionThreadReportActionsArray = Object.values(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`] ?? {}); + const isSelfDM = report?.chatType === CONST.REPORT.CHAT_TYPE.SELF_DM; + sortedReportActions = getCombinedReportActions(sortedReportActions, transactionThreadReportID, transactionThreadReportActionsArray, isSelfDM); + } + + return { + sortedReportActions, + transactionThreadReportID, + lastAction: sortedReportActions.at(0), + }; +} + +function computeForReport(reportID: string, actions: ReportActions, allReportActions: OnyxCollection, allReports: OnyxCollection, isOffline: boolean): ReportResult { + const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + const chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`]; + const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, actions, isOffline); + return combineWithTransactionThread(sortOwnReportActions(actions), report, transactionThreadReportID, allReportActions); +} + +const reportCache = new WeakMap(); + +function getTransactionThreadReportActions(allReportActions: OnyxCollection, transactionThreadReportID: string | undefined): OnyxEntry { + return transactionThreadReportID ? allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`] : undefined; +} + +/** + * Same result as computeForReport, cached on the frozen actions object of the report. The sort only reruns when the + * report's own actions change, and the previous result object is reused when the report, its chat report, its + * transaction thread actions and the offline state give the same output. + */ +function getReportResult(reportID: string, actions: ReportActions, allReportActions: OnyxCollection, allReports: OnyxCollection, isOffline: boolean): ReportResult { + const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + const chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`]; + const cachedEntry = reportCache.get(actions); + if ( + cachedEntry && + cachedEntry.report === report && + cachedEntry.chatReport === chatReport && + cachedEntry.isOffline === isOffline && + cachedEntry.transactionThreadReportActions === getTransactionThreadReportActions(allReportActions, cachedEntry.result.transactionThreadReportID) + ) { + return cachedEntry.result; + } + + const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, actions, isOffline); + const transactionThreadReportActions = getTransactionThreadReportActions(allReportActions, transactionThreadReportID); + const ownSortedReportActions = cachedEntry?.ownSortedReportActions ?? sortOwnReportActions(actions); + const canReuseResult = + !!cachedEntry && + cachedEntry.result.transactionThreadReportID === transactionThreadReportID && + cachedEntry.transactionThreadReportActions === transactionThreadReportActions && + (!transactionThreadReportID || cachedEntry.report?.chatType === report?.chatType); + const result = canReuseResult ? cachedEntry.result : combineWithTransactionThread(ownSortedReportActions, report, transactionThreadReportID, allReportActions); + + reportCache.set(actions, {report, chatReport, isOffline, transactionThreadReportActions, ownSortedReportActions, result}); + return result; +} + +let lastAllReportActions: OnyxCollection; +let lastAllReports: OnyxCollection; +let lastIsOffline: boolean | undefined; +let lastSortedReportActionsData = EMPTY_SORTED_REPORT_ACTIONS_DATA; + +/** + * Sorted actions, last action and one-transaction thread ID of every report that has actions. Every mounted consumer + * shares one pass per write, a report is only recomputed when one of its inputs changed, and the previous object is + * returned when no report changed, so consumers that compare it by reference skip their work. + */ +function getSortedReportActionsData(allReportActions: OnyxCollection, allReports: OnyxCollection, isOffline: boolean): SortedReportActionsData { + if (!allReportActions) { + return EMPTY_SORTED_REPORT_ACTIONS_DATA; + } + if (allReportActions === lastAllReportActions && allReports === lastAllReports && isOffline === lastIsOffline) { + return lastSortedReportActionsData; + } + + const previousData = lastSortedReportActionsData; + const sortedActions: SortedReportActionsData['sortedActions'] = {}; + const lastActions: SortedReportActionsData['lastActions'] = {}; + const transactionThreadIDs: SortedReportActionsData['transactionThreadIDs'] = {}; + let reportCount = 0; + let hasChangedReport = false; + + for (const key of Object.keys(allReportActions)) { + const actions = allReportActions[key]; + if (!actions) { + continue; + } + + const reportID = key.replace(ONYXKEYS.COLLECTION.REPORT_ACTIONS, ''); + if (!reportID) { + continue; + } + + const result = getReportResult(reportID, actions, allReportActions, allReports, isOffline); + reportCount++; + if ( + !(reportID in previousData.transactionThreadIDs) || + previousData.sortedActions[reportID] !== result.sortedReportActions || + previousData.transactionThreadIDs[reportID] !== result.transactionThreadReportID + ) { + hasChangedReport = true; + } + sortedActions[reportID] = result.sortedReportActions; + transactionThreadIDs[reportID] = result.transactionThreadReportID; + if (result.lastAction) { + lastActions[reportID] = result.lastAction; + } + } + + lastAllReportActions = allReportActions; + lastAllReports = allReports; + lastIsOffline = isOffline; + if (hasChangedReport || reportCount !== Object.keys(previousData.transactionThreadIDs).length) { + lastSortedReportActionsData = {sortedActions, lastActions, transactionThreadIDs}; + } + return lastSortedReportActionsData; +} + +export {computeForReport, getSortedReportActionsData}; +export type {SortedReportActionsData}; diff --git a/src/setup/index.ts b/src/setup/index.ts index 24d54ed5d84d..1c0343adb5d7 100644 --- a/src/setup/index.ts +++ b/src/setup/index.ts @@ -77,7 +77,6 @@ export default function () { ONYXKEYS.RAM_ONLY_MOBILE_SELECTION_MODE, ONYXKEYS.RAM_ONLY_IS_SIDEBAR_LOADED, ONYXKEYS.RAM_ONLY_IS_PRODUCT_MARKETING_WINDOW_COVERED, - ONYXKEYS.DERIVED.RAM_ONLY_SORTED_REPORT_ACTIONS, ONYXKEYS.RAM_ONLY_IS_CHECKING_PUBLIC_ROOM, ONYXKEYS.RAM_ONLY_IS_CHECKING_PENDING_WALLET_APPROVAL, ONYXKEYS.RAM_ONLY_UPDATE_REQUIRED, diff --git a/src/types/onyx/DerivedValues.ts b/src/types/onyx/DerivedValues.ts index 3f5e2a136157..b5d493580427 100644 --- a/src/types/onyx/DerivedValues.ts +++ b/src/types/onyx/DerivedValues.ts @@ -3,7 +3,7 @@ import type CONST from '@src/CONST'; import type {OnyxCollection} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; -import type {Card, ReportAction} from '.'; +import type {Card} from '.'; import type {CardList} from './Card'; import type {CardFeedWithDomainID} from './CardFeeds'; import type {Errors} from './OnyxCommon'; @@ -194,18 +194,6 @@ type CardFeedErrorsDerivedValue = CardFeedErrors; */ type NonPersonalAndWorkspaceCardListDerivedValue = CardList; -/** - * The derived value for sorted report actions, last report actions, and cached transaction thread report IDs. - */ -type SortedReportActionsDerivedValue = { - /** Sorted report actions keyed by report ID */ - sortedActions: Record; - /** Last report action for each report, keyed by report ID */ - lastActions: Record; - /** Transaction thread report IDs keyed by parent report action ID */ - transactionThreadIDs: Record; -}; - /** * The derived value for merged personal and workspace card feeds. */ @@ -251,7 +239,6 @@ export type { ReportTransactionsAndViolations, OutstandingReportsByPolicyIDDerivedValue, VisibleReportActionsDerivedValue, - SortedReportActionsDerivedValue, NonPersonalAndWorkspaceCardListDerivedValue, PersonalAndWorkspaceCardListDerivedValue, CardFeedErrorsDerivedValue, diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index fad7cf8b5ab5..acdae67f4b5c 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -69,7 +69,6 @@ import type { PersonalAndWorkspaceCardListDerivedValue, ReportAttributesDerivedValue, ReportTransactionsAndViolationsDerivedValue, - SortedReportActionsDerivedValue, VisibleReportActionsDerivedValue, } from './DerivedValues'; import type DeviceBiometrics from './DeviceBiometrics'; @@ -457,7 +456,6 @@ export type { ReportTransactionsAndViolationsDerivedValue, OutstandingReportsByPolicyIDDerivedValue, VisibleReportActionsDerivedValue, - SortedReportActionsDerivedValue, NonPersonalAndWorkspaceCardListDerivedValue, PersonalAndWorkspaceCardListDerivedValue, CardFeedErrorsDerivedValue, diff --git a/tests/unit/LhnSearchPreviewParityTest.ts b/tests/unit/LhnSearchPreviewParityTest.ts index da619e66777b..2a404f70df11 100644 --- a/tests/unit/LhnSearchPreviewParityTest.ts +++ b/tests/unit/LhnSearchPreviewParityTest.ts @@ -1,7 +1,8 @@ /* eslint-disable @typescript-eslint/naming-convention */ -import {act} from '@testing-library/react-native'; +import {act, renderHook} from '@testing-library/react-native'; import type {PrivateIsArchivedMap} from '@hooks/usePrivateIsArchivedMap'; +import useSortedReportActionsData from '@hooks/useSortedReportActionsData'; import {getMovedReportID} from '@libs/ModifiedExpenseMessage'; import {clearFilteredOptionListCache, createFilteredOptionList, getSearchOptions} from '@libs/OptionsListUtils'; @@ -323,7 +324,7 @@ async function computeBothSurfaces({ await waitForBatchedUpdatesWithAct(); } - const sortedData = await getOnyxValue(ONYXKEYS.DERIVED.RAM_ONLY_SORTED_REPORT_ACTIONS); + const sortedData = renderHook(() => useSortedReportActionsData()).result.current; const reportAttributesValue = await getOnyxValue(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES); const reportAttributesDerived = reportAttributesValue?.reports; diff --git a/tests/unit/OnyxDerived/computeForReportTest.ts b/tests/unit/OnyxDerived/computeForReportTest.ts index 3efe23623ea8..3ee172ac2959 100644 --- a/tests/unit/OnyxDerived/computeForReportTest.ts +++ b/tests/unit/OnyxDerived/computeForReportTest.ts @@ -1,11 +1,11 @@ -import {computeForReport} from '@libs/actions/OnyxDerived/configs/sortedReportActions'; - import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Report, ReportAction, ReportActions} from '@src/types/onyx'; import type {OnyxCollection} from 'react-native-onyx'; +import {computeForReport} from '@selectors/SortedReportActions'; + import {createMockReport} from '../../utils/ReportTestUtils'; function createAction(id: string, created: string, overrides: Partial = {}): ReportAction { diff --git a/tests/unit/OnyxDerivedTest.tsx b/tests/unit/OnyxDerivedTest.tsx index cb5d50da8939..56a97345e9d8 100644 --- a/tests/unit/OnyxDerivedTest.tsx +++ b/tests/unit/OnyxDerivedTest.tsx @@ -18,7 +18,7 @@ import OnyxUtils from 'react-native-onyx/dist/OnyxUtils'; import {createRandomCompanyCard, createRandomExpensifyCard} from '../utils/collections/card'; import {createRandomReport} from '../utils/collections/reports'; import createRandomTransaction from '../utils/collections/transaction'; -import {createMockReport, getFakeReportAction} from '../utils/ReportTestUtils'; +import {getFakeReportAction} from '../utils/ReportTestUtils'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; const onyxDerivedTestSetup = () => { @@ -760,54 +760,6 @@ describe('OnyxDerived', () => { }); }); - describe('sortedReportActions', () => { - it('applies a REPORT change that is coalesced with a REPORT_ACTIONS change for another report', async () => { - const chatReportID = '10'; - const expenseReportID = '20'; - const parentChatReportID = '30'; - const threadReportID = '40'; - - const iouAction = getFakeReportAction(100, { - actionName: CONST.REPORT.ACTIONS.TYPE.IOU, - childReportID: threadReportID, - reportID: expenseReportID, - originalMessage: {IOUTransactionID: 'txn1', type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, amount: 100, currency: 'USD'}, - } as Partial); - - await Onyx.multiSet({ - [`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}` as const]: createMockReport({reportID: chatReportID, type: CONST.REPORT.TYPE.CHAT}), - // The expense report starts as a CHAT, so its one-transaction thread does not resolve yet. - [`${ONYXKEYS.COLLECTION.REPORT}${expenseReportID}` as const]: createMockReport({reportID: expenseReportID, type: CONST.REPORT.TYPE.CHAT, chatReportID: parentChatReportID}), - [`${ONYXKEYS.COLLECTION.REPORT}${parentChatReportID}` as const]: createMockReport({reportID: parentChatReportID, type: CONST.REPORT.TYPE.CHAT}), - [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}` as const]: {'1': getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT})}, - [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReportID}` as const]: {'100': iouAction}, - [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${threadReportID}` as const]: {'200': getFakeReportAction(200, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT})}, - }); - await waitForBatchedUpdates(); - - // Precondition: while it is a CHAT, no transaction thread is resolved for the report. - let derived = await OnyxUtils.get(ONYXKEYS.DERIVED.RAM_ONLY_SORTED_REPORT_ACTIONS); - expect(derived?.transactionThreadIDs?.[expenseReportID]).toBeUndefined(); - - // One logical update: flip the report to EXPENSE (a REPORT change that resolves its thread) batched - // with an unrelated REPORT_ACTIONS change to a different report. - const updates: Array> = [ - {onyxMethod: Onyx.METHOD.MERGE_COLLECTION, key: ONYXKEYS.COLLECTION.REPORT, value: {[`${ONYXKEYS.COLLECTION.REPORT}${expenseReportID}`]: {type: CONST.REPORT.TYPE.EXPENSE}}}, - { - onyxMethod: Onyx.METHOD.MERGE_COLLECTION, - key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, - value: {[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`]: {'2': getFakeReportAction(2, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT})}}, - }, - ]; - await Onyx.update(updates); - await waitForBatchedUpdates(); - - // The batched REPORT change must be applied: the expense report's transaction thread now resolves. - derived = await OnyxUtils.get(ONYXKEYS.DERIVED.RAM_ONLY_SORTED_REPORT_ACTIONS); - expect(derived?.transactionThreadIDs?.[expenseReportID]).toBe(threadReportID); - }); - }); - describe('nonPersonalAndWorkspaceCardList', () => { beforeAll(async () => { // Initialize dependency keys so Onyx.clear() in beforeEach triggers derived value recomputation diff --git a/tests/unit/useSearchSelectorTest.tsx b/tests/unit/useSearchSelectorTest.tsx index cafd387c4466..93eb062269d9 100644 --- a/tests/unit/useSearchSelectorTest.tsx +++ b/tests/unit/useSearchSelectorTest.tsx @@ -11,8 +11,9 @@ import type {OptionData} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetails, ReportAction} from '@src/types/onyx'; -import type {SortedReportActionsDerivedValue} from '@src/types/onyx/DerivedValues'; +import type {ReportActionsCollectionDataSet} from '@src/types/onyx/ReportAction'; +import type {SortedReportActionsData} from '@selectors/SortedReportActions'; import type {OnyxMultiSetInput} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; @@ -64,17 +65,21 @@ jest.mock('@hooks/useCurrentUserPersonalDetails', () => () => ({ email: MOCK_EMAIL, })); -function buildMockSortedActions(reportIDs: string[]): SortedReportActionsDerivedValue { +function buildMockAction(reportID: string): ReportAction { + return { + reportActionID: `action_${reportID}`, + created: '2025-01-01 10:00:00.000', + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + } as ReportAction; +} + +function buildMockSortedActions(reportIDs: string[]): SortedReportActionsData { const sortedActions: Record = {}; const lastActions: Record = {}; const transactionThreadIDs: Record = {}; for (const id of reportIDs) { - const action = { - reportActionID: `action_${id}`, - created: '2025-01-01 10:00:00.000', - actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, - } as ReportAction; + const action = buildMockAction(id); sortedActions[id] = [action]; lastActions[id] = action; transactionThreadIDs[id] = undefined; @@ -83,6 +88,15 @@ function buildMockSortedActions(reportIDs: string[]): SortedReportActionsDerived return {sortedActions, lastActions, transactionThreadIDs}; } +async function setReportActions(reportIDs: string[]) { + const reportActions: ReportActionsCollectionDataSet = {}; + for (const id of reportIDs) { + const action = buildMockAction(id); + reportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${id}`] = {[action.reportActionID]: action}; + } + await Onyx.multiSet(reportActions); +} + describe('useSearchSelector sortedActions integration', () => { beforeAll(() => { Onyx.init({keys: ONYXKEYS}); @@ -113,7 +127,9 @@ describe('useSearchSelector sortedActions integration', () => { }); }); - it('passes undefined sortedActions to getValidOptions when RAM_ONLY_SORTED_REPORT_ACTIONS is not set', async () => { + it('should pass empty sortedActions to getValidOptions when there are no report actions', async () => { + // Given no report actions in Onyx + // When the selector mounts renderHook(() => useSearchSelectorBase({ selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_SINGLE, @@ -125,17 +141,19 @@ describe('useSearchSelector sortedActions integration', () => { expect(mockGetValidOptions).toHaveBeenCalled(); const lastCall = mockGetValidOptions.mock.calls.at(-1); const config = lastCall?.[7]; - expect(config?.sortedActions).toBeUndefined(); + // Then option building gets an empty map, which is what the derived value held after its first compute + expect(config?.sortedActions).toEqual({}); }); - it('passes sortedActions from RAM_ONLY_SORTED_REPORT_ACTIONS to getValidOptions for GENERAL context', async () => { + it('should pass sortedActions built from the report actions to getValidOptions for GENERAL context', async () => { + // Given report actions in Onyx, the input the sorted actions are built from const mockData = buildMockSortedActions(['1', '2']); - await act(async () => { - await Onyx.set(ONYXKEYS.DERIVED.RAM_ONLY_SORTED_REPORT_ACTIONS, mockData); + await setReportActions(['1', '2']); }); await waitForBatchedUpdatesWithAct(); + // When the selector mounts renderHook(() => useSearchSelectorBase({ selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_SINGLE, @@ -144,20 +162,22 @@ describe('useSearchSelector sortedActions integration', () => { ); await waitForBatchedUpdatesWithAct(); + // Then option building gets the actions sorted per report expect(mockGetValidOptions).toHaveBeenCalled(); const lastCall = mockGetValidOptions.mock.calls.at(-1); const config = lastCall?.[7]; expect(config?.sortedActions).toEqual(mockData.sortedActions); }); - it('passes sortedActions to getValidOptions for SHARE_DESTINATION context', async () => { + it('should pass sortedActions to getValidOptions for SHARE_DESTINATION context', async () => { + // Given report actions in Onyx, the input the sorted actions are built from const mockData = buildMockSortedActions(['20', '21']); - await act(async () => { - await Onyx.set(ONYXKEYS.DERIVED.RAM_ONLY_SORTED_REPORT_ACTIONS, mockData); + await setReportActions(['20', '21']); }); await waitForBatchedUpdatesWithAct(); + // When the selector mounts renderHook(() => useSearchSelectorBase({ selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_SINGLE, @@ -166,20 +186,22 @@ describe('useSearchSelector sortedActions integration', () => { ); await waitForBatchedUpdatesWithAct(); + // Then option building gets the actions sorted per report expect(mockGetValidOptions).toHaveBeenCalled(); const lastCall = mockGetValidOptions.mock.calls.at(-1); const config = lastCall?.[7]; expect(config?.sortedActions).toEqual(mockData.sortedActions); }); - it('passes sortedActions to getValidOptions for ATTENDEES context', async () => { + it('should pass sortedActions to getValidOptions for ATTENDEES context', async () => { + // Given report actions in Onyx, the input the sorted actions are built from const mockData = buildMockSortedActions(['30']); - await act(async () => { - await Onyx.set(ONYXKEYS.DERIVED.RAM_ONLY_SORTED_REPORT_ACTIONS, mockData); + await setReportActions(['30']); }); await waitForBatchedUpdatesWithAct(); + // When the selector mounts renderHook(() => useSearchSelectorBase({ selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_SINGLE, @@ -188,20 +210,22 @@ describe('useSearchSelector sortedActions integration', () => { ); await waitForBatchedUpdatesWithAct(); + // Then option building gets the actions sorted per report expect(mockGetValidOptions).toHaveBeenCalled(); const lastCall = mockGetValidOptions.mock.calls.at(-1); const config = lastCall?.[7]; expect(config?.sortedActions).toEqual(mockData.sortedActions); }); - it('updates sortedActions when RAM_ONLY_SORTED_REPORT_ACTIONS changes in Onyx', async () => { + it('should update sortedActions when report actions change in Onyx', async () => { + // Given report actions in Onyx, the input the sorted actions are built from const initialData = buildMockSortedActions(['1']); - await act(async () => { - await Onyx.set(ONYXKEYS.DERIVED.RAM_ONLY_SORTED_REPORT_ACTIONS, initialData); + await setReportActions(['1']); }); await waitForBatchedUpdatesWithAct(); + // When the selector mounts renderHook(() => useSearchSelectorBase({ selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_SINGLE, @@ -210,27 +234,31 @@ describe('useSearchSelector sortedActions integration', () => { ); await waitForBatchedUpdatesWithAct(); + // Then the first build gets the initial actions const firstCallConfig = mockGetValidOptions.mock.calls.at(-1)?.[7]; expect(firstCallConfig?.sortedActions).toEqual(initialData.sortedActions); + // When actions arrive for two more reports while the selector is mounted const updatedData = buildMockSortedActions(['1', '2', '3']); await act(async () => { - await Onyx.set(ONYXKEYS.DERIVED.RAM_ONLY_SORTED_REPORT_ACTIONS, updatedData); + await setReportActions(['1', '2', '3']); }); await waitForBatchedUpdatesWithAct(); + // Then option building is rerun with the new reports, since the hook reads report actions live const latestCallConfig = mockGetValidOptions.mock.calls.at(-1)?.[7]; expect(latestCallConfig?.sortedActions).toEqual(updatedData.sortedActions); }); - it('passes sortedActions to getSearchOptions for SEARCH context (SEARCH_CONTEXT_SEARCH)', async () => { + it('should pass sortedActions to getSearchOptions for SEARCH context (SEARCH_CONTEXT_SEARCH)', async () => { + // Given report actions in Onyx, the input the sorted actions are built from const mockData = buildMockSortedActions(['1']); - await act(async () => { - await Onyx.set(ONYXKEYS.DERIVED.RAM_ONLY_SORTED_REPORT_ACTIONS, mockData); + await setReportActions(['1']); }); await waitForBatchedUpdatesWithAct(); + // When the selector mounts renderHook(() => useSearchSelectorBase({ selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_SINGLE, @@ -239,10 +267,11 @@ describe('useSearchSelector sortedActions integration', () => { ); await waitForBatchedUpdatesWithAct(); + // Then search option building gets the sorted actions too expect(mockGetSearchOptions).toHaveBeenCalled(); const lastSearchCall = mockGetSearchOptions.mock.calls.at(-1); const searchConfig = lastSearchCall?.[0]; - expect(searchConfig).toHaveProperty('sortedActions'); + expect(searchConfig).toHaveProperty('sortedActions', mockData.sortedActions); }); }); diff --git a/tests/unit/useSortedReportActionsDataTest.tsx b/tests/unit/useSortedReportActionsDataTest.tsx new file mode 100644 index 000000000000..ffd4f4b96298 --- /dev/null +++ b/tests/unit/useSortedReportActionsDataTest.tsx @@ -0,0 +1,255 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import useSortedReportActionsData from '@hooks/useSortedReportActionsData'; + +import * as ReportActionsUtils from '@libs/ReportActionsUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report, ReportAction, ReportActions} from '@src/types/onyx'; + +import type {OnyxCollection, OnyxUpdate} from 'react-native-onyx'; + +import {computeForReport, getSortedReportActionsData} from '@selectors/SortedReportActions'; +import Onyx from 'react-native-onyx'; + +import {createMockReport, getFakeReportAction} from '../utils/ReportTestUtils'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +const CHAT_REPORT_ID = '10'; +const EXPENSE_REPORT_ID = '20'; +const PARENT_CHAT_REPORT_ID = '30'; +const THREAD_REPORT_ID = '40'; + +function reportActionsKey(reportID: TReportID) { + return `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}` as const; +} + +function reportKey(reportID: TReportID) { + return `${ONYXKEYS.COLLECTION.REPORT}${reportID}` as const; +} + +function createComment(id: number, created: string): ReportAction { + return getFakeReportAction(id, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, created}); +} + +function createIOUAction(id: number, overrides: Partial = {}): ReportAction { + return getFakeReportAction(id, { + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + childReportID: THREAD_REPORT_ID, + created: '2024-01-01 10:00:00.000', + originalMessage: {IOUTransactionID: `txn${id}`, IOUReportID: EXPENSE_REPORT_ID, type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, amount: 100, currency: 'USD'}, + ...overrides, + } as Partial); +} + +function toReportActions(...actions: ReportAction[]): ReportActions { + return Object.fromEntries(actions.map((action) => [action.reportActionID, action])); +} + +/** A chat with two comments, and a one-transaction expense report whose thread has one comment. */ +function buildInputs() { + const allReportActions: OnyxCollection = { + [reportActionsKey(CHAT_REPORT_ID)]: toReportActions(createComment(1, '2024-01-01 09:00:00.000'), createComment(2, '2024-01-01 09:30:00.000')), + [reportActionsKey(EXPENSE_REPORT_ID)]: toReportActions(createIOUAction(100)), + [reportActionsKey(THREAD_REPORT_ID)]: toReportActions(createComment(200, '2024-01-01 11:00:00.000')), + }; + const allReports: OnyxCollection = { + [reportKey(CHAT_REPORT_ID)]: createMockReport({reportID: CHAT_REPORT_ID, type: CONST.REPORT.TYPE.CHAT}), + [reportKey(EXPENSE_REPORT_ID)]: createMockReport({reportID: EXPENSE_REPORT_ID, type: CONST.REPORT.TYPE.EXPENSE, chatReportID: PARENT_CHAT_REPORT_ID}), + [reportKey(PARENT_CHAT_REPORT_ID)]: createMockReport({reportID: PARENT_CHAT_REPORT_ID, type: CONST.REPORT.TYPE.CHAT}), + }; + return {allReportActions, allReports}; +} + +describe('getSortedReportActionsData', () => { + it('should give every report the same result as computeForReport', () => { + // Given a chat and a one-transaction expense report with its thread + const {allReportActions, allReports} = buildInputs(); + + // When the data is built + const data = getSortedReportActionsData(allReportActions, allReports, false); + + // Then each report matches a fresh compute, so consumers get what the derived value gave them + for (const reportID of [CHAT_REPORT_ID, EXPENSE_REPORT_ID, THREAD_REPORT_ID]) { + const expected = computeForReport(reportID, allReportActions?.[reportActionsKey(reportID)] ?? {}, allReportActions, allReports, false); + expect(data.sortedActions[reportID]).toEqual(expected.sortedReportActions); + expect(data.lastActions[reportID]).toEqual(expected.lastAction); + expect(data.transactionThreadIDs[reportID]).toBe(expected.transactionThreadReportID); + } + expect(data.transactionThreadIDs[EXPENSE_REPORT_ID]).toBe(THREAD_REPORT_ID); + }); + + it('should return the previous object when a new snapshot leaves every report input unchanged', () => { + // Given data built from a set of inputs + const {allReportActions, allReports} = buildInputs(); + const data = getSortedReportActionsData(allReportActions, allReports, false); + + // When a report without actions is added, which gives both collections a new reference + const nextReports = {...allReports, [reportKey('999')]: createMockReport({reportID: '999'})}; + const nextData = getSortedReportActionsData({...allReportActions}, nextReports, false); + + // Then the same object comes back, so consumers comparing by reference skip their work + expect(nextData).toBe(data); + }); + + it('should keep the arrays of untouched reports when another report gets a new action', () => { + // Given data built from a set of inputs + const {allReportActions, allReports} = buildInputs(); + const data = getSortedReportActionsData(allReportActions, allReports, false); + + // When the chat gets a new comment + const newComment = createComment(3, '2024-01-01 12:00:00.000'); + const nextReportActions = { + ...allReportActions, + [reportActionsKey(CHAT_REPORT_ID)]: {...allReportActions?.[reportActionsKey(CHAT_REPORT_ID)], [newComment.reportActionID]: newComment}, + }; + const nextData = getSortedReportActionsData(nextReportActions, allReports, false); + + // Then only the chat is rebuilt and the expense report keeps its array + expect(nextData).not.toBe(data); + expect(nextData.lastActions[CHAT_REPORT_ID]).toBe(newComment); + expect(nextData.sortedActions[EXPENSE_REPORT_ID]).toBe(data.sortedActions[EXPENSE_REPORT_ID]); + }); + + it('should rebuild the parent report when only its transaction thread gets a new action', () => { + // Given data built from a set of inputs + const {allReportActions, allReports} = buildInputs(); + getSortedReportActionsData(allReportActions, allReports, false); + + // When the transaction thread gets a new comment + const threadComment = createComment(201, '2024-01-01 13:00:00.000'); + const nextReportActions = { + ...allReportActions, + [reportActionsKey(THREAD_REPORT_ID)]: {...allReportActions?.[reportActionsKey(THREAD_REPORT_ID)], [threadComment.reportActionID]: threadComment}, + }; + const nextData = getSortedReportActionsData(nextReportActions, allReports, false); + + // Then the expense report, which shows its thread's actions combined with its own, includes the new comment + expect(nextData.lastActions[EXPENSE_REPORT_ID]).toBe(threadComment); + }); + + it('should drop a report whose actions are removed', () => { + // Given data built from a set of inputs + const {allReportActions, allReports} = buildInputs(); + getSortedReportActionsData(allReportActions, allReports, false); + + // When the chat's actions are removed + const nextReportActions = {...allReportActions}; + delete nextReportActions[reportActionsKey(CHAT_REPORT_ID)]; + const nextData = getSortedReportActionsData(nextReportActions, allReports, false); + + // Then the chat has no entry left + expect(nextData.sortedActions[CHAT_REPORT_ID]).toBeUndefined(); + expect(nextData.lastActions[CHAT_REPORT_ID]).toBeUndefined(); + expect(CHAT_REPORT_ID in nextData.transactionThreadIDs).toBe(false); + }); + + it('should return the previous object when going offline does not change any transaction thread', () => { + // Given data built while online + const {allReportActions, allReports} = buildInputs(); + const data = getSortedReportActionsData(allReportActions, allReports, false); + + // When the app goes offline and no report has a pending deleted expense + const nextData = getSortedReportActionsData(allReportActions, allReports, true); + + // Then nothing changed, so the same object comes back + expect(nextData).toBe(data); + }); + + it('should drop the transaction thread when going offline with a second expense pending deletion', () => { + // Given a one-transaction expense report with a second expense deleted while offline + const {allReportActions, allReports} = buildInputs(); + const pendingDeleteIOUAction = createIOUAction(101, { + childReportID: '41', + created: '2024-01-01 09:00:00.000', + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + message: [{type: 'COMMENT', html: '', text: ''}], + }); + const reportActions = { + ...allReportActions, + [reportActionsKey(EXPENSE_REPORT_ID)]: {...allReportActions?.[reportActionsKey(EXPENSE_REPORT_ID)], [pendingDeleteIOUAction.reportActionID]: pendingDeleteIOUAction}, + }; + const onlineData = getSortedReportActionsData(reportActions, allReports, false); + + // When the app goes offline, which counts the pending-delete expense again + const offlineData = getSortedReportActionsData(reportActions, allReports, true); + + // Then the report is no longer a one-transaction report, as computeForReport says + expect(onlineData.transactionThreadIDs[EXPENSE_REPORT_ID]).toBe(THREAD_REPORT_ID); + expect(offlineData.transactionThreadIDs[EXPENSE_REPORT_ID]).toBeUndefined(); + }); +}); + +describe('useSortedReportActionsData', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await act(async () => { + await Onyx.clear(); + }); + await waitForBatchedUpdatesWithAct(); + }); + + it('should apply a REPORT change that is batched with a REPORT_ACTIONS change for another report', async () => { + // Given an expense report that is still a CHAT, so its one-transaction thread does not resolve yet + await act(async () => { + await Onyx.multiSet({ + [reportKey(CHAT_REPORT_ID)]: createMockReport({reportID: CHAT_REPORT_ID, type: CONST.REPORT.TYPE.CHAT}), + [reportKey(EXPENSE_REPORT_ID)]: createMockReport({reportID: EXPENSE_REPORT_ID, type: CONST.REPORT.TYPE.CHAT, chatReportID: PARENT_CHAT_REPORT_ID}), + [reportKey(PARENT_CHAT_REPORT_ID)]: createMockReport({reportID: PARENT_CHAT_REPORT_ID, type: CONST.REPORT.TYPE.CHAT}), + [reportActionsKey(CHAT_REPORT_ID)]: toReportActions(createComment(1, '2024-01-01 09:00:00.000')), + [reportActionsKey(EXPENSE_REPORT_ID)]: toReportActions(createIOUAction(100)), + [reportActionsKey(THREAD_REPORT_ID)]: toReportActions(createComment(200, '2024-01-01 11:00:00.000')), + }); + }); + await waitForBatchedUpdatesWithAct(); + const {result} = renderHook(() => useSortedReportActionsData()); + expect(result.current.transactionThreadIDs[EXPENSE_REPORT_ID]).toBeUndefined(); + + // When one update flips the report to EXPENSE and adds a comment to a different report + const updates: Array> = [ + {onyxMethod: Onyx.METHOD.MERGE_COLLECTION, key: ONYXKEYS.COLLECTION.REPORT, value: {[reportKey(EXPENSE_REPORT_ID)]: {type: CONST.REPORT.TYPE.EXPENSE}}}, + { + onyxMethod: Onyx.METHOD.MERGE_COLLECTION, + key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, + value: {[reportActionsKey(CHAT_REPORT_ID)]: toReportActions(createComment(2, '2024-01-01 09:30:00.000'))}, + }, + ]; + await act(async () => { + await Onyx.update(updates); + }); + await waitForBatchedUpdatesWithAct(); + + // Then the batched REPORT change is applied and the expense report's thread now resolves + expect(result.current.transactionThreadIDs[EXPENSE_REPORT_ID]).toBe(THREAD_REPORT_ID); + expect(result.current.lastActions[CHAT_REPORT_ID]?.reportActionID).toBe('2'); + }); + + it('should not sort again when a NETWORK write leaves the offline state unchanged', async () => { + // Given the hook mounted over some report actions + await act(async () => { + await Onyx.multiSet({ + [reportActionsKey(CHAT_REPORT_ID)]: toReportActions(createComment(1, '2024-01-01 09:00:00.000')), + [reportActionsKey(EXPENSE_REPORT_ID)]: toReportActions(createIOUAction(100)), + }); + }); + await waitForBatchedUpdatesWithAct(); + const {result} = renderHook(() => useSortedReportActionsData()); + const data = result.current; + const sortSpy = jest.spyOn(ReportActionsUtils, 'getSortedReportActions'); + + // When NETWORK changes a field that does not affect the offline state + await act(async () => { + await Onyx.merge(ONYXKEYS.NETWORK, {timeSkew: 5}); + }); + await waitForBatchedUpdatesWithAct(); + + // Then no report is sorted again, where the derived value re-sorted every report, and the same object comes back + expect(sortSpy).not.toHaveBeenCalled(); + expect(result.current).toBe(data); + sortSpy.mockRestore(); + }); +}); From 32cf021dc31d83416c229e9b25533432c743f958 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Thu, 8 Oct 2026 18:24:32 +0200 Subject: [PATCH 2/3] Add reassure test for sorted report actions consumers --- .../SortedReportActions.perf-test.tsx | 411 ++++++++++++++++++ 1 file changed, 411 insertions(+) create mode 100644 tests/perf-test/SortedReportActions.perf-test.tsx diff --git a/tests/perf-test/SortedReportActions.perf-test.tsx b/tests/perf-test/SortedReportActions.perf-test.tsx new file mode 100644 index 000000000000..9dcddffbf737 --- /dev/null +++ b/tests/perf-test/SortedReportActions.perf-test.tsx @@ -0,0 +1,411 @@ +import {render, screen} from '@testing-library/react-native'; + +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import SearchRouter from '@components/Search/SearchRouter/SearchRouter'; + +import useSortedReportActionsData from '@hooks/useSortedReportActionsData'; + +import {setHasRadio} from '@libs/NetworkState'; + +import ComposeProviders from '@src/components/ComposeProviders'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report, ReportAction} from '@src/types/onyx'; +import type {ReportCollectionDataSet} from '@src/types/onyx/Report'; +import type {ReportActionsCollectionDataSet} from '@src/types/onyx/ReportAction'; + +import type * as NativeNavigation from '@react-navigation/native'; + +import React from 'react'; +import {View} from 'react-native'; +import Onyx from 'react-native-onyx'; +import {measureAsyncFunction, measureRenders} from 'reassure'; + +import createMock from '../utils/createMock'; +import * as TestHelper from '../utils/TestHelper'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; +import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatchedUpdates'; + +jest.mock('lodash/debounce', () => + jest.fn((fn: Record) => { + // eslint-disable-next-line no-param-reassign + fn.cancel = jest.fn(); + return fn; + }), +); + +jest.mock('@src/libs/Log'); + +jest.mock('@src/libs/API', () => ({ + write: jest.fn(), + makeRequestWithSideEffects: jest.fn(), + read: jest.fn(), +})); + +jest.mock('@src/libs/Navigation/Navigation', () => ({ + dismissModalWithReport: jest.fn(), + getTopmostReportId: jest.fn(), + isNavigationReady: jest.fn(() => Promise.resolve()), + isDisplayedInModal: jest.fn(() => false), + getActiveRouteWithoutParams: jest.fn(() => ''), +})); + +jest.mock('@src/hooks/useRootNavigationState', () => ({ + __esModule: true, + default: () => ({ + contextualReportID: undefined, + isSearchRouterScreen: false, + }), +})); + +jest.mock('@hooks/useExportedToFilterOptions', () => ({ + __esModule: true, + default: () => ({ + exportedToFilterOptions: [], + connectedIntegrationNames: new Set(), + }), +})); + +jest.mock('@react-navigation/native', () => { + const actualNav = jest.requireActual('@react-navigation/native'); + return { + ...actualNav, + useFocusEffect: jest.fn(), + useIsFocused: () => true, + useRoute: () => jest.fn(), + usePreventRemove: () => jest.fn(), + useNavigation: () => ({ + navigate: jest.fn(), + addListener: () => jest.fn(), + }), + createNavigationContainerRef: () => ({ + addListener: () => jest.fn(), + removeListener: () => jest.fn(), + isReady: () => jest.fn(), + getCurrentRoute: () => jest.fn(), + getState: () => jest.fn(), + getRootState: () => undefined, + }), + useNavigationState: () => ({ + routes: [], + }), + }; +}); + +jest.mock('@src/components/ConfirmedRoute.tsx'); + +const CURRENT_USER_ACCOUNT_ID = 1; +const LARGE_REPORT_COUNT = 3000; +const ACTIONS_PER_REPORT = 10; +const EXPENSE_REPORT_EVERY = 10; +const THREAD_REPORT_ID_OFFSET = 100000; +const FUNNEL_CONSUMER_COUNT = 4; +const WRITES_PER_SCENARIO = 10; + +let networkWriteCount = 0; + +function createComment(reportID: number, index: number, label: string): ReportAction { + return createMock({ + reportActionID: `${reportID}${label}${index}`, + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + actorAccountID: 1, + created: `2024-01-01 10:${String(index % 60).padStart(2, '0')}:${String(reportID % 60).padStart(2, '0')}.000`, + message: [{type: 'COMMENT', html: `Comment ${index}`, text: `Comment ${index}`}], + }); +} + +/** + * Chats with comments, and every tenth report a one-transaction expense report whose IOU action points at a thread + * with comments, so the per-report compute also resolves and combines transaction threads. + * A new label gives every action a new object and ID, so nothing is reused from an earlier build. + */ +function buildData(reportCount: number, label = '') { + const reports: ReportCollectionDataSet = {}; + const reportActions: ReportActionsCollectionDataSet = {}; + for (let reportID = 1; reportID <= reportCount; reportID++) { + const actions: Record = {}; + for (let index = 0; index < ACTIONS_PER_REPORT; index++) { + const action = createComment(reportID, index, label); + actions[action.reportActionID] = action; + } + + const isExpenseReport = reportID % EXPENSE_REPORT_EVERY === 0; + if (isExpenseReport) { + const threadReportID = THREAD_REPORT_ID_OFFSET + reportID; + const iouAction = createMock({ + reportActionID: `${reportID}${label}iou`, + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + actorAccountID: 1, + childReportID: String(threadReportID), + created: '2024-01-01 09:00:00.000', + message: [{type: 'COMMENT', html: '$1.00 expense', text: '$1.00 expense'}], + originalMessage: {IOUTransactionID: `txn${reportID}`, IOUReportID: String(reportID), type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, amount: 100, currency: 'USD'}, + }); + actions[iouAction.reportActionID] = iouAction; + const threadComment = createComment(threadReportID, 0, label); + reportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${threadReportID}`] = {[threadComment.reportActionID]: threadComment}; + } + + reportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`] = actions; + reports[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`] = createMock({ + reportID: String(reportID), + reportName: `Report ${reportID}`, + type: isExpenseReport ? CONST.REPORT.TYPE.EXPENSE : CONST.REPORT.TYPE.CHAT, + chatReportID: isExpenseReport ? String(reportID - 1) : undefined, + ownerAccountID: 1, + participants: {[CURRENT_USER_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}}, + lastVisibleActionCreated: '2024-01-01 10:09:00.000', + lastReadTime: '2024-01-01 10:00:00.000', + }); + } + return {reports, reportActions}; +} + +const largeData = buildData(LARGE_REPORT_COUNT); + +async function seed() { + await Onyx.multiSet({ + ...largeData.reports, + ...largeData.reportActions, + [ONYXKEYS.BETAS]: Object.values(CONST.BETAS), + [ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS]: true, + }); + await waitForBatchedUpdates(); +} + +/** A report field that has no effect on sorted actions, written with a new value every time */ +async function writeReportReadTimes(label: string) { + for (let index = 0; index < WRITES_PER_SCENARIO; index++) { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${index + 1}`, {lastReadTime: `2024-02-01 ${label}-${index}`}); + await waitForBatchedUpdates(); + } +} + +/** A network field that leaves the offline state as it is */ +async function writeNetworkTimeSkews() { + for (let index = 0; index < WRITES_PER_SCENARIO; index++) { + networkWriteCount++; + await Onyx.merge(ONYXKEYS.NETWORK, {timeSkew: networkWriteCount}); + await waitForBatchedUpdates(); + } +} + +/** A new comment on one report per write, which really changes that report's sorted actions */ +async function writeNewComments(label: string) { + for (let index = 0; index < WRITES_PER_SCENARIO; index++) { + const reportID = index + 1; + const comment = createComment(reportID, ACTIONS_PER_REPORT + index, label); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {[comment.reportActionID]: comment}); + await waitForBatchedUpdates(); + } +} + +/** One update touching all three inputs, as a server response often does */ +async function writeMixedUpdates(label: string) { + for (let index = 0; index < WRITES_PER_SCENARIO; index++) { + const reportID = index + 1; + const comment = createComment(reportID, ACTIONS_PER_REPORT + index, `mixed${label}`); + networkWriteCount++; + await Onyx.update([ + {onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, value: {[comment.reportActionID]: comment}}, + {onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, value: {lastVisibleActionCreated: comment.created, lastReadTime: comment.created}}, + {onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.NETWORK, value: {timeSkew: networkWriteCount}}, + ]); + await waitForBatchedUpdates(); + } +} + +function SortedActionsConsumer() { + useSortedReportActionsData(); + return ; +} + +/** Several readers of the sorted actions and nothing else, as the Search router, its list and its option hooks are */ +function SortedActionsConsumers() { + return ( + <> + {Array.from({length: FUNNEL_CONSUMER_COUNT}, (value, index) => ( + + ))} + + ); +} + +function SearchRouterWrapper() { + return ( + + + + ); +} + +describe('Sorted report actions consumers', () => { + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + evictableKeys: [ONYXKEYS.COLLECTION.REPORT], + }); + // Required lazily so the navigation mocks above are registered before the engine's imports load it + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + require('@userActions/OnyxDerived').default(); + }); + + beforeEach(async () => { + global.fetch = TestHelper.getGlobalFetchMock(); + wrapOnyxWithWaitForBatchedUpdates(Onyx); + setHasRadio(true); + await seed(); + }); + + afterEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + test('[SortedReportActions] should mount the Search router over a large report collection', async () => { + // Given 3000 reports with 10 actions each already in Onyx + const scenario = async () => { + await screen.findByTestId('SearchRouter'); + }; + + // When the router mounts, then reassure measures the mount, which now pays the sort the engine did in the background + await measureRenders(, {scenario}); + }); + + test('[SortedReportActions] should open the Search router after every report action changed', async () => { + // Given 3000 reports with 10 actions each + let run = 0; + const openAfterFreshActions = async () => { + // When every report gets new action objects and the router is opened right after + await Onyx.multiSet(buildData(LARGE_REPORT_COUNT, `fresh${run}`).reportActions); + await waitForBatchedUpdates(); + const {unmount} = render(); + await screen.findByTestId('SearchRouter'); + unmount(); + run++; + }; + + // Then reassure times the write plus the open: the base sorts in the engine on the write, the branch on the mount + await measureAsyncFunction(openAfterFreshActions); + }); + + test('[SortedReportActions] should re-render the Search router on report writes', async () => { + // Given the router mounted over 3000 reports + let run = 0; + const scenario = async () => { + await screen.findByTestId('SearchRouter'); + + // When report fields that don't affect sorted actions change + await writeReportReadTimes(`router${run}`); + run++; + }; + + // Then reassure measures the renders, where the base re-sorted every report on each write + await measureRenders(, {scenario}); + }); + + test('[SortedReportActions] should re-render the Search router on network writes', async () => { + // Given the router mounted over 3000 reports + const scenario = async () => { + await screen.findByTestId('SearchRouter'); + + // When NETWORK changes without changing the offline state + await writeNetworkTimeSkews(); + }; + + // Then reassure measures the renders, where the base re-sorted every report on each write + await measureRenders(, {scenario}); + }); + + test('[SortedReportActions] should re-render sorted action readers on report writes', async () => { + // Given only readers of the sorted actions, so other Search inputs don't add renders + let run = 0; + const scenario = async () => { + await screen.findAllByTestId('sorted-actions-consumer'); + + // When report fields that don't affect sorted actions change + await writeReportReadTimes(`readers${run}`); + run++; + }; + + // Then every render here comes from the sorted actions changing reference + await measureRenders(, {scenario}); + }); + + test('[SortedReportActions] should re-render sorted action readers on network writes', async () => { + // Given only readers of the sorted actions + const scenario = async () => { + await screen.findAllByTestId('sorted-actions-consumer'); + + // When NETWORK changes without changing the offline state + await writeNetworkTimeSkews(); + }; + + // Then every render here comes from the sorted actions changing reference + await measureRenders(, {scenario}); + }); + + test('[SortedReportActions] should re-render sorted action readers on new comments', async () => { + // Given only readers of the sorted actions + let run = 0; + const scenario = async () => { + await screen.findAllByTestId('sorted-actions-consumer'); + + // When one report gets a new comment on every write, which both versions must deliver + await writeNewComments(`readers${run}`); + run++; + }; + + // Then reassure measures the cost of a real change + await measureRenders(, {scenario}); + }); + + test('[SortedReportActions] should process mixed updates with the Search router mounted', async () => { + // Given the router mounted over 3000 reports + render(); + await screen.findByTestId('SearchRouter'); + + // When updates touch report actions, the report and NETWORK at once + let run = 0; + const writeLoop = async () => { + await writeMixedUpdates(`mounted${run}`); + run++; + }; + + // Then reassure times the whole loop, including the engine's computes outside React renders + await measureAsyncFunction(writeLoop); + }); + + test('[SortedReportActions] should process report and network writes with the Search router mounted', async () => { + // Given the router mounted over 3000 reports + render(); + await screen.findByTestId('SearchRouter'); + + // When report fields and NETWORK change without changing any sorted actions + let run = 0; + const writeLoop = async () => { + await writeReportReadTimes(`mountedLoop${run}`); + await writeNetworkTimeSkews(); + run++; + }; + + // Then reassure times the whole loop, including the engine's computes outside React renders + await measureAsyncFunction(writeLoop); + }); + + test('[SortedReportActions] should process writes with no Search surface mounted', async () => { + // Given 3000 reports in Onyx and nothing that reads sorted actions mounted, as during plain inbox use + let run = 0; + const writeLoop = async () => { + // When report fields, NETWORK and report actions change + await writeReportReadTimes(`unmounted${run}`); + await writeNetworkTimeSkews(); + await writeNewComments(`unmounted${run}`); + run++; + }; + + // Then reassure times the loop, where the base still sorted on every write + await measureAsyncFunction(writeLoop); + }); +}); From 9ef3c3a699283cfcdfa3c3b4af17467a851c6298 Mon Sep 17 00:00:00 2001 From: truph01 Date: Thu, 15 Oct 2026 02:24:34 +0700 Subject: [PATCH 3/3] Show empty state instead of No results when default view hides all rows --- src/components/Table/TableBody.tsx | 7 +++- .../TableEmptyStates/TableNoResultsState.tsx | 5 ++- tests/ui/TableTest.tsx | 38 +++++++++++++++++++ 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/src/components/Table/TableBody.tsx b/src/components/Table/TableBody.tsx index 9a4db2f7aead..faa96b4da20f 100644 --- a/src/components/Table/TableBody.tsx +++ b/src/components/Table/TableBody.tsx @@ -164,6 +164,7 @@ function TableBodyList({contentContainerStyle, emptyMessage, onLayout, style, .. noResultsStateElement, tableListMetadata, isEmptyResult, + isDefaultViewEmpty, scrollWidth, tableWidth, } = useTableContext(); @@ -337,8 +338,10 @@ function TableBodyList({contentContainerStyle, emptyMessage, onLayout, style, .. ); - const emptyStateContent = - tableListMetadata.hasPageHeader && isEmptyResult ? (noResultsStateElement ?? EmptyResultComponent) : (emptyStateElement ?? renderListComponent(ListEmptyComponent)); + // When the default view hides every row (e.g. all workspaces are archived), no search or filter is active, + // so show the empty state rather than "No results". + const shouldShowNoResultsState = tableListMetadata.hasPageHeader && isEmptyResult && !isDefaultViewEmpty; + const emptyStateContent = shouldShowNoResultsState ? (noResultsStateElement ?? EmptyResultComponent) : (emptyStateElement ?? renderListComponent(ListEmptyComponent)); const footerElement = renderListComponent(ListFooterComponent); // Consumer footer flex growth is useful below normal rows, but inside the combined empty-state // footer it can expand over the page header. Preserve the remaining style while disabling growth. diff --git a/src/components/Table/TableEmptyStates/TableNoResultsState.tsx b/src/components/Table/TableEmptyStates/TableNoResultsState.tsx index 60a93ca4a2c6..30c49a66fe35 100644 --- a/src/components/Table/TableEmptyStates/TableNoResultsState.tsx +++ b/src/components/Table/TableEmptyStates/TableNoResultsState.tsx @@ -21,10 +21,11 @@ type TableNoResultsStateProps = Omit { expect(screen.queryByTestId('row-2')).toBeNull(); expect(screen.getByTestId('generic-empty-state')).toBeTruthy(); }); + + it('renders the empty state instead of "No results" when a page-header table hides every row by default', () => { + // Given a page-header table (like the Workspaces list) whose default filter hides every row, with both + // a no-results state and an empty state declared + const props = createDefaultProps(); + const data: TestItem[] = [ + {keyForList: '1', id: '1', name: 'Archived A', category: 'archived', value: 1}, + {keyForList: '2', id: '2', name: 'Archived B', category: 'archived', value: 2}, + ]; + + // When the table renders with no search or filter selected + render( + + data={data} + columns={props.columns} + renderItem={props.renderItem} + keyExtractor={props.keyExtractor} + filters={statusFilterConfig} + isItemInFilter={isItemInStatusFilter} + > + + Page header + + + + + + , + ); + + // Then the empty state shows inside the list with the page header still visible, because the user did + // not search or filter, so "No results found" would be misleading + expect(screen.getByTestId('flash-list')).toBeTruthy(); + expect(screen.getByTestId('table-header-component')).toBeTruthy(); + expect(screen.getAllByTestId('generic-empty-state')).toHaveLength(1); + expect(screen.getByText('No workspaces')).toBeTruthy(); + expect(screen.queryByText('common.noResultsFound')).toBeNull(); + }); }); describe('immediate filter', () => {