Skip to content

Conversation

@KyleAMathews
Copy link
Collaborator

@KyleAMathews KyleAMathews commented Feb 2, 2026

Summary

Fixes useLiveInfiniteQuery not paginating when used with on-demand collections that have async loadSubset (like Electric). Two independent bugs combined to make hasNextPage always false and subsequent pages never load.

Root Cause

Bug 1 — Peek-ahead never worked on page 1: hasNextPage is determined by requesting pageSize + 1 items and checking if the extra item exists. But the initial query compiled with .limit(pageSize), so the peek-ahead item could never be returned. hasNextPage was provably always false on the first page for query-function-based infinite queries.

Bug 2 — Async loadSubset never triggered for page 2+: When fetchNextPage() calls setWindow() to increase the limit, loadMoreIfNeeded must run to trigger a new loadSubset call. But loadMoreIfNeeded is invoked via a callback inside maybeRunGraph, which only fires during graph processing steps. If the graph has no pending work (common after an async loadSubset completes), the callback never fires and pagination stalls.

Approach

Fix 1 (useLiveInfiniteQuery.ts): Change initial query from .limit(pageSize) to .limit(pageSize + 1) to match the peek-ahead contract already used by setWindow() on subsequent pages.

Fix 2 (collection-config-builder.ts): After the graph processing loop, ensure the callback fires at least once even when pendingWork() is false. This lets loadMoreIfNeeded run after setWindow() increases the limit or after an async loadSubset resolves.

// New: guarantee callback fires at least once
if (!callbackCalled) {
  callback?.()
}

Key Invariants

  • The initial query and all subsequent setWindow() calls request pageSize + 1 items (peek-ahead)
  • loadMoreIfNeeded is always invoked after limit changes, regardless of graph state
  • The callback guard (callbackCalled) prevents double-invocation when the graph does have work

Non-goals

Verification

pnpm --filter react-db test

All 85 tests pass (30 in useLiveInfiniteQuery.test.tsx), including:

  • Peek-ahead limit verification (confirms loadSubset receives pageSize + 1)
  • Peek-ahead boundary test (pageSize + 1 items → hasNextPage true, data excludes extra item)
  • Sync on-demand e2e (3 pages, partial final page, hasNextPage transitions)
  • Async on-demand e2e (3 pages with delayed loadSubset, full lifecycle)
  • isFetchingNextPage lifecycle with async loading

Files Changed

File Change
packages/react-db/src/useLiveInfiniteQuery.ts Initial query uses pageSize + 1 for peek-ahead
packages/db/src/query/live/collection-config-builder.ts Callback fires at least once when graph has no pending work
packages/react-db/tests/useLiveInfiniteQuery.test.tsx 5 new tests for on-demand pagination (sync + async)
.changeset/fix-infinite-query-peek-ahead.md Patch bump for both @tanstack/db and @tanstack/react-db

Fixes #1206

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
@changeset-bot
Copy link

changeset-bot bot commented Feb 2, 2026

🦋 Changeset detected

Latest commit: 4357559

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 12 packages
Name Type
@tanstack/react-db Patch
@tanstack/db Patch
@tanstack/angular-db Patch
@tanstack/electric-db-collection Patch
@tanstack/offline-transactions Patch
@tanstack/powersync-db-collection Patch
@tanstack/query-db-collection Patch
@tanstack/rxdb-db-collection Patch
@tanstack/solid-db Patch
@tanstack/svelte-db Patch
@tanstack/trailbase-db-collection Patch
@tanstack/vue-db Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new
Copy link

pkg-pr-new bot commented Feb 2, 2026

More templates

@tanstack/angular-db

npm i https://pkg.pr.new/@tanstack/angular-db@1209

@tanstack/db

npm i https://pkg.pr.new/@tanstack/db@1209

@tanstack/db-ivm

npm i https://pkg.pr.new/@tanstack/db-ivm@1209

@tanstack/electric-db-collection

npm i https://pkg.pr.new/@tanstack/electric-db-collection@1209

@tanstack/offline-transactions

npm i https://pkg.pr.new/@tanstack/offline-transactions@1209

@tanstack/powersync-db-collection

npm i https://pkg.pr.new/@tanstack/powersync-db-collection@1209

@tanstack/query-db-collection

npm i https://pkg.pr.new/@tanstack/query-db-collection@1209

@tanstack/react-db

npm i https://pkg.pr.new/@tanstack/react-db@1209

@tanstack/rxdb-db-collection

npm i https://pkg.pr.new/@tanstack/rxdb-db-collection@1209

@tanstack/solid-db

npm i https://pkg.pr.new/@tanstack/solid-db@1209

@tanstack/svelte-db

npm i https://pkg.pr.new/@tanstack/svelte-db@1209

@tanstack/trailbase-db-collection

npm i https://pkg.pr.new/@tanstack/trailbase-db-collection@1209

@tanstack/vue-db

npm i https://pkg.pr.new/@tanstack/vue-db@1209

commit: 4357559

@github-actions
Copy link
Contributor

github-actions bot commented Feb 2, 2026

Size Change: +10 B (+0.01%)

Total Size: 92 kB

Filename Size Change
./packages/db/dist/esm/query/live/collection-config-builder.js 5.44 kB +10 B (+0.18%)
ℹ️ View Unchanged
Filename Size
./packages/db/dist/esm/collection/change-events.js 1.39 kB
./packages/db/dist/esm/collection/changes.js 1.22 kB
./packages/db/dist/esm/collection/events.js 388 B
./packages/db/dist/esm/collection/index.js 3.32 kB
./packages/db/dist/esm/collection/indexes.js 1.1 kB
./packages/db/dist/esm/collection/lifecycle.js 1.75 kB
./packages/db/dist/esm/collection/mutations.js 2.34 kB
./packages/db/dist/esm/collection/state.js 3.49 kB
./packages/db/dist/esm/collection/subscription.js 3.71 kB
./packages/db/dist/esm/collection/sync.js 2.41 kB
./packages/db/dist/esm/deferred.js 207 B
./packages/db/dist/esm/errors.js 4.7 kB
./packages/db/dist/esm/event-emitter.js 748 B
./packages/db/dist/esm/index.js 2.69 kB
./packages/db/dist/esm/indexes/auto-index.js 742 B
./packages/db/dist/esm/indexes/base-index.js 766 B
./packages/db/dist/esm/indexes/btree-index.js 2.17 kB
./packages/db/dist/esm/indexes/lazy-index.js 1.1 kB
./packages/db/dist/esm/indexes/reverse-index.js 538 B
./packages/db/dist/esm/local-only.js 837 B
./packages/db/dist/esm/local-storage.js 2.1 kB
./packages/db/dist/esm/optimistic-action.js 359 B
./packages/db/dist/esm/paced-mutations.js 496 B
./packages/db/dist/esm/proxy.js 3.75 kB
./packages/db/dist/esm/query/builder/functions.js 733 B
./packages/db/dist/esm/query/builder/index.js 4.09 kB
./packages/db/dist/esm/query/builder/ref-proxy.js 1.05 kB
./packages/db/dist/esm/query/compiler/evaluators.js 1.43 kB
./packages/db/dist/esm/query/compiler/expressions.js 430 B
./packages/db/dist/esm/query/compiler/group-by.js 1.81 kB
./packages/db/dist/esm/query/compiler/index.js 2.02 kB
./packages/db/dist/esm/query/compiler/joins.js 2.11 kB
./packages/db/dist/esm/query/compiler/order-by.js 1.45 kB
./packages/db/dist/esm/query/compiler/select.js 1.06 kB
./packages/db/dist/esm/query/expression-helpers.js 1.43 kB
./packages/db/dist/esm/query/ir.js 673 B
./packages/db/dist/esm/query/live-query-collection.js 360 B
./packages/db/dist/esm/query/live/collection-registry.js 264 B
./packages/db/dist/esm/query/live/collection-subscriber.js 2.42 kB
./packages/db/dist/esm/query/live/internal.js 145 B
./packages/db/dist/esm/query/optimizer.js 2.56 kB
./packages/db/dist/esm/query/predicate-utils.js 2.97 kB
./packages/db/dist/esm/query/subset-dedupe.js 921 B
./packages/db/dist/esm/scheduler.js 1.3 kB
./packages/db/dist/esm/SortedMap.js 1.3 kB
./packages/db/dist/esm/strategies/debounceStrategy.js 247 B
./packages/db/dist/esm/strategies/queueStrategy.js 428 B
./packages/db/dist/esm/strategies/throttleStrategy.js 246 B
./packages/db/dist/esm/transactions.js 2.9 kB
./packages/db/dist/esm/utils.js 924 B
./packages/db/dist/esm/utils/browser-polyfills.js 304 B
./packages/db/dist/esm/utils/btree.js 5.61 kB
./packages/db/dist/esm/utils/comparison.js 952 B
./packages/db/dist/esm/utils/cursor.js 457 B
./packages/db/dist/esm/utils/index-optimization.js 1.51 kB
./packages/db/dist/esm/utils/type-guards.js 157 B

compressed-size-action::db-package-size

@github-actions
Copy link
Contributor

github-actions bot commented Feb 2, 2026

Size Change: 0 B

Total Size: 3.7 kB

ℹ️ View Unchanged
Filename Size
./packages/react-db/dist/esm/index.js 225 B
./packages/react-db/dist/esm/useLiveInfiniteQuery.js 1.17 kB
./packages/react-db/dist/esm/useLiveQuery.js 1.34 kB
./packages/react-db/dist/esm/useLiveSuspenseQuery.js 559 B
./packages/react-db/dist/esm/usePacedMutations.js 401 B

compressed-size-action::react-db-package-size

claude and others added 4 commits February 2, 2026 08:46
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
- 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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Copy link
Collaborator

@samwillis samwillis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this looks good, I believe the bug was that the current tests don't trigger a pushdown to load more data from the server. So while we are calling fetchNextPage to paginate, and are checking the behaviour of hasNextPage, it's not checked against on-demand collections.

I would suggest one thing, the new test is checking for a specific internal detail (we query for offset+), but really the tests should be asserting that an on demand collection as the source with incremental sync does work with useLiveInfiniteQuery. Maybe ask Claude to add those tests.

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
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
@KyleAMathews KyleAMathews changed the title test: add peek-ahead limit detection test for useLiveInfiniteQuery fix(db): useLiveInfiniteQuery pagination with async on-demand loadSubset Feb 2, 2026
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
@KyleAMathews KyleAMathews force-pushed the claude/investigate-live-infinite-query-bug-aWea8 branch from 62e4bc3 to 2186630 Compare February 2, 2026 10:13
KyleAMathews and others added 2 commits February 12, 2026 14:31
- 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 <noreply@anthropic.com>
@KyleAMathews KyleAMathews merged commit 9184dcc into main Feb 12, 2026
7 checks passed
@KyleAMathews KyleAMathews deleted the claude/investigate-live-infinite-query-bug-aWea8 branch February 12, 2026 21:43
@github-actions github-actions bot mentioned this pull request Feb 12, 2026
@github-actions
Copy link
Contributor

🎉 This PR has been released!

Thank you for your contribution!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants