From 089f23ac2d5f21d1fb28e1e08e0a91c62a0a68e2 Mon Sep 17 00:00:00 2001 From: Joe Li Date: Thu, 14 May 2026 14:19:27 -0700 Subject: [PATCH] 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) --- .../components/Select/AsyncSelect.test.tsx | 28 ++++++++++++++++++- .../src/components/Select/AsyncSelect.tsx | 21 ++++++++++++-- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/superset-frontend/packages/superset-ui-core/src/components/Select/AsyncSelect.test.tsx b/superset-frontend/packages/superset-ui-core/src/components/Select/AsyncSelect.test.tsx index 91d4ca1c689..4800c0595b1 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Select/AsyncSelect.test.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/Select/AsyncSelect.test.tsx @@ -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( + , + ); + 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 () => { diff --git a/superset-frontend/packages/superset-ui-core/src/components/Select/AsyncSelect.tsx b/superset-frontend/packages/superset-ui-core/src/components/Select/AsyncSelect.tsx index 11d9be7dbec..f120b52fc9a 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Select/AsyncSelect.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/Select/AsyncSelect.tsx @@ -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(() => {