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
+12 -2
View File
@@ -93,13 +93,23 @@ export class MockSearchApi implements SearchApi {
// (undocumented)
mockedResults?: SearchResultSet | undefined;
// (undocumented)
query(): Promise<SearchResultSet>;
query(
_query: SearchQuery,
_options?: {
signal?: AbortSignal;
},
): Promise<SearchResultSet>;
}
// @public (undocumented)
export interface SearchApi {
// (undocumented)
query(query: SearchQuery): Promise<SearchResultSet>;
query(
query: SearchQuery,
options?: {
signal?: AbortSignal;
},
): Promise<SearchResultSet>;
}
// @public (undocumented)
+8 -2
View File
@@ -28,7 +28,10 @@ export const searchApiRef = createApiRef<SearchApi>({
* @public
*/
export interface SearchApi {
query(query: SearchQuery): Promise<SearchResultSet>;
query(
query: SearchQuery,
options?: { signal?: AbortSignal },
): Promise<SearchResultSet>;
}
/**
@@ -39,7 +42,10 @@ export interface SearchApi {
export class MockSearchApi implements SearchApi {
constructor(public mockedResults?: SearchResultSet) {}
query(): Promise<SearchResultSet> {
query(
_query: SearchQuery,
_options?: { signal?: AbortSignal },
): Promise<SearchResultSet> {
return Promise.resolve(this.mockedResults || { results: [] });
}
}
@@ -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),
},
);
});
});
@@ -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();
@@ -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) },
);
});
});
@@ -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),
},
);
});
});
@@ -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',
@@ -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<AbortController | null>(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.