diff --git a/src/createAsyncStoragePersister/asyncThrottle.ts b/src/createAsyncStoragePersister/asyncThrottle.ts new file mode 100644 index 00000000000..6cb09888f50 --- /dev/null +++ b/src/createAsyncStoragePersister/asyncThrottle.ts @@ -0,0 +1,56 @@ +export interface AsyncThrottleOptions { + interval?: number + onError?: (error: unknown) => void +} + +const noop = () => { /* do nothing */ } + +export function asyncThrottle( + func: (...args: Args) => Promise, + { interval = 1000, onError = noop }: AsyncThrottleOptions = {} +) { + if (typeof func !== 'function') throw new Error('argument is not function.') + + let running = false + let lastTime = 0 + let timeout: ReturnType + let currentArgs: Args | null = null + + const execFunc = async () => { + if (currentArgs) { + const args = currentArgs + currentArgs = null + try { + running = true + await func(...args) + } catch (error) { + onError(error) + } finally { + lastTime = Date.now() // this line must after 'func' executed to avoid two 'func' running in concurrent. + running = false + } + } + } + + const delayFunc = async () => { + clearTimeout(timeout) + timeout = setTimeout(() => { + if (running) { + delayFunc() // Will come here when 'func' execution time is greater than the interval. + } else { + execFunc() + } + }, interval) + } + + return (...args: Args) => { + currentArgs = args + + const tooSoon = Date.now() - lastTime < interval + if (running || tooSoon) { + delayFunc() + } else { + execFunc() + } + } +} diff --git a/src/createAsyncStoragePersister/index.ts b/src/createAsyncStoragePersister/index.ts index 9a1b7b66771..57122af0be9 100644 --- a/src/createAsyncStoragePersister/index.ts +++ b/src/createAsyncStoragePersister/index.ts @@ -1,4 +1,5 @@ import { PersistedClient, Persister } from '../persistQueryClient' +import { asyncThrottle } from './asyncThrottle' interface AsyncStorage { getItem: (key: string) => Promise @@ -50,43 +51,3 @@ export const createAsyncStoragePersister = ({ removeClient: () => storage.removeItem(key), } } - -function asyncThrottle( - func: (...args: Args) => Promise, - { interval = 1000, limit = 1 }: { interval?: number; limit?: number } = {} -) { - if (typeof func !== 'function') throw new Error('argument is not function.') - const running = { current: false } - let lastTime = 0 - let timeout: ReturnType - const queue: Array = [] - return (...args: Args) => - (async () => { - if (running.current) { - lastTime = Date.now() - if (queue.length > limit) { - queue.shift() - } - - queue.push(args) - clearTimeout(timeout) - } - if (Date.now() - lastTime > interval) { - running.current = true - await func(...args) - lastTime = Date.now() - running.current = false - } else { - if (queue.length > 0) { - const lastArgs = queue[queue.length - 1]! - timeout = setTimeout(async () => { - if (!running.current) { - running.current = true - await func(...lastArgs) - running.current = false - } - }, interval) - } - } - })() -} diff --git a/src/createAsyncStoragePersister/tests/asyncThrottle.test.ts b/src/createAsyncStoragePersister/tests/asyncThrottle.test.ts new file mode 100644 index 00000000000..7bbb06871f9 --- /dev/null +++ b/src/createAsyncStoragePersister/tests/asyncThrottle.test.ts @@ -0,0 +1,127 @@ +import { asyncThrottle } from '../asyncThrottle' +import { sleep as delay } from '../../reactjs/tests/utils' + +describe('asyncThrottle', () => { + test('basic', async () => { + const interval = 10 + const execTimeStamps: number[] = [] + const mockFunc = jest.fn( + async (id: number, complete?: (value?: unknown) => void) => { + await delay(1) + execTimeStamps.push(Date.now()) + if (complete) { + complete(id) + } + } + ) + const testFunc = asyncThrottle(mockFunc, { interval }) + + testFunc(1) + await delay(1) + testFunc(2) + await delay(1) + await new Promise(resolve => testFunc(3, resolve)) + + expect(mockFunc).toBeCalledTimes(2) + expect(mockFunc.mock.calls[1]?.[0]).toBe(3) + expect(execTimeStamps.length).toBe(2) + expect(execTimeStamps[1]! - execTimeStamps[0]!).toBeGreaterThanOrEqual( + interval + ) + }) + + test('Bug #3331 case 1: Special timing', async () => { + const interval = 1000 + const execTimeStamps: number[] = [] + const mockFunc = jest.fn( + async (id: number, complete?: (value?: unknown) => void) => { + await delay(30) + execTimeStamps.push(Date.now()) + if (complete) { + complete(id) + } + } + ) + const testFunc = asyncThrottle(mockFunc, { interval }) + + testFunc(1) + testFunc(2) + await delay(35) + testFunc(3) + await delay(35) + await new Promise(resolve => testFunc(4, resolve)) + + expect(mockFunc).toBeCalledTimes(2) + expect(mockFunc.mock.calls[1]?.[0]).toBe(4) + expect(execTimeStamps.length).toBe(2) + expect(execTimeStamps[1]! - execTimeStamps[0]!).toBeGreaterThanOrEqual( + interval + ) + }) + + test('Bug #3331 case 2: "func" execution time is greater than the interval.', async () => { + const interval = 1000 + const execTimeStamps: number[] = [] + const mockFunc = jest.fn( + async (id: number, complete?: (value?: unknown) => void) => { + await delay(interval + 10) + execTimeStamps.push(Date.now()) + if (complete) { + complete(id) + } + } + ) + const testFunc = asyncThrottle(mockFunc, { interval }) + + testFunc(1) + testFunc(2) + await new Promise(resolve => testFunc(3, resolve)) + + expect(mockFunc).toBeCalledTimes(2) + expect(mockFunc.mock.calls[1]?.[0]).toBe(3) + expect(execTimeStamps.length).toBe(2) + expect(execTimeStamps[1]! - execTimeStamps[0]!).toBeGreaterThanOrEqual( + interval + ) + }) + + test('"func" throw error not break next invoke', async () => { + const mockFunc = jest.fn( + async (id: number, complete?: (value?: unknown) => void) => { + if (id === 1) throw new Error('error') + await delay(1) + if (complete) { + complete(id) + } + } + ) + const testFunc = asyncThrottle(mockFunc, { interval: 10 }) + + testFunc(1) + await delay(1) + await new Promise(resolve => testFunc(2, resolve)) + + expect(mockFunc).toBeCalledTimes(2) + expect(mockFunc.mock.calls[1]?.[0]).toBe(2) + }) + + test('"onError" should be called when "func" throw error', done => { + const err = new Error('error') + const handleError = (e: unknown) => { + expect(e).toBe(err) + done() + } + + const testFunc = asyncThrottle( + () => { + throw err + }, + { onError: handleError } + ) + testFunc() + }) + + test('should throw error when "func" is not a function', () => { + expect(() => asyncThrottle(1 as any)).toThrowError(); + }) +})