diff --git a/.changeset/clever-ducks-strive.md b/.changeset/clever-ducks-strive.md new file mode 100644 index 00000000..5f63228a --- /dev/null +++ b/.changeset/clever-ducks-strive.md @@ -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 diff --git a/packages/db/src/collection.ts b/packages/db/src/collection.ts index eea7905c..0bb3fc14 100644 --- a/packages/db/src/collection.ts +++ b/packages/db/src/collection.ts @@ -957,6 +957,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([]) + } + } } /** diff --git a/packages/db/src/query/live-query-collection.ts b/packages/db/src/query/live-query-collection.ts index 9d7877f5..12fbd8c5 100644 --- a/packages/db/src/query/live-query-collection.ts +++ b/packages/db/src/query/live-query-collection.ts @@ -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` @@ -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 @@ -303,7 +309,9 @@ export function liveQueryCollectionOptions< commit() } // Mark the collection as ready after the first successful run - markReady() + if (allCollectionsReady()) { + markReady() + } } } diff --git a/packages/db/tests/query/indexes.test.ts b/packages/db/tests/query/indexes.test.ts index 5dfad097..06dab016 100644 --- a/packages/db/tests/query/indexes.test.ts +++ b/packages/db/tests/query/indexes.test.ts @@ -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) { @@ -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`)) { @@ -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`, @@ -547,6 +548,7 @@ describe(`Query Index Optimization`, () => { }, }) commit() + markReady() }, }, }) @@ -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`, @@ -645,6 +647,7 @@ describe(`Query Index Optimization`, () => { }, }) commit() + markReady() }, }, }) diff --git a/packages/db/tests/query/live-query-collection.test.ts b/packages/db/tests/query/live-query-collection.test.ts index 0ebcf4d4..15bd930d 100644 --- a/packages/db/tests/query/live-query-collection.test.ts +++ b/packages/db/tests/query/live-query-collection.test.ts @@ -170,4 +170,23 @@ 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) + }) }) diff --git a/packages/react-db/tests/useLiveQuery.test.tsx b/packages/react-db/tests/useLiveQuery.test.tsx index 9814dd07..69ed9b49 100644 --- a/packages/react-db/tests/useLiveQuery.test.tsx +++ b/packages/react-db/tests/useLiveQuery.test.tsx @@ -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({ 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 }, }, @@ -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() } }) @@ -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({ id: `join-has-loaded-persons`, @@ -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 }, }, @@ -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 }, }, @@ -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() } }) diff --git a/packages/solid-db/tests/useLiveQuery.test.tsx b/packages/solid-db/tests/useLiveQuery.test.tsx index e8f58ca3..43ed8b2c 100644 --- a/packages/solid-db/tests/useLiveQuery.test.tsx +++ b/packages/solid-db/tests/useLiveQuery.test.tsx @@ -1053,15 +1053,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({ 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 }, }, @@ -1089,9 +1091,10 @@ describe(`Query Collections`, () => { collection.preload() // Trigger the first commit to make collection ready - if (beginFn && commitFn) { + if (beginFn && commitFn && markReadyFn) { beginFn() commitFn() + markReadyFn() } // Insert data @@ -1172,8 +1175,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({ id: `join-has-loaded-persons`, @@ -1182,10 +1187,8 @@ describe(`Query Collections`, () => { sync: { sync: ({ begin, commit, markReady }) => { personBeginFn = begin - personCommitFn = () => { - commit() - markReady() - } + personCommitFn = commit + personMarkReadyFn = markReady // Don't sync immediately }, }, @@ -1201,10 +1204,8 @@ describe(`Query Collections`, () => { sync: { sync: ({ begin, commit, markReady }) => { issueBeginFn = begin - issueCommitFn = () => { - commit() - markReady() - } + issueCommitFn = commit + issueMarkReadyFn = markReady // Don't sync immediately }, }, @@ -1236,13 +1237,15 @@ describe(`Query Collections`, () => { issueCollection.preload() // Trigger the first commit for both collections to make them ready - if (personBeginFn && personCommitFn) { + if (personBeginFn && personCommitFn && personMarkReadyFn) { personBeginFn() personCommitFn() + personMarkReadyFn() } - if (issueBeginFn && issueCommitFn) { + if (issueBeginFn && issueCommitFn && issueMarkReadyFn) { issueBeginFn() issueCommitFn() + issueMarkReadyFn() } // Insert data into both collections diff --git a/packages/svelte-db/src/useLiveQuery.svelte.ts b/packages/svelte-db/src/useLiveQuery.svelte.ts index 1d078789..99e819c2 100644 --- a/packages/svelte-db/src/useLiveQuery.svelte.ts +++ b/packages/svelte-db/src/useLiveQuery.svelte.ts @@ -1,4 +1,4 @@ -import { untrack } from "svelte" +import { flushSync, untrack } from "svelte" import { createLiveQueryCollection } from "@tanstack/db" import { SvelteMap } from "svelte/reactivity" import type { @@ -246,7 +246,10 @@ export function useLiveQuery( if (isCollection) { // It's already a collection, ensure sync is started for Svelte helpers - unwrappedParam.startSyncImmediate() + // Only start sync if the collection is in idle state + if (unwrappedParam.status === `idle`) { + unwrappedParam.startSyncImmediate() + } return unwrappedParam } @@ -312,6 +315,15 @@ export function useLiveQuery( // Initialize data array in correct order syncDataFromCollection(currentCollection) + // Listen for the first ready event to catch status transitions + // that might not trigger change events (fixes async status transition bug) + currentCollection.onFirstReady(() => { + // Use flushSync to ensure Svelte reactivity updates properly + flushSync(() => { + status = currentCollection.status + }) + }) + // Subscribe to collection changes with granular updates currentUnsubscribe = currentCollection.subscribeChanges( (changes: Array>) => { diff --git a/packages/svelte-db/tests/useLiveQuery.svelte.test.ts b/packages/svelte-db/tests/useLiveQuery.svelte.test.ts index 1d1e982f..7cf9eae2 100644 --- a/packages/svelte-db/tests/useLiveQuery.svelte.test.ts +++ b/packages/svelte-db/tests/useLiveQuery.svelte.test.ts @@ -751,6 +751,7 @@ describe(`Query Collections`, () => { it(`should be false initially and true after collection is ready`, () => { let beginFn: (() => void) | undefined let commitFn: (() => void) | undefined + let markReadyFn: (() => void) | undefined // Create a collection that doesn't start sync immediately const collection = createCollection({ @@ -758,9 +759,10 @@ describe(`Query Collections`, () => { getKey: (person: Person) => person.id, startSync: false, // Don't start sync immediately sync: { - sync: ({ begin, commit }) => { + sync: ({ begin, commit, markReady }) => { beginFn = begin commitFn = commit + markReadyFn = markReady // Don't call begin/commit immediately }, }, @@ -787,9 +789,10 @@ describe(`Query Collections`, () => { collection.preload() // Trigger the first commit to make collection ready - if (beginFn && commitFn) { + if (beginFn && commitFn && markReadyFn) { beginFn() commitFn() + markReadyFn() } // Insert data @@ -874,15 +877,17 @@ describe(`Query Collections`, () => { it(`should update isReady when collection status changes`, () => { let beginFn: (() => void) | undefined let commitFn: (() => void) | undefined + let markReadyFn: (() => void) | undefined const collection = createCollection({ id: `status-change-is-ready-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 }, }, @@ -904,9 +909,10 @@ describe(`Query Collections`, () => { expect(query.isReady).toBe(false) collection.preload() - if (beginFn && commitFn) { + if (beginFn && commitFn && markReadyFn) { beginFn() commitFn() + markReadyFn() } collection.insert({ id: `1`, @@ -921,6 +927,74 @@ describe(`Query Collections`, () => { }) }) + it(`should update isLoading when collection status changes`, () => { + let beginFn: (() => void) | undefined + let commitFn: (() => void) | undefined + let markReadyFn: (() => void) | undefined + + const collection = createCollection({ + id: `status-change-is-loading-test`, + getKey: (person: Person) => person.id, + startSync: false, + sync: { + sync: ({ begin, commit, markReady }) => { + beginFn = begin + commitFn = commit + markReadyFn = markReady + // Don't sync immediately + }, + }, + onInsert: () => Promise.resolve(), + onUpdate: () => Promise.resolve(), + onDelete: () => Promise.resolve(), + }) + + cleanup = $effect.root(() => { + const query = useLiveQuery((q) => + q + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + })) + ) + + // Initially should be true + expect(query.isLoading).toBe(true) + + // Start sync manually + collection.preload() + + // Trigger the first commit to make collection ready + if (beginFn && commitFn && markReadyFn) { + beginFn() + commitFn() + markReadyFn() + } + + // Insert data + collection.insert({ + id: `1`, + name: `John Doe`, + age: 35, + email: `john.doe@example.com`, + isActive: true, + team: `team1`, + }) + + flushSync() + + expect(query.isLoading).toBe(false) + expect(query.isReady).toBe(true) + + // Wait for collection to become ready + flushSync() + expect(query.isLoading).toBe(false) + expect(query.status).toBe(`ready`) + }) + }) + it(`should maintain isReady state during live updates`, () => { const collection = createCollection( mockSyncCollectionOptions({ @@ -965,17 +1039,20 @@ describe(`Query Collections`, () => { it(`should handle isReady with complex queries including joins`, () => { 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({ id: `join-is-ready-persons`, getKey: (person: Person) => person.id, startSync: false, sync: { - sync: ({ begin, commit }) => { + sync: ({ begin, commit, markReady }) => { personBeginFn = begin personCommitFn = commit + personMarkReadyFn = markReady // Don't sync immediately }, }, @@ -990,9 +1067,10 @@ describe(`Query Collections`, () => { getKey: (issue: Issue) => issue.id, startSync: false, sync: { - sync: ({ begin, commit }) => { + sync: ({ begin, commit, markReady }) => { issueBeginFn = begin issueCommitFn = commit + issueMarkReadyFn = markReady // Don't sync immediately }, }, @@ -1017,13 +1095,15 @@ describe(`Query Collections`, () => { expect(query.isReady).toBe(false) personCollection.preload() issueCollection.preload() - if (personBeginFn && personCommitFn) { + if (personBeginFn && personCommitFn && personMarkReadyFn) { personBeginFn() personCommitFn() + personMarkReadyFn() } - if (issueBeginFn && issueCommitFn) { + if (issueBeginFn && issueCommitFn && issueMarkReadyFn) { issueBeginFn() issueCommitFn() + issueMarkReadyFn() } personCollection.insert({ id: `1`, @@ -1044,7 +1124,7 @@ describe(`Query Collections`, () => { }) }) - it(`should handle isReady with parameterized queries`, async () => { + it(`should handle isReady with parameterized queries`, () => { let beginFn: (() => void) | undefined let commitFn: (() => void) | undefined @@ -1053,9 +1133,12 @@ describe(`Query Collections`, () => { getKey: (person: Person) => person.id, startSync: false, sync: { - sync: ({ begin, commit }) => { + sync: ({ begin, commit, markReady }) => { beginFn = begin - commitFn = commit + commitFn = () => { + commit() + markReady() + } // Don't sync immediately }, }, @@ -1107,6 +1190,72 @@ describe(`Query Collections`, () => { expect(query.isReady).toBe(true) }) }) + + it(`should handle status transitions correctly with onFirstReady`, () => { + // This test verifies that the onFirstReady callback properly updates status + let beginFn: (() => void) | undefined + let commitFn: (() => void) | undefined + let markReadyFn: (() => void) | undefined + + const collection = createCollection({ + id: `onfirstready-test`, + getKey: (person: Person) => person.id, + startSync: false, + sync: { + sync: ({ begin, commit, markReady }) => { + beginFn = begin + commitFn = commit + markReadyFn = markReady + // Don't sync immediately + }, + }, + onInsert: () => Promise.resolve(), + onUpdate: () => Promise.resolve(), + onDelete: () => Promise.resolve(), + }) + + cleanup = $effect.root(() => { + const query = useLiveQuery((q) => + q + .from({ collection }) + .where(({ collection: c }) => gt(c.age, 30)) + .select(({ collection: c }) => ({ + id: c.id, + name: c.name, + })) + ) + + // Initially should be loading + expect(query.isLoading).toBe(true) + expect(query.isReady).toBe(false) + + // Start sync manually + collection.preload() + + // Trigger the first commit to make collection ready + if (beginFn && commitFn && markReadyFn) { + beginFn() + commitFn() + markReadyFn() + } + + // Insert data + collection.insert({ + id: `1`, + name: `John Doe`, + age: 35, + email: `john.doe@example.com`, + isActive: true, + team: `team1`, + }) + + // Wait for the status to transition correctly + flushSync() + expect(query.isLoading).toBe(false) + expect(query.isReady).toBe(true) + expect(query.status).toBe(`ready`) + }) + }) }) it(`should accept config object with pre-built QueryBuilder instance`, async () => { diff --git a/packages/vue-db/src/useLiveQuery.ts b/packages/vue-db/src/useLiveQuery.ts index 1339613a..e9ad9a31 100644 --- a/packages/vue-db/src/useLiveQuery.ts +++ b/packages/vue-db/src/useLiveQuery.ts @@ -1,6 +1,7 @@ import { computed, getCurrentInstance, + nextTick, onUnmounted, reactive, ref, @@ -230,7 +231,10 @@ export function useLiveQuery( if (isCollection) { // It's already a collection, ensure sync is started for Vue hooks - unwrappedParam.startSyncImmediate() + // Only start sync if the collection is in idle state + if (unwrappedParam.status === `idle`) { + unwrappedParam.startSyncImmediate() + } return unwrappedParam } @@ -295,6 +299,15 @@ export function useLiveQuery( // Initialize data array in correct order syncDataFromCollection(currentCollection) + // Listen for the first ready event to catch status transitions + // that might not trigger change events (fixes async status transition bug) + currentCollection.onFirstReady(() => { + // Use nextTick to ensure Vue reactivity updates properly + nextTick(() => { + status.value = currentCollection.status + }) + }) + // Subscribe to collection changes with granular updates currentUnsubscribe = currentCollection.subscribeChanges( (changes: Array>) => { diff --git a/packages/vue-db/tests/useLiveQuery.test.ts b/packages/vue-db/tests/useLiveQuery.test.ts index 874729d8..5bdeb7ff 100644 --- a/packages/vue-db/tests/useLiveQuery.test.ts +++ b/packages/vue-db/tests/useLiveQuery.test.ts @@ -840,6 +840,7 @@ describe(`Query Collections`, () => { it(`should be false initially and true after collection is ready`, async () => { let beginFn: (() => void) | undefined let commitFn: (() => void) | undefined + let markReadyFn: (() => void) | undefined // Create a collection that doesn't start sync immediately const collection = createCollection({ @@ -847,9 +848,10 @@ describe(`Query Collections`, () => { getKey: (person: Person) => person.id, startSync: false, // Don't start sync immediately sync: { - sync: ({ begin, commit }) => { + sync: ({ begin, commit, markReady }) => { beginFn = begin commitFn = commit + markReadyFn = markReady // Don't call begin/commit immediately }, }, @@ -875,9 +877,10 @@ describe(`Query Collections`, () => { collection.preload() // Trigger the first commit to make collection ready - if (beginFn && commitFn) { + if (beginFn && commitFn && markReadyFn) { beginFn() commitFn() + markReadyFn() } // Insert data @@ -955,15 +958,17 @@ describe(`Query Collections`, () => { it(`should update isReady when collection status changes`, async () => { let beginFn: (() => void) | undefined let commitFn: (() => void) | undefined + let markReadyFn: (() => void) | undefined const collection = createCollection({ id: `status-change-is-ready-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 }, }, @@ -984,9 +989,10 @@ describe(`Query Collections`, () => { expect(isReady.value).toBe(false) collection.preload() - if (beginFn && commitFn) { + if (beginFn && commitFn && markReadyFn) { beginFn() commitFn() + markReadyFn() } collection.insert({ id: `1`, @@ -999,6 +1005,73 @@ describe(`Query Collections`, () => { await waitFor(() => expect(isReady.value).toBe(true)) }) + it(`should update isLoading when collection status changes`, async () => { + let beginFn: (() => void) | undefined + let commitFn: (() => void) | undefined + let markReadyFn: (() => void) | undefined + + const collection = createCollection({ + id: `status-change-is-loading-test`, + getKey: (person: Person) => person.id, + startSync: false, + sync: { + sync: ({ begin, commit, markReady }) => { + beginFn = begin + commitFn = commit + markReadyFn = markReady + // Don't sync immediately + }, + }, + onInsert: () => Promise.resolve(), + onUpdate: () => Promise.resolve(), + onDelete: () => Promise.resolve(), + }) + + const { isLoading, isReady, status } = useLiveQuery((q) => + q + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + })) + ) + + // Initially should be true + expect(isLoading.value).toBe(true) + + // Start sync manually + collection.preload() + + // Trigger the first commit to make collection ready + if (beginFn && commitFn && markReadyFn) { + beginFn() + commitFn() + markReadyFn() + } + + // Insert data + collection.insert({ + id: `1`, + name: `John Doe`, + age: 35, + email: `john.doe@example.com`, + isActive: true, + team: `team1`, + }) + + await waitForVueUpdate() + + expect(isLoading.value).toBe(false) + expect(isReady.value).toBe(true) + + // Wait for collection to become ready + await waitFor(() => { + expect(isLoading.value).toBe(false) + }) + expect(status.value).toBe(`ready`) + }) + it(`should maintain isReady state during live updates`, async () => { const collection = createCollection( mockSyncCollectionOptions({ @@ -1041,17 +1114,20 @@ describe(`Query Collections`, () => { it(`should handle isReady 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({ id: `join-is-ready-persons`, getKey: (person: Person) => person.id, startSync: false, sync: { - sync: ({ begin, commit }) => { + sync: ({ begin, commit, markReady }) => { personBeginFn = begin personCommitFn = commit + personMarkReadyFn = markReady // Don't sync immediately }, }, @@ -1065,9 +1141,10 @@ describe(`Query Collections`, () => { getKey: (issue: Issue) => issue.id, startSync: false, sync: { - sync: ({ begin, commit }) => { + sync: ({ begin, commit, markReady }) => { issueBeginFn = begin issueCommitFn = commit + issueMarkReadyFn = markReady // Don't sync immediately }, }, @@ -1092,13 +1169,15 @@ describe(`Query Collections`, () => { expect(isReady.value).toBe(false) personCollection.preload() issueCollection.preload() - if (personBeginFn && personCommitFn) { + if (personBeginFn && personCommitFn && personMarkReadyFn) { personBeginFn() personCommitFn() + personMarkReadyFn() } - if (issueBeginFn && issueCommitFn) { + if (issueBeginFn && issueCommitFn && issueMarkReadyFn) { issueBeginFn() issueCommitFn() + issueMarkReadyFn() } personCollection.insert({ id: `1`, @@ -1126,9 +1205,12 @@ describe(`Query Collections`, () => { getKey: (person: Person) => person.id, startSync: false, sync: { - sync: ({ begin, commit }) => { + sync: ({ begin, commit, markReady }) => { beginFn = begin - commitFn = commit + commitFn = () => { + commit() + markReady() + } // Don't sync immediately }, }, @@ -1176,6 +1258,142 @@ describe(`Query Collections`, () => { minAge.value = 25 await waitFor(() => expect(isReady.value).toBe(true)) }) + + it(`should handle async status transitions correctly`, async () => { + let beginFn: (() => void) | undefined + let commitFn: (() => void) | undefined + let markReadyFn: (() => void) | undefined + + const collection = createCollection({ + id: `async-status-transition-test`, + getKey: (person: Person) => person.id, + startSync: false, + sync: { + sync: ({ begin, commit, markReady }) => { + beginFn = begin + commitFn = commit + markReadyFn = markReady + // Don't sync immediately + }, + }, + onInsert: () => Promise.resolve(), + onUpdate: () => Promise.resolve(), + onDelete: () => Promise.resolve(), + }) + + const { isLoading, isReady, status } = useLiveQuery((q) => + q + .from({ collection }) + .where(({ collection: c }) => gt(c.age, 30)) + .select(({ collection: c }) => ({ + id: c.id, + name: c.name, + })) + ) + + // Initially should be loading + expect(isLoading.value).toBe(true) + expect(isReady.value).toBe(false) + expect(status.value).toBe(`loading`) + + // Start sync manually + collection.preload() + + // Trigger the first commit to make collection ready + if (beginFn && commitFn && markReadyFn) { + beginFn() + commitFn() + // Simulate async delay before marking ready + await new Promise((resolve) => setTimeout(resolve, 10)) + markReadyFn() + } + + // Insert data + collection.insert({ + id: `1`, + name: `John Doe`, + age: 35, + email: `john.doe@example.com`, + isActive: true, + team: `team1`, + }) + + // Wait for the status to transition correctly + await waitFor(() => { + expect(isLoading.value).toBe(false) + expect(isReady.value).toBe(true) + expect(status.value).toBe(`ready`) + }) + }) + + it(`should handle status transitions without change events`, async () => { + // This test reproduces the bug where status gets stuck in 'initialCommit' + // when the collection status changes without triggering change events + let beginFn: (() => void) | undefined + let commitFn: (() => void) | undefined + let markReadyFn: (() => void) | undefined + + const collection = createCollection({ + id: `status-stuck-test`, + getKey: (person: Person) => person.id, + startSync: false, + sync: { + sync: ({ begin, commit, markReady }) => { + beginFn = begin + commitFn = commit + markReadyFn = markReady + // Don't sync immediately + }, + }, + onInsert: () => Promise.resolve(), + onUpdate: () => Promise.resolve(), + onDelete: () => Promise.resolve(), + }) + + const { isLoading, isReady, status } = useLiveQuery((q) => + q + .from({ collection }) + .where(({ collection: c }) => gt(c.age, 30)) + .select(({ collection: c }) => ({ + id: c.id, + name: c.name, + })) + ) + + // Initially should be loading + expect(isLoading.value).toBe(true) + expect(isReady.value).toBe(false) + + // Start sync manually + collection.preload() + + // Trigger the first commit to make collection ready + if (beginFn && commitFn && markReadyFn) { + beginFn() + commitFn() + // Simulate async delay before marking ready + await new Promise((resolve) => setTimeout(resolve, 10)) + markReadyFn() + } + + // Insert data + collection.insert({ + id: `1`, + name: `John Doe`, + age: 35, + email: `john.doe@example.com`, + isActive: true, + team: `team1`, + }) + + // Wait for the status to transition correctly + // This should work even if no change events are fired + await waitFor(() => { + expect(isLoading.value).toBe(false) + expect(isReady.value).toBe(true) + expect(status.value).toBe(`ready`) + }) + }) }) it(`should accept config object with pre-built QueryBuilder instance`, async () => {