diff --git a/src/core/mutation.ts b/src/core/mutation.ts index 093f4107ee..04533c11ea 100644 --- a/src/core/mutation.ts +++ b/src/core/mutation.ts @@ -4,7 +4,7 @@ import type { MutationObserver } from './mutationObserver' import { getLogger } from './logger' import { notifyManager } from './notifyManager' import { Removable } from './removable' -import { canFetch, Retryer } from './retryer' +import { canFetch, Retryer, createRetryer } from './retryer' import { noop } from './utils' // TYPES @@ -90,7 +90,7 @@ export class Mutation< private observers: MutationObserver[] private mutationCache: MutationCache - private retryer?: Retryer + private retryer?: Retryer constructor(config: MutationConfig) { super() @@ -262,7 +262,7 @@ export class Mutation< } private executeMutation(): Promise { - this.retryer = new Retryer({ + this.retryer = createRetryer({ fn: () => { if (!this.options.mutationFn) { return Promise.reject('No mutationFn found') diff --git a/src/core/notifyManager.ts b/src/core/notifyManager.ts index b584f7bf84..dc1e7a0cab 100644 --- a/src/core/notifyManager.ts +++ b/src/core/notifyManager.ts @@ -8,43 +8,32 @@ type NotifyFunction = (callback: () => void) => void type BatchNotifyFunction = (callback: () => void) => void -// CLASS - -export class NotifyManager { - private queue: NotifyCallback[] - private transactions: number - private notifyFn: NotifyFunction - private batchNotifyFn: BatchNotifyFunction - - constructor() { - this.queue = [] - this.transactions = 0 - - this.notifyFn = (callback: () => void) => { - callback() - } - - this.batchNotifyFn = (callback: () => void) => { - callback() - } +export function createNotifyManager() { + let queue: NotifyCallback[] = [] + let transactions = 0 + let notifyFn: NotifyFunction = callback => { + callback() + } + let batchNotifyFn: BatchNotifyFunction = (callback: () => void) => { + callback() } - batch(callback: () => T): T { - this.transactions++ + const batch = (callback: () => T): T => { + transactions++ const result = callback() - this.transactions-- - if (!this.transactions) { - this.flush() + transactions-- + if (!transactions) { + flush() } return result } - schedule(callback: NotifyCallback): void { - if (this.transactions) { - this.queue.push(callback) + const schedule = (callback: NotifyCallback): void => { + if (transactions) { + queue.push(callback) } else { scheduleMicrotask(() => { - this.notifyFn(callback) + notifyFn(callback) }) } } @@ -52,22 +41,22 @@ export class NotifyManager { /** * All calls to the wrapped function will be batched. */ - batchCalls(callback: T): T { + const batchCalls = (callback: T): T => { return ((...args: any[]) => { - this.schedule(() => { + schedule(() => { callback(...args) }) }) as any } - flush(): void { - const queue = this.queue - this.queue = [] - if (queue.length) { + const flush = (): void => { + const originalQueue = queue + queue = [] + if (originalQueue.length) { scheduleMicrotask(() => { - this.batchNotifyFn(() => { - queue.forEach(callback => { - this.notifyFn(callback) + batchNotifyFn(() => { + originalQueue.forEach(callback => { + notifyFn(callback) }) }) }) @@ -78,19 +67,26 @@ export class NotifyManager { * Use this method to set a custom notify function. * This can be used to for example wrap notifications with `React.act` while running tests. */ - setNotifyFunction(fn: NotifyFunction) { - this.notifyFn = fn + const setNotifyFunction = (fn: NotifyFunction) => { + notifyFn = fn } /** * Use this method to set a custom function to batch notifications together into a single tick. * By default React Query will use the batch function provided by ReactDOM or React Native. */ - setBatchNotifyFunction(fn: BatchNotifyFunction) { - this.batchNotifyFn = fn + const setBatchNotifyFunction = (fn: BatchNotifyFunction) => { + batchNotifyFn = fn } + + return { + batch, + batchCalls, + schedule, + setNotifyFunction, + setBatchNotifyFunction, + } as const } // SINGLETON - -export const notifyManager = new NotifyManager() +export const notifyManager = createNotifyManager() diff --git a/src/core/query.ts b/src/core/query.ts index 12e99622ed..07e171ecc7 100644 --- a/src/core/query.ts +++ b/src/core/query.ts @@ -21,7 +21,7 @@ import type { QueryCache } from './queryCache' import type { QueryObserver } from './queryObserver' import { notifyManager } from './notifyManager' import { getLogger } from './logger' -import { Retryer, isCancelledError, canFetch } from './retryer' +import { Retryer, isCancelledError, canFetch, createRetryer } from './retryer' import { Removable } from './removable' // TYPES @@ -158,7 +158,7 @@ export class Query< private cache: QueryCache private promise?: Promise - private retryer?: Retryer + private retryer?: Retryer private observers: QueryObserver[] private defaultOptions?: QueryOptions private abortSignalConsumed: boolean @@ -431,7 +431,7 @@ export class Query< } // Try to fetch the data - this.retryer = new Retryer({ + this.retryer = createRetryer({ fn: context.fetchFn as () => TData, abort: abortController?.abort?.bind(abortController), onSuccess: data => { diff --git a/src/core/retryer.ts b/src/core/retryer.ts index 537f1b28ab..0b5f8bf85c 100644 --- a/src/core/retryer.ts +++ b/src/core/retryer.ts @@ -18,6 +18,14 @@ interface RetryerConfig { networkMode: NetworkMode | undefined } +export interface Retryer { + promise: Promise + cancel: (cancelOptions?: CancelOptions) => void + continue: () => void + cancelRetry: () => void + continueRetry: () => void +} + export type RetryValue = boolean | number | ShouldRetryFunction type ShouldRetryFunction = ( @@ -55,160 +63,152 @@ export function isCancelledError(value: any): value is CancelledError { return value instanceof CancelledError } -// CLASS - -export class Retryer { - cancel: (options?: CancelOptions) => void - cancelRetry: () => void - continueRetry: () => void - continue: () => void - failureCount: number - isPaused: boolean - isResolved: boolean - promise: Promise - - constructor(config: RetryerConfig) { - let cancelRetry = false - let continueFn: ((value?: unknown) => void) | undefined - let promiseResolve: (data: TData) => void - let promiseReject: (error: TError) => void - - this.cancel = (cancelOptions?: CancelOptions): void => { - if (!this.isResolved) { - reject(new CancelledError(cancelOptions)) - - config.abort?.() - } - } - this.cancelRetry = () => { - cancelRetry = true +export function createRetryer( + config: RetryerConfig +): Retryer { + let isRetryCancelled = false + let failureCount = 0 + let isResolved = false + const promise = new Promise((outerResolve, outerReject) => { + promiseResolve = outerResolve + promiseReject = outerReject + }) + let continueFn: ((value?: unknown) => void) | undefined + let promiseResolve: (data: TData) => void + let promiseReject: (error: TError) => void + + const cancel = (cancelOptions?: CancelOptions): void => { + if (!isResolved) { + reject(new CancelledError(cancelOptions)) + + config.abort?.() } + } + const cancelRetry = () => { + isRetryCancelled = true + } - this.continueRetry = () => { - cancelRetry = false - } + const continueRetry = () => { + isRetryCancelled = false + } - const shouldPause = () => - !focusManager.isFocused() || - (config.networkMode !== 'always' && !onlineManager.isOnline()) + const shouldPause = () => + !focusManager.isFocused() || + (config.networkMode !== 'always' && !onlineManager.isOnline()) - this.continue = () => { + const resolve = (value: any) => { + if (!isResolved) { + isResolved = true + config.onSuccess?.(value) continueFn?.() + promiseResolve(value) } - this.failureCount = 0 - this.isPaused = false - this.isResolved = false - this.promise = new Promise((outerResolve, outerReject) => { - promiseResolve = outerResolve - promiseReject = outerReject - }) + } - const resolve = (value: any) => { - if (!this.isResolved) { - this.isResolved = true - config.onSuccess?.(value) - continueFn?.() - promiseResolve(value) - } + const reject = (value: any) => { + if (!isResolved) { + isResolved = true + config.onError?.(value) + continueFn?.() + promiseReject(value) } + } - const reject = (value: any) => { - if (!this.isResolved) { - this.isResolved = true - config.onError?.(value) - continueFn?.() - promiseReject(value) + const pause = () => { + return new Promise(continueResolve => { + continueFn = value => { + if (isResolved || !shouldPause()) { + return continueResolve(value) + } + } + config.onPause?.() + }).then(() => { + continueFn = undefined + if (!isResolved) { + config.onContinue?.() } + }) + } + + // Create loop function + const run = () => { + // Do nothing if already resolved + if (isResolved) { + return } - const pause = () => { - return new Promise(continueResolve => { - continueFn = value => { - if (this.isResolved || !shouldPause()) { - return continueResolve(value) - } - } - this.isPaused = true - config.onPause?.() - }).then(() => { - continueFn = undefined - this.isPaused = false - if (!this.isResolved) { - config.onContinue?.() - } - }) + let promiseOrValue: any + + // Execute query + try { + promiseOrValue = config.fn() + } catch (error) { + promiseOrValue = Promise.reject(error) } - // Create loop function - const run = () => { - // Do nothing if already resolved - if (this.isResolved) { - return - } + Promise.resolve(promiseOrValue) + .then(resolve) + .catch(error => { + // Stop if the fetch is already resolved + if (isResolved) { + return + } - let promiseOrValue: any + // Do we need to retry the request? + const retry = config.retry ?? 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 + } - // Execute query - try { - promiseOrValue = config.fn() - } catch (error) { - promiseOrValue = Promise.reject(error) - } + 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(() => { + if (shouldPause()) { + return pause() + } + }) + .then(() => { + if (isRetryCancelled) { + reject(error) + } else { + run() + } + }) + }) + } - Promise.resolve(promiseOrValue) - .then(resolve) - .catch(error => { - // Stop if the fetch is already resolved - if (this.isResolved) { - return - } - - // Do we need to retry the request? - const retry = config.retry ?? 3 - const retryDelay = config.retryDelay ?? defaultRetryDelay - const delay = - typeof retryDelay === 'function' - ? retryDelay(this.failureCount, error) - : retryDelay - const shouldRetry = - retry === true || - (typeof retry === 'number' && this.failureCount < retry) || - (typeof retry === 'function' && retry(this.failureCount, error)) - - if (cancelRetry || !shouldRetry) { - // We are done if the query does not need to be retried - reject(error) - return - } - - this.failureCount++ - - // Notify on fail - config.onFail?.(this.failureCount, error) - - // Delay - sleep(delay) - // Pause if the document is not visible or when the device is offline - .then(() => { - if (shouldPause()) { - return pause() - } - }) - .then(() => { - if (cancelRetry) { - reject(error) - } else { - run() - } - }) - }) - } + // Start loop + if (canFetch(config.networkMode)) { + run() + } else { + pause().then(run) + } - // Start loop - if (canFetch(config.networkMode)) { - run() - } else { - pause().then(run) - } + return { + promise, + cancel, + continue: () => { + continueFn?.() + }, + cancelRetry, + continueRetry, } } diff --git a/src/core/tests/notifyManager.test.tsx b/src/core/tests/notifyManager.test.tsx index 53816aa767..fc24c2c8c1 100644 --- a/src/core/tests/notifyManager.test.tsx +++ b/src/core/tests/notifyManager.test.tsx @@ -1,9 +1,9 @@ -import { NotifyManager } from '../notifyManager' +import { createNotifyManager } from '../notifyManager' import { sleep } from '../../devtools/tests/utils' describe('notifyManager', () => { it('should use default notifyFn', async () => { - const notifyManagerTest = new NotifyManager() + const notifyManagerTest = createNotifyManager() const callbackSpy = jest.fn() notifyManagerTest.schedule(callbackSpy) await sleep(1) @@ -11,7 +11,7 @@ describe('notifyManager', () => { }) it('should use default batchNotifyFn', async () => { - const notifyManagerTest = new NotifyManager() + const notifyManagerTest = createNotifyManager() const callbackScheduleSpy = jest .fn() .mockImplementation(async () => await sleep(20))