Files
simple-react-app-kit/packages/search/src/use-search.ts
T

136 lines
3.7 KiB
TypeScript

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<SearchState>(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<string, SearchResultGroup>()
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()]
}