snapshot of https://github.com/TanStack/query.git (main, 2026-09-16; blobs > 131072B omitted)
This commit is contained in:
9
packages/lit-query/.editorconfig
Normal file
9
packages/lit-query/.editorconfig
Normal 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
|
||||
2
packages/lit-query/.npmignore
Normal file
2
packages/lit-query/.npmignore
Normal file
@@ -0,0 +1,2 @@
|
||||
docs/
|
||||
src/tests/
|
||||
10
packages/lit-query/.prettierignore
Normal file
10
packages/lit-query/.prettierignore
Normal file
@@ -0,0 +1,10 @@
|
||||
dist
|
||||
dist-cjs
|
||||
node_modules
|
||||
coverage
|
||||
.claude
|
||||
.references
|
||||
*.tgz
|
||||
package-lock.json
|
||||
examples/**/output
|
||||
examples/**/dist
|
||||
156
packages/lit-query/CHANGELOG.md
Normal file
156
packages/lit-query/CHANGELOG.md
Normal 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))
|
||||
144
packages/lit-query/README.md
Normal file
144
packages/lit-query/README.md
Normal 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
|
||||
51
packages/lit-query/eslint.config.js
Normal file
51
packages/lit-query/eslint.config.js
Normal 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',
|
||||
},
|
||||
},
|
||||
)
|
||||
72
packages/lit-query/package.json
Normal file
72
packages/lit-query/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1
packages/lit-query/root.tsdown.config.js
Symbolic link
1
packages/lit-query/root.tsdown.config.js
Symbolic link
@@ -0,0 +1 @@
|
||||
../../scripts/getTsdownConfig.js
|
||||
124
packages/lit-query/scripts/l3-stress.mjs
Normal file
124
packages/lit-query/scripts/l3-stress.mjs
Normal 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)
|
||||
})
|
||||
43
packages/lit-query/scripts/measure-bundle.mjs
Normal file
43
packages/lit-query/scripts/measure-bundle.mjs
Normal 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)
|
||||
})
|
||||
174
packages/lit-query/src/QueryClientProvider.ts
Normal file
174
packages/lit-query/src/QueryClientProvider.ts
Normal 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
|
||||
}
|
||||
}
|
||||
43
packages/lit-query/src/accessor.ts
Normal file
43
packages/lit-query/src/accessor.ts
Normal 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
|
||||
}
|
||||
120
packages/lit-query/src/context.ts
Normal file
120
packages/lit-query/src/context.ts
Normal 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()
|
||||
}
|
||||
292
packages/lit-query/src/controllers/BaseController.ts
Normal file
292
packages/lit-query/src/controllers/BaseController.ts
Normal 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
|
||||
}
|
||||
432
packages/lit-query/src/createInfiniteQueryController.ts
Normal file
432
packages/lit-query/src/createInfiniteQueryController.ts
Normal 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(),
|
||||
},
|
||||
)
|
||||
}
|
||||
363
packages/lit-query/src/createMutationController.ts
Normal file
363
packages/lit-query/src/createMutationController.ts
Normal 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(),
|
||||
},
|
||||
)
|
||||
}
|
||||
719
packages/lit-query/src/createQueriesController.ts
Normal file
719
packages/lit-query/src/createQueriesController.ts
Normal 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(),
|
||||
},
|
||||
)
|
||||
}
|
||||
378
packages/lit-query/src/createQueryController.ts
Normal file
378
packages/lit-query/src/createQueryController.ts
Normal 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(),
|
||||
},
|
||||
)
|
||||
}
|
||||
72
packages/lit-query/src/index.ts
Normal file
72
packages/lit-query/src/index.ts
Normal 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'
|
||||
54
packages/lit-query/src/infiniteQueryOptions.ts
Normal file
54
packages/lit-query/src/infiniteQueryOptions.ts
Normal 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
|
||||
}
|
||||
31
packages/lit-query/src/mutationOptions.ts
Normal file
31
packages/lit-query/src/mutationOptions.ts
Normal 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
|
||||
}
|
||||
30
packages/lit-query/src/queryObserverResultTracker.ts
Normal file
30
packages/lit-query/src/queryObserverResultTracker.ts
Normal 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
|
||||
}
|
||||
}
|
||||
143
packages/lit-query/src/queryOptions.ts
Normal file
143
packages/lit-query/src/queryOptions.ts
Normal 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
|
||||
}
|
||||
111
packages/lit-query/src/tests/base-controller.test.ts
Normal file
111
packages/lit-query/src/tests/base-controller.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
483
packages/lit-query/src/tests/client-switch-controllers.test.ts
Normal file
483
packages/lit-query/src/tests/client-switch-controllers.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
211
packages/lit-query/src/tests/context-provider.test.ts
Normal file
211
packages/lit-query/src/tests/context-provider.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
736
packages/lit-query/src/tests/counters-and-state.test.ts
Normal file
736
packages/lit-query/src/tests/counters-and-state.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
583
packages/lit-query/src/tests/infinite-and-options.test.ts
Normal file
583
packages/lit-query/src/tests/infinite-and-options.test.ts
Normal 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])
|
||||
})
|
||||
})
|
||||
473
packages/lit-query/src/tests/mutation-controller.test.ts
Normal file
473
packages/lit-query/src/tests/mutation-controller.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
936
packages/lit-query/src/tests/queries-controller.test.ts
Normal file
936
packages/lit-query/src/tests/queries-controller.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
1514
packages/lit-query/src/tests/query-controller.test.ts
Normal file
1514
packages/lit-query/src/tests/query-controller.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
118
packages/lit-query/src/tests/testHost.ts
Normal file
118
packages/lit-query/src/tests/testHost.ts
Normal 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)
|
||||
}
|
||||
329
packages/lit-query/src/tests/type-inference.test.ts
Normal file
329
packages/lit-query/src/tests/type-inference.test.ts
Normal 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>>()
|
||||
})
|
||||
})
|
||||
77
packages/lit-query/src/types.ts
Normal file
77
packages/lit-query/src/types.ts
Normal 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>>
|
||||
159
packages/lit-query/src/useIsFetching.ts
Normal file
159
packages/lit-query/src/useIsFetching.ts
Normal 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(),
|
||||
},
|
||||
)
|
||||
}
|
||||
159
packages/lit-query/src/useIsMutating.ts
Normal file
159
packages/lit-query/src/useIsMutating.ts
Normal 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(),
|
||||
},
|
||||
)
|
||||
}
|
||||
206
packages/lit-query/src/useMutationState.ts
Normal file
206
packages/lit-query/src/useMutationState.ts
Normal 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(),
|
||||
},
|
||||
)
|
||||
}
|
||||
9
packages/lit-query/tsconfig.json
Normal file
9
packages/lit-query/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist-ts",
|
||||
"rootDir": ".",
|
||||
"lib": ["ES2022", "DOM"]
|
||||
},
|
||||
"include": ["src", "*.config.ts", "*.config.js", "package.json"]
|
||||
}
|
||||
10
packages/lit-query/tsconfig.prod.json
Normal file
10
packages/lit-query/tsconfig.prod.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"incremental": false,
|
||||
"composite": false,
|
||||
"rootDir": "../../"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/tests"]
|
||||
}
|
||||
7
packages/lit-query/tsdown.config.ts
Normal file
7
packages/lit-query/tsdown.config.ts
Normal 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'] }),
|
||||
])
|
||||
32
packages/lit-query/vite.config.ts
Normal file
32
packages/lit-query/vite.config.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user