diff --git a/README.md b/README.md index 52f8c1d..70c6c7a 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # mini-throttle This is a package which provides `throttle` and `debounce` functions, with both -flow and TypeScript declarations, and a minimal code footprint (less than 60 +flow and TypeScript declarations, and a minimal code footprint (less than 90 lines, less than 350 bytes minified+gzipped) @@ -10,9 +10,11 @@ lines, less than 350 bytes minified+gzipped) ```js type ThrottleOptions = { start?: boolean, // fire immediately on the first call - middle?: boolean, // if true, fire as soon as `wait` has passed - once?: boolean, // cancel after the first successful call + middle?: boolean, // fire during a sequence of calls when `wait` has passed + end?: boolean, // fire after the end of a sequence of calls + once?: boolean, // cancel after the first successful call, never calling again } + function throttle( callback: (...args: T[]) => any, wait: number, @@ -31,8 +33,8 @@ This package comes with two functions; `throttle` and `debounce`. Both of these functions offer the exact same signature, because they're both the same function - just with different `opts` defaults: - - `throttle` opts default to `{ start: true, middle: true, once: false }`. - - `debounce` opts default to `{ start: false, middle: false, once: false }`. + - `throttle` opts default to `{ start: true, middle: true, end: true, once: false }`. + - `debounce` opts default to `{ start: false, middle: false, end: true, once: false }`. Each of the options changes when `callback` gets called. The best way to illustrate this is with a marble diagram. @@ -44,19 +46,26 @@ for (let i = 1; i <= 10; ++i) { } await delay(100) ``` + ``` -| fn() | 1 2 3 4 5 6 7 8 9 10 | -| throttle(fn, 100) | 1 2 4 6 8 10 | -| throttle(fn, 100, {start: false}) | 2 4 6 8 10 | -| throttle(fn, 100, {middle: false}) | 1 10 | -| throttle(fn, 100, {once: true}) | 1 | -| throttle(fn, 100, {once: true, start: false})| 2 | -| debounce(fn, 100) | 10 | +| fn() | 1 2 3 4 5 6 7 8 9 10 | +| throttle(fn, 100) | 1 3 5 7 9 10 | +| throttle(fn, 100, {start: false}) | 3 5 7 9 10 | +| throttle(fn, 100, {middle: false}) | 1 10 | +| throttle(fn, 100, {end: false}) | 1 3 5 7 9 | +| throttle(fn, 100, {start: false, middle: false}) | 10 | +| throttle(fn, 100, {middle: false, end: false}) | 1 | +| throttle(fn, 100, {start: false, end: false}) | 3 5 7 9 | +| throttle(fn, 100, {once: true}) | 1 | +| throttle(fn, 100, {once: true, start: false}) | 3 | +| debounce(fn, 100) | 10 | ``` -### TypeScript Decorators Support! +**Note**: `wait` is the _minimum_ amount of time between calls. For some configurations, the actual wait time may be longer. For example, when `middle` is `false` and `end` is `true`, the end call must wait longer than otherwise to ensure that no calls are made afterwards which would turn it into a middle call. + +### TypeScript Decorators Support -This package also includes a decorator module which can be used to provide [TypeScript Decorator](https://www.typescriptlang.org/docs/handbook/decorators.html#decorators) annotations to functions. +This package also includes a decorator module which can be used to provide [TypeScript Decorator](https://www.typescriptlang.org/docs/handbook/decorators.html#decorators) annotations to class methods. Here's an example, showing what you need to do: diff --git a/decorators.ts b/decorators.ts index 44026b1..fdd1958 100644 --- a/decorators.ts +++ b/decorators.ts @@ -3,7 +3,7 @@ import {debounce as db, throttle as th, ThrottleOptions} from './index' export function throttle(wait = 0, opts: ThrottleOptions = {}): MethodDecorator { return (proto: unknown, name: string | symbol, descriptor: PropertyDescriptor) => { if (!descriptor || typeof descriptor.value !== 'function') { - throw new Error('debounce can only decorate functions') + throw new Error('throttle can only decorate functions') } const fn = descriptor.value descriptor.value = th(fn, wait, opts) diff --git a/index.js.flow b/index.js.flow deleted file mode 100644 index d55d88f..0000000 --- a/index.js.flow +++ /dev/null @@ -1,19 +0,0 @@ -/* @flow strict */ - -type ThrottleOptions = {| - start?: boolean, - middle?: boolean, - once?: boolean -|} - -declare export function throttle>( - callback: (...T) => mixed, - wait: number, - opts?: ThrottleOptions -): (...T) => void - -declare export function debounce>( - callback: (...T) => mixed, - wait: number, - opts?: ThrottleOptions -): (...T) => void diff --git a/index.ts b/index.ts index c2ad037..20c0d5f 100644 --- a/index.ts +++ b/index.ts @@ -1,68 +1,107 @@ export interface ThrottleOptions { /** - * Fire immediately on the first call. + * Fire at the start of a sequence of calls. */ start?: boolean /** - * Fire as soon as `wait` has passed. + * Fire in the middle of a sequence of calls when `wait` has passed. */ middle?: boolean /** - * Cancel after the first successful call. + * Fire at the end of a sequence of calls. + */ + end?: boolean + /** + * Cancel after the first successful call. Will never call again. */ once?: boolean } interface Throttler { (...args: T): void + /** Cancel any pending calls and never call the function again. */ cancel(): void } export function throttle( callback: (...args: T) => unknown, wait = 0, - {start = true, middle = true, once = false}: ThrottleOptions = {} + {start = true, middle = true, once = false, end = true}: ThrottleOptions = {} ): Throttler { - let innerStart = start - let last = 0 - let timer: ReturnType + /** Time of last made call in this sequence, or time of start of sequence if a call hasn't been made yet. */ + let lastCall = 0 + let lastAttempt = 0 + let endTimer: ReturnType let cancelled = false + function fn(this: unknown, ...args: T) { if (cancelled) return - const delta = Date.now() - last - last = Date.now() - - if (start && middle && delta >= wait) { - innerStart = true - } - if (innerStart) { - innerStart = false + const makeCall = () => { + clearTimeout(endTimer) + lastCall = Date.now() callback.apply(this, args) if (once) fn.cancel() - } else if ((middle && delta < wait) || !middle) { - clearTimeout(timer) - timer = setTimeout( - () => { - last = Date.now() - callback.apply(this, args) - if (once) fn.cancel() - }, - !middle ? wait : wait - delta - ) + } + + const queueCall = (delay: number) => { + clearTimeout(endTimer) + endTimer = setTimeout(() => makeCall(), delay) + } + + const isStartOfSequence = Date.now() - lastAttempt >= wait + lastAttempt = Date.now() + + if (isStartOfSequence) lastCall = Date.now() + + // start: If it has been a sufficient time since the last attempt, start a new sequence + if (start && isStartOfSequence) { + makeCall() + return + } + + // If middle and end are both enabled, we can queue them the same way. This ensures even spacing; calls are made + // exactly every `wait` ms during the sequence. Mimics Lodash style throttle. + if (middle && end) { + const remainingWait = wait - (Date.now() - lastCall) + if (remainingWait <= 0) { + makeCall() + } else { + // +1 ms ensures that if a call is attempted at exactly the same time as the queued call would be made, + // the requested call will win over the queued call. + queueCall(remainingWait + 1) + } + return + } + + // If middle is disabled (something Lodash doesn't support), we have to wait the full interval for the end call. + // This is because we don't know if another call will be made unless we wait until the sequence is definitely over. + if (!middle && end) { + queueCall(wait) + return + } + + // If middle is enabled but end is disabled, we cannot make the middle call until another call is made - if we + // optimistically make a call as soon as `wait` is over, it will look like an `end` call which would violate the end setting. + if (middle && !end) { + if (Date.now() - lastCall >= wait) makeCall() + return } } + fn.cancel = () => { - clearTimeout(timer) + clearTimeout(endTimer) cancelled = true } + return fn } +// debounce is just throttle but with only an ending call export function debounce( callback: (...args: T) => unknown, wait = 0, - {start = false, middle = false, once = false}: ThrottleOptions = {} + {start = false, middle = false, once = false, end = true}: ThrottleOptions = {} ): Throttler { - return throttle(callback, wait, {start, middle, once}) + return throttle(callback, wait, {start, middle, once, end}) } diff --git a/test/index.ts b/test/index.ts index 138e981..ebbe10b 100644 --- a/test/index.ts +++ b/test/index.ts @@ -1,7 +1,6 @@ import {throttle, debounce} from '../index' import {throttle as decoratorThrottle, debounce as decoratorDebounce} from '../decorators' -import {beforeEach, describe, it, expect} from 'vitest' -const delay = (m: number) => new Promise(r => setTimeout(r, m)) +import {beforeEach, describe, it, vi, expect} from 'vitest' interface Throttler { (...args: T): void @@ -14,74 +13,78 @@ let fn: Throttler beforeEach(() => { fn?.cancel?.() calls = [] + vi.useFakeTimers() }) describe('throttle', () => { beforeEach(() => { fn = throttle((...xs) => calls.push(xs), 100) }) - it('fires callback immediately', async () => { + it('fires callback immediately', () => { fn() expect(calls).toHaveLength(1) }) - it('calls callback with given arguments', async () => { + it('calls callback with given arguments', () => { fn(1, 2, 3) expect(calls).toEqual([[1, 2, 3]]) }) - it('fires once `wait` ms have passed', async () => { + it('fires once `wait` ms have passed', () => { fn(1) - await delay(50) + vi.advanceTimersByTime(50) fn(2) - await delay(50) + vi.advanceTimersByTime(50) fn(3) - await delay(50) + vi.advanceTimersByTime(50) fn(4) - expect(calls).toEqual([[1], [2]]) + vi.advanceTimersByTime(100) // wait for end + + // start: 1; middle: 3; end: 4 + expect(calls).toEqual([[1], [3], [4]]) }) - it('will fire last call after `wait` ms have passed', async () => { + it('will fire last call after `wait` ms have passed', () => { fn(1) fn(2) fn(3) - await delay(100) + vi.advanceTimersByTime(150) expect(calls).toEqual([[1], [3]]) }) - it('will fire even the passed time greater than `wait` ms', async () => { + it('will fire even the passed time greater than `wait` ms', () => { fn(1) - await delay(200) + vi.advanceTimersByTime(200) fn(2) fn(3) fn(4) - await delay(1000) + vi.advanceTimersByTime(1000) fn(5) expect(calls).toEqual([[1], [2], [4], [5]]) }) - it('calls callback with given arguments (middle)', async () => { + it('calls callback with given arguments (middle)', () => { fn(1, 2, 3) fn(4, 5, 6) fn(7, 8, 9) fn(10, 11, 12) - await delay(100) + vi.advanceTimersByTime(150) expect(calls).toEqual([ [1, 2, 3], [10, 11, 12] ]) }) - it('can be cancelled with cancel()', async () => { + it('can be cancelled with cancel()', () => { fn.cancel() fn(1, 2, 3) fn(4, 5, 6) fn(7, 8, 9) fn(10, 11, 12) - await delay(100) + vi.advanceTimersByTime(100) expect(calls).toEqual([]) }) - it('exposes `this`', async () => { + it('exposes `this`', () => { fn = throttle(function (this: unknown) { calls.push(this) }, 100) @@ -95,7 +98,7 @@ describe('throttle {start:false}', () => { beforeEach(() => { fn = throttle((...xs) => calls.push(xs), 100, {start: false}) }) - it('does not fire callback immediately', async () => { + it('does not fire callback immediately', () => { fn() expect(calls).toHaveLength(0) }) @@ -106,94 +109,128 @@ describe('throttle {middle:false}', () => { fn = throttle((...xs) => calls.push(xs), 100, {middle: false}) }) - it('fires first callback', async () => { + it('fires first callback', () => { fn(1) expect(calls).toEqual([[1]]) }) - it('does not fire if `wait` ms have passed', async () => { + it('does not fire if `wait` ms have passed', () => { fn(1) - await delay(50) + vi.advanceTimersByTime(50) fn(2) - await delay(50) + vi.advanceTimersByTime(50) fn(3) - await delay(50) + vi.advanceTimersByTime(50) fn(4) expect(calls).toEqual([[1]]) }) }) +describe('throttle {end:false}', () => { + beforeEach(() => { + fn = throttle((...xs) => calls.push(xs), 100, {end: false}) + }) + + it('fires first callback', () => { + fn(1) + expect(calls).toEqual([[1]]) + }) + + it('does not fire fast second callback even after `wait` ms passes', () => { + fn(1) + vi.advanceTimersByTime(50) + fn(2) + vi.advanceTimersByTime(100) + expect(calls).toEqual([[1]]) + }) + + it('still fires middle callback during sequence', () => { + fn(1) + vi.advanceTimersByTime(50) + fn(2) + vi.advanceTimersByTime(50) + fn(3) + vi.advanceTimersByTime(50) + fn(4) + + // start: 1; middle: 2; no end + expect(calls).toEqual([[1], [3]]) + }) +}) + describe('debounce (throttle with {start: false, middle: false})', () => { beforeEach(() => { fn = debounce((...xs) => calls.push(xs), 100) }) - it('does not fire callback immediately', async () => { + it('does not fire callback immediately', () => { fn() expect(calls).toHaveLength(0) }) - it('only fires once `wait` ms have passed without any calls', async () => { + it('only fires once `wait` ms have passed without any calls', () => { fn(1) fn(2) fn(3) - await delay(100) + vi.advanceTimersByTime(100) expect(calls).toEqual([[3]]) }) - it('will fire even the passed time greater than `wait` ms', async () => { + it('will fire even the passed time greater than `wait` ms', () => { fn(1) - await delay(200) + vi.advanceTimersByTime(200) fn(2) fn(3) fn(4) - await delay(1000) + vi.advanceTimersByTime(1000) fn(5) expect(calls).toEqual([[1], [4]]) }) - it('exposes `this`', async () => { + it('exposes `this`', () => { fn = debounce(function (this: unknown) { calls.push(this) }, 100) const receiver = {} fn.call(receiver, 1) - await delay(100) + vi.advanceTimersByTime(100) expect(calls).toEqual([receiver]) }) }) describe('marbles', () => { - const loop = async (cb: (n: number) => void) => { + const loop = (cb: (n: number) => void) => { for (let i = 1; i <= 10; ++i) { cb(i) - await delay(50) + // Things are clearer when the interval is not an exact divisor of the throttle time (at which point middle calls + // start conflicting and being less obvious/predictable) + vi.advanceTimersByTime(50) } - await delay(100) + vi.advanceTimersByTime(100) } - it('fn', async () => { - await loop((x: number) => calls.push(x)) + it('fn', () => { + loop((x: number) => calls.push(x)) expect(calls).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) }) - it('throttle(fn, 100)', async () => { - await loop(throttle(x => calls.push(x), 100)) - expect(calls).toEqual([1, 2, 4, 6, 8, 10]) - }) - - it('throttle(fn, 100, {start:false})', async () => { - await loop(throttle(x => calls.push(x), 100, {start: false})) - expect(calls).toEqual([2, 4, 6, 8, 10]) - }) - - it('throttle(fn, 100, {middle:false})', async () => { - await loop(throttle(x => calls.push(x), 100, {middle: false})) - expect(calls).toEqual([1, 10]) + it.each([ + [{start: true, middle: true, end: true}, [1, 3, 5, 7, 9, 10]], + [{start: true, middle: true, end: false}, [1, 3, 5, 7, 9]], + [{start: true, middle: false, end: true}, [1, 10]], + [{start: true, middle: false, end: false}, [1]], + [{start: false, middle: true, end: true}, [3, 5, 7, 9, 10]], + [{start: false, middle: true, end: false}, [3, 5, 7, 9]], + [{start: false, middle: false, end: true}, [10]], + [{start: false, middle: false, end: false}, []], + [{}, [1, 3, 5, 7, 9, 10]] + ])('throttle(fn, 100, %s)', (opts, expected) => { + loop(throttle(x => calls.push(x), 100, opts)) + expect(calls).toEqual(expected) }) - it('debounce(fn, 100)', async () => { - await loop(debounce(x => calls.push(x), 100)) + it('debounce(fn, 100)', () => { + loop(debounce(x => calls.push(x), 100)) expect(calls).toEqual([10]) }) }) @@ -202,13 +239,13 @@ describe('decorators', () => { const loop = async (cb: (n: number) => void) => { for (let i = 1; i <= 10; ++i) { cb(i) - await delay(50) + vi.advanceTimersByTime(50) } - await delay(100) + vi.advanceTimersByTime(100) } describe('throttle', () => { - it('wraps decorated function as throttle', async () => { + it('wraps decorated function as throttle', () => { class MyClass { @decoratorThrottle(100) foo(x: number) { @@ -216,13 +253,13 @@ describe('decorators', () => { } } const instance = new MyClass() - await loop(x => instance.foo(x)) - expect(calls).toEqual([1, 2, 4, 6, 8, 10]) + loop(x => instance.foo(x)) + expect(calls).toEqual([1, 3, 5, 7, 9, 10]) }) }) describe('debounce', () => { - it('wraps decorated function as throttle', async () => { + it('wraps decorated function as throttle', () => { class MyClass { @decoratorDebounce(100) foo(x: number) { @@ -230,7 +267,7 @@ describe('decorators', () => { } } const instance = new MyClass() - await loop(x => instance.foo(x)) + loop(x => instance.foo(x)) expect(calls).toEqual([10]) }) }) diff --git a/vitest.config.ts b/vitest.config.ts index ae21f5e..227798a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,18 +1,10 @@ import {defineConfig} from 'vitest/config' -import {playwright} from '@vitest/browser-playwright' export default defineConfig({ test: { include: ['test/**/*.ts'], browser: { - enabled: true, - instances: [ - { - browser: 'chromium' - } - ], - provider: playwright(), - headless: true + enabled: false } } })