From cc5892ef607ed0d203832007ed6d65aee9154d81 Mon Sep 17 00:00:00 2001 From: ashish-simpleCoder Date: Mon, 2 Feb 2026 20:38:39 +0530 Subject: [PATCH] feat: Add AbortSignal api support for use-debounced-fn hook --- .changeset/wet-states-wink.md | 5 + apps/doc/hooks/use-debounced-fn.md | 387 ++++++++----- src/lib/use-debounced-fn/index.test.tsx | 717 ++++++++++++------------ src/lib/use-debounced-fn/index.tsx | 56 +- 4 files changed, 663 insertions(+), 502 deletions(-) create mode 100644 .changeset/wet-states-wink.md diff --git a/.changeset/wet-states-wink.md b/.changeset/wet-states-wink.md new file mode 100644 index 0000000..1ec1aa7 --- /dev/null +++ b/.changeset/wet-states-wink.md @@ -0,0 +1,5 @@ +--- +'classic-react-hooks': minor +--- + +Feature: Add AbortSignal api support for use-debounced-fn hook for cancelling the async work diff --git a/apps/doc/hooks/use-debounced-fn.md b/apps/doc/hooks/use-debounced-fn.md index 7260d4d..83f32be 100644 --- a/apps/doc/hooks/use-debounced-fn.md +++ b/apps/doc/hooks/use-debounced-fn.md @@ -4,83 +4,23 @@ outline: deep # use-debounced-fn -An async aware React hook with features like built-in error handling and success/finally callbacks which completely transforms the way you implement debouncing feature in your application. +_`use-debounced-fn`_ is an async-aware React hook that provides a powerful, declarative way to implement debouncing with full lifecycle control. -## Features - -- **Auto cleanup:** Timeouts are automatically cleared on unmount or dependency changes -- **Flexible delay:** Configurable delay with sensible defaults -- **Immediate Callback:** Execute synchronous logic immediately before debouncing -- **Success Callback:** Run callback after debounced function completes successfully (supports async) -- **Error Callback:** Handle errors from debounced function execution -- **Finally Callback:** Execute cleanup logic that runs regardless of success or failure -- **Manual Cleanup:** Exposed cleanup function for advanced control -- **Type-safe Overloads:** Full TypeScript support for events and multiple arguments - -## Execution Flow for the callbacks - -```tsx -function SearchInput() { - const [query, setQuery] = useState('') - const [results, setResults] = useState([]) - - const { debouncedFn } = useDebouncedFn({ - /* searchTerm is type-safe for all of the callbacks. */ - immediateCallback: (searchTerm) => { - setQuery(searchTerm) /* Update UI immediately */ - }, - callbackToBounce: async (searchTerm) => { - // No try-catch needed - // Error is handled within `onError` callback - const response = await fetch(`/api/search?q=${searchTerm}`) - const data = await response.json() - setResults(data.results) - }, - onSuccess: (searchTerm) => { - console.log('runs after successful completion of callbackToBounce') - }, - onError: (error, searchTerm) => { - console.log('runs if error occurs', error) - }, - onFinally: (searchTerm) => { - console.log('runs after all of the callbacks') - }, - }) - - /* debouncedFn is aware of its type. String argument must be provided. */ - return debouncedFn(e.target.value)} placeholder='Search...' /> -} -``` - -## Callback Execution Order - -The callbacks execute in the following order: - -### Success Flow - -1. **immediateCallback** - Executes synchronously when `debouncedFn` is called -2. **callbackToBounce** - Executes after the delay period -3. **onSuccess** - Executes after `callbackToBounce` completes successfully -4. **onFinally** - Executes after `onSuccess` - -### Error Flow +It automatically manages timeouts and `AbortController`, ensuring stale async operations are safely cancelled when new calls occur or components unmount. -1. **immediateCallback** - Executes synchronously when `debouncedFn` is called -2. **callbackToBounce** - Executes after the delay period and throws an error -3. **onError** - Executes when error is caught (receives the error and all arguments) -4. **onFinally** - Executes after `onError` - -::: tip +The hook supports immediate execution for synchronous UI updates, along with structured `onSuccess`, `onError`, and `onFinally` callbacks for robust async workflows. Its ref-based implementation guarantees a stable function reference across re-renders, eliminating stale closures and unnecessary rebindings. This makes it ideal for complex scenarios like search, validation, auto-save, and any debounced async side effects. -- `onSuccess` and `onError` are mutually exclusive - only one will run per execution -- `onFinally` always runs, regardless of success or error -- All callbacks except `onError` receive the same arguments passed to `debouncedFn` -- `onError` receives the error as the first argument, followed by the original arguments - ::: +## Features -::: tip -The `debounced function` is purely ref based and does not change across re-renders. -::: +- **Automatic cleanup:** Timeouts are cleared on unmount or dependency changes +- **AbortSignal support:** Cancels pending async operations when a new call starts +- **Configurable delay:** Flexible timing with sensible defaults +- **Immediate execution:** Run synchronous logic before debouncing +- **Success handler:** Invoke a callback on successful completion (async supported) +- **Error handler:** Gracefully handle execution errors +- **Finally handler:** Run cleanup logic regardless of outcome +- **Manual cleanup:** Exposed cleanup function for advanced use cases +- **Type-safe overloads:** Full TypeScript support for events and multiple arguments ## Problem It Solves @@ -88,7 +28,7 @@ The `debounced function` is purely ref based and does not change across re-rende --- -**Problem:-** Manually implementing debouncing in React components leads to less control on behaviors, lengthy, error-prone code with potential memory leaks and stale closures. +**Problem:-** Manually implementing debouncing in React components leads to less control on behaviors, lengthy, error-prone code with potential memory leaks and stale closures. Additionally, handling request cancellation requires manual AbortController management. ```tsx // ❌ Problematic approach which is redundant and lengthy @@ -96,21 +36,34 @@ function SearchInput() { const [query, setQuery] = useState('') const [results, setResults] = useState([]) const timeoutRef = useRef() + const controllerRef = useRef() const handleSearch = useCallback(async (searchTerm: string) => { + // Cancel previous request + if (controllerRef.current) { + controllerRef.current.abort() + } + if (timeoutRef.current) { clearTimeout(timeoutRef.current) } + controllerRef.current = new AbortController() + const controller = controllerRef.current + timeoutRef.current = setTimeout(async () => { try { if (searchTerm.trim()) { - const response = await fetch(`/api/search?q=${searchTerm}`) + const response = await fetch(`/api/search?q=${searchTerm}`, { + signal: controller.signal, + }) const data = await response.json() setResults(data.results) } } catch (error) { - console.error('Search failed:', error) + if (error.name !== 'AbortError') { + console.error('Search failed:', error) + } } }, 500) }, []) @@ -118,7 +71,10 @@ function SearchInput() { useEffect(() => { return () => { if (timeoutRef.current) { - clearTimeout(timeoutRef.current) // Manual cleanup on unmount + clearTimeout(timeoutRef.current) + } + if (controllerRef.current) { + controllerRef.current.abort() } } }, []) @@ -138,14 +94,16 @@ function SearchInput() { **Solution:-** - Eliminates repetitive debounce timing logic +- Automatic AbortController management - previous requests are automatically cancelled - Eliminates manual management of custom callback for updating the UI state - Providing full flexibility on debouncing life cycle behavior with `immediateCallback`, `onSuccess`, `onError`, `onFinally` and `callbackToBounce` functions. -- Automatic cleanup ensures timeouts are cleared when: +- Automatic cleanup ensures timeouts and requests are cancelled when: - Component unmounts - Delay value changes + - New debounced call is triggered -```tsx {8,11,18,21,24} -// ✅ Clean, declarative approach +```tsx {8,11,14,18,21,24} +// ✅ Clean, declarative approach with automatic abort handling function SearchInput() { const [query, setQuery] = useState('') const [results, setResults] = useState([]) @@ -155,9 +113,10 @@ function SearchInput() { immediateCallback: (searchTerm) => { setQuery(searchTerm) // Update UI immediately }, - callbackToBounce: async (searchTerm) => { + callbackToBounce: async (signal, searchTerm) => { + // signal is automatically provided - use it in fetch! if (searchTerm.trim()) { - const response = await fetch(`/api/search?q=${searchTerm}`) + const response = await fetch(`/api/search?q=${searchTerm}`, { signal }) const data = await response.json() setResults(data.results) } @@ -166,6 +125,7 @@ function SearchInput() { console.log('runs after successful completion of callbackToBounce') }, onError: (error, searchTerm) => { + // AbortError is automatically filtered out - only real errors trigger this console.error('Search failed:', error) }, delay: 500, @@ -181,31 +141,133 @@ function SearchInput() { ::: details **Performance Benefits** - **Reduces execution frequency:** Limits function calls during rapid user input -- **Memory efficient:** Proper cleanup prevents memory leaks from pending timeouts +- **Automatic request cancellation:** Prevents race conditions by aborting stale requests +- **Memory efficient:** Proper cleanup prevents memory leaks from pending timeouts and requests - **Stable references:** Function reference remains stable across re-renders - **Immediate UI updates:** `immediateCallback` ensures responsive user experience - **Async-aware:** `onSuccess` waits for async operations to complete -- **Error resilience:** `onError` handles failures gracefully without breaking the UI +- **Error resilience:** `onError` handles failures gracefully (AbortError is automatically filtered out) ::: +## AbortSignal Support + +The hook automatically manages AbortController for you. The `callbackToBounce` function receives an AbortSignal as its first parameter, which you can pass to `fetch()` or other abortable operations. + +**Key behaviors:** + +- When a new debounced call is triggered, the previous async operation is automatically aborted +- `AbortError` exceptions are automatically filtered out and won't trigger `onError` +- Only real errors (network failures, API errors, etc.) will trigger the `onError` callback +- On component unmount, all pending operations are aborted + +```tsx +const { debouncedFn } = useDebouncedFn({ + callbackToBounce: async (signal, searchTerm) => { + // ✅ Pass signal to fetch + const response = await fetch(`/api/search?q=${searchTerm}`, { signal }) + const data = await response.json() + return data + }, + onError: (error, searchTerm) => { + // ✅ This will NOT be called for AbortError + // Only called for real errors (network issues, 500 errors, etc.) + console.error('Real error occurred:', error) + }, +}) +``` + +## Execution Flow for the callbacks + +```tsx +function SearchInput() { + const [query, setQuery] = useState('') + const [results, setResults] = useState([]) + + const { debouncedFn } = useDebouncedFn({ + /* searchTerm is type-safe for all of the callbacks. */ + immediateCallback: (searchTerm) => { + setQuery(searchTerm) /* Update UI immediately */ + }, + callbackToBounce: async (signal, searchTerm) => { + // No try-catch needed for AbortError + // Error is handled within `onError` callback (except AbortError) + const response = await fetch(`/api/search?q=${searchTerm}`, { signal }) + const data = await response.json() + setResults(data.results) + }, + onSuccess: (searchTerm) => { + console.log('runs after successful completion of callbackToBounce') + }, + onError: (error, searchTerm) => { + console.log('runs if error occurs (AbortError filtered out)', error) + }, + onFinally: (searchTerm) => { + console.log('runs after all of the callbacks (even on abort)') + }, + }) + + /* debouncedFn is aware of its type. String argument must be provided. */ + return debouncedFn(e.target.value)} placeholder='Search...' /> +} +``` + +## Callback Execution Order + +The callbacks execute in the following order: + +### Success Flow + +1. **immediateCallback** - Executes synchronously when `debouncedFn` is called +2. **callbackToBounce** - Executes after the delay period (receives AbortSignal as first parameter) +3. **onSuccess** - Executes after `callbackToBounce` completes successfully +4. **onFinally** - Executes after `onSuccess` + +### Error Flow + +1. **immediateCallback** - Executes synchronously when `debouncedFn` is called +2. **callbackToBounce** - Executes after the delay period and throws an error +3. **onError** - Executes when error is caught (receives the error and all arguments) - **AbortError is automatically filtered out** +4. **onFinally** - Executes after `onError` + +### Abort Flow + +1. **immediateCallback** - Executes synchronously when `debouncedFn` is called +2. **callbackToBounce** - Starts executing after delay, but gets aborted +3. **onError** - Does NOT execute (AbortError is filtered) +4. **onFinally** - Still executes + +::: tip + +- `onSuccess` and `onError` are mutually exclusive - only one will run per execution +- `onFinally` always runs, regardless of success, error, or abort +- All callbacks except `onError` receive the same arguments passed to `debouncedFn` +- `onError` receives the error as the first argument, followed by the original arguments +- **`callbackToBounce` receives AbortSignal as the first parameter, followed by the arguments** +- AbortError is automatically filtered and won't trigger `onError` + ::: + +::: tip +The `debounced function` is purely ref based and does not change across re-renders. +::: + ## Parameters -| Parameter | Type | Required | Default Value | Description | -| ----------------- | :------------------------------: | :------: | :-----------: | ------------------------------------------------------------------- | -| callbackToBounce | [DebouncedFn](#type-definitions) | ✅ | - | The function to debounce | -| immediateCallback | [DebouncedFn](#type-definitions) | ❌ | - | Function to execute immediately before debouncing starts | -| onSuccess | [DebouncedFn](#type-definitions) | ❌ | - | Function to execute after debounced callback completes successfully | -| onError | [ErrorFn](#type-definitions) | ❌ | - | Function to execute when debounced callback throws an error | -| onFinally | [DebouncedFn](#type-definitions) | ❌ | - | Function to execute after completion (success or error) | -| delay | number | ❌ | 300ms | Delay in milliseconds before function execution | +| Parameter | Type | Required | Default Value | Description | +| ----------------- | :------------------------------: | :------: | :-----------: | ------------------------------------------------------------------------------- | +| callbackToBounce | [DebouncedFn](#type-definitions) | ✅ | - | The function to debounce (receives AbortSignal as first parameter) | +| immediateCallback | [DebouncedFn](#type-definitions) | ❌ | - | Function to execute immediately before debouncing starts | +| onSuccess | [DebouncedFn](#type-definitions) | ❌ | - | Function to execute after debounced callback completes successfully | +| onError | [ErrorFn](#type-definitions) | ❌ | - | Function to execute when debounced callback throws an error (except AbortError) | +| onFinally | [DebouncedFn](#type-definitions) | ❌ | - | Function to execute after completion (success, error, or abort) | +| delay | number | ❌ | 300ms | Delay in milliseconds before function execution | ### Type Definitions ::: details ```ts -export type DebouncedFn any> = (...args: Parameters) => void +export type DebouncedFn any> = (signal: AbortSignal, ...args: Parameters) => void export type ErrorFn any> = (error: Error, ...args: Parameters) => void // Function overloads for type safety @@ -218,7 +280,7 @@ export function useDebouncedFn({ delay, }: { immediateCallback?: (...args: any[]) => void - callbackToBounce: (...args: any[]) => void + callbackToBounce: (signal: AbortSignal, ...args: any[]) => void onSuccess?: (...args: any[]) => void onError?: (error: Error, ...args: any[]) => void onFinally?: (...args: any[]) => void @@ -238,7 +300,7 @@ export function useDebouncedFn({ delay, }: { immediateCallback?: (ev: Ev, ...args: Args) => void - callbackToBounce: (ev: Ev, ...args: Args) => void + callbackToBounce: (signal: AbortSignal, ev: Ev, ...args: Args) => void onSuccess?: (ev: Ev, ...args: Args) => void onError?: (error: Error, ev: Ev, ...args: Args) => void onFinally?: (ev: Ev, ...args: Args) => void @@ -258,21 +320,21 @@ The hook returns an object with the debounced function and a cleanup function. | Return Value | Type | Description | | ------------- | ---------------------------------- | --------------------------------------------------------------------------------------- | | `debouncedFn` | `(...args: Parameters) => void` | Debounced version of the original function that delays execution by the specified delay | -| `cleanup` | `() => void` | Manual cleanup function to clear pending timeouts | +| `cleanup` | `() => void` | Manual cleanup function to clear pending timeouts and abort pending requests | ## Common Use Cases -- **Search functionality:** Debouncing search queries to reduce API calls with immediate UI updates and error handling -- **API rate limiting:** Preventing excessive API requests with proper error handling -- **Form validation:** Debouncing validation with loading states and error feedback -- **Auto-save:** Debouncing save operations with completion callbacks and error recovery +- **Search functionality:** Debouncing search queries to reduce API calls with automatic request cancellation, immediate UI updates, and error handling +- **API rate limiting:** Preventing excessive API requests with proper error handling and request cancellation +- **Form validation:** Debouncing validation with loading states, error feedback, and automatic abort of stale validations +- **Auto-save:** Debouncing save operations with completion callbacks, error recovery, and request cancellation - **Resize/scroll handlers:** Optimizing expensive DOM operations with error boundaries ## Usage Examples -### Basic Search with Immediate UI Update +### Basic Search with AbortSignal -```tsx {8-21} +```tsx {8-24} import { useState } from 'react' import { useDebouncedFn } from 'classic-react-hooks' @@ -284,9 +346,13 @@ export default function SearchExample() { immediateCallback: (searchTerm: string) => { setQuery(searchTerm) // Update input immediately }, - callbackToBounce: async (searchTerm: string) => { + callbackToBounce: async (signal, searchTerm: string) => { if (searchTerm.trim()) { - const response = await fetch(`https://api.example.com/search?q=${searchTerm}`) + // Pass signal to fetch - request will be automatically cancelled + // if user types again before this completes + const response = await fetch(`https://api.example.com/search?q=${searchTerm}`, { + signal, + }) const data = await response.json() setResults(data.results) } else { @@ -309,9 +375,9 @@ export default function SearchExample() { } ``` -### Auto-save with Loading State and Error Handling +### Auto-save with Loading State and Request Cancellation -```tsx {7-34} +```tsx {7-37} import { useState } from 'react' import { useDebouncedFn } from 'classic-react-hooks' @@ -326,11 +392,13 @@ export default function AutoSaveEditor() { setContent(text) // Update editor immediately setError(null) // Clear previous errors }, - callbackToBounce: async (text: string) => { + callbackToBounce: async (signal, text: string) => { setIsSaving(true) + // Previous save request will be automatically cancelled const response = await fetch('/api/save', { method: 'POST', body: JSON.stringify({ content: text }), + signal, // Pass the signal }) if (!response.ok) throw new Error('Save failed') }, @@ -338,6 +406,7 @@ export default function AutoSaveEditor() { setLastSaved(new Date()) }, onError: (err) => { + // AbortError won't trigger this - only real errors setError(err.message) }, onFinally: () => { @@ -359,9 +428,9 @@ export default function AutoSaveEditor() { } ``` -### Form Validation with Status Tracking +### Form Validation with Status Tracking and Abort -```tsx {7-38} +```tsx {7-41} import { useState } from 'react' import { useDebouncedFn } from 'classic-react-hooks' @@ -377,16 +446,20 @@ export default function UsernameValidator() { setIsAvailable(null) // Reset validation state setError(null) }, - callbackToBounce: async (value: string) => { + callbackToBounce: async (signal, value: string) => { if (value.length < 3) return setIsValidating(true) - const response = await fetch(`/api/check-username?name=${value}`) + // Previous validation will be automatically cancelled + const response = await fetch(`/api/check-username?name=${value}`, { + signal, + }) if (!response.ok) throw new Error('Validation failed') const data = await response.json() setIsAvailable(data.available) }, onError: (err) => { + // Only real errors trigger this (not AbortError) setError(err.message) setIsAvailable(null) }, @@ -409,7 +482,7 @@ export default function UsernameValidator() { ### Manual Cleanup Example -```tsx {8-18,24} +```tsx {8-21,27} import { useState } from 'react' import { useDebouncedFn } from 'classic-react-hooks' @@ -418,8 +491,10 @@ export default function SearchWithCancel() { const [results, setResults] = useState([]) const { debouncedFn, cleanup } = useDebouncedFn({ - callbackToBounce: async (searchTerm: string) => { - const response = await fetch(`/api/search?q=${searchTerm}`) + callbackToBounce: async (signal, searchTerm: string) => { + const response = await fetch(`/api/search?q=${searchTerm}`, { + signal, + }) const data = await response.json() setResults(data.results) }, @@ -429,7 +504,7 @@ export default function SearchWithCancel() { const handleClear = () => { setQuery('') setResults([]) - cleanup() // Cancel any pending debounced calls + cleanup() // Cancel pending timeout AND abort any in-flight request } return ( @@ -446,9 +521,9 @@ export default function SearchWithCancel() { } ``` -### Type-safe Event Handling +### Type-safe Event Handling with AbortSignal -```tsx {8-23} +```tsx {8-26} import { useDebouncedFn } from 'classic-react-hooks' export default function TypeSafeExample() { @@ -456,9 +531,13 @@ export default function TypeSafeExample() { immediateCallback: (event) => { console.log('Immediate:', event.target.value) }, - callbackToBounce: (event) => { - // Full type safety for event object - console.log('Debounced:', event.target.value) + callbackToBounce: async (signal, event) => { + // Full type safety for signal and event object + const response = await fetch(`/api/process?value=${event.target.value}`, { + signal, + }) + const data = await response.json() + console.log('Debounced:', data) }, onSuccess: (event) => { console.log('Completed for:', event.target.value) @@ -475,7 +554,7 @@ export default function TypeSafeExample() { ### Complex Workflow with All Callbacks -```tsx {7-44} +```tsx {7-47} import { useState } from 'react' import { useDebouncedFn } from 'classic-react-hooks' @@ -492,12 +571,15 @@ export default function CompleteExample() { setError(null) console.log('User typed:', searchTerm) }, - callbackToBounce: async (searchTerm) => { + callbackToBounce: async (signal, searchTerm) => { // 2. Runs after delay (debounced) setIsLoading(true) console.log('Searching for:', searchTerm) - const response = await fetch(`/api/search?q=${searchTerm}`) + // Previous request is automatically aborted when new one starts + const response = await fetch(`/api/search?q=${searchTerm}`, { + signal, + }) if (!response.ok) throw new Error('Search failed') const data = await response.json() @@ -509,12 +591,13 @@ export default function CompleteExample() { }, onError: (err, searchTerm) => { // 3. Runs if error occurs (instead of onSuccess) + // Note: AbortError is filtered out automatically console.error('Search failed for:', searchTerm, err) setError(err.message) setResults([]) }, onFinally: (searchTerm) => { - // 4. Always runs at the end + // 4. Always runs at the end (even after abort) setIsLoading(false) console.log('Finished processing:', searchTerm) }, @@ -536,13 +619,53 @@ export default function CompleteExample() { } ``` +### Custom Abortable Operation + +```tsx {7-30} +import { useState } from 'react' +import { useDebouncedFn } from 'classic-react-hooks' + +export default function CustomAbortExample() { + const [result, setResult] = useState('') + + const { debouncedFn } = useDebouncedFn({ + callbackToBounce: async (signal, value: string) => { + // You can use the signal for custom abort logic + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + resolve(`Processed: ${value}`) + }, 2000) + + // Listen to abort signal + signal.addEventListener('abort', () => { + clearTimeout(timeoutId) + reject(new DOMException('Aborted', 'AbortError')) + }) + }) + }, + onSuccess: (value) => { + setResult(`Success: ${value}`) + }, + delay: 500, + }) + + return ( +
+ debouncedFn(e.target.value)} placeholder='Type to trigger...' /> +
{result}
+
+ ) +} +``` + ## Things to keep in mind -- **Use `immediateCallback`** for synchronous UI updates to maintain responsive user experience -- **Use `onSuccess`** when you need to track completion of async operations (loading states, success messages) -- **Use `onError`** to handle failures gracefully and display error messages to users -- **Use `onFinally`** for cleanup operations that should run regardless of success or failure (e.g., hiding loading spinners) -- **Use `cleanup`** when you need to cancel pending operations (navigation, unmounting child components) -- Keep the `delay` value reasonable (300-600ms for search, 1000-2000ms for auto-save) -- No need to memoize the callbacks. No stale closures problems anymore. -- Error handling is built-in - no need for try-catch blocks in `callbackToBounce` when using `onError` +- Always use the `signal` parameter in `callbackToBounce` for fetches and other abortable operations to avoid race conditions +- Use `immediateCallback` for synchronous UI updates to keep interactions responsive +- Use `onSuccess` to track successful async completion (e.g., loading states, success messages) +- Use `onError` for graceful failure handling and user-facing errors — `AbortError` is filtered automatically +- Use `onFinally` for cleanup logic that must run on success, failure, or abort (e.g., hiding spinners) +- Use `cleanup` to cancel pending work and abort in-flight requests (navigation, unmounting) +- No callback memoization required — no stale-closure issues +- Built-in error handling removes the need for `try/catch` in `callbackToBounce` +- Abort signals are managed automatically — previous operations are cancelled on new calls or unmount diff --git a/src/lib/use-debounced-fn/index.test.tsx b/src/lib/use-debounced-fn/index.test.tsx index a970335..e493df7 100644 --- a/src/lib/use-debounced-fn/index.test.tsx +++ b/src/lib/use-debounced-fn/index.test.tsx @@ -1,6 +1,7 @@ import { vi } from 'vitest' -import { renderHook, act } from '@testing-library/react' +import { renderHook } from '@testing-library/react' import { useDebouncedFn } from '.' +import { act } from 'react' describe('use-debounced-fn', () => { beforeEach(() => { @@ -32,34 +33,30 @@ describe('use-debounced-fn', () => { const callback = vi.fn() const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: callback, delay: 300 })) - act(() => { - result.current.debouncedFn() - }) + result.current.debouncedFn() expect(callback).not.toHaveBeenCalled() }) }) describe('unmounting', () => { - it('should cleanup timer on unmount', () => { + it('should cleanup timer on unmount', async () => { const callback = vi.fn() const { result, unmount } = renderHook(() => useDebouncedFn({ callbackToBounce: callback, delay: 500 })) - act(() => { - result.current.debouncedFn() - }) + result.current.debouncedFn() unmount() - act(() => { + await act(() => { vi.advanceTimersByTime(600) }) expect(callback).not.toHaveBeenCalled() }) - it('should cleanup multiple pending timers on unmount', () => { + it('should cleanup multiple pending timers on unmount', async () => { const callback = vi.fn() const { result, unmount } = renderHook(() => useDebouncedFn({ callbackToBounce: callback, delay: 300 })) @@ -74,32 +71,62 @@ describe('use-debounced-fn', () => { unmount() - act(() => { + await act(() => { vi.advanceTimersByTime(500) }) expect(callback).not.toHaveBeenCalled() }) - it('should not cause memory leaks with repeated mount/unmount', () => { + it('should not cause memory leaks with repeated mount/unmount', async () => { const callback = vi.fn() for (let i = 0; i < 10; i++) { const { result, unmount } = renderHook(() => useDebouncedFn({ callbackToBounce: callback, delay: 100 })) - act(() => { - result.current.debouncedFn() - }) + result.current.debouncedFn() unmount() } - act(() => { + await act(() => { vi.advanceTimersByTime(200) }) expect(callback).not.toHaveBeenCalled() }) + + it('should abort pending async operations on unmount', () => { + const callback = vi.fn(async (signal: AbortSignal) => { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => resolve('completed'), 100) + signal.addEventListener('abort', () => { + clearTimeout(timeout) + reject(new DOMException('Aborted', 'AbortError')) + }) + }) + }) + const onError = vi.fn() + + const { result, unmount } = renderHook(() => + useDebouncedFn({ callbackToBounce: callback, onError, delay: 300 }) + ) + + result.current.debouncedFn() + + act(() => { + vi.advanceTimersByTime(300) + }) + + unmount() + + act(() => { + vi.advanceTimersByTime(100) + }) + + // AbortError should be caught but not passed to onError + expect(onError).not.toHaveBeenCalled() + }) }) describe('Debouncing behavior', () => { @@ -107,9 +134,7 @@ describe('use-debounced-fn', () => { const callback = vi.fn() const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: callback })) - act(() => { - result.current.debouncedFn() - }) + result.current.debouncedFn() act(() => { vi.advanceTimersByTime(299) @@ -127,9 +152,7 @@ describe('use-debounced-fn', () => { const customDelay = 500 const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: callback, delay: customDelay })) - act(() => { - result.current.debouncedFn() - }) + result.current.debouncedFn() act(() => { vi.advanceTimersByTime(499) @@ -142,7 +165,7 @@ describe('use-debounced-fn', () => { expect(callback).toHaveBeenCalledTimes(1) }) - it('should debounce multiple rapid calls', () => { + it('should debounce multiple rapid calls', async () => { const callback = vi.fn() const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: callback, delay: 200 })) @@ -157,60 +180,201 @@ describe('use-debounced-fn', () => { result.current.debouncedFn() // Call 4 - should reset timer again }) - act(() => { + await act(() => { vi.advanceTimersByTime(199) }) expect(callback).not.toHaveBeenCalled() - act(() => { + await act(() => { vi.advanceTimersByTime(1) // 200ms from last call }) expect(callback).toHaveBeenCalledTimes(1) }) - it('should allow multiple executions after delay periods', () => { + it('should allow multiple executions after delay periods', async () => { const callback = vi.fn() const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: callback, delay: 100 })) // First execution - act(() => { - result.current.debouncedFn('first') - }) - act(() => { + result.current.debouncedFn('first') + await act(() => { vi.advanceTimersByTime(100) }) expect(callback).toHaveBeenCalledTimes(1) - expect(callback).toHaveBeenNthCalledWith(1, 'first') + expect(callback).toHaveBeenNthCalledWith(1, expect.any(AbortSignal), 'first') // Second execution - act(() => { - result.current.debouncedFn('second') - }) - act(() => { + result.current.debouncedFn('second') + await act(() => { vi.advanceTimersByTime(100) }) expect(callback).toHaveBeenCalledTimes(2) - expect(callback).toHaveBeenNthCalledWith(2, 'second') + expect(callback).toHaveBeenNthCalledWith(2, expect.any(AbortSignal), 'second') }) }) - describe('Argument passing to callback', () => { - it('should pass arguments correctly to the callback', () => { + describe('AbortSignal behavior', () => { + it('should pass AbortSignal as first argument to callbackToBounce', () => { const callback = vi.fn() const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: callback })) + result.current.debouncedFn('arg1', 'arg2', 123) + vi.advanceTimersByTime(300) + + expect(callback).toHaveBeenCalledTimes(1) + const callArgs = callback.mock.calls[0]! + expect(callArgs[0]).toBeInstanceOf(AbortSignal) + expect(callArgs[1]).toBe('arg1') + expect(callArgs[2]).toBe('arg2') + expect(callArgs[3]).toBe(123) + }) + + it('should abort previous operation when new call is made', async () => { + let abortedCount = 0 + const callback = vi.fn(async (signal: AbortSignal, value: string) => { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => resolve(value), 100) + signal.addEventListener('abort', () => { + abortedCount++ + clearTimeout(timeout) + reject(new DOMException('Aborted', 'AbortError')) + }) + }) + }) + + const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: callback, delay: 200 })) + + result.current.debouncedFn('first') + + await act(() => { + vi.advanceTimersByTime(200) + }) + + result.current.debouncedFn('second') // Should abort first + + expect(abortedCount).toBe(1) + }) + + it('should not call onError when AbortError is thrown', async () => { + const callback = vi.fn(async (signal: AbortSignal) => { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => resolve('completed'), 100) + signal.addEventListener('abort', () => { + clearTimeout(timeout) + reject(new DOMException('Aborted', 'AbortError')) + }) + }) + }) + const onError = vi.fn() + + const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: callback, onError, delay: 300 })) + + result.current.debouncedFn() + + await act(() => { + vi.advanceTimersByTime(300) + }) + + // Trigger abort by calling again + result.current.debouncedFn() + + // AbortError should be caught internally and not trigger onError + expect(onError).not.toHaveBeenCalled() + }) + + it('should call onError for non-abort errors', async () => { + const testError = new Error('Regular error') + const callback = vi.fn(async (signal: AbortSignal) => { + throw testError + }) + const onError = vi.fn() + + const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: callback, onError, delay: 300 })) + + result.current.debouncedFn('test') + + await act(() => { + vi.advanceTimersByTime(300) + }) + + expect(onError).toHaveBeenCalledWith(testError, 'test') + }) + + it('should provide non-aborted signal on first execution', async () => { + const callback = vi.fn((signal: AbortSignal) => { + expect(signal.aborted).toBe(false) + }) + + const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: callback })) + + result.current.debouncedFn() + + await act(() => { + vi.advanceTimersByTime(300) + }) + + expect(callback).toHaveBeenCalledTimes(1) + }) + + it('should handle fetch requests with abort signal', async () => { + const mockFetch = vi.fn((url: string, options: any) => { + return new Promise((resolve, reject) => { + const timeout = setTimeout( + () => resolve({ ok: true, json: () => Promise.resolve({ data: 'test' }) }), + 100 + ) + options.signal.addEventListener('abort', () => { + clearTimeout(timeout) + reject(new DOMException('Aborted', 'AbortError')) + }) + }) + }) + + global.fetch = mockFetch as any + + const callback = vi.fn(async (signal: AbortSignal, query: string) => { + const response = await fetch(`/api/search?q=${query}`, { signal }) + return response + }) + + const onError = vi.fn() + + const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: callback, onError, delay: 300 })) + act(() => { - result.current.debouncedFn('arg1', 'arg2', 123) + result.current.debouncedFn('first') }) act(() => { vi.advanceTimersByTime(300) }) - expect(callback).toHaveBeenCalledWith('arg1', 'arg2', 123) + // Cancel with new call before fetch completes + act(() => { + result.current.debouncedFn('second') + }) + + await act(() => { + vi.advanceTimersByTime(200) + }) + + // First fetch should be aborted, onError should not be called for AbortError + expect(onError).not.toHaveBeenCalled() }) + }) - it('should use arguments from the latest call', () => { + describe('Argument passing to callback', () => { + it('should pass arguments correctly to the callback', () => { + const callback = vi.fn() + const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: callback })) + + result.current.debouncedFn('arg1', 'arg2', 123) + vi.advanceTimersByTime(300) + + expect(callback).toHaveBeenCalledWith(expect.any(AbortSignal), 'arg1', 'arg2', 123) + }) + + it('should use arguments from the latest call', async () => { const callback = vi.fn() const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: callback, delay: 200 })) @@ -220,12 +384,12 @@ describe('use-debounced-fn', () => { result.current.debouncedFn('third') // This should be the final call }) - act(() => { + await act(() => { vi.advanceTimersByTime(200) }) expect(callback).toHaveBeenCalledTimes(1) - expect(callback).toHaveBeenCalledWith('third') + expect(callback).toHaveBeenCalledWith(expect.any(AbortSignal), 'third') }) it('should preserve argument references', () => { @@ -245,8 +409,8 @@ describe('use-debounced-fn', () => { vi.advanceTimersByTime(300) }) - expect(callback).toHaveBeenCalledWith(originalObj) - expect(callback.mock.calls?.[0]?.[0].value).toBe('modified') + expect(callback).toHaveBeenCalledWith(expect.any(AbortSignal), originalObj) + expect(callback.mock.calls?.[0]?.[1].value).toBe('modified') }) }) @@ -291,7 +455,7 @@ describe('use-debounced-fn', () => { }) expect(callback).toHaveBeenCalledTimes(1) - expect(callback).toHaveBeenCalledWith('third') + expect(callback).toHaveBeenCalledWith(expect.any(AbortSignal), 'third') }) it('should pass all arguments to immediateCallback', () => { @@ -301,9 +465,7 @@ describe('use-debounced-fn', () => { useDebouncedFn({ immediateCallback: immediate, callbackToBounce: callback }) ) - act(() => { - result.current.debouncedFn('arg1', 42, { key: 'value' }) - }) + result.current.debouncedFn('arg1', 42, { key: 'value' }) expect(immediate).toHaveBeenCalledWith('arg1', 42, { key: 'value' }) }) @@ -312,74 +474,60 @@ describe('use-debounced-fn', () => { const callback = vi.fn() const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: callback })) - act(() => { - result.current.debouncedFn('test') - }) - - act(() => { - vi.advanceTimersByTime(300) - }) + result.current.debouncedFn('test') + vi.advanceTimersByTime(300) - expect(callback).toHaveBeenCalledWith('test') + expect(callback).toHaveBeenCalledWith(expect.any(AbortSignal), 'test') }) }) describe('onSuccess', () => { - it('should call onSuccess after sync callbackToBounce completes', () => { + it('should call onSuccess after sync callbackToBounce completes', async () => { const callback = vi.fn() const onSuccess = vi.fn() const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: callback, onSuccess, delay: 300 })) - act(() => { - result.current.debouncedFn('test') - }) + result.current.debouncedFn('test') - act(() => { + // Wait for the scheduled callbacks to get resolved + // then check the status + await act(() => { vi.advanceTimersByTime(300) }) - expect(callback).toHaveBeenCalledWith('test') + expect(callback).toHaveBeenCalledWith(expect.any(AbortSignal), 'test') expect(onSuccess).toHaveBeenCalledWith('test') expect(callback).toHaveBeenCalledBefore(onSuccess) }) it('should call onSuccess after async callbackToBounce completes', async () => { - const callback = vi.fn(async (val: string) => { + const callback = vi.fn(async (signal: AbortSignal, val: string) => { await new Promise((resolve) => setTimeout(resolve, 100)) return val }) const onSuccess = vi.fn() const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: callback, onSuccess, delay: 300 })) - act(() => { - result.current.debouncedFn('async-test') - }) - - act(() => { - vi.advanceTimersByTime(300) - }) + result.current.debouncedFn('async-test') + vi.advanceTimersByTime(300) - expect(callback).toHaveBeenCalledWith('async-test') + expect(callback).toHaveBeenCalledWith(expect.any(AbortSignal), 'async-test') - // Advance timers for the async operation - await act(async () => { + await act(() => { vi.advanceTimersByTime(100) - await Promise.resolve() }) expect(onSuccess).toHaveBeenCalledWith('async-test') }) - it('should pass same arguments to onSuccess', () => { + it('should pass same arguments to onSuccess', async () => { const callback = vi.fn() const onSuccess = vi.fn() const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: callback, onSuccess })) - act(() => { - result.current.debouncedFn('arg1', 123, { nested: true }) - }) + result.current.debouncedFn('arg1', 123, { nested: true }) - act(() => { + await act(() => { vi.advanceTimersByTime(300) }) @@ -398,29 +546,27 @@ describe('use-debounced-fn', () => { vi.advanceTimersByTime(300) }) - expect(callback).toHaveBeenCalledWith('test') + expect(callback).toHaveBeenCalledWith(expect.any(AbortSignal), 'test') }) - it('should not call onSuccess if execution is cancelled', () => { + it('should not call onSuccess if execution is cancelled', async () => { const callback = vi.fn() const onSuccess = vi.fn() const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: callback, onSuccess, delay: 300 })) - act(() => { - result.current.debouncedFn('first') - }) + result.current.debouncedFn('first') - act(() => { + await act(() => { vi.advanceTimersByTime(100) result.current.debouncedFn('second') // Cancels first }) - act(() => { + await act(() => { vi.advanceTimersByTime(300) }) expect(callback).toHaveBeenCalledTimes(1) - expect(callback).toHaveBeenCalledWith('second') + expect(callback).toHaveBeenCalledWith(expect.any(AbortSignal), 'second') expect(onSuccess).toHaveBeenCalledTimes(1) expect(onSuccess).toHaveBeenCalledWith('second') }) @@ -435,15 +581,10 @@ describe('use-debounced-fn', () => { const onError = vi.fn() const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: callback, onError, delay: 300 })) - act(() => { - result.current.debouncedFn('test') - }) - - act(() => { - vi.advanceTimersByTime(300) - }) + result.current.debouncedFn('test') + vi.advanceTimersByTime(300) - expect(callback).toHaveBeenCalledWith('test') + expect(callback).toHaveBeenCalledWith(expect.any(AbortSignal), 'test') expect(onError).toHaveBeenCalledWith(error, 'test') }) @@ -455,13 +596,8 @@ describe('use-debounced-fn', () => { const onError = vi.fn() const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: callback, onError })) - act(() => { - result.current.debouncedFn('arg1', 42, { key: 'value' }) - }) - - act(() => { - vi.advanceTimersByTime(300) - }) + result.current.debouncedFn('arg1', 42, { key: 'value' }) + vi.advanceTimersByTime(300) expect(onError).toHaveBeenCalledWith(error, 'arg1', 42, { key: 'value' }) }) @@ -476,53 +612,43 @@ describe('use-debounced-fn', () => { useDebouncedFn({ callbackToBounce: callback, onSuccess, onError, delay: 300 }) ) - act(() => { - result.current.debouncedFn('test') - }) - - act(() => { - vi.advanceTimersByTime(300) - }) + result.current.debouncedFn('test') + vi.advanceTimersByTime(300) expect(onError).toHaveBeenCalled() expect(onSuccess).not.toHaveBeenCalled() }) - it('should work without onError (error is not caught)', () => { + it('should work without onError (error is not caught)', async () => { const callback = vi.fn(() => { throw new Error('Test error') }) const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: callback })) - act(() => { - result.current.debouncedFn('test') - }) - + result.current.debouncedFn('test') // Error is thrown but not caught - act(() => { + await act(() => { vi.advanceTimersByTime(300) }) - expect(callback).toHaveBeenCalledWith('test') + expect(callback).toHaveBeenCalledWith(expect.any(AbortSignal), 'test') }) - it('should not call onError if execution is cancelled', () => { + it('should not call onError if execution is cancelled', async () => { const callback = vi.fn(() => { throw new Error('Test error') }) const onError = vi.fn() const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: callback, onError, delay: 300 })) - act(() => { - result.current.debouncedFn('first') - }) + result.current.debouncedFn('first') - act(() => { + await act(() => { vi.advanceTimersByTime(100) result.current.debouncedFn('second') // Cancels first }) - act(() => { + await act(() => { vi.advanceTimersByTime(300) }) @@ -532,20 +658,18 @@ describe('use-debounced-fn', () => { }) describe('onFinally', () => { - it('should call onFinally after successful execution', () => { + it('should call onFinally after successful execution', async () => { const callback = vi.fn() const onFinally = vi.fn() const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: callback, onFinally, delay: 300 })) - act(() => { - result.current.debouncedFn('test') - }) + result.current.debouncedFn('test') - act(() => { + await act(() => { vi.advanceTimersByTime(300) }) - expect(callback).toHaveBeenCalledWith('test') + expect(callback).toHaveBeenCalledWith(expect.any(AbortSignal), 'test') expect(onFinally).toHaveBeenCalledWith('test') }) @@ -556,27 +680,20 @@ describe('use-debounced-fn', () => { const onFinally = vi.fn() const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: callback, onFinally, delay: 300 })) - act(() => { - result.current.debouncedFn('test') - }) - - act(() => { - vi.advanceTimersByTime(300) - }) + result.current.debouncedFn('test') + vi.advanceTimersByTime(300) expect(onFinally).toHaveBeenCalledWith('test') }) - it('should call onFinally with all arguments', () => { + it('should call onFinally with all arguments', async () => { const callback = vi.fn() const onFinally = vi.fn() const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: callback, onFinally })) - act(() => { - result.current.debouncedFn('arg1', 42, { key: 'value' }) - }) + result.current.debouncedFn('arg1', 42, { key: 'value' }) - act(() => { + await act(() => { vi.advanceTimersByTime(300) }) @@ -587,32 +704,25 @@ describe('use-debounced-fn', () => { const callback = vi.fn() const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: callback })) - act(() => { - result.current.debouncedFn('test') - }) + result.current.debouncedFn('test') + vi.advanceTimersByTime(300) - act(() => { - vi.advanceTimersByTime(300) - }) - - expect(callback).toHaveBeenCalledWith('test') + expect(callback).toHaveBeenCalledWith(expect.any(AbortSignal), 'test') }) - it('should not call onFinally if execution is cancelled', () => { + it('should not call onFinally if execution is cancelled', async () => { const callback = vi.fn() const onFinally = vi.fn() const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: callback, onFinally, delay: 300 })) - act(() => { - result.current.debouncedFn('first') - }) + result.current.debouncedFn('first') - act(() => { + await act(() => { vi.advanceTimersByTime(100) result.current.debouncedFn('second') // Cancels first }) - act(() => { + await act(() => { vi.advanceTimersByTime(300) }) @@ -622,7 +732,7 @@ describe('use-debounced-fn', () => { }) describe('All callbacks together', () => { - it('should execute callbacks in correct order: immediate -> debounced -> success -> finally', () => { + it('should execute callbacks in correct order: immediate -> debounced -> success -> finally', async () => { const executionOrder: string[] = [] const immediate = vi.fn(() => executionOrder.push('immediate')) const callback = vi.fn(() => executionOrder.push('debounced')) @@ -639,13 +749,11 @@ describe('use-debounced-fn', () => { }) ) - act(() => { - result.current.debouncedFn('test') - }) + result.current.debouncedFn('test') expect(executionOrder).toEqual(['immediate']) - act(() => { + await act(() => { vi.advanceTimersByTime(300) }) @@ -672,20 +780,14 @@ describe('use-debounced-fn', () => { }) ) - act(() => { - result.current.debouncedFn('test') - }) - + result.current.debouncedFn('test') expect(executionOrder).toEqual(['immediate']) - - act(() => { - vi.advanceTimersByTime(300) - }) + vi.advanceTimersByTime(300) expect(executionOrder).toEqual(['immediate', 'debounced', 'error', 'finally']) }) - it('should pass same arguments to all callbacks', () => { + it('should pass same arguments to all callbacks (except AbortSignal to debounced)', async () => { const immediate = vi.fn() const callback = vi.fn() const onSuccess = vi.fn() @@ -702,22 +804,20 @@ describe('use-debounced-fn', () => { const testObj = { id: 1, name: 'test' } - act(() => { - result.current.debouncedFn(testObj, 'extra', 42) - }) + result.current.debouncedFn(testObj, 'extra', 42) expect(immediate).toHaveBeenCalledWith(testObj, 'extra', 42) - act(() => { + await act(() => { vi.advanceTimersByTime(300) }) - expect(callback).toHaveBeenCalledWith(testObj, 'extra', 42) + expect(callback).toHaveBeenCalledWith(expect.any(AbortSignal), testObj, 'extra', 42) expect(onSuccess).toHaveBeenCalledWith(testObj, 'extra', 42) expect(onFinally).toHaveBeenCalledWith(testObj, 'extra', 42) }) - it('should pass same arguments to error and finally callbacks', () => { + it('should pass same arguments to error and finally callbacks', async () => { const immediate = vi.fn() const callback = vi.fn(() => { throw new Error('Test error') @@ -742,9 +842,7 @@ describe('use-debounced-fn', () => { expect(immediate).toHaveBeenCalledWith(testObj, 'extra', 42) - act(() => { - vi.advanceTimersByTime(300) - }) + await vi.advanceTimersByTime(300) expect(onError).toHaveBeenCalledWith(expect.any(Error), testObj, 'extra', 42) expect(onFinally).toHaveBeenCalledWith(testObj, 'extra', 42) @@ -752,63 +850,40 @@ describe('use-debounced-fn', () => { }) describe('Manual cleanup', () => { - it('should cancel pending execution when cleanup is called', () => { + it('should cancel pending execution when cleanup is called', async () => { const callback = vi.fn() const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: callback, delay: 300 })) - act(() => { - result.current.debouncedFn('test') - }) - - act(() => { - vi.advanceTimersByTime(100) - result.current.cleanup() - }) + result.current.debouncedFn('test') + await vi.advanceTimersByTime(100) + result.current.cleanup() - act(() => { - vi.advanceTimersByTime(300) - }) + await vi.advanceTimersByTime(300) expect(callback).not.toHaveBeenCalled() }) - it('should not call onSuccess when cleanup cancels execution', () => { + it('should not call onSuccess when cleanup cancels execution', async () => { const callback = vi.fn() const onSuccess = vi.fn() const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: callback, onSuccess, delay: 300 })) - act(() => { - result.current.debouncedFn('test') - }) - - act(() => { - result.current.cleanup() - }) - - act(() => { - vi.advanceTimersByTime(300) - }) + result.current.debouncedFn('test') + result.current.cleanup() + await vi.advanceTimersByTime(300) expect(callback).not.toHaveBeenCalled() expect(onSuccess).not.toHaveBeenCalled() }) - it('should not call onFinally when cleanup cancels execution', () => { + it('should not call onFinally when cleanup cancels execution', async () => { const callback = vi.fn() const onFinally = vi.fn() const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: callback, onFinally, delay: 300 })) - act(() => { - result.current.debouncedFn('test') - }) - - act(() => { - result.current.cleanup() - }) - - act(() => { - vi.advanceTimersByTime(300) - }) + result.current.debouncedFn('test') + result.current.cleanup() + await vi.advanceTimersByTime(300) expect(callback).not.toHaveBeenCalled() expect(onFinally).not.toHaveBeenCalled() @@ -818,26 +893,48 @@ describe('use-debounced-fn', () => { const callback = vi.fn() const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: callback, delay: 300 })) - act(() => { - result.current.debouncedFn('first') - result.current.cleanup() - }) + result.current.debouncedFn('first') + result.current.cleanup() - act(() => { - result.current.debouncedFn('second') + result.current.debouncedFn('second') + + vi.advanceTimersByTime(300) + + expect(callback).toHaveBeenCalledTimes(1) + expect(callback).toHaveBeenCalledWith(expect.any(AbortSignal), 'second') + }) + + it('should abort async operations when cleanup is called', async () => { + let wasAborted = false + const callback = vi.fn(async (signal: AbortSignal) => { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => resolve('completed'), 100) + signal.addEventListener('abort', () => { + wasAborted = true + clearTimeout(timeout) + reject(new DOMException('Aborted', 'AbortError')) + }) + }) }) + const onError = vi.fn() + + const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: callback, onError, delay: 300 })) + + result.current.debouncedFn() + vi.advanceTimersByTime(300) act(() => { - vi.advanceTimersByTime(300) + result.current.cleanup() }) - expect(callback).toHaveBeenCalledTimes(1) - expect(callback).toHaveBeenCalledWith('second') + expect(wasAborted).toBe(true) + // AbortError should not trigger onError + expect(onError).not.toHaveBeenCalled() }) }) describe('Context binding', () => { - it('should not preserve this context (calls with null)', () => { + it('should not preserve this context (calls with null)', async () => { let capturedThis: any = 'not-set' const testObj = { name: 'test', @@ -848,11 +945,9 @@ describe('use-debounced-fn', () => { const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: testObj.callback })) - act(() => { - result.current.debouncedFn.call(testObj) // Try to set context - }) + result.current.debouncedFn.call(testObj) // Try to set context - act(() => { + await act(() => { vi.advanceTimersByTime(300) }) @@ -869,15 +964,11 @@ describe('use-debounced-fn', () => { initialProps: { callback: callback1 }, }) - act(() => { - result.current.debouncedFn() - }) + result.current.debouncedFn() rerender({ callback: callback2 }) - act(() => { - vi.advanceTimersByTime(300) - }) + vi.advanceTimersByTime(300) expect(callback1).not.toHaveBeenCalled() expect(callback2).toHaveBeenCalledTimes(1) @@ -890,24 +981,16 @@ describe('use-debounced-fn', () => { initialProps: { delay: 200 }, }) - act(() => { - result.current.debouncedFn() - }) + result.current.debouncedFn() rerender({ delay: 500 }) - act(() => { - vi.advanceTimersByTime(200) - }) + vi.advanceTimersByTime(200) expect(callback).not.toHaveBeenCalled() - act(() => { - result.current.debouncedFn() - }) + result.current.debouncedFn() + vi.advanceTimersByTime(500) - act(() => { - vi.advanceTimersByTime(500) - }) expect(callback).toHaveBeenCalledTimes(1) }) @@ -921,9 +1004,7 @@ describe('use-debounced-fn', () => { { initialProps: { immediate: immediate1 } } ) - act(() => { - result.current.debouncedFn('test1') - }) + result.current.debouncedFn('test1') expect(immediate1).toHaveBeenCalledWith('test1') @@ -937,7 +1018,7 @@ describe('use-debounced-fn', () => { expect(immediate1).toHaveBeenCalledTimes(1) }) - it('should update onSuccess when it changes', () => { + it('should update onSuccess when it changes', async () => { const callback = vi.fn() const onSuccess1 = vi.fn() const onSuccess2 = vi.fn() @@ -947,13 +1028,11 @@ describe('use-debounced-fn', () => { { initialProps: { onSuccess: onSuccess1 } } ) - act(() => { - result.current.debouncedFn('test1') - }) + result.current.debouncedFn('test1') rerender({ onSuccess: onSuccess2 }) - act(() => { + await act(() => { vi.advanceTimersByTime(300) }) @@ -961,7 +1040,7 @@ describe('use-debounced-fn', () => { expect(onSuccess2).toHaveBeenCalledWith('test1') }) - it('should update onError when it changes', () => { + it('should update onError when it changes', async () => { const callback = vi.fn(() => { throw new Error('Test error') }) @@ -973,13 +1052,11 @@ describe('use-debounced-fn', () => { { initialProps: { onError: onError1 } } ) - act(() => { - result.current.debouncedFn('test1') - }) + result.current.debouncedFn('test1') rerender({ onError: onError2 }) - act(() => { + await act(() => { vi.advanceTimersByTime(300) }) @@ -987,7 +1064,7 @@ describe('use-debounced-fn', () => { expect(onError2).toHaveBeenCalledWith(expect.any(Error), 'test1') }) - it('should update onFinally when it changes', () => { + it('should update onFinally when it changes', async () => { const callback = vi.fn() const onFinally1 = vi.fn() const onFinally2 = vi.fn() @@ -997,13 +1074,11 @@ describe('use-debounced-fn', () => { { initialProps: { onFinally: onFinally1 } } ) - act(() => { - result.current.debouncedFn('test1') - }) + result.current.debouncedFn('test1') rerender({ onFinally: onFinally2 }) - act(() => { + await act(() => { vi.advanceTimersByTime(300) }) @@ -1023,18 +1098,14 @@ describe('use-debounced-fn', () => { let callback = createCallback() const { result, rerender } = renderHook(() => useDebouncedFn({ callbackToBounce: callback })) - act(() => { - result.current.debouncedFn() - }) + result.current.debouncedFn() // Update both message and callback message = 'updated' callback = createCallback() rerender() - act(() => { - vi.advanceTimersByTime(300) - }) + vi.advanceTimersByTime(300) expect(callback).toHaveBeenCalledTimes(1) expect(logFn).toHaveBeenCalledTimes(1) @@ -1049,13 +1120,8 @@ describe('use-debounced-fn', () => { }) const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: errorCallback })) - act(() => { - result.current.debouncedFn() - }) - - act(() => { - vi.advanceTimersByTime(300) - }) + result.current.debouncedFn() + vi.advanceTimersByTime(300) expect(errorCallback).toHaveBeenCalledTimes(1) }) @@ -1073,25 +1139,17 @@ describe('use-debounced-fn', () => { const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: callback, onError })) // First call throws - act(() => { - result.current.debouncedFn() - }) + result.current.debouncedFn() - act(() => { - vi.advanceTimersByTime(300) - }) + vi.advanceTimersByTime(300) expect(onError).toHaveBeenCalled() // Second call succeeds shouldThrow = false - act(() => { - result.current.debouncedFn() - }) + result.current.debouncedFn() - act(() => { - vi.advanceTimersByTime(300) - }) + vi.advanceTimersByTime(300) expect(callback).toHaveBeenCalledTimes(2) }) @@ -1104,13 +1162,8 @@ describe('use-debounced-fn', () => { const onError = vi.fn() const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: errorCallback, onSuccess, onError })) - act(() => { - result.current.debouncedFn() - }) - - act(() => { - vi.advanceTimersByTime(300) - }) + result.current.debouncedFn() + vi.advanceTimersByTime(300) expect(onSuccess).not.toHaveBeenCalled() expect(onError).toHaveBeenCalled() @@ -1124,13 +1177,9 @@ describe('use-debounced-fn', () => { const onFinally = vi.fn() const { result } = renderHook(() => useDebouncedFn({ callbackToBounce: errorCallback, onError, onFinally })) - act(() => { - result.current.debouncedFn('test') - }) + result.current.debouncedFn('test') - act(() => { - vi.advanceTimersByTime(300) - }) + vi.advanceTimersByTime(300) expect(onError).toHaveBeenCalledWith(expect.any(Error), 'test') expect(onFinally).toHaveBeenCalledWith('test') @@ -1156,12 +1205,10 @@ describe('use-debounced-fn', () => { }) }) - act(() => { - vi.advanceTimersByTime(300) - }) + vi.advanceTimersByTime(300) expect(callback).toHaveBeenCalledTimes(1) - expect(callback).toHaveBeenCalledWith('call-4') + expect(callback).toHaveBeenCalledWith(expect.any(AbortSignal), 'call-4') }) it('should handle zero delay', () => { @@ -1172,11 +1219,9 @@ describe('use-debounced-fn', () => { result.current.debouncedFn('test') }) - act(() => { - vi.advanceTimersByTime(0) - }) + vi.advanceTimersByTime(0) - expect(callback).toHaveBeenCalledWith('test') + expect(callback).toHaveBeenCalledWith(expect.any(AbortSignal), 'test') }) }) @@ -1204,12 +1249,10 @@ describe('use-debounced-fn', () => { } }) - act(() => { - vi.advanceTimersByTime(100) - }) + vi.advanceTimersByTime(100) expect(callback).toHaveBeenCalledTimes(1) - expect(callback).toHaveBeenCalledWith(999) + expect(callback).toHaveBeenCalledWith(expect.any(AbortSignal), 999) }) it('should not cause memory leaks with immediateCallback on many calls', () => { @@ -1219,17 +1262,13 @@ describe('use-debounced-fn', () => { useDebouncedFn({ immediateCallback: immediate, callbackToBounce: callback, delay: 100 }) ) - act(() => { - for (let i = 0; i < 100; i++) { - result.current.debouncedFn(i) - } - }) + for (let i = 0; i < 100; i++) { + result.current.debouncedFn(i) + } expect(immediate).toHaveBeenCalledTimes(100) - act(() => { - vi.advanceTimersByTime(100) - }) + vi.advanceTimersByTime(100) expect(callback).toHaveBeenCalledTimes(1) }) @@ -1245,13 +1284,8 @@ describe('use-debounced-fn', () => { // Simulate StrictMode re-render rerender() - act(() => { - result.current.debouncedFn() - }) - - act(() => { - vi.advanceTimersByTime(200) - }) + result.current.debouncedFn() + vi.advanceTimersByTime(200) expect(callback).toHaveBeenCalledTimes(1) }) @@ -1265,9 +1299,7 @@ describe('use-debounced-fn', () => { return useDebouncedFn({ callbackToBounce: callback, delay: 300 }) }) - act(() => { - result.current.debouncedFn() - }) + result.current.debouncedFn() act(() => { vi.advanceTimersByTime(100) @@ -1298,17 +1330,13 @@ describe('use-debounced-fn', () => { target: { value: 'test' }, } as React.ChangeEvent - act(() => { - result.current.debouncedFn(mockEvent) - }) + result.current.debouncedFn(mockEvent) expect(immediate).toHaveBeenCalledWith(mockEvent) - act(() => { - vi.advanceTimersByTime(300) - }) + vi.advanceTimersByTime(300) - expect(callback).toHaveBeenCalledWith(mockEvent) + expect(callback).toHaveBeenCalledWith(expect.any(AbortSignal), mockEvent) }) it('should handle multiple arguments with proper typing', () => { @@ -1320,15 +1348,10 @@ describe('use-debounced-fn', () => { }) ) - act(() => { - result.current.debouncedFn('test', 42, true) - }) - - act(() => { - vi.advanceTimersByTime(300) - }) + result.current.debouncedFn('test', 42, true) + vi.advanceTimersByTime(300) - expect(callback).toHaveBeenCalledWith('test', 42, true) + expect(callback).toHaveBeenCalledWith(expect.any(AbortSignal), 'test', 42, true) }) }) }) diff --git a/src/lib/use-debounced-fn/index.tsx b/src/lib/use-debounced-fn/index.tsx index 99cbbd5..2e30055 100644 --- a/src/lib/use-debounced-fn/index.tsx +++ b/src/lib/use-debounced-fn/index.tsx @@ -4,7 +4,8 @@ const DEFAULT_DELAY = 300 /** * @description - * A React hook that returns a debounced version of any function, delaying its execution until after a specified delay has passed since the last time it was invoked. + * + * use-debounced-fn is an async-aware React hook that provides a powerful, declarative way to implement debouncing with full lifecycle control. * * @example * @@ -71,7 +72,7 @@ export function useDebouncedFn({ delay, }: { immediateCallback?: (...args: any[]) => void - callbackToBounce: (...args: any[]) => void + callbackToBounce: (signal: AbortSignal, ...args: any[]) => void onSuccess?: (...args: any[]) => void onError?: (error: Error, ...args: any[]) => void onFinally?: (...args: any[]) => void @@ -87,7 +88,7 @@ export function useDebouncedFn({ delay, }: { immediateCallback?: (ev: Ev, ...args: Args) => void - callbackToBounce: (ev: Ev, ...args: Args) => void + callbackToBounce: (signal: AbortSignal, ev: Ev, ...args: Args) => any onSuccess?: (ev: Ev, ...args: Args) => void onError?: (error: Error, ev: Ev, ...args: Args) => void onFinally?: (ev: Ev, ...args: Args) => void @@ -105,7 +106,7 @@ export function useDebouncedFn({ delay, }: { immediateCallback?: (...args: any[]) => void - callbackToBounce: (...args: any[]) => void + callbackToBounce: (signal: AbortSignal, ...args: any[]) => any onSuccess?: (...args: any[]) => void onError?: (error: Error, ...args: any[]) => void onFinally?: (...args: any[]) => void @@ -192,36 +193,45 @@ export function useDebouncedFn({ * @see Docs https://classic-react-hooks.vercel.app/hooks/use-debounced-fn.html */ export function debouncedFnWrapper any>(props: { - immediateCallback?: T - callbackToBounce: T - onError?: (error: Error, ...args: Parameters) => void - onSuccess?: T - onFinally?: T + immediateCallback?: (...args: Parameters) => void + callbackToBounce: (signal: AbortSignal, ...args: Parameters) => any + onSuccess?: (...args: Parameters) => void + onError?: (error: Error, ...args: Parameters) => void + onFinally?: (...args: Parameters) => void delay?: number }) { - let timerId: NodeJS.Timeout + let timerId: ReturnType + let controller: AbortController | null = null return { - debouncedFn: (...args: Parameters) => { + debouncedFn: (...args: Parameters) => { + // Immediate phase props.immediateCallback?.(...args) - if (timerId) { - clearTimeout(timerId) - } - timerId = setTimeout(() => { + + // Cancel previous async work + controller?.abort() + + if (timerId) clearTimeout(timerId) + + controller = new AbortController() + + timerId = setTimeout(async () => { try { - const res = props.callbackToBounce.call(null, ...args) - if (res instanceof Promise) { - res.then(() => props.onSuccess?.(...args)) - } else { - props.onSuccess?.(...args) - } + await props.callbackToBounce.call(null, controller!.signal, ...args) + props.onSuccess?.(...args) } catch (err) { - props.onError?.(err as Error, ...args) + if ((err as DOMException).name !== 'AbortError') { + props.onError?.(err as Error, ...args) + } } finally { props.onFinally?.(...args) } }, props.delay ?? DEFAULT_DELAY) }, - cleanup: () => clearTimeout(timerId), + + cleanup: () => { + controller?.abort() + clearTimeout(timerId) + }, } }