From df3cc405b531f6ee79b5439f075b3131e3ec6858 Mon Sep 17 00:00:00 2001 From: Tomasz Lesniakiewicz Date: Fri, 3 Apr 2026 14:34:58 +0200 Subject: [PATCH 1/2] perf: add selectors to policy/derived hooks to prevent unnecessary re-renders --- src/hooks/useDefaultExpensePolicy.tsx | 52 +++++++++++----- src/hooks/usePolicyForMovingExpenses.ts | 83 ++++++++++++++++++------- src/hooks/useReportAttributes.ts | 14 +++++ src/hooks/useReportIsArchived.ts | 9 ++- 4 files changed, 115 insertions(+), 43 deletions(-) diff --git a/src/hooks/useDefaultExpensePolicy.tsx b/src/hooks/useDefaultExpensePolicy.tsx index 60854211b2e4..32630fafff88 100644 --- a/src/hooks/useDefaultExpensePolicy.tsx +++ b/src/hooks/useDefaultExpensePolicy.tsx @@ -1,17 +1,50 @@ +import type {OnyxCollection} from 'react-native-onyx'; import {isPaidGroupPolicy, isPolicyAccessible} from '@libs/PolicyUtils'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy} from '@src/types/onyx'; import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; import useOnyx from './useOnyx'; import usePreferredPolicy from './usePreferredPolicy'; +/** + * Selector that finds the single qualifying group policy ID from the collection. + * Returns only an ID (stable) — prevents re-renders when unrelated policies change. + */ +function getSingleGroupPolicyID(policies: OnyxCollection, login: string): string | undefined { + if (!policies) { + return undefined; + } + + let singlePolicyID: string | undefined; + for (const policy of Object.values(policies)) { + if (!policy || !isPaidGroupPolicy(policy) || !isPolicyAccessible(policy, login)) { + continue; + } + if (!singlePolicyID) { + singlePolicyID = policy.id; + } else { + return undefined; // More than one — no single default + } + } + + return singlePolicyID; +} + export default function useDefaultExpensePolicy() { const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); const [activePolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`); - const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const {isRestrictedToPreferredPolicy, preferredPolicyID} = usePreferredPolicy(); const {login = ''} = useCurrentUserPersonalDetails(); const [preferredPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${preferredPolicyID}`); + // Selector returns only the qualifying policy ID — stable value, prevents re-renders + const [singleGroupPolicyID] = useOnyx(ONYXKEYS.COLLECTION.POLICY, { + selector: (policies) => getSingleGroupPolicyID(policies, login), + }); + + // Per-key lookup for the single group policy (only fires when that specific policy changes) + const [singleGroupPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${singleGroupPolicyID}`); + if (isRestrictedToPreferredPolicy && isPaidGroupPolicy(preferredPolicy) && isPolicyAccessible(preferredPolicy, login)) { return preferredPolicy; } @@ -20,20 +53,5 @@ export default function useDefaultExpensePolicy() { return activePolicy; } - // If there is exactly one group policy, use that as the default expense policy - let singlePolicy; - for (const policy of Object.values(allPolicies ?? {})) { - if (!policy || !isPaidGroupPolicy(policy) || !isPolicyAccessible(policy, login)) { - continue; - } - - if (!singlePolicy) { - singlePolicy = policy; - } else { - singlePolicy = undefined; - break; - } - } - - return singlePolicy; + return singleGroupPolicy; } diff --git a/src/hooks/usePolicyForMovingExpenses.ts b/src/hooks/usePolicyForMovingExpenses.ts index 712e689f56b9..b3f88e88a2ac 100644 --- a/src/hooks/usePolicyForMovingExpenses.ts +++ b/src/hooks/usePolicyForMovingExpenses.ts @@ -1,5 +1,5 @@ import {activePolicySelector} from '@selectors/Policy'; -import type {OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {useSession} from '@components/OnyxListItemProvider'; import {canSubmitPerDiemExpenseFromWorkspace, isPaidGroupPolicy, isPolicyMemberWithoutPendingDelete, isTimeTrackingEnabled} from '@libs/PolicyUtils'; import CONST from '@src/CONST'; @@ -31,48 +31,85 @@ function isPolicyValidForMovingExpenses(policy: OnyxEntry, login: string ); } -function usePolicyForMovingExpenses(isPerDiemRequest?: boolean, isTimeRequest?: boolean, expensePolicyID?: string) { - const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); - const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); - const [activePolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`, { - selector: activePolicySelector, - }); +type PolicyQualificationResult = { + singlePolicyID: string | undefined; + isMemberOfMoreThanOnePolicy: boolean; + validExpensePolicyID: string | undefined; +}; - const session = useSession(); - const login = session?.email ?? ''; +/** + * Selector that computes which policies qualify for moving expenses. + * Returns only IDs and flags — stable output that prevents re-renders when unrelated policies change. + */ +function getPolicyQualificationResult( + policies: OnyxCollection, + login: string, + isPerDiemRequest?: boolean, + isTimeRequest?: boolean, + expensePolicyID?: string, +): PolicyQualificationResult { + if (!policies) { + return {singlePolicyID: undefined, isMemberOfMoreThanOnePolicy: false, validExpensePolicyID: undefined}; + } - // Early exit optimization: only need to check if we have 0, 1, or >1 policies - let singleUserPolicy; + let singlePolicyID: string | undefined; let isMemberOfMoreThanOnePolicy = false; - for (const policy of Object.values(allPolicies ?? {})) { + for (const policy of Object.values(policies)) { if (!isPolicyValidForMovingExpenses(policy, login, isPerDiemRequest, isTimeRequest)) { continue; } - - if (!singleUserPolicy) { - singleUserPolicy = policy; + if (!singlePolicyID) { + singlePolicyID = policy?.id; } else { isMemberOfMoreThanOnePolicy = true; - break; // Found 2, no need to continue + break; } } - // If an expense policy ID is provided and valid, prefer it over the active policy - // This ensures that when viewing/editing an expense from workspace B, we show workspace B - // even if the user's default workspace is A + let validExpensePolicyID: string | undefined; if (expensePolicyID) { - const expensePolicy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${expensePolicyID}`]; + const expensePolicy = policies[`${ONYXKEYS.COLLECTION.POLICY}${expensePolicyID}`]; if (expensePolicy && isPolicyValidForMovingExpenses(expensePolicy, login, isPerDiemRequest, isTimeRequest)) { - return {policyForMovingExpensesID: expensePolicyID, policyForMovingExpenses: expensePolicy, shouldSelectPolicy: false}; + validExpensePolicyID = expensePolicyID; } } + return {singlePolicyID, isMemberOfMoreThanOnePolicy, validExpensePolicyID}; +} + +function usePolicyForMovingExpenses(isPerDiemRequest?: boolean, isTimeRequest?: boolean, expensePolicyID?: string) { + const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); + const [activePolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`, { + selector: activePolicySelector, + }); + + const session = useSession(); + const login = session?.email ?? ''; + + // Contextual selector — captures login/flags from closure. + // Returns only IDs + flags (stable output) to prevent re-renders when unrelated policies change. + const policyQualificationSelector = (policies: OnyxCollection) => getPolicyQualificationResult(policies, login, isPerDiemRequest, isTimeRequest, expensePolicyID); + const [qualificationResult] = useOnyx(ONYXKEYS.COLLECTION.POLICY, { + selector: policyQualificationSelector, + }); + + const {singlePolicyID, isMemberOfMoreThanOnePolicy, validExpensePolicyID} = qualificationResult ?? {}; + + // Per-key lookup for the resolved policy (only fires when that specific policy changes) + const resolvedPolicyID = validExpensePolicyID ?? singlePolicyID; + const [resolvedPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${resolvedPolicyID}`); + + // If an expense policy ID is provided and valid, prefer it over the active policy + if (validExpensePolicyID) { + return {policyForMovingExpensesID: validExpensePolicyID, policyForMovingExpenses: resolvedPolicy, shouldSelectPolicy: false}; + } + if (activePolicy && (!isPerDiemRequest || canSubmitPerDiemExpenseFromWorkspace(activePolicy)) && (!isTimeRequest || isTimeTrackingEnabled(activePolicy))) { return {policyForMovingExpensesID: activePolicyID, policyForMovingExpenses: activePolicy, shouldSelectPolicy: false}; } - if (singleUserPolicy && !isMemberOfMoreThanOnePolicy) { - return {policyForMovingExpensesID: singleUserPolicy.id, policyForMovingExpenses: singleUserPolicy, shouldSelectPolicy: false}; + if (singlePolicyID && !isMemberOfMoreThanOnePolicy) { + return {policyForMovingExpensesID: singlePolicyID, policyForMovingExpenses: resolvedPolicy, shouldSelectPolicy: false}; } if (isMemberOfMoreThanOnePolicy) { diff --git a/src/hooks/useReportAttributes.ts b/src/hooks/useReportAttributes.ts index d5d308541590..316cd5cd8c10 100644 --- a/src/hooks/useReportAttributes.ts +++ b/src/hooks/useReportAttributes.ts @@ -14,4 +14,18 @@ function useReportAttributes() { return reportAttributes?.reports; } +/** + * Returns a single report's attributes using a selector. + * Deep comparison is cheap (single small object), so re-renders only occur + * when that specific report's attributes change — not on every global report change. + */ +function useReportAttributesByID(reportID: string | undefined) { + const reportAttributesByIDSelector = (value: {reports?: Record} | undefined) => (reportID ? value?.reports?.[reportID] : undefined); + const [reportAttributes] = useOnyx(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, { + selector: reportAttributesByIDSelector, + }); + return reportAttributes; +} + export default useReportAttributes; +export {useReportAttributesByID}; diff --git a/src/hooks/useReportIsArchived.ts b/src/hooks/useReportIsArchived.ts index a52812a4bfd1..ee42cb04f7dd 100644 --- a/src/hooks/useReportIsArchived.ts +++ b/src/hooks/useReportIsArchived.ts @@ -2,10 +2,13 @@ import {isArchivedReport} from '@libs/ReportUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import useOnyx from './useOnyx'; +const isArchivedSelector = isArchivedReport; + function useReportIsArchived(reportID?: string): boolean { - const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`); - const isReportArchived = isArchivedReport(reportNameValuePairs); - return isReportArchived; + const [isArchived] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`, { + selector: isArchivedSelector, + }); + return !!isArchived; } export default useReportIsArchived; From 3f94e059294e50284bd81d27b38f5ac88f325041 Mon Sep 17 00:00:00 2001 From: Tomasz Lesniakiewicz Date: Fri, 3 Apr 2026 14:35:21 +0200 Subject: [PATCH 2/2] feat: export getAllTransactionDrafts and getAllReportNameValuePairs getters --- src/libs/actions/IOU/index.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index 7b22962ecd85..2a5bca9d714a 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -1057,6 +1057,14 @@ function getUserAccountID(): number { return userAccountID; } +function getAllTransactionDrafts(): NonNullable> { + return allTransactionDrafts; +} + +function getAllReportNameValuePairs(): OnyxCollection { + return allReportNameValuePairs; +} + /** * This function uses Onyx.connect and should be replaced with useOnyx for reactive data access. * TODO: remove `getPolicyTagsData` from this file (https://github.com/Expensify/App/issues/72721) @@ -13485,6 +13493,8 @@ export { getAllTransactionViolations, getAllReports, getAllReportActionsFromIOU, + getAllTransactionDrafts, + getAllReportNameValuePairs, getCurrentUserEmail, getUserAccountID, getReceiptError,