Skip to content

Conversation

@KyleAMathews
Copy link
Collaborator

When using useLiveSuspenseQuery with on-demand sync mode, the suspense boundary would sometimes release before the query's data was actually loaded. This happened because the live query collection was marked as ready immediately when the source collection was already ready, even though the loadSubset operation for the specific query hadn't completed.

This fix ensures that useLiveSuspenseQuery also suspends while isLoadingSubset is true, waiting for the initial subset load to complete before releasing the suspense boundary.

🎯 Changes

✅ Checklist

  • I have followed the steps in the Contributing guide.
  • I have tested this code locally with pnpm test:pr.

🚀 Release Impact

  • This change affects published code, and I have generated a changeset.
  • This change is docs/CI/dev-only (no release).

…in on-demand mode

When using useLiveSuspenseQuery with on-demand sync mode, the suspense
boundary would sometimes release before the query's data was actually
loaded. This happened because the live query collection was marked as
ready immediately when the source collection was already ready, even
though the loadSubset operation for the specific query hadn't completed.

This fix ensures that useLiveSuspenseQuery also suspends while
isLoadingSubset is true, waiting for the initial subset load to complete
before releasing the suspense boundary.
@changeset-bot
Copy link

changeset-bot bot commented Dec 30, 2025

🦋 Changeset detected

Latest commit: 12e4d74

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

This PR includes changesets to release 12 packages
Name Type
@tanstack/db Patch
@tanstack/react-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

claude and others added 2 commits December 30, 2025 21:48
This test verifies that useLiveSuspenseQuery holds the suspense boundary
when isLoadingSubset is true, even if the collection status is 'ready'.

The test confirms:
1. WITHOUT the fix: suspense releases prematurely (test fails)
2. WITH the fix: suspense waits for isLoadingSubset to be false (test passes)
@pkg-pr-new
Copy link

pkg-pr-new bot commented Dec 30, 2025

More templates

@tanstack/angular-db

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

@tanstack/db

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

@tanstack/db-ivm

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

@tanstack/electric-db-collection

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

@tanstack/offline-transactions

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

@tanstack/powersync-db-collection

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

@tanstack/query-db-collection

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

@tanstack/react-db

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

@tanstack/rxdb-db-collection

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

@tanstack/solid-db

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

@tanstack/svelte-db

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

@tanstack/trailbase-db-collection

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

@tanstack/vue-db

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

commit: 12e4d74

@github-actions
Copy link
Contributor

github-actions bot commented Dec 30, 2025

Size Change: +129 B (+0.14%)

Total Size: 89.6 kB

Filename Size Change
./packages/db/dist/esm/collection/changes.js 1.01 kB +13 B (+1.3%)
./packages/db/dist/esm/query/live/collection-config-builder.js 5.35 kB +29 B (+0.54%)
./packages/db/dist/esm/query/live/collection-subscriber.js 1.98 kB +87 B (+4.59%) 🔍
ℹ️ View Unchanged
Filename Size
./packages/db/dist/esm/collection/change-events.js 1.39 kB
./packages/db/dist/esm/collection/events.js 388 B
./packages/db/dist/esm/collection/index.js 3.24 kB
./packages/db/dist/esm/collection/indexes.js 1.1 kB
./packages/db/dist/esm/collection/lifecycle.js 1.67 kB
./packages/db/dist/esm/collection/mutations.js 2.34 kB
./packages/db/dist/esm/collection/state.js 3.46 kB
./packages/db/dist/esm/collection/subscription.js 3.62 kB
./packages/db/dist/esm/collection/sync.js 2.38 kB
./packages/db/dist/esm/deferred.js 207 B
./packages/db/dist/esm/errors.js 4.27 kB
./packages/db/dist/esm/event-emitter.js 748 B
./packages/db/dist/esm/index.js 2.68 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 1.93 kB
./packages/db/dist/esm/indexes/lazy-index.js 1.1 kB
./packages/db/dist/esm/indexes/reverse-index.js 513 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 3.96 kB
./packages/db/dist/esm/query/builder/ref-proxy.js 917 B
./packages/db/dist/esm/query/compiler/evaluators.js 1.35 kB
./packages/db/dist/esm/query/compiler/expressions.js 430 B
./packages/db/dist/esm/query/compiler/group-by.js 1.8 kB
./packages/db/dist/esm/query/compiler/index.js 1.96 kB
./packages/db/dist/esm/query/compiler/joins.js 2 kB
./packages/db/dist/esm/query/compiler/order-by.js 1.46 kB
./packages/db/dist/esm/query/compiler/select.js 1.07 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/internal.js 130 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 881 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 852 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 Dec 30, 2025

Size Change: 0 B

Total Size: 3.35 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.12 kB
./packages/react-db/dist/esm/useLiveSuspenseQuery.js 431 B
./packages/react-db/dist/esm/usePacedMutations.js 401 B

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

@marbemac
Copy link
Contributor

Doesn't seem to resolve the issue unfortunately.

Also, I'm not sure this is specific to suspense? For example if I have the below with regular useLiveQuery, sometimes isReady=true without any data, and sometimes after there is data.

const { data, isReady, state, } = useLiveQuery(
  q =>
    q
      .from({ w: workflowRunsCollection })
      .where(({ w }) => eq(w.id, 'some_id'))
      .findOne(),
  [],
);

console.log('run', { haveData: !!data, state, isReady });

I've stripped our app down to this one query, and reload the page, and see similar symptom to the suspense version (albeit using isReady as the boundary versus suspense):

Screenshot 2025-12-30 at 5 48 14 PM

claude and others added 6 commits December 30, 2025 23:31
…a is loaded

In on-demand sync mode, the live query collection was being marked as 'ready'
before the subset data finished loading. This caused useLiveQuery to return
isReady=true with empty data, and useLiveSuspenseQuery to release suspense
prematurely.

The fix:
1. Added isLoadingSubset check in updateLiveQueryStatus() to prevent marking
   ready while subset is loading
2. Added listener for loadingSubset:change events to trigger ready check
   when subset loading completes
3. Added test case that verifies the correct timing behavior
…race condition

The loadingSubset:change listener was registered after subscribeToAllCollections(),
which could cause a race condition where the event fires before the listener is
registered. This resulted in the live query never becoming ready.

Also adds await in electric test to account for async subset loading.
Register the status:change listener BEFORE checking the current
subscription status to avoid missing status transitions.

Previously, if loadSubset completed very quickly, the status could
change from 'loadingSubset' to 'ready' between checking the status
and registering the listener, causing the tracked promise to never
resolve and the live query to never become ready.
@marbemac
Copy link
Contributor

Out of curiosity, I tried the latest from here and unfortunately the problem persists. So I had Claude add a bunch of debug logs to spots that might be relevant, then reloaded our app, and fed the logs back into claude for diagnosis. Here are the resulting findings from Claude in case helpful (including the debug logs from the actual reproduction):


Debug Analysis: isReady=true before data is loaded

Issue Summary

In on-demand sync mode with Electric, useLiveQuery returns isReady=true before the query data has been written to the live query collection. This causes:

  • useLiveQuery to return isReady=true with empty data
  • useLiveSuspenseQuery to release suspense prematurely

Reproduction

function Debug() {
  const { data, isReady, state } = useLiveQuery(
    q =>
      q
        .from({ w: workflowRunsCollection })
        .where(({ w }) => eq(w.id, 'wrun_eco6todnlmxfvg'))
        .findOne(),
    [],
  );

  console.log('run', { haveData: !!data, state, isReady });

  return <div>Debug</div>;
}

Console output on page load:

run {haveData: false, state: Map(0), isReady: false}
run {haveData: false, state: Map(0), isReady: true}   ← BUG: isReady=true but no data
run {haveData: true, state: Map(1), isReady: true}

Debug Logs

Added console.logs to trace the flow. Full output:

[DEBUG allCollectionsReady] id=live-query-1
  statuses: [{id: 'workflowRuns', status: 'loading', isReady: false}]

[DEBUG updateLiveQueryStatus] id=live-query-1
  {allCollectionsReady: false, isLoadingSubset: false, currentStatus: 'loading', willMarkReady: false}

[DEBUG trackLoadPromise] id=workflowRuns
  {loadingStarting: true, pendingCount: 1}

[DEBUG CollectionSubscriber initial status check] alias=w
  {subscriptionStatus: 'ready'}                        ← Already 'ready'!

[DEBUG allCollectionsReady] id=live-query-1
  statuses: [{id: 'workflowRuns', status: 'loading', isReady: false}]

[DEBUG updateLiveQueryStatus] id=live-query-1
  {allCollectionsReady: false, isLoadingSubset: false, currentStatus: 'loading', willMarkReady: false}

run {haveData: false, state: Map(0), isReady: false}   ← Correct initial state

[DEBUG markReady] id=workflowRuns
  {currentStatus: 'loading'}                           ← Source collection becomes ready

[DEBUG allCollectionsReady] id=live-query-1
  statuses: [{id: 'workflowRuns', status: 'ready', isReady: true}]

[DEBUG updateLiveQueryStatus] id=live-query-1
  {allCollectionsReady: true, isLoadingSubset: false, currentStatus: 'loading', willMarkReady: true}
                                                       ↑ isLoadingSubset=false, so it marks ready!

[DEBUG markReady] id=live-query-1
  {currentStatus: 'loading'}                           ← Live query marked ready prematurely

run {haveData: false, state: Map(0), isReady: true}    ← BUG! isReady=true but no data

[DEBUG markReady] id=workflowRuns
  {currentStatus: 'ready'}

run {haveData: true, state: Map(1), isReady: true}     ← Data arrives later

[DEBUG trackLoadPromise.finally] id=workflowRuns
  {loadingEnding: true, pendingCount: 0}               ← Loading finishes after ready was set

Root Cause Analysis

The fix in this branch added a check for isLoadingSubset on the live query collection:

// collection-config-builder.ts
if (this.allCollectionsReady() && !this.liveQueryCollection?.isLoadingSubset) {
  markReady()
}

However, isLoadingSubset is always false on the live query collection because:

  1. trackLoadPromise is called on the source collection (workflowRuns), not the live query collection (live-query-1)

  2. The Electric subscription status is already 'ready' when CollectionSubscriber checks it at line 120-124:

    // collection-subscriber.ts
    if (subscription.status === `loadingSubset`) {
      trackLoadPromise()  // Never called because status is 'ready'
    }
  3. Therefore, trackLoadPromise() is never called on the live query collection

  4. The live query's isLoadingSubset remains false

  5. When the source collection becomes ready, the condition allCollectionsReady() && !isLoadingSubset is satisfied, and the live query is marked ready before its data is populated

Timeline

1. trackLoadPromise() called on workflowRuns (source) - starts loading
2. Electric subscription.status = 'ready' immediately
3. CollectionSubscriber checks status, sees 'ready', doesn't track load on live query
4. workflowRuns.markReady() - source collection ready
5. updateLiveQueryStatus() checks:
   - allCollectionsReady() = true (workflowRuns is ready)
   - isLoadingSubset = false (nothing tracked on live query)
   → Marks live-query-1 ready!
6. React renders: isReady=true, data=empty  ← BUG
7. Data is written to live query collection
8. React renders: isReady=true, data=populated
9. trackLoadPromise.finally() on workflowRuns - loading done

Potential Fix Directions

  1. Track loading on live query collection: When the source collection starts loading subset data, also track that loading on the live query collection that depends on it

  2. Investigate Electric subscription status: Why is subscription.status already 'ready' when data hasn't been loaded yet? This might be a bug in the Electric integration layer or Electric itself

  3. Use source collection's isLoadingSubset: Instead of (or in addition to) checking the live query's isLoadingSubset, check if any source collection has isLoadingSubset=true

Files Involved

  • packages/db/src/query/live/collection-config-builder.ts - updateLiveQueryStatus() and allCollectionsReady()
  • packages/db/src/query/live/collection-subscriber.ts - Electric subscription status tracking
  • packages/db/src/collection/sync.ts - trackLoadPromise() and isLoadingSubset
  • packages/db/src/collection/lifecycle.ts - markReady()

claude and others added 4 commits December 31, 2025 16:24
…ery's

The previous fix incorrectly checked isLoadingSubset on the live query
collection itself, but the loadSubset/trackLoadPromise mechanism runs on
SOURCE collections during on-demand sync, so the live query's isLoadingSubset
was always false.

This fix:
- Adds anySourceCollectionLoadingSubset() to check if any source collection
  has isLoadingSubset=true
- Listens for loadingSubset:change events on source collections instead of
  the live query collection
…rce collections

Reverts the change to check source collections' isLoadingSubset, which was causing
test timeouts in query-db-collection tests. The live query collection's isLoadingSubset
is correctly updated by CollectionSubscriber.trackLoadPromise() which tracks loading
on the live query collection itself.

Also updates changeset to accurately describe the fix.
@marbemac
Copy link
Contributor

That changes since my last comment fixed it! There are several adjustments in this PR, unsure which one(s) are the actual fixes, but something in the last couple of commits finally resolved it. Might want to figure out a test that fails before the last couple of commits, and succeeds with the changes from the last couple of commits.

claude and others added 2 commits December 31, 2025 17:51
…r snapshot trigger

The subscription's status:change listener was being registered AFTER the snapshot
was triggered (via requestSnapshot/requestLimitedSnapshot). This meant that if the
loadSubset promise resolved quickly (or synchronously), the status transition from
'loadingSubset' to 'ready' could be missed entirely.

Changes:
- Refactored subscribeToChanges() to split subscription creation from snapshot triggering
- subscribeToMatchingChanges() and subscribeToOrderedChanges() now return both the
  subscription AND a triggerSnapshot function
- The status listener is registered AFTER getting the subscription but BEFORE calling
  triggerSnapshot()
- Added deferSnapshot option to subscribeChanges() to prevent automatic snapshot request
- For non-ordered queries, continue using trackLoadSubsetPromise: false to maintain
  compatibility with query-db-collection's destroyed observer handling
- Updated test for source collection isLoadingSubset independence
- Added regression test for the race condition fix
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.

4 participants