11import {
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'
1216import type {
1317 ChangeMessage ,
1418 Collection ,
@@ -25,6 +29,8 @@ import type {
2529} from '@tanstack/db'
2630import 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 */
4047export 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
5260export 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
6877export 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}
0 commit comments