Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(createAsyncStoragePersister): persistClient not respect throttleTime and not persisted final state queryClient (#3331) #3336

Merged
merged 1 commit into from
Mar 1, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions src/createAsyncStoragePersister/asyncThrottle.ts
Original file line number Diff line number Diff line change
@@ -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 {
TkDodo marked this conversation as resolved.
Show resolved Hide resolved
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()
}
}
}
41 changes: 1 addition & 40 deletions src/createAsyncStoragePersister/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { PersistedClient, Persister } from '../persistQueryClient'
import { asyncThrottle } from './asyncThrottle'

interface AsyncStorage {
getItem: (key: string) => Promise<string | null>
Expand Down Expand Up @@ -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)
}
}
})()
}
127 changes: 127 additions & 0 deletions src/createAsyncStoragePersister/tests/asyncThrottle.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})