From d40c0c993ea401cc9ed1d33b07e0fe9985390a73 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Thu, 8 Oct 2026 17:03:46 +0200 Subject: [PATCH 1/3] Replace LOGIN_TO_ACCOUNT_ID_MAP derived value with a personal details selector --- src/ONYXKEYS.ts | 2 - .../sections/AttendeeField.tsx | 5 +- .../ReportActionItem/MoneyRequestView.tsx | 3 +- .../SearchList/ListItem/AttendeesCell.tsx | 10 +- src/hooks/useLoginToAccountIDMap.ts | 10 ++ src/libs/AttendeeUtils.ts | 4 +- .../OnyxDerived/ONYX_DERIVED_VALUES.ts | 2 - .../configs/loginToAccountIDMap.ts | 35 ------ src/selectors/PersonalDetails.ts | 39 +++++++ src/types/onyx/DerivedValues.ts | 9 -- src/types/onyx/index.ts | 2 - .../OnyxDerived/loginToAccountIDMapTest.ts | 41 ------- tests/unit/PersonalDetailsSelectorTest.ts | 106 ++++++++++++++++++ 13 files changed, 166 insertions(+), 102 deletions(-) create mode 100644 src/hooks/useLoginToAccountIDMap.ts delete mode 100644 src/libs/actions/OnyxDerived/configs/loginToAccountIDMap.ts delete mode 100644 tests/unit/OnyxDerived/loginToAccountIDMapTest.ts diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index d2b16572d106..9abcd9e2501d 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -1410,7 +1410,6 @@ const ONYXKEYS = { 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', ONE_ON_ONE_CHAT_REPORT_IDS: 'oneOnOneChatReportIDs', @@ -1981,7 +1980,6 @@ type OnyxDerivedValuesMapping = { [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; [ONYXKEYS.DERIVED.ONE_ON_ONE_CHAT_REPORT_IDS]: OnyxTypes.OneOnOneChatReportIDsDerivedValue; diff --git a/src/components/MoneyRequestConfirmationList/sections/AttendeeField.tsx b/src/components/MoneyRequestConfirmationList/sections/AttendeeField.tsx index cff6b871291d..1cc6b0f7e245 100644 --- a/src/components/MoneyRequestConfirmationList/sections/AttendeeField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/AttendeeField.tsx @@ -4,7 +4,7 @@ import UserPills from '@components/UserPills'; import useAttendees from '@hooks/useAttendees'; import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; +import useLoginToAccountIDMap from '@hooks/useLoginToAccountIDMap'; import useThemeStyles from '@hooks/useThemeStyles'; import {enrichAndSortAttendees} from '@libs/AttendeeUtils'; @@ -15,7 +15,6 @@ import {getAttendeesListDisplayString} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import type {IOUAction, IOUType} from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; -import ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; @@ -43,7 +42,7 @@ function AttendeeField({formattedAmountPerAttendee, isReadOnly, transactionID, a const styles = useThemeStyles(); const {translate, localeCompare} = useLocalize(); const personalDetailsList = usePersonalDetails(); - const [loginToAccountIDMap] = useOnyx(ONYXKEYS.DERIVED.LOGIN_TO_ACCOUNT_ID_MAP); + const loginToAccountIDMap = useLoginToAccountIDMap(); const shouldDisplayAttendeesError = formError === 'violations.missingAttendees'; const attendeeSlice = useTransactionSelector(transactionID, attendeeSliceSelector); diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 3fff41e1f9d3..d3055a4aa689 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -30,6 +30,7 @@ import useEnvironment from '@hooks/useEnvironment'; import useHasMultipleSplitChildren from '@hooks/useHasMultipleSplitChildren'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import useLoginToAccountIDMap from '@hooks/useLoginToAccountIDMap'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; @@ -223,7 +224,7 @@ function MoneyRequestView({ const {convertToDisplayString, getCurrencySymbol, getCurrencyDecimals} = useCurrencyListActions(); const {getReportRHPActiveRoute} = useActiveRoute(); const {showConfirmModal} = useConfirmModal(); - const [loginToAccountIDMap] = useOnyx(ONYXKEYS.DERIVED.LOGIN_TO_ACCOUNT_ID_MAP); + const loginToAccountIDMap = useLoginToAccountIDMap(); const {currentSearchResults} = useSearchResultsContext(); diff --git a/src/components/Search/SearchList/ListItem/AttendeesCell.tsx b/src/components/Search/SearchList/ListItem/AttendeesCell.tsx index 667e7b20b94d..6dd4aeae04d9 100644 --- a/src/components/Search/SearchList/ListItem/AttendeesCell.tsx +++ b/src/components/Search/SearchList/ListItem/AttendeesCell.tsx @@ -5,7 +5,7 @@ import UserDetailsTooltip from '@components/UserDetailsTooltip'; import useDefaultAvatars from '@hooks/useDefaultAvatars'; import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; +import {useAllPersonalDetailsWithoutSnapshots} from '@hooks/usePersonalDetails'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -17,10 +17,10 @@ import colors from '@styles/theme/colors'; import variables from '@styles/variables'; import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; import type {Attendee} from '@src/types/onyx/IOU'; import type {Icon as IconType} from '@src/types/onyx/OnyxCommon'; +import {accountIDsByLoginsSelector} from '@selectors/PersonalDetails'; import React from 'react'; import {View} from 'react-native'; @@ -32,9 +32,9 @@ type AttendeesCellProps = { function AttendeesCell({attendees, isHovered, isPressed}: AttendeesCellProps) { const defaultAvatars = useDefaultAvatars(); - const [loginToAccountIDMap] = useOnyx(ONYXKEYS.DERIVED.LOGIN_TO_ACCOUNT_ID_MAP); - const attendeeIcons: IconType[] = attendees.map((attendee) => { - const accountID = loginToAccountIDMap?.[attendee.email ?? ''] ?? CONST.DEFAULT_NUMBER_ID; + const [attendeeAccountIDs] = useAllPersonalDetailsWithoutSnapshots(accountIDsByLoginsSelector(attendees.map((attendee) => attendee.email))); + const attendeeIcons: IconType[] = attendees.map((attendee, index) => { + const accountID = attendeeAccountIDs?.at(index) ?? CONST.DEFAULT_NUMBER_ID; return { id: accountID, name: attendee.displayName ?? attendee.email, diff --git a/src/hooks/useLoginToAccountIDMap.ts b/src/hooks/useLoginToAccountIDMap.ts new file mode 100644 index 000000000000..ae93ac8c3754 --- /dev/null +++ b/src/hooks/useLoginToAccountIDMap.ts @@ -0,0 +1,10 @@ +import {loginToAccountIDMapSelector} from '@selectors/PersonalDetails'; + +import {useAllPersonalDetailsWithoutSnapshots} from './usePersonalDetails'; + +function useLoginToAccountIDMap() { + const [loginToAccountIDMap] = useAllPersonalDetailsWithoutSnapshots(loginToAccountIDMapSelector); + return loginToAccountIDMap; +} + +export default useLoginToAccountIDMap; diff --git a/src/libs/AttendeeUtils.ts b/src/libs/AttendeeUtils.ts index 517b6ebb723f..be4539d90a65 100644 --- a/src/libs/AttendeeUtils.ts +++ b/src/libs/AttendeeUtils.ts @@ -1,7 +1,7 @@ import type {LocaleContextProps} from '@components/LocaleContextProvider'; import CONST from '@src/CONST'; -import type {LoginToAccountIDMapDerivedValue, PersonalDetailsList, PolicyCategories} from '@src/types/onyx'; +import type {PersonalDetailsList, PolicyCategories} from '@src/types/onyx'; import type {Attendee} from '@src/types/onyx/IOU'; import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails'; @@ -134,7 +134,7 @@ type AttendeeWithAccountID = Attendee & {accountID?: number}; */ function enrichAndSortAttendees( attendees: Attendee[] | undefined, - loginToAccountIDMap: OnyxEntry, + loginToAccountIDMap: OnyxEntry>, personalDetailsList: OnyxEntry, localeCompare: LocaleContextProps['localeCompare'], ): AttendeeWithAccountID[] | undefined { diff --git a/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts b/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts index 4ea4fabd9560..507895a946ff 100644 --- a/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts +++ b/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts @@ -6,7 +6,6 @@ import type {OnyxDerivedValueConfig} from './types'; import cardFeedErrorsConfig from './configs/cardFeedErrors'; import guideAccountIDsConfig from './configs/guideAccountIDs'; -import loginToAccountIDMapConfig from './configs/loginToAccountIDMap'; import nonPersonalAndWorkspaceCardListConfig from './configs/nonPersonalAndWorkspaceCardList'; import oneOnOneChatReportIDsConfig from './configs/oneOnOneChatReportIDs'; import outstandingReportsByPolicyIDConfig from './configs/outstandingReportsByPolicyID'; @@ -30,7 +29,6 @@ const ONYX_DERIVED_VALUES = { [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, [ONYXKEYS.DERIVED.ONE_ON_ONE_CHAT_REPORT_IDS]: oneOnOneChatReportIDsConfig, diff --git a/src/libs/actions/OnyxDerived/configs/loginToAccountIDMap.ts b/src/libs/actions/OnyxDerived/configs/loginToAccountIDMap.ts deleted file mode 100644 index 5c0fc83521d5..000000000000 --- a/src/libs/actions/OnyxDerived/configs/loginToAccountIDMap.ts +++ /dev/null @@ -1,35 +0,0 @@ -import createOnyxDerivedValueConfig from '@userActions/OnyxDerived/createOnyxDerivedValueConfig'; - -import ONYXKEYS from '@src/ONYXKEYS'; -import type {LoginToAccountIDMapDerivedValue} from '@src/types/onyx'; - -/** - * Derives a map of login (lowercased) -> accountID from the personal details list. - * - * This is the reactive, persisted replacement for the imperative `emailToPersonalDetailsCache` - * login lookup that was built via `Onyx.connect` in `PersonalDetailsUtils` (see issue #66391). - * Keys are lowercased since logins/emails are case-insensitive, matching the previous cache behavior. - */ -export default createOnyxDerivedValueConfig({ - key: ONYXKEYS.DERIVED.LOGIN_TO_ACCOUNT_ID_MAP, - dependencies: [ONYXKEYS.PERSONAL_DETAILS_LIST], - compute: ([personalDetailsList]) => { - if (!personalDetailsList) { - return {}; - } - - const loginToAccountIDMap: LoginToAccountIDMapDerivedValue = {}; - for (const personalDetails of Object.values(personalDetailsList)) { - if (!personalDetails?.login) { - continue; - } - const login = personalDetails.login.toLowerCase(); - const existingAccountID = loginToAccountIDMap[login]; - const existingDetail = existingAccountID === undefined ? undefined : personalDetailsList[existingAccountID]; - if (!existingDetail || existingDetail.isClosed || existingDetail.isOptimisticPersonalDetail) { - loginToAccountIDMap[login] = personalDetails.accountID; - } - } - return loginToAccountIDMap; - }, -}); diff --git a/src/selectors/PersonalDetails.ts b/src/selectors/PersonalDetails.ts index 6213fb00410d..e94eab00a35a 100644 --- a/src/selectors/PersonalDetails.ts +++ b/src/selectors/PersonalDetails.ts @@ -95,6 +95,43 @@ const optimisticPersonalDetailsSelector = (personalDetailsList: OnyxEntry>(); + +/** + * Maps each lowercased login to its accountID. When two accounts share a login, a closed or optimistic one loses to the other. + * Cached on the list reference, so every Search row that reads it after the same write shares one build. + */ +const loginToAccountIDMapSelector = (personalDetailsList: OnyxEntry): Record => { + if (!personalDetailsList) { + return {}; + } + + const cachedLoginToAccountIDMap = loginToAccountIDMapCache.get(personalDetailsList); + if (cachedLoginToAccountIDMap) { + return cachedLoginToAccountIDMap; + } + + const loginToAccountIDMap: Record = {}; + for (const personalDetails of Object.values(personalDetailsList)) { + if (!personalDetails?.login) { + continue; + } + const login = personalDetails.login.toLowerCase(); + const existingAccountID = loginToAccountIDMap[login]; + const existingDetail = existingAccountID === undefined ? undefined : personalDetailsList[existingAccountID]; + if (!existingDetail || existingDetail.isClosed || existingDetail.isOptimisticPersonalDetail) { + loginToAccountIDMap[login] = personalDetails.accountID; + } + } + loginToAccountIDMapCache.set(personalDetailsList, loginToAccountIDMap); + return loginToAccountIDMap; +}; + +const accountIDsByLoginsSelector = (logins: Array) => (personalDetailsList: OnyxEntry) => { + const loginToAccountIDMap = loginToAccountIDMapSelector(personalDetailsList); + return logins.map((login) => loginToAccountIDMap[login ?? ''] ?? CONST.DEFAULT_NUMBER_ID); +}; + const newAccountIDsAndLoginsSelector = (invitedEmailsToAccountIDs: InvitedEmailsToAccountIDs | undefined) => (personalDetailsList: OnyxEntry) => getNewAccountIDsAndLogins(invitedEmailsToAccountIDs, personalDetailsList); @@ -125,6 +162,8 @@ export { accountIDToLoginSelector, isPersonalDetailOptimistic, optimisticPersonalDetailsSelector, + loginToAccountIDMapSelector, + accountIDsByLoginsSelector, createDisplayDetailsByAccountIDsSelector, newAccountIDsAndLoginsSelector, displayNameSelector, diff --git a/src/types/onyx/DerivedValues.ts b/src/types/onyx/DerivedValues.ts index 3f5e2a136157..16c4ddfe5f54 100644 --- a/src/types/onyx/DerivedValues.ts +++ b/src/types/onyx/DerivedValues.ts @@ -211,14 +211,6 @@ type SortedReportActionsDerivedValue = { */ type PersonalAndWorkspaceCardListDerivedValue = CardList; -/** - * The derived value mapping each user's login (lowercased) to their accountID. - * - * Replaces the imperative `emailToPersonalDetailsCache` login lookup that was built via `Onyx.connect` - * in `PersonalDetailsUtils` (see issue #66391). Keys are lowercased since logins/emails are case-insensitive. - */ -type LoginToAccountIDMapDerivedValue = Record; - /** * The accountIDs of every Expensify Guide known to the personal details list, sorted ascending. * Lets callers check for a guide participant without re-scanning the whole list (see issue #66413). @@ -255,7 +247,6 @@ export type { NonPersonalAndWorkspaceCardListDerivedValue, PersonalAndWorkspaceCardListDerivedValue, CardFeedErrorsDerivedValue, - LoginToAccountIDMapDerivedValue, GuideAccountIDsDerivedValue, SpendDataSignatureDerivedValue, OneOnOneChatReportIDsDerivedValue, diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index fad7cf8b5ab5..f9949c66d4b9 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -62,7 +62,6 @@ import type { CardFeedErrorsDerivedValue, GuideAccountIDsDerivedValue, SpendDataSignatureDerivedValue, - LoginToAccountIDMapDerivedValue, NonPersonalAndWorkspaceCardListDerivedValue, OneOnOneChatReportIDsDerivedValue, OutstandingReportsByPolicyIDDerivedValue, @@ -461,7 +460,6 @@ export type { NonPersonalAndWorkspaceCardListDerivedValue, PersonalAndWorkspaceCardListDerivedValue, CardFeedErrorsDerivedValue, - LoginToAccountIDMapDerivedValue, GuideAccountIDsDerivedValue, SpendDataSignatureDerivedValue, OneOnOneChatReportIDsDerivedValue, diff --git a/tests/unit/OnyxDerived/loginToAccountIDMapTest.ts b/tests/unit/OnyxDerived/loginToAccountIDMapTest.ts deleted file mode 100644 index c781a6644a9d..000000000000 --- a/tests/unit/OnyxDerived/loginToAccountIDMapTest.ts +++ /dev/null @@ -1,41 +0,0 @@ -import loginToAccountIDMapConfig from '@libs/actions/OnyxDerived/configs/loginToAccountIDMap'; - -import type {PersonalDetailsList} from '@src/types/onyx'; - -describe('loginToAccountIDMap', () => { - const accountID1 = 1; - const accountID2 = 2; - const login = 'user1@example.com'; - - it('prefers the live account when a closed merged-away account shares the same login, regardless of order', () => { - const closedHasHigherAccountID: PersonalDetailsList = { - [accountID1]: {accountID: accountID1, login}, - [accountID2]: {accountID: accountID2, login, isClosed: true}, - }; - - expect(loginToAccountIDMapConfig.compute([closedHasHigherAccountID], {})).toEqual({[login]: accountID1}); - - const closedHasLowerAccountID: PersonalDetailsList = { - [accountID1]: {accountID: accountID1, login, isClosed: true}, - [accountID2]: {accountID: accountID2, login}, - }; - - expect(loginToAccountIDMapConfig.compute([closedHasLowerAccountID], {})).toEqual({[login]: accountID2}); - }); - - it('prefers the real account when an optimistic personal detail shares the same login, regardless of order', () => { - const optimisticHasHigherAccountID: PersonalDetailsList = { - [accountID1]: {accountID: accountID1, login}, - [accountID2]: {accountID: accountID2, login, isOptimisticPersonalDetail: true}, - }; - - expect(loginToAccountIDMapConfig.compute([optimisticHasHigherAccountID], {})).toEqual({[login]: accountID1}); - - const optimisticHasLowerAccountID: PersonalDetailsList = { - [accountID1]: {accountID: accountID1, login, isOptimisticPersonalDetail: true}, - [accountID2]: {accountID: accountID2, login}, - }; - - expect(loginToAccountIDMapConfig.compute([optimisticHasLowerAccountID], {})).toEqual({[login]: accountID2}); - }); -}); diff --git a/tests/unit/PersonalDetailsSelectorTest.ts b/tests/unit/PersonalDetailsSelectorTest.ts index 64f131fc1170..bc1d826f86a1 100644 --- a/tests/unit/PersonalDetailsSelectorTest.ts +++ b/tests/unit/PersonalDetailsSelectorTest.ts @@ -6,7 +6,9 @@ import CONST from '@src/CONST'; import type {PersonalDetails, PersonalDetailsList} from '@src/types/onyx'; import { + accountIDsByLoginsSelector, createDisplayDetailsByAccountIDsSelector, + loginToAccountIDMapSelector, multiPersonalDetailsSelector, personalDetailsDisplayNameSelector, personalDetailsListSelector, @@ -240,4 +242,108 @@ describe('PersonalDetailsSelector', () => { expect(result).toEqual({}); }); }); + + describe('loginToAccountIDMapSelector', () => { + const accountID1 = 1; + const accountID2 = 2; + const login = 'user1@example.com'; + + it('should return an empty map when there are no personal details', () => { + // Given no personal details, or an empty list + // When the login map is selected + // Then no login resolves to an account + expect(loginToAccountIDMapSelector(undefined)).toEqual({}); + expect(loginToAccountIDMapSelector({})).toEqual({}); + }); + + it('should key the map by the lowercased login and skip entries without one', () => { + // Given a login with capitals next to an entry without a login + const list: PersonalDetailsList = { + [accountID1]: {accountID: accountID1, login: 'User1@Example.com'}, + [accountID2]: {accountID: accountID2}, + }; + + // When the login map is selected + // Then the login is lowercased, since logins are case-insensitive, and the entry without a login is left out + expect(loginToAccountIDMapSelector(list)).toEqual({[login]: accountID1}); + }); + + it('should prefer the live account when a closed merged-away account shares the same login, regardless of order', () => { + // Given a live and a closed account with the same login, in both key orders + const closedHasHigherAccountID: PersonalDetailsList = { + [accountID1]: {accountID: accountID1, login}, + [accountID2]: {accountID: accountID2, login, isClosed: true}, + }; + const closedHasLowerAccountID: PersonalDetailsList = { + [accountID1]: {accountID: accountID1, login, isClosed: true}, + [accountID2]: {accountID: accountID2, login}, + }; + + // When the login map is selected + // Then the login resolves to the live account, since the closed one was merged away + expect(loginToAccountIDMapSelector(closedHasHigherAccountID)).toEqual({[login]: accountID1}); + expect(loginToAccountIDMapSelector(closedHasLowerAccountID)).toEqual({[login]: accountID2}); + }); + + it('should prefer the real account when an optimistic personal detail shares the same login, regardless of order', () => { + // Given a real account and an optimistic one with the same login, in both key orders + const optimisticHasHigherAccountID: PersonalDetailsList = { + [accountID1]: {accountID: accountID1, login}, + [accountID2]: {accountID: accountID2, login, isOptimisticPersonalDetail: true}, + }; + const optimisticHasLowerAccountID: PersonalDetailsList = { + [accountID1]: {accountID: accountID1, login, isOptimisticPersonalDetail: true}, + [accountID2]: {accountID: accountID2, login}, + }; + + // When the login map is selected + // Then the login resolves to the real account, since the optimistic one is a placeholder + expect(loginToAccountIDMapSelector(optimisticHasHigherAccountID)).toEqual({[login]: accountID1}); + expect(loginToAccountIDMapSelector(optimisticHasLowerAccountID)).toEqual({[login]: accountID2}); + }); + + it('should return the same map for the same personal details list', () => { + // Given one personal details list + const list: PersonalDetailsList = {[accountID1]: {accountID: accountID1, login}}; + + // When the login map is selected twice, as two Search rows do after the same write + // Then both get the same object, so the map is built once per list + expect(loginToAccountIDMapSelector(list)).toBe(loginToAccountIDMapSelector(list)); + }); + }); + + describe('accountIDsByLoginsSelector', () => { + const accountID1 = 1; + const accountID2 = 2; + const login1 = 'user1@example.com'; + const login2 = 'user2@example.com'; + + it('should return the accountID of each login in order, with the default ID for unknown or missing logins', () => { + // Given two known accounts + const list: PersonalDetailsList = { + [accountID1]: {accountID: accountID1, login: login1}, + [accountID2]: {accountID: accountID2, login: login2}, + }; + + // When the accountIDs of two known logins, an unknown login and a missing one are selected + // Then each position holds its account, and the rest fall back to the default ID like a failed map lookup did + expect(accountIDsByLoginsSelector([login2, login1, 'nobody@example.com', undefined])(list)).toEqual([accountID2, accountID1, CONST.DEFAULT_NUMBER_ID, CONST.DEFAULT_NUMBER_ID]); + }); + + it('should select a deep-equal array when a user who is not in the logins changes', () => { + // Given an attendee and an unrelated user, before and after the unrelated user is renamed + const before: PersonalDetailsList = { + [accountID1]: {accountID: accountID1, login: login1}, + [accountID2]: {accountID: accountID2, login: login2, displayName: 'Before'}, + }; + const after: PersonalDetailsList = { + ...before, + [accountID2]: {accountID: accountID2, login: login2, displayName: 'After'}, + }; + + // When the attendee's accountID is selected from both + // Then both selections are equal, so useOnyx's deep-equal check skips the Search row re-render + expect(accountIDsByLoginsSelector([login1])(after)).toEqual(accountIDsByLoginsSelector([login1])(before)); + }); + }); }); From ea6aa07fe0e7480fe80638034c6f94039f22a136 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Thu, 8 Oct 2026 17:03:48 +0200 Subject: [PATCH 2/3] Add reassure test for login to accountID map on personal details writes --- ...ntIDMapPersonalDetailsWrites.perf-test.tsx | 178 ++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 tests/perf-test/LoginToAccountIDMapPersonalDetailsWrites.perf-test.tsx diff --git a/tests/perf-test/LoginToAccountIDMapPersonalDetailsWrites.perf-test.tsx b/tests/perf-test/LoginToAccountIDMapPersonalDetailsWrites.perf-test.tsx new file mode 100644 index 000000000000..a8da69782ffa --- /dev/null +++ b/tests/perf-test/LoginToAccountIDMapPersonalDetailsWrites.perf-test.tsx @@ -0,0 +1,178 @@ +import {render, screen} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import AttendeesCell from '@components/Search/SearchList/ListItem/AttendeesCell'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import type {PersonalDetailsList} from '@src/types/onyx'; +import type {Attendee} from '@src/types/onyx/IOU'; + +import React from 'react'; +import {View} from 'react-native'; +import Onyx from 'react-native-onyx'; +import {measureAsyncFunction, measureRenders} from 'reassure'; + +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +const ROW_COUNT = 50; +const ATTENDEES_PER_ROW = 3; +const WRITES_PER_SCENARIO = 10; +const LARGE_PERSONAL_DETAILS_COUNT = 5000; +const UNRELATED_ACCOUNT_ID = 1000; +const SHARED_ATTENDEE_ACCOUNT_ID = 2000; +const SHARED_ATTENDEE_DUPLICATE_ACCOUNT_ID = 2001; +const FIRST_EXTRA_ACCOUNT_ID = 10000; +const SHARED_ATTENDEE_EMAIL = 'shared@test.com'; + +const smallPersonalDetails: PersonalDetailsList = { + [UNRELATED_ACCOUNT_ID]: {accountID: UNRELATED_ACCOUNT_ID, login: 'unrelated@test.com', displayName: 'Unrelated'}, + [SHARED_ATTENDEE_ACCOUNT_ID]: {accountID: SHARED_ATTENDEE_ACCOUNT_ID, login: SHARED_ATTENDEE_EMAIL, displayName: 'Shared'}, + [SHARED_ATTENDEE_DUPLICATE_ACCOUNT_ID]: {accountID: SHARED_ATTENDEE_DUPLICATE_ACCOUNT_ID, login: SHARED_ATTENDEE_EMAIL, displayName: 'Shared duplicate'}, +}; +const rows: Attendee[][] = []; +for (let row = 0; row < ROW_COUNT; row++) { + const attendees: Attendee[] = [{email: SHARED_ATTENDEE_EMAIL, displayName: 'Shared', avatarUrl: ''}]; + for (let index = 0; index < ATTENDEES_PER_ROW; index++) { + const accountID = row * ATTENDEES_PER_ROW + index + 1; + const email = `attendee${accountID}@test.com`; + smallPersonalDetails[accountID] = {accountID, login: email, displayName: `Attendee ${accountID}`}; + attendees.push({email, displayName: `Attendee ${accountID}`, avatarUrl: ''}); + } + rows.push(attendees); +} + +const largePersonalDetails: PersonalDetailsList = { + ...smallPersonalDetails, + ...Object.fromEntries( + Array.from({length: LARGE_PERSONAL_DETAILS_COUNT}, (value, index) => { + const accountID = FIRST_EXTRA_ACCOUNT_ID + index; + return [accountID, {accountID, login: `user${accountID}@test.com`, displayName: `User ${accountID}`}]; + }), + ), +}; + +/** Passed as the reassure wrapper so the providers sit outside the profiler and render counts are the rows' own */ +function Providers({children}: {children: React.ReactNode}) { + return {children}; +} + +/** Search rows read the login map but not the report attributes, so their re-renders isolate login map churn */ +function SearchRowsWithAttendees() { + return ( + + {rows.map((attendees, index) => ( + + ))} + + ); +} + +async function seed(personalDetails: PersonalDetailsList) { + await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails); + await waitForBatchedUpdates(); +} + +async function renameUnrelatedUser(label: string) { + for (let index = 0; index < WRITES_PER_SCENARIO; index++) { + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {[UNRELATED_ACCOUNT_ID]: {displayName: `Unrelated ${label}-${index}`}}); + await waitForBatchedUpdates(); + } +} + +describe('Login to accountID map consumers on personal details writes', () => { + beforeAll(async () => { + Onyx.init({keys: ONYXKEYS}); + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + require('@userActions/OnyxDerived').default(); + await waitForBatchedUpdates(); + }); + + afterEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + test('[LoginToAccountIDMap] should re-render 50 Search rows when a user who attends nothing is renamed', async () => { + // Given 50 Search rows with attendees + await seed(smallPersonalDetails); + + // Reassure runs the scenario several times against one seeded Onyx, so every run writes a name that doesn't exist yet + let run = 0; + const scenario = async () => { + await screen.findAllByTestId('AttendeesCell-Row'); + + // When a user who attends none of them is renamed, which leaves every login's accountID unchanged + await renameUnrelatedUser(`small ${run}`); + run++; + }; + + // Then reassure measures how many row re-renders the unrelated writes cause + await measureRenders(, {scenario, wrapper: Providers}); + }); + + test('[LoginToAccountIDMap] should re-render 50 Search rows when the account behind a shared attendee changes', async () => { + // Given 50 Search rows that all list one attendee whose login belongs to two accounts + await seed(smallPersonalDetails); + + let isFirstAccountClosed = false; + const scenario = async () => { + await screen.findAllByTestId('AttendeesCell-Row'); + + // When the first account is closed and reopened in turn, so the login resolves to the other account and back + for (let index = 0; index < WRITES_PER_SCENARIO; index++) { + isFirstAccountClosed = !isFirstAccountClosed; + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {[SHARED_ATTENDEE_ACCOUNT_ID]: {isClosed: isFirstAccountClosed}}); + await waitForBatchedUpdates(); + } + }; + + // Then reassure measures the cost of a real change, which both versions have to pay + await measureRenders(, {scenario, wrapper: Providers}); + }); + + test('[LoginToAccountIDMap] should re-render 50 Search rows when a user who attends nothing is renamed in a large personal details list', async () => { + // Given the same rows, with 5000 more people in the personal details list + await seed(largePersonalDetails); + + let run = 0; + const scenario = async () => { + await screen.findAllByTestId('AttendeesCell-Row'); + + // When a user who attends nothing is renamed, so every write rebuilds the whole login map + await renameUnrelatedUser(`large ${run}`); + run++; + }; + + // Then reassure measures the render cost, which grows if each row rebuilds the map during render + await measureRenders(, {scenario, wrapper: Providers}); + }); + + test('[LoginToAccountIDMap] should process renames of a user who attends nothing in a large personal details list', async () => { + // Given the rows mounted over the large personal details list + await seed(largePersonalDetails); + render( + + + , + ); + await screen.findAllByTestId('AttendeesCell-Row'); + + // When a user who attends nothing is renamed repeatedly + let run = 0; + const writeRenames = async () => { + await renameUnrelatedUser(`timed ${run}`); + run++; + }; + + // Then reassure times the whole write loop, including work done outside React renders such as derived computes and their writes + await measureAsyncFunction(writeRenames); + }); +}); From 9ef3c3a699283cfcdfa3c3b4af17467a851c6298 Mon Sep 17 00:00:00 2001 From: truph01 Date: Thu, 15 Oct 2026 02:24:34 +0700 Subject: [PATCH 3/3] Show empty state instead of No results when default view hides all rows --- src/components/Table/TableBody.tsx | 7 +++- .../TableEmptyStates/TableNoResultsState.tsx | 5 ++- tests/ui/TableTest.tsx | 38 +++++++++++++++++++ 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/src/components/Table/TableBody.tsx b/src/components/Table/TableBody.tsx index 9a4db2f7aead..faa96b4da20f 100644 --- a/src/components/Table/TableBody.tsx +++ b/src/components/Table/TableBody.tsx @@ -164,6 +164,7 @@ function TableBodyList({contentContainerStyle, emptyMessage, onLayout, style, .. noResultsStateElement, tableListMetadata, isEmptyResult, + isDefaultViewEmpty, scrollWidth, tableWidth, } = useTableContext(); @@ -337,8 +338,10 @@ function TableBodyList({contentContainerStyle, emptyMessage, onLayout, style, .. ); - const emptyStateContent = - tableListMetadata.hasPageHeader && isEmptyResult ? (noResultsStateElement ?? EmptyResultComponent) : (emptyStateElement ?? renderListComponent(ListEmptyComponent)); + // When the default view hides every row (e.g. all workspaces are archived), no search or filter is active, + // so show the empty state rather than "No results". + const shouldShowNoResultsState = tableListMetadata.hasPageHeader && isEmptyResult && !isDefaultViewEmpty; + const emptyStateContent = shouldShowNoResultsState ? (noResultsStateElement ?? EmptyResultComponent) : (emptyStateElement ?? renderListComponent(ListEmptyComponent)); const footerElement = renderListComponent(ListFooterComponent); // Consumer footer flex growth is useful below normal rows, but inside the combined empty-state // footer it can expand over the page header. Preserve the remaining style while disabling growth. diff --git a/src/components/Table/TableEmptyStates/TableNoResultsState.tsx b/src/components/Table/TableEmptyStates/TableNoResultsState.tsx index 60a93ca4a2c6..30c49a66fe35 100644 --- a/src/components/Table/TableEmptyStates/TableNoResultsState.tsx +++ b/src/components/Table/TableEmptyStates/TableNoResultsState.tsx @@ -21,10 +21,11 @@ type TableNoResultsStateProps = Omit { expect(screen.queryByTestId('row-2')).toBeNull(); expect(screen.getByTestId('generic-empty-state')).toBeTruthy(); }); + + it('renders the empty state instead of "No results" when a page-header table hides every row by default', () => { + // Given a page-header table (like the Workspaces list) whose default filter hides every row, with both + // a no-results state and an empty state declared + const props = createDefaultProps(); + const data: TestItem[] = [ + {keyForList: '1', id: '1', name: 'Archived A', category: 'archived', value: 1}, + {keyForList: '2', id: '2', name: 'Archived B', category: 'archived', value: 2}, + ]; + + // When the table renders with no search or filter selected + render( + + data={data} + columns={props.columns} + renderItem={props.renderItem} + keyExtractor={props.keyExtractor} + filters={statusFilterConfig} + isItemInFilter={isItemInStatusFilter} + > + + Page header + + + + + + , + ); + + // Then the empty state shows inside the list with the page header still visible, because the user did + // not search or filter, so "No results found" would be misleading + expect(screen.getByTestId('flash-list')).toBeTruthy(); + expect(screen.getByTestId('table-header-component')).toBeTruthy(); + expect(screen.getAllByTestId('generic-empty-state')).toHaveLength(1); + expect(screen.getByText('No workspaces')).toBeTruthy(); + expect(screen.queryByText('common.noResultsFound')).toBeNull(); + }); }); describe('immediate filter', () => {