diff --git a/packages/react-hooks/src/useAsyncInterval.test.ts b/packages/react-hooks/src/useAsyncInterval.test.ts index 95db744678..d33e248914 100644 --- a/packages/react-hooks/src/useAsyncInterval.test.ts +++ b/packages/react-hooks/src/useAsyncInterval.test.ts @@ -1,9 +1,24 @@ import { renderHook, act } from '@testing-library/react-hooks'; +import Log from '@deephaven/log'; import { TestUtils } from '@deephaven/utils'; import useAsyncInterval from './useAsyncInterval'; +jest.mock('@deephaven/log', () => { + const logger = { + error: jest.fn(), + }; + return { + __esModule: true, + default: { + module: jest.fn(() => logger), + }, + }; +}); + const { asMock } = TestUtils; +const mockLoggerInstance = Log.module('mock.logger'); + beforeEach(() => { jest.clearAllMocks(); expect.hasAssertions(); @@ -16,12 +31,22 @@ afterAll(() => { }); describe('useAsyncInterval', () => { - function createCallback(ms: number) { + /** + * Creates a callback function that resolves after the given number of + * milliseconds. Accepts an optional array of booleans that determine whether + * calls to the callback will reject instead of resolve. They are mapped by + * index to the order in which the callback is called. + */ + function createCallback(ms: number, rejectWith: (Error | undefined)[] = []) { return jest .fn( async (): Promise => - new Promise(resolve => { - setTimeout(resolve, ms); + new Promise((resolve, reject) => { + const rejectArg = rejectWith.shift(); + setTimeout( + rejectArg == null ? resolve : () => reject(rejectArg), + ms + ); // Don't track the above call to `setTimeout` asMock(setTimeout).mock.calls.pop(); @@ -155,4 +180,29 @@ describe('useAsyncInterval', () => { expect(window.setTimeout).not.toHaveBeenCalled(); }); + + it('should handle tick errors', async () => { + const callbackDelayMs = 50; + const mockError = new Error('mock.error'); + const callback = createCallback(callbackDelayMs, [mockError, undefined]); + + renderHook(() => useAsyncInterval(callback, targetIntervalMs)); + + // First callback fires immediately + expect(callback).toHaveBeenCalledTimes(1); + + // Mimick the callback Promise rejecting + act(() => jest.advanceTimersByTime(callbackDelayMs)); + await TestUtils.flushPromises(); + + expect(mockLoggerInstance.error).toHaveBeenCalledWith( + 'A tick error occurred:', + mockError + ); + + // Advance to next interval + act(() => jest.advanceTimersByTime(targetIntervalMs)); + + expect(callback).toHaveBeenCalledTimes(2); + }); }); diff --git a/packages/react-hooks/src/useAsyncInterval.ts b/packages/react-hooks/src/useAsyncInterval.ts index 84a772dd1d..a40b0cae4a 100644 --- a/packages/react-hooks/src/useAsyncInterval.ts +++ b/packages/react-hooks/src/useAsyncInterval.ts @@ -1,6 +1,9 @@ import { useCallback, useEffect, useRef } from 'react'; +import Log from '@deephaven/log'; import { useIsMountedRef } from './useIsMountedRef'; +const log = Log.module('useAsyncInterval'); + /** * Calls the given async callback at a target interval. * @@ -40,7 +43,11 @@ export function useAsyncInterval( trackingStartedRef.current = now; - await callback(); + try { + await callback(); + } catch (err) { + log.error('A tick error occurred:', err); + } if (!isMountedRef.current) { return;