From bf0098bcb10ca78ed387b89edb79d5871dc42753 Mon Sep 17 00:00:00 2001 From: binayyub4211 Date: Tue, 26 May 2026 22:12:35 +0100 Subject: [PATCH] fix: batch and debounce rapid user input events (typing, scrolling) --- src/components/mobile/LessonCarousel.tsx | 25 ++- src/components/mobile/MobileSearch.tsx | 19 +- src/hooks/index.ts | 1 + src/hooks/useDebounce.ts | 68 ++++++++ tests/components/DebounceIntegration.test.tsx | 165 ++++++++++++++++++ tests/hooks/useDebounce.test.ts | 122 +++++++++++++ 6 files changed, 389 insertions(+), 11 deletions(-) create mode 100644 src/hooks/useDebounce.ts create mode 100644 tests/components/DebounceIntegration.test.tsx create mode 100644 tests/hooks/useDebounce.test.ts diff --git a/src/components/mobile/LessonCarousel.tsx b/src/components/mobile/LessonCarousel.tsx index fa285ac..af37177 100644 --- a/src/components/mobile/LessonCarousel.tsx +++ b/src/components/mobile/LessonCarousel.tsx @@ -11,6 +11,7 @@ import { } from 'react-native'; import { LinearGradient } from 'expo-linear-gradient'; import { Lesson, CourseProgress } from '../../types/course'; +import { useDebounceCallback } from '../../hooks'; const { width: SCREEN_WIDTH } = Dimensions.get('window'); @@ -84,15 +85,23 @@ export default function LessonCarousel({ } }; - const handleScroll = (event: any) => { - const offsetX = event.nativeEvent.contentOffset.x; + const debouncedScroll = useDebounceCallback((offsetX: number) => { const index = Math.round(offsetX / SCREEN_WIDTH); - - if (index !== currentIndex && index >= 0 && index < lessons.length) { - setCurrentIndex(index); - const lesson = lessons[index]; - onLessonChange(lesson.id, index); + if (index >= 0 && index < lessons.length) { + setCurrentIndex((prevIndex) => { + if (index !== prevIndex) { + const lesson = lessons[index]; + onLessonChange(lesson.id, index); + return index; + } + return prevIndex; + }); } + }, 100); + + const handleScroll = (event: any) => { + const offsetX = event.nativeEvent.contentOffset.x; + debouncedScroll(offsetX); }; const handleMomentumScrollEnd = (event: any) => { @@ -136,7 +145,7 @@ export default function LessonCarousel({ const lessonProgress = progress?.lessons[currentLesson.id]; return ( - + {/* Progress Bar */} diff --git a/src/components/mobile/MobileSearch.tsx b/src/components/mobile/MobileSearch.tsx index 01aa9be..e2acbca 100644 --- a/src/components/mobile/MobileSearch.tsx +++ b/src/components/mobile/MobileSearch.tsx @@ -10,7 +10,7 @@ import { View, } from 'react-native'; import { AppText as Text } from '../common/AppText'; -import { useAnalytics, useDynamicFontSize, useMemoryMonitor } from '../../hooks'; +import { useAnalytics, useDebounce, useDynamicFontSize, useMemoryMonitor } from '../../hooks'; import { AnalyticsEvent } from '../../utils/trackingEvents'; import { FilterField, FilterSheet, FilterValues } from './FilterSheet'; import { SearchHistory } from './SearchHistory'; @@ -103,13 +103,15 @@ export const MobileSearch = ({ useMemoryMonitor({ componentId: 'MobileSearch', itemCount: results.length }); + const debouncedQuery = useDebounce(query, 300); + const suggestions = useMemo(() => { - const q = query.trim().toLowerCase(); + const q = debouncedQuery.trim().toLowerCase(); if (!q) return SUGGESTION_KEYWORDS.slice(0, 5); return SUGGESTION_KEYWORDS.filter( s => s.toLowerCase().includes(q) || q.includes(s.toLowerCase()) ).slice(0, 6); - }, [query]); + }, [debouncedQuery]); const performSearch = useCallback( (searchQuery: string) => { @@ -134,6 +136,17 @@ export const MobileSearch = ({ [filterValues, trackEvent] ); + React.useEffect(() => { + const trimmed = debouncedQuery.trim(); + if (trimmed) { + performSearch(trimmed); + } else { + setResults([]); + setHasSearched(false); + } + }, [debouncedQuery, performSearch]); + + const handleSubmit = useCallback(() => { performSearch(query); }, [query, performSearch]); diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 9a344e3..ef8dc5f 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -21,3 +21,4 @@ export * from './useScreenReader'; export * from './useSwipe'; export * from './useVideoGestures'; export * from './useVoiceRecognition'; +export * from './useDebounce'; diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts new file mode 100644 index 0000000..1f16374 --- /dev/null +++ b/src/hooks/useDebounce.ts @@ -0,0 +1,68 @@ +import { useEffect, useState, useRef, useCallback } from 'react'; + +/** + * A hook that returns a debounced version of the provided value. + * Useful for debouncing values that change rapidly, such as search text. + * + * @param value The value to debounce + * @param delay The delay in milliseconds + * @returns The debounced value + */ +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +} + +/** + * A hook that returns a debounced version of the provided callback function. + * Useful for debouncing rapid event handlers, such as scroll events. + * + * @param callback The callback function to debounce + * @param delay The delay in milliseconds + * @returns A debounced version of the callback + */ +export function useDebounceCallback( + callback: (...args: Args) => void, + delay: number +): (...args: Args) => void { + const callbackRef = useRef(callback); + const timeoutRef = useRef(null); + + // Keep callback reference updated to avoid needing it in dependency array + useEffect(() => { + callbackRef.current = callback; + }, [callback]); + + // Clean up timer on unmount + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + return useCallback( + (...args: Args) => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + timeoutRef.current = setTimeout(() => { + callbackRef.current(...args); + }, delay); + }, + [delay] + ); +} diff --git a/tests/components/DebounceIntegration.test.tsx b/tests/components/DebounceIntegration.test.tsx new file mode 100644 index 0000000..12e5c52 --- /dev/null +++ b/tests/components/DebounceIntegration.test.tsx @@ -0,0 +1,165 @@ +import React from 'react'; +import { render, fireEvent, act } from '@testing-library/react-native'; +import { MobileSearch } from '../../src/components/mobile/MobileSearch'; +import LessonCarousel from '../../src/components/mobile/LessonCarousel'; + +// ── Mocks ────────────────────────────────────────────────────────────────── + +jest.mock('lucide-react-native', () => ({ + AlertCircle: () => null, + Search: () => null, + SlidersHorizontal: () => null, +})); + +// Mock only necessary hooks, require actual useDebounce / useDebounceCallback +jest.mock('../../src/hooks', () => { + const actual = jest.requireActual('../../src/hooks/useDebounce'); + return { + ...actual, + useAnalytics: () => ({ + trackEvent: jest.fn(), + }), + useDynamicFontSize: () => ({ + scale: (x: number) => x, + }), + useMemoryMonitor: jest.fn(), + }; +}); + +jest.mock('../../src/components/mobile/VoiceSearch', () => ({ + VoiceSearch: () => null, +})); + +jest.mock('../../src/components/mobile/FilterSheet', () => ({ + FilterSheet: () => null, +})); + +jest.mock('../../src/components/mobile/SearchHistory', () => ({ + SearchHistory: () => null, +})); + +// Mock expo linear gradient +jest.mock('expo-linear-gradient', () => ({ + LinearGradient: ({ children }: any) => children, +})); + +describe('Debouncing Rapid User Input & Scroll Events', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + // ── Search Input Debouncing ──────────────────────────────────────────────── + + describe('MobileSearch component', () => { + it('debounces rapid keystrokes to prevent search re-renders and query spam', () => { + const onResultPress = jest.fn(); + const { getByPlaceholderText, queryByText } = render( + + ); + + const input = getByPlaceholderText('Search courses...'); + + // Simulating rapid keystrokes typing: 'R', 'Re', 'Rea', 'React' + // Expected behavior: query state updates immediately in text input, + // but actual search/filtering (300ms debounce) is deferred. + fireEvent.changeText(input, 'R'); + fireEvent.changeText(input, 'Re'); + fireEvent.changeText(input, 'Rea'); + fireEvent.changeText(input, 'React'); + + // Before 300ms, search results shouldn't render yet + expect(queryByText('1 result')).toBeNull(); + + // Fast forward time by 200ms (not yet 300ms since last change) + act(() => { + jest.advanceTimersByTime(200); + }); + expect(queryByText('1 result')).toBeNull(); + + // Complete the remaining 100ms debounce delay + act(() => { + jest.advanceTimersByTime(100); + }); + + // Now it should have executed the search automatically and found results! + // (sampleCourse has "React Native" in title/description) + expect(queryByText('1 result')).toBeTruthy(); + }); + }); + + // ── Scroll Event Debouncing ──────────────────────────────────────────────── + + describe('LessonCarousel scroll debouncing', () => { + const mockLessons = [ + { id: '1', title: 'Lesson 1', duration: 10 }, + { id: '2', title: 'Lesson 2', duration: 15 }, + { id: '3', title: 'Lesson 3', duration: 20 }, + ]; + + it('debounces rapid scroll drag events to prevent state update spam', () => { + const onLessonChange = jest.fn(); + const renderContent = jest.fn(() => null); + + const { getByTestId } = render( + + ); + + // We obtain scroll view. + // Wait, LessonCarousel renders a ScrollView. We can simulate onScroll event. + // Line 188: { + fireEvent.scroll(scrollView, { + nativeEvent: { contentOffset: { x: 50, y: 0 } }, + }); + }); + act(() => { + fireEvent.scroll(scrollView, { + nativeEvent: { contentOffset: { x: 150, y: 0 } }, + }); + }); + act(() => { + fireEvent.scroll(scrollView, { + nativeEvent: { contentOffset: { x: 375, y: 0 } }, + }); + }); + + // At this point, the index is page 1 (Lesson 2). + // Since it is debounced by 100ms, onLessonChange should NOT have been called yet. + expect(onLessonChange).not.toHaveBeenCalled(); + + // Fast forward 50ms (still within 100ms) + act(() => { + jest.advanceTimersByTime(50); + }); + expect(onLessonChange).not.toHaveBeenCalled(); + + // Complete 100ms from the last event + act(() => { + jest.advanceTimersByTime(50); + }); + + // Should now be called exactly once for the final scrolled index! + expect(onLessonChange).toHaveBeenCalledTimes(1); + expect(onLessonChange).toHaveBeenCalledWith('2', 1); + }); + }); +}); diff --git a/tests/hooks/useDebounce.test.ts b/tests/hooks/useDebounce.test.ts new file mode 100644 index 0000000..57e95cc --- /dev/null +++ b/tests/hooks/useDebounce.test.ts @@ -0,0 +1,122 @@ +import { renderHook, act } from '@testing-library/react-native'; +import { useDebounce, useDebounceCallback } from '../../src/hooks/useDebounce'; + +describe('useDebounce and useDebounceCallback hooks', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + // ── useDebounce (value debouncing) ────────────────────────────────────────── + + describe('useDebounce', () => { + it('returns the initial value immediately', () => { + const { result } = renderHook(() => useDebounce('initial', 300)); + expect(result.current).toBe('initial'); + }); + + it('updates the debounced value only after the delay has passed', () => { + const { result, rerender } = renderHook( + ({ value }) => useDebounce(value, 300), + { initialProps: { value: 'initial' } } + ); + + // Perform rapid typing simulation + rerender({ value: 'typing...' }); + expect(result.current).toBe('initial'); // Not updated yet + + act(() => { + jest.advanceTimersByTime(150); + }); + expect(result.current).toBe('initial'); // Not updated yet + + act(() => { + jest.advanceTimersByTime(150); // Completes 300ms total + }); + expect(result.current).toBe('typing...'); // Updated now + }); + + it('resets the timer and debounces rapid successive changes', () => { + const { result, rerender } = renderHook( + ({ value }) => useDebounce(value, 300), + { initialProps: { value: 'init' } } + ); + + // simulate rapid keystrokes: 'a', 'ab', 'abc' (each separated by 100ms) + rerender({ value: 'a' }); + act(() => { jest.advanceTimersByTime(100); }); + expect(result.current).toBe('init'); + + rerender({ value: 'ab' }); + act(() => { jest.advanceTimersByTime(100); }); + expect(result.current).toBe('init'); + + rerender({ value: 'abc' }); + act(() => { jest.advanceTimersByTime(100); }); + expect(result.current).toBe('init'); // 300ms since start, but only 100ms since last change + + // Wait 300ms after the LAST change + act(() => { jest.advanceTimersByTime(200); }); // 300ms total for 'abc' + expect(result.current).toBe('abc'); + }); + }); + + // ── useDebounceCallback (callback debouncing) ────────────────────────────── + + describe('useDebounceCallback', () => { + it('calls the callback function only after the delay has passed', () => { + const callback = jest.fn(); + const { result } = renderHook(() => useDebounceCallback(callback, 100)); + + result.current('scroll-event-1'); + expect(callback).not.toHaveBeenCalled(); + + act(() => { + jest.advanceTimersByTime(50); + }); + expect(callback).not.toHaveBeenCalled(); + + act(() => { + jest.advanceTimersByTime(50); // Total 100ms + }); + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith('scroll-event-1'); + }); + + it('debounces rapid successive calls into a single invocation', () => { + const callback = jest.fn(); + const { result } = renderHook(() => useDebounceCallback(callback, 100)); + + // simulate rapid events (e.g. scrolling) + result.current('x:10'); + result.current('x:20'); + result.current('x:30'); + + expect(callback).not.toHaveBeenCalled(); + + act(() => { + jest.advanceTimersByTime(100); + }); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith('x:30'); // Should receive the last parameters + }); + + it('calls callback immediately on cleanup if needed or cleans up timer on unmount', () => { + const callback = jest.fn(); + const { result, unmount } = renderHook(() => useDebounceCallback(callback, 100)); + + result.current('unmount-me'); + unmount(); + + act(() => { + jest.advanceTimersByTime(100); + }); + + expect(callback).not.toHaveBeenCalled(); // Cleared and not called + }); + }); +});