From 7f8acdc7d3f5a3883e560e349cc82636a2c1c08c Mon Sep 17 00:00:00 2001 From: Nabi Date: Tue, 2 Jun 2026 00:24:20 +0430 Subject: [PATCH 01/13] fix: prevent empty expense report when category is disabled in another tab --- .../reportTransactionsAndViolations.ts | 17 +++-- .../reportTransactionsAndViolations.test.ts | 65 +++++++++++++++++++ 2 files changed, 75 insertions(+), 7 deletions(-) create mode 100644 tests/unit/reportTransactionsAndViolations.test.ts diff --git a/src/libs/actions/OnyxDerived/configs/reportTransactionsAndViolations.ts b/src/libs/actions/OnyxDerived/configs/reportTransactionsAndViolations.ts index 28dd3137d16b..33c022058cbd 100644 --- a/src/libs/actions/OnyxDerived/configs/reportTransactionsAndViolations.ts +++ b/src/libs/actions/OnyxDerived/configs/reportTransactionsAndViolations.ts @@ -12,10 +12,6 @@ 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 {}; - } - // 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]; @@ -30,6 +26,9 @@ export default createOnyxDerivedValueConfig({ } const reportTransactionsAndViolations = currentValue ? {...currentValue} : {}; + if (!transactions) { + return reportTransactionsAndViolations; + } // 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. @@ -47,11 +46,15 @@ export default createOnyxDerivedValueConfig({ }; for (const transactionKey of transactionsToProcess) { - 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]; + const previousReportTransactionsAndViolations = previousReportID ? reportTransactionsAndViolations[previousReportID] : undefined; + const previousTransaction = transactionViolationsUpdates ? previousReportTransactionsAndViolations?.transactions[transactionKey] : undefined; + + // A transaction-violation update should never make the report lose its transaction. If the transaction + // collection is briefly unavailable for this key, keep the last derived transaction while updating violations. + const transaction = transactions[transactionKey] ?? previousTransaction; + const reportID = transaction?.reportID; if (previousReportID && previousReportID !== reportID && reportTransactionsAndViolations[previousReportID]) { ensureCloned(previousReportID); diff --git a/tests/unit/reportTransactionsAndViolations.test.ts b/tests/unit/reportTransactionsAndViolations.test.ts new file mode 100644 index 000000000000..6efdbf7a0531 --- /dev/null +++ b/tests/unit/reportTransactionsAndViolations.test.ts @@ -0,0 +1,65 @@ +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 = { + transactionID, + reportID, + amount: 8700, + currency: CONST.CURRENCY.EUR, + merchant: 'Merchant', + } as Transaction; + 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( + [{}, {[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 = { + transactionID, + reportID, + amount: 8700, + currency: CONST.CURRENCY.EUR, + merchant: 'Merchant', + } as Transaction; + + const currentValue = reportTransactionsAndViolationsConfig.compute([{[transactionKey]: transaction}, {}], {currentValue: undefined, sourceValues: undefined}); + const result = reportTransactionsAndViolationsConfig.compute( + [{[transactionKey]: null}, {}], + { + currentValue, + sourceValues: { + [ONYXKEYS.COLLECTION.TRANSACTION]: {[transactionKey]: null}, + }, + }, + ); + + expect(result[reportID]?.transactions[transactionKey]).toBeUndefined(); + }); +}); From 95e8eae6dc044526ea203af706a1bfe6b8a097e0 Mon Sep 17 00:00:00 2001 From: Nabi Date: Tue, 2 Jun 2026 18:02:30 +0430 Subject: [PATCH 02/13] fix: preserve report transactions during violation-only updates --- .../reportTransactionsAndViolations.ts | 30 ++++++++++--------- .../reportTransactionsAndViolations.test.ts | 26 +++++++--------- 2 files changed, 26 insertions(+), 30 deletions(-) diff --git a/src/libs/actions/OnyxDerived/configs/reportTransactionsAndViolations.ts b/src/libs/actions/OnyxDerived/configs/reportTransactionsAndViolations.ts index 33c022058cbd..a34eda47b29f 100644 --- a/src/libs/actions/OnyxDerived/configs/reportTransactionsAndViolations.ts +++ b/src/libs/actions/OnyxDerived/configs/reportTransactionsAndViolations.ts @@ -12,6 +12,11 @@ export default createOnyxDerivedValueConfig({ key: ONYXKEYS.DERIVED.REPORT_TRANSACTIONS_AND_VIOLATIONS, dependencies: [ONYXKEYS.COLLECTION.TRANSACTION, ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS], compute: ([transactions, violations], {sourceValues, currentValue}) => { + const reportTransactionsAndViolations = currentValue ? {...currentValue} : {}; + if (!transactions) { + return reportTransactionsAndViolations; + } + // 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]; @@ -25,11 +30,6 @@ export default createOnyxDerivedValueConfig({ ); } - const reportTransactionsAndViolations = currentValue ? {...currentValue} : {}; - if (!transactions) { - return reportTransactionsAndViolations; - } - // 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(); @@ -47,16 +47,16 @@ export default createOnyxDerivedValueConfig({ 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 = transactionReportIDMapping[transactionKey]; - const previousReportTransactionsAndViolations = previousReportID ? reportTransactionsAndViolations[previousReportID] : undefined; - const previousTransaction = transactionViolationsUpdates ? previousReportTransactionsAndViolations?.transactions[transactionKey] : undefined; - - // A transaction-violation update should never make the report lose its transaction. If the transaction - // collection is briefly unavailable for this key, keep the last derived transaction while updating violations. + const previousReportID = + transactionReportIDMapping[transactionKey] ?? + Object.keys(reportTransactionsAndViolations).find((reportID) => !!reportTransactionsAndViolations[reportID].transactions[transactionKey]); + const transactionWasUpdated = !!transactionsUpdates; + // A violation-only update must not remove report membership when this tab has an incomplete transaction collection. + const previousTransaction = !transactionWasUpdated && previousReportID ? reportTransactionsAndViolations[previousReportID]?.transactions[transactionKey] : undefined; const transaction = transactions[transactionKey] ?? previousTransaction; const reportID = transaction?.reportID; - 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, ''); @@ -65,12 +65,14 @@ export default createOnyxDerivedValueConfig({ } } - if (!transaction && transactionReportIDMapping[transactionKey]) { + if (transactionWasUpdated && !transaction && transactionReportIDMapping[transactionKey]) { delete transactionReportIDMapping[transactionKey]; } if (!reportID) { - delete transactionToReportIDMap[transactionKey]; + if (transactionWasUpdated) { + delete transactionToReportIDMap[transactionKey]; + } continue; } diff --git a/tests/unit/reportTransactionsAndViolations.test.ts b/tests/unit/reportTransactionsAndViolations.test.ts index 6efdbf7a0531..763da932458d 100644 --- a/tests/unit/reportTransactionsAndViolations.test.ts +++ b/tests/unit/reportTransactionsAndViolations.test.ts @@ -23,15 +23,12 @@ describe('reportTransactionsAndViolations derived value', () => { } as TransactionViolation; const currentValue = reportTransactionsAndViolationsConfig.compute([{[transactionKey]: transaction}, {}], {currentValue: undefined, sourceValues: undefined}); - const result = reportTransactionsAndViolationsConfig.compute( - [{}, {[violationKey]: [violation]}], - { - currentValue, - sourceValues: { - [ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS]: {[violationKey]: [violation]}, - }, + const result = reportTransactionsAndViolationsConfig.compute([{}, {[violationKey]: [violation]}], { + currentValue, + sourceValues: { + [ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS]: {[violationKey]: [violation]}, }, - ); + }); expect(result[reportID]?.transactions[transactionKey]).toBe(transaction); expect(result[reportID]?.violations[violationKey]).toEqual([violation]); @@ -50,15 +47,12 @@ describe('reportTransactionsAndViolations derived value', () => { } as Transaction; const currentValue = reportTransactionsAndViolationsConfig.compute([{[transactionKey]: transaction}, {}], {currentValue: undefined, sourceValues: undefined}); - const result = reportTransactionsAndViolationsConfig.compute( - [{[transactionKey]: null}, {}], - { - currentValue, - sourceValues: { - [ONYXKEYS.COLLECTION.TRANSACTION]: {[transactionKey]: null}, - }, + const result = reportTransactionsAndViolationsConfig.compute([{[transactionKey]: null}, {}], { + currentValue, + sourceValues: { + [ONYXKEYS.COLLECTION.TRANSACTION]: {[transactionKey]: null}, }, - ); + }); expect(result[reportID]?.transactions[transactionKey]).toBeUndefined(); }); From c86669e5f0efbfd267b5057a11c35e0c379e84e5 Mon Sep 17 00:00:00 2001 From: Nabi Date: Tue, 2 Jun 2026 18:45:51 +0430 Subject: [PATCH 03/13] fix: update transaction handling to use undefined instead of null in reportTransactionsAndViolations tests --- tests/unit/reportTransactionsAndViolations.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/reportTransactionsAndViolations.test.ts b/tests/unit/reportTransactionsAndViolations.test.ts index 763da932458d..5db2b58a9e1b 100644 --- a/tests/unit/reportTransactionsAndViolations.test.ts +++ b/tests/unit/reportTransactionsAndViolations.test.ts @@ -47,10 +47,10 @@ describe('reportTransactionsAndViolations derived value', () => { } as Transaction; const currentValue = reportTransactionsAndViolationsConfig.compute([{[transactionKey]: transaction}, {}], {currentValue: undefined, sourceValues: undefined}); - const result = reportTransactionsAndViolationsConfig.compute([{[transactionKey]: null}, {}], { + const result = reportTransactionsAndViolationsConfig.compute([{[transactionKey]: undefined}, {}], { currentValue, sourceValues: { - [ONYXKEYS.COLLECTION.TRANSACTION]: {[transactionKey]: null}, + [ONYXKEYS.COLLECTION.TRANSACTION]: {[transactionKey]: undefined}, }, }); From 028de970fb62bf9540781fab4599fe0af9826df7 Mon Sep 17 00:00:00 2001 From: Nabi Date: Wed, 3 Jun 2026 15:31:03 +0430 Subject: [PATCH 04/13] fix: keep derived Onyx snapshot in sync across tabs OnyxDerived keeps the last computed value in a module-local closure so future source updates can be merged against the previous derived state. When another tab updates the same derived key, the local closure can stay stale even though Onyx has the newer value. Subscribe to the derived key itself so external updates refresh the local snapshot. This prevents later partial source updates from recomputing from stale derived data and accidentally dropping existing report transaction state. --- src/libs/actions/OnyxDerived/index.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/libs/actions/OnyxDerived/index.ts b/src/libs/actions/OnyxDerived/index.ts index 57950c582d5f..862a91b0da24 100644 --- a/src/libs/actions/OnyxDerived/index.ts +++ b/src/libs/actions/OnyxDerived/index.ts @@ -61,6 +61,13 @@ function init() { sourceValues: undefined, }; + Onyx.connectWithoutView({ + key, + callback: (value) => { + derivedValue = value; + }, + }); + const recomputeDerivedValue = (sourceKey?: string, sourceValue?: unknown, triggeredByIndex?: number) => { // If this recompute was triggered by a connection callback, check if it initializes the connection if (!areAllConnectionsSet && triggeredByIndex !== undefined) { From 34c46345a4486619b3e4104251a5dae0b3c8f264 Mon Sep 17 00:00:00 2001 From: Nabi Date: Thu, 4 Jun 2026 14:24:36 +0430 Subject: [PATCH 05/13] fix: preserve report transactions for violation-only updates --- .../configs/reportTransactionsAndViolations.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/libs/actions/OnyxDerived/configs/reportTransactionsAndViolations.ts b/src/libs/actions/OnyxDerived/configs/reportTransactionsAndViolations.ts index a34eda47b29f..2ab5941e037f 100644 --- a/src/libs/actions/OnyxDerived/configs/reportTransactionsAndViolations.ts +++ b/src/libs/actions/OnyxDerived/configs/reportTransactionsAndViolations.ts @@ -13,14 +13,16 @@ export default createOnyxDerivedValueConfig({ dependencies: [ONYXKEYS.COLLECTION.TRANSACTION, ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS], compute: ([transactions, violations], {sourceValues, currentValue}) => { const reportTransactionsAndViolations = currentValue ? {...currentValue} : {}; - if (!transactions) { - return reportTransactionsAndViolations; - } // 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]; + + if (!transactions) { + return transactionViolationsUpdates ? reportTransactionsAndViolations : {}; + } + let transactionsToProcess = Object.keys(transactions); if (transactionsUpdates) { transactionsToProcess = Object.keys(transactionsUpdates); From c309928881c9352583511bce411e8bc85bb54fa4 Mon Sep 17 00:00:00 2001 From: Nabi Date: Wed, 10 Jun 2026 08:58:31 +0430 Subject: [PATCH 06/13] fix: correct transaction object type definition in tests --- tests/unit/reportTransactionsAndViolations.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/unit/reportTransactionsAndViolations.test.ts b/tests/unit/reportTransactionsAndViolations.test.ts index 5db2b58a9e1b..c404ee7613da 100644 --- a/tests/unit/reportTransactionsAndViolations.test.ts +++ b/tests/unit/reportTransactionsAndViolations.test.ts @@ -9,13 +9,14 @@ describe('reportTransactionsAndViolations derived value', () => { const transactionID = '91016-transaction'; const transactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}` as const; const violationKey = `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}` as const; - const transaction = { + const transaction: Transaction = { transactionID, reportID, amount: 8700, currency: CONST.CURRENCY.EUR, merchant: 'Merchant', - } as Transaction; + created: '2026-06-10', + }; const violation = { name: CONST.VIOLATIONS.CATEGORY_OUT_OF_POLICY, type: CONST.VIOLATION_TYPES.VIOLATION, @@ -38,13 +39,14 @@ describe('reportTransactionsAndViolations derived value', () => { const reportID = '91016-delete'; const transactionID = '91016-delete-transaction'; const transactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}` as const; - const transaction = { + const transaction: Transaction = { transactionID, reportID, amount: 8700, currency: CONST.CURRENCY.EUR, merchant: 'Merchant', - } as Transaction; + created: '2026-06-10', + }; const currentValue = reportTransactionsAndViolationsConfig.compute([{[transactionKey]: transaction}, {}], {currentValue: undefined, sourceValues: undefined}); const result = reportTransactionsAndViolationsConfig.compute([{[transactionKey]: undefined}, {}], { From ca67d3cea6796266fc51505d56aa5545b5c812d6 Mon Sep 17 00:00:00 2001 From: Nabi Date: Tue, 16 Jun 2026 12:47:00 +0430 Subject: [PATCH 07/13] fix: skip incomplete violation-only derived updates --- .../reportTransactionsAndViolations.ts | 35 ++++++++++++++----- src/libs/actions/OnyxDerived/index.ts | 12 +++---- src/libs/actions/OnyxDerived/types.ts | 1 + .../reportTransactionsAndViolations.test.ts | 22 ++++++++++++ 4 files changed, 55 insertions(+), 15 deletions(-) diff --git a/src/libs/actions/OnyxDerived/configs/reportTransactionsAndViolations.ts b/src/libs/actions/OnyxDerived/configs/reportTransactionsAndViolations.ts index 2ab5941e037f..8693cfe137fe 100644 --- a/src/libs/actions/OnyxDerived/configs/reportTransactionsAndViolations.ts +++ b/src/libs/actions/OnyxDerived/configs/reportTransactionsAndViolations.ts @@ -8,10 +8,13 @@ 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}) => { + compute: ([transactions, violations], context) => { + const {sourceValues, currentValue} = context; const reportTransactionsAndViolations = currentValue ? {...currentValue} : {}; // If there is a source value for transactions or transaction violations, we need to process only the transactions that have been updated or added @@ -20,16 +23,18 @@ export default createOnyxDerivedValueConfig({ const transactionViolationsUpdates = sourceValues?.[ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS]; if (!transactions) { - return transactionViolationsUpdates ? reportTransactionsAndViolations : {}; + 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); } // Track which reportID entries have been cloned so we only clone once per reportID. @@ -47,11 +52,25 @@ export default createOnyxDerivedValueConfig({ clonedReportIDs.add(id); }; + const getPreviousReportID = (transactionKey: string) => + transactionReportIDMapping[transactionKey] ?? + Object.keys(reportTransactionsAndViolations).find((reportID) => !!reportTransactionsAndViolations[reportID].transactions[transactionKey]); + + if (!transactionsUpdates && transactionViolationsUpdates) { + const hasUnresolvedTransaction = transactionsToProcess.some((transactionKey) => { + const previousReportID = getPreviousReportID(transactionKey); + return !transactions[transactionKey] && !previousReportID; + }); + + if (hasUnresolvedTransaction) { + 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 = - transactionReportIDMapping[transactionKey] ?? - Object.keys(reportTransactionsAndViolations).find((reportID) => !!reportTransactionsAndViolations[reportID].transactions[transactionKey]); + const previousReportID = getPreviousReportID(transactionKey); const transactionWasUpdated = !!transactionsUpdates; // A violation-only update must not remove report membership when this tab has an incomplete transaction collection. const previousTransaction = !transactionWasUpdated && previousReportID ? reportTransactionsAndViolations[previousReportID]?.transactions[transactionKey] : undefined; diff --git a/src/libs/actions/OnyxDerived/index.ts b/src/libs/actions/OnyxDerived/index.ts index 862a91b0da24..6efaf5a4e884 100644 --- a/src/libs/actions/OnyxDerived/index.ts +++ b/src/libs/actions/OnyxDerived/index.ts @@ -61,13 +61,6 @@ function init() { sourceValues: undefined, }; - Onyx.connectWithoutView({ - key, - callback: (value) => { - derivedValue = value; - }, - }); - const recomputeDerivedValue = (sourceKey?: string, sourceValue?: unknown, triggeredByIndex?: number) => { // If this recompute was triggered by a connection callback, check if it initializes the connection if (!areAllConnectionsSet && triggeredByIndex !== undefined) { @@ -85,6 +78,7 @@ function init() { context.currentValue = derivedValue; context.sourceValues = sourceKey && sourceValue !== undefined ? {[sourceKey]: sourceValue} : undefined; + context.shouldSkipUpdate = false; const spanId = `${CONST.TELEMETRY.SPAN_ONYX_DERIVED_COMPUTE}_${key}`; startSpan(spanId, { @@ -97,6 +91,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); diff --git a/src/libs/actions/OnyxDerived/types.ts b/src/libs/actions/OnyxDerived/types.ts index a7b14999a9ba..20e5041e99a7 100644 --- a/src/libs/actions/OnyxDerived/types.ts +++ b/src/libs/actions/OnyxDerived/types.ts @@ -16,6 +16,7 @@ type DerivedSourceValues = Partial<{ type DerivedValueContext>> = { currentValue?: OnyxValue; sourceValues?: DerivedSourceValues; + shouldSkipUpdate?: boolean; }; /** diff --git a/tests/unit/reportTransactionsAndViolations.test.ts b/tests/unit/reportTransactionsAndViolations.test.ts index c404ee7613da..5446f0a7d566 100644 --- a/tests/unit/reportTransactionsAndViolations.test.ts +++ b/tests/unit/reportTransactionsAndViolations.test.ts @@ -58,4 +58,26 @@ describe('reportTransactionsAndViolations derived value', () => { expect(result[reportID]?.transactions[transactionKey]).toBeUndefined(); }); + + 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); + }); }); From 150dfd359e34c7fae22bcc6d9be7ac5f46363f70 Mon Sep 17 00:00:00 2001 From: Nabi Date: Wed, 17 Jun 2026 18:01:17 +0430 Subject: [PATCH 08/13] fix: remove empty report buckets when last transaction is deleted --- .../reportTransactionsAndViolations.ts | 19 +++++++++++++++ .../reportTransactionsAndViolations.test.ts | 24 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/src/libs/actions/OnyxDerived/configs/reportTransactionsAndViolations.ts b/src/libs/actions/OnyxDerived/configs/reportTransactionsAndViolations.ts index 8693cfe137fe..e58e8aa543a0 100644 --- a/src/libs/actions/OnyxDerived/configs/reportTransactionsAndViolations.ts +++ b/src/libs/actions/OnyxDerived/configs/reportTransactionsAndViolations.ts @@ -56,6 +56,20 @@ export default createOnyxDerivedValueConfig({ 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 (!transactionsUpdates && transactionViolationsUpdates) { const hasUnresolvedTransaction = transactionsToProcess.some((transactionKey) => { const previousReportID = getPreviousReportID(transactionKey); @@ -84,6 +98,7 @@ export default createOnyxDerivedValueConfig({ if (transactionID) { delete reportTransactionsAndViolations[previousReportID].violations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`]; } + deleteReportIfEmpty(previousReportID); } if (transactionWasUpdated && !transaction && transactionReportIDMapping[transactionKey]) { @@ -128,6 +143,10 @@ export default createOnyxDerivedValueConfig({ previousViolations = violations; + for (const reportID of Object.keys(reportTransactionsAndViolations)) { + deleteReportIfEmpty(reportID); + } + return reportTransactionsAndViolations; }, }); diff --git a/tests/unit/reportTransactionsAndViolations.test.ts b/tests/unit/reportTransactionsAndViolations.test.ts index 5446f0a7d566..27c50564f2a4 100644 --- a/tests/unit/reportTransactionsAndViolations.test.ts +++ b/tests/unit/reportTransactionsAndViolations.test.ts @@ -59,6 +59,30 @@ describe('reportTransactionsAndViolations derived value', () => { 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('skips violation-only updates when the affected transaction is unavailable', () => { const transactionID = '91016-missing-transaction'; const violationKey = `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}` as const; From 470aefa3d06c0d4b04a31f1933bbb09320ec8fdf Mon Sep 17 00:00:00 2001 From: Nabi Date: Wed, 17 Jun 2026 18:28:09 +0430 Subject: [PATCH 09/13] fix: update transaction processing logic to handle resolvable violation updates with unresolved transactions --- .../reportTransactionsAndViolations.ts | 6 +-- .../reportTransactionsAndViolations.test.ts | 43 +++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/libs/actions/OnyxDerived/configs/reportTransactionsAndViolations.ts b/src/libs/actions/OnyxDerived/configs/reportTransactionsAndViolations.ts index e58e8aa543a0..a126c854f161 100644 --- a/src/libs/actions/OnyxDerived/configs/reportTransactionsAndViolations.ts +++ b/src/libs/actions/OnyxDerived/configs/reportTransactionsAndViolations.ts @@ -71,12 +71,12 @@ export default createOnyxDerivedValueConfig({ }; if (!transactionsUpdates && transactionViolationsUpdates) { - const hasUnresolvedTransaction = transactionsToProcess.some((transactionKey) => { + transactionsToProcess = transactionsToProcess.filter((transactionKey) => { const previousReportID = getPreviousReportID(transactionKey); - return !transactions[transactionKey] && !previousReportID; + return !!transactions[transactionKey] || !!previousReportID; }); - if (hasUnresolvedTransaction) { + if (transactionsToProcess.length === 0) { context.shouldSkipUpdate = true; return reportTransactionsAndViolations; } diff --git a/tests/unit/reportTransactionsAndViolations.test.ts b/tests/unit/reportTransactionsAndViolations.test.ts index 27c50564f2a4..8b6aa98cf858 100644 --- a/tests/unit/reportTransactionsAndViolations.test.ts +++ b/tests/unit/reportTransactionsAndViolations.test.ts @@ -104,4 +104,47 @@ describe('reportTransactionsAndViolations derived value', () => { expect(result).toEqual({}); 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]); + }); }); From 9079c62a7bcfe25debfd9b5c5d7b0fc091dcc81f Mon Sep 17 00:00:00 2001 From: Nabi Date: Wed, 24 Jun 2026 10:41:52 +0430 Subject: [PATCH 10/13] fix: enhance transaction report handling to prevent carrying empty buckets and deleted transactions during recompute --- .../reportTransactionsAndViolations.ts | 19 +++++- .../reportTransactionsAndViolations.test.ts | 68 +++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/src/libs/actions/OnyxDerived/configs/reportTransactionsAndViolations.ts b/src/libs/actions/OnyxDerived/configs/reportTransactionsAndViolations.ts index a126c854f161..c8a2a13fe208 100644 --- a/src/libs/actions/OnyxDerived/configs/reportTransactionsAndViolations.ts +++ b/src/libs/actions/OnyxDerived/configs/reportTransactionsAndViolations.ts @@ -15,12 +15,15 @@ export default createOnyxDerivedValueConfig({ dependencies: [ONYXKEYS.COLLECTION.TRANSACTION, ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS], compute: ([transactions, violations], context) => { const {sourceValues, currentValue} = context; - const reportTransactionsAndViolations = currentValue ? {...currentValue} : {}; // 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 (!transactions) { if (transactionViolationsUpdates) { @@ -70,6 +73,20 @@ export default createOnyxDerivedValueConfig({ clonedReportIDs.delete(reportID); }; + if (!isPartialUpdate) { + for (const transactionKey of Object.keys(transactionReportIDMapping)) { + delete transactionReportIDMapping[transactionKey]; + } + + for (const transactionKey of Object.keys(transactionToReportIDMap)) { + delete transactionToReportIDMap[transactionKey]; + } + } else { + for (const transactionKey of Object.keys(reportTransactionsAndViolations)) { + deleteReportIfEmpty(transactionKey); + } + } + if (!transactionsUpdates && transactionViolationsUpdates) { transactionsToProcess = transactionsToProcess.filter((transactionKey) => { const previousReportID = getPreviousReportID(transactionKey); diff --git a/tests/unit/reportTransactionsAndViolations.test.ts b/tests/unit/reportTransactionsAndViolations.test.ts index 8b6aa98cf858..014f8e317cbc 100644 --- a/tests/unit/reportTransactionsAndViolations.test.ts +++ b/tests/unit/reportTransactionsAndViolations.test.ts @@ -83,6 +83,74 @@ describe('reportTransactionsAndViolations derived value', () => { 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; From 032ff9e0ba80f9569ddea7d8c2415f8afad12705 Mon Sep 17 00:00:00 2001 From: Nabi Date: Mon, 29 Jun 2026 13:14:54 +0430 Subject: [PATCH 11/13] fix: prevent stale report transaction derived updates --- .../reportTransactionsAndViolations.ts | 25 +++---- src/libs/actions/OnyxDerived/index.ts | 17 +++++ src/libs/actions/OnyxDerived/types.ts | 1 + .../reportTransactionsAndViolations.test.ts | 72 ++++++++++++++++++- 4 files changed, 98 insertions(+), 17 deletions(-) diff --git a/src/libs/actions/OnyxDerived/configs/reportTransactionsAndViolations.ts b/src/libs/actions/OnyxDerived/configs/reportTransactionsAndViolations.ts index c8a2a13fe208..ac6f086195df 100644 --- a/src/libs/actions/OnyxDerived/configs/reportTransactionsAndViolations.ts +++ b/src/libs/actions/OnyxDerived/configs/reportTransactionsAndViolations.ts @@ -6,8 +6,6 @@ import type {TransactionViolation} from '@src/types/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({ @@ -25,6 +23,11 @@ export default createOnyxDerivedValueConfig({ // 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) { + context.shouldSkipUpdate = true; + return currentValue; + } + if (!transactions) { if (transactionViolationsUpdates) { context.shouldSkipUpdate = true; @@ -77,20 +80,15 @@ export default createOnyxDerivedValueConfig({ for (const transactionKey of Object.keys(transactionReportIDMapping)) { delete transactionReportIDMapping[transactionKey]; } - - for (const transactionKey of Object.keys(transactionToReportIDMap)) { - delete transactionToReportIDMap[transactionKey]; - } } else { - for (const transactionKey of Object.keys(reportTransactionsAndViolations)) { - deleteReportIfEmpty(transactionKey); + for (const reportID of Object.keys(reportTransactionsAndViolations)) { + deleteReportIfEmpty(reportID); } } if (!transactionsUpdates && transactionViolationsUpdates) { transactionsToProcess = transactionsToProcess.filter((transactionKey) => { - const previousReportID = getPreviousReportID(transactionKey); - return !!transactions[transactionKey] || !!previousReportID; + return !!transactions[transactionKey]; }); if (transactionsToProcess.length === 0) { @@ -103,9 +101,7 @@ export default createOnyxDerivedValueConfig({ // 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; - // A violation-only update must not remove report membership when this tab has an incomplete transaction collection. - const previousTransaction = !transactionWasUpdated && previousReportID ? reportTransactionsAndViolations[previousReportID]?.transactions[transactionKey] : undefined; - const transaction = transactions[transactionKey] ?? previousTransaction; + const transaction = transactions[transactionKey]; const reportID = transaction?.reportID; if (transactionWasUpdated && previousReportID && previousReportID !== reportID && reportTransactionsAndViolations[previousReportID]) { @@ -123,9 +119,6 @@ export default createOnyxDerivedValueConfig({ } if (!reportID) { - if (transactionWasUpdated) { - delete transactionToReportIDMap[transactionKey]; - } continue; } diff --git a/src/libs/actions/OnyxDerived/index.ts b/src/libs/actions/OnyxDerived/index.ts index 6efaf5a4e884..864a2a0c0925 100644 --- a/src/libs/actions/OnyxDerived/index.ts +++ b/src/libs/actions/OnyxDerived/index.ts @@ -35,6 +35,7 @@ function init() { OnyxUtils.get(key).then((storedDerivedValue) => { let derivedValue = storedDerivedValue; + let hasSyncedDerivedValueFromOnyx = key !== ONYXKEYS.DERIVED.REPORT_TRANSACTIONS_AND_VIOLATIONS; if (derivedValue) { Log.info(`Derived value for ${key} restored from disk`); } @@ -61,6 +62,16 @@ function init() { sourceValues: undefined, }; + if (key === ONYXKEYS.DERIVED.REPORT_TRANSACTIONS_AND_VIOLATIONS) { + Onyx.connectWithoutView({ + key, + callback: (value) => { + derivedValue = value; + hasSyncedDerivedValueFromOnyx = true; + }, + }); + } + const recomputeDerivedValue = (sourceKey?: string, sourceValue?: unknown, triggeredByIndex?: number) => { // If this recompute was triggered by a connection callback, check if it initializes the connection if (!areAllConnectionsSet && triggeredByIndex !== undefined) { @@ -76,8 +87,14 @@ function init() { return; } + if (!hasSyncedDerivedValueFromOnyx) { + 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}`; diff --git a/src/libs/actions/OnyxDerived/types.ts b/src/libs/actions/OnyxDerived/types.ts index 20e5041e99a7..1318f9b7fed2 100644 --- a/src/libs/actions/OnyxDerived/types.ts +++ b/src/libs/actions/OnyxDerived/types.ts @@ -16,6 +16,7 @@ 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 index 014f8e317cbc..540ec89bfb99 100644 --- a/tests/unit/reportTransactionsAndViolations.test.ts +++ b/tests/unit/reportTransactionsAndViolations.test.ts @@ -24,7 +24,7 @@ describe('reportTransactionsAndViolations derived value', () => { } as TransactionViolation; const currentValue = reportTransactionsAndViolationsConfig.compute([{[transactionKey]: transaction}, {}], {currentValue: undefined, sourceValues: undefined}); - const result = reportTransactionsAndViolationsConfig.compute([{}, {[violationKey]: [violation]}], { + const result = reportTransactionsAndViolationsConfig.compute([{[transactionKey]: transaction}, {[violationKey]: [violation]}], { currentValue, sourceValues: { [ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS]: {[violationKey]: [violation]}, @@ -173,6 +173,76 @@ describe('reportTransactionsAndViolations derived value', () => { 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'; From a334fa102b3f6c0cf830c23005cff00362169f03 Mon Sep 17 00:00:00 2001 From: Nabi Date: Mon, 29 Jun 2026 13:54:31 +0430 Subject: [PATCH 12/13] fix: replay delayed report transaction recompute --- src/libs/actions/OnyxDerived/index.ts | 36 +++++++++++++++++++-------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/src/libs/actions/OnyxDerived/index.ts b/src/libs/actions/OnyxDerived/index.ts index 864a2a0c0925..fa4c184c25d1 100644 --- a/src/libs/actions/OnyxDerived/index.ts +++ b/src/libs/actions/OnyxDerived/index.ts @@ -36,6 +36,13 @@ 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`); } @@ -62,16 +69,6 @@ function init() { sourceValues: undefined, }; - if (key === ONYXKEYS.DERIVED.REPORT_TRANSACTIONS_AND_VIOLATIONS) { - Onyx.connectWithoutView({ - key, - callback: (value) => { - derivedValue = value; - hasSyncedDerivedValueFromOnyx = true; - }, - }); - } - const recomputeDerivedValue = (sourceKey?: string, sourceValue?: unknown, triggeredByIndex?: number) => { // If this recompute was triggered by a connection callback, check if it initializes the connection if (!areAllConnectionsSet && triggeredByIndex !== undefined) { @@ -88,6 +85,7 @@ function init() { } if (!hasSyncedDerivedValueFromOnyx) { + pendingRecomputeAfterDerivedValueSync = {sourceKey, sourceValue, triggeredByIndex}; Log.info(`[OnyxDerived] waiting for current value sync before recomputing ${key}`); return; } @@ -120,6 +118,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]; From 56b777bf2c696120f0a5020e8d321ea28614cf93 Mon Sep 17 00:00:00 2001 From: Nabi Date: Fri, 3 Jul 2026 10:56:37 +0430 Subject: [PATCH 13/13] fix: clear hydrated report transaction violations --- .../OnyxDerived/configs/reportTransactionsAndViolations.ts | 4 +++- src/libs/actions/OnyxDerived/index.ts | 6 +++++- tests/unit/reportTransactionsAndViolations.test.ts | 1 + 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/libs/actions/OnyxDerived/configs/reportTransactionsAndViolations.ts b/src/libs/actions/OnyxDerived/configs/reportTransactionsAndViolations.ts index 901f305ed797..cdc06a2c3042 100644 --- a/src/libs/actions/OnyxDerived/configs/reportTransactionsAndViolations.ts +++ b/src/libs/actions/OnyxDerived/configs/reportTransactionsAndViolations.ts @@ -26,6 +26,7 @@ export default createOnyxDerivedValueConfig({ const reportTransactionsAndViolations = isPartialUpdate && currentValue ? {...currentValue} : {}; if (context.isInitialDependencyLoad && currentValue) { + previousViolations = violations; context.shouldSkipUpdate = true; return currentValue; } @@ -140,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]; } diff --git a/src/libs/actions/OnyxDerived/index.ts b/src/libs/actions/OnyxDerived/index.ts index 7c3c94bac4c4..cb7867f84581 100644 --- a/src/libs/actions/OnyxDerived/index.ts +++ b/src/libs/actions/OnyxDerived/index.ts @@ -89,7 +89,11 @@ function init() { } if (!hasSyncedDerivedValueFromOnyx) { - pendingRecomputeAfterDerivedValueSync = {sourceKey, sourceValue, triggeredByIndex}; + pendingRecomputeAfterDerivedValueSync = { + sourceKey, + sourceValue, + triggeredByIndex, + }; Log.info(`[OnyxDerived] waiting for current value sync before recomputing ${key}`); return; } diff --git a/tests/unit/reportTransactionsAndViolations.test.ts b/tests/unit/reportTransactionsAndViolations.test.ts index 540ec89bfb99..041f15474066 100644 --- a/tests/unit/reportTransactionsAndViolations.test.ts +++ b/tests/unit/reportTransactionsAndViolations.test.ts @@ -1,4 +1,5 @@ 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';