Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add example of lit query adapter #7715

Draft
wants to merge 10 commits into
base: main
Choose a base branch
from
3 changes: 3 additions & 0 deletions packages/lit-query/.attw.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"ignoreRules": ["cjs-resolves-to-esm", "internal-resolution-error"]
}
12 changes: 12 additions & 0 deletions packages/lit-query/eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// @ts-check

import { configs as litConfigs } from 'eslint-plugin-lit'
import rootConfig from '../../eslint.config.js'

export default [
...rootConfig,
{
files: ['*.ts'],
...litConfigs['flat/recommended'],
},
]
61 changes: 61 additions & 0 deletions packages/lit-query/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
{
"name": "@tanstack/lit-query",
"version": "5.50.3",
"description": "Primitives for managing, caching and syncing asynchronous and remote data in lit",
"author": "Gabriel Legault",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/TanStack/query.git",
"directory": "packages/lit-query"
},
"homepage": "https://tanstack.com/query",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"scripts": {
"clean": "rimraf ./dist && rimraf ./coverage",
"test:eslint": "eslint ./src",
"test:lib": "vitest",
"test:lib:dev": "pnpm run test:lib --watch",
"test:build": "publint --strict && attw --pack",
"build": "tsup"
},
"type": "module",
"types": "build/legacy/index.d.ts",
"main": "build/legacy/index.cjs",
"module": "build/legacy/index.js",
"exports": {
".": {
"import": {
"types": "./build/modern/index.d.ts",
"default": "./build/modern/index.js"
},
"require": {
"types": "./build/modern/index.d.cts",
"default": "./build/modern/index.cjs"
}
},
"./package.json": "./package.json"
},
"sideEffects": false,
"files": [
"build",
"src"
],
"dependencies": {
"@tanstack/query-core": "workspace:*"
},
"devDependencies": {
"@lit/context": "^1.1.2",
"@open-wc/testing-helpers": "3.0.1",
"@types/jest-when": "3.5.5",
"eslint-plugin-lit": "^1.14.0",
"jest-when": "3.6.0",
"lit": "3.1.4"
},
"peerDependencies": {
"lit": "^2.7.0 || ^3.0.0"
}
}
65 changes: 65 additions & 0 deletions packages/lit-query/src/QueryClientProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { LitElement, html } from 'lit'
import { customElement, state } from 'lit/decorators.js'
import { provide } from '@lit/context'
import { QueryClient } from '@tanstack/query-core'
import { QueryContext } from './context.js'

/**
* Definition for the properties provided by the query client mixin class.
*/
export interface QueryContextProps {
/**
* Tanstack Query Client
*/
queryClient: QueryClient
}

/**
* Generic constructor definition
*/
export type Constructor<T = object> = new (...args: Array<any>) => T

/**
* Query Client Context as mixin class.
* Extend this mixin class to make any LitElement class a context provider.
*
* @param Base - The base class to extend. Must be or inherit LitElement.
* @returns Class extended with query client context provider property.
*/
export const QueryClientMixin = <T extends Constructor<LitElement>>(
Base: T,
) => {
class QueryClientContextProvider extends Base implements QueryContextProps {
/**
* The query client provided as a context.
* May be overridden to set a custom configuration.
*/
@provide({ context: QueryContext })
@state()
queryClient = new QueryClient()

connectedCallback(): void {
super.connectedCallback()
this.queryClient.mount()
}

disconnectedCallback(): void {
super.disconnectedCallback()
this.queryClient.unmount()
}
}

// Cast return type to the mixin's interface intersected with the Base type
return QueryClientContextProvider as Constructor<QueryContextProps> & T
}

/**
* Query client context provided as a Custom Component.
* Place any components that should use the query client context as children.
*/
@customElement('query-client-provider')
export class QueryClientProvider extends QueryClientMixin(LitElement) {
render() {
return html`<slot></slot>`
}
}
254 changes: 254 additions & 0 deletions packages/lit-query/src/QueryController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
import { ContextConsumer } from '@lit/context'
import { QueryObserver } from '@tanstack/query-core'
import { QueryContext } from './context'
import type {
QueryClient,
QueryKey,
QueryObserverOptions,
QueryObserverResult,
} from '@tanstack/query-core'
import type {
LitElement,
ReactiveController,
ReactiveControllerHost,
} from 'lit'

/**
* Temporary Promise.withResolvers type polyfill until Typescript workspace dependency is updated from 5.3.3 to >=5.4
*/
type PromiseWithResolvers = Promise<unknown> & {
withResolvers: <T>() => {
resolve: (value: T | PromiseLike<T>) => void
reject: (reason: any) => void
promise: Promise<T>
}
}

export type { QueryObserverOptions }

/**
* QueryController is a class that integrates a query-based data fetching system
* into a Lit component as a ReactiveController.
*
* @template TQueryFnData - The data type returned by the query function.
* @template TError - The error type for query errors.
* @template TData - The data type to be used in the component.
* @template TQueryData - The data type returned by the query (may differ from TData).
* @template TQueryKey - The query key type.
*/
export class QueryController<
TQueryFnData = unknown,
TError = unknown,
TData = TQueryFnData,
TQueryData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
> implements ReactiveController
{
/**
* The result of the query observer, containing data and error information.
*/
result?: QueryObserverResult<TData, TError>

/**
* Consumer of the lit query client context.
*/
protected context: ContextConsumer<{ __context__: QueryClient }, LitElement>

/**
* Promise that is resolved when the query client is set.
*/
whenQueryClient = (
Promise as unknown as PromiseWithResolvers
).withResolvers<QueryClient>()

/**
* The query client.
* This can be set manually or using a lit query client context provider.
*/
set queryClient(queryClient: QueryClient | undefined) {
this._queryClient = queryClient
if (queryClient) {
this.whenQueryClient.resolve(queryClient)
}
this.host.requestUpdate()
}

get queryClient() {
return this._queryClient
}

/**
* The internal query observer responsible for managing the query.
*/
protected queryObserver?: QueryObserver<
TQueryFnData,
TError,
TData,
TQueryData,
TQueryKey
>

/**
* Promise that resolves when the query observer is created.
*/
whenQueryObserver = (
Promise as unknown as PromiseWithResolvers
).withResolvers<
QueryObserver<TQueryFnData, TError, TData, TQueryData, TQueryKey>
>()

/**
* Creates a new QueryController instance.
*
* @param host - The host component to which this controller is added.
* @param optionsFn - A function that provides QueryObserverOptions for the query.
* @param _queryClient - Optionally set the query client.
* @link [QueryObserverOptions API Docs](). //TODO: Add the correct doc
*/
constructor(
protected host: ReactiveControllerHost,
protected optionsFn?: () => QueryObserverOptions<
TQueryFnData,
TError,
TData,
TQueryData,
TQueryKey
>,
private _queryClient?: QueryClient,
) {
this.host.addController(this)

// Initialize the context
this.context = new ContextConsumer(this.host as LitElement, {
context: QueryContext,
subscribe: true,
callback: (value) => {
if (value) {
this.queryClient = value
}
},
})

// Observe the query if a query function is provided
if (this.optionsFn) {
this.observeQuery(this.optionsFn)
}
}

/**
* Creates a query observer. The query is subscribed whenever the host is connected to the dom.
*
* @param options - Options for the query observer
* @param optimistic - Get an initial optimistic result. Defaults to true.
*/
async observeQuery(
options:
| QueryObserverOptions<TQueryFnData, TError, TData, TQueryData, TQueryKey>
| (() => QueryObserverOptions<
TQueryFnData,
TError,
TData,
TQueryData,
TQueryKey
>),
optimistic: boolean = true,
) {
const queryClient = await this.whenQueryClient.promise

// Initialize the QueryObserver with defaulted options.
const defaultedOptions = await this.getDefaultedOptions(
typeof options === 'function' ? options() : options,
)
this.queryObserver = new QueryObserver(queryClient, defaultedOptions)

// Get an optimistic result based on the defaulted options.
if (optimistic) {
this.result = this.queryObserver.getOptimisticResult(defaultedOptions)
} else {
this.result = undefined
}

this.host.requestUpdate()

this.whenQueryObserver.resolve(this.queryObserver)
}

/**
* Unsubscribe function to remove the observer when the component disconnects.
*/
protected unsubscribe?: () => void

/**
* Invoked when the host component updates.
* Updates the query observer options with default options if a query function is set.
*/
async hostUpdate() {
if (this.optionsFn) {
const queryObserver = await this.whenQueryObserver.promise

// Update options from the options function
const defaultedOptions = await this.getDefaultedOptions(this.optionsFn())
queryObserver.setOptions(defaultedOptions)
}
}

/**
* Invoked when the host component is connected.
*/
hostConnected() {
this.subscribe()
}

/**
* Subscribes to the query observer and updates the result.
*/
async subscribe() {
const queryObserver = await this.whenQueryObserver.promise

// Unsubscribe any previous subscription before subscribing
this.unsubscribe?.()

this.unsubscribe = queryObserver.subscribe((result: typeof this.result) => {
this.result = result
this.host.requestUpdate()

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's preferable to only requestUpdate when the result has changed since a lot of unnecessary updates is triggered otherwise.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was under the impression that this event listener was only going to get called when the result has changed.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you're right. I think all this is handled properly in the queryObserver now.

})

queryObserver.updateResult()
this.host.requestUpdate()
}

/**
* Invoked when the host component is disconnected.
* Unsubscribes from the query observer to clean up.
*/
hostDisconnected() {
this.unsubscribe?.()
this.unsubscribe = undefined
}

/**
* Retrieves the default query options by combining the user-provided options
* with the default options from the query client.
*
* @returns The default query options.
*/
protected async getDefaultedOptions(
options: QueryObserverOptions<
TQueryFnData,
TError,
TData,
TQueryData,
TQueryKey
>,
) {
const queryClient = await this.whenQueryClient.promise

return queryClient.defaultQueryOptions<
TQueryFnData,
TError,
TData,
TQueryData,
TQueryKey
>(options)
}
}
Loading
Loading