snapshot of https://github.com/TanStack/query.git (main, 2026-09-16; blobs > 131072B omitted)
This commit is contained in:
331
packages/query-core/CHANGELOG.md
Normal file
331
packages/query-core/CHANGELOG.md
Normal 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))
|
||||
5
packages/query-core/eslint.config.js
Normal file
5
packages/query-core/eslint.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
// @ts-check
|
||||
|
||||
import rootConfig from './root.eslint.config.js'
|
||||
|
||||
export default [...rootConfig]
|
||||
62
packages/query-core/package.json
Normal file
62
packages/query-core/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1
packages/query-core/root.eslint.config.js
Symbolic link
1
packages/query-core/root.eslint.config.js
Symbolic link
@@ -0,0 +1 @@
|
||||
../../eslint.config.js
|
||||
1
packages/query-core/root.tsdown.config.js
Symbolic link
1
packages/query-core/root.tsdown.config.js
Symbolic link
@@ -0,0 +1 @@
|
||||
../../scripts/getTsdownConfig.js
|
||||
341
packages/query-core/src/__tests__/OmitKeyof.test-d.ts
Normal file
341
packages/query-core/src/__tests__/OmitKeyof.test-d.ts
Normal 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 }>()
|
||||
})
|
||||
})
|
||||
@@ -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>()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
108
packages/query-core/src/__tests__/focusManager.test-d.tsx
Normal file
108
packages/query-core/src/__tests__/focusManager.test-d.tsx
Normal 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>()
|
||||
})
|
||||
})
|
||||
})
|
||||
175
packages/query-core/src/__tests__/focusManager.test.tsx
Normal file
175
packages/query-core/src/__tests__/focusManager.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
531
packages/query-core/src/__tests__/hydration.test-d.tsx
Normal file
531
packages/query-core/src/__tests__/hydration.test-d.tsx
Normal 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>()
|
||||
})
|
||||
})
|
||||
})
|
||||
2078
packages/query-core/src/__tests__/hydration.test.tsx
Normal file
2078
packages/query-core/src/__tests__/hydration.test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
533
packages/query-core/src/__tests__/infiniteQueryBehavior.test.tsx
Normal file
533
packages/query-core/src/__tests__/infiniteQueryBehavior.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
1335
packages/query-core/src/__tests__/infiniteQueryObserver.test-d.tsx
Normal file
1335
packages/query-core/src/__tests__/infiniteQueryObserver.test-d.tsx
Normal file
File diff suppressed because it is too large
Load Diff
252
packages/query-core/src/__tests__/infiniteQueryObserver.test.tsx
Normal file
252
packages/query-core/src/__tests__/infiniteQueryObserver.test.tsx
Normal 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
814
packages/query-core/src/__tests__/mutation.test-d.tsx
Normal file
814
packages/query-core/src/__tests__/mutation.test-d.tsx
Normal 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>()
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
1271
packages/query-core/src/__tests__/mutation.test.tsx
Normal file
1271
packages/query-core/src/__tests__/mutation.test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
430
packages/query-core/src/__tests__/mutationCache.test-d.tsx
Normal file
430
packages/query-core/src/__tests__/mutationCache.test-d.tsx
Normal 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>
|
||||
>()
|
||||
})
|
||||
})
|
||||
})
|
||||
511
packages/query-core/src/__tests__/mutationCache.test.tsx
Normal file
511
packages/query-core/src/__tests__/mutationCache.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
1015
packages/query-core/src/__tests__/mutationObserver.test-d.tsx
Normal file
1015
packages/query-core/src/__tests__/mutationObserver.test-d.tsx
Normal file
File diff suppressed because it is too large
Load Diff
544
packages/query-core/src/__tests__/mutationObserver.test.tsx
Normal file
544
packages/query-core/src/__tests__/mutationObserver.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
205
packages/query-core/src/__tests__/notifyManager.test-d.tsx
Normal file
205
packages/query-core/src/__tests__/notifyManager.test-d.tsx
Normal 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>()
|
||||
})
|
||||
})
|
||||
})
|
||||
106
packages/query-core/src/__tests__/notifyManager.test.tsx
Normal file
106
packages/query-core/src/__tests__/notifyManager.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
100
packages/query-core/src/__tests__/onlineManager.test-d.tsx
Normal file
100
packages/query-core/src/__tests__/onlineManager.test-d.tsx
Normal 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>()
|
||||
})
|
||||
})
|
||||
})
|
||||
190
packages/query-core/src/__tests__/onlineManager.test.tsx
Normal file
190
packages/query-core/src/__tests__/onlineManager.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
327
packages/query-core/src/__tests__/queriesObserver.test-d.tsx
Normal file
327
packages/query-core/src/__tests__/queriesObserver.test-d.tsx
Normal 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>()
|
||||
})
|
||||
})
|
||||
})
|
||||
854
packages/query-core/src/__tests__/queriesObserver.test.tsx
Normal file
854
packages/query-core/src/__tests__/queriesObserver.test.tsx
Normal 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 },
|
||||
])
|
||||
})
|
||||
})
|
||||
976
packages/query-core/src/__tests__/query.test-d.tsx
Normal file
976
packages/query-core/src/__tests__/query.test-d.tsx
Normal 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'
|
||||
>()
|
||||
})
|
||||
})
|
||||
})
|
||||
1473
packages/query-core/src/__tests__/query.test.tsx
Normal file
1473
packages/query-core/src/__tests__/query.test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
496
packages/query-core/src/__tests__/queryCache.test-d.tsx
Normal file
496
packages/query-core/src/__tests__/queryCache.test-d.tsx
Normal 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>()
|
||||
})
|
||||
})
|
||||
})
|
||||
456
packages/query-core/src/__tests__/queryCache.test.tsx
Normal file
456
packages/query-core/src/__tests__/queryCache.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
1468
packages/query-core/src/__tests__/queryClient.test-d.tsx
Normal file
1468
packages/query-core/src/__tests__/queryClient.test-d.tsx
Normal file
File diff suppressed because it is too large
Load Diff
3202
packages/query-core/src/__tests__/queryClient.test.tsx
Normal file
3202
packages/query-core/src/__tests__/queryClient.test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1899
packages/query-core/src/__tests__/queryObserver.test-d.tsx
Normal file
1899
packages/query-core/src/__tests__/queryObserver.test-d.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1983
packages/query-core/src/__tests__/queryObserver.test.tsx
Normal file
1983
packages/query-core/src/__tests__/queryObserver.test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
182
packages/query-core/src/__tests__/removable.test.tsx
Normal file
182
packages/query-core/src/__tests__/removable.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
624
packages/query-core/src/__tests__/retryer.test-d.tsx
Normal file
624
packages/query-core/src/__tests__/retryer.test-d.tsx
Normal 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>()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
315
packages/query-core/src/__tests__/retryer.test.tsx
Normal file
315
packages/query-core/src/__tests__/retryer.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
355
packages/query-core/src/__tests__/streamedQuery.test-d.tsx
Normal file
355
packages/query-core/src/__tests__/streamedQuery.test-d.tsx
Normal 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]
|
||||
>()
|
||||
})
|
||||
})
|
||||
})
|
||||
745
packages/query-core/src/__tests__/streamedQuery.test.tsx
Normal file
745
packages/query-core/src/__tests__/streamedQuery.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
191
packages/query-core/src/__tests__/subscribable.test-d.tsx
Normal file
191
packages/query-core/src/__tests__/subscribable.test-d.tsx
Normal 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>()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
109
packages/query-core/src/__tests__/subscribable.test.tsx
Normal file
109
packages/query-core/src/__tests__/subscribable.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
285
packages/query-core/src/__tests__/timeoutManager.test-d.tsx
Normal file
285
packages/query-core/src/__tests__/timeoutManager.test-d.tsx
Normal 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>()
|
||||
})
|
||||
})
|
||||
})
|
||||
136
packages/query-core/src/__tests__/timeoutManager.test.tsx
Normal file
136
packages/query-core/src/__tests__/timeoutManager.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
893
packages/query-core/src/__tests__/utils.test-d.tsx
Normal file
893
packages/query-core/src/__tests__/utils.test-d.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
745
packages/query-core/src/__tests__/utils.test.tsx
Normal file
745
packages/query-core/src/__tests__/utils.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
28
packages/query-core/src/__tests__/utils.ts
Normal file
28
packages/query-core/src/__tests__/utils.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
37
packages/query-core/src/environmentManager.ts
Normal file
37
packages/query-core/src/environmentManager.ts
Normal 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
|
||||
},
|
||||
}
|
||||
142
packages/query-core/src/focusManager.ts
Normal file
142
packages/query-core/src/focusManager.ts
Normal 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()
|
||||
394
packages/query-core/src/hydration.ts
Normal file
394
packages/query-core/src/hydration.ts
Normal 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)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
62
packages/query-core/src/index.ts
Normal file
62
packages/query-core/src/index.ts
Normal 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'
|
||||
176
packages/query-core/src/infiniteQueryBehavior.ts
Normal file
176
packages/query-core/src/infiniteQueryBehavior.ts
Normal 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
|
||||
}
|
||||
251
packages/query-core/src/infiniteQueryObserver.ts
Normal file
251
packages/query-core/src/infiniteQueryObserver.ts
Normal 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
|
||||
535
packages/query-core/src/mutation.ts
Normal file
535
packages/query-core/src/mutation.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
335
packages/query-core/src/mutationCache.ts
Normal file
335
packages/query-core/src/mutationCache.ts
Normal 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
|
||||
}
|
||||
306
packages/query-core/src/mutationObserver.ts
Normal file
306
packages/query-core/src/mutationObserver.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
144
packages/query-core/src/notifyManager.ts
Normal file
144
packages/query-core/src/notifyManager.ts
Normal 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()
|
||||
117
packages/query-core/src/onlineManager.ts
Normal file
117
packages/query-core/src/onlineManager.ts
Normal 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()
|
||||
402
packages/query-core/src/queriesObserver.ts
Normal file
402
packages/query-core/src/queriesObserver.ts
Normal 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
|
||||
}
|
||||
973
packages/query-core/src/query.ts
Normal file
973
packages/query-core/src/query.ts
Normal 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',
|
||||
}
|
||||
}
|
||||
357
packages/query-core/src/queryCache.ts
Normal file
357
packages/query-core/src/queryCache.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
1093
packages/query-core/src/queryClient.ts
Normal file
1093
packages/query-core/src/queryClient.ts
Normal file
File diff suppressed because it is too large
Load Diff
902
packages/query-core/src/queryObserver.ts
Normal file
902
packages/query-core/src/queryObserver.ts
Normal 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))
|
||||
)
|
||||
}
|
||||
40
packages/query-core/src/removable.ts
Normal file
40
packages/query-core/src/removable.ts
Normal 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
|
||||
}
|
||||
255
packages/query-core/src/retryer.ts
Normal file
255
packages/query-core/src/retryer.ts
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
||||
144
packages/query-core/src/streamedQuery.ts
Normal file
144
packages/query-core/src/streamedQuery.ts
Normal 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
|
||||
}
|
||||
}
|
||||
30
packages/query-core/src/subscribable.ts
Normal file
30
packages/query-core/src/subscribable.ts
Normal 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
|
||||
}
|
||||
}
|
||||
243
packages/query-core/src/timeoutManager.ts
Normal file
243
packages/query-core/src/timeoutManager.ts
Normal 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)
|
||||
}
|
||||
1541
packages/query-core/src/types.ts
Normal file
1541
packages/query-core/src/types.ts
Normal file
File diff suppressed because it is too large
Load Diff
617
packages/query-core/src/utils.ts
Normal file
617
packages/query-core/src/utils.ts
Normal 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 }
|
||||
}
|
||||
8
packages/query-core/tsconfig.json
Normal file
8
packages/query-core/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist-ts",
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["src", "*.config.ts", "*.config.js", "package.json"]
|
||||
}
|
||||
8
packages/query-core/tsconfig.legacy.json
Normal file
8
packages/query-core/tsconfig.legacy.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist-ts/legacy"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/__tests__"]
|
||||
}
|
||||
10
packages/query-core/tsconfig.prod.json
Normal file
10
packages/query-core/tsconfig.prod.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"incremental": false,
|
||||
"composite": false,
|
||||
"rootDir": "../../"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/__tests__"]
|
||||
}
|
||||
7
packages/query-core/tsdown.config.ts
Normal file
7
packages/query-core/tsdown.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
import { legacyConfig, modernConfig } from './root.tsdown.config.js'
|
||||
|
||||
export default defineConfig([
|
||||
modernConfig({ entry: ['src/*.ts'] }),
|
||||
legacyConfig({ entry: ['src/*.ts'] }),
|
||||
])
|
||||
31
packages/query-core/vite.config.ts
Normal file
31
packages/query-core/vite.config.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user