├── .eslintignore ├── .eslintrc.cjs ├── .github └── workflows │ ├── build.yaml │ ├── github-pages.yaml │ └── test.yaml ├── .gitignore ├── .npmrc ├── LICENSE ├── Makefile ├── README.md ├── assets ├── logo.png └── logo.pxz ├── docs ├── .vitepress │ ├── config.mts │ └── theme │ │ ├── index.ts │ │ └── style.css ├── api │ ├── InfiniteQuery.md │ ├── Mutation.md │ ├── Query.md │ ├── QueryClient.md │ └── other.md ├── index.md ├── introduction │ └── getting-started.md ├── other │ ├── project-examples.md │ └── swagger-codegen.md ├── package.json ├── pnpm-lock.yaml ├── preset │ ├── createInfiniteQuery.md │ ├── createMutation.md │ ├── createQuery.md │ ├── index.md │ └── queryClient.md ├── public │ ├── logo.png │ └── mobx.svg └── vite.config.ts ├── package.json ├── pnpm-lock.yaml ├── post-build.mjs ├── src ├── index.ts ├── infinite-query.test.ts ├── inifinite-query.ts ├── inifinite-query.types.ts ├── mutation.test.ts ├── mutation.ts ├── mutation.types.ts ├── preset │ ├── configs │ │ ├── default-query-client-config.ts │ │ └── index.ts │ ├── create-infinite-query.ts │ ├── create-mutation.ts │ ├── create-query.ts │ ├── index.ts │ └── query-client.ts ├── query-client.ts ├── query-client.types.ts ├── query-options.ts ├── query.test.ts ├── query.ts └── query.types.ts ├── tsconfig.json ├── tsconfig.test.json └── vitest.config.ts /.eslintignore: -------------------------------------------------------------------------------- 1 | tsconfig.json 2 | webpack.config.js 3 | .eslintrc 4 | .babelrc.js 5 | public* 6 | node_modules 7 | dist 8 | .vscode 9 | .kube 10 | .idea 11 | .dockerignore 12 | .gitlab-ci.yml 13 | nginx.conf 14 | jsconfig.json 15 | *.json 16 | .eslintrc.cjs 17 | post-build.mjs 18 | coverage 19 | vitest.config.ts 20 | website 21 | docs -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | const packageJson = require('./package.json'); 2 | 3 | module.exports = { 4 | extends: [require.resolve('js2me-eslint-config')], 5 | rules: { 6 | 'import/no-unresolved': [ 7 | 'error', 8 | { ignore: Object.keys(packageJson.peerDependencies) }, 9 | ], 10 | 'unicorn/prevent-abbreviations': 'off', 11 | 'sonarjs/no-redundant-optional': 'off', 12 | 'sonarjs/deprecation': 'off', 13 | 'sonarjs/redundant-type-aliases': 'off' 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: 3 | push: 4 | branches: 5 | - master 6 | 7 | jobs: 8 | job: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | 15 | - uses: pnpm/action-setup@v4 16 | name: Install pnpm 17 | with: 18 | run_install: false 19 | 20 | - name: Install Node.js 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: 20 24 | cache: 'pnpm' 25 | 26 | - name: Install dependencies 27 | run: pnpm install 28 | 29 | - name: Build the project 30 | run: pnpm build 31 | -------------------------------------------------------------------------------- /.github/workflows/github-pages.yaml: -------------------------------------------------------------------------------- 1 | name: Deploy to GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build: 10 | name: Build Docs 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | 16 | - uses: pnpm/action-setup@v4 17 | name: Install pnpm 18 | with: 19 | run_install: false 20 | 21 | - name: Install Node.js 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: 20 25 | cache: 'pnpm' 26 | 27 | - name: Install dependencies 28 | run: pnpm install && cd docs && pnpm install 29 | - name: Build docs 30 | run: pnpm docs:build 31 | 32 | - name: Upload Build Artifact 33 | uses: actions/upload-pages-artifact@v3 34 | with: 35 | path: docs/.vitepress/dist 36 | 37 | deploy: 38 | name: Deploy to GitHub Pages 39 | needs: build 40 | 41 | # Grant GITHUB_TOKEN the permissions required to make a Pages deployment 42 | permissions: 43 | pages: write # to deploy to Pages 44 | id-token: write # to verify the deployment originates from an appropriate source 45 | 46 | environment: 47 | name: github-pages 48 | url: ${{ steps.deployment.outputs.page_url }} 49 | 50 | runs-on: ubuntu-latest 51 | steps: 52 | - name: Deploy to GitHub Pages 53 | id: deployment 54 | uses: actions/deploy-pages@v4 -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | push: 4 | branches: 5 | - master 6 | 7 | jobs: 8 | job: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | 15 | - uses: pnpm/action-setup@v4 16 | name: Install pnpm 17 | with: 18 | run_install: false 19 | 20 | - name: Install Node.js 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: 20 24 | cache: 'pnpm' 25 | 26 | - name: Install dependencies 27 | run: pnpm install 28 | 29 | - name: Tests the project 30 | run: pnpm test:coverage 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage 4 | docs/.vitepress/cache -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | auto-install-peers = true -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Sergey 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | clean: 2 | rm -rf node_modules 3 | install: 4 | pnpm i 5 | reinstall: 6 | make clean 7 | make install 8 | doc: 9 | cd docs && \ 10 | rm -rf node_modules && \ 11 | rm -rf .vitepress/cache && \ 12 | pnpm i && \ 13 | pnpm dev 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | logo 2 | 3 | # mobx-tanstack-query 4 | 5 | [![NPM version][npm-image]][npm-url] [![test status][github-test-actions-image]][github-actions-url] [![build status][github-build-actions-image]][github-actions-url] [![npm download][download-image]][download-url] [![bundle size][bundlephobia-image]][bundlephobia-url] 6 | 7 | 8 | [npm-image]: http://img.shields.io/npm/v/mobx-tanstack-query.svg 9 | [npm-url]: http://npmjs.org/package/mobx-tanstack-query 10 | [github-test-actions-image]: https://github.com/js2me/mobx-tanstack-query/workflows/Test/badge.svg 11 | [github-build-actions-image]: https://github.com/js2me/mobx-tanstack-query/workflows/Build/badge.svg 12 | [github-actions-url]: https://github.com/js2me/mobx-tanstack-query/actions 13 | [download-image]: https://img.shields.io/npm/dm/mobx-tanstack-query.svg 14 | [download-url]: https://npmjs.org/package/mobx-tanstack-query 15 | [bundlephobia-url]: https://bundlephobia.com/result?p=mobx-tanstack-query 16 | [bundlephobia-image]: https://badgen.net/bundlephobia/minzip/mobx-tanstack-query 17 | 18 | 19 | _**MobX** wrapper for [**Tanstack Query Core**](https://tanstack.com/query/latest) package_ 20 | 21 | ## Documentation is [here](https://js2me.github.io/mobx-tanstack-query) 22 | 23 | ```ts 24 | import { Query } from "mobx-tanstack-query"; 25 | 26 | const query = new Query({ 27 | queryClient, 28 | queryKey: ['hello', 'world'], 29 | queryFn: async () => { 30 | const response = await fetch('/hello/world'); 31 | return await response.json(); 32 | } 33 | }) 34 | ``` 35 | -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/js2me/mobx-tanstack-query/d3feb304106a0e1898ba3d87025c66f622eb6816/assets/logo.png -------------------------------------------------------------------------------- /assets/logo.pxz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/js2me/mobx-tanstack-query/d3feb304106a0e1898ba3d87025c66f622eb6816/assets/logo.pxz -------------------------------------------------------------------------------- /docs/.vitepress/config.mts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitepress'; 2 | 3 | import path from 'path'; 4 | import fs from 'fs'; 5 | 6 | const { version, name: packageName, author, license } = JSON.parse( 7 | fs.readFileSync( 8 | path.resolve(__dirname, '../../package.json'), 9 | { encoding: 'utf-8' }, 10 | ), 11 | ); 12 | 13 | export default defineConfig({ 14 | title: packageName.replace(/-/g, ' '), 15 | description: `${packageName.replace(/-/g, ' ')} documentation`, 16 | base: `/${packageName}/`, 17 | lastUpdated: true, 18 | head: [ 19 | ['link', { rel: 'icon', href: `/${packageName}/logo.png` }], 20 | ], 21 | transformHead: ({ pageData, head }) => { 22 | head.push(['meta', { property: 'og:site_name', content: packageName }]); 23 | head.push(['meta', { property: 'og:title', content: pageData.title }]); 24 | if (pageData.description) { 25 | head.push(['meta', { property: 'og:description', content: pageData.description }]); 26 | } 27 | head.push(['meta', { property: 'og:image', content: `https://${author}.github.io/${packageName}/logo.png` }]); 28 | 29 | return head 30 | }, 31 | themeConfig: { 32 | logo: '/logo.png', 33 | search: { 34 | provider: 'local' 35 | }, 36 | nav: [ 37 | { text: 'Home', link: '/' }, 38 | { text: 'Introduction', link: '/introduction/getting-started' }, 39 | { 40 | text: `v${version}`, 41 | items: [ 42 | { 43 | items: [ 44 | { 45 | text: `v${version}`, 46 | link: `https://github.com/${author}/${packageName}/releases/tag/v${version}`, 47 | }, 48 | ], 49 | }, 50 | ], 51 | }, 52 | ], 53 | sidebar: [ 54 | { 55 | text: 'Introduction', 56 | items: [ 57 | { text: 'Getting started', link: '/introduction/getting-started' }, 58 | ], 59 | }, 60 | { 61 | text: 'Core API', 62 | link: '/api/Query', 63 | items: [ 64 | { text: 'Query', link: '/api/Query' }, 65 | { text: 'Mutation', link: '/api/Mutation' }, 66 | { text: 'InfiniteQuery', link: '/api/InfiniteQuery' }, 67 | { text: 'QueryClient', link: '/api/QueryClient' }, 68 | { text: 'Other', link: '/api/other' }, 69 | ], 70 | }, 71 | { 72 | text: 'Preset API', 73 | link: '/preset', 74 | items: [ 75 | { text: 'Overview', link: '/preset' }, 76 | { text: 'createQuery', link: '/preset/createQuery' }, 77 | { text: 'createMutation', link: '/preset/createMutation' }, 78 | { text: 'createInfiniteQuery', link: '/preset/createInfiniteQuery' }, 79 | { text: 'queryClient', link: '/preset/queryClient' }, 80 | ] 81 | }, 82 | { 83 | text: 'Other', 84 | items: [ 85 | { text: 'Project examples', link: '/other/project-examples' }, 86 | { text: 'Swagger Codegen', link: '/other/swagger-codegen' }, 87 | ], 88 | } 89 | ], 90 | 91 | footer: { 92 | message: `Released under the ${license} License.`, 93 | copyright: `Copyright © 2024-PRESENT ${author}`, 94 | }, 95 | 96 | socialLinks: [ 97 | { icon: 'github', link: `https://github.com/${author}/${packageName}` }, 98 | ], 99 | }, 100 | }); 101 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/index.ts: -------------------------------------------------------------------------------- 1 | // https://vitepress.dev/guide/custom-theme 2 | import { h } from 'vue' 3 | import type { Theme } from 'vitepress' 4 | import DefaultTheme from 'vitepress/theme' 5 | import './style.css' 6 | import 'uno.css' 7 | 8 | export default { 9 | extends: DefaultTheme, 10 | Layout: () => { 11 | return h(DefaultTheme.Layout, null, { 12 | // https://vitepress.dev/guide/extending-default-theme#layout-slots 13 | }) 14 | }, 15 | enhanceApp({ app, router, siteData }) { 16 | // ... 17 | } 18 | } satisfies Theme 19 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/style.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Customize default theme styling by overriding CSS variables: 3 | * https://github.com/vuejs/vitepress/blob/main/src/client/theme-default/styles/vars.css 4 | */ 5 | 6 | /** 7 | * Colors 8 | * 9 | * Each colors have exact same color scale system with 3 levels of solid 10 | * colors with different brightness, and 1 soft color. 11 | * 12 | * - `XXX-1`: The most solid color used mainly for colored text. It must 13 | * satisfy the contrast ratio against when used on top of `XXX-soft`. 14 | * 15 | * - `XXX-2`: The color used mainly for hover state of the button. 16 | * 17 | * - `XXX-3`: The color for solid background, such as bg color of the button. 18 | * It must satisfy the contrast ratio with pure white (#ffffff) text on 19 | * top of it. 20 | * 21 | * - `XXX-soft`: The color used for subtle background such as custom container 22 | * or badges. It must satisfy the contrast ratio when putting `XXX-1` colors 23 | * on top of it. 24 | * 25 | * The soft color must be semi transparent alpha channel. This is crucial 26 | * because it allows adding multiple "soft" colors on top of each other 27 | * to create a accent, such as when having inline code block inside 28 | * custom containers. 29 | * 30 | * - `default`: The color used purely for subtle indication without any 31 | * special meanings attched to it such as bg color for menu hover state. 32 | * 33 | * - `brand`: Used for primary brand colors, such as link text, button with 34 | * brand theme, etc. 35 | * 36 | * - `tip`: Used to indicate useful information. The default theme uses the 37 | * brand color for this by default. 38 | * 39 | * - `warning`: Used to indicate warning to the users. Used in custom 40 | * container, badges, etc. 41 | * 42 | * - `danger`: Used to show error, or dangerous message to the users. Used 43 | * in custom container, badges, etc. 44 | * -------------------------------------------------------------------------- */ 45 | 46 | :root { 47 | --vp-c-indigo-1: #d9195f; 48 | --vp-c-indigo-2: #ff5b71; 49 | --vp-c-indigo-3: #fb1f35; 50 | --vp-c-indigo-soft: #ff517a2b; 51 | } 52 | 53 | html.dark { 54 | --vp-c-indigo-1: #ee3479; 55 | --vp-c-indigo-2: #ff5b71; 56 | --vp-c-indigo-3: #fb1f35; 57 | --vp-c-indigo-soft: #ff517a2b; 58 | } 59 | 60 | :root { 61 | --vp-c-default-1: var(--vp-c-gray-1); 62 | --vp-c-default-2: var(--vp-c-gray-2); 63 | --vp-c-default-3: var(--vp-c-gray-3); 64 | --vp-c-default-soft: var(--vp-c-gray-soft); 65 | 66 | --vp-c-brand-1: var(--vp-c-indigo-1); 67 | --vp-c-brand-2: var(--vp-c-indigo-2); 68 | --vp-c-brand-3: var(--vp-c-indigo-3); 69 | --vp-c-brand-soft: var(--vp-c-indigo-soft); 70 | 71 | --vp-c-tip-1: var(--vp-c-brand-1); 72 | --vp-c-tip-2: var(--vp-c-brand-2); 73 | --vp-c-tip-3: var(--vp-c-brand-3); 74 | --vp-c-tip-soft: var(--vp-c-brand-soft); 75 | 76 | --vp-c-warning-1: var(--vp-c-yellow-1); 77 | --vp-c-warning-2: var(--vp-c-yellow-2); 78 | --vp-c-warning-3: var(--vp-c-yellow-3); 79 | --vp-c-warning-soft: var(--vp-c-yellow-soft); 80 | 81 | --vp-c-danger-1: var(--vp-c-red-1); 82 | --vp-c-danger-2: var(--vp-c-red-2); 83 | --vp-c-danger-3: var(--vp-c-red-3); 84 | --vp-c-danger-soft: var(--vp-c-red-soft); 85 | } 86 | 87 | /** 88 | * Component: Button 89 | * -------------------------------------------------------------------------- */ 90 | 91 | :root { 92 | --vp-button-brand-border: transparent; 93 | --vp-button-brand-text: var(--vp-c-white); 94 | --vp-button-brand-bg: var(--vp-c-brand-3); 95 | --vp-button-brand-hover-border: transparent; 96 | --vp-button-brand-hover-text: var(--vp-c-white); 97 | --vp-button-brand-hover-bg: var(--vp-c-brand-2); 98 | --vp-button-brand-active-border: transparent; 99 | --vp-button-brand-active-text: var(--vp-c-white); 100 | --vp-button-brand-active-bg: var(--vp-c-brand-1); 101 | } 102 | 103 | /** 104 | * Component: Home 105 | * -------------------------------------------------------------------------- */ 106 | 107 | :root { 108 | --vp-home-hero-name-color: transparent; 109 | --vp-home-hero-name-background: -webkit-linear-gradient(120deg, #ff1148 30%, #ff7a32); 110 | --vp-home-hero-image-background-image: linear-gradient(-45deg, #ff542f 50%, #ffbbcf 50%); 111 | --vp-home-hero-image-filter: blur(44px); 112 | } 113 | 114 | html.dark { 115 | --vp-home-hero-image-background-image: linear-gradient(-45deg, #ff510b 50%, #ff243d 50%); 116 | } 117 | 118 | @media (min-width: 640px) { 119 | :root { 120 | --vp-home-hero-image-filter: blur(56px); 121 | } 122 | } 123 | 124 | @media (min-width: 960px) { 125 | :root { 126 | --vp-home-hero-image-filter: blur(68px); 127 | } 128 | } 129 | 130 | /** 131 | * Component: Custom Block 132 | * -------------------------------------------------------------------------- */ 133 | 134 | :root { 135 | --vp-custom-block-tip-border: transparent; 136 | --vp-custom-block-tip-text: var(--vp-c-text-1); 137 | --vp-custom-block-tip-bg: var(--vp-c-brand-soft); 138 | --vp-custom-block-tip-code-bg: var(--vp-c-brand-soft); 139 | } 140 | 141 | .i-logos\:mobx-icon { 142 | background: url(/mobx.svg) no-repeat; 143 | background-size: 100% 100%; 144 | background-color: transparent; 145 | width: 1.2em; 146 | height: 1.2em; 147 | } 148 | 149 | /** 150 | * Component: Algolia 151 | * -------------------------------------------------------------------------- */ 152 | 153 | .DocSearch { 154 | --docsearch-primary-color: var(--vp-c-brand-1) !important; 155 | } 156 | 157 | @keyframes logoBgGradientAnimate { 158 | 0% { 159 | background-position: 0% 0%; 160 | transform: scale(1) translate(-50%, -50%); 161 | } 162 | 50% { 163 | background-position: 300% 300%; 164 | transform: scale(1.4) translate(-35%, -35%); 165 | } 166 | 100% { 167 | background-position: 0% 0%; 168 | transform: scale(1) translate(-50%, -50%); 169 | } 170 | } 171 | 172 | .image-container > .image-bg { 173 | background-size: 200% 200%; 174 | animation: logoBgGradientAnimate 7s linear infinite; 175 | } 176 | 177 | @keyframes logoAnimate { 178 | 0% { 179 | transform: scale(1) translate(-50%, -50%); 180 | } 181 | 50% { 182 | transform: scale(1.05) translate(-48%, -48%); 183 | } 184 | 100% { 185 | transform: scale(1) translate(-50%, -50%); 186 | } 187 | } 188 | 189 | .image-container > .image-src { 190 | animation: logoAnimate 10s linear infinite; 191 | } -------------------------------------------------------------------------------- /docs/api/InfiniteQuery.md: -------------------------------------------------------------------------------- 1 | # InfiniteQuery 2 | 3 | Class wrapper for [@tanstack-query/core infinite queries](https://tanstack.com/query/latest/docs/framework/react/guides/infinite-queries) with **MobX** reactivity 4 | 5 | [_See docs for Query_](/api/Query) 6 | 7 | [Reference to source code](/src/infinite-query.ts) 8 | 9 | ## Usage 10 | 11 | Create an instance of `InfiniteQuery` with [`queryKey`](https://tanstack.com/query/latest/docs/framework/react/guides/query-keys) and [`queryFn`](https://tanstack.com/query/latest/docs/framework/react/guides/query-functions) parameters 12 | 13 | ```ts 14 | const query = new InfiniteQuery({ 15 | queryClient, 16 | abortSignal: this.abortSignal, 17 | queryKey: ['stars'] 18 | queryFn: async ({ signal, pageParam }) => { 19 | const response = await starsApi.fetchStarsList( 20 | { 21 | count: 20, 22 | page: pageParam, 23 | }, 24 | { 25 | signal, 26 | }, 27 | ); 28 | 29 | return response.data; 30 | }, 31 | initialPageParam: 1, 32 | onError: (e) => { 33 | notify({ 34 | type: 'danger', 35 | title: 'Failed to load stars', 36 | }); 37 | }, 38 | getNextPageParam: (lastPage, _, lastPageParam) => { 39 | return lastPage.length ? lastPageParam + 1 : null; 40 | }, 41 | }); 42 | ``` 43 | -------------------------------------------------------------------------------- /docs/api/Mutation.md: -------------------------------------------------------------------------------- 1 | # Mutation 2 | 3 | Class wrapper for [@tanstack-query/core mutations](https://tanstack.com/query/latest/docs/framework/react/guides/mutations) with **MobX** reactivity 4 | 5 | [Reference to source code](/src/mutation.ts) 6 | 7 | ## Usage 8 | 9 | Create an instance of `Mutation` with [`mutationFn`](https://tanstack.com/query/latest/docs/framework/react/guides/mutations) parameter 10 | 11 | ```ts 12 | import { Mutation } from "mobx-tanstack-query"; 13 | import { petsApi } from "@/shared/apis" 14 | import { queryClient } from "@/shared/config" 15 | 16 | const petCreateMutation = new Mutation({ 17 | queryClient, 18 | mutationFn: async (petName: string) => { 19 | const response = await petsApi.createPet(petName); 20 | return await response.json(); 21 | }, 22 | onMutate: () => { 23 | console.log('Start creating pet'); 24 | }, 25 | onSuccess: (newPet) => { 26 | // Invalidate cache after succeed mutation 27 | queryClient.invalidateQueries({ queryKey: ['pets'] }); 28 | console.log('Pet has been created:', newPet); 29 | }, 30 | onError: (error) => { 31 | console.error('Failed to create pet:', error.message); 32 | }, 33 | }); 34 | 35 | ... 36 | const result = await petCreateMutation.mutate('Fluffy'); 37 | console.info(result.data, result.isPending, result.isError); 38 | 39 | ``` 40 | 41 | ## Built-in Features 42 | 43 | ### `abortSignal` option 44 | This field is necessary to kill all reactions and subscriptions that are created during the creation of an instance of the `Mutation` class 45 | 46 | ```ts 47 | const abortController = new AbortController(); 48 | 49 | const randomPetCreateMutation = new Mutation({ 50 | queryClient, 51 | mutationFn: async (_, { signal }) => { 52 | const response = await petsApi.createRandomPet({ signal }); 53 | return await response.json(); 54 | }, 55 | }); 56 | 57 | ... 58 | randomPetCreateMutation.mutate(); 59 | abortController.abort(); 60 | ``` 61 | 62 | This is alternative for `destroy` method 63 | 64 | ### `destroy()` method 65 | This method is necessary to kill all reactions and subscriptions that are created during the creation of an instance of the `Mutation` class 66 | 67 | This is alternative for `abortSignal` option 68 | 69 | ### method `mutate(variables, options?)` 70 | Runs the mutation. (Works the as `mutate` function in [`useMutation` hook](https://tanstack.com/query/latest/docs/framework/react/reference/useMutation)) 71 | 72 | ### hook `onDone()` 73 | Subscribe when mutation has been successfully finished 74 | 75 | ### hook `onError()` 76 | Subscribe when mutation has been finished with failure 77 | 78 | ### method `reset()` 79 | Reset current mutation 80 | 81 | ### property `result` 82 | Mutation result (The same as returns the [`useMutation` hook](https://tanstack.com/query/latest/docs/framework/react/reference/useMutation)) 83 | 84 | 85 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /docs/api/Query.md: -------------------------------------------------------------------------------- 1 | # Query 2 | 3 | Class wrapper for [@tanstack-query/core queries](https://tanstack.com/query/latest/docs/framework/react/guides/queries) with **MobX** reactivity 4 | 5 | [Reference to source code](/src/query.ts) 6 | 7 | ## Usage 8 | There are two ways to use queries: 9 | 10 | ### 1. Automatic enabling\disabling of queries 11 | This approach is suitable when we want the query to automatically make a request and process the data 12 | depending on the availability of the necessary data. 13 | 14 | Example: 15 | ```ts 16 | const petName = observable.box(); 17 | 18 | const petQuery = new Query(queryClient, () => ({ 19 | queryKey: ['pets', petName.get()] as const, 20 | enabled: !!petName.get(), // dynamic 21 | queryFn: async ({ queryKey }) => { 22 | const petName = queryKey[1]!; 23 | const response = await petsApi.getPetByName(petName); 24 | return await response.json(); 25 | }, 26 | })); 27 | 28 | // petQuery is not enabled 29 | petQuery.options.enabled; 30 | 31 | petName.set('Fluffy'); 32 | 33 | // petQuery is enabled 34 | petQuery.options.enabled; 35 | ``` 36 | ### 2. Manual control of query fetching 37 | This approach is suitable when we need to manually load data using a query. 38 | 39 | Example: 40 | ```ts 41 | const petQuery = new Query({ 42 | queryClient, 43 | queryKey: ['pets', undefined as (string | undefined)] as const, 44 | enabled: false, 45 | queryFn: async ({ queryKey }) => { 46 | const petName = queryKey[1]!; 47 | const response = await petsApi.getPetByName(petName); 48 | return await response.json(); 49 | }, 50 | }); 51 | 52 | const result = await petQuery.start({ 53 | queryKey: ['pets', 'Fluffy'], 54 | }); 55 | 56 | console.log(result.data); 57 | ``` 58 | 59 | ### Another examples 60 | 61 | Create an instance of `Query` with [`queryKey`](https://tanstack.com/query/latest/docs/framework/react/guides/query-keys) and [`queryFn`](https://tanstack.com/query/latest/docs/framework/react/guides/query-functions) parameters 62 | 63 | ```ts 64 | const petsQuery = new Query({ 65 | queryClient, 66 | abortSignal, // Helps you to automatically clean up query 67 | queryKey: ['pets'], 68 | queryFn: async ({ signal, queryKey }) => { 69 | const response = await petsApi.fetchPets({ signal }); 70 | return await response.json(); 71 | }, 72 | }); 73 | 74 | ... 75 | 76 | console.log( 77 | petsQuery.result.data, 78 | petsQuery.result.isLoading 79 | ) 80 | ``` 81 | 82 | ::: info This query is enabled by default! 83 | This means that the query will immediately call the `queryFn` function, 84 | i.e., make a request to `fetchPets` 85 | This is the default behavior of queries according to the [**query documtation**](https://tanstack.com/query/latest/docs/framework/react/guides/queries) 86 | ::: 87 | 88 | ## Recommendations 89 | 90 | ### Don't forget about `abortSignal`s 91 | When creating a query, subscriptions to the original queries and reactions are created. 92 | If you don't clean up subscriptions and reactions - memory leaks can occur. 93 | 94 | ### Use `queryKey` to pass data to `queryFn` 95 | 96 | `queryKey` is not only a cache key but also a way to send necessary data for our API requests! 97 | 98 | Example 99 | ```ts 100 | const petQuery = new Query(queryClient, () => ({ 101 | queryKey: ['pets', 'Fluffy'] as const, 102 | queryFn: async ({ queryKey }) => { 103 | const petName = queryKey[1]!; 104 | const response = await petsApi.getPetByName(petName); 105 | return await response.json(); 106 | }, 107 | })); 108 | ``` 109 | 110 | ## Built-in Features 111 | 112 | ### `abortSignal` option 113 | This field is necessary to kill all reactions and subscriptions that are created during the creation of an instance of the `Query` class 114 | 115 | ```ts 116 | const abortController = new AbortController(); 117 | 118 | const petsQuery = new Query({ 119 | queryClient, 120 | abortSignal: abortController.signal, 121 | queryKey: ['pets'] as const, 122 | queryFn: async ({ signal }) => { 123 | const response = await petsApi.getAllPets({ signal }); 124 | return await response.json(); 125 | }, 126 | }); 127 | 128 | ... 129 | abortController.abort() 130 | ``` 131 | 132 | This is alternative for `destroy` method 133 | 134 | ### `destroy()` method 135 | This method is necessary to kill all reactions and subscriptions that are created during the creation of an instance of the `Query` class 136 | 137 | This is alternative for `abortSignal` option 138 | 139 | ### `enableOnDemand` option 140 | Query will be disabled until you request result for this query 141 | Example: 142 | ```ts 143 | const query = new Query({ 144 | //... 145 | enableOnDemand: true 146 | }); 147 | // happens nothing 148 | query.result.data; // from this code line query starts fetching data 149 | ``` 150 | 151 | This option works as is if query will be "enabled", otherwise you should enable this query. 152 | ```ts 153 | const query = new Query({ 154 | enabled: false, 155 | enableOnDemand: true, 156 | queryFn: () => {}, 157 | }); 158 | query.result.data; // nothing happened because query is disabled. 159 | ``` 160 | But if you set `enabled` as `true` and option `enableOnDemand` will be `true` too then query will be fetched only after user will try to get access to result. 161 | ```ts 162 | const query = new Query({ 163 | enabled: true, 164 | enableOnDemand: true, 165 | queryFn: () => {}, 166 | }); 167 | ... 168 | // query is not fetched 169 | ... 170 | // query is not fetched 171 | query.result.data; // query starts execute the queryFn 172 | ``` 173 | 174 | ### dynamic `options` 175 | Options which can be dynamically updated for this query 176 | 177 | ```ts 178 | const query = new Query({ 179 | // ... 180 | options: () => ({ 181 | enabled: this.myObservableValue > 10, 182 | queryKey: ['foo', 'bar', this.myObservableValue] as const, 183 | }), 184 | queryFn: ({ queryKey }) => { 185 | const myObservableValue = queryKey[2]; 186 | } 187 | }); 188 | ``` 189 | 190 | ### dynamic `queryKey` 191 | Works the same as dynamic `options` option but only for `queryKey` 192 | ```ts 193 | const query = new Query({ 194 | // ... 195 | queryKey: () => ['foo', 'bar', this.myObservableValue] as const, 196 | queryFn: ({ queryKey }) => { 197 | const myObservableValue = queryKey[2]; 198 | } 199 | }); 200 | ``` 201 | P.S. you can combine it with dynamic (out of box) `enabled` property 202 | ```ts 203 | const query = new Query({ 204 | // ... 205 | queryKey: () => ['foo', 'bar', this.myObservableValue] as const, 206 | enabled: ({ queryKey }) => queryKey[2] > 10, 207 | queryFn: ({ queryKey }) => { 208 | const myObservableValue = queryKey[2]; 209 | } 210 | }); 211 | ``` 212 | 213 | ### method `start(params)` 214 | 215 | Enable query if it is disabled then fetch the query. 216 | This method is helpful if you want manually control fetching your query 217 | 218 | 219 | Example: 220 | 221 | ```ts 222 | 223 | ``` 224 | 225 | 226 | ### method `update()` 227 | 228 | Update options for query (Uses [QueryObserver](https://tanstack.com/query/latest/docs/reference/QueriesObserver).setOptions) 229 | 230 | ### hook `onDone()` 231 | 232 | Subscribe when query has been successfully fetched data 233 | 234 | ### hook `onError()` 235 | 236 | Subscribe when query has been failed fetched data 237 | 238 | ### method `invalidate()` 239 | 240 | Invalidate current query (Uses [queryClient.invalidateQueries](https://tanstack.com/query/latest/docs/reference/QueryClient/#queryclientinvalidatequeries)) 241 | 242 | ### method `reset()` 243 | 244 | Reset current query (Uses [queryClient.resetQueries](https://tanstack.com/query/latest/docs/reference/QueryClient#queryclientresetqueries)) 245 | 246 | ### method `setData()` 247 | 248 | Set data for current query (Uses [queryClient.setQueryData](https://tanstack.com/query/latest/docs/reference/QueryClient#queryclientsetquerydata)) 249 | 250 | ### property `isResultRequsted` 251 | Any time when you trying to get access to `result` property this field sets as `true` 252 | This field is needed for `enableOnDemand` option 253 | This property if **observable** 254 | 255 | ### property `result` 256 | 257 | **Observable** query result (The same as returns the [`useQuery` hook](https://tanstack.com/query/latest/docs/framework/react/reference/useQuery)) 258 | 259 | 260 | 261 | 262 | ## About `enabled` 263 | All queries are `enabled` (docs can be found [here](https://tanstack.com/query/latest/docs/framework/react/reference/useQuery)) by default, but you can set `enabled` as `false` or use dynamic value like `({ queryKey }) => !!queryKey[1]` 264 | You can use `update` method to update value for this property or use dynamic options construction (`options: () => ({ enabled: !!this.observableValue })`) 265 | 266 | 267 | ## About `refetchOnWindowFocus` and `refetchOnReconnect` 268 | 269 | They **will not work if** you will not call `mount()` method manually of your `QueryClient` instance which you send for your queries, all other cases dependents on query `stale` time and `enabled` properties. 270 | Example: 271 | 272 | ```ts 273 | import { hashKey, QueryClient } from '@tanstack/query-core'; 274 | 275 | export const queryClient = new QueryClient({ 276 | defaultOptions: { 277 | queries: { 278 | throwOnError: true, 279 | queryKeyHashFn: hashKey, 280 | refetchOnWindowFocus: 'always', 281 | refetchOnReconnect: 'always', 282 | staleTime: 5 * 60 * 1000, 283 | retry: (failureCount, error) => { 284 | if ('status' in error && Number(error.status) >= 500) { 285 | return failureCount < 3; 286 | } 287 | return false; 288 | }, 289 | }, 290 | mutations: { 291 | throwOnError: true, 292 | }, 293 | }, 294 | }); 295 | 296 | // enable all subscriptions for online\offline and window focus/blur 297 | queryClient.mount(); 298 | ``` 299 | 300 | If you work with [`QueryClient`](/api/QueryClient) then calling `mount()` is not needed. -------------------------------------------------------------------------------- /docs/api/QueryClient.md: -------------------------------------------------------------------------------- 1 | # QueryClient 2 | 3 | 4 | An enhanced version of [TanStack's Query QueryClient](https://tanstack.com/query/v5/docs/reference/QueryClient). 5 | Adds specialized configurations for library entities like [`Query`](/api/Query) or [`Mutation`](/api/Mutation). 6 | 7 | [Reference to source code](/src/query-client.ts) 8 | 9 | ## API Signature 10 | ```ts 11 | import { QueryClient } from "@tanstack/query-core"; 12 | 13 | class QueryClient extends QueryClient { 14 | constructor(config?: QueryClientConfig); 15 | } 16 | ``` 17 | 18 | ## Configuration 19 | When creating an instance, you can provide: 20 | ```ts 21 | import { DefaultOptions } from '@tanstack/query-core'; 22 | 23 | interface QueryClientConfig { 24 | defaultOptions?: DefaultOptions & { 25 | queries: QueryFeatures; 26 | mutations: MobxMutatonFeatures; 27 | }; 28 | hooks?: QueryClientHooks; 29 | } 30 | ``` 31 | 32 | ## Key methods and properties 33 | 34 | ### `queryFeatures` 35 | Features configurations exclusively for [`Query`](/api/Query)/[`InfiniteQuery`](/api/InfiniteQuery) 36 | 37 | ### `mutationFeatures` 38 | Features configurations exclusively for [`Mutation`](/api/Mutation) 39 | 40 | ### `hooks` 41 | Entity lifecycle events. Available hooks: 42 | 43 | | Hook | Description | 44 | |---|---| 45 | | onQueryInit | Triggered when a [`Query`](/api/Query) is created | 46 | | onInfiniteQueryInit | Triggered when a [`InfiniteQuery`](/api/InfiniteQuery) is created | 47 | | onMutationInit | Triggered when a [`Mutation`](/api/Mutation) is created | 48 | | onQueryDestroy | Triggered when a [`Query`](/api/Query) is destroyed | 49 | | onInfiniteQueryDestroy | Triggered when a [`InfiniteQuery`](/api/InfiniteQuery) is destroyed | 50 | | onMutationDestroy | Triggered when a [`Mutation`](/api/Mutation) is destroyed | 51 | 52 | ## Inheritance 53 | `QueryClient` inherits all methods and properties from [QueryClient](https://tanstack.com/query/v5/docs/reference/QueryClient), including: 54 | - [`getQueryData()`](https://tanstack.com/query/v5/docs/reference/QueryClient#queryclientgetquerydata) 55 | - [`setQueryData()`](https://tanstack.com/query/v5/docs/reference/QueryClient#queryclientsetquerydata) 56 | - [`invalidateQueries()`](https://tanstack.com/query/v5/docs/reference/QueryClient#queryclientinvalidatequeries) 57 | - [`prefetchQuery()`](https://tanstack.com/query/v5/docs/reference/QueryClient#queryclientprefetchquery) 58 | - [`cancelQueries()`](https://tanstack.com/query/v5/docs/reference/QueryClient#queryclientcancelqueries) 59 | - And others ([see official documentation](https://tanstack.com/query/v5/docs/reference/QueryClient)) 60 | 61 | ## Usage Example 62 | 63 | ```ts 64 | import { QueryClient } from 'mobx-tanstack-query'; 65 | 66 | // Create a client with custom hooks 67 | const client = new QueryClient({ 68 | hooks: { 69 | onQueryInit: (query) => { 70 | console.log('[Init] Query:', query.queryKey); 71 | }, 72 | onMutationDestroy: (mutation) => { 73 | console.log('[Destroy] Mutation:', mutation.options.mutationKey); 74 | } 75 | }, 76 | defaultOptions: { 77 | queries: { 78 | enableOnDemand: true, 79 | }, 80 | mutations: { 81 | invalidateByKey: true, 82 | } 83 | }, 84 | }); 85 | 86 | // Use standard QueryClient methods 87 | const data = client.getQueryData(['todos']); 88 | ``` 89 | 90 | ## When to Use? 91 | Use `QueryClient` if you need: 92 | - Customization of query/mutation lifecycle 93 | - Tracking entity initialization/destruction events 94 | - Advanced configuration for `MobX`-powered queries and mutations. -------------------------------------------------------------------------------- /docs/api/other.md: -------------------------------------------------------------------------------- 1 | # Other 2 | 3 | 4 | ## `InferQuery`, `InferMutation`, `InferInfiniteQuery` types 5 | 6 | This types are needed to infer some other types from mutations\configs. 7 | 8 | ```ts 9 | type MyData = InferMutation 10 | type MyVariables = InferMutation 11 | type MyConfig = InferMutation 12 | ``` 13 | 14 | 15 | ## `QueryConfigFromFn`, `MutationConfigFromFn`, `InfiniteQueryConfigFromFn` 16 | 17 | This types are needed to create configuration types from your functions of your http client 18 | 19 | ```ts 20 | const myApi = { 21 | createApple: (name: string): Promise => ... 22 | } 23 | 24 | type Config = MutationConfigFromFn 25 | ``` 26 | 27 | 28 | ## `using` keyword 29 | 30 | `Query`, `InfiniteQuery`, `Mutation` supports out-of-box [`using` keyword](https://github.com/tc39/proposal-explicit-resource-management). 31 | 32 | In your project you need to install babel plugin [`@babel/plugin-proposal-explicit-resource-management`](https://www.npmjs.com/package/@babel/plugin-proposal-explicit-resource-management) to add this support. 33 | 34 | How it looks: 35 | 36 | ```ts 37 | import { createQuery } from "mobx-tanstack-query/preset"; 38 | 39 | class DataModel { 40 | async getData() { 41 | using query = createQuery(() => yourApi.getData(), { queryKey: ['data']}); 42 | await when(() => !query.isLoading); 43 | return query.result.data!; 44 | } 45 | } 46 | 47 | const dataModel = new DataModel(); 48 | const data = await dataModel.getData(); 49 | // after call getData() created Query 50 | // will be destroyed 51 | ``` 52 | 53 | 54 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | # https://vitepress.dev/reference/default-theme-home-page 3 | layout: home 4 | 5 | hero: 6 | name: '{packageJson.name}' 7 | text: '{packageJson.description}' 8 | image: 9 | src: /logo.png 10 | actions: 11 | - theme: brand 12 | text: Get Started 13 | link: /introduction/getting-started.md 14 | - theme: alt 15 | text: View on GitHub 16 | link: https://github.com/{packageJson.author}/{packageJson.name} 17 | 18 | features: 19 | - title: MobX-based 20 | icon: 21 | details: Experience the power of MobX 22 | - title: TypeScript 23 | icon: 24 | details: Out-of-box TypeScript support 25 | - title: Dynamic 26 | icon: 🌪️ 27 | details: Create and destroy queries/mutations on a fly 28 | --- 29 | -------------------------------------------------------------------------------- /docs/introduction/getting-started.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Getting started 3 | --- 4 | 5 | # Getting started 6 | 7 | ## Installation 8 | 9 | ::: warning Peer Dependency 10 | @tanstack/query-core is a required peer dependency 11 | ::: 12 | 13 | ::: code-group 14 | 15 | ```bash [npm] 16 | npm install @tanstack/query-core {packageJson.name} 17 | ``` 18 | 19 | ```bash [yarn] 20 | yarn add @tanstack/query-core {packageJson.name} 21 | ``` 22 | 23 | ```bash [pnpm] 24 | pnpm add @tanstack/query-core {packageJson.name} 25 | ``` 26 | 27 | ::: 28 | 29 | ## React Integration 30 | 31 | This library is architecturally decoupled from React and doesn't provide React-specific hooks. 32 | For projects using React, we recommend leveraging the official [@tanstack/react-query](https://npmjs.com/package/@tanstack/react-query) package instead. 33 | It offers first-class support for React hooks and follows modern React patterns. 34 | 35 | The current React integration is implemented via `MobX` React bindings. 36 | 37 | ## Creating instance of [`QueryClient`](/api/QueryClient) 38 | This is extended version of original [`QueryClient`](https://tanstack.com/query/v5/docs/reference/QueryClient) 39 | 40 | ```ts 41 | import { QueryClient } from "mobx-tanstack-query"; 42 | import { hashKey } from '@tanstack/query-core'; 43 | 44 | export const queryClient = new QueryClient({ 45 | defaultOptions: { 46 | queries: { 47 | throwOnError: true, 48 | queryKeyHashFn: hashKey, 49 | refetchOnWindowFocus: true, 50 | refetchOnReconnect: true, 51 | staleTime: 10 * 60 * 1000, 52 | retry: (failureCount, error) => { 53 | if (error instanceof Response && error.status >= 500) { 54 | return failureCount < 3; 55 | } 56 | return false; 57 | }, 58 | }, 59 | mutations: { 60 | throwOnError: true, 61 | }, 62 | }, 63 | }); 64 | ``` 65 | 66 | ## Writing first queries 67 | 68 | ```ts 69 | import { Query } from 'mobx-tanstack-query'; 70 | 71 | const fruitQuery = new Query({ 72 | queryClient, 73 | queryFn: async ({ queryKey }) => { 74 | const response = await fetch(`/api/fruits/${queryKey[1]}`); 75 | return await response.json(); 76 | }, 77 | queryKey: ['fruits', 'apple'], 78 | }) 79 | ``` 80 | 81 | ## Using with classes 82 | 83 | ```ts 84 | import { observable, action } from "mobx"; 85 | import { Query } from 'mobx-tanstack-query'; 86 | 87 | class MyViewModel { 88 | abortController = new AbortController(); 89 | 90 | @observable 91 | accessor fruitName = 'apple'; 92 | 93 | fruitQuery = new Query({ 94 | queryClient, 95 | abortSignal: this.abortController.signal, // Don't forget about that! 96 | queryFn: async ({ queryKey }) => { 97 | const response = await fetch(`/api/fruits/${queryKey[1]}`); 98 | return await response.json(); 99 | }, 100 | options: () => ({ 101 | enabled: !!this.fruitName, 102 | queryKey: ['fruits', this.fruitName], 103 | }) 104 | }) 105 | 106 | @action 107 | setFruitName(fruitName: string) { 108 | this.fruitName = fruitName; 109 | } 110 | 111 | destroy() { 112 | this.abortController.abort(); 113 | } 114 | } 115 | ``` 116 | 117 | ## Using in React 118 | 119 | ```tsx 120 | import { observer } from "mobx-react-lite"; 121 | 122 | const App = observer(() => { 123 | return ( 124 |
125 | {fruitQuery.result.data?.name} 126 |
127 | ) 128 | }) 129 | ``` 130 | -------------------------------------------------------------------------------- /docs/other/project-examples.md: -------------------------------------------------------------------------------- 1 | # Project Examples 2 | 3 | ## **HTTP Status Codes** 4 | Simple usage `MobX` Tanstack queries to fetch JSON data from GitHub 5 | 6 | _Links_: 7 | - Source: https://github.com/js2me/http-status-codes 8 | - GitHub Pages: https://js2me.github.io/http-status-codes/#/ 9 | -------------------------------------------------------------------------------- /docs/other/swagger-codegen.md: -------------------------------------------------------------------------------- 1 | # Swagger Codegen 2 | 3 | 4 | ## `mobx-tanstack-query-api` 5 | 6 | This project is based on [`swagger-typescript-api`](https://github.com/acacode/swagger-typescript-api) 7 | 8 | Github: https://github.com/js2me/mobx-tanstack-query-api 9 | NPM: http://npmjs.org/package/mobx-tanstack-query-api 10 | 11 | ::: warning 12 | Currently `mobx-tanstack-query-api` is a WIP project. 13 | This is not production ready. 14 | ::: 15 | 16 | ### Steps to use 17 | 18 | #### Install 19 | 20 | ::: code-group 21 | 22 | ```bash [npm] 23 | npm install mobx-tanstack-query-api 24 | ``` 25 | 26 | ```bash [yarn] 27 | yarn add mobx-tanstack-query-api 28 | ``` 29 | 30 | ```bash [pnpm] 31 | pnpm add mobx-tanstack-query-api 32 | ``` 33 | 34 | ::: 35 | 36 | 37 | #### Create configuration file 38 | 39 | Create a codegen configuration file with file name `api-codegen.config.(js|mjs)` at root of your project. 40 | Add configuration using `defineConfig` 41 | 42 | ```ts 43 | import { defineConfig } from "mobx-tanstack-query-api/cli"; 44 | import { fileURLToPath } from "url"; 45 | import path from "path"; 46 | 47 | const __filename = fileURLToPath(import.meta.url); 48 | const __dirname = path.dirname(__filename); 49 | 50 | export default defineConfig({ 51 | // input: path.resolve(__dirname, './openapi.yaml'), 52 | input: "http://yourapi.com/url/openapi.yaml", 53 | output: path.resolve(__dirname, 'src/shared/api/__generated__'), 54 | httpClient: 'builtin', 55 | queryClient: 'builtin', 56 | endpoint: 'builtin', 57 | // namespace: 'collectedName', 58 | groupBy: 'tag', 59 | // groupBy: 'tag-1', 60 | // groupBy: 'path-segment', 61 | // groupBy: 'path-segment-1', 62 | filterRoutes: () => true, 63 | // groupBy: route => { 64 | // const api = apis.find(api => api.urls.some(url => route.raw.route.startsWith(url))) 65 | // return api?.name ?? 'other' 66 | // }, 67 | formatExportGroupName: (groupName) => `${groupName}Api`, 68 | }) 69 | ``` 70 | 71 | #### Add script to `package.json` 72 | 73 | ```json 74 | ... 75 | "scripts": { 76 | ... 77 | "api-codegen": "mobx-tanstack-query-api" 78 | ... 79 | } 80 | ... 81 | ``` 82 | 83 | #### Run 84 | 85 | ```bash 86 | npm run api-codegen 87 | ``` -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "type": "module", 4 | "devDependencies": { 5 | "@iconify-json/logos": "^1.1.44", 6 | "@unocss/preset-icons": "^0.61.9", 7 | "unocss": "^0.61.9", 8 | "vite": "^5.3.1", 9 | "vitepress": "^1.3.2" 10 | }, 11 | "scripts": { 12 | "dev": "vitepress dev", 13 | "build": "vitepress build", 14 | "preview": "vitepress preview" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /docs/preset/createInfiniteQuery.md: -------------------------------------------------------------------------------- 1 | # createInfiniteQuery 2 | 3 | This is alternative for `new InfiniteQuery()`. 4 | 5 | ## API Signature 6 | 7 | ```ts 8 | createInfiniteQuery(queryFn, otherOptionsWithoutFn?) 9 | ``` 10 | 11 | ## Usage 12 | ```ts 13 | import { createInfiniteQuery } from "mobx-tanstack-query/preset"; 14 | 15 | const query = createInfiniteQuery(async ({ 16 | signal, 17 | queryKey, 18 | pageParam, 19 | }) => { 20 | const response = await petsApi.fetchPetsApi({ signal, pageParam }) 21 | return response.data; 22 | }, { 23 | initialPageParam: 1, 24 | queryKey: ['pets'], 25 | getNextPageParam: (lastPage, _, lastPageParam) => { 26 | return lastPage.length ? lastPageParam + 1 : null; 27 | }, 28 | }); 29 | ``` 30 | -------------------------------------------------------------------------------- /docs/preset/createMutation.md: -------------------------------------------------------------------------------- 1 | # createMutation 2 | 3 | This is alternative for [`new Mutation()`](/api/Mutation#usage). 4 | 5 | ## API Signature 6 | 7 | ```ts 8 | createMutation(mutationFn, otherOptionsWithoutFn?) 9 | ``` 10 | 11 | ## Usage 12 | ```ts 13 | import { createMutation } from "mobx-tanstack-query/preset"; 14 | 15 | const mutation = createMutation(async (petName) => { 16 | const response = await petsApi.createPet(petName); 17 | return response.data; 18 | }); 19 | 20 | await mutation.mutate(); 21 | ``` 22 | -------------------------------------------------------------------------------- /docs/preset/createQuery.md: -------------------------------------------------------------------------------- 1 | # createQuery 2 | 3 | This is alternative for [`new Query()`](/api/Query#usage). 4 | 5 | ## API Signature 6 | 7 | ```ts 8 | createQuery(queryFn, otherOptionsWithoutFn?) 9 | createQuery(queryOptions?) 10 | createQuery(queryClient, options: () => QueryOptions) 11 | ``` 12 | 13 | ## Usage 14 | ```ts 15 | import { createQuery } from "mobx-tanstack-query/preset"; 16 | 17 | const query = createQuery(async ({ signal, queryKey }) => { 18 | const response = await petsApi.fetchPets({ signal }); 19 | return response.data; 20 | }, { 21 | queryKey: ['pets'], 22 | }) 23 | ``` 24 | -------------------------------------------------------------------------------- /docs/preset/index.md: -------------------------------------------------------------------------------- 1 | # Preset API 2 | 3 | _Or_ **mobx-tanstack-query/preset** 4 | 5 | This is additional api to work with this package, which contains factory functions for `mobx-tanstack-query` entities and already configured [`QueryClient`](/api/QueryClient) 6 | 7 | Here is [link for built-in configuration of `QueryClient`](/src/preset/configs/default-query-client-config.ts) 8 | 9 | 10 | ## Usage 11 | 12 | ```ts 13 | import { 14 | createQuery, 15 | createMutation 16 | } from "mobx-tanstack-query/preset"; 17 | 18 | 19 | const query = createQuery(async ({ signal }) => { 20 | const response = await fetch('/fruits', { signal }); 21 | return await response.json(); 22 | }, { 23 | enabled: false, 24 | queryKey: ['fruits'] 25 | }); 26 | 27 | await query.start(); 28 | 29 | const mutation = createMutation(async (fruitName: string) => { 30 | await fetch('/fruits', { 31 | method: "POST", 32 | data: { 33 | fruitName 34 | } 35 | }) 36 | }, { 37 | onDone: () => { 38 | query.invalidate(); 39 | } 40 | }); 41 | 42 | await mutation.mutate('Apple'); 43 | ``` 44 | 45 | 46 | ## Override configuration 47 | 48 | Every parameter in configuration you can override using this construction: 49 | 50 | ```ts 51 | import { queryClient } from "mobx-tanstack-query/preset"; 52 | 53 | const defaultOptions = queryClient.getDefaultOptions(); 54 | defaultOptions.queries!.refetchOnMount = true; 55 | queryClient.setDefaultOptions({ ...defaultOptions }) 56 | ``` 57 | 58 | ::: tip 59 | Override QueryClient parameters before all queries\mutations initializations 60 | ::: 61 | -------------------------------------------------------------------------------- /docs/preset/queryClient.md: -------------------------------------------------------------------------------- 1 | # queryClient 2 | 3 | This is instance of [`QueryClient`](/api/QueryClient) with [built-in configuration](/src/preset/configs/default-query-client-config.ts) 4 | 5 | -------------------------------------------------------------------------------- /docs/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/js2me/mobx-tanstack-query/d3feb304106a0e1898ba3d87025c66f622eb6816/docs/public/logo.png -------------------------------------------------------------------------------- /docs/public/mobx.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /docs/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import Unocss from 'unocss/vite'; 3 | import { presetAttributify, presetIcons, presetUno } from 'unocss'; 4 | import path from 'path'; 5 | import fs from 'fs'; 6 | 7 | const packageJson = JSON.parse( 8 | fs.readFileSync( 9 | path.resolve(__dirname, '../package.json'), 10 | { encoding: 'utf-8' }, 11 | ), 12 | ) 13 | 14 | export default defineConfig({ 15 | optimizeDeps: { 16 | exclude: ['@vueuse/core', 'vitepress'], 17 | }, 18 | server: { 19 | hmr: { 20 | overlay: false, 21 | }, 22 | }, 23 | define: { 24 | __PACKAGE_DATA__: JSON.stringify(packageJson), 25 | }, 26 | plugins: [ 27 | { 28 | name: 'replace-package-json-vars', 29 | transform(code, id) { 30 | if (!id.endsWith('.md')) return 31 | return code.replace(/\{packageJson\.(\w+)\}/g, (_, key) => { 32 | return packageJson[key] || '' 33 | }) 34 | } 35 | }, 36 | { 37 | name: 'replace-source-links', 38 | transform(code, id ) { 39 | if (!id.endsWith('.md')) return; 40 | return code.replace(/(\(\/src\/)/g, `(https://github.com/${packageJson.author}/${packageJson.name}/tree/master/src/`) 41 | } 42 | }, 43 | Unocss({ 44 | presets: [ 45 | presetUno({ 46 | dark: 'media', 47 | }), 48 | presetAttributify(), 49 | presetIcons({ 50 | scale: 1.2, 51 | }), 52 | ], 53 | }), 54 | ], 55 | }); 56 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mobx-tanstack-query", 3 | "version": "5.2.0", 4 | "scripts": { 5 | "clean": "rimraf dist", 6 | "check": "eslint . --fix", 7 | "prebuild": "npm run clean && npm run check", 8 | "build:watch": "pnpm build && nodemon --watch src --ext ts --exec \"tsc && node ./post-build.mjs\"", 9 | "build": "tsc && node ./post-build.mjs", 10 | "pub": "PUBLISH=true pnpm run build", 11 | "pub:patch": "PUBLISH=true PUBLISH_VERSION=patch pnpm run build", 12 | "pub:minor": "PUBLISH=true PUBLISH_VERSION=minor pnpm run build", 13 | "pub:major": "PUBLISH=true PUBLISH_VERSION=major pnpm run build", 14 | "test": "vitest run", 15 | "test:watch": "vitest watch", 16 | "test:coverage": "vitest run --coverage", 17 | "docs": "pnpm build && cd docs && pnpm dev", 18 | "docs:build": "pnpm build && cd docs && pnpm build", 19 | "docs:serve": "cd docs && pnpm preview", 20 | "dev": "pnpm test:watch" 21 | }, 22 | "keywords": [ 23 | "mobx", 24 | "tanstack", 25 | "tanstack-query", 26 | "query", 27 | "mutation" 28 | ], 29 | "author": "js2me", 30 | "license": "MIT", 31 | "description": "MobX wrappers for Tanstack Query (Core)", 32 | "bugs": { 33 | "url": "https://github.com/js2me/mobx-tanstack-query/issues" 34 | }, 35 | "homepage": "https://github.com/js2me/mobx-tanstack-query", 36 | "repository": { 37 | "type": "git", 38 | "url": "git://github.com/js2me/mobx-tanstack-query" 39 | }, 40 | "peerDependencies": { 41 | "mobx": "^6.12.4", 42 | "@tanstack/query-core": "^5.67.2" 43 | }, 44 | "dependencies": { 45 | "linked-abort-controller": "^1.1.0" 46 | }, 47 | "devDependencies": { 48 | "@testing-library/react": "^16.0.1", 49 | "@types/lodash-es": "^4.17.12", 50 | "@types/node": "^20.14.5", 51 | "@types/react": "^18.3.3", 52 | "@vitejs/plugin-react-swc": "^3.7.2", 53 | "@vitest/coverage-istanbul": "^2.1.6", 54 | "nodemon": "^3.1.0", 55 | "eslint": "^8.57.0", 56 | "js2me-eslint-config": "^1.0.6", 57 | "js2me-exports-post-build-script": "^2.0.18", 58 | "jsdom": "^25.0.1", 59 | "rimraf": "^6.0.1", 60 | "typescript": "^5.4.5", 61 | "vitest": "^2.1.4", 62 | "yummies": "^3.0.23" 63 | }, 64 | "packageManager": "pnpm@9.5.0+sha512.140036830124618d624a2187b50d04289d5a087f326c9edfc0ccd733d76c4f52c3a313d4fc148794a2a9d81553016004e6742e8cf850670268a7387fc220c903" 65 | } -------------------------------------------------------------------------------- /post-build.mjs: -------------------------------------------------------------------------------- 1 | import { postBuildScript, publishScript } from 'js2me-exports-post-build-script'; 2 | 3 | postBuildScript({ 4 | buildDir: 'dist', 5 | rootDir: '.', 6 | srcDirName: 'src', 7 | filesToCopy: ['LICENSE', 'README.md', 'assets'], 8 | updateVersion: process.env.PUBLISH_VERSION, 9 | onDone: (versionsDiff, { $ }, packageJson, { targetPackageJson }) => { 10 | if (process.env.PUBLISH) { 11 | $('pnpm test'); 12 | 13 | publishScript({ 14 | nextVersion: versionsDiff?.next ?? packageJson.version, 15 | currVersion: versionsDiff?.current, 16 | publishCommand: 'pnpm publish', 17 | commitAllCurrentChanges: true, 18 | createTag: true, 19 | githubRepoLink: 'https://github.com/js2me/mobx-tanstack-query', 20 | cleanupCommand: 'pnpm clean', 21 | targetPackageJson 22 | }) 23 | } 24 | } 25 | }); 26 | 27 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './mutation'; 2 | export * from './mutation.types'; 3 | export * from './query.types'; 4 | export * from './query'; 5 | export * from './query-client'; 6 | export * from './query-client.types'; 7 | export * from './inifinite-query'; 8 | export * from './inifinite-query.types'; 9 | export * from './query-options'; 10 | -------------------------------------------------------------------------------- /src/infinite-query.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DefaultError, 3 | FetchNextPageOptions, 4 | FetchPreviousPageOptions, 5 | QueryClient, 6 | QueryKey, 7 | RefetchOptions, 8 | } from '@tanstack/query-core'; 9 | import { when } from 'mobx'; 10 | import { describe, expect, it, vi } from 'vitest'; 11 | 12 | import { InfiniteQuery } from './inifinite-query'; 13 | import { 14 | InfiniteQueryConfig, 15 | InfiniteQueryDynamicOptions, 16 | InfiniteQueryUpdateOptions, 17 | } from './inifinite-query.types'; 18 | import { QueryInvalidateParams } from './query.types'; 19 | 20 | class InfiniteQueryMock< 21 | TData, 22 | TError = DefaultError, 23 | TQueryKey extends QueryKey = any, 24 | TPageParam = unknown, 25 | > extends InfiniteQuery { 26 | spies = { 27 | queryFn: null as unknown as ReturnType, 28 | setData: vi.fn(), 29 | update: vi.fn(), 30 | dispose: vi.fn(), 31 | refetch: vi.fn(), 32 | invalidate: vi.fn(), 33 | onDone: vi.fn(), 34 | onError: vi.fn(), 35 | fetchNextPage: vi.fn(), 36 | fetchPreviousPage: vi.fn(), 37 | }; 38 | 39 | constructor( 40 | options: Omit< 41 | InfiniteQueryConfig, 42 | 'queryClient' 43 | >, 44 | ) { 45 | super({ 46 | ...options, 47 | queryClient: new QueryClient({}), 48 | // @ts-ignore 49 | queryFn: vi.fn((...args: any[]) => { 50 | // @ts-ignore 51 | const result = options.queryFn?.(...args); 52 | return result; 53 | }), 54 | }); 55 | 56 | this.spies.queryFn = this.options.queryFn as any; 57 | 58 | this.onDone(this.spies.onDone); 59 | this.onError(this.spies.onError); 60 | } 61 | 62 | get _rawResult() { 63 | return this._result; 64 | } 65 | 66 | refetch(options?: RefetchOptions | undefined) { 67 | this.spies.refetch(options); 68 | return super.refetch(options); 69 | } 70 | 71 | invalidate(params?: QueryInvalidateParams | undefined): Promise { 72 | this.spies.invalidate(params)(); 73 | return super.invalidate(); 74 | } 75 | 76 | update( 77 | options: 78 | | InfiniteQueryUpdateOptions 79 | | InfiniteQueryDynamicOptions, 80 | ) { 81 | const result = super.update(options); 82 | this.spies.update.mockReturnValue(result)(options); 83 | return result; 84 | } 85 | 86 | async fetchNextPage(options?: FetchNextPageOptions | undefined) { 87 | const result = await super.fetchNextPage(options); 88 | this.spies.fetchNextPage.mockReturnValue(result)(options); 89 | return result; 90 | } 91 | 92 | async fetchPreviousPage(options?: FetchPreviousPageOptions | undefined) { 93 | const result = await super.fetchPreviousPage(options); 94 | this.spies.fetchPreviousPage.mockReturnValue(result)(options); 95 | return result; 96 | } 97 | 98 | setData(updater: any, options?: any) { 99 | const result = super.setData(updater, options); 100 | this.spies.setData.mockReturnValue(result)(updater, options); 101 | return result; 102 | } 103 | 104 | dispose(): void { 105 | const result = super.dispose(); 106 | this.spies.dispose.mockReturnValue(result)(); 107 | return result; 108 | } 109 | } 110 | 111 | describe('InfiniteQuery', () => { 112 | it('should call queryFn without infinite query params', async () => { 113 | const query = new InfiniteQueryMock({ 114 | queryKey: ['test'], 115 | queryFn: () => {}, 116 | }); 117 | 118 | expect(query.spies.queryFn).toBeCalledTimes(1); 119 | expect(query.spies.queryFn).toBeCalledWith({ 120 | ...query.spies.queryFn.mock.calls[0][0], 121 | direction: 'forward', 122 | meta: undefined, 123 | pageParam: undefined, 124 | queryKey: ['test'], 125 | }); 126 | 127 | query.dispose(); 128 | }); 129 | 130 | it('should call queryFn with initialPageParam', async () => { 131 | const query = new InfiniteQueryMock({ 132 | queryKey: ['test'], 133 | initialPageParam: 0, 134 | queryFn: () => {}, 135 | }); 136 | 137 | expect(query.spies.queryFn).toBeCalledTimes(1); 138 | expect(query.spies.queryFn).toBeCalledWith({ 139 | ...query.spies.queryFn.mock.calls[0][0], 140 | direction: 'forward', 141 | meta: undefined, 142 | pageParam: 0, 143 | queryKey: ['test'], 144 | }); 145 | 146 | query.dispose(); 147 | }); 148 | 149 | it('should call queryFn with getNextPageParam', async () => { 150 | const query = new InfiniteQueryMock({ 151 | queryKey: ['test'], 152 | getNextPageParam: () => 1, 153 | queryFn: () => {}, 154 | }); 155 | 156 | expect(query.spies.queryFn).toBeCalledTimes(1); 157 | expect(query.spies.queryFn).toBeCalledWith({ 158 | ...query.spies.queryFn.mock.calls[0][0], 159 | direction: 'forward', 160 | meta: undefined, 161 | pageParam: undefined, 162 | queryKey: ['test'], 163 | }); 164 | 165 | query.dispose(); 166 | }); 167 | 168 | it('should call queryFn with getNextPageParam returning null', async () => { 169 | const query = new InfiniteQueryMock({ 170 | queryKey: ['test'], 171 | getNextPageParam: () => null, 172 | queryFn: async () => 'data', 173 | }); 174 | 175 | expect(query.spies.queryFn).toBeCalledTimes(1); 176 | expect(query.spies.queryFn).toBeCalledWith({ 177 | ...query.spies.queryFn.mock.calls[0][0], 178 | direction: 'forward', 179 | meta: undefined, 180 | pageParam: undefined, 181 | queryKey: ['test'], 182 | }); 183 | 184 | await when(() => !query.result.isLoading); 185 | 186 | expect(query.result).toStrictEqual({ 187 | ...query.result, 188 | data: { 189 | pageParams: [undefined], 190 | pages: ['data'], 191 | }, 192 | error: null, 193 | errorUpdateCount: 0, 194 | errorUpdatedAt: 0, 195 | failureCount: 0, 196 | failureReason: null, 197 | fetchStatus: 'idle', 198 | hasNextPage: false, 199 | hasPreviousPage: false, 200 | isError: false, 201 | isFetchNextPageError: false, 202 | isFetchPreviousPageError: false, 203 | isFetched: true, 204 | isFetchedAfterMount: true, 205 | isFetching: false, 206 | isFetchingNextPage: false, 207 | isFetchingPreviousPage: false, 208 | isInitialLoading: false, 209 | isLoading: false, 210 | isLoadingError: false, 211 | isPaused: false, 212 | isPending: false, 213 | isPlaceholderData: false, 214 | isRefetchError: false, 215 | isRefetching: false, 216 | isStale: true, 217 | isSuccess: true, 218 | status: 'success', 219 | }); 220 | 221 | query.dispose(); 222 | }); 223 | 224 | it('should call queryFn after fetchNextPage call', async () => { 225 | const query = new InfiniteQueryMock({ 226 | queryKey: ['test'], 227 | initialPageParam: 1, 228 | getNextPageParam: (_, _1, lastPageParam) => lastPageParam + 1, 229 | queryFn: () => { 230 | return [1, 2, 3]; 231 | }, 232 | }); 233 | 234 | expect(query.result).toStrictEqual({ 235 | ...query.result, 236 | data: undefined, 237 | dataUpdatedAt: 0, 238 | error: null, 239 | errorUpdateCount: 0, 240 | errorUpdatedAt: 0, 241 | failureCount: 0, 242 | failureReason: null, 243 | fetchStatus: 'fetching', 244 | hasNextPage: false, 245 | hasPreviousPage: false, 246 | isError: false, 247 | isFetchNextPageError: false, 248 | isFetchPreviousPageError: false, 249 | isFetched: false, 250 | isFetchedAfterMount: false, 251 | isFetching: true, 252 | isFetchingNextPage: false, 253 | isFetchingPreviousPage: false, 254 | isInitialLoading: true, 255 | isLoading: true, 256 | isLoadingError: false, 257 | isPaused: false, 258 | isPending: true, 259 | isPlaceholderData: false, 260 | isRefetchError: false, 261 | isRefetching: false, 262 | isStale: true, 263 | isSuccess: false, 264 | status: 'pending', 265 | }); 266 | 267 | await query.fetchNextPage(); 268 | 269 | expect(query.spies.fetchNextPage).toBeCalledTimes(1); 270 | expect(query.spies.queryFn).toBeCalledTimes(1); 271 | 272 | expect(query.result).toStrictEqual({ 273 | ...query.result, 274 | data: { 275 | pageParams: [1], 276 | pages: [[1, 2, 3]], 277 | }, 278 | error: null, 279 | errorUpdateCount: 0, 280 | errorUpdatedAt: 0, 281 | failureCount: 0, 282 | failureReason: null, 283 | fetchStatus: 'idle', 284 | hasNextPage: true, 285 | hasPreviousPage: false, 286 | isError: false, 287 | isFetchNextPageError: false, 288 | isFetchPreviousPageError: false, 289 | isFetched: true, 290 | isFetchedAfterMount: true, 291 | isFetching: false, 292 | isFetchingNextPage: false, 293 | isFetchingPreviousPage: false, 294 | isInitialLoading: false, 295 | isLoading: false, 296 | isLoadingError: false, 297 | isPaused: false, 298 | isPending: false, 299 | isPlaceholderData: false, 300 | isRefetchError: false, 301 | isRefetching: false, 302 | isStale: true, 303 | isSuccess: true, 304 | status: 'success', 305 | }); 306 | 307 | query.dispose(); 308 | }); 309 | 310 | it('should call queryFn after fetchNextPage call (x3 times)', async () => { 311 | const query = new InfiniteQueryMock({ 312 | queryKey: ['test'], 313 | initialPageParam: 1, 314 | getNextPageParam: (_, _1, lastPageParam) => lastPageParam + 1, 315 | queryFn: ({ pageParam, queryKey }) => { 316 | return { data: pageParam, queryKey }; 317 | }, 318 | }); 319 | 320 | expect(query.result).toStrictEqual({ 321 | ...query.result, 322 | data: undefined, 323 | dataUpdatedAt: 0, 324 | error: null, 325 | errorUpdateCount: 0, 326 | errorUpdatedAt: 0, 327 | failureCount: 0, 328 | failureReason: null, 329 | fetchStatus: 'fetching', 330 | hasNextPage: false, 331 | hasPreviousPage: false, 332 | isError: false, 333 | isFetchNextPageError: false, 334 | isFetchPreviousPageError: false, 335 | isFetched: false, 336 | isFetchedAfterMount: false, 337 | isFetching: true, 338 | isFetchingNextPage: false, 339 | isFetchingPreviousPage: false, 340 | isInitialLoading: true, 341 | isLoading: true, 342 | isLoadingError: false, 343 | isPaused: false, 344 | isPending: true, 345 | isPlaceholderData: false, 346 | isRefetchError: false, 347 | isRefetching: false, 348 | isStale: true, 349 | isSuccess: false, 350 | status: 'pending', 351 | }); 352 | 353 | await query.fetchNextPage(); 354 | await query.fetchNextPage(); 355 | await query.fetchNextPage(); 356 | 357 | expect(query.result).toStrictEqual({ 358 | ...query.result, 359 | data: { 360 | pageParams: [1, 2, 3], 361 | pages: [ 362 | { 363 | data: 1, 364 | queryKey: ['test'], 365 | }, 366 | { 367 | data: 2, 368 | queryKey: ['test'], 369 | }, 370 | { 371 | data: 3, 372 | queryKey: ['test'], 373 | }, 374 | ], 375 | }, 376 | error: null, 377 | errorUpdateCount: 0, 378 | errorUpdatedAt: 0, 379 | failureCount: 0, 380 | failureReason: null, 381 | fetchStatus: 'idle', 382 | hasNextPage: true, 383 | hasPreviousPage: false, 384 | isError: false, 385 | isFetchNextPageError: false, 386 | isFetchPreviousPageError: false, 387 | isFetched: true, 388 | isFetchedAfterMount: true, 389 | isFetching: false, 390 | isFetchingNextPage: false, 391 | isFetchingPreviousPage: false, 392 | isInitialLoading: false, 393 | isLoading: false, 394 | isLoadingError: false, 395 | isPaused: false, 396 | isPending: false, 397 | isPlaceholderData: false, 398 | isRefetchError: false, 399 | isRefetching: false, 400 | isStale: true, 401 | isSuccess: true, 402 | status: 'success', 403 | }); 404 | 405 | query.dispose(); 406 | }); 407 | 408 | describe('"enabled" reactive parameter', () => { 409 | it('should be reactive after change queryKey', async () => { 410 | const query = new InfiniteQueryMock({ 411 | queryKey: ['test', 0 as number] as const, 412 | enabled: ({ queryKey }) => queryKey[1] > 0, 413 | getNextPageParam: () => 1, 414 | queryFn: () => 100, 415 | }); 416 | 417 | query.update({ queryKey: ['test', 1] as const }); 418 | 419 | await when(() => !query.result.isLoading); 420 | 421 | expect(query.spies.queryFn).toBeCalledTimes(1); 422 | expect(query.spies.queryFn).nthReturnedWith(1, 100); 423 | 424 | query.dispose(); 425 | }); 426 | }); 427 | }); 428 | -------------------------------------------------------------------------------- /src/inifinite-query.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DefaultError, 3 | FetchNextPageOptions, 4 | FetchPreviousPageOptions, 5 | hashKey, 6 | InfiniteQueryObserver, 7 | QueryKey, 8 | InfiniteQueryObserverResult, 9 | InfiniteData, 10 | RefetchOptions, 11 | SetDataOptions, 12 | Updater, 13 | } from '@tanstack/query-core'; 14 | import { LinkedAbortController } from 'linked-abort-controller'; 15 | import { 16 | action, 17 | reaction, 18 | makeObservable, 19 | observable, 20 | runInAction, 21 | } from 'mobx'; 22 | 23 | import { 24 | InfiniteQueryConfig, 25 | InfiniteQueryDynamicOptions, 26 | InfiniteQueryInvalidateParams, 27 | InfiniteQueryOptions, 28 | InfiniteQueryResetParams, 29 | InfiniteQueryUpdateOptions, 30 | } from './inifinite-query.types'; 31 | import { AnyQueryClient, QueryClientHooks } from './query-client.types'; 32 | 33 | export class InfiniteQuery< 34 | TData, 35 | TError = DefaultError, 36 | TQueryKey extends QueryKey = any, 37 | TPageParam = unknown, 38 | > implements Disposable 39 | { 40 | protected abortController: AbortController; 41 | protected queryClient: AnyQueryClient; 42 | 43 | protected _result: InfiniteQueryObserverResult< 44 | InfiniteData, 45 | TError 46 | >; 47 | options: InfiniteQueryOptions; 48 | queryObserver: InfiniteQueryObserver< 49 | TData, 50 | TError, 51 | InfiniteData, 52 | TData, 53 | TQueryKey, 54 | TPageParam 55 | >; 56 | 57 | isResultRequsted: boolean; 58 | 59 | private isEnabledOnResultDemand: boolean; 60 | 61 | /** 62 | * This parameter is responsible for holding the enabled value, 63 | * in cases where the "enableOnDemand" option is enabled 64 | */ 65 | private holdedEnabledOption: InfiniteQueryOptions< 66 | TData, 67 | TError, 68 | TQueryKey, 69 | TPageParam 70 | >['enabled']; 71 | private _observerSubscription?: VoidFunction; 72 | private hooks?: QueryClientHooks; 73 | 74 | constructor( 75 | protected config: InfiniteQueryConfig, 76 | ) { 77 | const { 78 | queryClient, 79 | queryKey: queryKeyOrDynamicQueryKey, 80 | options: getDynamicOptions, 81 | ...restOptions 82 | } = config; 83 | this.abortController = new LinkedAbortController(config.abortSignal); 84 | this.queryClient = queryClient; 85 | this._result = undefined as any; 86 | this.isResultRequsted = false; 87 | this.isEnabledOnResultDemand = config.enableOnDemand ?? false; 88 | this.hooks = 89 | 'hooks' in this.queryClient ? this.queryClient.hooks : undefined; 90 | 91 | if ('queryFeatures' in queryClient && config.enableOnDemand == null) { 92 | this.isEnabledOnResultDemand = 93 | queryClient.queryFeatures.enableOnDemand ?? false; 94 | } 95 | 96 | observable.deep(this, '_result'); 97 | observable.ref(this, 'isResultRequsted'); 98 | action.bound(this, 'setData'); 99 | action.bound(this, 'update'); 100 | action.bound(this, 'updateResult'); 101 | 102 | makeObservable(this); 103 | 104 | this.options = this.queryClient.defaultQueryOptions({ 105 | ...restOptions, 106 | ...getDynamicOptions?.(this), 107 | } as any) as InfiniteQueryOptions; 108 | 109 | this.options.structuralSharing = this.options.structuralSharing ?? false; 110 | 111 | this.processOptions(this.options); 112 | 113 | if (typeof queryKeyOrDynamicQueryKey === 'function') { 114 | this.options.queryKey = queryKeyOrDynamicQueryKey(); 115 | 116 | reaction( 117 | () => queryKeyOrDynamicQueryKey(), 118 | (queryKey) => { 119 | this.update({ 120 | queryKey, 121 | }); 122 | }, 123 | { 124 | signal: this.abortController.signal, 125 | }, 126 | ); 127 | } else { 128 | this.options.queryKey = 129 | queryKeyOrDynamicQueryKey ?? this.options.queryKey ?? []; 130 | } 131 | 132 | // Tracking props visit should be done in MobX, by default. 133 | this.options.notifyOnChangeProps = 134 | restOptions.notifyOnChangeProps ?? 135 | queryClient.getDefaultOptions().queries?.notifyOnChangeProps ?? 136 | 'all'; 137 | 138 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 139 | // @ts-expect-error 140 | this.queryObserver = new InfiniteQueryObserver(queryClient, this.options); 141 | 142 | this.updateResult(this.queryObserver.getOptimisticResult(this.options)); 143 | 144 | this._observerSubscription = this.queryObserver.subscribe( 145 | this.updateResult, 146 | ); 147 | 148 | if (getDynamicOptions) { 149 | reaction(() => getDynamicOptions(this), this.update, { 150 | signal: this.abortController.signal, 151 | }); 152 | } 153 | 154 | if (this.isEnabledOnResultDemand) { 155 | reaction( 156 | () => this.isResultRequsted, 157 | (isRequested) => { 158 | if (isRequested) { 159 | this.update(getDynamicOptions?.(this) ?? {}); 160 | } 161 | }, 162 | { 163 | signal: this.abortController.signal, 164 | fireImmediately: true, 165 | }, 166 | ); 167 | } 168 | 169 | if (config.onDone) { 170 | this.onDone(config.onDone); 171 | } 172 | if (config.onError) { 173 | this.onError(config.onError); 174 | } 175 | 176 | this.abortController.signal.addEventListener('abort', this.handleAbort); 177 | 178 | this.config.onInit?.(this); 179 | this.hooks?.onInfiniteQueryInit?.(this); 180 | } 181 | 182 | protected createQueryHash( 183 | queryKey: any, 184 | options: InfiniteQueryOptions, 185 | ) { 186 | if (options.queryKeyHashFn) { 187 | return options.queryKeyHashFn(queryKey); 188 | } 189 | 190 | return hashKey(queryKey); 191 | } 192 | 193 | setData( 194 | updater: Updater< 195 | NoInfer> | undefined, 196 | NoInfer> | undefined 197 | >, 198 | options?: SetDataOptions, 199 | ) { 200 | this.queryClient.setQueryData>( 201 | this.options.queryKey, 202 | updater, 203 | options, 204 | ); 205 | } 206 | 207 | private checkIsEnabled() { 208 | if (this.isEnabledOnResultDemand && !this.isResultRequsted) { 209 | return false; 210 | } 211 | 212 | return this.holdedEnabledOption; 213 | } 214 | 215 | fetchNextPage(options?: FetchNextPageOptions | undefined) { 216 | return this.queryObserver.fetchNextPage(options); 217 | } 218 | 219 | fetchPreviousPage(options?: FetchPreviousPageOptions | undefined) { 220 | return this.queryObserver.fetchPreviousPage(options); 221 | } 222 | 223 | update( 224 | optionsUpdate: 225 | | Partial> 226 | | InfiniteQueryUpdateOptions 227 | | InfiniteQueryDynamicOptions, 228 | ) { 229 | if (this.abortController.signal.aborted) { 230 | return; 231 | } 232 | 233 | const nextOptions = { 234 | ...this.options, 235 | ...optionsUpdate, 236 | } as InfiniteQueryOptions; 237 | 238 | this.processOptions(nextOptions); 239 | 240 | this.options = nextOptions; 241 | 242 | this.queryObserver.setOptions(this.options); 243 | } 244 | 245 | private isEnableHolded = false; 246 | 247 | private enableHolder = () => false; 248 | 249 | private processOptions = ( 250 | options: InfiniteQueryOptions, 251 | ) => { 252 | options.queryHash = this.createQueryHash(options.queryKey, options); 253 | 254 | // If the on-demand query mode is enabled (when using the result property) 255 | // then, if the user does not request the result, the queries should not be executed 256 | // to do this, we hold the original value of the enabled option 257 | // and set enabled to false until the user requests the result (this.isResultRequsted) 258 | if (this.isEnabledOnResultDemand) { 259 | if (this.isEnableHolded && options.enabled !== this.enableHolder) { 260 | this.holdedEnabledOption = options.enabled; 261 | } 262 | 263 | if (this.isResultRequsted) { 264 | if (this.isEnableHolded) { 265 | options.enabled = 266 | this.holdedEnabledOption === this.enableHolder 267 | ? undefined 268 | : this.holdedEnabledOption; 269 | this.isEnableHolded = false; 270 | } 271 | } else { 272 | this.isEnableHolded = true; 273 | this.holdedEnabledOption = options.enabled; 274 | options.enabled = this.enableHolder; 275 | } 276 | } 277 | }; 278 | 279 | public get result() { 280 | if (!this.isResultRequsted) { 281 | runInAction(() => { 282 | this.isResultRequsted = true; 283 | }); 284 | } 285 | return this._result; 286 | } 287 | 288 | /** 289 | * Modify this result so it matches the tanstack query result. 290 | */ 291 | private updateResult( 292 | nextResult: InfiniteQueryObserverResult< 293 | InfiniteData, 294 | TError 295 | >, 296 | ) { 297 | this._result = nextResult || {}; 298 | } 299 | 300 | async refetch(options?: RefetchOptions) { 301 | const result = await this.queryObserver.refetch(options); 302 | const query = this.queryObserver.getCurrentQuery(); 303 | 304 | if ( 305 | query.state.error && 306 | (options?.throwOnError || 307 | this.options.throwOnError === true || 308 | (typeof this.options.throwOnError === 'function' && 309 | this.options.throwOnError(query.state.error, query))) 310 | ) { 311 | throw query.state.error; 312 | } 313 | 314 | return result; 315 | } 316 | 317 | async reset(params?: InfiniteQueryResetParams) { 318 | await this.queryClient.resetQueries({ 319 | queryKey: this.options.queryKey, 320 | exact: true, 321 | ...params, 322 | } as any); 323 | } 324 | 325 | async invalidate(options?: InfiniteQueryInvalidateParams) { 326 | await this.queryClient.invalidateQueries({ 327 | exact: true, 328 | queryKey: this.options.queryKey, 329 | ...options, 330 | } as any); 331 | } 332 | 333 | onDone( 334 | onDoneCallback: ( 335 | data: InfiniteData, 336 | payload: void, 337 | ) => void, 338 | ): void { 339 | reaction( 340 | () => { 341 | const { error, isSuccess, fetchStatus } = this._result; 342 | return isSuccess && !error && fetchStatus === 'idle'; 343 | }, 344 | (isDone) => { 345 | if (isDone) { 346 | onDoneCallback(this._result.data!, void 0); 347 | } 348 | }, 349 | { 350 | signal: this.abortController.signal, 351 | }, 352 | ); 353 | } 354 | 355 | onError(onErrorCallback: (error: TError, payload: void) => void): void { 356 | reaction( 357 | () => this._result.error, 358 | (error) => { 359 | if (error) { 360 | onErrorCallback(error, void 0); 361 | } 362 | }, 363 | { 364 | signal: this.abortController.signal, 365 | }, 366 | ); 367 | } 368 | 369 | protected handleAbort = () => { 370 | this._observerSubscription?.(); 371 | 372 | this.queryObserver.destroy(); 373 | this.isResultRequsted = false; 374 | 375 | let isNeedToReset = 376 | this.config.resetOnDestroy || this.config.resetOnDispose; 377 | 378 | if ('queryFeatures' in this.queryClient && !isNeedToReset) { 379 | isNeedToReset = 380 | this.queryClient.queryFeatures.resetOnDestroy || 381 | this.queryClient.queryFeatures.resetOnDispose; 382 | } 383 | 384 | if (isNeedToReset) { 385 | this.reset(); 386 | } 387 | 388 | delete this._observerSubscription; 389 | this.hooks?.onInfiniteQueryDestroy?.(this); 390 | }; 391 | 392 | destroy() { 393 | this.abortController.abort(); 394 | } 395 | 396 | /** 397 | * @deprecated use `destroy`. This method will be removed in next major release 398 | */ 399 | dispose() { 400 | this.destroy(); 401 | } 402 | 403 | [Symbol.dispose](): void { 404 | this.destroy(); 405 | } 406 | 407 | // Firefox fix (Symbol.dispose is undefined in FF) 408 | [Symbol.for('Symbol.dispose')](): void { 409 | this.destroy(); 410 | } 411 | } 412 | 413 | /** 414 | * @remarks ⚠️ use `InfiniteQuery`. This export will be removed in next major release 415 | */ 416 | export const MobxInfiniteQuery = InfiniteQuery; 417 | -------------------------------------------------------------------------------- /src/inifinite-query.types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DefaultError, 3 | InfiniteQueryObserverOptions, 4 | QueryKey, 5 | InfiniteData, 6 | DefaultedInfiniteQueryObserverOptions, 7 | } from '@tanstack/query-core'; 8 | 9 | import { InfiniteQuery } from './inifinite-query'; 10 | import { AnyQueryClient } from './query-client.types'; 11 | import { 12 | QueryFeatures, 13 | QueryInvalidateParams, 14 | QueryResetParams, 15 | } from './query.types'; 16 | 17 | export interface InfiniteQueryInvalidateParams extends QueryInvalidateParams {} 18 | 19 | /** 20 | * @remarks ⚠️ use `InfiniteQueryInvalidateParams`. This type will be removed in next major release 21 | */ 22 | export type MobxInfiniteQueryInvalidateParams = InfiniteQueryInvalidateParams; 23 | 24 | export interface InfiniteQueryResetParams extends QueryResetParams {} 25 | 26 | /** 27 | * @remarks ⚠️ use `InfiniteQueryResetParams`. This type will be removed in next major release 28 | */ 29 | export type MobxInfiniteQueryResetParams = InfiniteQueryResetParams; 30 | 31 | export interface InfiniteQueryDynamicOptions< 32 | TData, 33 | TError = DefaultError, 34 | TQueryKey extends QueryKey = QueryKey, 35 | TPageParam = unknown, 36 | > extends Partial< 37 | Omit< 38 | InfiniteQueryObserverOptions< 39 | TData, 40 | TError, 41 | InfiniteData, 42 | TData, 43 | TQueryKey, 44 | TPageParam 45 | >, 46 | 'queryFn' | 'enabled' | 'queryKeyHashFn' 47 | > 48 | > { 49 | enabled?: boolean; 50 | } 51 | 52 | /** 53 | * @remarks ⚠️ use `InfiniteQueryDynamicOptions`. This type will be removed in next major release 54 | */ 55 | export type MobxInfiniteQueryDynamicOptions< 56 | TData, 57 | TError = DefaultError, 58 | TQueryKey extends QueryKey = QueryKey, 59 | TPageParam = unknown, 60 | > = InfiniteQueryDynamicOptions; 61 | 62 | export interface InfiniteQueryOptions< 63 | TData, 64 | TError = DefaultError, 65 | TQueryKey extends QueryKey = QueryKey, 66 | TPageParam = unknown, 67 | > extends DefaultedInfiniteQueryObserverOptions< 68 | TData, 69 | TError, 70 | InfiniteData, 71 | TData, 72 | TQueryKey, 73 | TPageParam 74 | > {} 75 | 76 | /** 77 | * @remarks ⚠️ use `InfiniteQueryOptions`. This type will be removed in next major release 78 | */ 79 | export type MobxInfiniteQueryOptions< 80 | TData, 81 | TError = DefaultError, 82 | TQueryKey extends QueryKey = QueryKey, 83 | TPageParam = unknown, 84 | > = InfiniteQueryOptions; 85 | 86 | export interface InfiniteQueryUpdateOptions< 87 | TData, 88 | TError = DefaultError, 89 | TQueryKey extends QueryKey = QueryKey, 90 | TPageParam = unknown, 91 | > extends Partial< 92 | InfiniteQueryObserverOptions< 93 | TData, 94 | TError, 95 | InfiniteData, 96 | TData, 97 | TQueryKey, 98 | TPageParam 99 | > 100 | > {} 101 | 102 | /** 103 | * @remarks ⚠️ use `InfiniteQueryUpdateOptions`. This type will be removed in next major release 104 | */ 105 | export type MobxInfiniteQueryUpdateOptions< 106 | TData, 107 | TError = DefaultError, 108 | TQueryKey extends QueryKey = QueryKey, 109 | TPageParam = unknown, 110 | > = InfiniteQueryUpdateOptions; 111 | 112 | export type InfiniteQueryConfigFromFn< 113 | TFn extends (...args: any[]) => any, 114 | TError = DefaultError, 115 | TQueryKey extends QueryKey = QueryKey, 116 | TPageParam = unknown, 117 | > = InfiniteQueryConfig< 118 | ReturnType extends Promise ? TData : ReturnType, 119 | TError, 120 | TQueryKey, 121 | TPageParam 122 | >; 123 | 124 | /** 125 | * @remarks ⚠️ use `InfiniteQueryConfigFromFn`. This type will be removed in next major release 126 | */ 127 | export type MobxInfiniteQueryConfigFromFn< 128 | TFn extends (...args: any[]) => any, 129 | TError = DefaultError, 130 | TQueryKey extends QueryKey = QueryKey, 131 | TPageParam = unknown, 132 | > = InfiniteQueryConfigFromFn; 133 | 134 | export interface InfiniteQueryConfig< 135 | TData, 136 | TError = DefaultError, 137 | TQueryKey extends QueryKey = QueryKey, 138 | TPageParam = unknown, 139 | > extends Partial< 140 | Omit< 141 | InfiniteQueryObserverOptions< 142 | TData, 143 | TError, 144 | InfiniteData, 145 | TData, 146 | TQueryKey, 147 | TPageParam 148 | >, 149 | 'queryKey' 150 | > 151 | >, 152 | QueryFeatures { 153 | queryClient: AnyQueryClient; 154 | /** 155 | * TanStack Query manages query caching for you based on query keys. 156 | * Query keys have to be an Array at the top level, and can be as simple as an Array with a single string, or as complex as an array of many strings and nested objects. 157 | * As long as the query key is serializable, and unique to the query's data, you can use it! 158 | * 159 | * **Important:** If you define it as a function then it will be reactively updates query origin key every time 160 | * when observable values inside the function changes 161 | * 162 | * @link https://tanstack.com/query/v4/docs/framework/react/guides/query-keys#simple-query-keys 163 | */ 164 | queryKey?: TQueryKey | (() => TQueryKey); 165 | onInit?: (query: InfiniteQuery) => void; 166 | abortSignal?: AbortSignal; 167 | onDone?: (data: InfiniteData, payload: void) => void; 168 | onError?: (error: TError, payload: void) => void; 169 | /** 170 | * Dynamic query parameters, when result of this function changed query will be updated 171 | * (reaction -> setOptions) 172 | */ 173 | options?: ( 174 | query: NoInfer< 175 | InfiniteQuery< 176 | NoInfer, 177 | NoInfer, 178 | NoInfer, 179 | NoInfer 180 | > 181 | >, 182 | ) => InfiniteQueryDynamicOptions; 183 | } 184 | 185 | /** 186 | * @remarks ⚠️ use `InfiniteQueryConfig`. This type will be removed in next major release 187 | */ 188 | export type MobxInfiniteQueryConfig< 189 | TData, 190 | TError = DefaultError, 191 | TQueryKey extends QueryKey = QueryKey, 192 | TPageParam = unknown, 193 | > = InfiniteQueryConfig; 194 | 195 | export type InferInfiniteQuery< 196 | T extends 197 | | InfiniteQueryConfig 198 | | InfiniteQuery, 199 | TInferValue extends 200 | | 'data' 201 | | 'key' 202 | | 'page-param' 203 | | 'error' 204 | | 'query' 205 | | 'config', 206 | > = 207 | T extends InfiniteQueryConfig< 208 | infer TData, 209 | infer TError, 210 | infer TQueryKey, 211 | infer TPageParam 212 | > 213 | ? TInferValue extends 'config' 214 | ? T 215 | : TInferValue extends 'data' 216 | ? TData 217 | : TInferValue extends 'key' 218 | ? TQueryKey 219 | : TInferValue extends 'page-param' 220 | ? TPageParam 221 | : TInferValue extends 'error' 222 | ? TError 223 | : TInferValue extends 'query' 224 | ? InfiniteQuery 225 | : never 226 | : T extends InfiniteQuery< 227 | infer TData, 228 | infer TError, 229 | infer TQueryKey, 230 | infer TPageParam 231 | > 232 | ? TInferValue extends 'config' 233 | ? InfiniteQueryConfig 234 | : TInferValue extends 'data' 235 | ? TData 236 | : TInferValue extends 'key' 237 | ? TQueryKey 238 | : TInferValue extends 'page-param' 239 | ? TPageParam 240 | : TInferValue extends 'error' 241 | ? TError 242 | : TInferValue extends 'query' 243 | ? T 244 | : never 245 | : never; 246 | -------------------------------------------------------------------------------- /src/mutation.test.ts: -------------------------------------------------------------------------------- 1 | import { DefaultError, QueryClient } from '@tanstack/query-core'; 2 | import { reaction } from 'mobx'; 3 | import { describe, expect, it, vi } from 'vitest'; 4 | 5 | import { Mutation } from './mutation'; 6 | import { MutationConfig } from './mutation.types'; 7 | 8 | class MutationMock< 9 | TData = unknown, 10 | TVariables = void, 11 | TError = DefaultError, 12 | TContext = unknown, 13 | > extends Mutation { 14 | spies = { 15 | mutationFn: null as unknown as ReturnType, 16 | dispose: vi.fn(), 17 | reset: vi.fn(), 18 | onDone: vi.fn(), 19 | onError: vi.fn(), 20 | }; 21 | 22 | constructor( 23 | options: Omit< 24 | MutationConfig, 25 | 'queryClient' 26 | >, 27 | ) { 28 | const mutationFn = vi.fn((...args: any[]) => { 29 | // @ts-ignore 30 | const result = options.mutationFn?.(...args); 31 | return result; 32 | }); 33 | super({ 34 | ...options, 35 | queryClient: new QueryClient({}), 36 | // @ts-ignore 37 | mutationFn, 38 | }); 39 | 40 | this.spies.mutationFn = mutationFn as any; 41 | 42 | this.onDone(this.spies.onDone); 43 | this.onError(this.spies.onError); 44 | } 45 | 46 | reset(): void { 47 | const result = super.reset(); 48 | this.spies.reset.mockReturnValue(result)(); 49 | return result; 50 | } 51 | 52 | dispose(): void { 53 | const result = super.dispose(); 54 | this.spies.dispose.mockReturnValue(result)(); 55 | return result; 56 | } 57 | } 58 | 59 | describe('Mutation', () => { 60 | it('should call mutationFn', async () => { 61 | const mutation = new MutationMock({ 62 | mutationKey: ['test'], 63 | mutationFn: async () => {}, 64 | }); 65 | 66 | await mutation.mutate(); 67 | 68 | expect(mutation.spies.mutationFn).toHaveBeenCalled(); 69 | }); 70 | 71 | it('should have result with finished data', async () => { 72 | const mutation = new MutationMock({ 73 | mutationKey: ['test'], 74 | mutationFn: async () => { 75 | return 'OK'; 76 | }, 77 | }); 78 | 79 | await mutation.mutate(); 80 | 81 | expect(mutation.result).toStrictEqual({ 82 | ...mutation.result, 83 | context: undefined, 84 | data: 'OK', 85 | error: null, 86 | failureCount: 0, 87 | failureReason: null, 88 | isError: false, 89 | isIdle: false, 90 | isPaused: false, 91 | isPending: false, 92 | isSuccess: true, 93 | status: 'success', 94 | variables: undefined, 95 | }); 96 | }); 97 | 98 | it('should change mutation status (success)', async () => { 99 | const mutation = new MutationMock({ 100 | mutationKey: ['test'], 101 | mutationFn: async () => { 102 | return 'OK'; 103 | }, 104 | }); 105 | 106 | const statuses: (typeof mutation)['result']['status'][] = []; 107 | 108 | reaction( 109 | () => mutation.result.status, 110 | (status) => { 111 | statuses.push(status); 112 | }, 113 | { 114 | fireImmediately: true, 115 | }, 116 | ); 117 | 118 | await mutation.mutate(); 119 | 120 | expect(statuses).toStrictEqual(['idle', 'pending', 'success']); 121 | }); 122 | 123 | it('should change mutation status (failure)', async () => { 124 | const mutation = new MutationMock({ 125 | mutationKey: ['test'], 126 | mutationFn: async () => { 127 | throw new Error('BAD'); 128 | }, 129 | }); 130 | 131 | const statuses: (typeof mutation)['result']['status'][] = []; 132 | 133 | reaction( 134 | () => mutation.result.status, 135 | (status) => { 136 | statuses.push(status); 137 | }, 138 | { 139 | fireImmediately: true, 140 | }, 141 | ); 142 | 143 | try { 144 | await mutation.mutate(); 145 | // eslint-disable-next-line no-empty 146 | } catch {} 147 | 148 | expect(statuses).toStrictEqual(['idle', 'pending', 'error']); 149 | }); 150 | 151 | it('should throw exception', async () => { 152 | const mutation = new MutationMock({ 153 | mutationKey: ['test'], 154 | mutationFn: async () => { 155 | throw new Error('BAD'); 156 | }, 157 | }); 158 | 159 | expect(async () => { 160 | await mutation.mutate(); 161 | }).rejects.toThrowError('BAD'); 162 | }); 163 | 164 | it('should be able to do abort using second argument in mutationFn', async () => { 165 | vi.useFakeTimers(); 166 | 167 | const fakeFetch = (data: any = 'OK', signal?: AbortSignal) => { 168 | return new Promise((resolve, reject) => { 169 | setTimeout(() => { 170 | // eslint-disable-next-line @typescript-eslint/no-use-before-define 171 | mutation.destroy(); 172 | }, 200); 173 | const timer = setTimeout(() => resolve(data), 1000); 174 | signal?.addEventListener('abort', () => { 175 | clearTimeout(timer); 176 | reject(signal.reason); 177 | }); 178 | vi.runAllTimers(); 179 | }); 180 | }; 181 | 182 | const mutation = new MutationMock({ 183 | mutationKey: ['test'], 184 | mutationFn: async (_, { signal }) => { 185 | await fakeFetch('OK', signal); 186 | }, 187 | }); 188 | try { 189 | await mutation.mutate(); 190 | await vi.runAllTimersAsync(); 191 | expect(false).toBe('abort should happen'); 192 | } catch (error) { 193 | if (error instanceof DOMException) { 194 | expect(error.message).toBe('The operation was aborted.'); 195 | } else { 196 | expect(false).toBe('error should be DOMException'); 197 | } 198 | } 199 | 200 | vi.useRealTimers(); 201 | }); 202 | }); 203 | -------------------------------------------------------------------------------- /src/mutation.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DefaultError, 3 | MutationObserver, 4 | MutationObserverOptions, 5 | MutationObserverResult, 6 | MutationOptions, 7 | } from '@tanstack/query-core'; 8 | import { LinkedAbortController } from 'linked-abort-controller'; 9 | import { action, makeObservable, observable, reaction } from 'mobx'; 10 | 11 | import { 12 | MutationConfig, 13 | MutationInvalidateQueriesOptions, 14 | } from './mutation.types'; 15 | import { AnyQueryClient, QueryClientHooks } from './query-client.types'; 16 | 17 | export class Mutation< 18 | TData = unknown, 19 | TVariables = void, 20 | TError = DefaultError, 21 | TContext = unknown, 22 | > implements Disposable 23 | { 24 | protected abortController: AbortController; 25 | protected queryClient: AnyQueryClient; 26 | 27 | mutationOptions: MutationObserverOptions; 28 | mutationObserver: MutationObserver; 29 | 30 | result: MutationObserverResult; 31 | 32 | private _observerSubscription?: VoidFunction; 33 | private hooks?: QueryClientHooks; 34 | 35 | constructor( 36 | protected config: MutationConfig, 37 | ) { 38 | const { 39 | queryClient, 40 | invalidateQueries, 41 | invalidateByKey: providedInvalidateByKey, 42 | mutationFn, 43 | ...restOptions 44 | } = config; 45 | this.abortController = new LinkedAbortController(config.abortSignal); 46 | this.queryClient = queryClient; 47 | this.result = undefined as any; 48 | 49 | observable.deep(this, 'result'); 50 | action.bound(this, 'updateResult'); 51 | 52 | makeObservable(this); 53 | 54 | const invalidateByKey = 55 | providedInvalidateByKey ?? 56 | ('mutationFeatures' in queryClient 57 | ? queryClient.mutationFeatures.invalidateByKey 58 | : null); 59 | 60 | this.mutationOptions = this.queryClient.defaultMutationOptions(restOptions); 61 | this.hooks = 62 | 'hooks' in this.queryClient ? this.queryClient.hooks : undefined; 63 | 64 | this.mutationObserver = new MutationObserver< 65 | TData, 66 | TError, 67 | TVariables, 68 | TContext 69 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 70 | // @ts-expect-error 71 | >(queryClient, { 72 | ...this.mutationOptions, 73 | mutationFn: (variables) => 74 | mutationFn?.(variables, { signal: this.abortController.signal }), 75 | }); 76 | 77 | this.updateResult(this.mutationObserver.getCurrentResult()); 78 | 79 | this._observerSubscription = this.mutationObserver.subscribe( 80 | this.updateResult, 81 | ); 82 | 83 | this.abortController.signal.addEventListener('abort', () => { 84 | this._observerSubscription?.(); 85 | 86 | if ( 87 | config.resetOnDispose || 88 | ('mutationFeatures' in queryClient && 89 | queryClient.mutationFeatures.resetOnDispose) 90 | ) { 91 | this.reset(); 92 | } 93 | }); 94 | 95 | if (invalidateQueries) { 96 | this.onDone((data, payload) => { 97 | let invalidateOptions: MutationInvalidateQueriesOptions; 98 | 99 | if (typeof invalidateQueries === 'function') { 100 | invalidateOptions = invalidateQueries(data, payload); 101 | } else { 102 | invalidateOptions = invalidateQueries; 103 | } 104 | 105 | if (invalidateOptions.queryKeys?.length) { 106 | invalidateOptions.queryKeys?.forEach((queryKey) => { 107 | this.queryClient.invalidateQueries({ 108 | ...invalidateOptions, 109 | queryKey, 110 | }); 111 | }); 112 | } else { 113 | this.queryClient.invalidateQueries(invalidateOptions); 114 | } 115 | }); 116 | } 117 | 118 | if (invalidateByKey && this.mutationOptions.mutationKey) { 119 | this.onDone(() => { 120 | this.queryClient.invalidateQueries({ 121 | ...(invalidateByKey === true ? {} : invalidateByKey), 122 | queryKey: this.mutationOptions.mutationKey, 123 | }); 124 | }); 125 | } 126 | 127 | config.onInit?.(this); 128 | this.hooks?.onMutationInit?.(this); 129 | } 130 | 131 | async mutate( 132 | variables: TVariables, 133 | options?: MutationOptions, 134 | ) { 135 | await this.mutationObserver.mutate(variables, options); 136 | return this.result; 137 | } 138 | 139 | /** 140 | * Modify this result so it matches the tanstack query result. 141 | */ 142 | private updateResult( 143 | nextResult: MutationObserverResult, 144 | ) { 145 | this.result = nextResult || {}; 146 | } 147 | 148 | onSettled( 149 | onSettledCallback: ( 150 | data: TData | undefined, 151 | error: TError | null, 152 | variables: TVariables, 153 | context: TContext | undefined, 154 | ) => void, 155 | ): void { 156 | reaction( 157 | () => { 158 | const { isSuccess, isError, isPending } = this.result; 159 | return !isPending && (isSuccess || isError); 160 | }, 161 | (isSettled) => { 162 | if (isSettled) { 163 | onSettledCallback( 164 | this.result.data, 165 | this.result.error, 166 | this.result.variables!, 167 | this.result.context, 168 | ); 169 | } 170 | }, 171 | { 172 | signal: this.abortController.signal, 173 | }, 174 | ); 175 | } 176 | 177 | onDone( 178 | onDoneCallback: ( 179 | data: TData, 180 | payload: TVariables, 181 | context: TContext | undefined, 182 | ) => void, 183 | ): void { 184 | reaction( 185 | () => { 186 | const { error, isSuccess } = this.result; 187 | return isSuccess && !error; 188 | }, 189 | (isDone) => { 190 | if (isDone) { 191 | onDoneCallback( 192 | this.result.data!, 193 | this.result.variables!, 194 | this.result.context, 195 | ); 196 | } 197 | }, 198 | { 199 | signal: this.abortController.signal, 200 | }, 201 | ); 202 | } 203 | 204 | onError( 205 | onErrorCallback: ( 206 | error: TError, 207 | payload: TVariables, 208 | context: TContext | undefined, 209 | ) => void, 210 | ): void { 211 | reaction( 212 | () => this.result.error, 213 | (error) => { 214 | if (error) { 215 | onErrorCallback(error, this.result.variables!, this.result.context); 216 | } 217 | }, 218 | { 219 | signal: this.abortController.signal, 220 | }, 221 | ); 222 | } 223 | 224 | reset() { 225 | this.mutationObserver.reset(); 226 | } 227 | 228 | protected handleAbort = () => { 229 | this._observerSubscription?.(); 230 | 231 | let isNeedToReset = 232 | this.config.resetOnDestroy || this.config.resetOnDispose; 233 | 234 | if ('mutationFeatures' in this.queryClient && !isNeedToReset) { 235 | isNeedToReset = 236 | this.queryClient.mutationFeatures.resetOnDestroy || 237 | this.queryClient.mutationFeatures.resetOnDispose; 238 | } 239 | 240 | if (isNeedToReset) { 241 | this.reset(); 242 | } 243 | 244 | delete this._observerSubscription; 245 | this.hooks?.onMutationDestroy?.(this); 246 | }; 247 | 248 | destroy() { 249 | this.abortController.abort(); 250 | } 251 | 252 | /** 253 | * @deprecated use `destroy`. This method will be removed in next major release 254 | */ 255 | dispose() { 256 | this.destroy(); 257 | } 258 | 259 | [Symbol.dispose](): void { 260 | this.destroy(); 261 | } 262 | 263 | // Firefox fix (Symbol.dispose is undefined in FF) 264 | [Symbol.for('Symbol.dispose')](): void { 265 | this.destroy(); 266 | } 267 | } 268 | 269 | /** 270 | * @remarks ⚠️ use `Mutation`. This export will be removed in next major release 271 | */ 272 | export const MobxMutation = Mutation; 273 | -------------------------------------------------------------------------------- /src/mutation.types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DefaultError, 3 | InvalidateQueryFilters, 4 | MutationObserverOptions, 5 | } from '@tanstack/query-core'; 6 | 7 | import { Mutation } from './mutation'; 8 | import { AnyQueryClient } from './query-client.types'; 9 | 10 | export interface MutationFeatures { 11 | /** 12 | * Invalidate queries by mutation key. 13 | * 14 | * - when `true`, invalidate all queries by mutation key (not exact) 15 | * - when `object`, invalidate all queries by mutation key with this additional filters 16 | */ 17 | invalidateByKey?: 18 | | boolean 19 | | Omit; 20 | /** 21 | * Reset mutation when dispose is called 22 | * 23 | * @deprecated Please use 'resetOnDestroy' 24 | */ 25 | resetOnDispose?: boolean; 26 | 27 | /** 28 | * Reset mutation when destroy or abort signal is called 29 | */ 30 | resetOnDestroy?: boolean; 31 | } 32 | 33 | /** 34 | * @remarks ⚠️ use `MutationFeatures`. This type will be removed in next major release 35 | */ 36 | export type MobxMutationFeatures = MutationFeatures; 37 | 38 | export interface MutationInvalidateQueriesOptions 39 | extends Omit { 40 | queryKey?: InvalidateQueryFilters['queryKey']; 41 | queryKeys?: InvalidateQueryFilters['queryKey'][]; 42 | } 43 | 44 | /** 45 | * @remarks ⚠️ use `MutationInvalidateQueriesOptions`. This type will be removed in next major release 46 | */ 47 | export type MobxMutationInvalidateQueriesOptions = 48 | MutationInvalidateQueriesOptions; 49 | 50 | export type MutationFn = ( 51 | variables: TVariables, 52 | options: { signal: AbortSignal }, 53 | ) => Promise; 54 | 55 | /** 56 | * @remarks ⚠️ use `MutationFn`. This type will be removed in next major release 57 | */ 58 | export type MobxMutationFunction< 59 | TData = unknown, 60 | TVariables = unknown, 61 | > = MutationFn; 62 | 63 | export interface MutationConfig< 64 | TData = unknown, 65 | TVariables = void, 66 | TError = DefaultError, 67 | TContext = unknown, 68 | > extends Omit< 69 | MutationObserverOptions, 70 | '_defaulted' | 'mutationFn' 71 | >, 72 | MutationFeatures { 73 | mutationFn?: MutationFn; 74 | queryClient: AnyQueryClient; 75 | abortSignal?: AbortSignal; 76 | invalidateQueries?: 77 | | MutationInvalidateQueriesOptions 78 | | ((data: TData, payload: TVariables) => MutationInvalidateQueriesOptions); 79 | onInit?: (mutation: Mutation) => void; 80 | } 81 | 82 | /** 83 | * @remarks ⚠️ use `MutationConfig`. This type will be removed in next major release 84 | */ 85 | export type MobxMutationConfig< 86 | TData = unknown, 87 | TVariables = void, 88 | TError = DefaultError, 89 | TContext = unknown, 90 | > = MutationConfig; 91 | 92 | export type MutationConfigFromFn< 93 | T extends (...args: any[]) => any, 94 | TError = DefaultError, 95 | TContext = unknown, 96 | > = MutationConfig< 97 | ReturnType extends Promise ? TData : ReturnType, 98 | Parameters[0], 99 | TError, 100 | TContext 101 | >; 102 | 103 | /** 104 | * @remarks ⚠️ use `MutationConfigFromFn`. This type will be removed in next major release 105 | */ 106 | export type MobxMutationConfigFromFn< 107 | T extends (...args: any[]) => any, 108 | TError = DefaultError, 109 | TContext = unknown, 110 | > = MutationConfigFromFn; 111 | 112 | export type InferMutation< 113 | T extends MutationConfig | Mutation, 114 | TInferValue extends 115 | | 'data' 116 | | 'variables' 117 | | 'error' 118 | | 'context' 119 | | 'mutation' 120 | | 'config', 121 | > = 122 | T extends MutationConfig< 123 | infer TData, 124 | infer TVariables, 125 | infer TError, 126 | infer TContext 127 | > 128 | ? TInferValue extends 'config' 129 | ? T 130 | : TInferValue extends 'data' 131 | ? TData 132 | : TInferValue extends 'variables' 133 | ? TVariables 134 | : TInferValue extends 'error' 135 | ? TError 136 | : TInferValue extends 'context' 137 | ? TContext 138 | : TInferValue extends 'mutation' 139 | ? Mutation 140 | : never 141 | : T extends Mutation< 142 | infer TData, 143 | infer TVariables, 144 | infer TError, 145 | infer TContext 146 | > 147 | ? TInferValue extends 'config' 148 | ? MutationConfig 149 | : TInferValue extends 'data' 150 | ? TData 151 | : TInferValue extends 'variables' 152 | ? TVariables 153 | : TInferValue extends 'error' 154 | ? TError 155 | : TInferValue extends 'context' 156 | ? TContext 157 | : TInferValue extends 'mutation' 158 | ? T 159 | : never 160 | : never; 161 | -------------------------------------------------------------------------------- /src/preset/configs/default-query-client-config.ts: -------------------------------------------------------------------------------- 1 | import { hashKey } from '@tanstack/query-core'; 2 | 3 | import { QueryClientConfig } from '../../query-client.types'; 4 | 5 | export const defaultQueryClientConfig = { 6 | defaultOptions: { 7 | queries: { 8 | throwOnError: true, 9 | queryKeyHashFn: hashKey, 10 | refetchOnWindowFocus: 'always', 11 | refetchOnReconnect: 'always', 12 | structuralSharing: false, // see https://github.com/js2me/mobx-tanstack-query/issues/7 13 | staleTime: 5 * 60 * 1000, 14 | retry: (failureCount, error) => { 15 | if (error instanceof Response && error.status >= 500) { 16 | return failureCount < 3; 17 | } 18 | return false; 19 | }, 20 | }, 21 | mutations: { 22 | throwOnError: true, 23 | }, 24 | }, 25 | } satisfies QueryClientConfig; 26 | -------------------------------------------------------------------------------- /src/preset/configs/index.ts: -------------------------------------------------------------------------------- 1 | export * from './default-query-client-config'; 2 | -------------------------------------------------------------------------------- /src/preset/create-infinite-query.ts: -------------------------------------------------------------------------------- 1 | import { DefaultError, QueryClient, QueryKey } from '@tanstack/query-core'; 2 | 3 | import { InfiniteQuery } from '../inifinite-query'; 4 | import { InfiniteQueryConfig } from '../inifinite-query.types'; 5 | 6 | import { queryClient } from './query-client'; 7 | 8 | export type CreateInfiniteQueryParams< 9 | TData, 10 | TError = DefaultError, 11 | TQueryKey extends QueryKey = any, 12 | TPageParam = unknown, 13 | > = Omit< 14 | InfiniteQueryConfig, 15 | 'queryClient' | 'queryFn' 16 | > & { 17 | queryClient?: QueryClient; 18 | }; 19 | 20 | export const createInfiniteQuery = < 21 | TData, 22 | TError = DefaultError, 23 | TQueryKey extends QueryKey = any, 24 | TPageParam = unknown, 25 | >( 26 | fn: InfiniteQueryConfig['queryFn'], 27 | params?: CreateInfiniteQueryParams, 28 | ) => { 29 | return new InfiniteQuery({ 30 | ...params, 31 | queryClient: params?.queryClient ?? queryClient, 32 | queryFn: fn, 33 | onInit: (query) => { 34 | queryClient.mount(); 35 | params?.onInit?.(query); 36 | }, 37 | }); 38 | }; 39 | -------------------------------------------------------------------------------- /src/preset/create-mutation.ts: -------------------------------------------------------------------------------- 1 | import { DefaultError, QueryClient } from '@tanstack/query-core'; 2 | 3 | import { Mutation } from '../mutation'; 4 | import { MutationConfig } from '../mutation.types'; 5 | 6 | import { queryClient } from './query-client'; 7 | 8 | export type CreateMutationParams< 9 | TData = unknown, 10 | TVariables = void, 11 | TError = DefaultError, 12 | TContext = unknown, 13 | > = Omit< 14 | MutationConfig, 15 | 'queryClient' | 'mutationFn' 16 | > & { 17 | queryClient?: QueryClient; 18 | }; 19 | 20 | export const createMutation = < 21 | TData = unknown, 22 | TVariables = void, 23 | TError = DefaultError, 24 | TContext = unknown, 25 | >( 26 | fn: MutationConfig['mutationFn'], 27 | params?: CreateMutationParams, 28 | ) => { 29 | return new Mutation({ 30 | ...params, 31 | queryClient: params?.queryClient ?? queryClient, 32 | mutationFn: fn, 33 | onInit: (mutation) => { 34 | queryClient.mount(); 35 | params?.onInit?.(mutation); 36 | }, 37 | }); 38 | }; 39 | -------------------------------------------------------------------------------- /src/preset/create-query.ts: -------------------------------------------------------------------------------- 1 | import { DefaultError, QueryClient, QueryKey } from '@tanstack/query-core'; 2 | 3 | import { Query } from '../query'; 4 | import { AnyQueryClient } from '../query-client.types'; 5 | import { QueryOptionsParams } from '../query-options'; 6 | import { QueryConfig, QueryFn } from '../query.types'; 7 | 8 | import { queryClient } from './query-client'; 9 | 10 | export type CreateQueryParams< 11 | TQueryFnData = unknown, 12 | TError = DefaultError, 13 | TData = TQueryFnData, 14 | TQueryData = TQueryFnData, 15 | TQueryKey extends QueryKey = QueryKey, 16 | > = Omit< 17 | QueryConfig, 18 | 'queryClient' | 'queryFn' 19 | > & { 20 | queryClient?: QueryClient; 21 | }; 22 | 23 | export function createQuery< 24 | TQueryFnData = unknown, 25 | TError = DefaultError, 26 | TData = TQueryFnData, 27 | TQueryData = TQueryFnData, 28 | TQueryKey extends QueryKey = QueryKey, 29 | >( 30 | options: QueryOptionsParams< 31 | TQueryFnData, 32 | TError, 33 | TData, 34 | TQueryData, 35 | TQueryKey 36 | >, 37 | ): Query; 38 | 39 | export function createQuery< 40 | TQueryFnData = unknown, 41 | TError = DefaultError, 42 | TData = TQueryFnData, 43 | TQueryData = TQueryFnData, 44 | TQueryKey extends QueryKey = QueryKey, 45 | >( 46 | queryFn: QueryFn, 47 | params?: CreateQueryParams< 48 | TQueryFnData, 49 | TError, 50 | TData, 51 | TQueryData, 52 | TQueryKey 53 | >, 54 | ): Query; 55 | 56 | export function createQuery< 57 | TQueryFnData = unknown, 58 | TError = DefaultError, 59 | TData = TQueryFnData, 60 | TQueryData = TQueryFnData, 61 | TQueryKey extends QueryKey = QueryKey, 62 | >( 63 | queryClient: AnyQueryClient, 64 | options: () => QueryOptionsParams< 65 | TQueryFnData, 66 | TError, 67 | TData, 68 | TQueryData, 69 | TQueryKey 70 | >, 71 | ): Query; 72 | 73 | export function createQuery(...args: [any, any?]) { 74 | if (typeof args[0] === 'function') { 75 | return new Query({ 76 | ...args[1], 77 | queryClient: args[1]?.queryClient ?? queryClient, 78 | queryFn: args[0], 79 | onInit: (query) => { 80 | queryClient.mount(); 81 | args[0]?.onInit?.(query); 82 | }, 83 | }); 84 | } else if (args.length === 2) { 85 | return new Query(args[0], args[1]()); 86 | } 87 | 88 | return new Query(queryClient, args[0]); 89 | } 90 | -------------------------------------------------------------------------------- /src/preset/index.ts: -------------------------------------------------------------------------------- 1 | export * from './query-client'; 2 | export * from './create-query'; 3 | export * from './create-mutation'; 4 | export * from './create-infinite-query'; 5 | -------------------------------------------------------------------------------- /src/preset/query-client.ts: -------------------------------------------------------------------------------- 1 | import { QueryClient } from '../query-client'; 2 | 3 | import { defaultQueryClientConfig } from './configs'; 4 | 5 | export const queryClient = new QueryClient(defaultQueryClientConfig); 6 | -------------------------------------------------------------------------------- /src/query-client.ts: -------------------------------------------------------------------------------- 1 | import { QueryClient as QueryClientCore } from '@tanstack/query-core'; 2 | 3 | import { MutationFeatures } from './mutation.types'; 4 | import { 5 | IQueryClientCore, 6 | QueryClientConfig, 7 | QueryClientHooks, 8 | } from './query-client.types'; 9 | import { QueryFeatures } from './query.types'; 10 | 11 | export class QueryClient extends QueryClientCore implements IQueryClientCore { 12 | hooks?: QueryClientHooks; 13 | 14 | constructor(private config: QueryClientConfig = {}) { 15 | super(config); 16 | this.hooks = config.hooks; 17 | } 18 | 19 | setDefaultOptions( 20 | options: Exclude, 21 | ): void { 22 | super.setDefaultOptions(options); 23 | this.config.defaultOptions = options; 24 | } 25 | 26 | getDefaultOptions(): Exclude { 27 | return super.getDefaultOptions(); 28 | } 29 | 30 | get queryFeatures(): QueryFeatures { 31 | return this.getDefaultOptions().queries ?? {}; 32 | } 33 | 34 | get mutationFeatures(): MutationFeatures { 35 | return this.getDefaultOptions().mutations ?? {}; 36 | } 37 | } 38 | 39 | /** 40 | * @remarks ⚠️ use `QueryClient`. This export will be removed in next major release 41 | */ 42 | export const MobxQueryClient = QueryClient; 43 | -------------------------------------------------------------------------------- /src/query-client.types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DefaultError, 3 | DefaultOptions as DefaultCoreOptions, 4 | QueryClient as QueryClientCore, 5 | QueryClientConfig as QueryClientCoreConfig, 6 | } from '@tanstack/query-core'; 7 | 8 | import { InfiniteQuery } from './inifinite-query'; 9 | import { Mutation } from './mutation'; 10 | import { MutationFeatures } from './mutation.types'; 11 | import type { QueryClient } from './query-client'; 12 | import { AnyQuery, QueryFeatures } from './query.types'; 13 | 14 | export type IQueryClientCore = { 15 | [K in keyof QueryClientCore]: QueryClientCore[K]; 16 | }; 17 | 18 | /** 19 | * @remarks ⚠️ use `IQueryClientCore`. This type will be removed in next major release 20 | */ 21 | export type IQueryClient = IQueryClientCore; 22 | 23 | /** 24 | * @deprecated renamed to `IQueryClient`. Will be removed in next major release. 25 | */ 26 | export type QueryClientInterface = IQueryClientCore; 27 | 28 | export type AnyQueryClient = QueryClient | IQueryClientCore; 29 | 30 | export interface DefaultOptions 31 | extends Omit, 'queries' | 'mutations'> { 32 | queries?: DefaultCoreOptions['queries'] & QueryFeatures; 33 | mutations?: DefaultCoreOptions['mutations'] & MutationFeatures; 34 | } 35 | 36 | /** 37 | * @remarks ⚠️ use `DefaultOptions`. This type will be removed in next major release 38 | */ 39 | export type MobxDefaultOptions = DefaultOptions; 40 | 41 | export interface QueryClientHooks { 42 | onQueryInit?: (query: AnyQuery) => void; 43 | onInfiniteQueryInit?: (query: InfiniteQuery) => void; 44 | onMutationInit?: (query: Mutation) => void; 45 | onQueryDestroy?: (query: AnyQuery) => void; 46 | onInfiniteQueryDestroy?: (query: InfiniteQuery) => void; 47 | onMutationDestroy?: (query: Mutation) => void; 48 | } 49 | 50 | /** 51 | * @remarks ⚠️ use `QueryClientHooks`. This type will be removed in next major release 52 | */ 53 | export type MobxQueryClientHooks = QueryClientHooks; 54 | 55 | export interface QueryClientConfig 56 | extends Omit { 57 | defaultOptions?: DefaultOptions; 58 | hooks?: QueryClientHooks; 59 | } 60 | 61 | /** 62 | * @remarks ⚠️ use `QueryClientConfig`. This type will be removed in next major release 63 | */ 64 | export type MobxQueryClientConfig = QueryClientConfig; 65 | -------------------------------------------------------------------------------- /src/query-options.ts: -------------------------------------------------------------------------------- 1 | import { DefaultError, QueryKey } from '@tanstack/query-core'; 2 | 3 | import { QueryConfig } from './query.types'; 4 | 5 | export interface QueryOptionsParams< 6 | TQueryFnData = unknown, 7 | TError = DefaultError, 8 | TData = TQueryFnData, 9 | TQueryData = TQueryFnData, 10 | TQueryKey extends QueryKey = QueryKey, 11 | > extends Omit< 12 | QueryConfig, 13 | 'queryClient' | 'options' 14 | > {} 15 | 16 | export function queryOptions< 17 | TQueryFnData = unknown, 18 | TError = DefaultError, 19 | TData = TQueryFnData, 20 | TQueryData = TQueryFnData, 21 | TQueryKey extends QueryKey = QueryKey, 22 | >( 23 | options: QueryOptionsParams< 24 | TQueryFnData, 25 | TError, 26 | TData, 27 | TQueryData, 28 | TQueryKey 29 | >, 30 | ): QueryOptionsParams; 31 | 32 | export function queryOptions(options: unknown) { 33 | return options; 34 | } 35 | -------------------------------------------------------------------------------- /src/query.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-async-promise-executor */ 2 | import { 3 | DefaultError, 4 | QueryClient, 5 | QueryKey, 6 | QueryObserverResult, 7 | RefetchOptions, 8 | SetDataOptions, 9 | Updater, 10 | hashKey, 11 | } from '@tanstack/query-core'; 12 | import { LinkedAbortController } from 'linked-abort-controller'; 13 | import { 14 | computed, 15 | makeObservable, 16 | observable, 17 | reaction, 18 | runInAction, 19 | when, 20 | } from 'mobx'; 21 | import { 22 | afterEach, 23 | describe, 24 | expect, 25 | expectTypeOf, 26 | it, 27 | test, 28 | vi, 29 | } from 'vitest'; 30 | import { waitAsync } from 'yummies/async'; 31 | 32 | import { createQuery } from './preset'; 33 | import { Query } from './query'; 34 | import { 35 | QueryConfig, 36 | QueryDynamicOptions, 37 | QueryInvalidateParams, 38 | QueryUpdateOptions, 39 | } from './query.types'; 40 | 41 | class QueryMock< 42 | TQueryFnData = unknown, 43 | TError = DefaultError, 44 | TData = TQueryFnData, 45 | TQueryData = TQueryFnData, 46 | TQueryKey extends QueryKey = QueryKey, 47 | > extends Query { 48 | spies = { 49 | queryFn: null as unknown as ReturnType, 50 | setData: vi.fn(), 51 | update: vi.fn(), 52 | dispose: vi.fn(), 53 | refetch: vi.fn(), 54 | invalidate: vi.fn(), 55 | onDone: vi.fn(), 56 | onError: vi.fn(), 57 | }; 58 | 59 | constructor( 60 | options: Omit< 61 | QueryConfig, 62 | 'queryClient' 63 | >, 64 | queryClient?: QueryClient, 65 | ) { 66 | super({ 67 | ...options, 68 | queryClient: queryClient ?? new QueryClient({}), 69 | // @ts-ignore 70 | queryFn: vi.fn((...args: any[]) => { 71 | // @ts-ignore 72 | const result = options.queryFn?.(...args); 73 | return result; 74 | }), 75 | }); 76 | 77 | this.spies.queryFn = this.options.queryFn as any; 78 | 79 | this.onDone(this.spies.onDone); 80 | this.onError(this.spies.onError); 81 | } 82 | 83 | get _rawResult() { 84 | return this._result; 85 | } 86 | 87 | refetch( 88 | options?: RefetchOptions | undefined, 89 | ): Promise> { 90 | this.spies.refetch(options); 91 | return super.refetch(options); 92 | } 93 | 94 | invalidate(params?: QueryInvalidateParams | undefined): Promise { 95 | this.spies.invalidate(params); 96 | return super.invalidate(); 97 | } 98 | 99 | update( 100 | options: 101 | | QueryUpdateOptions 102 | | QueryDynamicOptions, 103 | ): void { 104 | const result = super.update(options); 105 | this.spies.update.mockReturnValue(result)(options); 106 | return result; 107 | } 108 | 109 | setData( 110 | updater: Updater< 111 | NoInfer | undefined, 112 | NoInfer | undefined 113 | >, 114 | options?: SetDataOptions, 115 | ): TQueryFnData | undefined { 116 | const result = super.setData(updater, options); 117 | this.spies.setData.mockReturnValue(result)(updater, options); 118 | return result; 119 | } 120 | 121 | dispose(): void { 122 | const result = super.dispose(); 123 | this.spies.dispose.mockReturnValue(result)(); 124 | return result; 125 | } 126 | } 127 | 128 | class HttpResponse extends Response { 129 | data: TData | null; 130 | error: TError | null; 131 | 132 | constructor(data?: TData, error?: TError, init?: ResponseInit) { 133 | super(null, init); 134 | this.data = data ?? null; 135 | this.error = error ?? null; 136 | } 137 | } 138 | 139 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 140 | const createMockFetch = () => { 141 | return vi.fn( 142 | (cfg: { 143 | signal?: AbortSignal; 144 | waitAsync?: number; 145 | willReturn: HttpResponse; 146 | }) => { 147 | return new Promise>((resolve, reject) => { 148 | // Проверяем, если сигнал уже был прерван 149 | if (cfg.signal?.aborted) { 150 | reject(new DOMException('Aborted', 'AbortError')); 151 | return; 152 | } 153 | 154 | const timeout = setTimeout(() => { 155 | if (cfg.willReturn.error) { 156 | reject(cfg.willReturn); 157 | } else { 158 | resolve(cfg.willReturn); 159 | } 160 | }, cfg?.waitAsync ?? 5); 161 | 162 | // Добавляем обработчик прерывания 163 | if (cfg.signal) { 164 | const abortHandler = () => { 165 | clearTimeout(timeout); 166 | reject(new DOMException('Aborted', 'AbortError')); 167 | cfg.signal?.removeEventListener('abort', abortHandler); 168 | }; 169 | 170 | cfg.signal.addEventListener('abort', abortHandler); 171 | } 172 | }); 173 | }, 174 | ); 175 | }; 176 | 177 | describe('Query', () => { 178 | it('should be fetched on start', async () => { 179 | const query = new QueryMock({ 180 | queryKey: ['test'], 181 | queryFn: () => {}, 182 | }); 183 | 184 | await when(() => !query._rawResult.isLoading); 185 | 186 | expect(query.result.isFetched).toBeTruthy(); 187 | 188 | query.dispose(); 189 | }); 190 | 191 | it('"result" field to be defined', async () => { 192 | const query = new QueryMock({ 193 | queryKey: ['test'], 194 | queryFn: () => {}, 195 | }); 196 | 197 | await when(() => !query._rawResult.isLoading); 198 | 199 | expect(query.result).toBeDefined(); 200 | 201 | query.dispose(); 202 | }); 203 | 204 | it('"result" field should be reactive', async () => { 205 | let counter = 0; 206 | const query = new QueryMock({ 207 | queryKey: ['test'], 208 | queryFn: () => ++counter, 209 | }); 210 | const reactionSpy = vi.fn(); 211 | 212 | const dispose = reaction( 213 | () => query.result, 214 | (result) => reactionSpy(result), 215 | ); 216 | 217 | await when(() => !query._rawResult.isLoading); 218 | 219 | expect(reactionSpy).toBeCalled(); 220 | expect(reactionSpy).toBeCalledWith({ ...query.result }); 221 | 222 | dispose(); 223 | query.dispose(); 224 | }); 225 | 226 | describe('"queryKey" reactive parameter', () => { 227 | it('should rerun queryFn after queryKey change', async () => { 228 | const boxCounter = observable.box(0); 229 | const query = new QueryMock({ 230 | queryFn: ({ queryKey }) => { 231 | return queryKey[1]; 232 | }, 233 | queryKey: () => ['test', boxCounter.get()] as const, 234 | }); 235 | 236 | await when(() => !query._rawResult.isLoading); 237 | 238 | runInAction(() => { 239 | boxCounter.set(1); 240 | }); 241 | 242 | await when(() => !query._rawResult.isLoading); 243 | 244 | expect(query.spies.queryFn).toBeCalledTimes(2); 245 | expect(query.spies.queryFn).nthReturnedWith(1, 0); 246 | expect(query.spies.queryFn).nthReturnedWith(2, 1); 247 | 248 | query.dispose(); 249 | }); 250 | 251 | it('should rerun queryFn after queryKey change', async () => { 252 | const boxEnabled = observable.box(false); 253 | const query = new QueryMock({ 254 | queryFn: () => 10, 255 | queryKey: () => ['test', boxEnabled.get()] as const, 256 | enabled: ({ queryKey }) => queryKey[1], 257 | }); 258 | 259 | runInAction(() => { 260 | boxEnabled.set(true); 261 | }); 262 | 263 | await when(() => !query._rawResult.isLoading); 264 | 265 | expect(query.spies.queryFn).toBeCalledTimes(1); 266 | expect(query.spies.queryFn).nthReturnedWith(1, 10); 267 | 268 | query.dispose(); 269 | }); 270 | }); 271 | 272 | describe('"enabled" reactive parameter', () => { 273 | it('should be DISABLED from default query options (from query client)', async () => { 274 | const queryClient = new QueryClient({ 275 | defaultOptions: { 276 | queries: { 277 | enabled: false, 278 | }, 279 | }, 280 | }); 281 | const query = new QueryMock( 282 | { 283 | queryKey: ['test', 0 as number] as const, 284 | queryFn: () => 100, 285 | }, 286 | queryClient, 287 | ); 288 | 289 | expect(query.spies.queryFn).toBeCalledTimes(0); 290 | 291 | query.dispose(); 292 | }); 293 | 294 | it('should be reactive after change queryKey', async () => { 295 | const query = new QueryMock({ 296 | queryKey: ['test', 0 as number] as const, 297 | enabled: ({ queryKey }) => queryKey[1] > 0, 298 | queryFn: () => 100, 299 | }); 300 | 301 | query.update({ queryKey: ['test', 1] as const }); 302 | 303 | await when(() => !query._rawResult.isLoading); 304 | 305 | expect(query.spies.queryFn).toBeCalledTimes(1); 306 | expect(query.spies.queryFn).nthReturnedWith(1, 100); 307 | 308 | query.dispose(); 309 | }); 310 | 311 | it('should be reactive dependent on another query (runs before declartion)', async () => { 312 | const disabledQuery = new QueryMock({ 313 | queryKey: ['test', 0 as number] as const, 314 | enabled: ({ queryKey }) => queryKey[1] > 0, 315 | queryFn: () => 100, 316 | }); 317 | 318 | disabledQuery.update({ queryKey: ['test', 1] as const }); 319 | 320 | const dependentQuery = new QueryMock({ 321 | options: () => ({ 322 | enabled: !!disabledQuery.options.enabled, 323 | queryKey: [...disabledQuery.options.queryKey, 'dependent'], 324 | }), 325 | queryFn: ({ queryKey }) => queryKey, 326 | }); 327 | 328 | await when(() => !disabledQuery._rawResult.isLoading); 329 | await when(() => !dependentQuery._rawResult.isLoading); 330 | 331 | expect(dependentQuery.spies.queryFn).toBeCalledTimes(1); 332 | expect(dependentQuery.spies.queryFn).nthReturnedWith(1, [ 333 | 'test', 334 | 1, 335 | 'dependent', 336 | ]); 337 | 338 | disabledQuery.dispose(); 339 | dependentQuery.dispose(); 340 | }); 341 | 342 | it('should be reactive dependent on another query (runs after declaration)', async () => { 343 | const tempDisabledQuery = new QueryMock({ 344 | queryKey: ['test', 0 as number] as const, 345 | enabled: ({ queryKey }) => queryKey[1] > 0, 346 | queryFn: () => 100, 347 | }); 348 | 349 | const dependentQuery = new QueryMock({ 350 | options: () => ({ 351 | enabled: !!tempDisabledQuery.options.enabled, 352 | queryKey: [...tempDisabledQuery.options.queryKey, 'dependent'], 353 | }), 354 | queryFn: ({ queryKey }) => queryKey, 355 | }); 356 | 357 | tempDisabledQuery.update({ queryKey: ['test', 1] as const }); 358 | 359 | await when(() => !tempDisabledQuery._rawResult.isLoading); 360 | await when(() => !dependentQuery._rawResult.isLoading); 361 | 362 | expect(dependentQuery.spies.queryFn).toBeCalledTimes(1); 363 | // результат с 0 потому что options.enabled у первой квери - это функция и 364 | // !!tempDisabledQuery.options.enabled будет всегда true 365 | expect(dependentQuery.spies.queryFn).nthReturnedWith(1, [ 366 | 'test', 367 | 0, 368 | 'dependent', 369 | ]); 370 | 371 | tempDisabledQuery.dispose(); 372 | dependentQuery.dispose(); 373 | }); 374 | }); 375 | 376 | describe('"options" reactive parameter', () => { 377 | it('"options.queryKey" should updates query', async () => { 378 | const boxCounter = observable.box(0); 379 | let counter = 0; 380 | const query = new QueryMock({ 381 | queryFn: ({ queryKey }) => { 382 | counter += queryKey[1] * 10; 383 | return counter; 384 | }, 385 | options: () => ({ 386 | queryKey: ['test', boxCounter.get()] as const, 387 | }), 388 | }); 389 | 390 | runInAction(() => { 391 | boxCounter.set(1); 392 | }); 393 | 394 | await when(() => !query._rawResult.isLoading); 395 | 396 | expect(query.spies.queryFn).toBeCalledTimes(2); 397 | expect(query.spies.queryFn).nthReturnedWith(1, 0); 398 | expect(query.spies.queryFn).nthReturnedWith(2, 10); 399 | 400 | query.dispose(); 401 | }); 402 | 403 | it('"options.enabled" should change "enabled" statement for query (enabled as boolean in options)', async () => { 404 | const boxEnabled = observable.box(false); 405 | const query = new QueryMock({ 406 | queryFn: ({ queryKey }) => { 407 | return queryKey[1]; 408 | }, 409 | options: () => ({ 410 | enabled: boxEnabled.get(), 411 | queryKey: ['test', boxEnabled.get() ? 10 : 0] as const, 412 | }), 413 | }); 414 | 415 | runInAction(() => { 416 | boxEnabled.set(true); 417 | }); 418 | 419 | await when(() => !query._rawResult.isLoading); 420 | 421 | expect(query.spies.queryFn).toBeCalledTimes(1); 422 | expect(query.spies.queryFn).nthReturnedWith(1, 10); 423 | 424 | query.dispose(); 425 | }); 426 | 427 | it('"options.enabled" should change "enabled" statement for query (enabled as query based fn)', async () => { 428 | const boxEnabled = observable.box(false); 429 | const query = new QueryMock({ 430 | queryFn: ({ queryKey }) => { 431 | return queryKey[1]; 432 | }, 433 | enabled: ({ queryKey }) => queryKey[1], 434 | options: () => ({ 435 | queryKey: ['test', boxEnabled.get()] as const, 436 | }), 437 | }); 438 | 439 | runInAction(() => { 440 | boxEnabled.set(true); 441 | }); 442 | 443 | await when(() => !query._rawResult.isLoading); 444 | 445 | expect(query.spies.queryFn).toBeCalledTimes(1); 446 | expect(query.spies.queryFn).nthReturnedWith(1, true); 447 | 448 | query.dispose(); 449 | }); 450 | }); 451 | 452 | describe('"enableOnDemand" option', () => { 453 | describe('at start', () => { 454 | it('should not call query if result is not requested (without "enabled" property use)', async () => { 455 | const query = new QueryMock({ 456 | queryFn: () => 10, 457 | enableOnDemand: true, 458 | }); 459 | 460 | await when(() => !query._rawResult.isLoading); 461 | 462 | expect(query.spies.queryFn).toBeCalledTimes(0); 463 | 464 | query.dispose(); 465 | }); 466 | 467 | it('should not call query if result is not requested (with "enabled": false)', async () => { 468 | const query = new QueryMock({ 469 | queryFn: () => 10, 470 | enableOnDemand: true, 471 | enabled: false, 472 | }); 473 | 474 | await when(() => !query._rawResult.isLoading); 475 | 476 | expect(query.spies.queryFn).toBeCalledTimes(0); 477 | 478 | query.dispose(); 479 | }); 480 | 481 | it('should not call query if result is not requested (with "enabled": fn -> false)', async () => { 482 | const query = new QueryMock({ 483 | queryFn: () => 10, 484 | enableOnDemand: true, 485 | enabled: () => false, 486 | }); 487 | 488 | await when(() => !query._rawResult.isLoading); 489 | 490 | expect(query.spies.queryFn).toBeCalledTimes(0); 491 | 492 | query.dispose(); 493 | }); 494 | 495 | it('should not call query if result is not requested (with "enabled": fn -> true)', async () => { 496 | const query = new QueryMock({ 497 | queryFn: () => 10, 498 | enableOnDemand: true, 499 | enabled: () => true, 500 | }); 501 | 502 | await when(() => !query._rawResult.isLoading); 503 | 504 | expect(query.spies.queryFn).toBeCalledTimes(0); 505 | 506 | query.dispose(); 507 | }); 508 | 509 | it('should not call query if result is not requested (with "enabled": true)', async () => { 510 | const query = new QueryMock({ 511 | queryFn: () => 10, 512 | enableOnDemand: true, 513 | enabled: true, 514 | }); 515 | 516 | await when(() => !query._rawResult.isLoading); 517 | 518 | expect(query.spies.queryFn).toBeCalledTimes(0); 519 | 520 | query.dispose(); 521 | }); 522 | 523 | it('should not call query if result is not requested (with "enabled": false in dynamic options)', async () => { 524 | const query = new QueryMock({ 525 | queryFn: () => 10, 526 | enableOnDemand: true, 527 | options: () => ({ 528 | enabled: false, 529 | }), 530 | }); 531 | 532 | await when(() => !query._rawResult.isLoading); 533 | 534 | expect(query.spies.queryFn).toBeCalledTimes(0); 535 | 536 | query.dispose(); 537 | }); 538 | 539 | it('should not call query if result is not requested (with "enabled": true in dynamic options)', async () => { 540 | const query = new QueryMock({ 541 | queryFn: () => 10, 542 | enableOnDemand: true, 543 | options: () => ({ 544 | enabled: true, 545 | }), 546 | }); 547 | 548 | await when(() => !query._rawResult.isLoading); 549 | 550 | expect(query.spies.queryFn).toBeCalledTimes(0); 551 | 552 | query.dispose(); 553 | }); 554 | 555 | it('should call query if result is requested (without "enabled" property use)', async () => { 556 | const query = new QueryMock({ 557 | queryFn: () => 10, 558 | enableOnDemand: true, 559 | }); 560 | 561 | query.result.data; 562 | 563 | await when(() => !query._rawResult.isLoading); 564 | 565 | expect(query.spies.queryFn).toBeCalledTimes(1); 566 | 567 | query.dispose(); 568 | }); 569 | 570 | it('should not call query event if result is requested (reason: "enabled": false out of box)', async () => { 571 | const query = new QueryMock({ 572 | queryFn: () => 10, 573 | enableOnDemand: true, 574 | enabled: false, 575 | }); 576 | 577 | query.result.data; 578 | 579 | await when(() => !query._rawResult.isLoading); 580 | 581 | expect(query.spies.queryFn).toBeCalledTimes(0); 582 | 583 | query.dispose(); 584 | }); 585 | 586 | it('should not call query even if result is requested (reason: "enabled": fn -> false)', async () => { 587 | const query = new QueryMock({ 588 | queryFn: () => 10, 589 | enableOnDemand: true, 590 | enabled: function getEnabledFromUnitTest() { 591 | return false; 592 | }, 593 | }); 594 | 595 | query.result.data; 596 | 597 | await when(() => !query._rawResult.isLoading); 598 | 599 | expect(query.spies.queryFn).toBeCalledTimes(0); 600 | 601 | query.dispose(); 602 | }); 603 | 604 | it('should call query if result is requested (with "enabled": fn -> true)', async () => { 605 | const query = new QueryMock({ 606 | queryFn: () => 10, 607 | enableOnDemand: true, 608 | enabled: () => true, 609 | }); 610 | 611 | query.result.data; 612 | 613 | await when(() => !query._rawResult.isLoading); 614 | 615 | expect(query.spies.queryFn).toBeCalledTimes(1); 616 | 617 | query.dispose(); 618 | }); 619 | 620 | it('should call query if result is requested (with "enabled": true)', async () => { 621 | const query = new QueryMock({ 622 | queryFn: () => 10, 623 | enableOnDemand: true, 624 | enabled: true, 625 | }); 626 | 627 | query.result.data; 628 | 629 | await when(() => !query._rawResult.isLoading); 630 | 631 | expect(query.spies.queryFn).toBeCalledTimes(1); 632 | 633 | query.dispose(); 634 | }); 635 | it('should NOT call query if result is requested (reason: "enabled" false from default query client options)', async () => { 636 | const queryClient = new QueryClient({ 637 | defaultOptions: { 638 | queries: { 639 | enabled: false, 640 | }, 641 | }, 642 | }); 643 | const query = new QueryMock( 644 | { 645 | queryKey: ['test', 0 as number] as const, 646 | queryFn: () => 100, 647 | enableOnDemand: true, 648 | }, 649 | queryClient, 650 | ); 651 | 652 | query.result.data; 653 | query.result.isLoading; 654 | 655 | expect(query.spies.queryFn).toBeCalledTimes(0); 656 | 657 | query.dispose(); 658 | }); 659 | 660 | it('should not call query even it is enabled until result is requested', async () => { 661 | const queryClient = new QueryClient({ 662 | defaultOptions: { 663 | queries: { 664 | enabled: true, 665 | }, 666 | }, 667 | }); 668 | const query = new QueryMock( 669 | { 670 | queryKey: ['test', 0 as number] as const, 671 | queryFn: () => 100, 672 | enableOnDemand: true, 673 | }, 674 | queryClient, 675 | ); 676 | 677 | expect(query.spies.queryFn).toBeCalledTimes(0); 678 | 679 | query.result.data; 680 | query.result.isLoading; 681 | 682 | expect(query.spies.queryFn).toBeCalledTimes(1); 683 | 684 | query.dispose(); 685 | }); 686 | 687 | it('should enable query when result is requested', async () => { 688 | const query = new QueryMock({ 689 | queryKey: ['test', 0 as number] as const, 690 | queryFn: () => 100, 691 | enableOnDemand: true, 692 | }); 693 | 694 | expect(query.spies.queryFn).toBeCalledTimes(0); 695 | 696 | query.result.data; 697 | query.result.isLoading; 698 | 699 | expect(query.spies.queryFn).toBeCalledTimes(1); 700 | 701 | query.dispose(); 702 | }); 703 | 704 | it('should enable query from dynamic options ONLY AFTER result is requested', () => { 705 | const valueBox = observable.box(); 706 | 707 | const query = new QueryMock({ 708 | queryFn: () => 100, 709 | enableOnDemand: true, 710 | options: () => ({ 711 | queryKey: ['values', valueBox.get()] as const, 712 | enabled: !!valueBox.get(), 713 | }), 714 | }); 715 | 716 | query.result.data; 717 | query.result.isLoading; 718 | 719 | expect(query.spies.queryFn).toBeCalledTimes(0); 720 | 721 | runInAction(() => { 722 | valueBox.set('value'); 723 | }); 724 | 725 | expect(query.spies.queryFn).toBeCalledTimes(1); 726 | 727 | query.dispose(); 728 | }); 729 | 730 | it('should enable query from dynamic options ONLY AFTER result is requested (multiple observable updates)', () => { 731 | const valueBox = observable.box(); 732 | 733 | const query = new QueryMock({ 734 | queryFn: () => 100, 735 | enableOnDemand: true, 736 | options: () => { 737 | const value = valueBox.get(); 738 | return { 739 | queryKey: ['values', value] as const, 740 | enabled: value === 'kek', 741 | }; 742 | }, 743 | }); 744 | 745 | query.result.data; 746 | query.result.isLoading; 747 | 748 | expect(query.spies.queryFn).toBeCalledTimes(0); 749 | 750 | runInAction(() => { 751 | valueBox.set(null); 752 | }); 753 | 754 | runInAction(() => { 755 | valueBox.set('faslse'); 756 | }); 757 | 758 | expect(query.spies.queryFn).toBeCalledTimes(0); 759 | 760 | runInAction(() => { 761 | valueBox.set('kek'); 762 | }); 763 | 764 | expect(query.spies.queryFn).toBeCalledTimes(1); 765 | 766 | query.dispose(); 767 | }); 768 | }); 769 | }); 770 | 771 | describe('"setData" method', () => { 772 | const queryClient = new QueryClient(); 773 | 774 | afterEach(() => { 775 | vi.restoreAllMocks(); 776 | queryClient.clear(); 777 | }); 778 | 779 | it('should simple update query data', async ({ task }) => { 780 | const queryData = { 781 | a: { 782 | b: { 783 | c: { 784 | d: { 785 | f: { 786 | children: [ 787 | { 788 | id: '1', 789 | name: 'John', 790 | age: 20, 791 | }, 792 | ], 793 | }, 794 | }, 795 | }, 796 | }, 797 | }, 798 | } as Record; 799 | 800 | const query = new QueryMock( 801 | { 802 | queryKey: [task.name, '1'], 803 | queryFn: () => structuredClone(queryData), 804 | }, 805 | queryClient, 806 | ); 807 | 808 | await when(() => !query.result.isLoading); 809 | 810 | query.setData(() => ({ bar: 1, baz: 2 })); 811 | 812 | expect(query.spies.queryFn).toBeCalledTimes(1); 813 | expect(query.result.data).toEqual({ bar: 1, baz: 2 }); 814 | 815 | query.dispose(); 816 | }); 817 | it('should update query data using mutation', async ({ task }) => { 818 | const queryData = { 819 | a: { 820 | b: { 821 | c: { 822 | d: { 823 | e: { 824 | children: [ 825 | { 826 | id: '1', 827 | name: 'John', 828 | age: 20, 829 | }, 830 | ], 831 | }, 832 | }, 833 | }, 834 | }, 835 | }, 836 | } as Record; 837 | 838 | console.info('asdfdsaf', task.name); 839 | 840 | const query = new QueryMock( 841 | { 842 | queryKey: [task.name, '2'], 843 | queryFn: () => structuredClone(queryData), 844 | }, 845 | queryClient, 846 | ); 847 | 848 | await when(() => !query.result.isLoading); 849 | await waitAsync(10); 850 | 851 | query.setData((curr) => { 852 | if (!curr) return curr; 853 | curr.a.b.c.d.e.children.push({ id: '2', name: 'Doe', age: 21 }); 854 | return curr; 855 | }); 856 | 857 | await when(() => !query.result.isLoading); 858 | 859 | expect(query.spies.queryFn).toBeCalledTimes(1); 860 | expect(query.result.data).toEqual({ 861 | a: { 862 | b: { 863 | c: { 864 | d: { 865 | e: { 866 | children: [ 867 | { 868 | id: '1', 869 | name: 'John', 870 | age: 20, 871 | }, 872 | { 873 | id: '2', 874 | name: 'Doe', 875 | age: 21, 876 | }, 877 | ], 878 | }, 879 | }, 880 | }, 881 | }, 882 | }, 883 | }); 884 | }); 885 | it('should calls reactions after update query data using mutation', async ({ 886 | task, 887 | }) => { 888 | const queryData = { 889 | a: { 890 | b: { 891 | c: { 892 | d: { 893 | e: { 894 | children: [ 895 | { 896 | id: '1', 897 | name: 'John', 898 | age: 20, 899 | }, 900 | ], 901 | }, 902 | }, 903 | }, 904 | }, 905 | }, 906 | } as Record; 907 | 908 | const query = new QueryMock( 909 | { 910 | queryKey: [task.name, '3'], 911 | queryFn: () => structuredClone(queryData), 912 | }, 913 | queryClient, 914 | ); 915 | 916 | const reactionSpy = vi.fn(); 917 | 918 | reaction( 919 | () => query.result.data, 920 | (curr, prev) => reactionSpy(curr, prev), 921 | ); 922 | 923 | await when(() => !query.result.isLoading); 924 | await waitAsync(10); 925 | 926 | query.setData((curr) => { 927 | if (!curr) return curr; 928 | curr.a.b.c.d.e.children.push({ id: '2', name: 'Doe', age: 21 }); 929 | return curr; 930 | }); 931 | 932 | expect(reactionSpy).toBeCalledTimes(2); 933 | expect(reactionSpy).toHaveBeenNthCalledWith( 934 | 2, 935 | { 936 | a: { 937 | b: { 938 | c: { 939 | d: { 940 | e: { 941 | children: [ 942 | { 943 | id: '1', 944 | name: 'John', 945 | age: 20, 946 | }, 947 | { 948 | id: '2', 949 | name: 'Doe', 950 | age: 21, 951 | }, 952 | ], 953 | }, 954 | }, 955 | }, 956 | }, 957 | }, 958 | }, 959 | { 960 | a: { 961 | b: { 962 | c: { 963 | d: { 964 | e: { 965 | children: [ 966 | { 967 | id: '1', 968 | name: 'John', 969 | age: 20, 970 | }, 971 | ], 972 | }, 973 | }, 974 | }, 975 | }, 976 | }, 977 | }, 978 | ); 979 | 980 | query.dispose(); 981 | }); 982 | it('should update computed.structs after update query data using mutation', async ({ 983 | task, 984 | }) => { 985 | const queryData = { 986 | a: { 987 | b: { 988 | c: { 989 | d: { 990 | e: { 991 | children: [ 992 | { 993 | id: '1', 994 | name: 'John', 995 | age: 20, 996 | }, 997 | ], 998 | }, 999 | }, 1000 | }, 1001 | }, 1002 | }, 1003 | } as Record; 1004 | 1005 | class TestClass { 1006 | query = new QueryMock( 1007 | { 1008 | queryKey: [task.name, '4'], 1009 | queryFn: () => structuredClone(queryData), 1010 | }, 1011 | queryClient, 1012 | ); 1013 | 1014 | constructor() { 1015 | computed.struct(this, 'foo'); 1016 | makeObservable(this); 1017 | } 1018 | 1019 | get foo() { 1020 | return this.query.result.data?.a.b.c.d.e.children[0] || null; 1021 | } 1022 | 1023 | destroy() { 1024 | this.query.dispose(); 1025 | } 1026 | } 1027 | 1028 | const testClass = new TestClass(); 1029 | 1030 | await when(() => !testClass.query.result.isLoading); 1031 | await waitAsync(10); 1032 | 1033 | expect(testClass.foo).toStrictEqual({ 1034 | age: 20, 1035 | id: '1', 1036 | name: 'John', 1037 | }); 1038 | 1039 | testClass.query.setData((curr) => { 1040 | if (!curr) return curr; 1041 | curr.a.b.c.d.e.children[0].name = 'Doe'; 1042 | return curr; 1043 | }); 1044 | 1045 | expect(testClass.foo).toStrictEqual({ 1046 | age: 20, 1047 | id: '1', 1048 | name: 'Doe', 1049 | }); 1050 | 1051 | testClass.destroy(); 1052 | }); 1053 | it('computed.structs should be reactive after update query data using mutation', async ({ 1054 | task, 1055 | }) => { 1056 | const queryData = { 1057 | a: { 1058 | b: { 1059 | c: { 1060 | d: { 1061 | e: { 1062 | children: [ 1063 | { 1064 | id: '1', 1065 | name: 'John', 1066 | age: 20, 1067 | }, 1068 | ], 1069 | }, 1070 | }, 1071 | }, 1072 | }, 1073 | }, 1074 | } as Record; 1075 | 1076 | class TestClass { 1077 | query = new QueryMock( 1078 | { 1079 | queryKey: [task.name, '5'], 1080 | queryFn: () => structuredClone(queryData), 1081 | }, 1082 | queryClient, 1083 | ); 1084 | 1085 | constructor() { 1086 | computed.struct(this, 'foo'); 1087 | makeObservable(this); 1088 | } 1089 | 1090 | get foo() { 1091 | return this.query.result.data?.a.b.c.d.e.children[0] || null; 1092 | } 1093 | 1094 | destroy() { 1095 | this.query.dispose(); 1096 | } 1097 | } 1098 | 1099 | const testClass = new TestClass(); 1100 | 1101 | const reactionFooSpy = vi.fn(); 1102 | 1103 | reaction( 1104 | () => testClass.foo, 1105 | (curr, prev) => reactionFooSpy(curr, prev), 1106 | ); 1107 | 1108 | await when(() => !testClass.query.result.isLoading); 1109 | await waitAsync(10); 1110 | 1111 | testClass.query.setData((curr) => { 1112 | if (!curr) return curr; 1113 | curr.a.b.c.d.e.children[0].name = 'Doe'; 1114 | return curr; 1115 | }); 1116 | 1117 | expect(reactionFooSpy).toBeCalledTimes(2); 1118 | 1119 | expect(reactionFooSpy).toHaveBeenNthCalledWith( 1120 | 2, 1121 | { 1122 | age: 20, 1123 | id: '1', 1124 | name: 'Doe', 1125 | }, 1126 | { 1127 | age: 20, 1128 | id: '1', 1129 | name: 'John', 1130 | }, 1131 | ); 1132 | 1133 | testClass.destroy(); 1134 | }); 1135 | }); 1136 | 1137 | describe('"start" method', () => { 1138 | test('should call once queryFn', async () => { 1139 | const querySpyFn = vi.fn(); 1140 | const query = new QueryMock({ 1141 | queryKey: ['test'], 1142 | queryFn: querySpyFn, 1143 | enabled: false, 1144 | }); 1145 | 1146 | await query.start(); 1147 | 1148 | await when(() => !query._rawResult.isLoading); 1149 | 1150 | expect(query.result.isFetched).toBeTruthy(); 1151 | expect(querySpyFn).toBeCalledTimes(1); 1152 | 1153 | query.dispose(); 1154 | }); 1155 | 1156 | test('should call queryFn every time when start() method is called', async () => { 1157 | const querySpyFn = vi.fn(); 1158 | const query = new QueryMock({ 1159 | queryKey: ['test'], 1160 | queryFn: querySpyFn, 1161 | enabled: false, 1162 | }); 1163 | 1164 | await query.start(); 1165 | await query.start(); 1166 | await query.start(); 1167 | 1168 | await when(() => !query._rawResult.isLoading); 1169 | 1170 | expect(query.result.isFetched).toBeTruthy(); 1171 | expect(querySpyFn).toBeCalledTimes(3); 1172 | 1173 | query.dispose(); 1174 | }); 1175 | }); 1176 | 1177 | describe('scenarios', () => { 1178 | it('query with refetchInterval(number) should be stopped after inner abort', async () => { 1179 | const query = new QueryMock({ 1180 | queryFn: async () => { 1181 | await waitAsync(10); 1182 | return 10; 1183 | }, 1184 | enabled: true, 1185 | refetchInterval: 10, 1186 | }); 1187 | 1188 | await waitAsync(100); 1189 | expect(query.spies.queryFn).toBeCalledTimes(5); 1190 | query.dispose(); 1191 | 1192 | await waitAsync(100); 1193 | 1194 | expect(query.spies.queryFn).toBeCalledTimes(5); 1195 | }); 1196 | it('query with refetchInterval(number) should be stopped after outer abort', async () => { 1197 | const abortController = new AbortController(); 1198 | const query = new QueryMock({ 1199 | queryFn: async () => { 1200 | await waitAsync(10); 1201 | return 10; 1202 | }, 1203 | enabled: true, 1204 | abortSignal: abortController.signal, 1205 | refetchInterval: 10, 1206 | }); 1207 | 1208 | await waitAsync(100); 1209 | expect(query.spies.queryFn).toBeCalledTimes(5); 1210 | 1211 | abortController.abort(); 1212 | 1213 | await waitAsync(100); 1214 | 1215 | expect(query.spies.queryFn).toBeCalledTimes(5); 1216 | }); 1217 | it('query with refetchInterval(fn) should be stopped after inner abort', async () => { 1218 | const query = new QueryMock({ 1219 | queryFn: async () => { 1220 | await waitAsync(10); 1221 | return 10; 1222 | }, 1223 | enabled: true, 1224 | refetchInterval: () => 10, 1225 | }); 1226 | 1227 | await waitAsync(100); 1228 | expect(query.spies.queryFn).toBeCalledTimes(5); 1229 | 1230 | query.dispose(); 1231 | 1232 | await waitAsync(100); 1233 | 1234 | expect(query.spies.queryFn).toBeCalledTimes(5); 1235 | }); 1236 | it('query with refetchInterval(fn) should be stopped after outer abort', async () => { 1237 | const abortController = new AbortController(); 1238 | const query = new QueryMock({ 1239 | queryFn: async () => { 1240 | await waitAsync(10); 1241 | return 10; 1242 | }, 1243 | enabled: true, 1244 | abortSignal: abortController.signal, 1245 | refetchInterval: () => 10, 1246 | }); 1247 | 1248 | await waitAsync(100); 1249 | expect(query.spies.queryFn).toBeCalledTimes(5); 1250 | 1251 | abortController.abort(); 1252 | 1253 | await waitAsync(100); 1254 | 1255 | expect(query.spies.queryFn).toBeCalledTimes(5); 1256 | }); 1257 | it('query with refetchInterval(condition fn) should be stopped after inner abort', async () => { 1258 | const query = new QueryMock({ 1259 | queryFn: async () => { 1260 | await waitAsync(10); 1261 | return 10; 1262 | }, 1263 | enabled: true, 1264 | refetchInterval: (query) => (query.isActive() ? 10 : false), 1265 | }); 1266 | 1267 | await waitAsync(100); 1268 | expect(query.spies.queryFn).toBeCalledTimes(5); 1269 | query.dispose(); 1270 | 1271 | await waitAsync(100); 1272 | 1273 | expect(query.spies.queryFn).toBeCalledTimes(5); 1274 | }); 1275 | it('query with refetchInterval(condition-fn) should be stopped after outer abort', async () => { 1276 | const abortController = new AbortController(); 1277 | const query = new QueryMock({ 1278 | queryFn: async () => { 1279 | await waitAsync(10); 1280 | return 10; 1281 | }, 1282 | enabled: true, 1283 | abortSignal: abortController.signal, 1284 | refetchInterval: (query) => (query.isActive() ? 10 : false), 1285 | }); 1286 | 1287 | await waitAsync(100); 1288 | expect(query.spies.queryFn).toBeCalledTimes(5); 1289 | 1290 | abortController.abort(); 1291 | 1292 | await waitAsync(100); 1293 | 1294 | expect(query.spies.queryFn).toBeCalledTimes(5); 1295 | }); 1296 | it('dynamic enabled + dynamic refetchInterval', async () => { 1297 | const abortController = new AbortController(); 1298 | const counter = observable.box(0); 1299 | 1300 | const query = new QueryMock({ 1301 | queryFn: async () => { 1302 | runInAction(() => { 1303 | counter.set(counter.get() + 1); 1304 | }); 1305 | await waitAsync(10); 1306 | return 10; 1307 | }, 1308 | options: () => ({ 1309 | enabled: counter.get() < 3, 1310 | queryKey: ['test', counter.get()], 1311 | }), 1312 | abortSignal: abortController.signal, 1313 | refetchInterval: (query) => (query.isDisabled() ? false : 10), 1314 | }); 1315 | 1316 | await waitAsync(100); 1317 | expect(query.spies.queryFn).toBeCalledTimes(3); 1318 | 1319 | abortController.abort(); 1320 | 1321 | await waitAsync(100); 1322 | 1323 | expect(query.spies.queryFn).toBeCalledTimes(3); 1324 | }); 1325 | it('dynamic enabled + dynamic refetchInterval(refetchInterval is fixed)', async () => { 1326 | const abortController = new AbortController(); 1327 | const counter = observable.box(0); 1328 | 1329 | const query = new QueryMock({ 1330 | queryFn: async () => { 1331 | runInAction(() => { 1332 | counter.set(counter.get() + 1); 1333 | }); 1334 | await waitAsync(10); 1335 | return 10; 1336 | }, 1337 | options: () => ({ 1338 | enabled: counter.get() < 3, 1339 | queryKey: ['test', counter.get()], 1340 | }), 1341 | abortSignal: abortController.signal, 1342 | refetchInterval: () => 10, 1343 | }); 1344 | 1345 | await waitAsync(100); 1346 | expect(query.spies.queryFn).toBeCalledTimes(3); 1347 | 1348 | abortController.abort(); 1349 | 1350 | await waitAsync(100); 1351 | 1352 | expect(query.spies.queryFn).toBeCalledTimes(3); 1353 | }); 1354 | it('dynamic enabled + dynamic refetchInterval (+enabledOnDemand)', async () => { 1355 | const abortController = new AbortController(); 1356 | const counter = observable.box(0); 1357 | 1358 | const query = new QueryMock({ 1359 | queryFn: async () => { 1360 | await waitAsync(10); 1361 | runInAction(() => { 1362 | counter.set(counter.get() + 1); 1363 | }); 1364 | return 10; 1365 | }, 1366 | enableOnDemand: true, 1367 | options: () => ({ 1368 | enabled: counter.get() < 100, 1369 | queryKey: ['test', counter.get()], 1370 | }), 1371 | abortSignal: abortController.signal, 1372 | refetchInterval: (query) => (query.isDisabled() ? false : 10), 1373 | }); 1374 | 1375 | await waitAsync(100); 1376 | expect(query.spies.queryFn).toBeCalledTimes(0); 1377 | 1378 | query.result.data; 1379 | 1380 | await waitAsync(100); 1381 | expect(query.spies.queryFn).toBeCalledTimes(10); 1382 | 1383 | query.result.data; 1384 | query.result.isLoading; 1385 | await waitAsync(50); 1386 | expect(query.spies.queryFn).toBeCalledTimes(15); 1387 | abortController.abort(); 1388 | 1389 | query.result.data; 1390 | query.result.data; 1391 | query.result.isLoading; 1392 | 1393 | await waitAsync(100); 1394 | 1395 | query.result.data; 1396 | query.result.isLoading; 1397 | 1398 | expect(query.spies.queryFn).toBeCalledTimes(15); 1399 | }); 1400 | 1401 | it('after abort identical (by query key) query another query should work', async () => { 1402 | const abortController1 = new LinkedAbortController(); 1403 | const abortController2 = new LinkedAbortController(); 1404 | const query1 = new QueryMock({ 1405 | queryFn: async () => { 1406 | await waitAsync(5); 1407 | return 'bar'; 1408 | }, 1409 | abortSignal: abortController1.signal, 1410 | queryKey: ['test'] as const, 1411 | }); 1412 | const query2 = new QueryMock({ 1413 | queryFn: async () => { 1414 | await waitAsync(5); 1415 | return 'foo'; 1416 | }, 1417 | abortSignal: abortController2.signal, 1418 | queryKey: ['test'] as const, 1419 | }); 1420 | abortController1.abort(); 1421 | 1422 | expect(query1.result).toStrictEqual({ 1423 | data: undefined, 1424 | dataUpdatedAt: 0, 1425 | error: null, 1426 | errorUpdateCount: 0, 1427 | errorUpdatedAt: 0, 1428 | failureCount: 0, 1429 | failureReason: null, 1430 | fetchStatus: 'fetching', 1431 | isError: false, 1432 | isFetched: false, 1433 | isFetchedAfterMount: false, 1434 | isFetching: true, 1435 | isInitialLoading: true, 1436 | isLoading: true, 1437 | isLoadingError: false, 1438 | isPaused: false, 1439 | isPending: true, 1440 | isPlaceholderData: false, 1441 | isRefetchError: false, 1442 | isRefetching: false, 1443 | isStale: true, 1444 | isSuccess: false, 1445 | promise: query1.result.promise, 1446 | refetch: query1.result.refetch, 1447 | status: 'pending', 1448 | }); 1449 | expect(query2.result).toStrictEqual({ 1450 | data: undefined, 1451 | dataUpdatedAt: 0, 1452 | error: null, 1453 | errorUpdateCount: 0, 1454 | errorUpdatedAt: 0, 1455 | failureCount: 0, 1456 | failureReason: null, 1457 | fetchStatus: 'fetching', 1458 | isError: false, 1459 | isFetched: false, 1460 | isFetchedAfterMount: false, 1461 | isFetching: true, 1462 | isInitialLoading: true, 1463 | isLoading: true, 1464 | isLoadingError: false, 1465 | isPaused: false, 1466 | isPending: true, 1467 | isPlaceholderData: false, 1468 | isRefetchError: false, 1469 | isRefetching: false, 1470 | isStale: true, 1471 | isSuccess: false, 1472 | promise: query2.result.promise, 1473 | refetch: query2.result.refetch, 1474 | status: 'pending', 1475 | }); 1476 | await waitAsync(10); 1477 | expect(query1.result).toStrictEqual({ 1478 | data: undefined, 1479 | dataUpdatedAt: 0, 1480 | error: null, 1481 | errorUpdateCount: 0, 1482 | errorUpdatedAt: 0, 1483 | failureCount: 0, 1484 | failureReason: null, 1485 | fetchStatus: 'fetching', 1486 | isError: false, 1487 | isFetched: false, 1488 | isFetchedAfterMount: false, 1489 | isFetching: true, 1490 | isInitialLoading: true, 1491 | isLoading: true, 1492 | isLoadingError: false, 1493 | isPaused: false, 1494 | isPending: true, 1495 | isPlaceholderData: false, 1496 | isRefetchError: false, 1497 | isRefetching: false, 1498 | isStale: true, 1499 | isSuccess: false, 1500 | promise: query1.result.promise, 1501 | refetch: query1.result.refetch, 1502 | status: 'pending', 1503 | }); 1504 | expect(query2.result).toStrictEqual({ 1505 | data: 'foo', 1506 | dataUpdatedAt: query2.result.dataUpdatedAt, 1507 | error: null, 1508 | errorUpdateCount: 0, 1509 | errorUpdatedAt: 0, 1510 | failureCount: 0, 1511 | failureReason: null, 1512 | fetchStatus: 'idle', 1513 | isError: false, 1514 | isFetched: true, 1515 | isFetchedAfterMount: true, 1516 | isFetching: false, 1517 | isInitialLoading: false, 1518 | isLoading: false, 1519 | isLoadingError: false, 1520 | isPaused: false, 1521 | isPending: false, 1522 | isPlaceholderData: false, 1523 | isRefetchError: false, 1524 | isRefetching: false, 1525 | isStale: true, 1526 | isSuccess: true, 1527 | promise: query2.result.promise, 1528 | refetch: query2.result.refetch, 1529 | status: 'success', 1530 | }); 1531 | await waitAsync(10); 1532 | expect(query1.result).toStrictEqual({ 1533 | data: undefined, 1534 | dataUpdatedAt: 0, 1535 | error: null, 1536 | errorUpdateCount: 0, 1537 | errorUpdatedAt: 0, 1538 | failureCount: 0, 1539 | failureReason: null, 1540 | fetchStatus: 'fetching', 1541 | isError: false, 1542 | isFetched: false, 1543 | isFetchedAfterMount: false, 1544 | isFetching: true, 1545 | isInitialLoading: true, 1546 | isLoading: true, 1547 | isLoadingError: false, 1548 | isPaused: false, 1549 | isPending: true, 1550 | isPlaceholderData: false, 1551 | isRefetchError: false, 1552 | isRefetching: false, 1553 | isStale: true, 1554 | isSuccess: false, 1555 | promise: query1.result.promise, 1556 | refetch: query1.result.refetch, 1557 | status: 'pending', 1558 | }); 1559 | }); 1560 | 1561 | it('after abort identical (by query key) query another query should work (with resetOnDestroy option)', async () => { 1562 | const abortController1 = new LinkedAbortController(); 1563 | const abortController2 = new LinkedAbortController(); 1564 | const query1 = new QueryMock({ 1565 | queryFn: async () => { 1566 | await waitAsync(5); 1567 | return 'bar'; 1568 | }, 1569 | abortSignal: abortController1.signal, 1570 | queryKey: ['test'] as const, 1571 | resetOnDestroy: true, 1572 | }); 1573 | const query2 = new QueryMock({ 1574 | queryFn: async () => { 1575 | await waitAsync(5); 1576 | return 'foo'; 1577 | }, 1578 | abortSignal: abortController2.signal, 1579 | queryKey: ['test'] as const, 1580 | resetOnDestroy: true, 1581 | }); 1582 | abortController1.abort(); 1583 | 1584 | expect(query1.result).toStrictEqual({ 1585 | data: undefined, 1586 | dataUpdatedAt: 0, 1587 | error: null, 1588 | errorUpdateCount: 0, 1589 | errorUpdatedAt: 0, 1590 | failureCount: 0, 1591 | failureReason: null, 1592 | fetchStatus: 'fetching', 1593 | isError: false, 1594 | isFetched: false, 1595 | isFetchedAfterMount: false, 1596 | isFetching: true, 1597 | isInitialLoading: true, 1598 | isLoading: true, 1599 | isLoadingError: false, 1600 | isPaused: false, 1601 | isPending: true, 1602 | isPlaceholderData: false, 1603 | isRefetchError: false, 1604 | isRefetching: false, 1605 | isStale: true, 1606 | isSuccess: false, 1607 | promise: query1.result.promise, 1608 | refetch: query1.result.refetch, 1609 | status: 'pending', 1610 | }); 1611 | expect(query2.result).toStrictEqual({ 1612 | data: undefined, 1613 | dataUpdatedAt: 0, 1614 | error: null, 1615 | errorUpdateCount: 0, 1616 | errorUpdatedAt: 0, 1617 | failureCount: 0, 1618 | failureReason: null, 1619 | fetchStatus: 'fetching', 1620 | isError: false, 1621 | isFetched: false, 1622 | isFetchedAfterMount: false, 1623 | isFetching: true, 1624 | isInitialLoading: true, 1625 | isLoading: true, 1626 | isLoadingError: false, 1627 | isPaused: false, 1628 | isPending: true, 1629 | isPlaceholderData: false, 1630 | isRefetchError: false, 1631 | isRefetching: false, 1632 | isStale: true, 1633 | isSuccess: false, 1634 | promise: query2.result.promise, 1635 | refetch: query2.result.refetch, 1636 | status: 'pending', 1637 | }); 1638 | await waitAsync(10); 1639 | expect(query1.result).toStrictEqual({ 1640 | data: undefined, 1641 | dataUpdatedAt: 0, 1642 | error: null, 1643 | errorUpdateCount: 0, 1644 | errorUpdatedAt: 0, 1645 | failureCount: 0, 1646 | failureReason: null, 1647 | fetchStatus: 'fetching', 1648 | isError: false, 1649 | isFetched: false, 1650 | isFetchedAfterMount: false, 1651 | isFetching: true, 1652 | isInitialLoading: true, 1653 | isLoading: true, 1654 | isLoadingError: false, 1655 | isPaused: false, 1656 | isPending: true, 1657 | isPlaceholderData: false, 1658 | isRefetchError: false, 1659 | isRefetching: false, 1660 | isStale: true, 1661 | isSuccess: false, 1662 | promise: query1.result.promise, 1663 | refetch: query1.result.refetch, 1664 | status: 'pending', 1665 | }); 1666 | expect(query2.result).toStrictEqual({ 1667 | data: 'foo', 1668 | dataUpdatedAt: query2.result.dataUpdatedAt, 1669 | error: null, 1670 | errorUpdateCount: 0, 1671 | errorUpdatedAt: 0, 1672 | failureCount: 0, 1673 | failureReason: null, 1674 | fetchStatus: 'idle', 1675 | isError: false, 1676 | isFetched: true, 1677 | isFetchedAfterMount: true, 1678 | isFetching: false, 1679 | isInitialLoading: false, 1680 | isLoading: false, 1681 | isLoadingError: false, 1682 | isPaused: false, 1683 | isPending: false, 1684 | isPlaceholderData: false, 1685 | isRefetchError: false, 1686 | isRefetching: false, 1687 | isStale: true, 1688 | isSuccess: true, 1689 | promise: query2.result.promise, 1690 | refetch: query2.result.refetch, 1691 | status: 'success', 1692 | }); 1693 | await waitAsync(10); 1694 | expect(query1.result).toStrictEqual({ 1695 | data: undefined, 1696 | dataUpdatedAt: 0, 1697 | error: null, 1698 | errorUpdateCount: 0, 1699 | errorUpdatedAt: 0, 1700 | failureCount: 0, 1701 | failureReason: null, 1702 | fetchStatus: 'fetching', 1703 | isError: false, 1704 | isFetched: false, 1705 | isFetchedAfterMount: false, 1706 | isFetching: true, 1707 | isInitialLoading: true, 1708 | isLoading: true, 1709 | isLoadingError: false, 1710 | isPaused: false, 1711 | isPending: true, 1712 | isPlaceholderData: false, 1713 | isRefetchError: false, 1714 | isRefetching: false, 1715 | isStale: true, 1716 | isSuccess: false, 1717 | promise: query1.result.promise, 1718 | refetch: query1.result.refetch, 1719 | status: 'pending', 1720 | }); 1721 | }); 1722 | 1723 | it('options is not reactive when updating after creating #10', () => { 1724 | const enabled = observable.box(false); 1725 | 1726 | const queryFnSpy = vi.fn(); 1727 | const getDynamicOptionsSpy = vi.fn(); 1728 | 1729 | createQuery(queryFnSpy, { 1730 | options: () => { 1731 | getDynamicOptionsSpy(); 1732 | return { 1733 | enabled: enabled.get(), 1734 | }; 1735 | }, 1736 | }); 1737 | 1738 | enabled.set(true); 1739 | 1740 | expect(queryFnSpy).toBeCalledTimes(1); 1741 | expect(getDynamicOptionsSpy).toBeCalledTimes(3); 1742 | }); 1743 | 1744 | it('after abort signal for inprogress success work query create new instance with the same key and it should work', async () => { 1745 | const abortController1 = new LinkedAbortController(); 1746 | const query = new QueryMock({ 1747 | queryFn: async () => { 1748 | await waitAsync(11); 1749 | return { 1750 | foo: 1, 1751 | bar: 2, 1752 | kek: { 1753 | pek: { 1754 | tek: 1, 1755 | }, 1756 | }, 1757 | }; 1758 | }, 1759 | enabled: true, 1760 | abortSignal: abortController1.signal, 1761 | queryKey: ['test', 'key'] as const, 1762 | }); 1763 | 1764 | expect(query.result).toMatchObject({ 1765 | status: 'pending', 1766 | fetchStatus: 'fetching', 1767 | isPending: true, 1768 | isSuccess: false, 1769 | isError: false, 1770 | isInitialLoading: true, 1771 | isLoading: true, 1772 | data: undefined, 1773 | dataUpdatedAt: 0, 1774 | error: null, 1775 | errorUpdatedAt: 0, 1776 | failureCount: 0, 1777 | failureReason: null, 1778 | errorUpdateCount: 0, 1779 | isFetched: false, 1780 | isFetchedAfterMount: false, 1781 | isFetching: true, 1782 | isRefetching: false, 1783 | isLoadingError: false, 1784 | isPaused: false, 1785 | isPlaceholderData: false, 1786 | isRefetchError: false, 1787 | isStale: true, 1788 | } satisfies Partial>); 1789 | 1790 | abortController1.abort(); 1791 | 1792 | await waitAsync(10); 1793 | 1794 | expect(query.result).toMatchObject({ 1795 | status: 'pending', 1796 | fetchStatus: 'fetching', 1797 | isPending: true, 1798 | isSuccess: false, 1799 | isError: false, 1800 | isInitialLoading: true, 1801 | isLoading: true, 1802 | data: undefined, 1803 | dataUpdatedAt: 0, 1804 | error: null, 1805 | errorUpdatedAt: 0, 1806 | failureCount: 0, 1807 | failureReason: null, 1808 | errorUpdateCount: 0, 1809 | isFetched: false, 1810 | isFetchedAfterMount: false, 1811 | isFetching: true, 1812 | isRefetching: false, 1813 | isLoadingError: false, 1814 | isPaused: false, 1815 | isPlaceholderData: false, 1816 | isRefetchError: false, 1817 | isStale: true, 1818 | } satisfies Partial>); 1819 | 1820 | const query2 = new QueryMock({ 1821 | queryFn: async () => { 1822 | await waitAsync(5); 1823 | return 'foo'; 1824 | }, 1825 | queryKey: ['test', 'key'] as const, 1826 | }); 1827 | 1828 | await waitAsync(10); 1829 | 1830 | expect(query.result).toMatchObject({ 1831 | status: 'pending', 1832 | fetchStatus: 'fetching', 1833 | isPending: true, 1834 | isSuccess: false, 1835 | isError: false, 1836 | isInitialLoading: true, 1837 | isLoading: true, 1838 | data: undefined, 1839 | dataUpdatedAt: 0, 1840 | error: null, 1841 | errorUpdatedAt: 0, 1842 | failureCount: 0, 1843 | failureReason: null, 1844 | errorUpdateCount: 0, 1845 | isFetched: false, 1846 | isFetchedAfterMount: false, 1847 | isFetching: true, 1848 | isRefetching: false, 1849 | isLoadingError: false, 1850 | isPaused: false, 1851 | isPlaceholderData: false, 1852 | isRefetchError: false, 1853 | isStale: true, 1854 | } satisfies Partial>); 1855 | 1856 | expect(query2.result).toMatchObject({ 1857 | status: 'success', 1858 | fetchStatus: 'idle', 1859 | isPending: false, 1860 | isSuccess: true, 1861 | isError: false, 1862 | isInitialLoading: false, 1863 | isLoading: false, 1864 | data: 'foo', 1865 | dataUpdatedAt: query2.result.dataUpdatedAt, 1866 | error: null, 1867 | errorUpdatedAt: 0, 1868 | failureCount: 0, 1869 | failureReason: null, 1870 | errorUpdateCount: 0, 1871 | isFetched: true, 1872 | isFetchedAfterMount: true, 1873 | isFetching: false, 1874 | isRefetching: false, 1875 | }); 1876 | }); 1877 | 1878 | it('after aborted Query with failed queryFn - create new Query with the same key and it should has succeed execution', async () => { 1879 | vi.useFakeTimers(); 1880 | const box = observable.box('bar'); 1881 | 1882 | const badResponse = new HttpResponse( 1883 | undefined, 1884 | { 1885 | description: 'not found', 1886 | errorCode: '404', 1887 | }, 1888 | { 1889 | status: 404, 1890 | statusText: 'Not Found', 1891 | }, 1892 | ); 1893 | 1894 | const okResponse = new HttpResponse( 1895 | { 1896 | fooBars: [1, 2, 3], 1897 | }, 1898 | undefined, 1899 | { 1900 | status: 200, 1901 | statusText: 'OK', 1902 | }, 1903 | ); 1904 | 1905 | const queryClient = new QueryClient({ 1906 | defaultOptions: { 1907 | queries: { 1908 | throwOnError: true, 1909 | queryKeyHashFn: hashKey, 1910 | refetchOnWindowFocus: 'always', 1911 | refetchOnReconnect: 'always', 1912 | staleTime: 5 * 60 * 1000, 1913 | retry: (failureCount, error) => { 1914 | if (error instanceof Response && error.status >= 500) { 1915 | return 3 - failureCount > 0; 1916 | } 1917 | return false; 1918 | }, 1919 | }, 1920 | mutations: { 1921 | throwOnError: true, 1922 | }, 1923 | }, 1924 | }); 1925 | 1926 | queryClient.mount(); 1927 | 1928 | const vmAbortController = new AbortController(); 1929 | 1930 | const fetch = createMockFetch(); 1931 | 1932 | const query = new QueryMock( 1933 | { 1934 | abortSignal: vmAbortController.signal, 1935 | queryFn: () => 1936 | fetch({ 1937 | willReturn: badResponse, 1938 | }), 1939 | options: () => ({ 1940 | queryKey: ['foo', box.get(), 'baz'] as const, 1941 | enabled: !!box.get(), 1942 | }), 1943 | }, 1944 | queryClient, 1945 | ); 1946 | 1947 | await vi.runAllTimersAsync(); 1948 | 1949 | expect(query.result).toMatchObject({ 1950 | data: undefined, 1951 | dataUpdatedAt: 0, 1952 | errorUpdateCount: 1, 1953 | error: badResponse, 1954 | failureCount: 1, 1955 | failureReason: badResponse, 1956 | fetchStatus: 'idle', 1957 | isError: true, 1958 | isFetched: true, 1959 | isStale: true, 1960 | isSuccess: false, 1961 | isPending: false, 1962 | } satisfies Partial>); 1963 | expect(query.options).toMatchObject({ 1964 | enabled: true, 1965 | }); 1966 | 1967 | queryClient.invalidateQueries({ 1968 | queryKey: ['foo'], 1969 | }); 1970 | vmAbortController.abort(); 1971 | 1972 | await vi.runAllTimersAsync(); 1973 | 1974 | expect(query.result).toMatchObject({ 1975 | data: undefined, 1976 | dataUpdatedAt: 0, 1977 | error: null, 1978 | errorUpdateCount: 1, 1979 | failureCount: 0, 1980 | failureReason: null, 1981 | fetchStatus: 'fetching', 1982 | isError: false, 1983 | isFetched: true, 1984 | isStale: true, 1985 | isSuccess: false, 1986 | isPending: true, 1987 | } satisfies Partial>); 1988 | 1989 | const vmAbortController2 = new AbortController(); 1990 | 1991 | const query2 = new QueryMock( 1992 | { 1993 | abortSignal: vmAbortController2.signal, 1994 | queryFn: () => { 1995 | return fetch({ 1996 | willReturn: okResponse, 1997 | }); 1998 | }, 1999 | options: () => ({ 2000 | queryKey: ['foo', 'bar', 'baz'] as const, 2001 | enabled: true, 2002 | }), 2003 | }, 2004 | queryClient, 2005 | ); 2006 | 2007 | await vi.runAllTimersAsync(); 2008 | 2009 | expect(query2.result).toMatchObject({ 2010 | data: okResponse, 2011 | isError: false, 2012 | isFetched: true, 2013 | isStale: true, 2014 | isSuccess: true, 2015 | isPending: false, 2016 | } satisfies Partial>); 2017 | }); 2018 | 2019 | it('after aborted Query with failed queryFn - create new Query with the same key and it should has succeed execution (+ abort signal usage inside query fn)', async () => { 2020 | vi.useFakeTimers(); 2021 | const box = observable.box('bar'); 2022 | 2023 | const badResponse = new HttpResponse( 2024 | undefined, 2025 | { 2026 | description: 'not found', 2027 | errorCode: '404', 2028 | }, 2029 | { 2030 | status: 404, 2031 | statusText: 'Not Found', 2032 | }, 2033 | ); 2034 | 2035 | const okResponse = new HttpResponse( 2036 | { 2037 | fooBars: [1, 2, 3], 2038 | }, 2039 | undefined, 2040 | { 2041 | status: 200, 2042 | statusText: 'OK', 2043 | }, 2044 | ); 2045 | 2046 | const queryClient = new QueryClient({ 2047 | defaultOptions: { 2048 | queries: { 2049 | throwOnError: true, 2050 | queryKeyHashFn: hashKey, 2051 | refetchOnWindowFocus: 'always', 2052 | refetchOnReconnect: 'always', 2053 | staleTime: 5 * 60 * 1000, 2054 | retry: (failureCount, error) => { 2055 | if (error instanceof Response && error.status >= 500) { 2056 | return 3 - failureCount > 0; 2057 | } 2058 | return false; 2059 | }, 2060 | }, 2061 | mutations: { 2062 | throwOnError: true, 2063 | }, 2064 | }, 2065 | }); 2066 | 2067 | queryClient.mount(); 2068 | 2069 | const vmAbortController = new AbortController(); 2070 | 2071 | const fetch = createMockFetch(); 2072 | 2073 | const query = new QueryMock( 2074 | { 2075 | abortSignal: vmAbortController.signal, 2076 | queryFn: ({ signal }) => 2077 | fetch({ 2078 | willReturn: badResponse, 2079 | signal, 2080 | }), 2081 | options: () => ({ 2082 | queryKey: ['foo', box.get(), 'baz'] as const, 2083 | enabled: !!box.get(), 2084 | }), 2085 | }, 2086 | queryClient, 2087 | ); 2088 | 2089 | await vi.runAllTimersAsync(); 2090 | 2091 | expect(query.result).toMatchObject({ 2092 | data: undefined, 2093 | dataUpdatedAt: 0, 2094 | errorUpdateCount: 1, 2095 | error: badResponse, 2096 | failureCount: 1, 2097 | failureReason: badResponse, 2098 | fetchStatus: 'idle', 2099 | isError: true, 2100 | isFetched: true, 2101 | isStale: true, 2102 | isSuccess: false, 2103 | isPending: false, 2104 | } satisfies Partial>); 2105 | expect(query.options).toMatchObject({ 2106 | enabled: true, 2107 | }); 2108 | 2109 | queryClient.invalidateQueries({ 2110 | queryKey: ['foo'], 2111 | }); 2112 | vmAbortController.abort(); 2113 | 2114 | await vi.runAllTimersAsync(); 2115 | 2116 | expect(query.result).toMatchObject({ 2117 | data: undefined, 2118 | dataUpdatedAt: 0, 2119 | error: null, 2120 | errorUpdateCount: 1, 2121 | failureCount: 0, 2122 | failureReason: null, 2123 | fetchStatus: 'fetching', 2124 | isError: false, 2125 | isFetched: true, 2126 | isStale: true, 2127 | isSuccess: false, 2128 | isPending: true, 2129 | } satisfies Partial>); 2130 | 2131 | const vmAbortController2 = new AbortController(); 2132 | 2133 | const query2 = new QueryMock( 2134 | { 2135 | abortSignal: vmAbortController2.signal, 2136 | queryFn: ({ signal }) => { 2137 | return fetch({ 2138 | willReturn: okResponse, 2139 | signal, 2140 | }); 2141 | }, 2142 | options: () => ({ 2143 | queryKey: ['foo', 'bar', 'baz'] as const, 2144 | enabled: true, 2145 | }), 2146 | }, 2147 | queryClient, 2148 | ); 2149 | 2150 | await vi.runAllTimersAsync(); 2151 | 2152 | expect(query2.result).toMatchObject({ 2153 | data: okResponse, 2154 | isError: false, 2155 | isFetched: true, 2156 | isStale: true, 2157 | isSuccess: true, 2158 | isPending: false, 2159 | } satisfies Partial>); 2160 | }); 2161 | }); 2162 | 2163 | describe('throwOnError', () => { 2164 | it('should throw error (throwOnError: true in options)', async () => { 2165 | vi.useFakeTimers(); 2166 | const query = new QueryMock({ 2167 | throwOnError: true, 2168 | enabled: false, 2169 | queryFn: async () => { 2170 | throw new Error('QueryError'); 2171 | }, 2172 | }); 2173 | let error: Error | undefined; 2174 | 2175 | const promise = query.start().catch((error_) => { 2176 | error = error_; 2177 | }); 2178 | await vi.runAllTimersAsync(); 2179 | 2180 | await promise; 2181 | 2182 | expect(error?.message).toBe('QueryError'); 2183 | }); 2184 | 2185 | it('should throw error (updating param throwOnError true)', async () => { 2186 | vi.useFakeTimers(); 2187 | const query = new QueryMock({ 2188 | enabled: false, 2189 | queryFn: async () => { 2190 | throw new Error('QueryError'); 2191 | }, 2192 | }); 2193 | let error: Error | undefined; 2194 | 2195 | const promise = query.start({ throwOnError: true }).catch((error_) => { 2196 | error = error_; 2197 | }); 2198 | await vi.runAllTimersAsync(); 2199 | 2200 | await promise; 2201 | 2202 | expect(error?.message).toBe('QueryError'); 2203 | }); 2204 | 2205 | it('should throw error (throwOnError: true in global options)', async () => { 2206 | vi.useFakeTimers(); 2207 | const query = new QueryMock( 2208 | { 2209 | enabled: false, 2210 | queryFn: async () => { 2211 | throw new Error('QueryError'); 2212 | }, 2213 | }, 2214 | new QueryClient({ 2215 | defaultOptions: { 2216 | queries: { 2217 | throwOnError: true, 2218 | }, 2219 | }, 2220 | }), 2221 | ); 2222 | let error: Error | undefined; 2223 | 2224 | const promise = query.start().catch((error_) => { 2225 | error = error_; 2226 | }); 2227 | await vi.runAllTimersAsync(); 2228 | 2229 | await promise; 2230 | 2231 | expect(error?.message).toBe('QueryError'); 2232 | }); 2233 | }); 2234 | 2235 | it('select type bugfix (#12 issue)', async () => { 2236 | const data = [ 2237 | { 2238 | address: 'a1', 2239 | name: 'Foo', 2240 | }, 2241 | { 2242 | address: 'b1', 2243 | name: 'Bar', 2244 | }, 2245 | ]; 2246 | 2247 | const queryWithSelect = new Query({ 2248 | queryClient: new QueryClient(), 2249 | queryKey: ['a'], 2250 | queryFn: () => data, 2251 | select: (data) => { 2252 | return new Map(data.map((item) => [item.address, item])); 2253 | }, 2254 | }); 2255 | 2256 | await when(() => !queryWithSelect.result.isLoading); 2257 | 2258 | expectTypeOf(queryWithSelect.result.data).toEqualTypeOf< 2259 | undefined | Map 2260 | >(); 2261 | expect(queryWithSelect.result.data).toBeDefined(); 2262 | }); 2263 | }); 2264 | -------------------------------------------------------------------------------- /src/query.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DefaultError, 3 | hashKey, 4 | QueryKey, 5 | QueryObserver, 6 | QueryObserverResult, 7 | RefetchOptions, 8 | SetDataOptions, 9 | Updater, 10 | } from '@tanstack/query-core'; 11 | import { LinkedAbortController } from 'linked-abort-controller'; 12 | import { 13 | action, 14 | makeObservable, 15 | observable, 16 | reaction, 17 | runInAction, 18 | } from 'mobx'; 19 | 20 | import { QueryClient } from './query-client'; 21 | import { AnyQueryClient, QueryClientHooks } from './query-client.types'; 22 | import { QueryOptionsParams } from './query-options'; 23 | import { 24 | QueryConfig, 25 | QueryDynamicOptions, 26 | QueryInvalidateParams, 27 | QueryOptions, 28 | QueryResetParams, 29 | QueryStartParams, 30 | QueryUpdateOptions, 31 | } from './query.types'; 32 | 33 | export class Query< 34 | TQueryFnData = unknown, 35 | TError = DefaultError, 36 | TData = TQueryFnData, 37 | TQueryData = TQueryFnData, 38 | TQueryKey extends QueryKey = QueryKey, 39 | > implements Disposable 40 | { 41 | protected abortController: AbortController; 42 | protected queryClient: AnyQueryClient; 43 | 44 | protected _result: QueryObserverResult; 45 | 46 | options: QueryOptions; 47 | queryObserver: QueryObserver< 48 | TQueryFnData, 49 | TError, 50 | TData, 51 | TQueryData, 52 | TQueryKey 53 | >; 54 | 55 | isResultRequsted: boolean; 56 | 57 | private isEnabledOnResultDemand: boolean; 58 | 59 | /** 60 | * This parameter is responsible for holding the enabled value, 61 | * in cases where the "enableOnDemand" option is enabled 62 | */ 63 | private holdedEnabledOption: QueryOptions< 64 | TQueryFnData, 65 | TError, 66 | TData, 67 | TQueryData, 68 | TQueryKey 69 | >['enabled']; 70 | private _observerSubscription?: VoidFunction; 71 | private hooks?: QueryClientHooks; 72 | 73 | protected config: QueryConfig< 74 | TQueryFnData, 75 | TError, 76 | TData, 77 | TQueryData, 78 | TQueryKey 79 | >; 80 | 81 | constructor( 82 | config: QueryConfig, 83 | ); 84 | constructor( 85 | queryClient: AnyQueryClient, 86 | config: () => QueryOptionsParams< 87 | TQueryFnData, 88 | TError, 89 | TData, 90 | TQueryData, 91 | TQueryKey 92 | >, 93 | ); 94 | 95 | constructor(...args: any[]) { 96 | let queryClient: AnyQueryClient; 97 | let config: QueryOptionsParams< 98 | TQueryFnData, 99 | TError, 100 | TData, 101 | TQueryData, 102 | TQueryKey 103 | >; 104 | let getDynamicOptions: 105 | | QueryConfig< 106 | TQueryFnData, 107 | TError, 108 | TData, 109 | TQueryData, 110 | TQueryKey 111 | >['options'] 112 | | undefined; 113 | 114 | if (args.length === 2) { 115 | queryClient = args[0]; 116 | config = args[1](); 117 | getDynamicOptions = args[1]; 118 | } else { 119 | queryClient = args[0].queryClient; 120 | config = args[0]; 121 | getDynamicOptions = args[0].options; 122 | } 123 | 124 | const { queryKey: queryKeyOrDynamicQueryKey, ...restOptions } = config; 125 | 126 | this.config = { 127 | ...config, 128 | queryClient, 129 | }; 130 | 131 | this.abortController = new LinkedAbortController(config.abortSignal); 132 | this.queryClient = queryClient; 133 | this._result = undefined as any; 134 | this.isResultRequsted = false; 135 | this.isEnabledOnResultDemand = config.enableOnDemand ?? false; 136 | this.hooks = 137 | 'hooks' in this.queryClient ? this.queryClient.hooks : undefined; 138 | 139 | if ('queryFeatures' in queryClient && config.enableOnDemand == null) { 140 | this.isEnabledOnResultDemand = 141 | queryClient.queryFeatures.enableOnDemand ?? false; 142 | } 143 | 144 | observable.deep(this, '_result'); 145 | observable.ref(this, 'isResultRequsted'); 146 | action.bound(this, 'setData'); 147 | action.bound(this, 'update'); 148 | action.bound(this, 'updateResult'); 149 | 150 | makeObservable(this); 151 | 152 | this.options = this.queryClient.defaultQueryOptions({ 153 | ...restOptions, 154 | ...getDynamicOptions?.(this), 155 | } as any); 156 | 157 | this.options.structuralSharing = this.options.structuralSharing ?? false; 158 | 159 | this.processOptions(this.options); 160 | 161 | if (typeof queryKeyOrDynamicQueryKey === 'function') { 162 | this.options.queryKey = queryKeyOrDynamicQueryKey(); 163 | 164 | reaction( 165 | () => queryKeyOrDynamicQueryKey(), 166 | (queryKey) => { 167 | this.update({ 168 | queryKey, 169 | }); 170 | }, 171 | { 172 | signal: this.abortController.signal, 173 | }, 174 | ); 175 | } else { 176 | this.options.queryKey = 177 | queryKeyOrDynamicQueryKey ?? this.options.queryKey ?? []; 178 | } 179 | 180 | // Tracking props visit should be done in MobX, by default. 181 | this.options.notifyOnChangeProps = 182 | restOptions.notifyOnChangeProps ?? 183 | queryClient.getDefaultOptions().queries?.notifyOnChangeProps ?? 184 | 'all'; 185 | 186 | this.queryObserver = new QueryObserver< 187 | TQueryFnData, 188 | TError, 189 | TData, 190 | TQueryData, 191 | TQueryKey 192 | >(queryClient as QueryClient, this.options); 193 | 194 | this.updateResult(this.queryObserver.getOptimisticResult(this.options)); 195 | 196 | this._observerSubscription = this.queryObserver.subscribe( 197 | this.updateResult, 198 | ); 199 | 200 | if (getDynamicOptions) { 201 | reaction(() => getDynamicOptions(this), this.update, { 202 | signal: this.abortController.signal, 203 | }); 204 | } 205 | 206 | if (this.isEnabledOnResultDemand) { 207 | reaction( 208 | () => this.isResultRequsted, 209 | (isRequested) => { 210 | if (isRequested) { 211 | this.update(getDynamicOptions?.(this) ?? {}); 212 | } 213 | }, 214 | { 215 | signal: this.abortController.signal, 216 | fireImmediately: true, 217 | }, 218 | ); 219 | } 220 | 221 | if (config.onDone) { 222 | this.onDone(config.onDone); 223 | } 224 | if (config.onError) { 225 | this.onError(config.onError); 226 | } 227 | 228 | this.abortController.signal.addEventListener('abort', this.handleAbort); 229 | 230 | this.config.onInit?.(this); 231 | this.hooks?.onQueryInit?.(this); 232 | } 233 | 234 | async refetch(options?: RefetchOptions) { 235 | const result = await this.queryObserver.refetch(options); 236 | const query = this.queryObserver.getCurrentQuery(); 237 | 238 | if ( 239 | query.state.error && 240 | (options?.throwOnError || 241 | this.options.throwOnError === true || 242 | (typeof this.options.throwOnError === 'function' && 243 | this.options.throwOnError(query.state.error, query))) 244 | ) { 245 | throw query.state.error; 246 | } 247 | 248 | return result; 249 | } 250 | 251 | protected createQueryHash( 252 | queryKey: any, 253 | options: QueryOptions, 254 | ) { 255 | if (options.queryKeyHashFn) { 256 | return options.queryKeyHashFn(queryKey); 257 | } 258 | 259 | return hashKey(queryKey); 260 | } 261 | 262 | setData( 263 | updater: Updater< 264 | NoInfer | undefined, 265 | NoInfer | undefined 266 | >, 267 | options?: SetDataOptions, 268 | ) { 269 | return this.queryClient.setQueryData( 270 | this.options.queryKey, 271 | updater, 272 | options, 273 | ); 274 | } 275 | 276 | update( 277 | optionsUpdate: 278 | | Partial< 279 | QueryOptions 280 | > 281 | | QueryUpdateOptions 282 | | QueryDynamicOptions, 283 | ) { 284 | if (this.abortController.signal.aborted) { 285 | return; 286 | } 287 | 288 | const nextOptions = { 289 | ...this.options, 290 | ...optionsUpdate, 291 | }; 292 | 293 | this.processOptions(nextOptions); 294 | 295 | this.options = nextOptions; 296 | 297 | this.queryObserver.setOptions(this.options); 298 | } 299 | 300 | private isEnableHolded = false; 301 | 302 | private enableHolder = () => false; 303 | 304 | private processOptions = ( 305 | options: QueryOptions, 306 | ) => { 307 | options.queryHash = this.createQueryHash(options.queryKey, options); 308 | 309 | // If the on-demand query mode is enabled (when using the result property) 310 | // then, if the user does not request the result, the queries should not be executed 311 | // to do this, we hold the original value of the enabled option 312 | // and set enabled to false until the user requests the result (this.isResultRequsted) 313 | if (this.isEnabledOnResultDemand) { 314 | if (this.isEnableHolded && options.enabled !== this.enableHolder) { 315 | this.holdedEnabledOption = options.enabled; 316 | } 317 | 318 | if (this.isResultRequsted) { 319 | if (this.isEnableHolded) { 320 | options.enabled = 321 | this.holdedEnabledOption === this.enableHolder 322 | ? undefined 323 | : this.holdedEnabledOption; 324 | this.isEnableHolded = false; 325 | } 326 | } else { 327 | this.isEnableHolded = true; 328 | this.holdedEnabledOption = options.enabled; 329 | options.enabled = this.enableHolder; 330 | } 331 | } 332 | }; 333 | 334 | public get result() { 335 | if (!this.isResultRequsted) { 336 | runInAction(() => { 337 | this.isResultRequsted = true; 338 | }); 339 | } 340 | return this._result; 341 | } 342 | 343 | /** 344 | * Modify this result so it matches the tanstack query result. 345 | */ 346 | private updateResult(result: QueryObserverResult) { 347 | this._result = result; 348 | } 349 | 350 | async reset(params?: QueryResetParams) { 351 | return await this.queryClient.resetQueries({ 352 | queryKey: this.options.queryKey, 353 | exact: true, 354 | ...params, 355 | } as any); 356 | } 357 | 358 | async invalidate(params?: QueryInvalidateParams) { 359 | return await this.queryClient.invalidateQueries({ 360 | exact: true, 361 | queryKey: this.options.queryKey, 362 | ...params, 363 | } as any); 364 | } 365 | 366 | onDone(onDoneCallback: (data: TData, payload: void) => void): void { 367 | reaction( 368 | () => { 369 | const { error, isSuccess, fetchStatus } = this._result; 370 | return isSuccess && !error && fetchStatus === 'idle'; 371 | }, 372 | (isDone) => { 373 | if (isDone) { 374 | onDoneCallback(this._result.data!, void 0); 375 | } 376 | }, 377 | { 378 | signal: this.abortController.signal, 379 | }, 380 | ); 381 | } 382 | 383 | onError(onErrorCallback: (error: TError, payload: void) => void): void { 384 | reaction( 385 | () => this._result.error, 386 | (error) => { 387 | if (error) { 388 | onErrorCallback(error, void 0); 389 | } 390 | }, 391 | { 392 | signal: this.abortController.signal, 393 | }, 394 | ); 395 | } 396 | 397 | protected handleAbort = () => { 398 | this._observerSubscription?.(); 399 | 400 | this.queryObserver.destroy(); 401 | this.isResultRequsted = false; 402 | 403 | let isNeedToReset = 404 | this.config.resetOnDestroy || this.config.resetOnDispose; 405 | 406 | if (this.queryClient instanceof QueryClient && !isNeedToReset) { 407 | isNeedToReset = 408 | this.queryClient.queryFeatures.resetOnDestroy || 409 | this.queryClient.queryFeatures.resetOnDispose; 410 | } 411 | 412 | if (isNeedToReset) { 413 | this.reset(); 414 | } 415 | 416 | delete this._observerSubscription; 417 | 418 | this.hooks?.onQueryDestroy?.(this); 419 | }; 420 | 421 | destroy() { 422 | this.abortController.abort(); 423 | } 424 | 425 | async start({ 426 | cancelRefetch, 427 | ...params 428 | }: QueryStartParams< 429 | TQueryFnData, 430 | TError, 431 | TData, 432 | TQueryData, 433 | TQueryKey 434 | > = {}) { 435 | this.update({ ...params }); 436 | 437 | return await this.refetch({ cancelRefetch }); 438 | } 439 | 440 | /** 441 | * @deprecated use `destroy`. This method will be removed in next major release 442 | */ 443 | dispose() { 444 | this.destroy(); 445 | } 446 | 447 | [Symbol.dispose](): void { 448 | this.destroy(); 449 | } 450 | 451 | // Firefox fix (Symbol.dispose is undefined in FF) 452 | [Symbol.for('Symbol.dispose')](): void { 453 | this.destroy(); 454 | } 455 | } 456 | 457 | /** 458 | * @remarks ⚠️ use `Query`. This export will be removed in next major release 459 | */ 460 | export const MobxQuery = Query; 461 | -------------------------------------------------------------------------------- /src/query.types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DefaultedQueryObserverOptions, 3 | DefaultError, 4 | InvalidateQueryFilters, 5 | QueryFilters, 6 | QueryKey, 7 | QueryObserverOptions, 8 | RefetchOptions, 9 | } from '@tanstack/query-core'; 10 | 11 | import type { Query } from './query'; 12 | import { AnyQueryClient } from './query-client.types'; 13 | 14 | export interface QueryInvalidateParams 15 | extends Partial> {} 16 | 17 | /** 18 | * @remarks ⚠️ use `QueryInvalidateParams`. This type will be removed in next major release 19 | */ 20 | export type MobxQueryInvalidateParams = QueryInvalidateParams; 21 | 22 | export interface QueryResetParams 23 | extends Partial> {} 24 | 25 | /** 26 | * @remarks ⚠️ use `QueryResetParams`. This type will be removed in next major release 27 | */ 28 | export type MobxQueryResetParams = QueryResetParams; 29 | 30 | export interface QueryDynamicOptions< 31 | TQueryFnData = unknown, 32 | TError = DefaultError, 33 | TData = TQueryFnData, 34 | TQueryData = TQueryFnData, 35 | TQueryKey extends QueryKey = QueryKey, 36 | > extends Partial< 37 | Omit< 38 | QueryObserverOptions, 39 | 'queryFn' | 'enabled' | 'queryKeyHashFn' 40 | > 41 | > { 42 | enabled?: boolean; 43 | } 44 | 45 | /** 46 | * @remarks ⚠️ use `QueryDynamicOptions`. This type will be removed in next major released 47 | */ 48 | export type MobxQueryDynamicOptions< 49 | TQueryFnData = unknown, 50 | TError = DefaultError, 51 | TData = TQueryFnData, 52 | TQueryData = TQueryFnData, 53 | TQueryKey extends QueryKey = QueryKey, 54 | > = QueryDynamicOptions; 55 | 56 | export interface QueryOptions< 57 | TQueryFnData = unknown, 58 | TError = DefaultError, 59 | TData = TQueryFnData, 60 | TQueryData = TQueryFnData, 61 | TQueryKey extends QueryKey = QueryKey, 62 | > extends DefaultedQueryObserverOptions< 63 | TQueryFnData, 64 | TError, 65 | TData, 66 | TQueryData, 67 | TQueryKey 68 | > {} 69 | 70 | /** 71 | * @remarks ⚠️ use `QueryOptions`. This type will be removed in next major release 72 | */ 73 | export type MobxQueryOptions< 74 | TQueryFnData = unknown, 75 | TError = DefaultError, 76 | TData = TQueryFnData, 77 | TQueryData = TQueryFnData, 78 | TQueryKey extends QueryKey = QueryKey, 79 | > = QueryOptions; 80 | 81 | export type QueryUpdateOptions< 82 | TQueryFnData = unknown, 83 | TError = DefaultError, 84 | TData = TQueryFnData, 85 | TQueryData = TQueryFnData, 86 | TQueryKey extends QueryKey = QueryKey, 87 | > = Partial< 88 | QueryObserverOptions 89 | >; 90 | 91 | /** 92 | * @remarks ⚠️ use `QueryUpdateOptions`. This type will be removed in next major release 93 | */ 94 | export type MobxQueryUpdateOptions< 95 | TQueryFnData = unknown, 96 | TError = DefaultError, 97 | TData = TQueryFnData, 98 | TQueryData = TQueryFnData, 99 | TQueryKey extends QueryKey = QueryKey, 100 | > = QueryUpdateOptions; 101 | 102 | export interface QueryFeatures { 103 | /** 104 | * Reset query when dispose is called 105 | * 106 | * @deprecated Please use 'resetOnDestroy'. This type will be removed in next major release 107 | */ 108 | resetOnDispose?: boolean; 109 | 110 | /** 111 | * Reset query when destroy or abort signal is called 112 | */ 113 | resetOnDestroy?: boolean; 114 | 115 | /** 116 | * Enable query only if result is requested 117 | */ 118 | enableOnDemand?: boolean; 119 | } 120 | 121 | /** 122 | * @remarks ⚠️ use `QueryFeatures`. This type will be removed in next major release 123 | */ 124 | export type MobxQueryFeatures = QueryFeatures; 125 | 126 | export type QueryConfigFromFn< 127 | TFunction extends (...args: any[]) => any, 128 | TError = DefaultError, 129 | TQueryKey extends QueryKey = QueryKey, 130 | > = QueryConfig< 131 | ReturnType extends Promise 132 | ? TData 133 | : ReturnType, 134 | TError, 135 | TQueryKey 136 | >; 137 | 138 | /** 139 | * @remarks ⚠️ use `QueryConfigFromFn`. This type will be removed in next major release 140 | */ 141 | export type MobxQueryConfigFromFn< 142 | TFunction extends (...args: any[]) => any, 143 | TError = DefaultError, 144 | TQueryKey extends QueryKey = QueryKey, 145 | > = QueryConfigFromFn; 146 | 147 | export interface QueryConfig< 148 | TQueryFnData = unknown, 149 | TError = DefaultError, 150 | TData = TQueryFnData, 151 | TQueryData = TQueryFnData, 152 | TQueryKey extends QueryKey = QueryKey, 153 | > extends Partial< 154 | Omit< 155 | QueryObserverOptions< 156 | TQueryFnData, 157 | TError, 158 | TData, 159 | TQueryData, 160 | TQueryKey 161 | >, 162 | 'queryKey' 163 | > 164 | >, 165 | QueryFeatures { 166 | queryClient: AnyQueryClient; 167 | /** 168 | * TanStack Query manages query caching for you based on query keys. 169 | * Query keys have to be an Array at the top level, and can be as simple as an Array with a single string, or as complex as an array of many strings and nested objects. 170 | * As long as the query key is serializable, and unique to the query's data, you can use it! 171 | * 172 | * **Important:** If you define it as a function then it will be reactively updates query origin key every time 173 | * when observable values inside the function changes 174 | * 175 | * @link https://tanstack.com/query/v4/docs/framework/react/guides/query-keys#simple-query-keys 176 | */ 177 | queryKey?: TQueryKey | (() => TQueryKey); 178 | onInit?: ( 179 | query: Query, 180 | ) => void; 181 | abortSignal?: AbortSignal; 182 | onDone?: (data: TData, payload: void) => void; 183 | onError?: (error: TError, payload: void) => void; 184 | /** 185 | * Dynamic query parameters, when result of this function changed query will be updated 186 | * (reaction -> setOptions) 187 | */ 188 | options?: ( 189 | query: NoInfer< 190 | Query< 191 | NoInfer, 192 | NoInfer, 193 | NoInfer, 194 | NoInfer, 195 | NoInfer 196 | > 197 | >, 198 | ) => QueryDynamicOptions; 199 | } 200 | 201 | /** 202 | * @remarks ⚠️ use `QueryConfig`. This type will be removed in next major release 203 | */ 204 | export type MobxQueryConfig< 205 | TQueryFnData = unknown, 206 | TError = DefaultError, 207 | TData = TQueryFnData, 208 | TQueryData = TQueryFnData, 209 | TQueryKey extends QueryKey = QueryKey, 210 | > = QueryConfig; 211 | 212 | export type QueryFn< 213 | TQueryFnData = unknown, 214 | TError = DefaultError, 215 | TData = TQueryFnData, 216 | TQueryData = TQueryFnData, 217 | TQueryKey extends QueryKey = QueryKey, 218 | > = Exclude< 219 | QueryConfig['queryFn'], 220 | undefined 221 | >; 222 | 223 | /** 224 | * @remarks ⚠️ use `QueryFn`. This type will be removed in next major release 225 | */ 226 | export type MobxQueryFn< 227 | TQueryFnData = unknown, 228 | TError = DefaultError, 229 | TData = TQueryFnData, 230 | TQueryData = TQueryFnData, 231 | TQueryKey extends QueryKey = QueryKey, 232 | > = QueryFn; 233 | 234 | export type AnyQuery = Query; 235 | 236 | /** 237 | * @remarks ⚠️ use `AnyQuery`. This type will be removed in next major release 238 | */ 239 | export type AnyMobxQuery = AnyQuery; 240 | 241 | export interface QueryStartParams< 242 | TQueryFnData = unknown, 243 | TError = DefaultError, 244 | TData = TQueryFnData, 245 | TQueryData = TQueryFnData, 246 | TQueryKey extends QueryKey = QueryKey, 247 | > extends QueryUpdateOptions< 248 | TQueryFnData, 249 | TError, 250 | TData, 251 | TQueryData, 252 | TQueryKey 253 | >, 254 | Pick {} 255 | 256 | /** 257 | * @remarks ⚠️ use `QueryStartParams`. This type will be removed in next major release 258 | */ 259 | export type MobxQueryStartParams< 260 | TQueryFnData = unknown, 261 | TError = DefaultError, 262 | TData = TQueryFnData, 263 | TQueryData = TQueryFnData, 264 | TQueryKey extends QueryKey = QueryKey, 265 | > = QueryStartParams; 266 | 267 | export type InferQuery< 268 | T extends QueryConfig | Query, 269 | TInferValue extends 'data' | 'key' | 'error' | 'query' | 'config', 270 | > = 271 | T extends QueryConfig< 272 | infer TQueryFnData, 273 | infer TError, 274 | infer TData, 275 | infer TQueryData, 276 | infer TQueryKey 277 | > 278 | ? TInferValue extends 'config' 279 | ? T 280 | : TInferValue extends 'data' 281 | ? TData 282 | : TInferValue extends 'key' 283 | ? TQueryKey 284 | : TInferValue extends 'error' 285 | ? TError 286 | : TInferValue extends 'query' 287 | ? Query 288 | : never 289 | : T extends Query< 290 | infer TQueryFnData, 291 | infer TError, 292 | infer TData, 293 | infer TQueryData, 294 | infer TQueryKey 295 | > 296 | ? TInferValue extends 'config' 297 | ? QueryConfig 298 | : TInferValue extends 'data' 299 | ? TData 300 | : TInferValue extends 'key' 301 | ? TQueryKey 302 | : TInferValue extends 'error' 303 | ? TError 304 | : TInferValue extends 'query' 305 | ? T 306 | : never 307 | : never; 308 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "types": [], 4 | "target": "ESNext", 5 | "outDir": "dist", 6 | "declaration": true, 7 | "declarationMap": true, 8 | "useDefineForClassFields": true, 9 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 10 | "allowJs": false, 11 | "skipLibCheck": true, 12 | "esModuleInterop": false, 13 | "allowSyntheticDefaultImports": true, 14 | "strict": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "module": "ESNext", 17 | "moduleResolution": "Node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "typeRoots": [ 21 | "./node_modules/@types/", "./types", "./node_modules" 22 | ], 23 | "noEmit": false 24 | }, 25 | "include": [ 26 | "src" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "types": ["vitest"], 4 | "target": "ESNext", 5 | "outDir": "dist", 6 | "declaration": true, 7 | "declarationMap": true, 8 | "useDefineForClassFields": true, 9 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 10 | "allowJs": false, 11 | "skipLibCheck": true, 12 | "esModuleInterop": false, 13 | "allowSyntheticDefaultImports": true, 14 | "strict": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "module": "ESNext", 17 | "moduleResolution": "Node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "typeRoots": [ 21 | "./node_modules/@types/", "./types", "./node_modules" 22 | ], 23 | "noEmit": false, 24 | "jsx": "react-jsx" 25 | }, 26 | "include": [ 27 | "src/**/*.test.ts", 28 | "src/**/*.test.tsx", 29 | "node_modules" 30 | ], 31 | "exclude": [] 32 | } 33 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | import react from "@vitejs/plugin-react-swc"; 3 | 4 | 5 | export default defineConfig({ 6 | plugins: [react()], 7 | test: { 8 | globals: true, 9 | environment: "jsdom", 10 | coverage: { 11 | provider: 'istanbul', // or 'v8' 12 | include: ['src'], 13 | exclude: ['src/preset'], 14 | reporter: [ 15 | 'text', 16 | 'text-summary', 17 | 'html' 18 | ], 19 | reportsDirectory: './coverage' 20 | }, 21 | }, 22 | }); --------------------------------------------------------------------------------