fix(AsyncSelect): preserve isNewOption entries and reset refs on options-prop change

Preserve optimistic isNewOption entries inserted by handleOnSearch when
the search fetch resolves, so allowNewOptions users can still pick the
value they typed when the server returns no match (regression seen via
SaveModal "Add to dashboard"). Also reset initialOptionsRef and
wasSearchingRef when the options loader changes, so loader swaps don't
briefly restore stale options.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joe Li
2026-05-14 14:19:27 -07:00
parent b986e41581
commit 089f23ac2d
2 changed files with 45 additions and 4 deletions

View File

@@ -945,7 +945,33 @@ test('shows all options when filterOption is false', async () => {
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(2));
const options = await findAllSelectOptions();
expect(options.length).toBeGreaterThan(0);
expect(options).toHaveLength(10);
});
test('preserves new option entry across search fetch when allowNewOptions is on', async () => {
const page0Data = Array.from({ length: 10 }, (_, i) => ({
label: `Option ${i}`,
value: i,
}));
const loadOptions = jest.fn(async (search: string) => {
if (search === '') {
return { data: page0Data, totalCount: 100 };
}
return { data: [], totalCount: 0 };
});
render(
<AsyncSelect {...defaultProps} options={loadOptions} allowNewOptions />,
);
await open();
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(1));
await type('newval');
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(2));
const options = await findAllSelectOptions();
expect(options).toHaveLength(1);
expect(options[0]).toHaveTextContent('newval');
});
test('restores base options when search is cleared', async () => {

View File

@@ -337,10 +337,23 @@ const AsyncSelect = forwardRef(
const fetchOptions = options as SelectOptionsPagePromise;
fetchOptions(search, page, pageSize)
.then(({ data, totalCount }: SelectOptionsTypePage) => {
let resultData: SelectOptionsType;
let resultData: SelectOptionsType = data;
if (search && page === 0) {
resultData = data.slice().sort(sortComparatorForNoSearch);
setSelectOptions(resultData);
// Preserve optimistic isNewOption entries inserted by
// handleOnSearch so allowNewOptions users can still click
// the value they typed when the server returns no match.
setSelectOptions((prevOptions: SelectOptionsType) => {
const dataValues = new Set(
data.map((opt: SelectOptionsType[number]) => opt.value),
);
const preservedNew = prevOptions.filter(
(opt: SelectOptionsType[number]) =>
opt.isNewOption && !dataValues.has(opt.value),
);
return preservedNew
.concat(data)
.sort(sortComparatorForNoSearch);
});
} else {
resultData = mergeData(data);
if (!search) {
@@ -512,6 +525,8 @@ const AsyncSelect = forwardRef(
fetchedQueries.current.clear();
setAllValuesLoaded(false);
setSelectOptions(EMPTY_OPTIONS);
initialOptionsRef.current = EMPTY_OPTIONS;
wasSearchingRef.current = false;
}, [options]);
useEffect(() => {