Skip to content

Commit 9cd7a35

Browse files
authored
fix: rebuild cursors on infinite query refetch (#408)
* fix: rebuild cursors on infinite query refetch * update the infinite query docs about refetching
1 parent a8fbacd commit 9cd7a35

File tree

3 files changed

+159
-6
lines changed

3 files changed

+159
-6
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -746,7 +746,7 @@ function Projects() {
746746

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

749-
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.
749+
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.
750750

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

src/tests/useInfiniteQuery.test.js

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,4 +219,136 @@ describe('useInfiniteQuery', () => {
219219
rendered.getByText('Page 1: 2'),
220220
])
221221
})
222+
223+
it('should build fresh cursors on refetch', async () => {
224+
const genItems = size => [...new Array(size)].fill(null).map((_, d) => d)
225+
const items = genItems(15)
226+
const limit = 3
227+
228+
const fetchItems = async (cursor = 0, ts) => {
229+
await sleep(10)
230+
return {
231+
nextId: cursor + limit,
232+
items: items.slice(cursor, cursor + limit),
233+
ts,
234+
}
235+
}
236+
237+
function Page() {
238+
const fetchCountRef = React.useRef(0)
239+
const {
240+
status,
241+
data,
242+
error,
243+
isFetchingMore,
244+
fetchMore,
245+
canFetchMore,
246+
refetch,
247+
} = useInfiniteQuery(
248+
'items',
249+
(key, nextId = 0) => fetchItems(nextId, fetchCountRef.current++),
250+
{
251+
getFetchMore: (lastGroup, allGroups) => lastGroup.nextId,
252+
}
253+
)
254+
255+
return (
256+
<div>
257+
<h1>Pagination</h1>
258+
{status === 'loading' ? (
259+
'Loading...'
260+
) : status === 'error' ? (
261+
<span>Error: {error.message}</span>
262+
) : (
263+
<>
264+
<div>Data:</div>
265+
{data.map((page, i) => (
266+
<div key={i}>
267+
<div>
268+
Page {i}: {page.ts}
269+
</div>
270+
<div key={i}>
271+
{page.items.map(item => (
272+
<p key={item}>Item: {item}</p>
273+
))}
274+
</div>
275+
</div>
276+
))}
277+
<div>
278+
<button
279+
onClick={() => fetchMore()}
280+
disabled={!canFetchMore || isFetchingMore}
281+
>
282+
{isFetchingMore
283+
? 'Loading more...'
284+
: canFetchMore
285+
? 'Load More'
286+
: 'Nothing more to load'}
287+
</button>
288+
<button onClick={() => refetch()}>Refetch</button>
289+
<button
290+
onClick={() => {
291+
// Imagine that this mutation happens somewhere else
292+
// makes an actual network request
293+
// and calls refetchQueries in an onSuccess
294+
items.splice(4, 1)
295+
queryCache.refetchQueries('items')
296+
}}
297+
>
298+
Remove item
299+
</button>
300+
</div>
301+
<div>{!isFetchingMore ? 'Background Updating...' : null}</div>
302+
</>
303+
)}
304+
</div>
305+
)
306+
}
307+
308+
const rendered = render(<Page />)
309+
310+
rendered.getByText('Loading...')
311+
312+
await rendered.findByText('Item: 2')
313+
await rendered.findByText('Page 0: 0')
314+
315+
fireEvent.click(rendered.getByText('Load More'))
316+
317+
await rendered.findByText('Loading more...')
318+
await rendered.findByText('Item: 5')
319+
await rendered.findByText('Page 0: 0')
320+
await rendered.findByText('Page 1: 1')
321+
322+
fireEvent.click(rendered.getByText('Load More'))
323+
324+
await rendered.findByText('Loading more...')
325+
await rendered.findByText('Item: 8')
326+
await rendered.findByText('Page 0: 0')
327+
await rendered.findByText('Page 1: 1')
328+
await rendered.findByText('Page 2: 2')
329+
330+
fireEvent.click(rendered.getByText('Refetch'))
331+
332+
await rendered.findByText('Background Updating...')
333+
await rendered.findByText('Item: 8')
334+
await rendered.findByText('Page 0: 3')
335+
await rendered.findByText('Page 1: 4')
336+
await rendered.findByText('Page 2: 5')
337+
338+
// ensure that Item: 4 is rendered before removing it
339+
expect(rendered.queryAllByText('Item: 4')).toHaveLength(1)
340+
341+
// remove Item: 4
342+
fireEvent.click(rendered.getByText('Remove item'))
343+
344+
await rendered.findByText('Background Updating...')
345+
// ensure that an additional item is rendered (it means that cursors were properly rebuilt)
346+
await rendered.findByText('Item: 9')
347+
await rendered.findByText('Page 0: 6')
348+
await rendered.findByText('Page 1: 7')
349+
await rendered.findByText('Page 2: 8')
350+
351+
// ensure that Item: 4 is no longer rendered
352+
expect(rendered.queryAllByText('Item: 4')).toHaveLength(0)
353+
})
222354
})

src/useInfiniteQuery.js

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,36 @@ export function useInfiniteQuery(...args) {
1616
const originalQueryFn = queryFn
1717

1818
queryFn = async () => {
19-
const data = await Promise.all(
20-
queryInfoRef.current.query.pageVariables.map(args =>
21-
originalQueryFn(...args)
22-
)
23-
)
19+
const data = []
20+
const pageVariables = [...queryInfoRef.current.query.pageVariables]
21+
const rebuiltPageVariables = []
22+
23+
do {
24+
const args = pageVariables.shift()
25+
26+
if (!data.length) {
27+
// the first page query doesn't need to be rebuilt
28+
data.push(await originalQueryFn(...args))
29+
rebuiltPageVariables.push(args)
30+
} else {
31+
const pageArgs = [
32+
// remove the last argument (the previously saved cursor)
33+
...args.slice(0, -1),
34+
// generate an up-to-date cursor based on the previous data set
35+
getGetFetchMore()(data[data.length - 1], data),
36+
]
37+
38+
data.push(await originalQueryFn(...pageArgs))
39+
rebuiltPageVariables.push(pageArgs)
40+
}
41+
} while (pageVariables.length)
42+
2443
queryInfoRef.current.query.canFetchMore = getGetFetchMore()(
2544
data[data.length - 1],
2645
data
2746
)
47+
queryInfoRef.current.query.pageVariables = rebuiltPageVariables
48+
2849
return data
2950
}
3051

0 commit comments

Comments
 (0)