/* Copyright 2026 Marimo. All rights reserved. */ import { act, renderHook } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useDebounce, useDebounceControlledState, useDebouncedCallback, } from "../useDebounce"; describe("useDebounce", () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers(); }); it("should update value after delay", () => { const { result, rerender } = renderHook( ({ value }) => useDebounce(value, 1000), { initialProps: { value: "initial" } }, ); expect(result.current).toBe("initial"); rerender({ value: "updated" }); act(() => { vi.runAllTimers(); }); act(() => { vi.runAllTimers(); }); expect(result.current).toBe("updated"); }); it("should cleanup on unmount", () => { const { result, rerender, unmount } = renderHook( ({ value }) => useDebounce(value, 1000), { initialProps: { value: "initial" } }, ); expect(result.current).toBe("initial"); rerender({ value: "updated" }); unmount(); act(() => { vi.runAllTimers(); }); expect(result.current).toBe("initial"); }); }); describe("useDebounceControlledState", () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers(); }); it("should not call onChange with initial value immediately", () => { const onChange = vi.fn(); renderHook(() => useDebounceControlledState({ initialValue: "initial", onChange, delay: 1000, }), ); expect(onChange).not.toHaveBeenCalled(); }); it("should debounce subsequent onChange calls", () => { const onChange = vi.fn(); const { result } = renderHook(() => useDebounceControlledState({ initialValue: "initial", onChange, delay: 1000, }), ); onChange.mockClear(); act(() => { result.current.onChange("updated"); }); act(() => { vi.runAllTimers(); }); act(() => { vi.runAllTimers(); }); expect(onChange).toHaveBeenCalledWith("updated"); }); it("should not call onChange if disabled", () => { const onChange = vi.fn(); renderHook(() => useDebounceControlledState({ initialValue: "initial", onChange, delay: 1000, disabled: true, }), ); expect(onChange).not.toHaveBeenCalled(); }); }); describe("useDebouncedCallback", () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers(); }); it("should debounce the callback", () => { const callback = vi.fn(); const { result } = renderHook(() => useDebouncedCallback(callback, 1000)); act(() => { result.current("test"); result.current("test2"); result.current("test3"); }); expect(callback).not.toHaveBeenCalled(); act(() => { vi.runAllTimers(); }); expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledWith("test3"); }); it("should maintain the same reference when deps don't change", () => { const callback = vi.fn(); const { result, rerender } = renderHook(() => useDebouncedCallback(callback, 1000), ); const firstRef = result.current; rerender(); const secondRef = result.current; expect(firstRef).toBe(secondRef); }); it("should create new debounced function when delay changes", () => { const callback = vi.fn(); const { result, rerender } = renderHook( ({ delay }) => useDebouncedCallback(callback, delay), { initialProps: { delay: 1000 } }, ); const firstRef = result.current; rerender({ delay: 2000 }); const secondRef = result.current; expect(firstRef).not.toBe(secondRef); }); });