Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 17 additions & 8 deletions src/components/mobile/LessonCarousel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -136,7 +145,7 @@ export default function LessonCarousel({
const lessonProgress = progress?.lessons[currentLesson.id];

return (
<View style={styles.container}>
<View style={styles.container} testID="LessonCarousel">
{/* Progress Bar */}
<View style={styles.progressBarContainer}>
<Animated.View style={{ width: progressBarWidth, height: '100%' }}>
Expand Down
19 changes: 16 additions & 3 deletions src/components/mobile/MobileSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) => {
Expand All @@ -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]);
Expand Down
1 change: 1 addition & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ export * from './useScreenReader';
export * from './useSwipe';
export * from './useVideoGestures';
export * from './useVoiceRecognition';
export * from './useDebounce';
68 changes: 68 additions & 0 deletions src/hooks/useDebounce.ts
Original file line number Diff line number Diff line change
@@ -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<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(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<Args extends any[]>(
callback: (...args: Args) => void,
delay: number
): (...args: Args) => void {
const callbackRef = useRef(callback);
const timeoutRef = useRef<NodeJS.Timeout | null>(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]
);
}
165 changes: 165 additions & 0 deletions tests/components/DebounceIntegration.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<MobileSearch onResultPress={onResultPress} placeholder="Search courses..." />
);

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(
<LessonCarousel
lessons={mockLessons}
currentLessonId="1"
onLessonChange={onLessonChange}
renderLessonContent={renderContent}
/>
);

// We obtain scroll view.
// Wait, LessonCarousel renders a ScrollView. We can simulate onScroll event.
// Line 188: <ScrollView horizontal pagingEnabled onScroll={handleScroll} ...
const scrollView = getByTestId('LessonCarousel').parent?.findByType('ScrollView');
expect(scrollView).toBeDefined();

if (!scrollView) {
throw new Error('ScrollView not found in LessonCarousel');
}

// Simulate rapid drag/scroll offsets: 100, 200, 300, 375 (1 page width = SCREEN_WIDTH)
// Screen width is 375 by default inside test dimensions.
// Multiple scroll events fired sequentially (e.g. 10ms apart)
act(() => {
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);
});
});
});
Loading
Loading