diff --git a/packages/chronicle/src/components/ui/search.tsx b/packages/chronicle/src/components/ui/search.tsx index 4c75730..c5ee7c9 100644 --- a/packages/chronicle/src/components/ui/search.tsx +++ b/packages/chronicle/src/components/ui/search.tsx @@ -4,8 +4,9 @@ import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'; import { Badge, Command, IconButton, Text } from '@raystack/apsara'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { debounce } from 'lodash-es'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState, type ChangeEvent } from 'react'; import { useNavigate } from 'react-router'; import { MethodBadge } from '@/components/api/method-badge'; import { usePageContext } from '@/lib/page-context'; @@ -36,57 +37,40 @@ function buildSearchUrl(query: string, tag?: string): string { export function Search({ classNames }: SearchProps) { const [open, setOpen] = useState(false); const [search, setSearch] = useState(''); - const [results, setResults] = useState([]); - const [suggestions, setSuggestions] = useState([]); - const [isLoading, setIsLoading] = useState(false); + const [debouncedSearch, setDebouncedSearch] = useState(''); const navigate = useNavigate(); const { version } = usePageContext(); const tag = version.dir ?? undefined; - const abortRef = useRef(null); - const fetchResults = useCallback(async (query: string, signal?: AbortSignal) => { - setIsLoading(true); - try { - const res = await fetch(buildSearchUrl(query, tag), { signal }); - if (!res.ok || signal?.aborted) return; - const data: SearchResult[] = await res.json(); - if (signal?.aborted) return; - if (query) { - setResults(data); - } else { - setSuggestions(data); - } - } catch (err) { - if (err instanceof DOMException && err.name === 'AbortError') return; - console.error('Search fetch failed:', err); - } finally { - setIsLoading(false); - } - }, [tag]); - - const debouncedSearch = useMemo( - () => debounce((query: string) => { - abortRef.current?.abort(); - const controller = new AbortController(); - abortRef.current = controller; - fetchResults(query, controller.signal); - }, 150), - [fetchResults] + const updateDebouncedSearch = useMemo( + () => debounce((value: string) => setDebouncedSearch(value), 150), + [] ); + const onSearchChange = useCallback((e: ChangeEvent) => { + const value = e.target.value; + setSearch(value); + updateDebouncedSearch(value); + }, [updateDebouncedSearch]); + useEffect(() => { if (!open) { setSearch(''); - setResults([]); - return; + setDebouncedSearch(''); + updateDebouncedSearch.cancel(); } - if (!search) { - fetchResults(''); - return; - } - debouncedSearch(search); - return () => debouncedSearch.cancel(); - }, [open, search, fetchResults, debouncedSearch]); + }, [open, updateDebouncedSearch]); + + const { data = [], isLoading } = useQuery({ + queryKey: ['search', debouncedSearch, tag], + queryFn: async ({ signal }) => { + const res = await fetch(buildSearchUrl(debouncedSearch, tag), { signal }); + if (!res.ok) throw new Error(String(res.status)); + return res.json(); + }, + enabled: open, + placeholderData: keepPreviousData, + }); const onSelect = useCallback( (url: string) => { @@ -108,7 +92,7 @@ export function Search({ classNames }: SearchProps) { return () => document.removeEventListener('keydown', down); }, []); - const displayResults = deduplicateByUrl(search ? results : suggestions); + const displayResults = deduplicateByUrl(data); return ( <> @@ -129,7 +113,7 @@ export function Search({ classNames }: SearchProps) { placeholder='Search' leadingIcon={} value={search} - onChange={(e) => setSearch(e.target.value)} + onChange={onSearchChange} className={styles.input} /> diff --git a/packages/chronicle/src/lib/preload.ts b/packages/chronicle/src/lib/preload.ts index 7ca103f..8719e50 100644 --- a/packages/chronicle/src/lib/preload.ts +++ b/packages/chronicle/src/lib/preload.ts @@ -40,3 +40,14 @@ export function prefetchPageData(pathname: string) { queryFn: () => fetchPageDataByPathname(pathname), }); } + +export function prefetchSearchSuggestions() { + queryClient.prefetchQuery({ + queryKey: ['search', '', undefined], + queryFn: async () => { + const res = await fetch('/api/search'); + if (!res.ok) throw new Error(String(res.status)); + return res.json(); + }, + }); +} diff --git a/packages/chronicle/src/server/entry-client.tsx b/packages/chronicle/src/server/entry-client.tsx index fa83e99..5088d68 100644 --- a/packages/chronicle/src/server/entry-client.tsx +++ b/packages/chronicle/src/server/entry-client.tsx @@ -7,7 +7,7 @@ import { QueryClientProvider } from '@tanstack/react-query'; import { mdxComponents } from '@/components/mdx'; import { getApiConfigsForVersion } from '@/lib/config'; import { PageProvider } from '@/lib/page-context'; -import { queryClient } from '@/lib/preload'; +import { prefetchSearchSuggestions, queryClient } from '@/lib/preload'; import { resolveRoute, RouteType } from '@/lib/route-resolver'; import { resolveVersionFromUrl, type VersionContext } from '@/lib/version-source'; import type { ChronicleConfig, Frontmatter, PageNavLink, Root, TableOfContents } from '@/types'; @@ -56,6 +56,7 @@ async function hydrate() { window as unknown as { __PAGE_DATA__?: EmbeddedData } ).__PAGE_DATA__; + prefetchSearchSuggestions(); const config: ChronicleConfig = embedded?.config ?? defaultConfig; const tree: Root = embedded?.tree ?? { name: 'root', children: [] };