From 42e9a6eb22733fd58d325fff9f552afb454c34d8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:56:02 +0000 Subject: [PATCH 1/7] Initial plan From 0f9c0bce4971d17d11aabd9d4be91c56139c62dc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:57:59 +0000 Subject: [PATCH 2/7] Apply remaining changes Co-authored-by: fzaninotto <99944+fzaninotto@users.noreply.github.com> --- .../src/util/useDebouncedEvent.spec.tsx | 44 +++++++++++++++++++ .../ra-core/src/util/useDebouncedEvent.ts | 33 ++++++++++---- 2 files changed, 69 insertions(+), 8 deletions(-) create mode 100644 packages/ra-core/src/util/useDebouncedEvent.spec.tsx diff --git a/packages/ra-core/src/util/useDebouncedEvent.spec.tsx b/packages/ra-core/src/util/useDebouncedEvent.spec.tsx new file mode 100644 index 00000000000..979b99615dd --- /dev/null +++ b/packages/ra-core/src/util/useDebouncedEvent.spec.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import { render, screen, act } from '@testing-library/react'; +import { useDebouncedEvent } from './useDebouncedEvent'; + +const Comp = ({ cb, cancelOnClick = false }: any) => { + const update = useDebouncedEvent(cb, 300); + return ( + + ); +}; + +describe('useDebouncedEvent', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('calls the callback after the delay', () => { + const cb = jest.fn(); + render(); + screen.getByText('go').click(); + act(() => jest.advanceTimersByTime(300)); + expect(cb).toHaveBeenCalledWith('x'); + }); + it('cancels a pending call with cancel()', () => { + const cb = jest.fn(); + const { rerender } = render(); + screen.getByText('go').click(); + rerender(); + screen.getByText('go').click(); + act(() => jest.advanceTimersByTime(300)); + expect(cb).not.toHaveBeenCalled(); + }); + it('cancels a pending call on unmount', () => { + const cb = jest.fn(); + const { unmount } = render(); + screen.getByText('go').click(); + unmount(); + act(() => jest.advanceTimersByTime(300)); + expect(cb).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ra-core/src/util/useDebouncedEvent.ts b/packages/ra-core/src/util/useDebouncedEvent.ts index abd608e75fb..80a8a6898a3 100644 --- a/packages/ra-core/src/util/useDebouncedEvent.ts +++ b/packages/ra-core/src/util/useDebouncedEvent.ts @@ -1,6 +1,7 @@ import * as React from 'react'; -import { useCallback, useRef } from 'react'; +import { useMemo, useRef } from 'react'; import debounce from 'lodash/debounce.js'; +import type { DebouncedFunc } from 'lodash'; import { useEvent } from './useEvent'; // allow the hook to work in SSR @@ -19,10 +20,17 @@ export const useDebouncedEvent = ( delay: number ) => { // Create a ref that stores the debounced callback - const debouncedCallbackRef = useRef<(...args: Args) => Return | undefined>( - () => { - throw new Error('Cannot call an event handler while rendering.'); - } + const debouncedCallbackRef = useRef Return + >>( + Object.assign( + () => { + throw new Error( + 'Cannot call an event handler while rendering.' + ); + }, + { cancel: () => {}, flush: () => undefined } + ) as unknown as DebouncedFunc<(...args: Args) => Return> ); // Keep a stable ref to the callback (in case it's an inline function for instance) @@ -30,13 +38,22 @@ export const useDebouncedEvent = ( // Whenever callback or delay changes, we need to update the debounced callback useLayoutEffect(() => { - debouncedCallbackRef.current = debounce(stableCallback, delay); + const debounced = debounce(stableCallback, delay); + debouncedCallbackRef.current = debounced; + return () => debounced.cancel(); }, [stableCallback, delay]); // The function returned by useCallback will invoke the debounced callback // Its dependencies array is empty, so it never changes across re-renders - return useCallback( - (...args: Args) => debouncedCallbackRef.current(...args), + return useMemo( + () => + Object.assign( + (...args: Args) => debouncedCallbackRef.current(...args), + { + cancel: () => debouncedCallbackRef.current.cancel(), + flush: () => debouncedCallbackRef.current.flush(), + } + ), [] ); }; From 88cba47f2a01dc3c8c7c028a007a903d053f8642 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:54:58 +0000 Subject: [PATCH 3/7] fix: Fix lint errors in useDebouncedEvent Co-authored-by: fzaninotto <99944+fzaninotto@users.noreply.github.com> --- packages/ra-core/src/util/useDebouncedEvent.spec.tsx | 4 +--- packages/ra-core/src/util/useDebouncedEvent.ts | 11 ++++++----- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/ra-core/src/util/useDebouncedEvent.spec.tsx b/packages/ra-core/src/util/useDebouncedEvent.spec.tsx index 979b99615dd..683ff82d391 100644 --- a/packages/ra-core/src/util/useDebouncedEvent.spec.tsx +++ b/packages/ra-core/src/util/useDebouncedEvent.spec.tsx @@ -5,9 +5,7 @@ import { useDebouncedEvent } from './useDebouncedEvent'; const Comp = ({ cb, cancelOnClick = false }: any) => { const update = useDebouncedEvent(cb, 300); return ( - ); diff --git a/packages/ra-core/src/util/useDebouncedEvent.ts b/packages/ra-core/src/util/useDebouncedEvent.ts index 80a8a6898a3..df62fd9e011 100644 --- a/packages/ra-core/src/util/useDebouncedEvent.ts +++ b/packages/ra-core/src/util/useDebouncedEvent.ts @@ -1,9 +1,12 @@ import * as React from 'react'; import { useMemo, useRef } from 'react'; import debounce from 'lodash/debounce.js'; -import type { DebouncedFunc } from 'lodash'; import { useEvent } from './useEvent'; +type Debounced = ReturnType< + typeof debounce<(...args: Args) => Return> +>; + // allow the hook to work in SSR const useLayoutEffect = typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect; @@ -20,9 +23,7 @@ export const useDebouncedEvent = ( delay: number ) => { // Create a ref that stores the debounced callback - const debouncedCallbackRef = useRef Return - >>( + const debouncedCallbackRef = useRef>( Object.assign( () => { throw new Error( @@ -30,7 +31,7 @@ export const useDebouncedEvent = ( ); }, { cancel: () => {}, flush: () => undefined } - ) as unknown as DebouncedFunc<(...args: Args) => Return> + ) as unknown as Debounced ); // Keep a stable ref to the callback (in case it's an inline function for instance) From ef65eb3c3c4f401a8eefdfbf5345252a781d9fef Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Wed, 14 Oct 2026 16:14:21 +0200 Subject: [PATCH 4/7] fix: Flush instead of cancel pending debounced calls on unmount and delay change Cancelling dropped user input in FilterLiveForm: a filter typed just before the form unmounts (or before its debounce changes) was never applied, because setFilters lives in the ListContext which outlives the form. Callers that want to drop a pending call can still call cancel() explicitly. Co-Authored-By: Claude Opus 5.5 --- .../src/util/useDebouncedEvent.spec.tsx | 20 +++++++++++++++---- .../ra-core/src/util/useDebouncedEvent.ts | 4 +++- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/ra-core/src/util/useDebouncedEvent.spec.tsx b/packages/ra-core/src/util/useDebouncedEvent.spec.tsx index 683ff82d391..aefcd7f37a8 100644 --- a/packages/ra-core/src/util/useDebouncedEvent.spec.tsx +++ b/packages/ra-core/src/util/useDebouncedEvent.spec.tsx @@ -2,8 +2,8 @@ import * as React from 'react'; import { render, screen, act } from '@testing-library/react'; import { useDebouncedEvent } from './useDebouncedEvent'; -const Comp = ({ cb, cancelOnClick = false }: any) => { - const update = useDebouncedEvent(cb, 300); +const Comp = ({ cb, cancelOnClick = false, delay = 300 }: any) => { + const update = useDebouncedEvent(cb, delay); return ( + <> + + + + ); }; @@ -25,24 +25,34 @@ describe('useDebouncedEvent', () => { it('calls the callback after the delay', () => { const cb = jest.fn(); - render(); - fireEvent.click(screen.getByText('go')); + render(); + fireEvent.click(screen.getByText('update')); + expect(cb).not.toHaveBeenCalled(); act(() => jest.advanceTimersByTime(300)); expect(cb).toHaveBeenCalledWith('x'); }); it('cancels a pending call with cancel()', () => { const cb = jest.fn(); - const { rerender } = render(); - fireEvent.click(screen.getByText('go')); - rerender(); - fireEvent.click(screen.getByText('go')); + render(); + fireEvent.click(screen.getByText('update')); + fireEvent.click(screen.getByText('cancel')); act(() => jest.advanceTimersByTime(300)); expect(cb).not.toHaveBeenCalled(); }); + it('runs a pending call immediately with flush()', () => { + const cb = jest.fn(); + render(); + fireEvent.click(screen.getByText('update')); + fireEvent.click(screen.getByText('flush')); + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenCalledWith('x'); + act(() => jest.advanceTimersByTime(300)); + expect(cb).toHaveBeenCalledTimes(1); + }); it('flushes a pending call on unmount', () => { const cb = jest.fn(); - const { unmount } = render(); - fireEvent.click(screen.getByText('go')); + const { unmount } = render(); + fireEvent.click(screen.getByText('update')); unmount(); expect(cb).toHaveBeenCalledTimes(1); expect(cb).toHaveBeenCalledWith('x'); @@ -51,9 +61,9 @@ describe('useDebouncedEvent', () => { }); it('flushes a pending call when the delay changes', () => { const cb = jest.fn(); - const { rerender } = render(); - fireEvent.click(screen.getByText('go')); - rerender(); + const { rerender } = render(); + fireEvent.click(screen.getByText('update')); + rerender(); expect(cb).toHaveBeenCalledTimes(1); expect(cb).toHaveBeenCalledWith('x'); act(() => jest.advanceTimersByTime(500));