diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 24e2033b9a99..df6e37cd335a 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2218,6 +2218,9 @@ const CONST = { APP_OPEN: 'NavigationAppOpen', }, // Attribute names + ATTRIBUTE_DERIVED_TRIGGER: 'derived_trigger', + /** Value of derived_trigger for a derived value's first compute, which runs from scratch rather than from a delta. */ + DERIVED_TRIGGER_INITIAL: 'initial', ATTRIBUTE_IOU_TYPE: 'iou_type', ATTRIBUTE_IS_ONE_TRANSACTION_REPORT: 'is_one_transaction_report', ATTRIBUTE_IS_TRANSACTION_THREAD: 'is_transaction_thread', diff --git a/src/libs/actions/OnyxDerived/configs/reportAttributes.ts b/src/libs/actions/OnyxDerived/configs/reportAttributes.ts index 9effe46e29f5..24874e37d789 100644 --- a/src/libs/actions/OnyxDerived/configs/reportAttributes.ts +++ b/src/libs/actions/OnyxDerived/configs/reportAttributes.ts @@ -1,6 +1,7 @@ import type {LocalizedTranslate} from '@components/LocaleContextProvider'; import {getReportPreviewAction} from '@libs/actions/IOU/MoneyRequestBuilder'; +import hashCode from '@libs/hashCode'; import {translate as translateForLocale} from '@libs/Localize'; import {getIsOffline} from '@libs/NetworkState'; import {getLoginByAccountID} from '@libs/PersonalDetailsUtils'; @@ -34,7 +35,6 @@ import {isTrackIntentUserSelector} from '@selectors/Onboarding'; // The name-related fields we saw for each account last time, so we can spot which accounts changed. let previousDisplayNames: Record = {}; let previousPersonalDetails: OnyxEntry | undefined; -let previousPolicies: OnyxCollection; const RECOMPUTE_ALL = 'all' as const; @@ -51,21 +51,86 @@ const prepareReportKeys = (keys: string[]) => { ]; }; -const hasPolicyRelevantFieldChanged = (prev: Policy | null | undefined, next: Policy | null | undefined): boolean => { - if (!prev && !next) { - return false; +// Keys that change without affecting the computed attributes: write-bookkeeping keys and loading flags +// flip on every optimistic write / API call, and `connections` is large, volatile, and never read here. +// Excluded at every nesting depth, not just the policy top level — nested bookkeeping (e.g. a member's +// pendingAction/errors inside employeeList) is just as irrelevant to the attributes as the top-level kind. +const POLICY_SIGNATURE_EXCLUDED_KEYS = new Set([ + 'pendingAction', + 'pendingFields', + 'errors', + 'errorFields', + 'connections', + 'isLoading', + 'isLoadingWorkspaceReimbursement', + 'isLoadingReceiptPartners', + 'isChangeOwnerSuccessful', + 'isChangeOwnerFailed', + 'lastModified', +]); + +// Deterministic stringify: keys are sorted at every level, so equal content yields equal strings regardless of key insertion order. +const stableStringify = (value: unknown): string => { + if (value === null || typeof value !== 'object') { + return JSON.stringify(value) ?? 'undefined'; } - if (!prev || !next) { - return true; + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(',')}]`; + } + const entries = Object.entries(value) + .filter(([key]) => !POLICY_SIGNATURE_EXCLUDED_KEYS.has(key)) + .sort(([a], [b]) => (a < b ? -1 : 1)); + return `{${entries.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`).join(',')}}`; +}; + +// Signature of a policy's attribute-relevant content, stored in the derived value (like `locale`) so the +// change-detection baseline survives app restarts. The serialized length is appended so a 32-bit hash +// collision alone cannot mask a change. The serialized string encodes its own shape (keys included), so +// identical signatures imply identical relevant content: any change to the exclusion set, stringify format, +// or hash function alters signatures of affected policies — the resulting mismatch triggers a one-time +// scoped recompute that also refreshes the stored baseline. +const policyRelevantSignature = (policy: Policy | null | undefined): string | null => { + if (!policy) { + return null; + } + const serialized = stableStringify(policy); + return `${hashCode(serialized)}.${serialized.length}`; +}; + +const buildPolicySignatures = (policies: OnyxCollection): Record => { + const signatures: Record = {}; + for (const [key, policy] of Object.entries(policies ?? {})) { + const signature = policyRelevantSignature(policy); + if (signature !== null) { + signatures[key] = signature; + } + } + return signatures; +}; + +// Report keys whose attributes depend on one of the given policies. +const collectReportKeysForPolicies = (reports: OnyxCollection, changedPolicyIDs: Set): string[] => { + const reportKeys: string[] = []; + for (const [reportKey, report] of Object.entries(reports ?? {})) { + if (!report) { + continue; + } + // The report's own policy — the sender workspace for an invoice. + if (report.policyID && changedPolicyIDs.has(report.policyID)) { + reportKeys.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))) { + reportKeys.push(reportKey); + } } - return ( - prev.type !== next.type || - prev.approvalMode !== next.approvalMode || - prev.reimbursementChoice !== next.reimbursementChoice || - prev.autoReimbursementLimit !== next.autoReimbursementLimit || - prev.role !== next.role || - prev.autoReimbursement?.limit !== next.autoReimbursement?.limit - ); + return reportKeys; }; // A short string built from the fields a report name can come from: displayName and firstName. @@ -256,58 +321,120 @@ export default createOnyxDerivedValueConfig({ seedDisplayNamesBaseline(personalDetails); } + const nextIsTrackIntentUser = isTrackIntentUserSelector(introSelected); + // conciergeReportID and introSelected are re-delivered on every OpenApp/reconnect merge, so a full + // recompute fires only when the delivered value differs from the stored baseline. A missing baseline + // (value written by an older app version) counts as a change on the delivery pass — that pass is the + // one chance to reconcile names the persisted value may have been computed with a different value, so + // it recomputes rather than being silently absorbed; on non-delivery passes the missing baseline is + // seeded instead. '' (not null) marks "no concierge report" — Onyx.set strips nested nulls on persist, + // so a null baseline would read back as missing after a restart. + // eslint-disable-next-line rulesdir/no-default-id-values -- '' is a persistable baseline sentinel, never used as a lookup ID + const nextConciergeReportID = conciergeReportID ?? ''; + // eslint-disable-next-line rulesdir/no-default-id-values -- same sentinel for values persisted before this field existed + const storedConciergeReportID = currentValue && 'conciergeReportID' in currentValue ? (currentValue.conciergeReportID ?? '') : undefined; + const storedIsTrackIntentUser = currentValue && 'isTrackIntentUser' in currentValue ? currentValue.isTrackIntentUser : undefined; + const conciergeReportIDTriggered = hasKeyTriggeredCompute(ONYXKEYS.CONCIERGE_REPORT_ID, triggeredKeys); + const introSelectedTriggered = hasKeyTriggeredCompute(ONYXKEYS.NVP_INTRO_SELECTED, triggeredKeys); + const hasConciergeReportIDChanged = conciergeReportIDTriggered && storedConciergeReportID !== nextConciergeReportID; + const hasIsTrackIntentUserChanged = introSelectedTriggered && storedIsTrackIntentUser !== nextIsTrackIntentUser; + // 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. const needsFullRecompute = (hasKeyTriggeredCompute(ONYXKEYS.NVP_PREFERRED_LOCALE, triggeredKeys) && preferredLocale !== currentValue?.locale) || displayNameChanges === RECOMPUTE_ALL || - hasKeyTriggeredCompute(ONYXKEYS.CONCIERGE_REPORT_ID, triggeredKeys) || - hasKeyTriggeredCompute(ONYXKEYS.NVP_INTRO_SELECTED, triggeredKeys); + hasConciergeReportIDChanged || + hasIsTrackIntentUserChanged; + + // Policy changes are detected by diffing against signatures stored in the derived value, so the + // baseline survives app restarts. Signatures advance only together with the recompute they imply. + const storedPolicySignatures = currentValue?.policySignatures; + const hasComputedReports = !!currentValue?.reports && Object.keys(currentValue.reports).length > 0; + let nextPolicySignatures = storedPolicySignatures; + // True when signatures may persist through an early return: a pure baseline seed, or changed policies with no reports referencing them. + let canPersistSignaturesWithoutRecompute = false; + let policyChangedReportKeys: string[] = []; + + // Cached — a single pass can snapshot the full baseline more than once. + let allPolicySignaturesCache: Record | undefined; + const buildAllPolicySignatures = () => { + allPolicySignaturesCache ??= buildPolicySignatures(policies); + return allPolicySignaturesCache; + }; - const policyChangedReportKeys: string[] = []; if (hasKeyTriggeredCompute(ONYXKEYS.COLLECTION.POLICY, triggeredKeys)) { - if (!needsFullRecompute) { - // Policy updated — only recompute reports whose relevant fields actually changed + if (needsFullRecompute) { + // Every report recomputes with the current policies anyway — snapshot the full baseline. + nextPolicySignatures = buildAllPolicySignatures(); + } else if (storedPolicySignatures) { const changedPolicyIDs = new Set(); + const updatedSignatures = {...storedPolicySignatures}; for (const key of Object.keys(sourceValues?.[ONYXKEYS.COLLECTION.POLICY] ?? {})) { - if (hasPolicyRelevantFieldChanged(previousPolicies?.[key], policies?.[key])) { - changedPolicyIDs.add(key.replace(ONYXKEYS.COLLECTION.POLICY, '')); + const signature = policyRelevantSignature(policies?.[key]); + if ((storedPolicySignatures[key] ?? null) === signature) { + continue; + } + changedPolicyIDs.add(key.replace(ONYXKEYS.COLLECTION.POLICY, '')); + if (signature === null) { + delete updatedSignatures[key]; + } else { + updatedSignatures[key] = signature; } } if (changedPolicyIDs.size > 0) { - for (const reportKey of Object.keys(reports ?? {})) { - const report = reports?.[reportKey]; - 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))) { - policyChangedReportKeys.push(reportKey); - } - } + nextPolicySignatures = updatedSignatures; + policyChangedReportKeys = collectReportKeysForPolicies(reports, changedPolicyIDs); + // With the reports collection missing, the recompute is skipped rather than unnecessary, so the baseline must not advance. + canPersistSignaturesWithoutRecompute = !!reports && policyChangedReportKeys.length === 0; } + } else if (hasComputedReports) { + // Attributes exist but carry no signature baseline (value written by an older app version, or + // computed before policies loaded) — recompute the delivered policies' reports and snapshot the full baseline. + const deliveredPolicyIDs = new Set(Object.keys(sourceValues?.[ONYXKEYS.COLLECTION.POLICY] ?? {}).map((key) => key.replace(ONYXKEYS.COLLECTION.POLICY, ''))); + // A coalesced policy trigger can fire with an empty delta — skip the full report walk then. + policyChangedReportKeys = deliveredPolicyIDs.size > 0 ? collectReportKeysForPolicies(reports, deliveredPolicyIDs) : []; + canPersistSignaturesWithoutRecompute = !!reports && policyChangedReportKeys.length === 0; + if (reports) { + nextPolicySignatures = buildAllPolicySignatures(); + } + } else { + // No attributes computed yet — the pass below runs a full scan, so seeding is safe. + nextPolicySignatures = buildAllPolicySignatures(); } - previousPolicies = policies; + } else if (!storedPolicySignatures && policies && hasComputedReports) { + // No baseline yet on a pass that did not deliver policies: the attributes and the policies both + // come from the same disk-hydrated state, so seeding without a recompute is consistent. + nextPolicySignatures = buildAllPolicySignatures(); + canPersistSignaturesWithoutRecompute = true; + } + + // Baseline writes that ride early returns. An existing conciergeReportID/isTrackIntentUser baseline + // advances only in the final return of a full recompute — advancing it here would absorb a change and + // suppress the recompute its next delivery should trigger. A missing baseline is seeded only on a pass + // that did not deliver the key: the persisted attributes and the delivered value then both come from + // the same disk-hydrated state, so the names already reflect it. On the delivery pass a missing + // baseline is treated as a change above and recomputed in the final return, not seeded here. + const metaPatch: Partial = {}; + if (canPersistSignaturesWithoutRecompute && nextPolicySignatures !== storedPolicySignatures) { + metaPatch.policySignatures = nextPolicySignatures; + } + if (storedConciergeReportID === undefined && !conciergeReportIDTriggered) { + metaPatch.conciergeReportID = nextConciergeReportID; + } + if (storedIsTrackIntentUser === undefined && !introSelectedTriggered) { + metaPatch.isTrackIntentUser = nextIsTrackIntentUser; } + const withMetaPatch = (value: ReportAttributesDerivedValue): ReportAttributesDerivedValue => (Object.keys(metaPatch).length > 0 ? {...value, ...metaPatch} : value); // Use incremental updates when currentValue is already populated and no full recompute is required. // If currentValue has no reports (fresh install or cleared storage), fall back to a full scan. - const useIncrementalUpdates = !!currentValue?.reports && Object.keys(currentValue.reports).length > 0 && !needsFullRecompute; + const useIncrementalUpdates = hasComputedReports && !needsFullRecompute; // if we already computed the report attributes and there is no new reports data, return the current value if ((useIncrementalUpdates && !sourceValues) || !reports) { - return currentValue ?? {reports: {}, locale: null}; + return withMetaPatch(currentValue ?? {reports: {}, locale: null}); } const reportUpdates = sourceValues?.[ONYXKEYS.COLLECTION.REPORT] ?? {}; @@ -437,7 +564,7 @@ export default createOnyxDerivedValueConfig({ } } else { // No updates to process, return current value to prevent unnecessary computation - return currentValue ?? {reports: {}, locale: null}; + return withMetaPatch(currentValue ?? {reports: {}, locale: null}); } } @@ -542,7 +669,7 @@ export default createOnyxDerivedValueConfig({ allPolicyTags: policyTags, conciergeReportID: conciergeReportID ?? undefined, reportAttributes: currentValue?.reports, - isTrackIntentUser: isTrackIntentUserSelector(introSelected), + isTrackIntentUser: nextIsTrackIntentUser, }) : '', isEmpty: generateIsEmptyReport(report, isReportArchived), @@ -560,82 +687,106 @@ export default createOnyxDerivedValueConfig({ currentValue?.reports ? {...currentValue.reports} : {}, ); - // Propagate errors from IOU reports to their parent chat reports. - const currentUserAccountID = session?.accountID ?? CONST.DEFAULT_NUMBER_ID; - const currentUserEmail = session?.email ?? ''; - const erroredChildReportIDsByChat = new Map(); - const childReportIDsByChat = new Map(); - for (const report of Object.values(reports)) { - if (!report?.reportID || !report.chatReportID || report.reportID === report.chatReportID) { - continue; - } + // Propagate errors from IOU reports to their parent chat reports. Rebuilding the parent/child maps + // scans every report, so an incremental pass pays that cost only when a recomputed report changed + // one of the two fields the propagation depends on: needsParentChatErrorPropagation or brickRoadStatus. + const errorRelevantAttributesChanged = + !useIncrementalUpdates || + dataToIterate.some((key) => { + const reportID = key.replace(ONYXKEYS.COLLECTION.REPORT, ''); + const previous = currentValue?.reports?.[reportID]; + const next = reportAttributes[reportID]; + return (previous?.needsParentChatErrorPropagation ?? false) !== (next?.needsParentChatErrorPropagation ?? false) || previous?.brickRoadStatus !== next?.brickRoadStatus; + }); + + if (errorRelevantAttributesChanged) { + const currentUserAccountID = session?.accountID ?? CONST.DEFAULT_NUMBER_ID; + const currentUserEmail = session?.email ?? ''; + const erroredChildReportIDsByChat = new Map(); + const childReportIDsByChat = new Map(); + for (const report of Object.values(reports)) { + if (!report?.reportID || !report.chatReportID || report.reportID === report.chatReportID) { + continue; + } - const childReportIDs = childReportIDsByChat.get(report.chatReportID) ?? []; - childReportIDs.push(report.reportID); - childReportIDsByChat.set(report.chatReportID, childReportIDs); - - // When the child IOU's parent action in the chat is deleted (e.g. another user deleted the request - // while an optimistic pay was queued offline), the chat has no actionable surface for the error. - // Skip propagation so the parent DM row doesn't show a stale "Fix" for a request that no longer exists. - const parentReportAction = report.parentReportActionID - ? reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`]?.[report.parentReportActionID] - : undefined; - if (isDeletedAction(parentReportAction)) { - continue; - } + const childReportIDs = childReportIDsByChat.get(report.chatReportID) ?? []; + childReportIDs.push(report.reportID); + childReportIDsByChat.set(report.chatReportID, childReportIDs); + + // When the child IOU's parent action in the chat is deleted (e.g. another user deleted the request + // while an optimistic pay was queued offline), the chat has no actionable surface for the error. + // Skip propagation so the parent DM row doesn't show a stale "Fix" for a request that no longer exists. + const parentReportAction = report.parentReportActionID + ? reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`]?.[report.parentReportActionID] + : undefined; + if (isDeletedAction(parentReportAction)) { + continue; + } - // If this is an IOU report and its calculated attributes have an error, - // then we need to mark its parent chat report. - // We read `needsParentChatErrorPropagation` rather than `brickRoadStatus` because the per-report - // pass suppresses the child's own brickRoadStatus when the parent workspace chat is accessible — - // we still need to propagate the error up so the parent shows the indicator. - const attributes = reportAttributes[report.reportID]; - if (attributes?.needsParentChatErrorPropagation || attributes?.brickRoadStatus === CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR) { - const erroredChildReportIDs = erroredChildReportIDsByChat.get(report.chatReportID) ?? []; - erroredChildReportIDs.push(report.reportID); - erroredChildReportIDsByChat.set(report.chatReportID, erroredChildReportIDs); + // If this is an IOU report and its calculated attributes have an error, + // then we need to mark its parent chat report. + // We read `needsParentChatErrorPropagation` rather than `brickRoadStatus` because the per-report + // pass suppresses the child's own brickRoadStatus when the parent workspace chat is accessible — + // we still need to propagate the error up so the parent shows the indicator. + const attributes = reportAttributes[report.reportID]; + if (attributes?.needsParentChatErrorPropagation || attributes?.brickRoadStatus === CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR) { + const erroredChildReportIDs = erroredChildReportIDsByChat.get(report.chatReportID) ?? []; + erroredChildReportIDs.push(report.reportID); + erroredChildReportIDsByChat.set(report.chatReportID, erroredChildReportIDs); + } } - } - // Apply the error status to the parent chat reports. - for (const [chatReportID, erroredChildReportIDs] of erroredChildReportIDsByChat) { - if (!reportAttributes[chatReportID]) { - continue; + // Apply the error status to the parent chat reports. + for (const [chatReportID, erroredChildReportIDs] of erroredChildReportIDsByChat) { + if (!reportAttributes[chatReportID]) { + continue; + } + + const chatAttributes = reportAttributes[chatReportID]; + let actionTargetReportActionID = chatAttributes.actionTargetReportActionID; + + actionTargetReportActionID = + getOldestPreviewActionID(chatReportID, erroredChildReportIDs, reports, isActionable) ?? + getOldestPreviewActionID(chatReportID, childReportIDsByChat.get(chatReportID), reports, (childReport) => + needsViolationFix( + childReport, + getLoginByAccountID(childReport?.ownerAccountID, personalDetails), + policies, + transactionViolations, + currentUserAccountID, + currentUserEmail, + ), + ) ?? + getOldestPreviewActionID(chatReportID, erroredChildReportIDs, reports) ?? + actionTargetReportActionID; + + // Clone the entry before mutating — it may be a reference carried over from + // currentValue.reports that wasn't recomputed in this incremental run. + reportAttributes[chatReportID] = { + ...chatAttributes, + brickRoadStatus: CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR, + actionBadge: CONST.REPORT.ACTION_BADGE.FIX, + actionTargetReportActionID, + }; } + } - const chatAttributes = reportAttributes[chatReportID]; - let actionTargetReportActionID = chatAttributes.actionTargetReportActionID; - - actionTargetReportActionID = - getOldestPreviewActionID(chatReportID, erroredChildReportIDs, reports, isActionable) ?? - getOldestPreviewActionID(chatReportID, childReportIDsByChat.get(chatReportID), reports, (childReport) => - needsViolationFix( - childReport, - getLoginByAccountID(childReport?.ownerAccountID, personalDetails), - policies, - transactionViolations, - currentUserAccountID, - currentUserEmail, - ), - ) ?? - getOldestPreviewActionID(chatReportID, erroredChildReportIDs, reports) ?? - actionTargetReportActionID; - - // Clone the entry before mutating — it may be a reference carried over from - // currentValue.reports that wasn't recomputed in this incremental run. - reportAttributes[chatReportID] = { - ...chatAttributes, - brickRoadStatus: CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR, - actionBadge: CONST.REPORT.ACTION_BADGE.FIX, - actionTargetReportActionID, - }; + // A full scan recomputed every report with the current policies, so the baseline can be snapshotted + // even when no policy trigger fired this pass. + if (!useIncrementalUpdates && policies) { + nextPolicySignatures = buildAllPolicySignatures(); } + // The stored conciergeReportID/isTrackIntentUser always reflect the values the full attribute set + // was computed with, so they advance only on a full recompute (or the very first write). return { reports: reportAttributes, locale: preferredLocale ?? null, + policySignatures: nextPolicySignatures, + conciergeReportID: storedConciergeReportID === undefined || needsFullRecompute ? nextConciergeReportID : storedConciergeReportID, + isTrackIntentUser: storedIsTrackIntentUser === undefined || needsFullRecompute ? nextIsTrackIntentUser : storedIsTrackIntentUser, }; }, }); -export {hasPolicyRelevantFieldChanged}; +export {policyRelevantSignature}; diff --git a/src/libs/actions/OnyxDerived/index.ts b/src/libs/actions/OnyxDerived/index.ts index 085d5b2e8de1..909596e8dd64 100644 --- a/src/libs/actions/OnyxDerived/index.ts +++ b/src/libs/actions/OnyxDerived/index.ts @@ -90,7 +90,14 @@ function init() { name: CONST.TELEMETRY.SPAN_ONYX_DERIVED_COMPUTE, op: CONST.TELEMETRY.SPAN_ONYX_DERIVED_COMPUTE, parentSpan: getSpan(CONST.TELEMETRY.SPAN_APP_STARTUP), - attributes: {derivedKey: key}, + // Sorted so the same trigger combo serializes identically across derived keys, keeping + // Sentry group-bys stable regardless of each config's dependency order. The first flush + // computes from scratch regardless of which keys fired, so it is stamped as the initial + // compute instead of listing them. + attributes: { + derivedKey: key, + [CONST.TELEMETRY.ATTRIBUTE_DERIVED_TRIGGER]: hasFlushedOnce ? [...triggeredKeys].sort().join(',') : CONST.TELEMETRY.DERIVED_TRIGGER_INITIAL, + }, }); try { diff --git a/src/types/onyx/DerivedValues.ts b/src/types/onyx/DerivedValues.ts index 4223a0ed8183..e2cb2b4c73ae 100644 --- a/src/types/onyx/DerivedValues.ts +++ b/src/types/onyx/DerivedValues.ts @@ -69,6 +69,20 @@ type ReportAttributesDerivedValue = { * The locale used to compute the report attributes. */ locale: string | null; + /** + * Signatures of each policy's attribute-relevant content, keyed by policy Onyx key. + * Persisted so a fresh app session can tell real policy changes from the first post-startup merge. + */ + policySignatures?: Record; + /** + * The conciergeReportID used to compute the report attributes. An empty string means the attributes + * were computed without one (null is not stored — Onyx.set strips nested null values on persist). + */ + conciergeReportID?: string; + /** + * Whether the user was a track-intent user when the attributes were computed. + */ + isTrackIntentUser?: boolean; }; /** diff --git a/tests/unit/reportAttributesTest.ts b/tests/unit/reportAttributesTest.ts index ebdcdc3dcb4d..c12ca7ac4486 100644 --- a/tests/unit/reportAttributesTest.ts +++ b/tests/unit/reportAttributesTest.ts @@ -1,5 +1,5 @@ import type reportAttributesModuleDefault from '@userActions/OnyxDerived/configs/reportAttributes'; -import {hasPolicyRelevantFieldChanged} from '@userActions/OnyxDerived/configs/reportAttributes'; +import {policyRelevantSignature} from '@userActions/OnyxDerived/configs/reportAttributes'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -8,11 +8,14 @@ import type {Policy, Report, ReportAttributesDerivedValue, Transaction} from '@s import type {OnyxCollection} from 'react-native-onyx'; +import createRandomReportAction from '../utils/collections/reportActions'; import {createRandomReport} from '../utils/collections/reports'; import createRandomTransaction from '../utils/collections/transaction'; type ReportAttributesConfig = typeof reportAttributesModuleDefault; +const triggeredBy = (...keys: OnyxKey[]) => new Set(keys); + jest.mock('@libs/ReportUtils', () => ({ generateReportAttributes: jest.fn(() => ({ hasAnyViolations: false, @@ -27,6 +30,13 @@ jest.mock('@libs/ReportUtils', () => ({ isArchivedReport: jest.fn(() => false), isValidReport: jest.fn(() => true), parseReportRouteParams: jest.fn(() => ({reportID: ''})), + // Only reached once a report has an RBR reason (see the `reportAttributes compute` — error propagation' + // describe block); false keeps that report's own brickRoadStatus as ERROR rather than suppressed. + isPolicyExpenseChat: jest.fn(() => false), + isPolicyAdmin: jest.fn(() => false), + isOpenReport: jest.fn(() => false), + isProcessingReport: jest.fn(() => false), + hasViolations: jest.fn(() => false), })); jest.mock('@libs/SidebarUtils', () => ({ @@ -52,81 +62,45 @@ const basePolicy: Policy = { autoReimbursement: {limit: 500}, } as unknown as Policy; -describe('hasPolicyRelevantFieldChanged', () => { +describe('policyRelevantSignature', () => { describe('null / undefined edge cases', () => { - it('returns false when both are null', () => { - expect(hasPolicyRelevantFieldChanged(null, null)).toBe(false); - }); - - it('returns false when both are undefined', () => { - expect(hasPolicyRelevantFieldChanged(undefined, undefined)).toBe(false); - }); - - it('returns false when both are null/undefined mix', () => { - expect(hasPolicyRelevantFieldChanged(null, undefined)).toBe(false); - expect(hasPolicyRelevantFieldChanged(undefined, null)).toBe(false); - }); - - it('returns true when prev is null and next has a policy', () => { - expect(hasPolicyRelevantFieldChanged(null, basePolicy)).toBe(true); - }); - - it('returns true when next is null and prev had a policy', () => { - expect(hasPolicyRelevantFieldChanged(basePolicy, null)).toBe(true); - }); - }); - - describe('identical policies', () => { - it('returns false when all tracked fields are the same', () => { - const copy = {...basePolicy}; - expect(hasPolicyRelevantFieldChanged(basePolicy, copy)).toBe(false); - }); - - it('returns false when only a non-tracked field changes', () => { - const updated = {...basePolicy, name: 'Updated Name'} as unknown as Policy; - expect(hasPolicyRelevantFieldChanged(basePolicy, updated)).toBe(false); + it('returns null for missing policies', () => { + expect(policyRelevantSignature(null)).toBeNull(); + expect(policyRelevantSignature(undefined)).toBeNull(); }); }); - describe('tracked field changes', () => { - it('returns true when type changes', () => { - const updated = {...basePolicy, type: CONST.POLICY.TYPE.TEAM} as unknown as Policy; - expect(hasPolicyRelevantFieldChanged(basePolicy, updated)).toBe(true); + describe('identical content', () => { + it('produces the same signature for a shallow copy', () => { + expect(policyRelevantSignature({...basePolicy})).toBe(policyRelevantSignature(basePolicy)); }); - it('returns true when approvalMode changes', () => { - const updated = {...basePolicy, approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL} as unknown as Policy; - expect(hasPolicyRelevantFieldChanged(basePolicy, updated)).toBe(true); + it('produces the same signature regardless of key insertion order', () => { + const reordered = Object.fromEntries(Object.entries(basePolicy).reverse()) as unknown as Policy; + expect(policyRelevantSignature(reordered)).toBe(policyRelevantSignature(basePolicy)); }); - it('returns true when reimbursementChoice changes', () => { - const updated = {...basePolicy, reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO} as unknown as Policy; - expect(hasPolicyRelevantFieldChanged(basePolicy, updated)).toBe(true); - }); - - it('returns true when autoReimbursementLimit changes', () => { - const updated = {...basePolicy, autoReimbursementLimit: 2000} as unknown as Policy; - expect(hasPolicyRelevantFieldChanged(basePolicy, updated)).toBe(true); - }); - - it('returns true when role changes', () => { - const updated = {...basePolicy, role: CONST.POLICY.ROLE.USER} as unknown as Policy; - expect(hasPolicyRelevantFieldChanged(basePolicy, updated)).toBe(true); - }); - - it('returns true when autoReimbursement.limit changes', () => { - const updated = {...basePolicy, autoReimbursement: {limit: 999}} as unknown as Policy; - expect(hasPolicyRelevantFieldChanged(basePolicy, updated)).toBe(true); - }); - - it('returns true when autoReimbursement goes from defined to undefined', () => { - const updated = {...basePolicy, autoReimbursement: undefined} as unknown as Policy; - expect(hasPolicyRelevantFieldChanged(basePolicy, updated)).toBe(true); + it('ignores Onyx write-bookkeeping keys', () => { + const withWriteNoise = {...basePolicy, pendingAction: 'update', pendingFields: {name: 'update'}, errors: {a: 'err'}, errorFields: {name: {a: 'err'}}} as unknown as Policy; + expect(policyRelevantSignature(withWriteNoise)).toBe(policyRelevantSignature(basePolicy)); }); + }); - it('returns true when autoReimbursement goes from undefined to defined', () => { - const withoutAutoReimburse = {...basePolicy, autoReimbursement: undefined} as unknown as Policy; - expect(hasPolicyRelevantFieldChanged(withoutAutoReimburse, basePolicy)).toBe(true); + describe('content changes', () => { + const changes: Array<[string, Partial]> = [ + ['type', {type: CONST.POLICY.TYPE.TEAM}], + ['approvalMode', {approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL}], + ['reimbursementChoice', {reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO}], + ['autoReimbursementLimit', {autoReimbursementLimit: 2000}], + ['role', {role: CONST.POLICY.ROLE.USER}], + ['autoReimbursement.limit', {autoReimbursement: {limit: 999}}], + ['autoReimbursement removed', {autoReimbursement: undefined}], + ['name', {name: 'Renamed Workspace'}], + ['employeeList', {employeeList: {['a@b.com' as string]: {submitsTo: 'c@d.com'}}}], + ]; + it.each(changes)('changes the signature when %s changes', (_label, change) => { + const updated = {...basePolicy, ...change} as unknown as Policy; + expect(policyRelevantSignature(updated)).not.toBe(policyRelevantSignature(basePolicy)); }); }); }); @@ -161,42 +135,52 @@ describe('reportAttributes compute — policy change code flow', () => { [`${ONYXKEYS.COLLECTION.POLICY}policy2`]: policy2, }; + // policyRelevantSignature returns null only for a missing policy, so tests can rely on a string. + const signatureOf = (policy: Policy): string => policyRelevantSignature(policy) ?? ''; + beforeEach(() => { jest.resetModules(); config = (require('@userActions/OnyxDerived/configs/reportAttributes') as {default: ReportAttributesConfig}).default; }); - const buildArgs = (overridePolicies?: OnyxCollection, overrideReports?: OnyxCollection, transactionsUpdate?: OnyxCollection | null) => + const buildArgs = ( + overridePolicies?: OnyxCollection, + overrideReports?: OnyxCollection, + transactionsUpdate?: OnyxCollection | null, + conciergeReportID?: string, + overrideReportActions?: OnyxCollection>, + ) => [ overrideReports ?? reports, // reports null, // preferredLocale null, // transactionViolations - null, // reportActions + overrideReportActions ?? null, // reportActions null, // reportNameValuePairs transactionsUpdate ?? null, // transactions null, // personalDetails null, // session overridePolicies ?? policies, // policies null, // policyTags - null, // reportViolations - null, // reportMetadata + conciergeReportID ?? null, // conciergeReportID + null, // introSelected ] as unknown as Parameters[0]; 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}, - triggeredKeys: new Set([ONYXKEYS.COLLECTION.POLICY]), + triggeredKeys: triggeredBy(ONYXKEYS.COLLECTION.POLICY), }); expect(result?.reports).toHaveProperty('r1'); expect(result?.reports).toHaveProperty('r2'); }); - it('scopes the first policy load to reports referencing the loaded policies when currentValue is already populated', () => { - // Reproduces the ReconnectApp-after-open case: attributes were already computed, then ~1k policies - // land. Only reports whose policy actually arrived should recompute — not every report. + it('recomputes reports of delivered policies when the stored value has no signature baseline', () => { + // Reproduces the first policy delivery on a value with no baseline (written by an older app + // version, or computed in-session before policies loaded): whether the stored attributes match + // these policies is unknown, so the delivered policies' reports recompute and the full baseline is recorded. const report3: Report = {...createRandomReport(12, undefined), reportID: 'r3', policyID: 'policyOther', chatReportID: undefined}; const reportsWithUnrelated: OnyxCollection = { ...reports, @@ -215,14 +199,83 @@ describe('reportAttributes compute — policy change code flow', () => { const result = config.compute(buildArgs(policies, reportsWithUnrelated), { currentValue: existingValue, sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: policies}, - triggeredKeys: new Set([ONYXKEYS.COLLECTION.POLICY]), + triggeredKeys: triggeredBy(ONYXKEYS.COLLECTION.POLICY), }); - // r1/r2 reference the loaded policies → recomputed (default mock name). + // r1/r2 reference the delivered policies → recomputed (default mock name). expect(result?.reports.r1?.reportName).toBe('Test Report'); expect(result?.reports.r2?.reportName).toBe('Test Report'); + // r3 references a policy that was not delivered → keeps its existing value. + expect(result?.reports.r3?.reportName).toBe('Old Name 3'); + expect(result?.policySignatures).toEqual({ + [`${ONYXKEYS.COLLECTION.POLICY}policy1`]: policyRelevantSignature(policy1), + [`${ONYXKEYS.COLLECTION.POLICY}policy2`]: policyRelevantSignature(policy2), + }); + }); + + it('seeds policy signatures without recomputing on a pass that did not deliver policies', () => { + // Disk-hydrated policies and a disk-restored value are consistent, so a non-policy pass just + // records the baseline; only the report from this pass's own update recomputes. + 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 result = config.compute(buildArgs(), { + currentValue: existingValue, + sourceValues: {[ONYXKEYS.COLLECTION.REPORT]: {[`${ONYXKEYS.COLLECTION.REPORT}r1`]: report1}}, + triggeredKeys: triggeredBy(ONYXKEYS.COLLECTION.REPORT), + }); + + expect(result?.reports.r1?.reportName).toBe('Test Report'); + expect(result?.reports.r2?.reportName).toBe('Old Name 2'); + expect(result?.policySignatures).toEqual({ + [`${ONYXKEYS.COLLECTION.POLICY}policy1`]: policyRelevantSignature(policy1), + [`${ONYXKEYS.COLLECTION.POLICY}policy2`]: policyRelevantSignature(policy2), + }); + }); + + it('scopes a policy delivery to reports whose stored signature differs when currentValue is already populated', () => { + // Reproduces the ReconnectApp-after-restart case: attributes and signatures were persisted, then + // ~1k policies land and some actually changed. Only reports whose policy signature differs should + // recompute — not every report. + const report3: Report = {...createRandomReport(12, undefined), reportID: 'r3', policyID: 'policyOther', chatReportID: undefined}; + const reportsWithUnrelated: OnyxCollection = { + ...reports, + [`${ONYXKEYS.COLLECTION.REPORT}r3`]: report3, + }; + + const stalePolicy1: Policy = {...policy1, approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL}; + 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: {}}, + r3: {reportName: 'Old Name 3', isEmpty: false, brickRoadStatus: undefined, requiresAttention: false, reportErrors: {}}, + }, + locale: null, + policySignatures: { + [`${ONYXKEYS.COLLECTION.POLICY}policy1`]: signatureOf(stalePolicy1), + [`${ONYXKEYS.COLLECTION.POLICY}policy2`]: signatureOf(policy2), + }, + }; + + const result = config.compute(buildArgs(policies, reportsWithUnrelated), { + currentValue: existingValue, + sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: policies}, + triggeredKeys: triggeredBy(ONYXKEYS.COLLECTION.POLICY), + }); + + // r1's policy signature differs from the stored one → recomputed (default mock name). + expect(result?.reports.r1?.reportName).toBe('Test Report'); + // r2's policy matches its stored signature → keeps its existing value. + expect(result?.reports.r2?.reportName).toBe('Old Name 2'); // r3 references a policy that did not load → keeps its existing value (not recomputed). expect(result?.reports.r3?.reportName).toBe('Old Name 3'); + // The changed policy's signature is refreshed in the stored baseline. + expect(result?.policySignatures?.[`${ONYXKEYS.COLLECTION.POLICY}policy1`]).toBe(policyRelevantSignature(policy1)); }); it('recomputes a child invoice report when only its receiver workspace policy loads', () => { @@ -246,19 +299,16 @@ describe('reportAttributes compute — policy change code flow', () => { [`${ONYXKEYS.COLLECTION.REPORT}invoiceChild`]: invoiceChild, }; - // Seed previousPolicies with just the sender policy, as if it arrived in an earlier batch. - config.compute(buildArgs({[`${ONYXKEYS.COLLECTION.POLICY}senderPolicy`]: senderPolicy}, invoiceReports), { - currentValue: undefined, - sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: {[`${ONYXKEYS.COLLECTION.POLICY}senderPolicy`]: senderPolicy}}, - triggeredKeys: new Set([ONYXKEYS.COLLECTION.POLICY]), - }); - + // The stored baseline knows only the sender policy, as if it arrived in an earlier batch. const existingValue: ReportAttributesDerivedValue = { reports: { invoiceRoom: {reportName: 'Old Room', isEmpty: false, brickRoadStatus: undefined, requiresAttention: false, reportErrors: {}}, invoiceChild: {reportName: 'Old Child', isEmpty: false, brickRoadStatus: undefined, requiresAttention: false, reportErrors: {}}, }, locale: null, + policySignatures: { + [`${ONYXKEYS.COLLECTION.POLICY}senderPolicy`]: signatureOf(senderPolicy), + }, }; // The receiver policy now arrives in its own batch. @@ -269,7 +319,7 @@ describe('reportAttributes compute — policy change code flow', () => { const result = config.compute(buildArgs(bothPolicies, invoiceReports), { currentValue: existingValue, sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: {[`${ONYXKEYS.COLLECTION.POLICY}receiverPolicy`]: receiverPolicy}}, - triggeredKeys: new Set([ONYXKEYS.COLLECTION.POLICY]), + triggeredKeys: triggeredBy(ONYXKEYS.COLLECTION.POLICY), }); // Both the room (own invoiceReceiver) and the child (receiver read from its parent room) recompute. @@ -278,13 +328,6 @@ describe('reportAttributes compute — policy change code flow', () => { }); it('only recomputes reports for the changed policy when a tracked field changes', () => { - // Seed previousPolicies by doing an initial compute - config.compute(buildArgs(), { - currentValue: undefined, - sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: policies as never}, - triggeredKeys: new Set([ONYXKEYS.COLLECTION.POLICY]), - }); - const policy1Changed = {...policy1, approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL} as unknown as Policy; const updatedPolicies: OnyxCollection = { ...policies, @@ -297,6 +340,10 @@ describe('reportAttributes compute — policy change code flow', () => { r2: {reportName: 'Old Name 2', isEmpty: false, brickRoadStatus: undefined, requiresAttention: false, reportErrors: {}}, }, locale: null, + policySignatures: { + [`${ONYXKEYS.COLLECTION.POLICY}policy1`]: signatureOf(policy1), + [`${ONYXKEYS.COLLECTION.POLICY}policy2`]: signatureOf(policy2), + }, }; const computeReportNameMock = (jest.requireMock('@libs/ReportNameUtils') as unknown as {computeReportName: jest.Mock}).computeReportName; @@ -305,7 +352,7 @@ 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}, - triggeredKeys: new Set([ONYXKEYS.COLLECTION.POLICY]), + triggeredKeys: triggeredBy(ONYXKEYS.COLLECTION.POLICY), }); // r1 (policy1 changed) should be recomputed with new name @@ -314,18 +361,11 @@ describe('reportAttributes compute — policy change code flow', () => { expect(result?.reports.r2?.reportName).toBe('Old Name 2'); }); - it('skips recompute when a non-tracked policy field changes', () => { - // Seed previousPolicies - config.compute(buildArgs(), { - currentValue: undefined, - sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: policies as never}, - triggeredKeys: new Set([ONYXKEYS.COLLECTION.POLICY]), - }); - - const policy1WithNameChange = {...policy1, name: 'New Policy Name'} as unknown as Policy; + it('skips recompute when only Onyx write-bookkeeping keys change on a policy', () => { + const policy1WithWriteNoise = {...policy1, pendingFields: {generalSettings: 'update'}, errorFields: {generalSettings: {a: 'err'}}} as unknown as Policy; const updatedPolicies: OnyxCollection = { ...policies, - [`${ONYXKEYS.COLLECTION.POLICY}policy1`]: policy1WithNameChange, + [`${ONYXKEYS.COLLECTION.POLICY}policy1`]: policy1WithWriteNoise, }; const existingValue: ReportAttributesDerivedValue = { @@ -334,18 +374,239 @@ describe('reportAttributes compute — policy change code flow', () => { r2: {reportName: 'Existing r2', isEmpty: false, brickRoadStatus: undefined, requiresAttention: false, reportErrors: {}}, }, locale: null, + policySignatures: { + [`${ONYXKEYS.COLLECTION.POLICY}policy1`]: signatureOf(policy1), + [`${ONYXKEYS.COLLECTION.POLICY}policy2`]: signatureOf(policy2), + }, + conciergeReportID: '', + isTrackIntentUser: false, }; const result = config.compute(buildArgs(updatedPolicies), { currentValue: existingValue, - sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: {[`${ONYXKEYS.COLLECTION.POLICY}policy1`]: policy1WithNameChange} as never}, - triggeredKeys: new Set([ONYXKEYS.COLLECTION.POLICY]), + sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: {[`${ONYXKEYS.COLLECTION.POLICY}policy1`]: policy1WithWriteNoise} as never}, + triggeredKeys: triggeredBy(ONYXKEYS.COLLECTION.POLICY), }); - // No tracked fields changed → return currentValue unchanged + // No content change → return currentValue unchanged expect(result).toEqual(existingValue); }); + it('recomputes reports of a policy whose name changes', () => { + const policy1Renamed = {...policy1, name: 'Renamed Workspace'} as unknown as Policy; + const updatedPolicies: OnyxCollection = { + ...policies, + [`${ONYXKEYS.COLLECTION.POLICY}policy1`]: policy1Renamed, + }; + + 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, + policySignatures: { + [`${ONYXKEYS.COLLECTION.POLICY}policy1`]: signatureOf(policy1), + [`${ONYXKEYS.COLLECTION.POLICY}policy2`]: signatureOf(policy2), + }, + }; + + const result = config.compute(buildArgs(updatedPolicies), { + currentValue: existingValue, + sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: {[`${ONYXKEYS.COLLECTION.POLICY}policy1`]: policy1Renamed} as never}, + triggeredKeys: triggeredBy(ONYXKEYS.COLLECTION.POLICY), + }); + + // Report names embed the workspace name, so a rename recomputes that policy's reports. + expect(result?.reports.r1?.reportName).toBe('Test Report'); + expect(result?.reports.r2?.reportName).toBe('Old Name 2'); + expect(result?.policySignatures?.[`${ONYXKEYS.COLLECTION.POLICY}policy1`]).toBe(signatureOf(policy1Renamed)); + }); + + it('keeps the concierge baseline on passes that did not recompute everything', () => { + 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, + policySignatures: { + [`${ONYXKEYS.COLLECTION.POLICY}policy1`]: signatureOf(policy1), + [`${ONYXKEYS.COLLECTION.POLICY}policy2`]: signatureOf(policy2), + }, + conciergeReportID: 'conciergeOld', + isTrackIntentUser: false, + }; + + // An incremental pass while the current conciergeReportID already drifted (it changed while + // computes were deferred): the baseline must NOT advance, or the change would be absorbed. + const incrementalResult = config.compute(buildArgs(policies, undefined, null, 'conciergeNew'), { + currentValue: existingValue, + sourceValues: {[ONYXKEYS.COLLECTION.REPORT]: {[`${ONYXKEYS.COLLECTION.REPORT}r1`]: report1}}, + triggeredKeys: triggeredBy(ONYXKEYS.COLLECTION.REPORT), + }); + expect(incrementalResult?.conciergeReportID).toBe('conciergeOld'); + expect(incrementalResult?.reports.r2?.reportName).toBe('Old Name 2'); + + // The next delivery of the key compares against the preserved baseline → full recompute + advance. + const deliveryResult = config.compute(buildArgs(policies, undefined, null, 'conciergeNew'), { + currentValue: incrementalResult, + sourceValues: {[ONYXKEYS.CONCIERGE_REPORT_ID]: 'conciergeNew' as never}, + triggeredKeys: triggeredBy(ONYXKEYS.CONCIERGE_REPORT_ID), + }); + expect(deliveryResult?.conciergeReportID).toBe('conciergeNew'); + expect(deliveryResult?.reports.r2?.reportName).toBe('Test Report'); + }); + + it('recomputes and advances a missing baseline on a lone CONCIERGE_REPORT_ID delivery', () => { + // Old-format persisted value: reports/policySignatures exist, but conciergeReportID/isTrackIntentUser + // were never stored (written before these fields existed). The first delivery of one of these keys is + // the only chance to reconcile names that may have been computed with a different value, so a missing + // baseline counts as a change and forces a full recompute instead of being silently absorbed. + 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, + policySignatures: { + [`${ONYXKEYS.COLLECTION.POLICY}policy1`]: signatureOf(policy1), + [`${ONYXKEYS.COLLECTION.POLICY}policy2`]: signatureOf(policy2), + }, + }; + + const result = config.compute(buildArgs(policies, undefined, null, 'conciergeNew'), { + currentValue: existingValue, + sourceValues: {[ONYXKEYS.CONCIERGE_REPORT_ID]: 'conciergeNew' as never}, + triggeredKeys: triggeredBy(ONYXKEYS.CONCIERGE_REPORT_ID), + }); + + // The delivery pass recomputes every report and advances the baseline. + expect(result?.conciergeReportID).toBe('conciergeNew'); + expect(result?.isTrackIntentUser).toBe(false); + expect(result?.reports.r1?.reportName).toBe('Test Report'); + expect(result?.reports.r2?.reportName).toBe('Test Report'); + }); + + it('seeds a missing conciergeReportID/isTrackIntentUser baseline without recomputing on a pass that did not deliver the key', () => { + // Old-format persisted value on a pass whose only update is an unrelated report: the attributes and the + // delivered value both come from the same disk-hydrated state, so the missing baseline is seeded and + // only this pass's own report recomputes — the concierge-dependent names are left untouched. + 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, + policySignatures: { + [`${ONYXKEYS.COLLECTION.POLICY}policy1`]: signatureOf(policy1), + [`${ONYXKEYS.COLLECTION.POLICY}policy2`]: signatureOf(policy2), + }, + }; + + const result = config.compute(buildArgs(policies, undefined, null, 'conciergeNew'), { + currentValue: existingValue, + sourceValues: {[ONYXKEYS.COLLECTION.REPORT]: {[`${ONYXKEYS.COLLECTION.REPORT}r1`]: report1}}, + triggeredKeys: triggeredBy(ONYXKEYS.COLLECTION.REPORT), + }); + + // Seeded from this non-delivery pass alone, and only r1 (this pass's update) recomputes. + expect(result?.conciergeReportID).toBe('conciergeNew'); + expect(result?.isTrackIntentUser).toBe(false); + expect(result?.reports.r1?.reportName).toBe('Test Report'); + expect(result?.reports.r2?.reportName).toBe('Old Name 2'); + }); + + it('does not persist advanced policy signatures when the reports collection is unavailable', () => { + const policy1Changed = {...policy1, approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL} 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: {}}, + }, + locale: null, + policySignatures: { + [`${ONYXKEYS.COLLECTION.POLICY}policy1`]: signatureOf(policy1), + [`${ONYXKEYS.COLLECTION.POLICY}policy2`]: signatureOf(policy2), + }, + }; + + const argsWithoutReports = [ + undefined, // reports + null, // preferredLocale + null, // transactionViolations + null, // reportActions + null, // reportNameValuePairs + null, // transactions + null, // personalDetails + null, // session + updatedPolicies, // policies + null, // policyTags + null, // conciergeReportID + null, // introSelected + ] as unknown as Parameters[0]; + + const result = config.compute(argsWithoutReports, { + currentValue: existingValue, + sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: {[`${ONYXKEYS.COLLECTION.POLICY}policy1`]: policy1Changed} as never}, + triggeredKeys: triggeredBy(ONYXKEYS.COLLECTION.POLICY), + }); + + // The scoped recompute could not run, so the old baseline must survive — the next delivery + // re-diffs against it and retries the recompute. + expect(result?.policySignatures?.[`${ONYXKEYS.COLLECTION.POLICY}policy1`]).toBe(signatureOf(policy1)); + }); + + it('persists advanced signatures without recompute when the changed policy has no reports', () => { + const policyNoReports: Policy = {...basePolicy, id: 'policyNoReports'}; + const policyNoReportsChanged: Policy = {...policyNoReports, approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL}; + const policiesWithExtra: OnyxCollection = { + ...policies, + [`${ONYXKEYS.COLLECTION.POLICY}policyNoReports`]: policyNoReportsChanged, + }; + 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, + policySignatures: { + [`${ONYXKEYS.COLLECTION.POLICY}policy1`]: signatureOf(policy1), + [`${ONYXKEYS.COLLECTION.POLICY}policy2`]: signatureOf(policy2), + [`${ONYXKEYS.COLLECTION.POLICY}policyNoReports`]: signatureOf(policyNoReports), + }, + }; + + const result = config.compute(buildArgs(policiesWithExtra), { + currentValue: existingValue, + sourceValues: {[ONYXKEYS.COLLECTION.POLICY]: {[`${ONYXKEYS.COLLECTION.POLICY}policyNoReports`]: policyNoReportsChanged} as never}, + triggeredKeys: triggeredBy(ONYXKEYS.COLLECTION.POLICY), + }); + + // No report references the changed policy, so the recompute is vacuous — nothing recomputes, + // but the advanced signature persists so the same delivery is not re-diffed forever. + expect(result?.reports.r1?.reportName).toBe('Old Name 1'); + expect(result?.reports.r2?.reportName).toBe('Old Name 2'); + expect(result?.policySignatures?.[`${ONYXKEYS.COLLECTION.POLICY}policyNoReports`]).toBe(signatureOf(policyNoReportsChanged)); + }); + + it('snapshots the policy baseline after a full scan even without a policy trigger', () => { + const result = config.compute(buildArgs(), { + currentValue: undefined, + sourceValues: {[ONYXKEYS.COLLECTION.REPORT]: {[`${ONYXKEYS.COLLECTION.REPORT}r1`]: report1}}, + triggeredKeys: triggeredBy(ONYXKEYS.COLLECTION.REPORT), + }); + + expect(result?.reports.r1?.reportName).toBe('Test Report'); + expect(result?.policySignatures).toEqual({ + [`${ONYXKEYS.COLLECTION.POLICY}policy1`]: signatureOf(policy1), + [`${ONYXKEYS.COLLECTION.POLICY}policy2`]: signatureOf(policy2), + }); + }); + 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}; @@ -372,6 +633,7 @@ describe('reportAttributes compute — policy change code flow', () => { const result = config.compute(args, { currentValue: existingValue, sourceValues: {[ONYXKEYS.COLLECTION.TRANSACTION]: transactionsUpdate}, + triggeredKeys: triggeredBy(ONYXKEYS.COLLECTION.TRANSACTION), }); // The expense report is recomputed, and its parent workspace chat (where the to-do/GBR render) is too, @@ -379,4 +641,171 @@ describe('reportAttributes compute — policy change code flow', () => { expect(result?.reports.expense1?.reportName).toBe('Test Report'); expect(result?.reports.chat1?.reportName).toBe('Test Report'); }); + + describe('parent chat error propagation — skipped when nothing error-relevant changed', () => { + // The outer beforeEach calls jest.resetModules() before re-requiring the config, which replaces + // this mock with a fresh instance — so the reference must be re-fetched per test, not captured once. + const getReasonMock = () => + jest.requireMock<{default: {getReasonAndReportActionThatHasRedBrickRoad: jest.Mock}}>('@libs/SidebarUtils').default.getReasonAndReportActionThatHasRedBrickRoad; + + it('still propagates a new error to the parent chat when a child gains one during an incremental update', () => { + // parentReportID/parentReportActionID point at a real (non-deleted) preview action — the + // isDeletedAction guard treats a report with no resolvable parent action as deleted and skips + // propagation for it, so the fixture needs one to actually exercise the propagation path below. + const expenseReport: Report = { + ...createRandomReport(10, undefined), + reportID: 'expense1', + policyID: 'policy3', + chatReportID: 'chat1', + parentReportID: 'chat1', + parentReportActionID: 'previewAction1', + }; + const chatReport: Report = {...createRandomReport(11, CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT), reportID: 'chat1', policyID: 'policy3', chatReportID: undefined}; + const reportsWithChat: OnyxCollection = { + ...reports, + [`${ONYXKEYS.COLLECTION.REPORT}expense1`]: expenseReport, + [`${ONYXKEYS.COLLECTION.REPORT}chat1`]: chatReport, + }; + const reportActions = {[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}chat1`]: {previewAction1: createRandomReportAction(1)}}; + + const existingValue: ReportAttributesDerivedValue = { + reports: { + expense1: {reportName: 'Old expense name', isEmpty: false, brickRoadStatus: undefined, requiresAttention: false, reportErrors: {}}, + chat1: {reportName: 'Old chat name', isEmpty: false, brickRoadStatus: undefined, requiresAttention: false, reportErrors: {}}, + }, + locale: null, + }; + + // Only the expense report now has an RBR reason. + getReasonMock().mockImplementation((report: Report) => (report.reportID === 'expense1' ? {reportAction: undefined} : undefined)); + + const transactionsUpdate: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}tx1`]: {...createRandomTransaction(1), transactionID: 'tx1', reportID: 'expense1'}, + }; + + const args = buildArgs(undefined, reportsWithChat, transactionsUpdate, undefined, reportActions); + const result = config.compute(args, { + currentValue: existingValue, + sourceValues: {[ONYXKEYS.COLLECTION.TRANSACTION]: transactionsUpdate}, + triggeredKeys: triggeredBy(ONYXKEYS.COLLECTION.TRANSACTION), + }); + + // Skipping the propagation scan must not prevent a genuinely new error from reaching the parent. + expect(result?.reports.chat1?.brickRoadStatus).toBe(CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR); + expect(result?.reports.chat1?.actionBadge).toBe(CONST.REPORT.ACTION_BADGE.FIX); + }); + + it('clears a stale parent error once the only erroring child stops erroring', () => { + // A real (non-deleted) parent action, so the assertion below reflects needsViolationFix + // re-evaluating to false rather than the isDeletedAction guard skipping expense1 outright. + const expenseReport: Report = { + ...createRandomReport(10, undefined), + reportID: 'expense1', + policyID: 'policy3', + chatReportID: 'chat1', + parentReportID: 'chat1', + parentReportActionID: 'previewAction1', + }; + const chatReport: Report = {...createRandomReport(11, CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT), reportID: 'chat1', policyID: 'policy3', chatReportID: undefined}; + const reportsWithChat: OnyxCollection = { + ...reports, + [`${ONYXKEYS.COLLECTION.REPORT}expense1`]: expenseReport, + [`${ONYXKEYS.COLLECTION.REPORT}chat1`]: chatReport, + }; + const reportActions = {[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}chat1`]: {previewAction1: createRandomReportAction(1)}}; + + // Simulates the state left behind by a previous pass where expense1 was erroring and had + // already propagated ERROR up to chat1. + const existingValue: ReportAttributesDerivedValue = { + reports: { + expense1: { + reportName: 'Old expense name', + isEmpty: false, + brickRoadStatus: CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR, + requiresAttention: false, + reportErrors: {}, + needsParentChatErrorPropagation: true, + }, + chat1: { + reportName: 'Old chat name', + isEmpty: false, + brickRoadStatus: CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR, + actionBadge: CONST.REPORT.ACTION_BADGE.FIX, + requiresAttention: false, + reportErrors: {}, + }, + }, + locale: null, + }; + + // getReasonMock defaults to undefined (no RBR reason) for this pass — the violation is resolved. + const transactionsUpdate: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}tx1`]: {...createRandomTransaction(1), transactionID: 'tx1', reportID: 'expense1'}, + }; + + const args = buildArgs(undefined, reportsWithChat, transactionsUpdate, undefined, reportActions); + const result = config.compute(args, { + currentValue: existingValue, + sourceValues: {[ONYXKEYS.COLLECTION.TRANSACTION]: transactionsUpdate}, + triggeredKeys: triggeredBy(ONYXKEYS.COLLECTION.TRANSACTION), + }); + + // expense1 losing its error must be detected (previous vs next differ) so the propagation loop + // still runs and correctly clears the now-stale ERROR badge on chat1. + expect(result?.reports.expense1?.brickRoadStatus).toBeUndefined(); + expect(result?.reports.chat1?.brickRoadStatus).toBeUndefined(); + }); + + it('preserves a previously-propagated parent error when an unrelated 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}; + const unrelatedReport: Report = {...createRandomReport(12, undefined), reportID: 'unrelated1', policyID: 'policy4', chatReportID: undefined}; + const reportsWithChat: OnyxCollection = { + ...reports, + [`${ONYXKEYS.COLLECTION.REPORT}expense1`]: expenseReport, + [`${ONYXKEYS.COLLECTION.REPORT}chat1`]: chatReport, + [`${ONYXKEYS.COLLECTION.REPORT}unrelated1`]: unrelatedReport, + }; + + const existingValue: ReportAttributesDerivedValue = { + reports: { + expense1: { + reportName: 'Old expense name', + isEmpty: false, + brickRoadStatus: CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR, + requiresAttention: false, + reportErrors: {}, + needsParentChatErrorPropagation: true, + }, + chat1: { + reportName: 'Old chat name', + isEmpty: false, + brickRoadStatus: CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR, + actionBadge: CONST.REPORT.ACTION_BADGE.FIX, + requiresAttention: false, + reportErrors: {}, + }, + unrelated1: {reportName: 'Old unrelated name', isEmpty: false, brickRoadStatus: undefined, requiresAttention: false, reportErrors: {}}, + }, + locale: null, + }; + + // expense1 would still be erroring if re-evaluated, but this pass only touches unrelated1, + // so expense1/chat1 should never even be re-examined. + getReasonMock().mockImplementation((report: Report) => (report.reportID === 'expense1' ? {reportAction: undefined} : undefined)); + + const args = buildArgs(undefined, reportsWithChat); + const result = config.compute(args, { + currentValue: existingValue, + sourceValues: {[ONYXKEYS.COLLECTION.REPORT]: {[`${ONYXKEYS.COLLECTION.REPORT}unrelated1`]: unrelatedReport}}, + triggeredKeys: triggeredBy(ONYXKEYS.COLLECTION.REPORT), + }); + + // The unrelated report is recomputed, and the untouched chat1/expense1 keep their prior + // propagated ERROR status exactly as it was, confirming the skip didn't drop it. + expect(result?.reports.unrelated1?.reportName).toBe('Test Report'); + expect(result?.reports.chat1?.brickRoadStatus).toBe(CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR); + expect(result?.reports.expense1?.brickRoadStatus).toBe(CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR); + }); + }); });