snapshot of https://github.com/TanStack/query.git (main, 2026-09-16; blobs > 131072B omitted)
Some checks failed
autofix.ci / autofix (push) Failing after 4s
Release / Release (push) Has been skipped
GitHub Actions Security Analysis / Run zizmor (push) Failing after 2s

This commit is contained in:
upstream-mirror
2026-09-16 01:13:08 +05:00
commit 3a013ccdfe
3570 changed files with 490194 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

View File

@@ -0,0 +1,2 @@
docs/
src/tests/

View File

@@ -0,0 +1,10 @@
dist
dist-cjs
node_modules
coverage
.claude
.references
*.tgz
package-lock.json
examples/**/output
examples/**/dist

View File

@@ -0,0 +1,156 @@
# @tanstack/lit-query
## 0.2.20
### Patch Changes
- Updated dependencies []:
- @tanstack/query-core@5.102.8
## 0.2.19
### Patch Changes
- Updated dependencies []:
- @tanstack/query-core@5.102.7
## 0.2.18
### Patch Changes
- Updated dependencies []:
- @tanstack/query-core@5.102.6
## 0.2.17
### Patch Changes
- Updated dependencies [[`578e5c2`](https://github.com/TanStack/query/commit/578e5c26e8ebd0d7351b4b8e2bafba695e672b8d)]:
- @tanstack/query-core@5.102.5
## 0.2.16
### Patch Changes
- Updated dependencies [[`a05df6a`](https://github.com/TanStack/query/commit/a05df6aefb0e2489ec2c879ae16e2ee7cb3123ec)]:
- @tanstack/query-core@5.102.4
## 0.2.15
### Patch Changes
- Updated dependencies []:
- @tanstack/query-core@5.102.3
## 0.2.14
### Patch Changes
- Updated dependencies [[`80fbf73`](https://github.com/TanStack/query/commit/80fbf73e77892d702c107e14a84c219a8ed825dc)]:
- @tanstack/query-core@5.102.2
## 0.2.13
### Patch Changes
- Updated dependencies [[`134890d`](https://github.com/TanStack/query/commit/134890dbdc60e4fb0313e44b512d29284ca82f96)]:
- @tanstack/query-core@5.102.1
## 0.2.12
### Patch Changes
- [#10943](https://github.com/TanStack/query/pull/10943) [`64a8035`](https://github.com/TanStack/query/commit/64a8035303a7d979d54d9fff9220904f218246c7) - fix(lit-query): migrate to the standard tsdown build so the `build` output is cached by nx and `test:build` passes
- [#8737](https://github.com/TanStack/query/pull/8737) [`2215bb0`](https://github.com/TanStack/query/commit/2215bb031139cdc8a84751b37a485c38ca9d2b6e) - fix: make mutation variables optional when `undefined extends TVariables`
- [#11221](https://github.com/TanStack/query/pull/11221) [`1ef4208`](https://github.com/TanStack/query/commit/1ef42087c9a266c2137d2ef645cbcc662f60ac93) - Remove experimental render-time prefetching and the `promise` property from query results.
- [#11146](https://github.com/TanStack/query/pull/11146) [`5633820`](https://github.com/TanStack/query/commit/563382018149c2f0994551ac383eb4531062227c) - Brand the `queryKey` returned by `infiniteQueryOptions` with `DataTag` so `getQueryData` and `setQueryData` infer `InfiniteData` instead of `unknown`.
- Updated dependencies [[`34f7cee`](https://github.com/TanStack/query/commit/34f7ceed09c10e4a3aa2df31a106ddf02ec4e787), [`b4368c4`](https://github.com/TanStack/query/commit/b4368c43792349f6c29d1fb41f7ee1ef3a8bdd2c), [`5bb089d`](https://github.com/TanStack/query/commit/5bb089d153d33933983b7ea80f8d69b51d423698), [`ba4650c`](https://github.com/TanStack/query/commit/ba4650c05e61e33c609e051b948c9f7d31ce70d1), [`294d4e6`](https://github.com/TanStack/query/commit/294d4e62c4f7d674574a4903eef2a1bc3dd1413b), [`1f631b3`](https://github.com/TanStack/query/commit/1f631b3604aab6567fb3f6c90646a5a304641546), [`01a02bf`](https://github.com/TanStack/query/commit/01a02bfad32f9efbc17796be31d6399f1197a655), [`18c1c1e`](https://github.com/TanStack/query/commit/18c1c1ef94883e781ad36d36f9ea4a043ce4260b), [`5448063`](https://github.com/TanStack/query/commit/5448063f828d2270dccd41ae375e1afde35e91f2), [`2215bb0`](https://github.com/TanStack/query/commit/2215bb031139cdc8a84751b37a485c38ca9d2b6e), [`1ef4208`](https://github.com/TanStack/query/commit/1ef42087c9a266c2137d2ef645cbcc662f60ac93), [`5981771`](https://github.com/TanStack/query/commit/5981771abec9330b344a2617543eb05ba7c99e24), [`4a9bef6`](https://github.com/TanStack/query/commit/4a9bef6cf19b1cd6b014032d696129f12a848185), [`bef4bc7`](https://github.com/TanStack/query/commit/bef4bc780ce7cca32b7e3dea85f77d92f82a62a2), [`9656dc4`](https://github.com/TanStack/query/commit/9656dc4e5fef5f8c502579b52459f9e8c72e787c), [`326aaf1`](https://github.com/TanStack/query/commit/326aaf1333e5d9cbc46569c53f218d31684162d2), [`3e83601`](https://github.com/TanStack/query/commit/3e836010032c2586e9f1c66a271fa9114a6401f9), [`c6fc17c`](https://github.com/TanStack/query/commit/c6fc17cfad6e452261c585fabfb8c210e60531ed)]:
- @tanstack/query-core@5.102.0
## 0.2.11
### Patch Changes
- Updated dependencies []:
- @tanstack/query-core@5.101.4
## 0.2.10
### Patch Changes
- Updated dependencies [[`7e3c822`](https://github.com/TanStack/query/commit/7e3c822a10896f41a8f1031c16b85096277af677)]:
- @tanstack/query-core@5.101.3
## 0.2.9
### Patch Changes
- Updated dependencies []:
- @tanstack/query-core@5.101.2
## 0.2.8
### Patch Changes
- Updated dependencies [[`9eff92e`](https://github.com/TanStack/query/commit/9eff92ed86e284ec0125b3a3539d028688235bd1)]:
- @tanstack/query-core@5.101.1
## 0.2.7
### Patch Changes
- [#10789](https://github.com/TanStack/query/pull/10789) [`3023dad`](https://github.com/TanStack/query/commit/3023dadec37ce74d4fbeb28f94adebacc6d43d60) - Fix redundant Lit host updates for function-backed query options, mutation state selectors, and tracked query result reads.
- Updated dependencies []:
- @tanstack/query-core@5.101.0
## 0.2.6
### Patch Changes
- Updated dependencies []:
- @tanstack/query-core@5.100.14
## 0.2.5
### Patch Changes
- Updated dependencies [[`d423168`](https://github.com/TanStack/query/commit/d423168f6261a5cb3d353e53b27c8150cc271151)]:
- @tanstack/query-core@5.100.13
## 0.2.4
### Patch Changes
- Updated dependencies []:
- @tanstack/query-core@5.100.12
## 0.2.3
### Patch Changes
- Avoid scheduling redundant host updates when accessor function options resolve to an unchanged query result. ([#10716](https://github.com/TanStack/query/pull/10716))
## 0.2.2
### Patch Changes
- Updated dependencies []:
- @tanstack/query-core@5.100.11
## 0.2.1
### Patch Changes
- Updated dependencies []:
- @tanstack/query-core@5.100.10
## 0.2.0
### Minor Changes
- Add initial @tanstack/lit-query package ([#10652](https://github.com/TanStack/query/pull/10652))

View File

@@ -0,0 +1,144 @@
<div align="center">
<picture>
<source
media="(prefers-color-scheme: dark)"
srcset="https://tanstack.com/api/readme/query.png?framework=lit&theme=dark"
/>
<source
media="(prefers-color-scheme: light)"
srcset="https://tanstack.com/api/readme/query.png?framework=lit"
/>
<img
src="https://tanstack.com/api/readme/query.png?framework=lit"
alt="TanStack Lit Query"
width="900"
/>
</picture>
</div>
# @tanstack/lit-query
Lit adapter for `@tanstack/query-core` using Lit reactive controllers.
This package is currently experimental and v0.1. Pin exact versions if you use
it in production while the API is early.
## Install
```bash
npm install @tanstack/lit-query @tanstack/query-core lit
```
For local development in this repository:
```bash
pnpm install
pnpm --dir packages/lit-query run build
```
## Quick Start
```ts
import { LitElement, html } from 'lit'
import {
QueryClient,
QueryClientProvider,
createQueryController,
} from '@tanstack/lit-query'
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
class AppProvider extends QueryClientProvider {
constructor() {
super()
this.client = client
}
}
customElements.define('app-provider', AppProvider)
class UsersView extends LitElement {
private readonly users = createQueryController(this, {
queryKey: ['users'],
queryFn: async () => {
const response = await fetch('/api/users')
return response.json() as Promise<Array<{ id: string; name: string }>>
},
})
render() {
const query = this.users()
if (query.isPending) return html`Loading...`
if (query.isError) return html`Error`
return html`<ul>
${query.data?.map((u) => html`<li>${u.name}</li>`)}
</ul>`
}
}
customElements.define('users-view', UsersView)
```
## API Surface
- `QueryClientProvider`, `useQueryClient`, `resolveQueryClient`
- `createQueryController`
- `createMutationController`
- `createInfiniteQueryController`
- `createQueriesController`
- `useIsFetching`, `useIsMutating`, `useMutationState`
- `queryOptions`, `infiniteQueryOptions`, `mutationOptions`
## Runnable Examples
This repo includes runnable Lit examples under the top-level `examples/lit`
directory so they can be surfaced in the docs:
- `examples/lit/basic`: Vite Lit app covering query and mutation primitives.
- `examples/lit/pagination`: pagination, prefetching, optimistic updates, and error recovery.
- `examples/lit/ssr`: Lit SSR render, dehydrate, and hydrate flow.
Run an example from the repo root:
```bash
pnpm --dir examples/lit/basic run dev
pnpm --dir examples/lit/pagination run dev
pnpm --dir examples/lit/ssr run dev
```
Open:
- `http://127.0.0.1:4173/` (basic example)
- `http://127.0.0.1:4183/` (pagination example app)
- `http://127.0.0.1:4174/` (SSR example app)
Use a different port (optional):
```bash
DEMO_PORT=4180 pnpm --dir examples/lit/basic run dev
PAGINATION_DEMO_PORT=4181 PAGINATION_API_PORT=4182 pnpm --dir examples/lit/pagination run dev
SSR_PORT=4180 pnpm --dir examples/lit/ssr run dev
SSR_HOST=0.0.0.0 pnpm --dir examples/lit/ssr run dev
```
## Integration Smoke
For the framework build smoke used in CI:
```bash
pnpm --dir integrations/lit-vite run build
```
## Quality Gates
From the repository root:
```bash
pnpm --dir packages/lit-query run test:types
pnpm --dir packages/lit-query run test:lib
pnpm --dir packages/lit-query run build
pnpm --dir packages/lit-query run test:build
```
## License
MIT

View File

@@ -0,0 +1,51 @@
// @ts-check
import js from '@eslint/js'
import vitest from '@vitest/eslint-plugin'
import globals from 'globals'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{
ignores: [
'build/**',
'coverage/**',
'node_modules/**',
'.claude/**',
'.references/**',
'**/build/**',
'examples/**/output/**',
'**/*.d.ts',
],
},
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ['**/*.{ts,js,mjs,cjs}'],
languageOptions: {
globals: {
...globals.browser,
...globals.node,
},
},
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'no-console': 'off',
},
},
{
files: ['src/tests/**/*.{ts,js,mjs}', 'examples/**/e2e/**/*.{js,mjs}'],
plugins: {
vitest,
},
languageOptions: {
globals: {
...vitest.environments.env.globals,
},
},
rules: {
...vitest.configs.recommended.rules,
'vitest/expect-expect': 'off',
},
},
)

View File

@@ -0,0 +1,72 @@
{
"name": "@tanstack/lit-query",
"version": "0.2.20",
"description": "Lit adapter for TanStack Query Core",
"author": "tannerlinsley",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+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": "premove ./build ./coverage ./dist-ts ./.cache/test-types",
"compile": "tsc --project tsconfig.json",
"test:eslint": "eslint --concurrency=auto .",
"lint:fix": "eslint --concurrency=auto . --fix",
"test:types": "tsc --noEmit --tsBuildInfoFile .cache/test-types/tscurrent.tsbuildinfo",
"test:lib": "vitest",
"test:lib:dev": "pnpm run test:lib --watch",
"test:watch": "pnpm run test:lib:dev",
"test:build": "publint --strict && attw --pack",
"build": "tsdown --tsconfig tsconfig.prod.json",
"measure:bundle": "pnpm run build && node scripts/measure-bundle.mjs",
"measure:bundle:raw": "node scripts/measure-bundle.mjs",
"perf:l3": "pnpm run build && node scripts/l3-stress.mjs",
"perf:l3:raw": "node scripts/l3-stress.mjs"
},
"type": "module",
"types": "build/legacy/index.d.ts",
"main": "build/legacy/index.cjs",
"module": "build/legacy/index.js",
"exports": {
".": {
"@tanstack/custom-condition": "./src/index.ts",
"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",
"!src/tests"
],
"dependencies": {
"@lit/context": "^1.1.6",
"@tanstack/query-core": "workspace:*",
"lit": "^3.3.1"
},
"peerDependencies": {
"@tanstack/query-core": "^5.0.0",
"lit": ">=2.8.0 <4"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
"@tanstack/query-test-utils": "workspace:*",
"globals": "^17.4.0",
"typescript-eslint": "^8.54.0"
}
}

View File

@@ -0,0 +1 @@
../../scripts/getTsdownConfig.js

View File

@@ -0,0 +1,124 @@
import { QueryClient } from '@tanstack/query-core'
import { createQueryController } from '../build/modern/index.js'
class TestControllerHost {
controllers = new Set()
updatesRequested = 0
updateComplete = Promise.resolve(true)
addController(controller) {
this.controllers.add(controller)
}
removeController(controller) {
this.controllers.delete(controller)
}
requestUpdate() {
this.updatesRequested += 1
}
connect() {
for (const controller of this.controllers) {
controller.hostConnected?.()
}
}
disconnect() {
for (const controller of this.controllers) {
controller.hostDisconnected?.()
}
}
update() {
for (const controller of this.controllers) {
controller.hostUpdate?.()
}
for (const controller of this.controllers) {
controller.hostUpdated?.()
}
}
}
async function waitFor(assertion, timeoutMs = 3000) {
const startedAt = Date.now()
while (!assertion()) {
if (Date.now() - startedAt > timeoutMs) {
throw new Error(`Timed out waiting for assertion after ${timeoutMs}ms`)
}
await new Promise((resolve) => setTimeout(resolve, 5))
}
}
async function run(cycles = 1000) {
const client = new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
},
},
})
const queryKey = ['l3-stress']
const startedAt = Date.now()
const initialHeapMb = process.memoryUsage().heapUsed / (1024 * 1024)
for (let cycle = 0; cycle < cycles; cycle += 1) {
const host = new TestControllerHost()
const query = createQueryController(
host,
{
queryKey,
gcTime: 0,
queryFn: async () => cycle,
},
client,
)
host.connect()
host.update()
await waitFor(() => query().isSuccess, 4000)
const cacheQuery = client.getQueryCache().find({ queryKey })
const connectedCount = cacheQuery?.getObserversCount() ?? 0
if (connectedCount !== 1) {
throw new Error(
`observer_count_connected_invalid:${connectedCount}:cycle:${cycle}`,
)
}
host.disconnect()
query.destroy()
const disconnectedCount = cacheQuery?.getObserversCount() ?? 0
if (disconnectedCount !== 0) {
throw new Error(
`observer_count_disconnected_invalid:${disconnectedCount}:cycle:${cycle}`,
)
}
}
const elapsedMs = Date.now() - startedAt
const finalHeapMb = process.memoryUsage().heapUsed / (1024 * 1024)
const memoryGrowthMb = Number((finalHeapMb - initialHeapMb).toFixed(3))
const summary = {
measuredAt: new Date().toISOString(),
cycles,
elapsedMs,
initialHeapMb: Number(initialHeapMb.toFixed(3)),
finalHeapMb: Number(finalHeapMb.toFixed(3)),
memoryGrowthMb,
retainedObserversAfterRun:
client.getQueryCache().find({ queryKey })?.getObserversCount() ?? 0,
}
console.log(JSON.stringify(summary, null, 2))
}
run().catch((error) => {
console.error(error)
process.exit(1)
})

View File

@@ -0,0 +1,43 @@
import { readFile, readdir, stat } from 'node:fs/promises'
import path from 'node:path'
import { gzipSync } from 'node:zlib'
import { fileURLToPath } from 'node:url'
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(scriptDir, '..')
const distDir = path.join(repoRoot, 'build', 'modern')
const entryFile = path.join(distDir, 'index.js')
async function getDirSizeBytes(dirPath) {
let total = 0
const entries = await readdir(dirPath, { withFileTypes: true })
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name)
if (entry.isDirectory()) {
total += await getDirSizeBytes(fullPath)
} else if (entry.isFile()) {
total += (await stat(fullPath)).size
}
}
return total
}
async function run() {
const entry = await readFile(entryFile)
const entryGzip = gzipSync(entry)
const distBytes = await getDirSizeBytes(distDir)
const output = {
measuredAt: new Date().toISOString(),
entryJsBytes: entry.length,
entryJsGzipBytes: entryGzip.length,
distTotalBytes: distBytes,
}
console.log(JSON.stringify(output, null, 2))
}
run().catch((error) => {
console.error(error)
process.exit(1)
})

View File

@@ -0,0 +1,174 @@
import { ContextProvider } from '@lit/context'
import type { QueryClient } from '@tanstack/query-core'
import type { TemplateResult } from 'lit'
import { LitElement, html } from 'lit'
import {
createMissingQueryClientError,
queryClientContext,
registerDefaultQueryClient,
unregisterDefaultQueryClient,
} from './context.js'
/**
* Lit element that provides a `QueryClient` to descendant Lit Query
* controllers through Lit context.
*
* The `client` is a property, not an attribute. When rendering this element in
* a Lit template, bind it with property binding: `.client=${queryClient}`.
* The provider throws if it connects without a client, or if an already
* connected provider has its client cleared.
*
* This class is not registered as a custom element by the package. Applications
* must register either a subclass or the class itself with
* `customElements.define`.
*
* @example
* ```ts
* import { html, LitElement } from 'lit'
* import { QueryClient, QueryClientProvider } from '@tanstack/lit-query'
*
* const queryClient = new QueryClient()
*
* class AppQueryProvider extends QueryClientProvider {
* constructor() {
* super()
* this.client = queryClient
* }
* }
*
* customElements.define('app-query-provider', AppQueryProvider)
*
* class AppRoot extends LitElement {
* render() {
* return html`<app-query-provider><todos-view></todos-view></app-query-provider>`
* }
* }
* ```
*
* @example
* ```ts
* import { html } from 'lit'
* import { QueryClient, QueryClientProvider } from '@tanstack/lit-query'
*
* const queryClient = new QueryClient()
*
* customElements.define('query-client-provider', QueryClientProvider)
*
* const view = html`
* <query-client-provider .client=${queryClient}>
* <todos-view></todos-view>
* </query-client-provider>
* `
* ```
*/
export class QueryClientProvider extends LitElement {
/** @internal */
static properties = {
client: { attribute: false },
}
/**
* The `QueryClient` provided to descendant controllers and global fallback
* helpers while this provider is connected.
*
* Bind this as a property in Lit templates with `.client=${queryClient}`.
*/
declare client: QueryClient
private readonly contextProvider: ContextProvider<typeof queryClientContext>
private mountedClient: QueryClient | undefined
constructor() {
super()
this.contextProvider = new ContextProvider(this, {
context: queryClientContext,
})
}
/** @internal */
connectedCallback(): void {
super.connectedCallback()
const client = this.requireClient()
this.contextProvider.setValue(client)
this.mountClient(client)
}
/** @internal */
disconnectedCallback(): void {
this.unmountClient(this.mountedClient)
super.disconnectedCallback()
}
/** @internal */
protected willUpdate(changedProperties: Map<PropertyKey, unknown>): void {
if (!changedProperties.has('client')) {
return
}
const nextClient = this.client
if (!nextClient) {
if (this.isConnected) {
this.unmountClient(this.mountedClient)
// Sentinel: notify active consumers that the provider is now unbound.
this.contextProvider.setValue(undefined as unknown as QueryClient)
throw createMissingQueryClientError()
}
return
}
const previousClient = changedProperties.get('client') as
| QueryClient
| undefined
if (previousClient && previousClient !== nextClient && this.isConnected) {
this.unmountClient(previousClient)
}
this.contextProvider.setValue(nextClient)
if (this.isConnected) {
this.mountClient(nextClient)
}
}
/** @internal */
render(): TemplateResult {
return html`<slot></slot>`
}
private mountClient(client: QueryClient): void {
if (this.mountedClient === client) {
return
}
if (this.mountedClient) {
this.unmountClient(this.mountedClient)
}
client.mount()
registerDefaultQueryClient(client)
this.mountedClient = client
}
private unmountClient(client?: QueryClient): void {
if (!client) {
return
}
client.unmount()
unregisterDefaultQueryClient(client)
if (this.mountedClient === client) {
this.mountedClient = undefined
}
}
private requireClient(): QueryClient {
if (!this.client) {
throw createMissingQueryClientError()
}
return this.client
}
}

View File

@@ -0,0 +1,43 @@
/**
* A value that can be passed directly or read from a zero-argument getter.
*
* Lit Query APIs read function accessors during host updates, so the getter can
* depend on reactive host state.
*
* @example
* ```ts
* const staticKey: Accessor<readonly unknown[]> = ['todos']
* const reactiveKey: Accessor<readonly unknown[]> = () => ['todos', this.userId]
* ```
*/
export type Accessor<T> = T | (() => T)
export function readAccessor<T>(value: Accessor<T>): T {
return typeof value === 'function' ? (value as () => T)() : value
}
/**
* A callable accessor with a `current` property for reading the latest
* controller result.
*
* Controller creators and cache state helpers return this shape so render code
* can use either `result()` or `result.current`.
*
* @example
* ```ts
* const query = this.todos()
* const sameQuery = this.todos.current
* ```
*/
export type ValueAccessor<T> = (() => T) & {
readonly current: T
}
export function createValueAccessor<T>(getter: () => T): ValueAccessor<T> {
const accessor = (() => getter()) as ValueAccessor<T>
Object.defineProperty(accessor, 'current', {
get: getter,
enumerable: true,
})
return accessor
}

View File

@@ -0,0 +1,120 @@
import { createContext } from '@lit/context'
import type { QueryClient } from '@tanstack/query-core'
/**
* Lit context key used by `QueryClientProvider` and host-bound APIs to share a
* `QueryClient` through the DOM tree.
*
* Most applications use `QueryClientProvider` instead of interacting with this
* context directly.
*/
export const queryClientContext = createContext<QueryClient>(
Symbol.for('tanstack-query-client'),
)
const missingQueryClientMessage =
'No QueryClient available. Pass one explicitly or render within QueryClientProvider.'
const ambiguousQueryClientMessage =
'Multiple QueryClients are mounted. Pass one explicitly instead of relying on global QueryClient helpers.'
const registeredClients = new Map<QueryClient, number>()
let defaultClient: QueryClient | undefined
/**
* Registers a `QueryClient` as a process-local fallback for APIs that resolve a
* client without an explicit argument.
*
* `QueryClientProvider` calls this automatically while it is connected. Prefer
* passing an explicit client or rendering under a provider when possible.
*
* @param client - The query client to register as the current default.
*/
export function registerDefaultQueryClient(client: QueryClient): void {
registeredClients.set(client, (registeredClients.get(client) ?? 0) + 1)
defaultClient = client
}
/**
* Unregisters a client previously registered with
* `registerDefaultQueryClient`.
*
* `QueryClientProvider` calls this automatically when it disconnects.
*
* @param client - The query client registration to release.
*/
export function unregisterDefaultQueryClient(client: QueryClient): void {
const count = registeredClients.get(client)
if (count === undefined) {
return
}
if (count > 1) {
registeredClients.set(client, count - 1)
return
}
registeredClients.delete(client)
if (defaultClient !== client) {
return
}
const remaining = [...registeredClients.keys()]
defaultClient = remaining.at(-1)
}
/**
* Returns the registered default `QueryClient`, if exactly one default client is
* available.
*
* @returns The default query client, or `undefined` when there is no registered
* client or more than one registered client.
*/
export function getDefaultQueryClient(): QueryClient | undefined {
if (registeredClients.size > 1) {
return undefined
}
return defaultClient
}
export function createMissingQueryClientError(): Error {
return new Error(missingQueryClientMessage)
}
function createAmbiguousQueryClientError(): Error {
return new Error(ambiguousQueryClientMessage)
}
/**
* Resolves the current default `QueryClient` registered by a connected
* `QueryClientProvider`.
*
* This helper is useful outside a Lit reactive controller when a single
* provider is mounted. It throws if no client is registered or if multiple
* clients are mounted and the default would be ambiguous.
*
* @returns The single registered query client.
*/
export function useQueryClient(): QueryClient {
const client = getDefaultQueryClient()
if (client) {
return client
}
if (registeredClients.size > 1) {
throw createAmbiguousQueryClientError()
}
throw createMissingQueryClientError()
}
/**
* Resolves an explicit `QueryClient` or falls back to `useQueryClient`.
*
* @param explicit - Optional client supplied by the caller.
* @returns The explicit client when provided, otherwise the current default
* client.
*/
export function resolveQueryClient(explicit?: QueryClient): QueryClient {
return explicit ?? useQueryClient()
}

View File

@@ -0,0 +1,292 @@
import { ContextEvent } from '@lit/context'
import type { QueryClient } from '@tanstack/query-core'
import type { ReactiveController, ReactiveControllerHost } from 'lit'
import {
createMissingQueryClientError,
queryClientContext,
} from '../context.js'
type QueryClientResolutionState =
| 'pre-connect'
| 'awaiting-context'
| 'bound'
| 'missing'
export abstract class BaseController<TResult> implements ReactiveController {
protected result: TResult
private readonly explicitClient?: QueryClient
private contextClient: QueryClient | undefined
private contextUnsubscribe: (() => void) | undefined
private connected = false
private destroyed = false
private updateQueued = false
private clientChangeQueued = false
private connectionAttempt = 0
private isHostUpdating = false
private queryClientResolutionState: QueryClientResolutionState
protected constructor(
protected readonly host: ReactiveControllerHost,
initialResult: TResult,
queryClient?: QueryClient,
) {
this.explicitClient = queryClient
this.result = initialResult
this.queryClientResolutionState = queryClient ? 'bound' : 'pre-connect'
host.addController(this)
}
hostConnected(): void {
if (this.connected || this.destroyed) {
return
}
this.connected = true
let contextResolutionAttempt: number | undefined
if (this.explicitClient) {
this.queryClientResolutionState = 'bound'
} else {
contextResolutionAttempt = ++this.connectionAttempt
this.beginContextResolution()
}
// Defer onConnected to ensure subclass constructors complete before
// lifecycle callbacks access subclass state. This handles the case where
// addController is called on an already-connected host (e.g., during
// willUpdate), which synchronously triggers hostConnected before
// subclass field initialization.
queueMicrotask(() => {
if (this.connected && !this.destroyed) {
this.onConnected()
}
})
if (contextResolutionAttempt !== undefined) {
// Provider-backed controllers on already-connected hosts should finish
// their deferred onConnected pass before a context client binds.
this.queueContextResolution(contextResolutionAttempt)
}
}
hostDisconnected(): void {
if (!this.connected) {
return
}
this.connected = false
if (!this.explicitClient) {
this.connectionAttempt += 1
this.clearContextClient()
this.updateQueryClientResolutionState('pre-connect')
}
this.onDisconnected()
}
hostUpdate(): void {
if (this.destroyed) {
return
}
this.isHostUpdating = true
try {
this.onHostUpdate()
} finally {
this.isHostUpdating = false
}
}
destroy(): void {
if (this.destroyed) {
return
}
this.destroyed = true
this.connected = false
this.connectionAttempt += 1
this.clearContextClient()
this.queryClientResolutionState = this.explicitClient
? 'bound'
: 'pre-connect'
this.onDisconnected()
if ('removeController' in this.host) {
this.host.removeController(this)
}
}
protected tryGetQueryClient(): QueryClient | undefined {
return this.explicitClient ?? this.contextClient
}
protected getQueryClient(): QueryClient {
const client = this.tryGetQueryClient()
if (!client) {
throw createMissingQueryClientError()
}
return client
}
protected setResult(next: TResult): void {
if (Object.is(this.result, next)) {
return
}
this.result = next
if (!this.isHostUpdating) {
this.queueUpdate()
}
}
get current(): TResult {
if (this.queryClientResolutionState === 'missing') {
throw createMissingQueryClientError()
}
return this.result
}
protected get connectedState(): boolean {
return this.connected
}
protected queueUpdate(): void {
if (this.updateQueued) {
return
}
this.updateQueued = true
queueMicrotask(() => {
this.updateQueued = false
if (!this.destroyed) {
this.host.requestUpdate()
}
})
}
private queueQueryClientChanged(): void {
if (this.clientChangeQueued) {
return
}
this.clientChangeQueued = true
queueMicrotask(() => {
this.clientChangeQueued = false
if (!this.destroyed) {
this.onQueryClientChanged()
}
})
}
private beginContextResolution(): void {
this.clearContextClient()
this.updateQueryClientResolutionState('awaiting-context')
}
private queueContextResolution(attempt: number): void {
queueMicrotask(() => {
if (
this.destroyed ||
!this.connected ||
attempt !== this.connectionAttempt ||
this.queryClientResolutionState !== 'awaiting-context'
) {
return
}
this.dispatchContextRequest(attempt)
this.queueInitialContextResolutionCompletion(attempt)
})
}
private dispatchContextRequest(attempt: number): void {
if (!('dispatchEvent' in this.host)) {
return
}
const contextTarget = this.host as ReactiveControllerHost & EventTarget
contextTarget.dispatchEvent(
new ContextEvent(
queryClientContext,
contextTarget as unknown as Element,
(value, unsubscribe) => {
if (
this.destroyed ||
!this.connected ||
attempt !== this.connectionAttempt
) {
unsubscribe?.()
return
}
if (
this.contextUnsubscribe &&
this.contextUnsubscribe !== unsubscribe
) {
this.contextUnsubscribe()
}
const resolutionChanged = this.updateQueryClientResolutionState(
value === undefined ? 'missing' : 'bound',
)
const clientChanged = this.contextClient !== value
this.contextClient = value
this.contextUnsubscribe = unsubscribe
if (resolutionChanged || clientChanged) {
this.queueUpdate()
this.queueQueryClientChanged()
}
},
true,
),
)
}
private queueInitialContextResolutionCompletion(attempt: number): void {
queueMicrotask(() => {
if (
this.destroyed ||
!this.connected ||
attempt !== this.connectionAttempt ||
this.queryClientResolutionState !== 'awaiting-context'
) {
return
}
if (this.updateQueryClientResolutionState('missing')) {
this.queueUpdate()
this.queueQueryClientChanged()
}
})
}
private clearContextClient(): void {
this.contextUnsubscribe?.()
this.contextUnsubscribe = undefined
this.contextClient = undefined
}
private updateQueryClientResolutionState(
nextState: QueryClientResolutionState,
): boolean {
if (this.queryClientResolutionState === nextState) {
return false
}
this.queryClientResolutionState = nextState
return true
}
protected abstract onConnected(): void
protected abstract onDisconnected(): void
protected abstract onHostUpdate(): void
protected abstract onQueryClientChanged(): void
}

View File

@@ -0,0 +1,432 @@
import {
InfiniteQueryObserver,
type DefaultError,
type DefaultedInfiniteQueryObserverOptions,
type InfiniteData,
type InfiniteQueryObserverOptions,
type InfiniteQueryObserverResult,
type QueryKey,
} 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 `createInfiniteQueryController`.
*
* This is the Lit adapter shape for `InfiniteQueryObserverOptions`. Pass it
* directly or through an `Accessor` when the options depend on Lit host state.
*/
export type CreateInfiniteQueryOptions<
TQueryFnData = unknown,
TError = DefaultError,
TData = InfiniteData<TQueryFnData>,
TQueryKey extends QueryKey = QueryKey,
TPageParam = unknown,
> = InfiniteQueryObserverOptions<
TQueryFnData,
TError,
TData,
TQueryKey,
TPageParam
>
/**
* Accessor returned by `createInfiniteQueryController`.
*
* Call the accessor or read its `current` property to get the latest infinite
* query result. The attached methods delegate to the active infinite query
* observer.
*/
export type InfiniteQueryResultAccessor<TData, TError> = ValueAccessor<
InfiniteQueryObserverResult<TData, TError>
> & {
/** Refetches the current infinite query. */
refetch: InfiniteQueryObserverResult<TData, TError>['refetch']
/** Fetches the next page for the current infinite query. */
fetchNextPage: InfiniteQueryObserverResult<TData, TError>['fetchNextPage']
/** Fetches the previous page for the current infinite query. */
fetchPreviousPage: InfiniteQueryObserverResult<
TData,
TError
>['fetchPreviousPage']
/** Removes the controller from its Lit host and unsubscribes observers. */
destroy: () => void
}
function createPendingInfiniteQueryResult<
TData,
TError,
>(): InfiniteQueryObserverResult<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 InfiniteQueryObserverResult<TData, TError>['refetch'],
fetchNextPage: (() =>
Promise.reject(
createMissingQueryClientError(),
)) as InfiniteQueryObserverResult<TData, TError>['fetchNextPage'],
fetchPreviousPage: (() =>
Promise.reject(
createMissingQueryClientError(),
)) as InfiniteQueryObserverResult<TData, TError>['fetchPreviousPage'],
hasNextPage: false,
hasPreviousPage: false,
isFetchNextPageError: false,
isFetchingNextPage: false,
isFetchPreviousPageError: false,
isFetchingPreviousPage: false,
} as unknown as InfiniteQueryObserverResult<TData, TError>
}
class InfiniteQueryController<
TQueryFnData,
TError,
TData,
TQueryKey extends QueryKey,
TPageParam,
> extends BaseController<InfiniteQueryObserverResult<TData, TError>> {
private readonly options: Accessor<
CreateInfiniteQueryOptions<
TQueryFnData,
TError,
TData,
TQueryKey,
TPageParam
>
>
private observer:
| InfiniteQueryObserver<TQueryFnData, TError, TData, TQueryKey, TPageParam>
| undefined
private readonly resultTracker = new QueryObserverResultTracker<
InfiniteQueryObserverResult<TData, TError>
>()
private unsubscribe: (() => void) | undefined
private queryClient: QueryClient | undefined
constructor(
host: ReactiveControllerHost,
options: Accessor<
CreateInfiniteQueryOptions<
TQueryFnData,
TError,
TData,
TQueryKey,
TPageParam
>
>,
queryClient?: QueryClient,
) {
super(host, createPendingInfiniteQueryResult(), queryClient)
this.options = options
if (!queryClient) {
return
}
if (typeof options === 'function') {
return
}
const defaulted = this.defaultOptions(queryClient)
const observer = new InfiniteQueryObserver(queryClient, defaulted)
this.queryClient = queryClient
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() || !this.connectedState) {
return
}
this.refreshOptions()
this.subscribe()
this.observer?.updateResult()
if (this.observer) {
this.setObserverResult(this.observer.getCurrentResult())
}
}
refetch: InfiniteQueryObserverResult<TData, TError>['refetch'] = (
...args
) => {
if (!this.applyOptions() || !this.observer) {
return Promise.reject(createMissingQueryClientError())
}
return this.observer.refetch(...args)
}
fetchNextPage: InfiniteQueryObserverResult<TData, TError>['fetchNextPage'] = (
...args
) => {
if (!this.applyOptions() || !this.observer) {
return Promise.reject(createMissingQueryClientError())
}
return this.observer.fetchNextPage(...args)
}
fetchPreviousPage: InfiniteQueryObserverResult<
TData,
TError
>['fetchPreviousPage'] = (...args) => {
if (!this.applyOptions() || !this.observer) {
return Promise.reject(createMissingQueryClientError())
}
return this.observer.fetchPreviousPage(...args)
}
readCurrent(): InfiniteQueryObserverResult<TData, TError> {
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(createPendingInfiniteQueryResult())
return false
}
if (nextClient === this.queryClient) {
return true
}
this.unsubscribeObserver()
this.queryClient = nextClient
const options = this.defaultOptions(this.queryClient)
this.observer = new InfiniteQueryObserver(this.queryClient, options)
this.setObserverResult(this.observer.getOptimisticResult(options))
return true
}
private applyOptions(): boolean {
if (!this.syncClient() || !this.observer || !this.queryClient) {
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: InfiniteQueryObserverResult<TData, TError>,
): void {
const trackedResult = this.resultTracker.update(this.observer, result)
if (trackedResult) {
this.result = trackedResult
}
}
private setObserverResult(
result: InfiniteQueryObserverResult<TData, TError>,
): void {
const trackedResult = this.resultTracker.update(this.observer, result)
if (trackedResult) {
this.setResult(trackedResult)
}
}
private defaultOptions(
client = this.queryClient,
): DefaultedInfiniteQueryObserverOptions<
TQueryFnData,
TError,
TData,
TQueryKey,
TPageParam
> {
if (!client) {
throw createMissingQueryClientError()
}
const defaulted = client.defaultQueryOptions(
readAccessor(this.options),
) as DefaultedInfiniteQueryObserverOptions<
TQueryFnData,
TError,
TData,
TQueryKey,
TPageParam
>
;(defaulted as { _optimisticResults?: 'optimistic' })._optimisticResults =
'optimistic'
return defaulted
}
}
/**
* Creates a Lit reactive controller that subscribes the host to an infinite
* query.
*
* The returned accessor is callable and also exposes `current`, `refetch`,
* `fetchNextPage`, `fetchPreviousPage`, 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 infinite query
* subscription.
* @param options - Infinite 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 infinite query result with page helper
* methods.
*
* @example
* ```ts
* import { LitElement, html } from 'lit'
* import { createInfiniteQueryController } from '@tanstack/lit-query'
*
* class ProjectsView extends LitElement {
* private readonly projects = createInfiniteQueryController(this, {
* queryKey: ['projects'],
* queryFn: ({ pageParam }) => fetchProjects(pageParam),
* initialPageParam: 0,
* getNextPageParam: (lastPage) => lastPage.nextCursor,
* })
*
* render() {
* const query = this.projects()
*
* return html`
* <button ?disabled=${!query.hasNextPage} @click=${() => this.projects.fetchNextPage()}>
* Load more
* </button>
* `
* }
* }
* ```
*/
export function createInfiniteQueryController<
TQueryFnData = unknown,
TError = DefaultError,
TData = InfiniteData<TQueryFnData>,
TQueryKey extends QueryKey = QueryKey,
TPageParam = unknown,
>(
host: ReactiveControllerHost,
options: Accessor<
CreateInfiniteQueryOptions<
TQueryFnData,
TError,
TData,
TQueryKey,
TPageParam
>
>,
queryClient?: QueryClient,
): InfiniteQueryResultAccessor<TData, TError> {
const controller = new InfiniteQueryController(host, options, queryClient)
return Object.assign(
createValueAccessor(() => controller.readCurrent()),
{
refetch: controller.refetch,
fetchNextPage: controller.fetchNextPage,
fetchPreviousPage: controller.fetchPreviousPage,
destroy: () => controller.destroy(),
},
)
}

View File

@@ -0,0 +1,363 @@
import {
MutationObserver,
type DefaultError,
type MutateFunction,
type MutationObserverOptions,
type MutationObserverResult,
} 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'
/**
* Options accepted by `createMutationController`.
*
* This is the Lit adapter shape for `MutationObserverOptions`. Pass it directly
* or through an `Accessor` when the options depend on Lit host state.
*/
export type CreateMutationOptions<
TData = unknown,
TError = DefaultError,
TVariables = void,
TOnMutateResult = unknown,
> = MutationObserverOptions<TData, TError, TVariables, TOnMutateResult>
/**
* Accessor returned by `createMutationController`.
*
* Call the accessor or read its `current` property to get the latest mutation
* result. The attached methods delegate to the active mutation observer.
*/
export type MutationResultAccessor<TData, TError, TVariables, TOnMutateResult> =
ValueAccessor<
MutationObserverResult<TData, TError, TVariables, TOnMutateResult>
> & {
/**
* Starts the mutation and swallows the returned promise.
*
* Throws synchronously if no `QueryClient` can be resolved.
*/
mutate: (
...args: Parameters<
MutateFunction<TData, TError, TVariables, TOnMutateResult>
>
) => void
/**
* Starts the mutation and returns the observer promise.
*
* Rejects if no `QueryClient` can be resolved.
*/
mutateAsync: MutationObserverResult<
TData,
TError,
TVariables,
TOnMutateResult
>['mutate']
/** Resets the mutation observer to its idle state. */
reset: MutationObserverResult<
TData,
TError,
TVariables,
TOnMutateResult
>['reset']
/** Removes the controller from its Lit host and unsubscribes observers. */
destroy: () => void
}
function createIdleMutationResult<
TData,
TError,
TVariables,
TOnMutateResult,
>(): MutationObserverResult<TData, TError, TVariables, TOnMutateResult> {
return {
context: undefined,
data: undefined,
error: null,
failureCount: 0,
failureReason: null,
isError: false,
isIdle: true,
isPending: false,
isPaused: false,
isSuccess: false,
status: 'idle',
submittedAt: 0,
variables: undefined,
mutate: (() =>
Promise.reject(
createMissingQueryClientError(),
)) as MutationObserverResult<
TData,
TError,
TVariables,
TOnMutateResult
>['mutate'],
reset: (() => undefined) as MutationObserverResult<
TData,
TError,
TVariables,
TOnMutateResult
>['reset'],
} as MutationObserverResult<TData, TError, TVariables, TOnMutateResult>
}
class MutationController<
TData,
TError,
TVariables,
TOnMutateResult,
> extends BaseController<
MutationObserverResult<TData, TError, TVariables, TOnMutateResult>
> {
private readonly options: Accessor<
CreateMutationOptions<TData, TError, TVariables, TOnMutateResult>
>
private observer:
| MutationObserver<TData, TError, TVariables, TOnMutateResult>
| undefined
private unsubscribe: (() => void) | undefined
private queryClient: QueryClient | undefined
constructor(
host: ReactiveControllerHost,
options: Accessor<
CreateMutationOptions<TData, TError, TVariables, TOnMutateResult>
>,
queryClient?: QueryClient,
) {
super(host, createIdleMutationResult(), queryClient)
this.options = options
if (!queryClient) {
return
}
if (typeof options === 'function') {
return
}
const observer = new MutationObserver(
queryClient,
this.defaultOptions(queryClient),
)
this.queryClient = queryClient
this.observer = observer
this.result = observer.getCurrentResult()
}
protected onConnected(): void {
if (!this.syncClient()) {
return
}
this.refreshOptions()
this.subscribe()
if (this.observer) {
this.setResult(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() || !this.connectedState) {
return
}
this.refreshOptions()
this.subscribe()
if (this.observer) {
this.setResult(this.observer.getCurrentResult())
}
}
mutate = (
...args: Parameters<
MutateFunction<TData, TError, TVariables, TOnMutateResult>
>
): void => {
if (!this.syncClient() || !this.observer) {
throw createMissingQueryClientError()
}
void this.observer.mutate(args[0] as TVariables, args[1]).catch(() => {
// Intentionally swallow in sync mutate path.
})
}
mutateAsync: MutationObserverResult<
TData,
TError,
TVariables,
TOnMutateResult
>['mutate'] = (...args) => {
if (!this.syncClient() || !this.observer) {
return Promise.reject(createMissingQueryClientError())
}
return this.observer.mutate(args[0] as TVariables, args[1])
}
reset: MutationObserverResult<
TData,
TError,
TVariables,
TOnMutateResult
>['reset'] = () => {
if (!this.syncClient() || !this.observer) {
return
}
this.observer.reset()
this.setResult(this.observer.getCurrentResult())
}
private subscribe(): void {
if (!this.observer) {
return
}
if (this.unsubscribe) {
return
}
this.unsubscribe = this.observer.subscribe((next) => {
this.setResult(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.setResult(createIdleMutationResult())
return false
}
if (nextClient === this.queryClient) {
return true
}
this.unsubscribeObserver()
this.queryClient = nextClient
this.observer = new MutationObserver(
this.queryClient,
this.defaultOptions(this.queryClient),
)
this.setResult(this.observer.getCurrentResult())
return true
}
private refreshOptions(): boolean {
if (!this.syncClient() || !this.observer || !this.queryClient) {
return false
}
this.observer.setOptions(this.defaultOptions())
this.setResult(this.observer.getCurrentResult())
return true
}
private defaultOptions(
client = this.queryClient,
): MutationObserverOptions<TData, TError, TVariables, TOnMutateResult> {
if (!client) {
throw createMissingQueryClientError()
}
return client.defaultMutationOptions(readAccessor(this.options))
}
}
/**
* Creates a Lit reactive controller that subscribes the host to a mutation.
*
* The returned accessor is callable and also exposes `current`, `mutate`,
* `mutateAsync`, `reset`, and `destroy`. When `options` is a function, it is
* re-read during host updates so mutation 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 mutation
* subscription.
* @param options - Mutation 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 mutation result with mutation helper
* methods.
*
* @example
* ```ts
* import { LitElement, html } from 'lit'
* import { createMutationController } from '@tanstack/lit-query'
*
* class AddTodoForm extends LitElement {
* private readonly addTodo = createMutationController(this, {
* mutationFn: (title: string) =>
* fetch('/api/todos', { method: 'POST', body: JSON.stringify({ title }) }),
* })
*
* render() {
* const mutation = this.addTodo()
*
* return html`
* <button ?disabled=${mutation.isPending} @click=${() => this.addTodo.mutate('Ship docs')}>
* Add todo
* </button>
* `
* }
* }
* ```
*/
export function createMutationController<
TData = unknown,
TError = DefaultError,
TVariables = void,
TOnMutateResult = unknown,
>(
host: ReactiveControllerHost,
options: Accessor<
CreateMutationOptions<TData, TError, TVariables, TOnMutateResult>
>,
queryClient?: QueryClient,
): MutationResultAccessor<TData, TError, TVariables, TOnMutateResult> {
const controller = new MutationController(host, options, queryClient)
return Object.assign(
createValueAccessor(() => controller.current),
{
mutate: controller.mutate,
mutateAsync: controller.mutateAsync,
reset: controller.reset,
destroy: () => controller.destroy(),
},
)
}

View File

@@ -0,0 +1,719 @@
import {
QueriesObserver,
replaceEqualDeep,
type DefaultError,
type DefinedQueryObserverResult,
type OmitKeyof,
type QueriesObserverOptions,
type QueryFunction,
type QueryKey,
type QueryObserverOptions,
type QueryObserverResult,
type ThrowOnError,
} 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'
/**
* Options for one query inside `createQueriesController`.
*
* This mirrors `QueryObserverOptions` and is used by the tuple inference that
* maps each input query to its corresponding result.
*/
export type CreateQueriesInput<
TQueryFnData = unknown,
TError = DefaultError,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
> = QueryObserverOptions<TQueryFnData, TError, TData, TQueryFnData, TQueryKey>
type CreateQueriesInputForController<
TQueryFnData = unknown,
TError = DefaultError,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
> = OmitKeyof<CreateQueriesInput<TQueryFnData, TError, TData, TQueryKey>, never>
type MAXIMUM_DEPTH = 20
type SkipTokenForCreateQueries = symbol
type GetCreateQueriesInput<T> = T extends {
queryFnData: infer TQueryFnData
error?: infer TError
data: infer TData
}
? CreateQueriesInputForController<TQueryFnData, TError, TData>
: T extends { queryFnData: infer TQueryFnData; error?: infer TError }
? CreateQueriesInputForController<TQueryFnData, TError>
: T extends { data: infer TData; error?: infer TError }
? CreateQueriesInputForController<unknown, TError, TData>
: T extends [infer TQueryFnData, infer TError, infer TData]
? CreateQueriesInputForController<TQueryFnData, TError, TData>
: T extends [infer TQueryFnData, infer TError]
? CreateQueriesInputForController<TQueryFnData, TError>
: T extends [infer TQueryFnData]
? CreateQueriesInputForController<TQueryFnData>
: T extends {
queryFn?:
| QueryFunction<infer TQueryFnData, infer TQueryKey>
| SkipTokenForCreateQueries
select?: (data: any) => infer TData
throwOnError?: ThrowOnError<any, infer TError, any, any>
}
? CreateQueriesInputForController<
TQueryFnData,
unknown extends TError ? DefaultError : TError,
unknown extends TData ? TQueryFnData : TData,
TQueryKey
>
: CreateQueriesInputForController
type GetDefinedOrUndefinedCreateQueriesResult<
T,
TData,
TError = unknown,
> = T extends {
initialData?: infer TInitialData
}
? unknown extends TInitialData
? QueryObserverResult<TData, TError>
: TInitialData extends TData
? DefinedQueryObserverResult<TData, TError>
: TInitialData extends () => infer TInitialDataResult
? unknown extends TInitialDataResult
? QueryObserverResult<TData, TError>
: TInitialDataResult extends TData
? DefinedQueryObserverResult<TData, TError>
: QueryObserverResult<TData, TError>
: QueryObserverResult<TData, TError>
: QueryObserverResult<TData, TError>
type GetCreateQueriesResult<T> = T extends {
queryFnData: any
error?: infer TError
data: infer TData
}
? GetDefinedOrUndefinedCreateQueriesResult<T, TData, TError>
: T extends { queryFnData: infer TQueryFnData; error?: infer TError }
? GetDefinedOrUndefinedCreateQueriesResult<T, TQueryFnData, TError>
: T extends { data: infer TData; error?: infer TError }
? GetDefinedOrUndefinedCreateQueriesResult<T, TData, TError>
: T extends [any, infer TError, infer TData]
? GetDefinedOrUndefinedCreateQueriesResult<T, TData, TError>
: T extends [infer TQueryFnData, infer TError]
? GetDefinedOrUndefinedCreateQueriesResult<T, TQueryFnData, TError>
: T extends [infer TQueryFnData]
? GetDefinedOrUndefinedCreateQueriesResult<T, TQueryFnData>
: T extends {
queryFn?:
| QueryFunction<infer TQueryFnData, any>
| SkipTokenForCreateQueries
select?: (data: any) => infer TData
throwOnError?: ThrowOnError<any, infer TError, any, any>
}
? GetDefinedOrUndefinedCreateQueriesResult<
T,
unknown extends TData ? TQueryFnData : TData,
unknown extends TError ? DefaultError : TError
>
: QueryObserverResult
type CreateQueriesOptions<
T extends Array<any>,
TResults extends Array<any> = [],
TDepth extends ReadonlyArray<number> = [],
> = TDepth['length'] extends MAXIMUM_DEPTH
? Array<CreateQueriesInputForController>
: T extends []
? []
: T extends [infer Head]
? [...TResults, GetCreateQueriesInput<Head>]
: T extends [infer Head, ...infer Tails]
? CreateQueriesOptions<
[...Tails],
[...TResults, GetCreateQueriesInput<Head>],
[...TDepth, 1]
>
: ReadonlyArray<unknown> extends T
? T
: T extends Array<
CreateQueriesInputForController<
infer TQueryFnData,
infer TError,
infer TData,
infer TQueryKey
>
>
? Array<
CreateQueriesInputForController<
TQueryFnData,
TError,
TData,
TQueryKey
>
>
: Array<CreateQueriesInputForController>
/**
* Tuple of query results inferred from the query inputs passed to
* `createQueriesController`.
*/
export type CreateQueriesResults<
T extends Array<any>,
TResults extends Array<any> = [],
TDepth extends ReadonlyArray<number> = [],
> = TDepth['length'] extends MAXIMUM_DEPTH
? Array<QueryObserverResult>
: T extends []
? []
: T extends [infer Head]
? [...TResults, GetCreateQueriesResult<Head>]
: T extends [infer Head, ...infer Tails]
? CreateQueriesResults<
[...Tails],
[...TResults, GetCreateQueriesResult<Head>],
[...TDepth, 1]
>
: { [K in keyof T]: GetCreateQueriesResult<T[K]> }
/**
* Options accepted by `createQueriesController`.
*
* `queries` can be a static list or a getter that returns the current list.
* `combine` can reshape the array of query results into a single value for the
* returned accessor.
*/
export type CreateQueriesControllerOptions<
TQueryOptions extends Array<any> = Array<any>,
TCombinedResult = CreateQueriesResults<TQueryOptions>,
> = {
/** Query options to observe, or a getter that returns the current options. */
queries: Accessor<
| readonly [...CreateQueriesOptions<TQueryOptions>]
| readonly [
...{
[K in keyof TQueryOptions]: GetCreateQueriesInput<TQueryOptions[K]>
},
]
>
/** Optional function that combines the query result array into one value. */
combine?: (result: CreateQueriesResults<TQueryOptions>) => TCombinedResult
}
/**
* Accessor returned by `createQueriesController`.
*
* Call the accessor or read its `current` property to get the latest combined
* value.
*/
export type QueriesResultAccessor<TCombinedResult> =
ValueAccessor<TCombinedResult> & {
/** Removes the controller from its Lit host and unsubscribes observers. */
destroy: () => void
}
function createPendingQueryObserverResult(): QueryObserverResult {
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['refetch'],
} as unknown as QueryObserverResult
}
function createPlaceholderQueryObserverResult(
query: QueryObserverOptions,
): QueryObserverResult {
const initialData =
typeof query.initialData === 'function'
? query.initialData()
: query.initialData
if (initialData === undefined) {
return createPendingQueryObserverResult()
}
const data = query.select ? query.select(initialData) : initialData
const initialDataUpdatedAt =
typeof query.initialDataUpdatedAt === 'function'
? query.initialDataUpdatedAt()
: query.initialDataUpdatedAt
return {
...createPendingQueryObserverResult(),
data,
dataUpdatedAt: initialDataUpdatedAt ?? Date.now(),
isPending: false,
isInitialLoading: false,
isLoading: false,
isSuccess: true,
status: 'success',
} as QueryObserverResult
}
function resolveQueriesOptions<TCombinedResult>(
optionsAccessor: Accessor<
CreateQueriesControllerOptions<any, TCombinedResult>
>,
client: QueryClient,
): {
queries: Array<QueryObserverOptions>
combine: QueriesObserverOptions<TCombinedResult>['combine']
} {
const resolvedOptions = readAccessor(optionsAccessor)
const resolvedQueries = readAccessor(resolvedOptions.queries)
const combine =
resolvedOptions.combine as QueriesObserverOptions<TCombinedResult>['combine']
return {
queries: resolvedQueries.map((query) => {
const defaulted = client.defaultQueryOptions(
query as QueryObserverOptions,
)
;(defaulted as { _optimisticResults?: 'optimistic' })._optimisticResults =
'optimistic'
return defaulted
}),
combine,
}
}
class QueriesController<
TQueryOptions extends Array<any>,
TCombinedResult,
> extends BaseController<TCombinedResult> {
private readonly options: Accessor<
CreateQueriesControllerOptions<TQueryOptions, TCombinedResult>
>
private observer: QueriesObserver<TCombinedResult> | undefined
private unsubscribe: (() => void) | undefined
private queryClient: QueryClient | undefined
private queries: Array<QueryObserverOptions> = []
private combine: QueriesObserverOptions<TCombinedResult>['combine']
private combinedResult: TCombinedResult | undefined
private rawResult: Array<QueryObserverResult> = []
private explicitInitializationError: unknown | undefined
private placeholderInitialized = false
private placeholderRetryableFailure = true
constructor(
host: ReactiveControllerHost,
options: Accessor<
CreateQueriesControllerOptions<TQueryOptions, TCombinedResult>
>,
queryClient?: QueryClient,
) {
super(host, [] as unknown as TCombinedResult, queryClient)
this.options = options
queueMicrotask(() => {
this.placeholderRetryableFailure = false
})
if (!queryClient) {
return
}
if (this.shouldRefreshOnHostUpdate()) {
return
}
this.tryInitializeExplicitClient(queryClient)
}
protected onConnected(): void {
if (!this.syncClient()) {
return
}
this.refreshOptions()
this.subscribe()
}
protected onDisconnected(): void {
this.unsubscribeObserver()
this.syncClient()
}
protected onHostUpdate(): void {
if (!this.shouldRefreshOnHostUpdate()) {
return
}
if (!this.refreshOptions()) {
this.setPlaceholderResult()
}
}
protected onQueryClientChanged(): void {
if (!this.syncClient() || !this.connectedState) {
return
}
this.refreshOptions()
this.subscribe()
}
private subscribe(): void {
if (!this.observer) {
return
}
if (this.unsubscribe) {
return
}
this.unsubscribe = this.observer.subscribe((next) => {
this.setObserverResult(next)
})
}
private tryInitializeExplicitClient(queryClient: QueryClient): boolean {
try {
const { queries, combine } = resolveQueriesOptions(
this.options,
queryClient,
)
this.queries = queries
this.combine = combine
const observer = new QueriesObserver(queryClient, this.queries, {
combine: this.combine,
} as QueriesObserverOptions<TCombinedResult>)
this.queryClient = queryClient
this.observer = observer
this.assignObserverResult(observer.getCurrentResult(), true)
this.explicitInitializationError = undefined
this.placeholderInitialized = true
return true
} catch (error) {
// Retry after construction completes so late host fields used by
// static queries/combine callbacks can finish initializing first.
this.explicitInitializationError = error
this.queryClient = undefined
this.observer = undefined
return false
}
}
private retryExplicitInitializationIfNeeded(): boolean {
if (!this.explicitInitializationError || this.shouldRefreshOnHostUpdate()) {
return false
}
const explicitClient = this.tryGetQueryClient()
if (!explicitClient) {
return false
}
return this.tryInitializeExplicitClient(explicitClient)
}
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.queries = []
this.combine = undefined
this.combinedResult = undefined
this.rawResult = []
this.setPlaceholderResult()
return false
}
if (nextClient === this.queryClient) {
return true
}
this.unsubscribeObserver()
this.queryClient = nextClient
const { queries, combine } = this.readResolvedOptions()
this.queries = queries
this.combine = combine
this.observer = new QueriesObserver(this.queryClient, this.queries, {
combine: this.combine,
} as QueriesObserverOptions<TCombinedResult>)
this.setObserverResult(this.observer.getCurrentResult(), true)
this.placeholderInitialized = true
return true
}
private refreshOptions(): boolean {
if (!this.syncClient() || !this.observer) {
return false
}
const { queries, combine } = this.readResolvedOptions()
this.queries = queries
this.combine = combine
this.observer.setQueries(this.queries, {
combine: this.combine,
} as QueriesObserverOptions<TCombinedResult>)
this.setObserverResult(this.observer.getCurrentResult(), true)
return true
}
private readResolvedOptions(client = this.queryClient): {
queries: Array<QueryObserverOptions>
combine: QueriesObserverOptions<TCombinedResult>['combine']
} {
if (!client) {
throw createMissingQueryClientError()
}
return resolveQueriesOptions(
this.options as Accessor<
CreateQueriesControllerOptions<any, TCombinedResult>
>,
client,
)
}
private shouldRefreshOnHostUpdate(): boolean {
if (typeof this.options === 'function') {
return true
}
return typeof this.options.queries === 'function'
}
private createResult(rawResult: Array<QueryObserverResult>): TCombinedResult {
const trackedResult = this.trackResult(rawResult)
const combine = this.combine
if (!combine) {
return trackedResult as TCombinedResult
}
this.combinedResult = replaceEqualDeep(
this.combinedResult,
combine(trackedResult),
) as TCombinedResult
return this.combinedResult
}
private assignObserverResult(
rawResult: Array<QueryObserverResult>,
force = false,
): void {
if (!force && this.hasObserverResult(rawResult)) {
return
}
this.rawResult = rawResult
this.result = this.createResult(rawResult)
}
private setObserverResult(
rawResult: Array<QueryObserverResult>,
force = false,
): void {
if (!force && this.hasObserverResult(rawResult)) {
return
}
this.rawResult = rawResult
this.setResult(this.createResult(rawResult))
}
private hasObserverResult(rawResult: Array<QueryObserverResult>): boolean {
return (
this.rawResult.length === rawResult.length &&
rawResult.every((result, index) =>
Object.is(this.rawResult[index], result),
)
)
}
private getCurrentObserverResults(): Array<QueryObserverResult> {
if (!this.observer) {
return []
}
return this.observer
.getObservers()
.map((observer) => observer.getCurrentResult())
}
private trackResult(
rawResult: Array<QueryObserverResult>,
): Array<QueryObserverResult> {
if (!this.observer) {
return rawResult
}
const observers = this.observer.getObservers()
return rawResult.map((result, index) => {
const observer = observers[index]
if (!observer || observer.options.notifyOnChangeProps) {
return result
}
return observer.trackResult(result, (accessedProp) => {
observers.forEach((observer) => {
observer.trackProp(accessedProp)
})
})
})
}
private static createPlaceholderResult<TCombinedResult>(
optionsAccessor: Accessor<
CreateQueriesControllerOptions<any, TCombinedResult>
>,
): TCombinedResult {
const resolvedOptions = readAccessor(optionsAccessor)
const queries = readAccessor(resolvedOptions.queries)
const placeholders = queries.map((query) =>
createPlaceholderQueryObserverResult(query as QueryObserverOptions),
)
return (
resolvedOptions.combine
? resolvedOptions.combine(placeholders as never)
: placeholders
) as TCombinedResult
}
readCurrent(): TCombinedResult {
if (this.retryExplicitInitializationIfNeeded()) {
return this.current
}
if (this.explicitInitializationError && !this.placeholderRetryableFailure) {
throw this.explicitInitializationError
}
if (!this.queryClient && !this.observer && !this.placeholderInitialized) {
try {
// Early reads can happen during class-field initialization, before
// accessors referenced by queries/combine are ready. Retry normally
// after construction finishes and only surface errors after that point.
this.setPlaceholderResult()
} catch (error) {
if (!this.placeholderRetryableFailure) {
throw error
}
}
}
if (this.observer) {
this.assignObserverResult(this.getCurrentObserverResults())
}
return this.current
}
private setPlaceholderResult(): void {
this.rawResult = []
this.result = QueriesController.createPlaceholderResult(this.options)
this.placeholderInitialized = true
}
}
/**
* Creates a Lit reactive controller that subscribes the host to multiple
* queries.
*
* The returned accessor is callable and also exposes `current` and `destroy`.
* When `options` or `options.queries` is a function, it is re-read during host
* updates so the query list 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 queries
* subscription.
* @param options - Queries controller 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 results, or the value returned by
* `combine`.
*
* @example
* ```ts
* import { LitElement, html } from 'lit'
* import { createQueriesController } from '@tanstack/lit-query'
*
* class DashboardView extends LitElement {
* private readonly dashboard = createQueriesController(this, {
* queries: [
* { queryKey: ['stats'], queryFn: fetchStats },
* { queryKey: ['projects'], queryFn: fetchProjects },
* ],
* combine: ([stats, projects]) => ({
* stats: stats.data,
* projects: projects.data ?? [],
* isPending: stats.isPending || projects.isPending,
* }),
* })
*
* render() {
* const dashboard = this.dashboard()
* return html`<p>Projects: ${dashboard.projects.length}</p>`
* }
* }
* ```
*/
export function createQueriesController<
TQueryOptions extends Array<any>,
TCombinedResult = CreateQueriesResults<TQueryOptions>,
>(
host: ReactiveControllerHost,
options: Accessor<
CreateQueriesControllerOptions<TQueryOptions, TCombinedResult>
>,
queryClient?: QueryClient,
): QueriesResultAccessor<TCombinedResult> {
const controller = new QueriesController(host, options, queryClient)
return Object.assign(
createValueAccessor(() => controller.readCurrent()),
{
destroy: () => controller.destroy(),
},
)
}

View File

@@ -0,0 +1,378 @@
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<TQueryFnData, TError, TData, TQueryData, TQueryKey>
/**
* 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<TData, TError> = ValueAccessor<
QueryObserverResult<TData, TError>
> & {
/** Refetches the current query. */
refetch: QueryObserverResult<TData, TError>['refetch']
/** Resolves with an optimistic query result, fetching first when needed. */
suspense: () => Promise<QueryObserverResult<TData, TError>>
/** Removes the controller from its Lit host and unsubscribes observers. */
destroy: () => void
}
function createPendingQueryResult<TData, TError>(): 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<TData, TError>
}
class QueryController<
TQueryFnData,
TError,
TData,
TQueryData,
TQueryKey extends QueryKey,
> extends BaseController<QueryObserverResult<TData, TError>> {
private readonly options: Accessor<
CreateQueryOptions<TQueryFnData, TError, TData, TQueryData, TQueryKey>
>
private observer:
| QueryObserver<TQueryFnData, TError, TData, TQueryData, TQueryKey>
| undefined
private readonly resultTracker = new QueryObserverResultTracker<
QueryObserverResult<TData, TError>
>()
private unsubscribe: (() => void) | undefined
private queryClient: QueryClient | undefined
constructor(
host: ReactiveControllerHost,
options: Accessor<
CreateQueryOptions<TQueryFnData, TError, TData, TQueryData, TQueryKey>
>,
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<TData, TError>['refetch'] = (...args) => {
if (!this.applyOptions() || !this.observer) {
return Promise.reject(createMissingQueryClientError())
}
return this.observer.refetch(...args)
}
suspense = async (): Promise<QueryObserverResult<TData, TError>> => {
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<TData, TError> {
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<TData, TError>,
): void {
const trackedResult = this.resultTracker.update(this.observer, result)
if (trackedResult) {
this.result = trackedResult
}
}
private setObserverResult(result: QueryObserverResult<TData, TError>): 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`<ul>${query.data.map((todo) => html`<li>${todo.title}</li>`)}</ul>`
* }
* }
* ```
*/
export function createQueryController<
TQueryFnData = unknown,
TError = DefaultError,
TData = TQueryFnData,
TQueryData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
>(
host: ReactiveControllerHost,
options: Accessor<
CreateQueryOptions<TQueryFnData, TError, TData, TQueryData, TQueryKey>
>,
queryClient?: QueryClient,
): QueryResultAccessor<TData, TError> {
const controller = new QueryController(host, options, queryClient)
return Object.assign(
createValueAccessor(() => controller.readCurrent()),
{
refetch: controller.refetch,
suspense: controller.suspense,
destroy: () => controller.destroy(),
},
)
}

View File

@@ -0,0 +1,72 @@
/* istanbul ignore file */
export * from '@tanstack/query-core'
export type { Accessor, ValueAccessor } from './accessor.js'
export {
getDefaultQueryClient,
queryClientContext,
registerDefaultQueryClient,
resolveQueryClient,
unregisterDefaultQueryClient,
useQueryClient,
} from './context.js'
export { QueryClientProvider } from './QueryClientProvider.js'
export type {
CreateInfiniteQueryOptions,
InfiniteQueryResultAccessor,
} from './createInfiniteQueryController.js'
export { createInfiniteQueryController } from './createInfiniteQueryController.js'
export type {
CreateMutationOptions,
MutationResultAccessor,
} from './createMutationController.js'
export { createMutationController } from './createMutationController.js'
export type {
CreateQueriesControllerOptions,
CreateQueriesInput,
QueriesResultAccessor,
} from './createQueriesController.js'
export { createQueriesController } from './createQueriesController.js'
export type {
CreateQueryOptions,
QueryResultAccessor,
} from './createQueryController.js'
export { createQueryController } from './createQueryController.js'
export type { IsFetchingAccessor } from './useIsFetching.js'
export { useIsFetching } from './useIsFetching.js'
export type { IsMutatingAccessor } from './useIsMutating.js'
export { useIsMutating } from './useIsMutating.js'
export type {
MutationStateAccessor,
MutationStateOptions,
} from './useMutationState.js'
export { useMutationState } from './useMutationState.js'
export type {
DefinedInitialDataOptions,
UndefinedInitialDataOptions,
UnusedSkipTokenOptions,
} from './queryOptions.js'
export { queryOptions } from './queryOptions.js'
export { infiniteQueryOptions } from './infiniteQueryOptions.js'
export { mutationOptions } from './mutationOptions.js'
export type {
InfiniteQueryControllerOptions,
MutationControllerOptions,
MutationControllerResult,
QueriesControllerOptions,
QueryControllerOptions,
QueryControllerResult,
} from './types.js'

View File

@@ -0,0 +1,54 @@
import type {
DataTag,
DefaultError,
InfiniteData,
InfiniteQueryObserverOptions,
QueryKey,
} from '@tanstack/query-core'
/**
* Brands infinite query options so the `queryKey` carries the infinite query
* data and error types across TanStack Query APIs.
*
* @param options - Infinite query options to preserve and brand.
* @returns The same options object with a typed `queryKey`.
*
* @example
* ```ts
* import { infiniteQueryOptions } from '@tanstack/lit-query'
*
* const projectsOptions = infiniteQueryOptions({
* queryKey: ['projects'],
* queryFn: ({ pageParam }) => fetchProjects(pageParam),
* initialPageParam: 0,
* getNextPageParam: (lastPage) => lastPage.nextCursor,
* })
* ```
*/
export function infiniteQueryOptions<
TQueryFnData = unknown,
TError = DefaultError,
TData = InfiniteData<TQueryFnData>,
TQueryKey extends QueryKey = QueryKey,
TPageParam = unknown,
>(
options: InfiniteQueryObserverOptions<
TQueryFnData,
TError,
TData,
TQueryKey,
TPageParam
>,
): InfiniteQueryObserverOptions<
TQueryFnData,
TError,
TData,
TQueryKey,
TPageParam
> & {
queryKey: DataTag<TQueryKey, InfiniteData<TQueryFnData>, TError>
}
export function infiniteQueryOptions(options: unknown) {
return options
}

View File

@@ -0,0 +1,31 @@
import type {
DefaultError,
MutationObserverOptions,
} from '@tanstack/query-core'
/**
* Preserves and types mutation options for reuse across Lit Query APIs.
*
* @param options - Mutation options to preserve.
* @returns The same options object.
*
* @example
* ```ts
* import { mutationOptions } from '@tanstack/lit-query'
*
* const addTodoOptions = mutationOptions({
* mutationKey: ['add-todo'],
* mutationFn: (title: string) => addTodo(title),
* })
* ```
*/
export function mutationOptions<
TData = unknown,
TError = DefaultError,
TVariables = void,
TOnMutateResult = unknown,
>(
options: MutationObserverOptions<TData, TError, TVariables, TOnMutateResult>,
): MutationObserverOptions<TData, TError, TVariables, TOnMutateResult> {
return options
}

View File

@@ -0,0 +1,30 @@
type TrackableQueryObserver<TResult extends object> = {
options: { notifyOnChangeProps?: unknown }
trackResult: (result: TResult) => unknown
}
export class QueryObserverResultTracker<TResult extends object> {
private result: TResult | undefined
private usesTracking = false
reset(): void {
this.result = undefined
this.usesTracking = false
}
update(
observer: TrackableQueryObserver<TResult> | undefined,
result: TResult,
): TResult | undefined {
const usesTracking = !!observer && !observer.options.notifyOnChangeProps
if (Object.is(this.result, result) && this.usesTracking === usesTracking) {
return undefined
}
this.result = result
this.usesTracking = usesTracking
return usesTracking ? (observer.trackResult(result) as TResult) : result
}
}

View File

@@ -0,0 +1,143 @@
import type {
DataTag,
DefaultError,
InitialDataFunction,
NonUndefinedGuard,
OmitKeyof,
QueryFunction,
QueryKey,
QueryObserverOptions,
SkipToken,
} from '@tanstack/query-core'
/**
* Query options with `initialData` that guarantees defined query data.
*/
export type DefinedInitialDataOptions<
TQueryFnData = unknown,
TError = DefaultError,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
> = Omit<
QueryObserverOptions<TQueryFnData, TError, TData, TQueryFnData, TQueryKey>,
'queryFn'
> & {
initialData:
| NonUndefinedGuard<TQueryFnData>
| (() => NonUndefinedGuard<TQueryFnData>)
queryFn?: QueryFunction<TQueryFnData, TQueryKey>
}
/**
* Query options where `queryFn` is present and not a `skipToken`.
*/
export type UnusedSkipTokenOptions<
TQueryFnData = unknown,
TError = DefaultError,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
> = OmitKeyof<
QueryObserverOptions<TQueryFnData, TError, TData, TQueryFnData, TQueryKey>,
'queryFn'
> & {
queryFn?: Exclude<
QueryObserverOptions<
TQueryFnData,
TError,
TData,
TQueryFnData,
TQueryKey
>['queryFn'],
SkipToken | undefined
>
}
/**
* Query options where `initialData` can be omitted or undefined.
*/
export type UndefinedInitialDataOptions<
TQueryFnData = unknown,
TError = DefaultError,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
> = QueryObserverOptions<
TQueryFnData,
TError,
TData,
TQueryFnData,
TQueryKey
> & {
initialData?:
| undefined
| InitialDataFunction<NonUndefinedGuard<TQueryFnData>>
| NonUndefinedGuard<TQueryFnData>
}
/**
* Brands query options so the `queryKey` carries the query function data and
* error types across TanStack Query APIs.
*
* @param options - Query options to preserve and brand.
* @returns The same options object with a typed `queryKey`.
*
* @example
* ```ts
* import { queryOptions } from '@tanstack/lit-query'
*
* const todosOptions = queryOptions({
* queryKey: ['todos'],
* queryFn: fetchTodos,
* initialData: [],
* })
* ```
*/
export function queryOptions<
TQueryFnData = unknown,
TError = DefaultError,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
>(
options: DefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey>,
): DefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey> & {
queryKey: DataTag<TQueryKey, TQueryFnData, TError>
}
/**
* Brands query options so the `queryKey` carries the query function data and
* error types across TanStack Query APIs.
*
* @param options - Query options to preserve and brand.
* @returns The same options object with a typed `queryKey`.
*/
export function queryOptions<
TQueryFnData = unknown,
TError = DefaultError,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
>(
options: UnusedSkipTokenOptions<TQueryFnData, TError, TData, TQueryKey>,
): UnusedSkipTokenOptions<TQueryFnData, TError, TData, TQueryKey> & {
queryKey: DataTag<TQueryKey, TQueryFnData, TError>
}
/**
* Brands query options so the `queryKey` carries the query function data and
* error types across TanStack Query APIs.
*
* @param options - Query options to preserve and brand.
* @returns The same options object with a typed `queryKey`.
*/
export function queryOptions<
TQueryFnData = unknown,
TError = DefaultError,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
>(
options: UndefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey>,
): UndefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey> & {
queryKey: DataTag<TQueryKey, TQueryFnData, TError>
}
export function queryOptions(options: unknown) {
return options
}

View File

@@ -0,0 +1,111 @@
import { describe, expect, it } from 'vitest'
import type { ReactiveController, ReactiveControllerHost } from 'lit'
import { QueryClient } from '@tanstack/query-core'
import { QueryClientProvider } from '../QueryClientProvider.js'
import { BaseController } from '../controllers/BaseController.js'
const providerTagName = 'test-query-client-provider-base-controller'
if (!customElements.get(providerTagName)) {
customElements.define(providerTagName, QueryClientProvider)
}
class RecordingController extends BaseController<string> {
readonly lifecycle: string[] = []
constructor(host: ReactiveControllerHost) {
super(host, 'pending')
}
protected onConnected(): void {
this.lifecycle.push(
`connected:${this.tryGetQueryClient() ? 'client' : 'missing'}`,
)
}
protected onDisconnected(): void {}
protected onHostUpdate(): void {}
protected onQueryClientChanged(): void {
this.lifecycle.push(
`changed:${this.tryGetQueryClient() ? 'client' : 'missing'}`,
)
}
}
class AlreadyConnectedContextHost
extends HTMLElement
implements ReactiveControllerHost
{
private readonly controllers = new Set<ReactiveController>()
updatesRequested = 0
readonly updateComplete: Promise<boolean> = Promise.resolve(true)
addController(controller: ReactiveController): void {
this.controllers.add(controller)
if (this.isConnected) {
controller.hostConnected?.()
}
}
removeController(controller: ReactiveController): void {
this.controllers.delete(controller)
}
requestUpdate(): void {
this.updatesRequested += 1
}
connectedCallback(): void {
for (const controller of this.controllers) {
controller.hostConnected?.()
}
}
disconnectedCallback(): void {
for (const controller of this.controllers) {
controller.hostDisconnected?.()
}
}
attachController(): RecordingController {
return new RecordingController(this)
}
}
const hostTagName = 'test-base-controller-context-host'
if (!customElements.get(hostTagName)) {
customElements.define(hostTagName, AlreadyConnectedContextHost)
}
describe('BaseController', () => {
it('defers provider resolution on already-connected hosts until after onConnected', async () => {
const client = new QueryClient()
const provider = document.createElement(
providerTagName,
) as QueryClientProvider
provider.client = client
const host = document.createElement(
hostTagName,
) as AlreadyConnectedContextHost
provider.append(host)
document.body.append(provider)
await provider.updateComplete
const controller = host.attachController()
await Promise.resolve()
await Promise.resolve()
expect(controller.lifecycle).toEqual([
'connected:missing',
'changed:client',
])
controller.destroy()
provider.remove()
await Promise.resolve()
})
})

View File

@@ -0,0 +1,483 @@
import { describe, expect, it } from 'vitest'
import { QueryClient } from '@tanstack/query-core'
import { sleep } from '@tanstack/query-test-utils'
import type { ReactiveController, ReactiveControllerHost } from 'lit'
import { QueryClientProvider } from '../QueryClientProvider.js'
import { createInfiniteQueryController } from '../createInfiniteQueryController.js'
import { createMutationController } from '../createMutationController.js'
import { createQueriesController } from '../createQueriesController.js'
import { waitFor } from './testHost.js'
const providerTagName = 'test-query-client-provider-switch'
if (!customElements.get(providerTagName)) {
customElements.define(providerTagName, QueryClientProvider)
}
class BaseControllerHostElement
extends HTMLElement
implements ReactiveControllerHost
{
private readonly controllers = new Set<ReactiveController>()
updatesRequested = 0
readonly updateComplete: Promise<boolean> = Promise.resolve(true)
addController(controller: ReactiveController): void {
this.controllers.add(controller)
}
removeController(controller: ReactiveController): void {
this.controllers.delete(controller)
}
requestUpdate(): void {
this.updatesRequested += 1
}
connectedCallback(): void {
for (const controller of this.controllers) {
controller.hostConnected?.()
}
}
disconnectedCallback(): void {
for (const controller of this.controllers) {
controller.hostDisconnected?.()
}
}
}
class MutationSwitchHostElement extends BaseControllerHostElement {
mutationCalls = 0
readonly mutationKey = ['switch-mutation'] as const
readonly mutation = createMutationController(this, () => ({
mutationKey: this.mutationKey,
mutationFn: async (value: number) => {
this.mutationCalls += 1
return value + 1
},
}))
}
const mutationHostTagName = 'test-mutation-switch-host'
if (!customElements.get(mutationHostTagName)) {
customElements.define(mutationHostTagName, MutationSwitchHostElement)
}
class QueriesSwitchHostElement extends BaseControllerHostElement {
queryCalls = 0
readonly queryKey = ['switch-queries'] as const
readonly queries = createQueriesController(this, () => ({
queries: [
{
queryKey: this.queryKey,
queryFn: async () => {
this.queryCalls += 1
return `q-${this.queryCalls}`
},
retry: false,
},
] as const,
combine: (results) => results.map((result) => result.data),
}))
}
const queriesHostTagName = 'test-queries-switch-host'
if (!customElements.get(queriesHostTagName)) {
customElements.define(queriesHostTagName, QueriesSwitchHostElement)
}
class InfiniteSwitchHostElement extends BaseControllerHostElement {
pageCalls = 0
readonly queryKey = ['switch-infinite'] as const
readonly infinite = createInfiniteQueryController(this, () => ({
queryKey: this.queryKey,
initialPageParam: 0,
queryFn: async ({ pageParam }) => {
this.pageCalls += 1
return Number(pageParam)
},
getNextPageParam: (lastPage: number) =>
lastPage < 1 ? lastPage + 1 : undefined,
retry: false,
}))
}
const infiniteHostTagName = 'test-infinite-switch-host'
if (!customElements.get(infiniteHostTagName)) {
customElements.define(infiniteHostTagName, InfiniteSwitchHostElement)
}
describe('LQ-003 client-switch coverage across controllers', () => {
it('switches mutation controller to new provider client while connected', async () => {
const clientA = new QueryClient()
const clientB = new QueryClient()
const provider = document.createElement(
providerTagName,
) as QueryClientProvider
provider.client = clientA
document.body.append(provider)
await provider.updateComplete
const consumer = document.createElement(
mutationHostTagName,
) as MutationSwitchHostElement
provider.append(consumer)
await Promise.resolve()
await Promise.resolve()
await expect(consumer.mutation.mutateAsync(1)).resolves.toBe(2)
const countAAfterFirst = clientA
.getMutationCache()
.findAll({ mutationKey: consumer.mutationKey }).length
expect(countAAfterFirst).toBeGreaterThan(0)
provider.client = clientB
await provider.updateComplete
await Promise.resolve()
await expect(consumer.mutation.mutateAsync(2)).resolves.toBe(3)
const countAAfterSecond = clientA
.getMutationCache()
.findAll({ mutationKey: consumer.mutationKey }).length
const countBAfterSecond = clientB
.getMutationCache()
.findAll({ mutationKey: consumer.mutationKey }).length
expect(countAAfterSecond).toBe(countAAfterFirst)
expect(countBAfterSecond).toBeGreaterThan(0)
consumer.mutation.destroy()
provider.remove()
await Promise.resolve()
})
it('switches queries controller to new provider client while connected', async () => {
const clientA = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
const clientB = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
const provider = document.createElement(
providerTagName,
) as QueryClientProvider
provider.client = clientA
document.body.append(provider)
await provider.updateComplete
const consumer = document.createElement(
queriesHostTagName,
) as QueriesSwitchHostElement
provider.append(consumer)
await Promise.resolve()
await waitFor(() => typeof consumer.queries()[0] === 'string')
const cacheAEntryBeforeSwitch = clientA
.getQueryCache()
.find({ queryKey: consumer.queryKey })
expect(cacheAEntryBeforeSwitch?.getObserversCount()).toBe(1)
provider.client = clientB
await provider.updateComplete
await waitFor(() => {
const cacheBEntry = clientB
.getQueryCache()
.find({ queryKey: consumer.queryKey })
return Boolean(cacheBEntry && cacheBEntry.getObserversCount() === 1)
})
const cacheAEntryAfterSwitch = clientA
.getQueryCache()
.find({ queryKey: consumer.queryKey })
expect(cacheAEntryAfterSwitch?.getObserversCount() ?? 0).toBe(0)
void clientB.invalidateQueries({ queryKey: consumer.queryKey })
await waitFor(() => consumer.queryCalls >= 2)
consumer.queries.destroy()
provider.remove()
await Promise.resolve()
})
it('switches infinite query controller to new provider client while connected', async () => {
const clientA = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
const clientB = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
const provider = document.createElement(
providerTagName,
) as QueryClientProvider
provider.client = clientA
document.body.append(provider)
await provider.updateComplete
const consumer = document.createElement(
infiniteHostTagName,
) as InfiniteSwitchHostElement
provider.append(consumer)
await Promise.resolve()
await waitFor(() => consumer.infinite().isSuccess)
expect(consumer.infinite().data?.pages).toEqual([0])
const cacheAEntryBeforeSwitch = clientA
.getQueryCache()
.find({ queryKey: consumer.queryKey })
expect(cacheAEntryBeforeSwitch?.getObserversCount()).toBe(1)
provider.client = clientB
await provider.updateComplete
await waitFor(() => {
const cacheBEntry = clientB
.getQueryCache()
.find({ queryKey: consumer.queryKey })
return Boolean(cacheBEntry && cacheBEntry.getObserversCount() === 1)
})
await waitFor(
() =>
consumer.infinite().isSuccess &&
(consumer.infinite().data?.pages.length ?? 0) >= 1,
4000,
)
await waitFor(() => consumer.infinite().hasNextPage === true, 4000)
const cacheAEntryAfterSwitch = clientA
.getQueryCache()
.find({ queryKey: consumer.queryKey })
expect(cacheAEntryAfterSwitch?.getObserversCount() ?? 0).toBe(0)
await consumer.infinite.fetchNextPage()
await waitFor(() => consumer.infinite().data?.pages.length === 2, 4000)
expect(consumer.infinite().data?.pages).toEqual([0, 1])
consumer.infinite.destroy()
provider.remove()
await Promise.resolve()
})
it('reparents mutation controller under a different provider and binds the new nearest client', async () => {
const clientA = new QueryClient()
const clientB = new QueryClient()
const providerA = document.createElement(
providerTagName,
) as QueryClientProvider
providerA.client = clientA
const providerB = document.createElement(
providerTagName,
) as QueryClientProvider
providerB.client = clientB
const consumer = document.createElement(
mutationHostTagName,
) as MutationSwitchHostElement
providerA.append(consumer)
document.body.append(providerA)
await providerA.updateComplete
await Promise.resolve()
await Promise.resolve()
await expect(consumer.mutation.mutateAsync(1)).resolves.toBe(2)
consumer.remove()
await sleep(0)
providerA.remove()
providerB.append(consumer)
document.body.append(providerB)
await providerB.updateComplete
await Promise.resolve()
await Promise.resolve()
await expect(consumer.mutation.mutateAsync(2)).resolves.toBe(3)
expect(
clientA.getMutationCache().findAll({ mutationKey: consumer.mutationKey })
.length,
).toBeGreaterThan(0)
expect(
clientB.getMutationCache().findAll({ mutationKey: consumer.mutationKey })
.length,
).toBeGreaterThan(0)
consumer.mutation.destroy()
providerB.remove()
await Promise.resolve()
})
it('reparents queries controller under a different provider without cross-tree leakage', async () => {
const clientA = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
const clientB = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
const providerA = document.createElement(
providerTagName,
) as QueryClientProvider
providerA.client = clientA
const providerB = document.createElement(
providerTagName,
) as QueryClientProvider
providerB.client = clientB
const consumer = document.createElement(
queriesHostTagName,
) as QueriesSwitchHostElement
providerA.append(consumer)
document.body.append(providerA)
await providerA.updateComplete
await waitFor(() => typeof consumer.queries()[0] === 'string')
consumer.remove()
await waitFor(
() =>
(clientA
.getQueryCache()
.find({ queryKey: consumer.queryKey })
?.getObserversCount() ?? 0) === 0,
)
providerA.remove()
providerB.append(consumer)
document.body.append(providerB)
await providerB.updateComplete
await waitFor(
() =>
typeof consumer.queries()[0] === 'string' && consumer.queryCalls >= 2,
)
expect(
clientA
.getQueryCache()
.find({ queryKey: consumer.queryKey })
?.getObserversCount() ?? 0,
).toBe(0)
expect(
clientB
.getQueryCache()
.find({ queryKey: consumer.queryKey })
?.getObserversCount(),
).toBe(1)
consumer.queries.destroy()
providerA.remove()
providerB.remove()
await Promise.resolve()
})
it('reparents infinite query controller under a different provider and binds the new nearest client', async () => {
const clientA = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
const clientB = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
const providerA = document.createElement(
providerTagName,
) as QueryClientProvider
providerA.client = clientA
const providerB = document.createElement(
providerTagName,
) as QueryClientProvider
providerB.client = clientB
const consumer = document.createElement(
infiniteHostTagName,
) as InfiniteSwitchHostElement
providerA.append(consumer)
document.body.append(providerA)
await providerA.updateComplete
await waitFor(() => consumer.infinite().isSuccess)
consumer.remove()
await waitFor(
() =>
(clientA
.getQueryCache()
.find({ queryKey: consumer.queryKey })
?.getObserversCount() ?? 0) === 0,
)
providerA.remove()
providerB.append(consumer)
document.body.append(providerB)
await providerB.updateComplete
await waitFor(
() =>
consumer.infinite().isSuccess &&
(consumer.infinite().data?.pages.length ?? 0) >= 1 &&
consumer.pageCalls >= 2,
)
expect(
clientA
.getQueryCache()
.find({ queryKey: consumer.queryKey })
?.getObserversCount() ?? 0,
).toBe(0)
expect(
clientB
.getQueryCache()
.find({ queryKey: consumer.queryKey })
?.getObserversCount(),
).toBe(1)
consumer.infinite.destroy()
providerA.remove()
providerB.remove()
await Promise.resolve()
})
})

View File

@@ -0,0 +1,211 @@
import { describe, expect, it, vi } from 'vitest'
import { QueryClient } from '@tanstack/query-core'
import { createQueryController } from '../createQueryController.js'
import {
getDefaultQueryClient,
resolveQueryClient,
useQueryClient,
} from '../index.js'
import { QueryClientProvider } from '../QueryClientProvider.js'
import {
TestElementHost,
waitFor,
waitForMissingQueryClient,
} from './testHost.js'
const tagName = 'test-query-client-provider'
if (!customElements.get(tagName)) {
customElements.define(tagName, QueryClientProvider)
}
class ProviderContextConsumerElement extends TestElementHost {
readonly query = createQueryController(this, {
queryKey: ['provider-context-consumer'] as const,
queryFn: async () => 'ok',
retry: false,
})
}
const consumerTagName = 'test-query-client-provider-consumer'
if (!customElements.get(consumerTagName)) {
customElements.define(consumerTagName, ProviderContextConsumerElement)
}
describe('QueryClientProvider/context', () => {
it('registers and unregisters the default query client for public helpers', async () => {
const client = new QueryClient()
const provider = document.createElement(tagName) as QueryClientProvider
provider.client = client
document.body.append(provider)
await provider.updateComplete
expect(useQueryClient()).toBe(client)
expect(resolveQueryClient()).toBe(client)
provider.remove()
await Promise.resolve()
expect(() => useQueryClient()).toThrow(/No QueryClient available/)
})
it('prefers an explicit client in resolveQueryClient', () => {
const explicit = new QueryClient()
expect(resolveQueryClient(explicit)).toBe(explicit)
})
it('keeps the default client registered until the last provider using it disconnects', async () => {
const client = new QueryClient()
const providerA = document.createElement(tagName) as QueryClientProvider
const providerB = document.createElement(tagName) as QueryClientProvider
providerA.client = client
providerB.client = client
document.body.append(providerA)
document.body.append(providerB)
await providerA.updateComplete
await providerB.updateComplete
expect(useQueryClient()).toBe(client)
providerB.remove()
await Promise.resolve()
expect(useQueryClient()).toBe(client)
providerA.remove()
await Promise.resolve()
expect(() => useQueryClient()).toThrow(/No QueryClient available/)
})
it('throws when multiple different providers make global lookup ambiguous', async () => {
const clientA = new QueryClient()
const clientB = new QueryClient()
const providerA = document.createElement(tagName) as QueryClientProvider
const providerB = document.createElement(tagName) as QueryClientProvider
providerA.client = clientA
providerB.client = clientB
document.body.append(providerA)
document.body.append(providerB)
await providerA.updateComplete
await providerB.updateComplete
expect(getDefaultQueryClient()).toBeUndefined()
expect(() => useQueryClient()).toThrow(/Multiple QueryClients are mounted/)
expect(() => resolveQueryClient()).toThrow(
/Multiple QueryClients are mounted/,
)
providerB.remove()
await Promise.resolve()
expect(getDefaultQueryClient()).toBe(clientA)
expect(useQueryClient()).toBe(clientA)
providerA.remove()
await Promise.resolve()
})
it('requires an explicit client before connect', () => {
const provider = document.createElement(tagName) as QueryClientProvider
expect(() => provider.connectedCallback()).toThrow(
/No QueryClient available/,
)
})
it('S8: provider swap while disconnected preserves mount/unmount contract', async () => {
const clientA = new QueryClient()
const clientB = new QueryClient()
const mountA = vi.spyOn(clientA, 'mount')
const unmountA = vi.spyOn(clientA, 'unmount')
const mountB = vi.spyOn(clientB, 'mount')
const unmountB = vi.spyOn(clientB, 'unmount')
const provider = document.createElement(tagName) as QueryClientProvider
provider.client = clientA
document.body.append(provider)
await provider.updateComplete
expect(mountA).toHaveBeenCalledTimes(1)
expect(unmountA).toHaveBeenCalledTimes(0)
expect(mountB).toHaveBeenCalledTimes(0)
expect(unmountB).toHaveBeenCalledTimes(0)
provider.remove()
await Promise.resolve()
expect(unmountA).toHaveBeenCalledTimes(1)
expect(mountB).toHaveBeenCalledTimes(0)
provider.client = clientB
await provider.updateComplete
expect(unmountA).toHaveBeenCalledTimes(1)
expect(mountB).toHaveBeenCalledTimes(0)
document.body.append(provider)
await provider.updateComplete
expect(mountA).toHaveBeenCalledTimes(1)
expect(unmountA).toHaveBeenCalledTimes(1)
expect(mountB).toHaveBeenCalledTimes(1)
expect(unmountB).toHaveBeenCalledTimes(0)
provider.remove()
await Promise.resolve()
expect(unmountB).toHaveBeenCalledTimes(1)
mountA.mockRestore()
unmountA.mockRestore()
mountB.mockRestore()
unmountB.mockRestore()
})
it('LC-PROVIDER-01: invalid connected client updates tear down the mounted client before surfacing the error', async () => {
const client = new QueryClient()
const mount = vi.spyOn(client, 'mount')
const unmount = vi.spyOn(client, 'unmount')
const provider = document.createElement(tagName) as QueryClientProvider
const consumer = document.createElement(
consumerTagName,
) as ProviderContextConsumerElement
provider.client = client
provider.append(consumer)
document.body.append(provider)
await provider.updateComplete
await consumer.updateComplete
await waitFor(() => consumer.query().isSuccess)
expect(mount).toHaveBeenCalledTimes(1)
expect(unmount).toHaveBeenCalledTimes(0)
expect(consumer.query().data).toBe('ok')
provider.client = undefined as unknown as QueryClient
await expect(provider.updateComplete).rejects.toThrow(
/No QueryClient available/,
)
expect(unmount).toHaveBeenCalledTimes(1)
expect(getDefaultQueryClient()).toBeUndefined()
expect(() => useQueryClient()).toThrow(/No QueryClient available/)
await waitForMissingQueryClient(() => consumer.query())
await expect(consumer.query.refetch()).rejects.toThrow(
/No QueryClient available/,
)
consumer.query.destroy()
provider.remove()
await Promise.resolve()
expect(unmount).toHaveBeenCalledTimes(1)
mount.mockRestore()
unmount.mockRestore()
})
})

View File

@@ -0,0 +1,736 @@
import { describe, expect, it } from 'vitest'
import { QueryClient } from '@tanstack/query-core'
import { sleep } from '@tanstack/query-test-utils'
import type { ReactiveController, ReactiveControllerHost } from 'lit'
import { QueryClientProvider } from '../QueryClientProvider.js'
import { createMutationController } from '../createMutationController.js'
import { createQueryController } from '../createQueryController.js'
import { useIsFetching } from '../useIsFetching.js'
import { useIsMutating } from '../useIsMutating.js'
import { useMutationState } from '../useMutationState.js'
import {
TestControllerHost,
TestElementHost,
waitFor,
waitForMissingQueryClient,
} from './testHost.js'
const providerTagName = 'test-query-client-provider-counters'
if (!customElements.get(providerTagName)) {
customElements.define(providerTagName, QueryClientProvider)
}
let explicitCountersClient: QueryClient | undefined
class ContextCountersHostElement extends TestElementHost {
readonly queryKey = ['context-counters', 'query'] as const
readonly mutationKey = ['context-counters', 'mutation'] as const
private resolveQuery: (() => void) | undefined
private resolveMutation: (() => void) | undefined
readonly query = createQueryController(
this,
{
queryKey: this.queryKey,
queryFn: () =>
new Promise<string>((resolve) => {
this.resolveQuery = () => resolve('query-ok')
}),
retry: false,
},
explicitCountersClient,
)
readonly mutation = createMutationController(
this,
{
mutationKey: this.mutationKey,
mutationFn: () =>
new Promise<string>((resolve) => {
this.resolveMutation = () => resolve('mutation-ok')
}),
},
explicitCountersClient,
)
readonly isFetching = useIsFetching(
this,
{ queryKey: this.queryKey },
explicitCountersClient,
)
readonly isMutating = useIsMutating(
this,
{ mutationKey: this.mutationKey },
explicitCountersClient,
)
readonly mutationStatuses = useMutationState<string>(
this,
{
filters: { mutationKey: this.mutationKey },
select: (mutation) => mutation.state.status,
},
explicitCountersClient,
)
resolvePendingQuery(): void {
this.resolveQuery?.()
}
resolvePendingMutation(): void {
this.resolveMutation?.()
}
}
const contextCountersTagName = 'test-context-counters-host'
if (!customElements.get(contextCountersTagName)) {
customElements.define(contextCountersTagName, ContextCountersHostElement)
}
describe('useIsFetching/useIsMutating/useMutationState', () => {
it('does not request another update when stable mutation state selectors refresh during host update', async () => {
const client = new QueryClient({
defaultOptions: {
mutations: {
retry: false,
},
},
})
const host = new TestControllerHost()
const mutationStates = useMutationState<string>(
host,
{
select: (mutation) => mutation.state.status,
},
client,
)
try {
host.connect()
await Promise.resolve()
expect(mutationStates()).toEqual([])
host.updatesRequested = 0
for (let i = 0; i < 5; i += 1) {
host.update()
await Promise.resolve()
}
expect(host.updatesRequested).toBe(0)
expect(mutationStates()).toEqual([])
} finally {
mutationStates.destroy()
}
})
it('does not request another update when mutation cache emits with unchanged selected state', async () => {
const client = new QueryClient({
defaultOptions: {
mutations: {
retry: false,
},
},
})
const mutationKey = ['mutation-state', 'stable-cache-events'] as const
const mutation = client.getMutationCache().build(client, {
mutationKey,
})
const host = new TestControllerHost()
const mutationStates = useMutationState<string>(
host,
{
filters: { mutationKey },
select: (mutation) => mutation.state.status,
},
client,
)
try {
host.connect()
await Promise.resolve()
expect(mutationStates()).toEqual(['idle'])
host.updatesRequested = 0
for (let i = 0; i < 5; i += 1) {
client.getMutationCache().notify({
type: 'updated',
mutation,
action: { type: 'pause' } as never,
})
await Promise.resolve()
}
expect(host.updatesRequested).toBe(0)
expect(mutationStates()).toEqual(['idle'])
} finally {
mutationStates.destroy()
}
})
it('LC-COUNTERS-01: pre-connect placeholders stay zero/empty until a provider binds', async () => {
const consumer = document.createElement(
contextCountersTagName,
) as ContextCountersHostElement
expect(consumer.query().status).toBe('pending')
expect(consumer.isFetching()).toBe(0)
expect(consumer.isMutating()).toBe(0)
expect(consumer.mutationStatuses()).toEqual([])
const client = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
mutations: {
retry: false,
},
},
})
const provider = document.createElement(
providerTagName,
) as QueryClientProvider
provider.client = client
provider.append(consumer)
document.body.append(provider)
await provider.updateComplete
await consumer.updateComplete
await waitFor(() => consumer.isFetching() === 1)
consumer.resolvePendingQuery()
await waitFor(() => consumer.query().isSuccess)
await waitFor(() => consumer.isFetching() === 0)
consumer.mutation.mutate()
await waitFor(() => consumer.isMutating() === 1)
consumer.resolvePendingMutation()
await waitFor(() => consumer.isMutating() === 0)
await waitFor(() => consumer.mutationStatuses().includes('success'))
consumer.query.destroy()
consumer.mutation.destroy()
consumer.isFetching.destroy()
consumer.isMutating.destroy()
consumer.mutationStatuses.destroy()
provider.remove()
await Promise.resolve()
})
it('LC-COUNTERS-02: explicit client takes precedence over provider context', async () => {
const explicitClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
mutations: {
retry: false,
},
},
})
const providerClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
mutations: {
retry: false,
},
},
})
explicitCountersClient = explicitClient
const provider = document.createElement(
providerTagName,
) as QueryClientProvider
provider.client = providerClient
const consumer = document.createElement(
contextCountersTagName,
) as ContextCountersHostElement
provider.append(consumer)
document.body.append(provider)
await provider.updateComplete
await consumer.updateComplete
await waitFor(() => consumer.isFetching() === 1)
consumer.resolvePendingQuery()
await waitFor(() => consumer.query().isSuccess)
consumer.mutation.mutate()
await waitFor(() => consumer.isMutating() === 1)
consumer.resolvePendingMutation()
await waitFor(() => consumer.isMutating() === 0)
expect(
explicitClient.getQueryCache().find({ queryKey: consumer.queryKey })
?.state.data,
).toBe('query-ok')
expect(
providerClient.getQueryCache().find({ queryKey: consumer.queryKey }),
).toBeUndefined()
expect(
explicitClient
.getMutationCache()
.findAll({ mutationKey: consumer.mutationKey }).length,
).toBeGreaterThan(0)
expect(
providerClient
.getMutationCache()
.findAll({ mutationKey: consumer.mutationKey }).length,
).toBe(0)
consumer.query.destroy()
consumer.mutation.destroy()
consumer.isFetching.destroy()
consumer.isMutating.destroy()
consumer.mutationStatuses.destroy()
provider.remove()
explicitCountersClient = undefined
await Promise.resolve()
})
it('tracks fetch/mutate counters and mutation state', async () => {
const client = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
const host = new TestControllerHost()
const query = createQueryController(
host,
{
queryKey: ['counter-test'],
queryFn: async () => {
await sleep(40)
return 'done'
},
},
client,
)
const mutation = createMutationController(
host,
{
mutationFn: async (value: number) => {
await sleep(40)
return value + 10
},
},
client,
)
const isFetching = useIsFetching(host, {}, client)
const isMutating = useIsMutating(host, {}, client)
const mutationStatuses = useMutationState<string>(
host,
{
select: (item) => item.state.status,
},
client,
)
host.connect()
host.update()
await waitFor(() => isFetching() === 1)
await waitFor(() => query().isSuccess)
await waitFor(() => isFetching() === 0)
mutation.mutate(1)
await waitFor(() => isMutating() === 1)
await waitFor(() => isMutating() === 0)
await waitFor(() => mutationStatuses().includes('success'))
})
it('S1: useIsFetching tracks filters and filter reactivity', async () => {
const client = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
const host = new TestControllerHost()
let resolveA: (() => void) | undefined
let resolveB: (() => void) | undefined
let activeFilter: { queryKey?: readonly string[] } = {
queryKey: ['fetch-a'],
}
createQueryController(
host,
{
queryKey: ['fetch-a'],
queryFn: () =>
new Promise<string>((resolve) => {
resolveA = () => resolve('a')
}),
},
client,
)
createQueryController(
host,
{
queryKey: ['fetch-b'],
queryFn: () =>
new Promise<string>((resolve) => {
resolveB = () => resolve('b')
}),
},
client,
)
const isFetchingAll = useIsFetching(host, {}, client)
const isFetchingFiltered = useIsFetching(host, () => activeFilter, client)
host.connect()
host.update()
await waitFor(() => isFetchingAll() === 2)
await waitFor(() => isFetchingFiltered() === 1)
activeFilter = { queryKey: ['fetch-b'] }
host.update()
await waitFor(() => isFetchingFiltered() === 1)
resolveA?.()
await waitFor(() => isFetchingAll() === 1)
await waitFor(() => isFetchingFiltered() === 1)
resolveB?.()
await waitFor(() => isFetchingAll() === 0)
await waitFor(() => isFetchingFiltered() === 0)
})
it('S2: useIsMutating tracks mutation filters and reactivity', async () => {
const client = new QueryClient()
const host = new TestControllerHost()
let resolveA: (() => void) | undefined
let resolveB: (() => void) | undefined
let activeFilter: { mutationKey?: readonly string[] } = {
mutationKey: ['mut-a'],
}
const mutationA = createMutationController(
host,
{
mutationKey: ['mut-a'],
mutationFn: () =>
new Promise<number>((resolve) => {
resolveA = () => resolve(1)
}),
},
client,
)
const mutationB = createMutationController(
host,
{
mutationKey: ['mut-b'],
mutationFn: () =>
new Promise<number>((resolve) => {
resolveB = () => resolve(2)
}),
},
client,
)
const isMutatingAll = useIsMutating(host, {}, client)
const isMutatingFiltered = useIsMutating(host, () => activeFilter, client)
host.connect()
host.update()
mutationA.mutate()
mutationB.mutate()
await waitFor(() => isMutatingAll() === 2)
await waitFor(() => isMutatingFiltered() === 1)
activeFilter = { mutationKey: ['mut-b'] }
host.update()
await waitFor(() => isMutatingFiltered() === 1)
resolveA?.()
await waitFor(() => isMutatingAll() === 1)
await waitFor(() => isMutatingFiltered() === 1)
resolveB?.()
await waitFor(() => isMutatingAll() === 0)
await waitFor(() => isMutatingFiltered() === 0)
})
it('S3: useMutationState selects and filters by mutation key/status', async () => {
const client = new QueryClient()
const host = new TestControllerHost()
let activeFilter: { mutationKey?: readonly string[] } = {
mutationKey: ['state-a'],
}
const mutationA = createMutationController(
host,
{
mutationKey: ['state-a'],
mutationFn: async () => 'ok',
},
client,
)
const mutationB = createMutationController(
host,
{
mutationKey: ['state-b'],
mutationFn: async () => {
throw new Error('state-b-failure')
},
},
client,
)
const mutationStatuses = useMutationState<string>(
host,
{
filters: () => activeFilter,
select: (item) => item.state.status,
},
client,
)
host.connect()
host.update()
await expect(mutationA.mutateAsync(undefined)).resolves.toBe('ok')
await expect(mutationB.mutateAsync(undefined)).rejects.toThrow(
'state-b-failure',
)
await waitFor(
() =>
mutationStatuses().length === 1 && mutationStatuses()[0] === 'success',
)
activeFilter = { mutationKey: ['state-b'] }
host.update()
await waitFor(
() =>
mutationStatuses().length === 1 && mutationStatuses()[0] === 'error',
)
})
it('S4: useMutationState refreshes when the select closure changes on host update', async () => {
const client = new QueryClient()
const host = new TestControllerHost()
let label = 'before'
const mutation = createMutationController(
host,
{
mutationKey: ['state-select-reactivity'],
mutationFn: async () => 'ok',
},
client,
)
const mutationLabels = useMutationState<string>(
host,
{
filters: {
mutationKey: ['state-select-reactivity'],
},
select: () => label,
},
client,
)
host.connect()
host.update()
await expect(mutation.mutateAsync(undefined)).resolves.toBe('ok')
await waitFor(
() => mutationLabels().length === 1 && mutationLabels()[0] === 'before',
)
label = 'after'
host.update()
await waitFor(
() => mutationLabels().length === 1 && mutationLabels()[0] === 'after',
)
mutation.destroy()
mutationLabels.destroy()
})
it('LC-COUNTERS-03: read-only helpers fail after handshake and recover under a provider', async () => {
const consumer = document.createElement(
contextCountersTagName,
) as ContextCountersHostElement
expect(consumer.query().status).toBe('pending')
expect(consumer.isFetching()).toBe(0)
expect(consumer.isMutating()).toBe(0)
expect(consumer.mutationStatuses()).toEqual([])
document.body.append(consumer)
await waitForMissingQueryClient(() => consumer.query())
expect(() => consumer.isFetching()).toThrow(/No QueryClient available/)
expect(() => consumer.isMutating()).toThrow(/No QueryClient available/)
expect(() => consumer.mutationStatuses()).toThrow(
/No QueryClient available/,
)
const client = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
mutations: {
retry: false,
},
},
})
const provider = document.createElement(
providerTagName,
) as QueryClientProvider
provider.client = client
provider.append(consumer)
document.body.append(provider)
await provider.updateComplete
await consumer.updateComplete
await waitFor(() => consumer.isFetching() === 1)
consumer.resolvePendingQuery()
await waitFor(() => consumer.query().isSuccess)
await waitFor(() => consumer.isFetching() === 0)
consumer.query.destroy()
consumer.mutation.destroy()
consumer.isFetching.destroy()
consumer.isMutating.destroy()
consumer.mutationStatuses.destroy()
provider.remove()
await Promise.resolve()
})
it('ALREADYCONN-COUNTERS-01: read-only helpers on already-connected host with explicit client do not throw', async () => {
const client = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
mutations: {
retry: false,
},
},
})
const producerHost = new TestControllerHost()
let resolveQuery: (() => void) | undefined
let resolveMutation: (() => void) | undefined
createQueryController(
producerHost,
{
queryKey: ['already-connected-counters-query'],
queryFn: () =>
new Promise<string>((resolve) => {
resolveQuery = () => resolve('query-ok')
}),
retry: false,
},
client,
)
const producerMutation = createMutationController(
producerHost,
{
mutationKey: ['already-connected-counters-mutation'],
mutationFn: () =>
new Promise<string>((resolve) => {
resolveMutation = () => resolve('mutation-ok')
}),
},
client,
)
producerHost.connect()
producerHost.update()
await waitFor(() => client.isFetching() === 1)
producerMutation.mutate()
await waitFor(() => client.isMutating() === 1)
class AlreadyConnectedHost implements ReactiveControllerHost {
private readonly controllers = new Set<ReactiveController>()
private isConnected = true
updatesRequested = 0
readonly updateComplete: Promise<boolean> = Promise.resolve(true)
addController(controller: ReactiveController): void {
this.controllers.add(controller)
if (this.isConnected) {
controller.hostConnected?.()
}
}
removeController(controller: ReactiveController): void {
this.controllers.delete(controller)
}
requestUpdate(): void {
this.updatesRequested += 1
}
}
const host = new AlreadyConnectedHost()
const isFetching = useIsFetching(host, {}, client)
const isMutating = useIsMutating(host, {}, client)
const mutationStatuses = useMutationState<string>(
host,
{
filters: { mutationKey: ['already-connected-counters-mutation'] },
select: (mutation) => mutation.state.status,
},
client,
)
await Promise.resolve()
await Promise.resolve()
await waitFor(() => isFetching() === 1)
await waitFor(() => isMutating() === 1)
await waitFor(() => mutationStatuses().includes('pending'))
resolveQuery?.()
resolveMutation?.()
await waitFor(() => isFetching() === 0)
await waitFor(() => isMutating() === 0)
await waitFor(() => mutationStatuses().includes('success'))
isFetching.destroy()
isMutating.destroy()
mutationStatuses.destroy()
producerMutation.destroy()
})
})

View File

@@ -0,0 +1,583 @@
import { describe, expect, it } from 'vitest'
import { QueryClient } from '@tanstack/query-core'
import type { ReactiveController, ReactiveControllerHost } from 'lit'
import { QueryClientProvider } from '../QueryClientProvider.js'
import { createInfiniteQueryController } from '../createInfiniteQueryController.js'
import { createMutationController } from '../createMutationController.js'
import { createQueryController } from '../createQueryController.js'
import { infiniteQueryOptions } from '../infiniteQueryOptions.js'
import { mutationOptions } from '../mutationOptions.js'
import { queryOptions } from '../queryOptions.js'
import {
TestControllerHost,
TestElementHost,
waitFor,
waitForMissingQueryClient,
} from './testHost.js'
const providerTagName = 'test-query-client-provider-infinite'
if (!customElements.get(providerTagName)) {
customElements.define(providerTagName, QueryClientProvider)
}
let explicitInfiniteClient: QueryClient | undefined
class ContextInfiniteHostElement extends TestElementHost {
readonly queryKey = ['context-infinite'] as const
readonly infinite = createInfiniteQueryController(
this,
{
queryKey: this.queryKey,
initialPageParam: 0,
queryFn: async ({ pageParam }) => Number(pageParam),
getNextPageParam: (lastPage) => (lastPage < 1 ? lastPage + 1 : undefined),
getPreviousPageParam: (firstPage) =>
firstPage > -1 ? firstPage - 1 : undefined,
retry: false,
},
explicitInfiniteClient,
)
}
const contextInfiniteTagName = 'test-context-infinite-host'
if (!customElements.get(contextInfiniteTagName)) {
customElements.define(contextInfiniteTagName, ContextInfiniteHostElement)
}
describe('createInfiniteQueryController', () => {
it('LC-INF-01: first provider connection resolves from the pre-connect placeholder state', async () => {
const consumer = document.createElement(
contextInfiniteTagName,
) as ContextInfiniteHostElement
expect(consumer.infinite().status).toBe('pending')
await expect(consumer.infinite.refetch()).rejects.toThrow(
/No QueryClient available/,
)
await expect(consumer.infinite.fetchNextPage()).rejects.toThrow(
/No QueryClient available/,
)
const client = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
const provider = document.createElement(
providerTagName,
) as QueryClientProvider
provider.client = client
provider.append(consumer)
document.body.append(provider)
await provider.updateComplete
await consumer.updateComplete
await waitFor(() => consumer.infinite().isSuccess)
expect(consumer.infinite().data?.pages).toEqual([0])
consumer.infinite.destroy()
provider.remove()
await Promise.resolve()
})
it('LC-INF-02: explicit client takes precedence over provider context', async () => {
const explicitClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
const providerClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
explicitInfiniteClient = explicitClient
const provider = document.createElement(
providerTagName,
) as QueryClientProvider
provider.client = providerClient
const consumer = document.createElement(
contextInfiniteTagName,
) as ContextInfiniteHostElement
provider.append(consumer)
document.body.append(provider)
await provider.updateComplete
await consumer.updateComplete
await waitFor(() => consumer.infinite().isSuccess)
expect(consumer.infinite().data?.pages).toEqual([0])
expect(
explicitClient.getQueryCache().find({ queryKey: consumer.queryKey })
?.state.data,
).toEqual({ pages: [0], pageParams: [0] })
expect(
providerClient.getQueryCache().find({ queryKey: consumer.queryKey }),
).toBeUndefined()
consumer.infinite.destroy()
provider.remove()
explicitInfiniteClient = undefined
await Promise.resolve()
})
it('M14: supports initial page, fetchNextPage, and fetchPreviousPage', async () => {
const client = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
const host = new TestControllerHost()
const infinite = createInfiniteQueryController(
host,
{
queryKey: ['m14', 'infinite'],
initialPageParam: 0,
queryFn: async ({ pageParam }) => Number(pageParam),
getNextPageParam: (lastPage) =>
lastPage < 1 ? lastPage + 1 : undefined,
getPreviousPageParam: (firstPage) =>
firstPage > -1 ? firstPage - 1 : undefined,
},
client,
)
host.connect()
host.update()
await waitFor(() => infinite().isSuccess)
expect(infinite().data?.pages).toEqual([0])
await infinite.fetchNextPage()
await waitFor(() => (infinite().data?.pages.length ?? 0) === 2)
expect(infinite().data?.pages).toEqual([0, 1])
await infinite.fetchPreviousPage()
await waitFor(() => (infinite().data?.pages.length ?? 0) === 3)
expect(infinite().data?.pages).toEqual([-1, 0, 1])
})
it('does not request another update when stable function options refresh during host update', async () => {
const client = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
const host = new TestControllerHost()
let callCount = 0
const infinite = createInfiniteQueryController(
host,
() => ({
queryKey: ['infinite-controller', 'stable-function-options'],
initialPageParam: 0,
queryFn: async ({ pageParam }) => {
callCount += 1
return Number(pageParam)
},
getNextPageParam: () => undefined,
staleTime: Infinity,
}),
client,
)
try {
host.connect()
host.update()
await waitFor(() => infinite().isSuccess)
host.updatesRequested = 0
for (let i = 0; i < 5; i += 1) {
host.update()
await Promise.resolve()
}
expect(host.updatesRequested).toBe(0)
expect(infinite().data?.pages).toEqual([0])
expect(callCount).toBe(1)
} finally {
infinite.destroy()
}
})
it('does not request an update for refetch-only state changes when only data was read', async () => {
const client = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
const host = new TestControllerHost()
let resolveRefetch: (() => void) | undefined
const infinite = createInfiniteQueryController(
host,
{
queryKey: ['infinite-controller', 'tracked-data-only'],
initialPageParam: 0,
initialData: {
pages: ['stable-page'],
pageParams: [0],
},
staleTime: Infinity,
queryFn: () =>
new Promise<string>((resolve) => {
resolveRefetch = () => resolve('stable-page')
}),
getNextPageParam: () => undefined,
},
client,
)
try {
host.connect()
host.update()
expect(infinite().data?.pages).toEqual(['stable-page'])
await Promise.resolve()
await Promise.resolve()
host.updatesRequested = 0
const refetch = infinite.refetch()
await waitFor(() => resolveRefetch !== undefined)
await Promise.resolve()
expect(host.updatesRequested).toBe(0)
resolveRefetch!()
await refetch
await Promise.resolve()
expect(host.updatesRequested).toBe(0)
} finally {
infinite.destroy()
}
})
it('refreshes a suppressed result on the next accessor read when a newly read property changed', async () => {
const client = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
const queryKey = ['infinite-controller', 'late-read-freshness'] as const
const host = new TestControllerHost()
const infinite = createInfiniteQueryController(
host,
{
queryKey,
initialPageParam: 0,
initialData: {
pages: ['initial-page'],
pageParams: [0],
},
staleTime: Infinity,
queryFn: async () => 'unused',
getNextPageParam: () => undefined,
},
client,
)
try {
host.connect()
host.update()
expect(infinite().status).toBe('success')
await Promise.resolve()
await Promise.resolve()
host.updatesRequested = 0
client.setQueryData(queryKey, {
pages: ['updated-page'],
pageParams: [0],
})
await Promise.resolve()
expect(infinite().data?.pages).toEqual(['updated-page'])
expect(host.updatesRequested).toBe(0)
} finally {
infinite.destroy()
}
})
it('INFEDGE-01: next-page failure preserves prior pages consistently', async () => {
const client = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
const host = new TestControllerHost()
const infinite = createInfiniteQueryController(
host,
{
queryKey: ['infedge-01'],
initialPageParam: 0,
queryFn: async ({ pageParam }) => {
const page = Number(pageParam)
if (page === 1) {
throw new Error('next-page-failed')
}
return page
},
getNextPageParam: (lastPage) =>
lastPage < 1 ? lastPage + 1 : undefined,
},
client,
)
host.connect()
host.update()
await waitFor(() => infinite().isSuccess)
expect(infinite().data?.pages).toEqual([0])
const nextPageResult = await infinite.fetchNextPage()
expect(nextPageResult.isFetchNextPageError).toBe(true)
expect(nextPageResult.error).toEqual(new Error('next-page-failed'))
await waitFor(() => infinite().isFetchNextPageError)
expect(infinite().data?.pages).toEqual([0])
})
it('LC-INF-03: missing provider fails deterministically and imperative methods align', async () => {
const consumer = document.createElement(
contextInfiniteTagName,
) as ContextInfiniteHostElement
const placeholderResult = consumer.infinite()
expect(placeholderResult.status).toBe('pending')
document.body.append(consumer)
expect(() => consumer.infinite()).not.toThrow()
await waitForMissingQueryClient(() => consumer.infinite())
await expect(consumer.infinite.refetch()).rejects.toThrow(
/No QueryClient available/,
)
await expect(consumer.infinite.fetchNextPage()).rejects.toThrow(
/No QueryClient available/,
)
await expect(placeholderResult.refetch()).rejects.toThrow(
/No QueryClient available/,
)
await expect(placeholderResult.fetchNextPage()).rejects.toThrow(
/No QueryClient available/,
)
await expect(placeholderResult.fetchPreviousPage()).rejects.toThrow(
/No QueryClient available/,
)
consumer.infinite.destroy()
consumer.remove()
await Promise.resolve()
})
it('ALREADYCONN-INF-01: infinite query controller on already-connected host with explicit client does not throw', async () => {
const client = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
client.setQueryData(['already-connected-infinite'], {
pages: [0],
pageParams: [0],
})
class AlreadyConnectedHost implements ReactiveControllerHost {
private readonly controllers = new Set<ReactiveController>()
private isConnected = true
updatesRequested = 0
readonly updateComplete: Promise<boolean> = Promise.resolve(true)
addController(controller: ReactiveController): void {
this.controllers.add(controller)
if (this.isConnected) {
controller.hostConnected?.()
}
}
removeController(controller: ReactiveController): void {
this.controllers.delete(controller)
}
requestUpdate(): void {
this.updatesRequested += 1
}
}
const host = new AlreadyConnectedHost()
const infinite = createInfiniteQueryController(
host,
{
queryKey: ['already-connected-infinite'],
initialPageParam: 0,
queryFn: async ({ pageParam }) => Number(pageParam),
getNextPageParam: (lastPage) =>
lastPage < 1 ? lastPage + 1 : undefined,
staleTime: 30_000,
},
client,
)
await Promise.resolve()
await Promise.resolve()
expect(infinite().isSuccess).toBe(true)
expect(infinite().data?.pages).toEqual([0])
infinite.destroy()
})
it('LC-INF-04: explicit-client infinite accessors defer until host fields are initialized', () => {
const client = new QueryClient()
class DeferredExplicitInfiniteHost implements ReactiveControllerHost {
private readonly controllers = new Set<ReactiveController>()
updatesRequested = 0
readonly updateComplete: Promise<boolean> = Promise.resolve(true)
readonly infinite = createInfiniteQueryController(
this,
() => ({
queryKey: ['deferred-explicit-infinite', this.id] as const,
initialPageParam: 0,
queryFn: async ({ pageParam }) => Number(pageParam),
getNextPageParam: (lastPage) =>
lastPage < 1 ? lastPage + 1 : undefined,
retry: false,
}),
client,
)
readonly firstRead = this.infinite()
readonly id = 'alpha'
addController(controller: ReactiveController): void {
this.controllers.add(controller)
}
removeController(controller: ReactiveController): void {
this.controllers.delete(controller)
}
requestUpdate(): void {
this.updatesRequested += 1
}
}
expect(() => new DeferredExplicitInfiniteHost()).not.toThrow()
const host = new DeferredExplicitInfiniteHost()
expect(host.infinite().status).toBe('pending')
host.infinite.destroy()
})
})
describe('options helpers integration', () => {
it('OPT-01: queryOptions integrates with createQueryController', async () => {
const client = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
const host = new TestControllerHost()
const query = createQueryController(
host,
queryOptions({
queryKey: ['opt-01', 'query'] as const,
queryFn: async () => 'query-ok',
}),
client,
)
host.connect()
host.update()
await waitFor(() => query().isSuccess)
expect(query().data).toBe('query-ok')
})
it('OPT-01: mutationOptions integrates with createMutationController', async () => {
const client = new QueryClient()
const host = new TestControllerHost()
const mutation = createMutationController(
host,
mutationOptions({
mutationFn: async (value: number) => value + 10,
}),
client,
)
host.connect()
host.update()
await expect(mutation.mutateAsync(5)).resolves.toBe(15)
expect(mutation().isSuccess).toBe(true)
})
it('OPT-01: infiniteQueryOptions integrates with createInfiniteQueryController', async () => {
const client = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
const host = new TestControllerHost()
const infinite = createInfiniteQueryController(
host,
infiniteQueryOptions({
queryKey: ['opt-01', 'infinite'],
initialPageParam: 0,
queryFn: async ({ pageParam }) => Number(pageParam),
getNextPageParam: (lastPage) =>
lastPage < 1 ? lastPage + 1 : undefined,
}),
client,
)
host.connect()
host.update()
await waitFor(() => infinite().isSuccess)
expect(infinite().data?.pages).toEqual([0])
})
})

View File

@@ -0,0 +1,473 @@
import { describe, expect, it } from 'vitest'
import { QueryClient } from '@tanstack/query-core'
import { sleep } from '@tanstack/query-test-utils'
import type { ReactiveController, ReactiveControllerHost } from 'lit'
import { QueryClientProvider } from '../QueryClientProvider.js'
import { createMutationController } from '../createMutationController.js'
import {
TestControllerHost,
TestElementHost,
waitFor,
waitForMissingQueryClient,
} from './testHost.js'
const providerTagName = 'test-query-client-provider-mutation'
if (!customElements.get(providerTagName)) {
customElements.define(providerTagName, QueryClientProvider)
}
let explicitMutationClient: QueryClient | undefined
class ContextMutationHostElement extends TestElementHost {
readonly mutationKey = ['context-mutation'] as const
readonly mutation = createMutationController(
this,
{
mutationKey: this.mutationKey,
mutationFn: async (value: number) => value + 1,
},
explicitMutationClient,
)
}
const contextMutationTagName = 'test-context-mutation-host'
if (!customElements.get(contextMutationTagName)) {
customElements.define(contextMutationTagName, ContextMutationHostElement)
}
describe('createMutationController', () => {
it('LC-MUT-01: first provider connection resolves from the pre-connect placeholder state', async () => {
const consumer = document.createElement(
contextMutationTagName,
) as ContextMutationHostElement
expect(consumer.mutation().isIdle).toBe(true)
expect(() => consumer.mutation.mutate(1)).toThrow(
/No QueryClient available/,
)
await expect(consumer.mutation.mutateAsync(1)).rejects.toThrow(
/No QueryClient available/,
)
const client = new QueryClient()
const provider = document.createElement(
providerTagName,
) as QueryClientProvider
provider.client = client
provider.append(consumer)
document.body.append(provider)
await provider.updateComplete
await Promise.resolve()
await expect(consumer.mutation.mutateAsync(1)).resolves.toBe(2)
expect(consumer.mutation().isSuccess).toBe(true)
consumer.mutation.destroy()
provider.remove()
await Promise.resolve()
})
it('LC-MUT-02: explicit client takes precedence over provider context', async () => {
const explicitClient = new QueryClient()
const providerClient = new QueryClient()
explicitMutationClient = explicitClient
const provider = document.createElement(
providerTagName,
) as QueryClientProvider
provider.client = providerClient
const consumer = document.createElement(
contextMutationTagName,
) as ContextMutationHostElement
provider.append(consumer)
document.body.append(provider)
await provider.updateComplete
await Promise.resolve()
await expect(consumer.mutation.mutateAsync(2)).resolves.toBe(3)
expect(
explicitClient
.getMutationCache()
.findAll({ mutationKey: consumer.mutationKey }).length,
).toBeGreaterThan(0)
expect(
providerClient
.getMutationCache()
.findAll({ mutationKey: consumer.mutationKey }).length,
).toBe(0)
consumer.mutation.destroy()
provider.remove()
explicitMutationClient = undefined
await Promise.resolve()
})
it('supports mutate and mutateAsync paths', async () => {
const client = new QueryClient()
const host = new TestControllerHost()
const mutation = createMutationController(
host,
{
mutationFn: async (value: number) => value + 1,
},
client,
)
host.connect()
host.update()
const result = await mutation.mutateAsync(1)
expect(result).toBe(2)
expect(mutation().isSuccess).toBe(true)
expect(mutation().data).toBe(2)
mutation.mutate(2)
await waitFor(() => mutation().data === 3)
expect(mutation().isSuccess).toBe(true)
})
it('M9: mutation state transitions cover idle/pending/success/error', async () => {
const client = new QueryClient()
const host = new TestControllerHost()
const mutation = createMutationController(
host,
{
mutationFn: async (value: number) => {
await sleep(10)
if (value < 0) {
throw new Error('negative-not-allowed')
}
return value + 1
},
},
client,
)
host.connect()
host.update()
expect(mutation().isIdle).toBe(true)
const successPromise = mutation.mutateAsync(10)
await waitFor(() => mutation().isPending)
await expect(successPromise).resolves.toBe(11)
await waitFor(() => mutation().isSuccess)
expect(mutation().data).toBe(11)
const errorPromise = mutation.mutateAsync(-1)
await waitFor(() => mutation().isPending)
await expect(errorPromise).rejects.toThrow('negative-not-allowed')
await waitFor(() => mutation().isError)
expect(mutation().error).toEqual(new Error('negative-not-allowed'))
})
it('M10: reset clears mutation state back to idle baseline', async () => {
const client = new QueryClient()
const host = new TestControllerHost()
const mutation = createMutationController(
host,
{
mutationFn: async () => {
throw new Error('reset-target')
},
},
client,
)
host.connect()
host.update()
await expect(mutation.mutateAsync(undefined)).rejects.toThrow(
'reset-target',
)
await waitFor(() => mutation().isError)
expect(mutation().error).toEqual(new Error('reset-target'))
mutation.reset()
expect(mutation().isIdle).toBe(true)
expect(mutation().isPaused).toBe(false)
expect(mutation().isError).toBe(false)
expect(mutation().error).toBeNull()
expect(mutation().data).toBeUndefined()
})
it('M11: mutate is non-throwing while mutateAsync rejects on error', async () => {
const client = new QueryClient()
const host = new TestControllerHost()
const mutation = createMutationController(
host,
{
mutationFn: async (value: number) => {
if (value < 0) {
throw new Error('negative-not-allowed')
}
return value + 1
},
},
client,
)
host.connect()
host.update()
expect(() => mutation.mutate(-1)).not.toThrow()
await waitFor(() => mutation().isError)
expect(mutation().error).toEqual(new Error('negative-not-allowed'))
await expect(mutation.mutateAsync(-1)).rejects.toThrow(
'negative-not-allowed',
)
})
it('M12: mutation callback order and call counts are deterministic', async () => {
const client = new QueryClient()
const host = new TestControllerHost()
const callbackEvents: string[] = []
const mutation = createMutationController(
host,
{
mutationFn: async (value: number) => {
await sleep(5)
if (value < 0) {
throw new Error('callback-order-failure')
}
return value + 1
},
onSuccess: (_data, value) => {
callbackEvents.push(`success:${value}`)
},
onError: (_error, value) => {
callbackEvents.push(`error:${value}`)
},
onSettled: (_data, _error, value) => {
callbackEvents.push(`settled:${value}`)
},
},
client,
)
host.connect()
host.update()
await expect(mutation.mutateAsync(1)).resolves.toBe(2)
await expect(mutation.mutateAsync(-1)).rejects.toThrow(
'callback-order-failure',
)
expect(callbackEvents).toEqual([
'success:1',
'settled:1',
'error:-1',
'settled:-1',
])
})
it('AREACT-02: refreshed mutation callbacks use latest closures', async () => {
const client = new QueryClient()
const host = new TestControllerHost()
const callbackEvents: string[] = []
let version = 'v1'
const mutation = createMutationController(
host,
() => ({
mutationFn: async (value: number) => {
if (value < 0) {
throw new Error('freshness-failure')
}
return value + 1
},
onSuccess: () => {
callbackEvents.push(`success:${version}`)
},
onError: () => {
callbackEvents.push(`error:${version}`)
},
onSettled: () => {
callbackEvents.push(`settled:${version}`)
},
}),
client,
)
host.connect()
host.update()
await expect(mutation.mutateAsync(1)).resolves.toBe(2)
expect(callbackEvents.slice(0, 2)).toEqual(['success:v1', 'settled:v1'])
version = 'v2'
host.update()
await expect(mutation.mutateAsync(-1)).rejects.toThrow('freshness-failure')
expect(callbackEvents.slice(2)).toEqual(['error:v2', 'settled:v2'])
})
it('LC-MUT-03: missing provider becomes a deterministic missing-client state', async () => {
const consumer = document.createElement(
contextMutationTagName,
) as ContextMutationHostElement
const placeholderResult = consumer.mutation()
expect(placeholderResult.isIdle).toBe(true)
expect(placeholderResult.isPaused).toBe(false)
document.body.append(consumer)
expect(() => consumer.mutation()).not.toThrow()
await waitForMissingQueryClient(() => consumer.mutation())
expect(() => consumer.mutation.mutate(1)).toThrow(
/No QueryClient available/,
)
await expect(consumer.mutation.mutateAsync(1)).rejects.toThrow(
/No QueryClient available/,
)
await expect(placeholderResult.mutate(1)).rejects.toThrow(
/No QueryClient available/,
)
expect(() => consumer.mutation.reset()).not.toThrow()
expect(() => placeholderResult.reset()).not.toThrow()
consumer.mutation.destroy()
consumer.remove()
await Promise.resolve()
})
it('LC-MUT-04: later valid provider adoption recovers without reconstruction', async () => {
const consumer = document.createElement(
contextMutationTagName,
) as ContextMutationHostElement
document.body.append(consumer)
await waitForMissingQueryClient(() => consumer.mutation())
const client = new QueryClient()
const provider = document.createElement(
providerTagName,
) as QueryClientProvider
provider.client = client
provider.append(consumer)
document.body.append(provider)
await provider.updateComplete
await Promise.resolve()
await expect(consumer.mutation.mutateAsync(1)).resolves.toBe(2)
expect(consumer.mutation().isSuccess).toBe(true)
consumer.mutation.destroy()
provider.remove()
await Promise.resolve()
})
it('ALREADYCONN-MUT-01: mutation controller on already-connected host with explicit client does not throw', async () => {
// Regression test for SSR hydration scenario where controller is created
// during willUpdate on an already-connected host.
const client = new QueryClient()
// Create a host that simulates Lit's behavior: addController calls
// hostConnected immediately if the host is already connected
class AlreadyConnectedHost {
private readonly controllers = new Set<{
hostConnected?: () => void
}>()
private isConnected = true
updatesRequested = 0
readonly updateComplete: Promise<boolean> = Promise.resolve(true)
addController(controller: { hostConnected?: () => void }): void {
this.controllers.add(controller)
if (this.isConnected) {
controller.hostConnected?.()
}
}
removeController(controller: { hostConnected?: () => void }): void {
this.controllers.delete(controller)
}
requestUpdate(): void {
this.updatesRequested += 1
}
}
const host = new AlreadyConnectedHost()
// This should NOT throw even though hostConnected runs during construction
const mutation = createMutationController(
host,
{
mutationKey: ['already-connected-mutation-test'],
mutationFn: async (value: number) => value * 2,
},
client,
)
// Wait for the deferred onConnected to complete
await Promise.resolve()
await Promise.resolve()
// Mutation controller should work correctly
expect(mutation().isIdle).toBe(true)
await expect(mutation.mutateAsync(5)).resolves.toBe(10)
expect(mutation().isSuccess).toBe(true)
mutation.destroy()
})
it('LC-MUT-05: explicit-client mutation accessors defer until host fields are initialized', () => {
const client = new QueryClient()
class DeferredExplicitMutationHost implements ReactiveControllerHost {
private readonly controllers = new Set<ReactiveController>()
updatesRequested = 0
readonly updateComplete: Promise<boolean> = Promise.resolve(true)
readonly mutation = createMutationController(
this,
() => ({
mutationKey: ['deferred-explicit-mutation', this.id] as const,
mutationFn: async (value: number) => value + this.offset,
}),
client,
)
readonly firstRead = this.mutation()
readonly id = 'alpha'
readonly offset = 1
addController(controller: ReactiveController): void {
this.controllers.add(controller)
}
removeController(controller: ReactiveController): void {
this.controllers.delete(controller)
}
requestUpdate(): void {
this.updatesRequested += 1
}
}
expect(() => new DeferredExplicitMutationHost()).not.toThrow()
const host = new DeferredExplicitMutationHost()
expect(host.mutation().isIdle).toBe(true)
host.mutation.destroy()
})
})

View File

@@ -0,0 +1,936 @@
import { describe, expect, it } from 'vitest'
import { QueryClient } from '@tanstack/query-core'
import type { ReactiveController, ReactiveControllerHost } from 'lit'
import { QueryClientProvider } from '../QueryClientProvider.js'
import { createQueriesController } from '../createQueriesController.js'
import { queryOptions } from '../queryOptions.js'
import {
TestControllerHost,
TestElementHost,
waitFor,
waitForMissingQueryClient,
} from './testHost.js'
const providerTagName = 'test-query-client-provider-queries'
if (!customElements.get(providerTagName)) {
customElements.define(providerTagName, QueryClientProvider)
}
let explicitQueriesClient: QueryClient | undefined
class ContextQueriesHostElement extends TestElementHost {
readonly queryKeys = [
['context-queries', 'alpha'] as const,
['context-queries', 'beta'] as const,
]
readonly queries = createQueriesController(
this,
{
queries: this.queryKeys.map((queryKey) => ({
queryKey,
queryFn: async () => queryKey[1],
retry: false,
})),
combine: (results) =>
results.map((result) => ({
status: result.status,
data: result.data,
})),
},
explicitQueriesClient,
)
}
const contextQueriesTagName = 'test-context-queries-host'
if (!customElements.get(contextQueriesTagName)) {
customElements.define(contextQueriesTagName, ContextQueriesHostElement)
}
class RawContextQueriesHostElement extends TestElementHost {
readonly queryKeys = [
['raw-context-queries', 'alpha'] as const,
['raw-context-queries', 'beta'] as const,
]
readonly queries = createQueriesController(this, {
queries: this.queryKeys.map((queryKey) => ({
queryKey,
queryFn: async () => queryKey[1],
retry: false,
})),
})
}
const rawContextQueriesTagName = 'test-raw-context-queries-host'
if (!customElements.get(rawContextQueriesTagName)) {
customElements.define(rawContextQueriesTagName, RawContextQueriesHostElement)
}
class DeferredFieldsQueriesHost implements ReactiveControllerHost {
private readonly controllers = new Set<ReactiveController>()
updatesRequested = 0
readonly updateComplete: Promise<boolean> = Promise.resolve(true)
readonly queries = createQueriesController(this, () => ({
queries: this.ids.map((id) => ({
queryKey: ['deferred-fields-queries', id] as const,
queryFn: async () => id,
retry: false,
})),
combine: (results) => results.map((result) => result.status),
}))
readonly firstRead = this.queries()
readonly ids = ['alpha', 'beta'] as const
addController(controller: ReactiveController): void {
this.controllers.add(controller)
}
removeController(controller: ReactiveController): void {
this.controllers.delete(controller)
}
requestUpdate(): void {
this.updatesRequested += 1
}
}
describe('createQueriesController', () => {
it('LC-QUERIES-01: first provider connection resolves from the pre-connect placeholder state', async () => {
const consumer = document.createElement(
contextQueriesTagName,
) as ContextQueriesHostElement
expect(consumer.queries()).toEqual([
{ status: 'pending', data: undefined },
{ status: 'pending', data: undefined },
])
const client = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
const provider = document.createElement(
providerTagName,
) as QueryClientProvider
provider.client = client
provider.append(consumer)
document.body.append(provider)
await provider.updateComplete
await consumer.updateComplete
await waitFor(
() =>
consumer.queries()[0]?.status === 'success' &&
consumer.queries()[1]?.status === 'success',
)
expect(consumer.queries().map((item) => item.data)).toEqual([
'alpha',
'beta',
])
consumer.queries.destroy()
provider.remove()
await Promise.resolve()
})
it('LC-QUERIES-02: explicit client takes precedence over provider context', async () => {
const explicitClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
const providerClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
explicitQueriesClient = explicitClient
const provider = document.createElement(
providerTagName,
) as QueryClientProvider
provider.client = providerClient
const consumer = document.createElement(
contextQueriesTagName,
) as ContextQueriesHostElement
provider.append(consumer)
document.body.append(provider)
await provider.updateComplete
await consumer.updateComplete
await waitFor(
() =>
consumer.queries()[0]?.status === 'success' &&
consumer.queries()[1]?.status === 'success',
)
expect(
explicitClient.getQueryCache().find({ queryKey: consumer.queryKeys[0]! })
?.state.data,
).toBe('alpha')
expect(
providerClient.getQueryCache().find({ queryKey: consumer.queryKeys[0]! }),
).toBeUndefined()
consumer.queries.destroy()
provider.remove()
explicitQueriesClient = undefined
await Promise.resolve()
})
it('combines multiple query results', async () => {
const client = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
const host = new TestControllerHost()
const queries = createQueriesController(
host,
{
queries: [
{
queryKey: ['q1'],
queryFn: async () => 'alpha',
},
{
queryKey: ['q2'],
queryFn: async () => 'beta',
},
] as const,
combine: (results) => results.map((result) => result.data),
},
client,
)
host.connect()
host.update()
await waitFor(() => queries().every((value) => typeof value === 'string'))
expect(queries()).toEqual(['alpha', 'beta'])
})
it('does not request another update when stable function query options refresh during host update', async () => {
const client = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
const host = new TestControllerHost()
let callCount = 0
const queries = createQueriesController(
host,
() => ({
queries: [
{
queryKey: [
'queries-controller',
'stable-function-options',
] as const,
queryFn: async () => {
callCount += 1
return 'stable-result'
},
staleTime: Infinity,
},
] as const,
}),
client,
)
try {
host.connect()
host.update()
await waitFor(() => queries()[0]?.isSuccess === true)
host.updatesRequested = 0
for (let i = 0; i < 5; i += 1) {
host.update()
await Promise.resolve()
}
expect(host.updatesRequested).toBe(0)
expect(queries()[0]?.data).toBe('stable-result')
expect(callCount).toBe(1)
} finally {
queries.destroy()
}
})
it('does not request an update for refetch-only state changes when data was read and result refetch is invoked', async () => {
const client = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
const queryKey = ['queries-controller', 'tracked-data-only'] as const
const host = new TestControllerHost()
let resolveRefetch: (() => void) | undefined
const queries = createQueriesController(
host,
{
queries: [
{
queryKey,
initialData: 'stable-data',
staleTime: Infinity,
queryFn: () =>
new Promise<string>((resolve) => {
resolveRefetch = () => resolve('stable-data')
}),
},
] as const,
},
client,
)
try {
host.connect()
host.update()
expect(queries()[0]?.data).toBe('stable-data')
await Promise.resolve()
await Promise.resolve()
host.updatesRequested = 0
const refetch = queries()[0]!.refetch()
await waitFor(() => resolveRefetch !== undefined)
await Promise.resolve()
expect(host.updatesRequested).toBe(0)
resolveRefetch!()
await refetch
await Promise.resolve()
expect(host.updatesRequested).toBe(0)
} finally {
queries.destroy()
}
})
it('does not re-default query options when subscribed queries emit', async () => {
const client = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
const originalDefaultQueryOptions = client.defaultQueryOptions
let defaultQueryOptionsCalls = 0
client.defaultQueryOptions = ((options) => {
defaultQueryOptionsCalls += 1
return originalDefaultQueryOptions.call(client, options as never)
}) as typeof client.defaultQueryOptions
const queryKey = ['queries-controller', 'per-emit-defaulting'] as const
const host = new TestControllerHost()
let resolveRefetch: (() => void) | undefined
const queries = createQueriesController(
host,
{
queries: [
{
queryKey,
initialData: 'stable-data',
staleTime: Infinity,
queryFn: () =>
new Promise<string>((resolve) => {
resolveRefetch = () => resolve('stable-data')
}),
},
] as const,
},
client,
)
try {
host.connect()
host.update()
expect(queries()[0]?.isFetching).toBe(false)
await Promise.resolve()
await Promise.resolve()
defaultQueryOptionsCalls = 0
const refetch = queries()[0]!.refetch()
await waitFor(() => resolveRefetch !== undefined)
await Promise.resolve()
expect(queries()[0]?.isFetching).toBe(true)
expect(defaultQueryOptionsCalls).toBe(0)
resolveRefetch!()
await refetch
await Promise.resolve()
expect(queries()[0]?.isFetching).toBe(false)
expect(defaultQueryOptionsCalls).toBe(0)
} finally {
queries.destroy()
}
})
it('refreshes suppressed query results on the next accessor read when a newly read property changed', async () => {
const client = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
const queryKey = ['queries-controller', 'late-read-freshness'] as const
const host = new TestControllerHost()
const queries = createQueriesController(
host,
{
queries: [
{
queryKey,
initialData: 'initial-data',
staleTime: Infinity,
queryFn: async () => 'unused',
},
] as const,
},
client,
)
try {
host.connect()
host.update()
expect(queries()[0]?.status).toBe('success')
await Promise.resolve()
await Promise.resolve()
host.updatesRequested = 0
client.setQueryData(queryKey, 'updated-data')
await Promise.resolve()
expect(host.updatesRequested).toBe(0)
expect(queries()[0]?.data).toBe('updated-data')
expect(host.updatesRequested).toBe(0)
} finally {
queries.destroy()
}
})
it('M13: supports dynamic add/remove and keeps partial failure stability', async () => {
const client = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
const host = new TestControllerHost()
let includeThird = false
let includeFailing = true
const queries = createQueriesController(
host,
() => ({
queries: [
{
queryKey: ['m13', 'alpha'] as const,
queryFn: async () => 'alpha',
},
...(includeFailing
? [
{
queryKey: ['m13', 'failing'] as const,
queryFn: async () => {
throw new Error('m13-fail')
},
},
]
: []),
...(includeThird
? [
{
queryKey: ['m13', 'gamma'] as const,
queryFn: async () => 'gamma',
},
]
: []),
] as const,
combine: (results) =>
results.map((result) => ({
status: result.status,
data: result.data,
error: result.error instanceof Error ? result.error.message : null,
})),
}),
client,
)
host.connect()
host.update()
await waitFor(() => queries().length === 2)
await waitFor(
() =>
queries()[0]?.status === 'success' && queries()[1]?.status === 'error',
)
expect(queries()[0]).toMatchObject({ status: 'success', data: 'alpha' })
expect(queries()[1]).toMatchObject({ status: 'error', error: 'm13-fail' })
includeThird = true
host.update()
await waitFor(() => queries().length === 3)
await waitFor(
() =>
queries()[0]?.status === 'success' &&
queries()[1]?.status === 'error' &&
queries()[2]?.status === 'success',
)
expect(queries()[2]).toMatchObject({ status: 'success', data: 'gamma' })
includeFailing = false
host.update()
await waitFor(() => queries().length === 2)
await waitFor(
() =>
queries()[0]?.status === 'success' &&
queries()[1]?.status === 'success',
)
expect(queries().map((item) => item.data)).toEqual(['alpha', 'gamma'])
})
it('CQS-ADV-01: reordering queries preserves documented result order mapping', async () => {
const client = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
const host = new TestControllerHost()
let order: Array<'first' | 'second'> = ['first', 'second']
const queries = createQueriesController(
host,
() => ({
queries: order.map((id) => ({
queryKey: ['cqs-adv-01', id] as const,
queryFn: async () => id,
})),
combine: (results) => results.map((result) => result.data),
}),
client,
)
host.connect()
host.update()
await waitFor(() => queries().every((value) => typeof value === 'string'))
expect(queries()).toEqual(['first', 'second'])
order = ['second', 'first']
host.update()
await waitFor(() => queries()[0] === 'second' && queries()[1] === 'first')
expect(queries()).toEqual(['second', 'first'])
})
it('CQS-ADV-02: duplicate query keys return stable per-index results by contract', async () => {
const client = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
const host = new TestControllerHost()
let callCount = 0
const queries = createQueriesController(
host,
{
queries: [
{
queryKey: ['dup-key'] as const,
queryFn: async () => {
callCount += 1
return 'shared-value'
},
},
{
queryKey: ['dup-key'] as const,
queryFn: async () => {
callCount += 1
return 'shared-value'
},
},
] as const,
combine: (results) =>
results.map((result) => ({
status: result.status,
data: result.data,
})),
},
client,
)
host.connect()
host.update()
await waitFor(
() =>
queries().length === 2 &&
queries()[0]?.status === 'success' &&
queries()[1]?.status === 'success',
)
expect(queries()[0]?.data).toBe('shared-value')
expect(queries()[1]?.data).toBe('shared-value')
expect(callCount).toBeGreaterThan(0)
})
it('LC-QUERIES-03: missing provider fails after handshake and later provider adoption recovers', async () => {
const consumer = document.createElement(
contextQueriesTagName,
) as ContextQueriesHostElement
expect(consumer.queries()).toEqual([
{ status: 'pending', data: undefined },
{ status: 'pending', data: undefined },
])
document.body.append(consumer)
expect(() => consumer.queries()).not.toThrow()
await waitForMissingQueryClient(() => consumer.queries())
const client = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
const provider = document.createElement(
providerTagName,
) as QueryClientProvider
provider.client = client
provider.append(consumer)
document.body.append(provider)
await provider.updateComplete
await consumer.updateComplete
await waitFor(
() =>
consumer.queries()[0]?.status === 'success' &&
consumer.queries()[1]?.status === 'success',
)
expect(consumer.queries().map((item) => item.data)).toEqual([
'alpha',
'beta',
])
consumer.queries.destroy()
provider.remove()
await Promise.resolve()
})
it('LC-QUERIES-04: raw query results reject placeholder refetch after missing-client handshake', async () => {
const consumer = document.createElement(
rawContextQueriesTagName,
) as RawContextQueriesHostElement
const firstQuery = consumer.queries()[0]
expect(firstQuery?.status).toBe('pending')
document.body.append(consumer)
await waitForMissingQueryClient(() => consumer.queries())
await expect(firstQuery?.refetch()).rejects.toThrow(
'No QueryClient available. Pass one explicitly or render within QueryClientProvider.',
)
consumer.queries.destroy()
consumer.remove()
await Promise.resolve()
})
it('LC-QUERIES-05: constructor defers placeholder accessors until host fields are initialized', () => {
expect(() => new DeferredFieldsQueriesHost()).not.toThrow()
const host = new DeferredFieldsQueriesHost()
expect(host.queries()).toEqual(['pending', 'pending'])
})
it('LC-QUERIES-06: placeholder combine materializes defined initialData before a client is available', () => {
const host = new TestControllerHost()
const queries = createQueriesController(host, {
queries: [
queryOptions({
queryKey: ['placeholder-initial-data'] as const,
queryFn: async () => ({ id: 4, name: 'Marie' }),
initialData: { id: 0, name: 'Seed' },
}),
] as const,
combine: (result) => result[0].data.name,
})
expect(queries()).toBe('Seed')
queries.destroy()
})
it('LC-QUERIES-07: explicit-client constructor defers dynamic accessors until host fields are initialized', () => {
const client = new QueryClient()
class DeferredExplicitQueriesHost implements ReactiveControllerHost {
private readonly controllers = new Set<ReactiveController>()
updatesRequested = 0
readonly updateComplete: Promise<boolean> = Promise.resolve(true)
readonly queries = createQueriesController(
this,
() => ({
queries: this.ids.map((id) => ({
queryKey: ['deferred-explicit-queries', id] as const,
queryFn: async () => id,
retry: false,
})),
combine: (results) => results.map((result) => result.status),
}),
client,
)
readonly firstRead = this.queries()
readonly ids = ['alpha', 'beta'] as const
addController(controller: ReactiveController): void {
this.controllers.add(controller)
}
removeController(controller: ReactiveController): void {
this.controllers.delete(controller)
}
requestUpdate(): void {
this.updatesRequested += 1
}
}
expect(() => new DeferredExplicitQueriesHost()).not.toThrow()
const host = new DeferredExplicitQueriesHost()
expect(host.queries()).toEqual(['pending', 'pending'])
host.queries.destroy()
})
it('LC-QUERIES-08: explicit-client constructor defers static combine callbacks until host fields are initialized', () => {
const client = new QueryClient()
class DeferredExplicitCombineQueriesHost implements ReactiveControllerHost {
private readonly controllers = new Set<ReactiveController>()
updatesRequested = 0
readonly updateComplete: Promise<boolean> = Promise.resolve(true)
readonly queries = createQueriesController(
this,
{
queries: [
{
queryKey: ['deferred-explicit-combine-queries', 'alpha'] as const,
queryFn: async () => 'alpha',
retry: false,
},
] as const,
combine: (results) =>
this.ids.map((id, index) => `${id}:${results[index]?.status}`),
},
client,
)
readonly firstRead = this.queries()
readonly ids = ['alpha'] as const
addController(controller: ReactiveController): void {
this.controllers.add(controller)
}
removeController(controller: ReactiveController): void {
this.controllers.delete(controller)
}
requestUpdate(): void {
this.updatesRequested += 1
}
}
expect(() => new DeferredExplicitCombineQueriesHost()).not.toThrow()
const host = new DeferredExplicitCombineQueriesHost()
expect(host.queries()).toEqual(['alpha:pending'])
host.queries.destroy()
})
it('LC-QUERIES-09: explicit-client constructor re-surfaces permanent static combine errors after initialization', async () => {
const client = new QueryClient()
class InvalidExplicitCombineQueriesHost implements ReactiveControllerHost {
private readonly controllers = new Set<ReactiveController>()
updatesRequested = 0
readonly updateComplete: Promise<boolean> = Promise.resolve(true)
readonly queries = createQueriesController(
this,
{
queries: [
{
queryKey: ['invalid-explicit-combine-queries', 'alpha'] as const,
queryFn: async () => 'alpha',
retry: false,
},
] as const,
combine: () => {
throw new Error('invalid combine')
},
},
client,
)
addController(controller: ReactiveController): void {
this.controllers.add(controller)
}
removeController(controller: ReactiveController): void {
this.controllers.delete(controller)
}
requestUpdate(): void {
this.updatesRequested += 1
}
}
expect(() => new InvalidExplicitCombineQueriesHost()).not.toThrow()
const host = new InvalidExplicitCombineQueriesHost()
await Promise.resolve()
expect(() => host.queries()).toThrow('invalid combine')
})
it('ALREADYCONN-QUERIES-01: queries controller on already-connected host with explicit client does not throw', async () => {
const client = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
client.setQueryData(['already-connected-queries', 'alpha'], 'alpha')
client.setQueryData(['already-connected-queries', 'beta'], 'beta')
class AlreadyConnectedHost implements ReactiveControllerHost {
private readonly controllers = new Set<ReactiveController>()
private isConnected = true
updatesRequested = 0
readonly updateComplete: Promise<boolean> = Promise.resolve(true)
addController(controller: ReactiveController): void {
this.controllers.add(controller)
if (this.isConnected) {
controller.hostConnected?.()
}
}
removeController(controller: ReactiveController): void {
this.controllers.delete(controller)
}
requestUpdate(): void {
this.updatesRequested += 1
}
}
const host = new AlreadyConnectedHost()
const queries = createQueriesController(
host,
{
queries: [
{
queryKey: ['already-connected-queries', 'alpha'] as const,
queryFn: async () => 'fetched-alpha',
staleTime: 30_000,
},
{
queryKey: ['already-connected-queries', 'beta'] as const,
queryFn: async () => 'fetched-beta',
staleTime: 30_000,
},
] as const,
combine: (results) =>
results.map((result) => ({
status: result.status,
data: result.data,
})),
},
client,
)
await Promise.resolve()
await Promise.resolve()
expect(queries()).toEqual([
{ status: 'success', data: 'alpha' },
{ status: 'success', data: 'beta' },
])
queries.destroy()
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,118 @@
import { sleep } from '@tanstack/query-test-utils'
import type { ReactiveController, ReactiveControllerHost } from 'lit'
export class TestControllerHost implements ReactiveControllerHost {
private readonly controllers = new Set<ReactiveController>()
updatesRequested = 0
readonly updateComplete: Promise<boolean> = Promise.resolve(true)
addController(controller: ReactiveController): void {
this.controllers.add(controller)
}
removeController(controller: ReactiveController): void {
this.controllers.delete(controller)
}
requestUpdate(): void {
this.updatesRequested += 1
}
connect(): void {
for (const controller of this.controllers) {
controller.hostConnected?.()
}
}
disconnect(): void {
for (const controller of this.controllers) {
controller.hostDisconnected?.()
}
}
update(): void {
for (const controller of this.controllers) {
controller.hostUpdate?.()
}
for (const controller of this.controllers) {
controller.hostUpdated?.()
}
}
}
export class TestElementHost
extends HTMLElement
implements ReactiveControllerHost
{
protected readonly controllers = new Set<ReactiveController>()
updatesRequested = 0
readonly updateComplete: Promise<boolean> = Promise.resolve(true)
addController(controller: ReactiveController): void {
this.controllers.add(controller)
}
removeController(controller: ReactiveController): void {
this.controllers.delete(controller)
}
requestUpdate(): void {
this.updatesRequested += 1
}
connectedCallback(): void {
for (const controller of this.controllers) {
controller.hostConnected?.()
}
}
disconnectedCallback(): void {
for (const controller of this.controllers) {
controller.hostDisconnected?.()
}
}
flushHostUpdate(): void {
for (const controller of this.controllers) {
controller.hostUpdate?.()
}
for (const controller of this.controllers) {
controller.hostUpdated?.()
}
}
}
export async function waitFor(
assertion: () => boolean,
timeoutMs = 2000,
): Promise<void> {
const startedAt = Date.now()
while (!assertion()) {
if (Date.now() - startedAt > timeoutMs) {
throw new Error(`Timed out waiting for assertion after ${timeoutMs}ms`)
}
await sleep(10)
}
}
function isMissingQueryClientError(error: unknown): boolean {
return (
error instanceof Error && /No QueryClient available/.test(error.message)
)
}
export async function waitForMissingQueryClient(
read: () => unknown,
timeoutMs = 2000,
): Promise<void> {
await waitFor(() => {
try {
read()
return false
} catch (error) {
return isMissingQueryClientError(error)
}
}, timeoutMs)
}

View File

@@ -0,0 +1,329 @@
import {
dataTagErrorSymbol,
dataTagSymbol,
QueryClient,
type DefinedQueryObserverResult,
type InfiniteData,
type QueryObserverResult,
} from '@tanstack/query-core'
import { describe, expectTypeOf, it } from 'vitest'
import { createMutationController } from '../createMutationController.js'
import { createQueriesController } from '../createQueriesController.js'
import { createInfiniteQueryController } from '../createInfiniteQueryController.js'
import { createQueryController } from '../createQueryController.js'
import { infiniteQueryOptions } from '../infiniteQueryOptions.js'
import { mutationOptions } from '../mutationOptions.js'
import { queryOptions } from '../queryOptions.js'
import { TestControllerHost } from './testHost.js'
describe('type inference', () => {
it('L1: createQueriesController preserves tuple/combine inference', () => {
const client = new QueryClient()
const host = new TestControllerHost()
const expectTupleResult = (
value: [QueryObserverResult<number>, QueryObserverResult<string>],
) => value
const expectDefinedInitialDataTuple = (
value: [DefinedQueryObserverResult<{ id: number; name: string }>],
) => value
const expectMappedQueriesResult = (
value: [
...Array<QueryObserverResult<number>>,
QueryObserverResult<boolean>,
],
) => value
const tupleResult = createQueriesController(
host,
{
queries: [
{
queryKey: ['type-inference', 'tuple-number'] as const,
queryFn: async () => 1,
},
{
queryKey: ['type-inference', 'tuple-string'] as const,
queryFn: async () => 'x',
},
] as const,
},
client,
)
const tupleData = expectTupleResult(tupleResult())
expectTypeOf(tupleData[0].data).toEqualTypeOf<number | undefined>()
expectTypeOf(tupleData[1].data).toEqualTypeOf<string | undefined>()
const combinedResult = createQueriesController(
host,
{
queries: [
{
queryKey: ['type-inference', 'combined-number'] as const,
queryFn: async () => 7,
},
{
queryKey: ['type-inference', 'combined-string'] as const,
queryFn: async () => 'ok',
},
] as const,
combine: (result) => ({
first: result[0].data,
second: result[1].data,
}),
},
client,
)
expectTypeOf(combinedResult().first).toEqualTypeOf<number | undefined>()
expectTypeOf(combinedResult().second).toEqualTypeOf<string | undefined>()
const definedInitialDataResult = createQueriesController(
host,
{
queries: [
queryOptions({
queryKey: ['type-inference', 'defined-initial-data'] as const,
queryFn: async () => ({ id: 4, name: 'Marie' }),
initialData: { id: 0, name: 'Seed' },
}),
] as const,
},
client,
)
const definedInitialDataTuple = expectDefinedInitialDataTuple(
definedInitialDataResult(),
)
expectTypeOf(definedInitialDataTuple[0].data).toEqualTypeOf<{
id: number
name: string
}>()
const definedInitialDataCombined = createQueriesController(
host,
{
queries: [
queryOptions({
queryKey: [
'type-inference',
'defined-initial-data-combine',
] as const,
queryFn: async () => ({ id: 5, name: 'Katherine' }),
initialData: { id: 1, name: 'Init' },
}),
] as const,
combine: (result) => result[0].data.name,
},
client,
)
const definedInitialDataCombinedValue: string = definedInitialDataCombined()
expectTypeOf(definedInitialDataCombinedValue).toEqualTypeOf<string>()
const numberQueries = [1, 2, 3].map((value) =>
queryOptions({
queryKey: ['type-inference', 'mapped-number', value] as const,
queryFn: async () => value,
}),
)
const mappedQueriesResult = createQueriesController(
host,
{
queries: [
...numberQueries,
queryOptions({
queryKey: ['type-inference', 'mapped-boolean'] as const,
queryFn: async () => true,
}),
],
},
client,
)
const mappedQueriesData = expectMappedQueriesResult(mappedQueriesResult())
expectTypeOf(mappedQueriesData[0].data).toEqualTypeOf<
number | boolean | undefined
>()
})
it('L2: helper option generics preserve controller inference', () => {
const client = new QueryClient()
const host = new TestControllerHost()
const query = createQueryController(
host,
queryOptions({
queryKey: ['type-inference', 'query'] as const,
queryFn: async () => ({ id: 1, name: 'Ada' }),
}),
client,
)
expectTypeOf(query().data).toEqualTypeOf<
{ id: number; name: string } | undefined
>()
const mutation = createMutationController(
host,
mutationOptions({
mutationFn: async (input: { id: number }) => input.id.toString(),
}),
client,
)
expectTypeOf(mutation().data).toEqualTypeOf<string | undefined>()
expectTypeOf(mutation().variables).toEqualTypeOf<
{ id: number } | undefined
>()
const queryOpts = queryOptions({
queryKey: ['type-inference', 'query-options'] as const,
queryFn: async () => ({ id: 2, name: 'Grace' }),
})
expectTypeOf(queryOpts.queryKey[dataTagSymbol]).toEqualTypeOf<{
id: number
name: string
}>()
const cachedData = client.getQueryData(queryOpts.queryKey)
expectTypeOf(cachedData).toEqualTypeOf<
{ id: number; name: string } | undefined
>()
const updatedData = client.setQueryData(queryOpts.queryKey, {
id: 3,
name: 'Lin',
})
expectTypeOf(updatedData).toEqualTypeOf<
{ id: number; name: string } | undefined
>()
const infinite = createInfiniteQueryController(
host,
infiniteQueryOptions({
queryKey: ['type-inference', 'infinite'] as const,
initialPageParam: 0,
queryFn: async () => ({ page: 1 }),
getNextPageParam: (lastPage) => lastPage.page + 1,
}),
client,
)
expectTypeOf(infinite().data?.pages).toEqualTypeOf<
Array<{ page: number }> | undefined
>()
const infiniteQueryOpts = infiniteQueryOptions({
queryKey: ['type-inference', 'infinite-query-options'] as const,
initialPageParam: 0,
queryFn: async () => ({ page: 3 }),
getNextPageParam: (lastPage) => lastPage.page + 1,
})
expectTypeOf(infiniteQueryOpts.queryKey[dataTagSymbol]).toEqualTypeOf<
InfiniteData<{ page: number }>
>()
expectTypeOf(
infiniteQueryOpts.queryKey[dataTagErrorSymbol],
).toEqualTypeOf<Error>()
const cachedPages = client.getQueryData(infiniteQueryOpts.queryKey)
expectTypeOf(cachedPages).toEqualTypeOf<
InfiniteData<{ page: number }> | undefined
>()
const updatedPages = client.setQueryData(infiniteQueryOpts.queryKey, {
pages: [{ page: 4 }],
pageParams: [0],
})
expectTypeOf(updatedPages).toEqualTypeOf<
InfiniteData<{ page: number }> | undefined
>()
const updatedPagesViaCallback = client.setQueryData(
infiniteQueryOpts.queryKey,
(previous) => {
expectTypeOf(previous).toEqualTypeOf<
InfiniteData<{ page: number }> | undefined
>()
return previous
},
)
expectTypeOf(updatedPagesViaCallback).toEqualTypeOf<
InfiniteData<{ page: number }> | undefined
>()
})
it('L3: queryOptions integrates with queryClient.query', async () => {
const options = queryOptions({
queryKey: ['type-inference', 'query'] as const,
queryFn: () => Promise.resolve(5),
})
const data = await new QueryClient().query(options)
expectTypeOf(data).toEqualTypeOf<number>()
})
it('L4: queryOptions with select integrates with queryClient.query', async () => {
const options = queryOptions({
queryKey: ['type-inference', 'query-select'] as const,
queryFn: () => Promise.resolve(5),
select: (data) => data.toString(),
})
const data = await new QueryClient().query(options)
expectTypeOf(data).toEqualTypeOf<string>()
})
it('L5: queryOptions with enabled: false integrates with queryClient.query', async () => {
const options = queryOptions({
queryKey: ['type-inference', 'query-enabled-false'] as const,
queryFn: () => Promise.resolve(5),
enabled: false,
})
const client = new QueryClient()
// Disabled imperative queries require cached data; otherwise query() throws before type assertions run.
client.setQueryData(options.queryKey, 5)
const data = await client.query(options)
expectTypeOf(data).toEqualTypeOf<number>()
})
it('L7: infiniteQueryOptions integrates with queryClient.infiniteQuery', async () => {
const options = infiniteQueryOptions({
queryKey: ['type-inference', 'infinite-query'] as const,
queryFn: () => Promise.resolve('data'),
getNextPageParam: () => 1,
initialPageParam: 1,
})
const data = await new QueryClient().infiniteQuery(options)
expectTypeOf(data).toEqualTypeOf<InfiniteData<string, number>>()
})
it('L8: infiniteQueryOptions with select integrates with queryClient.infiniteQuery', async () => {
const options = infiniteQueryOptions({
queryKey: ['type-inference', 'infinite-query-select'] as const,
queryFn: () => Promise.resolve('data'),
getNextPageParam: () => 1,
initialPageParam: 1,
select: (data) => data.pages,
})
const data = await new QueryClient().infiniteQuery(options)
expectTypeOf(data).toEqualTypeOf<Array<string>>()
})
it('L9: infiniteQueryOptions with enabled: false integrates with queryClient.infiniteQuery', async () => {
const options = infiniteQueryOptions({
queryKey: ['type-inference', 'infinite-query-enabled-false'] as const,
queryFn: () => Promise.resolve('data'),
getNextPageParam: () => 1,
initialPageParam: 1,
enabled: false,
})
const client = new QueryClient()
// Disabled imperative infinite queries require cached data to avoid throwing before type assertions.
client.setQueryData(options.queryKey, {
pages: ['data'],
pageParams: [1],
})
const data = await client.infiniteQuery(options)
expectTypeOf(data).toEqualTypeOf<InfiniteData<string, number>>()
})
})

View File

@@ -0,0 +1,77 @@
import type {
DefaultError,
InfiniteData,
MutationObserverResult,
QueryKey,
QueryObserverResult,
} from '@tanstack/query-core'
import type { Accessor } from './accessor.js'
import type { CreateInfiniteQueryOptions } from './createInfiniteQueryController.js'
import type { CreateMutationOptions } from './createMutationController.js'
import type {
CreateQueriesControllerOptions,
CreateQueriesResults,
} from './createQueriesController.js'
import type { CreateQueryOptions } from './createQueryController.js'
/**
* Accessor-wrapped options accepted by `createQueryController`.
*/
export type QueryControllerOptions<
TQueryFnData = unknown,
TError = DefaultError,
TData = TQueryFnData,
TQueryData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
> = Accessor<
CreateQueryOptions<TQueryFnData, TError, TData, TQueryData, TQueryKey>
>
/**
* Result object produced by a Lit query controller.
*/
export type QueryControllerResult<
TData = unknown,
TError = DefaultError,
> = QueryObserverResult<TData, TError>
/**
* Accessor-wrapped options accepted by `createInfiniteQueryController`.
*/
export type InfiniteQueryControllerOptions<
TQueryFnData = unknown,
TError = DefaultError,
TData = InfiniteData<TQueryFnData>,
TQueryKey extends QueryKey = QueryKey,
TPageParam = unknown,
> = Accessor<
CreateInfiniteQueryOptions<TQueryFnData, TError, TData, TQueryKey, TPageParam>
>
/**
* Accessor-wrapped options accepted by `createMutationController`.
*/
export type MutationControllerOptions<
TData = unknown,
TError = DefaultError,
TVariables = void,
TOnMutateResult = unknown,
> = Accessor<CreateMutationOptions<TData, TError, TVariables, TOnMutateResult>>
/**
* Result object produced by a Lit mutation controller.
*/
export type MutationControllerResult<
TData = unknown,
TError = DefaultError,
TVariables = void,
TOnMutateResult = unknown,
> = MutationObserverResult<TData, TError, TVariables, TOnMutateResult>
/**
* Accessor-wrapped options accepted by `createQueriesController`.
*/
export type QueriesControllerOptions<
TQueryOptions extends Array<any> = Array<any>,
TCombinedResult = CreateQueriesResults<TQueryOptions>,
> = Accessor<CreateQueriesControllerOptions<TQueryOptions, TCombinedResult>>

View File

@@ -0,0 +1,159 @@
import type { QueryClient, QueryFilters } from '@tanstack/query-core'
import type { ReactiveControllerHost } from 'lit'
import {
createValueAccessor,
readAccessor,
type Accessor,
type ValueAccessor,
} from './accessor.js'
import { BaseController } from './controllers/BaseController.js'
/**
* Accessor returned by `useIsFetching`.
*
* Call the accessor or read its `current` property to get the number of
* currently fetching queries that match the filters.
*/
export type IsFetchingAccessor = ValueAccessor<number> & { destroy: () => void }
class IsFetchingController extends BaseController<number> {
private queryClient: QueryClient | undefined
private unsubscribe: (() => void) | undefined
constructor(
host: ReactiveControllerHost,
private readonly filters: Accessor<QueryFilters> = {},
queryClient?: QueryClient,
) {
super(host, 0, queryClient)
if (!queryClient) {
return
}
this.queryClient = queryClient
this.result = this.computeValue()
}
protected onConnected(): void {
if (!this.syncClient()) {
this.setResult(0)
return
}
this.subscribe()
this.setResult(this.computeValue())
}
protected onDisconnected(): void {
this.unsubscribe?.()
this.unsubscribe = undefined
this.syncClient()
}
protected onHostUpdate(): void {
if (typeof this.filters !== 'function') {
return
}
this.setResult(this.syncClient() ? this.computeValue() : 0)
}
protected onQueryClientChanged(): void {
if (!this.syncClient()) {
this.setResult(0)
return
}
if (this.connectedState) {
this.subscribe()
this.setResult(this.computeValue())
}
}
private syncClient(): boolean {
const nextClient = this.tryGetQueryClient()
if (!nextClient) {
this.unsubscribe?.()
this.unsubscribe = undefined
this.queryClient = undefined
return false
}
if (nextClient === this.queryClient) {
return true
}
this.unsubscribe?.()
this.unsubscribe = undefined
this.queryClient = nextClient
return true
}
private subscribe(): void {
if (!this.queryClient) {
return
}
if (this.unsubscribe) {
return
}
this.unsubscribe = this.queryClient.getQueryCache().subscribe(() => {
this.setResult(this.computeValue())
})
}
private computeValue(): number {
if (!this.queryClient) {
return 0
}
return this.queryClient.isFetching(readAccessor(this.filters))
}
}
/**
* Creates a Lit reactive controller that tracks how many matching queries are
* currently fetching.
*
* When `filters` is a function, it is re-read during host updates so the count
* 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 cache
* subscription.
* @param filters - Query filters, or a getter that returns query filters.
* @param queryClient - Optional explicit query client. Provide this for
* controllers that should not resolve a client from Lit context.
* @returns An accessor for the current number of matching fetching queries.
*
* @example
* ```ts
* import { LitElement, html } from 'lit'
* import { useIsFetching } from '@tanstack/lit-query'
*
* class TodosStatus extends LitElement {
* private readonly todosFetching = useIsFetching(this, {
* queryKey: ['todos'],
* })
*
* render() {
* return html`<span>${this.todosFetching()} active todo fetches</span>`
* }
* }
* ```
*/
export function useIsFetching(
host: ReactiveControllerHost,
filters: Accessor<QueryFilters> = {},
queryClient?: QueryClient,
): IsFetchingAccessor {
const controller = new IsFetchingController(host, filters, queryClient)
return Object.assign(
createValueAccessor(() => controller.current),
{
destroy: () => controller.destroy(),
},
)
}

View File

@@ -0,0 +1,159 @@
import type { MutationFilters, QueryClient } from '@tanstack/query-core'
import type { ReactiveControllerHost } from 'lit'
import {
createValueAccessor,
readAccessor,
type Accessor,
type ValueAccessor,
} from './accessor.js'
import { BaseController } from './controllers/BaseController.js'
/**
* Accessor returned by `useIsMutating`.
*
* Call the accessor or read its `current` property to get the number of
* currently pending mutations that match the filters.
*/
export type IsMutatingAccessor = ValueAccessor<number> & { destroy: () => void }
class IsMutatingController extends BaseController<number> {
private queryClient: QueryClient | undefined
private unsubscribe: (() => void) | undefined
constructor(
host: ReactiveControllerHost,
private readonly filters: Accessor<MutationFilters> = {},
queryClient?: QueryClient,
) {
super(host, 0, queryClient)
if (!queryClient) {
return
}
this.queryClient = queryClient
this.result = this.computeValue()
}
protected onConnected(): void {
if (!this.syncClient()) {
this.setResult(0)
return
}
this.subscribe()
this.setResult(this.computeValue())
}
protected onDisconnected(): void {
this.unsubscribe?.()
this.unsubscribe = undefined
this.syncClient()
}
protected onHostUpdate(): void {
if (typeof this.filters !== 'function') {
return
}
this.setResult(this.syncClient() ? this.computeValue() : 0)
}
protected onQueryClientChanged(): void {
if (!this.syncClient()) {
this.setResult(0)
return
}
if (this.connectedState) {
this.subscribe()
this.setResult(this.computeValue())
}
}
private syncClient(): boolean {
const nextClient = this.tryGetQueryClient()
if (!nextClient) {
this.unsubscribe?.()
this.unsubscribe = undefined
this.queryClient = undefined
return false
}
if (nextClient === this.queryClient) {
return true
}
this.unsubscribe?.()
this.unsubscribe = undefined
this.queryClient = nextClient
return true
}
private subscribe(): void {
if (!this.queryClient) {
return
}
if (this.unsubscribe) {
return
}
this.unsubscribe = this.queryClient.getMutationCache().subscribe(() => {
this.setResult(this.computeValue())
})
}
private computeValue(): number {
if (!this.queryClient) {
return 0
}
return this.queryClient.isMutating(readAccessor(this.filters))
}
}
/**
* Creates a Lit reactive controller that tracks how many matching mutations are
* currently pending.
*
* When `filters` is a function, it is re-read during host updates so the count
* 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 cache
* subscription.
* @param filters - Mutation filters, or a getter that returns mutation filters.
* @param queryClient - Optional explicit query client. Provide this for
* controllers that should not resolve a client from Lit context.
* @returns An accessor for the current number of matching pending mutations.
*
* @example
* ```ts
* import { LitElement, html } from 'lit'
* import { useIsMutating } from '@tanstack/lit-query'
*
* class MutationStatus extends LitElement {
* private readonly savesPending = useIsMutating(this, {
* mutationKey: ['save-project'],
* })
*
* render() {
* return html`<span>${this.savesPending()} saves pending</span>`
* }
* }
* ```
*/
export function useIsMutating(
host: ReactiveControllerHost,
filters: Accessor<MutationFilters> = {},
queryClient?: QueryClient,
): IsMutatingAccessor {
const controller = new IsMutatingController(host, filters, queryClient)
return Object.assign(
createValueAccessor(() => controller.current),
{
destroy: () => controller.destroy(),
},
)
}

View File

@@ -0,0 +1,206 @@
import {
replaceEqualDeep,
type Mutation,
type MutationFilters,
type MutationState,
type QueryClient,
} from '@tanstack/query-core'
import type { ReactiveControllerHost } from 'lit'
import {
createValueAccessor,
readAccessor,
type Accessor,
type ValueAccessor,
} from './accessor.js'
import { BaseController } from './controllers/BaseController.js'
/**
* Options accepted by `useMutationState`.
*/
export type MutationStateOptions<TResult> = {
/** Filters used to select mutations from the mutation cache. */
filters?: Accessor<MutationFilters>
/** Maps each matching mutation to the value returned by the accessor. */
select?: (mutation: Mutation) => TResult
}
/**
* Accessor returned by `useMutationState`.
*
* Call the accessor or read its `current` property to get the selected state for
* matching mutations.
*/
export type MutationStateAccessor<TResult> = ValueAccessor<TResult[]> & {
/** Removes the controller from its Lit host and unsubscribes observers. */
destroy: () => void
}
class MutationStateController<TResult> extends BaseController<TResult[]> {
private queryClient: QueryClient | undefined
private unsubscribe: (() => void) | undefined
constructor(
host: ReactiveControllerHost,
private readonly options: MutationStateOptions<TResult>,
queryClient?: QueryClient,
) {
super(host, [], queryClient)
if (!queryClient) {
return
}
this.queryClient = queryClient
this.result = this.computeState()
}
protected onConnected(): void {
if (!this.syncClient()) {
this.setMutationState([])
return
}
this.subscribe()
this.setMutationState(this.computeState())
}
protected onDisconnected(): void {
this.unsubscribe?.()
this.unsubscribe = undefined
this.syncClient()
}
protected onHostUpdate(): void {
if (!this.shouldRefreshOnHostUpdate()) {
return
}
this.setMutationState(this.syncClient() ? this.computeState() : [])
}
protected onQueryClientChanged(): void {
if (!this.syncClient()) {
this.setMutationState([])
return
}
if (this.connectedState) {
this.subscribe()
this.setMutationState(this.computeState())
}
}
private syncClient(): boolean {
const nextClient = this.tryGetQueryClient()
if (!nextClient) {
this.unsubscribe?.()
this.unsubscribe = undefined
this.queryClient = undefined
return false
}
if (nextClient === this.queryClient) {
return true
}
this.unsubscribe?.()
this.unsubscribe = undefined
this.queryClient = nextClient
return true
}
private subscribe(): void {
if (!this.queryClient) {
return
}
if (this.unsubscribe) {
return
}
this.unsubscribe = this.queryClient.getMutationCache().subscribe(() => {
this.setMutationState(this.computeState())
})
}
private setMutationState(next: TResult[]): void {
this.setResult(replaceEqualDeep(this.result, next))
}
private shouldRefreshOnHostUpdate(): boolean {
return (
typeof this.options.filters === 'function' ||
typeof this.options.select === 'function'
)
}
private computeState(): TResult[] {
if (!this.queryClient) {
return []
}
const filters = this.options.filters
? readAccessor(this.options.filters)
: undefined
const select = this.options.select
const mutations = this.queryClient.getMutationCache().findAll(filters)
return mutations.map((mutation) => {
if (select) {
return select(mutation)
}
return mutation.state as TResult
})
}
}
/**
* Creates a Lit reactive controller that selects state from matching mutations
* in the mutation cache.
*
* When `options.filters` is a function, it is re-read during host updates so
* the selection 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 mutation cache
* subscription.
* @param options - Mutation state filters and optional selector.
* @param queryClient - Optional explicit query client. Provide this for
* controllers that should not resolve a client from Lit context.
* @returns An accessor for the selected mutation state array.
*
* @example
* ```ts
* import { LitElement, html } from 'lit'
* import { useMutationState } from '@tanstack/lit-query'
*
* class PendingUploads extends LitElement {
* private readonly uploads = useMutationState(this, {
* filters: { mutationKey: ['upload'], status: 'pending' },
* select: (mutation) => mutation.state.variables as File,
* })
*
* render() {
* return html`<span>${this.uploads().length} uploads pending</span>`
* }
* }
* ```
*/
export function useMutationState<
TResult = MutationState<unknown, unknown, unknown, unknown>,
>(
host: ReactiveControllerHost,
options: MutationStateOptions<TResult> = {},
queryClient?: QueryClient,
): MutationStateAccessor<TResult> {
const controller = new MutationStateController(host, options, queryClient)
return Object.assign(
createValueAccessor(() => controller.current),
{
destroy: () => controller.destroy(),
},
)
}

View File

@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist-ts",
"rootDir": ".",
"lib": ["ES2022", "DOM"]
},
"include": ["src", "*.config.ts", "*.config.js", "package.json"]
}

View File

@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"incremental": false,
"composite": false,
"rootDir": "../../"
},
"include": ["src"],
"exclude": ["src/tests"]
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'tsdown'
import { legacyConfig, modernConfig } from './root.tsdown.config.js'
export default defineConfig([
modernConfig({ entry: ['src/index.ts'] }),
legacyConfig({ entry: ['src/index.ts'] }),
])

View File

@@ -0,0 +1,32 @@
import { defineConfig } from 'vitest/config'
import packageJson from './package.json'
export default defineConfig({
// fix from https://github.com/vitest-dev/vitest/issues/6992#issuecomment-2509408660
resolve: {
conditions: ['@tanstack/custom-condition'],
},
environments: {
ssr: {
resolve: {
conditions: ['@tanstack/custom-condition'],
},
},
},
test: {
name: packageJson.name,
dir: './src',
watch: false,
environment: 'jsdom',
include: ['tests/**/*.test.ts'],
coverage: {
enabled: !!process.env.CI,
provider: 'istanbul',
include: ['src/**/*'],
exclude: ['src/tests/**'],
},
typecheck: { enabled: true },
restoreMocks: true,
},
})