Implemented a reusable useDebounce hook to prevent rapid-fire clicks on refresh buttons throughout the application. Changes: - Created hooks/useDebounce.ts with configurable delay and leading/trailing edge options - Added comprehensive unit tests in hooks/__tests__/useDebounce.test.ts - Applied debouncing to dashboard WebView refresh button (app/(tabs)/dashboard.tsx) - Applied debouncing to beneficiary detail pull-to-refresh (app/(tabs)/beneficiaries/[id]/index.tsx) - Applied debouncing to equipment screen refresh (app/(tabs)/beneficiaries/[id]/equipment.tsx) - Applied debouncing to all error retry buttons (components/ui/ErrorMessage.tsx) - Fixed jest.setup.js to properly mock React Native modules - Added implementation documentation in docs/DEBOUNCE_IMPLEMENTATION.md Technical details: - Default 1-second debounce delay - Leading edge execution (immediate first call, then debounce) - Type-safe with TypeScript generics - Automatic cleanup on component unmount 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
236 lines
6.5 KiB
TypeScript
236 lines
6.5 KiB
TypeScript
import { renderHook, act } from '@testing-library/react-native';
|
|
import { useDebounce } from '../useDebounce';
|
|
|
|
jest.useFakeTimers();
|
|
|
|
describe('useDebounce', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllTimers();
|
|
});
|
|
|
|
describe('leading edge (default)', () => {
|
|
it('should execute callback immediately on first call', () => {
|
|
const callback = jest.fn();
|
|
const { result } = renderHook(() => useDebounce(callback, { delay: 1000 }));
|
|
|
|
act(() => {
|
|
result.current.debouncedFn();
|
|
});
|
|
|
|
expect(callback).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should ignore subsequent calls within delay period', () => {
|
|
const callback = jest.fn();
|
|
const { result } = renderHook(() => useDebounce(callback, { delay: 1000 }));
|
|
|
|
// First call - executes immediately
|
|
act(() => {
|
|
result.current.debouncedFn();
|
|
});
|
|
expect(callback).toHaveBeenCalledTimes(1);
|
|
|
|
// Second call within delay - ignored
|
|
act(() => {
|
|
jest.advanceTimersByTime(500);
|
|
result.current.debouncedFn();
|
|
});
|
|
expect(callback).toHaveBeenCalledTimes(1);
|
|
|
|
// Third call within delay - ignored
|
|
act(() => {
|
|
jest.advanceTimersByTime(400);
|
|
result.current.debouncedFn();
|
|
});
|
|
expect(callback).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should allow execution after delay period has passed', () => {
|
|
const callback = jest.fn();
|
|
const { result } = renderHook(() => useDebounce(callback, { delay: 1000 }));
|
|
|
|
// First call
|
|
act(() => {
|
|
result.current.debouncedFn();
|
|
});
|
|
expect(callback).toHaveBeenCalledTimes(1);
|
|
|
|
// Wait for delay to pass
|
|
act(() => {
|
|
jest.advanceTimersByTime(1000);
|
|
});
|
|
|
|
// Second call after delay - executes
|
|
act(() => {
|
|
result.current.debouncedFn();
|
|
});
|
|
expect(callback).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('should pass arguments to callback', () => {
|
|
const callback = jest.fn();
|
|
const { result } = renderHook(() => useDebounce(callback, { delay: 1000 }));
|
|
|
|
act(() => {
|
|
result.current.debouncedFn('test', 123, { key: 'value' });
|
|
});
|
|
|
|
expect(callback).toHaveBeenCalledWith('test', 123, { key: 'value' });
|
|
});
|
|
});
|
|
|
|
describe('trailing edge', () => {
|
|
it('should delay execution on trailing edge', () => {
|
|
const callback = jest.fn();
|
|
const { result } = renderHook(() => useDebounce(callback, { delay: 1000, leading: false }));
|
|
|
|
act(() => {
|
|
result.current.debouncedFn();
|
|
});
|
|
|
|
// Should not execute immediately
|
|
expect(callback).not.toHaveBeenCalled();
|
|
|
|
// Should execute after delay
|
|
act(() => {
|
|
jest.advanceTimersByTime(1000);
|
|
});
|
|
expect(callback).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should reset timer on subsequent calls', () => {
|
|
const callback = jest.fn();
|
|
const { result } = renderHook(() => useDebounce(callback, { delay: 1000, leading: false }));
|
|
|
|
// First call
|
|
act(() => {
|
|
result.current.debouncedFn();
|
|
});
|
|
|
|
// Second call before timer fires - resets timer
|
|
act(() => {
|
|
jest.advanceTimersByTime(500);
|
|
result.current.debouncedFn();
|
|
});
|
|
|
|
// Advance to original timeout - should not fire
|
|
act(() => {
|
|
jest.advanceTimersByTime(500);
|
|
});
|
|
expect(callback).not.toHaveBeenCalled();
|
|
|
|
// Advance to new timeout - should fire once
|
|
act(() => {
|
|
jest.advanceTimersByTime(500);
|
|
});
|
|
expect(callback).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe('isDebouncing', () => {
|
|
it('should return true within delay period', () => {
|
|
const callback = jest.fn();
|
|
const { result } = renderHook(() => useDebounce(callback, { delay: 1000 }));
|
|
|
|
act(() => {
|
|
result.current.debouncedFn();
|
|
});
|
|
|
|
expect(result.current.isDebouncing()).toBe(true);
|
|
|
|
act(() => {
|
|
jest.advanceTimersByTime(500);
|
|
});
|
|
expect(result.current.isDebouncing()).toBe(true);
|
|
});
|
|
|
|
it('should return false after delay period', () => {
|
|
const callback = jest.fn();
|
|
const { result } = renderHook(() => useDebounce(callback, { delay: 1000 }));
|
|
|
|
act(() => {
|
|
result.current.debouncedFn();
|
|
});
|
|
|
|
act(() => {
|
|
jest.advanceTimersByTime(1000);
|
|
});
|
|
|
|
expect(result.current.isDebouncing()).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('cancel', () => {
|
|
it('should cancel pending trailing edge execution', () => {
|
|
const callback = jest.fn();
|
|
const { result } = renderHook(() => useDebounce(callback, { delay: 1000, leading: false }));
|
|
|
|
act(() => {
|
|
result.current.debouncedFn();
|
|
});
|
|
|
|
// Cancel before timer fires
|
|
act(() => {
|
|
jest.advanceTimersByTime(500);
|
|
result.current.cancel();
|
|
});
|
|
|
|
// Advance past original timeout
|
|
act(() => {
|
|
jest.advanceTimersByTime(600);
|
|
});
|
|
|
|
expect(callback).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('custom delay', () => {
|
|
it('should respect custom delay value', () => {
|
|
const callback = jest.fn();
|
|
const { result } = renderHook(() => useDebounce(callback, { delay: 2000 }));
|
|
|
|
act(() => {
|
|
result.current.debouncedFn();
|
|
});
|
|
expect(callback).toHaveBeenCalledTimes(1);
|
|
|
|
// Call within 2 seconds - ignored
|
|
act(() => {
|
|
jest.advanceTimersByTime(1500);
|
|
result.current.debouncedFn();
|
|
});
|
|
expect(callback).toHaveBeenCalledTimes(1);
|
|
|
|
// Call after 2 seconds - executes
|
|
act(() => {
|
|
jest.advanceTimersByTime(500);
|
|
result.current.debouncedFn();
|
|
});
|
|
expect(callback).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
describe('rapid clicks simulation', () => {
|
|
it('should handle rapid refresh button clicks correctly', () => {
|
|
const callback = jest.fn();
|
|
const { result } = renderHook(() => useDebounce(callback, { delay: 1000 }));
|
|
|
|
// Simulate 5 rapid clicks within 2 seconds
|
|
act(() => {
|
|
result.current.debouncedFn(); // Click 1 - executes
|
|
jest.advanceTimersByTime(200);
|
|
result.current.debouncedFn(); // Click 2 - ignored
|
|
jest.advanceTimersByTime(300);
|
|
result.current.debouncedFn(); // Click 3 - ignored
|
|
jest.advanceTimersByTime(400);
|
|
result.current.debouncedFn(); // Click 4 - ignored
|
|
jest.advanceTimersByTime(400);
|
|
result.current.debouncedFn(); // Click 5 - executes (>1000ms since last execution)
|
|
});
|
|
|
|
// Only first and last should execute
|
|
expect(callback).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
});
|