Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2211,6 +2211,7 @@ const CONST = {
APP_OPEN: 'NavigationAppOpen',
},
// Attribute names
ATTRIBUTE_DERIVED_TRIGGER: 'derived_trigger',
ATTRIBUTE_IOU_TYPE: 'iou_type',
ATTRIBUTE_IS_ONE_TRANSACTION_REPORT: 'is_one_transaction_report',
ATTRIBUTE_IS_TRANSACTION_THREAD: 'is_transaction_thread',
Expand Down
234 changes: 189 additions & 45 deletions src/libs/actions/OnyxDerived/configs/reportAttributes.ts
Original file line number Diff line number Diff line change
@@ -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 {getLinkedTransactionID} from '@libs/ReportActionsUtils';
Expand Down Expand Up @@ -33,7 +34,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<string, string> = {};
let previousPersonalDetails: OnyxEntry<PersonalDetailsList> | undefined;
let previousPolicies: OnyxCollection<Policy>;

const RECOMPUTE_ALL = 'all' as const;

Expand All @@ -50,21 +50,55 @@ const prepareReportKeys = (keys: string[]) => {
];
};

const hasPolicyRelevantFieldChanged = (prev: Policy | null | undefined, next: Policy | null | undefined): boolean => {
if (!prev && !next) {
return false;
// Keys excluded from policy signatures because they change without affecting the computed attributes:
// Onyx write-bookkeeping keys and loading flags flip during every optimistic write / API call, and
// `connections` (accounting integration configs, incl. per-sync timestamps) is both volatile and large
// while the attribute computation never reads it. Excluding them prevents recompute churn.
const POLICY_SIGNATURE_EXCLUDED_KEYS = new Set([
'pendingAction',
'pendingFields',
'errors',
'errorFields',
'connections',
'isLoading',
'isLoadingWorkspaceReimbursement',
'isLoadingReceiptPartners',
'isChangeOwnerSuccessful',
'isChangeOwnerFailed',
'lastModified',
]);

// Bump when the signature format or exclusion set changes: stored signatures from an older format then
// mismatch, which safely degrades to a scoped recompute of the delivered policies' reports once.
const POLICY_SIGNATURE_VERSION = '2';

// Deterministic stringify: object keys are sorted at every level so the same policy content always
// produces the same string regardless of key insertion order after Onyx merges.
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(',')}}`;
};

// A signature of the whole policy content (minus write-bookkeeping keys), stored in the derived value
// (like `locale`) so the change-detection baseline survives app restarts. Hashing the full content
// instead of an enumerated field list means a policy field the compute reads can never silently fall
// outside change detection; over-triggering is safe because the recompute is scoped to that policy's
// reports. The serialized length rides along as a second dimension so a 32-bit hash collision alone
// cannot mask a change; a full collision would only skip one scoped recompute for one policy change.
const policyRelevantSignature = (policy: Policy | null | undefined): string | null => {
if (!policy) {
return null;
}
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
);
const serialized = stableStringify(policy);
return `${POLICY_SIGNATURE_VERSION}|${hashCode(serialized)}.${serialized.length}`;
};

// A short string built from the fields a report name can come from: displayName and firstName.
Expand Down Expand Up @@ -250,56 +284,151 @@ export default createOnyxDerivedValueConfig({
seedDisplayNamesBaseline(personalDetails);
}

const nextIsTrackIntentUser = isTrackIntentUserSelector(introSelected);
// conciergeReportID and introSelected are re-delivered on every OpenApp/reconnect merge, so a full
// recompute happens only when the value differs from the one the attributes were computed with.
// A missing stored value (attributes written by an older app version) seeds the baseline instead.
// "No concierge report yet" is stored as an empty string, NOT null — Onyx.set strips nested null
// values on persist, so a null baseline would read back as missing after a restart and seed
// instead of detecting the change.
// eslint-disable-next-line rulesdir/no-default-id-values -- '' is a persistable baseline sentinel for "computed without a concierge report ID", never used as a lookup ID
const nextConciergeReportID = conciergeReportID ?? '';
// eslint-disable-next-line rulesdir/no-default-id-values -- same sentinel when reading values persisted before the field became non-nullable
const storedConciergeReportID = currentValue && 'conciergeReportID' in currentValue ? (currentValue.conciergeReportID ?? '') : undefined;
const storedIsTrackIntentUser = currentValue && 'isTrackIntentUser' in currentValue ? currentValue.isTrackIntentUser : undefined;
const hasConciergeReportIDChanged =
hasKeyTriggeredCompute(ONYXKEYS.CONCIERGE_REPORT_ID, sourceValues) && storedConciergeReportID !== undefined && storedConciergeReportID !== nextConciergeReportID;
const hasIsTrackIntentUserChanged =
hasKeyTriggeredCompute(ONYXKEYS.NVP_INTRO_SELECTED, sourceValues) && storedIsTrackIntentUser !== undefined && 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, sourceValues) && preferredLocale !== currentValue?.locale) ||
displayNameChanges === RECOMPUTE_ALL ||
hasKeyTriggeredCompute(ONYXKEYS.CONCIERGE_REPORT_ID, sourceValues) ||
hasKeyTriggeredCompute(ONYXKEYS.NVP_INTRO_SELECTED, sourceValues);

hasConciergeReportIDChanged ||
hasIsTrackIntentUserChanged;

// Policy change detection compares signatures stored in the derived value, so the baseline survives
// app restarts. Signatures advance only when the recompute they imply actually runs (or vacuously
// has no reports to touch); otherwise the next delivery re-diffs against the old baseline.
const storedPolicySignatures = currentValue?.policySignatures;
const hasComputedReports = !!currentValue?.reports && Object.keys(currentValue.reports).length > 0;
let nextPolicySignatures = storedPolicySignatures;
// True when the signatures may persist through an early return: a pure baseline seed, or a diff
// whose changed policies have no reports referencing them (nothing to recompute).
let canPersistSignaturesWithoutRecompute = false;
const policyChangedReportKeys: string[] = [];

// Lazily built and cached — a full-recompute pass with a policy trigger would otherwise
// serialize every policy twice (once here, once in the pre-return snapshot).
let allPolicySignaturesCache: Record<string, string> | undefined;
const buildAllPolicySignatures = () => {
if (allPolicySignaturesCache) {
return allPolicySignaturesCache;
}
const signatures: Record<string, string> = {};
for (const [key, policy] of Object.entries(policies ?? {})) {
const signature = policyRelevantSignature(policy);
if (signature !== null) {
signatures[key] = signature;
}
}
allPolicySignaturesCache = signatures;
return signatures;
};
const collectReportKeysForPolicies = (changedPolicyIDs: Set<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)) {
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);
}
}
};

if (hasKeyTriggeredCompute(ONYXKEYS.COLLECTION.POLICY, sourceValues)) {
if (!needsFullRecompute) {
if (needsFullRecompute) {
// Every report recomputes with the current policies anyway — snapshot the full baseline.
nextPolicySignatures = buildAllPolicySignatures();
} else if (storedPolicySignatures) {
const changedPolicyIDs = new Set<string>();
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, 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)) {
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;
collectReportKeysForPolicies(changedPolicyIDs);
// Vacuously satisfied only when the reports collection is actually available —
// with it missing, the recompute is skipped rather than unnecessary.
canPersistSignaturesWithoutRecompute = !!reports && policyChangedReportKeys.length === 0;
}
} else if (hasComputedReports) {
// No stored baseline but attributes exist (value written by an older app version, or computed
// in-session before policies loaded) — we cannot tell whether these policies match the ones the
// attributes were computed with, so recompute the delivered policies' reports like the
// pre-signature code did, then snapshot the full baseline.
const deliveredPolicyIDs = new Set(Object.keys(sourceValues?.[ONYXKEYS.COLLECTION.POLICY] ?? {}).map((key) => key.replace(ONYXKEYS.COLLECTION.POLICY, '')));
collectReportKeysForPolicies(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) {
// Baseline seeding on a pass that did not deliver policies: both the attributes and the policies
// come from the same disk-hydrated state (an in-session value computed without policies is
// impossible here — policies would have arrived as a policy trigger), so they are consistent.
// Known one-time limitation: on the FIRST session after this feature ships, a policy change
// merged while startup computes are still deferred is seeded here without a recompute; from the
// next value write onward the persisted baseline closes that window.
nextPolicySignatures = buildAllPolicySignatures();
canPersistSignaturesWithoutRecompute = true;
}

// Baseline writes that may ride an early return: only signature snapshots that require no recompute.
// conciergeReportID/isTrackIntentUser baselines advance exclusively in the final return, and only on
// passes that recomputed everything with the current values — advancing them on other passes would
// silently absorb a change and suppress the full recompute its next delivery should cause.
const metaPatch: Partial<ReportAttributesDerivedValue> = {};
if (canPersistSignaturesWithoutRecompute && nextPolicySignatures !== storedPolicySignatures) {
metaPatch.policySignatures = nextPolicySignatures;
}
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] ?? {};
Expand Down Expand Up @@ -424,7 +553,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});
}
}

Expand Down Expand Up @@ -529,7 +658,7 @@ export default createOnyxDerivedValueConfig({
allPolicyTags: policyTags,
conciergeReportID: conciergeReportID ?? undefined,
reportAttributes: currentValue?.reports,
isTrackIntentUser: isTrackIntentUserSelector(introSelected),
isTrackIntentUser: nextIsTrackIntentUser,
})
: '',
isEmpty: generateIsEmptyReport(report, isReportArchived),
Expand Down Expand Up @@ -601,11 +730,26 @@ export default createOnyxDerivedValueConfig({
};
}

// A full scan just recomputed every report with the current policies, so the baseline can be
// snapshotted even when no policy trigger fired this pass (e.g. policies merged while computes
// were deferred during startup, or a locale-driven full recompute).
if (!useIncrementalUpdates && policies) {
nextPolicySignatures = buildAllPolicySignatures();
}

// The stored conciergeReportID/isTrackIntentUser must always equal the values the full attribute
// set was computed with, so they advance only when everything recomputed with the current values
// (or on the very first write). Incremental passes keep the stored baseline — if the current value
// drifted (e.g. a change landed while computes were deferred), the next delivery of that key still
// compares unequal and triggers the healing full recompute.
return {
reports: reportAttributes,
locale: preferredLocale ?? null,
policySignatures: nextPolicySignatures,
conciergeReportID: storedConciergeReportID === undefined || needsFullRecompute ? nextConciergeReportID : storedConciergeReportID,
isTrackIntentUser: storedIsTrackIntentUser === undefined || needsFullRecompute ? nextIsTrackIntentUser : storedIsTrackIntentUser,
};
},
});

export {hasPolicyRelevantFieldChanged};
export {policyRelevantSignature};
Loading
Loading