Skip to content

Commit

Permalink
fix: handle late resolving promises with promise cancelation (#864)
Browse files Browse the repository at this point in the history
Co-authored-by: François Chalifour <francoischalifour@users.noreply.github.com>
Co-authored-by: Haroen Viaene <hello@haroen.me>
  • Loading branch information
3 people authored Jan 26, 2022
1 parent 5c29a20 commit 9640c2d
Show file tree
Hide file tree
Showing 15 changed files with 927 additions and 143 deletions.
112 changes: 98 additions & 14 deletions packages/autocomplete-core/src/__tests__/concurrency.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { noop } from '@algolia/autocomplete-shared';
import userEvent from '@testing-library/user-event';

import { AutocompleteState } from '..';
import { createPlayground, createSource, defer } from '../../../../test/utils';
import {
createPlayground,
createSource,
defer,
runAllMicroTasks,
} from '../../../../test/utils';
import { createAutocomplete } from '../createAutocomplete';

type Item = {
Expand Down Expand Up @@ -31,7 +37,7 @@ describe('concurrency', () => {
userEvent.type(input, 'b');
userEvent.type(input, 'c');

await defer(() => {}, timeout);
await defer(noop, timeout);

let stateHistory: Array<
AutocompleteState<Item>
Expand All @@ -57,7 +63,7 @@ describe('concurrency', () => {

userEvent.type(input, '{backspace}'.repeat(3));

await defer(() => {}, timeout);
await defer(noop, timeout);

stateHistory = onStateChange.mock.calls.flatMap((x) => x[0].state);

Expand Down Expand Up @@ -88,19 +94,44 @@ describe('concurrency', () => {
getSources,
});

userEvent.type(inputElement, 'ab{esc}');
userEvent.type(inputElement, 'ab');

await defer(() => {}, timeout);
// The search request is triggered
expect(onStateChange).toHaveBeenLastCalledWith(
expect.objectContaining({
state: expect.objectContaining({
status: 'loading',
query: 'ab',
}),
})
);

userEvent.type(inputElement, '{esc}');

// The status is immediately set to "idle" and the panel is closed
expect(onStateChange).toHaveBeenLastCalledWith(
expect.objectContaining({
state: expect.objectContaining({
status: 'idle',
isOpen: false,
query: '',
}),
})
);

await defer(noop, timeout);

// Once the request is settled, the state remains unchanged
expect(onStateChange).toHaveBeenLastCalledWith(
expect.objectContaining({
state: expect.objectContaining({
status: 'idle',
isOpen: false,
}),
})
);
expect(getSources).toHaveBeenCalledTimes(2);

expect(getSources).toHaveBeenCalledTimes(3);
});

test('keeps the panel closed on blur', async () => {
Expand All @@ -115,19 +146,46 @@ describe('concurrency', () => {
getSources,
});

userEvent.type(inputElement, 'a{enter}');
userEvent.type(inputElement, 'a');

await runAllMicroTasks();

// The search request is triggered
expect(onStateChange).toHaveBeenLastCalledWith(
expect.objectContaining({
state: expect.objectContaining({
status: 'loading',
query: 'a',
}),
})
);

await defer(() => {}, timeout);
userEvent.type(inputElement, '{enter}');

// The status is immediately set to "idle" and the panel is closed
expect(onStateChange).toHaveBeenLastCalledWith(
expect.objectContaining({
state: expect.objectContaining({
status: 'idle',
isOpen: false,
query: 'a',
}),
})
);

await defer(noop, timeout);

// Once the request is settled, the state remains unchanged
expect(onStateChange).toHaveBeenLastCalledWith(
expect.objectContaining({
state: expect.objectContaining({
status: 'idle',
isOpen: false,
}),
})
);
expect(getSources).toHaveBeenCalledTimes(1);

expect(getSources).toHaveBeenCalledTimes(2);
});

test('keeps the panel closed on touchstart blur', async () => {
Expand Down Expand Up @@ -156,19 +214,45 @@ describe('concurrency', () => {
window.addEventListener('touchstart', onTouchStart);

userEvent.type(inputElement, 'a');

await runAllMicroTasks();

// The search request is triggered
expect(onStateChange).toHaveBeenLastCalledWith(
expect.objectContaining({
state: expect.objectContaining({
status: 'loading',
query: 'a',
}),
})
);

const customEvent = new CustomEvent('touchstart', { bubbles: true });
window.document.dispatchEvent(customEvent);

await defer(() => {}, timeout);

// The status is immediately set to "idle" and the panel is closed
expect(onStateChange).toHaveBeenLastCalledWith(
expect.objectContaining({
state: expect.objectContaining({
status: 'idle',
isOpen: false,
query: 'a',
}),
})
);

await defer(noop, timeout);

// Once the request is settled, the state remains unchanged
expect(onStateChange).toHaveBeenLastCalledWith(
expect.objectContaining({
state: expect.objectContaining({
status: 'idle',
isOpen: false,
}),
})
);

expect(getSources).toHaveBeenCalledTimes(1);

window.removeEventListener('touchstart', onTouchStart);
Expand Down Expand Up @@ -197,7 +281,7 @@ describe('concurrency', () => {

userEvent.type(inputElement, 'a{esc}');

await defer(() => {}, delay);
await defer(noop, delay);

expect(onStateChange).toHaveBeenLastCalledWith(
expect.objectContaining({
Expand Down Expand Up @@ -229,7 +313,7 @@ describe('concurrency', () => {

userEvent.type(inputElement, 'a{enter}');

await defer(() => {}, delay);
await defer(noop, delay);

expect(onStateChange).toHaveBeenLastCalledWith(
expect.objectContaining({
Expand Down Expand Up @@ -276,7 +360,7 @@ describe('concurrency', () => {
const customEvent = new CustomEvent('touchstart', { bubbles: true });
window.document.dispatchEvent(customEvent);

await defer(() => {}, delay);
await defer(noop, delay);

expect(onStateChange).toHaveBeenLastCalledWith(
expect.objectContaining({
Expand Down
99 changes: 99 additions & 0 deletions packages/autocomplete-core/src/__tests__/debouncing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { noop } from '@algolia/autocomplete-shared';
import userEvent from '@testing-library/user-event';

import { createAutocomplete, InternalAutocompleteSource } from '..';
import { createPlayground, createSource, defer } from '../../../../test/utils';

type Source = InternalAutocompleteSource<{ label: string }>;

const delay = 10;

const debounced = debouncePromise<Source[][], Source[]>(
(items) => Promise.resolve(items),
delay
);

describe('debouncing', () => {
test('only submits the final query', async () => {
const onStateChange = jest.fn();
const getItems = jest.fn(({ query }) => [{ label: query }]);
const { inputElement } = createPlayground(createAutocomplete, {
onStateChange,
getSources: () => debounced([createSource({ getItems })]),
});

userEvent.type(inputElement, 'abc');

await defer(noop, delay);

expect(getItems).toHaveBeenCalledTimes(1);
expect(onStateChange).toHaveBeenLastCalledWith(
expect.objectContaining({
state: expect.objectContaining({
status: 'idle',
isOpen: true,
collections: expect.arrayContaining([
expect.objectContaining({
items: [{ __autocomplete_id: 0, label: 'abc' }],
}),
]),
}),
})
);
});

test('triggers subsequent queries after reopening the panel', async () => {
const onStateChange = jest.fn();
const getItems = jest.fn(({ query }) => [{ label: query }]);
const { inputElement } = createPlayground(createAutocomplete, {
onStateChange,
getSources: () => debounced([createSource({ getItems })]),
});

userEvent.type(inputElement, 'abc{esc}');

expect(onStateChange).toHaveBeenLastCalledWith(
expect.objectContaining({
state: expect.objectContaining({
status: 'idle',
isOpen: false,
}),
})
);

userEvent.type(inputElement, 'def');

await defer(noop, delay);

expect(onStateChange).toHaveBeenLastCalledWith(
expect.objectContaining({
state: expect.objectContaining({
collections: expect.arrayContaining([
expect.objectContaining({
items: [{ __autocomplete_id: 0, label: 'abcdef' }],
}),
]),
status: 'idle',
isOpen: true,
}),
})
);
});
});

function debouncePromise<TParams extends unknown[], TResponse>(
fn: (...params: TParams) => Promise<TResponse>,
time: number
) {
let timerId: ReturnType<typeof setTimeout> | undefined = undefined;

return function (...args: TParams) {
if (timerId) {
clearTimeout(timerId);
}

return new Promise<TResponse>((resolve) => {
timerId = setTimeout(() => resolve(fn(...args)), time);
});
};
}
3 changes: 2 additions & 1 deletion packages/autocomplete-core/src/createStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
InternalAutocompleteOptions,
Reducer,
} from './types';
import { createCancelablePromiseList } from './utils';

type OnStoreStateChange<TItem extends BaseItem> = ({
prevState,
Expand Down Expand Up @@ -35,6 +36,6 @@ export function createStore<TItem extends BaseItem>(

onStoreStateChange({ state, prevState });
},
shouldSkipPendingUpdate: false,
pendingRequests: createCancelablePromiseList(),
};
}
20 changes: 10 additions & 10 deletions packages/autocomplete-core/src/getPropGetters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,13 @@ export function getPropGetters<
// The `onTouchStart` event shouldn't trigger the `blur` handler when
// it's not an interaction with Autocomplete. We detect it with the
// following heuristics:
// - the panel is closed AND there are no running requests
// - the panel is closed AND there are no pending requests
// (no interaction with the autocomplete, no future state updates)
// - OR the touched target is the input element (should open the panel)
const isNotAutocompleteInteraction =
store.getState().isOpen === false && !onInput.isRunning();
const isAutocompleteInteraction =
store.getState().isOpen || !store.pendingRequests.isEmpty();

if (isNotAutocompleteInteraction || event.target === inputElement) {
if (!isAutocompleteInteraction || event.target === inputElement) {
return;
}

Expand All @@ -62,12 +62,12 @@ export function getPropGetters<
if (isTargetWithinAutocomplete === false) {
store.dispatch('blur', null);

// If requests are still running when the user closes the panel, they
// If requests are still pending when the user closes the panel, they
// could reopen the panel once they resolve.
// We want to prevent any subsequent query from reopening the panel
// because it would result in an unsolicited UI behavior.
if (!props.debug && onInput.isRunning()) {
store.shouldSkipPendingUpdate = true;
if (!props.debug) {
store.pendingRequests.cancelAll();
}
}
},
Expand Down Expand Up @@ -208,12 +208,12 @@ export function getPropGetters<
if (!isTouchDevice) {
store.dispatch('blur', null);

// If requests are still running when the user closes the panel, they
// If requests are still pending when the user closes the panel, they
// could reopen the panel once they resolve.
// We want to prevent any subsequent query from reopening the panel
// because it would result in an unsolicited UI behavior.
if (!props.debug && onInput.isRunning()) {
store.shouldSkipPendingUpdate = true;
if (!props.debug) {
store.pendingRequests.cancelAll();
}
}
},
Expand Down
Loading

0 comments on commit 9640c2d

Please sign in to comment.