Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
edc4956
Decouple NewReportWorkspaceSelection from TODOS derived value
TMisiukiewicz Jun 24, 2026
94ca90e
Extract createTodosReportsAndTransactions into shared TodosUtils
TMisiukiewicz Jun 24, 2026
7aa0ee6
Add useTodoCounts hook and migrate count consumers off TODOS derived …
TMisiukiewicz Jun 24, 2026
ffbd138
Add useTodoSearchResults hook for single-category live to-do data
TMisiukiewicz Jun 24, 2026
7417fce
Switch SearchResultsProvider to useTodoSearchResults and remove useTodos
TMisiukiewicz Jun 24, 2026
34b437c
Merge remote-tracking branch 'origin/main' into perf/remove-todos-der…
TMisiukiewicz Jun 26, 2026
ef0b379
Remove unused TODOS derived value
TMisiukiewicz Jun 26, 2026
1c4c7bc
Freeze useTodoCounts when consumer is disabled
TMisiukiewicz Jun 26, 2026
16e5709
Freeze useTodoCounts in ForYouSection when home is unfocused
TMisiukiewicz Jun 26, 2026
e681dbb
Add useTodoCounts tests and fix TODOS derived-value removal fallout
TMisiukiewicz Jun 26, 2026
9aa3af6
code cleanup
TMisiukiewicz Jun 26, 2026
6474ce9
Merge remote-tracking branch 'origin/main' into perf/remove-todos-der…
TMisiukiewicz Jun 26, 2026
82ebb7a
Merge remote-tracking branch 'origin/main' into perf/remove-todos-der…
TMisiukiewicz Jun 29, 2026
76b5ea8
fix knip
TMisiukiewicz Jun 29, 2026
28ef04f
Merge remote-tracking branch 'origin/main' into perf/remove-todos-der…
TMisiukiewicz Jun 29, 2026
d045b14
Extract shared reportMatchesTodoBucket helper to dedupe todo classifi…
TMisiukiewicz Jun 29, 2026
a9d81bf
Treat empty todo search bucket as no results
TMisiukiewicz Jun 29, 2026
e68066f
Merge remote-tracking branch 'origin/main' into perf/remove-todos-der…
TMisiukiewicz Jun 29, 2026
4aa8a23
Merge branch 'main' into perf/remove-todos-derived-value
TMisiukiewicz Jun 29, 2026
9c9c1ba
remove unused EMPTY_TODOS_REPORT_COUNTS
TMisiukiewicz Jun 29, 2026
d795a89
add todos docs and tests
TMisiukiewicz Jun 29, 2026
ae9c537
fix lint in TodosUtilsTest
TMisiukiewicz Jun 29, 2026
7a7fe10
Merge remote-tracking branch 'origin/main' into perf/remove-todos-der…
TMisiukiewicz Jun 29, 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
1 change: 1 addition & 0 deletions config/eslint/eslint.seatbelt.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -2018,6 +2018,7 @@
"../../tests/unit/useSubPageTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../tests/unit/useSubStepTest.tsx" "@typescript-eslint/no-deprecated/useSubStep" 13
"../../tests/unit/useSyncFocusTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../tests/unit/useTodoCountsTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../tests/unit/useTodosTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 4
"../../tests/unit/useTransactionViolationsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 21
"../../tests/unit/useWorkspacesTabIndicatorStatusTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 6
Expand Down
7 changes: 0 additions & 7 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,6 @@ const EMPTY_ARRAY = Object.freeze([]);
const EMPTY_OBJECT = Object.freeze({});
// Shared immutable map used in hot paths that only read from the instance.
const EMPTY_MAP = new Map<string, string>();
const EMPTY_TODOS_REPORT_COUNTS = Object.freeze({
submit: 0,
approve: 0,
pay: 0,
export: 0,
});

// Using 28 days to align with OldDot and because all months are guaranteed to be at least 28 days.
const MONTH_DAYS = Object.freeze([...Array(28).keys()].map((i) => i + 1));
Expand Down Expand Up @@ -1241,7 +1235,6 @@ const CONST = {
EMPTY_ARRAY,
EMPTY_OBJECT,
EMPTY_MAP,
EMPTY_TODOS_REPORT_COUNTS,
DEFAULT_NUMBER_ID,
DEFAULT_MISSING_ID,
DEFAULT_COUNTRY_CODE,
Expand Down
2 changes: 0 additions & 2 deletions src/ONYXKEYS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1194,7 +1194,6 @@ const ONYXKEYS = {
NON_PERSONAL_AND_WORKSPACE_CARD_LIST: 'nonPersonalAndWorkspaceCardList',
PERSONAL_AND_WORKSPACE_CARD_LIST: 'personalAndWorkspaceCardList',
CARD_FEED_ERRORS: 'cardFeedErrors',
TODOS: 'todos',
RAM_ONLY_SORTED_REPORT_ACTIONS: 'sortedReportActions',
FLAGGED_EXPENSES: 'flaggedExpenses',
},
Expand Down Expand Up @@ -1682,7 +1681,6 @@ type OnyxDerivedValuesMapping = {
[ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST]: OnyxTypes.NonPersonalAndWorkspaceCardListDerivedValue;
[ONYXKEYS.DERIVED.PERSONAL_AND_WORKSPACE_CARD_LIST]: OnyxTypes.PersonalAndWorkspaceCardListDerivedValue;
[ONYXKEYS.DERIVED.CARD_FEED_ERRORS]: OnyxTypes.CardFeedErrorsDerivedValue;
[ONYXKEYS.DERIVED.TODOS]: OnyxTypes.TodosDerivedValue;
[ONYXKEYS.DERIVED.RAM_ONLY_SORTED_REPORT_ACTIONS]: OnyxTypes.SortedReportActionsDerivedValue;
[ONYXKEYS.DERIVED.FLAGGED_EXPENSES]: OnyxTypes.FlaggedExpensesDerivedValue;
};
Expand Down
10 changes: 5 additions & 5 deletions src/components/Search/SearchResultsProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import React, {useState} from 'react';
// so it would add nothing for this read. Use the raw react-native-onyx hook directly.
// eslint-disable-next-line no-restricted-imports
import {useOnyx} from 'react-native-onyx';
import useTodos from '@hooks/useTodos';
import useTodoSearchResults from '@hooks/useTodoSearchResults';
import {isTodoSearch} from '@libs/SearchUIUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
Expand Down Expand Up @@ -36,16 +36,16 @@ function SearchResultsProvider({children}: SearchResultsProviderProps) {
const {currentSearchHash, currentSearchKey, currentSearchQueryJSON, suggestedSearches} = useSearchQueryContext();
const currentRecentSearchHash = currentSearchQueryJSON?.recentSearchHash ?? -1;

const todoSearchResultsData = useTodos();
const [snapshotSearchResults] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${currentSearchHash}`);

const shouldUseLiveData = !!currentSearchKey && isTodoSearch(currentRecentSearchHash, suggestedSearches);
const liveTodoData = useTodoSearchResults(shouldUseLiveData ? currentSearchKey : undefined);

// If viewing a to-do search, use live data from useTodos, otherwise return the snapshot data
// We do this so that we can show the counters for the to-do search results without visiting the specific to-do page, e.g. show `Approve [3]` while viewing the `Submit` to-do search.
// If viewing a to-do search, use live Onyx data for the active category, otherwise return the snapshot data.
// We do this so the results stay fresh as the user acts on reports, instead of showing a stale server snapshot.
let currentSearchResults;
if (shouldUseLiveData) {
const liveData = todoSearchResultsData[currentSearchKey as keyof typeof todoSearchResultsData];
const liveData = liveTodoData ?? {data: {}, metadata: {count: 0, total: 0, currency: undefined}};
const searchInfo: SearchResultsInfo = {
...(snapshotSearchResults?.search ?? defaultSearchInfo),
count: liveData.metadata.count,
Expand Down
95 changes: 95 additions & 0 deletions src/hooks/useTodoCounts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import {useState} from 'react';
// We need direct access to useOnyx from react-native-onyx to avoid reading search snapshots instead of live to-do data
// eslint-disable-next-line no-restricted-imports
import {useOnyx} from 'react-native-onyx';
import createTodosReportsAndTransactions from '@libs/TodosUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';

type TodoCounts = {
[CONST.SEARCH.SEARCH_KEYS.SUBMIT]: number;
[CONST.SEARCH.SEARCH_KEYS.APPROVE]: number;
[CONST.SEARCH.SEARCH_KEYS.PAY]: number;
[CONST.SEARCH.SEARCH_KEYS.EXPORT]: number;
};

type TodoSingleReportIDs = {
[CONST.SEARCH.SEARCH_KEYS.SUBMIT]: string | undefined;
[CONST.SEARCH.SEARCH_KEYS.APPROVE]: string | undefined;
[CONST.SEARCH.SEARCH_KEYS.PAY]: string | undefined;
[CONST.SEARCH.SEARCH_KEYS.EXPORT]: string | undefined;
};

const TODO_KEYS = [CONST.SEARCH.SEARCH_KEYS.SUBMIT, CONST.SEARCH.SEARCH_KEYS.APPROVE, CONST.SEARCH.SEARCH_KEYS.PAY, CONST.SEARCH.SEARCH_KEYS.EXPORT] as const;

/**
* Computes live to-do report counts and, for each bucket that contains exactly one report, that report's ID.
* Runs the to-do classification on demand from live Onyx data, only while a consumer is mounted, replacing the
* always-on TODOS derived value for count consumers.
*
* Pass `enabled: false` to freeze the hook: while disabled it skips the expensive classification and returns the
* last computed result, so e.g. an unfocused screen stops recomputing to-do counts on background Onyx writes.
*/
function useTodoCounts(enabled = true): {counts: TodoCounts; singleReportIDs: TodoSingleReportIDs} {
const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT);
const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY);
const [allReportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS);
const [allTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION);
const [allReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS);
const [allReportMetadata] = useOnyx(ONYXKEYS.COLLECTION.REPORT_METADATA);
const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST);
const [session] = useOnyx(ONYXKEYS.SESSION);
const [personalDetailsList] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST);

// Holds the most recent result so a frozen (inactive) consumer can keep returning it without recomputing.
const [frozen, setFrozen] = useState<{counts: TodoCounts; singleReportIDs: TodoSingleReportIDs} | null>(null);

// While frozen, reuse the last captured result and skip the expensive classification below.
if (!enabled && frozen) {
return frozen;
}

const userAccountID = session?.accountID ?? CONST.DEFAULT_NUMBER_ID;
const login = personalDetailsList?.[userAccountID]?.login ?? session?.email ?? '';

const {reportsToSubmit, reportsToApprove, reportsToPay, reportsToExport} = createTodosReportsAndTransactions({
allReports,
allTransactions,
allPolicies,
allReportNameValuePairs,
allReportActions,
allReportMetadata,
personalDetailsList,
bankAccountList,
currentUserAccountID: userAccountID,
login,
});

const counts: TodoCounts = {
[CONST.SEARCH.SEARCH_KEYS.SUBMIT]: reportsToSubmit.length,
[CONST.SEARCH.SEARCH_KEYS.APPROVE]: reportsToApprove.length,
[CONST.SEARCH.SEARCH_KEYS.PAY]: reportsToPay.length,
[CONST.SEARCH.SEARCH_KEYS.EXPORT]: reportsToExport.length,
};

const singleReportIDs: TodoSingleReportIDs = {
[CONST.SEARCH.SEARCH_KEYS.SUBMIT]: reportsToSubmit.length === 1 ? reportsToSubmit.at(0)?.reportID : undefined,
[CONST.SEARCH.SEARCH_KEYS.APPROVE]: reportsToApprove.length === 1 ? reportsToApprove.at(0)?.reportID : undefined,
[CONST.SEARCH.SEARCH_KEYS.PAY]: reportsToPay.length === 1 ? reportsToPay.at(0)?.reportID : undefined,
[CONST.SEARCH.SEARCH_KEYS.EXPORT]: reportsToExport.length === 1 ? reportsToExport.at(0)?.reportID : undefined,
};

const value = {counts, singleReportIDs};

// Capture the latest result so it can be returned verbatim once the consumer freezes. Only re-store when a
// count or single-report ID actually changes, so the setState-during-render can't loop.
const hasChanged = !frozen || TODO_KEYS.some((key) => frozen.counts[key] !== counts[key] || frozen.singleReportIDs[key] !== singleReportIDs[key]);
if (hasChanged) {
setFrozen(value);
}

return value;
}

export default useTodoCounts;
export type {TodoCounts};
110 changes: 60 additions & 50 deletions src/hooks/useTodos.ts → src/hooks/useTodoSearchResults.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
// We need direct access to useOnyx from react-native-onyx to avoid using snapshots for live to-do data
// We need direct access to useOnyx from react-native-onyx to avoid reading search snapshots instead of live to-do data
// eslint-disable-next-line no-restricted-imports
import {useOnyx} from 'react-native-onyx';
import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
import type {SearchKey} from '@libs/SearchUIUtils';
import {getTodoReportsForSearchKey} from '@libs/TodosUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {PersonalDetailsList, Policy, Report, ReportActions, ReportNameValuePairs, SearchResults, Transaction, TransactionViolations} from '@src/types/onyx';
Expand Down Expand Up @@ -52,13 +55,13 @@ function computeMetadata(reports: Report[], transactionsByReportID: Record<strin
function buildSearchResultsData(
reports: Report[],
transactionsByReportID: Record<string, Transaction[]>,
allPolicies: Record<string, Policy> | undefined,
allReportActions: Record<string, ReportActions> | undefined,
allReportNameValuePairs: Record<string, ReportNameValuePairs> | undefined,
personalDetails: PersonalDetailsList | undefined,
transactionViolations: Record<string, TransactionViolations> | undefined,
allPolicies: OnyxCollection<Policy>,
allReportActions: OnyxCollection<ReportActions>,
allReportNameValuePairs: OnyxCollection<ReportNameValuePairs>,
personalDetails: OnyxEntry<PersonalDetailsList>,
transactionViolations: OnyxCollection<TransactionViolations>,
): TodoSearchResultsData {
const data: Record<string, unknown> = {};
const data: TodoSearchResultsData = {};

for (const report of reports) {
if (!report?.reportID) {
Expand All @@ -67,7 +70,7 @@ function buildSearchResultsData(
data[`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`] = report;

if (report.policyID && allPolicies) {
const policyKey = `${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`;
const policyKey = `${ONYXKEYS.COLLECTION.POLICY}${report.policyID}` as const;
if (allPolicies[policyKey] && !data[policyKey]) {
data[policyKey] = allPolicies[policyKey];
}
Expand All @@ -76,14 +79,14 @@ function buildSearchResultsData(
// Add the report name value pairs for the chat report (needed for pay eligibility checks)
// Note: We don't add the chat report itself to match API behavior and avoid affecting shouldShowYear calculations
if (report.chatReportID && allReportNameValuePairs) {
const nvpKey = `${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.chatReportID}`;
const nvpKey = `${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.chatReportID}` as const;
if (allReportNameValuePairs[nvpKey] && !data[nvpKey]) {
data[nvpKey] = allReportNameValuePairs[nvpKey];
}
}

if (allReportActions) {
const actionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`;
const actionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}` as const;
if (allReportActions[actionsKey] && !data[actionsKey]) {
data[actionsKey] = allReportActions[actionsKey];
}
Expand All @@ -93,11 +96,11 @@ function buildSearchResultsData(
const reportTransactions = transactionsByReportID[report.reportID];
if (reportTransactions) {
for (const transaction of reportTransactions) {
const transactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`;
const transactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}` as const;
data[transactionKey] = transaction;

if (transactionViolations) {
const violationsKey = `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transaction.transactionID}`;
const violationsKey = `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transaction.transactionID}` as const;
if (transactionViolations[violationsKey]) {
data[violationsKey] = transactionViolations[violationsKey];
}
Expand All @@ -106,52 +109,59 @@ function buildSearchResultsData(
}
}

if (personalDetails) {
data[ONYXKEYS.PERSONAL_DETAILS_LIST] = personalDetails;
// Only attach personal details once we have at least one matching report. Otherwise an empty bucket would still
// produce a non-empty `data` object, which SearchResultsProvider reads as "has results" and shows the wrong
// empty state.
if (personalDetails && reports.length > 0) {
// PersonalDetailsList allows null values whereas the snapshot type does not; merge via Object.assign to keep
// the live data faithful (including any null entries) without an unsafe narrowing assertion.
Object.assign(data, {[ONYXKEYS.PERSONAL_DETAILS_LIST]: personalDetails});
}

return data as TodoSearchResultsData;
return data;
}

export default function useTodos() {
/**
* Builds live SearchResults-shaped data and metadata for a single to-do search (submit/approve/pay/export).
* Returns `undefined` (and skips all classification work) when `searchKey` is not provided, so the app-wide
* SearchResultsProvider only does work while a to-do search is actually being viewed.
*/
function useTodoSearchResults(searchKey: SearchKey | undefined): {data: TodoSearchResultsData; metadata: TodoMetadata} | undefined {
const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT);
const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY);
const [allReportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS);
const [allTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION);
const [allReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS);
Comment thread
TMisiukiewicz marked this conversation as resolved.
const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS);
const [allReportMetadata] = useOnyx(ONYXKEYS.COLLECTION.REPORT_METADATA);
const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST);
const [session] = useOnyx(ONYXKEYS.SESSION);
const [personalDetailsList] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST);
const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS);

const [todosReports] = useOnyx(ONYXKEYS.DERIVED.TODOS);

const transactionsByReportID = todosReports?.transactionsByReportID ?? CONST.EMPTY_OBJECT;

// Build SearchResults-formatted data for each to-do category
const buildData = (reports?: Report[]): {data: TodoSearchResultsData; metadata: TodoMetadata} => {
if (!reports?.length) {
// Return empty object like the Search API would when there's no data
return {
data: CONST.EMPTY_OBJECT as TodoSearchResultsData,
metadata: {count: 0, total: 0, currency: undefined},
};
}
if (!searchKey) {
return undefined;
}

const metadata = computeMetadata(reports, transactionsByReportID);
const data = buildSearchResultsData(
reports,
transactionsByReportID,
allPolicies as Record<string, Policy> | undefined,
allReportActions as Record<string, ReportActions> | undefined,
allReportNameValuePairs as Record<string, ReportNameValuePairs> | undefined,
personalDetailsList,
allTransactionViolations as Record<string, TransactionViolations> | undefined,
);

return {data, metadata};
};

return {
[CONST.SEARCH.SEARCH_KEYS.SUBMIT]: buildData(todosReports?.reportsToSubmit),
[CONST.SEARCH.SEARCH_KEYS.APPROVE]: buildData(todosReports?.reportsToApprove),
[CONST.SEARCH.SEARCH_KEYS.PAY]: buildData(todosReports?.reportsToPay),
[CONST.SEARCH.SEARCH_KEYS.EXPORT]: buildData(todosReports?.reportsToExport),
};
const userAccountID = session?.accountID ?? CONST.DEFAULT_NUMBER_ID;
const login = personalDetailsList?.[userAccountID]?.login ?? session?.email ?? '';

const {reports, transactionsByReportID} = getTodoReportsForSearchKey(searchKey, {
allReports,
allTransactions,
allPolicies,
allReportNameValuePairs,
allReportActions,
allReportMetadata,
personalDetailsList,
bankAccountList,
currentUserAccountID: userAccountID,
login,
});

const metadata = computeMetadata(reports, transactionsByReportID);
const data = buildSearchResultsData(reports, transactionsByReportID, allPolicies, allReportActions, allReportNameValuePairs, personalDetailsList, allTransactionViolations);

return {data, metadata};
}

export default useTodoSearchResults;
Loading
Loading