Last active
February 4, 2026 09:31
-
-
Save sonhanguyen/d26249e1675dcc5c49df03362e035dbe to your computer and use it in GitHub Desktop.
TypeScript Debouncer class with subscription support and comprehensive tests
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| export class Debouncer<T> { | |
| private timeout!: ReturnType<typeof setTimeout>; | |
| private promises = Array<Promise<T>>(); | |
| private subscriptions = new Set<{ | |
| onNext: (value: T, stale: boolean, id: number) => void; | |
| onError?: (error: any) => void; | |
| }>(); | |
| private sequenceId = 0; | |
| constructor(public defaultDelay?: number) {} | |
| schedule(todo: () => Promise<T>, delay = this.defaultDelay) { | |
| if (this.timeout) clearTimeout(this.timeout); | |
| const id = this.sequenceId++; | |
| this.timeout = setTimeout(async () => { | |
| this.timeout = 0; | |
| const promise = todo(); | |
| this.promises.push(promise); | |
| try { | |
| const value = await promise; | |
| const stale = id < this.sequenceId - 1; | |
| this.subscriptions.forEach((sub) => { | |
| try { | |
| sub.onNext(value, stale, id); | |
| } catch (err) { | |
| sub.onError?.(err); | |
| } | |
| }); | |
| } catch (error) { | |
| this.subscriptions.forEach((sub) => sub.onError?.(error)); | |
| } finally { | |
| this.promises = this.promises.filter((toKeep) => toKeep != promise); | |
| } | |
| }, delay); | |
| return id; | |
| } | |
| subscribe( | |
| onNext: (value: T, stale: boolean, id: number) => void, | |
| onError?: (error: any) => void, | |
| ): () => void { | |
| const subscription = { onNext, onError }; | |
| this.subscriptions.add(subscription); | |
| return () => this.subscriptions.delete(subscription); | |
| } | |
| } | |
| try { | |
| const { test, expect, describe } = await import("bun:test"); | |
| describe("Debouncer", () => { | |
| test("debounces multiple calls and only executes the last one", async () => { | |
| const debouncer = new Debouncer<number>(50); | |
| const results: Array<{ value: number; id: number; stale: boolean }> = []; | |
| debouncer.subscribe((value, stale, id) => { | |
| results.push({ value, stale, id }); | |
| }); | |
| debouncer.schedule(() => Promise.resolve(1)); | |
| debouncer.schedule(() => Promise.resolve(2)); | |
| debouncer.schedule(() => Promise.resolve(3)); | |
| await new Promise((resolve) => setTimeout(resolve, 100)); | |
| expect(results.length).toBe(1); | |
| expect(results[0]).toEqual({ value: 3, id: 2, stale: false }); | |
| }); | |
| test("marks earlier results as stale when they resolve after newer calls", async () => { | |
| const debouncer = new Debouncer<number>(50); | |
| const results: Array<{ value: number; id: number; stale: boolean }> = []; | |
| debouncer.subscribe((value, stale, id) => { | |
| results.push({ value, stale, id }); | |
| }); | |
| // First call resolves slowly | |
| debouncer.schedule( | |
| () => new Promise((resolve) => setTimeout(() => resolve(1), 150)), | |
| ); | |
| await new Promise((resolve) => setTimeout(resolve, 60)); | |
| // Second call resolves quickly | |
| debouncer.schedule(() => Promise.resolve(2)); | |
| await new Promise((resolve) => setTimeout(resolve, 200)); | |
| expect(results.length).toBe(2); | |
| expect(results[0]).toEqual({ value: 2, id: 1, stale: false }); | |
| expect(results[1]).toEqual({ value: 1, id: 0, stale: true }); | |
| }); | |
| test("supports multiple subscribers", async () => { | |
| const debouncer = new Debouncer<string>(50); | |
| const results1: string[] = []; | |
| const results2: string[] = []; | |
| debouncer.subscribe((value) => results1.push(value)); | |
| debouncer.subscribe((value) => results2.push(value)); | |
| debouncer.schedule(() => Promise.resolve("test")); | |
| await new Promise((resolve) => setTimeout(resolve, 100)); | |
| expect(results1).toEqual(["test"]); | |
| expect(results2).toEqual(["test"]); | |
| }); | |
| test("allows unsubscribing", async () => { | |
| const debouncer = new Debouncer<string>(50); | |
| const results: string[] = []; | |
| const unsubscribe = debouncer.subscribe((value) => results.push(value)); | |
| debouncer.schedule(() => Promise.resolve("before")); | |
| await new Promise((resolve) => setTimeout(resolve, 100)); | |
| unsubscribe(); | |
| debouncer.schedule(() => Promise.resolve("after")); | |
| await new Promise((resolve) => setTimeout(resolve, 100)); | |
| expect(results).toEqual(["before"]); | |
| }); | |
| test("handles errors through onError callback", async () => { | |
| const debouncer = new Debouncer<number>(50); | |
| const errors: any[] = []; | |
| debouncer.subscribe( | |
| () => {}, | |
| (error) => errors.push(error), | |
| ); | |
| debouncer.schedule(() => Promise.reject(new Error("test error"))); | |
| await new Promise((resolve) => setTimeout(resolve, 100)); | |
| expect(errors.length).toBe(1); | |
| expect(errors[0].message).toBe("test error"); | |
| }); | |
| test("only notifies the subscriber whose onNext throws", async () => { | |
| const debouncer = new Debouncer<number>(50); | |
| const errors1: any[] = []; | |
| const errors2: any[] = []; | |
| const results: number[] = []; | |
| debouncer.subscribe( | |
| () => { | |
| throw new Error("subscriber1 error"); | |
| }, | |
| (err) => errors1.push(err), | |
| ); | |
| debouncer.subscribe( | |
| (value) => results.push(value), | |
| (err) => errors2.push(err), | |
| ); | |
| debouncer.schedule(() => Promise.resolve(42)); | |
| await new Promise((resolve) => setTimeout(resolve, 100)); | |
| expect(errors1.length).toBe(1); | |
| expect(errors1[0].message).toBe("subscriber1 error"); | |
| expect(errors2.length).toBe(0); | |
| expect(results).toEqual([42]); | |
| }); | |
| test("does not call onError of other subscribers if one onNext throws", async () => { | |
| const debouncer = new Debouncer<number>(50); | |
| const errorsA: any[] = []; | |
| const errorsB: any[] = []; | |
| debouncer.subscribe( | |
| () => { | |
| throw new Error("A error"); | |
| }, | |
| (err) => errorsA.push(err), | |
| ); | |
| debouncer.subscribe( | |
| () => {}, | |
| (err) => errorsB.push(err), | |
| ); | |
| debouncer.schedule(() => Promise.resolve(1)); | |
| await new Promise((resolve) => setTimeout(resolve, 100)); | |
| expect(errorsA.length).toBe(1); | |
| expect(errorsA[0].message).toBe("A error"); | |
| expect(errorsB.length).toBe(0); | |
| }); | |
| test("uses default delay from constructor", async () => { | |
| const debouncer = new Debouncer<number>(100); | |
| const results: number[] = []; | |
| debouncer.subscribe((value) => results.push(value)); | |
| const start = Date.now(); | |
| debouncer.schedule(() => Promise.resolve(1)); | |
| await new Promise((resolve) => setTimeout(resolve, 150)); | |
| const elapsed = Date.now() - start; | |
| expect(elapsed).toBeGreaterThanOrEqual(100); | |
| expect(results).toEqual([1]); | |
| }); | |
| test("allows overriding delay per call", async () => { | |
| const debouncer = new Debouncer<number>(500); | |
| const results: number[] = []; | |
| debouncer.subscribe((value) => results.push(value)); | |
| const start = Date.now(); | |
| debouncer.schedule(() => Promise.resolve(1), 50); | |
| await new Promise((resolve) => setTimeout(resolve, 100)); | |
| const elapsed = Date.now() - start; | |
| expect(elapsed).toBeLessThan(500); | |
| expect(results).toEqual([1]); | |
| }); | |
| test("returns unique sequential IDs", () => { | |
| const debouncer = new Debouncer<number>(50); | |
| const id1 = debouncer.schedule(() => Promise.resolve(1)); | |
| const id2 = debouncer.schedule(() => Promise.resolve(2)); | |
| const id3 = debouncer.schedule(() => Promise.resolve(3)); | |
| expect(id1).toBe(0); | |
| expect(id2).toBe(1); | |
| expect(id3).toBe(2); | |
| }); | |
| test("cleans up promises after they complete", async () => { | |
| const debouncer = new Debouncer<number>(50); | |
| debouncer.subscribe(() => {}); | |
| debouncer.schedule(() => Promise.resolve(1)); | |
| await new Promise((resolve) => setTimeout(resolve, 100)); | |
| // Access private field for testing | |
| const promises = (debouncer as any).promises; | |
| expect(promises.length).toBe(0); | |
| }); | |
| }); | |
| } catch { | |
| // Not in a test environment | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment