Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -746,7 +746,7 @@ function Projects() {

### What happens when an infinite query needs to be refetched?

When an infinite query becomes `stale` and needs to be refetched, each group is fetched `individually` and in parallel with the same variables that were originally used to request each group. If an infinite query's results are ever removed from the cache, the pagination restarts at the initial state with only the initial group being requested.
When an infinite query becomes `stale` and needs to be refetched, each group is fetched `sequentially`, starting from the first one. This ensures that even if the underlying data is mutated we're not using stale cursors and potentially getting duplicates or skipping records. If an infinite query's results are ever removed from the cache, the pagination restarts at the initial state with only the initial group being requested.

### What if I need to pass custom information to my query function?

Expand Down
132 changes: 132 additions & 0 deletions src/tests/useInfiniteQuery.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -219,4 +219,136 @@ describe('useInfiniteQuery', () => {
rendered.getByText('Page 1: 2'),
])
})

it('should build fresh cursors on refetch', async () => {
const genItems = size => [...new Array(size)].fill(null).map((_, d) => d)
const items = genItems(15)
const limit = 3

const fetchItems = async (cursor = 0, ts) => {
await sleep(10)
return {
nextId: cursor + limit,
items: items.slice(cursor, cursor + limit),
ts,
}
}

function Page() {
const fetchCountRef = React.useRef(0)
const {
status,
data,
error,
isFetchingMore,
fetchMore,
canFetchMore,
refetch,
} = useInfiniteQuery(
'items',
(key, nextId = 0) => fetchItems(nextId, fetchCountRef.current++),
{
getFetchMore: (lastGroup, allGroups) => lastGroup.nextId,
}
)

return (
<div>
<h1>Pagination</h1>
{status === 'loading' ? (
'Loading...'
) : status === 'error' ? (
<span>Error: {error.message}</span>
) : (
<>
<div>Data:</div>
{data.map((page, i) => (
<div key={i}>
<div>
Page {i}: {page.ts}
</div>
<div key={i}>
{page.items.map(item => (
<p key={item}>Item: {item}</p>
))}
</div>
</div>
))}
<div>
<button
onClick={() => fetchMore()}
disabled={!canFetchMore || isFetchingMore}
>
{isFetchingMore
? 'Loading more...'
: canFetchMore
? 'Load More'
: 'Nothing more to load'}
</button>
<button onClick={() => refetch()}>Refetch</button>
<button
onClick={() => {
// Imagine that this mutation happens somewhere else
// makes an actual network request
// and calls refetchQueries in an onSuccess
items.splice(4, 1)
queryCache.refetchQueries('items')
}}
>
Remove item
</button>
</div>
<div>{!isFetchingMore ? 'Background Updating...' : null}</div>
</>
)}
</div>
)
}

const rendered = render(<Page />)

rendered.getByText('Loading...')

await rendered.findByText('Item: 2')
await rendered.findByText('Page 0: 0')

fireEvent.click(rendered.getByText('Load More'))

await rendered.findByText('Loading more...')
await rendered.findByText('Item: 5')
await rendered.findByText('Page 0: 0')
await rendered.findByText('Page 1: 1')

fireEvent.click(rendered.getByText('Load More'))

await rendered.findByText('Loading more...')
await rendered.findByText('Item: 8')
await rendered.findByText('Page 0: 0')
await rendered.findByText('Page 1: 1')
await rendered.findByText('Page 2: 2')

fireEvent.click(rendered.getByText('Refetch'))

await rendered.findByText('Background Updating...')
await rendered.findByText('Item: 8')
await rendered.findByText('Page 0: 3')
await rendered.findByText('Page 1: 4')
await rendered.findByText('Page 2: 5')

// ensure that Item: 4 is rendered before removing it
expect(rendered.queryAllByText('Item: 4')).toHaveLength(1)

// remove Item: 4
fireEvent.click(rendered.getByText('Remove item'))

await rendered.findByText('Background Updating...')
// ensure that an additional item is rendered (it means that cursors were properly rebuilt)
await rendered.findByText('Item: 9')
await rendered.findByText('Page 0: 6')
await rendered.findByText('Page 1: 7')
await rendered.findByText('Page 2: 8')

// ensure that Item: 4 is no longer rendered
expect(rendered.queryAllByText('Item: 4')).toHaveLength(0)
})
})
31 changes: 26 additions & 5 deletions src/useInfiniteQuery.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,36 @@ export function useInfiniteQuery(...args) {
const originalQueryFn = queryFn

queryFn = async () => {
const data = await Promise.all(
queryInfoRef.current.query.pageVariables.map(args =>
originalQueryFn(...args)
)
)
const data = []
const pageVariables = [...queryInfoRef.current.query.pageVariables]
const rebuiltPageVariables = []

do {
const args = pageVariables.shift()

if (!data.length) {
// the first page query doesn't need to be rebuilt
data.push(await originalQueryFn(...args))
rebuiltPageVariables.push(args)
} else {
const pageArgs = [
// remove the last argument (the previously saved cursor)
...args.slice(0, -1),
// generate an up-to-date cursor based on the previous data set
getGetFetchMore()(data[data.length - 1], data),
]

data.push(await originalQueryFn(...pageArgs))
rebuiltPageVariables.push(pageArgs)
}
} while (pageVariables.length)

queryInfoRef.current.query.canFetchMore = getGetFetchMore()(
data[data.length - 1],
data
)
queryInfoRef.current.query.pageVariables = rebuiltPageVariables

return data
}

Expand Down