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
45df747
Precompute sorted report IDs in Search to avoid duplicate getSections…
leshniak Mar 24, 2026
9d2936c
Rename useSortedSearchResults/useSearchNavigationState to useSearchSe…
leshniak Mar 25, 2026
b90bfcd
Simplify useSearchSections interface and fix sortedReportIDs paginati…
leshniak Mar 25, 2026
5b1cb48
Move sorted report IDs from Onyx to Search context
leshniak Mar 25, 2026
da38d8a
Move setSortedReportIDs effect into useSearchSections
leshniak Mar 25, 2026
7ef0211
Extract sortedReportIDs as useMemo for React Compiler compliance
leshniak Mar 25, 2026
a8013a5
perf: add render instrumentation to MoneyRequestReportNavigation
leshniak Mar 25, 2026
84a2a61
perf: improve render instrumentation with timing and item count
leshniak Mar 25, 2026
e88c2b4
Merge upstream/main — adopt shouldShowActionsBarLoading rename and us…
leshniak Mar 26, 2026
2b1a49b
Extract useFilterPendingDeleteReports hook
leshniak Mar 30, 2026
f1bc6a6
Add sortedReportIDs state to SearchContext
leshniak Mar 30, 2026
877b5e9
Precompute sorted report IDs in Search via useSaveSortedReportIDs
leshniak Mar 30, 2026
b00fda0
Fast-path MoneyRequestReportNavigation via pre-computed context IDs
leshniak Mar 30, 2026
c9c40e4
Add tests for MoneyRequestReportNavigation routing and useSearchSections
leshniak Mar 30, 2026
78706ca
Fix tsc error: cast partial Report mock to unknown first
leshniak Mar 30, 2026
98a7645
perf-11 fixes
leshniak Mar 30, 2026
8d9171a
linter fix
leshniak May 13, 2026
4ce6f7f
Merge remote-tracking branch 'upstream/main' into perf/search-section…
leshniak May 13, 2026
a90e96e
Merge remote-tracking branch 'upstream/main' into perf/search-section…
leshniak May 18, 2026
2120637
Merge remote-tracking branch 'upstream/main' into perf/search-section…
leshniak May 19, 2026
9a25389
Remove unused eslint-disable directives for @typescript-eslint/naming…
leshniak May 19, 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 @@ -2,7 +2,9 @@ import React, {useEffect, useState} from 'react';
import {View} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
import PrevNextButtons from '@components/PrevNextButtons';
import {useSearchStateContext} from '@components/Search/SearchContext';
import Text from '@components/Text';
import useFilterPendingDeleteReports from '@hooks/useFilterPendingDeleteReports';
import useOnyx from '@hooks/useOnyx';
import useSearchSections from '@hooks/useSearchSections';
import useThemeStyles from '@hooks/useThemeStyles';
Expand All @@ -20,6 +22,12 @@ type MoneyRequestReportNavigationProps = {
shouldDisplayNarrowVersion: boolean;
};

type MoneyRequestReportNavigationContentProps = MoneyRequestReportNavigationProps & {
allReports: Array<string | undefined>;
isSearchLoading: boolean;
lastSearchQuery: OnyxEntry<LastSearchParams>;
};

type SnapshotGuard = {
hasMultiple: boolean;
includesReport: boolean;
Expand Down Expand Up @@ -71,8 +79,7 @@ const buildSnapshotGuardSelector =
return {hasMultiple: count > 1, includesReport};
};

function MoneyRequestReportNavigationInner({reportID, shouldDisplayNarrowVersion}: MoneyRequestReportNavigationProps) {
const {allReports, isSearchLoading, lastSearchQuery} = useSearchSections();
function MoneyRequestReportNavigationContent({reportID, shouldDisplayNarrowVersion, allReports, isSearchLoading, lastSearchQuery}: MoneyRequestReportNavigationContentProps) {
const styles = useThemeStyles();

const liveCurrentIndex = allReports.indexOf(reportID);
Expand Down Expand Up @@ -192,19 +199,43 @@ function MoneyRequestReportNavigationInner({reportID, shouldDisplayNarrowVersion
);
}

// All Onyx subscriptions via useSearchSections. Mounts if there are no sorted report IDs in the context.
function MoneyRequestReportNavigationStandalone({reportID, shouldDisplayNarrowVersion}: MoneyRequestReportNavigationProps) {
const {allReports, isSearchLoading, lastSearchQuery} = useSearchSections();

return (
<MoneyRequestReportNavigationContent
reportID={reportID}
shouldDisplayNarrowVersion={shouldDisplayNarrowVersion}
allReports={allReports}
isSearchLoading={isSearchLoading}
lastSearchQuery={lastSearchQuery}
/>
);
}

function MoneyRequestReportNavigation({reportID, shouldDisplayNarrowVersion}: MoneyRequestReportNavigationProps) {
// Guard: only mount inner tree when snapshot confirms multiple expense reports
const [isExpenseReportSearch] = useOnyx(ONYXKEYS.REPORT_NAVIGATION_LAST_SEARCH_QUERY, {selector: selectIsExpenseReportSearch});
const [hash] = useOnyx(ONYXKEYS.REPORT_NAVIGATION_LAST_SEARCH_QUERY, {selector: selectQueryHash});
const snapshotGuardSelector = buildSnapshotGuardSelector(reportID);
const [snapshotGuard = EMPTY_GUARD] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`, {selector: snapshotGuardSelector});

// Fast-path hooks (always called to satisfy rules of hooks)
const {sortedReportIDs} = useSearchStateContext();
const [lastSearchQuery] = useOnyx(ONYXKEYS.REPORT_NAVIGATION_LAST_SEARCH_QUERY);
const searchLoadingSelector = (data: OnyxEntry<SearchResults>) => !!data?.search?.isLoading;
const [isSearchLoading = false] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${lastSearchQuery?.queryJSON?.hash}`, {
selector: searchLoadingSelector,
});
const allReports = useFilterPendingDeleteReports(sortedReportIDs);

const isLiveGuardSatisfied = isExpenseReportSearch && snapshotGuard.hasMultiple && snapshotGuard.includesReport;

// Once the live snapshot has satisfied the guard during this mount, keep the inner component
// mounted even if the guard later flips false (e.g. the current report is removed from the
// snapshot after approving it). The inner component falls back to a cached list so the
// carousel stays visible for continued navigation. setState during render is the React-
// recommended pattern for storing information from previous renders.
// carousel stays visible for continued navigation.
const [shouldKeepMounted, setShouldKeepMounted] = useState(false);
if (isLiveGuardSatisfied && !shouldKeepMounted) {
setShouldKeepMounted(true);
Expand All @@ -214,8 +245,22 @@ function MoneyRequestReportNavigation({reportID, shouldDisplayNarrowVersion}: Mo
return null;
}

// Fast path: use pre-computed IDs from context when available and no pagination is in flight.
// During pagination fall back to full subscription so new pages are reflected immediately.
if (allReports.length > 0 && !isSearchLoading) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep using full search sections after pagination completes

Switching back to the fast path as soon as isSearchLoading becomes false can reuse a stale sortedReportIDs list after search() loads another page from the report view. In that window (or whenever Search isn’t mounted to refresh context), allReports may still contain only the old page, so pressing Next at the end of loaded results wraps to the first item instead of navigating into newly fetched reports. The fallback should remain active until the context IDs are confirmed to match the updated snapshot/query state.

Useful? React with 👍 / 👎.

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 works only with expense-reports in Search, so if the user actively browses expense reports. It falls back to the full logic in every other case.

return (
<MoneyRequestReportNavigationContent
reportID={reportID}
shouldDisplayNarrowVersion={shouldDisplayNarrowVersion}
allReports={allReports}
isSearchLoading={isSearchLoading}
lastSearchQuery={lastSearchQuery}
/>
);
}

return (
<MoneyRequestReportNavigationInner
<MoneyRequestReportNavigationStandalone
reportID={reportID}
shouldDisplayNarrowVersion={shouldDisplayNarrowVersion}
/>
Expand Down
12 changes: 12 additions & 0 deletions src/components/Search/SearchContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const defaultSearchContextData: SearchContextData = {
currentSearchHash: -1,
currentSimilarSearchHash: -1,
suggestedSearches: {} as Record<SearchKey, SearchTypeMenuItem>,
sortedReportIDs: CONST.EMPTY_ARRAY,
};

const defaultSearchStateContext: SearchStateContextValue = {
Expand All @@ -77,6 +78,7 @@ const defaultSearchActionsContext: SearchActionsContextValue = {
setShouldShowSelectAllMatchingItems: () => {},
selectAllMatchingItems: () => {},
setShouldResetSearchQuery: () => {},
setSortedReportIDs: () => {},
};

const SearchStateContext = React.createContext<SearchStateContextValue>(defaultSearchStateContext);
Expand Down Expand Up @@ -316,6 +318,15 @@ function SearchContextProvider({children}: SearchContextProps) {
}));
};

const setSortedReportIDs = (newIDs: ReadonlyArray<string | undefined>) => {
setSearchContextData((prev) => {
// ensure that we don't save the same report IDs unless they are really different
const hasChanged = prev.sortedReportIDs.length !== newIDs.length || prev.sortedReportIDs.some((id, i) => id !== newIDs.at(i));

return hasChanged ? {...prev, sortedReportIDs: newIDs} : prev;
});
};

const searchStateContextValue: SearchStateContextValue = {
...searchContextData,
suggestedSearches,
Expand All @@ -342,6 +353,7 @@ function SearchContextProvider({children}: SearchContextProps) {
setShouldShowSelectAllMatchingItems,
selectAllMatchingItems,
setShouldResetSearchQuery,
setSortedReportIDs,
};

return (
Expand Down
3 changes: 3 additions & 0 deletions src/components/Search/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses';
import usePrevious from '@hooks/usePrevious';
import useReportAttributes from '@hooks/useReportAttributes';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useSaveSortedReportIDs from '@hooks/useSaveSortedReportIDs';
import useSearchHighlightAndScroll from '@hooks/useSearchHighlightAndScroll';
import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals';
import useStableArrayReference from '@hooks/useStableArrayReference';
Expand Down Expand Up @@ -1286,6 +1287,8 @@ function Search({
[type, status, filteredData, localeCompare, translate, sortBy, sortOrder, validGroupBy, isChat, newSearchResultKeys, hash],
);

useSaveSortedReportIDs(type, sortedData);

const {stableSortedData, hasCachedOptimisticItem} = useStableOptimisticSortedData(sortedData, searchResults, optimisticTrackingState);

useEffect(() => {
Expand Down
2 changes: 2 additions & 0 deletions src/components/Search/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ type SearchContextData = {
isOnSearch: boolean;
shouldTurnOffSelectionMode: boolean;
shouldResetSearchQuery: boolean;
sortedReportIDs: ReadonlyArray<string | undefined>;
/** True when at least one transaction is selected. */
hasSelectedTransactions: boolean;
};
Expand Down Expand Up @@ -212,6 +213,7 @@ type SearchActionsContextValue = {
setShouldShowSelectAllMatchingItems: (shouldShow: boolean) => void;
selectAllMatchingItems: (on: boolean) => void;
setShouldResetSearchQuery: (shouldReset: boolean) => void;
setSortedReportIDs: (ids: ReadonlyArray<string | undefined>) => void;
};

type ASTNode = {
Expand Down
39 changes: 39 additions & 0 deletions src/hooks/useFilterPendingDeleteReports.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type {OnyxCollection} from 'react-native-onyx';
import {isReportPendingDelete} from '@libs/ReportUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Report} from '@src/types/onyx';
import useOnyx from './useOnyx';

/**
* Returns sorted keys of reports pending deletion.
* Sorted string[] keeps Onyx comparison cheap (PERF-11).
*/
const selectPendingDeleteReportKeys = (reports: OnyxCollection<Report>): string[] => {
const keys: string[] = [];
for (const [key, report] of Object.entries(reports ?? {})) {
if (isReportPendingDelete(report)) {
keys.push(key);
}
}
return keys.sort();
};

/**
* Filters out report IDs whose corresponding reports have a pending DELETE action.
* Subscribes to the REPORT collection with a lightweight selector to minimize re-renders.
*/
function useFilterPendingDeleteReports(ids: ReadonlyArray<string | undefined>): Array<string | undefined> {
const [pendingDeleteReportKeys = CONST.EMPTY_ARRAY] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {selector: selectPendingDeleteReportKeys});
const pendingDeleteReportKeysSet = new Set(pendingDeleteReportKeys);

return ids.filter((id) => {
if (!id) {
return false;
}
return !pendingDeleteReportKeysSet.has(`${ONYXKEYS.COLLECTION.REPORT}${id}`);
});
}

export {selectPendingDeleteReportKeys};
export default useFilterPendingDeleteReports;
30 changes: 30 additions & 0 deletions src/hooks/useSaveSortedReportIDs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import {useEffect} from 'react';
import {useSearchActionsContext} from '@components/Search/SearchContext';
import CONST from '@src/CONST';
import type {SearchDataTypes} from '@src/types/onyx/SearchResults';

/**
* Persists sorted report IDs to Search context so that MoneyRequestReportNavigation
* can read them without recomputing getSortedSections.
* Only stores IDs for expense-report searches; clears the context for all other types
* to force the fallback computation in the navigation header.
*/
function useSaveSortedReportIDs(type: SearchDataTypes, items: Array<{reportID?: string | undefined}>) {
const {setSortedReportIDs} = useSearchActionsContext();

useEffect(() => {
// Only expense-report searches produce report-level IDs suitable for navigation arrows.
// For all other types (expense, invoice, etc.) the items are transaction-level and share
// reportIDs, so we clear the context to force the fallback computation in navigation.
if (type !== CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT) {
setSortedReportIDs([]);
return;
}

const reportIDs = items.map((item) => item.reportID);

setSortedReportIDs(reportIDs);
}, [type, items, setSortedReportIDs]);
}

export default useSaveSortedReportIDs;
32 changes: 4 additions & 28 deletions src/hooks/useSearchSections.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,17 @@
import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
import {isReportPendingDelete, selectFilteredReportActions} from '@libs/ReportUtils';
import type {OnyxEntry} from 'react-native-onyx';
import {selectFilteredReportActions} from '@libs/ReportUtils';
import {getSections, getSortedSections} from '@libs/SearchUIUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Report} from '@src/types/onyx';
import type LastSearchParams from '@src/types/onyx/ReportNavigation';
import useActionLoadingReportIDs from './useActionLoadingReportIDs';
import useArchivedReportsIdSet from './useArchivedReportsIdSet';
import {useCurrencyListActions} from './useCurrencyList';
import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails';
import useFilterPendingDeleteReports, {selectPendingDeleteReportKeys} from './useFilterPendingDeleteReports';
import useLocalize from './useLocalize';
import useOnyx from './useOnyx';
import useReportAttributes from './useReportAttributes';

/**
* Returns sorted keys of reports pending deletion.
* Sorted string[] keeps Onyx comparison cheap (PERF-11).
*/
const selectPendingDeleteReportKeys = (reports: OnyxCollection<Report>): string[] => {
const keys: string[] = [];
for (const [key, report] of Object.entries(reports ?? {})) {
if (isReportPendingDelete(report)) {
keys.push(key);
}
}
return keys.sort();
};

type UseSearchSectionsResult = {
allReports: Array<string | undefined>;
isSearchLoading: boolean;
Expand All @@ -36,8 +21,6 @@ type UseSearchSectionsResult = {
function useSearchSections(): UseSearchSectionsResult {
const [lastSearchQuery] = useOnyx(ONYXKEYS.REPORT_NAVIGATION_LAST_SEARCH_QUERY);
const [currentSearchResults] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${lastSearchQuery?.queryJSON?.hash}`);
const [pendingDeleteReportKeys = CONST.EMPTY_ARRAY] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {selector: selectPendingDeleteReportKeys});
const pendingDeleteReportKeysSet = new Set(pendingDeleteReportKeys);
const currentUserDetails = useCurrentUserPersonalDetails();
const {localeCompare, formatPhoneNumber, translate} = useLocalize();
const isActionLoadingSet = useActionLoadingReportIDs();
Expand Down Expand Up @@ -86,14 +69,7 @@ function useSearchSections(): UseSearchSectionsResult {
results = getSortedSections(type, status ?? '', searchData, localeCompare, translate, sortBy, sortOrder, groupBy).map((value) => value.reportID);
}

const allReports = results.filter((id) => {
if (!id) {
return false;
}
return !pendingDeleteReportKeysSet.has(`${ONYXKEYS.COLLECTION.REPORT}${id}`);
});

return {allReports, isSearchLoading: !!currentSearchResults?.search?.isLoading, lastSearchQuery};
return {allReports: useFilterPendingDeleteReports(results), isSearchLoading: !!currentSearchResults?.search?.isLoading, lastSearchQuery};
}

export {selectPendingDeleteReportKeys};
Expand Down
2 changes: 2 additions & 0 deletions tests/ui/CategoryListItemHeaderTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const mockSearchStateContext = {
shouldUseLiveData: false,
currentSimilarSearchHash: -1,
suggestedSearches: {} as SearchStateContextValue['suggestedSearches'],
sortedReportIDs: [],
hasSelectedTransactions: false,
} satisfies SearchStateContextValue;

Expand All @@ -54,6 +55,7 @@ const mockSearchActionsContext = {
setShouldShowSelectAllMatchingItems: jest.fn(),
selectAllMatchingItems: jest.fn(),
setShouldResetSearchQuery: jest.fn(),
setSortedReportIDs: jest.fn(),
} satisfies SearchActionsContextValue;

const createCategoryListItem = (category: string, options: Partial<TransactionCategoryGroupListItemType> = {}): TransactionCategoryGroupListItemType => ({
Expand Down
2 changes: 2 additions & 0 deletions tests/ui/MerchantListItemHeaderTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const mockSearchStateContext = {
shouldUseLiveData: false,
currentSimilarSearchHash: -1,
suggestedSearches: {} as SearchStateContextValue['suggestedSearches'],
sortedReportIDs: [],
hasSelectedTransactions: false,
} satisfies SearchStateContextValue;

Expand All @@ -54,6 +55,7 @@ const mockSearchActionsContext = {
setShouldShowSelectAllMatchingItems: jest.fn(),
selectAllMatchingItems: jest.fn(),
setShouldResetSearchQuery: jest.fn(),
setSortedReportIDs: jest.fn(),
} satisfies SearchActionsContextValue;

const createMerchantListItem = (merchant: string, options: Partial<TransactionMerchantGroupListItemType> = {}): TransactionMerchantGroupListItemType => ({
Expand Down
2 changes: 2 additions & 0 deletions tests/ui/MonthListItemHeaderTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const mockSearchStateContext = {
shouldUseLiveData: false,
currentSimilarSearchHash: -1,
suggestedSearches: {} as SearchStateContextValue['suggestedSearches'],
sortedReportIDs: [],
hasSelectedTransactions: false,
} satisfies SearchStateContextValue;

Expand All @@ -54,6 +55,7 @@ const mockSearchActionsContext = {
setShouldShowSelectAllMatchingItems: jest.fn(),
selectAllMatchingItems: jest.fn(),
setShouldResetSearchQuery: jest.fn(),
setSortedReportIDs: jest.fn(),
} satisfies SearchActionsContextValue;

const createMonthListItem = (year: number, month: number, options: Partial<TransactionMonthGroupListItemType> = {}): TransactionMonthGroupListItemType => ({
Expand Down
2 changes: 2 additions & 0 deletions tests/ui/WeekListItemHeaderTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ const mockSearchStateContext = {
shouldUseLiveData: false,
currentSimilarSearchHash: -1,
suggestedSearches: {} as SearchStateContextValue['suggestedSearches'],
sortedReportIDs: [],
hasSelectedTransactions: false,
} satisfies SearchStateContextValue;

Expand All @@ -53,6 +54,7 @@ const mockSearchActionsContext = {
setShouldShowSelectAllMatchingItems: jest.fn(),
selectAllMatchingItems: jest.fn(),
setShouldResetSearchQuery: jest.fn(),
setSortedReportIDs: jest.fn(),
} satisfies SearchActionsContextValue;

const createWeekListItem = (week: string, options: Partial<TransactionWeekGroupListItemType> = {}): TransactionWeekGroupListItemType => ({
Expand Down
2 changes: 2 additions & 0 deletions tests/ui/YearListItemHeaderTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const mockSearchStateContext = {
shouldUseLiveData: false,
currentSimilarSearchHash: -1,
suggestedSearches: {} as SearchStateContextValue['suggestedSearches'],
sortedReportIDs: [],
hasSelectedTransactions: false,
} satisfies SearchStateContextValue;

Expand All @@ -54,6 +55,7 @@ const mockSearchActionsContext = {
setShouldShowSelectAllMatchingItems: jest.fn(),
selectAllMatchingItems: jest.fn(),
setShouldResetSearchQuery: jest.fn(),
setSortedReportIDs: jest.fn(),
} satisfies SearchActionsContextValue;

const createYearListItem = (year: number, options: Partial<TransactionYearGroupListItemType> = {}): TransactionYearGroupListItemType => ({
Expand Down
Loading
Loading