diff --git a/.changeset/fix-framework-live-query-result-types.md b/.changeset/fix-framework-live-query-result-types.md new file mode 100644 index 0000000000..95ccaac5a2 --- /dev/null +++ b/.changeset/fix-framework-live-query-result-types.md @@ -0,0 +1,12 @@ +--- +'@tanstack/angular-db': patch +'@tanstack/react-db': patch +'@tanstack/solid-db': patch +'@tanstack/svelte-db': patch +'@tanstack/vue-db': patch +--- + +Preserve pre-created collection row, key, and utility types in React infinite +queries. Align conditional live-query result types with each framework's +disabled representation, including nullable collections, disabled statuses, +and empty single-result data in the empty-reactive bindings. diff --git a/packages/angular-db/src/index.ts b/packages/angular-db/src/index.ts index ba579c31a6..6d4fbad6a6 100644 --- a/packages/angular-db/src/index.ts +++ b/packages/angular-db/src/index.ts @@ -58,6 +58,18 @@ export interface InjectLiveQueryResult { isCleanedUp: Signal } +type InferConditionalResultType = + TContext extends SingleResult + ? InferResultType | [] + : InferResultType + +export type InjectConditionalLiveQueryResult = Omit< + InjectLiveQueryResult, + `data` +> & { + data: Signal> +} + export interface InjectLiveQueryResultWithCollection< TResult extends object = any, TKey extends string | number = string | number, @@ -109,7 +121,7 @@ export function injectLiveQuery< params: TParams q: InitialQueryBuilder }) => QueryBuilder | undefined | null -}): InjectLiveQueryResult +}): InjectConditionalLiveQueryResult export function injectLiveQuery( queryFn: (q: InitialQueryBuilder) => QueryBuilder, ): InjectLiveQueryResult @@ -117,7 +129,7 @@ export function injectLiveQuery( queryFn: ( q: InitialQueryBuilder, ) => QueryBuilder | undefined | null, -): InjectLiveQueryResult +): InjectConditionalLiveQueryResult export function injectLiveQuery( config: LiveQueryCollectionConfig, ): InjectLiveQueryResult diff --git a/packages/angular-db/tests/inject-live-query.test-d.ts b/packages/angular-db/tests/inject-live-query.test-d.ts index d83f88ba76..8c46515707 100644 --- a/packages/angular-db/tests/inject-live-query.test-d.ts +++ b/packages/angular-db/tests/inject-live-query.test-d.ts @@ -7,6 +7,8 @@ import { liveQueryCollectionOptions, } from '../../db/src/query/index' import { injectLiveQuery } from '../src/index' +import type { Prettify } from '../../db/src/query/index' +import type { Collection, CollectionStatus } from '@tanstack/db' import type { OutputWithVirtual } from '../../db/tests/utils' import type { SingleResult } from '../../db/src/types' @@ -133,4 +135,56 @@ describe(`injectLiveQuery type assertions`, () => { Array> >() }) + + it(`types disabled callbacks from their empty reactive runtime`, () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `test-conditional-angular`, + getKey: (person: Person) => person.id, + initialData: [], + }), + ) + const enabled = null as unknown as boolean + + // Compile-time observation cut: the public signal accessors returned by + // `injectLiveQuery`; the preload error proves the live-query Collection is + // absent while disabled. + const result = injectLiveQuery((q) => + enabled ? q.from({ collection }) : undefined, + ) + + expectTypeOf(result.data()).toEqualTypeOf< + Array>> + >() + expectTypeOf(result.collection()).toEqualTypeOf>, + string | number, + {} + > | null>() + expectTypeOf(result.status()).toEqualTypeOf() + + // @ts-expect-error Disabled callbacks expose a null collection until enabled. + result.collection().preload() + }) + + it(`types conditional findOne data with its empty disabled representation`, () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `test-conditional-find-one-angular`, + getKey: (person: Person) => person.id, + initialData: [], + }), + ) + const enabled = null as unknown as boolean + + // The exact public result combines enabled `findOne` cardinality with the + // empty-reactive disabled value. A paired framework test owns transitions. + const result = injectLiveQuery((q) => + enabled ? q.from({ collection }).findOne() : null, + ) + + expectTypeOf(result.data()).toEqualTypeOf< + Prettify> | undefined | [] + >() + }) }) diff --git a/packages/angular-db/tests/inject-live-query.test.ts b/packages/angular-db/tests/inject-live-query.test.ts index a2dceb0591..1c90a76025 100644 --- a/packages/angular-db/tests/inject-live-query.test.ts +++ b/packages/angular-db/tests/inject-live-query.test.ts @@ -1208,5 +1208,47 @@ describe(`injectLiveQuery`, () => { expect(result.data()).toEqual([]) }) }) + + /** + * Driver: public `injectLiveQuery` with an Angular signal. Each + * `waitForAngularUpdate` is an observation cut after disabled, enabled, and + * disabled-again updates. This test observes status and public result data; + * array-query Collection/state behavior remains in shared conformance. + */ + it(`keeps conditional findOne data empty while disabled`, async () => { + await TestBed.runInInjectionContext(async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `disabled-find-one-angular`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }), + ) + const enabled = signal(false) + const result = injectLiveQuery({ + params: () => ({ enabled: enabled() }), + query: ({ params, q }) => + params.enabled + ? q + .from({ collection }) + .where(({ collection: person }) => eq(person.id, `3`)) + .findOne() + : null, + }) + + await waitForAngularUpdate() + expect(result.status()).toBe(`disabled`) + expect(result.data()).toEqual([]) + + enabled.set(true) + await waitForAngularUpdate() + expect(result.data()).toMatchObject({ id: `3` }) + + enabled.set(false) + await waitForAngularUpdate() + expect(result.status()).toBe(`disabled`) + expect(result.data()).toEqual([]) + }) + }) }) }) diff --git a/packages/db/tests/conformance/contract.ts b/packages/db/tests/conformance/contract.ts index d945e089a5..ad1b2e799c 100644 --- a/packages/db/tests/conformance/contract.ts +++ b/packages/db/tests/conformance/contract.ts @@ -118,7 +118,17 @@ export interface ControllableHandle

extends LiveQueryHandle { setParam: (param: P) => Promise } -/** What each adapter package implements and hands to `runSuite`. */ +/** + * Shared live-query binding law: an enabled array query exposes row data from + * its live-query Collection; `findOne` exposes one row or `undefined`. A + * disabled callback has no live-query Collection, reports `disabled` status, + * and exposes the adapter's declared `absent` or `empty-reactive` public result. + * + * Each adapter implements this driver through its real public hook. `flush` + * defines the observation cut after framework updates and core sync settle. + * The runtime suite observes public result data, state, and status. Framework + * type inference and framework scheduler internals are outside this contract. + */ export interface LiveQueryDriver { name: string /** Public disabled data/state policy, not inferred from observed output. */ diff --git a/packages/react-db/src/useLiveInfiniteQuery.ts b/packages/react-db/src/useLiveInfiniteQuery.ts index a9a657a669..8711f45dea 100644 --- a/packages/react-db/src/useLiveInfiniteQuery.ts +++ b/packages/react-db/src/useLiveInfiniteQuery.ts @@ -28,6 +28,7 @@ import type { import type { Collection, CollectionImpl as CollectionImplType, + CollectionStatus, Context, DbClient, InferResultType, @@ -69,6 +70,29 @@ export type UseLiveInfiniteQueryReturn = Omit< error: unknown } +export type UseLiveInfiniteQueryReturnWithCollection< + TResult extends object, + TKey extends string | number, + TUtils extends Record, +> = { + data: Array + state: Map + collection: Collection & NonSingleResult + status: CollectionStatus + isLoading: boolean + isReady: boolean + isIdle: boolean + isError: boolean + isCleanedUp: boolean + isEnabled: true + pages: Array> + pageParams: Array + fetchNextPage: () => Promise + hasNextPage: boolean + isFetchingNextPage: boolean + error: unknown +} + type EnabledLiveQueryReturn = ReturnType< typeof useLiveQuery > @@ -111,7 +135,7 @@ export function useLiveInfiniteQuery< >( liveQueryCollection: Collection & NonSingleResult, config: UseLiveInfiniteQueryConfig, -): UseLiveInfiniteQueryReturn +): UseLiveInfiniteQueryReturnWithCollection // Overload for query function export function useLiveInfiniteQuery( diff --git a/packages/react-db/tests/useLiveInfiniteQuery.test-d.tsx b/packages/react-db/tests/useLiveInfiniteQuery.test-d.tsx index dd3923af7a..a196313acf 100644 --- a/packages/react-db/tests/useLiveInfiniteQuery.test-d.tsx +++ b/packages/react-db/tests/useLiveInfiniteQuery.test-d.tsx @@ -1,9 +1,10 @@ import { describe, expectTypeOf, it } from 'vitest' +import { useLiveInfiniteQuery } from '../src/useLiveInfiniteQuery' +import type { Collection, Context, NonSingleResult } from '@tanstack/db' import type { UseLiveInfiniteQueryConfig, UseLiveInfiniteQueryReturn, } from '../src/useLiveInfiniteQuery' -import type { Context } from '@tanstack/db' describe(`useLiveInfiniteQuery type assertions`, () => { it(`does not advertise a server-page callback`, () => { @@ -26,4 +27,34 @@ describe(`useLiveInfiniteQuery type assertions`, () => { UseLiveInfiniteQueryReturn[`fetchNextPage`] >().toEqualTypeOf<() => Promise>() }) + + /** + * Law and source: the public pre-created live-query Collection overload must + * preserve its row, key, and utility types through `useLiveInfiniteQuery`. + * The compile-time observation cut is the returned public result. Missing-row + * and wrong-key accesses are hostile controls. Pagination timing and runtime + * page contents remain in the infinite-query conformance suite. + */ + it(`preserves pre-created collection row, key, and utility types`, () => { + type Post = { id: `post-${number}`; title: string } + type PostKey = Post[`id`] + type PostUtils = { refreshPost: (key: PostKey) => Promise } + + const collection = null as unknown as Collection & + NonSingleResult + const result = useLiveInfiniteQuery(collection, { pageSize: 5 }) + + expectTypeOf(result.data).toEqualTypeOf>() + expectTypeOf(result.pages).toEqualTypeOf>>() + expectTypeOf(result.state).toEqualTypeOf>() + expectTypeOf(result.collection).toEqualTypeOf() + expectTypeOf(result.collection.utils.refreshPost).toEqualTypeOf< + (key: PostKey) => Promise + >() + + // @ts-expect-error The collection overload must not erase row fields to any. + result.data[0]!.missing + // @ts-expect-error The collection overload must preserve the collection key. + result.state.get(`not-a-post-key`) + }) }) diff --git a/packages/react-db/tests/useLiveQuery.test-d.tsx b/packages/react-db/tests/useLiveQuery.test-d.tsx index c541e71e74..8148d2f832 100644 --- a/packages/react-db/tests/useLiveQuery.test-d.tsx +++ b/packages/react-db/tests/useLiveQuery.test-d.tsx @@ -18,7 +18,7 @@ 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 { Prettify, QueryBuilder } from '../../db/src/query/index' import type { ConditionalUseLiveQueryConfig, UseLiveQueryConfig, @@ -178,6 +178,36 @@ describe(`useLiveQuery type assertions`, () => { expectTypeOf(result.current.isEnabled).toEqualTypeOf() }) + /** + * React's public disabled result is absent rather than empty-reactive. The + * observation cut is `result.current` from the real `useLiveQuery` hook; the + * negative `map` call rejects an array-only disabled type. Runtime lifecycle + * and scheduler behavior remain in React conformance tests. + */ + it(`types disabled callbacks with React's absent result representation`, () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `test-conditional-callback`, + getKey: (person: Person) => person.id, + initialData: [], + }), + ) + const enabled = null as unknown as boolean + + const { result } = renderHook(() => + useLiveQuery((q) => (enabled ? q.from({ collection }) : null)), + ) + + expectTypeOf(result.current.data).toEqualTypeOf< + Array>> | undefined + >() + expectTypeOf(result.current.status).toEqualTypeOf() + expectTypeOf(result.current.isEnabled).toEqualTypeOf() + + // @ts-expect-error React omits disabled query data until the callback enables it. + result.current.data.map((person) => person.id) + }) + it(`rejects a conditional config with a top-level scalar result`, () => { const collection = createCollection( mockSyncCollectionOptions({ diff --git a/packages/solid-db/src/useLiveQuery.ts b/packages/solid-db/src/useLiveQuery.ts index 67e421be7a..5fd3c43116 100644 --- a/packages/solid-db/src/useLiveQuery.ts +++ b/packages/solid-db/src/useLiveQuery.ts @@ -30,6 +30,11 @@ import type { SingleResult, } from '@tanstack/db' +type InferConditionalResultType = + TContext extends SingleResult + ? InferResultType | [] + : InferResultType + /** * Create a live query using a query function * @param queryFn - Query function that defines what data to fetch @@ -123,12 +128,12 @@ export function useLiveQuery( queryFn: ( q: InitialQueryBuilder, ) => QueryBuilder | undefined | null, -): Accessor> & { +): Accessor> & { /** * @deprecated use function result instead * query.data -> query() */ - data: InferResultType + data: InferConditionalResultType state: ReactiveMap> collection: Collection, string | number, {}> | null status: CollectionStatus | `disabled` diff --git a/packages/solid-db/tests/useLiveQuery.test-d.tsx b/packages/solid-db/tests/useLiveQuery.test-d.tsx index 29c5061ea7..44b4ed8f77 100644 --- a/packages/solid-db/tests/useLiveQuery.test-d.tsx +++ b/packages/solid-db/tests/useLiveQuery.test-d.tsx @@ -4,6 +4,8 @@ import { createCollection } from '../../db/src/collection/index' import { mockSyncCollectionOptions } from '../../db/tests/utils' import { createLiveQueryCollection, eq } from '../../db/src/query/index' import { useLiveQuery } from '../src/useLiveQuery' +import type { Prettify } from '../../db/src/query/index' +import type { Collection, CollectionStatus } from '@tanstack/db' import type { OutputWithVirtual } from '../../db/tests/utils' import type { SingleResult } from '../../db/src/types' @@ -85,4 +87,60 @@ describe(`useLiveQuery type assertions`, () => { Array> >() }) + + it(`types disabled callbacks from their empty reactive runtime`, () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `test-conditional-solid`, + getKey: (person: Person) => person.id, + initialData: [], + }), + ) + const enabled = null as unknown as boolean + + // Compile-time observation cut: the public accessors returned by the real + // `useLiveQuery` hook; the preload error proves the live-query Collection + // is absent while disabled. + const rendered = renderHook(() => + useLiveQuery((q) => (enabled ? q.from({ collection }) : null)), + ) + + expectTypeOf(rendered.result()).toEqualTypeOf< + Array>> + >() + expectTypeOf(rendered.result.collection).toEqualTypeOf>, + string | number, + {} + > | null>() + expectTypeOf(rendered.result.status).toEqualTypeOf< + CollectionStatus | `disabled` + >() + + // @ts-expect-error Disabled callbacks expose a null collection until enabled. + rendered.result.collection.preload() + }) + + it(`types conditional findOne data with its empty disabled representation`, () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `test-conditional-find-one-solid`, + getKey: (person: Person) => person.id, + initialData: [], + }), + ) + const enabled = null as unknown as boolean + + // The exact public result combines enabled `findOne` cardinality with the + // empty-reactive disabled value. A paired framework test owns transitions. + const rendered = renderHook(() => + useLiveQuery((q) => + enabled ? q.from({ collection }).findOne() : undefined, + ), + ) + + expectTypeOf(rendered.result()).toEqualTypeOf< + Prettify> | undefined | [] + >() + }) }) diff --git a/packages/solid-db/tests/useLiveQuery.test.tsx b/packages/solid-db/tests/useLiveQuery.test.tsx index e92572a2ae..41e0c09ca1 100644 --- a/packages/solid-db/tests/useLiveQuery.test.tsx +++ b/packages/solid-db/tests/useLiveQuery.test.tsx @@ -2077,6 +2077,50 @@ describe(`Query Collections`, () => { dispose() }) }) + + /** + * Driver: public `useLiveQuery` with a Solid signal. Initial reads and each + * `waitFor` completion are observation cuts for disabled, enabled, and + * disabled-again public results. Collection/state behavior remains in + * shared conformance; this test isolates conditional `findOne` data. + */ + it(`keeps conditional findOne data empty while disabled`, async () => { + return createRoot(async (dispose) => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `disabled-find-one-solid`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }), + ) + const [enabled, setEnabled] = createSignal(false) + const rendered = renderHook(() => + useLiveQuery((q) => + enabled() + ? q + .from({ collection }) + .where(({ collection: person }) => eq(person.id, `3`)) + .findOne() + : null, + ), + ) + + expect(rendered.result.status).toBe(`disabled`) + expect(rendered.result()).toEqual([]) + + setEnabled(true) + await waitFor(() => { + expect(rendered.result()).toMatchObject({ id: `3` }) + }) + + setEnabled(false) + await waitFor(() => { + expect(rendered.result.status).toBe(`disabled`) + }) + expect(rendered.result()).toEqual([]) + dispose() + }) + }) }) describe(`Suspense Integration`, () => { diff --git a/packages/svelte-db/src/useLiveQuery.svelte.ts b/packages/svelte-db/src/useLiveQuery.svelte.ts index 5af53f6b55..f12df5fab0 100644 --- a/packages/svelte-db/src/useLiveQuery.svelte.ts +++ b/packages/svelte-db/src/useLiveQuery.svelte.ts @@ -56,6 +56,19 @@ export interface UseLiveQueryReturn> { isCleanedUp: boolean } +type InferConditionalResultType = + TContext extends SingleResult + ? InferResultType | [] + : InferResultType + +export type ConditionalUseLiveQueryReturn< + T extends object, + TData = Array, +> = Omit, `collection` | `status`> & { + collection: Collection | null + status: CollectionStatus | `disabled` +} + export interface UseLiveQueryReturnWithCollection< T extends object, TKey extends string | number, @@ -188,9 +201,9 @@ export function useLiveQuery( q: InitialQueryBuilder, ) => QueryBuilder | undefined | null, deps?: Array<() => unknown>, -): UseLiveQueryReturn< +): ConditionalUseLiveQueryReturn< GetResult, - InferResultType | undefined + InferConditionalResultType > /** diff --git a/packages/svelte-db/tests/useLiveQuery.svelte.test.ts b/packages/svelte-db/tests/useLiveQuery.svelte.test.ts index 5d490f3036..a4f8d9e41e 100644 --- a/packages/svelte-db/tests/useLiveQuery.svelte.test.ts +++ b/packages/svelte-db/tests/useLiveQuery.svelte.test.ts @@ -2186,5 +2186,48 @@ describe(`Query Collections`, () => { expect(query.state.size).toBe(0) }) }) + + /** + * Driver: public `useLiveQuery` with Svelte state. Each `flushSync` is the + * observation cut after disabled, enabled, and disabled-again updates. This + * test observes conditional `findOne` data and status; shared conformance + * owns array-query Collection/state behavior. + */ + it(`keeps conditional findOne data empty while disabled`, () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `disabled-find-one-svelte`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }), + ) + + cleanup = $effect.root(() => { + let enabled = $state(false) + const query = useLiveQuery( + (q) => + enabled + ? q + .from({ collection }) + .where(({ collection: person }) => eq(person.id, `3`)) + .findOne() + : null, + [() => enabled], + ) + + flushSync() + expect(query.status).toBe(`disabled`) + expect(query.data).toEqual([]) + + enabled = true + flushSync() + expect(query.data).toMatchObject({ id: `3` }) + + enabled = false + flushSync() + expect(query.status).toBe(`disabled`) + expect(query.data).toEqual([]) + }) + }) }) }) diff --git a/packages/svelte-db/tests/useLiveQuery.test-d.ts b/packages/svelte-db/tests/useLiveQuery.test-d.ts new file mode 100644 index 0000000000..67ff608b11 --- /dev/null +++ b/packages/svelte-db/tests/useLiveQuery.test-d.ts @@ -0,0 +1,87 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { createCollection } from '../../db/src/collection/index' +import { mockSyncCollectionOptions } from '../../db/tests/utils' +import { useLiveQuery } from '../src/useLiveQuery.svelte.js' +import type { ConditionalUseLiveQueryReturn } from '../src/index.js' +import type { Prettify } from '../../db/src/query/index' +import type { Collection, CollectionStatus } from '@tanstack/db' +import type { OutputWithVirtual } from '../../db/tests/utils' + +type Person = { + id: string + name: string +} + +describe(`useLiveQuery type assertions`, () => { + it(`types disabled-capable callbacks from their empty reactive runtime`, () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `test-conditional-svelte`, + getKey: (person: Person) => person.id, + initialData: [], + }), + ) + const enabled = null as unknown as boolean + + // Compile-time observation cut: the public properties returned by the real + // `useLiveQuery` hook; the preload error proves the live-query Collection + // is absent while disabled. + const result = useLiveQuery((q) => + enabled ? q.from({ collection }) : undefined, + ) + + expectTypeOf(result.data).toEqualTypeOf< + Array>> + >() + expectTypeOf(result.collection).toEqualTypeOf>, + string | number, + {} + > | null>() + expectTypeOf(result.status).toEqualTypeOf() + + // @ts-expect-error Disabled callbacks expose a null collection until enabled. + result.collection.preload() + }) + + it(`preserves findOne cardinality`, () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `test-find-one-svelte`, + getKey: (person: Person) => person.id, + initialData: [], + }), + ) + + const result = useLiveQuery((q) => q.from({ collection }).findOne()) + + const data: OutputWithVirtual | undefined = result.data + expectTypeOf(data).toEqualTypeOf | undefined>() + }) + + it(`types conditional findOne data with its empty disabled representation`, () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `test-conditional-find-one-svelte`, + getKey: (person: Person) => person.id, + initialData: [], + }), + ) + const enabled = null as unknown as boolean + + // The exact public result combines enabled `findOne` cardinality with the + // empty-reactive disabled value. A paired framework test owns transitions. + const result = useLiveQuery((q) => + enabled ? q.from({ collection }).findOne() : null, + ) + const annotated: ConditionalUseLiveQueryReturn< + Prettify>, + Prettify> | undefined | [] + > = result + + expectTypeOf(annotated).toEqualTypeOf() + expectTypeOf(result.data).toEqualTypeOf< + Prettify> | undefined | [] + >() + }) +}) diff --git a/packages/vue-db/src/useLiveQuery.ts b/packages/vue-db/src/useLiveQuery.ts index 40a2fafb9d..efd233111b 100644 --- a/packages/vue-db/src/useLiveQuery.ts +++ b/packages/vue-db/src/useLiveQuery.ts @@ -53,6 +53,24 @@ export interface UseLiveQueryReturn { isCleanedUp: ComputedRef } +type InferConditionalResultType = + TContext extends SingleResult + ? InferResultType | [] + : InferResultType + +export type ConditionalUseLiveQueryReturn = Omit< + UseLiveQueryReturn, + `data` | `collection` | `status` +> & { + data: ComputedRef> + collection: ComputedRef, + string | number, + {} + > | null> + status: ComputedRef +} + export interface UseLiveQueryReturnWithCollection< T extends object, TKey extends string | number, @@ -146,7 +164,7 @@ export function useLiveQuery( q: InitialQueryBuilder, ) => QueryBuilder | undefined | null, deps?: Array>, -): UseLiveQueryReturn +): ConditionalUseLiveQueryReturn /** * Create a live query using configuration object diff --git a/packages/vue-db/tests/useLiveQuery.test-d.ts b/packages/vue-db/tests/useLiveQuery.test-d.ts index 8c1d5077ed..79599c7fac 100644 --- a/packages/vue-db/tests/useLiveQuery.test-d.ts +++ b/packages/vue-db/tests/useLiveQuery.test-d.ts @@ -7,6 +7,14 @@ import { liveQueryCollectionOptions, } from '../../db/src/query/index' import { useLiveQuery } from '../src/useLiveQuery' +import type { ConditionalUseLiveQueryReturn } from '../src/index' +import type { Prettify } from '../../db/src/query/index' +import type { + Collection, + CollectionStatus, + InitialQueryBuilder, + QueryBuilder, +} from '@tanstack/db' import type { OutputWithVirtual } from '../../db/tests/utils' import type { SingleResult } from '../../db/src/types' @@ -145,4 +153,63 @@ describe(`useLiveQuery type assertions`, () => { Array> >() }) + + it(`types disabled-capable callbacks from their empty reactive runtime`, () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `test-conditional-vue`, + getKey: (person: Person) => person.id, + initialData: [], + }), + ) + const enabled = null as unknown as boolean + + // Compile-time observation cut: the public refs returned by the real + // `useLiveQuery` hook; the preload error proves the live-query Collection + // is absent while disabled. + const result = useLiveQuery((q) => + enabled ? q.from({ collection }) : null, + ) + + expectTypeOf(result.data.value).toEqualTypeOf< + Array>> + >() + expectTypeOf(result.collection.value).toEqualTypeOf>, + string | number, + {} + > | null>() + expectTypeOf(result.status.value).toEqualTypeOf< + CollectionStatus | `disabled` + >() + + // @ts-expect-error Disabled callbacks expose a null collection until enabled. + result.collection.value.preload() + }) + + it(`types conditional findOne data with its empty disabled representation`, () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `test-conditional-find-one-vue`, + getKey: (person: Person) => person.id, + initialData: [], + }), + ) + const enabled = null as unknown as boolean + const build = (q: InitialQueryBuilder) => q.from({ collection }).findOne() + type QueryContext = + ReturnType extends QueryBuilder + ? TContext + : never + + // The exact public result combines enabled `findOne` cardinality with the + // empty-reactive disabled value. A paired framework test owns transitions. + const result = useLiveQuery((q) => (enabled ? build(q) : null)) + const annotated: ConditionalUseLiveQueryReturn = result + + expectTypeOf(annotated).toEqualTypeOf() + expectTypeOf(result.data.value).toEqualTypeOf< + Prettify> | undefined | [] + >() + }) }) diff --git a/packages/vue-db/tests/useLiveQuery.test.ts b/packages/vue-db/tests/useLiveQuery.test.ts index 657aec902f..037028fe22 100644 --- a/packages/vue-db/tests/useLiveQuery.test.ts +++ b/packages/vue-db/tests/useLiveQuery.test.ts @@ -1933,5 +1933,46 @@ describe(`Query Collections`, () => { expect(result.data.value).toHaveLength(1) expect(result.isReady.value).toBe(true) }) + + /** + * Driver: public `useLiveQuery` with a Vue ref. The initial read and each + * `waitFor` completion are observation cuts for disabled, enabled, and + * disabled-again public results. Collection/state behavior remains in + * shared conformance; this test isolates conditional `findOne` data. + */ + it(`keeps conditional findOne data empty while disabled`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `disabled-find-one-vue`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }), + ) + const enabled = ref(false) + const result = useLiveQuery( + (q) => + enabled.value + ? q + .from({ collection }) + .where(({ collection: person }) => eq(person.id, `3`)) + .findOne() + : null, + [() => enabled.value], + ) + + expect(result.status.value).toBe(`disabled`) + expect(result.data.value).toEqual([]) + + enabled.value = true + await waitFor(() => { + expect(result.data.value).toMatchObject({ id: `3` }) + }) + + enabled.value = false + await waitFor(() => { + expect(result.status.value).toBe(`disabled`) + }) + expect(result.data.value).toEqual([]) + }) }) })