|
// 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, |
|
} |
|
} |
that was my first plan but it only refetch the first page and keep the cache data for the other page and won't trigger anymore so it won't refresh cache except for the first page.
currently getting help on discord for maybe a solution that fit react-native need because of FlatList requirement preventing recursive lists page.