diff --git a/src/libs/actions/OnyxDerived/configs/reportAttributes.ts b/src/libs/actions/OnyxDerived/configs/reportAttributes.ts index 9effe46e29f5..7b73733d2871 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 { @@ -256,6 +257,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 +273,42 @@ export default createOnyxDerivedValueConfig({ hasKeyTriggeredCompute(ONYXKEYS.NVP_INTRO_SELECTED, triggeredKeys); const policyChangedReportKeys: 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 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] ?? {})) { - if (hasPolicyRelevantFieldChanged(previousPolicies?.[key], policies?.[key])) { - changedPolicyIDs.add(key.replace(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; + // `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) { + continue; + } + const policyID = key.replace(ONYXKEYS.COLLECTION.POLICY, ''); + changedPolicyIDs.add(policyID); + if (nameChanged) { + nameChangedPolicyIDs.add(policyID); + } + if (threadNameChanged) { + threadNameChangedPolicyIDs.add(policyID); + } + if (emptyNameChanged) { + emptyNameChangedPolicyIDs.add(policyID); } } if (changedPolicyIDs.size > 0) { @@ -281,18 +317,30 @@ export default createOnyxDerivedValueConfig({ if (!report) { continue; } - // The report's own policy — the sender workspace for an invoice. - if (report.policyID && changedPolicyIDs.has(report.policyID)) { - policyChangedReportKeys.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))) { + 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); + // 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); + if (!nameChangedPolicyIDs.has(report.policyID) && !receiverPolicyChanged && !isThreadNameAffected && !isEmptyNameAffected) { + nameSkipPolicyReportKeys.push(reportKey); + } + continue; + } + if (receiverPolicyChanged) { policyChangedReportKeys.push(reportKey); } } @@ -348,16 +396,28 @@ export default createOnyxDerivedValueConfig({ } } - const updates = [ + // 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), ...Object.keys(reportActionsUpdates), ...Object.keys(reportNameValuePairsUpdates), ...Array.from(reportUpdatesRelatedToReportActions), - ...policyChangedReportKeys, ...personalDetailsChangedReportKeys, ]; + const updates = [...nonPolicyUpdates, ...policyChangedReportKeys]; + + // 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); + } + if (useIncrementalUpdates) { // if there are report-related updates, iterate over the updates if (updates.length > 0 || !!transactionsUpdates || !!transactionViolationsUpdates || !!policyTagsUpdates) { @@ -422,7 +482,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 +498,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 +596,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 +618,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..46dd97ca7cda 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,15 +277,56 @@ 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}, + triggeredKeys: new Set([ONYXKEYS.COLLECTION.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, + }; + + 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.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 as never}, + sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: policies}, triggeredKeys: new Set([ONYXKEYS.COLLECTION.POLICY]), }); - const policy1Changed = {...policy1, approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL} as unknown as Policy; + // Both a badge field and the name change — the report name must be recomputed. + 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, @@ -304,13 +345,204 @@ describe('reportAttributes compute — policy change code flow', () => { 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'); + }); + + // 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}], + ])('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, 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), + 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'}; + + 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 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}}, + 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, reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO} 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,14 +550,14 @@ 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]), }); - 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 = { @@ -338,7 +570,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]), }); @@ -346,6 +578,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 samePoliciesNewRefs: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.POLICY}policy1`]: {...policy1}, + [`${ONYXKEYS.COLLECTION.POLICY}policy2`]: {...policy2}, + }; + + const result = config.compute(buildArgs(samePoliciesNewRefs), { + currentValue: existingValue, + sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: samePoliciesNewRefs}, + 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};