Skip to content
5 changes: 5 additions & 0 deletions .changeset/fast-crabs-change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tanstack/db": patch
---

Ensure LiveQueryCollections are properly transitioning to ready state when source collections are preloaded after creation of the live query collection
3 changes: 1 addition & 2 deletions packages/db/src/collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -393,9 +393,8 @@ export class CollectionImpl<
this.onFirstReadyCallbacks = []
callbacks.forEach((callback) => callback())

// If the collection is empty when it becomes ready, emit an empty change event
// to notify subscribers (like LiveQueryCollection) that the collection is ready
if (this.size === 0 && this.changeListeners.size > 0) {
if (this.changeListeners.size > 0) {
this.emitEmptyReadyEvent()
}
}
Expand Down
99 changes: 99 additions & 0 deletions packages/db/tests/query/live-query-collection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createCollection } from "../../src/collection.js"
import { createLiveQueryCollection, eq } from "../../src/query/index.js"
import { Query } from "../../src/query/builder/index.js"
import { mockSyncCollectionOptions } from "../utls.js"
import type { ChangeMessage } from "../../src/types.js"

// Sample user type for tests
type User = {
Expand Down Expand Up @@ -189,4 +190,102 @@ describe(`createLiveQueryCollection`, () => {
})
expect(liveQuery.isReady()).toBe(false)
})

it(`should update after source collection is loaded even when not preloaded before rendering`, async () => {
// Create a source collection that doesn't start sync immediately
let beginCallback: (() => void) | undefined
let writeCallback:
| ((message: Omit<ChangeMessage<User, string | number>, `key`>) => void)
| undefined
let markReadyCallback: (() => void) | undefined
let commitCallback: (() => void) | undefined

const sourceCollection = createCollection<User>({
id: `delayed-source-collection`,
getKey: (user) => user.id,
startSync: false, // Don't start sync immediately
sync: {
sync: ({ begin, commit, write, markReady }) => {
beginCallback = begin
commitCallback = commit
markReadyCallback = markReady
writeCallback = write
return () => {} // cleanup function
},
},
onInsert: ({ transaction }) => {
const newItem = transaction.mutations[0].modified
// We need to call begin, write, and commit to properly sync the data
beginCallback!()
writeCallback!({
type: `insert`,
value: newItem,
})
commitCallback!()
return Promise.resolve()
},
onUpdate: () => Promise.resolve(),
onDelete: () => Promise.resolve(),
})

// Create a live query collection BEFORE the source collection is preloaded
// This simulates the scenario where the live query is created during rendering
// but the source collection hasn't been preloaded yet
const liveQuery = createLiveQueryCollection((q) =>
q
.from({ user: sourceCollection })
.where(({ user }) => eq(user.active, true))
)

// Initially, the live query should be in idle state (default startSync: false)
expect(liveQuery.status).toBe(`idle`)
expect(liveQuery.size).toBe(0)

// Now preload the source collection (simulating what happens after rendering)
sourceCollection.preload()

// Store the promise so we can wait for it later
const preloadPromise = liveQuery.preload()

// Trigger the initial data load first
if (beginCallback && writeCallback && commitCallback && markReadyCallback) {
beginCallback()
// Write initial data
writeCallback({
type: `insert`,
value: { id: 1, name: `Alice`, active: true },
})
writeCallback({
type: `insert`,
value: { id: 2, name: `Bob`, active: false },
})
writeCallback({
type: `insert`,
value: { id: 3, name: `Charlie`, active: true },
})
commitCallback()
markReadyCallback()
}

// Wait for the preload to complete
await preloadPromise

// The live query should be ready and have the initial data
expect(liveQuery.size).toBe(2) // Alice and Charlie are active
expect(liveQuery.get(1)).toEqual({ id: 1, name: `Alice`, active: true })
expect(liveQuery.get(3)).toEqual({ id: 3, name: `Charlie`, active: true })
expect(liveQuery.get(2)).toBeUndefined() // Bob is not active
// This test should fail because the live query is stuck in 'initialCommit' status
expect(liveQuery.status).toBe(`ready`) // This should be 'ready' but is currently 'initialCommit'

// Now add some new data to the source collection (this should work as per the original report)
sourceCollection.insert({ id: 4, name: `David`, active: true })

// Wait for the mutation to propagate
await new Promise((resolve) => setTimeout(resolve, 10))

// The live query should update to include the new data
expect(liveQuery.size).toBe(3) // Alice, Charlie, and David are active
expect(liveQuery.get(4)).toEqual({ id: 4, name: `David`, active: true })
})
})