From 52ce6664365196a05fc77af9944566af9545beee Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Nov 2025 18:31:17 +0000 Subject: [PATCH 1/9] fix(react-db): return isReady true for disabled queries in useLiveQuery When useLiveQuery's query function returns null or undefined, the query is effectively "disabled" - there's no async operation to wait for. The hook should be considered "ready" immediately since there's nothing loading. This change updates isReady to return true (instead of false) when status is 'disabled', matching user expectations when conditionally enabling queries. Fixes #883 --- packages/react-db/src/useLiveQuery.ts | 2 +- packages/react-db/tests/useLiveQuery.test.tsx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/react-db/src/useLiveQuery.ts b/packages/react-db/src/useLiveQuery.ts index 90145d25d..8fd0d2eb0 100644 --- a/packages/react-db/src/useLiveQuery.ts +++ b/packages/react-db/src/useLiveQuery.ts @@ -492,7 +492,7 @@ export function useLiveQuery( collection: undefined, status: `disabled`, isLoading: false, - isReady: false, + isReady: true, isIdle: false, isError: false, isCleanedUp: false, diff --git a/packages/react-db/tests/useLiveQuery.test.tsx b/packages/react-db/tests/useLiveQuery.test.tsx index fec064163..fb7ab5dbc 100644 --- a/packages/react-db/tests/useLiveQuery.test.tsx +++ b/packages/react-db/tests/useLiveQuery.test.tsx @@ -2005,7 +2005,7 @@ describe(`Query Collections`, () => { expect(result.current.collection).toBeUndefined() expect(result.current.status).toBe(`disabled`) expect(result.current.isLoading).toBe(false) - expect(result.current.isReady).toBe(false) + expect(result.current.isReady).toBe(true) expect(result.current.isIdle).toBe(false) expect(result.current.isError).toBe(false) expect(result.current.isCleanedUp).toBe(false) @@ -2044,7 +2044,7 @@ describe(`Query Collections`, () => { expect(result.current.collection).toBeUndefined() expect(result.current.status).toBe(`disabled`) expect(result.current.isLoading).toBe(false) - expect(result.current.isReady).toBe(false) + expect(result.current.isReady).toBe(true) expect(result.current.isIdle).toBe(false) expect(result.current.isError).toBe(false) expect(result.current.isCleanedUp).toBe(false) @@ -2085,7 +2085,7 @@ describe(`Query Collections`, () => { expect(result.current.collection).toBeUndefined() expect(result.current.status).toBe(`disabled`) expect(result.current.isLoading).toBe(false) - expect(result.current.isReady).toBe(false) + expect(result.current.isReady).toBe(true) expect(result.current.isIdle).toBe(false) expect(result.current.isError).toBe(false) expect(result.current.isCleanedUp).toBe(false) From 0e1d286598a3ffcbded8497f79036e420e850641 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Nov 2025 20:50:55 +0000 Subject: [PATCH 2/9] chore: add changeset for isReady disabled queries fix --- .changeset/fix-isready-disabled-queries.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-isready-disabled-queries.md diff --git a/.changeset/fix-isready-disabled-queries.md b/.changeset/fix-isready-disabled-queries.md new file mode 100644 index 000000000..f146fe4bf --- /dev/null +++ b/.changeset/fix-isready-disabled-queries.md @@ -0,0 +1,5 @@ +--- +"@tanstack/react-db": patch +--- + +Fixed `isReady` to return `true` for disabled queries in `useLiveQuery`. When a query function returns `null` or `undefined` (disabling the query), there's no async operation to wait for, so the hook should be considered "ready" immediately. This fixes the common pattern where users conditionally enable queries and don't want to show loading states when the query is disabled. From ab7511c808c2b72449bf8e68a07de4b531c6eff3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Nov 2025 21:18:00 +0000 Subject: [PATCH 3/9] fix: return isReady true for disabled queries in all frameworks Extended the fix for disabled queries to solid-db, vue-db, and svelte-db. All frameworks now properly handle when query functions return null/undefined by: - Returning null for the collection - Setting status to 'disabled' - Returning isReady: true (since there's nothing to wait for) This provides a consistent API across all framework packages and fixes the common conditional query pattern. Related to #883 --- .changeset/fix-isready-disabled-queries.md | 5 ++- packages/solid-db/src/useLiveQuery.ts | 44 ++++++++++++++++--- packages/svelte-db/src/useLiveQuery.svelte.ts | 29 ++++++++++-- packages/vue-db/src/useLiveQuery.ts | 31 +++++++++++-- 4 files changed, 97 insertions(+), 12 deletions(-) diff --git a/.changeset/fix-isready-disabled-queries.md b/.changeset/fix-isready-disabled-queries.md index f146fe4bf..81a54531b 100644 --- a/.changeset/fix-isready-disabled-queries.md +++ b/.changeset/fix-isready-disabled-queries.md @@ -1,5 +1,8 @@ --- "@tanstack/react-db": patch +"@tanstack/solid-db": patch +"@tanstack/vue-db": patch +"@tanstack/svelte-db": patch --- -Fixed `isReady` to return `true` for disabled queries in `useLiveQuery`. When a query function returns `null` or `undefined` (disabling the query), there's no async operation to wait for, so the hook should be considered "ready" immediately. This fixes the common pattern where users conditionally enable queries and don't want to show loading states when the query is disabled. +Fixed `isReady` to return `true` for disabled queries in `useLiveQuery` across all framework packages. When a query function returns `null` or `undefined` (disabling the query), there's no async operation to wait for, so the hook should be considered "ready" immediately. This fixes the common pattern where users conditionally enable queries and don't want to show loading states when the query is disabled. diff --git a/packages/solid-db/src/useLiveQuery.ts b/packages/solid-db/src/useLiveQuery.ts index d3e645871..2a17d1905 100644 --- a/packages/solid-db/src/useLiveQuery.ts +++ b/packages/solid-db/src/useLiveQuery.ts @@ -7,7 +7,11 @@ import { onCleanup, } from "solid-js" import { ReactiveMap } from "@solid-primitives/map" -import { CollectionImpl, createLiveQueryCollection } from "@tanstack/db" +import { + BaseQueryBuilder, + CollectionImpl, + createLiveQueryCollection, +} from "@tanstack/db" import { createStore, reconcile } from "solid-js/store" import type { Accessor } from "solid-js" import type { @@ -207,6 +211,15 @@ export function useLiveQuery( const collection = createMemo( () => { if (configOrQueryOrCollection.length === 1) { + // This is a query function - check if it returns null/undefined + const queryBuilder = new BaseQueryBuilder() as InitialQueryBuilder + const result = configOrQueryOrCollection(queryBuilder) + + if (result === undefined || result === null) { + // Disabled query - return null + return null + } + return createLiveQueryCollection({ query: configOrQueryOrCollection, startSync: true, @@ -214,6 +227,12 @@ export function useLiveQuery( } const innerCollection = configOrQueryOrCollection() + + if (innerCollection === undefined || innerCollection === null) { + // Disabled query - return null + return null + } + if (innerCollection instanceof CollectionImpl) { innerCollection.startSyncImmediate() return innerCollection as Collection @@ -237,9 +256,12 @@ export function useLiveQuery( }) // Track collection status reactively - const [status, setStatus] = createSignal(collection().status, { - name: `TanstackDBStatus`, - }) + const [status, setStatus] = createSignal( + collection() ? collection()!.status : (`disabled` as const), + { + name: `TanstackDBStatus`, + } + ) // Helper to sync data array from collection in correct order const syncDataFromCollection = ( @@ -257,6 +279,18 @@ export function useLiveQuery( () => { const currentCollection = collection() + // Handle null collection (disabled query) + if (!currentCollection) { + setStatus(`disabled` as const) + state.clear() + setData([]) + if (currentUnsubscribe) { + currentUnsubscribe() + currentUnsubscribe = null + } + return + } + // Update status ref whenever the effect runs setStatus(currentCollection.status) @@ -320,7 +354,7 @@ export function useLiveQuery( collection, status, isLoading: () => status() === `loading`, - isReady: () => status() === `ready`, + isReady: () => status() === `ready` || status() === `disabled`, isIdle: () => status() === `idle`, isError: () => status() === `error`, isCleanedUp: () => status() === `cleaned-up`, diff --git a/packages/svelte-db/src/useLiveQuery.svelte.ts b/packages/svelte-db/src/useLiveQuery.svelte.ts index d769d9237..9edcea0af 100644 --- a/packages/svelte-db/src/useLiveQuery.svelte.ts +++ b/packages/svelte-db/src/useLiveQuery.svelte.ts @@ -2,7 +2,7 @@ import { untrack } from "svelte" // eslint-disable-next-line import/no-duplicates -- See https://github.com/un-ts/eslint-plugin-import-x/issues/308 import { SvelteMap } from "svelte/reactivity" -import { createLiveQueryCollection } from "@tanstack/db" +import { BaseQueryBuilder, createLiveQueryCollection } from "@tanstack/db" import type { ChangeMessage, Collection, @@ -293,6 +293,15 @@ export function useLiveQuery( // Ensure we always start sync for Svelte helpers if (typeof unwrappedParam === `function`) { + // Check if query function returns null/undefined (disabled query) + const queryBuilder = new BaseQueryBuilder() as InitialQueryBuilder + const result = unwrappedParam(queryBuilder) + + if (result === undefined || result === null) { + // Disabled query - return null + return null + } + return createLiveQueryCollection({ query: unwrappedParam, startSync: true, @@ -312,7 +321,7 @@ export function useLiveQuery( let internalData = $state>([]) // Track collection status reactively - let status = $state(collection.status) + let status = $state(collection ? collection.status : (`disabled` as const)) // Helper to sync data array from collection in correct order const syncDataFromCollection = ( @@ -331,6 +340,20 @@ export function useLiveQuery( $effect(() => { const currentCollection = collection + // Handle null collection (disabled query) + if (!currentCollection) { + status = `disabled` as const + untrack(() => { + state.clear() + internalData = [] + }) + if (currentUnsubscribe) { + currentUnsubscribe() + currentUnsubscribe = null + } + return + } + // Update status state whenever the effect runs status = currentCollection.status @@ -419,7 +442,7 @@ export function useLiveQuery( return status === `loading` }, get isReady() { - return status === `ready` + return status === `ready` || status === `disabled` }, get isIdle() { return status === `idle` diff --git a/packages/vue-db/src/useLiveQuery.ts b/packages/vue-db/src/useLiveQuery.ts index 298eaed91..6f524d668 100644 --- a/packages/vue-db/src/useLiveQuery.ts +++ b/packages/vue-db/src/useLiveQuery.ts @@ -8,7 +8,7 @@ import { toValue, watchEffect, } from "vue" -import { createLiveQueryCollection } from "@tanstack/db" +import { BaseQueryBuilder, createLiveQueryCollection } from "@tanstack/db" import type { ChangeMessage, Collection, @@ -243,6 +243,15 @@ export function useLiveQuery( // Ensure we always start sync for Vue hooks if (typeof unwrappedParam === `function`) { + // Check if query function returns null/undefined (disabled query) + const queryBuilder = new BaseQueryBuilder() as InitialQueryBuilder + const result = unwrappedParam(queryBuilder) + + if (result === undefined || result === null) { + // Disabled query - return null + return null + } + return createLiveQueryCollection({ query: unwrappedParam, startSync: true, @@ -265,7 +274,9 @@ export function useLiveQuery( const data = computed(() => internalData) // Track collection status reactively - const status = ref(collection.value.status) + const status = ref( + collection.value ? collection.value.status : (`disabled` as const) + ) // Helper to sync data array from collection in correct order const syncDataFromCollection = ( @@ -282,6 +293,18 @@ export function useLiveQuery( watchEffect((onInvalidate) => { const currentCollection = collection.value + // Handle null collection (disabled query) + if (!currentCollection) { + status.value = `disabled` as const + state.clear() + internalData.length = 0 + if (currentUnsubscribe) { + currentUnsubscribe() + currentUnsubscribe = null + } + return + } + // Update status ref whenever the effect runs status.value = currentCollection.status @@ -366,7 +389,9 @@ export function useLiveQuery( collection: computed(() => collection.value), status: computed(() => status.value), isLoading: computed(() => status.value === `loading`), - isReady: computed(() => status.value === `ready`), + isReady: computed( + () => status.value === `ready` || status.value === `disabled` + ), isIdle: computed(() => status.value === `idle`), isError: computed(() => status.value === `error`), isCleanedUp: computed(() => status.value === `cleaned-up`), From e8fd49324b7e381fb257a7d160a528ac1cbf4ed1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Nov 2025 21:20:34 +0000 Subject: [PATCH 4/9] feat: add TypeScript overloads for disabled queries in all frameworks Added explicit TypeScript overloads to support returning null/undefined from query functions across all framework packages: - solid-db: useLiveQuery - vue-db: useLiveQuery - svelte-db: useLiveQuery - angular-db: injectLiveQuery This makes the disabled query pattern type-safe, allowing developers to conditionally enable queries with proper type inference. Related to #883 --- .changeset/fix-isready-disabled-queries.md | 7 ++- packages/angular-db/src/index.ts | 45 +++++++++++++++++-- packages/solid-db/src/useLiveQuery.ts | 21 ++++++++- packages/svelte-db/src/useLiveQuery.svelte.ts | 10 ++++- packages/vue-db/src/useLiveQuery.ts | 10 ++++- 5 files changed, 86 insertions(+), 7 deletions(-) diff --git a/.changeset/fix-isready-disabled-queries.md b/.changeset/fix-isready-disabled-queries.md index 81a54531b..132902238 100644 --- a/.changeset/fix-isready-disabled-queries.md +++ b/.changeset/fix-isready-disabled-queries.md @@ -3,6 +3,11 @@ "@tanstack/solid-db": patch "@tanstack/vue-db": patch "@tanstack/svelte-db": patch +"@tanstack/angular-db": patch --- -Fixed `isReady` to return `true` for disabled queries in `useLiveQuery` across all framework packages. When a query function returns `null` or `undefined` (disabling the query), there's no async operation to wait for, so the hook should be considered "ready" immediately. This fixes the common pattern where users conditionally enable queries and don't want to show loading states when the query is disabled. +Fixed `isReady` to return `true` for disabled queries in `useLiveQuery`/`injectLiveQuery` across all framework packages. When a query function returns `null` or `undefined` (disabling the query), there's no async operation to wait for, so the hook should be considered "ready" immediately. + +Additionally, all frameworks now have proper TypeScript overloads that explicitly support returning `undefined | null` from query functions, making the disabled query pattern type-safe. + +This fixes the common pattern where users conditionally enable queries and don't want to show loading states when the query is disabled. diff --git a/packages/angular-db/src/index.ts b/packages/angular-db/src/index.ts index b2080b3e4..e26ce243c 100644 --- a/packages/angular-db/src/index.ts +++ b/packages/angular-db/src/index.ts @@ -6,7 +6,7 @@ import { inject, signal, } from "@angular/core" -import { createLiveQueryCollection } from "@tanstack/db" +import { BaseQueryBuilder, createLiveQueryCollection } from "@tanstack/db" import type { ChangeMessage, Collection, @@ -58,9 +58,22 @@ export function injectLiveQuery< q: InitialQueryBuilder }) => QueryBuilder }): InjectLiveQueryResult> +export function injectLiveQuery< + TContext extends Context, + TParams extends any, +>(options: { + params: () => TParams + query: (args: { + params: TParams + q: InitialQueryBuilder + }) => QueryBuilder | undefined | null +}): InjectLiveQueryResult> export function injectLiveQuery( queryFn: (q: InitialQueryBuilder) => QueryBuilder ): InjectLiveQueryResult> +export function injectLiveQuery( + queryFn: (q: InitialQueryBuilder) => QueryBuilder | undefined | null +): InjectLiveQueryResult> export function injectLiveQuery( config: LiveQueryCollectionConfig ): InjectLiveQueryResult> @@ -89,6 +102,15 @@ export function injectLiveQuery(opts: any) { } if (typeof opts === `function`) { + // Check if query function returns null/undefined (disabled query) + const queryBuilder = new BaseQueryBuilder() as InitialQueryBuilder + const result = opts(queryBuilder) + + if (result === undefined || result === null) { + // Disabled query - return null + return null + } + return createLiveQueryCollection({ query: opts, startSync: true, @@ -106,6 +128,16 @@ export function injectLiveQuery(opts: any) { if (isReactiveQueryOptions) { const { params, query } = opts const currentParams = params() + + // Check if query function returns null/undefined (disabled query) + const queryBuilder = new BaseQueryBuilder() as InitialQueryBuilder + const result = query({ params: currentParams, q: queryBuilder }) + + if (result === undefined || result === null) { + // Disabled query - return null + return null + } + return createLiveQueryCollection({ query: (q) => query({ params: currentParams, q }), startSync: true, @@ -123,7 +155,9 @@ export function injectLiveQuery(opts: any) { const state = signal(new Map()) const data = signal>([]) - const status = signal(`idle`) + const status = signal( + collection() ? `idle` : `disabled` + ) const syncDataFromCollection = ( currentCollection: Collection @@ -145,7 +179,12 @@ export function injectLiveQuery(opts: any) { effect((onCleanup) => { const currentCollection = collection() + // Handle null collection (disabled query) if (!currentCollection) { + status.set(`disabled` as const) + state.set(new Map()) + data.set([]) + cleanup() return } @@ -185,7 +224,7 @@ export function injectLiveQuery(opts: any) { collection, status, isLoading: computed(() => status() === `loading`), - isReady: computed(() => status() === `ready`), + isReady: computed(() => status() === `ready` || status() === `disabled`), isIdle: computed(() => status() === `idle`), isError: computed(() => status() === `error`), isCleanedUp: computed(() => status() === `cleaned-up`), diff --git a/packages/solid-db/src/useLiveQuery.ts b/packages/solid-db/src/useLiveQuery.ts index 2a17d1905..9654ac20b 100644 --- a/packages/solid-db/src/useLiveQuery.ts +++ b/packages/solid-db/src/useLiveQuery.ts @@ -80,7 +80,7 @@ import type { * * ) */ -// Overload 1: Accept just the query function +// Overload 1: Accept query function that always returns QueryBuilder export function useLiveQuery( queryFn: (q: InitialQueryBuilder) => QueryBuilder ): { @@ -95,6 +95,25 @@ export function useLiveQuery( isCleanedUp: Accessor } +// Overload 1b: Accept query function that can return undefined/null +export function useLiveQuery( + queryFn: (q: InitialQueryBuilder) => QueryBuilder | undefined | null +): { + state: ReactiveMap> + data: Array> + collection: Accessor, + string | number, + {} + > | null> + status: Accessor + isLoading: Accessor + isReady: Accessor + isIdle: Accessor + isError: Accessor + isCleanedUp: Accessor +} + /** * Create a live query using configuration object * @param config - Configuration object with query and options diff --git a/packages/svelte-db/src/useLiveQuery.svelte.ts b/packages/svelte-db/src/useLiveQuery.svelte.ts index 9edcea0af..af7f5b550 100644 --- a/packages/svelte-db/src/useLiveQuery.svelte.ts +++ b/packages/svelte-db/src/useLiveQuery.svelte.ts @@ -151,12 +151,20 @@ function toValue(value: MaybeGetter): T { * // * // {/if} */ -// Overload 1: Accept just the query function +// Overload 1: Accept query function that always returns QueryBuilder export function useLiveQuery( queryFn: (q: InitialQueryBuilder) => QueryBuilder, deps?: Array<() => unknown> ): UseLiveQueryReturn> +// Overload 1b: Accept query function that can return undefined/null +export function useLiveQuery( + queryFn: ( + q: InitialQueryBuilder + ) => QueryBuilder | undefined | null, + deps?: Array<() => unknown> +): UseLiveQueryReturn> + /** * Create a live query using configuration object * @param config - Configuration object with query and options diff --git a/packages/vue-db/src/useLiveQuery.ts b/packages/vue-db/src/useLiveQuery.ts index 6f524d668..56962e90b 100644 --- a/packages/vue-db/src/useLiveQuery.ts +++ b/packages/vue-db/src/useLiveQuery.ts @@ -110,12 +110,20 @@ export interface UseLiveQueryReturnWithCollection< * //
  • {{ todo.text }}
  • * // */ -// Overload 1: Accept just the query function +// Overload 1: Accept query function that always returns QueryBuilder export function useLiveQuery( queryFn: (q: InitialQueryBuilder) => QueryBuilder, deps?: Array> ): UseLiveQueryReturn> +// Overload 1b: Accept query function that can return undefined/null +export function useLiveQuery( + queryFn: ( + q: InitialQueryBuilder + ) => QueryBuilder | undefined | null, + deps?: Array> +): UseLiveQueryReturn> + /** * Create a live query using configuration object * @param config - Configuration object with query and options From 5ac3b5590a1ab80d860e322566dc48fae1078fb0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Dec 2025 14:27:54 +0000 Subject: [PATCH 5/9] fix(angular-db): fix TypeScript overload compatibility --- packages/angular-db/src/index.ts | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/angular-db/src/index.ts b/packages/angular-db/src/index.ts index e26ce243c..7827f4885 100644 --- a/packages/angular-db/src/index.ts +++ b/packages/angular-db/src/index.ts @@ -67,13 +67,41 @@ export function injectLiveQuery< params: TParams q: InitialQueryBuilder }) => QueryBuilder | undefined | null -}): InjectLiveQueryResult> +}): { + state: Signal>> + data: Signal>> + collection: Signal, + string | number, + {} + > | null> + status: Signal + isLoading: Signal + isReady: Signal + isIdle: Signal + isError: Signal + isCleanedUp: Signal +} export function injectLiveQuery( queryFn: (q: InitialQueryBuilder) => QueryBuilder ): InjectLiveQueryResult> export function injectLiveQuery( queryFn: (q: InitialQueryBuilder) => QueryBuilder | undefined | null -): InjectLiveQueryResult> +): { + state: Signal>> + data: Signal>> + collection: Signal, + string | number, + {} + > | null> + status: Signal + isLoading: Signal + isReady: Signal + isIdle: Signal + isError: Signal + isCleanedUp: Signal +} export function injectLiveQuery( config: LiveQueryCollectionConfig ): InjectLiveQueryResult> From 1cff49b1b86609c17d26489cfc13fb8b99367a7f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Dec 2025 14:33:18 +0000 Subject: [PATCH 6/9] test: add disabled query tests for solid-db and vue-db Added tests to verify that disabled queries (returning null/undefined) properly return isReady: true. Solid tests pass. Vue tests added but need additional debugging of the reactive deps implementation. Related to #883 - addressing Sam's review feedback --- packages/solid-db/tests/useLiveQuery.test.tsx | 109 ++++++++++++++++++ packages/vue-db/tests/useLiveQuery.test.ts | 101 ++++++++++++++++ 2 files changed, 210 insertions(+) diff --git a/packages/solid-db/tests/useLiveQuery.test.tsx b/packages/solid-db/tests/useLiveQuery.test.tsx index 459a10bd1..db59f828a 100644 --- a/packages/solid-db/tests/useLiveQuery.test.tsx +++ b/packages/solid-db/tests/useLiveQuery.test.tsx @@ -1748,4 +1748,113 @@ describe(`Query Collections`, () => { expect(result.status()).toBe(`ready`) }) }) + + describe(`Disabled queries`, () => { + it(`should handle callback returning undefined with proper state`, async () => { + return createRoot(async (dispose) => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `disabled-undefined-test`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }) + ) + + const [enabled, setEnabled] = createSignal(false) + const rendered = renderHook( + (props: { enabled: Accessor }) => { + return useLiveQuery((q) => { + if (!props.enabled()) return undefined + return q + .from({ collection }) + .where(({ collection: c }) => gt(c.age, 30)) + .select(({ collection: c }) => ({ + id: c.id, + name: c.name, + age: c.age, + })) + }) + }, + { initialProps: [{ enabled }] } + ) + + // When callback returns undefined, should return disabled state + expect(rendered.result.state.size).toBe(0) + expect(rendered.result.data).toEqual([]) + expect(rendered.result.collection()).toBeNull() + expect(rendered.result.status()).toBe(`disabled`) + expect(rendered.result.isLoading()).toBe(false) + expect(rendered.result.isReady()).toBe(true) + + // Enable the query + setEnabled(true) + await new Promise((resolve) => setTimeout(resolve, 10)) + + await waitFor(() => { + expect(rendered.result.state.size).toBe(1) // Only John Smith (age 35) + }) + expect(rendered.result.data).toHaveLength(1) + expect(rendered.result.isReady()).toBe(true) + + // Disable the query again + setEnabled(false) + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(rendered.result.status()).toBe(`disabled`) + expect(rendered.result.isReady()).toBe(true) + + dispose() + }) + }) + + it(`should handle callback returning null with proper state`, async () => { + return createRoot(async (dispose) => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `disabled-null-test`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }) + ) + + const [enabled, setEnabled] = createSignal(false) + const rendered = renderHook( + (props: { enabled: Accessor }) => { + return useLiveQuery((q) => { + if (!props.enabled()) return null + return q + .from({ collection }) + .where(({ collection: c }) => gt(c.age, 30)) + .select(({ collection: c }) => ({ + id: c.id, + name: c.name, + age: c.age, + })) + }) + }, + { initialProps: [{ enabled }] } + ) + + // When callback returns null, should return disabled state + expect(rendered.result.state.size).toBe(0) + expect(rendered.result.data).toEqual([]) + expect(rendered.result.collection()).toBeNull() + expect(rendered.result.status()).toBe(`disabled`) + expect(rendered.result.isLoading()).toBe(false) + expect(rendered.result.isReady()).toBe(true) + + // Enable the query + setEnabled(true) + await new Promise((resolve) => setTimeout(resolve, 10)) + + await waitFor(() => { + expect(rendered.result.state.size).toBe(1) + }) + expect(rendered.result.data).toHaveLength(1) + expect(rendered.result.isReady()).toBe(true) + + dispose() + }) + }) + }) }) diff --git a/packages/vue-db/tests/useLiveQuery.test.ts b/packages/vue-db/tests/useLiveQuery.test.ts index 372bba52d..60dc77cd9 100644 --- a/packages/vue-db/tests/useLiveQuery.test.ts +++ b/packages/vue-db/tests/useLiveQuery.test.ts @@ -1812,4 +1812,105 @@ describe(`Query Collections`, () => { expect(status.value).toBe(`ready`) }) }) + + describe(`Disabled queries`, () => { + it(`should handle callback returning undefined with proper state`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `disabled-undefined-test`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }) + ) + + const enabled = ref(false) + const result = useLiveQuery( + (q) => { + if (!enabled.value) return undefined + return q + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + age: persons.age, + })) + }, + [() => enabled.value] + ) + + // When callback returns undefined, should return disabled state + expect(result.state.value.size).toBe(0) + expect(result.data.value).toEqual([]) + expect(result.collection.value).toBeNull() + expect(result.status.value).toBe(`disabled`) + expect(result.isLoading.value).toBe(false) + expect(result.isReady.value).toBe(true) + + // Enable the query + enabled.value = true + await waitFor(() => { + expect(result.collection.value).not.toBeNull() + }) + + await waitFor(() => { + expect(result.state.value.size).toBe(1) // Only John Smith (age 35) + }) + expect(result.data.value).toHaveLength(1) + expect(result.isReady.value).toBe(true) + + // Disable the query again + enabled.value = false + await waitFor(() => { + expect(result.status.value).toBe(`disabled`) + }) + expect(result.isReady.value).toBe(true) + }) + + it(`should handle callback returning null with proper state`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `disabled-null-test`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }) + ) + + const enabled = ref(false) + const result = useLiveQuery( + (q) => { + if (!enabled.value) return null + return q + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + age: persons.age, + })) + }, + [() => enabled.value] + ) + + // When callback returns null, should return disabled state + expect(result.state.value.size).toBe(0) + expect(result.data.value).toEqual([]) + expect(result.collection.value).toBeNull() + expect(result.status.value).toBe(`disabled`) + expect(result.isLoading.value).toBe(false) + expect(result.isReady.value).toBe(true) + + // Enable the query + enabled.value = true + await waitFor(() => { + expect(result.collection.value).not.toBeNull() + }) + + await waitFor(() => { + expect(result.state.value.size).toBe(1) + }) + expect(result.data.value).toHaveLength(1) + expect(result.isReady.value).toBe(true) + }) + }) }) From 8e61399ec08b62d2bd4295938e43b4afdf3da83a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Dec 2025 14:47:25 +0000 Subject: [PATCH 7/9] fix(angular-db): fix isReady to return true for disabled queries - Updated InjectLiveQueryResult interface to support nullable collection and disabled status - Fixed isReady computed to return true when status is 'disabled' - Added TypeScript overloads for query functions that can return null/undefined - Added tests for disabled query functionality with reactive params --- packages/angular-db/src/index.ts | 38 +----- .../tests/inject-live-query.test.ts | 115 ++++++++++++++++++ 2 files changed, 120 insertions(+), 33 deletions(-) diff --git a/packages/angular-db/src/index.ts b/packages/angular-db/src/index.ts index 7827f4885..2220dc85f 100644 --- a/packages/angular-db/src/index.ts +++ b/packages/angular-db/src/index.ts @@ -32,10 +32,10 @@ export interface InjectLiveQueryResult< state: Signal> /** A signal containing the results as an array */ data: Signal> - /** A signal containing the underlying collection instance */ - collection: Signal> + /** A signal containing the underlying collection instance (null for disabled queries) */ + collection: Signal | null> /** A signal containing the current status of the collection */ - status: Signal + status: Signal /** A signal indicating whether the collection is currently loading */ isLoading: Signal /** A signal indicating whether the collection is ready */ @@ -67,41 +67,13 @@ export function injectLiveQuery< params: TParams q: InitialQueryBuilder }) => QueryBuilder | undefined | null -}): { - state: Signal>> - data: Signal>> - collection: Signal, - string | number, - {} - > | null> - status: Signal - isLoading: Signal - isReady: Signal - isIdle: Signal - isError: Signal - isCleanedUp: Signal -} +}): InjectLiveQueryResult> export function injectLiveQuery( queryFn: (q: InitialQueryBuilder) => QueryBuilder ): InjectLiveQueryResult> export function injectLiveQuery( queryFn: (q: InitialQueryBuilder) => QueryBuilder | undefined | null -): { - state: Signal>> - data: Signal>> - collection: Signal, - string | number, - {} - > | null> - status: Signal - isLoading: Signal - isReady: Signal - isIdle: Signal - isError: Signal - isCleanedUp: Signal -} +): InjectLiveQueryResult> export function injectLiveQuery( config: LiveQueryCollectionConfig ): InjectLiveQueryResult> diff --git a/packages/angular-db/tests/inject-live-query.test.ts b/packages/angular-db/tests/inject-live-query.test.ts index 6579bd0a3..7c721b453 100644 --- a/packages/angular-db/tests/inject-live-query.test.ts +++ b/packages/angular-db/tests/inject-live-query.test.ts @@ -1031,4 +1031,119 @@ describe(`injectLiveQuery`, () => { }) }) }) + + describe(`disabled queries`, () => { + it(`should handle query function returning undefined with proper state`, async () => { + await TestBed.runInInjectionContext(async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `disabled-test-undefined-angular`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }) + ) + + const enabled = signal(false) + + const result = injectLiveQuery({ + params: () => ({ enabled: enabled() }), + query: ({ params, q }) => { + if (!params.enabled) return undefined + return q + .from({ persons: collection }) + .where(({ persons }) => eq(persons.isActive, true)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + })) + }, + }) + + await waitForAngularUpdate() + + // When disabled, status should be 'disabled' and isReady should be true + expect(result.status()).toBe(`disabled`) + expect(result.isReady()).toBe(true) + expect(result.isLoading()).toBe(false) + expect(result.isIdle()).toBe(false) + expect(result.isError()).toBe(false) + expect(result.collection()).toBeNull() + expect(result.data()).toEqual([]) + expect(result.state().size).toBe(0) + + // Enable the query + enabled.set(true) + await waitForAngularUpdate() + + // Should now be ready with data + expect(result.status()).toBe(`ready`) + expect(result.isReady()).toBe(true) + expect(result.isLoading()).toBe(false) + expect(result.collection()).not.toBeNull() + expect(result.data().length).toBeGreaterThan(0) + }) + }) + + it(`should handle query function returning null with proper state`, async () => { + await TestBed.runInInjectionContext(async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `disabled-test-null-angular`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }) + ) + + const enabled = signal(false) + + const result = injectLiveQuery({ + params: () => ({ enabled: enabled() }), + query: ({ params, q }) => { + if (!params.enabled) return null + return q + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 25)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + age: persons.age, + })) + }, + }) + + await waitForAngularUpdate() + + // When disabled, status should be 'disabled' and isReady should be true + expect(result.status()).toBe(`disabled`) + expect(result.isReady()).toBe(true) + expect(result.isLoading()).toBe(false) + expect(result.isIdle()).toBe(false) + expect(result.isError()).toBe(false) + expect(result.collection()).toBeNull() + expect(result.data()).toEqual([]) + expect(result.state().size).toBe(0) + + // Enable the query + enabled.set(true) + await waitForAngularUpdate() + + // Should now be ready with data + expect(result.status()).toBe(`ready`) + expect(result.isReady()).toBe(true) + expect(result.isLoading()).toBe(false) + expect(result.collection()).not.toBeNull() + expect(result.data().length).toBeGreaterThan(0) + + // Disable again + enabled.set(false) + await waitForAngularUpdate() + + // Should go back to disabled state + expect(result.status()).toBe(`disabled`) + expect(result.isReady()).toBe(true) + expect(result.collection()).toBeNull() + expect(result.data()).toEqual([]) + }) + }) + }) }) From a98ba2455bd3d78646cc8f4200fad73d584b617d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Dec 2025 15:04:31 +0000 Subject: [PATCH 8/9] fix(vue-db): add disabled query support with TODO for test pattern - Added check for query functions that return null/undefined - Sets status to 'disabled' when collection is null - Fixed isReady to return true for disabled status - Added tests but marked as skipped due to Vue reactivity limitations - TODO: Need different test pattern that works with Vue's reactivity system --- packages/vue-db/src/useLiveQuery.ts | 28 ++++++++++++++++++---- packages/vue-db/tests/useLiveQuery.test.ts | 7 ++++-- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/packages/vue-db/src/useLiveQuery.ts b/packages/vue-db/src/useLiveQuery.ts index 56962e90b..5fa9afaeb 100644 --- a/packages/vue-db/src/useLiveQuery.ts +++ b/packages/vue-db/src/useLiveQuery.ts @@ -251,12 +251,30 @@ export function useLiveQuery( // Ensure we always start sync for Vue hooks if (typeof unwrappedParam === `function`) { - // Check if query function returns null/undefined (disabled query) - const queryBuilder = new BaseQueryBuilder() as InitialQueryBuilder - const result = unwrappedParam(queryBuilder) + // We need to check if the query function might return null/undefined + // without actually calling it with BaseQueryBuilder (which doesn't work well with Vue's reactivity) + // So we wrap it and let createLiveQueryCollection handle errors + let checkedForNull = false + let isNullQuery = false + + const checkQuery = () => { + if (!checkedForNull) { + try { + const queryBuilder = new BaseQueryBuilder() as InitialQueryBuilder + const result = unwrappedParam(queryBuilder) + isNullQuery = result === undefined || result === null + checkedForNull = true + } catch { + // If checking fails, assume it's a normal query + isNullQuery = false + checkedForNull = true + } + } + return isNullQuery + } - if (result === undefined || result === null) { - // Disabled query - return null + // Check once at creation time + if (checkQuery()) { return null } diff --git a/packages/vue-db/tests/useLiveQuery.test.ts b/packages/vue-db/tests/useLiveQuery.test.ts index 60dc77cd9..48a5b43ee 100644 --- a/packages/vue-db/tests/useLiveQuery.test.ts +++ b/packages/vue-db/tests/useLiveQuery.test.ts @@ -1814,7 +1814,10 @@ describe(`Query Collections`, () => { }) describe(`Disabled queries`, () => { - it(`should handle callback returning undefined with proper state`, async () => { + // TODO: These tests need rework for Vue's reactivity system + // The BaseQueryBuilder check doesn't work well when the query function + // accesses reactive values directly. Need to use a different pattern. + it.skip(`should handle callback returning undefined with proper state`, async () => { const collection = createCollection( mockSyncCollectionOptions({ id: `disabled-undefined-test`, @@ -1867,7 +1870,7 @@ describe(`Query Collections`, () => { expect(result.isReady.value).toBe(true) }) - it(`should handle callback returning null with proper state`, async () => { + it.skip(`should handle callback returning null with proper state`, async () => { const collection = createCollection( mockSyncCollectionOptions({ id: `disabled-null-test`, From d7aef20a6d6c835459522dd675f759b804e9e180 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Dec 2025 15:46:46 +0000 Subject: [PATCH 9/9] fix(vue-db): fix disabled query support and avoid double-invocation - Fixed toValue() being called on query functions, which was treating them as getters - Wrapped query functions to handle null/undefined returns without double-invocation - All 25 tests now passing including disabled query tests - Performance improvement: query function only called once by createLiveQueryCollection --- packages/vue-db/src/useLiveQuery.ts | 67 +++++++++++----------- packages/vue-db/tests/useLiveQuery.test.ts | 7 +-- 2 files changed, 34 insertions(+), 40 deletions(-) diff --git a/packages/vue-db/src/useLiveQuery.ts b/packages/vue-db/src/useLiveQuery.ts index 5fa9afaeb..a7116b66d 100644 --- a/packages/vue-db/src/useLiveQuery.ts +++ b/packages/vue-db/src/useLiveQuery.ts @@ -8,7 +8,7 @@ import { toValue, watchEffect, } from "vue" -import { BaseQueryBuilder, createLiveQueryCollection } from "@tanstack/db" +import { createLiveQueryCollection } from "@tanstack/db" import type { ChangeMessage, Collection, @@ -218,15 +218,18 @@ export function useLiveQuery( const collection = computed(() => { // First check if the original parameter might be a ref/getter // by seeing if toValue returns something different than the original + // NOTE: Don't call toValue on functions - toValue treats functions as getters and calls them! let unwrappedParam = configOrQueryOrCollection - try { - const potentiallyUnwrapped = toValue(configOrQueryOrCollection) - if (potentiallyUnwrapped !== configOrQueryOrCollection) { - unwrappedParam = potentiallyUnwrapped + if (typeof configOrQueryOrCollection !== `function`) { + try { + const potentiallyUnwrapped = toValue(configOrQueryOrCollection) + if (potentiallyUnwrapped !== configOrQueryOrCollection) { + unwrappedParam = potentiallyUnwrapped + } + } catch { + // If toValue fails, use original parameter + unwrappedParam = configOrQueryOrCollection } - } catch { - // If toValue fails, use original parameter - unwrappedParam = configOrQueryOrCollection } // Check if it's already a collection by checking for specific collection methods @@ -251,37 +254,31 @@ export function useLiveQuery( // Ensure we always start sync for Vue hooks if (typeof unwrappedParam === `function`) { - // We need to check if the query function might return null/undefined - // without actually calling it with BaseQueryBuilder (which doesn't work well with Vue's reactivity) - // So we wrap it and let createLiveQueryCollection handle errors - let checkedForNull = false - let isNullQuery = false - - const checkQuery = () => { - if (!checkedForNull) { - try { - const queryBuilder = new BaseQueryBuilder() as InitialQueryBuilder - const result = unwrappedParam(queryBuilder) - isNullQuery = result === undefined || result === null - checkedForNull = true - } catch { - // If checking fails, assume it's a normal query - isNullQuery = false - checkedForNull = true - } + // 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 isNullQuery + return result } - // Check once at creation time - if (checkQuery()) { - return null + 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 } - - return createLiveQueryCollection({ - query: unwrappedParam, - startSync: true, - }) } else { return createLiveQueryCollection({ ...unwrappedParam, diff --git a/packages/vue-db/tests/useLiveQuery.test.ts b/packages/vue-db/tests/useLiveQuery.test.ts index 48a5b43ee..60dc77cd9 100644 --- a/packages/vue-db/tests/useLiveQuery.test.ts +++ b/packages/vue-db/tests/useLiveQuery.test.ts @@ -1814,10 +1814,7 @@ describe(`Query Collections`, () => { }) describe(`Disabled queries`, () => { - // TODO: These tests need rework for Vue's reactivity system - // The BaseQueryBuilder check doesn't work well when the query function - // accesses reactive values directly. Need to use a different pattern. - it.skip(`should handle callback returning undefined with proper state`, async () => { + it(`should handle callback returning undefined with proper state`, async () => { const collection = createCollection( mockSyncCollectionOptions({ id: `disabled-undefined-test`, @@ -1870,7 +1867,7 @@ describe(`Query Collections`, () => { expect(result.isReady.value).toBe(true) }) - it.skip(`should handle callback returning null with proper state`, async () => { + it(`should handle callback returning null with proper state`, async () => { const collection = createCollection( mockSyncCollectionOptions({ id: `disabled-null-test`,