From d471677070ccfb9c8476293a22f9dca78881d8f4 Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Wed, 22 Jul 2026 16:57:17 +0000 Subject: [PATCH 01/12] Update tests to use fake timers and run without browser --- test/index.ts | 102 +++++++++++++++++++++++------------------------ vitest.config.ts | 10 +---- 2 files changed, 52 insertions(+), 60 deletions(-) diff --git a/test/index.ts b/test/index.ts index 138e981..fcc5785 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,75 @@ 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]]) }) - 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(100) 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(100) 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 +95,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,18 +106,18 @@ 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]]) }) @@ -128,72 +128,72 @@ describe('debounce (throttle with {start: false, middle: false})', () => { 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) + 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)) + it('throttle(fn, 100)', () => { + 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})) + it('throttle(fn, 100, {start:false})', () => { + 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})) + it('throttle(fn, 100, {middle:false})', () => { + loop(throttle(x => calls.push(x), 100, {middle: false})) expect(calls).toEqual([1, 10]) }) - 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 +202,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 +216,13 @@ describe('decorators', () => { } } const instance = new MyClass() - await loop(x => instance.foo(x)) + loop(x => instance.foo(x)) expect(calls).toEqual([1, 2, 4, 6, 8, 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 +230,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 } } }) From aab84f9aa64149f8f28d7832f847ceedabbbaa01 Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Wed, 22 Jul 2026 17:32:39 +0000 Subject: [PATCH 02/12] Refactor and fix bugs, update tests --- index.ts | 73 +++++++++++++++++++++++++++++++-------------------- test/index.ts | 7 ++--- 2 files changed, 48 insertions(+), 32 deletions(-) diff --git a/index.ts b/index.ts index c2ad037..c3636d3 100644 --- a/index.ts +++ b/index.ts @@ -1,68 +1,83 @@ 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 + let lastCall = 0 + let lastAttempt = 0 + let endTimer: ReturnType let cancelled = false - function fn(this: unknown, ...args: T) { + + function fn(this: Throttler, ...args: T) { if (cancelled) return - const delta = Date.now() - last - last = Date.now() - if (start && middle && delta >= wait) { - innerStart = true + const makeCall = () => { + clearTimeout(endTimer) + lastCall = Date.now() + callback.apply(this, args) + if (once) this.cancel() } - if (innerStart) { - innerStart = false - 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 isStartOfSequence = Date.now() - lastAttempt >= wait + lastAttempt = Date.now() + + // start: If it has been a sufficient time since the last attempt, start a new sequence + if (start && isStartOfSequence) { + makeCall() + return + } + + // middle: If it has been sufficient time since the last successful call, make another call + if (middle && Date.now() - lastCall >= wait && !isStartOfSequence) { + makeCall() + return + } + + // end: Schedule a call for after the sequence ends, if it hasn't already been handled + if (end) { + clearTimeout(endTimer) + endTimer = setTimeout(() => makeCall(), wait) } } + 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 fcc5785..eea765d 100644 --- a/test/index.ts +++ b/test/index.ts @@ -37,7 +37,8 @@ describe('throttle', () => { fn(3) vi.advanceTimersByTime(50) fn(4) - expect(calls).toEqual([[1], [2]]) + vi.advanceTimersByTime(100) // wait for end + expect(calls).toEqual([[1], [3], [4]]) }) it('will fire last call after `wait` ms have passed', () => { @@ -179,7 +180,7 @@ describe('marbles', () => { it('throttle(fn, 100)', () => { loop(throttle(x => calls.push(x), 100)) - expect(calls).toEqual([1, 2, 4, 6, 8, 10]) + expect(calls).toEqual([1, 3, 5, 7, 9, 10]) }) it('throttle(fn, 100, {start:false})', () => { @@ -217,7 +218,7 @@ describe('decorators', () => { } const instance = new MyClass() loop(x => instance.foo(x)) - expect(calls).toEqual([1, 2, 4, 6, 8, 10]) + expect(calls).toEqual([1, 3, 5, 7, 9, 10]) }) }) From 88c060c6c5d1ba7dd95a54113eaa64f2b0e0abcd Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Wed, 22 Jul 2026 17:35:32 +0000 Subject: [PATCH 03/12] Add tests for new `end` option --- test/index.ts | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/test/index.ts b/test/index.ts index eea765d..fc4dac6 100644 --- a/test/index.ts +++ b/test/index.ts @@ -38,6 +38,8 @@ describe('throttle', () => { vi.advanceTimersByTime(50) fn(4) vi.advanceTimersByTime(100) // wait for end + + // start: 1; middle: 3; end: 4 expect(calls).toEqual([[1], [3], [4]]) }) @@ -124,6 +126,38 @@ describe('throttle {middle:false}', () => { }) }) +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: 3; no end + expect(calls).toEqual([[1], [3]]) + }) +}) + describe('debounce (throttle with {start: false, middle: false})', () => { beforeEach(() => { fn = debounce((...xs) => calls.push(xs), 100) From aa50567c7093addcace288160f1eee0b8e617f16 Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Wed, 22 Jul 2026 17:48:00 +0000 Subject: [PATCH 04/12] Update readme to document `end` and correct behavior --- README.md | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 52f8c1d..76bd3cb 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) @@ -11,7 +11,8 @@ lines, less than 350 bytes minified+gzipped) 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 + end?: boolean, // if true, fire after `wait` has passed since last call + once?: boolean, // cancel after the first successful call, never calling again } function throttle( callback: (...args: T[]) => any, @@ -31,8 +32,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. @@ -45,18 +46,19 @@ 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, {once: true}) | 1 | +| throttle(fn, 100, {once: true, start: false})| 3 | +| debounce(fn, 100) | 10 | ``` ### 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: From ab8d78a28b55736c7ff9698bce5076ba7b36dab4 Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Wed, 22 Jul 2026 14:36:57 -0400 Subject: [PATCH 05/12] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- README.md | 2 +- index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 76bd3cb..d596d1e 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ await delay(100) ``` | 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, {start: false}) | 2 4 6 8 10 | | throttle(fn, 100, {middle: false}) | 1 10 | | throttle(fn, 100, {end: false}) | 1 3 5 7 9 | | throttle(fn, 100, {once: true}) | 1 | diff --git a/index.ts b/index.ts index c3636d3..6f40cc0 100644 --- a/index.ts +++ b/index.ts @@ -40,7 +40,7 @@ export function throttle( clearTimeout(endTimer) lastCall = Date.now() callback.apply(this, args) - if (once) this.cancel() + if (once) fn.cancel() } const isStartOfSequence = Date.now() - lastAttempt >= wait From 88b9a4e00a46ba7353f58ef7b5b34c5f3a9cde7c Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Thu, 23 Jul 2026 15:46:09 +0000 Subject: [PATCH 06/12] Add more explicit marble tests for every config --- test/index.ts | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/test/index.ts b/test/index.ts index fc4dac6..6f7f9ae 100644 --- a/test/index.ts +++ b/test/index.ts @@ -212,19 +212,19 @@ describe('marbles', () => { expect(calls).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) }) - it('throttle(fn, 100)', () => { - loop(throttle(x => calls.push(x), 100)) - expect(calls).toEqual([1, 3, 5, 7, 9, 10]) - }) - - it('throttle(fn, 100, {start:false})', () => { - loop(throttle(x => calls.push(x), 100, {start: false})) - expect(calls).toEqual([2, 4, 6, 8, 10]) - }) - - it('throttle(fn, 100, {middle:false})', () => { - 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)', () => { From 2b5402701d49ecf72963471afa9b2c9f587ac891 Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Thu, 23 Jul 2026 15:46:34 +0000 Subject: [PATCH 07/12] Fix middle behavior near start of sequence --- index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/index.ts b/index.ts index c3636d3..1902b77 100644 --- a/index.ts +++ b/index.ts @@ -28,6 +28,7 @@ export function throttle( wait = 0, {start = true, middle = true, once = false, end = true}: ThrottleOptions = {} ): Throttler { + /** 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 @@ -46,6 +47,8 @@ export function throttle( 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() @@ -53,7 +56,7 @@ export function throttle( } // middle: If it has been sufficient time since the last successful call, make another call - if (middle && Date.now() - lastCall >= wait && !isStartOfSequence) { + if (middle && Date.now() - lastCall >= wait) { makeCall() return } From bd0ded6104599031cf9070312b6afaf4a85d46e9 Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Thu, 23 Jul 2026 15:55:55 +0000 Subject: [PATCH 08/12] Extract `queueCall` helper --- index.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/index.ts b/index.ts index 3b8260f..ea2a322 100644 --- a/index.ts +++ b/index.ts @@ -44,6 +44,11 @@ export function throttle( if (once) fn.cancel() } + const queueCall = (delay: number) => { + clearTimeout(endTimer) + endTimer = setTimeout(() => makeCall(), delay) + } + const isStartOfSequence = Date.now() - lastAttempt >= wait lastAttempt = Date.now() @@ -63,8 +68,7 @@ export function throttle( // end: Schedule a call for after the sequence ends, if it hasn't already been handled if (end) { - clearTimeout(endTimer) - endTimer = setTimeout(() => makeCall(), wait) + queueCall(wait) } } From ec9908cfe63f60490c976abca9b2ca936d09e96f Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Fri, 24 Jul 2026 20:06:54 +0000 Subject: [PATCH 09/12] Update behavior to work more like lodash, when possible --- README.md | 29 ++++++++++++++++++----------- index.ts | 27 ++++++++++++++++++++++----- test/index.ts | 10 +++++++--- 3 files changed, 47 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index d596d1e..70c6c7a 100644 --- a/README.md +++ b/README.md @@ -10,10 +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 - end?: boolean, // if true, fire after `wait` has passed since last 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, @@ -45,18 +46,24 @@ 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 3 5 7 9 10 | -| throttle(fn, 100, {start: false}) | 2 4 6 8 10 | -| throttle(fn, 100, {middle: false}) | 1 10 | -| throttle(fn, 100, {end: false}) | 1 3 5 7 9 | -| throttle(fn, 100, {once: true}) | 1 | -| throttle(fn, 100, {once: true, start: false})| 3 | -| 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 class methods. diff --git a/index.ts b/index.ts index ea2a322..a286280 100644 --- a/index.ts +++ b/index.ts @@ -60,15 +60,32 @@ export function throttle( return } - // middle: If it has been sufficient time since the last successful call, make another call - if (middle && Date.now() - lastCall >= wait) { - makeCall() + // 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 } - // end: Schedule a call for after the sequence ends, if it hasn't already been handled - if (end) { + // 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 } } diff --git a/test/index.ts b/test/index.ts index 6f7f9ae..6e1d5b5 100644 --- a/test/index.ts +++ b/test/index.ts @@ -47,7 +47,7 @@ describe('throttle', () => { fn(1) fn(2) fn(3) - vi.advanceTimersByTime(100) + vi.advanceTimersByTime(150) expect(calls).toEqual([[1], [3]]) }) @@ -67,7 +67,7 @@ describe('throttle', () => { fn(4, 5, 6) fn(7, 8, 9) fn(10, 11, 12) - vi.advanceTimersByTime(100) + vi.advanceTimersByTime(150) expect(calls).toEqual([ [1, 2, 3], [10, 11, 12] @@ -153,7 +153,7 @@ describe('throttle {end:false}', () => { vi.advanceTimersByTime(50) fn(4) - // start: 1; middle: 3; no end + // start: 1; middle: 2; no end expect(calls).toEqual([[1], [3]]) }) }) @@ -202,6 +202,8 @@ describe('marbles', () => { const loop = (cb: (n: number) => void) => { for (let i = 1; i <= 10; ++i) { cb(i) + // 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) } vi.advanceTimersByTime(100) @@ -212,6 +214,8 @@ describe('marbles', () => { expect(calls).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) }) + // Note: middle calls are even numbers instead of odd numbers, because the second call gets queued for exactly the + // time the third call gets made. The second call just barely beats the third call. 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]], From 17645894b2f5c027035c97f2a5b419b0884f1060 Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Fri, 24 Jul 2026 20:32:31 +0000 Subject: [PATCH 10/12] Remove unused flow types --- index.js.flow | 19 ------------------- 1 file changed, 19 deletions(-) delete mode 100644 index.js.flow 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 From 519f5c299b9bf325ee9e6a1551cce2f8b5ede572 Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Fri, 24 Jul 2026 20:32:52 +0000 Subject: [PATCH 11/12] Fix `this` type accuracy --- test/index.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/index.ts b/test/index.ts index 6e1d5b5..ebbe10b 100644 --- a/test/index.ts +++ b/test/index.ts @@ -214,8 +214,6 @@ describe('marbles', () => { expect(calls).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) }) - // Note: middle calls are even numbers instead of odd numbers, because the second call gets queued for exactly the - // time the third call gets made. The second call just barely beats the third call. 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]], From 53b6c6c2c896bc8e49afc9c7e31bc31df6ecc49e Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Fri, 24 Jul 2026 20:32:59 +0000 Subject: [PATCH 12/12] Update comments and error messages --- decorators.ts | 2 +- index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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.ts b/index.ts index a286280..20c0d5f 100644 --- a/index.ts +++ b/index.ts @@ -34,7 +34,7 @@ export function throttle( let endTimer: ReturnType let cancelled = false - function fn(this: Throttler, ...args: T) { + function fn(this: unknown, ...args: T) { if (cancelled) return const makeCall = () => {