From 63ae0e2e7ad490f5749c0e5b7a9a41b9b4de0cba Mon Sep 17 00:00:00 2001 From: Rishabh Date: Wed, 20 May 2026 11:08:16 +0530 Subject: [PATCH 1/5] refactor: migrate search to react-query Replace manual fetch/abort/debounce with useQuery and keepPreviousData. Lodash debounce drives debouncedSearch state for the query key. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../chronicle/src/components/ui/search.tsx | 68 +++++++------------ 1 file changed, 25 insertions(+), 43 deletions(-) diff --git a/packages/chronicle/src/components/ui/search.tsx b/packages/chronicle/src/components/ui/search.tsx index 4c75730..28bd34d 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 } from 'react'; import { useNavigate } from 'react-router'; import { MethodBadge } from '@/components/api/method-badge'; import { usePageContext } from '@/lib/page-context'; @@ -36,57 +37,38 @@ 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), + [] ); + useEffect(() => { + updateDebouncedSearch(search); + return () => updateDebouncedSearch.cancel(); + }, [search, updateDebouncedSearch]); + useEffect(() => { if (!open) { setSearch(''); - setResults([]); - return; - } - if (!search) { - fetchResults(''); - return; + setDebouncedSearch(''); } - debouncedSearch(search); - return () => debouncedSearch.cancel(); - }, [open, search, fetchResults, debouncedSearch]); + }, [open]); + + 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 +90,7 @@ export function Search({ classNames }: SearchProps) { return () => document.removeEventListener('keydown', down); }, []); - const displayResults = deduplicateByUrl(search ? results : suggestions); + const displayResults = deduplicateByUrl(data); return ( <> From 478974ec193b3e36a7a4e7957250bf4a30ab6480 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Wed, 20 May 2026 11:08:21 +0530 Subject: [PATCH 2/5] feat: prefetch search suggestions on hydration Suggestions cached before dialog opens for instant display. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/chronicle/src/lib/preload.ts | 11 +++++++++++ packages/chronicle/src/server/entry-client.tsx | 3 ++- 2 files changed, 13 insertions(+), 1 deletion(-) 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: [] }; From 572b954431f2a4f58f68ef9a53b5a2905c0f9f09 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Wed, 20 May 2026 11:10:59 +0530 Subject: [PATCH 3/5] fix: use useCallback for debounced search updater Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/chronicle/src/components/ui/search.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/chronicle/src/components/ui/search.tsx b/packages/chronicle/src/components/ui/search.tsx index 28bd34d..f7517cc 100644 --- a/packages/chronicle/src/components/ui/search.tsx +++ b/packages/chronicle/src/components/ui/search.tsx @@ -6,7 +6,7 @@ import { import { Badge, Command, IconButton, Text } from '@raystack/apsara'; import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { debounce } from 'lodash-es'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { useNavigate } from 'react-router'; import { MethodBadge } from '@/components/api/method-badge'; import { usePageContext } from '@/lib/page-context'; @@ -42,8 +42,8 @@ export function Search({ classNames }: SearchProps) { const { version } = usePageContext(); const tag = version.dir ?? undefined; - const updateDebouncedSearch = useMemo( - () => debounce((value: string) => setDebouncedSearch(value), 150), + const updateDebouncedSearch = useCallback( + debounce((value: string) => setDebouncedSearch(value), 150), [] ); From 81f1a4bee4517f5ccd6a381ab68aeddc7bae8aa5 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Wed, 20 May 2026 11:13:02 +0530 Subject: [PATCH 4/5] fix: revert to useMemo for debounced search updater useMemo is semantically correct for memoizing a computed value. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/chronicle/src/components/ui/search.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/chronicle/src/components/ui/search.tsx b/packages/chronicle/src/components/ui/search.tsx index f7517cc..28bd34d 100644 --- a/packages/chronicle/src/components/ui/search.tsx +++ b/packages/chronicle/src/components/ui/search.tsx @@ -6,7 +6,7 @@ import { import { Badge, Command, IconButton, Text } from '@raystack/apsara'; import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { debounce } from 'lodash-es'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router'; import { MethodBadge } from '@/components/api/method-badge'; import { usePageContext } from '@/lib/page-context'; @@ -42,8 +42,8 @@ export function Search({ classNames }: SearchProps) { const { version } = usePageContext(); const tag = version.dir ?? undefined; - const updateDebouncedSearch = useCallback( - debounce((value: string) => setDebouncedSearch(value), 150), + const updateDebouncedSearch = useMemo( + () => debounce((value: string) => setDebouncedSearch(value), 150), [] ); From 1b27108b1eb036290a40112fcc0a6b5bdd415566 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Wed, 20 May 2026 13:40:12 +0530 Subject: [PATCH 5/5] fix: call debounce from onChange instead of useEffect Move debouncedSearch update to onChange handler directly, removing unnecessary useEffect sync. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/chronicle/src/components/ui/search.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/chronicle/src/components/ui/search.tsx b/packages/chronicle/src/components/ui/search.tsx index 28bd34d..c5ee7c9 100644 --- a/packages/chronicle/src/components/ui/search.tsx +++ b/packages/chronicle/src/components/ui/search.tsx @@ -6,7 +6,7 @@ import { import { Badge, Command, IconButton, Text } from '@raystack/apsara'; import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { debounce } from 'lodash-es'; -import { useCallback, useEffect, useMemo, 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'; @@ -47,17 +47,19 @@ export function Search({ classNames }: SearchProps) { [] ); - useEffect(() => { - updateDebouncedSearch(search); - return () => updateDebouncedSearch.cancel(); - }, [search, updateDebouncedSearch]); + const onSearchChange = useCallback((e: ChangeEvent) => { + const value = e.target.value; + setSearch(value); + updateDebouncedSearch(value); + }, [updateDebouncedSearch]); useEffect(() => { if (!open) { setSearch(''); setDebouncedSearch(''); + updateDebouncedSearch.cancel(); } - }, [open]); + }, [open, updateDebouncedSearch]); const { data = [], isLoading } = useQuery({ queryKey: ['search', debouncedSearch, tag], @@ -111,7 +113,7 @@ export function Search({ classNames }: SearchProps) { placeholder='Search' leadingIcon={} value={search} - onChange={(e) => setSearch(e.target.value)} + onChange={onSearchChange} className={styles.input} />