Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
28b09a0
schedule onyx derived values as macrotask
sosek108 Jun 25, 2026
127ce0b
Merge remote-tracking branch 'exfy/main' into perf/derived-value-macr…
sosek108 Jun 25, 2026
daac1e1
Merge branch 'main' into perf/derived-value-macrotask-scheduling
sosek108 Jun 26, 2026
32fa2b4
Merge branch 'main' into perf/derived-value-macrotask-scheduling
fabioh8010 Jul 2, 2026
fceec9a
temp
fabioh8010 Jul 3, 2026
1100c67
Cleanup
fabioh8010 Jul 3, 2026
0ebbee3
Merge branch 'main' into perf/derived-value-macrotask-scheduling-2
fabioh8010 Jul 3, 2026
24222c6
Fix formatting
fabioh8010 Jul 3, 2026
0842b78
fix: honor all triggers in coalesced reportTransactionsAndViolations …
fabioh8010 Jul 3, 2026
3288505
Implement scheduleMacrotask lib
fabioh8010 Jul 6, 2026
2006be2
Merge branch 'main' into perf/derived-value-macrotask-scheduling-2
fabioh8010 Jul 6, 2026
1f3ddea
Update cspell.json
fabioh8010 Jul 6, 2026
d9f95c6
Remove useCollectionDelta
fabioh8010 Jul 6, 2026
4d73906
Merge branch 'main' into perf/derived-value-macrotask-scheduling-2
fabioh8010 Jul 7, 2026
f28899c
Add try/catch to scheduleMacrotask
fabioh8010 Jul 7, 2026
8735ed1
Preserve OnyxDerived pending deltas when a coalesced compute throws
fabioh8010 Jul 7, 2026
291d0ff
Detect OnyxDerived triggers via triggeredKeys, not sourceValues
fabioh8010 Jul 7, 2026
d45adb7
Merge remote-tracking branch 'origin/main' into perf/derived-value-ma…
fabioh8010 Jul 7, 2026
3b6502a
Merge remote-tracking branch 'origin/main' into perf/derived-value-ma…
fabioh8010 Jul 8, 2026
fe9d4e4
Address comment
fabioh8010 Jul 8, 2026
4646e2e
Merge remote-tracking branch 'origin/main' into perf/derived-value-ma…
fabioh8010 Jul 8, 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
4 changes: 3 additions & 1 deletion cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -1047,7 +1047,9 @@
"Prefetch",
"Prefetcher",
"knip",
"lottiefiles"
"lottiefiles",
"macrotask",
"Macrotask"
],
"ignorePaths": [
".gitignore",
Expand Down
18 changes: 11 additions & 7 deletions src/libs/actions/OnyxDerived/configs/reportAttributes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,17 +117,21 @@ export default createOnyxDerivedValueConfig({
conciergeReportID,
introSelected,
],
{currentValue, sourceValues},
{currentValue, sourceValues, triggeredKeys},
) => {
// Read the in-memory offline state directly (NETWORK is a dependency so recompute still fires when it changes).
const isOffline = getIsOffline();
const translate: LocalizedTranslate = (path, ...parameters) => translateForLocale(preferredLocale, path, ...parameters);
// Check if display names changed when personal details are updated
let displayNamesChanged = false;
if (hasKeyTriggeredCompute(ONYXKEYS.PERSONAL_DETAILS_LIST, sourceValues)) {
if (hasKeyTriggeredCompute(ONYXKEYS.PERSONAL_DETAILS_LIST, triggeredKeys)) {
// Must run regardless — it updates the tracked previous display names.
displayNamesChanged = checkDisplayNamesChanged(personalDetails);

if (!displayNamesChanged) {
// Only short-circuit when personal details were the sole trigger; coalescing can batch them
// with report/transaction changes, and returning early would drop those.
const personalDetailsIsOnlyTrigger = triggeredKeys?.size === 1;
if (!displayNamesChanged && personalDetailsIsOnlyTrigger) {
return currentValue ?? {reports: {}, locale: null};
}
} else if (!sourceValues) {
Expand All @@ -139,14 +143,14 @@ export default createOnyxDerivedValueConfig({
// 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.
let needsFullRecompute =
(hasKeyTriggeredCompute(ONYXKEYS.NVP_PREFERRED_LOCALE, sourceValues) && preferredLocale !== currentValue?.locale) ||
(hasKeyTriggeredCompute(ONYXKEYS.NVP_PREFERRED_LOCALE, triggeredKeys) && preferredLocale !== currentValue?.locale) ||
displayNamesChanged ||
hasKeyTriggeredCompute(ONYXKEYS.CONCIERGE_REPORT_ID, sourceValues) ||
hasKeyTriggeredCompute(ONYXKEYS.NVP_INTRO_SELECTED, sourceValues);
hasKeyTriggeredCompute(ONYXKEYS.CONCIERGE_REPORT_ID, triggeredKeys) ||
hasKeyTriggeredCompute(ONYXKEYS.NVP_INTRO_SELECTED, triggeredKeys);

// if policies are loaded first time, we need to recompute all report attributes to get correct action badge in LHN, such as Approve because it depends on policy's type (see canApproveIOU function)
const policyChangedReportKeys: string[] = [];
if (hasKeyTriggeredCompute(ONYXKEYS.COLLECTION.POLICY, sourceValues)) {
if (hasKeyTriggeredCompute(ONYXKEYS.COLLECTION.POLICY, triggeredKeys)) {
if (Object.keys(previousPolicies ?? {}).length === 0 && Object.keys(policies ?? {}).length > 0) {
needsFullRecompute = true;
} else if (!needsFullRecompute) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,18 @@ export default createOnyxDerivedValueConfig({
const transactionsUpdates = sourceValues?.[ONYXKEYS.COLLECTION.TRANSACTION];
const transactionViolationsUpdates = sourceValues?.[ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS];
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),
);
// When we have a delta, process the union of transactions that changed directly and transactions
// whose violations changed. Coalescing can put both in the same flush, so an `if/else` would drop
// the second trigger (e.g. a transaction change for A batched with a violations change for B).
if (transactionsUpdates || transactionViolationsUpdates) {
const transactionKeys = new Set<string>();
for (const transactionKey of Object.keys(transactionsUpdates ?? {})) {
transactionKeys.add(transactionKey);
}
for (const transactionViolationKey of Object.keys(transactionViolationsUpdates ?? {})) {
transactionKeys.add(transactionViolationKey.replace(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, ONYXKEYS.COLLECTION.TRANSACTION));
}
transactionsToProcess = Array.from(transactionKeys);
}

const reportTransactionsAndViolations = currentValue ? {...currentValue} : {};
Expand Down
10 changes: 8 additions & 2 deletions src/libs/actions/OnyxDerived/configs/sortedReportActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ function computeForReport(
export default createOnyxDerivedValueConfig({
key: ONYXKEYS.DERIVED.RAM_ONLY_SORTED_REPORT_ACTIONS,
dependencies: [ONYXKEYS.COLLECTION.REPORT_ACTIONS, ONYXKEYS.COLLECTION.REPORT, ONYXKEYS.NETWORK],
compute: ([allReportActions, allReports], {sourceValues, currentValue}): SortedReportActionsDerivedValue => {
compute: ([allReportActions, allReports], {sourceValues, currentValue, triggeredKeys}): SortedReportActionsDerivedValue => {
if (!allReportActions) {
return EMPTY_VALUE;
}
Expand All @@ -53,8 +53,14 @@ export default createOnyxDerivedValueConfig({

const reportActionsUpdates = sourceValues?.[ONYXKEYS.COLLECTION.REPORT_ACTIONS];

// The incremental branch only knows how to react to report-action changes; REPORT/NETWORK changes are
// handled by the full recompute below. Coalescing can batch REPORT_ACTIONS with REPORT/NETWORK in a
// single flush, so only go incremental when report actions are the sole trigger — otherwise a batched
// REPORT change (e.g. chatReportID -> transactionThreadReportID) would be silently dropped.
const reportActionsIsOnlyTrigger = triggeredKeys?.size === 1;

// Incremental update: only recompute reports whose actions changed
if (reportActionsUpdates && currentValue) {
if (reportActionsUpdates && currentValue && reportActionsIsOnlyTrigger) {
const sortedActions = {...currentValue.sortedActions};
const lastActions = {...currentValue.lastActions};
const transactionThreadIDs = {...currentValue.transactionThreadIDs};
Expand Down
125 changes: 38 additions & 87 deletions src/libs/actions/OnyxDerived/configs/visibleReportActions.ts

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change is needed because of getCollectionDelta, it was the same change I applied in 076914f in #93438

Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,6 @@ import ONYXKEYS from '@src/ONYXKEYS';
import type {ReportAction, ReportActions} from '@src/types/onyx';
import type {VisibleReportActionsDerivedValue} from '@src/types/onyx/DerivedValues';

import type {OnyxEntry} from 'react-native-onyx';

function getOrCreateReportVisibilityRecord(result: VisibleReportActionsDerivedValue, reportID: string, clonedReportIDs: Set<string>): Record<string, boolean> {
if (!result[reportID]) {
// Parameter reassignment is necessary here because we are building up the derived value
// object incrementally as we process report actions. Creating a new object would break
// the reference chain and lose previously computed visibility data.
// eslint-disable-next-line no-param-reassign
result[reportID] = {};
clonedReportIDs.add(reportID);
} else if (!clonedReportIDs.has(reportID)) {
// Clone the existing entry to avoid mutating the cached value
// eslint-disable-next-line no-param-reassign
result[reportID] = {...result[reportID]};
clonedReportIDs.add(reportID);
}
return result[reportID];
}

/**
* Returns true if the action's visibility depends on runtime context that can't be cached,
* such as write permissions or policy settings.
Expand All @@ -33,96 +14,66 @@ function shouldSkipCachingAction(action: ReportAction): boolean {
return isActionableWhisperRequiringWritePermission(action) || isConciergeCategoryOptions(action);
}

/**
* Builds a report's action-visibility map (keyed by `reportActionID`) from its full set of report
* actions. Rebuilding the whole map rather than updating individual entries keeps deletions correct:
* a removed action is absent from `reportActions`, so it drops out of the result.
*/
function computeReportVisibility(reportActions: ReportActions): Record<string, boolean> {
const reportVisibility: Record<string, boolean> = {};

for (const [actionID, action] of Object.entries(reportActions)) {
if (!action) {
continue;
}
// Skip deprecated keys (e.g. sequenceNumber-keyed duplicates) so they
// cannot overwrite the canonical entry's visibility with false.
if (actionID !== action.reportActionID) {
continue;
}
if (shouldSkipCachingAction(action)) {
continue;
}
reportVisibility[action.reportActionID] = shouldReportActionBeVisible(action, actionID, undefined);
}

return reportVisibility;
}

export default createOnyxDerivedValueConfig({
key: ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS,
// Note: REPORT dependency is needed both to trigger recompute when reports change
// (for UNREPORTED_TRANSACTION/MOVED_TRANSACTION visibility) AND to provide the current
// report collection to the visibility check, avoiding stale data from global connections.
// SESSION dependency is needed for whisper targeting when user changes.
dependencies: [ONYXKEYS.COLLECTION.REPORT_ACTIONS, ONYXKEYS.SESSION],
compute: ([allReportActions], {sourceValues, currentValue}): VisibleReportActionsDerivedValue => {
compute: ([allReportActions], {sourceValues, currentValue, triggeredKeys}): VisibleReportActionsDerivedValue => {
if (!allReportActions) {
return {};
}

const reportActionsUpdates = sourceValues?.[ONYXKEYS.COLLECTION.REPORT_ACTIONS];
const sessionUpdates = sourceValues?.[ONYXKEYS.SESSION];

// Track which reportID entries have been cloned to avoid mutating cached nested objects.
const clonedReportIDs = new Set<string>();

// Session change = user changed, need full recompute due to whisper targeting
if (sessionUpdates) {
const result: VisibleReportActionsDerivedValue = {};
// Recompute only the reports whose actions changed when we have a usable delta. Otherwise
// recompute everything: on first load, when there's no delta, or on a SESSION change (the
// user changed, which affects whisper targeting for every report). SESSION is checked via
// triggeredKeys, not sourceValues, so a session cleared to `undefined` still forces the full recompute.
const isIncremental = !!reportActionsUpdates && !triggeredKeys?.has(ONYXKEYS.SESSION) && !!currentValue;

for (const [reportActionsKey, reportActions] of Object.entries(allReportActions)) {
if (!reportActions) {
continue;
}
const result: VisibleReportActionsDerivedValue = isIncremental ? {...currentValue} : {};
const reportActionsKeysToProcess = isIncremental ? Object.keys(reportActionsUpdates) : Object.keys(allReportActions);

const reportID = reportActionsKey.replace(ONYXKEYS.COLLECTION.REPORT_ACTIONS, '');
const reportVisibility = getOrCreateReportVisibilityRecord(result, reportID, clonedReportIDs);

for (const [actionID, action] of Object.entries(reportActions)) {
if (action) {
if (actionID !== action.reportActionID) {
continue;
}
if (shouldSkipCachingAction(action)) {
continue;
}
reportVisibility[action.reportActionID] = shouldReportActionBeVisible(action, actionID, undefined);
}
}
}

return result;
}

const result: VisibleReportActionsDerivedValue = currentValue ? {...currentValue} : {};

const reportActionsToProcess = reportActionsUpdates ? Object.keys(reportActionsUpdates) : Object.keys(allReportActions);

for (const reportActionsKey of reportActionsToProcess) {
const reportActions: OnyxEntry<ReportActions> = allReportActions[reportActionsKey];
for (const reportActionsKey of reportActionsKeysToProcess) {
const reportID = reportActionsKey.replace(ONYXKEYS.COLLECTION.REPORT_ACTIONS, '');
const reportActions = allReportActions[reportActionsKey];

// The member was removed entirely — drop the report from the result.
if (!reportActions) {
delete result[reportID];
continue;
}

const reportVisibility = getOrCreateReportVisibilityRecord(result, reportID, clonedReportIDs);

const specificUpdates = reportActionsUpdates?.[reportActionsKey];
const actionIDsToProcess = specificUpdates ? Object.keys(specificUpdates) : Object.keys(reportActions);

for (const actionID of actionIDsToProcess) {
if (specificUpdates?.[actionID] === null) {
delete reportVisibility[actionID];
continue;
}

const action = reportActions[actionID];
if (!action) {
delete reportVisibility[actionID];
continue;
}

// Skip deprecated keys (e.g. sequenceNumber-keyed duplicates) so they
// cannot overwrite the canonical entry's visibility with false.
if (actionID !== action.reportActionID) {
delete reportVisibility[actionID];
continue;
}

if (shouldSkipCachingAction(action)) {
delete reportVisibility[action.reportActionID];
continue;
}

reportVisibility[action.reportActionID] = shouldReportActionBeVisible(action, actionID, undefined);
}
result[reportID] = computeReportVisibility(reportActions);
}

return result;
Expand Down
Loading
Loading