"use strict"; var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault"); Object.defineProperty(exports, "__esModule", { value: true }); exports.useTrackedPromise = exports.isRejectedPromiseState = exports.SilentCanceledPromiseError = exports.CanceledPromiseError = void 0; var _defineProperty2 = _interopRequireDefault(require("@babel/runtime/helpers/defineProperty")); var _react = require("react"); var _useMountedState = _interopRequireDefault(require("react-use/lib/useMountedState")); /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ /* eslint-disable max-classes-per-file */ /** * This hook manages a Promise factory and can create new Promises from it. The * state of these Promises is tracked and they can be canceled when superseded * to avoid race conditions. * * ``` * const [requestState, performRequest] = useTrackedPromise( * { * cancelPreviousOn: 'resolution', * createPromise: async (url: string) => { * return await fetchSomething(url) * }, * onResolve: response => { * setSomeState(response.data); * }, * onReject: response => { * setSomeError(response); * }, * }, * [fetchSomething] * ); * ``` * * The `onResolve` and `onReject` handlers are registered separately, because * the hook will inject a rejection when in case of a canellation. The * `cancelPreviousOn` attribute can be used to indicate when the preceding * pending promises should be canceled: * * 'never': No preceding promises will be canceled. * * 'creation': Any preceding promises will be canceled as soon as a new one is * created. * * 'settlement': Any preceding promise will be canceled when a newer promise is * resolved or rejected. * * 'resolution': Any preceding promise will be canceled when a newer promise is * resolved. * * 'rejection': Any preceding promise will be canceled when a newer promise is * rejected. * * Any pending promises will be canceled when the component using the hook is * unmounted, but their status will not be tracked to avoid React warnings * about memory leaks. * * The last argument is a normal React hook dependency list that indicates * under which conditions a new reference to the configuration object should be * used. * * The `onResolve`, `onReject` and possible uncatched errors are only triggered * if the underlying component is mounted. To ensure they always trigger (i.e. * if the promise is called in a `useLayoutEffect`) use the `triggerOrThrow` * attribute: * * 'whenMounted': (default) they are called only if the component is mounted. * * 'always': they always call. The consumer is then responsible of ensuring no * side effects happen if the underlying component is not mounted. */ const useTrackedPromise = ({ createPromise, onResolve = noOp, onReject = noOp, cancelPreviousOn = 'never', triggerOrThrow = 'whenMounted' }, dependencies) => { const isComponentMounted = (0, _useMountedState.default)(); const shouldTriggerOrThrow = (0, _react.useCallback)(() => { switch (triggerOrThrow) { case 'always': return true; case 'whenMounted': return isComponentMounted(); } }, [isComponentMounted, triggerOrThrow]); /** * If a promise is currently pending, this holds a reference to it and its * cancellation function. */ const pendingPromises = (0, _react.useRef)([]); /** * The state of the promise most recently created by the `createPromise` * factory. It could be uninitialized, pending, resolved or rejected. */ const [promiseState, setPromiseState] = (0, _react.useState)({ state: 'uninitialized' }); const reset = (0, _react.useCallback)(() => { setPromiseState({ state: 'uninitialized' }); }, []); const execute = (0, _react.useMemo)(() => (...args) => { let rejectCancellationPromise; const cancellationPromise = new Promise((_, reject) => { rejectCancellationPromise = reject; }); // remember the list of prior pending promises for cancellation const previousPendingPromises = pendingPromises.current; const cancelPreviousPendingPromises = () => { previousPendingPromises.forEach(promise => promise.cancel()); }; const newPromise = createPromise(...args); const newCancelablePromise = Promise.race([newPromise, cancellationPromise]); // track this new state setPromiseState({ state: 'pending', promise: newCancelablePromise }); if (cancelPreviousOn === 'creation') { cancelPreviousPendingPromises(); } const newPendingPromise = { cancel: () => { rejectCancellationPromise(new CanceledPromiseError()); }, cancelSilently: () => { rejectCancellationPromise(new SilentCanceledPromiseError()); }, promise: newCancelablePromise.then(value => { if (['settlement', 'resolution'].includes(cancelPreviousOn)) { cancelPreviousPendingPromises(); } // remove itself from the list of pending promises pendingPromises.current = pendingPromises.current.filter(pendingPromise => pendingPromise.promise !== newPendingPromise.promise); if (onResolve && shouldTriggerOrThrow()) { onResolve(value); } setPromiseState(previousPromiseState => previousPromiseState.state === 'pending' && previousPromiseState.promise === newCancelablePromise ? { state: 'resolved', promise: newPendingPromise.promise, value } : previousPromiseState); return value; }, value => { if (!(value instanceof SilentCanceledPromiseError)) { if (['settlement', 'rejection'].includes(cancelPreviousOn)) { cancelPreviousPendingPromises(); } // remove itself from the list of pending promises pendingPromises.current = pendingPromises.current.filter(pendingPromise => pendingPromise.promise !== newPendingPromise.promise); if (shouldTriggerOrThrow()) { if (onReject) { onReject(value); } else { throw value; } } setPromiseState(previousPromiseState => previousPromiseState.state === 'pending' && previousPromiseState.promise === newCancelablePromise ? { state: 'rejected', promise: newCancelablePromise, value } : previousPromiseState); } }) }; // add the new promise to the list of pending promises pendingPromises.current = [...pendingPromises.current, newPendingPromise]; // silence "unhandled rejection" warnings newPendingPromise.promise.catch(noOp); return newPendingPromise.promise; }, // the dependencies are managed by the caller // eslint-disable-next-line react-hooks/exhaustive-deps dependencies); /** * Cancel any pending promises silently to avoid memory leaks and race * conditions. */ (0, _react.useEffect)(() => () => { pendingPromises.current.forEach(promise => promise.cancelSilently()); }, []); return [promiseState, execute, reset]; }; exports.useTrackedPromise = useTrackedPromise; const isRejectedPromiseState = promiseState => promiseState.state === 'rejected'; exports.isRejectedPromiseState = isRejectedPromiseState; class CanceledPromiseError extends Error { constructor(message) { super(message); (0, _defineProperty2.default)(this, "isCanceled", true); Object.setPrototypeOf(this, new.target.prototype); } } exports.CanceledPromiseError = CanceledPromiseError; class SilentCanceledPromiseError extends CanceledPromiseError {} exports.SilentCanceledPromiseError = SilentCanceledPromiseError; const noOp = () => undefined;