snapshot of https://github.com/TanStack/query.git (main, 2026-09-16; blobs > 131072B omitted)
This commit is contained in:
386
packages/preact-query/CHANGELOG.md
Normal file
386
packages/preact-query/CHANGELOG.md
Normal file
@@ -0,0 +1,386 @@
|
||||
# @tanstack/preact-query
|
||||
|
||||
## 5.102.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#11315](https://github.com/TanStack/query/pull/11315) [`6c27607`](https://github.com/TanStack/query/commit/6c27607d7ffc87612a8f1222a95f28bf5f9a045e) - fix(preact-query/useQueries): keep unsubscribed idle
|
||||
|
||||
- Updated dependencies []:
|
||||
- @tanstack/query-core@5.102.8
|
||||
|
||||
## 5.102.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#11309](https://github.com/TanStack/query/pull/11309) [`67fddee`](https://github.com/TanStack/query/commit/67fddee6310ca0dd87749ed7a1b4a7178c6e6aae) - fix(preact-query): propagate falsy errors to the error boundary
|
||||
|
||||
- Updated dependencies []:
|
||||
- @tanstack/query-core@5.102.7
|
||||
|
||||
## 5.102.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @tanstack/query-core@5.102.6
|
||||
|
||||
## 5.102.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`578e5c2`](https://github.com/TanStack/query/commit/578e5c26e8ebd0d7351b4b8e2bafba695e672b8d)]:
|
||||
- @tanstack/query-core@5.102.5
|
||||
|
||||
## 5.102.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`a05df6a`](https://github.com/TanStack/query/commit/a05df6aefb0e2489ec2c879ae16e2ee7cb3123ec)]:
|
||||
- @tanstack/query-core@5.102.4
|
||||
|
||||
## 5.102.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#11274](https://github.com/TanStack/query/pull/11274) [`714c285`](https://github.com/TanStack/query/commit/714c2856e09bbaf7448ccc02364887e36c18b37c) - fix(preact-query/usePrefetchQuery): replace deprecated `queryClient.fetchQuery` with `queryClient.query`
|
||||
|
||||
- Updated dependencies []:
|
||||
- @tanstack/query-core@5.102.3
|
||||
|
||||
## 5.102.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`80fbf73`](https://github.com/TanStack/query/commit/80fbf73e77892d702c107e14a84c219a8ed825dc)]:
|
||||
- @tanstack/query-core@5.102.2
|
||||
|
||||
## 5.102.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`134890d`](https://github.com/TanStack/query/commit/134890dbdc60e4fb0313e44b512d29284ca82f96)]:
|
||||
- @tanstack/query-core@5.102.1
|
||||
|
||||
## 5.102.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#10669](https://github.com/TanStack/query/pull/10669) [`4e48c58`](https://github.com/TanStack/query/commit/4e48c581b70216e9c66a72cc328b61789194db4f) - preact-query: update usePrefetchQuery and usePrefetchInfiniteQuery to use queryClient.query and queryClient.infiniteQuery
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#11245](https://github.com/TanStack/query/pull/11245) [`37127db`](https://github.com/TanStack/query/commit/37127dbe479a4892dbbe28ac8ceabf8832b5f1a3) - revert: remove NoInfer from useQuery return types
|
||||
|
||||
- [#10373](https://github.com/TanStack/query/pull/10373) [`6e3d521`](https://github.com/TanStack/query/commit/6e3d521fe54f78624e53c1c6f9cadd678504bee8) - fix(types): propagate generic type parameters to `useMutationState` select callback
|
||||
|
||||
- [#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.
|
||||
|
||||
- [#11147](https://github.com/TanStack/query/pull/11147) [`cb6c9d3`](https://github.com/TanStack/query/commit/cb6c9d3725db0fed94fb5133820e41b7c326d2ef) - Default `TData` of `UseInfiniteQueryOptions` and `UseSuspenseInfiniteQueryOptions` to `InfiniteData<TQueryFnData>` so it matches the hook generics.
|
||||
|
||||
- [#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`
|
||||
|
||||
- [#11166](https://github.com/TanStack/query/pull/11166) [`4913802`](https://github.com/TanStack/query/commit/49138028953f38097cfac4a6e9843c987705325f) - Evaluate a function-form `throwOnError` against the actual query error before disabling `retryOnMount` (port of [#9338](https://github.com/TanStack/query/issues/9338)), so errored queries whose `throwOnError` returns `false` are retried on mount again.
|
||||
|
||||
- [#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.
|
||||
|
||||
- [#11233](https://github.com/TanStack/query/pull/11233) [`b866a95`](https://github.com/TanStack/query/commit/b866a95adde7e8465462526df5870ebc12340b36) - remove unused experimental_beforeQuery and experimental_afterQuery hooks
|
||||
|
||||
- [#11144](https://github.com/TanStack/query/pull/11144) [`e546d03`](https://github.com/TanStack/query/commit/e546d03bef116c66a05dbf42ccfd70b6d8600a8f) - fix: remove placeholderData from suspense infinite query
|
||||
|
||||
- Updated dependencies [[`34f7cee`](https://github.com/TanStack/query/commit/34f7ceed09c10e4a3aa2df31a106ddf02ec4e787), [`b4368c4`](https://github.com/TanStack/query/commit/b4368c43792349f6c29d1fb41f7ee1ef3a8bdd2c), [`5bb089d`](https://github.com/TanStack/query/commit/5bb089d153d33933983b7ea80f8d69b51d423698), [`ba4650c`](https://github.com/TanStack/query/commit/ba4650c05e61e33c609e051b948c9f7d31ce70d1), [`294d4e6`](https://github.com/TanStack/query/commit/294d4e62c4f7d674574a4903eef2a1bc3dd1413b), [`1f631b3`](https://github.com/TanStack/query/commit/1f631b3604aab6567fb3f6c90646a5a304641546), [`01a02bf`](https://github.com/TanStack/query/commit/01a02bfad32f9efbc17796be31d6399f1197a655), [`18c1c1e`](https://github.com/TanStack/query/commit/18c1c1ef94883e781ad36d36f9ea4a043ce4260b), [`5448063`](https://github.com/TanStack/query/commit/5448063f828d2270dccd41ae375e1afde35e91f2), [`2215bb0`](https://github.com/TanStack/query/commit/2215bb031139cdc8a84751b37a485c38ca9d2b6e), [`1ef4208`](https://github.com/TanStack/query/commit/1ef42087c9a266c2137d2ef645cbcc662f60ac93), [`5981771`](https://github.com/TanStack/query/commit/5981771abec9330b344a2617543eb05ba7c99e24), [`4a9bef6`](https://github.com/TanStack/query/commit/4a9bef6cf19b1cd6b014032d696129f12a848185), [`bef4bc7`](https://github.com/TanStack/query/commit/bef4bc780ce7cca32b7e3dea85f77d92f82a62a2), [`9656dc4`](https://github.com/TanStack/query/commit/9656dc4e5fef5f8c502579b52459f9e8c72e787c), [`326aaf1`](https://github.com/TanStack/query/commit/326aaf1333e5d9cbc46569c53f218d31684162d2), [`3e83601`](https://github.com/TanStack/query/commit/3e836010032c2586e9f1c66a271fa9114a6401f9), [`c6fc17c`](https://github.com/TanStack/query/commit/c6fc17cfad6e452261c585fabfb8c210e60531ed)]:
|
||||
- @tanstack/query-core@5.102.0
|
||||
|
||||
## 5.101.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @tanstack/query-core@5.101.4
|
||||
|
||||
## 5.101.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`7e3c822`](https://github.com/TanStack/query/commit/7e3c822a10896f41a8f1031c16b85096277af677)]:
|
||||
- @tanstack/query-core@5.101.3
|
||||
|
||||
## 5.101.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @tanstack/query-core@5.101.2
|
||||
|
||||
## 5.101.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`9eff92e`](https://github.com/TanStack/query/commit/9eff92ed86e284ec0125b3a3539d028688235bd1)]:
|
||||
- @tanstack/query-core@5.101.1
|
||||
|
||||
## 5.101.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @tanstack/query-core@5.101.0
|
||||
|
||||
## 5.100.14
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @tanstack/query-core@5.100.14
|
||||
|
||||
## 5.100.13
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`d423168`](https://github.com/TanStack/query/commit/d423168f6261a5cb3d353e53b27c8150cc271151)]:
|
||||
- @tanstack/query-core@5.100.13
|
||||
|
||||
## 5.100.12
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @tanstack/query-core@5.100.12
|
||||
|
||||
## 5.100.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @tanstack/query-core@5.100.11
|
||||
|
||||
## 5.100.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @tanstack/query-core@5.100.10
|
||||
|
||||
## 5.100.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`fcee7bd`](https://github.com/TanStack/query/commit/fcee7bdc429385ae8ffa224fa8a7a9ec7b8ee380)]:
|
||||
- @tanstack/query-core@5.100.9
|
||||
|
||||
## 5.100.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @tanstack/query-core@5.100.8
|
||||
|
||||
## 5.100.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @tanstack/query-core@5.100.7
|
||||
|
||||
## 5.100.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @tanstack/query-core@5.100.6
|
||||
|
||||
## 5.100.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`a53ef97`](https://github.com/TanStack/query/commit/a53ef97f87decb8ea2431710f5199431d3c94c8d)]:
|
||||
- @tanstack/query-core@5.100.5
|
||||
|
||||
## 5.100.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @tanstack/query-core@5.100.4
|
||||
|
||||
## 5.100.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`f85d825`](https://github.com/TanStack/query/commit/f85d825e02efbbff02e2081528ed28f5e5382f7a)]:
|
||||
- @tanstack/query-core@5.100.3
|
||||
|
||||
## 5.100.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`ea4497e`](https://github.com/TanStack/query/commit/ea4497e8aa00d8c1c3a36fb1e17563a889d6ab31), [`d6a7bf3`](https://github.com/TanStack/query/commit/d6a7bf3e3e024c1a77d0536813238cc8007a5fa7), [`645d5d1`](https://github.com/TanStack/query/commit/645d5d130f5e8017cb1bf1a37987f7b980aed705)]:
|
||||
- @tanstack/query-core@5.100.2
|
||||
|
||||
## 5.100.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`1bb0d23`](https://github.com/TanStack/query/commit/1bb0d234280fd4ae1725c439088426a20593a8df)]:
|
||||
- @tanstack/query-core@5.100.1
|
||||
|
||||
## 5.100.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`6540a41`](https://github.com/TanStack/query/commit/6540a4126b1c087d86d64525e78f32d9920dcd31)]:
|
||||
- @tanstack/query-core@5.100.0
|
||||
|
||||
## 5.99.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @tanstack/query-core@5.99.2
|
||||
|
||||
## 5.99.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @tanstack/query-core@5.99.1
|
||||
|
||||
## 5.99.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @tanstack/query-core@5.99.0
|
||||
|
||||
## 5.98.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @tanstack/query-core@5.98.0
|
||||
|
||||
## 5.97.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`2bfb12c`](https://github.com/TanStack/query/commit/2bfb12cc44f1d8495106136e4ddacb817135f8f9)]:
|
||||
- @tanstack/query-core@5.97.0
|
||||
|
||||
## 5.96.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @tanstack/query-core@5.96.2
|
||||
|
||||
## 5.96.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @tanstack/query-core@5.96.1
|
||||
|
||||
## 5.96.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @tanstack/query-core@5.96.0
|
||||
|
||||
## 5.95.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`cd5a35b`](https://github.com/TanStack/query/commit/cd5a35b328837781aa4f9305bb2bd7877ca934e9)]:
|
||||
- @tanstack/query-core@5.95.2
|
||||
|
||||
## 5.95.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`1f1775c`](https://github.com/TanStack/query/commit/1f1775ca92f2b6c035682947ff3b3424804ff31a)]:
|
||||
- @tanstack/query-core@5.95.1
|
||||
|
||||
## 5.95.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @tanstack/query-core@5.95.0
|
||||
|
||||
## 5.94.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- fix(\*): resolve issue about excluded build directory ([#10312](https://github.com/TanStack/query/pull/10312))
|
||||
|
||||
- Updated dependencies [[`4b6536d`](https://github.com/TanStack/query/commit/4b6536dfce99036f4e37f52943c6fed3ad0e0a18)]:
|
||||
- @tanstack/query-core@5.94.5
|
||||
|
||||
## 5.94.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- chore: fixed version ([#10064](https://github.com/TanStack/query/pull/10064))
|
||||
|
||||
- Updated dependencies [[`4c75210`](https://github.com/TanStack/query/commit/4c75210ce8235fe3d39b67e1029eff11278927cc)]:
|
||||
- @tanstack/query-core@5.94.4
|
||||
|
||||
## 5.94.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- fix: stop node types from leaking into browser ([#10302](https://github.com/TanStack/query/pull/10302))
|
||||
|
||||
## 5.94.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- fix(streamedQuery): maintain error state on reset refetch with initialData defined ([#10287](https://github.com/TanStack/query/pull/10287))
|
||||
|
||||
- Updated dependencies [[`248975e`](https://github.com/TanStack/query/commit/248975e896f585f6eaa505c796e73fcf7bfd1eec)]:
|
||||
- @tanstack/query-core@5.91.2
|
||||
|
||||
## 5.94.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- fix(core): cancel paused initial fetch when last observer unsubscribes ([#10291](https://github.com/TanStack/query/pull/10291))
|
||||
|
||||
- Updated dependencies [[`a89aab9`](https://github.com/TanStack/query/commit/a89aab975581c25c113a26c8af486b4cafad272a)]:
|
||||
- @tanstack/query-core@5.91.1
|
||||
|
||||
## 5.94.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- feat: environmentManager ([#10199](https://github.com/TanStack/query/pull/10199))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`6fa901b`](https://github.com/TanStack/query/commit/6fa901b97a22a80d0fca3f6ed86237ff0cbdd13b)]:
|
||||
- @tanstack/query-core@5.91.0
|
||||
|
||||
## 5.93.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- feat: Add preact persist plugin ([#10120](https://github.com/TanStack/query/pull/10120))
|
||||
|
||||
## 5.92.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- feat: Add preact query devtools ([#10119](https://github.com/TanStack/query/pull/10119))
|
||||
|
||||
## 5.91.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- refactor(preact-query/useQueries): remove unreachable 'willFetch' branch in suspense promise collection ([#10188](https://github.com/TanStack/query/pull/10188))
|
||||
|
||||
## 5.91.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- feat: Preact Adapter ([#9935](https://github.com/TanStack/query/pull/9935))
|
||||
62
packages/preact-query/README.md
Normal file
62
packages/preact-query/README.md
Normal file
@@ -0,0 +1,62 @@
|
||||
<img src="https://static.scarf.sh/a.png?x-pxid=be2d8a11-9712-4c1d-9963-580b2d4fb133" />
|
||||
|
||||
<picture>
|
||||
<source
|
||||
media="(prefers-color-scheme: dark)"
|
||||
srcset="https://tanstack.com/api/readme/query.png?framework=preact&theme=dark"
|
||||
/>
|
||||
<source
|
||||
media="(prefers-color-scheme: light)"
|
||||
srcset="https://tanstack.com/api/readme/query.png?framework=preact"
|
||||
/>
|
||||
<img
|
||||
src="https://tanstack.com/api/readme/query.png?framework=preact"
|
||||
alt="TanStack Preact Query"
|
||||
width="900"
|
||||
/>
|
||||
</picture>
|
||||
|
||||
Hooks for fetching, caching and updating asynchronous data in Preact
|
||||
|
||||
<a href="https://twitter.com/intent/tweet?button_hashtag=TanStack" target="\_parent">
|
||||
<img alt="#TanStack" src="https://img.shields.io/twitter/url?color=%2308a0e9&label=%23TanStack&style=social&url=https%3A%2F%2Ftwitter.com%2Fintent%2Ftweet%3Fbutton_hashtag%3DTanStack">
|
||||
</a><a href="https://discord.com/invite/WrRKjPJ" target="\_parent">
|
||||
<img alt="" src="https://img.shields.io/badge/Discord-TanStack-%235865F2" />
|
||||
</a><a href="https://github.com/TanStack/query/actions?query=workflow%3A%22preact-query+tests%22">
|
||||
<img src="https://github.com/TanStack/query/workflows/preact-query%20tests/badge.svg" />
|
||||
</a><a href="https://www.npmjs.com/package/@tanstack/query-core" target="\_parent">
|
||||
<img alt="" src="https://img.shields.io/npm/dm/@tanstack/query-core.svg" />
|
||||
</a><a href="https://bundlejs.com/?q=%40tanstack%2Fpreact-query&config=%7B%22esbuild%22%3A%7B%22external%22%3A%5B%22react%22%2C%22react-dom%22%5D%7D%7D&badge=" target="\_parent">
|
||||
<img alt="" src="https://deno.bundlejs.com/?q=@tanstack/preact-query&config={%22esbuild%22:{%22external%22:[%22react%22,%22react-dom%22]}}&badge=detailed" />
|
||||
</a><a href="#badge">
|
||||
<img alt="semantic-release" src="https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg">
|
||||
</a><a href="https://github.com/TanStack/query/discussions">
|
||||
<img alt="Join the discussion on Github" src="https://img.shields.io/badge/Github%20Discussions%20%26%20Support-Chat%20now!-blue" />
|
||||
</a><a href="https://bestofjs.org/projects/tanstack-query"><img alt="Best of JS" src="https://img.shields.io/endpoint?url=https://bestofjs-serverless.now.sh/api/project-badge?fullName=TanStack%2Fquery%26since=daily" /></a><a href="https://github.com/TanStack/query/" target="\_parent">
|
||||
<img alt="" src="https://img.shields.io/github/stars/TanStack/query.svg?style=social&label=Star" />
|
||||
</a><a href="https://twitter.com/tannerlinsley" target="\_parent">
|
||||
<img alt="" src="https://img.shields.io/twitter/follow/tannerlinsley.svg?style=social&label=Follow" />
|
||||
</a> <a href="https://gitpod.io/from-referrer/">
|
||||
<img src="https://img.shields.io/badge/Gitpod-Ready--to--Code-blue?logo=gitpod" alt="Gitpod Ready-to-Code"/>
|
||||
</a>
|
||||
|
||||
Enjoy this library? Try the entire [TanStack](https://tanstack.com)! [TanStack Table](https://github.com/TanStack/table), [TanStack Router](https://github.com/tanstack/router), [TanStack Virtual](https://github.com/tanstack/virtual), [React Charts](https://github.com/TanStack/react-charts), [React Ranger](https://github.com/TanStack/ranger)
|
||||
|
||||
## Visit [tanstack.com/query](https://tanstack.com/query) for docs, guides, API and more!
|
||||
|
||||
## Quick Features
|
||||
|
||||
- Transport/protocol/backend agnostic data fetching (REST, GraphQL, promises, whatever!)
|
||||
- Auto Caching + Refetching (stale-while-revalidate, Window Refocus, Polling/Realtime)
|
||||
- Parallel + Dependent Queries
|
||||
- Mutations + Reactive Query Refetching
|
||||
- Multi-layer Cache + Automatic Garbage Collection
|
||||
- Paginated + Cursor-based Queries
|
||||
- Load-More + Infinite Scroll Queries w/ Scroll Recovery
|
||||
- Request Cancellation
|
||||
- [Preact Suspense](https://preactjs.com/guide/v10/api-reference/#suspense) + Fetch-As-You-Render Query Prefetching -- Though not recommended because of the bulk preact/compat adds
|
||||
- Dedicated Devtools
|
||||
|
||||
### [Become a Sponsor!](https://github.com/sponsors/tannerlinsley/)
|
||||
|
||||
<!-- Use the force, Luke -->
|
||||
38
packages/preact-query/eslint.config.js
Normal file
38
packages/preact-query/eslint.config.js
Normal file
@@ -0,0 +1,38 @@
|
||||
// @ts-check
|
||||
// @ts-ignore: no types for eslint-config-preact
|
||||
import preact from 'eslint-config-preact'
|
||||
// eslint-config-preact uses typescript-eslint under the hood
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
import rootConfig from './root.eslint.config.js'
|
||||
|
||||
export default [
|
||||
...rootConfig,
|
||||
...preact,
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
parser: tseslint.parser,
|
||||
parserOptions: {
|
||||
project: true,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'typescript-eslint': tseslint.plugin,
|
||||
},
|
||||
rules: {
|
||||
// Disable base rule to prevent overload false positives
|
||||
'no-redeclare': 'off',
|
||||
'no-duplicate-imports': 'off',
|
||||
'no-unused-vars': 'off',
|
||||
'import/order': 'off',
|
||||
'sort-imports': 'off',
|
||||
'no-import-assign': 'off',
|
||||
// TS-aware version handles overloads correctly
|
||||
'@typescript-eslint/no-redeclare': 'error',
|
||||
'@typescript-eslint/array-type': 'off',
|
||||
'@typescript-eslint/no-unnecessary-type-assertion': 'off',
|
||||
'@typescript-eslint/no-unnecessary-condition': 'off',
|
||||
},
|
||||
},
|
||||
]
|
||||
82
packages/preact-query/package.json
Normal file
82
packages/preact-query/package.json
Normal file
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"name": "@tanstack/preact-query",
|
||||
"version": "5.102.8",
|
||||
"description": "Hooks for managing, caching and syncing asynchronous and remote data in preact",
|
||||
"author": "tannerlinsley",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/TanStack/query.git",
|
||||
"directory": "packages/preact-query"
|
||||
},
|
||||
"homepage": "https://tanstack.com/query",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "premove ./build ./coverage ./dist-ts ./.cache/test-types",
|
||||
"compile": "tsc --project tsconfig.json",
|
||||
"test:eslint": "eslint --concurrency=auto ./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.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": "pnpm build:tsdown && pnpm build:codemods",
|
||||
"build:tsdown": "tsdown --tsconfig tsconfig.prod.json",
|
||||
"build:codemods": "cpy ../query-codemods/* ./build/codemods"
|
||||
},
|
||||
"type": "module",
|
||||
"types": "build/legacy/index.d.ts",
|
||||
"main": "build/legacy/index.cjs",
|
||||
"module": "build/legacy/index.js",
|
||||
"exports": {
|
||||
".": {
|
||||
"@tanstack/custom-condition": "./src/index.ts",
|
||||
"import": {
|
||||
"types": "./build/modern/index.d.ts",
|
||||
"default": "./build/modern/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./build/modern/index.d.cts",
|
||||
"default": "./build/modern/index.cjs"
|
||||
}
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"files": [
|
||||
"build",
|
||||
"src",
|
||||
"!src/__tests__",
|
||||
"!build/codemods/node_modules",
|
||||
"!build/codemods/vite.config.ts",
|
||||
"!build/codemods/**/__testfixtures__",
|
||||
"!build/codemods/**/__tests__"
|
||||
],
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@preact/preset-vite": "^2.10.2",
|
||||
"@tanstack/query-persist-client-core": "workspace:*",
|
||||
"@tanstack/query-test-utils": "workspace:*",
|
||||
"@testing-library/preact": "^3.2.4",
|
||||
"cpy-cli": "^5.0.0",
|
||||
"eslint-config-preact": "^2.0.0",
|
||||
"npm-run-all2": "^5.0.0",
|
||||
"preact": "^10.28.0",
|
||||
"preact-render-to-string": "^6.6.4",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "^8.54.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"preact": "^10.0.0"
|
||||
}
|
||||
}
|
||||
1
packages/preact-query/root.eslint.config.js
Symbolic link
1
packages/preact-query/root.eslint.config.js
Symbolic link
@@ -0,0 +1 @@
|
||||
../../eslint.config.js
|
||||
1
packages/preact-query/root.tsdown.config.js
Symbolic link
1
packages/preact-query/root.tsdown.config.js
Symbolic link
@@ -0,0 +1 @@
|
||||
../../scripts/getTsdownConfig.js
|
||||
168
packages/preact-query/src/HydrationBoundary.tsx
Normal file
168
packages/preact-query/src/HydrationBoundary.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { hydrate } from '@tanstack/query-core'
|
||||
import type {
|
||||
DehydratedState,
|
||||
HydrateOptions,
|
||||
OmitKeyof,
|
||||
QueryClient,
|
||||
} from '@tanstack/query-core'
|
||||
import { Fragment } from 'preact'
|
||||
import type { ComponentChildren } from 'preact'
|
||||
import { useEffect, useMemo, useRef } from 'preact/hooks'
|
||||
|
||||
import { useQueryClient } from './QueryClientProvider'
|
||||
|
||||
/**
|
||||
* The props accepted by `HydrationBoundary`.
|
||||
*/
|
||||
export interface HydrationBoundaryProps {
|
||||
/**
|
||||
* The state to hydrate.
|
||||
*/
|
||||
state: DehydratedState | null | undefined
|
||||
/**
|
||||
* Optional. Note: unlike `hydrate`, `mutations` cannot be set here.
|
||||
*/
|
||||
options?: OmitKeyof<HydrateOptions, 'defaultOptions'> & {
|
||||
defaultOptions?: OmitKeyof<
|
||||
Exclude<HydrateOptions['defaultOptions'], undefined>,
|
||||
'mutations'
|
||||
>
|
||||
}
|
||||
/**
|
||||
* The components to render — always rendered unconditionally, not gated on hydration. New queries are
|
||||
* hydrated into the cache during render; for queries that already exist in the cache, only newer dehydrated
|
||||
* data is hydrated, and that happens in an effect after commit, so `children` may render briefly before it
|
||||
* lands.
|
||||
*/
|
||||
children?: ComponentChildren
|
||||
/**
|
||||
* Use this to use a custom `QueryClient`. Otherwise, the one from the nearest context will be used.
|
||||
*/
|
||||
queryClient?: QueryClient
|
||||
}
|
||||
|
||||
/**
|
||||
* `HydrationBoundary` adds a previously dehydrated state into the `queryClient` that would be returned by
|
||||
* `useQueryClient()`. If the client already contains data, the new queries will be intelligently merged based on
|
||||
* update timestamp.
|
||||
*
|
||||
* Note: Only `queries` can be dehydrated with an `HydrationBoundary`.
|
||||
*
|
||||
* @returns The provided `children`, rendered unconditionally. New queries in `state` are hydrated into the
|
||||
* cache during render; for queries already in the cache, only newer dehydrated data is hydrated, in an effect
|
||||
* after commit.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { HydrationBoundary } from '@tanstack/preact-query'
|
||||
*
|
||||
* function App() {
|
||||
* return <HydrationBoundary state={dehydratedState}>...</HydrationBoundary>
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* Server-side prefetch handed off to the client via `dehydrate`:
|
||||
* ```tsx
|
||||
* import { HydrationBoundary, dehydrate, noop } from '@tanstack/preact-query'
|
||||
*
|
||||
* async function ServerComponent() {
|
||||
* const queryClient = getQueryClient()
|
||||
*
|
||||
* await queryClient
|
||||
* .query({
|
||||
* queryKey: ['posts'],
|
||||
* queryFn: fetchPosts,
|
||||
* })
|
||||
* .catch(noop)
|
||||
*
|
||||
* return (
|
||||
* <HydrationBoundary state={dehydrate(queryClient)}>
|
||||
* <Posts />
|
||||
* </HydrationBoundary>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const HydrationBoundary = ({
|
||||
children,
|
||||
options = {},
|
||||
state,
|
||||
queryClient,
|
||||
}: HydrationBoundaryProps) => {
|
||||
const client = useQueryClient(queryClient)
|
||||
|
||||
const optionsRef = useRef(options)
|
||||
useEffect(() => {
|
||||
optionsRef.current = options
|
||||
})
|
||||
|
||||
// This useMemo is for performance reasons only, everything inside it must
|
||||
// be safe to run in every render and code here should be read as "in render".
|
||||
//
|
||||
// This code needs to happen during the render phase, because after initial
|
||||
// SSR, hydration needs to happen _before_ children render. Also, if hydrating
|
||||
// during a transition, we want to hydrate as much as is safe in render so
|
||||
// we can prerender as much as possible.
|
||||
//
|
||||
// For any queries that already exist in the cache, we want to hold back on
|
||||
// hydrating until _after_ the render phase. The reason for this is that during
|
||||
// transitions, we don't want the existing queries and observers to update to
|
||||
// the new data on the current page, only _after_ the transition is committed.
|
||||
// If the transition is aborted, we will have hydrated any _new_ queries, but
|
||||
// we throw away the fresh data for any existing ones to avoid unexpectedly
|
||||
// updating the UI.
|
||||
const hydrationQueue: DehydratedState['queries'] | undefined = useMemo(() => {
|
||||
if (state) {
|
||||
if (typeof state !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
const queryCache = client.getQueryCache()
|
||||
// State is supplied from the outside and we might as well fail
|
||||
// gracefully if it has the wrong shape, so while we type `queries`
|
||||
// as required, we still provide a fallback.
|
||||
const queries = state.queries || []
|
||||
|
||||
const newQueries: DehydratedState['queries'] = []
|
||||
const existingQueries: DehydratedState['queries'] = []
|
||||
for (const dehydratedQuery of queries) {
|
||||
const existingQuery = queryCache.get(dehydratedQuery.queryHash)
|
||||
|
||||
if (!existingQuery) {
|
||||
newQueries.push(dehydratedQuery)
|
||||
} else {
|
||||
const hydrationIsNewer =
|
||||
dehydratedQuery.state.dataUpdatedAt >
|
||||
existingQuery.state.dataUpdatedAt ||
|
||||
(dehydratedQuery.promise &&
|
||||
existingQuery.state.status !== 'pending' &&
|
||||
existingQuery.state.fetchStatus !== 'fetching' &&
|
||||
dehydratedQuery.dehydratedAt > existingQuery.state.dataUpdatedAt)
|
||||
|
||||
if (hydrationIsNewer) {
|
||||
existingQueries.push(dehydratedQuery)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (newQueries.length > 0) {
|
||||
// It's actually fine to call this with queries/state that already exists
|
||||
// in the cache, or is older. hydrate() is idempotent for queries.
|
||||
hydrate(client, { queries: newQueries }, optionsRef.current)
|
||||
}
|
||||
if (existingQueries.length > 0) {
|
||||
return existingQueries
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}, [client, state])
|
||||
|
||||
useEffect(() => {
|
||||
if (hydrationQueue) {
|
||||
hydrate(client, { queries: hydrationQueue }, optionsRef.current)
|
||||
}
|
||||
}, [client, hydrationQueue])
|
||||
|
||||
return <Fragment>{children}</Fragment>
|
||||
}
|
||||
19
packages/preact-query/src/IsRestoringProvider.ts
Normal file
19
packages/preact-query/src/IsRestoringProvider.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { createContext } from 'preact'
|
||||
import { useContext } from 'preact/hooks'
|
||||
|
||||
const IsRestoringContext = createContext(false)
|
||||
|
||||
/**
|
||||
* If you are using `PersistQueryClientProvider`, you can also use the `useIsRestoring` hook alongside it to
|
||||
* check if a restore is currently in progress. `useQuery` and friends also check this internally to avoid
|
||||
* race conditions between the restore and mounting queries.
|
||||
*
|
||||
* @returns `true` while a persisted client is being restored, `false` otherwise.
|
||||
*/
|
||||
export const useIsRestoring = () => useContext(IsRestoringContext)
|
||||
|
||||
/**
|
||||
* The Provider that `PersistQueryClientProvider` uses to signal whether a persisted client is currently
|
||||
* being restored, read by `useIsRestoring`.
|
||||
*/
|
||||
export const IsRestoringProvider = IsRestoringContext.Provider
|
||||
86
packages/preact-query/src/QueryClientProvider.tsx
Normal file
86
packages/preact-query/src/QueryClientProvider.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { QueryClient } from '@tanstack/query-core'
|
||||
import { createContext } from 'preact'
|
||||
import type { ComponentChildren, VNode } from 'preact'
|
||||
import { useContext, useEffect } from 'preact/hooks'
|
||||
|
||||
/**
|
||||
* The context that `useQueryClient` reads from. `QueryClientProvider` is the normal way to set it.
|
||||
*/
|
||||
export const QueryClientContext = createContext<QueryClient | undefined>(
|
||||
undefined,
|
||||
)
|
||||
|
||||
/**
|
||||
* The `useQueryClient` hook returns the current `QueryClient` instance.
|
||||
*
|
||||
* @param queryClient - Use this to use a custom `QueryClient`. Otherwise, the one from the nearest context will
|
||||
* be used.
|
||||
* @returns The current `QueryClient` instance.
|
||||
* @throws If no `queryClient` argument is passed and no `QueryClientProvider` is found in the component tree.
|
||||
*/
|
||||
export const useQueryClient = (queryClient?: QueryClient) => {
|
||||
const client = useContext(QueryClientContext)
|
||||
|
||||
if (queryClient) {
|
||||
return queryClient
|
||||
}
|
||||
|
||||
if (!client) {
|
||||
throw new Error('No QueryClient set, use QueryClientProvider to set one')
|
||||
}
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
/**
|
||||
* The props accepted by `QueryClientProvider`.
|
||||
*/
|
||||
export type QueryClientProviderProps = {
|
||||
/**
|
||||
* **Required**
|
||||
*
|
||||
* The `QueryClient` instance to provide.
|
||||
*/
|
||||
client: QueryClient
|
||||
/**
|
||||
* The components that get access to the provided `QueryClient`.
|
||||
*/
|
||||
children?: ComponentChildren
|
||||
}
|
||||
|
||||
/**
|
||||
* Use the `QueryClientProvider` component to connect and provide a `QueryClient` to your application. Also
|
||||
* calls `client.mount()`/`client.unmount()` as this component mounts/unmounts, which subscribes the client to
|
||||
* focus/online events (resuming any paused mutations and refetching as needed when the app regains focus or
|
||||
* comes back online).
|
||||
*
|
||||
* @returns The provided `children`, wrapped so they can read the `QueryClient` via `useQueryClient`.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { QueryClient, QueryClientProvider } from '@tanstack/preact-query'
|
||||
*
|
||||
* const queryClient = new QueryClient()
|
||||
*
|
||||
* function App() {
|
||||
* return <QueryClientProvider client={queryClient}>...</QueryClientProvider>
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const QueryClientProvider = ({
|
||||
client,
|
||||
children,
|
||||
}: QueryClientProviderProps): VNode => {
|
||||
useEffect(() => {
|
||||
client.mount()
|
||||
return () => {
|
||||
client.unmount()
|
||||
}
|
||||
}, [client])
|
||||
|
||||
return (
|
||||
<QueryClientContext.Provider value={client}>
|
||||
{children}
|
||||
</QueryClientContext.Provider>
|
||||
)
|
||||
}
|
||||
168
packages/preact-query/src/QueryErrorResetBoundary.tsx
Normal file
168
packages/preact-query/src/QueryErrorResetBoundary.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { createContext } from 'preact'
|
||||
import type { ComponentChildren } from 'preact'
|
||||
import { useContext, useState } from 'preact/hooks'
|
||||
|
||||
// CONTEXT
|
||||
|
||||
/**
|
||||
* Resets any query errors within the boundary, so queries know they can try again.
|
||||
*/
|
||||
export type QueryErrorResetFunction = () => void
|
||||
|
||||
/**
|
||||
* Returns whether the boundary has been reset and not yet cleared.
|
||||
*/
|
||||
export type QueryErrorIsResetFunction = () => boolean
|
||||
|
||||
/**
|
||||
* Clears the reset state, so queries know not to try again until the boundary is reset again.
|
||||
*/
|
||||
export type QueryErrorClearResetFunction = () => void
|
||||
|
||||
export interface QueryErrorResetBoundaryValue {
|
||||
/**
|
||||
* Clears the reset state, so queries know not to try again until the boundary is reset again.
|
||||
*/
|
||||
clearReset: QueryErrorClearResetFunction
|
||||
/**
|
||||
* Returns whether the boundary has been reset and not yet cleared.
|
||||
*/
|
||||
isReset: QueryErrorIsResetFunction
|
||||
/**
|
||||
* Resets any query errors within the boundary, so queries know they can try again.
|
||||
*/
|
||||
reset: QueryErrorResetFunction
|
||||
}
|
||||
|
||||
function createValue(): QueryErrorResetBoundaryValue {
|
||||
let isReset = false
|
||||
return {
|
||||
clearReset: () => {
|
||||
isReset = false
|
||||
},
|
||||
reset: () => {
|
||||
isReset = true
|
||||
},
|
||||
isReset: () => {
|
||||
return isReset
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const QueryErrorResetBoundaryContext = createContext(createValue())
|
||||
|
||||
// HOOK
|
||||
|
||||
/**
|
||||
* This hook will reset any query errors within the closest `QueryErrorResetBoundary`. If there is no boundary
|
||||
* defined it will reset them globally.
|
||||
*
|
||||
* @returns The boundary's {@link QueryErrorResetBoundaryValue}.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { useErrorBoundary } from 'preact/hooks'
|
||||
* import type { ComponentChildren } from 'preact'
|
||||
* import { useQueryErrorResetBoundary } from '@tanstack/preact-query'
|
||||
*
|
||||
* function App({ children }: { children: ComponentChildren }) {
|
||||
* const { reset } = useQueryErrorResetBoundary()
|
||||
* const [error, resetError] = useErrorBoundary(() => reset())
|
||||
*
|
||||
* if (error) {
|
||||
* return (
|
||||
* <div>
|
||||
* There was an error!
|
||||
* <button onClick={() => resetError()}>Try again</button>
|
||||
* </div>
|
||||
* )
|
||||
* }
|
||||
*
|
||||
* return children
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const useQueryErrorResetBoundary = () =>
|
||||
useContext(QueryErrorResetBoundaryContext)
|
||||
|
||||
// COMPONENT
|
||||
|
||||
/**
|
||||
* A render-prop function usable as `children` on `QueryErrorResetBoundary`.
|
||||
*
|
||||
* @param value - The boundary's {@link QueryErrorResetBoundaryValue}.
|
||||
* @returns The children to render.
|
||||
*/
|
||||
export type QueryErrorResetBoundaryFunction = (
|
||||
value: QueryErrorResetBoundaryValue,
|
||||
) => ComponentChildren
|
||||
|
||||
/**
|
||||
* The props accepted by `QueryErrorResetBoundary`.
|
||||
*/
|
||||
export interface QueryErrorResetBoundaryProps {
|
||||
/**
|
||||
* Either a plain node, or a function that receives the boundary's {@link QueryErrorResetBoundaryValue} and
|
||||
* returns a node.
|
||||
*/
|
||||
children: QueryErrorResetBoundaryFunction | ComponentChildren
|
||||
}
|
||||
|
||||
/**
|
||||
* When using `suspense` or `throwOnError` in your queries, you need a way to let queries know that you want to
|
||||
* try again when re-rendering after some error occurred. With the `QueryErrorResetBoundary` component you can
|
||||
* reset any query errors within the boundaries of the component.
|
||||
*
|
||||
* @returns The `children`, rendered as-is, or called with the boundary's {@link QueryErrorResetBoundaryValue}
|
||||
* if `children` is a function.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { useErrorBoundary } from 'preact/hooks'
|
||||
* import type { ComponentChildren } from 'preact'
|
||||
* import { QueryErrorResetBoundary } from '@tanstack/preact-query'
|
||||
*
|
||||
* function App() {
|
||||
* return (
|
||||
* <QueryErrorResetBoundary>
|
||||
* {({ reset }) => (
|
||||
* <ErrorBoundary reset={reset}>
|
||||
* <Page />
|
||||
* </ErrorBoundary>
|
||||
* )}
|
||||
* </QueryErrorResetBoundary>
|
||||
* )
|
||||
* }
|
||||
*
|
||||
* function ErrorBoundary({
|
||||
* children,
|
||||
* reset,
|
||||
* }: {
|
||||
* children: ComponentChildren
|
||||
* reset: () => void
|
||||
* }) {
|
||||
* const [error, resetError] = useErrorBoundary(() => reset())
|
||||
*
|
||||
* if (error) {
|
||||
* return (
|
||||
* <div>
|
||||
* There was an error!
|
||||
* <button onClick={() => resetError()}>Try again</button>
|
||||
* </div>
|
||||
* )
|
||||
* }
|
||||
*
|
||||
* return children
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const QueryErrorResetBoundary = ({
|
||||
children,
|
||||
}: QueryErrorResetBoundaryProps) => {
|
||||
const [value] = useState(() => createValue())
|
||||
return (
|
||||
<QueryErrorResetBoundaryContext.Provider value={value}>
|
||||
{typeof children === 'function' ? children(value) : children}
|
||||
</QueryErrorResetBoundaryContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { createElement, Component } from 'preact'
|
||||
import type { ErrorInfo } from 'preact'
|
||||
|
||||
import { ErrorBoundaryContext } from './ErrorBoundaryContext'
|
||||
import type { ErrorBoundaryProps, FallbackProps } from './types'
|
||||
|
||||
type ErrorBoundaryState =
|
||||
| {
|
||||
didCatch: true
|
||||
error: any
|
||||
}
|
||||
| {
|
||||
didCatch: false
|
||||
error: null
|
||||
}
|
||||
|
||||
const initialState: ErrorBoundaryState = {
|
||||
didCatch: false,
|
||||
error: null,
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<
|
||||
ErrorBoundaryProps,
|
||||
ErrorBoundaryState
|
||||
> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props)
|
||||
|
||||
this.resetErrorBoundary = this.resetErrorBoundary.bind(this)
|
||||
this.state = initialState
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { didCatch: true, error }
|
||||
}
|
||||
|
||||
resetErrorBoundary(...args: any[]) {
|
||||
const { error } = this.state
|
||||
|
||||
if (error !== null) {
|
||||
this.props.onReset?.({
|
||||
args,
|
||||
reason: 'imperative-api',
|
||||
})
|
||||
|
||||
this.setState(initialState)
|
||||
}
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
/**
|
||||
* To emulate the react behavior of console.error
|
||||
* we add one here to show that the errors bubble up
|
||||
* to the system and can be seen in the console
|
||||
*/
|
||||
console.error('%o\n\n%s', error, info)
|
||||
this.props.onError?.(error, info)
|
||||
}
|
||||
|
||||
componentDidUpdate(
|
||||
prevProps: ErrorBoundaryProps,
|
||||
prevState: ErrorBoundaryState,
|
||||
) {
|
||||
const { didCatch } = this.state
|
||||
const { resetKeys } = this.props
|
||||
|
||||
// There's an edge case where if the thing that triggered the error happens to *also* be in the resetKeys array,
|
||||
// we'd end up resetting the error boundary immediately.
|
||||
// This would likely trigger a second error to be thrown.
|
||||
// So we make sure that we don't check the resetKeys on the first call of cDU after the error is set.
|
||||
|
||||
if (
|
||||
didCatch &&
|
||||
prevState.error !== null &&
|
||||
hasArrayChanged(prevProps.resetKeys, resetKeys)
|
||||
) {
|
||||
this.props.onReset?.({
|
||||
next: resetKeys,
|
||||
prev: prevProps.resetKeys,
|
||||
reason: 'keys',
|
||||
})
|
||||
|
||||
// eslint-disable-next-line
|
||||
this.setState(initialState)
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { children, fallbackRender, FallbackComponent, fallback } = this.props
|
||||
const { didCatch, error } = this.state
|
||||
|
||||
let childToRender = children
|
||||
|
||||
if (didCatch) {
|
||||
const props: FallbackProps = {
|
||||
error,
|
||||
resetErrorBoundary: this.resetErrorBoundary,
|
||||
}
|
||||
|
||||
if (typeof fallbackRender === 'function') {
|
||||
childToRender = fallbackRender(props)
|
||||
} else if (FallbackComponent) {
|
||||
childToRender = createElement(FallbackComponent, props)
|
||||
} else if (fallback !== undefined) {
|
||||
childToRender = fallback
|
||||
} else {
|
||||
console.error(
|
||||
'preact-error-boundary requires either a fallback, fallbackRender, or FallbackComponent prop',
|
||||
)
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return createElement(
|
||||
ErrorBoundaryContext.Provider,
|
||||
{
|
||||
value: {
|
||||
didCatch,
|
||||
error,
|
||||
resetErrorBoundary: this.resetErrorBoundary,
|
||||
},
|
||||
},
|
||||
childToRender,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function hasArrayChanged(a: any[] = [], b: any[] = []) {
|
||||
return (
|
||||
a.length !== b.length || a.some((item, index) => !Object.is(item, b[index]))
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createContext } from 'preact'
|
||||
|
||||
type ErrorBoundaryContextType = {
|
||||
didCatch: boolean
|
||||
error: any
|
||||
resetErrorBoundary: (...args: any[]) => void
|
||||
}
|
||||
|
||||
export const ErrorBoundaryContext =
|
||||
createContext<ErrorBoundaryContextType | null>(null)
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Custom Error Boundary port from 'react-error-boundary'
|
||||
* Taken directly from https://github.com/bvaughn/react-error-boundary/
|
||||
* and modified to server a preact use case
|
||||
*/
|
||||
|
||||
export * from './ErrorBoundary'
|
||||
export * from './ErrorBoundaryContext'
|
||||
export * from './types'
|
||||
48
packages/preact-query/src/__tests__/ErrorBoundary/types.ts
Normal file
48
packages/preact-query/src/__tests__/ErrorBoundary/types.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type {
|
||||
ComponentChild,
|
||||
ComponentChildren,
|
||||
ComponentType,
|
||||
ErrorInfo,
|
||||
} from 'preact'
|
||||
|
||||
export type FallbackProps = {
|
||||
error: any
|
||||
resetErrorBoundary: (...args: any[]) => void
|
||||
}
|
||||
|
||||
type PropsWithChildren<TProps = {}> = TProps & {
|
||||
children?: ComponentChildren
|
||||
}
|
||||
|
||||
type ErrorBoundarySharedProps = PropsWithChildren<{
|
||||
onError?: (error: Error, info: ErrorInfo) => void
|
||||
onReset?: (
|
||||
details:
|
||||
| { reason: 'imperative-api'; args: any[] }
|
||||
| { reason: 'keys'; prev: any[] | undefined; next: any[] | undefined },
|
||||
) => void
|
||||
resetKeys?: any[]
|
||||
}>
|
||||
|
||||
type ErrorBoundaryPropsWithComponent = ErrorBoundarySharedProps & {
|
||||
fallback?: never
|
||||
FallbackComponent: ComponentType<FallbackProps>
|
||||
fallbackRender?: never
|
||||
}
|
||||
|
||||
type ErrorBoundaryPropsWithRender = ErrorBoundarySharedProps & {
|
||||
fallback?: never
|
||||
FallbackComponent?: never
|
||||
fallbackRender: (props: FallbackProps) => ComponentChild
|
||||
}
|
||||
|
||||
type ErrorBoundaryPropsWithFallback = ErrorBoundarySharedProps & {
|
||||
fallback: ComponentChild
|
||||
FallbackComponent?: never
|
||||
fallbackRender?: never
|
||||
}
|
||||
|
||||
export type ErrorBoundaryProps =
|
||||
| ErrorBoundaryPropsWithFallback
|
||||
| ErrorBoundaryPropsWithComponent
|
||||
| ErrorBoundaryPropsWithRender
|
||||
502
packages/preact-query/src/__tests__/HydrationBoundary.test.tsx
Normal file
502
packages/preact-query/src/__tests__/HydrationBoundary.test.tsx
Normal file
@@ -0,0 +1,502 @@
|
||||
import * as coreModule from '@tanstack/query-core'
|
||||
import type { hydrate } from '@tanstack/query-core'
|
||||
import { queryKey, sleep } from '@tanstack/query-test-utils'
|
||||
import { render } from '@testing-library/preact'
|
||||
import { Suspense, startTransition } from 'preact/compat'
|
||||
import { afterEach, beforeEach, describe, expect, vi, it } from 'vitest'
|
||||
|
||||
import {
|
||||
HydrationBoundary,
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
dehydrate,
|
||||
noop,
|
||||
useQuery,
|
||||
} from '..'
|
||||
|
||||
describe('Preact hydration', () => {
|
||||
const stringKey = queryKey()
|
||||
const addedKey = queryKey()
|
||||
const promiseKey = queryKey()
|
||||
let stringifiedState: string
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers()
|
||||
const queryClient = new QueryClient()
|
||||
void queryClient
|
||||
.query({
|
||||
queryKey: stringKey,
|
||||
queryFn: () => sleep(10).then(() => ['stringCached']),
|
||||
})
|
||||
.catch(noop)
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
const dehydrated = dehydrate(queryClient)
|
||||
stringifiedState = JSON.stringify(dehydrated)
|
||||
queryClient.clear()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should hydrate queries to the cache on context', async () => {
|
||||
const dehydratedState = JSON.parse(stringifiedState)
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
function Page() {
|
||||
const { data } = useQuery({
|
||||
queryKey: stringKey,
|
||||
queryFn: () => sleep(20).then(() => ['string']),
|
||||
})
|
||||
return (
|
||||
<div>
|
||||
<h1>{data}</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<HydrationBoundary state={dehydratedState}>
|
||||
<Page />
|
||||
</HydrationBoundary>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
expect(rendered.getByText('stringCached')).toBeInTheDocument()
|
||||
await vi.advanceTimersByTimeAsync(21)
|
||||
expect(rendered.getByText('string')).toBeInTheDocument()
|
||||
queryClient.clear()
|
||||
})
|
||||
|
||||
it('should hydrate queries to the cache on custom context', async () => {
|
||||
const queryClientInner = new QueryClient()
|
||||
const queryClientOuter = new QueryClient()
|
||||
|
||||
const dehydratedState = JSON.parse(stringifiedState)
|
||||
|
||||
function Page() {
|
||||
const { data } = useQuery({
|
||||
queryKey: stringKey,
|
||||
queryFn: () => sleep(20).then(() => ['string']),
|
||||
})
|
||||
return (
|
||||
<div>
|
||||
<h1>{data}</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = render(
|
||||
<QueryClientProvider client={queryClientOuter}>
|
||||
<QueryClientProvider client={queryClientInner}>
|
||||
<HydrationBoundary state={dehydratedState}>
|
||||
<Page />
|
||||
</HydrationBoundary>
|
||||
</QueryClientProvider>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
expect(rendered.getByText('stringCached')).toBeInTheDocument()
|
||||
await vi.advanceTimersByTimeAsync(21)
|
||||
expect(rendered.getByText('string')).toBeInTheDocument()
|
||||
|
||||
queryClientInner.clear()
|
||||
queryClientOuter.clear()
|
||||
})
|
||||
|
||||
describe('PreactQueryCacheProvider with hydration support', () => {
|
||||
it('should hydrate new queries if queries change', async () => {
|
||||
const dehydratedState = JSON.parse(stringifiedState)
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
function Page({ queryKey: pageKey }: { queryKey: Array<string> }) {
|
||||
const { data } = useQuery({
|
||||
queryKey: pageKey,
|
||||
queryFn: () => sleep(20).then(() => pageKey),
|
||||
})
|
||||
return (
|
||||
<div>
|
||||
<h1>{data}</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<HydrationBoundary state={dehydratedState}>
|
||||
<Page queryKey={stringKey} />
|
||||
</HydrationBoundary>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
expect(rendered.getByText('stringCached')).toBeInTheDocument()
|
||||
await vi.advanceTimersByTimeAsync(21)
|
||||
expect(rendered.getByText(stringKey[0]!)).toBeInTheDocument()
|
||||
|
||||
const intermediateClient = new QueryClient()
|
||||
|
||||
void intermediateClient
|
||||
.query({
|
||||
queryKey: stringKey,
|
||||
queryFn: () => sleep(20).then(() => ['should change']),
|
||||
})
|
||||
.catch(noop)
|
||||
void intermediateClient
|
||||
.query({
|
||||
queryKey: addedKey,
|
||||
queryFn: () => sleep(20).then(() => [addedKey[0]]),
|
||||
})
|
||||
.catch(noop)
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
const dehydrated = dehydrate(intermediateClient)
|
||||
intermediateClient.clear()
|
||||
|
||||
rendered.rerender(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<HydrationBoundary state={dehydrated}>
|
||||
<Page queryKey={stringKey} />
|
||||
<Page queryKey={addedKey} />
|
||||
</HydrationBoundary>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
// Existing observer should not have updated at this point,
|
||||
// as that would indicate a side effect in the render phase
|
||||
expect(rendered.getByText(stringKey[0]!)).toBeInTheDocument()
|
||||
// New query data should be available immediately
|
||||
expect(rendered.getByText(addedKey[0]!)).toBeInTheDocument()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
// After effects phase has had time to run, the observer should have updated
|
||||
expect(rendered.queryByText(stringKey[0]!)).not.toBeInTheDocument()
|
||||
expect(rendered.getByText('should change')).toBeInTheDocument()
|
||||
|
||||
queryClient.clear()
|
||||
})
|
||||
|
||||
// When we hydrate in transitions that are later aborted, it could be
|
||||
// confusing to both developers and users if we suddenly updated existing
|
||||
// state on the screen (why did this update when it was not stale, nothing
|
||||
// remounted, I didn't change tabs etc?).
|
||||
// Any queries that does not exist in the cache yet can still be hydrated
|
||||
// since they don't have any observers on the current page that would update.
|
||||
it('should hydrate new but not existing queries if transition is aborted', async () => {
|
||||
const initialDehydratedState = JSON.parse(stringifiedState)
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
function Page({ queryKey: pageKey }: { queryKey: Array<string> }) {
|
||||
const { data } = useQuery({
|
||||
queryKey: pageKey,
|
||||
queryFn: () => sleep(20).then(() => pageKey),
|
||||
})
|
||||
return (
|
||||
<div>
|
||||
<h1>{data}</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<HydrationBoundary state={initialDehydratedState}>
|
||||
<Page queryKey={stringKey} />
|
||||
</HydrationBoundary>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
expect(rendered.getByText('stringCached')).toBeInTheDocument()
|
||||
await vi.advanceTimersByTimeAsync(21)
|
||||
expect(rendered.getByText(stringKey[0]!)).toBeInTheDocument()
|
||||
|
||||
const intermediateClient = new QueryClient()
|
||||
void intermediateClient
|
||||
.query({
|
||||
queryKey: stringKey,
|
||||
queryFn: () => sleep(20).then(() => ['should not change']),
|
||||
})
|
||||
.catch(noop)
|
||||
void intermediateClient
|
||||
.query({
|
||||
queryKey: addedKey,
|
||||
queryFn: () => sleep(20).then(() => [addedKey[0]]),
|
||||
})
|
||||
.catch(noop)
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
|
||||
const newDehydratedState = dehydrate(intermediateClient)
|
||||
intermediateClient.clear()
|
||||
|
||||
function Thrower(): never {
|
||||
throw new Promise(() => {
|
||||
// Never resolve
|
||||
})
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
rendered.rerender(
|
||||
<Suspense fallback="loading">
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<HydrationBoundary state={newDehydratedState}>
|
||||
<Page queryKey={stringKey} />
|
||||
<Page queryKey={addedKey} />
|
||||
<Thrower />
|
||||
</HydrationBoundary>
|
||||
</QueryClientProvider>
|
||||
</Suspense>,
|
||||
)
|
||||
|
||||
expect(rendered.getByText('loading')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
startTransition(() => {
|
||||
rendered.rerender(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<HydrationBoundary state={initialDehydratedState}>
|
||||
<Page queryKey={stringKey} />
|
||||
<Page queryKey={addedKey} />
|
||||
</HydrationBoundary>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
// This query existed before the transition so it should stay the same
|
||||
expect(rendered.getByText(stringKey[0]!)).toBeInTheDocument()
|
||||
expect(
|
||||
rendered.queryByText('should not change'),
|
||||
).not.toBeInTheDocument()
|
||||
// New query data should be available immediately because it was
|
||||
// hydrated in the previous transition, even though the new dehydrated
|
||||
// state did not contain it
|
||||
expect(rendered.getByText(addedKey[0]!)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
// It should stay the same even after effects have had a chance to run
|
||||
expect(rendered.getByText(stringKey[0]!)).toBeInTheDocument()
|
||||
expect(rendered.queryByText('should not change')).not.toBeInTheDocument()
|
||||
|
||||
queryClient.clear()
|
||||
})
|
||||
|
||||
it('should hydrate queries to new cache if cache changes', async () => {
|
||||
const dehydratedState = JSON.parse(stringifiedState)
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
function Page() {
|
||||
const { data } = useQuery({
|
||||
queryKey: stringKey,
|
||||
queryFn: () => sleep(20).then(() => ['string']),
|
||||
})
|
||||
return (
|
||||
<div>
|
||||
<h1>{data}</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<HydrationBoundary state={dehydratedState}>
|
||||
<Page />
|
||||
</HydrationBoundary>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
expect(rendered.getByText('stringCached')).toBeInTheDocument()
|
||||
await vi.advanceTimersByTimeAsync(21)
|
||||
expect(rendered.getByText('string')).toBeInTheDocument()
|
||||
const newClientQueryClient = new QueryClient()
|
||||
|
||||
rendered.rerender(
|
||||
<QueryClientProvider client={newClientQueryClient}>
|
||||
<HydrationBoundary state={dehydratedState}>
|
||||
<Page />
|
||||
</HydrationBoundary>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
expect(rendered.getByText('string')).toBeInTheDocument()
|
||||
|
||||
queryClient.clear()
|
||||
newClientQueryClient.clear()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not hydrate queries if state is null', async () => {
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
const hydrateSpy = vi.spyOn(coreModule, 'hydrate')
|
||||
|
||||
function Page() {
|
||||
return null
|
||||
}
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<HydrationBoundary state={null}>
|
||||
<Page />
|
||||
</HydrationBoundary>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: 1000 }).map(async (_, index) => {
|
||||
await vi.advanceTimersByTimeAsync(index)
|
||||
expect(hydrateSpy).toHaveBeenCalledTimes(0)
|
||||
}),
|
||||
)
|
||||
|
||||
hydrateSpy.mockRestore()
|
||||
queryClient.clear()
|
||||
})
|
||||
|
||||
it('should not hydrate queries if state is undefined', async () => {
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
const hydrateSpy = vi.spyOn(coreModule, 'hydrate')
|
||||
|
||||
function Page() {
|
||||
return null
|
||||
}
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<HydrationBoundary state={undefined}>
|
||||
<Page />
|
||||
</HydrationBoundary>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(hydrateSpy).toHaveBeenCalledTimes(0)
|
||||
|
||||
hydrateSpy.mockRestore()
|
||||
queryClient.clear()
|
||||
})
|
||||
|
||||
it('should not hydrate queries if state is not an object', async () => {
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
const hydrateSpy = vi.spyOn(coreModule, 'hydrate')
|
||||
|
||||
function Page() {
|
||||
return null
|
||||
}
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<HydrationBoundary state={'invalid-state' as any}>
|
||||
<Page />
|
||||
</HydrationBoundary>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(hydrateSpy).toHaveBeenCalledTimes(0)
|
||||
|
||||
hydrateSpy.mockRestore()
|
||||
queryClient.clear()
|
||||
})
|
||||
|
||||
it('should handle state without queries property gracefully', async () => {
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
const hydrateSpy = vi.spyOn(coreModule, 'hydrate')
|
||||
|
||||
function Page() {
|
||||
return null
|
||||
}
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<HydrationBoundary state={{} as any}>
|
||||
<Page />
|
||||
</HydrationBoundary>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(hydrateSpy).toHaveBeenCalledTimes(0)
|
||||
|
||||
hydrateSpy.mockRestore()
|
||||
queryClient.clear()
|
||||
})
|
||||
|
||||
// https://github.com/TanStack/query/issues/8677
|
||||
it('should not infinite loop when hydrating promises that resolve to errors', async () => {
|
||||
const originalHydrate = coreModule.hydrate
|
||||
const hydrateSpy = vi.spyOn(coreModule, 'hydrate')
|
||||
let hydrationCount = 0
|
||||
hydrateSpy.mockImplementation((...args: Parameters<typeof hydrate>) => {
|
||||
hydrationCount++
|
||||
// Arbitrary number
|
||||
if (hydrationCount > 10) {
|
||||
// This is a rough way to detect it. Calling hydrate multiple times with
|
||||
// the same data is usually fine, but in this case it indicates the
|
||||
// logic in HydrationBoundary is not working as expected.
|
||||
throw new Error('Too many hydrations detected')
|
||||
}
|
||||
return originalHydrate(...args)
|
||||
})
|
||||
|
||||
// For the bug to trigger, there needs to already be a query in the cache,
|
||||
// with a dataUpdatedAt earlier than the dehydratedAt of the next query
|
||||
const clientQueryClient = new QueryClient()
|
||||
void clientQueryClient
|
||||
.query({
|
||||
queryKey: promiseKey,
|
||||
queryFn: () => sleep(20).then(() => 'existing'),
|
||||
})
|
||||
.catch(noop)
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
|
||||
const prefetchQueryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
dehydrate: {
|
||||
shouldDehydrateQuery: () => true,
|
||||
},
|
||||
},
|
||||
})
|
||||
void prefetchQueryClient
|
||||
.query({
|
||||
queryKey: promiseKey,
|
||||
queryFn: () =>
|
||||
sleep(10).then(() => Promise.reject(new Error('Query failed'))),
|
||||
})
|
||||
.catch(noop)
|
||||
|
||||
const dehydratedState = dehydrate(prefetchQueryClient)
|
||||
|
||||
// Mimic what React/our synchronous thenable does for already rejected promises
|
||||
// @ts-expect-error
|
||||
dehydratedState.queries[0].promise.status = 'failure'
|
||||
|
||||
function Page() {
|
||||
const { data } = useQuery({
|
||||
queryKey: promiseKey,
|
||||
queryFn: () => sleep(20).then(() => ['new']),
|
||||
})
|
||||
return (
|
||||
<div>
|
||||
<h1>{data}</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = render(
|
||||
<QueryClientProvider client={clientQueryClient}>
|
||||
<HydrationBoundary state={dehydratedState}>
|
||||
<Page />
|
||||
</HydrationBoundary>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
expect(rendered.getByText('existing')).toBeInTheDocument()
|
||||
await vi.advanceTimersByTimeAsync(21)
|
||||
expect(rendered.getByText('new')).toBeInTheDocument()
|
||||
|
||||
hydrateSpy.mockRestore()
|
||||
prefetchQueryClient.clear()
|
||||
clientQueryClient.clear()
|
||||
})
|
||||
})
|
||||
165
packages/preact-query/src/__tests__/QueryClientProvider.test.tsx
Normal file
165
packages/preact-query/src/__tests__/QueryClientProvider.test.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { queryKey, sleep } from '@tanstack/query-test-utils'
|
||||
import { render } from '@testing-library/preact'
|
||||
import { afterEach, beforeEach, describe, expect, vi, it } from 'vitest'
|
||||
|
||||
import {
|
||||
QueryCache,
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from '..'
|
||||
|
||||
describe('QueryClientProvider', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('sets a specific cache for all queries to use', async () => {
|
||||
const key = queryKey()
|
||||
|
||||
const queryCache = new QueryCache()
|
||||
const queryClient = new QueryClient({ queryCache })
|
||||
|
||||
function Page() {
|
||||
const { data } = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () => sleep(10).then(() => 'test'),
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>{data}</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Page />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('test')).toBeInTheDocument()
|
||||
|
||||
expect(queryCache.find({ queryKey: key })?.state.data).toBe('test')
|
||||
})
|
||||
|
||||
it('allows multiple caches to be partitioned', async () => {
|
||||
const key1 = queryKey()
|
||||
const key2 = queryKey()
|
||||
|
||||
const queryCache1 = new QueryCache()
|
||||
const queryCache2 = new QueryCache()
|
||||
|
||||
const queryClient1 = new QueryClient({ queryCache: queryCache1 })
|
||||
const queryClient2 = new QueryClient({ queryCache: queryCache2 })
|
||||
|
||||
function Page1() {
|
||||
const { data } = useQuery({
|
||||
queryKey: key1,
|
||||
queryFn: () => sleep(10).then(() => 'test1'),
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>{data}</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
function Page2() {
|
||||
const { data } = useQuery({
|
||||
queryKey: key2,
|
||||
queryFn: () => sleep(10).then(() => 'test2'),
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>{data}</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = render(
|
||||
<>
|
||||
<QueryClientProvider client={queryClient1}>
|
||||
<Page1 />
|
||||
</QueryClientProvider>
|
||||
<QueryClientProvider client={queryClient2}>
|
||||
<Page2 />
|
||||
</QueryClientProvider>
|
||||
</>,
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('test1')).toBeInTheDocument()
|
||||
expect(rendered.getByText('test2')).toBeInTheDocument()
|
||||
|
||||
expect(queryCache1.find({ queryKey: key1 })?.state.data).toBe('test1')
|
||||
expect(queryCache1.find({ queryKey: key2 })).toBeUndefined()
|
||||
expect(queryCache2.find({ queryKey: key1 })).toBeUndefined()
|
||||
expect(queryCache2.find({ queryKey: key2 })?.state.data).toBe('test2')
|
||||
})
|
||||
|
||||
it("uses defaultOptions for queries when they don't provide their own config", async () => {
|
||||
const key = queryKey()
|
||||
|
||||
const queryCache = new QueryCache()
|
||||
const queryClient = new QueryClient({
|
||||
queryCache,
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
gcTime: Infinity,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
function Page() {
|
||||
const { data } = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () => sleep(10).then(() => 'test'),
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>{data}</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Page />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('test')).toBeInTheDocument()
|
||||
|
||||
expect(queryCache.find({ queryKey: key })?.options.gcTime).toBe(Infinity)
|
||||
})
|
||||
|
||||
describe('useQueryClient', () => {
|
||||
it('should throw an error if no query client has been set', () => {
|
||||
const consoleMock = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined)
|
||||
|
||||
function Page() {
|
||||
useQueryClient()
|
||||
return null
|
||||
}
|
||||
|
||||
expect(() => render(<Page />)).toThrow(
|
||||
'No QueryClient set, use QueryClientProvider to set one',
|
||||
)
|
||||
|
||||
consoleMock.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,871 @@
|
||||
import { queryKey, sleep } from '@tanstack/query-test-utils'
|
||||
import { fireEvent } from '@testing-library/preact'
|
||||
import { Suspense } from 'preact/compat'
|
||||
import { useEffect, useState } from 'preact/hooks'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
QueryCache,
|
||||
QueryClient,
|
||||
QueryErrorResetBoundary,
|
||||
useQueries,
|
||||
useQuery,
|
||||
useSuspenseQueries,
|
||||
useSuspenseQuery,
|
||||
} from '..'
|
||||
import { ErrorBoundary } from './ErrorBoundary'
|
||||
import { renderWithClient } from './utils'
|
||||
|
||||
describe('QueryErrorResetBoundary', () => {
|
||||
let queryCache: QueryCache
|
||||
let queryClient: QueryClient
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
queryCache = new QueryCache()
|
||||
queryClient = new QueryClient({ queryCache })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
queryClient.clear()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('useQuery', () => {
|
||||
it('should retry fetch if the reset error boundary has been reset', async () => {
|
||||
const consoleMock = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined)
|
||||
const key = queryKey()
|
||||
|
||||
let succeed = false
|
||||
|
||||
function Page() {
|
||||
const { data } = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () =>
|
||||
sleep(10).then(() => {
|
||||
if (!succeed) throw new Error('Error')
|
||||
return 'data'
|
||||
}),
|
||||
retry: false,
|
||||
throwOnError: true,
|
||||
})
|
||||
|
||||
return <div>{data}</div>
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(
|
||||
queryClient,
|
||||
<QueryErrorResetBoundary>
|
||||
{({ reset }) => (
|
||||
<ErrorBoundary
|
||||
onReset={reset}
|
||||
fallbackRender={({ resetErrorBoundary }) => (
|
||||
<div>
|
||||
<div>error boundary</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
resetErrorBoundary()
|
||||
}}
|
||||
>
|
||||
retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Page />
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</QueryErrorResetBoundary>,
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('error boundary')).toBeInTheDocument()
|
||||
expect(rendered.getByText('retry')).toBeInTheDocument()
|
||||
|
||||
succeed = true
|
||||
|
||||
fireEvent.click(rendered.getByText('retry'))
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('data')).toBeInTheDocument()
|
||||
|
||||
consoleMock.mockRestore()
|
||||
})
|
||||
|
||||
it('should not throw error if query is disabled', async () => {
|
||||
const consoleMock = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined)
|
||||
const key = queryKey()
|
||||
|
||||
let succeed = false
|
||||
|
||||
function Page() {
|
||||
const { data, status } = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () =>
|
||||
sleep(10).then(() => {
|
||||
if (!succeed) throw new Error('Error')
|
||||
return 'data'
|
||||
}),
|
||||
retry: false,
|
||||
enabled: !succeed,
|
||||
throwOnError: true,
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>status: {status}</div>
|
||||
<div>{data}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(
|
||||
queryClient,
|
||||
<QueryErrorResetBoundary>
|
||||
{({ reset }) => (
|
||||
<ErrorBoundary
|
||||
onReset={reset}
|
||||
fallbackRender={({ resetErrorBoundary }) => (
|
||||
<div>
|
||||
<div>error boundary</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
resetErrorBoundary()
|
||||
}}
|
||||
>
|
||||
retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Page />
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</QueryErrorResetBoundary>,
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('error boundary')).toBeInTheDocument()
|
||||
expect(rendered.getByText('retry')).toBeInTheDocument()
|
||||
|
||||
succeed = true
|
||||
|
||||
fireEvent.click(rendered.getByText('retry'))
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('status: error')).toBeInTheDocument()
|
||||
|
||||
consoleMock.mockRestore()
|
||||
})
|
||||
|
||||
it('should not throw error if query is disabled, and refetch if query becomes enabled again', async () => {
|
||||
const consoleMock = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined)
|
||||
|
||||
const key = queryKey()
|
||||
|
||||
let succeed = false
|
||||
|
||||
function Page() {
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const { data } = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () =>
|
||||
sleep(10).then(() => {
|
||||
if (!succeed) throw new Error('Error')
|
||||
return 'data'
|
||||
}),
|
||||
retry: false,
|
||||
enabled,
|
||||
throwOnError: true,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setEnabled(true)
|
||||
}, [])
|
||||
|
||||
return <div>{data}</div>
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(
|
||||
queryClient,
|
||||
<QueryErrorResetBoundary>
|
||||
{({ reset }) => (
|
||||
<ErrorBoundary
|
||||
onReset={reset}
|
||||
fallbackRender={({ resetErrorBoundary }) => (
|
||||
<div>
|
||||
<div>error boundary</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
resetErrorBoundary()
|
||||
}}
|
||||
>
|
||||
retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Page />
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</QueryErrorResetBoundary>,
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('error boundary')).toBeInTheDocument()
|
||||
expect(rendered.getByText('retry')).toBeInTheDocument()
|
||||
|
||||
succeed = true
|
||||
|
||||
fireEvent.click(rendered.getByText('retry'))
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('data')).toBeInTheDocument()
|
||||
|
||||
consoleMock.mockRestore()
|
||||
})
|
||||
|
||||
it('should throw error if query is disabled and manually refetch', async () => {
|
||||
const consoleMock = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined)
|
||||
|
||||
const key = queryKey()
|
||||
|
||||
function Page() {
|
||||
const { data, refetch, status, fetchStatus } = useQuery<string>({
|
||||
queryKey: key,
|
||||
queryFn: () =>
|
||||
sleep(10).then(() => Promise.reject(new Error('Error'))),
|
||||
retry: false,
|
||||
enabled: false,
|
||||
throwOnError: true,
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => refetch()}>refetch</button>
|
||||
<div>
|
||||
status: {status}, fetchStatus: {fetchStatus}
|
||||
</div>
|
||||
<div>{data}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(
|
||||
queryClient,
|
||||
<QueryErrorResetBoundary>
|
||||
{({ reset }) => (
|
||||
<ErrorBoundary
|
||||
onReset={reset}
|
||||
fallbackRender={({ resetErrorBoundary }) => (
|
||||
<div>
|
||||
<div>error boundary</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
resetErrorBoundary()
|
||||
}}
|
||||
>
|
||||
retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Page />
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</QueryErrorResetBoundary>,
|
||||
)
|
||||
|
||||
expect(
|
||||
rendered.getByText('status: pending, fetchStatus: idle'),
|
||||
).toBeInTheDocument()
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(
|
||||
rendered.getByText('status: pending, fetchStatus: idle'),
|
||||
).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(rendered.getByRole('button', { name: /refetch/i }))
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('error boundary')).toBeInTheDocument()
|
||||
|
||||
consoleMock.mockRestore()
|
||||
})
|
||||
|
||||
it('should not retry fetch if the reset error boundary has not been reset', async () => {
|
||||
const consoleMock = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined)
|
||||
|
||||
const key = queryKey()
|
||||
|
||||
let succeed = false
|
||||
|
||||
function Page() {
|
||||
const { data } = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () =>
|
||||
sleep(10).then(() => {
|
||||
if (!succeed) throw new Error('Error')
|
||||
return 'data'
|
||||
}),
|
||||
retry: false,
|
||||
throwOnError: true,
|
||||
})
|
||||
|
||||
return <div>{data}</div>
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(
|
||||
queryClient,
|
||||
<QueryErrorResetBoundary>
|
||||
{() => (
|
||||
<ErrorBoundary
|
||||
fallbackRender={({ resetErrorBoundary }) => (
|
||||
<div>
|
||||
<div>error boundary</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
resetErrorBoundary()
|
||||
}}
|
||||
>
|
||||
retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Page />
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</QueryErrorResetBoundary>,
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('error boundary')).toBeInTheDocument()
|
||||
expect(rendered.getByText('retry')).toBeInTheDocument()
|
||||
|
||||
succeed = true
|
||||
|
||||
fireEvent.click(rendered.getByText('retry'))
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('error boundary')).toBeInTheDocument()
|
||||
|
||||
consoleMock.mockRestore()
|
||||
})
|
||||
|
||||
it('should retry fetch if the reset error boundary has been reset and the query contains data from a previous fetch', async () => {
|
||||
const consoleMock = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined)
|
||||
|
||||
const key = queryKey()
|
||||
|
||||
let succeed = false
|
||||
|
||||
function Page() {
|
||||
const { data } = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () =>
|
||||
sleep(10).then(() => {
|
||||
if (!succeed) throw new Error('Error')
|
||||
return 'data'
|
||||
}),
|
||||
retry: false,
|
||||
throwOnError: true,
|
||||
initialData: 'initial',
|
||||
})
|
||||
|
||||
return <div>{data}</div>
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(
|
||||
queryClient,
|
||||
<QueryErrorResetBoundary>
|
||||
{({ reset }) => (
|
||||
<ErrorBoundary
|
||||
onReset={reset}
|
||||
fallbackRender={({ resetErrorBoundary }) => (
|
||||
<div>
|
||||
<div>error boundary</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
resetErrorBoundary()
|
||||
}}
|
||||
>
|
||||
retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Page />
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</QueryErrorResetBoundary>,
|
||||
)
|
||||
|
||||
expect(rendered.getByText('initial')).toBeInTheDocument()
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('error boundary')).toBeInTheDocument()
|
||||
expect(rendered.getByText('retry')).toBeInTheDocument()
|
||||
|
||||
succeed = true
|
||||
|
||||
fireEvent.click(rendered.getByText('retry'))
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('data')).toBeInTheDocument()
|
||||
|
||||
consoleMock.mockRestore()
|
||||
})
|
||||
|
||||
it('should not retry fetch if the reset error boundary has not been reset after a previous reset', async () => {
|
||||
const consoleMock = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined)
|
||||
|
||||
const key = queryKey()
|
||||
|
||||
let succeed = false
|
||||
let shouldReset = false
|
||||
|
||||
function Page() {
|
||||
const { data } = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () =>
|
||||
sleep(10).then(() => {
|
||||
if (!succeed) throw new Error('Error')
|
||||
return 'data'
|
||||
}),
|
||||
retry: false,
|
||||
throwOnError: true,
|
||||
})
|
||||
|
||||
return <div>{data}</div>
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(
|
||||
queryClient,
|
||||
<QueryErrorResetBoundary>
|
||||
{({ reset }) => (
|
||||
<ErrorBoundary
|
||||
onReset={() => {
|
||||
if (shouldReset) {
|
||||
reset()
|
||||
}
|
||||
}}
|
||||
fallbackRender={({ resetErrorBoundary }) => (
|
||||
<div>
|
||||
<div>error boundary</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
resetErrorBoundary()
|
||||
}}
|
||||
>
|
||||
retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Page />
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</QueryErrorResetBoundary>,
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('error boundary')).toBeInTheDocument()
|
||||
expect(rendered.getByText('retry')).toBeInTheDocument()
|
||||
|
||||
succeed = false
|
||||
shouldReset = true
|
||||
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('error boundary')).toBeInTheDocument()
|
||||
expect(rendered.getByText('retry')).toBeInTheDocument()
|
||||
|
||||
succeed = true
|
||||
shouldReset = false
|
||||
|
||||
fireEvent.click(rendered.getByText('retry'))
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('error boundary')).toBeInTheDocument()
|
||||
|
||||
succeed = true
|
||||
shouldReset = true
|
||||
|
||||
fireEvent.click(rendered.getByText('retry'))
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('data')).toBeInTheDocument()
|
||||
|
||||
consoleMock.mockRestore()
|
||||
})
|
||||
|
||||
it('should throw again on error after the reset error boundary has been reset', async () => {
|
||||
const consoleMock = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined)
|
||||
|
||||
const key = queryKey()
|
||||
let fetchCount = 0
|
||||
|
||||
function Page() {
|
||||
const { data } = useQuery<string>({
|
||||
queryKey: key,
|
||||
queryFn: () =>
|
||||
sleep(10).then(() => {
|
||||
fetchCount++
|
||||
throw new Error('Error')
|
||||
}),
|
||||
retry: false,
|
||||
throwOnError: true,
|
||||
})
|
||||
|
||||
return <div>{data}</div>
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(
|
||||
queryClient,
|
||||
<QueryErrorResetBoundary>
|
||||
{({ reset }) => (
|
||||
<ErrorBoundary
|
||||
onReset={reset}
|
||||
fallbackRender={({ resetErrorBoundary }) => (
|
||||
<div>
|
||||
<div>error boundary</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
resetErrorBoundary()
|
||||
}}
|
||||
>
|
||||
retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Page />
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</QueryErrorResetBoundary>,
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('error boundary')).toBeInTheDocument()
|
||||
expect(rendered.getByText('retry')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(rendered.getByText('retry'))
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('error boundary')).toBeInTheDocument()
|
||||
expect(rendered.getByText('retry')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(rendered.getByText('retry'))
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('error boundary')).toBeInTheDocument()
|
||||
|
||||
expect(fetchCount).toBe(3)
|
||||
|
||||
consoleMock.mockRestore()
|
||||
})
|
||||
|
||||
it('should never render the component while the query is in error state', async () => {
|
||||
const consoleMock = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined)
|
||||
|
||||
const key = queryKey()
|
||||
let fetchCount = 0
|
||||
let renders = 0
|
||||
|
||||
function Page() {
|
||||
const { data } = useSuspenseQuery({
|
||||
queryKey: key,
|
||||
queryFn: () =>
|
||||
sleep(10).then(() => {
|
||||
fetchCount++
|
||||
if (fetchCount > 2) return 'data'
|
||||
throw new Error('Error')
|
||||
}),
|
||||
retry: false,
|
||||
})
|
||||
|
||||
renders++
|
||||
|
||||
return <div>{data}</div>
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(
|
||||
queryClient,
|
||||
<QueryErrorResetBoundary>
|
||||
{({ reset }) => (
|
||||
<ErrorBoundary
|
||||
onReset={reset}
|
||||
fallbackRender={({ resetErrorBoundary }) => (
|
||||
<div>
|
||||
<div>error boundary</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
resetErrorBoundary()
|
||||
}}
|
||||
>
|
||||
retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Suspense fallback={<div>loading</div>}>
|
||||
<Page />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</QueryErrorResetBoundary>,
|
||||
)
|
||||
|
||||
expect(rendered.getByText('loading')).toBeInTheDocument()
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
expect(rendered.getByText('error boundary')).toBeInTheDocument()
|
||||
expect(rendered.getByText('retry')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(rendered.getByText('retry'))
|
||||
expect(rendered.getByText('loading')).toBeInTheDocument()
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
expect(rendered.getByText('error boundary')).toBeInTheDocument()
|
||||
expect(rendered.getByText('retry')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(rendered.getByText('retry'))
|
||||
expect(rendered.getByText('loading')).toBeInTheDocument()
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
expect(rendered.getByText('data')).toBeInTheDocument()
|
||||
|
||||
expect(fetchCount).toBe(3)
|
||||
expect(renders).toBe(1)
|
||||
|
||||
consoleMock.mockRestore()
|
||||
})
|
||||
|
||||
it('should render children', () => {
|
||||
const consoleMock = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined)
|
||||
|
||||
function Page() {
|
||||
return (
|
||||
<div>
|
||||
<span>page</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(
|
||||
queryClient,
|
||||
<QueryErrorResetBoundary>
|
||||
<Page />
|
||||
</QueryErrorResetBoundary>,
|
||||
)
|
||||
|
||||
expect(rendered.queryByText('page')).not.toBeNull()
|
||||
|
||||
consoleMock.mockRestore()
|
||||
})
|
||||
|
||||
it('should show error boundary when using tracked queries even though we do not track the error field', async () => {
|
||||
const consoleMock = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined)
|
||||
|
||||
const key = queryKey()
|
||||
|
||||
let succeed = false
|
||||
|
||||
function Page() {
|
||||
const { data } = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () =>
|
||||
sleep(10).then(() => {
|
||||
if (!succeed) throw new Error('Error')
|
||||
return 'data'
|
||||
}),
|
||||
retry: false,
|
||||
throwOnError: true,
|
||||
})
|
||||
|
||||
return <div>{data}</div>
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(
|
||||
queryClient,
|
||||
<QueryErrorResetBoundary>
|
||||
{({ reset }) => (
|
||||
<ErrorBoundary
|
||||
onReset={reset}
|
||||
fallbackRender={({ resetErrorBoundary }) => (
|
||||
<div>
|
||||
<div>error boundary</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
resetErrorBoundary()
|
||||
}}
|
||||
>
|
||||
retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Page />
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</QueryErrorResetBoundary>,
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('error boundary')).toBeInTheDocument()
|
||||
expect(rendered.getByText('retry')).toBeInTheDocument()
|
||||
|
||||
succeed = true
|
||||
|
||||
fireEvent.click(rendered.getByText('retry'))
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('data')).toBeInTheDocument()
|
||||
|
||||
consoleMock.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useQueries', () => {
|
||||
it('should retry fetch if the reset error boundary has been reset', async () => {
|
||||
const consoleMock = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined)
|
||||
const key = queryKey()
|
||||
|
||||
let succeed = false
|
||||
|
||||
function Page() {
|
||||
const [{ data }] = useQueries({
|
||||
queries: [
|
||||
{
|
||||
queryKey: key,
|
||||
queryFn: () =>
|
||||
sleep(10).then(() => {
|
||||
if (!succeed) throw new Error('Error')
|
||||
return 'data'
|
||||
}),
|
||||
retry: false,
|
||||
throwOnError: true,
|
||||
retryOnMount: () => true,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
return <div>{data}</div>
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(
|
||||
queryClient,
|
||||
<QueryErrorResetBoundary>
|
||||
{({ reset }) => (
|
||||
<ErrorBoundary
|
||||
onReset={reset}
|
||||
fallbackRender={({ resetErrorBoundary }) => (
|
||||
<div>
|
||||
<div>error boundary</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
resetErrorBoundary()
|
||||
}}
|
||||
>
|
||||
retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Page />
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</QueryErrorResetBoundary>,
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('error boundary')).toBeInTheDocument()
|
||||
expect(rendered.getByText('retry')).toBeInTheDocument()
|
||||
|
||||
succeed = true
|
||||
|
||||
fireEvent.click(rendered.getByText('retry'))
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('data')).toBeInTheDocument()
|
||||
|
||||
consoleMock.mockRestore()
|
||||
})
|
||||
|
||||
it('with suspense should retry fetch if the reset error boundary has been reset', async () => {
|
||||
const key = queryKey()
|
||||
const consoleMock = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined)
|
||||
|
||||
let succeed = false
|
||||
|
||||
function Page() {
|
||||
const [{ data }] = useSuspenseQueries({
|
||||
queries: [
|
||||
{
|
||||
queryKey: key,
|
||||
queryFn: () =>
|
||||
sleep(10).then(() => {
|
||||
if (!succeed) throw new Error('Error')
|
||||
return 'data'
|
||||
}),
|
||||
retry: false,
|
||||
retryOnMount: () => true,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
return <div>{data}</div>
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(
|
||||
queryClient,
|
||||
<QueryErrorResetBoundary>
|
||||
{({ reset }) => (
|
||||
<ErrorBoundary
|
||||
onReset={reset}
|
||||
fallbackRender={({ resetErrorBoundary }) => (
|
||||
<div>
|
||||
<div>error boundary</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
resetErrorBoundary()
|
||||
}}
|
||||
>
|
||||
retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Suspense fallback="loading">
|
||||
<Page />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</QueryErrorResetBoundary>,
|
||||
)
|
||||
|
||||
expect(rendered.getByText('loading')).toBeInTheDocument()
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
expect(rendered.getByText('error boundary')).toBeInTheDocument()
|
||||
expect(rendered.getByText('retry')).toBeInTheDocument()
|
||||
|
||||
succeed = true
|
||||
|
||||
fireEvent.click(rendered.getByText('retry'))
|
||||
expect(rendered.getByText('loading')).toBeInTheDocument()
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
expect(rendered.getByText('data')).toBeInTheDocument()
|
||||
|
||||
consoleMock.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,179 @@
|
||||
import {
|
||||
PERSISTER_KEY_PREFIX,
|
||||
experimental_createQueryPersister,
|
||||
} from '@tanstack/query-persist-client-core'
|
||||
import { queryKey, sleep } from '@tanstack/query-test-utils'
|
||||
import { useState } from 'preact/hooks'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { QueryCache, QueryClient, hashKey, useQuery } from '..'
|
||||
import { renderWithClient } from './utils'
|
||||
|
||||
describe('fine grained persister', () => {
|
||||
let queryCache: QueryCache
|
||||
let queryClient: QueryClient
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
queryCache = new QueryCache()
|
||||
queryClient = new QueryClient({ queryCache })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
queryClient.clear()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should restore query state from persister and not refetch', async () => {
|
||||
const key = queryKey()
|
||||
const hash = hashKey(key)
|
||||
const spy = vi.fn(() => Promise.resolve('Works from queryFn'))
|
||||
|
||||
const mapStorage = new Map()
|
||||
const storage = {
|
||||
getItem: (itemKey: string) => Promise.resolve(mapStorage.get(itemKey)),
|
||||
setItem: (itemKey: string, value: unknown) => {
|
||||
mapStorage.set(itemKey, value)
|
||||
return Promise.resolve()
|
||||
},
|
||||
removeItem: (itemKey: string) => {
|
||||
mapStorage.delete(itemKey)
|
||||
return Promise.resolve()
|
||||
},
|
||||
}
|
||||
|
||||
await storage.setItem(
|
||||
`${PERSISTER_KEY_PREFIX}-${hash}`,
|
||||
JSON.stringify({
|
||||
buster: '',
|
||||
queryHash: hash,
|
||||
queryKey: key,
|
||||
state: {
|
||||
dataUpdatedAt: Date.now(),
|
||||
data: 'Works from persister',
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
function Test() {
|
||||
const [_, setRef] = useState<HTMLDivElement | null>()
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: spy,
|
||||
persister: experimental_createQueryPersister({
|
||||
storage,
|
||||
}).persisterFn,
|
||||
staleTime: 5000,
|
||||
})
|
||||
|
||||
return <div ref={(value) => setRef(value)}>{data}</div>
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(queryClient, <Test />)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(rendered.getByText('Works from persister')).toBeInTheDocument()
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should restore query state from persister and refetch', async () => {
|
||||
const key = queryKey()
|
||||
const hash = hashKey(key)
|
||||
const spy = vi.fn(() => sleep(5).then(() => 'Works from queryFn'))
|
||||
|
||||
const mapStorage = new Map()
|
||||
const storage = {
|
||||
getItem: (itemKey: string) => Promise.resolve(mapStorage.get(itemKey)),
|
||||
setItem: (itemKey: string, value: unknown) => {
|
||||
mapStorage.set(itemKey, value)
|
||||
return Promise.resolve()
|
||||
},
|
||||
removeItem: (itemKey: string) => {
|
||||
mapStorage.delete(itemKey)
|
||||
return Promise.resolve()
|
||||
},
|
||||
}
|
||||
|
||||
await storage.setItem(
|
||||
`${PERSISTER_KEY_PREFIX}-${hash}`,
|
||||
JSON.stringify({
|
||||
buster: '',
|
||||
queryHash: hash,
|
||||
queryKey: key,
|
||||
state: {
|
||||
dataUpdatedAt: Date.now(),
|
||||
data: 'Works from persister',
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
function Test() {
|
||||
const [_, setRef] = useState<HTMLDivElement | null>()
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: spy,
|
||||
persister: experimental_createQueryPersister({
|
||||
storage,
|
||||
}).persisterFn,
|
||||
})
|
||||
|
||||
return <div ref={(value) => setRef(value)}>{data}</div>
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(queryClient, <Test />)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(rendered.getByText('Works from persister')).toBeInTheDocument()
|
||||
await vi.advanceTimersByTimeAsync(6)
|
||||
expect(rendered.getByText('Works from queryFn')).toBeInTheDocument()
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should store query state to persister after fetch', async () => {
|
||||
const key = queryKey()
|
||||
const hash = hashKey(key)
|
||||
const spy = vi.fn(() => Promise.resolve('Works from queryFn'))
|
||||
|
||||
const mapStorage = new Map()
|
||||
const storage = {
|
||||
getItem: (itemKey: string) => Promise.resolve(mapStorage.get(itemKey)),
|
||||
setItem: (itemKey: string, value: unknown) => {
|
||||
mapStorage.set(itemKey, value)
|
||||
return Promise.resolve()
|
||||
},
|
||||
removeItem: (itemKey: string) => {
|
||||
mapStorage.delete(itemKey)
|
||||
return Promise.resolve()
|
||||
},
|
||||
}
|
||||
|
||||
function Test() {
|
||||
const [_, setRef] = useState<HTMLDivElement | null>()
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: spy,
|
||||
persister: experimental_createQueryPersister({
|
||||
storage,
|
||||
}).persisterFn,
|
||||
})
|
||||
|
||||
return <div ref={(value) => setRef(value)}>{data}</div>
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(queryClient, <Test />)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(rendered.getByText('Works from queryFn')).toBeInTheDocument()
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
|
||||
const storedItem = await storage.getItem(`${PERSISTER_KEY_PREFIX}-${hash}`)
|
||||
expect(JSON.parse(storedItem)).toMatchObject({
|
||||
state: {
|
||||
data: 'Works from queryFn',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,323 @@
|
||||
import { QueryClient, dataTagSymbol, skipToken } from '@tanstack/query-core'
|
||||
import type {
|
||||
DataTag,
|
||||
InfiniteData,
|
||||
InitialDataFunction,
|
||||
} from '@tanstack/query-core'
|
||||
import { queryKey } from '@tanstack/query-test-utils'
|
||||
import { assertType, describe, expectTypeOf, it } from 'vitest'
|
||||
|
||||
import { infiniteQueryOptions } from '../infiniteQueryOptions'
|
||||
import { useInfiniteQuery } from '../useInfiniteQuery'
|
||||
import { useQuery } from '../useQuery'
|
||||
import { useSuspenseInfiniteQuery } from '../useSuspenseInfiniteQuery'
|
||||
|
||||
// Regression test for exported infiniteQueryOptions inference under declaration emit.
|
||||
// TypeScript should be able to name the return type without expanding the
|
||||
// internal data tag symbols into the consumer's .d.ts output.
|
||||
export const exportedInfiniteQueryOptions = infiniteQueryOptions({
|
||||
queryKey: ['invalid'],
|
||||
getNextPageParam: () => 1,
|
||||
initialPageParam: 1,
|
||||
})
|
||||
|
||||
describe('infiniteQueryOptions', () => {
|
||||
it('should not allow excess properties', () => {
|
||||
assertType(
|
||||
infiniteQueryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve('data'),
|
||||
getNextPageParam: () => 1,
|
||||
initialPageParam: 1,
|
||||
// @ts-expect-error this is a good error, because stallTime does not exist!
|
||||
stallTime: 1000,
|
||||
}),
|
||||
)
|
||||
})
|
||||
it('should infer types for callbacks', () => {
|
||||
infiniteQueryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve('data'),
|
||||
staleTime: 1000,
|
||||
getNextPageParam: () => 1,
|
||||
initialPageParam: 1,
|
||||
select: (data) => {
|
||||
expectTypeOf(data).toEqualTypeOf<InfiniteData<string, number>>()
|
||||
},
|
||||
})
|
||||
})
|
||||
it('should work when passed to useInfiniteQuery', () => {
|
||||
const options = infiniteQueryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve('string'),
|
||||
getNextPageParam: () => 1,
|
||||
initialPageParam: 1,
|
||||
})
|
||||
|
||||
const { data } = useInfiniteQuery(options)
|
||||
|
||||
// known issue: type of pageParams is unknown when returned from useInfiniteQuery
|
||||
expectTypeOf(data).toEqualTypeOf<
|
||||
InfiniteData<string, unknown> | undefined
|
||||
>()
|
||||
})
|
||||
it('should work when passed to useSuspenseInfiniteQuery', () => {
|
||||
const options = infiniteQueryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve('string'),
|
||||
getNextPageParam: () => 1,
|
||||
initialPageParam: 1,
|
||||
})
|
||||
|
||||
const { data } = useSuspenseInfiniteQuery(options)
|
||||
|
||||
expectTypeOf(data).toEqualTypeOf<InfiniteData<string, unknown>>()
|
||||
})
|
||||
it('should work when passed to infiniteQuery', async () => {
|
||||
const options = infiniteQueryOptions({
|
||||
queryKey: ['key'],
|
||||
queryFn: () => Promise.resolve('string'),
|
||||
getNextPageParam: () => 1,
|
||||
initialPageParam: 1,
|
||||
})
|
||||
|
||||
const data = await new QueryClient().infiniteQuery(options)
|
||||
|
||||
expectTypeOf(data).toEqualTypeOf<InfiniteData<string, number>>()
|
||||
})
|
||||
it('should work when passed to infiniteQuery with select', async () => {
|
||||
const options = infiniteQueryOptions({
|
||||
queryKey: ['key'],
|
||||
queryFn: () => Promise.resolve('string'),
|
||||
getNextPageParam: () => 1,
|
||||
initialPageParam: 1,
|
||||
select: (data) => data.pages,
|
||||
})
|
||||
|
||||
const data = await new QueryClient().infiniteQuery(options)
|
||||
|
||||
expectTypeOf(data).toEqualTypeOf<Array<string>>()
|
||||
})
|
||||
it('should work when passed to infiniteQuery with enabled: false', async () => {
|
||||
const options = infiniteQueryOptions({
|
||||
queryKey: ['key'],
|
||||
queryFn: () => Promise.resolve('string'),
|
||||
getNextPageParam: () => 1,
|
||||
initialPageParam: 1,
|
||||
enabled: false,
|
||||
})
|
||||
|
||||
const data = await new QueryClient().infiniteQuery(options)
|
||||
|
||||
expectTypeOf(data).toEqualTypeOf<InfiniteData<string, number>>()
|
||||
})
|
||||
it('should work when passed to infiniteQuery with skipToken', async () => {
|
||||
const options = infiniteQueryOptions({
|
||||
queryKey: ['key'],
|
||||
queryFn: skipToken,
|
||||
getNextPageParam: () => 1,
|
||||
initialPageParam: 1,
|
||||
})
|
||||
|
||||
const data = await new QueryClient().infiniteQuery(options)
|
||||
|
||||
expectTypeOf(data).toEqualTypeOf<InfiniteData<unknown, number>>()
|
||||
})
|
||||
it('should work when passed to fetchInfiniteQuery', async () => {
|
||||
const options = infiniteQueryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve('string'),
|
||||
getNextPageParam: () => 1,
|
||||
initialPageParam: 1,
|
||||
})
|
||||
|
||||
// grandfathered direct test
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const data = await new QueryClient().fetchInfiniteQuery(options)
|
||||
|
||||
expectTypeOf(data).toEqualTypeOf<InfiniteData<string, number>>()
|
||||
})
|
||||
it('should tag the queryKey with the result type of the QueryFn', () => {
|
||||
const { queryKey: tagged } = infiniteQueryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve('string'),
|
||||
getNextPageParam: () => 1,
|
||||
initialPageParam: 1,
|
||||
})
|
||||
|
||||
expectTypeOf(tagged[dataTagSymbol]).toEqualTypeOf<InfiniteData<string>>()
|
||||
})
|
||||
it('should tag the queryKey even if no promise is returned', () => {
|
||||
const { queryKey: tagged } = infiniteQueryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => 'string',
|
||||
getNextPageParam: () => 1,
|
||||
initialPageParam: 1,
|
||||
})
|
||||
|
||||
expectTypeOf(tagged[dataTagSymbol]).toEqualTypeOf<InfiniteData<string>>()
|
||||
})
|
||||
it('should tag the queryKey with the result type of the QueryFn if select is used', () => {
|
||||
const { queryKey: tagged } = infiniteQueryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve('string'),
|
||||
select: (data) => data.pages,
|
||||
getNextPageParam: () => 1,
|
||||
initialPageParam: 1,
|
||||
})
|
||||
|
||||
expectTypeOf(tagged[dataTagSymbol]).toEqualTypeOf<InfiniteData<string>>()
|
||||
})
|
||||
it('should return the proper type when passed to getQueryData', () => {
|
||||
const { queryKey: tagged } = infiniteQueryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve('string'),
|
||||
getNextPageParam: () => 1,
|
||||
initialPageParam: 1,
|
||||
})
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
const data = queryClient.getQueryData(tagged)
|
||||
|
||||
expectTypeOf(data).toEqualTypeOf<
|
||||
InfiniteData<string, unknown> | undefined
|
||||
>()
|
||||
})
|
||||
it('should properly type when passed to setQueryData', () => {
|
||||
const { queryKey: tagged } = infiniteQueryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve('string'),
|
||||
getNextPageParam: () => 1,
|
||||
initialPageParam: 1,
|
||||
})
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
const data = queryClient.setQueryData(tagged, (prev) => {
|
||||
expectTypeOf(prev).toEqualTypeOf<
|
||||
InfiniteData<string, unknown> | undefined
|
||||
>()
|
||||
return prev
|
||||
})
|
||||
|
||||
expectTypeOf(data).toEqualTypeOf<
|
||||
InfiniteData<string, unknown> | undefined
|
||||
>()
|
||||
})
|
||||
it('should throw a type error when using queryFn with skipToken in a suspense query', () => {
|
||||
const options = infiniteQueryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn:
|
||||
Math.random() > 0.5 ? skipToken : () => Promise.resolve('string'),
|
||||
getNextPageParam: () => 1,
|
||||
initialPageParam: 1,
|
||||
})
|
||||
// @ts-expect-error TS2345
|
||||
const { data } = useSuspenseInfiniteQuery(options)
|
||||
expectTypeOf(data).toEqualTypeOf<InfiniteData<string, unknown>>()
|
||||
})
|
||||
|
||||
it('should not be allowed to be passed to non-infinite query functions', () => {
|
||||
const queryClient = new QueryClient()
|
||||
const options = infiniteQueryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve('string'),
|
||||
getNextPageParam: () => 1,
|
||||
initialPageParam: 1,
|
||||
})
|
||||
assertType(
|
||||
// @ts-expect-error cannot pass infinite options to non-infinite query functions
|
||||
useQuery(options),
|
||||
)
|
||||
assertType(
|
||||
// @ts-expect-error cannot pass infinite options to non-infinite query functions
|
||||
queryClient.query(options),
|
||||
)
|
||||
|
||||
// deprecated methods below to be removed next major version
|
||||
assertType(
|
||||
// @ts-expect-error cannot pass infinite options to non-infinite query functions
|
||||
// eslint-disable-next-line no-restricted-syntax -- grandfathered direct test
|
||||
queryClient.ensureQueryData(options),
|
||||
)
|
||||
assertType(
|
||||
// @ts-expect-error cannot pass infinite options to non-infinite query functions
|
||||
// eslint-disable-next-line no-restricted-syntax -- grandfathered direct test
|
||||
queryClient.fetchQuery(options),
|
||||
)
|
||||
assertType(
|
||||
// @ts-expect-error cannot pass infinite options to non-infinite query functions
|
||||
// eslint-disable-next-line no-restricted-syntax -- grandfathered direct test
|
||||
queryClient.prefetchQuery(options),
|
||||
)
|
||||
})
|
||||
|
||||
it('allow optional initialData function', () => {
|
||||
const initialData: { example: boolean } | undefined = { example: true }
|
||||
const queryOptions = infiniteQueryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => initialData,
|
||||
initialData: initialData
|
||||
? () => ({ pages: [initialData], pageParams: [] })
|
||||
: undefined,
|
||||
getNextPageParam: () => 1,
|
||||
initialPageParam: 1,
|
||||
})
|
||||
expectTypeOf(queryOptions.initialData).toExtend<
|
||||
| InitialDataFunction<InfiniteData<{ example: boolean }, number>>
|
||||
| InfiniteData<{ example: boolean }, number>
|
||||
| undefined
|
||||
>()
|
||||
})
|
||||
|
||||
it('allow optional initialData object', () => {
|
||||
const initialData: { example: boolean } | undefined = { example: true }
|
||||
const queryOptions = infiniteQueryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => initialData,
|
||||
initialData: initialData
|
||||
? { pages: [initialData], pageParams: [] }
|
||||
: undefined,
|
||||
getNextPageParam: () => 1,
|
||||
initialPageParam: 1,
|
||||
})
|
||||
expectTypeOf(queryOptions.initialData).toExtend<
|
||||
| InitialDataFunction<InfiniteData<{ example: boolean }, number>>
|
||||
| InfiniteData<{ example: boolean }, number>
|
||||
| undefined
|
||||
>()
|
||||
})
|
||||
|
||||
it('should return a custom query key type', () => {
|
||||
type MyQueryKey = [Array<string>, { type: 'foo' }]
|
||||
|
||||
const options = infiniteQueryOptions({
|
||||
queryKey: [['key'], { type: 'foo' }] as MyQueryKey,
|
||||
queryFn: () => Promise.resolve(1),
|
||||
getNextPageParam: () => 1,
|
||||
initialPageParam: 1,
|
||||
})
|
||||
|
||||
expectTypeOf(options.queryKey).toEqualTypeOf<
|
||||
DataTag<MyQueryKey, InfiniteData<number>, Error>
|
||||
>()
|
||||
})
|
||||
|
||||
it('should return a custom query key type with datatag', () => {
|
||||
type MyQueryKey = DataTag<
|
||||
[Array<string>, { type: 'foo' }],
|
||||
number,
|
||||
Error & { myMessage: string }
|
||||
>
|
||||
|
||||
const options = infiniteQueryOptions({
|
||||
queryKey: [['key'], { type: 'foo' }] as MyQueryKey,
|
||||
queryFn: () => Promise.resolve(1),
|
||||
getNextPageParam: () => 1,
|
||||
initialPageParam: 1,
|
||||
})
|
||||
|
||||
expectTypeOf(options.queryKey).toEqualTypeOf<
|
||||
DataTag<MyQueryKey, InfiniteData<number>, Error & { myMessage: string }>
|
||||
>()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import { queryKey } from '@tanstack/query-test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { infiniteQueryOptions } from '../infiniteQueryOptions'
|
||||
import type { UseInfiniteQueryOptions } from '../types'
|
||||
|
||||
describe('infiniteQueryOptions', () => {
|
||||
it('should return the object received as a parameter without any modification.', () => {
|
||||
const key = queryKey()
|
||||
const object: UseInfiniteQueryOptions = {
|
||||
queryKey: key,
|
||||
queryFn: () => Promise.resolve(5),
|
||||
getNextPageParam: () => null,
|
||||
initialPageParam: null,
|
||||
}
|
||||
|
||||
expect(infiniteQueryOptions(object)).toBe(object)
|
||||
})
|
||||
})
|
||||
219
packages/preact-query/src/__tests__/mutationOptions.test-d.tsx
Normal file
219
packages/preact-query/src/__tests__/mutationOptions.test-d.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import { QueryClient } from '@tanstack/query-core'
|
||||
import type {
|
||||
DefaultError,
|
||||
MutationFunctionContext,
|
||||
MutationState,
|
||||
WithRequired,
|
||||
} from '@tanstack/query-core'
|
||||
import { queryKey } from '@tanstack/query-test-utils'
|
||||
import { assertType, describe, expectTypeOf, it } from 'vitest'
|
||||
|
||||
import { useIsMutating, useMutation, useMutationState } from '..'
|
||||
import { mutationOptions } from '../mutationOptions'
|
||||
import type { UseMutationOptions, UseMutationResult } from '../types'
|
||||
|
||||
describe('mutationOptions', () => {
|
||||
it('should not allow excess properties', () => {
|
||||
mutationOptions({
|
||||
mutationFn: () => Promise.resolve(5),
|
||||
// @ts-expect-error this is a good error, because onMutates does not exist!
|
||||
onMutates: 1000,
|
||||
mutationKey: queryKey(),
|
||||
onSuccess: (data) => {
|
||||
expectTypeOf(data).toEqualTypeOf<number>()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should infer types for callbacks', () => {
|
||||
mutationOptions({
|
||||
mutationFn: () => Promise.resolve(5),
|
||||
mutationKey: queryKey(),
|
||||
onSuccess: (data) => {
|
||||
expectTypeOf(data).toEqualTypeOf<number>()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should infer types for onError callback', () => {
|
||||
mutationOptions({
|
||||
mutationFn: () => {
|
||||
throw new Error('fail')
|
||||
},
|
||||
mutationKey: queryKey(),
|
||||
onError: (error) => {
|
||||
expectTypeOf(error).toEqualTypeOf<DefaultError>()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should infer types for variables', () => {
|
||||
mutationOptions<number, DefaultError, { id: string }>({
|
||||
mutationFn: (vars) => {
|
||||
expectTypeOf(vars).toEqualTypeOf<{ id: string }>()
|
||||
return Promise.resolve(5)
|
||||
},
|
||||
mutationKey: queryKey(),
|
||||
})
|
||||
})
|
||||
|
||||
it('should infer result type correctly', () => {
|
||||
mutationOptions<number, DefaultError, void, { name: string }>({
|
||||
mutationFn: () => Promise.resolve(5),
|
||||
mutationKey: queryKey(),
|
||||
onMutate: () => {
|
||||
return { name: 'onMutateResult' }
|
||||
},
|
||||
onSuccess: (_data, _variables, onMutateResult) => {
|
||||
expectTypeOf(onMutateResult).toEqualTypeOf<{ name: string }>()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should infer context type correctly', () => {
|
||||
mutationOptions<number>({
|
||||
mutationFn: (_variables, context) => {
|
||||
expectTypeOf(context).toEqualTypeOf<MutationFunctionContext>()
|
||||
return Promise.resolve(5)
|
||||
},
|
||||
mutationKey: queryKey(),
|
||||
onMutate: (_variables, context) => {
|
||||
expectTypeOf(context).toEqualTypeOf<MutationFunctionContext>()
|
||||
},
|
||||
onSuccess: (_data, _variables, _onMutateResult, context) => {
|
||||
expectTypeOf(context).toEqualTypeOf<MutationFunctionContext>()
|
||||
},
|
||||
onError: (_error, _variables, _onMutateResult, context) => {
|
||||
expectTypeOf(context).toEqualTypeOf<MutationFunctionContext>()
|
||||
},
|
||||
onSettled: (_data, _error, _variables, _onMutateResult, context) => {
|
||||
expectTypeOf(context).toEqualTypeOf<MutationFunctionContext>()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should error if mutationFn return type mismatches TData', () => {
|
||||
assertType(
|
||||
mutationOptions<number>({
|
||||
// @ts-expect-error this is a good error, because return type is string, not number
|
||||
mutationFn: async () => Promise.resolve('wrong return'),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should allow mutationKey to be omitted', () => {
|
||||
return mutationOptions({
|
||||
mutationFn: () => Promise.resolve(123),
|
||||
onSuccess: (data) => {
|
||||
expectTypeOf(data).toEqualTypeOf<number>()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should infer all types when not explicitly provided', () => {
|
||||
expectTypeOf(
|
||||
mutationOptions({
|
||||
mutationFn: (id: string) => Promise.resolve(id.length),
|
||||
mutationKey: queryKey(),
|
||||
onSuccess: (data) => {
|
||||
expectTypeOf(data).toEqualTypeOf<number>()
|
||||
},
|
||||
}),
|
||||
).toEqualTypeOf<
|
||||
WithRequired<
|
||||
UseMutationOptions<number, DefaultError, string>,
|
||||
'mutationKey'
|
||||
>
|
||||
>()
|
||||
expectTypeOf(
|
||||
mutationOptions({
|
||||
mutationFn: (id: string) => Promise.resolve(id.length),
|
||||
onSuccess: (data) => {
|
||||
expectTypeOf(data).toEqualTypeOf<number>()
|
||||
},
|
||||
}),
|
||||
).toEqualTypeOf<
|
||||
Omit<UseMutationOptions<number, DefaultError, string>, 'mutationKey'>
|
||||
>()
|
||||
})
|
||||
|
||||
it('should infer types when used with useMutation', () => {
|
||||
const mutation = useMutation(
|
||||
mutationOptions({
|
||||
mutationKey: queryKey(),
|
||||
mutationFn: () => Promise.resolve('data'),
|
||||
onSuccess: (data) => {
|
||||
expectTypeOf(data).toEqualTypeOf<string>()
|
||||
},
|
||||
}),
|
||||
)
|
||||
expectTypeOf(mutation).toEqualTypeOf<
|
||||
UseMutationResult<string, DefaultError, void, unknown>
|
||||
>()
|
||||
|
||||
useMutation(
|
||||
// should allow when used with useMutation without mutationKey
|
||||
mutationOptions({
|
||||
mutationFn: () => Promise.resolve('data'),
|
||||
onSuccess: (data) => {
|
||||
expectTypeOf(data).toEqualTypeOf<string>()
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should infer types when used with useIsMutating', () => {
|
||||
const isMutating = useIsMutating(
|
||||
mutationOptions({
|
||||
mutationKey: queryKey(),
|
||||
mutationFn: () => Promise.resolve(5),
|
||||
}),
|
||||
)
|
||||
expectTypeOf(isMutating).toEqualTypeOf<number>()
|
||||
|
||||
useIsMutating(
|
||||
// @ts-expect-error filters should have mutationKey
|
||||
mutationOptions({
|
||||
mutationFn: () => Promise.resolve(5),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should infer types when used with queryClient.isMutating', () => {
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
const isMutating = queryClient.isMutating(
|
||||
mutationOptions({
|
||||
mutationKey: queryKey(),
|
||||
mutationFn: () => Promise.resolve(5),
|
||||
}),
|
||||
)
|
||||
expectTypeOf(isMutating).toEqualTypeOf<number>()
|
||||
|
||||
queryClient.isMutating(
|
||||
// @ts-expect-error filters should have mutationKey
|
||||
mutationOptions({
|
||||
mutationFn: () => Promise.resolve(5),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should infer types when used with useMutationState', () => {
|
||||
const mutationState = useMutationState({
|
||||
filters: mutationOptions({
|
||||
mutationKey: queryKey(),
|
||||
mutationFn: () => Promise.resolve(5),
|
||||
}),
|
||||
})
|
||||
expectTypeOf(mutationState).toEqualTypeOf<
|
||||
Array<MutationState<unknown, Error, unknown, unknown>>
|
||||
>()
|
||||
|
||||
useMutationState({
|
||||
// @ts-expect-error filters should have mutationKey
|
||||
filters: mutationOptions({
|
||||
mutationFn: () => Promise.resolve(5),
|
||||
}),
|
||||
})
|
||||
})
|
||||
})
|
||||
536
packages/preact-query/src/__tests__/mutationOptions.test.tsx
Normal file
536
packages/preact-query/src/__tests__/mutationOptions.test.tsx
Normal file
@@ -0,0 +1,536 @@
|
||||
import { QueryClient } from '@tanstack/query-core'
|
||||
import type { MutationState } from '@tanstack/query-core'
|
||||
import { queryKey, sleep } from '@tanstack/query-test-utils'
|
||||
import { fireEvent } from '@testing-library/preact'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useIsMutating, useMutation, useMutationState } from '..'
|
||||
import { mutationOptions } from '../mutationOptions'
|
||||
import { renderWithClient } from './utils'
|
||||
import type { UseMutationOptions } from '../types'
|
||||
|
||||
describe('mutationOptions', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should return the object received as a parameter without any modification (with mutationKey in mutationOptions)', () => {
|
||||
const object: UseMutationOptions = {
|
||||
mutationKey: ['key'],
|
||||
mutationFn: () => sleep(10).then(() => 5),
|
||||
} as const
|
||||
|
||||
expect(mutationOptions(object)).toBe(object)
|
||||
})
|
||||
|
||||
it('should return the object received as a parameter without any modification (without mutationKey in mutationOptions)', () => {
|
||||
const object: UseMutationOptions = {
|
||||
mutationFn: () => sleep(10).then(() => 5),
|
||||
} as const
|
||||
|
||||
expect(mutationOptions(object)).toBe(object)
|
||||
})
|
||||
|
||||
it('should return the number of fetching mutations when used with useIsMutating (with mutationKey in mutationOptions)', async () => {
|
||||
const isMutatingArray: Array<number> = []
|
||||
const queryClient = new QueryClient()
|
||||
const key = queryKey()
|
||||
const mutationOpts = mutationOptions({
|
||||
mutationKey: key,
|
||||
mutationFn: () => sleep(50).then(() => 'data'),
|
||||
})
|
||||
|
||||
function IsMutating() {
|
||||
const isMutating = useIsMutating()
|
||||
|
||||
isMutatingArray.push(isMutating)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function Mutation() {
|
||||
const { mutate } = useMutation(mutationOpts)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => mutate()}>mutate</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Page() {
|
||||
return (
|
||||
<div>
|
||||
<IsMutating />
|
||||
<Mutation />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(queryClient, <Page />)
|
||||
|
||||
fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
|
||||
expect(isMutatingArray[0]).toEqual(0)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(isMutatingArray[1]).toEqual(1)
|
||||
await vi.advanceTimersByTimeAsync(51)
|
||||
expect(isMutatingArray[2]).toEqual(0)
|
||||
expect(isMutatingArray[isMutatingArray.length - 1]).toEqual(0)
|
||||
})
|
||||
|
||||
it('should return the number of fetching mutations when used with useIsMutating (without mutationKey in mutationOptions)', async () => {
|
||||
const isMutatingArray: Array<number> = []
|
||||
const queryClient = new QueryClient()
|
||||
const mutationOpts = mutationOptions({
|
||||
mutationFn: () => sleep(50).then(() => 'data'),
|
||||
})
|
||||
|
||||
function IsMutating() {
|
||||
const isMutating = useIsMutating()
|
||||
|
||||
isMutatingArray.push(isMutating)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function Mutation() {
|
||||
const { mutate } = useMutation(mutationOpts)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => mutate()}>mutate</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Page() {
|
||||
return (
|
||||
<div>
|
||||
<IsMutating />
|
||||
<Mutation />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(queryClient, <Page />)
|
||||
|
||||
fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
|
||||
expect(isMutatingArray[0]).toEqual(0)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(isMutatingArray[1]).toEqual(1)
|
||||
await vi.advanceTimersByTimeAsync(51)
|
||||
expect(isMutatingArray[2]).toEqual(0)
|
||||
expect(isMutatingArray[isMutatingArray.length - 1]).toEqual(0)
|
||||
})
|
||||
|
||||
it('should return the number of fetching mutations when used with useIsMutating', async () => {
|
||||
const isMutatingArray: Array<number> = []
|
||||
const queryClient = new QueryClient()
|
||||
const key = queryKey()
|
||||
const mutationOpts1 = mutationOptions({
|
||||
mutationKey: key,
|
||||
mutationFn: () => sleep(50).then(() => 'data1'),
|
||||
})
|
||||
const mutationOpts2 = mutationOptions({
|
||||
mutationFn: () => sleep(50).then(() => 'data2'),
|
||||
})
|
||||
|
||||
function IsMutating() {
|
||||
const isMutating = useIsMutating()
|
||||
|
||||
isMutatingArray.push(isMutating)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function Mutation() {
|
||||
const { mutate: mutate1 } = useMutation(mutationOpts1)
|
||||
const { mutate: mutate2 } = useMutation(mutationOpts2)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => mutate1()}>mutate1</button>
|
||||
<button onClick={() => mutate2()}>mutate2</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Page() {
|
||||
return (
|
||||
<div>
|
||||
<IsMutating />
|
||||
<Mutation />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(queryClient, <Page />)
|
||||
|
||||
fireEvent.click(rendered.getByRole('button', { name: /mutate1/i }))
|
||||
fireEvent.click(rendered.getByRole('button', { name: /mutate2/i }))
|
||||
expect(isMutatingArray[0]).toEqual(0)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(isMutatingArray[1]).toEqual(2)
|
||||
await vi.advanceTimersByTimeAsync(51)
|
||||
expect(isMutatingArray[2]).toEqual(0)
|
||||
expect(isMutatingArray[isMutatingArray.length - 1]).toEqual(0)
|
||||
})
|
||||
|
||||
it('should return the number of fetching mutations when used with useIsMutating (filter mutationOpts1.mutationKey)', async () => {
|
||||
const isMutatingArray: Array<number> = []
|
||||
const queryClient = new QueryClient()
|
||||
const key = queryKey()
|
||||
const mutationOpts1 = mutationOptions({
|
||||
mutationKey: key,
|
||||
mutationFn: () => sleep(50).then(() => 'data1'),
|
||||
})
|
||||
const mutationOpts2 = mutationOptions({
|
||||
mutationFn: () => sleep(50).then(() => 'data2'),
|
||||
})
|
||||
|
||||
function IsMutating() {
|
||||
const isMutating = useIsMutating({
|
||||
mutationKey: mutationOpts1.mutationKey,
|
||||
})
|
||||
|
||||
isMutatingArray.push(isMutating)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function Mutation() {
|
||||
const { mutate: mutate1 } = useMutation(mutationOpts1)
|
||||
const { mutate: mutate2 } = useMutation(mutationOpts2)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => mutate1()}>mutate1</button>
|
||||
<button onClick={() => mutate2()}>mutate2</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Page() {
|
||||
return (
|
||||
<div>
|
||||
<IsMutating />
|
||||
<Mutation />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(queryClient, <Page />)
|
||||
|
||||
fireEvent.click(rendered.getByRole('button', { name: /mutate1/i }))
|
||||
fireEvent.click(rendered.getByRole('button', { name: /mutate2/i }))
|
||||
expect(isMutatingArray[0]).toEqual(0)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(isMutatingArray[1]).toEqual(1)
|
||||
await vi.advanceTimersByTimeAsync(51)
|
||||
expect(isMutatingArray[2]).toEqual(0)
|
||||
expect(isMutatingArray[isMutatingArray.length - 1]).toEqual(0)
|
||||
})
|
||||
|
||||
it('should return the number of fetching mutations when used with queryClient.isMutating (with mutationKey in mutationOptions)', async () => {
|
||||
const isMutatingArray: Array<number> = []
|
||||
const queryClient = new QueryClient()
|
||||
const key = queryKey()
|
||||
const mutationOpts = mutationOptions({
|
||||
mutationKey: key,
|
||||
mutationFn: () => sleep(500).then(() => 'data'),
|
||||
})
|
||||
|
||||
function Mutation() {
|
||||
const isMutating = queryClient.isMutating(mutationOpts)
|
||||
const { mutate } = useMutation(mutationOpts)
|
||||
|
||||
isMutatingArray.push(isMutating)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => mutate()}>mutate</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(queryClient, <Mutation />)
|
||||
|
||||
fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
|
||||
expect(isMutatingArray[0]).toEqual(0)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(isMutatingArray[1]).toEqual(1)
|
||||
await vi.advanceTimersByTimeAsync(501)
|
||||
expect(isMutatingArray[2]).toEqual(0)
|
||||
expect(isMutatingArray[isMutatingArray.length - 1]).toEqual(0)
|
||||
})
|
||||
|
||||
it('should return the number of fetching mutations when used with queryClient.isMutating (without mutationKey in mutationOptions)', async () => {
|
||||
const isMutatingArray: Array<number> = []
|
||||
const queryClient = new QueryClient()
|
||||
const mutationOpts = mutationOptions({
|
||||
mutationFn: () => sleep(500).then(() => 'data'),
|
||||
})
|
||||
|
||||
function Mutation() {
|
||||
const isMutating = queryClient.isMutating()
|
||||
const { mutate } = useMutation(mutationOpts)
|
||||
|
||||
isMutatingArray.push(isMutating)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => mutate()}>mutate</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(queryClient, <Mutation />)
|
||||
|
||||
fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
|
||||
expect(isMutatingArray[0]).toEqual(0)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(isMutatingArray[1]).toEqual(1)
|
||||
await vi.advanceTimersByTimeAsync(501)
|
||||
expect(isMutatingArray[2]).toEqual(0)
|
||||
expect(isMutatingArray[isMutatingArray.length - 1]).toEqual(0)
|
||||
})
|
||||
|
||||
it('should return the number of fetching mutations when used with queryClient.isMutating', async () => {
|
||||
const isMutatingArray: Array<number> = []
|
||||
const queryClient = new QueryClient()
|
||||
const key = queryKey()
|
||||
const mutationOpts1 = mutationOptions({
|
||||
mutationKey: key,
|
||||
mutationFn: () => sleep(500).then(() => 'data1'),
|
||||
})
|
||||
const mutationOpts2 = mutationOptions({
|
||||
mutationFn: () => sleep(500).then(() => 'data2'),
|
||||
})
|
||||
|
||||
function Mutation() {
|
||||
const isMutating = queryClient.isMutating()
|
||||
const { mutate: mutate1 } = useMutation(mutationOpts1)
|
||||
const { mutate: mutate2 } = useMutation(mutationOpts2)
|
||||
|
||||
isMutatingArray.push(isMutating)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => mutate1()}>mutate1</button>
|
||||
<button onClick={() => mutate2()}>mutate2</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(queryClient, <Mutation />)
|
||||
|
||||
fireEvent.click(rendered.getByRole('button', { name: /mutate1/i }))
|
||||
fireEvent.click(rendered.getByRole('button', { name: /mutate2/i }))
|
||||
expect(isMutatingArray[0]).toEqual(0)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(isMutatingArray[1]).toEqual(2)
|
||||
await vi.advanceTimersByTimeAsync(501)
|
||||
expect(isMutatingArray[2]).toEqual(0)
|
||||
expect(isMutatingArray[isMutatingArray.length - 1]).toEqual(0)
|
||||
})
|
||||
|
||||
it('should return the number of fetching mutations when used with queryClient.isMutating (filter mutationOpt1.mutationKey)', async () => {
|
||||
const isMutatingArray: Array<number> = []
|
||||
const queryClient = new QueryClient()
|
||||
const key = queryKey()
|
||||
const mutationOpts1 = mutationOptions({
|
||||
mutationKey: key,
|
||||
mutationFn: () => sleep(500).then(() => 'data1'),
|
||||
})
|
||||
const mutationOpts2 = mutationOptions({
|
||||
mutationFn: () => sleep(500).then(() => 'data2'),
|
||||
})
|
||||
|
||||
function Mutation() {
|
||||
const isMutating = queryClient.isMutating({
|
||||
mutationKey: mutationOpts1.mutationKey,
|
||||
})
|
||||
const { mutate: mutate1 } = useMutation(mutationOpts1)
|
||||
const { mutate: mutate2 } = useMutation(mutationOpts2)
|
||||
|
||||
isMutatingArray.push(isMutating)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => mutate1()}>mutate1</button>
|
||||
<button onClick={() => mutate2()}>mutate2</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(queryClient, <Mutation />)
|
||||
|
||||
fireEvent.click(rendered.getByRole('button', { name: /mutate1/i }))
|
||||
fireEvent.click(rendered.getByRole('button', { name: /mutate2/i }))
|
||||
expect(isMutatingArray[0]).toEqual(0)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(isMutatingArray[1]).toEqual(1)
|
||||
await vi.advanceTimersByTimeAsync(501)
|
||||
expect(isMutatingArray[2]).toEqual(0)
|
||||
expect(isMutatingArray[isMutatingArray.length - 1]).toEqual(0)
|
||||
})
|
||||
|
||||
it('should return the number of fetching mutations when used with useMutationState (with mutationKey in mutationOptions)', async () => {
|
||||
const mutationStateArray: Array<
|
||||
MutationState<unknown, Error, unknown, unknown>
|
||||
> = []
|
||||
const queryClient = new QueryClient()
|
||||
const key = queryKey()
|
||||
const mutationOpts = mutationOptions({
|
||||
mutationKey: key,
|
||||
mutationFn: () => sleep(10).then(() => 'data'),
|
||||
})
|
||||
|
||||
function Mutation() {
|
||||
const { mutate } = useMutation(mutationOpts)
|
||||
const data = useMutationState({
|
||||
filters: { mutationKey: mutationOpts.mutationKey, status: 'success' },
|
||||
})
|
||||
|
||||
mutationStateArray.push(...data)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => mutate()}>mutate</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(queryClient, <Mutation />)
|
||||
|
||||
expect(mutationStateArray.length).toEqual(0)
|
||||
|
||||
fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(mutationStateArray.length).toEqual(1)
|
||||
expect(mutationStateArray[0]?.data).toEqual('data')
|
||||
})
|
||||
|
||||
it('should return the number of fetching mutations when used with useMutationState (without mutationKey in mutationOptions)', async () => {
|
||||
const mutationStateArray: Array<
|
||||
MutationState<unknown, Error, unknown, unknown>
|
||||
> = []
|
||||
const queryClient = new QueryClient()
|
||||
const mutationOpts = mutationOptions({
|
||||
mutationFn: () => sleep(10).then(() => 'data'),
|
||||
})
|
||||
|
||||
function Mutation() {
|
||||
const { mutate } = useMutation(mutationOpts)
|
||||
const data = useMutationState({
|
||||
filters: { status: 'success' },
|
||||
})
|
||||
|
||||
mutationStateArray.push(...data)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => mutate()}>mutate</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(queryClient, <Mutation />)
|
||||
|
||||
expect(mutationStateArray.length).toEqual(0)
|
||||
|
||||
fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(mutationStateArray.length).toEqual(1)
|
||||
expect(mutationStateArray[0]?.data).toEqual('data')
|
||||
})
|
||||
|
||||
it('should return the number of fetching mutations when used with useMutationState', async () => {
|
||||
const mutationStateArray: Array<
|
||||
MutationState<unknown, Error, unknown, unknown>
|
||||
> = []
|
||||
const queryClient = new QueryClient()
|
||||
const key = queryKey()
|
||||
const mutationOpts1 = mutationOptions({
|
||||
mutationKey: key,
|
||||
mutationFn: () => sleep(10).then(() => 'data1'),
|
||||
})
|
||||
const mutationOpts2 = mutationOptions({
|
||||
mutationFn: () => sleep(10).then(() => 'data2'),
|
||||
})
|
||||
|
||||
function Mutation() {
|
||||
const { mutate: mutate1 } = useMutation(mutationOpts1)
|
||||
const { mutate: mutate2 } = useMutation(mutationOpts2)
|
||||
const data = useMutationState({
|
||||
filters: { status: 'success' },
|
||||
})
|
||||
|
||||
mutationStateArray.push(...data)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => mutate1()}>mutate1</button>
|
||||
<button onClick={() => mutate2()}>mutate2</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(queryClient, <Mutation />)
|
||||
|
||||
expect(mutationStateArray.length).toEqual(0)
|
||||
|
||||
fireEvent.click(rendered.getByRole('button', { name: /mutate1/i }))
|
||||
fireEvent.click(rendered.getByRole('button', { name: /mutate2/i }))
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(mutationStateArray.length).toEqual(2)
|
||||
expect(mutationStateArray[0]?.data).toEqual('data1')
|
||||
expect(mutationStateArray[1]?.data).toEqual('data2')
|
||||
})
|
||||
|
||||
it('should return the number of fetching mutations when used with useMutationState (filter mutationOpt1.mutationKey)', async () => {
|
||||
const mutationStateArray: Array<
|
||||
MutationState<unknown, Error, unknown, unknown>
|
||||
> = []
|
||||
const queryClient = new QueryClient()
|
||||
const key = queryKey()
|
||||
const mutationOpts1 = mutationOptions({
|
||||
mutationKey: key,
|
||||
mutationFn: () => sleep(10).then(() => 'data1'),
|
||||
})
|
||||
const mutationOpts2 = mutationOptions({
|
||||
mutationFn: () => sleep(10).then(() => 'data2'),
|
||||
})
|
||||
|
||||
function Mutation() {
|
||||
const { mutate: mutate1 } = useMutation(mutationOpts1)
|
||||
const { mutate: mutate2 } = useMutation(mutationOpts2)
|
||||
const data = useMutationState({
|
||||
filters: { mutationKey: mutationOpts1.mutationKey, status: 'success' },
|
||||
})
|
||||
|
||||
mutationStateArray.push(...data)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => mutate1()}>mutate1</button>
|
||||
<button onClick={() => mutate2()}>mutate2</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(queryClient, <Mutation />)
|
||||
|
||||
expect(mutationStateArray.length).toEqual(0)
|
||||
|
||||
fireEvent.click(rendered.getByRole('button', { name: /mutate1/i }))
|
||||
fireEvent.click(rendered.getByRole('button', { name: /mutate2/i }))
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(mutationStateArray.length).toEqual(1)
|
||||
expect(mutationStateArray[0]?.data).toEqual('data1')
|
||||
})
|
||||
})
|
||||
335
packages/preact-query/src/__tests__/queryOptions.test-d.tsx
Normal file
335
packages/preact-query/src/__tests__/queryOptions.test-d.tsx
Normal file
@@ -0,0 +1,335 @@
|
||||
import {
|
||||
QueriesObserver,
|
||||
QueryClient,
|
||||
dataTagSymbol,
|
||||
skipToken,
|
||||
} from '@tanstack/query-core'
|
||||
import type {
|
||||
DataTag,
|
||||
InitialDataFunction,
|
||||
QueryObserverResult,
|
||||
} from '@tanstack/query-core'
|
||||
import { queryKey } from '@tanstack/query-test-utils'
|
||||
import { assertType, describe, expectTypeOf, it } from 'vitest'
|
||||
|
||||
import { queryOptions } from '../queryOptions'
|
||||
import type { AnyUseQueryOptions } from '../types'
|
||||
import { useQueries } from '../useQueries'
|
||||
import { useQuery } from '../useQuery'
|
||||
import { useSuspenseQuery } from '../useSuspenseQuery'
|
||||
|
||||
// Regression test for exported queryOptions inference under declaration emit.
|
||||
// TypeScript should be able to name the return type without expanding the
|
||||
// internal data tag symbols into the consumer's .d.ts output.
|
||||
export const exportedQueryOptions = queryOptions({
|
||||
queryKey: ['invalid'],
|
||||
})
|
||||
|
||||
describe('queryOptions', () => {
|
||||
it('should not allow excess properties', () => {
|
||||
assertType(
|
||||
queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
// @ts-expect-error this is a good error, because stallTime does not exist!
|
||||
stallTime: 1000,
|
||||
}),
|
||||
)
|
||||
})
|
||||
it('should infer types for callbacks', () => {
|
||||
queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
staleTime: 1000,
|
||||
select: (data) => {
|
||||
expectTypeOf(data).toEqualTypeOf<number>()
|
||||
},
|
||||
})
|
||||
})
|
||||
it('should work when passed to useQuery', () => {
|
||||
const options = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
})
|
||||
|
||||
const { data } = useQuery(options)
|
||||
expectTypeOf(data).toEqualTypeOf<number | undefined>()
|
||||
})
|
||||
it('should work when passed to useSuspenseQuery', () => {
|
||||
const options = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
})
|
||||
|
||||
const { data } = useSuspenseQuery(options)
|
||||
expectTypeOf(data).toEqualTypeOf<number>()
|
||||
})
|
||||
it('should work when passed to query', async () => {
|
||||
const options = queryOptions({
|
||||
queryKey: ['key'],
|
||||
queryFn: () => Promise.resolve(5),
|
||||
})
|
||||
|
||||
const data = await new QueryClient().query(options)
|
||||
expectTypeOf(data).toEqualTypeOf<number>()
|
||||
})
|
||||
it('should work when passed to query with select', async () => {
|
||||
const options = queryOptions({
|
||||
queryKey: ['key'],
|
||||
queryFn: () => Promise.resolve(5),
|
||||
select: (data) => data.toString(),
|
||||
})
|
||||
|
||||
const data = await new QueryClient().query(options)
|
||||
expectTypeOf(data).toEqualTypeOf<string>()
|
||||
})
|
||||
it('should work when passed to query with enabled: false', async () => {
|
||||
const options = queryOptions({
|
||||
queryKey: ['key'],
|
||||
queryFn: () => Promise.resolve(5),
|
||||
enabled: false,
|
||||
})
|
||||
|
||||
const data = await new QueryClient().query(options)
|
||||
expectTypeOf(data).toEqualTypeOf<number>()
|
||||
})
|
||||
it('should work when passed to query with skipToken', async () => {
|
||||
const options = queryOptions({
|
||||
queryKey: ['key'],
|
||||
queryFn: skipToken,
|
||||
})
|
||||
|
||||
const data = await new QueryClient().query(options)
|
||||
expectTypeOf(data).toEqualTypeOf<unknown>()
|
||||
})
|
||||
|
||||
it('should work when passed to fetchQuery', async () => {
|
||||
const options = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
})
|
||||
|
||||
// grandfathered direct test
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const data = await new QueryClient().fetchQuery(options)
|
||||
expectTypeOf(data).toEqualTypeOf<number>()
|
||||
})
|
||||
it('should work when passed to useQueries', () => {
|
||||
const options = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
})
|
||||
|
||||
const [{ data }] = useQueries({
|
||||
queries: [options],
|
||||
})
|
||||
|
||||
expectTypeOf(data).toEqualTypeOf<number | undefined>()
|
||||
})
|
||||
it('should tag the queryKey with the result type of the QueryFn', () => {
|
||||
const { queryKey: tagged } = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
})
|
||||
|
||||
expectTypeOf(tagged[dataTagSymbol]).toEqualTypeOf<number>()
|
||||
})
|
||||
it('should tag the queryKey even if no promise is returned', () => {
|
||||
const { queryKey: tagged } = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => 5,
|
||||
})
|
||||
|
||||
expectTypeOf(tagged[dataTagSymbol]).toEqualTypeOf<number>()
|
||||
})
|
||||
it('should tag the queryKey with unknown if there is no queryFn', () => {
|
||||
const { queryKey: tagged } = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
})
|
||||
|
||||
expectTypeOf(tagged[dataTagSymbol]).toEqualTypeOf<unknown>()
|
||||
})
|
||||
it('should tag the queryKey with the result type of the QueryFn if select is used', () => {
|
||||
const { queryKey: tagged } = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
select: (data) => data.toString(),
|
||||
})
|
||||
|
||||
expectTypeOf(tagged[dataTagSymbol]).toEqualTypeOf<number>()
|
||||
})
|
||||
it('should return the proper type when passed to getQueryData', () => {
|
||||
const { queryKey: tagged } = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
})
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
const data = queryClient.getQueryData(tagged)
|
||||
expectTypeOf(data).toEqualTypeOf<number | undefined>()
|
||||
})
|
||||
it('should return the proper type when passed to getQueryState', () => {
|
||||
const { queryKey: tagged } = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
})
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
const state = queryClient.getQueryState(tagged)
|
||||
expectTypeOf(state?.data).toEqualTypeOf<number | undefined>()
|
||||
})
|
||||
it('should properly type updaterFn when passed to setQueryData', () => {
|
||||
const { queryKey: tagged } = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
})
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
const data = queryClient.setQueryData(tagged, (prev) => {
|
||||
expectTypeOf(prev).toEqualTypeOf<number | undefined>()
|
||||
return prev
|
||||
})
|
||||
expectTypeOf(data).toEqualTypeOf<number | undefined>()
|
||||
})
|
||||
it('should properly type value when passed to setQueryData', () => {
|
||||
const { queryKey: tagged } = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
})
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
// @ts-expect-error value should be a number
|
||||
queryClient.setQueryData(tagged, '5')
|
||||
// @ts-expect-error value should be a number
|
||||
queryClient.setQueryData(tagged, () => '5')
|
||||
|
||||
const data = queryClient.setQueryData(tagged, 5)
|
||||
expectTypeOf(data).toEqualTypeOf<number | undefined>()
|
||||
})
|
||||
|
||||
it('should infer even if there is a conditional skipToken', () => {
|
||||
const options = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: Math.random() > 0.5 ? skipToken : () => Promise.resolve(5),
|
||||
})
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
const data = queryClient.getQueryData(options.queryKey)
|
||||
expectTypeOf(data).toEqualTypeOf<number | undefined>()
|
||||
})
|
||||
|
||||
it('should infer to unknown if we disable a query with just a skipToken', () => {
|
||||
const options = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: skipToken,
|
||||
})
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
const data = queryClient.getQueryData(options.queryKey)
|
||||
expectTypeOf(data).toEqualTypeOf<unknown>()
|
||||
})
|
||||
|
||||
it('should throw a type error when using queryFn with skipToken in a suspense query', () => {
|
||||
const options = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: Math.random() > 0.5 ? skipToken : () => Promise.resolve(5),
|
||||
})
|
||||
// @ts-expect-error TS2345
|
||||
const { data } = useSuspenseQuery(options)
|
||||
expectTypeOf(data).toEqualTypeOf<number>()
|
||||
})
|
||||
|
||||
it('should return the proper type when passed to QueriesObserver', () => {
|
||||
const options = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
})
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
const queriesObserver = new QueriesObserver(queryClient, [options])
|
||||
expectTypeOf(queriesObserver).toEqualTypeOf<
|
||||
QueriesObserver<Array<QueryObserverResult>>
|
||||
>()
|
||||
})
|
||||
|
||||
it('should allow undefined response in initialData', () => {
|
||||
assertType((id: string | null) =>
|
||||
queryOptions({
|
||||
queryKey: [...queryKey(), id],
|
||||
queryFn: () =>
|
||||
Promise.resolve({
|
||||
id: '1',
|
||||
title: 'Do Laundry',
|
||||
}),
|
||||
initialData: () =>
|
||||
!id
|
||||
? undefined
|
||||
: {
|
||||
id,
|
||||
title: 'Initial Data',
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should allow optional initialData object', () => {
|
||||
const testFn = (id?: string) => {
|
||||
const options = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve('something string'),
|
||||
initialData: id ? 'initial string' : undefined,
|
||||
})
|
||||
expectTypeOf(options.initialData).toExtend<
|
||||
InitialDataFunction<string> | string | undefined
|
||||
>()
|
||||
}
|
||||
testFn('id')
|
||||
testFn()
|
||||
})
|
||||
|
||||
it('should be passable to UseQueryOptions', () => {
|
||||
function somethingWithQueryOptions<TQueryOpts extends AnyUseQueryOptions>(
|
||||
options: TQueryOpts,
|
||||
) {
|
||||
return options.queryKey
|
||||
}
|
||||
|
||||
const options = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(1),
|
||||
})
|
||||
|
||||
assertType(somethingWithQueryOptions(options))
|
||||
})
|
||||
|
||||
it('should return a custom query key type', () => {
|
||||
type MyQueryKey = [Array<string>, { type: 'foo' }]
|
||||
|
||||
const options = queryOptions({
|
||||
queryKey: [['key'], { type: 'foo' }] as MyQueryKey,
|
||||
queryFn: () => Promise.resolve(1),
|
||||
})
|
||||
|
||||
expectTypeOf(options.queryKey).toEqualTypeOf<
|
||||
DataTag<MyQueryKey, number, Error>
|
||||
>()
|
||||
})
|
||||
|
||||
it('should return a custom query key type with datatag', () => {
|
||||
type MyQueryKey = DataTag<
|
||||
[Array<string>, { type: 'foo' }],
|
||||
number,
|
||||
Error & { myMessage: string }
|
||||
>
|
||||
|
||||
const options = queryOptions({
|
||||
queryKey: [['key'], { type: 'foo' }] as MyQueryKey,
|
||||
queryFn: () => Promise.resolve(1),
|
||||
})
|
||||
|
||||
expectTypeOf(options.queryKey).toEqualTypeOf<
|
||||
DataTag<MyQueryKey, number, Error & { myMessage: string }>
|
||||
>()
|
||||
})
|
||||
})
|
||||
15
packages/preact-query/src/__tests__/queryOptions.test.tsx
Normal file
15
packages/preact-query/src/__tests__/queryOptions.test.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { queryOptions } from '../queryOptions'
|
||||
import type { UseQueryOptions } from '../types'
|
||||
|
||||
describe('queryOptions', () => {
|
||||
it('should return the object received as a parameter without any modification.', () => {
|
||||
const object: UseQueryOptions = {
|
||||
queryKey: ['key'],
|
||||
queryFn: () => Promise.resolve(5),
|
||||
} as const
|
||||
|
||||
expect(queryOptions(object)).toBe(object)
|
||||
})
|
||||
})
|
||||
277
packages/preact-query/src/__tests__/ssr-hydration.test.tsx
Normal file
277
packages/preact-query/src/__tests__/ssr-hydration.test.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
import { queryKey } from '@tanstack/query-test-utils'
|
||||
import { act } from '@testing-library/preact'
|
||||
import { hydrate as preactHydrate, render } from 'preact'
|
||||
import type { VNode } from 'preact'
|
||||
import { renderToString } from 'preact-render-to-string'
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
QueryCache,
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
dehydrate,
|
||||
hydrate,
|
||||
noop,
|
||||
useQuery,
|
||||
} from '..'
|
||||
import { setIsServer } from './utils'
|
||||
|
||||
const PreactHydrate = (element: VNode, container: Element) => {
|
||||
act(() => {
|
||||
preactHydrate(element, container)
|
||||
})
|
||||
// To unmount in Preact, you render null into the same container
|
||||
return () => {
|
||||
act(() => {
|
||||
render(null, container)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchData<TData>(value: TData, ms?: number): Promise<TData> {
|
||||
await vi.advanceTimersByTimeAsync(ms || 1)
|
||||
return value
|
||||
}
|
||||
|
||||
function PrintStateComponent({ componentName, result }: any): any {
|
||||
return `${componentName} - status:${result.status} fetching:${result.isFetching} data:${result.data}`
|
||||
}
|
||||
|
||||
describe('Server side rendering with de/rehydration', () => {
|
||||
const successKey = queryKey()
|
||||
const errorKey = queryKey()
|
||||
|
||||
beforeAll(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should not mismatch on success', async () => {
|
||||
const consoleMock = vi.spyOn(console, 'error')
|
||||
consoleMock.mockImplementation(() => undefined)
|
||||
|
||||
const fetchDataSuccess = vi.fn<typeof fetchData>(fetchData)
|
||||
|
||||
// -- Shared part --
|
||||
function SuccessComponent() {
|
||||
const result = useQuery({
|
||||
queryKey: successKey,
|
||||
queryFn: () => fetchDataSuccess('success!'),
|
||||
})
|
||||
return (
|
||||
<PrintStateComponent componentName="SuccessComponent" result={result} />
|
||||
)
|
||||
}
|
||||
|
||||
// -- Server part --
|
||||
setIsServer(true)
|
||||
|
||||
const prefetchCache = new QueryCache()
|
||||
const prefetchClient = new QueryClient({
|
||||
queryCache: prefetchCache,
|
||||
})
|
||||
await prefetchClient
|
||||
.query({
|
||||
queryKey: successKey,
|
||||
queryFn: () => fetchDataSuccess('success'),
|
||||
})
|
||||
.catch(noop)
|
||||
const dehydratedStateServer = dehydrate(prefetchClient)
|
||||
const renderCache = new QueryCache()
|
||||
const renderClient = new QueryClient({
|
||||
queryCache: renderCache,
|
||||
})
|
||||
hydrate(renderClient, dehydratedStateServer)
|
||||
const markup = renderToString(
|
||||
<QueryClientProvider client={renderClient}>
|
||||
<SuccessComponent />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
const stringifiedState = JSON.stringify(dehydratedStateServer)
|
||||
renderClient.clear()
|
||||
setIsServer(false)
|
||||
|
||||
const expectedMarkup =
|
||||
'SuccessComponent - status:success fetching:true data:success'
|
||||
|
||||
expect(markup).toBe(expectedMarkup)
|
||||
expect(fetchDataSuccess).toHaveBeenCalledTimes(1)
|
||||
|
||||
// -- Client part --
|
||||
const el = document.createElement('div')
|
||||
el.innerHTML = markup
|
||||
|
||||
const queryCache = new QueryCache()
|
||||
const queryClient = new QueryClient({ queryCache })
|
||||
hydrate(queryClient, JSON.parse(stringifiedState))
|
||||
|
||||
const unmount = PreactHydrate(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SuccessComponent />
|
||||
</QueryClientProvider>,
|
||||
el,
|
||||
)
|
||||
|
||||
// Check that we have no React hydration mismatches
|
||||
expect(consoleMock).toHaveBeenCalledTimes(0)
|
||||
|
||||
expect(fetchDataSuccess).toHaveBeenCalledTimes(2)
|
||||
expect(el.innerHTML).toBe(expectedMarkup)
|
||||
|
||||
unmount()
|
||||
queryClient.clear()
|
||||
consoleMock.mockRestore()
|
||||
})
|
||||
|
||||
it('should not mismatch on error', async () => {
|
||||
const consoleMock = vi.spyOn(console, 'error')
|
||||
consoleMock.mockImplementation(() => undefined)
|
||||
|
||||
const fetchDataError = vi.fn(() => {
|
||||
throw new Error('fetchDataError')
|
||||
})
|
||||
|
||||
// -- Shared part --
|
||||
function ErrorComponent() {
|
||||
const result = useQuery({
|
||||
queryKey: errorKey,
|
||||
queryFn: () => fetchDataError(),
|
||||
retry: false,
|
||||
})
|
||||
return (
|
||||
<PrintStateComponent componentName="ErrorComponent" result={result} />
|
||||
)
|
||||
}
|
||||
|
||||
// -- Server part --
|
||||
setIsServer(true)
|
||||
const prefetchCache = new QueryCache()
|
||||
const prefetchClient = new QueryClient({
|
||||
queryCache: prefetchCache,
|
||||
})
|
||||
await prefetchClient
|
||||
.query({
|
||||
queryKey: errorKey,
|
||||
queryFn: () => fetchDataError(),
|
||||
})
|
||||
.catch(noop)
|
||||
const dehydratedStateServer = dehydrate(prefetchClient)
|
||||
const renderCache = new QueryCache()
|
||||
const renderClient = new QueryClient({
|
||||
queryCache: renderCache,
|
||||
})
|
||||
hydrate(renderClient, dehydratedStateServer)
|
||||
const markup = renderToString(
|
||||
<QueryClientProvider client={renderClient}>
|
||||
<ErrorComponent />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
const stringifiedState = JSON.stringify(dehydratedStateServer)
|
||||
renderClient.clear()
|
||||
setIsServer(false)
|
||||
|
||||
const expectedMarkup =
|
||||
'ErrorComponent - status:pending fetching:true data:undefined'
|
||||
|
||||
expect(markup).toBe(expectedMarkup)
|
||||
|
||||
// -- Client part --
|
||||
const el = document.createElement('div')
|
||||
el.innerHTML = markup
|
||||
|
||||
const queryCache = new QueryCache()
|
||||
const queryClient = new QueryClient({ queryCache })
|
||||
hydrate(queryClient, JSON.parse(stringifiedState))
|
||||
|
||||
const unmount = PreactHydrate(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ErrorComponent />
|
||||
</QueryClientProvider>,
|
||||
el,
|
||||
)
|
||||
|
||||
expect(consoleMock).toHaveBeenCalledTimes(0)
|
||||
expect(fetchDataError).toHaveBeenCalledTimes(2)
|
||||
expect(el.innerHTML).toBe(expectedMarkup)
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
expect(fetchDataError).toHaveBeenCalledTimes(2)
|
||||
expect(el.innerHTML).toBe(
|
||||
'ErrorComponent - status:error fetching:false data:undefined',
|
||||
)
|
||||
|
||||
unmount()
|
||||
queryClient.clear()
|
||||
consoleMock.mockRestore()
|
||||
})
|
||||
|
||||
it('should not mismatch on queries that were not prefetched', async () => {
|
||||
const consoleMock = vi.spyOn(console, 'error')
|
||||
consoleMock.mockImplementation(() => undefined)
|
||||
|
||||
const fetchDataSuccess = vi.fn<typeof fetchData>(fetchData)
|
||||
|
||||
// -- Shared part --
|
||||
function SuccessComponent() {
|
||||
const result = useQuery({
|
||||
queryKey: successKey,
|
||||
queryFn: () => fetchDataSuccess('success!'),
|
||||
})
|
||||
return (
|
||||
<PrintStateComponent componentName="SuccessComponent" result={result} />
|
||||
)
|
||||
}
|
||||
|
||||
// -- Server part --
|
||||
setIsServer(true)
|
||||
|
||||
const prefetchClient = new QueryClient()
|
||||
const dehydratedStateServer = dehydrate(prefetchClient)
|
||||
const renderClient = new QueryClient()
|
||||
hydrate(renderClient, dehydratedStateServer)
|
||||
const markup = renderToString(
|
||||
<QueryClientProvider client={renderClient}>
|
||||
<SuccessComponent />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
const stringifiedState = JSON.stringify(dehydratedStateServer)
|
||||
renderClient.clear()
|
||||
setIsServer(false)
|
||||
|
||||
const expectedMarkup =
|
||||
'SuccessComponent - status:pending fetching:true data:undefined'
|
||||
|
||||
expect(markup).toBe(expectedMarkup)
|
||||
|
||||
// -- Client part --
|
||||
const el = document.createElement('div')
|
||||
el.innerHTML = markup
|
||||
|
||||
const queryCache = new QueryCache()
|
||||
const queryClient = new QueryClient({ queryCache })
|
||||
hydrate(queryClient, JSON.parse(stringifiedState))
|
||||
|
||||
const unmount = PreactHydrate(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SuccessComponent />
|
||||
</QueryClientProvider>,
|
||||
el,
|
||||
)
|
||||
|
||||
// Check that we have no React hydration mismatches
|
||||
expect(consoleMock).toHaveBeenCalledTimes(0)
|
||||
expect(fetchDataSuccess).toHaveBeenCalledTimes(1)
|
||||
expect(el.innerHTML).toBe(expectedMarkup)
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
expect(fetchDataSuccess).toHaveBeenCalledTimes(1)
|
||||
expect(el.innerHTML).toBe(
|
||||
'SuccessComponent - status:success fetching:false data:success!',
|
||||
)
|
||||
|
||||
unmount()
|
||||
queryClient.clear()
|
||||
consoleMock.mockRestore()
|
||||
})
|
||||
})
|
||||
188
packages/preact-query/src/__tests__/ssr.test.tsx
Normal file
188
packages/preact-query/src/__tests__/ssr.test.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import { queryKey, sleep } from '@tanstack/query-test-utils'
|
||||
import { renderToString } from 'preact-render-to-string'
|
||||
import { useState } from 'preact/hooks'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
QueryCache,
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
noop,
|
||||
useInfiniteQuery,
|
||||
useMutationState,
|
||||
useQuery,
|
||||
} from '..'
|
||||
import { setIsServer } from './utils'
|
||||
|
||||
describe('Server Side Rendering', () => {
|
||||
setIsServer(true)
|
||||
|
||||
let queryCache: QueryCache
|
||||
let queryClient: QueryClient
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
queryCache = new QueryCache()
|
||||
queryClient = new QueryClient({ queryCache })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
queryClient.clear()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should not trigger fetch', () => {
|
||||
const key = queryKey()
|
||||
const queryFn = vi.fn(() => sleep(10).then(() => 'data'))
|
||||
|
||||
function Page() {
|
||||
const query = useQuery({ queryKey: key, queryFn })
|
||||
|
||||
const content = `status ${query.status}`
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>{content}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const markup = renderToString(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Page />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
expect(markup).toContain('status pending')
|
||||
expect(queryFn).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
it('should add prefetched data to cache', async () => {
|
||||
const key = queryKey()
|
||||
|
||||
const promise = queryClient.query({
|
||||
queryKey: key,
|
||||
queryFn: () => sleep(10).then(() => 'data'),
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
|
||||
const data = await promise
|
||||
|
||||
expect(data).toBe('data')
|
||||
expect(queryCache.find({ queryKey: key })?.state.data).toBe('data')
|
||||
})
|
||||
|
||||
it('should return existing data from the cache', async () => {
|
||||
const key = queryKey()
|
||||
const queryFn = vi.fn(() => sleep(10).then(() => 'data'))
|
||||
|
||||
function Page() {
|
||||
const query = useQuery({ queryKey: key, queryFn })
|
||||
|
||||
const content = `status ${query.status}`
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>{content}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
void queryClient.query({ queryKey: key, queryFn }).catch(noop)
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
|
||||
const markup = renderToString(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Page />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
expect(markup).toContain('status success')
|
||||
expect(queryFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should add initialData to the cache', () => {
|
||||
const key = queryKey()
|
||||
|
||||
function Page() {
|
||||
const [page, setPage] = useState(1)
|
||||
const { data } = useQuery({
|
||||
queryKey: [key, page],
|
||||
queryFn: () => sleep(10).then(() => page),
|
||||
initialData: 1,
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 data-testid="title">{data}</h1>
|
||||
<button onClick={() => setPage(page + 1)}>next</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
renderToString(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Page />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
const keys = queryCache.getAll().map((query) => query.queryKey)
|
||||
|
||||
expect(keys).toEqual([[key, 1]])
|
||||
})
|
||||
|
||||
it('useMutationState should return empty array', () => {
|
||||
function Page() {
|
||||
const mutationState = useMutationState()
|
||||
|
||||
return <div>{`mutationState: ${mutationState.length}`}</div>
|
||||
}
|
||||
|
||||
const markup = renderToString(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Page />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
expect(markup).toContain('mutationState: 0')
|
||||
})
|
||||
|
||||
it('useInfiniteQuery should return the correct state', async () => {
|
||||
const key = queryKey()
|
||||
const queryFn = vi.fn(() => sleep(10).then(() => 'page 1'))
|
||||
|
||||
function Page() {
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: key,
|
||||
queryFn,
|
||||
getNextPageParam: () => undefined,
|
||||
initialPageParam: 0,
|
||||
})
|
||||
return (
|
||||
<ul>
|
||||
{query.data?.pages.map((page) => (
|
||||
<li key={page}>{page}</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
void queryClient
|
||||
.infiniteQuery({
|
||||
queryKey: key,
|
||||
queryFn,
|
||||
initialPageParam: 0,
|
||||
})
|
||||
.catch(noop)
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
|
||||
const markup = renderToString(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Page />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
expect(markup).toContain('page 1')
|
||||
expect(queryFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
240
packages/preact-query/src/__tests__/suspense.test.tsx
Normal file
240
packages/preact-query/src/__tests__/suspense.test.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
import { queryKey, sleep } from '@tanstack/query-test-utils'
|
||||
import { render } from '@testing-library/preact'
|
||||
import type { ComponentChildren } from 'preact'
|
||||
import { Suspense } from 'preact/compat'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { QueryClient, QueryClientProvider, useSuspenseQuery } from '..'
|
||||
import type { StaleTime } from '@tanstack/query-core'
|
||||
import type { QueryKey } from '..'
|
||||
|
||||
function renderWithSuspense(client: QueryClient, ui: ComponentChildren) {
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<Suspense fallback="loading">{ui}</Suspense>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
function createTestQuery(options: {
|
||||
fetchCount: { count: number }
|
||||
queryKey: QueryKey
|
||||
staleTime?: StaleTime | (() => StaleTime)
|
||||
}) {
|
||||
return function TestComponent() {
|
||||
const { data } = useSuspenseQuery({
|
||||
queryKey: options.queryKey,
|
||||
queryFn: () =>
|
||||
sleep(10).then(() => {
|
||||
options.fetchCount.count++
|
||||
return 'data'
|
||||
}),
|
||||
staleTime: options.staleTime,
|
||||
})
|
||||
return <div>data: {data}</div>
|
||||
}
|
||||
}
|
||||
|
||||
describe('Suspense Timer Tests', () => {
|
||||
let queryClient: QueryClient
|
||||
let fetchCount: { count: number }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
fetchCount = { count: 0 }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
queryClient.clear()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should enforce minimum staleTime of 1000ms when using suspense with number', async () => {
|
||||
const TestComponent = createTestQuery({
|
||||
fetchCount,
|
||||
queryKey: queryKey(),
|
||||
staleTime: 10,
|
||||
})
|
||||
|
||||
const rendered = renderWithSuspense(queryClient, <TestComponent />)
|
||||
|
||||
expect(rendered.getByText('loading')).toBeInTheDocument()
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
expect(rendered.getByText('data: data')).toBeInTheDocument()
|
||||
|
||||
rendered.rerender(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Suspense fallback="loading">
|
||||
<TestComponent />
|
||||
</Suspense>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
|
||||
expect(fetchCount.count).toBe(1)
|
||||
})
|
||||
|
||||
it('should enforce minimum staleTime of 1000ms when using suspense with function', async () => {
|
||||
const TestComponent = createTestQuery({
|
||||
fetchCount,
|
||||
queryKey: queryKey(),
|
||||
staleTime: () => 10,
|
||||
})
|
||||
|
||||
const rendered = renderWithSuspense(queryClient, <TestComponent />)
|
||||
|
||||
expect(rendered.getByText('loading')).toBeInTheDocument()
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
expect(rendered.getByText('data: data')).toBeInTheDocument()
|
||||
|
||||
rendered.rerender(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Suspense fallback="loading">
|
||||
<TestComponent />
|
||||
</Suspense>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
|
||||
expect(fetchCount.count).toBe(1)
|
||||
})
|
||||
|
||||
it('should respect staleTime when value is greater than 1000ms', async () => {
|
||||
const TestComponent = createTestQuery({
|
||||
fetchCount,
|
||||
queryKey: queryKey(),
|
||||
staleTime: 2000,
|
||||
})
|
||||
|
||||
const rendered = renderWithSuspense(queryClient, <TestComponent />)
|
||||
|
||||
expect(rendered.getByText('loading')).toBeInTheDocument()
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
expect(rendered.getByText('data: data')).toBeInTheDocument()
|
||||
|
||||
rendered.rerender(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Suspense fallback="loading">
|
||||
<TestComponent />
|
||||
</Suspense>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1500)
|
||||
|
||||
expect(fetchCount.count).toBe(1)
|
||||
})
|
||||
|
||||
it('should enforce minimum staleTime when undefined is provided', async () => {
|
||||
const TestComponent = createTestQuery({
|
||||
fetchCount,
|
||||
queryKey: queryKey(),
|
||||
staleTime: undefined,
|
||||
})
|
||||
|
||||
const rendered = renderWithSuspense(queryClient, <TestComponent />)
|
||||
|
||||
expect(rendered.getByText('loading')).toBeInTheDocument()
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
expect(rendered.getByText('data: data')).toBeInTheDocument()
|
||||
|
||||
rendered.rerender(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Suspense fallback="loading">
|
||||
<TestComponent />
|
||||
</Suspense>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
|
||||
expect(fetchCount.count).toBe(1)
|
||||
})
|
||||
|
||||
it('should preserve staleTime when value is static', async () => {
|
||||
const TestComponent = createTestQuery({
|
||||
fetchCount,
|
||||
queryKey: queryKey(),
|
||||
staleTime: 'static',
|
||||
})
|
||||
|
||||
const rendered = renderWithSuspense(queryClient, <TestComponent />)
|
||||
|
||||
expect(rendered.getByText('loading')).toBeInTheDocument()
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
expect(rendered.getByText('data: data')).toBeInTheDocument()
|
||||
|
||||
rendered.rerender(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Suspense fallback="loading">
|
||||
<TestComponent />
|
||||
</Suspense>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
|
||||
expect(fetchCount.count).toBe(1)
|
||||
})
|
||||
|
||||
it('should preserve staleTime when function returns static', async () => {
|
||||
const TestComponent = createTestQuery({
|
||||
fetchCount,
|
||||
queryKey: queryKey(),
|
||||
staleTime: () => 'static',
|
||||
})
|
||||
|
||||
const rendered = renderWithSuspense(queryClient, <TestComponent />)
|
||||
|
||||
expect(rendered.getByText('loading')).toBeInTheDocument()
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
expect(rendered.getByText('data: data')).toBeInTheDocument()
|
||||
|
||||
rendered.rerender(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Suspense fallback="loading">
|
||||
<TestComponent />
|
||||
</Suspense>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
|
||||
expect(fetchCount.count).toBe(1)
|
||||
})
|
||||
|
||||
it('should respect staleTime when function returns value greater than 1000ms', async () => {
|
||||
const TestComponent = createTestQuery({
|
||||
fetchCount,
|
||||
queryKey: queryKey(),
|
||||
staleTime: () => 3000,
|
||||
})
|
||||
|
||||
const rendered = renderWithSuspense(queryClient, <TestComponent />)
|
||||
|
||||
expect(rendered.getByText('loading')).toBeInTheDocument()
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
expect(rendered.getByText('data: data')).toBeInTheDocument()
|
||||
|
||||
rendered.rerender(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Suspense fallback="loading">
|
||||
<TestComponent />
|
||||
</Suspense>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
|
||||
expect(fetchCount.count).toBe(1)
|
||||
})
|
||||
})
|
||||
177
packages/preact-query/src/__tests__/useInfiniteQuery.test-d.tsx
Normal file
177
packages/preact-query/src/__tests__/useInfiniteQuery.test-d.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { QueryClient } from '@tanstack/query-core'
|
||||
import type { InfiniteData } from '@tanstack/query-core'
|
||||
import { queryKey } from '@tanstack/query-test-utils'
|
||||
import { describe, expectTypeOf, it } from 'vitest'
|
||||
|
||||
import type { UseInfiniteQueryOptions } from '../types'
|
||||
import { useInfiniteQuery } from '../useInfiniteQuery'
|
||||
|
||||
describe('pageParam', () => {
|
||||
it('initialPageParam should define type of param passed to queryFunctionContext', () => {
|
||||
useInfiniteQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: ({ pageParam }) => {
|
||||
expectTypeOf(pageParam).toEqualTypeOf<number>()
|
||||
},
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: () => undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('direction should be passed to queryFn of useInfiniteQuery', () => {
|
||||
useInfiniteQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: ({ direction }) => {
|
||||
expectTypeOf(direction).toEqualTypeOf<'forward' | 'backward'>()
|
||||
},
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: () => undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('initialPageParam should define type of param passed to queryFunctionContext for fetchInfiniteQuery', () => {
|
||||
const queryClient = new QueryClient()
|
||||
// grandfathered direct test
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
queryClient.fetchInfiniteQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: ({ pageParam }) => {
|
||||
expectTypeOf(pageParam).toEqualTypeOf<number>()
|
||||
},
|
||||
initialPageParam: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('initialPageParam should define type of param passed to queryFunctionContext for infiniteQuery', () => {
|
||||
const queryClient = new QueryClient()
|
||||
queryClient.infiniteQuery({
|
||||
queryKey: ['key'],
|
||||
queryFn: ({ pageParam }) => {
|
||||
expectTypeOf(pageParam).toEqualTypeOf<number>()
|
||||
return Promise.resolve(pageParam)
|
||||
},
|
||||
initialPageParam: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('initialPageParam should define type of param passed to queryFunctionContext for prefetchInfiniteQuery', () => {
|
||||
const queryClient = new QueryClient()
|
||||
// grandfathered direct test
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
queryClient.prefetchInfiniteQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: ({ pageParam }) => {
|
||||
expectTypeOf(pageParam).toEqualTypeOf<number>()
|
||||
},
|
||||
initialPageParam: 1,
|
||||
})
|
||||
})
|
||||
})
|
||||
describe('select', () => {
|
||||
it('should still return paginated data if no select result', () => {
|
||||
const infiniteQuery = useInfiniteQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: ({ pageParam }) => {
|
||||
return pageParam * 5
|
||||
},
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: () => undefined,
|
||||
})
|
||||
|
||||
// TODO: Order of generics prevents pageParams to be typed correctly. Using `unknown` for now
|
||||
expectTypeOf(infiniteQuery.data).toEqualTypeOf<
|
||||
InfiniteData<number, unknown> | undefined
|
||||
>()
|
||||
})
|
||||
|
||||
it('should be able to transform data to arbitrary result', () => {
|
||||
const infiniteQuery = useInfiniteQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: ({ pageParam }) => {
|
||||
return pageParam * 5
|
||||
},
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: () => undefined,
|
||||
select: (data) => {
|
||||
expectTypeOf(data).toEqualTypeOf<InfiniteData<number, number>>()
|
||||
return 'selected' as const
|
||||
},
|
||||
})
|
||||
|
||||
expectTypeOf(infiniteQuery.data).toEqualTypeOf<'selected' | undefined>()
|
||||
})
|
||||
})
|
||||
describe('getNextPageParam / getPreviousPageParam', () => {
|
||||
it('should get typed params', () => {
|
||||
const infiniteQuery = useInfiniteQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: ({ pageParam }) => {
|
||||
return String(pageParam)
|
||||
},
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => {
|
||||
expectTypeOf(lastPage).toEqualTypeOf<string>()
|
||||
expectTypeOf(allPages).toEqualTypeOf<Array<string>>()
|
||||
expectTypeOf(lastPageParam).toEqualTypeOf<number>()
|
||||
expectTypeOf(allPageParams).toEqualTypeOf<Array<number>>()
|
||||
return undefined
|
||||
},
|
||||
getPreviousPageParam: (
|
||||
firstPage,
|
||||
allPages,
|
||||
firstPageParam,
|
||||
allPageParams,
|
||||
) => {
|
||||
expectTypeOf(firstPage).toEqualTypeOf<string>()
|
||||
expectTypeOf(allPages).toEqualTypeOf<Array<string>>()
|
||||
expectTypeOf(firstPageParam).toEqualTypeOf<number>()
|
||||
expectTypeOf(allPageParams).toEqualTypeOf<Array<number>>()
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
|
||||
// TODO: Order of generics prevents pageParams to be typed correctly. Using `unknown` for now
|
||||
expectTypeOf(infiniteQuery.data).toEqualTypeOf<
|
||||
InfiniteData<string, unknown> | undefined
|
||||
>()
|
||||
})
|
||||
})
|
||||
|
||||
describe('error booleans', () => {
|
||||
it('should not be permanently `false`', () => {
|
||||
const {
|
||||
isFetchNextPageError,
|
||||
isFetchPreviousPageError,
|
||||
isLoadingError,
|
||||
isRefetchError,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: ({ pageParam }) => {
|
||||
return pageParam * 5
|
||||
},
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: () => undefined,
|
||||
})
|
||||
|
||||
expectTypeOf(isFetchNextPageError).toEqualTypeOf<boolean>()
|
||||
expectTypeOf(isFetchPreviousPageError).toEqualTypeOf<boolean>()
|
||||
expectTypeOf(isLoadingError).toEqualTypeOf<boolean>()
|
||||
expectTypeOf(isRefetchError).toEqualTypeOf<boolean>()
|
||||
})
|
||||
})
|
||||
|
||||
describe('UseInfiniteQueryOptions', () => {
|
||||
it('should default TData to InfiniteData<TQueryFnData>', () => {
|
||||
const options: UseInfiniteQueryOptions<number, Error> = {
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => 5,
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: () => undefined,
|
||||
}
|
||||
const infiniteQuery = useInfiniteQuery(options)
|
||||
|
||||
expectTypeOf(infiniteQuery.data).toEqualTypeOf<
|
||||
InfiniteData<number, unknown> | undefined
|
||||
>()
|
||||
})
|
||||
})
|
||||
1772
packages/preact-query/src/__tests__/useInfiniteQuery.test.tsx
Normal file
1772
packages/preact-query/src/__tests__/useInfiniteQuery.test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
247
packages/preact-query/src/__tests__/useIsFetching.test.tsx
Normal file
247
packages/preact-query/src/__tests__/useIsFetching.test.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
import { queryKey, sleep } from '@tanstack/query-test-utils'
|
||||
import { fireEvent, render } from '@testing-library/preact'
|
||||
import { useEffect, useState } from 'preact/hooks'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { QueryCache, QueryClient, useIsFetching, useQuery } from '..'
|
||||
import { renderWithClient, setActTimeout } from './utils'
|
||||
|
||||
describe('useIsFetching', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
// See https://github.com/tannerlinsley/react-query/issues/105
|
||||
it('should update as queries start and stop fetching', async () => {
|
||||
const queryClient = new QueryClient()
|
||||
const key = queryKey()
|
||||
|
||||
function IsFetching() {
|
||||
const isFetching = useIsFetching()
|
||||
|
||||
return <div>isFetching: {isFetching}</div>
|
||||
}
|
||||
|
||||
function Query() {
|
||||
const [ready, setReady] = useState(false)
|
||||
|
||||
useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () => sleep(50).then(() => 'test'),
|
||||
enabled: ready,
|
||||
})
|
||||
|
||||
return <button onClick={() => setReady(true)}>setReady</button>
|
||||
}
|
||||
|
||||
function Page() {
|
||||
return (
|
||||
<div>
|
||||
<IsFetching />
|
||||
<Query />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(queryClient, <Page />)
|
||||
|
||||
expect(rendered.getByText('isFetching: 0')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(rendered.getByRole('button', { name: /setReady/i }))
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(rendered.getByText('isFetching: 1')).toBeInTheDocument()
|
||||
await vi.advanceTimersByTimeAsync(51)
|
||||
expect(rendered.getByText('isFetching: 0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not update state while rendering', async () => {
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
const key1 = queryKey()
|
||||
const key2 = queryKey()
|
||||
|
||||
const isFetchingArray: Array<number> = []
|
||||
|
||||
function IsFetching() {
|
||||
const isFetching = useIsFetching()
|
||||
|
||||
isFetchingArray.push(isFetching)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function FirstQuery() {
|
||||
useQuery({
|
||||
queryKey: key1,
|
||||
queryFn: () => sleep(100).then(() => 'data1'),
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function SecondQuery() {
|
||||
useQuery({
|
||||
queryKey: key2,
|
||||
queryFn: () => sleep(100).then(() => 'data2'),
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function Page() {
|
||||
const [renderSecond, setRenderSecond] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setActTimeout(() => {
|
||||
setRenderSecond(true)
|
||||
}, 50)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<IsFetching />
|
||||
<FirstQuery />
|
||||
{renderSecond && <SecondQuery />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
renderWithClient(queryClient, <Page />)
|
||||
|
||||
expect(isFetchingArray[0]).toEqual(0)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(isFetchingArray[1]).toEqual(1)
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
expect(isFetchingArray[2]).toEqual(1)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(isFetchingArray[3]).toEqual(2)
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
expect(isFetchingArray[4]).toEqual(1)
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
expect(isFetchingArray[5]).toEqual(0)
|
||||
|
||||
expect(isFetchingArray).toEqual([0, 1, 1, 2, 1, 0])
|
||||
})
|
||||
|
||||
it('should be able to filter', async () => {
|
||||
const queryClient = new QueryClient()
|
||||
const key1 = queryKey()
|
||||
const key2 = queryKey()
|
||||
|
||||
const isFetchingArray: Array<number> = []
|
||||
|
||||
function One() {
|
||||
useQuery({
|
||||
queryKey: key1,
|
||||
queryFn: () => sleep(10).then(() => 'test1'),
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function Two() {
|
||||
useQuery({
|
||||
queryKey: key2,
|
||||
queryFn: () => sleep(20).then(() => 'test2'),
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function Page() {
|
||||
const [started, setStarted] = useState(false)
|
||||
const isFetching = useIsFetching({ queryKey: key1 })
|
||||
|
||||
isFetchingArray.push(isFetching)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => setStarted(true)}>setStarted</button>
|
||||
<div>isFetching: {isFetching}</div>
|
||||
{started ? (
|
||||
<>
|
||||
<One />
|
||||
<Two />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(queryClient, <Page />)
|
||||
|
||||
expect(rendered.getByText('isFetching: 0')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(rendered.getByRole('button', { name: /setStarted/i }))
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(rendered.getByText('isFetching: 1')).toBeInTheDocument()
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('isFetching: 0')).toBeInTheDocument()
|
||||
|
||||
// at no point should we have isFetching: 2
|
||||
expect(isFetchingArray).toEqual(expect.not.arrayContaining([2]))
|
||||
})
|
||||
|
||||
it('should show the correct fetching state when mounted after a query', async () => {
|
||||
const queryClient = new QueryClient()
|
||||
const key = queryKey()
|
||||
|
||||
function Page() {
|
||||
useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () => sleep(10).then(() => 'test'),
|
||||
})
|
||||
|
||||
const isFetching = useIsFetching()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>isFetching: {isFetching}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(queryClient, <Page />)
|
||||
|
||||
expect(rendered.getByText('isFetching: 1')).toBeInTheDocument()
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('isFetching: 0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use provided custom queryClient', async () => {
|
||||
const onSuccess = vi.fn()
|
||||
|
||||
const queryCache = new QueryCache({ onSuccess })
|
||||
const queryClient = new QueryClient({ queryCache })
|
||||
const key = queryKey()
|
||||
|
||||
function Page() {
|
||||
useQuery(
|
||||
{
|
||||
queryKey: key,
|
||||
queryFn: () => sleep(10).then(() => 'test'),
|
||||
},
|
||||
queryClient,
|
||||
)
|
||||
|
||||
const isFetching = useIsFetching({}, queryClient)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>isFetching: {isFetching}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = render(<Page />)
|
||||
|
||||
expect(rendered.getByText('isFetching: 1')).toBeInTheDocument()
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('isFetching: 0')).toBeInTheDocument()
|
||||
expect(onSuccess).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
203
packages/preact-query/src/__tests__/useMutation.test-d.tsx
Normal file
203
packages/preact-query/src/__tests__/useMutation.test-d.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import { describe, expectTypeOf, it } from 'vitest'
|
||||
import { QueryClient } from '@tanstack/query-core'
|
||||
import { useMutation } from '../useMutation'
|
||||
import type {
|
||||
DefaultError,
|
||||
MutationFunctionContext,
|
||||
MutationKey,
|
||||
} from '@tanstack/query-core'
|
||||
import type { UseMutationResult } from '../types'
|
||||
|
||||
describe('useMutation', () => {
|
||||
it('should infer TData from mutationFn return type', () => {
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => Promise.resolve('data'),
|
||||
})
|
||||
|
||||
expectTypeOf(mutation.data).toEqualTypeOf<string | undefined>()
|
||||
expectTypeOf(mutation.error).toEqualTypeOf<DefaultError | null>()
|
||||
})
|
||||
|
||||
it('should infer TVariables from mutationFn parameter', () => {
|
||||
const mutation = useMutation({
|
||||
mutationFn: (vars: { id: string }) => Promise.resolve(vars.id),
|
||||
})
|
||||
|
||||
expectTypeOf(mutation.mutate).toBeCallableWith({ id: '1' })
|
||||
expectTypeOf(mutation.data).toEqualTypeOf<string | undefined>()
|
||||
})
|
||||
|
||||
it('should infer TOnMutateResult from onMutate return type', () => {
|
||||
useMutation({
|
||||
mutationFn: () => Promise.resolve('data'),
|
||||
onMutate: () => {
|
||||
return { token: 'abc' }
|
||||
},
|
||||
onSuccess: (_data, _variables, onMutateResult) => {
|
||||
expectTypeOf(onMutateResult).toEqualTypeOf<{ token: string }>()
|
||||
},
|
||||
onError: (_error, _variables, onMutateResult) => {
|
||||
expectTypeOf(onMutateResult).toEqualTypeOf<
|
||||
{ token: string } | undefined
|
||||
>()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should allow explicit generic types', () => {
|
||||
const mutation = useMutation<string, Error, { id: number }>({
|
||||
mutationFn: (vars) => {
|
||||
expectTypeOf(vars).toEqualTypeOf<{ id: number }>()
|
||||
return Promise.resolve('result')
|
||||
},
|
||||
})
|
||||
|
||||
expectTypeOf(mutation.data).toEqualTypeOf<string | undefined>()
|
||||
expectTypeOf(mutation.error).toEqualTypeOf<Error | null>()
|
||||
})
|
||||
|
||||
it('should return correct UseMutationResult type', () => {
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => Promise.resolve(42),
|
||||
})
|
||||
|
||||
expectTypeOf(mutation).toEqualTypeOf<
|
||||
UseMutationResult<number, DefaultError, void, unknown>
|
||||
>()
|
||||
})
|
||||
|
||||
it('should type mutateAsync with correct return type', () => {
|
||||
const mutation = useMutation({
|
||||
mutationFn: (id: string) => Promise.resolve(id.length),
|
||||
})
|
||||
|
||||
expectTypeOf(mutation.mutateAsync).toBeCallableWith('test')
|
||||
expectTypeOf(mutation.mutateAsync('test')).toEqualTypeOf<Promise<number>>()
|
||||
})
|
||||
|
||||
it('should default TVariables to void when mutationFn has no parameters', () => {
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => Promise.resolve('data'),
|
||||
})
|
||||
|
||||
expectTypeOf(mutation.mutate).toBeCallableWith()
|
||||
})
|
||||
|
||||
it('should allow calling mutate with no arguments when TVariables is optional undefinable', () => {
|
||||
const mutation = useMutation({
|
||||
mutationFn: (_variables: number | undefined) => Promise.resolve(1),
|
||||
})
|
||||
|
||||
expectTypeOf(mutation.mutate).toBeCallableWith()
|
||||
expectTypeOf(mutation.mutateAsync).toBeCallableWith()
|
||||
})
|
||||
|
||||
it('should infer custom TError type', () => {
|
||||
class CustomError extends Error {
|
||||
code: number
|
||||
constructor(code: number) {
|
||||
super()
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
const mutation = useMutation<string, CustomError>({
|
||||
mutationFn: () => Promise.resolve('data'),
|
||||
})
|
||||
|
||||
expectTypeOf(mutation.error).toEqualTypeOf<CustomError | null>()
|
||||
expectTypeOf(mutation.data).toEqualTypeOf<string | undefined>()
|
||||
})
|
||||
|
||||
it('should infer types for onSettled callback', () => {
|
||||
useMutation({
|
||||
mutationFn: () => Promise.resolve(42),
|
||||
onSettled: (data, error, _variables, _onMutateResult) => {
|
||||
expectTypeOf(data).toEqualTypeOf<number | undefined>()
|
||||
expectTypeOf(error).toEqualTypeOf<DefaultError | null>()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should infer custom TError in onError callback', () => {
|
||||
class CustomError extends Error {
|
||||
code: number
|
||||
constructor(code: number) {
|
||||
super()
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
useMutation<string, CustomError>({
|
||||
mutationFn: () => Promise.resolve('data'),
|
||||
onError: (error) => {
|
||||
expectTypeOf(error).toEqualTypeOf<CustomError>()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should accept queryClient as second argument', () => {
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
const mutation = useMutation(
|
||||
{
|
||||
mutationFn: () => Promise.resolve('data'),
|
||||
},
|
||||
queryClient,
|
||||
)
|
||||
|
||||
expectTypeOf(mutation.data).toEqualTypeOf<string | undefined>()
|
||||
})
|
||||
|
||||
it('should type context as the last argument for mutationFn and every hook-level callback', () => {
|
||||
useMutation({
|
||||
mutationFn: (_variables, context) => {
|
||||
expectTypeOf(context).toEqualTypeOf<MutationFunctionContext>()
|
||||
expectTypeOf(context.client).toEqualTypeOf<QueryClient>()
|
||||
return Promise.resolve('data')
|
||||
},
|
||||
onMutate: (_variables, context) => {
|
||||
expectTypeOf(context).toEqualTypeOf<MutationFunctionContext>()
|
||||
},
|
||||
onSuccess: (_data, _variables, _onMutateResult, context) => {
|
||||
expectTypeOf(context).toEqualTypeOf<MutationFunctionContext>()
|
||||
},
|
||||
onError: (_error, _variables, _onMutateResult, context) => {
|
||||
expectTypeOf(context).toEqualTypeOf<MutationFunctionContext>()
|
||||
},
|
||||
onSettled: (_data, _error, _variables, _onMutateResult, context) => {
|
||||
expectTypeOf(context).toEqualTypeOf<MutationFunctionContext>()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should type context as the last argument for every per-call mutate option', () => {
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => Promise.resolve('data'),
|
||||
})
|
||||
|
||||
mutation.mutate(undefined, {
|
||||
onSuccess: (_data, _variables, _onMutateResult, context) => {
|
||||
expectTypeOf(context).toEqualTypeOf<MutationFunctionContext>()
|
||||
},
|
||||
onError: (_error, _variables, _onMutateResult, context) => {
|
||||
expectTypeOf(context).toEqualTypeOf<MutationFunctionContext>()
|
||||
},
|
||||
onSettled: (_data, _error, _variables, _onMutateResult, context) => {
|
||||
expectTypeOf(context).toEqualTypeOf<MutationFunctionContext>()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should type context.mutationKey as MutationKey', () => {
|
||||
useMutation({
|
||||
mutationKey: ['todos', 'add'] as const,
|
||||
mutationFn: () => Promise.resolve('data'),
|
||||
onSuccess: (_data, _variables, _onMutateResult, context) => {
|
||||
expectTypeOf(context.mutationKey).toEqualTypeOf<
|
||||
MutationKey | undefined
|
||||
>()
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
2580
packages/preact-query/src/__tests__/useMutation.test.tsx
Normal file
2580
packages/preact-query/src/__tests__/useMutation.test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
import { describe, expectTypeOf, it } from 'vitest'
|
||||
|
||||
import { useMutationState } from '../useMutationState'
|
||||
import type {
|
||||
Mutation,
|
||||
MutationState,
|
||||
MutationStatus,
|
||||
} from '@tanstack/query-core'
|
||||
|
||||
describe('useMutationState', () => {
|
||||
it('should default to QueryState', () => {
|
||||
const result = useMutationState({
|
||||
filters: { status: 'pending' },
|
||||
})
|
||||
|
||||
expectTypeOf(result).toEqualTypeOf<
|
||||
Array<MutationState<unknown, Error, unknown, unknown>>
|
||||
>()
|
||||
})
|
||||
it('should infer with select', () => {
|
||||
const result = useMutationState({
|
||||
filters: { status: 'pending' },
|
||||
select: (mutation) => mutation.state.status,
|
||||
})
|
||||
|
||||
expectTypeOf(result).toEqualTypeOf<Array<MutationStatus>>()
|
||||
})
|
||||
it('should propagate generics to select callback when TResult is typed MutationState', () => {
|
||||
type MyData = { data: Array<string> }
|
||||
type MyError = { code: number; message: string }
|
||||
type MyVars = { id: number }
|
||||
|
||||
useMutationState<MutationState<MyData, MyError, MyVars>>({
|
||||
filters: { mutationKey: ['key'] },
|
||||
select: (mutation) => {
|
||||
expectTypeOf(mutation).toEqualTypeOf<
|
||||
Mutation<MyData, MyError, MyVars, unknown>
|
||||
>()
|
||||
return mutation.state
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
246
packages/preact-query/src/__tests__/useMutationState.test.tsx
Normal file
246
packages/preact-query/src/__tests__/useMutationState.test.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import { queryKey, sleep } from '@tanstack/query-test-utils'
|
||||
import { fireEvent, render } from '@testing-library/preact'
|
||||
import { useEffect } from 'preact/hooks'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { QueryClient, useIsMutating, useMutation, useMutationState } from '..'
|
||||
import { renderWithClient } from './utils'
|
||||
|
||||
describe('useIsMutating', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should return the number of fetching mutations', async () => {
|
||||
const isMutatingArray: Array<number> = []
|
||||
const queryClient = new QueryClient()
|
||||
const mutationKey1 = queryKey()
|
||||
const mutationKey2 = queryKey()
|
||||
|
||||
function IsMutating() {
|
||||
const isMutating = useIsMutating()
|
||||
|
||||
isMutatingArray.push(isMutating)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function Mutations() {
|
||||
const { mutate: mutate1 } = useMutation({
|
||||
mutationKey: mutationKey1,
|
||||
mutationFn: () => sleep(50).then(() => 'data'),
|
||||
})
|
||||
const { mutate: mutate2 } = useMutation({
|
||||
mutationKey: mutationKey2,
|
||||
mutationFn: () => sleep(10).then(() => 'data'),
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => mutate1()}>mutate1</button>
|
||||
<button onClick={() => mutate2()}>mutate2</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Page() {
|
||||
return (
|
||||
<div>
|
||||
<IsMutating />
|
||||
<Mutations />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(queryClient, <Page />)
|
||||
|
||||
fireEvent.click(rendered.getByRole('button', { name: /mutate1/i }))
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
fireEvent.click(rendered.getByRole('button', { name: /mutate2/i }))
|
||||
|
||||
// we don't really care if this yields
|
||||
// [ +0, 1, 2, +0 ]
|
||||
// or
|
||||
// [ +0, 1, 2, 1, +0 ]
|
||||
// our batching strategy might yield different results
|
||||
|
||||
await vi.advanceTimersByTimeAsync(41)
|
||||
expect(isMutatingArray[0]).toEqual(0)
|
||||
expect(isMutatingArray[1]).toEqual(1)
|
||||
expect(isMutatingArray[2]).toEqual(2)
|
||||
expect(isMutatingArray[3]).toEqual(1)
|
||||
expect(isMutatingArray[4]).toEqual(0)
|
||||
|
||||
expect(isMutatingArray).toEqual([0, 1, 2, 1, 0])
|
||||
})
|
||||
|
||||
it('should filter correctly by mutationKey', async () => {
|
||||
const isMutatingArray: Array<number> = []
|
||||
const queryClient = new QueryClient()
|
||||
const mutationKey1 = queryKey()
|
||||
const mutationKey2 = queryKey()
|
||||
|
||||
function IsMutating() {
|
||||
const isMutating = useIsMutating({ mutationKey: mutationKey1 })
|
||||
isMutatingArray.push(isMutating)
|
||||
return null
|
||||
}
|
||||
|
||||
function Page() {
|
||||
const { mutate: mutate1 } = useMutation({
|
||||
mutationKey: mutationKey1,
|
||||
mutationFn: () => sleep(100).then(() => 'data'),
|
||||
})
|
||||
const { mutate: mutate2 } = useMutation({
|
||||
mutationKey: mutationKey2,
|
||||
mutationFn: () => sleep(100).then(() => 'data'),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
mutate1()
|
||||
mutate2()
|
||||
}, [mutate1, mutate2])
|
||||
|
||||
return <IsMutating />
|
||||
}
|
||||
|
||||
renderWithClient(queryClient, <Page />)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(101)
|
||||
expect(isMutatingArray).toEqual([0, 1, 0])
|
||||
})
|
||||
|
||||
it('should filter correctly by predicate', async () => {
|
||||
const isMutatingArray: Array<number> = []
|
||||
const queryClient = new QueryClient()
|
||||
const mutationKey1 = queryKey()
|
||||
const mutationKey2 = queryKey()
|
||||
|
||||
function IsMutating() {
|
||||
const isMutating = useIsMutating({
|
||||
predicate: (mutation) =>
|
||||
mutation.options.mutationKey?.[0] === mutationKey1[0],
|
||||
})
|
||||
isMutatingArray.push(isMutating)
|
||||
return null
|
||||
}
|
||||
|
||||
function Page() {
|
||||
const { mutate: mutate1 } = useMutation({
|
||||
mutationKey: mutationKey1,
|
||||
mutationFn: () => sleep(100).then(() => 'data'),
|
||||
})
|
||||
const { mutate: mutate2 } = useMutation({
|
||||
mutationKey: mutationKey2,
|
||||
mutationFn: () => sleep(100).then(() => 'data'),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
mutate1()
|
||||
mutate2()
|
||||
}, [mutate1, mutate2])
|
||||
|
||||
return <IsMutating />
|
||||
}
|
||||
|
||||
renderWithClient(queryClient, <Page />)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(101)
|
||||
expect(isMutatingArray).toEqual([0, 1, 0])
|
||||
})
|
||||
|
||||
it('should use provided custom queryClient', async () => {
|
||||
const queryClient = new QueryClient()
|
||||
const key = queryKey()
|
||||
|
||||
function Page() {
|
||||
const isMutating = useIsMutating({}, queryClient)
|
||||
const { mutate } = useMutation(
|
||||
{
|
||||
mutationKey: key,
|
||||
mutationFn: () => sleep(10).then(() => 'data'),
|
||||
},
|
||||
queryClient,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
mutate()
|
||||
}, [mutate])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>mutating: {isMutating}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = render(<Page />)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(rendered.getByText('mutating: 1')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useMutationState', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should return variables after calling mutate', async () => {
|
||||
const queryClient = new QueryClient()
|
||||
const variables: Array<Array<unknown>> = []
|
||||
const mutationKey = queryKey()
|
||||
|
||||
function Variables() {
|
||||
variables.push(
|
||||
useMutationState({
|
||||
filters: { mutationKey, status: 'pending' },
|
||||
select: (mutation) => mutation.state.variables,
|
||||
}),
|
||||
)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function Mutate() {
|
||||
const { mutate, data } = useMutation({
|
||||
mutationKey,
|
||||
mutationFn: (input: number) => sleep(150).then(() => `data${input}`),
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
data: {data ?? 'null'}
|
||||
<button onClick={() => mutate(1)}>mutate</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Page() {
|
||||
return (
|
||||
<div>
|
||||
<Variables />
|
||||
<Mutate />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(queryClient, <Page />)
|
||||
|
||||
expect(rendered.getByText('data: null')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
|
||||
await vi.advanceTimersByTimeAsync(151)
|
||||
expect(rendered.getByText('data: data1')).toBeInTheDocument()
|
||||
|
||||
expect(variables).toEqual([[], [1], []])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
import { queryKey } from '@tanstack/query-test-utils'
|
||||
import { assertType, describe, expectTypeOf, it } from 'vitest'
|
||||
|
||||
import { skipToken, usePrefetchInfiniteQuery } from '..'
|
||||
|
||||
describe('usePrefetchInfiniteQuery', () => {
|
||||
it('should return nothing', () => {
|
||||
const result = usePrefetchInfiniteQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: () => 1,
|
||||
})
|
||||
|
||||
expectTypeOf(result).toEqualTypeOf<void>()
|
||||
})
|
||||
|
||||
it('should require initialPageParam and getNextPageParam', () => {
|
||||
assertType(
|
||||
// @ts-expect-error TS2345
|
||||
usePrefetchInfiniteQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should not allow refetchInterval, enabled or throwOnError options', () => {
|
||||
assertType(
|
||||
usePrefetchInfiniteQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: () => 1,
|
||||
// @ts-expect-error TS2353
|
||||
refetchInterval: 1000,
|
||||
}),
|
||||
)
|
||||
|
||||
assertType(
|
||||
usePrefetchInfiniteQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: () => 1,
|
||||
// @ts-expect-error TS2353
|
||||
enabled: true,
|
||||
}),
|
||||
)
|
||||
|
||||
assertType(
|
||||
usePrefetchInfiniteQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: () => 1,
|
||||
// @ts-expect-error TS2353
|
||||
throwOnError: true,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should not allow skipToken in queryFn', () => {
|
||||
assertType(
|
||||
usePrefetchInfiniteQuery({
|
||||
queryKey: queryKey(),
|
||||
// @ts-expect-error
|
||||
queryFn: skipToken,
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: () => 1,
|
||||
}),
|
||||
)
|
||||
assertType(
|
||||
usePrefetchInfiniteQuery({
|
||||
queryKey: queryKey(),
|
||||
// @ts-expect-error
|
||||
queryFn: Math.random() > 0.5 ? skipToken : () => Promise.resolve(5),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: () => 1,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,206 @@
|
||||
import { queryKey, sleep } from '@tanstack/query-test-utils'
|
||||
import { fireEvent } from '@testing-library/preact'
|
||||
import type { VNode } from 'preact'
|
||||
import { Suspense } from 'preact/compat'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
QueryCache,
|
||||
QueryClient,
|
||||
noop,
|
||||
usePrefetchInfiniteQuery,
|
||||
useSuspenseInfiniteQuery,
|
||||
} from '..'
|
||||
import { renderWithClient } from './utils'
|
||||
|
||||
describe('usePrefetchInfiniteQuery', () => {
|
||||
let queryCache: QueryCache
|
||||
let queryClient: QueryClient
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
queryCache = new QueryCache()
|
||||
queryClient = new QueryClient({ queryCache })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
queryClient.clear()
|
||||
Fallback.mockClear()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
const Fallback = vi.fn().mockImplementation(() => <div>Loading...</div>)
|
||||
|
||||
it('should prefetch an infinite query if query state does not exist', async () => {
|
||||
const data = [
|
||||
'Do you fetch on render?',
|
||||
'Or do you render as you fetch?',
|
||||
'Either way, Tanstack Query helps you!',
|
||||
]
|
||||
|
||||
const queryOpts = {
|
||||
queryKey: queryKey(),
|
||||
queryFn: vi
|
||||
.fn<(context: { pageParam: number }) => Promise<string>>()
|
||||
.mockImplementation(({ pageParam }) =>
|
||||
sleep(10).then(() => data[pageParam]!),
|
||||
),
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (_lastPage: string, allPages: Array<string>) =>
|
||||
allPages.length < data.length ? allPages.length : undefined,
|
||||
}
|
||||
|
||||
function Page() {
|
||||
const state = useSuspenseInfiniteQuery(queryOpts)
|
||||
|
||||
return (
|
||||
<div>
|
||||
{state.data.pages.map((page, index) => (
|
||||
<div key={index}>data: {page}</div>
|
||||
))}
|
||||
<button onClick={() => state.fetchNextPage()}>Next Page</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
usePrefetchInfiniteQuery({ ...queryOpts, pages: data.length })
|
||||
|
||||
return (
|
||||
<Suspense fallback={<Fallback />}>
|
||||
<Page />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(queryClient, <App />)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30)
|
||||
rendered.getByText('data: Do you fetch on render?')
|
||||
fireEvent.click(rendered.getByText('Next Page'))
|
||||
expect(
|
||||
rendered.getByText('data: Or do you render as you fetch?'),
|
||||
).toBeInTheDocument()
|
||||
fireEvent.click(rendered.getByText('Next Page'))
|
||||
expect(
|
||||
rendered.getByText('data: Either way, Tanstack Query helps you!'),
|
||||
).toBeInTheDocument()
|
||||
expect(Fallback).toHaveBeenCalledTimes(1)
|
||||
expect(queryOpts.queryFn).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('should not display fallback if the query cache is already populated', async () => {
|
||||
const data = [
|
||||
'Prefetch rocks!',
|
||||
'No waterfalls, boy!',
|
||||
'Tanstack Query #ftw',
|
||||
]
|
||||
|
||||
const queryOpts = {
|
||||
queryKey: queryKey(),
|
||||
queryFn: vi
|
||||
.fn<(context: { pageParam: number }) => Promise<string>>()
|
||||
.mockImplementation(({ pageParam }) =>
|
||||
sleep(10).then(() => data[pageParam]!),
|
||||
),
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (_lastPage: string, allPages: Array<string>) =>
|
||||
allPages.length < data.length ? allPages.length : undefined,
|
||||
}
|
||||
|
||||
void queryClient.infiniteQuery({ ...queryOpts, pages: 3 }).catch(noop)
|
||||
await vi.advanceTimersByTimeAsync(30)
|
||||
queryOpts.queryFn.mockClear()
|
||||
|
||||
function Page() {
|
||||
const state = useSuspenseInfiniteQuery(queryOpts)
|
||||
|
||||
return (
|
||||
<div>
|
||||
{state.data.pages.map((page, index) => (
|
||||
<div key={index}>data: {page}</div>
|
||||
))}
|
||||
<button onClick={() => state.fetchNextPage()}>Next Page</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
usePrefetchInfiniteQuery(queryOpts)
|
||||
|
||||
return (
|
||||
<Suspense fallback={<Fallback />}>
|
||||
<Page />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(queryClient, <App />)
|
||||
|
||||
expect(rendered.getByText('data: Prefetch rocks!')).toBeInTheDocument()
|
||||
fireEvent.click(rendered.getByText('Next Page'))
|
||||
expect(rendered.getByText('data: No waterfalls, boy!')).toBeInTheDocument()
|
||||
fireEvent.click(rendered.getByText('Next Page'))
|
||||
expect(rendered.getByText('data: Tanstack Query #ftw')).toBeInTheDocument()
|
||||
expect(queryOpts.queryFn).not.toHaveBeenCalled()
|
||||
expect(Fallback).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not create an endless loop when using inside a suspense boundary', async () => {
|
||||
const data = ['Infinite Page 1', 'Infinite Page 2', 'Infinite Page 3']
|
||||
|
||||
const queryOpts = {
|
||||
queryKey: queryKey(),
|
||||
queryFn: vi
|
||||
.fn<(context: { pageParam: number }) => Promise<string>>()
|
||||
.mockImplementation(({ pageParam }) =>
|
||||
sleep(10).then(() => data[pageParam]!),
|
||||
),
|
||||
initialPageParam: 0,
|
||||
// always reports another page available, to guard against an endless
|
||||
// auto-advance loop rather than a bounded pagination sequence
|
||||
getNextPageParam: (_lastPage: string, allPages: Array<string>) =>
|
||||
allPages.length,
|
||||
}
|
||||
|
||||
function Prefetch({ children }: { children: VNode }) {
|
||||
usePrefetchInfiniteQuery(queryOpts)
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function Page() {
|
||||
const state = useSuspenseInfiniteQuery(queryOpts)
|
||||
|
||||
return (
|
||||
<div>
|
||||
{state.data.pages.map((page, index) => (
|
||||
<div key={index}>data: {page}</div>
|
||||
))}
|
||||
<button onClick={() => state.fetchNextPage()}>Next Page</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Suspense fallback={<></>}>
|
||||
<Prefetch>
|
||||
<Page />
|
||||
</Prefetch>
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(queryClient, <App />)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
rendered.getByText('data: Infinite Page 1')
|
||||
fireEvent.click(rendered.getByText('Next Page'))
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('data: Infinite Page 2')).toBeInTheDocument()
|
||||
fireEvent.click(rendered.getByText('Next Page'))
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
expect(rendered.getByText('data: Infinite Page 3')).toBeInTheDocument()
|
||||
expect(queryOpts.queryFn).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import { queryKey } from '@tanstack/query-test-utils'
|
||||
import { assertType, describe, expectTypeOf, it } from 'vitest'
|
||||
|
||||
import { skipToken, usePrefetchQuery } from '..'
|
||||
|
||||
describe('usePrefetchQuery', () => {
|
||||
it('should return nothing', () => {
|
||||
const result = usePrefetchQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
})
|
||||
|
||||
expectTypeOf(result).toEqualTypeOf<void>()
|
||||
})
|
||||
|
||||
it('should forward query data and query key types', () => {
|
||||
type CustomQueryKey = readonly ['key', number]
|
||||
|
||||
usePrefetchQuery<string, Error, number, Array<string>, CustomQueryKey>({
|
||||
queryKey: ['key', 1],
|
||||
queryFn: (context) => {
|
||||
expectTypeOf(context.queryKey).toEqualTypeOf<CustomQueryKey>()
|
||||
return Promise.resolve('data')
|
||||
},
|
||||
select: (data) => {
|
||||
expectTypeOf(data).toEqualTypeOf<Array<string>>()
|
||||
return data.length
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should not allow refetchInterval, enabled or throwOnError options', () => {
|
||||
assertType(
|
||||
usePrefetchQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
// @ts-expect-error TS2345
|
||||
refetchInterval: 1000,
|
||||
}),
|
||||
)
|
||||
|
||||
assertType(
|
||||
usePrefetchQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
// @ts-expect-error TS2345
|
||||
enabled: true,
|
||||
}),
|
||||
)
|
||||
|
||||
assertType(
|
||||
usePrefetchQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
// @ts-expect-error TS2345
|
||||
throwOnError: true,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should not allow skipToken in queryFn', () => {
|
||||
assertType(
|
||||
usePrefetchQuery({
|
||||
queryKey: queryKey(),
|
||||
// @ts-expect-error
|
||||
queryFn: skipToken,
|
||||
}),
|
||||
)
|
||||
assertType(
|
||||
usePrefetchQuery({
|
||||
queryKey: queryKey(),
|
||||
// @ts-expect-error
|
||||
queryFn: Math.random() > 0.5 ? skipToken : () => Promise.resolve(5),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
329
packages/preact-query/src/__tests__/usePrefetchQuery.test.tsx
Normal file
329
packages/preact-query/src/__tests__/usePrefetchQuery.test.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
import { queryKey, sleep } from '@tanstack/query-test-utils'
|
||||
import { act, fireEvent } from '@testing-library/preact'
|
||||
import type { VNode } from 'preact'
|
||||
import { Suspense } from 'preact/compat'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
QueryCache,
|
||||
QueryClient,
|
||||
noop,
|
||||
usePrefetchQuery,
|
||||
useQueryErrorResetBoundary,
|
||||
useSuspenseQuery,
|
||||
} from '..'
|
||||
import { ErrorBoundary } from './ErrorBoundary'
|
||||
import { renderWithClient } from './utils'
|
||||
|
||||
describe('usePrefetchQuery', () => {
|
||||
let queryCache: QueryCache
|
||||
let queryClient: QueryClient
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
queryCache = new QueryCache()
|
||||
queryClient = new QueryClient({ queryCache })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
queryClient.clear()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should prefetch query if query state does not exist', async () => {
|
||||
const queryOpts = {
|
||||
queryKey: queryKey(),
|
||||
queryFn: vi.fn(() => sleep(10).then(() => 'prefetchQuery')),
|
||||
}
|
||||
|
||||
const componentQueryOpts = {
|
||||
...queryOpts,
|
||||
queryFn: () => sleep(10).then(() => 'useSuspenseQuery'),
|
||||
}
|
||||
|
||||
function Page() {
|
||||
const state = useSuspenseQuery(componentQueryOpts)
|
||||
return <div>data: {String(state.data)}</div>
|
||||
}
|
||||
|
||||
function App() {
|
||||
usePrefetchQuery(queryOpts)
|
||||
|
||||
return (
|
||||
<Suspense fallback="Loading...">
|
||||
<Page />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(queryClient, <App />)
|
||||
|
||||
expect(rendered.getByText('Loading...')).toBeInTheDocument()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
expect(rendered.getByText('data: prefetchQuery')).toBeInTheDocument()
|
||||
expect(queryOpts.queryFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not prefetch query if query state exists', async () => {
|
||||
const queryOpts = {
|
||||
queryKey: queryKey(),
|
||||
queryFn: vi.fn(() =>
|
||||
sleep(10).then(() => 'The usePrefetchQuery hook is smart!'),
|
||||
),
|
||||
}
|
||||
|
||||
function Page() {
|
||||
const state = useSuspenseQuery(queryOpts)
|
||||
return <div>data: {String(state.data)}</div>
|
||||
}
|
||||
|
||||
function App() {
|
||||
usePrefetchQuery(queryOpts)
|
||||
|
||||
return (
|
||||
<Suspense fallback="Loading...">
|
||||
<Page />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
void queryClient.query(queryOpts).catch(noop)
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
queryOpts.queryFn.mockClear()
|
||||
const rendered = renderWithClient(queryClient, <App />)
|
||||
|
||||
expect(rendered.queryByText('fetching: true')).not.toBeInTheDocument()
|
||||
expect(
|
||||
rendered.getByText('data: The usePrefetchQuery hook is smart!'),
|
||||
).toBeInTheDocument()
|
||||
expect(queryOpts.queryFn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should let errors fall through and not refetch failed queries', async () => {
|
||||
const consoleMock = vi.spyOn(console, 'error')
|
||||
consoleMock.mockImplementation(() => undefined)
|
||||
const queryFn = vi.fn(() => sleep(10).then(() => 'Not an error'))
|
||||
|
||||
const queryOpts = {
|
||||
queryKey: queryKey(),
|
||||
queryFn,
|
||||
}
|
||||
|
||||
queryFn.mockImplementationOnce(() =>
|
||||
sleep(10).then(() => {
|
||||
throw new Error('Oops! Server error!')
|
||||
}),
|
||||
)
|
||||
|
||||
function Page() {
|
||||
const state = useSuspenseQuery(queryOpts)
|
||||
return <div>data: {String(state.data)}</div>
|
||||
}
|
||||
|
||||
function App() {
|
||||
usePrefetchQuery(queryOpts)
|
||||
|
||||
return (
|
||||
<ErrorBoundary fallbackRender={() => <div>Oops!</div>}>
|
||||
<Suspense fallback="Loading...">
|
||||
<Page />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
void queryClient.query(queryOpts).catch(noop)
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
queryFn.mockClear()
|
||||
const rendered = renderWithClient(queryClient, <App />)
|
||||
|
||||
expect(rendered.getByText('Oops!')).toBeInTheDocument()
|
||||
expect(rendered.queryByText('data: Not an error')).not.toBeInTheDocument()
|
||||
expect(queryOpts.queryFn).not.toHaveBeenCalled()
|
||||
|
||||
consoleMock.mockRestore()
|
||||
})
|
||||
|
||||
it('should not create an endless loop when using inside a suspense boundary', async () => {
|
||||
const queryFn = vi.fn(() => sleep(10).then(() => 'prefetchedQuery'))
|
||||
|
||||
const queryOpts = {
|
||||
queryKey: queryKey(),
|
||||
queryFn,
|
||||
}
|
||||
|
||||
function Prefetch({ children }: { children: VNode }) {
|
||||
usePrefetchQuery(queryOpts)
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function Page() {
|
||||
const state = useSuspenseQuery(queryOpts)
|
||||
return <div>data: {String(state.data)}</div>
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Suspense fallback={<></>}>
|
||||
<Prefetch>
|
||||
<Page />
|
||||
</Prefetch>
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(queryClient, <App />)
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
expect(rendered.getByText('data: prefetchedQuery')).toBeInTheDocument()
|
||||
expect(queryOpts.queryFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should be able to recover from errors and try fetching again', async () => {
|
||||
const consoleMock = vi.spyOn(console, 'error')
|
||||
consoleMock.mockImplementation(() => undefined)
|
||||
const queryFn = vi.fn(() =>
|
||||
sleep(10).then(() => 'This is fine :dog: :fire:'),
|
||||
)
|
||||
|
||||
const queryOpts = {
|
||||
queryKey: queryKey(),
|
||||
queryFn,
|
||||
}
|
||||
|
||||
queryFn.mockImplementationOnce(() =>
|
||||
sleep(10).then(() => {
|
||||
throw new Error('Oops! Server error!')
|
||||
}),
|
||||
)
|
||||
|
||||
function Page() {
|
||||
const state = useSuspenseQuery(queryOpts)
|
||||
return <div>data: {String(state.data)}</div>
|
||||
}
|
||||
|
||||
function App() {
|
||||
const { reset } = useQueryErrorResetBoundary()
|
||||
usePrefetchQuery(queryOpts)
|
||||
|
||||
return (
|
||||
<ErrorBoundary
|
||||
onReset={reset}
|
||||
fallbackRender={({ resetErrorBoundary }) => (
|
||||
<div>
|
||||
<div>Oops!</div>
|
||||
<button onClick={resetErrorBoundary}>Try again</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Suspense fallback="Loading...">
|
||||
<Page />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
void queryClient.query(queryOpts).catch(noop)
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
queryFn.mockClear()
|
||||
|
||||
const rendered = renderWithClient(queryClient, <App />)
|
||||
|
||||
expect(rendered.getByText('Oops!')).toBeInTheDocument()
|
||||
fireEvent.click(rendered.getByText('Try again'))
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
expect(
|
||||
rendered.getByText('data: This is fine :dog: :fire:'),
|
||||
).toBeInTheDocument()
|
||||
expect(queryOpts.queryFn).toHaveBeenCalledTimes(1)
|
||||
consoleMock.mockRestore()
|
||||
})
|
||||
|
||||
it('should not create a suspense waterfall if prefetch is fired', async () => {
|
||||
const firstQueryOpts = {
|
||||
queryKey: queryKey(),
|
||||
queryFn: vi.fn(() => sleep(10).then(() => 'Prefetch is nice!')),
|
||||
}
|
||||
|
||||
const secondQueryOpts = {
|
||||
queryKey: queryKey(),
|
||||
queryFn: vi.fn(() => sleep(10).then(() => 'Prefetch is really nice!!')),
|
||||
}
|
||||
|
||||
const thirdQueryOpts = {
|
||||
queryKey: queryKey(),
|
||||
queryFn: vi.fn(() =>
|
||||
sleep(10).then(() => 'Prefetch does not create waterfalls!!'),
|
||||
),
|
||||
}
|
||||
|
||||
const Fallback = vi.fn().mockImplementation(() => <div>Loading...</div>)
|
||||
|
||||
function FirstQuery({ children }: { children?: VNode }) {
|
||||
const state = useSuspenseQuery(firstQueryOpts)
|
||||
return (
|
||||
<div>
|
||||
<div>data: {String(state.data)}</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SecondQuery({ children }: { children?: VNode }) {
|
||||
const state = useSuspenseQuery(secondQueryOpts)
|
||||
return (
|
||||
<div>
|
||||
<div>data: {String(state.data)}</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ThirdQuery() {
|
||||
const state = useSuspenseQuery(thirdQueryOpts)
|
||||
return <div>data: {String(state.data)}</div>
|
||||
}
|
||||
|
||||
function App() {
|
||||
usePrefetchQuery(firstQueryOpts)
|
||||
usePrefetchQuery(secondQueryOpts)
|
||||
usePrefetchQuery(thirdQueryOpts)
|
||||
|
||||
return (
|
||||
<Suspense fallback={<Fallback />}>
|
||||
<FirstQuery>
|
||||
<SecondQuery>
|
||||
<ThirdQuery />
|
||||
</SecondQuery>
|
||||
</FirstQuery>
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
const rendered = renderWithClient(queryClient, <App />)
|
||||
expect(
|
||||
queryClient.getQueryState(firstQueryOpts.queryKey)?.fetchStatus,
|
||||
).toBe('fetching')
|
||||
expect(
|
||||
queryClient.getQueryState(secondQueryOpts.queryKey)?.fetchStatus,
|
||||
).toBe('fetching')
|
||||
expect(
|
||||
queryClient.getQueryState(thirdQueryOpts.queryKey)?.fetchStatus,
|
||||
).toBe('fetching')
|
||||
expect(rendered.getByText('Loading...')).toBeInTheDocument()
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
})
|
||||
expect(rendered.getByText('data: Prefetch is nice!')).toBeInTheDocument()
|
||||
expect(
|
||||
rendered.getByText('data: Prefetch is really nice!!'),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
rendered.getByText('data: Prefetch does not create waterfalls!!'),
|
||||
).toBeInTheDocument()
|
||||
expect(Fallback).toHaveBeenCalledTimes(1)
|
||||
expect(firstQueryOpts.queryFn).toHaveBeenCalledTimes(1)
|
||||
expect(secondQueryOpts.queryFn).toHaveBeenCalledTimes(1)
|
||||
expect(thirdQueryOpts.queryFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
872
packages/preact-query/src/__tests__/useQueries.test-d.tsx
Normal file
872
packages/preact-query/src/__tests__/useQueries.test-d.tsx
Normal file
@@ -0,0 +1,872 @@
|
||||
import { queryKey } from '@tanstack/query-test-utils'
|
||||
import { describe, expectTypeOf, it } from 'vitest'
|
||||
|
||||
import { skipToken } from '..'
|
||||
import type { OmitKeyof, QueryFunction, QueryKey } from '..'
|
||||
import { queryOptions } from '../queryOptions'
|
||||
import type { UseQueryOptions, UseQueryResult } from '../types'
|
||||
import { useQueries } from '../useQueries'
|
||||
import type { QueryFunctionContext } from '@tanstack/query-core'
|
||||
|
||||
describe('useQueries', () => {
|
||||
describe('config object overload', () => {
|
||||
it('TData should always be defined when initialData is provided as an object', () => {
|
||||
const query1 = {
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => {
|
||||
return {
|
||||
wow: true,
|
||||
}
|
||||
},
|
||||
initialData: {
|
||||
wow: false,
|
||||
},
|
||||
}
|
||||
|
||||
const query2 = {
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => 'Query Data',
|
||||
initialData: 'initial data',
|
||||
}
|
||||
|
||||
const query3 = {
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => 'Query Data',
|
||||
}
|
||||
|
||||
const queryResults = useQueries({ queries: [query1, query2, query3] })
|
||||
|
||||
const query1Data = queryResults[0].data
|
||||
const query2Data = queryResults[1].data
|
||||
const query3Data = queryResults[2].data
|
||||
|
||||
expectTypeOf(query1Data).toEqualTypeOf<{ wow: boolean }>()
|
||||
expectTypeOf(query2Data).toEqualTypeOf<string>()
|
||||
expectTypeOf(query3Data).toEqualTypeOf<string | undefined>()
|
||||
})
|
||||
|
||||
it('TData should be defined when passed through queryOptions', () => {
|
||||
const options = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => {
|
||||
return {
|
||||
wow: true,
|
||||
}
|
||||
},
|
||||
initialData: {
|
||||
wow: true,
|
||||
},
|
||||
})
|
||||
const queryResults = useQueries({ queries: [options] })
|
||||
|
||||
const data = queryResults[0].data
|
||||
|
||||
expectTypeOf(data).toEqualTypeOf<{ wow: boolean }>()
|
||||
})
|
||||
|
||||
it('should be possible to define a different TData than TQueryFnData using select with queryOptions spread into useQueries', () => {
|
||||
const query1 = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(1),
|
||||
select: (data) => data > 1,
|
||||
})
|
||||
|
||||
const query2 = {
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(1),
|
||||
select: (data: number) => data > 1,
|
||||
}
|
||||
|
||||
const queryResults = useQueries({ queries: [query1, query2] })
|
||||
const query1Data = queryResults[0].data
|
||||
const query2Data = queryResults[1].data
|
||||
|
||||
expectTypeOf(query1Data).toEqualTypeOf<boolean | undefined>()
|
||||
expectTypeOf(query2Data).toEqualTypeOf<boolean | undefined>()
|
||||
})
|
||||
|
||||
it('TData should have undefined in the union when initialData is provided as a function which can return undefined', () => {
|
||||
const queryResults = useQueries({
|
||||
queries: [
|
||||
{
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => {
|
||||
return {
|
||||
wow: true,
|
||||
}
|
||||
},
|
||||
initialData: () => undefined as { wow: boolean } | undefined,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const data = queryResults[0].data
|
||||
|
||||
expectTypeOf(data).toEqualTypeOf<{ wow: boolean } | undefined>()
|
||||
})
|
||||
|
||||
describe('custom hook', () => {
|
||||
it('should allow custom hooks using UseQueryOptions', () => {
|
||||
type Data = string
|
||||
|
||||
const useCustomQueries = (
|
||||
options?: OmitKeyof<UseQueryOptions<Data>, 'queryKey' | 'queryFn'>,
|
||||
) => {
|
||||
return useQueries({
|
||||
queries: [
|
||||
{
|
||||
...options,
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve('data'),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const queryResults = useCustomQueries()
|
||||
const data = queryResults[0].data
|
||||
|
||||
expectTypeOf(data).toEqualTypeOf<Data | undefined>()
|
||||
})
|
||||
})
|
||||
|
||||
it('TData should have correct type when conditional skipToken is passed', () => {
|
||||
const queryResults = useQueries({
|
||||
queries: [
|
||||
{
|
||||
queryKey: queryKey(),
|
||||
queryFn: Math.random() > 0.5 ? skipToken : () => Promise.resolve(5),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const firstResult = queryResults[0]
|
||||
|
||||
expectTypeOf(firstResult).toEqualTypeOf<UseQueryResult<number, Error>>()
|
||||
expectTypeOf(firstResult.data).toEqualTypeOf<number | undefined>()
|
||||
})
|
||||
|
||||
it('should return correct data for dynamic queries with mixed result types', () => {
|
||||
const Queries1 = {
|
||||
get: () =>
|
||||
queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(1),
|
||||
}),
|
||||
}
|
||||
const Queries2 = {
|
||||
get: () =>
|
||||
queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(true),
|
||||
}),
|
||||
}
|
||||
|
||||
const queries1List = [1, 2, 3].map(() => ({ ...Queries1.get() }))
|
||||
const result = useQueries({
|
||||
queries: [...queries1List, { ...Queries2.get() }],
|
||||
})
|
||||
|
||||
expectTypeOf(result).toEqualTypeOf<
|
||||
[
|
||||
...Array<UseQueryResult<number, Error>>,
|
||||
UseQueryResult<boolean, Error>,
|
||||
]
|
||||
>()
|
||||
})
|
||||
})
|
||||
|
||||
describe('type parameters', () => {
|
||||
it('should handle type parameter - tuple of tuples', () => {
|
||||
const key1 = queryKey()
|
||||
const key2 = queryKey()
|
||||
const key3 = queryKey()
|
||||
|
||||
// @ts-expect-error (Page component is not rendered)
|
||||
function Page() {
|
||||
const result1 = useQueries<
|
||||
[[number], [string], [Array<string>, boolean]]
|
||||
>({
|
||||
queries: [
|
||||
{
|
||||
queryKey: key1,
|
||||
queryFn: () => 1,
|
||||
},
|
||||
{
|
||||
queryKey: key2,
|
||||
queryFn: () => 'string',
|
||||
},
|
||||
{
|
||||
queryKey: key3,
|
||||
queryFn: () => ['string[]'],
|
||||
},
|
||||
],
|
||||
})
|
||||
expectTypeOf(result1[0]).toEqualTypeOf<
|
||||
UseQueryResult<number, unknown>
|
||||
>()
|
||||
expectTypeOf(result1[1]).toEqualTypeOf<
|
||||
UseQueryResult<string, unknown>
|
||||
>()
|
||||
expectTypeOf(result1[2]).toEqualTypeOf<
|
||||
UseQueryResult<Array<string>, boolean>
|
||||
>()
|
||||
expectTypeOf(result1[0].data).toEqualTypeOf<number | undefined>()
|
||||
expectTypeOf(result1[1].data).toEqualTypeOf<string | undefined>()
|
||||
expectTypeOf(result1[2].data).toEqualTypeOf<Array<string> | undefined>()
|
||||
expectTypeOf(result1[2].error).toEqualTypeOf<boolean | null>()
|
||||
|
||||
// TData (3rd element) takes precedence over TQueryFnData (1st element)
|
||||
const result2 = useQueries<
|
||||
[[string, unknown, string], [string, unknown, number]]
|
||||
>({
|
||||
queries: [
|
||||
{
|
||||
queryKey: key1,
|
||||
queryFn: () => 'string',
|
||||
select: (a) => {
|
||||
expectTypeOf(a).toEqualTypeOf<string>()
|
||||
return a.toLowerCase()
|
||||
},
|
||||
},
|
||||
{
|
||||
queryKey: key2,
|
||||
queryFn: () => 'string',
|
||||
select: (a) => {
|
||||
expectTypeOf(a).toEqualTypeOf<string>()
|
||||
return parseInt(a, 10)
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
expectTypeOf(result2[0]).toEqualTypeOf<
|
||||
UseQueryResult<string, unknown>
|
||||
>()
|
||||
expectTypeOf(result2[1]).toEqualTypeOf<
|
||||
UseQueryResult<number, unknown>
|
||||
>()
|
||||
expectTypeOf(result2[0].data).toEqualTypeOf<string | undefined>()
|
||||
expectTypeOf(result2[1].data).toEqualTypeOf<number | undefined>()
|
||||
|
||||
// types should be enforced
|
||||
useQueries<[[string, unknown, string], [string, boolean, number]]>({
|
||||
queries: [
|
||||
{
|
||||
queryKey: key1,
|
||||
queryFn: () => 'string',
|
||||
select: (a) => {
|
||||
expectTypeOf(a).toEqualTypeOf<string>()
|
||||
return a.toLowerCase()
|
||||
},
|
||||
placeholderData: 'string',
|
||||
// @ts-expect-error (initialData: string)
|
||||
initialData: 123,
|
||||
},
|
||||
{
|
||||
queryKey: key2,
|
||||
queryFn: () => 'string',
|
||||
select: (a) => {
|
||||
expectTypeOf(a).toEqualTypeOf<string>()
|
||||
return parseInt(a, 10)
|
||||
},
|
||||
placeholderData: 'string',
|
||||
// @ts-expect-error (initialData: string)
|
||||
initialData: 123,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// field names should be enforced
|
||||
useQueries<[[string]]>({
|
||||
queries: [
|
||||
{
|
||||
queryKey: key1,
|
||||
queryFn: () => 'string',
|
||||
// @ts-expect-error (invalidField)
|
||||
someInvalidField: [],
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('should handle type parameter - tuple of objects', () => {
|
||||
const key1 = queryKey()
|
||||
const key2 = queryKey()
|
||||
const key3 = queryKey()
|
||||
|
||||
// @ts-expect-error (Page component is not rendered)
|
||||
function Page() {
|
||||
const result1 = useQueries<
|
||||
[
|
||||
{ queryFnData: number },
|
||||
{ queryFnData: string },
|
||||
{ queryFnData: Array<string>; error: boolean },
|
||||
]
|
||||
>({
|
||||
queries: [
|
||||
{
|
||||
queryKey: key1,
|
||||
queryFn: () => 1,
|
||||
},
|
||||
{
|
||||
queryKey: key2,
|
||||
queryFn: () => 'string',
|
||||
},
|
||||
{
|
||||
queryKey: key3,
|
||||
queryFn: () => ['string[]'],
|
||||
},
|
||||
],
|
||||
})
|
||||
expectTypeOf(result1[0]).toEqualTypeOf<
|
||||
UseQueryResult<number, unknown>
|
||||
>()
|
||||
expectTypeOf(result1[1]).toEqualTypeOf<
|
||||
UseQueryResult<string, unknown>
|
||||
>()
|
||||
expectTypeOf(result1[2]).toEqualTypeOf<
|
||||
UseQueryResult<Array<string>, boolean>
|
||||
>()
|
||||
expectTypeOf(result1[0].data).toEqualTypeOf<number | undefined>()
|
||||
expectTypeOf(result1[1].data).toEqualTypeOf<string | undefined>()
|
||||
expectTypeOf(result1[2].data).toEqualTypeOf<Array<string> | undefined>()
|
||||
expectTypeOf(result1[2].error).toEqualTypeOf<boolean | null>()
|
||||
|
||||
// TData (data prop) takes precedence over TQueryFnData (queryFnData prop)
|
||||
const result2 = useQueries<
|
||||
[
|
||||
{ queryFnData: string; data: string },
|
||||
{ queryFnData: string; data: number },
|
||||
]
|
||||
>({
|
||||
queries: [
|
||||
{
|
||||
queryKey: key1,
|
||||
queryFn: () => 'string',
|
||||
select: (a) => {
|
||||
expectTypeOf(a).toEqualTypeOf<string>()
|
||||
return a.toLowerCase()
|
||||
},
|
||||
},
|
||||
{
|
||||
queryKey: key2,
|
||||
queryFn: () => 'string',
|
||||
select: (a) => {
|
||||
expectTypeOf(a).toEqualTypeOf<string>()
|
||||
return parseInt(a, 10)
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
expectTypeOf(result2[0]).toEqualTypeOf<
|
||||
UseQueryResult<string, unknown>
|
||||
>()
|
||||
expectTypeOf(result2[1]).toEqualTypeOf<
|
||||
UseQueryResult<number, unknown>
|
||||
>()
|
||||
expectTypeOf(result2[0].data).toEqualTypeOf<string | undefined>()
|
||||
expectTypeOf(result2[1].data).toEqualTypeOf<number | undefined>()
|
||||
|
||||
// can pass only TData (data prop) although TQueryFnData will be left unknown
|
||||
const result3 = useQueries<[{ data: string }, { data: number }]>({
|
||||
queries: [
|
||||
{
|
||||
queryKey: key1,
|
||||
queryFn: () => 'string',
|
||||
select: (a) => {
|
||||
expectTypeOf(a).toEqualTypeOf<unknown>()
|
||||
return a as string
|
||||
},
|
||||
},
|
||||
{
|
||||
queryKey: key2,
|
||||
queryFn: () => 'string',
|
||||
select: (a) => {
|
||||
expectTypeOf(a).toEqualTypeOf<unknown>()
|
||||
return a as number
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
expectTypeOf(result3[0]).toEqualTypeOf<
|
||||
UseQueryResult<string, unknown>
|
||||
>()
|
||||
expectTypeOf(result3[1]).toEqualTypeOf<
|
||||
UseQueryResult<number, unknown>
|
||||
>()
|
||||
expectTypeOf(result3[0].data).toEqualTypeOf<string | undefined>()
|
||||
expectTypeOf(result3[1].data).toEqualTypeOf<number | undefined>()
|
||||
|
||||
// types should be enforced
|
||||
useQueries<
|
||||
[
|
||||
{ queryFnData: string; data: string },
|
||||
{ queryFnData: string; data: number; error: boolean },
|
||||
]
|
||||
>({
|
||||
queries: [
|
||||
{
|
||||
queryKey: key1,
|
||||
queryFn: () => 'string',
|
||||
select: (a) => {
|
||||
expectTypeOf(a).toEqualTypeOf<string>()
|
||||
return a.toLowerCase()
|
||||
},
|
||||
placeholderData: 'string',
|
||||
// @ts-expect-error (initialData: string)
|
||||
initialData: 123,
|
||||
},
|
||||
{
|
||||
queryKey: key2,
|
||||
queryFn: () => 'string',
|
||||
select: (a) => {
|
||||
expectTypeOf(a).toEqualTypeOf<string>()
|
||||
return parseInt(a, 10)
|
||||
},
|
||||
placeholderData: 'string',
|
||||
// @ts-expect-error (initialData: string)
|
||||
initialData: 123,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// field names should be enforced
|
||||
useQueries<[{ queryFnData: string }]>({
|
||||
queries: [
|
||||
{
|
||||
queryKey: key1,
|
||||
queryFn: () => 'string',
|
||||
// @ts-expect-error (invalidField)
|
||||
someInvalidField: [],
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('should return correct types when passing through queryOptions', () => {
|
||||
// @ts-expect-error (Page component is not rendered)
|
||||
function Page() {
|
||||
// data and results types are correct when using queryOptions
|
||||
const result4 = useQueries({
|
||||
queries: [
|
||||
queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => 'string',
|
||||
select: (a) => {
|
||||
expectTypeOf(a).toEqualTypeOf<string>()
|
||||
return a.toLowerCase()
|
||||
},
|
||||
}),
|
||||
queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => 'string',
|
||||
select: (a) => {
|
||||
expectTypeOf(a).toEqualTypeOf<string>()
|
||||
return parseInt(a, 10)
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
expectTypeOf(result4[0]).toEqualTypeOf<UseQueryResult<string, Error>>()
|
||||
expectTypeOf(result4[1]).toEqualTypeOf<UseQueryResult<number, Error>>()
|
||||
expectTypeOf(result4[0].data).toEqualTypeOf<string | undefined>()
|
||||
expectTypeOf(result4[1].data).toEqualTypeOf<number | undefined>()
|
||||
}
|
||||
})
|
||||
|
||||
it('should handle array literal without type parameter to infer result type', () => {
|
||||
const key1 = queryKey()
|
||||
const key2 = queryKey()
|
||||
const key3 = queryKey()
|
||||
const key4 = queryKey()
|
||||
const key5 = queryKey()
|
||||
|
||||
type BizError = { code: number }
|
||||
const throwOnError = (_error: BizError) => true
|
||||
|
||||
// @ts-expect-error (Page component is not rendered)
|
||||
function Page() {
|
||||
// Array.map preserves TQueryFnData
|
||||
const result1 = useQueries({
|
||||
queries: Array(50).map((_, i) => ({
|
||||
queryKey: ['key', i] as const,
|
||||
queryFn: () => i + 10,
|
||||
})),
|
||||
})
|
||||
expectTypeOf(result1).toEqualTypeOf<
|
||||
Array<UseQueryResult<number, Error>>
|
||||
>()
|
||||
if (result1[0]) {
|
||||
expectTypeOf(result1[0].data).toEqualTypeOf<number | undefined>()
|
||||
}
|
||||
|
||||
// Array.map preserves TError
|
||||
const result1_err = useQueries({
|
||||
queries: Array(50).map((_, i) => ({
|
||||
queryKey: ['key', i] as const,
|
||||
queryFn: () => i + 10,
|
||||
throwOnError,
|
||||
})),
|
||||
})
|
||||
expectTypeOf(result1_err).toEqualTypeOf<
|
||||
Array<UseQueryResult<number, BizError>>
|
||||
>()
|
||||
if (result1_err[0]) {
|
||||
expectTypeOf(result1_err[0].data).toEqualTypeOf<number | undefined>()
|
||||
expectTypeOf(result1_err[0].error).toEqualTypeOf<BizError | null>()
|
||||
}
|
||||
|
||||
// Array.map preserves TData
|
||||
const result2 = useQueries({
|
||||
queries: Array(50).map((_, i) => ({
|
||||
queryKey: ['key', i] as const,
|
||||
queryFn: () => i + 10,
|
||||
select: (data: number) => data.toString(),
|
||||
})),
|
||||
})
|
||||
expectTypeOf(result2).toEqualTypeOf<
|
||||
Array<UseQueryResult<string, Error>>
|
||||
>()
|
||||
|
||||
const result2_err = useQueries({
|
||||
queries: Array(50).map((_, i) => ({
|
||||
queryKey: ['key', i] as const,
|
||||
queryFn: () => i + 10,
|
||||
select: (data: number) => data.toString(),
|
||||
throwOnError,
|
||||
})),
|
||||
})
|
||||
expectTypeOf(result2_err).toEqualTypeOf<
|
||||
Array<UseQueryResult<string, BizError>>
|
||||
>()
|
||||
|
||||
const result3 = useQueries({
|
||||
queries: [
|
||||
{
|
||||
queryKey: key1,
|
||||
queryFn: () => 1,
|
||||
},
|
||||
{
|
||||
queryKey: key2,
|
||||
queryFn: () => 'string',
|
||||
},
|
||||
{
|
||||
queryKey: key3,
|
||||
queryFn: () => ['string[]'],
|
||||
select: () => 123,
|
||||
},
|
||||
{
|
||||
queryKey: key5,
|
||||
queryFn: () => 'string',
|
||||
throwOnError,
|
||||
},
|
||||
],
|
||||
})
|
||||
expectTypeOf(result3[0]).toEqualTypeOf<UseQueryResult<number, Error>>()
|
||||
expectTypeOf(result3[1]).toEqualTypeOf<UseQueryResult<string, Error>>()
|
||||
expectTypeOf(result3[2]).toEqualTypeOf<UseQueryResult<number, Error>>()
|
||||
expectTypeOf(result3[0].data).toEqualTypeOf<number | undefined>()
|
||||
expectTypeOf(result3[1].data).toEqualTypeOf<string | undefined>()
|
||||
expectTypeOf(result3[3].data).toEqualTypeOf<string | undefined>()
|
||||
// select takes precedence over queryFn
|
||||
expectTypeOf(result3[2].data).toEqualTypeOf<number | undefined>()
|
||||
// infer TError from throwOnError
|
||||
expectTypeOf(result3[3].error).toEqualTypeOf<BizError | null>()
|
||||
|
||||
// initialData/placeholderData are enforced
|
||||
useQueries({
|
||||
queries: [
|
||||
{
|
||||
queryKey: key1,
|
||||
queryFn: () => 'string',
|
||||
placeholderData: 'string',
|
||||
// @ts-expect-error (initialData: string)
|
||||
initialData: 123,
|
||||
},
|
||||
{
|
||||
queryKey: key2,
|
||||
queryFn: () => 123,
|
||||
// @ts-expect-error (placeholderData: number)
|
||||
placeholderData: 'string',
|
||||
initialData: 123,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// select and throwOnError params are "indirectly" enforced
|
||||
useQueries({
|
||||
queries: [
|
||||
// unfortunately TS will not suggest the type for you
|
||||
{
|
||||
queryKey: key1,
|
||||
queryFn: () => 'string',
|
||||
},
|
||||
// however you can add a type to the callback
|
||||
{
|
||||
queryKey: key2,
|
||||
queryFn: () => 'string',
|
||||
},
|
||||
// the type you do pass is enforced
|
||||
{
|
||||
queryKey: key3,
|
||||
queryFn: () => 'string',
|
||||
},
|
||||
{
|
||||
queryKey: key4,
|
||||
queryFn: () => 'string',
|
||||
select: (a: string) => parseInt(a, 10),
|
||||
},
|
||||
{
|
||||
queryKey: key5,
|
||||
queryFn: () => 'string',
|
||||
throwOnError,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// callbacks are also indirectly enforced with Array.map
|
||||
useQueries({
|
||||
queries: Array(50).map((_, i) => ({
|
||||
queryKey: ['key', i] as const,
|
||||
queryFn: () => i + 10,
|
||||
select: (data: number) => data.toString(),
|
||||
})),
|
||||
})
|
||||
useQueries({
|
||||
queries: Array(50).map((_, i) => ({
|
||||
queryKey: ['key', i] as const,
|
||||
queryFn: () => i + 10,
|
||||
select: (data: number) => data.toString(),
|
||||
})),
|
||||
})
|
||||
|
||||
// results inference works when all the handlers are defined
|
||||
const result4 = useQueries({
|
||||
queries: [
|
||||
{
|
||||
queryKey: key1,
|
||||
queryFn: () => 'string',
|
||||
},
|
||||
{
|
||||
queryKey: key2,
|
||||
queryFn: () => 'string',
|
||||
},
|
||||
{
|
||||
queryKey: key4,
|
||||
queryFn: () => 'string',
|
||||
select: (a: string) => parseInt(a, 10),
|
||||
},
|
||||
{
|
||||
queryKey: key5,
|
||||
queryFn: () => 'string',
|
||||
select: (a: string) => parseInt(a, 10),
|
||||
throwOnError,
|
||||
},
|
||||
],
|
||||
})
|
||||
expectTypeOf(result4[0]).toEqualTypeOf<UseQueryResult<string, Error>>()
|
||||
expectTypeOf(result4[1]).toEqualTypeOf<UseQueryResult<string, Error>>()
|
||||
expectTypeOf(result4[2]).toEqualTypeOf<UseQueryResult<number, Error>>()
|
||||
expectTypeOf(result4[3]).toEqualTypeOf<
|
||||
UseQueryResult<number, BizError>
|
||||
>()
|
||||
|
||||
// handles when queryFn returns a Promise
|
||||
const result5 = useQueries({
|
||||
queries: [
|
||||
{
|
||||
queryKey: key1,
|
||||
queryFn: () => Promise.resolve('string'),
|
||||
},
|
||||
],
|
||||
})
|
||||
expectTypeOf(result5[0]).toEqualTypeOf<UseQueryResult<string, Error>>()
|
||||
|
||||
// Array as const does not throw error
|
||||
const result6 = useQueries({
|
||||
queries: [
|
||||
{
|
||||
queryKey: ['key1'],
|
||||
queryFn: () => 'string',
|
||||
},
|
||||
{
|
||||
queryKey: ['key1'],
|
||||
queryFn: () => 123,
|
||||
},
|
||||
{
|
||||
queryKey: key5,
|
||||
queryFn: () => 'string',
|
||||
throwOnError,
|
||||
},
|
||||
],
|
||||
} as const)
|
||||
expectTypeOf(result6[0]).toEqualTypeOf<UseQueryResult<string, Error>>()
|
||||
expectTypeOf(result6[1]).toEqualTypeOf<UseQueryResult<number, Error>>()
|
||||
expectTypeOf(result6[2]).toEqualTypeOf<
|
||||
UseQueryResult<string, BizError>
|
||||
>()
|
||||
|
||||
// field names should be enforced - array literal
|
||||
useQueries({
|
||||
queries: [
|
||||
{
|
||||
queryKey: key1,
|
||||
queryFn: () => 'string',
|
||||
// @ts-expect-error (invalidField)
|
||||
someInvalidField: [],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// field names should be enforced - Array.map() result
|
||||
useQueries({
|
||||
// @ts-expect-error (invalidField)
|
||||
queries: Array(10).map(() => ({
|
||||
someInvalidField: '',
|
||||
})),
|
||||
})
|
||||
|
||||
// field names should be enforced - array literal
|
||||
useQueries({
|
||||
queries: [
|
||||
{
|
||||
queryKey: key1,
|
||||
queryFn: () => 'string',
|
||||
// @ts-expect-error (invalidField)
|
||||
someInvalidField: [],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// supports queryFn using fetch() to return Promise<any> - Array.map() result
|
||||
useQueries({
|
||||
queries: Array(50).map((_, i) => ({
|
||||
queryKey: ['key', i] as const,
|
||||
queryFn: () =>
|
||||
fetch('return Promise<any>').then((resp) => resp.json()),
|
||||
})),
|
||||
})
|
||||
|
||||
// supports queryFn using fetch() to return Promise<any> - array literal
|
||||
useQueries({
|
||||
queries: [
|
||||
{
|
||||
queryKey: key1,
|
||||
queryFn: () =>
|
||||
fetch('return Promise<any>').then((resp) => resp.json()),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('should handle strongly typed queryFn factories and useQueries wrappers', () => {
|
||||
// QueryKey + queryFn factory
|
||||
type QueryKeyA = ['queryA']
|
||||
const getQueryKeyA = (): QueryKeyA => ['queryA']
|
||||
type GetQueryFunctionA = () => QueryFunction<number, QueryKeyA>
|
||||
const getQueryFunctionA: GetQueryFunctionA = () => () => {
|
||||
return Promise.resolve(1)
|
||||
}
|
||||
type SelectorA = (data: number) => [number, string]
|
||||
const getSelectorA = (): SelectorA => (data) => [data, data.toString()]
|
||||
|
||||
type QueryKeyB = ['queryB', string]
|
||||
const getQueryKeyB = (id: string): QueryKeyB => ['queryB', id]
|
||||
type GetQueryFunctionB = () => QueryFunction<string, QueryKeyB>
|
||||
const getQueryFunctionB: GetQueryFunctionB = () => () => {
|
||||
return Promise.resolve('1')
|
||||
}
|
||||
type SelectorB = (data: string) => [string, number]
|
||||
const getSelectorB = (): SelectorB => (data) => [data, +data]
|
||||
|
||||
// Wrapper with strongly typed array-parameter
|
||||
function useWrappedQueries<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey extends QueryKey,
|
||||
>(
|
||||
queries: Array<UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>>,
|
||||
) {
|
||||
return useQueries({
|
||||
queries: queries.map(
|
||||
// no need to type the mapped query
|
||||
(query) => {
|
||||
const { queryFn: fn, queryKey: key } = query
|
||||
expectTypeOf(fn).toEqualTypeOf<
|
||||
| typeof skipToken
|
||||
| QueryFunction<TQueryFnData, TQueryKey, never>
|
||||
| undefined
|
||||
>()
|
||||
return {
|
||||
queryKey: key,
|
||||
queryFn:
|
||||
fn && fn !== skipToken
|
||||
? (ctx: QueryFunctionContext<TQueryKey>) => {
|
||||
// eslint-disable-next-line vitest/valid-expect
|
||||
expectTypeOf<TQueryKey>(ctx.queryKey)
|
||||
return fn.call({}, ctx)
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
},
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
// @ts-expect-error (Page component is not rendered)
|
||||
function Page() {
|
||||
const result = useQueries({
|
||||
queries: [
|
||||
{
|
||||
queryKey: getQueryKeyA(),
|
||||
queryFn: getQueryFunctionA(),
|
||||
},
|
||||
{
|
||||
queryKey: getQueryKeyB('id'),
|
||||
queryFn: getQueryFunctionB(),
|
||||
},
|
||||
],
|
||||
})
|
||||
expectTypeOf(result[0]).toEqualTypeOf<UseQueryResult<number, Error>>()
|
||||
expectTypeOf(result[1]).toEqualTypeOf<UseQueryResult<string, Error>>()
|
||||
|
||||
const withSelector = useQueries({
|
||||
queries: [
|
||||
{
|
||||
queryKey: getQueryKeyA(),
|
||||
queryFn: getQueryFunctionA(),
|
||||
select: getSelectorA(),
|
||||
},
|
||||
{
|
||||
queryKey: getQueryKeyB('id'),
|
||||
queryFn: getQueryFunctionB(),
|
||||
select: getSelectorB(),
|
||||
},
|
||||
],
|
||||
})
|
||||
expectTypeOf(withSelector[0]).toEqualTypeOf<
|
||||
UseQueryResult<[number, string], Error>
|
||||
>()
|
||||
expectTypeOf(withSelector[1]).toEqualTypeOf<
|
||||
UseQueryResult<[string, number], Error>
|
||||
>()
|
||||
|
||||
const withWrappedQueries = useWrappedQueries(
|
||||
Array(10).map(() => ({
|
||||
queryKey: getQueryKeyA(),
|
||||
queryFn: getQueryFunctionA(),
|
||||
select: getSelectorA(),
|
||||
})),
|
||||
)
|
||||
|
||||
expectTypeOf(withWrappedQueries).toEqualTypeOf<
|
||||
Array<UseQueryResult<number, Error>>
|
||||
>()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
1269
packages/preact-query/src/__tests__/useQueries.test.tsx
Normal file
1269
packages/preact-query/src/__tests__/useQueries.test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
334
packages/preact-query/src/__tests__/useQuery.test-d.tsx
Normal file
334
packages/preact-query/src/__tests__/useQuery.test-d.tsx
Normal file
@@ -0,0 +1,334 @@
|
||||
import { queryKey } from '@tanstack/query-test-utils'
|
||||
import { describe, expectTypeOf, it } from 'vitest'
|
||||
|
||||
import type { OmitKeyof, QueryFunction, UseQueryOptions } from '..'
|
||||
import { queryOptions } from '../queryOptions'
|
||||
import { useQuery } from '../useQuery'
|
||||
|
||||
describe('useQuery', () => {
|
||||
const key = queryKey()
|
||||
|
||||
// unspecified query function should default to unknown
|
||||
const noQueryFn = useQuery({ queryKey: key })
|
||||
expectTypeOf(noQueryFn.data).toEqualTypeOf<unknown>()
|
||||
expectTypeOf(noQueryFn.error).toEqualTypeOf<Error | null>()
|
||||
|
||||
// it should infer the result type from the query function
|
||||
const fromQueryFn = useQuery({ queryKey: key, queryFn: () => 'test' })
|
||||
expectTypeOf(fromQueryFn.data).toEqualTypeOf<string | undefined>()
|
||||
expectTypeOf(fromQueryFn.error).toEqualTypeOf<Error | null>()
|
||||
expectTypeOf(fromQueryFn).not.toHaveProperty('promise')
|
||||
|
||||
// it should be possible to specify the result type
|
||||
const withResult = useQuery<string>({
|
||||
queryKey: key,
|
||||
queryFn: () => 'test',
|
||||
})
|
||||
expectTypeOf(withResult.data).toEqualTypeOf<string | undefined>()
|
||||
expectTypeOf(withResult.error).toEqualTypeOf<Error | null>()
|
||||
|
||||
// it should be possible to specify the error type
|
||||
const withError = useQuery<string, Error>({
|
||||
queryKey: key,
|
||||
queryFn: () => 'test',
|
||||
})
|
||||
expectTypeOf(withError.data).toEqualTypeOf<string | undefined>()
|
||||
expectTypeOf(withError.error).toEqualTypeOf<Error | null>()
|
||||
|
||||
// it should provide the result type in the configuration
|
||||
useQuery({
|
||||
queryKey: [key],
|
||||
queryFn: () => Promise.resolve(true),
|
||||
})
|
||||
|
||||
// it should be possible to specify a union type as result type
|
||||
const unionTypeSync = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () => (Math.random() > 0.5 ? ('a' as const) : ('b' as const)),
|
||||
})
|
||||
expectTypeOf(unionTypeSync.data).toEqualTypeOf<'a' | 'b' | undefined>()
|
||||
const unionTypeAsync = useQuery<'a' | 'b'>({
|
||||
queryKey: key,
|
||||
queryFn: () => Promise.resolve(Math.random() > 0.5 ? 'a' : 'b'),
|
||||
})
|
||||
expectTypeOf(unionTypeAsync.data).toEqualTypeOf<'a' | 'b' | undefined>()
|
||||
|
||||
// should error when the query function result does not match with the specified type
|
||||
// @ts-expect-error
|
||||
useQuery<number>({ queryKey: key, queryFn: () => 'test' })
|
||||
|
||||
// it should infer the result type from a generic query function
|
||||
function queryFn<T = string>(): Promise<T> {
|
||||
return Promise.resolve({} as T)
|
||||
}
|
||||
|
||||
const fromGenericQueryFn = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () => queryFn(),
|
||||
})
|
||||
expectTypeOf(fromGenericQueryFn.data).toEqualTypeOf<string | undefined>()
|
||||
expectTypeOf(fromGenericQueryFn.error).toEqualTypeOf<Error | null>()
|
||||
|
||||
type MyData = number
|
||||
type MyQueryKey = readonly ['my-data', number]
|
||||
|
||||
const getMyDataArrayKey: QueryFunction<MyData, MyQueryKey> = ({
|
||||
queryKey: [, n],
|
||||
}) => {
|
||||
return Promise.resolve(n + 42)
|
||||
}
|
||||
|
||||
useQuery({
|
||||
queryKey: ['my-data', 100],
|
||||
queryFn: getMyDataArrayKey,
|
||||
})
|
||||
|
||||
const getMyDataStringKey: QueryFunction<MyData, ['1']> = (context) => {
|
||||
expectTypeOf(context.queryKey).toEqualTypeOf<['1']>()
|
||||
return Promise.resolve(Number(context.queryKey[0]) + 42)
|
||||
}
|
||||
|
||||
useQuery({
|
||||
queryKey: ['1'],
|
||||
queryFn: getMyDataStringKey,
|
||||
})
|
||||
|
||||
// it should handle query-functions that return Promise<any>
|
||||
useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () => fetch('return Promise<any>').then((resp) => resp.json()),
|
||||
})
|
||||
|
||||
// handles wrapped queries with custom fetcher passed as inline queryFn
|
||||
const useWrappedQuery = <
|
||||
TQueryKey extends [string, Record<string, unknown>?],
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData = TQueryFnData,
|
||||
>(
|
||||
qk: TQueryKey,
|
||||
fetcher: (
|
||||
obj: TQueryKey[1],
|
||||
token: string,
|
||||
// return type must be wrapped with TQueryFnReturn
|
||||
) => Promise<TQueryFnData>,
|
||||
options?: OmitKeyof<
|
||||
UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
|
||||
'queryKey' | 'queryFn' | 'initialData'
|
||||
>,
|
||||
) =>
|
||||
useQuery({
|
||||
queryKey: qk,
|
||||
queryFn: () => fetcher(qk[1], 'token'),
|
||||
...options,
|
||||
})
|
||||
const testQuery = useWrappedQuery([''], () => Promise.resolve('1'))
|
||||
expectTypeOf(testQuery.data).toEqualTypeOf<string | undefined>()
|
||||
|
||||
// handles wrapped queries with custom fetcher passed directly to useQuery
|
||||
const useWrappedFuncStyleQuery = <
|
||||
TQueryKey extends [string, Record<string, unknown>?],
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData = TQueryFnData,
|
||||
>(
|
||||
qk: TQueryKey,
|
||||
fetcher: () => Promise<TQueryFnData>,
|
||||
options?: OmitKeyof<
|
||||
UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
|
||||
'queryKey' | 'queryFn' | 'initialData'
|
||||
>,
|
||||
) => useQuery({ queryKey: qk, queryFn: fetcher, ...options })
|
||||
const testFuncStyle = useWrappedFuncStyleQuery([''], () =>
|
||||
Promise.resolve(true),
|
||||
)
|
||||
expectTypeOf(testFuncStyle.data).toEqualTypeOf<boolean | undefined>()
|
||||
|
||||
it('should return the correct states for a successful query', () => {
|
||||
const state = useQuery<string, Error>({
|
||||
queryKey: key,
|
||||
queryFn: () => Promise.resolve('test'),
|
||||
})
|
||||
|
||||
if (state.isPending) {
|
||||
expectTypeOf(state.data).toEqualTypeOf<undefined>()
|
||||
expectTypeOf(state.error).toEqualTypeOf<null>()
|
||||
return <span>pending</span>
|
||||
}
|
||||
|
||||
if (state.isLoadingError) {
|
||||
expectTypeOf(state.data).toEqualTypeOf<undefined>()
|
||||
expectTypeOf(state.error).toEqualTypeOf<Error>()
|
||||
return <span>{state.error.message}</span>
|
||||
}
|
||||
|
||||
expectTypeOf(state.data).toEqualTypeOf<string>()
|
||||
expectTypeOf(state.error).toEqualTypeOf<Error | null>()
|
||||
return <span>{state.data}</span>
|
||||
})
|
||||
|
||||
describe('initialData', () => {
|
||||
describe('Config object overload', () => {
|
||||
it('TData should always be defined when initialData is provided as an object', () => {
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => ({ wow: true }),
|
||||
initialData: { wow: true },
|
||||
})
|
||||
|
||||
expectTypeOf(data).toEqualTypeOf<{ wow: boolean }>()
|
||||
})
|
||||
|
||||
it('TData should be defined when passed through queryOptions', () => {
|
||||
const options = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => {
|
||||
return {
|
||||
wow: true,
|
||||
}
|
||||
},
|
||||
initialData: {
|
||||
wow: true,
|
||||
},
|
||||
})
|
||||
const { data } = useQuery(options)
|
||||
|
||||
expectTypeOf(data).toEqualTypeOf<{ wow: boolean }>()
|
||||
})
|
||||
|
||||
it('should be possible to define a different TData than TQueryFnData using select with queryOptions spread into useQuery', () => {
|
||||
const options = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(1),
|
||||
})
|
||||
|
||||
const query = useQuery({
|
||||
...options,
|
||||
select: (data) => data > 1,
|
||||
})
|
||||
|
||||
expectTypeOf(query.data).toEqualTypeOf<boolean | undefined>()
|
||||
})
|
||||
|
||||
it('TData should always be defined when initialData is provided as a function which ALWAYS returns the data', () => {
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => {
|
||||
return {
|
||||
wow: true,
|
||||
}
|
||||
},
|
||||
initialData: () => ({
|
||||
wow: true,
|
||||
}),
|
||||
})
|
||||
|
||||
expectTypeOf(data).toEqualTypeOf<{ wow: boolean }>()
|
||||
})
|
||||
|
||||
it('TData should have undefined in the union when initialData is NOT provided', () => {
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => {
|
||||
return {
|
||||
wow: true,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
expectTypeOf(data).toEqualTypeOf<{ wow: boolean } | undefined>()
|
||||
})
|
||||
|
||||
it('TData should have undefined in the union when initialData is provided as a function which can return undefined', () => {
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => {
|
||||
return {
|
||||
wow: true,
|
||||
}
|
||||
},
|
||||
initialData: () => undefined as { wow: boolean } | undefined,
|
||||
})
|
||||
|
||||
expectTypeOf(data).toEqualTypeOf<{ wow: boolean } | undefined>()
|
||||
})
|
||||
|
||||
it('TData should be narrowed after an isSuccess check when initialData is provided as a function which can return undefined', () => {
|
||||
const { data, isSuccess } = useQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => {
|
||||
return {
|
||||
wow: true,
|
||||
}
|
||||
},
|
||||
initialData: () => undefined as { wow: boolean } | undefined,
|
||||
})
|
||||
|
||||
if (isSuccess) {
|
||||
expectTypeOf(data).toEqualTypeOf<{ wow: boolean }>()
|
||||
}
|
||||
})
|
||||
|
||||
it('data should not have undefined when initialData is provided', () => {
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKey(),
|
||||
initialData: 42,
|
||||
})
|
||||
|
||||
expectTypeOf(data).toEqualTypeOf<number>()
|
||||
})
|
||||
})
|
||||
|
||||
it('should preserve discriminated-union narrowing', () => {
|
||||
type Result =
|
||||
| { type: 'first'; first: string }
|
||||
| { type: 'second'; second: string }
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: (): Result => ({ type: 'first', first: 'a' }),
|
||||
})
|
||||
|
||||
const second = query.data?.type === 'first' ? undefined : query.data
|
||||
|
||||
expectTypeOf(second).toEqualTypeOf<
|
||||
{ type: 'second'; second: string } | undefined
|
||||
>()
|
||||
})
|
||||
|
||||
describe('custom hook', () => {
|
||||
it('should allow custom hooks using UseQueryOptions', () => {
|
||||
type Data = string
|
||||
|
||||
const useCustomQuery = (
|
||||
options?: OmitKeyof<UseQueryOptions<Data>, 'queryKey' | 'queryFn'>,
|
||||
) => {
|
||||
return useQuery({
|
||||
...options,
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve('data'),
|
||||
})
|
||||
}
|
||||
|
||||
const { data } = useCustomQuery()
|
||||
|
||||
expectTypeOf(data).toEqualTypeOf<Data | undefined>()
|
||||
})
|
||||
})
|
||||
|
||||
describe('structuralSharing', () => {
|
||||
it('should be able to use structuralSharing with unknown types', () => {
|
||||
// https://github.com/TanStack/query/issues/6525#issuecomment-1938411343
|
||||
useQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => 5,
|
||||
structuralSharing: (oldData, newData) => {
|
||||
expectTypeOf(oldData).toBeUnknown()
|
||||
expectTypeOf(newData).toBeUnknown()
|
||||
return newData
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
6979
packages/preact-query/src/__tests__/useQuery.test.tsx
Normal file
6979
packages/preact-query/src/__tests__/useQuery.test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,108 @@
|
||||
import { skipToken } from '@tanstack/query-core'
|
||||
import type { InfiniteData } from '@tanstack/query-core'
|
||||
import { queryKey } from '@tanstack/query-test-utils'
|
||||
import { assertType, describe, expectTypeOf, it } from 'vitest'
|
||||
|
||||
import type { UseSuspenseInfiniteQueryOptions } from '../types'
|
||||
import { useSuspenseInfiniteQuery } from '../useSuspenseInfiniteQuery'
|
||||
|
||||
describe('useSuspenseInfiniteQuery', () => {
|
||||
it('should always have data defined', () => {
|
||||
const { data } = useSuspenseInfiniteQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: () => 1,
|
||||
})
|
||||
|
||||
expectTypeOf(data).toEqualTypeOf<InfiniteData<number, unknown>>()
|
||||
})
|
||||
|
||||
it('should not allow skipToken in queryFn', () => {
|
||||
assertType(
|
||||
useSuspenseInfiniteQuery({
|
||||
queryKey: queryKey(),
|
||||
// @ts-expect-error
|
||||
queryFn: skipToken,
|
||||
}),
|
||||
)
|
||||
|
||||
assertType(
|
||||
useSuspenseInfiniteQuery({
|
||||
queryKey: queryKey(),
|
||||
// @ts-expect-error
|
||||
queryFn: Math.random() > 0.5 ? skipToken : () => Promise.resolve(5),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should not have pending status', () => {
|
||||
const { status } = useSuspenseInfiniteQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: () => 1,
|
||||
})
|
||||
|
||||
expectTypeOf(status).toEqualTypeOf<'error' | 'success'>()
|
||||
})
|
||||
|
||||
it('should not allow placeholderData, enabled or throwOnError props', () => {
|
||||
assertType(
|
||||
useSuspenseInfiniteQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: () => 1,
|
||||
// @ts-expect-error TS2345
|
||||
placeholderData: 5,
|
||||
enabled: true,
|
||||
}),
|
||||
)
|
||||
|
||||
assertType(
|
||||
useSuspenseInfiniteQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: () => 1,
|
||||
// @ts-expect-error TS2345
|
||||
enabled: true,
|
||||
}),
|
||||
)
|
||||
|
||||
assertType(
|
||||
useSuspenseInfiniteQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: () => 1,
|
||||
// @ts-expect-error TS2345
|
||||
throwOnError: true,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should default TData of UseSuspenseInfiniteQueryOptions to InfiniteData<TQueryFnData>', () => {
|
||||
const options: UseSuspenseInfiniteQueryOptions<number, Error> = {
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: () => 1,
|
||||
}
|
||||
const { data } = useSuspenseInfiniteQuery(options)
|
||||
|
||||
expectTypeOf(data).toEqualTypeOf<InfiniteData<number, unknown>>()
|
||||
})
|
||||
|
||||
it('should not return isPlaceholderData', () => {
|
||||
const query = useSuspenseInfiniteQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: () => 1,
|
||||
})
|
||||
|
||||
expectTypeOf(query).not.toHaveProperty('isPlaceholderData')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
import { queryKey } from '@tanstack/query-test-utils'
|
||||
import { Suspense } from 'preact/compat'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
QueryCache,
|
||||
QueryClient,
|
||||
skipToken,
|
||||
useSuspenseInfiniteQuery,
|
||||
} from '..'
|
||||
import { renderWithClient } from './utils'
|
||||
|
||||
describe('useSuspenseInfiniteQuery', () => {
|
||||
let queryCache: QueryCache
|
||||
let queryClient: QueryClient
|
||||
|
||||
beforeEach(() => {
|
||||
queryCache = new QueryCache()
|
||||
queryClient = new QueryClient({ queryCache })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
queryClient.clear()
|
||||
})
|
||||
|
||||
it('should log an error when skipToken is passed as queryFn', () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {})
|
||||
const key = queryKey()
|
||||
|
||||
function Page() {
|
||||
useSuspenseInfiniteQuery({
|
||||
queryKey: key,
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: () => 1,
|
||||
// @ts-expect-error
|
||||
queryFn: Math.random() >= 0 ? skipToken : () => Promise.resolve(5),
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Suspense fallback="Loading...">
|
||||
<Page />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
renderWithClient(queryClient, <App />)
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'skipToken is not allowed for useSuspenseInfiniteQuery',
|
||||
)
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should log an error when skipToken is used in development environment', () => {
|
||||
const envCopy = process.env.NODE_ENV
|
||||
process.env.NODE_ENV = 'development'
|
||||
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined)
|
||||
const key = queryKey()
|
||||
|
||||
function Page() {
|
||||
useSuspenseInfiniteQuery({
|
||||
queryKey: key,
|
||||
queryFn: skipToken as any,
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: () => 1,
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
renderWithClient(
|
||||
queryClient,
|
||||
<Suspense fallback="Loading...">
|
||||
<Page />
|
||||
</Suspense>,
|
||||
)
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'skipToken is not allowed for useSuspenseInfiniteQuery',
|
||||
)
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
process.env.NODE_ENV = envCopy
|
||||
})
|
||||
|
||||
it('should not log an error when skipToken is used in production environment', () => {
|
||||
const envCopy = process.env.NODE_ENV
|
||||
process.env.NODE_ENV = 'production'
|
||||
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined)
|
||||
const key = queryKey()
|
||||
|
||||
function Page() {
|
||||
useSuspenseInfiniteQuery({
|
||||
queryKey: key,
|
||||
queryFn: skipToken as any,
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: () => 1,
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
renderWithClient(
|
||||
queryClient,
|
||||
<Suspense fallback="Loading...">
|
||||
<Page />
|
||||
</Suspense>,
|
||||
)
|
||||
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled()
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
process.env.NODE_ENV = envCopy
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,258 @@
|
||||
import { queryKey } from '@tanstack/query-test-utils'
|
||||
import { assertType, describe, expectTypeOf, it } from 'vitest'
|
||||
|
||||
import { skipToken, useSuspenseQueries } from '..'
|
||||
import type { OmitKeyof } from '..'
|
||||
import { queryOptions } from '../queryOptions'
|
||||
import type { UseQueryOptions, UseSuspenseQueryResult } from '../types'
|
||||
|
||||
describe('UseSuspenseQueries config object overload', () => {
|
||||
it('TData should always be defined', () => {
|
||||
const query1 = {
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => {
|
||||
return {
|
||||
wow: true,
|
||||
}
|
||||
},
|
||||
initialData: {
|
||||
wow: false,
|
||||
},
|
||||
}
|
||||
|
||||
const query2 = {
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => 'Query Data',
|
||||
}
|
||||
|
||||
const queryResults = useSuspenseQueries({ queries: [query1, query2] })
|
||||
|
||||
const query1Data = queryResults[0].data
|
||||
const query2Data = queryResults[1].data
|
||||
|
||||
expectTypeOf(query1Data).toEqualTypeOf<{ wow: boolean }>()
|
||||
expectTypeOf(query2Data).toEqualTypeOf<string>()
|
||||
})
|
||||
|
||||
it('TData should be defined when passed through queryOptions', () => {
|
||||
const options = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => {
|
||||
return {
|
||||
wow: true,
|
||||
}
|
||||
},
|
||||
})
|
||||
const queryResults = useSuspenseQueries({ queries: [options] })
|
||||
|
||||
const data = queryResults[0].data
|
||||
|
||||
expectTypeOf(data).toEqualTypeOf<{ wow: boolean }>()
|
||||
})
|
||||
|
||||
it('should be possible to define a different TData than TQueryFnData using select with queryOptions spread into useQuery', () => {
|
||||
const query1 = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(1),
|
||||
select: (data) => data > 1,
|
||||
})
|
||||
|
||||
const query2 = {
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(1),
|
||||
select: (data: number) => data > 1,
|
||||
}
|
||||
|
||||
const queryResults = useSuspenseQueries({ queries: [query1, query2] })
|
||||
const query1Data = queryResults[0].data
|
||||
const query2Data = queryResults[1].data
|
||||
|
||||
expectTypeOf(query1Data).toEqualTypeOf<boolean>()
|
||||
expectTypeOf(query2Data).toEqualTypeOf<boolean>()
|
||||
})
|
||||
|
||||
it('TData should have undefined in the union when initialData is provided as a function which can return undefined', () => {
|
||||
const queryResults = useSuspenseQueries({
|
||||
queries: [
|
||||
{
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => {
|
||||
return {
|
||||
wow: true,
|
||||
}
|
||||
},
|
||||
initialData: () => undefined as { wow: boolean } | undefined,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const data = queryResults[0].data
|
||||
|
||||
expectTypeOf(data).toEqualTypeOf<{ wow: boolean }>()
|
||||
})
|
||||
|
||||
it('should not allow skipToken in queryFn', () => {
|
||||
assertType(
|
||||
useSuspenseQueries({
|
||||
queries: [
|
||||
{
|
||||
queryKey: queryKey(),
|
||||
// @ts-expect-error
|
||||
queryFn: skipToken,
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
assertType(
|
||||
useSuspenseQueries({
|
||||
queries: [
|
||||
{
|
||||
queryKey: queryKey(),
|
||||
// @ts-expect-error
|
||||
queryFn: Math.random() > 0.5 ? skipToken : () => Promise.resolve(5),
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('TData should have correct type when conditional skipToken is passed', () => {
|
||||
const queryResults = useSuspenseQueries({
|
||||
queries: [
|
||||
{
|
||||
queryKey: queryKey(),
|
||||
// @ts-expect-error
|
||||
queryFn: Math.random() > 0.5 ? skipToken : () => Promise.resolve(5),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const firstResult = queryResults[0]
|
||||
|
||||
expectTypeOf(firstResult).toEqualTypeOf<
|
||||
UseSuspenseQueryResult<number, Error>
|
||||
>()
|
||||
expectTypeOf(firstResult.data).toEqualTypeOf<number>()
|
||||
})
|
||||
|
||||
describe('custom hook', () => {
|
||||
it('should allow custom hooks using UseQueryOptions', () => {
|
||||
type Data = string
|
||||
|
||||
const useCustomQueries = (
|
||||
options?: OmitKeyof<UseQueryOptions<Data>, 'queryKey' | 'queryFn'>,
|
||||
) => {
|
||||
return useSuspenseQueries({
|
||||
queries: [
|
||||
{
|
||||
...options,
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve('data'),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const queryResults = useCustomQueries()
|
||||
const data = queryResults[0].data
|
||||
|
||||
expectTypeOf(data).toEqualTypeOf<Data>()
|
||||
})
|
||||
})
|
||||
|
||||
it('should return correct data for dynamic queries with mixed result types', () => {
|
||||
const Queries1 = {
|
||||
get: () =>
|
||||
queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(1),
|
||||
}),
|
||||
}
|
||||
const Queries2 = {
|
||||
get: () =>
|
||||
queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(true),
|
||||
}),
|
||||
}
|
||||
|
||||
const queries1List = [1, 2, 3].map(() => ({ ...Queries1.get() }))
|
||||
const result = useSuspenseQueries({
|
||||
queries: [
|
||||
...queries1List,
|
||||
{
|
||||
...Queries2.get(),
|
||||
select(data: boolean) {
|
||||
return data
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expectTypeOf(result).toEqualTypeOf<
|
||||
[
|
||||
...Array<UseSuspenseQueryResult<number, Error>>,
|
||||
UseSuspenseQueryResult<boolean, Error>,
|
||||
]
|
||||
>()
|
||||
})
|
||||
|
||||
it('queryOptions with initialData works on useSuspenseQueries', () => {
|
||||
const query1 = queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => 'Query Data',
|
||||
initialData: 'initial data',
|
||||
})
|
||||
|
||||
const queryResults = useSuspenseQueries({ queries: [query1] })
|
||||
const query1Data = queryResults[0].data
|
||||
|
||||
expectTypeOf(query1Data).toEqualTypeOf<string>()
|
||||
})
|
||||
|
||||
it('queryOptions with skipToken in queryFn should not work on useSuspenseQueries', () => {
|
||||
assertType(
|
||||
useSuspenseQueries({
|
||||
queries: [
|
||||
// @ts-expect-error
|
||||
queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: Math.random() > 0.5 ? skipToken : () => Promise.resolve(5),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
assertType(
|
||||
useSuspenseQueries({
|
||||
queries: [
|
||||
// @ts-expect-error
|
||||
queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: Math.random() > 0.5 ? skipToken : () => Promise.resolve(5),
|
||||
initialData: 5,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should not show type error when using rest queryOptions', () => {
|
||||
assertType(
|
||||
useSuspenseQueries({
|
||||
queries: [
|
||||
{
|
||||
...queryOptions({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => 'Query Data',
|
||||
}),
|
||||
select(data: string) {
|
||||
return data
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
1326
packages/preact-query/src/__tests__/useSuspenseQueries.test.tsx
Normal file
1326
packages/preact-query/src/__tests__/useSuspenseQueries.test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,90 @@
|
||||
import { skipToken } from '@tanstack/query-core'
|
||||
import { queryKey } from '@tanstack/query-test-utils'
|
||||
import { assertType, describe, expectTypeOf, it } from 'vitest'
|
||||
|
||||
import { useSuspenseQuery } from '../useSuspenseQuery'
|
||||
|
||||
describe('useSuspenseQuery', () => {
|
||||
it('should always have data defined', () => {
|
||||
const { data } = useSuspenseQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
})
|
||||
|
||||
expectTypeOf(data).toEqualTypeOf<number>()
|
||||
})
|
||||
|
||||
it('should not have pending status', () => {
|
||||
const { status } = useSuspenseQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
})
|
||||
|
||||
expectTypeOf(status).toEqualTypeOf<'error' | 'success'>()
|
||||
})
|
||||
|
||||
it('should not allow skipToken in queryFn', () => {
|
||||
assertType(
|
||||
useSuspenseQuery({
|
||||
queryKey: queryKey(),
|
||||
// @ts-expect-error
|
||||
queryFn: skipToken,
|
||||
}),
|
||||
)
|
||||
assertType(
|
||||
useSuspenseQuery({
|
||||
queryKey: queryKey(),
|
||||
// @ts-expect-error
|
||||
queryFn: Math.random() > 0.5 ? skipToken : () => Promise.resolve(5),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should not allow placeholderData, enabled or throwOnError props', () => {
|
||||
assertType(
|
||||
useSuspenseQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
// @ts-expect-error TS2345
|
||||
placeholderData: 5,
|
||||
enabled: true,
|
||||
}),
|
||||
)
|
||||
assertType(
|
||||
useSuspenseQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
// @ts-expect-error TS2345
|
||||
enabled: true,
|
||||
}),
|
||||
)
|
||||
assertType(
|
||||
useSuspenseQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
// @ts-expect-error TS2345
|
||||
throwOnError: true,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should not return isPlaceholderData', () => {
|
||||
expectTypeOf(
|
||||
useSuspenseQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
}),
|
||||
).not.toHaveProperty('isPlaceholderData')
|
||||
})
|
||||
|
||||
it('should type-narrow the error field', () => {
|
||||
const query = useSuspenseQuery({
|
||||
queryKey: queryKey(),
|
||||
queryFn: () => Promise.resolve(5),
|
||||
})
|
||||
|
||||
if (query.status === 'error') {
|
||||
expectTypeOf(query.error).toEqualTypeOf<Error>()
|
||||
}
|
||||
})
|
||||
})
|
||||
1183
packages/preact-query/src/__tests__/useSuspenseQuery.test.tsx
Normal file
1183
packages/preact-query/src/__tests__/useSuspenseQuery.test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
66
packages/preact-query/src/__tests__/utils.tsx
Normal file
66
packages/preact-query/src/__tests__/utils.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { environmentManager, isServer } from '@tanstack/query-core'
|
||||
import { act, render } from '@testing-library/preact'
|
||||
import type { ComponentChildren, VNode } from 'preact'
|
||||
import { useEffect, useState } from 'preact/hooks'
|
||||
import { vi } from 'vitest'
|
||||
import type { MockInstance } from 'vitest'
|
||||
|
||||
import { QueryClientProvider, onlineManager } from '..'
|
||||
import type { QueryClient } from '..'
|
||||
|
||||
export function renderWithClient(
|
||||
client: QueryClient,
|
||||
ui: VNode,
|
||||
): ReturnType<typeof render> {
|
||||
const { rerender, ...result } = render(
|
||||
<QueryClientProvider client={client}>{ui}</QueryClientProvider>,
|
||||
)
|
||||
return {
|
||||
...result,
|
||||
rerender: (rerenderUi: VNode) =>
|
||||
rerender(
|
||||
<QueryClientProvider client={client}>{rerenderUi}</QueryClientProvider>,
|
||||
),
|
||||
} as any
|
||||
}
|
||||
|
||||
export function Blink({
|
||||
duration,
|
||||
children,
|
||||
}: {
|
||||
duration: number
|
||||
children: ComponentChildren
|
||||
}) {
|
||||
const [shouldShow, setShouldShow] = useState<boolean>(true)
|
||||
|
||||
useEffect(() => {
|
||||
setShouldShow(true)
|
||||
const timeout = setActTimeout(() => setShouldShow(false), duration)
|
||||
return () => {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}, [duration, children])
|
||||
|
||||
return shouldShow ? <>{children}</> : <>off</>
|
||||
}
|
||||
|
||||
export function mockOnlineManagerIsOnline(
|
||||
value: boolean,
|
||||
): MockInstance<() => boolean> {
|
||||
return vi.spyOn(onlineManager, 'isOnline').mockReturnValue(value)
|
||||
}
|
||||
|
||||
export function setActTimeout(fn: () => void, ms?: number) {
|
||||
return setTimeout(() => {
|
||||
act(() => {
|
||||
fn()
|
||||
})
|
||||
}, ms)
|
||||
}
|
||||
|
||||
export function setIsServer(value: boolean) {
|
||||
environmentManager.setIsServer(() => value)
|
||||
return () => {
|
||||
environmentManager.setIsServer(() => isServer)
|
||||
}
|
||||
}
|
||||
78
packages/preact-query/src/errorBoundaryUtils.ts
Normal file
78
packages/preact-query/src/errorBoundaryUtils.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { shouldThrowError } from '@tanstack/query-core'
|
||||
import type {
|
||||
DefaultedQueryObserverOptions,
|
||||
Query,
|
||||
QueryKey,
|
||||
QueryObserverResult,
|
||||
ThrowOnError,
|
||||
} from '@tanstack/query-core'
|
||||
import { useEffect } from 'preact/hooks'
|
||||
|
||||
import type { QueryErrorResetBoundaryValue } from './QueryErrorResetBoundary'
|
||||
|
||||
export const ensurePreventErrorBoundaryRetry = <
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryData,
|
||||
TQueryKey extends QueryKey,
|
||||
>(
|
||||
options: DefaultedQueryObserverOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryData,
|
||||
TQueryKey
|
||||
>,
|
||||
errorResetBoundary: QueryErrorResetBoundaryValue,
|
||||
query: Query<TQueryFnData, TError, TQueryData, TQueryKey> | undefined,
|
||||
) => {
|
||||
const throwOnError =
|
||||
query?.state.error && typeof options.throwOnError === 'function'
|
||||
? shouldThrowError(options.throwOnError, [query.state.error, query])
|
||||
: options.throwOnError
|
||||
|
||||
if (options.suspense || throwOnError) {
|
||||
// Prevent retrying failed query if the error boundary has not been reset yet
|
||||
if (!errorResetBoundary.isReset()) {
|
||||
options.retryOnMount = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const useClearResetErrorBoundary = (
|
||||
errorResetBoundary: QueryErrorResetBoundaryValue,
|
||||
) => {
|
||||
useEffect(() => {
|
||||
errorResetBoundary.clearReset()
|
||||
}, [errorResetBoundary])
|
||||
}
|
||||
|
||||
export const getHasError = <
|
||||
TData,
|
||||
TError,
|
||||
TQueryFnData,
|
||||
TQueryData,
|
||||
TQueryKey extends QueryKey,
|
||||
>({
|
||||
result,
|
||||
errorResetBoundary,
|
||||
throwOnError,
|
||||
query,
|
||||
suspense,
|
||||
}: {
|
||||
result: QueryObserverResult<TData, TError>
|
||||
errorResetBoundary: QueryErrorResetBoundaryValue
|
||||
throwOnError: ThrowOnError<TQueryFnData, TError, TQueryData, TQueryKey>
|
||||
query: Query<TQueryFnData, TError, TQueryData, TQueryKey> | undefined
|
||||
suspense: boolean | undefined
|
||||
}) => {
|
||||
return (
|
||||
result.isError &&
|
||||
!errorResetBoundary.isReset() &&
|
||||
!result.isFetching &&
|
||||
query &&
|
||||
((suspense && result.data === undefined) ||
|
||||
shouldThrowError(throwOnError, [result.error, query]))
|
||||
)
|
||||
}
|
||||
56
packages/preact-query/src/index.ts
Normal file
56
packages/preact-query/src/index.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/* istanbul ignore file */
|
||||
|
||||
// Re-export core
|
||||
export * from '@tanstack/query-core'
|
||||
|
||||
// Preact Query
|
||||
export * from './types'
|
||||
export { useQueries } from './useQueries'
|
||||
export type { QueriesResults, QueriesOptions } from './useQueries'
|
||||
export { useQuery } from './useQuery'
|
||||
export { useSuspenseQuery } from './useSuspenseQuery'
|
||||
export { useSuspenseInfiniteQuery } from './useSuspenseInfiniteQuery'
|
||||
export { useSuspenseQueries } from './useSuspenseQueries'
|
||||
export type {
|
||||
SuspenseQueriesResults,
|
||||
SuspenseQueriesOptions,
|
||||
} from './useSuspenseQueries'
|
||||
export { usePrefetchQuery } from './usePrefetchQuery'
|
||||
export { usePrefetchInfiniteQuery } from './usePrefetchInfiniteQuery'
|
||||
export { queryOptions } from './queryOptions'
|
||||
export type {
|
||||
DefinedInitialDataOptions,
|
||||
UndefinedInitialDataOptions,
|
||||
UnusedSkipTokenOptions,
|
||||
} from './queryOptions'
|
||||
export { infiniteQueryOptions } from './infiniteQueryOptions'
|
||||
export type {
|
||||
DefinedInitialDataInfiniteOptions,
|
||||
UndefinedInitialDataInfiniteOptions,
|
||||
UnusedSkipTokenInfiniteOptions,
|
||||
} from './infiniteQueryOptions'
|
||||
export {
|
||||
QueryClientContext,
|
||||
QueryClientProvider,
|
||||
useQueryClient,
|
||||
} from './QueryClientProvider'
|
||||
export type { QueryClientProviderProps } from './QueryClientProvider'
|
||||
export type { QueryErrorResetBoundaryProps } from './QueryErrorResetBoundary'
|
||||
export { HydrationBoundary } from './HydrationBoundary'
|
||||
export type { HydrationBoundaryProps } from './HydrationBoundary'
|
||||
export type {
|
||||
QueryErrorClearResetFunction,
|
||||
QueryErrorIsResetFunction,
|
||||
QueryErrorResetBoundaryFunction,
|
||||
QueryErrorResetFunction,
|
||||
} from './QueryErrorResetBoundary'
|
||||
export {
|
||||
QueryErrorResetBoundary,
|
||||
useQueryErrorResetBoundary,
|
||||
} from './QueryErrorResetBoundary'
|
||||
export { useIsFetching } from './useIsFetching'
|
||||
export { useIsMutating, useMutationState } from './useMutationState'
|
||||
export { useMutation } from './useMutation'
|
||||
export { mutationOptions } from './mutationOptions'
|
||||
export { useInfiniteQuery } from './useInfiniteQuery'
|
||||
export { useIsRestoring, IsRestoringProvider } from './IsRestoringProvider'
|
||||
320
packages/preact-query/src/infiniteQueryOptions.ts
Normal file
320
packages/preact-query/src/infiniteQueryOptions.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
import type {
|
||||
DefaultError,
|
||||
InfiniteData,
|
||||
InitialDataFunction,
|
||||
NonUndefinedGuard,
|
||||
OmitKeyof,
|
||||
QueryKey,
|
||||
QueryKeyWithDataTag,
|
||||
SkipToken,
|
||||
} from '@tanstack/query-core'
|
||||
|
||||
import type { UseInfiniteQueryOptions } from './types'
|
||||
|
||||
/**
|
||||
* The options accepted by the `infiniteQueryOptions` overload selected when no `initialData` is set — `data`
|
||||
* may be `undefined` while the query is `pending`.
|
||||
*
|
||||
* @template TQueryFnData - The type of a single page, as your `queryFn` resolves it.
|
||||
* @template TError - The type of errors your `queryFn` may throw.
|
||||
* @template TData - The type `data` ends up as after `select` runs — defaults to `InfiniteData<TQueryFnData>`,
|
||||
* the shape of all fetched pages plus their page params.
|
||||
* @template TQueryKey - The type of your `queryKey`.
|
||||
* @template TPageParam - The type of the parameter passed to `queryFn` to fetch a given page.
|
||||
*/
|
||||
export type UndefinedInitialDataInfiniteOptions<
|
||||
TQueryFnData,
|
||||
TError = DefaultError,
|
||||
TData = InfiniteData<TQueryFnData>,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
TPageParam = unknown,
|
||||
> = UseInfiniteQueryOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey,
|
||||
TPageParam
|
||||
> & {
|
||||
/**
|
||||
* If set, this value will be used as the initial data for the query cache (as long as the query hasn't been
|
||||
* created or cached yet). If set to a function, the function will be called **once** during the shared/root
|
||||
* query initialization, and be expected to synchronously return the initial data. Initial data is
|
||||
* considered stale by default unless a `staleTime` has been set. `initialData` **is persisted** to the
|
||||
* cache.
|
||||
*/
|
||||
initialData?:
|
||||
| undefined
|
||||
| NonUndefinedGuard<InfiniteData<TQueryFnData, TPageParam>>
|
||||
| InitialDataFunction<
|
||||
NonUndefinedGuard<InfiniteData<TQueryFnData, TPageParam>>
|
||||
>
|
||||
}
|
||||
|
||||
/**
|
||||
* The options accepted by the `infiniteQueryOptions` overload selected when no `initialData` is set and
|
||||
* `queryFn` is not `skipToken` — same as {@link UndefinedInitialDataInfiniteOptions}, but `queryFn` may not be
|
||||
* `skipToken`.
|
||||
*
|
||||
* @template TQueryFnData - The type of a single page, as your `queryFn` resolves it.
|
||||
* @template TError - The type of errors your `queryFn` may throw.
|
||||
* @template TData - The type `data` ends up as after `select` runs — defaults to `InfiniteData<TQueryFnData>`,
|
||||
* the shape of all fetched pages plus their page params.
|
||||
* @template TQueryKey - The type of your `queryKey`.
|
||||
* @template TPageParam - The type of the parameter passed to `queryFn` to fetch a given page.
|
||||
*/
|
||||
export type UnusedSkipTokenInfiniteOptions<
|
||||
TQueryFnData,
|
||||
TError = DefaultError,
|
||||
TData = InfiniteData<TQueryFnData>,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
TPageParam = unknown,
|
||||
> = OmitKeyof<
|
||||
UseInfiniteQueryOptions<TQueryFnData, TError, TData, TQueryKey, TPageParam>,
|
||||
'queryFn'
|
||||
> & {
|
||||
/**
|
||||
* `skipToken` is not allowed as a value here — this overload is selected when no `initialData` is set. If
|
||||
* you don't intend to run the query yet, set `enabled: false` — omitting `queryFn` alone still triggers a
|
||||
* fetch that fails with "Missing queryFn" unless `enabled` is `false` or a default query function has been
|
||||
* defined. A default query function only supplies `queryFn`; it doesn't defer the fetch on its own.
|
||||
*/
|
||||
queryFn?: Exclude<
|
||||
UseInfiniteQueryOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey,
|
||||
TPageParam
|
||||
>['queryFn'],
|
||||
SkipToken | undefined
|
||||
>
|
||||
}
|
||||
|
||||
/**
|
||||
* The options accepted by the `infiniteQueryOptions` overload selected when `initialData` is set — `data` is
|
||||
* never `undefined`.
|
||||
*
|
||||
* @template TQueryFnData - The type of a single page, as your `queryFn` resolves it.
|
||||
* @template TError - The type of errors your `queryFn` may throw.
|
||||
* @template TData - The type `data` ends up as after `select` runs — defaults to `InfiniteData<TQueryFnData>`,
|
||||
* the shape of all fetched pages plus their page params.
|
||||
* @template TQueryKey - The type of your `queryKey`.
|
||||
* @template TPageParam - The type of the parameter passed to `queryFn` to fetch a given page.
|
||||
*/
|
||||
export type DefinedInitialDataInfiniteOptions<
|
||||
TQueryFnData,
|
||||
TError = DefaultError,
|
||||
TData = InfiniteData<TQueryFnData>,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
TPageParam = unknown,
|
||||
> = UseInfiniteQueryOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey,
|
||||
TPageParam
|
||||
> & {
|
||||
/**
|
||||
* If set, this value will be used as the initial data for the query cache (as long as the query hasn't been
|
||||
* created or cached yet). If set to a function, the function will be called **once** during the shared/root
|
||||
* query initialization, and be expected to synchronously return the initial data. Initial data is
|
||||
* considered stale by default unless a `staleTime` has been set. `initialData` **is persisted** to the
|
||||
* cache.
|
||||
*/
|
||||
initialData:
|
||||
| NonUndefinedGuard<InfiniteData<TQueryFnData, TPageParam>>
|
||||
| (() => NonUndefinedGuard<InfiniteData<TQueryFnData, TPageParam>>)
|
||||
| undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* You can generally pass everything to `infiniteQueryOptions` that you can also pass to `useInfiniteQuery`.
|
||||
* These options can be shared across hooks and imperative APIs such as `queryClient.infiniteQuery`.
|
||||
* `options.queryKey` is required and is the query key to generate options for.
|
||||
*
|
||||
* This overload is selected when `initialData` is set.
|
||||
*
|
||||
* @see {@link useInfiniteQuery} to run an infinite query with these options.
|
||||
* @param options - The {@link DefinedInitialDataInfiniteOptions} to use — everything you can pass to `useInfiniteQuery`, with `initialData` set.
|
||||
* @returns The same options object, typed so that `queryKey` carries the inferred data type.
|
||||
* @remarks See {@link useInfiniteQuery} for examples that fetch further pages, from a button click or
|
||||
* automatically as the user scrolls.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { infiniteQueryOptions, useInfiniteQuery } from '@tanstack/preact-query'
|
||||
*
|
||||
* export const projectsOptions = infiniteQueryOptions({
|
||||
* queryKey: ['projects'],
|
||||
* queryFn: ({ pageParam }) => fetchProjects(pageParam),
|
||||
* initialPageParam: 0,
|
||||
* getNextPageParam: (lastPage) => lastPage.nextId,
|
||||
* initialData: { pages: [], pageParams: [] },
|
||||
* })
|
||||
*
|
||||
* function Projects() {
|
||||
* // `data` is never `undefined`, thanks to `initialData` — even if a refetch fails, so the
|
||||
* // list stays visible alongside the error.
|
||||
* const { data, isError, error } = useInfiniteQuery(projectsOptions)
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* {isError ? <span>Error: {error.message}</span> : null}
|
||||
* <ul>
|
||||
* {data.pages.map((page) => page.projects.map((p) => <li key={p.id}>{p.name}</li>))}
|
||||
* </ul>
|
||||
* </div>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function infiniteQueryOptions<
|
||||
TQueryFnData,
|
||||
TError = DefaultError,
|
||||
TData = InfiniteData<TQueryFnData>,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
TPageParam = unknown,
|
||||
>(
|
||||
options: DefinedInitialDataInfiniteOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey,
|
||||
TPageParam
|
||||
>,
|
||||
): DefinedInitialDataInfiniteOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey,
|
||||
TPageParam
|
||||
> &
|
||||
QueryKeyWithDataTag<TQueryKey, InfiniteData<TQueryFnData>, TError>
|
||||
|
||||
/**
|
||||
* You can generally pass everything to `infiniteQueryOptions` that you can also pass to `useInfiniteQuery`.
|
||||
* These options can be shared across hooks and imperative APIs such as `queryClient.infiniteQuery`.
|
||||
* `options.queryKey` is required and is the query key to generate options for.
|
||||
*
|
||||
* @returns The same options object, typed so that `queryKey` carries the inferred data type.
|
||||
* @remarks See {@link useInfiniteQuery} for examples that fetch further pages, from a button click or
|
||||
* automatically as the user scrolls.
|
||||
*
|
||||
* @example
|
||||
* A parameterized factory, so the same options object can be reused per `postId`:
|
||||
* ```tsx
|
||||
* import { infiniteQueryOptions, useInfiniteQuery } from '@tanstack/preact-query'
|
||||
*
|
||||
* export const commentsOptions = (postId: string) =>
|
||||
* infiniteQueryOptions({
|
||||
* queryKey: ['post', postId, 'comments'],
|
||||
* queryFn: ({ pageParam }) => fetchComments(postId, pageParam),
|
||||
* initialPageParam: 0,
|
||||
* getNextPageParam: (lastPage) => lastPage.nextId,
|
||||
* })
|
||||
*
|
||||
* function Comments({ postId }: { postId: string }) {
|
||||
* const { data, isPending, isError, error } = useInfiniteQuery(commentsOptions(postId))
|
||||
*
|
||||
* if (isPending) return 'Loading...'
|
||||
* if (isError) return <span>Error: {error.message}</span>
|
||||
*
|
||||
* return (
|
||||
* <ul>
|
||||
* {data.pages.map((page) => page.comments.map((c) => <li key={c.id}>{c.text}</li>))}
|
||||
* </ul>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @see {@link useInfiniteQuery} to run an infinite query with these options.
|
||||
* @param options - The {@link UnusedSkipTokenInfiniteOptions} to use — everything you can pass to `useInfiniteQuery`.
|
||||
*/
|
||||
export function infiniteQueryOptions<
|
||||
TQueryFnData,
|
||||
TError = DefaultError,
|
||||
TData = InfiniteData<TQueryFnData>,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
TPageParam = unknown,
|
||||
>(
|
||||
options: UnusedSkipTokenInfiniteOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey,
|
||||
TPageParam
|
||||
>,
|
||||
): UnusedSkipTokenInfiniteOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey,
|
||||
TPageParam
|
||||
> &
|
||||
QueryKeyWithDataTag<TQueryKey, InfiniteData<TQueryFnData>, TError>
|
||||
|
||||
/**
|
||||
* You can generally pass everything to `infiniteQueryOptions` that you can also pass to `useInfiniteQuery`.
|
||||
* These options can be shared across hooks and imperative APIs such as `queryClient.infiniteQuery`.
|
||||
* `options.queryKey` is required and is the query key to generate options for.
|
||||
*
|
||||
* @returns The same options object, typed so that `queryKey` carries the inferred data type.
|
||||
* @remarks See {@link useInfiniteQuery} for examples that fetch further pages (from a button click or
|
||||
* automatically as the user scrolls) and that use `skipToken` to disable the query until `postId` is set.
|
||||
*
|
||||
* @example
|
||||
* A parameterized factory, so the same options object can be reused per `postId`:
|
||||
* ```tsx
|
||||
* import { infiniteQueryOptions, useInfiniteQuery } from '@tanstack/preact-query'
|
||||
*
|
||||
* export const commentsOptions = (postId: string) =>
|
||||
* infiniteQueryOptions({
|
||||
* queryKey: ['post', postId, 'comments'],
|
||||
* queryFn: ({ pageParam }) => fetchComments(postId, pageParam),
|
||||
* initialPageParam: 0,
|
||||
* getNextPageParam: (lastPage) => lastPage.nextId,
|
||||
* })
|
||||
*
|
||||
* function Comments({ postId }: { postId: string }) {
|
||||
* const { data, isPending, isError, error } = useInfiniteQuery(commentsOptions(postId))
|
||||
*
|
||||
* if (isPending) return 'Loading...'
|
||||
* if (isError) return <span>Error: {error.message}</span>
|
||||
*
|
||||
* return (
|
||||
* <ul>
|
||||
* {data.pages.map((page) => page.comments.map((c) => <li key={c.id}>{c.text}</li>))}
|
||||
* </ul>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @see {@link useInfiniteQuery} to run an infinite query with these options.
|
||||
* @param options - The {@link UndefinedInitialDataInfiniteOptions} to use — everything you can pass to `useInfiniteQuery`.
|
||||
*/
|
||||
export function infiniteQueryOptions<
|
||||
TQueryFnData,
|
||||
TError = DefaultError,
|
||||
TData = InfiniteData<TQueryFnData>,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
TPageParam = unknown,
|
||||
>(
|
||||
options: UndefinedInitialDataInfiniteOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey,
|
||||
TPageParam
|
||||
>,
|
||||
): UndefinedInitialDataInfiniteOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey,
|
||||
TPageParam
|
||||
> &
|
||||
QueryKeyWithDataTag<TQueryKey, InfiniteData<TQueryFnData>, TError>
|
||||
|
||||
export function infiniteQueryOptions(options: unknown) {
|
||||
return options
|
||||
}
|
||||
97
packages/preact-query/src/mutationOptions.ts
Normal file
97
packages/preact-query/src/mutationOptions.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import type { DefaultError, WithRequired } from '@tanstack/query-core'
|
||||
|
||||
import type { UseMutationOptions } from './types'
|
||||
|
||||
/**
|
||||
* You can generally pass everything to `mutationOptions` that you can also pass to `useMutation`. A
|
||||
* `mutationKey` is required on this overload so the mutation can be looked up later, e.g. with
|
||||
* `useMutationState`.
|
||||
*
|
||||
* @see {@link useMutation} to run the mutation these options describe.
|
||||
* @param options - The mutation options to use, identical to what you'd pass to `useMutation`, with a
|
||||
* required `mutationKey`.
|
||||
* @returns The same options object, unchanged.
|
||||
*
|
||||
* @example
|
||||
* Looking the mutation up elsewhere via its `mutationKey`, e.g. for a global "saving…" indicator:
|
||||
* ```tsx
|
||||
* import { mutationOptions, useMutationState } from '@tanstack/preact-query'
|
||||
*
|
||||
* const createPostOptions = mutationOptions({
|
||||
* mutationKey: ['posts', 'create'],
|
||||
* mutationFn: createPost,
|
||||
* })
|
||||
*
|
||||
* function SavingIndicator() {
|
||||
* const isCreatingPost = useMutationState({
|
||||
* filters: { mutationKey: createPostOptions.mutationKey, status: 'pending' },
|
||||
* }).length > 0
|
||||
*
|
||||
* return isCreatingPost ? <span>Saving…</span> : null
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function mutationOptions<
|
||||
TData = unknown,
|
||||
TError = DefaultError,
|
||||
TVariables = void,
|
||||
TOnMutateResult = unknown,
|
||||
>(
|
||||
options: WithRequired<
|
||||
UseMutationOptions<TData, TError, TVariables, TOnMutateResult>,
|
||||
'mutationKey'
|
||||
>,
|
||||
): WithRequired<
|
||||
UseMutationOptions<TData, TError, TVariables, TOnMutateResult>,
|
||||
'mutationKey'
|
||||
>
|
||||
/**
|
||||
* You can generally pass everything to `mutationOptions` that you can also pass to `useMutation`. No
|
||||
* `mutationKey` is required on this overload — use this when you don't need to target the mutation via a
|
||||
* `mutationKey` filter later (e.g. with `useMutationState`); it can still be observed through other filters,
|
||||
* such as `status`.
|
||||
*
|
||||
* @see {@link useMutation} to run the mutation these options describe.
|
||||
* @param options - The mutation options to use, identical to what you'd pass to `useMutation`, without a
|
||||
* `mutationKey`.
|
||||
* @returns The same options object, unchanged.
|
||||
* @remarks See the other overload's example for looking a mutation up via `useMutationState`.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { mutationOptions, useMutation } from '@tanstack/preact-query'
|
||||
*
|
||||
* export const createPostOptions = mutationOptions({
|
||||
* mutationFn: createPost,
|
||||
* })
|
||||
*
|
||||
* function CreatePost() {
|
||||
* const mutation = useMutation(createPostOptions)
|
||||
* return <button onClick={() => mutation.mutate({ title: 'Hello' })}>Create</button>
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function mutationOptions<
|
||||
TData = unknown,
|
||||
TError = DefaultError,
|
||||
TVariables = void,
|
||||
TOnMutateResult = unknown,
|
||||
>(
|
||||
options: Omit<
|
||||
UseMutationOptions<TData, TError, TVariables, TOnMutateResult>,
|
||||
'mutationKey'
|
||||
>,
|
||||
): Omit<
|
||||
UseMutationOptions<TData, TError, TVariables, TOnMutateResult>,
|
||||
'mutationKey'
|
||||
>
|
||||
export function mutationOptions<
|
||||
TData = unknown,
|
||||
TError = DefaultError,
|
||||
TVariables = void,
|
||||
TOnMutateResult = unknown,
|
||||
>(
|
||||
options: UseMutationOptions<TData, TError, TVariables, TOnMutateResult>,
|
||||
): UseMutationOptions<TData, TError, TVariables, TOnMutateResult> {
|
||||
return options
|
||||
}
|
||||
260
packages/preact-query/src/queryOptions.ts
Normal file
260
packages/preact-query/src/queryOptions.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import type {
|
||||
DefaultError,
|
||||
InitialDataFunction,
|
||||
NonUndefinedGuard,
|
||||
OmitKeyof,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
QueryKeyWithDataTag,
|
||||
SkipToken,
|
||||
} from '@tanstack/query-core'
|
||||
|
||||
import type { UseQueryOptions } from './types'
|
||||
|
||||
/**
|
||||
* The options accepted by the `queryOptions` overload selected when no `initialData` is set — `data` may be
|
||||
* `undefined` while the query is `pending`.
|
||||
*
|
||||
* @template TQueryFnData - The type your `queryFn` resolves to.
|
||||
* @template TError - The type of errors your `queryFn` may throw.
|
||||
* @template TData - The type `data` ends up as after `select` runs.
|
||||
* @template TQueryKey - The type of your `queryKey`.
|
||||
*/
|
||||
export type UndefinedInitialDataOptions<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
> = UseQueryOptions<TQueryFnData, TError, TData, TQueryKey> & {
|
||||
/**
|
||||
* If set, this value will be used as the initial data for the query cache (as long as the query hasn't been
|
||||
* created or cached yet). If set to a function, the function will be called **once** during the shared/root
|
||||
* query initialization, and be expected to synchronously return the initial data. Initial data is
|
||||
* considered stale by default unless a `staleTime` has been set. `initialData` **is persisted** to the
|
||||
* cache.
|
||||
*/
|
||||
initialData?:
|
||||
| undefined
|
||||
| InitialDataFunction<NonUndefinedGuard<TQueryFnData>>
|
||||
| NonUndefinedGuard<TQueryFnData>
|
||||
}
|
||||
|
||||
/**
|
||||
* The options accepted by the `queryOptions` overload selected when no `initialData` is set and `queryFn` is
|
||||
* not `skipToken` — same as {@link UndefinedInitialDataOptions}, but `queryFn` may not be `skipToken`.
|
||||
*
|
||||
* @template TQueryFnData - The type your `queryFn` resolves to.
|
||||
* @template TError - The type of errors your `queryFn` may throw.
|
||||
* @template TData - The type `data` ends up as after `select` runs.
|
||||
* @template TQueryKey - The type of your `queryKey`.
|
||||
*/
|
||||
export type UnusedSkipTokenOptions<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
> = OmitKeyof<
|
||||
UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
|
||||
'queryFn'
|
||||
> & {
|
||||
/**
|
||||
* `skipToken` is not allowed as a value here — this overload is selected when no `initialData` is set. If
|
||||
* you don't intend to run the query yet, set `enabled: false` — omitting `queryFn` alone still triggers a
|
||||
* fetch that fails with "Missing queryFn" unless `enabled` is `false` or a default query function has been
|
||||
* defined. A default query function only supplies `queryFn`; it doesn't defer the fetch on its own.
|
||||
*/
|
||||
queryFn?: Exclude<
|
||||
UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>['queryFn'],
|
||||
SkipToken | undefined
|
||||
>
|
||||
}
|
||||
|
||||
/**
|
||||
* The options accepted by the `queryOptions` overload selected when `initialData` is set — `data` is never
|
||||
* `undefined`.
|
||||
*
|
||||
* @template TQueryFnData - The type your `queryFn` resolves to.
|
||||
* @template TError - The type of errors your `queryFn` may throw.
|
||||
* @template TData - The type `data` ends up as after `select` runs.
|
||||
* @template TQueryKey - The type of your `queryKey`.
|
||||
*/
|
||||
export type DefinedInitialDataOptions<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
> = Omit<UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>, 'queryFn'> & {
|
||||
/**
|
||||
* If set, this value will be used as the initial data for the query cache (as long as the query hasn't been
|
||||
* created or cached yet). If set to a function, the function will be called **once** during the shared/root
|
||||
* query initialization, and be expected to synchronously return the initial data. Initial data is
|
||||
* considered stale by default unless a `staleTime` has been set. `initialData` **is persisted** to the
|
||||
* cache.
|
||||
*/
|
||||
initialData:
|
||||
| NonUndefinedGuard<TQueryFnData>
|
||||
| (() => NonUndefinedGuard<TQueryFnData>)
|
||||
/**
|
||||
* Optional here, but omitting it is only safe when no fetch will be attempted — for example with
|
||||
* `enabled: false`, or when a default query function has been defined. Otherwise, an enabled query with no
|
||||
* `queryFn` still tries to fetch and fails with a "Missing queryFn" error; `initialData` does not prevent this.
|
||||
*/
|
||||
queryFn?: QueryFunction<TQueryFnData, TQueryKey>
|
||||
}
|
||||
|
||||
/**
|
||||
* You can generally pass everything to `queryOptions` that you can also pass to `useQuery`. These options can
|
||||
* be shared across hooks and imperative APIs such as `queryClient.query`. `options.queryKey` is required and
|
||||
* is the query key to generate options for.
|
||||
*
|
||||
* This overload is selected when `initialData` is set, so the resulting `data` is never `undefined`.
|
||||
*
|
||||
* @see {@link useQuery} to run a query with these options.
|
||||
* @see [The Query Options API](https://tkdodo.eu/blog/the-query-options-api) for more on this pattern.
|
||||
* @param options - The {@link DefinedInitialDataOptions} to use — everything you can pass to `useQuery`, with `initialData` set.
|
||||
* @returns The same options object, typed so that `queryKey` carries the inferred data type.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { queryOptions, useQuery } from '@tanstack/preact-query'
|
||||
*
|
||||
* export const postsOptions = queryOptions({
|
||||
* queryKey: ['posts'],
|
||||
* queryFn: fetchPosts,
|
||||
* initialData: [],
|
||||
* })
|
||||
*
|
||||
* function Posts() {
|
||||
* // `data` is `Post[]`, never `undefined`, thanks to `initialData` — even if a refetch fails,
|
||||
* // so the list stays visible alongside the error.
|
||||
* const { data, isError, error } = useQuery(postsOptions)
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* {isError ? <span>Error: {error.message}</span> : null}
|
||||
* <ul>
|
||||
* {data.map((post) => <li key={post.id}>{post.title}</li>)}
|
||||
* </ul>
|
||||
* </div>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function queryOptions<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
>(
|
||||
options: DefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey>,
|
||||
): DefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey> &
|
||||
QueryKeyWithDataTag<TQueryKey, TQueryFnData, TError>
|
||||
|
||||
/**
|
||||
* You can generally pass everything to `queryOptions` that you can also pass to `useQuery`. These options can
|
||||
* be shared across hooks and imperative APIs such as `queryClient.query`. `options.queryKey` is required and
|
||||
* is the query key to generate options for.
|
||||
*
|
||||
* @see {@link useQuery} to run a query with these options.
|
||||
* @see [The Query Options API](https://tkdodo.eu/blog/the-query-options-api) for more on this pattern.
|
||||
* @param options - The {@link UnusedSkipTokenOptions} to use — everything you can pass to `useQuery`.
|
||||
* @returns The same options object, typed so that `queryKey` carries the inferred data type.
|
||||
*
|
||||
* @example
|
||||
* A parameterized factory, so the same options object can be reused per `id`:
|
||||
* ```tsx
|
||||
* import { queryOptions, useQuery } from '@tanstack/preact-query'
|
||||
*
|
||||
* export const postOptions = (id: string) =>
|
||||
* queryOptions({
|
||||
* queryKey: ['post', id],
|
||||
* queryFn: () => fetchPost(id),
|
||||
* })
|
||||
*
|
||||
* function Post({ id }: { id: string }) {
|
||||
* const { data, isPending, isError, error } = useQuery(postOptions(id))
|
||||
*
|
||||
* if (isPending) return 'Loading...'
|
||||
* if (isError) return <span>Error: {error.message}</span>
|
||||
*
|
||||
* return <h1>{data.title}</h1>
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function queryOptions<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
>(
|
||||
options: UnusedSkipTokenOptions<TQueryFnData, TError, TData, TQueryKey>,
|
||||
): UnusedSkipTokenOptions<TQueryFnData, TError, TData, TQueryKey> &
|
||||
QueryKeyWithDataTag<TQueryKey, TQueryFnData, TError>
|
||||
|
||||
/**
|
||||
* You can generally pass everything to `queryOptions` that you can also pass to `useQuery`. These options can
|
||||
* be shared across hooks and imperative APIs such as `queryClient.query`. `options.queryKey` is required and
|
||||
* is the query key to generate options for.
|
||||
*
|
||||
* @see {@link useQuery} to run a query with these options.
|
||||
* @see [The Query Options API](https://tkdodo.eu/blog/the-query-options-api) for more on this pattern.
|
||||
* @param options - The {@link UndefinedInitialDataOptions} to use — everything you can pass to `useQuery`.
|
||||
* @returns The same options object, typed so that `queryKey` carries the inferred data type.
|
||||
* @remarks This is the only overload that accepts `queryFn: skipToken`, shown below.
|
||||
*
|
||||
* @example
|
||||
* A parameterized factory, so the same options object can be reused per `id`:
|
||||
* ```tsx
|
||||
* import { queryOptions, useQuery } from '@tanstack/preact-query'
|
||||
*
|
||||
* export const postOptions = (id: string) =>
|
||||
* queryOptions({
|
||||
* queryKey: ['post', id],
|
||||
* queryFn: () => fetchPost(id),
|
||||
* })
|
||||
*
|
||||
* function Post({ id }: { id: string }) {
|
||||
* const { data, isPending, isError, error } = useQuery(postOptions(id))
|
||||
*
|
||||
* if (isPending) return 'Loading...'
|
||||
* if (isError) return <span>Error: {error.message}</span>
|
||||
*
|
||||
* return <h1>{data.title}</h1>
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* A factory that disables the query, type safe, until `postId` is set:
|
||||
* ```tsx
|
||||
* import { queryOptions, skipToken, useQuery } from '@tanstack/preact-query'
|
||||
*
|
||||
* export const postOptions = (postId: number | undefined) =>
|
||||
* queryOptions({
|
||||
* queryKey: ['post', postId],
|
||||
* queryFn: postId != null ? () => fetchPost(postId) : skipToken,
|
||||
* })
|
||||
*
|
||||
* function Post({ postId }: { postId: number | undefined }) {
|
||||
* const { data, isLoading, isError, error } = useQuery(postOptions(postId))
|
||||
*
|
||||
* if (postId == null) return 'Select a post'
|
||||
* if (isLoading) return 'Loading...'
|
||||
* if (isError) return <span>Error: {error.message}</span>
|
||||
*
|
||||
* return <h1>{data?.title}</h1>
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function queryOptions<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
>(
|
||||
options: UndefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey>,
|
||||
): UndefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey> &
|
||||
QueryKeyWithDataTag<TQueryKey, TQueryFnData, TError>
|
||||
|
||||
export function queryOptions(options: unknown) {
|
||||
return options
|
||||
}
|
||||
76
packages/preact-query/src/suspense.ts
Normal file
76
packages/preact-query/src/suspense.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type {
|
||||
DefaultError,
|
||||
DefaultedQueryObserverOptions,
|
||||
Query,
|
||||
QueryKey,
|
||||
QueryObserver,
|
||||
QueryObserverResult,
|
||||
} from '@tanstack/query-core'
|
||||
|
||||
import type { QueryErrorResetBoundaryValue } from './QueryErrorResetBoundary'
|
||||
|
||||
export const defaultThrowOnError = <
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
>(
|
||||
_error: TError,
|
||||
query: Query<TQueryFnData, TError, TData, TQueryKey>,
|
||||
) => query.state.data === undefined
|
||||
|
||||
export const ensureSuspenseTimers = (
|
||||
defaultedOptions: DefaultedQueryObserverOptions<any, any, any, any, any>,
|
||||
) => {
|
||||
if (defaultedOptions.suspense) {
|
||||
// Handle staleTime to ensure minimum 1000ms in Suspense mode
|
||||
// This prevents unnecessary refetching when components remount after suspending
|
||||
const MIN_SUSPENSE_TIME_MS = 1000
|
||||
|
||||
const clamp = (value: number | 'static' | undefined) =>
|
||||
value === 'static'
|
||||
? value
|
||||
: Math.max(value ?? MIN_SUSPENSE_TIME_MS, MIN_SUSPENSE_TIME_MS)
|
||||
|
||||
const originalStaleTime = defaultedOptions.staleTime
|
||||
defaultedOptions.staleTime =
|
||||
typeof originalStaleTime === 'function'
|
||||
? (...args) => clamp(originalStaleTime(...args))
|
||||
: clamp(originalStaleTime)
|
||||
|
||||
if (typeof defaultedOptions.gcTime === 'number') {
|
||||
defaultedOptions.gcTime = Math.max(
|
||||
defaultedOptions.gcTime,
|
||||
MIN_SUSPENSE_TIME_MS,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const shouldSuspend = (
|
||||
defaultedOptions:
|
||||
| DefaultedQueryObserverOptions<any, any, any, any, any>
|
||||
| undefined,
|
||||
result: QueryObserverResult<any, any>,
|
||||
) => defaultedOptions?.suspense && result.isPending
|
||||
|
||||
export const fetchOptimistic = <
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryData,
|
||||
TQueryKey extends QueryKey,
|
||||
>(
|
||||
defaultedOptions: DefaultedQueryObserverOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryData,
|
||||
TQueryKey
|
||||
>,
|
||||
observer: QueryObserver<TQueryFnData, TError, TData, TQueryData, TQueryKey>,
|
||||
errorResetBoundary: QueryErrorResetBoundaryValue,
|
||||
) =>
|
||||
observer.fetchOptimistic(defaultedOptions).catch(() => {
|
||||
errorResetBoundary.clearReset()
|
||||
})
|
||||
504
packages/preact-query/src/types.ts
Normal file
504
packages/preact-query/src/types.ts
Normal file
@@ -0,0 +1,504 @@
|
||||
/* istanbul ignore file */
|
||||
import type {
|
||||
DefaultError,
|
||||
DefinedInfiniteQueryObserverResult,
|
||||
DefinedQueryObserverResult,
|
||||
DistributiveOmit,
|
||||
InfiniteData,
|
||||
InfiniteQueryExecuteOptions,
|
||||
InfiniteQueryObserverOptions,
|
||||
InfiniteQueryObserverResult,
|
||||
MutateFunction,
|
||||
MutationObserverOptions,
|
||||
MutationObserverResult,
|
||||
OmitKeyof,
|
||||
Override,
|
||||
QueryExecuteOptions,
|
||||
QueryKey,
|
||||
QueryObserverOptions,
|
||||
QueryObserverResult,
|
||||
SkipToken,
|
||||
} from '@tanstack/query-core'
|
||||
|
||||
/**
|
||||
* {@link UseBaseQueryOptions} with all type parameters set to `any`, useful when the specific types aren't
|
||||
* relevant, e.g. when accepting options for any query in a helper function.
|
||||
*/
|
||||
export type AnyUseBaseQueryOptions = UseBaseQueryOptions<
|
||||
any,
|
||||
any,
|
||||
any,
|
||||
any,
|
||||
any
|
||||
>
|
||||
/**
|
||||
* The options shared by `useQuery` and `useSuspenseQuery`. Extends {@link QueryObserverOptions} from
|
||||
* `@tanstack/query-core` with the `preact-query`-specific `subscribed` option.
|
||||
*
|
||||
* @template TQueryFnData - The type your `queryFn` resolves to.
|
||||
* @template TError - The type of errors your `queryFn` may throw.
|
||||
* @template TData - The type `data` ends up as after `select` runs. Defaults to `TQueryFnData` when no
|
||||
* `select` is used.
|
||||
* @template TQueryData - The type of the data actually held in the query cache — the input to `select` and
|
||||
* `placeholderData`. Defaults to, and is usually the same as, `TQueryFnData`.
|
||||
* @template TQueryKey - The type of your `queryKey`.
|
||||
*/
|
||||
export interface UseBaseQueryOptions<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
> extends QueryObserverOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryData,
|
||||
TQueryKey
|
||||
> {
|
||||
/**
|
||||
* Set this to `false` to unsubscribe this observer from updates to the query cache.
|
||||
*
|
||||
* @defaultValue true
|
||||
*/
|
||||
subscribed?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The options accepted by `usePrefetchQuery` — everything you can pass to `queryClient.query`, except `queryFn`
|
||||
* is required unless a default query function has been defined.
|
||||
*
|
||||
* @template TQueryFnData - The type your `queryFn` resolves to.
|
||||
* @template TError - The type of errors your `queryFn` may throw.
|
||||
* @template TData - The type `data` ends up as after `select` runs. Defaults to `TQueryFnData` when no
|
||||
* `select` is used.
|
||||
* @template TQueryData - The type of the data actually held in the query cache — the input to `select` and
|
||||
* `placeholderData`. Defaults to, and is usually the same as, `TQueryFnData`.
|
||||
* @template TQueryKey - The type of your `queryKey`.
|
||||
*/
|
||||
export type UsePrefetchQueryOptions<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
> = DistributiveOmit<
|
||||
QueryExecuteOptions<TQueryFnData, TError, TData, TQueryData, TQueryKey>,
|
||||
'queryFn'
|
||||
> & {
|
||||
/**
|
||||
* `skipToken` is not allowed as a value here — a prefetch always needs a query function to actually run,
|
||||
* unless a default query function has been defined.
|
||||
*/
|
||||
queryFn?: Exclude<
|
||||
QueryExecuteOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryFnData,
|
||||
TQueryKey
|
||||
>['queryFn'],
|
||||
SkipToken
|
||||
>
|
||||
}
|
||||
|
||||
/**
|
||||
* The options accepted by `usePrefetchInfiniteQuery` — everything you can pass to `queryClient.infiniteQuery`,
|
||||
* except `queryFn` is required unless a default query function has been defined.
|
||||
*
|
||||
* @template TQueryFnData - The type of a single page, as your `queryFn` resolves it.
|
||||
* @template TError - The type of errors your `queryFn` may throw.
|
||||
* @template TData - The type `data` ends up as after `select` runs. Defaults to `TQueryFnData` (a single page)
|
||||
* here, since a prefetch never reads `data` back out — this parameter only matters if you reuse these options
|
||||
* elsewhere with `select` applied.
|
||||
* @template TQueryKey - The type of your `queryKey`.
|
||||
* @template TPageParam - The type of the parameter passed to `queryFn` to fetch a given page.
|
||||
*/
|
||||
export type UsePrefetchInfiniteQueryOptions<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
TPageParam = unknown,
|
||||
> = DistributiveOmit<
|
||||
InfiniteQueryExecuteOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey,
|
||||
TPageParam
|
||||
>,
|
||||
'queryFn'
|
||||
> & {
|
||||
/**
|
||||
* `skipToken` is not allowed as a value here — a prefetch always needs a query function to actually run,
|
||||
* unless a default query function has been defined.
|
||||
*/
|
||||
queryFn?: Exclude<
|
||||
InfiniteQueryExecuteOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey,
|
||||
TPageParam
|
||||
>['queryFn'],
|
||||
SkipToken
|
||||
>
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link UseQueryOptions} with all type parameters set to `any`, useful when the specific types aren't
|
||||
* relevant, e.g. when accepting options for any query in a helper function.
|
||||
*/
|
||||
export type AnyUseQueryOptions = UseQueryOptions<any, any, any, any>
|
||||
/**
|
||||
* The options accepted by `useQuery`. Same as {@link UseBaseQueryOptions}, minus `suspense` (which
|
||||
* `preact-query` derives from which hook you call rather than exposing as an option).
|
||||
*
|
||||
* @template TQueryFnData - The type your `queryFn` resolves to.
|
||||
* @template TError - The type of errors your `queryFn` may throw.
|
||||
* @template TData - The type `data` ends up as after `select` runs. Defaults to `TQueryFnData` when no
|
||||
* `select` is used.
|
||||
* @template TQueryKey - The type of your `queryKey`.
|
||||
*/
|
||||
export interface UseQueryOptions<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
> extends OmitKeyof<
|
||||
UseBaseQueryOptions<TQueryFnData, TError, TData, TQueryFnData, TQueryKey>,
|
||||
'suspense'
|
||||
> {}
|
||||
|
||||
/**
|
||||
* {@link UseSuspenseQueryOptions} with all type parameters set to `any`, useful when the specific types aren't
|
||||
* relevant, e.g. when accepting options for any query in a helper function.
|
||||
*/
|
||||
export type AnyUseSuspenseQueryOptions = UseSuspenseQueryOptions<
|
||||
any,
|
||||
any,
|
||||
any,
|
||||
any
|
||||
>
|
||||
/**
|
||||
* The options accepted by `useSuspenseQuery`. Same as {@link UseQueryOptions}, minus `enabled`, `throwOnError`,
|
||||
* and `placeholderData` — Suspense hooks cannot render a "disabled" or "placeholder" state, so those options
|
||||
* don't apply.
|
||||
*
|
||||
* @template TQueryFnData - The type your `queryFn` resolves to.
|
||||
* @template TError - The type of errors your `queryFn` may throw.
|
||||
* @template TData - The type `data` ends up as after `select` runs. Defaults to `TQueryFnData` when no
|
||||
* `select` is used.
|
||||
* @template TQueryKey - The type of your `queryKey`.
|
||||
*/
|
||||
export interface UseSuspenseQueryOptions<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
> extends OmitKeyof<
|
||||
UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
|
||||
'queryFn' | 'enabled' | 'throwOnError' | 'placeholderData'
|
||||
> {
|
||||
/**
|
||||
* `skipToken` is not allowed here — Suspense hooks cannot render a "disabled" state, so a query function
|
||||
* must always be provided, unless a default query function has been defined.
|
||||
*/
|
||||
queryFn?: Exclude<
|
||||
UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>['queryFn'],
|
||||
SkipToken
|
||||
>
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link UseInfiniteQueryOptions} with all type parameters set to `any`, useful when the specific types aren't
|
||||
* relevant, e.g. when accepting options for any query in a helper function.
|
||||
*/
|
||||
export type AnyUseInfiniteQueryOptions = UseInfiniteQueryOptions<
|
||||
any,
|
||||
any,
|
||||
any,
|
||||
any,
|
||||
any
|
||||
>
|
||||
/**
|
||||
* The options accepted by `useInfiniteQuery`. Extends {@link InfiniteQueryObserverOptions} from
|
||||
* `@tanstack/query-core` with the `preact-query`-specific `subscribed` option, minus `suspense` (which
|
||||
* `preact-query` derives from which hook you call rather than exposing as an option).
|
||||
*
|
||||
* @template TQueryFnData - The type of a single page, as your `queryFn` resolves it.
|
||||
* @template TError - The type of errors your `queryFn` may throw.
|
||||
* @template TData - The type `data` ends up as after `select` runs — defaults to `InfiniteData<TQueryFnData>`,
|
||||
* the shape of all fetched pages plus their page params.
|
||||
* @template TQueryKey - The type of your `queryKey`.
|
||||
* @template TPageParam - The type of the parameter passed to `queryFn` to fetch a given page.
|
||||
*/
|
||||
export interface UseInfiniteQueryOptions<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = InfiniteData<TQueryFnData>,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
TPageParam = unknown,
|
||||
> extends OmitKeyof<
|
||||
InfiniteQueryObserverOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey,
|
||||
TPageParam
|
||||
>,
|
||||
'suspense'
|
||||
> {
|
||||
/**
|
||||
* Set this to `false` to unsubscribe this observer from updates to the query cache.
|
||||
*
|
||||
* @defaultValue true
|
||||
*/
|
||||
subscribed?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link UseSuspenseInfiniteQueryOptions} with all type parameters set to `any`, useful when the specific types
|
||||
* aren't relevant, e.g. when accepting options for any query in a helper function.
|
||||
*/
|
||||
export type AnyUseSuspenseInfiniteQueryOptions =
|
||||
UseSuspenseInfiniteQueryOptions<any, any, any, any, any>
|
||||
/**
|
||||
* The options accepted by `useSuspenseInfiniteQuery`. Same as {@link UseInfiniteQueryOptions}, minus `enabled`,
|
||||
* `throwOnError`, and `placeholderData` — Suspense hooks cannot render a "disabled" or "placeholder" state, so
|
||||
* those options don't apply.
|
||||
*
|
||||
* @template TQueryFnData - The type of a single page, as your `queryFn` resolves it.
|
||||
* @template TError - The type of errors your `queryFn` may throw.
|
||||
* @template TData - The type `data` ends up as after `select` runs — defaults to `InfiniteData<TQueryFnData>`,
|
||||
* the shape of all fetched pages plus their page params.
|
||||
* @template TQueryKey - The type of your `queryKey`.
|
||||
* @template TPageParam - The type of the parameter passed to `queryFn` to fetch a given page.
|
||||
*/
|
||||
export interface UseSuspenseInfiniteQueryOptions<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = InfiniteData<TQueryFnData>,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
TPageParam = unknown,
|
||||
> extends OmitKeyof<
|
||||
UseInfiniteQueryOptions<TQueryFnData, TError, TData, TQueryKey, TPageParam>,
|
||||
'queryFn' | 'enabled' | 'throwOnError' | 'placeholderData'
|
||||
> {
|
||||
/**
|
||||
* `skipToken` is not allowed here — Suspense hooks cannot render a "disabled" state, so a query function
|
||||
* must always be provided, unless a default query function has been defined.
|
||||
*/
|
||||
queryFn?: Exclude<
|
||||
UseInfiniteQueryOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey,
|
||||
TPageParam
|
||||
>['queryFn'],
|
||||
SkipToken
|
||||
>
|
||||
}
|
||||
|
||||
/**
|
||||
* The result of `useQuery` when `initialData` isn't set — `data` may be `undefined` while the query is
|
||||
* `pending`. Re-exports {@link QueryObserverResult} from `@tanstack/query-core`. `useInfiniteQuery` returns
|
||||
* {@link UseInfiniteQueryResult} instead.
|
||||
*
|
||||
* @template TData - The type `data` ends up as after `select` runs.
|
||||
* @template TError - The type of errors your `queryFn` may throw.
|
||||
*/
|
||||
export type UseBaseQueryResult<
|
||||
TData = unknown,
|
||||
TError = DefaultError,
|
||||
> = QueryObserverResult<TData, TError>
|
||||
|
||||
/**
|
||||
* The result of `useQuery`. Same as {@link UseBaseQueryResult}.
|
||||
*
|
||||
* @template TData - The type `data` ends up as after `select` runs.
|
||||
* @template TError - The type of errors your `queryFn` may throw.
|
||||
*/
|
||||
export type UseQueryResult<
|
||||
TData = unknown,
|
||||
TError = DefaultError,
|
||||
> = UseBaseQueryResult<TData, TError>
|
||||
|
||||
/**
|
||||
* The result of `useSuspenseQuery`. Same as {@link DefinedUseQueryResult}, minus `isPlaceholderData` — always
|
||||
* `false` on that type, so this drops the dead field rather than an active state.
|
||||
*
|
||||
* @template TData - The type `data` ends up as after `select` runs.
|
||||
* @template TError - The type of errors your `queryFn` may throw.
|
||||
*/
|
||||
export type UseSuspenseQueryResult<
|
||||
TData = unknown,
|
||||
TError = DefaultError,
|
||||
> = DistributiveOmit<
|
||||
DefinedQueryObserverResult<TData, TError>,
|
||||
'isPlaceholderData'
|
||||
>
|
||||
|
||||
/**
|
||||
* The result of `useQuery` when `initialData` is set, or of `useSuspenseQuery` before the `isPlaceholderData`
|
||||
* omission — `data` is never `undefined`. Re-exports {@link DefinedQueryObserverResult} from
|
||||
* `@tanstack/query-core`.
|
||||
*
|
||||
* @template TData - The type `data` ends up as after `select` runs.
|
||||
* @template TError - The type of errors your `queryFn` may throw.
|
||||
*/
|
||||
export type DefinedUseQueryResult<
|
||||
TData = unknown,
|
||||
TError = DefaultError,
|
||||
> = DefinedQueryObserverResult<TData, TError>
|
||||
|
||||
/**
|
||||
* The result of `useInfiniteQuery` when `initialData` isn't set — `data` may be `undefined` while the query is
|
||||
* `pending`. Re-exports {@link InfiniteQueryObserverResult} from `@tanstack/query-core`.
|
||||
*
|
||||
* @template TData - The type `data` ends up as after `select` runs.
|
||||
* @template TError - The type of errors your `queryFn` may throw.
|
||||
*/
|
||||
export type UseInfiniteQueryResult<
|
||||
TData = unknown,
|
||||
TError = DefaultError,
|
||||
> = InfiniteQueryObserverResult<TData, TError>
|
||||
|
||||
/**
|
||||
* The result of `useInfiniteQuery` when `initialData` is set — `data` is never `undefined`. Re-exports
|
||||
* {@link DefinedInfiniteQueryObserverResult} from `@tanstack/query-core`.
|
||||
*
|
||||
* @template TData - The type `data` ends up as after `select` runs.
|
||||
* @template TError - The type of errors your `queryFn` may throw.
|
||||
*/
|
||||
export type DefinedUseInfiniteQueryResult<
|
||||
TData = unknown,
|
||||
TError = DefaultError,
|
||||
> = DefinedInfiniteQueryObserverResult<TData, TError>
|
||||
|
||||
/**
|
||||
* The result of `useSuspenseInfiniteQuery`. Same as {@link DefinedUseInfiniteQueryResult}, minus
|
||||
* `isPlaceholderData` — Suspense hooks never render placeholder data.
|
||||
*
|
||||
* @template TData - The type `data` ends up as after `select` runs.
|
||||
* @template TError - The type of errors your `queryFn` may throw.
|
||||
*/
|
||||
export type UseSuspenseInfiniteQueryResult<
|
||||
TData = unknown,
|
||||
TError = DefaultError,
|
||||
> = OmitKeyof<
|
||||
DefinedInfiniteQueryObserverResult<TData, TError>,
|
||||
'isPlaceholderData'
|
||||
>
|
||||
|
||||
/**
|
||||
* {@link UseMutationOptions} with all type parameters set to `any`, useful when the specific types aren't
|
||||
* relevant, e.g. when accepting options for any mutation in a helper function.
|
||||
*/
|
||||
export type AnyUseMutationOptions = UseMutationOptions<any, any, any, any>
|
||||
/**
|
||||
* The options accepted by `useMutation`. Same as {@link MutationObserverOptions} from `@tanstack/query-core`,
|
||||
* minus the internal `_defaulted` flag.
|
||||
*
|
||||
* @template TData - The type your mutation function resolves to.
|
||||
* @template TError - The type of errors your mutation function may throw.
|
||||
* @template TVariables - The type of the variable passed to `mutate`/`mutateAsync`.
|
||||
* @template TOnMutateResult - The type returned by `onMutate`, passed to `onSuccess`/`onError`/`onSettled` as
|
||||
* their `onMutateResult` parameter — useful for optimistic-update rollback data.
|
||||
*/
|
||||
export interface UseMutationOptions<
|
||||
TData = unknown,
|
||||
TError = DefaultError,
|
||||
TVariables = void,
|
||||
TOnMutateResult = unknown,
|
||||
> extends OmitKeyof<
|
||||
MutationObserverOptions<TData, TError, TVariables, TOnMutateResult>,
|
||||
'_defaulted'
|
||||
> {}
|
||||
|
||||
/**
|
||||
* The type of `mutate`, as returned by `useMutation`. Forwards the variables (and an optional per-call
|
||||
* `onSuccess`/`onError`/`onSettled`) to the underlying `mutate` call. Fire-and-forget — errors are surfaced
|
||||
* through the mutation result, not thrown.
|
||||
*
|
||||
* @template TData - The type your mutation function resolves to.
|
||||
* @template TError - The type of errors your mutation function may throw.
|
||||
* @template TVariables - The type of the variable passed to `mutate`.
|
||||
* @template TOnMutateResult - The type returned by `onMutate`, passed to `onSuccess`/`onError`/`onSettled` as
|
||||
* their `onMutateResult` parameter — useful for optimistic-update rollback data.
|
||||
*/
|
||||
export type UseMutateFunction<
|
||||
TData = unknown,
|
||||
TError = DefaultError,
|
||||
TVariables = void,
|
||||
TOnMutateResult = unknown,
|
||||
> = (
|
||||
...args: Parameters<
|
||||
MutateFunction<TData, TError, TVariables, TOnMutateResult>
|
||||
>
|
||||
) => void
|
||||
|
||||
/**
|
||||
* The type of `mutateAsync`, as returned by `useMutation`. Similar to {@link UseMutateFunction}, but returns a
|
||||
* promise which can be awaited.
|
||||
*
|
||||
* @template TData - The type your mutation function resolves to.
|
||||
* @template TError - The type of errors your mutation function may throw.
|
||||
* @template TVariables - The type of the variable passed to `mutateAsync`.
|
||||
* @template TOnMutateResult - The type returned by `onMutate`, passed to `onSuccess`/`onError`/`onSettled` as
|
||||
* their `onMutateResult` parameter — useful for optimistic-update rollback data.
|
||||
*/
|
||||
export type UseMutateAsyncFunction<
|
||||
TData = unknown,
|
||||
TError = DefaultError,
|
||||
TVariables = void,
|
||||
TOnMutateResult = unknown,
|
||||
> = MutateFunction<TData, TError, TVariables, TOnMutateResult>
|
||||
|
||||
/**
|
||||
* The result of `useMutation`. Same as {@link MutationObserverResult} from `@tanstack/query-core`, with
|
||||
* `mutate` narrowed to the fire-and-forget {@link UseMutateFunction} signature, plus the added `mutateAsync`.
|
||||
*
|
||||
* @template TData - The type your mutation function resolves to.
|
||||
* @template TError - The type of errors your mutation function may throw.
|
||||
* @template TVariables - The type of the variable passed to `mutate`/`mutateAsync`.
|
||||
* @template TOnMutateResult - The type returned by `onMutate`, passed to `onSuccess`/`onError`/`onSettled` as
|
||||
* their `onMutateResult` parameter — useful for optimistic-update rollback data.
|
||||
*/
|
||||
export type UseBaseMutationResult<
|
||||
TData = unknown,
|
||||
TError = DefaultError,
|
||||
TVariables = unknown,
|
||||
TOnMutateResult = unknown,
|
||||
> = Override<
|
||||
MutationObserverResult<TData, TError, TVariables, TOnMutateResult>,
|
||||
{ mutate: UseMutateFunction<TData, TError, TVariables, TOnMutateResult> }
|
||||
> & {
|
||||
/**
|
||||
* Similar to `mutate`, but returns a promise which can be awaited.
|
||||
*/
|
||||
mutateAsync: UseMutateAsyncFunction<
|
||||
TData,
|
||||
TError,
|
||||
TVariables,
|
||||
TOnMutateResult
|
||||
>
|
||||
}
|
||||
|
||||
/**
|
||||
* The result of `useMutation`. Same as {@link UseBaseMutationResult}.
|
||||
*
|
||||
* @template TData - The type your mutation function resolves to.
|
||||
* @template TError - The type of errors your mutation function may throw.
|
||||
* @template TVariables - The type of the variable passed to `mutate`/`mutateAsync`.
|
||||
* @template TOnMutateResult - The type returned by `onMutate`, passed to `onSuccess`/`onError`/`onSettled` as
|
||||
* their `onMutateResult` parameter — useful for optimistic-update rollback data.
|
||||
*/
|
||||
export type UseMutationResult<
|
||||
TData = unknown,
|
||||
TError = DefaultError,
|
||||
TVariables = unknown,
|
||||
TOnMutateResult = unknown,
|
||||
> = UseBaseMutationResult<TData, TError, TVariables, TOnMutateResult>
|
||||
139
packages/preact-query/src/useBaseQuery.ts
Normal file
139
packages/preact-query/src/useBaseQuery.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { noop, notifyManager } from '@tanstack/query-core'
|
||||
import type {
|
||||
QueryClient,
|
||||
QueryKey,
|
||||
QueryObserver,
|
||||
QueryObserverResult,
|
||||
} from '@tanstack/query-core'
|
||||
import { useCallback, useEffect, useState } from 'preact/hooks'
|
||||
|
||||
import { useIsRestoring } from './IsRestoringProvider'
|
||||
import { useQueryClient } from './QueryClientProvider'
|
||||
import { useQueryErrorResetBoundary } from './QueryErrorResetBoundary'
|
||||
import {
|
||||
ensurePreventErrorBoundaryRetry,
|
||||
getHasError,
|
||||
useClearResetErrorBoundary,
|
||||
} from './errorBoundaryUtils'
|
||||
import {
|
||||
ensureSuspenseTimers,
|
||||
fetchOptimistic,
|
||||
shouldSuspend,
|
||||
} from './suspense'
|
||||
import type { UseBaseQueryOptions } from './types'
|
||||
import { useSyncExternalStore } from './utils'
|
||||
|
||||
export function useBaseQuery<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryData,
|
||||
TQueryKey extends QueryKey,
|
||||
>(
|
||||
options: UseBaseQueryOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryData,
|
||||
TQueryKey
|
||||
>,
|
||||
Observer: typeof QueryObserver,
|
||||
queryClient?: QueryClient,
|
||||
): QueryObserverResult<TData, TError> {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
if (typeof options !== 'object' || Array.isArray(options)) {
|
||||
throw new Error(
|
||||
'Bad argument type. Starting with v5, only the "Object" form is allowed when calling query related functions. Please use the error stack to find the culprit call. More info here: https://tanstack.com/query/latest/docs/react/guides/migrating-to-v5#supports-a-single-signature-one-object',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const isRestoring = useIsRestoring()
|
||||
const errorResetBoundary = useQueryErrorResetBoundary()
|
||||
const client = useQueryClient(queryClient)
|
||||
const defaultedOptions = client.defaultQueryOptions(options)
|
||||
|
||||
const query = client
|
||||
.getQueryCache()
|
||||
.get<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TQueryData,
|
||||
TQueryKey
|
||||
>(defaultedOptions.queryHash)
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
if (!defaultedOptions.queryFn) {
|
||||
console.error(
|
||||
`[${defaultedOptions.queryHash}]: No queryFn was passed as an option, and no default queryFn was found. The queryFn parameter is only optional when using a default queryFn. More info here: https://tanstack.com/query/latest/docs/framework/react/guides/default-query-function`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Make sure results are optimistically set in fetching state before subscribing or updating options
|
||||
defaultedOptions._optimisticResults = isRestoring
|
||||
? 'isRestoring'
|
||||
: 'optimistic'
|
||||
|
||||
ensureSuspenseTimers(defaultedOptions)
|
||||
ensurePreventErrorBoundaryRetry(defaultedOptions, errorResetBoundary, query)
|
||||
|
||||
useClearResetErrorBoundary(errorResetBoundary)
|
||||
|
||||
const [observer] = useState(
|
||||
() =>
|
||||
new Observer<TQueryFnData, TError, TData, TQueryData, TQueryKey>(
|
||||
client,
|
||||
defaultedOptions,
|
||||
),
|
||||
)
|
||||
|
||||
// note: this must be called before useSyncExternalStore
|
||||
const result = observer.getOptimisticResult(defaultedOptions)
|
||||
|
||||
const shouldSubscribe = !isRestoring && options.subscribed !== false
|
||||
useSyncExternalStore(
|
||||
useCallback(
|
||||
(onStoreChange) => {
|
||||
const unsubscribe = shouldSubscribe
|
||||
? observer.subscribe(notifyManager.batchCalls(onStoreChange))
|
||||
: noop
|
||||
|
||||
// Update result to make sure we did not miss any query updates
|
||||
// between creating the observer and subscribing to it.
|
||||
observer.updateResult()
|
||||
|
||||
return unsubscribe
|
||||
},
|
||||
[observer, shouldSubscribe],
|
||||
),
|
||||
() => observer.getCurrentResult(),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
observer.setOptions(defaultedOptions)
|
||||
}, [defaultedOptions, observer])
|
||||
|
||||
// Handle suspense
|
||||
if (shouldSuspend(defaultedOptions, result)) {
|
||||
throw fetchOptimistic(defaultedOptions, observer, errorResetBoundary)
|
||||
}
|
||||
|
||||
// Handle error boundary
|
||||
if (
|
||||
getHasError({
|
||||
result,
|
||||
errorResetBoundary,
|
||||
throwOnError: defaultedOptions.throwOnError,
|
||||
query,
|
||||
suspense: defaultedOptions.suspense,
|
||||
})
|
||||
) {
|
||||
throw result.error
|
||||
}
|
||||
|
||||
// Handle result property usage tracking
|
||||
return !defaultedOptions.notifyOnChangeProps
|
||||
? observer.trackResult(result)
|
||||
: result
|
||||
}
|
||||
373
packages/preact-query/src/useInfiniteQuery.ts
Normal file
373
packages/preact-query/src/useInfiniteQuery.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
import { InfiniteQueryObserver } from '@tanstack/query-core'
|
||||
import type {
|
||||
DefaultError,
|
||||
InfiniteData,
|
||||
QueryClient,
|
||||
QueryKey,
|
||||
QueryObserver,
|
||||
} from '@tanstack/query-core'
|
||||
|
||||
import type {
|
||||
DefinedInitialDataInfiniteOptions,
|
||||
UndefinedInitialDataInfiniteOptions,
|
||||
} from './infiniteQueryOptions'
|
||||
import type {
|
||||
DefinedUseInfiniteQueryResult,
|
||||
UseInfiniteQueryOptions,
|
||||
UseInfiniteQueryResult,
|
||||
} from './types'
|
||||
import { useBaseQuery } from './useBaseQuery'
|
||||
|
||||
/**
|
||||
* The options for `useInfiniteQuery` are identical to `useQuery`, with the addition of
|
||||
* `initialPageParam`, `getNextPageParam`, `getPreviousPageParam`, and `maxPages`.
|
||||
*
|
||||
* This overload is selected when `initialData` is set.
|
||||
*
|
||||
* @remarks Keep in mind that imperative fetch calls, such as `fetchNextPage`, may interfere with the default
|
||||
* refetch behavior, resulting in outdated data. Make sure to call these functions only in response to user
|
||||
* actions, or add conditions like `hasNextPage && !isFetching`.
|
||||
* @see {@link infiniteQueryOptions} to share these options between `useInfiniteQuery` and imperative APIs like `queryClient.infiniteQuery`.
|
||||
* @param options - The {@link DefinedInitialDataInfiniteOptions} to use — everything you can pass to `useInfiniteQuery`, with `initialData` set.
|
||||
* @param queryClient - Use this to use a custom `QueryClient`. Otherwise, the one from the nearest context will
|
||||
* be used.
|
||||
* @returns The same properties as `useQuery`, with the addition of `fetchNextPage`, `fetchPreviousPage`,
|
||||
* `hasNextPage`, `hasPreviousPage`, `isFetchingNextPage`, and `isFetchingPreviousPage`. `data.pages` and
|
||||
* `data.pageParams` are also added, as long as a `select` doesn't change `TData` away from its default
|
||||
* `InfiniteData<TQueryFnData>` shape.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { useInfiniteQuery } from '@tanstack/preact-query'
|
||||
*
|
||||
* function Projects() {
|
||||
* // `data` is never `undefined`, thanks to `initialData` — even if a refetch fails, so the
|
||||
* // list stays visible alongside the error.
|
||||
* const { data, isError, error } = useInfiniteQuery({
|
||||
* queryKey: ['projects'],
|
||||
* queryFn: ({ pageParam }) => fetchProjects(pageParam),
|
||||
* initialPageParam: 0,
|
||||
* getNextPageParam: (lastPage) => lastPage.nextId,
|
||||
* initialData: { pages: [], pageParams: [] },
|
||||
* })
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* {isError ? <span>Error: {error.message}</span> : null}
|
||||
* <ul>
|
||||
* {data.pages.map((page) => page.projects.map((p) => <li key={p.id}>{p.name}</li>))}
|
||||
* </ul>
|
||||
* </div>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useInfiniteQuery<
|
||||
TQueryFnData,
|
||||
TError = DefaultError,
|
||||
TData = InfiniteData<TQueryFnData>,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
TPageParam = unknown,
|
||||
>(
|
||||
options: DefinedInitialDataInfiniteOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey,
|
||||
TPageParam
|
||||
>,
|
||||
queryClient?: QueryClient,
|
||||
): DefinedUseInfiniteQueryResult<TData, TError>
|
||||
|
||||
/**
|
||||
* The options for `useInfiniteQuery` are identical to `useQuery`, with the addition of
|
||||
* `initialPageParam`, `getNextPageParam`, `getPreviousPageParam`, and `maxPages`.
|
||||
*
|
||||
* @remarks Keep in mind that imperative fetch calls, such as `fetchNextPage`, may interfere with the default
|
||||
* refetch behavior, resulting in outdated data. Make sure to call these functions only in response to user
|
||||
* actions, or add conditions like `hasNextPage && !isFetching`.
|
||||
* @see {@link infiniteQueryOptions} to share these options between `useInfiniteQuery` and imperative APIs like `queryClient.infiniteQuery`.
|
||||
* @param options - The {@link UndefinedInitialDataInfiniteOptions} to use — everything you can pass to `useInfiniteQuery`.
|
||||
* @param queryClient - Use this to use a custom `QueryClient`. Otherwise, the one from the nearest context will
|
||||
* be used.
|
||||
* @returns The same properties as `useQuery`, with the addition of `fetchNextPage`, `fetchPreviousPage`,
|
||||
* `hasNextPage`, `hasPreviousPage`, `isFetchingNextPage`, and `isFetchingPreviousPage`. `data.pages` and
|
||||
* `data.pageParams` are also added, as long as a `select` doesn't change `TData` away from its default
|
||||
* `InfiniteData<TQueryFnData>` shape.
|
||||
*
|
||||
* @example
|
||||
* Fetching the next page from a "Load More" button click:
|
||||
* ```tsx
|
||||
* import { useInfiniteQuery } from '@tanstack/preact-query'
|
||||
*
|
||||
* function Projects() {
|
||||
* const { data, isPending, isError, error, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage } =
|
||||
* useInfiniteQuery({
|
||||
* queryKey: ['projects'],
|
||||
* queryFn: ({ pageParam }) => fetchProjects(pageParam),
|
||||
* initialPageParam: 0,
|
||||
* getNextPageParam: (lastPage) => lastPage.nextId,
|
||||
* })
|
||||
*
|
||||
* if (isPending) return 'Loading...'
|
||||
* if (isError) return <span>Error: {error.message}</span>
|
||||
*
|
||||
* return (
|
||||
* <>
|
||||
* <ul>
|
||||
* {data.pages.map((page) =>
|
||||
* page.projects.map((project) => <li key={project.id}>{project.name}</li>),
|
||||
* )}
|
||||
* </ul>
|
||||
* <button
|
||||
* onClick={() => fetchNextPage()}
|
||||
* disabled={!hasNextPage || isFetching}
|
||||
* >
|
||||
* {isFetchingNextPage
|
||||
* ? 'Loading more...'
|
||||
* : hasNextPage
|
||||
* ? 'Load More'
|
||||
* : 'Nothing more to load'}
|
||||
* </button>
|
||||
* </>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* Fetching the next page automatically as the user scrolls, using an `IntersectionObserver` on a
|
||||
* sentinel element after the list:
|
||||
* ```tsx
|
||||
* import { useInfiniteQuery } from '@tanstack/preact-query'
|
||||
* import { useEffect, useRef } from 'preact/hooks'
|
||||
*
|
||||
* function Projects() {
|
||||
* const {
|
||||
* data,
|
||||
* isPending,
|
||||
* isError,
|
||||
* error,
|
||||
* fetchNextPage,
|
||||
* hasNextPage,
|
||||
* isFetching,
|
||||
* isFetchingNextPage,
|
||||
* } = useInfiniteQuery({
|
||||
* queryKey: ['projects'],
|
||||
* queryFn: ({ pageParam }) => fetchProjects(pageParam),
|
||||
* initialPageParam: 0,
|
||||
* getNextPageParam: (lastPage) => lastPage.nextId,
|
||||
* })
|
||||
*
|
||||
* const sentinelRef = useRef<HTMLDivElement>(null)
|
||||
*
|
||||
* useEffect(() => {
|
||||
* const sentinel = sentinelRef.current
|
||||
* if (sentinel == null || !hasNextPage || isFetching) return
|
||||
*
|
||||
* const observer = new IntersectionObserver(([entry]) => {
|
||||
* if (entry?.isIntersecting) fetchNextPage()
|
||||
* })
|
||||
* observer.observe(sentinel)
|
||||
*
|
||||
* return () => observer.disconnect()
|
||||
* }, [hasNextPage, isFetching, fetchNextPage])
|
||||
*
|
||||
* if (isPending) return 'Loading...'
|
||||
* if (isError) return <span>Error: {error.message}</span>
|
||||
*
|
||||
* return (
|
||||
* <>
|
||||
* <ul>
|
||||
* {data.pages.map((page) =>
|
||||
* page.projects.map((project) => <li key={project.id}>{project.name}</li>),
|
||||
* )}
|
||||
* </ul>
|
||||
* <div ref={sentinelRef}>{isFetchingNextPage ? 'Loading more...' : null}</div>
|
||||
* </>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useInfiniteQuery<
|
||||
TQueryFnData,
|
||||
TError = DefaultError,
|
||||
TData = InfiniteData<TQueryFnData>,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
TPageParam = unknown,
|
||||
>(
|
||||
options: UndefinedInitialDataInfiniteOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey,
|
||||
TPageParam
|
||||
>,
|
||||
queryClient?: QueryClient,
|
||||
): UseInfiniteQueryResult<TData, TError>
|
||||
|
||||
/**
|
||||
* The options for `useInfiniteQuery` are identical to `useQuery`, with the addition of
|
||||
* `initialPageParam`, `getNextPageParam`, `getPreviousPageParam`, and `maxPages`.
|
||||
*
|
||||
* @remarks Keep in mind that imperative fetch calls, such as `fetchNextPage`, may interfere with the default
|
||||
* refetch behavior, resulting in outdated data. Make sure to call these functions only in response to user
|
||||
* actions, or add conditions like `hasNextPage && !isFetching`.
|
||||
* @see {@link infiniteQueryOptions} to share these options between `useInfiniteQuery` and imperative APIs like `queryClient.infiniteQuery`.
|
||||
* @param options - The {@link UseInfiniteQueryOptions} to use — everything you can pass to `useInfiniteQuery`.
|
||||
* @param queryClient - Use this to use a custom `QueryClient`. Otherwise, the one from the nearest context will
|
||||
* be used.
|
||||
* @returns The same properties as `useQuery`, with the addition of `fetchNextPage`, `fetchPreviousPage`,
|
||||
* `hasNextPage`, `hasPreviousPage`, `isFetchingNextPage`, and `isFetchingPreviousPage`. `data.pages` and
|
||||
* `data.pageParams` are also added, as long as a `select` doesn't change `TData` away from its default
|
||||
* `InfiniteData<TQueryFnData>` shape.
|
||||
*
|
||||
* @example
|
||||
* Fetching the next page from a "Load More" button click:
|
||||
* ```tsx
|
||||
* import { useInfiniteQuery } from '@tanstack/preact-query'
|
||||
*
|
||||
* function Projects() {
|
||||
* const { data, isPending, isError, error, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage } =
|
||||
* useInfiniteQuery({
|
||||
* queryKey: ['projects'],
|
||||
* queryFn: ({ pageParam }) => fetchProjects(pageParam),
|
||||
* initialPageParam: 0,
|
||||
* getNextPageParam: (lastPage) => lastPage.nextId,
|
||||
* })
|
||||
*
|
||||
* if (isPending) return 'Loading...'
|
||||
* if (isError) return <span>Error: {error.message}</span>
|
||||
*
|
||||
* return (
|
||||
* <>
|
||||
* <ul>
|
||||
* {data.pages.map((page) =>
|
||||
* page.projects.map((project) => <li key={project.id}>{project.name}</li>),
|
||||
* )}
|
||||
* </ul>
|
||||
* <button
|
||||
* onClick={() => fetchNextPage()}
|
||||
* disabled={!hasNextPage || isFetching}
|
||||
* >
|
||||
* {isFetchingNextPage
|
||||
* ? 'Loading more...'
|
||||
* : hasNextPage
|
||||
* ? 'Load More'
|
||||
* : 'Nothing more to load'}
|
||||
* </button>
|
||||
* </>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* Fetching the next page automatically as the user scrolls, using an `IntersectionObserver` on a
|
||||
* sentinel element after the list:
|
||||
* ```tsx
|
||||
* import { useInfiniteQuery } from '@tanstack/preact-query'
|
||||
* import { useEffect, useRef } from 'preact/hooks'
|
||||
*
|
||||
* function Projects() {
|
||||
* const {
|
||||
* data,
|
||||
* isPending,
|
||||
* isError,
|
||||
* error,
|
||||
* fetchNextPage,
|
||||
* hasNextPage,
|
||||
* isFetching,
|
||||
* isFetchingNextPage,
|
||||
* } = useInfiniteQuery({
|
||||
* queryKey: ['projects'],
|
||||
* queryFn: ({ pageParam }) => fetchProjects(pageParam),
|
||||
* initialPageParam: 0,
|
||||
* getNextPageParam: (lastPage) => lastPage.nextId,
|
||||
* })
|
||||
*
|
||||
* const sentinelRef = useRef<HTMLDivElement>(null)
|
||||
*
|
||||
* useEffect(() => {
|
||||
* const sentinel = sentinelRef.current
|
||||
* if (sentinel == null || !hasNextPage || isFetching) return
|
||||
*
|
||||
* const observer = new IntersectionObserver(([entry]) => {
|
||||
* if (entry?.isIntersecting) fetchNextPage()
|
||||
* })
|
||||
* observer.observe(sentinel)
|
||||
*
|
||||
* return () => observer.disconnect()
|
||||
* }, [hasNextPage, isFetching, fetchNextPage])
|
||||
*
|
||||
* if (isPending) return 'Loading...'
|
||||
* if (isError) return <span>Error: {error.message}</span>
|
||||
*
|
||||
* return (
|
||||
* <>
|
||||
* <ul>
|
||||
* {data.pages.map((page) =>
|
||||
* page.projects.map((project) => <li key={project.id}>{project.name}</li>),
|
||||
* )}
|
||||
* </ul>
|
||||
* <div ref={sentinelRef}>{isFetchingNextPage ? 'Loading more...' : null}</div>
|
||||
* </>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* A query that's disabled, type safe, until `postId` is set — pass `skipToken` as `queryFn`
|
||||
* instead of setting `enabled: false`:
|
||||
* ```tsx
|
||||
* import { skipToken, useInfiniteQuery } from '@tanstack/preact-query'
|
||||
*
|
||||
* function Comments({ postId }: { postId: string | undefined }) {
|
||||
* // Use `isLoading`, not `isPending`, so the loading state doesn't show while the query is disabled.
|
||||
* const { data, isLoading, isError, error } = useInfiniteQuery({
|
||||
* queryKey: ['post', postId, 'comments'],
|
||||
* queryFn:
|
||||
* postId != null
|
||||
* ? ({ pageParam }) => fetchComments(postId, pageParam)
|
||||
* : skipToken,
|
||||
* initialPageParam: 0,
|
||||
* getNextPageParam: (lastPage) => lastPage.nextId,
|
||||
* })
|
||||
*
|
||||
* if (postId == null) return 'Select a post'
|
||||
* if (isLoading) return 'Loading...'
|
||||
* if (isError) return <span>Error: {error.message}</span>
|
||||
*
|
||||
* return (
|
||||
* <ul>
|
||||
* {data?.pages.map((page) => page.comments.map((c) => <li key={c.id}>{c.text}</li>))}
|
||||
* </ul>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useInfiniteQuery<
|
||||
TQueryFnData,
|
||||
TError = DefaultError,
|
||||
TData = InfiniteData<TQueryFnData>,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
TPageParam = unknown,
|
||||
>(
|
||||
options: UseInfiniteQueryOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey,
|
||||
TPageParam
|
||||
>,
|
||||
queryClient?: QueryClient,
|
||||
): UseInfiniteQueryResult<TData, TError>
|
||||
|
||||
export function useInfiniteQuery(
|
||||
options: UseInfiniteQueryOptions,
|
||||
queryClient?: QueryClient,
|
||||
) {
|
||||
return useBaseQuery(
|
||||
options,
|
||||
InfiniteQueryObserver as typeof QueryObserver,
|
||||
queryClient,
|
||||
)
|
||||
}
|
||||
59
packages/preact-query/src/useIsFetching.ts
Normal file
59
packages/preact-query/src/useIsFetching.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { notifyManager } from '@tanstack/query-core'
|
||||
import type { QueryClient, QueryFilters } from '@tanstack/query-core'
|
||||
import { useCallback } from 'preact/hooks'
|
||||
|
||||
import { useQueryClient } from './QueryClientProvider'
|
||||
import { useSyncExternalStore } from './utils'
|
||||
|
||||
/**
|
||||
* The `useIsFetching` hook returns the `number` of the queries that your application is loading or fetching in
|
||||
* the background (useful for app-wide loading indicators).
|
||||
*
|
||||
* @param filters - The {@link QueryFilters} to narrow down the matched queries.
|
||||
* @param queryClient - Use this to use a custom `QueryClient`. Otherwise, the one from the nearest context will
|
||||
* be used.
|
||||
* @returns Will be the `number` of the queries that your application is currently loading or fetching in the
|
||||
* background.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { useIsFetching } from '@tanstack/preact-query'
|
||||
*
|
||||
* function PostsFetchingIndicator() {
|
||||
* // How many queries matching the posts prefix are fetching?
|
||||
* const isFetchingPosts = useIsFetching({ queryKey: ['posts'] })
|
||||
*
|
||||
* return isFetchingPosts ? <span>Refreshing posts...</span> : null
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* A global loading indicator for any query fetching in the background, not just the ones on screen:
|
||||
* ```tsx
|
||||
* import { useIsFetching } from '@tanstack/preact-query'
|
||||
*
|
||||
* function GlobalLoadingIndicator() {
|
||||
* const isFetching = useIsFetching()
|
||||
*
|
||||
* return isFetching ? (
|
||||
* <div>Queries are fetching in the background...</div>
|
||||
* ) : null
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useIsFetching(
|
||||
filters?: QueryFilters,
|
||||
queryClient?: QueryClient,
|
||||
): number {
|
||||
const client = useQueryClient(queryClient)
|
||||
const queryCache = client.getQueryCache()
|
||||
|
||||
return useSyncExternalStore(
|
||||
useCallback(
|
||||
(onStoreChange) =>
|
||||
queryCache.subscribe(notifyManager.batchCalls(onStoreChange)),
|
||||
[queryCache],
|
||||
),
|
||||
() => client.isFetching(filters),
|
||||
)
|
||||
}
|
||||
245
packages/preact-query/src/useMutation.ts
Normal file
245
packages/preact-query/src/useMutation.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import {
|
||||
MutationObserver,
|
||||
noop,
|
||||
notifyManager,
|
||||
shouldThrowError,
|
||||
} from '@tanstack/query-core'
|
||||
import type { DefaultError, QueryClient } from '@tanstack/query-core'
|
||||
import { useCallback, useEffect, useState } from 'preact/hooks'
|
||||
|
||||
import { useQueryClient } from './QueryClientProvider'
|
||||
import type {
|
||||
UseMutateFunction,
|
||||
UseMutationOptions,
|
||||
UseMutationResult,
|
||||
} from './types'
|
||||
import { useSyncExternalStore } from './utils'
|
||||
|
||||
// HOOK
|
||||
|
||||
/**
|
||||
* Unlike queries, mutations are typically used to create/update/delete data or perform server side-effects.
|
||||
* `useMutation` is the hook for that.
|
||||
*
|
||||
* @see {@link mutationOptions} to share these options across multiple `useMutation` call sites, or to look
|
||||
* the mutation up elsewhere via its `mutationKey` (e.g. with `useMutationState`).
|
||||
* @param options - The {@link UseMutationOptions} to use — everything you can pass to `useMutation`.
|
||||
* @param queryClient - Use this to use a custom `QueryClient`. Otherwise, the one from the nearest context will
|
||||
* be used.
|
||||
* @returns `mutate`/`mutateAsync` also accept per-call `onSuccess`/`onError`/`onSettled` callbacks as a second
|
||||
* argument, useful for triggering call-site side effects (e.g. navigation) without coupling them to the shared
|
||||
* mutation definition. Hook-level callbacks (passed to `options`) fire for every mutation; per-call callbacks
|
||||
* fire only for the latest call you've made, and only while the component is still mounted — unmounting before
|
||||
* the mutation settles removes the subscription and prevents them from firing.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { useMutation, useQueryClient } from '@tanstack/preact-query'
|
||||
*
|
||||
* function AddTodo() {
|
||||
* const queryClient = useQueryClient()
|
||||
*
|
||||
* const addMutation = useMutation({
|
||||
* mutationFn: addTodo,
|
||||
* onSuccess: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
|
||||
* })
|
||||
*
|
||||
* return (
|
||||
* <button
|
||||
* onClick={() =>
|
||||
* addMutation.mutate('Item', {
|
||||
* onError: (error) => console.error('Failed to add item:', error),
|
||||
* })
|
||||
* }
|
||||
* >
|
||||
* Add
|
||||
* </button>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* Rendering the mutation's own state, rather than just firing it off:
|
||||
* ```tsx
|
||||
* import { useMutation, useQueryClient } from '@tanstack/preact-query'
|
||||
*
|
||||
* function AddTodo() {
|
||||
* const queryClient = useQueryClient()
|
||||
*
|
||||
* const addMutation = useMutation({
|
||||
* mutationFn: addTodo,
|
||||
* onSuccess: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
|
||||
* })
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* {addMutation.isPending ? (
|
||||
* 'Adding todo...'
|
||||
* ) : (
|
||||
* <>
|
||||
* {addMutation.isError ? (
|
||||
* <div>An error occurred: {addMutation.error.message}</div>
|
||||
* ) : null}
|
||||
* <button onClick={() => addMutation.mutate('Item')}>Add</button>
|
||||
* </>
|
||||
* )}
|
||||
* </div>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* Optimistic update via `onMutate`, rolling back on `onError`:
|
||||
* ```tsx
|
||||
* import { useMutation, useQueryClient } from '@tanstack/preact-query'
|
||||
*
|
||||
* function AddTodo() {
|
||||
* const queryClient = useQueryClient()
|
||||
*
|
||||
* const addMutation = useMutation({
|
||||
* mutationFn: addTodo,
|
||||
* onMutate: async (newTodo) => {
|
||||
* await queryClient.cancelQueries({ queryKey: ['todos'] })
|
||||
* const previousTodos = queryClient.getQueryData<Array<string>>(['todos'])
|
||||
*
|
||||
* queryClient.setQueryData<Array<string>>(['todos'], (old) => [
|
||||
* ...(old ?? []),
|
||||
* newTodo,
|
||||
* ])
|
||||
*
|
||||
* // Passed to `onError` as `onMutateResult` if the mutation fails.
|
||||
* return { previousTodos }
|
||||
* },
|
||||
* onError: (_err, _newTodo, onMutateResult) => {
|
||||
* queryClient.setQueryData(['todos'], onMutateResult?.previousTodos)
|
||||
* },
|
||||
* onSettled: () => {
|
||||
* queryClient.invalidateQueries({ queryKey: ['todos'] })
|
||||
* },
|
||||
* })
|
||||
*
|
||||
* return (
|
||||
* <button onClick={() => addMutation.mutate('Item')}>Add</button>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* Callbacks passed per call to `mutate` only fire for the last call — `mutateAsync` gives you a
|
||||
* promise per call instead, so you can wait for all of them when they succeed:
|
||||
* ```tsx
|
||||
* import { useMutation, useQueryClient } from '@tanstack/preact-query'
|
||||
*
|
||||
* function AddTodos() {
|
||||
* const queryClient = useQueryClient()
|
||||
*
|
||||
* const addMutation = useMutation({
|
||||
* mutationFn: addTodo,
|
||||
* onSuccess: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
|
||||
* })
|
||||
*
|
||||
* async function handleAddAll(todos: Array<string>) {
|
||||
* try {
|
||||
* await Promise.all(todos.map((todo) => addMutation.mutateAsync(todo)))
|
||||
* } catch (error) {
|
||||
* console.error('Failed to add todos:', error)
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* return (
|
||||
* <button onClick={() => handleAddAll(['Todo 1', 'Todo 2', 'Todo 3'])}>
|
||||
* Add all
|
||||
* </button>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* If some of the mutations above can fail independently of the others, and you want to know which ones
|
||||
* did — rather than losing that information the moment the first one rejects — swap `Promise.all` for
|
||||
* `Promise.allSettled`:
|
||||
* ```tsx
|
||||
* import { useMutation, useQueryClient } from '@tanstack/preact-query'
|
||||
*
|
||||
* function AddTodos() {
|
||||
* const queryClient = useQueryClient()
|
||||
*
|
||||
* const addMutation = useMutation({
|
||||
* mutationFn: addTodo,
|
||||
* onSuccess: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
|
||||
* })
|
||||
*
|
||||
* async function handleAddAll(todos: Array<string>) {
|
||||
* const addResults = await Promise.allSettled(
|
||||
* todos.map((todo) => addMutation.mutateAsync(todo)),
|
||||
* )
|
||||
*
|
||||
* addResults.forEach((addResult, index) => {
|
||||
* if (addResult.status === 'rejected') {
|
||||
* console.error(`Failed to add "${todos[index]}":`, addResult.reason)
|
||||
* }
|
||||
* })
|
||||
* }
|
||||
*
|
||||
* return (
|
||||
* <button onClick={() => handleAddAll(['Todo 1', 'Todo 2', 'Todo 3'])}>
|
||||
* Add all
|
||||
* </button>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useMutation<
|
||||
TData = unknown,
|
||||
TError = DefaultError,
|
||||
TVariables = void,
|
||||
TOnMutateResult = unknown,
|
||||
>(
|
||||
options: UseMutationOptions<TData, TError, TVariables, TOnMutateResult>,
|
||||
queryClient?: QueryClient,
|
||||
): UseMutationResult<TData, TError, TVariables, TOnMutateResult> {
|
||||
const client = useQueryClient(queryClient)
|
||||
|
||||
const [observer] = useState(
|
||||
() =>
|
||||
new MutationObserver<TData, TError, TVariables, TOnMutateResult>(
|
||||
client,
|
||||
options,
|
||||
),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
observer.setOptions(options)
|
||||
}, [observer, options])
|
||||
|
||||
const result = useSyncExternalStore(
|
||||
useCallback(
|
||||
(onStoreChange) =>
|
||||
observer.subscribe(notifyManager.batchCalls(onStoreChange)),
|
||||
[observer],
|
||||
),
|
||||
() => observer.getCurrentResult(),
|
||||
)
|
||||
|
||||
const mutate = useCallback<
|
||||
UseMutateFunction<TData, TError, TVariables, TOnMutateResult>
|
||||
>(
|
||||
(
|
||||
...args: Parameters<
|
||||
UseMutateFunction<TData, TError, TVariables, TOnMutateResult>
|
||||
>
|
||||
) => {
|
||||
observer.mutate(args[0] as TVariables, args[1]).catch(noop)
|
||||
},
|
||||
[observer],
|
||||
)
|
||||
|
||||
if (
|
||||
result.error &&
|
||||
shouldThrowError(observer.options.throwOnError, [result.error])
|
||||
) {
|
||||
throw result.error
|
||||
}
|
||||
|
||||
return { ...result, mutate, mutateAsync: result.mutate }
|
||||
}
|
||||
193
packages/preact-query/src/useMutationState.ts
Normal file
193
packages/preact-query/src/useMutationState.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { notifyManager, replaceEqualDeep } from '@tanstack/query-core'
|
||||
import type {
|
||||
Mutation,
|
||||
MutationCache,
|
||||
MutationFilters,
|
||||
MutationState,
|
||||
QueryClient,
|
||||
} from '@tanstack/query-core'
|
||||
import { useCallback, useEffect, useRef } from 'preact/hooks'
|
||||
|
||||
import { useQueryClient } from './QueryClientProvider'
|
||||
import { useSyncExternalStore } from './utils'
|
||||
|
||||
/**
|
||||
* The `useIsMutating` hook returns the `number` of mutations that your application currently has `pending`
|
||||
* (useful for app-wide loading indicators).
|
||||
*
|
||||
* @param filters - The {@link MutationFilters} to narrow down the matched mutations.
|
||||
* @param queryClient - Use this to use a custom `QueryClient`. Otherwise, the one from the nearest context will
|
||||
* be used.
|
||||
* @returns Will be the `number` of the mutations that your application currently has `pending`.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { useIsMutating } from '@tanstack/preact-query'
|
||||
*
|
||||
* function PostsMutatingIndicator() {
|
||||
* // How many mutations matching the posts prefix are in progress?
|
||||
* const isMutatingPosts = useIsMutating({ mutationKey: ['posts'] })
|
||||
*
|
||||
* return isMutatingPosts ? <span>Saving posts...</span> : null
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useIsMutating(
|
||||
filters?: MutationFilters,
|
||||
queryClient?: QueryClient,
|
||||
): number {
|
||||
const client = useQueryClient(queryClient)
|
||||
return useMutationState(
|
||||
{ filters: { ...filters, status: 'pending' } },
|
||||
client,
|
||||
).length
|
||||
}
|
||||
|
||||
type MutationTypeFromResult<TResult> = [TResult] extends [
|
||||
MutationState<
|
||||
infer TData,
|
||||
infer TError,
|
||||
infer TVariables,
|
||||
infer TOnMutateResult
|
||||
>,
|
||||
]
|
||||
? Mutation<TData, TError, TVariables, TOnMutateResult>
|
||||
: Mutation
|
||||
|
||||
type MutationStateOptions<
|
||||
TResult = MutationState,
|
||||
TMutation extends Mutation<any, any, any, any> =
|
||||
MutationTypeFromResult<TResult>,
|
||||
> = {
|
||||
filters?: MutationFilters
|
||||
select?: (mutation: TMutation) => TResult
|
||||
}
|
||||
|
||||
function getResult<
|
||||
TResult = MutationState,
|
||||
TMutation extends Mutation<any, any, any, any> =
|
||||
MutationTypeFromResult<TResult>,
|
||||
>(
|
||||
mutationCache: MutationCache,
|
||||
options: MutationStateOptions<TResult, TMutation>,
|
||||
): Array<TResult> {
|
||||
return mutationCache
|
||||
.findAll(options.filters)
|
||||
.map(
|
||||
(mutation): TResult =>
|
||||
(options.select
|
||||
? options.select(mutation as TMutation)
|
||||
: mutation.state) as TResult,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* `useMutationState` is a hook that gives you access to all mutations in the `MutationCache`. You can pass
|
||||
* `filters` ({@link MutationFilters}) to narrow down your mutations, and `select` to transform the mutation
|
||||
* state.
|
||||
*
|
||||
* @param options - The `filters` to narrow down matched mutations, and an optional `select` to transform the
|
||||
* mutation state.
|
||||
* @param queryClient - Use this to use a custom `QueryClient`. Otherwise, the one from the nearest context will
|
||||
* be used.
|
||||
* @returns Will be an Array of whatever `select` returns for each matching mutation.
|
||||
*
|
||||
* @example
|
||||
* Get all variables of all running mutations:
|
||||
* ```tsx
|
||||
* import { useMutationState } from '@tanstack/preact-query'
|
||||
*
|
||||
* function PendingPosts() {
|
||||
* const pendingVariables = useMutationState({
|
||||
* filters: { status: 'pending' },
|
||||
* select: (mutation) => mutation.state.variables,
|
||||
* })
|
||||
*
|
||||
* return <>{pendingVariables.length} posts saving...</>
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* Get all data for specific mutations via the `mutationKey`:
|
||||
* ```tsx
|
||||
* import { useMutation, useMutationState } from '@tanstack/preact-query'
|
||||
*
|
||||
* const mutationKey = ['posts']
|
||||
*
|
||||
* function Posts() {
|
||||
* // Some mutation that we want to get the state for
|
||||
* const mutation = useMutation({
|
||||
* mutationKey,
|
||||
* mutationFn: createPosts,
|
||||
* })
|
||||
*
|
||||
* const savedPosts = useMutationState({
|
||||
* // this mutation key needs to match the mutation key of the given mutation (see above)
|
||||
* filters: { mutationKey, status: 'success' },
|
||||
* select: (mutation) => mutation.state.data,
|
||||
* })
|
||||
*
|
||||
* return (
|
||||
* <button onClick={() => mutation.mutate(['New Post'])}>
|
||||
* Create post ({savedPosts.length} saved so far)
|
||||
* </button>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* Access the latest successful mutation data via the `mutationKey`. Each invocation of `mutate` adds a new
|
||||
* entry to the mutation cache for `gcTime` milliseconds — with the `status: 'success'` filter below, check the
|
||||
* last item that `useMutationState` returns to get the latest successful invocation:
|
||||
* ```tsx
|
||||
* import { useMutationState } from '@tanstack/preact-query'
|
||||
*
|
||||
* function LatestPost() {
|
||||
* const savedPosts = useMutationState({
|
||||
* filters: { mutationKey: ['posts'], status: 'success' },
|
||||
* select: (mutation) => mutation.state.data,
|
||||
* })
|
||||
*
|
||||
* const latestSavedPost = savedPosts[savedPosts.length - 1]
|
||||
*
|
||||
* return <>{latestSavedPost ? 'Saved' : 'Nothing saved yet'}</>
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useMutationState<
|
||||
TResult = MutationState,
|
||||
TMutation extends Mutation<any, any, any, any> =
|
||||
MutationTypeFromResult<TResult>,
|
||||
>(
|
||||
options: MutationStateOptions<TResult, TMutation> = {},
|
||||
queryClient?: QueryClient,
|
||||
): Array<TResult> {
|
||||
const mutationCache = useQueryClient(queryClient).getMutationCache()
|
||||
const optionsRef = useRef(options)
|
||||
const result = useRef<Array<TResult>>(null)
|
||||
if (result.current === null) {
|
||||
result.current = getResult(mutationCache, options)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
optionsRef.current = options
|
||||
})
|
||||
|
||||
return useSyncExternalStore(
|
||||
useCallback(
|
||||
(onStoreChange) =>
|
||||
mutationCache.subscribe(() => {
|
||||
const nextResult = replaceEqualDeep(
|
||||
result.current,
|
||||
getResult(mutationCache, optionsRef.current),
|
||||
)
|
||||
if (result.current !== nextResult) {
|
||||
result.current = nextResult
|
||||
notifyManager.schedule(onStoreChange)
|
||||
}
|
||||
}),
|
||||
[mutationCache],
|
||||
),
|
||||
() => result.current,
|
||||
)!
|
||||
}
|
||||
72
packages/preact-query/src/usePrefetchInfiniteQuery.tsx
Normal file
72
packages/preact-query/src/usePrefetchInfiniteQuery.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { noop } from '@tanstack/query-core'
|
||||
import { useQueryClient } from './QueryClientProvider'
|
||||
|
||||
import type { DefaultError, QueryClient, QueryKey } from '@tanstack/query-core'
|
||||
import type { UsePrefetchInfiniteQueryOptions } from './types'
|
||||
|
||||
/**
|
||||
* `usePrefetchInfiniteQuery` does not return anything, it should be used just to fire a prefetch during render,
|
||||
* before a suspense boundary that wraps a component that uses `useSuspenseInfiniteQuery`. You can pass
|
||||
* everything to `usePrefetchInfiniteQuery` that you can pass to `queryClient.infiniteQuery`, though
|
||||
* `queryKey`, `initialPageParam`, and `getNextPageParam` are always required, and `queryFn` is required unless
|
||||
* a default query function has been defined.
|
||||
*
|
||||
* `getNextPageParam` receives both the last page of the infinite list of data and the full array of all pages,
|
||||
* as well as pageParam information, and should return a single variable that will be passed to your query
|
||||
* function as `context.pageParam`. Return `undefined` or `null` to indicate there is no next page available.
|
||||
*
|
||||
* The prefetch is skipped if the query already has any cached state — including a `pending`/`error` state left
|
||||
* over from a previous attempt — so calling this on every render is cheap and won't refetch data that's
|
||||
* already there or already in flight.
|
||||
*
|
||||
* @param options - The {@link UsePrefetchInfiniteQueryOptions} to use — everything you can pass to `queryClient.infiniteQuery`.
|
||||
* @param queryClient - Use this to use a custom `QueryClient`. Otherwise, the one from the nearest context will
|
||||
* be used.
|
||||
* @returns `void` — nothing is returned.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { Suspense } from 'preact/compat'
|
||||
* import { infiniteQueryOptions, usePrefetchInfiniteQuery } from '@tanstack/preact-query'
|
||||
*
|
||||
* const projectsOptions = infiniteQueryOptions({
|
||||
* queryKey: ['projects'],
|
||||
* queryFn: ({ pageParam }) => fetchProjects(pageParam),
|
||||
* initialPageParam: 0,
|
||||
* getNextPageParam: (lastPage) => lastPage.nextId,
|
||||
* })
|
||||
*
|
||||
* function App() {
|
||||
* // Fire the prefetch during render, before the suspense boundary below.
|
||||
* usePrefetchInfiniteQuery(projectsOptions)
|
||||
*
|
||||
* return (
|
||||
* <Suspense fallback={<h1>Loading projects...</h1>}>
|
||||
* <Projects />
|
||||
* </Suspense>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function usePrefetchInfiniteQuery<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
TPageParam = unknown,
|
||||
>(
|
||||
options: UsePrefetchInfiniteQueryOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey,
|
||||
TPageParam
|
||||
>,
|
||||
queryClient?: QueryClient,
|
||||
) {
|
||||
const client = useQueryClient(queryClient)
|
||||
|
||||
if (!client.getQueryState(options.queryKey)) {
|
||||
void client.infiniteQuery(options).catch(noop)
|
||||
}
|
||||
}
|
||||
63
packages/preact-query/src/usePrefetchQuery.tsx
Normal file
63
packages/preact-query/src/usePrefetchQuery.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { noop } from '@tanstack/query-core'
|
||||
import { useQueryClient } from './QueryClientProvider'
|
||||
|
||||
import type { DefaultError, QueryClient, QueryKey } from '@tanstack/query-core'
|
||||
import type { UsePrefetchQueryOptions } from './types'
|
||||
|
||||
/**
|
||||
* `usePrefetchQuery` does not return anything, it should be used just to fire a prefetch during render, before
|
||||
* a suspense boundary that wraps a component that uses `useSuspenseQuery`. You can pass everything to
|
||||
* `usePrefetchQuery` that you can pass to `queryClient.query`, though `queryKey` is always required, and
|
||||
* `queryFn` is required unless a default query function has been defined.
|
||||
*
|
||||
* The prefetch is skipped if the query already has any cached state — including a `pending`/`error` state left
|
||||
* over from a previous attempt — so calling this on every render is cheap and won't refetch data that's
|
||||
* already there or already in flight.
|
||||
*
|
||||
* @param options - The {@link UsePrefetchQueryOptions} to use — everything you can pass to `queryClient.query`.
|
||||
* @param queryClient - Use this to use a custom `QueryClient`. Otherwise, the one from the nearest context will
|
||||
* be used.
|
||||
* @returns `void` — nothing is returned.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { Suspense } from 'preact/compat'
|
||||
* import { usePrefetchQuery } from '@tanstack/preact-query'
|
||||
*
|
||||
* function App() {
|
||||
* // Fire the prefetch during render, before the suspense boundary below.
|
||||
* usePrefetchQuery({
|
||||
* queryKey: ['posts'],
|
||||
* queryFn: fetchPosts,
|
||||
* })
|
||||
*
|
||||
* return (
|
||||
* <Suspense fallback={<h1>Loading posts...</h1>}>
|
||||
* <Posts />
|
||||
* </Suspense>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function usePrefetchQuery<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
>(
|
||||
options: UsePrefetchQueryOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryData,
|
||||
TQueryKey
|
||||
>,
|
||||
queryClient?: QueryClient,
|
||||
) {
|
||||
const client = useQueryClient(queryClient)
|
||||
|
||||
if (!client.getQueryState(options.queryKey)) {
|
||||
void client.query(options).catch(noop)
|
||||
}
|
||||
}
|
||||
440
packages/preact-query/src/useQueries.ts
Normal file
440
packages/preact-query/src/useQueries.ts
Normal file
@@ -0,0 +1,440 @@
|
||||
import {
|
||||
QueriesObserver,
|
||||
QueryObserver,
|
||||
noop,
|
||||
notifyManager,
|
||||
} from '@tanstack/query-core'
|
||||
import type {
|
||||
DefaultError,
|
||||
OmitKeyof,
|
||||
QueriesObserverOptions,
|
||||
QueriesPlaceholderDataFunction,
|
||||
QueryClient,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
QueryObserverOptions,
|
||||
ThrowOnError,
|
||||
} from '@tanstack/query-core'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'preact/hooks'
|
||||
|
||||
import { useIsRestoring } from './IsRestoringProvider'
|
||||
import { useQueryClient } from './QueryClientProvider'
|
||||
import { useQueryErrorResetBoundary } from './QueryErrorResetBoundary'
|
||||
import {
|
||||
ensurePreventErrorBoundaryRetry,
|
||||
getHasError,
|
||||
useClearResetErrorBoundary,
|
||||
} from './errorBoundaryUtils'
|
||||
import {
|
||||
ensureSuspenseTimers,
|
||||
fetchOptimistic,
|
||||
shouldSuspend,
|
||||
} from './suspense'
|
||||
import type {
|
||||
DefinedUseQueryResult,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from './types'
|
||||
import { useSyncExternalStore } from './utils'
|
||||
|
||||
// This defines the `UseQueryOptions` that are accepted in `QueriesOptions` & `GetOptions`.
|
||||
// `placeholderData` function always gets undefined passed
|
||||
type UseQueryOptionsForUseQueries<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
> = OmitKeyof<
|
||||
UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
|
||||
'placeholderData' | 'subscribed'
|
||||
> & {
|
||||
placeholderData?: TQueryFnData | QueriesPlaceholderDataFunction<TQueryFnData>
|
||||
}
|
||||
|
||||
// Avoid TS depth-limit error in case of large array literal
|
||||
type MAXIMUM_DEPTH = 20
|
||||
|
||||
// Widen the type of the symbol to enable type inference even if skipToken is not immutable.
|
||||
type SkipTokenForUseQueries = symbol
|
||||
|
||||
type GetUseQueryOptionsForUseQueries<T> =
|
||||
// Part 1: responsible for applying explicit type parameter to function arguments, if object { queryFnData: TQueryFnData, error: TError, data: TData }
|
||||
T extends {
|
||||
queryFnData: infer TQueryFnData
|
||||
error?: infer TError
|
||||
data: infer TData
|
||||
}
|
||||
? UseQueryOptionsForUseQueries<TQueryFnData, TError, TData>
|
||||
: T extends { queryFnData: infer TQueryFnData; error?: infer TError }
|
||||
? UseQueryOptionsForUseQueries<TQueryFnData, TError>
|
||||
: T extends { data: infer TData; error?: infer TError }
|
||||
? UseQueryOptionsForUseQueries<unknown, TError, TData>
|
||||
: // Part 2: responsible for applying explicit type parameter to function arguments, if tuple [TQueryFnData, TError, TData]
|
||||
T extends [infer TQueryFnData, infer TError, infer TData]
|
||||
? UseQueryOptionsForUseQueries<TQueryFnData, TError, TData>
|
||||
: T extends [infer TQueryFnData, infer TError]
|
||||
? UseQueryOptionsForUseQueries<TQueryFnData, TError>
|
||||
: T extends [infer TQueryFnData]
|
||||
? UseQueryOptionsForUseQueries<TQueryFnData>
|
||||
: // Part 3: responsible for inferring and enforcing type if no explicit parameter was provided
|
||||
T extends {
|
||||
queryFn?:
|
||||
| QueryFunction<infer TQueryFnData, infer TQueryKey>
|
||||
| SkipTokenForUseQueries
|
||||
select?: (data: any) => infer TData
|
||||
throwOnError?: ThrowOnError<any, infer TError, any, any>
|
||||
}
|
||||
? UseQueryOptionsForUseQueries<
|
||||
TQueryFnData,
|
||||
unknown extends TError ? DefaultError : TError,
|
||||
unknown extends TData ? TQueryFnData : TData,
|
||||
TQueryKey
|
||||
>
|
||||
: // Fallback
|
||||
UseQueryOptionsForUseQueries
|
||||
|
||||
// A defined initialData setting should return a DefinedUseQueryResult rather than UseQueryResult
|
||||
type GetDefinedOrUndefinedQueryResult<T, TData, TError = unknown> = T extends {
|
||||
initialData?: infer TInitialData
|
||||
}
|
||||
? unknown extends TInitialData
|
||||
? UseQueryResult<TData, TError>
|
||||
: TInitialData extends TData
|
||||
? DefinedUseQueryResult<TData, TError>
|
||||
: TInitialData extends () => infer TInitialDataResult
|
||||
? unknown extends TInitialDataResult
|
||||
? UseQueryResult<TData, TError>
|
||||
: TInitialDataResult extends TData
|
||||
? DefinedUseQueryResult<TData, TError>
|
||||
: UseQueryResult<TData, TError>
|
||||
: UseQueryResult<TData, TError>
|
||||
: UseQueryResult<TData, TError>
|
||||
|
||||
type GetUseQueryResult<T> =
|
||||
// Part 1: responsible for mapping explicit type parameter to function result, if object
|
||||
T extends { queryFnData: any; error?: infer TError; data: infer TData }
|
||||
? GetDefinedOrUndefinedQueryResult<T, TData, TError>
|
||||
: T extends { queryFnData: infer TQueryFnData; error?: infer TError }
|
||||
? GetDefinedOrUndefinedQueryResult<T, TQueryFnData, TError>
|
||||
: T extends { data: infer TData; error?: infer TError }
|
||||
? GetDefinedOrUndefinedQueryResult<T, TData, TError>
|
||||
: // Part 2: responsible for mapping explicit type parameter to function result, if tuple
|
||||
T extends [any, infer TError, infer TData]
|
||||
? GetDefinedOrUndefinedQueryResult<T, TData, TError>
|
||||
: T extends [infer TQueryFnData, infer TError]
|
||||
? GetDefinedOrUndefinedQueryResult<T, TQueryFnData, TError>
|
||||
: T extends [infer TQueryFnData]
|
||||
? GetDefinedOrUndefinedQueryResult<T, TQueryFnData>
|
||||
: // Part 3: responsible for mapping inferred type to results, if no explicit parameter was provided
|
||||
T extends {
|
||||
queryFn?:
|
||||
| QueryFunction<infer TQueryFnData, any>
|
||||
| SkipTokenForUseQueries
|
||||
select?: (data: any) => infer TData
|
||||
throwOnError?: ThrowOnError<any, infer TError, any, any>
|
||||
}
|
||||
? GetDefinedOrUndefinedQueryResult<
|
||||
T,
|
||||
unknown extends TData ? TQueryFnData : TData,
|
||||
unknown extends TError ? DefaultError : TError
|
||||
>
|
||||
: // Fallback
|
||||
UseQueryResult
|
||||
|
||||
/**
|
||||
* The `queries` array accepted by `useQueries`. Recursively unwraps each tuple element so every entry's
|
||||
* `queryFn`/`select`/`throwOnError` are inferred individually, up to 20 elements. An opaque array (e.g.
|
||||
* `unknown[]`) is returned as-is; a non-tuple array of a known element type, or a tuple past 20 elements, falls
|
||||
* back to a single homogeneous options type.
|
||||
*
|
||||
* @template T - The type of the `queries` array as written at the call site.
|
||||
* @template TResults - The internal accumulator that this type builds during recursion. It is not meant
|
||||
* to be set explicitly.
|
||||
* @template TDepth - The internal recursion-depth counter, checked against the 20-element limit. It is not
|
||||
* meant to be set explicitly.
|
||||
*/
|
||||
export type QueriesOptions<
|
||||
T extends Array<any>,
|
||||
TResults extends Array<any> = [],
|
||||
TDepth extends ReadonlyArray<number> = [],
|
||||
> = TDepth['length'] extends MAXIMUM_DEPTH
|
||||
? Array<UseQueryOptionsForUseQueries>
|
||||
: T extends []
|
||||
? []
|
||||
: T extends [infer Head]
|
||||
? [...TResults, GetUseQueryOptionsForUseQueries<Head>]
|
||||
: T extends [infer Head, ...infer Tails]
|
||||
? QueriesOptions<
|
||||
[...Tails],
|
||||
[...TResults, GetUseQueryOptionsForUseQueries<Head>],
|
||||
[...TDepth, 1]
|
||||
>
|
||||
: ReadonlyArray<unknown> extends T
|
||||
? T
|
||||
: // If T is *some* array but we couldn't assign unknown[] to it, then it must hold some known/homogeneous type!
|
||||
// use this to infer the param types in the case of Array.map() argument
|
||||
T extends Array<
|
||||
UseQueryOptionsForUseQueries<
|
||||
infer TQueryFnData,
|
||||
infer TError,
|
||||
infer TData,
|
||||
infer TQueryKey
|
||||
>
|
||||
>
|
||||
? Array<
|
||||
UseQueryOptionsForUseQueries<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey
|
||||
>
|
||||
>
|
||||
: // Fallback
|
||||
Array<UseQueryOptionsForUseQueries>
|
||||
|
||||
/**
|
||||
* The result type returned by `useQueries`, when no `combine` is provided. Mirrors {@link QueriesOptions}: each
|
||||
* tuple element's result type is inferred individually, up to 20 elements. A non-tuple array is mapped
|
||||
* per-element instead, still inferring each entry individually; only past 20 elements does this fall back to a
|
||||
* single homogeneous {@link UseQueryResult} type.
|
||||
*
|
||||
* @template T - The type of the `queries` array, as inferred by {@link QueriesOptions}.
|
||||
* @template TResults - The internal accumulator that this type builds during recursion. It is not meant
|
||||
* to be set explicitly.
|
||||
* @template TDepth - The internal recursion-depth counter, checked against the 20-element limit. It is not
|
||||
* meant to be set explicitly.
|
||||
*/
|
||||
export type QueriesResults<
|
||||
T extends Array<any>,
|
||||
TResults extends Array<any> = [],
|
||||
TDepth extends ReadonlyArray<number> = [],
|
||||
> = TDepth['length'] extends MAXIMUM_DEPTH
|
||||
? Array<UseQueryResult>
|
||||
: T extends []
|
||||
? []
|
||||
: T extends [infer Head]
|
||||
? [...TResults, GetUseQueryResult<Head>]
|
||||
: T extends [infer Head, ...infer Tails]
|
||||
? QueriesResults<
|
||||
[...Tails],
|
||||
[...TResults, GetUseQueryResult<Head>],
|
||||
[...TDepth, 1]
|
||||
>
|
||||
: { [K in keyof T]: GetUseQueryResult<T[K]> }
|
||||
|
||||
/**
|
||||
* The `useQueries` hook can be used to fetch a variable number of queries.
|
||||
*
|
||||
* The `queries` key accepts an array with query option objects mostly identical to `useQuery` — see the
|
||||
* `queries` parameter below for the differences. A custom `QueryClient` is supplied once, as `useQueries`' own
|
||||
* top-level second argument, rather than per query.
|
||||
*
|
||||
* Having the same query key more than once in the array of query objects may cause some data to be shared
|
||||
* between queries. To avoid this, consider de-duplicating the queries and map the results back to the desired
|
||||
* structure.
|
||||
*
|
||||
* The `combine` option can be used to combine the results of the queries into a single value. The result will
|
||||
* be structurally shared to be as referentially stable as possible.
|
||||
*
|
||||
* @param queryClient - Use this to provide a custom `QueryClient`. Otherwise, the one from the nearest context
|
||||
* will be used.
|
||||
* @returns The combined result. Without `combine`, this is an array with all the query results, in the same
|
||||
* order as the input. When `combine` is provided, this is the value returned by `combine` instead.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { useQueries } from '@tanstack/preact-query'
|
||||
*
|
||||
* function Posts({ ids }: { ids: Array<number> }) {
|
||||
* const postQueries = useQueries({
|
||||
* queries: ids.map((id) => ({
|
||||
* queryKey: ['post', id],
|
||||
* queryFn: () => fetchPost(id),
|
||||
* staleTime: Infinity,
|
||||
* })),
|
||||
* })
|
||||
*
|
||||
* return (
|
||||
* <ul>
|
||||
* {postQueries.map((query, index) => {
|
||||
* if (query.isPending) return <li key={ids[index]}>Loading...</li>
|
||||
* if (query.isError) return <li key={ids[index]}>Error: {query.error.message}</li>
|
||||
* return <li key={ids[index]}>{query.data.title}</li>
|
||||
* })}
|
||||
* </ul>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* Combining results into a single value:
|
||||
* ```tsx
|
||||
* import { useQueries } from '@tanstack/preact-query'
|
||||
*
|
||||
* function Posts({ ids }: { ids: Array<number> }) {
|
||||
* const { data, isPending, isError } = useQueries({
|
||||
* queries: ids.map((id) => ({
|
||||
* queryKey: ['post', id],
|
||||
* queryFn: () => fetchPost(id),
|
||||
* })),
|
||||
* combine: (postQueries) => {
|
||||
* return {
|
||||
* data: postQueries.map((query) => query.data),
|
||||
* isPending: postQueries.some((query) => query.isPending),
|
||||
* isError: postQueries.some((query) => query.isError),
|
||||
* }
|
||||
* },
|
||||
* })
|
||||
*
|
||||
* if (isPending) return 'Loading...'
|
||||
* if (isError) return 'Error loading posts'
|
||||
*
|
||||
* return (
|
||||
* <ul>
|
||||
* {data.map((post) => (
|
||||
* <li key={post?.id}>{post?.title}</li>
|
||||
* ))}
|
||||
* </ul>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useQueries<
|
||||
T extends Array<any>,
|
||||
TCombinedResult = QueriesResults<T>,
|
||||
>(
|
||||
{
|
||||
queries,
|
||||
...options
|
||||
}: {
|
||||
/**
|
||||
* An array with query option objects, mostly identical to `useQuery` — except that `queryClient` and
|
||||
* `subscribed` aren't accepted per-query (`subscribed` is a top-level option here instead), and
|
||||
* `placeholderData` accepts a {@link QueriesPlaceholderDataFunction}, which is called with `previousData`
|
||||
* and `previousQuery` always `undefined`, rather than `useQuery`'s placeholder function.
|
||||
*/
|
||||
queries:
|
||||
| readonly [...QueriesOptions<T>]
|
||||
| readonly [...{ [K in keyof T]: GetUseQueryOptionsForUseQueries<T[K]> }]
|
||||
/**
|
||||
* Use this to combine the results of the queries into a single value. The result will be structurally
|
||||
* shared to be as referentially stable as possible.
|
||||
*/
|
||||
combine?: (result: QueriesResults<T>) => TCombinedResult
|
||||
/**
|
||||
* Set this to `false` to unsubscribe this observer from updates to the query cache.
|
||||
*
|
||||
* @defaultValue true
|
||||
*/
|
||||
subscribed?: boolean
|
||||
},
|
||||
queryClient?: QueryClient,
|
||||
): TCombinedResult {
|
||||
const client = useQueryClient(queryClient)
|
||||
const isRestoring = useIsRestoring()
|
||||
const errorResetBoundary = useQueryErrorResetBoundary()
|
||||
const subscribed = options.subscribed !== false
|
||||
|
||||
const defaultedQueries = useMemo(
|
||||
() =>
|
||||
queries.map((opts) => {
|
||||
const defaultedOptions = client.defaultQueryOptions(
|
||||
opts as QueryObserverOptions,
|
||||
)
|
||||
|
||||
// Make sure the results are already in fetching state before subscribing or updating options
|
||||
defaultedOptions._optimisticResults = isRestoring
|
||||
? 'isRestoring'
|
||||
: subscribed
|
||||
? 'optimistic'
|
||||
: undefined
|
||||
|
||||
return defaultedOptions
|
||||
}),
|
||||
[queries, client, isRestoring, subscribed],
|
||||
)
|
||||
|
||||
defaultedQueries.forEach((queryOptions) => {
|
||||
ensureSuspenseTimers(queryOptions)
|
||||
const query = client.getQueryCache().get(queryOptions.queryHash)
|
||||
ensurePreventErrorBoundaryRetry(queryOptions, errorResetBoundary, query)
|
||||
})
|
||||
|
||||
useClearResetErrorBoundary(errorResetBoundary)
|
||||
|
||||
const [observer] = useState(
|
||||
() =>
|
||||
new QueriesObserver<TCombinedResult>(
|
||||
client,
|
||||
defaultedQueries,
|
||||
options as QueriesObserverOptions<TCombinedResult>,
|
||||
),
|
||||
)
|
||||
|
||||
// note: this must be called before useSyncExternalStore
|
||||
const [optimisticResult, getCombinedResult, trackResult] =
|
||||
observer.getOptimisticResult(
|
||||
defaultedQueries,
|
||||
(options as QueriesObserverOptions<TCombinedResult>).combine,
|
||||
)
|
||||
|
||||
const shouldSubscribe = !isRestoring && subscribed
|
||||
useSyncExternalStore(
|
||||
useCallback(
|
||||
(onStoreChange) =>
|
||||
shouldSubscribe
|
||||
? observer.subscribe(notifyManager.batchCalls(onStoreChange))
|
||||
: noop,
|
||||
[observer, shouldSubscribe],
|
||||
),
|
||||
() => observer.getCurrentResult(),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
observer.setQueries(
|
||||
defaultedQueries,
|
||||
options as QueriesObserverOptions<TCombinedResult>,
|
||||
)
|
||||
}, [defaultedQueries, options, observer])
|
||||
|
||||
const shouldAtLeastOneSuspend = optimisticResult.some((result, index) =>
|
||||
shouldSuspend(defaultedQueries[index], result),
|
||||
)
|
||||
|
||||
const suspensePromises = shouldAtLeastOneSuspend
|
||||
? optimisticResult.flatMap((result, index) => {
|
||||
const opts = defaultedQueries[index]
|
||||
|
||||
if (opts && shouldSuspend(opts, result)) {
|
||||
const queryObserver = new QueryObserver(client, opts)
|
||||
return fetchOptimistic(opts, queryObserver, errorResetBoundary)
|
||||
}
|
||||
return []
|
||||
})
|
||||
: []
|
||||
|
||||
if (suspensePromises.length > 0) {
|
||||
throw Promise.all(suspensePromises)
|
||||
}
|
||||
const firstSingleResultWhichShouldThrow = optimisticResult.find(
|
||||
(result, index) => {
|
||||
const query = defaultedQueries[index]
|
||||
return (
|
||||
query &&
|
||||
getHasError({
|
||||
result,
|
||||
errorResetBoundary,
|
||||
throwOnError: query.throwOnError,
|
||||
query: client.getQueryCache().get(query.queryHash),
|
||||
suspense: query.suspense,
|
||||
})
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
if (firstSingleResultWhichShouldThrow) {
|
||||
throw firstSingleResultWhichShouldThrow.error
|
||||
}
|
||||
|
||||
return getCombinedResult(trackResult())
|
||||
}
|
||||
293
packages/preact-query/src/useQuery.ts
Normal file
293
packages/preact-query/src/useQuery.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { QueryObserver } from '@tanstack/query-core'
|
||||
import type { DefaultError, QueryClient, QueryKey } from '@tanstack/query-core'
|
||||
|
||||
import type {
|
||||
DefinedInitialDataOptions,
|
||||
UndefinedInitialDataOptions,
|
||||
} from './queryOptions'
|
||||
import type {
|
||||
DefinedUseQueryResult,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from './types'
|
||||
import { useBaseQuery } from './useBaseQuery'
|
||||
|
||||
/**
|
||||
* This overload is selected when `initialData` is set, so the resulting `data` is never `undefined`.
|
||||
*
|
||||
* @see {@link queryOptions} to share these options between `useQuery` and imperative APIs like `queryClient.query`.
|
||||
* @param options - The {@link DefinedInitialDataOptions} to use — everything you can pass to `useQuery`, with `initialData` set.
|
||||
* @param queryClient - Use this to use a custom `QueryClient`. Otherwise, the one from the nearest context will
|
||||
* be used.
|
||||
* @returns The current query result, typed so that `status` is `success` — or `error` if a fetch attempt
|
||||
* fails while keeping the existing data (`status` never resolves to `pending` in this overload's type,
|
||||
* since `initialData` guarantees data upfront). `isSuccess`/`isError` are derived booleans for convenience.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { useQuery } from '@tanstack/preact-query'
|
||||
*
|
||||
* function Posts() {
|
||||
* // `data` is `Post[]`, never `undefined`, thanks to `initialData` — even if a refetch fails,
|
||||
* // so the list stays visible alongside the error.
|
||||
* const { data, isError, error } = useQuery({
|
||||
* queryKey: ['posts'],
|
||||
* queryFn: fetchPosts,
|
||||
* initialData: [],
|
||||
* })
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* {isError ? <span>Error: {error.message}</span> : null}
|
||||
* <ul>
|
||||
* {data.map((post) => <li key={post.id}>{post.title}</li>)}
|
||||
* </ul>
|
||||
* </div>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useQuery<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
>(
|
||||
options: DefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey>,
|
||||
queryClient?: QueryClient,
|
||||
): DefinedUseQueryResult<TData, TError>
|
||||
|
||||
/**
|
||||
* @see {@link queryOptions} to share these options between `useQuery` and imperative APIs like `queryClient.query`.
|
||||
* @param options - The {@link UndefinedInitialDataOptions} to use — everything you can pass to `useQuery`.
|
||||
* @param queryClient - Use this to use a custom `QueryClient`. Otherwise, the one from the nearest context will
|
||||
* be used.
|
||||
* @returns The current query result. `status` is `pending` if there is no cached data to display, `error` if
|
||||
* the last fetch attempt failed, or `success` if the query has data to display. `isPending`/`isSuccess`/`isError`
|
||||
* are derived booleans for convenience.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { useQuery } from '@tanstack/preact-query'
|
||||
*
|
||||
* function Posts() {
|
||||
* const { status, data, error, isFetching } = useQuery({
|
||||
* queryKey: ['posts'],
|
||||
* queryFn: fetchPosts,
|
||||
* })
|
||||
*
|
||||
* if (status === 'pending') return 'Loading...'
|
||||
* if (status === 'error') return <span>Error: {error.message}</span>
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* <ul>
|
||||
* {data.map((post) => (
|
||||
* <li key={post.id}>{post.title}</li>
|
||||
* ))}
|
||||
* </ul>
|
||||
* <div>{isFetching ? 'Background Updating...' : ' '}</div>
|
||||
* </div>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* The same query, checking `isPending`/`isError` instead of `status` — pick whichever reads better to you:
|
||||
* ```tsx
|
||||
* import { useQuery } from '@tanstack/preact-query'
|
||||
*
|
||||
* function Posts() {
|
||||
* const { isPending, isError, data, error } = useQuery({
|
||||
* queryKey: ['posts'],
|
||||
* queryFn: fetchPosts,
|
||||
* })
|
||||
*
|
||||
* if (isPending) return 'Loading...'
|
||||
* if (isError) return <span>Error: {error.message}</span>
|
||||
*
|
||||
* return (
|
||||
* <ul>
|
||||
* {data.map((post) => <li key={post.id}>{post.title}</li>)}
|
||||
* </ul>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useQuery<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
>(
|
||||
options: UndefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey>,
|
||||
queryClient?: QueryClient,
|
||||
): UseQueryResult<TData, TError>
|
||||
|
||||
/**
|
||||
* @see {@link queryOptions} to share these options between `useQuery` and imperative APIs like `queryClient.query`.
|
||||
* @param options - The {@link UseQueryOptions} to use — everything you can pass to `useQuery`.
|
||||
* @param queryClient - Use this to use a custom `QueryClient`. Otherwise, the one from the nearest context will
|
||||
* be used.
|
||||
* @returns The current query result. `status` is `pending` if there is no cached data to display, `error` if
|
||||
* the last fetch attempt failed, or `success` if the query has data to display. `isPending`/`isSuccess`/`isError`
|
||||
* are derived booleans for convenience.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { useQuery } from '@tanstack/preact-query'
|
||||
*
|
||||
* function Posts() {
|
||||
* const { status, data, error, isFetching } = useQuery({
|
||||
* queryKey: ['posts'],
|
||||
* queryFn: fetchPosts,
|
||||
* })
|
||||
*
|
||||
* if (status === 'pending') return 'Loading...'
|
||||
* if (status === 'error') return <span>Error: {error.message}</span>
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* <ul>
|
||||
* {data.map((post) => (
|
||||
* <li key={post.id}>{post.title}</li>
|
||||
* ))}
|
||||
* </ul>
|
||||
* <div>{isFetching ? 'Background Updating...' : ' '}</div>
|
||||
* </div>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* `select` derives whatever `data` a component needs from the cached value, without changing what's
|
||||
* actually stored in the cache — the cache still holds the full `Post[]`, but `data` here is a `number`:
|
||||
* ```tsx
|
||||
* import { useQuery } from '@tanstack/preact-query'
|
||||
*
|
||||
* function PostCount() {
|
||||
* const { data, isPending, isError, error } = useQuery({
|
||||
* queryKey: ['posts'],
|
||||
* queryFn: fetchPosts,
|
||||
* select: (posts) => posts.length,
|
||||
* })
|
||||
*
|
||||
* if (isPending) return 'Loading...'
|
||||
* if (isError) return <span>Error: {error.message}</span>
|
||||
*
|
||||
* return <span>{data} posts</span>
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* A dependent query, only enabled once `postId` is set — use `isLoading`, not `isPending`, so the
|
||||
* loading state doesn't show while the query is disabled:
|
||||
* ```tsx
|
||||
* import { useQuery } from '@tanstack/preact-query'
|
||||
*
|
||||
* function Post({ postId }: { postId: number | undefined }) {
|
||||
* const { data, isLoading, isError, error } = useQuery({
|
||||
* queryKey: ['post', postId],
|
||||
* queryFn: () => fetchPost(postId!),
|
||||
* enabled: postId != null,
|
||||
* })
|
||||
*
|
||||
* if (postId == null) return 'Select a post'
|
||||
* if (isLoading) return 'Loading...'
|
||||
* if (isError) return <span>Error: {error.message}</span>
|
||||
*
|
||||
* return <h1>{data?.title}</h1>
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* The same dependent query, type safe: `skipToken` disables the query without needing the
|
||||
* non-null assertion above, since `queryFn` is only ever called when `postId` is defined.
|
||||
* `refetch` doesn't work while `queryFn` is `skipToken` — use `enabled: false` instead if you
|
||||
* need to trigger the query manually:
|
||||
* ```tsx
|
||||
* import { skipToken, useQuery } from '@tanstack/preact-query'
|
||||
*
|
||||
* function Post({ postId }: { postId: number | undefined }) {
|
||||
* const { data, isLoading, isError, error } = useQuery({
|
||||
* queryKey: ['post', postId],
|
||||
* queryFn: postId != null ? () => fetchPost(postId) : skipToken,
|
||||
* })
|
||||
*
|
||||
* if (postId == null) return 'Select a post'
|
||||
* if (isLoading) return 'Loading...'
|
||||
* if (isError) return <span>Error: {error.message}</span>
|
||||
*
|
||||
* return <h1>{data?.title}</h1>
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* Seeding a detail query from an already-cached list, to skip the loading state:
|
||||
* ```tsx
|
||||
* import { useQuery, useQueryClient } from '@tanstack/preact-query'
|
||||
*
|
||||
* function Post({ postId }: { postId: number }) {
|
||||
* const queryClient = useQueryClient()
|
||||
*
|
||||
* const { data, isError, error } = useQuery({
|
||||
* queryKey: ['post', postId],
|
||||
* queryFn: () => fetchPost(postId),
|
||||
* initialData: () =>
|
||||
* queryClient
|
||||
* .getQueryData<Array<Post>>(['posts'])
|
||||
* ?.find((post) => post.id === postId),
|
||||
* })
|
||||
*
|
||||
* if (isError) return <span>Error: {error.message}</span>
|
||||
*
|
||||
* return <h1>{data?.title}</h1>
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* Paginated data, keeping the previous page's data visible while the next page loads:
|
||||
* ```tsx
|
||||
* import { keepPreviousData, useQuery } from '@tanstack/preact-query'
|
||||
* import { useState } from 'preact/hooks'
|
||||
*
|
||||
* function Posts() {
|
||||
* const [page, setPage] = useState(0)
|
||||
*
|
||||
* const { data, isPlaceholderData, isError, error } = useQuery({
|
||||
* queryKey: ['posts', page],
|
||||
* queryFn: () => fetchPosts(page),
|
||||
* placeholderData: keepPreviousData,
|
||||
* })
|
||||
*
|
||||
* if (isError) return <span>Error: {error.message}</span>
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* <ul>
|
||||
* {data?.map((post) => <li key={post.id}>{post.title}</li>)}
|
||||
* </ul>
|
||||
* <button
|
||||
* disabled={isPlaceholderData}
|
||||
* onClick={() => setPage((old) => old + 1)}
|
||||
* >
|
||||
* Next Page
|
||||
* </button>
|
||||
* </div>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useQuery<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
>(
|
||||
options: UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
|
||||
queryClient?: QueryClient,
|
||||
): UseQueryResult<TData, TError>
|
||||
|
||||
export function useQuery(options: UseQueryOptions, queryClient?: QueryClient) {
|
||||
return useBaseQuery(options, QueryObserver, queryClient)
|
||||
}
|
||||
157
packages/preact-query/src/useSuspenseInfiniteQuery.ts
Normal file
157
packages/preact-query/src/useSuspenseInfiniteQuery.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { InfiniteQueryObserver, skipToken } from '@tanstack/query-core'
|
||||
import type {
|
||||
DefaultError,
|
||||
InfiniteData,
|
||||
InfiniteQueryObserverSuccessResult,
|
||||
QueryClient,
|
||||
QueryKey,
|
||||
QueryObserver,
|
||||
} from '@tanstack/query-core'
|
||||
|
||||
import { defaultThrowOnError } from './suspense'
|
||||
import type {
|
||||
UseSuspenseInfiniteQueryOptions,
|
||||
UseSuspenseInfiniteQueryResult,
|
||||
} from './types'
|
||||
import { useBaseQuery } from './useBaseQuery'
|
||||
|
||||
/**
|
||||
* The options for `useSuspenseInfiniteQuery` are the same as for `useInfiniteQuery`, except for `throwOnError`,
|
||||
* `enabled`, and `placeholderData`.
|
||||
*
|
||||
* Caveat: cancellation does not work.
|
||||
*
|
||||
* @remarks Multiple suspenseful query calls in the same component suspend serially, causing a request
|
||||
* waterfall — each one blocks rendering until it resolves, so the next doesn't even start fetching until
|
||||
* then. There's no way to parallelize multiple infinite queries under Suspense. Also keep in mind that
|
||||
* imperative fetch calls, such as `fetchNextPage`, may interfere with the default refetch behavior,
|
||||
* resulting in outdated data. Make sure to call these functions only in response to user actions, or add
|
||||
* conditions like `hasNextPage && !isFetching`.
|
||||
* @see {@link useInfiniteQuery} for the non-Suspense version of this hook.
|
||||
* @param options - The {@link UseSuspenseInfiniteQueryOptions} to use — the same options as `useInfiniteQuery`, minus the ones listed above.
|
||||
* @param queryClient - Use this to use a custom `QueryClient`. Otherwise, the one from the nearest context will
|
||||
* be used.
|
||||
* @returns The same object as `useInfiniteQuery`, except that `data` is guaranteed to be defined,
|
||||
* `isPlaceholderData` is missing, and `status` is either `success` or `error` (with the derived flags set
|
||||
* accordingly).
|
||||
*
|
||||
* @example
|
||||
* The query error is thrown if a fetch fails and no cached data exists yet, so an error boundary is
|
||||
* required around `<Suspense>`. A failed background refetch instead continues to render the cached data.
|
||||
* Use {@link QueryErrorResetBoundary} to let the user retry after such an error:
|
||||
* ```tsx
|
||||
* import { Suspense } from 'preact/compat'
|
||||
* import { useErrorBoundary } from 'preact/hooks'
|
||||
* import {
|
||||
* QueryErrorResetBoundary,
|
||||
* useSuspenseInfiniteQuery,
|
||||
* } from '@tanstack/preact-query'
|
||||
* import type { ComponentChildren } from 'preact'
|
||||
*
|
||||
* function Projects() {
|
||||
* // `data` is guaranteed to be defined here — no `isPending` check needed.
|
||||
* const { data, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage } =
|
||||
* useSuspenseInfiniteQuery({
|
||||
* queryKey: ['projects'],
|
||||
* queryFn: ({ pageParam }) => fetchProjects(pageParam),
|
||||
* initialPageParam: 0,
|
||||
* getNextPageParam: (lastPage) => lastPage.nextId,
|
||||
* })
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* <ul>
|
||||
* {data.pages.map((page) =>
|
||||
* page.projects.map((project) => <li key={project.id}>{project.name}</li>),
|
||||
* )}
|
||||
* </ul>
|
||||
* <button
|
||||
* onClick={() => fetchNextPage()}
|
||||
* disabled={!hasNextPage || isFetching}
|
||||
* >
|
||||
* {isFetchingNextPage
|
||||
* ? 'Loading more...'
|
||||
* : hasNextPage
|
||||
* ? 'Load More'
|
||||
* : 'Nothing more to load'}
|
||||
* </button>
|
||||
* </div>
|
||||
* )
|
||||
* }
|
||||
*
|
||||
* function App() {
|
||||
* return (
|
||||
* <QueryErrorResetBoundary>
|
||||
* {({ reset }) => (
|
||||
* <ErrorBoundary
|
||||
* onReset={reset}
|
||||
* fallbackRender={({ resetErrorBoundary }) => (
|
||||
* <div>
|
||||
* There was an error!
|
||||
* <button onClick={() => resetErrorBoundary()}>Try again</button>
|
||||
* </div>
|
||||
* )}
|
||||
* >
|
||||
* <Suspense fallback={<h1>Loading projects...</h1>}>
|
||||
* <Projects />
|
||||
* </Suspense>
|
||||
* </ErrorBoundary>
|
||||
* )}
|
||||
* </QueryErrorResetBoundary>
|
||||
* )
|
||||
* }
|
||||
*
|
||||
* function ErrorBoundary({
|
||||
* children,
|
||||
* onReset,
|
||||
* fallbackRender,
|
||||
* }: {
|
||||
* children: ComponentChildren
|
||||
* onReset: () => void
|
||||
* fallbackRender: (props: {
|
||||
* error: Error
|
||||
* resetErrorBoundary: () => void
|
||||
* }) => ComponentChildren
|
||||
* }) {
|
||||
* const [error, resetErrorBoundary] = useErrorBoundary(() => onReset())
|
||||
*
|
||||
* if (error) return fallbackRender({ error, resetErrorBoundary })
|
||||
*
|
||||
* return children
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useSuspenseInfiniteQuery<
|
||||
TQueryFnData,
|
||||
TError = DefaultError,
|
||||
TData = InfiniteData<TQueryFnData>,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
TPageParam = unknown,
|
||||
>(
|
||||
options: UseSuspenseInfiniteQueryOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey,
|
||||
TPageParam
|
||||
>,
|
||||
queryClient?: QueryClient,
|
||||
): UseSuspenseInfiniteQueryResult<TData, TError> {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
if ((options.queryFn as any) === skipToken) {
|
||||
console.error('skipToken is not allowed for useSuspenseInfiniteQuery')
|
||||
}
|
||||
}
|
||||
|
||||
return useBaseQuery(
|
||||
{
|
||||
...options,
|
||||
enabled: true,
|
||||
suspense: true,
|
||||
throwOnError: defaultThrowOnError,
|
||||
placeholderData: undefined,
|
||||
},
|
||||
InfiniteQueryObserver as typeof QueryObserver,
|
||||
queryClient,
|
||||
) as InfiniteQueryObserverSuccessResult<TData, TError>
|
||||
}
|
||||
629
packages/preact-query/src/useSuspenseQueries.ts
Normal file
629
packages/preact-query/src/useSuspenseQueries.ts
Normal file
@@ -0,0 +1,629 @@
|
||||
import { skipToken } from '@tanstack/query-core'
|
||||
import type {
|
||||
DefaultError,
|
||||
QueryClient,
|
||||
QueryFunction,
|
||||
ThrowOnError,
|
||||
} from '@tanstack/query-core'
|
||||
|
||||
import { defaultThrowOnError } from './suspense'
|
||||
import type { UseSuspenseQueryOptions, UseSuspenseQueryResult } from './types'
|
||||
import { useQueries } from './useQueries'
|
||||
|
||||
// Avoid TS depth-limit error in case of large array literal
|
||||
type MAXIMUM_DEPTH = 20
|
||||
|
||||
// Widen the type of the symbol to enable type inference even if skipToken is not immutable.
|
||||
type SkipTokenForUseQueries = symbol
|
||||
|
||||
type GetUseSuspenseQueryOptions<T> =
|
||||
// Part 1: responsible for applying explicit type parameter to function arguments, if object { queryFnData: TQueryFnData, error: TError, data: TData }
|
||||
T extends {
|
||||
queryFnData: infer TQueryFnData
|
||||
error?: infer TError
|
||||
data: infer TData
|
||||
}
|
||||
? UseSuspenseQueryOptions<TQueryFnData, TError, TData>
|
||||
: T extends { queryFnData: infer TQueryFnData; error?: infer TError }
|
||||
? UseSuspenseQueryOptions<TQueryFnData, TError>
|
||||
: T extends { data: infer TData; error?: infer TError }
|
||||
? UseSuspenseQueryOptions<unknown, TError, TData>
|
||||
: // Part 2: responsible for applying explicit type parameter to function arguments, if tuple [TQueryFnData, TError, TData]
|
||||
T extends [infer TQueryFnData, infer TError, infer TData]
|
||||
? UseSuspenseQueryOptions<TQueryFnData, TError, TData>
|
||||
: T extends [infer TQueryFnData, infer TError]
|
||||
? UseSuspenseQueryOptions<TQueryFnData, TError>
|
||||
: T extends [infer TQueryFnData]
|
||||
? UseSuspenseQueryOptions<TQueryFnData>
|
||||
: // Part 3: responsible for inferring and enforcing type if no explicit parameter was provided
|
||||
T extends {
|
||||
queryFn?:
|
||||
| QueryFunction<infer TQueryFnData, infer TQueryKey>
|
||||
| SkipTokenForUseQueries
|
||||
select?: (data: any) => infer TData
|
||||
throwOnError?: ThrowOnError<any, infer TError, any, any>
|
||||
}
|
||||
? UseSuspenseQueryOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey
|
||||
>
|
||||
: T extends {
|
||||
queryFn?:
|
||||
| QueryFunction<infer TQueryFnData, infer TQueryKey>
|
||||
| SkipTokenForUseQueries
|
||||
throwOnError?: ThrowOnError<any, infer TError, any, any>
|
||||
}
|
||||
? UseSuspenseQueryOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TQueryFnData,
|
||||
TQueryKey
|
||||
>
|
||||
: // Fallback
|
||||
UseSuspenseQueryOptions
|
||||
|
||||
type GetUseSuspenseQueryResult<T> =
|
||||
// Part 1: responsible for mapping explicit type parameter to function result, if object
|
||||
T extends { queryFnData: any; error?: infer TError; data: infer TData }
|
||||
? UseSuspenseQueryResult<TData, TError>
|
||||
: T extends { queryFnData: infer TQueryFnData; error?: infer TError }
|
||||
? UseSuspenseQueryResult<TQueryFnData, TError>
|
||||
: T extends { data: infer TData; error?: infer TError }
|
||||
? UseSuspenseQueryResult<TData, TError>
|
||||
: // Part 2: responsible for mapping explicit type parameter to function result, if tuple
|
||||
T extends [any, infer TError, infer TData]
|
||||
? UseSuspenseQueryResult<TData, TError>
|
||||
: T extends [infer TQueryFnData, infer TError]
|
||||
? UseSuspenseQueryResult<TQueryFnData, TError>
|
||||
: T extends [infer TQueryFnData]
|
||||
? UseSuspenseQueryResult<TQueryFnData>
|
||||
: // Part 3: responsible for mapping inferred type to results, if no explicit parameter was provided
|
||||
T extends {
|
||||
queryFn?:
|
||||
| QueryFunction<infer TQueryFnData, any>
|
||||
| SkipTokenForUseQueries
|
||||
select?: (data: any) => infer TData
|
||||
throwOnError?: ThrowOnError<any, infer TError, any, any>
|
||||
}
|
||||
? UseSuspenseQueryResult<
|
||||
unknown extends TData ? TQueryFnData : TData,
|
||||
unknown extends TError ? DefaultError : TError
|
||||
>
|
||||
: T extends {
|
||||
queryFn?:
|
||||
| QueryFunction<infer TQueryFnData, any>
|
||||
| SkipTokenForUseQueries
|
||||
throwOnError?: ThrowOnError<any, infer TError, any, any>
|
||||
}
|
||||
? UseSuspenseQueryResult<
|
||||
TQueryFnData,
|
||||
unknown extends TError ? DefaultError : TError
|
||||
>
|
||||
: // Fallback
|
||||
UseSuspenseQueryResult
|
||||
|
||||
/**
|
||||
* The `queries` array accepted by `useSuspenseQueries`. Recursively unwraps each tuple element so every
|
||||
* entry's `queryFn`/`select` are inferred individually, up to 20 elements. An opaque array (e.g. `unknown[]`)
|
||||
* is returned as-is; a non-tuple array of a known element type, or a tuple past 20 elements, falls back to a
|
||||
* single homogeneous {@link UseSuspenseQueryOptions} type.
|
||||
*
|
||||
* @template T - The type of the `queries` array as written at the call site.
|
||||
* @template TResults - The internal accumulator that this type builds during recursion. It is not meant
|
||||
* to be set explicitly.
|
||||
* @template TDepth - The internal recursion-depth counter, checked against the 20-element limit. It is not
|
||||
* meant to be set explicitly.
|
||||
*/
|
||||
export type SuspenseQueriesOptions<
|
||||
T extends Array<any>,
|
||||
TResults extends Array<any> = [],
|
||||
TDepth extends ReadonlyArray<number> = [],
|
||||
> = TDepth['length'] extends MAXIMUM_DEPTH
|
||||
? Array<UseSuspenseQueryOptions>
|
||||
: T extends []
|
||||
? []
|
||||
: T extends [infer Head]
|
||||
? [...TResults, GetUseSuspenseQueryOptions<Head>]
|
||||
: T extends [infer Head, ...infer Tails]
|
||||
? SuspenseQueriesOptions<
|
||||
[...Tails],
|
||||
[...TResults, GetUseSuspenseQueryOptions<Head>],
|
||||
[...TDepth, 1]
|
||||
>
|
||||
: Array<unknown> extends T
|
||||
? T
|
||||
: // If T is *some* array but we couldn't assign unknown[] to it, then it must hold some known/homogeneous type!
|
||||
// use this to infer the param types in the case of Array.map() argument
|
||||
T extends Array<
|
||||
UseSuspenseQueryOptions<
|
||||
infer TQueryFnData,
|
||||
infer TError,
|
||||
infer TData,
|
||||
infer TQueryKey
|
||||
>
|
||||
>
|
||||
? Array<
|
||||
UseSuspenseQueryOptions<TQueryFnData, TError, TData, TQueryKey>
|
||||
>
|
||||
: // Fallback
|
||||
Array<UseSuspenseQueryOptions>
|
||||
|
||||
/**
|
||||
* The result type returned by `useSuspenseQueries`, when no `combine` is provided. Mirrors
|
||||
* {@link SuspenseQueriesOptions}: each tuple element's result type is inferred individually, up to 20 elements.
|
||||
* A non-tuple array is mapped per-element instead, still inferring each entry individually; only past 20
|
||||
* elements does this fall back to a single homogeneous {@link UseSuspenseQueryResult} type.
|
||||
*
|
||||
* @template T - The type of the `queries` array, as inferred by {@link SuspenseQueriesOptions}.
|
||||
* @template TResults - The internal accumulator that this type builds during recursion. It is not meant
|
||||
* to be set explicitly.
|
||||
* @template TDepth - The internal recursion-depth counter, checked against the 20-element limit. It is not
|
||||
* meant to be set explicitly.
|
||||
*/
|
||||
export type SuspenseQueriesResults<
|
||||
T extends Array<any>,
|
||||
TResults extends Array<any> = [],
|
||||
TDepth extends ReadonlyArray<number> = [],
|
||||
> = TDepth['length'] extends MAXIMUM_DEPTH
|
||||
? Array<UseSuspenseQueryResult>
|
||||
: T extends []
|
||||
? []
|
||||
: T extends [infer Head]
|
||||
? [...TResults, GetUseSuspenseQueryResult<Head>]
|
||||
: T extends [infer Head, ...infer Tails]
|
||||
? SuspenseQueriesResults<
|
||||
[...Tails],
|
||||
[...TResults, GetUseSuspenseQueryResult<Head>],
|
||||
[...TDepth, 1]
|
||||
>
|
||||
: { [K in keyof T]: GetUseSuspenseQueryResult<T[K]> }
|
||||
|
||||
/**
|
||||
* The options for `useSuspenseQueries` are the same as for `useQueries`, except that the top-level `subscribed`
|
||||
* option isn't supported, and each `query` can't have `throwOnError`, `enabled`, or `placeholderData`.
|
||||
*
|
||||
* @param options - The `queries` array to run in Suspense, and an optional `combine` function.
|
||||
* @param queryClient - Use this to provide a custom `QueryClient`. Otherwise, the one from the nearest context
|
||||
* will be used.
|
||||
* @returns The same structure as `useQueries`, except that for each `query`, `data` is guaranteed to be
|
||||
* defined, `isPlaceholderData` is missing, and `status` is either `success` or `error` (with the derived
|
||||
* flags set accordingly).
|
||||
*
|
||||
* Caveat: the component will only re-mount after all queries have finished loading. Hence, if a query has gone
|
||||
* stale in the time it took for all the queries to complete, it will be fetched again at re-mount. To avoid
|
||||
* this, make sure to set a high enough `staleTime`. Cancellation does not work.
|
||||
*
|
||||
* @example
|
||||
* The query error is thrown if a fetch fails and no cached data exists yet, so an error boundary is
|
||||
* required around `<Suspense>`. A failed background refetch instead continues to render the cached data.
|
||||
* Use {@link QueryErrorResetBoundary} to let the user retry after such an error:
|
||||
* ```tsx
|
||||
* import { Suspense } from 'preact/compat'
|
||||
* import { useErrorBoundary } from 'preact/hooks'
|
||||
* import {
|
||||
* QueryErrorResetBoundary,
|
||||
* useSuspenseQueries,
|
||||
* } from '@tanstack/preact-query'
|
||||
* import type { ComponentChildren } from 'preact'
|
||||
*
|
||||
* function Posts({ ids }: { ids: Array<number> }) {
|
||||
* // Every result is guaranteed to be defined — no per-query `isPending` check needed.
|
||||
* const postQueries = useSuspenseQueries({
|
||||
* queries: ids.map((id) => ({
|
||||
* queryKey: ['post', id],
|
||||
* queryFn: () => fetchPost(id),
|
||||
* })),
|
||||
* })
|
||||
*
|
||||
* return (
|
||||
* <ul>
|
||||
* {postQueries.map((query) => (
|
||||
* <li key={query.data.id}>{query.data.title}</li>
|
||||
* ))}
|
||||
* </ul>
|
||||
* )
|
||||
* }
|
||||
*
|
||||
* function App() {
|
||||
* return (
|
||||
* <QueryErrorResetBoundary>
|
||||
* {({ reset }) => (
|
||||
* <ErrorBoundary
|
||||
* onReset={reset}
|
||||
* fallbackRender={({ resetErrorBoundary }) => (
|
||||
* <div>
|
||||
* There was an error!
|
||||
* <button onClick={() => resetErrorBoundary()}>Try again</button>
|
||||
* </div>
|
||||
* )}
|
||||
* >
|
||||
* <Suspense fallback={<h1>Loading posts...</h1>}>
|
||||
* <Posts ids={[1, 2, 3]} />
|
||||
* </Suspense>
|
||||
* </ErrorBoundary>
|
||||
* )}
|
||||
* </QueryErrorResetBoundary>
|
||||
* )
|
||||
* }
|
||||
*
|
||||
* function ErrorBoundary({
|
||||
* children,
|
||||
* onReset,
|
||||
* fallbackRender,
|
||||
* }: {
|
||||
* children: ComponentChildren
|
||||
* onReset: () => void
|
||||
* fallbackRender: (props: {
|
||||
* error: Error
|
||||
* resetErrorBoundary: () => void
|
||||
* }) => ComponentChildren
|
||||
* }) {
|
||||
* const [error, resetErrorBoundary] = useErrorBoundary(() => onReset())
|
||||
*
|
||||
* if (error) return fallbackRender({ error, resetErrorBoundary })
|
||||
*
|
||||
* return children
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* Several different queries — use `useSuspenseQueries` instead of multiple `useSuspenseQuery` calls, so
|
||||
* they fetch in parallel rather than suspending one after another:
|
||||
* ```tsx
|
||||
* import { Suspense } from 'preact/compat'
|
||||
* import { useErrorBoundary } from 'preact/hooks'
|
||||
* import {
|
||||
* QueryErrorResetBoundary,
|
||||
* useSuspenseQueries,
|
||||
* } from '@tanstack/preact-query'
|
||||
* import type { ComponentChildren } from 'preact'
|
||||
*
|
||||
* function Dashboard() {
|
||||
* const [usersQuery, teamsQuery, projectsQuery] = useSuspenseQueries({
|
||||
* queries: [
|
||||
* { queryKey: ['users'], queryFn: fetchUsers },
|
||||
* { queryKey: ['teams'], queryFn: fetchTeams },
|
||||
* { queryKey: ['projects'], queryFn: fetchProjects },
|
||||
* ],
|
||||
* })
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* <UserList users={usersQuery.data} />
|
||||
* <TeamList teams={teamsQuery.data} />
|
||||
* <ProjectList projects={projectsQuery.data} />
|
||||
* </div>
|
||||
* )
|
||||
* }
|
||||
*
|
||||
* function App() {
|
||||
* return (
|
||||
* <QueryErrorResetBoundary>
|
||||
* {({ reset }) => (
|
||||
* <ErrorBoundary
|
||||
* onReset={reset}
|
||||
* fallbackRender={({ resetErrorBoundary }) => (
|
||||
* <div>
|
||||
* There was an error!
|
||||
* <button onClick={() => resetErrorBoundary()}>Try again</button>
|
||||
* </div>
|
||||
* )}
|
||||
* >
|
||||
* <Suspense fallback={<h1>Loading dashboard...</h1>}>
|
||||
* <Dashboard />
|
||||
* </Suspense>
|
||||
* </ErrorBoundary>
|
||||
* )}
|
||||
* </QueryErrorResetBoundary>
|
||||
* )
|
||||
* }
|
||||
*
|
||||
* function ErrorBoundary({
|
||||
* children,
|
||||
* onReset,
|
||||
* fallbackRender,
|
||||
* }: {
|
||||
* children: ComponentChildren
|
||||
* onReset: () => void
|
||||
* fallbackRender: (props: {
|
||||
* error: Error
|
||||
* resetErrorBoundary: () => void
|
||||
* }) => ComponentChildren
|
||||
* }) {
|
||||
* const [error, resetErrorBoundary] = useErrorBoundary(() => onReset())
|
||||
*
|
||||
* if (error) return fallbackRender({ error, resetErrorBoundary })
|
||||
*
|
||||
* return children
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* `combine`s the results into a single boolean, so `Refresh` only re-renders when that boolean changes,
|
||||
* not on every individual query update. This overload is the only one that accepts `combine`:
|
||||
* ```tsx
|
||||
* import { Suspense } from 'preact/compat'
|
||||
* import { useErrorBoundary } from 'preact/hooks'
|
||||
* import {
|
||||
* QueryErrorResetBoundary,
|
||||
* useSuspenseQueries,
|
||||
* } from '@tanstack/preact-query'
|
||||
* import type { ComponentChildren } from 'preact'
|
||||
*
|
||||
* function Refresh() {
|
||||
* const anyFetching = useSuspenseQueries({
|
||||
* queries: [
|
||||
* { queryKey: ['users'], queryFn: fetchUsers },
|
||||
* { queryKey: ['teams'], queryFn: fetchTeams },
|
||||
* ],
|
||||
* combine: (results) => results.some((result) => result.isFetching),
|
||||
* })
|
||||
*
|
||||
* return anyFetching ? <span>Refreshing…</span> : null
|
||||
* }
|
||||
*
|
||||
* function App() {
|
||||
* return (
|
||||
* <QueryErrorResetBoundary>
|
||||
* {({ reset }) => (
|
||||
* <ErrorBoundary
|
||||
* onReset={reset}
|
||||
* fallbackRender={({ resetErrorBoundary }) => (
|
||||
* <div>
|
||||
* There was an error!
|
||||
* <button onClick={() => resetErrorBoundary()}>Try again</button>
|
||||
* </div>
|
||||
* )}
|
||||
* >
|
||||
* <Suspense fallback={<h1>Loading dashboard...</h1>}>
|
||||
* <Refresh />
|
||||
* </Suspense>
|
||||
* </ErrorBoundary>
|
||||
* )}
|
||||
* </QueryErrorResetBoundary>
|
||||
* )
|
||||
* }
|
||||
*
|
||||
* function ErrorBoundary({
|
||||
* children,
|
||||
* onReset,
|
||||
* fallbackRender,
|
||||
* }: {
|
||||
* children: ComponentChildren
|
||||
* onReset: () => void
|
||||
* fallbackRender: (props: {
|
||||
* error: Error
|
||||
* resetErrorBoundary: () => void
|
||||
* }) => ComponentChildren
|
||||
* }) {
|
||||
* const [error, resetErrorBoundary] = useErrorBoundary(() => onReset())
|
||||
*
|
||||
* if (error) return fallbackRender({ error, resetErrorBoundary })
|
||||
*
|
||||
* return children
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useSuspenseQueries<
|
||||
T extends Array<any>,
|
||||
TCombinedResult = SuspenseQueriesResults<T>,
|
||||
>(
|
||||
options: {
|
||||
/**
|
||||
* An array with query option objects identical to `useSuspenseQuery`.
|
||||
*/
|
||||
queries:
|
||||
| readonly [...SuspenseQueriesOptions<T>]
|
||||
| readonly [...{ [K in keyof T]: GetUseSuspenseQueryOptions<T[K]> }]
|
||||
/**
|
||||
* Use this to combine the results of the queries into a single value. The result will be structurally
|
||||
* shared to be as referentially stable as possible.
|
||||
*/
|
||||
combine?: (result: SuspenseQueriesResults<T>) => TCombinedResult
|
||||
},
|
||||
queryClient?: QueryClient,
|
||||
): TCombinedResult
|
||||
|
||||
/**
|
||||
* The options for `useSuspenseQueries` are the same as for `useQueries`, except that the top-level `subscribed`
|
||||
* option isn't supported, and each `query` can't have `throwOnError`, `enabled`, or `placeholderData`.
|
||||
*
|
||||
* @param options - The `queries` array to run in Suspense, and an optional `combine` function.
|
||||
* @param queryClient - Use this to provide a custom `QueryClient`. Otherwise, the one from the nearest context
|
||||
* will be used.
|
||||
* @returns The same structure as `useQueries`, except that for each `query`, `data` is guaranteed to be
|
||||
* defined, `isPlaceholderData` is missing, and `status` is either `success` or `error` (with the derived
|
||||
* flags set accordingly).
|
||||
*
|
||||
* Caveat: the component will only re-mount after all queries have finished loading. Hence, if a query has gone
|
||||
* stale in the time it took for all the queries to complete, it will be fetched again at re-mount. To avoid
|
||||
* this, make sure to set a high enough `staleTime`. Cancellation does not work.
|
||||
*
|
||||
* @example
|
||||
* The query error is thrown if a fetch fails and no cached data exists yet, so an error boundary is
|
||||
* required around `<Suspense>`. A failed background refetch instead continues to render the cached data.
|
||||
* Use {@link QueryErrorResetBoundary} to let the user retry after such an error:
|
||||
* ```tsx
|
||||
* import { Suspense } from 'preact/compat'
|
||||
* import { useErrorBoundary } from 'preact/hooks'
|
||||
* import {
|
||||
* QueryErrorResetBoundary,
|
||||
* useSuspenseQueries,
|
||||
* } from '@tanstack/preact-query'
|
||||
* import type { ComponentChildren } from 'preact'
|
||||
*
|
||||
* function Posts({ ids }: { ids: Array<number> }) {
|
||||
* // Every result is guaranteed to be defined — no per-query `isPending` check needed.
|
||||
* const postQueries = useSuspenseQueries({
|
||||
* queries: ids.map((id) => ({
|
||||
* queryKey: ['post', id],
|
||||
* queryFn: () => fetchPost(id),
|
||||
* })),
|
||||
* })
|
||||
*
|
||||
* return (
|
||||
* <ul>
|
||||
* {postQueries.map((query) => (
|
||||
* <li key={query.data.id}>{query.data.title}</li>
|
||||
* ))}
|
||||
* </ul>
|
||||
* )
|
||||
* }
|
||||
*
|
||||
* function App() {
|
||||
* return (
|
||||
* <QueryErrorResetBoundary>
|
||||
* {({ reset }) => (
|
||||
* <ErrorBoundary
|
||||
* onReset={reset}
|
||||
* fallbackRender={({ resetErrorBoundary }) => (
|
||||
* <div>
|
||||
* There was an error!
|
||||
* <button onClick={() => resetErrorBoundary()}>Try again</button>
|
||||
* </div>
|
||||
* )}
|
||||
* >
|
||||
* <Suspense fallback={<h1>Loading posts...</h1>}>
|
||||
* <Posts ids={[1, 2, 3]} />
|
||||
* </Suspense>
|
||||
* </ErrorBoundary>
|
||||
* )}
|
||||
* </QueryErrorResetBoundary>
|
||||
* )
|
||||
* }
|
||||
*
|
||||
* function ErrorBoundary({
|
||||
* children,
|
||||
* onReset,
|
||||
* fallbackRender,
|
||||
* }: {
|
||||
* children: ComponentChildren
|
||||
* onReset: () => void
|
||||
* fallbackRender: (props: {
|
||||
* error: Error
|
||||
* resetErrorBoundary: () => void
|
||||
* }) => ComponentChildren
|
||||
* }) {
|
||||
* const [error, resetErrorBoundary] = useErrorBoundary(() => onReset())
|
||||
*
|
||||
* if (error) return fallbackRender({ error, resetErrorBoundary })
|
||||
*
|
||||
* return children
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* Several different queries — use `useSuspenseQueries` instead of multiple `useSuspenseQuery` calls, so
|
||||
* they fetch in parallel rather than suspending one after another:
|
||||
* ```tsx
|
||||
* import { Suspense } from 'preact/compat'
|
||||
* import { useErrorBoundary } from 'preact/hooks'
|
||||
* import {
|
||||
* QueryErrorResetBoundary,
|
||||
* useSuspenseQueries,
|
||||
* } from '@tanstack/preact-query'
|
||||
* import type { ComponentChildren } from 'preact'
|
||||
*
|
||||
* function Dashboard() {
|
||||
* const [usersQuery, teamsQuery, projectsQuery] = useSuspenseQueries({
|
||||
* queries: [
|
||||
* { queryKey: ['users'], queryFn: fetchUsers },
|
||||
* { queryKey: ['teams'], queryFn: fetchTeams },
|
||||
* { queryKey: ['projects'], queryFn: fetchProjects },
|
||||
* ],
|
||||
* })
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* <UserList users={usersQuery.data} />
|
||||
* <TeamList teams={teamsQuery.data} />
|
||||
* <ProjectList projects={projectsQuery.data} />
|
||||
* </div>
|
||||
* )
|
||||
* }
|
||||
*
|
||||
* function App() {
|
||||
* return (
|
||||
* <QueryErrorResetBoundary>
|
||||
* {({ reset }) => (
|
||||
* <ErrorBoundary
|
||||
* onReset={reset}
|
||||
* fallbackRender={({ resetErrorBoundary }) => (
|
||||
* <div>
|
||||
* There was an error!
|
||||
* <button onClick={() => resetErrorBoundary()}>Try again</button>
|
||||
* </div>
|
||||
* )}
|
||||
* >
|
||||
* <Suspense fallback={<h1>Loading dashboard...</h1>}>
|
||||
* <Dashboard />
|
||||
* </Suspense>
|
||||
* </ErrorBoundary>
|
||||
* )}
|
||||
* </QueryErrorResetBoundary>
|
||||
* )
|
||||
* }
|
||||
*
|
||||
* function ErrorBoundary({
|
||||
* children,
|
||||
* onReset,
|
||||
* fallbackRender,
|
||||
* }: {
|
||||
* children: ComponentChildren
|
||||
* onReset: () => void
|
||||
* fallbackRender: (props: {
|
||||
* error: Error
|
||||
* resetErrorBoundary: () => void
|
||||
* }) => ComponentChildren
|
||||
* }) {
|
||||
* const [error, resetErrorBoundary] = useErrorBoundary(() => onReset())
|
||||
*
|
||||
* if (error) return fallbackRender({ error, resetErrorBoundary })
|
||||
*
|
||||
* return children
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useSuspenseQueries<
|
||||
T extends Array<any>,
|
||||
TCombinedResult = SuspenseQueriesResults<T>,
|
||||
>(
|
||||
options: {
|
||||
/**
|
||||
* An array with query option objects identical to `useSuspenseQuery`.
|
||||
*/
|
||||
queries: readonly [...SuspenseQueriesOptions<T>]
|
||||
/**
|
||||
* Use this to combine the results of the queries into a single value. The result will be structurally
|
||||
* shared to be as referentially stable as possible.
|
||||
*/
|
||||
combine?: (result: SuspenseQueriesResults<T>) => TCombinedResult
|
||||
},
|
||||
queryClient?: QueryClient,
|
||||
): TCombinedResult
|
||||
|
||||
export function useSuspenseQueries(options: any, queryClient?: QueryClient) {
|
||||
return useQueries(
|
||||
{
|
||||
...options,
|
||||
queries: options.queries.map((query: any) => {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
if (query.queryFn === skipToken) {
|
||||
console.error('skipToken is not allowed for useSuspenseQueries')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...query,
|
||||
suspense: true,
|
||||
throwOnError: defaultThrowOnError,
|
||||
enabled: true,
|
||||
placeholderData: undefined,
|
||||
}
|
||||
}),
|
||||
},
|
||||
queryClient,
|
||||
)
|
||||
}
|
||||
121
packages/preact-query/src/useSuspenseQuery.ts
Normal file
121
packages/preact-query/src/useSuspenseQuery.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { QueryObserver, skipToken } from '@tanstack/query-core'
|
||||
import type { DefaultError, QueryClient, QueryKey } from '@tanstack/query-core'
|
||||
|
||||
import { defaultThrowOnError } from './suspense'
|
||||
import type { UseSuspenseQueryOptions, UseSuspenseQueryResult } from './types'
|
||||
import { useBaseQuery } from './useBaseQuery'
|
||||
|
||||
/**
|
||||
* The options for `useSuspenseQuery` are the same as for `useQuery`, except for `throwOnError`, `enabled`, and
|
||||
* `placeholderData`.
|
||||
*
|
||||
* Caveat: cancellation does not work.
|
||||
*
|
||||
* @remarks Multiple `useSuspenseQuery` calls in the same component suspend serially, causing a request
|
||||
* waterfall — each one blocks rendering until it resolves, so the next doesn't even start fetching until then.
|
||||
* Use {@link useSuspenseQueries} instead when you have more than one suspenseful query in a component, so they
|
||||
* fetch in parallel.
|
||||
* @param options - The {@link UseSuspenseQueryOptions} to use — the same options as `useQuery`, minus the ones listed above.
|
||||
* @param queryClient - Use this to use a custom `QueryClient`. Otherwise, the one from the nearest context will
|
||||
* be used.
|
||||
* @returns The same object as `useQuery`, except that `data` is guaranteed to be defined, `isPlaceholderData`
|
||||
* is missing, and `status` is either `success` or `error` (with the derived flags set accordingly).
|
||||
*
|
||||
* @example
|
||||
* The query error is thrown if the fetch fails and no cached data exists yet, so an error boundary is
|
||||
* required around `<Suspense>`. A failed background refetch instead continues to render the cached data.
|
||||
* Use {@link QueryErrorResetBoundary} to let the user retry after such an error:
|
||||
* ```tsx
|
||||
* import { Suspense } from 'preact/compat'
|
||||
* import { useErrorBoundary } from 'preact/hooks'
|
||||
* import { QueryErrorResetBoundary, useSuspenseQuery } from '@tanstack/preact-query'
|
||||
* import type { ComponentChildren } from 'preact'
|
||||
*
|
||||
* function Posts() {
|
||||
* // `data` is guaranteed to be defined here — no `isPending` check needed.
|
||||
* const { data, isFetching } = useSuspenseQuery({
|
||||
* queryKey: ['posts'],
|
||||
* queryFn: fetchPosts,
|
||||
* })
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* <h1>Posts {isFetching ? '(refreshing...)' : null}</h1>
|
||||
* <ul>
|
||||
* {data.map((post) => (
|
||||
* <li key={post.id}>{post.title}</li>
|
||||
* ))}
|
||||
* </ul>
|
||||
* </div>
|
||||
* )
|
||||
* }
|
||||
*
|
||||
* function App() {
|
||||
* return (
|
||||
* <QueryErrorResetBoundary>
|
||||
* {({ reset }) => (
|
||||
* <ErrorBoundary
|
||||
* onReset={reset}
|
||||
* fallbackRender={({ resetErrorBoundary }) => (
|
||||
* <div>
|
||||
* There was an error!
|
||||
* <button onClick={() => resetErrorBoundary()}>Try again</button>
|
||||
* </div>
|
||||
* )}
|
||||
* >
|
||||
* <Suspense fallback={<h1>Loading posts...</h1>}>
|
||||
* <Posts />
|
||||
* </Suspense>
|
||||
* </ErrorBoundary>
|
||||
* )}
|
||||
* </QueryErrorResetBoundary>
|
||||
* )
|
||||
* }
|
||||
*
|
||||
* function ErrorBoundary({
|
||||
* children,
|
||||
* onReset,
|
||||
* fallbackRender,
|
||||
* }: {
|
||||
* children: ComponentChildren
|
||||
* onReset: () => void
|
||||
* fallbackRender: (props: {
|
||||
* error: Error
|
||||
* resetErrorBoundary: () => void
|
||||
* }) => ComponentChildren
|
||||
* }) {
|
||||
* const [error, resetErrorBoundary] = useErrorBoundary(() => onReset())
|
||||
*
|
||||
* if (error) return fallbackRender({ error, resetErrorBoundary })
|
||||
*
|
||||
* return children
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useSuspenseQuery<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
>(
|
||||
options: UseSuspenseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
|
||||
queryClient?: QueryClient,
|
||||
): UseSuspenseQueryResult<TData, TError> {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
if ((options.queryFn as any) === skipToken) {
|
||||
console.error('skipToken is not allowed for useSuspenseQuery')
|
||||
}
|
||||
}
|
||||
|
||||
return useBaseQuery(
|
||||
{
|
||||
...options,
|
||||
enabled: true,
|
||||
suspense: true,
|
||||
throwOnError: defaultThrowOnError,
|
||||
placeholderData: undefined,
|
||||
},
|
||||
QueryObserver,
|
||||
queryClient,
|
||||
) as UseSuspenseQueryResult<TData, TError>
|
||||
}
|
||||
87
packages/preact-query/src/utils.ts
Normal file
87
packages/preact-query/src/utils.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks'
|
||||
|
||||
/**
|
||||
* This is taken from https://github.com/preactjs/preact/blob/main/compat/src/hooks.js#L8-L54
|
||||
* which is taken from https://github.com/facebook/react/blob/main/packages/use-sync-external-store/src/useSyncExternalStoreShimClient.js#L84
|
||||
* on a high level this cuts out the warnings, ... and attempts a smaller implementation.
|
||||
* This way we don't have to import preact/compat with side effects
|
||||
*/
|
||||
type InternalStore = {
|
||||
_value: any
|
||||
_getSnapshot: () => any
|
||||
}
|
||||
type StoreRef = {
|
||||
_instance: InternalStore
|
||||
}
|
||||
export function useSyncExternalStore(
|
||||
subscribe: (onStoreChange: () => void) => () => void,
|
||||
getSnapshot: () => any,
|
||||
) {
|
||||
const value = getSnapshot()
|
||||
|
||||
const [{ _instance }, forceUpdate] = useState<StoreRef>({
|
||||
_instance: { _value: value, _getSnapshot: getSnapshot },
|
||||
})
|
||||
|
||||
useLayoutEffect(() => {
|
||||
_instance._value = value
|
||||
_instance._getSnapshot = getSnapshot
|
||||
|
||||
if (didSnapshotChange(_instance)) {
|
||||
forceUpdate({ _instance })
|
||||
}
|
||||
}, [subscribe, value, getSnapshot])
|
||||
|
||||
useEffect(() => {
|
||||
if (didSnapshotChange(_instance)) {
|
||||
forceUpdate({ _instance })
|
||||
}
|
||||
|
||||
return subscribe(() => {
|
||||
if (didSnapshotChange(_instance)) {
|
||||
forceUpdate({ _instance })
|
||||
}
|
||||
})
|
||||
}, [subscribe])
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function didSnapshotChange(inst: {
|
||||
_getSnapshot: () => any
|
||||
_value: any
|
||||
}): boolean {
|
||||
const latestGetSnapshot = inst._getSnapshot
|
||||
const prevValue = inst._value
|
||||
try {
|
||||
const nextValue = latestGetSnapshot()
|
||||
return !Object.is(prevValue, nextValue)
|
||||
} catch (_error) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export function useSyncExternalStoreWithSelector<TSnapshot, TSelected>(
|
||||
subscribe: (onStoreChange: () => void) => () => void,
|
||||
getSnapshot: () => TSnapshot,
|
||||
selector: (snapshot: TSnapshot) => TSelected,
|
||||
isEqual: (a: TSelected, b: TSelected) => boolean,
|
||||
): TSelected {
|
||||
const selectedSnapshotRef = useRef<TSelected | undefined>()
|
||||
|
||||
const getSelectedSnapshot = () => {
|
||||
const snapshot = getSnapshot()
|
||||
const selected = selector(snapshot)
|
||||
|
||||
if (
|
||||
selectedSnapshotRef.current === undefined ||
|
||||
!isEqual(selectedSnapshotRef.current, selected)
|
||||
) {
|
||||
selectedSnapshotRef.current = selected
|
||||
}
|
||||
|
||||
return selectedSnapshotRef.current
|
||||
}
|
||||
|
||||
return useSyncExternalStore(subscribe, getSelectedSnapshot)
|
||||
}
|
||||
14
packages/preact-query/test-setup.ts
Normal file
14
packages/preact-query/test-setup.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { notifyManager } from '@tanstack/query-core'
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { act, cleanup as cleanupRTL } from '@testing-library/preact'
|
||||
import { afterEach } from 'vitest'
|
||||
|
||||
// https://testing-library.com/docs/preact-testing-library/api#cleanup
|
||||
afterEach(() => {
|
||||
cleanupRTL()
|
||||
})
|
||||
|
||||
// Wrap notifications with act to make sure React knows about React Query updates
|
||||
notifyManager.setNotifyFunction((fn) => {
|
||||
act(fn)
|
||||
})
|
||||
16
packages/preact-query/tsconfig.json
Normal file
16
packages/preact-query/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist-ts",
|
||||
"rootDir": ".",
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact"
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
"test-setup.ts",
|
||||
"*.config.ts",
|
||||
"*.config.js",
|
||||
"package.json"
|
||||
]
|
||||
}
|
||||
10
packages/preact-query/tsconfig.legacy.json
Normal file
10
packages/preact-query/tsconfig.legacy.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"outDir": "./dist-ts/legacy"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/__tests__"],
|
||||
"references": [{ "path": "../query-persist-client-core" }]
|
||||
}
|
||||
10
packages/preact-query/tsconfig.prod.json
Normal file
10
packages/preact-query/tsconfig.prod.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"incremental": false,
|
||||
"composite": false,
|
||||
"rootDir": "../../"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/__tests__"]
|
||||
}
|
||||
8
packages/preact-query/tsdown.config.ts
Normal file
8
packages/preact-query/tsdown.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
import { legacyConfig, modernConfig } from './root.tsdown.config.js'
|
||||
|
||||
export default defineConfig([
|
||||
modernConfig({ entry: ['src/*.ts', 'src/*.tsx'] }),
|
||||
legacyConfig({ entry: ['src/*.ts', 'src/*.tsx'] }),
|
||||
])
|
||||
35
packages/preact-query/vite.config.ts
Normal file
35
packages/preact-query/vite.config.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import preact from '@preact/preset-vite'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import packageJson from './package.json'
|
||||
import type { UserConfig as ViteUserConfig } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [preact() as ViteUserConfig['plugins']],
|
||||
// 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',
|
||||
setupFiles: ['test-setup.ts'],
|
||||
coverage: {
|
||||
enabled: !!process.env.CI,
|
||||
provider: 'istanbul',
|
||||
include: ['src/**/*'],
|
||||
exclude: ['src/__tests__/**'],
|
||||
},
|
||||
typecheck: { enabled: true },
|
||||
restoreMocks: true,
|
||||
retry: process.env.CI ? 3 : 0,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user