From 9a9af303f2bc8e7bd5957cf4e89b5a4874cc0aa3 Mon Sep 17 00:00:00 2001 From: VH Date: Fri, 5 Jun 2026 15:57:54 +0700 Subject: [PATCH 1/9] feat(onyx): add accountIDToNameMap derived value Adds a new OnyxDerived value that builds a Record from PERSONAL_DETAILS_LIST, replacing ad-hoc Onyx.connect calls that were scattered across modules. Co-Authored-By: Claude Sonnet 4.6 --- src/ONYXKEYS.ts | 2 + .../OnyxDerived/ONYX_DERIVED_VALUES.ts | 2 + .../OnyxDerived/configs/accountIDToNameMap.ts | 23 ++++++ src/types/onyx/DerivedValues.ts | 6 ++ src/types/onyx/index.ts | 2 + .../OnyxDerived/accountIDToNameMapTest.ts | 78 +++++++++++++++++++ 6 files changed, 113 insertions(+) create mode 100644 src/libs/actions/OnyxDerived/configs/accountIDToNameMap.ts create mode 100644 tests/unit/OnyxDerived/accountIDToNameMapTest.ts diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 451db5291bb9..8d1ade669e7b 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -1192,6 +1192,7 @@ const ONYXKEYS = { TODOS: 'todos', RAM_ONLY_SORTED_REPORT_ACTIONS: 'sortedReportActions', OPEN_AND_SUBMITTED_REPORTS_BY_POLICY_ID: 'openAndSubmittedReportsByPolicyID', + ACCOUNT_ID_TO_NAME_MAP: 'accountIDToNameMap', }, /** Stores HybridApp specific state required to interoperate with OldDot */ @@ -1678,6 +1679,7 @@ type OnyxDerivedValuesMapping = { [ONYXKEYS.DERIVED.TODOS]: OnyxTypes.TodosDerivedValue; [ONYXKEYS.DERIVED.RAM_ONLY_SORTED_REPORT_ACTIONS]: OnyxTypes.SortedReportActionsDerivedValue; [ONYXKEYS.DERIVED.OPEN_AND_SUBMITTED_REPORTS_BY_POLICY_ID]: OnyxTypes.OpenAndSubmittedReportsByPolicyIDDerivedValue; + [ONYXKEYS.DERIVED.ACCOUNT_ID_TO_NAME_MAP]: OnyxTypes.AccountIDToNameMapDerivedValue; }; type OnyxValues = OnyxValuesMapping & OnyxCollectionValuesMapping & OnyxFormValuesMapping & OnyxFormDraftValuesMapping & OnyxDerivedValuesMapping; diff --git a/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts b/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts index 161c7f72ecf8..7aa4730315f2 100644 --- a/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts +++ b/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts @@ -1,5 +1,6 @@ import type {ValueOf} from 'type-fest'; import ONYXKEYS from '@src/ONYXKEYS'; +import accountIDToNameMapConfig from './configs/accountIDToNameMap'; import cardFeedErrorsConfig from './configs/cardFeedErrors'; import nonPersonalAndWorkspaceCardListConfig from './configs/nonPersonalAndWorkspaceCardList'; import openAndSubmittedReportsByPolicyIDConfig from './configs/openAndSubmittedReportsByPolicyID'; @@ -27,6 +28,7 @@ const ONYX_DERIVED_VALUES = { [ONYXKEYS.DERIVED.TODOS]: todosConfig, [ONYXKEYS.DERIVED.RAM_ONLY_SORTED_REPORT_ACTIONS]: sortedReportActionsConfig, [ONYXKEYS.DERIVED.OPEN_AND_SUBMITTED_REPORTS_BY_POLICY_ID]: openAndSubmittedReportsByPolicyIDConfig, + [ONYXKEYS.DERIVED.ACCOUNT_ID_TO_NAME_MAP]: accountIDToNameMapConfig, } as const satisfies { // eslint-disable-next-line @typescript-eslint/no-explicit-any [Key in ValueOf]: OnyxDerivedValueConfig; diff --git a/src/libs/actions/OnyxDerived/configs/accountIDToNameMap.ts b/src/libs/actions/OnyxDerived/configs/accountIDToNameMap.ts new file mode 100644 index 000000000000..fd1f07dbc981 --- /dev/null +++ b/src/libs/actions/OnyxDerived/configs/accountIDToNameMap.ts @@ -0,0 +1,23 @@ +import createOnyxDerivedValueConfig from '@userActions/OnyxDerived/createOnyxDerivedValueConfig'; +import ONYXKEYS from '@src/ONYXKEYS'; + +/** + * Builds a mapping from accountID to user name (login or displayName). + */ +export default createOnyxDerivedValueConfig({ + key: ONYXKEYS.DERIVED.ACCOUNT_ID_TO_NAME_MAP, + dependencies: [ONYXKEYS.PERSONAL_DETAILS_LIST], + compute: ([personalDetailsList]) => { + const accountIDToNameMap: Record = {}; + + for (const personalDetails of Object.values(personalDetailsList ?? {})) { + if (!personalDetails) { + continue; + } + + accountIDToNameMap[personalDetails.accountID] = personalDetails.login ?? personalDetails.displayName ?? ''; + } + + return accountIDToNameMap; + }, +}); diff --git a/src/types/onyx/DerivedValues.ts b/src/types/onyx/DerivedValues.ts index bbcecb4f4d6f..b0584880d761 100644 --- a/src/types/onyx/DerivedValues.ts +++ b/src/types/onyx/DerivedValues.ts @@ -283,6 +283,11 @@ type SortedReportActionsDerivedValue = { */ type PersonalAndWorkspaceCardListDerivedValue = CardList; +/** + * Mapping from accountID to user name (login or displayName). + */ +type AccountIDToNameMapDerivedValue = Record; + export type { ReportAttributes, ReportAttributesDerivedValue, @@ -301,4 +306,5 @@ export type { CardFeedErrorState, CardFeedErrors, CardErrors, + AccountIDToNameMapDerivedValue, }; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 2d530f190329..1f2f7296b4e4 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -47,6 +47,7 @@ import type {CurrencyList} from './Currency'; import type CustomStatusDraft from './CustomStatusDraft'; import type DeferredAgentWorkflowSave from './DeferredAgentWorkflowSave'; import type { + AccountIDToNameMapDerivedValue, CardFeedErrorsDerivedValue, NonPersonalAndWorkspaceCardListDerivedValue, OpenAndSubmittedReportsByPolicyIDDerivedValue, @@ -393,6 +394,7 @@ export type { OutstandingReportsByPolicyIDDerivedValue, OpenAndSubmittedReportsByPolicyIDDerivedValue, VisibleReportActionsDerivedValue, + AccountIDToNameMapDerivedValue, SortedReportActionsDerivedValue, NonPersonalAndWorkspaceCardListDerivedValue, PersonalAndWorkspaceCardListDerivedValue, diff --git a/tests/unit/OnyxDerived/accountIDToNameMapTest.ts b/tests/unit/OnyxDerived/accountIDToNameMapTest.ts new file mode 100644 index 000000000000..cbd018608b78 --- /dev/null +++ b/tests/unit/OnyxDerived/accountIDToNameMapTest.ts @@ -0,0 +1,78 @@ +import accountIDToNameMapConfig from '@libs/actions/OnyxDerived/configs/accountIDToNameMap'; +import type {DerivedValueContext} from '@libs/actions/OnyxDerived/types'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {PersonalDetails, PersonalDetailsList} from '@src/types/onyx'; + +const {compute} = accountIDToNameMapConfig; +const emptyContext = {} as DerivedValueContext; + +function createPersonalDetails(accountID: number, overrides: Partial = {}): PersonalDetails { + return { + accountID, + login: `user${accountID}@example.com`, + displayName: `User ${accountID}`, + ...overrides, + } as PersonalDetails; +} + +describe('accountIDToNameMap derived value', () => { + it('returns empty object when personalDetailsList is undefined', () => { + const result = compute([undefined], emptyContext); + expect(result).toEqual({}); + }); + + it('returns empty object when personalDetailsList is empty', () => { + const result = compute([{}], emptyContext); + expect(result).toEqual({}); + }); + + it('maps accountID to login when login is present', () => { + const details = createPersonalDetails(1, {login: 'alice@example.com', displayName: 'Alice'}); + const personalDetailsList: PersonalDetailsList = {1: details}; + + const result = compute([personalDetailsList], emptyContext); + + expect(result['1']).toBe('alice@example.com'); + }); + + it('falls back to displayName when login is undefined', () => { + const details = createPersonalDetails(2, {login: undefined, displayName: 'Bob'}); + const personalDetailsList: PersonalDetailsList = {2: details}; + + const result = compute([personalDetailsList], emptyContext); + + expect(result['2']).toBe('Bob'); + }); + + it('falls back to empty string when both login and displayName are undefined', () => { + const details = createPersonalDetails(3, {login: undefined, displayName: undefined}); + const personalDetailsList: PersonalDetailsList = {3: details}; + + const result = compute([personalDetailsList], emptyContext); + + expect(result['3']).toBe(''); + }); + + it('prefers login over displayName', () => { + const details = createPersonalDetails(4, {login: 'charlie@example.com', displayName: 'Charlie'}); + const personalDetailsList: PersonalDetailsList = {4: details}; + + const result = compute([personalDetailsList], emptyContext); + + expect(result['4']).toBe('charlie@example.com'); + }); + + it('maps multiple accounts correctly', () => { + const personalDetailsList: PersonalDetailsList = { + 10: createPersonalDetails(10, {login: 'eve@example.com', displayName: 'Eve'}), + 11: createPersonalDetails(11, {login: undefined, displayName: 'Frank'}), + 12: createPersonalDetails(12, {login: undefined, displayName: undefined}), + }; + + const result = compute([personalDetailsList], emptyContext); + + expect(result['10']).toBe('eve@example.com'); + expect(result['11']).toBe('Frank'); + expect(result['12']).toBe(''); + }); +}); From 02eecc212b43470b31d0e6d660bc347221f826d3 Mon Sep 17 00:00:00 2001 From: VH Date: Fri, 5 Jun 2026 15:59:02 +0700 Subject: [PATCH 2/9] fix(tasks): pass accountIDToName to Parser.htmlToMarkdown in task pages Task title/description pages now pass the accountIDToNameMap derived value to htmlToMarkdown so user @mentions render as names instead of raw HTML in task fields. Co-Authored-By: Claude Sonnet 4.6 --- src/pages/tasks/NewTaskDescriptionPage.tsx | 3 ++- src/pages/tasks/NewTaskDetailsPage.tsx | 9 +++++---- src/pages/tasks/NewTaskTitlePage.tsx | 3 ++- src/pages/tasks/TaskDescriptionPage.tsx | 5 +++-- src/pages/tasks/TaskTitlePage.tsx | 3 ++- 5 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/pages/tasks/NewTaskDescriptionPage.tsx b/src/pages/tasks/NewTaskDescriptionPage.tsx index abdb9b1f106f..46d087153d0a 100644 --- a/src/pages/tasks/NewTaskDescriptionPage.tsx +++ b/src/pages/tasks/NewTaskDescriptionPage.tsx @@ -35,6 +35,7 @@ function NewTaskDescriptionPage({route}: NewTaskDescriptionPageProps) { const {translate} = useLocalize(); const [task, taskMetadata] = useOnyx(ONYXKEYS.TASK); const {inputCallbackRef, inputRef} = useAutoFocusInput(); + const [accountIDToName] = useOnyx(ONYXKEYS.DERIVED.ACCOUNT_ID_TO_NAME_MAP); const goBack = () => Navigation.goBack(ROUTES.NEW_TASK.getRoute(route.params?.backTo)); const onSubmit = (values: FormOnyxValues) => { @@ -80,7 +81,7 @@ function NewTaskDescriptionPage({route}: NewTaskDescriptionPageProps) { (); const [localDescription, setLocalDescription] = useState(); - const taskTitle = localTitle ?? Parser.htmlToMarkdown(Parser.replace(task?.title ?? '')); - const taskDescription = localDescription ?? Parser.htmlToMarkdown(Parser.replace(task?.description ?? '')); + const taskTitle = localTitle ?? Parser.htmlToMarkdown(Parser.replace(task?.title ?? ''), {accountIDToName}); + const taskDescription = localDescription ?? Parser.htmlToMarkdown(Parser.replace(task?.description ?? ''), {accountIDToName}); - const titleDefaultValue = Parser.htmlToMarkdown(Parser.replace(taskTitle)); - const descriptionDefaultValue = Parser.htmlToMarkdown(Parser.replace(taskDescription)); + const titleDefaultValue = Parser.htmlToMarkdown(Parser.replace(taskTitle), {accountIDToName}); + const descriptionDefaultValue = Parser.htmlToMarkdown(Parser.replace(taskDescription), {accountIDToName}); const {inputCallbackRef} = useAutoFocusInput(); const backTo = route.params?.backTo; diff --git a/src/pages/tasks/NewTaskTitlePage.tsx b/src/pages/tasks/NewTaskTitlePage.tsx index 028e08b669b3..5f32a63a2ced 100644 --- a/src/pages/tasks/NewTaskTitlePage.tsx +++ b/src/pages/tasks/NewTaskTitlePage.tsx @@ -34,6 +34,7 @@ function NewTaskTitlePage({route}: NewTaskTitlePageProps) { const {inputCallbackRef} = useAutoFocusInput(); const [task, taskMetadata] = useOnyx(ONYXKEYS.TASK); const {translate} = useLocalize(); + const [accountIDToName] = useOnyx(ONYXKEYS.DERIVED.ACCOUNT_ID_TO_NAME_MAP); const goBack = () => Navigation.goBack(ROUTES.NEW_TASK.getRoute(route.params?.backTo)); const validate = (values: FormOnyxValues): FormInputErrors => { @@ -87,7 +88,7 @@ function NewTaskTitlePage({route}: NewTaskTitlePageProps) { ): FormInputErrors => { @@ -54,7 +55,7 @@ function TaskDescriptionPage({report, currentUserPersonalDetails}: TaskDescripti ); const submit = (values: FormOnyxValues) => { - if (values.description !== Parser.htmlToMarkdown(report?.description ?? '') && !isEmptyObject(report)) { + if (values.description !== Parser.htmlToMarkdown(report?.description ?? '', {accountIDToName}) && !isEmptyObject(report)) { // Set the description of the report in the store and then call EditTask API // to update the description of the report on the server editTask(report, {description: values.description}, delegateEmail); @@ -120,7 +121,7 @@ function TaskDescriptionPage({report, currentUserPersonalDetails}: TaskDescripti name={INPUT_IDS.DESCRIPTION} label={translate('newTaskPage.descriptionOptional')} accessibilityLabel={translate('newTaskPage.descriptionOptional')} - defaultValue={Parser.htmlToMarkdown(report?.description ?? '')} + defaultValue={Parser.htmlToMarkdown(report?.description ?? '', {accountIDToName})} ref={(element: AnimatedTextInputRef | null) => { if (!element) { return; diff --git a/src/pages/tasks/TaskTitlePage.tsx b/src/pages/tasks/TaskTitlePage.tsx index 011057f84b47..d7377bc6fee3 100644 --- a/src/pages/tasks/TaskTitlePage.tsx +++ b/src/pages/tasks/TaskTitlePage.tsx @@ -38,6 +38,7 @@ function TaskTitlePage({report, currentUserPersonalDetails}: TaskTitlePageProps) const styles = useThemeStyles(); const {translate} = useLocalize(); const [delegateEmail] = useOnyx(ONYXKEYS.ACCOUNT, {selector: delegateEmailSelector}); + const [accountIDToName] = useOnyx(ONYXKEYS.DERIVED.ACCOUNT_ID_TO_NAME_MAP); const validate = useCallback( ({title}: FormOnyxValues): FormInputErrors => { @@ -58,7 +59,7 @@ function TaskTitlePage({report, currentUserPersonalDetails}: TaskTitlePageProps) ); const submit = (values: FormOnyxValues) => { - if (values.title !== Parser.htmlToMarkdown(report?.reportName ?? '') && !isEmptyObject(report)) { + if (values.title !== Parser.htmlToMarkdown(report?.reportName ?? '', {accountIDToName}) && !isEmptyObject(report)) { // Set the title of the report in the store and then call EditTask API // to update the title of the report on the server editTask(report, {title: values.title}, delegateEmail); From 9fe79adcd1132044887b1f58da35d56ef9894dfe Mon Sep 17 00:00:00 2001 From: VH Date: Fri, 5 Jun 2026 15:59:13 +0700 Subject: [PATCH 3/9] test(parser): add specs for htmlToMarkdown and htmlToText with accountIDToName Covers mention resolution via accountIDToName map and the @Hidden fallback when the accountID is absent. Co-Authored-By: Claude Sonnet 4.6 --- tests/unit/ParserTest.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/unit/ParserTest.ts b/tests/unit/ParserTest.ts index bac3148bf6aa..acaeb0a6db61 100644 --- a/tests/unit/ParserTest.ts +++ b/tests/unit/ParserTest.ts @@ -47,5 +47,27 @@ describe('Parser', () => { test('returns an empty string for an empty input', () => { expect(Parser.htmlToMarkdown('')).toBe(''); }); + + test('resolves mention accountID to @name via accountIDToName map', () => { + const accountIDToName: Record = {}; + accountIDToName['123'] = 'alice@example.com'; + expect(Parser.htmlToMarkdown('', {accountIDToName})).toBe('@alice@example.com'); + }); + + test('returns @Hidden for mention when accountID is missing from the map', () => { + expect(Parser.htmlToMarkdown('', {accountIDToName: {}})).toBe('@Hidden'); + }); + }); + + describe('htmlToText', () => { + test('resolves mention accountID to @name via accountIDToName map', () => { + const accountIDToName: Record = {}; + accountIDToName['456'] = 'bob@example.com'; + expect(Parser.htmlToText('', {accountIDToName})).toBe('@bob@example.com'); + }); + + test('returns @Hidden for mention when accountID is missing from the map', () => { + expect(Parser.htmlToText('', {accountIDToName: {}})).toBe('@Hidden'); + }); }); }); From fb6778307179ab40b81ab2829e7c276f7253ee36 Mon Sep 17 00:00:00 2001 From: VH Date: Fri, 5 Jun 2026 17:04:16 +0700 Subject: [PATCH 4/9] fix(test): resolve lint errors in accountIDToNameMapTest Switch to import type, and use a buildList helper to avoid numeric object literal keys that violate the naming-convention rule. --- .../OnyxDerived/accountIDToNameMapTest.ts | 40 +++++++++---------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/tests/unit/OnyxDerived/accountIDToNameMapTest.ts b/tests/unit/OnyxDerived/accountIDToNameMapTest.ts index cbd018608b78..373ba455e929 100644 --- a/tests/unit/OnyxDerived/accountIDToNameMapTest.ts +++ b/tests/unit/OnyxDerived/accountIDToNameMapTest.ts @@ -15,6 +15,14 @@ function createPersonalDetails(accountID: number, overrides: Partial): PersonalDetailsList { + const list: PersonalDetailsList = {}; + for (const [id, details] of entries) { + list[String(id)] = details; + } + return list; +} + describe('accountIDToNameMap derived value', () => { it('returns empty object when personalDetailsList is undefined', () => { const result = compute([undefined], emptyContext); @@ -28,48 +36,36 @@ describe('accountIDToNameMap derived value', () => { it('maps accountID to login when login is present', () => { const details = createPersonalDetails(1, {login: 'alice@example.com', displayName: 'Alice'}); - const personalDetailsList: PersonalDetailsList = {1: details}; - - const result = compute([personalDetailsList], emptyContext); - + const result = compute([buildList([[1, details]])], emptyContext); expect(result['1']).toBe('alice@example.com'); }); it('falls back to displayName when login is undefined', () => { const details = createPersonalDetails(2, {login: undefined, displayName: 'Bob'}); - const personalDetailsList: PersonalDetailsList = {2: details}; - - const result = compute([personalDetailsList], emptyContext); - + const result = compute([buildList([[2, details]])], emptyContext); expect(result['2']).toBe('Bob'); }); it('falls back to empty string when both login and displayName are undefined', () => { const details = createPersonalDetails(3, {login: undefined, displayName: undefined}); - const personalDetailsList: PersonalDetailsList = {3: details}; - - const result = compute([personalDetailsList], emptyContext); - + const result = compute([buildList([[3, details]])], emptyContext); expect(result['3']).toBe(''); }); it('prefers login over displayName', () => { const details = createPersonalDetails(4, {login: 'charlie@example.com', displayName: 'Charlie'}); - const personalDetailsList: PersonalDetailsList = {4: details}; - - const result = compute([personalDetailsList], emptyContext); - + const result = compute([buildList([[4, details]])], emptyContext); expect(result['4']).toBe('charlie@example.com'); }); it('maps multiple accounts correctly', () => { - const personalDetailsList: PersonalDetailsList = { - 10: createPersonalDetails(10, {login: 'eve@example.com', displayName: 'Eve'}), - 11: createPersonalDetails(11, {login: undefined, displayName: 'Frank'}), - 12: createPersonalDetails(12, {login: undefined, displayName: undefined}), - }; + const list = buildList([ + [10, createPersonalDetails(10, {login: 'eve@example.com', displayName: 'Eve'})], + [11, createPersonalDetails(11, {login: undefined, displayName: 'Frank'})], + [12, createPersonalDetails(12, {login: undefined, displayName: undefined})], + ]); - const result = compute([personalDetailsList], emptyContext); + const result = compute([list], emptyContext); expect(result['10']).toBe('eve@example.com'); expect(result['11']).toBe('Frank'); From 7b1ecd4cb3f8c9c46893cad546a5e827a9a8c2e0 Mon Sep 17 00:00:00 2001 From: VH Date: Fri, 5 Jun 2026 17:33:43 +0700 Subject: [PATCH 5/9] Fix linter --- tests/unit/OnyxDerived/accountIDToNameMapTest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/OnyxDerived/accountIDToNameMapTest.ts b/tests/unit/OnyxDerived/accountIDToNameMapTest.ts index 373ba455e929..b4fd0b771535 100644 --- a/tests/unit/OnyxDerived/accountIDToNameMapTest.ts +++ b/tests/unit/OnyxDerived/accountIDToNameMapTest.ts @@ -1,6 +1,6 @@ import accountIDToNameMapConfig from '@libs/actions/OnyxDerived/configs/accountIDToNameMap'; import type {DerivedValueContext} from '@libs/actions/OnyxDerived/types'; -import ONYXKEYS from '@src/ONYXKEYS'; +import type ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetails, PersonalDetailsList} from '@src/types/onyx'; const {compute} = accountIDToNameMapConfig; From e660b59389833fec0bddf7caea5070425bbbca70 Mon Sep 17 00:00:00 2001 From: VH Date: Fri, 5 Jun 2026 17:42:09 +0700 Subject: [PATCH 6/9] Add missing accountIDToName --- src/pages/tasks/TaskTitlePage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/tasks/TaskTitlePage.tsx b/src/pages/tasks/TaskTitlePage.tsx index d7377bc6fee3..bfc2616eb2aa 100644 --- a/src/pages/tasks/TaskTitlePage.tsx +++ b/src/pages/tasks/TaskTitlePage.tsx @@ -112,7 +112,7 @@ function TaskTitlePage({report, currentUserPersonalDetails}: TaskTitlePageProps) name={INPUT_IDS.TITLE} label={translate('task.title')} accessibilityLabel={translate('task.title')} - defaultValue={Parser.htmlToMarkdown(report?.reportName ?? '', {})} + defaultValue={Parser.htmlToMarkdown(report?.reportName ?? '', {accountIDToName})} ref={(element: AnimatedTextInputRef | null) => { if (!element) { return; From 05f2a2239a592db92fced32cced881da8abfca76 Mon Sep 17 00:00:00 2001 From: VH Date: Tue, 14 Jul 2026 16:22:27 +0700 Subject: [PATCH 7/9] Fix Oxfmt check --- src/libs/actions/OnyxDerived/configs/accountIDToNameMap.ts | 1 + tests/unit/OnyxDerived/accountIDToNameMapTest.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/libs/actions/OnyxDerived/configs/accountIDToNameMap.ts b/src/libs/actions/OnyxDerived/configs/accountIDToNameMap.ts index fd1f07dbc981..28f509a66f43 100644 --- a/src/libs/actions/OnyxDerived/configs/accountIDToNameMap.ts +++ b/src/libs/actions/OnyxDerived/configs/accountIDToNameMap.ts @@ -1,4 +1,5 @@ import createOnyxDerivedValueConfig from '@userActions/OnyxDerived/createOnyxDerivedValueConfig'; + import ONYXKEYS from '@src/ONYXKEYS'; /** diff --git a/tests/unit/OnyxDerived/accountIDToNameMapTest.ts b/tests/unit/OnyxDerived/accountIDToNameMapTest.ts index b4fd0b771535..384687887186 100644 --- a/tests/unit/OnyxDerived/accountIDToNameMapTest.ts +++ b/tests/unit/OnyxDerived/accountIDToNameMapTest.ts @@ -1,5 +1,6 @@ import accountIDToNameMapConfig from '@libs/actions/OnyxDerived/configs/accountIDToNameMap'; import type {DerivedValueContext} from '@libs/actions/OnyxDerived/types'; + import type ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetails, PersonalDetailsList} from '@src/types/onyx'; From ff71cbadfad242dca45cc7173d30971d23e8f6b8 Mon Sep 17 00:00:00 2001 From: VH Date: Thu, 6 Aug 2026 21:26:46 +0700 Subject: [PATCH 8/9] refactor(tasks): use context provider for accountID-to-name map Replace the ONYXKEYS.DERIVED.ACCOUNT_ID_TO_NAME_MAP derived value (added earlier on this branch) with a React context provider, following the review direction that new Onyx derived values are being deprecated. Derived values also can't hand consumers a stable reference: setDerivedValue writes with `skipCacheCheck: true`, so every PERSONAL_DETAILS_LIST write re-broadcasts a fresh map. The provider computes the map in a useMemo instead. - Add src/hooks/useAccountIDToNameMap.tsx and register the provider in AuthScreens ComposeProviders - Point the five Task pages at the new hook - Remove the DERIVED.ACCOUNT_ID_TO_NAME_MAP key, config, and type - Replace the derived-value unit test with a provider/hook test --- src/ONYXKEYS.ts | 2 - src/hooks/useAccountIDToNameMap.tsx | 46 +++++++ .../Navigation/AppNavigator/AuthScreens.tsx | 2 + .../OnyxDerived/ONYX_DERIVED_VALUES.ts | 2 - .../OnyxDerived/configs/accountIDToNameMap.ts | 24 ---- .../tasks/DynamicNewTaskDescriptionPage.tsx | 3 +- src/pages/tasks/DynamicNewTaskDetailsPage.tsx | 3 +- src/pages/tasks/DynamicNewTaskTitlePage.tsx | 3 +- src/pages/tasks/TaskDescriptionPage.tsx | 3 +- src/pages/tasks/TaskTitlePage.tsx | 3 +- src/types/onyx/DerivedValues.ts | 6 - src/types/onyx/index.ts | 2 - .../OnyxDerived/accountIDToNameMapTest.ts | 75 ------------ tests/unit/useAccountIDToNameMapTest.tsx | 113 ++++++++++++++++++ 14 files changed, 171 insertions(+), 116 deletions(-) create mode 100644 src/hooks/useAccountIDToNameMap.tsx delete mode 100644 src/libs/actions/OnyxDerived/configs/accountIDToNameMap.ts delete mode 100644 tests/unit/OnyxDerived/accountIDToNameMapTest.ts create mode 100644 tests/unit/useAccountIDToNameMapTest.tsx diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 15a717c8ea80..62f3d937a907 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -1296,7 +1296,6 @@ const ONYXKEYS = { PERSONAL_AND_WORKSPACE_CARD_LIST: 'personalAndWorkspaceCardList', CARD_FEED_ERRORS: 'cardFeedErrors', RAM_ONLY_SORTED_REPORT_ACTIONS: 'sortedReportActions', - ACCOUNT_ID_TO_NAME_MAP: 'accountIDToNameMap', LOGIN_TO_ACCOUNT_ID_MAP: 'loginToAccountIDMap', }, @@ -1818,7 +1817,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.ACCOUNT_ID_TO_NAME_MAP]: OnyxTypes.AccountIDToNameMapDerivedValue; [ONYXKEYS.DERIVED.LOGIN_TO_ACCOUNT_ID_MAP]: OnyxTypes.LoginToAccountIDMapDerivedValue; }; diff --git a/src/hooks/useAccountIDToNameMap.tsx b/src/hooks/useAccountIDToNameMap.tsx new file mode 100644 index 000000000000..77a0312f8837 --- /dev/null +++ b/src/hooks/useAccountIDToNameMap.tsx @@ -0,0 +1,46 @@ +import ONYXKEYS from '@src/ONYXKEYS'; + +import React, {createContext, useContext, useMemo} from 'react'; + +import useOnyx from './useOnyx'; + +/** Mapping from accountID to user name (login or displayName). */ +type AccountIDToNameMap = Record; + +type AccountIDToNameMapContextProviderProps = { + children: React.ReactNode; +}; + +const AccountIDToNameMapContext = createContext({}); + +/** + * Provides an accountID -> name (login or displayName) map built from PERSONAL_DETAILS_LIST. + * + * The map is computed in a `useMemo` keyed on PERSONAL_DETAILS_LIST, so consumers keep the same reference + * across re-renders that don't change personal details. + */ +function AccountIDToNameMapContextProvider({children}: AccountIDToNameMapContextProviderProps) { + const [personalDetailsList] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); + + const accountIDToNameMap = useMemo(() => { + const map: AccountIDToNameMap = {}; + + for (const personalDetails of Object.values(personalDetailsList ?? {})) { + if (!personalDetails) { + continue; + } + + map[personalDetails.accountID] = personalDetails.login ?? personalDetails.displayName ?? ''; + } + + return map; + }, [personalDetailsList]); + + return {children}; +} + +function useAccountIDToNameMap() { + return useContext(AccountIDToNameMapContext); +} + +export {AccountIDToNameMapContextProvider, useAccountIDToNameMap}; diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index 73850ecbcb43..dde6d80669bb 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -19,6 +19,7 @@ import {VideoPopoverMenuContextProvider} from '@components/VideoPlayerContexts/V import {VolumeContextProvider} from '@components/VideoPlayerContexts/VolumeContext'; import WideRHPContextProvider from '@components/WideRHPContextProvider'; +import {AccountIDToNameMapContextProvider} from '@hooks/useAccountIDToNameMap'; import useOnboardingFlowRouter from '@hooks/useOnboardingFlow'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useShouldSuppressPromotionalUI from '@hooks/useShouldSuppressPromotionalUI'; @@ -181,6 +182,7 @@ function AuthScreens() { WideRHPContextProvider, KeyboardDismissibleFlatListContextProvider, SidebarOrderedReportsContextProvider, + AccountIDToNameMapContextProvider, SearchContextProvider, LockedAccountModalProvider, DelegateNoAccessModalProvider, diff --git a/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts b/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts index 81b8ee2cb501..d01744852e4f 100644 --- a/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts +++ b/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts @@ -4,7 +4,6 @@ import type {ValueOf} from 'type-fest'; import type {OnyxDerivedValueConfig} from './types'; -import accountIDToNameMapConfig from './configs/accountIDToNameMap'; import cardFeedErrorsConfig from './configs/cardFeedErrors'; import loginToAccountIDMapConfig from './configs/loginToAccountIDMap'; import nonPersonalAndWorkspaceCardListConfig from './configs/nonPersonalAndWorkspaceCardList'; @@ -28,7 +27,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.ACCOUNT_ID_TO_NAME_MAP]: accountIDToNameMapConfig, [ONYXKEYS.DERIVED.LOGIN_TO_ACCOUNT_ID_MAP]: loginToAccountIDMapConfig, } as const satisfies { // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/libs/actions/OnyxDerived/configs/accountIDToNameMap.ts b/src/libs/actions/OnyxDerived/configs/accountIDToNameMap.ts deleted file mode 100644 index 28f509a66f43..000000000000 --- a/src/libs/actions/OnyxDerived/configs/accountIDToNameMap.ts +++ /dev/null @@ -1,24 +0,0 @@ -import createOnyxDerivedValueConfig from '@userActions/OnyxDerived/createOnyxDerivedValueConfig'; - -import ONYXKEYS from '@src/ONYXKEYS'; - -/** - * Builds a mapping from accountID to user name (login or displayName). - */ -export default createOnyxDerivedValueConfig({ - key: ONYXKEYS.DERIVED.ACCOUNT_ID_TO_NAME_MAP, - dependencies: [ONYXKEYS.PERSONAL_DETAILS_LIST], - compute: ([personalDetailsList]) => { - const accountIDToNameMap: Record = {}; - - for (const personalDetails of Object.values(personalDetailsList ?? {})) { - if (!personalDetails) { - continue; - } - - accountIDToNameMap[personalDetails.accountID] = personalDetails.login ?? personalDetails.displayName ?? ''; - } - - return accountIDToNameMap; - }, -}); diff --git a/src/pages/tasks/DynamicNewTaskDescriptionPage.tsx b/src/pages/tasks/DynamicNewTaskDescriptionPage.tsx index 4b79aa160b81..e24b7c687027 100644 --- a/src/pages/tasks/DynamicNewTaskDescriptionPage.tsx +++ b/src/pages/tasks/DynamicNewTaskDescriptionPage.tsx @@ -6,6 +6,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import TextInput from '@components/TextInput'; +import {useAccountIDToNameMap} from '@hooks/useAccountIDToNameMap'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; @@ -37,7 +38,7 @@ function DynamicNewTaskDescriptionPage() { const {translate} = useLocalize(); const [task, taskMetadata] = useOnyx(ONYXKEYS.TASK); const {inputCallbackRef, inputRef} = useAutoFocusInput(); - const [accountIDToName] = useOnyx(ONYXKEYS.DERIVED.ACCOUNT_ID_TO_NAME_MAP); + const accountIDToName = useAccountIDToNameMap(); const backPath = useDynamicBackPath(DYNAMIC_ROUTES.NEW_TASK_DESCRIPTION.path); const goBack = () => Navigation.goBack(backPath); diff --git a/src/pages/tasks/DynamicNewTaskDetailsPage.tsx b/src/pages/tasks/DynamicNewTaskDetailsPage.tsx index 5ea20c67fabc..974ebda2f469 100644 --- a/src/pages/tasks/DynamicNewTaskDetailsPage.tsx +++ b/src/pages/tasks/DynamicNewTaskDetailsPage.tsx @@ -5,6 +5,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import TextInput from '@components/TextInput'; +import {useAccountIDToNameMap} from '@hooks/useAccountIDToNameMap'; import useAncestors from '@hooks/useAncestors'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; @@ -43,7 +44,7 @@ function DynamicNewTaskDetailsPage() { }); const styles = useThemeStyles(); const {translate} = useLocalize(); - const [accountIDToName] = useOnyx(ONYXKEYS.DERIVED.ACCOUNT_ID_TO_NAME_MAP); + const accountIDToName = useAccountIDToNameMap(); const [localTitle, setLocalTitle] = useState(); const [localDescription, setLocalDescription] = useState(); const taskTitle = localTitle ?? Parser.htmlToMarkdown(Parser.replace(task?.title ?? ''), {accountIDToName}); diff --git a/src/pages/tasks/DynamicNewTaskTitlePage.tsx b/src/pages/tasks/DynamicNewTaskTitlePage.tsx index 7a82f3c6f6f1..01740b9b43a7 100644 --- a/src/pages/tasks/DynamicNewTaskTitlePage.tsx +++ b/src/pages/tasks/DynamicNewTaskTitlePage.tsx @@ -6,6 +6,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import TextInput from '@components/TextInput'; +import {useAccountIDToNameMap} from '@hooks/useAccountIDToNameMap'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; @@ -36,7 +37,7 @@ function DynamicNewTaskTitlePage() { const {inputCallbackRef} = useAutoFocusInput(); const [task, taskMetadata] = useOnyx(ONYXKEYS.TASK); const {translate} = useLocalize(); - const [accountIDToName] = useOnyx(ONYXKEYS.DERIVED.ACCOUNT_ID_TO_NAME_MAP); + const accountIDToName = useAccountIDToNameMap(); const backPath = useDynamicBackPath(DYNAMIC_ROUTES.NEW_TASK_TITLE.path); const goBack = () => Navigation.goBack(backPath); diff --git a/src/pages/tasks/TaskDescriptionPage.tsx b/src/pages/tasks/TaskDescriptionPage.tsx index 519742ec73b7..a0479266ed83 100644 --- a/src/pages/tasks/TaskDescriptionPage.tsx +++ b/src/pages/tasks/TaskDescriptionPage.tsx @@ -9,6 +9,7 @@ import TextInput from '@components/TextInput'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; +import {useAccountIDToNameMap} from '@hooks/useAccountIDToNameMap'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -46,7 +47,7 @@ function TaskDescriptionPage({report, currentUserPersonalDetails}: TaskDescripti const styles = useThemeStyles(); const {translate} = useLocalize(); const [delegateEmail] = useOnyx(ONYXKEYS.ACCOUNT, {selector: delegateEmailSelector}); - const [accountIDToName] = useOnyx(ONYXKEYS.DERIVED.ACCOUNT_ID_TO_NAME_MAP); + const accountIDToName = useAccountIDToNameMap(); const validate = useCallback( (values: FormOnyxValues): FormInputErrors => { diff --git a/src/pages/tasks/TaskTitlePage.tsx b/src/pages/tasks/TaskTitlePage.tsx index 502c2aa91145..92bb91c347a7 100644 --- a/src/pages/tasks/TaskTitlePage.tsx +++ b/src/pages/tasks/TaskTitlePage.tsx @@ -9,6 +9,7 @@ import TextInput from '@components/TextInput'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; +import {useAccountIDToNameMap} from '@hooks/useAccountIDToNameMap'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -45,7 +46,7 @@ function TaskTitlePage({report, currentUserPersonalDetails}: TaskTitlePageProps) const styles = useThemeStyles(); const {translate} = useLocalize(); const [delegateEmail] = useOnyx(ONYXKEYS.ACCOUNT, {selector: delegateEmailSelector}); - const [accountIDToName] = useOnyx(ONYXKEYS.DERIVED.ACCOUNT_ID_TO_NAME_MAP); + const accountIDToName = useAccountIDToNameMap(); const validate = useCallback( ({title}: FormOnyxValues): FormInputErrors => { diff --git a/src/types/onyx/DerivedValues.ts b/src/types/onyx/DerivedValues.ts index e28e025c8255..4223a0ed8183 100644 --- a/src/types/onyx/DerivedValues.ts +++ b/src/types/onyx/DerivedValues.ts @@ -248,11 +248,6 @@ type SortedReportActionsDerivedValue = { */ type PersonalAndWorkspaceCardListDerivedValue = CardList; -/** - * Mapping from accountID to user name (login or displayName). - */ -type AccountIDToNameMapDerivedValue = Record; - /** * The derived value mapping each user's login (lowercased) to their accountID. * @@ -277,5 +272,4 @@ export type { CardFeedErrorState, CardFeedErrors, CardErrors, - AccountIDToNameMapDerivedValue, }; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index e7eeb6342960..a26297812c3f 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -55,7 +55,6 @@ import type Currency from './Currency'; import type {CurrencyList} from './Currency'; import type CustomStatusDraft from './CustomStatusDraft'; import type { - AccountIDToNameMapDerivedValue, CardFeedErrorsDerivedValue, LoginToAccountIDMapDerivedValue, NonPersonalAndWorkspaceCardListDerivedValue, @@ -414,7 +413,6 @@ export type { ReportTransactionsAndViolationsDerivedValue, OutstandingReportsByPolicyIDDerivedValue, VisibleReportActionsDerivedValue, - AccountIDToNameMapDerivedValue, SortedReportActionsDerivedValue, NonPersonalAndWorkspaceCardListDerivedValue, PersonalAndWorkspaceCardListDerivedValue, diff --git a/tests/unit/OnyxDerived/accountIDToNameMapTest.ts b/tests/unit/OnyxDerived/accountIDToNameMapTest.ts deleted file mode 100644 index 384687887186..000000000000 --- a/tests/unit/OnyxDerived/accountIDToNameMapTest.ts +++ /dev/null @@ -1,75 +0,0 @@ -import accountIDToNameMapConfig from '@libs/actions/OnyxDerived/configs/accountIDToNameMap'; -import type {DerivedValueContext} from '@libs/actions/OnyxDerived/types'; - -import type ONYXKEYS from '@src/ONYXKEYS'; -import type {PersonalDetails, PersonalDetailsList} from '@src/types/onyx'; - -const {compute} = accountIDToNameMapConfig; -const emptyContext = {} as DerivedValueContext; - -function createPersonalDetails(accountID: number, overrides: Partial = {}): PersonalDetails { - return { - accountID, - login: `user${accountID}@example.com`, - displayName: `User ${accountID}`, - ...overrides, - } as PersonalDetails; -} - -function buildList(entries: Array<[number, PersonalDetails | null]>): PersonalDetailsList { - const list: PersonalDetailsList = {}; - for (const [id, details] of entries) { - list[String(id)] = details; - } - return list; -} - -describe('accountIDToNameMap derived value', () => { - it('returns empty object when personalDetailsList is undefined', () => { - const result = compute([undefined], emptyContext); - expect(result).toEqual({}); - }); - - it('returns empty object when personalDetailsList is empty', () => { - const result = compute([{}], emptyContext); - expect(result).toEqual({}); - }); - - it('maps accountID to login when login is present', () => { - const details = createPersonalDetails(1, {login: 'alice@example.com', displayName: 'Alice'}); - const result = compute([buildList([[1, details]])], emptyContext); - expect(result['1']).toBe('alice@example.com'); - }); - - it('falls back to displayName when login is undefined', () => { - const details = createPersonalDetails(2, {login: undefined, displayName: 'Bob'}); - const result = compute([buildList([[2, details]])], emptyContext); - expect(result['2']).toBe('Bob'); - }); - - it('falls back to empty string when both login and displayName are undefined', () => { - const details = createPersonalDetails(3, {login: undefined, displayName: undefined}); - const result = compute([buildList([[3, details]])], emptyContext); - expect(result['3']).toBe(''); - }); - - it('prefers login over displayName', () => { - const details = createPersonalDetails(4, {login: 'charlie@example.com', displayName: 'Charlie'}); - const result = compute([buildList([[4, details]])], emptyContext); - expect(result['4']).toBe('charlie@example.com'); - }); - - it('maps multiple accounts correctly', () => { - const list = buildList([ - [10, createPersonalDetails(10, {login: 'eve@example.com', displayName: 'Eve'})], - [11, createPersonalDetails(11, {login: undefined, displayName: 'Frank'})], - [12, createPersonalDetails(12, {login: undefined, displayName: undefined})], - ]); - - const result = compute([list], emptyContext); - - expect(result['10']).toBe('eve@example.com'); - expect(result['11']).toBe('Frank'); - expect(result['12']).toBe(''); - }); -}); diff --git a/tests/unit/useAccountIDToNameMapTest.tsx b/tests/unit/useAccountIDToNameMapTest.tsx new file mode 100644 index 000000000000..06b0076f4f80 --- /dev/null +++ b/tests/unit/useAccountIDToNameMapTest.tsx @@ -0,0 +1,113 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import {AccountIDToNameMapContextProvider, useAccountIDToNameMap} from '@hooks/useAccountIDToNameMap'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import type {PersonalDetails, PersonalDetailsList} from '@src/types/onyx'; + +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +function createPersonalDetails(accountID: number, overrides: Partial = {}): PersonalDetails { + return { + accountID, + login: `user${accountID}@example.com`, + displayName: `User ${accountID}`, + ...overrides, + } as PersonalDetails; +} + +function buildList(entries: Array<[number, PersonalDetails | null]>): PersonalDetailsList { + const list: PersonalDetailsList = {}; + for (const [id, details] of entries) { + list[String(id)] = details; + } + return list; +} + +const renderAccountIDToNameMap = async () => { + const hook = renderHook(() => useAccountIDToNameMap(), {wrapper: AccountIDToNameMapContextProvider}); + await act(async () => { + await waitForBatchedUpdates(); + }); + return hook; +}; + +describe('useAccountIDToNameMap', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + it('returns an empty object when personalDetailsList is not set', async () => { + const {result} = await renderAccountIDToNameMap(); + expect(result.current).toEqual({}); + }); + + it('maps accountID to login when login is present', async () => { + await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, buildList([[1, createPersonalDetails(1, {login: 'alice@example.com', displayName: 'Alice'})]])); + const {result} = await renderAccountIDToNameMap(); + expect(result.current['1']).toBe('alice@example.com'); + }); + + it('falls back to displayName when login is undefined', async () => { + await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, buildList([[2, createPersonalDetails(2, {login: undefined, displayName: 'Bob'})]])); + const {result} = await renderAccountIDToNameMap(); + expect(result.current['2']).toBe('Bob'); + }); + + it('falls back to an empty string when both login and displayName are undefined', async () => { + await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, buildList([[3, createPersonalDetails(3, {login: undefined, displayName: undefined})]])); + const {result} = await renderAccountIDToNameMap(); + expect(result.current['3']).toBe(''); + }); + + it('maps multiple accounts and skips null entries', async () => { + await Onyx.set( + ONYXKEYS.PERSONAL_DETAILS_LIST, + buildList([ + [10, createPersonalDetails(10, {login: 'eve@example.com', displayName: 'Eve'})], + [11, createPersonalDetails(11, {login: undefined, displayName: 'Frank'})], + [12, null], + ]), + ); + + const {result} = await renderAccountIDToNameMap(); + + expect(result.current['10']).toBe('eve@example.com'); + expect(result.current['11']).toBe('Frank'); + expect(result.current['12']).toBeUndefined(); + }); + + it('reflects updates to personal details', async () => { + await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, buildList([[1, createPersonalDetails(1, {login: 'alice@example.com'})]])); + const {result} = await renderAccountIDToNameMap(); + expect(result.current['1']).toBe('alice@example.com'); + + const accountID = 1; + await act(async () => { + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {[accountID]: {login: 'alice2@example.com'}}); + await waitForBatchedUpdates(); + }); + + expect(result.current['1']).toBe('alice2@example.com'); + }); + + it('keeps a stable reference across re-renders when personal details do not change', async () => { + await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, buildList([[1, createPersonalDetails(1, {login: 'alice@example.com'})]])); + const {result, rerender} = await renderAccountIDToNameMap(); + const firstResult = result.current; + + rerender({}); + await act(async () => { + await waitForBatchedUpdates(); + }); + + expect(result.current).toBe(firstResult); + }); +}); From 8c89a838ac17eb3299f55f1a8acf63cb29db3f35 Mon Sep 17 00:00:00 2001 From: VH Date: Thu, 13 Aug 2026 15:44:09 +0700 Subject: [PATCH 9/9] refactor(tasks): use useMemo hook instead of context provider for accountID-to-name map Switch AccountIDToNameMapContextProvider to a plain useOnyx + useMemo hook. Benchmarking showed neither the provider nor a useOnyx selector is worth it: - a selector returning this same-size map runs deepEqual over the whole map on every PERSONAL_DETAILS_LIST write (~2.2ms at 20k PDs) and violates PERF-11 - the context provider re-renders consumers on every PD write anyway - same as the plain hook - while adding an always-mounted global computation The hook reads PERSONAL_DETAILS_LIST and reshapes it in a useMemo (no selector). - Replace src/hooks/useAccountIDToNameMap.tsx (provider) with .ts (hook) - Remove the provider from AuthScreens ComposeProviders - Switch the five Task pages to the default-exported hook - Drop the provider wrapper from the hook unit test --- src/hooks/useAccountIDToNameMap.ts | 29 ++++++++++++ src/hooks/useAccountIDToNameMap.tsx | 46 ------------------- .../Navigation/AppNavigator/AuthScreens.tsx | 2 - .../tasks/DynamicNewTaskDescriptionPage.tsx | 2 +- src/pages/tasks/DynamicNewTaskDetailsPage.tsx | 2 +- src/pages/tasks/DynamicNewTaskTitlePage.tsx | 2 +- src/pages/tasks/TaskDescriptionPage.tsx | 2 +- src/pages/tasks/TaskTitlePage.tsx | 2 +- tests/unit/useAccountIDToNameMapTest.tsx | 4 +- 9 files changed, 36 insertions(+), 55 deletions(-) create mode 100644 src/hooks/useAccountIDToNameMap.ts delete mode 100644 src/hooks/useAccountIDToNameMap.tsx diff --git a/src/hooks/useAccountIDToNameMap.ts b/src/hooks/useAccountIDToNameMap.ts new file mode 100644 index 000000000000..c4b29604873d --- /dev/null +++ b/src/hooks/useAccountIDToNameMap.ts @@ -0,0 +1,29 @@ +import ONYXKEYS from '@src/ONYXKEYS'; +import type {PersonalDetailsList} from '@src/types/onyx'; + +import {useMemo} from 'react'; + +import useOnyx from './useOnyx'; + +/** Mapping from accountID to user name (login or displayName). */ +type AccountIDToNameMap = Record; + +/** Build the accountID -> name map from PERSONAL_DETAILS_LIST. */ +function buildAccountIDToNameMap(personalDetailsList: PersonalDetailsList | undefined): AccountIDToNameMap { + const map: AccountIDToNameMap = {}; + for (const personalDetails of Object.values(personalDetailsList ?? {})) { + if (!personalDetails) { + continue; + } + map[personalDetails.accountID] = personalDetails.login ?? personalDetails.displayName ?? ''; + } + return map; +} + +/** Returns an accountID -> name (login or displayName) map built from PERSONAL_DETAILS_LIST. */ +function useAccountIDToNameMap(): AccountIDToNameMap { + const [personalDetailsList] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); + return useMemo(() => buildAccountIDToNameMap(personalDetailsList), [personalDetailsList]); +} + +export default useAccountIDToNameMap; diff --git a/src/hooks/useAccountIDToNameMap.tsx b/src/hooks/useAccountIDToNameMap.tsx deleted file mode 100644 index 77a0312f8837..000000000000 --- a/src/hooks/useAccountIDToNameMap.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import ONYXKEYS from '@src/ONYXKEYS'; - -import React, {createContext, useContext, useMemo} from 'react'; - -import useOnyx from './useOnyx'; - -/** Mapping from accountID to user name (login or displayName). */ -type AccountIDToNameMap = Record; - -type AccountIDToNameMapContextProviderProps = { - children: React.ReactNode; -}; - -const AccountIDToNameMapContext = createContext({}); - -/** - * Provides an accountID -> name (login or displayName) map built from PERSONAL_DETAILS_LIST. - * - * The map is computed in a `useMemo` keyed on PERSONAL_DETAILS_LIST, so consumers keep the same reference - * across re-renders that don't change personal details. - */ -function AccountIDToNameMapContextProvider({children}: AccountIDToNameMapContextProviderProps) { - const [personalDetailsList] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); - - const accountIDToNameMap = useMemo(() => { - const map: AccountIDToNameMap = {}; - - for (const personalDetails of Object.values(personalDetailsList ?? {})) { - if (!personalDetails) { - continue; - } - - map[personalDetails.accountID] = personalDetails.login ?? personalDetails.displayName ?? ''; - } - - return map; - }, [personalDetailsList]); - - return {children}; -} - -function useAccountIDToNameMap() { - return useContext(AccountIDToNameMapContext); -} - -export {AccountIDToNameMapContextProvider, useAccountIDToNameMap}; diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index dde6d80669bb..73850ecbcb43 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -19,7 +19,6 @@ import {VideoPopoverMenuContextProvider} from '@components/VideoPlayerContexts/V import {VolumeContextProvider} from '@components/VideoPlayerContexts/VolumeContext'; import WideRHPContextProvider from '@components/WideRHPContextProvider'; -import {AccountIDToNameMapContextProvider} from '@hooks/useAccountIDToNameMap'; import useOnboardingFlowRouter from '@hooks/useOnboardingFlow'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useShouldSuppressPromotionalUI from '@hooks/useShouldSuppressPromotionalUI'; @@ -182,7 +181,6 @@ function AuthScreens() { WideRHPContextProvider, KeyboardDismissibleFlatListContextProvider, SidebarOrderedReportsContextProvider, - AccountIDToNameMapContextProvider, SearchContextProvider, LockedAccountModalProvider, DelegateNoAccessModalProvider, diff --git a/src/pages/tasks/DynamicNewTaskDescriptionPage.tsx b/src/pages/tasks/DynamicNewTaskDescriptionPage.tsx index e24b7c687027..9f221ad24eaf 100644 --- a/src/pages/tasks/DynamicNewTaskDescriptionPage.tsx +++ b/src/pages/tasks/DynamicNewTaskDescriptionPage.tsx @@ -6,7 +6,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import TextInput from '@components/TextInput'; -import {useAccountIDToNameMap} from '@hooks/useAccountIDToNameMap'; +import useAccountIDToNameMap from '@hooks/useAccountIDToNameMap'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; diff --git a/src/pages/tasks/DynamicNewTaskDetailsPage.tsx b/src/pages/tasks/DynamicNewTaskDetailsPage.tsx index 974ebda2f469..76fbaa96c5aa 100644 --- a/src/pages/tasks/DynamicNewTaskDetailsPage.tsx +++ b/src/pages/tasks/DynamicNewTaskDetailsPage.tsx @@ -5,7 +5,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import TextInput from '@components/TextInput'; -import {useAccountIDToNameMap} from '@hooks/useAccountIDToNameMap'; +import useAccountIDToNameMap from '@hooks/useAccountIDToNameMap'; import useAncestors from '@hooks/useAncestors'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; diff --git a/src/pages/tasks/DynamicNewTaskTitlePage.tsx b/src/pages/tasks/DynamicNewTaskTitlePage.tsx index 01740b9b43a7..5371c371e239 100644 --- a/src/pages/tasks/DynamicNewTaskTitlePage.tsx +++ b/src/pages/tasks/DynamicNewTaskTitlePage.tsx @@ -6,7 +6,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import TextInput from '@components/TextInput'; -import {useAccountIDToNameMap} from '@hooks/useAccountIDToNameMap'; +import useAccountIDToNameMap from '@hooks/useAccountIDToNameMap'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; diff --git a/src/pages/tasks/TaskDescriptionPage.tsx b/src/pages/tasks/TaskDescriptionPage.tsx index a0479266ed83..902694aae00a 100644 --- a/src/pages/tasks/TaskDescriptionPage.tsx +++ b/src/pages/tasks/TaskDescriptionPage.tsx @@ -9,7 +9,7 @@ import TextInput from '@components/TextInput'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; -import {useAccountIDToNameMap} from '@hooks/useAccountIDToNameMap'; +import useAccountIDToNameMap from '@hooks/useAccountIDToNameMap'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; diff --git a/src/pages/tasks/TaskTitlePage.tsx b/src/pages/tasks/TaskTitlePage.tsx index 92bb91c347a7..9dcef4c080c8 100644 --- a/src/pages/tasks/TaskTitlePage.tsx +++ b/src/pages/tasks/TaskTitlePage.tsx @@ -9,7 +9,7 @@ import TextInput from '@components/TextInput'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; -import {useAccountIDToNameMap} from '@hooks/useAccountIDToNameMap'; +import useAccountIDToNameMap from '@hooks/useAccountIDToNameMap'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; diff --git a/tests/unit/useAccountIDToNameMapTest.tsx b/tests/unit/useAccountIDToNameMapTest.tsx index 06b0076f4f80..17ec926b35ff 100644 --- a/tests/unit/useAccountIDToNameMapTest.tsx +++ b/tests/unit/useAccountIDToNameMapTest.tsx @@ -1,6 +1,6 @@ import {act, renderHook} from '@testing-library/react-native'; -import {AccountIDToNameMapContextProvider, useAccountIDToNameMap} from '@hooks/useAccountIDToNameMap'; +import useAccountIDToNameMap from '@hooks/useAccountIDToNameMap'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetails, PersonalDetailsList} from '@src/types/onyx'; @@ -27,7 +27,7 @@ function buildList(entries: Array<[number, PersonalDetails | null]>): PersonalDe } const renderAccountIDToNameMap = async () => { - const hook = renderHook(() => useAccountIDToNameMap(), {wrapper: AccountIDToNameMapContextProvider}); + const hook = renderHook(() => useAccountIDToNameMap()); await act(async () => { await waitForBatchedUpdates(); });