From 6189320290774ea44cbd3de88e06fd695d72689b Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Mon, 16 Mar 2026 22:24:00 +0430 Subject: [PATCH 1/8] fix: stabilize reopen ordering in basic pickers --- src/CONST/index.ts | 1 + .../CountryPicker/CountrySelectorModal.tsx | 10 +- .../PushRowWithModal/PushRowModal.tsx | 10 +- .../StatePicker/StateSelectorModal.tsx | 12 +- .../ValuePicker/ValueSelectionList.tsx | 18 +- .../ValuePicker/ValueSelectorModal.tsx | 1 + src/components/ValuePicker/types.ts | 5 +- src/hooks/useInitialSelectionRef.ts | 65 +++++++ src/libs/SelectionListOrderUtils.ts | 45 +++++ .../companyCards/addNew/SelectCountryStep.tsx | 43 +++-- tests/ui/CountrySelectorModalTest.tsx | 111 +++++++++++ tests/ui/PushRowModalTest.tsx | 112 ++++++++++++ tests/ui/SelectCountryStepTest.tsx | 172 ++++++++++++++++++ tests/ui/StateSelectorModalTest.tsx | 117 ++++++++++++ tests/ui/ValueSelectionListTest.tsx | 86 +++++++++ tests/ui/ValueSelectorModalTest.tsx | 35 ++++ tests/unit/SelectionListOrderUtilsTest.ts | 31 ++++ 17 files changed, 844 insertions(+), 30 deletions(-) create mode 100644 src/hooks/useInitialSelectionRef.ts create mode 100644 src/libs/SelectionListOrderUtils.ts create mode 100644 tests/ui/CountrySelectorModalTest.tsx create mode 100644 tests/ui/PushRowModalTest.tsx create mode 100644 tests/ui/SelectCountryStepTest.tsx create mode 100644 tests/ui/StateSelectorModalTest.tsx create mode 100644 tests/ui/ValueSelectionListTest.tsx create mode 100644 tests/ui/ValueSelectorModalTest.tsx create mode 100644 tests/unit/SelectionListOrderUtilsTest.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index dcff383627eb5..1f8e30f9d2079 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -247,6 +247,7 @@ const CONST = { POPOVER_DROPDOWN_MAX_HEIGHT: 416, POPOVER_MENU_MAX_HEIGHT: 496, POPOVER_MENU_MAX_HEIGHT_MOBILE: 432, + MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD: 8, POPOVER_DATE_WIDTH: 338, POPOVER_DATE_MAX_HEIGHT: 366, POPOVER_DATE_MIN_HEIGHT: 322, diff --git a/src/components/CountryPicker/CountrySelectorModal.tsx b/src/components/CountryPicker/CountrySelectorModal.tsx index a2f7e0fffeeb3..cbd49292c57a5 100644 --- a/src/components/CountryPicker/CountrySelectorModal.tsx +++ b/src/components/CountryPicker/CountrySelectorModal.tsx @@ -5,10 +5,12 @@ import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; import RadioListItem from '@components/SelectionList/ListItem/RadioListItem'; import useDebouncedState from '@hooks/useDebouncedState'; +import useInitialSelectionRef from '@hooks/useInitialSelectionRef'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import searchOptions from '@libs/searchOptions'; import type {Option} from '@libs/searchOptions'; +import {moveInitialSelectionToTopByValue} from '@libs/SelectionListOrderUtils'; import StringUtils from '@libs/StringUtils'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; @@ -36,6 +38,8 @@ type CountrySelectorModalProps = { function CountrySelectorModal({isVisible, currentCountry, onCountrySelected, onClose, label, onBackdropPress}: CountrySelectorModalProps) { const {translate} = useLocalize(); const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); + const initialSelectedValues = useInitialSelectionRef(currentCountry ? [currentCountry] : [], {resetDeps: [isVisible]}); + const initiallyFocusedCountry = initialSelectedValues.at(0); const countries = useMemo( () => @@ -52,7 +56,9 @@ function CountrySelectorModal({isVisible, currentCountry, onCountrySelected, onC [translate, currentCountry], ); - const searchResults = searchOptions(debouncedSearchValue, countries); + const orderedCountries = useMemo(() => moveInitialSelectionToTopByValue(countries, initialSelectedValues), [countries, initialSelectedValues]); + + const searchResults = useMemo(() => searchOptions(debouncedSearchValue, debouncedSearchValue ? countries : orderedCountries), [countries, orderedCountries, debouncedSearchValue]); const headerMessage = debouncedSearchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : ''; const styles = useThemeStyles(); @@ -91,7 +97,7 @@ function CountrySelectorModal({isVisible, currentCountry, onCountrySelected, onC textInputOptions={textInputOptions} onSelectRow={onCountrySelected} ListItem={RadioListItem} - initiallyFocusedItemKey={currentCountry} + initiallyFocusedItemKey={initiallyFocusedCountry} shouldSingleExecuteRowSelect shouldStopPropagation /> diff --git a/src/components/PushRowWithModal/PushRowModal.tsx b/src/components/PushRowWithModal/PushRowModal.tsx index a2f94504d49a6..4f08d86d03689 100644 --- a/src/components/PushRowWithModal/PushRowModal.tsx +++ b/src/components/PushRowWithModal/PushRowModal.tsx @@ -5,8 +5,10 @@ import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; import RadioListItem from '@components/SelectionList/ListItem/RadioListItem'; import useDebouncedState from '@hooks/useDebouncedState'; +import useInitialSelectionRef from '@hooks/useInitialSelectionRef'; import useLocalize from '@hooks/useLocalize'; import searchOptions from '@libs/searchOptions'; +import {moveInitialSelectionToTopByValue} from '@libs/SelectionListOrderUtils'; import StringUtils from '@libs/StringUtils'; import CONST from '@src/CONST'; @@ -44,6 +46,8 @@ function PushRowModal({isVisible, selectedOption, onOptionChange, onClose, optio const {translate} = useLocalize(); const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); + const initialSelectedValues = useInitialSelectionRef(selectedOption ? [selectedOption] : [], {resetDeps: [isVisible]}); + const initiallyFocusedOption = initialSelectedValues.at(0); const options = useMemo( () => @@ -57,6 +61,8 @@ function PushRowModal({isVisible, selectedOption, onOptionChange, onClose, optio [optionsList, selectedOption], ); + const orderedOptions = useMemo(() => moveInitialSelectionToTopByValue(options, initialSelectedValues), [initialSelectedValues, options]); + const handleSelectRow = (option: ListItemType) => { onOptionChange(option.value); onClose(); @@ -67,7 +73,7 @@ function PushRowModal({isVisible, selectedOption, onOptionChange, onClose, optio setSearchValue(''); }; - const searchResults = searchOptions(debouncedSearchValue, options); + const searchResults = useMemo(() => searchOptions(debouncedSearchValue, debouncedSearchValue ? options : orderedOptions), [debouncedSearchValue, options, orderedOptions]); const textInputOptions = useMemo( () => ({ @@ -102,7 +108,7 @@ function PushRowModal({isVisible, selectedOption, onOptionChange, onClose, optio ListItem={RadioListItem} onSelectRow={handleSelectRow} textInputOptions={textInputOptions} - initiallyFocusedItemKey={selectedOption} + initiallyFocusedItemKey={initiallyFocusedOption} disableMaintainingScrollPosition shouldShowTooltips={false} showScrollIndicator diff --git a/src/components/StatePicker/StateSelectorModal.tsx b/src/components/StatePicker/StateSelectorModal.tsx index d03e8183ca300..916ff5e547319 100644 --- a/src/components/StatePicker/StateSelectorModal.tsx +++ b/src/components/StatePicker/StateSelectorModal.tsx @@ -6,10 +6,12 @@ import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; import RadioListItem from '@components/SelectionList/ListItem/RadioListItem'; import useDebouncedState from '@hooks/useDebouncedState'; +import useInitialSelectionRef from '@hooks/useInitialSelectionRef'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import searchOptions from '@libs/searchOptions'; import type {Option} from '@libs/searchOptions'; +import {moveInitialSelectionToTopByValue} from '@libs/SelectionListOrderUtils'; import StringUtils from '@libs/StringUtils'; import CONST from '@src/CONST'; @@ -39,6 +41,8 @@ function StateSelectorModal({isVisible, currentState, onStateSelected, onClose, const {translate} = useLocalize(); const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); const styles = useThemeStyles(); + const initialSelectedValues = useInitialSelectionRef(currentState ? [currentState] : [], {resetDeps: [isVisible]}); + const initiallyFocusedState = initialSelectedValues.at(0); const countryStates = useMemo( () => @@ -57,7 +61,11 @@ function StateSelectorModal({isVisible, currentState, onStateSelected, onClose, [translate, currentState], ); - const searchResults = searchOptions(debouncedSearchValue, countryStates); + const orderedCountryStates = useMemo(() => moveInitialSelectionToTopByValue(countryStates, initialSelectedValues), [countryStates, initialSelectedValues]); + const searchResults = useMemo( + () => searchOptions(debouncedSearchValue, debouncedSearchValue ? countryStates : orderedCountryStates), + [countryStates, orderedCountryStates, debouncedSearchValue], + ); const textInputOptions = useMemo( () => ({ @@ -93,7 +101,7 @@ function StateSelectorModal({isVisible, currentState, onStateSelected, onClose, ListItem={RadioListItem} onSelectRow={onStateSelected} textInputOptions={textInputOptions} - initiallyFocusedItemKey={currentState} + initiallyFocusedItemKey={initiallyFocusedState} disableMaintainingScrollPosition shouldSingleExecuteRowSelect shouldStopPropagation diff --git a/src/components/ValuePicker/ValueSelectionList.tsx b/src/components/ValuePicker/ValueSelectionList.tsx index cde8688085984..99283c3c87b14 100644 --- a/src/components/ValuePicker/ValueSelectionList.tsx +++ b/src/components/ValuePicker/ValueSelectionList.tsx @@ -1,6 +1,8 @@ import React, {useMemo} from 'react'; import SelectionList from '@components/SelectionList'; import RadioListItem from '@components/SelectionList/ListItem/RadioListItem'; +import useInitialSelectionRef from '@hooks/useInitialSelectionRef'; +import {moveInitialSelectionToTopByValue} from '@libs/SelectionListOrderUtils'; import type {ValueSelectionListProps} from './types'; function ValueSelectionList({ @@ -11,20 +13,24 @@ function ValueSelectionList({ addBottomSafeAreaPadding = true, disableKeyboardShortcuts = false, alternateNumberOfSupportedLines, + isVisible, }: ValueSelectionListProps) { - const options = useMemo( - () => items.map((item) => ({value: item.value, alternateText: item.description, text: item.label ?? '', isSelected: item === selectedItem, keyForList: item.value ?? ''})), - [items, selectedItem], - ); + const initialSelectedValues = useInitialSelectionRef(selectedItem?.value ? [selectedItem.value] : [], isVisible === undefined ? {resetOnFocus: true} : {resetDeps: [isVisible]}); + const initiallyFocusedItemKey = initialSelectedValues.at(0); + + const mappedOptions = useMemo(() => items.map((item) => ({value: item.value ?? '', alternateText: item.description, text: item.label ?? '', keyForList: item.value ?? ''})), [items]); + const orderedOptions = useMemo(() => moveInitialSelectionToTopByValue(mappedOptions, initialSelectedValues), [initialSelectedValues, mappedOptions]); + const options = useMemo(() => orderedOptions.map((item) => ({...item, isSelected: item.value === selectedItem?.value})), [orderedOptions, selectedItem?.value]); return ( onItemSelected?.(item)} - initiallyFocusedItemKey={selectedItem?.value} + initiallyFocusedItemKey={initiallyFocusedItemKey} shouldStopPropagation shouldShowTooltips={shouldShowTooltips} - shouldUpdateFocusedIndex + shouldScrollToFocusedIndex={false} + shouldScrollToFocusedIndexOnMount={false} ListItem={RadioListItem} addBottomSafeAreaPadding={addBottomSafeAreaPadding} disableKeyboardShortcuts={disableKeyboardShortcuts} diff --git a/src/components/ValuePicker/ValueSelectorModal.tsx b/src/components/ValuePicker/ValueSelectorModal.tsx index 4aa1ba794ceda..24afba16afe7a 100644 --- a/src/components/ValuePicker/ValueSelectorModal.tsx +++ b/src/components/ValuePicker/ValueSelectorModal.tsx @@ -42,6 +42,7 @@ function ValueSelectorModal({ ; +> & { + /** Whether the parent modal is visible */ + isVisible?: boolean; +}; type ValuePickerProps = ForwardedFSClassProps & { /** Item to display */ diff --git a/src/hooks/useInitialSelectionRef.ts b/src/hooks/useInitialSelectionRef.ts new file mode 100644 index 0000000000000..349cce8f89d40 --- /dev/null +++ b/src/hooks/useInitialSelectionRef.ts @@ -0,0 +1,65 @@ +import {useFocusEffect} from '@react-navigation/native'; +import type {DependencyList} from 'react'; +import {useCallback, useEffect, useRef, useState} from 'react'; + +type UseInitialSelectionRefOptions = { + /** Dependencies that should trigger refreshing the snapshot (e.g., when a modal opens) */ + resetDeps?: DependencyList; + + /** Whether to refresh the snapshot whenever the screen gains focus */ + resetOnFocus?: boolean; + + /** Whether the snapshot should continue following incoming selection changes */ + shouldSyncSelection?: boolean; + + /** Equality check used to avoid replacing the snapshot with equivalent values */ + isEqual?: (previousSelection: T, nextSelection: T) => boolean; +}; + +/** + * Keeps an immutable snapshot of the initial selection for the current open/focus cycle. + * Callers can refresh the snapshot by changing `resetDeps` or via screen focus. + */ +function useInitialSelectionRef(selection: T, options: UseInitialSelectionRefOptions = {}) { + const {resetDeps = [], resetOnFocus = false, shouldSyncSelection = false, isEqual = Object.is} = options; + const [initialSelection, setInitialSelection] = useState(selection); + const latestSelectionRef = useRef(selection); + + const updateInitialSelection = useCallback( + (nextSelection: T) => { + setInitialSelection((previousSelection) => (isEqual(previousSelection, nextSelection) ? previousSelection : nextSelection)); + }, + [isEqual], + ); + + useEffect(() => { + latestSelectionRef.current = selection; + }, [selection]); + + useEffect(() => { + updateInitialSelection(selection); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, resetDeps); + + useFocusEffect( + useCallback(() => { + if (!resetOnFocus) { + return; + } + + updateInitialSelection(latestSelectionRef.current); + }, [resetOnFocus, updateInitialSelection]), + ); + + useEffect(() => { + if (!shouldSyncSelection) { + return; + } + + updateInitialSelection(selection); + }, [selection, shouldSyncSelection, updateInitialSelection]); + + return initialSelection; +} + +export default useInitialSelectionRef; diff --git a/src/libs/SelectionListOrderUtils.ts b/src/libs/SelectionListOrderUtils.ts new file mode 100644 index 0000000000000..29cccec119b3b --- /dev/null +++ b/src/libs/SelectionListOrderUtils.ts @@ -0,0 +1,45 @@ +import CONST from '@src/CONST'; + +function moveInitialSelectionToTopByKey(keys: string[], initialSelectedKeys: string[]): string[] { + if (initialSelectedKeys.length === 0 || keys.length <= CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD) { + return keys; + } + + const selectedKeys = new Set(initialSelectedKeys); + const selected: string[] = []; + const remaining: string[] = []; + + for (const key of keys) { + if (selectedKeys.has(key)) { + selected.push(key); + continue; + } + + remaining.push(key); + } + + return [...selected, ...remaining]; +} + +function moveInitialSelectionToTopByValue(items: T[], initialSelectedValues: string[]): T[] { + if (initialSelectedValues.length === 0 || items.length <= CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD) { + return items; + } + + const selectedValues = new Set(initialSelectedValues); + const selected: T[] = []; + const remaining: T[] = []; + + for (const item of items) { + if (selectedValues.has(item.value)) { + selected.push(item); + continue; + } + + remaining.push(item); + } + + return [...selected, ...remaining]; +} + +export {moveInitialSelectionToTopByKey, moveInitialSelectionToTopByValue}; diff --git a/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx b/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx index 6aac0599a93b5..a12d2ad24c748 100644 --- a/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx +++ b/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx @@ -1,5 +1,5 @@ import {useRoute} from '@react-navigation/native'; -import React, {useState} from 'react'; +import React, {useMemo, useState} from 'react'; import {View} from 'react-native'; import FormHelpMessage from '@components/FormHelpMessage'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -9,12 +9,14 @@ import RadioListItem from '@components/SelectionList/ListItem/RadioListItem'; import Text from '@components/Text'; import {useCurrencyListState} from '@hooks/useCurrencyList'; import useDebouncedState from '@hooks/useDebouncedState'; +import useInitialSelectionRef from '@hooks/useInitialSelectionRef'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; import {getPlaidCountry, isPlaidSupportedCountry} from '@libs/CardUtils'; import searchOptions from '@libs/searchOptions'; +import {moveInitialSelectionToTopByValue} from '@libs/SelectionListOrderUtils'; import StringUtils from '@libs/StringUtils'; import Navigation from '@navigation/Navigation'; import type {PlatformStackRouteProp} from '@navigation/PlatformStackNavigation/types'; @@ -42,6 +44,8 @@ function SelectCountryStep({policyID}: CountryStepProps) { const [selectedCountry, setSelectedCountry] = useState(null); const currentCountry = selectedCountry ?? addNewCard?.data?.selectedCountry ?? getPlaidCountry(policy?.outputCurrency, currencyList, countryByIp); + const initialSelectedValues = useInitialSelectionRef(currentCountry ? [currentCountry] : [], {resetOnFocus: true}); + const initiallyFocusedCountry = initialSelectedValues.at(0); const [hasError, setHasError] = useState(false); const doesCountrySupportPlaid = isPlaidSupportedCountry(currentCountry); @@ -72,20 +76,24 @@ function SelectCountryStep({policyID}: CountryStepProps) { Navigation.goBack(); }; - const countries = Object.keys(CONST.ALL_COUNTRIES) - .filter((countryISO) => !CONST.PLAID_EXCLUDED_COUNTRIES.includes(countryISO)) - .map((countryISO) => { - const countryName = translate(`allCountries.${countryISO}` as TranslationPaths); - return { - value: countryISO, - keyForList: countryISO, - text: countryName, - isSelected: currentCountry === countryISO, - searchValue: StringUtils.sanitizeString(`${countryISO}${countryName}`), - }; - }); - - const searchResults = searchOptions(debouncedSearchValue, countries); + const countries = useMemo( + () => + Object.keys(CONST.ALL_COUNTRIES) + .filter((countryISO) => !CONST.PLAID_EXCLUDED_COUNTRIES.includes(countryISO)) + .map((countryISO) => { + const countryName = translate(`allCountries.${countryISO}` as TranslationPaths); + return { + value: countryISO, + keyForList: countryISO, + text: countryName, + searchValue: StringUtils.sanitizeString(`${countryISO}${countryName}`), + }; + }), + [translate], + ); + const orderedCountries = useMemo(() => moveInitialSelectionToTopByValue(countries, initialSelectedValues), [countries, initialSelectedValues]); + const filteredCountries = useMemo(() => searchOptions(debouncedSearchValue, debouncedSearchValue ? countries : orderedCountries), [debouncedSearchValue, countries, orderedCountries]); + const searchResults = useMemo(() => filteredCountries.map((country) => ({...country, isSelected: currentCountry === country.value})), [filteredCountries, currentCountry]); const headerMessage = debouncedSearchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : ''; const textInputOptions = { @@ -122,10 +130,11 @@ function SelectCountryStep({policyID}: CountryStepProps) { }} textInputOptions={textInputOptions} confirmButtonOptions={confirmButtonOptions} - initiallyFocusedItemKey={currentCountry} + initiallyFocusedItemKey={initiallyFocusedCountry} disableMaintainingScrollPosition shouldSingleExecuteRowSelect - shouldUpdateFocusedIndex + shouldScrollToFocusedIndex={false} + shouldScrollToFocusedIndexOnMount={false} addBottomSafeAreaPadding shouldStopPropagation > diff --git a/tests/ui/CountrySelectorModalTest.tsx b/tests/ui/CountrySelectorModalTest.tsx new file mode 100644 index 0000000000000..c51ff2f39da71 --- /dev/null +++ b/tests/ui/CountrySelectorModalTest.tsx @@ -0,0 +1,111 @@ +import type * as ReactNavigation from '@react-navigation/native'; +import {act, render} from '@testing-library/react-native'; +import React from 'react'; +import CountrySelectorModal from '@components/CountryPicker/CountrySelectorModal'; +import SelectionList from '@components/SelectionList'; +import searchOptions from '@libs/searchOptions'; +import StringUtils from '@libs/StringUtils'; +import CONST from '@src/CONST'; + +const mockUseState = React.useState; +const mockAllCountries = CONST.ALL_COUNTRIES; + +jest.mock('@react-navigation/native', () => { + const actualNavigation: typeof ReactNavigation = jest.requireActual('@react-navigation/native'); + + return { + ...actualNavigation, + useFocusEffect: jest.fn(), + }; +}); + +jest.mock('@components/HeaderWithBackButton', () => jest.fn(() => null)); +jest.mock('@components/Modal', () => jest.fn(({children}: {children: React.ReactNode}) => children)); +jest.mock('@components/ScreenWrapper', () => jest.fn(({children}: {children: React.ReactNode}) => children)); +jest.mock('@components/SelectionList', () => jest.fn(() => null)); +jest.mock('@components/SelectionList/ListItem/RadioListItem', () => jest.fn(() => null)); +jest.mock('@hooks/useDebouncedState', () => + jest.fn((initialValue: string) => { + const [value, setValue] = mockUseState(initialValue); + return [value, value, setValue]; + }), +); +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: (key: string) => { + if (key.startsWith('allCountries.')) { + const countryISO = key.split('.').at(-1) ?? ''; + return mockAllCountries[countryISO as keyof typeof mockAllCountries] ?? key; + } + + return key; + }, + })), +); +jest.mock('@hooks/useThemeStyles', () => + jest.fn(() => ({ + pb0: {}, + })), +); + +describe('CountrySelectorModal', () => { + const mockedSelectionList = jest.mocked(SelectionList); + + beforeEach(() => { + mockedSelectionList.mockClear(); + }); + + it('pins the saved country to the top on reopen', () => { + render( + , + ); + + const selectionListProps = mockedSelectionList.mock.lastCall?.[0]; + expect(selectionListProps?.data.at(0)).toEqual( + expect.objectContaining({ + keyForList: 'US', + value: 'US', + isSelected: true, + }), + ); + expect(selectionListProps?.initiallyFocusedItemKey).toBe('US'); + }); + + it('keeps natural filtered ordering while search is active', () => { + render( + , + ); + + const initialProps = mockedSelectionList.mock.lastCall?.[0]; + + act(() => { + initialProps?.textInputOptions?.onChangeText?.('Uni'); + }); + + const searchedProps = mockedSelectionList.mock.lastCall?.[0]; + const expectedSearchResults = searchOptions( + 'Uni', + Object.keys(CONST.ALL_COUNTRIES).map((countryISO) => ({ + value: countryISO, + keyForList: countryISO, + text: CONST.ALL_COUNTRIES[countryISO as keyof typeof CONST.ALL_COUNTRIES], + isSelected: countryISO === 'US', + searchValue: StringUtils.sanitizeString(`${countryISO}${CONST.ALL_COUNTRIES[countryISO as keyof typeof CONST.ALL_COUNTRIES]}`), + })), + ); + + expect(searchedProps?.data.map((item) => item.keyForList)).toEqual(expectedSearchResults.map((item) => item.keyForList)); + }); +}); diff --git a/tests/ui/PushRowModalTest.tsx b/tests/ui/PushRowModalTest.tsx new file mode 100644 index 0000000000000..5ab2d4370d593 --- /dev/null +++ b/tests/ui/PushRowModalTest.tsx @@ -0,0 +1,112 @@ +import type * as ReactNavigation from '@react-navigation/native'; +import {act, render} from '@testing-library/react-native'; +import React from 'react'; +import SelectionList from '@components/SelectionList'; +import searchOptions from '@libs/searchOptions'; +import StringUtils from '@libs/StringUtils'; +import PushRowModal from '@src/components/PushRowWithModal/PushRowModal'; + +const mockUseState = React.useState; + +jest.mock('@react-navigation/native', () => { + const actualNavigation: typeof ReactNavigation = jest.requireActual('@react-navigation/native'); + + return { + ...actualNavigation, + useFocusEffect: jest.fn(), + }; +}); + +jest.mock('@components/HeaderWithBackButton', () => jest.fn(() => null)); +jest.mock('@components/Modal', () => jest.fn(({children}: {children: React.ReactNode}) => children)); +jest.mock('@components/ScreenWrapper', () => jest.fn(({children}: {children: React.ReactNode}) => children)); +jest.mock('@components/SelectionList', () => jest.fn(() => null)); +jest.mock('@components/SelectionList/ListItem/RadioListItem', () => jest.fn(() => null)); +jest.mock('@hooks/useDebouncedState', () => + jest.fn((initialValue: string) => { + const [value, setValue] = mockUseState(initialValue); + return [value, value, setValue]; + }), +); +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: (key: string) => key, + })), +); + +describe('PushRowModal', () => { + const mockedSelectionList = jest.mocked(SelectionList); + const optionsList = { + one: 'Option 1', + two: 'Option 2', + three: 'Option 3', + four: 'Option 4', + five: 'Option 5', + six: 'Option 6', + seven: 'Option 7', + eight: 'Option 8', + nine: 'Option 9', + ten: 'Option 10', + }; + + beforeEach(() => { + mockedSelectionList.mockClear(); + }); + + it('pins the saved option to the top on reopen', () => { + render( + , + ); + + const selectionListProps = mockedSelectionList.mock.lastCall?.[0]; + expect(selectionListProps?.data.at(0)).toEqual( + expect.objectContaining({ + keyForList: 'ten', + value: 'ten', + isSelected: true, + }), + ); + expect(selectionListProps?.initiallyFocusedItemKey).toBe('ten'); + }); + + it('keeps natural filtered ordering while search is active', () => { + render( + , + ); + + const initialProps = mockedSelectionList.mock.lastCall?.[0]; + + act(() => { + initialProps?.textInputOptions?.onChangeText?.('Option 1'); + }); + + const searchedProps = mockedSelectionList.mock.lastCall?.[0]; + const expectedSearchResults = searchOptions( + 'Option 1', + Object.entries(optionsList).map(([key, value]) => ({ + value: key, + keyForList: key, + text: value, + isSelected: key === 'ten', + searchValue: StringUtils.sanitizeString(value), + })), + ); + + expect(searchedProps?.data.map((item) => item.keyForList)).toEqual(expectedSearchResults.map((item) => item.keyForList)); + }); +}); diff --git a/tests/ui/SelectCountryStepTest.tsx b/tests/ui/SelectCountryStepTest.tsx new file mode 100644 index 0000000000000..4fe741f567b00 --- /dev/null +++ b/tests/ui/SelectCountryStepTest.tsx @@ -0,0 +1,172 @@ +import type * as ReactNavigation from '@react-navigation/native'; +import {act, render} from '@testing-library/react-native'; +import React from 'react'; +import SelectionList from '@components/SelectionList'; +import useOnyx from '@hooks/useOnyx'; +import searchOptions from '@libs/searchOptions'; +import StringUtils from '@libs/StringUtils'; +import SelectCountryStep from '@pages/workspace/companyCards/addNew/SelectCountryStep'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +const mockUseState = React.useState; +const mockAllCountries = CONST.ALL_COUNTRIES; + +jest.mock('@react-navigation/native', () => { + const actualNavigation: typeof ReactNavigation = jest.requireActual('@react-navigation/native'); + + return { + ...actualNavigation, + useFocusEffect: jest.fn(), + useRoute: jest.fn(() => ({params: {backTo: ''}})), + }; +}); + +jest.mock('@components/FormHelpMessage', () => jest.fn(() => null)); +jest.mock('@components/HeaderWithBackButton', () => jest.fn(() => null)); +jest.mock('@components/ScreenWrapper', () => jest.fn(({children}: {children: React.ReactNode}) => children)); +jest.mock('@components/SelectionList', () => jest.fn(() => null)); +jest.mock('@components/SelectionList/ListItem/RadioListItem', () => jest.fn(() => null)); +jest.mock('@components/Text', () => jest.fn(() => null)); +jest.mock('@hooks/useCurrencyList', () => ({ + useCurrencyListState: jest.fn(() => ({ + currencyList: {}, + })), +})); +jest.mock('@hooks/useDebouncedState', () => + jest.fn((initialValue: string) => { + const [value, setValue] = mockUseState(initialValue); + return [value, value, setValue]; + }), +); +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: (key: string) => { + if (key.startsWith('allCountries.')) { + const countryISO = key.split('.').at(-1) ?? ''; + return mockAllCountries[countryISO as keyof typeof mockAllCountries] ?? key; + } + + return key; + }, + })), +); +jest.mock('@hooks/useOnyx', () => jest.fn()); +jest.mock('@hooks/usePolicy', () => jest.fn(() => ({outputCurrency: 'USD'}))); +jest.mock('@hooks/useThemeStyles', () => + jest.fn(() => ({ + textHeadlineLineHeightXXL: {}, + ph5: {}, + mv3: {}, + ph3: {}, + mb3: {}, + })), +); +jest.mock('@libs/CardUtils', () => ({ + getPlaidCountry: jest.fn(() => 'US'), + isPlaidSupportedCountry: jest.fn(() => true), +})); +jest.mock('@navigation/Navigation', () => ({ + goBack: jest.fn(), + navigate: jest.fn(), +})); +jest.mock('@userActions/CompanyCards', () => ({ + clearAddNewCardFlow: jest.fn(), + setAddNewCompanyCardStepAndData: jest.fn(), +})); + +describe('SelectCountryStep', () => { + const mockedSelectionList = jest.mocked(SelectionList); + const mockedUseOnyx = jest.mocked(useOnyx); + + let addNewCardCountry: string | undefined; + + beforeEach(() => { + addNewCardCountry = undefined; + mockedSelectionList.mockClear(); + mockedUseOnyx.mockImplementation((key) => { + if (key === ONYXKEYS.COUNTRY) { + return ['US', jest.fn()] as never; + } + + if (key === ONYXKEYS.ADD_NEW_COMPANY_CARD) { + return [{data: {selectedCountry: addNewCardCountry}}, jest.fn()] as never; + } + + return [undefined, jest.fn()] as never; + }); + }); + + it('pins the saved country to the top on reopen and disables focus-driven scroll', () => { + addNewCardCountry = 'US'; + + render(); + + const selectionListProps = mockedSelectionList.mock.lastCall?.[0]; + expect(selectionListProps?.data.at(0)).toEqual( + expect.objectContaining({ + keyForList: 'US', + value: 'US', + isSelected: true, + }), + ); + expect(selectionListProps?.initiallyFocusedItemKey).toBe('US'); + expect(selectionListProps?.shouldScrollToFocusedIndex).toBe(false); + expect(selectionListProps?.shouldScrollToFocusedIndexOnMount).toBe(false); + expect(selectionListProps?.shouldUpdateFocusedIndex).toBeUndefined(); + }); + + it('keeps the initially pinned country at the top while the live selection changes during the same mount', () => { + addNewCardCountry = 'US'; + + render(); + + const initialProps = mockedSelectionList.mock.lastCall?.[0]; + + act(() => { + initialProps?.onSelectRow?.({value: 'GB', keyForList: 'GB'}); + }); + + const updatedProps = mockedSelectionList.mock.lastCall?.[0]; + expect(updatedProps?.data.at(0)).toEqual( + expect.objectContaining({ + keyForList: 'US', + isSelected: false, + }), + ); + expect(updatedProps?.initiallyFocusedItemKey).toBe('US'); + expect(updatedProps?.data.find((item) => item.keyForList === 'GB')).toEqual( + expect.objectContaining({ + keyForList: 'GB', + isSelected: true, + }), + ); + }); + + it('keeps natural filtered ordering while search is active', () => { + addNewCardCountry = 'US'; + + render(); + + const initialProps = mockedSelectionList.mock.lastCall?.[0]; + + act(() => { + initialProps?.textInputOptions?.onChangeText?.('Uni'); + }); + + const searchedProps = mockedSelectionList.mock.lastCall?.[0]; + const expectedSearchResults = searchOptions( + 'Uni', + Object.keys(CONST.ALL_COUNTRIES) + .filter((countryISO) => !CONST.PLAID_EXCLUDED_COUNTRIES.includes(countryISO)) + .map((countryISO) => ({ + value: countryISO, + keyForList: countryISO, + text: CONST.ALL_COUNTRIES[countryISO as keyof typeof CONST.ALL_COUNTRIES], + searchValue: StringUtils.sanitizeString(`${countryISO}${CONST.ALL_COUNTRIES[countryISO as keyof typeof CONST.ALL_COUNTRIES]}`), + })), + ); + + expect(searchedProps?.data.map((item) => item.keyForList)).toEqual(expectedSearchResults.map((item) => item.keyForList)); + }); +}); diff --git a/tests/ui/StateSelectorModalTest.tsx b/tests/ui/StateSelectorModalTest.tsx new file mode 100644 index 0000000000000..4df6f34618b2f --- /dev/null +++ b/tests/ui/StateSelectorModalTest.tsx @@ -0,0 +1,117 @@ +import type * as ReactNavigation from '@react-navigation/native'; +import {act, render} from '@testing-library/react-native'; +import {CONST as COMMON_CONST} from 'expensify-common'; +import React from 'react'; +import SelectionList from '@components/SelectionList'; +import StateSelectorModal from '@components/StatePicker/StateSelectorModal'; +import searchOptions from '@libs/searchOptions'; +import StringUtils from '@libs/StringUtils'; + +const mockUseState = React.useState; +const mockStates = COMMON_CONST.STATES; + +jest.mock('@react-navigation/native', () => { + const actualNavigation: typeof ReactNavigation = jest.requireActual('@react-navigation/native'); + + return { + ...actualNavigation, + useFocusEffect: jest.fn(), + }; +}); + +jest.mock('@components/HeaderWithBackButton', () => jest.fn(() => null)); +jest.mock('@components/Modal', () => jest.fn(({children}: {children: React.ReactNode}) => children)); +jest.mock('@components/ScreenWrapper', () => jest.fn(({children}: {children: React.ReactNode}) => children)); +jest.mock('@components/SelectionList', () => jest.fn(() => null)); +jest.mock('@components/SelectionList/ListItem/RadioListItem', () => jest.fn(() => null)); +jest.mock('@hooks/useDebouncedState', () => + jest.fn((initialValue: string) => { + const [value, setValue] = mockUseState(initialValue); + return [value, value, setValue]; + }), +); +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: (key: string) => { + if (!key.startsWith('allStates.')) { + return key; + } + + const [, stateKey, property] = key.split('.'); + const state = mockStates[stateKey as keyof typeof mockStates]; + + if (property === 'stateName') { + return state.stateName; + } + + return state.stateISO; + }, + })), +); +jest.mock('@hooks/useThemeStyles', () => + jest.fn(() => ({ + pb0: {}, + })), +); + +describe('StateSelectorModal', () => { + const mockedSelectionList = jest.mocked(SelectionList); + + beforeEach(() => { + mockedSelectionList.mockClear(); + }); + + it('pins the saved state to the top on reopen', () => { + render( + , + ); + + const selectionListProps = mockedSelectionList.mock.lastCall?.[0]; + expect(selectionListProps?.data.at(0)).toEqual( + expect.objectContaining({ + keyForList: 'NY', + value: 'NY', + isSelected: true, + }), + ); + expect(selectionListProps?.initiallyFocusedItemKey).toBe('NY'); + }); + + it('keeps natural filtered ordering while search is active', () => { + render( + , + ); + + const initialProps = mockedSelectionList.mock.lastCall?.[0]; + + act(() => { + initialProps?.textInputOptions?.onChangeText?.('New'); + }); + + const searchedProps = mockedSelectionList.mock.lastCall?.[0]; + const expectedSearchResults = searchOptions( + 'New', + Object.keys(mockStates).map((state) => ({ + value: mockStates[state as keyof typeof mockStates].stateISO, + keyForList: mockStates[state as keyof typeof mockStates].stateISO, + text: mockStates[state as keyof typeof mockStates].stateName, + isSelected: mockStates[state as keyof typeof mockStates].stateISO === 'NY', + searchValue: StringUtils.sanitizeString(`${mockStates[state as keyof typeof mockStates].stateISO}${mockStates[state as keyof typeof mockStates].stateName}`), + })), + ); + + expect(searchedProps?.data.map((item) => item.keyForList)).toEqual(expectedSearchResults.map((item) => item.keyForList)); + }); +}); diff --git a/tests/ui/ValueSelectionListTest.tsx b/tests/ui/ValueSelectionListTest.tsx new file mode 100644 index 0000000000000..bfc4e9bad4f0a --- /dev/null +++ b/tests/ui/ValueSelectionListTest.tsx @@ -0,0 +1,86 @@ +import type * as ReactNavigation from '@react-navigation/native'; +import {render} from '@testing-library/react-native'; +import React from 'react'; +import SelectionList from '@components/SelectionList'; +import ValueSelectionList from '@components/ValuePicker/ValueSelectionList'; +import CONST from '@src/CONST'; + +jest.mock('@react-navigation/native', () => { + const actualNavigation: typeof ReactNavigation = jest.requireActual('@react-navigation/native'); + + return { + ...actualNavigation, + useFocusEffect: jest.fn(), + }; +}); + +jest.mock('@components/SelectionList', () => jest.fn(() => null)); +jest.mock('@components/SelectionList/ListItem/RadioListItem', () => jest.fn(() => null)); + +describe('ValueSelectionList', () => { + const mockedSelectionList = jest.mocked(SelectionList); + const items = Array.from({length: CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD + 2}, (_, index) => ({ + value: `value-${index}`, + label: `Label ${index}`, + description: `Description ${index}`, + })); + + beforeEach(() => { + mockedSelectionList.mockClear(); + }); + + it('pins the initial value to the top and disables focus-driven scroll', () => { + render( + , + ); + + const selectionListProps = mockedSelectionList.mock.lastCall?.[0]; + expect(selectionListProps?.data.at(0)).toEqual( + expect.objectContaining({ + keyForList: items.at(-1)?.value, + isSelected: true, + }), + ); + expect(selectionListProps?.initiallyFocusedItemKey).toBe(items.at(-1)?.value); + expect(selectionListProps?.shouldUpdateFocusedIndex).toBeUndefined(); + expect(selectionListProps?.shouldScrollToFocusedIndex).toBe(false); + expect(selectionListProps?.shouldScrollToFocusedIndexOnMount).toBe(false); + }); + + it('keeps the initially pinned value at the top while the live selection changes during the same mount', () => { + const {rerender} = render( + , + ); + + rerender( + , + ); + + const selectionListProps = mockedSelectionList.mock.lastCall?.[0]; + expect(selectionListProps?.data.at(0)).toEqual( + expect.objectContaining({ + keyForList: items.at(-1)?.value, + isSelected: false, + }), + ); + expect(selectionListProps?.initiallyFocusedItemKey).toBe(items.at(-1)?.value); + expect(selectionListProps?.data.find((item) => item.keyForList === items.at(-2)?.value)).toEqual( + expect.objectContaining({ + keyForList: items.at(-2)?.value, + isSelected: true, + }), + ); + }); +}); diff --git a/tests/ui/ValueSelectorModalTest.tsx b/tests/ui/ValueSelectorModalTest.tsx new file mode 100644 index 0000000000000..77c71176f8bff --- /dev/null +++ b/tests/ui/ValueSelectorModalTest.tsx @@ -0,0 +1,35 @@ +import {render} from '@testing-library/react-native'; +import React from 'react'; +import ValueSelectionList from '@components/ValuePicker/ValueSelectionList'; +import ValueSelectorModal from '@components/ValuePicker/ValueSelectorModal'; + +jest.mock('@components/HeaderWithBackButton', () => jest.fn(() => null)); +jest.mock('@components/Modal', () => jest.fn(({children}: {children: React.ReactNode}) => children)); +jest.mock('@components/ScreenWrapper', () => jest.fn(({children}: {children: React.ReactNode}) => children)); +jest.mock('@components/ValuePicker/ValueSelectionList', () => jest.fn(() => null)); + +describe('ValueSelectorModal', () => { + const mockedValueSelectionList = jest.mocked(ValueSelectionList); + + beforeEach(() => { + mockedValueSelectionList.mockClear(); + }); + + it('forwards modal visibility to ValueSelectionList', () => { + render( + , + ); + + expect(mockedValueSelectionList.mock.lastCall?.[0]).toEqual(expect.objectContaining({isVisible: true})); + }); +}); diff --git a/tests/unit/SelectionListOrderUtilsTest.ts b/tests/unit/SelectionListOrderUtilsTest.ts new file mode 100644 index 0000000000000..71c93d962755f --- /dev/null +++ b/tests/unit/SelectionListOrderUtilsTest.ts @@ -0,0 +1,31 @@ +import {moveInitialSelectionToTopByKey, moveInitialSelectionToTopByValue} from '@libs/SelectionListOrderUtils'; +import CONST from '@src/CONST'; + +describe('SelectionListOrderUtils', () => { + it('does not reorder keys when there is no initial selection', () => { + const keys = Array.from({length: CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD + 2}, (_, index) => `item-${index}`); + + expect(moveInitialSelectionToTopByKey(keys, [])).toEqual(keys); + }); + + it('does not reorder values when the list is under the global threshold', () => { + const items = Array.from({length: CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD}, (_, index) => ({ + value: `item-${index}`, + keyForList: `item-${index}`, + })); + + expect(moveInitialSelectionToTopByValue(items, ['item-3'])).toEqual(items); + }); + + it('moves the initially selected values to the top while preserving source order', () => { + const items = Array.from({length: CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD + 2}, (_, index) => ({ + value: `item-${index}`, + keyForList: `item-${index}`, + })); + const selectedValues = [`item-${CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD}`, `item-${CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD + 1}`]; + + const reorderedItems = moveInitialSelectionToTopByValue(items, selectedValues); + + expect(reorderedItems.map((item) => item.value)).toEqual([...selectedValues, ...items.filter((item) => !selectedValues.includes(item.value)).map((item) => item.value)]); + }); +}); From be13995c5cf0e485bcb6499b247048d971c54c9f Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Mon, 30 Mar 2026 08:12:58 +0430 Subject: [PATCH 2/8] refactor: rename useInitialSelection hook and simplify single-value callers Rename useInitialSelectionRef to useInitialSelection to match its reactive state return value, add a clarifying comment about why the snapshot only resets on open/focus boundaries, and switch the current single-selection picker callers to pass scalar values instead of string arrays. --- src/components/CountryPicker/CountrySelectorModal.tsx | 7 ++++--- src/components/PushRowWithModal/PushRowModal.tsx | 7 ++++--- src/components/StatePicker/StateSelectorModal.tsx | 7 ++++--- src/components/ValuePicker/ValueSelectionList.tsx | 7 ++++--- .../{useInitialSelectionRef.ts => useInitialSelection.ts} | 8 +++++--- .../workspace/companyCards/addNew/SelectCountryStep.tsx | 7 ++++--- 6 files changed, 25 insertions(+), 18 deletions(-) rename src/hooks/{useInitialSelectionRef.ts => useInitialSelection.ts} (84%) diff --git a/src/components/CountryPicker/CountrySelectorModal.tsx b/src/components/CountryPicker/CountrySelectorModal.tsx index cbd49292c57a5..1a2d2266855cc 100644 --- a/src/components/CountryPicker/CountrySelectorModal.tsx +++ b/src/components/CountryPicker/CountrySelectorModal.tsx @@ -5,7 +5,7 @@ import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; import RadioListItem from '@components/SelectionList/ListItem/RadioListItem'; import useDebouncedState from '@hooks/useDebouncedState'; -import useInitialSelectionRef from '@hooks/useInitialSelectionRef'; +import useInitialSelection from '@hooks/useInitialSelection'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import searchOptions from '@libs/searchOptions'; @@ -38,8 +38,9 @@ type CountrySelectorModalProps = { function CountrySelectorModal({isVisible, currentCountry, onCountrySelected, onClose, label, onBackdropPress}: CountrySelectorModalProps) { const {translate} = useLocalize(); const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); - const initialSelectedValues = useInitialSelectionRef(currentCountry ? [currentCountry] : [], {resetDeps: [isVisible]}); - const initiallyFocusedCountry = initialSelectedValues.at(0); + const initialSelectedValue = useInitialSelection(currentCountry || undefined, {resetDeps: [isVisible]}); + const initialSelectedValues = initialSelectedValue ? [initialSelectedValue] : []; + const initiallyFocusedCountry = initialSelectedValue; const countries = useMemo( () => diff --git a/src/components/PushRowWithModal/PushRowModal.tsx b/src/components/PushRowWithModal/PushRowModal.tsx index 4f08d86d03689..3d41052ede540 100644 --- a/src/components/PushRowWithModal/PushRowModal.tsx +++ b/src/components/PushRowWithModal/PushRowModal.tsx @@ -5,7 +5,7 @@ import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; import RadioListItem from '@components/SelectionList/ListItem/RadioListItem'; import useDebouncedState from '@hooks/useDebouncedState'; -import useInitialSelectionRef from '@hooks/useInitialSelectionRef'; +import useInitialSelection from '@hooks/useInitialSelection'; import useLocalize from '@hooks/useLocalize'; import searchOptions from '@libs/searchOptions'; import {moveInitialSelectionToTopByValue} from '@libs/SelectionListOrderUtils'; @@ -46,8 +46,9 @@ function PushRowModal({isVisible, selectedOption, onOptionChange, onClose, optio const {translate} = useLocalize(); const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); - const initialSelectedValues = useInitialSelectionRef(selectedOption ? [selectedOption] : [], {resetDeps: [isVisible]}); - const initiallyFocusedOption = initialSelectedValues.at(0); + const initialSelectedValue = useInitialSelection(selectedOption || undefined, {resetDeps: [isVisible]}); + const initialSelectedValues = initialSelectedValue ? [initialSelectedValue] : []; + const initiallyFocusedOption = initialSelectedValue; const options = useMemo( () => diff --git a/src/components/StatePicker/StateSelectorModal.tsx b/src/components/StatePicker/StateSelectorModal.tsx index 916ff5e547319..ee957ea7200dc 100644 --- a/src/components/StatePicker/StateSelectorModal.tsx +++ b/src/components/StatePicker/StateSelectorModal.tsx @@ -6,7 +6,7 @@ import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; import RadioListItem from '@components/SelectionList/ListItem/RadioListItem'; import useDebouncedState from '@hooks/useDebouncedState'; -import useInitialSelectionRef from '@hooks/useInitialSelectionRef'; +import useInitialSelection from '@hooks/useInitialSelection'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import searchOptions from '@libs/searchOptions'; @@ -41,8 +41,9 @@ function StateSelectorModal({isVisible, currentState, onStateSelected, onClose, const {translate} = useLocalize(); const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); const styles = useThemeStyles(); - const initialSelectedValues = useInitialSelectionRef(currentState ? [currentState] : [], {resetDeps: [isVisible]}); - const initiallyFocusedState = initialSelectedValues.at(0); + const initialSelectedValue = useInitialSelection(currentState || undefined, {resetDeps: [isVisible]}); + const initialSelectedValues = initialSelectedValue ? [initialSelectedValue] : []; + const initiallyFocusedState = initialSelectedValue; const countryStates = useMemo( () => diff --git a/src/components/ValuePicker/ValueSelectionList.tsx b/src/components/ValuePicker/ValueSelectionList.tsx index 99283c3c87b14..f558753501e7d 100644 --- a/src/components/ValuePicker/ValueSelectionList.tsx +++ b/src/components/ValuePicker/ValueSelectionList.tsx @@ -1,7 +1,7 @@ import React, {useMemo} from 'react'; import SelectionList from '@components/SelectionList'; import RadioListItem from '@components/SelectionList/ListItem/RadioListItem'; -import useInitialSelectionRef from '@hooks/useInitialSelectionRef'; +import useInitialSelection from '@hooks/useInitialSelection'; import {moveInitialSelectionToTopByValue} from '@libs/SelectionListOrderUtils'; import type {ValueSelectionListProps} from './types'; @@ -15,8 +15,9 @@ function ValueSelectionList({ alternateNumberOfSupportedLines, isVisible, }: ValueSelectionListProps) { - const initialSelectedValues = useInitialSelectionRef(selectedItem?.value ? [selectedItem.value] : [], isVisible === undefined ? {resetOnFocus: true} : {resetDeps: [isVisible]}); - const initiallyFocusedItemKey = initialSelectedValues.at(0); + const initialSelectedValue = useInitialSelection(selectedItem?.value || undefined, isVisible === undefined ? {resetOnFocus: true} : {resetDeps: [isVisible]}); + const initialSelectedValues = initialSelectedValue ? [initialSelectedValue] : []; + const initiallyFocusedItemKey = initialSelectedValue; const mappedOptions = useMemo(() => items.map((item) => ({value: item.value ?? '', alternateText: item.description, text: item.label ?? '', keyForList: item.value ?? ''})), [items]); const orderedOptions = useMemo(() => moveInitialSelectionToTopByValue(mappedOptions, initialSelectedValues), [initialSelectedValues, mappedOptions]); diff --git a/src/hooks/useInitialSelectionRef.ts b/src/hooks/useInitialSelection.ts similarity index 84% rename from src/hooks/useInitialSelectionRef.ts rename to src/hooks/useInitialSelection.ts index 349cce8f89d40..f56271d9e486e 100644 --- a/src/hooks/useInitialSelectionRef.ts +++ b/src/hooks/useInitialSelection.ts @@ -2,7 +2,7 @@ import {useFocusEffect} from '@react-navigation/native'; import type {DependencyList} from 'react'; import {useCallback, useEffect, useRef, useState} from 'react'; -type UseInitialSelectionRefOptions = { +type UseInitialSelectionOptions = { /** Dependencies that should trigger refreshing the snapshot (e.g., when a modal opens) */ resetDeps?: DependencyList; @@ -20,7 +20,7 @@ type UseInitialSelectionRefOptions = { * Keeps an immutable snapshot of the initial selection for the current open/focus cycle. * Callers can refresh the snapshot by changing `resetDeps` or via screen focus. */ -function useInitialSelectionRef(selection: T, options: UseInitialSelectionRefOptions = {}) { +function useInitialSelection(selection: T, options: UseInitialSelectionOptions = {}) { const {resetDeps = [], resetOnFocus = false, shouldSyncSelection = false, isEqual = Object.is} = options; const [initialSelection, setInitialSelection] = useState(selection); const latestSelectionRef = useRef(selection); @@ -37,6 +37,8 @@ function useInitialSelectionRef(selection: T, options: UseInitialSelectionRef }, [selection]); useEffect(() => { + // Intentionally refresh the snapshot only when the caller marks a new open/focus cycle. + // Live selection changes while the picker stays open should not repin or refocus the list. updateInitialSelection(selection); // eslint-disable-next-line react-hooks/exhaustive-deps }, resetDeps); @@ -62,4 +64,4 @@ function useInitialSelectionRef(selection: T, options: UseInitialSelectionRef return initialSelection; } -export default useInitialSelectionRef; +export default useInitialSelection; diff --git a/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx b/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx index a12d2ad24c748..df6ae11f2dcb2 100644 --- a/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx +++ b/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx @@ -9,7 +9,7 @@ import RadioListItem from '@components/SelectionList/ListItem/RadioListItem'; import Text from '@components/Text'; import {useCurrencyListState} from '@hooks/useCurrencyList'; import useDebouncedState from '@hooks/useDebouncedState'; -import useInitialSelectionRef from '@hooks/useInitialSelectionRef'; +import useInitialSelection from '@hooks/useInitialSelection'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; @@ -44,8 +44,9 @@ function SelectCountryStep({policyID}: CountryStepProps) { const [selectedCountry, setSelectedCountry] = useState(null); const currentCountry = selectedCountry ?? addNewCard?.data?.selectedCountry ?? getPlaidCountry(policy?.outputCurrency, currencyList, countryByIp); - const initialSelectedValues = useInitialSelectionRef(currentCountry ? [currentCountry] : [], {resetOnFocus: true}); - const initiallyFocusedCountry = initialSelectedValues.at(0); + const initialSelectedValue = useInitialSelection(currentCountry || undefined, {resetOnFocus: true}); + const initialSelectedValues = initialSelectedValue ? [initialSelectedValue] : []; + const initiallyFocusedCountry = initialSelectedValue; const [hasError, setHasError] = useState(false); const doesCountrySupportPlaid = isPlaidSupportedCountry(currentCountry); From 2be0c60ef4a138b77c98f6478cd33db3296ebbff Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Mon, 30 Mar 2026 08:54:24 +0430 Subject: [PATCH 3/8] fix: keep picker focus sync aligned with debounced search state Pass the debounced search value into SelectionList focus synchronization for the basic picker flows so search-clear transitions use the same state that drives the rendered data. --- .../CountryPicker/CountrySelectorModal.tsx | 1 + .../PushRowWithModal/PushRowModal.tsx | 1 + .../SelectionList/BaseSelectionList.tsx | 6 ++- src/components/SelectionList/types.ts | 3 ++ .../StatePicker/StateSelectorModal.tsx | 1 + .../companyCards/addNew/SelectCountryStep.tsx | 1 + .../SelectionList/useSearchFocusSync.test.ts | 49 +++++++++++++++++++ 7 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 tests/unit/components/SelectionList/useSearchFocusSync.test.ts diff --git a/src/components/CountryPicker/CountrySelectorModal.tsx b/src/components/CountryPicker/CountrySelectorModal.tsx index 1a2d2266855cc..b416ec140a416 100644 --- a/src/components/CountryPicker/CountrySelectorModal.tsx +++ b/src/components/CountryPicker/CountrySelectorModal.tsx @@ -96,6 +96,7 @@ function CountrySelectorModal({isVisible, currentCountry, onCountrySelected, onC ({ ref, ListItem, textInputOptions, + searchValueForFocusSync, initiallyFocusedItemKey, onSelectRow, onSelectAll, @@ -205,6 +206,7 @@ function BaseSelectionList({ // Including data.length ensures FlashList resets its layout cache when the list size changes // This prevents "index out of bounds" errors when filtering reduces the list size const extraData = useMemo(() => [data.length], [data.length]); + const syncedSearchValue = searchValueForFocusSync ?? textInputOptions?.value; const selectRow = useCallback( (item: TItem, indexToFocus?: number) => { @@ -493,12 +495,12 @@ function BaseSelectionList({ initiallyFocusedItemKey, isItemSelected, focusedIndex, - searchValue: textInputOptions?.value, + searchValue: syncedSearchValue, setFocusedIndex, }); useSearchFocusSync({ - searchValue: textInputOptions?.value, + searchValue: syncedSearchValue, data, selectedOptionsCount: dataDetails.selectedOptions.length, isItemSelected, diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index 8b6b8eca5300c..943ac3f4bfd03 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -56,6 +56,9 @@ type BaseSelectionListProps = { /** Configuration options for the text input */ textInputOptions?: TextInputOptions; + /** Search value used for focus synchronization. Defaults to textInputOptions.value */ + searchValueForFocusSync?: string; + /** Whether to show the text input */ shouldShowTextInput?: boolean; diff --git a/src/components/StatePicker/StateSelectorModal.tsx b/src/components/StatePicker/StateSelectorModal.tsx index ee957ea7200dc..619bf578e9579 100644 --- a/src/components/StatePicker/StateSelectorModal.tsx +++ b/src/components/StatePicker/StateSelectorModal.tsx @@ -102,6 +102,7 @@ function StateSelectorModal({isVisible, currentState, onStateSelected, onClose, ListItem={RadioListItem} onSelectRow={onStateSelected} textInputOptions={textInputOptions} + searchValueForFocusSync={debouncedSearchValue} initiallyFocusedItemKey={initiallyFocusedState} disableMaintainingScrollPosition shouldSingleExecuteRowSelect diff --git a/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx b/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx index df6ae11f2dcb2..e0c234e93dc43 100644 --- a/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx +++ b/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx @@ -130,6 +130,7 @@ function SelectCountryStep({policyID}: CountryStepProps) { setSelectedCountry(countryOption.value ?? null); }} textInputOptions={textInputOptions} + searchValueForFocusSync={debouncedSearchValue} confirmButtonOptions={confirmButtonOptions} initiallyFocusedItemKey={initiallyFocusedCountry} disableMaintainingScrollPosition diff --git a/tests/unit/components/SelectionList/useSearchFocusSync.test.ts b/tests/unit/components/SelectionList/useSearchFocusSync.test.ts new file mode 100644 index 0000000000000..7c6843df359fe --- /dev/null +++ b/tests/unit/components/SelectionList/useSearchFocusSync.test.ts @@ -0,0 +1,49 @@ +import {renderHook} from '@testing-library/react-native'; +import useSearchFocusSync from '@components/SelectionList/hooks/useSearchFocusSync'; + +type MockItem = { + keyForList: string; + isSelected?: boolean; +}; + +describe('useSearchFocusSync', () => { + it('focuses the selected row when the debounced search is cleared and the full list returns', () => { + const scrollToIndex = jest.fn(); + const setFocusedIndex = jest.fn(); + const filteredData: MockItem[] = [{keyForList: 'match'}]; + const fullData: MockItem[] = [ + {keyForList: 'a'}, + {keyForList: 'b'}, + {keyForList: 'selected', isSelected: true}, + {keyForList: 'c'}, + ]; + + const {rerender} = renderHook( + ({searchValue, data}: {searchValue: string; data: MockItem[]}) => + useSearchFocusSync({ + searchValue, + data, + selectedOptionsCount: data.filter((item) => item.isSelected).length, + isItemSelected: (item) => !!item.isSelected, + canSelectMultiple: false, + shouldUpdateFocusedIndex: false, + scrollToIndex, + setFocusedIndex, + }), + { + initialProps: { + searchValue: 'uni', + data: filteredData, + }, + }, + ); + + rerender({ + searchValue: '', + data: fullData, + }); + + expect(scrollToIndex).toHaveBeenCalledWith(2); + expect(setFocusedIndex).toHaveBeenCalledWith(2); + }); +}); From 3acb38bf20e39ea5bc527f352c81c87b5e22de8a Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Mon, 30 Mar 2026 13:35:52 +0430 Subject: [PATCH 4/8] fix: extend basic picker reopen ordering to dynamic country and state flows Apply the initial-selection snapshot and reopen-only ordering behavior to the dynamic profile country/state pickers and the shared wallet country selection list. Search results now keep their natural filtered order while focus sync follows the debounced search value, and the wallet country list matches the no-focus-scroll behavior used in the first picker slice. --- .../DynamicCountrySelectionPage.tsx | 22 ++- .../PersonalDetails/StateSelectionPage.tsx | 22 ++- .../settings/Wallet/CountrySelectionList.tsx | 75 ++++++--- tests/ui/CountrySelectionListTest.tsx | 155 ++++++++++++++++++ tests/ui/DynamicCountrySelectionPageTest.tsx | 95 +++++++++++ tests/ui/StateSelectionPageTest.tsx | 101 ++++++++++++ 6 files changed, 431 insertions(+), 39 deletions(-) create mode 100644 tests/ui/CountrySelectionListTest.tsx create mode 100644 tests/ui/DynamicCountrySelectionPageTest.tsx create mode 100644 tests/ui/StateSelectionPageTest.tsx diff --git a/src/pages/settings/Profile/PersonalDetails/DynamicCountrySelectionPage.tsx b/src/pages/settings/Profile/PersonalDetails/DynamicCountrySelectionPage.tsx index e83eecb21dd90..09a05a6aee530 100644 --- a/src/pages/settings/Profile/PersonalDetails/DynamicCountrySelectionPage.tsx +++ b/src/pages/settings/Profile/PersonalDetails/DynamicCountrySelectionPage.tsx @@ -1,13 +1,16 @@ -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useCallback, useMemo} from 'react'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; import RadioListItem from '@components/SelectionList/ListItem/RadioListItem'; +import useDebouncedState from '@hooks/useDebouncedState'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; +import useInitialSelection from '@hooks/useInitialSelection'; import useLocalize from '@hooks/useLocalize'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import {moveInitialSelectionToTopByValue} from '@libs/SelectionListOrderUtils'; import type {Option} from '@libs/searchOptions'; import searchOptions from '@libs/searchOptions'; import StringUtils from '@libs/StringUtils'; @@ -20,10 +23,12 @@ import type SCREENS from '@src/SCREENS'; type DynamicCountrySelectionPageProps = PlatformStackScreenProps; function DynamicCountrySelectionPage({route}: DynamicCountrySelectionPageProps) { - const [searchValue, setSearchValue] = useState(''); + const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); const {translate} = useLocalize(); const currentCountry = route.params.country; const backPath = useDynamicBackPath(DYNAMIC_ROUTES.ADDRESS_COUNTRY.path); + const initialSelectedValue = useInitialSelection(currentCountry ?? undefined, {resetOnFocus: true}); + const initialSelectedValues = useMemo(() => (initialSelectedValue ? [initialSelectedValue] : []), [initialSelectedValue]); const countries = useMemo( () => @@ -40,7 +45,11 @@ function DynamicCountrySelectionPage({route}: DynamicCountrySelectionPageProps) [translate, currentCountry], ); - const searchResults = searchOptions(searchValue, countries); + const orderedCountries = useMemo(() => moveInitialSelectionToTopByValue(countries, initialSelectedValues), [countries, initialSelectedValues]); + const searchResults = useMemo( + () => searchOptions(debouncedSearchValue, debouncedSearchValue ? countries : orderedCountries), + [countries, debouncedSearchValue, orderedCountries], + ); const selectCountry = useCallback( (option: Option) => { @@ -51,12 +60,12 @@ function DynamicCountrySelectionPage({route}: DynamicCountrySelectionPageProps) const textInputOptions = useMemo( () => ({ - headerMessage: searchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : '', + headerMessage: debouncedSearchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : '', label: translate('common.country'), value: searchValue, onChangeText: setSearchValue, }), - [searchResults.length, searchValue, translate], + [debouncedSearchValue, searchResults.length, searchValue, translate, setSearchValue], ); return ( @@ -77,7 +86,8 @@ function DynamicCountrySelectionPage({route}: DynamicCountrySelectionPageProps) ListItem={RadioListItem} onSelectRow={selectCountry} textInputOptions={textInputOptions} - initiallyFocusedItemKey={currentCountry} + searchValueForFocusSync={debouncedSearchValue} + initiallyFocusedItemKey={initialSelectedValue} shouldSingleExecuteRowSelect addBottomSafeAreaPadding /> diff --git a/src/pages/settings/Profile/PersonalDetails/StateSelectionPage.tsx b/src/pages/settings/Profile/PersonalDetails/StateSelectionPage.tsx index 27148e19171c3..5320c48511b1c 100644 --- a/src/pages/settings/Profile/PersonalDetails/StateSelectionPage.tsx +++ b/src/pages/settings/Profile/PersonalDetails/StateSelectionPage.tsx @@ -1,13 +1,16 @@ import {useRoute} from '@react-navigation/native'; import {CONST as COMMON_CONST} from 'expensify-common'; -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useCallback, useMemo} from 'react'; import {View} from 'react-native'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; import RadioListItem from '@components/SelectionList/ListItem/RadioListItem'; +import useDebouncedState from '@hooks/useDebouncedState'; +import useInitialSelection from '@hooks/useInitialSelection'; import useLocalize from '@hooks/useLocalize'; import Navigation from '@libs/Navigation/Navigation'; +import {moveInitialSelectionToTopByValue} from '@libs/SelectionListOrderUtils'; import searchOptions from '@libs/searchOptions'; import type {Option} from '@libs/searchOptions'; import StringUtils from '@libs/StringUtils'; @@ -26,10 +29,12 @@ function StateSelectionPage() { const route = useRoute(); const {translate} = useLocalize(); - const [searchValue, setSearchValue] = useState(''); + const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); const params = route.params as RouteParams | undefined; const currentState = params?.state; const label = params?.label; + const initialSelectedValue = useInitialSelection(currentState ?? undefined, {resetOnFocus: true}); + const initialSelectedValues = useMemo(() => (initialSelectedValue ? [initialSelectedValue] : []), [initialSelectedValue]); const countryStates = useMemo( () => @@ -48,8 +53,12 @@ function StateSelectionPage() { [translate, currentState], ); - const searchResults = searchOptions(searchValue, countryStates); - const headerMessage = searchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : ''; + const orderedCountryStates = useMemo(() => moveInitialSelectionToTopByValue(countryStates, initialSelectedValues), [countryStates, initialSelectedValues]); + const searchResults = useMemo( + () => searchOptions(debouncedSearchValue, debouncedSearchValue ? countryStates : orderedCountryStates), + [countryStates, debouncedSearchValue, orderedCountryStates], + ); + const headerMessage = debouncedSearchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : ''; const selectCountryState = useCallback( (option: Option) => { @@ -75,7 +84,7 @@ function StateSelectionPage() { value: searchValue, onChangeText: setSearchValue, }), - [headerMessage, label, searchValue, translate], + [headerMessage, label, searchValue, setSearchValue, translate], ); return ( @@ -106,7 +115,8 @@ function StateSelectionPage() { ListItem={RadioListItem} onSelectRow={selectCountryState} textInputOptions={textInputOptions} - initiallyFocusedItemKey={currentState} + searchValueForFocusSync={debouncedSearchValue} + initiallyFocusedItemKey={initialSelectedValue} shouldSingleExecuteRowSelect disableMaintainingScrollPosition addBottomSafeAreaPadding diff --git a/src/pages/settings/Wallet/CountrySelectionList.tsx b/src/pages/settings/Wallet/CountrySelectionList.tsx index 30c39317c1f60..9e1dcaddc8ac0 100644 --- a/src/pages/settings/Wallet/CountrySelectionList.tsx +++ b/src/pages/settings/Wallet/CountrySelectionList.tsx @@ -1,11 +1,14 @@ -import React, {useState} from 'react'; +import React, {useMemo} from 'react'; import {View} from 'react-native'; import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; import SelectionList from '@components/SelectionList'; import RadioListItem from '@components/SelectionList/ListItem/RadioListItem'; +import useDebouncedState from '@hooks/useDebouncedState'; +import useInitialSelection from '@hooks/useInitialSelection'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; +import {moveInitialSelectionToTopByValue} from '@libs/SelectionListOrderUtils'; import searchOptions from '@libs/searchOptions'; import type {Option} from '@libs/searchOptions'; import StringUtils from '@libs/StringUtils'; @@ -36,38 +39,54 @@ function CountrySelectionList({isEditing, selectedCountry, countries, onCountryS const {translate} = useLocalize(); const {isOffline} = useNetwork(); const styles = useThemeStyles(); - const [searchValue, setSearchValue] = useState(''); + const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); + const initialSelectedValue = useInitialSelection(selectedCountry ?? undefined, {resetOnFocus: true}); + const initialSelectedValues = useMemo(() => (initialSelectedValue ? [initialSelectedValue] : []), [initialSelectedValue]); const onSelectionChange = (country: Option) => { onCountrySelected(country.value); }; - const countriesList = countries.map((countryISO) => { - const countryName = translate(`allCountries.${countryISO}` as TranslationPaths); - return { - value: countryISO, - keyForList: countryISO, - text: countryName, - isSelected: selectedCountry === countryISO, - searchValue: StringUtils.sanitizeString(`${countryISO}${countryName}`), - }; - }); + const countriesList = useMemo( + () => + countries.map((countryISO) => { + const countryName = translate(`allCountries.${countryISO}` as TranslationPaths); + return { + value: countryISO, + keyForList: countryISO, + text: countryName, + isSelected: selectedCountry === countryISO, + searchValue: StringUtils.sanitizeString(`${countryISO}${countryName}`), + }; + }), + [countries, selectedCountry, translate], + ); - const searchResults = searchOptions(searchValue, countriesList); + const orderedCountries = useMemo(() => moveInitialSelectionToTopByValue(countriesList, initialSelectedValues), [countriesList, initialSelectedValues]); + const searchResults = useMemo( + () => searchOptions(debouncedSearchValue, debouncedSearchValue ? countriesList : orderedCountries), + [countriesList, debouncedSearchValue, orderedCountries], + ); - const textInputOptions = { - label: translate('common.search'), - value: searchValue, - onChangeText: setSearchValue, - headerMessage: searchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : '', - }; + const textInputOptions = useMemo( + () => ({ + label: translate('common.search'), + value: searchValue, + onChangeText: setSearchValue, + headerMessage: debouncedSearchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : '', + }), + [debouncedSearchValue, searchResults.length, searchValue, setSearchValue, translate], + ); - const confirmButtonOptions = { - showButton: true, - text: isEditing ? translate('common.confirm') : translate('common.next'), - isDisabled: isOffline, - onConfirm, - }; + const confirmButtonOptions = useMemo( + () => ({ + showButton: true, + text: isEditing ? translate('common.confirm') : translate('common.next'), + isDisabled: isOffline, + onConfirm, + }), + [isEditing, isOffline, onConfirm, translate], + ); return ( @@ -79,12 +98,14 @@ function CountrySelectionList({isEditing, selectedCountry, countries, onCountryS ListItem={RadioListItem} onSelectRow={onSelectionChange} textInputOptions={textInputOptions} + searchValueForFocusSync={debouncedSearchValue} confirmButtonOptions={confirmButtonOptions} - initiallyFocusedItemKey={selectedCountry} + initiallyFocusedItemKey={initialSelectedValue} footerContent={footerContent} disableMaintainingScrollPosition shouldSingleExecuteRowSelect - shouldUpdateFocusedIndex + shouldScrollToFocusedIndex={false} + shouldScrollToFocusedIndexOnMount={false} shouldStopPropagation /> diff --git a/tests/ui/CountrySelectionListTest.tsx b/tests/ui/CountrySelectionListTest.tsx new file mode 100644 index 0000000000000..e05fd7bc5ed65 --- /dev/null +++ b/tests/ui/CountrySelectionListTest.tsx @@ -0,0 +1,155 @@ +import type * as ReactNavigation from '@react-navigation/native'; +import {act, render} from '@testing-library/react-native'; +import React from 'react'; +import SelectionList from '@components/SelectionList'; +import CountrySelectionList from '@pages/settings/Wallet/CountrySelectionList'; +import searchOptions from '@libs/searchOptions'; +import StringUtils from '@libs/StringUtils'; +import CONST from '@src/CONST'; + +const mockUseState = React.useState; +const mockAllCountries = CONST.ALL_COUNTRIES; + +jest.mock('@react-navigation/native', () => { + const actualNavigation: typeof ReactNavigation = jest.requireActual('@react-navigation/native'); + + return { + ...actualNavigation, + useFocusEffect: jest.fn(), + }; +}); + +jest.mock('@components/BlockingViews/FullPageOfflineBlockingView', () => jest.fn(({children}: {children: React.ReactNode}) => children)); +jest.mock('@components/SelectionList', () => jest.fn(() => null)); +jest.mock('@components/SelectionList/ListItem/RadioListItem', () => jest.fn(() => null)); +jest.mock('@hooks/useDebouncedState', () => + jest.fn((initialValue: string) => { + const [value, setValue] = mockUseState(initialValue); + return [value, value, setValue]; + }), +); +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: (key: string) => { + if (key.startsWith('allCountries.')) { + const countryISO = key.split('.').at(-1) ?? ''; + return mockAllCountries[countryISO as keyof typeof mockAllCountries] ?? key; + } + + return key; + }, + })), +); +jest.mock('@hooks/useNetwork', () => jest.fn(() => ({isOffline: false}))); +jest.mock('@hooks/useThemeStyles', () => + jest.fn(() => ({ + ph5: {}, + textHeadlineLineHeightXXL: {}, + mb6: {}, + mt5: {}, + })), +); +jest.mock('@src/components/Text', () => jest.fn(() => null)); + +describe('CountrySelectionList', () => { + const mockedSelectionList = jest.mocked(SelectionList); + const countries = Object.keys(CONST.ALL_COUNTRIES).slice(0, CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD + 2); + const initialCountry = countries.at(-1) ?? ''; + const updatedCountry = countries.at(-2) ?? ''; + + beforeEach(() => { + mockedSelectionList.mockClear(); + }); + + it('pins the saved country to the top on reopen and disables focus-driven scroll', () => { + render( + , + ); + + const selectionListProps = mockedSelectionList.mock.lastCall?.[0]; + expect(selectionListProps?.data.at(0)).toEqual( + expect.objectContaining({ + keyForList: initialCountry, + value: initialCountry, + isSelected: true, + }), + ); + expect(selectionListProps?.initiallyFocusedItemKey).toBe(initialCountry); + expect(selectionListProps?.searchValueForFocusSync).toBe(''); + expect(selectionListProps?.shouldUpdateFocusedIndex).toBeUndefined(); + expect(selectionListProps?.shouldScrollToFocusedIndex).toBe(false); + expect(selectionListProps?.shouldScrollToFocusedIndexOnMount).toBe(false); + }); + + it('keeps the initially pinned country at the top while the live selection changes during the same mount', () => { + const {rerender} = render( + , + ); + + rerender( + , + ); + + const selectionListProps = mockedSelectionList.mock.lastCall?.[0]; + expect(selectionListProps?.data.at(0)).toEqual( + expect.objectContaining({ + keyForList: initialCountry, + isSelected: false, + }), + ); + expect(selectionListProps?.initiallyFocusedItemKey).toBe(initialCountry); + expect(selectionListProps?.data.find((item) => item.keyForList === updatedCountry)).toEqual( + expect.objectContaining({ + keyForList: updatedCountry, + isSelected: true, + }), + ); + }); + + it('keeps natural filtered ordering while search is active', () => { + render( + , + ); + + const initialProps = mockedSelectionList.mock.lastCall?.[0]; + + act(() => { + initialProps?.textInputOptions?.onChangeText?.('Uni'); + }); + + const searchedProps = mockedSelectionList.mock.lastCall?.[0]; + const expectedSearchResults = searchOptions( + 'Uni', + countries.map((countryISO) => ({ + value: countryISO, + keyForList: countryISO, + text: CONST.ALL_COUNTRIES[countryISO as keyof typeof CONST.ALL_COUNTRIES], + isSelected: countryISO === initialCountry, + searchValue: StringUtils.sanitizeString(`${countryISO}${CONST.ALL_COUNTRIES[countryISO as keyof typeof CONST.ALL_COUNTRIES]}`), + })), + ); + + expect(searchedProps?.data.map((item) => item.keyForList)).toEqual(expectedSearchResults.map((item) => item.keyForList)); + expect(searchedProps?.searchValueForFocusSync).toBe('Uni'); + }); +}); diff --git a/tests/ui/DynamicCountrySelectionPageTest.tsx b/tests/ui/DynamicCountrySelectionPageTest.tsx new file mode 100644 index 0000000000000..5340a158b4fd0 --- /dev/null +++ b/tests/ui/DynamicCountrySelectionPageTest.tsx @@ -0,0 +1,95 @@ +import type * as ReactNavigation from '@react-navigation/native'; +import {act, render} from '@testing-library/react-native'; +import React from 'react'; +import DynamicCountrySelectionPage from '@pages/settings/Profile/PersonalDetails/DynamicCountrySelectionPage'; +import SelectionList from '@components/SelectionList'; +import searchOptions from '@libs/searchOptions'; +import StringUtils from '@libs/StringUtils'; +import CONST from '@src/CONST'; + +const mockUseState = React.useState; +const mockAllCountries = CONST.ALL_COUNTRIES; + +jest.mock('@react-navigation/native', () => { + const actualNavigation: typeof ReactNavigation = jest.requireActual('@react-navigation/native'); + + return { + ...actualNavigation, + useFocusEffect: jest.fn(), + }; +}); + +jest.mock('@components/HeaderWithBackButton', () => jest.fn(() => null)); +jest.mock('@components/ScreenWrapper', () => jest.fn(({children}: {children: React.ReactNode}) => children)); +jest.mock('@components/SelectionList', () => jest.fn(() => null)); +jest.mock('@components/SelectionList/ListItem/RadioListItem', () => jest.fn(() => null)); +jest.mock('@hooks/useDebouncedState', () => + jest.fn((initialValue: string) => { + const [value, setValue] = mockUseState(initialValue); + return [value, value, setValue]; + }), +); +jest.mock('@hooks/useDynamicBackPath', () => jest.fn(() => 'settings/profile/address')); +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: (key: string) => { + if (key.startsWith('allCountries.')) { + const countryISO = key.split('.').at(-1) ?? ''; + return mockAllCountries[countryISO as keyof typeof mockAllCountries] ?? key; + } + + return key; + }, + })), +); +jest.mock('@libs/Navigation/Navigation', () => ({ + goBack: jest.fn(), +})); + +describe('DynamicCountrySelectionPage', () => { + const mockedSelectionList = jest.mocked(SelectionList); + + beforeEach(() => { + mockedSelectionList.mockClear(); + }); + + it('pins the saved country to the top on reopen and wires debounced focus sync', () => { + render(); + + const selectionListProps = mockedSelectionList.mock.lastCall?.[0]; + expect(selectionListProps?.data.at(0)).toEqual( + expect.objectContaining({ + keyForList: 'US', + value: 'US', + isSelected: true, + }), + ); + expect(selectionListProps?.initiallyFocusedItemKey).toBe('US'); + expect(selectionListProps?.searchValueForFocusSync).toBe(''); + }); + + it('keeps natural filtered ordering while search is active', () => { + render(); + + const initialProps = mockedSelectionList.mock.lastCall?.[0]; + + act(() => { + initialProps?.textInputOptions?.onChangeText?.('Uni'); + }); + + const searchedProps = mockedSelectionList.mock.lastCall?.[0]; + const expectedSearchResults = searchOptions( + 'Uni', + Object.keys(CONST.ALL_COUNTRIES).map((countryISO) => ({ + value: countryISO, + keyForList: countryISO, + text: CONST.ALL_COUNTRIES[countryISO as keyof typeof CONST.ALL_COUNTRIES], + isSelected: countryISO === 'US', + searchValue: StringUtils.sanitizeString(`${countryISO}${CONST.ALL_COUNTRIES[countryISO as keyof typeof CONST.ALL_COUNTRIES]}`), + })), + ); + + expect(searchedProps?.data.map((item) => item.keyForList)).toEqual(expectedSearchResults.map((item) => item.keyForList)); + expect(searchedProps?.searchValueForFocusSync).toBe('Uni'); + }); +}); diff --git a/tests/ui/StateSelectionPageTest.tsx b/tests/ui/StateSelectionPageTest.tsx new file mode 100644 index 0000000000000..b08dcaa591a6c --- /dev/null +++ b/tests/ui/StateSelectionPageTest.tsx @@ -0,0 +1,101 @@ +import type * as ReactNavigation from '@react-navigation/native'; +import {act, render} from '@testing-library/react-native'; +import {CONST as COMMON_CONST} from 'expensify-common'; +import React from 'react'; +import SelectionList from '@components/SelectionList'; +import searchOptions from '@libs/searchOptions'; +import StringUtils from '@libs/StringUtils'; +import StateSelectionPage from '@pages/settings/Profile/PersonalDetails/StateSelectionPage'; + +const mockUseState = React.useState; +const mockStates = COMMON_CONST.STATES; + +jest.mock('@react-navigation/native', () => { + const actualNavigation: typeof ReactNavigation = jest.requireActual('@react-navigation/native'); + + return { + ...actualNavigation, + useFocusEffect: jest.fn(), + useRoute: jest.fn(() => ({params: {state: 'NY', label: 'State', backTo: ''}})), + }; +}); + +jest.mock('@components/HeaderWithBackButton', () => jest.fn(() => null)); +jest.mock('@components/ScreenWrapper', () => jest.fn(({children}: {children: React.ReactNode}) => children)); +jest.mock('@components/SelectionList', () => jest.fn(() => null)); +jest.mock('@components/SelectionList/ListItem/RadioListItem', () => jest.fn(() => null)); +jest.mock('@hooks/useDebouncedState', () => + jest.fn((initialValue: string) => { + const [value, setValue] = mockUseState(initialValue); + return [value, value, setValue]; + }), +); +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: (key: string) => { + if (!key.startsWith('allStates.')) { + return key; + } + + const [, stateKey, property] = key.split('.'); + const state = mockStates[stateKey as keyof typeof mockStates]; + + if (property === 'stateName') { + return state.stateName; + } + + return state.stateISO; + }, + })), +); +jest.mock('@libs/Navigation/Navigation', () => ({ + goBack: jest.fn(), +})); + +describe('StateSelectionPage', () => { + const mockedSelectionList = jest.mocked(SelectionList); + + beforeEach(() => { + mockedSelectionList.mockClear(); + }); + + it('pins the saved state to the top on reopen and wires debounced focus sync', () => { + render(); + + const selectionListProps = mockedSelectionList.mock.lastCall?.[0]; + expect(selectionListProps?.data.at(0)).toEqual( + expect.objectContaining({ + keyForList: 'NY', + value: 'NY', + isSelected: true, + }), + ); + expect(selectionListProps?.initiallyFocusedItemKey).toBe('NY'); + expect(selectionListProps?.searchValueForFocusSync).toBe(''); + }); + + it('keeps natural filtered ordering while search is active', () => { + render(); + + const initialProps = mockedSelectionList.mock.lastCall?.[0]; + + act(() => { + initialProps?.textInputOptions?.onChangeText?.('New'); + }); + + const searchedProps = mockedSelectionList.mock.lastCall?.[0]; + const expectedSearchResults = searchOptions( + 'New', + Object.keys(mockStates).map((state) => ({ + value: mockStates[state as keyof typeof mockStates].stateISO, + keyForList: mockStates[state as keyof typeof mockStates].stateISO, + text: mockStates[state as keyof typeof mockStates].stateName, + isSelected: mockStates[state as keyof typeof mockStates].stateISO === 'NY', + searchValue: StringUtils.sanitizeString(`${mockStates[state as keyof typeof mockStates].stateISO}${mockStates[state as keyof typeof mockStates].stateName}`), + })), + ); + + expect(searchedProps?.data.map((item) => item.keyForList)).toEqual(expectedSearchResults.map((item) => item.keyForList)); + expect(searchedProps?.searchValueForFocusSync).toBe('New'); + }); +}); From bd70341174ff5181d90b045cb60adcb8b105e1f5 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Mon, 30 Mar 2026 14:19:27 +0430 Subject: [PATCH 5/8] refactor: limit new picker memoization and preserve existing behavior Keep the pre-existing memoization already present on main, but avoid the new manual useMemo patterns introduced in this picker-ordering work where they are not needed. --- .../CountryPicker/CountrySelectorModal.tsx | 6 +- .../PushRowWithModal/PushRowModal.tsx | 4 +- .../StatePicker/StateSelectorModal.tsx | 7 +- .../ValuePicker/ValueSelectionList.tsx | 11 ++-- .../DynamicCountrySelectionPage.tsx | 9 +-- .../PersonalDetails/StateSelectionPage.tsx | 9 +-- .../settings/Wallet/CountrySelectionList.tsx | 65 ++++++++----------- .../companyCards/addNew/SelectCountryStep.tsx | 34 +++++----- 8 files changed, 60 insertions(+), 85 deletions(-) diff --git a/src/components/CountryPicker/CountrySelectorModal.tsx b/src/components/CountryPicker/CountrySelectorModal.tsx index b416ec140a416..9d85f55d6ee3b 100644 --- a/src/components/CountryPicker/CountrySelectorModal.tsx +++ b/src/components/CountryPicker/CountrySelectorModal.tsx @@ -56,10 +56,8 @@ function CountrySelectorModal({isVisible, currentCountry, onCountrySelected, onC }), [translate, currentCountry], ); - - const orderedCountries = useMemo(() => moveInitialSelectionToTopByValue(countries, initialSelectedValues), [countries, initialSelectedValues]); - - const searchResults = useMemo(() => searchOptions(debouncedSearchValue, debouncedSearchValue ? countries : orderedCountries), [countries, orderedCountries, debouncedSearchValue]); + const orderedCountries = moveInitialSelectionToTopByValue(countries, initialSelectedValues); + const searchResults = searchOptions(debouncedSearchValue, debouncedSearchValue ? countries : orderedCountries); const headerMessage = debouncedSearchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : ''; const styles = useThemeStyles(); diff --git a/src/components/PushRowWithModal/PushRowModal.tsx b/src/components/PushRowWithModal/PushRowModal.tsx index be29f39e36c19..0c4acb2f7ddbd 100644 --- a/src/components/PushRowWithModal/PushRowModal.tsx +++ b/src/components/PushRowWithModal/PushRowModal.tsx @@ -62,7 +62,7 @@ function PushRowModal({isVisible, selectedOption, onOptionChange, onClose, optio [optionsList, selectedOption], ); - const orderedOptions = useMemo(() => moveInitialSelectionToTopByValue(options, initialSelectedValues), [initialSelectedValues, options]); + const orderedOptions = moveInitialSelectionToTopByValue(options, initialSelectedValues); const handleSelectRow = (option: ListItemType) => { onOptionChange(option.value); @@ -74,7 +74,7 @@ function PushRowModal({isVisible, selectedOption, onOptionChange, onClose, optio setSearchValue(''); }; - const searchResults = useMemo(() => searchOptions(debouncedSearchValue, debouncedSearchValue ? options : orderedOptions), [debouncedSearchValue, options, orderedOptions]); + const searchResults = searchOptions(debouncedSearchValue, debouncedSearchValue ? options : orderedOptions); const textInputOptions = useMemo( () => ({ diff --git a/src/components/StatePicker/StateSelectorModal.tsx b/src/components/StatePicker/StateSelectorModal.tsx index 619bf578e9579..b66e2046264a5 100644 --- a/src/components/StatePicker/StateSelectorModal.tsx +++ b/src/components/StatePicker/StateSelectorModal.tsx @@ -62,11 +62,8 @@ function StateSelectorModal({isVisible, currentState, onStateSelected, onClose, [translate, currentState], ); - const orderedCountryStates = useMemo(() => moveInitialSelectionToTopByValue(countryStates, initialSelectedValues), [countryStates, initialSelectedValues]); - const searchResults = useMemo( - () => searchOptions(debouncedSearchValue, debouncedSearchValue ? countryStates : orderedCountryStates), - [countryStates, orderedCountryStates, debouncedSearchValue], - ); + const orderedCountryStates = moveInitialSelectionToTopByValue(countryStates, initialSelectedValues); + const searchResults = searchOptions(debouncedSearchValue, debouncedSearchValue ? countryStates : orderedCountryStates); const textInputOptions = useMemo( () => ({ diff --git a/src/components/ValuePicker/ValueSelectionList.tsx b/src/components/ValuePicker/ValueSelectionList.tsx index f558753501e7d..7ed5ec99f538b 100644 --- a/src/components/ValuePicker/ValueSelectionList.tsx +++ b/src/components/ValuePicker/ValueSelectionList.tsx @@ -15,13 +15,16 @@ function ValueSelectionList({ alternateNumberOfSupportedLines, isVisible, }: ValueSelectionListProps) { - const initialSelectedValue = useInitialSelection(selectedItem?.value || undefined, isVisible === undefined ? {resetOnFocus: true} : {resetDeps: [isVisible]}); + const initialSelectedValue = useInitialSelection(selectedItem?.value ? selectedItem.value : undefined, isVisible === undefined ? {resetOnFocus: true} : {resetDeps: [isVisible]}); const initialSelectedValues = initialSelectedValue ? [initialSelectedValue] : []; const initiallyFocusedItemKey = initialSelectedValue; - const mappedOptions = useMemo(() => items.map((item) => ({value: item.value ?? '', alternateText: item.description, text: item.label ?? '', keyForList: item.value ?? ''})), [items]); - const orderedOptions = useMemo(() => moveInitialSelectionToTopByValue(mappedOptions, initialSelectedValues), [initialSelectedValues, mappedOptions]); - const options = useMemo(() => orderedOptions.map((item) => ({...item, isSelected: item.value === selectedItem?.value})), [orderedOptions, selectedItem?.value]); + const options = useMemo(() => { + const mappedOptions = items.map((item) => ({value: item.value ?? '', alternateText: item.description, text: item.label ?? '', keyForList: item.value ?? ''})); + const orderedOptions = moveInitialSelectionToTopByValue(mappedOptions, initialSelectedValues); + + return orderedOptions.map((item) => ({...item, isSelected: item.value === selectedItem?.value})); + }, [initialSelectedValues, items, selectedItem?.value]); return ( (initialSelectedValue ? [initialSelectedValue] : []), [initialSelectedValue]); + const initialSelectedValues = initialSelectedValue ? [initialSelectedValue] : []; const countries = useMemo( () => @@ -45,11 +45,8 @@ function DynamicCountrySelectionPage({route}: DynamicCountrySelectionPageProps) [translate, currentCountry], ); - const orderedCountries = useMemo(() => moveInitialSelectionToTopByValue(countries, initialSelectedValues), [countries, initialSelectedValues]); - const searchResults = useMemo( - () => searchOptions(debouncedSearchValue, debouncedSearchValue ? countries : orderedCountries), - [countries, debouncedSearchValue, orderedCountries], - ); + const orderedCountries = moveInitialSelectionToTopByValue(countries, initialSelectedValues); + const searchResults = searchOptions(debouncedSearchValue, debouncedSearchValue ? countries : orderedCountries); const selectCountry = useCallback( (option: Option) => { diff --git a/src/pages/settings/Profile/PersonalDetails/StateSelectionPage.tsx b/src/pages/settings/Profile/PersonalDetails/StateSelectionPage.tsx index 5320c48511b1c..553afcb8a6fb5 100644 --- a/src/pages/settings/Profile/PersonalDetails/StateSelectionPage.tsx +++ b/src/pages/settings/Profile/PersonalDetails/StateSelectionPage.tsx @@ -34,7 +34,7 @@ function StateSelectionPage() { const currentState = params?.state; const label = params?.label; const initialSelectedValue = useInitialSelection(currentState ?? undefined, {resetOnFocus: true}); - const initialSelectedValues = useMemo(() => (initialSelectedValue ? [initialSelectedValue] : []), [initialSelectedValue]); + const initialSelectedValues = initialSelectedValue ? [initialSelectedValue] : []; const countryStates = useMemo( () => @@ -53,11 +53,8 @@ function StateSelectionPage() { [translate, currentState], ); - const orderedCountryStates = useMemo(() => moveInitialSelectionToTopByValue(countryStates, initialSelectedValues), [countryStates, initialSelectedValues]); - const searchResults = useMemo( - () => searchOptions(debouncedSearchValue, debouncedSearchValue ? countryStates : orderedCountryStates), - [countryStates, debouncedSearchValue, orderedCountryStates], - ); + const orderedCountryStates = moveInitialSelectionToTopByValue(countryStates, initialSelectedValues); + const searchResults = searchOptions(debouncedSearchValue, debouncedSearchValue ? countryStates : orderedCountryStates); const headerMessage = debouncedSearchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : ''; const selectCountryState = useCallback( diff --git a/src/pages/settings/Wallet/CountrySelectionList.tsx b/src/pages/settings/Wallet/CountrySelectionList.tsx index 9e1dcaddc8ac0..6a5a2145db300 100644 --- a/src/pages/settings/Wallet/CountrySelectionList.tsx +++ b/src/pages/settings/Wallet/CountrySelectionList.tsx @@ -1,4 +1,4 @@ -import React, {useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; import SelectionList from '@components/SelectionList'; @@ -41,52 +41,39 @@ function CountrySelectionList({isEditing, selectedCountry, countries, onCountryS const styles = useThemeStyles(); const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); const initialSelectedValue = useInitialSelection(selectedCountry ?? undefined, {resetOnFocus: true}); - const initialSelectedValues = useMemo(() => (initialSelectedValue ? [initialSelectedValue] : []), [initialSelectedValue]); + const initialSelectedValues = initialSelectedValue ? [initialSelectedValue] : []; const onSelectionChange = (country: Option) => { onCountrySelected(country.value); }; - const countriesList = useMemo( - () => - countries.map((countryISO) => { - const countryName = translate(`allCountries.${countryISO}` as TranslationPaths); - return { - value: countryISO, - keyForList: countryISO, - text: countryName, - isSelected: selectedCountry === countryISO, - searchValue: StringUtils.sanitizeString(`${countryISO}${countryName}`), - }; - }), - [countries, selectedCountry, translate], - ); + const countriesList = countries.map((countryISO) => { + const countryName = translate(`allCountries.${countryISO}` as TranslationPaths); + return { + value: countryISO, + keyForList: countryISO, + text: countryName, + isSelected: selectedCountry === countryISO, + searchValue: StringUtils.sanitizeString(`${countryISO}${countryName}`), + }; + }); - const orderedCountries = useMemo(() => moveInitialSelectionToTopByValue(countriesList, initialSelectedValues), [countriesList, initialSelectedValues]); - const searchResults = useMemo( - () => searchOptions(debouncedSearchValue, debouncedSearchValue ? countriesList : orderedCountries), - [countriesList, debouncedSearchValue, orderedCountries], - ); + const orderedCountries = moveInitialSelectionToTopByValue(countriesList, initialSelectedValues); + const searchResults = searchOptions(debouncedSearchValue, debouncedSearchValue ? countriesList : orderedCountries); - const textInputOptions = useMemo( - () => ({ - label: translate('common.search'), - value: searchValue, - onChangeText: setSearchValue, - headerMessage: debouncedSearchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : '', - }), - [debouncedSearchValue, searchResults.length, searchValue, setSearchValue, translate], - ); + const textInputOptions = { + label: translate('common.search'), + value: searchValue, + onChangeText: setSearchValue, + headerMessage: debouncedSearchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : '', + }; - const confirmButtonOptions = useMemo( - () => ({ - showButton: true, - text: isEditing ? translate('common.confirm') : translate('common.next'), - isDisabled: isOffline, - onConfirm, - }), - [isEditing, isOffline, onConfirm, translate], - ); + const confirmButtonOptions = { + showButton: true, + text: isEditing ? translate('common.confirm') : translate('common.next'), + isDisabled: isOffline, + onConfirm, + }; return ( diff --git a/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx b/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx index e0c234e93dc43..8e2e10cd39c33 100644 --- a/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx +++ b/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx @@ -1,5 +1,5 @@ import {useRoute} from '@react-navigation/native'; -import React, {useMemo, useState} from 'react'; +import React, {useState} from 'react'; import {View} from 'react-native'; import FormHelpMessage from '@components/FormHelpMessage'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -77,24 +77,20 @@ function SelectCountryStep({policyID}: CountryStepProps) { Navigation.goBack(); }; - const countries = useMemo( - () => - Object.keys(CONST.ALL_COUNTRIES) - .filter((countryISO) => !CONST.PLAID_EXCLUDED_COUNTRIES.includes(countryISO)) - .map((countryISO) => { - const countryName = translate(`allCountries.${countryISO}` as TranslationPaths); - return { - value: countryISO, - keyForList: countryISO, - text: countryName, - searchValue: StringUtils.sanitizeString(`${countryISO}${countryName}`), - }; - }), - [translate], - ); - const orderedCountries = useMemo(() => moveInitialSelectionToTopByValue(countries, initialSelectedValues), [countries, initialSelectedValues]); - const filteredCountries = useMemo(() => searchOptions(debouncedSearchValue, debouncedSearchValue ? countries : orderedCountries), [debouncedSearchValue, countries, orderedCountries]); - const searchResults = useMemo(() => filteredCountries.map((country) => ({...country, isSelected: currentCountry === country.value})), [filteredCountries, currentCountry]); + const countries = Object.keys(CONST.ALL_COUNTRIES) + .filter((countryISO) => !CONST.PLAID_EXCLUDED_COUNTRIES.includes(countryISO)) + .map((countryISO) => { + const countryName = translate(`allCountries.${countryISO}` as TranslationPaths); + return { + value: countryISO, + keyForList: countryISO, + text: countryName, + searchValue: StringUtils.sanitizeString(`${countryISO}${countryName}`), + }; + }); + const orderedCountries = moveInitialSelectionToTopByValue(countries, initialSelectedValues); + const filteredCountries = searchOptions(debouncedSearchValue, debouncedSearchValue ? countries : orderedCountries); + const searchResults = filteredCountries.map((country) => ({...country, isSelected: currentCountry === country.value})); const headerMessage = debouncedSearchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : ''; const textInputOptions = { From c9189b57072fab38761c237bd2637aa19a8612b3 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Mon, 30 Mar 2026 14:42:36 +0430 Subject: [PATCH 6/8] fix: align SelectCountryStep country options with selection list types Include `isSelected` on the base country options used by `SelectCountryStep` so the reordered and searched lists satisfy the shared `searchOptions` option shape, and update the test to select a real rendered row instead of constructing a narrower list item. --- .../DynamicCountrySelectionPage.tsx | 2 +- .../PersonalDetails/StateSelectionPage.tsx | 2 +- .../settings/Wallet/CountrySelectionList.tsx | 2 +- .../companyCards/addNew/SelectCountryStep.tsx | 1 + tests/ui/CountrySelectionListTest.tsx | 2 +- tests/ui/DynamicCountrySelectionPageTest.tsx | 16 +++++++++++++--- tests/ui/SelectCountryStepTest.tsx | 10 +++++++++- .../SelectionList/useSearchFocusSync.test.ts | 7 +------ 8 files changed, 28 insertions(+), 14 deletions(-) diff --git a/src/pages/settings/Profile/PersonalDetails/DynamicCountrySelectionPage.tsx b/src/pages/settings/Profile/PersonalDetails/DynamicCountrySelectionPage.tsx index cf803899ef3f8..210fe5586283c 100644 --- a/src/pages/settings/Profile/PersonalDetails/DynamicCountrySelectionPage.tsx +++ b/src/pages/settings/Profile/PersonalDetails/DynamicCountrySelectionPage.tsx @@ -10,9 +10,9 @@ import useLocalize from '@hooks/useLocalize'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import {moveInitialSelectionToTopByValue} from '@libs/SelectionListOrderUtils'; import type {Option} from '@libs/searchOptions'; import searchOptions from '@libs/searchOptions'; +import {moveInitialSelectionToTopByValue} from '@libs/SelectionListOrderUtils'; import StringUtils from '@libs/StringUtils'; import {appendParam} from '@libs/Url'; import CONST from '@src/CONST'; diff --git a/src/pages/settings/Profile/PersonalDetails/StateSelectionPage.tsx b/src/pages/settings/Profile/PersonalDetails/StateSelectionPage.tsx index 553afcb8a6fb5..684b0ae223f60 100644 --- a/src/pages/settings/Profile/PersonalDetails/StateSelectionPage.tsx +++ b/src/pages/settings/Profile/PersonalDetails/StateSelectionPage.tsx @@ -10,9 +10,9 @@ import useDebouncedState from '@hooks/useDebouncedState'; import useInitialSelection from '@hooks/useInitialSelection'; import useLocalize from '@hooks/useLocalize'; import Navigation from '@libs/Navigation/Navigation'; -import {moveInitialSelectionToTopByValue} from '@libs/SelectionListOrderUtils'; import searchOptions from '@libs/searchOptions'; import type {Option} from '@libs/searchOptions'; +import {moveInitialSelectionToTopByValue} from '@libs/SelectionListOrderUtils'; import StringUtils from '@libs/StringUtils'; import {appendParam} from '@libs/Url'; import type {Route} from '@src/ROUTES'; diff --git a/src/pages/settings/Wallet/CountrySelectionList.tsx b/src/pages/settings/Wallet/CountrySelectionList.tsx index 6a5a2145db300..a80f94f17f710 100644 --- a/src/pages/settings/Wallet/CountrySelectionList.tsx +++ b/src/pages/settings/Wallet/CountrySelectionList.tsx @@ -8,9 +8,9 @@ import useInitialSelection from '@hooks/useInitialSelection'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; -import {moveInitialSelectionToTopByValue} from '@libs/SelectionListOrderUtils'; import searchOptions from '@libs/searchOptions'; import type {Option} from '@libs/searchOptions'; +import {moveInitialSelectionToTopByValue} from '@libs/SelectionListOrderUtils'; import StringUtils from '@libs/StringUtils'; import Text from '@src/components/Text'; import type {TranslationPaths} from '@src/languages/types'; diff --git a/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx b/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx index 8e2e10cd39c33..43e1532ebb58c 100644 --- a/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx +++ b/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx @@ -85,6 +85,7 @@ function SelectCountryStep({policyID}: CountryStepProps) { value: countryISO, keyForList: countryISO, text: countryName, + isSelected: currentCountry === countryISO, searchValue: StringUtils.sanitizeString(`${countryISO}${countryName}`), }; }); diff --git a/tests/ui/CountrySelectionListTest.tsx b/tests/ui/CountrySelectionListTest.tsx index e05fd7bc5ed65..abe5e7561bcca 100644 --- a/tests/ui/CountrySelectionListTest.tsx +++ b/tests/ui/CountrySelectionListTest.tsx @@ -2,9 +2,9 @@ import type * as ReactNavigation from '@react-navigation/native'; import {act, render} from '@testing-library/react-native'; import React from 'react'; import SelectionList from '@components/SelectionList'; -import CountrySelectionList from '@pages/settings/Wallet/CountrySelectionList'; import searchOptions from '@libs/searchOptions'; import StringUtils from '@libs/StringUtils'; +import CountrySelectionList from '@pages/settings/Wallet/CountrySelectionList'; import CONST from '@src/CONST'; const mockUseState = React.useState; diff --git a/tests/ui/DynamicCountrySelectionPageTest.tsx b/tests/ui/DynamicCountrySelectionPageTest.tsx index 5340a158b4fd0..f1fcad19e2e62 100644 --- a/tests/ui/DynamicCountrySelectionPageTest.tsx +++ b/tests/ui/DynamicCountrySelectionPageTest.tsx @@ -1,10 +1,10 @@ import type * as ReactNavigation from '@react-navigation/native'; import {act, render} from '@testing-library/react-native'; import React from 'react'; -import DynamicCountrySelectionPage from '@pages/settings/Profile/PersonalDetails/DynamicCountrySelectionPage'; import SelectionList from '@components/SelectionList'; import searchOptions from '@libs/searchOptions'; import StringUtils from '@libs/StringUtils'; +import DynamicCountrySelectionPage from '@pages/settings/Profile/PersonalDetails/DynamicCountrySelectionPage'; import CONST from '@src/CONST'; const mockUseState = React.useState; @@ -54,7 +54,12 @@ describe('DynamicCountrySelectionPage', () => { }); it('pins the saved country to the top on reopen and wires debounced focus sync', () => { - render(); + render( + , + ); const selectionListProps = mockedSelectionList.mock.lastCall?.[0]; expect(selectionListProps?.data.at(0)).toEqual( @@ -69,7 +74,12 @@ describe('DynamicCountrySelectionPage', () => { }); it('keeps natural filtered ordering while search is active', () => { - render(); + render( + , + ); const initialProps = mockedSelectionList.mock.lastCall?.[0]; diff --git a/tests/ui/SelectCountryStepTest.tsx b/tests/ui/SelectCountryStepTest.tsx index 4fe741f567b00..210e1b05fd677 100644 --- a/tests/ui/SelectCountryStepTest.tsx +++ b/tests/ui/SelectCountryStepTest.tsx @@ -122,9 +122,16 @@ describe('SelectCountryStep', () => { render(); const initialProps = mockedSelectionList.mock.lastCall?.[0]; + const selectedCountry = initialProps?.data.find((item) => item.keyForList === 'GB'); + + expect(selectedCountry).toBeDefined(); act(() => { - initialProps?.onSelectRow?.({value: 'GB', keyForList: 'GB'}); + if (!selectedCountry) { + return; + } + + initialProps?.onSelectRow?.(selectedCountry); }); const updatedProps = mockedSelectionList.mock.lastCall?.[0]; @@ -163,6 +170,7 @@ describe('SelectCountryStep', () => { value: countryISO, keyForList: countryISO, text: CONST.ALL_COUNTRIES[countryISO as keyof typeof CONST.ALL_COUNTRIES], + isSelected: false, searchValue: StringUtils.sanitizeString(`${countryISO}${CONST.ALL_COUNTRIES[countryISO as keyof typeof CONST.ALL_COUNTRIES]}`), })), ); diff --git a/tests/unit/components/SelectionList/useSearchFocusSync.test.ts b/tests/unit/components/SelectionList/useSearchFocusSync.test.ts index 7c6843df359fe..d0ac6349ab85e 100644 --- a/tests/unit/components/SelectionList/useSearchFocusSync.test.ts +++ b/tests/unit/components/SelectionList/useSearchFocusSync.test.ts @@ -11,12 +11,7 @@ describe('useSearchFocusSync', () => { const scrollToIndex = jest.fn(); const setFocusedIndex = jest.fn(); const filteredData: MockItem[] = [{keyForList: 'match'}]; - const fullData: MockItem[] = [ - {keyForList: 'a'}, - {keyForList: 'b'}, - {keyForList: 'selected', isSelected: true}, - {keyForList: 'c'}, - ]; + const fullData: MockItem[] = [{keyForList: 'a'}, {keyForList: 'b'}, {keyForList: 'selected', isSelected: true}, {keyForList: 'c'}]; const {rerender} = renderHook( ({searchValue, data}: {searchValue: string; data: MockItem[]}) => From 0138ea8f75d0e1b97f69d6de86990ada54784bb0 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Sat, 4 Apr 2026 14:47:02 +0430 Subject: [PATCH 7/8] refactor: simplify initial selection hook and memo deps Avoid rebuilding `ValueSelectionList` options on every render by depending on the scalar initial selection value instead of a freshly created array, and trim the unused `shouldSyncSelection` / `isEqual` options from `useInitialSelection`. --- .../ValuePicker/ValueSelectionList.tsx | 5 ++-- src/hooks/useInitialSelection.ts | 25 +++---------------- 2 files changed, 6 insertions(+), 24 deletions(-) diff --git a/src/components/ValuePicker/ValueSelectionList.tsx b/src/components/ValuePicker/ValueSelectionList.tsx index 7ed5ec99f538b..d212cd39d8259 100644 --- a/src/components/ValuePicker/ValueSelectionList.tsx +++ b/src/components/ValuePicker/ValueSelectionList.tsx @@ -16,15 +16,14 @@ function ValueSelectionList({ isVisible, }: ValueSelectionListProps) { const initialSelectedValue = useInitialSelection(selectedItem?.value ? selectedItem.value : undefined, isVisible === undefined ? {resetOnFocus: true} : {resetDeps: [isVisible]}); - const initialSelectedValues = initialSelectedValue ? [initialSelectedValue] : []; const initiallyFocusedItemKey = initialSelectedValue; const options = useMemo(() => { const mappedOptions = items.map((item) => ({value: item.value ?? '', alternateText: item.description, text: item.label ?? '', keyForList: item.value ?? ''})); - const orderedOptions = moveInitialSelectionToTopByValue(mappedOptions, initialSelectedValues); + const orderedOptions = moveInitialSelectionToTopByValue(mappedOptions, initialSelectedValue ? [initialSelectedValue] : []); return orderedOptions.map((item) => ({...item, isSelected: item.value === selectedItem?.value})); - }, [initialSelectedValues, items, selectedItem?.value]); + }, [initialSelectedValue, items, selectedItem?.value]); return ( = { /** Whether to refresh the snapshot whenever the screen gains focus */ resetOnFocus?: boolean; - - /** Whether the snapshot should continue following incoming selection changes */ - shouldSyncSelection?: boolean; - - /** Equality check used to avoid replacing the snapshot with equivalent values */ - isEqual?: (previousSelection: T, nextSelection: T) => boolean; }; /** @@ -21,16 +15,13 @@ type UseInitialSelectionOptions = { * Callers can refresh the snapshot by changing `resetDeps` or via screen focus. */ function useInitialSelection(selection: T, options: UseInitialSelectionOptions = {}) { - const {resetDeps = [], resetOnFocus = false, shouldSyncSelection = false, isEqual = Object.is} = options; + const {resetDeps = [], resetOnFocus = false} = options; const [initialSelection, setInitialSelection] = useState(selection); const latestSelectionRef = useRef(selection); - const updateInitialSelection = useCallback( - (nextSelection: T) => { - setInitialSelection((previousSelection) => (isEqual(previousSelection, nextSelection) ? previousSelection : nextSelection)); - }, - [isEqual], - ); + const updateInitialSelection = useCallback((nextSelection: T) => { + setInitialSelection((previousSelection) => (Object.is(previousSelection, nextSelection) ? previousSelection : nextSelection)); + }, []); useEffect(() => { latestSelectionRef.current = selection; @@ -53,14 +44,6 @@ function useInitialSelection(selection: T, options: UseInitialSelectionOption }, [resetOnFocus, updateInitialSelection]), ); - useEffect(() => { - if (!shouldSyncSelection) { - return; - } - - updateInitialSelection(selection); - }, [selection, shouldSyncSelection, updateInitialSelection]); - return initialSelection; } From 00a7f226e4bdb82de56fa293dacd977b6867f5b9 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Sat, 4 Apr 2026 14:58:23 +0430 Subject: [PATCH 8/8] fixed lint error --- src/hooks/useInitialSelection.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hooks/useInitialSelection.ts b/src/hooks/useInitialSelection.ts index fa65044dc56e8..c61868e650e66 100644 --- a/src/hooks/useInitialSelection.ts +++ b/src/hooks/useInitialSelection.ts @@ -2,7 +2,7 @@ import {useFocusEffect} from '@react-navigation/native'; import type {DependencyList} from 'react'; import {useCallback, useEffect, useRef, useState} from 'react'; -type UseInitialSelectionOptions = { +type UseInitialSelectionOptions = { /** Dependencies that should trigger refreshing the snapshot (e.g., when a modal opens) */ resetDeps?: DependencyList; @@ -14,7 +14,7 @@ type UseInitialSelectionOptions = { * Keeps an immutable snapshot of the initial selection for the current open/focus cycle. * Callers can refresh the snapshot by changing `resetDeps` or via screen focus. */ -function useInitialSelection(selection: T, options: UseInitialSelectionOptions = {}) { +function useInitialSelection(selection: T, options: UseInitialSelectionOptions = {}) { const {resetDeps = [], resetOnFocus = false} = options; const [initialSelection, setInitialSelection] = useState(selection); const latestSelectionRef = useRef(selection);