From cdbaa85beb343e61aec9956d4b22ca4f19f66aa5 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 10 Mar 2026 13:39:44 +0100 Subject: [PATCH] Fix debounce with immediate firing twice on single call (#30082) --- src/common/util/debounce.ts | 34 +++++-- test/common/util/debounce.test.ts | 153 ++++++++++++++++++++++++++++++ 2 files changed, 177 insertions(+), 10 deletions(-) create mode 100644 test/common/util/debounce.test.ts diff --git a/src/common/util/debounce.ts b/src/common/util/debounce.ts index ec96fa63eb..9a1a0b526e 100644 --- a/src/common/util/debounce.ts +++ b/src/common/util/debounce.ts @@ -1,9 +1,8 @@ -// From: https://davidwalsh.name/javascript-debounce-function - // Returns a function, that, as long as it continues to be invoked, will not // be triggered. The function will be called after it stops being called for // N milliseconds. If `immediate` is passed, trigger the function on the -// leading edge and on the trailing. +// leading edge. The trailing edge only fires if there were additional calls +// during the wait period. export const debounce = ( func: (...args: T) => void, @@ -11,20 +10,35 @@ export const debounce = ( immediate = false ) => { let timeout: number | undefined; + let trailingArgs: T | undefined; + const debouncedFunc = (...args: T): void => { - const later = () => { - timeout = undefined; - func(...args); - }; - const callNow = immediate && !timeout; + const isLeading = immediate && !timeout; + + if (timeout) { + trailingArgs = args; + } clearTimeout(timeout); - timeout = window.setTimeout(later, wait); - if (callNow) { + + timeout = window.setTimeout(() => { + timeout = undefined; + if (trailingArgs) { + func(...trailingArgs); + trailingArgs = undefined; + } else if (!immediate) { + func(...args); + } + }, wait); + + if (isLeading) { func(...args); } }; + debouncedFunc.cancel = () => { clearTimeout(timeout); + trailingArgs = undefined; }; + return debouncedFunc; }; diff --git a/test/common/util/debounce.test.ts b/test/common/util/debounce.test.ts new file mode 100644 index 0000000000..890ce831e0 --- /dev/null +++ b/test/common/util/debounce.test.ts @@ -0,0 +1,153 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { debounce } from "../../../src/common/util/debounce"; + +describe("debounce", () => { + beforeEach(() => { + vi.useFakeTimers({ + toFake: ["setTimeout", "clearTimeout"], + shouldAdvanceTime: false, + }); + vi.stubGlobal("window", { + ...window, + setTimeout: globalThis.setTimeout, + clearTimeout: globalThis.clearTimeout, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + describe("without immediate", () => { + it("calls function after wait period", () => { + const fn = vi.fn(); + const debounced = debounce(fn, 500); + + debounced(); + expect(fn).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(500); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it("resets timer on subsequent calls", () => { + const fn = vi.fn(); + const debounced = debounce(fn, 500); + + debounced(); + vi.advanceTimersByTime(300); + debounced(); + vi.advanceTimersByTime(300); + expect(fn).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(200); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it("uses the latest arguments", () => { + const fn = vi.fn(); + const debounced = debounce(fn, 500); + + debounced("a"); + debounced("b"); + debounced("c"); + + vi.advanceTimersByTime(500); + expect(fn).toHaveBeenCalledTimes(1); + expect(fn).toHaveBeenCalledWith("c"); + }); + }); + + describe("with immediate", () => { + it("calls function immediately on first call", () => { + const fn = vi.fn(); + const debounced = debounce(fn, 500, true); + + debounced(); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it("does not call again on trailing edge for a single call", () => { + const fn = vi.fn(); + const debounced = debounce(fn, 500, true); + + debounced(); + expect(fn).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(500); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it("calls trailing edge when there are additional calls during wait", () => { + const fn = vi.fn(); + const debounced = debounce(fn, 500, true); + + debounced("a"); + expect(fn).toHaveBeenCalledTimes(1); + expect(fn).toHaveBeenCalledWith("a"); + + debounced("b"); + debounced("c"); + expect(fn).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(500); + expect(fn).toHaveBeenCalledTimes(2); + expect(fn).toHaveBeenLastCalledWith("c"); + }); + + it("does not fire leading edge during cooldown", () => { + const fn = vi.fn(); + const debounced = debounce(fn, 500, true); + + debounced(); + expect(fn).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(200); + debounced(); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it("allows a new leading call after cooldown expires", () => { + const fn = vi.fn(); + const debounced = debounce(fn, 500, true); + + debounced("first"); + expect(fn).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(500); + expect(fn).toHaveBeenCalledTimes(1); + + debounced("second"); + expect(fn).toHaveBeenCalledTimes(2); + expect(fn).toHaveBeenLastCalledWith("second"); + }); + }); + + describe("cancel", () => { + it("cancels pending trailing call", () => { + const fn = vi.fn(); + const debounced = debounce(fn, 500); + + debounced(); + debounced.cancel(); + + vi.advanceTimersByTime(500); + expect(fn).not.toHaveBeenCalled(); + }); + + it("cancels pending trailing call with immediate", () => { + const fn = vi.fn(); + const debounced = debounce(fn, 500, true); + + debounced("a"); + debounced("b"); + expect(fn).toHaveBeenCalledTimes(1); + + debounced.cancel(); + + vi.advanceTimersByTime(500); + expect(fn).toHaveBeenCalledTimes(1); + }); + }); +});