diff --git a/packages/vue-db/src/useLiveQuery.ts b/packages/vue-db/src/useLiveQuery.ts index 479c92b2b..a7f89d69b 100644 --- a/packages/vue-db/src/useLiveQuery.ts +++ b/packages/vue-db/src/useLiveQuery.ts @@ -1,14 +1,18 @@ import { computed, - getCurrentInstance, - nextTick, - onUnmounted, - reactive, + getCurrentScope, + onScopeDispose, ref, + shallowReactive, + shallowRef, toValue, watchEffect, } from 'vue' -import { createLiveQueryCollection } from '@tanstack/db' +import { + BaseQueryBuilder, + CollectionImpl, + createLiveQueryCollection, +} from '@tanstack/db' import type { ChangeMessage, Collection, @@ -25,6 +29,8 @@ import type { } from '@tanstack/db' import type { ComputedRef, MaybeRefOrGetter } from 'vue' +const DEFAULT_GC_TIME_MS = 1 // Live queries created by useLiveQuery are cleaned up immediately (0 disables GC) + /** * Return type for useLiveQuery hook * @property state - Reactive Map of query results (key → item) @@ -36,6 +42,7 @@ import type { ComputedRef, MaybeRefOrGetter } from 'vue' * @property isIdle - True when query hasn't started yet * @property isError - True when query encountered an error * @property isCleanedUp - True when query has been cleaned up + * @property isEnabled - True when query is active, false when disabled */ export interface UseLiveQueryReturn { state: ComputedRef>> @@ -47,6 +54,7 @@ export interface UseLiveQueryReturn { isIdle: ComputedRef isError: ComputedRef isCleanedUp: ComputedRef + isEnabled: ComputedRef } export interface UseLiveQueryReturnWithCollection< @@ -63,6 +71,7 @@ export interface UseLiveQueryReturnWithCollection< isIdle: ComputedRef isError: ComputedRef isCleanedUp: ComputedRef + isEnabled: ComputedRef } export interface UseLiveQueryReturnWithSingleResultCollection< @@ -79,6 +88,7 @@ export interface UseLiveQueryReturnWithSingleResultCollection< isIdle: ComputedRef isError: ComputedRef isCleanedUp: ComputedRef + isEnabled: ComputedRef } /** @@ -265,15 +275,8 @@ export function useLiveQuery( } } - // Check if it's already a collection by checking for specific collection methods - const isCollection = - unwrappedParam && - typeof unwrappedParam === `object` && - typeof unwrappedParam.subscribeChanges === `function` && - typeof unwrappedParam.startSyncImmediate === `function` && - typeof unwrappedParam.id === `string` - - if (isCollection) { + // Check if it's already a collection instance + if (unwrappedParam instanceof CollectionImpl) { // Warn when passing a collection directly with on-demand sync mode // In on-demand mode, data is only loaded when queries with predicates request it // Passing the collection directly doesn't provide any predicates, so no data loads @@ -301,33 +304,23 @@ export function useLiveQuery( // Ensure we always start sync for Vue hooks if (typeof unwrappedParam === `function`) { - // To avoid calling the query function twice, we wrap it to handle null/undefined returns - // The wrapper will be called once by createLiveQueryCollection - const wrappedQuery = (q: InitialQueryBuilder) => { - const result = unwrappedParam(q) - // If the query function returns null/undefined, throw a special error - // that we'll catch to return null collection - if (result === undefined || result === null) { - throw new Error(`__DISABLED_QUERY__`) - } - return result - } + // Probe the query function to check if it returns null/undefined (disabled query) + // This matches the pattern used by React and Solid adapters + const queryBuilder = new BaseQueryBuilder() as InitialQueryBuilder + const result = unwrappedParam(queryBuilder) - try { - return createLiveQueryCollection({ - query: wrappedQuery, - startSync: true, - }) - } catch (error) { - // Check if this is our special disabled query marker - if (error instanceof Error && error.message === `__DISABLED_QUERY__`) { - return null - } - // Re-throw other errors - throw error + if (result === undefined || result === null) { + return null } + + return createLiveQueryCollection({ + query: unwrappedParam, + startSync: true, + gcTime: DEFAULT_GC_TIME_MS, + }) } else { return createLiveQueryCollection({ + gcTime: DEFAULT_GC_TIME_MS, ...unwrappedParam, startSync: true, }) @@ -335,21 +328,25 @@ export function useLiveQuery( }) // Reactive state that gets updated granularly through change events - const state = reactive(new Map()) + // shallowReactive tracks Map operations (set/delete/has/get/size) without + // deeply proxying stored values — collection items are immutable snapshots + const state = shallowReactive(new Map()) - // Reactive data array that maintains sorted order - const internalData = reactive>([]) + // Reactive data array — shallowRef avoids deep proxying of array elements + // and triggers a single notification on .value assignment (vs reactive array's + // double trigger from length=0 + push) + const internalData = shallowRef>([]) // Computed wrapper for the data to match expected return type // Returns single item for singleResult collections, array otherwise const data = computed(() => { const currentCollection = collection.value if (!currentCollection) { - return internalData + return internalData.value } const config: CollectionConfigSingleRowOption = currentCollection.config - return config.singleResult ? internalData[0] : internalData + return config.singleResult ? internalData.value[0] : internalData.value }) // Track collection status reactively @@ -361,8 +358,7 @@ export function useLiveQuery( const syncDataFromCollection = ( currentCollection: Collection, ) => { - internalData.length = 0 - internalData.push(...Array.from(currentCollection.values())) + internalData.value = Array.from(currentCollection.values()) } // Track current unsubscribe function @@ -376,7 +372,7 @@ export function useLiveQuery( if (!currentCollection) { status.value = `disabled` as const state.clear() - internalData.length = 0 + internalData.value = [] if (currentUnsubscribe) { currentUnsubscribe() currentUnsubscribe = null @@ -402,12 +398,14 @@ export function useLiveQuery( syncDataFromCollection(currentCollection) // Listen for the first ready event to catch status transitions - // that might not trigger change events (fixes async status transition bug) + // that might not trigger change events (fixes async status transition bug). + // Guard: if the collection has changed by the time the callback fires, + // skip the update — the new collection's own callback will handle it. + const collectionAtRegistration = currentCollection currentCollection.onFirstReady(() => { - // Use nextTick to ensure Vue reactivity updates properly - nextTick(() => { + if (collection.value === collectionAtRegistration) { status.value = currentCollection.status - }) + } }) // Subscribe to collection changes with granular updates @@ -452,12 +450,15 @@ export function useLiveQuery( }) }) - // Cleanup on unmount (only if we're in a component context) - const instance = getCurrentInstance() - if (instance) { - onUnmounted(() => { + // Cleanup on scope disposal — works in components, composables, and standalone effectScope. + // Guard with getCurrentScope() since useLiveQuery may be called outside any reactive scope + // (e.g., in tests or standalone utility code). watchEffect's onInvalidate handles cleanup + // when the effect is stopped, but onScopeDispose provides defense-in-depth for scope disposal. + if (getCurrentScope()) { + onScopeDispose(() => { if (currentUnsubscribe) { currentUnsubscribe() + currentUnsubscribe = null } }) } @@ -465,8 +466,10 @@ export function useLiveQuery( return { state: computed(() => state), data, - collection: computed(() => collection.value), - status: computed(() => status.value), + collection: computed( + () => collection.value as Collection, + ), + status: computed(() => status.value as CollectionStatus), isLoading: computed(() => status.value === `loading`), isReady: computed( () => status.value === `ready` || status.value === `disabled`, @@ -474,5 +477,6 @@ export function useLiveQuery( isIdle: computed(() => status.value === `idle`), isError: computed(() => status.value === `error`), isCleanedUp: computed(() => status.value === `cleaned-up`), + isEnabled: computed(() => status.value !== `disabled`), } } diff --git a/packages/vue-db/tests/test-utils.ts b/packages/vue-db/tests/test-utils.ts new file mode 100644 index 000000000..e8b4d0d8d --- /dev/null +++ b/packages/vue-db/tests/test-utils.ts @@ -0,0 +1,24 @@ +import { nextTick } from 'vue' + +// Helper function to wait for Vue reactivity +export async function waitForVueUpdate() { + await nextTick() + // Additional small delay to ensure collection updates are processed + await new Promise((resolve) => setTimeout(resolve, 50)) +} + +// Helper function to poll for a condition until it passes or times out +export async function waitFor(fn: () => void, timeout = 2000, interval = 20) { + const start = Date.now() + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + while (true) { + try { + fn() + return + } catch (err) { + if (Date.now() - start > timeout) throw err + await new Promise((resolve) => setTimeout(resolve, interval)) + } + } +} diff --git a/packages/vue-db/tests/useLiveQuery-bestpractices.test.ts b/packages/vue-db/tests/useLiveQuery-bestpractices.test.ts new file mode 100644 index 000000000..c45e17246 --- /dev/null +++ b/packages/vue-db/tests/useLiveQuery-bestpractices.test.ts @@ -0,0 +1,607 @@ +/** + * Targeted tests for Vue best-practice fixes in useLiveQuery. + * + * Each test validates a specific behavioral change from the Phase 0 alignment: + * 0.1 shallowReactive Map — items are NOT deeply reactive + * 0.2 shallowRef array — data elements are NOT deeply reactive + * 0.3 BaseQueryBuilder probe — disabled queries via null/undefined + * 0.4 onScopeDispose — cleanup runs on effectScope disposal + * 0.5 gcTime — hook-created collections have GC time set + * 0.6 instanceof CollectionImpl — robust collection detection + * 0.9 isEnabled — new return field + */ + +import { describe, expect, it } from 'vitest' +import { + createCollection, + createLiveQueryCollection, + gt, +} from '@tanstack/db' +import { + effectScope, + isReactive, + ref, +} from 'vue' +import { useLiveQuery } from '../src/useLiveQuery' +import { mockSyncCollectionOptions } from '../../db/tests/utils' +import { waitFor, waitForVueUpdate } from './test-utils' + +type Person = { + id: string + name: string + age: number + email: string + isActive: boolean + team: string +} + +const initialPersons: Array = [ + { + id: `1`, + name: `John Doe`, + age: 30, + email: `john.doe@example.com`, + isActive: true, + team: `team1`, + }, + { + id: `2`, + name: `Jane Doe`, + age: 25, + email: `jane.doe@example.com`, + isActive: true, + team: `team2`, + }, + { + id: `3`, + name: `John Smith`, + age: 35, + email: `john.smith@example.com`, + isActive: true, + team: `team1`, + }, +] + +describe(`Vue best-practice fixes`, () => { + // ── 0.1 shallowReactive Map ────────────────────────────────────────── + describe(`shallowReactive state Map (fix 0.1)`, () => { + it(`should store items that are NOT deeply reactive`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `shallow-map-test`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }), + ) + + const { state } = useLiveQuery((q) => + q + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + age: persons.age, + })), + ) + + await waitForVueUpdate() + + // The Map itself should be reactive (shallowReactive tracks set/delete/has) + expect(state.value.size).toBe(1) + + // But the items stored inside should NOT be deeply reactive proxies + // shallowReactive only tracks Map operations, not the stored values + const item = state.value.get(`3`) + expect(item).toBeDefined() + expect(isReactive(item)).toBe(false) + }) + }) + + // ── 0.2 shallowRef data array ──────────────────────────────────────── + describe(`shallowRef data array (fix 0.2)`, () => { + it(`should store array elements that are NOT deeply reactive`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `shallow-array-test`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }), + ) + + const { data } = useLiveQuery((q) => + q + .from({ persons: collection }) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + age: persons.age, + })), + ) + + await waitForVueUpdate() + + expect(data.value.length).toBe(3) + + // Array elements should NOT be deeply reactive + for (const item of data.value) { + expect(isReactive(item)).toBe(false) + } + }) + + it(`should replace data array atomically on changes (not splice/push)`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `atomic-replace-test`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }), + ) + + const { data } = useLiveQuery((q) => + q + .from({ persons: collection }) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + })), + ) + + await waitForVueUpdate() + + // Capture reference to current array + const firstArray = data.value + + // Insert a new person + collection.utils.begin() + collection.utils.write({ + type: `insert`, + value: { + id: `4`, + name: `New Person`, + age: 40, + email: `new@example.com`, + isActive: true, + team: `team1`, + }, + }) + collection.utils.commit() + + await waitForVueUpdate() + + // After update, data.value should be a NEW array reference + // (shallowRef replaces .value entirely, not mutating the existing array) + expect(data.value).not.toBe(firstArray) + expect(data.value.length).toBe(4) + }) + }) + + // ── 0.4 onScopeDispose cleanup ─────────────────────────────────────── + describe(`onScopeDispose cleanup (fix 0.4)`, () => { + it(`should clean up subscription when effectScope is disposed`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `scope-dispose-test`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }), + ) + + const scope = effectScope() + let result: ReturnType> | undefined + + scope.run(() => { + result = useLiveQuery((q) => + q + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + })), + ) as any + }) + + await waitForVueUpdate() + + // Should have data while scope is active + expect(result!.state.value.size).toBe(1) + expect(result!.data.value).toHaveLength(1) + + // Dispose the scope — this should trigger onScopeDispose cleanup + scope.stop() + + // Insert a new person after disposal + collection.utils.begin() + collection.utils.write({ + type: `insert`, + value: { + id: `4`, + name: `Post-Disposal Person`, + age: 40, + email: `post@example.com`, + isActive: true, + team: `team1`, + }, + }) + collection.utils.commit() + + await waitForVueUpdate() + + // After scope disposal, the subscription should be cleaned up + // The state should NOT update with new data + // (The reactive refs are still readable but no longer being updated) + expect(result!.state.value.size).toBe(1) // Still 1, not 2 + }) + + it(`should not warn when used outside an effectScope`, () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `no-scope-warn-test`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }), + ) + + // This should not throw or produce Vue warnings + // getCurrentScope() returns undefined, so onScopeDispose is skipped + const { state } = useLiveQuery((q) => + q + .from({ persons: collection }) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + })), + ) + + expect(state).toBeDefined() + }) + }) + + // ── 0.5 gcTime on hook-created collections ─────────────────────────── + describe(`gcTime for hook-created collections (fix 0.5)`, () => { + it(`should set gcTime on collections created by query function`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `gctime-query-fn-test`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }), + ) + + const { collection: returnedCollection } = useLiveQuery((q) => + q + .from({ persons: collection }) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + })), + ) + + await waitForVueUpdate() + + // The collection created internally should have gcTime set to 1 (immediate cleanup) + expect(returnedCollection.value).toBeDefined() + expect((returnedCollection.value as any).config.gcTime).toBe(1) + }) + + it(`should set gcTime on collections created by config object`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `gctime-config-test`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }), + ) + + const { collection: returnedCollection } = useLiveQuery({ + query: (q) => + q + .from({ persons: collection }) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + })), + }) + + await waitForVueUpdate() + + expect(returnedCollection.value).toBeDefined() + expect((returnedCollection.value as any).config.gcTime).toBe(1) + }) + + it(`should preserve user-specified gcTime in config objects`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `gctime-config-preserve-test`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }), + ) + + const { collection: returnedCollection } = useLiveQuery({ + query: (q) => + q + .from({ persons: collection }) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + })), + gcTime: 60000, // User specifies custom GC time + }) + + await waitForVueUpdate() + + // User-specified gcTime should take precedence over the default + expect(returnedCollection.value).toBeDefined() + expect((returnedCollection.value as any).config.gcTime).toBe(60000) + }) + + it(`should force startSync even if user config passes startSync: false`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `startsync-forced-test`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }), + ) + + const { data } = useLiveQuery({ + query: (q) => + q + .from({ persons: collection }) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + })), + startSync: false, // User tries to disable sync — should be overridden + }) + + await waitForVueUpdate() + + // startSync is forced to true, so data should still load + expect(data.value.length).toBe(3) + }) + + it(`should not override gcTime on pre-created collections`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `gctime-precreated-test`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }), + ) + + const preCreated = createLiveQueryCollection({ + query: (q) => + q + .from({ persons: collection }) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + })), + startSync: true, + gcTime: 60000, // User-specified GC time + }) + + const { collection: returnedCollection } = useLiveQuery(preCreated) + + await waitForVueUpdate() + + // Pre-created collection should keep its original gcTime + expect(returnedCollection.value).toBe(preCreated) + expect((returnedCollection.value as any).config.gcTime).toBe(60000) + }) + }) + + // ── 0.6 instanceof CollectionImpl detection ────────────────────────── + describe(`instanceof CollectionImpl detection (fix 0.6)`, () => { + it(`should correctly detect pre-created live query collections`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `instanceof-detect-test`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }), + ) + + const preCreated = createLiveQueryCollection({ + query: (q) => + q + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + })), + startSync: true, + }) + + const { collection: returnedCollection, data } = + useLiveQuery(preCreated) + + await waitForVueUpdate() + + // Should return the exact same instance (detected as collection, not re-wrapped) + expect(returnedCollection.value).toBe(preCreated) + expect(data.value).toHaveLength(1) + }) + + it(`should reject a plain object that duck-types as a collection`, () => { + // A duck-typed object should NOT be treated as a collection. + // With instanceof CollectionImpl, it's correctly rejected and + // falls through to createLiveQueryCollection which fails on the + // invalid config — this is the desired behavior. + const fakeCollection = { + subscribeChanges: () => ({ unsubscribe: () => {} }), + entries: () => [].entries(), + values: () => [].values(), + status: `ready`, + config: {}, + id: `fake`, + } + + // instanceof CollectionImpl rejects this, so it's treated as a config + // object — which is invalid and throws. This is correct: only real + // CollectionImpl instances should be passed directly. + expect(() => { + useLiveQuery(fakeCollection as any) + }).toThrow() + }) + }) + + // ── 0.9 isEnabled return field ──────────────────────────────────────── + describe(`isEnabled return field (fix 0.9)`, () => { + it(`should be true for active queries`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `is-enabled-active-test`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }), + ) + + const { isEnabled, status } = useLiveQuery((q) => + q + .from({ persons: collection }) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + })), + ) + + await waitForVueUpdate() + + expect(isEnabled.value).toBe(true) + expect(status.value).not.toBe(`disabled`) + }) + + it(`should report isReady as true for disabled queries (nothing to wait for)`, () => { + // Disabled queries are considered "ready" because there is no pending + // data to wait for — matching the React adapter's behavior + const { isEnabled, isReady, isLoading } = useLiveQuery( + + (_q) => { + return undefined + }, + ) + + expect(isEnabled.value).toBe(false) + expect(isReady.value).toBe(true) + expect(isLoading.value).toBe(false) + }) + + it(`should be false for disabled queries (returning undefined)`, () => { + const { isEnabled, status } = useLiveQuery( + + (_q) => { + return undefined + }, + ) + + expect(isEnabled.value).toBe(false) + expect(status.value).toBe(`disabled`) + }) + + it(`should be false for disabled queries (returning null)`, () => { + const { isEnabled, status } = useLiveQuery( + + (_q) => { + return null + }, + ) + + expect(isEnabled.value).toBe(false) + expect(status.value).toBe(`disabled`) + }) + + it(`should toggle when query transitions between enabled and disabled`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `is-enabled-toggle-test`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }), + ) + + const enabled = ref(true) + const { isEnabled, data } = useLiveQuery( + (q) => { + if (!enabled.value) return undefined + return q + .from({ persons: collection }) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + })) + }, + [() => enabled.value], + ) + + await waitForVueUpdate() + + expect(isEnabled.value).toBe(true) + expect(data.value.length).toBe(3) + + // Disable + enabled.value = false + await waitFor(() => { + expect(isEnabled.value).toBe(false) + }) + + // Re-enable + enabled.value = true + await waitFor(() => { + expect(isEnabled.value).toBe(true) + }) + await waitFor(() => { + expect(data.value.length).toBe(3) + }) + }) + }) + + // ── 0.3 BaseQueryBuilder probe (disabled query detection) ──────────── + describe(`BaseQueryBuilder probe for disabled queries (fix 0.3)`, () => { + it(`should correctly detect disabled state without throwing sentinel errors`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `probe-no-sentinel-test`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }), + ) + + const enabled = ref(false) + + // The old implementation would throw Error('__DISABLED_QUERY__') and catch it. + // The new implementation probes with BaseQueryBuilder and checks for null/undefined. + // Both should produce the same result, but the new way is cleaner. + const result = useLiveQuery( + (q) => { + if (!enabled.value) return undefined + return q + .from({ persons: collection }) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + })) + }, + [() => enabled.value], + ) + + // Should be in disabled state + expect(result.status.value).toBe(`disabled`) + expect(result.collection.value).toBeNull() + expect(result.data.value).toEqual([]) + + // Enable → should work normally + enabled.value = true + await waitFor(() => { + expect(result.status.value).not.toBe(`disabled`) + }) + await waitFor(() => { + expect(result.data.value.length).toBe(3) + }) + }) + }) +}) diff --git a/packages/vue-db/tests/useLiveQuery.test.ts b/packages/vue-db/tests/useLiveQuery.test.ts index 634c5ff8d..564aea2c2 100644 --- a/packages/vue-db/tests/useLiveQuery.test.ts +++ b/packages/vue-db/tests/useLiveQuery.test.ts @@ -10,6 +10,7 @@ import { import { nextTick, ref, watchEffect } from 'vue' import { useLiveQuery } from '../src/useLiveQuery' import { mockSyncCollectionOptions } from '../../db/tests/utils' +import { waitFor, waitForVueUpdate } from './test-utils' type Person = { id: string @@ -75,29 +76,6 @@ const initialIssues: Array = [ }, ] -// Helper function to wait for Vue reactivity -async function waitForVueUpdate() { - await nextTick() - // Additional small delay to ensure collection updates are processed - await new Promise((resolve) => setTimeout(resolve, 50)) -} - -// Helper function to poll for a condition until it passes or times out -async function waitFor(fn: () => void, timeout = 2000, interval = 20) { - const start = Date.now() - - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - while (true) { - try { - fn() - return - } catch (err) { - if (Date.now() - start > timeout) throw err - await new Promise((resolve) => setTimeout(resolve, interval)) - } - } -} - describe(`Query Collections`, () => { it(`should work with basic collection and select`, async () => { const collection = createCollection(