Repository: tannerlinsley/react-query (ts)

Issue:
createAsyncStoragePersistor's persistClient not respect throttleTime and not persisted final state queryClient 
### Describe the bug

The persistClient of createAsyncStoragePersistor does not execute according to the throttleTime and not persisted final state queryClient when make many queries to refetch. To be precise, there is a problem with the function [asyncThrottle](https://github.com/tannerlinsley/react-query/blob/aa853d826d6776e073b9f5dbdd38e5ffa27a52bb/src/createAsyncStoragePersister/index.ts#L54).

### Your minimal, reproducible example

https://www.typescriptlang.org/play?#code/FAYw9gdgzgLgBDAprAjHAvHAhlAnhEAFQAsAnMGGAG0QAod8Q5aBLAEwC44IBXAWwBGiUgEoMAPjgBvYHDjhoYGgDoqYAOa0ABiByI4aVpzgASKewC+I5QhZ9kMLHwAOXMwBEsSZRDAB3WhELLREAbllsPywWeDZEKixcWgBmAAYw4AsAGmkIlggkUgA3LCouFFTKrMyM0EhYBAcAJgxsPAISckoaenamIy5eQWExdEkZOQUoJURVDW1dKH0WgdNzNisbGDsHJ1c1z29fAKCQ8LksKJi4OISkpsr00LgAehf5MAbt+zgBFnV1MIEMQsBA4PlCiUqJkchNwQVhFDyo9qlZwsA3nAAMJUFggADWcAA5AAlHgQIm-HiUSAIMDyYiIAlwMDU5zUuBqdRQDjADHvRb6FC8zEAUQAHs4mfAuQg-PTcRBkFT4OoKHAAGas0jYCBsTk4eDscFQeHgUikaXKYC9RjMUbjCJIVC0FAZOTOmAoWhNd2RaKxeKJFIAVj9nu9yT9lwDNyDSWSYfOjRdABYMlZAujMYK4E0Re8JVKQDKNHKFfllQIOWr4JB9KD9QkGsaWKb8ubLSXrbaCPaJLkPc1XeHh77wpnagw+xrySWWLTbsG+DzuPwhKJB3J-dclX44AAFch8Nt0WiW6ZUIqIB1b7fbpYwQg7Vkwc-IJTXnIrv1yNGZPl+TgYhKGcHk3nUGJiB4ARlHAPgXkcCAlVIRUoBoXAXktLASwAWgARx4YRMIENQBBeLAsAADhDZI2CopoADY2EYgB2VjGMQVJWOSAQAE4NRDNgBDYNhkioxAQw1DUsCaVisBDJoBHIqBSBAF4QGwpAAEE+gAZRgMBSCwQED2EKA20KF58jicVlBgKAAGIABkQ1TYBZwIbZaWnIgyAoahEAAHm00huTgRBxSQPVTWwthICoXA4HJfFjggABtABdHISWQHgqBgcRaAiTyQC4WhlEqrAwtXULuVvI8wBPJYgtyqB8sK6o5CkeFIVKVoKiqTk7GuTA0AsLgeohRFSgAfkGddhGeXETxgea12GHULFaKQLGAMQ4RYDVmBgXApTAY7SrgABCdBMCJUrvIpMQYH8-c9zgUULSM2giWq9R+EQAoTW4dVHoXCBlCJEQIimeBSHJCB8nUHb5B4C0gZgLgZKoJY4D2uQaBlQ1nx+TBUgiInbHsV8uFymB0YgQgzuC07zuOx9ScQV9xFh+p4EIxAiK4ULjNwEKaskTAsoiS0GdIMEKqqmqRZqh0IjkXt+lvOF7yO5gEeQ5G4PRy0CgOjX7wNWAudaQ5ZmOQJLb147aEFojVCB9RXrgSQVpiC2rat93ZigYgjrfGGg7-Plo7gEPlHZMP6DV53txAGhqq519aG+bnqSjq2Cat-XaHtnx-ECOBcOtp8dl93qZqoQPo8NpGIHUE2MeBzAYARxA04uK54FKpXlH+qBC6D5s67JuBy8dqerbb42QFNzHWhxpY0+2+I8d1kvXYTmgO59yR0jvaO4drurTUwEP0uPr2fZrlBMuuwftzz19Wk5l9qS1v2MYl846l2uivDuXczYwBbnHe8EDO5r27vAXu-dP5BxjNcUelVVCGlvkvOBcAEFQI3pgLeA9CEx0IdkRuxRSgEO3MXe8TD8YiCdhYIAA

### Steps to reproduce

Just run the code on the TypeScript playground.
1. Click 'Run' button on the left panel.
2. Click  'Logs'  button on the right panel.
3. Check output logs.


### Expected behavior

Case1: Expect log two lines and print maximum id but got four lines and not print maximum id.
Case2: Expect log two lines and print maximum id but got one line and not print maximum id.

### How often does this bug happen?

_No response_

### Screenshots or Videos

_No response_

### Platform

Only react native.

### react-query version

v4.0.0-alpha.14 and v3.34.16

### TypeScript version

_No response_

### Additional context

_No response_

Pull request description:
fix(createAsyncStoragePersister): persistClient not respect throttleTime and not persisted final state queryClient (#3331)
closes #3331

fix(createAsyncStoragePersister): persistClient not respect throttleTime and not persisted final state queryClient (#3331).
And add unit test for asyncThrottle.

Patch:
diff --git a/src/createAsyncStoragePersister/asyncThrottle.ts b/src/createAsyncStoragePersister/asyncThrottle.ts
new file mode 100644
index 000000000..940f03df6
--- /dev/null
+++ b/src/createAsyncStoragePersister/asyncThrottle.ts
@@ -0,0 +1,58 @@
+export interface AsyncThrottleOptions {
+  interval?: number
+  onError?: (error: unknown) => void
+}
+
+const noop = () => {
+  /* do nothing */
+}
+
+export function asyncThrottle<Args extends readonly unknown[]>(
+  func: (...args: Args) => Promise<void>,
+  { 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<typeof setTimeout>
+  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 9a1b7b667..57122af0b 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<string | null>
@@ -50,43 +51,3 @@ export const createAsyncStoragePersister = ({
     removeClient: () => storage.removeItem(key),
   }
 }
-
-function asyncThrottle<Args extends readonly unknown[], Result>(
-  func: (...args: Args) => Promise<Result>,
-  { 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<typeof setTimeout>
-  const queue: Array<Args> = []
-  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)
-        }
-      }
-    })()
-}


Test patch:
diff --git a/src/createAsyncStoragePersister/tests/asyncThrottle.test.ts b/src/createAsyncStoragePersister/tests/asyncThrottle.test.ts
new file mode 100644
index 000000000..54f7ee3dd
--- /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()
+  })
+})