From fdd41007da898f90646d65b375ecb93a888d7a0e Mon Sep 17 00:00:00 2001 From: Olamidepy Date: Sat, 25 Jul 2026 16:36:47 +0100 Subject: [PATCH] fix(#986): debounce IntelligentAutoComplete suggestion computation --- .../search/IntelligentAutoComplete.tsx | 14 ++-- .../IntelligentAutoComplete.test.tsx | 69 +++++++++++++++++++ 2 files changed, 78 insertions(+), 5 deletions(-) create mode 100644 src/components/search/__tests__/IntelligentAutoComplete.test.tsx diff --git a/src/components/search/IntelligentAutoComplete.tsx b/src/components/search/IntelligentAutoComplete.tsx index 58c7f392..af5186b5 100644 --- a/src/components/search/IntelligentAutoComplete.tsx +++ b/src/components/search/IntelligentAutoComplete.tsx @@ -1,23 +1,27 @@ 'use client'; -import React, { useState, useEffect, useRef, useCallback } from 'react'; +import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { Search, X, Clock, ChevronRight, Sparkles, User, Tag, Type } from 'lucide-react'; import { getSearchSuggestions, highlightMatch } from '../../utils/searchUtils'; +import { useDebounce } from '../../hooks/useDebounce'; interface IntelligentAutoCompleteProps { value: string; onChange: (value: string) => void; onSearch: (value: string) => void; history?: string[]; + debounceMs?: number; } export const IntelligentAutoComplete = React.memo( - ({ value, onChange, onSearch, history = [] }) => { + ({ value, onChange, onSearch, history = [], debounceMs = 300 }) => { const [isOpen, setIsOpen] = useState(false); const [activeIndex, setActiveIndex] = useState(-1); const dropdownRef = useRef(null); const inputRef = useRef(null); - const suggestions = getSearchSuggestions(value); + + const debouncedValue = useDebounce(value, debounceMs); + const suggestions = useMemo(() => getSearchSuggestions(debouncedValue), [debouncedValue]); useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -154,11 +158,11 @@ export const IntelligentAutoComplete = React.memo( {getSuggestionIcon(suggestion)} - {highlightMatch(suggestion, value).map((part, index) => ( + {highlightMatch(suggestion, debouncedValue).map((part, index) => ( { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('renders input with given initial value', () => { + const onChange = vi.fn(); + const onSearch = vi.fn(); + + render(); + + const input = screen.getByPlaceholderText('Search for knowledge, authors, or topics...'); + expect(input).toHaveValue('react'); + }); + + it('debounces calls to getSearchSuggestions when typing', () => { + const getSearchSuggestionsSpy = vi.spyOn(searchUtils, 'getSearchSuggestions'); + const onChange = vi.fn(); + const onSearch = vi.fn(); + + const { rerender } = render( + , + ); + + const initialCalls = getSearchSuggestionsSpy.mock.calls.length; + + // Simulate typing multiple characters sequentially + rerender(); + rerender(); + rerender(); + rerender(); + rerender(); + + // Before timer advances, getSearchSuggestions should not have been called for intermediate keystrokes + expect(getSearchSuggestionsSpy.mock.calls.length).toBe(initialCalls); + + // Advance time by 300ms + act(() => { + vi.advanceTimersByTime(300); + }); + + // Now getSearchSuggestions should be called once with the final debounced value + expect(getSearchSuggestionsSpy.mock.calls.length).toBe(initialCalls + 1); + expect(getSearchSuggestionsSpy).toHaveBeenLastCalledWith('cairo'); + }); + + it('triggers onSearch when Enter is pressed without active dropdown selection', () => { + const onChange = vi.fn(); + const onSearch = vi.fn(); + + render(); + + const input = screen.getByPlaceholderText('Search for knowledge, authors, or topics...'); + fireEvent.keyDown(input, { key: 'Enter' }); + + expect(onSearch).toHaveBeenCalledWith('starknet'); + }); +});