Skip to content

Commit 9542d22

Browse files
Danny-Devsclaude
andcommitted
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 <noreply@anthropic.com>
1 parent f13819f commit 9542d22

4 files changed

Lines changed: 661 additions & 79 deletions

File tree

packages/vue-db/src/useLiveQuery.ts

Lines changed: 55 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
import {
22
computed,
3-
getCurrentInstance,
4-
nextTick,
5-
onUnmounted,
6-
reactive,
3+
getCurrentScope,
4+
onScopeDispose,
75
ref,
6+
shallowReactive,
7+
shallowRef,
88
toValue,
99
watchEffect,
1010
} from 'vue'
11-
import { createLiveQueryCollection } from '@tanstack/db'
11+
import {
12+
BaseQueryBuilder,
13+
CollectionImpl,
14+
createLiveQueryCollection,
15+
} from '@tanstack/db'
1216
import type {
1317
ChangeMessage,
1418
Collection,
@@ -25,6 +29,8 @@ import type {
2529
} from '@tanstack/db'
2630
import type { ComputedRef, MaybeRefOrGetter } from 'vue'
2731

32+
const DEFAULT_GC_TIME_MS = 1 // Live queries created by useLiveQuery are cleaned up immediately (0 disables GC)
33+
2834
/**
2935
* Return type for useLiveQuery hook
3036
* @property state - Reactive Map of query results (key → item)
@@ -36,6 +42,7 @@ import type { ComputedRef, MaybeRefOrGetter } from 'vue'
3642
* @property isIdle - True when query hasn't started yet
3743
* @property isError - True when query encountered an error
3844
* @property isCleanedUp - True when query has been cleaned up
45+
* @property isEnabled - True when query is active, false when disabled
3946
*/
4047
export interface UseLiveQueryReturn<TContext extends Context> {
4148
state: ComputedRef<Map<string | number, GetResult<TContext>>>
@@ -47,6 +54,7 @@ export interface UseLiveQueryReturn<TContext extends Context> {
4754
isIdle: ComputedRef<boolean>
4855
isError: ComputedRef<boolean>
4956
isCleanedUp: ComputedRef<boolean>
57+
isEnabled: ComputedRef<boolean>
5058
}
5159

5260
export interface UseLiveQueryReturnWithCollection<
@@ -63,6 +71,7 @@ export interface UseLiveQueryReturnWithCollection<
6371
isIdle: ComputedRef<boolean>
6472
isError: ComputedRef<boolean>
6573
isCleanedUp: ComputedRef<boolean>
74+
isEnabled: ComputedRef<boolean>
6675
}
6776

6877
export interface UseLiveQueryReturnWithSingleResultCollection<
@@ -79,6 +88,7 @@ export interface UseLiveQueryReturnWithSingleResultCollection<
7988
isIdle: ComputedRef<boolean>
8089
isError: ComputedRef<boolean>
8190
isCleanedUp: ComputedRef<boolean>
91+
isEnabled: ComputedRef<boolean>
8292
}
8393

8494
/**
@@ -265,15 +275,8 @@ export function useLiveQuery(
265275
}
266276
}
267277

268-
// Check if it's already a collection by checking for specific collection methods
269-
const isCollection =
270-
unwrappedParam &&
271-
typeof unwrappedParam === `object` &&
272-
typeof unwrappedParam.subscribeChanges === `function` &&
273-
typeof unwrappedParam.startSyncImmediate === `function` &&
274-
typeof unwrappedParam.id === `string`
275-
276-
if (isCollection) {
278+
// Check if it's already a collection instance
279+
if (unwrappedParam instanceof CollectionImpl) {
277280
// Warn when passing a collection directly with on-demand sync mode
278281
// In on-demand mode, data is only loaded when queries with predicates request it
279282
// Passing the collection directly doesn't provide any predicates, so no data loads
@@ -301,55 +304,49 @@ export function useLiveQuery(
301304

302305
// Ensure we always start sync for Vue hooks
303306
if (typeof unwrappedParam === `function`) {
304-
// To avoid calling the query function twice, we wrap it to handle null/undefined returns
305-
// The wrapper will be called once by createLiveQueryCollection
306-
const wrappedQuery = (q: InitialQueryBuilder) => {
307-
const result = unwrappedParam(q)
308-
// If the query function returns null/undefined, throw a special error
309-
// that we'll catch to return null collection
310-
if (result === undefined || result === null) {
311-
throw new Error(`__DISABLED_QUERY__`)
312-
}
313-
return result
314-
}
307+
// Probe the query function to check if it returns null/undefined (disabled query)
308+
// This matches the pattern used by React and Solid adapters
309+
const queryBuilder = new BaseQueryBuilder() as InitialQueryBuilder
310+
const result = unwrappedParam(queryBuilder)
315311

316-
try {
317-
return createLiveQueryCollection({
318-
query: wrappedQuery,
319-
startSync: true,
320-
})
321-
} catch (error) {
322-
// Check if this is our special disabled query marker
323-
if (error instanceof Error && error.message === `__DISABLED_QUERY__`) {
324-
return null
325-
}
326-
// Re-throw other errors
327-
throw error
312+
if (result === undefined || result === null) {
313+
return null
328314
}
315+
316+
return createLiveQueryCollection({
317+
query: unwrappedParam,
318+
startSync: true,
319+
gcTime: DEFAULT_GC_TIME_MS,
320+
})
329321
} else {
330322
return createLiveQueryCollection({
331-
...unwrappedParam,
332323
startSync: true,
324+
gcTime: DEFAULT_GC_TIME_MS,
325+
...unwrappedParam,
333326
})
334327
}
335328
})
336329

337330
// Reactive state that gets updated granularly through change events
338-
const state = reactive(new Map<string | number, any>())
331+
// shallowReactive tracks Map operations (set/delete/has/get/size) without
332+
// deeply proxying stored values — collection items are immutable snapshots
333+
const state = shallowReactive(new Map<string | number, any>())
339334

340-
// Reactive data array that maintains sorted order
341-
const internalData = reactive<Array<any>>([])
335+
// Reactive data array — shallowRef avoids deep proxying of array elements
336+
// and triggers a single notification on .value assignment (vs reactive array's
337+
// double trigger from length=0 + push)
338+
const internalData = shallowRef<Array<any>>([])
342339

343340
// Computed wrapper for the data to match expected return type
344341
// Returns single item for singleResult collections, array otherwise
345342
const data = computed(() => {
346343
const currentCollection = collection.value
347344
if (!currentCollection) {
348-
return internalData
345+
return internalData.value
349346
}
350347
const config: CollectionConfigSingleRowOption<any, any, any> =
351348
currentCollection.config
352-
return config.singleResult ? internalData[0] : internalData
349+
return config.singleResult ? internalData.value[0] : internalData.value
353350
})
354351

355352
// Track collection status reactively
@@ -361,8 +358,7 @@ export function useLiveQuery(
361358
const syncDataFromCollection = (
362359
currentCollection: Collection<any, any, any>,
363360
) => {
364-
internalData.length = 0
365-
internalData.push(...Array.from(currentCollection.values()))
361+
internalData.value = Array.from(currentCollection.values())
366362
}
367363

368364
// Track current unsubscribe function
@@ -376,7 +372,7 @@ export function useLiveQuery(
376372
if (!currentCollection) {
377373
status.value = `disabled` as const
378374
state.clear()
379-
internalData.length = 0
375+
internalData.value = []
380376
if (currentUnsubscribe) {
381377
currentUnsubscribe()
382378
currentUnsubscribe = null
@@ -404,10 +400,7 @@ export function useLiveQuery(
404400
// Listen for the first ready event to catch status transitions
405401
// that might not trigger change events (fixes async status transition bug)
406402
currentCollection.onFirstReady(() => {
407-
// Use nextTick to ensure Vue reactivity updates properly
408-
nextTick(() => {
409-
status.value = currentCollection.status
410-
})
403+
status.value = currentCollection.status
411404
})
412405

413406
// Subscribe to collection changes with granular updates
@@ -452,27 +445,33 @@ export function useLiveQuery(
452445
})
453446
})
454447

455-
// Cleanup on unmount (only if we're in a component context)
456-
const instance = getCurrentInstance()
457-
if (instance) {
458-
onUnmounted(() => {
448+
// Cleanup on scope disposal — works in components, composables, and standalone effectScope.
449+
// Guard with getCurrentScope() since useLiveQuery may be called outside any reactive scope
450+
// (e.g., in tests or standalone utility code). watchEffect's onInvalidate handles cleanup
451+
// when the effect is stopped, but onScopeDispose provides defense-in-depth for scope disposal.
452+
if (getCurrentScope()) {
453+
onScopeDispose(() => {
459454
if (currentUnsubscribe) {
460455
currentUnsubscribe()
456+
currentUnsubscribe = null
461457
}
462458
})
463459
}
464460

465461
return {
466462
state: computed(() => state),
467463
data,
468-
collection: computed(() => collection.value),
469-
status: computed(() => status.value),
464+
collection: computed(
465+
() => collection.value as Collection<any, any, any>,
466+
),
467+
status: computed(() => status.value as CollectionStatus),
470468
isLoading: computed(() => status.value === `loading`),
471469
isReady: computed(
472470
() => status.value === `ready` || status.value === `disabled`,
473471
),
474472
isIdle: computed(() => status.value === `idle`),
475473
isError: computed(() => status.value === `error`),
476474
isCleanedUp: computed(() => status.value === `cleaned-up`),
475+
isEnabled: computed(() => status.value !== `disabled`),
477476
}
478477
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { nextTick } from 'vue'
2+
3+
// Helper function to wait for Vue reactivity
4+
export async function waitForVueUpdate() {
5+
await nextTick()
6+
// Additional small delay to ensure collection updates are processed
7+
await new Promise((resolve) => setTimeout(resolve, 50))
8+
}
9+
10+
// Helper function to poll for a condition until it passes or times out
11+
export async function waitFor(fn: () => void, timeout = 2000, interval = 20) {
12+
const start = Date.now()
13+
14+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
15+
while (true) {
16+
try {
17+
fn()
18+
return
19+
} catch (err) {
20+
if (Date.now() - start > timeout) throw err
21+
await new Promise((resolve) => setTimeout(resolve, interval))
22+
}
23+
}
24+
}

0 commit comments

Comments
 (0)