From 5bbb8321c38581a42ac7792992148c77ac5ab8da Mon Sep 17 00:00:00 2001 From: Andras Date: Thu, 9 Apr 2026 09:11:48 +0300 Subject: [PATCH 1/5] (bug-fix): autocomplete should only refine on main search if submitted --- .../react-instantsearch/src/components/AutocompleteSearch.tsx | 3 --- packages/react-instantsearch/src/widgets/Autocomplete.tsx | 1 - 2 files changed, 4 deletions(-) diff --git a/packages/react-instantsearch/src/components/AutocompleteSearch.tsx b/packages/react-instantsearch/src/components/AutocompleteSearch.tsx index c6ac2fde46..14d9771b04 100644 --- a/packages/react-instantsearch/src/components/AutocompleteSearch.tsx +++ b/packages/react-instantsearch/src/components/AutocompleteSearch.tsx @@ -13,7 +13,6 @@ export type AutocompleteSearchProps = { clearQuery: () => void; onQueryChange?: (query: string) => void; query: string; - refine: (query: string) => void; isSearchStalled: boolean; onAiModeClick?: () => void; }; @@ -23,7 +22,6 @@ export function AutocompleteSearch({ clearQuery, onQueryChange, query, - refine, isSearchStalled, onAiModeClick, }: AutocompleteSearchProps) { @@ -33,7 +31,6 @@ export function AutocompleteSearch({ ...(inputProps as NonNullable), onChange: (event: React.ChangeEvent) => { const value = event.currentTarget.value; - refine(value); onQueryChange?.(value); }, }} diff --git a/packages/react-instantsearch/src/widgets/Autocomplete.tsx b/packages/react-instantsearch/src/widgets/Autocomplete.tsx index 2784363ae8..5c7fa19117 100644 --- a/packages/react-instantsearch/src/widgets/Autocomplete.tsx +++ b/packages/react-instantsearch/src/widgets/Autocomplete.tsx @@ -879,7 +879,6 @@ function InnerAutocomplete({ refineAutocomplete(query); }} query={currentRefinement || indexUiState.query || ''} - refine={refineSearchBox} isSearchStalled={isSearchStalled} onAiModeClick={ aiMode From 0894e3cec0e27a4ce47e278a8aefd1eb00d9828d Mon Sep 17 00:00:00 2001 From: Andras Date: Fri, 10 Apr 2026 09:38:30 +0300 Subject: [PATCH 2/5] (bug-fix): autocomplete should only refine on main search if submitted --- .../autocomplete/connectAutocomplete.ts | 30 ++- .../src/widgets/autocomplete/autocomplete.tsx | 17 +- .../src/widgets/Autocomplete.tsx | 16 +- tests/common/widgets/autocomplete/options.tsx | 197 ++++++++++++++++++ 4 files changed, 242 insertions(+), 18 deletions(-) diff --git a/packages/instantsearch.js/src/connectors/autocomplete/connectAutocomplete.ts b/packages/instantsearch.js/src/connectors/autocomplete/connectAutocomplete.ts index 60ce4d6954..89a698c5bd 100644 --- a/packages/instantsearch.js/src/connectors/autocomplete/connectAutocomplete.ts +++ b/packages/instantsearch.js/src/connectors/autocomplete/connectAutocomplete.ts @@ -37,13 +37,28 @@ export type AutocompleteConnectorParams = { transformItems?: ( indices: TransformItemsIndicesConfig[] ) => TransformItemsIndicesConfig[]; + /** + * Enable usage of future Autocomplete behavior. + */ + future?: { + /** + * When set to true, `currentRefinement` is `undefined` when no query has + * been set (instead of an empty string). This lets consumers distinguish + * between "initial/submitted state" and "user explicitly cleared the input". + * + * @default `false` + */ + undefinedEmptyQuery?: boolean; + }; }; export type AutocompleteRenderState = { /** * The current value of the query. + * When `future.undefinedEmptyQuery` is `true`, this is `undefined` when no + * query has been set yet (e.g. on init or after submit). */ - currentRefinement: string; + currentRefinement: string | undefined; /** * The indices this widget has access to. @@ -111,6 +126,7 @@ const connectAutocomplete: AutocompleteConnector = function connectAutocomplete( transformItems = ((indices) => indices) as NonNullable< AutocompleteConnectorParams['transformItems'] >, + future: { undefinedEmptyQuery = false } = {}, } = widgetParams || {}; warning( @@ -221,7 +237,9 @@ search.addWidgets([ }); return { - currentRefinement: state.query || '', + currentRefinement: undefinedEmptyQuery + ? state.query + : state.query || '', indices: transformItems(indices).map((transformedIndex) => ({ ...transformedIndex, sendEvent: sendEventMap[transformedIndex.indexId], @@ -232,9 +250,11 @@ search.addWidgets([ }, getWidgetUiState(uiState, { searchParameters }) { - const query = searchParameters.query || ''; + const query = undefinedEmptyQuery + ? searchParameters.query + : searchParameters.query || ''; - if (query === '' || (uiState && uiState.query === query)) { + if (!query || query === '' || (uiState && uiState.query === query)) { return uiState; } @@ -246,7 +266,7 @@ search.addWidgets([ getWidgetSearchParameters(searchParameters, { uiState }) { const parameters = { - query: uiState.query || '', + query: undefinedEmptyQuery ? uiState.query : uiState.query || '', }; if (!escapeHTML) { diff --git a/packages/instantsearch.js/src/widgets/autocomplete/autocomplete.tsx b/packages/instantsearch.js/src/widgets/autocomplete/autocomplete.tsx index 7f8f8bad37..5bdf7e6e92 100644 --- a/packages/instantsearch.js/src/widgets/autocomplete/autocomplete.tsx +++ b/packages/instantsearch.js/src/widgets/autocomplete/autocomplete.tsx @@ -386,16 +386,15 @@ function AutocompleteWrapper({ const searchboxQuery = isolatedIndex?.getHelper()?.state.query; const targetIndexQuery = targetIndex?.getHelper()?.state.query; - // Local query state for immediate updates (especially for detached search button) const [localQuery, setLocalQuery] = useState( - searchboxQuery || targetIndexQuery || '' + searchboxQuery !== undefined ? searchboxQuery : targetIndexQuery ?? '' ); - // Sync local query with searchbox query when it changes externally useEffect(() => { - // If the isolated index has a query, use it (user typing). - // If not, fall back to the target index query (URL/main state). - const query = searchboxQuery || targetIndexQuery; + // When the isolated index has a defined query (including ''), use it. + // Only fall back to the target index query when not yet set (undefined). + const query = + searchboxQuery !== undefined ? searchboxQuery : targetIndexQuery; if (query !== undefined) { setLocalQuery(query); } @@ -1278,7 +1277,11 @@ export function EXPERIMENTAL_autocomplete( ]) ), { - ...makeWidget({ escapeHTML, transformItems }), + ...makeWidget({ + escapeHTML, + transformItems, + future: { undefinedEmptyQuery: true }, + }), $$widgetType: 'ais.autocomplete', }, ]), diff --git a/packages/react-instantsearch/src/widgets/Autocomplete.tsx b/packages/react-instantsearch/src/widgets/Autocomplete.tsx index 5c7fa19117..ed165f8baf 100644 --- a/packages/react-instantsearch/src/widgets/Autocomplete.tsx +++ b/packages/react-instantsearch/src/widgets/Autocomplete.tsx @@ -622,8 +622,14 @@ function InnerAutocomplete({ currentRefinement, } = useAutocomplete({ transformItems, + future: { undefinedEmptyQuery: true }, }); + const resolvedQuery = + currentRefinement !== undefined + ? currentRefinement + : indexUiState.query ?? ''; + const { isDetached, isModalDetached, isModalOpen, setIsModalOpen } = useDetachedMode(detachedMediaQuery); const previousIsDetachedRef = useRef(isDetached); @@ -878,17 +884,15 @@ function InnerAutocomplete({ onQueryChange={(query) => { refineAutocomplete(query); }} - query={currentRefinement || indexUiState.query || ''} + query={resolvedQuery} isSearchStalled={isSearchStalled} onAiModeClick={ aiMode ? () => { if (chatRenderState) { chatRenderState.setOpen?.(true); - const query = - currentRefinement || indexUiState.query || ''; - if (query.trim()) { - chatRenderState.sendMessage?.({ text: query }); + if (resolvedQuery.trim()) { + chatRenderState.sendMessage?.({ text: resolvedQuery }); } } } @@ -931,7 +935,7 @@ function InnerAutocomplete({ classNames={classNames} > { diff --git a/tests/common/widgets/autocomplete/options.tsx b/tests/common/widgets/autocomplete/options.tsx index c975794ec3..084aaac2e3 100644 --- a/tests/common/widgets/autocomplete/options.tsx +++ b/tests/common/widgets/autocomplete/options.tsx @@ -718,6 +718,138 @@ export function createOptionsTests( ]); }); + test('only triggers one search when typing, not a duplicate on the parent', async () => { + const searchClient = createMockedSearchClient( + createMultiSearchResponse( + createSingleSearchResponse({ + index: 'indexName', + hits: [ + { objectID: '1', name: 'Item 1' }, + { objectID: '2', name: 'Item 2' }, + ], + }) + ) + ); + + await setup({ + instantSearchOptions: { + indexName: 'indexName', + searchClient, + }, + widgetParams: { + javascript: { + indices: [ + { + indexName: 'indexName', + templates: { + item: (props) => props.item.name, + }, + }, + ], + }, + react: { + indices: [ + { + indexName: 'indexName', + itemComponent: (props) => props.item.name, + }, + ], + }, + vue: {}, + }, + }); + + await act(async () => { + await wait(0); + }); + + (searchClient.search as jest.Mock).mockClear(); + + const input = screen.getByRole('combobox', { name: /submit/i }); + + await act(async () => { + await userEvent.click(input); + await userEvent.paste(input, 'hello'); + await wait(0); + }); + + expect(searchClient.search).toHaveBeenCalledTimes(1); + expect(searchClient.search).toHaveBeenLastCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + params: expect.objectContaining({ + query: 'hello', + }), + }), + ]) + ); + }); + + test('triggers a search on the parent index when submitting', async () => { + const searchClient = createMockedSearchClient( + createMultiSearchResponse( + createSingleSearchResponse({ + index: 'indexName', + hits: [ + { objectID: '1', name: 'Item 1' }, + { objectID: '2', name: 'Item 2' }, + ], + }) + ) + ); + + await setup({ + instantSearchOptions: { + indexName: 'indexName', + searchClient, + }, + widgetParams: { + javascript: { + indices: [ + { + indexName: 'indexName', + templates: { + item: (props) => props.item.name, + }, + }, + ], + }, + react: { + indices: [ + { + indexName: 'indexName', + itemComponent: (props) => props.item.name, + }, + ], + }, + vue: {}, + }, + }); + + await act(async () => { + await wait(0); + }); + + const input = screen.getByRole('combobox', { name: /submit/i }); + + await act(async () => { + await userEvent.click(input); + await userEvent.paste(input, 'hello'); + await wait(0); + }); + + (searchClient.search as jest.Mock).mockClear(); + + await act(async () => { + userEvent.keyboard('{Enter}'); + await wait(0); + }); + + expect( + (searchClient.search as jest.Mock).mock.calls.length + ).toBeGreaterThanOrEqual(2); + }); + test('closes the panel then blurs the input when pressing enter', async () => { const searchClient = createMockedSearchClient( createMultiSearchResponse( @@ -954,6 +1086,71 @@ export function createOptionsTests( ]); }); + test('does not show the submitted query after clearing the input', async () => { + const searchClient = createMockedSearchClient( + createMultiSearchResponse( + createSingleSearchResponse({ + index: 'indexName', + hits: [ + { objectID: '1', name: 'Item 1' }, + { objectID: '2', name: 'Item 2' }, + ], + }) + ) + ); + + await setup({ + instantSearchOptions: { + indexName: 'indexName', + searchClient, + }, + widgetParams: { + javascript: { + indices: [ + { + indexName: 'indexName', + templates: { + item: (props) => props.item.name, + }, + }, + ], + }, + react: { + indices: [ + { + indexName: 'indexName', + itemComponent: (props) => props.item.name, + }, + ], + }, + vue: {}, + }, + }); + + await act(async () => { + await wait(0); + }); + + const input = screen.getByRole('combobox', { name: /submit/i }); + + // Type and submit a query + await act(async () => { + userEvent.click(input); + userEvent.type(input, 'hello'); + userEvent.keyboard('{Enter}'); + await wait(0); + }); + + // Clear the input by typing + await act(async () => { + userEvent.click(input); + userEvent.clear(input); + await wait(0); + }); + + expect(input).toHaveValue(''); + }); + test('refocuses the input after clearing the query', async () => { const searchClient = createMockedSearchClient( createMultiSearchResponse( From fbc4ef3d8a83cc0bd8a29a014f83dd99f7f4b7d7 Mon Sep 17 00:00:00 2001 From: Andras Date: Fri, 10 Apr 2026 12:14:53 +0300 Subject: [PATCH 3/5] Code Review Feedback --- .../components/autocomplete/createAutocompletePropGetters.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/instantsearch-ui-components/src/components/autocomplete/createAutocompletePropGetters.ts b/packages/instantsearch-ui-components/src/components/autocomplete/createAutocompletePropGetters.ts index a9b0ee3a84..148067f063 100644 --- a/packages/instantsearch-ui-components/src/components/autocomplete/createAutocompletePropGetters.ts +++ b/packages/instantsearch-ui-components/src/components/autocomplete/createAutocompletePropGetters.ts @@ -185,7 +185,7 @@ export function createAutocompletePropGetters({ const actualDescendant = override.activeDescendant ?? activeDescendant; - if (!actualDescendant && override.query) { + if (!actualDescendant && override.query !== undefined) { onRefine(override.query); } From f01b5c5e751fc543b1ab0e340fedaeb7e39fcce1 Mon Sep 17 00:00:00 2001 From: Andras Date: Fri, 10 Apr 2026 12:16:49 +0300 Subject: [PATCH 4/5] Update bundlesize.config.json --- bundlesize.config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bundlesize.config.json b/bundlesize.config.json index fc61445eec..ea3829c08e 100644 --- a/bundlesize.config.json +++ b/bundlesize.config.json @@ -10,7 +10,7 @@ }, { "path": "./packages/instantsearch.js/dist/instantsearch.production.min.js", - "maxSize": "121.25 kB" + "maxSize": "121.50 kB" }, { "path": "./packages/instantsearch.js/dist/instantsearch.development.js", From 6e0a826808f832df38c26cc99bab8e4d697e6e46 Mon Sep 17 00:00:00 2001 From: Andras Date: Fri, 10 Apr 2026 14:14:01 +0300 Subject: [PATCH 5/5] Update bundlesize.config.json --- bundlesize.config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bundlesize.config.json b/bundlesize.config.json index 1dfc56e30e..087734a090 100644 --- a/bundlesize.config.json +++ b/bundlesize.config.json @@ -10,7 +10,7 @@ }, { "path": "./packages/instantsearch.js/dist/instantsearch.production.min.js", - "maxSize": "121.8 kB" + "maxSize": "122 kB" }, { "path": "./packages/instantsearch.js/dist/instantsearch.development.js",