diff --git a/src/libs/actions/OnyxDerived/configs/reportTransactionsAndViolations.ts b/src/libs/actions/OnyxDerived/configs/reportTransactionsAndViolations.ts index 9f7af296d060..cdc06a2c3042 100644 --- a/src/libs/actions/OnyxDerived/configs/reportTransactionsAndViolations.ts +++ b/src/libs/actions/OnyxDerived/configs/reportTransactionsAndViolations.ts @@ -8,31 +8,44 @@ import type {OnyxCollection} from 'react-native-onyx'; let previousViolations: OnyxCollection = {}; const transactionReportIDMapping: Record = {}; -const transactionToReportIDMap: Record = {}; +const getTransactionKeyFromViolationKey = (violationKey: string) => violationKey.replace(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, ONYXKEYS.COLLECTION.TRANSACTION); export default createOnyxDerivedValueConfig({ key: ONYXKEYS.DERIVED.REPORT_TRANSACTIONS_AND_VIOLATIONS, dependencies: [ONYXKEYS.COLLECTION.TRANSACTION, ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS], - compute: ([transactions, violations], {sourceValues, currentValue}) => { - if (!transactions) { - return {}; - } + compute: ([transactions, violations], context) => { + const {sourceValues, currentValue} = context; // If there is a source value for transactions or transaction violations, we need to process only the transactions that have been updated or added // If not, we need to process all transactions const transactionsUpdates = sourceValues?.[ONYXKEYS.COLLECTION.TRANSACTION]; const transactionViolationsUpdates = sourceValues?.[ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS]; + const isPartialUpdate = !!transactionsUpdates || !!transactionViolationsUpdates; + // Full recomputes should rebuild from the transaction source so stale derived buckets or deleted transactions are not carried forward. + // Partial updates still start from currentValue so violation-only refreshes can preserve report membership when this tab has an incomplete transaction snapshot. + const reportTransactionsAndViolations = isPartialUpdate && currentValue ? {...currentValue} : {}; + + if (context.isInitialDependencyLoad && currentValue) { + previousViolations = violations; + context.shouldSkipUpdate = true; + return currentValue; + } + + if (!transactions) { + if (transactionViolationsUpdates) { + context.shouldSkipUpdate = true; + return reportTransactionsAndViolations; + } + return {}; + } + let transactionsToProcess = Object.keys(transactions); if (transactionsUpdates) { transactionsToProcess = Object.keys(transactionsUpdates); } else if (transactionViolationsUpdates) { - transactionsToProcess = Object.keys(transactionViolationsUpdates).map((transactionViolation) => - transactionViolation.replace(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, ONYXKEYS.COLLECTION.TRANSACTION), - ); + transactionsToProcess = Object.keys(transactionViolationsUpdates).map(getTransactionKeyFromViolationKey); } - const reportTransactionsAndViolations = currentValue ? {...currentValue} : {}; - // Track which reportID entries have been cloned so we only clone once per reportID. // This avoids mutating nested objects that are still referenced by the cached value. const clonedReportIDs = new Set(); @@ -48,28 +61,67 @@ export default createOnyxDerivedValueConfig({ clonedReportIDs.add(id); }; + const getPreviousReportID = (transactionKey: string) => + transactionReportIDMapping[transactionKey] ?? + Object.keys(reportTransactionsAndViolations).find((reportID) => !!reportTransactionsAndViolations[reportID].transactions[transactionKey]); + + // Empty buckets carry no derived data and can make deleted reports appear again. + const deleteReportIfEmpty = (reportID: string | undefined) => { + if (!reportID || !reportTransactionsAndViolations[reportID]) { + return; + } + + if (Object.keys(reportTransactionsAndViolations[reportID].transactions).length > 0 || Object.keys(reportTransactionsAndViolations[reportID].violations).length > 0) { + return; + } + + delete reportTransactionsAndViolations[reportID]; + clonedReportIDs.delete(reportID); + }; + + if (!isPartialUpdate) { + for (const transactionKey of Object.keys(transactionReportIDMapping)) { + delete transactionReportIDMapping[transactionKey]; + } + } else { + for (const reportID of Object.keys(reportTransactionsAndViolations)) { + deleteReportIfEmpty(reportID); + } + } + + if (!transactionsUpdates && transactionViolationsUpdates) { + transactionsToProcess = transactionsToProcess.filter((transactionKey) => { + return !!transactions[transactionKey]; + }); + + if (transactionsToProcess.length === 0) { + context.shouldSkipUpdate = true; + return reportTransactionsAndViolations; + } + } + for (const transactionKey of transactionsToProcess) { + // If the reportID of the transaction has changed (e.g. the transaction was split into multiple reports), we need to delete the transaction from the previous reportID and the violations from the previous reportID + const previousReportID = getPreviousReportID(transactionKey); + const transactionWasUpdated = !!transactionsUpdates; const transaction = transactions[transactionKey]; const reportID = transaction?.reportID; - // If the reportID of the transaction has changed (e.g. the transaction was split into multiple reports), we need to delete the transaction from the previous reportID and the violations from the previous reportID - const previousReportID = transactionReportIDMapping[transactionKey]; - - if (previousReportID && previousReportID !== reportID && reportTransactionsAndViolations[previousReportID]) { + if (transactionWasUpdated && previousReportID && previousReportID !== reportID && reportTransactionsAndViolations[previousReportID]) { ensureCloned(previousReportID); delete reportTransactionsAndViolations[previousReportID].transactions[transactionKey]; const transactionID = transactionKey.replace(ONYXKEYS.COLLECTION.TRANSACTION, ''); if (transactionID) { delete reportTransactionsAndViolations[previousReportID].violations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`]; } + deleteReportIfEmpty(previousReportID); } - if (!transaction && transactionReportIDMapping[transactionKey]) { + if (transactionWasUpdated && !transaction && transactionReportIDMapping[transactionKey]) { delete transactionReportIDMapping[transactionKey]; } if (!reportID) { - delete transactionToReportIDMap[transactionKey]; continue; } @@ -89,11 +141,12 @@ export default createOnyxDerivedValueConfig({ const previousTransactionViolations = previousViolations?.[violationKey]; const violationInSourceValues = transactionViolationsUpdates?.[violationKey]; + const hasExplicitViolationClear = Array.isArray(violationInSourceValues) && violationInSourceValues.length === 0; // If violations exist and have length > 0, add them to the structure if (transactionViolations && transactionViolations.length > 0) { reportTransactionsAndViolations[reportID].violations[violationKey] = transactionViolations; - } else if (violationInSourceValues === undefined || (previousTransactionViolations && previousTransactionViolations.length > 0)) { + } else if (violationInSourceValues === undefined || hasExplicitViolationClear || (previousTransactionViolations && previousTransactionViolations.length > 0)) { // If violations were removed (previous had violations but current doesn't) or explicitly set to undefined, remove them from the structure delete reportTransactionsAndViolations[reportID].violations[violationKey]; } @@ -104,6 +157,10 @@ export default createOnyxDerivedValueConfig({ previousViolations = violations; + for (const reportID of Object.keys(reportTransactionsAndViolations)) { + deleteReportIfEmpty(reportID); + } + return reportTransactionsAndViolations; }, }); diff --git a/src/libs/actions/OnyxDerived/index.ts b/src/libs/actions/OnyxDerived/index.ts index ecd0764882e4..cb7867f84581 100644 --- a/src/libs/actions/OnyxDerived/index.ts +++ b/src/libs/actions/OnyxDerived/index.ts @@ -39,6 +39,14 @@ function init() { OnyxUtils.get(key).then((storedDerivedValue) => { let derivedValue = storedDerivedValue; + let hasSyncedDerivedValueFromOnyx = key !== ONYXKEYS.DERIVED.REPORT_TRANSACTIONS_AND_VIOLATIONS; + let pendingRecomputeAfterDerivedValueSync: + | { + sourceKey?: string; + sourceValue?: unknown; + triggeredByIndex?: number; + } + | undefined; if (derivedValue) { Log.info(`Derived value for ${key} restored from disk`); } @@ -80,8 +88,20 @@ function init() { return; } + if (!hasSyncedDerivedValueFromOnyx) { + pendingRecomputeAfterDerivedValueSync = { + sourceKey, + sourceValue, + triggeredByIndex, + }; + Log.info(`[OnyxDerived] waiting for current value sync before recomputing ${key}`); + return; + } + context.currentValue = derivedValue; context.sourceValues = sourceKey && sourceValue !== undefined ? {[sourceKey]: sourceValue} : undefined; + context.isInitialDependencyLoad = sourceKey !== undefined && sourceValue === undefined && triggeredByIndex !== undefined; + context.shouldSkipUpdate = false; const spanId = `${CONST.TELEMETRY.SPAN_ONYX_DERIVED_COMPUTE}_${key}`; startSpan(spanId, { @@ -94,6 +114,10 @@ function init() { try { // @ts-expect-error TypeScript can't confirm the shape of dependencyValues matches the compute function's parameters const newDerivedValue = compute(dependencyValues, context); + if (context.shouldSkipUpdate) { + Log.info(`[OnyxDerived] skipping update for ${key}`); + return; + } Log.info(`[OnyxDerived] updating value for ${key} in Onyx`); derivedValue = newDerivedValue; setDerivedValue(key, derivedValue); @@ -102,6 +126,24 @@ function init() { } }; + if (key === ONYXKEYS.DERIVED.REPORT_TRANSACTIONS_AND_VIOLATIONS) { + Onyx.connectWithoutView({ + key, + callback: (value) => { + derivedValue = value; + hasSyncedDerivedValueFromOnyx = true; + + if (!pendingRecomputeAfterDerivedValueSync) { + return; + } + + const {sourceKey, sourceValue, triggeredByIndex} = pendingRecomputeAfterDerivedValueSync; + pendingRecomputeAfterDerivedValueSync = undefined; + recomputeDerivedValue(sourceKey, sourceValue, triggeredByIndex); + }, + }); + } + for (let i = 0; i < dependencies.length; i++) { const dependencyIndex = i; const dependencyOnyxKey = dependencies[dependencyIndex]; diff --git a/src/libs/actions/OnyxDerived/types.ts b/src/libs/actions/OnyxDerived/types.ts index f2d0a57a05b7..9722a72a192c 100644 --- a/src/libs/actions/OnyxDerived/types.ts +++ b/src/libs/actions/OnyxDerived/types.ts @@ -17,6 +17,8 @@ type DerivedSourceValues = Partial<{ type DerivedValueContext>> = { currentValue?: OnyxValue; sourceValues?: DerivedSourceValues; + isInitialDependencyLoad?: boolean; + shouldSkipUpdate?: boolean; }; /** diff --git a/tests/unit/reportTransactionsAndViolations.test.ts b/tests/unit/reportTransactionsAndViolations.test.ts new file mode 100644 index 000000000000..041f15474066 --- /dev/null +++ b/tests/unit/reportTransactionsAndViolations.test.ts @@ -0,0 +1,289 @@ +import reportTransactionsAndViolationsConfig from '@libs/actions/OnyxDerived/configs/reportTransactionsAndViolations'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Transaction, TransactionViolation} from '@src/types/onyx'; + +describe('reportTransactionsAndViolations derived value', () => { + it('keeps the existing transaction when only transaction violations are updated', () => { + const reportID = '91016'; + const transactionID = '91016-transaction'; + const transactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}` as const; + const violationKey = `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}` as const; + const transaction: Transaction = { + transactionID, + reportID, + amount: 8700, + currency: CONST.CURRENCY.EUR, + merchant: 'Merchant', + created: '2026-06-10', + }; + const violation = { + name: CONST.VIOLATIONS.CATEGORY_OUT_OF_POLICY, + type: CONST.VIOLATION_TYPES.VIOLATION, + showInReview: true, + } as TransactionViolation; + + const currentValue = reportTransactionsAndViolationsConfig.compute([{[transactionKey]: transaction}, {}], {currentValue: undefined, sourceValues: undefined}); + const result = reportTransactionsAndViolationsConfig.compute([{[transactionKey]: transaction}, {[violationKey]: [violation]}], { + currentValue, + sourceValues: { + [ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS]: {[violationKey]: [violation]}, + }, + }); + + expect(result[reportID]?.transactions[transactionKey]).toBe(transaction); + expect(result[reportID]?.violations[violationKey]).toEqual([violation]); + }); + + it('still removes the existing transaction when the transaction collection sends a delete update', () => { + const reportID = '91016-delete'; + const transactionID = '91016-delete-transaction'; + const transactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}` as const; + const transaction: Transaction = { + transactionID, + reportID, + amount: 8700, + currency: CONST.CURRENCY.EUR, + merchant: 'Merchant', + created: '2026-06-10', + }; + + const currentValue = reportTransactionsAndViolationsConfig.compute([{[transactionKey]: transaction}, {}], {currentValue: undefined, sourceValues: undefined}); + const result = reportTransactionsAndViolationsConfig.compute([{[transactionKey]: undefined}, {}], { + currentValue, + sourceValues: { + [ONYXKEYS.COLLECTION.TRANSACTION]: {[transactionKey]: undefined}, + }, + }); + + expect(result[reportID]?.transactions[transactionKey]).toBeUndefined(); + }); + + it('removes the report bucket when the last transaction is deleted', () => { + const reportID = '91016-empty-report'; + const transactionID = '91016-empty-report-transaction'; + const transactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}` as const; + const transaction: Transaction = { + transactionID, + reportID, + amount: 8700, + currency: CONST.CURRENCY.EUR, + merchant: 'Merchant', + created: '2026-06-10', + }; + + const currentValue = reportTransactionsAndViolationsConfig.compute([{[transactionKey]: transaction}, {}], {currentValue: undefined, sourceValues: undefined}); + const result = reportTransactionsAndViolationsConfig.compute([{[transactionKey]: undefined}, {}], { + currentValue, + sourceValues: { + [ONYXKEYS.COLLECTION.TRANSACTION]: {[transactionKey]: undefined}, + }, + }); + + expect(result[reportID]).toBeUndefined(); + }); + + it('does not carry empty currentValue buckets into a full recompute', () => { + const staleReportID = '91016-stale-empty-report'; + const reportID = '91016-full-recompute'; + const transactionID = '91016-full-recompute-transaction'; + const transactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}` as const; + const transaction: Transaction = { + transactionID, + reportID, + amount: 8700, + currency: CONST.CURRENCY.EUR, + merchant: 'Merchant', + created: '2026-06-10', + }; + + const result = reportTransactionsAndViolationsConfig.compute([{[transactionKey]: transaction}, {}], { + currentValue: { + [staleReportID]: { + transactions: {}, + violations: {}, + }, + }, + sourceValues: undefined, + }); + + expect(result[staleReportID]).toBeUndefined(); + expect(result[reportID]?.transactions[transactionKey]).toBe(transaction); + }); + + it('does not carry deleted transactions from currentValue into a full recompute', () => { + const reportID = '91016-non-empty-report'; + const deletedTransactionID = '91016-deleted-transaction'; + const remainingTransactionID = '91016-remaining-transaction'; + const deletedTransactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${deletedTransactionID}` as const; + const remainingTransactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${remainingTransactionID}` as const; + const deletedTransaction: Transaction = { + transactionID: deletedTransactionID, + reportID, + amount: 8700, + currency: CONST.CURRENCY.EUR, + merchant: 'Deleted merchant', + created: '2026-06-10', + }; + const remainingTransaction: Transaction = { + transactionID: remainingTransactionID, + reportID, + amount: 4200, + currency: CONST.CURRENCY.EUR, + merchant: 'Remaining merchant', + created: '2026-06-10', + }; + + const result = reportTransactionsAndViolationsConfig.compute([{[remainingTransactionKey]: remainingTransaction}, {}], { + currentValue: { + [reportID]: { + transactions: { + [deletedTransactionKey]: deletedTransaction, + [remainingTransactionKey]: remainingTransaction, + }, + violations: {}, + }, + }, + sourceValues: undefined, + }); + + expect(result[reportID]?.transactions[deletedTransactionKey]).toBeUndefined(); + expect(result[reportID]?.transactions[remainingTransactionKey]).toBe(remainingTransaction); + }); + + it('skips violation-only updates when the affected transaction is unavailable', () => { + const transactionID = '91016-missing-transaction'; + const violationKey = `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}` as const; + const violation = { + name: CONST.VIOLATIONS.CATEGORY_OUT_OF_POLICY, + type: CONST.VIOLATION_TYPES.VIOLATION, + showInReview: true, + } as TransactionViolation; + const context = { + currentValue: {}, + sourceValues: { + [ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS]: {[violationKey]: [violation]}, + }, + shouldSkipUpdate: false, + }; + + const result = reportTransactionsAndViolationsConfig.compute([{}, {[violationKey]: [violation]}], context); + + expect(result).toEqual({}); + expect(context.shouldSkipUpdate).toBe(true); + }); + + it('skips violation-only updates when the affected transaction only exists in currentValue', () => { + const reportID = '91016-current-value-only'; + const transactionID = '91016-current-value-only-transaction'; + const transactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}` as const; + const violationKey = `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}` as const; + const transaction: Transaction = { + transactionID, + reportID, + amount: 8700, + currency: CONST.CURRENCY.EUR, + merchant: 'Merchant', + created: '2026-06-10', + }; + const violation = { + name: CONST.VIOLATIONS.CATEGORY_OUT_OF_POLICY, + type: CONST.VIOLATION_TYPES.VIOLATION, + showInReview: true, + } as TransactionViolation; + const currentValue = reportTransactionsAndViolationsConfig.compute([{[transactionKey]: transaction}, {}], {currentValue: undefined, sourceValues: undefined}); + const context = { + currentValue, + sourceValues: { + [ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS]: {[violationKey]: [violation]}, + }, + shouldSkipUpdate: false, + }; + + const result = reportTransactionsAndViolationsConfig.compute([{}, {[violationKey]: [violation]}], context); + + expect(result).toEqual(currentValue); + expect(context.shouldSkipUpdate).toBe(true); + }); + + it('skips initial dependency loads when a current derived value already exists', () => { + const staleReportID = '91016-stale-initial-load'; + const currentReportID = '91016-current-initial-load'; + const staleTransactionID = '91016-stale-initial-load-transaction'; + const currentTransactionID = '91016-current-initial-load-transaction'; + const staleTransactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${staleTransactionID}` as const; + const currentTransactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${currentTransactionID}` as const; + const staleTransaction: Transaction = { + transactionID: staleTransactionID, + reportID: staleReportID, + amount: 8700, + currency: CONST.CURRENCY.EUR, + merchant: 'Stale merchant', + created: '2026-06-10', + }; + const currentTransaction: Transaction = { + transactionID: currentTransactionID, + reportID: currentReportID, + amount: 4200, + currency: CONST.CURRENCY.EUR, + merchant: 'Current merchant', + created: '2026-06-10', + }; + const currentValue = reportTransactionsAndViolationsConfig.compute([{[currentTransactionKey]: currentTransaction}, {}], {currentValue: undefined, sourceValues: undefined}); + const context = { + currentValue, + sourceValues: undefined, + isInitialDependencyLoad: true, + shouldSkipUpdate: false, + }; + + const result = reportTransactionsAndViolationsConfig.compute([{[staleTransactionKey]: staleTransaction}, {}], context); + + expect(result).toEqual(currentValue); + expect(context.shouldSkipUpdate).toBe(true); + }); + + it('applies resolvable violation updates when the same batch has unresolved transactions', () => { + const reportID = '91016-mixed-batch'; + const transactionID = '91016-visible-transaction'; + const missingTransactionID = '91016-missing-transaction'; + const transactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}` as const; + const violationKey = `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}` as const; + const missingViolationKey = `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${missingTransactionID}` as const; + const transaction: Transaction = { + transactionID, + reportID, + amount: 8700, + currency: CONST.CURRENCY.EUR, + merchant: 'Merchant', + created: '2026-06-10', + }; + const violation = { + name: CONST.VIOLATIONS.CATEGORY_OUT_OF_POLICY, + type: CONST.VIOLATION_TYPES.VIOLATION, + showInReview: true, + } as TransactionViolation; + const missingViolation = { + name: CONST.VIOLATIONS.TAG_OUT_OF_POLICY, + type: CONST.VIOLATION_TYPES.VIOLATION, + showInReview: true, + } as TransactionViolation; + const context = { + currentValue: reportTransactionsAndViolationsConfig.compute([{[transactionKey]: transaction}, {}], {currentValue: undefined, sourceValues: undefined}), + sourceValues: { + [ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS]: { + [violationKey]: [violation], + [missingViolationKey]: [missingViolation], + }, + }, + shouldSkipUpdate: false, + }; + + const result = reportTransactionsAndViolationsConfig.compute([{[transactionKey]: transaction}, {[violationKey]: [violation], [missingViolationKey]: [missingViolation]}], context); + + expect(context.shouldSkipUpdate).toBe(false); + expect(result[reportID]?.transactions[transactionKey]).toBe(transaction); + expect(result[reportID]?.violations[violationKey]).toEqual([violation]); + }); +});