Skip to content

Commit e59a355

Browse files
authored
fix stuck empty remounted live query that uses joins bug (#484)
1 parent 074aab0 commit e59a355

3 files changed

Lines changed: 137 additions & 7 deletions

File tree

.changeset/deep-bushes-sell.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@tanstack/db": patch
3+
---
4+
5+
fix an bug where a live query that used joins could become stuck empty when its remounted/resubscribed

packages/db/src/query/live/collection-config-builder.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,15 +49,11 @@ export class CollectionConfigBuilder<
4949
| undefined
5050

5151
// Map of collection IDs to functions that load keys for that lazy collection
52-
readonly lazyCollectionsCallbacks: Record<string, LazyCollectionCallbacks> =
53-
{}
52+
lazyCollectionsCallbacks: Record<string, LazyCollectionCallbacks> = {}
5453
// Set of collection IDs that are lazy collections
5554
readonly lazyCollections = new Set<string>()
5655
// Set of collection IDs that include an optimizable ORDER BY clause
57-
readonly optimizableOrderByCollections: Record<
58-
string,
59-
OrderByOptimizationInfo
60-
> = {}
56+
optimizableOrderByCollections: Record<string, OrderByOptimizationInfo> = {}
6157

6258
constructor(
6359
private readonly config: LiveQueryCollectionConfig<TContext, TResult>
@@ -168,6 +164,11 @@ export class CollectionConfigBuilder<
168164
this.inputsCache = undefined
169165
this.pipelineCache = undefined
170166
this.collectionWhereClausesCache = undefined
167+
168+
// Reset lazy collection state
169+
this.lazyCollections.clear()
170+
this.optimizableOrderByCollections = {}
171+
this.lazyCollectionsCallbacks = {}
171172
}
172173
}
173174

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

Lines changed: 125 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { beforeEach, describe, expect, it } from "vitest"
22
import { Temporal } from "temporal-polyfill"
33
import { createCollection } from "../../src/collection.js"
4-
import { createLiveQueryCollection, eq } from "../../src/query/index.js"
4+
import {
5+
createLiveQueryCollection,
6+
eq,
7+
liveQueryCollectionOptions,
8+
} from "../../src/query/index.js"
59
import { Query } from "../../src/query/builder/index.js"
610
import {
711
mockSyncCollectionOptions,
@@ -318,6 +322,126 @@ describe(`createLiveQueryCollection`, () => {
318322
expect(() => liveQuery.subscribeChanges(() => {})).not.toThrow()
319323
})
320324

325+
it(`nested live query should not go blank after GC and resubscribe`, async () => {
326+
type Thread = { id: string; last_email_id: string; last_sent_at: number }
327+
type LabelByEmail = { email_id: string; label: string }
328+
329+
const threads = createCollection(
330+
mockSyncCollectionOptions<Thread>({
331+
id: `threads-for-nested-gc-repro-collection`,
332+
getKey: (t) => t.id,
333+
initialData: [
334+
{ id: `t1`, last_email_id: `e1`, last_sent_at: 3 },
335+
{ id: `t2`, last_email_id: `e2`, last_sent_at: 2 },
336+
],
337+
})
338+
)
339+
340+
const labelsByEmail = createCollection(
341+
mockSyncCollectionOptions<LabelByEmail>({
342+
id: `labels-for-nested-gc-repro-collection`,
343+
getKey: (l) => l.email_id,
344+
initialData: [
345+
{ email_id: `e1`, label: `inbox` },
346+
{ email_id: `e2`, label: `work` },
347+
],
348+
})
349+
)
350+
351+
// Source live query (pre-created)
352+
const sourceLQ = createCollection({
353+
...liveQueryCollectionOptions({
354+
query: (q: any) =>
355+
q
356+
.from({ thread: threads })
357+
.orderBy(({ thread }: any) => thread.last_sent_at, {
358+
direction: `desc`,
359+
}),
360+
startSync: true,
361+
gcTime: 5,
362+
}),
363+
id: `source-lq`,
364+
})
365+
366+
// Nested live query built from the source live query
367+
const nestedLQ = createCollection({
368+
...liveQueryCollectionOptions({
369+
query: (q: any) =>
370+
q
371+
.from({ thread: sourceLQ })
372+
.join(
373+
{ label: labelsByEmail },
374+
({ thread, label }: any) =>
375+
eq(thread.last_email_id, label.email_id),
376+
`inner`
377+
)
378+
.orderBy(({ thread }: any) => thread.last_sent_at, {
379+
direction: `desc`,
380+
}),
381+
startSync: true,
382+
gcTime: 5,
383+
}),
384+
id: `nested-lq`,
385+
})
386+
387+
// Wait for initial sync
388+
await nestedLQ.preload()
389+
expect(nestedLQ.size).toBe(2)
390+
expect(nestedLQ.status).toBe(`ready`)
391+
392+
// First subscription cycle
393+
const unsubscribe1 = nestedLQ.subscribeChanges(() => {})
394+
395+
// Verify we still have data after subscribing
396+
expect(nestedLQ.size).toBe(2)
397+
expect(nestedLQ.status).toBe(`ready`)
398+
399+
// Unsubscribe and wait for GC
400+
unsubscribe1()
401+
const deadline1 = Date.now() + 500
402+
while (nestedLQ.status !== `cleaned-up` && Date.now() < deadline1) {
403+
await new Promise((r) => setTimeout(r, 1))
404+
}
405+
expect(nestedLQ.status).toBe(`cleaned-up`)
406+
407+
// Try multiple resubscribe cycles to increase chance of reproduction
408+
for (let i = 0; i < 3; i++) {
409+
// Resubscribe
410+
const unsubscribe2 = nestedLQ.subscribeChanges(() => {})
411+
412+
// Wait for the collection to potentially recover
413+
await new Promise((r) => setTimeout(r, 50))
414+
415+
expect(nestedLQ.status).toBe(`ready`)
416+
expect(nestedLQ.size).toBe(2)
417+
418+
// Unsubscribe and wait for GC again
419+
unsubscribe2()
420+
const deadline2 = Date.now() + 500
421+
while (nestedLQ.status !== `cleaned-up` && Date.now() < deadline2) {
422+
await new Promise((r) => setTimeout(r, 1))
423+
}
424+
expect(nestedLQ.status).toBe(`cleaned-up`)
425+
426+
// Small delay between cycles
427+
await new Promise((r) => setTimeout(r, 20))
428+
}
429+
430+
// Final verification - resubscribe one more time and ensure data is available
431+
const finalUnsubscribe = nestedLQ.subscribeChanges(() => {})
432+
433+
// Wait for the collection to become ready
434+
const finalDeadline = Date.now() + 1000
435+
while (nestedLQ.status !== `ready` && Date.now() < finalDeadline) {
436+
await new Promise((r) => setTimeout(r, 10))
437+
}
438+
439+
expect(nestedLQ.status).toBe(`ready`)
440+
expect(nestedLQ.size).toBe(2)
441+
442+
finalUnsubscribe()
443+
})
444+
321445
it(`should handle temporal values correctly in live queries`, async () => {
322446
// Define a type with temporal values
323447
type Task = {

0 commit comments

Comments
 (0)