UNPKG

@tanstack/query-core

Version:

The framework agnostic core that powers TanStack Query

313 lines (278 loc) • 8.9 kB
import { focusManager } from './focusManager' import { onlineManager } from './onlineManager' import { isServer as isServerEnvironment } from './environmentManager' import { noop, sleep } from './utils' import type { CancelOptions, DefaultError, NetworkMode } from './types' // TYPES interface RetryerConfig<TData = unknown, TError = DefaultError> { fn: () => TData | Promise<TData> initialPromise?: Promise<TData> onCancel?: (error: TError) => void onFail?: (failureCount: number, error: TError) => void onPause?: () => void onContinue?: () => void retry?: RetryValue<TError> retryDelay?: RetryDelayValue<TError> networkMode: NetworkMode | undefined canRun: () => boolean } /** * Runs a function with retries, and can pause, continue, or cancel it. Queries and mutations use * one for each fetch or execution. */ export interface Retryer<TData = unknown> { /** * The promise that resolves with the data, or rejects once retries are exhausted or it is * cancelled. */ promise: Promise<TData> /** * Cancels the run, rejecting `promise` with a `CancelledError`. */ cancel: (cancelOptions?: CancelOptions) => void /** * Resumes a paused run, and returns `promise`. */ continue: () => Promise<unknown> /** * Stops further retries. The current attempt still finishes. */ cancelRetry: () => void /** * Allows retries again after `cancelRetry`. */ continueRetry: () => void /** * Returns whether the run can start now, based on the network mode and `canRun`. */ canStart: () => boolean /** * Starts the run, or waits until it can start, and returns `promise`. */ start: () => Promise<TData> /** * Returns whether `promise` is still pending, resolved, or rejected. */ status: () => 'pending' | 'resolved' | 'rejected' } type RetryerStatus = 'pending' | 'resolved' | 'rejected' /** @inline */ export type RetryValue<TError> = boolean | number | ShouldRetryFunction<TError> /** @inline */ type ShouldRetryFunction<TError = DefaultError> = ( failureCount: number, error: TError, ) => boolean /** @inline */ export type RetryDelayValue<TError> = number | RetryDelayFunction<TError> /** @inline */ type RetryDelayFunction<TError = DefaultError> = ( failureCount: number, error: TError, ) => number /** * The default `retryDelay`: doubles with every failure, starting at 1 second, up to 30 seconds. * @param failureCount - The number of failures so far. * @returns The delay before the next retry, in milliseconds. */ function defaultRetryDelay(failureCount: number) { return Math.min(1000 * 2 ** failureCount, 30000) } /** * Checks whether a fetch can run for the given network mode. * @param networkMode - The `networkMode` option. Defaults to `'online'`. * @returns `true` if the network mode is not `'online'`, or if it is and the client is online. */ export function canFetch(networkMode: NetworkMode | undefined): boolean { return (networkMode ?? 'online') === 'online' ? onlineManager.isOnline() : true } /** * The error thrown by a `Retryer` (and surfaced to `query.promise`/`mutation`) when a fetch is cancelled, e.g. via * `query.cancel()`. `revert`, if `true`, tells the caller to restore the state the query was in before the fetch * started instead of surfacing the error. `silent`, if `true`, tells the caller to suppress this error and instead * resolve with the promise of the fetch that triggered the cancellation. * @example * ```ts * query.cancel() * * try { * await query.promise * } catch (error) { * if (error instanceof CancelledError) { * // the fetch was cancelled, e.g. via `query.cancel()` * } * } * ``` */ export class CancelledError extends Error { revert?: boolean silent?: boolean constructor(options?: CancelOptions) { super('CancelledError') this.revert = options?.revert this.silent = options?.silent } } /** * Checks whether a value is a `CancelledError`. * @param value - The value to check. * @returns `true` if `value` is a `CancelledError`. * @deprecated Use instanceof `CancelledError` instead. */ export function isCancelledError(value: any): value is CancelledError { return value instanceof CancelledError } /** * Creates a retryer, which runs a function and retries it on failure according to the `retry` and * `retryDelay` options. It pauses while the network mode doesn't allow fetching or `canRun` returns * `false`, and can be cancelled. Queries and mutations use it to run their `queryFn` and * `mutationFn`. * @param config - The function to run, the retry options, and the callbacks for failures, pausing, * continuing, and cancellation. * @returns The retryer, whose `promise` settles with the result of the function. */ export function createRetryer<TData = unknown, TError = DefaultError>( config: RetryerConfig<TData, TError>, ): Retryer<TData> { let isRetryCancelled = false let failureCount = 0 let continueFn: ((value?: unknown) => void) | undefined let status: RetryerStatus = 'pending' let promiseResolve: (data: TData) => void let promiseReject: (error: TError) => void const promise = new Promise<TData>((resolve, reject) => { promiseResolve = resolve promiseReject = reject }) promise.catch(noop) const isResolved = () => status !== 'pending' const cancel = (cancelOptions?: CancelOptions): void => { if (!isResolved()) { const error = new CancelledError(cancelOptions) as TError reject(error) config.onCancel?.(error) } } 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()) { continueFn?.() status = 'resolved' promiseResolve(value) } } const reject = (value: any) => { if (!isResolved()) { continueFn?.() status = 'rejected' promiseReject(value) } } const pause = (canResume: () => boolean) => { return new Promise((continueResolve) => { continueFn = (value) => { if (isResolved() || canResume()) { 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 ?? (isServerEnvironment() ? 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(canContinue) }) .then(() => { if (isRetryCancelled) { reject(error) } else { run() } }) }) } return { promise, status: () => status, cancel, continue: () => { continueFn?.() return promise }, cancelRetry, continueRetry, canStart, start: () => { // Start loop if (canStart()) { run() } else { // Waiting to start (e.g. queued behind a scope) does not depend on focus, // only on the same conditions that allow starting in the first place pause(canStart).then(run) } return promise }, } }