Skip to content

Fix useDebouncedEvent pending calls surviving unmount and delay changes - #11400

Merged
fzaninotto merged 7 commits into
masterfrom
copilot/fix-usedebouncedevent-cancel-flush
Oct 16, 2026
Merged

fzaninotto merged 7 commits into
masterfrom
copilot/fix-usedebouncedevent-cancel-flush

Conversation

Copilot AI commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Problem

useDebouncedEvent wraps lodash/debounce but never cleans up the instance it creates, and it hides cancel() and flush(). As a result:

  • A call pending at unmount fires delay ms later, after the component is gone.
  • A delay change replaces the debounced instance but leaves the old one's pending call to fire later.
  • Callers can't drop a pending call, for example a dialog that resets its state on close. They have to call the debounced function again with a neutral value instead.

Solution

  • Cleanup: the layout effect that creates the debounced function now returns a cleanup that flushes the pending call. On unmount or delay change, the pending call runs immediately instead of delay ms later.
  • cancel/flush: the returned callback is still referentially stable, and now carries cancel() and flush(). Both delegate to the current debounced instance.
  • Types: the return type is a local DebouncedEvent<Args, Return> interface, so ra-core's public API does not depend on lodash typings.
const update = useDebouncedEvent(setValue, 300);
update(text);
// later, e.g. on dialog close
update.cancel();

This is backward compatible: the return value is still callable with the same signature, and only gains two properties.

How To Test

Run yarn jest packages/ra-core/src/util/useDebouncedEvent. The spec uses fake timers to check:

  • the callback fires after the delay
  • cancel() drops a pending call
  • flush() runs a pending call immediately, and only once
  • unmount flushes a pending call
  • a delay change flushes a pending call

Additional Checks

  • The PR targets master for a bugfix or a documentation fix, or next for a feature
  • The PR includes unit tests (if not possible, describe why)
  • The PR includes one or several stories (if not possible, describe why). N/A: this is a headless hook with no UI.
  • The documentation is up to date. cancel/flush are not documented yet.

Also, please make sure to read the contributing guidelines.

What & why: useDebouncedEvent now exposes cancel()/flush() and flushes a pending call on unmount or delay change, so callers can drop pending calls and no call fires late.
Risk: 🔴 High (public API of ra-core: hook return type and cleanup behavior change)
Start with: the useLayoutEffect cleanup in packages/ra-core/src/util/useDebouncedEvent.ts, which decides what happens to a pending call.

🎯 Intent

A debounced callback from useDebouncedEvent can be cancelled (or flushed),
and a pending call does not run after the component unmounts.

Source: #11399 (provided by dev)

🧭 Non-obvious decisions
  • Flush instead of cancel on cleanup, which departs from the issue's proposed fix: FilterLiveForm's setFilters lives in the ListContext, which outlives the form. Cancelling dropped a filter typed less than debounce ms before the filter form unmounts. Flushing runs the call during unmount, never delay ms after.
  • Callers who want to drop the pending call must call cancel() from an event handler: a cancel() in the caller's own cleanup runs after the hook's flush.
  • Local DebouncedEvent interface instead of lodash's DebouncedFunc: keeps @types/lodash out of ra-core's public types and removes the as unknown as cast on the placeholder ref.
🧪 Tests
  • Verified: yarn jest packages/ra-core/src/util/useDebouncedEvent: 1 suite, 5 tests passed
  • Verified: yarn jest packages/ra-core/src/util/useDebouncedEvent packages/ra-core/src/form/FilterLiveForm: 2 suites passed (before the spec refactor)
  • Verified: yarn jest packages/ra-core/src/util/useDebouncedEvent packages/ra-core/src/form/FilterLiveForm packages/ra-ui-materialui/src/list/filter (before the type refactor): 8 suites, 56 tests passed
  • Verified: npx tsc --noEmit -p packages/ra-core and npx eslint on both files: no errors
  • Not verified: full test suite, e2e
💥 Impact if wrong
  • Who: FilterLiveForm (all list filter forms), and apps that import useDebouncedEvent
  • Worst case: list filters typed just before closing the filter form are lost, or applied twice
  • Rollback: revert the PR
📖 Docs to update
  • useDebouncedEvent has no page in docs/ or docs_headless/; cancel/flush are undocumented.
🤖 AI
  • Model: claude-opus-5-5. Wrote: code, tests, description (on top of the Copilot commits)
  • Checkout: git fetch origin copilot/fix-usedebouncedevent-cancel-flush && git switch copilot/fix-usedebouncedevent-cancel-flush
  • Test: yarn install && yarn jest packages/ra-core/src/util/useDebouncedEvent packages/ra-core/src/form/FilterLiveForm packages/ra-ui-materialui/src/list/filter && npx tsc --noEmit -p packages/ra-core
  • Review: check the diff against 🎯 Intent and each 🧭 decision, then the cleanup order on unmount: the hook's layout-effect flush runs before FilterLiveForm's watch unsubscribe, so setFilters must still be safe to call at that point.

🤖 Generated with Claude Code

Co-authored-by: fzaninotto <99944+fzaninotto@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix useDebouncedEvent to allow cancellation and proper cleanup Fix useDebouncedEvent pending calls surviving unmount and delay changes, and expose cancel/flush Oct 11, 2026
Copilot AI requested a review from fzaninotto October 11, 2026 11:58
@fzaninotto
fzaninotto marked this pull request as ready for review October 13, 2026 15:54
@fzaninotto

Copy link
Copy Markdown
Member

@copilot fix the ci failures

Co-authored-by: fzaninotto <99944+fzaninotto@users.noreply.github.com>
fzaninotto and others added 2 commits October 14, 2026 16:14
…elay 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 <noreply@anthropic.com>
…dash's

Exposing lodash's DebouncedFunc tied ra-core's public API to @types/lodash.
A local DebouncedEvent interface also removes the need for the
`as unknown as` cast on the placeholder ref. Tests now use fireEvent so
clicks are wrapped in act().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@fzaninotto fzaninotto changed the title Fix useDebouncedEvent pending calls surviving unmount and delay changes, and expose cancel/flush Fix useDebouncedEvent pending calls surviving unmount and delay changes Oct 16, 2026
fzaninotto and others added 2 commits October 16, 2026 15:37
Comp did not say what it renders, and its `any` props hid type errors in
the tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ush()

A cancelOnClick prop forced the cancel test to rerender mid-test to switch
the button's behavior. Separate update/cancel/flush buttons read like the
real use case (cancel from an event handler) and let flush() be tested
directly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@fzaninotto
fzaninotto merged commit a66bd3e into master Oct 16, 2026
15 checks passed
@fzaninotto
fzaninotto deleted the copilot/fix-usedebouncedevent-cancel-flush branch October 16, 2026 14:13
@fzaninotto fzaninotto added this to the 5.15.5 milestone Oct 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

useDebouncedEvent: pending calls can't be cancelled, and survive unmount

2 participants