Compare commits

...

2 Commits

Author SHA1 Message Date
Joe Li
83fe647a2b Merge branch 'master' into fix-asyncselect-search-option-merging 2026-05-11 16:22:45 -07:00
sadpandajoe
c81ba84a46 fix(select): replace cached options with search results in AsyncSelect
When searching with >100 cached records, server search results were merged
with page-0 options instead of replacing them, burying matches at the end.
Also fixes filterOption=false falling through to return false (hiding all
options) instead of returning true (show all, server-side filtering).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 02:07:26 +00:00
3 changed files with 113 additions and 3 deletions

View File

@@ -897,6 +897,86 @@ test('fires onChange when pasting a selection', async () => {
await waitFor(() => expect(onChange).toHaveBeenCalledTimes(1));
});
test('replaces cached options with search results instead of merging', async () => {
const page0Data = Array.from({ length: 10 }, (_, i) => ({
label: `Option ${i}`,
value: i,
}));
const searchData = [{ label: 'Search Match', value: 100 }];
const loadOptions = jest.fn(async (search: string) => {
if (search === '') {
return { data: page0Data, totalCount: 100 };
}
return { data: searchData, totalCount: 1 };
});
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
await open();
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(1));
let options = await findAllSelectOptions();
expect(options).toHaveLength(10);
await type('search');
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(2));
options = await findAllSelectOptions();
expect(options).toHaveLength(1);
expect(options[0]).toHaveTextContent('Search Match');
});
test('shows all options when filterOption is false', async () => {
const loadOptions = jest.fn(async () => ({
data: OPTIONS.slice(0, 10),
totalCount: 20,
}));
render(
<AsyncSelect
{...defaultProps}
options={loadOptions}
filterOption={false}
/>,
);
await open();
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(1));
await type('zzz_no_match');
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(2));
const options = await findAllSelectOptions();
expect(options.length).toBeGreaterThan(0);
});
test('restores base options when search is cleared', async () => {
const page0Data = Array.from({ length: 10 }, (_, i) => ({
label: `Option ${i}`,
value: i,
}));
const searchData = [{ label: 'Search Match', value: 100 }];
const loadOptions = jest.fn(async (search: string) => {
if (search === '') {
return { data: page0Data, totalCount: 100 };
}
return { data: searchData, totalCount: 1 };
});
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
await open();
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(1));
await type('search');
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(2));
let options = await findAllSelectOptions();
expect(options).toHaveLength(1);
await type('{backspace}{backspace}{backspace}{backspace}{backspace}{backspace}');
await waitFor(async () => {
options = await findAllSelectOptions();
expect(options).toHaveLength(10);
});
});
test('does not duplicate options when using numeric values', async () => {
render(
<AsyncSelect

View File

@@ -160,6 +160,8 @@ const AsyncSelect = forwardRef(
const [allValuesLoaded, setAllValuesLoaded] = useState(false);
const selectValueRef = useRef(selectValue);
const fetchedQueries = useRef(new Map<string, number>());
const initialOptionsRef = useRef<SelectOptionsType>(EMPTY_OPTIONS);
const wasSearchingRef = useRef(false);
const mappedMode = isSingleMode ? undefined : 'multiple';
const allowFetch = !fetchOnlyOnSearch || inputValue;
const [maxTagCount, setMaxTagCount] = useState(
@@ -335,13 +337,22 @@ const AsyncSelect = forwardRef(
const fetchOptions = options as SelectOptionsPagePromise;
fetchOptions(search, page, pageSize)
.then(({ data, totalCount }: SelectOptionsTypePage) => {
const mergedData = mergeData(data);
let resultData: SelectOptionsType;
if (search && page === 0) {
resultData = data.slice().sort(sortComparatorForNoSearch);
setSelectOptions(resultData);
} else {
resultData = mergeData(data);
if (!search) {
initialOptionsRef.current = resultData;
}
}
fetchedQueries.current.set(key, totalCount);
setTotalCount(totalCount);
if (
!fetchOnlyOnSearch &&
search === '' &&
mergedData.length >= totalCount
resultData.length >= totalCount
) {
setAllValuesLoaded(true);
}
@@ -358,6 +369,7 @@ const AsyncSelect = forwardRef(
internalOnError,
options,
pageSize,
sortComparatorForNoSearch,
],
);
@@ -518,12 +530,26 @@ const AsyncSelect = forwardRef(
if (loadingEnabled && allowFetch) {
// trigger fetch every time inputValue changes
if (inputValue) {
wasSearchingRef.current = true;
debouncedFetchPage(inputValue, 0);
} else {
if (wasSearchingRef.current && initialOptionsRef.current.length > 0) {
setSelectOptions(
[...initialOptionsRef.current].sort(sortComparatorForNoSearch),
);
}
wasSearchingRef.current = false;
fetchPage('', 0);
}
}
}, [loadingEnabled, fetchPage, allowFetch, inputValue, debouncedFetchPage]);
}, [
loadingEnabled,
fetchPage,
allowFetch,
inputValue,
debouncedFetchPage,
sortComparatorForNoSearch,
]);
useEffect(() => {
if (loading !== undefined && loading !== isLoading) {

View File

@@ -211,6 +211,10 @@ export const handleFilterOptionHelper = (
return filterOption(search, option);
}
if (filterOption === false) {
return true;
}
if (filterOption) {
const searchValue = search.trim().toLowerCase();
if (optionFilterProps?.length) {