From 9482e0eaf399aa5265881b308a5785796d20477a Mon Sep 17 00:00:00 2001 From: Danny Ahn Date: Wed, 25 Feb 2026 18:56:05 -0800 Subject: [PATCH 1/4] refactor(vue-db): modernize useLiveQuery reactivity and lifecycle patterns Align useLiveQuery with Vue 3's recommended primitives: - Use shallowReactive(Map) instead of reactive(Map) for immutable collection items - Use shallowRef([]) instead of reactive([]) for atomic array replacement - Replace sentinel-throw disabled query detection with BaseQueryBuilder probe - Use getCurrentScope/onScopeDispose instead of getCurrentInstance/onUnmounted - Set gcTime on hook-created collections for immediate cleanup - Use instanceof CollectionImpl instead of duck-typing for collection detection - Remove unnecessary nextTick in onFirstReady callback - Add isEnabled computed to return type Also extract shared test utilities to test-utils.ts and add 17 targeted tests. Co-Authored-By: Claude Opus 4.6 --- packages/vue-db/src/useLiveQuery.ts | 111 ++-- packages/vue-db/tests/test-utils.ts | 24 + .../tests/useLiveQuery-bestpractices.test.ts | 581 ++++++++++++++++++ packages/vue-db/tests/useLiveQuery.test.ts | 24 +- 4 files changed, 661 insertions(+), 79 deletions(-) create mode 100644 packages/vue-db/tests/test-utils.ts create mode 100644 packages/vue-db/tests/useLiveQuery-bestpractices.test.ts diff --git a/packages/vue-db/src/useLiveQuery.ts b/packages/vue-db/src/useLiveQuery.ts index 479c92b2b..4dae4e0d8 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,55 +304,49 @@ 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({ - ...unwrappedParam, startSync: true, + gcTime: DEFAULT_GC_TIME_MS, + ...unwrappedParam, }) } }) // 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 @@ -404,10 +400,7 @@ export function useLiveQuery( // 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 - }) + status.value = currentCollection.status }) // Subscribe to collection changes with granular updates @@ -452,12 +445,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 +461,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 +472,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..259fb1a3a --- /dev/null +++ b/packages/vue-db/tests/useLiveQuery-bestpractices.test.ts @@ -0,0 +1,581 @@ +/** + * 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 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( From 40a28b01c58f235000206967a555d8d1892c0423 Mon Sep 17 00:00:00 2001 From: Danny Ahn Date: Thu, 26 Feb 2026 09:50:03 -0800 Subject: [PATCH 2/4] fix(vue-db): force startSync after user spread, guard stale onFirstReady MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Config-object path: move `startSync: true` after the user spread so it cannot be accidentally overridden to false. `gcTime` stays before the spread so users CAN customize it — this matches React's adapter pattern. 2. onFirstReady callback: capture collection identity at registration time and skip the status update if the collection has changed by the time the callback fires. Prevents stale callbacks from overwriting the current collection's status ref. Co-Authored-By: Claude Opus 4.6 --- packages/vue-db/src/useLiveQuery.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/vue-db/src/useLiveQuery.ts b/packages/vue-db/src/useLiveQuery.ts index 4dae4e0d8..a7f89d69b 100644 --- a/packages/vue-db/src/useLiveQuery.ts +++ b/packages/vue-db/src/useLiveQuery.ts @@ -320,9 +320,9 @@ export function useLiveQuery( }) } else { return createLiveQueryCollection({ - startSync: true, gcTime: DEFAULT_GC_TIME_MS, ...unwrappedParam, + startSync: true, }) } }) @@ -398,9 +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(() => { - status.value = currentCollection.status + if (collection.value === collectionAtRegistration) { + status.value = currentCollection.status + } }) // Subscribe to collection changes with granular updates From 4f2ae53883832b2e63ad34a22fbf8c45c6b7787b Mon Sep 17 00:00:00 2001 From: Danny Ahn Date: Thu, 26 Feb 2026 10:32:18 -0800 Subject: [PATCH 3/4] test(vue-db): add regression test for startSync override prevention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verifies that passing `startSync: false` in a config object does not prevent sync — `startSync: true` is always forced after the user spread. Co-Authored-By: Claude Opus 4.6 --- .../tests/useLiveQuery-bestpractices.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/vue-db/tests/useLiveQuery-bestpractices.test.ts b/packages/vue-db/tests/useLiveQuery-bestpractices.test.ts index 259fb1a3a..c45e17246 100644 --- a/packages/vue-db/tests/useLiveQuery-bestpractices.test.ts +++ b/packages/vue-db/tests/useLiveQuery-bestpractices.test.ts @@ -337,6 +337,32 @@ describe(`Vue best-practice fixes`, () => { 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({ From de5f28d6fe9ee6f89dcf81a9d39fab90d51c4e99 Mon Sep 17 00:00:00 2001 From: Daniel Ahn Date: Sat, 28 Feb 2026 22:48:40 -0800 Subject: [PATCH 4/4] Add changeset for vue-db refactor --- .changeset/tasty-items-begin.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/tasty-items-begin.md diff --git a/.changeset/tasty-items-begin.md b/.changeset/tasty-items-begin.md new file mode 100644 index 000000000..0dc7b4af2 --- /dev/null +++ b/.changeset/tasty-items-begin.md @@ -0,0 +1,5 @@ +--- +"@tanstack/vue-db": patch +--- + +refactor(vue-db): modernize useLiveQuery reactivity and lifecycle patterns