Skip to content

Commit 6f7161c

Browse files
committed
isFetchingNextPage set by promise from setWindow
1 parent 3baa43f commit 6f7161c

3 files changed

Lines changed: 206 additions & 34 deletions

File tree

.changeset/smooth-goats-ring.md

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ Add `useLiveInfiniteQuery` hook for infinite scrolling with live updates.
77
The new `useLiveInfiniteQuery` hook provides an infinite query pattern similar to TanStack Query's `useInfiniteQuery`, but with live updates from your local collection. It uses `liveQueryCollection.utils.setWindow()` internally to efficiently paginate through ordered data without recreating the query on each page fetch.
88

99
**Key features:**
10+
1011
- Automatic live updates as data changes in the collection
1112
- Efficient pagination using dynamic window adjustment
1213
- Peek-ahead mechanism to detect when more pages are available
@@ -15,43 +16,43 @@ The new `useLiveInfiniteQuery` hook provides an infinite query pattern similar t
1516
**Example usage:**
1617

1718
```tsx
18-
import { useLiveInfiniteQuery } from '@tanstack/react-db'
19+
import { useLiveInfiniteQuery } from "@tanstack/react-db"
1920

2021
function PostList() {
21-
const { data, pages, fetchNextPage, hasNextPage, isLoading } = useLiveInfiniteQuery(
22-
(q) => q
23-
.from({ posts: postsCollection })
24-
.orderBy(({ posts }) => posts.createdAt, 'desc'),
25-
{
26-
pageSize: 20,
27-
getNextPageParam: (lastPage, allPages) =>
28-
lastPage.length === 20 ? allPages.length : undefined
29-
}
30-
)
22+
const { data, pages, fetchNextPage, hasNextPage, isLoading } =
23+
useLiveInfiniteQuery(
24+
(q) =>
25+
q
26+
.from({ posts: postsCollection })
27+
.orderBy(({ posts }) => posts.createdAt, "desc"),
28+
{
29+
pageSize: 20,
30+
getNextPageParam: (lastPage, allPages) =>
31+
lastPage.length === 20 ? allPages.length : undefined,
32+
}
33+
)
3134

3235
if (isLoading) return <div>Loading...</div>
3336

3437
return (
3538
<div>
3639
{pages.map((page, i) => (
3740
<div key={i}>
38-
{page.map(post => (
41+
{page.map((post) => (
3942
<PostCard key={post.id} post={post} />
4043
))}
4144
</div>
4245
))}
4346
{hasNextPage && (
44-
<button onClick={() => fetchNextPage()}>
45-
Load More
46-
</button>
47+
<button onClick={() => fetchNextPage()}>Load More</button>
4748
)}
4849
</div>
4950
)
5051
}
5152
```
5253

5354
**Requirements:**
55+
5456
- Query must include `.orderBy()` for the window mechanism to work
5557
- Returns flattened `data` array and `pages` array for flexible rendering
5658
- Automatically detects new pages when data is synced to the collection
57-

packages/react-db/src/useLiveInfiniteQuery.ts

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ export function useLiveInfiniteQuery<TContext extends Context>(
9393

9494
// Track how many pages have been loaded
9595
const [loadedPageCount, setLoadedPageCount] = useState(1)
96-
const isFetchingRef = useRef(false)
96+
const [isFetchingNextPage, setIsFetchingNextPage] = useState(false)
9797

9898
// Stringify deps for comparison
9999
const depsKey = JSON.stringify(deps)
@@ -121,7 +121,16 @@ export function useLiveInfiniteQuery<TContext extends Context>(
121121
const utils = queryResult.collection.utils
122122
// setWindow is available on live query collections with orderBy
123123
if (isLiveQueryCollectionUtils(utils)) {
124-
utils.setWindow({ offset: 0, limit: newLimit })
124+
const result = utils.setWindow({ offset: 0, limit: newLimit })
125+
// setWindow returns true if data is immediately available, or Promise<void> if loading
126+
if (result !== true) {
127+
setIsFetchingNextPage(true)
128+
result.then(() => {
129+
setIsFetchingNextPage(false)
130+
})
131+
} else {
132+
setIsFetchingNextPage(false)
133+
}
125134
}
126135
}, [loadedPageCount, pageSize, queryResult.collection])
127136

@@ -158,20 +167,11 @@ export function useLiveInfiniteQuery<TContext extends Context>(
158167
}, [queryResult.data, loadedPageCount, pageSize, initialPageParam])
159168

160169
// Fetch next page
161-
// TODO: this should use the `collection.isLoadingSubset` flag in combination with
162-
// isFetchingRef to track if it is fetching from subset for this. This needs adding
163-
// once https://github.com/TanStack/db/pull/669 is merged
164170
const fetchNextPage = useCallback(() => {
165-
if (!hasNextPage || isFetchingRef.current) return
171+
if (!hasNextPage || isFetchingNextPage) return
166172

167-
isFetchingRef.current = true
168173
setLoadedPageCount((prev) => prev + 1)
169-
170-
// Reset fetching state synchronously
171-
Promise.resolve().then(() => {
172-
isFetchingRef.current = false
173-
})
174-
}, [hasNextPage])
174+
}, [hasNextPage, isFetchingNextPage])
175175

176176
return {
177177
...queryResult,
@@ -180,6 +180,6 @@ export function useLiveInfiniteQuery<TContext extends Context>(
180180
pageParams,
181181
fetchNextPage,
182182
hasNextPage,
183-
isFetchingNextPage: isFetchingRef.current,
183+
isFetchingNextPage,
184184
}
185185
}

packages/react-db/tests/useLiveInfiniteQuery.test.tsx

Lines changed: 175 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -611,19 +611,37 @@ describe(`useLiveInfiniteQuery`, () => {
611611
expect(result.current.isReady).toBe(true)
612612
})
613613

614-
// Try to fetch multiple times rapidly
614+
expect(result.current.pages).toHaveLength(1)
615+
616+
// With sync data, all fetches complete immediately, so all 3 calls will succeed
617+
// The key is that they won't cause race conditions or errors
615618
act(() => {
616619
result.current.fetchNextPage()
620+
})
621+
622+
await waitFor(() => {
623+
expect(result.current.pages).toHaveLength(2)
624+
})
625+
626+
act(() => {
617627
result.current.fetchNextPage()
628+
})
629+
630+
await waitFor(() => {
631+
expect(result.current.pages).toHaveLength(3)
632+
})
633+
634+
act(() => {
618635
result.current.fetchNextPage()
619636
})
620637

621638
await waitFor(() => {
622-
expect(result.current.pages).toHaveLength(2)
639+
expect(result.current.pages).toHaveLength(4)
623640
})
624641

625-
// Should only have fetched one additional page
626-
expect(result.current.pages).toHaveLength(2)
642+
// All fetches should have succeeded
643+
expect(result.current.pages).toHaveLength(4)
644+
expect(result.current.data).toHaveLength(40)
627645
})
628646

629647
it(`should not fetch when hasNextPage is false`, async () => {
@@ -793,4 +811,157 @@ describe(`useLiveInfiniteQuery`, () => {
793811
// No more pages available now
794812
expect(result.current.hasNextPage).toBe(false)
795813
})
814+
815+
it(`should set isFetchingNextPage to false when data is immediately available`, async () => {
816+
const posts = createMockPosts(50)
817+
const collection = createCollection(
818+
mockSyncCollectionOptions<Post>({
819+
id: `immediate-data-test`,
820+
getKey: (post: Post) => post.id,
821+
initialData: posts,
822+
})
823+
)
824+
825+
const { result } = renderHook(() => {
826+
return useLiveInfiniteQuery(
827+
(q) =>
828+
q
829+
.from({ posts: collection })
830+
.orderBy(({ posts: p }) => p.createdAt, `desc`),
831+
{
832+
pageSize: 10,
833+
getNextPageParam: (lastPage) =>
834+
lastPage.length === 10 ? lastPage.length : undefined,
835+
}
836+
)
837+
})
838+
839+
await waitFor(() => {
840+
expect(result.current.isReady).toBe(true)
841+
})
842+
843+
// Initially 1 page and not fetching
844+
expect(result.current.pages).toHaveLength(1)
845+
expect(result.current.isFetchingNextPage).toBe(false)
846+
847+
// Fetch next page - should remain false because data is immediately available
848+
act(() => {
849+
result.current.fetchNextPage()
850+
})
851+
852+
// Since data is *synchronously* available, isFetchingNextPage should be false
853+
expect(result.current.pages).toHaveLength(2)
854+
expect(result.current.isFetchingNextPage).toBe(false)
855+
})
856+
857+
it(`should track isFetchingNextPage when async loading is triggered`, async () => {
858+
let loadSubsetCallCount = 0
859+
860+
const collection = createCollection<Post>({
861+
id: `async-loading-test`,
862+
getKey: (post: Post) => post.id,
863+
syncMode: `on-demand`,
864+
startSync: true,
865+
sync: {
866+
sync: ({ markReady, begin, write, commit }) => {
867+
// Provide initial data
868+
begin()
869+
for (let i = 1; i <= 15; i++) {
870+
write({
871+
type: `insert`,
872+
value: {
873+
id: `${i}`,
874+
title: `Post ${i}`,
875+
content: `Content ${i}`,
876+
createdAt: 1000000 - i * 1000,
877+
category: i % 2 === 0 ? `tech` : `life`,
878+
},
879+
})
880+
}
881+
commit()
882+
markReady()
883+
884+
return {
885+
loadSubset: () => {
886+
loadSubsetCallCount++
887+
888+
// First few calls return true (initial load + window setup)
889+
if (loadSubsetCallCount <= 2) {
890+
return true
891+
}
892+
893+
// Subsequent calls simulate async loading with a real timeout
894+
const loadPromise = new Promise<void>((resolve) => {
895+
setTimeout(() => {
896+
begin()
897+
// Load more data
898+
for (let i = 16; i <= 30; i++) {
899+
write({
900+
type: `insert`,
901+
value: {
902+
id: `${i}`,
903+
title: `Post ${i}`,
904+
content: `Content ${i}`,
905+
createdAt: 1000000 - i * 1000,
906+
category: i % 2 === 0 ? `tech` : `life`,
907+
},
908+
})
909+
}
910+
commit()
911+
resolve()
912+
}, 50)
913+
})
914+
915+
return loadPromise
916+
},
917+
}
918+
},
919+
},
920+
})
921+
922+
const { result } = renderHook(() => {
923+
return useLiveInfiniteQuery(
924+
(q) =>
925+
q
926+
.from({ posts: collection })
927+
.orderBy(({ posts: p }) => p.createdAt, `desc`),
928+
{
929+
pageSize: 10,
930+
getNextPageParam: (lastPage) =>
931+
lastPage.length === 10 ? lastPage.length : undefined,
932+
}
933+
)
934+
})
935+
936+
await waitFor(() => {
937+
expect(result.current.isReady).toBe(true)
938+
})
939+
940+
// Wait for initial window setup to complete
941+
await waitFor(() => {
942+
expect(result.current.isFetchingNextPage).toBe(false)
943+
})
944+
945+
expect(result.current.pages).toHaveLength(1)
946+
947+
// Fetch next page which will trigger async loading
948+
act(() => {
949+
result.current.fetchNextPage()
950+
})
951+
952+
// Should be fetching now and so isFetchingNextPage should be true *synchronously!*
953+
expect(result.current.isFetchingNextPage).toBe(true)
954+
955+
// Wait for loading to complete
956+
await waitFor(
957+
() => {
958+
expect(result.current.isFetchingNextPage).toBe(false)
959+
},
960+
{ timeout: 200 }
961+
)
962+
963+
// Should have 2 pages now
964+
expect(result.current.pages).toHaveLength(2)
965+
expect(result.current.data).toHaveLength(20)
966+
}, 10000)
796967
})

0 commit comments

Comments
 (0)