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

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

View File

@@ -0,0 +1,331 @@
# @tanstack/query-core
## 5.102.8
## 5.102.7
## 5.102.6
## 5.102.5
### Patch Changes
- [#11302](https://github.com/TanStack/query/pull/11302) [`578e5c2`](https://github.com/TanStack/query/commit/578e5c26e8ebd0d7351b4b8e2bafba695e672b8d) - Reduce the Query Core bundle size by removing unused symbol descriptions and
simplifying internal helpers.
## 5.102.4
### Patch Changes
- [#11293](https://github.com/TanStack/query/pull/11293) [`a05df6a`](https://github.com/TanStack/query/commit/a05df6aefb0e2489ec2c879ae16e2ee7cb3123ec) - Avoid scheduling stale timeouts for disabled query observers.
## 5.102.3
## 5.102.2
### Patch Changes
- [#11263](https://github.com/TanStack/query/pull/11263) [`80fbf73`](https://github.com/TanStack/query/commit/80fbf73e77892d702c107e14a84c219a8ed825dc) - Export MutationCacheConfig and QueryCacheConfig.
## 5.102.1
### Patch Changes
- [#11260](https://github.com/TanStack/query/pull/11260) [`134890d`](https://github.com/TanStack/query/commit/134890dbdc60e4fb0313e44b512d29284ca82f96) - Type hydration input as a partial dehydrated state so omitted mutation and query collections are supported.
## 5.102.0
### Minor Changes
- [#10658](https://github.com/TanStack/query/pull/10658) [`c6fc17c`](https://github.com/TanStack/query/commit/c6fc17cfad6e452261c585fabfb8c210e60531ed) - add query and infiniteQuery methods, deprecate old imperative methods
### Patch Changes
- [#11161](https://github.com/TanStack/query/pull/11161) [`34f7cee`](https://github.com/TanStack/query/commit/34f7ceed09c10e4a3aa2df31a106ddf02ec4e787) - fix(query-core): clear a stale `select` error when the observer switches to a query without data, and reset `isPlaceholderData` on select-error results to match the declared result types, so a previous query's select error no longer leaks into the new result
- [#11253](https://github.com/TanStack/query/pull/11253) [`b4368c4`](https://github.com/TanStack/query/commit/b4368c43792349f6c29d1fb41f7ee1ef3a8bdd2c) - Improve hydration performance by skipping no-op data transforms and default error-redaction callbacks.
Export dehydrateQuery.
- [#11214](https://github.com/TanStack/query/pull/11214) [`5bb089d`](https://github.com/TanStack/query/commit/5bb089d153d33933983b7ea80f8d69b51d423698) - Reduce observer churn overhead by removing observers in place instead of copying the observer list for every unsubscribe.
- [#11011](https://github.com/TanStack/query/pull/11011) [`ba4650c`](https://github.com/TanStack/query/commit/ba4650c05e61e33c609e051b948c9f7d31ce70d1) - fix(query-core): reset `isPlaceholderData` when `select` throws on placeholder data
- [#11224](https://github.com/TanStack/query/pull/11224) [`294d4e6`](https://github.com/TanStack/query/commit/294d4e62c4f7d674574a4903eef2a1bc3dd1413b) - Fix `queryOptions` and `infiniteQueryOptions` return types so exported inferred options can be emitted in declaration files without leaking internal data tag symbols.
- [#11225](https://github.com/TanStack/query/pull/11225) [`1f631b3`](https://github.com/TanStack/query/commit/1f631b3604aab6567fb3f6c90646a5a304641546) - Skip unused result tracking when notifying `useQueries` listeners without a `combine` function.
- [#11215](https://github.com/TanStack/query/pull/11215) [`01a02bf`](https://github.com/TanStack/query/commit/01a02bfad32f9efbc17796be31d6399f1197a655) - Avoid repeatedly synchronizing the same tracked property across all observers in `useQueries`.
- [#11172](https://github.com/TanStack/query/pull/11172) [`18c1c1e`](https://github.com/TanStack/query/commit/18c1c1ef94883e781ad36d36f9ea4a043ce4260b) - Reattach `MutationObserver` to its current mutation when a listener subscribes again, so a `useMutation` result no longer stays `pending` after React tears down and re-establishes the subscription mid-mutation.
- [#11128](https://github.com/TanStack/query/pull/11128) [`5448063`](https://github.com/TanStack/query/commit/5448063f828d2270dccd41ae375e1afde35e91f2) - Ignore a retained `pendingThenable` settlement callback invoked after the thenable has settled. Holding a reference to `resolve`/`reject` and calling it later used to overwrite `status` and `reason` even though the underlying promise had already settled, leaving the thenable advertising a state that disagreed with its value.
- [#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.
- [#11218](https://github.com/TanStack/query/pull/11218) [`5981771`](https://github.com/TanStack/query/commit/5981771abec9330b344a2617543eb05ba7c99e24) - Release a mutation's retryer once its execution settles, so the settled promise no longer keeps that mutation's result, variables and context in memory for as long as the mutation cache retains it.
- [#11163](https://github.com/TanStack/query/pull/11163) [`4a9bef6`](https://github.com/TanStack/query/commit/4a9bef6cf19b1cd6b014032d696129f12a848185) - Release a query's retryer once its fetch settles, so the settled promise no longer keeps that fetch's raw result in memory alongside the structurally shared `state.data` (or after the query is reset or removed).
- [#11036](https://github.com/TanStack/query/pull/11036) [`bef4bc7`](https://github.com/TanStack/query/commit/bef4bc780ce7cca32b7e3dea85f77d92f82a62a2) - Resolve suspense when query data is set programmatically via setQueryData or streamedQuery. Previously, fetchOptimistic returned only the fetch promise, which left the Suspense boundary stuck until the queryFn completed — even when data already existed in the cache. The fix uses Promise.race with a cache subscriber to release suspense as soon as data becomes available.
- [#11234](https://github.com/TanStack/query/pull/11234) [`9656dc4`](https://github.com/TanStack/query/commit/9656dc4e5fef5f8c502579b52459f9e8c72e787c) - Notify every query observer when another observer unsubscribes synchronously during the same query update.
- [#11211](https://github.com/TanStack/query/pull/11211) [`326aaf1`](https://github.com/TanStack/query/commit/326aaf1333e5d9cbc46569c53f218d31684162d2) - fix: resetQueries now preserves the queries matched before query.reset() changes their state.
- [#11065](https://github.com/TanStack/query/pull/11065) [`3e83601`](https://github.com/TanStack/query/commit/3e836010032c2586e9f1c66a271fa9114a6401f9) - Memoize falsy `combine` results when the function and query results are unchanged.
## 5.101.4
## 5.101.3
### Patch Changes
- [#11084](https://github.com/TanStack/query/pull/11084) [`7e3c822`](https://github.com/TanStack/query/commit/7e3c822a10896f41a8f1031c16b85096277af677) - Improve `partialMatchKey` performance in query-core.
## 5.101.2
## 5.101.1
### Patch Changes
- [#10610](https://github.com/TanStack/query/pull/10610) [`9eff92e`](https://github.com/TanStack/query/commit/9eff92ed86e284ec0125b3a3539d028688235bd1) - fix missing `dataUpdatedAt` for streamed queries that resolve before hydration
## 5.101.0
## 5.100.14
## 5.100.13
### Patch Changes
- fix(query-core): drop the custom `NoInfer<T>` re-export and rely on TypeScript's built-in `NoInfer` (TS ≥ 5.4) so `NoInfer<X[K]>` stays assignable to `X[K]` in generic contexts (fixes [#9937](https://github.com/TanStack/query/issues/9937)) ([#10593](https://github.com/TanStack/query/pull/10593))
## 5.100.12
## 5.100.11
## 5.100.10
## 5.100.9
### Patch Changes
- fix(query-core): wrap `persister`'s `TQueryKey` in `NoInfer` so that the `persister` slot no longer contributes to `TQueryKey` inference. Follow-up to #10510, which removed `NoInfer` on all three `persister` generics. Preserving `NoInfer<TQueryKey>` keeps that fix's benefit for `TQueryFnData` while preventing `TQueryKey` from widening to the augmented constraint when `Register.queryKey` is narrowed — which made `DataTag`-branded wrapper returns un-assignable in contravariant slots. ([#10601](https://github.com/TanStack/query/pull/10601))
## 5.100.8
## 5.100.7
## 5.100.6
## 5.100.5
### Patch Changes
- fix(core): propagate AbortSignal reason in infinite queries ([`a53ef97`](https://github.com/TanStack/query/commit/a53ef97f87decb8ea2431710f5199431d3c94c8d))
## 5.100.4
## 5.100.3
### Patch Changes
- fix(suspense): skip calling combine when queries would suspend ([#10576](https://github.com/TanStack/query/pull/10576))
## 5.100.2
### Patch Changes
- fix(query-core): allow `persister` to contribute to `TQueryFnData` inference so a `queryFn` that declares a parameter no longer produces a spurious overload mismatch against a typed persister (#7842). ([#10510](https://github.com/TanStack/query/pull/10510))
- fix: preserve infinite query behavior during SSR hydration (#8825) ([#10074](https://github.com/TanStack/query/pull/10074))
- ref(core): remove leftover setStateOptions ([#10574](https://github.com/TanStack/query/pull/10574))
## 5.100.1
### Patch Changes
- Fix bugs where hydrating queries with promises that had already resolved could cause queries to briefly and incorrectly show as pending/fetching ([#10444](https://github.com/TanStack/query/pull/10444))
## 5.100.0
### Minor Changes
- feat(query-core): accept callback function for retryOnMount ([#10515](https://github.com/TanStack/query/pull/10515))
## 5.99.2
## 5.99.1
## 5.99.0
## 5.98.0
## 5.97.0
### Patch Changes
- fix(query-core): use explicit `undefined` check for timer IDs so that custom `TimeoutProvider`s returning `0` as a valid timer ID are properly cleared ([#10401](https://github.com/TanStack/query/pull/10401))
## 5.96.2
## 5.96.1
## 5.96.0
## 5.95.2
### Patch Changes
- fix(timeoutManager): make sure NodeJs.Timout doesn't leak ([#10325](https://github.com/TanStack/query/pull/10325))
## 5.95.1
### Patch Changes
- fix(timeoutManager): make sure NodeJs.Timout doesn't leak ([#10323](https://github.com/TanStack/query/pull/10323))
## 5.95.0
## 5.94.5
### Patch Changes
- fix(\*): resolve issue about excluded build directory ([#10312](https://github.com/TanStack/query/pull/10312))
## 5.94.4
### Patch Changes
- chore: fixed version ([#10064](https://github.com/TanStack/query/pull/10064))
## 5.91.2
### Patch Changes
- fix(streamedQuery): maintain error state on reset refetch with initialData defined ([#10287](https://github.com/TanStack/query/pull/10287))
## 5.91.1
### Patch Changes
- fix(core): cancel paused initial fetch when last observer unsubscribes ([#10291](https://github.com/TanStack/query/pull/10291))
## 5.91.0
### Minor Changes
- feat: environmentManager ([#10199](https://github.com/TanStack/query/pull/10199))
## 5.90.20
### Patch Changes
- Fix: onMutate callback now runs synchronously when mutationCache.config.onMutate is not defined ([#10066](https://github.com/TanStack/query/pull/10066))
## 5.90.19
### Patch Changes
- fix stable combine reference not updating when queries change dynamically ([#9954](https://github.com/TanStack/query/pull/9954))
## 5.90.18
### Patch Changes
- Align experimental_prefetchInRender promise rejection with Suspense behavior by only throwing when no data is available. ([#10025](https://github.com/TanStack/query/pull/10025))
## 5.90.17
### Patch Changes
- fix(query-core): replaceEqualDeep max depth ([#10032](https://github.com/TanStack/query/pull/10032))
## 5.90.16
### Patch Changes
- fix useQueries race condition on queries length change (#9971) ([#9973](https://github.com/TanStack/query/pull/9973))
## 5.90.15
### Patch Changes
- Fix: Always treat existing data as stale when query goes into error state. ([#9927](https://github.com/TanStack/query/pull/9927))
## 5.90.14
### Patch Changes
- Fix streamedQuery reducer being called twice ([#9970](https://github.com/TanStack/query/pull/9970))
## 5.90.13
### Patch Changes
- Made context.signal consume aware with streamedQuery ([#9963](https://github.com/TanStack/query/pull/9963))
## 5.90.12
### Patch Changes
- fix: update react and nextJs ([#9944](https://github.com/TanStack/query/pull/9944))
## 5.90.11
### Patch Changes
- Prevent infinite render loops when useSuspenseQueries has duplicate queryKeys ([#9886](https://github.com/TanStack/query/pull/9886))
## 5.90.10
### Patch Changes
- fix(types): allow QueryFilters union with different lengths ([#9878](https://github.com/TanStack/query/pull/9878))
- Fix streamedQuery to avoid returning undefined when the stream yields no values ([#9876](https://github.com/TanStack/query/pull/9876))
## 5.90.9
### Patch Changes
- fix(types): do not drop readonly for partial QueryFilter matching ([#9872](https://github.com/TanStack/query/pull/9872))
## 5.90.8
### Patch Changes
- fix: allow partial query keys in `QueryFilters` ([#9686](https://github.com/TanStack/query/pull/9686))
## 5.90.7
### Patch Changes
- fix(core): only attach .then and .catch onto a promise if it gets dehydrated ([#9847](https://github.com/TanStack/query/pull/9847))
## 5.90.6
### Patch Changes
- Fixed isFetchedAfterMount in cases where initialData is applied ([#9743](https://github.com/TanStack/query/pull/9743))
## 5.90.5
### Patch Changes
- fix: observing "promise" needs to implicitly observe "data" ([#9772](https://github.com/TanStack/query/pull/9772))
## 5.90.4
### Patch Changes
- fix(types): remove duplicate Array condition in MutationKey type ([#9754](https://github.com/TanStack/query/pull/9754))
## 5.90.3
### Patch Changes
- Avoid unhandled promise rejection errors during de/rehydration of pending queries. ([#9752](https://github.com/TanStack/query/pull/9752))

View File

@@ -0,0 +1,5 @@
// @ts-check
import rootConfig from './root.eslint.config.js'
export default [...rootConfig]

View File

@@ -0,0 +1,62 @@
{
"name": "@tanstack/query-core",
"version": "5.102.8",
"description": "The framework agnostic core that powers TanStack Query",
"author": "tannerlinsley",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/TanStack/query.git",
"directory": "packages/query-core"
},
"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 ./src",
"test:types": "npm-run-all --serial test:types:*",
"test:types:ts56": "node ../../node_modules/typescript56/lib/tsc.js --project tsconfig.legacy.json --outDir .cache/test-types/ts56 --tsBuildInfoFile .cache/test-types/ts56.tsbuildinfo",
"test:types:ts57": "node ../../node_modules/typescript57/lib/tsc.js --project tsconfig.legacy.json --outDir .cache/test-types/ts57 --tsBuildInfoFile .cache/test-types/ts57.tsbuildinfo",
"test:types:ts58": "node ../../node_modules/typescript58/lib/tsc.js --project tsconfig.legacy.json --outDir .cache/test-types/ts58 --tsBuildInfoFile .cache/test-types/ts58.tsbuildinfo",
"test:types:ts59": "node ../../node_modules/typescript59/lib/tsc.js --project tsconfig.legacy.json --outDir .cache/test-types/ts59 --tsBuildInfoFile .cache/test-types/ts59.tsbuildinfo",
"test:types:tscurrent": "tsc --project tsconfig.json --outDir .cache/test-types/tscurrent --tsBuildInfoFile .cache/test-types/tscurrent.tsbuildinfo",
"test:types:ts70": "node ../../node_modules/typescript70/lib/tsc.js --project tsconfig.legacy.json --outDir .cache/test-types/ts70 --tsBuildInfoFile .cache/test-types/ts70.tsbuildinfo",
"test:lib": "vitest",
"test:lib:dev": "pnpm run test:lib --watch",
"test:build": "publint --strict && attw --pack",
"build": "tsdown --tsconfig tsconfig.prod.json"
},
"type": "module",
"types": "build/legacy/index.d.ts",
"main": "build/legacy/index.cjs",
"module": "build/legacy/index.js",
"react-native": "src/index.ts",
"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__"
],
"devDependencies": {
"@tanstack/query-test-utils": "workspace:*",
"npm-run-all2": "^5.0.0"
}
}

View File

@@ -0,0 +1 @@
../../eslint.config.js

View File

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

View File

@@ -0,0 +1,341 @@
import { describe, expectTypeOf, it } from 'vitest'
import type { DistributiveOmit, OmitKeyof } from '..'
describe('OmitKeyof', () => {
it('should omit a string key and reject one the object does not have', () => {
type A = {
x: string
y: number
}
type ExpectedType = {
x: string
}
// Bad point
// 1. original Omit can use 'z' as type parameter with no type error
// 2. original Omit have no auto complete for 2nd type parameter
expectTypeOf<Omit<A, 'z' | 'y'>>().toEqualTypeOf<ExpectedType>()
// Solution
// 1. strictly
expectTypeOf<
OmitKeyof<
A,
// OmitKeyof can't use 'z' as type parameter with type error because A don't have key 'z'
// @ts-expect-error Type does not satisfy the constraint keyof A
'z' | 'y'
>
>().toEqualTypeOf<ExpectedType>()
expectTypeOf<
OmitKeyof<
A,
// OmitKeyof can't use 'z' as type parameter with type error because A don't have key 'z'
// @ts-expect-error Type does not satisfy the constraint keyof A
'z' | 'y',
'strictly'
>
>().toEqualTypeOf<ExpectedType>()
// 2. safely
expectTypeOf<
OmitKeyof<
A,
// OmitKeyof can't use 'z' as type parameter type error with strictly parameter or default parameter
// @ts-expect-error Type does not satisfy the constraint keyof A
'z' | 'y'
>
>().toEqualTypeOf<ExpectedType>()
expectTypeOf<
OmitKeyof<
A,
// With 'safely', OmitKeyof can use 'z' as type parameter like original Omit but This support autocomplete too yet for DX.
'z' | 'y',
'safely'
>
>().toEqualTypeOf<ExpectedType>()
})
it('should omit a number key and reject one the object does not have', () => {
type A = {
[1]: string
[2]: number
}
type ExpectedType = {
[1]: string
}
// Bad point
// 1. original Omit can use 3 as type parameter with no type error
// 2. original Omit have no auto complete for 2nd type parameter
expectTypeOf<Omit<A, 3 | 2>>().toEqualTypeOf<ExpectedType>()
// Solution
// 1. strictly
expectTypeOf<
OmitKeyof<
A,
// OmitKeyof can't use 3 as type parameter with type error because A don't have key 3
// @ts-expect-error Type does not satisfy the constraint keyof A
3 | 2
>
>().toEqualTypeOf<ExpectedType>()
expectTypeOf<
OmitKeyof<
A,
// OmitKeyof can't use 3 as type parameter with type error because A don't have key 3
// @ts-expect-error Type does not satisfy the constraint keyof A
3 | 2,
'strictly'
>
>().toEqualTypeOf<ExpectedType>()
// 2. safely
expectTypeOf<
OmitKeyof<
A,
// OmitKeyof can't use 3 as type parameter type error with strictly parameter or default parameter
// @ts-expect-error Type does not satisfy the constraint keyof A
3 | 2
>
>().toEqualTypeOf<ExpectedType>()
expectTypeOf<
OmitKeyof<
A,
// With 'safely', OmitKeyof can use 3 as type parameter like original Omit but This support autocomplete too yet for DX.
3 | 2,
'safely'
>
>().toEqualTypeOf<ExpectedType>()
})
it('should omit a symbol key and reject one the object does not have', () => {
const symbol1 = Symbol()
const symbol2 = Symbol()
const symbol3 = Symbol()
type A = {
[symbol1]: string
[symbol2]: number
}
type ExpectedType = {
[symbol1]: string
}
// Bad point
// 1. original Omit can use symbol3 as type parameter with no type error
// 2. original Omit have no auto complete for 2nd type parameter
expectTypeOf<
Omit<A, typeof symbol3 | typeof symbol2>
>().toEqualTypeOf<ExpectedType>()
// Solution
// 1. strictly
expectTypeOf<
OmitKeyof<
A,
// OmitKeyof can't use symbol3 as type parameter with type error because A don't have key symbol3
// @ts-expect-error Type does not satisfy the constraint keyof A
typeof symbol3 | typeof symbol2
>
>().toEqualTypeOf<ExpectedType>()
expectTypeOf<
OmitKeyof<
A,
// OmitKeyof can't use symbol3 as type parameter with type error because A don't have key symbol3
// @ts-expect-error Type does not satisfy the constraint keyof A
typeof symbol3 | typeof symbol2,
'strictly'
>
>().toEqualTypeOf<ExpectedType>()
// 2. safely
expectTypeOf<
OmitKeyof<
A,
// OmitKeyof can't use symbol3 as type parameter type error with strictly parameter or default parameter
// @ts-expect-error Type does not satisfy the constraint keyof A
typeof symbol3 | typeof symbol2
>
>().toEqualTypeOf<ExpectedType>()
expectTypeOf<
OmitKeyof<
A,
// With 'safely', OmitKeyof can use symbol3 as type parameter like original Omit but This support autocomplete too yet for DX.
typeof symbol3 | typeof symbol2,
'safely'
>
>().toEqualTypeOf<ExpectedType>()
})
it('should default TStrictly to strictly', () => {
type A = {
x: string
y: number
}
// the two-argument form must behave exactly like the explicit 'strictly' form
expectTypeOf<OmitKeyof<A, 'y'>>().toEqualTypeOf<
OmitKeyof<A, 'y', 'strictly'>
>()
// and it must reject an unknown key just like 'strictly' does
expectTypeOf<
OmitKeyof<
A,
// @ts-expect-error Type does not satisfy the constraint keyof A
'z'
>
>().toEqualTypeOf<A>()
})
it('should constrain TStrictly to strictly or safely', () => {
type A = {
x: string
y: number
}
expectTypeOf<OmitKeyof<A, 'y', 'safely'>>().toEqualTypeOf<{ x: string }>()
expectTypeOf<
OmitKeyof<
A,
'y',
// @ts-expect-error 'loosely' is not a valid TStrictly
'loosely'
>
>().toEqualTypeOf<{ x: string }>()
})
it('should accept an arbitrary key only with safely, never with strictly', () => {
type A = {
x: string
y: number
}
// 'safely' widens the constraint so an unrelated key is accepted and simply ignored
expectTypeOf<OmitKeyof<A, 'z', 'safely'>>().toEqualTypeOf<A>()
expectTypeOf<OmitKeyof<A, 3, 'safely'>>().toEqualTypeOf<A>()
// while 'strictly' rejects the very same key
expectTypeOf<
OmitKeyof<
A,
// @ts-expect-error Type does not satisfy the constraint keyof A
'z',
'strictly'
>
>().toEqualTypeOf<A>()
})
it('should preserve optional and readonly modifiers', () => {
type A = {
readonly x: string
y?: number
readonly z?: boolean
}
expectTypeOf<OmitKeyof<A, 'y'>>().toEqualTypeOf<{
readonly x: string
readonly z?: boolean
}>()
expectTypeOf<OmitKeyof<A, 'z'>>().toEqualTypeOf<{
readonly x: string
y?: number
}>()
})
it('should collapse a union instead of distributing over it', () => {
type A = { kind: 'a'; shared: string; onlyA: number }
type B = { kind: 'b'; shared: string; onlyB: boolean }
// `keyof (A | B)` is the intersection of the keys, so only the common keys
// satisfy the 'strictly' constraint
expectTypeOf<OmitKeyof<A | B, 'shared'>>().toEqualTypeOf<{
kind: 'a' | 'b'
}>()
// a key that only exists on one member is rejected by 'strictly'
expectTypeOf<
OmitKeyof<
A | B,
// @ts-expect-error 'onlyA' is not in keyof (A | B)
'onlyA'
>
>().toEqualTypeOf<{ kind: 'a' | 'b'; shared: string }>()
})
it('should accept every key of an index signature', () => {
type A = Record<string, number>
// `keyof Record<string, number>` is `string | number`, so 'strictly'
// accepts an arbitrary key here even though nothing is removed
expectTypeOf<OmitKeyof<A, 'anything'>>().toEqualTypeOf<{
[x: string]: number
}>()
expectTypeOf<OmitKeyof<A, string>>().toEqualTypeOf<{}>()
})
it('should omit every key when TKey is keyof TObject', () => {
type A = {
x: string
y: number
}
expectTypeOf<OmitKeyof<A, keyof A>>().toEqualTypeOf<{}>()
expectTypeOf<OmitKeyof<A, never>>().toEqualTypeOf<A>()
})
})
describe('DistributiveOmit', () => {
it('should distribute over a union instead of collapsing it, unlike OmitKeyof', () => {
type A = { kind: 'a'; shared: string; onlyA: number }
type B = { kind: 'b'; shared: string; onlyB: boolean }
// each union member is omitted from separately, so the member-only keys survive
expectTypeOf<DistributiveOmit<A | B, 'shared'>>().toEqualTypeOf<
{ kind: 'a'; onlyA: number } | { kind: 'b'; onlyB: boolean }
>()
// whereas OmitKeyof collapses the union into its common keys
expectTypeOf<OmitKeyof<A | B, 'shared'>>().toEqualTypeOf<{
kind: 'a' | 'b'
}>()
})
it('should constrain TKey to the keys shared by every union member', () => {
type A = { kind: 'a'; shared: string; onlyA: number }
type B = { kind: 'b'; shared: string; onlyB: boolean }
// a key that only exists on one member is rejected, because the constraint
// is `keyof TObject` on the union itself rather than on each distributed arm
expectTypeOf<
DistributiveOmit<
A | B,
// @ts-expect-error 'onlyA' is not in keyof (A | B)
'onlyA'
>['kind']
>().toEqualTypeOf<'a' | 'b'>()
// the key shared by every member is accepted
expectTypeOf<DistributiveOmit<A | B, 'kind'>>().toEqualTypeOf<
{ shared: string; onlyA: number } | { shared: string; onlyB: boolean }
>()
})
it('should behave like Omit on a non-union object', () => {
type A = {
x: string
y: number
}
expectTypeOf<DistributiveOmit<A, 'y'>>().toEqualTypeOf<{ x: string }>()
})
})

View File

@@ -0,0 +1,62 @@
import { describe, expectTypeOf, it } from 'vitest'
import { environmentManager } from '..'
import { isServer } from '../environmentManager'
import type { IsServerValue } from '../environmentManager'
describe('environmentManager', () => {
describe('IsServerValue', () => {
it('should take no arguments and return a boolean', () => {
expectTypeOf<IsServerValue>().toEqualTypeOf<() => boolean>()
})
it('should reject an override that returns a non-boolean', () => {
// @ts-expect-error the server check has to answer with a boolean
const invalid: IsServerValue = () => 'server'
expectTypeOf(invalid).toEqualTypeOf<() => boolean>()
})
it('should reject an override that requires an argument', () => {
// @ts-expect-error the server check is called without any argument
const invalid: IsServerValue = (_: string) => true
expectTypeOf(invalid).toEqualTypeOf<() => boolean>()
})
})
describe('isServer', () => {
it('should be a function returning a boolean, not a boolean constant', () => {
// `utils.isServer` is a boolean constant evaluated once at module load,
// while this one re-runs the (overridable) check on every call.
expectTypeOf(isServer).toEqualTypeOf<() => boolean>()
expectTypeOf(isServer()).toEqualTypeOf<boolean>()
})
it('should take no parameters', () => {
expectTypeOf(isServer).parameters.toEqualTypeOf<[]>()
})
})
describe('environmentManager', () => {
it('should expose exactly isServer and setIsServer', () => {
expectTypeOf<keyof typeof environmentManager>().toEqualTypeOf<
'isServer' | 'setIsServer'
>()
})
it('should expose isServer as the standalone check', () => {
expectTypeOf(environmentManager.isServer).toEqualTypeOf<() => boolean>()
})
describe('setIsServer', () => {
it('should only accept an IsServerValue and return void', () => {
expectTypeOf(environmentManager.setIsServer).parameters.toEqualTypeOf<
[isServerValue: IsServerValue]
>()
expectTypeOf(
environmentManager.setIsServer,
).returns.toEqualTypeOf<void>()
})
})
})
})

View File

@@ -0,0 +1,29 @@
import { afterEach, describe, expect, it } from 'vitest'
import { environmentManager, isServer } from '..'
describe('environmentManager', () => {
afterEach(() => {
environmentManager.setIsServer(() => isServer)
})
it('should use the default isServer detection', () => {
expect(environmentManager.isServer()).toBe(isServer)
})
it('should allow overriding isServer globally', () => {
environmentManager.setIsServer(() => true)
expect(environmentManager.isServer()).toBe(true)
environmentManager.setIsServer(() => false)
expect(environmentManager.isServer()).toBe(false)
})
it('should allow overriding isServer with a function', () => {
let server = true
environmentManager.setIsServer(() => server)
expect(environmentManager.isServer()).toBe(true)
server = false
expect(environmentManager.isServer()).toBe(false)
})
})

View File

@@ -0,0 +1,108 @@
import { describe, expectTypeOf, it } from 'vitest'
import { focusManager } from '..'
import type { FocusManager } from '..'
describe('focusManager', () => {
describe('FocusManager', () => {
it('should type the singleton as a FocusManager', () => {
expectTypeOf(focusManager).toEqualTypeOf<FocusManager>()
})
it('should only expose its documented members', () => {
expectTypeOf<keyof FocusManager>().toEqualTypeOf<
| 'subscribe'
| 'hasListeners'
| 'setEventListener'
| 'setFocused'
| 'onFocus'
| 'isFocused'
>()
})
})
describe('subscribe', () => {
it('should take a listener of the focus state and return an unsubscribe function', () => {
expectTypeOf(focusManager.subscribe).parameters.toEqualTypeOf<
[listener: (focused: boolean) => void]
>()
expectTypeOf(focusManager.subscribe).returns.toEqualTypeOf<() => void>()
})
it('should reject a listener that takes a non-boolean focus state', () => {
// @ts-expect-error the focus state handed to a listener is a boolean
const unsubscribe = focusManager.subscribe((focused: string) => focused)
expectTypeOf(unsubscribe).toEqualTypeOf<() => void>()
})
})
describe('hasListeners', () => {
it('should take no arguments and return a boolean', () => {
expectTypeOf(focusManager.hasListeners).parameters.toEqualTypeOf<[]>()
expectTypeOf(focusManager.hasListeners).returns.toEqualTypeOf<boolean>()
})
})
describe('setEventListener', () => {
it('should only accept a setup function and return void', () => {
expectTypeOf(focusManager.setEventListener).parameters.toEqualTypeOf<
[
setup: (
setFocused: (focused?: boolean) => void,
) => (() => void) | undefined,
]
>()
expectTypeOf(focusManager.setEventListener).returns.toEqualTypeOf<void>()
})
it('should hand the setup function a setFocused callback whose argument is optional', () => {
// Unlike `onlineManager`, the focus callback may be called with no
// argument at all to re-evaluate the current focus state.
type Setup = Parameters<FocusManager['setEventListener']>[0]
expectTypeOf<Parameters<Setup>[0]>().toEqualTypeOf<
(focused?: boolean) => void
>()
expectTypeOf<Parameters<Parameters<Setup>[0]>['length']>().toEqualTypeOf<
0 | 1
>()
})
it('should let the setup function opt out of returning a cleanup function', () => {
type Setup = Parameters<FocusManager['setEventListener']>[0]
expectTypeOf<ReturnType<Setup>>().toEqualTypeOf<
(() => void) | undefined
>()
})
})
describe('setFocused', () => {
it('should take an optional boolean and return void', () => {
expectTypeOf(focusManager.setFocused).parameters.toEqualTypeOf<
[focused?: boolean]
>()
expectTypeOf(focusManager.setFocused).returns.toEqualTypeOf<void>()
})
it('should be callable with no argument to fall back to the default check', () => {
expectTypeOf<
Parameters<FocusManager['setFocused']>['length']
>().toEqualTypeOf<0 | 1>()
})
})
describe('onFocus', () => {
it('should take no arguments and return void', () => {
expectTypeOf(focusManager.onFocus).parameters.toEqualTypeOf<[]>()
expectTypeOf(focusManager.onFocus).returns.toEqualTypeOf<void>()
})
})
describe('isFocused', () => {
it('should take no arguments and return a boolean', () => {
expectTypeOf(focusManager.isFocused).parameters.toEqualTypeOf<[]>()
expectTypeOf(focusManager.isFocused).returns.toEqualTypeOf<boolean>()
})
})
})

View File

@@ -0,0 +1,175 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { FocusManager } from '../focusManager'
describe('focusManager', () => {
let focusManager: FocusManager
beforeEach(() => {
vi.useFakeTimers()
vi.resetModules()
focusManager = new FocusManager()
})
afterEach(() => {
vi.useRealTimers()
})
it('should call previous remove handler when replacing an event listener', () => {
const remove1Spy = vi.fn()
const remove2Spy = vi.fn()
focusManager.setEventListener(() => remove1Spy)
focusManager.setEventListener(() => remove2Spy)
expect(remove1Spy).toHaveBeenCalledTimes(1)
expect(remove2Spy).not.toHaveBeenCalled()
})
it('should use focused boolean arg', () => {
let count = 0
const setup = (setFocused: (focused?: boolean) => void) => {
setTimeout(() => {
count++
setFocused(true)
}, 20)
return () => void 0
}
focusManager.setEventListener(setup)
vi.advanceTimersByTime(20)
expect(count).toEqual(1)
expect(focusManager.isFocused()).toBe(true)
})
it('should return true for isFocused if document is undefined', () => {
const { document } = globalThis
// @ts-expect-error
delete globalThis.document
focusManager.setFocused()
expect(focusManager.isFocused()).toBe(true)
globalThis.document = document
})
it('cleanup (removeEventListener) should not be called if window is not defined', () => {
const windowSpy = vi.spyOn(globalThis, 'window', 'get')
windowSpy.mockImplementation(
() => undefined as unknown as Window & typeof globalThis,
)
const removeEventListenerSpy = vi.spyOn(globalThis, 'removeEventListener')
const subscribe = () => focusManager.subscribe(() => undefined)
let firstUnsubscribe: (() => void) | undefined
expect(() => {
firstUnsubscribe = subscribe()
}).not.toThrow()
const secondUnsubscribe = subscribe()
firstUnsubscribe?.()
secondUnsubscribe()
expect(removeEventListenerSpy).not.toHaveBeenCalled()
windowSpy.mockRestore()
})
it('cleanup (removeEventListener) should not be called if window.addEventListener is not defined', () => {
const { addEventListener } = globalThis.window
// @ts-expect-error
globalThis.window.addEventListener = undefined
const removeEventListenerSpy = vi.spyOn(globalThis, 'removeEventListener')
const unsubscribe = focusManager.subscribe(() => undefined)
unsubscribe()
expect(removeEventListenerSpy).not.toHaveBeenCalled()
globalThis.window.addEventListener = addEventListener
})
it('should replace default window listener when a new event listener is set', () => {
const unsubscribeSpy = vi.fn().mockImplementation(() => undefined)
const handlerSpy = vi.fn().mockImplementation(() => unsubscribeSpy)
focusManager.setEventListener(() => handlerSpy())
const unsubscribe = focusManager.subscribe(() => undefined)
// Should call the custom event once
expect(handlerSpy).toHaveBeenCalledTimes(1)
unsubscribe()
// Should unsubscribe our event listener once
expect(unsubscribeSpy).toHaveBeenCalledTimes(1)
handlerSpy.mockRestore()
unsubscribeSpy.mockRestore()
})
it('should call removeEventListener when last listener unsubscribes', () => {
const addEventListenerSpy = vi.spyOn(globalThis.window, 'addEventListener')
const removeEventListenerSpy = vi.spyOn(
globalThis.window,
'removeEventListener',
)
const unsubscribe1 = focusManager.subscribe(() => undefined)
const unsubscribe2 = focusManager.subscribe(() => undefined)
expect(addEventListenerSpy).toHaveBeenCalledTimes(1) // visibilitychange event
unsubscribe1()
expect(removeEventListenerSpy).toHaveBeenCalledTimes(0)
unsubscribe2()
expect(removeEventListenerSpy).toHaveBeenCalledTimes(1) // visibilitychange event
})
it('should keep setup function even if last listener unsubscribes', () => {
const setupSpy = vi.fn().mockImplementation(() => () => undefined)
focusManager.setEventListener(setupSpy)
const unsubscribe1 = focusManager.subscribe(() => undefined)
expect(setupSpy).toHaveBeenCalledTimes(1)
unsubscribe1()
const unsubscribe2 = focusManager.subscribe(() => undefined)
expect(setupSpy).toHaveBeenCalledTimes(2)
unsubscribe2()
})
it('should call listeners when setFocused is called', () => {
const listener = vi.fn()
focusManager.subscribe(listener)
focusManager.setFocused(true)
focusManager.setFocused(true)
expect(listener).toHaveBeenCalledTimes(1)
expect(listener).toHaveBeenNthCalledWith(1, true)
focusManager.setFocused(false)
focusManager.setFocused(false)
expect(listener).toHaveBeenCalledTimes(2)
expect(listener).toHaveBeenNthCalledWith(2, false)
focusManager.setFocused(undefined)
focusManager.setFocused(undefined)
expect(listener).toHaveBeenCalledTimes(3)
expect(listener).toHaveBeenNthCalledWith(3, true)
})
})

View File

@@ -0,0 +1,531 @@
import { beforeEach, describe, expectTypeOf, it } from 'vitest'
import {
QueryClient,
defaultShouldDehydrateMutation,
defaultShouldDehydrateQuery,
dehydrate,
dehydrateQuery,
hydrate,
} from '..'
import type {
DefaultError,
DehydrateOptions,
DehydratedState,
HydrateOptions,
Mutation,
MutationKey,
MutationMeta,
MutationOptions,
MutationScope,
MutationState,
Query,
QueryKey,
QueryMeta,
QueryOptions,
QueryState,
} from '..'
type DehydratedQuery = DehydratedState['queries'][number]
type DehydratedMutation = DehydratedState['mutations'][number]
describe('hydration', () => {
let queryClient: QueryClient
beforeEach(() => {
queryClient = new QueryClient()
})
describe('dehydrate', () => {
it('should take a query client and return a dehydrated state', () => {
expectTypeOf(dehydrate(queryClient)).toEqualTypeOf<DehydratedState>()
})
it('should require a query client', () => {
// @ts-expect-error the query client is required
dehydrate()
expectTypeOf(dehydrate(queryClient)).toEqualTypeOf<DehydratedState>()
})
it('should reject a value that is not a query client', () => {
// @ts-expect-error a plain object is not a query client
dehydrate({})
expectTypeOf(dehydrate).parameter(0).toEqualTypeOf<QueryClient>()
})
it('should take optional dehydrate options as its second parameter', () => {
expectTypeOf(dehydrate)
.parameter(1)
.toEqualTypeOf<DehydrateOptions | undefined>()
expectTypeOf(dehydrate(queryClient, {})).toEqualTypeOf<DehydratedState>()
})
it('should take no more than two parameters', () => {
// @ts-expect-error there is no third parameter
dehydrate(queryClient, {}, 'extra')
expectTypeOf(dehydrate).parameters.toEqualTypeOf<
[client: QueryClient, options?: DehydrateOptions]
>()
})
it('should reject an unknown dehydrate option', () => {
dehydrate(queryClient, {
// @ts-expect-error 'shouldDehydrateAll' is not a dehydrate option
shouldDehydrateAll: true,
})
expectTypeOf(
dehydrate(queryClient, { shouldDehydrateQuery: () => true }),
).toEqualTypeOf<DehydratedState>()
})
})
describe('hydrate', () => {
it('should take a query client and a dehydrated state and return void', () => {
expectTypeOf(
hydrate(queryClient, { queries: [], mutations: [] }),
).toEqualTypeOf<void>()
})
it('should accept a partial dehydrated state', () => {
expectTypeOf(hydrate(queryClient, {})).toEqualTypeOf<void>()
expectTypeOf(hydrate(queryClient, { queries: [] })).toEqualTypeOf<void>()
expectTypeOf(
hydrate(queryClient, { mutations: [] }),
).toEqualTypeOf<void>()
expectTypeOf(hydrate)
.parameter(1)
.toEqualTypeOf<Partial<DehydratedState>>()
})
it('should require the dehydrated state', () => {
// @ts-expect-error the dehydrated state is required
hydrate(queryClient)
expectTypeOf(hydrate(queryClient, {})).toEqualTypeOf<void>()
})
it('should take optional hydrate options as its third parameter', () => {
expectTypeOf(hydrate)
.parameter(2)
.toEqualTypeOf<HydrateOptions | undefined>()
expectTypeOf(
hydrate(queryClient, {}, { defaultOptions: {} }),
).toEqualTypeOf<void>()
})
it('should take no more than three parameters', () => {
// @ts-expect-error there is no fourth parameter
hydrate(queryClient, {}, {}, 'extra')
expectTypeOf(hydrate).parameters.toEqualTypeOf<
[
client: QueryClient,
dehydratedState: Partial<DehydratedState>,
options?: HydrateOptions,
]
>()
})
it('should reject a dehydrated state with an unknown key', () => {
hydrate(queryClient, {
// @ts-expect-error 'infiniteQueries' is not part of a dehydrated state
infiniteQueries: [],
})
expectTypeOf(hydrate(queryClient, { queries: [] })).toEqualTypeOf<void>()
})
})
describe('dehydrateQuery', () => {
it('should take a query and return a dehydrated query', () => {
expectTypeOf(dehydrateQuery).parameter(0).toEqualTypeOf<Query>()
expectTypeOf(dehydrateQuery).returns.toEqualTypeOf<DehydratedQuery>()
})
it('should take an optional serializeData transform', () => {
expectTypeOf(dehydrateQuery)
.parameter(1)
.toEqualTypeOf<((data: any) => any) | undefined>()
})
it('should take an optional shouldRedactErrors predicate', () => {
expectTypeOf(dehydrateQuery)
.parameter(2)
.toEqualTypeOf<((error: unknown) => boolean) | undefined>()
})
it('should require the query', () => {
// @ts-expect-error the query is required
dehydrateQuery()
expectTypeOf(dehydrateQuery).parameters.toEqualTypeOf<
[
query: Query,
serializeData?: (data: any) => any,
shouldRedactErrors?: (error: unknown) => boolean,
]
>()
})
})
describe('DehydratedState', () => {
it('should have queries and mutations arrays', () => {
expectTypeOf<DehydratedState>().toEqualTypeOf<{
mutations: Array<DehydratedMutation>
queries: Array<DehydratedQuery>
}>()
})
it('should require both of its keys', () => {
// @ts-expect-error mutations is required
const missing: DehydratedState = { queries: [] }
expectTypeOf(missing).toEqualTypeOf<DehydratedState>()
expectTypeOf<DehydratedState>().toHaveProperty('queries')
expectTypeOf<DehydratedState>().toHaveProperty('mutations')
// neither key may be optional
expectTypeOf<
keyof {
[K in keyof DehydratedState as {} extends Pick<DehydratedState, K>
? K
: never]: true
}
>().toEqualTypeOf<never>()
})
})
describe('DehydratedQuery', () => {
it('should keep every property writable', () => {
expectTypeOf<DehydratedQuery>().toEqualTypeOf<{
-readonly [K in keyof DehydratedQuery]: DehydratedQuery[K]
}>()
})
describe('queryHash', () => {
it('should be a required string', () => {
expectTypeOf<DehydratedQuery['queryHash']>().toEqualTypeOf<string>()
})
})
describe('queryKey', () => {
it('should be a required query key', () => {
expectTypeOf<DehydratedQuery['queryKey']>().toEqualTypeOf<QueryKey>()
})
})
describe('state', () => {
it('should be a required query state', () => {
expectTypeOf<DehydratedQuery['state']>().toEqualTypeOf<QueryState>()
})
})
describe('dehydratedAt', () => {
it('should be a required number', () => {
expectTypeOf<DehydratedQuery['dehydratedAt']>().toEqualTypeOf<number>()
})
})
describe('promise', () => {
it('should be an optional promise of unknown', () => {
expectTypeOf<DehydratedQuery['promise']>().toEqualTypeOf<
Promise<unknown> | undefined
>()
})
})
describe('meta', () => {
it('should be optional query meta', () => {
expectTypeOf<DehydratedQuery['meta']>().toEqualTypeOf<
QueryMeta | undefined
>()
})
})
describe('queryType', () => {
it('should only ever be infinite', () => {
expectTypeOf<DehydratedQuery['queryType']>().toEqualTypeOf<
'infinite' | undefined
>()
})
it('should reject any other query type', () => {
const query = {} as DehydratedQuery
// @ts-expect-error 'paginated' is not a dehydrated query type
const invalid: typeof query.queryType = 'paginated'
expectTypeOf(invalid).toEqualTypeOf<'infinite' | undefined>()
})
})
it('should only expose its documented members', () => {
expectTypeOf<keyof DehydratedQuery>().toEqualTypeOf<
| 'queryHash'
| 'queryKey'
| 'state'
| 'dehydratedAt'
| 'promise'
| 'meta'
| 'queryType'
>()
})
})
describe('DehydratedMutation', () => {
it('should keep every property writable', () => {
expectTypeOf<DehydratedMutation>().toEqualTypeOf<{
-readonly [K in keyof DehydratedMutation]: DehydratedMutation[K]
}>()
})
describe('mutationKey', () => {
it('should be an optional mutation key', () => {
expectTypeOf<DehydratedMutation['mutationKey']>().toEqualTypeOf<
MutationKey | undefined
>()
})
})
describe('state', () => {
it('should be a required mutation state', () => {
expectTypeOf<
DehydratedMutation['state']
>().toEqualTypeOf<MutationState>()
})
})
describe('meta', () => {
it('should be optional mutation meta', () => {
expectTypeOf<DehydratedMutation['meta']>().toEqualTypeOf<
MutationMeta | undefined
>()
})
})
describe('scope', () => {
it('should be an optional mutation scope', () => {
expectTypeOf<DehydratedMutation['scope']>().toEqualTypeOf<
MutationScope | undefined
>()
})
})
it('should only expose its documented members', () => {
expectTypeOf<keyof DehydratedMutation>().toEqualTypeOf<
'mutationKey' | 'state' | 'meta' | 'scope'
>()
})
})
describe('DehydrateOptions', () => {
it('should keep every option writable', () => {
expectTypeOf<DehydrateOptions>().toEqualTypeOf<{
-readonly [K in keyof DehydrateOptions]: DehydrateOptions[K]
}>()
})
describe('serializeData', () => {
it('should be an optional transform', () => {
expectTypeOf<DehydrateOptions['serializeData']>().toEqualTypeOf<
((data: any) => any) | undefined
>()
})
})
describe('shouldDehydrateMutation', () => {
it('should take a mutation and return a boolean', () => {
expectTypeOf<
DehydrateOptions['shouldDehydrateMutation']
>().toEqualTypeOf<((mutation: Mutation) => boolean) | undefined>()
})
it('should type the mutation it receives', () => {
dehydrate(queryClient, {
shouldDehydrateMutation: (mutation) => {
expectTypeOf(mutation).toEqualTypeOf<Mutation>()
expectTypeOf(mutation.state).toEqualTypeOf<MutationState>()
return true
},
})
})
it('should only accept a boolean from the predicate', () => {
dehydrate(queryClient, {
// @ts-expect-error the predicate must return a boolean
shouldDehydrateMutation: () => 'yes',
})
expectTypeOf(
dehydrate(queryClient, { shouldDehydrateMutation: () => false }),
).toEqualTypeOf<DehydratedState>()
})
})
describe('shouldDehydrateQuery', () => {
it('should take a query and return a boolean', () => {
expectTypeOf<DehydrateOptions['shouldDehydrateQuery']>().toEqualTypeOf<
((query: Query) => boolean) | undefined
>()
})
it('should type the query it receives', () => {
dehydrate(queryClient, {
shouldDehydrateQuery: (query) => {
expectTypeOf(query).toEqualTypeOf<Query>()
expectTypeOf(query.queryKey).toEqualTypeOf<QueryKey>()
expectTypeOf(query.queryHash).toEqualTypeOf<string>()
return true
},
})
})
it('should only accept a boolean from the predicate', () => {
dehydrate(queryClient, {
// @ts-expect-error the predicate must return a boolean
shouldDehydrateQuery: () => 1,
})
expectTypeOf(
dehydrate(queryClient, { shouldDehydrateQuery: () => true }),
).toEqualTypeOf<DehydratedState>()
})
})
describe('shouldRedactErrors', () => {
it('should take an unknown error and return a boolean', () => {
expectTypeOf<DehydrateOptions['shouldRedactErrors']>().toEqualTypeOf<
((error: unknown) => boolean) | undefined
>()
})
it('should type the error it receives as unknown', () => {
dehydrate(queryClient, {
shouldRedactErrors: (error) => {
expectTypeOf(error).toEqualTypeOf<unknown>()
return false
},
})
})
})
it('should only expose its documented options', () => {
expectTypeOf<keyof DehydrateOptions>().toEqualTypeOf<
| 'serializeData'
| 'shouldDehydrateMutation'
| 'shouldDehydrateQuery'
| 'shouldRedactErrors'
>()
})
})
describe('HydrateOptions', () => {
describe('defaultOptions', () => {
it('should be optional', () => {
expectTypeOf<HydrateOptions>().toEqualTypeOf<{
defaultOptions?: {
deserializeData?: (data: any) => any
queries?: QueryOptions
mutations?: MutationOptions<unknown, DefaultError, unknown, unknown>
}
}>()
})
})
describe('deserializeData', () => {
it('should be an optional transform', () => {
expectTypeOf<
NonNullable<HydrateOptions['defaultOptions']>['deserializeData']
>().toEqualTypeOf<((data: any) => any) | undefined>()
})
})
describe('queries', () => {
it('should be optional query options', () => {
expectTypeOf<
NonNullable<HydrateOptions['defaultOptions']>['queries']
>().toEqualTypeOf<QueryOptions | undefined>()
})
})
describe('mutations', () => {
it('should be optional mutation options with unknown data and variables', () => {
expectTypeOf<
NonNullable<HydrateOptions['defaultOptions']>['mutations']
>().toEqualTypeOf<
MutationOptions<unknown, DefaultError, unknown, unknown> | undefined
>()
})
})
it('should reject an unknown hydrate option', () => {
hydrate(
queryClient,
{},
{
// @ts-expect-error 'defaults' is not a hydrate option
defaults: {},
},
)
expectTypeOf(
hydrate(queryClient, {}, { defaultOptions: { queries: {} } }),
).toEqualTypeOf<void>()
})
})
describe('defaultShouldDehydrateQuery', () => {
it('should take a query and return a boolean', () => {
expectTypeOf(defaultShouldDehydrateQuery).toEqualTypeOf<
(query: Query) => boolean
>()
})
it('should be assignable to the shouldDehydrateQuery option', () => {
expectTypeOf(
dehydrate(queryClient, {
shouldDehydrateQuery: defaultShouldDehydrateQuery,
}),
).toEqualTypeOf<DehydratedState>()
})
it('should require the query', () => {
// @ts-expect-error the query is required
defaultShouldDehydrateQuery()
expectTypeOf(
defaultShouldDehydrateQuery({} as Query),
).toEqualTypeOf<boolean>()
})
})
describe('defaultShouldDehydrateMutation', () => {
it('should take a mutation and return a boolean', () => {
expectTypeOf(defaultShouldDehydrateMutation).toEqualTypeOf<
(mutation: Mutation) => boolean
>()
})
it('should be assignable to the shouldDehydrateMutation option', () => {
expectTypeOf(
dehydrate(queryClient, {
shouldDehydrateMutation: defaultShouldDehydrateMutation,
}),
).toEqualTypeOf<DehydratedState>()
})
it('should require the mutation', () => {
// @ts-expect-error the mutation is required
defaultShouldDehydrateMutation()
expectTypeOf(
defaultShouldDehydrateMutation({} as Mutation),
).toEqualTypeOf<boolean>()
})
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,533 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { queryKey, sleep } from '@tanstack/query-test-utils'
import { CancelledError, InfiniteQueryObserver, QueryClient } from '..'
import { infiniteQueryBehavior } from '../infiniteQueryBehavior'
import type { InfiniteData, InfiniteQueryObserverResult, QueryCache } from '..'
describe('InfiniteQueryBehavior', () => {
let queryClient: QueryClient
let queryCache: QueryCache
beforeEach(() => {
vi.useFakeTimers()
queryClient = new QueryClient()
queryCache = queryClient.getQueryCache()
queryClient.mount()
})
afterEach(() => {
queryClient.clear()
vi.useRealTimers()
})
it('should throw an error if the queryFn is not defined', async () => {
const key = queryKey()
const observer = new InfiniteQueryObserver(queryClient, {
queryKey: key,
retry: false,
initialPageParam: 1,
getNextPageParam: () => 2,
})
let observerResult:
| InfiniteQueryObserverResult<unknown, unknown>
| undefined
const unsubscribe = observer.subscribe((result) => {
observerResult = result
})
await vi.advanceTimersByTimeAsync(0)
const query = queryCache.find({ queryKey: key })!
expect(observerResult).toMatchObject({
isError: true,
error: new Error(`Missing queryFn: '${query.queryHash}'`),
})
unsubscribe()
})
it('should apply the maxPages option to limit the number of pages', async () => {
const key = queryKey()
let abortSignal: AbortSignal | null = null
const queryFnSpy = vi.fn().mockImplementation(({ pageParam, signal }) => {
abortSignal = signal
return pageParam
})
const observer = new InfiniteQueryObserver<number>(queryClient, {
queryKey: key,
queryFn: queryFnSpy,
getNextPageParam: (lastPage) => lastPage + 1,
getPreviousPageParam: (firstPage) => firstPage - 1,
maxPages: 2,
initialPageParam: 1,
})
let observerResult:
| InfiniteQueryObserverResult<unknown, unknown>
| undefined
const unsubscribe = observer.subscribe((result) => {
observerResult = result
})
// Wait for the first page to be fetched
await vi.advanceTimersByTimeAsync(0)
expect(observerResult).toMatchObject({
isFetching: false,
data: { pages: [1], pageParams: [1] },
})
expect(queryFnSpy).toHaveBeenNthCalledWith(1, {
queryKey: key,
client: queryClient,
pageParam: 1,
meta: undefined,
direction: 'forward',
signal: abortSignal,
})
queryFnSpy.mockClear()
// Fetch the second page
await observer.fetchNextPage()
expect(queryFnSpy).toHaveBeenNthCalledWith(1, {
queryKey: key,
client: queryClient,
pageParam: 2,
direction: 'forward',
meta: undefined,
signal: abortSignal,
})
expect(observerResult).toMatchObject({
isFetching: false,
data: { pages: [1, 2], pageParams: [1, 2] },
})
queryFnSpy.mockClear()
// Fetch the page before the first page
await observer.fetchPreviousPage()
expect(queryFnSpy).toHaveBeenNthCalledWith(1, {
queryKey: key,
client: queryClient,
pageParam: 0,
direction: 'backward',
meta: undefined,
signal: abortSignal,
})
// Only first two pages should be in the data
expect(observerResult).toMatchObject({
isFetching: false,
data: { pages: [0, 1], pageParams: [0, 1] },
})
queryFnSpy.mockClear()
// Fetch the page before
await observer.fetchPreviousPage()
expect(queryFnSpy).toHaveBeenNthCalledWith(1, {
queryKey: key,
client: queryClient,
pageParam: -1,
meta: undefined,
direction: 'backward',
signal: abortSignal,
})
expect(observerResult).toMatchObject({
isFetching: false,
data: { pages: [-1, 0], pageParams: [-1, 0] },
})
queryFnSpy.mockClear()
// Fetch the page after
await observer.fetchNextPage()
expect(queryFnSpy).toHaveBeenNthCalledWith(1, {
queryKey: key,
client: queryClient,
pageParam: 1,
meta: undefined,
direction: 'forward',
signal: abortSignal,
})
expect(observerResult).toMatchObject({
isFetching: false,
data: { pages: [0, 1] },
})
queryFnSpy.mockClear()
// Refetch the infinite query
await observer.refetch()
// Only 2 pages should refetch
expect(queryFnSpy).toHaveBeenCalledTimes(2)
expect(queryFnSpy).toHaveBeenNthCalledWith(1, {
queryKey: key,
client: queryClient,
pageParam: 0,
meta: undefined,
direction: 'forward',
signal: abortSignal,
})
expect(queryFnSpy).toHaveBeenNthCalledWith(2, {
queryKey: key,
client: queryClient,
pageParam: 1,
meta: undefined,
direction: 'forward',
signal: abortSignal,
})
unsubscribe()
})
it('should support query cancellation', async () => {
const key = queryKey()
let abortSignal: AbortSignal | null = null
const queryFnSpy = vi.fn().mockImplementation(({ pageParam, signal }) => {
abortSignal = signal
sleep(10)
return pageParam
})
const observer = new InfiniteQueryObserver<number>(queryClient, {
queryKey: key,
queryFn: queryFnSpy,
getNextPageParam: (lastPage) => lastPage + 1,
getPreviousPageParam: (firstPage) => firstPage - 1,
initialPageParam: 1,
})
let observerResult:
| InfiniteQueryObserverResult<unknown, unknown>
| undefined
const unsubscribe = observer.subscribe((result) => {
observerResult = result
})
const query = observer.getCurrentQuery()
query.cancel()
// Wait for the first page to be cancelled
await vi.advanceTimersByTimeAsync(0)
expect(observerResult).toMatchObject({
isFetching: false,
isError: true,
error: new CancelledError(),
data: undefined,
})
expect(queryFnSpy).toHaveBeenCalledTimes(1)
expect(queryFnSpy).toHaveBeenNthCalledWith(1, {
queryKey: key,
client: queryClient,
pageParam: 1,
meta: undefined,
direction: 'forward',
signal: abortSignal,
})
unsubscribe()
})
it('should not refetch pages if the query is cancelled', async () => {
const key = queryKey()
let abortSignal: AbortSignal | null = null
let queryFnSpy = vi.fn().mockImplementation(({ pageParam, signal }) => {
abortSignal = signal
return pageParam
})
const observer = new InfiniteQueryObserver<number>(queryClient, {
queryKey: key,
queryFn: queryFnSpy,
getNextPageParam: (lastPage) => lastPage + 1,
getPreviousPageParam: (firstPage) => firstPage - 1,
initialPageParam: 1,
})
let observerResult:
| InfiniteQueryObserverResult<unknown, unknown>
| undefined
const unsubscribe = observer.subscribe((result) => {
observerResult = result
})
// Wait for the first page to be fetched
await vi.advanceTimersByTimeAsync(0)
expect(observerResult).toMatchObject({
isFetching: false,
data: { pages: [1], pageParams: [1] },
})
queryFnSpy.mockClear()
// Fetch the second page
await observer.fetchNextPage()
expect(observerResult).toMatchObject({
isFetching: false,
data: { pages: [1, 2], pageParams: [1, 2] },
})
expect(queryFnSpy).toHaveBeenCalledTimes(1)
expect(queryFnSpy).toHaveBeenNthCalledWith(1, {
queryKey: key,
client: queryClient,
pageParam: 2,
meta: undefined,
direction: 'forward',
signal: abortSignal,
})
queryFnSpy = vi.fn().mockImplementation(({ pageParam = 1, signal }) => {
abortSignal = signal
sleep(10)
return pageParam
})
// Refetch the query
observer.refetch()
expect(observerResult).toMatchObject({
isFetching: true,
isError: false,
})
// Cancel the query
const query = observer.getCurrentQuery()
await query.cancel()
vi.advanceTimersByTime(10)
expect(observerResult).toMatchObject({
isFetching: false,
isError: true,
error: new CancelledError(),
data: { pages: [1, 2], pageParams: [1, 2] },
})
// Pages should not have been fetched
expect(queryFnSpy).toHaveBeenCalledTimes(0)
unsubscribe()
})
it('should surface the abort reason when cancellation happens between refetched pages', async () => {
const key = queryKey()
const abortController = new AbortController()
const queryFn = vi.fn().mockImplementation(({ pageParam, signal }) => {
void signal.aborted
if (pageParam === 1) {
abortController.abort()
}
return pageParam
})
const behavior = infiniteQueryBehavior<number, Error, number, number>()
const context = {
client: queryClient,
queryKey: key,
fetchOptions: undefined,
options: {
queryKey: key,
queryFn,
initialPageParam: 1,
getNextPageParam: (lastPage: number) => lastPage + 1,
},
state: {
data: {
pages: [1, 2],
pageParams: [1, 2],
},
},
fetchFn: () => Promise.resolve(),
signal: abortController.signal,
}
behavior.onFetch(context as any, {} as any)
await expect(context.fetchFn()).rejects.toBe(abortController.signal.reason)
expect(queryFn).toHaveBeenCalledTimes(1)
})
it('should not enter an infinite loop when a page errors while retry is on #8046', async () => {
let errorCount = 0
const key = queryKey()
interface TestResponse {
data: Array<{ id: string }>
nextToken?: number
}
const fakeData = [
{ data: [{ id: 'item-1' }], nextToken: 1 },
{ data: [{ id: 'item-2' }], nextToken: 2 },
{ data: [{ id: 'item-3' }], nextToken: 3 },
{ data: [{ id: 'item-4' }] },
]
const fetchData = async ({ nextToken = 0 }: { nextToken?: number }) =>
new Promise<TestResponse>((resolve, reject) => {
setTimeout(() => {
if (nextToken == 2 && errorCount < 3) {
errorCount += 1
reject({ statusCode: 429 })
return
}
resolve(fakeData[nextToken] as TestResponse)
}, 10)
})
const observer = new InfiniteQueryObserver<
TestResponse,
Error,
InfiniteData<TestResponse>,
typeof key,
number
>(queryClient, {
retry: 5,
staleTime: 0,
retryDelay: 10,
queryKey: key,
initialPageParam: 1,
getNextPageParam: (lastPage) => lastPage.nextToken,
queryFn: ({ pageParam }) => fetchData({ nextToken: pageParam }),
})
// Fetch Page 1
const fetchPage1Promise = observer.fetchNextPage()
await vi.advanceTimersByTimeAsync(10)
const page1Data = await fetchPage1Promise
expect(page1Data.data?.pageParams).toEqual([1])
// Fetch Page 2, as per the queryFn, this will reject 2 times then resolves
const fetchPage2Promise = observer.fetchNextPage()
await vi.advanceTimersByTimeAsync(70)
const page2Data = await fetchPage2Promise
expect(page2Data.data?.pageParams).toEqual([1, 2])
// Fetch Page 3
const fetchPage3Promise = observer.fetchNextPage()
await vi.advanceTimersByTimeAsync(10)
const page3Data = await fetchPage3Promise
expect(page3Data.data?.pageParams).toEqual([1, 2, 3])
// Now the real deal; re-fetching this query **should not** stamp into an
// infinite loop where the retryer every time restarts from page 1
// once it reaches the page where it errors.
// For this to work, we'd need to reset the error count so we actually retry
errorCount = 0
const reFetchPromise = observer.fetchNextPage()
await vi.advanceTimersByTimeAsync(10)
const reFetchedData = await reFetchPromise
expect(reFetchedData.data?.pageParams).toEqual([1, 2, 3])
})
it('should fetch even if initialPageParam is null', async () => {
const key = queryKey()
const observer = new InfiniteQueryObserver(queryClient, {
queryKey: key,
queryFn: () => 'data',
getNextPageParam: () => null,
initialPageParam: null,
})
let observerResult:
| InfiniteQueryObserverResult<unknown, unknown>
| undefined
const unsubscribe = observer.subscribe((result) => {
observerResult = result
})
await vi.advanceTimersByTimeAsync(0)
expect(observerResult).toMatchObject({
isFetching: false,
data: { pages: ['data'], pageParams: [null] },
})
unsubscribe()
})
it('should not fetch next page when getNextPageParam returns null', async () => {
const key = queryKey()
const observer = new InfiniteQueryObserver(queryClient, {
queryKey: key,
queryFn: ({ pageParam }) => sleep(0).then(() => pageParam),
getNextPageParam: (lastPage) => (lastPage === 1 ? null : lastPage + 1),
initialPageParam: 1,
})
let observerResult:
| InfiniteQueryObserverResult<InfiniteData<number, unknown>, Error>
| undefined
const unsubscribe = observer.subscribe((result) => {
observerResult = result
})
await vi.advanceTimersByTimeAsync(0)
expect(observerResult).toMatchObject({
isFetching: false,
data: { pages: [1], pageParams: [1] },
})
await observer.fetchNextPage()
expect(observerResult).toMatchObject({
isFetching: false,
data: { pages: [1], pageParams: [1] },
})
unsubscribe()
})
it('should use persister when provided', async () => {
const key = queryKey()
const persisterSpy = vi.fn().mockImplementation(async (fn) => {
return await fn()
})
const observer = new InfiniteQueryObserver(queryClient, {
queryKey: key,
queryFn: ({ pageParam }) => sleep(0).then(() => pageParam),
getNextPageParam: (lastPage) => lastPage + 1,
initialPageParam: 1,
persister: persisterSpy,
})
const unsubscribe = observer.subscribe(() => {})
await vi.advanceTimersByTimeAsync(0)
expect(persisterSpy).toHaveBeenCalledTimes(1)
unsubscribe()
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,252 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { queryKey, sleep } from '@tanstack/query-test-utils'
import { InfiniteQueryObserver, QueryClient } from '..'
import type {
DefaultedInfiniteQueryObserverOptions,
InfiniteData,
} from '../types'
describe('InfiniteQueryObserver', () => {
let queryClient: QueryClient
beforeEach(() => {
vi.useFakeTimers()
queryClient = new QueryClient()
queryClient.mount()
})
afterEach(() => {
queryClient.clear()
vi.useRealTimers()
})
it('should be able to fetch an infinite query with selector', async () => {
const key = queryKey()
const observer = new InfiniteQueryObserver(queryClient, {
queryKey: key,
queryFn: () => sleep(10).then(() => 1),
select: (data) => ({
pages: data.pages.map((x) => `${x}`),
pageParams: data.pageParams,
}),
initialPageParam: 1,
getNextPageParam: () => 2,
})
let observerResult
const unsubscribe = observer.subscribe((result) => {
observerResult = result
})
await vi.advanceTimersByTimeAsync(10)
unsubscribe()
expect(observerResult).toMatchObject({
data: { pages: ['1'], pageParams: [1] },
})
})
it('should pass the meta option to the queryFn', async () => {
const meta = {
it: 'works',
}
const key = queryKey()
const queryFn = vi.fn(() => sleep(10).then(() => 1))
const observer = new InfiniteQueryObserver(queryClient, {
meta,
queryKey: key,
queryFn,
select: (data) => ({
pages: data.pages.map((x) => `${x}`),
pageParams: data.pageParams,
}),
initialPageParam: 1,
getNextPageParam: () => 2,
})
let observerResult
const unsubscribe = observer.subscribe((result) => {
observerResult = result
})
await vi.advanceTimersByTimeAsync(10)
unsubscribe()
expect(observerResult).toMatchObject({
data: { pages: ['1'], pageParams: [1] },
})
expect(queryFn).toHaveBeenCalledWith(expect.objectContaining({ meta }))
})
it('should make getNextPageParam and getPreviousPageParam receive current pageParams', async () => {
const key = queryKey()
let single: Array<string> = []
let all: Array<string> = []
const observer = new InfiniteQueryObserver(queryClient, {
queryKey: key,
queryFn: ({ pageParam }) => sleep(10).then(() => String(pageParam)),
initialPageParam: 1,
getNextPageParam: (_, __, lastPageParam, allPageParams) => {
single.push('next' + lastPageParam)
all.push('next' + allPageParams.join(','))
return lastPageParam + 1
},
getPreviousPageParam: (_, __, firstPageParam, allPageParams) => {
single.push('prev' + firstPageParam)
all.push('prev' + allPageParams.join(','))
return firstPageParam - 1
},
})
await vi.advanceTimersByTimeAsync(10)
observer.fetchNextPage()
await vi.advanceTimersByTimeAsync(10)
observer.fetchPreviousPage()
await vi.advanceTimersByTimeAsync(10)
expect(single).toEqual(['next1', 'prev1', 'prev1', 'next1', 'prev0'])
expect(all).toEqual(['next1', 'prev1', 'prev1', 'next0,1', 'prev0,1'])
single = []
all = []
observer.refetch()
await vi.advanceTimersByTimeAsync(20)
expect(single).toEqual(['next0', 'next1', 'prev0'])
expect(all).toEqual(['next0', 'next0,1', 'prev0,1'])
})
it('should not invoke getNextPageParam and getPreviousPageParam on empty pages', () => {
const key = queryKey()
const getNextPageParam = vi.fn()
const getPreviousPageParam = vi.fn()
const observer = new InfiniteQueryObserver(queryClient, {
queryKey: key,
queryFn: ({ pageParam }) => sleep(10).then(() => String(pageParam)),
initialPageParam: 1,
getNextPageParam: getNextPageParam.mockImplementation(
(_, __, lastPageParam) => lastPageParam + 1,
),
getPreviousPageParam: getPreviousPageParam.mockImplementation(
(_, __, firstPageParam) => firstPageParam - 1,
),
})
const unsubscribe = observer.subscribe(() => {})
getNextPageParam.mockClear()
getPreviousPageParam.mockClear()
queryClient.setQueryData(key, { pages: [], pageParams: [] })
expect(getNextPageParam).toHaveBeenCalledTimes(0)
expect(getPreviousPageParam).toHaveBeenCalledTimes(0)
unsubscribe()
})
it('should stop refetching if undefined is returned from getNextPageParam', async () => {
const key = queryKey()
let next: number | undefined = 2
const queryFn = vi.fn<(...args: Array<any>) => any>(({ pageParam }) =>
sleep(10).then(() => String(pageParam)),
)
const observer = new InfiniteQueryObserver(queryClient, {
queryKey: key,
queryFn,
initialPageParam: 1,
getNextPageParam: () => next,
})
observer.fetchNextPage()
await vi.advanceTimersByTimeAsync(10)
observer.fetchNextPage()
await vi.advanceTimersByTimeAsync(10)
expect(observer.getCurrentResult().data?.pages).toEqual(['1', '2'])
expect(queryFn).toHaveBeenCalledTimes(2)
expect(observer.getCurrentResult().hasNextPage).toBe(true)
next = undefined
observer.refetch()
await vi.advanceTimersByTimeAsync(10)
expect(observer.getCurrentResult().data?.pages).toEqual(['1'])
expect(queryFn).toHaveBeenCalledTimes(3)
expect(observer.getCurrentResult().hasNextPage).toBe(false)
})
it('should stop refetching if null is returned from getNextPageParam', async () => {
const key = queryKey()
let next: number | null = 2
const queryFn = vi.fn<(...args: Array<any>) => any>(({ pageParam }) =>
sleep(10).then(() => String(pageParam)),
)
const observer = new InfiniteQueryObserver(queryClient, {
queryKey: key,
queryFn,
initialPageParam: 1,
getNextPageParam: () => next,
})
observer.fetchNextPage()
await vi.advanceTimersByTimeAsync(10)
observer.fetchNextPage()
await vi.advanceTimersByTimeAsync(10)
expect(observer.getCurrentResult().data?.pages).toEqual(['1', '2'])
expect(queryFn).toHaveBeenCalledTimes(2)
expect(observer.getCurrentResult().hasNextPage).toBe(true)
next = null
observer.refetch()
await vi.advanceTimersByTimeAsync(10)
expect(observer.getCurrentResult().data?.pages).toEqual(['1'])
expect(queryFn).toHaveBeenCalledTimes(3)
expect(observer.getCurrentResult().hasNextPage).toBe(false)
})
it('should set infinite query behavior via getOptimisticResult and return the initial state', () => {
const key = queryKey()
const observer = new InfiniteQueryObserver(queryClient, {
queryKey: key,
queryFn: () => sleep(10).then(() => 1),
initialPageParam: 1,
getNextPageParam: () => 2,
})
const options: DefaultedInfiniteQueryObserverOptions<
number,
Error,
InfiniteData<number>,
typeof key,
number
> = {
queryKey: key,
queryFn: () => sleep(10).then(() => 1),
initialPageParam: 1,
getNextPageParam: () => 2,
throwOnError: true,
refetchOnReconnect: false,
queryHash: key.join(''),
behavior: undefined,
}
const result = observer.getOptimisticResult(options)
expect(options._type).toBe('infinite')
expect(result).toMatchObject({
data: undefined,
hasNextPage: false,
hasPreviousPage: false,
isFetching: false,
isFetchingNextPage: false,
isFetchingPreviousPage: false,
isError: false,
isRefetchError: false,
isRefetching: false,
})
})
})

View File

@@ -0,0 +1,814 @@
import { describe, expectTypeOf, it } from 'vitest'
import { QueryClient } from '../queryClient'
import { MutationCache } from '../mutationCache'
import type { MutationObserver } from '../mutationObserver'
import type { Action, Mutation, MutationState } from '../mutation'
import type {
DefaultError,
MutateFunction,
MutateOptions,
MutationMeta,
MutationOptions,
} from '../types'
class CustomError extends Error {
name = 'CustomError' as const
}
describe('mutation', () => {
describe('MutationState', () => {
it('should keep every property writable', () => {
type State = MutationState<{ value: string }, CustomError, number, string>
expectTypeOf<State>().toEqualTypeOf<{
-readonly [K in keyof State]: State[K]
}>()
})
it('should declare every property as required', () => {
type State = MutationState<{ value: string }, CustomError, number, string>
type OptionalKeys = {
[K in keyof State]-?: {} extends Pick<State, K> ? K : never
}[keyof State]
expectTypeOf<OptionalKeys>().toEqualTypeOf<never>()
})
it('should declare exactly its nine properties', () => {
expectTypeOf<keyof MutationState>().toEqualTypeOf<
| 'context'
| 'data'
| 'error'
| 'failureCount'
| 'failureReason'
| 'isPaused'
| 'status'
| 'variables'
| 'submittedAt'
>()
})
it('should default its type parameters', () => {
expectTypeOf<MutationState>().toEqualTypeOf<
MutationState<unknown, DefaultError, unknown, unknown>
>()
})
describe('context', () => {
it('should be the on-mutate result or undefined', () => {
expectTypeOf<
MutationState<
{ value: string },
CustomError,
number,
string
>['context']
>().toEqualTypeOf<string | undefined>()
})
})
describe('data', () => {
it('should be the data or undefined', () => {
expectTypeOf<
MutationState<{ value: string }, CustomError, number, string>['data']
>().toEqualTypeOf<{ value: string } | undefined>()
})
})
describe('error', () => {
it('should be the error or null', () => {
expectTypeOf<
MutationState<{ value: string }, CustomError, number, string>['error']
>().toEqualTypeOf<CustomError | null>()
})
})
describe('failureCount', () => {
it('should be a number', () => {
expectTypeOf<
MutationState<
{ value: string },
CustomError,
number,
string
>['failureCount']
>().toEqualTypeOf<number>()
})
})
describe('failureReason', () => {
it('should be the error or null', () => {
expectTypeOf<
MutationState<
{ value: string },
CustomError,
number,
string
>['failureReason']
>().toEqualTypeOf<CustomError | null>()
})
})
describe('isPaused', () => {
it('should be a boolean', () => {
expectTypeOf<
MutationState<
{ value: string },
CustomError,
number,
string
>['isPaused']
>().toEqualTypeOf<boolean>()
})
})
describe('status', () => {
it('should spell out every mutation status', () => {
expectTypeOf<
MutationState<
{ value: string },
CustomError,
number,
string
>['status']
>().toEqualTypeOf<'idle' | 'pending' | 'success' | 'error'>()
})
it('should not widen beyond those literals on the default parameters', () => {
expectTypeOf<MutationState['status']>().toEqualTypeOf<
'idle' | 'pending' | 'success' | 'error'
>()
})
})
describe('variables', () => {
it('should be the variables or undefined', () => {
expectTypeOf<
MutationState<
{ value: string },
CustomError,
number,
string
>['variables']
>().toEqualTypeOf<number | undefined>()
})
})
describe('submittedAt', () => {
it('should be a number', () => {
expectTypeOf<
MutationState<
{ value: string },
CustomError,
number,
string
>['submittedAt']
>().toEqualTypeOf<number>()
})
})
})
describe('Mutation', () => {
it('should declare exactly its public members', () => {
expectTypeOf<keyof Mutation>().toEqualTypeOf<
| 'state'
| 'options'
| 'mutationId'
| 'setOptions'
| 'meta'
| 'addObserver'
| 'removeObserver'
| 'continue'
| 'execute'
| 'gcTime'
| 'destroy'
>()
})
it('should default its type parameters', () => {
expectTypeOf<Mutation>().toEqualTypeOf<
Mutation<unknown, DefaultError, unknown, unknown>
>()
})
describe('state', () => {
it('should be the mutation state for the same type parameters', () => {
const mutation = new MutationCache().build<
{ value: string },
CustomError,
number,
string
>(new QueryClient(), {
mutationFn: (variables: number) =>
Promise.resolve({ value: String(variables) }),
})
expectTypeOf(mutation.state).toEqualTypeOf<
MutationState<{ value: string }, CustomError, number, string>
>()
})
it('should stay writable', () => {
// `Pick` preserves the `readonly` modifier that an indexed access such
// as `Mutation<...>['state']` silently strips
type State = Pick<
Mutation<{ value: string }, CustomError, number, string>,
'state'
>
expectTypeOf<State>().toEqualTypeOf<{
-readonly [K in keyof State]: State[K]
}>()
})
})
describe('options', () => {
it('should be the mutation options for the same type parameters', () => {
const mutation = new MutationCache().build<
{ value: string },
CustomError,
number,
string
>(new QueryClient(), {
mutationFn: (variables: number) =>
Promise.resolve({ value: String(variables) }),
})
expectTypeOf(mutation.options).toEqualTypeOf<
MutationOptions<{ value: string }, CustomError, number, string>
>()
})
it('should always be declared, despite being assigned lazily', () => {
type Keys = Mutation<{ value: string }, CustomError, number, string>
type OptionalKeys = {
[K in keyof Keys]-?: {} extends Pick<Keys, K> ? K : never
}[keyof Keys]
expectTypeOf<
'options' extends OptionalKeys ? true : false
>().toEqualTypeOf<false>()
})
})
describe('mutationId', () => {
it('should be a readonly number', () => {
type Id = Pick<Mutation, 'mutationId'>
expectTypeOf<Id>().toEqualTypeOf<{ readonly mutationId: number }>()
expectTypeOf<Id>().not.toEqualTypeOf<{ mutationId: number }>()
})
})
describe('setOptions', () => {
it('should take the mutation options and return nothing', () => {
const mutation = new MutationCache().build<
{ value: string },
CustomError,
number,
string
>(new QueryClient(), {
mutationFn: (variables: number) =>
Promise.resolve({ value: String(variables) }),
})
expectTypeOf(mutation.setOptions)
.parameter(0)
.toEqualTypeOf<
MutationOptions<{ value: string }, CustomError, number, string>
>()
expectTypeOf(mutation.setOptions).returns.toEqualTypeOf<void>()
expectTypeOf(mutation.setOptions).parameters.toEqualTypeOf<
[MutationOptions<{ value: string }, CustomError, number, string>]
>()
})
})
describe('meta', () => {
it('should be the mutation meta or undefined', () => {
const mutation = new MutationCache().build<
{ value: string },
CustomError,
number,
string
>(new QueryClient(), {
mutationFn: (variables: number) =>
Promise.resolve({ value: String(variables) }),
})
expectTypeOf(mutation.meta).toEqualTypeOf<MutationMeta | undefined>()
})
it('should be a getter without a setter', () => {
const mutation = new MutationCache().build<
{ value: string },
CustomError,
number,
string
>(new QueryClient(), {
mutationFn: (variables: number) =>
Promise.resolve({ value: String(variables) }),
})
// @ts-expect-error meta is a getter only
mutation.meta = {}
expectTypeOf<Mutation['meta']>().toEqualTypeOf<
MutationMeta | undefined
>()
})
})
describe('addObserver', () => {
it('should take a single observer and return nothing', () => {
const mutation = new MutationCache().build<
{ value: string },
CustomError,
number,
string
>(new QueryClient(), {
mutationFn: (variables: number) =>
Promise.resolve({ value: String(variables) }),
})
expectTypeOf(mutation.addObserver).returns.toEqualTypeOf<void>()
expectTypeOf(mutation.addObserver).parameters.toEqualTypeOf<
[MutationObserver<any, any, any, any>]
>()
})
})
describe('removeObserver', () => {
it('should take a single observer and return nothing', () => {
const mutation = new MutationCache().build<
{ value: string },
CustomError,
number,
string
>(new QueryClient(), {
mutationFn: (variables: number) =>
Promise.resolve({ value: String(variables) }),
})
expectTypeOf(mutation.removeObserver).returns.toEqualTypeOf<void>()
expectTypeOf(mutation.removeObserver).parameters.toEqualTypeOf<
[MutationObserver<any, any, any, any>]
>()
})
})
describe('continue', () => {
it('should take no arguments and resolve with an unknown value', () => {
const mutation = new MutationCache().build<
{ value: string },
CustomError,
number,
string
>(new QueryClient(), {
mutationFn: (variables: number) =>
Promise.resolve({ value: String(variables) }),
})
expectTypeOf(mutation.continue).parameters.toEqualTypeOf<[]>()
expectTypeOf(mutation.continue()).toEqualTypeOf<Promise<unknown>>()
})
})
describe('execute', () => {
it('should take the variables and resolve with the data', () => {
const mutation = new MutationCache().build<
{ value: string },
CustomError,
number,
string
>(new QueryClient(), {
mutationFn: (variables: number) =>
Promise.resolve({ value: String(variables) }),
})
expectTypeOf(mutation.execute).parameters.toEqualTypeOf<[number]>()
expectTypeOf(mutation.execute(1)).toEqualTypeOf<
Promise<{ value: string }>
>()
})
it('should reject variables of another type', () => {
const mutation = new MutationCache().build<
{ value: string },
CustomError,
number,
string
>(new QueryClient(), {
mutationFn: (variables: number) =>
Promise.resolve({ value: String(variables) }),
})
// @ts-expect-error the variables must match the mutation function
mutation.execute('1')
expectTypeOf(mutation.execute).parameter(0).toEqualTypeOf<number>()
})
})
describe('gcTime', () => {
it('should be a number inherited from Removable', () => {
const mutation = new MutationCache().build<
{ value: string },
CustomError,
number,
string
>(new QueryClient(), {
mutationFn: (variables: number) =>
Promise.resolve({ value: String(variables) }),
})
expectTypeOf(mutation.gcTime).toEqualTypeOf<number>()
})
})
describe('destroy', () => {
it('should take no arguments and return nothing', () => {
const mutation = new MutationCache().build<
{ value: string },
CustomError,
number,
string
>(new QueryClient(), {
mutationFn: (variables: number) =>
Promise.resolve({ value: String(variables) }),
})
expectTypeOf(mutation.destroy).parameters.toEqualTypeOf<[]>()
expectTypeOf(mutation.destroy).returns.toEqualTypeOf<void>()
})
})
})
describe('MutationOptions', () => {
type Options = MutationOptions<
{ value: string },
CustomError,
number,
string
>
describe('retry', () => {
it('should be an optional retry value taking the error', () => {
expectTypeOf<Options['retry']>().toEqualTypeOf<
| boolean
| number
| ((failureCount: number, error: CustomError) => boolean)
| undefined
>()
})
})
describe('retryDelay', () => {
it('should be an optional delay taking the error', () => {
expectTypeOf<Options['retryDelay']>().toEqualTypeOf<
| number
| ((failureCount: number, error: CustomError) => number)
| undefined
>()
})
})
describe('networkMode', () => {
it('should spell out every network mode', () => {
expectTypeOf<Options['networkMode']>().toEqualTypeOf<
'online' | 'always' | 'offlineFirst' | undefined
>()
})
})
describe('gcTime', () => {
it('should be an optional number', () => {
expectTypeOf<Options['gcTime']>().toEqualTypeOf<number | undefined>()
})
})
describe('scope', () => {
it('should be an optional scope carrying a string id', () => {
expectTypeOf<Options['scope']>().toEqualTypeOf<
{ id: string } | undefined
>()
})
})
})
describe('Action', () => {
type MutationAction = Action<{ value: string }, CustomError, number, string>
it('should union exactly its six action type literals', () => {
expectTypeOf<MutationAction['type']>().toEqualTypeOf<
'continue' | 'error' | 'failed' | 'pending' | 'pause' | 'success'
>()
})
describe('failed', () => {
it('should carry the failure count and the nullable error', () => {
type Failed = Extract<MutationAction, { type: 'failed' }>
expectTypeOf<Failed>().toEqualTypeOf<{
type: 'failed'
failureCount: number
error: CustomError | null
}>()
})
})
describe('pause', () => {
it('should carry nothing but its type', () => {
expectTypeOf<
Extract<MutationAction, { type: 'pause' }>
>().toEqualTypeOf<{ type: 'pause' }>()
})
})
describe('continue', () => {
it('should carry nothing but its type', () => {
expectTypeOf<
Extract<MutationAction, { type: 'continue' }>
>().toEqualTypeOf<{ type: 'continue' }>()
})
})
describe('success', () => {
it('should carry the data', () => {
expectTypeOf<
Extract<MutationAction, { type: 'success' }>
>().toEqualTypeOf<{ type: 'success'; data: { value: string } }>()
})
})
describe('error', () => {
it('should carry the error', () => {
expectTypeOf<
Extract<MutationAction, { type: 'error' }>
>().toEqualTypeOf<{ type: 'error'; error: CustomError }>()
})
})
describe('pending', () => {
it('should carry the paused flag, the variables and the on-mutate result', () => {
expectTypeOf<
Extract<MutationAction, { type: 'pending' }>
>().toEqualTypeOf<{
type: 'pending'
isPaused: boolean
variables?: number
context?: string
}>()
})
})
})
describe('MutateFunction', () => {
it('should resolve with the data', () => {
const mutate = {} as MutateFunction<string, CustomError, number, unknown>
expectTypeOf(mutate(1)).toEqualTypeOf<Promise<string>>()
})
it('should default its type parameters', () => {
expectTypeOf<MutateFunction>().toEqualTypeOf<
MutateFunction<unknown, DefaultError, void, unknown>
>()
})
it('should handle void variables', () => {
const mutate = {} as MutateFunction
expectTypeOf<Parameters<typeof mutate>[0]>().toEqualTypeOf<
undefined | void
>()
expectTypeOf<Parameters<typeof mutate>[1]>().toEqualTypeOf<
undefined | MutateOptions<unknown, DefaultError, void, unknown>
>()
mutate() // can be called with no arguments
mutate(undefined, {
onError: (e) => {
expectTypeOf(e).toEqualTypeOf<DefaultError>()
},
})
})
it('should handle optional undefinable variables', () => {
const mutate = {} as MutateFunction<
unknown,
DefaultError,
number | undefined,
unknown
>
expectTypeOf<Parameters<typeof mutate>[0]>().toEqualTypeOf<
number | undefined
>()
expectTypeOf<Parameters<typeof mutate>[1]>().toEqualTypeOf<
| undefined
| MutateOptions<unknown, DefaultError, number | undefined, unknown>
>()
mutate() // can be called with no arguments
mutate(undefined, {
onError: (e) => {
expectTypeOf(e).toEqualTypeOf<DefaultError>()
},
})
})
it('should handle unknown variables', () => {
const mutate = {} as MutateFunction<
unknown,
DefaultError,
unknown,
unknown
>
expectTypeOf<Parameters<typeof mutate>[0]>().toEqualTypeOf<unknown>()
expectTypeOf<Parameters<typeof mutate>[1]>().toEqualTypeOf<
undefined | MutateOptions<unknown, DefaultError, unknown, unknown>
>()
mutate() // can be called with no arguments
})
it('should handle any variables', () => {
const mutate = {} as MutateFunction<unknown, DefaultError, any, unknown>
expectTypeOf<Parameters<typeof mutate>[0]>().toEqualTypeOf<any>()
expectTypeOf<Parameters<typeof mutate>[1]>().toEqualTypeOf<
undefined | MutateOptions<unknown, DefaultError, any, unknown>
>()
mutate() // can be called with no arguments
})
it('should handle void union variables', () => {
const mutate = {} as MutateFunction<
unknown,
DefaultError,
void | string,
unknown
>
expectTypeOf<Parameters<typeof mutate>[0]>().toEqualTypeOf<
void | string | undefined
>()
expectTypeOf<Parameters<typeof mutate>[1]>().toEqualTypeOf<
undefined | MutateOptions<unknown, DefaultError, void | string, unknown>
>()
mutate() // can be called with no arguments
})
it('should handle required non-undefinable variables', () => {
const mutate = {} as MutateFunction<
unknown,
DefaultError,
number,
unknown
>
expectTypeOf<Parameters<typeof mutate>[0]>().toEqualTypeOf<number>()
expectTypeOf<Parameters<typeof mutate>[1]>().toEqualTypeOf<
undefined | MutateOptions<unknown, DefaultError, number, unknown>
>()
// @ts-expect-error --- required variables
mutate()
mutate(123, {
onError: (e) => {
expectTypeOf(e).toEqualTypeOf<DefaultError>()
},
})
})
describe('MutateFunctionRest', () => {
// this is common pattern used internal so we need make sure it still works
it('should handle void variables', () => {
const mutate = {} as (...options: Parameters<MutateFunction>) => void
expectTypeOf<Parameters<typeof mutate>[0]>().toEqualTypeOf<
undefined | void
>()
expectTypeOf<Parameters<typeof mutate>[1]>().toEqualTypeOf<
undefined | MutateOptions<unknown, DefaultError, void, unknown>
>()
mutate() // can be called with no arguments
mutate(undefined, {
onError: (e) => {
expectTypeOf(e).toEqualTypeOf<DefaultError>()
},
})
})
it('should handle optional undefinable variables', () => {
const mutate = {} as (
...options: Parameters<
MutateFunction<unknown, DefaultError, number | undefined, unknown>
>
) => void
expectTypeOf<Parameters<typeof mutate>[0]>().toEqualTypeOf<
number | undefined
>()
expectTypeOf<Parameters<typeof mutate>[1]>().toEqualTypeOf<
| undefined
| MutateOptions<unknown, DefaultError, number | undefined, unknown>
>()
mutate() // can be called with no arguments
mutate(undefined, {
onError: (e) => {
expectTypeOf(e).toEqualTypeOf<DefaultError>()
},
})
})
it('should handle unknown variables', () => {
const mutate = {} as (
...options: Parameters<
MutateFunction<unknown, DefaultError, unknown, unknown>
>
) => void
expectTypeOf<Parameters<typeof mutate>[0]>().toEqualTypeOf<unknown>()
expectTypeOf<Parameters<typeof mutate>[1]>().toEqualTypeOf<
undefined | MutateOptions<unknown, DefaultError, unknown, unknown>
>()
mutate() // can be called with no arguments
})
it('should handle any variables', () => {
const mutate = {} as (
...options: Parameters<
MutateFunction<unknown, DefaultError, any, unknown>
>
) => void
expectTypeOf<Parameters<typeof mutate>[0]>().toEqualTypeOf<any>()
expectTypeOf<Parameters<typeof mutate>[1]>().toEqualTypeOf<
undefined | MutateOptions<unknown, DefaultError, any, unknown>
>()
mutate() // can be called with no arguments
})
it('should handle void union variables', () => {
const mutate = {} as (
...options: Parameters<
MutateFunction<unknown, DefaultError, void | string, unknown>
>
) => void
expectTypeOf<Parameters<typeof mutate>[0]>().toEqualTypeOf<
void | string | undefined
>()
expectTypeOf<Parameters<typeof mutate>[1]>().toEqualTypeOf<
| undefined
| MutateOptions<unknown, DefaultError, void | string, unknown>
>()
mutate() // can be called with no arguments
})
it('should handle required non-undefinable variables', () => {
const mutate = {} as (
...options: Parameters<
MutateFunction<unknown, DefaultError, number, unknown>
>
) => void
expectTypeOf<Parameters<typeof mutate>[0]>().toEqualTypeOf<number>()
expectTypeOf<Parameters<typeof mutate>[1]>().toEqualTypeOf<
undefined | MutateOptions<unknown, DefaultError, number, unknown>
>()
// @ts-expect-error --- required variables
mutate()
mutate(123, {
onError: (e) => {
expectTypeOf(e).toEqualTypeOf<DefaultError>()
},
})
})
})
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,430 @@
import { afterEach, beforeEach, describe, expectTypeOf, it } from 'vitest'
import { MutationCache, QueryClient } from '..'
import type {
DefaultError,
Mutation,
MutationCacheConfig,
MutationCacheNotifyEvent,
MutationFilters,
MutationFunctionContext,
MutationObserver,
MutationState,
} from '..'
class CustomError extends Error {
name = 'CustomError' as const
}
describe('mutationCache', () => {
let queryClient: QueryClient
let mutationCache: MutationCache
beforeEach(() => {
queryClient = new QueryClient()
queryClient.mount()
mutationCache = queryClient.getMutationCache()
})
afterEach(() => {
queryClient.clear()
})
describe('MutationCache', () => {
it('should only expose its documented members', () => {
expectTypeOf<keyof MutationCache>().toEqualTypeOf<
| 'subscribe'
| 'hasListeners'
| 'config'
| 'build'
| 'add'
| 'remove'
| 'canRun'
| 'runNext'
| 'clear'
| 'getAll'
| 'find'
| 'findAll'
| 'notify'
| 'resumePausedMutations'
>()
})
})
describe('config', () => {
it('should be typed as the cache config', () => {
expectTypeOf(mutationCache.config).toEqualTypeOf<MutationCacheConfig>()
expectTypeOf(
new MutationCache().config,
).toEqualTypeOf<MutationCacheConfig>()
})
it('should stay writable', () => {
type Config = Pick<MutationCache, 'config'>
expectTypeOf<Config>().toEqualTypeOf<{
-readonly [K in keyof Config]: Config[K]
}>()
})
it('should be optional when constructing a cache', () => {
expectTypeOf(MutationCache).constructorParameters.toEqualTypeOf<
[config?: MutationCacheConfig]
>()
})
})
describe('MutationCacheConfig', () => {
it('should declare exactly its four callbacks', () => {
expectTypeOf<keyof MutationCacheConfig>().toEqualTypeOf<
'onError' | 'onSuccess' | 'onMutate' | 'onSettled'
>()
})
it('should declare every callback as optional', () => {
type OptionalKeys = {
[K in keyof MutationCacheConfig]-?: {} extends Pick<
MutationCacheConfig,
K
>
? K
: never
}[keyof MutationCacheConfig]
expectTypeOf<OptionalKeys>().toEqualTypeOf<keyof MutationCacheConfig>()
})
describe('onError', () => {
it('should take the error, variables, onMutateResult, mutation and context, and return an unknown value', () => {
expectTypeOf<
NonNullable<MutationCacheConfig['onError']>
>().returns.toEqualTypeOf<unknown>()
expectTypeOf<
NonNullable<MutationCacheConfig['onError']>
>().parameters.toEqualTypeOf<
[
error: DefaultError,
variables: unknown,
onMutateResult: unknown,
mutation: Mutation<unknown, unknown, unknown>,
context: MutationFunctionContext,
]
>()
})
})
describe('onSuccess', () => {
it('should take the data, variables, onMutateResult, mutation and context, and return an unknown value', () => {
expectTypeOf<
NonNullable<MutationCacheConfig['onSuccess']>
>().returns.toEqualTypeOf<unknown>()
expectTypeOf<
NonNullable<MutationCacheConfig['onSuccess']>
>().parameters.toEqualTypeOf<
[
data: unknown,
variables: unknown,
onMutateResult: unknown,
mutation: Mutation<unknown, unknown, unknown>,
context: MutationFunctionContext,
]
>()
})
})
describe('onMutate', () => {
it('should take only the variables, mutation and context, and return an unknown value', () => {
expectTypeOf<
NonNullable<MutationCacheConfig['onMutate']>
>().returns.toEqualTypeOf<unknown>()
expectTypeOf<
NonNullable<MutationCacheConfig['onMutate']>
>().parameters.toEqualTypeOf<
[
variables: unknown,
mutation: Mutation<unknown, unknown, unknown>,
context: MutationFunctionContext,
]
>()
})
})
describe('onSettled', () => {
it('should take the data, error, variables, onMutateResult, mutation and context, and return an unknown value', () => {
expectTypeOf<
NonNullable<MutationCacheConfig['onSettled']>
>().returns.toEqualTypeOf<unknown>()
expectTypeOf<
NonNullable<MutationCacheConfig['onSettled']>
>().parameters.toEqualTypeOf<
[
data: unknown,
error: DefaultError | null,
variables: unknown,
onMutateResult: unknown,
mutation: Mutation<unknown, unknown, unknown>,
context: MutationFunctionContext,
]
>()
})
})
})
describe('MutationCacheNotifyEvent', () => {
it('should union exactly the six event type literals', () => {
expectTypeOf<MutationCacheNotifyEvent['type']>().toEqualTypeOf<
| 'added'
| 'removed'
| 'observerAdded'
| 'observerRemoved'
| 'observerOptionsUpdated'
| 'updated'
>()
})
it('should declare an optional mutation only on the observerOptionsUpdated event', () => {
type OptionsUpdated = Extract<
MutationCacheNotifyEvent,
{ type: 'observerOptionsUpdated' }
>
expectTypeOf<keyof OptionsUpdated>().toEqualTypeOf<
'type' | 'mutation' | 'observer'
>()
expectTypeOf<OptionsUpdated['mutation']>().toEqualTypeOf<
Mutation<any, any, any, any> | undefined
>()
expectTypeOf<OptionsUpdated['observer']>().toEqualTypeOf<
MutationObserver<any, any, any, any>
>()
})
it('should declare only a mutation on the added event', () => {
type Added = Extract<MutationCacheNotifyEvent, { type: 'added' }>
expectTypeOf<keyof Added>().toEqualTypeOf<'type' | 'mutation'>()
expectTypeOf<Added['mutation']>().toEqualTypeOf<
Mutation<any, any, any, any>
>()
})
it('should declare only a mutation on the removed event', () => {
expectTypeOf<
keyof Extract<MutationCacheNotifyEvent, { type: 'removed' }>
>().toEqualTypeOf<'type' | 'mutation'>()
})
it('should carry an action on the updated event', () => {
expectTypeOf<
keyof Extract<MutationCacheNotifyEvent, { type: 'updated' }>
>().toEqualTypeOf<'type' | 'mutation' | 'action'>()
})
it('should carry an observer on the observerAdded event', () => {
expectTypeOf<
keyof Extract<MutationCacheNotifyEvent, { type: 'observerAdded' }>
>().toEqualTypeOf<'type' | 'mutation' | 'observer'>()
expectTypeOf<
Extract<MutationCacheNotifyEvent, { type: 'observerAdded' }>['observer']
>().toEqualTypeOf<MutationObserver<any, any, any>>()
})
it('should carry an observer on the observerRemoved event', () => {
expectTypeOf<
keyof Extract<MutationCacheNotifyEvent, { type: 'observerRemoved' }>
>().toEqualTypeOf<'type' | 'mutation' | 'observer'>()
expectTypeOf<
Extract<
MutationCacheNotifyEvent,
{ type: 'observerRemoved' }
>['observer']
>().toEqualTypeOf<MutationObserver<any, any, any>>()
})
it('should require the mutation on every event except observerOptionsUpdated', () => {
type EventsWithRequiredMutation = Extract<
MutationCacheNotifyEvent,
{ mutation: Mutation<any, any, any, any> }
>['type']
expectTypeOf<EventsWithRequiredMutation>().toEqualTypeOf<
'added' | 'removed' | 'observerAdded' | 'observerRemoved' | 'updated'
>()
})
})
describe('subscribe', () => {
it('should type the event given to a listener', () => {
mutationCache.subscribe((event) => {
expectTypeOf(event).toEqualTypeOf<MutationCacheNotifyEvent>()
})
})
it('should return an unsubscribe function', () => {
expectTypeOf(mutationCache.subscribe(() => {})).toEqualTypeOf<
() => void
>()
})
})
describe('build', () => {
it('should carry its type parameters into the mutation it returns', () => {
const mutation = mutationCache.build<
{ value: string },
CustomError,
number,
string
>(queryClient, {
mutationFn: (variables) => {
expectTypeOf(variables).toEqualTypeOf<number>()
return Promise.resolve({ value: 'data' })
},
})
expectTypeOf(mutation).toEqualTypeOf<
Mutation<{ value: string }, CustomError, number, string>
>()
expectTypeOf(mutation.state).toEqualTypeOf<
MutationState<{ value: string }, CustomError, number, string>
>()
})
it('should accept an optional initial state', () => {
expectTypeOf(
mutationCache.build<{ value: string }, CustomError, number, string>,
)
.parameter(2)
.toEqualTypeOf<
| MutationState<{ value: string }, CustomError, number, string>
| undefined
>()
})
})
describe('getAll', () => {
it('should return an array of fully defaulted mutations', () => {
const all = mutationCache.getAll()
expectTypeOf(all).toBeArray()
expectTypeOf(all[0]!.state).toEqualTypeOf<
MutationState<unknown, DefaultError, unknown, unknown>
>()
expectTypeOf(all[0]!.state.data).toEqualTypeOf<unknown>()
expectTypeOf(all[0]!.state.error).toEqualTypeOf<DefaultError | null>()
})
it('should take no arguments', () => {
expectTypeOf(mutationCache.getAll).parameters.toEqualTypeOf<[]>()
})
})
describe('find', () => {
it('should carry its type parameters into the mutation it returns', () => {
expectTypeOf(
mutationCache.find<{ value: string }, CustomError, number, string>({
mutationKey: ['a'],
}),
).toEqualTypeOf<
Mutation<{ value: string }, CustomError, number, string> | undefined
>()
})
it('should default its variables type parameter to any', () => {
expectTypeOf(mutationCache.find({ mutationKey: ['a'] })).toEqualTypeOf<
Mutation<unknown, DefaultError, any, unknown> | undefined
>()
})
it('should only accept mutation filters', () => {
expectTypeOf(mutationCache.find).parameters.toEqualTypeOf<
[filters: MutationFilters]
>()
})
})
describe('findAll', () => {
it('should return an array of fully defaulted mutations', () => {
const found = mutationCache.findAll({ mutationKey: ['a'] })
expectTypeOf(found).toBeArray()
expectTypeOf(found[0]!.state).toEqualTypeOf<
MutationState<unknown, DefaultError, unknown, unknown>
>()
})
it('should accept filters or nothing', () => {
mutationCache.findAll()
mutationCache.findAll({ status: 'pending' })
// @ts-expect-error status must be a mutation status
mutationCache.findAll({ status: 'nope' })
expectTypeOf(mutationCache.findAll).parameters.toEqualTypeOf<
[filters?: MutationFilters]
>()
})
})
describe('add', () => {
it('should take a single mutation and return void', () => {
expectTypeOf(mutationCache.add).returns.toEqualTypeOf<void>()
expectTypeOf(mutationCache.add).parameters.toEqualTypeOf<
[mutation: Mutation<any, any, any, any>]
>()
})
})
describe('remove', () => {
it('should take a single mutation and return void', () => {
expectTypeOf(mutationCache.remove).returns.toEqualTypeOf<void>()
expectTypeOf(mutationCache.remove).parameters.toEqualTypeOf<
[mutation: Mutation<any, any, any, any>]
>()
})
})
describe('canRun', () => {
it('should return a boolean for a single mutation', () => {
expectTypeOf(mutationCache.canRun).returns.toEqualTypeOf<boolean>()
expectTypeOf(mutationCache.canRun).parameters.toEqualTypeOf<
[mutation: Mutation<any, any, any, any>]
>()
})
})
describe('runNext', () => {
it('should resolve with an unknown value', () => {
expectTypeOf(mutationCache.runNext).returns.toEqualTypeOf<
Promise<unknown>
>()
expectTypeOf(mutationCache.runNext).parameters.toEqualTypeOf<
[mutation: Mutation<any, any, any, any>]
>()
})
})
describe('clear', () => {
it('should take no arguments and return void', () => {
expectTypeOf(mutationCache.clear).parameters.toEqualTypeOf<[]>()
expectTypeOf(mutationCache.clear).returns.toEqualTypeOf<void>()
})
})
describe('notify', () => {
it('should only accept a notify event and return void', () => {
expectTypeOf(mutationCache.notify).parameters.toEqualTypeOf<
[event: MutationCacheNotifyEvent]
>()
expectTypeOf(mutationCache.notify).returns.toEqualTypeOf<void>()
})
})
describe('resumePausedMutations', () => {
it('should take no arguments and resolve with an unknown value', () => {
expectTypeOf(
mutationCache.resumePausedMutations,
).parameters.toEqualTypeOf<[]>()
expectTypeOf(mutationCache.resumePausedMutations).returns.toEqualTypeOf<
Promise<unknown>
>()
})
})
})

View File

@@ -0,0 +1,511 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { queryKey, sleep } from '@tanstack/query-test-utils'
import { MutationCache, MutationObserver, QueryClient } from '..'
import { executeMutation } from './utils'
describe('mutationCache', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
describe('MutationCacheConfig error callbacks', () => {
it('should call onError and onSettled when a mutation errors', async () => {
const key = queryKey()
const onError = vi.fn()
const onSuccess = vi.fn()
const onSettled = vi.fn()
const testCache = new MutationCache({ onError, onSuccess, onSettled })
const testClient = new QueryClient({ mutationCache: testCache })
executeMutation(
testClient,
{
mutationKey: key,
mutationFn: () =>
sleep(10).then(() => Promise.reject(new Error('error'))),
onMutate: () => 'result',
},
'vars',
).catch(() => undefined)
await vi.advanceTimersByTimeAsync(10)
const mutation = testCache.getAll()[0]
expect(onError).toHaveBeenCalledTimes(1)
expect(onError).toHaveBeenCalledWith(
new Error('error'),
'vars',
'result',
mutation,
{
client: testClient,
meta: undefined,
mutationKey: key,
},
)
expect(onSuccess).not.toHaveBeenCalled()
expect(onSettled).toHaveBeenCalledTimes(1)
expect(onSettled).toHaveBeenCalledWith(
undefined,
new Error('error'),
'vars',
'result',
mutation,
{
client: testClient,
meta: undefined,
mutationKey: key,
},
)
})
it('should be awaited', async () => {
const key = queryKey()
const states: Array<number> = []
const onError = () =>
sleep(10).then(() => {
states.push(1)
states.push(2)
})
const onSettled = () =>
sleep(10).then(() => {
states.push(5)
states.push(6)
})
const testCache = new MutationCache({ onError, onSettled })
const testClient = new QueryClient({ mutationCache: testCache })
executeMutation(
testClient,
{
mutationKey: key,
mutationFn: () =>
sleep(10).then(() => Promise.reject(new Error('error'))),
onError: () =>
sleep(10).then(() => {
states.push(3)
states.push(4)
}),
onSettled: () =>
sleep(10).then(() => {
states.push(7)
states.push(8)
}),
},
'vars',
).catch(() => undefined)
await vi.advanceTimersByTimeAsync(50)
expect(states).toEqual([1, 2, 3, 4, 5, 6, 7, 8])
})
})
describe('MutationCacheConfig success callbacks', () => {
it('should call onSuccess and onSettled when a mutation is successful', async () => {
const key = queryKey()
const onError = vi.fn()
const onSuccess = vi.fn()
const onSettled = vi.fn()
const testCache = new MutationCache({ onError, onSuccess, onSettled })
const testClient = new QueryClient({ mutationCache: testCache })
executeMutation(
testClient,
{
mutationKey: key,
mutationFn: () => sleep(10).then(() => ({ data: 5 })),
onMutate: () => 'result',
},
'vars',
)
await vi.advanceTimersByTimeAsync(10)
const mutation = testCache.getAll()[0]
expect(onSuccess).toHaveBeenCalledTimes(1)
expect(onSuccess).toHaveBeenCalledWith(
{ data: 5 },
'vars',
'result',
mutation,
{
client: testClient,
meta: undefined,
mutationKey: key,
},
)
expect(onError).not.toHaveBeenCalled()
expect(onSettled).toHaveBeenCalledTimes(1)
expect(onSettled).toHaveBeenCalledWith(
{ data: 5 },
null,
'vars',
'result',
mutation,
{
client: testClient,
meta: undefined,
mutationKey: key,
},
)
})
it('should be awaited', async () => {
const key = queryKey()
const states: Array<number> = []
const onSuccess = () =>
sleep(10).then(() => {
states.push(1)
states.push(2)
})
const onSettled = () =>
sleep(10).then(() => {
states.push(5)
states.push(6)
})
const testCache = new MutationCache({ onSuccess, onSettled })
const testClient = new QueryClient({ mutationCache: testCache })
executeMutation(
testClient,
{
mutationKey: key,
mutationFn: () => sleep(10).then(() => ({ data: 5 })),
onSuccess: () =>
sleep(10).then(() => {
states.push(3)
states.push(4)
}),
onSettled: () =>
sleep(10).then(() => {
states.push(7)
states.push(8)
}),
},
'vars',
)
await vi.advanceTimersByTimeAsync(50)
expect(states).toEqual([1, 2, 3, 4, 5, 6, 7, 8])
})
})
describe('MutationCacheConfig.onMutate', () => {
it('should be called before a mutation executes', () => {
const key = queryKey()
const onMutate = vi.fn()
const testCache = new MutationCache({ onMutate })
const testClient = new QueryClient({ mutationCache: testCache })
executeMutation(
testClient,
{
mutationKey: key,
mutationFn: () => sleep(10).then(() => ({ data: 5 })),
onMutate: () => 'result',
},
'vars',
)
const mutation = testCache.getAll()[0]
expect(onMutate).toHaveBeenCalledWith('vars', mutation, {
client: testClient,
meta: undefined,
mutationKey: key,
})
})
it('should be awaited', async () => {
const key = queryKey()
const states: Array<number> = []
const onMutate = () =>
sleep(10).then(() => {
states.push(1)
states.push(2)
})
const testCache = new MutationCache({ onMutate })
const testClient = new QueryClient({ mutationCache: testCache })
executeMutation(
testClient,
{
mutationKey: key,
mutationFn: () => sleep(10).then(() => ({ data: 5 })),
onMutate: () =>
sleep(10).then(() => {
states.push(3)
states.push(4)
}),
},
'vars',
)
await vi.advanceTimersByTimeAsync(20)
expect(states).toEqual([1, 2, 3, 4])
})
it('options.onMutate should run synchronously when mutationCache.config.onMutate is not defined', () => {
const key = queryKey()
const states: Array<string> = []
// No onMutate in cache config
const testCache = new MutationCache({})
const testClient = new QueryClient({ mutationCache: testCache })
executeMutation(
testClient,
{
mutationKey: key,
mutationFn: () => sleep(10).then(() => ({ data: 5 })),
onMutate: () => {
states.push('onMutate')
return 'context'
},
},
'vars',
)
expect(states).toEqual(['onMutate'])
})
})
describe('find', () => {
it('should filter correctly', () => {
const testCache = new MutationCache()
const testClient = new QueryClient({ mutationCache: testCache })
const key = ['mutation', 'vars']
executeMutation(
testClient,
{
mutationKey: key,
mutationFn: () => sleep(10).then(() => undefined),
},
'vars',
)
const [mutation] = testCache.getAll()
expect(testCache.find({ mutationKey: key })).toEqual(mutation)
expect(
testCache.find({ mutationKey: ['mutation'], exact: false }),
).toEqual(mutation)
expect(testCache.find({ mutationKey: ['unknown'] })).toEqual(undefined)
expect(
testCache.find({
predicate: (m) => m.options.mutationKey?.[0] === key[0],
}),
).toEqual(mutation)
})
})
describe('findAll', () => {
it('should filter correctly', () => {
const testCache = new MutationCache()
const testClient = new QueryClient({ mutationCache: testCache })
executeMutation(
testClient,
{
mutationKey: ['a', 1],
mutationFn: () => sleep(10).then(() => undefined),
},
1,
)
executeMutation(
testClient,
{
mutationKey: ['a', 2],
mutationFn: () => sleep(10).then(() => undefined),
},
2,
)
executeMutation(
testClient,
{
mutationKey: ['b'],
mutationFn: () => sleep(10).then(() => undefined),
},
3,
)
const [mutation1, mutation2] = testCache.getAll()
expect(
testCache.findAll({ mutationKey: ['a'], exact: false }),
).toHaveLength(2)
expect(testCache.find({ mutationKey: ['a', 1] })).toEqual(mutation1)
expect(
testCache.findAll({
predicate: (m) => m.options.mutationKey?.[1] === 2,
}),
).toEqual([mutation2])
expect(testCache.findAll({ mutationKey: ['unknown'] })).toEqual([])
})
})
describe('garbage collection', () => {
it('should remove unused mutations after gcTime has elapsed', async () => {
const testCache = new MutationCache()
const testClient = new QueryClient({ mutationCache: testCache })
const onSuccess = vi.fn()
executeMutation(
testClient,
{
mutationKey: ['a', 1],
gcTime: 10,
mutationFn: () => sleep(10).then(() => undefined),
onSuccess,
},
1,
)
await vi.advanceTimersByTimeAsync(10)
expect(testCache.getAll()).toHaveLength(1)
await vi.advanceTimersByTimeAsync(10)
expect(testCache.getAll()).toHaveLength(0)
expect(onSuccess).toHaveBeenCalledTimes(1)
})
it('should not remove mutations if there are active observers', async () => {
const queryClient = new QueryClient()
const observer = new MutationObserver(queryClient, {
gcTime: 10,
mutationFn: (input: number) => sleep(10).then(() => input),
})
const unsubscribe = observer.subscribe(() => undefined)
expect(queryClient.getMutationCache().getAll()).toHaveLength(0)
observer.mutate(1)
expect(queryClient.getMutationCache().getAll()).toHaveLength(1)
await vi.advanceTimersByTimeAsync(10)
expect(queryClient.getMutationCache().getAll()).toHaveLength(1)
unsubscribe()
expect(queryClient.getMutationCache().getAll()).toHaveLength(1)
await vi.advanceTimersByTimeAsync(10)
expect(queryClient.getMutationCache().getAll()).toHaveLength(0)
})
it('should be garbage collected later when unsubscribed and mutation is pending', async () => {
const queryClient = new QueryClient()
const onSuccess = vi.fn()
const observer = new MutationObserver(queryClient, {
gcTime: 10,
mutationFn: () => sleep(10).then(() => 'data'),
onSuccess,
})
const unsubscribe = observer.subscribe(() => undefined)
observer.mutate(1)
unsubscribe()
expect(queryClient.getMutationCache().getAll()).toHaveLength(1)
await vi.advanceTimersByTimeAsync(10)
// unsubscribe should not remove even though gcTime has elapsed b/c mutation is still pending
expect(queryClient.getMutationCache().getAll()).toHaveLength(1)
await vi.advanceTimersByTimeAsync(10)
// should be removed after an additional gcTime wait
expect(queryClient.getMutationCache().getAll()).toHaveLength(0)
expect(onSuccess).toHaveBeenCalledTimes(1)
})
it('should call callbacks even with gcTime 0 and mutation still pending', async () => {
const queryClient = new QueryClient()
const onSuccess = vi.fn()
const observer = new MutationObserver(queryClient, {
gcTime: 0,
mutationFn: () => sleep(10).then(() => 'data'),
onSuccess,
})
const unsubscribe = observer.subscribe(() => undefined)
observer.mutate(1)
unsubscribe()
await vi.advanceTimersByTimeAsync(10)
expect(queryClient.getMutationCache().getAll()).toHaveLength(0)
expect(onSuccess).toHaveBeenCalledTimes(1)
})
})
describe('remove', () => {
it('should remove only the target mutation from scope when multiple scoped mutations exist', () => {
const testCache = new MutationCache()
const testClient = new QueryClient({ mutationCache: testCache })
const mutation1 = testCache.build(testClient, {
scope: { id: 'scope1' },
mutationFn: () => Promise.resolve('data1'),
})
const mutation2 = testCache.build(testClient, {
scope: { id: 'scope1' },
mutationFn: () => Promise.resolve('data2'),
})
expect(testCache.getAll()).toHaveLength(2)
testCache.remove(mutation1)
expect(testCache.getAll()).toHaveLength(1)
expect(testCache.getAll()).toEqual([mutation2])
})
it('should delete scope when removing the only mutation in that scope', () => {
const testCache = new MutationCache()
const testClient = new QueryClient({ mutationCache: testCache })
const mutation = testCache.build(testClient, {
scope: { id: 'scope1' },
mutationFn: () => Promise.resolve('data'),
})
expect(testCache.getAll()).toHaveLength(1)
testCache.remove(mutation)
expect(testCache.getAll()).toHaveLength(0)
})
it('should still notify removal when removing a mutation that does not exist in the cache', () => {
const testCache = new MutationCache()
const testClient = new QueryClient({ mutationCache: testCache })
const mutation = testCache.build(testClient, {
mutationFn: () => Promise.resolve('data'),
})
expect(testCache.getAll()).toHaveLength(1)
testCache.remove(mutation)
expect(testCache.getAll()).toHaveLength(0)
// Remove again — mutation is already gone from the cache
const callback = vi.fn()
const unsubscribe = testCache.subscribe(callback)
testCache.remove(mutation)
expect(testCache.getAll()).toHaveLength(0)
expect(callback).toHaveBeenCalledTimes(1)
expect(callback).toHaveBeenCalledWith({ type: 'removed', mutation })
unsubscribe()
})
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,544 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { queryKey, sleep } from '@tanstack/query-test-utils'
import { MutationObserver, QueryClient } from '..'
describe('mutationObserver', () => {
let queryClient: QueryClient
beforeEach(() => {
vi.useFakeTimers()
queryClient = new QueryClient()
queryClient.mount()
})
afterEach(() => {
queryClient.clear()
vi.useRealTimers()
})
it('onUnsubscribe should not remove the current mutation observer if there is still a subscription', async () => {
const mutation = new MutationObserver(queryClient, {
mutationFn: (text: string) => sleep(20).then(() => text),
})
const subscription1Handler = vi.fn()
const subscription2Handler = vi.fn()
const unsubscribe1 = mutation.subscribe(subscription1Handler)
const unsubscribe2 = mutation.subscribe(subscription2Handler)
mutation.mutate('input')
unsubscribe1()
expect(subscription1Handler).toHaveBeenCalledTimes(1)
expect(subscription2Handler).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(20)
expect(subscription1Handler).toHaveBeenCalledTimes(1)
expect(subscription2Handler).toHaveBeenCalledTimes(2)
unsubscribe2()
})
it('unsubscribe should remove observer to trigger GC', async () => {
const mutation = new MutationObserver(queryClient, {
mutationFn: (text: string) => sleep(5).then(() => text),
gcTime: 10,
})
const subscriptionHandler = vi.fn()
const unsubscribe = mutation.subscribe(subscriptionHandler)
mutation.mutate('input')
await vi.advanceTimersByTimeAsync(5)
expect(queryClient.getMutationCache().findAll()).toHaveLength(1)
unsubscribe()
await vi.advanceTimersByTimeAsync(10)
expect(queryClient.getMutationCache().findAll()).toHaveLength(0)
})
it('resubscribing should reattach the observer to the in-flight mutation', async () => {
const mutation = new MutationObserver(queryClient, {
mutationFn: (text: string) => sleep(20).then(() => text),
})
const unsubscribe = mutation.subscribe(vi.fn())
mutation.mutate('input')
unsubscribe()
const subscriptionHandler = vi.fn()
mutation.subscribe(subscriptionHandler)
await vi.advanceTimersByTimeAsync(20)
expect(mutation.getCurrentResult()).toMatchObject({
status: 'success',
data: 'input',
})
expect(subscriptionHandler).toHaveBeenCalledTimes(1)
})
it('resubscribing should pick up a mutation that settled while unsubscribed', async () => {
const mutation = new MutationObserver(queryClient, {
mutationFn: (text: string) => sleep(20).then(() => text),
})
const unsubscribe = mutation.subscribe(vi.fn())
mutation.mutate('input')
unsubscribe()
await vi.advanceTimersByTimeAsync(20)
mutation.subscribe(vi.fn())
expect(mutation.getCurrentResult()).toMatchObject({
status: 'success',
data: 'input',
})
})
it('reset should remove observer to trigger GC', async () => {
const mutation = new MutationObserver(queryClient, {
mutationFn: (text: string) => sleep(5).then(() => text),
gcTime: 10,
})
const subscriptionHandler = vi.fn()
const unsubscribe = mutation.subscribe(subscriptionHandler)
mutation.mutate('input')
await vi.advanceTimersByTimeAsync(5)
expect(queryClient.getMutationCache().findAll()).toHaveLength(1)
mutation.reset()
await vi.advanceTimersByTimeAsync(10)
expect(queryClient.getMutationCache().findAll()).toHaveLength(0)
unsubscribe()
})
it('changing mutation keys should reset the observer', async () => {
const key = queryKey()
const mutation = new MutationObserver(queryClient, {
mutationKey: [...key, '1'],
mutationFn: (text: string) => sleep(5).then(() => text),
})
const subscriptionHandler = vi.fn()
const unsubscribe = mutation.subscribe(subscriptionHandler)
mutation.mutate('input')
await vi.advanceTimersByTimeAsync(5)
expect(mutation.getCurrentResult()).toMatchObject({
status: 'success',
data: 'input',
})
mutation.setOptions({
mutationKey: [...key, '2'],
})
expect(mutation.getCurrentResult()).toMatchObject({
status: 'idle',
})
unsubscribe()
})
it('changing mutation keys should not affect already existing mutations', async () => {
const key = queryKey()
const mutationObserver = new MutationObserver(queryClient, {
mutationKey: [...key, '1'],
mutationFn: (text: string) => sleep(5).then(() => text),
})
const subscriptionHandler = vi.fn()
const unsubscribe = mutationObserver.subscribe(subscriptionHandler)
mutationObserver.mutate('input')
await vi.advanceTimersByTimeAsync(5)
expect(
queryClient.getMutationCache().find({ mutationKey: [...key, '1'] }),
).toMatchObject({
options: { mutationKey: [...key, '1'] },
state: {
status: 'success',
data: 'input',
},
})
mutationObserver.setOptions({
mutationKey: [...key, '2'],
})
expect(
queryClient.getMutationCache().find({ mutationKey: [...key, '1'] }),
).toMatchObject({
options: { mutationKey: [...key, '1'] },
state: {
status: 'success',
data: 'input',
},
})
unsubscribe()
})
it('changing mutation meta should not affect successful mutations', async () => {
const mutationObserver = new MutationObserver(queryClient, {
meta: { a: 1 },
mutationFn: (text: string) => sleep(5).then(() => text),
})
const subscriptionHandler = vi.fn()
const unsubscribe = mutationObserver.subscribe(subscriptionHandler)
mutationObserver.mutate('input')
await vi.advanceTimersByTimeAsync(5)
expect(queryClient.getMutationCache().find({})).toMatchObject({
options: { meta: { a: 1 } },
state: {
status: 'success',
data: 'input',
},
})
mutationObserver.setOptions({
meta: { a: 2 },
})
expect(queryClient.getMutationCache().find({})).toMatchObject({
options: { meta: { a: 1 } },
state: {
status: 'success',
data: 'input',
},
})
unsubscribe()
})
it('mutation cache should have different meta when updated between mutations', async () => {
const mutationFn = (text: string) => sleep(5).then(() => text)
const mutationObserver = new MutationObserver(queryClient, {
meta: { a: 1 },
mutationFn,
})
const subscriptionHandler = vi.fn()
const unsubscribe = mutationObserver.subscribe(subscriptionHandler)
mutationObserver.mutate('input')
await vi.advanceTimersByTimeAsync(5)
mutationObserver.setOptions({
meta: { a: 2 },
mutationFn,
})
mutationObserver.mutate('input')
await vi.advanceTimersByTimeAsync(5)
const mutations = queryClient.getMutationCache().findAll()
expect(mutations[0]).toMatchObject({
options: { meta: { a: 1 } },
state: {
status: 'success',
data: 'input',
},
})
expect(mutations[1]).toMatchObject({
options: { meta: { a: 2 } },
state: {
status: 'success',
data: 'input',
},
})
unsubscribe()
})
it('changing mutation meta should not affect rejected mutations', async () => {
const mutationObserver = new MutationObserver(queryClient, {
meta: { a: 1 },
mutationFn: (_: string) =>
sleep(5).then(() => Promise.reject(new Error('err'))),
})
const subscriptionHandler = vi.fn()
const unsubscribe = mutationObserver.subscribe(subscriptionHandler)
mutationObserver.mutate('input').catch(() => undefined)
await vi.advanceTimersByTimeAsync(5)
expect(queryClient.getMutationCache().find({})).toMatchObject({
options: { meta: { a: 1 } },
state: {
status: 'error',
},
})
mutationObserver.setOptions({
meta: { a: 2 },
})
expect(queryClient.getMutationCache().find({})).toMatchObject({
options: { meta: { a: 1 } },
state: {
status: 'error',
},
})
unsubscribe()
})
it('changing mutation meta should affect pending mutations', async () => {
const mutationObserver = new MutationObserver(queryClient, {
meta: { a: 1 },
mutationFn: (text: string) => sleep(20).then(() => text),
})
const subscriptionHandler = vi.fn()
const unsubscribe = mutationObserver.subscribe(subscriptionHandler)
mutationObserver.mutate('input')
await vi.advanceTimersByTimeAsync(5)
expect(queryClient.getMutationCache().find({})).toMatchObject({
options: { meta: { a: 1 } },
state: {
status: 'pending',
},
})
mutationObserver.setOptions({
meta: { a: 2 },
})
expect(queryClient.getMutationCache().find({})).toMatchObject({
options: { meta: { a: 2 } },
state: {
status: 'pending',
},
})
unsubscribe()
})
it('mutation callbacks should be called in correct order with correct arguments for success case', async () => {
const onSuccess = vi.fn()
const onSettled = vi.fn()
const mutationObserver = new MutationObserver(queryClient, {
mutationFn: (text: string) => Promise.resolve(text.toUpperCase()),
})
const subscriptionHandler = vi.fn()
const unsubscribe = mutationObserver.subscribe(subscriptionHandler)
mutationObserver.mutate('success', {
onSuccess,
onSettled,
})
await vi.advanceTimersByTimeAsync(0)
expect(onSuccess).toHaveBeenCalledTimes(1)
expect(onSuccess).toHaveBeenCalledWith('SUCCESS', 'success', undefined, {
client: queryClient,
meta: undefined,
mutationKey: undefined,
})
expect(onSettled).toHaveBeenCalledTimes(1)
expect(onSettled).toHaveBeenCalledWith(
'SUCCESS',
null,
'success',
undefined,
{
client: queryClient,
meta: undefined,
mutationKey: undefined,
},
)
unsubscribe()
})
it('mutation callbacks should be called in correct order with correct arguments for error case', async () => {
const onError = vi.fn()
const onSettled = vi.fn()
const error = new Error('error')
const mutationObserver = new MutationObserver(queryClient, {
mutationFn: (_: string) => Promise.reject(error),
})
const subscriptionHandler = vi.fn()
const unsubscribe = mutationObserver.subscribe(subscriptionHandler)
mutationObserver
.mutate('error', {
onError,
onSettled,
})
.catch(() => {})
await vi.advanceTimersByTimeAsync(0)
expect(onError).toHaveBeenCalledTimes(1)
expect(onError).toHaveBeenCalledWith(error, 'error', undefined, {
client: queryClient,
meta: undefined,
mutationKey: undefined,
})
expect(onSettled).toHaveBeenCalledTimes(1)
expect(onSettled).toHaveBeenCalledWith(
undefined,
error,
'error',
undefined,
{
client: queryClient,
meta: undefined,
mutationKey: undefined,
},
)
unsubscribe()
})
describe('erroneous mutation callback', () => {
it('onSuccess and onSettled is transferred to different execution context where it is reported', async ({
onTestFinished,
}) => {
const unhandledRejectionFn = vi.fn()
process.on('unhandledRejection', (error) => unhandledRejectionFn(error))
onTestFinished(() => {
process.off('unhandledRejection', unhandledRejectionFn)
})
const onSuccessError = new Error('onSuccess-error')
const onSuccess = vi.fn(() => {
throw onSuccessError
})
const onSettledError = new Error('onSettled-error')
const onSettled = vi.fn(() => {
throw onSettledError
})
const mutationObserver = new MutationObserver(queryClient, {
mutationFn: (text: string) => Promise.resolve(text.toUpperCase()),
})
const subscriptionHandler = vi.fn()
const unsubscribe = mutationObserver.subscribe(subscriptionHandler)
mutationObserver.mutate('success', {
onSuccess,
onSettled,
})
await vi.advanceTimersByTimeAsync(0)
expect(onSuccess).toHaveBeenCalledTimes(1)
expect(onSettled).toHaveBeenCalledTimes(1)
expect(unhandledRejectionFn).toHaveBeenCalledTimes(2)
expect(unhandledRejectionFn).toHaveBeenNthCalledWith(1, onSuccessError)
expect(unhandledRejectionFn).toHaveBeenNthCalledWith(2, onSettledError)
expect(subscriptionHandler).toHaveBeenCalledTimes(2)
unsubscribe()
})
it('onError and onSettled is transferred to different execution context where it is reported', async ({
onTestFinished,
}) => {
const unhandledRejectionFn = vi.fn()
process.on('unhandledRejection', (error) => unhandledRejectionFn(error))
onTestFinished(() => {
process.off('unhandledRejection', unhandledRejectionFn)
})
const onErrorError = new Error('onError-error')
const onError = vi.fn(() => {
throw onErrorError
})
const onSettledError = new Error('onSettled-error')
const onSettled = vi.fn(() => {
throw onSettledError
})
const error = new Error('error')
const mutationObserver = new MutationObserver(queryClient, {
mutationFn: (_: string) => Promise.reject(error),
})
const subscriptionHandler = vi.fn()
const unsubscribe = mutationObserver.subscribe(subscriptionHandler)
mutationObserver
.mutate('error', {
onError,
onSettled,
})
.catch(() => {})
await vi.advanceTimersByTimeAsync(0)
expect(onError).toHaveBeenCalledTimes(1)
expect(onSettled).toHaveBeenCalledTimes(1)
expect(unhandledRejectionFn).toHaveBeenCalledTimes(2)
expect(unhandledRejectionFn).toHaveBeenNthCalledWith(1, onErrorError)
expect(unhandledRejectionFn).toHaveBeenNthCalledWith(2, onSettledError)
expect(subscriptionHandler).toHaveBeenCalledTimes(2)
unsubscribe()
})
})
it('should not notify cache when setOptions is called with same options', () => {
const mutationObserver = new MutationObserver(queryClient, {
mutationFn: (text: string) => Promise.resolve(text),
})
const notifySpy = vi.spyOn(queryClient.getMutationCache(), 'notify')
const unsubscribe = mutationObserver.subscribe(() => undefined)
notifySpy.mockClear()
// Call setOptions with the same options
mutationObserver.setOptions({
mutationFn: mutationObserver.options.mutationFn,
})
expect(notifySpy).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'observerOptionsUpdated' }),
)
unsubscribe()
})
})

View File

@@ -0,0 +1,205 @@
import { assertType, describe, expectTypeOf, it } from 'vitest'
import {
createNotifyManager,
defaultScheduler,
notifyManager,
} from '../notifyManager'
describe('notifyManager', () => {
it('should expose the same type as the factory', () => {
expectTypeOf(notifyManager).toEqualTypeOf<
ReturnType<typeof createNotifyManager>
>()
})
it('should expose every method as readonly', () => {
type Manager = ReturnType<typeof createNotifyManager>
expectTypeOf<Manager>().toEqualTypeOf<{
readonly [K in keyof Manager]: Manager[K]
}>()
})
it('should only expose its documented members', () => {
expectTypeOf<keyof ReturnType<typeof createNotifyManager>>().toEqualTypeOf<
| 'batch'
| 'batchCalls'
| 'schedule'
| 'setNotifyFunction'
| 'setBatchNotifyFunction'
| 'setScheduler'
>()
})
describe('batch', () => {
it('should pass the return type of the callback through', () => {
const notifyManagerTest = createNotifyManager()
expectTypeOf(notifyManagerTest.batch(() => 5)).toEqualTypeOf<number>()
expectTypeOf(
notifyManagerTest.batch(() => 'im happy' as const),
).toEqualTypeOf<'im happy'>()
expectTypeOf(notifyManagerTest.batch(() => ({ a: 1 }))).toEqualTypeOf<{
a: number
}>()
})
it('should be void when the callback returns nothing', () => {
const notifyManagerTest = createNotifyManager()
expectTypeOf(notifyManagerTest.batch(() => {})).toEqualTypeOf<void>()
})
it('should only accept a callback taking no arguments', () => {
const notifyManagerTest = createNotifyManager()
expectTypeOf(notifyManagerTest.batch).parameters.toEqualTypeOf<
[callback: () => unknown]
>()
})
})
describe('batchCalls', () => {
it('should catch improper signatures of the wrapped function', () => {
const notifyManagerTest = createNotifyManager()
// we define some fn with its signature:
const fn: (a: string, b: number) => string = (a, b) => a + b
// now someFn expect to be called with args [a: string, b: number]
const someFn = notifyManagerTest.batchCalls(fn)
expectTypeOf(someFn).parameters.toEqualTypeOf<Parameters<typeof fn>>()
assertType<Parameters<typeof someFn>>(['im happy', 4])
assertType<Parameters<typeof someFn>>([
'im not happy',
// @ts-expect-error
false,
])
})
it('should discard the return type of the wrapped function', () => {
const notifyManagerTest = createNotifyManager()
const fn: (a: string) => string = (a) => a
const someFn = notifyManagerTest.batchCalls(fn)
expectTypeOf(someFn).returns.toEqualTypeOf<void>()
})
it('should accept a callback without parameters', () => {
const notifyManagerTest = createNotifyManager()
const someFn = notifyManagerTest.batchCalls(() => {})
expectTypeOf(someFn).parameters.toEqualTypeOf<[]>()
})
it('should preserve optional parameters', () => {
const notifyManagerTest = createNotifyManager()
const someFn = notifyManagerTest.batchCalls(
(_a: string, _b?: number) => {},
)
expectTypeOf(someFn).parameters.toEqualTypeOf<[_a: string, _b?: number]>()
})
it('should preserve rest parameters', () => {
const notifyManagerTest = createNotifyManager()
const someFn = notifyManagerTest.batchCalls(
(..._args: Array<number>) => {},
)
expectTypeOf(someFn).parameters.toEqualTypeOf<Array<number>>()
})
it('should constrain its type argument to an argument tuple', () => {
const notifyManagerTest = createNotifyManager()
expectTypeOf(
notifyManagerTest.batchCalls<[a: string, b: number]>((_a, _b) => {}),
).parameters.toEqualTypeOf<[a: string, b: number]>()
// @ts-expect-error a non-array type does not satisfy Array<unknown>
notifyManagerTest.batchCalls<string>(() => {})
})
})
describe('schedule', () => {
it('should only accept a callback taking no arguments', () => {
const notifyManagerTest = createNotifyManager()
expectTypeOf(notifyManagerTest.schedule).parameters.toEqualTypeOf<
[callback: () => void]
>()
})
it('should return void', () => {
const notifyManagerTest = createNotifyManager()
expectTypeOf(notifyManagerTest.schedule).returns.toEqualTypeOf<void>()
})
})
describe('setNotifyFunction', () => {
it('should only accept a function taking a callback', () => {
const notifyManagerTest = createNotifyManager()
expectTypeOf(
notifyManagerTest.setNotifyFunction,
).parameters.toEqualTypeOf<[fn: (callback: () => void) => void]>()
})
it('should return void', () => {
const notifyManagerTest = createNotifyManager()
expectTypeOf(
notifyManagerTest.setNotifyFunction,
).returns.toEqualTypeOf<void>()
})
})
describe('setBatchNotifyFunction', () => {
it('should only accept a function taking a callback', () => {
const notifyManagerTest = createNotifyManager()
expectTypeOf(
notifyManagerTest.setBatchNotifyFunction,
).parameters.toEqualTypeOf<[fn: (callback: () => void) => void]>()
})
it('should return void', () => {
const notifyManagerTest = createNotifyManager()
expectTypeOf(
notifyManagerTest.setBatchNotifyFunction,
).returns.toEqualTypeOf<void>()
})
})
describe('setScheduler', () => {
it('should only accept a function taking a callback', () => {
const notifyManagerTest = createNotifyManager()
expectTypeOf(notifyManagerTest.setScheduler).parameters.toEqualTypeOf<
[fn: (callback: () => void) => void]
>()
})
it('should accept the exported defaultScheduler', () => {
const notifyManagerTest = createNotifyManager()
expectTypeOf(defaultScheduler).toEqualTypeOf<
Parameters<typeof notifyManagerTest.setScheduler>[0]
>()
})
it('should return void', () => {
const notifyManagerTest = createNotifyManager()
expectTypeOf(notifyManagerTest.setScheduler).returns.toEqualTypeOf<void>()
})
})
})

View File

@@ -0,0 +1,106 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { sleep } from '@tanstack/query-test-utils'
import { createNotifyManager } from '../notifyManager'
describe('notifyManager', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('should use default notifyFn', async () => {
const notifyManagerTest = createNotifyManager()
const callbackSpy = vi.fn()
notifyManagerTest.schedule(callbackSpy)
await vi.advanceTimersByTimeAsync(0)
expect(callbackSpy).toHaveBeenCalled()
})
it('should use default batchNotifyFn', async () => {
const notifyManagerTest = createNotifyManager()
const callbackScheduleSpy = vi.fn().mockImplementation(() => sleep(20))
const callbackBatchLevel2Spy = vi.fn().mockImplementation(() => {
notifyManagerTest.schedule(callbackScheduleSpy)
})
const callbackBatchLevel1Spy = vi.fn().mockImplementation(() => {
notifyManagerTest.batch(callbackBatchLevel2Spy)
})
notifyManagerTest.batch(callbackBatchLevel1Spy)
await vi.advanceTimersByTimeAsync(0)
expect(callbackBatchLevel1Spy).toHaveBeenCalledTimes(1)
expect(callbackBatchLevel2Spy).toHaveBeenCalledTimes(1)
expect(callbackScheduleSpy).toHaveBeenCalledTimes(1)
})
it('should use a custom scheduler when configured', async () => {
const customCallback = vi.fn((cb) => queueMicrotask(cb))
const notifyManagerTest = createNotifyManager()
const notifySpy = vi.fn()
notifyManagerTest.setScheduler(customCallback)
notifyManagerTest.setNotifyFunction(notifySpy)
notifyManagerTest.batch(() => notifyManagerTest.schedule(vi.fn))
expect(customCallback).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(0)
expect(notifySpy).toHaveBeenCalledTimes(1)
})
it('should notify if error is thrown', async () => {
const notifyManagerTest = createNotifyManager()
const notifySpy = vi.fn()
notifyManagerTest.setNotifyFunction(notifySpy)
try {
notifyManagerTest.batch(() => {
notifyManagerTest.schedule(vi.fn)
throw new Error('Foo')
})
} catch {}
await vi.advanceTimersByTimeAsync(0)
expect(notifySpy).toHaveBeenCalledTimes(1)
})
it('should use custom batch notify function', async () => {
const notifyManagerTest = createNotifyManager()
const batchNotifySpy = vi.fn((cb) => cb())
const callbackSpy1 = vi.fn()
const callbackSpy2 = vi.fn()
notifyManagerTest.setBatchNotifyFunction(batchNotifySpy)
notifyManagerTest.batch(() => {
notifyManagerTest.schedule(callbackSpy1)
notifyManagerTest.schedule(callbackSpy2)
})
await vi.advanceTimersByTimeAsync(0)
expect(batchNotifySpy).toHaveBeenCalled()
expect(callbackSpy1).toHaveBeenCalled()
expect(callbackSpy2).toHaveBeenCalled()
})
it('should batch calls correctly', async () => {
const notifyManagerTest = createNotifyManager()
const callbackSpy = vi.fn()
const batchedFn = notifyManagerTest.batchCalls((a: number, b: string) => {
callbackSpy(a, b)
})
batchedFn(1, 'test')
await vi.advanceTimersByTimeAsync(0)
expect(callbackSpy).toHaveBeenCalledWith(1, 'test')
})
})

View File

@@ -0,0 +1,100 @@
import { describe, expectTypeOf, it } from 'vitest'
import { onlineManager } from '..'
import type { OnlineManager } from '..'
describe('onlineManager', () => {
describe('OnlineManager', () => {
it('should type the singleton as an OnlineManager', () => {
expectTypeOf(onlineManager).toEqualTypeOf<OnlineManager>()
})
it('should only expose its documented members', () => {
expectTypeOf<keyof OnlineManager>().toEqualTypeOf<
| 'subscribe'
| 'hasListeners'
| 'setEventListener'
| 'setOnline'
| 'isOnline'
>()
})
})
describe('subscribe', () => {
it('should take a listener of the online state and return an unsubscribe function', () => {
expectTypeOf(onlineManager.subscribe).parameters.toEqualTypeOf<
[listener: (online: boolean) => void]
>()
expectTypeOf(onlineManager.subscribe).returns.toEqualTypeOf<() => void>()
})
it('should reject a listener that takes a non-boolean online state', () => {
// @ts-expect-error the online state handed to a listener is a boolean
const unsubscribe = onlineManager.subscribe((online: string) => online)
expectTypeOf(unsubscribe).toEqualTypeOf<() => void>()
})
})
describe('hasListeners', () => {
it('should take no arguments and return a boolean', () => {
expectTypeOf(onlineManager.hasListeners).parameters.toEqualTypeOf<[]>()
expectTypeOf(onlineManager.hasListeners).returns.toEqualTypeOf<boolean>()
})
})
describe('setEventListener', () => {
it('should only accept a setup function and return void', () => {
expectTypeOf(onlineManager.setEventListener).parameters.toEqualTypeOf<
[
setup: (
setOnline: (online: boolean) => void,
) => (() => void) | undefined,
]
>()
expectTypeOf(onlineManager.setEventListener).returns.toEqualTypeOf<void>()
})
it('should require an argument on the setOnline callback', () => {
// Unlike `focusManager`, there is no "re-evaluate the current state"
// mode here, so the online state always has to be passed explicitly.
type Setup = Parameters<OnlineManager['setEventListener']>[0]
expectTypeOf<Parameters<Setup>[0]>().toEqualTypeOf<
(online: boolean) => void
>()
expectTypeOf<
Parameters<Parameters<Setup>[0]>['length']
>().toEqualTypeOf<1>()
})
it('should let the setup function opt out of returning a cleanup function', () => {
type Setup = Parameters<OnlineManager['setEventListener']>[0]
expectTypeOf<ReturnType<Setup>>().toEqualTypeOf<
(() => void) | undefined
>()
})
})
describe('setOnline', () => {
it('should require a boolean and return void', () => {
expectTypeOf(onlineManager.setOnline).parameters.toEqualTypeOf<
[online: boolean]
>()
expectTypeOf(onlineManager.setOnline).returns.toEqualTypeOf<void>()
})
it('should not be callable without the online state', () => {
expectTypeOf<
Parameters<OnlineManager['setOnline']>['length']
>().toEqualTypeOf<1>()
})
})
describe('isOnline', () => {
it('should take no arguments and return a boolean', () => {
expectTypeOf(onlineManager.isOnline).parameters.toEqualTypeOf<[]>()
expectTypeOf(onlineManager.isOnline).returns.toEqualTypeOf<boolean>()
})
})
})

View File

@@ -0,0 +1,190 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { OnlineManager } from '../onlineManager'
describe('onlineManager', () => {
let onlineManager: OnlineManager
beforeEach(() => {
vi.useFakeTimers()
onlineManager = new OnlineManager()
})
afterEach(() => {
vi.useRealTimers()
})
it('isOnline should return true if navigator is undefined', () => {
const navigatorSpy = vi.spyOn(globalThis, 'navigator', 'get')
// Force navigator to be undefined
// @ts-expect-error
navigatorSpy.mockImplementation(() => undefined)
expect(onlineManager.isOnline()).toBe(true)
navigatorSpy.mockRestore()
})
it('isOnline should return true if navigator.onLine is true', () => {
const navigatorSpy = vi.spyOn(navigator, 'onLine', 'get')
navigatorSpy.mockImplementation(() => true)
expect(onlineManager.isOnline()).toBe(true)
navigatorSpy.mockRestore()
})
it('setEventListener should use online boolean arg', () => {
let count = 0
const setup = (setOnline: (online: boolean) => void) => {
setTimeout(() => {
count++
setOnline(false)
}, 20)
return () => void 0
}
onlineManager.setEventListener(setup)
vi.advanceTimersByTime(20)
expect(count).toEqual(1)
expect(onlineManager.isOnline()).toBe(false)
})
it('setEventListener should call previous remove handler when replacing an event listener', () => {
const remove1Spy = vi.fn()
const remove2Spy = vi.fn()
onlineManager.setEventListener(() => remove1Spy)
onlineManager.setEventListener(() => remove2Spy)
expect(remove1Spy).toHaveBeenCalledTimes(1)
expect(remove2Spy).not.toHaveBeenCalled()
})
it('cleanup (removeEventListener) should not be called if window is not defined', () => {
const windowSpy = vi.spyOn(globalThis, 'window', 'get')
windowSpy.mockImplementation(
() => undefined as unknown as Window & typeof globalThis,
)
const removeEventListenerSpy = vi.spyOn(globalThis, 'removeEventListener')
const unsubscribe = onlineManager.subscribe(() => undefined)
expect(unsubscribe).toBeInstanceOf(Function)
unsubscribe()
expect(removeEventListenerSpy).not.toHaveBeenCalled()
windowSpy.mockRestore()
})
it('cleanup (removeEventListener) should not be called if window.addEventListener is not defined', () => {
const { addEventListener } = globalThis.window
// @ts-expect-error
globalThis.window.addEventListener = undefined
const removeEventListenerSpy = vi.spyOn(globalThis, 'removeEventListener')
const unsubscribe = onlineManager.subscribe(() => undefined)
unsubscribe()
expect(removeEventListenerSpy).not.toHaveBeenCalled()
globalThis.window.addEventListener = addEventListener
})
it('should replace default window listener when a new event listener is set', () => {
const addEventListenerSpy = vi.spyOn(globalThis.window, 'addEventListener')
const removeEventListenerSpy = vi.spyOn(
globalThis.window,
'removeEventListener',
)
// Should set the default event listener with window event listeners
const unsubscribe = onlineManager.subscribe(() => undefined)
expect(addEventListenerSpy).toHaveBeenCalledTimes(2)
// Should replace the window default event listener by a new one
// and it should call window.removeEventListener twice
onlineManager.setEventListener(() => {
return () => void 0
})
expect(removeEventListenerSpy).toHaveBeenCalledTimes(2)
unsubscribe()
addEventListenerSpy.mockRestore()
removeEventListenerSpy.mockRestore()
})
it('should call removeEventListener when last listener unsubscribes', () => {
const addEventListenerSpy = vi.spyOn(globalThis.window, 'addEventListener')
const removeEventListenerSpy = vi.spyOn(
globalThis.window,
'removeEventListener',
)
const unsubscribe1 = onlineManager.subscribe(() => undefined)
const unsubscribe2 = onlineManager.subscribe(() => undefined)
expect(addEventListenerSpy).toHaveBeenCalledTimes(2) // online + offline
unsubscribe1()
expect(removeEventListenerSpy).toHaveBeenCalledTimes(0)
unsubscribe2()
expect(removeEventListenerSpy).toHaveBeenCalledTimes(2) // online + offline
})
it('should keep setup function even if last listener unsubscribes', () => {
const setupSpy = vi.fn().mockImplementation(() => () => undefined)
onlineManager.setEventListener(setupSpy)
const unsubscribe1 = onlineManager.subscribe(() => undefined)
expect(setupSpy).toHaveBeenCalledTimes(1)
unsubscribe1()
const unsubscribe2 = onlineManager.subscribe(() => undefined)
expect(setupSpy).toHaveBeenCalledTimes(2)
unsubscribe2()
})
it('should update online status from window online and offline events', () => {
const unsubscribe = onlineManager.subscribe(() => undefined)
expect(onlineManager.isOnline()).toBe(true)
window.dispatchEvent(new Event('offline'))
expect(onlineManager.isOnline()).toBe(false)
window.dispatchEvent(new Event('online'))
expect(onlineManager.isOnline()).toBe(true)
unsubscribe()
})
it('should call listeners when setOnline is called', () => {
const listener = vi.fn()
onlineManager.subscribe(listener)
onlineManager.setOnline(false)
onlineManager.setOnline(false)
expect(listener).toHaveBeenNthCalledWith(1, false)
onlineManager.setOnline(true)
onlineManager.setOnline(true)
expect(listener).toHaveBeenCalledTimes(2)
expect(listener).toHaveBeenNthCalledWith(2, true)
})
})

View File

@@ -0,0 +1,327 @@
import { afterEach, beforeEach, describe, expectTypeOf, it } from 'vitest'
import { queryKey } from '@tanstack/query-test-utils'
import { QueriesObserver, QueryClient } from '..'
import type {
Query,
QueryObserver,
QueryObserverOptions,
QueryObserverResult,
} from '..'
import type { QueriesObserverOptions } from '../queriesObserver'
describe('queriesObserver', () => {
let queryClient: QueryClient
beforeEach(() => {
queryClient = new QueryClient()
queryClient.mount()
})
afterEach(() => {
queryClient.clear()
})
describe('QueriesObserver', () => {
it('should only expose its documented members', () => {
expectTypeOf<keyof QueriesObserver>().toEqualTypeOf<
| 'subscribe'
| 'hasListeners'
| 'destroy'
| 'setQueries'
| 'getCurrentResult'
| 'getQueries'
| 'getObservers'
| 'getOptimisticResult'
>()
})
})
describe('QueriesObserverOptions', () => {
it('should keep every option writable', () => {
type Options = QueriesObserverOptions<number>
expectTypeOf<Options>().toEqualTypeOf<{
-readonly [K in keyof Options]: Options[K]
}>()
})
it('should default its type parameter to the array of results', () => {
expectTypeOf<QueriesObserverOptions>().toEqualTypeOf<
QueriesObserverOptions<Array<QueryObserverResult>>
>()
})
describe('combine', () => {
it('should be optional', () => {
expectTypeOf<QueriesObserverOptions<number>>().toEqualTypeOf<{
combine?: (result: Array<QueryObserverResult>) => number
}>()
})
it('should take the array of results and return the combined result', () => {
expectTypeOf<
NonNullable<QueriesObserverOptions<number>['combine']>
>().toEqualTypeOf<(result: Array<QueryObserverResult>) => number>()
})
})
})
describe('constructor', () => {
it('should default the combined result to the array of results', () => {
const observer = new QueriesObserver(queryClient, [
{ queryKey: queryKey(), queryFn: () => Promise.resolve(1) },
])
expectTypeOf(observer).toEqualTypeOf<
QueriesObserver<Array<QueryObserverResult>>
>()
})
it('should infer the combined result from combine', () => {
const observer = new QueriesObserver(
queryClient,
[{ queryKey: queryKey(), queryFn: () => Promise.resolve(1) }],
{ combine: (results) => results.length },
)
expectTypeOf(observer).toEqualTypeOf<QueriesObserver<number>>()
})
it('should type the results combine is given', () => {
new QueriesObserver(queryClient, [], {
combine: (results) => {
expectTypeOf(results).toEqualTypeOf<Array<QueryObserverResult>>()
return results.length
},
})
})
it('should reject an option it does not declare', () => {
new QueriesObserver(queryClient, [], {
// @ts-expect-error combine is the only option
select: (results: Array<QueryObserverResult>) => results,
})
expectTypeOf<
keyof QueriesObserverOptions<number>
>().toEqualTypeOf<'combine'>()
})
it('should make the options argument optional', () => {
expectTypeOf<
ConstructorParameters<typeof QueriesObserver<number>>['length']
>().toEqualTypeOf<2 | 3>()
})
it('should take the query client as its first argument', () => {
expectTypeOf<
ConstructorParameters<typeof QueriesObserver<number>>[0]
>().toEqualTypeOf<QueryClient>()
})
it('should reject an option it does not declare on an observed query', () => {
new QueriesObserver(queryClient, [
// @ts-expect-error staleTimes is not a query option
{ queryKey: queryKey(), staleTimes: 1000 },
])
const observer = new QueriesObserver(queryClient, [
{ queryKey: queryKey(), staleTime: 1000 },
])
expectTypeOf(observer).toEqualTypeOf<
QueriesObserver<Array<QueryObserverResult>>
>()
})
})
describe('setQueries', () => {
it('should take an array of query options', () => {
const observer = new QueriesObserver(queryClient, [])
expectTypeOf(observer.setQueries)
.parameter(0)
.toEqualTypeOf<Array<QueryObserverOptions>>()
})
it('should take the same options as the constructor', () => {
const observer = new QueriesObserver(queryClient, [], {
combine: (results) => results.length,
})
expectTypeOf(observer.setQueries)
.parameter(1)
.toEqualTypeOf<QueriesObserverOptions<number> | undefined>()
})
it('should make the options argument optional', () => {
const observer = new QueriesObserver(queryClient, [])
expectTypeOf<
Parameters<typeof observer.setQueries>['length']
>().toEqualTypeOf<1 | 2>()
})
it('should return nothing', () => {
const observer = new QueriesObserver(queryClient, [])
expectTypeOf(observer.setQueries).returns.toEqualTypeOf<void>()
})
})
describe('getCurrentResult', () => {
it('should return the array of results rather than the combined result', () => {
const observer = new QueriesObserver(queryClient, [], {
combine: (results) => results.length,
})
expectTypeOf(observer.getCurrentResult()).toEqualTypeOf<
Array<QueryObserverResult>
>()
})
it('should take no arguments', () => {
const observer = new QueriesObserver(queryClient, [])
expectTypeOf<
Parameters<typeof observer.getCurrentResult>
>().toEqualTypeOf<[]>()
})
})
describe('getQueries', () => {
it('should return the observed queries', () => {
const observer = new QueriesObserver(queryClient, [])
expectTypeOf(observer.getQueries()).toEqualTypeOf<Array<Query>>()
})
})
describe('getObservers', () => {
it('should return the underlying observers', () => {
const observer = new QueriesObserver(queryClient, [])
expectTypeOf(observer.getObservers()).toEqualTypeOf<
Array<QueryObserver>
>()
})
})
describe('getOptimisticResult', () => {
it('should take an array of query options', () => {
const observer = new QueriesObserver(queryClient, [])
expectTypeOf(observer.getOptimisticResult)
.parameter(0)
.toEqualTypeOf<Array<QueryObserverOptions>>()
})
it('should take a combine function that accepts undefined but is required', () => {
const observer = new QueriesObserver(queryClient, [], {
combine: (results) => results.length,
})
expectTypeOf(observer.getOptimisticResult)
.parameter(1)
.toEqualTypeOf<
((result: Array<QueryObserverResult>) => number) | undefined
>()
expectTypeOf<
Parameters<typeof observer.getOptimisticResult>['length']
>().toEqualTypeOf<2>()
// @ts-expect-error the combine argument may be undefined but must be passed
observer.getOptimisticResult([])
observer.getOptimisticResult([], undefined)
})
it('should return the raw results as the first tuple member', () => {
const observer = new QueriesObserver(queryClient, [], {
combine: (results) => results.length,
})
expectTypeOf(
observer.getOptimisticResult([], undefined)[0],
).toEqualTypeOf<Array<QueryObserverResult>>()
})
it('should return a combine function producing the combined result as the second tuple member', () => {
const observer = new QueriesObserver(queryClient, [], {
combine: (results) => results.length,
})
expectTypeOf(
observer.getOptimisticResult([], undefined)[1],
).toEqualTypeOf<(r?: Array<QueryObserverResult>) => number>()
})
it('should return a track function producing the raw results as the third tuple member', () => {
const observer = new QueriesObserver(queryClient, [], {
combine: (results) => results.length,
})
expectTypeOf(
observer.getOptimisticResult([], undefined)[2],
).toEqualTypeOf<() => Array<QueryObserverResult>>()
})
it('should return a tuple of exactly three members', () => {
const observer = new QueriesObserver(queryClient, [])
expectTypeOf(observer.getOptimisticResult([], undefined)).toEqualTypeOf<
[
rawResult: Array<QueryObserverResult>,
combineResult: (
r?: Array<QueryObserverResult>,
) => Array<QueryObserverResult>,
trackResult: () => Array<QueryObserverResult>,
]
>()
})
})
describe('subscribe', () => {
it('should take a listener receiving the array of results', () => {
const observer = new QueriesObserver(queryClient, [], {
combine: (results) => results.length,
})
expectTypeOf(observer.subscribe)
.parameter(0)
.parameter(0)
.toEqualTypeOf<Array<QueryObserverResult>>()
})
it('should take a listener returning nothing', () => {
const observer = new QueriesObserver(queryClient, [])
expectTypeOf(observer.subscribe)
.parameter(0)
.returns.toEqualTypeOf<void>()
})
it('should return a function that unsubscribes', () => {
const observer = new QueriesObserver(queryClient, [])
expectTypeOf(observer.subscribe).returns.toEqualTypeOf<() => void>()
})
})
describe('hasListeners', () => {
it('should return whether the observer is subscribed to', () => {
const observer = new QueriesObserver(queryClient, [])
expectTypeOf(observer.hasListeners()).toEqualTypeOf<boolean>()
})
})
describe('destroy', () => {
it('should return nothing', () => {
const observer = new QueriesObserver(queryClient, [])
expectTypeOf(observer.destroy()).toEqualTypeOf<void>()
})
})
})

View File

@@ -0,0 +1,854 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { queryKey, sleep } from '@tanstack/query-test-utils'
import { QueriesObserver, QueryClient, QueryObserver } from '..'
import type { QueryObserverResult } from '..'
describe('queriesObserver', () => {
let queryClient: QueryClient
beforeEach(() => {
vi.useFakeTimers()
queryClient = new QueryClient()
queryClient.mount()
})
afterEach(() => {
queryClient.clear()
vi.useRealTimers()
})
it('should return an array with all query results', async () => {
const key1 = queryKey()
const key2 = queryKey()
const queryFn1 = vi.fn().mockReturnValue(1)
const queryFn2 = vi.fn().mockReturnValue(2)
const observer = new QueriesObserver(queryClient, [
{ queryKey: key1, queryFn: queryFn1 },
{ queryKey: key2, queryFn: queryFn2 },
])
let observerResult
const unsubscribe = observer.subscribe((result) => {
observerResult = result
})
await vi.advanceTimersByTimeAsync(0)
unsubscribe()
expect(observerResult).toMatchObject([{ data: 1 }, { data: 2 }])
})
it('should return current queries via getQueries', async () => {
const key1 = queryKey()
const key2 = queryKey()
const queryFn1 = vi.fn().mockReturnValue(1)
const queryFn2 = vi.fn().mockReturnValue(2)
const observer = new QueriesObserver(queryClient, [
{ queryKey: key1, queryFn: queryFn1 },
{ queryKey: key2, queryFn: queryFn2 },
])
const unsubscribe = observer.subscribe(() => undefined)
await vi.advanceTimersByTimeAsync(0)
const queries = observer.getQueries()
expect(queries).toHaveLength(2)
expect(queries[0]?.queryKey).toEqual(key1)
expect(queries[1]?.queryKey).toEqual(key2)
unsubscribe()
})
it('should update when a query updates', async () => {
const key1 = queryKey()
const key2 = queryKey()
const queryFn1 = vi.fn().mockReturnValue(1)
const queryFn2 = vi.fn().mockReturnValue(2)
const observer = new QueriesObserver(queryClient, [
{ queryKey: key1, queryFn: queryFn1 },
{ queryKey: key2, queryFn: queryFn2 },
])
const results: Array<Array<QueryObserverResult>> = []
results.push(observer.getCurrentResult())
const unsubscribe = observer.subscribe((result) => {
results.push(result)
})
await vi.advanceTimersByTimeAsync(0)
queryClient.setQueryData(key2, 3)
unsubscribe()
expect(results.length).toBe(6)
expect(results[0]).toMatchObject([
{ status: 'pending', fetchStatus: 'idle', data: undefined },
{ status: 'pending', fetchStatus: 'idle', data: undefined },
])
expect(results[1]).toMatchObject([
{ status: 'pending', fetchStatus: 'fetching', data: undefined },
{ status: 'pending', fetchStatus: 'idle', data: undefined },
])
expect(results[2]).toMatchObject([
{ status: 'pending', fetchStatus: 'fetching', data: undefined },
{ status: 'pending', fetchStatus: 'fetching', data: undefined },
])
expect(results[3]).toMatchObject([
{ status: 'success', data: 1 },
{ status: 'pending', fetchStatus: 'fetching', data: undefined },
])
expect(results[4]).toMatchObject([
{ status: 'success', data: 1 },
{ status: 'success', data: 2 },
])
expect(results[5]).toMatchObject([
{ status: 'success', data: 1 },
{ status: 'success', data: 3 },
])
})
it('should return current observers via getObservers', async () => {
const key1 = queryKey()
const key2 = queryKey()
const queryFn1 = vi.fn().mockReturnValue(1)
const queryFn2 = vi.fn().mockReturnValue(2)
const observer = new QueriesObserver(queryClient, [
{ queryKey: key1, queryFn: queryFn1 },
{ queryKey: key2, queryFn: queryFn2 },
])
const unsubscribe = observer.subscribe(() => undefined)
await vi.advanceTimersByTimeAsync(0)
const observers = observer.getObservers()
expect(observers).toHaveLength(2)
expect(observers[0]).toBeInstanceOf(QueryObserver)
expect(observers[1]).toBeInstanceOf(QueryObserver)
unsubscribe()
})
it('should update when a query is removed', async () => {
const key1 = queryKey()
const key2 = queryKey()
const queryFn1 = vi.fn().mockReturnValue(1)
const queryFn2 = vi.fn().mockReturnValue(2)
const observer = new QueriesObserver(queryClient, [
{ queryKey: key1, queryFn: queryFn1 },
{ queryKey: key2, queryFn: queryFn2 },
])
const results: Array<Array<QueryObserverResult>> = []
results.push(observer.getCurrentResult())
const unsubscribe = observer.subscribe((result) => {
results.push(result)
})
await vi.advanceTimersByTimeAsync(0)
observer.setQueries([{ queryKey: key2, queryFn: queryFn2 }])
const queryCache = queryClient.getQueryCache()
expect(queryCache.find({ queryKey: key1, type: 'active' })).toBeUndefined()
expect(
queryCache.find({ queryKey: key2, type: 'active' })?.queryKey,
).toEqual(key2)
unsubscribe()
expect(queryCache.find({ queryKey: key1, type: 'active' })).toBeUndefined()
expect(queryCache.find({ queryKey: key2, type: 'active' })).toBeUndefined()
expect(results.length).toBe(6)
expect(results[0]).toMatchObject([
{ status: 'pending', fetchStatus: 'idle', data: undefined },
{ status: 'pending', fetchStatus: 'idle', data: undefined },
])
expect(results[1]).toMatchObject([
{ status: 'pending', fetchStatus: 'fetching', data: undefined },
{ status: 'pending', fetchStatus: 'idle', data: undefined },
])
expect(results[2]).toMatchObject([
{ status: 'pending', fetchStatus: 'fetching', data: undefined },
{ status: 'pending', fetchStatus: 'fetching', data: undefined },
])
expect(results[3]).toMatchObject([
{ status: 'success', data: 1 },
{ status: 'pending', fetchStatus: 'fetching', data: undefined },
])
expect(results[4]).toMatchObject([
{ status: 'success', data: 1 },
{ status: 'success', data: 2 },
])
expect(results[5]).toMatchObject([{ status: 'success', data: 2 }])
})
it('should update when a query changed position', async () => {
const key1 = queryKey()
const key2 = queryKey()
const queryFn1 = vi.fn().mockReturnValue(1)
const queryFn2 = vi.fn().mockReturnValue(2)
const observer = new QueriesObserver(queryClient, [
{ queryKey: key1, queryFn: queryFn1 },
{ queryKey: key2, queryFn: queryFn2 },
])
const results: Array<Array<QueryObserverResult>> = []
results.push(observer.getCurrentResult())
const unsubscribe = observer.subscribe((result) => {
results.push(result)
})
await vi.advanceTimersByTimeAsync(0)
observer.setQueries([
{ queryKey: key2, queryFn: queryFn2 },
{ queryKey: key1, queryFn: queryFn1 },
])
unsubscribe()
expect(results.length).toBe(6)
expect(results[0]).toMatchObject([
{ status: 'pending', fetchStatus: 'idle', data: undefined },
{ status: 'pending', fetchStatus: 'idle', data: undefined },
])
expect(results[1]).toMatchObject([
{ status: 'pending', fetchStatus: 'fetching', data: undefined },
{ status: 'pending', fetchStatus: 'idle', data: undefined },
])
expect(results[2]).toMatchObject([
{ status: 'pending', fetchStatus: 'fetching', data: undefined },
{ status: 'pending', fetchStatus: 'fetching', data: undefined },
])
expect(results[3]).toMatchObject([
{ status: 'success', data: 1 },
{ status: 'pending', fetchStatus: 'fetching', data: undefined },
])
expect(results[4]).toMatchObject([
{ status: 'success', data: 1 },
{ status: 'success', data: 2 },
])
expect(results[5]).toMatchObject([
{ status: 'success', data: 2 },
{ status: 'success', data: 1 },
])
})
it('should not update when nothing has changed', async () => {
const key1 = queryKey()
const key2 = queryKey()
const queryFn1 = vi.fn().mockReturnValue(1)
const queryFn2 = vi.fn().mockReturnValue(2)
const observer = new QueriesObserver(queryClient, [
{ queryKey: key1, queryFn: queryFn1 },
{ queryKey: key2, queryFn: queryFn2 },
])
const results: Array<Array<QueryObserverResult>> = []
results.push(observer.getCurrentResult())
const unsubscribe = observer.subscribe((result) => {
results.push(result)
})
await vi.advanceTimersByTimeAsync(0)
observer.setQueries([
{ queryKey: key1, queryFn: queryFn1 },
{ queryKey: key2, queryFn: queryFn2 },
])
unsubscribe()
expect(results.length).toBe(5)
expect(results[0]).toMatchObject([
{ status: 'pending', fetchStatus: 'idle', data: undefined },
{ status: 'pending', fetchStatus: 'idle', data: undefined },
])
expect(results[1]).toMatchObject([
{ status: 'pending', fetchStatus: 'fetching', data: undefined },
{ status: 'pending', fetchStatus: 'idle', data: undefined },
])
expect(results[2]).toMatchObject([
{ status: 'pending', fetchStatus: 'fetching', data: undefined },
{ status: 'pending', fetchStatus: 'fetching', data: undefined },
])
expect(results[3]).toMatchObject([
{ status: 'success', data: 1 },
{ status: 'pending', fetchStatus: 'fetching', data: undefined },
])
expect(results[4]).toMatchObject([
{ status: 'success', data: 1 },
{ status: 'success', data: 2 },
])
})
it('should trigger all fetches when subscribed', () => {
const key1 = queryKey()
const key2 = queryKey()
const queryFn1 = vi.fn().mockReturnValue(1)
const queryFn2 = vi.fn().mockReturnValue(2)
const observer = new QueriesObserver(queryClient, [
{ queryKey: key1, queryFn: queryFn1 },
{ queryKey: key2, queryFn: queryFn2 },
])
const unsubscribe = observer.subscribe(() => undefined)
unsubscribe()
expect(queryFn1).toHaveBeenCalledTimes(1)
expect(queryFn2).toHaveBeenCalledTimes(1)
})
it('should not destroy the observer if there is still a subscription', async () => {
const key1 = queryKey()
const observer = new QueriesObserver(queryClient, [
{
queryKey: key1,
queryFn: () => sleep(20).then(() => 1),
},
])
const subscription1Handler = vi.fn()
const subscription2Handler = vi.fn()
const unsubscribe1 = observer.subscribe(subscription1Handler)
const unsubscribe2 = observer.subscribe(subscription2Handler)
unsubscribe1()
await vi.advanceTimersByTimeAsync(20)
// 1 call: pending
expect(subscription1Handler).toHaveBeenCalledTimes(1)
// 1 call: success
expect(subscription2Handler).toHaveBeenCalledTimes(1)
// Clean-up
unsubscribe2()
})
it('should handle duplicate query keys in different positions', async () => {
const key1 = queryKey()
const key2 = queryKey()
const queryFn1 = vi.fn().mockReturnValue(1)
const queryFn2 = vi.fn().mockReturnValue(2)
const observer = new QueriesObserver(queryClient, [
{ queryKey: key1, queryFn: queryFn1 },
{ queryKey: key2, queryFn: queryFn2 },
{ queryKey: key1, queryFn: queryFn1 },
])
const results: Array<Array<QueryObserverResult>> = []
results.push(
observer.getOptimisticResult(
[
{ queryKey: key1, queryFn: queryFn1 },
{ queryKey: key2, queryFn: queryFn2 },
{ queryKey: key1, queryFn: queryFn1 },
],
undefined,
)[0],
)
const unsubscribe = observer.subscribe((result) => {
results.push(result)
})
await vi.advanceTimersByTimeAsync(0)
unsubscribe()
expect(results.length).toBe(6)
expect(results[0]).toMatchObject([
{ status: 'pending', fetchStatus: 'idle', data: undefined },
{ status: 'pending', fetchStatus: 'idle', data: undefined },
{ status: 'pending', fetchStatus: 'idle', data: undefined },
])
expect(results[1]).toMatchObject([
{ status: 'pending', fetchStatus: 'fetching', data: undefined },
{ status: 'pending', fetchStatus: 'idle', data: undefined },
{ status: 'pending', fetchStatus: 'idle', data: undefined },
])
expect(results[2]).toMatchObject([
{ status: 'pending', fetchStatus: 'fetching', data: undefined },
{ status: 'pending', fetchStatus: 'fetching', data: undefined },
{ status: 'pending', fetchStatus: 'idle', data: undefined },
])
expect(results[3]).toMatchObject([
{ status: 'success', fetchStatus: 'idle', data: 1 },
{ status: 'pending', fetchStatus: 'fetching', data: undefined },
{ status: 'pending', fetchStatus: 'idle', data: undefined },
])
expect(results[4]).toMatchObject([
{ status: 'success', fetchStatus: 'idle', data: 1 },
{ status: 'pending', fetchStatus: 'fetching', data: undefined },
{ status: 'success', fetchStatus: 'idle', data: 1 },
])
expect(results[5]).toMatchObject([
{ status: 'success', fetchStatus: 'idle', data: 1 },
{ status: 'success', fetchStatus: 'idle', data: 2 },
{ status: 'success', fetchStatus: 'idle', data: 1 },
])
// Verify that queryFn1 was only called once despite being used twice
expect(queryFn1).toHaveBeenCalledTimes(1)
expect(queryFn2).toHaveBeenCalledTimes(1)
})
it('should notify when results change during early return', async () => {
const key1 = queryKey()
const key2 = queryKey()
const queryFn1 = vi.fn().mockReturnValue(1)
const queryFn2 = vi.fn().mockReturnValue(2)
queryClient.setQueryData(key1, 1)
queryClient.setQueryData(key2, 2)
const observer = new QueriesObserver(queryClient, [
{ queryKey: key1, queryFn: queryFn1 },
{ queryKey: key2, queryFn: queryFn2 },
])
const results: Array<Array<QueryObserverResult>> = []
results.push(observer.getCurrentResult())
const onUpdate = vi.fn((result: Array<QueryObserverResult>) => {
results.push(result)
})
const unsubscribe = observer.subscribe(onUpdate)
const baseline = results.length
observer.setQueries([
{
queryKey: key1,
queryFn: queryFn1,
select: (d: any) => d + 100,
},
{
queryKey: key2,
queryFn: queryFn2,
select: (d: any) => d + 100,
},
])
await vi.advanceTimersByTimeAsync(0)
unsubscribe()
expect(results.length).toBeGreaterThan(baseline)
expect(results[results.length - 1]).toMatchObject([
{ status: 'success', data: 101 },
{ status: 'success', data: 102 },
])
})
it('should update combined result when queries are added with stable combine reference', () => {
const combine = vi.fn((results: Array<QueryObserverResult>) => ({
count: results.length,
results,
}))
const key1 = queryKey()
const key2 = queryKey()
const queryFn1 = vi.fn().mockReturnValue(1)
const queryFn2 = vi.fn().mockReturnValue(2)
const observer = new QueriesObserver<{
count: number
results: Array<QueryObserverResult>
}>(queryClient, [{ queryKey: key1, queryFn: queryFn1 }], { combine })
const [initialRaw, getInitialCombined] = observer.getOptimisticResult(
[{ queryKey: key1, queryFn: queryFn1 }],
combine,
)
const initialCombined = getInitialCombined(initialRaw)
expect(initialCombined.count).toBe(1)
const newQueries = [
{ queryKey: key1, queryFn: queryFn1 },
{ queryKey: key2, queryFn: queryFn2 },
]
const [newRaw, getNewCombined] = observer.getOptimisticResult(
newQueries,
combine,
)
const newCombined = getNewCombined(newRaw)
expect(newCombined.count).toBe(2)
})
it('should skip combine notifications while suspense queries have no data', () => {
const key = queryKey()
const combine = vi.fn((results: Array<QueryObserverResult>) =>
results.map((result) => result.data),
)
const query = {
queryKey: key,
queryFn: () => sleep(10).then(() => 'data'),
staleTime: Infinity,
suspense: true,
}
queryClient.setQueryData(key, 'data')
const observer = new QueriesObserver<Array<unknown>>(queryClient, [query], {
combine,
})
const [rawResult, getCombinedResult] = observer.getOptimisticResult(
[query],
combine,
)
expect(getCombinedResult(rawResult)).toEqual(['data'])
expect(combine).toHaveBeenCalledTimes(1)
const unsubscribe = observer.subscribe(() => undefined)
void queryClient.resetQueries({ queryKey: key })
expect(combine).toHaveBeenCalledTimes(1)
unsubscribe()
})
it('should skip combine notifications after suspense is enabled without structural changes', () => {
const key = queryKey()
const combine = vi.fn((results: Array<QueryObserverResult>) =>
results.map((result) => result.data),
)
const query = {
queryKey: key,
queryFn: () => sleep(10).then(() => 'data'),
staleTime: Infinity,
suspense: false,
}
queryClient.setQueryData(key, 'data')
const observer = new QueriesObserver<Array<unknown>>(queryClient, [query], {
combine,
})
const [rawResult, getCombinedResult] = observer.getOptimisticResult(
[query],
combine,
)
expect(getCombinedResult(rawResult)).toEqual(['data'])
expect(combine).toHaveBeenCalledTimes(1)
const unsubscribe = observer.subscribe(() => undefined)
observer.setQueries(
[
{
...query,
suspense: true,
},
],
{ combine },
)
void queryClient.resetQueries({ queryKey: key })
expect(combine).toHaveBeenCalledTimes(1)
unsubscribe()
})
it('should handle queries being removed with stable combine reference', () => {
const combine = vi.fn((results: Array<QueryObserverResult>) => ({
count: results.length,
results,
}))
const key1 = queryKey()
const key2 = queryKey()
const queryFn1 = vi.fn().mockReturnValue(1)
const queryFn2 = vi.fn().mockReturnValue(2)
const observer = new QueriesObserver<{
count: number
results: Array<QueryObserverResult>
}>(
queryClient,
[
{ queryKey: key1, queryFn: queryFn1 },
{ queryKey: key2, queryFn: queryFn2 },
],
{ combine },
)
const [initialRaw, getInitialCombined] = observer.getOptimisticResult(
[
{ queryKey: key1, queryFn: queryFn1 },
{ queryKey: key2, queryFn: queryFn2 },
],
combine,
)
const initialCombined = getInitialCombined(initialRaw)
expect(initialCombined.count).toBe(2)
const newQueries = [{ queryKey: key1, queryFn: queryFn1 }]
const [newRaw, getNewCombined] = observer.getOptimisticResult(
newQueries,
combine,
)
const newCombined = getNewCombined(newRaw)
expect(newCombined.count).toBe(1)
})
it('should update combined result when queries are replaced with different ones (same length)', () => {
const combine = vi.fn((results: Array<QueryObserverResult>) => ({
keys: results.map((r) => r.status),
results,
}))
const key1 = queryKey()
const key2 = queryKey()
const queryFn1 = vi.fn().mockReturnValue(1)
const queryFn2 = vi.fn().mockReturnValue(2)
queryClient.setQueryData(key1, 'cached-1')
const observer = new QueriesObserver<{
keys: Array<string>
results: Array<QueryObserverResult>
}>(queryClient, [{ queryKey: key1, queryFn: queryFn1 }], { combine })
const [initialRaw, getInitialCombined] = observer.getOptimisticResult(
[{ queryKey: key1, queryFn: queryFn1 }],
combine,
)
const initialCombined = getInitialCombined(initialRaw)
expect(initialCombined.keys).toEqual(['success'])
const [newRaw, getNewCombined] = observer.getOptimisticResult(
[{ queryKey: key2, queryFn: queryFn2 }],
combine,
)
const newCombined = getNewCombined(newRaw)
expect(newCombined.keys).toEqual(['pending'])
})
it('should recalculate combined result when combine function changes', () => {
const combine1 = vi.fn((results: Array<QueryObserverResult>) => ({
total: results.length,
}))
const combine2 = vi.fn((results: Array<QueryObserverResult>) => ({
total: results.length * 4,
}))
const key1 = queryKey()
const key2 = queryKey()
const queryFn1 = vi.fn().mockReturnValue(1)
const queryFn2 = vi.fn().mockReturnValue(2)
const queries = [
{ queryKey: key1, queryFn: queryFn1 },
{ queryKey: key2, queryFn: queryFn2 },
]
const observer = new QueriesObserver<{ total: number }>(
queryClient,
queries,
{ combine: combine1 },
)
const [raw1, getCombined1] = observer.getOptimisticResult(queries, combine1)
const combined1 = getCombined1(raw1)
const [raw2, getCombined2] = observer.getOptimisticResult(queries, combine2)
const combined2 = getCombined2(raw2)
expect(combined1.total).toBe(2)
expect(combined2.total).toBe(8)
})
it('should use fallback result when combineResult is called without raw argument', () => {
const combine = vi.fn((results: Array<QueryObserverResult>) => ({
count: results.length,
}))
const key = queryKey()
const queryFn = vi.fn().mockReturnValue(1)
const observer = new QueriesObserver<{ count: number }>(
queryClient,
[{ queryKey: key, queryFn }],
{ combine },
)
const [, getCombined] = observer.getOptimisticResult(
[{ queryKey: key, queryFn }],
combine,
)
const combined = getCombined()
expect(combined.count).toBe(1)
})
it('should return observer result directly when notifyOnChangeProps is set', () => {
const key = queryKey()
const queryFn = vi.fn().mockReturnValue(1)
const observer = new QueriesObserver(queryClient, [
{ queryKey: key, queryFn, notifyOnChangeProps: ['data'] },
])
const trackResultSpy = vi.spyOn(QueryObserver.prototype, 'trackResult')
const [, , trackResult] = observer.getOptimisticResult(
[{ queryKey: key, queryFn, notifyOnChangeProps: ['data'] }],
undefined,
)
const trackedResults = trackResult()
expect(trackedResults).toHaveLength(1)
// trackResult should NOT be called when notifyOnChangeProps is set
expect(trackResultSpy).not.toHaveBeenCalled()
trackResultSpy.mockRestore()
})
it('should return cached combined result when nothing has changed', () => {
const combine = vi.fn((results: Array<QueryObserverResult>) => ({
count: results.length,
}))
const key = queryKey()
const queryFn = vi.fn().mockReturnValue(1)
const queries = [{ queryKey: key, queryFn }]
const observer = new QueriesObserver<{ count: number }>(
queryClient,
queries,
{ combine },
)
const [raw1, getCombined1] = observer.getOptimisticResult(queries, combine)
const combined1 = getCombined1(raw1)
const [raw2, getCombined2] = observer.getOptimisticResult(queries, combine)
const combined2 = getCombined2(raw2)
// Same combine, same queries → cached result returned
expect(combined1).toBe(combined2)
})
it.each([
['zero', 0],
['negative zero', -0],
['NaN', Number.NaN],
['false', false],
['empty string', ''],
['null', null],
['undefined', undefined],
['zero bigint', 0n],
])(
'should cache the falsy combined result %s when nothing has changed',
(_name, value) => {
const combine = vi.fn(() => value)
const key = queryKey()
const queries = [{ queryKey: key, queryFn: () => 1 }]
const observer = new QueriesObserver<typeof value>(queryClient, queries, {
combine,
})
const [raw1, getCombined1] = observer.getOptimisticResult(
queries,
combine,
)
getCombined1(raw1)
const [raw2, getCombined2] = observer.getOptimisticResult(
queries,
combine,
)
getCombined2(raw2)
expect(combine).toHaveBeenCalledTimes(1)
},
)
it('should track properties on all observers when trackResult is called', () => {
const key1 = queryKey()
const key2 = queryKey()
const queryFn1 = () => 'data1'
const queryFn2 = () => 'data2'
const observer = new QueriesObserver(queryClient, [
{ queryKey: key1, queryFn: queryFn1 },
{ queryKey: key2, queryFn: queryFn2 },
])
const trackPropSpy = vi.spyOn(QueryObserver.prototype, 'trackProp')
const [, , trackResult] = observer.getOptimisticResult(
[
{ queryKey: key1, queryFn: queryFn1 },
{ queryKey: key2, queryFn: queryFn2 },
],
undefined,
)
const trackedResults = trackResult()
expect(trackedResults).toHaveLength(2)
// Accessing a property on the first result should trigger trackProp on all observers
void trackedResults[0]!.status
// 1 direct call from the accessed observer's proxy +
// 2 synchronized calls from onPropTracked callback (one per observer)
expect(trackPropSpy).toHaveBeenCalledWith('status')
expect(trackPropSpy).toHaveBeenCalledTimes(3)
void trackedResults[1]!.status
expect(trackPropSpy).toHaveBeenCalledTimes(4)
trackPropSpy.mockRestore()
})
it('should subscribe to new observers when a query is added while subscribed', async () => {
const key1 = queryKey()
const key2 = queryKey()
const key3 = queryKey()
const queryFn1 = vi.fn().mockReturnValue(1)
const queryFn2 = vi.fn().mockReturnValue(2)
const queryFn3 = vi.fn(() => sleep(10).then(() => 3))
const observer = new QueriesObserver(queryClient, [
{ queryKey: key1, queryFn: queryFn1 },
{ queryKey: key2, queryFn: queryFn2 },
])
const results: Array<Array<QueryObserverResult>> = []
const unsubscribe = observer.subscribe((result) => {
results.push(result)
})
await vi.advanceTimersByTimeAsync(0)
expect(results[results.length - 1]).toMatchObject([
{ status: 'success', data: 1 },
{ status: 'success', data: 2 },
])
observer.setQueries([
{ queryKey: key1, queryFn: queryFn1 },
{ queryKey: key2, queryFn: queryFn2 },
{ queryKey: key3, queryFn: queryFn3 },
])
await vi.advanceTimersByTimeAsync(10)
unsubscribe()
expect(results[results.length - 1]).toMatchObject([
{ status: 'success', data: 1 },
{ status: 'success', data: 2 },
{ status: 'success', data: 3 },
])
})
})

View File

@@ -0,0 +1,976 @@
import { afterEach, beforeEach, describe, expectTypeOf, it } from 'vitest'
import { queryKey } from '@tanstack/query-test-utils'
import { QueryClient } from '..'
import type {
CancelOptions,
DefaultError,
Query,
QueryKey,
QueryObserver,
QueryOptions,
QueryState,
} from '..'
import type {
Action,
FetchContext,
FetchDirection,
FetchMeta,
FetchOptions,
QueryBehavior,
fetchState,
} from '../query'
class CustomError extends Error {
name = 'CustomError' as const
}
describe('query', () => {
let queryClient: QueryClient
beforeEach(() => {
queryClient = new QueryClient()
queryClient.mount()
})
afterEach(() => {
queryClient.clear()
})
describe('QueryState', () => {
it('should declare every field with its own type', () => {
expectTypeOf<QueryState<{ value: string }, CustomError>>().toEqualTypeOf<{
data: { value: string } | undefined
dataUpdateCount: number
dataUpdatedAt: number
error: CustomError | null
errorUpdateCount: number
errorUpdatedAt: number
fetchFailureCount: number
fetchFailureReason: CustomError | null
fetchMeta: FetchMeta | null
isInvalidated: boolean
status: 'pending' | 'error' | 'success'
fetchStatus: 'fetching' | 'paused' | 'idle'
}>()
})
it('should default its data to unknown and its error to the default error', () => {
expectTypeOf<QueryState>().toEqualTypeOf<
QueryState<unknown, DefaultError>
>()
expectTypeOf<QueryState['data']>().toEqualTypeOf<unknown>()
expectTypeOf<QueryState['error']>().toEqualTypeOf<DefaultError | null>()
})
it('should keep every field required and writable', () => {
type State = QueryState<{ value: string }, CustomError>
type OptionalKeys = {
[K in keyof State]-?: {} extends Pick<State, K> ? K : never
}[keyof State]
expectTypeOf<[OptionalKeys]>().toEqualTypeOf<[never]>()
expectTypeOf<State>().toEqualTypeOf<{
-readonly [K in keyof State]: State[K]
}>()
})
})
describe('FetchMeta', () => {
it('should carry an optional fetchMore direction', () => {
expectTypeOf<FetchMeta>().toEqualTypeOf<{
fetchMore?: { direction: 'forward' | 'backward' }
}>()
})
})
describe('FetchDirection', () => {
it('should be the forward and backward literals', () => {
expectTypeOf<FetchDirection>().toEqualTypeOf<'forward' | 'backward'>()
})
})
describe('FetchOptions', () => {
it('should type its initialPromise from its data type', () => {
expectTypeOf<FetchOptions<{ value: string }>>().toEqualTypeOf<{
cancelRefetch?: boolean
meta?: FetchMeta
initialPromise?: Promise<{ value: string }>
}>()
})
it('should default its data type to unknown', () => {
expectTypeOf<FetchOptions['initialPromise']>().toEqualTypeOf<
Promise<unknown> | undefined
>()
})
})
describe('QueryBehavior', () => {
it('should type the fetch context given to onFetch', () => {
const behavior: QueryBehavior<
{ value: string },
CustomError,
{ value: string },
readonly ['a', 1]
> = {
onFetch: (context, query) => {
expectTypeOf(context).toEqualTypeOf<
FetchContext<
{ value: string },
CustomError,
{ value: string },
readonly ['a', 1]
>
>()
expectTypeOf(query).toEqualTypeOf<Query>()
},
}
expectTypeOf(behavior.onFetch).returns.toEqualTypeOf<void>()
})
it('should declare onFetch as its only, required member', () => {
expectTypeOf<keyof QueryBehavior>().toEqualTypeOf<'onFetch'>()
expectTypeOf<QueryBehavior>().toEqualTypeOf<{
onFetch: (
context: FetchContext<unknown, DefaultError, unknown, QueryKey>,
query: Query,
) => void
}>()
})
it('should default its type parameters', () => {
expectTypeOf<QueryBehavior>().toEqualTypeOf<
QueryBehavior<unknown, DefaultError, unknown, QueryKey>
>()
})
it('should default its data type to its query fn data type', () => {
expectTypeOf<QueryBehavior<{ value: string }>>().toEqualTypeOf<
QueryBehavior<
{ value: string },
DefaultError,
{ value: string },
QueryKey
>
>()
})
})
describe('FetchContext', () => {
it('should declare exactly its own keys', () => {
type TestFetchContext = FetchContext<
{ value: string },
CustomError,
{ value: string },
readonly ['a', 1]
>
expectTypeOf<keyof TestFetchContext>().toEqualTypeOf<
| 'fetchFn'
| 'fetchOptions'
| 'signal'
| 'options'
| 'client'
| 'queryKey'
| 'state'
>()
})
it('should type its fetchFn, signal and client', () => {
type TestFetchContext = FetchContext<
{ value: string },
CustomError,
{ value: string },
readonly ['a', 1]
>
expectTypeOf<TestFetchContext['fetchFn']>().toEqualTypeOf<
() => unknown | Promise<unknown>
>()
expectTypeOf<TestFetchContext['signal']>().toEqualTypeOf<AbortSignal>()
expectTypeOf<TestFetchContext['client']>().toEqualTypeOf<QueryClient>()
})
it('should type its fetchOptions as optional and data-agnostic', () => {
type TestFetchContext = FetchContext<
{ value: string },
CustomError,
{ value: string },
readonly ['a', 1]
>
// `Pick` rather than an index access: `T['k']` strips the `?` modifier,
// so an index access cannot tell `fetchOptions?: X` from
// `fetchOptions: X | undefined`.
expectTypeOf<Pick<TestFetchContext, 'fetchOptions'>>().toEqualTypeOf<{
fetchOptions?: FetchOptions<unknown>
}>()
})
it('should carry its data and error types into its state', () => {
type TestFetchContext = FetchContext<
{ value: string },
CustomError,
{ value: string },
readonly ['a', 1]
>
expectTypeOf<TestFetchContext['state']>().toEqualTypeOf<
QueryState<{ value: string }, CustomError>
>()
})
it('should keep its own queryKey while widening the queryKey of its options', () => {
type TestFetchContext = FetchContext<
{ value: string },
CustomError,
{ value: string },
readonly ['a', 1]
>
expectTypeOf<TestFetchContext['queryKey']>().toEqualTypeOf<
readonly ['a', 1]
>()
expectTypeOf<TestFetchContext['options']>().toEqualTypeOf<
QueryOptions<{ value: string }, CustomError, { value: string }, any>
>()
})
})
describe('Action', () => {
it('should discriminate the failed action by its type literal', () => {
expectTypeOf<
Extract<Action<{ value: string }, CustomError>, { type: 'failed' }>
>().toEqualTypeOf<{
type: 'failed'
failureCount: number
error: CustomError
}>()
})
it('should discriminate the fetch action by its type literal', () => {
expectTypeOf<
Extract<Action<{ value: string }, CustomError>, { type: 'fetch' }>
>().toEqualTypeOf<{ type: 'fetch'; meta?: FetchMeta }>()
})
it('should discriminate the success action by its type literal', () => {
expectTypeOf<
Extract<Action<{ value: string }, CustomError>, { type: 'success' }>
>().toEqualTypeOf<{
data: { value: string } | undefined
type: 'success'
dataUpdatedAt?: number
manual?: boolean
}>()
})
it('should discriminate the error action by its type literal', () => {
expectTypeOf<
Extract<Action<{ value: string }, CustomError>, { type: 'error' }>
>().toEqualTypeOf<{ type: 'error'; error: CustomError }>()
})
it('should discriminate the invalidate action by its type literal', () => {
expectTypeOf<
Extract<Action<{ value: string }, CustomError>, { type: 'invalidate' }>
>().toEqualTypeOf<{ type: 'invalidate' }>()
})
it('should discriminate the pause action by its type literal', () => {
expectTypeOf<
Extract<Action<{ value: string }, CustomError>, { type: 'pause' }>
>().toEqualTypeOf<{ type: 'pause' }>()
})
it('should discriminate the continue action by its type literal', () => {
expectTypeOf<
Extract<Action<{ value: string }, CustomError>, { type: 'continue' }>
>().toEqualTypeOf<{ type: 'continue' }>()
})
it('should discriminate the setState action by its type literal', () => {
expectTypeOf<
Extract<Action<{ value: string }, CustomError>, { type: 'setState' }>
>().toEqualTypeOf<{
type: 'setState'
state: Partial<QueryState<{ value: string }, CustomError>>
}>()
})
it('should union exactly the eight action type literals', () => {
expectTypeOf<
Action<{ value: string }, CustomError>['type']
>().toEqualTypeOf<
| 'continue'
| 'error'
| 'failed'
| 'fetch'
| 'invalidate'
| 'pause'
| 'setState'
| 'success'
>()
})
})
describe('fetchState', () => {
it('should return the fetching fields it resets', () => {
expectTypeOf<ReturnType<typeof fetchState>>().toEqualTypeOf<{
readonly fetchFailureCount: 0
readonly fetchFailureReason: null
readonly fetchStatus: 'fetching' | 'paused'
readonly error?: null
readonly status?: 'pending'
}>()
})
})
describe('queryKey', () => {
// A literal queryKey is preserved by `queryCache.build`, which is where
// that inference happens and where it is asserted
// (queryCache.test-d.tsx > build > should preserve a literal queryKey).
it('should default to a readonly unknown array', () => {
expectTypeOf<Query['queryKey']>().toEqualTypeOf<ReadonlyArray<unknown>>()
})
it('should carry the query key type parameter of its instance', () => {
const query = queryClient
.getQueryCache()
.build<
{ value: string },
CustomError,
{ value: string },
['k', number]
>(queryClient, {
queryKey: ['k', 1],
queryFn: () => Promise.resolve({ value: 'data' }),
})
expectTypeOf(query.queryKey).toEqualTypeOf<['k', number]>()
})
})
describe('queryHash', () => {
it('should be typed as a string', () => {
const query = queryClient
.getQueryCache()
.build<{ value: string }, CustomError>(queryClient, {
queryKey: queryKey(),
queryFn: () => Promise.resolve({ value: 'data' }),
})
expectTypeOf(query.queryHash).toEqualTypeOf<string>()
})
})
describe('state', () => {
it('should carry the data and error types of the query', () => {
const query = queryClient
.getQueryCache()
.build<{ value: string }, CustomError>(queryClient, {
queryKey: queryKey(),
queryFn: () => Promise.resolve({ value: 'data' }),
})
expectTypeOf(query.state).toEqualTypeOf<
QueryState<{ value: string }, CustomError>
>()
})
})
describe('options', () => {
it('should carry the type parameters of the query', () => {
const query = queryClient
.getQueryCache()
.build<{ value: string }, CustomError>(queryClient, {
queryKey: queryKey(),
queryFn: () => Promise.resolve({ value: 'data' }),
})
expectTypeOf(query.options).toEqualTypeOf<
QueryOptions<
{ value: string },
CustomError,
{ value: string },
ReadonlyArray<unknown>
>
>()
expectTypeOf(query.options.queryKey).toEqualTypeOf<
ReadonlyArray<unknown> | undefined
>()
expectTypeOf<Extract<typeof query.options.retry, Function>>()
.parameter(1)
.toEqualTypeOf<CustomError>()
expectTypeOf(query.options.initialData).toEqualTypeOf<
{ value: string } | (() => { value: string } | undefined) | undefined
>()
})
it('should always be declared', () => {
type Query_ = Query<{ value: string }, CustomError>
type OptionalKeys = {
[K in keyof Query_]-?: {} extends Pick<Query_, K> ? K : never
}[keyof Query_]
expectTypeOf<
'options' extends OptionalKeys ? true : false
>().toEqualTypeOf<false>()
})
})
describe('meta', () => {
it('should be typed as the query meta or undefined', () => {
const query = queryClient
.getQueryCache()
.build<{ value: string }, CustomError>(queryClient, {
queryKey: queryKey(),
queryFn: () => Promise.resolve({ value: 'data' }),
})
expectTypeOf(query.meta).toEqualTypeOf<
Record<string, unknown> | undefined
>()
})
})
describe('resetState', () => {
it('should carry the data and error types of the query', () => {
const query = queryClient
.getQueryCache()
.build<{ value: string }, CustomError>(queryClient, {
queryKey: queryKey(),
queryFn: () => Promise.resolve({ value: 'data' }),
})
expectTypeOf(query.resetState).toEqualTypeOf<
QueryState<{ value: string }, CustomError>
>()
})
})
describe('queryType', () => {
it('should be the infinite literal or undefined', () => {
const query = queryClient
.getQueryCache()
.build<{ value: string }, CustomError>(queryClient, {
queryKey: queryKey(),
queryFn: () => Promise.resolve({ value: 'data' }),
})
expectTypeOf(query.queryType).toEqualTypeOf<'infinite' | undefined>()
})
})
describe('promise', () => {
it('should resolve with the data type or be undefined', () => {
const query = queryClient
.getQueryCache()
.build<{ value: string }, CustomError>(queryClient, {
queryKey: queryKey(),
queryFn: () => Promise.resolve({ value: 'data' }),
})
expectTypeOf(query.promise).toEqualTypeOf<
Promise<{ value: string }> | undefined
>()
})
})
describe('observers', () => {
it('should be an array', () => {
const query = queryClient
.getQueryCache()
.build<{ value: string }, CustomError>(queryClient, {
queryKey: queryKey(),
queryFn: () => Promise.resolve({ value: 'data' }),
})
expectTypeOf(query.observers).toEqualTypeOf<
Array<QueryObserver<any, any, any, any, any>>
>()
})
})
describe('setData', () => {
it('should return the data type it is given', () => {
const query = queryClient
.getQueryCache()
.build<{ value: string }, CustomError>(queryClient, {
queryKey: queryKey(),
queryFn: () => Promise.resolve({ value: 'data' }),
})
expectTypeOf(query.setData).returns.toEqualTypeOf<{
value: string
}>()
expectTypeOf(query.setData)
.parameter(0)
.toEqualTypeOf<{ value: string }>()
})
it('should require manual on the options it is given', () => {
const query = queryClient
.getQueryCache()
.build<{ value: string }, CustomError>(queryClient, {
queryKey: queryKey(),
queryFn: () => Promise.resolve({ value: 'data' }),
})
query.setData({ value: 'data' })
query.setData({ value: 'data' }, { manual: true, updatedAt: 1 })
// @ts-expect-error manual is required when options are given
query.setData({ value: 'data' }, { updatedAt: 1 })
expectTypeOf(query.setData).parameters.toEqualTypeOf<
[
newData: { value: string },
options?: { updatedAt?: number } & { manual: boolean },
]
>()
})
it('should reject a data type other than its own', () => {
const query = queryClient
.getQueryCache()
.build<{ value: string }, CustomError>(queryClient, {
queryKey: queryKey(),
queryFn: () => Promise.resolve({ value: 'data' }),
})
// @ts-expect-error the data must be the query data type
query.setData('data')
expectTypeOf(query.setData).parameter(0).toEqualTypeOf<{
value: string
}>()
})
})
describe('setState', () => {
it('should accept a partial state and return void', () => {
const query = queryClient
.getQueryCache()
.build<{ value: string }, CustomError>(queryClient, {
queryKey: queryKey(),
queryFn: () => Promise.resolve({ value: 'data' }),
})
query.setState({ status: 'success' })
expectTypeOf(query.setState)
.parameter(0)
.toEqualTypeOf<Partial<QueryState<{ value: string }, CustomError>>>()
expectTypeOf(query.setState).returns.toEqualTypeOf<void>()
})
it('should reject a state field that is not its own type', () => {
const query = queryClient
.getQueryCache()
.build<{ value: string }, CustomError>(queryClient, {
queryKey: queryKey(),
queryFn: () => Promise.resolve({ value: 'data' }),
})
// @ts-expect-error the data must be the query data type
query.setState({ data: 'data' })
expectTypeOf(query.setState)
.parameter(0)
.toHaveProperty('data')
.toEqualTypeOf<{ value: string } | undefined>()
})
})
describe('cancel', () => {
it('should resolve with void', () => {
const query = queryClient
.getQueryCache()
.build<{ value: string }, CustomError>(queryClient, {
queryKey: queryKey(),
queryFn: () => Promise.resolve({ value: 'data' }),
})
expectTypeOf(query.cancel).returns.toEqualTypeOf<Promise<void>>()
})
it('should only accept CancelOptions', () => {
const query = queryClient
.getQueryCache()
.build<{ value: string }, CustomError>(queryClient, {
queryKey: queryKey(),
queryFn: () => Promise.resolve({ value: 'data' }),
})
expectTypeOf(query.cancel).parameters.toEqualTypeOf<
[options?: CancelOptions]
>()
expectTypeOf<CancelOptions>().toEqualTypeOf<{
revert?: boolean
silent?: boolean
}>()
})
})
describe('destroy', () => {
it('should take no arguments and return void', () => {
const query = queryClient
.getQueryCache()
.build<{ value: string }, CustomError>(queryClient, {
queryKey: queryKey(),
queryFn: () => Promise.resolve({ value: 'data' }),
})
expectTypeOf(query.destroy).parameters.toEqualTypeOf<[]>()
expectTypeOf(query.destroy).returns.toEqualTypeOf<void>()
})
})
describe('reset', () => {
it('should take no arguments and return void', () => {
const query = queryClient
.getQueryCache()
.build<{ value: string }, CustomError>(queryClient, {
queryKey: queryKey(),
queryFn: () => Promise.resolve({ value: 'data' }),
})
expectTypeOf(query.reset).parameters.toEqualTypeOf<[]>()
expectTypeOf(query.reset).returns.toEqualTypeOf<void>()
})
})
describe('isActive', () => {
it('should return a boolean', () => {
const query = queryClient
.getQueryCache()
.build<{ value: string }, CustomError>(queryClient, {
queryKey: queryKey(),
queryFn: () => Promise.resolve({ value: 'data' }),
})
expectTypeOf(query.isActive).returns.toEqualTypeOf<boolean>()
})
})
describe('isDisabled', () => {
it('should return a boolean', () => {
const query = queryClient
.getQueryCache()
.build<{ value: string }, CustomError>(queryClient, {
queryKey: queryKey(),
queryFn: () => Promise.resolve({ value: 'data' }),
})
expectTypeOf(query.isDisabled).returns.toEqualTypeOf<boolean>()
})
})
describe('isFetched', () => {
it('should return a boolean', () => {
const query = queryClient
.getQueryCache()
.build<{ value: string }, CustomError>(queryClient, {
queryKey: queryKey(),
queryFn: () => Promise.resolve({ value: 'data' }),
})
expectTypeOf(query.isFetched).returns.toEqualTypeOf<boolean>()
})
})
describe('isStatic', () => {
it('should return a boolean', () => {
const query = queryClient
.getQueryCache()
.build<{ value: string }, CustomError>(queryClient, {
queryKey: queryKey(),
queryFn: () => Promise.resolve({ value: 'data' }),
})
expectTypeOf(query.isStatic).returns.toEqualTypeOf<boolean>()
})
})
describe('isStale', () => {
it('should return a boolean', () => {
const query = queryClient
.getQueryCache()
.build<{ value: string }, CustomError>(queryClient, {
queryKey: queryKey(),
queryFn: () => Promise.resolve({ value: 'data' }),
})
expectTypeOf(query.isStale).returns.toEqualTypeOf<boolean>()
})
})
describe('isStaleByTime', () => {
it('should return a boolean', () => {
const query = queryClient
.getQueryCache()
.build<{ value: string }, CustomError>(queryClient, {
queryKey: queryKey(),
queryFn: () => Promise.resolve({ value: 'data' }),
})
expectTypeOf(query.isStaleByTime).returns.toEqualTypeOf<boolean>()
})
it('should only accept an optional stale time', () => {
const query = queryClient
.getQueryCache()
.build<{ value: string }, CustomError>(queryClient, {
queryKey: queryKey(),
queryFn: () => Promise.resolve({ value: 'data' }),
})
query.isStaleByTime(1000)
query.isStaleByTime('static')
query.isStaleByTime()
// @ts-expect-error a stale time must be a number or the static literal
query.isStaleByTime('soon')
expectTypeOf(query.isStaleByTime).parameters.toEqualTypeOf<
[staleTime?: number | 'static']
>()
})
})
describe('getObserversCount', () => {
it('should return a number', () => {
const query = queryClient
.getQueryCache()
.build<{ value: string }, CustomError>(queryClient, {
queryKey: queryKey(),
queryFn: () => Promise.resolve({ value: 'data' }),
})
expectTypeOf(query.getObserversCount).returns.toEqualTypeOf<number>()
})
})
describe('invalidate', () => {
it('should take no arguments and return void', () => {
const query = queryClient
.getQueryCache()
.build<{ value: string }, CustomError>(queryClient, {
queryKey: queryKey(),
queryFn: () => Promise.resolve({ value: 'data' }),
})
expectTypeOf(query.invalidate).parameters.toEqualTypeOf<[]>()
expectTypeOf(query.invalidate).returns.toEqualTypeOf<void>()
})
})
describe('onFocus', () => {
it('should take no arguments and return void', () => {
const query = queryClient
.getQueryCache()
.build<{ value: string }, CustomError>(queryClient, {
queryKey: queryKey(),
queryFn: () => Promise.resolve({ value: 'data' }),
})
expectTypeOf(query.onFocus).parameters.toEqualTypeOf<[]>()
expectTypeOf(query.onFocus).returns.toEqualTypeOf<void>()
})
})
describe('onOnline', () => {
it('should take no arguments and return void', () => {
const query = queryClient
.getQueryCache()
.build<{ value: string }, CustomError>(queryClient, {
queryKey: queryKey(),
queryFn: () => Promise.resolve({ value: 'data' }),
})
expectTypeOf(query.onOnline).parameters.toEqualTypeOf<[]>()
expectTypeOf(query.onOnline).returns.toEqualTypeOf<void>()
})
})
describe('addObserver', () => {
it('should take a single observer and return void', () => {
const query = queryClient
.getQueryCache()
.build<{ value: string }, CustomError>(queryClient, {
queryKey: queryKey(),
queryFn: () => Promise.resolve({ value: 'data' }),
})
expectTypeOf(query.addObserver).parameters.toEqualTypeOf<
[observer: QueryObserver<any, any, any, any, any>]
>()
expectTypeOf(query.addObserver).returns.toEqualTypeOf<void>()
})
})
describe('removeObserver', () => {
it('should take a single observer and return void', () => {
const query = queryClient
.getQueryCache()
.build<{ value: string }, CustomError>(queryClient, {
queryKey: queryKey(),
queryFn: () => Promise.resolve({ value: 'data' }),
})
expectTypeOf(query.removeObserver).parameters.toEqualTypeOf<
[observer: QueryObserver<any, any, any, any, any>]
>()
expectTypeOf(query.removeObserver).returns.toEqualTypeOf<void>()
})
})
describe('setOptions', () => {
it('should accept the query options or nothing', () => {
const query = queryClient
.getQueryCache()
.build<{ value: string }, CustomError>(queryClient, {
queryKey: queryKey(),
queryFn: () => Promise.resolve({ value: 'data' }),
})
query.setOptions()
query.setOptions({
queryKey: queryKey(),
queryFn: () => Promise.resolve({ value: 'data' }),
})
query.setOptions({
queryKey: queryKey(),
// @ts-expect-error the queryFn must return the query data type
queryFn: () => 42,
})
expectTypeOf(query.setOptions).returns.toEqualTypeOf<void>()
})
})
describe('fetch', () => {
it('should resolve with the query data type', () => {
const query = queryClient
.getQueryCache()
.build<{ value: string }, CustomError>(queryClient, {
queryKey: queryKey(),
queryFn: () => Promise.resolve({ value: 'data' }),
})
expectTypeOf(query.fetch).returns.toEqualTypeOf<
Promise<{ value: string }>
>()
})
it('should resolve with its data type while typing its fetch options from its query fn data type', () => {
const query = queryClient
.getQueryCache()
.build<{ value: string }, CustomError, number>(queryClient, {
queryKey: queryKey(),
queryFn: () => Promise.resolve({ value: 'data' }),
})
expectTypeOf(query.fetch).returns.toEqualTypeOf<Promise<number>>()
expectTypeOf(query.fetch).parameters.toEqualTypeOf<
[
options?: QueryOptions<
{ value: string },
CustomError,
number,
ReadonlyArray<unknown>
>,
fetchOptions?: FetchOptions<{ value: string }>,
]
>()
})
it('should accept the query options or nothing', () => {
const query = queryClient
.getQueryCache()
.build<{ value: string }, CustomError>(queryClient, {
queryKey: queryKey(),
queryFn: () => Promise.resolve({ value: 'data' }),
})
query.fetch()
query.fetch({
queryKey: queryKey(),
queryFn: () => Promise.resolve({ value: 'data' }),
})
query.fetch({
queryKey: queryKey(),
// @ts-expect-error the queryFn must return the query data type
queryFn: () => 42,
})
expectTypeOf(query.fetch)
.parameter(0)
.toEqualTypeOf<
| QueryOptions<
{ value: string },
CustomError,
{ value: string },
ReadonlyArray<unknown>
>
| undefined
>()
})
})
describe('Query', () => {
it('should default its type parameters', () => {
expectTypeOf<Query>().toEqualTypeOf<
Query<unknown, DefaultError, unknown, QueryKey>
>()
})
it('should derive its data type from its query fn data type', () => {
expectTypeOf<Query<{ value: string }>>().toEqualTypeOf<
Query<{ value: string }, DefaultError, { value: string }, QueryKey>
>()
})
it('should keep its own fields writable', () => {
type Fields = Pick<Query, 'queryKey' | 'queryHash' | 'options' | 'state'>
expectTypeOf<Fields>().toEqualTypeOf<{
-readonly [K in keyof Fields]: Fields[K]
}>()
})
it('should declare exactly its public members', () => {
expectTypeOf<keyof Query>().toEqualTypeOf<
| 'queryKey'
| 'queryHash'
| 'options'
| 'state'
| 'observers'
| 'meta'
| 'promise'
| 'queryType'
| 'resetState'
| 'gcTime'
| 'setOptions'
| 'setData'
| 'setState'
| 'cancel'
| 'destroy'
| 'reset'
| 'isActive'
| 'isDisabled'
| 'isFetched'
| 'isStatic'
| 'isStale'
| 'isStaleByTime'
| 'onFocus'
| 'onOnline'
| 'addObserver'
| 'removeObserver'
| 'getObserversCount'
| 'invalidate'
| 'fetch'
>()
})
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,496 @@
import { afterEach, beforeEach, describe, expectTypeOf, it } from 'vitest'
import { queryKey } from '@tanstack/query-test-utils'
import { QueryCache, QueryClient } from '..'
import type {
DefaultError,
NotifyEvent,
NotifyEventType,
Query,
QueryCacheConfig,
QueryCacheNotifyEvent,
QueryFilters,
QueryKey,
QueryObserver,
QueryState,
} from '..'
import type { Action } from '../query'
import type { QueryStore } from '../queryCache'
class CustomError extends Error {
name = 'CustomError' as const
}
describe('queryCache', () => {
let queryClient: QueryClient
let queryCache: QueryCache
beforeEach(() => {
queryClient = new QueryClient()
queryClient.mount()
queryCache = queryClient.getQueryCache()
})
afterEach(() => {
queryClient.clear()
})
describe('QueryCache', () => {
it('should only expose its documented members', () => {
expectTypeOf<keyof QueryCache>().toEqualTypeOf<
| 'subscribe'
| 'hasListeners'
| 'config'
| 'build'
| 'add'
| 'remove'
| 'clear'
| 'get'
| 'getAll'
| 'find'
| 'findAll'
| 'notify'
| 'onFocus'
| 'onOnline'
>()
})
})
describe('config', () => {
it('should be typed as the cache config', () => {
expectTypeOf(queryCache.config).toEqualTypeOf<QueryCacheConfig>()
expectTypeOf(new QueryCache().config).toEqualTypeOf<QueryCacheConfig>()
})
it('should stay writable', () => {
type Config = Pick<QueryCache, 'config'>
expectTypeOf<Config>().toEqualTypeOf<{
-readonly [K in keyof Config]: Config[K]
}>()
})
it('should be optional when constructing a cache', () => {
expectTypeOf(QueryCache).constructorParameters.toEqualTypeOf<
[config?: QueryCacheConfig]
>()
})
})
describe('QueryCacheConfig', () => {
it('should declare every callback as optional', () => {
expectTypeOf<QueryCacheConfig>().toEqualTypeOf<{
onError?: (
error: DefaultError,
query: Query<unknown, unknown, unknown>,
) => void
onSuccess?: (
data: unknown,
query: Query<unknown, unknown, unknown>,
) => void
onSettled?: (
data: unknown | undefined,
error: DefaultError | null,
query: Query<unknown, unknown, unknown>,
) => void
}>()
})
describe('onError', () => {
it('should type the error and the query it is given', () => {
expectTypeOf<
NonNullable<QueryCacheConfig['onError']>
>().parameters.toEqualTypeOf<
[error: DefaultError, query: Query<unknown, unknown, unknown>]
>()
})
it('should not await its return value', () => {
expectTypeOf<
NonNullable<QueryCacheConfig['onError']>
>().returns.toEqualTypeOf<void>()
})
})
describe('onSuccess', () => {
it('should type the data and the query it is given', () => {
expectTypeOf<
NonNullable<QueryCacheConfig['onSuccess']>
>().parameters.toEqualTypeOf<
[data: unknown, query: Query<unknown, unknown, unknown>]
>()
})
it('should not await its return value', () => {
expectTypeOf<
NonNullable<QueryCacheConfig['onSuccess']>
>().returns.toEqualTypeOf<void>()
})
})
describe('onSettled', () => {
it('should type the data, error and query it is given', () => {
expectTypeOf<
NonNullable<QueryCacheConfig['onSettled']>
>().parameters.toEqualTypeOf<
[
data: unknown,
error: DefaultError | null,
query: Query<unknown, unknown, unknown>,
]
>()
})
it('should not await its return value', () => {
expectTypeOf<
NonNullable<QueryCacheConfig['onSettled']>
>().returns.toEqualTypeOf<void>()
})
})
})
describe('NotifyEventType', () => {
it('should union exactly the seven event type literals', () => {
// Every `QueryCacheNotifyEvent` variant redeclares `type` with its own
// literal, so the base union is only observable through this alias.
expectTypeOf<NotifyEventType>().toEqualTypeOf<
| 'added'
| 'removed'
| 'updated'
| 'observerAdded'
| 'observerRemoved'
| 'observerResultsUpdated'
| 'observerOptionsUpdated'
>()
})
})
describe('NotifyEvent', () => {
it('should declare a single type property taken from NotifyEventType', () => {
expectTypeOf<keyof NotifyEvent>().toEqualTypeOf<'type'>()
expectTypeOf<NotifyEvent['type']>().toEqualTypeOf<NotifyEventType>()
})
})
describe('QueryCacheNotifyEvent', () => {
it('should union exactly the seven event type literals', () => {
expectTypeOf<QueryCacheNotifyEvent['type']>().toEqualTypeOf<
| 'added'
| 'removed'
| 'updated'
| 'observerAdded'
| 'observerRemoved'
| 'observerResultsUpdated'
| 'observerOptionsUpdated'
>()
})
it('should carry an action only on the updated event', () => {
type Updated = Extract<QueryCacheNotifyEvent, { type: 'updated' }>
expectTypeOf<keyof Updated>().toEqualTypeOf<'type' | 'query' | 'action'>()
expectTypeOf<Updated['query']>().toEqualTypeOf<
Query<any, any, any, any>
>()
expectTypeOf<Updated['action']>().toEqualTypeOf<Action<any, any>>()
})
it('should declare only a query on the added event', () => {
expectTypeOf<
keyof Extract<QueryCacheNotifyEvent, { type: 'added' }>
>().toEqualTypeOf<'type' | 'query'>()
})
it('should declare only a query on the removed event', () => {
expectTypeOf<
keyof Extract<QueryCacheNotifyEvent, { type: 'removed' }>
>().toEqualTypeOf<'type' | 'query'>()
})
it('should carry an observer on the observerAdded event', () => {
expectTypeOf<
keyof Extract<QueryCacheNotifyEvent, { type: 'observerAdded' }>
>().toEqualTypeOf<'type' | 'query' | 'observer'>()
expectTypeOf<
Extract<QueryCacheNotifyEvent, { type: 'observerAdded' }>['observer']
>().toEqualTypeOf<QueryObserver<any, any, any, any, any>>()
})
it('should carry an observer on the observerRemoved event', () => {
expectTypeOf<
keyof Extract<QueryCacheNotifyEvent, { type: 'observerRemoved' }>
>().toEqualTypeOf<'type' | 'query' | 'observer'>()
expectTypeOf<
Extract<QueryCacheNotifyEvent, { type: 'observerRemoved' }>['observer']
>().toEqualTypeOf<QueryObserver<any, any, any, any, any>>()
})
it('should carry an observer on the observerOptionsUpdated event', () => {
expectTypeOf<
keyof Extract<QueryCacheNotifyEvent, { type: 'observerOptionsUpdated' }>
>().toEqualTypeOf<'type' | 'query' | 'observer'>()
expectTypeOf<
Extract<
QueryCacheNotifyEvent,
{ type: 'observerOptionsUpdated' }
>['observer']
>().toEqualTypeOf<QueryObserver<any, any, any, any, any>>()
})
it('should require the query on every event', () => {
type EventsWithRequiredQuery = Extract<
QueryCacheNotifyEvent,
{ query: Query<any, any, any, any> }
>['type']
expectTypeOf<EventsWithRequiredQuery>().toEqualTypeOf<
QueryCacheNotifyEvent['type']
>()
})
it('should not carry an observer on the observerResultsUpdated event', () => {
expectTypeOf<
keyof Extract<QueryCacheNotifyEvent, { type: 'observerResultsUpdated' }>
>().toEqualTypeOf<'type' | 'query'>()
})
})
describe('QueryStore', () => {
it('should type every method of the store', () => {
expectTypeOf<QueryStore>().toEqualTypeOf<{
has: (queryHash: string) => boolean
set: (queryHash: string, query: Query) => void
get: (queryHash: string) => Query | undefined
delete: (queryHash: string) => void
values: () => IterableIterator<Query>
}>()
})
})
describe('subscribe', () => {
it('should type the event given to a listener', () => {
queryCache.subscribe((event) => {
expectTypeOf(event).toEqualTypeOf<QueryCacheNotifyEvent>()
})
})
it('should return an unsubscribe function', () => {
expectTypeOf(queryCache.subscribe(() => {})).toEqualTypeOf<() => void>()
})
})
describe('build', () => {
it('should carry its type parameters into the query it returns', () => {
const query = queryCache.build<{ value: string }, CustomError>(
queryClient,
{
queryKey: queryKey(),
queryFn: () => Promise.resolve({ value: 'data' }),
},
)
expectTypeOf(query).toEqualTypeOf<
Query<
{ value: string },
CustomError,
{ value: string },
ReadonlyArray<unknown>
>
>()
expectTypeOf(query.state).toEqualTypeOf<
QueryState<{ value: string }, CustomError>
>()
})
it('should preserve a literal queryKey', () => {
const key = ['a', 1] as const
expectTypeOf(
queryCache.build(queryClient, {
queryKey: key,
queryFn: () => Promise.resolve('data'),
}).queryKey,
).toEqualTypeOf<readonly ['a', 1]>()
})
it('should require a queryKey in the options it is given', () => {
// @ts-expect-error a queryKey is required
queryCache.build(queryClient, {
queryFn: () => Promise.resolve('data'),
})
expectTypeOf(queryCache.build)
.parameter(1)
.toHaveProperty('queryKey')
.toEqualTypeOf<ReadonlyArray<unknown>>()
})
it('should accept an optional initial state', () => {
expectTypeOf(queryCache.build<{ value: string }, CustomError>)
.parameter(2)
.toEqualTypeOf<QueryState<{ value: string }, CustomError> | undefined>()
})
it('should default its type parameters', () => {
const query = queryCache.build(queryClient, { queryKey: queryKey() })
expectTypeOf(query.state).toEqualTypeOf<
QueryState<unknown, DefaultError>
>()
expectTypeOf(query.state.data).toEqualTypeOf<unknown>()
expectTypeOf(query.state.error).toEqualTypeOf<DefaultError | null>()
})
})
describe('get', () => {
it('should carry its type parameters into the query it returns', () => {
expectTypeOf(
queryCache.get<{ value: string }, CustomError>('hash'),
).toEqualTypeOf<
| Query<
{ value: string },
CustomError,
{ value: string },
ReadonlyArray<unknown>
>
| undefined
>()
})
it('should take a query hash and default its type parameters', () => {
expectTypeOf(queryCache.get).parameters.toEqualTypeOf<
[queryHash: string]
>()
expectTypeOf(queryCache.get('hash')).toEqualTypeOf<
Query<unknown, DefaultError, unknown, QueryKey> | undefined
>()
})
it('should accept a queryKey type parameter, unlike find', () => {
expectTypeOf(
queryCache.get<
{ value: string },
CustomError,
{ value: string },
readonly ['a', 1]
>('hash'),
).toEqualTypeOf<
| Query<
{ value: string },
CustomError,
{ value: string },
readonly ['a', 1]
>
| undefined
>()
})
})
describe('getAll', () => {
it('should return an array of fully defaulted queries', () => {
expectTypeOf(queryCache.getAll()).toEqualTypeOf<
Array<Query<unknown, DefaultError, unknown, QueryKey>>
>()
})
it('should take no arguments', () => {
expectTypeOf(queryCache.getAll).parameters.toEqualTypeOf<[]>()
})
})
describe('find', () => {
it('should carry its type parameters into the query it returns', () => {
expectTypeOf(
queryCache.find<{ value: string }, CustomError>({
queryKey: queryKey(),
}),
).toEqualTypeOf<
| Query<{ value: string }, CustomError, { value: string }, QueryKey>
| undefined
>()
})
it('should require a queryKey in the filters it is given', () => {
// @ts-expect-error a queryKey is required
queryCache.find({})
expectTypeOf(queryCache.find)
.parameter(0)
.toHaveProperty('queryKey')
.toEqualTypeOf<ReadonlyArray<unknown>>()
})
it('should default its type parameters', () => {
expectTypeOf(queryCache.find({ queryKey: queryKey() })).toEqualTypeOf<
Query<unknown, DefaultError, unknown, QueryKey> | undefined
>()
})
})
describe('findAll', () => {
it('should return an array of fully defaulted queries', () => {
expectTypeOf(queryCache.findAll({ queryKey: queryKey() })).toEqualTypeOf<
Array<Query<unknown, DefaultError, unknown, QueryKey>>
>()
})
it('should accept filters or nothing', () => {
queryCache.findAll()
queryCache.findAll({ queryKey: queryKey() })
queryCache.findAll({ stale: true })
// @ts-expect-error stale must be a boolean
queryCache.findAll({ stale: 'yes' })
expectTypeOf(queryCache.findAll).parameters.toEqualTypeOf<
[filters?: QueryFilters<any>]
>()
})
})
describe('add', () => {
it('should take a single query and return void', () => {
expectTypeOf(queryCache.add).returns.toEqualTypeOf<void>()
expectTypeOf(queryCache.add).parameters.toEqualTypeOf<
[query: Query<any, any, any, any>]
>()
})
})
describe('remove', () => {
it('should take a single query and return void', () => {
expectTypeOf(queryCache.remove).returns.toEqualTypeOf<void>()
expectTypeOf(queryCache.remove).parameters.toEqualTypeOf<
[query: Query<any, any, any, any>]
>()
})
})
describe('clear', () => {
it('should take no arguments and return void', () => {
expectTypeOf(queryCache.clear).parameters.toEqualTypeOf<[]>()
expectTypeOf(queryCache.clear).returns.toEqualTypeOf<void>()
})
})
describe('notify', () => {
it('should only accept a notify event and return void', () => {
expectTypeOf(queryCache.notify).parameters.toEqualTypeOf<
[event: QueryCacheNotifyEvent]
>()
expectTypeOf(queryCache.notify).returns.toEqualTypeOf<void>()
})
})
describe('onFocus', () => {
it('should take no arguments and return void', () => {
expectTypeOf(queryCache.onFocus).parameters.toEqualTypeOf<[]>()
expectTypeOf(queryCache.onFocus).returns.toEqualTypeOf<void>()
})
})
describe('onOnline', () => {
it('should take no arguments and return void', () => {
expectTypeOf(queryCache.onOnline).parameters.toEqualTypeOf<[]>()
expectTypeOf(queryCache.onOnline).returns.toEqualTypeOf<void>()
})
})
})

View File

@@ -0,0 +1,456 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { queryKey, sleep } from '@tanstack/query-test-utils'
import { QueryCache, QueryClient, QueryObserver, hashKey, noop } from '..'
describe('queryCache', () => {
let queryClient: QueryClient
let queryCache: QueryCache
beforeEach(() => {
vi.useFakeTimers()
queryClient = new QueryClient()
queryCache = queryClient.getQueryCache()
})
afterEach(() => {
queryClient.clear()
vi.useRealTimers()
})
describe('subscribe', () => {
it('should pass the correct query', () => {
const key = queryKey()
const subscriber = vi.fn()
const unsubscribe = queryCache.subscribe(subscriber)
queryClient.setQueryData(key, 'foo')
const query = queryCache.find({ queryKey: key })
expect(subscriber).toHaveBeenNthCalledWith(1, { query, type: 'added' })
unsubscribe()
})
it('should notify listeners when new query is added', async () => {
const key = queryKey()
const callback = vi.fn()
queryCache.subscribe(callback)
void queryClient
.query({
queryKey: key,
queryFn: () => sleep(100).then(() => 'data'),
})
.catch(noop)
await vi.advanceTimersByTimeAsync(100)
const query = queryCache.find({ queryKey: key })
expect(callback).toHaveBeenNthCalledWith(1, { query, type: 'added' })
})
it('should notify query cache when a query becomes stale', async () => {
const key = queryKey()
const events: Array<string> = []
const queries: Array<unknown> = []
const unsubscribe = queryCache.subscribe((event) => {
events.push(event.type)
queries.push(event.query)
})
const observer = new QueryObserver(queryClient, {
queryKey: key,
queryFn: () => 'data',
staleTime: 10,
})
const unsubScribeObserver = observer.subscribe(vi.fn())
await vi.advanceTimersByTimeAsync(11)
expect(events.length).toBe(8)
expect(events).toEqual([
'added', // 1. Query added -> loading
'observerResultsUpdated', // 2. Observer result updated -> loading
'observerAdded', // 3. Observer added
'observerResultsUpdated', // 4. Observer result updated -> fetching
'updated', // 5. Query updated -> fetching
'observerResultsUpdated', // 6. Observer result updated -> success
'updated', // 7. Query updated -> success
'observerResultsUpdated', // 8. Observer result updated -> stale
])
const cachedQuery = queryCache.find({ queryKey: key })
queries.forEach((query) => {
expect(query).toBe(cachedQuery)
})
unsubscribe()
unsubScribeObserver()
})
it('should include the queryCache and query when notifying listeners', async () => {
const key = queryKey()
const callback = vi.fn()
queryCache.subscribe(callback)
void queryClient
.query({
queryKey: key,
queryFn: () => sleep(100).then(() => 'data'),
})
.catch(noop)
await vi.advanceTimersByTimeAsync(100)
const query = queryCache.find({ queryKey: key })
expect(callback).toHaveBeenNthCalledWith(1, { query, type: 'added' })
})
it('should notify subscribers when new query with initialData is added', async () => {
const key = queryKey()
const callback = vi.fn()
queryCache.subscribe(callback)
void queryClient
.query({
queryKey: key,
queryFn: () => sleep(100).then(() => 'data'),
initialData: 'initial',
})
.catch(noop)
await vi.advanceTimersByTimeAsync(100)
const query = queryCache.find({ queryKey: key })
expect(callback).toHaveBeenNthCalledWith(1, { query, type: 'added' })
})
it('should be able to limit cache size', async () => {
const testCache = new QueryCache()
const unsubscribe = testCache.subscribe((event) => {
if (event.type === 'added') {
if (testCache.getAll().length > 2) {
testCache
.findAll({
type: 'inactive',
predicate: (q) => q !== event.query,
})
.forEach((query) => {
testCache.remove(query)
})
}
}
})
const testClient = new QueryClient({ queryCache: testCache })
const key1 = queryKey()
const key2 = queryKey()
const key3 = queryKey()
void testClient
.query({
queryKey: key1,
queryFn: () => sleep(100).then(() => 'data1'),
})
.catch(noop)
expect(testCache.findAll().length).toBe(1)
void testClient
.query({
queryKey: key2,
queryFn: () => sleep(100).then(() => 'data2'),
})
.catch(noop)
expect(testCache.findAll().length).toBe(2)
void testClient
.query({
queryKey: key3,
queryFn: () => sleep(100).then(() => 'data3'),
})
.catch(noop)
await vi.advanceTimersByTimeAsync(100)
expect(testCache.findAll().length).toBe(1)
expect(testCache.findAll()[0]!.state.data).toBe('data3')
unsubscribe()
})
})
describe('find', () => {
it('find should filter correctly', async () => {
const key = queryKey()
void queryClient
.query({
queryKey: key,
queryFn: () => sleep(100).then(() => 'data1'),
})
.catch(noop)
await vi.advanceTimersByTimeAsync(100)
const query = queryCache.find({ queryKey: key })!
expect(query.state.data).toBe('data1')
})
it('find should filter correctly with exact set to false', async () => {
const key = queryKey()
void queryClient
.query({
queryKey: key,
queryFn: () => sleep(100).then(() => 'data1'),
})
.catch(noop)
await vi.advanceTimersByTimeAsync(100)
const query = queryCache.find({ queryKey: key, exact: false })!
expect(query.state.data).toBe('data1')
})
})
describe('findAll', () => {
it('should filter correctly', async () => {
const key1 = queryKey()
const key2 = queryKey()
const keyFetching = queryKey()
void queryClient
.query({
queryKey: key1,
queryFn: () => sleep(100).then(() => 'data1'),
})
.catch(noop)
void queryClient
.query({
queryKey: key2,
queryFn: () => sleep(100).then(() => 'data2'),
})
.catch(noop)
void queryClient
.query({
queryKey: [{ a: 'a', b: 'b' }],
queryFn: () => sleep(100).then(() => 'data3'),
})
.catch(noop)
void queryClient
.query({
queryKey: ['posts', 1],
queryFn: () => sleep(100).then(() => 'data4'),
})
.catch(noop)
await vi.advanceTimersByTimeAsync(100)
queryClient.invalidateQueries({ queryKey: key2 })
const query1 = queryCache.find({ queryKey: key1 })!
const query2 = queryCache.find({ queryKey: key2 })!
const query3 = queryCache.find({ queryKey: [{ a: 'a', b: 'b' }] })!
const query4 = queryCache.find({ queryKey: ['posts', 1] })!
expect(queryCache.findAll({ queryKey: key1 })).toEqual([query1])
// wrapping in an extra array doesn't yield the same results anymore since v4 because keys need to be an array
expect(queryCache.findAll({ queryKey: [key1] })).toEqual([])
expect(queryCache.findAll()).toEqual([query1, query2, query3, query4])
expect(queryCache.findAll({})).toEqual([query1, query2, query3, query4])
expect(queryCache.findAll({ queryKey: key1, type: 'inactive' })).toEqual([
query1,
])
expect(queryCache.findAll({ queryKey: key1, type: 'active' })).toEqual([])
expect(queryCache.findAll({ queryKey: key1, stale: true })).toEqual([])
expect(queryCache.findAll({ queryKey: key1, stale: false })).toEqual([
query1,
])
expect(
queryCache.findAll({ queryKey: key1, stale: false, type: 'active' }),
).toEqual([])
expect(
queryCache.findAll({
queryKey: key1,
stale: false,
type: 'inactive',
}),
).toEqual([query1])
expect(
queryCache.findAll({
queryKey: key1,
stale: false,
type: 'inactive',
exact: true,
}),
).toEqual([query1])
expect(queryCache.findAll({ queryKey: key2 })).toEqual([query2])
expect(queryCache.findAll({ queryKey: key2, stale: undefined })).toEqual([
query2,
])
expect(queryCache.findAll({ queryKey: key2, stale: true })).toEqual([
query2,
])
expect(queryCache.findAll({ queryKey: key2, stale: false })).toEqual([])
expect(queryCache.findAll({ queryKey: [{ b: 'b' }] })).toEqual([query3])
expect(
queryCache.findAll({ queryKey: [{ a: 'a' }], exact: false }),
).toEqual([query3])
expect(
queryCache.findAll({ queryKey: [{ a: 'a' }], exact: true }),
).toEqual([])
expect(
queryCache.findAll({ queryKey: [{ a: 'a', b: 'b' }], exact: true }),
).toEqual([query3])
expect(queryCache.findAll({ queryKey: [{ a: 'a', b: 'b' }] })).toEqual([
query3,
])
expect(
queryCache.findAll({ queryKey: [{ a: 'a', b: 'b', c: 'c' }] }),
).toEqual([])
expect(
queryCache.findAll({ queryKey: [{ a: 'a' }], stale: false }),
).toEqual([query3])
expect(
queryCache.findAll({ queryKey: [{ a: 'a' }], stale: true }),
).toEqual([])
expect(
queryCache.findAll({ queryKey: [{ a: 'a' }], type: 'active' }),
).toEqual([])
expect(
queryCache.findAll({ queryKey: [{ a: 'a' }], type: 'inactive' }),
).toEqual([query3])
expect(
queryCache.findAll({ predicate: (query) => query === query3 }),
).toEqual([query3])
expect(queryCache.findAll({ queryKey: ['posts'] })).toEqual([query4])
expect(queryCache.findAll({ fetchStatus: 'idle' })).toEqual([
query1,
query2,
query3,
query4,
])
expect(
queryCache.findAll({ queryKey: key2, fetchStatus: undefined }),
).toEqual([query2])
void queryClient
.query({
queryKey: keyFetching,
queryFn: () => sleep(20).then(() => 'dataFetching'),
})
.catch(noop)
expect(queryCache.findAll({ fetchStatus: 'fetching' })).toEqual([
queryCache.find({ queryKey: keyFetching }),
])
await vi.advanceTimersByTimeAsync(20)
expect(queryCache.findAll({ fetchStatus: 'fetching' })).toEqual([])
})
it('should return all the queries when no filters are defined', async () => {
const key1 = queryKey()
const key2 = queryKey()
await queryClient
.query({
queryKey: key1,
queryFn: () => 'data1',
})
.catch(noop)
await queryClient
.query({
queryKey: key2,
queryFn: () => 'data2',
})
.catch(noop)
expect(queryCache.findAll().length).toBe(2)
})
})
describe('QueryCacheConfig error callbacks', () => {
it('should call onError and onSettled when a query errors', async () => {
const key = queryKey()
const onSuccess = vi.fn()
const onSettled = vi.fn()
const onError = vi.fn()
const testCache = new QueryCache({ onSuccess, onError, onSettled })
const testClient = new QueryClient({ queryCache: testCache })
void testClient
.query({
queryKey: key,
queryFn: () =>
sleep(100).then(() => Promise.reject<unknown>('error')),
})
.catch(noop)
await vi.advanceTimersByTimeAsync(100)
const query = testCache.find({ queryKey: key })
expect(onError).toHaveBeenCalledWith('error', query)
expect(onError).toHaveBeenCalledTimes(1)
expect(onSuccess).not.toHaveBeenCalled()
expect(onSettled).toHaveBeenCalledTimes(1)
expect(onSettled).toHaveBeenCalledWith(undefined, 'error', query)
})
})
describe('QueryCacheConfig success callbacks', () => {
it('should call onSuccess and onSettled when a query is successful', async () => {
const key = queryKey()
const onSuccess = vi.fn()
const onSettled = vi.fn()
const onError = vi.fn()
const testCache = new QueryCache({ onSuccess, onError, onSettled })
const testClient = new QueryClient({ queryCache: testCache })
void testClient
.query({
queryKey: key,
queryFn: () => sleep(100).then(() => ({ data: 5 })),
})
.catch(noop)
await vi.advanceTimersByTimeAsync(100)
const query = testCache.find({ queryKey: key })
expect(onSuccess).toHaveBeenCalledWith({ data: 5 }, query)
expect(onSuccess).toHaveBeenCalledTimes(1)
expect(onError).not.toHaveBeenCalled()
expect(onSettled).toHaveBeenCalledTimes(1)
expect(onSettled).toHaveBeenCalledWith({ data: 5 }, null, query)
})
})
describe('build', () => {
it('should compute queryHash from queryKey when queryHash is not provided', () => {
const key = queryKey()
const query = queryCache.build(queryClient, {
queryKey: key,
})
expect(query.queryHash).toBe(hashKey(key))
})
it('should use provided queryHash instead of computing it', () => {
const key = queryKey()
const customHash = 'custom-hash'
const query = queryCache.build(queryClient, {
queryKey: key,
queryHash: customHash,
})
expect(query.queryHash).toBe(customHash)
expect(query.queryHash).not.toBe(hashKey(key))
})
})
describe('QueryCache.remove', () => {
it('should only delete the instance currently stored under its queryHash', () => {
const key = queryKey()
const staleQuery = queryCache.build(queryClient, { queryKey: key })
queryCache.remove(staleQuery)
const currentQuery = queryCache.build(queryClient, { queryKey: key })
expect(currentQuery).not.toBe(staleQuery)
queryCache.remove(staleQuery)
expect(queryCache.get(hashKey(key))).toBe(currentQuery)
})
})
describe('QueryCache.add', () => {
it('should not try to add a query already added to the cache', async () => {
const key = queryKey()
void queryClient
.query({
queryKey: key,
queryFn: () => sleep(100).then(() => 'data1'),
})
.catch(noop)
await vi.advanceTimersByTimeAsync(100)
const query = queryCache.findAll()[0]!
const queryClone = Object.assign({}, query)
queryCache.add(queryClone)
expect(queryCache.getAll().length).toEqual(1)
})
})
})

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,182 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Removable } from '../removable'
import { defaultTimeoutProvider, timeoutManager } from '../timeoutManager'
import { setIsServer } from './utils'
import type { TimeoutCallback } from '../timeoutManager'
class RemovableTest extends Removable {
optionalRemoveSpy = vi.fn()
// Expose the protected members so they can be exercised directly.
callScheduleGc(): void {
this.scheduleGc()
}
callUpdateGcTime(newGcTime: number | undefined): void {
this.updateGcTime(newGcTime)
}
callClearGcTimeout(): void {
this.clearGcTimeout()
}
protected optionalRemove(): void {
this.optionalRemoveSpy()
}
}
describe('Removable', () => {
function createMockProvider() {
return {
setTimeout: vi.fn((_callback: TimeoutCallback, _delay: number) => 123),
clearTimeout: vi.fn(),
setInterval: vi.fn((_callback: TimeoutCallback, _delay: number) => 456),
clearInterval: vi.fn(),
}
}
let provider: ReturnType<typeof createMockProvider>
beforeEach(() => {
provider = createMockProvider()
timeoutManager.setTimeoutProvider(provider)
})
afterEach(() => {
timeoutManager.setTimeoutProvider(defaultTimeoutProvider)
vi.restoreAllMocks()
})
describe('updateGcTime', () => {
it('should default to 5 minutes when no gcTime is provided on the client', () => {
const removable = new RemovableTest()
removable.callUpdateGcTime(undefined)
expect(removable.gcTime).toBe(5 * 60 * 1000)
})
it('should use the provided gcTime when it is larger than the default', () => {
const removable = new RemovableTest()
removable.callUpdateGcTime(10 * 60 * 1000)
expect(removable.gcTime).toBe(10 * 60 * 1000)
})
it('should use an explicit gcTime even when it is smaller than the default', () => {
const removable = new RemovableTest()
removable.callUpdateGcTime(1000)
expect(removable.gcTime).toBe(1000)
})
it('should never decrease an already larger gcTime', () => {
const removable = new RemovableTest()
removable.callUpdateGcTime(10 * 60 * 1000)
removable.callUpdateGcTime(1000)
expect(removable.gcTime).toBe(10 * 60 * 1000)
})
it('should default to Infinity on the server', () => {
const resetIsServer = setIsServer(true)
try {
const removable = new RemovableTest()
removable.callUpdateGcTime(undefined)
expect(removable.gcTime).toBe(Infinity)
} finally {
resetIsServer()
}
})
})
describe('scheduleGc', () => {
it('should schedule optionalRemove after a valid gcTime', () => {
const removable = new RemovableTest()
removable.callUpdateGcTime(1000)
removable.callScheduleGc()
expect(provider.setTimeout).toHaveBeenCalledTimes(1)
expect(provider.setTimeout).toHaveBeenCalledWith(
expect.any(Function),
1000,
)
// The scheduled callback should invoke optionalRemove.
const [scheduledCallback] = provider.setTimeout.mock.lastCall ?? []
expect(removable.optionalRemoveSpy).toHaveBeenCalledTimes(0)
scheduledCallback?.()
expect(removable.optionalRemoveSpy).toHaveBeenCalledTimes(1)
})
it('should not schedule when gcTime is not a valid timeout', () => {
const removable = new RemovableTest()
removable.gcTime = Infinity
removable.callScheduleGc()
expect(provider.setTimeout).not.toHaveBeenCalled()
})
it('should clear a previously scheduled timeout before scheduling a new one', () => {
const removable = new RemovableTest()
removable.callUpdateGcTime(1000)
removable.callScheduleGc()
removable.callScheduleGc()
// The second schedule clears the first timer before setting a new one.
expect(provider.clearTimeout).toHaveBeenCalledWith(123)
expect(provider.setTimeout).toHaveBeenCalledTimes(2)
})
})
describe('clearGcTimeout', () => {
it('should clear a scheduled timeout', () => {
const removable = new RemovableTest()
removable.callUpdateGcTime(1000)
removable.callScheduleGc()
removable.callClearGcTimeout()
expect(provider.clearTimeout).toHaveBeenCalledWith(123)
})
it('should do nothing when no timeout is scheduled', () => {
const removable = new RemovableTest()
removable.callClearGcTimeout()
expect(provider.clearTimeout).not.toHaveBeenCalled()
})
it('should not clear the same timeout twice', () => {
const removable = new RemovableTest()
removable.callUpdateGcTime(1000)
removable.callScheduleGc()
removable.callClearGcTimeout()
removable.callClearGcTimeout()
expect(provider.clearTimeout).toHaveBeenCalledTimes(1)
})
})
describe('destroy', () => {
it('should clear the scheduled gc timeout', () => {
const removable = new RemovableTest()
removable.callUpdateGcTime(1000)
removable.callScheduleGc()
removable.destroy()
expect(provider.clearTimeout).toHaveBeenCalledWith(123)
})
})
})

View File

@@ -0,0 +1,624 @@
import { describe, expectTypeOf, it } from 'vitest'
import {
CancelledError,
canFetch,
createRetryer,
isCancelledError,
} from '../retryer'
import type { RetryDelayValue, RetryValue, Retryer } from '../retryer'
import type { CancelOptions, DefaultError, NetworkMode } from '..'
class CustomError extends Error {
name = 'CustomError' as const
}
describe('retryer', () => {
describe('createRetryer', () => {
it('should infer TData from the config fn', () => {
expectTypeOf(
createRetryer({
fn: () => ({ value: 'data' }),
networkMode: 'online',
canRun: () => true,
}),
).toEqualTypeOf<Retryer<{ value: string }>>()
})
it('should infer TData from a promise returned by the config fn', () => {
expectTypeOf(
createRetryer({
fn: () => Promise.resolve({ value: 'data' }),
networkMode: 'online',
canRun: () => true,
}),
).toEqualTypeOf<Retryer<{ value: string }>>()
})
it('should default TData to unknown', () => {
expectTypeOf(
createRetryer({
fn: () => ({}) as unknown,
networkMode: undefined,
canRun: () => true,
}),
).toEqualTypeOf<Retryer<unknown>>()
})
it('should default TError to DefaultError', () => {
// The default is only observable at a call site: going through
// `Parameters<typeof createRetryer>` instantiates the generic first, so
// the callbacks come back already widened.
createRetryer({
fn: () => 'data',
networkMode: 'online',
canRun: () => true,
onCancel: (error) => {
expectTypeOf(error).toEqualTypeOf<DefaultError>()
},
onFail: (failureCount, error) => {
expectTypeOf(failureCount).toEqualTypeOf<number>()
expectTypeOf(error).toEqualTypeOf<DefaultError>()
},
retry: (failureCount, error) => {
expectTypeOf(failureCount).toEqualTypeOf<number>()
expectTypeOf(error).toEqualTypeOf<DefaultError>()
return false
},
retryDelay: (failureCount, error) => {
expectTypeOf(failureCount).toEqualTypeOf<number>()
expectTypeOf(error).toEqualTypeOf<DefaultError>()
return 0
},
})
})
it('should infer TError from an explicit type argument', () => {
createRetryer<string, CustomError>({
fn: () => 'data',
networkMode: 'online',
canRun: () => true,
onCancel: (error) => {
expectTypeOf(error).toEqualTypeOf<CustomError>()
},
onFail: (_failureCount, error) => {
expectTypeOf(error).toEqualTypeOf<CustomError>()
},
})
})
it('should not carry TError into the returned Retryer', () => {
expectTypeOf(
createRetryer<{ value: string }, CustomError>({
fn: () => ({ value: 'data' }),
networkMode: 'online',
canRun: () => true,
}),
).toEqualTypeOf<Retryer<{ value: string }>>()
})
it('should take the config as its only parameter', () => {
createRetryer(
{ fn: () => 'data', networkMode: 'online', canRun: () => true },
// @ts-expect-error there is no second parameter
'extra',
)
expectTypeOf<
Parameters<typeof createRetryer<string, CustomError>>['length']
>().toEqualTypeOf<1>()
})
})
describe('RetryerConfig', () => {
it('should keep every option writable', () => {
type Config = Parameters<typeof createRetryer<string, CustomError>>[0]
expectTypeOf<Config>().toEqualTypeOf<{
-readonly [K in keyof Config]: Config[K]
}>()
})
it('should only expose its documented options', () => {
expectTypeOf<
keyof Parameters<typeof createRetryer<string, CustomError>>[0]
>().toEqualTypeOf<
| 'fn'
| 'initialPromise'
| 'onCancel'
| 'onFail'
| 'onPause'
| 'onContinue'
| 'retry'
| 'retryDelay'
| 'networkMode'
| 'canRun'
>()
})
describe('fn', () => {
it('should be required', () => {
// @ts-expect-error fn is required
createRetryer({ networkMode: 'online', canRun: () => true })
expectTypeOf(
createRetryer({
fn: () => 'data',
networkMode: 'online',
canRun: () => true,
}),
).toEqualTypeOf<Retryer<string>>()
})
it('should take no argument and return the data or a promise of it', () => {
expectTypeOf(createRetryer<{ value: string }, CustomError>)
.parameter(0)
.toHaveProperty('fn')
.toEqualTypeOf<() => { value: string } | Promise<{ value: string }>>()
})
})
describe('networkMode', () => {
it('should be required but accept undefined', () => {
// @ts-expect-error networkMode is required
createRetryer({ fn: () => 'data', canRun: () => true })
expectTypeOf(
createRetryer({
fn: () => 'data',
networkMode: undefined,
canRun: () => true,
}),
).toEqualTypeOf<Retryer<string>>()
})
it('should only accept a network mode', () => {
createRetryer({
fn: () => 'data',
// @ts-expect-error 'sometimes' is not a network mode
networkMode: 'sometimes',
canRun: () => true,
})
expectTypeOf(createRetryer<string, CustomError>)
.parameter(0)
.toHaveProperty('networkMode')
.toEqualTypeOf<'online' | 'always' | 'offlineFirst' | undefined>()
})
})
describe('canRun', () => {
it('should be required', () => {
// @ts-expect-error canRun is required
createRetryer({ fn: () => 'data', networkMode: 'online' })
expectTypeOf(
createRetryer({
fn: () => 'data',
networkMode: 'online',
canRun: () => true,
}),
).toEqualTypeOf<Retryer<string>>()
})
it('should take no argument and return a boolean', () => {
expectTypeOf(createRetryer<string, CustomError>)
.parameter(0)
.toHaveProperty('canRun')
.toEqualTypeOf<() => boolean>()
})
})
describe('initialPromise', () => {
it('should be an optional promise of the data', () => {
expectTypeOf(createRetryer<{ value: string }, CustomError>)
.parameter(0)
.toHaveProperty('initialPromise')
.toEqualTypeOf<Promise<{ value: string }> | undefined>()
})
it('should reject a promise of a different type', () => {
createRetryer<{ value: string }, CustomError>({
fn: () => ({ value: 'data' }),
// @ts-expect-error the initial promise must resolve to the data type
initialPromise: Promise.resolve('data'),
networkMode: 'online',
canRun: () => true,
})
expectTypeOf(
createRetryer<{ value: string }, CustomError>({
fn: () => ({ value: 'data' }),
initialPromise: Promise.resolve({ value: 'data' }),
networkMode: 'online',
canRun: () => true,
}),
).toEqualTypeOf<Retryer<{ value: string }>>()
})
})
describe('onCancel', () => {
it('should take the error and return void', () => {
expectTypeOf(createRetryer<string, CustomError>)
.parameter(0)
.toHaveProperty('onCancel')
.toEqualTypeOf<((error: CustomError) => void) | undefined>()
})
it('should type the error it receives', () => {
createRetryer<string, CustomError>({
fn: () => 'data',
networkMode: 'online',
canRun: () => true,
onCancel: (error) => {
expectTypeOf(error).toEqualTypeOf<CustomError>()
},
})
})
})
describe('onFail', () => {
it('should take the failure count and the error and return void', () => {
expectTypeOf(createRetryer<string, CustomError>)
.parameter(0)
.toHaveProperty('onFail')
.toEqualTypeOf<
((failureCount: number, error: CustomError) => void) | undefined
>()
})
it('should type the arguments it receives', () => {
createRetryer<string, CustomError>({
fn: () => 'data',
networkMode: 'online',
canRun: () => true,
onFail: (failureCount, error) => {
expectTypeOf(failureCount).toEqualTypeOf<number>()
expectTypeOf(error).toEqualTypeOf<CustomError>()
},
})
})
})
describe('onPause', () => {
it('should take no argument and return void', () => {
expectTypeOf(createRetryer<string, CustomError>)
.parameter(0)
.toHaveProperty('onPause')
.toEqualTypeOf<(() => void) | undefined>()
})
})
describe('onContinue', () => {
it('should take no argument and return void', () => {
expectTypeOf(createRetryer<string, CustomError>)
.parameter(0)
.toHaveProperty('onContinue')
.toEqualTypeOf<(() => void) | undefined>()
})
})
describe('retry', () => {
it('should be an optional retry value carrying the error type', () => {
expectTypeOf(createRetryer<string, CustomError>)
.parameter(0)
.toHaveProperty('retry')
.toEqualTypeOf<RetryValue<CustomError> | undefined>()
})
it('should type the arguments given to a retry function', () => {
createRetryer<string, CustomError>({
fn: () => 'data',
networkMode: 'online',
canRun: () => true,
retry: (failureCount, error) => {
expectTypeOf(failureCount).toEqualTypeOf<number>()
expectTypeOf(error).toEqualTypeOf<CustomError>()
return true
},
})
})
})
describe('retryDelay', () => {
it('should be an optional retry delay value carrying the error type', () => {
expectTypeOf(createRetryer<string, CustomError>)
.parameter(0)
.toHaveProperty('retryDelay')
.toEqualTypeOf<RetryDelayValue<CustomError> | undefined>()
})
it('should type the arguments given to a retry delay function', () => {
createRetryer<string, CustomError>({
fn: () => 'data',
networkMode: 'online',
canRun: () => true,
retryDelay: (failureCount, error) => {
expectTypeOf(failureCount).toEqualTypeOf<number>()
expectTypeOf(error).toEqualTypeOf<CustomError>()
return 1000
},
})
})
})
})
describe('Retryer', () => {
it('should keep every member writable', () => {
type Members = Retryer<{ value: string }>
expectTypeOf<Members>().toEqualTypeOf<{
-readonly [K in keyof Members]: Members[K]
}>()
})
describe('promise', () => {
it('should be a promise of the data', () => {
expectTypeOf<Retryer<{ value: string }>['promise']>().toEqualTypeOf<
Promise<{ value: string }>
>()
})
})
describe('start', () => {
it('should take no argument and return a promise of the data', () => {
expectTypeOf<Retryer<{ value: string }>['start']>().toEqualTypeOf<
() => Promise<{ value: string }>
>()
})
})
describe('continue', () => {
it('should return a promise of unknown rather than of the data', () => {
expectTypeOf<Retryer<{ value: string }>['continue']>().toEqualTypeOf<
() => Promise<unknown>
>()
})
})
describe('cancel', () => {
it('should take optional cancel options and return void', () => {
expectTypeOf<Retryer<{ value: string }>['cancel']>().toEqualTypeOf<
(cancelOptions?: { revert?: boolean; silent?: boolean }) => void
>()
})
})
describe('cancelRetry', () => {
it('should take no argument and return void', () => {
expectTypeOf<Retryer<{ value: string }>['cancelRetry']>().toEqualTypeOf<
() => void
>()
})
})
describe('continueRetry', () => {
it('should take no argument and return void', () => {
expectTypeOf<
Retryer<{ value: string }>['continueRetry']
>().toEqualTypeOf<() => void>()
})
})
describe('canStart', () => {
it('should take no argument and return a boolean', () => {
expectTypeOf<Retryer<{ value: string }>['canStart']>().toEqualTypeOf<
() => boolean
>()
})
})
describe('status', () => {
it('should return one of pending, resolved or rejected', () => {
expectTypeOf<Retryer<{ value: string }>['status']>().toEqualTypeOf<
() => 'pending' | 'resolved' | 'rejected'
>()
})
})
it('should default TData to unknown', () => {
expectTypeOf<Retryer>().toEqualTypeOf<Retryer<unknown>>()
})
it('should only expose its documented members', () => {
expectTypeOf<keyof Retryer<string>>().toEqualTypeOf<
| 'promise'
| 'cancel'
| 'continue'
| 'cancelRetry'
| 'continueRetry'
| 'canStart'
| 'start'
| 'status'
>()
})
})
describe('RetryValue', () => {
it('should be a boolean, a number or a should-retry function', () => {
expectTypeOf<RetryValue<CustomError>>().toEqualTypeOf<
| boolean
| number
| ((failureCount: number, error: CustomError) => boolean)
>()
})
it('should require an error type argument', () => {
// @ts-expect-error an error type must be provided
expectTypeOf<RetryValue>().toBeUnknown()
expectTypeOf<RetryValue<Error>>().toEqualTypeOf<
boolean | number | ((failureCount: number, error: Error) => boolean)
>()
})
it('should reject a should-retry function that does not return a boolean', () => {
createRetryer<string, CustomError>({
fn: () => 'data',
networkMode: 'online',
canRun: () => true,
// @ts-expect-error a should-retry function must return a boolean
retry: () => 'yes',
})
expectTypeOf<
ReturnType<
Extract<RetryValue<CustomError>, (...args: never) => unknown>
>
>().toEqualTypeOf<boolean>()
})
it('should accept a boolean or a number without a function', () => {
createRetryer<string, CustomError>({
fn: () => 'data',
networkMode: 'online',
canRun: () => true,
// @ts-expect-error a retry value cannot be a string
retry: 'three',
})
expectTypeOf<
Extract<RetryValue<CustomError>, boolean>
>().toEqualTypeOf<boolean>()
expectTypeOf<
Extract<RetryValue<CustomError>, number>
>().toEqualTypeOf<number>()
expectTypeOf<
Extract<RetryValue<CustomError>, string>
>().toEqualTypeOf<never>()
})
})
describe('RetryDelayValue', () => {
it('should be a number or a retry delay function', () => {
expectTypeOf<RetryDelayValue<CustomError>>().toEqualTypeOf<
number | ((failureCount: number, error: CustomError) => number)
>()
})
it('should not accept a boolean', () => {
createRetryer<string, CustomError>({
fn: () => 'data',
networkMode: 'online',
canRun: () => true,
// @ts-expect-error a retry delay cannot be a boolean
retryDelay: true,
})
expectTypeOf<
Extract<RetryDelayValue<CustomError>, boolean>
>().toEqualTypeOf<never>()
})
it('should reject a retry delay function that does not return a number', () => {
createRetryer<string, CustomError>({
fn: () => 'data',
networkMode: 'online',
canRun: () => true,
// @ts-expect-error a retry delay function must return a number
retryDelay: () => '1000',
})
expectTypeOf<
ReturnType<
Extract<RetryDelayValue<CustomError>, (...args: never) => unknown>
>
>().toEqualTypeOf<number>()
})
})
describe('CancelledError', () => {
it('should be an Error with optional revert and silent flags', () => {
const error = new CancelledError()
expectTypeOf(error).toEqualTypeOf<CancelledError>()
expectTypeOf(error).toExtend<Error>()
expectTypeOf(error.revert).toEqualTypeOf<boolean | undefined>()
expectTypeOf(error.silent).toEqualTypeOf<boolean | undefined>()
})
it('should only add revert and silent to the Error surface', () => {
expectTypeOf<Exclude<keyof CancelledError, keyof Error>>().toEqualTypeOf<
'revert' | 'silent'
>()
})
it('should take optional cancel options', () => {
expectTypeOf(CancelledError).toBeConstructibleWith()
expectTypeOf(CancelledError).toBeConstructibleWith({ revert: true })
expectTypeOf(CancelledError).toBeConstructibleWith({ silent: true })
expectTypeOf<
ConstructorParameters<typeof CancelledError>
>().toEqualTypeOf<[options?: CancelOptions]>()
})
it('should reject an unknown cancel option', () => {
// @ts-expect-error 'revertible' is not a cancel option
new CancelledError({ revertible: true })
expectTypeOf(
new CancelledError({ revert: true, silent: true }),
).toEqualTypeOf<CancelledError>()
})
})
describe('CancelOptions', () => {
it('should have optional revert and silent booleans', () => {
expectTypeOf<CancelOptions>().toEqualTypeOf<{
revert?: boolean
silent?: boolean
}>()
})
})
describe('canFetch', () => {
it('should take a network mode or undefined and return a boolean', () => {
expectTypeOf(canFetch).toEqualTypeOf<
(networkMode: NetworkMode | undefined) => boolean
>()
})
it('should require its argument', () => {
// @ts-expect-error the network mode argument is required
canFetch()
expectTypeOf(canFetch(undefined)).toEqualTypeOf<boolean>()
expectTypeOf(canFetch('always')).toEqualTypeOf<boolean>()
})
it('should reject a value that is not a network mode', () => {
// @ts-expect-error 'sometimes' is not a network mode
canFetch('sometimes')
expectTypeOf(canFetch('offlineFirst')).toEqualTypeOf<boolean>()
})
})
describe('isCancelledError', () => {
it('should narrow an unknown value to a CancelledError', () => {
const value: unknown = new CancelledError()
if (isCancelledError(value)) {
expectTypeOf(value).toEqualTypeOf<CancelledError>()
} else {
expectTypeOf(value).toEqualTypeOf<unknown>()
}
})
it('should accept any value and return a type predicate', () => {
expectTypeOf(isCancelledError).toEqualTypeOf<
(value: any) => value is CancelledError
>()
})
it('should narrow a union down to the CancelledError member', () => {
const value = {} as CancelledError | CustomError
if (isCancelledError(value)) {
expectTypeOf(value).toEqualTypeOf<CancelledError>()
} else {
expectTypeOf(value).toEqualTypeOf<CustomError>()
}
})
})
})

View File

@@ -0,0 +1,315 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { CancelledError, canFetch, createRetryer } from '../retryer'
import { focusManager } from '../focusManager'
import { onlineManager } from '../onlineManager'
describe('createRetryer', () => {
beforeEach(() => {
vi.useFakeTimers()
onlineManager.setOnline(true)
focusManager.setFocused(undefined)
})
afterEach(() => {
onlineManager.setOnline(true)
focusManager.setFocused(undefined)
vi.useRealTimers()
})
it('should resolve with the result of fn and set status to resolved', async () => {
const fn = vi.fn().mockResolvedValue('success')
const retryer = createRetryer<string>({
fn,
networkMode: 'online',
canRun: () => true,
})
const promise = retryer.start()
await vi.advanceTimersByTimeAsync(0)
await expect(promise).resolves.toBe('success')
expect(retryer.status()).toBe('resolved')
})
it('should reject after exhausting the retry limit and report each failure', async () => {
const error = new Error('failed')
const fn = vi.fn().mockRejectedValue(error)
const onFail = vi.fn()
const retryer = createRetryer({
fn,
retry: 2,
onFail,
networkMode: 'online',
canRun: () => true,
})
const promise = retryer.start()
await vi.advanceTimersByTimeAsync(10_000)
await expect(promise).rejects.toBe(error)
expect(fn).toHaveBeenCalledTimes(3)
expect(onFail).toHaveBeenNthCalledWith(1, 1, error)
expect(onFail).toHaveBeenNthCalledWith(2, 2, error)
expect(retryer.status()).toBe('rejected')
})
it('should retry a synchronous throw and resolve once fn succeeds', async () => {
let calls = 0
const fn = vi.fn().mockImplementation(() => {
calls++
if (calls === 1) {
throw new Error('sync throw')
}
return Promise.resolve('success')
})
const retryer = createRetryer({
fn,
retry: 1,
networkMode: 'online',
canRun: () => true,
})
const promise = retryer.start()
await vi.advanceTimersByTimeAsync(5_000)
await expect(promise).resolves.toBe('success')
expect(fn).toHaveBeenCalledTimes(2)
})
it('should use the default exponential backoff capped at 30 seconds', async () => {
const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout')
let calls = 0
const fn = vi.fn().mockImplementation(() => {
calls++
return calls < 7
? Promise.reject(new Error('failed'))
: Promise.resolve('success')
})
const retryer = createRetryer({
fn,
retry: true,
networkMode: 'online',
canRun: () => true,
})
const promise = retryer.start()
await vi.advanceTimersByTimeAsync(100_000)
await expect(promise).resolves.toBe('success')
const delays = setTimeoutSpy.mock.calls
.filter((call) => typeof call[1] === 'number')
.map((call) => call[1])
expect(delays).toEqual([1000, 2000, 4000, 8000, 16000, 30000])
})
it('should reject with a CancelledError carrying the cancel options and call onCancel', async () => {
const onCancel = vi.fn()
const fn = vi.fn().mockImplementation(() => new Promise(() => {}))
const retryer = createRetryer({
fn,
onCancel,
networkMode: 'online',
canRun: () => true,
})
const promise = retryer.start()
retryer.cancel({ revert: true, silent: true })
let caught: unknown
await promise.catch((error) => {
caught = error
})
expect(caught).toBeInstanceOf(CancelledError)
expect((caught as CancelledError).revert).toBe(true)
expect((caught as CancelledError).silent).toBe(true)
expect(onCancel).toHaveBeenCalledWith(caught)
expect(retryer.status()).toBe('rejected')
})
it('should ignore cancel after the retryer resolved', async () => {
const onCancel = vi.fn()
const fn = vi.fn().mockResolvedValue('success')
const retryer = createRetryer({
fn,
onCancel,
networkMode: 'online',
canRun: () => true,
})
const promise = retryer.start()
await vi.advanceTimersByTimeAsync(0)
retryer.cancel()
await expect(promise).resolves.toBe('success')
expect(retryer.status()).toBe('resolved')
expect(onCancel).not.toHaveBeenCalled()
})
it('should reject the pending retry instead of re-running when cancelRetry was called', async () => {
const error = new Error('failed')
const fn = vi.fn().mockRejectedValue(error)
const retryer = createRetryer({
fn,
retry: 1,
networkMode: 'online',
canRun: () => true,
})
const promise = retryer.start()
await vi.advanceTimersByTimeAsync(0)
expect(fn).toHaveBeenCalledTimes(1)
retryer.cancelRetry()
await vi.advanceTimersByTimeAsync(5_000)
await expect(promise).rejects.toBe(error)
expect(fn).toHaveBeenCalledTimes(1)
expect(retryer.status()).toBe('rejected')
})
it('should resume retrying after continueRetry reverts cancelRetry', async () => {
let calls = 0
const fn = vi.fn().mockImplementation(() => {
calls++
return calls === 1
? Promise.reject(new Error('failed'))
: Promise.resolve('success')
})
const retryer = createRetryer({
fn,
retry: 1,
networkMode: 'online',
canRun: () => true,
})
const promise = retryer.start()
await vi.advanceTimersByTimeAsync(0)
retryer.cancelRetry()
retryer.continueRetry()
await vi.advanceTimersByTimeAsync(5_000)
await expect(promise).resolves.toBe('success')
expect(fn).toHaveBeenCalledTimes(2)
})
it('should pause when offline during a retry and continue when back online', async () => {
let calls = 0
const fn = vi.fn().mockImplementation(() => {
calls++
return calls === 1
? Promise.reject(new Error('failed'))
: Promise.resolve('success')
})
const onPause = vi.fn()
const onContinue = vi.fn()
const retryer = createRetryer({
fn,
retry: 1,
onPause,
onContinue,
networkMode: 'online',
canRun: () => true,
})
const promise = retryer.start()
onlineManager.setOnline(false)
await vi.advanceTimersByTimeAsync(5_000)
expect(onPause).toHaveBeenCalledTimes(1)
expect(fn).toHaveBeenCalledTimes(1)
expect(retryer.status()).toBe('pending')
onlineManager.setOnline(true)
void retryer.continue()
await vi.advanceTimersByTimeAsync(0)
expect(onContinue).toHaveBeenCalledTimes(1)
await expect(promise).resolves.toBe('success')
expect(fn).toHaveBeenCalledTimes(2)
})
it('should run even when offline with networkMode "always"', async () => {
onlineManager.setOnline(false)
const fn = vi.fn().mockResolvedValue('success')
const retryer = createRetryer({
fn,
networkMode: 'always',
canRun: () => true,
})
const promise = retryer.start()
await vi.advanceTimersByTimeAsync(0)
await expect(promise).resolves.toBe('success')
expect(fn).toHaveBeenCalledTimes(1)
})
it('should start offline with networkMode "offlineFirst" but pause when the retry stays offline', async () => {
onlineManager.setOnline(false)
let calls = 0
const fn = vi.fn().mockImplementation(() => {
calls++
return calls === 1
? Promise.reject(new Error('failed'))
: Promise.resolve('success')
})
const onPause = vi.fn()
const retryer = createRetryer({
fn,
retry: 1,
onPause,
networkMode: 'offlineFirst',
canRun: () => true,
})
void retryer.start()
await vi.advanceTimersByTimeAsync(5_000)
expect(fn).toHaveBeenCalledTimes(1)
expect(onPause).toHaveBeenCalledTimes(1)
expect(retryer.status()).toBe('pending')
})
it('should reflect network availability in canFetch and canStart', () => {
const canRun = () => true
onlineManager.setOnline(false)
expect(canFetch('online')).toBe(false)
expect(canFetch('offlineFirst')).toBe(true)
const onlineRetryer = createRetryer({
fn: () => Promise.resolve(''),
networkMode: 'online',
canRun,
})
expect(onlineRetryer.canStart()).toBe(false)
const offlineFirstRetryer = createRetryer({
fn: () => Promise.resolve(''),
networkMode: 'offlineFirst',
canRun,
})
expect(offlineFirstRetryer.canStart()).toBe(true)
onlineManager.setOnline(true)
expect(onlineRetryer.canStart()).toBe(true)
})
it('should reuse the initialPromise on the first run and call fn only on retries', async () => {
const fn = vi.fn().mockResolvedValue('from fn')
const retryer = createRetryer({
fn,
initialPromise: Promise.reject(new Error('initial failed')),
retry: 1,
networkMode: 'online',
canRun: () => true,
})
const promise = retryer.start()
await vi.advanceTimersByTimeAsync(5_000)
await expect(promise).resolves.toBe('from fn')
expect(fn).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,355 @@
import { describe, expectTypeOf, it } from 'vitest'
import { streamedQuery } from '../streamedQuery'
import type { QueryClient, QueryFunction, QueryKey, QueryMeta } from '..'
function numberStream(): AsyncIterable<number> {
return {} as AsyncIterable<number>
}
// `StreamedQueryParams` is a union, and `keyof` on a union is the intersection
// of its members' keys, so each arm has to be pinned on its own.
type Params = Parameters<typeof streamedQuery<number, string, ['stream']>>[0]
type SimpleParams = Extract<Params, { reducer?: never }>
type ReducibleParams = Exclude<Params, { reducer?: never }>
describe('streamedQuery', () => {
describe('TQueryFnData', () => {
it('should infer the chunk type from the stream returned by streamFn', () => {
expectTypeOf(
streamedQuery({
streamFn: () => numberStream(),
}),
).toEqualTypeOf<QueryFunction<Array<number>, QueryKey>>()
})
it('should infer the chunk type from a promise of a stream', () => {
expectTypeOf(
streamedQuery({
streamFn: () => Promise.resolve(numberStream()),
}),
).toEqualTypeOf<QueryFunction<Array<number>, QueryKey>>()
})
it('should default to unknown when the stream type cannot be inferred', () => {
expectTypeOf(
streamedQuery({
streamFn: () =>
({}) as AsyncIterable<unknown> | Promise<AsyncIterable<unknown>>,
}),
).toEqualTypeOf<QueryFunction<Array<unknown>, QueryKey>>()
})
it('should default to unknown when there is no inference site', () => {
// an `AsyncIterable` of an unconstrained type parameter gives the
// inference nothing to latch onto, so the default has to fill in
function streamOf<T>(): AsyncIterable<T> {
return {} as AsyncIterable<T>
}
expectTypeOf(streamedQuery({ streamFn: () => streamOf() })).toEqualTypeOf<
QueryFunction<Array<unknown>, QueryKey>
>()
})
})
describe('TData', () => {
it('should default to an array of the chunk type', () => {
expectTypeOf(
streamedQuery<{ value: string }>({
streamFn: () => ({}) as AsyncIterable<{ value: string }>,
}),
).toEqualTypeOf<QueryFunction<Array<{ value: string }>, QueryKey>>()
})
it('should be the reducer result when a reducer is provided', () => {
expectTypeOf(
streamedQuery({
streamFn: () => numberStream(),
reducer: (acc: string, chunk: number) => acc + chunk,
initialValue: '',
}),
).toEqualTypeOf<QueryFunction<string, QueryKey>>()
})
})
describe('TQueryKey', () => {
it('should default to QueryKey', () => {
streamedQuery({
streamFn: (context) => {
expectTypeOf(context.queryKey).toEqualTypeOf<QueryKey>()
return numberStream()
},
})
})
it('should be carried into the returned QueryFunction when given explicitly', () => {
expectTypeOf(
streamedQuery<number, Array<number>, ['stream', number]>({
streamFn: () => numberStream(),
}),
).toEqualTypeOf<QueryFunction<Array<number>, ['stream', number]>>()
})
it('should only accept a query key', () => {
// @ts-expect-error a query key must be an array
streamedQuery<number, Array<number>, string>({
streamFn: () => numberStream(),
})
expectTypeOf(
streamedQuery<number, Array<number>, ReadonlyArray<unknown>>({
streamFn: () => numberStream(),
}),
).toEqualTypeOf<QueryFunction<Array<number>, ReadonlyArray<unknown>>>()
})
})
describe('StreamedQueryParams', () => {
it('should be the only parameter', () => {
streamedQuery<number, Array<number>, ['stream']>(
{ streamFn: () => numberStream() },
// @ts-expect-error there is no second parameter
'extra',
)
expectTypeOf<
Parameters<
typeof streamedQuery<number, Array<number>, ['stream']>
>['length']
>().toEqualTypeOf<1>()
})
it('should only expose the options of its simple form', () => {
expectTypeOf<keyof SimpleParams>().toEqualTypeOf<
'streamFn' | 'refetchMode' | 'reducer' | 'initialValue'
>()
})
it('should only expose the options of its reducible form', () => {
expectTypeOf<keyof ReducibleParams>().toEqualTypeOf<
'streamFn' | 'refetchMode' | 'reducer' | 'initialValue'
>()
})
it('should keep every option writable', () => {
// `Pick` preserves the `readonly` modifier that an indexed access such as
// `Params['refetchMode']` silently strips, and distributes over the union
expectTypeOf<Pick<Params, 'streamFn'>>().toEqualTypeOf<{
streamFn: (
context: Parameters<QueryFunction<string, ['stream']>>[0],
) => AsyncIterable<number> | Promise<AsyncIterable<number>>
}>()
expectTypeOf<Pick<Params, 'refetchMode'>>().toEqualTypeOf<{
refetchMode?: 'append' | 'reset' | 'replace'
}>()
expectTypeOf<
Pick<ReducibleParams, 'reducer' | 'initialValue'>
>().toEqualTypeOf<{
reducer: (acc: string, chunk: number) => string
initialValue: string
}>()
})
it('should accept an object literal with all of its options', () => {
expectTypeOf(
streamedQuery<number, string, ['stream']>({
streamFn: () => numberStream(),
refetchMode: 'append',
reducer: (acc, chunk) => acc + chunk,
initialValue: '',
}),
).toEqualTypeOf<QueryFunction<string, ['stream']>>()
})
})
describe('streamFn', () => {
it('should be required', () => {
// @ts-expect-error streamFn is required
streamedQuery({})
expectTypeOf(
streamedQuery({ streamFn: () => numberStream() }),
).toEqualTypeOf<QueryFunction<Array<number>, QueryKey>>()
})
it('should type the context it receives', () => {
streamedQuery<number, Array<number>, ['stream', number]>({
streamFn: (context) => {
expectTypeOf(context.queryKey).toEqualTypeOf<['stream', number]>()
expectTypeOf(context.client).toEqualTypeOf<QueryClient>()
expectTypeOf(context.signal).toEqualTypeOf<AbortSignal>()
expectTypeOf(context.meta).toEqualTypeOf<QueryMeta | undefined>()
expectTypeOf(context.pageParam).toEqualTypeOf<unknown>()
expectTypeOf(context.direction).toEqualTypeOf<unknown>()
return numberStream()
},
})
})
it('should reject a stream whose chunk type does not match', () => {
streamedQuery<number>({
// @ts-expect-error the stream must yield numbers
streamFn: () => ({}) as AsyncIterable<string>,
})
expectTypeOf(
streamedQuery<number>({
streamFn: () => ({}) as AsyncIterable<number>,
}),
).toEqualTypeOf<QueryFunction<Array<number>, QueryKey>>()
})
it('should reject a stream that is not an AsyncIterable', () => {
streamedQuery({
// @ts-expect-error a plain array is not an AsyncIterable
streamFn: () => [1, 2, 3],
})
expectTypeOf(
streamedQuery({ streamFn: () => numberStream() }),
).toEqualTypeOf<QueryFunction<Array<number>, QueryKey>>()
})
})
describe('refetchMode', () => {
it('should be optional', () => {
expectTypeOf(
streamedQuery({ streamFn: () => numberStream() }),
).toEqualTypeOf<QueryFunction<Array<number>, QueryKey>>()
})
it('should only accept append, reset or replace', () => {
streamedQuery({
streamFn: () => numberStream(),
// @ts-expect-error 'merge' is not a valid refetch mode
refetchMode: 'merge',
})
expectTypeOf(
streamedQuery({
streamFn: () => numberStream(),
refetchMode: 'append',
}),
).toEqualTypeOf<QueryFunction<Array<number>, QueryKey>>()
expectTypeOf(
streamedQuery({
streamFn: () => numberStream(),
refetchMode: 'reset',
}),
).toEqualTypeOf<QueryFunction<Array<number>, QueryKey>>()
expectTypeOf(
streamedQuery({
streamFn: () => numberStream(),
refetchMode: 'replace',
}),
).toEqualTypeOf<QueryFunction<Array<number>, QueryKey>>()
})
})
describe('reducer', () => {
it('should not be accepted without initialValue', () => {
streamedQuery({
streamFn: () => numberStream(),
// @ts-expect-error a reducer requires an initialValue
reducer: (acc: string, chunk: number) => acc + chunk,
})
expectTypeOf(
streamedQuery({
streamFn: () => numberStream(),
reducer: (acc: string, chunk: number) => acc + chunk,
initialValue: '',
}),
).toEqualTypeOf<QueryFunction<string, QueryKey>>()
})
it('should type its accumulator and chunk parameters', () => {
streamedQuery<number, string>({
streamFn: () => numberStream(),
reducer: (acc, chunk) => {
expectTypeOf(acc).toEqualTypeOf<string>()
expectTypeOf(chunk).toEqualTypeOf<number>()
return acc + chunk
},
initialValue: '',
})
})
it('should reject a return type that does not match the accumulator', () => {
streamedQuery<number, string>({
streamFn: () => numberStream(),
// @ts-expect-error the reducer must return the accumulator type
reducer: (_acc, chunk) => chunk,
initialValue: '',
})
expectTypeOf(
streamedQuery<number, string>({
streamFn: () => numberStream(),
reducer: (_acc, chunk) => String(chunk),
initialValue: '',
}),
).toEqualTypeOf<QueryFunction<string, QueryKey>>()
})
})
describe('initialValue', () => {
it('should not be accepted without a reducer', () => {
streamedQuery({
streamFn: () => numberStream(),
// @ts-expect-error an initialValue requires a reducer
initialValue: '',
})
expectTypeOf(
streamedQuery({
streamFn: () => numberStream(),
reducer: (acc: string, chunk: number) => acc + chunk,
initialValue: '',
}),
).toEqualTypeOf<QueryFunction<string, QueryKey>>()
})
it('should be required alongside a reducer', () => {
streamedQuery<number, string>({
streamFn: () => numberStream(),
reducer: (acc, chunk) => acc + chunk,
// @ts-expect-error initialValue must match TData
initialValue: 0,
})
expectTypeOf(
streamedQuery<number, string>({
streamFn: () => numberStream(),
reducer: (acc, chunk) => acc + chunk,
initialValue: '',
}),
).toEqualTypeOf<QueryFunction<string, QueryKey>>()
})
})
describe('QueryFunction', () => {
it('should return the accumulated data, not a single chunk', () => {
const queryFn = streamedQuery({
streamFn: () => numberStream(),
})
expectTypeOf(queryFn).returns.toEqualTypeOf<
Array<number> | Promise<Array<number>>
>()
})
it('should take the query function context as its only parameter', () => {
const queryFn = streamedQuery<number, Array<number>, ['stream']>({
streamFn: () => numberStream(),
})
expectTypeOf(queryFn).parameter(0).toHaveProperty('queryKey')
expectTypeOf(queryFn)
.parameter(0)
.toEqualTypeOf<
Parameters<QueryFunction<Array<number>, ['stream']>>[0]
>()
})
})
})

View File

@@ -0,0 +1,745 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { queryKey, sleep } from '@tanstack/query-test-utils'
import { streamedQuery } from '../streamedQuery'
import { QueryClient, QueryObserver } from '..'
describe('streamedQuery', () => {
let queryClient: QueryClient
beforeEach(() => {
vi.useFakeTimers()
queryClient = new QueryClient()
queryClient.mount()
})
afterEach(() => {
queryClient.clear()
vi.useRealTimers()
})
function createAsyncNumberGenerator(amount: number, start = 0) {
return {
async *[Symbol.asyncIterator]() {
let num = start
while (num < amount + start) {
await sleep(50)
yield num++
}
},
}
}
it('should stream data from an AsyncIterable', async () => {
const key = queryKey()
const observer = new QueryObserver(queryClient, {
queryKey: key,
queryFn: streamedQuery({
streamFn: () => createAsyncNumberGenerator(3),
}),
})
const unsubscribe = observer.subscribe(vi.fn())
expect(observer.getCurrentResult()).toMatchObject({
status: 'pending',
fetchStatus: 'fetching',
data: undefined,
})
await vi.advanceTimersByTimeAsync(50)
expect(observer.getCurrentResult()).toMatchObject({
status: 'success',
fetchStatus: 'fetching',
data: [0],
})
await vi.advanceTimersByTimeAsync(50)
expect(observer.getCurrentResult()).toMatchObject({
status: 'success',
fetchStatus: 'fetching',
data: [0, 1],
})
await vi.advanceTimersByTimeAsync(50)
expect(observer.getCurrentResult()).toMatchObject({
status: 'success',
fetchStatus: 'idle',
data: [0, 1, 2],
})
unsubscribe()
})
it('should allow Arrays to be returned from the stream', async () => {
const key = queryKey()
const observer = new QueryObserver(queryClient, {
queryKey: key,
queryFn: streamedQuery({
streamFn: async function* () {
for await (const num of createAsyncNumberGenerator(3)) {
yield [num, num] as const
}
},
}),
})
const unsubscribe = observer.subscribe(vi.fn())
expect(observer.getCurrentResult()).toMatchObject({
status: 'pending',
fetchStatus: 'fetching',
data: undefined,
})
await vi.advanceTimersByTimeAsync(50)
expect(observer.getCurrentResult()).toMatchObject({
status: 'success',
fetchStatus: 'fetching',
data: [[0, 0]],
})
await vi.advanceTimersByTimeAsync(50)
expect(observer.getCurrentResult()).toMatchObject({
status: 'success',
fetchStatus: 'fetching',
data: [
[0, 0],
[1, 1],
],
})
await vi.advanceTimersByTimeAsync(50)
expect(observer.getCurrentResult()).toMatchObject({
status: 'success',
fetchStatus: 'idle',
data: [
[0, 0],
[1, 1],
[2, 2],
],
})
unsubscribe()
})
it('should handle empty streams', async () => {
const key = queryKey()
const observer = new QueryObserver(queryClient, {
queryKey: key,
queryFn: streamedQuery({
streamFn: async function* () {},
}),
})
const unsubscribe = observer.subscribe(vi.fn())
expect(observer.getCurrentResult()).toMatchObject({
status: 'pending',
fetchStatus: 'fetching',
data: undefined,
})
await vi.advanceTimersByTimeAsync(50)
expect(observer.getCurrentResult()).toMatchObject({
status: 'success',
fetchStatus: 'idle',
data: [],
})
unsubscribe()
})
it('should replace on refetch', async () => {
const key = queryKey()
const observer = new QueryObserver(queryClient, {
queryKey: key,
queryFn: streamedQuery({
streamFn: () => createAsyncNumberGenerator(2),
}),
})
const unsubscribe = observer.subscribe(vi.fn())
expect(observer.getCurrentResult()).toMatchObject({
status: 'pending',
fetchStatus: 'fetching',
data: undefined,
})
await vi.advanceTimersByTimeAsync(100)
expect(observer.getCurrentResult()).toMatchObject({
status: 'success',
fetchStatus: 'idle',
data: [0, 1],
})
void observer.refetch()
await vi.advanceTimersByTimeAsync(10)
expect(observer.getCurrentResult()).toMatchObject({
status: 'pending',
fetchStatus: 'fetching',
data: undefined,
})
await vi.advanceTimersByTimeAsync(40)
expect(observer.getCurrentResult()).toMatchObject({
status: 'success',
fetchStatus: 'fetching',
data: [0],
})
await vi.advanceTimersByTimeAsync(50)
expect(observer.getCurrentResult()).toMatchObject({
status: 'success',
fetchStatus: 'idle',
data: [0, 1],
})
unsubscribe()
})
it('should support refetchMode append', async () => {
const key = queryKey()
const observer = new QueryObserver(queryClient, {
queryKey: key,
queryFn: streamedQuery({
streamFn: () => createAsyncNumberGenerator(2),
refetchMode: 'append',
}),
})
const unsubscribe = observer.subscribe(vi.fn())
expect(observer.getCurrentResult()).toMatchObject({
status: 'pending',
fetchStatus: 'fetching',
data: undefined,
})
await vi.advanceTimersByTimeAsync(100)
expect(observer.getCurrentResult()).toMatchObject({
status: 'success',
fetchStatus: 'idle',
data: [0, 1],
})
void observer.refetch()
await vi.advanceTimersByTimeAsync(10)
expect(observer.getCurrentResult()).toMatchObject({
status: 'success',
fetchStatus: 'fetching',
data: [0, 1],
})
await vi.advanceTimersByTimeAsync(40)
expect(observer.getCurrentResult()).toMatchObject({
status: 'success',
fetchStatus: 'fetching',
data: [0, 1, 0],
})
await vi.advanceTimersByTimeAsync(50)
expect(observer.getCurrentResult()).toMatchObject({
status: 'success',
fetchStatus: 'idle',
data: [0, 1, 0, 1],
})
unsubscribe()
})
it('should support refetchMode replace', async () => {
const key = queryKey()
let offset = 0
const observer = new QueryObserver(queryClient, {
queryKey: key,
queryFn: streamedQuery({
streamFn: () => createAsyncNumberGenerator(2, offset),
refetchMode: 'replace',
}),
})
const unsubscribe = observer.subscribe(vi.fn())
expect(observer.getCurrentResult()).toMatchObject({
status: 'pending',
fetchStatus: 'fetching',
data: undefined,
})
await vi.advanceTimersByTimeAsync(100)
expect(observer.getCurrentResult()).toMatchObject({
status: 'success',
fetchStatus: 'idle',
data: [0, 1],
})
offset = 100
void observer.refetch()
await vi.advanceTimersByTimeAsync(10)
expect(observer.getCurrentResult()).toMatchObject({
status: 'success',
fetchStatus: 'fetching',
data: [0, 1],
})
await vi.advanceTimersByTimeAsync(40)
expect(observer.getCurrentResult()).toMatchObject({
status: 'success',
fetchStatus: 'fetching',
data: [0, 1],
})
await vi.advanceTimersByTimeAsync(50)
expect(observer.getCurrentResult()).toMatchObject({
status: 'success',
fetchStatus: 'idle',
data: [100, 101],
})
unsubscribe()
})
it('should abort ongoing stream when refetch happens', async () => {
const key = queryKey()
const observer = new QueryObserver(queryClient, {
queryKey: key,
queryFn: streamedQuery({
streamFn: (context) => {
// just consume the signal
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const numbers = context.signal ? 3 : 0
return createAsyncNumberGenerator(numbers)
},
refetchMode: 'append',
}),
})
const unsubscribe = observer.subscribe(vi.fn())
expect(observer.getCurrentResult()).toMatchObject({
status: 'pending',
fetchStatus: 'fetching',
data: undefined,
})
await vi.advanceTimersByTimeAsync(100)
expect(observer.getCurrentResult()).toMatchObject({
status: 'success',
fetchStatus: 'fetching',
data: [0, 1],
})
void observer.refetch()
await vi.advanceTimersByTimeAsync(10)
expect(observer.getCurrentResult()).toMatchObject({
status: 'success',
fetchStatus: 'fetching',
data: [0, 1],
})
await vi.advanceTimersByTimeAsync(40)
expect(observer.getCurrentResult()).toMatchObject({
status: 'success',
fetchStatus: 'fetching',
data: [0, 1, 0],
})
await vi.advanceTimersByTimeAsync(100)
expect(observer.getCurrentResult()).toMatchObject({
status: 'success',
fetchStatus: 'idle',
data: [0, 1, 0, 1, 2],
})
unsubscribe()
})
it('should abort when unsubscribed', async () => {
const key = queryKey()
const observer = new QueryObserver(queryClient, {
queryKey: key,
queryFn: streamedQuery({
streamFn: (context) => {
// just consume the signal
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const numbers = context.signal ? 3 : 0
return createAsyncNumberGenerator(numbers)
},
}),
})
const unsubscribe = observer.subscribe(vi.fn())
expect(queryClient.getQueryState(key)).toMatchObject({
status: 'pending',
fetchStatus: 'fetching',
data: undefined,
})
await vi.advanceTimersByTimeAsync(60)
expect(queryClient.getQueryState(key)).toMatchObject({
status: 'success',
fetchStatus: 'fetching',
data: [0],
})
unsubscribe()
await vi.advanceTimersByTimeAsync(10)
expect(queryClient.getQueryState(key)).toMatchObject({
status: 'success',
fetchStatus: 'idle',
data: [0],
})
})
it('should not abort when signal not consumed', async () => {
const key = queryKey()
const observer = new QueryObserver(queryClient, {
queryKey: key,
queryFn: streamedQuery({
streamFn: () => createAsyncNumberGenerator(3),
}),
})
const unsubscribe = observer.subscribe(vi.fn())
expect(queryClient.getQueryState(key)).toMatchObject({
status: 'pending',
fetchStatus: 'fetching',
data: undefined,
})
await vi.advanceTimersByTimeAsync(60)
expect(queryClient.getQueryState(key)).toMatchObject({
status: 'success',
fetchStatus: 'fetching',
data: [0],
})
unsubscribe()
await vi.advanceTimersByTimeAsync(50)
expect(queryClient.getQueryState(key)).toMatchObject({
status: 'success',
fetchStatus: 'fetching',
data: [0, 1],
})
})
it('should support custom reducer', async () => {
const key = queryKey()
const observer = new QueryObserver(queryClient, {
queryKey: key,
queryFn: streamedQuery({
streamFn: () => createAsyncNumberGenerator(2),
reducer: (acc, chunk) => ({
...acc,
[chunk]: true,
}),
initialValue: {} as Record<number, boolean>,
}),
})
const unsubscribe = observer.subscribe(vi.fn())
expect(observer.getCurrentResult()).toMatchObject({
status: 'pending',
fetchStatus: 'fetching',
data: undefined,
})
await vi.advanceTimersByTimeAsync(100)
expect(observer.getCurrentResult()).toMatchObject({
status: 'success',
fetchStatus: 'idle',
data: {
0: true,
1: true,
},
})
unsubscribe()
})
it('should support custom reducer with initialValue', async () => {
const key = queryKey()
const observer = new QueryObserver(queryClient, {
queryKey: key,
queryFn: streamedQuery({
streamFn: () => createAsyncNumberGenerator(2),
reducer: (acc, chunk) => ({
...acc,
[chunk]: true,
}),
initialValue: {
10: true,
11: true,
} as Record<number, boolean>,
}),
})
const unsubscribe = observer.subscribe(vi.fn())
expect(observer.getCurrentResult()).toMatchObject({
status: 'pending',
fetchStatus: 'fetching',
data: undefined,
})
await vi.advanceTimersByTimeAsync(100)
expect(observer.getCurrentResult()).toMatchObject({
status: 'success',
fetchStatus: 'idle',
data: {
10: true,
11: true,
0: true,
1: true,
},
})
unsubscribe()
})
it('should keep error state on reset refetch when initialData is defined', async () => {
const key = queryKey()
let shouldError = false
const error = new Error('stream failed')
const observer = new QueryObserver(queryClient, {
queryKey: key,
initialData: ['initial'],
retry: false,
queryFn: streamedQuery({
refetchMode: 'reset',
// eslint-disable-next-line @typescript-eslint/require-await
streamFn: async function* () {
if (shouldError) {
throw error
}
yield 0
},
}),
})
const unsubscribe = observer.subscribe(vi.fn())
await vi.advanceTimersByTimeAsync(0)
expect(observer.getCurrentResult()).toMatchObject({
status: 'success',
fetchStatus: 'idle',
data: ['initial', 0],
})
shouldError = true
const refetchPromise = observer.refetch()
await vi.advanceTimersByTimeAsync(0)
await expect(refetchPromise).resolves.toMatchObject({
status: 'error',
error,
data: ['initial'],
})
expect(observer.getCurrentResult()).toMatchObject({
status: 'error',
fetchStatus: 'idle',
data: ['initial'],
error,
})
unsubscribe()
})
it('should treat a fetch after an initial error as a refetch for reset mode', async () => {
const key = queryKey()
let shouldError = true
const error = new Error('stream failed')
const observer = new QueryObserver(queryClient, {
queryKey: key,
retry: false,
queryFn: streamedQuery({
refetchMode: 'reset',
streamFn: async function* () {
if (shouldError) {
throw error
}
yield* createAsyncNumberGenerator(1)
},
}),
})
const unsubscribe = observer.subscribe(vi.fn())
await vi.advanceTimersByTimeAsync(0)
expect(observer.getCurrentResult()).toMatchObject({
status: 'error',
fetchStatus: 'idle',
data: undefined,
error,
})
shouldError = false
void observer.refetch()
await vi.advanceTimersByTimeAsync(0)
expect(observer.getCurrentResult()).toMatchObject({
status: 'pending',
fetchStatus: 'fetching',
data: undefined,
error: null,
})
await vi.advanceTimersByTimeAsync(50)
expect(observer.getCurrentResult()).toMatchObject({
status: 'success',
fetchStatus: 'idle',
data: [0],
error: null,
})
unsubscribe()
})
it('should reset to initialData on refetch after an initial error', async () => {
const key = queryKey()
let shouldError = true
const error = new Error('stream failed')
const observer = new QueryObserver(queryClient, {
queryKey: key,
initialData: ['initial'],
retry: false,
queryFn: streamedQuery({
refetchMode: 'reset',
streamFn: async function* () {
if (shouldError) {
throw error
}
yield* createAsyncNumberGenerator(1)
},
}),
})
const unsubscribe = observer.subscribe(vi.fn())
await vi.advanceTimersByTimeAsync(0)
expect(observer.getCurrentResult()).toMatchObject({
status: 'error',
fetchStatus: 'idle',
data: ['initial'],
error,
})
shouldError = false
void observer.refetch()
await vi.advanceTimersByTimeAsync(0)
expect(observer.getCurrentResult()).toMatchObject({
status: 'success',
fetchStatus: 'fetching',
data: ['initial'],
error: null,
})
await vi.advanceTimersByTimeAsync(50)
expect(observer.getCurrentResult()).toMatchObject({
status: 'success',
fetchStatus: 'idle',
data: ['initial', 0],
error: null,
})
unsubscribe()
})
it('should not call reducer twice when refetchMode is replace', async () => {
const key = queryKey()
const arr: Array<number> = []
const observer = new QueryObserver(queryClient, {
queryKey: key,
queryFn: streamedQuery({
// eslint-disable-next-line @typescript-eslint/require-await
streamFn: async function* () {
const v = [1, 2, 3]
yield* v
},
reducer: (oldStream, newChunk) => {
arr.push(newChunk)
return [...oldStream, newChunk]
},
initialValue: [] as Array<number>,
refetchMode: 'replace',
}),
})
const unsubscribe = observer.subscribe(vi.fn())
await vi.advanceTimersByTimeAsync(0)
expect(arr).toEqual([1, 2, 3])
expect(observer.getCurrentResult().data).toEqual([1, 2, 3])
void observer.refetch()
await vi.advanceTimersByTimeAsync(0)
expect(arr).toEqual([1, 2, 3, 1, 2, 3])
expect(observer.getCurrentResult().data).toEqual([1, 2, 3])
unsubscribe()
})
})

View File

@@ -0,0 +1,191 @@
import { describe, expectTypeOf, it } from 'vitest'
import { Subscribable } from '../subscribable'
describe('subscribable', () => {
describe('Subscribable', () => {
describe('TListener', () => {
it('should only accept a function type', () => {
// @ts-expect-error a listener must extend Function
new Subscribable<string>()
expectTypeOf(new Subscribable<() => void>()).toEqualTypeOf<
Subscribable<() => void>
>()
})
it('should accept a function type with parameters and a return type', () => {
expectTypeOf(
new Subscribable<(value: number) => string>(),
).toEqualTypeOf<Subscribable<(value: number) => string>>()
})
it('should accept any Function subtype, not just a call signature', () => {
// `Function` is wider than `(...args: Array<any>) => any`: a construct
// signature and a function carrying extra properties both satisfy the
// former but not the latter.
type Constructor = new () => object
interface CallableWithProperty extends Function {
custom: number
}
expectTypeOf(new Subscribable<Constructor>()).toEqualTypeOf<
Subscribable<Constructor>
>()
expectTypeOf(new Subscribable<CallableWithProperty>()).toEqualTypeOf<
Subscribable<CallableWithProperty>
>()
})
it('should not have a default type argument', () => {
// @ts-expect-error a listener type must be provided
type WithoutArgument = Subscribable
// the `@ts-expect-error` above is the verification here: omitting the
// argument has to be an error. It leaves the alias as the error type,
// so there is no positive assertion to pair it with -- these only
// record that the erroring alias is `any` while a supplied one is not.
expectTypeOf<WithoutArgument>().toBeAny()
expectTypeOf<Subscribable<() => void>>().not.toBeAny()
})
})
it('should only expose subscribe and hasListeners', () => {
expectTypeOf<keyof Subscribable<() => void>>().toEqualTypeOf<
'subscribe' | 'hasListeners'
>()
})
describe('constructor', () => {
it('should not accept any argument', () => {
expectTypeOf<
ConstructorParameters<typeof Subscribable>
>().toEqualTypeOf<[]>()
})
})
describe('subscribe', () => {
it('should accept the listener type as its only parameter', () => {
const subscribable = new Subscribable<(value: number) => string>()
expectTypeOf(subscribable.subscribe).parameters.toEqualTypeOf<
[listener: (value: number) => string]
>()
})
it('should return an unsubscribe function that takes no argument and returns void', () => {
const subscribable = new Subscribable<() => void>()
expectTypeOf(subscribable.subscribe).returns.toEqualTypeOf<() => void>()
expectTypeOf(subscribable.subscribe(() => {})).toEqualTypeOf<
() => void
>()
})
it('should reject a listener that does not match the listener type', () => {
const subscribable = new Subscribable<(value: number) => string>()
// @ts-expect-error the listener must return a string
subscribable.subscribe((value: number) => value)
expectTypeOf(
subscribable.subscribe((value: number) => String(value)),
).toEqualTypeOf<() => void>()
})
})
describe('hasListeners', () => {
it('should take no argument and return a boolean', () => {
const subscribable = new Subscribable<() => void>()
expectTypeOf(subscribable.hasListeners).parameters.toEqualTypeOf<[]>()
expectTypeOf(subscribable.hasListeners).returns.toEqualTypeOf<boolean>()
})
})
describe('listeners', () => {
it('should not be accessible from outside the class', () => {
const subscribable = new Subscribable<() => void>()
// @ts-expect-error listeners is protected
subscribable.listeners
expectTypeOf(subscribable).not.toHaveProperty('listeners')
})
it('should be a set of listeners inside the class', () => {
class CustomSubscribable extends Subscribable<(value: number) => void> {
getListeners() {
expectTypeOf(this.listeners).toEqualTypeOf<
Set<(value: number) => void>
>()
return this.listeners
}
}
expectTypeOf(new CustomSubscribable().getListeners()).toEqualTypeOf<
Set<(value: number) => void>
>()
})
it('should stay writable inside the class', () => {
class CustomSubscribable extends Subscribable<() => void> {
replaceListeners() {
this.listeners = new Set<() => void>()
}
}
expectTypeOf<CustomSubscribable['replaceListeners']>().toEqualTypeOf<
() => void
>()
})
})
describe('onSubscribe', () => {
it('should not be accessible from outside the class', () => {
const subscribable = new Subscribable<() => void>()
// @ts-expect-error onSubscribe is protected
subscribable.onSubscribe()
expectTypeOf(subscribable).not.toHaveProperty('onSubscribe')
})
it('should take no argument and return void inside the class', () => {
class CustomSubscribable extends Subscribable<() => void> {
protected override onSubscribe(): void {
expectTypeOf(super.onSubscribe).parameters.toEqualTypeOf<[]>()
expectTypeOf(super.onSubscribe).returns.toEqualTypeOf<void>()
}
}
expectTypeOf(
new CustomSubscribable(),
).toEqualTypeOf<CustomSubscribable>()
})
})
describe('onUnsubscribe', () => {
it('should not be accessible from outside the class', () => {
const subscribable = new Subscribable<() => void>()
// @ts-expect-error onUnsubscribe is protected
subscribable.onUnsubscribe()
expectTypeOf(subscribable).not.toHaveProperty('onUnsubscribe')
})
it('should take no argument and return void inside the class', () => {
class CustomSubscribable extends Subscribable<() => void> {
protected override onUnsubscribe(): void {
expectTypeOf(super.onUnsubscribe).parameters.toEqualTypeOf<[]>()
expectTypeOf(super.onUnsubscribe).returns.toEqualTypeOf<void>()
}
}
expectTypeOf(
new CustomSubscribable(),
).toEqualTypeOf<CustomSubscribable>()
})
})
})
})

View File

@@ -0,0 +1,109 @@
import { describe, expect, it, vi } from 'vitest'
import { Subscribable } from '../subscribable'
type Listener = () => void
class SubscribableTest extends Subscribable<Listener> {
onSubscribeSpy = vi.fn()
onUnsubscribeSpy = vi.fn()
protected onSubscribe(): void {
this.onSubscribeSpy()
}
protected onUnsubscribe(): void {
this.onUnsubscribeSpy()
}
}
describe('Subscribable', () => {
it('should call onSubscribe when a listener subscribes', () => {
const subscribable = new SubscribableTest()
subscribable.subscribe(() => undefined)
expect(subscribable.onSubscribeSpy).toHaveBeenCalledTimes(1)
expect(subscribable.onUnsubscribeSpy).toHaveBeenCalledTimes(0)
})
it('should call onSubscribe once per subscribe call', () => {
const subscribable = new SubscribableTest()
subscribable.subscribe(() => undefined)
subscribable.subscribe(() => undefined)
expect(subscribable.onSubscribeSpy).toHaveBeenCalledTimes(2)
})
it('should call onUnsubscribe when a listener unsubscribes', () => {
const subscribable = new SubscribableTest()
const unsubscribe = subscribable.subscribe(() => undefined)
unsubscribe()
expect(subscribable.onUnsubscribeSpy).toHaveBeenCalledTimes(1)
})
it('should return `false` from hasListeners when there are no listeners', () => {
const subscribable = new SubscribableTest()
expect(subscribable.hasListeners()).toBe(false)
})
it('should return `true` from hasListeners while a listener is subscribed', () => {
const subscribable = new SubscribableTest()
subscribable.subscribe(() => undefined)
expect(subscribable.hasListeners()).toBe(true)
})
it('should return `false` from hasListeners after the last listener unsubscribes', () => {
const subscribable = new SubscribableTest()
const unsubscribe = subscribable.subscribe(() => undefined)
unsubscribe()
expect(subscribable.hasListeners()).toBe(false)
})
it('should still have listeners while at least one remains subscribed', () => {
const subscribable = new SubscribableTest()
const unsubscribe1 = subscribable.subscribe(() => undefined)
subscribable.subscribe(() => undefined)
unsubscribe1()
expect(subscribable.hasListeners()).toBe(true)
})
it('should deduplicate the same listener reference', () => {
const subscribable = new SubscribableTest()
const listener = () => undefined
subscribable.subscribe(listener)
subscribable.subscribe(listener)
expect(subscribable.hasListeners()).toBe(true)
const unsubscribe = subscribable.subscribe(listener)
unsubscribe()
expect(subscribable.hasListeners()).toBe(false)
})
it('should keep a stable subscribe reference when destructured', () => {
const subscribable = new SubscribableTest()
const { subscribe } = subscribable
const unsubscribe = subscribe(() => undefined)
expect(subscribable.onSubscribeSpy).toHaveBeenCalledTimes(1)
expect(subscribable.hasListeners()).toBe(true)
unsubscribe()
expect(subscribable.hasListeners()).toBe(false)
})
})

View File

@@ -0,0 +1,285 @@
import { describe, expectTypeOf, it } from 'vitest'
import { timeoutManager } from '..'
import { defaultTimeoutProvider, systemSetTimeoutZero } from '../timeoutManager'
import type {
ManagedTimerId,
TimeoutCallback,
TimeoutManager,
TimeoutProvider,
} from '..'
describe('timeoutManager', () => {
describe('TimeoutCallback', () => {
it('should take a single void argument and return void', () => {
expectTypeOf<TimeoutCallback>().toEqualTypeOf<(_: void) => void>()
})
it('should be callable without an argument', () => {
// A single `void` parameter is what lets `setTimeout(resolve, n)` and a
// zero-argument callback both type check.
const callback: TimeoutCallback = () => {}
expectTypeOf(callback).toEqualTypeOf<TimeoutCallback>()
// A lone `void` parameter is optional at the call site, so the tuple is
// `[] | [void]` and the callback may be called with no argument at all.
expectTypeOf<Parameters<TimeoutCallback>['length']>().toEqualTypeOf<
0 | 1
>()
})
it('should reject a callback that requires a value argument', () => {
// @ts-expect-error a timeout callback cannot require a value argument
const invalid: TimeoutCallback = (_: number) => {}
expectTypeOf(invalid).toBeFunction()
expectTypeOf<TimeoutCallback>().parameter(0).toBeVoid()
expectTypeOf<ReturnType<TimeoutCallback>>().toEqualTypeOf<void>()
})
})
describe('ManagedTimerId', () => {
it('should be a number or a primitive-coercible object', () => {
expectTypeOf<ManagedTimerId>().toEqualTypeOf<
number | { [Symbol.toPrimitive]: () => number }
>()
})
it('should accept a number and a primitive-coercible object', () => {
expectTypeOf<Extract<ManagedTimerId, number>>().toEqualTypeOf<number>()
expectTypeOf<Exclude<ManagedTimerId, number>>().toEqualTypeOf<{
[Symbol.toPrimitive]: () => number
}>()
})
it('should reject a value that cannot be coerced to a number', () => {
// @ts-expect-error a timer id cannot be a string
const invalid: ManagedTimerId = 'timer'
expectTypeOf(invalid).toEqualTypeOf<
number | { [Symbol.toPrimitive]: () => number }
>()
expectTypeOf<Extract<ManagedTimerId, string>>().toEqualTypeOf<never>()
})
})
describe('TimeoutProvider', () => {
describe('setTimeout', () => {
it('should take a callback and a delay and return the timer id', () => {
expectTypeOf<TimeoutProvider['setTimeout']>().toEqualTypeOf<
(callback: TimeoutCallback, delay: number) => ManagedTimerId
>()
})
})
describe('clearTimeout', () => {
it('should take the timer id or undefined and return void', () => {
expectTypeOf<TimeoutProvider['clearTimeout']>().toEqualTypeOf<
(timeoutId: ManagedTimerId | undefined) => void
>()
})
})
describe('setInterval', () => {
it('should take a callback and a delay and return the timer id', () => {
expectTypeOf<TimeoutProvider['setInterval']>().toEqualTypeOf<
(callback: TimeoutCallback, delay: number) => ManagedTimerId
>()
})
})
describe('clearInterval', () => {
it('should take the timer id or undefined and return void', () => {
expectTypeOf<TimeoutProvider['clearInterval']>().toEqualTypeOf<
(intervalId: ManagedTimerId | undefined) => void
>()
})
})
it('should have every member readonly', () => {
// `Pick` preserves the `readonly` modifier that an indexed access strips.
expectTypeOf<TimeoutProvider>().toEqualTypeOf<{
readonly setTimeout: (
callback: TimeoutCallback,
delay: number,
) => ManagedTimerId
readonly clearTimeout: (timeoutId: ManagedTimerId | undefined) => void
readonly setInterval: (
callback: TimeoutCallback,
delay: number,
) => ManagedTimerId
readonly clearInterval: (intervalId: ManagedTimerId | undefined) => void
}>()
})
it('should default its timer id to a managed timer id', () => {
expectTypeOf<TimeoutProvider>().toEqualTypeOf<
TimeoutProvider<ManagedTimerId>
>()
})
it('should carry a narrowed timer id through its members', () => {
expectTypeOf<TimeoutProvider<number>['setTimeout']>().toEqualTypeOf<
(callback: TimeoutCallback, delay: number) => number
>()
expectTypeOf<TimeoutProvider<number>['clearTimeout']>().toEqualTypeOf<
(timeoutId: number | undefined) => void
>()
})
it('should constrain its timer id to a managed timer id', () => {
// @ts-expect-error a string is not a managed timer id
expectTypeOf<TimeoutProvider<string>>().toBeObject()
expectTypeOf<TimeoutProvider<number>>().toBeObject()
})
it('should only expose its documented members', () => {
expectTypeOf<keyof TimeoutProvider>().toEqualTypeOf<
'setTimeout' | 'clearTimeout' | 'setInterval' | 'clearInterval'
>()
})
})
describe('defaultTimeoutProvider', () => {
it('should be a timeout provider of the default timer id', () => {
expectTypeOf(defaultTimeoutProvider).toEqualTypeOf<TimeoutProvider>()
})
it('should not narrow its timer id to the platform one', () => {
// The provider is declared as `TimeoutProvider`, not
// `TimeoutProvider<ReturnType<typeof setTimeout>>`, so swapping it for a
// custom provider stays type safe across environments.
expectTypeOf(
defaultTimeoutProvider.setTimeout,
).returns.toEqualTypeOf<ManagedTimerId>()
expectTypeOf(
defaultTimeoutProvider.setInterval,
).returns.toEqualTypeOf<ManagedTimerId>()
})
it('should be accepted by setTimeoutProvider', () => {
expectTypeOf(
timeoutManager.setTimeoutProvider(defaultTimeoutProvider),
).toEqualTypeOf<void>()
})
})
describe('TimeoutManager', () => {
describe('setTimeoutProvider', () => {
it('should take a provider and return void', () => {
expectTypeOf(timeoutManager.setTimeoutProvider).toEqualTypeOf<
<TTimerId extends ManagedTimerId>(
provider: TimeoutProvider<TTimerId>,
) => void
>()
})
it('should reject a provider that is not a timeout provider', () => {
// @ts-expect-error a plain object is not a timeout provider
timeoutManager.setTimeoutProvider({})
expectTypeOf(
timeoutManager.setTimeoutProvider({
setTimeout: (_callback, _delay) => 1,
clearTimeout: (_timeoutId) => {},
setInterval: (_callback, _delay) => 1,
clearInterval: (_intervalId) => {},
}),
).toEqualTypeOf<void>()
})
it('should infer the timer id from the provider', () => {
timeoutManager.setTimeoutProvider({
setTimeout: (callback, delay) => {
expectTypeOf(callback).toEqualTypeOf<TimeoutCallback>()
expectTypeOf(delay).toEqualTypeOf<number>()
return 1
},
clearTimeout: (timeoutId) => {
// The timer id is inferred from `setTimeout`'s literal return.
expectTypeOf(timeoutId).toEqualTypeOf<1 | undefined>()
},
setInterval: (_callback, _delay) => 1,
clearInterval: (_intervalId) => {},
})
})
})
describe('setTimeout', () => {
it('should take a callback and a delay and return the timer id', () => {
expectTypeOf(timeoutManager.setTimeout).toEqualTypeOf<
(callback: TimeoutCallback, delay: number) => ManagedTimerId
>()
})
it('should require the delay', () => {
// @ts-expect-error the delay is required
timeoutManager.setTimeout(() => {})
expectTypeOf(
timeoutManager.setTimeout(() => {}, 0),
).toEqualTypeOf<ManagedTimerId>()
})
})
describe('clearTimeout', () => {
it('should take the timer id or undefined and return void', () => {
expectTypeOf(timeoutManager.clearTimeout).toEqualTypeOf<
(timeoutId: ManagedTimerId | undefined) => void
>()
})
})
describe('setInterval', () => {
it('should take a callback and a delay and return the timer id', () => {
expectTypeOf(timeoutManager.setInterval).toEqualTypeOf<
(callback: TimeoutCallback, delay: number) => ManagedTimerId
>()
})
})
describe('clearInterval', () => {
it('should take the timer id or undefined and return void', () => {
expectTypeOf(timeoutManager.clearInterval).toEqualTypeOf<
(intervalId: ManagedTimerId | undefined) => void
>()
})
})
it('should be the type of the exported singleton', () => {
expectTypeOf(timeoutManager).toEqualTypeOf<TimeoutManager>()
})
it('should only expose its documented members', () => {
expectTypeOf<keyof TimeoutManager>().toEqualTypeOf<
| 'setTimeoutProvider'
| 'setTimeout'
| 'clearTimeout'
| 'setInterval'
| 'clearInterval'
>()
})
})
describe('systemSetTimeoutZero', () => {
it('should take a callback and return void', () => {
expectTypeOf(systemSetTimeoutZero).toEqualTypeOf<
(callback: TimeoutCallback) => void
>()
})
it('should require the callback', () => {
// @ts-expect-error the callback is required
systemSetTimeoutZero()
expectTypeOf(systemSetTimeoutZero(() => {})).toEqualTypeOf<void>()
})
it('should reject a callback that requires a value argument', () => {
// @ts-expect-error a timeout callback cannot require a value argument
systemSetTimeoutZero((_: number) => {})
expectTypeOf(systemSetTimeoutZero(() => {})).toEqualTypeOf<void>()
})
})
})

View File

@@ -0,0 +1,136 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
TimeoutManager,
defaultTimeoutProvider,
systemSetTimeoutZero,
timeoutManager,
} from '../timeoutManager'
import type { MockInstance } from 'vitest'
describe('timeoutManager', () => {
function createMockProvider(name: string = 'custom') {
return {
__TEST_ONLY__name: name,
setTimeout: vi.fn(() => 123),
clearTimeout: vi.fn(),
setInterval: vi.fn(() => 456),
clearInterval: vi.fn(),
}
}
let consoleErrorSpy: MockInstance<typeof console.error>
beforeEach(() => {
consoleErrorSpy = vi.spyOn(console, 'error')
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('TimeoutManager', () => {
let manager: TimeoutManager
beforeEach(() => {
manager = new TimeoutManager()
})
it('by default proxies calls to globalThis setTimeout/clearTimeout', () => {
const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout')
const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout')
const setIntervalSpy = vi.spyOn(globalThis, 'setInterval')
const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval')
const callback = vi.fn()
const timeoutId = manager.setTimeout(callback, 100)
expect(setTimeoutSpy).toHaveBeenCalledWith(callback, 100)
clearTimeout(Number(timeoutId))
manager.clearTimeout(200)
expect(clearTimeoutSpy).toHaveBeenCalledWith(200)
const intervalId = manager.setInterval(callback, 300)
expect(setIntervalSpy).toHaveBeenCalledWith(callback, 300)
clearInterval(Number(intervalId))
manager.clearInterval(400)
expect(clearIntervalSpy).toHaveBeenCalledWith(400)
})
describe('setTimeoutProvider', () => {
it('proxies calls to the configured timeout provider', () => {
const customProvider = createMockProvider()
manager.setTimeoutProvider(customProvider)
const callback = vi.fn()
manager.setTimeout(callback, 100)
expect(customProvider.setTimeout).toHaveBeenCalledWith(callback, 100)
manager.clearTimeout(999)
expect(customProvider.clearTimeout).toHaveBeenCalledWith(999)
manager.setInterval(callback, 200)
expect(customProvider.setInterval).toHaveBeenCalledWith(callback, 200)
manager.clearInterval(888)
expect(customProvider.clearInterval).toHaveBeenCalledWith(888)
})
it('warns when switching providers after making call', () => {
// 1. switching before making any calls does not warn
const customProvider = createMockProvider()
manager.setTimeoutProvider(customProvider)
expect(consoleErrorSpy).not.toHaveBeenCalled()
// Make a call. The next switch should warn
manager.setTimeout(vi.fn(), 100)
// 2. switching after making a call should warn
const customProvider2 = createMockProvider('custom2')
manager.setTimeoutProvider(customProvider2)
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringMatching(
/\[timeoutManager\]: Switching .* might result in unexpected behavior\..*/,
),
{ previous: customProvider, provider: customProvider2 },
)
// 3. Switching again with no intermediate calls should not warn
vi.mocked(consoleErrorSpy).mockClear()
const customProvider3 = createMockProvider('custom3')
manager.setTimeoutProvider(customProvider3)
expect(consoleErrorSpy).not.toHaveBeenCalled()
})
})
})
describe('globalThis timeoutManager instance', () => {
it('should be an instance of TimeoutManager', () => {
expect(timeoutManager).toBeInstanceOf(TimeoutManager)
})
})
describe('exported functions', () => {
let provider: ReturnType<typeof createMockProvider>
beforeEach(() => {
provider = createMockProvider()
timeoutManager.setTimeoutProvider(provider)
})
afterEach(() => {
timeoutManager.setTimeoutProvider(defaultTimeoutProvider)
})
describe('systemSetTimeoutZero', () => {
it('should use globalThis setTimeout with 0 delay', () => {
const spy = vi.spyOn(globalThis, 'setTimeout')
const callback = vi.fn()
systemSetTimeoutZero(callback)
expect(spy).toHaveBeenCalledWith(callback, 0)
clearTimeout(spy.mock.results[0]?.value)
})
})
})
})

View File

@@ -0,0 +1,893 @@
import { assertType, describe, expectTypeOf, it } from 'vitest'
import { QueryClient } from '../queryClient'
import {
addConsumeAwareSignal,
addToEnd,
addToStart,
ensureQueryFn,
functionalUpdate,
hashKey,
hashQueryKeyByOptions,
isPlainArray,
isPlainObject,
isServer,
isValidTimeout,
keepPreviousData,
matchMutation,
matchQuery,
noop,
partialMatchKey,
replaceData,
replaceEqualDeep,
resolveQueryValue,
shallowEqualObjects,
shouldThrowError,
skipToken,
sleep,
timeUntilStale,
} from '../utils'
import type {
MutationFilters,
QueryFilters,
QueryTypeFilter,
SkipToken,
Updater,
} from '../utils'
import type { Mutation } from '../mutation'
import type { Query } from '../query'
import type {
DataTag,
MutationStatus,
QueryFunction,
QueryKey,
QueryOptions,
} from '../types'
class CustomError extends Error {
name = 'CustomError' as const
}
describe('QueryFilters', () => {
it('should be typed unknown even if tagged generics are passed', () => {
type TData = { a: number; b: string }
type TError = Error & { message: string }
const filters: QueryFilters<DataTag<QueryKey, TData, TError>> = {
predicate(query) {
expectTypeOf(query.setData({ a: 1, b: '1' })).toEqualTypeOf<unknown>()
return true
},
queryKey: ['key'] as DataTag<undefined, TData, TError>,
}
const queryClient = new QueryClient()
const data = queryClient.getQueryData(filters.queryKey!)
expectTypeOf(data).toEqualTypeOf<TData | undefined>()
const error = queryClient.getQueryState(filters.queryKey!)?.error
expectTypeOf(error).toEqualTypeOf<TError | null | undefined>()
})
it('should be loose typed if generics are defaults', () => {
const a: QueryFilters = {
predicate(query) {
expectTypeOf(query.setData({ a: 1, b: '1' })).toEqualTypeOf<unknown>()
return true
},
queryKey: ['key'],
}
const queryClient = new QueryClient()
const data = queryClient.getQueryData(a.queryKey!)
expectTypeOf(data).toEqualTypeOf<unknown>()
const error = queryClient.getQueryState(a.queryKey!)?.error
expectTypeOf(error).toEqualTypeOf<Error | null | undefined>()
})
it('should allow a partial query key to be passed', () => {
const filters: QueryFilters<readonly ['key', { a: number; b: string }]> = {
queryKey: ['key'],
}
expectTypeOf(filters.queryKey).toEqualTypeOf<
| undefined
| readonly []
| readonly ['key']
| readonly [
'key',
{
a: number
b: string
},
]
>()
})
it('should work with readonly union types', () => {
const filters: QueryFilters<
readonly ['key'] | readonly ['key', 'something']
> = {
queryKey: ['key'],
}
expectTypeOf(filters.queryKey).toEqualTypeOf<
undefined | readonly [] | readonly ['key'] | readonly ['key', 'something']
>()
})
// we test that there are not type errors here
// eslint-disable-next-line vitest/expect-expect
it('should work with unions of different lengths', () => {
type Key =
| readonly ['foo']
| readonly ['foo', 'bar']
| readonly ['foo', 'bar', 'baz']
const queryKey: Key = ['foo', 'bar'] as any as Key
new QueryClient().invalidateQueries({ queryKey })
})
it('should error on invalid query keys', () => {
assertType<QueryFilters<readonly ['key', { a: number; b: string }]>>({
// @ts-expect-error cannot pass invalid query key
queryKey: ['invalid', { a: 1, b: '1' }],
})
})
it('should type every filter as optional', () => {
expectTypeOf<QueryFilters>().toEqualTypeOf<{
type?: QueryTypeFilter
exact?: boolean
predicate?: (query: Query) => boolean
queryKey?: QueryKey | ReadonlyArray<unknown>
stale?: boolean
fetchStatus?: 'fetching' | 'paused' | 'idle'
}>()
})
it('should keep every filter writable', () => {
// stripping `readonly` from every key must be a no-op
expectTypeOf<{
-readonly [Key in keyof QueryFilters]: QueryFilters[Key]
}>().toEqualTypeOf<QueryFilters>()
const filters: QueryFilters = {}
filters.type = 'active'
filters.exact = true
filters.predicate = () => true
filters.queryKey = ['key']
filters.stale = true
filters.fetchStatus = 'idle'
})
it('should default TQueryKey to QueryKey', () => {
expectTypeOf<QueryFilters>().toEqualTypeOf<QueryFilters<QueryKey>>()
expectTypeOf<QueryFilters['queryKey']>().toEqualTypeOf<
ReadonlyArray<unknown> | undefined
>()
})
it('should constrain TQueryKey to QueryKey', () => {
expectTypeOf<QueryFilters<readonly ['key']>['queryKey']>().toEqualTypeOf<
undefined | readonly [] | readonly ['key']
>()
assertType<
// @ts-expect-error a non-array type does not satisfy the QueryKey constraint
QueryFilters<{ key: string }>
>({})
})
it('should type predicate with an untyped Query regardless of TQueryKey', () => {
expectTypeOf<NonNullable<QueryFilters<readonly ['key']>['predicate']>>()
.parameter(0)
.toEqualTypeOf<Query>()
expectTypeOf<NonNullable<QueryFilters['predicate']>>()
.parameter(0)
.toEqualTypeOf<Query<unknown, Error, unknown, ReadonlyArray<unknown>>>()
})
it('should type fetchStatus as FetchStatus', () => {
expectTypeOf<QueryFilters['fetchStatus']>().toEqualTypeOf<
'fetching' | 'paused' | 'idle' | undefined
>()
assertType<QueryFilters>({
// @ts-expect-error 'success' is not a FetchStatus
fetchStatus: 'success',
})
})
describe('type', () => {
it('should be typed as QueryTypeFilter', () => {
expectTypeOf<Pick<QueryFilters, 'type'>>().toEqualTypeOf<{
type?: 'all' | 'active' | 'inactive'
}>()
assertType<QueryFilters>({
// @ts-expect-error 'stale' is not a QueryTypeFilter
type: 'stale',
})
})
})
describe('exact', () => {
it('should be typed as an optional boolean', () => {
expectTypeOf<Pick<QueryFilters, 'exact'>>().toEqualTypeOf<{
exact?: boolean
}>()
assertType<QueryFilters>({
// @ts-expect-error exact must be a boolean
exact: 'yes',
})
})
})
describe('stale', () => {
it('should be typed as an optional boolean', () => {
expectTypeOf<Pick<QueryFilters, 'stale'>>().toEqualTypeOf<{
stale?: boolean
}>()
assertType<QueryFilters>({
// @ts-expect-error stale must be a boolean
stale: 'yes',
})
})
})
})
describe('MutationFilters', () => {
it('should type every filter as optional', () => {
expectTypeOf<MutationFilters>().toEqualTypeOf<{
exact?: boolean
predicate?: (mutation: Mutation) => boolean
mutationKey?: ReadonlyArray<unknown>
status?: MutationStatus
}>()
})
it('should keep every filter writable', () => {
// stripping `readonly` from every key must be a no-op
expectTypeOf<{
-readonly [Key in keyof MutationFilters]: MutationFilters[Key]
}>().toEqualTypeOf<MutationFilters>()
const filters: MutationFilters = {}
filters.exact = true
filters.predicate = () => true
filters.mutationKey = ['key']
filters.status = 'success'
})
it('should default TData to unknown and TError to DefaultError', () => {
expectTypeOf<NonNullable<MutationFilters['predicate']>>()
.parameter(0)
.toEqualTypeOf<Mutation<unknown, Error, unknown, unknown>>()
})
it('should default TVariables and TOnMutateResult to unknown', () => {
expectTypeOf<
NonNullable<MutationFilters<number, CustomError>['predicate']>
>()
.parameter(0)
.toEqualTypeOf<Mutation<number, CustomError, unknown, unknown>>()
})
it('should forward TVariables into the predicate mutation', () => {
const filters: MutationFilters<number, CustomError, string> = {
predicate(mutation) {
expectTypeOf(mutation).toEqualTypeOf<
Mutation<number, CustomError, string, unknown>
>()
expectTypeOf(mutation.state.variables).toEqualTypeOf<
string | undefined
>()
return true
},
}
expectTypeOf(filters.predicate).toEqualTypeOf<
| ((mutation: Mutation<number, CustomError, string, unknown>) => boolean)
| undefined
>()
})
it('should forward TOnMutateResult into the predicate mutation', () => {
const filters: MutationFilters<
number,
CustomError,
string,
{ rollback: () => void }
> = {
predicate(mutation) {
expectTypeOf(mutation).toEqualTypeOf<
Mutation<number, CustomError, string, { rollback: () => void }>
>()
expectTypeOf(mutation.state.context).toEqualTypeOf<
{ rollback: () => void } | undefined
>()
return true
},
}
expectTypeOf(filters.predicate).toEqualTypeOf<
| ((
mutation: Mutation<
number,
CustomError,
string,
{ rollback: () => void }
>,
) => boolean)
| undefined
>()
})
it('should forward TData and TError into the predicate mutation state', () => {
const filters: MutationFilters<number, CustomError> = {
predicate(mutation) {
expectTypeOf(mutation.state.data).toEqualTypeOf<number | undefined>()
expectTypeOf(mutation.state.error).toEqualTypeOf<CustomError | null>()
return true
},
}
expectTypeOf(filters.predicate).toEqualTypeOf<
| ((mutation: Mutation<number, CustomError, unknown, unknown>) => boolean)
| undefined
>()
})
it('should not parameterize mutationKey by any generic, unlike QueryFilters', () => {
// `QueryFilters.queryKey` narrows with TQueryKey, but `MutationFilters` has
// no key generic at all: `mutationKey` is always the prefixes of `MutationKey`
expectTypeOf<
MutationFilters<number, CustomError, string, void>['mutationKey']
>().toEqualTypeOf<ReadonlyArray<unknown> | undefined>()
expectTypeOf<MutationFilters['mutationKey']>().toEqualTypeOf<
MutationFilters<number>['mutationKey']
>()
// any MutationKey is therefore accepted, whatever the generics are
assertType<MutationFilters<number, CustomError, string, void>>({
mutationKey: ['anything', { at: 'all' }, 1],
})
})
it('should type status as MutationStatus', () => {
expectTypeOf<MutationFilters['status']>().toEqualTypeOf<
'idle' | 'pending' | 'success' | 'error' | undefined
>()
assertType<MutationFilters>({
// @ts-expect-error 'fetching' is not a MutationStatus
status: 'fetching',
})
})
})
describe('QueryTypeFilter', () => {
it('should be a union of the three query types', () => {
expectTypeOf<QueryTypeFilter>().toEqualTypeOf<
'all' | 'active' | 'inactive'
>()
})
})
describe('Updater', () => {
it('should be a union of the value and the updater function', () => {
expectTypeOf<Updater<number, string>>().toEqualTypeOf<
string | ((input: number) => string)
>()
})
it('should accept both branches for the same instantiation', () => {
const value: Updater<number, string> = 'next'
const fn: Updater<number, string> = (input) => {
// the annotation alone contextually types the parameter as TInput
expectTypeOf(input).toEqualTypeOf<number>()
return String(input)
}
// the annotation narrows each variable down to the branch it was given
expectTypeOf(value).toEqualTypeOf<string>()
expectTypeOf(fn).toEqualTypeOf<(input: number) => string>()
})
it('should keep the value branch assignable but not callable', () => {
expectTypeOf<
Exclude<Updater<number, string>, Function>
>().toEqualTypeOf<string>()
// @ts-expect-error the value branch is not a function
const _fn: Extract<Updater<number, string>, string> = (input: number) =>
String(input)
})
it('should type the function branch input as TInput and output as TOutput', () => {
type UpdaterFn = Extract<Updater<number, string>, Function>
expectTypeOf<UpdaterFn>().parameter(0).toEqualTypeOf<number>()
expectTypeOf<UpdaterFn>().returns.toEqualTypeOf<string>()
})
})
describe('functionalUpdate', () => {
it('should type the updater parameter as Updater<TInput, TOutput>', () => {
expectTypeOf(functionalUpdate<number, string>)
.parameter(0)
.toEqualTypeOf<string | ((input: number) => string)>()
})
it('should type the input parameter as TInput', () => {
expectTypeOf(functionalUpdate<number, string>)
.parameter(1)
.toEqualTypeOf<number>()
})
it('should return TOutput', () => {
expectTypeOf(
functionalUpdate<number, string>,
).returns.toEqualTypeOf<string>()
const result = functionalUpdate<number, string>((input) => String(input), 1)
expectTypeOf(result).toEqualTypeOf<string>()
})
it('should infer both generics from the value branch', () => {
const result = functionalUpdate('next', 1)
expectTypeOf(result).toEqualTypeOf<string>()
})
})
describe('noop', () => {
it('should resolve to the void overload when called', () => {
expectTypeOf(noop()).toEqualTypeOf<void>()
})
it('should be usable as a VoidFunction', () => {
const fn: VoidFunction = noop
expectTypeOf(fn).toEqualTypeOf<VoidFunction>()
})
})
describe('isValidTimeout', () => {
it('should narrow unknown to number', () => {
const value: unknown = 1
if (isValidTimeout(value)) {
expectTypeOf(value).toEqualTypeOf<number>()
} else {
expectTypeOf(value).toEqualTypeOf<unknown>()
}
})
it('should accept unknown as its parameter', () => {
expectTypeOf(isValidTimeout).parameter(0).toEqualTypeOf<unknown>()
expectTypeOf(isValidTimeout).returns.toEqualTypeOf<boolean>()
})
})
describe('isPlainArray', () => {
it('should narrow unknown to Array<unknown>', () => {
const value: unknown = []
if (isPlainArray(value)) {
expectTypeOf(value).toEqualTypeOf<Array<unknown>>()
} else {
expectTypeOf(value).toEqualTypeOf<unknown>()
}
})
it('should accept unknown as its parameter', () => {
expectTypeOf(isPlainArray).parameter(0).toEqualTypeOf<unknown>()
})
})
describe('isPlainObject', () => {
it('should narrow unknown to Record<PropertyKey, unknown>', () => {
const value: unknown = {}
if (isPlainObject(value)) {
expectTypeOf(value).toEqualTypeOf<Record<PropertyKey, unknown>>()
} else {
expectTypeOf(value).toEqualTypeOf<unknown>()
}
})
it('should accept any as its parameter, unlike the other guards', () => {
expectTypeOf(isPlainObject).parameter(0).toBeAny()
})
})
describe('partialMatchKey', () => {
it('should only expose the QueryKey overload, not the any implementation', () => {
expectTypeOf(partialMatchKey)
.parameter(0)
.toEqualTypeOf<ReadonlyArray<unknown>>()
expectTypeOf(partialMatchKey)
.parameter(1)
.toEqualTypeOf<ReadonlyArray<unknown>>()
expectTypeOf(partialMatchKey).returns.toEqualTypeOf<boolean>()
expectTypeOf(partialMatchKey(['a'], ['a'])).toEqualTypeOf<boolean>()
// @ts-expect-error a non-QueryKey argument is rejected
partialMatchKey(1, 2)
})
})
describe('hashKey', () => {
it('should only accept an array key', () => {
// `QueryKey | MutationKey` collapses to a single type here: with an empty
// `Register`, both resolve to `ReadonlyArray<unknown>`, so the declared
// union is structurally indistinguishable from either member alone.
expectTypeOf(hashKey).parameter(0).toEqualTypeOf<ReadonlyArray<unknown>>()
// @ts-expect-error a non-array key is rejected
hashKey('a')
})
it('should return a string', () => {
expectTypeOf(hashKey).returns.toEqualTypeOf<string>()
})
})
describe('hashQueryKeyByOptions', () => {
it('should accept the options argument optionally', () => {
expectTypeOf(hashQueryKeyByOptions(['a'])).toEqualTypeOf<string>()
expectTypeOf(
hashQueryKeyByOptions(['a'], { queryKeyHashFn: hashKey }),
).toEqualTypeOf<string>()
})
})
describe('resolveQueryValue', () => {
it('should default TData to TQueryFnData', () => {
const query = {} as Query<number, Error, number, readonly ['key']>
resolveQueryValue<string, number>((q) => {
expectTypeOf(q).toEqualTypeOf<
Query<number, Error, number, ReadonlyArray<unknown>>
>()
return 'value'
}, query as never)
})
it('should widen the return type with undefined', () => {
const query = {} as Query<number, Error, number, readonly ['key']>
const value = resolveQueryValue<
string,
number,
Error,
number,
readonly ['key']
>('value', query)
expectTypeOf(value).toEqualTypeOf<string | undefined>()
})
it('should type the query parameter with every generic', () => {
expectTypeOf(
resolveQueryValue<string, number, CustomError, boolean, readonly ['key']>,
)
.parameter(1)
.toEqualTypeOf<Query<number, CustomError, boolean, readonly ['key']>>()
})
it('should accept both a plain value and a resolver function', () => {
const query = {} as Query<number, Error, number, readonly ['key']>
const fromFn = resolveQueryValue<
string,
number,
Error,
number,
readonly ['key']
>((q) => {
expectTypeOf(q).toEqualTypeOf<
Query<number, Error, number, readonly ['key']>
>()
return 'value'
}, query)
expectTypeOf(fromFn).toEqualTypeOf<string | undefined>()
const fromUndefined = resolveQueryValue<
string,
number,
Error,
number,
readonly ['key']
>(undefined, query)
expectTypeOf(fromUndefined).toEqualTypeOf<string | undefined>()
})
})
describe('matchQuery', () => {
it('should accept a QueryFilters and any Query', () => {
expectTypeOf(matchQuery).parameter(0).toEqualTypeOf<QueryFilters>()
expectTypeOf(matchQuery).returns.toEqualTypeOf<boolean>()
expectTypeOf(
matchQuery({ queryKey: ['a'] }, {} as Query<number, Error>),
).toEqualTypeOf<boolean>()
})
})
describe('matchMutation', () => {
it('should accept a MutationFilters and any Mutation', () => {
expectTypeOf(matchMutation).parameter(0).toEqualTypeOf<MutationFilters>()
expectTypeOf(matchMutation).returns.toEqualTypeOf<boolean>()
expectTypeOf(
matchMutation({ mutationKey: ['a'] }, {} as Mutation<number, Error>),
).toEqualTypeOf<boolean>()
})
})
describe('replaceEqualDeep', () => {
it('should return the type of b and not relate it to a', () => {
const result = replaceEqualDeep({ a: 1 } as unknown, { b: '1' })
expectTypeOf(result).toEqualTypeOf<{ b: string }>()
expectTypeOf(replaceEqualDeep<number>)
.parameter(0)
.toEqualTypeOf<unknown>()
expectTypeOf(replaceEqualDeep<number>)
.parameter(1)
.toEqualTypeOf<number>()
})
it('should accept the depth argument optionally', () => {
expectTypeOf(replaceEqualDeep(1 as unknown, 2)).toEqualTypeOf<number>()
expectTypeOf(replaceEqualDeep(1 as unknown, 2, 0)).toEqualTypeOf<number>()
})
})
describe('shallowEqualObjects', () => {
it('should allow b to be undefined but not a', () => {
expectTypeOf(shallowEqualObjects<{ a: number }>)
.parameter(0)
.toEqualTypeOf<{ a: number }>()
expectTypeOf(shallowEqualObjects<{ a: number }>)
.parameter(1)
.toEqualTypeOf<{ a: number } | undefined>()
expectTypeOf(
shallowEqualObjects({ a: 1 }, undefined),
).toEqualTypeOf<boolean>()
// @ts-expect-error a cannot be undefined
shallowEqualObjects(undefined, { a: 1 })
})
it('should constrain T to a record', () => {
expectTypeOf(
shallowEqualObjects({ a: 1 }, { a: 1 }),
).toEqualTypeOf<boolean>()
// @ts-expect-error a primitive does not satisfy Record<string, any>
shallowEqualObjects(1, 1)
})
})
describe('keepPreviousData', () => {
it('should return the previous data widened with undefined', () => {
expectTypeOf(keepPreviousData<string>('x')).toEqualTypeOf<
string | undefined
>()
expectTypeOf(keepPreviousData<string>)
.parameter(0)
.toEqualTypeOf<string | undefined>()
})
})
describe('addToEnd', () => {
it('should return a mutable Array<T>', () => {
const result = addToEnd([1, 2], 3)
expectTypeOf(result).toEqualTypeOf<Array<number>>()
// the result is mutable, so push is available
result.push(4)
})
it('should accept the max argument optionally', () => {
expectTypeOf(addToEnd([1], 2)).toEqualTypeOf<Array<number>>()
expectTypeOf(addToEnd([1], 2, 5)).toEqualTypeOf<Array<number>>()
})
it('should reject a readonly array of items', () => {
const items: ReadonlyArray<number> = [1, 2]
expectTypeOf(addToEnd([...items], 3)).toEqualTypeOf<Array<number>>()
// @ts-expect-error a readonly array is not assignable to Array<T>
addToEnd(items, 3)
})
})
describe('addToStart', () => {
it('should return a mutable Array<T>', () => {
const result = addToStart([1, 2], 3)
expectTypeOf(result).toEqualTypeOf<Array<number>>()
result.push(4)
})
it('should reject a readonly array of items', () => {
const items: ReadonlyArray<number> = [1, 2]
expectTypeOf(addToStart([...items], 3)).toEqualTypeOf<Array<number>>()
// @ts-expect-error a readonly array is not assignable to Array<T>
addToStart(items, 3)
})
})
describe('skipToken', () => {
it('should be a unique symbol, not the widened symbol type', () => {
expectTypeOf(skipToken).toEqualTypeOf<SkipToken>()
expectTypeOf(skipToken).not.toEqualTypeOf<symbol>()
// the sentinel narrows to itself, so a plain symbol is not assignable to it
const plainSymbol: symbol = skipToken
expectTypeOf(plainSymbol).toEqualTypeOf<symbol>()
// @ts-expect-error a plain symbol is not the skipToken sentinel
assertType<SkipToken>(plainSymbol)
})
})
describe('ensureQueryFn', () => {
it('should accept a SkipToken in queryFn but never return one', () => {
const queryFn = skipToken as
| QueryFunction<number, readonly ['key']>
| SkipToken
expectTypeOf<
Parameters<typeof ensureQueryFn<number, readonly ['key']>>[0]['queryFn']
>().toEqualTypeOf<
QueryFunction<number, readonly ['key']> | SkipToken | undefined
>()
const resolved = ensureQueryFn<number, readonly ['key']>({ queryFn })
expectTypeOf(resolved).toEqualTypeOf<
QueryFunction<number, readonly ['key']>
>()
})
it('should accept the fetchOptions argument optionally', () => {
expectTypeOf(ensureQueryFn<number, readonly ['key']>({})).toEqualTypeOf<
QueryFunction<number, readonly ['key']>
>()
})
it('should only read queryFn and queryHash from the options', () => {
expectTypeOf<
Parameters<typeof ensureQueryFn<number, readonly ['key']>>[0]
>().toEqualTypeOf<{
queryFn?: QueryFunction<number, readonly ['key']> | SkipToken
queryHash?: string
}>()
})
})
describe('shouldThrowError', () => {
it('should tie params to the parameters of the throwOnError function', () => {
type ThrowOnError = (error: CustomError, query: Query) => boolean
expectTypeOf(shouldThrowError<ThrowOnError>)
.parameter(1)
.toEqualTypeOf<[error: CustomError, query: Query]>()
expectTypeOf(
shouldThrowError<ThrowOnError>,
).returns.toEqualTypeOf<boolean>()
})
it('should constrain the throwOnError function to one returning a boolean', () => {
expectTypeOf(
shouldThrowError<(error: CustomError) => boolean>(
(_error) => true,
[new CustomError()],
),
).toEqualTypeOf<boolean>()
shouldThrowError<
// @ts-expect-error a throwOnError function has to return a boolean
(error: CustomError) => unknown
>((_error) => undefined, [new CustomError()])
})
it('should accept a boolean or undefined as throwOnError', () => {
type ThrowOnError = (error: CustomError) => boolean
expectTypeOf(shouldThrowError<ThrowOnError>)
.parameter(0)
.toEqualTypeOf<boolean | ThrowOnError | undefined>()
expectTypeOf(
shouldThrowError<ThrowOnError>(true, [new CustomError()]),
).toEqualTypeOf<boolean>()
})
})
describe('sleep', () => {
it('should return a Promise<void>', () => {
expectTypeOf(sleep(1)).toEqualTypeOf<Promise<void>>()
expectTypeOf(sleep).parameter(0).toEqualTypeOf<number>()
})
})
describe('addConsumeAwareSignal', () => {
it('should intersect the object with a signal property', () => {
const result = addConsumeAwareSignal(
{ queryKey: ['key'] },
() => new AbortController().signal,
noop,
)
expectTypeOf(result).toEqualTypeOf<
{ queryKey: Array<string> } & { signal: AbortSignal }
>()
expectTypeOf(result.signal).toEqualTypeOf<AbortSignal>()
expectTypeOf(result.queryKey).toEqualTypeOf<Array<string>>()
})
it('should only accept an onCancelled callback taking no arguments', () => {
expectTypeOf(addConsumeAwareSignal<{ a: number }>)
.parameter(2)
.toEqualTypeOf<VoidFunction>()
})
})
describe('isServer', () => {
it('should be typed as a boolean', () => {
expectTypeOf(isServer).toEqualTypeOf<boolean>()
})
})
describe('timeUntilStale', () => {
it('should return a number', () => {
expectTypeOf(timeUntilStale).returns.toEqualTypeOf<number>()
})
it('should accept the staleTime argument optionally', () => {
expectTypeOf(timeUntilStale).parameters.toEqualTypeOf<
[updatedAt: number, staleTime?: number]
>()
expectTypeOf(timeUntilStale(1)).toEqualTypeOf<number>()
expectTypeOf(timeUntilStale(1, 2)).toEqualTypeOf<number>()
})
})
describe('replaceData', () => {
it('should return TData rather than the options type', () => {
expectTypeOf(
replaceData<{ value: string }, QueryOptions<any, any, any, any>>,
).returns.toEqualTypeOf<{ value: string }>()
})
it('should widen only the previous data with undefined', () => {
type Params = Parameters<
typeof replaceData<{ value: string }, QueryOptions<any, any, any, any>>
>
expectTypeOf<Params[0]>().toEqualTypeOf<{ value: string } | undefined>()
expectTypeOf<Params[1]>().toEqualTypeOf<{ value: string }>()
})
it('should constrain TOptions to QueryOptions', () => {
expectTypeOf(
replaceData<{ value: string }, QueryOptions<any, any, any, any>>,
)
.parameter(2)
.toEqualTypeOf<QueryOptions<any, any, any, any>>()
// @ts-expect-error a primitive does not satisfy the QueryOptions constraint
replaceData<{ value: string }, number>({ value: 'a' }, { value: 'b' }, 1)
})
})

View File

@@ -0,0 +1,745 @@
import { describe, expect, it, vi } from 'vitest'
import { queryKey } from '@tanstack/query-test-utils'
import { QueryClient } from '..'
import {
addConsumeAwareSignal,
addToEnd,
addToStart,
ensureQueryFn,
hashKey,
hashQueryKeyByOptions,
isPlainArray,
isPlainObject,
isValidTimeout,
keepPreviousData,
matchMutation,
partialMatchKey,
replaceEqualDeep,
shallowEqualObjects,
shouldThrowError,
skipToken,
} from '../utils'
import { Mutation } from '../mutation'
import type { QueryFunctionContext } from '..'
describe('core/utils', () => {
describe('hashQueryKeyByOptions', () => {
it('should use custom hash function when provided in options', () => {
const key = ['test', { a: 1, b: 2 }]
const customHashFn = vi.fn(() => 'custom-hash')
const result = hashQueryKeyByOptions(key, {
queryKeyHashFn: customHashFn,
})
expect(customHashFn).toHaveBeenCalledWith(key)
expect(result).toEqual('custom-hash')
})
it('should use default hash function when no options provided', () => {
const key = ['test', { a: 1, b: 2 }]
const defaultResult = hashKey(key)
const result = hashQueryKeyByOptions(key)
expect(result).toEqual(defaultResult)
})
})
describe('shallowEqualObjects', () => {
it('should return `true` for shallow equal objects', () => {
expect(shallowEqualObjects({ a: 1 }, { a: 1 })).toBe(true)
})
it('should return `false` for non shallow equal objects', () => {
expect(shallowEqualObjects({ a: 1 }, { a: 2 })).toBe(false)
})
it('should return `false` if lengths are not equal', () => {
expect(shallowEqualObjects({ a: 1 }, { a: 1, b: 2 })).toBe(false)
})
it('should return false if b is undefined', () => {
expect(shallowEqualObjects({ a: 1 }, undefined)).toBe(false)
})
})
describe('isPlainObject', () => {
it('should return `true` for a plain object', () => {
expect(isPlainObject({})).toBe(true)
})
it('should return `false` for an array', () => {
expect(isPlainObject([])).toBe(false)
})
it('should return `false` for null', () => {
expect(isPlainObject(null)).toBe(false)
})
it('should return `false` for undefined', () => {
expect(isPlainObject(undefined)).toBe(false)
})
it('should return `true` for object with an undefined constructor', () => {
expect(isPlainObject(Object.create(null))).toBe(true)
})
it('should return `false` if constructor does not have an Object-specific method', () => {
class Foo {
abc: any
constructor() {
this.abc = {}
}
}
expect(isPlainObject(new Foo())).toBe(false)
})
it('should return `false` if the object has a modified prototype', () => {
function Graph(this: any) {
this.vertices = []
this.edges = []
}
Graph.prototype.addVertex = function (v: any) {
this.vertices.push(v)
}
expect(isPlainObject(Object.create(Graph))).toBe(false)
})
it('should return `false` for object with custom prototype', () => {
const CustomProto = Object.create({ a: 1 })
const obj = Object.create(CustomProto)
obj.b = 2
expect(isPlainObject(obj)).toBe(false)
})
})
describe('isPlainArray', () => {
it('should return `true` for plain arrays', () => {
expect(isPlainArray([1, 2])).toBe(true)
})
it('should return `false` for non plain arrays', () => {
expect(isPlainArray(Object.assign([1, 2], { a: 'b' }))).toBe(false)
})
})
describe('partialMatchKey', () => {
it('should return `true` if a includes b', () => {
const a = [{ a: { b: 'b' }, c: 'c', d: [{ d: 'd ' }] }]
const b = [{ a: { b: 'b' }, c: 'c', d: [] }]
expect(partialMatchKey(a, b)).toBe(true)
})
it('should return `false` if a does not include b', () => {
const a = [{ a: { b: 'b' }, c: 'c', d: [] }]
const b = [{ a: { b: 'b' }, c: 'c', d: [{ d: 'd ' }] }]
expect(partialMatchKey(a, b)).toBe(false)
})
it('should return `true` if array a includes array b', () => {
const a = [1, 2, 3]
const b = [1, 2]
expect(partialMatchKey(a, b)).toBe(true)
})
it('should return `false` if a is shorter and b has a trailing undefined', () => {
const a = [1]
const b = [1, undefined]
expect(partialMatchKey(a, b)).toBe(false)
})
it('should return `false` if a is null and b is not', () => {
const a = [null]
const b = [{ a: { b: 'b' }, c: 'c', d: [{ d: 'd ' }] }]
expect(partialMatchKey(a, b)).toBe(false)
})
it('should return `false` if a contains null and b is not', () => {
const a = [{ a: null, c: 'c', d: [] }]
const b = [{ a: { b: 'b' }, c: 'c', d: [{ d: 'd ' }] }]
expect(partialMatchKey(a, b)).toBe(false)
})
it('should return `false` if b is null and a is not', () => {
const a = [{ a: { b: 'b' }, c: 'c', d: [] }]
const b = [null]
expect(partialMatchKey(a, b)).toBe(false)
})
it('should return `false` if b contains null and a is not', () => {
const a = [{ a: { b: 'b' }, c: 'c', d: [] }]
const b = [{ a: null, c: 'c', d: [{ d: 'd ' }] }]
expect(partialMatchKey(a, b)).toBe(false)
})
it('should treat undefined object properties as matching missing properties', () => {
const queryKeyWithUndefined = ['todos', { filters: undefined }]
const queryKeyWithoutProperty = ['todos', {}]
expect(
partialMatchKey(queryKeyWithoutProperty, queryKeyWithUndefined),
).toBe(true)
expect(
partialMatchKey(queryKeyWithUndefined, queryKeyWithoutProperty),
).toBe(true)
})
})
describe('replaceEqualDeep', () => {
it('should return the previous value when the next value is an equal primitive', () => {
expect(replaceEqualDeep(1, 1)).toBe(1)
expect(replaceEqualDeep('1', '1')).toBe('1')
expect(replaceEqualDeep(true, true)).toBe(true)
expect(replaceEqualDeep(false, false)).toBe(false)
expect(replaceEqualDeep(null, null)).toBe(null)
expect(replaceEqualDeep(undefined, undefined)).toBe(undefined)
})
it('should return the next value when the previous value is a different value', () => {
const date1 = new Date()
const date2 = new Date()
expect(replaceEqualDeep(1, 0)).toBe(0)
expect(replaceEqualDeep(1, 2)).toBe(2)
expect(replaceEqualDeep('1', '2')).toBe('2')
expect(replaceEqualDeep(true, false)).toBe(false)
expect(replaceEqualDeep(false, true)).toBe(true)
expect(replaceEqualDeep(date1, date2)).toBe(date2)
})
it('should return the next value when the previous value is a different type', () => {
const array = [1]
const object = { a: 'a' }
expect(replaceEqualDeep(0, undefined)).toBe(undefined)
expect(replaceEqualDeep(undefined, 0)).toBe(0)
expect(replaceEqualDeep(2, undefined)).toBe(undefined)
expect(replaceEqualDeep(undefined, 2)).toBe(2)
expect(replaceEqualDeep(undefined, null)).toBe(null)
expect(replaceEqualDeep(null, undefined)).toBe(undefined)
expect(replaceEqualDeep({}, undefined)).toBe(undefined)
expect(replaceEqualDeep([], undefined)).toBe(undefined)
expect(replaceEqualDeep(array, object)).toBe(object)
expect(replaceEqualDeep(object, array)).toBe(array)
})
it('should return the previous value when the next value is an equal array', () => {
const prev = [1, 2]
const next = [1, 2]
expect(replaceEqualDeep(prev, next)).toBe(prev)
})
it('should return a copy when the previous value is a different array subset', () => {
const prev = [1, 2]
const next = [1, 2, 3]
const result = replaceEqualDeep(prev, next)
expect(result).toEqual(next)
expect(result).not.toBe(prev)
expect(result).not.toBe(next)
})
it('should return a copy when the previous value is a different array superset', () => {
const prev = [1, 2, 3]
const next = [1, 2]
const result = replaceEqualDeep(prev, next)
expect(result).toEqual(next)
expect(result).not.toBe(prev)
expect(result).not.toBe(next)
})
it('should return the previous value when the next value is an equal empty array', () => {
const prev: Array<any> = []
const next: Array<any> = []
expect(replaceEqualDeep(prev, next)).toBe(prev)
})
it('should return the previous value when the next value is an equal empty object', () => {
const prev = {}
const next = {}
expect(replaceEqualDeep(prev, next)).toBe(prev)
})
it('should return the previous value when the next value is an equal object', () => {
const prev = { a: 'a' }
const next = { a: 'a' }
expect(replaceEqualDeep(prev, next)).toBe(prev)
})
it('should replace different values in objects', () => {
const prev = { a: { b: 'b' }, c: 'c' }
const next = { a: { b: 'b' }, c: 'd' }
const result = replaceEqualDeep(prev, next)
expect(result).toEqual(next)
expect(result).not.toBe(prev)
expect(result).not.toBe(next)
expect(result.a).toBe(prev.a)
expect(result.c).toBe(next.c)
})
it('should replace different values in arrays', () => {
const prev = [1, { a: 'a' }, { b: { b: 'b' } }, [1]] as const
const next = [1, { a: 'a' }, { b: { b: 'c' } }, [1]] as const
const result = replaceEqualDeep(prev, next)
expect(result).toEqual(next)
expect(result).not.toBe(prev)
expect(result).not.toBe(next)
expect(result[0]).toBe(prev[0])
expect(result[1]).toBe(prev[1])
expect(result[2]).not.toBe(next[2])
expect(result[2].b.b).toBe(next[2].b.b)
expect(result[3]).toBe(prev[3])
})
it('should replace different values in arrays when the next value is a subset', () => {
const prev = [{ a: 'a' }, { b: 'b' }, { c: 'c' }]
const next = [{ a: 'a' }, { b: 'b' }]
const result = replaceEqualDeep(prev, next)
expect(result).toEqual(next)
expect(result).not.toBe(prev)
expect(result).not.toBe(next)
expect(result[0]).toBe(prev[0])
expect(result[1]).toBe(prev[1])
expect(result[2]).toBeUndefined()
})
it('should replace different values in arrays when the next value is a superset', () => {
const prev = [{ a: 'a' }, { b: 'b' }]
const next = [{ a: 'a' }, { b: 'b' }, { c: 'c' }]
const result = replaceEqualDeep(prev, next)
expect(result).toEqual(next)
expect(result).not.toBe(prev)
expect(result).not.toBe(next)
expect(result[0]).toBe(prev[0])
expect(result[1]).toBe(prev[1])
expect(result[2]).toBe(next[2])
})
it('should copy objects which are not arrays or objects', () => {
const prev = [{ a: 'a' }, { b: 'b' }, { c: 'c' }, 1]
const next = [{ a: 'a' }, new Map(), { c: 'c' }, 2]
const result = replaceEqualDeep(prev, next)
expect(result).not.toBe(prev)
expect(result).not.toBe(next)
expect(result[0]).toBe(prev[0])
expect(result[1]).toBe(next[1])
expect(result[2]).toBe(prev[2])
expect(result[3]).toBe(next[3])
})
it('should support equal objects which are not arrays or objects', () => {
const map = new Map()
const prev = [map, [1]]
const next = [map, [1]]
const result = replaceEqualDeep(prev, next)
expect(result).toBe(prev)
})
it('should support non equal objects which are not arrays or objects', () => {
const map1 = new Map()
const map2 = new Map()
const prev = [map1, [1]]
const next = [map2, [1]]
const result = replaceEqualDeep(prev, next)
expect(result).not.toBe(prev)
expect(result).not.toBe(next)
expect(result[0]).toBe(next[0])
expect(result[1]).toBe(prev[1])
})
it('should support objects which are not plain arrays', () => {
const prev = Object.assign([1, 2], { a: { b: 'b' }, c: 'c' })
const next = Object.assign([1, 2], { a: { b: 'b' }, c: 'c' })
const result = replaceEqualDeep(prev, next)
expect(result).toBe(next)
})
it('should replace all parent objects if some nested value changes', () => {
const prev = {
todo: { id: '1', meta: { createdAt: 0 }, state: { done: false } },
otherTodo: { id: '2', meta: { createdAt: 0 }, state: { done: true } },
}
const next = {
todo: { id: '1', meta: { createdAt: 0 }, state: { done: true } },
otherTodo: { id: '2', meta: { createdAt: 0 }, state: { done: true } },
}
const result = replaceEqualDeep(prev, next)
expect(result).toEqual(next)
expect(result).not.toBe(prev)
expect(result).not.toBe(next)
expect(result.todo).not.toBe(prev.todo)
expect(result.todo).not.toBe(next.todo)
expect(result.todo.id).toBe(next.todo.id)
expect(result.todo.meta).toBe(prev.todo.meta)
expect(result.todo.state).not.toBe(next.todo.state)
expect(result.todo.state.done).toBe(next.todo.state.done)
expect(result.otherTodo).toBe(prev.otherTodo)
})
it('should replace all parent arrays if some nested value changes', () => {
const prev = {
todos: [
{ id: '1', meta: { createdAt: 0 }, state: { done: false } },
{ id: '2', meta: { createdAt: 0 }, state: { done: true } },
],
}
const next = {
todos: [
{ id: '1', meta: { createdAt: 0 }, state: { done: true } },
{ id: '2', meta: { createdAt: 0 }, state: { done: true } },
],
}
const result = replaceEqualDeep(prev, next)
expect(result).toEqual(next)
expect(result).not.toBe(prev)
expect(result).not.toBe(next)
expect(result.todos).not.toBe(prev.todos)
expect(result.todos).not.toBe(next.todos)
expect(result.todos[0]).not.toBe(prev.todos[0])
expect(result.todos[0]).not.toBe(next.todos[0])
expect(result.todos[0]?.id).toBe(next.todos[0]?.id)
expect(result.todos[0]?.meta).toBe(prev.todos[0]?.meta)
expect(result.todos[0]?.state).not.toBe(next.todos[0]?.state)
expect(result.todos[0]?.state.done).toBe(next.todos[0]?.state.done)
expect(result.todos[1]).toBe(prev.todos[1])
})
it('should correctly handle objects with the same number of properties and one property being undefined', () => {
const obj1 = { a: 2, c: 123 }
const obj2 = { a: 2, b: undefined }
const result = replaceEqualDeep(obj1, obj2)
expect(result).toStrictEqual(obj2)
})
it('should be able to share values that contain undefined', () => {
const current = [
{
data: undefined,
foo: true,
},
]
const next = replaceEqualDeep(current, [
{
data: undefined,
foo: true,
},
])
expect(current).toBe(next)
})
it('should return the previous value when both values are an array of undefined', () => {
const current = [undefined]
const next = replaceEqualDeep(current, [undefined])
expect(next).toBe(current)
})
it('should return the previous value when both values are an array that contains undefined', () => {
const current = [{ foo: 1 }, undefined]
const next = replaceEqualDeep(current, [{ foo: 1 }, undefined])
expect(next).toBe(current)
})
it('should stop structural sharing once the recursion depth exceeds the limit', () => {
const nest = (depth: number, leaf: number) => {
let value: any = { leaf }
for (let i = 0; i < depth; i++) {
value = { child: value }
}
return value
}
const prev = nest(502, 1)
const next = nest(502, 2)
const result = replaceEqualDeep(prev, next)
let resultNode = result
let nextNode = next
for (let i = 0; i < 502; i++) {
resultNode = resultNode.child
nextNode = nextNode.child
}
expect(resultNode).toBe(nextNode)
})
})
describe('matchMutation', () => {
it('should return false if mutationKey options is undefined', () => {
const filters = { mutationKey: queryKey() }
const queryClient = new QueryClient()
const mutation = new Mutation({
client: queryClient,
mutationId: 1,
mutationCache: queryClient.getMutationCache(),
options: {},
})
expect(matchMutation(filters, mutation)).toBe(false)
})
})
describe('keepPreviousData', () => {
it('should return the parameter as is', () => {
const x = { a: 1, b: 2 }
expect(keepPreviousData(x)).toBe(x)
})
})
describe('addToEnd', () => {
it('should add item to the end of the array', () => {
const items = [1, 2, 3]
const newItems = addToEnd(items, 4)
expect(newItems).toEqual([1, 2, 3, 4])
})
it('should not exceed max if provided', () => {
const items = [1, 2, 3]
const newItems = addToEnd(items, 4, 3)
expect(newItems).toEqual([2, 3, 4])
})
it('should add item to the end of the array when max = 0', () => {
const items = [1, 2, 3]
const item = 4
const max = 0
expect(addToEnd(items, item, max)).toEqual([1, 2, 3, 4])
})
it('should add item to the end of the array when max is undefined', () => {
const items = [1, 2, 3]
const item = 4
const max = undefined
expect(addToEnd(items, item, max)).toEqual([1, 2, 3, 4])
})
})
describe('addToStart', () => {
it('should add an item to the start of the array', () => {
const items = [1, 2, 3]
const item = 4
const newItems = addToStart(items, item)
expect(newItems).toEqual([4, 1, 2, 3])
})
it('should respect the max argument', () => {
const items = [1, 2, 3]
const item = 4
const max = 2
const newItems = addToStart(items, item, max)
expect(newItems).toEqual([4, 1, 2])
})
it('should not remove any items if max = 0', () => {
const items = [1, 2, 3]
const item = 4
const max = 0
const newItems = addToStart(items, item, max)
expect(newItems).toEqual([4, 1, 2, 3])
})
it('should not remove any items if max is undefined', () => {
const items = [1, 2, 3]
const item = 4
const max = 0
const newItems = addToStart(items, item, max)
expect(newItems).toEqual([4, 1, 2, 3])
})
})
describe('hashKey', () => {
it('should hash primitives correctly', () => {
expect(hashKey(['test'])).toEqual(JSON.stringify(['test']))
expect(hashKey([123])).toEqual(JSON.stringify([123]))
expect(hashKey([null])).toEqual(JSON.stringify([null]))
})
it('should hash objects with sorted keys consistently', () => {
const key1 = [{ b: 2, a: 1 }]
const key2 = [{ a: 1, b: 2 }]
const hash1 = hashKey(key1)
const hash2 = hashKey(key2)
expect(hash1).toEqual(hash2)
expect(hash1).toEqual(JSON.stringify([{ a: 1, b: 2 }]))
})
it('should hash arrays consistently', () => {
const arr1 = [{ b: 2, a: 1 }, 'test', 123]
const arr2 = [{ a: 1, b: 2 }, 'test', 123]
expect(hashKey(arr1)).toEqual(hashKey(arr2))
})
it('should handle nested objects with sorted keys', () => {
const nested1 = [{ a: { d: 4, c: 3 }, b: 2 }]
const nested2 = [{ b: 2, a: { c: 3, d: 4 } }]
expect(hashKey(nested1)).toEqual(hashKey(nested2))
})
it('should hash undefined object properties the same as missing properties', () => {
const withUndefined = ['todos', { filters: undefined }]
const withoutProperty = ['todos', {}]
expect(hashKey(withUndefined)).toEqual(hashKey(withoutProperty))
})
})
describe('isValidTimeout', () => {
it('should accept valid timeout values', () => {
expect(isValidTimeout(0)).toBe(true)
expect(isValidTimeout(1_000)).toBe(true)
})
it('should reject a negative timeout value', () => {
expect(isValidTimeout(-1)).toBe(false)
})
it('should reject NaN', () => {
expect(isValidTimeout(Number.NaN)).toBe(false)
})
it('should reject Infinity', () => {
expect(isValidTimeout(Number.POSITIVE_INFINITY)).toBe(false)
})
it('should reject a string timeout value', () => {
expect(isValidTimeout('1000')).toBe(false)
})
it('should reject undefined', () => {
expect(isValidTimeout(undefined)).toBe(false)
})
})
describe('ensureQueryFn', () => {
const context = {} as QueryFunctionContext
it('should return a function that resolves to initialPromise when queryFn is missing and initialPromise is provided', async () => {
const initialPromise = Promise.resolve('initial-data')
const resolved = ensureQueryFn(
{ queryHash: '["key"]' },
{ initialPromise },
)
await expect(resolved(context)).resolves.toBe('initial-data')
})
it('should return a function that rejects when initialPromise rejects', async () => {
const error = new Error('initial-promise-error')
const initialPromise = Promise.reject(error)
const resolved = ensureQueryFn(
{ queryHash: '["key"]' },
{ initialPromise },
)
await expect(resolved(context)).rejects.toBe(error)
})
it('should return a function that rejects with missing queryFn error when queryFn is set to skipToken', async () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined)
const resolved = ensureQueryFn({
queryFn: skipToken,
queryHash: '["skip"]',
})
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining(
'Attempted to invoke queryFn when set to skipToken',
),
)
await expect(resolved(context)).rejects.toThrow(
'Missing queryFn: \'["skip"]\'',
)
consoleErrorSpy.mockRestore()
})
})
describe('shouldThrowError', () => {
it('should return the result of executing throwOnError if throwOnError parameter is a function', () => {
const throwOnError = (error: Error) => error.message === 'test error'
expect(shouldThrowError(throwOnError, [new Error('test error')])).toBe(
true,
)
expect(shouldThrowError(throwOnError, [new Error('other error')])).toBe(
false,
)
})
it('should return throwOnError parameter itself if throwOnError is not a function', () => {
expect(shouldThrowError(true, [new Error('test error')])).toBe(true)
expect(shouldThrowError(false, [new Error('test error')])).toBe(false)
expect(shouldThrowError(undefined, [new Error('test error')])).toBe(false)
})
})
describe('addConsumeAwareSignal', () => {
it('should expose the signal on the query context while preserving its properties', () => {
const controller = new AbortController()
const key = queryKey()
const context = addConsumeAwareSignal(
{ queryKey: key, meta: undefined },
() => controller.signal,
vi.fn(),
)
expect(context.queryKey).toBe(key)
expect(context.signal).toBe(controller.signal)
})
it('should call onCancelled immediately when the signal is already aborted on first access', () => {
const controller = new AbortController()
controller.abort()
const onCancelled = vi.fn()
const object = addConsumeAwareSignal(
{},
() => controller.signal,
onCancelled,
)
// Access the signal to consume it
void object.signal
expect(onCancelled).toHaveBeenCalledTimes(1)
})
it('should flag cancellation when the consumed signal aborts, mirroring streamed/infinite queries', () => {
const controller = new AbortController()
let cancelled = false
const context = addConsumeAwareSignal(
{ queryKey: queryKey() },
() => controller.signal,
() => (cancelled = true),
)
void context.signal
expect(cancelled).toBe(false)
controller.abort()
expect(cancelled).toBe(true)
})
it('should consume the signal only once across repeated accesses', () => {
const controller = new AbortController()
const addEventListener = vi.spyOn(controller.signal, 'addEventListener')
const context = addConsumeAwareSignal(
{ queryKey: queryKey() },
() => controller.signal,
vi.fn(),
)
expect(context.signal).toBe(controller.signal)
expect(context.signal).toBe(controller.signal)
expect(addEventListener).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -0,0 +1,28 @@
import { vi } from 'vitest'
import { environmentManager, isServer, onlineManager } from '..'
import type { MockInstance } from 'vitest'
import type { MutationOptions, QueryClient } from '..'
export function mockOnlineManagerIsOnline(
value: boolean,
): MockInstance<() => boolean> {
return vi.spyOn(onlineManager, 'isOnline').mockReturnValue(value)
}
export function executeMutation<TVariables>(
queryClient: QueryClient,
options: MutationOptions<any, any, TVariables, any>,
variables: TVariables,
) {
return queryClient
.getMutationCache()
.build(queryClient, options)
.execute(variables)
}
export function setIsServer(value: boolean) {
environmentManager.setIsServer(() => value)
return () => {
environmentManager.setIsServer(() => isServer)
}
}

View File

@@ -0,0 +1,37 @@
import { isServer as defaultIsServer } from './utils'
export type IsServerValue = () => boolean
let isServerFn: IsServerValue = () => defaultIsServer
/**
* Returns whether the current runtime should be treated as a server environment.
*/
export const isServer = (): boolean => isServerFn()
/**
* Manages how TanStack Query detects whether the current runtime should be treated as
* server-side, which disables scheduling refetch timers and changes the default `retry` count
* and `gcTime`. By default, the detection treats a missing `window` (or the presence of a
* `Deno` global) as server.
*
* Override this for runtimes where that default detection would give the wrong answer — for
* example, a Service Worker, where `window` is undefined even though the environment should
* behave like a client.
*
* @example
* ```ts
* import { environmentManager } from '@tanstack/query-core'
*
* environmentManager.setIsServer(() => false)
* ```
*/
export const environmentManager = {
isServer,
/**
* Overrides the server check globally.
*/
setIsServer(isServerValue: IsServerValue): void {
isServerFn = isServerValue
},
}

View File

@@ -0,0 +1,142 @@
import { Subscribable } from './subscribable'
type Listener = (focused: boolean) => void
type SetupFn = (
setFocused: (focused?: boolean) => void,
) => (() => void) | undefined
/**
* The `FocusManager` manages the focus state within TanStack Query.
*
* It can be used to change the default event listeners or to manually change the focus state.
*/
export class FocusManager extends Subscribable<Listener> {
#focused?: boolean
#cleanup?: () => void
#setup: SetupFn
constructor() {
super()
this.#setup = (onFocus) => {
// addEventListener does not exist in React Native, but window does
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (typeof window !== 'undefined' && window.addEventListener) {
const listener = () => onFocus()
// Listen to visibilitychange
window.addEventListener('visibilitychange', listener, false)
return () => {
// Be sure to unsubscribe if a new handler is set
window.removeEventListener('visibilitychange', listener)
}
}
return
}
}
protected onSubscribe(): void {
if (!this.#cleanup) {
this.setEventListener(this.#setup)
}
}
protected onUnsubscribe() {
if (!this.hasListeners()) {
this.#cleanup?.()
this.#cleanup = undefined
}
}
/**
* `setEventListener` can be used to set a custom event listener that will
* be used to determine the focus state. The provided `setup` function
* receives a `setFocused` callback: call it with a `boolean` to manually
* set the focus state, or with no arguments to re-evaluate the current
* focus state and notify subscribers.
*
* @example
* ```ts
* import { focusManager } from '@tanstack/query-core'
*
* focusManager.setEventListener((handleFocus) => {
* const listener = () => handleFocus()
* // Listen to visibilitychange
* if (typeof window !== 'undefined' && window.addEventListener) {
* window.addEventListener('visibilitychange', listener, false)
* }
*
* return () => {
* // Be sure to unsubscribe if a new handler is set
* window.removeEventListener('visibilitychange', listener)
* }
* })
* ```
*/
setEventListener(setup: SetupFn): void {
this.#setup = setup
this.#cleanup?.()
this.#cleanup = setup((focused) => {
if (typeof focused === 'boolean') {
this.setFocused(focused)
} else {
this.onFocus()
}
})
}
/**
* `setFocused` can be used to manually set the focus state. Set `undefined`
* to fall back to the default focus check.
*
* @example
* ```ts
* import { focusManager } from '@tanstack/query-core'
*
* // Set focused
* focusManager.setFocused(true)
*
* // Set unfocused
* focusManager.setFocused(false)
*
* // Fallback to the default focus check
* focusManager.setFocused(undefined)
* ```
*/
setFocused(focused?: boolean): void {
const changed = this.#focused !== focused
if (changed) {
this.#focused = focused
this.onFocus()
}
}
/**
* `onFocus` notifies all subscribed listeners with the current focus state.
*/
onFocus(): void {
const isFocused = this.isFocused()
this.listeners.forEach((listener) => {
listener(isFocused)
})
}
/**
* `isFocused` can be used to get the current focus state.
*/
isFocused(): boolean {
if (typeof this.#focused === 'boolean') {
return this.#focused
}
// document global can be unavailable in react native
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
return globalThis.document?.visibilityState !== 'hidden'
}
}
/**
* Singleton instance of {@link FocusManager}, used to manage and observe the focus state within TanStack Query.
*/
export const focusManager = new FocusManager()

View File

@@ -0,0 +1,394 @@
import { noop } from './utils'
import type {
DefaultError,
MutationKey,
MutationMeta,
MutationOptions,
MutationScope,
QueryKey,
QueryMeta,
QueryOptions,
} from './types'
import type { QueryClient } from './queryClient'
import type { Query, QueryState } from './query'
import type { Mutation, MutationState } from './mutation'
// TYPES
type TransformerFn = (data: any) => any
function tryResolveSync(promise: PromiseLike<unknown>) {
let data: unknown
const thenResult = promise.then((result) => {
data = result
return result
}, noop) as Promise<unknown> | undefined
// .catch can be unavailable on certain kinds of thenable's
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
thenResult?.catch?.(noop)
if (data !== undefined) {
return { data }
}
return undefined
}
/**
* Options for `dehydrate`, controlling which queries/mutations are included in the resulting `DehydratedState` and
* how their data/errors are transformed before being serialized (e.g. for embedding in server-rendered markup).
*/
export interface DehydrateOptions {
/** Transforms a query's `data` before it is dehydrated. Useful for non-JSON-serializable data. */
serializeData?: TransformerFn
/** Predicate to decide whether a given `Mutation` should be dehydrated. Defaults to `defaultShouldDehydrateMutation`. */
shouldDehydrateMutation?: (mutation: Mutation) => boolean
/** Predicate to decide whether a given `Query` should be dehydrated. Defaults to `defaultShouldDehydrateQuery`. */
shouldDehydrateQuery?: (query: Query) => boolean
/**
* Predicate to decide whether a query's error should be redacted before dehydration. Errors are redacted
* (replaced with a generic `Error('redacted')`) unless this function is provided and returns `false` for the
* given error, in which case the original error is kept.
*/
shouldRedactErrors?: (error: unknown) => boolean
}
/**
* Options for `hydrate`, controlling the default options applied to queries/mutations restored from a
* `DehydratedState`, and how to reverse any transformation applied by `DehydrateOptions.serializeData`.
*/
export interface HydrateOptions {
defaultOptions?: {
/** Transforms a query's `data` after it is read from the dehydrated state, reversing `serializeData`. */
deserializeData?: TransformerFn
/** Default options merged into every query restored from the dehydrated state. */
queries?: QueryOptions
/** Default options merged into every mutation restored from the dehydrated state. */
mutations?: MutationOptions<unknown, DefaultError, unknown, unknown>
}
}
interface DehydratedMutation {
mutationKey?: MutationKey
state: MutationState
meta?: MutationMeta
scope?: MutationScope
}
interface DehydratedQuery {
queryHash: string
queryKey: QueryKey
state: QueryState
dehydratedAt: number
promise?: Promise<unknown>
meta?: QueryMeta
queryType?: 'infinite'
}
/**
* A serializable snapshot of a `QueryClient`'s cache, as produced by `dehydrate` and consumed by `hydrate`. Typically
* transported from server to client (e.g. embedded in server-rendered markup) to seed the client's cache with data
* that has already been fetched, avoiding a redundant fetch on the client.
*/
export interface DehydratedState {
mutations: Array<DehydratedMutation>
queries: Array<DehydratedQuery>
}
// FUNCTIONS
function dehydrateMutation(mutation: Mutation): DehydratedMutation {
return {
mutationKey: mutation.options.mutationKey,
state: mutation.state,
...(mutation.options.scope && { scope: mutation.options.scope }),
...(mutation.meta && { meta: mutation.meta }),
}
}
function dehydratePromise(
query: Query,
serializeData?: TransformerFn,
shouldRedactErrors?: (error: unknown) => boolean,
): Promise<unknown> | undefined {
const promise = query.promise?.then(serializeData).catch((error) => {
if (shouldRedactErrors?.(error) === false) {
// Reject original error if it should not be redacted
return Promise.reject(error)
}
// If not in production, log original error before rejecting redacted error
if (process.env.NODE_ENV !== 'production') {
console.error(
`A query that was dehydrated as pending ended up rejecting. [${query.queryHash}]: ${error}; The error will be redacted in production builds`,
)
}
return Promise.reject(new Error('redacted'))
})
// Avoid unhandled promise rejections
// We need the promise we dehydrate to reject to get the correct result into
// the query cache, but we also want to avoid unhandled promise rejections
// in whatever environment the prefetches are happening in.
promise?.catch(noop)
return promise
}
/**
* Dehydrates a single `Query` into a serializable `DehydratedQuery` snapshot. Note that most query config (e.g.
* `queryFn`, `staleTime`) is not dehydrated but instead meant to be configured again when consuming the
* de/rehydrated data, typically with `useQuery` on the client. If the query is still `pending`, its in-flight
* promise is dehydrated too so it can be resumed on the other side instead of re-fetched.
* @param query - The query to dehydrate.
* @param serializeData - Optional transform applied to `query.state.data` before it is included in the snapshot.
* @param shouldRedactErrors - Optional predicate; if it returns `false` for the promise's rejection error, that
* error is kept as-is instead of being redacted.
*/
export function dehydrateQuery(
query: Query,
serializeData?: TransformerFn,
shouldRedactErrors?: (error: unknown) => boolean,
): DehydratedQuery {
return {
dehydratedAt: Date.now(),
state: {
...query.state,
...(query.state.data !== undefined && {
data: serializeData
? serializeData(query.state.data)
: query.state.data,
}),
},
queryKey: query.queryKey,
queryHash: query.queryHash,
...(query.state.status === 'pending' && {
promise: dehydratePromise(query, serializeData, shouldRedactErrors),
}),
...(query.meta && { meta: query.meta }),
...(query.queryType && { queryType: query.queryType }),
}
}
/**
* The default `shouldDehydrateMutation` predicate used by `dehydrate`. Only dehydrates mutations that are
* currently paused (e.g. paused by `networkMode` while offline).
*/
export function defaultShouldDehydrateMutation(mutation: Mutation) {
return mutation.state.isPaused
}
/**
* The default `shouldDehydrateQuery` predicate used by `dehydrate`. Only dehydrates queries whose status is
* `'success'`.
*/
export function defaultShouldDehydrateQuery(query: Query) {
return query.state.status === 'success'
}
/**
* Dehydrates a `QueryClient`'s cache (queries and mutations) into a plain, serializable `DehydratedState`,
* typically to embed in server-rendered markup and later restore into a client-side `QueryClient` via `hydrate`.
* Which queries/mutations are included, and how their data/errors are transformed, is controlled by `options`,
* falling back to the client's `dehydrate` default options, and finally to `defaultShouldDehydrateQuery` /
* `defaultShouldDehydrateMutation`.
* @example
* ```ts
* const queryClient = new QueryClient()
*
* await queryClient.prefetchQuery({
* queryKey: ['posts'],
* queryFn: getPosts,
* })
*
* const dehydratedState = dehydrate(queryClient)
* ```
*/
export function dehydrate(
client: QueryClient,
options: DehydrateOptions = {},
): DehydratedState {
const filterMutation =
options.shouldDehydrateMutation ??
client.getDefaultOptions().dehydrate?.shouldDehydrateMutation ??
defaultShouldDehydrateMutation
const mutations = client
.getMutationCache()
.getAll()
.flatMap((mutation) =>
filterMutation(mutation) ? [dehydrateMutation(mutation)] : [],
)
const filterQuery =
options.shouldDehydrateQuery ??
client.getDefaultOptions().dehydrate?.shouldDehydrateQuery ??
defaultShouldDehydrateQuery
const shouldRedactErrors =
options.shouldRedactErrors ??
client.getDefaultOptions().dehydrate?.shouldRedactErrors
const serializeData =
options.serializeData ?? client.getDefaultOptions().dehydrate?.serializeData
const queries = client
.getQueryCache()
.getAll()
.flatMap((query) =>
filterQuery(query)
? [dehydrateQuery(query, serializeData, shouldRedactErrors)]
: [],
)
return { mutations, queries }
}
/**
* Restores a `DehydratedState` (as produced by `dehydrate`) into a `QueryClient`'s cache, typically to seed the
* client with data already fetched on the server. `mutations` and `queries` are each optional on `dehydratedState`.
* Queries not yet in the cache are built from the dehydrated snapshot; queries that already exist are only updated
* when the dehydrated data is newer than what's already cached. Newly built queries have their `fetchStatus` reset
* to `'idle'` so they don't hydrate stuck in a fetching state. If a dehydrated query still had an in-flight
* promise, it is resumed via `query.fetch()` (reusing that promise as `initialPromise`) rather than re-invoking
* `queryFn`.
* @example
* ```ts
* // dehydratedState was produced by `dehydrate` on the server
* // and sent to the client, e.g. embedded in server-rendered markup.
* const queryClient = new QueryClient()
*
* hydrate(queryClient, dehydratedState)
* ```
*/
export function hydrate(
client: QueryClient,
dehydratedState: Partial<DehydratedState>,
options?: HydrateOptions,
): void {
const mutationCache = client.getMutationCache()
const queryCache = client.getQueryCache()
const deserializeData =
options?.defaultOptions?.deserializeData ??
client.getDefaultOptions().hydrate?.deserializeData
dehydratedState.mutations?.forEach(({ state, ...mutationOptions }) => {
mutationCache.build(
client,
{
...client.getDefaultOptions().hydrate?.mutations,
...options?.defaultOptions?.mutations,
...mutationOptions,
},
state,
)
})
dehydratedState.queries?.forEach(
({
queryKey,
state,
queryHash,
meta,
promise,
dehydratedAt,
queryType,
}) => {
const syncData = promise ? tryResolveSync(promise) : undefined
const rawData = state.data === undefined ? syncData?.data : state.data
const data =
rawData === undefined
? rawData
: deserializeData
? deserializeData(rawData)
: rawData
let query = queryCache.get(queryHash)
const existingQueryIsPending = query?.state.status === 'pending'
const existingQueryIsFetching = query?.state.fetchStatus === 'fetching'
// Do not hydrate if an existing query exists with newer data
if (query) {
const hasNewerSyncData =
syncData && dehydratedAt > query.state.dataUpdatedAt
if (
state.dataUpdatedAt > query.state.dataUpdatedAt ||
hasNewerSyncData
) {
// Omit fetchStatus from dehydrated state so that query stays in its current fetchStatus
const { fetchStatus: _ignored, ...serializedState } = state
query.setState({
...serializedState,
data,
// If the query was pending at the moment of dehydration, but resolved to have data
// before hydration, we can assume the query should be hydrated as successful.
//
// Since you can opt into dehydrating failed queries, and those can have data from
// previous successful fetches, we make sure we only do this for pending queries.
...(state.status === 'pending' &&
data !== undefined && {
status: 'success' as const,
dataUpdatedAt: dehydratedAt,
// Preserve existing fetchStatus if the existing query is actively fetching.
...(!existingQueryIsFetching && {
fetchStatus: 'idle' as const,
}),
}),
})
}
} else {
// Restore query
query = queryCache.build(
client,
{
...client.getDefaultOptions().hydrate?.queries,
...options?.defaultOptions?.queries,
queryKey,
queryHash,
meta,
_type: queryType,
},
// Reset fetch status to idle to avoid
// query being stuck in fetching state upon hydration
{
...state,
data,
fetchStatus: 'idle',
// Like above, if the query was pending at the moment of dehydration but has data,
// we can assume it should be hydrated as successful.
status:
state.status === 'pending' && data !== undefined
? 'success'
: state.status,
...(state.status === 'pending' &&
data !== undefined && {
dataUpdatedAt: dehydratedAt,
}),
},
)
}
if (
promise &&
// If the data was synchronously available, there is no need to set up
// a retryer and thus no reason to call fetch
!syncData &&
!existingQueryIsPending &&
!existingQueryIsFetching &&
// Only hydrate if dehydration is newer than any existing data,
// this is always true for new queries
dehydratedAt > query.state.dataUpdatedAt
) {
// This doesn't actually fetch - it just creates a retryer
// which will re-use the passed `initialPromise`
query
.fetch(undefined, {
// RSC transformed promises are not thenable
initialPromise: Promise.resolve(promise).then(deserializeData),
})
// Avoid unhandled promise rejections
.catch(noop)
}
},
)
}

View File

@@ -0,0 +1,62 @@
/* istanbul ignore file */
export { focusManager } from './focusManager'
export type { FocusManager } from './focusManager'
export { environmentManager } from './environmentManager'
export {
defaultShouldDehydrateMutation,
defaultShouldDehydrateQuery,
dehydrate,
dehydrateQuery,
hydrate,
} from './hydration'
export { InfiniteQueryObserver } from './infiniteQueryObserver'
export { MutationCache } from './mutationCache'
export type { MutationCacheNotifyEvent } from './mutationCache'
export { MutationObserver } from './mutationObserver'
export { defaultScheduler, notifyManager } from './notifyManager'
export { onlineManager } from './onlineManager'
export type { OnlineManager } from './onlineManager'
export { QueriesObserver } from './queriesObserver'
export { QueryCache } from './queryCache'
export type { QueryCacheNotifyEvent } from './queryCache'
export { QueryClient } from './queryClient'
export { QueryObserver } from './queryObserver'
export { CancelledError, isCancelledError } from './retryer'
export {
timeoutManager,
type ManagedTimerId,
type TimeoutCallback,
type TimeoutManager,
type TimeoutProvider,
} from './timeoutManager'
export {
hashKey,
isServer,
keepPreviousData,
matchMutation,
matchQuery,
noop,
partialMatchKey,
replaceEqualDeep,
shouldThrowError,
skipToken,
} from './utils'
export type { MutationFilters, QueryFilters, SkipToken, Updater } from './utils'
export { streamedQuery as experimental_streamedQuery } from './streamedQuery'
// Types
export type {
DehydratedState,
DehydrateOptions,
HydrateOptions,
} from './hydration'
export { Mutation } from './mutation'
export type { MutationState } from './mutation'
export type { MutationCacheConfig } from './mutationCache'
export type { QueriesObserverOptions } from './queriesObserver'
export { Query } from './query'
export type { QueryState } from './query'
export type { QueryCacheConfig } from './queryCache'
export * from './types'

View File

@@ -0,0 +1,176 @@
import {
addConsumeAwareSignal,
addToEnd,
addToStart,
ensureQueryFn,
} from './utils'
import type { QueryBehavior } from './query'
import type {
InfiniteData,
InfiniteQueryPageParamsOptions,
OmitKeyof,
QueryFunctionContext,
QueryKey,
} from './types'
export function infiniteQueryBehavior<TQueryFnData, TError, TData, TPageParam>(
pages?: number,
): QueryBehavior<TQueryFnData, TError, InfiniteData<TData, TPageParam>> {
return {
onFetch: (context, query) => {
const options = context.options as InfiniteQueryPageParamsOptions<TData>
const direction = context.fetchOptions?.meta?.fetchMore?.direction
const oldPages = context.state.data?.pages || []
const oldPageParams = context.state.data?.pageParams || []
let result: InfiniteData<unknown> = { pages: [], pageParams: [] }
let currentPage = 0
const fetchFn = async () => {
let cancelled = false
const addSignalProperty = (object: unknown) => {
addConsumeAwareSignal(
object,
() => context.signal,
() => (cancelled = true),
)
}
const queryFn = ensureQueryFn(context.options, context.fetchOptions)
// Create function to fetch a page
const fetchPage = async (
data: InfiniteData<unknown>,
param: unknown,
previous?: boolean,
): Promise<InfiniteData<unknown>> => {
if (cancelled) {
return Promise.reject(context.signal.reason)
}
if (param == null && data.pages.length) {
return Promise.resolve(data)
}
const createQueryFnContext = () => {
const queryFnContext: OmitKeyof<
QueryFunctionContext<QueryKey, unknown>,
'signal'
> = {
client: context.client,
queryKey: context.queryKey,
pageParam: param,
direction: previous ? 'backward' : 'forward',
meta: context.options.meta,
}
addSignalProperty(queryFnContext)
return queryFnContext as QueryFunctionContext<QueryKey, unknown>
}
const queryFnContext = createQueryFnContext()
const page = await queryFn(queryFnContext)
const { maxPages } = context.options
const addTo = previous ? addToStart : addToEnd
return {
pages: addTo(data.pages, page, maxPages),
pageParams: addTo(data.pageParams, param, maxPages),
}
}
// fetch next / previous page?
if (direction && oldPages.length) {
const previous = direction === 'backward'
const pageParamFn = previous ? getPreviousPageParam : getNextPageParam
const oldData = {
pages: oldPages,
pageParams: oldPageParams,
}
const param = pageParamFn(options, oldData)
result = await fetchPage(oldData, param, previous)
} else {
const remainingPages = pages ?? oldPages.length
// Fetch all pages
do {
const param =
currentPage === 0
? (oldPageParams[0] ?? options.initialPageParam)
: getNextPageParam(options, result)
if (currentPage > 0 && param == null) {
break
}
result = await fetchPage(result, param)
currentPage++
} while (currentPage < remainingPages)
}
return result
}
if (context.options.persister) {
context.fetchFn = () => {
return context.options.persister?.(
fetchFn as any,
{
client: context.client,
queryKey: context.queryKey,
meta: context.options.meta,
signal: context.signal,
},
query,
)
}
} else {
context.fetchFn = fetchFn
}
},
}
}
function getNextPageParam(
options: InfiniteQueryPageParamsOptions<any>,
{ pages, pageParams }: InfiniteData<unknown>,
): unknown | undefined {
const lastIndex = pages.length - 1
return pages.length > 0
? options.getNextPageParam(
pages[lastIndex],
pages,
pageParams[lastIndex],
pageParams,
)
: undefined
}
function getPreviousPageParam(
options: InfiniteQueryPageParamsOptions<any>,
{ pages, pageParams }: InfiniteData<unknown>,
): unknown | undefined {
return pages.length > 0
? options.getPreviousPageParam?.(pages[0], pages, pageParams[0], pageParams)
: undefined
}
/**
* Checks if there is a next page.
*/
export function hasNextPage(
options: InfiniteQueryPageParamsOptions<any, any>,
data?: InfiniteData<unknown>,
): boolean {
if (!data) return false
return getNextPageParam(options, data) != null
}
/**
* Checks if there is a previous page.
*/
export function hasPreviousPage(
options: InfiniteQueryPageParamsOptions<any, any>,
data?: InfiniteData<unknown>,
): boolean {
if (!data || !options.getPreviousPageParam) return false
return getPreviousPageParam(options, data) != null
}

View File

@@ -0,0 +1,251 @@
import { QueryObserver } from './queryObserver'
import { hasNextPage, hasPreviousPage } from './infiniteQueryBehavior'
import type { Subscribable } from './subscribable'
import type {
DefaultError,
DefaultedInfiniteQueryObserverOptions,
FetchNextPageOptions,
FetchPreviousPageOptions,
InfiniteData,
InfiniteQueryObserverBaseResult,
InfiniteQueryObserverOptions,
InfiniteQueryObserverResult,
QueryKey,
} from './types'
import type { QueryClient } from './queryClient'
import type { Query } from './query'
type InfiniteQueryObserverListener<TData, TError> = (
result: InfiniteQueryObserverResult<TData, TError>,
) => void
/**
* An `InfiniteQueryObserver` extends `QueryObserver` to observe and switch
* between infinite queries. It augments the base `QueryObserverResult` with
* infinite-query-specific fields and methods, such as `hasNextPage` and
* `fetchNextPage`, and is the primitive that framework adapters (e.g.
* `useInfiniteQuery`) build their hooks on top of.
*
* @example
* ```ts
* const observer = new InfiniteQueryObserver(queryClient, {
* queryKey: ['projects'],
* queryFn: ({ pageParam }) => fetchProjects(pageParam),
* initialPageParam: 0,
* getNextPageParam: (lastPage) => lastPage.nextCursor,
* })
*
* const unsubscribe = observer.subscribe((result) => console.log(result))
* ```
*/
export class InfiniteQueryObserver<
TQueryFnData = unknown,
TError = DefaultError,
TData = InfiniteData<TQueryFnData>,
TQueryKey extends QueryKey = QueryKey,
TPageParam = unknown,
> extends QueryObserver<
TQueryFnData,
TError,
TData,
InfiniteData<TQueryFnData, TPageParam>,
TQueryKey
> {
// Type override
subscribe!: Subscribable<
InfiniteQueryObserverListener<TData, TError>
>['subscribe']
// Type override
getCurrentResult!: ReplaceReturnType<
QueryObserver<
TQueryFnData,
TError,
TData,
InfiniteData<TQueryFnData, TPageParam>,
TQueryKey
>['getCurrentResult'],
InfiniteQueryObserverResult<TData, TError>
>
// Type override
protected fetch!: ReplaceReturnType<
QueryObserver<
TQueryFnData,
TError,
TData,
InfiniteData<TQueryFnData, TPageParam>,
TQueryKey
>['fetch'],
Promise<InfiniteQueryObserverResult<TData, TError>>
>
constructor(
client: QueryClient,
options: InfiniteQueryObserverOptions<
TQueryFnData,
TError,
TData,
TQueryKey,
TPageParam
>,
) {
super(client, options)
}
protected bindMethods(): void {
super.bindMethods()
this.fetchNextPage = this.fetchNextPage.bind(this)
this.fetchPreviousPage = this.fetchPreviousPage.bind(this)
}
/**
* Updates the observer's options. Behaves the same as
* `QueryObserver.setOptions`, additionally marking the options as
* belonging to an infinite query before delegating to the base
* implementation.
*/
setOptions(
options: InfiniteQueryObserverOptions<
TQueryFnData,
TError,
TData,
TQueryKey,
TPageParam
>,
): void {
options._type = 'infinite'
super.setOptions(options)
}
/**
* The infinite-query counterpart of {@link QueryObserver#getOptimisticResult}, marking the
* options as an infinite query before delegating to it. Called by framework adapters (e.g.
* `useInfiniteQuery`) ahead of subscribing, to compute the current `InfiniteQueryObserverResult`
* synchronously.
*/
getOptimisticResult(
options: DefaultedInfiniteQueryObserverOptions<
TQueryFnData,
TError,
TData,
TQueryKey,
TPageParam
>,
): InfiniteQueryObserverResult<TData, TError> {
options._type = 'infinite'
return super.getOptimisticResult(options) as InfiniteQueryObserverResult<
TData,
TError
>
}
/**
* Fetches the next page of the infinite query and returns a promise that
* resolves with the resulting `InfiniteQueryObserverResult`. The page
* param used for the fetch is determined by `getNextPageParam`, which
* receives the current pages/page params and whose result also determines
* `hasNextPage`.
*
* @example
* ```ts
* const { hasNextPage } = observer.getCurrentResult()
*
* if (hasNextPage) {
* await observer.fetchNextPage()
* }
* ```
*
* @see {@link InfiniteQueryObserver#fetchPreviousPage}
*/
fetchNextPage(
options?: FetchNextPageOptions,
): Promise<InfiniteQueryObserverResult<TData, TError>> {
return this.fetch({
...options,
meta: {
fetchMore: { direction: 'forward' },
},
})
}
/**
* Fetches the previous page of the infinite query and returns a promise
* that resolves with the resulting `InfiniteQueryObserverResult`. The page
* param used for the fetch is determined by `getPreviousPageParam`, which
* receives the current pages/page params and whose result also determines
* `hasPreviousPage`.
*
* @example
* ```ts
* const { hasPreviousPage } = observer.getCurrentResult()
*
* if (hasPreviousPage) {
* await observer.fetchPreviousPage()
* }
* ```
*
* @see {@link InfiniteQueryObserver#fetchNextPage}
*/
fetchPreviousPage(
options?: FetchPreviousPageOptions,
): Promise<InfiniteQueryObserverResult<TData, TError>> {
return this.fetch({
...options,
meta: {
fetchMore: { direction: 'backward' },
},
})
}
protected createResult(
query: Query<
TQueryFnData,
TError,
InfiniteData<TQueryFnData, TPageParam>,
TQueryKey
>,
options: InfiniteQueryObserverOptions<
TQueryFnData,
TError,
TData,
TQueryKey,
TPageParam
>,
): InfiniteQueryObserverResult<TData, TError> {
const { state } = query
const parentResult = super.createResult(query, options)
const { isFetching, isRefetching, isError, isRefetchError } = parentResult
const fetchDirection = state.fetchMeta?.fetchMore?.direction
const isFetchNextPageError = isError && fetchDirection === 'forward'
const isFetchingNextPage = isFetching && fetchDirection === 'forward'
const isFetchPreviousPageError = isError && fetchDirection === 'backward'
const isFetchingPreviousPage = isFetching && fetchDirection === 'backward'
const result: InfiniteQueryObserverBaseResult<TData, TError> = {
...parentResult,
fetchNextPage: this.fetchNextPage,
fetchPreviousPage: this.fetchPreviousPage,
hasNextPage: hasNextPage(options, state.data),
hasPreviousPage: hasPreviousPage(options, state.data),
isFetchNextPageError,
isFetchingNextPage,
isFetchPreviousPageError,
isFetchingPreviousPage,
isRefetchError:
isRefetchError && !isFetchNextPageError && !isFetchPreviousPageError,
isRefetching:
isRefetching && !isFetchingNextPage && !isFetchingPreviousPage,
}
return result as InfiniteQueryObserverResult<TData, TError>
}
}
type ReplaceReturnType<
TFunction extends (...args: Array<any>) => unknown,
TReturn,
> = (...args: Parameters<TFunction>) => TReturn

View File

@@ -0,0 +1,535 @@
import { notifyManager } from './notifyManager'
import { Removable } from './removable'
import { createRetryer } from './retryer'
import type {
DefaultError,
MutationFunctionContext,
MutationMeta,
MutationOptions,
MutationStatus,
} from './types'
import type { MutationCache } from './mutationCache'
import type { MutationObserver } from './mutationObserver'
import type { Retryer } from './retryer'
import type { QueryClient } from './queryClient'
// TYPES
interface MutationConfig<TData, TError, TVariables, TOnMutateResult> {
client: QueryClient
mutationId: number
mutationCache: MutationCache
options: MutationOptions<TData, TError, TVariables, TOnMutateResult>
state?: MutationState<TData, TError, TVariables, TOnMutateResult>
}
/**
* The raw state stored on a `Mutation` instance. This is the underlying state
* that observer results (e.g. `MutationObserverResult`) are derived from.
*/
export interface MutationState<
TData = unknown,
TError = DefaultError,
TVariables = unknown,
TOnMutateResult = unknown,
> {
/**
* The value returned by `onMutate`, if defined. Passed to `onSuccess`,
* `onError` and `onSettled` as the mutation's context.
*/
context: TOnMutateResult | undefined
/**
* The last successfully resolved data for the mutation.
*/
data: TData | undefined
/**
* The error object for the mutation, if the last attempt resulted in an error.
* - Defaults to `null`.
*/
error: TError | null
/**
* The number of times the mutation function has failed for the current attempt.
*/
failureCount: number
/**
* The reason the current attempt failed, as reported by the retryer.
*/
failureReason: TError | null
/**
* Whether the mutation is currently paused (see network mode), or is
* waiting for another mutation with the same `scope` to finish.
*/
isPaused: boolean
/**
* The status of the mutation.
*/
status: MutationStatus
/**
* The variables the mutation was last called with.
*/
variables: TVariables | undefined
/**
* The timestamp for when the mutation was submitted.
*/
submittedAt: number
}
interface FailedAction<TError> {
type: 'failed'
failureCount: number
error: TError | null
}
interface PendingAction<TVariables, TOnMutateResult> {
type: 'pending'
isPaused: boolean
variables?: TVariables
context?: TOnMutateResult
}
interface SuccessAction<TData> {
type: 'success'
data: TData
}
interface ErrorAction<TError> {
type: 'error'
error: TError
}
interface PauseAction {
type: 'pause'
}
interface ContinueAction {
type: 'continue'
}
export type Action<TData, TError, TVariables, TOnMutateResult> =
| ContinueAction
| ErrorAction<TError>
| FailedAction<TError>
| PendingAction<TVariables, TOnMutateResult>
| PauseAction
| SuccessAction<TData>
// CLASS
/**
* Represents a single mutation attempt. A `Mutation` holds the mutation's
* options, state (data/error/status), and the `MutationObserver`s currently
* subscribed to it.
*
* Instances are created and managed internally by `MutationCache`; application
* code typically interacts with mutations indirectly through `QueryClient` or
* a framework hook like `useMutation`. Direct access to a `Mutation` instance
* is possible via `mutationCache.find()`/`getAll()` for inspecting cache state.
*
* @example
* ```ts
* const mutationCache = queryClient.getMutationCache()
*
* const mutation = mutationCache.find({ mutationKey: ['addPost'] })
* ```
*/
export class Mutation<
TData = unknown,
TError = DefaultError,
TVariables = unknown,
TOnMutateResult = unknown,
> extends Removable {
state: MutationState<TData, TError, TVariables, TOnMutateResult>
options!: MutationOptions<TData, TError, TVariables, TOnMutateResult>
readonly mutationId: number
#client: QueryClient
#observers: Array<
MutationObserver<TData, TError, TVariables, TOnMutateResult>
>
#mutationCache: MutationCache
#retryer?: Retryer<TData>
constructor(
config: MutationConfig<TData, TError, TVariables, TOnMutateResult>,
) {
super()
this.#client = config.client
this.mutationId = config.mutationId
this.#mutationCache = config.mutationCache
this.#observers = []
this.state = config.state || getDefaultState()
this.setOptions(config.options)
this.scheduleGc()
}
/** @internal */
setOptions(
options: MutationOptions<TData, TError, TVariables, TOnMutateResult>,
): void {
this.options = options
this.updateGcTime(this.options.gcTime)
}
/**
* The `meta` object passed in the mutation's options, if any.
*/
get meta(): MutationMeta | undefined {
return this.options.meta
}
/** @internal */
addObserver(observer: MutationObserver<any, any, any, any>): void {
if (!this.#observers.includes(observer)) {
this.#observers.push(observer)
// Stop the mutation from being garbage collected
this.clearGcTimeout()
this.#mutationCache.notify({
type: 'observerAdded',
mutation: this,
observer,
})
}
}
/** @internal */
removeObserver(observer: MutationObserver<any, any, any, any>): void {
this.#observers = this.#observers.filter((x) => x !== observer)
this.scheduleGc()
this.#mutationCache.notify({
type: 'observerRemoved',
mutation: this,
observer,
})
}
protected optionalRemove() {
if (!this.#observers.length) {
if (this.state.status === 'pending') {
this.scheduleGc()
} else {
this.#mutationCache.remove(this)
}
}
}
/**
* Resumes a mutation that is currently paused or was restored from a
* dehydrated, still-`pending` state.
*
* - If this mutation has an active retryer (it paused mid-attempt, e.g. due
* to the network mode or scope-based queuing), its retryer is resumed.
* - Otherwise, if the mutation's status is still `pending` (e.g. it was
* dehydrated while an attempt was in flight and never got a retryer in
* this instance), `execute` is called again with the last known variables.
* - Otherwise the mutation has already settled and this resolves immediately
* without running anything again.
*
* @example
* ```ts
* // typically driven by reconnect handling, e.g. queryClient.resumePausedMutations()
* const mutation = mutationCache.find({ mutationKey: ['addPost'] })
* await mutation?.continue()
* ```
*
* @see {@link Mutation#execute}
*/
continue(): Promise<unknown> {
return (
this.#retryer?.continue() ??
// continuing a mutation assumes that variables are set, mutation must have been dehydrated before.
// a settled mutation has no retryer to continue and must not run again
(this.state.status === 'pending'
? this.execute(this.state.variables!)
: Promise.resolve())
)
}
/**
* Runs the mutation function for the given variables through a retryer, and
* drives the mutation's state and lifecycle callbacks through to settlement.
*
* If this mutation's state is already `pending` when `execute` is called
* (i.e. it was restored, still in-flight, from a dehydrated state), the
* `onMutate` step is skipped and a `continue` action is dispatched to
* unpause it; otherwise a `pending` action is dispatched first, then the
* mutation cache's `onMutate` and the mutation's own `onMutate` option are
* awaited in that order, and the resulting context is stored.
*
* The mutation function is then run (subject to `retry`/`retryDelay`/
* `networkMode`, and to the mutation cache's scope-based serialization).
* On success, the cache's `onSuccess`/`onSettled` callbacks run before the
* mutation's own `onSuccess`/`onSettled` options, a `success` action is
* dispatched, and the resolved data is returned. On failure, the same
* cache-then-option ordering is used for `onError`/`onSettled`, but each of
* those four callbacks is individually caught so that a throwing callback
* cannot mask the original error; an `error` action is then dispatched and
* the original error is re-thrown.
*
* @example
* ```ts
* // Called internally by `MutationObserver.mutate` and `Mutation.continue` —
* // applications normally trigger mutations through those, not this method.
* const data = await mutation.execute(variables)
* ```
*
* @see {@link Mutation#continue}
*/
async execute(variables: TVariables): Promise<TData> {
const onContinue = () => {
this.#dispatch({ type: 'continue' })
}
const mutationFnContext = {
client: this.#client,
meta: this.options.meta,
mutationKey: this.options.mutationKey,
} satisfies MutationFunctionContext
const retryer = (this.#retryer = createRetryer({
fn: () => {
if (!this.options.mutationFn) {
return Promise.reject(new Error('No mutationFn found'))
}
return this.options.mutationFn(variables, mutationFnContext)
},
onFail: (failureCount, error) => {
this.#dispatch({ type: 'failed', failureCount, error })
},
onPause: () => {
this.#dispatch({ type: 'pause' })
},
onContinue,
retry: this.options.retry ?? 0,
retryDelay: this.options.retryDelay,
networkMode: this.options.networkMode,
canRun: () => this.#mutationCache.canRun(this),
}))
const restored = this.state.status === 'pending'
const isPaused = !retryer.canStart()
try {
if (restored) {
// Dispatch continue action to unpause restored mutation
onContinue()
} else {
this.#dispatch({ type: 'pending', variables, isPaused })
// Notify cache callback
if (this.#mutationCache.config.onMutate) {
await this.#mutationCache.config.onMutate(
variables,
this as Mutation<unknown, unknown, unknown, unknown>,
mutationFnContext,
)
}
const context = await this.options.onMutate?.(
variables,
mutationFnContext,
)
if (context !== this.state.context) {
this.#dispatch({
type: 'pending',
context,
variables,
isPaused,
})
}
}
const data = await retryer.start()
// Notify cache callback
await this.#mutationCache.config.onSuccess?.(
data,
variables,
this.state.context,
this as Mutation<unknown, unknown, unknown, unknown>,
mutationFnContext,
)
await this.options.onSuccess?.(
data,
variables,
this.state.context!,
mutationFnContext,
)
// Notify cache callback
await this.#mutationCache.config.onSettled?.(
data,
null,
this.state.variables,
this.state.context,
this as Mutation<unknown, unknown, unknown, unknown>,
mutationFnContext,
)
await this.options.onSettled?.(
data,
null,
variables,
this.state.context,
mutationFnContext,
)
this.#dispatch({ type: 'success', data })
return data
} catch (error) {
try {
// Notify cache callback
await this.#mutationCache.config.onError?.(
error as any,
variables,
this.state.context,
this as Mutation<unknown, unknown, unknown, unknown>,
mutationFnContext,
)
} catch (e) {
void Promise.reject(e)
}
try {
await this.options.onError?.(
error as TError,
variables,
this.state.context,
mutationFnContext,
)
} catch (e) {
void Promise.reject(e)
}
try {
// Notify cache callback
await this.#mutationCache.config.onSettled?.(
undefined,
error as any,
this.state.variables,
this.state.context,
this as Mutation<unknown, unknown, unknown, unknown>,
mutationFnContext,
)
} catch (e) {
void Promise.reject(e)
}
try {
await this.options.onSettled?.(
undefined,
error as TError,
variables,
this.state.context,
mutationFnContext,
)
} catch (e) {
void Promise.reject(e)
}
this.#dispatch({ type: 'error', error: error as TError })
throw error
} finally {
// The settled retryer's promise would otherwise pin this mutation's
// result, variables and context for as long as the cache keeps it
if (this.#retryer === retryer) {
this.#retryer = undefined
}
this.#mutationCache.runNext(this)
}
}
#dispatch(action: Action<TData, TError, TVariables, TOnMutateResult>): void {
const reducer = (
state: MutationState<TData, TError, TVariables, TOnMutateResult>,
): MutationState<TData, TError, TVariables, TOnMutateResult> => {
switch (action.type) {
case 'failed':
return {
...state,
failureCount: action.failureCount,
failureReason: action.error,
}
case 'pause':
return {
...state,
isPaused: true,
}
case 'continue':
return {
...state,
isPaused: false,
}
case 'pending':
return {
...state,
context: action.context,
data: undefined,
failureCount: 0,
failureReason: null,
error: null,
isPaused: action.isPaused,
status: 'pending',
variables: action.variables,
submittedAt: Date.now(),
}
case 'success':
return {
...state,
data: action.data,
failureCount: 0,
failureReason: null,
error: null,
status: 'success',
isPaused: false,
}
case 'error':
return {
...state,
data: undefined,
error: action.error,
failureCount: state.failureCount + 1,
failureReason: action.error,
isPaused: false,
status: 'error',
}
}
}
this.state = reducer(this.state)
notifyManager.batch(() => {
this.#observers.forEach((observer) => {
observer.onMutationUpdate(action)
})
this.#mutationCache.notify({
mutation: this,
type: 'updated',
action,
})
})
}
}
export function getDefaultState<
TData,
TError,
TVariables,
TOnMutateResult,
>(): MutationState<TData, TError, TVariables, TOnMutateResult> {
return {
context: undefined,
data: undefined,
error: null,
failureCount: 0,
failureReason: null,
isPaused: false,
status: 'idle',
variables: undefined,
submittedAt: 0,
}
}

View File

@@ -0,0 +1,335 @@
import { notifyManager } from './notifyManager'
import { Mutation } from './mutation'
import { matchMutation, noop } from './utils'
import { Subscribable } from './subscribable'
import type { MutationObserver } from './mutationObserver'
import type {
DefaultError,
MutationFunctionContext,
MutationOptions,
NotifyEvent,
} from './types'
import type { QueryClient } from './queryClient'
import type { Action, MutationState } from './mutation'
import type { MutationFilters } from './utils'
// TYPES
/**
* Global callbacks that fire for every mutation handled by a `MutationCache`, regardless of which
* component or observer triggered it. They differ from the `defaultOptions` provided to a
* `QueryClient` in two ways: `defaultOptions` can be overridden by each mutation, while these
* callbacks are always called, and `onMutate` here does not allow returning a result.
*
* If a callback returns a promise, it will be awaited before the mutation continues.
*/
export interface MutationCacheConfig {
/** Called when any mutation in the cache encounters an error. */
onError?: (
error: DefaultError,
variables: unknown,
onMutateResult: unknown,
mutation: Mutation<unknown, unknown, unknown>,
context: MutationFunctionContext,
) => Promise<unknown> | unknown
/** Called when any mutation in the cache is successful. */
onSuccess?: (
data: unknown,
variables: unknown,
onMutateResult: unknown,
mutation: Mutation<unknown, unknown, unknown>,
context: MutationFunctionContext,
) => Promise<unknown> | unknown
/** Called before any mutation in the cache executes. */
onMutate?: (
variables: unknown,
mutation: Mutation<unknown, unknown, unknown>,
context: MutationFunctionContext,
) => Promise<unknown> | unknown
/** Called when any mutation in the cache is settled, either successfully or with an error. */
onSettled?: (
data: unknown | undefined,
error: DefaultError | null,
variables: unknown,
onMutateResult: unknown,
mutation: Mutation<unknown, unknown, unknown>,
context: MutationFunctionContext,
) => Promise<unknown> | unknown
}
interface NotifyEventMutationAdded extends NotifyEvent {
type: 'added'
mutation: Mutation<any, any, any, any>
}
interface NotifyEventMutationRemoved extends NotifyEvent {
type: 'removed'
mutation: Mutation<any, any, any, any>
}
interface NotifyEventMutationObserverAdded extends NotifyEvent {
type: 'observerAdded'
mutation: Mutation<any, any, any, any>
observer: MutationObserver<any, any, any>
}
interface NotifyEventMutationObserverRemoved extends NotifyEvent {
type: 'observerRemoved'
mutation: Mutation<any, any, any, any>
observer: MutationObserver<any, any, any>
}
interface NotifyEventMutationObserverOptionsUpdated extends NotifyEvent {
type: 'observerOptionsUpdated'
mutation?: Mutation<any, any, any, any>
observer: MutationObserver<any, any, any, any>
}
interface NotifyEventMutationUpdated extends NotifyEvent {
type: 'updated'
mutation: Mutation<any, any, any, any>
action: Action<any, any, any, any>
}
/**
* The event passed to a `MutationCache` subscriber. Fired whenever a mutation is added or removed
* from the cache, its state is updated, or one of its observers is added, removed, or has its
* options updated.
*/
export type MutationCacheNotifyEvent =
| NotifyEventMutationAdded
| NotifyEventMutationRemoved
| NotifyEventMutationObserverAdded
| NotifyEventMutationObserverRemoved
| NotifyEventMutationObserverOptionsUpdated
| NotifyEventMutationUpdated
type MutationCacheListener = (event: MutationCacheNotifyEvent) => void
// CLASS
/**
* The `MutationCache` is the storage for mutations.
*
* Normally, you will not interact with the `MutationCache` directly and instead use a
* `QueryClient`. You can subscribe to it (inherited from `Subscribable`) to be informed of
* safe/known updates to the cache, such as mutations being added, removed, or updated.
*
* @example
* ```ts
* const unsubscribe = mutationCache.subscribe((event) => {
* console.log(event.type, event.mutation)
* })
* ```
*/
export class MutationCache extends Subscribable<MutationCacheListener> {
#mutations: Set<Mutation<any, any, any, any>>
#scopes: Map<string, Array<Mutation<any, any, any, any>>>
#mutationId: number
constructor(public config: MutationCacheConfig = {}) {
super()
this.#mutations = new Set()
this.#scopes = new Map()
this.#mutationId = 0
}
/** @internal */
build<TData, TError, TVariables, TOnMutateResult>(
client: QueryClient,
options: MutationOptions<TData, TError, TVariables, TOnMutateResult>,
state?: MutationState<TData, TError, TVariables, TOnMutateResult>,
): Mutation<TData, TError, TVariables, TOnMutateResult> {
const mutation = new Mutation({
client,
mutationCache: this,
mutationId: ++this.#mutationId,
options: client.defaultMutationOptions(options),
state,
})
this.add(mutation)
return mutation
}
/** @internal */
add(mutation: Mutation<any, any, any, any>): void {
this.#mutations.add(mutation)
const scope = scopeFor(mutation)
if (typeof scope === 'string') {
const scopedMutations = this.#scopes.get(scope)
if (scopedMutations) {
scopedMutations.push(mutation)
} else {
this.#scopes.set(scope, [mutation])
}
}
this.notify({ type: 'added', mutation })
}
/** @internal */
remove(mutation: Mutation<any, any, any, any>): void {
if (this.#mutations.delete(mutation)) {
const scope = scopeFor(mutation)
if (typeof scope === 'string') {
const scopedMutations = this.#scopes.get(scope)
if (scopedMutations) {
if (scopedMutations.length > 1) {
const index = scopedMutations.indexOf(mutation)
if (index !== -1) {
scopedMutations.splice(index, 1)
}
} else if (scopedMutations[0] === mutation) {
this.#scopes.delete(scope)
}
}
}
}
// Currently we notify the removal even if the mutation was already removed.
// Consider making this an error or not notifying of the removal depending on the desired semantics.
this.notify({ type: 'removed', mutation })
}
/** @internal */
canRun(mutation: Mutation<any, any, any, any>): boolean {
const scope = scopeFor(mutation)
if (typeof scope === 'string') {
const mutationsWithSameScope = this.#scopes.get(scope)
const firstPendingMutation = mutationsWithSameScope?.find(
(m) => m.state.status === 'pending',
)
// we can run if there is no current pending mutation (start use-case)
// or if WE are the first pending mutation (continue use-case)
return !firstPendingMutation || firstPendingMutation === mutation
} else {
// For unscoped mutations there are never any pending mutations in front of the
// current mutation
return true
}
}
/** @internal */
runNext(mutation: Mutation<any, any, any, any>): Promise<unknown> {
const scope = scopeFor(mutation)
if (typeof scope === 'string') {
const foundMutation = this.#scopes
.get(scope)
?.find((m) => m !== mutation && m.state.isPaused)
return foundMutation?.continue() ?? Promise.resolve()
} else {
return Promise.resolve()
}
}
/**
* Removes all mutations from the cache.
*
* @example
* ```ts
* const mutationCache = queryClient.getMutationCache()
*
* mutationCache.clear()
* ```
*/
clear(): void {
notifyManager.batch(() => {
this.#mutations.forEach((mutation) => {
this.notify({ type: 'removed', mutation })
})
this.#mutations.clear()
this.#scopes.clear()
})
}
/**
* Returns all mutations within the cache.
*
* This is not typically needed for most applications, but can come in handy when needing more
* information about a mutation in rare scenarios.
*
* @example
* ```ts
* const mutationCache = queryClient.getMutationCache()
*
* const mutations = mutationCache.getAll()
* ```
*/
getAll(): Array<Mutation> {
return Array.from(this.#mutations)
}
/**
* A slightly more advanced method that can be used to get an existing mutation instance from
* the cache. If the mutation does not exist, `undefined` is returned.
*
* This is not typically needed for most applications, but can come in handy when needing more
* information about a mutation in rare scenarios.
*
* @see {@link MutationCache#findAll}
* @example
* ```ts
* const mutationCache = queryClient.getMutationCache()
*
* const mutation = mutationCache.find({ mutationKey: ['addPost'] })
* ```
*/
find<
TData = unknown,
TError = DefaultError,
TVariables = any,
TOnMutateResult = unknown,
>(
filters: MutationFilters,
): Mutation<TData, TError, TVariables, TOnMutateResult> | undefined {
const defaultedFilters = { exact: true, ...filters }
return this.getAll().find((mutation) =>
matchMutation(defaultedFilters, mutation),
) as Mutation<TData, TError, TVariables, TOnMutateResult> | undefined
}
/**
* An even more advanced method that can be used to get existing mutation instances from the
* cache that match the given filters. If no mutations match, an empty array is returned.
*
* This is not typically needed for most applications, but can come in handy when needing more
* information about mutations in rare scenarios.
*
* @see {@link MutationCache#find}
* @example
* ```ts
* const mutationCache = queryClient.getMutationCache()
*
* const mutations = mutationCache.findAll({ mutationKey: ['addPost'] })
* ```
*/
findAll(filters: MutationFilters = {}): Array<Mutation> {
return this.getAll().filter((mutation) => matchMutation(filters, mutation))
}
/** @internal */
notify(event: MutationCacheNotifyEvent) {
notifyManager.batch(() => {
this.listeners.forEach((listener) => {
listener(event)
})
})
}
/** @internal */
resumePausedMutations(): Promise<unknown> {
const pausedMutations = this.getAll().filter((x) => x.state.isPaused)
return notifyManager.batch(() =>
Promise.all(
pausedMutations.map((mutation) => mutation.continue().catch(noop)),
),
)
}
}
function scopeFor(mutation: Mutation<any, any, any, any>) {
return mutation.options.scope?.id
}

View File

@@ -0,0 +1,306 @@
import { getDefaultState } from './mutation'
import { notifyManager } from './notifyManager'
import { Subscribable } from './subscribable'
import { hashKey, shallowEqualObjects } from './utils'
import type { QueryClient } from './queryClient'
import type {
DefaultError,
MutateOptions,
MutationFunctionContext,
MutationObserverOptions,
MutationObserverResult,
} from './types'
import type { Action, Mutation } from './mutation'
// TYPES
type MutationObserverListener<TData, TError, TVariables, TOnMutateResult> = (
result: MutationObserverResult<TData, TError, TVariables, TOnMutateResult>,
) => void
// CLASS
/**
* Observes a single mutation and derives a `MutationObserverResult` from it.
* A framework hook like `useMutation` creates one `MutationObserver` per hook
* call, keeps it stable across re-renders, calls `setOptions` when the options
* passed to the hook change, subscribes to it to re-render on updates, and
* reads `getCurrentResult()` for the value to return. Calling `mutate()`
* builds a new underlying `Mutation` in the `MutationCache` and executes it.
*
* @example
* ```ts
* const observer = new MutationObserver(queryClient, {
* mutationFn: (variables: { title: string }) => addPost(variables),
* })
* ```
*/
export class MutationObserver<
TData = unknown,
TError = DefaultError,
TVariables = void,
TOnMutateResult = unknown,
> extends Subscribable<
MutationObserverListener<TData, TError, TVariables, TOnMutateResult>
> {
options!: MutationObserverOptions<TData, TError, TVariables, TOnMutateResult>
#client: QueryClient
#currentResult: MutationObserverResult<
TData,
TError,
TVariables,
TOnMutateResult
> = undefined!
#currentMutation?: Mutation<TData, TError, TVariables, TOnMutateResult>
#mutateOptions?: MutateOptions<TData, TError, TVariables, TOnMutateResult>
constructor(
client: QueryClient,
options: MutationObserverOptions<
TData,
TError,
TVariables,
TOnMutateResult
>,
) {
super()
this.#client = client
this.setOptions(options)
this.bindMethods()
this.#updateResult()
}
protected bindMethods(): void {
this.mutate = this.mutate.bind(this)
this.reset = this.reset.bind(this)
}
/**
* Updates the observer's options.
*
* If the new `mutationKey` differs from the previous one (and both were
* defined), the observer is reset, detaching it from the mutation it was
* observing. Otherwise, if the currently observed mutation is still
* `pending`, its options are updated in place as well.
*
* @example
* ```ts
* observer.setOptions({
* mutationFn: (variables: { title: string }) => addPost(variables),
* onSuccess: (data) => console.log(data),
* })
* ```
*/
setOptions(
options: MutationObserverOptions<
TData,
TError,
TVariables,
TOnMutateResult
>,
) {
const prevOptions = this.options as
| MutationObserverOptions<TData, TError, TVariables, TOnMutateResult>
| undefined
this.options = this.#client.defaultMutationOptions(options)
if (!shallowEqualObjects(this.options, prevOptions)) {
this.#client.getMutationCache().notify({
type: 'observerOptionsUpdated',
mutation: this.#currentMutation,
observer: this,
})
}
if (
prevOptions?.mutationKey &&
this.options.mutationKey &&
hashKey(prevOptions.mutationKey) !== hashKey(this.options.mutationKey)
) {
this.reset()
} else if (this.#currentMutation?.state.status === 'pending') {
this.#currentMutation.setOptions(this.options)
}
}
protected onSubscribe(): void {
if (this.listeners.size === 1 && this.#currentMutation) {
this.#currentMutation.addObserver(this)
this.#updateResult()
}
}
protected onUnsubscribe(): void {
if (!this.hasListeners()) {
this.#currentMutation?.removeObserver(this)
}
}
/** @internal */
onMutationUpdate(
action: Action<TData, TError, TVariables, TOnMutateResult>,
): void {
this.#updateResult()
this.#notify(action)
}
/**
* Returns the observer's current result, derived from the observed
* mutation's state (or the default, `idle` state if no mutation has been
* built yet, e.g. before the first `mutate()` call or after `reset()`).
*/
getCurrentResult(): MutationObserverResult<
TData,
TError,
TVariables,
TOnMutateResult
> {
return this.#currentResult
}
/**
* Detaches the observer from the mutation it is currently observing (if
* any) and resets the observed result back to its default, `idle` state.
*
* This does not cancel an in-flight mutation; the mutation itself keeps
* running to completion and its own callbacks still fire, but this
* observer stops reflecting its state and a subsequent `mutate()` call
* will build a brand new mutation.
*
* @example
* ```ts
* observer.reset()
* ```
*
* @see {@link MutationObserver#mutate}
*/
reset(): void {
// reset needs to remove the observer from the mutation because there is no way to "get it back"
// another mutate call will yield a new mutation!
this.#currentMutation?.removeObserver(this)
this.#currentMutation = undefined
this.#updateResult()
this.#notify()
}
/**
* Builds a new `Mutation` in the `MutationCache` using the observer's
* current options, detaches this observer from any previously observed
* mutation, attaches it to the new one, and executes it with the given
* variables.
*
* The optional per-call `options` (`onSuccess`/`onError`/`onSettled`) are
* invoked once the mutation settles, in addition to any callbacks defined
* on the observer's own options.
*
* @example
* ```ts
* await observer.mutate(
* { title: 'New post' },
* { onSuccess: (data) => console.log(data) },
* )
* ```
*/
mutate(
variables: TVariables,
options?: MutateOptions<TData, TError, TVariables, TOnMutateResult>,
): Promise<TData> {
this.#mutateOptions = options
this.#currentMutation?.removeObserver(this)
this.#currentMutation = this.#client
.getMutationCache()
.build(this.#client, this.options)
this.#currentMutation.addObserver(this)
return this.#currentMutation.execute(variables)
}
#updateResult(): void {
const state =
this.#currentMutation?.state ??
getDefaultState<TData, TError, TVariables, TOnMutateResult>()
this.#currentResult = {
...state,
isPending: state.status === 'pending',
isSuccess: state.status === 'success',
isError: state.status === 'error',
isIdle: state.status === 'idle',
mutate: this.mutate,
reset: this.reset,
} as MutationObserverResult<TData, TError, TVariables, TOnMutateResult>
}
#notify(action?: Action<TData, TError, TVariables, TOnMutateResult>): void {
notifyManager.batch(() => {
// First trigger the mutate callbacks
if (this.#mutateOptions && this.hasListeners()) {
const variables = this.#currentResult.variables!
const onMutateResult = this.#currentResult.context
const context = {
client: this.#client,
meta: this.options.meta,
mutationKey: this.options.mutationKey,
} satisfies MutationFunctionContext
if (action?.type === 'success') {
try {
this.#mutateOptions.onSuccess?.(
action.data,
variables,
onMutateResult,
context,
)
} catch (e) {
void Promise.reject(e)
}
try {
this.#mutateOptions.onSettled?.(
action.data,
null,
variables,
onMutateResult,
context,
)
} catch (e) {
void Promise.reject(e)
}
} else if (action?.type === 'error') {
try {
this.#mutateOptions.onError?.(
action.error,
variables,
onMutateResult,
context,
)
} catch (e) {
void Promise.reject(e)
}
try {
this.#mutateOptions.onSettled?.(
undefined,
action.error,
variables,
onMutateResult,
context,
)
} catch (e) {
void Promise.reject(e)
}
}
}
// Then trigger the listeners
this.listeners.forEach((listener) => {
listener(this.#currentResult)
})
})
}
}

View File

@@ -0,0 +1,144 @@
// TYPES
import { systemSetTimeoutZero } from './timeoutManager'
type NotifyCallback = () => void
type NotifyFunction = (callback: () => void) => void
type BatchNotifyFunction = (callback: () => void) => void
type BatchCallsCallback<T extends Array<unknown>> = (...args: T) => void
type ScheduleFunction = (callback: () => void) => void
/**
* Default scheduling function used by the notify manager.
* Schedules the callback with the system's `setTimeout(callback, 0)`.
*/
export const defaultScheduler: ScheduleFunction = systemSetTimeoutZero
export function createNotifyManager() {
let queue: Array<NotifyCallback> = []
let transactions = 0
let notifyFn: NotifyFunction = (callback) => {
callback()
}
let batchNotifyFn: BatchNotifyFunction = (callback: () => void) => {
callback()
}
let scheduleFn = defaultScheduler
const schedule = (callback: NotifyCallback): void => {
if (transactions) {
queue.push(callback)
} else {
scheduleFn(() => {
notifyFn(callback)
})
}
}
const flush = (): void => {
const originalQueue = queue
queue = []
if (originalQueue.length) {
scheduleFn(() => {
batchNotifyFn(() => {
originalQueue.forEach((callback) => {
notifyFn(callback)
})
})
})
}
}
return {
/**
* Batches all updates scheduled inside the passed callback.
* This is mainly used internally to optimize query client updating.
* Batches can be nested; the queue is only flushed once the outermost `batch` call finishes.
* The return value of `callback` is passed through.
*/
batch: <T>(callback: () => T): T => {
let result
transactions++
try {
result = callback()
} finally {
transactions--
if (!transactions) {
flush()
}
}
return result
},
/**
* All calls to the wrapped function will be batched.
*/
batchCalls: <T extends Array<unknown>>(
callback: BatchCallsCallback<T>,
): BatchCallsCallback<T> => {
return (...args) => {
schedule(() => {
callback(...args)
})
}
},
/**
* Schedules a function to be run on the next batch.
* By default, the batch is run with a `setTimeout`, but this can be configured via `setScheduler`.
*/
schedule,
/**
* Use this method to set a custom notify function.
* This can be used to for example wrap notifications with `React.act` while running tests.
*/
setNotifyFunction: (fn: NotifyFunction) => {
notifyFn = fn
},
/**
* Use this method to set a custom function to batch notifications together into a single tick.
* Framework adapters use this to plug in their own batching primitive, so that a single query
* update only triggers one re-render instead of one per subscriber.
*
* @example
* ```ts
* import { notifyManager } from '@tanstack/query-core'
* import { batch } from 'solid-js'
*
* notifyManager.setBatchNotifyFunction(batch)
* ```
*/
setBatchNotifyFunction: (fn: BatchNotifyFunction) => {
batchNotifyFn = fn
},
/**
* Configures a custom callback that schedules when the next batch runs.
* The default behavior is `setTimeout(callback, 0)`.
*
* @example
* ```ts
* import { notifyManager } from '@tanstack/query-core'
*
* // Schedule batches in the next microtask
* notifyManager.setScheduler(queueMicrotask)
*
* // Schedule batches before the next frame is rendered
* notifyManager.setScheduler(requestAnimationFrame)
*
* // Schedule batches some time in the future
* notifyManager.setScheduler((cb) => setTimeout(cb, 10))
* ```
*/
setScheduler: (fn: ScheduleFunction) => {
scheduleFn = fn
},
} as const
}
// SINGLETON
/**
* Handles scheduling and batching callbacks in TanStack Query.
*/
export const notifyManager = createNotifyManager()

View File

@@ -0,0 +1,117 @@
import { Subscribable } from './subscribable'
type Listener = (online: boolean) => void
type SetupFn = (setOnline: Listener) => (() => void) | undefined
/**
* The `OnlineManager` manages the online state within TanStack Query. It can
* be used to change the default event listeners or to manually change the
* online state.
*
* By default, the `onlineManager` assumes an active network connection, and
* listens to the `online` and `offline` events on the `window` object to
* detect changes.
*/
export class OnlineManager extends Subscribable<Listener> {
#online = true
#cleanup?: () => void
#setup: SetupFn
constructor() {
super()
this.#setup = (onOnline) => {
// addEventListener does not exist in React Native, but window does
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (typeof window !== 'undefined' && window.addEventListener) {
const onlineListener = () => onOnline(true)
const offlineListener = () => onOnline(false)
// Listen to online
window.addEventListener('online', onlineListener, false)
window.addEventListener('offline', offlineListener, false)
return () => {
// Be sure to unsubscribe if a new handler is set
window.removeEventListener('online', onlineListener)
window.removeEventListener('offline', offlineListener)
}
}
return
}
}
protected onSubscribe(): void {
if (!this.#cleanup) {
this.setEventListener(this.#setup)
}
}
protected onUnsubscribe() {
if (!this.hasListeners()) {
this.#cleanup?.()
this.#cleanup = undefined
}
}
/**
* `setEventListener` can be used to set a custom event listener that will
* be used to determine the online state. The provided `setup` function
* receives a `setOnline` callback that should be called with a `boolean`
* whenever the online state changes.
*
* @example
* ```ts
* import NetInfo from '@react-native-community/netinfo'
* import { onlineManager } from '@tanstack/query-core'
*
* onlineManager.setEventListener((setOnline) => {
* return NetInfo.addEventListener((state) => {
* setOnline(!!state.isConnected)
* })
* })
* ```
*/
setEventListener(setup: SetupFn): void {
this.#setup = setup
this.#cleanup?.()
this.#cleanup = setup(this.setOnline.bind(this))
}
/**
* `setOnline` can be used to manually set the online state.
*
* @example
* ```ts
* import { onlineManager } from '@tanstack/query-core'
*
* // Set to online
* onlineManager.setOnline(true)
*
* // Set to offline
* onlineManager.setOnline(false)
* ```
*/
setOnline(online: boolean): void {
const changed = this.#online !== online
if (changed) {
this.#online = online
this.listeners.forEach((listener) => {
listener(online)
})
}
}
/**
* `isOnline` can be used to get the current online state.
*/
isOnline(): boolean {
return this.#online
}
}
/**
* Singleton instance of {@link OnlineManager}, used to manage and observe the online state within TanStack Query.
*/
export const onlineManager = new OnlineManager()

View File

@@ -0,0 +1,402 @@
import { notifyManager } from './notifyManager'
import { QueryObserver } from './queryObserver'
import { Subscribable } from './subscribable'
import { replaceEqualDeep, shallowEqualObjects } from './utils'
import type {
DefaultedQueryObserverOptions,
QueryObserverOptions,
QueryObserverResult,
} from './types'
import type { QueryClient } from './queryClient'
function difference<T>(array1: Array<T>, array2: Array<T>): Array<T> {
const excludeSet = new Set(array2)
return array1.filter((x) => !excludeSet.has(x))
}
type QueriesObserverListener = (result: Array<QueryObserverResult>) => void
type CombineFn<TCombinedResult> = (
result: Array<QueryObserverResult>,
) => TCombinedResult
export interface QueriesObserverOptions<
TCombinedResult = Array<QueryObserverResult>,
> {
/**
* A function that combines the array of `QueryObserverResult`s (one per
* observed query) into a single value. The combined value is memoized and
* only recomputed when one of the underlying results, the query hashes, or
* the `combine` function itself changes.
*
* Defaults to returning the array of `QueryObserverResult`s unchanged.
*/
combine?: CombineFn<TCombinedResult>
}
/**
* A `QueriesObserver` watches an array of queries at once, exposing them as
* a single array of `QueryObserverResult`s (or, when a `combine` option is
* given, as a combined value derived from that array). It manages one
* internal `QueryObserver` per query, and is the primitive that framework
* adapters (e.g. `useQueries`) build their hooks on top of.
*
* @example
* ```ts
* const observer = new QueriesObserver(queryClient, [
* { queryKey: ['post', 1], queryFn: fetchPost },
* { queryKey: ['post', 2], queryFn: fetchPost },
* ])
*
* const unsubscribe = observer.subscribe((result) => {
* console.log(result)
* })
* ```
*/
export class QueriesObserver<
TCombinedResult = Array<QueryObserverResult>,
> extends Subscribable<QueriesObserverListener> {
#client: QueryClient
#result!: Array<QueryObserverResult>
#queries: Array<QueryObserverOptions>
#options?: QueriesObserverOptions<TCombinedResult>
#observers: Array<QueryObserver>
#combinedResult!: TCombinedResult
#lastCombine?: CombineFn<TCombinedResult>
#lastResult?: Array<QueryObserverResult>
#lastQueryHashes?: Array<string>
#observerMatches: Array<QueryObserverMatch> = []
constructor(
client: QueryClient,
queries: Array<QueryObserverOptions<any, any, any, any, any>>,
options?: QueriesObserverOptions<TCombinedResult>,
) {
super()
this.#client = client
this.#options = options
this.#queries = []
this.#observers = []
this.#result = []
this.setQueries(queries)
}
protected onSubscribe(): void {
if (this.listeners.size === 1) {
this.#observers.forEach((observer) => {
observer.subscribe((result) => {
this.#onUpdate(observer, result)
})
})
}
}
protected onUnsubscribe(): void {
if (!this.listeners.size) {
this.destroy()
}
}
/**
* Stops observing all queries: clears all listeners and destroys every
* underlying `QueryObserver` this observer manages.
*/
destroy(): void {
this.listeners = new Set()
this.#observers.forEach((observer) => {
observer.destroy()
})
}
/**
* Replaces the set of queries being observed. Existing `QueryObserver`s
* are reused for queries that match an already-observed query hash;
* observers for queries that are no longer present are destroyed, and new
* observers are created and subscribed to for newly added queries.
*
* @example
* ```ts
* observer.setQueries([
* { queryKey: ['post', 1], queryFn: fetchPost },
* { queryKey: ['post', 3], queryFn: fetchPost },
* ])
* ```
*/
setQueries(
queries: Array<QueryObserverOptions>,
options?: QueriesObserverOptions<TCombinedResult>,
): void {
this.#queries = queries
this.#options = options
if (process.env.NODE_ENV !== 'production') {
const queryHashes = queries.map(
(query) => this.#client.defaultQueryOptions(query).queryHash,
)
if (new Set(queryHashes).size !== queryHashes.length) {
console.warn(
'[QueriesObserver]: Duplicate Queries found. This might result in unexpected behavior.',
)
}
}
notifyManager.batch(() => {
const prevObservers = this.#observers
const newObserverMatches = this.#findMatchingObservers(this.#queries)
// set options for the new observers to notify of changes
newObserverMatches.forEach((match) =>
match.observer.setOptions(match.defaultedQueryOptions),
)
const newObservers = newObserverMatches.map((match) => match.observer)
const newResult = newObservers.map((observer) =>
observer.getCurrentResult(),
)
const hasLengthChange = prevObservers.length !== newObservers.length
const hasIndexChange = newObservers.some(
(observer, index) => observer !== prevObservers[index],
)
const hasStructuralChange = hasLengthChange || hasIndexChange
const hasResultChange = hasStructuralChange
? true
: newResult.some((result, index) => {
const prev = this.#result[index]
return !prev || !shallowEqualObjects(result, prev)
})
if (!hasStructuralChange && !hasResultChange) return
if (hasStructuralChange) {
this.#observerMatches = newObserverMatches
this.#observers = newObservers
}
this.#result = newResult
if (!this.hasListeners()) return
if (hasStructuralChange) {
difference(prevObservers, newObservers).forEach((observer) => {
observer.destroy()
})
difference(newObservers, prevObservers).forEach((observer) => {
observer.subscribe((result) => {
this.#onUpdate(observer, result)
})
})
}
this.#notify()
})
}
/**
* Returns the most recently computed array of `QueryObserverResult`s, one
* per observed query, in the same order as the queries passed to the
* constructor or `setQueries`.
*
* @example
* ```ts
* const results = observer.getCurrentResult()
* const data = results.map((result) => result.data)
* ```
*/
getCurrentResult(): Array<QueryObserverResult> {
return this.#result
}
/**
* Returns the underlying `Query` instances currently being observed, in
* the same order as the queries passed to the constructor or `setQueries`.
*/
getQueries() {
return this.#observers.map((observer) => observer.getCurrentQuery())
}
/**
* Returns the underlying `QueryObserver` instances this observer manages,
* in the same order as the queries passed to the constructor or
* `setQueries`.
*/
getObservers() {
return this.#observers
}
/**
* The `QueriesObserver` counterpart of {@link QueryObserver#getOptimisticResult} — computes
* the result for the given (already-defaulted) queries right now, synchronously. Called by
* framework adapters (e.g. `useQueries`) ahead of subscribing, returning a tuple of the raw
* per-query results, a function to compute the combined result from them, and a function to
* wrap the results for property-access tracking.
*/
getOptimisticResult(
queries: Array<QueryObserverOptions>,
combine: CombineFn<TCombinedResult> | undefined,
): [
rawResult: Array<QueryObserverResult>,
combineResult: (r?: Array<QueryObserverResult>) => TCombinedResult,
trackResult: () => Array<QueryObserverResult>,
] {
const matches = this.#findMatchingObservers(queries)
const result = matches.map((match) =>
match.observer.getOptimisticResult(match.defaultedQueryOptions),
)
const queryHashes = matches.map(
(match) => match.defaultedQueryOptions.queryHash,
)
return [
result,
(r?: Array<QueryObserverResult>) => {
return this.#combineResult(r ?? result, combine, queryHashes)
},
() => {
return this.#trackResult(result, matches)
},
]
}
#trackResult(
result: Array<QueryObserverResult>,
matches: Array<QueryObserverMatch>,
) {
const trackedProps = new Set<keyof QueryObserverResult>()
return matches.map((match, index) => {
const observerResult = result[index]!
return !match.defaultedQueryOptions.notifyOnChangeProps
? match.observer.trackResult(observerResult, (accessedProp) => {
// track property on all observers to ensure proper (synchronized) tracking (#7000)
if (!trackedProps.has(accessedProp)) {
trackedProps.add(accessedProp)
matches.forEach((m) => {
m.observer.trackProp(accessedProp)
})
}
})
: observerResult
})
}
#combineResult(
input: Array<QueryObserverResult>,
combine: CombineFn<TCombinedResult> | undefined,
queryHashes?: Array<string>,
): TCombinedResult {
if (combine) {
const lastHashes = this.#lastQueryHashes
const queryHashesChanged =
queryHashes !== undefined &&
lastHashes !== undefined &&
(lastHashes.length !== queryHashes.length ||
queryHashes.some((hash, i) => hash !== lastHashes[i]))
if (
this.#result !== this.#lastResult ||
queryHashesChanged ||
combine !== this.#lastCombine
) {
this.#lastCombine = combine
this.#lastResult = this.#result
if (queryHashes !== undefined) {
this.#lastQueryHashes = queryHashes
}
this.#combinedResult = replaceEqualDeep(
this.#combinedResult,
combine(input),
)
}
return this.#combinedResult
}
return input as any
}
#shouldSkipCombine(): boolean {
return (
!this.#options?.combine ||
this.#observers.some((observer, index) => {
return (
observer.options.suspense && this.#result[index]?.data === undefined
)
})
)
}
#findMatchingObservers(
queries: Array<QueryObserverOptions>,
): Array<QueryObserverMatch> {
const prevObserversMap = new Map<string, Array<QueryObserver>>()
this.#observers.forEach((observer) => {
const key = observer.options.queryHash
if (!key) return
const previousObservers = prevObserversMap.get(key)
if (previousObservers) {
previousObservers.push(observer)
} else {
prevObserversMap.set(key, [observer])
}
})
const observers: Array<QueryObserverMatch> = []
queries.forEach((options) => {
const defaultedOptions = this.#client.defaultQueryOptions(options)
const match = prevObserversMap.get(defaultedOptions.queryHash)?.shift()
const observer =
match ?? new QueryObserver(this.#client, defaultedOptions)
observers.push({
defaultedQueryOptions: defaultedOptions,
observer,
})
})
return observers
}
#onUpdate(observer: QueryObserver, result: QueryObserverResult): void {
const index = this.#observers.indexOf(observer)
if (index !== -1) {
this.#result = this.#result.slice()
this.#result[index] = result
this.#notify()
}
}
#notify(): void {
if (this.hasListeners()) {
const shouldSkipCombine = this.#shouldSkipCombine()
const previousResult = this.#combinedResult
const newResult = shouldSkipCombine
? previousResult
: this.#combineResult(
this.#trackResult(this.#result, this.#observerMatches),
this.#options?.combine,
)
if (shouldSkipCombine || previousResult !== newResult) {
notifyManager.batch(() => {
this.listeners.forEach((listener) => {
listener(this.#result)
})
})
}
}
}
}
type QueryObserverMatch = {
defaultedQueryOptions: DefaultedQueryObserverOptions
observer: QueryObserver
}

View File

@@ -0,0 +1,973 @@
import {
ensureQueryFn,
noop,
replaceData,
resolveQueryValue,
skipToken,
timeUntilStale,
} from './utils'
import { notifyManager } from './notifyManager'
import { CancelledError, canFetch, createRetryer } from './retryer'
import { Removable } from './removable'
import { infiniteQueryBehavior } from './infiniteQueryBehavior'
import type { QueryCache } from './queryCache'
import type { QueryClient } from './queryClient'
import type {
CancelOptions,
DefaultError,
FetchStatus,
InitialDataFunction,
OmitKeyof,
QueryFunctionContext,
QueryKey,
QueryMeta,
QueryOptions,
QueryStatus,
SetDataOptions,
StaleTime,
} from './types'
import type { QueryObserver } from './queryObserver'
import type { Retryer } from './retryer'
// TYPES
interface QueryConfig<
TQueryFnData,
TError,
TData,
TQueryKey extends QueryKey = QueryKey,
> {
client: QueryClient
queryKey: TQueryKey
queryHash: string
options?: QueryOptions<TQueryFnData, TError, TData, TQueryKey>
defaultOptions?: QueryOptions<TQueryFnData, TError, TData, TQueryKey>
state?: QueryState<TData, TError>
}
/**
* The raw state stored on a `Query` instance. This is the underlying state
* that observer results (e.g. `QueryObserverResult`) are derived from.
*/
export interface QueryState<TData = unknown, TError = DefaultError> {
/**
* The last successfully resolved data for the query.
*/
data: TData | undefined
/**
* The number of times the query has successfully resolved.
*/
dataUpdateCount: number
/**
* The timestamp for when the query most recently returned the `status` as `"success"`.
*/
dataUpdatedAt: number
/**
* The error object for the query, if the last attempt resulted in an error.
* - Defaults to `null`.
*/
error: TError | null
/**
* The sum of all errors, incremented every time the query resolves with an error.
*/
errorUpdateCount: number
/**
* The timestamp for when the query most recently returned the `status` as `"error"`.
*/
errorUpdatedAt: number
/**
* The failure count for the current fetch.
* - Incremented every time the fetch fails.
* - Reset to `0` when the fetch succeeds.
*/
fetchFailureCount: number
/**
* The reason the current fetch failed, as reported by the retryer.
* - Reset to `null` when the fetch succeeds.
*/
fetchFailureReason: TError | null
/**
* Metadata passed to the currently in-flight (or most recent) fetch, e.g. the
* `fetchMore` direction for infinite queries.
*/
fetchMeta: FetchMeta | null
/**
* Whether the query has been marked as invalidated via `invalidate()`.
* - Reset to `false` whenever the query resolves successfully.
*/
isInvalidated: boolean
/**
* The status of the query.
* - `pending` if there's no cached data and no attempt was finished yet.
* - `error` if the last attempt resulted in an error.
* - `success` if the query has data.
*/
status: QueryStatus
/**
* The fetch status of the query.
* - `fetching`: the `queryFn` is currently executing.
* - `paused`: a fetch wanted to run but has been paused (see network mode).
* - `idle`: the query is not fetching.
*/
fetchStatus: FetchStatus
}
export interface FetchContext<
TQueryFnData,
TError,
TData,
TQueryKey extends QueryKey = QueryKey,
> {
fetchFn: () => unknown | Promise<unknown>
fetchOptions?: FetchOptions
signal: AbortSignal
options: QueryOptions<TQueryFnData, TError, TData, any>
client: QueryClient
queryKey: TQueryKey
state: QueryState<TData, TError>
}
export interface QueryBehavior<
TQueryFnData = unknown,
TError = DefaultError,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
> {
onFetch: (
context: FetchContext<TQueryFnData, TError, TData, TQueryKey>,
query: Query,
) => void
}
export type FetchDirection = 'forward' | 'backward'
export interface FetchMeta {
fetchMore?: { direction: FetchDirection }
}
export interface FetchOptions<TData = unknown> {
cancelRefetch?: boolean
meta?: FetchMeta
initialPromise?: Promise<TData>
}
interface FailedAction<TError> {
type: 'failed'
failureCount: number
error: TError
}
interface FetchAction {
type: 'fetch'
meta?: FetchMeta
}
interface SuccessAction<TData> {
data: TData | undefined
type: 'success'
dataUpdatedAt?: number
manual?: boolean
}
interface ErrorAction<TError> {
type: 'error'
error: TError
}
interface InvalidateAction {
type: 'invalidate'
}
interface PauseAction {
type: 'pause'
}
interface ContinueAction {
type: 'continue'
}
interface SetStateAction<TData, TError> {
type: 'setState'
state: Partial<QueryState<TData, TError>>
}
export type Action<TData, TError> =
| ContinueAction
| ErrorAction<TError>
| FailedAction<TError>
| FetchAction
| InvalidateAction
| PauseAction
| SetStateAction<TData, TError>
| SuccessAction<TData>
// CLASS
/**
* Represents a single cached query. A `Query` holds the query's key, options,
* state (data/error/status), and the observers currently subscribed to it.
*
* Instances are created and managed internally by `QueryCache`; application
* code typically interacts with queries indirectly through `QueryClient` or
* a framework hook like `useQuery`. Direct access to a `Query` instance is
* possible via `queryCache.find()`/`findAll()` for inspecting cache state.
*
* @example
* ```ts
* const queryCache = queryClient.getQueryCache()
* const query = queryCache.find({ queryKey: ['posts'] })
*
* if (query) {
* console.log(query.state.dataUpdatedAt)
* }
* ```
*/
export class Query<
TQueryFnData = unknown,
TError = DefaultError,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
> extends Removable {
queryKey: TQueryKey
queryHash: string
options!: QueryOptions<TQueryFnData, TError, TData, TQueryKey>
state: QueryState<TData, TError>
#queryType?: 'infinite'
#initialState: QueryState<TData, TError>
#revertState?: QueryState<TData, TError>
#cache: QueryCache
#client: QueryClient
#retryer?: Retryer<TData>
observers: Array<QueryObserver<any, any, any, any, any>>
#defaultOptions?: QueryOptions<TQueryFnData, TError, TData, TQueryKey>
#abortSignalConsumed: boolean
constructor(config: QueryConfig<TQueryFnData, TError, TData, TQueryKey>) {
super()
this.#abortSignalConsumed = false
this.#defaultOptions = config.defaultOptions
this.setOptions(config.options)
this.observers = []
this.#client = config.client
this.#cache = this.#client.getQueryCache()
this.queryKey = config.queryKey
this.queryHash = config.queryHash
this.#initialState = getDefaultState(this.options)
this.state = config.state ?? this.#initialState
this.scheduleGc()
}
/**
* The `meta` object passed in the query's options, if any.
*/
get meta(): QueryMeta | undefined {
return this.options.meta
}
/** @internal */
get queryType() {
return this.#queryType
}
/**
* The promise for the currently in-flight fetch, if the query is fetching.
* `undefined` when the query is not fetching.
*/
get promise(): Promise<TData> | undefined {
return this.#retryer?.promise
}
/** @internal */
setOptions(
options?: QueryOptions<TQueryFnData, TError, TData, TQueryKey>,
): void {
this.options = { ...this.#defaultOptions, ...options }
if (options?._type) {
this.#queryType = options._type
}
this.updateGcTime(this.options.gcTime)
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (this.state && this.state.data === undefined) {
const defaultState = getDefaultState(this.options)
if (defaultState.data !== undefined) {
this.setState(
successState(defaultState.data, defaultState.dataUpdatedAt),
)
this.#initialState = defaultState
}
}
}
protected optionalRemove() {
if (!this.observers.length && this.state.fetchStatus === 'idle') {
this.#cache.remove(this)
}
}
/** @internal */
setData(
newData: TData,
options?: SetDataOptions & { manual: boolean },
): TData {
const data = replaceData(this.state.data, newData, this.options)
// Set data and mark it as cached
this.#dispatch({
data,
type: 'success',
dataUpdatedAt: options?.updatedAt,
manual: options?.manual,
})
return data
}
/**
* Merges the given partial state directly into this query's state, notifying observers. Used
* by persistence and broadcast plugins to restore a state snapshot, and by devtools to let a
* user manually trigger a loading/error state or edit the cached data.
*/
setState(state: Partial<QueryState<TData, TError>>): void {
this.#dispatch({ type: 'setState', state })
}
/**
* Cancels the query's currently in-flight fetch, if any.
* - Returns a promise that resolves once the cancellation has settled.
* - If no fetch is in progress, resolves immediately.
*
* @example
* ```ts
* await query.cancel()
* ```
*/
cancel(options?: CancelOptions): Promise<void> {
const promise = this.#retryer?.promise
this.#retryer?.cancel(options)
return promise ? promise.then(noop).catch(noop) : Promise.resolve()
}
/**
* Clears the query's garbage collection timeout and silently cancels any
* in-flight fetch. Called by `QueryCache` when the query is removed from
* the cache.
*
* @see {@link Query#cancel}
*/
destroy(): void {
super.destroy()
this.cancel({ silent: true })
}
/** @internal */
get resetState(): QueryState<TData, TError> {
return this.#initialState
}
/**
* Resets the query back to its initial state (the state it had when it was
* first created, e.g. any `initialData`), destroying it first to cancel any
* in-flight fetch.
*/
reset(): void {
this.destroy()
this.setState(this.resetState)
}
/**
* Returns `true` if the query has at least one observer for which `enabled`
* does not resolve to `false`.
*/
isActive(): boolean {
return this.observers.some(
(observer) => resolveQueryValue(observer.options.enabled, this) !== false,
)
}
/**
* Returns `true` if the query is disabled, meaning it will not fetch
* automatically.
* - If the query has observers, it is disabled when none of them are active
* (see `isActive`).
* - If the query has no observers, it is disabled when its `queryFn` is
* `skipToken` or it has never been fetched.
*/
isDisabled(): boolean {
if (this.getObserversCount() > 0) {
return !this.isActive()
}
// if a query has no observers, it should still be considered disabled if it never attempted a fetch
return this.options.queryFn === skipToken || !this.isFetched()
}
/**
* Returns `true` if the query has been fetched, i.e. it has resolved with
* either data or an error at least once.
*/
isFetched() {
return this.state.dataUpdateCount + this.state.errorUpdateCount > 0
}
/**
* Returns `true` if the query has at least one observer configured with
* `staleTime: 'static'`, meaning it is treated as never stale.
*/
isStatic(): boolean {
if (this.getObserversCount() > 0) {
return this.observers.some(
(observer) =>
resolveQueryValue(observer.options.staleTime, this) === 'static',
)
}
return false
}
/**
* Returns `true` if the query is stale.
* - If the query has observers, defers to whether any observer's current
* result reports `isStale` (which accounts for each observer's own
* `staleTime` and `enabled` state).
* - If the query has no observers, it is considered stale when it has no
* data or has been invalidated.
*
* @see {@link Query#isStaleByTime}
* @example
* ```ts
* if (query.isStale()) {
* // refetch or otherwise treat the cached data as outdated
* }
* ```
*/
isStale(): boolean {
// check observers first, their `isStale` has the source of truth
// calculated with `isStaleByTime` and it takes `enabled` into account
if (this.getObserversCount() > 0) {
return this.observers.some(
(observer) => observer.getCurrentResult().isStale,
)
}
return this.state.data === undefined || this.state.isInvalidated
}
/**
* Returns `true` if the query's data is stale relative to the given
* `staleTime` (defaults to `0`).
* - A query with no data is always stale.
* - `staleTime: 'static'` is never stale.
* - An invalidated query is always stale.
* - Otherwise, staleness is based on elapsed time since `dataUpdatedAt`.
*
* @see {@link Query#isStale}
* @example
* ```ts
* const isStale = query.isStaleByTime(1000 * 60)
* ```
*/
isStaleByTime(staleTime: StaleTime = 0): boolean {
// no data is always stale
if (this.state.data === undefined) {
return true
}
// static is never stale
if (staleTime === 'static') {
return false
}
// if the query is invalidated, it is stale
if (this.state.isInvalidated) {
return true
}
return !timeUntilStale(this.state.dataUpdatedAt, staleTime)
}
/** @internal */
onFocus(): void {
const observer = this.observers.find((x) => x.shouldFetchOnWindowFocus())
observer?.refetch({ cancelRefetch: false })
// Continue fetch if currently paused
this.#retryer?.continue()
}
/** @internal */
onOnline(): void {
const observer = this.observers.find((x) => x.shouldFetchOnReconnect())
observer?.refetch({ cancelRefetch: false })
// Continue fetch if currently paused
this.#retryer?.continue()
}
/** @internal */
addObserver(observer: QueryObserver<any, any, any, any, any>): void {
if (!this.observers.includes(observer)) {
this.observers.push(observer)
// Stop the query from being garbage collected
this.clearGcTimeout()
this.#cache.notify({ type: 'observerAdded', query: this, observer })
}
}
/** @internal */
removeObserver(observer: QueryObserver<any, any, any, any, any>): void {
const index = this.observers.indexOf(observer)
if (index !== -1) {
this.observers.splice(index, 1)
if (!this.observers.length) {
// If the transport layer does not support cancellation
// we'll let the query continue so the result can be cached
if (this.#retryer) {
if (
this.#abortSignalConsumed ||
(this.state.fetchStatus === 'paused' &&
this.state.status === 'pending')
) {
this.#retryer.cancel({ revert: true })
} else {
this.#retryer.cancelRetry()
}
}
this.scheduleGc()
}
this.#cache.notify({ type: 'observerRemoved', query: this, observer })
}
}
/**
* Returns the number of observers currently subscribed to this query.
*
* @example
* ```ts
* if (query.getObserversCount() === 0) {
* // no component is currently watching this query
* }
* ```
*/
getObserversCount(): number {
return this.observers.length
}
/**
* Marks the query as invalidated, unless it is already invalidated. This
* updates `state.isInvalidated` and notifies observers, but does not by
* itself trigger a refetch.
*
* @example
* ```ts
* query.invalidate()
* ```
*/
invalidate(): void {
if (!this.state.isInvalidated) {
this.#dispatch({ type: 'invalidate' })
}
}
/**
* Fetches the query, i.e. runs its `queryFn` (through any configured
* retryer/behavior) and updates the query's state with the result.
* - If a fetch is already in flight, returns its promise instead of
* starting a new one, unless `fetchOptions.cancelRefetch` is set and the
* query already has data, in which case the current fetch is silently
* cancelled first.
* - If `options` is passed, it replaces the query's current options
* before fetching.
*/
async fetch(
options?: QueryOptions<TQueryFnData, TError, TData, TQueryKey>,
fetchOptions?: FetchOptions<TQueryFnData>,
): Promise<TData> {
if (
this.state.fetchStatus !== 'idle' &&
// If the promise in the retryer is already rejected, we have to definitely
// re-start the fetch; there is a chance that the query is still in a
// pending state when that happens
this.#retryer?.status() !== 'rejected'
) {
if (this.state.data !== undefined && fetchOptions?.cancelRefetch) {
// Silently cancel current fetch if the user wants to cancel refetch
this.cancel({ silent: true })
} else if (this.#retryer) {
// make sure that retries that were potentially cancelled due to unmounts can continue
this.#retryer.continueRetry()
// Return current promise if we are already fetching
return this.#retryer.promise
}
}
// Update config if passed, otherwise the config from the last execution is used
if (options) {
this.setOptions(options)
}
// Use the options from the first observer with a query function if no function is found.
// This can happen when the query is hydrated or created with setQueryData.
if (!this.options.queryFn) {
const observer = this.observers.find((x) => x.options.queryFn)
if (observer) {
this.setOptions(observer.options)
}
}
if (process.env.NODE_ENV !== 'production') {
if (!Array.isArray(this.options.queryKey)) {
console.error(
`As of v4, queryKey needs to be an Array. If you are using a string like 'repoData', please change it to an Array, e.g. ['repoData']`,
)
}
}
const abortController = new AbortController()
// Adds an enumerable signal property to the object that
// which sets abortSignalConsumed to true when the signal
// is read.
const addSignalProperty = (object: unknown) => {
Object.defineProperty(object, 'signal', {
enumerable: true,
get: () => {
this.#abortSignalConsumed = true
return abortController.signal
},
})
}
// Create fetch function
const fetchFn = () => {
const queryFn = ensureQueryFn(this.options, fetchOptions)
// Create query function context
const createQueryFnContext = (): QueryFunctionContext<TQueryKey> => {
const queryFnContext: OmitKeyof<
QueryFunctionContext<TQueryKey>,
'signal'
> = {
client: this.#client,
queryKey: this.queryKey,
meta: this.meta,
}
addSignalProperty(queryFnContext)
return queryFnContext as QueryFunctionContext<TQueryKey>
}
const queryFnContext = createQueryFnContext()
this.#abortSignalConsumed = false
if (this.options.persister) {
return this.options.persister(
queryFn,
queryFnContext,
this as unknown as Query,
)
}
return queryFn(queryFnContext)
}
// Trigger behavior hook
const createFetchContext = (): FetchContext<
TQueryFnData,
TError,
TData,
TQueryKey
> => {
const context: OmitKeyof<
FetchContext<TQueryFnData, TError, TData, TQueryKey>,
'signal'
> = {
fetchOptions,
options: this.options,
queryKey: this.queryKey,
client: this.#client,
state: this.state,
fetchFn,
}
addSignalProperty(context)
return context as FetchContext<TQueryFnData, TError, TData, TQueryKey>
}
const context = createFetchContext()
const behavior =
this.#queryType === 'infinite'
? (infiniteQueryBehavior(
(this.options as { pages?: number }).pages,
) as QueryBehavior<TQueryFnData, TError, TData, TQueryKey>)
: this.options.behavior
behavior?.onFetch(context, this as unknown as Query)
// Store state in case the current fetch needs to be reverted
this.#revertState = this.state
// Set to fetching state if not already in it
if (
this.state.fetchStatus === 'idle' ||
this.state.fetchMeta !== context.fetchOptions?.meta
) {
this.#dispatch({ type: 'fetch', meta: context.fetchOptions?.meta })
}
// Try to fetch the data
const retryer = (this.#retryer = createRetryer({
initialPromise: fetchOptions?.initialPromise as
| Promise<TData>
| undefined,
fn: context.fetchFn as () => Promise<TData>,
onCancel: (error) => {
if (error instanceof CancelledError && error.revert) {
this.setState({
...this.#revertState,
fetchStatus: 'idle' as const,
})
}
abortController.abort()
},
onFail: (failureCount, error) => {
this.#dispatch({ type: 'failed', failureCount, error })
},
onPause: () => {
this.#dispatch({ type: 'pause' })
},
onContinue: () => {
this.#dispatch({ type: 'continue' })
},
retry: context.options.retry,
retryDelay: context.options.retryDelay,
networkMode: context.options.networkMode,
canRun: () => true,
}))
try {
const data = await retryer.start()
// this is more of a runtime guard
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (data === undefined) {
if (process.env.NODE_ENV !== 'production') {
console.error(
`Query data cannot be undefined. Please make sure to return a value other than undefined from your query function. Affected query key: ${this.queryHash}`,
)
}
throw new Error(`${this.queryHash} data is undefined`)
}
this.setData(data)
// Notify cache callback
this.#cache.config.onSuccess?.(data, this as Query<any, any, any, any>)
this.#cache.config.onSettled?.(
data,
this.state.error as any,
this as Query<any, any, any, any>,
)
return data
} catch (error) {
if (error instanceof CancelledError) {
if (error.silent) {
// silent cancellation implies a new fetch is going to be started,
// so we piggyback onto that promise
return this.#retryer.promise
} else if (error.revert) {
// transform error into reverted state data
// if the initial fetch was cancelled, we have no data, so we have
// to get reject with a CancelledError
if (this.state.data === undefined) {
throw error
}
return this.state.data
}
}
this.#dispatch({
type: 'error',
error: error as TError,
})
// Notify cache callback
this.#cache.config.onError?.(
error as any,
this as Query<any, any, any, any>,
)
this.#cache.config.onSettled?.(
this.state.data,
error as any,
this as Query<any, any, any, any>,
)
throw error // rethrow the error for further handling
} finally {
// The settled retryer's promise would otherwise pin this fetch's raw
// result (a second copy after structural sharing) for the query's lifetime
if (this.#retryer === retryer) {
this.#retryer = undefined
}
// Schedule query gc after fetching
this.scheduleGc()
}
}
#dispatch(action: Action<TData, TError>): void {
const reducer = (
state: QueryState<TData, TError>,
): QueryState<TData, TError> => {
switch (action.type) {
case 'failed':
return {
...state,
fetchFailureCount: action.failureCount,
fetchFailureReason: action.error,
}
case 'pause':
return {
...state,
fetchStatus: 'paused',
}
case 'continue':
return {
...state,
fetchStatus: 'fetching',
}
case 'fetch':
return {
...state,
...fetchState(state.data, this.options),
fetchMeta: action.meta ?? null,
}
case 'success':
const newState = {
...state,
...successState(action.data, action.dataUpdatedAt),
dataUpdateCount: state.dataUpdateCount + 1,
...(!action.manual && {
fetchStatus: 'idle' as const,
fetchFailureCount: 0,
fetchFailureReason: null,
}),
}
// If fetching ends successfully, we don't need revertState as a fallback anymore.
// For manual updates, capture the state to revert to it in case of a cancellation.
this.#revertState = action.manual ? newState : undefined
return newState
case 'error':
const error = action.error
return {
...state,
error,
errorUpdateCount: state.errorUpdateCount + 1,
errorUpdatedAt: Date.now(),
fetchFailureCount: state.fetchFailureCount + 1,
fetchFailureReason: error,
fetchStatus: 'idle',
status: 'error',
// flag existing data as invalidated if we get a background error
// note that "no data" always means stale so we can set unconditionally here
isInvalidated: true,
}
case 'invalidate':
return {
...state,
isInvalidated: true,
}
case 'setState':
return {
...state,
...action.state,
}
}
}
this.state = reducer(this.state)
notifyManager.batch(() => {
// Keep the current iteration stable if an observer unsubscribes
// synchronously while it is being notified.
this.observers.slice().forEach((observer) => {
observer.onQueryUpdate()
})
this.#cache.notify({ query: this, type: 'updated', action })
})
}
}
export function fetchState<
TQueryFnData,
TError,
TData,
TQueryKey extends QueryKey,
>(
data: TData | undefined,
options: QueryOptions<TQueryFnData, TError, TData, TQueryKey>,
) {
return {
fetchFailureCount: 0,
fetchFailureReason: null,
fetchStatus: canFetch(options.networkMode) ? 'fetching' : 'paused',
...(data === undefined &&
({
error: null,
status: 'pending',
} as const)),
} as const
}
function successState<TData>(data: TData | undefined, dataUpdatedAt?: number) {
return {
data,
dataUpdatedAt: dataUpdatedAt ?? Date.now(),
error: null,
isInvalidated: false,
status: 'success' as const,
}
}
function getDefaultState<
TQueryFnData,
TError,
TData,
TQueryKey extends QueryKey,
>(
options: QueryOptions<TQueryFnData, TError, TData, TQueryKey>,
): QueryState<TData, TError> {
const data =
typeof options.initialData === 'function'
? (options.initialData as InitialDataFunction<TData>)()
: options.initialData
const hasData = data !== undefined
const initialDataUpdatedAt = hasData
? typeof options.initialDataUpdatedAt === 'function'
? options.initialDataUpdatedAt()
: options.initialDataUpdatedAt
: 0
return {
data,
dataUpdateCount: 0,
dataUpdatedAt: hasData ? (initialDataUpdatedAt ?? Date.now()) : 0,
error: null,
errorUpdateCount: 0,
errorUpdatedAt: 0,
fetchFailureCount: 0,
fetchFailureReason: null,
fetchMeta: null,
isInvalidated: false,
status: hasData ? 'success' : 'pending',
fetchStatus: 'idle',
}
}

View File

@@ -0,0 +1,357 @@
import { hashQueryKeyByOptions, matchQuery } from './utils'
import { Query } from './query'
import { notifyManager } from './notifyManager'
import { Subscribable } from './subscribable'
import type { QueryFilters } from './utils'
import type { Action, QueryState } from './query'
import type {
DefaultError,
NotifyEvent,
QueryKey,
QueryOptions,
WithRequired,
} from './types'
import type { QueryClient } from './queryClient'
import type { QueryObserver } from './queryObserver'
// TYPES
/**
* Global callbacks that fire for every query handled by a `QueryCache`, regardless of which
* component or observer triggered it. Unlike `QueryClient`'s `defaultOptions`, which a query can
* override, these callbacks are always called. Unlike `MutationCacheConfig`'s callbacks, these
* are fire-and-forget: their return value is not awaited before the query settles.
*/
export interface QueryCacheConfig {
/** Called when any query in the cache encounters an error. */
onError?: (
error: DefaultError,
query: Query<unknown, unknown, unknown>,
) => void
/** Called when any query in the cache is successful. */
onSuccess?: (data: unknown, query: Query<unknown, unknown, unknown>) => void
/** Called when any query in the cache is settled, either successfully or with an error. */
onSettled?: (
data: unknown | undefined,
error: DefaultError | null,
query: Query<unknown, unknown, unknown>,
) => void
}
interface NotifyEventQueryAdded extends NotifyEvent {
type: 'added'
query: Query<any, any, any, any>
}
interface NotifyEventQueryRemoved extends NotifyEvent {
type: 'removed'
query: Query<any, any, any, any>
}
interface NotifyEventQueryUpdated extends NotifyEvent {
type: 'updated'
query: Query<any, any, any, any>
action: Action<any, any>
}
interface NotifyEventQueryObserverAdded extends NotifyEvent {
type: 'observerAdded'
query: Query<any, any, any, any>
observer: QueryObserver<any, any, any, any, any>
}
interface NotifyEventQueryObserverRemoved extends NotifyEvent {
type: 'observerRemoved'
query: Query<any, any, any, any>
observer: QueryObserver<any, any, any, any, any>
}
interface NotifyEventQueryObserverResultsUpdated extends NotifyEvent {
type: 'observerResultsUpdated'
query: Query<any, any, any, any>
}
interface NotifyEventQueryObserverOptionsUpdated extends NotifyEvent {
type: 'observerOptionsUpdated'
query: Query<any, any, any, any>
observer: QueryObserver<any, any, any, any, any>
}
/**
* The event passed to a `QueryCache` subscriber. Fired whenever a query is added or removed from
* the cache, its state is updated (e.g. via `query.setState` or `queryClient.removeQueries`), or
* one of its observers is added, removed, or has its results or options updated.
*/
export type QueryCacheNotifyEvent =
| NotifyEventQueryAdded
| NotifyEventQueryRemoved
| NotifyEventQueryUpdated
| NotifyEventQueryObserverAdded
| NotifyEventQueryObserverRemoved
| NotifyEventQueryObserverResultsUpdated
| NotifyEventQueryObserverOptionsUpdated
type QueryCacheListener = (event: QueryCacheNotifyEvent) => void
export interface QueryStore {
has: (queryHash: string) => boolean
set: (queryHash: string, query: Query) => void
get: (queryHash: string) => Query | undefined
delete: (queryHash: string) => void
values: () => IterableIterator<Query>
}
// CLASS
/**
* The `QueryCache` is the storage mechanism for TanStack Query. It stores all the data, meta
* information, and state of the queries it contains.
*
* Normally, you will not interact with the `QueryCache` directly and instead use a `QueryClient`
* for a specific cache. You can subscribe to it (inherited from `Subscribable`) to be informed of
* safe/known updates to the cache, such as queries being added, removed, or updated — updates made
* outside of the cache's own tracked mechanisms (e.g. mutating a query's state object directly) do
* not notify subscribers.
*
* @example
* ```ts
* const unsubscribe = queryCache.subscribe((event) => {
* console.log(event.type, event.query)
* })
* ```
*/
export class QueryCache extends Subscribable<QueryCacheListener> {
#queries: QueryStore
constructor(public config: QueryCacheConfig = {}) {
super()
this.#queries = new Map<string, Query>()
}
/**
* Returns the existing `Query` instance for the given options' `queryKey`/`queryHash`, or
* builds and adds a new one to the cache if none exists yet. Used by framework adapters and
* plugins (e.g. broadcast/persistence) that need to get-or-create a `Query` directly, bypassing
* the reactive `QueryObserver` machinery.
*
* @example
* ```ts
* const queryCache = queryClient.getQueryCache()
*
* const query = queryCache.build(queryClient, {
* queryKey: ['posts'],
* queryFn: fetchPosts,
* })
* ```
*/
build<
TQueryFnData = unknown,
TError = DefaultError,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
>(
client: QueryClient,
options: WithRequired<
QueryOptions<TQueryFnData, TError, TData, TQueryKey>,
'queryKey'
>,
state?: QueryState<TData, TError>,
): Query<TQueryFnData, TError, TData, TQueryKey> {
const queryKey = options.queryKey
const queryHash =
options.queryHash ?? hashQueryKeyByOptions(queryKey, options)
let query = this.get<TQueryFnData, TError, TData, TQueryKey>(queryHash)
if (!query) {
query = new Query({
client,
queryKey,
queryHash,
options: client.defaultQueryOptions(options),
state,
defaultOptions: client.getQueryDefaults(queryKey),
})
this.add(query)
}
return query
}
/** @internal */
add(query: Query<any, any, any, any>): void {
if (!this.#queries.has(query.queryHash)) {
this.#queries.set(query.queryHash, query)
this.notify({
type: 'added',
query,
})
}
}
/**
* Destroys the given `Query` and removes it from the cache, notifying subscribers with a
* `'removed'` event. A no-op if the query is no longer the one currently stored under its hash
* (e.g. it was already replaced). Used by plugins (e.g. the broadcast client) that mirror
* removals across `QueryCache` instances.
*
* @example
* ```ts
* const queryCache = queryClient.getQueryCache()
* const query = queryCache.find({ queryKey: ['posts'] })
*
* if (query) {
* queryCache.remove(query)
* }
* ```
*/
remove(query: Query<any, any, any, any>): void {
const queryInMap = this.#queries.get(query.queryHash)
if (queryInMap) {
query.destroy()
if (queryInMap === query) {
this.#queries.delete(query.queryHash)
}
this.notify({ type: 'removed', query })
}
}
/**
* Removes all queries from the cache.
*
* @example
* ```ts
* const queryCache = queryClient.getQueryCache()
*
* queryCache.clear()
* ```
*/
clear(): void {
notifyManager.batch(() => {
this.getAll().forEach((query) => {
this.remove(query)
})
})
}
/**
* Returns the `Query` instance stored under the given `queryHash`, or `undefined` if none
* exists. Unlike {@link QueryCache#find}, this looks up by the already-computed hash rather
* than by `QueryFilters`. Used by plugins (e.g. broadcast/hydration) that already have a hash
* to look up directly.
*
* @example
* ```ts
* const queryCache = queryClient.getQueryCache()
* const queryHash = hashKey(['posts'])
*
* const query = queryCache.get(queryHash)
* ```
*/
get<
TQueryFnData = unknown,
TError = DefaultError,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
>(
queryHash: string,
): Query<TQueryFnData, TError, TData, TQueryKey> | undefined {
return this.#queries.get(queryHash) as
| Query<TQueryFnData, TError, TData, TQueryKey>
| undefined
}
/**
* Returns all queries within the cache.
*
* @example
* ```ts
* const queryCache = queryClient.getQueryCache()
*
* const queries = queryCache.getAll()
* ```
*/
getAll(): Array<Query> {
return [...this.#queries.values()]
}
/**
* A slightly more advanced method that can be used to get an existing query instance from the
* cache. This instance not only contains all the state for the query, but all of the instances,
* and underlying guts of the query as well. If the query does not exist, `undefined` is
* returned.
*
* This is not typically needed for most applications, but can come in handy when needing more
* information about a query in rare scenarios (e.g. looking at `query.state.dataUpdatedAt` to
* decide whether a query is fresh enough to be used as an initial value).
*
* @see {@link QueryCache#findAll}
* @example
* ```ts
* const queryCache = queryClient.getQueryCache()
*
* const query = queryCache.find({ queryKey: ['posts'] })
* ```
*/
find<TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData>(
filters: WithRequired<QueryFilters, 'queryKey'>,
): Query<TQueryFnData, TError, TData> | undefined {
const defaultedFilters = { exact: true, ...filters }
return this.getAll().find((query) =>
matchQuery(defaultedFilters, query),
) as Query<TQueryFnData, TError, TData> | undefined
}
/**
* An even more advanced method that can be used to get existing query instances from the cache
* that partially match a query key. If no queries match, an empty array is returned.
*
* This is not typically needed for most applications, but can come in handy when needing more
* information about queries in rare scenarios.
*
* @see {@link QueryCache#find}
* @example
* ```ts
* const queryCache = queryClient.getQueryCache()
*
* const queries = queryCache.findAll({ queryKey: ['posts'] })
* ```
*/
findAll(filters: QueryFilters<any> = {}): Array<Query> {
const queries = this.getAll()
return Object.keys(filters).length > 0
? queries.filter((query) => matchQuery(filters, query))
: queries
}
/** @internal */
notify(event: QueryCacheNotifyEvent): void {
notifyManager.batch(() => {
this.listeners.forEach((listener) => {
listener(event)
})
})
}
/** @internal */
onFocus(): void {
notifyManager.batch(() => {
this.getAll().forEach((query) => {
query.onFocus()
})
})
}
/** @internal */
onOnline(): void {
notifyManager.batch(() => {
this.getAll().forEach((query) => {
query.onOnline()
})
})
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,902 @@
import { focusManager } from './focusManager'
import { isServer as isServerEnvironment } from './environmentManager'
import { notifyManager } from './notifyManager'
import { fetchState } from './query'
import { Subscribable } from './subscribable'
import {
isValidTimeout,
noop,
replaceData,
resolveQueryValue,
shallowEqualObjects,
timeUntilStale,
} from './utils'
import { timeoutManager } from './timeoutManager'
import type { ManagedTimerId } from './timeoutManager'
import type { FetchOptions, Query, QueryState } from './query'
import type { QueryClient } from './queryClient'
import type {
DefaultError,
DefaultedQueryObserverOptions,
PlaceholderDataFunction,
QueryKey,
QueryObserverBaseResult,
QueryObserverOptions,
QueryObserverResult,
RefetchOptions,
} from './types'
type QueryObserverListener<TData, TError> = (
result: QueryObserverResult<TData, TError>,
) => void
interface ObserverFetchOptions extends FetchOptions {
throwOnError?: boolean
}
/**
* A `QueryObserver` watches a single query in the `QueryCache` and computes a
* `QueryObserverResult` from its state, recomputing and notifying subscribers
* whenever the underlying query (or the observer's options) changes. It is
* the primitive that framework adapters (e.g. `useQuery`) build their hooks
* on top of, but it can also be used directly to observe and switch between
* queries outside of any framework.
*
* @example
* ```ts
* const observer = new QueryObserver(queryClient, {
* queryKey: ['posts'],
* queryFn: fetchPosts,
* })
*
* const unsubscribe = observer.subscribe((result) => {
* console.log(result.data)
* })
* ```
*/
export class QueryObserver<
TQueryFnData = unknown,
TError = DefaultError,
TData = TQueryFnData,
TQueryData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
> extends Subscribable<QueryObserverListener<TData, TError>> {
#client: QueryClient
#currentQuery: Query<TQueryFnData, TError, TQueryData, TQueryKey> = undefined!
#currentQueryInitialState: QueryState<TQueryData, TError> = undefined!
#currentResult: QueryObserverResult<TData, TError> = undefined!
#currentResultState?: QueryState<TQueryData, TError>
#currentResultOptions?: QueryObserverOptions<
TQueryFnData,
TError,
TData,
TQueryData,
TQueryKey
>
#selectError: TError | null
#selectFn?: (data: TQueryData) => TData
#selectResult?: TData
// This property keeps track of the last query with defined data.
// It will be used to pass the previous data and query to the placeholder function between renders.
#lastQueryWithDefinedData?: Query<TQueryFnData, TError, TQueryData, TQueryKey>
#staleTimeoutId?: ManagedTimerId
#refetchIntervalId?: ManagedTimerId
#currentRefetchInterval?: number | false
#trackedProps = new Set<keyof QueryObserverResult>()
constructor(
client: QueryClient,
public options: QueryObserverOptions<
TQueryFnData,
TError,
TData,
TQueryData,
TQueryKey
>,
) {
super()
this.#client = client
this.#selectError = null
this.bindMethods()
this.setOptions(options)
}
protected bindMethods(): void {
this.refetch = this.refetch.bind(this)
}
protected onSubscribe(): void {
if (this.listeners.size === 1) {
this.#currentQuery.addObserver(this)
if (shouldFetchOnMount(this.#currentQuery, this.options)) {
this.#executeFetch()
} else {
this.updateResult()
}
this.#updateTimers()
}
}
protected onUnsubscribe(): void {
if (!this.hasListeners()) {
this.destroy()
}
}
/**
* Returns whether the observed query is currently stale and configured
* (via the `refetchOnReconnect` option) to refetch when the network
* reconnects.
*/
shouldFetchOnReconnect(): boolean {
return shouldFetchOn(
this.#currentQuery,
this.options,
this.options.refetchOnReconnect,
)
}
/**
* Returns whether the observed query is currently stale and configured
* (via the `refetchOnWindowFocus` option) to refetch when the window
* regains focus.
*/
shouldFetchOnWindowFocus(): boolean {
return shouldFetchOn(
this.#currentQuery,
this.options,
this.options.refetchOnWindowFocus,
)
}
/**
* Stops observing the current query: clears all listeners, cancels the
* stale and refetch-interval timers, and removes this observer from the
* query it was observing.
*/
destroy(): void {
this.listeners = new Set()
this.#clearStaleTimeout()
this.#clearRefetchInterval()
this.#currentQuery.removeObserver(this)
}
/**
* Updates the observer's options. This will re-resolve the query being
* observed (switching to a different query if the `queryKey` changed),
* trigger a fetch if the new options require one and the observer has
* subscribers, recompute the current result, and reschedule the stale and
* refetch-interval timers as needed.
*
* @example
* ```ts
* observer.setOptions({ queryKey: ['posts', 1], queryFn: () => fetchPost(1) })
* // later: switch to a different query, reusing the same observer
* observer.setOptions({ queryKey: ['posts', 2], queryFn: () => fetchPost(2) })
* ```
*/
setOptions(
options: QueryObserverOptions<
TQueryFnData,
TError,
TData,
TQueryData,
TQueryKey
>,
): void {
const prevOptions = this.options
const prevQuery = this.#currentQuery
this.options = this.#client.defaultQueryOptions(options)
if (
this.options.enabled !== undefined &&
typeof this.options.enabled !== 'boolean' &&
typeof this.options.enabled !== 'function' &&
typeof resolveQueryValue(this.options.enabled, this.#currentQuery) !==
'boolean'
) {
throw new Error(
'Expected enabled to be a boolean or a callback that returns a boolean',
)
}
this.#updateQuery()
this.#currentQuery.setOptions(this.options)
if (
prevOptions._defaulted &&
!shallowEqualObjects(this.options, prevOptions)
) {
this.#client.getQueryCache().notify({
type: 'observerOptionsUpdated',
query: this.#currentQuery,
observer: this,
})
}
const mounted = this.hasListeners()
// Fetch if there are subscribers
if (
mounted &&
shouldFetchOptionally(
this.#currentQuery,
prevQuery,
this.options,
prevOptions,
)
) {
this.#executeFetch()
}
// Update result
this.updateResult()
// Update stale interval if needed
if (
mounted &&
(this.#currentQuery !== prevQuery ||
resolveQueryValue(this.options.enabled, this.#currentQuery) !==
resolveQueryValue(prevOptions.enabled, this.#currentQuery) ||
resolveQueryValue(this.options.staleTime, this.#currentQuery) !==
resolveQueryValue(prevOptions.staleTime, this.#currentQuery))
) {
this.#updateStaleTimeout()
}
const nextRefetchInterval = this.#computeRefetchInterval()
// Update refetch interval if needed
if (
mounted &&
(this.#currentQuery !== prevQuery ||
resolveQueryValue(this.options.enabled, this.#currentQuery) !==
resolveQueryValue(prevOptions.enabled, this.#currentQuery) ||
nextRefetchInterval !== this.#currentRefetchInterval)
) {
this.#updateRefetchInterval(nextRefetchInterval)
}
}
/**
* Computes the result the observer would produce for the given (already-defaulted) options
* right now, building the underlying `Query` if it doesn't exist yet, without waiting for a
* subscription callback. Called by framework adapters on every render (e.g. `useQuery`) so the
* returned value is available synchronously, ahead of `setOptions` triggering an actual fetch.
*/
getOptimisticResult(
options: DefaultedQueryObserverOptions<
TQueryFnData,
TError,
TData,
TQueryData,
TQueryKey
>,
): QueryObserverResult<TData, TError> {
const query = this.#client.getQueryCache().build(this.#client, options)
const result = this.createResult(query, options)
if (!shallowEqualObjects(this.getCurrentResult(), result)) {
// this assigns the optimistic result to the current Observer
// because if the query function changes, useQuery will be performing
// an effect where it would fetch again.
// When the fetch finishes, we perform a deep data cloning in order
// to reuse objects references. This deep data clone is performed against
// the `observer.currentResult.data` property
// When QueryKey changes, we refresh the query and get new `optimistic`
// result, while we leave the `observer.currentResult`, so when new data
// arrives, it finds the old `observer.currentResult` which is related
// to the old QueryKey. Which means that currentResult and selectData are
// out of sync already.
// To solve this, we move the cursor of the currentResult every time
// an observer reads an optimistic value.
// When keeping the previous data, the result doesn't change until new
// data arrives.
this.#currentResult = result
this.#currentResultOptions = this.options
this.#currentResultState = this.#currentQuery.state
}
return result
}
/**
* Returns the most recently computed `QueryObserverResult` for the
* observed query. This is a point-in-time read; to be notified of updates
* as they happen, subscribe to the observer instead (its inherited
* `subscribe` method).
*
* @example
* ```ts
* const result = observer.getCurrentResult()
* console.log(result.status, result.data)
* ```
*/
getCurrentResult(): QueryObserverResult<TData, TError> {
return this.#currentResult
}
/**
* Wraps a `QueryObserverResult` in a `Proxy` that records which properties are read, via
* {@link QueryObserver#trackProp} (and an optional `onPropTracked` callback). Used by framework
* adapters when `notifyOnChangeProps` is not set, to implement its default "only re-render on
* properties you actually read" behavior.
*/
trackResult(
result: QueryObserverResult<TData, TError>,
onPropTracked?: (key: keyof QueryObserverResult) => void,
): QueryObserverResult<TData, TError> {
return new Proxy(result, {
get: (target, key) => {
this.trackProp(key as keyof QueryObserverResult)
onPropTracked?.(key as keyof QueryObserverResult)
return Reflect.get(target, key)
},
})
}
/**
* Records that the given `QueryObserverResult` property was read, so a subsequent update only
* notifies this observer if a tracked property actually changed. Normally called indirectly via
* {@link QueryObserver#trackResult}'s proxy; exposed directly for adapters that track property
* access themselves (e.g. through their own reactivity system) instead of via the proxy.
*/
trackProp(key: keyof QueryObserverResult) {
this.#trackedProps.add(key)
}
/**
* Returns the `Query` instance this observer is currently observing.
*/
getCurrentQuery(): Query<TQueryFnData, TError, TQueryData, TQueryKey> {
return this.#currentQuery
}
/**
* Refetches the observed query and returns a promise that resolves with
* the resulting `QueryObserverResult`.
*
* @example
* ```ts
* const result = await observer.refetch({ cancelRefetch: false })
* console.log(result.data)
* ```
*/
refetch({ ...options }: RefetchOptions = {}): Promise<
QueryObserverResult<TData, TError>
> {
return this.fetch({
...options,
})
}
/**
* Fetches a query defined by the given options without affecting this
* observer's own tracked query or result, and returns a promise that
* resolves with the `QueryObserverResult` for that fetch. This is useful
* for prefetching data that another observer (e.g. a query about to be
* navigated to) will need, ahead of time.
*
* @example
* ```ts
* const result = await observer.fetchOptimistic({
* queryKey: ['posts', 2],
* queryFn: () => fetchPost(2),
* })
* console.log(result.data)
* ```
*/
fetchOptimistic(
options: QueryObserverOptions<
TQueryFnData,
TError,
TData,
TQueryData,
TQueryKey
>,
): Promise<QueryObserverResult<TData, TError>> {
const defaultedOptions = this.#client.defaultQueryOptions(options)
const query = this.#client
.getQueryCache()
.build(this.#client, defaultedOptions)
let unsubscribe = () => {}
let resolveEarly:
| ((result: QueryObserverResult<TData, TError>) => void)
| undefined
const cachePromise = new Promise<QueryObserverResult<TData, TError>>(
(resolve) => {
resolveEarly = resolve
unsubscribe = this.#client.getQueryCache().subscribe((event) => {
if (
event.type === 'updated' &&
event.query.queryHash === query.queryHash &&
query.state.data !== undefined
) {
unsubscribe()
resolve(this.createResult(query, defaultedOptions))
}
})
},
)
return Promise.race([
query
.fetch()
.then(() => {
const result = this.createResult(query, defaultedOptions)
// Settle the subscriber promise so both branches always settle.
// This value is ignored by Promise.race since the fetch branch already won.
resolveEarly?.(result)
return result
})
.finally(() => {
unsubscribe()
}),
cachePromise,
])
}
protected fetch(
fetchOptions: ObserverFetchOptions,
): Promise<QueryObserverResult<TData, TError>> {
return this.#executeFetch({
...fetchOptions,
cancelRefetch: fetchOptions.cancelRefetch ?? true,
}).then(() => {
this.updateResult()
return this.#currentResult
})
}
#executeFetch(
fetchOptions?: Omit<ObserverFetchOptions, 'initialPromise'>,
): Promise<TQueryData | undefined> {
// Make sure we reference the latest query as the current one might have been removed
this.#updateQuery()
// Fetch
let promise: Promise<TQueryData | undefined> = this.#currentQuery.fetch(
this.options,
fetchOptions,
)
if (!fetchOptions?.throwOnError) {
promise = promise.catch(noop)
}
return promise
}
#shouldScheduleTimer(timeout: unknown): timeout is number {
return (
!isServerEnvironment() &&
resolveQueryValue(this.options.enabled, this.#currentQuery) !== false &&
isValidTimeout(timeout)
)
}
#updateStaleTimeout(): void {
this.#clearStaleTimeout()
const staleTime = resolveQueryValue(
this.options.staleTime,
this.#currentQuery,
)
if (this.#currentResult.isStale || !this.#shouldScheduleTimer(staleTime)) {
return
}
const time = timeUntilStale(this.#currentResult.dataUpdatedAt, staleTime)
// The timeout is sometimes triggered 1 ms before the stale time expiration.
// To mitigate this issue we always add 1 ms to the timeout.
const timeout = time + 1
this.#staleTimeoutId = timeoutManager.setTimeout(() => {
if (!this.#currentResult.isStale) {
this.updateResult()
}
}, timeout)
}
#computeRefetchInterval() {
return (
resolveQueryValue(this.options.refetchInterval, this.#currentQuery) ??
false
)
}
#updateRefetchInterval(nextInterval: number | false): void {
this.#clearRefetchInterval()
this.#currentRefetchInterval = nextInterval
if (
this.#currentRefetchInterval === 0 ||
!this.#shouldScheduleTimer(this.#currentRefetchInterval)
) {
return
}
this.#refetchIntervalId = timeoutManager.setInterval(() => {
if (
this.options.refetchIntervalInBackground ||
focusManager.isFocused()
) {
this.#executeFetch()
}
}, this.#currentRefetchInterval)
}
#updateTimers(): void {
this.#updateStaleTimeout()
this.#updateRefetchInterval(this.#computeRefetchInterval())
}
#clearStaleTimeout(): void {
if (this.#staleTimeoutId !== undefined) {
timeoutManager.clearTimeout(this.#staleTimeoutId)
this.#staleTimeoutId = undefined
}
}
#clearRefetchInterval(): void {
if (this.#refetchIntervalId !== undefined) {
timeoutManager.clearInterval(this.#refetchIntervalId)
this.#refetchIntervalId = undefined
}
}
protected createResult(
query: Query<TQueryFnData, TError, TQueryData, TQueryKey>,
options: QueryObserverOptions<
TQueryFnData,
TError,
TData,
TQueryData,
TQueryKey
>,
): QueryObserverResult<TData, TError> {
const prevQuery = this.#currentQuery
const prevOptions = this.options
const prevResult = this.#currentResult as
| QueryObserverResult<TData, TError>
| undefined
const prevResultState = this.#currentResultState
const prevResultOptions = this.#currentResultOptions
const queryChange = query !== prevQuery
const queryInitialState = queryChange
? query.state
: this.#currentQueryInitialState
const { state } = query
let newState = { ...state }
let isPlaceholderData = false
let data: TData | undefined
// Optimistically set result in fetching state if needed
if (options._optimisticResults) {
const mounted = this.hasListeners()
const fetchOnMount = !mounted && shouldFetchOnMount(query, options)
const fetchOptionally =
mounted && shouldFetchOptionally(query, prevQuery, options, prevOptions)
if (fetchOnMount || fetchOptionally) {
newState = {
...newState,
...fetchState(state.data, query.options),
}
}
if (options._optimisticResults === 'isRestoring') {
newState.fetchStatus = 'idle'
}
}
let { error, errorUpdatedAt, status } = newState
// Per default, use query data
data = newState.data as unknown as TData
let skipSelect = false
// use placeholderData if needed
if (
options.placeholderData !== undefined &&
data === undefined &&
status === 'pending'
) {
let placeholderData
// Memoize placeholder data
if (
prevResult?.isPlaceholderData &&
options.placeholderData === prevResultOptions?.placeholderData
) {
placeholderData = prevResult.data
// we have to skip select when reading this memoization
// because prevResult.data is already "selected"
skipSelect = true
} else {
// compute placeholderData
placeholderData =
typeof options.placeholderData === 'function'
? (
options.placeholderData as unknown as PlaceholderDataFunction<TQueryData>
)(
this.#lastQueryWithDefinedData?.state.data,
this.#lastQueryWithDefinedData as any,
)
: options.placeholderData
}
if (placeholderData !== undefined) {
status = 'success'
data = replaceData(
prevResult?.data,
placeholderData as unknown,
options,
) as TData
isPlaceholderData = true
}
}
// Select data if needed
// this also runs placeholderData through the select function
if (options.select && data !== undefined && !skipSelect) {
// Memoize select result
if (
prevResult &&
data === prevResultState?.data &&
options.select === this.#selectFn
) {
data = this.#selectResult
} else {
try {
this.#selectFn = options.select
data = options.select(data as any)
data = replaceData(prevResult?.data, data, options)
this.#selectResult = data
this.#selectError = null
} catch (selectError) {
this.#selectError = selectError as TError
}
}
} else if (data === undefined) {
// a stored select error belongs to previously selected data; once that
// data is gone (query switch or reset), it must not leak into this result
this.#selectError = null
}
if (this.#selectError) {
error = this.#selectError
data = this.#selectResult
errorUpdatedAt = Date.now()
status = 'error'
isPlaceholderData = false
}
const isFetching = newState.fetchStatus === 'fetching'
const isPending = status === 'pending'
const isError = status === 'error'
const isLoading = isPending && isFetching
const hasData = data !== undefined
const result: QueryObserverBaseResult<TData, TError> = {
status,
fetchStatus: newState.fetchStatus,
isPending,
isSuccess: status === 'success',
isError,
isInitialLoading: isLoading,
isLoading,
data,
dataUpdatedAt: newState.dataUpdatedAt,
error,
errorUpdatedAt,
failureCount: newState.fetchFailureCount,
failureReason: newState.fetchFailureReason,
errorUpdateCount: newState.errorUpdateCount,
isFetched: query.isFetched(),
isFetchedAfterMount:
newState.dataUpdateCount > queryInitialState.dataUpdateCount ||
newState.errorUpdateCount > queryInitialState.errorUpdateCount,
isFetching,
isRefetching: isFetching && !isPending,
isLoadingError: isError && !hasData,
isPaused: newState.fetchStatus === 'paused',
isPlaceholderData,
isRefetchError: isError && hasData,
isStale: isStale(query, options),
refetch: this.refetch,
isEnabled: resolveQueryValue(options.enabled, query) !== false,
}
const nextResult = result as QueryObserverResult<TData, TError>
return nextResult
}
/**
* Recomputes and stores the current result from the current query/options, notifying listeners
* if it changed. Framework adapters call this right after subscribing to make sure no query
* update was missed in the gap between creating the observer and subscribing to it.
*/
updateResult(): void {
const prevResult = this.#currentResult as
| QueryObserverResult<TData, TError>
| undefined
const nextResult = this.createResult(this.#currentQuery, this.options)
this.#currentResultState = this.#currentQuery.state
this.#currentResultOptions = this.options
if (this.#currentResultState.data !== undefined) {
this.#lastQueryWithDefinedData = this.#currentQuery
}
// Only notify and update result if something has changed
if (shallowEqualObjects(nextResult, prevResult)) {
return
}
this.#currentResult = nextResult
const shouldNotifyListeners = (): boolean => {
if (!prevResult) {
return true
}
const { notifyOnChangeProps } = this.options
const notifyOnChangePropsValue =
typeof notifyOnChangeProps === 'function'
? notifyOnChangeProps()
: notifyOnChangeProps
if (
notifyOnChangePropsValue === 'all' ||
(!notifyOnChangePropsValue && !this.#trackedProps.size)
) {
return true
}
const includedProps = new Set(
notifyOnChangePropsValue ?? this.#trackedProps,
)
if (this.options.throwOnError) {
includedProps.add('error')
}
return Object.keys(this.#currentResult).some((key) => {
const typedKey = key as keyof QueryObserverResult
const changed = this.#currentResult[typedKey] !== prevResult[typedKey]
return changed && includedProps.has(typedKey)
})
}
const notifyListeners = shouldNotifyListeners()
notifyManager.batch(() => {
// First, trigger the listeners
if (notifyListeners) {
this.listeners.forEach((listener) => {
listener(this.#currentResult)
})
}
// Then the cache listeners
this.#client.getQueryCache().notify({
query: this.#currentQuery,
type: 'observerResultsUpdated',
})
})
}
#updateQuery(): void {
const query = this.#client.getQueryCache().build(this.#client, this.options)
if (query === this.#currentQuery) {
return
}
const prevQuery = this.#currentQuery as
| Query<TQueryFnData, TError, TQueryData, TQueryKey>
| undefined
this.#currentQuery = query
this.#currentQueryInitialState = query.state
if (this.hasListeners()) {
prevQuery?.removeObserver(this)
query.addObserver(this)
}
}
/** @internal */
onQueryUpdate(): void {
this.updateResult()
if (this.hasListeners()) {
this.#updateTimers()
}
}
}
function shouldLoadOnMount(
query: Query<any, any, any, any>,
options: QueryObserverOptions<any, any, any, any>,
): boolean {
return (
resolveQueryValue(options.enabled, query) !== false &&
query.state.data === undefined &&
!(
query.state.status === 'error' &&
resolveQueryValue(options.retryOnMount, query) === false
)
)
}
function shouldFetchOnMount(
query: Query<any, any, any, any>,
options: QueryObserverOptions<any, any, any, any, any>,
): boolean {
return (
shouldLoadOnMount(query, options) ||
(query.state.data !== undefined &&
shouldFetchOn(query, options, options.refetchOnMount))
)
}
function shouldFetchOn(
query: Query<any, any, any, any>,
options: QueryObserverOptions<any, any, any, any, any>,
field: (typeof options)['refetchOnMount'] &
(typeof options)['refetchOnWindowFocus'] &
(typeof options)['refetchOnReconnect'],
) {
if (
resolveQueryValue(options.enabled, query) !== false &&
resolveQueryValue(options.staleTime, query) !== 'static'
) {
const value = resolveQueryValue(field, query)
return value === 'always' || (value !== false && isStale(query, options))
}
return false
}
function shouldFetchOptionally(
query: Query<any, any, any, any>,
prevQuery: Query<any, any, any, any>,
options: QueryObserverOptions<any, any, any, any, any>,
prevOptions: QueryObserverOptions<any, any, any, any, any>,
): boolean {
return (
(query !== prevQuery ||
resolveQueryValue(prevOptions.enabled, query) === false) &&
(!options.suspense || query.state.status !== 'error') &&
isStale(query, options)
)
}
function isStale(
query: Query<any, any, any, any>,
options: QueryObserverOptions<any, any, any, any, any>,
): boolean {
return (
resolveQueryValue(options.enabled, query) !== false &&
query.isStaleByTime(resolveQueryValue(options.staleTime, query))
)
}

View File

@@ -0,0 +1,40 @@
import { timeoutManager } from './timeoutManager'
import { isServer as isServerEnvironment } from './environmentManager'
import { isValidTimeout } from './utils'
import type { ManagedTimerId } from './timeoutManager'
export abstract class Removable {
gcTime!: number
#gcTimeout?: ManagedTimerId
destroy(): void {
this.clearGcTimeout()
}
protected scheduleGc(): void {
this.clearGcTimeout()
if (isValidTimeout(this.gcTime)) {
this.#gcTimeout = timeoutManager.setTimeout(() => {
this.optionalRemove()
}, this.gcTime)
}
}
protected updateGcTime(newGcTime: number | undefined): void {
// Default to 5 minutes (Infinity for server-side) if no gcTime is set
this.gcTime = Math.max(
this.gcTime || 0,
newGcTime ?? (isServerEnvironment() ? Infinity : 5 * 60 * 1000),
)
}
protected clearGcTimeout() {
if (this.#gcTimeout !== undefined) {
timeoutManager.clearTimeout(this.#gcTimeout)
this.#gcTimeout = undefined
}
}
protected abstract optionalRemove(): void
}

View File

@@ -0,0 +1,255 @@
import { focusManager } from './focusManager'
import { onlineManager } from './onlineManager'
import { isServer as isServerEnvironment } from './environmentManager'
import { noop, sleep } from './utils'
import type { CancelOptions, DefaultError, NetworkMode } from './types'
// TYPES
interface RetryerConfig<TData = unknown, TError = DefaultError> {
fn: () => TData | Promise<TData>
initialPromise?: Promise<TData>
onCancel?: (error: TError) => void
onFail?: (failureCount: number, error: TError) => void
onPause?: () => void
onContinue?: () => void
retry?: RetryValue<TError>
retryDelay?: RetryDelayValue<TError>
networkMode: NetworkMode | undefined
canRun: () => boolean
}
export interface Retryer<TData = unknown> {
promise: Promise<TData>
cancel: (cancelOptions?: CancelOptions) => void
continue: () => Promise<unknown>
cancelRetry: () => void
continueRetry: () => void
canStart: () => boolean
start: () => Promise<TData>
status: () => 'pending' | 'resolved' | 'rejected'
}
type RetryerStatus = 'pending' | 'resolved' | 'rejected'
export type RetryValue<TError> = boolean | number | ShouldRetryFunction<TError>
type ShouldRetryFunction<TError = DefaultError> = (
failureCount: number,
error: TError,
) => boolean
export type RetryDelayValue<TError> = number | RetryDelayFunction<TError>
type RetryDelayFunction<TError = DefaultError> = (
failureCount: number,
error: TError,
) => number
function defaultRetryDelay(failureCount: number) {
return Math.min(1000 * 2 ** failureCount, 30000)
}
export function canFetch(networkMode: NetworkMode | undefined): boolean {
return (networkMode ?? 'online') === 'online'
? onlineManager.isOnline()
: true
}
/**
* The error thrown by a `Retryer` (and surfaced to `query.promise`/`mutation`) when a fetch is cancelled, e.g. via
* `query.cancel()`. `revert`, if `true`, tells the caller to restore the state the query was in before the fetch
* started instead of surfacing the error. `silent`, if `true`, tells the caller to suppress this error and instead
* resolve with the promise of the fetch that triggered the cancellation.
* @example
* ```ts
* query.cancel()
*
* try {
* await query.promise
* } catch (error) {
* if (error instanceof CancelledError) {
* // the fetch was cancelled, e.g. via `query.cancel()`
* }
* }
* ```
*/
export class CancelledError extends Error {
revert?: boolean
silent?: boolean
constructor(options?: CancelOptions) {
super('CancelledError')
this.revert = options?.revert
this.silent = options?.silent
}
}
/**
* @deprecated Use instanceof `CancelledError` instead.
*/
export function isCancelledError(value: any): value is CancelledError {
return value instanceof CancelledError
}
export function createRetryer<TData = unknown, TError = DefaultError>(
config: RetryerConfig<TData, TError>,
): Retryer<TData> {
let isRetryCancelled = false
let failureCount = 0
let continueFn: ((value?: unknown) => void) | undefined
let status: RetryerStatus = 'pending'
let promiseResolve: (data: TData) => void
let promiseReject: (error: TError) => void
const promise = new Promise<TData>((resolve, reject) => {
promiseResolve = resolve
promiseReject = reject
})
promise.catch(noop)
const isResolved = () => status !== 'pending'
const cancel = (cancelOptions?: CancelOptions): void => {
if (!isResolved()) {
const error = new CancelledError(cancelOptions) as TError
reject(error)
config.onCancel?.(error)
}
}
const cancelRetry = () => {
isRetryCancelled = true
}
const continueRetry = () => {
isRetryCancelled = false
}
const canContinue = () =>
focusManager.isFocused() &&
(config.networkMode === 'always' || onlineManager.isOnline()) &&
config.canRun()
const canStart = () => canFetch(config.networkMode) && config.canRun()
const resolve = (value: any) => {
if (!isResolved()) {
continueFn?.()
status = 'resolved'
promiseResolve(value)
}
}
const reject = (value: any) => {
if (!isResolved()) {
continueFn?.()
status = 'rejected'
promiseReject(value)
}
}
const pause = () => {
return new Promise((continueResolve) => {
continueFn = (value) => {
if (isResolved() || canContinue()) {
continueResolve(value)
}
}
config.onPause?.()
}).then(() => {
continueFn = undefined
if (!isResolved()) {
config.onContinue?.()
}
})
}
// Create loop function
const run = () => {
// Do nothing if already resolved
if (isResolved()) {
return
}
let promiseOrValue: any
// we can re-use config.initialPromise on the first call of run()
const initialPromise =
failureCount === 0 ? config.initialPromise : undefined
// Execute query
try {
promiseOrValue = initialPromise ?? config.fn()
} catch (error) {
promiseOrValue = Promise.reject(error)
}
Promise.resolve(promiseOrValue)
.then(resolve)
.catch((error) => {
// Stop if the fetch is already resolved
if (isResolved()) {
return
}
// Do we need to retry the request?
const retry = config.retry ?? (isServerEnvironment() ? 0 : 3)
const retryDelay = config.retryDelay ?? defaultRetryDelay
const delay =
typeof retryDelay === 'function'
? retryDelay(failureCount, error)
: retryDelay
const shouldRetry =
retry === true ||
(typeof retry === 'number' && failureCount < retry) ||
(typeof retry === 'function' && retry(failureCount, error))
if (isRetryCancelled || !shouldRetry) {
// We are done if the query does not need to be retried
reject(error)
return
}
failureCount++
// Notify on fail
config.onFail?.(failureCount, error)
// Delay
sleep(delay)
// Pause if the document is not visible or when the device is offline
.then(() => {
return canContinue() ? undefined : pause()
})
.then(() => {
if (isRetryCancelled) {
reject(error)
} else {
run()
}
})
})
}
return {
promise,
status: () => status,
cancel,
continue: () => {
continueFn?.()
return promise
},
cancelRetry,
continueRetry,
canStart,
start: () => {
// Start loop
if (canStart()) {
run()
} else {
pause().then(run)
}
return promise
},
}
}

View File

@@ -0,0 +1,144 @@
import { addConsumeAwareSignal, addToEnd } from './utils'
import type {
OmitKeyof,
QueryFunction,
QueryFunctionContext,
QueryKey,
} from './types'
type BaseStreamedQueryParams<TQueryFnData, TQueryKey extends QueryKey> = {
/** The function that returns an `AsyncIterable` to stream data from. */
streamFn: (
context: QueryFunctionContext<TQueryKey>,
) => AsyncIterable<TQueryFnData> | Promise<AsyncIterable<TQueryFnData>>
/**
* Defines how refetches are handled.
* - `'reset'` (default): erases all data and puts the query back into `pending` state.
* - `'append'`: appends new data to the existing data.
* - `'replace'`: writes all data to the cache once the stream ends.
*/
refetchMode?: 'append' | 'reset' | 'replace'
}
type SimpleStreamedQueryParams<
TQueryFnData,
TQueryKey extends QueryKey,
> = BaseStreamedQueryParams<TQueryFnData, TQueryKey> & {
reducer?: never
initialValue?: never
}
type ReducibleStreamedQueryParams<
TQueryFnData,
TData,
TQueryKey extends QueryKey,
> = BaseStreamedQueryParams<TQueryFnData, TQueryKey> & {
/**
* Reduces streamed chunks into the final data shape. Required whenever `TData` is not an
* array, since there is no default way to accumulate non-array chunks.
*/
reducer: (acc: TData, chunk: TQueryFnData) => TData
/**
* The value used while the first chunk is being fetched, and returned if the stream yields no
* values. Required together with a custom `reducer`.
*/
initialValue: TData
}
type StreamedQueryParams<TQueryFnData, TData, TQueryKey extends QueryKey> =
| SimpleStreamedQueryParams<TQueryFnData, TQueryKey>
| ReducibleStreamedQueryParams<TQueryFnData, TData, TQueryKey>
/**
* This is a helper function to create a query function that streams data from an AsyncIterable.
* Data will be an Array of all the chunks received.
* The query will be in a 'pending' state until the first chunk of data is received, but will go to 'success' after that.
* The query will stay in fetchStatus 'fetching' until the stream ends.
* @param streamFn - The function that returns an AsyncIterable to stream data from.
* @param refetchMode - Defines how re-fetches are handled.
* Defaults to `'reset'`, erases all data and puts the query back into `pending` state.
* Set to `'append'` to append new data to the existing data.
* Set to `'replace'` to write all data to the cache once the stream ends.
* @param reducer - A function to reduce the streamed chunks into the final data.
* Defaults to a function that appends chunks to the end of the array.
* @param initialValue - Initial value to be used while the first chunk is being fetched, and returned if the stream yields no values.
* @example
* ```ts
* await queryClient.query({
* queryKey: ['data'],
* queryFn: streamedQuery({
* streamFn: fetchDataInChunks,
* }),
* })
* ```
*/
export function streamedQuery<
TQueryFnData = unknown,
TData = Array<TQueryFnData>,
TQueryKey extends QueryKey = QueryKey,
>({
streamFn,
refetchMode = 'reset',
reducer = (items, chunk) =>
addToEnd(items as Array<TQueryFnData>, chunk) as TData,
initialValue = [] as TData,
}: StreamedQueryParams<TQueryFnData, TData, TQueryKey>): QueryFunction<
TData,
TQueryKey
> {
return async (context) => {
const query = context.client
.getQueryCache()
.find({ queryKey: context.queryKey, exact: true })
const isRefetch = !!query && query.isFetched()
if (isRefetch && refetchMode === 'reset') {
query.setState({
...query.resetState,
fetchStatus: 'fetching',
})
}
let result = initialValue
let cancelled: boolean = false as boolean
const streamFnContext = addConsumeAwareSignal<
OmitKeyof<typeof context, 'signal'>
>(
{
client: context.client,
meta: context.meta,
queryKey: context.queryKey,
pageParam: context.pageParam,
direction: context.direction,
},
() => context.signal,
() => (cancelled = true),
)
const stream = await streamFn(streamFnContext)
const isReplaceRefetch = isRefetch && refetchMode === 'replace'
for await (const chunk of stream) {
if (cancelled) {
break
}
if (isReplaceRefetch) {
// don't append to the cache directly when replace-refetching
result = reducer(result, chunk)
} else {
context.client.setQueryData<TData>(context.queryKey, (prev) =>
reducer(prev === undefined ? initialValue : prev, chunk),
)
}
}
// finalize result: replace-refetching needs to write to the cache
if (isReplaceRefetch && !cancelled) {
context.client.setQueryData<TData>(context.queryKey, result)
}
return context.client.getQueryData(context.queryKey) ?? initialValue
}
}

View File

@@ -0,0 +1,30 @@
export class Subscribable<TListener extends Function> {
protected listeners = new Set<TListener>()
constructor() {
this.subscribe = this.subscribe.bind(this)
}
subscribe(listener: TListener): () => void {
this.listeners.add(listener)
this.onSubscribe()
return () => {
this.listeners.delete(listener)
this.onUnsubscribe()
}
}
hasListeners(): boolean {
return this.listeners.size > 0
}
protected onSubscribe(): void {
// Do nothing
}
protected onUnsubscribe(): void {
// Do nothing
}
}

View File

@@ -0,0 +1,243 @@
/**
* {@link TimeoutManager} does not support passing arguments to the callback.
*
* `(_: void)` is the argument type inferred by TypeScript's default typings for
* `setTimeout(cb, number)`.
* If we don't accept a single void argument, then
* `new Promise(resolve => timeoutManager.setTimeout(resolve, N))` is a type error.
*/
export type TimeoutCallback = (_: void) => void
/**
* Wrapping `setTimeout` is awkward from a typing perspective because platform
* typings may extend the return type of `setTimeout`. For example, NodeJS
* typings add `NodeJS.Timeout`; but a non-default `timeoutManager` may not be
* able to return such a type.
*/
export type ManagedTimerId = number | { [Symbol.toPrimitive]: () => number }
/**
* Backend for timer functions.
*
* Timers are performance-sensitive: short-lived timers (delays under a few seconds) tend to be
* latency-sensitive, while long-lived ones may benefit more from coalescing — batching timers
* with similar deadlines together — which the default provider (backed by the platform's global
* `setTimeout`/`setInterval`) does not do. A custom provider can implement coalescing, and can
* also support delays longer than the ~24-day maximum of the global `setTimeout`.
*/
export type TimeoutProvider<TTimerId extends ManagedTimerId = ManagedTimerId> =
{
readonly setTimeout: (callback: TimeoutCallback, delay: number) => TTimerId
readonly clearTimeout: (timeoutId: TTimerId | undefined) => void
readonly setInterval: (callback: TimeoutCallback, delay: number) => TTimerId
readonly clearInterval: (intervalId: TTimerId | undefined) => void
}
type SystemTimerId = ReturnType<typeof setTimeout>
export const defaultTimeoutProvider: TimeoutProvider = {
// We need the wrapper function syntax below instead of direct references to
// global setTimeout etc.
//
// BAD: `setTimeout: setTimeout`
// GOOD: `setTimeout: (cb, delay) => setTimeout(cb, delay)`
//
// If we use direct references here, then anything that wants to spy on or
// replace the global setTimeout (like tests) won't work since we'll already
// have a hard reference to the original implementation at the time when this
// file was imported.
setTimeout: (callback, delay) => setTimeout(callback, delay),
clearTimeout: (timeoutId) =>
clearTimeout(timeoutId as SystemTimerId | undefined),
setInterval: (callback, delay) => setInterval(callback, delay),
clearInterval: (intervalId) =>
clearInterval(intervalId as SystemTimerId | undefined),
}
/**
* Allows customization of how timeouts are created.
*
* @tanstack/query-core makes liberal use of timeouts to implement `staleTime`
* and `gcTime`. The default TimeoutManager provider uses the platform's global
* `setTimeout` implementation, which is known to have scalability issues with
* thousands of timeouts on the event loop.
*
* If you hit this limitation, consider providing a custom TimeoutProvider that
* coalesces timeouts.
*/
export class TimeoutManager implements Omit<TimeoutProvider, 'name'> {
// We cannot have TimeoutManager<T> as we must instantiate it with a concrete
// type at app boot; and if we leave that type, then any new timer provider
// would need to support the default provider's concrete timer ID, which is
// infeasible across environments.
//
// We settle for type safety for the TimeoutProvider type, and accept that
// this class is unsafe internally to allow for extension.
#provider: TimeoutProvider<any> = defaultTimeoutProvider
#providerCalled = false
/**
* `setTimeoutProvider` can be used to set a custom implementation of the
* `setTimeout`, `clearTimeout`, `setInterval`, `clearInterval` functions,
* called a `TimeoutProvider`.
*
* This may be useful if you notice event loop performance issues with
* thousands of queries. A custom TimeoutProvider could also support timer
* delays longer than the global `setTimeout` maximum delay value of about
* 24 days.
*
* It is important to call `setTimeoutProvider` before creating a
* QueryClient or queries, so that the same provider is used consistently
* for all timers in the application, since different TimeoutProviders
* cannot cancel each others' timers.
*
* @example
* ```ts
* import { timeoutManager, QueryClient } from '@tanstack/query-core'
* import { CustomTimeoutProvider } from './CustomTimeoutProvider'
*
* timeoutManager.setTimeoutProvider(new CustomTimeoutProvider())
*
* export const queryClient = new QueryClient()
* ```
*/
setTimeoutProvider<TTimerId extends ManagedTimerId>(
provider: TimeoutProvider<TTimerId>,
): void {
if (process.env.NODE_ENV !== 'production') {
if (this.#providerCalled && provider !== this.#provider) {
// After changing providers, `clearTimeout` will not work as expected for
// timeouts from the previous provider.
//
// Since they may allocate the same timeout ID, clearTimeout may cancel an
// arbitrary different timeout, or unexpected no-op.
//
// We could protect against this by mixing the timeout ID bits
// deterministically with some per-provider bits.
//
// We could internally queue `setTimeout` calls to `TimeoutManager` until
// some API call to set the initial provider.
console.error(
`[timeoutManager]: Switching provider after calls to previous provider might result in unexpected behavior.`,
{ previous: this.#provider, provider },
)
}
}
this.#provider = provider
if (process.env.NODE_ENV !== 'production') {
this.#providerCalled = false
}
}
/**
* `setTimeout` schedules a callback to run after approximately `delay`
* milliseconds, like the global `setTimeout` function. The callback can be
* canceled with `clearTimeout`.
*
* It returns a timer ID, which may be a number or an object that can be
* coerced to a number via `Symbol.toPrimitive`.
*
* @example
* ```ts
* import { timeoutManager } from '@tanstack/query-core'
*
* const timeoutId = timeoutManager.setTimeout(
* () => console.log('ran at:', new Date()),
* 1000,
* )
*
* const timeoutIdNumber: number = Number(timeoutId)
* ```
*/
setTimeout(callback: TimeoutCallback, delay: number): ManagedTimerId {
if (process.env.NODE_ENV !== 'production') {
this.#providerCalled = true
}
return this.#provider.setTimeout(callback, delay)
}
/**
* `clearTimeout` cancels a timeout callback scheduled with `setTimeout`,
* like the global `clearTimeout` function. It should be called with a
* timer ID returned by `setTimeout`.
*
* @example
* ```ts
* import { timeoutManager } from '@tanstack/query-core'
*
* const timeoutId = timeoutManager.setTimeout(
* () => console.log('ran at:', new Date()),
* 1000,
* )
*
* timeoutManager.clearTimeout(timeoutId)
* ```
*/
clearTimeout(timeoutId: ManagedTimerId | undefined): void {
this.#provider.clearTimeout(timeoutId)
}
/**
* `setInterval` schedules a callback to be called approximately every
* `delay` milliseconds, like the global `setInterval` function.
*
* Like `setTimeout`, it returns a timer ID, which may be a number or an
* object that can be coerced to a number via `Symbol.toPrimitive`.
*
* @example
* ```ts
* import { timeoutManager } from '@tanstack/query-core'
*
* const intervalId = timeoutManager.setInterval(
* () => console.log('ran at:', new Date()),
* 1000,
* )
* ```
*/
setInterval(callback: TimeoutCallback, delay: number): ManagedTimerId {
if (process.env.NODE_ENV !== 'production') {
this.#providerCalled = true
}
return this.#provider.setInterval(callback, delay)
}
/**
* `clearInterval` can be used to cancel an interval, like the global
* `clearInterval` function. It should be called with an interval ID
* returned by `setInterval`.
*
* @example
* ```ts
* import { timeoutManager } from '@tanstack/query-core'
*
* const intervalId = timeoutManager.setInterval(
* () => console.log('ran at:', new Date()),
* 1000,
* )
*
* timeoutManager.clearInterval(intervalId)
* ```
*/
clearInterval(intervalId: ManagedTimerId | undefined): void {
this.#provider.clearInterval(intervalId)
}
}
/**
* Singleton instance of {@link TimeoutManager}, used throughout TanStack Query to schedule and cancel timers.
*/
export const timeoutManager = new TimeoutManager()
/**
* In many cases code wants to delay to the next event loop tick; this is not
* mediated by {@link timeoutManager}.
*
* This function is provided to make auditing the `tanstack/query-core` for
* incorrect use of system `setTimeout` easier.
*/
export function systemSetTimeoutZero(callback: TimeoutCallback): void {
setTimeout(callback, 0)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,617 @@
import { timeoutManager } from './timeoutManager'
import type {
DefaultError,
FetchStatus,
MutationKey,
MutationStatus,
QueryFunction,
QueryKey,
QueryOptions,
} from './types'
import type { Mutation } from './mutation'
import type { FetchOptions, Query } from './query'
// TYPES
type DropLast<T extends ReadonlyArray<unknown>> = T extends readonly [
...infer R,
unknown,
]
? readonly [...R]
: never
type TuplePrefixes<T extends ReadonlyArray<unknown>> = T extends readonly []
? readonly []
: TuplePrefixes<DropLast<T>> | T
/**
* Filters used to select queries, for example in `queryClient.getQueriesData` or `queryClient.invalidateQueries`.
* All provided filters must match; filters that are left unspecified are ignored.
*/
export interface QueryFilters<TQueryKey extends QueryKey = QueryKey> {
/**
* Filter to active queries, inactive queries or all queries
*
* Defaults to `'all'`.
*/
type?: QueryTypeFilter
/**
* Match query key exactly
*/
exact?: boolean
/**
* Include queries matching this predicate function
*/
predicate?: (query: Query) => boolean
/**
* Include queries matching this query key
*/
queryKey?: TQueryKey | TuplePrefixes<TQueryKey>
/**
* Include or exclude stale queries
*/
stale?: boolean
/**
* Include queries matching their fetchStatus
*/
fetchStatus?: FetchStatus
}
/**
* Filters used to select mutations, for example in `mutationCache.findAll` or `queryClient.isMutating`.
* All provided filters must match; filters that are left unspecified are ignored.
*/
export interface MutationFilters<
TData = unknown,
TError = DefaultError,
TVariables = unknown,
TOnMutateResult = unknown,
> {
/**
* Match mutation key exactly
*/
exact?: boolean
/**
* Include mutations matching this predicate function
*/
predicate?: (
mutation: Mutation<TData, TError, TVariables, TOnMutateResult>,
) => boolean
/**
* Include mutations matching this mutation key
*/
mutationKey?: TuplePrefixes<MutationKey>
/**
* Filter by mutation status
*/
status?: MutationStatus
}
/**
* Either a plain value of type `TOutput`, or a function that receives `TInput` and returns `TOutput`.
* Used for example by `setQueryData`-style updaters, which accept either the new data directly or a
* function that computes it from the previous data. See {@link functionalUpdate}.
*
* @example
* ```ts
* queryClient.setQueryData(['posts'], newPosts)
*
* // Or, using an updater function that receives the current data:
* queryClient.setQueryData(['posts'], (oldPosts) =>
* oldPosts ? [...oldPosts, newPost] : oldPosts,
* )
* ```
*/
export type Updater<TInput, TOutput> = TOutput | ((input: TInput) => TOutput)
export type QueryTypeFilter = 'all' | 'active' | 'inactive'
// UTILS
/** @deprecated
* use `environmentManager.isServer()` instead.
*/
export const isServer = typeof window === 'undefined' || 'Deno' in globalThis
/**
* A function that does nothing.
*/
export function noop(): void
export function noop(): undefined
export function noop() {}
export function functionalUpdate<TInput, TOutput>(
updater: Updater<TInput, TOutput>,
input: TInput,
): TOutput {
return typeof updater === 'function'
? (updater as (_: TInput) => TOutput)(input)
: updater
}
export function isValidTimeout(value: unknown): value is number {
return typeof value === 'number' && value >= 0 && value !== Infinity
}
export function timeUntilStale(updatedAt: number, staleTime?: number): number {
return Math.max(updatedAt + (staleTime || 0) - Date.now(), 0)
}
export function resolveQueryValue<
TValue,
TQueryFnData = unknown,
TError = DefaultError,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
>(
value:
| undefined
| TValue
| ((query: Query<TQueryFnData, TError, TData, TQueryKey>) => TValue),
query: Query<TQueryFnData, TError, TData, TQueryKey>,
): TValue | undefined {
return typeof value === 'function'
? (
value as (
query: Query<TQueryFnData, TError, TData, TQueryKey>,
) => TValue
)(query)
: value
}
/**
* Checks whether a query matches the given {@link QueryFilters}.
* Every filter that is specified must match; filters that are left unspecified are ignored.
*
* @example
* ```ts
* const queryCache = queryClient.getQueryCache()
*
* const matchingQueries = queryCache
* .getAll()
* .filter((query) => matchQuery({ queryKey: ['posts'] }, query))
* ```
*/
export function matchQuery(
filters: QueryFilters,
query: Query<any, any, any, any>,
): boolean {
const {
type = 'all',
exact,
fetchStatus,
predicate,
queryKey,
stale,
} = filters
if (queryKey) {
if (exact) {
if (query.queryHash !== hashQueryKeyByOptions(queryKey, query.options)) {
return false
}
} else if (!partialMatchKey(query.queryKey, queryKey)) {
return false
}
}
if (type !== 'all') {
const isActive = query.isActive()
if (type === 'active' && !isActive) {
return false
}
if (type === 'inactive' && isActive) {
return false
}
}
if (typeof stale === 'boolean' && query.isStale() !== stale) {
return false
}
if (fetchStatus && fetchStatus !== query.state.fetchStatus) {
return false
}
if (predicate && !predicate(query)) {
return false
}
return true
}
/**
* Checks whether a mutation matches the given {@link MutationFilters}.
* Every filter that is specified must match; filters that are left unspecified are ignored.
* If a `mutationKey` filter is provided but the mutation has no `mutationKey` of its own, it does not match.
*
* @example
* ```ts
* const mutationCache = queryClient.getMutationCache()
*
* const matchingMutations = mutationCache
* .getAll()
* .filter((mutation) => matchMutation({ mutationKey: ['addPost'] }, mutation))
* ```
*/
export function matchMutation(
filters: MutationFilters,
mutation: Mutation<any, any>,
): boolean {
const { exact, status, predicate, mutationKey } = filters
if (mutationKey) {
if (!mutation.options.mutationKey) {
return false
}
if (exact) {
if (hashKey(mutation.options.mutationKey) !== hashKey(mutationKey)) {
return false
}
} else if (!partialMatchKey(mutation.options.mutationKey, mutationKey)) {
return false
}
}
if (status && mutation.state.status !== status) {
return false
}
if (predicate && !predicate(mutation)) {
return false
}
return true
}
export function hashQueryKeyByOptions<TQueryKey extends QueryKey = QueryKey>(
queryKey: TQueryKey,
options?: Pick<QueryOptions<any, any, any, any>, 'queryKeyHashFn'>,
): string {
const hashFn = options?.queryKeyHashFn || hashKey
return hashFn(queryKey)
}
/**
* Default query & mutation keys hash function.
* Hashes the value into a stable hash.
*
* @example
* ```ts
* // Object keys are sorted, so key order doesn't affect the hash:
* hashKey(['todos', { page: 1, filter: 'done' }]) // === '["todos",{"filter":"done","page":1}]'
* ```
*/
export function hashKey(queryKey: QueryKey | MutationKey): string {
return JSON.stringify(queryKey, (_, val) =>
isPlainObject(val)
? Object.keys(val)
.sort()
.reduce((result, key) => {
result[key] = val[key]
return result
}, {} as any)
: val,
)
}
/**
* Checks if key `b` partially matches with key `a`.
*/
export function partialMatchKey(a: QueryKey, b: QueryKey): boolean
export function partialMatchKey(a: any, b: any): boolean {
if (a === b) {
return true
}
if (typeof a !== typeof b) {
return false
}
if (a && b && typeof a === 'object' && typeof b === 'object') {
if (Array.isArray(a) && Array.isArray(b)) {
if (b.length > a.length) return false
for (let i = 0; i < b.length; i++) {
if (!partialMatchKey(a[i], b[i])) {
return false
}
}
return true
}
const bKeys = Object.keys(b)
for (const key of bKeys) {
if (!partialMatchKey(a[key], b[key])) {
return false
}
}
return true
}
return false
}
const hasOwn = Object.prototype.hasOwnProperty
/**
* This function returns `a` if `b` is deeply equal.
* If not, it will replace any deeply equal children of `b` with those of `a`.
* This can be used for structural sharing between JSON values for example.
*/
export function replaceEqualDeep<T>(a: unknown, b: T, depth?: number): T
export function replaceEqualDeep(a: any, b: any, depth = 0): any {
if (a === b) {
return a
}
if (depth > 500) return b
const array = isPlainArray(a) && isPlainArray(b)
if (!array && !(isPlainObject(a) && isPlainObject(b))) return b
const aItems = array ? a : Object.keys(a)
const aSize = aItems.length
const bItems = array ? b : Object.keys(b)
const bSize = bItems.length
const copy: any = array ? new Array(bSize) : {}
let equalItems = 0
for (let i = 0; i < bSize; i++) {
const key: any = array ? i : bItems[i]
const aItem = a[key]
const bItem = b[key]
if (aItem === bItem) {
copy[key] = aItem
if (array ? i < aSize : hasOwn.call(a, key)) equalItems++
continue
}
if (
aItem === null ||
bItem === null ||
typeof aItem !== 'object' ||
typeof bItem !== 'object'
) {
copy[key] = bItem
continue
}
const v = replaceEqualDeep(aItem, bItem, depth + 1)
copy[key] = v
if (v === aItem) equalItems++
}
return aSize === bSize && equalItems === aSize ? a : copy
}
/**
* Shallow compare objects.
*/
export function shallowEqualObjects<T extends Record<string, any>>(
a: T,
b: T | undefined,
): boolean {
if (!b || Object.keys(a).length !== Object.keys(b).length) {
return false
}
for (const key in a) {
if (a[key] !== b[key]) {
return false
}
}
return true
}
export function isPlainArray(value: unknown): value is Array<unknown> {
return Array.isArray(value) && value.length === Object.keys(value).length
}
// Copied from: https://github.com/jonschlinkert/is-plain-object
export function isPlainObject(o: any): o is Record<PropertyKey, unknown> {
if (!hasObjectPrototype(o)) {
return false
}
// If has no constructor
const ctor = o.constructor
if (ctor === undefined) {
return true
}
// If has modified prototype
const prot = ctor.prototype
if (!hasObjectPrototype(prot)) {
return false
}
// If constructor does not have an Object-specific method
if (!prot.hasOwnProperty('isPrototypeOf')) {
return false
}
// Handles Objects created by Object.create(<arbitrary prototype>)
if (Object.getPrototypeOf(o) !== Object.prototype) {
return false
}
// Most likely a plain Object
return true
}
function hasObjectPrototype(o: any): boolean {
return Object.prototype.toString.call(o) === '[object Object]'
}
export function sleep(timeout: number): Promise<void> {
return new Promise((resolve) => {
timeoutManager.setTimeout(resolve, timeout)
})
}
export function replaceData<
TData,
TOptions extends QueryOptions<any, any, any, any>,
>(prevData: TData | undefined, data: TData, options: TOptions): TData {
if (typeof options.structuralSharing === 'function') {
return options.structuralSharing(prevData, data) as TData
} else if (options.structuralSharing !== false) {
if (process.env.NODE_ENV !== 'production') {
try {
return replaceEqualDeep(prevData, data)
} catch (error) {
console.error(
`Structural sharing requires data to be JSON serializable. To fix this, turn off structuralSharing or return JSON-serializable data from your queryFn. [${options.queryHash}]: ${error}`,
)
// Prevent the replaceEqualDeep from being called again down below.
throw error
}
}
// Structurally share data between prev and new data if needed
return replaceEqualDeep(prevData, data)
}
return data
}
/**
* Intended to be passed as a query's `placeholderData` option, for example
* `placeholderData: keepPreviousData`. Instead of resetting the query's data to `undefined` while a new
* query key is fetching, it keeps displaying the previously fetched data until the new data arrives.
*
* @example
* ```ts
* new QueryObserver(queryClient, {
* queryKey: ['posts', page],
* queryFn: () => fetchPosts(page),
* placeholderData: keepPreviousData,
* })
* ```
*/
export function keepPreviousData<T>(
previousData: T | undefined,
): T | undefined {
return previousData
}
export function addToEnd<T>(items: Array<T>, item: T, max = 0): Array<T> {
const newItems = [...items, item]
return max && newItems.length > max ? newItems.slice(1) : newItems
}
export function addToStart<T>(items: Array<T>, item: T, max = 0): Array<T> {
const newItems = [item, ...items]
return max && newItems.length > max ? newItems.slice(0, -1) : newItems
}
/**
* Sentinel value that can be passed as a query's `queryFn` to conditionally disable the query (equivalent
* to `enabled: false`) while preserving full type inference for the query's data. Unlike `enabled: false`,
* a query disabled via `skipToken` cannot be triggered with `refetch`.
*
* @example
* ```ts
* new QueryObserver(queryClient, {
* queryKey: ['post', postId],
* queryFn: postId != null ? () => fetchPost(postId) : skipToken,
* })
* ```
*/
export const skipToken = Symbol()
/**
* The type of the {@link skipToken} sentinel value.
*/
export type SkipToken = typeof skipToken
export function ensureQueryFn<
TQueryFnData = unknown,
TQueryKey extends QueryKey = QueryKey,
>(
options: {
queryFn?: QueryFunction<TQueryFnData, TQueryKey> | SkipToken
queryHash?: string
},
fetchOptions?: FetchOptions<TQueryFnData>,
): QueryFunction<TQueryFnData, TQueryKey> {
if (process.env.NODE_ENV !== 'production') {
if (options.queryFn === skipToken) {
console.error(
`Attempted to invoke queryFn when set to skipToken. This is likely a configuration error. Query hash: '${options.queryHash}'`,
)
}
}
// if we attempt to retry a fetch that was triggered from an initialPromise
// when we don't have a queryFn yet, we can't retry, so we just return the already rejected initialPromise
// if an observer has already mounted, we will be able to retry with that queryFn
if (!options.queryFn && fetchOptions?.initialPromise) {
return () => fetchOptions.initialPromise!
}
if (!options.queryFn || options.queryFn === skipToken) {
return () =>
Promise.reject(new Error(`Missing queryFn: '${options.queryHash}'`))
}
return options.queryFn
}
/**
* Resolves a `throwOnError` option to a boolean.
* If `throwOnError` is a function, it is called with `params` (e.g. the error and, depending on the caller,
* additional context such as the query or mutation) and its result is returned, allowing the throwing
* behavior to be decided per error. Otherwise, `throwOnError` itself is coerced to a boolean (`undefined`
* resolves to `false`).
*
* @example
* ```ts
* const throwOnError =
* query.state.error && typeof options.throwOnError === 'function'
* ? shouldThrowError(options.throwOnError, [query.state.error, query])
* : options.throwOnError
* ```
*/
export function shouldThrowError<T extends (...args: Array<any>) => boolean>(
throwOnError: boolean | T | undefined,
params: Parameters<T>,
): boolean {
// Allow throwOnError function to override throwing behavior on a per-error basis
if (typeof throwOnError === 'function') {
return throwOnError(...params)
}
return !!throwOnError
}
export function addConsumeAwareSignal<T>(
object: T,
getSignal: () => AbortSignal,
onCancelled: VoidFunction,
): T & { signal: AbortSignal } {
let consumed = false
let signal: AbortSignal | undefined
Object.defineProperty(object, 'signal', {
enumerable: true,
get: () => {
signal ??= getSignal()
if (consumed) {
return signal
}
consumed = true
if (signal.aborted) {
onCancelled()
} else {
signal.addEventListener('abort', onCancelled, { once: true })
}
return signal
},
})
return object as T & { signal: AbortSignal }
}

View File

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

View File

@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./dist-ts/legacy"
},
"include": ["src"],
"exclude": ["src/__tests__"]
}

View File

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

View File

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

View File

@@ -0,0 +1,31 @@
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',
coverage: {
enabled: !!process.env.CI,
provider: 'istanbul',
include: ['src/**/*'],
exclude: ['src/__tests__/**'],
},
typecheck: { enabled: true },
restoreMocks: true,
},
})