Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
7f8acdc
fix: prevent empty expense report when category is disabled in anothe…
nabi-ebrahimi Jun 1, 2026
3cbf341
Merge branch 'main' into fix/expense-empty-report-disabled-category
nabi-ebrahimi Jun 2, 2026
95e8eae
fix: preserve report transactions during violation-only updates
nabi-ebrahimi Jun 2, 2026
c86669e
fix: update transaction handling to use undefined instead of null in …
nabi-ebrahimi Jun 2, 2026
a7d4b1e
Merge branch 'main' into fix/expense-empty-report-disabled-category
nabi-ebrahimi Jun 3, 2026
028de97
fix: keep derived Onyx snapshot in sync across tabs
nabi-ebrahimi Jun 3, 2026
97fcf62
Merge branch 'main' into fix/expense-empty-report-disabled-category
nabi-ebrahimi Jun 4, 2026
34c4634
fix: preserve report transactions for violation-only updates
nabi-ebrahimi Jun 4, 2026
4783497
Merge branch 'main' into fix/expense-empty-report-disabled-category
nabi-ebrahimi Jun 9, 2026
c309928
fix: correct transaction object type definition in tests
nabi-ebrahimi Jun 10, 2026
ca67d3c
fix: skip incomplete violation-only derived updates
nabi-ebrahimi Jun 16, 2026
4401d6f
Merge branch 'main' into fix/expense-empty-report-disabled-category
nabi-ebrahimi Jun 17, 2026
150dfd3
fix: remove empty report buckets when last transaction is deleted
nabi-ebrahimi Jun 17, 2026
470aefa
fix: update transaction processing logic to handle resolvable violati…
nabi-ebrahimi Jun 17, 2026
9b2b860
Merge branch 'main' into fix/expense-empty-report-disabled-category
nabi-ebrahimi Jun 18, 2026
7bb9a97
Merge branch 'main' into fix/expense-empty-report-disabled-category
nabi-ebrahimi Jun 24, 2026
9079c62
fix: enhance transaction report handling to prevent carrying empty bu…
nabi-ebrahimi Jun 24, 2026
0b40588
Merge branch 'main' into fix/expense-empty-report-disabled-category
nabi-ebrahimi Jun 26, 2026
80bc73e
Merge branch 'main' into fix/expense-empty-report-disabled-category
nabi-ebrahimi Jun 29, 2026
032ff9e
fix: prevent stale report transaction derived updates
nabi-ebrahimi Jun 29, 2026
ad25169
Merge branch 'main' into fix/expense-empty-report-disabled-category
nabi-ebrahimi Jun 29, 2026
a334fa1
fix: replay delayed report transaction recompute
nabi-ebrahimi Jun 29, 2026
9923d2d
Merge branch 'main' into fix/expense-empty-report-disabled-category
nabi-ebrahimi Jul 3, 2026
56b777b
fix: clear hydrated report transaction violations
nabi-ebrahimi Jul 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,31 +8,44 @@ import type {OnyxCollection} from 'react-native-onyx';
let previousViolations: OnyxCollection<TransactionViolation[]> = {};
const transactionReportIDMapping: Record<string, string> = {};

const transactionToReportIDMap: Record<string, string> = {};
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;
Comment thread
nabi-ebrahimi marked this conversation as resolved.
}

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<string>();
Expand All @@ -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;
}

Expand All @@ -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];
}
Expand All @@ -104,6 +157,10 @@ export default createOnyxDerivedValueConfig({

previousViolations = violations;

for (const reportID of Object.keys(reportTransactionsAndViolations)) {
deleteReportIfEmpty(reportID);
}

return reportTransactionsAndViolations;
},
});
42 changes: 42 additions & 0 deletions src/libs/actions/OnyxDerived/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
}
Expand Down Expand Up @@ -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, {
Expand All @@ -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);
Expand All @@ -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];
Expand Down
2 changes: 2 additions & 0 deletions src/libs/actions/OnyxDerived/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ type DerivedSourceValues<Deps extends readonly OnyxKey[]> = Partial<{
type DerivedValueContext<Key extends OnyxKey, Deps extends NonEmptyTuple<Exclude<OnyxKey, Key>>> = {
currentValue?: OnyxValue<Key>;
sourceValues?: DerivedSourceValues<Deps>;
isInitialDependencyLoad?: boolean;
shouldSkipUpdate?: boolean;
};

/**
Expand Down
Loading
Loading