1
0
mirror of https://github.com/home-assistant/frontend.git synced 2026-04-02 00:27:49 +01:00

Fix debounce with immediate firing twice on single call (#30082)

This commit is contained in:
Paul Bottein
2026-03-10 13:39:44 +01:00
committed by GitHub
parent b58fc68ea8
commit cdbaa85beb
2 changed files with 177 additions and 10 deletions

View File

@@ -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 = <T extends any[]>(
func: (...args: T) => void,
@@ -11,20 +10,35 @@ export const debounce = <T extends any[]>(
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;
};

View File

@@ -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);
});
});
});