diff --git a/packages/@mantine/hooks/src/use-debounced-callback/use-debounced-callback.test.ts b/packages/@mantine/hooks/src/use-debounced-callback/use-debounced-callback.test.ts index 141582b600..c568e17601 100644 --- a/packages/@mantine/hooks/src/use-debounced-callback/use-debounced-callback.test.ts +++ b/packages/@mantine/hooks/src/use-debounced-callback/use-debounced-callback.test.ts @@ -174,10 +174,10 @@ describe('@mantine/hooks/use-debounced-callback', () => { expect(callback).not.toHaveBeenCalled(); jest.advanceTimersByTime(100); - expect(callback).toHaveBeenCalledWith(3); + expect(callback).not.toHaveBeenCalled(); // Fixed: no trailing call expected }); - it('resets leading after flush', () => { + it('resets leading after delay with leading=true', () => { const callback = jest.fn(); const { result } = renderHook(() => useDebouncedCallback(callback, { delay: 100, leading: true }) @@ -188,20 +188,18 @@ describe('@mantine/hooks/use-debounced-callback', () => { expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledWith('a'); - // A second call is made. Since "leadingRef" is now false, this call is debounced and schedules a timeout. + // A second call during delay period should be ignored result.current('b'); - // The callback has still only been called once (with 'a'). - expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledTimes(1); // Still only 'a' - // Then we advance the timers to trigger the internal flush of the first call, executing "b" + // After delay, no trailing execution should happen jest.advanceTimersByTime(100); - expect(callback).toHaveBeenCalledTimes(2); - expect(callback).toHaveBeenNthCalledWith(2, 'b'); + expect(callback).toHaveBeenCalledTimes(1); // Still only 'a' - // After the flush from "b", "leadingRef" resets, so the next call fires immediately again + // After delay has passed, leading state should reset - next call fires immediately result.current('c'); - expect(callback).toHaveBeenCalledTimes(3); - expect(callback).toHaveBeenCalledWith('c'); + expect(callback).toHaveBeenCalledTimes(2); + expect(callback).toHaveBeenNthCalledWith(2, 'c'); }); it('doesnt call on leading edge if leading changes from true to false', () => { @@ -227,13 +225,14 @@ describe('@mantine/hooks/use-debounced-callback', () => { expect(callback).toHaveBeenCalledTimes(1); rerender({ delay: 200 }); result.current(2); + expect(callback).toHaveBeenCalledTimes(2); // Leading call + result.current(3); expect(callback).toHaveBeenCalledTimes(2); jest.advanceTimersByTime(100); expect(callback).toHaveBeenCalledTimes(2); jest.advanceTimersByTime(100); - expect(callback).toHaveBeenCalledTimes(3); - expect(callback).toHaveBeenCalledWith(3); + expect(callback).toHaveBeenCalledTimes(2); }); it('can cancel debounced callback', () => { @@ -334,4 +333,25 @@ describe('@mantine/hooks/use-debounced-callback', () => { expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledWith(2); }); + + it('leading=true should suppress trailing execution', () => { + const callback = jest.fn(); + const { result } = renderHook(() => + useDebouncedCallback(callback, { delay: 100, leading: true }) + ); + result.current('first'); + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith('first'); + + result.current('second'); + expect(callback).toHaveBeenCalledTimes(1); // Still only first call + + callback.mockClear(); + jest.advanceTimersByTime(100); + expect(callback).not.toHaveBeenCalled(); // This is the fix + + result.current('third'); + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith('third'); + }); }); diff --git a/packages/@mantine/hooks/src/use-debounced-callback/use-debounced-callback.ts b/packages/@mantine/hooks/src/use-debounced-callback/use-debounced-callback.ts index a368d0249a..67433a0a58 100644 --- a/packages/@mantine/hooks/src/use-debounced-callback/use-debounced-callback.ts +++ b/packages/@mantine/hooks/src/use-debounced-callback/use-debounced-callback.ts @@ -31,15 +31,56 @@ export function useDebouncedCallback any>( const isFirstCall = currentCallback._isFirstCall; currentCallback._isFirstCall = false; + function clearTimeoutAndLeadingRef() { + window.clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = 0; + currentCallback._isFirstCall = true; + } + if (leading && isFirstCall) { handleCallback(...args); + + const resetLeadingState = () => { + clearTimeoutAndLeadingRef(); + }; + + const flush = () => { + if (debounceTimerRef.current !== 0) { + clearTimeoutAndLeadingRef(); + handleCallback(...args); + } + }; + + const cancel = () => { + clearTimeoutAndLeadingRef(); + }; + + currentCallback.flush = flush; + currentCallback.cancel = cancel; + debounceTimerRef.current = window.setTimeout(resetLeadingState, delay); return; } - function clearTimeoutAndLeadingRef() { - window.clearTimeout(debounceTimerRef.current); - debounceTimerRef.current = 0; - currentCallback._isFirstCall = true; + if (leading && !isFirstCall) { + const flush = () => { + if (debounceTimerRef.current !== 0) { + clearTimeoutAndLeadingRef(); + handleCallback(...args); + } + }; + + const cancel = () => { + clearTimeoutAndLeadingRef(); + }; + + currentCallback.flush = flush; + currentCallback.cancel = cancel; + + const resetLeadingState = () => { + clearTimeoutAndLeadingRef(); + }; + debounceTimerRef.current = window.setTimeout(resetLeadingState, delay); + return; } const flush = () => { @@ -57,7 +98,11 @@ export function useDebouncedCallback any>( currentCallback.cancel = cancel; debounceTimerRef.current = window.setTimeout(flush, delay); }, - { flush: () => {}, cancel: () => {}, _isFirstCall: true } + { + flush: () => {}, + cancel: () => {}, + _isFirstCall: true, + } ); return currentCallback; }, [handleCallback, delay, leading]);