Skip to content
Merged
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
4 changes: 2 additions & 2 deletions src/components/SelectionList/components/TextInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,8 @@ function TextInput({
const noResultsFoundText = translate('common.noResultsFound');
const isNoResultsFoundMessage = headerMessage === noResultsFoundText;
const isScreenReaderEnabled = Accessibility.useScreenReaderStatus();
const noData = dataLength === 0 && !shouldShowLoadingPlaceholder;
const shouldShowHeaderMessage = !!shouldShowTextInput && !!headerMessage && (!isLoadingNewOptions || !isNoResultsFoundMessage || noData);
const hasNoData = dataLength === 0 && !shouldShowLoadingPlaceholder;
const shouldShowHeaderMessage = !!shouldShowTextInput && !!headerMessage && (!isLoadingNewOptions || !isNoResultsFoundMessage || hasNoData);
const trimmedSearchValue = value?.trim() ?? '';
const suggestionsCount = dataLength ?? 0;
const suggestionsAnnouncement =
Expand Down
3 changes: 2 additions & 1 deletion src/hooks/useFilteredOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,8 @@ function useFilteredOptions(config: UseFilteredOptionsConfig = {}): UseFilteredO
[enabled, allReports, allPersonalDetails, reportAttributesDerived, privateIsArchivedMap, allPolicies, reportsLimit, includeP2P, isSearching, betas],
);

const hasMore = options ? reportsLimit < totalReports : false;
// When isSearching is set to true, the createFilteredOptionList returns all reports
const hasMore = !isSearching && options ? reportsLimit < totalReports : false;

const loadMore = () => {
if (!hasMore) {
Expand Down
57 changes: 57 additions & 0 deletions src/hooks/usePaginatedData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import {useState} from 'react';

type UsePaginatedDataConfig = {
/** When this value changes, pagination resets to the first page. */
resetKey?: string;

/** When true, returns `data` as-is with `hasMore: false` and a no-op `loadMore`. */
skipPagination?: boolean;
};

/**
* Client-side bounded-slice pagination.
*
* Returns the first `pageSize * currentPage` items of `data`, plus `loadMore` to advance a page and
* `hasMore` to indicate whether more items are available. `currentPage` is reset to 1 whenever
* `resetKey` changes, so callers can tie the page back to a logical context (e.g. a search query).
*/
function usePaginatedData<T>(
data: T[],
pageSize: number,
{resetKey = '', skipPagination = false}: UsePaginatedDataConfig = {},
): {
paginatedData: T[];
loadMore: () => void;
hasMore: boolean;
} {
const [prevResetKey, setPrevResetKey] = useState(resetKey);
const [currentPage, setCurrentPage] = useState(1);

if (resetKey !== prevResetKey) {
setPrevResetKey(resetKey);
setCurrentPage(1);
Comment on lines +29 to +32

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reset pagination state before slicing data

When resetKey changes after the user has already loaded multiple pages, this hook queues setCurrentPage(1) but still computes limit from the previous currentPage in the same render. That means one render returns too many rows for the new context (for example, after changing the search term), which can briefly show stale pagination and trigger extra onEndReached work before the next render corrects it.

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.

When you call the set function during render, React will re-render that component immediately after your component exits with a return statement, and before rendering the children.

}

if (skipPagination) {
return {paginatedData: data, loadMore: () => {}, hasMore: false};
}

if (pageSize < 1) {
return {paginatedData: [], loadMore: () => {}, hasMore: false};
}
Comment on lines +35 to +41

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Could these early returns can go above the if (resetKey !== prevResetKey) condition? 🤔

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.

at first, after reading this question, I though it would be better to move it down. But actually it needs to stay in place.

Example:

usePaginatedData(data, pageSize, { resetKey: "false", skipPagination:false})
// some time later
usePaginatedData(data, pageSize, { resetKey: "true", skipPagination:true})
// some time later 
usePaginatedData(data, pageSize, { resetKey: "false", skipPagination:false})

If we move the condition down, the change in resetKey would go unnoticed; the pagination would not reset to the first page.


const limit = pageSize * currentPage;
const paginatedData = data.slice(0, limit);
const hasMore = data.length > limit;
Comment thread
sharabai marked this conversation as resolved.

const loadMore = () => {
if (!hasMore) {
return;
}
setCurrentPage((prev) => prev + 1);
};

return {paginatedData, loadMore, hasMore};
}

export default usePaginatedData;
23 changes: 15 additions & 8 deletions src/libs/OptionsListUtils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1587,11 +1587,15 @@ const reportSortComparator = (report: Report, privateIsArchivedMap: PrivateIsArc
*
* Performance optimization approach:
* 1. Pre-filters reports using shouldReportBeInOptionList with correct parameters (betas, etc.)
* 2. Sorts by lastVisibleActionCreated (most recent first)
* 3. Limits to top N reports
* 4. Processes only those N reports
* 2. Default (`options.isSearching` false): sorts by lastVisibleActionCreated (most recent first), limits to
* the top N reports (`maxRecentReports`), then processes only those reports. This avoids processing
* thousands of reports while ensuring correct filtering.
* 3. Search mode (`options.isSearching` true): uses the full pre-filtered report list with no recency sort and
* no `maxRecentReports` cap, so search can include all eligible reports.
*
* This avoids processing thousands of reports while ensuring correct filtering.
* @param options.isSearching - When true, skips the sort and top-N limit in step 2; when false, applies them.
*
* @remarks In search mode, sorting by last visible action is skipped because the UI needs the full eligible set.
*
* Use this for screens that need recent reports (NewChatPage, WorkspaceInvitePage, etc.)
*/
Expand Down Expand Up @@ -1728,9 +1732,11 @@ function createOptionFromReport(
};
}

function orderPersonalDetailsOptions(options: SearchOptionData[]) {
function orderPersonalDetailsOptions<T extends SearchOptionData>(options: T[]): T[] {
// PersonalDetails should be ordered Alphabetically by default - https://github.com/Expensify/App/issues/8220#issuecomment-1104009435
return lodashOrderBy(options, [(personalDetail) => personalDetail.text?.toLowerCase()], 'asc');
// Keep this aligned with `getValidOptions` ordering (`optionsOrderBy(..., personalDetailsComparator, ...)`)
// so upstream and downstream sorting use the same key (text -> alternateText -> login).
return lodashOrderBy(options, [personalDetailsComparator], 'asc');
}

/**
Expand All @@ -1744,10 +1750,10 @@ function orderReportOptions(options: SearchOptionData[]) {
/**
* Sort personal details by displayName or login in alphabetical order
*/
const personalDetailsComparator = (personalDetail: SearchOptionData | PersonalDetailOptionData) => {
function personalDetailsComparator(personalDetail: SearchOptionData | PersonalDetailOptionData) {
const name = personalDetail.text ?? personalDetail.alternateText ?? personalDetail.login ?? '';
return name.toLowerCase();
};
}

/**
* Sort reports by archived status and last visible action
Expand Down Expand Up @@ -3405,6 +3411,7 @@ export {
isSearchStringMatchUserDetails,
optionsOrderBy,
orderOptions,
orderPersonalDetailsOptions,
orderWorkspaceOptions,
processReport,
recentReportComparator,
Expand Down
134 changes: 67 additions & 67 deletions src/libs/ReportUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -816,75 +816,75 @@ type CustomIcon = {
color?: string;
};

type OptionData = {
text?: string;
alternateText?: string;
allReportErrors?: Errors;
brickRoadIndicator?: ValueOf<typeof CONST.BRICK_ROAD_INDICATOR_STATUS> | '' | null;
actionBadge?: ValueOf<typeof CONST.REPORT.ACTION_BADGE>;
actionTargetReportActionID?: string;
tooltipText?: string | null;
alternateTextMaxLines?: number;
boldStyle?: boolean;
customIcon?: CustomIcon;
subtitle?: string;
login?: string;
accountID?: number;
pronouns?: string;
status?: Status | null;
phoneNumber?: string;
isUnread?: boolean | null;
isUnreadWithMention?: boolean | null;
hasDraftComment?: boolean | null;
keyForList: string;
searchText?: string;
isIOUReportOwner?: boolean | null;
shouldShowSubscript?: boolean | null;
isPolicyExpenseChat?: boolean;
isMoneyRequestReport?: boolean | null;
isInvoiceReport?: boolean;
isExpenseRequest?: boolean | null;
isAllowedToComment?: boolean | null;
isThread?: boolean | null;
isTaskReport?: boolean | null;
parentReportAction?: OnyxEntry<ReportAction>;
displayNamesWithTooltips?: DisplayNameWithTooltips | null;
isDefaultRoom?: boolean;
isInvoiceRoom?: boolean;
isExpenseReport?: boolean;
isDM?: boolean;
isOptimisticPersonalDetail?: boolean;
selected?: boolean;
isOptimisticAccount?: boolean;
isSelected?: boolean;
descriptiveText?: string;
notificationPreference?: NotificationPreference | null;
isDisabled?: boolean | null;
name?: string | null;
isSelfDM?: boolean;
isOneOnOneChat?: boolean;
reportID?: string;
enabled?: boolean;
code?: string;
transactionThreadReportID?: string | null;
shouldShowAmountInput?: boolean;
amountInputProps?: MoneyRequestAmountInputProps;
tabIndex?: 0 | -1;
isConciergeChat?: boolean;
isBold?: boolean;
lastIOUCreationDate?: string;
isChatRoom?: boolean;
participantsList?: PersonalDetails[];
icons?: Icon[];
iouReportAmount?: number;
displayName?: string;
firstName?: string;
lastName?: string;
avatar?: AvatarSource;
timezone?: Timezone;
} & Report &
type OptionData = Report &
Omit<ReportNameValuePairs, 'private_isArchived'> & {
private_isArchived?: boolean;
} & {
text?: string;
alternateText?: string;
allReportErrors?: Errors;
brickRoadIndicator?: ValueOf<typeof CONST.BRICK_ROAD_INDICATOR_STATUS> | '' | null;
actionBadge?: ValueOf<typeof CONST.REPORT.ACTION_BADGE>;
actionTargetReportActionID?: string;
tooltipText?: string | null;
alternateTextMaxLines?: number;
boldStyle?: boolean;
customIcon?: CustomIcon;
subtitle?: string;
login?: string;
accountID?: number;
pronouns?: string;
status?: Status | null;
phoneNumber?: string;
isUnread?: boolean | null;
isUnreadWithMention?: boolean | null;
hasDraftComment?: boolean | null;
keyForList: string;
searchText?: string;
isIOUReportOwner?: boolean | null;
shouldShowSubscript?: boolean | null;
isPolicyExpenseChat?: boolean;
isMoneyRequestReport?: boolean | null;
isInvoiceReport?: boolean;
isExpenseRequest?: boolean | null;
isAllowedToComment?: boolean | null;
isThread?: boolean | null;
isTaskReport?: boolean | null;
parentReportAction?: OnyxEntry<ReportAction>;
displayNamesWithTooltips?: DisplayNameWithTooltips | null;
isDefaultRoom?: boolean;
isInvoiceRoom?: boolean;
isExpenseReport?: boolean;
isDM?: boolean;
isOptimisticPersonalDetail?: boolean;
selected?: boolean;
isOptimisticAccount?: boolean;
isSelected?: boolean;
descriptiveText?: string;
notificationPreference?: NotificationPreference | null;
isDisabled?: boolean | null;
name?: string | null;
isSelfDM?: boolean;
isOneOnOneChat?: boolean;
reportID?: string;
enabled?: boolean;
code?: string;
transactionThreadReportID?: string | null;
shouldShowAmountInput?: boolean;
amountInputProps?: MoneyRequestAmountInputProps;
tabIndex?: 0 | -1;
isConciergeChat?: boolean;
isBold?: boolean;
lastIOUCreationDate?: string;
isChatRoom?: boolean;
participantsList?: PersonalDetails[];
icons?: Icon[];
iouReportAmount?: number;
displayName?: string;
firstName?: string;
lastName?: string;
avatar?: AvatarSource;
timezone?: Timezone;
};

type OnyxDataTaskAssigneeChat = {
Expand Down
Loading
Loading