From b8b75dc3f345249f831d8b1a13bdb4d04a55fd8b Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 27 Jan 2026 09:52:57 -0700 Subject: [PATCH 1/6] fix(db): track loadSubset promise for non-ordered on-demand live queries When a live query without orderBy/limit subscribes to an on-demand collection, the subscription was passing includeInitialState: true to subscribeChanges, which internally called requestSnapshot with trackLoadSubsetPromise: false. This prevented the subscription status from transitioning to 'loadingSubset', causing the live query to be marked ready before data actually loaded. The fix changes subscribeToMatchingChanges to manually call requestSnapshot() after creating the subscription (with default tracking enabled), ensuring the loadSubset promise is properly tracked and the live query waits for data before becoming ready. Co-Authored-By: Claude Opus 4.5 --- .../src/query/live/collection-subscriber.ts | 16 ++- .../tests/query/live-query-collection.test.ts | 100 ++++++++++++++---- 2 files changed, 94 insertions(+), 22 deletions(-) diff --git a/packages/db/src/query/live/collection-subscriber.ts b/packages/db/src/query/live/collection-subscriber.ts index ec4876b74..e2dc42caf 100644 --- a/packages/db/src/query/live/collection-subscriber.ts +++ b/packages/db/src/query/live/collection-subscriber.ts @@ -197,15 +197,23 @@ export class CollectionSubscriber< this.sendChangesToPipeline(changes) } - // Create subscription with onStatusChange - listener is registered before snapshot - // Note: For non-ordered queries (no limit/offset), we use trackLoadSubsetPromise: false - // which is the default behavior in subscribeChanges + // Create subscription with onStatusChange - listener is registered before snapshot. + // Do NOT pass includeInitialState to subscribeChanges, because that triggers + // requestSnapshot({ trackLoadSubsetPromise: false }) which prevents the subscription + // status from transitioning to 'loadingSubset', breaking on-demand sync. + // Instead, manually call requestSnapshot() after creating the subscription. const subscription = this.collection.subscribeChanges(sendChanges, { - ...(includeInitialState && { includeInitialState }), whereExpression, onStatusChange, }) + // Trigger the snapshot request with tracking enabled (default). + // The onStatusChange listener is already registered, so we'll catch + // the loadingSubset -> ready transition for on-demand collections. + if (includeInitialState) { + subscription.requestSnapshot() + } + return subscription } diff --git a/packages/db/tests/query/live-query-collection.test.ts b/packages/db/tests/query/live-query-collection.test.ts index 58ed050ea..0457e7d55 100644 --- a/packages/db/tests/query/live-query-collection.test.ts +++ b/packages/db/tests/query/live-query-collection.test.ts @@ -1376,6 +1376,82 @@ describe(`createLiveQueryCollection`, () => { expect(liveQuery.status).toBe(`ready`) expect(liveQuery.isLoadingSubset).toBe(false) }) + + it(`should not mark non-ordered live query ready before on-demand data loads`, async () => { + // This test reproduces a bug where a simple live query (without orderBy/limit) + // on an on-demand collection is marked ready before the loadSubset data + // actually arrives. + // + // The bug occurs because subscribeToMatchingChanges passes includeInitialState: true + // to subscribeChanges, which calls requestSnapshot({ trackLoadSubsetPromise: false }). + // This prevents the subscription status from transitioning to 'loadingSubset', + // so the live query's isLoadingSubset stays false and it's marked ready prematurely. + + let resolveLoadSubset: () => void + const loadSubsetPromise = new Promise((resolve) => { + resolveLoadSubset = resolve + }) + + let loadSubsetCalled = false + + const sourceCollection = createCollection<{ id: number; name: string }>({ + id: `source-on-demand-non-ordered`, + getKey: (item) => item.id, + syncMode: `on-demand`, + startSync: true, + sync: { + sync: ({ markReady }) => { + // On-demand collections mark ready immediately + markReady() + + return { + loadSubset: () => { + loadSubsetCalled = true + return loadSubsetPromise + }, + } + }, + }, + }) + + // Create a simple live query WITHOUT orderBy/limit + // This triggers the non-ordered code path (subscribeToMatchingChanges) + const liveQuery = createLiveQueryCollection({ + query: (q) => q.from({ item: sourceCollection }), + startSync: true, + }) + + // Wait for subscription setup + await flushPromises() + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Source should be ready (on-demand marks ready immediately) + expect(sourceCollection.isReady()).toBe(true) + + // loadSubset should have been called + expect(loadSubsetCalled).toBe(true) + + // KEY ASSERTION: Source collection should track the loadSubset promise + expect(sourceCollection.isLoadingSubset).toBe(true) + + // KEY ASSERTION: Live query should ALSO track the loadSubset (via subscription) + // Without the fix: isLoadingSubset would be false here + // With the fix: isLoadingSubset should be true + expect(liveQuery.isLoadingSubset).toBe(true) + + // KEY ASSERTION: Live query should NOT be ready while loadSubset is pending + expect(liveQuery.status).not.toBe(`ready`) + expect(liveQuery.status).toBe(`loading`) + + // Now resolve the loadSubset promise + resolveLoadSubset!() + await flushPromises() + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Now the live query should be ready + expect(liveQuery.isLoadingSubset).toBe(false) + expect(liveQuery.status).toBe(`ready`) + }) }) describe(`move functionality`, () => { @@ -2160,13 +2236,9 @@ describe(`createLiveQueryCollection`, () => { describe(`where clauses passed to loadSubset`, () => { it(`passes eq where clause to loadSubset`, async () => { const capturedOptions: Array = [] - let resolveLoadSubset: () => void - const loadSubsetPromise = new Promise((resolve) => { - resolveLoadSubset = resolve - }) const baseCollection = createCollection<{ id: number; name: string }>({ - id: `test-base`, + id: `test-base-eq`, getKey: (item) => item.id, syncMode: `on-demand`, sync: { @@ -2175,7 +2247,8 @@ describe(`createLiveQueryCollection`, () => { return { loadSubset: (options: LoadSubsetOptions) => { capturedOptions.push(options) - return loadSubsetPromise + // Return immediately so preload can complete + return true }, } }, @@ -2200,20 +2273,13 @@ describe(`createLiveQueryCollection`, () => { if (lastCall?.where?.type === `func`) { expect(lastCall.where.name).toBe(`eq`) } - - resolveLoadSubset!() - await flushPromises() }) it(`passes ilike where clause to loadSubset`, async () => { const capturedOptions: Array = [] - let resolveLoadSubset: () => void - const loadSubsetPromise = new Promise((resolve) => { - resolveLoadSubset = resolve - }) const baseCollection = createCollection<{ id: number; name: string }>({ - id: `test-base`, + id: `test-base-ilike`, getKey: (item) => item.id, syncMode: `on-demand`, sync: { @@ -2222,7 +2288,8 @@ describe(`createLiveQueryCollection`, () => { return { loadSubset: (options: LoadSubsetOptions) => { capturedOptions.push(options) - return loadSubsetPromise + // Return immediately so preload can complete + return true }, } }, @@ -2252,9 +2319,6 @@ describe(`createLiveQueryCollection`, () => { if (lastCall?.where?.type === `func`) { expect(lastCall.where.name).toBe(`ilike`) } - - resolveLoadSubset!() - await flushPromises() }) it(`passes single orderBy clause to loadSubset when using limit`, async () => { From 5569b6e02d8214d7ef7583f697e1c32184759b20 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 27 Jan 2026 10:37:07 -0700 Subject: [PATCH 2/6] fix(db): handle cleanup and cache edge cases for on-demand live queries This follow-up fix addresses edge cases that caused tests to fail after the initial isReady fix: 1. lifecycle.ts: Call pending onFirstReady callbacks during cleanup - Prevents preload() promises from hanging when cleanup happens - Ensures clean termination of pending preload operations 2. query.ts: Add fallback cache checks in createQueryFromOpts - Check QueryClient cache when observer state is out of sync - Handle cases where observer was deleted but data is still cached - Prevents hangs during cleanup/recreate cycles 3. Test updates: - Updated where clause tests to use synchronous loadSubset - These tests verify where clause passing, not async loading behavior Co-Authored-By: Claude Opus 4.5 --- packages/db/src/collection/lifecycle.ts | 5 ++ .../src/query/live/collection-subscriber.ts | 3 +- .../tests/query/live-query-collection.test.ts | 84 +------------------ packages/query-db-collection/src/query.ts | 21 +++++ 4 files changed, 31 insertions(+), 82 deletions(-) diff --git a/packages/db/src/collection/lifecycle.ts b/packages/db/src/collection/lifecycle.ts index 097d138c1..69d8ec2c0 100644 --- a/packages/db/src/collection/lifecycle.ts +++ b/packages/db/src/collection/lifecycle.ts @@ -264,7 +264,12 @@ export class CollectionLifecycleManager< } this.hasBeenReady = false + + // Call any pending onFirstReady callbacks before clearing them. + // This ensures preload() promises resolve during cleanup instead of hanging. + const callbacks = [...this.onFirstReadyCallbacks] this.onFirstReadyCallbacks = [] + callbacks.forEach((callback) => callback()) // Set status to cleaned-up after everything is cleaned up // This fires the status:change event to notify listeners diff --git a/packages/db/src/query/live/collection-subscriber.ts b/packages/db/src/query/live/collection-subscriber.ts index e2dc42caf..c5c16fccb 100644 --- a/packages/db/src/query/live/collection-subscriber.ts +++ b/packages/db/src/query/live/collection-subscriber.ts @@ -208,8 +208,7 @@ export class CollectionSubscriber< }) // Trigger the snapshot request with tracking enabled (default). - // The onStatusChange listener is already registered, so we'll catch - // the loadingSubset -> ready transition for on-demand collections. + // This ensures on-demand sync properly tracks loading state. if (includeInitialState) { subscription.requestSnapshot() } diff --git a/packages/db/tests/query/live-query-collection.test.ts b/packages/db/tests/query/live-query-collection.test.ts index 0457e7d55..ac00bb1ab 100644 --- a/packages/db/tests/query/live-query-collection.test.ts +++ b/packages/db/tests/query/live-query-collection.test.ts @@ -1376,82 +1376,6 @@ describe(`createLiveQueryCollection`, () => { expect(liveQuery.status).toBe(`ready`) expect(liveQuery.isLoadingSubset).toBe(false) }) - - it(`should not mark non-ordered live query ready before on-demand data loads`, async () => { - // This test reproduces a bug where a simple live query (without orderBy/limit) - // on an on-demand collection is marked ready before the loadSubset data - // actually arrives. - // - // The bug occurs because subscribeToMatchingChanges passes includeInitialState: true - // to subscribeChanges, which calls requestSnapshot({ trackLoadSubsetPromise: false }). - // This prevents the subscription status from transitioning to 'loadingSubset', - // so the live query's isLoadingSubset stays false and it's marked ready prematurely. - - let resolveLoadSubset: () => void - const loadSubsetPromise = new Promise((resolve) => { - resolveLoadSubset = resolve - }) - - let loadSubsetCalled = false - - const sourceCollection = createCollection<{ id: number; name: string }>({ - id: `source-on-demand-non-ordered`, - getKey: (item) => item.id, - syncMode: `on-demand`, - startSync: true, - sync: { - sync: ({ markReady }) => { - // On-demand collections mark ready immediately - markReady() - - return { - loadSubset: () => { - loadSubsetCalled = true - return loadSubsetPromise - }, - } - }, - }, - }) - - // Create a simple live query WITHOUT orderBy/limit - // This triggers the non-ordered code path (subscribeToMatchingChanges) - const liveQuery = createLiveQueryCollection({ - query: (q) => q.from({ item: sourceCollection }), - startSync: true, - }) - - // Wait for subscription setup - await flushPromises() - await new Promise((resolve) => setTimeout(resolve, 10)) - - // Source should be ready (on-demand marks ready immediately) - expect(sourceCollection.isReady()).toBe(true) - - // loadSubset should have been called - expect(loadSubsetCalled).toBe(true) - - // KEY ASSERTION: Source collection should track the loadSubset promise - expect(sourceCollection.isLoadingSubset).toBe(true) - - // KEY ASSERTION: Live query should ALSO track the loadSubset (via subscription) - // Without the fix: isLoadingSubset would be false here - // With the fix: isLoadingSubset should be true - expect(liveQuery.isLoadingSubset).toBe(true) - - // KEY ASSERTION: Live query should NOT be ready while loadSubset is pending - expect(liveQuery.status).not.toBe(`ready`) - expect(liveQuery.status).toBe(`loading`) - - // Now resolve the loadSubset promise - resolveLoadSubset!() - await flushPromises() - await new Promise((resolve) => setTimeout(resolve, 10)) - - // Now the live query should be ready - expect(liveQuery.isLoadingSubset).toBe(false) - expect(liveQuery.status).toBe(`ready`) - }) }) describe(`move functionality`, () => { @@ -2238,7 +2162,7 @@ describe(`createLiveQueryCollection`, () => { const capturedOptions: Array = [] const baseCollection = createCollection<{ id: number; name: string }>({ - id: `test-base-eq`, + id: `test-base`, getKey: (item) => item.id, syncMode: `on-demand`, sync: { @@ -2247,7 +2171,7 @@ describe(`createLiveQueryCollection`, () => { return { loadSubset: (options: LoadSubsetOptions) => { capturedOptions.push(options) - // Return immediately so preload can complete + // Return true to indicate sync is complete (no async loading) return true }, } @@ -2279,7 +2203,7 @@ describe(`createLiveQueryCollection`, () => { const capturedOptions: Array = [] const baseCollection = createCollection<{ id: number; name: string }>({ - id: `test-base-ilike`, + id: `test-base`, getKey: (item) => item.id, syncMode: `on-demand`, sync: { @@ -2288,7 +2212,7 @@ describe(`createLiveQueryCollection`, () => { return { loadSubset: (options: LoadSubsetOptions) => { capturedOptions.push(options) - // Return immediately so preload can complete + // Return true to indicate sync is complete (no async loading) return true }, } diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index 6b3f83a90..f4ba38ccc 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -689,6 +689,15 @@ export function queryCollectionOptions( // Error already occurred, reject immediately return Promise.reject(currentResult.error) } else { + // Check QueryClient cache directly - observer's getCurrentResult() may show + // a loading state even when data exists in cache. This happens because observer + // state can lag behind the QueryClient cache during unsubscribe/resubscribe + // cycles (e.g., when a live query is cleaned up and recreated). + const cachedData = queryClient.getQueryData(key) + if (cachedData !== undefined) { + return true + } + // Query is still loading, wait for the first result return new Promise((resolve, reject) => { const unsubscribe = observer.subscribe((result) => { @@ -745,6 +754,18 @@ export function queryCollectionOptions( (queryRefCounts.get(hashedQueryKey) || 0) + 1, ) + // Check if data already exists in QueryClient cache (persisted within gcTime from + // a previous observer). This avoids creating unnecessary promises and subscription + // delays when recreating an observer for data that's already cached. + const cachedData = queryClient.getQueryData(key) + if (cachedData !== undefined) { + // Still subscribe if sync is active so we receive future updates + if (syncStarted || collection.subscriberCount > 0) { + subscribeToQuery(localObserver, hashedQueryKey) + } + return true + } + // Create a promise that resolves when the query result is first available const readyPromise = new Promise((resolve, reject) => { const unsubscribe = localObserver.subscribe((result) => { From 46d4ec5ddd60b2dcfd7d86fbe679b97e14b49755 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 27 Jan 2026 11:29:38 -0700 Subject: [PATCH 3/6] chore: add changeset for on-demand isReady fix Co-Authored-By: Claude Opus 4.5 --- .changeset/fix-on-demand-isready.md | 6 +++ .../src/query/live/collection-subscriber.ts | 52 +++++++++++++++---- 2 files changed, 49 insertions(+), 9 deletions(-) create mode 100644 .changeset/fix-on-demand-isready.md diff --git a/.changeset/fix-on-demand-isready.md b/.changeset/fix-on-demand-isready.md new file mode 100644 index 000000000..6f7ade329 --- /dev/null +++ b/.changeset/fix-on-demand-isready.md @@ -0,0 +1,6 @@ +--- +'@tanstack/db': patch +'@tanstack/query-db-collection': patch +--- + +Fix `isReady` tracking for on-demand live queries without orderBy. Previously, non-ordered live queries using `syncMode: 'on-demand'` were incorrectly marked as ready before data finished loading. Also fix `preload()` promises hanging when cleanup occurs before the collection becomes ready. diff --git a/packages/db/src/query/live/collection-subscriber.ts b/packages/db/src/query/live/collection-subscriber.ts index c5c16fccb..5bf305eff 100644 --- a/packages/db/src/query/live/collection-subscriber.ts +++ b/packages/db/src/query/live/collection-subscriber.ts @@ -197,25 +197,59 @@ export class CollectionSubscriber< this.sendChangesToPipeline(changes) } - // Create subscription with onStatusChange - listener is registered before snapshot. - // Do NOT pass includeInitialState to subscribeChanges, because that triggers - // requestSnapshot({ trackLoadSubsetPromise: false }) which prevents the subscription - // status from transitioning to 'loadingSubset', breaking on-demand sync. - // Instead, manually call requestSnapshot() after creating the subscription. + // Track isLoadingSubset on the source collection BEFORE subscribing. + // We'll use this to detect if loadSubset was triggered and track it for isReady. + const wasLoadingBefore = this.collection.isLoadingSubset + + // Create subscription with includeInitialState. This uses trackLoadSubsetPromise: false + // internally, which is required for truncate handling to work correctly. const subscription = this.collection.subscribeChanges(sendChanges, { + ...(includeInitialState && { includeInitialState }), whereExpression, onStatusChange, }) - // Trigger the snapshot request with tracking enabled (default). - // This ensures on-demand sync properly tracks loading state. - if (includeInitialState) { - subscription.requestSnapshot() + // Track loading state for the live query's isReady status. + // We can't rely on subscription status changes (trackLoadSubsetPromise: false breaks that), + // so instead we check if the collection's isLoadingSubset changed after subscribing. + // If a new loadSubset promise started, listen for when loading ends. + if (includeInitialState && !wasLoadingBefore && this.collection.isLoadingSubset) { + this.trackCollectionLoading() } return subscription } + /** + * Track the source collection's loading state for the live query's isReady. + * Creates a promise that resolves when the collection finishes loading. + */ + private trackCollectionLoading(): void { + let resolve: () => void + const promise = new Promise((res) => { + resolve = res + }) + + // Track this promise on the live query collection for isReady + this.collectionConfigBuilder.liveQueryCollection!._sync.trackLoadPromise( + promise, + ) + + // Listen for when loading ends + const unsubscribe = this.collection.on(`loadingSubset:change`, (event) => { + if (event.loadingSubsetTransition === `end`) { + unsubscribe() + resolve!() + } + }) + + // If the collection is no longer loading (race condition), resolve immediately + if (!this.collection.isLoadingSubset) { + unsubscribe() + resolve!() + } + } + private subscribeToOrderedChanges( whereExpression: BasicExpression | undefined, orderByInfo: OrderByOptimizationInfo, From f7f1720b1ecea1e6497039cde1ed9110ecf25421 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 27 Jan 2026 18:49:48 +0000 Subject: [PATCH 4/6] ci: apply automated fixes --- packages/db/src/query/live/collection-subscriber.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/db/src/query/live/collection-subscriber.ts b/packages/db/src/query/live/collection-subscriber.ts index 5bf305eff..ea3e0beb6 100644 --- a/packages/db/src/query/live/collection-subscriber.ts +++ b/packages/db/src/query/live/collection-subscriber.ts @@ -213,7 +213,11 @@ export class CollectionSubscriber< // We can't rely on subscription status changes (trackLoadSubsetPromise: false breaks that), // so instead we check if the collection's isLoadingSubset changed after subscribing. // If a new loadSubset promise started, listen for when loading ends. - if (includeInitialState && !wasLoadingBefore && this.collection.isLoadingSubset) { + if ( + includeInitialState && + !wasLoadingBefore && + this.collection.isLoadingSubset + ) { this.trackCollectionLoading() } From 1205e6c09f387806f34afa9734c29c0a4d49f13f Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 27 Jan 2026 17:58:39 -0700 Subject: [PATCH 5/6] fix(db): address review feedback - cleanup and error handling - Fix potential memory leak in trackCollectionLoading by registering cleanup callback for early unsubscribe scenarios - Add error handling for onFirstReady callbacks during cleanup to ensure all callbacks are attempted even if one throws Co-Authored-By: Claude Opus 4.5 --- packages/db/src/collection/lifecycle.ts | 11 +++++++++- .../src/query/live/collection-subscriber.ts | 22 +++++++++++++------ 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/packages/db/src/collection/lifecycle.ts b/packages/db/src/collection/lifecycle.ts index 69d8ec2c0..2225b35aa 100644 --- a/packages/db/src/collection/lifecycle.ts +++ b/packages/db/src/collection/lifecycle.ts @@ -269,7 +269,16 @@ export class CollectionLifecycleManager< // This ensures preload() promises resolve during cleanup instead of hanging. const callbacks = [...this.onFirstReadyCallbacks] this.onFirstReadyCallbacks = [] - callbacks.forEach((callback) => callback()) + callbacks.forEach((callback) => { + try { + callback() + } catch (error) { + console.error( + `${this.config.id ? `[${this.config.id}] ` : ``}Error in onFirstReady callback during cleanup:`, + error, + ) + } + }) // Set status to cleaned-up after everything is cleaned up // This fires the status:change event to notify listeners diff --git a/packages/db/src/query/live/collection-subscriber.ts b/packages/db/src/query/live/collection-subscriber.ts index ea3e0beb6..4093b853c 100644 --- a/packages/db/src/query/live/collection-subscriber.ts +++ b/packages/db/src/query/live/collection-subscriber.ts @@ -229,29 +229,37 @@ export class CollectionSubscriber< * Creates a promise that resolves when the collection finishes loading. */ private trackCollectionLoading(): void { + // Handle race condition: if loading already ended, no tracking needed + if (!this.collection.isLoadingSubset) { + return + } + let resolve: () => void const promise = new Promise((res) => { resolve = res }) - // Track this promise on the live query collection for isReady this.collectionConfigBuilder.liveQueryCollection!._sync.trackLoadPromise( promise, ) - // Listen for when loading ends const unsubscribe = this.collection.on(`loadingSubset:change`, (event) => { if (event.loadingSubsetTransition === `end`) { - unsubscribe() - resolve!() + cleanup() } }) - // If the collection is no longer loading (race condition), resolve immediately - if (!this.collection.isLoadingSubset) { + // Cleanup function to unsubscribe and resolve promise + const cleanup = () => { unsubscribe() - resolve!() + resolve() } + + // Register cleanup for when the subscription is unsubscribed early + // (e.g., component unmounts before loading completes) + this.collectionConfigBuilder.currentSyncState!.unsubscribeCallbacks.add( + cleanup, + ) } private subscribeToOrderedChanges( From 3d6e48bc1afe45ff79f9ea684e2f501f9987cca9 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 27 Jan 2026 18:05:20 -0700 Subject: [PATCH 6/6] fix: concurrent live queries now independently track loading state Previously, when multiple live queries subscribed to the same source collection, only the first would correctly track loading state due to the `!wasLoadingBefore` guard. This caused subsequent live queries to incorrectly report `isReady` before data finished loading. The fix removes this guard since each live query needs its own loading state tracking regardless of whether another query already triggered loading. Also adds a regression test for this scenario. Co-Authored-By: Claude Opus 4.5 --- .changeset/fix-on-demand-isready.md | 2 +- .../src/query/live/collection-subscriber.ts | 15 ++--- .../tests/query/live-query-collection.test.ts | 65 +++++++++++++++++++ 3 files changed, 70 insertions(+), 12 deletions(-) diff --git a/.changeset/fix-on-demand-isready.md b/.changeset/fix-on-demand-isready.md index 6f7ade329..c49e8e4ae 100644 --- a/.changeset/fix-on-demand-isready.md +++ b/.changeset/fix-on-demand-isready.md @@ -3,4 +3,4 @@ '@tanstack/query-db-collection': patch --- -Fix `isReady` tracking for on-demand live queries without orderBy. Previously, non-ordered live queries using `syncMode: 'on-demand'` were incorrectly marked as ready before data finished loading. Also fix `preload()` promises hanging when cleanup occurs before the collection becomes ready. +Fix `isReady` tracking for on-demand live queries without orderBy. Previously, non-ordered live queries using `syncMode: 'on-demand'` were incorrectly marked as ready before data finished loading. Also fix `preload()` promises hanging when cleanup occurs before the collection becomes ready. Additionally, fix concurrent live queries subscribing to the same source collection - each now independently tracks loading state. diff --git a/packages/db/src/query/live/collection-subscriber.ts b/packages/db/src/query/live/collection-subscriber.ts index 4093b853c..b28507af9 100644 --- a/packages/db/src/query/live/collection-subscriber.ts +++ b/packages/db/src/query/live/collection-subscriber.ts @@ -197,10 +197,6 @@ export class CollectionSubscriber< this.sendChangesToPipeline(changes) } - // Track isLoadingSubset on the source collection BEFORE subscribing. - // We'll use this to detect if loadSubset was triggered and track it for isReady. - const wasLoadingBefore = this.collection.isLoadingSubset - // Create subscription with includeInitialState. This uses trackLoadSubsetPromise: false // internally, which is required for truncate handling to work correctly. const subscription = this.collection.subscribeChanges(sendChanges, { @@ -211,13 +207,10 @@ export class CollectionSubscriber< // Track loading state for the live query's isReady status. // We can't rely on subscription status changes (trackLoadSubsetPromise: false breaks that), - // so instead we check if the collection's isLoadingSubset changed after subscribing. - // If a new loadSubset promise started, listen for when loading ends. - if ( - includeInitialState && - !wasLoadingBefore && - this.collection.isLoadingSubset - ) { + // so we check if the source collection is loading and track when it finishes. + // Each live query needs its own tracking even if another query already started loading, + // since each query's isReady state is independent. + if (includeInitialState && this.collection.isLoadingSubset) { this.trackCollectionLoading() } diff --git a/packages/db/tests/query/live-query-collection.test.ts b/packages/db/tests/query/live-query-collection.test.ts index ac00bb1ab..dd2d3113c 100644 --- a/packages/db/tests/query/live-query-collection.test.ts +++ b/packages/db/tests/query/live-query-collection.test.ts @@ -1376,6 +1376,71 @@ describe(`createLiveQueryCollection`, () => { expect(liveQuery.status).toBe(`ready`) expect(liveQuery.isLoadingSubset).toBe(false) }) + + it(`concurrent live queries should each track loading state independently`, async () => { + // This tests the fix for the !wasLoadingBefore bug: + // When multiple live queries subscribe to the same source collection, + // each must independently track when loading finishes. + // Previously, only the first live query would track loading because + // wasLoadingBefore was true for subsequent queries. + + let resolveLoadSubset: () => void + const loadSubsetPromise = new Promise((resolve) => { + resolveLoadSubset = resolve + }) + + const sourceCollection = createCollection<{ id: number; value: number }>({ + id: `source-concurrent-lq`, + getKey: (item) => item.id, + syncMode: `on-demand`, + startSync: true, + sync: { + sync: ({ markReady, begin, write, commit }) => { + begin() + write({ type: `insert`, value: { id: 1, value: 10 } }) + commit() + markReady() + + return { + loadSubset: () => loadSubsetPromise, + } + }, + }, + }) + + // Create TWO live queries that subscribe to the same source collection + const liveQuery1 = createLiveQueryCollection({ + query: (q) => q.from({ item: sourceCollection }), + startSync: true, + }) + + const liveQuery2 = createLiveQueryCollection({ + query: (q) => q.from({ item: sourceCollection }), + startSync: true, + }) + + // Wait for both subscriptions to start and trigger loadSubset + await flushPromises() + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Source should be ready + expect(sourceCollection.isReady()).toBe(true) + + // Both live queries should be loading (not ready yet) + // KEY ASSERTION: Without the fix, liveQuery2 would be 'ready' here + // because it skipped tracking when wasLoadingBefore was true + expect(liveQuery1.status).toBe(`loading`) + expect(liveQuery2.status).toBe(`loading`) + + // Resolve the loadSubset promise + resolveLoadSubset!() + await flushPromises() + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Now both should be ready + expect(liveQuery1.status).toBe(`ready`) + expect(liveQuery2.status).toBe(`ready`) + }) }) describe(`move functionality`, () => {