Skip to content

Commit 0d5afb1

Browse files
committed
tick immediately when dependencies change
#1464
1 parent 6ff27a6 commit 0d5afb1

3 files changed

Lines changed: 75 additions & 41 deletions

File tree

packages/react-hooks/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"dependencies": {
2424
"@deephaven/log": "file:../log",
2525
"@deephaven/utils": "file:../utils",
26+
"lodash.clamp": "^4.0.3",
2627
"shortid": "^2.2.16"
2728
},
2829
"peerDependencies": {

packages/react-hooks/src/useAsyncInterval.test.ts

Lines changed: 44 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { renderHook, act } from '@testing-library/react-hooks';
22
import { TestUtils } from '@deephaven/utils';
33
import useAsyncInterval from './useAsyncInterval';
44

5+
const { asMock } = TestUtils;
6+
57
beforeEach(() => {
68
jest.clearAllMocks();
79
expect.hasAssertions();
@@ -15,31 +17,43 @@ afterAll(() => {
1517

1618
describe('useAsyncInterval', () => {
1719
function createCallback(ms: number) {
18-
return jest.fn(
19-
async (): Promise<void> =>
20-
new Promise(resolve => {
21-
setTimeout(resolve, ms);
22-
})
23-
);
20+
return jest
21+
.fn(
22+
async (): Promise<void> =>
23+
new Promise(resolve => {
24+
setTimeout(resolve, ms);
25+
26+
// Don't track the above call to `setTimeout`
27+
asMock(setTimeout).mock.calls.pop();
28+
})
29+
)
30+
.mockName('callback');
2431
}
2532

2633
const targetIntervalMs = 1000;
2734

28-
it('should call the callback function after the target interval', async () => {
29-
const callback = createCallback(50);
30-
31-
renderHook(() => useAsyncInterval(callback, targetIntervalMs));
35+
it('should call the callback function immediately any time the callback or target interval changes', () => {
36+
const callbackA = createCallback(50);
37+
const callbackB = createCallback(50);
3238

33-
// First tick should be scheduled for target interval
34-
expect(callback).not.toHaveBeenCalled();
35-
expect(window.setTimeout).toHaveBeenCalledWith(
36-
expect.any(Function),
37-
targetIntervalMs
39+
const { rerender } = renderHook(
40+
([cb, target]: [() => Promise<void>, number]) =>
41+
useAsyncInterval(cb, target),
42+
{
43+
initialProps: [callbackA, targetIntervalMs],
44+
}
3845
);
3946

40-
// Callback should be called after target interval
41-
act(() => jest.advanceTimersByTime(targetIntervalMs));
42-
expect(callback).toHaveBeenCalledTimes(1);
47+
expect(callbackA).toHaveBeenCalledTimes(1);
48+
jest.clearAllMocks();
49+
50+
rerender([callbackB, targetIntervalMs]);
51+
expect(callbackA).not.toHaveBeenCalled();
52+
expect(callbackB).toHaveBeenCalledTimes(1);
53+
jest.clearAllMocks();
54+
55+
rerender([callbackB, targetIntervalMs + 20]);
56+
expect(callbackB).toHaveBeenCalledTimes(1);
4357
});
4458

4559
it('should adjust the target interval based on how long async call takes', async () => {
@@ -48,11 +62,8 @@ describe('useAsyncInterval', () => {
4862

4963
renderHook(() => useAsyncInterval(callback, targetIntervalMs));
5064

51-
// Callback should be called after target interval
52-
expect(callback).not.toHaveBeenCalled();
53-
act(() => jest.advanceTimersByTime(targetIntervalMs));
5465
expect(callback).toHaveBeenCalledTimes(1);
55-
66+
expect(window.setTimeout).not.toHaveBeenCalled();
5667
jest.clearAllMocks();
5768

5869
// Mimick the callback Promise resolving
@@ -79,11 +90,7 @@ describe('useAsyncInterval', () => {
7990

8091
renderHook(() => useAsyncInterval(callback, targetIntervalMs));
8192

82-
// Callback should be called after target interval
83-
expect(callback).not.toHaveBeenCalled();
84-
act(() => jest.advanceTimersByTime(targetIntervalMs));
8593
expect(callback).toHaveBeenCalledTimes(1);
86-
8794
jest.clearAllMocks();
8895

8996
// Mimick the callback Promise resolving
@@ -99,12 +106,22 @@ describe('useAsyncInterval', () => {
99106
});
100107

101108
it('should stop calling the callback function after unmounting', async () => {
102-
const callback = createCallback(50);
109+
const callbackDelayMs = 50;
110+
const callback = createCallback(callbackDelayMs);
103111

104112
const { unmount } = renderHook(() =>
105113
useAsyncInterval(callback, targetIntervalMs)
106114
);
107115

116+
expect(callback).toHaveBeenCalledTimes(1);
117+
jest.clearAllMocks();
118+
119+
// Mimick the callback Promise resolving
120+
act(() => jest.advanceTimersByTime(callbackDelayMs));
121+
await TestUtils.flushPromises();
122+
123+
expect(window.setTimeout).toHaveBeenCalledTimes(1);
124+
108125
unmount();
109126

110127
act(() => jest.advanceTimersByTime(targetIntervalMs));
@@ -120,7 +137,6 @@ describe('useAsyncInterval', () => {
120137
useAsyncInterval(callback, targetIntervalMs)
121138
);
122139

123-
act(() => jest.advanceTimersByTime(targetIntervalMs));
124140
expect(callback).toHaveBeenCalledTimes(1);
125141
jest.clearAllMocks();
126142

packages/react-hooks/src/useAsyncInterval.ts

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useCallback, useEffect, useRef } from 'react';
22
import Log from '@deephaven/log';
3+
import clamp from 'lodash.clamp';
34
import { useIsMountedRef } from './useIsMountedRef';
45

56
const log = Log.module('useAsyncInterval');
@@ -26,22 +27,31 @@ export function useAsyncInterval(
2627
targetIntervalMs: number
2728
) {
2829
const isMountedRef = useIsMountedRef();
29-
const trackingRef = useRef({ count: 0, started: Date.now() });
3030
const setTimeoutRef = useRef(0);
31+
const trackingRef = useRef({
32+
count: 0,
33+
started: null as number | null,
34+
});
3135

3236
const tick = useCallback(async () => {
3337
const now = Date.now();
34-
let elapsedSinceLastTick = now - trackingRef.current.started;
35-
3638
trackingRef.current.count += 1;
37-
trackingRef.current.started = now;
39+
40+
// If this is our first tick, treat it as if we've already waited the full,
41+
// interval, otherwise calculate the elapsed time since the last tick
42+
let elapsedSinceLastTick =
43+
trackingRef.current.started == null
44+
? targetIntervalMs
45+
: now - trackingRef.current.started;
3846

3947
log.debug(
4048
`tick #${trackingRef.current.count}.`,
4149
elapsedSinceLastTick,
4250
'ms elapsed since last tick.'
4351
);
4452

53+
trackingRef.current.started = now;
54+
4555
await callback();
4656

4757
if (!isMountedRef.current) {
@@ -50,24 +60,31 @@ export function useAsyncInterval(
5060

5161
elapsedSinceLastTick += Date.now() - trackingRef.current.started;
5262

53-
// If elapsed time is > than the target interval, adjust the next tick interval
54-
const nextTickInterval = Math.max(
63+
const overage = clamp(
64+
elapsedSinceLastTick - targetIntervalMs,
5565
0,
56-
Math.min(
57-
targetIntervalMs,
58-
targetIntervalMs - (elapsedSinceLastTick - targetIntervalMs)
59-
)
66+
targetIntervalMs
6067
);
6168

62-
log.debug('adjusted minIntervalMs:', nextTickInterval);
69+
const nextTickInterval = targetIntervalMs - overage;
70+
71+
log.debug(
72+
'Next tick target:',
73+
targetIntervalMs,
74+
', overage:',
75+
overage,
76+
', adjusted:',
77+
nextTickInterval
78+
);
6379

6480
setTimeoutRef.current = window.setTimeout(tick, nextTickInterval);
6581
}, [callback, isMountedRef, targetIntervalMs]);
6682

6783
useEffect(() => {
68-
log.debug('Setting interval minIntervalMs:', targetIntervalMs);
84+
log.debug('Setting target interval:', targetIntervalMs);
6985

70-
setTimeoutRef.current = window.setTimeout(tick, targetIntervalMs);
86+
trackingRef.current.started = null;
87+
tick();
7188

7289
return () => {
7390
window.clearTimeout(setTimeoutRef.current);

0 commit comments

Comments
 (0)