Skip to content

Commit 9184dcc

Browse files
KyleAMathewsclaudeautofix-ci[bot]
authored
fix(db): useLiveInfiniteQuery pagination with async on-demand loadSubset (#1209)
* test: add failing test for useLiveInfiniteQuery peek-ahead limit bug This test documents a bug where useLiveInfiniteQuery doesn't request pageSize+1 items from loadSubset for hasNextPage peek-ahead detection. The bug causes hasNextPage to always return false when using on-demand sync mode with Electric collections, because: 1. useLiveInfiniteQuery calls setWindow({ limit: pageSize + 1 }) in useEffect 2. But subscribeToOrderedChanges calls requestLimitedSnapshot BEFORE the useEffect runs, using the original compiled limit (pageSize) 3. The loadSubset function receives limit=pageSize instead of limit=pageSize+1 4. This prevents the peek-ahead strategy from working correctly Related: Discord bug report about useLiveInfiniteQuery + Electric on-demand https://claude.ai/code/session_01FskX2noxNAj1zCiALFQXnC * ci: apply automated fixes * fix(react-db): use pageSize+1 in initial query for peek-ahead detection The initial query was using `.limit(pageSize)` but `setWindow` expects `pageSize + 1` for peek-ahead detection. This caused a race condition where the first `requestLimitedSnapshot` was called with `limit = pageSize` before `setWindow` could adjust it to `pageSize + 1`. The fix uses `pageSize + 1` from the start so the compiled query includes the peek-ahead limit, ensuring `loadSubset` receives the correct limit for `hasNextPage` detection. https://claude.ai/code/session_01FskX2noxNAj1zCiALFQXnC * ci: apply automated fixes * refactor(tests): simplify useLiveInfiniteQuery test assertions - Fix unused parameter lint warnings (allPages -> _allPages) - Simplify test logic using .find() instead of .filter()[0] - Condense redundant comments Co-Authored-By: Claude Opus 4.5 <[email protected]> * chore: add changeset for peek-ahead fix Co-Authored-By: Claude Opus 4.5 <[email protected]> * test: add e2e test for useLiveInfiniteQuery with on-demand collection Replaces the previous implementation-detail test with a proper e2e test that verifies the actual behavior of useLiveInfiniteQuery with on-demand collections: - Initial page loads correctly with hasNextPage=true - fetchNextPage() actually loads more data via loadSubset - Multiple pages can be fetched with correct items - hasNextPage correctly reflects when no more data exists This test catches bugs where the incremental sync doesn't properly fetch data from the backend when paginating. https://claude.ai/code/session_01FskX2noxNAj1zCiALFQXnC * test: add failing test for async loadSubset pagination bug This test reproduces a bug where useLiveInfiniteQuery doesn't fetch subsequent pages when loadSubset returns a Promise (async mode). Root cause identified in collection-subscriber.ts: - When loadSubset returns a Promise, pendingOrderedLoadPromise is set - loadMoreIfNeeded returns early while the promise is pending - When the promise resolves, pendingOrderedLoadPromise is cleared - BUT loadMoreIfNeeded is NOT re-triggered to check if more data is needed This affects Electric on-demand mode where all data comes from async loadSubset calls. The initial page loads correctly, but fetchNextPage fails to trigger additional loadSubset calls. https://claude.ai/code/session_01FskX2noxNAj1zCiALFQXnC * fix(db): re-trigger loadMoreIfNeeded when async loadSubset completes When useLiveInfiniteQuery uses an on-demand collection with async loadSubset, the second page was never loaded because: 1. When setWindow() was called for the next page, maybeRunGraph's callback was never called because the graph had no pending work This fix ensures the graph run callback is called at least once even when there's no pending work, so setWindow() can trigger loadMoreIfNeeded for lazy loading scenarios. https://claude.ai/code/session_01FskX2noxNAj1zCiALFQXnC * refactor: simplify tests, extend async coverage, fix changeset - Extract createOnDemandCollection helper to reduce test duplication - Extend async on-demand test to verify all 3 pages and hasNextPage=false - Add peek-ahead boundary test for pageSize+1 items - Replace silent catch block with explicit re-throw in test helper - Add @tanstack/db to changeset (independently versioned) - Remove redundant comments and tighten callback-guarantee comment Co-Authored-By: Claude Opus 4.6 <[email protected]> * ci: apply automated fixes --------- Co-authored-by: Claude <[email protected]> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent d99775a commit 9184dcc

4 files changed

Lines changed: 317 additions & 6 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@tanstack/react-db': patch
3+
'@tanstack/db': patch
4+
---
5+
6+
Fix `useLiveInfiniteQuery` peek-ahead detection for `hasNextPage`. The initial query now correctly requests `pageSize + 1` items to detect whether additional pages exist, matching the behavior of subsequent page loads.
7+
8+
Fix async on-demand pagination by ensuring the graph callback fires at least once even when there is no pending graph work, so that `loadMoreIfNeeded` is triggered after `setWindow()` increases the limit.

packages/db/src/query/live/collection-config-builder.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,13 +336,22 @@ export class CollectionConfigBuilder<
336336

337337
// Always run the graph if subscribed (eager execution)
338338
if (syncState.subscribedToAllCollections) {
339+
let callbackCalled = false
339340
while (syncState.graph.pendingWork()) {
340341
syncState.graph.run()
341342
// Flush accumulated changes after each graph step to commit them as one transaction.
342343
// This ensures intermediate join states (like null on one side) don't cause
343344
// duplicate key errors when the full join result arrives in the same step.
344345
syncState.flushPendingChanges?.()
345346
callback?.()
347+
callbackCalled = true
348+
}
349+
350+
// Ensure the callback runs at least once even when the graph has no pending work.
351+
// This handles lazy loading scenarios where setWindow() increases the limit or
352+
// an async loadSubset completes and we need to re-check if more data is needed.
353+
if (!callbackCalled) {
354+
callback?.()
346355
}
347356

348357
// On the initial run, we may need to do an empty commit to ensure that

packages/react-db/src/useLiveInfiniteQuery.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,10 +184,14 @@ export function useLiveInfiniteQuery<TContext extends Context>(
184184

185185
// Create a live query with initial limit and offset
186186
// Either pass collection directly or wrap query function
187+
// Use pageSize + 1 for peek-ahead detection (to know if there are more pages)
187188
const queryResult = isCollection
188189
? useLiveQuery(queryFnOrCollection)
189190
: useLiveQuery(
190-
(q) => queryFnOrCollection(q).limit(pageSize).offset(0),
191+
(q) =>
192+
queryFnOrCollection(q)
193+
.limit(pageSize + 1)
194+
.offset(0),
191195
deps,
192196
)
193197

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

Lines changed: 295 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ type Post = {
1414
category: string
1515
}
1616

17-
const createMockPosts = (count: number): Array<Post> => {
17+
function createMockPosts(count: number): Array<Post> {
1818
const posts: Array<Post> = []
1919
for (let i = 1; i <= count; i++) {
2020
posts.push({
@@ -28,6 +28,79 @@ const createMockPosts = (count: number): Array<Post> => {
2828
return posts
2929
}
3030

31+
type OnDemandCollectionOptions = {
32+
id: string
33+
allPosts: Array<Post>
34+
autoIndex?: `off` | `eager`
35+
asyncDelay?: number
36+
}
37+
38+
/**
39+
* Creates an on-demand collection with a loadSubset handler that supports
40+
* sorting, cursor-based pagination, and limit. Returns the collection and
41+
* a reference to recorded loadSubset calls for test assertions.
42+
*/
43+
function createOnDemandCollection(opts: OnDemandCollectionOptions) {
44+
const loadSubsetCalls: Array<LoadSubsetOptions> = []
45+
const { id, allPosts, autoIndex, asyncDelay } = opts
46+
47+
const collection = createCollection<Post>({
48+
id,
49+
getKey: (post: Post) => post.id,
50+
syncMode: `on-demand`,
51+
startSync: true,
52+
...(autoIndex ? { autoIndex } : {}),
53+
sync: {
54+
sync: ({ markReady, begin, write, commit }) => {
55+
markReady()
56+
57+
return {
58+
loadSubset: (subsetOpts: LoadSubsetOptions) => {
59+
loadSubsetCalls.push({ ...subsetOpts })
60+
61+
let filtered = [...allPosts].sort(
62+
(a, b) => b.createdAt - a.createdAt,
63+
)
64+
65+
if (subsetOpts.cursor) {
66+
const whereFromFn = createFilterFunctionFromExpression(
67+
subsetOpts.cursor.whereFrom,
68+
)
69+
filtered = filtered.filter(whereFromFn)
70+
}
71+
72+
if (subsetOpts.limit !== undefined) {
73+
filtered = filtered.slice(0, subsetOpts.limit)
74+
}
75+
76+
function writeAll(): void {
77+
begin()
78+
for (const post of filtered) {
79+
write({ type: `insert`, value: post })
80+
}
81+
commit()
82+
}
83+
84+
if (asyncDelay !== undefined) {
85+
return new Promise<void>((resolve) => {
86+
setTimeout(() => {
87+
writeAll()
88+
resolve()
89+
}, asyncDelay)
90+
})
91+
}
92+
93+
writeAll()
94+
return true
95+
},
96+
}
97+
},
98+
},
99+
})
100+
101+
return { collection, loadSubsetCalls }
102+
}
103+
31104
describe(`useLiveInfiniteQuery`, () => {
32105
it(`should fetch initial page of data`, async () => {
33106
const posts = createMockPosts(50)
@@ -629,7 +702,7 @@ describe(`useLiveInfiniteQuery`, () => {
629702
{
630703
pageSize: 10,
631704
initialPageParam: 0,
632-
getNextPageParam: (lastPage, allPages, lastPageParam) =>
705+
getNextPageParam: (lastPage, _allPages, lastPageParam) =>
633706
lastPage.length === 10 ? lastPageParam + 1 : undefined,
634707
},
635708
)
@@ -838,7 +911,7 @@ describe(`useLiveInfiniteQuery`, () => {
838911
{
839912
pageSize: 10,
840913
initialPageParam: 100,
841-
getNextPageParam: (lastPage, allPages, lastPageParam) =>
914+
getNextPageParam: (lastPage, _allPages, lastPageParam) =>
842915
lastPage.length === 10 ? lastPageParam + 1 : undefined,
843916
},
844917
)
@@ -987,6 +1060,221 @@ describe(`useLiveInfiniteQuery`, () => {
9871060
expect(result.current.isFetchingNextPage).toBe(false)
9881061
})
9891062

1063+
it(`should request limit+1 (peek-ahead) from loadSubset for hasNextPage detection`, async () => {
1064+
// Verifies that useLiveInfiniteQuery requests pageSize+1 items from loadSubset
1065+
// to detect whether there are more pages available (peek-ahead strategy)
1066+
const PAGE_SIZE = 10
1067+
const { collection, loadSubsetCalls } = createOnDemandCollection({
1068+
id: `peek-ahead-limit-test`,
1069+
allPosts: createMockPosts(PAGE_SIZE), // Exactly PAGE_SIZE posts
1070+
})
1071+
1072+
const { result } = renderHook(() => {
1073+
return useLiveInfiniteQuery(
1074+
(q) =>
1075+
q
1076+
.from({ posts: collection })
1077+
.orderBy(({ posts: p }) => p.createdAt, `desc`),
1078+
{
1079+
pageSize: PAGE_SIZE,
1080+
getNextPageParam: (lastPage) =>
1081+
lastPage.length === PAGE_SIZE ? lastPage.length : undefined,
1082+
},
1083+
)
1084+
})
1085+
1086+
await waitFor(() => {
1087+
expect(result.current.isReady).toBe(true)
1088+
})
1089+
1090+
const callWithLimit = loadSubsetCalls.find(
1091+
(call) => call.limit !== undefined,
1092+
)
1093+
expect(callWithLimit).toBeDefined()
1094+
expect(callWithLimit!.limit).toBe(PAGE_SIZE + 1)
1095+
1096+
// With exactly PAGE_SIZE posts, hasNextPage should be false (no peek-ahead item returned)
1097+
expect(result.current.hasNextPage).toBe(false)
1098+
expect(result.current.data).toHaveLength(PAGE_SIZE)
1099+
})
1100+
1101+
it(`should detect hasNextPage via peek-ahead with exactly pageSize+1 items in on-demand collection`, async () => {
1102+
// Boundary test: with exactly pageSize+1 items, the peek-ahead item should
1103+
// signal hasNextPage=true but NOT appear in user-visible data
1104+
const PAGE_SIZE = 10
1105+
const { collection } = createOnDemandCollection({
1106+
id: `peek-ahead-boundary-test`,
1107+
allPosts: createMockPosts(PAGE_SIZE + 1),
1108+
})
1109+
1110+
const { result } = renderHook(() => {
1111+
return useLiveInfiniteQuery(
1112+
(q) =>
1113+
q
1114+
.from({ posts: collection })
1115+
.orderBy(({ posts: p }) => p.createdAt, `desc`),
1116+
{
1117+
pageSize: PAGE_SIZE,
1118+
getNextPageParam: (lastPage) =>
1119+
lastPage.length === PAGE_SIZE ? lastPage.length : undefined,
1120+
},
1121+
)
1122+
})
1123+
1124+
await waitFor(() => {
1125+
expect(result.current.isReady).toBe(true)
1126+
})
1127+
1128+
// Peek-ahead item detected: hasNextPage should be true
1129+
expect(result.current.hasNextPage).toBe(true)
1130+
// But user-visible data should be exactly pageSize (peek-ahead excluded)
1131+
expect(result.current.data).toHaveLength(PAGE_SIZE)
1132+
expect(result.current.pages).toHaveLength(1)
1133+
expect(result.current.pages[0]).toHaveLength(PAGE_SIZE)
1134+
})
1135+
1136+
it(`should work with on-demand collection and fetch multiple pages`, async () => {
1137+
// End-to-end test: on-demand collection where ALL data comes from loadSubset
1138+
// (no initial data). Simulates the real Electric on-demand scenario.
1139+
const PAGE_SIZE = 10
1140+
const { collection, loadSubsetCalls } = createOnDemandCollection({
1141+
id: `on-demand-e2e-test`,
1142+
allPosts: createMockPosts(25), // 2 full pages + 5 items
1143+
autoIndex: `eager`,
1144+
})
1145+
1146+
const { result } = renderHook(() => {
1147+
return useLiveInfiniteQuery(
1148+
(q) =>
1149+
q
1150+
.from({ posts: collection })
1151+
.orderBy(({ posts: p }) => p.createdAt, `desc`),
1152+
{
1153+
pageSize: PAGE_SIZE,
1154+
getNextPageParam: (lastPage) =>
1155+
lastPage.length === PAGE_SIZE ? lastPage.length : undefined,
1156+
},
1157+
)
1158+
})
1159+
1160+
await waitFor(() => {
1161+
expect(result.current.isReady).toBe(true)
1162+
})
1163+
1164+
// Page 1: 10 items
1165+
expect(result.current.pages).toHaveLength(1)
1166+
expect(result.current.data).toHaveLength(PAGE_SIZE)
1167+
expect(result.current.hasNextPage).toBe(true)
1168+
expect(result.current.data[0]!.id).toBe(`1`)
1169+
expect(result.current.data[9]!.id).toBe(`10`)
1170+
1171+
// Fetch page 2
1172+
act(() => {
1173+
result.current.fetchNextPage()
1174+
})
1175+
1176+
await waitFor(() => {
1177+
expect(result.current.pages).toHaveLength(2)
1178+
})
1179+
1180+
expect(loadSubsetCalls.length).toBeGreaterThan(1)
1181+
expect(result.current.data).toHaveLength(20)
1182+
expect(result.current.hasNextPage).toBe(true)
1183+
expect(result.current.pages[1]![0]!.id).toBe(`11`)
1184+
expect(result.current.pages[1]![9]!.id).toBe(`20`)
1185+
1186+
// Fetch page 3 (partial page)
1187+
act(() => {
1188+
result.current.fetchNextPage()
1189+
})
1190+
1191+
await waitFor(() => {
1192+
expect(result.current.pages).toHaveLength(3)
1193+
})
1194+
1195+
expect(result.current.data).toHaveLength(25)
1196+
expect(result.current.pages[2]).toHaveLength(5)
1197+
expect(result.current.hasNextPage).toBe(false)
1198+
expect(result.current.pages[2]![0]!.id).toBe(`21`)
1199+
expect(result.current.pages[2]![4]!.id).toBe(`25`)
1200+
})
1201+
1202+
it(`should work with on-demand collection with async loadSubset`, async () => {
1203+
// Same as the sync on-demand test, but loadSubset returns a Promise
1204+
// to simulate async network requests (the real Electric scenario).
1205+
const PAGE_SIZE = 10
1206+
const { collection, loadSubsetCalls } = createOnDemandCollection({
1207+
id: `on-demand-async-test`,
1208+
allPosts: createMockPosts(25),
1209+
autoIndex: `eager`,
1210+
asyncDelay: 10,
1211+
})
1212+
1213+
const { result } = renderHook(() => {
1214+
return useLiveInfiniteQuery(
1215+
(q) =>
1216+
q
1217+
.from({ posts: collection })
1218+
.orderBy(({ posts: p }) => p.createdAt, `desc`),
1219+
{
1220+
pageSize: PAGE_SIZE,
1221+
getNextPageParam: (lastPage) =>
1222+
lastPage.length === PAGE_SIZE ? lastPage.length : undefined,
1223+
},
1224+
)
1225+
})
1226+
1227+
await waitFor(() => {
1228+
expect(result.current.isReady).toBe(true)
1229+
})
1230+
1231+
await waitFor(() => {
1232+
expect(result.current.data).toHaveLength(PAGE_SIZE)
1233+
})
1234+
1235+
expect(result.current.pages).toHaveLength(1)
1236+
expect(result.current.hasNextPage).toBe(true)
1237+
1238+
const initialCallCount = loadSubsetCalls.length
1239+
1240+
// Fetch page 2
1241+
act(() => {
1242+
result.current.fetchNextPage()
1243+
})
1244+
1245+
expect(result.current.isFetchingNextPage).toBe(true)
1246+
1247+
await waitFor(
1248+
() => {
1249+
expect(result.current.data).toHaveLength(20)
1250+
},
1251+
{ timeout: 500 },
1252+
)
1253+
1254+
expect(result.current.pages).toHaveLength(2)
1255+
expect(loadSubsetCalls.length).toBeGreaterThan(initialCallCount)
1256+
expect(result.current.hasNextPage).toBe(true)
1257+
1258+
// Fetch page 3 (partial page) to verify async path handles end-of-data
1259+
const callCountBeforePage3 = loadSubsetCalls.length
1260+
1261+
act(() => {
1262+
result.current.fetchNextPage()
1263+
})
1264+
1265+
await waitFor(
1266+
() => {
1267+
expect(result.current.data).toHaveLength(25)
1268+
},
1269+
{ timeout: 500 },
1270+
)
1271+
1272+
expect(result.current.pages).toHaveLength(3)
1273+
expect(result.current.pages[2]).toHaveLength(5)
1274+
expect(loadSubsetCalls.length).toBeGreaterThan(callCountBeforePage3)
1275+
expect(result.current.hasNextPage).toBe(false)
1276+
})
1277+
9901278
it(`should track isFetchingNextPage when async loading is triggered`, async () => {
9911279
// Define all data upfront
9921280
const allPosts = createMockPosts(30)
@@ -1062,8 +1350,10 @@ describe(`useLiveInfiniteQuery`, () => {
10621350
}
10631351
// Re-sort after combining
10641352
filtered.sort((a, b) => b.createdAt - a.createdAt)
1065-
} catch {
1066-
// Fallback to original filtered if cursor parsing fails
1353+
} catch (e) {
1354+
throw new Error(`Test loadSubset: cursor parsing failed`, {
1355+
cause: e,
1356+
})
10671357
}
10681358
} else if (opts.limit !== undefined) {
10691359
// Apply limit only if no cursor (cursor handles limit internally)

0 commit comments

Comments
 (0)