From b3deae35bdbd3bdf72e9f869205d2f7ffd15f3dc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Dec 2025 20:13:43 +0000 Subject: [PATCH 01/20] fix(react-db): prevent suspense from releasing before data is loaded 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/fix-on-demand-suspense.md | 9 ++++++ packages/react-db/src/useLiveSuspenseQuery.ts | 32 +++++++++++++++++-- 2 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 .changeset/fix-on-demand-suspense.md diff --git a/.changeset/fix-on-demand-suspense.md b/.changeset/fix-on-demand-suspense.md new file mode 100644 index 000000000..23a49fe35 --- /dev/null +++ b/.changeset/fix-on-demand-suspense.md @@ -0,0 +1,9 @@ +--- +'@tanstack/react-db': patch +--- + +Fix `useLiveSuspenseQuery` releasing suspense before data is loaded 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 yet. + +The fix ensures that `useLiveSuspenseQuery` also suspends while `isLoadingSubset` is true, waiting for the initial subset load to complete before releasing the suspense boundary. diff --git a/packages/react-db/src/useLiveSuspenseQuery.ts b/packages/react-db/src/useLiveSuspenseQuery.ts index 326bc131a..95dd6ebde 100644 --- a/packages/react-db/src/useLiveSuspenseQuery.ts +++ b/packages/react-db/src/useLiveSuspenseQuery.ts @@ -124,6 +124,7 @@ export function useLiveSuspenseQuery( deps: Array = [], ) { const promiseRef = useRef | null>(null) + const loadingSubsetPromiseRef = useRef | null>(null) const collectionRef = useRef | null>(null) const hasBeenReadyRef = useRef(false) @@ -133,14 +134,16 @@ export function useLiveSuspenseQuery( // Reset promise and ready state when collection changes (deps changed) if (collectionRef.current !== result.collection) { promiseRef.current = null + loadingSubsetPromiseRef.current = null collectionRef.current = result.collection hasBeenReadyRef.current = false } - // Track when we reach ready state - if (result.status === `ready`) { + // Track when we reach ready state AND finished loading subset + if (result.status === `ready` && !result.collection.isLoadingSubset) { hasBeenReadyRef.current = true promiseRef.current = null + loadingSubsetPromiseRef.current = null } // SUSPENSE LOGIC: Throw promise or error based on collection status @@ -156,6 +159,7 @@ export function useLiveSuspenseQuery( // After success, errors surface as stale data (matches TanStack Query behavior) if (result.status === `error` && !hasBeenReadyRef.current) { promiseRef.current = null + loadingSubsetPromiseRef.current = null // TODO: Once collections hold a reference to their last error object (#671), // we should rethrow that actual error instead of creating a generic message throw new Error(`Collection "${result.collection.id}" failed to load`) @@ -172,6 +176,30 @@ export function useLiveSuspenseQuery( throw promiseRef.current } + // Also suspend while loading subset data in on-demand mode + // This prevents suspense from releasing before the query's data is loaded + if (result.collection.isLoadingSubset && !hasBeenReadyRef.current) { + if (!loadingSubsetPromiseRef.current) { + loadingSubsetPromiseRef.current = new Promise((resolve) => { + // Check if already done loading (race condition guard) + if (!result.collection.isLoadingSubset) { + resolve() + return + } + const unsubscribe = result.collection.on( + `loadingSubset:change`, + (event) => { + if (!event.isLoadingSubset) { + unsubscribe() + resolve() + } + }, + ) + }) + } + throw loadingSubsetPromiseRef.current + } + // Return data without status/loading flags (handled by Suspense/ErrorBoundary) // If error after success, return last known good state (stale data) return { From b9e8eeb4dcf2e364fc8e4fd98dc9bec2d26098ab Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Dec 2025 21:48:45 +0000 Subject: [PATCH 02/20] test(react-db): add test for isLoadingSubset suspense behavior 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) --- .../tests/useLiveSuspenseQuery.test.tsx | 169 ++++++++++++++++++ 1 file changed, 169 insertions(+) diff --git a/packages/react-db/tests/useLiveSuspenseQuery.test.tsx b/packages/react-db/tests/useLiveSuspenseQuery.test.tsx index 16376306f..a0c1e1406 100644 --- a/packages/react-db/tests/useLiveSuspenseQuery.test.tsx +++ b/packages/react-db/tests/useLiveSuspenseQuery.test.tsx @@ -602,4 +602,173 @@ describe(`useLiveSuspenseQuery`, () => { name: `John Doe`, }) }) + + it(`should not re-suspend after hasBeenReady when isLoadingSubset changes`, async () => { + // This test verifies that after the initial ready state is reached, + // subsequent isLoadingSubset changes don't cause re-suspension + // (stale-while-revalidate behavior, matching TanStack Query) + + const collection = createCollection( + mockSyncCollectionOptions({ + id: `test-persons-suspense-on-demand`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }), + ) + + let suspenseCount = 0 + + const SuspenseTracker = ({ children }: { children: ReactNode }) => { + return ( + + {(() => { + suspenseCount++ + return `Loading...` + })()} + + } + > + {children} + + ) + } + + const { result } = renderHook( + () => { + return useLiveSuspenseQuery((q) => q.from({ persons: collection })) + }, + { + wrapper: SuspenseTracker, + }, + ) + + // Wait for initial load + await waitFor(() => { + expect(result.current.data).toHaveLength(3) + }) + + const initialSuspenseCount = suspenseCount + + // Now simulate on-demand loading by tracking a load promise on the live query collection + // This mimics what happens when a new subset query is made in on-demand mode + let resolveLoadPromise: () => void + const loadPromise = new Promise((resolve) => { + resolveLoadPromise = resolve + }) + + // Track the load promise on the LIVE QUERY collection - this sets isLoadingSubset = true + result.current.collection._sync.trackLoadPromise(loadPromise) + + // Verify isLoadingSubset is now true on the live query collection + expect(result.current.collection.isLoadingSubset).toBe(true) + + // The collection is still ready, but isLoadingSubset is true + expect(result.current.collection.status).toBe(`ready`) + + // Resolve the load promise to simulate data loading complete + resolveLoadPromise!() + + // Wait for the loadingSubset:change event to propagate + await waitFor(() => { + expect(result.current.collection.isLoadingSubset).toBe(false) + }) + + // After hasBeenReadyRef is set, subsequent isLoadingSubset changes + // should NOT cause re-suspension (stale-while-revalidate behavior) + expect(suspenseCount).toBe(initialSuspenseCount) + + // Data should still be available + expect(result.current.data).toHaveLength(3) + }) + + it(`should hold suspense until isLoadingSubset is false during initial load`, async () => { + // This test specifically verifies the bug fix: + // When the LIVE QUERY collection's status becomes 'ready' but its isLoadingSubset + // is still true, suspense should NOT release until isLoadingSubset becomes false. + // + // We test this by creating a pre-made live query collection and tracking a load + // promise on it BEFORE passing it to useLiveSuspenseQuery. + + let resolveLoadPromise: () => void + const loadPromise = new Promise((resolve) => { + resolveLoadPromise = resolve + }) + + const sourceCollection = createCollection( + mockSyncCollectionOptions({ + id: `test-persons-suspense-loading-subset-source`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }), + ) + + // Create a live query collection manually + const liveQueryCollection = createLiveQueryCollection({ + query: (q) => q.from({ persons: sourceCollection }), + startSync: true, + }) + + // Wait for the live query to be ready + await liveQueryCollection.preload() + expect(liveQueryCollection.status).toBe(`ready`) + + // Now track a load promise to simulate on-demand loading starting + // This sets isLoadingSubset=true AFTER the collection is ready + liveQueryCollection._sync.trackLoadPromise(loadPromise) + expect(liveQueryCollection.isLoadingSubset).toBe(true) + + let suspenseReleased = false + let dataWhenSuspenseReleased: Array | undefined + + const SuspenseTracker = ({ children }: { children: ReactNode }) => { + return ( + Loading...}>{children} + ) + } + + const { result } = renderHook( + () => { + const queryResult = useLiveSuspenseQuery(liveQueryCollection) + + // If we get here without throwing, suspense has released + suspenseReleased = true + dataWhenSuspenseReleased = queryResult.data + return queryResult + }, + { + wrapper: SuspenseTracker, + }, + ) + + // Give React time to process - suspense should still be active + // because isLoadingSubset is true on the live query collection + await new Promise((r) => setTimeout(r, 50)) + + // Verify state: ready but still loading subset + expect(liveQueryCollection.status).toBe(`ready`) + expect(liveQueryCollection.isLoadingSubset).toBe(true) + + // KEY ASSERTION: Suspense should NOT have released yet + // This is the bug we're fixing - without the fix, suspense would release here + expect(suspenseReleased).toBe(false) + + // Now resolve the load promise + resolveLoadPromise!() + + // Wait for suspense to release + await waitFor(() => { + expect(suspenseReleased).toBe(true) + }) + + // Verify data is available when suspense releases + expect(dataWhenSuspenseReleased).toHaveLength(3) + + // Wait for React to finish rendering + await waitFor(() => { + expect(result.current).not.toBeNull() + }) + expect(result.current.data).toHaveLength(3) + }) }) From 4ac4e035f3a99da4e7a56c975dd394f81a70d42a Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 30 Dec 2025 21:49:53 +0000 Subject: [PATCH 03/20] ci: apply automated fixes --- packages/react-db/tests/useLiveSuspenseQuery.test.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/react-db/tests/useLiveSuspenseQuery.test.tsx b/packages/react-db/tests/useLiveSuspenseQuery.test.tsx index a0c1e1406..997ff9b2a 100644 --- a/packages/react-db/tests/useLiveSuspenseQuery.test.tsx +++ b/packages/react-db/tests/useLiveSuspenseQuery.test.tsx @@ -723,9 +723,7 @@ describe(`useLiveSuspenseQuery`, () => { let dataWhenSuspenseReleased: Array | undefined const SuspenseTracker = ({ children }: { children: ReactNode }) => { - return ( - Loading...}>{children} - ) + return Loading...}>{children} } const { result } = renderHook( From e630acd5ab640d219c37aeae2dd4abd3f188dc8c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Dec 2025 23:30:41 +0000 Subject: [PATCH 04/20] fix(db): prevent live query from being marked ready before subset data 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 --- .changeset/fix-on-demand-ready-timing.md | 13 ++++ .../query/live/collection-config-builder.ts | 24 +++++- .../tests/query/live-query-collection.test.ts | 77 +++++++++++++++++++ 3 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 .changeset/fix-on-demand-ready-timing.md diff --git a/.changeset/fix-on-demand-ready-timing.md b/.changeset/fix-on-demand-ready-timing.md new file mode 100644 index 000000000..dcbb30444 --- /dev/null +++ b/.changeset/fix-on-demand-ready-timing.md @@ -0,0 +1,13 @@ +--- +"@tanstack/db": patch +--- + +fix(db): prevent live query from being marked ready before subset data 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 adds a check in `updateLiveQueryStatus()` to ensure that the live query is not +marked ready while `isLoadingSubset` is true. Additionally, a listener is added for +`loadingSubset:change` events to trigger the ready check when subset loading completes. diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index b663fdad5..6f26ce96e 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -571,6 +571,20 @@ export class CollectionConfigBuilder< fullSyncState, ) + // Listen for loadingSubset changes on the live query collection. + // When isLoadingSubset becomes false, we may need to mark the collection as ready + // (if all source collections are already ready but we were waiting for subset load to complete) + const loadingSubsetUnsubscribe = config.collection.on( + `loadingSubset:change`, + (event) => { + if (!event.isLoadingSubset) { + // Subset loading finished, check if we can now mark ready + this.updateLiveQueryStatus(config) + } + }, + ) + syncState.unsubscribeCallbacks.add(loadingSubsetUnsubscribe) + this.maybeRunGraphFn = () => this.scheduleGraphRun(loadSubsetDataCallbacks) // Initial run with callback to load more data if needed @@ -793,8 +807,14 @@ export class CollectionConfigBuilder< return } - // Mark ready when all source collections are ready - if (this.allCollectionsReady()) { + // Mark ready when all source collections are ready AND + // the live query collection is not loading subset data. + // This prevents marking the live query ready before its data is loaded + // (fixes issue where useLiveQuery returns isReady=true with empty data) + if ( + this.allCollectionsReady() && + !this.liveQueryCollection?.isLoadingSubset + ) { markReady() } } diff --git a/packages/db/tests/query/live-query-collection.test.ts b/packages/db/tests/query/live-query-collection.test.ts index 8c10c3eb8..84c6cd73a 100644 --- a/packages/db/tests/query/live-query-collection.test.ts +++ b/packages/db/tests/query/live-query-collection.test.ts @@ -1075,6 +1075,83 @@ describe(`createLiveQueryCollection`, () => { }) describe(`isLoadingSubset integration`, () => { + it(`should not mark live query ready while isLoadingSubset is true`, async () => { + // This test demonstrates the bug where live query is marked ready + // before isLoadingSubset becomes false, causing "ready" status with no data + + let resolveLoadSubset: () => void + const loadSubsetPromise = new Promise((resolve) => { + resolveLoadSubset = resolve + }) + + // Track whether loadSubset was called + let loadSubsetCalled = false + + const sourceCollection = createCollection<{ id: number; value: number }>({ + id: `source-delayed-subset`, + getKey: (item) => item.id, + syncMode: `on-demand`, + startSync: true, + sync: { + sync: ({ markReady, begin, write, commit }) => { + // Mark source ready immediately with some initial data + begin() + write({ type: `insert`, value: { id: 1, value: 10 } }) + write({ type: `insert`, value: { id: 2, value: 20 } }) + write({ type: `insert`, value: { id: 3, value: 30 } }) + commit() + markReady() + + return { + loadSubset: () => { + loadSubsetCalled = true + // Return a promise that we control to delay the subset loading + return loadSubsetPromise + }, + } + }, + }, + }) + + // Create a live query with orderBy + limit that triggers lazy loading + const liveQuery = createLiveQueryCollection({ + query: (q) => + q + .from({ item: sourceCollection }) + .orderBy(({ item }) => item.value, `asc`) + .limit(2), + startSync: true, + }) + + // Wait a bit for the subscription to start and trigger loadSubset + await flushPromises() + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Source should be ready + expect(sourceCollection.isReady()).toBe(true) + + // loadSubset should have been called (verifying our test setup is correct) + expect(loadSubsetCalled).toBe(true) + + // Live query should have isLoadingSubset = true + expect(liveQuery.isLoadingSubset).toBe(true) + + // KEY ASSERTION: Live query should NOT be ready while isLoadingSubset is true + // This is the bug we're fixing - without the fix, status would be 'ready' here + expect(liveQuery.status).not.toBe(`ready`) + + // Now resolve the loadSubset promise + resolveLoadSubset!() + await flushPromises() + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Now isLoadingSubset should be false + expect(liveQuery.isLoadingSubset).toBe(false) + + // Now the live query should be ready + expect(liveQuery.status).toBe(`ready`) + }) + it(`live query result collection has isLoadingSubset property`, async () => { const sourceCollection = createCollection<{ id: string; value: string }>({ id: `source`, From c9b928a6fe01ef9a2286a5e723f3ab348ad51a50 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 30 Dec 2025 23:32:02 +0000 Subject: [PATCH 05/20] ci: apply automated fixes --- .changeset/fix-on-demand-ready-timing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/fix-on-demand-ready-timing.md b/.changeset/fix-on-demand-ready-timing.md index dcbb30444..46c789c52 100644 --- a/.changeset/fix-on-demand-ready-timing.md +++ b/.changeset/fix-on-demand-ready-timing.md @@ -1,5 +1,5 @@ --- -"@tanstack/db": patch +'@tanstack/db': patch --- fix(db): prevent live query from being marked ready before subset data is loaded From e66bc50c8887bff10bf6da09220b61e3f501cd93 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Dec 2025 23:48:07 +0000 Subject: [PATCH 06/20] test(db): verify status is 'loading' while isLoadingSubset is true --- packages/db/tests/query/live-query-collection.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/db/tests/query/live-query-collection.test.ts b/packages/db/tests/query/live-query-collection.test.ts index 84c6cd73a..250387042 100644 --- a/packages/db/tests/query/live-query-collection.test.ts +++ b/packages/db/tests/query/live-query-collection.test.ts @@ -1140,6 +1140,9 @@ describe(`createLiveQueryCollection`, () => { // This is the bug we're fixing - without the fix, status would be 'ready' here expect(liveQuery.status).not.toBe(`ready`) + // Status should be 'loading', which means useLiveQuery would return isLoading=true + expect(liveQuery.status).toBe(`loading`) + // Now resolve the loadSubset promise resolveLoadSubset!() await flushPromises() From cbba6e40f811dbcfc63ee6afbf20a701a725f84a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 31 Dec 2025 02:32:40 +0000 Subject: [PATCH 07/20] fix(db): register loadingSubset listener before subscribing to avoid 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. --- .../db/src/query/live/collection-config-builder.ts | 13 +++++++------ .../tests/electric-live-query.test.ts | 3 +++ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index 6f26ce96e..59efff818 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -566,12 +566,8 @@ export class CollectionConfigBuilder< }, ) - const loadSubsetDataCallbacks = this.subscribeToAllCollections( - config, - fullSyncState, - ) - - // Listen for loadingSubset changes on the live query collection. + // Listen for loadingSubset changes on the live query collection BEFORE subscribing. + // This ensures we don't miss the event if subset loading completes synchronously. // When isLoadingSubset becomes false, we may need to mark the collection as ready // (if all source collections are already ready but we were waiting for subset load to complete) const loadingSubsetUnsubscribe = config.collection.on( @@ -585,6 +581,11 @@ export class CollectionConfigBuilder< ) syncState.unsubscribeCallbacks.add(loadingSubsetUnsubscribe) + const loadSubsetDataCallbacks = this.subscribeToAllCollections( + config, + fullSyncState, + ) + this.maybeRunGraphFn = () => this.scheduleGraphRun(loadSubsetDataCallbacks) // Initial run with callback to load more data if needed diff --git a/packages/electric-db-collection/tests/electric-live-query.test.ts b/packages/electric-db-collection/tests/electric-live-query.test.ts index bcd83d310..2606bbfaf 100644 --- a/packages/electric-db-collection/tests/electric-live-query.test.ts +++ b/packages/electric-db-collection/tests/electric-live-query.test.ts @@ -544,6 +544,9 @@ describe.each([ .limit(2), }) + // Wait for async subset loading to complete + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(limitedLiveQuery.status).toBe(`ready`) expect(limitedLiveQuery.size).toBe(2) // Only first 2 active users expect(mockRequestSnapshot).toHaveBeenCalledTimes(1) From 6b09e0195d9cf543e507002aadf3d3d82bfb2e74 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 31 Dec 2025 02:53:38 +0000 Subject: [PATCH 08/20] fix(db): fix race condition in subscription status tracking 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. --- .changeset/fix-on-demand-ready-timing.md | 12 +++++++++--- .../src/query/live/collection-subscriber.ts | 19 ++++++++++++------- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/.changeset/fix-on-demand-ready-timing.md b/.changeset/fix-on-demand-ready-timing.md index 46c789c52..41e6cd6a0 100644 --- a/.changeset/fix-on-demand-ready-timing.md +++ b/.changeset/fix-on-demand-ready-timing.md @@ -8,6 +8,12 @@ In on-demand sync mode, the live query collection was being marked as `ready` be the subset data finished loading. This caused `useLiveQuery` to return `isReady=true` with empty data, and `useLiveSuspenseQuery` to release suspense prematurely. -The fix adds a check in `updateLiveQueryStatus()` to ensure that the live query is not -marked ready while `isLoadingSubset` is true. Additionally, a listener is added for -`loadingSubset:change` events to trigger the ready check when subset loading completes. +Changes: +- Add a check in `updateLiveQueryStatus()` to ensure that the live query is not + marked ready while `isLoadingSubset` is true +- Add a listener for `loadingSubset:change` events to trigger the ready check when + subset loading completes +- Register the `loadingSubset:change` listener before subscribing to avoid race conditions +- Fix race condition in `CollectionSubscriber` where the `status:change` listener was + registered after checking the subscription status, causing missed `ready` events when + `loadSubset` completed quickly diff --git a/packages/db/src/query/live/collection-subscriber.ts b/packages/db/src/query/live/collection-subscriber.ts index 303c833fc..f4e07b33f 100644 --- a/packages/db/src/query/live/collection-subscriber.ts +++ b/packages/db/src/query/live/collection-subscriber.ts @@ -89,13 +89,11 @@ export class CollectionSubscriber< } } - // It can be that we are not yet subscribed when the first `loadSubset` call happens (i.e. the initial query). - // So we also check the status here and if it's `loadingSubset` then we track the load promise - if (subscription.status === `loadingSubset`) { - trackLoadPromise() - } - - // Subscribe to subscription status changes to propagate loading state + // Subscribe to subscription status changes to propagate loading state. + // IMPORTANT: Register the listener BEFORE checking the current status to avoid a race condition. + // If we check status first and it's 'loadingSubset', then register the listener, + // the loadSubset promise might resolve between these two steps, causing us to miss + // the 'ready' status change event and leaving the tracked promise unresolved forever. const statusUnsubscribe = subscription.on(`status:change`, (event) => { if (event.status === `loadingSubset`) { trackLoadPromise() @@ -110,6 +108,13 @@ export class CollectionSubscriber< } }) + // Now check the current status after the listener is registered. + // If status is 'loadingSubset', track it - the listener above will catch the transition to 'ready'. + // If status is already 'ready', we either missed a quick transition or there was no loading at all. + if (subscription.status === `loadingSubset`) { + trackLoadPromise() + } + const unsubscribe = () => { // If subscription has a pending promise, resolve it before unsubscribing const deferred = this.subscriptionLoadingPromises.get(subscription) From 732acd3a5cc58a474aae7fb45dbfde923d5b67c3 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 31 Dec 2025 02:54:43 +0000 Subject: [PATCH 09/20] ci: apply automated fixes --- .changeset/fix-on-demand-ready-timing.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/fix-on-demand-ready-timing.md b/.changeset/fix-on-demand-ready-timing.md index 41e6cd6a0..69cf75c15 100644 --- a/.changeset/fix-on-demand-ready-timing.md +++ b/.changeset/fix-on-demand-ready-timing.md @@ -9,6 +9,7 @@ the subset data finished loading. This caused `useLiveQuery` to return `isReady= with empty data, and `useLiveSuspenseQuery` to release suspense prematurely. Changes: + - Add a check in `updateLiveQueryStatus()` to ensure that the live query is not marked ready while `isLoadingSubset` is true - Add a listener for `loadingSubset:change` events to trigger the ready check when From 67acfa64e2e6bbad29feda6b132f451b6074843b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 31 Dec 2025 16:23:02 +0000 Subject: [PATCH 10/20] fix(db): check source collections' isLoadingSubset instead of live query'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 --- .changeset/fix-on-demand-ready-timing.md | 20 ++++---- .../query/live/collection-config-builder.ts | 47 ++++++++++++------- 2 files changed, 41 insertions(+), 26 deletions(-) diff --git a/.changeset/fix-on-demand-ready-timing.md b/.changeset/fix-on-demand-ready-timing.md index 69cf75c15..8f76c9fc3 100644 --- a/.changeset/fix-on-demand-ready-timing.md +++ b/.changeset/fix-on-demand-ready-timing.md @@ -8,13 +8,17 @@ In on-demand sync mode, the live query collection was being marked as `ready` be the subset data finished loading. This caused `useLiveQuery` to return `isReady=true` with empty data, and `useLiveSuspenseQuery` to release suspense prematurely. -Changes: +The root cause was that `updateLiveQueryStatus()` was checking `isLoadingSubset` on the +live query collection itself, but the `loadSubset`/`trackLoadPromise` mechanism runs on +SOURCE collections during on-demand sync. The fix now correctly checks if any source +collection is loading subset data. -- Add a check in `updateLiveQueryStatus()` to ensure that the live query is not - marked ready while `isLoadingSubset` is true -- Add a listener for `loadingSubset:change` events to trigger the ready check when - subset loading completes -- Register the `loadingSubset:change` listener before subscribing to avoid race conditions +Changes: +- Add `anySourceCollectionLoadingSubset()` helper to check if any source collection + has `isLoadingSubset=true` +- Update `updateLiveQueryStatus()` to use this helper instead of checking the live + query collection's `isLoadingSubset` +- Listen for `loadingSubset:change` events on SOURCE collections (not the live query + collection) to trigger the ready check when subset loading completes - Fix race condition in `CollectionSubscriber` where the `status:change` listener was - registered after checking the subscription status, causing missed `ready` events when - `loadSubset` completed quickly + registered after checking the subscription status diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index 59efff818..fcd86dd49 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -566,20 +566,24 @@ export class CollectionConfigBuilder< }, ) - // Listen for loadingSubset changes on the live query collection BEFORE subscribing. + // Listen for loadingSubset changes on SOURCE collections BEFORE subscribing. // This ensures we don't miss the event if subset loading completes synchronously. - // When isLoadingSubset becomes false, we may need to mark the collection as ready - // (if all source collections are already ready but we were waiting for subset load to complete) - const loadingSubsetUnsubscribe = config.collection.on( - `loadingSubset:change`, - (event) => { - if (!event.isLoadingSubset) { - // Subset loading finished, check if we can now mark ready - this.updateLiveQueryStatus(config) - } - }, - ) - syncState.unsubscribeCallbacks.add(loadingSubsetUnsubscribe) + // When a source collection's isLoadingSubset becomes false, we may need to mark the + // live query as ready (if all source collections are already ready and no longer loading). + // Note: We listen to source collections, not the live query collection, because the + // loadSubset/trackLoadPromise mechanism runs on source collections during on-demand sync. + for (const collection of Object.values(this.collections)) { + const loadingSubsetUnsubscribe = collection.on( + `loadingSubset:change`, + (event) => { + if (!event.isLoadingSubset) { + // This source collection finished loading, check if we can now mark ready + this.updateLiveQueryStatus(config) + } + }, + ) + syncState.unsubscribeCallbacks.add(loadingSubsetUnsubscribe) + } const loadSubsetDataCallbacks = this.subscribeToAllCollections( config, @@ -809,13 +813,10 @@ export class CollectionConfigBuilder< } // Mark ready when all source collections are ready AND - // the live query collection is not loading subset data. + // no source collection is currently loading subset data. // This prevents marking the live query ready before its data is loaded // (fixes issue where useLiveQuery returns isReady=true with empty data) - if ( - this.allCollectionsReady() && - !this.liveQueryCollection?.isLoadingSubset - ) { + if (this.allCollectionsReady() && !this.anySourceCollectionLoadingSubset()) { markReady() } } @@ -839,6 +840,16 @@ export class CollectionConfigBuilder< ) } + /** + * Check if any source collection is currently loading subset data. + * This is used to prevent marking the live query ready before data has been loaded. + */ + private anySourceCollectionLoadingSubset() { + return Object.values(this.collections).some( + (collection) => collection.isLoadingSubset, + ) + } + /** * Creates per-alias subscriptions enabling self-join support. * Each alias gets its own subscription with independent filters, even for the same collection. From 6c0975c94db27b49a1febf6a6be27d1b62dd2b91 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 31 Dec 2025 16:24:56 +0000 Subject: [PATCH 11/20] ci: apply automated fixes --- .changeset/fix-on-demand-ready-timing.md | 1 + packages/db/src/query/live/collection-config-builder.ts | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.changeset/fix-on-demand-ready-timing.md b/.changeset/fix-on-demand-ready-timing.md index 8f76c9fc3..c602a2381 100644 --- a/.changeset/fix-on-demand-ready-timing.md +++ b/.changeset/fix-on-demand-ready-timing.md @@ -14,6 +14,7 @@ SOURCE collections during on-demand sync. The fix now correctly checks if any so collection is loading subset data. Changes: + - Add `anySourceCollectionLoadingSubset()` helper to check if any source collection has `isLoadingSubset=true` - Update `updateLiveQueryStatus()` to use this helper instead of checking the live diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index fcd86dd49..f17464859 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -816,7 +816,10 @@ export class CollectionConfigBuilder< // no source collection is currently loading subset data. // This prevents marking the live query ready before its data is loaded // (fixes issue where useLiveQuery returns isReady=true with empty data) - if (this.allCollectionsReady() && !this.anySourceCollectionLoadingSubset()) { + if ( + this.allCollectionsReady() && + !this.anySourceCollectionLoadingSubset() + ) { markReady() } } From 6b03b56789656f78f55065d359306dbb45e1d948 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 31 Dec 2025 17:09:01 +0000 Subject: [PATCH 12/20] fix(db): check live query collection's isLoadingSubset instead of source 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. --- .changeset/fix-on-demand-ready-timing.md | 19 +++----- .../query/live/collection-config-builder.ts | 44 +++++++------------ 2 files changed, 21 insertions(+), 42 deletions(-) diff --git a/.changeset/fix-on-demand-ready-timing.md b/.changeset/fix-on-demand-ready-timing.md index c602a2381..d4fb5bc9b 100644 --- a/.changeset/fix-on-demand-ready-timing.md +++ b/.changeset/fix-on-demand-ready-timing.md @@ -8,18 +8,11 @@ In on-demand sync mode, the live query collection was being marked as `ready` be the subset data finished loading. This caused `useLiveQuery` to return `isReady=true` with empty data, and `useLiveSuspenseQuery` to release suspense prematurely. -The root cause was that `updateLiveQueryStatus()` was checking `isLoadingSubset` on the -live query collection itself, but the `loadSubset`/`trackLoadPromise` mechanism runs on -SOURCE collections during on-demand sync. The fix now correctly checks if any source -collection is loading subset data. - Changes: - -- Add `anySourceCollectionLoadingSubset()` helper to check if any source collection - has `isLoadingSubset=true` -- Update `updateLiveQueryStatus()` to use this helper instead of checking the live - query collection's `isLoadingSubset` -- Listen for `loadingSubset:change` events on SOURCE collections (not the live query - collection) to trigger the ready check when subset loading completes +- Update `updateLiveQueryStatus()` to check `isLoadingSubset` on the live query collection + before marking it ready +- Listen for `loadingSubset:change` events on the live query collection to trigger + the ready check when subset loading completes - Fix race condition in `CollectionSubscriber` where the `status:change` listener was - registered after checking the subscription status + registered after checking the subscription status, which could cause the listener + to miss status changes that occurred between the check and registration diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index f17464859..59efff818 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -566,24 +566,20 @@ export class CollectionConfigBuilder< }, ) - // Listen for loadingSubset changes on SOURCE collections BEFORE subscribing. + // Listen for loadingSubset changes on the live query collection BEFORE subscribing. // This ensures we don't miss the event if subset loading completes synchronously. - // When a source collection's isLoadingSubset becomes false, we may need to mark the - // live query as ready (if all source collections are already ready and no longer loading). - // Note: We listen to source collections, not the live query collection, because the - // loadSubset/trackLoadPromise mechanism runs on source collections during on-demand sync. - for (const collection of Object.values(this.collections)) { - const loadingSubsetUnsubscribe = collection.on( - `loadingSubset:change`, - (event) => { - if (!event.isLoadingSubset) { - // This source collection finished loading, check if we can now mark ready - this.updateLiveQueryStatus(config) - } - }, - ) - syncState.unsubscribeCallbacks.add(loadingSubsetUnsubscribe) - } + // When isLoadingSubset becomes false, we may need to mark the collection as ready + // (if all source collections are already ready but we were waiting for subset load to complete) + const loadingSubsetUnsubscribe = config.collection.on( + `loadingSubset:change`, + (event) => { + if (!event.isLoadingSubset) { + // Subset loading finished, check if we can now mark ready + this.updateLiveQueryStatus(config) + } + }, + ) + syncState.unsubscribeCallbacks.add(loadingSubsetUnsubscribe) const loadSubsetDataCallbacks = this.subscribeToAllCollections( config, @@ -813,12 +809,12 @@ export class CollectionConfigBuilder< } // Mark ready when all source collections are ready AND - // no source collection is currently loading subset data. + // the live query collection is not loading subset data. // This prevents marking the live query ready before its data is loaded // (fixes issue where useLiveQuery returns isReady=true with empty data) if ( this.allCollectionsReady() && - !this.anySourceCollectionLoadingSubset() + !this.liveQueryCollection?.isLoadingSubset ) { markReady() } @@ -843,16 +839,6 @@ export class CollectionConfigBuilder< ) } - /** - * Check if any source collection is currently loading subset data. - * This is used to prevent marking the live query ready before data has been loaded. - */ - private anySourceCollectionLoadingSubset() { - return Object.values(this.collections).some( - (collection) => collection.isLoadingSubset, - ) - } - /** * Creates per-alias subscriptions enabling self-join support. * Each alias gets its own subscription with independent filters, even for the same collection. From d68d585af525d804d519cefe9c5177d61951342c Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 31 Dec 2025 17:11:39 +0000 Subject: [PATCH 13/20] ci: apply automated fixes --- .changeset/fix-on-demand-ready-timing.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/fix-on-demand-ready-timing.md b/.changeset/fix-on-demand-ready-timing.md index d4fb5bc9b..4fc978acd 100644 --- a/.changeset/fix-on-demand-ready-timing.md +++ b/.changeset/fix-on-demand-ready-timing.md @@ -9,6 +9,7 @@ the subset data finished loading. This caused `useLiveQuery` to return `isReady= with empty data, and `useLiveSuspenseQuery` to release suspense prematurely. Changes: + - Update `updateLiveQueryStatus()` to check `isLoadingSubset` on the live query collection before marking it ready - Listen for `loadingSubset:change` events on the live query collection to trigger From cc9a8a4ab97c63fa13b17a161af2c1d3aec69e4a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 31 Dec 2025 17:51:17 +0000 Subject: [PATCH 14/20] fix(db): fix race condition where status listener was registered after 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 --- .changeset/fix-on-demand-ready-timing.md | 8 +- packages/db/src/collection/changes.ts | 17 ++- .../src/query/live/collection-subscriber.ts | 104 +++++++++++------- packages/db/src/types.ts | 7 ++ .../tests/query/live-query-collection.test.ts | 82 ++++++++++++-- 5 files changed, 161 insertions(+), 57 deletions(-) diff --git a/.changeset/fix-on-demand-ready-timing.md b/.changeset/fix-on-demand-ready-timing.md index 4fc978acd..c39b20f99 100644 --- a/.changeset/fix-on-demand-ready-timing.md +++ b/.changeset/fix-on-demand-ready-timing.md @@ -15,5 +15,9 @@ Changes: - Listen for `loadingSubset:change` events on the live query collection to trigger the ready check when subset loading completes - Fix race condition in `CollectionSubscriber` where the `status:change` listener was - registered after checking the subscription status, which could cause the listener - to miss status changes that occurred between the check and registration + registered after the snapshot was triggered. Now the subscription creation is split + from snapshot triggering, allowing the listener to be registered BEFORE any async + work starts. This ensures we never miss status transitions even if the loadSubset + promise resolves synchronously. +- Add `deferSnapshot` option to `subscribeChanges()` to support the deferred snapshot + pattern used by the race condition fix diff --git a/packages/db/src/collection/changes.ts b/packages/db/src/collection/changes.ts index a69400375..a7be7f308 100644 --- a/packages/db/src/collection/changes.ts +++ b/packages/db/src/collection/changes.ts @@ -107,12 +107,17 @@ export class CollectionChangesManager< }, }) - if (options.includeInitialState) { - subscription.requestSnapshot({ trackLoadSubsetPromise: false }) - } else if (options.includeInitialState === false) { - // When explicitly set to false (not just undefined), mark all state as "seen" - // so that all future changes (including deletes) pass through unfiltered. - subscription.markAllStateAsSeen() + // When deferSnapshot is true, the caller will manually trigger the snapshot request + // after registering status listeners. This prevents race conditions where the + // loadSubset promise resolves before the listener is registered. + if (!options.deferSnapshot) { + if (options.includeInitialState) { + subscription.requestSnapshot({ trackLoadSubsetPromise: false }) + } else if (options.includeInitialState === false) { + // When explicitly set to false (not just undefined), mark all state as "seen" + // so that all future changes (including deletes) pass through unfiltered. + subscription.markAllStateAsSeen() + } } // Add to batched listeners diff --git a/packages/db/src/query/live/collection-subscriber.ts b/packages/db/src/query/live/collection-subscriber.ts index f4e07b33f..6324fb4bc 100644 --- a/packages/db/src/query/live/collection-subscriber.ts +++ b/packages/db/src/query/live/collection-subscriber.ts @@ -53,23 +53,28 @@ export class CollectionSubscriber< } private subscribeToChanges(whereExpression?: BasicExpression) { + // Step 1: Create subscription and get deferred snapshot trigger + // The subscription is created but snapshot request is NOT triggered yet. + // This allows us to register the status listener BEFORE any async work starts. let subscription: CollectionSubscription + let triggerSnapshot: () => void const orderByInfo = this.getOrderByInfo() if (orderByInfo) { - subscription = this.subscribeToOrderedChanges( - whereExpression, - orderByInfo, - ) + const result = this.subscribeToOrderedChanges(whereExpression, orderByInfo) + subscription = result.subscription + triggerSnapshot = result.triggerSnapshot } else { // If the source alias is lazy then we should not include the initial state const includeInitialState = !this.collectionConfigBuilder.isLazyAlias( this.alias, ) - subscription = this.subscribeToMatchingChanges( + const result = this.subscribeToMatchingChanges( whereExpression, includeInitialState, ) + subscription = result.subscription + triggerSnapshot = result.triggerSnapshot } const trackLoadPromise = () => { @@ -89,11 +94,9 @@ export class CollectionSubscriber< } } - // Subscribe to subscription status changes to propagate loading state. - // IMPORTANT: Register the listener BEFORE checking the current status to avoid a race condition. - // If we check status first and it's 'loadingSubset', then register the listener, - // the loadSubset promise might resolve between these two steps, causing us to miss - // the 'ready' status change event and leaving the tracked promise unresolved forever. + // Step 2: Register status listener BEFORE triggering snapshot. + // This ensures we don't miss any status transitions, even if the loadSubset + // promise resolves synchronously or very quickly. const statusUnsubscribe = subscription.on(`status:change`, (event) => { if (event.status === `loadingSubset`) { trackLoadPromise() @@ -108,9 +111,12 @@ export class CollectionSubscriber< } }) - // Now check the current status after the listener is registered. - // If status is 'loadingSubset', track it - the listener above will catch the transition to 'ready'. - // If status is already 'ready', we either missed a quick transition or there was no loading at all. + // Step 3: NOW trigger the snapshot request. + // The status listener is already registered, so we'll catch any status transitions. + triggerSnapshot() + + // Check current status after triggering - if status is 'loadingSubset', track it. + // The listener above will catch the transition to 'ready'. if (subscription.status === `loadingSubset`) { trackLoadPromise() } @@ -185,30 +191,39 @@ export class CollectionSubscriber< private subscribeToMatchingChanges( whereExpression: BasicExpression | undefined, includeInitialState: boolean = false, - ) { + ): { subscription: CollectionSubscription; triggerSnapshot: () => void } { const sendChanges = ( changes: Array>, ) => { this.sendChangesToPipeline(changes) } - // Only pass includeInitialState when true. When it's false, we leave it - // undefined so that user subscriptions with explicit `includeInitialState: false` - // can be distinguished from internal lazy-loading subscriptions. - // If we pass `false`, changes.ts would call markAllStateAsSeen() which - // disables filtering - but internal subscriptions still need filtering. + // Create subscription WITHOUT triggering snapshot - pass deferSnapshot: true + // This allows the caller to register status listeners before any async work starts const subscription = this.collection.subscribeChanges(sendChanges, { ...(includeInitialState && { includeInitialState }), whereExpression, + deferSnapshot: true, }) - return subscription + // Return the subscription and a function to trigger the snapshot later + // Note: For non-ordered queries (no limit/offset), we use trackLoadSubsetPromise: false + // to match the original behavior. The race condition fix is primarily needed for + // ordered queries with pagination where Electric's on-demand sync needs to wait + // for subset data before marking ready. + const triggerSnapshot = () => { + if (includeInitialState) { + subscription.requestSnapshot({ trackLoadSubsetPromise: false }) + } + } + + return { subscription, triggerSnapshot } } private subscribeToOrderedChanges( whereExpression: BasicExpression | undefined, orderByInfo: OrderByOptimizationInfo, - ) { + ): { subscription: CollectionSubscription; triggerSnapshot: () => void } { const { orderBy, offset, limit, index } = orderByInfo const sendChangesInRange = ( @@ -241,29 +256,34 @@ export class CollectionSubscriber< // Normalize the orderBy clauses such that the references are relative to the collection const normalizedOrderBy = normalizeOrderByPaths(orderBy, this.alias) - if (index) { - // We have an index on the first orderBy column - use lazy loading optimization - // This works for both single-column and multi-column orderBy: - // - Single-column: index provides exact ordering - // - Multi-column: index provides ordering on first column, secondary sort in memory - subscription.setOrderByIndex(index) - - // Load the first `offset + limit` values from the index - // i.e. the K items from the collection that fall into the requested range: [offset, offset + limit[ - subscription.requestLimitedSnapshot({ - limit: offset + limit, - orderBy: normalizedOrderBy, - }) - } else { - // No index available (e.g., non-ref expression): pass orderBy/limit to loadSubset - // so the sync layer can optimize if the backend supports it - subscription.requestSnapshot({ - orderBy: normalizedOrderBy, - limit: offset + limit, - }) + // Return the subscription and a deferred function to trigger the snapshot. + // The caller will register status listeners before calling triggerSnapshot, + // ensuring we don't miss any status transitions (even if loadSubset resolves quickly). + const triggerSnapshot = () => { + if (index) { + // We have an index on the first orderBy column - use lazy loading optimization + // This works for both single-column and multi-column orderBy: + // - Single-column: index provides exact ordering + // - Multi-column: index provides ordering on first column, secondary sort in memory + subscription.setOrderByIndex(index) + + // Load the first `offset + limit` values from the index + // i.e. the K items from the collection that fall into the requested range: [offset, offset + limit[ + subscription.requestLimitedSnapshot({ + limit: offset + limit, + orderBy: normalizedOrderBy, + }) + } else { + // No index available (e.g., non-ref expression): pass orderBy/limit to loadSubset + // so the sync layer can optimize if the backend supports it + subscription.requestSnapshot({ + orderBy: normalizedOrderBy, + limit: offset + limit, + }) + } } - return subscription + return { subscription, triggerSnapshot } } // This function is called by maybeRunGraph diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index bb78f35eb..9b9505a92 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -780,6 +780,13 @@ export interface SubscribeChangesOptions { includeInitialState?: boolean /** Pre-compiled expression for filtering changes */ whereExpression?: BasicExpression + /** + * When true, defers the snapshot request so the caller can register status + * listeners before any async work starts. The caller must call + * subscription.requestSnapshot() manually. + * @internal + */ + deferSnapshot?: boolean } export interface SubscribeChangesSnapshotOptions extends Omit< diff --git a/packages/db/tests/query/live-query-collection.test.ts b/packages/db/tests/query/live-query-collection.test.ts index 250387042..8de432a02 100644 --- a/packages/db/tests/query/live-query-collection.test.ts +++ b/packages/db/tests/query/live-query-collection.test.ts @@ -1197,12 +1197,16 @@ describe(`createLiveQueryCollection`, () => { expect(liveQuery.isLoadingSubset).toBe(false) }) - it(`source collection isLoadingSubset is independent`, async () => { - let resolveLoadSubset: () => void - const loadSubsetPromise = new Promise((resolve) => { - resolveLoadSubset = resolve + it(`source collection isLoadingSubset is independent from direct calls`, async () => { + // Create a pending promise for tracking direct loadSubset calls (not the initial subscription load) + let resolveDirectLoadSubset: () => void + const directLoadSubsetPromise = new Promise((resolve) => { + resolveDirectLoadSubset = resolve }) + // Track how many times loadSubset is called + let loadSubsetCallCount = 0 + const sourceCollection = createCollection<{ id: string; value: number }>({ id: `source`, getKey: (item) => item.id, @@ -1214,7 +1218,15 @@ describe(`createLiveQueryCollection`, () => { commit() markReady() return { - loadSubset: () => loadSubsetPromise, + loadSubset: () => { + loadSubsetCallCount++ + // First call is from the subscription's initial load - complete synchronously + // Subsequent calls (direct calls) use the pending promise + if (loadSubsetCallCount === 1) { + return true // synchronous completion + } + return directLoadSubsetPromise + }, } }, }, @@ -1227,20 +1239,76 @@ describe(`createLiveQueryCollection`, () => { await liveQuery.preload() + // After preload, both should have isLoadingSubset = false + expect(sourceCollection.isLoadingSubset).toBe(false) + expect(liveQuery.isLoadingSubset).toBe(false) + // Calling loadSubset directly on source collection sets its own isLoadingSubset sourceCollection._sync.loadSubset({}) expect(sourceCollection.isLoadingSubset).toBe(true) // But live query isLoadingSubset tracks subscription-driven loads, not direct loadSubset calls - // so it remains false unless subscriptions trigger loads via predicate pushdown + // so it remains false when loadSubset is called directly on the source collection expect(liveQuery.isLoadingSubset).toBe(false) - resolveLoadSubset!() + resolveDirectLoadSubset!() await new Promise((resolve) => setTimeout(resolve, 10)) expect(sourceCollection.isLoadingSubset).toBe(false) expect(liveQuery.isLoadingSubset).toBe(false) }) + + it(`status listener is registered before triggering snapshot to prevent race condition`, async () => { + // This test verifies the fix for the race condition where the subscription + // status could transition to 'loadingSubset' and back to 'ready' before + // the status listener was registered, causing the live query to miss + // tracking the loadSubset promise. + // + // The fix ensures the status listener is registered BEFORE calling + // triggerSnapshot() (which calls requestSnapshot/requestLimitedSnapshot). + + // Track the order of events + const events: Array = [] + + // Create a source collection where loadSubset synchronously calls the tracking + const sourceCollection = createCollection<{ id: number; name: string }>({ + id: `race-condition-source`, + getKey: (item) => item.id, + syncMode: `on-demand`, + sync: { + sync: ({ markReady }) => { + markReady() + return { + loadSubset: () => { + events.push(`loadSubset called`) + // Return a synchronously resolved promise to simulate the race condition + return Promise.resolve().then(() => { + events.push(`loadSubset resolved`) + }) + }, + } + }, + }, + }) + + const liveQuery = createLiveQueryCollection({ + query: (q) => q.from({ item: sourceCollection }), + startSync: true, + }) + + // Wait for the live query to be ready + await liveQuery.preload() + + // Verify the events occurred + expect(events).toContain(`loadSubset called`) + expect(events).toContain(`loadSubset resolved`) + + // The key assertion: the live query should be ready after preload + // This proves that even with a synchronously-resolved promise, + // the code path works correctly + expect(liveQuery.status).toBe(`ready`) + expect(liveQuery.isLoadingSubset).toBe(false) + }) }) describe(`move functionality`, () => { From 2ae80a2e031b0675d454633392616cbdcc0bfb85 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 31 Dec 2025 17:52:42 +0000 Subject: [PATCH 15/20] ci: apply automated fixes --- packages/db/src/query/live/collection-subscriber.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/db/src/query/live/collection-subscriber.ts b/packages/db/src/query/live/collection-subscriber.ts index 6324fb4bc..34d5914f1 100644 --- a/packages/db/src/query/live/collection-subscriber.ts +++ b/packages/db/src/query/live/collection-subscriber.ts @@ -60,7 +60,10 @@ export class CollectionSubscriber< let triggerSnapshot: () => void const orderByInfo = this.getOrderByInfo() if (orderByInfo) { - const result = this.subscribeToOrderedChanges(whereExpression, orderByInfo) + const result = this.subscribeToOrderedChanges( + whereExpression, + orderByInfo, + ) subscription = result.subscription triggerSnapshot = result.triggerSnapshot } else { From 84b65696928794e0004cdc9a154d3197dc88bf23 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Wed, 7 Jan 2026 09:22:20 -0700 Subject: [PATCH 16/20] refactor: streamline on-demand suspense fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove useLiveSuspenseQuery changes (not needed with core fix) - Remove artificial test that manually sets isLoadingSubset after ready - Update changeset to accurately describe the fix - Remove react-db changeset (no react-db source changes) The core fix (deferSnapshot + ready gating) is sufficient. The suspense hook doesn't need additional isLoadingSubset checks because the live query collection won't be marked ready while isLoadingSubset is true. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .changeset/fix-on-demand-ready-timing.md | 23 ++--- .changeset/fix-on-demand-suspense.md | 9 -- packages/react-db/src/useLiveSuspenseQuery.ts | 32 +------ .../tests/useLiveSuspenseQuery.test.tsx | 86 ------------------- 4 files changed, 14 insertions(+), 136 deletions(-) delete mode 100644 .changeset/fix-on-demand-suspense.md diff --git a/.changeset/fix-on-demand-ready-timing.md b/.changeset/fix-on-demand-ready-timing.md index c39b20f99..e794246bb 100644 --- a/.changeset/fix-on-demand-ready-timing.md +++ b/.changeset/fix-on-demand-ready-timing.md @@ -8,16 +8,17 @@ In on-demand sync mode, the live query collection was being marked as `ready` be the subset data finished loading. This caused `useLiveQuery` to return `isReady=true` with empty data, and `useLiveSuspenseQuery` to release suspense prematurely. +The root cause was a race condition: the `status:change` listener in `CollectionSubscriber` +was registered *after* the snapshot was triggered. If `loadSubset` resolved quickly +(or synchronously), the `loadingSubset` status transition would be missed entirely, +so `trackLoadPromise` was never called on the live query collection. + Changes: -- Update `updateLiveQueryStatus()` to check `isLoadingSubset` on the live query collection - before marking it ready -- Listen for `loadingSubset:change` events on the live query collection to trigger - the ready check when subset loading completes -- Fix race condition in `CollectionSubscriber` where the `status:change` listener was - registered after the snapshot was triggered. Now the subscription creation is split - from snapshot triggering, allowing the listener to be registered BEFORE any async - work starts. This ensures we never miss status transitions even if the loadSubset - promise resolves synchronously. -- Add `deferSnapshot` option to `subscribeChanges()` to support the deferred snapshot - pattern used by the race condition fix +1. **Core fix - deferred snapshot triggering**: Split subscription creation from snapshot + triggering using a new `deferSnapshot` option. This ensures the status listener is + registered BEFORE any async work starts, so we never miss status transitions. + +2. **Ready state gating**: `updateLiveQueryStatus()` now checks `isLoadingSubset` on the + live query collection before marking it ready, and listens for `loadingSubset:change` + to trigger the ready check when subset loading completes. diff --git a/.changeset/fix-on-demand-suspense.md b/.changeset/fix-on-demand-suspense.md deleted file mode 100644 index 23a49fe35..000000000 --- a/.changeset/fix-on-demand-suspense.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@tanstack/react-db': patch ---- - -Fix `useLiveSuspenseQuery` releasing suspense before data is loaded 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 yet. - -The fix ensures that `useLiveSuspenseQuery` also suspends while `isLoadingSubset` is true, waiting for the initial subset load to complete before releasing the suspense boundary. diff --git a/packages/react-db/src/useLiveSuspenseQuery.ts b/packages/react-db/src/useLiveSuspenseQuery.ts index 95dd6ebde..326bc131a 100644 --- a/packages/react-db/src/useLiveSuspenseQuery.ts +++ b/packages/react-db/src/useLiveSuspenseQuery.ts @@ -124,7 +124,6 @@ export function useLiveSuspenseQuery( deps: Array = [], ) { const promiseRef = useRef | null>(null) - const loadingSubsetPromiseRef = useRef | null>(null) const collectionRef = useRef | null>(null) const hasBeenReadyRef = useRef(false) @@ -134,16 +133,14 @@ export function useLiveSuspenseQuery( // Reset promise and ready state when collection changes (deps changed) if (collectionRef.current !== result.collection) { promiseRef.current = null - loadingSubsetPromiseRef.current = null collectionRef.current = result.collection hasBeenReadyRef.current = false } - // Track when we reach ready state AND finished loading subset - if (result.status === `ready` && !result.collection.isLoadingSubset) { + // Track when we reach ready state + if (result.status === `ready`) { hasBeenReadyRef.current = true promiseRef.current = null - loadingSubsetPromiseRef.current = null } // SUSPENSE LOGIC: Throw promise or error based on collection status @@ -159,7 +156,6 @@ export function useLiveSuspenseQuery( // After success, errors surface as stale data (matches TanStack Query behavior) if (result.status === `error` && !hasBeenReadyRef.current) { promiseRef.current = null - loadingSubsetPromiseRef.current = null // TODO: Once collections hold a reference to their last error object (#671), // we should rethrow that actual error instead of creating a generic message throw new Error(`Collection "${result.collection.id}" failed to load`) @@ -176,30 +172,6 @@ export function useLiveSuspenseQuery( throw promiseRef.current } - // Also suspend while loading subset data in on-demand mode - // This prevents suspense from releasing before the query's data is loaded - if (result.collection.isLoadingSubset && !hasBeenReadyRef.current) { - if (!loadingSubsetPromiseRef.current) { - loadingSubsetPromiseRef.current = new Promise((resolve) => { - // Check if already done loading (race condition guard) - if (!result.collection.isLoadingSubset) { - resolve() - return - } - const unsubscribe = result.collection.on( - `loadingSubset:change`, - (event) => { - if (!event.isLoadingSubset) { - unsubscribe() - resolve() - } - }, - ) - }) - } - throw loadingSubsetPromiseRef.current - } - // Return data without status/loading flags (handled by Suspense/ErrorBoundary) // If error after success, return last known good state (stale data) return { diff --git a/packages/react-db/tests/useLiveSuspenseQuery.test.tsx b/packages/react-db/tests/useLiveSuspenseQuery.test.tsx index 997ff9b2a..587f4b2c0 100644 --- a/packages/react-db/tests/useLiveSuspenseQuery.test.tsx +++ b/packages/react-db/tests/useLiveSuspenseQuery.test.tsx @@ -683,90 +683,4 @@ describe(`useLiveSuspenseQuery`, () => { expect(result.current.data).toHaveLength(3) }) - it(`should hold suspense until isLoadingSubset is false during initial load`, async () => { - // This test specifically verifies the bug fix: - // When the LIVE QUERY collection's status becomes 'ready' but its isLoadingSubset - // is still true, suspense should NOT release until isLoadingSubset becomes false. - // - // We test this by creating a pre-made live query collection and tracking a load - // promise on it BEFORE passing it to useLiveSuspenseQuery. - - let resolveLoadPromise: () => void - const loadPromise = new Promise((resolve) => { - resolveLoadPromise = resolve - }) - - const sourceCollection = createCollection( - mockSyncCollectionOptions({ - id: `test-persons-suspense-loading-subset-source`, - getKey: (person: Person) => person.id, - initialData: initialPersons, - }), - ) - - // Create a live query collection manually - const liveQueryCollection = createLiveQueryCollection({ - query: (q) => q.from({ persons: sourceCollection }), - startSync: true, - }) - - // Wait for the live query to be ready - await liveQueryCollection.preload() - expect(liveQueryCollection.status).toBe(`ready`) - - // Now track a load promise to simulate on-demand loading starting - // This sets isLoadingSubset=true AFTER the collection is ready - liveQueryCollection._sync.trackLoadPromise(loadPromise) - expect(liveQueryCollection.isLoadingSubset).toBe(true) - - let suspenseReleased = false - let dataWhenSuspenseReleased: Array | undefined - - const SuspenseTracker = ({ children }: { children: ReactNode }) => { - return Loading...}>{children} - } - - const { result } = renderHook( - () => { - const queryResult = useLiveSuspenseQuery(liveQueryCollection) - - // If we get here without throwing, suspense has released - suspenseReleased = true - dataWhenSuspenseReleased = queryResult.data - return queryResult - }, - { - wrapper: SuspenseTracker, - }, - ) - - // Give React time to process - suspense should still be active - // because isLoadingSubset is true on the live query collection - await new Promise((r) => setTimeout(r, 50)) - - // Verify state: ready but still loading subset - expect(liveQueryCollection.status).toBe(`ready`) - expect(liveQueryCollection.isLoadingSubset).toBe(true) - - // KEY ASSERTION: Suspense should NOT have released yet - // This is the bug we're fixing - without the fix, suspense would release here - expect(suspenseReleased).toBe(false) - - // Now resolve the load promise - resolveLoadPromise!() - - // Wait for suspense to release - await waitFor(() => { - expect(suspenseReleased).toBe(true) - }) - - // Verify data is available when suspense releases - expect(dataWhenSuspenseReleased).toHaveLength(3) - - // Wait for React to finish rendering - await waitFor(() => { - expect(result.current).not.toBeNull() - }) - expect(result.current.data).toHaveLength(3) - }) }) From cdd706dcf4f6133a05566c7c20c43c0260b675e2 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 7 Jan 2026 16:23:24 +0000 Subject: [PATCH 17/20] ci: apply automated fixes --- .changeset/fix-on-demand-ready-timing.md | 2 +- packages/react-db/tests/useLiveSuspenseQuery.test.tsx | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.changeset/fix-on-demand-ready-timing.md b/.changeset/fix-on-demand-ready-timing.md index e794246bb..340bd244e 100644 --- a/.changeset/fix-on-demand-ready-timing.md +++ b/.changeset/fix-on-demand-ready-timing.md @@ -9,7 +9,7 @@ the subset data finished loading. This caused `useLiveQuery` to return `isReady= with empty data, and `useLiveSuspenseQuery` to release suspense prematurely. The root cause was a race condition: the `status:change` listener in `CollectionSubscriber` -was registered *after* the snapshot was triggered. If `loadSubset` resolved quickly +was registered _after_ the snapshot was triggered. If `loadSubset` resolved quickly (or synchronously), the `loadingSubset` status transition would be missed entirely, so `trackLoadPromise` was never called on the live query collection. diff --git a/packages/react-db/tests/useLiveSuspenseQuery.test.tsx b/packages/react-db/tests/useLiveSuspenseQuery.test.tsx index 587f4b2c0..fc78b0796 100644 --- a/packages/react-db/tests/useLiveSuspenseQuery.test.tsx +++ b/packages/react-db/tests/useLiveSuspenseQuery.test.tsx @@ -682,5 +682,4 @@ describe(`useLiveSuspenseQuery`, () => { // Data should still be available expect(result.current.data).toHaveLength(3) }) - }) From e5c0f6bfc905adedbace2b3349c4d8882375dc89 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Wed, 7 Jan 2026 11:03:58 -0700 Subject: [PATCH 18/20] test: add sync/instant loadSubset resolution test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests the tricky case where loadSubset returns Promise.resolve() immediately. This proves the race condition fix works even when the status transition happens synchronously, not just with delayed promises. Addresses reviewer feedback to strengthen test coverage. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../tests/query/live-query-collection.test.ts | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/packages/db/tests/query/live-query-collection.test.ts b/packages/db/tests/query/live-query-collection.test.ts index 8de432a02..85866945c 100644 --- a/packages/db/tests/query/live-query-collection.test.ts +++ b/packages/db/tests/query/live-query-collection.test.ts @@ -1155,6 +1155,73 @@ describe(`createLiveQueryCollection`, () => { expect(liveQuery.status).toBe(`ready`) }) + it(`should handle synchronously resolving loadSubset without race condition`, async () => { + // This test specifically targets the race condition where loadSubset resolves + // synchronously (or extremely fast). The fix must ensure we don't miss the + // transient loadingSubset -> ready transition even in this case. + + // Track whether loadSubset was called + let loadSubsetCalled = false + + const sourceCollection = createCollection<{ id: number; value: number }>({ + id: `source-sync-subset`, + getKey: (item) => item.id, + syncMode: `on-demand`, + startSync: true, + sync: { + sync: ({ markReady, begin, write, commit }) => { + // Mark source ready immediately with some initial data + begin() + write({ type: `insert`, value: { id: 1, value: 10 } }) + write({ type: `insert`, value: { id: 2, value: 20 } }) + write({ type: `insert`, value: { id: 3, value: 30 } }) + commit() + markReady() + + return { + loadSubset: () => { + loadSubsetCalled = true + // Return an IMMEDIATELY resolving promise - this is the tricky case + // where the status transition could be missed if listener registration + // happens after snapshot triggering + return Promise.resolve() + }, + } + }, + }, + }) + + // Create a live query with orderBy + limit that triggers lazy loading + const liveQuery = createLiveQueryCollection({ + query: (q) => + q + .from({ item: sourceCollection }) + .orderBy(({ item }) => item.value, `asc`) + .limit(2), + startSync: true, + }) + + // Wait for everything to settle + await flushPromises() + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Source should be ready + expect(sourceCollection.isReady()).toBe(true) + + // loadSubset should have been called + expect(loadSubsetCalled).toBe(true) + + // KEY ASSERTION: Even with sync resolution, isLoadingSubset should now be false + // (the promise resolved immediately) + expect(liveQuery.isLoadingSubset).toBe(false) + + // And the live query should be ready (not stuck in loading) + expect(liveQuery.status).toBe(`ready`) + + // Verify we have data (not empty due to race condition) + expect(liveQuery.size).toBeGreaterThan(0) + }) + it(`live query result collection has isLoadingSubset property`, async () => { const sourceCollection = createCollection<{ id: string; value: string }>({ id: `source`, From 62f0bd8034a2f2230a41756026378a14935a646a Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Thu, 8 Jan 2026 15:57:10 -0700 Subject: [PATCH 19/20] refactor(db): replace deferSnapshot with onStatusChange option Simplify the subscription API by replacing the error-prone 3-step deferSnapshot pattern with a cleaner onStatusChange callback option. The listener is now registered internally BEFORE any snapshot is requested, guaranteeing no status transitions are missed regardless of how quickly loadSubset resolves. Co-Authored-By: Claude Opus 4.5 --- .changeset/fix-on-demand-ready-timing.md | 7 +- packages/db/src/collection/changes.ts | 24 +-- .../src/query/live/collection-subscriber.ts | 162 ++++++++---------- packages/db/src/types.ts | 7 +- 4 files changed, 95 insertions(+), 105 deletions(-) diff --git a/.changeset/fix-on-demand-ready-timing.md b/.changeset/fix-on-demand-ready-timing.md index 340bd244e..3b96f0e8d 100644 --- a/.changeset/fix-on-demand-ready-timing.md +++ b/.changeset/fix-on-demand-ready-timing.md @@ -15,9 +15,10 @@ so `trackLoadPromise` was never called on the live query collection. Changes: -1. **Core fix - deferred snapshot triggering**: Split subscription creation from snapshot - triggering using a new `deferSnapshot` option. This ensures the status listener is - registered BEFORE any async work starts, so we never miss status transitions. +1. **Core fix - `onStatusChange` option**: Added `onStatusChange` callback option to + `subscribeChanges()`. The listener is registered BEFORE any snapshot is requested, + guaranteeing no status transitions are missed. This replaces the error-prone pattern + of manually deferring snapshots and registering listeners in the correct order. 2. **Ready state gating**: `updateLiveQueryStatus()` now checks `isLoadingSubset` on the live query collection before marking it ready, and listens for `loadingSubset:change` diff --git a/packages/db/src/collection/changes.ts b/packages/db/src/collection/changes.ts index a7be7f308..b0944861b 100644 --- a/packages/db/src/collection/changes.ts +++ b/packages/db/src/collection/changes.ts @@ -107,17 +107,19 @@ export class CollectionChangesManager< }, }) - // When deferSnapshot is true, the caller will manually trigger the snapshot request - // after registering status listeners. This prevents race conditions where the - // loadSubset promise resolves before the listener is registered. - if (!options.deferSnapshot) { - if (options.includeInitialState) { - subscription.requestSnapshot({ trackLoadSubsetPromise: false }) - } else if (options.includeInitialState === false) { - // When explicitly set to false (not just undefined), mark all state as "seen" - // so that all future changes (including deletes) pass through unfiltered. - subscription.markAllStateAsSeen() - } + // Register status listener BEFORE requesting snapshot to avoid race condition. + // This ensures the listener catches all status transitions, even if the + // loadSubset promise resolves synchronously or very quickly. + if (options.onStatusChange) { + subscription.on(`status:change`, options.onStatusChange) + } + + if (options.includeInitialState) { + subscription.requestSnapshot({ trackLoadSubsetPromise: false }) + } else if (options.includeInitialState === false) { + // When explicitly set to false (not just undefined), mark all state as "seen" + // so that all future changes (including deletes) pass through unfiltered. + subscription.markAllStateAsSeen() } // Add to batched listeners diff --git a/packages/db/src/query/live/collection-subscriber.ts b/packages/db/src/query/live/collection-subscriber.ts index 34d5914f1..3f03ba415 100644 --- a/packages/db/src/query/live/collection-subscriber.ts +++ b/packages/db/src/query/live/collection-subscriber.ts @@ -5,7 +5,7 @@ import { } from '../compiler/expressions.js' import type { MultiSetArray, RootStreamBuilder } from '@tanstack/db-ivm' import type { Collection } from '../../collection/index.js' -import type { ChangeMessage } from '../../types.js' +import type { ChangeMessage, SubscriptionStatusChangeEvent } from '../../types.js' import type { Context, GetResult } from '../builder/types.js' import type { BasicExpression } from '../ir.js' import type { OrderByOptimizationInfo } from '../compiler/order-by.js' @@ -53,34 +53,10 @@ export class CollectionSubscriber< } private subscribeToChanges(whereExpression?: BasicExpression) { - // Step 1: Create subscription and get deferred snapshot trigger - // The subscription is created but snapshot request is NOT triggered yet. - // This allows us to register the status listener BEFORE any async work starts. - let subscription: CollectionSubscription - let triggerSnapshot: () => void const orderByInfo = this.getOrderByInfo() - if (orderByInfo) { - const result = this.subscribeToOrderedChanges( - whereExpression, - orderByInfo, - ) - subscription = result.subscription - triggerSnapshot = result.triggerSnapshot - } else { - // If the source alias is lazy then we should not include the initial state - const includeInitialState = !this.collectionConfigBuilder.isLazyAlias( - this.alias, - ) - const result = this.subscribeToMatchingChanges( - whereExpression, - includeInitialState, - ) - subscription = result.subscription - triggerSnapshot = result.triggerSnapshot - } - - const trackLoadPromise = () => { + // Track load promises using subscription from the event (avoids circular dependency) + const trackLoadPromise = (subscription: CollectionSubscription) => { // Guard against duplicate transitions if (!this.subscriptionLoadingPromises.has(subscription)) { let resolve: () => void @@ -97,12 +73,12 @@ export class CollectionSubscriber< } } - // Step 2: Register status listener BEFORE triggering snapshot. - // This ensures we don't miss any status transitions, even if the loadSubset - // promise resolves synchronously or very quickly. - const statusUnsubscribe = subscription.on(`status:change`, (event) => { + // Status change handler - passed to subscribeChanges so it's registered + // BEFORE any snapshot is requested, preventing race conditions + const onStatusChange = (event: SubscriptionStatusChangeEvent) => { + const subscription = event.subscription as CollectionSubscription if (event.status === `loadingSubset`) { - trackLoadPromise() + trackLoadPromise(subscription) } else { // status is 'ready' const deferred = this.subscriptionLoadingPromises.get(subscription) @@ -112,16 +88,33 @@ export class CollectionSubscriber< deferred.resolve() } } - }) + } + + // Create subscription with onStatusChange - listener is registered before any async work + let subscription: CollectionSubscription + if (orderByInfo) { + subscription = this.subscribeToOrderedChanges( + whereExpression, + orderByInfo, + onStatusChange, + ) + } else { + // If the source alias is lazy then we should not include the initial state + const includeInitialState = !this.collectionConfigBuilder.isLazyAlias( + this.alias, + ) - // Step 3: NOW trigger the snapshot request. - // The status listener is already registered, so we'll catch any status transitions. - triggerSnapshot() + subscription = this.subscribeToMatchingChanges( + whereExpression, + includeInitialState, + onStatusChange, + ) + } - // Check current status after triggering - if status is 'loadingSubset', track it. - // The listener above will catch the transition to 'ready'. + // Check current status after subscribing - if status is 'loadingSubset', track it. + // The onStatusChange listener will catch the transition to 'ready'. if (subscription.status === `loadingSubset`) { - trackLoadPromise() + trackLoadPromise(subscription) } const unsubscribe = () => { @@ -133,7 +126,6 @@ export class CollectionSubscriber< deferred.resolve() } - statusUnsubscribe() subscription.unsubscribe() } // currentSyncState is always defined when subscribe() is called @@ -193,55 +185,55 @@ export class CollectionSubscriber< private subscribeToMatchingChanges( whereExpression: BasicExpression | undefined, - includeInitialState: boolean = false, - ): { subscription: CollectionSubscription; triggerSnapshot: () => void } { + includeInitialState: boolean, + onStatusChange: (event: SubscriptionStatusChangeEvent) => void, + ): CollectionSubscription { const sendChanges = ( changes: Array>, ) => { this.sendChangesToPipeline(changes) } - // Create subscription WITHOUT triggering snapshot - pass deferSnapshot: true - // This allows the caller to register status listeners before any async work starts + // 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 const subscription = this.collection.subscribeChanges(sendChanges, { ...(includeInitialState && { includeInitialState }), whereExpression, - deferSnapshot: true, + onStatusChange, }) - // Return the subscription and a function to trigger the snapshot later - // Note: For non-ordered queries (no limit/offset), we use trackLoadSubsetPromise: false - // to match the original behavior. The race condition fix is primarily needed for - // ordered queries with pagination where Electric's on-demand sync needs to wait - // for subset data before marking ready. - const triggerSnapshot = () => { - if (includeInitialState) { - subscription.requestSnapshot({ trackLoadSubsetPromise: false }) - } - } - - return { subscription, triggerSnapshot } + return subscription } private subscribeToOrderedChanges( whereExpression: BasicExpression | undefined, orderByInfo: OrderByOptimizationInfo, - ): { subscription: CollectionSubscription; triggerSnapshot: () => void } { + onStatusChange: (event: SubscriptionStatusChangeEvent) => void, + ): CollectionSubscription { const { orderBy, offset, limit, index } = orderByInfo + // Use a holder to forward-reference subscription in the callback + const subscriptionHolder: { current?: CollectionSubscription } = {} + const sendChangesInRange = ( changes: Iterable>, ) => { // Split live updates into a delete of the old value and an insert of the new value const splittedChanges = splitUpdates(changes) - this.sendChangesToPipelineWithTracking(splittedChanges, subscription) + this.sendChangesToPipelineWithTracking( + splittedChanges, + subscriptionHolder.current!, + ) } - // Subscribe to changes and only send changes that are smaller than the biggest value we've sent so far - // values that are bigger don't need to be sent because they can't affect the topK + // Subscribe to changes with onStatusChange - listener is registered before any snapshot + // values bigger than what we've sent don't need to be sent because they can't affect the topK const subscription = this.collection.subscribeChanges(sendChangesInRange, { whereExpression, + onStatusChange, }) + subscriptionHolder.current = subscription // Listen for truncate events to reset cursor tracking state and sentToD2Keys // This ensures that after a must-refetch/truncate, we don't use stale cursor data @@ -259,34 +251,30 @@ export class CollectionSubscriber< // Normalize the orderBy clauses such that the references are relative to the collection const normalizedOrderBy = normalizeOrderByPaths(orderBy, this.alias) - // Return the subscription and a deferred function to trigger the snapshot. - // The caller will register status listeners before calling triggerSnapshot, - // ensuring we don't miss any status transitions (even if loadSubset resolves quickly). - const triggerSnapshot = () => { - if (index) { - // We have an index on the first orderBy column - use lazy loading optimization - // This works for both single-column and multi-column orderBy: - // - Single-column: index provides exact ordering - // - Multi-column: index provides ordering on first column, secondary sort in memory - subscription.setOrderByIndex(index) - - // Load the first `offset + limit` values from the index - // i.e. the K items from the collection that fall into the requested range: [offset, offset + limit[ - subscription.requestLimitedSnapshot({ - limit: offset + limit, - orderBy: normalizedOrderBy, - }) - } else { - // No index available (e.g., non-ref expression): pass orderBy/limit to loadSubset - // so the sync layer can optimize if the backend supports it - subscription.requestSnapshot({ - orderBy: normalizedOrderBy, - limit: offset + limit, - }) - } + // Trigger the snapshot request - onStatusChange listener is already registered + if (index) { + // We have an index on the first orderBy column - use lazy loading optimization + // This works for both single-column and multi-column orderBy: + // - Single-column: index provides exact ordering + // - Multi-column: index provides ordering on first column, secondary sort in memory + subscription.setOrderByIndex(index) + + // Load the first `offset + limit` values from the index + // i.e. the K items from the collection that fall into the requested range: [offset, offset + limit[ + subscription.requestLimitedSnapshot({ + limit: offset + limit, + orderBy: normalizedOrderBy, + }) + } else { + // No index available (e.g., non-ref expression): pass orderBy/limit to loadSubset + // so the sync layer can optimize if the backend supports it + subscription.requestSnapshot({ + orderBy: normalizedOrderBy, + limit: offset + limit, + }) } - return { subscription, triggerSnapshot } + return subscription } // This function is called by maybeRunGraph diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 9b9505a92..14eeba4d6 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -781,12 +781,11 @@ export interface SubscribeChangesOptions { /** Pre-compiled expression for filtering changes */ whereExpression?: BasicExpression /** - * When true, defers the snapshot request so the caller can register status - * listeners before any async work starts. The caller must call - * subscription.requestSnapshot() manually. + * Listener for subscription status changes. + * Registered BEFORE any snapshot is requested, ensuring no status transitions are missed. * @internal */ - deferSnapshot?: boolean + onStatusChange?: (event: SubscriptionStatusChangeEvent) => void } export interface SubscribeChangesSnapshotOptions extends Omit< From 5c3ab10745abc8203d30a9de27e07a7fe98b7952 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 8 Jan 2026 22:58:09 +0000 Subject: [PATCH 20/20] ci: apply automated fixes --- packages/db/src/query/live/collection-subscriber.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/db/src/query/live/collection-subscriber.ts b/packages/db/src/query/live/collection-subscriber.ts index 3f03ba415..ec4876b74 100644 --- a/packages/db/src/query/live/collection-subscriber.ts +++ b/packages/db/src/query/live/collection-subscriber.ts @@ -5,7 +5,10 @@ import { } from '../compiler/expressions.js' import type { MultiSetArray, RootStreamBuilder } from '@tanstack/db-ivm' import type { Collection } from '../../collection/index.js' -import type { ChangeMessage, SubscriptionStatusChangeEvent } from '../../types.js' +import type { + ChangeMessage, + SubscriptionStatusChangeEvent, +} from '../../types.js' import type { Context, GetResult } from '../builder/types.js' import type { BasicExpression } from '../ir.js' import type { OrderByOptimizationInfo } from '../compiler/order-by.js'