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 |
--------------------------------------------------------------------------------
/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 | });
--------------------------------------------------------------------------------