Skip to content

Commit e04bd12

Browse files
KyleAMathewsclaude
andauthored
fix: ensure LiveQueryCollection markReady is called when source collections have no data (#309)
Co-authored-by: Claude <[email protected]>
1 parent 2b489e2 commit e04bd12

File tree

5 files changed

+150
-1
lines changed

5 files changed

+150
-1
lines changed

.changeset/ninety-parts-check.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@tanstack/query-db-collection": patch
3+
"@tanstack/db": patch
4+
---
5+
6+
Fix LiveQueryCollection hanging when source collections have no data
7+
8+
Fixed an issue where `LiveQueryCollection.preload()` would hang indefinitely when source collections call `markReady()` without data changes (e.g., when queryFn returns empty array).
9+
10+
The fix implements a proper event-based solution:
11+
12+
- Collections now emit empty change events when becoming ready with no data
13+
- WHERE clause filtered subscriptions now correctly pass through empty ready signals
14+
- Both regular and WHERE clause optimized LiveQueryCollections now work correctly with empty source collections

packages/db/src/change-events.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,9 @@ export function createFilteredCallback<T extends object>(
250250
}
251251
}
252252

253-
if (filteredChanges.length > 0) {
253+
// Always call the original callback if we have filtered changes OR
254+
// if the original changes array was empty (which indicates a ready signal)
255+
if (filteredChanges.length > 0 || changes.length === 0) {
254256
originalCallback(filteredChanges)
255257
}
256258
}

packages/db/src/collection.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,12 @@ export class CollectionImpl<
318318
const callbacks = [...this.onFirstReadyCallbacks]
319319
this.onFirstReadyCallbacks = []
320320
callbacks.forEach((callback) => callback())
321+
322+
// If the collection is empty when it becomes ready, emit an empty change event
323+
// to notify subscribers (like LiveQueryCollection) that the collection is ready
324+
if (this.size === 0 && this.changeListeners.size > 0) {
325+
this.emitEmptyReadyEvent()
326+
}
321327
}
322328
}
323329
}
@@ -882,6 +888,17 @@ export class CollectionImpl<
882888
return this.syncedData.get(key)
883889
}
884890

891+
/**
892+
* Emit an empty ready event to notify subscribers that the collection is ready
893+
* This bypasses the normal empty array check in emitEvents
894+
*/
895+
private emitEmptyReadyEvent(): void {
896+
// Emit empty array directly to all listeners
897+
for (const listener of this.changeListeners) {
898+
listener([])
899+
}
900+
}
901+
885902
/**
886903
* Emit events either immediately or batch them for later emission
887904
*/

packages/db/tests/query/live-query-collection.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,4 +88,86 @@ describe(`createLiveQueryCollection`, () => {
8888
expect(activeUsers1.size).toBe(2)
8989
expect(activeUsers2.size).toBe(2)
9090
})
91+
92+
it(`should call markReady when source collection returns empty array`, async () => {
93+
// Create an empty source collection using the mock sync options
94+
const emptyUsersCollection = createCollection(
95+
mockSyncCollectionOptions<User>({
96+
id: `empty-test-users`,
97+
getKey: (user) => user.id,
98+
initialData: [], // Empty initial data
99+
})
100+
)
101+
102+
// Create a live query collection that depends on the empty source collection
103+
const liveQuery = createLiveQueryCollection((q) =>
104+
q
105+
.from({ user: emptyUsersCollection })
106+
.where(({ user }) => eq(user.active, true))
107+
)
108+
109+
// This should resolve and not hang, even though the source collection is empty
110+
await liveQuery.preload()
111+
112+
expect(liveQuery.status).toBe(`ready`)
113+
expect(liveQuery.size).toBe(0)
114+
})
115+
116+
it(`should call markReady when source collection sync doesn't call begin/commit (without WHERE clause)`, async () => {
117+
// Create a collection with sync that only calls markReady (like the reproduction case)
118+
const problemCollection = createCollection<User>({
119+
id: `problem-collection`,
120+
sync: {
121+
sync: ({ markReady }) => {
122+
// Simulate async operation without begin/commit (like empty queryFn case)
123+
setTimeout(() => {
124+
markReady()
125+
}, 50)
126+
return () => {} // cleanup function
127+
},
128+
},
129+
getKey: (user) => user.id,
130+
})
131+
132+
// Create a live query collection that depends on the problematic source collection
133+
const liveQuery = createLiveQueryCollection((q) =>
134+
q.from({ user: problemCollection })
135+
)
136+
137+
// This should resolve and not hang, even though the source collection doesn't commit data
138+
await liveQuery.preload()
139+
140+
expect(liveQuery.status).toBe(`ready`)
141+
expect(liveQuery.size).toBe(0)
142+
})
143+
144+
it(`should call markReady when source collection sync doesn't call begin/commit (with WHERE clause)`, async () => {
145+
// Create a collection with sync that only calls markReady (like the reproduction case)
146+
const problemCollection = createCollection<User>({
147+
id: `problem-collection-where`,
148+
sync: {
149+
sync: ({ markReady }) => {
150+
// Simulate async operation without begin/commit (like empty queryFn case)
151+
setTimeout(() => {
152+
markReady()
153+
}, 50)
154+
return () => {} // cleanup function
155+
},
156+
},
157+
getKey: (user) => user.id,
158+
})
159+
160+
// Create a live query collection that depends on the problematic source collection
161+
const liveQuery = createLiveQueryCollection((q) =>
162+
q
163+
.from({ user: problemCollection })
164+
.where(({ user }) => eq(user.active, true))
165+
)
166+
167+
// This should resolve and not hang, even though the source collection doesn't commit data
168+
await liveQuery.preload()
169+
170+
expect(liveQuery.status).toBe(`ready`)
171+
expect(liveQuery.size).toBe(0)
172+
})
91173
})

packages/query-db-collection/tests/query.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1016,4 +1016,38 @@ describe(`QueryCollection`, () => {
10161016
expect(finalItem?.name).toMatch(/^Item \d+$/)
10171017
})
10181018
})
1019+
1020+
it(`should call markReady when queryFn returns an empty array`, async () => {
1021+
const queryKey = [`emptyArrayTest`]
1022+
const queryFn = vi.fn().mockResolvedValue([])
1023+
1024+
const config: QueryCollectionConfig<TestItem> = {
1025+
id: `test`,
1026+
queryClient,
1027+
queryKey,
1028+
queryFn,
1029+
getKey,
1030+
startSync: true,
1031+
}
1032+
1033+
const options = queryCollectionOptions(config)
1034+
const collection = createCollection(options)
1035+
1036+
// Wait for the query to complete
1037+
await vi.waitFor(
1038+
() => {
1039+
expect(queryFn).toHaveBeenCalledTimes(1)
1040+
// The collection should be marked as ready even with empty array
1041+
expect(collection.status).toBe(`ready`)
1042+
},
1043+
{
1044+
timeout: 1000,
1045+
interval: 50,
1046+
}
1047+
)
1048+
1049+
// Verify the collection is empty but ready
1050+
expect(collection.size).toBe(0)
1051+
expect(collection.status).toBe(`ready`)
1052+
})
10191053
})

0 commit comments

Comments
 (0)