Implement AbortController request cancellation for search.

Signed-off-by: Sydney Achinger <sydneynicoleachinger@spotify.com>
This commit is contained in:
Sydney Achinger
2025-09-23 11:58:54 -04:00
parent a919ca34b5
commit 67a3e1a7a4
16 changed files with 322 additions and 128 deletions
+13 -4
View File
@@ -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 () => {
+7 -2
View File
@@ -30,12 +30,17 @@ export class SearchClient implements SearchApi {
this.fetchApi = options.fetchApi;
}
async query(query: SearchQuery): Promise<SearchResultSet> {
async query(
query: SearchQuery,
options?: { signal?: AbortSignal },
): Promise<SearchResultSet> {
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);
@@ -48,6 +48,9 @@ describe('<HomePageSearchBar/>', () => {
expect(searchApiMock.query).toHaveBeenCalledWith(
expect.objectContaining({ term: '' }),
{
signal: expect.any(AbortSignal),
},
);
await userEvent.type(screen.getByLabelText('Search'), 'term{enter}');
@@ -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<HTMLInputElement>('Search');
@@ -232,6 +238,7 @@ describe('SearchModal', () => {
expect(searchApiMock.query).toHaveBeenCalledWith(
expect.objectContaining({ term: 'term' }),
{ signal: expect.any(AbortSignal) },
);
const fullResultsBtn = screen.getByRole('button', {
@@ -157,18 +157,28 @@ describe('SearchType.Accordion', () => {
</Wrapper>,
);
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);
@@ -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<AbortController | null>(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 (
<Box>
<Typography variant="body2" component="h2">
@@ -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),
},
);
});
});