Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/fix-framework-live-query-result-types.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 14 additions & 2 deletions packages/angular-db/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,18 @@ export interface InjectLiveQueryResult<TContext extends Context> {
isCleanedUp: Signal<boolean>
}

type InferConditionalResultType<TContext extends Context> =
TContext extends SingleResult
? InferResultType<TContext> | []
: InferResultType<TContext>

export type InjectConditionalLiveQueryResult<TContext extends Context> = Omit<
InjectLiveQueryResult<TContext>,
`data`
> & {
data: Signal<InferConditionalResultType<TContext>>
}

export interface InjectLiveQueryResultWithCollection<
TResult extends object = any,
TKey extends string | number = string | number,
Expand Down Expand Up @@ -109,15 +121,15 @@ export function injectLiveQuery<
params: TParams
q: InitialQueryBuilder
}) => QueryBuilder<TContext> | undefined | null
}): InjectLiveQueryResult<TContext>
}): InjectConditionalLiveQueryResult<TContext>
export function injectLiveQuery<TContext extends Context>(
queryFn: (q: InitialQueryBuilder) => QueryBuilder<TContext>,
): InjectLiveQueryResult<TContext>
export function injectLiveQuery<TContext extends Context>(
queryFn: (
q: InitialQueryBuilder,
) => QueryBuilder<TContext> | undefined | null,
): InjectLiveQueryResult<TContext>
): InjectConditionalLiveQueryResult<TContext>
export function injectLiveQuery<TContext extends Context>(
config: LiveQueryCollectionConfig<TContext>,
): InjectLiveQueryResult<TContext>
Expand Down
54 changes: 54 additions & 0 deletions packages/angular-db/tests/inject-live-query.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -133,4 +135,56 @@ describe(`injectLiveQuery type assertions`, () => {
Array<OutputWithVirtual<{ id: string; name: string }>>
>()
})

it(`types disabled callbacks from their empty reactive runtime`, () => {
const collection = createCollection(
mockSyncCollectionOptions<Person>({
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<Prettify<OutputWithVirtual<Person>>>
>()
expectTypeOf(result.collection()).toEqualTypeOf<Collection<
Prettify<OutputWithVirtual<Person>>,
string | number,
{}
> | null>()
expectTypeOf(result.status()).toEqualTypeOf<CollectionStatus | `disabled`>()

// @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<Person>({
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<OutputWithVirtual<Person>> | undefined | []
>()
})
})
42 changes: 42 additions & 0 deletions packages/angular-db/tests/inject-live-query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Person>({
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([])
})
})
})
})
12 changes: 11 additions & 1 deletion packages/db/tests/conformance/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,17 @@ export interface ControllableHandle<P> extends LiveQueryHandle {
setParam: (param: P) => Promise<void>
}

/** 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. */
Expand Down
26 changes: 25 additions & 1 deletion packages/react-db/src/useLiveInfiniteQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import type {
import type {
Collection,
CollectionImpl as CollectionImplType,
CollectionStatus,
Context,
DbClient,
InferResultType,
Expand Down Expand Up @@ -69,6 +70,29 @@ export type UseLiveInfiniteQueryReturn<TContext extends Context> = Omit<
error: unknown
}

export type UseLiveInfiniteQueryReturnWithCollection<
TResult extends object,
TKey extends string | number,
TUtils extends Record<string, any>,
> = {
data: Array<TResult>
state: Map<TKey, TResult>
collection: Collection<TResult, TKey, TUtils> & NonSingleResult
status: CollectionStatus
isLoading: boolean
isReady: boolean
isIdle: boolean
isError: boolean
isCleanedUp: boolean
isEnabled: true
pages: Array<Array<TResult>>
pageParams: Array<number>
fetchNextPage: () => Promise<void>
hasNextPage: boolean
isFetchingNextPage: boolean
error: unknown
}

type EnabledLiveQueryReturn<TContext extends Context> = ReturnType<
typeof useLiveQuery<TContext>
>
Expand Down Expand Up @@ -111,7 +135,7 @@ export function useLiveInfiniteQuery<
>(
liveQueryCollection: Collection<TResult, TKey, TUtils> & NonSingleResult,
config: UseLiveInfiniteQueryConfig<any>,
): UseLiveInfiniteQueryReturn<any>
): UseLiveInfiniteQueryReturnWithCollection<TResult, TKey, TUtils>

// Overload for query function
export function useLiveInfiniteQuery<TContext extends Context>(
Expand Down
33 changes: 32 additions & 1 deletion packages/react-db/tests/useLiveInfiniteQuery.test-d.tsx
Original file line number Diff line number Diff line change
@@ -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`, () => {
Expand All @@ -26,4 +27,34 @@ describe(`useLiveInfiniteQuery type assertions`, () => {
UseLiveInfiniteQueryReturn<Context>[`fetchNextPage`]
>().toEqualTypeOf<() => Promise<void>>()
})

/**
* 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<void> }

const collection = null as unknown as Collection<Post, PostKey, PostUtils> &
NonSingleResult
const result = useLiveInfiniteQuery(collection, { pageSize: 5 })

expectTypeOf(result.data).toEqualTypeOf<Array<Post>>()
expectTypeOf(result.pages).toEqualTypeOf<Array<Array<Post>>>()
expectTypeOf(result.state).toEqualTypeOf<Map<PostKey, Post>>()
expectTypeOf(result.collection).toEqualTypeOf<typeof collection>()
expectTypeOf(result.collection.utils.refreshPost).toEqualTypeOf<
(key: PostKey) => Promise<void>
>()

// @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`)
})
})
32 changes: 31 additions & 1 deletion packages/react-db/tests/useLiveQuery.test-d.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -178,6 +178,36 @@ describe(`useLiveQuery type assertions`, () => {
expectTypeOf(result.current.isEnabled).toEqualTypeOf<boolean>()
})

/**
* 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<Person>({
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<Prettify<OutputWithVirtual<Person>>> | undefined
>()
expectTypeOf(result.current.status).toEqualTypeOf<UseLiveQueryStatus>()
expectTypeOf(result.current.isEnabled).toEqualTypeOf<boolean>()

// @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<Person>({
Expand Down
9 changes: 7 additions & 2 deletions packages/solid-db/src/useLiveQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ import type {
SingleResult,
} from '@tanstack/db'

type InferConditionalResultType<TContext extends Context> =
TContext extends SingleResult
? InferResultType<TContext> | []
: InferResultType<TContext>

/**
* Create a live query using a query function
* @param queryFn - Query function that defines what data to fetch
Expand Down Expand Up @@ -123,12 +128,12 @@ export function useLiveQuery<TContext extends Context>(
queryFn: (
q: InitialQueryBuilder,
) => QueryBuilder<TContext> | undefined | null,
): Accessor<InferResultType<TContext>> & {
): Accessor<InferConditionalResultType<TContext>> & {
/**
* @deprecated use function result instead
* query.data -> query()
*/
data: InferResultType<TContext>
data: InferConditionalResultType<TContext>
state: ReactiveMap<string | number, GetResult<TContext>>
collection: Collection<GetResult<TContext>, string | number, {}> | null
status: CollectionStatus | `disabled`
Expand Down
Loading
Loading