From 9b8ba052b893080a046648efd398c93a9b6cb4df Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Mon, 27 Jul 2026 14:06:16 +0200 Subject: [PATCH 1/8] Skip report-name recompute for badge-only policy changes --- .../OnyxDerived/configs/reportAttributes.ts | 68 ++++++- tests/unit/reportAttributesTest.ts | 178 +++++++++++++++++- 2 files changed, 229 insertions(+), 17 deletions(-) diff --git a/src/libs/actions/OnyxDerived/configs/reportAttributes.ts b/src/libs/actions/OnyxDerived/configs/reportAttributes.ts index 9effe46e29f5..78b894f21600 100644 --- a/src/libs/actions/OnyxDerived/configs/reportAttributes.ts +++ b/src/libs/actions/OnyxDerived/configs/reportAttributes.ts @@ -256,6 +256,12 @@ export default createOnyxDerivedValueConfig({ seedDisplayNamesBaseline(personalDetails); } + // Seed the policy value-baseline on the startup flush (policies from disk, no POLICY trigger). Without + // it the first POLICY trigger has no baseline and treats every policy as changed. See getCollectionDelta. + if (previousPolicies === undefined && policies && !hasKeyTriggeredCompute(ONYXKEYS.COLLECTION.POLICY, triggeredKeys)) { + previousPolicies = policies; + } + // A full recompute is needed when locale changes (report names are locale-dependent) or display names change. // We compare preferredLocale against currentValue?.locale so that the first locale load on startup // (where both equal the same persisted value) does not trigger an unnecessary full recompute. @@ -266,13 +272,26 @@ export default createOnyxDerivedValueConfig({ hasKeyTriggeredCompute(ONYXKEYS.NVP_INTRO_SELECTED, triggeredKeys); const policyChangedReportKeys: string[] = []; + // Reports whose only policy change is in badge fields — their name can't have changed, so they reuse + // the cached one. Name inputs from a policy are `name` and `achAccount` (see getPolicyName and the + // REIMBURSED thread name); receiver-policy matches never qualify because invoice room names also read + // the receiver `role` (see getInvoicesChatName). + const policyBadgeOnlyReportKeys: string[] = []; if (hasKeyTriggeredCompute(ONYXKEYS.COLLECTION.POLICY, triggeredKeys)) { if (!needsFullRecompute) { // Policy updated — only recompute reports whose relevant fields actually changed const changedPolicyIDs = new Set(); + const nameChangedPolicyIDs = new Set(); for (const key of Object.keys(sourceValues?.[ONYXKEYS.COLLECTION.POLICY] ?? {})) { - if (hasPolicyRelevantFieldChanged(previousPolicies?.[key], policies?.[key])) { - changedPolicyIDs.add(key.replace(ONYXKEYS.COLLECTION.POLICY, '')); + const prevPolicy = previousPolicies?.[key]; + const nextPolicy = policies?.[key]; + if (!hasPolicyRelevantFieldChanged(prevPolicy, nextPolicy)) { + continue; + } + const policyID = key.replace(ONYXKEYS.COLLECTION.POLICY, ''); + changedPolicyIDs.add(policyID); + if (prevPolicy?.name !== nextPolicy?.name || prevPolicy?.achAccount?.accountNumber !== nextPolicy?.achAccount?.accountNumber) { + nameChangedPolicyIDs.add(policyID); } } if (changedPolicyIDs.size > 0) { @@ -284,6 +303,9 @@ export default createOnyxDerivedValueConfig({ // The report's own policy — the sender workspace for an invoice. if (report.policyID && changedPolicyIDs.has(report.policyID)) { policyChangedReportKeys.push(reportKey); + if (!nameChangedPolicyIDs.has(report.policyID)) { + policyBadgeOnlyReportKeys.push(reportKey); + } continue; } // An invoice follows its receiver workspace. The invoice room carries the receiver @@ -348,16 +370,28 @@ export default createOnyxDerivedValueConfig({ } } - const updates = [ + // Sources that can move a report's name. A report pulled in purely by a policy badge change is absent + // here, so it keeps its cached name and skips the expensive computeReportName. + const nonPolicyUpdates = [ ...Object.keys(reportUpdates), ...Object.keys(reportMetadataUpdates), ...Object.keys(reportActionsUpdates), ...Object.keys(reportNameValuePairsUpdates), ...Array.from(reportUpdatesRelatedToReportActions), - ...policyChangedReportKeys, ...personalDetailsChangedReportKeys, ]; + const updates = [...nonPolicyUpdates, ...policyChangedReportKeys]; + + // Keys that reuse their cached name. Starts as the badge-only reports; every other change source + // (report/action/nvp/personal-details updates here, transactions and policy tags below) deletes its + // keys, so a report skips computeReportName only when a badge-field change is its sole reason to be here. + // Parent-chat enqueues don't delete: a child update never feeds the parent chat's own name. + const nameSkipKeys = new Set(prepareReportKeys(policyBadgeOnlyReportKeys)); + for (const key of prepareReportKeys(nonPolicyUpdates)) { + nameSkipKeys.delete(key); + } + if (useIncrementalUpdates) { // if there are report-related updates, iterate over the updates if (updates.length > 0 || !!transactionsUpdates || !!transactionViolationsUpdates || !!policyTagsUpdates) { @@ -422,7 +456,12 @@ export default createOnyxDerivedValueConfig({ .filter(Boolean) .map((chatReportID) => `${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`); - dataToIterate.push(...prepareReportKeys([...transactionReportIDs, ...transactionParentChatReportIDs])); + // Transactions feed thread/expense report names, so these keys must not skip the name recompute. + const transactionReportKeys = prepareReportKeys([...transactionReportIDs, ...transactionParentChatReportIDs]); + dataToIterate.push(...transactionReportKeys); + for (const key of transactionReportKeys) { + nameSkipKeys.delete(key); + } } if (policyTagsUpdates) { const changedPolicyIDs = new Set(Object.keys(policyTagsUpdates).map((key) => key.replace(ONYXKEYS.COLLECTION.POLICY_TAGS, ''))); @@ -433,7 +472,12 @@ export default createOnyxDerivedValueConfig({ } affectedReportKeys.push(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`); } - dataToIterate.push(...prepareReportKeys(affectedReportKeys)); + // Policy tags feed thread names (computeReportName reads allPolicyTags), so no name skip here. + const policyTagsReportKeys = prepareReportKeys(affectedReportKeys); + dataToIterate.push(...policyTagsReportKeys); + for (const key of policyTagsReportKeys) { + nameSkipKeys.delete(key); + } } } else { // No updates to process, return current value to prevent unnecessary computation @@ -526,9 +570,14 @@ export default createOnyxDerivedValueConfig({ actionTargetReportActionID = actionGreenTargetReportActionID; } + // Skip computeReportName when the name can't have changed (see nameSkipKeys). + const cachedName = currentValue?.reports?.[report.reportID]?.reportName; + const canReuseCachedName = cachedName !== undefined && nameSkipKeys.has(key); + acc[report.reportID] = { - reportName: report - ? computeReportName({ + reportName: canReuseCachedName + ? cachedName + : computeReportName({ report, reports, policies, @@ -543,8 +592,7 @@ export default createOnyxDerivedValueConfig({ conciergeReportID: conciergeReportID ?? undefined, reportAttributes: currentValue?.reports, isTrackIntentUser: isTrackIntentUserSelector(introSelected), - }) - : '', + }), isEmpty: generateIsEmptyReport(report, isReportArchived), brickRoadStatus, requiresAttention, diff --git a/tests/unit/reportAttributesTest.ts b/tests/unit/reportAttributesTest.ts index ebdcdc3dcb4d..52f743e04e90 100644 --- a/tests/unit/reportAttributesTest.ts +++ b/tests/unit/reportAttributesTest.ts @@ -186,7 +186,7 @@ describe('reportAttributes compute — policy change code flow', () => { it('computes every report on a cold start (no currentValue) when policies load', () => { const result = config.compute(buildArgs(), { currentValue: undefined, - sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: policies as never}, + sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: policies}, triggeredKeys: new Set([ONYXKEYS.COLLECTION.POLICY]), }); @@ -277,14 +277,15 @@ describe('reportAttributes compute — policy change code flow', () => { expect(result?.reports.invoiceChild?.reportName).not.toBe('Old Child'); }); - it('only recomputes reports for the changed policy when a tracked field changes', () => { + it('keeps report names cached when only a policy badge field changes (no name recompute)', () => { // Seed previousPolicies by doing an initial compute config.compute(buildArgs(), { currentValue: undefined, - sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: policies as never}, + sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: policies}, triggeredKeys: new Set([ONYXKEYS.COLLECTION.POLICY]), }); + // approvalMode is a badge-relevant field; the policy name is unchanged. const policy1Changed = {...policy1, approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL} as unknown as Policy; const updatedPolicies: OnyxCollection = { ...policies, @@ -299,18 +300,146 @@ describe('reportAttributes compute — policy change code flow', () => { locale: null, }; + const computeReportNameMock = (jest.requireMock('@libs/ReportNameUtils') as unknown as {computeReportName: jest.Mock}).computeReportName; + computeReportNameMock.mockClear(); + + const result = config.compute(buildArgs(updatedPolicies), { + currentValue: existingValue, + sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: {[`${ONYXKEYS.COLLECTION.POLICY}policy1`]: policy1Changed}}, + triggeredKeys: new Set([ONYXKEYS.COLLECTION.POLICY]), + }); + + // A badge-only change never affects the name, so computeReportName is skipped entirely... + expect(computeReportNameMock).not.toHaveBeenCalled(); + // ...and r1 keeps its cached name (its badge attributes are still recomputed). + expect(result?.reports.r1?.reportName).toBe('Old Name 1'); + // r2 (policy2 unchanged) keeps its value from currentValue. + expect(result?.reports.r2?.reportName).toBe('Old Name 2'); + }); + + it('recomputes the name when a policy name changes', () => { + // Seed previousPolicies by doing an initial compute + config.compute(buildArgs(), { + currentValue: undefined, + sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: policies}, + triggeredKeys: new Set([ONYXKEYS.COLLECTION.POLICY]), + }); + + // Both a badge field and the name change — the report name must be recomputed. + const policy1Changed = {...policy1, approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL, name: 'Renamed Policy'} as unknown as Policy; + const updatedPolicies: OnyxCollection = { + ...policies, + [`${ONYXKEYS.COLLECTION.POLICY}policy1`]: policy1Changed, + }; + + const existingValue: ReportAttributesDerivedValue = { + reports: { + r1: {reportName: 'Old Name 1', isEmpty: false, brickRoadStatus: undefined, requiresAttention: false, reportErrors: {}}, + r2: {reportName: 'Old Name 2', isEmpty: false, brickRoadStatus: undefined, requiresAttention: false, reportErrors: {}}, + }, + locale: null, + }; + const computeReportNameMock = (jest.requireMock('@libs/ReportNameUtils') as unknown as {computeReportName: jest.Mock}).computeReportName; computeReportNameMock.mockReturnValue('New Name'); const result = config.compute(buildArgs(updatedPolicies), { currentValue: existingValue, - sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: {[`${ONYXKEYS.COLLECTION.POLICY}policy1`]: policy1Changed} as never}, + sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: {[`${ONYXKEYS.COLLECTION.POLICY}policy1`]: policy1Changed}}, triggeredKeys: new Set([ONYXKEYS.COLLECTION.POLICY]), }); - // r1 (policy1 changed) should be recomputed with new name + // r1's policy name changed → name recomputed. expect(result?.reports.r1?.reportName).toBe('New Name'); - // r2 (policy2 unchanged) should keep its value from currentValue + // r2 (policy2 unchanged) keeps its value from currentValue. + expect(result?.reports.r2?.reportName).toBe('Old Name 2'); + }); + + it('recomputes invoice names when only the receiver policy role changes', () => { + // Invoice room names read the receiver policy `role` (isPolicyAdmin in getInvoicesChatName), + // so a role-only change on the receiver policy must not reuse the cached name. + const senderPolicy: Policy = {...basePolicy, id: 'senderPolicy'}; + const receiverPolicy: Policy = {...basePolicy, id: 'receiverPolicy'}; + + const invoiceRoom: Report = { + ...createRandomReport(30, CONST.REPORT.CHAT_TYPE.INVOICE), + reportID: 'invoiceRoom', + policyID: 'senderPolicy', + chatReportID: undefined, + invoiceReceiver: {type: CONST.REPORT.INVOICE_RECEIVER_TYPE.BUSINESS, policyID: 'receiverPolicy'}, + }; + const invoiceReports: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.REPORT}invoiceRoom`]: invoiceRoom, + }; + const bothPolicies: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.POLICY}senderPolicy`]: senderPolicy, + [`${ONYXKEYS.COLLECTION.POLICY}receiverPolicy`]: receiverPolicy, + }; + + // Seed previousPolicies with both policies. + config.compute(buildArgs(bothPolicies, invoiceReports), { + currentValue: undefined, + sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: bothPolicies}, + triggeredKeys: new Set([ONYXKEYS.COLLECTION.POLICY]), + }); + + const existingValue: ReportAttributesDerivedValue = { + reports: { + invoiceRoom: {reportName: 'Old Room', isEmpty: false, brickRoadStatus: undefined, requiresAttention: false, reportErrors: {}}, + }, + locale: null, + }; + + // Receiver policy role changes — a tracked badge field, name untouched. + const receiverPolicyChanged: Policy = {...receiverPolicy, role: CONST.POLICY.ROLE.USER}; + const result = config.compute(buildArgs({...bothPolicies, [`${ONYXKEYS.COLLECTION.POLICY}receiverPolicy`]: receiverPolicyChanged}, invoiceReports), { + currentValue: existingValue, + sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: {[`${ONYXKEYS.COLLECTION.POLICY}receiverPolicy`]: receiverPolicyChanged}}, + triggeredKeys: new Set([ONYXKEYS.COLLECTION.POLICY]), + }); + + // Receiver-policy matches never skip the name recompute. + expect(result?.reports.invoiceRoom?.reportName).not.toBe('Old Room'); + }); + + it('recomputes the name when a badge-only policy change coalesces with a transaction update on the same report', () => { + // Seed previousPolicies. + config.compute(buildArgs(), { + currentValue: undefined, + sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: policies}, + triggeredKeys: new Set([ONYXKEYS.COLLECTION.POLICY]), + }); + + const policy1Changed = {...policy1, approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL} as unknown as Policy; + const updatedPolicies: OnyxCollection = { + ...policies, + [`${ONYXKEYS.COLLECTION.POLICY}policy1`]: policy1Changed, + }; + const transactionsUpdate: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}tx1`]: {...createRandomTransaction(1), transactionID: 'tx1', reportID: 'r1'}, + }; + + const existingValue: ReportAttributesDerivedValue = { + reports: { + r1: {reportName: 'Old Name 1', isEmpty: false, brickRoadStatus: undefined, requiresAttention: false, reportErrors: {}}, + r2: {reportName: 'Old Name 2', isEmpty: false, brickRoadStatus: undefined, requiresAttention: false, reportErrors: {}}, + }, + locale: null, + }; + + // One coalesced flush: policy1 badge change + a transaction update whose report is also r1. + const result = config.compute(buildArgs(updatedPolicies, undefined, transactionsUpdate), { + currentValue: existingValue, + sourceValues: { + [ONYXKEYS.COLLECTION.POLICY]: {[`${ONYXKEYS.COLLECTION.POLICY}policy1`]: policy1Changed}, + [ONYXKEYS.COLLECTION.TRANSACTION]: transactionsUpdate, + }, + triggeredKeys: new Set([ONYXKEYS.COLLECTION.POLICY, ONYXKEYS.COLLECTION.TRANSACTION]), + }); + + // Transactions feed the name, so the badge-only skip must not apply to r1. + expect(result?.reports.r1?.reportName).toBe('Test Report'); + // r2 is untouched. expect(result?.reports.r2?.reportName).toBe('Old Name 2'); }); @@ -318,7 +447,7 @@ describe('reportAttributes compute — policy change code flow', () => { // Seed previousPolicies config.compute(buildArgs(), { currentValue: undefined, - sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: policies as never}, + sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: policies}, triggeredKeys: new Set([ONYXKEYS.COLLECTION.POLICY]), }); @@ -346,6 +475,41 @@ describe('reportAttributes compute — policy change code flow', () => { expect(result).toEqual(existingValue); }); + it('seeds the policy baseline on a non-policy compute so a reference-only policy re-merge is not recomputed', () => { + // Reproduces OpenSearchPage: policies were restored from disk (never triggering a POLICY compute), then a + // mergeCollection re-sends them with fresh object references. getCollectionDelta is reference-based, so the + // delta lists every policy — but nothing actually changed, so no report should recompute. + const existingValue: ReportAttributesDerivedValue = { + reports: { + r1: {reportName: 'Existing r1', isEmpty: false, brickRoadStatus: undefined, requiresAttention: false, reportErrors: {}}, + r2: {reportName: 'Existing r2', isEmpty: false, brickRoadStatus: undefined, requiresAttention: false, reportErrors: {}}, + }, + locale: null, + }; + + // A non-POLICY compute (e.g. reports loading) seeds previousPolicies from the disk-restored policies. + config.compute(buildArgs(), { + currentValue: existingValue, + sourceValues: {[ONYXKEYS.COLLECTION.REPORT]: reports}, + triggeredKeys: new Set([ONYXKEYS.COLLECTION.REPORT]), + }); + + // The same policies come back with fresh references (identical values). + const rementedPolicies: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.POLICY}policy1`]: {...policy1}, + [`${ONYXKEYS.COLLECTION.POLICY}policy2`]: {...policy2}, + }; + + const result = config.compute(buildArgs(rementedPolicies), { + currentValue: existingValue, + sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: rementedPolicies}, + triggeredKeys: new Set([ONYXKEYS.COLLECTION.POLICY]), + }); + + // No relevant field changed → currentValue returned unchanged, no report-name recompute. + expect(result).toEqual(existingValue); + }); + it('recomputes the parent workspace chat when a transaction on its expense report changes', () => { const expenseReport: Report = {...createRandomReport(10, undefined), reportID: 'expense1', policyID: 'policy3', chatReportID: 'chat1'}; const chatReport: Report = {...createRandomReport(11, CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT), reportID: 'chat1', policyID: 'policy3', chatReportID: undefined}; From d816bbbee35a4ea75ac66274a5a3f8be37704b52 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Mon, 27 Jul 2026 14:19:23 +0200 Subject: [PATCH 2/8] Rename test variable to fix cspell --- tests/unit/reportAttributesTest.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit/reportAttributesTest.ts b/tests/unit/reportAttributesTest.ts index 52f743e04e90..91d1d84e7914 100644 --- a/tests/unit/reportAttributesTest.ts +++ b/tests/unit/reportAttributesTest.ts @@ -495,14 +495,14 @@ describe('reportAttributes compute — policy change code flow', () => { }); // The same policies come back with fresh references (identical values). - const rementedPolicies: OnyxCollection = { + const samePoliciesNewRefs: OnyxCollection = { [`${ONYXKEYS.COLLECTION.POLICY}policy1`]: {...policy1}, [`${ONYXKEYS.COLLECTION.POLICY}policy2`]: {...policy2}, }; - const result = config.compute(buildArgs(rementedPolicies), { + const result = config.compute(buildArgs(samePoliciesNewRefs), { currentValue: existingValue, - sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: rementedPolicies}, + sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: samePoliciesNewRefs}, triggeredKeys: new Set([ONYXKEYS.COLLECTION.POLICY]), }); From e20715c3e2c67eab67f99754ebcaeb44e17a1751 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Mon, 27 Jul 2026 15:12:53 +0200 Subject: [PATCH 3/8] Fix stale name on policy rename and receiver-policy change --- .../OnyxDerived/configs/reportAttributes.ts | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/src/libs/actions/OnyxDerived/configs/reportAttributes.ts b/src/libs/actions/OnyxDerived/configs/reportAttributes.ts index 78b894f21600..f16cc8e19f51 100644 --- a/src/libs/actions/OnyxDerived/configs/reportAttributes.ts +++ b/src/libs/actions/OnyxDerived/configs/reportAttributes.ts @@ -285,12 +285,15 @@ export default createOnyxDerivedValueConfig({ for (const key of Object.keys(sourceValues?.[ONYXKEYS.COLLECTION.POLICY] ?? {})) { const prevPolicy = previousPolicies?.[key]; const nextPolicy = policies?.[key]; - if (!hasPolicyRelevantFieldChanged(prevPolicy, nextPolicy)) { + // `name`/`achAccount` feed report names but aren't in hasPolicyRelevantFieldChanged, so a + // name-only change (e.g. workspace rename) would be skipped and leave the cached name stale. + const nameChanged = prevPolicy?.name !== nextPolicy?.name || prevPolicy?.achAccount?.accountNumber !== nextPolicy?.achAccount?.accountNumber; + if (!hasPolicyRelevantFieldChanged(prevPolicy, nextPolicy) && !nameChanged) { continue; } const policyID = key.replace(ONYXKEYS.COLLECTION.POLICY, ''); changedPolicyIDs.add(policyID); - if (prevPolicy?.name !== nextPolicy?.name || prevPolicy?.achAccount?.accountNumber !== nextPolicy?.achAccount?.accountNumber) { + if (nameChanged) { nameChangedPolicyIDs.add(policyID); } } @@ -300,21 +303,26 @@ export default createOnyxDerivedValueConfig({ if (!report) { continue; } + // An invoice follows its receiver workspace. The invoice room carries the receiver + // on itself; a child invoice report doesn't, so we read it from its parent room + // (chatReportID) — the same place computeReportName looks for the invoice name. + const ownReceiverPolicyID = report.invoiceReceiver && 'policyID' in report.invoiceReceiver ? report.invoiceReceiver.policyID : undefined; + const room = report.chatReportID ? reports?.[`${ONYXKEYS.COLLECTION.REPORT}${report.chatReportID}`] : undefined; + const roomReceiverPolicyID = room?.invoiceReceiver && 'policyID' in room.invoiceReceiver ? room.invoiceReceiver.policyID : undefined; + const receiverPolicyChanged = + (!!ownReceiverPolicyID && changedPolicyIDs.has(ownReceiverPolicyID)) || (!!roomReceiverPolicyID && changedPolicyIDs.has(roomReceiverPolicyID)); + // The report's own policy — the sender workspace for an invoice. if (report.policyID && changedPolicyIDs.has(report.policyID)) { policyChangedReportKeys.push(reportKey); - if (!nameChangedPolicyIDs.has(report.policyID)) { + // Reuse the cached name only for a badge-only sender change with no receiver + // change — the invoice name reads the receiver's role/name too. + if (!nameChangedPolicyIDs.has(report.policyID) && !receiverPolicyChanged) { policyBadgeOnlyReportKeys.push(reportKey); } continue; } - // An invoice follows its receiver workspace. The invoice room carries the receiver - // on itself; a child invoice report doesn't, so we read it from its parent room - // (chatReportID) — the same place computeReportName looks for the invoice name. - const ownReceiverPolicyID = report.invoiceReceiver && 'policyID' in report.invoiceReceiver ? report.invoiceReceiver.policyID : undefined; - const room = report.chatReportID ? reports?.[`${ONYXKEYS.COLLECTION.REPORT}${report.chatReportID}`] : undefined; - const roomReceiverPolicyID = room?.invoiceReceiver && 'policyID' in room.invoiceReceiver ? room.invoiceReceiver.policyID : undefined; - if ((ownReceiverPolicyID && changedPolicyIDs.has(ownReceiverPolicyID)) || (roomReceiverPolicyID && changedPolicyIDs.has(roomReceiverPolicyID))) { + if (receiverPolicyChanged) { policyChangedReportKeys.push(reportKey); } } From f51a475399bfca6b845f00697dda2ea40bf8a5f5 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Mon, 27 Jul 2026 15:17:51 +0200 Subject: [PATCH 4/8] Update test: policy name change now recomputes name --- tests/unit/reportAttributesTest.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit/reportAttributesTest.ts b/tests/unit/reportAttributesTest.ts index 91d1d84e7914..8d78c67c1d97 100644 --- a/tests/unit/reportAttributesTest.ts +++ b/tests/unit/reportAttributesTest.ts @@ -451,10 +451,10 @@ describe('reportAttributes compute — policy change code flow', () => { triggeredKeys: new Set([ONYXKEYS.COLLECTION.POLICY]), }); - const policy1WithNameChange = {...policy1, name: 'New Policy Name'} as unknown as Policy; + const policy1WithUntrackedChange = {...policy1, description: 'New description'} as unknown as Policy; const updatedPolicies: OnyxCollection = { ...policies, - [`${ONYXKEYS.COLLECTION.POLICY}policy1`]: policy1WithNameChange, + [`${ONYXKEYS.COLLECTION.POLICY}policy1`]: policy1WithUntrackedChange, }; const existingValue: ReportAttributesDerivedValue = { @@ -467,7 +467,7 @@ describe('reportAttributes compute — policy change code flow', () => { const result = config.compute(buildArgs(updatedPolicies), { currentValue: existingValue, - sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: {[`${ONYXKEYS.COLLECTION.POLICY}policy1`]: policy1WithNameChange} as never}, + sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: {[`${ONYXKEYS.COLLECTION.POLICY}policy1`]: policy1WithUntrackedChange} as never}, triggeredKeys: new Set([ONYXKEYS.COLLECTION.POLICY]), }); From edee233822d9e63c512a105133dc8ca3ce843a6b Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Tue, 28 Jul 2026 10:27:08 +0200 Subject: [PATCH 5/8] Scope policy name-field checks to affected report shapes --- .../OnyxDerived/configs/reportAttributes.ts | 33 ++++- tests/unit/reportAttributesTest.ts | 124 ++++++++++++++++-- 2 files changed, 143 insertions(+), 14 deletions(-) diff --git a/src/libs/actions/OnyxDerived/configs/reportAttributes.ts b/src/libs/actions/OnyxDerived/configs/reportAttributes.ts index f16cc8e19f51..869694af0603 100644 --- a/src/libs/actions/OnyxDerived/configs/reportAttributes.ts +++ b/src/libs/actions/OnyxDerived/configs/reportAttributes.ts @@ -4,6 +4,7 @@ import {getReportPreviewAction} from '@libs/actions/IOU/MoneyRequestBuilder'; import {translate as translateForLocale} from '@libs/Localize'; import {getIsOffline} from '@libs/NetworkState'; import {getLoginByAccountID} from '@libs/PersonalDetailsUtils'; +import {isPolicyFieldListEmpty} from '@libs/PolicyUtils'; import {getLinkedTransactionID, isDeletedAction} from '@libs/ReportActionsUtils'; import {computeReportName} from '@libs/ReportNameUtils'; import { @@ -273,8 +274,8 @@ export default createOnyxDerivedValueConfig({ const policyChangedReportKeys: string[] = []; // Reports whose only policy change is in badge fields — their name can't have changed, so they reuse - // the cached one. Name inputs from a policy are `name` and `achAccount` (see getPolicyName and the - // REIMBURSED thread name); receiver-policy matches never qualify because invoice room names also read + // the cached one. Name inputs from a policy are listed in the `nameChanged` check below; + // receiver-policy matches never qualify because invoice room names also read // the receiver `role` (see getInvoicesChatName). const policyBadgeOnlyReportKeys: string[] = []; if (hasKeyTriggeredCompute(ONYXKEYS.COLLECTION.POLICY, triggeredKeys)) { @@ -282,13 +283,24 @@ export default createOnyxDerivedValueConfig({ // Policy updated — only recompute reports whose relevant fields actually changed const changedPolicyIDs = new Set(); const nameChangedPolicyIDs = new Set(); + const threadNameChangedPolicyIDs = new Set(); + const emptyNameChangedPolicyIDs = new Set(); for (const key of Object.keys(sourceValues?.[ONYXKEYS.COLLECTION.POLICY] ?? {})) { const prevPolicy = previousPolicies?.[key]; const nextPolicy = policies?.[key]; // `name`/`achAccount` feed report names but aren't in hasPolicyRelevantFieldChanged, so a // name-only change (e.g. workspace rename) would be skipped and leave the cached name stale. const nameChanged = prevPolicy?.name !== nextPolicy?.name || prevPolicy?.achAccount?.accountNumber !== nextPolicy?.achAccount?.accountNumber; - if (!hasPolicyRelevantFieldChanged(prevPolicy, nextPolicy) && !nameChanged) { + // `approvalMode`/`role` feed only *thread* names (submitted-thread "marked as done" via + // shouldShowMarkAsDone; rules link in MODIFIED_EXPENSE messages via isPolicyAdmin), and + // `fieldList` emptiness only the "New Report" fallback for money-request reports with an + // empty reportName (getMoneyRequestReportName). These must NOT disqualify the skip for the + // whole policy: mass flushes (e.g. the first OpenSearchPage delivering `fieldList` for every + // policy) would then recompute every report's name again — the exact hang this skip removes. + // Instead the report loop below drops the skip only for the report shapes that read them. + const threadNameChanged = prevPolicy?.approvalMode !== nextPolicy?.approvalMode || prevPolicy?.role !== nextPolicy?.role; + const emptyNameChanged = isPolicyFieldListEmpty(prevPolicy ?? undefined) !== isPolicyFieldListEmpty(nextPolicy ?? undefined); + if (!hasPolicyRelevantFieldChanged(prevPolicy, nextPolicy) && !nameChanged && !emptyNameChanged) { continue; } const policyID = key.replace(ONYXKEYS.COLLECTION.POLICY, ''); @@ -296,6 +308,12 @@ export default createOnyxDerivedValueConfig({ if (nameChanged) { nameChangedPolicyIDs.add(policyID); } + if (threadNameChanged) { + threadNameChangedPolicyIDs.add(policyID); + } + if (emptyNameChanged) { + emptyNameChangedPolicyIDs.add(policyID); + } } if (changedPolicyIDs.size > 0) { for (const reportKey of Object.keys(reports ?? {})) { @@ -316,8 +334,13 @@ export default createOnyxDerivedValueConfig({ if (report.policyID && changedPolicyIDs.has(report.policyID)) { policyChangedReportKeys.push(reportKey); // Reuse the cached name only for a badge-only sender change with no receiver - // change — the invoice name reads the receiver's role/name too. - if (!nameChangedPolicyIDs.has(report.policyID) && !receiverPolicyChanged) { + // change — the invoice name reads the receiver's role/name too. Threads also read + // `approvalMode`/`role`, and empty-named money-request reports read `fieldList` + // emptiness, so those shapes forgo the skip when that's what changed. + const isThreadNameAffected = threadNameChangedPolicyIDs.has(report.policyID) && !!report.parentReportActionID; + const isEmptyNameAffected = + emptyNameChangedPolicyIDs.has(report.policyID) && !report.reportName && (report.type === CONST.REPORT.TYPE.EXPENSE || report.type === CONST.REPORT.TYPE.IOU); + if (!nameChangedPolicyIDs.has(report.policyID) && !receiverPolicyChanged && !isThreadNameAffected && !isEmptyNameAffected) { policyBadgeOnlyReportKeys.push(reportKey); } continue; diff --git a/tests/unit/reportAttributesTest.ts b/tests/unit/reportAttributesTest.ts index 8d78c67c1d97..6e13a1cd248b 100644 --- a/tests/unit/reportAttributesTest.ts +++ b/tests/unit/reportAttributesTest.ts @@ -285,8 +285,8 @@ describe('reportAttributes compute — policy change code flow', () => { triggeredKeys: new Set([ONYXKEYS.COLLECTION.POLICY]), }); - // approvalMode is a badge-relevant field; the policy name is unchanged. - const policy1Changed = {...policy1, approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL} as unknown as Policy; + // reimbursementChoice is a badge-relevant field that no report name reads. + const policy1Changed = {...policy1, reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO} as unknown as Policy; const updatedPolicies: OnyxCollection = { ...policies, [`${ONYXKEYS.COLLECTION.POLICY}policy1`]: policy1Changed, @@ -326,7 +326,7 @@ describe('reportAttributes compute — policy change code flow', () => { }); // Both a badge field and the name change — the report name must be recomputed. - const policy1Changed = {...policy1, approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL, name: 'Renamed Policy'} as unknown as Policy; + const policy1Changed = {...policy1, reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO, name: 'Renamed Policy'} as unknown as Policy; const updatedPolicies: OnyxCollection = { ...policies, [`${ONYXKEYS.COLLECTION.POLICY}policy1`]: policy1Changed, @@ -355,9 +355,115 @@ describe('reportAttributes compute — policy change code flow', () => { expect(result?.reports.r2?.reportName).toBe('Old Name 2'); }); - it('recomputes invoice names when only the receiver policy role changes', () => { - // Invoice room names read the receiver policy `role` (isPolicyAdmin in getInvoicesChatName), - // so a role-only change on the receiver policy must not reuse the cached name. + // approvalMode/role feed only *thread* names (submitted-thread "marked as done"; rules link in + // MODIFIED_EXPENSE messages), so only threads forgo the badge-only skip when they change — plain + // reports on the same policy keep their cached name. + it.each([ + ['approvalMode', {approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL}], + ['role', {role: CONST.POLICY.ROLE.USER}], + ])('recomputes only thread names when a thread-name-affecting policy field changes (%s)', (_field, policyPatch) => { + const thread: Report = { + ...createRandomReport(40, undefined), + reportID: 'thread1', + policyID: 'policy1', + parentReportID: 'r1', + parentReportActionID: 'action1', + chatReportID: undefined, + participants: {}, + }; + const reportsWithThread: OnyxCollection = {...reports, [`${ONYXKEYS.COLLECTION.REPORT}thread1`]: thread}; + + // Seed previousPolicies by doing an initial compute + config.compute(buildArgs(undefined, reportsWithThread), { + currentValue: undefined, + sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: policies}, + triggeredKeys: new Set([ONYXKEYS.COLLECTION.POLICY]), + }); + + const policy1Changed: Policy = {...policy1, ...policyPatch}; + const updatedPolicies: OnyxCollection = { + ...policies, + [`${ONYXKEYS.COLLECTION.POLICY}policy1`]: policy1Changed, + }; + + const existingValue: ReportAttributesDerivedValue = { + reports: { + r1: {reportName: 'Old Name 1', isEmpty: false, brickRoadStatus: undefined, requiresAttention: false, reportErrors: {}}, + r2: {reportName: 'Old Name 2', isEmpty: false, brickRoadStatus: undefined, requiresAttention: false, reportErrors: {}}, + thread1: {reportName: 'Old Thread Name', isEmpty: false, brickRoadStatus: undefined, requiresAttention: false, reportErrors: {}}, + }, + locale: null, + }; + + const result = config.compute(buildArgs(updatedPolicies, reportsWithThread), { + currentValue: existingValue, + sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: {[`${ONYXKEYS.COLLECTION.POLICY}policy1`]: policy1Changed}}, + triggeredKeys: new Set([ONYXKEYS.COLLECTION.POLICY]), + }); + + // The thread's name reads the changed field → recomputed. + expect(result?.reports.thread1?.reportName).toBe('Test Report'); + // A plain report on the same policy keeps the badge-only skip. + expect(result?.reports.r1?.reportName).toBe('Old Name 1'); + // r2 (policy2 unchanged) keeps its value from currentValue. + expect(result?.reports.r2?.reportName).toBe('Old Name 2'); + }); + + // fieldList emptiness feeds only the "New Report" fallback for money-request reports with an empty + // reportName. A mass flush delivering fieldList for every policy (the first OpenSearchPage) must NOT + // recompute every report's name — only empty-named money-request reports. + it('recomputes only empty-named money-request report names when fieldList emptiness changes', () => { + const emptyNameExpense: Report = { + ...createRandomReport(41, undefined), + reportID: 'exp1', + policyID: 'policy1', + type: CONST.REPORT.TYPE.EXPENSE, + reportName: '', + chatReportID: undefined, + participants: {}, + }; + const reportsWithExpense: OnyxCollection = {...reports, [`${ONYXKEYS.COLLECTION.REPORT}exp1`]: emptyNameExpense}; + + // Seed previousPolicies by doing an initial compute (basePolicy has no fieldList → empty). + config.compute(buildArgs(undefined, reportsWithExpense), { + currentValue: undefined, + sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: policies}, + triggeredKeys: new Set([ONYXKEYS.COLLECTION.POLICY]), + }); + + // fieldList arrives → emptiness flips. Not a badge field, so this alone must still be detected. + const policy1Changed = {...policy1, fieldList: {textTitle: {name: 'title'}}} as unknown as Policy; + const updatedPolicies: OnyxCollection = { + ...policies, + [`${ONYXKEYS.COLLECTION.POLICY}policy1`]: policy1Changed, + }; + + const existingValue: ReportAttributesDerivedValue = { + reports: { + r1: {reportName: 'Old Name 1', isEmpty: false, brickRoadStatus: undefined, requiresAttention: false, reportErrors: {}}, + r2: {reportName: 'Old Name 2', isEmpty: false, brickRoadStatus: undefined, requiresAttention: false, reportErrors: {}}, + exp1: {reportName: 'New Report', isEmpty: false, brickRoadStatus: undefined, requiresAttention: false, reportErrors: {}}, + }, + locale: null, + }; + + const result = config.compute(buildArgs(updatedPolicies, reportsWithExpense), { + currentValue: existingValue, + sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: {[`${ONYXKEYS.COLLECTION.POLICY}policy1`]: policy1Changed}}, + triggeredKeys: new Set([ONYXKEYS.COLLECTION.POLICY]), + }); + + // The empty-named expense report reads fieldList emptiness → recomputed. + expect(result?.reports.exp1?.reportName).toBe('Test Report'); + // A plain report on the same policy keeps its cached name — the mass-flush hot path stays fast. + expect(result?.reports.r1?.reportName).toBe('Old Name 1'); + // r2 (policy2 unchanged) keeps its value from currentValue. + expect(result?.reports.r2?.reportName).toBe('Old Name 2'); + }); + + it('recomputes invoice names when only a receiver policy badge field changes', () => { + // Invoice room names read the receiver policy (isPolicyAdmin in getInvoicesChatName), so a + // receiver-policy match must never reuse the cached name — even for a badge-only change. const senderPolicy: Policy = {...basePolicy, id: 'senderPolicy'}; const receiverPolicy: Policy = {...basePolicy, id: 'receiverPolicy'}; @@ -390,8 +496,8 @@ describe('reportAttributes compute — policy change code flow', () => { locale: null, }; - // Receiver policy role changes — a tracked badge field, name untouched. - const receiverPolicyChanged: Policy = {...receiverPolicy, role: CONST.POLICY.ROLE.USER}; + // Receiver policy reimbursementChoice changes — a tracked badge field that no name reads. + const receiverPolicyChanged: Policy = {...receiverPolicy, reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO}; const result = config.compute(buildArgs({...bothPolicies, [`${ONYXKEYS.COLLECTION.POLICY}receiverPolicy`]: receiverPolicyChanged}, invoiceReports), { currentValue: existingValue, sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: {[`${ONYXKEYS.COLLECTION.POLICY}receiverPolicy`]: receiverPolicyChanged}}, @@ -410,7 +516,7 @@ describe('reportAttributes compute — policy change code flow', () => { triggeredKeys: new Set([ONYXKEYS.COLLECTION.POLICY]), }); - const policy1Changed = {...policy1, approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL} as unknown as Policy; + const policy1Changed = {...policy1, reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO} as unknown as Policy; const updatedPolicies: OnyxCollection = { ...policies, [`${ONYXKEYS.COLLECTION.POLICY}policy1`]: policy1Changed, From f51b9c5b8ce5e449ac15fe77cf29be49f5b7f007 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Tue, 28 Jul 2026 10:35:09 +0200 Subject: [PATCH 6/8] Shorten comments --- .../OnyxDerived/configs/reportAttributes.ts | 19 ++++++++----------- tests/unit/reportAttributesTest.ts | 9 +++------ 2 files changed, 11 insertions(+), 17 deletions(-) diff --git a/src/libs/actions/OnyxDerived/configs/reportAttributes.ts b/src/libs/actions/OnyxDerived/configs/reportAttributes.ts index 869694af0603..b45cfac69f47 100644 --- a/src/libs/actions/OnyxDerived/configs/reportAttributes.ts +++ b/src/libs/actions/OnyxDerived/configs/reportAttributes.ts @@ -291,13 +291,11 @@ export default createOnyxDerivedValueConfig({ // `name`/`achAccount` feed report names but aren't in hasPolicyRelevantFieldChanged, so a // name-only change (e.g. workspace rename) would be skipped and leave the cached name stale. const nameChanged = prevPolicy?.name !== nextPolicy?.name || prevPolicy?.achAccount?.accountNumber !== nextPolicy?.achAccount?.accountNumber; - // `approvalMode`/`role` feed only *thread* names (submitted-thread "marked as done" via - // shouldShowMarkAsDone; rules link in MODIFIED_EXPENSE messages via isPolicyAdmin), and - // `fieldList` emptiness only the "New Report" fallback for money-request reports with an - // empty reportName (getMoneyRequestReportName). These must NOT disqualify the skip for the - // whole policy: mass flushes (e.g. the first OpenSearchPage delivering `fieldList` for every - // policy) would then recompute every report's name again — the exact hang this skip removes. - // Instead the report loop below drops the skip only for the report shapes that read them. + // `approvalMode`/`role` feed only thread names (shouldShowMarkAsDone, isPolicyAdmin) and + // `fieldList` emptiness only empty-named money-request reports (getMoneyRequestReportName). + // They must not disqualify the whole policy — mass flushes (e.g. the first OpenSearchPage + // delivers `fieldList` for every policy) would recompute every name again. The report loop + // below drops the skip only for the shapes that read them. const threadNameChanged = prevPolicy?.approvalMode !== nextPolicy?.approvalMode || prevPolicy?.role !== nextPolicy?.role; const emptyNameChanged = isPolicyFieldListEmpty(prevPolicy ?? undefined) !== isPolicyFieldListEmpty(nextPolicy ?? undefined); if (!hasPolicyRelevantFieldChanged(prevPolicy, nextPolicy) && !nameChanged && !emptyNameChanged) { @@ -333,10 +331,9 @@ export default createOnyxDerivedValueConfig({ // The report's own policy — the sender workspace for an invoice. if (report.policyID && changedPolicyIDs.has(report.policyID)) { policyChangedReportKeys.push(reportKey); - // Reuse the cached name only for a badge-only sender change with no receiver - // change — the invoice name reads the receiver's role/name too. Threads also read - // `approvalMode`/`role`, and empty-named money-request reports read `fieldList` - // emptiness, so those shapes forgo the skip when that's what changed. + // Reuse the cached name only when nothing the report's name reads has changed: + // receiver policy (invoices), `approvalMode`/`role` (threads), `fieldList` + // emptiness (empty-named money-request reports). const isThreadNameAffected = threadNameChangedPolicyIDs.has(report.policyID) && !!report.parentReportActionID; const isEmptyNameAffected = emptyNameChangedPolicyIDs.has(report.policyID) && !report.reportName && (report.type === CONST.REPORT.TYPE.EXPENSE || report.type === CONST.REPORT.TYPE.IOU); diff --git a/tests/unit/reportAttributesTest.ts b/tests/unit/reportAttributesTest.ts index 6e13a1cd248b..46dd97ca7cda 100644 --- a/tests/unit/reportAttributesTest.ts +++ b/tests/unit/reportAttributesTest.ts @@ -355,9 +355,7 @@ describe('reportAttributes compute — policy change code flow', () => { expect(result?.reports.r2?.reportName).toBe('Old Name 2'); }); - // approvalMode/role feed only *thread* names (submitted-thread "marked as done"; rules link in - // MODIFIED_EXPENSE messages), so only threads forgo the badge-only skip when they change — plain - // reports on the same policy keep their cached name. + // approvalMode/role feed only thread names, so only threads forgo the badge-only skip when they change. it.each([ ['approvalMode', {approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL}], ['role', {role: CONST.POLICY.ROLE.USER}], @@ -409,9 +407,8 @@ describe('reportAttributes compute — policy change code flow', () => { expect(result?.reports.r2?.reportName).toBe('Old Name 2'); }); - // fieldList emptiness feeds only the "New Report" fallback for money-request reports with an empty - // reportName. A mass flush delivering fieldList for every policy (the first OpenSearchPage) must NOT - // recompute every report's name — only empty-named money-request reports. + // fieldList emptiness feeds only the "New Report" fallback, so a mass flush delivering fieldList for + // every policy (the first OpenSearchPage) recomputes only empty-named money-request reports. it('recomputes only empty-named money-request report names when fieldList emptiness changes', () => { const emptyNameExpense: Report = { ...createRandomReport(41, undefined), From ac950abc004a329a60264621daa864f37cc03f01 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Tue, 28 Jul 2026 10:37:56 +0200 Subject: [PATCH 7/8] Trim redundant comment --- src/libs/actions/OnyxDerived/configs/reportAttributes.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/libs/actions/OnyxDerived/configs/reportAttributes.ts b/src/libs/actions/OnyxDerived/configs/reportAttributes.ts index b45cfac69f47..f28b04176b68 100644 --- a/src/libs/actions/OnyxDerived/configs/reportAttributes.ts +++ b/src/libs/actions/OnyxDerived/configs/reportAttributes.ts @@ -273,10 +273,7 @@ export default createOnyxDerivedValueConfig({ hasKeyTriggeredCompute(ONYXKEYS.NVP_INTRO_SELECTED, triggeredKeys); const policyChangedReportKeys: string[] = []; - // Reports whose only policy change is in badge fields — their name can't have changed, so they reuse - // the cached one. Name inputs from a policy are listed in the `nameChanged` check below; - // receiver-policy matches never qualify because invoice room names also read - // the receiver `role` (see getInvoicesChatName). + // Reports whose only policy change is in badge fields — their name can't have changed, so they reuse the cached one. const policyBadgeOnlyReportKeys: string[] = []; if (hasKeyTriggeredCompute(ONYXKEYS.COLLECTION.POLICY, triggeredKeys)) { if (!needsFullRecompute) { From a816f8147175049dd1f6391ee16905a4f8917c7f Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Tue, 28 Jul 2026 20:14:37 +0200 Subject: [PATCH 8/8] Rename policyBadgeOnlyReportKeys to nameSkipPolicyReportKeys --- .../OnyxDerived/configs/reportAttributes.ts | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/libs/actions/OnyxDerived/configs/reportAttributes.ts b/src/libs/actions/OnyxDerived/configs/reportAttributes.ts index f28b04176b68..7b73733d2871 100644 --- a/src/libs/actions/OnyxDerived/configs/reportAttributes.ts +++ b/src/libs/actions/OnyxDerived/configs/reportAttributes.ts @@ -273,8 +273,9 @@ export default createOnyxDerivedValueConfig({ hasKeyTriggeredCompute(ONYXKEYS.NVP_INTRO_SELECTED, triggeredKeys); const policyChangedReportKeys: string[] = []; - // Reports whose only policy change is in badge fields — their name can't have changed, so they reuse the cached one. - const policyBadgeOnlyReportKeys: string[] = []; + // Reports whose policy change touched only fields that don't feed the report name (type, approvalMode, + // role, etc.) — their name can't have changed, so they skip computeReportName and reuse the cached one. + const nameSkipPolicyReportKeys: string[] = []; if (hasKeyTriggeredCompute(ONYXKEYS.COLLECTION.POLICY, triggeredKeys)) { if (!needsFullRecompute) { // Policy updated — only recompute reports whose relevant fields actually changed @@ -335,7 +336,7 @@ export default createOnyxDerivedValueConfig({ const isEmptyNameAffected = emptyNameChangedPolicyIDs.has(report.policyID) && !report.reportName && (report.type === CONST.REPORT.TYPE.EXPENSE || report.type === CONST.REPORT.TYPE.IOU); if (!nameChangedPolicyIDs.has(report.policyID) && !receiverPolicyChanged && !isThreadNameAffected && !isEmptyNameAffected) { - policyBadgeOnlyReportKeys.push(reportKey); + nameSkipPolicyReportKeys.push(reportKey); } continue; } @@ -395,8 +396,8 @@ export default createOnyxDerivedValueConfig({ } } - // Sources that can move a report's name. A report pulled in purely by a policy badge change is absent - // here, so it keeps its cached name and skips the expensive computeReportName. + // Sources that can move a report's name. A report pulled in purely by a name-irrelevant policy change is + // absent here, so it keeps its cached name and skips the expensive computeReportName. const nonPolicyUpdates = [ ...Object.keys(reportUpdates), ...Object.keys(reportMetadataUpdates), @@ -408,11 +409,11 @@ export default createOnyxDerivedValueConfig({ const updates = [...nonPolicyUpdates, ...policyChangedReportKeys]; - // Keys that reuse their cached name. Starts as the badge-only reports; every other change source - // (report/action/nvp/personal-details updates here, transactions and policy tags below) deletes its - // keys, so a report skips computeReportName only when a badge-field change is its sole reason to be here. - // Parent-chat enqueues don't delete: a child update never feeds the parent chat's own name. - const nameSkipKeys = new Set(prepareReportKeys(policyBadgeOnlyReportKeys)); + // Keys that reuse their cached name. Starts as the name-irrelevant policy reports; every other change + // source (report/action/nvp/personal-details updates here, transactions and policy tags below) deletes + // its keys, so a report skips computeReportName only when a name-irrelevant policy change is its sole + // reason to be here. Parent-chat enqueues don't delete: a child update never feeds the parent chat's own name. + const nameSkipKeys = new Set(prepareReportKeys(nameSkipPolicyReportKeys)); for (const key of prepareReportKeys(nonPolicyUpdates)) { nameSkipKeys.delete(key); }