import { QueryObserver, type DefaultError, type DefaultedQueryObserverOptions, type QueryKey, type QueryObserverOptions, type QueryObserverResult, } from '@tanstack/query-core' import type { QueryClient } from '@tanstack/query-core' import type { ReactiveControllerHost } from 'lit' import { createValueAccessor, readAccessor, type Accessor, type ValueAccessor, } from './accessor.js' import { createMissingQueryClientError } from './context.js' import { BaseController } from './controllers/BaseController.js' import { QueryObserverResultTracker } from './queryObserverResultTracker.js' /** * Options accepted by `createQueryController`. * * This is the Lit adapter shape for `QueryObserverOptions`. It can be passed * directly to `createQueryController`, or wrapped in an `Accessor` when the * options depend on Lit host state. */ export type CreateQueryOptions< TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, TQueryData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, > = QueryObserverOptions /** * Accessor returned by `createQueryController`. * * Call the accessor or read its `current` property to get the latest query * result. The attached methods delegate to the active query observer. */ export type QueryResultAccessor = ValueAccessor< QueryObserverResult > & { /** Refetches the current query. */ refetch: QueryObserverResult['refetch'] /** Resolves with an optimistic query result, fetching first when needed. */ suspense: () => Promise> /** Removes the controller from its Lit host and unsubscribes observers. */ destroy: () => void } function createPendingQueryResult(): QueryObserverResult< TData, TError > { return { data: undefined, dataUpdatedAt: 0, error: null, errorUpdatedAt: 0, failureCount: 0, failureReason: null, errorUpdateCount: 0, isError: false, isFetched: false, isFetchedAfterMount: false, isFetching: false, isInitialLoading: false, isLoading: false, isLoadingError: false, isPaused: false, isPending: true, isPlaceholderData: false, isRefetchError: false, isRefetching: false, isStale: true, isEnabled: true, isSuccess: false, fetchStatus: 'idle', status: 'pending', refetch: (() => Promise.reject(createMissingQueryClientError())) as QueryObserverResult< TData, TError >['refetch'], } as unknown as QueryObserverResult } class QueryController< TQueryFnData, TError, TData, TQueryData, TQueryKey extends QueryKey, > extends BaseController> { private readonly options: Accessor< CreateQueryOptions > private observer: | QueryObserver | undefined private readonly resultTracker = new QueryObserverResultTracker< QueryObserverResult >() private unsubscribe: (() => void) | undefined private queryClient: QueryClient | undefined constructor( host: ReactiveControllerHost, options: Accessor< CreateQueryOptions >, queryClient?: QueryClient, ) { const initialClient = queryClient super(host, createPendingQueryResult(), queryClient) this.options = options if (!initialClient) { return } if (typeof options === 'function') { return } const defaulted = this.defaultOptions(initialClient) const observer = new QueryObserver(initialClient, defaulted) this.queryClient = initialClient this.observer = observer this.assignObserverResult(observer.getOptimisticResult(defaulted)) } protected onConnected(): void { if (!this.syncClient()) { return } this.refreshOptions() this.subscribe() this.observer?.updateResult() if (this.observer) { this.setObserverResult(this.observer.getCurrentResult()) } } protected onDisconnected(): void { this.unsubscribeObserver() this.syncClient() } protected onHostUpdate(): void { if (typeof this.options !== 'function') { return } this.refreshOptions() } protected onQueryClientChanged(): void { if (!this.syncClient()) { return } if (!this.connectedState) { return } this.refreshOptions() this.subscribe() this.observer?.updateResult() if (this.observer) { this.setObserverResult(this.observer.getCurrentResult()) } } refetch: QueryObserverResult['refetch'] = (...args) => { if (!this.applyOptions() || !this.observer) { return Promise.reject(createMissingQueryClientError()) } return this.observer.refetch(...args) } suspense = async (): Promise> => { if (!this.syncClient() || !this.observer || !this.queryClient) { throw createMissingQueryClientError() } const options = this.defaultOptions(this.queryClient) this.observer.setOptions(options) const optimistic = this.observer.getOptimisticResult(options) if (options.enabled !== false && optimistic.isStale) { return this.observer.fetchOptimistic(options) } return optimistic } readCurrent(): QueryObserverResult { if (this.observer) { this.assignObserverResult(this.observer.getCurrentResult()) } return this.current } private subscribe(): void { if (!this.observer) { return } if (this.unsubscribe) { return } this.unsubscribe = this.observer.subscribe((next) => { this.setObserverResult(next) }) } private unsubscribeObserver(): void { this.unsubscribe?.() this.unsubscribe = undefined } private syncClient(): boolean { const nextClient = this.tryGetQueryClient() if (!nextClient) { this.unsubscribeObserver() this.queryClient = undefined this.observer = undefined this.resultTracker.reset() this.setResult(createPendingQueryResult()) return false } if (nextClient === this.queryClient && this.observer) { return true } this.unsubscribeObserver() this.queryClient = nextClient const options = this.defaultOptions() this.observer = new QueryObserver(this.queryClient, options) this.setObserverResult(this.observer.getOptimisticResult(options)) return true } private applyOptions(): boolean { if (!this.syncClient() || !this.observer) { return false } const options = this.defaultOptions(this.queryClient) this.observer.setOptions(options) return true } private refreshOptions(): boolean { if (!this.applyOptions() || !this.observer) { return false } this.setObserverResult(this.observer.getCurrentResult()) return true } private assignObserverResult( result: QueryObserverResult, ): void { const trackedResult = this.resultTracker.update(this.observer, result) if (trackedResult) { this.result = trackedResult } } private setObserverResult(result: QueryObserverResult): void { const trackedResult = this.resultTracker.update(this.observer, result) if (trackedResult) { this.setResult(trackedResult) } } private defaultOptions( client = this.queryClient, ): DefaultedQueryObserverOptions< TQueryFnData, TError, TData, TQueryData, TQueryKey > { const resolvedClient = client ?? this.tryGetQueryClient() if (!resolvedClient) { throw createMissingQueryClientError() } this.queryClient = resolvedClient const defaulted = resolvedClient.defaultQueryOptions( readAccessor(this.options), ) as DefaultedQueryObserverOptions< TQueryFnData, TError, TData, TQueryData, TQueryKey > ;(defaulted as { _optimisticResults?: 'optimistic' })._optimisticResults = 'optimistic' return defaulted } } /** * Creates a Lit reactive controller that subscribes the host to a single query. * * The returned accessor is callable and also exposes `current`, `refetch`, * `suspense`, and `destroy`. When `options` is a function, it is re-read during * host updates so query keys and options can follow reactive host state. * * If `queryClient` is omitted, the controller resolves the client from the * nearest connected `QueryClientProvider`. * * @param host - The Lit reactive controller host that owns the query * subscription. * @param options - Query observer options, or a getter that returns options. * @param queryClient - Optional explicit query client. Provide this for * controllers that should not resolve a client from Lit context. * @returns An accessor for the latest query result with query helper methods. * * @example * ```ts * import { LitElement, html } from 'lit' * import { createQueryController } from '@tanstack/lit-query' * * class TodosView extends LitElement { * private readonly todos = createQueryController(this, { * queryKey: ['todos'], * queryFn: async () => fetch('/api/todos').then((r) => r.json()), * }) * * render() { * const query = this.todos() * * if (query.isPending) return html`Loading...` * if (query.isError) return html`Error` * * return html`
    ${query.data.map((todo) => html`
  • ${todo.title}
  • `)}
` * } * } * ``` */ export function createQueryController< TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, TQueryData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, >( host: ReactiveControllerHost, options: Accessor< CreateQueryOptions >, queryClient?: QueryClient, ): QueryResultAccessor { const controller = new QueryController(host, options, queryClient) return Object.assign( createValueAccessor(() => controller.readCurrent()), { refetch: controller.refetch, suspense: controller.suspense, destroy: () => controller.destroy(), }, ) }