From 5caf8f02ffaf824588595ee4b32516fc987efcb8 Mon Sep 17 00:00:00 2001 From: "mkhutornyi (via MelvinBot)" Date: Sun, 10 May 2026 03:25:01 +0000 Subject: [PATCH 1/5] Stop workspace filter from scrolling and reordering on selection - Add shouldSortSelectedToTop param to useWorkspaceList hook - Remove scrollAndHighlightItem call and ref from SearchFiltersWorkspacePage - Add initiallyFocusedItemKey to scroll to first selected item on mount - Create useInitiallyFocusedKey hook for clearing focused key after first render Co-authored-by: mkhutornyi --- src/hooks/useInitiallyFocusedKey.ts | 25 +++++++++++++++++++ src/hooks/useWorkspaceList.ts | 23 +++++++++++++---- .../SearchFiltersWorkspacePage.tsx | 16 +++++------- 3 files changed, 49 insertions(+), 15 deletions(-) create mode 100644 src/hooks/useInitiallyFocusedKey.ts diff --git a/src/hooks/useInitiallyFocusedKey.ts b/src/hooks/useInitiallyFocusedKey.ts new file mode 100644 index 000000000000..7bee182ed302 --- /dev/null +++ b/src/hooks/useInitiallyFocusedKey.ts @@ -0,0 +1,25 @@ +import {useEffect, useState} from 'react'; + +/** + * Returns an initially focused key that is cleared after the first render cycle. + * This prevents FlashList from auto-scrolling when data changes cause the key + * to transition from "not found" to "found" (e.g., clearing a search). + * + * Note: We use setTimeout instead of requestAnimationFrame because FlashList has a bug + * where clearing the focused key via requestAnimationFrame causes the list to scroll + * to the end unexpectedly. + */ +function useInitiallyFocusedKey(computeKey: () => string | undefined): string | undefined { + const [initiallyFocusedKey, setInitiallyFocusedKey] = useState(computeKey); + + useEffect(() => { + const id = setTimeout(() => { + setInitiallyFocusedKey(undefined); + }); + return () => clearTimeout(id); + }, []); + + return initiallyFocusedKey; +} + +export default useInitiallyFocusedKey; diff --git a/src/hooks/useWorkspaceList.ts b/src/hooks/useWorkspaceList.ts index 8ff888a32859..2e3e1d2b88ae 100644 --- a/src/hooks/useWorkspaceList.ts +++ b/src/hooks/useWorkspaceList.ts @@ -19,9 +19,19 @@ type UseWorkspaceListParams = { searchTerm: string; localeCompare: LocaleContextProps['localeCompare']; additionalFilter?: (policy: OnyxEntry) => boolean; + shouldSortSelectedToTop?: boolean; }; -function useWorkspaceList({policies, currentUserLogin, selectedPolicyIDs, searchTerm, shouldShowPendingDeletePolicy, localeCompare, additionalFilter}: UseWorkspaceListParams) { +function useWorkspaceList({ + policies, + currentUserLogin, + selectedPolicyIDs, + searchTerm, + shouldShowPendingDeletePolicy, + localeCompare, + additionalFilter, + shouldSortSelectedToTop = true, +}: UseWorkspaceListParams) { const icons = useMemoizedLazyExpensifyIcons(['FallbackWorkspaceAvatar']); const usersWorkspaces = useMemo(() => { if (!policies || isEmptyObject(policies)) { @@ -56,10 +66,13 @@ function useWorkspaceList({policies, currentUserLogin, selectedPolicyIDs, search const filteredAndSortedUserWorkspaces = useMemo( () => - tokenizedSearch(usersWorkspaces, searchTerm, (policy) => [policy.text]).sort((policy1, policy2) => - sortWorkspacesBySelected({policyID: policy1.policyID, name: policy1.text}, {policyID: policy2.policyID, name: policy2.text}, selectedPolicyIDs, localeCompare), - ), - [searchTerm, usersWorkspaces, selectedPolicyIDs, localeCompare], + tokenizedSearch(usersWorkspaces, searchTerm, (policy) => [policy.text]).sort((policy1, policy2) => { + if (shouldSortSelectedToTop) { + return sortWorkspacesBySelected({policyID: policy1.policyID, name: policy1.text}, {policyID: policy2.policyID, name: policy2.text}, selectedPolicyIDs, localeCompare); + } + return localeCompare(policy1.text, policy2.text); + }), + [searchTerm, usersWorkspaces, selectedPolicyIDs, localeCompare, shouldSortSelectedToTop], ); const sections = useMemo(() => { diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersWorkspacePage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersWorkspacePage.tsx index ae95bcdd61c7..fd789c2f9d46 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersWorkspacePage.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersWorkspacePage.tsx @@ -1,13 +1,13 @@ import {emailSelector} from '@selectors/Session'; -import React, {useCallback, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useMemo, useState} from 'react'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import SearchFilterPageFooterButtons from '@components/Search/SearchFilterPageFooterButtons'; import SelectionList from '@components/SelectionList'; import UserListItem from '@components/SelectionList/ListItem/UserListItem'; -import type {SelectionListHandle} from '@components/SelectionList/types'; import useDebouncedState from '@hooks/useDebouncedState'; +import useInitiallyFocusedKey from '@hooks/useInitiallyFocusedKey'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; @@ -38,8 +38,6 @@ function SearchFiltersWorkspacePage() { const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); const shouldShowLoadingIndicator = isLoadingApp && !isOffline; - const selectionListRef = useRef>(null); - const [selectedOptions, setSelectedOptions] = useState(() => (searchAdvancedFiltersForm?.policyID ? Array.from(searchAdvancedFiltersForm?.policyID) : [])); const {data, shouldShowNoResultsFoundMessage, shouldShowSearchInput} = useWorkspaceList({ @@ -49,8 +47,11 @@ function SearchFiltersWorkspacePage() { selectedPolicyIDs: selectedOptions, searchTerm: debouncedSearchTerm, localeCompare, + shouldSortSelectedToTop: false, }); + const initiallyFocusedKey = useInitiallyFocusedKey(() => data.find((item) => item.isSelected)?.keyForList); + const selectWorkspace = useCallback( (option: WorkspaceListItem) => { const optionIndex = selectedOptions.findIndex((selectedOption: string) => { @@ -60,10 +61,6 @@ function SearchFiltersWorkspacePage() { if (optionIndex === -1 && option?.policyID) { setSelectedOptions([...selectedOptions, option.policyID]); - - requestAnimationFrame(() => { - selectionListRef.current?.scrollAndHighlightItem([option.keyForList]); - }); } else { const newSelectedOptions = [...selectedOptions.slice(0, optionIndex), ...selectedOptions.slice(optionIndex + 1)]; setSelectedOptions(newSelectedOptions); @@ -114,14 +111,13 @@ function SearchFiltersWorkspacePage() { /> ) : ( - ref={selectionListRef} data={data} ListItem={UserListItem} + initiallyFocusedItemKey={initiallyFocusedKey} onSelectRow={selectWorkspace} textInputOptions={textInputOptions} canSelectMultiple shouldShowLoadingPlaceholder={isLoadingOnyxValue(policiesResult) || !didScreenTransitionEnd} - disableMaintainingScrollPosition footerContent={ Date: Sun, 10 May 2026 06:57:17 +0000 Subject: [PATCH 2/5] Add shouldUpdateFocusedIndex to workspace filter SelectionList Co-authored-by: mkhutornyi --- .../SearchAdvancedFiltersPage/SearchFiltersWorkspacePage.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersWorkspacePage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersWorkspacePage.tsx index fd789c2f9d46..6891e48afb95 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersWorkspacePage.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersWorkspacePage.tsx @@ -117,6 +117,7 @@ function SearchFiltersWorkspacePage() { onSelectRow={selectWorkspace} textInputOptions={textInputOptions} canSelectMultiple + shouldUpdateFocusedIndex shouldShowLoadingPlaceholder={isLoadingOnyxValue(policiesResult) || !didScreenTransitionEnd} footerContent={ Date: Sun, 10 May 2026 07:31:16 +0000 Subject: [PATCH 3/5] Add suppressNextFocusScroll to BaseSelectionList Introduces the same suppressNextFocusScrollRef pattern used in BaseSelectionListWithSections to prevent unwanted auto-scrolling when focused index changes due to item selection or search updates. Co-authored-by: mkhutornyi --- .../SelectionList/BaseSelectionList.tsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index 4f4095388348..df3dadd82c36 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -111,6 +111,7 @@ function BaseSelectionList({ const listRef = useRef | null>(null); const itemFocusTimeoutRef = useRef(null); const keyboardListenerRef = useRef | null>(null); + const suppressNextFocusScrollRef = useRef(false); const initialFocusedIndex = useMemo(() => data.findIndex((i) => i.keyForList === initiallyFocusedItemKey), [data, initiallyFocusedItemKey]); const [itemsToHighlight, setItemsToHighlight] = useState | null>(null); @@ -210,6 +211,10 @@ function BaseSelectionList({ disabledIndexes: dataDetails.disabledArrowKeyIndexes, isActive: isFocused, onFocusedIndexChange: (index: number) => { + if (suppressNextFocusScrollRef.current) { + suppressNextFocusScrollRef.current = false; + return; + } if (!shouldScrollToFocusedIndex) { return; } @@ -242,6 +247,9 @@ function BaseSelectionList({ } } if (shouldUpdateFocusedIndex && typeof indexToFocus === 'number') { + if (indexToFocus !== focusedIndex) { + suppressNextFocusScrollRef.current = true; + } setFocusedIndex(indexToFocus); } onSelectRow(item); @@ -254,6 +262,7 @@ function BaseSelectionList({ isFocused, canSelectMultiple, shouldUpdateFocusedIndex, + focusedIndex, onSelectRow, shouldShowTextInput, shouldClearInputOnSelect, @@ -507,6 +516,9 @@ function BaseSelectionList({ if (newFocusedIndex < 0 || newFocusedIndex >= data.length) { return; } + if (!shouldScroll) { + suppressNextFocusScrollRef.current = true; + } setFocusedIndex(newFocusedIndex); if (shouldScroll) { scrollToIndex(newFocusedIndex); @@ -524,6 +536,10 @@ function BaseSelectionList({ setFocusedIndex, }); + const suppressNextFocusScroll = useCallback(() => { + suppressNextFocusScrollRef.current = true; + }, []); + useSearchFocusSync({ searchValue: textInputOptions?.value, data, @@ -533,6 +549,8 @@ function BaseSelectionList({ shouldUpdateFocusedIndex, scrollToIndex, setFocusedIndex, + focusedIndex, + suppressNextFocusScroll, }); useEffect(() => { From e51d77d1a94203689e55023d80894236e7e9e807 Mon Sep 17 00:00:00 2001 From: "mkhutornyi (via MelvinBot)" Date: Sun, 10 May 2026 07:39:52 +0000 Subject: [PATCH 4/5] Remove suppressNextFocusScrollRef from updateFocusedIndex Reverts the unrelated change in updateFocusedIndex that set suppressNextFocusScrollRef when shouldScroll is false, as requested by reviewer. Co-authored-by: mkhutornyi --- src/components/SelectionList/BaseSelectionList.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index df3dadd82c36..f9b017b3ee50 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -516,9 +516,6 @@ function BaseSelectionList({ if (newFocusedIndex < 0 || newFocusedIndex >= data.length) { return; } - if (!shouldScroll) { - suppressNextFocusScrollRef.current = true; - } setFocusedIndex(newFocusedIndex); if (shouldScroll) { scrollToIndex(newFocusedIndex); From 783f5e64f6344de577715d0162bdd69f85879640 Mon Sep 17 00:00:00 2001 From: "mkhutornyi (via MelvinBot)" Date: Sun, 10 May 2026 07:57:07 +0000 Subject: [PATCH 5/5] Add back disableMaintainingScrollPosition Restoring this prop to fix the issue where typing in the search input and then clearing it causes the list to scroll back to a previous scroll position. Co-authored-by: mkhutornyi --- .../SearchAdvancedFiltersPage/SearchFiltersWorkspacePage.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersWorkspacePage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersWorkspacePage.tsx index 6891e48afb95..3cd8051a5622 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersWorkspacePage.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersWorkspacePage.tsx @@ -119,6 +119,7 @@ function SearchFiltersWorkspacePage() { canSelectMultiple shouldUpdateFocusedIndex shouldShowLoadingPlaceholder={isLoadingOnyxValue(policiesResult) || !didScreenTransitionEnd} + disableMaintainingScrollPosition footerContent={