import { focusManager } from './focusManager' import { onlineManager } from './onlineManager' import { pendingThenable } from './thenable' import { isServer, sleep } from './utils' import type { CancelOptions, DefaultError, NetworkMode } from './types' // TYPES interface RetryerConfig { fn: () => TData | Promise initialPromise?: Promise abort?: () => void onError?: (error: TError) => void onSuccess?: (data: TData) => void onFail?: (failureCount: number, error: TError) => void onPause?: () => void onContinue?: () => void retry?: RetryValue retryDelay?: RetryDelayValue networkMode: NetworkMode | undefined canRun: () => boolean } export interface Retryer { promise: Promise cancel: (cancelOptions?: CancelOptions) => void continue: () => Promise cancelRetry: () => void continueRetry: () => void canStart: () => boolean start: () => Promise } export type RetryValue = boolean | number | ShouldRetryFunction type ShouldRetryFunction = ( failureCount: number, error: TError, ) => boolean export type RetryDelayValue = number | RetryDelayFunction type RetryDelayFunction = ( failureCount: number, error: TError, ) => number function defaultRetryDelay(failureCount: number) { return Math.min(1000 * 2 ** failureCount, 30000) } export function canFetch(networkMode: NetworkMode | undefined): boolean { return (networkMode ?? 'online') === 'online' ? onlineManager.isOnline() : true } export class CancelledError extends Error { revert?: boolean silent?: boolean constructor(options?: CancelOptions) { super('CancelledError') this.revert = options?.revert this.silent = options?.silent } } export function isCancelledError(value: any): value is CancelledError { return value instanceof CancelledError } export function createRetryer( config: RetryerConfig, ): Retryer { let isRetryCancelled = false let failureCount = 0 let isResolved = false let continueFn: ((value?: unknown) => void) | undefined const thenable = pendingThenable() const cancel = (cancelOptions?: CancelOptions): void => { if (!isResolved) { reject(new CancelledError(cancelOptions)) config.abort?.() } } const cancelRetry = () => { isRetryCancelled = true } const continueRetry = () => { isRetryCancelled = false } const canContinue = () => focusManager.isFocused() && (config.networkMode === 'always' || onlineManager.isOnline()) && config.canRun() const canStart = () => canFetch(config.networkMode) && config.canRun() const resolve = (value: any) => { if (!isResolved) { isResolved = true config.onSuccess?.(value) continueFn?.() thenable.resolve(value) } } const reject = (value: any) => { if (!isResolved) { isResolved = true config.onError?.(value) continueFn?.() thenable.reject(value) } } const pause = () => { return new Promise((continueResolve) => { continueFn = (value) => { if (isResolved || canContinue()) { continueResolve(value) } } config.onPause?.() }).then(() => { continueFn = undefined if (!isResolved) { config.onContinue?.() } }) } // Create loop function const run = () => { // Do nothing if already resolved if (isResolved) { return } let promiseOrValue: any // we can re-use config.initialPromise on the first call of run() const initialPromise = failureCount === 0 ? config.initialPromise : undefined // Execute query try { promiseOrValue = initialPromise ?? config.fn() } catch (error) { promiseOrValue = Promise.reject(error) } Promise.resolve(promiseOrValue) .then(resolve) .catch((error) => { // Stop if the fetch is already resolved if (isResolved) { return } // Do we need to retry the request? const retry = config.retry ?? (isServer ? 0 : 3) const retryDelay = config.retryDelay ?? defaultRetryDelay const delay = typeof retryDelay === 'function' ? retryDelay(failureCount, error) : retryDelay const shouldRetry = retry === true || (typeof retry === 'number' && failureCount < retry) || (typeof retry === 'function' && retry(failureCount, error)) if (isRetryCancelled || !shouldRetry) { // We are done if the query does not need to be retried reject(error) return } failureCount++ // Notify on fail config.onFail?.(failureCount, error) // Delay sleep(delay) // Pause if the document is not visible or when the device is offline .then(() => { return canContinue() ? undefined : pause() }) .then(() => { if (isRetryCancelled) { reject(error) } else { run() } }) }) } return { promise: thenable, cancel, continue: () => { continueFn?.() return thenable }, cancelRetry, continueRetry, canStart, start: () => { // Start loop if (canStart()) { run() } else { pause().then(run) } return thenable }, } }