Skip to content

Commit 338da80

Browse files
authored
fix(createAsyncStoragePersister): persistClient respects throttleTime (#3331) (#3336)
1 parent 9d260e7 commit 338da80

File tree

3 files changed

+186
-40
lines changed

3 files changed

+186
-40
lines changed
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
export interface AsyncThrottleOptions {
2+
interval?: number
3+
onError?: (error: unknown) => void
4+
}
5+
6+
const noop = () => {
7+
/* do nothing */
8+
}
9+
10+
export function asyncThrottle<Args extends readonly unknown[]>(
11+
func: (...args: Args) => Promise<void>,
12+
{ interval = 1000, onError = noop }: AsyncThrottleOptions = {}
13+
) {
14+
if (typeof func !== 'function') throw new Error('argument is not function.')
15+
16+
let running = false
17+
let lastTime = 0
18+
let timeout: ReturnType<typeof setTimeout>
19+
let currentArgs: Args | null = null
20+
21+
const execFunc = async () => {
22+
if (currentArgs) {
23+
const args = currentArgs
24+
currentArgs = null
25+
try {
26+
running = true
27+
await func(...args)
28+
} catch (error) {
29+
onError(error)
30+
} finally {
31+
lastTime = Date.now() // this line must after 'func' executed to avoid two 'func' running in concurrent.
32+
running = false
33+
}
34+
}
35+
}
36+
37+
const delayFunc = async () => {
38+
clearTimeout(timeout)
39+
timeout = setTimeout(() => {
40+
if (running) {
41+
delayFunc() // Will come here when 'func' execution time is greater than the interval.
42+
} else {
43+
execFunc()
44+
}
45+
}, interval)
46+
}
47+
48+
return (...args: Args) => {
49+
currentArgs = args
50+
51+
const tooSoon = Date.now() - lastTime < interval
52+
if (running || tooSoon) {
53+
delayFunc()
54+
} else {
55+
execFunc()
56+
}
57+
}
58+
}

src/createAsyncStoragePersister/index.ts

Lines changed: 1 addition & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { PersistedClient, Persister } from '../persistQueryClient'
2+
import { asyncThrottle } from './asyncThrottle'
23

34
interface AsyncStorage {
45
getItem: (key: string) => Promise<string | null>
@@ -50,43 +51,3 @@ export const createAsyncStoragePersister = ({
5051
removeClient: () => storage.removeItem(key),
5152
}
5253
}
53-
54-
function asyncThrottle<Args extends readonly unknown[], Result>(
55-
func: (...args: Args) => Promise<Result>,
56-
{ interval = 1000, limit = 1 }: { interval?: number; limit?: number } = {}
57-
) {
58-
if (typeof func !== 'function') throw new Error('argument is not function.')
59-
const running = { current: false }
60-
let lastTime = 0
61-
let timeout: ReturnType<typeof setTimeout>
62-
const queue: Array<Args> = []
63-
return (...args: Args) =>
64-
(async () => {
65-
if (running.current) {
66-
lastTime = Date.now()
67-
if (queue.length > limit) {
68-
queue.shift()
69-
}
70-
71-
queue.push(args)
72-
clearTimeout(timeout)
73-
}
74-
if (Date.now() - lastTime > interval) {
75-
running.current = true
76-
await func(...args)
77-
lastTime = Date.now()
78-
running.current = false
79-
} else {
80-
if (queue.length > 0) {
81-
const lastArgs = queue[queue.length - 1]!
82-
timeout = setTimeout(async () => {
83-
if (!running.current) {
84-
running.current = true
85-
await func(...lastArgs)
86-
running.current = false
87-
}
88-
}, interval)
89-
}
90-
}
91-
})()
92-
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { asyncThrottle } from '../asyncThrottle'
2+
import { sleep as delay } from '../../reactjs/tests/utils'
3+
4+
describe('asyncThrottle', () => {
5+
test('basic', async () => {
6+
const interval = 10
7+
const execTimeStamps: number[] = []
8+
const mockFunc = jest.fn(
9+
async (id: number, complete?: (value?: unknown) => void) => {
10+
await delay(1)
11+
execTimeStamps.push(Date.now())
12+
if (complete) {
13+
complete(id)
14+
}
15+
}
16+
)
17+
const testFunc = asyncThrottle(mockFunc, { interval })
18+
19+
testFunc(1)
20+
await delay(1)
21+
testFunc(2)
22+
await delay(1)
23+
await new Promise(resolve => testFunc(3, resolve))
24+
25+
expect(mockFunc).toBeCalledTimes(2)
26+
expect(mockFunc.mock.calls[1]?.[0]).toBe(3)
27+
expect(execTimeStamps.length).toBe(2)
28+
expect(execTimeStamps[1]! - execTimeStamps[0]!).toBeGreaterThanOrEqual(
29+
interval
30+
)
31+
})
32+
33+
test('Bug #3331 case 1: Special timing', async () => {
34+
const interval = 1000
35+
const execTimeStamps: number[] = []
36+
const mockFunc = jest.fn(
37+
async (id: number, complete?: (value?: unknown) => void) => {
38+
await delay(30)
39+
execTimeStamps.push(Date.now())
40+
if (complete) {
41+
complete(id)
42+
}
43+
}
44+
)
45+
const testFunc = asyncThrottle(mockFunc, { interval })
46+
47+
testFunc(1)
48+
testFunc(2)
49+
await delay(35)
50+
testFunc(3)
51+
await delay(35)
52+
await new Promise(resolve => testFunc(4, resolve))
53+
54+
expect(mockFunc).toBeCalledTimes(2)
55+
expect(mockFunc.mock.calls[1]?.[0]).toBe(4)
56+
expect(execTimeStamps.length).toBe(2)
57+
expect(execTimeStamps[1]! - execTimeStamps[0]!).toBeGreaterThanOrEqual(
58+
interval
59+
)
60+
})
61+
62+
test('Bug #3331 case 2: "func" execution time is greater than the interval.', async () => {
63+
const interval = 1000
64+
const execTimeStamps: number[] = []
65+
const mockFunc = jest.fn(
66+
async (id: number, complete?: (value?: unknown) => void) => {
67+
await delay(interval + 10)
68+
execTimeStamps.push(Date.now())
69+
if (complete) {
70+
complete(id)
71+
}
72+
}
73+
)
74+
const testFunc = asyncThrottle(mockFunc, { interval })
75+
76+
testFunc(1)
77+
testFunc(2)
78+
await new Promise(resolve => testFunc(3, resolve))
79+
80+
expect(mockFunc).toBeCalledTimes(2)
81+
expect(mockFunc.mock.calls[1]?.[0]).toBe(3)
82+
expect(execTimeStamps.length).toBe(2)
83+
expect(execTimeStamps[1]! - execTimeStamps[0]!).toBeGreaterThanOrEqual(
84+
interval
85+
)
86+
})
87+
88+
test('"func" throw error not break next invoke', async () => {
89+
const mockFunc = jest.fn(
90+
async (id: number, complete?: (value?: unknown) => void) => {
91+
if (id === 1) throw new Error('error')
92+
await delay(1)
93+
if (complete) {
94+
complete(id)
95+
}
96+
}
97+
)
98+
const testFunc = asyncThrottle(mockFunc, { interval: 10 })
99+
100+
testFunc(1)
101+
await delay(1)
102+
await new Promise(resolve => testFunc(2, resolve))
103+
104+
expect(mockFunc).toBeCalledTimes(2)
105+
expect(mockFunc.mock.calls[1]?.[0]).toBe(2)
106+
})
107+
108+
test('"onError" should be called when "func" throw error', done => {
109+
const err = new Error('error')
110+
const handleError = (e: unknown) => {
111+
expect(e).toBe(err)
112+
done()
113+
}
114+
115+
const testFunc = asyncThrottle(
116+
() => {
117+
throw err
118+
},
119+
{ onError: handleError }
120+
)
121+
testFunc()
122+
})
123+
124+
test('should throw error when "func" is not a function', () => {
125+
expect(() => asyncThrottle(1 as any)).toThrowError()
126+
})
127+
})

0 commit comments

Comments
 (0)