Skip to content

Commit e12514a

Browse files
KyleAMathewsclaude
andauthored
Fix isReady return value for early useLiveQuery exits (#886)
* 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 * chore: add changeset for isReady disabled queries fix * 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 * 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 * fix(angular-db): fix TypeScript overload compatibility * 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 * 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 * 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 * 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 --------- Co-authored-by: Claude <[email protected]>
1 parent 747c147 commit e12514a

File tree

10 files changed

+543
-34
lines changed

10 files changed

+543
-34
lines changed
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@tanstack/react-db": patch
3+
"@tanstack/solid-db": patch
4+
"@tanstack/vue-db": patch
5+
"@tanstack/svelte-db": patch
6+
"@tanstack/angular-db": patch
7+
---
8+
9+
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.
10+
11+
Additionally, all frameworks now have proper TypeScript overloads that explicitly support returning `undefined | null` from query functions, making the disabled query pattern type-safe.
12+
13+
This fixes the common pattern where users conditionally enable queries and don't want to show loading states when the query is disabled.

packages/angular-db/src/index.ts

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
inject,
77
signal,
88
} from "@angular/core"
9-
import { createLiveQueryCollection } from "@tanstack/db"
9+
import { BaseQueryBuilder, createLiveQueryCollection } from "@tanstack/db"
1010
import type {
1111
ChangeMessage,
1212
Collection,
@@ -32,10 +32,10 @@ export interface InjectLiveQueryResult<
3232
state: Signal<Map<TKey, TResult>>
3333
/** A signal containing the results as an array */
3434
data: Signal<Array<TResult>>
35-
/** A signal containing the underlying collection instance */
36-
collection: Signal<Collection<TResult, TKey, TUtils>>
35+
/** A signal containing the underlying collection instance (null for disabled queries) */
36+
collection: Signal<Collection<TResult, TKey, TUtils> | null>
3737
/** A signal containing the current status of the collection */
38-
status: Signal<CollectionStatus>
38+
status: Signal<CollectionStatus | `disabled`>
3939
/** A signal indicating whether the collection is currently loading */
4040
isLoading: Signal<boolean>
4141
/** A signal indicating whether the collection is ready */
@@ -58,9 +58,22 @@ export function injectLiveQuery<
5858
q: InitialQueryBuilder
5959
}) => QueryBuilder<TContext>
6060
}): InjectLiveQueryResult<GetResult<TContext>>
61+
export function injectLiveQuery<
62+
TContext extends Context,
63+
TParams extends any,
64+
>(options: {
65+
params: () => TParams
66+
query: (args: {
67+
params: TParams
68+
q: InitialQueryBuilder
69+
}) => QueryBuilder<TContext> | undefined | null
70+
}): InjectLiveQueryResult<GetResult<TContext>>
6171
export function injectLiveQuery<TContext extends Context>(
6272
queryFn: (q: InitialQueryBuilder) => QueryBuilder<TContext>
6373
): InjectLiveQueryResult<GetResult<TContext>>
74+
export function injectLiveQuery<TContext extends Context>(
75+
queryFn: (q: InitialQueryBuilder) => QueryBuilder<TContext> | undefined | null
76+
): InjectLiveQueryResult<GetResult<TContext>>
6477
export function injectLiveQuery<TContext extends Context>(
6578
config: LiveQueryCollectionConfig<TContext>
6679
): InjectLiveQueryResult<GetResult<TContext>>
@@ -89,6 +102,15 @@ export function injectLiveQuery(opts: any) {
89102
}
90103

91104
if (typeof opts === `function`) {
105+
// Check if query function returns null/undefined (disabled query)
106+
const queryBuilder = new BaseQueryBuilder() as InitialQueryBuilder
107+
const result = opts(queryBuilder)
108+
109+
if (result === undefined || result === null) {
110+
// Disabled query - return null
111+
return null
112+
}
113+
92114
return createLiveQueryCollection({
93115
query: opts,
94116
startSync: true,
@@ -106,6 +128,16 @@ export function injectLiveQuery(opts: any) {
106128
if (isReactiveQueryOptions) {
107129
const { params, query } = opts
108130
const currentParams = params()
131+
132+
// Check if query function returns null/undefined (disabled query)
133+
const queryBuilder = new BaseQueryBuilder() as InitialQueryBuilder
134+
const result = query({ params: currentParams, q: queryBuilder })
135+
136+
if (result === undefined || result === null) {
137+
// Disabled query - return null
138+
return null
139+
}
140+
109141
return createLiveQueryCollection({
110142
query: (q) => query({ params: currentParams, q }),
111143
startSync: true,
@@ -123,7 +155,9 @@ export function injectLiveQuery(opts: any) {
123155

124156
const state = signal(new Map<string | number, any>())
125157
const data = signal<Array<any>>([])
126-
const status = signal<CollectionStatus>(`idle`)
158+
const status = signal<CollectionStatus | `disabled`>(
159+
collection() ? `idle` : `disabled`
160+
)
127161

128162
const syncDataFromCollection = (
129163
currentCollection: Collection<any, any, any>
@@ -145,7 +179,12 @@ export function injectLiveQuery(opts: any) {
145179
effect((onCleanup) => {
146180
const currentCollection = collection()
147181

182+
// Handle null collection (disabled query)
148183
if (!currentCollection) {
184+
status.set(`disabled` as const)
185+
state.set(new Map())
186+
data.set([])
187+
cleanup()
149188
return
150189
}
151190

@@ -185,7 +224,7 @@ export function injectLiveQuery(opts: any) {
185224
collection,
186225
status,
187226
isLoading: computed(() => status() === `loading`),
188-
isReady: computed(() => status() === `ready`),
227+
isReady: computed(() => status() === `ready` || status() === `disabled`),
189228
isIdle: computed(() => status() === `idle`),
190229
isError: computed(() => status() === `error`),
191230
isCleanedUp: computed(() => status() === `cleaned-up`),

packages/angular-db/tests/inject-live-query.test.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1031,4 +1031,119 @@ describe(`injectLiveQuery`, () => {
10311031
})
10321032
})
10331033
})
1034+
1035+
describe(`disabled queries`, () => {
1036+
it(`should handle query function returning undefined with proper state`, async () => {
1037+
await TestBed.runInInjectionContext(async () => {
1038+
const collection = createCollection(
1039+
mockSyncCollectionOptions<Person>({
1040+
id: `disabled-test-undefined-angular`,
1041+
getKey: (person: Person) => person.id,
1042+
initialData: initialPersons,
1043+
})
1044+
)
1045+
1046+
const enabled = signal(false)
1047+
1048+
const result = injectLiveQuery({
1049+
params: () => ({ enabled: enabled() }),
1050+
query: ({ params, q }) => {
1051+
if (!params.enabled) return undefined
1052+
return q
1053+
.from({ persons: collection })
1054+
.where(({ persons }) => eq(persons.isActive, true))
1055+
.select(({ persons }) => ({
1056+
id: persons.id,
1057+
name: persons.name,
1058+
}))
1059+
},
1060+
})
1061+
1062+
await waitForAngularUpdate()
1063+
1064+
// When disabled, status should be 'disabled' and isReady should be true
1065+
expect(result.status()).toBe(`disabled`)
1066+
expect(result.isReady()).toBe(true)
1067+
expect(result.isLoading()).toBe(false)
1068+
expect(result.isIdle()).toBe(false)
1069+
expect(result.isError()).toBe(false)
1070+
expect(result.collection()).toBeNull()
1071+
expect(result.data()).toEqual([])
1072+
expect(result.state().size).toBe(0)
1073+
1074+
// Enable the query
1075+
enabled.set(true)
1076+
await waitForAngularUpdate()
1077+
1078+
// Should now be ready with data
1079+
expect(result.status()).toBe(`ready`)
1080+
expect(result.isReady()).toBe(true)
1081+
expect(result.isLoading()).toBe(false)
1082+
expect(result.collection()).not.toBeNull()
1083+
expect(result.data().length).toBeGreaterThan(0)
1084+
})
1085+
})
1086+
1087+
it(`should handle query function returning null with proper state`, async () => {
1088+
await TestBed.runInInjectionContext(async () => {
1089+
const collection = createCollection(
1090+
mockSyncCollectionOptions<Person>({
1091+
id: `disabled-test-null-angular`,
1092+
getKey: (person: Person) => person.id,
1093+
initialData: initialPersons,
1094+
})
1095+
)
1096+
1097+
const enabled = signal(false)
1098+
1099+
const result = injectLiveQuery({
1100+
params: () => ({ enabled: enabled() }),
1101+
query: ({ params, q }) => {
1102+
if (!params.enabled) return null
1103+
return q
1104+
.from({ persons: collection })
1105+
.where(({ persons }) => gt(persons.age, 25))
1106+
.select(({ persons }) => ({
1107+
id: persons.id,
1108+
name: persons.name,
1109+
age: persons.age,
1110+
}))
1111+
},
1112+
})
1113+
1114+
await waitForAngularUpdate()
1115+
1116+
// When disabled, status should be 'disabled' and isReady should be true
1117+
expect(result.status()).toBe(`disabled`)
1118+
expect(result.isReady()).toBe(true)
1119+
expect(result.isLoading()).toBe(false)
1120+
expect(result.isIdle()).toBe(false)
1121+
expect(result.isError()).toBe(false)
1122+
expect(result.collection()).toBeNull()
1123+
expect(result.data()).toEqual([])
1124+
expect(result.state().size).toBe(0)
1125+
1126+
// Enable the query
1127+
enabled.set(true)
1128+
await waitForAngularUpdate()
1129+
1130+
// Should now be ready with data
1131+
expect(result.status()).toBe(`ready`)
1132+
expect(result.isReady()).toBe(true)
1133+
expect(result.isLoading()).toBe(false)
1134+
expect(result.collection()).not.toBeNull()
1135+
expect(result.data().length).toBeGreaterThan(0)
1136+
1137+
// Disable again
1138+
enabled.set(false)
1139+
await waitForAngularUpdate()
1140+
1141+
// Should go back to disabled state
1142+
expect(result.status()).toBe(`disabled`)
1143+
expect(result.isReady()).toBe(true)
1144+
expect(result.collection()).toBeNull()
1145+
expect(result.data()).toEqual([])
1146+
})
1147+
})
1148+
})
10341149
})

packages/react-db/src/useLiveQuery.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -492,7 +492,7 @@ export function useLiveQuery(
492492
collection: undefined,
493493
status: `disabled`,
494494
isLoading: false,
495-
isReady: false,
495+
isReady: true,
496496
isIdle: false,
497497
isError: false,
498498
isCleanedUp: false,

packages/react-db/tests/useLiveQuery.test.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2005,7 +2005,7 @@ describe(`Query Collections`, () => {
20052005
expect(result.current.collection).toBeUndefined()
20062006
expect(result.current.status).toBe(`disabled`)
20072007
expect(result.current.isLoading).toBe(false)
2008-
expect(result.current.isReady).toBe(false)
2008+
expect(result.current.isReady).toBe(true)
20092009
expect(result.current.isIdle).toBe(false)
20102010
expect(result.current.isError).toBe(false)
20112011
expect(result.current.isCleanedUp).toBe(false)
@@ -2044,7 +2044,7 @@ describe(`Query Collections`, () => {
20442044
expect(result.current.collection).toBeUndefined()
20452045
expect(result.current.status).toBe(`disabled`)
20462046
expect(result.current.isLoading).toBe(false)
2047-
expect(result.current.isReady).toBe(false)
2047+
expect(result.current.isReady).toBe(true)
20482048
expect(result.current.isIdle).toBe(false)
20492049
expect(result.current.isError).toBe(false)
20502050
expect(result.current.isCleanedUp).toBe(false)
@@ -2085,7 +2085,7 @@ describe(`Query Collections`, () => {
20852085
expect(result.current.collection).toBeUndefined()
20862086
expect(result.current.status).toBe(`disabled`)
20872087
expect(result.current.isLoading).toBe(false)
2088-
expect(result.current.isReady).toBe(false)
2088+
expect(result.current.isReady).toBe(true)
20892089
expect(result.current.isIdle).toBe(false)
20902090
expect(result.current.isError).toBe(false)
20912091
expect(result.current.isCleanedUp).toBe(false)

0 commit comments

Comments
 (0)