Skip to content

Commit 96b27a5

Browse files
authored
fix: Heap usage should tick immediately when dependencies change (#1468)
fixes #1464 - Heap usage checks now run immediately whenever useEffect dependencies change.
1 parent 08d0296 commit 96b27a5

2 files changed

Lines changed: 79 additions & 45 deletions

File tree

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

Lines changed: 51 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,50 @@ 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);
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);
3038

31-
renderHook(() => useAsyncInterval(callback, targetIntervalMs));
32-
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+
// Should not call callback if depedencies don't change
51+
rerender([callbackA, targetIntervalMs]);
52+
expect(callbackA).not.toHaveBeenCalled();
53+
jest.clearAllMocks();
54+
55+
// Callback change
56+
rerender([callbackB, targetIntervalMs]);
57+
expect(callbackA).not.toHaveBeenCalled();
58+
expect(callbackB).toHaveBeenCalledTimes(1);
59+
jest.clearAllMocks();
60+
61+
// Interval change
62+
rerender([callbackB, targetIntervalMs + 20]);
63+
expect(callbackB).toHaveBeenCalledTimes(1);
4364
});
4465

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

4970
renderHook(() => useAsyncInterval(callback, targetIntervalMs));
5071

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

5876
// Mimick the callback Promise resolving
@@ -79,11 +97,7 @@ describe('useAsyncInterval', () => {
7997

8098
renderHook(() => useAsyncInterval(callback, targetIntervalMs));
8199

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

89103
// Mimick the callback Promise resolving
@@ -99,12 +113,22 @@ describe('useAsyncInterval', () => {
99113
});
100114

101115
it('should stop calling the callback function after unmounting', async () => {
102-
const callback = createCallback(50);
116+
const callbackDelayMs = 50;
117+
const callback = createCallback(callbackDelayMs);
103118

104119
const { unmount } = renderHook(() =>
105120
useAsyncInterval(callback, targetIntervalMs)
106121
);
107122

123+
expect(callback).toHaveBeenCalledTimes(1);
124+
jest.clearAllMocks();
125+
126+
// Mimick the callback Promise resolving
127+
act(() => jest.advanceTimersByTime(callbackDelayMs));
128+
await TestUtils.flushPromises();
129+
130+
expect(window.setTimeout).toHaveBeenCalledTimes(1);
131+
108132
unmount();
109133

110134
act(() => jest.advanceTimersByTime(targetIntervalMs));
@@ -120,7 +144,6 @@ describe('useAsyncInterval', () => {
120144
useAsyncInterval(callback, targetIntervalMs)
121145
);
122146

123-
act(() => jest.advanceTimersByTime(targetIntervalMs));
124147
expect(callback).toHaveBeenCalledTimes(1);
125148
jest.clearAllMocks();
126149

packages/react-hooks/src/useAsyncInterval.ts

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -26,48 +26,59 @@ export function useAsyncInterval(
2626
targetIntervalMs: number
2727
) {
2828
const isMountedRef = useIsMountedRef();
29-
const trackingRef = useRef({ count: 0, started: Date.now() });
3029
const setTimeoutRef = useRef(0);
30+
const trackingCountRef = useRef(0);
31+
const trackingStartedRef = useRef<number | null>(null);
3132

3233
const tick = useCallback(async () => {
3334
const now = Date.now();
34-
let elapsedSinceLastTick = now - trackingRef.current.started;
35+
trackingCountRef.current += 1;
3536

36-
trackingRef.current.count += 1;
37-
trackingRef.current.started = now;
37+
// If this is our first tick, treat it as if we've already waited the full
38+
// interval, otherwise calculate the elapsed time since the last tick
39+
let elapsedSinceLastTick =
40+
trackingStartedRef.current == null
41+
? targetIntervalMs
42+
: now - trackingStartedRef.current;
3843

3944
log.debug(
40-
`tick #${trackingRef.current.count}.`,
45+
`tick #${trackingCountRef.current}.`,
4146
elapsedSinceLastTick,
4247
'ms elapsed since last tick.'
4348
);
4449

50+
trackingStartedRef.current = now;
51+
4552
await callback();
4653

4754
if (!isMountedRef.current) {
4855
return;
4956
}
5057

51-
elapsedSinceLastTick += Date.now() - trackingRef.current.started;
58+
elapsedSinceLastTick += Date.now() - trackingStartedRef.current;
5259

53-
// If elapsed time is > than the target interval, adjust the next tick interval
54-
const nextTickInterval = Math.max(
55-
0,
56-
Math.min(
57-
targetIntervalMs,
58-
targetIntervalMs - (elapsedSinceLastTick - targetIntervalMs)
59-
)
60-
);
60+
// Calculate any elapsed time beyond the target interval.
61+
const overage = Math.max(0, elapsedSinceLastTick - targetIntervalMs);
62+
63+
const nextTickInterval = Math.max(0, targetIntervalMs - overage);
6164

62-
log.debug('adjusted minIntervalMs:', nextTickInterval);
65+
log.debug(
66+
'Next tick target:',
67+
targetIntervalMs,
68+
', overage',
69+
overage,
70+
', adjusted:',
71+
nextTickInterval
72+
);
6373

6474
setTimeoutRef.current = window.setTimeout(tick, nextTickInterval);
6575
}, [callback, isMountedRef, targetIntervalMs]);
6676

6777
useEffect(() => {
68-
log.debug('Setting interval minIntervalMs:', targetIntervalMs);
78+
log.debug('Setting target interval:', targetIntervalMs);
6979

70-
setTimeoutRef.current = window.setTimeout(tick, targetIntervalMs);
80+
trackingStartedRef.current = null;
81+
tick();
7182

7283
return () => {
7384
window.clearTimeout(setTimeoutRef.current);

0 commit comments

Comments
 (0)