|
// Copyright 2023, Fernando Rojo. Free to use. |
|
import { |
|
useCallback, |
|
useRef, |
|
useState, |
|
useLayoutEffect, |
|
useEffect, |
|
useMemo, |
|
} from 'react' |
|
import { useQuery, UseQueryArgs, AnyVariables, useClient } from 'urql' |
|
import type { TypedDocumentNode } from '@graphql-typed-document-node/core' |
|
|
|
import deepEqual from 'react-fast-compare' |
|
|
|
const useServerLayoutEffect = |
|
typeof window !== 'undefined' ? useLayoutEffect : useEffect |
|
|
|
type Options<V extends AnyVariables = AnyVariables> = Omit< |
|
UseQueryArgs<V>, |
|
'query' | 'variables' |
|
> & { |
|
variables: Omit<V, 'offset'> |
|
} |
|
/** |
|
* Variables should be memoized here. |
|
*/ |
|
export function usePaginatedUrqlQuery< |
|
D, |
|
V extends { limit: number; offset: number } = { |
|
limit: number |
|
offset: number |
|
} |
|
>( |
|
document: TypedDocumentNode<D, V>, |
|
options: Options<V>, |
|
{ |
|
getLength, |
|
keepPreviousNonEmptyData, |
|
}: { getLength: (data: D) => number; keepPreviousNonEmptyData?: boolean } |
|
) { |
|
const [page, setPage] = useState(1) |
|
const { limit } = options.variables |
|
const offset = (page - 1) * options.variables.limit |
|
|
|
const client = useClient() |
|
|
|
// @ts-expect-error i think it's fine, just generic stuff |
|
const [query, executeQuery] = useQuery<D, V>({ |
|
query: document, |
|
...options, |
|
variables: useMemo( |
|
() => ({ |
|
...options.variables, |
|
offset, |
|
}), |
|
[options.variables, offset] |
|
), |
|
}) |
|
|
|
const previousNonEmptyData = useRef<typeof query.data>() |
|
|
|
useEffect(() => { |
|
if (query.data && getLength(query.data)) { |
|
previousNonEmptyData.current = query.data |
|
} |
|
}, [query.data]) |
|
|
|
const { fetching, stale, data, error, operation } = query |
|
|
|
const isFetchingMore = Boolean( |
|
(query.fetching || query.stale) && |
|
query.data && |
|
page > 1 && |
|
getLength(query.data) <= (page - 1) * limit |
|
) |
|
|
|
const isLoadingInitial = Boolean( |
|
page === 1 && (query.fetching || query.stale) && !query.data && !query.error |
|
) |
|
|
|
const canFetchMore = Boolean( |
|
!query.fetching && query.data && getLength(query.data) >= page * limit |
|
) |
|
|
|
const fetchMore = useCallback( |
|
() => canFetchMore && setPage((page) => page + 1), |
|
[canFetchMore] |
|
) |
|
|
|
const revalidate = useCallback( |
|
() => executeQuery({ requestPolicy: 'network-only' }), |
|
[executeQuery] |
|
) |
|
|
|
const pullToRefresh = useCallback(() => { |
|
revalidate() |
|
setPage(1) |
|
}, [revalidate]) |
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps |
|
const variablesToCompare = useMemo( |
|
() => ({ |
|
...options.variables, |
|
offset: undefined, |
|
}), |
|
[options.variables] |
|
) |
|
|
|
const previousVariables = useRef(variablesToCompare) |
|
|
|
useServerLayoutEffect( |
|
function setFirstPageOnMeaningfulVariableChange() { |
|
if (!deepEqual(previousVariables.current, variablesToCompare)) { |
|
setPage(1) |
|
} |
|
previousVariables.current = variablesToCompare |
|
}, |
|
// this isn't a deep-equal check, but it at least is better than nothing |
|
// "fast-deep-equal" will do the most on most renders. idk a way around it |
|
// eslint-disable-next-line react-hooks/exhaustive-deps |
|
Object.values(variablesToCompare) |
|
) |
|
const [isPullingToRefresh, setPullingToRefresh] = useState(false) |
|
|
|
const timer = useRef(0) |
|
|
|
const onPullToRefresh = useCallback(() => { |
|
clearTimeout(timer.current) |
|
setPullingToRefresh(true) |
|
executeQuery({ |
|
requestPolicy: 'network-only', |
|
}) |
|
new Promise<void>((resolve) => { |
|
timer.current = setTimeout(() => { |
|
clearTimeout(timer.current) |
|
resolve() |
|
}, 1000) |
|
}) |
|
.catch() |
|
.finally(() => { |
|
setPullingToRefresh(false) |
|
}) |
|
}, [executeQuery]) |
|
|
|
useEffect( |
|
() => () => { |
|
clearTimeout(timer.current) |
|
}, |
|
[] |
|
) |
|
|
|
return { |
|
fetching, |
|
stale, |
|
data: (() => { |
|
if ( |
|
keepPreviousNonEmptyData && |
|
query.data && |
|
getLength(query.data) === 0 && |
|
previousNonEmptyData.current && |
|
getLength(previousNonEmptyData.current) > 0 |
|
) { |
|
return previousNonEmptyData.current |
|
} |
|
return data |
|
})(), |
|
error, |
|
variables: operation?.variables, |
|
isPullingToRefresh, |
|
isLoadingInitial, |
|
onPullToRefresh, |
|
execute: useCallback(async () => { |
|
return executeQuery({ |
|
requestPolicy: 'network-only', |
|
}) |
|
}, [executeQuery]), |
|
refreshPageOfItemIndex: useCallback( |
|
(itemIndex: number) => { |
|
if (itemIndex < 0) { |
|
return |
|
} |
|
const pageToRefresh = Math.ceil((itemIndex + 1) / limit) |
|
|
|
client |
|
.query( |
|
document, |
|
{ |
|
offset: (pageToRefresh - 1) * limit, |
|
...options.variables, |
|
} as any, |
|
{ |
|
requestPolicy: 'network-only', |
|
} |
|
) |
|
.toPromise() |
|
}, |
|
[document, limit, client, options.variables] |
|
), |
|
canFetchMore, |
|
isFetchingMore, |
|
fetchMore, |
|
revalidate, |
|
pullToRefresh, |
|
} |
|
} |
yeah true, at the time of writing set timeout just returned a number as the type