diff --git a/packages/autocomplete-core/src/__tests__/concurrency.test.ts b/packages/autocomplete-core/src/__tests__/concurrency.test.ts index 8284887ea..de7dde305 100644 --- a/packages/autocomplete-core/src/__tests__/concurrency.test.ts +++ b/packages/autocomplete-core/src/__tests__/concurrency.test.ts @@ -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 = { @@ -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 @@ -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); @@ -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 () => { @@ -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 () => { @@ -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); @@ -197,7 +281,7 @@ describe('concurrency', () => { userEvent.type(inputElement, 'a{esc}'); - await defer(() => {}, delay); + await defer(noop, delay); expect(onStateChange).toHaveBeenLastCalledWith( expect.objectContaining({ @@ -229,7 +313,7 @@ describe('concurrency', () => { userEvent.type(inputElement, 'a{enter}'); - await defer(() => {}, delay); + await defer(noop, delay); expect(onStateChange).toHaveBeenLastCalledWith( expect.objectContaining({ @@ -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({ diff --git a/packages/autocomplete-core/src/__tests__/debouncing.test.ts b/packages/autocomplete-core/src/__tests__/debouncing.test.ts new file mode 100644 index 000000000..f4bd90e08 --- /dev/null +++ b/packages/autocomplete-core/src/__tests__/debouncing.test.ts @@ -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( + (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( + fn: (...params: TParams) => Promise, + time: number +) { + let timerId: ReturnType | undefined = undefined; + + return function (...args: TParams) { + if (timerId) { + clearTimeout(timerId); + } + + return new Promise((resolve) => { + timerId = setTimeout(() => resolve(fn(...args)), time); + }); + }; +} diff --git a/packages/autocomplete-core/src/createStore.ts b/packages/autocomplete-core/src/createStore.ts index 8761b5e0b..4e3853d07 100644 --- a/packages/autocomplete-core/src/createStore.ts +++ b/packages/autocomplete-core/src/createStore.ts @@ -5,6 +5,7 @@ import { InternalAutocompleteOptions, Reducer, } from './types'; +import { createCancelablePromiseList } from './utils'; type OnStoreStateChange = ({ prevState, @@ -35,6 +36,6 @@ export function createStore( onStoreStateChange({ state, prevState }); }, - shouldSkipPendingUpdate: false, + pendingRequests: createCancelablePromiseList(), }; } diff --git a/packages/autocomplete-core/src/getPropGetters.ts b/packages/autocomplete-core/src/getPropGetters.ts index 24cdbf41f..0944a2ab1 100644 --- a/packages/autocomplete-core/src/getPropGetters.ts +++ b/packages/autocomplete-core/src/getPropGetters.ts @@ -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; } @@ -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(); } } }, @@ -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(); } } }, diff --git a/packages/autocomplete-core/src/onInput.ts b/packages/autocomplete-core/src/onInput.ts index ba69409a6..043c1fb34 100644 --- a/packages/autocomplete-core/src/onInput.ts +++ b/packages/autocomplete-core/src/onInput.ts @@ -7,7 +7,12 @@ import { BaseItem, InternalAutocompleteOptions, } from './types'; -import { createConcurrentSafePromise, getActiveItem } from './utils'; +import { + cancelable, + CancelablePromise, + createConcurrentSafePromise, + getActiveItem, +} from './utils'; let lastStalledId: number | null = null; @@ -37,7 +42,7 @@ export function onInput({ refresh, store, ...setters -}: OnInputParams): Promise { +}: OnInputParams): CancelablePromise { if (lastStalledId) { props.environment.clearTimeout(lastStalledId); } @@ -69,7 +74,11 @@ export function onInput({ // 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()) + ); + + return store.pendingRequests.add(request); } setStatus('loading'); @@ -84,35 +93,37 @@ export function onInput({ // 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(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(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 @@ -122,14 +133,6 @@ export function onInput({ setStatus('idle'); - if (store.shouldSkipPendingUpdate) { - if (!runConcurrentSafePromise.isRunning()) { - store.shouldSkipPendingUpdate = false; - } - - return; - } - setCollections(collections as any); const isPanelOpen = props.shouldPanelOpen({ state: store.getState() }); @@ -157,10 +160,12 @@ export function onInput({ } }) .finally(() => { + setStatus('idle'); + if (lastStalledId) { props.environment.clearTimeout(lastStalledId); } }); -} -onInput.isRunning = runConcurrentSafePromise.isRunning; + return store.pendingRequests.add(request); +} diff --git a/packages/autocomplete-core/src/onKeyDown.ts b/packages/autocomplete-core/src/onKeyDown.ts index f4fb79b9a..65b9135c3 100644 --- a/packages/autocomplete-core/src/onKeyDown.ts +++ b/packages/autocomplete-core/src/onKeyDown.ts @@ -102,11 +102,9 @@ export function onKeyDown({ // Hitting the `Escape` key signals the end of a user interaction with the // autocomplete. At this point, we should ignore any requests that are still - // running and could reopen the panel once they resolve, because that would + // pending 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. diff --git a/packages/autocomplete-core/src/types/AutocompleteStore.ts b/packages/autocomplete-core/src/types/AutocompleteStore.ts index d9beaae0c..e29914a9f 100644 --- a/packages/autocomplete-core/src/types/AutocompleteStore.ts +++ b/packages/autocomplete-core/src/types/AutocompleteStore.ts @@ -1,3 +1,5 @@ +import { CancelablePromiseList } from '../utils'; + import { BaseItem } from './AutocompleteApi'; import { InternalAutocompleteOptions } from './AutocompleteOptions'; import { AutocompleteState } from './AutocompleteState'; @@ -5,7 +7,7 @@ import { AutocompleteState } from './AutocompleteState'; export interface AutocompleteStore { getState(): AutocompleteState; dispatch(action: ActionType, payload: any): void; - shouldSkipPendingUpdate: boolean; + pendingRequests: CancelablePromiseList; } export type Reducer = ( diff --git a/packages/autocomplete-core/src/utils/__tests__/createCancelablePromise.test.ts b/packages/autocomplete-core/src/utils/__tests__/createCancelablePromise.test.ts new file mode 100644 index 000000000..2dfc49d4f --- /dev/null +++ b/packages/autocomplete-core/src/utils/__tests__/createCancelablePromise.test.ts @@ -0,0 +1,379 @@ +import { noop } from '@algolia/autocomplete-shared'; + +import { cancelable, createCancelablePromise } from '..'; +import { runAllMicroTasks } from '../../../../../test/utils'; + +describe('createCancelablePromise', () => { + test('returns an immediately resolved cancelable promise', async () => { + const onFulfilled = jest.fn(); + const onRejected = jest.fn(); + const onFinally = jest.fn(); + + await createCancelablePromise + .resolve('ok') + .then(onFulfilled) + .catch(onRejected) + .finally(onFinally); + + expect(onFulfilled).toHaveBeenCalledTimes(1); + expect(onFulfilled).toHaveBeenCalledWith('ok'); + expect(onRejected).not.toHaveBeenCalled(); + expect(onFinally).toHaveBeenCalledTimes(1); + expect(onFinally).toHaveBeenCalledWith(); + }); + + test('returns an immediately rejected cancelable promise', async () => { + const onFulfilled = jest.fn(); + const onRejected = jest.fn(); + const onFinally = jest.fn(); + + await createCancelablePromise + .reject(new Error()) + .then(onFulfilled) + .catch(onRejected) + .finally(onFinally); + + expect(onRejected).toHaveBeenCalledTimes(1); + expect(onRejected).toHaveBeenCalledWith(new Error()); + expect(onFulfilled).not.toHaveBeenCalled(); + expect(onFinally).toHaveBeenCalledTimes(1); + expect(onFinally).toHaveBeenCalledWith(); + }); + + test('triggers callbacks when the cancelable promise resolves', async () => { + const onFulfilled = jest.fn(); + const onRejected = jest.fn(); + const onFinally = jest.fn(); + + await createCancelablePromise((resolve) => { + resolve('ok'); + }) + .then(onFulfilled) + .catch(onRejected) + .finally(onFinally); + + expect(onFulfilled).toHaveBeenCalledTimes(1); + expect(onFulfilled).toHaveBeenCalledWith('ok'); + expect(onRejected).not.toHaveBeenCalled(); + expect(onFinally).toHaveBeenCalledTimes(1); + expect(onFinally).toHaveBeenCalledWith(); + }); + + test('triggers callbacks when the cancelable promise rejects', async () => { + const onFulfilled = jest.fn(); + const onRejected = jest.fn(); + const onFinally = jest.fn(); + + await createCancelablePromise((_, reject) => { + reject(new Error()); + }) + .then(onFulfilled) + .catch(onRejected) + .finally(onFinally); + + expect(onRejected).toHaveBeenCalledTimes(1); + expect(onRejected).toHaveBeenCalledWith(new Error()); + expect(onFulfilled).not.toHaveBeenCalled(); + expect(onFinally).toHaveBeenCalledTimes(1); + expect(onFinally).toHaveBeenCalledWith(); + }); + + test('only triggers `finally` handler when the cancelable promise is canceled then resolves', async () => { + const onFulfilled = jest.fn(); + const onRejected = jest.fn(); + const onFinally = jest.fn(); + const cancelablePromise = createCancelablePromise((resolve) => { + resolve('ok'); + }); + + cancelablePromise.then(onFulfilled).catch(onRejected).finally(onFinally); + + expect(cancelablePromise.isCanceled()).toBe(false); + + cancelablePromise.cancel(); + + expect(cancelablePromise.isCanceled()).toBe(true); + + await runAllMicroTasks(); + + expect(onFulfilled).not.toHaveBeenCalled(); + expect(onRejected).not.toHaveBeenCalled(); + expect(onFinally).toHaveBeenCalledTimes(1); + expect(onFinally).toHaveBeenCalledWith(); + }); + + test('only triggers `finally` handler when the cancelable promise is canceled then rejects', async () => { + const onFulfilled = jest.fn(); + const onRejected = jest.fn(); + const onFinally = jest.fn(); + const cancelablePromise = createCancelablePromise((_, reject) => { + reject(new Error()); + }); + + cancelablePromise.then(onFulfilled).catch(onRejected).finally(onFinally); + + expect(cancelablePromise.isCanceled()).toBe(false); + + cancelablePromise.cancel(); + + expect(cancelablePromise.isCanceled()).toBe(true); + + await runAllMicroTasks(); + + expect(onFulfilled).not.toHaveBeenCalled(); + expect(onRejected).not.toHaveBeenCalled(); + expect(onFinally).toHaveBeenCalledTimes(1); + expect(onFinally).toHaveBeenCalledWith(); + }); + + test('only triggers `finally` handler once when the cancelable promise is canceled after resolving', async () => { + const onFulfilled = jest.fn(); + const cancelablePromise = createCancelablePromise((resolve) => { + resolve('ok'); + }).finally(onFulfilled); + + await cancelablePromise; + + expect(onFulfilled).toHaveBeenCalledTimes(1); + + cancelablePromise.cancel(); + + await runAllMicroTasks(); + + expect(onFulfilled).toHaveBeenCalledTimes(1); + }); + + test('only triggers `finally` handler once when the cancelable promise is canceled after rejecting', async () => { + const onFulfilled = jest.fn(); + const cancelablePromise = createCancelablePromise((_, reject) => { + reject(new Error()); + }).finally(onFulfilled); + + await cancelablePromise.catch(noop); + + expect(onFulfilled).toHaveBeenCalledTimes(1); + + cancelablePromise.cancel(); + + await runAllMicroTasks(); + + expect(onFulfilled).toHaveBeenCalledTimes(1); + }); + + test('only triggers `finally` handler once when calling `cancel` several times', async () => { + const onFinally = jest.fn(); + const cancelablePromise = createCancelablePromise((resolve) => { + resolve('ok'); + }); + + cancelablePromise.finally(onFinally); + + cancelablePromise.cancel(); + cancelablePromise.cancel(); + + await runAllMicroTasks(); + + expect(onFinally).toHaveBeenCalledTimes(1); + }); + + test('cancels nested cancelable promises', async () => { + const onFulfilled = jest.fn(); + const cancelablePromise = createCancelablePromise((resolve) => { + resolve('ok'); + }).then(() => + createCancelablePromise((resolve) => { + resolve('ok'); + onFulfilled(); + }) + ); + + expect(cancelablePromise.isCanceled()).toBe(false); + + cancelablePromise.cancel(); + + expect(cancelablePromise.isCanceled()).toBe(true); + + await runAllMicroTasks(); + + expect(onFulfilled).not.toHaveBeenCalled(); + }); +}); + +describe('cancelable', () => { + test('triggers callbacks when the cancelable promise resolves', async () => { + const onFulfilled = jest.fn(); + const onRejected = jest.fn(); + const onFinally = jest.fn(); + + await cancelable( + new Promise((resolve) => { + resolve('ok'); + }) + ) + .then(onFulfilled) + .catch(onRejected) + .finally(onFinally); + + expect(onFulfilled).toHaveBeenCalledTimes(1); + expect(onFulfilled).toHaveBeenCalledWith('ok'); + expect(onRejected).not.toHaveBeenCalled(); + expect(onFinally).toHaveBeenCalledTimes(1); + expect(onFinally).toHaveBeenCalledWith(); + }); + + test('triggers callbacks when the cancelable promise rejects', async () => { + const onFulfilled = jest.fn(); + const onRejected = jest.fn(); + const onFinally = jest.fn(); + + await cancelable( + new Promise((_, reject) => { + reject(new Error()); + }) + ) + .then(onFulfilled) + .catch(onRejected) + .finally(onFinally); + + expect(onRejected).toHaveBeenCalledTimes(1); + expect(onRejected).toHaveBeenCalledWith(new Error()); + expect(onFulfilled).not.toHaveBeenCalled(); + expect(onFinally).toHaveBeenCalledTimes(1); + expect(onFinally).toHaveBeenCalledWith(); + }); + + test('only triggers `finally` handler when the cancelable promise is canceled then resolves', async () => { + const onFulfilled = jest.fn(); + const onRejected = jest.fn(); + const onFinally = jest.fn(); + const cancelablePromise = cancelable( + new Promise((resolve) => { + resolve('ok'); + }) + ); + + cancelablePromise.then(onFulfilled).catch(onRejected).finally(onFinally); + + expect(cancelablePromise.isCanceled()).toBe(false); + + cancelablePromise.cancel(); + + expect(cancelablePromise.isCanceled()).toBe(true); + + await runAllMicroTasks(); + + expect(onFulfilled).not.toHaveBeenCalled(); + expect(onRejected).not.toHaveBeenCalled(); + expect(onFinally).toHaveBeenCalledTimes(1); + expect(onFinally).toHaveBeenCalledWith(); + }); + + test('only triggers `finally` handler when the cancelable promise is canceled then rejects', async () => { + const onFulfilled = jest.fn(); + const onRejected = jest.fn(); + const onFinally = jest.fn(); + const cancelablePromise = cancelable( + new Promise((_, reject) => { + reject(new Error()); + }) + ); + + cancelablePromise.then(onFulfilled).catch(onRejected).finally(onFinally); + + expect(cancelablePromise.isCanceled()).toBe(false); + + cancelablePromise.cancel(); + + expect(cancelablePromise.isCanceled()).toBe(true); + + await runAllMicroTasks(); + + expect(onFulfilled).not.toHaveBeenCalled(); + expect(onRejected).not.toHaveBeenCalled(); + expect(onFinally).toHaveBeenCalledTimes(1); + expect(onFinally).toHaveBeenCalledWith(); + }); + + test('only triggers `finally` handler once when the cancelable promise is canceled after resolving', async () => { + const onFulfilled = jest.fn(); + const cancelablePromise = cancelable( + new Promise((resolve) => { + resolve('ok'); + }) + ).finally(onFulfilled); + + await cancelablePromise; + + expect(onFulfilled).toHaveBeenCalledTimes(1); + + cancelablePromise.cancel(); + + await runAllMicroTasks(); + + expect(onFulfilled).toHaveBeenCalledTimes(1); + }); + + test('only triggers `finally` handler once when the cancelable promise is canceled after rejecting', async () => { + const onFulfilled = jest.fn(); + const cancelablePromise = cancelable( + new Promise((_, reject) => { + reject(new Error()); + }) + ).finally(onFulfilled); + + await cancelablePromise.catch(noop); + + expect(onFulfilled).toHaveBeenCalledTimes(1); + + cancelablePromise.cancel(); + + await runAllMicroTasks(); + + expect(onFulfilled).toHaveBeenCalledTimes(1); + }); + + test('only triggers `finally` handler once when calling `cancel` several times', async () => { + const onFinally = jest.fn(); + const cancelablePromise = cancelable( + new Promise((resolve) => { + resolve('ok'); + }) + ); + + cancelablePromise.finally(onFinally); + + cancelablePromise.cancel(); + cancelablePromise.cancel(); + + await runAllMicroTasks(); + + expect(onFinally).toHaveBeenCalledTimes(1); + }); + + test('cancels nested cancelable promises', async () => { + const onFulfilled = jest.fn(); + + const cancelablePromise = cancelable( + new Promise((resolve) => { + resolve('ok'); + }) + ).then(() => + cancelable( + new Promise((resolve) => { + resolve('ok'); + onFulfilled(); + }) + ) + ); + + expect(cancelablePromise.isCanceled()).toBe(false); + + cancelablePromise.cancel(); + + expect(cancelablePromise.isCanceled()).toBe(true); + + await runAllMicroTasks(); + + expect(onFulfilled).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/autocomplete-core/src/utils/__tests__/createCancelablePromiseList.test.ts b/packages/autocomplete-core/src/utils/__tests__/createCancelablePromiseList.test.ts new file mode 100644 index 000000000..a0ccc7064 --- /dev/null +++ b/packages/autocomplete-core/src/utils/__tests__/createCancelablePromiseList.test.ts @@ -0,0 +1,78 @@ +import { noop } from '@algolia/autocomplete-shared'; + +import { createCancelablePromise, createCancelablePromiseList } from '..'; + +describe('createCancelablePromiseList', () => { + test('adds cancelable promises to the list', () => { + const cancelablePromiseList = createCancelablePromiseList(); + const cancelablePromise = createCancelablePromise(noop); + + expect(cancelablePromiseList.isEmpty()).toBe(true); + + cancelablePromiseList.add(cancelablePromise); + + expect(cancelablePromiseList.isEmpty()).toBe(false); + }); + + test('removes the cancelable promise from the list when it resolves', async () => { + const cancelablePromiseList = createCancelablePromiseList(); + const cancelablePromise = createCancelablePromise.resolve(); + + cancelablePromiseList.add(cancelablePromise); + + expect(cancelablePromiseList.isEmpty()).toBe(false); + + await cancelablePromise; + + expect(cancelablePromiseList.isEmpty()).toBe(true); + }); + + test('removes the cancelable promise from the list when it rejects', async () => { + const cancelablePromiseList = createCancelablePromiseList(); + const cancelablePromise = createCancelablePromise.reject(); + + cancelablePromiseList.add(cancelablePromise).catch(noop); + + expect(cancelablePromiseList.isEmpty()).toBe(false); + + await cancelablePromise.catch(noop); + + expect(cancelablePromiseList.isEmpty()).toBe(true); + }); + + test('removes the cancelable promise from the list when it is canceled', () => { + const cancelablePromiseList = createCancelablePromiseList(); + const cancelablePromise = createCancelablePromise.resolve(); + + cancelablePromiseList.add(cancelablePromise); + + expect(cancelablePromiseList.isEmpty()).toBe(false); + + cancelablePromise.cancel(); + + expect(cancelablePromiseList.isEmpty()).toBe(true); + }); + + test('cancels all promises and empties the list', () => { + const cancelablePromiseList = createCancelablePromiseList(); + const cancelablePromise1 = createCancelablePromise.resolve(); + const cancelablePromise2 = createCancelablePromise.reject(); + const cancelablePromise3 = createCancelablePromise(noop); + + cancelablePromiseList.add(cancelablePromise1).catch(noop); + cancelablePromiseList.add(cancelablePromise2).catch(noop); + cancelablePromiseList.add(cancelablePromise3).catch(noop); + + expect(cancelablePromise1.isCanceled()).toBe(false); + expect(cancelablePromise2.isCanceled()).toBe(false); + expect(cancelablePromise3.isCanceled()).toBe(false); + expect(cancelablePromiseList.isEmpty()).toBe(false); + + cancelablePromiseList.cancelAll(); + + expect(cancelablePromise1.isCanceled()).toBe(true); + expect(cancelablePromise2.isCanceled()).toBe(true); + expect(cancelablePromise3.isCanceled()).toBe(true); + expect(cancelablePromiseList.isEmpty()).toBe(true); + }); +}); diff --git a/packages/autocomplete-core/src/utils/__tests__/createConcurrentSafePromise.test.ts b/packages/autocomplete-core/src/utils/__tests__/createConcurrentSafePromise.test.ts index b605842c3..a67b3a40e 100644 --- a/packages/autocomplete-core/src/utils/__tests__/createConcurrentSafePromise.test.ts +++ b/packages/autocomplete-core/src/utils/__tests__/createConcurrentSafePromise.test.ts @@ -55,43 +55,4 @@ describe('createConcurrentSafePromise', () => { expect(await concurrentSafePromise2).toEqual({ value: 3 }); expect(await concurrentSafePromise3).toEqual({ value: 3 }); }); - - test('returns whether promises are currently running', async () => { - const runConcurrentSafePromise = createConcurrentSafePromise(); - const concurrentSafePromise1 = runConcurrentSafePromise( - defer(() => ({ value: 1 }), 0) - ); - const concurrentSafePromise2 = runConcurrentSafePromise( - defer(() => ({ value: 2 }), 0) - ); - const concurrentSafePromise3 = runConcurrentSafePromise( - defer(() => ({ value: 3 }), 0) - ); - - jest.runAllTimers(); - - expect(runConcurrentSafePromise.isRunning()).toBe(true); - - await concurrentSafePromise1; - await concurrentSafePromise2; - - expect(runConcurrentSafePromise.isRunning()).toBe(true); - - await concurrentSafePromise3; - - expect(runConcurrentSafePromise.isRunning()).toBe(false); - - const concurrentSafePromise4 = runConcurrentSafePromise( - defer(() => Promise.reject(new Error()), 400) - ); - - expect(runConcurrentSafePromise.isRunning()).toBe(true); - - try { - await concurrentSafePromise4; - // eslint-disable-next-line no-empty - } catch (err) {} - - expect(runConcurrentSafePromise.isRunning()).toBe(false); - }); }); diff --git a/packages/autocomplete-core/src/utils/createCancelablePromise.ts b/packages/autocomplete-core/src/utils/createCancelablePromise.ts new file mode 100644 index 000000000..8c770ae92 --- /dev/null +++ b/packages/autocomplete-core/src/utils/createCancelablePromise.ts @@ -0,0 +1,146 @@ +type PromiseExecutor = ( + resolve: (value: TValue | PromiseLike) => void, + reject: (reason?: any) => void +) => void; + +type CancelablePromiseState = { + isCanceled: boolean; + onCancelList: Array<(...args: any[]) => any>; +}; + +function createInternalCancelablePromise( + promise: Promise, + initialState: CancelablePromiseState +): CancelablePromise { + const state = initialState; + + return { + then(onfulfilled, onrejected) { + return createInternalCancelablePromise( + promise.then( + createCallback(onfulfilled, state, promise), + createCallback(onrejected, state, promise) + ), + state + ); + }, + catch(onrejected) { + return createInternalCancelablePromise( + promise.catch(createCallback(onrejected, state, promise)), + state + ); + }, + finally(onfinally) { + if (onfinally) { + state.onCancelList.push(onfinally); + } + + return createInternalCancelablePromise( + promise.finally( + createCallback( + onfinally && + (() => { + state.onCancelList = []; + + return onfinally(); + }), + state, + promise + ) + ), + state + ); + }, + cancel() { + state.isCanceled = true; + const callbacks = state.onCancelList; + state.onCancelList = []; + + callbacks.forEach((callback) => { + callback(); + }); + }, + isCanceled() { + return state.isCanceled === true; + }, + }; +} + +export type CancelablePromise = { + then( + onfulfilled?: + | (( + value: TValue + ) => + | TResultFulfilled + | PromiseLike + | CancelablePromise) + | undefined + | null, + onrejected?: + | (( + reason: any + ) => + | TResultRejected + | PromiseLike + | CancelablePromise) + | undefined + | null + ): CancelablePromise; + catch( + onrejected?: + | (( + reason: any + ) => TResult | PromiseLike | CancelablePromise) + | undefined + | null + ): CancelablePromise; + finally( + onfinally?: (() => void) | undefined | null + ): CancelablePromise; + cancel(): void; + isCanceled(): boolean; +}; + +export function createCancelablePromise( + executor: PromiseExecutor +): CancelablePromise { + return createInternalCancelablePromise( + new Promise((resolve, reject) => { + return executor(resolve, reject); + }), + { isCanceled: false, onCancelList: [] } + ); +} + +createCancelablePromise.resolve = ( + value?: TValue | PromiseLike | CancelablePromise +) => cancelable(Promise.resolve(value)); + +createCancelablePromise.reject = (reason?: any) => + cancelable(Promise.reject(reason)); + +export function cancelable(promise: Promise) { + return createInternalCancelablePromise(promise, { + isCanceled: false, + onCancelList: [], + }); +} + +function createCallback( + onResult: ((...args: any[]) => any) | null | undefined, + state: CancelablePromiseState, + fallback: any +) { + if (!onResult) { + return fallback; + } + + return function callback(arg?: any) { + if (state.isCanceled) { + return arg; + } + + return onResult(arg); + }; +} diff --git a/packages/autocomplete-core/src/utils/createCancelablePromiseList.ts b/packages/autocomplete-core/src/utils/createCancelablePromiseList.ts new file mode 100644 index 000000000..d48d3f4e2 --- /dev/null +++ b/packages/autocomplete-core/src/utils/createCancelablePromiseList.ts @@ -0,0 +1,29 @@ +import { CancelablePromise } from '.'; + +export type CancelablePromiseList = { + add(cancelablePromise: CancelablePromise): CancelablePromise; + cancelAll(): void; + isEmpty(): boolean; +}; + +export function createCancelablePromiseList< + TValue +>(): CancelablePromiseList { + let list: Array> = []; + + return { + add(cancelablePromise) { + list.push(cancelablePromise); + + return cancelablePromise.finally(() => { + list = list.filter((item) => item !== cancelablePromise); + }); + }, + cancelAll() { + list.forEach((promise) => promise.cancel()); + }, + isEmpty() { + return list.length === 0; + }, + }; +} diff --git a/packages/autocomplete-core/src/utils/createConcurrentSafePromise.ts b/packages/autocomplete-core/src/utils/createConcurrentSafePromise.ts index 6c992be11..ae72460b7 100644 --- a/packages/autocomplete-core/src/utils/createConcurrentSafePromise.ts +++ b/packages/autocomplete-core/src/utils/createConcurrentSafePromise.ts @@ -10,41 +10,35 @@ export function createConcurrentSafePromise() { let basePromiseId = -1; let latestResolvedId = -1; let latestResolvedValue: unknown = undefined; - let runningPromisesCount = 0; - function runConcurrentSafePromise(promise: MaybePromise) { + return function runConcurrentSafePromise( + promise: MaybePromise + ) { basePromiseId++; - runningPromisesCount++; const currentPromiseId = basePromiseId; - return Promise.resolve(promise) - .then((x) => { - // The promise might take too long to resolve and get outdated. This would - // result in resolving stale values. - // When this happens, we ignore the promise value and return the one - // coming from the latest resolved value. - // - // +----------------------------------+ - // | 100ms | - // | run(1) +---> R1 | - // | 300ms | - // | run(2) +-------------> R2 (SKIP) | - // | 200ms | - // | run(3) +--------> R3 | - // +----------------------------------+ - if (latestResolvedValue && currentPromiseId < latestResolvedId) { - return latestResolvedValue as TValue; - } + return Promise.resolve(promise).then((x) => { + // The promise might take too long to resolve and get outdated. This would + // result in resolving stale values. + // When this happens, we ignore the promise value and return the one + // coming from the latest resolved value. + // + // +----------------------------------+ + // | 100ms | + // | run(1) +---> R1 | + // | 300ms | + // | run(2) +-------------> R2 (SKIP) | + // | 200ms | + // | run(3) +--------> R3 | + // +----------------------------------+ + if (latestResolvedValue && currentPromiseId < latestResolvedId) { + return latestResolvedValue as TValue; + } - latestResolvedId = currentPromiseId; - latestResolvedValue = x; + latestResolvedId = currentPromiseId; + latestResolvedValue = x; - return x; - }) - .finally(() => runningPromisesCount--); - } - - runConcurrentSafePromise.isRunning = () => runningPromisesCount > 0; - - return runConcurrentSafePromise; + return x; + }); + }; } diff --git a/packages/autocomplete-core/src/utils/index.ts b/packages/autocomplete-core/src/utils/index.ts index c4245e292..d732b3ee1 100644 --- a/packages/autocomplete-core/src/utils/index.ts +++ b/packages/autocomplete-core/src/utils/index.ts @@ -1,3 +1,5 @@ +export * from './createCancelablePromise'; +export * from './createCancelablePromiseList'; export * from './createConcurrentSafePromise'; export * from './getNextActiveItemId'; export * from './getNormalizedSources'; diff --git a/packages/autocomplete-js/src/__tests__/autocomplete.test.ts b/packages/autocomplete-js/src/__tests__/autocomplete.test.ts index 6af5bd29c..4b01c21bc 100644 --- a/packages/autocomplete-js/src/__tests__/autocomplete.test.ts +++ b/packages/autocomplete-js/src/__tests__/autocomplete.test.ts @@ -1,7 +1,11 @@ import * as autocompleteShared from '@algolia/autocomplete-shared'; import { fireEvent, waitFor } from '@testing-library/dom'; -import { castToJestMock, createMatchMedia } from '../../../../test/utils'; +import { + castToJestMock, + createMatchMedia, + runAllMicroTasks, +} from '../../../../test/utils'; import { autocomplete } from '../autocomplete'; jest.mock('@algolia/autocomplete-shared', () => { @@ -524,7 +528,7 @@ describe('autocomplete-js', () => { expect(input).toHaveValue('Query'); }); - test('renders on input', () => { + test('renders on input', async () => { const container = document.createElement('div'); autocomplete<{ label: string }>({ id: 'autocomplete', @@ -554,6 +558,8 @@ describe('autocomplete-js', () => { fireEvent.input(input, { target: { value: 'a' } }); + await runAllMicroTasks(); + expect(input).toHaveValue('a'); }); });