From 0c0cc3cd3d7c7acedff9ed8b1166adc98ab547c7 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Thu, 8 Oct 2026 17:41:08 +0200 Subject: [PATCH 1/3] Replace ONE_ON_ONE_CHAT_REPORT_IDS derived value with a memoized useOnyxState lookup --- __mocks__/react-native-onyx.ts | 4 +- src/ONYXKEYS.ts | 2 - src/hooks/useOneOnOneChatReportID.ts | 27 +++ src/libs/ReportUtils.ts | 2 +- .../OnyxDerived/ONYX_DERIVED_VALUES.ts | 2 - .../configs/oneOnOneChatReportIDs.ts | 105 ---------- .../step/IOURequestStepConfirmation.tsx | 10 +- src/selectors/OneOnOneChatReportID.ts | 66 +++++++ src/types/onyx/DerivedValues.ts | 9 - src/types/onyx/index.ts | 2 - .../IOURequestStepConfirmationPageTest.tsx | 6 - .../OnyxDerived/oneOnOneChatReportIDsTest.ts | 156 --------------- .../OneOnOneChatReportIDSelectorTest.ts | 181 ++++++++++++++++++ tests/unit/useOneOnOneChatReportIDTest.ts | 108 +++++++++++ 14 files changed, 388 insertions(+), 292 deletions(-) create mode 100644 src/hooks/useOneOnOneChatReportID.ts delete mode 100644 src/libs/actions/OnyxDerived/configs/oneOnOneChatReportIDs.ts create mode 100644 src/selectors/OneOnOneChatReportID.ts delete mode 100644 tests/unit/OnyxDerived/oneOnOneChatReportIDsTest.ts create mode 100644 tests/unit/selectors/OneOnOneChatReportIDSelectorTest.ts create mode 100644 tests/unit/useOneOnOneChatReportIDTest.ts 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..0611452bfad4 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -1413,7 +1413,6 @@ const ONYXKEYS = { LOGIN_TO_ACCOUNT_ID_MAP: 'loginToAccountIDMap', GUIDE_ACCOUNT_IDS: 'guideAccountIDs', SPEND_DATA_SIGNATURE: 'spendDataSignature', - ONE_ON_ONE_CHAT_REPORT_IDS: 'oneOnOneChatReportIDs', }, /** Stores HybridApp specific state required to interoperate with OldDot */ @@ -1984,7 +1983,6 @@ type OnyxDerivedValuesMapping = { [ONYXKEYS.DERIVED.LOGIN_TO_ACCOUNT_ID_MAP]: OnyxTypes.LoginToAccountIDMapDerivedValue; [ONYXKEYS.DERIVED.SPEND_DATA_SIGNATURE]: OnyxTypes.SpendDataSignatureDerivedValue; [ONYXKEYS.DERIVED.GUIDE_ACCOUNT_IDS]: OnyxTypes.GuideAccountIDsDerivedValue; - [ONYXKEYS.DERIVED.ONE_ON_ONE_CHAT_REPORT_IDS]: OnyxTypes.OneOnOneChatReportIDsDerivedValue; }; type OnyxValues = OnyxValuesMapping & OnyxCollectionValuesMapping & OnyxFormValuesMapping & OnyxFormDraftValuesMapping & OnyxDerivedValuesMapping; diff --git a/src/hooks/useOneOnOneChatReportID.ts b/src/hooks/useOneOnOneChatReportID.ts new file mode 100644 index 000000000000..83758522c601 --- /dev/null +++ b/src/hooks/useOneOnOneChatReportID.ts @@ -0,0 +1,27 @@ +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report} from '@src/types/onyx'; + +import type {OnyxCollection} from 'react-native-onyx'; + +import {getOneOnOneChatReportID} from '@selectors/OneOnOneChatReportID'; +import {useOnyxState} from 'react-native-onyx'; + +const ONE_ON_ONE_CHAT_REPORT_ID_DEPENDENCIES = [ONYXKEYS.COLLECTION.REPORT, ONYXKEYS.SESSION]; + +/** Returns the reportID of the 1:1 or system chat with exactly these participants, read live so the Search snapshot never applies. */ +function useOneOnOneChatReportID(participantsChatKey: string | undefined): string | undefined { + return useOnyxState( + (state) => { + if (!participantsChatKey) { + return undefined; + } + // useOnyxState's state view types a collection key as one member, but at runtime it returns the whole collection + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const reports = state[ONYXKEYS.COLLECTION.REPORT] as OnyxCollection; + return getOneOnOneChatReportID(reports, state[ONYXKEYS.SESSION]?.accountID, participantsChatKey); + }, + {dependencies: ONE_ON_ONE_CHAT_REPORT_ID_DEPENDENCIES}, + ); +} + +export default useOneOnOneChatReportID; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 439fa74a2dea..f7779f493612 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -10805,7 +10805,7 @@ function shouldReportBeInOptionList(params: ShouldReportBeInOptionListParams) { } /** - * Stable key for a participant set, used by the `ONE_ON_ONE_CHAT_REPORT_IDS` derived value. + * Stable key for a participant set, used to index 1:1 chats by participants. * * Sorting uses the default comparator to match `getChatByParticipants`, which compares `.sort()`ed number arrays. * That sort is lexicographic: [2, 10] becomes [10, 2]. A numeric sort here would stop matching. diff --git a/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts b/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts index 4ea4fabd9560..63af25b96010 100644 --- a/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts +++ b/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts @@ -8,7 +8,6 @@ import cardFeedErrorsConfig from './configs/cardFeedErrors'; import guideAccountIDsConfig from './configs/guideAccountIDs'; import loginToAccountIDMapConfig from './configs/loginToAccountIDMap'; import nonPersonalAndWorkspaceCardListConfig from './configs/nonPersonalAndWorkspaceCardList'; -import oneOnOneChatReportIDsConfig from './configs/oneOnOneChatReportIDs'; import outstandingReportsByPolicyIDConfig from './configs/outstandingReportsByPolicyID'; import personalAndWorkspaceCardListConfig from './configs/personalAndWorkspaceCardList'; import reportAttributesConfig from './configs/reportAttributes'; @@ -33,7 +32,6 @@ const ONYX_DERIVED_VALUES = { [ONYXKEYS.DERIVED.LOGIN_TO_ACCOUNT_ID_MAP]: loginToAccountIDMapConfig, [ONYXKEYS.DERIVED.GUIDE_ACCOUNT_IDS]: guideAccountIDsConfig, [ONYXKEYS.DERIVED.SPEND_DATA_SIGNATURE]: spendDataSignatureConfig, - [ONYXKEYS.DERIVED.ONE_ON_ONE_CHAT_REPORT_IDS]: oneOnOneChatReportIDsConfig, } as const satisfies { // eslint-disable-next-line @typescript-eslint/no-explicit-any [Key in ValueOf]: OnyxDerivedValueConfig; diff --git a/src/libs/actions/OnyxDerived/configs/oneOnOneChatReportIDs.ts b/src/libs/actions/OnyxDerived/configs/oneOnOneChatReportIDs.ts deleted file mode 100644 index 0b3289116fdd..000000000000 --- a/src/libs/actions/OnyxDerived/configs/oneOnOneChatReportIDs.ts +++ /dev/null @@ -1,105 +0,0 @@ -import {getParticipantsChatKey, isOneOnOneChat, isSystemChat} from '@libs/ReportUtils'; - -import createOnyxDerivedValueConfig from '@userActions/OnyxDerived/createOnyxDerivedValueConfig'; - -import ONYXKEYS from '@src/ONYXKEYS'; -import type {OneOnOneChatReportIDsDerivedValue} from '@src/types/onyx'; -import type Report from '@src/types/onyx/Report'; - -import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; - -function getIndexableParticipantsKey(report: OnyxEntry, currentUserAccountID: number): string | undefined { - if (!report?.reportID || (!isOneOnOneChat(report, currentUserAccountID) && !isSystemChat(report))) { - return undefined; - } - return getParticipantsChatKey(Object.keys(report.participants ?? {}).map(Number)); -} - -/** - * The first report in collection order wins a key, matching `getChatByParticipants`. When `keysToFind` is passed, - * only those keys are looked up. - */ -function buildIndex(reports: NonNullable>, currentUserAccountID: number, keysToFind?: Set): OneOnOneChatReportIDsDerivedValue['reportIDs'] { - const index: OneOnOneChatReportIDsDerivedValue['reportIDs'] = {}; - for (const report of Object.values(reports)) { - const participantsKey = getIndexableParticipantsKey(report, currentUserAccountID); - if (!participantsKey || !report?.reportID || index[participantsKey] || (keysToFind && !keysToFind.has(participantsKey))) { - continue; - } - index[participantsKey] = report.reportID; - } - return index; -} - -/** - * Indexes 1:1 and system chats by participant set. - * - * Reports are keyed by reportID, so `getChatByParticipants` has to walk the whole collection to find a chat by its - * participants. This index answers the same question with a lookup. Group chats are excluded because - * `getChatByParticipants` only returns those behind `shouldIncludeGroupChats`. - * - * All state lives in the derived value; nothing is kept in module scope between computes. - */ -export default createOnyxDerivedValueConfig({ - key: ONYXKEYS.DERIVED.ONE_ON_ONE_CHAT_REPORT_IDS, - // `isOneOnOneChat` needs the current accountID to tell a DM from a group chat, so SESSION has to be a - // dependency. Without it the index gets built before the ID arrives and every two-participant DM is missing. - dependencies: [ONYXKEYS.COLLECTION.REPORT, ONYXKEYS.SESSION], - compute: ([reports, session], {sourceValues, currentValue}) => { - const currentUserAccountID = session?.accountID; - - // Without the accountID every DM looks like a group chat, so an index built now would be thrown away. - if (!reports || !currentUserAccountID) { - return {reportIDs: {}}; - } - - // The first flush after a reload has no `sourceValues`. The restored index can disagree with the restored - // reports, because the two are written separately and reports that change before the subscriptions connect - // never show up in a delta, so it is rebuilt rather than trusted. - if (!currentValue || !sourceValues || currentValue.accountID !== currentUserAccountID) { - return {reportIDs: buildIndex(reports, currentUserAccountID), accountID: currentUserAccountID}; - } - - const reportUpdates = sourceValues[ONYXKEYS.COLLECTION.REPORT]; - if (!reportUpdates) { - return currentValue; - } - - const updatedIndex: OneOnOneChatReportIDsDerivedValue['reportIDs'] = {...currentValue.reportIDs}; - let keyByReportID: Record | undefined; - // Keys whose report left, refilled from `reports` after the loop. The server's `preexistingReportID` - // replacement keeps an optimistic DM and the real one on the same participants for a moment, so dropping the - // key would lose the chat that remains. - const keysToRefill = new Set(); - - for (const reportKey of Object.keys(reportUpdates)) { - const reportID = reportKey.slice(ONYXKEYS.COLLECTION.REPORT.length); - // From the full collection, because a delta entry carries only the changed fields. - const nextKey = getIndexableParticipantsKey(reports[reportKey], currentUserAccountID); - - if (nextKey && updatedIndex[nextKey] === reportID) { - continue; - } - - keyByReportID ??= Object.fromEntries(Object.entries(updatedIndex).map(([participantsKey, id]) => [id, participantsKey])); - const previousKey = keyByReportID[reportID]; - if (previousKey) { - delete updatedIndex[previousKey]; - delete keyByReportID[reportID]; - keysToRefill.add(previousKey); - } - - if (!nextKey || updatedIndex[nextKey] || keysToRefill.has(nextKey)) { - continue; - } - updatedIndex[nextKey] = reportID; - keyByReportID[reportID] = nextKey; - } - - if (keysToRefill.size === 0) { - return {reportIDs: updatedIndex, accountID: currentUserAccountID}; - } - - return {reportIDs: {...updatedIndex, ...buildIndex(reports, currentUserAccountID, keysToRefill)}, accountID: currentUserAccountID}; - }, -}); diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index 7d26c7d118f1..32dfa3f04399 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -23,6 +23,7 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOdometerReceiptStitcher from '@hooks/useOdometerReceiptStitcher'; +import useOneOnOneChatReportID from '@hooks/useOneOnOneChatReportID'; import useOnyx from '@hooks/useOnyx'; import useOptimisticDraftTransactions from '@hooks/useOptimisticDraftTransactions'; import useParticipantsPolicies from '@hooks/useParticipantsPolicies'; @@ -94,7 +95,7 @@ import type {IOUType} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; -import type {OneOnOneChatReportIDsDerivedValue, Policy} from '@src/types/onyx'; +import type {Policy} from '@src/types/onyx'; import type {Participant} from '@src/types/onyx/IOU'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import type {Receipt} from '@src/types/onyx/Transaction'; @@ -722,12 +723,7 @@ function IOURequestStepConfirmationContent({ const p2pRecipientAccountID = firstParticipant?.accountID ?? CONST.DEFAULT_NUMBER_ID; // Read reports reactively: if the chat lands mid-flow the optimistic ID must drop out, or we'd reveal an uncreated report. - const existingP2PChatSelector = useCallback( - (chatReportIDs: OnyxEntry) => - isP2PDestination ? chatReportIDs?.reportIDs?.[getParticipantsChatKey([p2pRecipientAccountID, currentUserPersonalDetails.accountID])] : undefined, - [isP2PDestination, p2pRecipientAccountID, currentUserPersonalDetails.accountID], - ); - const [existingP2PDestinationReportID] = useOnyx(ONYXKEYS.DERIVED.ONE_ON_ONE_CHAT_REPORT_IDS, {selector: existingP2PChatSelector}); + const existingP2PDestinationReportID = useOneOnOneChatReportID(isP2PDestination ? getParticipantsChatKey([p2pRecipientAccountID, currentUserPersonalDetails.accountID]) : undefined); const optimisticP2PDestinationReportID = !existingP2PDestinationReportID && reusableP2PReportID ? reusableP2PReportID : undefined; // Trust `report` when it already belongs to this participant (their chat, or an IOU report under it), so a // flow started from an IOU report keeps that report as destination instead of falling back to the chat. diff --git a/src/selectors/OneOnOneChatReportID.ts b/src/selectors/OneOnOneChatReportID.ts new file mode 100644 index 000000000000..8f590bc3a79e --- /dev/null +++ b/src/selectors/OneOnOneChatReportID.ts @@ -0,0 +1,66 @@ +import {getParticipantsChatKey, isOneOnOneChat, isSystemChat} from '@libs/ReportUtils'; + +import type {Report} from '@src/types/onyx'; + +import type {OnyxCollection} from 'react-native-onyx'; + +const lookupCache = new WeakMap>, Map>(); + +function isOneOnOneChatWithParticipants(report: Report, currentUserAccountID: number, participantsChatKey: string, participantAccountIDs: string[]): boolean { + const participants = report.participants; + if (!report.reportID || !participants) { + return false; + } + for (const accountID of participantAccountIDs) { + if (!(accountID in participants)) { + return false; + } + } + if (!isOneOnOneChat(report, currentUserAccountID) && !isSystemChat(report)) { + return false; + } + return getParticipantsChatKey(Object.keys(participants).map(Number)) === participantsChatKey; +} + +/** + * Finds the 1:1 or system chat whose participants are exactly the ones in `participantsChatKey` (built with + * `getParticipantsChatKey`). The first report in collection order wins, matching `getChatByParticipants`. Group chats + * are left out because `getChatByParticipants` only returns those behind `shouldIncludeGroupChats`. + * + * Reports that miss one of the participants are skipped before the 1:1 checks run, and the answer is cached on the + * collection snapshot, so re-renders without a report write cost a lookup. + */ +function getOneOnOneChatReportID(reports: OnyxCollection, currentUserAccountID: number | undefined, participantsChatKey: string): string | undefined { + // Without the accountID every DM looks like a group chat. + if (!reports || !currentUserAccountID) { + return undefined; + } + + let cachedLookups = lookupCache.get(reports); + const cacheKey = `${currentUserAccountID}:${participantsChatKey}`; + if (cachedLookups?.has(cacheKey)) { + return cachedLookups.get(cacheKey); + } + + const participantAccountIDs = participantsChatKey.split(','); + let reportID: string | undefined; + // for...in walks the frozen snapshot without copying thousands of reports into an array, which is most of the cost of a lookup on mount + // eslint-disable-next-line guard-for-in + for (const reportKey in reports) { + const report = reports[reportKey]; + if (report && isOneOnOneChatWithParticipants(report, currentUserAccountID, participantsChatKey, participantAccountIDs)) { + reportID = report.reportID; + break; + } + } + + if (!cachedLookups) { + cachedLookups = new Map(); + lookupCache.set(reports, cachedLookups); + } + cachedLookups.set(cacheKey, reportID); + return reportID; +} + +// eslint-disable-next-line import/prefer-default-export +export {getOneOnOneChatReportID}; diff --git a/src/types/onyx/DerivedValues.ts b/src/types/onyx/DerivedValues.ts index 3f5e2a136157..8aa59ebec59e 100644 --- a/src/types/onyx/DerivedValues.ts +++ b/src/types/onyx/DerivedValues.ts @@ -236,14 +236,6 @@ type SpendDataSignatureDerivedValue = { cardExpenses: number; }; -/** - * Maps a participant set to the reportID of its 1:1 (or system) chat, for the accountID it was built for. - */ -type OneOnOneChatReportIDsDerivedValue = { - reportIDs: Record; - accountID?: number; -}; - export type { ReportAttributes, ReportAttributesDerivedValue, @@ -258,7 +250,6 @@ export type { LoginToAccountIDMapDerivedValue, GuideAccountIDsDerivedValue, SpendDataSignatureDerivedValue, - OneOnOneChatReportIDsDerivedValue, CardFeedErrorsObject, CardFeedErrorState, CardFeedErrors, diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index fad7cf8b5ab5..27c3ce03a580 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -64,7 +64,6 @@ import type { SpendDataSignatureDerivedValue, LoginToAccountIDMapDerivedValue, NonPersonalAndWorkspaceCardListDerivedValue, - OneOnOneChatReportIDsDerivedValue, OutstandingReportsByPolicyIDDerivedValue, PersonalAndWorkspaceCardListDerivedValue, ReportAttributesDerivedValue, @@ -464,7 +463,6 @@ export type { LoginToAccountIDMapDerivedValue, GuideAccountIDsDerivedValue, SpendDataSignatureDerivedValue, - OneOnOneChatReportIDsDerivedValue, ScheduleCallDraft, ValidateUserAndGetAccessiblePolicies, VacationDelegate, diff --git a/tests/ui/components/IOURequestStepConfirmationPageTest.tsx b/tests/ui/components/IOURequestStepConfirmationPageTest.tsx index e1b394ac6c7c..c2fb7483bb8a 100644 --- a/tests/ui/components/IOURequestStepConfirmationPageTest.tsx +++ b/tests/ui/components/IOURequestStepConfirmationPageTest.tsx @@ -1366,12 +1366,6 @@ describe('IOURequestStepConfirmationPageTest', () => { iouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, participants: [{accountID: PARTICIPANT_ACCOUNT_ID, reportID: chatReportID, selected: true}], }); - // The page resolves the participant chat through the derived index, so seed the entry the - // derived value would produce for this chat report. - await Onyx.merge(ONYXKEYS.DERIVED.ONE_ON_ONE_CHAT_REPORT_IDS, { - reportIDs: {[ReportUtils.getParticipantsChatKey([PARTICIPANT_ACCOUNT_ID, ACCOUNT_ID])]: chatReportID}, - accountID: ACCOUNT_ID, - }); }); render( diff --git a/tests/unit/OnyxDerived/oneOnOneChatReportIDsTest.ts b/tests/unit/OnyxDerived/oneOnOneChatReportIDsTest.ts deleted file mode 100644 index 7060e845cfe2..000000000000 --- a/tests/unit/OnyxDerived/oneOnOneChatReportIDsTest.ts +++ /dev/null @@ -1,156 +0,0 @@ -import oneOnOneChatReportIDsConfig from '@libs/actions/OnyxDerived/configs/oneOnOneChatReportIDs'; -import {buildParticipantsFromAccountIDs, getParticipantsChatKey} from '@libs/ReportUtils'; - -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {Report, Session} from '@src/types/onyx'; - -import type {OnyxCollection} from 'react-native-onyx'; - -const ME = 1; -const ALICE = 2; -const BOB = 3; - -const session: Session = {accountID: ME}; - -function chatWith(reportID: string, otherAccountID: number): Report { - return { - reportID, - type: CONST.REPORT.TYPE.CHAT, - participants: buildParticipantsFromAccountIDs([ME, otherAccountID]), - }; -} - -function collection(...reports: Report[]): OnyxCollection { - return Object.fromEntries(reports.map((report) => [`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report])); -} - -function delta(...reports: Array<[string, Report | undefined]>) { - return { - [ONYXKEYS.COLLECTION.REPORT]: Object.fromEntries(reports.map(([reportID, report]) => [`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, report])), - }; -} - -function index(reportIDs: Record, accountID = ME) { - return {reportIDs, accountID}; -} - -const aliceKey = getParticipantsChatKey([ME, ALICE]); -const bobKey = getParticipantsChatKey([ME, BOB]); - -describe('oneOnOneChatReportIDs', () => { - it('indexes 1:1 chats by participant set on a full compute', () => { - const reports = collection(chatWith('10', ALICE), chatWith('20', BOB)); - - expect(oneOnOneChatReportIDsConfig.compute([reports, session], {})).toEqual(index({[aliceKey]: '10', [bobKey]: '20'})); - }); - - it('adds a newly created chat without rebuilding', () => { - const initial = collection(chatWith('10', ALICE)); - const currentValue = oneOnOneChatReportIDsConfig.compute([initial, session], {}); - - const withBob = collection(chatWith('10', ALICE), chatWith('20', BOB)); - const next = oneOnOneChatReportIDsConfig.compute([withBob, session], {currentValue, sourceValues: delta(['20', chatWith('20', BOB)])}); - - expect(next).toEqual(index({[aliceKey]: '10', [bobKey]: '20'})); - }); - - it('drops the entry when a chat is deleted', () => { - const initial = collection(chatWith('10', ALICE), chatWith('20', BOB)); - const currentValue = oneOnOneChatReportIDsConfig.compute([initial, session], {}); - - const withoutBob = collection(chatWith('10', ALICE)); - const next = oneOnOneChatReportIDsConfig.compute([withoutBob, session], {currentValue, sourceValues: delta(['20', undefined])}); - - expect(next).toEqual(index({[aliceKey]: '10'})); - }); - - it('moves the entry when a chat changes participants', () => { - const initial = collection(chatWith('10', ALICE)); - const currentValue = oneOnOneChatReportIDsConfig.compute([initial, session], {}); - - const nowWithBob = collection(chatWith('10', BOB)); - const next = oneOnOneChatReportIDsConfig.compute([nowWithBob, session], {currentValue, sourceValues: delta(['10', chatWith('10', BOB)])}); - - expect(next).toEqual(index({[bobKey]: '10'})); - }); - - it('keeps the first chat when two share a participant set, and leaves it alone when the other one goes', () => { - const duplicates = collection(chatWith('10', ALICE), chatWith('20', ALICE)); - const currentValue = oneOnOneChatReportIDsConfig.compute([duplicates, session], {}); - expect(currentValue).toEqual(index({[aliceKey]: '10'})); - - const remaining = collection(chatWith('10', ALICE)); - const next = oneOnOneChatReportIDsConfig.compute([remaining, session], {currentValue, sourceValues: delta(['20', undefined])}); - - expect(next).toEqual(index({[aliceKey]: '10'})); - }); - - it('promotes the remaining chat when the indexed one is deleted, as happens when an optimistic chat is replaced', () => { - // The optimistic chat is indexed first, then the server report arrives on the same participants. - const optimisticOnly = collection(chatWith('optimistic-1', ALICE)); - const afterOptimistic = oneOnOneChatReportIDsConfig.compute([optimisticOnly, session], {}); - expect(afterOptimistic).toEqual(index({[aliceKey]: 'optimistic-1'})); - - const bothReports = collection(chatWith('optimistic-1', ALICE), chatWith('30', ALICE)); - const afterServerReport = oneOnOneChatReportIDsConfig.compute([bothReports, session], { - currentValue: afterOptimistic, - sourceValues: delta(['30', chatWith('30', ALICE)]), - }); - expect(afterServerReport).toEqual(index({[aliceKey]: 'optimistic-1'})); - - // The optimistic report is cleaned up, so the real one has to take over rather than the key disappearing. - const serverOnly = collection(chatWith('30', ALICE)); - const afterCleanup = oneOnOneChatReportIDsConfig.compute([serverOnly, session], { - currentValue: afterServerReport, - sourceValues: delta(['optimistic-1', undefined]), - }); - - expect(afterCleanup).toEqual(index({[aliceKey]: '30'})); - }); - - it('rebuilds when SESSION carries a different account ID, because that changes what counts as a 1:1 chat', () => { - const reports = collection(chatWith('10', ALICE), chatWith('20', BOB)); - const currentValue = oneOnOneChatReportIDsConfig.compute([reports, session], {}); - expect(currentValue).toEqual(index({[aliceKey]: '10', [bobKey]: '20'})); - - const asOutsider = oneOnOneChatReportIDsConfig.compute([reports, {accountID: 99}], {currentValue, sourceValues: {}, triggeredKeys: new Set([ONYXKEYS.SESSION])}); - - expect(asOutsider).toEqual(index({}, 99)); - }); - - it('keeps the index when SESSION changes without changing the account ID', () => { - const reports = collection(chatWith('10', ALICE)); - const currentValue = oneOnOneChatReportIDsConfig.compute([reports, session], {}); - - const withBob = collection(chatWith('10', ALICE), chatWith('20', BOB)); - const next = oneOnOneChatReportIDsConfig.compute([withBob, {accountID: ME, authToken: 'refreshed'}], { - currentValue, - sourceValues: {}, - triggeredKeys: new Set([ONYXKEYS.SESSION]), - }); - - expect(next).toBe(currentValue); - }); - - it('rebuilds on the first compute after a reload, so a stale restored index is corrected', () => { - const reports = collection(chatWith('10', ALICE)); - // Bob's chat was deleted after the index was last written, and Alice's chat arrived before the first compute. - const staleValue = index({[bobKey]: '99'}); - - // The engine's first flush has no baselines to diff against, so it passes no sourceValues, while - // triggeredKeys holds every dependency. - const next = oneOnOneChatReportIDsConfig.compute([reports, session], { - currentValue: staleValue, - triggeredKeys: new Set([ONYXKEYS.COLLECTION.REPORT, ONYXKEYS.SESSION]), - }); - - expect(next).toEqual(index({[aliceKey]: '10'})); - }); - - it('builds nothing until the current account ID is known', () => { - const reports = collection(chatWith('10', ALICE)); - - expect(oneOnOneChatReportIDsConfig.compute([reports, {}], {})).toEqual({reportIDs: {}}); - }); -}); diff --git a/tests/unit/selectors/OneOnOneChatReportIDSelectorTest.ts b/tests/unit/selectors/OneOnOneChatReportIDSelectorTest.ts new file mode 100644 index 000000000000..7d7386148ca5 --- /dev/null +++ b/tests/unit/selectors/OneOnOneChatReportIDSelectorTest.ts @@ -0,0 +1,181 @@ +import {buildParticipantsFromAccountIDs, getParticipantsChatKey} from '@libs/ReportUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report} from '@src/types/onyx'; + +import type {OnyxCollection} from 'react-native-onyx'; + +import {getOneOnOneChatReportID} from '@selectors/OneOnOneChatReportID'; + +const ME = 1; +const ALICE = 2; +const BOB = 3; + +function chatWith(reportID: string, otherAccountID: number): Report { + return { + reportID, + type: CONST.REPORT.TYPE.CHAT, + participants: buildParticipantsFromAccountIDs([ME, otherAccountID]), + }; +} + +function collection(...reports: Report[]): OnyxCollection { + return Object.fromEntries(reports.map((report) => [`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report])); +} + +const aliceKey = getParticipantsChatKey([ME, ALICE]); +const bobKey = getParticipantsChatKey([ME, BOB]); + +describe('getOneOnOneChatReportID', () => { + it('should find each 1:1 chat by its participants', () => { + // Given a chat with Alice and a chat with Bob + const reports = collection(chatWith('10', ALICE), chatWith('20', BOB)); + + // When each chat is looked up by its participants + const aliceChatID = getOneOnOneChatReportID(reports, ME, aliceKey); + const bobChatID = getOneOnOneChatReportID(reports, ME, bobKey); + + // Then each lookup gets its own chat + expect(aliceChatID).toBe('10'); + expect(bobChatID).toBe('20'); + }); + + it('should leave out group chats and rooms', () => { + // Given a group chat with Alice and Bob, and a room with only Alice + const groupChat: Report = {reportID: '10', type: CONST.REPORT.TYPE.CHAT, participants: buildParticipantsFromAccountIDs([ME, ALICE, BOB])}; + const room: Report = {...chatWith('20', ALICE), chatType: CONST.REPORT.CHAT_TYPE.POLICY_ROOM}; + + // When the chat with Alice is looked up + const reportID = getOneOnOneChatReportID(collection(groupChat, room), ME, aliceKey); + + // Then neither is returned, because getChatByParticipants never returns them for a 1:1 lookup + expect(reportID).toBeUndefined(); + }); + + it('should find a system chat only when its participants match exactly', () => { + // Given a system chat with Alice and Bob, followed by a system chat with only Alice + const systemChatWithBoth: Report = { + reportID: '10', + type: CONST.REPORT.TYPE.CHAT, + chatType: CONST.REPORT.CHAT_TYPE.SYSTEM, + participants: buildParticipantsFromAccountIDs([ME, ALICE, BOB]), + }; + const systemChatWithAlice: Report = {...chatWith('20', ALICE), chatType: CONST.REPORT.CHAT_TYPE.SYSTEM}; + + // When the chat with Alice is looked up + const reportID = getOneOnOneChatReportID(collection(systemChatWithBoth, systemChatWithAlice), ME, aliceKey); + + // Then the system chat with only Alice is returned, because a system chat skips the 1:1 check and has to match on its whole participant set + expect(reportID).toBe('20'); + }); + + it('should find a chat added by a later write', () => { + // Given a lookup for Bob's chat that found nothing + expect(getOneOnOneChatReportID(collection(chatWith('10', ALICE)), ME, bobKey)).toBeUndefined(); + + // When a write adds Bob's chat, which gives a new collection snapshot + const reportID = getOneOnOneChatReportID(collection(chatWith('10', ALICE), chatWith('20', BOB)), ME, bobKey); + + // Then the new snapshot is searched again and Bob's chat is found + expect(reportID).toBe('20'); + }); + + it('should stop finding a chat that was deleted', () => { + // Given a lookup that found Bob's chat + const aliceChat = chatWith('10', ALICE); + expect(getOneOnOneChatReportID(collection(aliceChat, chatWith('20', BOB)), ME, bobKey)).toBe('20'); + + // When Bob's chat is deleted + const reportID = getOneOnOneChatReportID(collection(aliceChat), ME, bobKey); + + // Then nothing is found + expect(reportID).toBeUndefined(); + }); + + it('should follow a chat whose participants changed', () => { + // Given a chat with Alice + expect(getOneOnOneChatReportID(collection(chatWith('10', ALICE)), ME, aliceKey)).toBe('10'); + + // When the same report now has Bob as participant + const reports = collection(chatWith('10', BOB)); + + // Then it is found under Bob only, so a stale lookup never points at it + expect(getOneOnOneChatReportID(reports, ME, aliceKey)).toBeUndefined(); + expect(getOneOnOneChatReportID(reports, ME, bobKey)).toBe('10'); + }); + + it('should keep the first chat in collection order when two share a participant set', () => { + // Given two chats with Alice + const firstChat = chatWith('20', ALICE); + + // When the chat with Alice is looked up, and again after the second chat goes away + const withBoth = getOneOnOneChatReportID(collection(firstChat, chatWith('10', ALICE)), ME, aliceKey); + const withFirstOnly = getOneOnOneChatReportID(collection(firstChat), ME, aliceKey); + + // Then the first one in collection order wins both times, as in getChatByParticipants + expect(withBoth).toBe('20'); + expect(withFirstOnly).toBe('20'); + }); + + it('should keep the optimistic chat while the real one arrives, then switch to the real one when the optimistic one is removed', () => { + // Given an optimistic chat with Alice, as created before the server answers + const optimisticChat = chatWith('optimistic-1', ALICE); + const afterOptimistic = getOneOnOneChatReportID(collection(optimisticChat), ME, aliceKey); + + // When the server adds the real chat on the same participants, and then removes the optimistic one + const afterServerReport = getOneOnOneChatReportID(collection(optimisticChat, chatWith('30', ALICE)), ME, aliceKey); + const afterCleanup = getOneOnOneChatReportID(collection(chatWith('30', ALICE)), ME, aliceKey); + + // Then the optimistic chat stays for the moment both exist, and the real one takes over instead of the lookup coming back empty + expect(afterOptimistic).toBe('optimistic-1'); + expect(afterServerReport).toBe('optimistic-1'); + expect(afterCleanup).toBe('30'); + }); + + it('should not search the same collection twice for the same participants', () => { + // Given a collection that counts how often its report is read, and a first lookup over it + let reportReads = 0; + const aliceChat = chatWith('10', ALICE); + const reports: OnyxCollection = {}; + Object.defineProperty(reports, `${ONYXKEYS.COLLECTION.REPORT}10`, { + enumerable: true, + get: () => { + reportReads++; + return aliceChat; + }, + }); + expect(getOneOnOneChatReportID(reports, ME, aliceKey)).toBe('10'); + const readsAfterFirstLookup = reportReads; + + // When the page looks up the same chat again without a report write, for example on a re-render or a SESSION token refresh + const reportID = getOneOnOneChatReportID(reports, ME, aliceKey); + + // Then the cached answer comes back without reading the collection again + expect(reportID).toBe('10'); + expect(reportReads).toBe(readsAfterFirstLookup); + }); + + it('should search again for a different account, because that changes what counts as a 1:1 chat', () => { + // Given a lookup for the current user that found Alice's chat + const reports = collection(chatWith('10', ALICE)); + expect(getOneOnOneChatReportID(reports, ME, aliceKey)).toBe('10'); + + // When the same reports are searched for an account that is in none of them + const reportID = getOneOnOneChatReportID(reports, 99, aliceKey); + + // Then the chat has two other participants for that account, so it is not a 1:1 chat + expect(reportID).toBeUndefined(); + }); + + it('should find nothing until the current account ID is known', () => { + // Given a chat with Alice and no session yet + const reports = collection(chatWith('10', ALICE)); + + // When the chat is looked up without an accountID + const reportID = getOneOnOneChatReportID(reports, undefined, aliceKey); + + // Then nothing is found, because without the accountID every DM looks like a group chat + expect(reportID).toBeUndefined(); + }); +}); diff --git a/tests/unit/useOneOnOneChatReportIDTest.ts b/tests/unit/useOneOnOneChatReportIDTest.ts new file mode 100644 index 000000000000..67bf97bfebc3 --- /dev/null +++ b/tests/unit/useOneOnOneChatReportIDTest.ts @@ -0,0 +1,108 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import useOneOnOneChatReportID from '@hooks/useOneOnOneChatReportID'; + +import {buildParticipantsFromAccountIDs, getParticipantsChatKey} from '@libs/ReportUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report} from '@src/types/onyx'; + +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +const ME = 1; +const ALICE = 2; +const BOB = 3; + +const aliceKey = getParticipantsChatKey([ME, ALICE]); + +function chatWith(reportID: string, otherAccountID: number): Report { + return { + reportID, + type: CONST.REPORT.TYPE.CHAT, + participants: buildParticipantsFromAccountIDs([ME, otherAccountID]), + }; +} + +async function writeReport(report: Report) { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report); + await waitForBatchedUpdates(); +} + +describe('useOneOnOneChatReportID', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await Onyx.clear(); + await Onyx.set(ONYXKEYS.SESSION, {accountID: ME}); + await waitForBatchedUpdates(); + }); + + it('should return the chat with the given participants', async () => { + // Given chats with Alice and Bob + await writeReport(chatWith('10', ALICE)); + await writeReport(chatWith('20', BOB)); + + // When the page looks up the chat with Alice + const {result} = renderHook(() => useOneOnOneChatReportID(aliceKey)); + + // Then it gets Alice's chat + expect(result.current).toBe('10'); + }); + + it('should return nothing without a participants key', async () => { + // Given a chat with Alice + await writeReport(chatWith('10', ALICE)); + + // When the page is not sending to a single person, so it passes no key + const {result} = renderHook(() => useOneOnOneChatReportID(undefined)); + + // Then no chat is returned + expect(result.current).toBeUndefined(); + }); + + it('should keep the optimistic chat while the real one arrives, then follow the real one when the optimistic one is removed', async () => { + // Given an optimistic chat with Alice and the page looking it up + await writeReport(chatWith('optimistic-1', ALICE)); + const {result} = renderHook(() => useOneOnOneChatReportID(aliceKey)); + expect(result.current).toBe('optimistic-1'); + + // When the server adds the real chat on the same participants + await act(async () => { + await writeReport(chatWith('30', ALICE)); + }); + + // Then the optimistic chat is still the answer, as it was with the derived index + expect(result.current).toBe('optimistic-1'); + + // When the optimistic chat is removed + await act(async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}optimistic-1`, null); + await waitForBatchedUpdates(); + }); + + // Then the real chat takes over instead of the lookup coming back empty + expect(result.current).toBe('30'); + }); + + it('should find a chat once the session arrives', async () => { + // Given a chat with Alice and no accountID yet, as during sign in + await Onyx.set(ONYXKEYS.SESSION, {}); + await writeReport(chatWith('10', ALICE)); + const {result} = renderHook(() => useOneOnOneChatReportID(aliceKey)); + expect(result.current).toBeUndefined(); + + // When the session gets the accountID + await act(async () => { + await Onyx.merge(ONYXKEYS.SESSION, {accountID: ME}); + await waitForBatchedUpdates(); + }); + + // Then the chat is found, because SESSION is a dependency of the lookup + expect(result.current).toBe('10'); + }); +}); From d31d0c2852e65d68a48882036f3b57b72606616e Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Thu, 8 Oct 2026 17:41:09 +0200 Subject: [PATCH 2/3] Add reassure test for the P2P chat lookup on confirmation mount --- .../OneOnOneChatReportIDs.perf-test.tsx | 337 ++++++++++++++++++ 1 file changed, 337 insertions(+) create mode 100644 tests/perf-test/OneOnOneChatReportIDs.perf-test.tsx diff --git a/tests/perf-test/OneOnOneChatReportIDs.perf-test.tsx b/tests/perf-test/OneOnOneChatReportIDs.perf-test.tsx new file mode 100644 index 000000000000..1d7f0dc14c6d --- /dev/null +++ b/tests/perf-test/OneOnOneChatReportIDs.perf-test.tsx @@ -0,0 +1,337 @@ +import {render} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import {CurrentUserPersonalDetailsProvider} from '@components/CurrentUserPersonalDetailsProvider'; +import HTMLEngineProvider from '@components/HTMLEngineProvider'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; + +import type * as MoneyRequest from '@libs/actions/IOU/MoneyRequest'; +import {buildParticipantsFromAccountIDs} from '@libs/ReportUtils'; + +import IOURequestStepConfirmationWithWritableReportOrNotFound from '@pages/iou/request/step/IOURequestStepConfirmation'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report} from '@src/types/onyx'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; +import {measureAsyncFunction, measureRenders} from 'reassure'; + +import createMockScreenNavigation from '../utils/createMockScreenNavigation'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('@rnmapbox/maps', () => { + return { + default: jest.fn(), + MarkerView: jest.fn(), + setAccessToken: jest.fn(), + }; +}); + +jest.mock('@src/languages/IntlStore', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + const en: Record = require('@src/languages/en').default; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + const flatten: (obj: Record) => Record = require('@src/languages/flattenObject').default; + const cache = new Map>(); + cache.set('en', flatten(en)); + return { + getCurrentLocale: jest.fn(() => 'en'), + getDateFnsLocale: jest.fn(() => undefined), + load: jest.fn(() => Promise.resolve()), + get: jest.fn((key: string, locale?: string) => { + const translations = cache.get(locale ?? 'en'); + return translations?.[key] ?? null; + }), + }; +}); + +jest.mock('@assets/emojis', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actual = jest.requireActual('@assets/emojis'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actual, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + default: actual.default, + importEmojiLocale: jest.fn(() => Promise.resolve()), + }; +}); + +jest.mock('@libs/EmojiTrie', () => ({ + buildEmojisTrie: jest.fn(), +})); +jest.mock('@libs/actions/IOU/MoneyRequest', () => { + const actual = jest.requireActual('@libs/actions/IOU/MoneyRequest'); + return { + ...actual, + startMoneyRequest: jest.fn(), + }; +}); +jest.mock('@components/ProductTrainingContext', () => ({ + useProductTrainingContext: () => [false], +})); + +jest.mock('@src/hooks/useResponsiveLayout'); +jest.mock('@libs/getCurrentPosition'); +jest.mock('@libs/getIsNarrowLayout', () => jest.fn(() => false)); + +jest.mock('@libs/Navigation/navigationRef', () => ({ + __esModule: true, + default: { + getCurrentRoute: jest.fn(() => ({ + name: 'Money_Request_Step_Confirmation', + params: {}, + })), + getState: jest.fn(() => ({})), + getRootState: jest.fn(() => ({routes: []})), + }, +})); + +jest.mock('@libs/Navigation/Navigation', () => { + const mockRef = { + getCurrentRoute: jest.fn(() => ({ + name: 'Money_Request_Step_Confirmation', + params: {}, + })), + getState: jest.fn(() => ({})), + getRootState: jest.fn(() => ({routes: []})), + }; + return { + navigate: jest.fn(), + goBack: jest.fn(), + getActiveRouteWithoutParams: jest.fn(() => ''), + isNavigationReady: jest.fn(() => Promise.resolve()), + dismissModal: jest.fn((options?: {afterTransition?: () => void}) => { + options?.afterTransition?.(); + }), + dismissModalWithReport: jest.fn(), + dismissToPreviousRHP: jest.fn((options?: {afterTransition?: () => void}) => { + options?.afterTransition?.(); + }), + setNavigationActionToMicrotaskQueue: jest.fn((callback: () => void) => callback()), + getIsFullscreenPreInsertedUnderRHP: jest.fn(() => false), + getPreInsertedFullscreenRouteName: jest.fn(() => undefined), + clearFullscreenPreInsertedFlag: jest.fn(), + revealRouteBeforeDismissingModal: jest.fn((_route: unknown, options?: {afterTransition?: () => void}) => { + options?.afterTransition?.(); + }), + getTopmostReportId: jest.fn(() => undefined), + preInsertFullscreenUnderRHP: jest.fn(), + removePreInsertedFullscreenIfNeeded: jest.fn(), + isTopmostRouteModalScreen: jest.fn(() => false), + navigationRef: mockRef, + }; +}); + +jest.mock('@react-navigation/native', () => { + const mockRef = { + getCurrentRoute: jest.fn(() => ({ + name: 'Money_Request_Step_Confirmation', + params: {}, + })), + getState: jest.fn(() => ({})), + }; + return { + ...jest.requireActual>('@react-navigation/native'), + createNavigationContainerRef: jest.fn(() => mockRef), + useIsFocused: () => true, + useNavigation: () => ({navigate: jest.fn(), addListener: jest.fn()}), + useFocusEffect: jest.fn(), + usePreventRemove: jest.fn(), + }; +}); + +const {navigation: mockNavigation} = createMockScreenNavigation(); + +const ACCOUNT_ID = 1; +const ACCOUNT_LOGIN = 'test@user.com'; +const RECIPIENT_ACCOUNT_ID = 2; +const RECIPIENT_LOGIN = 'recipient@user.com'; +const OTHER_ACCOUNT_ID_START = 1000; +const LARGE_REPORT_COUNT = 5000; +const WRITES_PER_SCENARIO = 10; +const CHAT_REPORT_ID = 'p2p-chat'; +const IOU_REPORT_ID = 'p2p-iou-report'; +const TRANSACTION_ID = 'p2p-transaction'; +const UNRELATED_REPORT_ID = '1'; + +/** Thousands of chats, most of them DMs with other people, so a lookup by participants has a real collection to index */ +function buildReports(label: string): Record { + const reports: Record = {}; + for (let index = 0; index < LARGE_REPORT_COUNT; index++) { + const reportID = String(index + 1); + const otherAccountIDs = index % 5 === 0 ? [OTHER_ACCOUNT_ID_START + index, OTHER_ACCOUNT_ID_START + index + 1] : [OTHER_ACCOUNT_ID_START + index]; + reports[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`] = { + reportID, + type: CONST.REPORT.TYPE.CHAT, + participants: buildParticipantsFromAccountIDs([ACCOUNT_ID, ...otherAccountIDs]), + lastMessageText: `${label} ${reportID}`, + }; + } + reports[`${ONYXKEYS.COLLECTION.REPORT}${CHAT_REPORT_ID}`] = { + reportID: CHAT_REPORT_ID, + type: CONST.REPORT.TYPE.CHAT, + participants: buildParticipantsFromAccountIDs([ACCOUNT_ID, RECIPIENT_ACCOUNT_ID]), + }; + reports[`${ONYXKEYS.COLLECTION.REPORT}${IOU_REPORT_ID}`] = { + reportID: IOU_REPORT_ID, + chatReportID: CHAT_REPORT_ID, + type: CONST.REPORT.TYPE.IOU, + ownerAccountID: ACCOUNT_ID, + managerID: RECIPIENT_ACCOUNT_ID, + }; + return reports; +} + +async function seed() { + await Onyx.set(ONYXKEYS.SESSION, {accountID: ACCOUNT_ID, email: ACCOUNT_LOGIN}); + await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, { + [ACCOUNT_ID]: {accountID: ACCOUNT_ID, login: ACCOUNT_LOGIN, displayName: 'Me'}, + [RECIPIENT_ACCOUNT_ID]: {accountID: RECIPIENT_ACCOUNT_ID, login: RECIPIENT_LOGIN, displayName: 'Recipient'}, + }); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, { + transactionID: TRANSACTION_ID, + reportID: IOU_REPORT_ID, + amount: 1000, + isAmountSet: true, + currency: 'USD', + merchant: 'Test', + created: '2025-01-15', + iouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, + participants: [{accountID: RECIPIENT_ACCOUNT_ID, reportID: CHAT_REPORT_ID, selected: true}], + }); + await Onyx.setCollection(ONYXKEYS.COLLECTION.REPORT, buildReports('seed')); + await waitForBatchedUpdates(); +} + +/** Passed as the reassure wrapper so the providers sit outside the profiler and the counts are the page's own */ +function Providers({children}: {children: React.ReactNode}) { + return {children}; +} + +function ConfirmationPage() { + return ( + + ); +} + +async function mountScenario() { + await waitForBatchedUpdatesWithAct(); +} + +async function writeUnrelatedReport(label: string) { + for (let index = 0; index < WRITES_PER_SCENARIO; index++) { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${UNRELATED_REPORT_ID}`, {lastMessageText: `${label} ${index}`}); + await waitForBatchedUpdates(); + } +} + +describe('IOURequestStepConfirmation P2P chat lookup with a large report collection', () => { + beforeAll(async () => { + Onyx.init({keys: ONYXKEYS, evictableKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS]}); + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + require('@userActions/OnyxDerived').default(); + await waitForBatchedUpdates(); + }); + + beforeEach(async () => { + await seed(); + }); + + afterEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + test('[OneOnOneChatReportIDs] should mount the confirmation page after every report was replaced', async () => { + // Given 5000 reports, all replaced by new objects before each mount, as after sign in or a full reconnect + let run = 0; + const replaceAllReports = async () => { + await Onyx.setCollection(ONYXKEYS.COLLECTION.REPORT, buildReports(`replaced ${run}`)); + await waitForBatchedUpdates(); + run++; + }; + + // When the confirmation page mounts for an expense to the recipient, so it looks up their existing chat + // Then reassure measures the mount, which pays any lookup work that wasn't done before it + await measureRenders(, {beforeEach: replaceAllReports, scenario: mountScenario, wrapper: Providers}); + }); + + test('[OneOnOneChatReportIDs] should mount the confirmation page after one report changed', async () => { + // Given 5000 reports, one of them changed before each mount, as in normal use between two expenses + let run = 0; + const changeOneReport = async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${UNRELATED_REPORT_ID}`, {lastMessageText: `changed ${run}`}); + await waitForBatchedUpdates(); + run++; + }; + + // When the confirmation page mounts for an expense to the recipient + // Then reassure measures the mount + await measureRenders(, {beforeEach: changeOneReport, scenario: mountScenario, wrapper: Providers}); + }); + + test('[OneOnOneChatReportIDs] should re-render the mounted confirmation page on writes to an unrelated report', async () => { + // Given the confirmation page mounted over 5000 reports + let run = 0; + const scenario = async () => { + await waitForBatchedUpdatesWithAct(); + + // When an unrelated chat gets new messages, which leaves the recipient's chat unchanged + await writeUnrelatedReport(`render ${run}`); + run++; + }; + + // Then reassure measures what those writes cost the page + await measureRenders(, {scenario, wrapper: Providers}); + }); + + test('[OneOnOneChatReportIDs] should process writes to an unrelated report while the confirmation page is mounted', async () => { + // Given the confirmation page mounted over 5000 reports + render( + + + , + ); + await waitForBatchedUpdatesWithAct(); + + // When an unrelated chat gets new messages repeatedly + let run = 0; + const writeMessages = async () => { + await writeUnrelatedReport(`timed ${run}`); + run++; + }; + + // Then reassure times the whole write loop, including work done outside React renders such as derived computes and their writes + await measureAsyncFunction(writeMessages); + }); + + test('[OneOnOneChatReportIDs] should process writes to an unrelated report while the confirmation page is not mounted', async () => { + // Given 5000 reports and no confirmation page, as during most of the app's life + let run = 0; + const writeMessages = async () => { + await writeUnrelatedReport(`unmounted ${run}`); + run++; + }; + + // When an unrelated chat gets new messages repeatedly + // Then reassure times the write loop, which is the background cost of keeping the lookup ready + await measureAsyncFunction(writeMessages); + }); +}); 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', () => {