Skip to content

Commit

Permalink
fix throttle function not being able to run with argument(s)
Browse files Browse the repository at this point in the history
  • Loading branch information
undergroundwires committed Mar 13, 2021
1 parent 1f515e7 commit 1935db1
Show file tree
Hide file tree
Showing 2 changed files with 108 additions and 24 deletions.
56 changes: 39 additions & 17 deletions src/presentation/components/Shared/Throttle.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,10 @@
export function throttle<T extends []>(
callback: (..._: T) => void, wait: number,
timer: ITimer = NodeTimer): (..._: T) => void {
let queuedToRun: ReturnType<typeof setTimeout>;
let previouslyRun: number;
return function invokeFn(...args: T) {
const now = timer.dateNow();
if (queuedToRun) {
queuedToRun = timer.clearTimeout(queuedToRun) as undefined;
}
if (!previouslyRun || (now - previouslyRun >= wait)) {
callback(...args);
previouslyRun = now;
} else {
queuedToRun = timer.setTimeout(invokeFn.bind(null, ...args), wait - (now - previouslyRun));
}
};
export type CallbackType = (..._: any[]) => void;

export function throttle(
callback: CallbackType, waitInMs: number,
timer: ITimer = NodeTimer): CallbackType {
const throttler = new Throttler(timer, waitInMs, callback);
return (...args: any[]) => throttler.invoke(...args);
}

export interface ITimer {
Expand All @@ -28,3 +18,35 @@ const NodeTimer: ITimer = {
clearTimeout: (timeoutId) => clearTimeout(timeoutId),
dateNow: () => Date.now(),
};

interface IThrottler {
invoke: CallbackType;
}

class Throttler implements IThrottler {
private queuedToRun: ReturnType<typeof setTimeout>;
private previouslyRun: number;
constructor(
private readonly timer: ITimer,
private readonly waitInMs: number,
private readonly callback: CallbackType) {
if (!timer) { throw new Error('undefined timer'); }
if (!waitInMs) { throw new Error('no delay to throttle'); }
if (waitInMs < 0) { throw new Error('negative delay'); }
if (!callback) { throw new Error('undefined callback'); }
}
public invoke(...args: any[]): void {
const now = this.timer.dateNow();
if (this.queuedToRun) {
this.queuedToRun = this.timer.clearTimeout(this.queuedToRun) as undefined;
}
if (!this.previouslyRun || (now - this.previouslyRun >= this.waitInMs)) {
this.callback(...args);
this.previouslyRun = now;
} else {
const nextCall = () => this.invoke(...args);
const nextCallDelayInMs = this.waitInMs - (now - this.previouslyRun);
this.queuedToRun = this.timer.setTimeout(nextCall, nextCallDelayInMs);
}
}
}
76 changes: 69 additions & 7 deletions tests/unit/presentation/components/Shared/Throttle.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,32 @@ import { EventSource } from '@/infrastructure/Events/EventSource';
import { IEventSubscription } from '@/infrastructure/Events/IEventSource';

describe('throttle', () => {
it('throws if callback is undefined', () => {
// arrange
const expectedError = 'undefined callback';
const callback = undefined;
// act
const act = () => throttle(callback, 500);
// assert
expect(act).to.throw(expectedError);
});
describe('throws if waitInMs is negative or zero', () => {
// arrange
const testCases = [
{ value: 0, expectedError: 'no delay to throttle' },
{ value: -2, expectedError: 'negative delay' },
];
const callback = () => { return; };
for (const testCase of testCases) {
it(`"${testCase.value}" throws "${testCase.expectedError}"`, () => {
// act
const waitInMs = testCase.value;
const act = () => throttle(callback, waitInMs);
// assert
expect(act).to.throw(testCase.expectedError);
});
}
});
it('should call the callback immediately', () => {
// arrange
const timer = new TimerMock();
Expand All @@ -21,16 +47,17 @@ describe('throttle', () => {
const timer = new TimerMock();
let totalRuns = 0;
const callback = () => totalRuns++;
const throttleFunc = throttle(callback, 500, timer);
const waitInMs = 500;
const throttleFunc = throttle(callback, waitInMs, timer);
// act
throttleFunc();
totalRuns--;
totalRuns--; // So we don't count the initial run
throttleFunc();
timer.tick(500);
timer.tickNext(waitInMs);
// assert
expect(totalRuns).to.equal(1);
});
it('calls the callback at most once at given time', () => {
it('should call the callback at most once at given time', () => {
// arrange
const timer = new TimerMock();
let totalRuns = 0;
Expand All @@ -40,11 +67,43 @@ describe('throttle', () => {
const throttleFunc = throttle(callback, waitInMs, timer);
// act
for (let i = 0; i < totalCalls; i++) {
timer.tick(waitInMs / totalCalls * i);
timer.setCurrentTime(waitInMs / totalCalls * i);
throttleFunc();
}
// assert
expect(totalRuns).to.equal(2); // one initial and one at the end
});
it('should call the callback as long as delay is waited', () => {
// arrange
const timer = new TimerMock();
let totalRuns = 0;
const callback = () => totalRuns++;
const waitInMs = 500;
const expectedTotalRuns = 10;
const throttleFunc = throttle(callback, waitInMs, timer);
// act
for (let i = 0; i < expectedTotalRuns; i++) {
throttleFunc();
timer.tickNext(waitInMs);
}
// assert
expect(totalRuns).to.equal(2); // initial and at the end
expect(totalRuns).to.equal(expectedTotalRuns);
});
it('should call arguments as expected', () => {
// arrange
const timer = new TimerMock();
const expected = [ 1, 2, 3 ];
const actual = new Array<number>();
const callback = (arg: number) => { actual.push(arg); };
const waitInMs = 500;
const throttleFunc = throttle(callback, waitInMs, timer);
// act
for (const arg of expected) {
throttleFunc(arg);
timer.tickNext(waitInMs);
}
// assert
expect(expected).to.deep.equal(actual);
});
});

Expand All @@ -69,7 +128,10 @@ class TimerMock implements ITimer {
public dateNow(): number {
return this.currentTime;
}
public tick(ms: number): void {
public tickNext(ms: number): void {
this.setCurrentTime(this.currentTime + ms);
}
public setCurrentTime(ms: number): void {
this.currentTime = ms;
this.timeChanged.notify(this.currentTime);
}
Expand Down

0 comments on commit 1935db1

Please sign in to comment.