From 67a3e1a7a4285dda50c97b9a71b4a5949f400962 Mon Sep 17 00:00:00 2001 From: Sydney Achinger Date: Tue, 23 Sep 2025 11:58:54 -0400 Subject: [PATCH] Implement AbortController request cancellation for search. Signed-off-by: Sydney Achinger --- .changeset/ripe-yaks-brake.md | 6 + plugins/search-react/report.api.md | 14 +- plugins/search-react/src/api.ts | 10 +- .../SearchAutocomplete.test.tsx | 68 +++++--- .../components/SearchBar/SearchBar.test.tsx | 12 ++ .../SearchFilter/SearchFilter.test.tsx | 8 + .../SearchPagination.test.tsx | 12 ++ .../src/context/SearchContext.test.tsx | 151 +++++++++++------- .../src/context/SearchContext.tsx | 36 ++++- plugins/search/src/apis.test.ts | 17 +- plugins/search/src/apis.ts | 9 +- .../HomePageSearchBar.test.tsx | 3 + .../SearchModal/SearchModal.test.tsx | 21 ++- .../SearchType/SearchType.Accordion.test.tsx | 34 ++-- .../SearchType/SearchType.Accordion.tsx | 36 ++++- .../components/SearchType/SearchType.test.tsx | 13 +- 16 files changed, 322 insertions(+), 128 deletions(-) create mode 100644 .changeset/ripe-yaks-brake.md diff --git a/.changeset/ripe-yaks-brake.md b/.changeset/ripe-yaks-brake.md new file mode 100644 index 0000000000..953d0f3192 --- /dev/null +++ b/.changeset/ripe-yaks-brake.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-search-react': patch +'@backstage/plugin-search': patch +--- + +Implement AbortController request cancellation in SearchBar to prevent overlapping search requests. This change ensures that when users type quickly, previous search requests are properly canceled before new ones start. diff --git a/plugins/search-react/report.api.md b/plugins/search-react/report.api.md index f0ec6030fd..6c9a1cd09d 100644 --- a/plugins/search-react/report.api.md +++ b/plugins/search-react/report.api.md @@ -93,13 +93,23 @@ export class MockSearchApi implements SearchApi { // (undocumented) mockedResults?: SearchResultSet | undefined; // (undocumented) - query(): Promise; + query( + _query: SearchQuery, + _options?: { + signal?: AbortSignal; + }, + ): Promise; } // @public (undocumented) export interface SearchApi { // (undocumented) - query(query: SearchQuery): Promise; + query( + query: SearchQuery, + options?: { + signal?: AbortSignal; + }, + ): Promise; } // @public (undocumented) diff --git a/plugins/search-react/src/api.ts b/plugins/search-react/src/api.ts index 24a247a641..61da8a3d77 100644 --- a/plugins/search-react/src/api.ts +++ b/plugins/search-react/src/api.ts @@ -28,7 +28,10 @@ export const searchApiRef = createApiRef({ * @public */ export interface SearchApi { - query(query: SearchQuery): Promise; + query( + query: SearchQuery, + options?: { signal?: AbortSignal }, + ): Promise; } /** @@ -39,7 +42,10 @@ export interface SearchApi { export class MockSearchApi implements SearchApi { constructor(public mockedResults?: SearchResultSet) {} - query(): Promise { + query( + _query: SearchQuery, + _options?: { signal?: AbortSignal }, + ): Promise { return Promise.resolve(this.mockedResults || { results: [] }); } } diff --git a/plugins/search-react/src/components/SearchAutocomplete/SearchAutocomplete.test.tsx b/plugins/search-react/src/components/SearchAutocomplete/SearchAutocomplete.test.tsx index 173d6c3450..36757dafff 100644 --- a/plugins/search-react/src/components/SearchAutocomplete/SearchAutocomplete.test.tsx +++ b/plugins/search-react/src/components/SearchAutocomplete/SearchAutocomplete.test.tsx @@ -95,12 +95,17 @@ describe('SearchAutocomplete', () => { ); await waitFor(() => { - expect(query).toHaveBeenCalledWith({ - filters: {}, - pageCursor: undefined, - term: options[0], - types: [], - }); + expect(query).toHaveBeenCalledWith( + { + filters: {}, + pageCursor: undefined, + term: options[0], + types: [], + }, + { + signal: expect.any(AbortSignal), + }, + ); }); }); @@ -117,23 +122,33 @@ describe('SearchAutocomplete', () => { ); await waitFor(() => { - expect(query).toHaveBeenCalledWith({ - filters: {}, - pageCursor: undefined, - term: options[0], - types: [], - }); + expect(query).toHaveBeenCalledWith( + { + filters: {}, + pageCursor: undefined, + term: options[0], + types: [], + }, + { + signal: expect.any(AbortSignal), + }, + ); }); await userEvent.click(screen.getByLabelText('Clear')); await waitFor(() => { - expect(query).toHaveBeenCalledWith({ - filters: {}, - pageCursor: undefined, - term: '', - types: [], - }); + expect(query).toHaveBeenCalledWith( + { + filters: {}, + pageCursor: undefined, + term: '', + types: [], + }, + { + signal: expect.any(AbortSignal), + }, + ); }); }); @@ -154,12 +169,17 @@ describe('SearchAutocomplete', () => { await userEvent.click(screen.getByText(options[0])); await waitFor(() => { - expect(query).toHaveBeenCalledWith({ - filters: {}, - pageCursor: undefined, - term: options[0], - types: [], - }); + expect(query).toHaveBeenCalledWith( + { + filters: {}, + pageCursor: undefined, + term: options[0], + types: [], + }, + { + signal: expect.any(AbortSignal), + }, + ); }); }); diff --git a/plugins/search-react/src/components/SearchBar/SearchBar.test.tsx b/plugins/search-react/src/components/SearchBar/SearchBar.test.tsx index 54889792db..977f413f97 100644 --- a/plugins/search-react/src/components/SearchBar/SearchBar.test.tsx +++ b/plugins/search-react/src/components/SearchBar/SearchBar.test.tsx @@ -171,6 +171,9 @@ describe('SearchBar', () => { expect(searchApiMock.query).toHaveBeenLastCalledWith( expect.objectContaining({ term: value }), + { + signal: expect.any(AbortSignal), + }, ); jest.runAllTimers(); @@ -203,6 +206,9 @@ describe('SearchBar', () => { expect(searchApiMock.query).toHaveBeenLastCalledWith( expect.objectContaining({ term: '' }), + { + signal: expect.any(AbortSignal), + }, ); }); @@ -254,6 +260,9 @@ describe('SearchBar', () => { await waitFor(() => expect(searchApiMock.query).not.toHaveBeenLastCalledWith( expect.objectContaining({ term: value }), + expect.objectContaining({ + signal: expect.any(AbortSignal), + }), ), ); @@ -265,6 +274,9 @@ describe('SearchBar', () => { expect(searchApiMock.query).toHaveBeenLastCalledWith( expect.objectContaining({ term: value }), + { + signal: expect.any(AbortSignal), + }, ); jest.runAllTimers(); diff --git a/plugins/search-react/src/components/SearchFilter/SearchFilter.test.tsx b/plugins/search-react/src/components/SearchFilter/SearchFilter.test.tsx index d8d3b75aa2..7191b842d3 100644 --- a/plugins/search-react/src/components/SearchFilter/SearchFilter.test.tsx +++ b/plugins/search-react/src/components/SearchFilter/SearchFilter.test.tsx @@ -178,6 +178,7 @@ describe('SearchFilter', () => { await waitFor(() => { expect(searchApiMock.query).toHaveBeenLastCalledWith( expect.objectContaining({ filters: { field: [values[0]] } }), + { signal: expect.any(Object) }, ); }); @@ -186,6 +187,7 @@ describe('SearchFilter', () => { await waitFor(() => { expect(searchApiMock.query).toHaveBeenLastCalledWith( expect.objectContaining({ filters: {} }), + { signal: expect.any(Object) }, ); }); }); @@ -217,6 +219,7 @@ describe('SearchFilter', () => { expect.objectContaining({ filters: { ...filters, field: [values[0]] }, }), + { signal: expect.any(Object) }, ); }); @@ -225,6 +228,7 @@ describe('SearchFilter', () => { await waitFor(() => { expect(searchApiMock.query).toHaveBeenLastCalledWith( expect.objectContaining({ filters }), + { signal: expect.any(Object) }, ); }); }); @@ -421,6 +425,7 @@ describe('SearchFilter', () => { expect.objectContaining({ filters: { [name]: values[0] }, }), + { signal: expect.any(AbortSignal) }, ); }); @@ -437,6 +442,7 @@ describe('SearchFilter', () => { expect.objectContaining({ filters: {}, }), + { signal: expect.any(AbortSignal) }, ); }); }); @@ -479,6 +485,7 @@ describe('SearchFilter', () => { expect.objectContaining({ filters: { ...filters, [name]: values[0] }, }), + { signal: expect.any(AbortSignal) }, ); }); @@ -493,6 +500,7 @@ describe('SearchFilter', () => { await waitFor(() => { expect(searchApiMock.query).toHaveBeenLastCalledWith( expect.objectContaining({ filters }), + { signal: expect.any(AbortSignal) }, ); }); }); diff --git a/plugins/search-react/src/components/SearchPagination/SearchPagination.test.tsx b/plugins/search-react/src/components/SearchPagination/SearchPagination.test.tsx index d601caadd9..e482dc3a75 100644 --- a/plugins/search-react/src/components/SearchPagination/SearchPagination.test.tsx +++ b/plugins/search-react/src/components/SearchPagination/SearchPagination.test.tsx @@ -197,6 +197,9 @@ describe('SearchPagination', () => { expect.objectContaining({ pageLimit: 10, }), + { + signal: expect.any(AbortSignal), + }, ); }); @@ -229,6 +232,9 @@ describe('SearchPagination', () => { expect.objectContaining({ pageCursor: 'Mg==', // page: 2 }), + { + signal: expect.any(AbortSignal), + }, ); await userEvent.click(screen.getByLabelText('Previous page')); @@ -239,6 +245,9 @@ describe('SearchPagination', () => { expect.objectContaining({ pageCursor: 'MQ==', // page: 1 }), + { + signal: expect.any(AbortSignal), + }, ); }); @@ -271,6 +280,9 @@ describe('SearchPagination', () => { pageCursor: undefined, pageLimit: 10, }), + { + signal: expect.any(AbortSignal), + }, ); }); }); diff --git a/plugins/search-react/src/context/SearchContext.test.tsx b/plugins/search-react/src/context/SearchContext.test.tsx index f604183c7f..cbdf684b70 100644 --- a/plugins/search-react/src/context/SearchContext.test.tsx +++ b/plugins/search-react/src/context/SearchContext.test.tsx @@ -264,11 +264,14 @@ describe('SearchContext', () => { result.current.setTerm(term); }); - expect(searchApiMock.query).toHaveBeenLastCalledWith({ - term, - types: ['*'], - filters: {}, - }); + expect(searchApiMock.query).toHaveBeenLastCalledWith( + { + term, + types: ['*'], + filters: {}, + }, + { signal: expect.any(AbortSignal) }, + ); }); it('When types is set', async () => { @@ -286,11 +289,14 @@ describe('SearchContext', () => { result.current.setTypes(types); }); - expect(searchApiMock.query).toHaveBeenLastCalledWith({ - types, - term: '', - filters: {}, - }); + expect(searchApiMock.query).toHaveBeenLastCalledWith( + { + types, + term: '', + filters: {}, + }, + { signal: expect.any(AbortSignal) }, + ); }); it('When filters are set', async () => { @@ -308,11 +314,14 @@ describe('SearchContext', () => { result.current.setFilters(filters); }); - expect(searchApiMock.query).toHaveBeenLastCalledWith({ - filters, - term: '', - types: ['*'], - }); + expect(searchApiMock.query).toHaveBeenLastCalledWith( + { + filters, + term: '', + types: ['*'], + }, + { signal: expect.any(AbortSignal) }, + ); }); it('When page limit is set', async () => { @@ -330,12 +339,15 @@ describe('SearchContext', () => { result.current.setPageLimit(pageLimit); }); - expect(searchApiMock.query).toHaveBeenLastCalledWith({ - pageLimit, - term: '', - types: ['*'], - filters: {}, - }); + expect(searchApiMock.query).toHaveBeenLastCalledWith( + { + pageLimit, + term: '', + types: ['*'], + filters: {}, + }, + { signal: expect.any(AbortSignal) }, + ); }); it('When page cursor is set', async () => { @@ -353,12 +365,15 @@ describe('SearchContext', () => { result.current.setPageCursor(pageCursor); }); - expect(searchApiMock.query).toHaveBeenLastCalledWith({ - pageCursor, - term: '', - types: ['*'], - filters: {}, - }); + expect(searchApiMock.query).toHaveBeenLastCalledWith( + { + pageCursor, + term: '', + types: ['*'], + filters: {}, + }, + { signal: expect.any(AbortSignal) }, + ); }); it('provides function for fetch the next page', async () => { @@ -382,12 +397,15 @@ describe('SearchContext', () => { result.current.fetchNextPage!(); }); - expect(searchApiMock.query).toHaveBeenLastCalledWith({ - term: '', - types: ['*'], - filters: {}, - pageCursor: 'NEXT', - }); + expect(searchApiMock.query).toHaveBeenLastCalledWith( + { + term: '', + types: ['*'], + filters: {}, + pageCursor: 'NEXT', + }, + { signal: expect.any(AbortSignal) }, + ); }); it('provides function for fetch the previous page', async () => { @@ -410,12 +428,15 @@ describe('SearchContext', () => { result.current.fetchPreviousPage!(); }); - expect(searchApiMock.query).toHaveBeenLastCalledWith({ - term: '', - types: ['*'], - filters: {}, - pageCursor: 'PREVIOUS', - }); + expect(searchApiMock.query).toHaveBeenLastCalledWith( + { + term: '', + types: ['*'], + filters: {}, + pageCursor: 'PREVIOUS', + }, + { signal: expect.any(AbortSignal) }, + ); }); }); @@ -456,11 +477,14 @@ describe('SearchContext', () => { }); await waitFor(() => { - expect(searchApiMock.query).toHaveBeenCalledWith({ - term: '', - types: ['*'], - filters: {}, - }); + expect(searchApiMock.query).toHaveBeenCalledWith( + { + term: '', + types: ['*'], + filters: {}, + }, + { signal: expect.any(AbortSignal) }, + ); expect(analyticsApiMock.captureEvent).not.toHaveBeenCalled(); }); @@ -470,11 +494,14 @@ describe('SearchContext', () => { }); await waitFor(() => { - expect(searchApiMock.query).toHaveBeenCalledWith({ - term: 'eva', - types: ['*'], - filters: {}, - }); + expect(searchApiMock.query).toHaveBeenCalledWith( + { + term: 'eva', + types: ['*'], + filters: {}, + }, + { signal: expect.any(AbortSignal) }, + ); expect(analyticsApiMock.captureEvent).toHaveBeenCalledWith({ action: 'search', subject: 'eva', @@ -493,11 +520,14 @@ describe('SearchContext', () => { }); await waitFor(() => { - expect(searchApiMock.query).toHaveBeenCalledWith({ - term: 'eva.m', - types: ['*'], - filters: {}, - }); + expect(searchApiMock.query).toHaveBeenCalledWith( + { + term: 'eva.m', + types: ['*'], + filters: {}, + }, + { signal: expect.any(AbortSignal) }, + ); expect(analyticsApiMock.captureEvent).toHaveBeenCalledWith({ action: 'search', subject: 'eva.m', @@ -531,11 +561,14 @@ describe('SearchContext', () => { }); await waitFor(() => { - expect(searchApiMock.query).toHaveBeenLastCalledWith({ - term: 'term', - types: ['*'], - filters: {}, - }); + expect(searchApiMock.query).toHaveBeenLastCalledWith( + { + term: 'term', + types: ['*'], + filters: {}, + }, + { signal: expect.any(AbortSignal) }, + ); expect(analyticsApiMock.captureEvent).toHaveBeenCalledWith({ action: 'search', subject: 'term', diff --git a/plugins/search-react/src/context/SearchContext.tsx b/plugins/search-react/src/context/SearchContext.tsx index 3bd29c881a..ba06010789 100644 --- a/plugins/search-react/src/context/SearchContext.tsx +++ b/plugins/search-react/src/context/SearchContext.tsx @@ -23,6 +23,7 @@ import { useCallback, useContext, useEffect, + useRef, useState, } from 'react'; import useAsync, { AsyncState } from 'react-use/esm/useAsync'; @@ -132,15 +133,28 @@ const useSearchContextValue = ( const prevTerm = usePrevious(term); const prevFilters = usePrevious(filters); + const abortControllerRef = useRef(null); const result = useAsync(async () => { - const resultSet = await searchApi.query({ - term, - types, - filters, - pageLimit, - pageCursor, - }); + // Here we cancel the previous request before making a new one + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + const controller = new AbortController(); + abortControllerRef.current = controller; + + const resultSet = await searchApi.query( + { + term, + types, + filters, + pageLimit, + pageCursor, + }, + { signal: controller.signal }, + ); + if (term) { analytics.captureEvent('search', term, { value: resultSet.numberOfResults, @@ -162,6 +176,14 @@ const useSearchContextValue = ( setPageCursor(result.value?.previousPageCursor); }, [result.value?.previousPageCursor]); + useEffect(() => { + return () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + }; + }, []); + useEffect(() => { // Any time a term is reset, we want to start from page 0. // Only reset the term if it has been modified by the user at least once, the initial state must not reset the term. diff --git a/plugins/search/src/apis.test.ts b/plugins/search/src/apis.test.ts index 15745d2e68..1bb245dc9f 100644 --- a/plugins/search/src/apis.test.ts +++ b/plugins/search/src/apis.test.ts @@ -52,10 +52,19 @@ describe('apis', () => { identityApi.getCredentials.mockResolvedValue({}); await client.query(query); expect(getBaseUrl).toHaveBeenLastCalledWith('search'); - expect(mockFetch).toHaveBeenLastCalledWith( - `${baseUrl}/query?term=`, - undefined, - ); + expect(mockFetch).toHaveBeenLastCalledWith(`${baseUrl}/query?term=`, { + signal: undefined, + }); + }); + + it('Fetch is called with abort signal when provided', async () => { + identityApi.getCredentials.mockResolvedValue({}); + const abortController = new AbortController(); + await client.query(query, { signal: abortController.signal }); + expect(getBaseUrl).toHaveBeenLastCalledWith('search'); + expect(mockFetch).toHaveBeenLastCalledWith(`${baseUrl}/query?term=`, { + signal: abortController.signal, + }); }); it('Resolves JSON from fetch response', async () => { diff --git a/plugins/search/src/apis.ts b/plugins/search/src/apis.ts index b56d88a22c..03e6b6d1d4 100644 --- a/plugins/search/src/apis.ts +++ b/plugins/search/src/apis.ts @@ -30,12 +30,17 @@ export class SearchClient implements SearchApi { this.fetchApi = options.fetchApi; } - async query(query: SearchQuery): Promise { + async query( + query: SearchQuery, + options?: { signal?: AbortSignal }, + ): Promise { const queryString = qs.stringify(query); const url = `${await this.discoveryApi.getBaseUrl( 'search', )}/query?${queryString}`; - const response = await this.fetchApi.fetch(url); + const response = await this.fetchApi.fetch(url, { + signal: options?.signal, + }); if (!response.ok) { throw await ResponseError.fromResponse(response); diff --git a/plugins/search/src/components/HomePageComponent/HomePageSearchBar.test.tsx b/plugins/search/src/components/HomePageComponent/HomePageSearchBar.test.tsx index 37b7c13665..e7c47b7d30 100644 --- a/plugins/search/src/components/HomePageComponent/HomePageSearchBar.test.tsx +++ b/plugins/search/src/components/HomePageComponent/HomePageSearchBar.test.tsx @@ -48,6 +48,9 @@ describe('', () => { expect(searchApiMock.query).toHaveBeenCalledWith( expect.objectContaining({ term: '' }), + { + signal: expect.any(AbortSignal), + }, ); await userEvent.type(screen.getByLabelText('Search'), 'term{enter}'); diff --git a/plugins/search/src/components/SearchModal/SearchModal.test.tsx b/plugins/search/src/components/SearchModal/SearchModal.test.tsx index c404f54e06..8d8e1bb9b9 100644 --- a/plugins/search/src/components/SearchModal/SearchModal.test.tsx +++ b/plugins/search/src/components/SearchModal/SearchModal.test.tsx @@ -88,7 +88,9 @@ describe('SearchModal', () => { ); expect(screen.getByRole('dialog')).toBeInTheDocument(); - expect(searchApiMock.query).toHaveBeenCalledWith(initialState); + expect(searchApiMock.query).toHaveBeenCalledWith(initialState, { + signal: expect.any(AbortSignal), + }); }); it('Should create a local search context if a parent is not defined', async () => { @@ -104,12 +106,15 @@ describe('SearchModal', () => { ); expect(screen.getByRole('dialog')).toBeInTheDocument(); - expect(searchApiMock.query).toHaveBeenCalledWith({ - term: '', - filters: {}, - types: [], - pageCursor: undefined, - }); + expect(searchApiMock.query).toHaveBeenCalledWith( + { + term: '', + filters: {}, + types: [], + pageCursor: undefined, + }, + { signal: expect.any(AbortSignal) }, + ); }); it('Should render a custom Modal correctly', async () => { @@ -200,6 +205,7 @@ describe('SearchModal', () => { expect(searchApiMock.query).toHaveBeenCalledWith( expect.objectContaining({ term: 'term' }), + { signal: expect.any(AbortSignal) }, ); const input = screen.getByLabelText('Search'); @@ -232,6 +238,7 @@ describe('SearchModal', () => { expect(searchApiMock.query).toHaveBeenCalledWith( expect.objectContaining({ term: 'term' }), + { signal: expect.any(AbortSignal) }, ); const fullResultsBtn = screen.getByRole('button', { diff --git a/plugins/search/src/components/SearchType/SearchType.Accordion.test.tsx b/plugins/search/src/components/SearchType/SearchType.Accordion.test.tsx index 1245ceed5a..a0dd2976e6 100644 --- a/plugins/search/src/components/SearchType/SearchType.Accordion.test.tsx +++ b/plugins/search/src/components/SearchType/SearchType.Accordion.test.tsx @@ -157,18 +157,28 @@ describe('SearchType.Accordion', () => { , ); - expect(searchApiMock.query).toHaveBeenCalledWith({ - term: 'abc', - types: [], - filters: { foo: 'bar' }, - pageLimit: 0, - }); - expect(searchApiMock.query).toHaveBeenCalledWith({ - term: 'abc', - types: [expectedType.value], - filters: {}, - pageLimit: 0, - }); + expect(searchApiMock.query).toHaveBeenCalledWith( + { + term: 'abc', + types: [], + filters: { foo: 'bar' }, + pageLimit: 0, + }, + { + signal: expect.any(AbortSignal), + }, + ); + expect(searchApiMock.query).toHaveBeenCalledWith( + { + term: 'abc', + types: [expectedType.value], + filters: {}, + pageLimit: 0, + }, + { + signal: expect.any(AbortSignal), + }, + ); await waitFor(() => { const countLabels = getAllByText('1234 results'); expect(countLabels.length).toEqual(2); diff --git a/plugins/search/src/components/SearchType/SearchType.Accordion.tsx b/plugins/search/src/components/SearchType/SearchType.Accordion.tsx index 65c96b1e60..8168418ed2 100644 --- a/plugins/search/src/components/SearchType/SearchType.Accordion.tsx +++ b/plugins/search/src/components/SearchType/SearchType.Accordion.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { cloneElement, Fragment, useEffect, useState } from 'react'; +import { cloneElement, Fragment, useEffect, useRef, useState } from 'react'; import { useApi } from '@backstage/core-plugin-api'; import { searchApiRef, useSearch } from '@backstage/plugin-search-react'; import Accordion from '@material-ui/core/Accordion'; @@ -86,6 +86,7 @@ export const SearchTypeAccordion = (props: SearchTypeAccordionProps) => { const [expanded, setExpanded] = useState(true); const { defaultValue, name, showCounts, types: givenTypes } = props; const { t } = useTranslationRef(searchTranslationRef); + const abortControllerRef = useRef(null); const toggleExpanded = () => setExpanded(prevState => !prevState); const handleClick = (type: string) => { @@ -117,18 +118,29 @@ export const SearchTypeAccordion = (props: SearchTypeAccordionProps) => { if (!showCounts) { return {}; } + // Here we cancel the previous requests before making a new one + // All requests are made with a new AbortController signal + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + const controller = new AbortController(); + abortControllerRef.current = controller; const counts = await Promise.all( definedTypes .map(type => type.value) .map(async type => { - const { numberOfResults } = await searchApi.query({ - term, - types: type ? [type] : [], - filters: - types.includes(type) || (!types.length && !type) ? filters : {}, - pageLimit: 0, - }); + const { numberOfResults } = await searchApi.query( + { + term, + types: type ? [type] : [], + filters: + types.includes(type) || (!types.length && !type) ? filters : {}, + pageLimit: 0, + }, + { signal: controller.signal }, + ); return [ type, @@ -145,6 +157,14 @@ export const SearchTypeAccordion = (props: SearchTypeAccordionProps) => { return Object.fromEntries(counts); }, [filters, showCounts, term, types]); + useEffect(() => { + return () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + }; + }, []); + return ( diff --git a/plugins/search/src/components/SearchType/SearchType.test.tsx b/plugins/search/src/components/SearchType/SearchType.test.tsx index 231463c9bf..e62ac19fee 100644 --- a/plugins/search/src/components/SearchType/SearchType.test.tsx +++ b/plugins/search/src/components/SearchType/SearchType.test.tsx @@ -192,6 +192,9 @@ describe('SearchType', () => { expect.objectContaining({ types: [values[0]], }), + { + signal: expect.any(AbortSignal), + }, ); }); @@ -240,6 +243,9 @@ describe('SearchType', () => { expect.objectContaining({ types: [...typeValues, values[0]], }), + { + signal: expect.any(AbortSignal), + }, ); }); @@ -253,7 +259,12 @@ describe('SearchType', () => { await waitFor(() => { expect(searchApiMock.query).toHaveBeenLastCalledWith( - expect.objectContaining([]), + expect.objectContaining({ + types: typeValues, + }), + { + signal: expect.any(AbortSignal), + }, ); }); });