Skip to content
9 changes: 9 additions & 0 deletions .changeset/clever-ducks-strive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@tanstack/svelte-db": patch
"@tanstack/react-db": patch
"@tanstack/solid-db": patch
"@tanstack/vue-db": patch
"@tanstack/db": patch
---

Ensure that the ready status is correctly returned from a live query
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
9 changes: 7 additions & 2 deletions packages/db/src/collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,9 +392,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 Expand Up @@ -957,6 +956,12 @@ export class CollectionImpl<
for (const listener of this.changeListeners) {
listener([])
}
// Emit to key-specific listeners
for (const [_key, keyListeners] of this.changeKeyListeners) {
for (const listener of keyListeners) {
listener([])
}
}
}

/**
Expand Down
12 changes: 10 additions & 2 deletions packages/db/src/query/live-query-collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,12 @@ export function liveQueryCollectionOptions<
const collections = extractCollectionsFromQuery(query)

const allCollectionsReady = () => {
return Object.values(collections).every(
(collection) => collection.status === `ready`
)
}

const allCollectionsReadyOrInitialCommit = () => {
return Object.values(collections).every(
(collection) =>
collection.status === `ready` || collection.status === `initialCommit`
Expand Down Expand Up @@ -294,7 +300,7 @@ export function liveQueryCollectionOptions<

const maybeRunGraph = () => {
// We only run the graph if all the collections are ready
if (allCollectionsReady()) {
if (allCollectionsReadyOrInitialCommit()) {
graph.run()
// On the initial run, we may need to do an empty commit to ensure that
// the collection is initialized
Expand All @@ -303,7 +309,9 @@ export function liveQueryCollectionOptions<
commit()
}
// Mark the collection as ready after the first successful run
markReady()
if (allCollectionsReady()) {
markReady()
}
}
}

Expand Down
9 changes: 6 additions & 3 deletions packages/db/tests/query/indexes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ describe(`Query Index Optimization`, () => {
getKey: (item) => item.id,
startSync: true,
sync: {
sync: ({ begin, write, commit }) => {
sync: ({ begin, write, commit, markReady }) => {
// Provide initial data through sync
begin()
for (const item of testData) {
Expand All @@ -247,6 +247,7 @@ describe(`Query Index Optimization`, () => {
})
}
commit()
markReady()

// Listen for mutations and sync them back (only register once)
if (!emitter.all.has(`sync`)) {
Expand Down Expand Up @@ -534,7 +535,7 @@ describe(`Query Index Optimization`, () => {
getKey: (item) => item.id,
startSync: true,
sync: {
sync: ({ begin, write, commit }) => {
sync: ({ begin, write, commit, markReady }) => {
begin()
write({
type: `insert`,
Expand All @@ -547,6 +548,7 @@ describe(`Query Index Optimization`, () => {
},
})
commit()
markReady()
},
},
})
Expand Down Expand Up @@ -622,7 +624,7 @@ describe(`Query Index Optimization`, () => {
getKey: (item) => item.id,
startSync: true,
sync: {
sync: ({ begin, write, commit }) => {
sync: ({ begin, write, commit, markReady }) => {
begin()
write({
type: `insert`,
Expand All @@ -645,6 +647,7 @@ describe(`Query Index Optimization`, () => {
},
})
commit()
markReady()
},
},
})
Expand Down
118 changes: 118 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 @@ -170,4 +171,121 @@ describe(`createLiveQueryCollection`, () => {
expect(liveQuery.status).toBe(`ready`)
expect(liveQuery.size).toBe(0)
})

it(`shouldn't call markReady when source collection sync doesn't call markReady`, () => {
const collection = createCollection<{ id: string }>({
sync: {
sync({ begin, commit }) {
begin()
commit()
},
},
getKey: (item) => item.id,
startSync: true,
})

const liveQuery = createLiveQueryCollection({
query: (q) => q.from({ collection }),
startSync: true,
})
expect(liveQuery.isReady()).toBe(false)
})

it(`should update after source collection is loaded even when not preloaded before rendering - REPRODUCES BUG`, 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 })
})
})
27 changes: 15 additions & 12 deletions packages/react-db/tests/useLiveQuery.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1077,15 +1077,17 @@ describe(`Query Collections`, () => {
it(`should update isLoading when collection status changes`, async () => {
let beginFn: (() => void) | undefined
let commitFn: (() => void) | undefined
let markReadyFn: (() => void) | undefined

const collection = createCollection<Person>({
id: `status-change-has-loaded-test`,
getKey: (person: Person) => person.id,
startSync: false,
sync: {
sync: ({ begin, commit }) => {
sync: ({ begin, commit, markReady }) => {
beginFn = begin
commitFn = commit
markReadyFn = markReady
// Don't sync immediately
},
},
Expand Down Expand Up @@ -1116,9 +1118,10 @@ describe(`Query Collections`, () => {

// Trigger the first commit to make collection ready
act(() => {
if (beginFn && commitFn) {
if (beginFn && commitFn && markReadyFn) {
beginFn()
commitFn()
markReadyFn()
}
})

Expand Down Expand Up @@ -1202,8 +1205,10 @@ describe(`Query Collections`, () => {
it(`should handle isLoading with complex queries including joins`, async () => {
let personBeginFn: (() => void) | undefined
let personCommitFn: (() => void) | undefined
let personMarkReadyFn: (() => void) | undefined
let issueBeginFn: (() => void) | undefined
let issueCommitFn: (() => void) | undefined
let issueMarkReadyFn: (() => void) | undefined

const personCollection = createCollection<Person>({
id: `join-has-loaded-persons`,
Expand All @@ -1212,10 +1217,8 @@ describe(`Query Collections`, () => {
sync: {
sync: ({ begin, commit, markReady }) => {
personBeginFn = begin
personCommitFn = () => {
commit()
markReady()
}
personCommitFn = commit
personMarkReadyFn = markReady
// Don't sync immediately
},
},
Expand All @@ -1231,10 +1234,8 @@ describe(`Query Collections`, () => {
sync: {
sync: ({ begin, commit, markReady }) => {
issueBeginFn = begin
issueCommitFn = () => {
commit()
markReady()
}
issueCommitFn = commit
issueMarkReadyFn = markReady
// Don't sync immediately
},
},
Expand Down Expand Up @@ -1269,13 +1270,15 @@ describe(`Query Collections`, () => {

// Trigger the first commit for both collections to make them ready
act(() => {
if (personBeginFn && personCommitFn) {
if (personBeginFn && personCommitFn && personMarkReadyFn) {
personBeginFn()
personCommitFn()
personMarkReadyFn()
}
if (issueBeginFn && issueCommitFn) {
if (issueBeginFn && issueCommitFn && issueMarkReadyFn) {
issueBeginFn()
issueCommitFn()
issueMarkReadyFn()
}
})

Expand Down
Loading