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
9 changes: 8 additions & 1 deletion packages/lit-query/eslint.config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
// @ts-check

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

export default [...rootConfig]
export default [
...rootConfig,
{
files: ['*.ts'],
...litConfigs['flat/recommended'],
},
]
22 changes: 15 additions & 7 deletions packages/lit-query/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,30 +20,38 @@
"test:lib": "vitest",
"test:lib:dev": "pnpm run test:lib --watch",
"test:build": "publint --strict && attw --pack",
"build": "svelte-package --input ./src --output ./dist"
"build": "tsup"
},
"type": "module",
"types": "dist/index.d.ts",
"module": "dist/index.js",
"types": "build/legacy/index.d.ts",
"main": "build/legacy/index.cjs",
"module": "build/legacy/index.js",
"exports": {
".": {
"types": "./dist/index.d.ts",
"svelte": "./dist/index.js",
"import": "./dist/index.js"
"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": [
"dist",
"build",
"src"
],
"dependencies": {
"@tanstack/query-core": "workspace:*"
},
"devDependencies": {
"@lit/context": "^1.1.2",
Comment thread
Gabswim marked this conversation as resolved.
"@open-wc/testing-helpers": "3.0.1",
"@types/jest-when": "3.5.5",
"eslint-plugin-lit": "^1.14.0",
Comment thread
Gabswim marked this conversation as resolved.
"jest-when": "3.6.0",
"lit": "3.1.4"
},
Expand Down
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>`
}
}
188 changes: 161 additions & 27 deletions packages/lit-query/src/QueryController.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,28 @@
import { ContextConsumer } from '@lit/context'
import { QueryObserver } from '@tanstack/query-core'
import { getQueryClient } from './queryClientHelper'
import { QueryContext } from './context'
import type {
QueryClient,
QueryKey,
QueryObserverOptions,
QueryObserverResult,
} from '@tanstack/query-core'
import type { ReactiveController, ReactiveControllerHost } from 'lit'
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 }

Expand All @@ -30,80 +47,183 @@ export class QueryController<
/**
* The result of the query observer, containing data and error information.
*/
result: QueryObserverResult<TData, TError>
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.
*/
private queryObserver: QueryObserver<
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 options - A function that provides QueryObserverOptions for the query.
* @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(
private host: ReactiveControllerHost,
private options: () => QueryObserverOptions<
protected host: ReactiveControllerHost,
protected optionsFn?: () => QueryObserverOptions<
TQueryFnData,
TError,
TData,
TQueryData,
TQueryKey
>,
private _queryClient?: QueryClient,
) {
this.host.addController(this)

// Initialize the QueryObserver with default options.
const queryClient = getQueryClient()
const defaultOption = this.getDefaultOptions()
this.queryObserver = new QueryObserver(queryClient, defaultOption)
// Initialize the context
this.context = new ContextConsumer(this.host as LitElement, {
context: QueryContext,
subscribe: true,
callback: (value) => {
if (value) {
this.queryClient = value
}
},
})

// Get an optimistic result based on the default options.
this.result = this.queryObserver.getOptimisticResult(defaultOption)
// Observe the query if a query function is provided
if (this.optionsFn) {
this.observeQuery(this.optionsFn)
}
}

/**
* Unsubscribe function to remove the observer when the component disconnects.
* 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.
*/
private unsubscribe() {
// We set the unsubscribe function when hostConnected is invoked
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.
* Updates the query observer options with default options if a query function is set.
*/
hostUpdate() {
const defaultOption = this.getDefaultOptions()
this.queryObserver.setOptions(defaultOption)
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.
* Subscribes to the query observer and updates the result.
*/
hostConnected() {
this.unsubscribe = this.queryObserver.subscribe((result) => {
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()
})

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?.()
this.unsubscribe = undefined
Comment thread
Gabswim marked this conversation as resolved.
}

/**
Expand All @@ -112,9 +232,23 @@ export class QueryController<
*
* @returns The default query options.
*/
private getDefaultOptions() {
const queryClient = getQueryClient()
const defaultOption = queryClient.defaultQueryOptions(this.options())
return defaultOption
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