Commit 5c21dd98 authored by Zach Pomerantz's avatar Zach Pomerantz Committed by GitHub

fix: defer useInterval until callback resolves (#5096)

* fix: defer useInterval until callback resolves

* fix: avoid refs in useInterval
parent 2ce5990f
import useInterval from 'lib/hooks/useInterval' import useInterval from 'lib/hooks/useInterval'
import { useState } from 'react' import { useCallback, useState } from 'react'
const useMachineTimeMs = (updateInterval: number): number => { const useMachineTimeMs = (updateInterval: number): number => {
const [now, setNow] = useState(Date.now()) const [now, setNow] = useState(Date.now())
useInterval(() => { useInterval(
setNow(Date.now()) useCallback(() => {
}, updateInterval) setNow(Date.now())
}, []),
updateInterval
)
return now return now
} }
......
import { renderHook } from '@testing-library/react'
import useInterval from './useInterval'
describe('useInterval', () => {
const spy = jest.fn()
it('with no interval it does not run', () => {
renderHook(() => useInterval(spy, null))
expect(spy).toHaveBeenCalledTimes(0)
})
describe('with a synchronous function', () => {
it('it runs on an interval', () => {
jest.useFakeTimers()
renderHook(() => useInterval(spy, 100))
expect(spy).toHaveBeenCalledTimes(1)
jest.runTimersToTime(100)
expect(spy).toHaveBeenCalledTimes(2)
})
})
describe('with an async funtion', () => {
it('it runs on an interval exclusive of fn resolving', async () => {
jest.useFakeTimers()
spy.mockImplementation(() => Promise.resolve(undefined))
renderHook(() => useInterval(spy, 100))
expect(spy).toHaveBeenCalledTimes(1)
jest.runTimersToTime(100)
expect(spy).toHaveBeenCalledTimes(1)
await spy.mock.results[0].value
jest.runTimersToTime(100)
expect(spy).toHaveBeenCalledTimes(2)
})
})
})
import { useEffect, useRef } from 'react' import { useEffect } from 'react'
/** /**
* Invokes callback repeatedly over an interval defined by the delay * Invokes callback repeatedly over an interval defined by the delay
*
* @param callback * @param callback
* @param delay if null, the callback will not be invoked * @param delay if null, the callback will not be invoked
* @param leading if true, the callback will be invoked immediately (on the leading edge); otherwise, it will be invoked after delay * @param leading by default, the callback will be invoked immediately (on the leading edge);
* if false, the callback will not be invoked until a first delay
*/ */
export default function useInterval(callback: () => void, delay: null | number, leading = true) { export default function useInterval(callback: () => void | Promise<void>, delay: null | number, leading = true) {
const savedCallback = useRef<() => void>()
// Remember the latest callback.
useEffect(() => { useEffect(() => {
savedCallback.current = callback if (delay === null) {
}, [callback]) return
}
// Set up the interval. let timeout: ReturnType<typeof setTimeout>
useEffect(() => { tick(delay, /* skip= */ !leading)
function tick() { return () => {
const { current } = savedCallback if (timeout) {
current && current() clearInterval(timeout)
}
} }
if (delay !== null) { async function tick(delay: number, skip = false) {
if (leading) tick() if (!skip) {
const id = setInterval(tick, delay) const promise = callback()
return () => clearInterval(id)
// Defer the next interval until the current callback has resolved.
if (promise) await promise
}
timeout = setTimeout(() => tick(delay), delay)
} }
return }, [callback, delay, leading])
}, [delay, leading])
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment