Skip to content

Commit

Permalink
add leading option to throttle (#503)
Browse files Browse the repository at this point in the history
  • Loading branch information
ryanatkn authored Oct 3, 2024
1 parent c6b0122 commit c352db8
Show file tree
Hide file tree
Showing 3 changed files with 104 additions and 30 deletions.
5 changes: 5 additions & 0 deletions .changeset/thirty-comics-drive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@ryanatkn/gro': patch
---

add `leading` option to `throttle`
108 changes: 88 additions & 20 deletions src/lib/throttle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,43 +10,111 @@ test('throttles calls to a function', async () => {
results.push(name + '_run');
await wait();
results.push(name + '_done');
});
}, 0);

const promise_a = fn('a');
const promise_b = fn('b'); // discarded
const promise_c = fn('c'); // discarded
const promise_d = fn('d');

assert.ok(promise_a !== promise_b);
assert.is(promise_b, promise_c);
assert.is(promise_b, promise_d);
assert.equal(results, ['a_run']);

await promise_a;

assert.equal(results, ['a_run', 'a_done']);

await wait();

assert.equal(results, ['a_run', 'a_done', 'd_run']);

await promise_b;

assert.equal(results, ['a_run', 'a_done', 'd_run', 'd_done']);
const promise_e = fn('e'); // discarded
const promise_f = fn('f');
assert.ok(promise_d !== promise_e);
assert.is(promise_e, promise_f);
assert.equal(results, ['a_run', 'a_done', 'd_run', 'd_done']); // delayed
});

test('calls functions in sequence', async () => {
const results: string[] = [];
const fn = throttle(async (name: string) => {
results.push(name + '_run');
await wait();
results.push(name + '_done');
}, 0);

const promise_a = fn('a');

assert.equal(results, ['a_run']);

await promise_a;

assert.equal(results, ['a_run', 'a_done']);

const promise_b = fn('b');

assert.ok(promise_a !== promise_b);

await promise_b;

assert.equal(results, ['a_run', 'a_done', 'b_run', 'b_done']);
});

test('throttles calls to a function with leading = false', async () => {
const results: string[] = [];
const fn = throttle(
async (name: string) => {
results.push(name + '_run');
await wait();
results.push(name + '_done');
},
0,
false,
);

const promise_a = fn('a'); // discarded
const promise_b = fn('b'); // discarded
const promise_c = fn('c'); // discarded
const promise_d = fn('d');

assert.is(promise_a, promise_b);
assert.is(promise_a, promise_c);
assert.is(promise_a, promise_d);
assert.equal(results, []); // No immediate execution

await wait();

assert.equal(results, ['d_run']);

await promise_a; // All promises resolve to the same result

assert.equal(results, ['d_run', 'd_done']);

const promise_e = fn('e');
assert.ok(promise_a !== promise_e);
assert.equal(results, ['d_run', 'd_done']);

await wait();
assert.equal(results, ['a_run', 'a_done', 'd_run', 'd_done', 'f_run']);

assert.equal(results, ['d_run', 'd_done', 'e_run']);

await promise_e;
assert.equal(results, ['a_run', 'a_done', 'd_run', 'd_done', 'f_run', 'f_done']);

assert.equal(results, ['d_run', 'd_done', 'e_run', 'e_done']);

const promise_f = fn('f'); // discarded
const promise_g = fn('g');
assert.equal(results, ['a_run', 'a_done', 'd_run', 'd_done', 'f_run', 'f_done']); // delayed
assert.ok(promise_e !== promise_f);
assert.ok(promise_f === promise_g);
assert.equal(results, ['d_run', 'd_done', 'e_run', 'e_done']);

await wait();
assert.equal(results, ['a_run', 'a_done', 'd_run', 'd_done', 'f_run', 'f_done', 'g_run']);

assert.equal(results, ['d_run', 'd_done', 'e_run', 'e_done', 'g_run']);

await promise_g;
assert.equal(results, [
'a_run',
'a_done',
'd_run',
'd_done',
'f_run',
'f_done',
'g_run',
'g_done',
]);

assert.equal(results, ['d_run', 'd_done', 'e_run', 'e_done', 'g_run', 'g_done']);
});

test.run();
21 changes: 11 additions & 10 deletions src/lib/throttle.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
import {wait} from '@ryanatkn/belt/async.js';

// TODO maybe support non-promise return values?

/**
* Throttles calls to a callback that returns a void promise.
* Immediately invokes the callback on the first call.
Expand All @@ -11,13 +7,19 @@ import {wait} from '@ryanatkn/belt/async.js';
* In other words, all calls and their args are discarded
* during the pending window except for the most recent.
* Unlike debouncing, this calls the throttled callback
* both on the leading and trailing edges of the delay window.
* both on the leading and trailing edges of the delay window,
* and this can be customized by setting `leading` to `false`.
* It also differs from a queue where every call to the throttled callback eventually runs.
* @param cb - any function that returns a void promise
* @param delay - delay this many milliseconds between the pending call finishing and the next starting
* @param leading - if `true`, the default, the callback is called immediately
* @returns same as `cb`
*/
export const throttle = <T extends (...args: any[]) => Promise<void>>(cb: T, delay = 0): T => {
export const throttle = <T extends (...args: any[]) => Promise<void>>(
cb: T,
delay = 0,
leading = true,
): T => {
let pending_promise: Promise<void> | null = null;
let next_args: any[] | null = null;
let next_promise: Promise<void> | null = null;
Expand All @@ -29,6 +31,7 @@ export const throttle = <T extends (...args: any[]) => Promise<void>>(cb: T, del
next_promise = new Promise((resolve) => {
next_promise_resolve = resolve;
});
setTimeout(flush, delay);
}
return next_promise;
};
Expand All @@ -45,16 +48,14 @@ export const throttle = <T extends (...args: any[]) => Promise<void>>(cb: T, del

const call = (args: any[]): Promise<any> => {
pending_promise = cb(...args);
void pending_promise.then(async () => {
await wait(delay);
void pending_promise.then(() => {
pending_promise = null;
await flush();
});
return pending_promise;
};

return ((...args) => {
if (pending_promise) {
if (pending_promise || !leading) {
return defer(args);
} else {
return call(args);
Expand Down

0 comments on commit c352db8

Please sign in to comment.