diff --git a/.changeset/support-conditional-live-query-configs.md b/.changeset/support-conditional-live-query-configs.md new file mode 100644 index 000000000..656df1b0d --- /dev/null +++ b/.changeset/support-conditional-live-query-configs.md @@ -0,0 +1,6 @@ +--- +'@tanstack/db': patch +'@tanstack/react-db': patch +--- + +Support disabling live queries declared with the `{ query }` config syntax by returning `undefined` or `null` from the query callback. diff --git a/docs/guides/live-queries.md b/docs/guides/live-queries.md index eb2bf08f0..2fcc57714 100644 --- a/docs/guides/live-queries.md +++ b/docs/guides/live-queries.md @@ -454,11 +454,26 @@ function TodoList({ userId }: { userId: string }) { } ``` -The callback form can also return `undefined` or `null` to disable a query. This still uses derived identity, so captured structured values do not need a dependency array. When the query is disabled: +The `query` callback can return `undefined` or `null` to disable a query. This still uses derived identity, so captured structured values do not need a dependency array: + +```tsx +const { data, isEnabled, status } = useLiveQuery({ + query: (q) => { + if (!userId) return undefined + + return q + .from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.userId, userId)) + }, +}) +``` + +The top-level callback form supports the same behavior. When the query is disabled: - `status` is `'disabled'` - `data`, `state`, and `collection` are `undefined` - `isEnabled` is `false` -- `isLoading`, `isReady`, `isIdle`, and `isError` are all `false` +- `isReady` is `true` +- `isLoading`, `isIdle`, `isError`, and `isCleanedUp` are all `false` ### Alternative Input Forms diff --git a/packages/db/src/live-query-options.ts b/packages/db/src/live-query-options.ts index 4005c3f9b..7439b2f14 100644 --- a/packages/db/src/live-query-options.ts +++ b/packages/db/src/live-query-options.ts @@ -8,8 +8,10 @@ import type { CollectionImpl } from './collection/index.js' import type { CollectionOptionsIdentity } from './collection-options.js' import type { CollectionOptions, DbClient } from './client.js' import type { + Context, InitialQueryBuilder, LiveQueryCollectionConfig, + QueryBuilder, } from './query/index.js' export type LiveQueryKey = ReadonlyArray @@ -22,6 +24,17 @@ export type DeferredLiveQueryCollections = Set< CollectionImpl > +type PreparedLiveQueryConfigInput = Omit< + LiveQueryCollectionConfig, + `query` +> & { + query: + | QueryBuilder + | ((q: InitialQueryBuilder) => QueryBuilder | undefined | null) + queryKey?: LiveQueryKey + client?: DbClient +} + function createInitialQueryBuilder( dbClient: DbClient | undefined, deferredCollections: DeferredLiveQueryCollections, @@ -73,17 +86,20 @@ export function prepareLiveQueryValue( queryKey: _queryKey, client: _client, ...config - } = value as LiveQueryCollectionConfig & { - queryKey?: LiveQueryKey - client?: DbClient + } = value as PreparedLiveQueryConfigInput + + const preparedQuery = + typeof query === `function` + ? query(createInitialQueryBuilder(dbClient, deferredCollections)) + : query + + if (preparedQuery === undefined || preparedQuery === null) { + return preparedQuery } return { ...config, - query: - typeof query === `function` - ? query(createInitialQueryBuilder(dbClient, deferredCollections)) - : query, + query: preparedQuery, } } diff --git a/packages/db/tests/live-query-options.test.ts b/packages/db/tests/live-query-options.test.ts index 5283d0cff..6c2500d42 100644 --- a/packages/db/tests/live-query-options.test.ts +++ b/packages/db/tests/live-query-options.test.ts @@ -3,9 +3,25 @@ import { createCollection } from '../src/collection/index.js' import { getLiveQueryHash, getPreparedLiveQueryIdentity, + prepareLiveQueryValue, } from '../src/live-query-options.js' import { BaseQueryBuilder } from '../src/query/builder/index.js' +describe(`live query preparation`, () => { + it.each([undefined, null])( + `promotes a nullish config query result to a disabled query`, + (disabled) => { + const prepared = prepareLiveQueryValue( + { query: () => disabled }, + undefined, + new Set(), + ) + + expect(prepared).toBe(disabled) + }, + ) +}) + describe(`live query identity`, () => { it(`hashes Map values in an explicit queryKey deterministically`, () => { const first = getLiveQueryHash(undefined, [ diff --git a/packages/react-db/skills/react-db/SKILL.md b/packages/react-db/skills/react-db/SKILL.md index 90cfbc7bb..273fad8cb 100644 --- a/packages/react-db/skills/react-db/SKILL.md +++ b/packages/react-db/skills/react-db/SKILL.md @@ -89,11 +89,13 @@ const { data } = useLiveQuery({ const { data } = useLiveQuery(preloadedCollection) // Conditional query — derived identity handles enabled/disabled transitions -const { data, status } = useLiveQuery((q) => { - if (!userId) return undefined - return q - .from({ todo: todoCollection }) - .where(({ todo }) => eq(todo.userId, userId)) +const { data, status } = useLiveQuery({ + query: (q) => { + if (!userId) return undefined + return q + .from({ todo: todoCollection }) + .where(({ todo }) => eq(todo.userId, userId)) + }, }) // When disabled: status='disabled', data=undefined ``` diff --git a/packages/react-db/src/index.ts b/packages/react-db/src/index.ts index b042d41c4..b616297a8 100644 --- a/packages/react-db/src/index.ts +++ b/packages/react-db/src/index.ts @@ -1,6 +1,7 @@ // Re-export all public APIs export { useLiveQuery } from './useLiveQuery' export type { + ConditionalUseLiveQueryConfig, LiveQueryKey, UseLiveQueryConfig, UseLiveQueryStatus, diff --git a/packages/react-db/src/useLiveQuery.ts b/packages/react-db/src/useLiveQuery.ts index 7f8e303dd..c9c012646 100644 --- a/packages/react-db/src/useLiveQuery.ts +++ b/packages/react-db/src/useLiveQuery.ts @@ -48,16 +48,36 @@ export type DerivedIdentityProfiler = { export type UseLiveQueryStatus = CollectionStatus | `disabled` export type LiveQueryKey = ReadonlyArray +type UseLiveQueryConfigOptions = Omit< + LiveQueryCollectionConfig, + `query` +> & { + /** + * Explicit identity for queries that contain opaque functional variants or + * are hot enough that deriving identity from structured IR is too expensive. + * Structured queries should omit this so DB can derive identity directly. + */ + queryKey?: LiveQueryKey + /** Override the nearest DbProvider for this query. */ + client?: DbClient +} + +type ConfiguredQueryBuilder = Extract< + LiveQueryCollectionConfig[`query`], + QueryBuilder +> + export type UseLiveQueryConfig = - LiveQueryCollectionConfig & { - /** - * Explicit identity for queries that contain opaque functional variants or - * are hot enough that deriving identity from structured IR is too expensive. - * Structured queries should omit this so DB can derive identity directly. - */ - queryKey?: LiveQueryKey - /** Override the nearest DbProvider for this query. */ - client?: DbClient + UseLiveQueryConfigOptions & + Pick, `query`> + +export type ConditionalUseLiveQueryConfig = + UseLiveQueryConfigOptions & { + query: + | ConfiguredQueryBuilder + | (( + q: InitialQueryBuilder, + ) => ConfiguredQueryBuilder | undefined | null) } export function warnDeprecatedDepsArray( @@ -297,6 +317,16 @@ function createCollectionFromPreparedQuery(value: unknown) { * }) * * @example + * // Return undefined or null to disable a query + * const { data, isEnabled } = useLiveQuery({ + * query: (q) => { + * if (!userId) return undefined + * return q.from({ todos: todosCollection }) + * .where(({ todos }) => eq(todos.userId, userId)) + * }, + * }) + * + * @example * // Join pattern * const { data } = useLiveQuery({ * query: (q) => @@ -481,16 +511,32 @@ export function useLiveQuery( state: Map> data: InferResultType collection: Collection, string | number, {}> - status: CollectionStatus // Can't be disabled for config objects + status: CollectionStatus // Can't be disabled when query always returns a builder + isLoading: boolean + isReady: boolean + isIdle: boolean + isError: boolean + isCleanedUp: boolean + isEnabled: true // Always true when query always returns a builder +} + +// Overload 7: Accept config object with a query that can return undefined/null +export function useLiveQuery( + config: ConditionalUseLiveQueryConfig, +): { + state: Map> | undefined + data: InferResultType | undefined + collection: Collection, string | number, {}> | undefined + status: UseLiveQueryStatus isLoading: boolean isReady: boolean isIdle: boolean isError: boolean isCleanedUp: boolean - isEnabled: true // Always true for config objects + isEnabled: boolean } -// Overload 7: Accept config object with legacy deps +// Overload 8: Accept config object with legacy deps export function useLiveQuery( config: LiveQueryCollectionConfig, deps?: Array, @@ -498,13 +544,30 @@ export function useLiveQuery( state: Map> data: InferResultType collection: Collection, string | number, {}> - status: CollectionStatus // Can't be disabled for config objects + status: CollectionStatus // Can't be disabled when query always returns a builder isLoading: boolean isReady: boolean isIdle: boolean isError: boolean isCleanedUp: boolean - isEnabled: true // Always true for config objects + isEnabled: true // Always true when query always returns a builder +} + +// Overload 9: Accept config object with legacy deps and a query that can return undefined/null +export function useLiveQuery( + config: ConditionalUseLiveQueryConfig, + deps: Array, +): { + state: Map> | undefined + data: InferResultType | undefined + collection: Collection, string | number, {}> | undefined + status: UseLiveQueryStatus + isLoading: boolean + isReady: boolean + isIdle: boolean + isError: boolean + isCleanedUp: boolean + isEnabled: boolean } /** @@ -536,7 +599,7 @@ export function useLiveQuery( * * return
{data.map(item => )}
*/ -// Overload 8: Accept pre-created live query collection +// Overload 10: Accept pre-created live query collection export function useLiveQuery< TResult extends object, TKey extends string | number, @@ -556,7 +619,7 @@ export function useLiveQuery< isEnabled: true // Always true for pre-created live query collections } -// Overload 9: Accept pre-created live query collection with singleResult: true +// Overload 10: Accept pre-created live query collection with singleResult: true export function useLiveQuery< TResult extends object, TKey extends string | number, diff --git a/packages/react-db/tests/useLiveQuery.test-d.tsx b/packages/react-db/tests/useLiveQuery.test-d.tsx index 7d31205fd..c541e71e7 100644 --- a/packages/react-db/tests/useLiveQuery.test-d.tsx +++ b/packages/react-db/tests/useLiveQuery.test-d.tsx @@ -4,6 +4,7 @@ import { createCollection } from '../../db/src/collection/index' import { collectionOptions } from '../../db/src/index' import { mockSyncCollectionOptions } from '../../db/tests/utils' import { + Query, createLiveQueryCollection, eq, liveQueryCollectionOptions, @@ -17,6 +18,12 @@ import type { DbClient, DehydratedDbState } from '../../db/src/index' import type { JSX } from 'react' import type { OutputWithVirtual } from '../../db/tests/utils' import type { SingleResult } from '../../db/src/types' +import type { QueryBuilder } from '../../db/src/query/index' +import type { + ConditionalUseLiveQueryConfig, + UseLiveQueryConfig, + UseLiveQueryStatus, +} from '../src/index' type Person = { id: string @@ -91,6 +98,105 @@ describe(`useLiveQuery type assertions`, () => { >() }) + it(`types a conditional findOne config object as disabled-capable`, () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `test-conditional-person-config`, + getKey: (person: Person) => person.id, + initialData: [], + }), + ) + const enabled = null as unknown as boolean + const query = new Query() + .from({ collection }) + .where(({ collection: c }) => eq(c.id, `3`)) + .findOne() + type QueryContext = + typeof query extends QueryBuilder ? TContext : never + const config: ConditionalUseLiveQueryConfig = { + queryKey: [collection.id, enabled], + query: () => (enabled ? query : undefined), + } + + const { result } = renderHook(() => { + return useLiveQuery(config) + }) + + expectTypeOf(result.current.data).toMatchTypeOf< + OutputWithVirtual | undefined + >() + expectTypeOf(result.current.status).toEqualTypeOf() + expectTypeOf(result.current.isEnabled).toEqualTypeOf() + }) + + it(`accepts an annotated enabled config in useLiveSuspenseQuery`, () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `test-annotated-suspense-config`, + getKey: (person: Person) => person.id, + initialData: [], + }), + ) + const query = new Query().from({ collection }) + type QueryContext = + typeof query extends QueryBuilder ? TContext : never + const config: UseLiveQueryConfig = { + query: () => query, + } + + const { result } = renderHook(() => useLiveSuspenseQuery(config)) + + expectTypeOf(result.current.data).toMatchTypeOf< + Array> + >() + }) + + it(`types a conditional config with deprecated dependencies`, () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `test-conditional-config-deps`, + getKey: (person: Person) => person.id, + initialData: [], + }), + ) + const enabled = null as unknown as boolean + + const { result } = renderHook(() => + useLiveQuery( + { + query: (q) => + enabled ? q.from({ collection }).findOne() : undefined, + }, + [enabled], + ), + ) + + expectTypeOf(result.current.data).toMatchTypeOf< + OutputWithVirtual | undefined + >() + expectTypeOf(result.current.status).toEqualTypeOf() + expectTypeOf(result.current.isEnabled).toEqualTypeOf() + }) + + it(`rejects a conditional config with a top-level scalar result`, () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `test-conditional-scalar-config`, + getKey: (person: Person) => person.id, + initialData: [], + }), + ) + const enabled = null as unknown as boolean + + useLiveQuery({ + // @ts-expect-error - top-level scalar results are not supported + query: (q) => { + if (!enabled) return undefined + return q.from({ collection }).select(({ collection: c }) => c.name) + }, + }) + }) + it(`should type config object to return query rows without queryKey`, () => { const collection = createCollection( mockSyncCollectionOptions({ diff --git a/packages/react-db/tests/useLiveQuery.test.tsx b/packages/react-db/tests/useLiveQuery.test.tsx index 149aef9ee..87e678e1d 100644 --- a/packages/react-db/tests/useLiveQuery.test.tsx +++ b/packages/react-db/tests/useLiveQuery.test.tsx @@ -1981,7 +1981,169 @@ describe(`Query Collections`, () => { }) }) - describe(`callback variants with conditional returns`, () => { + describe(`conditional returns`, () => { + it(`disables a config query that returns undefined`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `undefined-config-query-test`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }), + ) + + const { result, rerender } = renderHook( + ({ enabled }: { enabled: boolean }) => + useLiveQuery({ + query: (q) => { + if (!enabled) return undefined + return q + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 30)) + }, + }), + { initialProps: { enabled: false } }, + ) + + expect(result.current.data).toBeUndefined() + expect(result.current.collection).toBeUndefined() + expect(result.current.status).toBe(`disabled`) + expect(result.current.isEnabled).toBe(false) + expect(result.current.isReady).toBe(true) + + rerender({ enabled: true }) + + await waitFor(() => expect(result.current.data).toHaveLength(1)) + expect(result.current.status).toBe(`ready`) + expect(result.current.isEnabled).toBe(true) + + rerender({ enabled: false }) + + expect(result.current.data).toBeUndefined() + expect(result.current.collection).toBeUndefined() + expect(result.current.status).toBe(`disabled`) + expect(result.current.isEnabled).toBe(false) + expect(result.current.isReady).toBe(true) + }) + + it(`disables a config query that returns null`, () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `null-config-query-test`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }), + ) + + const { result } = renderHook( + ({ enabled }: { enabled: boolean }) => + useLiveQuery({ + query: (q) => { + if (!enabled) return null + return q.from({ persons: collection }) + }, + }), + { initialProps: { enabled: false } }, + ) + + expect(result.current.data).toBeUndefined() + expect(result.current.collection).toBeUndefined() + expect(result.current.status).toBe(`disabled`) + expect(result.current.isEnabled).toBe(false) + }) + + it(`disables a config query with deprecated dependencies`, async () => { + const warnSpy = vi.spyOn(console, `warn`).mockImplementation(() => {}) + const collection = createCollection( + mockSyncCollectionOptions({ + id: `conditional-config-deps-test`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }), + ) + + const { result, rerender } = renderHook( + ({ enabled }: { enabled: boolean }) => + useLiveQuery( + { + query: (q) => { + if (!enabled) return undefined + return q + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 30)) + }, + }, + [enabled], + ), + { initialProps: { enabled: false } }, + ) + + expect(result.current.status).toBe(`disabled`) + expect(result.current.isEnabled).toBe(false) + + rerender({ enabled: true }) + + await waitFor(() => expect(result.current.data).toHaveLength(1)) + expect(result.current.status).toBe(`ready`) + expect(result.current.isEnabled).toBe(true) + + rerender({ enabled: false }) + + expect(result.current.status).toBe(`disabled`) + expect(result.current.isEnabled).toBe(false) + warnSpy.mockRestore() + }) + + it(`stays disabled when the prior query becomes ready`, async () => { + let finishSync: (() => void) | undefined + const collection = createCollection({ + id: `conditional-config-pending-sync-test`, + getKey: (person) => person.id, + sync: { + sync: ({ begin, write, commit, markReady }) => { + finishSync = () => { + begin() + write({ type: `insert`, value: initialPersons[2]! }) + commit() + markReady() + } + }, + }, + onInsert: async () => {}, + onUpdate: async () => {}, + onDelete: async () => {}, + }) + + const { result, rerender } = renderHook( + ({ enabled }: { enabled: boolean }) => + useLiveQuery({ + query: (q) => { + if (!enabled) return undefined + return q + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 30)) + }, + }), + { initialProps: { enabled: true } }, + ) + + await waitFor(() => expect(finishSync).toBeDefined()) + expect(result.current.isLoading).toBe(true) + + rerender({ enabled: false }) + expect(result.current.status).toBe(`disabled`) + + await act(async () => { + finishSync!() + await Promise.resolve() + }) + + expect(collection.status).toBe(`ready`) + expect(collection.state.size).toBe(1) + expect(result.current.status).toBe(`disabled`) + expect(result.current.data).toBeUndefined() + expect(result.current.collection).toBeUndefined() + }) + it(`should handle callback returning undefined without a dependency array`, async () => { const collection = createCollection( mockSyncCollectionOptions({