Skip to content

Commit 2708964

Browse files
authored
feat: get betterSetInterval ready for 2025 (#496)
Historically, functions scheduled with `betterSetInterval` needed to accept a callback and then call it when done, to schedule their next invocation. This was because Promises were not a thing in 2015 when this function was originally written. It's silly to have this requirement now, almost ten years later, when async/await and Promises are a normal feature. This change adds support for scheduling any function, sync or async, accepting or not accepting a callback, and having it just work. ```js betterSetInterval(async () => { await myAsyncOperation(); }, 1000); ``` For backwards compatibility reasons, a dummy callback is still passed to the invoked function as its first argument, otherwise the functions that don't expect this change would break.
1 parent 27e9b1e commit 2708964

File tree

2 files changed

+72
-10
lines changed

2 files changed

+72
-10
lines changed

packages/utilities/src/utilities.ts

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -117,24 +117,27 @@ export type BetterIntervalID = { _betterClearInterval: () => void };
117117
* Similar to setInterval() but with two important differences:
118118
* First, it assumes the function is asynchronous and only schedules its next invocation AFTER the asynchronous function finished.
119119
* Second, it invokes the function immediately.
120-
* @param func Asynchronous function to be periodically executed. It must take a single argument with a callback that
121-
* the function must invoke after it's done.
122-
* @param delay The number of milliseconds to wait to next invocation of the function.
120+
* @param func Function to be periodically executed.
121+
* For backwards compatibility reasons, it is passed a callback as its first argument during invocation, however that callback has no effect.
122+
* @param delay The number of milliseconds to wait to next invocation of the function after the current invocation finishes.
123123
* @returns Object that can be passed to betterClearInterval()
124124
*/
125-
export function betterSetInterval(func: (a: (...args: unknown[]) => unknown) => void, delay: number): BetterIntervalID {
126-
let callback: (...a: unknown[]) => unknown;
127-
let timeoutId: number;
125+
export function betterSetInterval(func: ((a: (...args: unknown[]) => unknown) => void) | ((...args: unknown[]) => unknown), delay: number): BetterIntervalID {
126+
let scheduleNextRun: () => void;
127+
let timeoutId: NodeJS.Timeout;
128128
let isRunning = true;
129+
129130
const funcWrapper = function () {
130-
func(callback);
131+
// Historically, the function was passed a callback that it needed to call to signal it was done.
132+
// We keep passing this callback for backwards compatibility, but it has no effect anymore.
133+
void new Promise((resolve) => resolve(func(() => undefined))).finally(scheduleNextRun);
131134
};
132-
callback = function () {
133-
if (isRunning) timeoutId = setTimeout(funcWrapper, delay) as unknown as number;
135+
scheduleNextRun = function () {
136+
if (isRunning) timeoutId = setTimeout(funcWrapper, delay);
134137
};
135138
funcWrapper();
136-
return {
137139

140+
return {
138141
_betterClearInterval() {
139142
isRunning = false;
140143
clearTimeout(timeoutId);

test/utilities.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,3 +342,62 @@ describe('timeoutPromise()', () => {
342342
});
343343
});
344344
});
345+
346+
describe('BetterSetInterval', () => {
347+
it('works with normal function', async () => {
348+
const fn = jest.fn();
349+
350+
const interval = utils.betterSetInterval(fn, 200);
351+
352+
// 3 x 200ms + some leeway
353+
await utils.delayPromise(700);
354+
utils.betterClearInterval(interval);
355+
356+
// 1st call is immediate, 3 more after 1, 2 and 3 intervals
357+
expect(fn).toHaveBeenCalledTimes(4);
358+
359+
// No more calls after clearing the interval
360+
await utils.delayPromise(500);
361+
expect(fn).toHaveBeenCalledTimes(4);
362+
});
363+
364+
it('works with async function', async () => {
365+
const fn = jest.fn();
366+
367+
const interval = utils.betterSetInterval(async () => {
368+
fn();
369+
await utils.delayPromise(100);
370+
}, 200);
371+
372+
// 3 x (200 + 100)ms + some leeway
373+
await utils.delayPromise(1000);
374+
utils.betterClearInterval(interval);
375+
376+
// 1st call is immediate, 3 more after 1, 2 and 3 intervals
377+
expect(fn).toHaveBeenCalledTimes(4);
378+
379+
// No more calls after clearing the interval
380+
await utils.delayPromise(500);
381+
expect(fn).toHaveBeenCalledTimes(4);
382+
});
383+
384+
it('works with function that accepts a callback (legacy)', async () => {
385+
const fn = jest.fn();
386+
387+
const interval = utils.betterSetInterval((cb: () => void) => {
388+
fn();
389+
cb();
390+
}, 200);
391+
392+
// 3 x 200ms + some leeway
393+
await utils.delayPromise(700);
394+
utils.betterClearInterval(interval);
395+
396+
// 1st call is immediate, 3 more after 1, 2 and 3 intervals
397+
expect(fn).toHaveBeenCalledTimes(4);
398+
399+
// No more calls after clearing the interval
400+
await utils.delayPromise(500);
401+
expect(fn).toHaveBeenCalledTimes(4);
402+
});
403+
});

0 commit comments

Comments
 (0)