Skip to content

Commit

Permalink
fix: handle late resolving promises with promise cancelation
Browse files Browse the repository at this point in the history
  • Loading branch information
sarahdayan committed Jan 14, 2022
1 parent 47b5982 commit 6cc5451
Show file tree
Hide file tree
Showing 13 changed files with 708 additions and 516 deletions.
3 changes: 2 additions & 1 deletion packages/autocomplete-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@
"watch": "watch \"yarn on:change\" --ignoreDirectoryPattern \"/dist/\""
},
"dependencies": {
"@algolia/autocomplete-shared": "1.5.1"
"@algolia/autocomplete-shared": "1.5.1",
"cancelable-promise": "4.2.1"
},
"devDependencies": {
"@algolia/autocomplete-preset-algolia": "1.5.1",
Expand Down
113 changes: 113 additions & 0 deletions packages/autocomplete-core/src/__tests__/debouncing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import userEvent from '@testing-library/user-event';

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

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

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

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(() => {}, 300);

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');

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

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

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

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

userEvent.type(inputElement, 'def');

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

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(),
};
}
18 changes: 9 additions & 9 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 === true || !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 @@ -212,8 +212,8 @@ export function getPropGetters<
// 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
88 changes: 47 additions & 41 deletions packages/autocomplete-core/src/onInput.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import CancelablePromise, { cancelable } from 'cancelable-promise';

import { reshape } from './reshape';
import { preResolve, resolve, postResolve } from './resolve';
import {
Expand Down Expand Up @@ -37,7 +39,7 @@ export function onInput<TItem extends BaseItem>({
refresh,
store,
...setters
}: OnInputParams<TItem>): Promise<void> {
}: OnInputParams<TItem>): CancelablePromise<void> {
if (lastStalledId) {
props.environment.clearTimeout(lastStalledId);
}
Expand Down Expand Up @@ -69,7 +71,13 @@ export function onInput<TItem extends BaseItem>({
// promises to keep late resolving promises from "cancelling" the state
// updates performed in this code path.
// We chain with a void promise to respect `onInput`'s expected return type.
return runConcurrentSafePromise(collections).then(() => Promise.resolve());
const request = cancelable(
runConcurrentSafePromise(collections).then(() => Promise.resolve())
);

store.pendingRequests.add(request);

return request;
}

setStatus('loading');
Expand All @@ -84,35 +92,37 @@ export function onInput<TItem extends BaseItem>({
// We don't track nested promises and only rely on the full chain resolution,
// meaning we should only ever manipulate the state once this concurrent-safe
// promise is resolved.
return runConcurrentSafePromise(
props
.getSources({
query,
refresh,
state: store.getState(),
...setters,
})
.then((sources) => {
return Promise.all(
sources.map((source) => {
return Promise.resolve(
source.getItems({
query,
refresh,
state: store.getState(),
...setters,
})
).then((itemsOrDescription) =>
preResolve<TItem>(itemsOrDescription, source.sourceId)
const request = cancelable(
runConcurrentSafePromise(
props
.getSources({
query,
refresh,
state: store.getState(),
...setters,
})
.then((sources) => {
return Promise.all(
sources.map((source) => {
return Promise.resolve(
source.getItems({
query,
refresh,
state: store.getState(),
...setters,
})
).then((itemsOrDescription) =>
preResolve<TItem>(itemsOrDescription, source.sourceId)
);
})
)
.then(resolve)
.then((responses) => postResolve(responses, sources))
.then((collections) =>
reshape({ collections, props, state: store.getState() })
);
})
)
.then(resolve)
.then((responses) => postResolve(responses, sources))
.then((collections) =>
reshape({ collections, props, state: store.getState() })
);
})
})
)
)
.then((collections) => {
// Parameters passed to `onInput` could be stale when the following code
Expand All @@ -122,14 +132,6 @@ export function onInput<TItem extends BaseItem>({

setStatus('idle');

if (store.shouldSkipPendingUpdate) {
if (!runConcurrentSafePromise.isRunning()) {
store.shouldSkipPendingUpdate = false;
}

return;
}

setCollections(collections as any);

const isPanelOpen = props.shouldPanelOpen({ state: store.getState() });
Expand Down Expand Up @@ -157,10 +159,14 @@ export function onInput<TItem extends BaseItem>({
}
})
.finally(() => {
setStatus('idle');

if (lastStalledId) {
props.environment.clearTimeout(lastStalledId);
}
});
}
}, true);

store.pendingRequests.add(request);

onInput.isRunning = runConcurrentSafePromise.isRunning;
return request;
}
4 changes: 1 addition & 3 deletions packages/autocomplete-core/src/onKeyDown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,7 @@ export function onKeyDown<TItem extends BaseItem>({
// autocomplete. At this point, we should ignore any requests that are still
// running and could reopen the panel once they resolve, because that would
// result in an unsolicited UI behavior.
if (onInput.isRunning()) {
store.shouldSkipPendingUpdate = true;
}
store.pendingRequests.cancelAll();
} else if (event.key === 'Enter') {
// No active item, so we let the browser handle the native `onSubmit` form
// event.
Expand Down
4 changes: 3 additions & 1 deletion packages/autocomplete-core/src/types/AutocompleteStore.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { CancelablePromiseQueue } from '../utils';

import { BaseItem } from './AutocompleteApi';
import { InternalAutocompleteOptions } from './AutocompleteOptions';
import { AutocompleteState } from './AutocompleteState';

export interface AutocompleteStore<TItem extends BaseItem> {
getState(): AutocompleteState<TItem>;
dispatch(action: ActionType, payload: any): void;
shouldSkipPendingUpdate: boolean;
pendingRequests: CancelablePromiseQueue;
}

export type Reducer = <TItem extends BaseItem>(
Expand Down
Loading

0 comments on commit 6cc5451

Please sign in to comment.