import * as React from "react" import type { SearchPage, SearchResultGroup } from "./types" import { useSearchContext } from "./context" interface UseSearchOptions { active: boolean query: string } interface SearchState { error: unknown isLoading: boolean isLoadingMore: boolean pages: readonly SearchPage[] } const INITIAL_STATE: SearchState = { error: null, isLoading: false, isLoadingMore: false, pages: [], } export function useSearch({ active, query }: UseSearchOptions) { const { adapter } = useSearchContext() const normalizedQuery = query.trim() const [state, setState] = React.useState(INITIAL_STATE) const [retryKey, setRetryKey] = React.useState(0) const requestId = React.useRef(0) React.useEffect(() => { if (!active || !normalizedQuery) { setState(INITIAL_STATE) return } const controller = new AbortController() const currentRequest = ++requestId.current setState({ error: null, isLoading: true, isLoadingMore: false, pages: [] }) void adapter .search({ query: normalizedQuery, signal: controller.signal }) .then((page) => { if (currentRequest !== requestId.current || controller.signal.aborted) return setState({ error: null, isLoading: false, isLoadingMore: false, pages: [page], }) }) .catch((error: unknown) => { if (controller.signal.aborted || currentRequest !== requestId.current) return setState({ error, isLoading: false, isLoadingMore: false, pages: [] }) }) return () => controller.abort() }, [active, adapter, normalizedQuery, retryKey]) const groups = React.useMemo(() => mergeGroups(state.pages), [state.pages]) const cursor = state.pages.at(-1)?.nextCursor ?? null const loadMore = React.useCallback(() => { if (!active || !normalizedQuery || cursor === null || state.isLoadingMore) return const controller = new AbortController() const currentRequest = ++requestId.current setState((current) => ({ ...current, error: null, isLoadingMore: true })) void adapter .search({ cursor, query: normalizedQuery, signal: controller.signal, }) .then((page) => { if (currentRequest !== requestId.current || controller.signal.aborted) return setState((current) => ({ error: null, isLoading: false, isLoadingMore: false, pages: [...current.pages, page], })) }) .catch((error: unknown) => { if (currentRequest !== requestId.current || controller.signal.aborted) return setState((current) => ({ ...current, error, isLoadingMore: false })) }) }, [active, adapter, cursor, normalizedQuery, state.isLoadingMore]) const retry = React.useCallback(() => setRetryKey((value) => value + 1), []) return { error: state.error, groups, hasMore: cursor !== null, isLoading: state.isLoading, isLoadingMore: state.isLoadingMore, retry, loadMore, } } function mergeGroups( pages: readonly SearchPage[] ): readonly SearchResultGroup[] { const groups = new Map() for (const page of pages) { for (const group of page.groups) { const existing = groups.get(group.id) if (!existing) { groups.set(group.id, { ...group, items: [...group.items] }) continue } const items = new Map(existing.items.map((item) => [item.id, item])) for (const item of group.items) items.set(item.id, item) groups.set(group.id, { ...existing, ...group, items: [...items.values()], total: group.total ?? existing.total, }) } } return [...groups.values()] }