diff --git a/.changeset/wet-turtles-relax.md b/.changeset/wet-turtles-relax.md new file mode 100644 index 0000000..eefa8db --- /dev/null +++ b/.changeset/wet-turtles-relax.md @@ -0,0 +1,5 @@ +--- +"mobx-tanstack-query": minor +--- + +added fetch queries new features (FetchQuery, createFetchQuery) diff --git a/.github/workflows/version-or-publish.yml b/.github/workflows/version-or-publish.yml index b1962da..c9b2f87 100644 --- a/.github/workflows/version-or-publish.yml +++ b/.github/workflows/version-or-publish.yml @@ -21,12 +21,15 @@ jobs: steps: - name: Checkout tree uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Set-up Node.js uses: actions/setup-node@v4 with: check-latest: true node-version-file: .nvmrc + registry-url: https://registry.npmjs.org - run: corepack enable - run: pnpm i @@ -43,6 +46,8 @@ jobs: echo "last_tag=$tag" >> $GITHUB_OUTPUT fi + - run: npm install -g npm@latest + - name: Create Release Pull Request uses: changesets/action@v1 continue-on-error: false @@ -50,8 +55,7 @@ jobs: with: version: pnpm changeset version publish: pnpm pub + createGithubReleases: false env: CI: true GITHUB_TOKEN: ${{ github.token }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - NPM_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index af26bea..2cfb823 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -33,8 +33,10 @@ export default defineDocsVitepressConfig(configs, { link: "/api/Query", items: [ { text: "Query", link: "/api/Query" }, + { text: "FetchQuery", link: "/api/FetchQuery" }, { text: "Mutation", link: "/api/Mutation" }, { text: "InfiniteQuery", link: "/api/InfiniteQuery" }, + { text: "FetchInfiniteQuery", link: "/api/FetchInfiniteQuery" }, { text: "QueryClient", link: "/api/QueryClient" }, { text: "Other", link: "/api/other" }, ], @@ -45,11 +47,21 @@ export default defineDocsVitepressConfig(configs, { items: [ { text: "Overview", link: "/preset" }, { text: "createQuery", link: "/preset/createQuery" }, + { text: "createFetchQuery", link: "/preset/createFetchQuery" }, { text: "createMutation", link: "/preset/createMutation" }, { text: "createInfiniteQuery", link: "/preset/createInfiniteQuery" }, + { text: "createFetchInfiniteQuery", link: "/preset/createFetchInfiniteQuery" }, { text: "queryClient", link: "/preset/queryClient" }, ], }, + { + text: "Errors 🚨", + link: "/errors/1", + items: [ + { text: "#1: Fetch query is not configured", link: "/errors/1" }, + { text: "#2: Fetch infinite query is not configured", link: "/errors/2" }, + ], + }, { text: "Other", items: [ diff --git a/docs/api/FetchInfiniteQuery.md b/docs/api/FetchInfiniteQuery.md new file mode 100644 index 0000000..cd33d67 --- /dev/null +++ b/docs/api/FetchInfiniteQuery.md @@ -0,0 +1,67 @@ +# FetchInfiniteQuery + +A [`FetchQuery`](/api/FetchQuery) for paginated data — same declarative `params` API, but with built-in page navigation. + +Like [`FetchQuery`](/api/FetchQuery), it auto-generates `queryKey` from `params` and handles the HTTP request for you. The difference is that `params` receives a `pageParam` argument, and you control pagination via `getNextPageParam`. + +**All properties and methods** (`data`, `isLoading`, `error`, `fetchNextPage`, `hasNextPage`, etc.) are inherited from [`InfiniteQuery`](/api/InfiniteQuery). + +[Reference to source code](/src/fetch-infinite-query.ts) + +## Usage + +```ts +const starsQuery = new FetchInfiniteQuery({ + queryClient, + params: ({ pageParam }) => ({ + path: '/stars', + query: { + count: 20, + page: pageParam, + }, + }), + initialPageParam: 1, + getNextPageParam: (lastPage, _, lastPageParam) => { + return lastPage.length ? lastPageParam + 1 : null; + }, +}); +``` + +### Manual control with `start()` + +Create a `FetchInfiniteQuery` without `params` (disabled by default), then fetch manually: + +```ts +const starsQuery = new FetchInfiniteQuery({ + queryClient, + initialPageParam: 1, + getNextPageParam: (lastPage, _, lastPageParam) => { + return lastPage.length ? lastPageParam + 1 : null; + }, +}); + +const result = await starsQuery.start({ + params: { path: '/stars', query: { count: 20 } }, +}); +``` + +### How it works + +1. `params` is a function that receives `{ pageParam }` — the current page parameter (starts at `initialPageParam`) +2. Each page request uses the same `params` logic with a different `pageParam` +3. After each page loads, `getNextPageParam` determines the next page parameter — return `null` to indicate no more pages +4. `data` is an array of all fetched pages, and `fetchNextPage()` triggers the next request + +### Example: cursor-based pagination + +```ts +const commentsQuery = new FetchInfiniteQuery({ + queryClient, + params: ({ pageParam }) => ({ + path: '/comments', + query: { cursor: pageParam }, + }), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => lastPage.nextCursor ?? null, +}); +``` diff --git a/docs/api/FetchQuery.md b/docs/api/FetchQuery.md new file mode 100644 index 0000000..e3935d8 --- /dev/null +++ b/docs/api/FetchQuery.md @@ -0,0 +1,142 @@ +# FetchQuery + +A [`Query`](/api/Query) that makes HTTP requests for you — no need to write `queryFn` or `queryKey` manually. + +Instead of describing **how** to fetch data, you describe **what** to fetch using `params`: + +```ts +// Query — you write the fetch logic yourself +new Query({ + queryClient, + queryKey: ['/pets'], + queryFn: () => fetch('/pets').then(r => r.json()), +}) + +// FetchQuery — just describe the request +new FetchQuery({ + queryClient, + params: { path: '/pets' }, +}) +``` + +`FetchQuery` auto-generates `queryKey` from your `params`, so the cache stays consistent automatically. + +**All properties and methods** (`data`, `isLoading`, `error`, `refetch`, etc.) are inherited from [`Query`](/api/Query). + +[Reference to source code](/src/fetch-query.ts) + +## Usage + +### Basic GET request + +```ts +const petsQuery = new FetchQuery({ + queryClient, + params: { + path: '/pets', + }, +}); + +console.log(petsQuery.data, petsQuery.isLoading); +``` + +### Dynamic params + +`params` can be a function — when MobX observables inside it change, the query automatically refetches with new parameters. Return a falsy value to disable the query. + +```ts +const petId = observable.box(); + +const petQuery = new FetchQuery({ + queryClient, + params: () => petId.get() + ? { path: `/pets/${petId.get()}` } + : null, // query is disabled while petId is empty +}); +``` + +### POST request with body + +```ts +const createPetQuery = new FetchQuery({ + queryClient, + params: () => ({ + path: '/pets', + method: 'POST', + body: { name: 'Fluffy', type: 'cat' }, + }), +}); +``` + +### Full fetch configuration + +```ts +const petQuery = new FetchQuery({ + queryClient, + params: { + baseUrl: 'https://api.example.com', + path: '/pets', + query: { limit: 10, sort: 'name' }, + method: 'GET', + headers: { 'X-Custom-Header': 'value' }, + notFoundAsNull: true, + }, +}); +``` + +## `FetchQueryParams` + +- `path: string` — **Required.** The URL path for the request +- `baseUrl?: string` — Base URL prepended to `path`. Can also be set globally via [`QueryClient.fetchQueries`](/api/QueryClient#fetchqueries) +- `query?: Record` — Query parameters to be serialized and appended to the URL +- `method?: string` — HTTP method. Defaults to `"GET"` (or `"POST"` when `body` is provided) +- `headers?: Record` — Request headers. Merged with global headers from `QueryClient.fetchQueries` +- `body?: BodyInit | Record` — Request body. Objects are JSON-serialized with `Content-Type: application/json`; native `BodyInit` (Blob, FormData, etc.) is passed through as-is +- `request?: Partial` — Additional `RequestInit` options (except `signal`, `method`, `headers`, `body`) +- `notFoundAsNull?: boolean` — If `true`, a 404 response returns `null` instead of throwing +- `responseType?: ResponseType` — Method to read the response body (e.g. `"json"`, `"text"`, `"blob"`). Defaults to `"json"` +- `transformResponse?: TransformResponseFn` — Custom function to transform the `Response` object before data is returned +- `throwOnError?: boolean` — Whether to throw the raw `Response` on non-ok status. Defaults to `true` +- `meta?: QueryMeta` — Metadata attached to the query + +## Manual control with `start()` + +Create a `FetchQuery` without `params` (disabled by default), then fetch manually: + +```ts +const petQuery = new FetchQuery({ + queryClient, + // no params — query is disabled +}); + +const result = await petQuery.start({ + params: { path: '/pets/123' }, +}); + +console.log(result.data); +``` + +## Request behavior + +- Per-request `params` are **merged** with global defaults from [`QueryClient.fetchQueries`](/api/QueryClient#fetchqueries) (per-request values take precedence) +- The full URL is built as `baseUrl + path` with `query` params serialized and appended +- `queryKey` is auto-generated from `params` — you never set it manually + +## Recommendations + +### Use `QueryClient.fetchQueries` for global configuration + +Instead of repeating `baseUrl`, `headers`, and other options in every `FetchQuery`, set them once on the `QueryClient`: + +```ts +const queryClient = new QueryClient({ + fetchQueries: { + baseUrl: 'https://api.example.com', + headers: { + Authorization: 'Bearer my-token', + }, + throwOnError: true, + timeout: 30_000, + }, +}); +``` diff --git a/docs/api/QueryClient.md b/docs/api/QueryClient.md index 9c63e25..625f7ea 100644 --- a/docs/api/QueryClient.md +++ b/docs/api/QueryClient.md @@ -28,6 +28,7 @@ interface QueryClientConfig { mutations: MobxMutatonFeatures; }; hooks?: QueryClientHooks; + fetchQueries?: FetchQueriesOptions; } ``` @@ -92,6 +93,38 @@ Entity lifecycle events. Available hooks: | onInfiniteQueryDestroy | Triggered when a [`InfiniteQuery`](/api/InfiniteQuery) is destroyed | | onMutationDestroy | Triggered when a [`Mutation`](/api/Mutation) is destroyed | +### `fetchQueries` + +Global configuration for [`FetchQuery`](/api/FetchQuery) and [`FetchInfiniteQuery`](/api/FetchInfiniteQuery). These defaults are merged with per-request `params` in every fetch query. + +- `baseUrl?: string` — Base URL prepended to every `path` +- `headers?: Record` — Default headers merged with per-request headers +- `customFetch?: typeof globalThis.fetch` — Custom fetch implementation (useful for testing or non-browser environments) +- `transformResponse?: TransformResponseFn` — Global response transformation function +- `throwOnError?: boolean` — Whether to throw the raw `Response` on non-ok status. Defaults to `true` +- `timeout?: number` — Request timeout in milliseconds +- `credentials?: RequestCredentials` — Credentials mode for requests (e.g. `"include"`, `"same-origin"`) +- `mode?: RequestMode` — Request mode (e.g. `"cors"`, `"no-cors"`) +- `meta?: QueryMeta` — Default metadata attached to fetch queries + +Example: + +```ts +import { QueryClient } from "mobx-tanstack-query"; + +const queryClient = new QueryClient({ + fetchQueries: { + baseUrl: "https://api.example.com", + headers: { + Authorization: "Bearer __TOKEN__", + }, + throwOnError: true, + timeout: 30_000, + credentials: "include", + }, +}); +``` + ## Inheritance `QueryClient` inherits all methods and properties from [QueryClient](https://tanstack.com/query/v5/docs/reference/QueryClient), including: diff --git a/docs/api/other.md b/docs/api/other.md index 8ef8256..6249d3e 100644 --- a/docs/api/other.md +++ b/docs/api/other.md @@ -1,6 +1,6 @@ # Other -## `InferQuery`, `InferMutation`, `InferInfiniteQuery` types +## `InferQuery`, `InferMutation`, `InferInfiniteQuery`, `InferFetchQuery`, `InferFetchInfiniteQuery` types This types are needed to infer some other types from mutations\configs. @@ -8,9 +8,11 @@ This types are needed to infer some other types from mutations\configs. type MyData = InferMutation; type MyVariables = InferMutation; type MyConfig = InferMutation; +type MyFetchData = InferFetchQuery; +type MyFetchConfig = InferFetchQuery; ``` -## `QueryConfigFromFn`, `MutationConfigFromFn`, `InfiniteQueryConfigFromFn` +## `QueryConfigFromFn`, `MutationConfigFromFn`, `InfiniteQueryConfigFromFn`, `FetchQueryConfigFromFn`, `FetchInfiniteQueryConfigFromFn` This types are needed to create configuration types from your functions of your http client @@ -24,7 +26,7 @@ type Config = MutationConfigFromFn ## `using` keyword -`Query`, `InfiniteQuery`, `Mutation` supports out-of-box [`using` keyword](https://github.com/tc39/proposal-explicit-resource-management). +`Query`, `InfiniteQuery`, `Mutation`, `FetchQuery`, `FetchInfiniteQuery` supports out-of-box [`using` keyword](https://github.com/tc39/proposal-explicit-resource-management). 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. diff --git a/docs/errors/1.md b/docs/errors/1.md new file mode 100644 index 0000000..e492a2b --- /dev/null +++ b/docs/errors/1.md @@ -0,0 +1,38 @@ +# Error `#1`: Fetch query is not configured + +This happened because the `params` option for [`FetchQuery`](/api/Query#fetchquery) returned a falsy value and the query key could not be parsed. + +## Explanation: + +This error is thrown inside the `queryFn` of [`FetchQuery`](/api/Query#fetchquery) when the query key is parsed and the result is `null`. This typically occurs when the `params` option returns a falsy value (such as `null`, `undefined`, `false`, or an empty string), causing the query key to be built as `[null]`. + +```ts +const query = new FetchQuery(queryClient, { + params: () => null, // ⬅️ returns falsy value +}); +``` + +When `params` returns a falsy value, the query is disabled (`enabled: false`) and no fetch is performed. However, if the query somehow becomes enabled while the key is still `[null]`, the `queryFn` will throw this error because it cannot extract fetch parameters from the key. + +This can also happen if you manually invalidate or refetch the query while `params` is still returning a falsy value. + +## Potential solution + +Ensure that the `params` option always returns a valid [`FetchQueryParams`](/api/Query#fetchqueryparams) object with at least a `path` property when the query should be active: + +```ts +const query = new FetchQuery(queryClient, { + params: () => ({ path: '/api/users' }), // ✅ returns valid params +}); +``` + +If the query should be disabled conditionally, make sure `params` returns a falsy value only when the query should truly be inactive, and avoid manually triggering refetches while it is disabled: + +```ts +const query = new FetchQuery(queryClient, { + params: () => { + if (!userId) return null; // ✅ disable when no userId + return { path: `/api/users/${userId}` }; + }, +}); +``` diff --git a/docs/errors/2.md b/docs/errors/2.md new file mode 100644 index 0000000..a27c6c5 --- /dev/null +++ b/docs/errors/2.md @@ -0,0 +1,44 @@ +# Error `#2`: Fetch infinite query is not configured + +This happened because the `params` option for [`FetchInfiniteQuery`](/api/InfiniteQuery#fetchinfinitequery) returned a falsy value and the query key could not be parsed. + +## Explanation: + +This error is thrown inside the `queryFn` of [`FetchInfiniteQuery`](/api/InfiniteQuery#fetchinfinitequery) when the query key is parsed and the result is `null`. This typically occurs when the `params` option returns a falsy value (such as `null`, `undefined`, `false`, or an empty string), causing the query key to be built as `[null]`. + +```ts +const query = new FetchInfiniteQuery(queryClient, { + params: () => null, // ⬅️ returns falsy value + initialPageParam: 0, + getNextPageParam: () => undefined, +}); +``` + +When `params` returns a falsy value, the query is disabled (`enabled: false`) and no fetch is performed. However, if the query somehow becomes enabled while the key is still `[null]`, the `queryFn` will throw this error because it cannot extract fetch parameters from the key. + +This can also happen if you manually invalidate or refetch the query while `params` is still returning a falsy value. + +## Potential solution + +Ensure that the `params` option always returns a valid [`FetchInfiniteQueryParams`](/api/InfiniteQuery#fetchinfinitequeryparams) object with at least a `path` property when the query should be active: + +```ts +const query = new FetchInfiniteQuery(queryClient, { + params: () => ({ path: '/api/users' }), // ✅ returns valid params + initialPageParam: 0, + getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, +}); +``` + +If the query should be disabled conditionally, make sure `params` returns a falsy value only when the query should truly be inactive, and avoid manually triggering refetches while it is disabled: + +```ts +const query = new FetchInfiniteQuery(queryClient, { + params: () => { + if (!cursor) return null; // ✅ disable when no cursor + return { path: '/api/users', pageParam: cursor }; + }, + initialPageParam: 0, + getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, +}); +``` diff --git a/docs/preset/createFetchInfiniteQuery.md b/docs/preset/createFetchInfiniteQuery.md new file mode 100644 index 0000000..3f519c8 --- /dev/null +++ b/docs/preset/createFetchInfiniteQuery.md @@ -0,0 +1,35 @@ +# createFetchInfiniteQuery + +This is alternative for [`new FetchInfiniteQuery()`](/api/FetchInfiniteQuery#usage). + +## API Signature + +```ts +createFetchInfiniteQuery(queryClient, options) +createFetchInfiniteQuery(options) +``` + +## Usage + +```ts +import { createFetchInfiniteQuery } from "mobx-tanstack-query/preset"; + +const query = createFetchInfiniteQuery({ + params: ({ pageParam }) => ({ + path: "/pets", + query: { page: pageParam ?? 1 }, + }), + initialPageParam: 1, + getNextPageParam: (lastPage, _, lastPageParam) => { + return lastPage.length ? lastPageParam + 1 : null; + }, +}); +``` + +## QueryClient `mount()` + +TanStack Query expects the [`QueryClient`](/api/QueryClient) to be **mounted** so observers, refetch scheduling, and related behavior work as intended. + +After the `FetchInfiniteQuery` is constructed, preset `createFetchInfiniteQuery` calls `mountQueryClientOnce` for the **effective client** attached to that query (the default [`queryClient`](/preset/queryClient) from the preset, or the `queryClient` you pass in options). Each client instance is only mounted once across the process (`WeakSet` inside [`mountQueryClientOnce`](/src/utils/mount-query-client-once.ts)), so many `createFetchInfiniteQuery` calls against the same client do not stack `mount()` work. + +If you use [`new FetchInfiniteQuery(...)`](/api/FetchInfiniteQuery#usage) without this preset, you must arrange `queryClient.mount()` yourself when your setup requires it, as described in the [`Query`](/api/Query) docs. diff --git a/docs/preset/createFetchQuery.md b/docs/preset/createFetchQuery.md new file mode 100644 index 0000000..ebdf22e --- /dev/null +++ b/docs/preset/createFetchQuery.md @@ -0,0 +1,30 @@ +# createFetchQuery + +This is alternative for [`new FetchQuery()`](/api/FetchQuery#usage). + +## API Signature + +```ts +createFetchQuery(queryClient, options) +createFetchQuery(options) +``` + +## Usage + +```ts +import { createFetchQuery } from "mobx-tanstack-query/preset"; + +const query = createFetchQuery({ + params: { + path: "/pets", + }, +}); +``` + +## QueryClient `mount()` + +TanStack Query expects the [`QueryClient`](/api/QueryClient) to be **mounted** so observers, refetch scheduling, and related behavior work as intended. + +After the `FetchQuery` is constructed, preset `createFetchQuery` calls `mountQueryClientOnce` for the **effective client** attached to that query (the default [`queryClient`](/preset/queryClient) from the preset, or the `queryClient` you pass in options). Each client instance is only mounted once across the process (`WeakSet` inside [`mountQueryClientOnce`](/src/utils/mount-query-client-once.ts)), so many `createFetchQuery` calls against the same client do not stack `mount()` work. + +If you use [`new FetchQuery(...)`](/api/FetchQuery#usage) without this preset, you must arrange `queryClient.mount()` yourself when your setup requires it, as described in the [`Query`](/api/Query) docs. diff --git a/docs/preset/index.md b/docs/preset/index.md index 0c123ac..be9edf7 100644 --- a/docs/preset/index.md +++ b/docs/preset/index.md @@ -6,7 +6,7 @@ This is additional api to work with this package, which contains factory functio Here is [link for built-in configuration of `QueryClient`](/src/preset/configs/default-query-client-config.ts) -[`createQuery`](/preset/createQuery) from the preset **mounts the query's `QueryClient` once** (per client instance) after the query is created, so you usually do not need to call `mount()` yourself for queries created this way. See [createQuery → QueryClient `mount()`](/preset/createQuery#queryclient-mount). +[`createQuery`](/preset/createQuery) and [`createFetchQuery`](/preset/createFetchQuery) from the preset **mount the query's `QueryClient` once** (per client instance) after the query is created, so you usually do not need to call `mount()` yourself for queries created this way. See [createQuery → QueryClient `mount()`](/preset/createQuery#queryclient-mount). ## Usage @@ -14,6 +14,7 @@ Here is [link for built-in configuration of `QueryClient`](/src/preset/configs/d ```ts import { createQuery, + createFetchQuery, createMutation } from "mobx-tanstack-query/preset"; @@ -26,6 +27,12 @@ const query = createQuery(async ({ signal }) => { queryKey: ['fruits'] }); +const fetchQuery = createFetchQuery({ + params: () => ({ + path: '/vegetables', + }), +}); + await query.start(); const mutation = createMutation(async (fruitName: string) => { diff --git a/package.json b/package.json index 04f8547..b719177 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "build:watch": "pnpm build && nodemon --watch src --ext ts,tsx --exec \"pnpm build\"", "build": "vite build", "pub": "pnpm build && sborshik publish --useDistDir", + "changeset:version": "bash scripts/changeset-version.sh", "pub:patch": "PUBLISH_VERSION=patch pnpm pub", "pub:minor": "PUBLISH_VERSION=minor pnpm pub", "pub:major": "PUBLISH_VERSION=major pnpm pub", @@ -41,7 +42,7 @@ "homepage": "https://js2me.github.io/mobx-tanstack-query/", "repository": { "type": "git", - "url": "git://github.com/js2me/mobx-tanstack-query" + "url": "https://github.com/js2me/mobx-tanstack-query" }, "type": "module", "peerDependencies": { @@ -50,10 +51,10 @@ }, "dependencies": { "linked-abort-controller": "^1.1.1", - "yummies": "^7.19.4" + "yummies": "^7.20.1" }, "devDependencies": { - "@biomejs/biome": "^2.4.14", + "@biomejs/biome": "^2.5.3", "@changesets/changelog-github": "^0.6.0", "@changesets/cli": "^2.31.0", "@testing-library/react": "^16.3.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 140693e..adbb1b2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,12 +18,12 @@ importers: specifier: ^6.12.4 version: 6.15.0 yummies: - specifier: ^7.19.4 - version: 7.19.4(mobx@6.15.0)(react@19.0.0) + specifier: ^7.20.1 + version: 7.20.1(mobx@6.15.0)(react@19.0.0) devDependencies: '@biomejs/biome': - specifier: ^2.4.14 - version: 2.4.14 + specifier: ^2.5.3 + version: 2.5.3 '@changesets/changelog-github': specifier: ^0.6.0 version: 0.6.0 @@ -255,55 +255,55 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} - '@biomejs/biome@2.4.14': - resolution: {integrity: sha512-TmAvxOEgrpLypzVGJ8FulIZnlyA9TxrO1hyqYrCz9r+bwma9xXxuLA5IuYnj55XQneFx460KjRbx6SWGLkg3bQ==} + '@biomejs/biome@2.5.3': + resolution: {integrity: sha512-MrJswFdei9EfDwwUy2tQrPDpK0AO+RmMFvBoaaJ6ayBc3sUbHdCE+XG5N8vp+5So41ZupZJQm0roHFFhMGVD7A==} engines: {node: '>=14.21.3'} hasBin: true - '@biomejs/cli-darwin-arm64@2.4.14': - resolution: {integrity: sha512-XvgoE9XOawUOQPdmvs4J7wPhi/DLwSCGks3AlPJDmh34O0awRTqCED1HRcRDdpf1Zrp4us4MGOOdIxNpbqNF5Q==} + '@biomejs/cli-darwin-arm64@2.5.3': + resolution: {integrity: sha512-QhYP9muVQ0nUO5zztFuPbEwi4+94sJWVjaZds9aMi1l/KNZBiUjdiSUrGHsTaMGDXrYl+r4AS2sUKfgH3w+V3g==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [darwin] - '@biomejs/cli-darwin-x64@2.4.14': - resolution: {integrity: sha512-jE7hKBCFhOx3uUh+ZkWBfOHxAcILPfhFplNkuID/eZeSTLHzfZzoZxW8fbqY9xXRnPi7jGNAf1iPVR+0yWsM/Q==} + '@biomejs/cli-darwin-x64@2.5.3': + resolution: {integrity: sha512-NC1Ss13UaW7QZX+y8j44bF7AP0jSJdBl6iRhe0MAkvaSqZy+mWg3GaXsrb+eSoHoGDBtaXWEbMVV0iVN2cZ7cQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [darwin] - '@biomejs/cli-linux-arm64-musl@2.4.14': - resolution: {integrity: sha512-/z+6gqAqqUQTHazwStxSXKHg9b8UvqBmDFRp+c4wYbq2KXhELQDon9EoC9RpmQ8JWkqQx/lIUy/cs+MhzDZp6A==} + '@biomejs/cli-linux-arm64-musl@2.5.3': + resolution: {integrity: sha512-fccix0w6xp6csCXgxeC0dU/3ecgRQal0y+cv2SP9ajNlhe7Yrk2Ug7UDe2j9AT9ZDYitkXpvUKgZjjuoYeP4Vg==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - '@biomejs/cli-linux-arm64@2.4.14': - resolution: {integrity: sha512-2TELhZnW5RSLL063l9rc5xLpA0ZIw0Ccwy/0q384rvNAgFw3yI76bd59547yxowdQr5MNPET/xDLrLuvgSeeWQ==} + '@biomejs/cli-linux-arm64@2.5.3': + resolution: {integrity: sha512-ksx1KWeyYW18ILL04msF/J4ZBtBDN33znYK8Z/aNv/vlBVxL9/g3mGP+omgHJKy4+KWbK87vcmmpmurfNjSgiA==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - '@biomejs/cli-linux-x64-musl@2.4.14': - resolution: {integrity: sha512-R6BWgJdQOwW9ulJatuTVrQkjnODjqHZkKNOqb1sz++3Noe5LYd0i3PchnOBUCYAPHoPWHhjJqbdZlHEu0hpjdA==} + '@biomejs/cli-linux-x64-musl@2.5.3': + resolution: {integrity: sha512-O/yU9YKRUiHhmcjF2f38PSjseVk3G4VLWYc0G2HWpzdBVREV6G8IGWIVEFf7MFPfWIzNUIvPsEjeAZQIOgnLcQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - '@biomejs/cli-linux-x64@2.4.14': - resolution: {integrity: sha512-zHrlQZDBDUz4OLAraYpWKcnLS6HOewBFWYOzY91d1ZjdqZwibOyb6BEu6WuWLugyo0P3riCmsbV9UqV1cSXwQg==} + '@biomejs/cli-linux-x64@2.5.3': + resolution: {integrity: sha512-yMkJtilsgvILDcVkh187aVLTb64xYsrxYajx5kym+r1ULkO5HUOfu9AYKLGQbOVLwJtT2utNw7hhFNg+17mUYA==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - '@biomejs/cli-win32-arm64@2.4.14': - resolution: {integrity: sha512-M3EH5hqOI/F/FUA2u4xcLoUgmxd218mvuj/6JL7Hv2toQvr2/AdOvKSpGkoRuWFCtQPVa+ZqkEV3Q5xBA9+XSA==} + '@biomejs/cli-win32-arm64@2.5.3': + resolution: {integrity: sha512-cX5z+GYwRcqEok0AH3KSfQGgqYd0Nomfp6Fbe1uiTtELE38hdH2k842wQ9wLNaF/JJ7r4rjJQ4VR+ce+fRmQbw==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [win32] - '@biomejs/cli-win32-x64@2.4.14': - resolution: {integrity: sha512-WL0EG5qE+EAKomGXbf2g6VnSKJhTL3tXC0QRzWRwA5VpjxNYa6H4P7ZWfymbGE4IhZZQi1KXQ2R0YjwInmz2fA==} + '@biomejs/cli-win32-x64@2.5.3': + resolution: {integrity: sha512-ExSaJWi4/u6+GXCszlSKpWSjKNbDseAYqqkCznsCsZ/4uidZ/BEqsCc5/3ctlq6dfIubdIIRSVLC/PG9xPl70Q==} engines: {node: '>=14.21.3'} cpu: [x64] os: [win32] @@ -2100,8 +2100,8 @@ packages: dataloader@1.4.0: resolution: {integrity: sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw==} - dayjs@1.11.20: - resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} + dayjs@1.11.21: + resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} de-indent@1.0.2: resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} @@ -2174,8 +2174,8 @@ packages: dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} - dompurify@3.4.2: - resolution: {integrity: sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==} + dompurify@3.4.12: + resolution: {integrity: sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==} dot-prop@5.3.0: resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} @@ -3100,8 +3100,8 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - nanoid@5.1.11: - resolution: {integrity: sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==} + nanoid@5.1.16: + resolution: {integrity: sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==} engines: {node: ^18 || >=20} hasBin: true @@ -3673,8 +3673,8 @@ packages: tabbable@6.4.0: resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} - tailwind-merge@3.5.0: - resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} + tailwind-merge@3.6.0: + resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} @@ -4142,8 +4142,8 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} - yummies@7.19.4: - resolution: {integrity: sha512-OO7jPtnYW+D8DX/1P1vQsWGZZjpQljuW2oEHc3RJ1a8e6SiWOtIUCL8sTwkLO6kaf7NfocRr/ld48vXZnO2hOg==} + yummies@7.20.1: + resolution: {integrity: sha512-H/AxRy+SjVlfpeI0TkiVkU/M2n+XpR1XGt5yS5r7GsP9Iz3mr17dbq9DLW18PmMlyKO78+yvH/9Z9VAnVuV3og==} peerDependencies: mobx: ^6.12.4 react: ^18 || ^19 @@ -4397,39 +4397,39 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@biomejs/biome@2.4.14': + '@biomejs/biome@2.5.3': optionalDependencies: - '@biomejs/cli-darwin-arm64': 2.4.14 - '@biomejs/cli-darwin-x64': 2.4.14 - '@biomejs/cli-linux-arm64': 2.4.14 - '@biomejs/cli-linux-arm64-musl': 2.4.14 - '@biomejs/cli-linux-x64': 2.4.14 - '@biomejs/cli-linux-x64-musl': 2.4.14 - '@biomejs/cli-win32-arm64': 2.4.14 - '@biomejs/cli-win32-x64': 2.4.14 + '@biomejs/cli-darwin-arm64': 2.5.3 + '@biomejs/cli-darwin-x64': 2.5.3 + '@biomejs/cli-linux-arm64': 2.5.3 + '@biomejs/cli-linux-arm64-musl': 2.5.3 + '@biomejs/cli-linux-x64': 2.5.3 + '@biomejs/cli-linux-x64-musl': 2.5.3 + '@biomejs/cli-win32-arm64': 2.5.3 + '@biomejs/cli-win32-x64': 2.5.3 - '@biomejs/cli-darwin-arm64@2.4.14': + '@biomejs/cli-darwin-arm64@2.5.3': optional: true - '@biomejs/cli-darwin-x64@2.4.14': + '@biomejs/cli-darwin-x64@2.5.3': optional: true - '@biomejs/cli-linux-arm64-musl@2.4.14': + '@biomejs/cli-linux-arm64-musl@2.5.3': optional: true - '@biomejs/cli-linux-arm64@2.4.14': + '@biomejs/cli-linux-arm64@2.5.3': optional: true - '@biomejs/cli-linux-x64-musl@2.4.14': + '@biomejs/cli-linux-x64-musl@2.5.3': optional: true - '@biomejs/cli-linux-x64@2.4.14': + '@biomejs/cli-linux-x64@2.5.3': optional: true - '@biomejs/cli-win32-arm64@2.4.14': + '@biomejs/cli-win32-arm64@2.5.3': optional: true - '@biomejs/cli-win32-x64@2.4.14': + '@biomejs/cli-win32-x64@2.5.3': optional: true '@bramus/specificity@2.4.2': @@ -6259,7 +6259,7 @@ snapshots: dataloader@1.4.0: {} - dayjs@1.11.20: {} + dayjs@1.11.21: {} de-indent@1.0.2: {} @@ -6315,7 +6315,7 @@ snapshots: dom-accessibility-api@0.5.16: {} - dompurify@3.4.2: + dompurify@3.4.12: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -7355,7 +7355,7 @@ snapshots: nanoid@3.3.12: {} - nanoid@5.1.11: {} + nanoid@5.1.16: {} node-fetch-native@1.6.7: {} @@ -8070,7 +8070,7 @@ snapshots: tabbable@6.4.0: {} - tailwind-merge@3.5.0: {} + tailwind-merge@3.6.0: {} term-size@2.2.1: {} @@ -8626,14 +8626,14 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 - yummies@7.19.4(mobx@6.15.0)(react@19.0.0): + yummies@7.20.1(mobx@6.15.0)(react@19.0.0): dependencies: class-variance-authority: 0.7.1 clsx: 2.1.1 - dayjs: 1.11.20 - dompurify: 3.4.2 - nanoid: 5.1.11 - tailwind-merge: 3.5.0 + dayjs: 1.11.21 + dompurify: 3.4.12 + nanoid: 5.1.16 + tailwind-merge: 3.6.0 optionalDependencies: mobx: 6.15.0 react: 19.0.0 diff --git a/scripts/prepare-dist.ts b/scripts/prepare-dist.ts index 2904406..884ddaa 100644 --- a/scripts/prepare-dist.ts +++ b/scripts/prepare-dist.ts @@ -1,7 +1,7 @@ #!/usr/bin/env tsx -import { readFileSync } from 'fs'; -import { resolve } from 'path'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; import { postBuildScript } from 'sborshik/post-build-script'; console.log(process.cwd()); diff --git a/src/fetch-infinite-query.ts b/src/fetch-infinite-query.ts new file mode 100644 index 0000000..9b7d590 --- /dev/null +++ b/src/fetch-infinite-query.ts @@ -0,0 +1,339 @@ +import type { + DefaultError, + InfiniteData, + InfiniteQueryObserverResult, +} from '@tanstack/query-core'; +import { callFunction } from 'yummies/common'; +import type { Dict, MaybeFalsy, MaybeFn } from 'yummies/types'; + +import type { + FetchQueryKeyMeta, + FetchQueryParams, + TransformResponseFn, +} from './fetch-query.js'; +import { serializeFetchQuery } from './fetch-query.js'; +import { InfiniteQuery } from './inifinite-query.js'; +import type { + InfiniteQueryConfig, + InfiniteQueryStartParams, +} from './inifinite-query.types.js'; +import type { QueryClient } from './query-client.js'; +import type { AnyQueryClient } from './query-client.types.js'; +import { makeFetchRequest } from './utils/make-fetch-request.js'; + +declare const process: Dict; + +export type FetchInfiniteQueryParams< + TOutputData = any, + TPageParam = unknown, +> = FetchQueryParams & { + pageParam?: TPageParam; +}; + +export type FetchInfiniteQueryKeyMeta = FetchQueryKeyMeta & { + pageParam?: unknown; +}; + +export type FetchInfiniteQueryKey = readonly ( + | string + | Dict + | FetchInfiniteQueryKeyMeta + | null +)[]; + +export interface FetchInfiniteQueryStartParams< + TOutputData = any, + TPageParam = unknown, +> { + params: Omit, 'pageParam'>; +} + +export interface FetchInfiniteQueryConfig< + TQueryFnData = unknown, + TError = DefaultError, + TPageParam = unknown, + TData = InfiniteData, + TOutputData = any, +> extends Omit< + Partial>, + | 'queryClient' + | 'queryFn' + | 'queryKey' + | 'options' + | 'select' + | 'initialPageParam' + | 'getNextPageParam' + | 'getPreviousPageParam' + > { + queryClient: AnyQueryClient; + initialPageParam: TPageParam; + getNextPageParam: ( + lastPage: TQueryFnData, + allPages: TQueryFnData[], + lastPageParam: TPageParam, + allPageParams: TPageParam[], + ) => TPageParam | undefined | null; + getPreviousPageParam?: ( + firstPage: TQueryFnData, + allPages: TQueryFnData[], + firstPageParam: TPageParam, + allPageParams: TPageParam[], + ) => TPageParam | undefined | null; + params?: MaybeFn< + MaybeFalsy> + >; + select?: (data: InfiniteData) => TData; +} + +export interface FetchInfiniteQueryPositionalConfig< + TQueryFnData = unknown, + TError = DefaultError, + TPageParam = unknown, + TData = InfiniteData, + TOutputData = any, +> extends Omit< + Partial>, + | 'queryFn' + | 'queryKey' + | 'options' + | 'select' + | 'initialPageParam' + | 'getNextPageParam' + | 'getPreviousPageParam' + > { + params?: MaybeFn< + MaybeFalsy> + >; + select?: (data: InfiniteData) => TData; +} + +export const buildFetchInfiniteQueryKey = ( + fetchParams: FetchInfiniteQueryParams | null, +): FetchInfiniteQueryKey => { + if (!fetchParams) { + return [null]; + } + + const { pageParam, ...restParams } = fetchParams; + + return [ + ...restParams.path.split('/'), + serializeFetchQuery(restParams.query) as any, + { + baseUrl: restParams.baseUrl, + method: restParams.method, + headers: restParams.headers, + body: restParams.body as any, + notFoundAsNull: restParams.notFoundAsNull, + responseType: restParams.responseType, + request: restParams.request, + pageParam: pageParam ?? null, + } satisfies FetchInfiniteQueryKeyMeta, + ]; +}; + +export const parseFetchInfiniteQueryKey = ( + queryKey: FetchInfiniteQueryKey, +): FetchInfiniteQueryParams | null => { + if (!queryKey.length || queryKey[0] === null) { + return null; + } + + const meta = queryKey[queryKey.length - 1] as FetchInfiniteQueryKeyMeta; + const serializedQuery = queryKey[queryKey.length - 2] as + | Dict + | undefined; + const path = (queryKey.slice(0, -2) as string[]).join('/'); + + return { + path, + query: serializedQuery, + baseUrl: meta.baseUrl, + method: meta.method, + headers: meta.headers, + body: meta.body, + notFoundAsNull: meta.notFoundAsNull, + responseType: meta.responseType, + request: meta.request, + pageParam: meta.pageParam ?? undefined, + }; +}; + +export class FetchInfiniteQuery< + TQueryFnData = unknown, + TError = DefaultError, + TPageParam = unknown, + TData = InfiniteData, + TOutputData = any, +> extends InfiniteQuery { + private _holders!: { + params: MaybeFn< + MaybeFalsy> + >; + transformResponse: undefined | TransformResponseFn; + }; + + constructor( + config: FetchInfiniteQueryConfig< + TQueryFnData, + TError, + TPageParam, + TData, + TOutputData + >, + ); + constructor( + queryClient: AnyQueryClient, + config: FetchInfiniteQueryPositionalConfig< + TQueryFnData, + TError, + TPageParam, + TData, + TOutputData + >, + ); + + constructor( + queryClientOrConfig: + | AnyQueryClient + | FetchInfiniteQueryConfig< + TQueryFnData, + TError, + TPageParam, + TData, + TOutputData + >, + positionalConfig?: FetchInfiniteQueryPositionalConfig< + TQueryFnData, + TError, + TPageParam, + TData, + TOutputData + >, + ) { + let queryFullConfig: FetchInfiniteQueryConfig< + TQueryFnData, + TError, + TPageParam, + TData, + TOutputData + >; + + if ( + 'queryClient' in + (queryClientOrConfig as FetchInfiniteQueryConfig< + TQueryFnData, + TError, + TPageParam, + TData, + TOutputData + >) + ) { + queryFullConfig = queryClientOrConfig as FetchInfiniteQueryConfig< + TQueryFnData, + TError, + TPageParam, + TData, + TOutputData + >; + } else { + queryFullConfig = { + ...positionalConfig, + queryClient: queryClientOrConfig as AnyQueryClient, + } as FetchInfiniteQueryConfig< + TQueryFnData, + TError, + TPageParam, + TData, + TOutputData + >; + } + + const holders: { + params: MaybeFn< + MaybeFalsy> + >; + transformResponse: undefined | TransformResponseFn; + } = { + params: queryFullConfig.params, + transformResponse: undefined, + }; + + super({ + ...(queryFullConfig as any), + options: () => { + const fetchParams = callFunction(holders.params) || null; + const qc = queryFullConfig.queryClient as QueryClient; + + holders.transformResponse = fetchParams?.transformResponse; + + return { + enabled: !!fetchParams, + queryKey: buildFetchInfiniteQueryKey(fetchParams), + meta: fetchParams?.meta ?? qc.fetchQueries?.meta, + }; + }, + queryFn: async ({ signal, queryKey, pageParam }) => { + const baseFetchParams = parseFetchInfiniteQueryKey( + queryKey as FetchInfiniteQueryKey, + ); + + if (!baseFetchParams) { + if (process.env.NODE_ENV !== 'production') { + throw new Error( + 'Error #2: Fetch infinite query is not configured.\n' + + 'This happened because the "params" option for FetchInfiniteQuery returned a falsy value and the query key could not be parsed.\n' + + 'Please ensure that "params" returns a valid FetchInfiniteQueryParams object with at least a "path" property.\n' + + 'More info: https://js2me.github.io/mobx-tanstack-query/errors/2', + ); + } + throw new Error( + 'Error #2: https://js2me.github.io/mobx-tanstack-query/errors/2', + ); + } + + const fetchParams: FetchInfiniteQueryParams = { + ...baseFetchParams, + pageParam: pageParam as TPageParam, + }; + + return makeFetchRequest({ + fetchParams, + transformResponse: holders.transformResponse, + queryClient: queryFullConfig.queryClient, + signal, + }); + }, + }); + + this._holders = holders; + } + + start( + startParams: FetchInfiniteQueryStartParams, + ): Promise>; + start( + startParams?: InfiniteQueryStartParams< + TQueryFnData, + TError, + TPageParam, + TData, + any + >, + ): Promise>; + start(startParams: any): Promise> { + if (startParams?.params) { + const fetchParams: FetchInfiniteQueryParams = + startParams.params; + this._holders.params = fetchParams; + this._holders.transformResponse = fetchParams.transformResponse; + + return super.start({ + queryKey: buildFetchInfiniteQueryKey(fetchParams), + enabled: true, + } as any); + } + + return super.start(startParams); + } +} diff --git a/src/fetch-query.test.ts b/src/fetch-query.test.ts new file mode 100644 index 0000000..1872b6b --- /dev/null +++ b/src/fetch-query.test.ts @@ -0,0 +1,1018 @@ +/// +import { QueryClient } from '@tanstack/query-core'; +import { observable, runInAction, when } from 'mobx'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + buildFetchQueryKey, + buildFetchQueryUrl, + FetchQuery, + parseFetchQueryKey, + serializeFetchQuery, +} from './fetch-query.js'; +import { QueryClient as MobxQueryClient } from './query-client.js'; + +const okJson = (data: unknown, init?: ResponseInit) => + new Response(JSON.stringify(data), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + ...init, + }); + +const okText = (data: string, init?: ResponseInit) => + new Response(data, { + status: 200, + headers: { 'Content-Type': 'text/plain' }, + ...init, + }); + +const errorResponse = (status: number) => + new Response(null, { status, statusText: 'error' }); + +describe('serializeFetchQuery', () => { + it('returns null when query is undefined', () => { + expect(serializeFetchQuery()).toBeUndefined(); + }); + + it('returns null when query is empty object', () => { + expect(serializeFetchQuery({})).toBeUndefined(); + }); + + it('returns null when all values are null/undefined', () => { + expect(serializeFetchQuery({ a: null, b: undefined })).toBeUndefined(); + }); + + it('serializes only defined values into strings', () => { + expect( + serializeFetchQuery({ + page: 1, + active: true, + title: 'hello', + skipNull: null, + skipUndef: undefined, + }), + ).toEqual({ page: '1', active: 'true', title: 'hello' }); + }); +}); + +describe('buildFetchQueryUrl', () => { + it('returns path unchanged when query is undefined', () => { + expect(buildFetchQueryUrl('/users', undefined)).toBe('/users'); + }); + + it('appends a query string when path has none', () => { + expect(buildFetchQueryUrl('/users', { a: '1', b: '2' })).toBe( + '/users?a=1&b=2', + ); + }); + + it('joins with & when path already has a query string', () => { + expect(buildFetchQueryUrl('/users?x=10', { a: '1' })).toBe( + '/users?x=10&a=1', + ); + }); + + it('returns path unchanged when serialized query string is empty', () => { + expect(buildFetchQueryUrl('/users', {})).toBe('/users'); + }); +}); + +describe('buildFetchQueryKey / parseFetchQueryKey', () => { + it('builds [null] for null params', () => { + expect(buildFetchQueryKey(null)).toEqual([null]); + }); + + it('roundtrips path/query/method/headers/body/meta', () => { + const params = { + path: '/api/users', + query: { page: 1 }, + method: 'POST', + headers: { 'X-Token': 'abc' }, + body: '{"search":"hello"}', + notFoundAsNull: true, + responseType: 'json' as const, + request: { cache: 'no-cache' as RequestCache }, + }; + const key = buildFetchQueryKey(params); + const parsed = parseFetchQueryKey(key); + + expect(parsed).toEqual({ + path: '/api/users', + query: { page: '1' }, + method: 'POST', + headers: { 'X-Token': 'abc' }, + body: '{"search":"hello"}', + notFoundAsNull: true, + responseType: 'json', + request: { cache: 'no-cache' }, + }); + }); + + it('stores plain object body as-is in key', () => { + const key = buildFetchQueryKey({ + path: '/search', + body: { q: 'hello' }, + }); + const meta = key[key.length - 1] as { body: unknown }; + expect(meta.body).toEqual({ q: 'hello' }); + }); + + it('stores string body as-is in key', () => { + const key = buildFetchQueryKey({ + path: '/submit', + body: 'raw-payload', + }); + const meta = key[key.length - 1] as { body: string | null }; + expect(meta.body).toBe('raw-payload'); + }); + + it('stores FormData body as-is in key', () => { + const formData = new FormData(); + const key = buildFetchQueryKey({ + path: '/upload', + body: formData, + }); + const meta = key[key.length - 1] as { body: unknown }; + expect(meta.body).toBe(formData); + }); + + it('stores null headers when not provided', () => { + const key = buildFetchQueryKey({ path: '/items' }); + const meta = key[key.length - 1] as { headers: unknown }; + expect(meta.headers).toBeUndefined(); + }); + + it('parse returns null when key is null-headed', () => { + expect(parseFetchQueryKey([null])).toBeNull(); + }); + + it('parse returns null for empty key', () => { + expect(parseFetchQueryKey([])).toBeNull(); + }); +}); + +describe('FetchQuery', () => { + let queryClient: QueryClient; + let fetchSpy: ReturnType; + + beforeEach(() => { + queryClient = new QueryClient(); + fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('fetches and exposes JSON data', async () => { + fetchSpy.mockResolvedValueOnce(okJson({ id: 1, name: 'js2me' })); + + const query = new FetchQuery(queryClient, { + params: () => ({ path: '/users/1' }), + }); + + await when(() => !query.isLoading); + + expect(fetchSpy).toHaveBeenCalledWith('/users/1', { + signal: expect.any(AbortSignal), + }); + expect(query.data).toEqual({ id: 1, name: 'js2me' }); + expect(query.isSuccess).toBe(true); + + query.destroy(); + }); + + it('builds URL with serialized query params', async () => { + fetchSpy.mockResolvedValueOnce(okJson([{ id: 1 }])); + + const query = new FetchQuery(queryClient, { + params: () => ({ + path: '/users', + query: { page: 1, active: true, skip: null }, + }), + }); + + await when(() => !query.isLoading); + + expect(fetchSpy).toHaveBeenCalledWith( + '/users?page=1&active=true', + expect.any(Object), + ); + + query.destroy(); + }); + + it('is disabled when params returns falsy', async () => { + const query = new FetchQuery(queryClient, { + params: () => false, + }); + + expect(query.options.enabled).toBe(false); + expect(fetchSpy).not.toHaveBeenCalled(); + + query.destroy(); + }); + + it('returns text when responseType is "text"', async () => { + fetchSpy.mockResolvedValueOnce(okText('plain body')); + + const query = new FetchQuery(queryClient, { + params: () => ({ path: '/readme', responseType: 'text' }), + }); + + await when(() => !query.isLoading); + + expect(query.data).toBe('plain body'); + + query.destroy(); + }); + + it('throws on non-ok response', async () => { + fetchSpy.mockResolvedValueOnce(errorResponse(500)); + + const query = new FetchQuery(queryClient, { + params: () => ({ path: '/oops' }), + throwOnError: false, + retry: false, + }); + + await when(() => query.isError); + + expect(query.error).toBeInstanceOf(Response); + expect((query.error as unknown as Response).status).toBe(500); + + query.destroy(); + }); + + it('returns null on 404 when notFoundAsNull is true', async () => { + fetchSpy.mockResolvedValueOnce(errorResponse(404)); + + const query = new FetchQuery(queryClient, { + params: () => ({ path: '/missing', notFoundAsNull: true }), + retry: false, + }); + + await when(() => !query.isLoading); + + expect(query.isSuccess).toBe(true); + expect(query.data).toBeNull(); + + query.destroy(); + }); + + it('still throws on 404 when notFoundAsNull is false', async () => { + fetchSpy.mockResolvedValueOnce(errorResponse(404)); + + const query = new FetchQuery(queryClient, { + params: () => ({ path: '/missing' }), + throwOnError: false, + retry: false, + }); + + await when(() => query.isError); + expect(query.error).toBeInstanceOf(Response); + expect((query.error as unknown as Response).status).toBe(404); + + query.destroy(); + }); + + it('applies select() to transform response', async () => { + fetchSpy.mockResolvedValueOnce(okJson({ id: 1, name: 'js2me' })); + + const query = new FetchQuery(queryClient, { + params: () => ({ path: '/users/1' }), + select: (data) => (data as { name: string }).name.toUpperCase(), + }); + + await when(() => !query.isLoading); + + expect(query.data).toBe('JS2ME'); + + query.destroy(); + }); + + it('sends JSON body with auto POST method and Content-Type', async () => { + fetchSpy.mockResolvedValueOnce(okJson({ ok: true })); + + const query = new FetchQuery(queryClient, { + params: () => ({ path: '/search', body: { q: 'hello' } }), + }); + + await when(() => !query.isLoading); + + expect(fetchSpy).toHaveBeenCalledWith('/search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{"q":"hello"}', + signal: expect.any(AbortSignal), + }); + + query.destroy(); + }); + + it('allows overriding auto method and Content-Type', async () => { + fetchSpy.mockResolvedValueOnce(okJson({ ok: true })); + + const query = new FetchQuery(queryClient, { + params: () => ({ + path: '/search', + method: 'PUT', + headers: { 'Content-Type': 'text/plain', 'X-Custom': 'yes' }, + body: { q: 'hello' }, + }), + }); + + await when(() => !query.isLoading); + + expect(fetchSpy).toHaveBeenCalledWith('/search', { + method: 'PUT', + headers: { 'Content-Type': 'text/plain', 'X-Custom': 'yes' }, + body: '{"q":"hello"}', + signal: expect.any(AbortSignal), + }); + + query.destroy(); + }); + + it('sends string body as-is without Content-Type auto-set', async () => { + fetchSpy.mockResolvedValueOnce(okJson({ ok: true })); + + const query = new FetchQuery(queryClient, { + params: () => ({ path: '/submit', body: 'raw-payload' }), + }); + + await when(() => !query.isLoading); + + expect(fetchSpy).toHaveBeenCalledWith('/submit', { + method: 'POST', + headers: undefined, + body: 'raw-payload', + signal: expect.any(AbortSignal), + }); + + query.destroy(); + }); + + it('sends FormData body without JSON auto-handling', async () => { + fetchSpy.mockResolvedValueOnce(okJson({ ok: true })); + const formData = new FormData(); + formData.append('file', 'content'); + + const query = new FetchQuery(queryClient, { + params: () => ({ path: '/upload', body: formData }), + }); + + await when(() => !query.isLoading); + + expect(fetchSpy).toHaveBeenCalledWith('/upload', { + method: 'POST', + headers: undefined, + body: formData, + signal: expect.any(AbortSignal), + }); + + query.destroy(); + }); + + it('sends custom headers alongside JSON body', async () => { + fetchSpy.mockResolvedValueOnce(okJson({ ok: true })); + + const query = new FetchQuery(queryClient, { + params: () => ({ + path: '/data', + headers: { Authorization: 'Bearer __SECRET_TOKEN__' }, + body: { page: 1 }, + }), + }); + + await when(() => !query.isLoading); + + expect(fetchSpy).toHaveBeenCalledWith('/data', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer __SECRET_TOKEN__', + }, + body: '{"page":1}', + signal: expect.any(AbortSignal), + }); + + query.destroy(); + }); + + it('forwards remaining request options (credentials, cache, etc.)', async () => { + fetchSpy.mockResolvedValueOnce(okJson({ ok: true })); + + const query = new FetchQuery(queryClient, { + params: () => ({ + path: '/data', + request: { credentials: 'include', cache: 'no-cache' }, + }), + }); + + await when(() => !query.isLoading); + + expect(fetchSpy).toHaveBeenCalledWith('/data', { + credentials: 'include', + cache: 'no-cache', + method: undefined, + headers: undefined, + body: undefined, + signal: expect.any(AbortSignal), + }); + + query.destroy(); + }); + + it('does not default to POST when there is no body', async () => { + fetchSpy.mockResolvedValueOnce(okJson([])); + + const query = new FetchQuery(queryClient, { + params: () => ({ path: '/items' }), + }); + + await when(() => !query.isLoading); + + expect(fetchSpy).toHaveBeenCalledWith('/items', { + method: undefined, + headers: undefined, + body: undefined, + signal: expect.any(AbortSignal), + }); + + query.destroy(); + }); + + it('different body values produce different query keys', async () => { + const key1 = buildFetchQueryKey({ path: '/search', body: { q: 'a' } }); + const key2 = buildFetchQueryKey({ path: '/search', body: { q: 'b' } }); + + expect(key1).not.toEqual(key2); + }); + + it('same body values produce same query key', async () => { + const key1 = buildFetchQueryKey({ path: '/search', body: { q: 'a' } }); + const key2 = buildFetchQueryKey({ path: '/search', body: { q: 'a' } }); + + expect(key1).toEqual(key2); + }); + + it('refetches when observable params change (different query key)', async () => { + fetchSpy + .mockResolvedValueOnce(okJson({ id: 1 })) + .mockResolvedValueOnce(okJson({ id: 2 })); + + const state = observable({ id: 1 }); + const query = new FetchQuery(queryClient, { + params: () => ({ path: `/users/${state.id}` }), + }); + + await when(() => !query.isLoading); + expect(query.data).toEqual({ id: 1 }); + + runInAction(() => { + state.id = 2; + }); + + await when(() => (query.data as any)?.id === 2); + expect(fetchSpy).toHaveBeenCalledTimes(2); + + query.destroy(); + }); + + it('aborts fetch when destroy is called', async () => { + let capturedSignal: AbortSignal | undefined; + fetchSpy.mockImplementation((_url, init) => { + capturedSignal = init.signal; + return new Promise(() => {}); + }); + + const query = new FetchQuery(queryClient, { + params: () => ({ path: '/slow' }), + }); + + await vi.waitFor(() => expect(capturedSignal).toBeDefined()); + + query.destroy(); + + expect(capturedSignal?.aborted).toBe(true); + }); + + it('requires queryClient as the first argument', async () => { + fetchSpy.mockResolvedValueOnce(okJson({ id: 1 })); + + const query = new FetchQuery(queryClient, { + params: () => ({ path: '/users/1' }), + }); + + await when(() => !query.isLoading); + + expect(queryClient.getQueryData(query.options.queryKey)).toEqual({ + id: 1, + }); + + query.destroy(); + }); + + it('accepts queryClient inside config object', async () => { + fetchSpy.mockResolvedValueOnce(okJson({ id: 1 })); + + const query = new FetchQuery({ + queryClient, + params: () => ({ path: '/users/1' }), + }); + + await when(() => !query.isLoading); + + expect(query.data).toEqual({ id: 1 }); + + query.destroy(); + }); + + describe('client-level headers from fetchQueries', () => { + let clientWithHeaders: MobxQueryClient; + + beforeEach(() => { + clientWithHeaders = new MobxQueryClient({ + fetchQueries: { + headers: { Authorization: 'Bearer token', 'X-App': 'test' }, + }, + }); + }); + + it('merges client-level headers with request headers', async () => { + fetchSpy.mockResolvedValueOnce(okJson({ ok: true })); + + const query = new FetchQuery(clientWithHeaders, { + params: () => ({ + path: '/data', + headers: { 'X-Custom': 'yes' }, + }), + }); + + await when(() => !query.isLoading); + + expect(fetchSpy).toHaveBeenCalledWith('/data', { + method: undefined, + headers: { + Authorization: 'Bearer token', + 'X-App': 'test', + 'X-Custom': 'yes', + }, + body: undefined, + signal: expect.any(AbortSignal), + }); + + query.destroy(); + }); + + it('request headers override client-level headers', async () => { + fetchSpy.mockResolvedValueOnce(okJson({ ok: true })); + + const query = new FetchQuery(clientWithHeaders, { + params: () => ({ + path: '/data', + headers: { Authorization: 'Override' }, + }), + }); + + await when(() => !query.isLoading); + + expect(fetchSpy).toHaveBeenCalledWith('/data', { + method: undefined, + headers: { + Authorization: 'Override', + 'X-App': 'test', + }, + body: undefined, + signal: expect.any(AbortSignal), + }); + + query.destroy(); + }); + + it('applies client-level headers before Content-Type for JSON body', async () => { + fetchSpy.mockResolvedValueOnce(okJson({ ok: true })); + + const query = new FetchQuery(clientWithHeaders, { + params: () => ({ + path: '/data', + body: { q: 'hello' }, + }), + }); + + await when(() => !query.isLoading); + + expect(fetchSpy).toHaveBeenCalledWith('/data', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer token', + 'X-App': 'test', + }, + body: '{"q":"hello"}', + signal: expect.any(AbortSignal), + }); + + query.destroy(); + }); + + it('uses only request headers when client has no fetchQueries', async () => { + fetchSpy.mockResolvedValueOnce(okJson({ ok: true })); + + const query = new FetchQuery(queryClient, { + params: () => ({ + path: '/data', + headers: { Authorization: '__SECRET_3__' }, + }), + }); + + await when(() => !query.isLoading); + + expect(fetchSpy).toHaveBeenCalledWith('/data', { + method: undefined, + headers: { Authorization: '__SECRET_3__' }, + body: undefined, + signal: expect.any(AbortSignal), + }); + + query.destroy(); + }); + }); + + describe('customFetch from fetchQueries', () => { + it('uses customFetch when provided', async () => { + const customFetch = vi + .fn() + .mockResolvedValueOnce(okJson({ custom: true })); + const clientWithCustomFetch = new MobxQueryClient({ + fetchQueries: { customFetch }, + }); + + const query = new FetchQuery(clientWithCustomFetch, { + params: () => ({ path: '/custom' }), + }); + + await when(() => !query.isLoading); + + expect(customFetch).toHaveBeenCalledWith('/custom', { + method: undefined, + headers: undefined, + body: undefined, + signal: expect.any(AbortSignal), + }); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(query.data).toEqual({ custom: true }); + + query.destroy(); + }); + + it('falls back to globalThis.fetch when customFetch is not provided', async () => { + fetchSpy.mockResolvedValueOnce(okJson({ default: true })); + + const query = new FetchQuery(queryClient, { + params: () => ({ path: '/default' }), + }); + + await when(() => !query.isLoading); + + expect(fetchSpy).toHaveBeenCalledWith('/default', expect.any(Object)); + expect(query.data).toEqual({ default: true }); + + query.destroy(); + }); + + it('customFetch works with client-level headers and baseUrl', async () => { + const customFetch = vi.fn().mockResolvedValueOnce(okJson({ ok: true })); + const clientWithCustomFetch = new MobxQueryClient({ + fetchQueries: { + customFetch, + baseUrl: 'https://api.example.com', + headers: { Authorization: 'Bearer token' }, + }, + }); + + const query = new FetchQuery(clientWithCustomFetch, { + params: () => ({ path: '/data' }), + }); + + await when(() => !query.isLoading); + + expect(customFetch).toHaveBeenCalledWith('https://api.example.com/data', { + method: undefined, + headers: { Authorization: 'Bearer token' }, + body: undefined, + signal: expect.any(AbortSignal), + }); + + query.destroy(); + }); + }); + + describe('transformResponse from fetchQueries', () => { + it('uses client-level transformResponse', async () => { + fetchSpy.mockResolvedValueOnce( + okJson({ data: { id: 1 }, meta: { page: 1 } }), + ); + + const clientWithTransform = new MobxQueryClient({ + fetchQueries: { + transformResponse: async (response) => { + const json = (await response.json()) as { + data: unknown; + meta: unknown; + }; + return json.data; + }, + }, + }); + + const query = new FetchQuery(clientWithTransform, { + params: () => ({ path: '/wrapped' }), + }); + + await when(() => !query.isLoading); + + expect(query.data).toEqual({ id: 1 }); + + query.destroy(); + }); + + it('per-query transformResponse overrides client-level transformResponse', async () => { + fetchSpy.mockResolvedValueOnce(okJson({ data: { id: 1 } })); + + const clientWithTransform = new MobxQueryClient({ + fetchQueries: { + transformResponse: async () => 'client-transform', + }, + }); + + const query = new FetchQuery(clientWithTransform, { + params: () => ({ + path: '/wrapped', + transformResponse: async (response) => { + const json = (await response.json()) as { data: unknown }; + return json.data as string; + }, + }), + }); + + await when(() => !query.isLoading); + + expect(query.data).toEqual({ id: 1 }); + + query.destroy(); + }); + + it('does not use client-level transformResponse when not configured', async () => { + fetchSpy.mockResolvedValueOnce(okJson({ id: 1 })); + + const query = new FetchQuery(queryClient, { + params: () => ({ path: '/plain' }), + }); + + await when(() => !query.isLoading); + + expect(query.data).toEqual({ id: 1 }); + + query.destroy(); + }); + }); + + describe('timeout from fetchQueries', () => { + it('aborts the request after the timeout elapses', async () => { + fetchSpy.mockImplementation( + (_url, init) => + new Promise((_resolve, reject) => { + init.signal.addEventListener('abort', () => { + reject( + new DOMException('The operation was aborted', 'AbortError'), + ); + }); + }), + ); + + const clientWithTimeout = new MobxQueryClient({ + fetchQueries: { timeout: 10 }, + }); + + const query = new FetchQuery(clientWithTimeout, { + params: () => ({ path: '/slow' }), + throwOnError: false, + retry: false, + }); + + await when(() => query.isError); + + expect(query.error).toBeInstanceOf(DOMException); + + query.destroy(); + }); + + it('does not set a timeout signal when timeout is not configured', async () => { + fetchSpy.mockResolvedValueOnce(okJson({ ok: true })); + + const query = new FetchQuery(queryClient, { + params: () => ({ path: '/data' }), + }); + + await when(() => !query.isLoading); + + expect(fetchSpy).toHaveBeenCalledWith('/data', { + credentials: undefined, + mode: undefined, + method: undefined, + headers: undefined, + body: undefined, + signal: expect.any(AbortSignal), + }); + expect(query.isSuccess).toBe(true); + + query.destroy(); + }); + }); + + describe('credentials from fetchQueries', () => { + it('sends credentials from client-level config', async () => { + fetchSpy.mockResolvedValueOnce(okJson({ ok: true })); + + const clientWithCredentials = new MobxQueryClient({ + fetchQueries: { credentials: 'include' }, + }); + + const query = new FetchQuery(clientWithCredentials, { + params: () => ({ path: '/data' }), + }); + + await when(() => !query.isLoading); + + expect(fetchSpy).toHaveBeenCalledWith('/data', { + credentials: 'include', + mode: undefined, + method: undefined, + headers: undefined, + body: undefined, + signal: expect.any(AbortSignal), + }); + + query.destroy(); + }); + + it('per-request credentials overrides client-level credentials', async () => { + fetchSpy.mockResolvedValueOnce(okJson({ ok: true })); + + const clientWithCredentials = new MobxQueryClient({ + fetchQueries: { credentials: 'include' }, + }); + + const query = new FetchQuery(clientWithCredentials, { + params: () => ({ + path: '/data', + request: { credentials: 'same-origin' }, + }), + }); + + await when(() => !query.isLoading); + + expect(fetchSpy).toHaveBeenCalledWith('/data', { + credentials: 'same-origin', + mode: undefined, + method: undefined, + headers: undefined, + body: undefined, + signal: expect.any(AbortSignal), + }); + + query.destroy(); + }); + }); + + describe('mode from fetchQueries', () => { + it('sends mode from client-level config', async () => { + fetchSpy.mockResolvedValueOnce(okJson({ ok: true })); + + const clientWithMode = new MobxQueryClient({ + fetchQueries: { mode: 'cors' }, + }); + + const query = new FetchQuery(clientWithMode, { + params: () => ({ path: '/data' }), + }); + + await when(() => !query.isLoading); + + expect(fetchSpy).toHaveBeenCalledWith('/data', { + credentials: undefined, + mode: 'cors', + method: undefined, + headers: undefined, + body: undefined, + signal: expect.any(AbortSignal), + }); + + query.destroy(); + }); + + it('per-request mode overrides client-level mode', async () => { + fetchSpy.mockResolvedValueOnce(okJson({ ok: true })); + + const clientWithMode = new MobxQueryClient({ + fetchQueries: { mode: 'cors' }, + }); + + const query = new FetchQuery(clientWithMode, { + params: () => ({ + path: '/data', + request: { mode: 'no-cors' }, + }), + }); + + await when(() => !query.isLoading); + + expect(fetchSpy).toHaveBeenCalledWith('/data', { + credentials: undefined, + mode: 'no-cors', + method: undefined, + headers: undefined, + body: undefined, + signal: expect.any(AbortSignal), + }); + + query.destroy(); + }); + }); + + describe('meta', () => { + it('sets meta on query options from params', async () => { + fetchSpy.mockResolvedValueOnce(okJson({ id: 1 })); + + const query = new FetchQuery(queryClient, { + params: () => ({ path: '/users/1', meta: { source: 'test' } }), + }); + + expect(query.options.meta).toEqual({ source: 'test' }); + + await when(() => !query.isLoading); + expect(query.data).toEqual({ id: 1 }); + + query.destroy(); + }); + + it('uses client-level meta as default', async () => { + fetchSpy.mockResolvedValueOnce(okJson({ id: 1 })); + + const clientWithMeta = new MobxQueryClient({ + fetchQueries: { meta: { source: 'client' } }, + }); + + const query = new FetchQuery(clientWithMeta, { + params: () => ({ path: '/users/1' }), + }); + + expect(query.options.meta).toEqual({ source: 'client' }); + + await when(() => !query.isLoading); + expect(query.data).toEqual({ id: 1 }); + + query.destroy(); + }); + + it('per-request meta overrides client-level meta', async () => { + fetchSpy.mockResolvedValueOnce(okJson({ id: 1 })); + + const clientWithMeta = new MobxQueryClient({ + fetchQueries: { meta: { source: 'client' } }, + }); + + const query = new FetchQuery(clientWithMeta, { + params: () => ({ path: '/users/1', meta: { source: 'request' } }), + }); + + expect(query.options.meta).toEqual({ source: 'request' }); + + query.destroy(); + }); + + it('updates meta reactively without refetching', async () => { + fetchSpy.mockResolvedValueOnce(okJson({ id: 1 })); + + const state = observable({ source: 'a' }); + const query = new FetchQuery(queryClient, { + params: () => ({ path: '/data', meta: { source: state.source } }), + }); + + await when(() => !query.isLoading); + expect(query.options.meta).toEqual({ source: 'a' }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + + runInAction(() => { + state.source = 'b'; + }); + + expect(query.options.meta).toEqual({ source: 'b' }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + + query.destroy(); + }); + }); +}); diff --git a/src/fetch-query.ts b/src/fetch-query.ts new file mode 100644 index 0000000..0f85729 --- /dev/null +++ b/src/fetch-query.ts @@ -0,0 +1,284 @@ +import type { + DefaultError, + QueryMeta, + QueryObserverResult, +} from '@tanstack/query-core'; +import { callFunction } from 'yummies/common'; +import type { Dict, MaybeFalsy, MaybeFn, PickByValue } from 'yummies/types'; +import { Query } from './query.js'; +import type { QueryConfig, QueryStartParams } from './query.types.js'; +import type { QueryClient } from './query-client.js'; +import type { AnyQueryClient } from './query-client.types.js'; +import { makeFetchRequest } from './utils/make-fetch-request.js'; + +declare const process: Dict; + +export type ResponseType = keyof PickByValue; + +export type TransformResponseMeta = Omit; + +export type TransformResponseFn = ( + response: Response, + meta: TransformResponseMeta, +) => TOutputData; + +export type FetchQueryParams = { + baseUrl?: string; + path: string; + query?: Dict; + method?: string; + headers?: Dict | undefined; + body?: BodyInit | Dict; + request?: Partial< + Omit + >; + notFoundAsNull?: boolean; + responseType?: ResponseType; + transformResponse?: TransformResponseFn; + throwOnError?: boolean; + meta?: QueryMeta; +}; + +export type FetchQueryKeyMeta = Pick< + FetchQueryParams, + | 'baseUrl' + | 'method' + | 'headers' + | 'body' + | 'notFoundAsNull' + | 'responseType' + | 'request' +>; + +export type FetchQueryKey = readonly ( + | string + | Dict + | FetchQueryKeyMeta + | null +)[]; +export interface FetchQueryStartParams { + params: FetchQueryParams; +} + +export interface FetchQueryConfig + extends Omit< + Partial>, + 'queryClient' | 'queryFn' | 'queryKey' | 'options' | 'select' + > { + queryClient: AnyQueryClient; + params?: MaybeFn>>; + select?: (data: TOutputData) => TData; +} + +export interface FetchQueryPositionalConfig + extends Omit< + Partial>, + 'queryClient' | 'queryFn' | 'queryKey' | 'options' | 'select' + > { + params?: MaybeFn>>; + select?: (data: TOutputData) => TData; +} + +export const serializeFetchQuery = ( + query?: FetchQueryParams['query'], +): Dict | undefined => { + if (!query) { + return undefined; + } + + const entries = Object.entries(query) + .filter(([, value]) => value != null) + .map(([key, value]) => [key, String(value)] as const); + + return entries.length ? Object.fromEntries(entries) : undefined; +}; + +export const buildFetchQueryUrl = ( + path: string, + query: Dict | undefined, +): string => { + if (!query) { + return path; + } + + const params = new URLSearchParams(query); + const queryString = params.toString(); + + if (!queryString) { + return path; + } + + return path.includes('?') + ? `${path}&${queryString}` + : `${path}?${queryString}`; +}; + +export const isNativeBodyInit = (body: unknown): body is BodyInit => { + return ( + typeof body === 'string' || + body instanceof Blob || + body instanceof FormData || + body instanceof URLSearchParams || + body instanceof ReadableStream || + body instanceof ArrayBuffer || + ArrayBuffer.isView(body) + ); +}; + +export const buildFetchQueryKey = ( + fetchParams: FetchQueryParams | null, +): FetchQueryKey => { + if (!fetchParams) { + return [null]; + } + + return [ + ...fetchParams.path.split('/'), + serializeFetchQuery(fetchParams.query) as any, + { + baseUrl: fetchParams.baseUrl, + method: fetchParams.method, + headers: fetchParams.headers, + body: fetchParams.body as any, + notFoundAsNull: fetchParams.notFoundAsNull, + responseType: fetchParams.responseType, + request: fetchParams.request, + } satisfies FetchQueryKeyMeta, + ]; +}; + +export const parseFetchQueryKey = ( + queryKey: FetchQueryKey, +): FetchQueryParams | null => { + if (!queryKey.length || queryKey[0] === null) { + return null; + } + + const meta = queryKey[queryKey.length - 1] as FetchQueryKeyMeta; + const serializedQuery = queryKey[queryKey.length - 2] as + | Dict + | undefined; + const path = (queryKey.slice(0, -2) as string[]).join('/'); + + return { + path, + query: serializedQuery, + baseUrl: meta.baseUrl, + method: meta.method, + headers: meta.headers, + body: meta.body, + notFoundAsNull: meta.notFoundAsNull, + responseType: meta.responseType, + request: meta.request, + }; +}; + +export class FetchQuery extends Query< + unknown, + DefaultError, + TData +> { + private _holders!: { + params: MaybeFn>>; + transformResponse: undefined | TransformResponseFn; + }; + + constructor(config: FetchQueryConfig); + constructor( + queryClient: AnyQueryClient, + config: FetchQueryPositionalConfig, + ); + + constructor( + queryClientOrConfig: AnyQueryClient | FetchQueryConfig, + positionalConfig?: FetchQueryPositionalConfig, + ) { + let queryFullConfig: FetchQueryConfig; + + if ( + 'queryClient' in + (queryClientOrConfig as FetchQueryConfig) + ) { + queryFullConfig = queryClientOrConfig as FetchQueryConfig< + TData, + TOutputData + >; + } else { + queryFullConfig = { + ...positionalConfig, + queryClient: queryClientOrConfig as AnyQueryClient, + } as FetchQueryConfig; + } + + const holders: { + params: MaybeFn>>; + transformResponse: undefined | TransformResponseFn; + } = { + params: queryFullConfig.params, + transformResponse: undefined, + }; + + super({ + ...(queryFullConfig as any), + options: () => { + const fetchParams = callFunction(holders.params) || null; + const qc = queryFullConfig.queryClient as QueryClient; + + holders.transformResponse = fetchParams?.transformResponse; + + return { + enabled: !!fetchParams, + queryKey: buildFetchQueryKey(fetchParams), + meta: fetchParams?.meta ?? qc.fetchQueries?.meta, + }; + }, + queryFn: async ({ signal, queryKey }) => { + const fetchParams = parseFetchQueryKey(queryKey as FetchQueryKey); + + if (!fetchParams) { + if (process.env.NODE_ENV !== 'production') { + throw new Error( + 'Error #1: Fetch query is not configured.\n' + + 'This happened because the "params" option for FetchQuery returned a falsy value and the query key could not be parsed.\n' + + 'Please ensure that "params" returns a valid FetchQueryParams object with at least a "path" property.\n' + + 'More info: https://js2me.github.io/mobx-tanstack-query/errors/1', + ); + } + throw new Error( + 'Error #1: https://js2me.github.io/mobx-tanstack-query/errors/1', + ); + } + + return makeFetchRequest({ + fetchParams, + transformResponse: holders.transformResponse, + queryClient: queryFullConfig.queryClient, + signal, + }); + }, + }); + + this._holders = holders; + } + + start( + startParams: FetchQueryStartParams, + ): Promise>; + start( + startParams?: QueryStartParams, + ): Promise>; + start(startParams: any): Promise> { + if (startParams?.params) { + const fetchParams: FetchQueryParams = startParams.params; + this._holders.params = fetchParams; + this._holders.transformResponse = fetchParams.transformResponse; + + return super.start({ + queryKey: buildFetchQueryKey(fetchParams), + enabled: true, + } as any); + } + + return super.start(startParams); + } +} diff --git a/src/index.ts b/src/index.ts index ec2f952..936d82f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,5 @@ +export * from './fetch-infinite-query.js'; +export * from './fetch-query.js'; export * from './inifinite-query.js'; export * from './inifinite-query.types.js'; export * from './mutation.js'; diff --git a/src/inifinite-query.types.ts b/src/inifinite-query.types.ts index 4174f29..ece74b9 100644 --- a/src/inifinite-query.types.ts +++ b/src/inifinite-query.types.ts @@ -8,9 +8,6 @@ import type { RefetchOptions, ThrowOnError, } from '@tanstack/query-core'; - -export type { InfiniteQueryObserverResult }; - import type { InfiniteQuery } from './inifinite-query.js'; import type { QueryFeatures, @@ -20,6 +17,19 @@ import type { } from './query.types.js'; import type { AnyQueryClient } from './query-client.types.js'; +export type InfiniteQueryLike< + TQueryFnData = unknown, + TError = DefaultError, + TPageParam = unknown, + TData = InfiniteData, + TQueryKey extends QueryKey = QueryKey, +> = Omit< + InfiniteQuery, + 'start' | 'update' +>; + +export type { InfiniteQueryObserverResult }; + export type InfiniteQueryErrorListener = ( error: TError, payload: void, diff --git a/src/preset/create-fetch-infinite-query.test.ts b/src/preset/create-fetch-infinite-query.test.ts new file mode 100644 index 0000000..4805b8c --- /dev/null +++ b/src/preset/create-fetch-infinite-query.test.ts @@ -0,0 +1,147 @@ +import type { InfiniteData } from '@tanstack/query-core'; +import { FetchInfiniteQuery, QueryClient } from 'mobx-tanstack-query'; +import { describe, expect, expectTypeOf, it } from 'vitest'; +import { createFetchInfiniteQuery } from './create-fetch-infinite-query.js'; + +describe('createFetchInfiniteQuery', () => { + it('preserves typings for overloads', () => { + type User = { + id: string; + email: string; + }; + + // options-only overload — queryClient defaults to preset + const queryFromOptions = createFetchInfiniteQuery({ + params: () => ({ path: '/users' }), + initialPageParam: 0, + getNextPageParam: () => undefined, + }); + + expectTypeOf(queryFromOptions.data).toMatchTypeOf< + InfiniteData | undefined + >(); + + // options-only overload with explicit generics + const queryFromOptionsWithGenerics = createFetchInfiniteQuery({ + params: () => ({ path: '/users' }), + initialPageParam: 0, + getNextPageParam: () => undefined, + }); + + expectTypeOf(queryFromOptionsWithGenerics.data).toMatchTypeOf< + InfiniteData | undefined + >(); + + // options-only overload with select + const queryFromOptionsWithSelect = createFetchInfiniteQuery< + User, + Error, + number, + string + >({ + params: () => ({ path: '/users' }), + initialPageParam: 0, + getNextPageParam: () => undefined, + select: (data) => data.pages.map((u) => u.email).join(','), + }); + + expectTypeOf(queryFromOptionsWithSelect.data).toEqualTypeOf< + string | undefined + >(); + + // queryClient + dynamic options overload + const queryFromClientAndOptions = createFetchInfiniteQuery< + User, + Error, + number, + string + >(new QueryClient(), () => ({ + params: () => ({ path: '/users' }), + initialPageParam: 0, + getNextPageParam: () => undefined, + select: (data) => data.pages.map((u) => u.email).join(','), + })); + + expectTypeOf(queryFromClientAndOptions.data).toEqualTypeOf< + string | undefined + >(); + + // queryClient + static options overload + const queryFromClientAndStaticOptions = createFetchInfiniteQuery( + new QueryClient(), + { + params: () => ({ path: '/users' }), + initialPageParam: 0, + getNextPageParam: () => undefined, + }, + ); + + expectTypeOf(queryFromClientAndStaticOptions.data).toMatchTypeOf< + InfiniteData | undefined + >(); + + // with explicit TPageParam generic + const queryWithPageParam = createFetchInfiniteQuery({ + params: () => ({ path: '/users', pageParam: 0 }), + initialPageParam: 0, + getNextPageParam: (lastPage, _allPages, lastPageParam) => + lastPage.id ? lastPageParam + 1 : undefined, + }); + + expectTypeOf(queryWithPageParam.data).toMatchTypeOf< + InfiniteData | undefined + >(); + }); + + it('returns FetchInfiniteQuery instance from options overload', () => { + const query = createFetchInfiniteQuery({ + params: () => ({ path: '/test' }), + initialPageParam: 0, + getNextPageParam: () => undefined, + }); + + expect(query).toBeInstanceOf(FetchInfiniteQuery); + + query.destroy(); + }); + + it('returns FetchInfiniteQuery instance from queryClient overload', () => { + const client = new QueryClient(); + const query = createFetchInfiniteQuery(client, { + params: () => ({ path: '/test' }), + initialPageParam: 0, + getNextPageParam: () => undefined, + }); + + expect(query).toBeInstanceOf(FetchInfiniteQuery); + + query.destroy(); + }); + + it('uses preset queryClient when not provided', () => { + const query = createFetchInfiniteQuery({ + params: () => ({ path: '/test' }), + initialPageParam: 0, + getNextPageParam: () => undefined, + }); + + expect(query).toBeInstanceOf(FetchInfiniteQuery); + + query.destroy(); + }); + + it('uses explicit queryClient in single options overload', () => { + const customClient = new QueryClient(); + + const query = createFetchInfiniteQuery({ + queryClient: customClient, + params: () => ({ path: '/test' }), + initialPageParam: 0, + getNextPageParam: () => undefined, + }); + + expect(query).toBeInstanceOf(FetchInfiniteQuery); + + query.destroy(); + }); +}); diff --git a/src/preset/create-fetch-infinite-query.ts b/src/preset/create-fetch-infinite-query.ts new file mode 100644 index 0000000..6522bda --- /dev/null +++ b/src/preset/create-fetch-infinite-query.ts @@ -0,0 +1,99 @@ +import type { DefaultError, InfiniteData } from '@tanstack/query-core'; +import { + type AnyQueryClient, + FetchInfiniteQuery, + type FetchInfiniteQueryConfig, + getQueryClient, + mountQueryClientOnce, +} from 'mobx-tanstack-query'; +import type { PartialKeys } from 'yummies/types'; +import { queryClient } from './query-client.js'; + +export interface CreateFetchInfiniteQueryFnConfig< + TQueryFnData = unknown, + TError = DefaultError, + TPageParam = unknown, + TData = InfiniteData, + TOutputData = any, +> extends FetchInfiniteQueryConfig< + TQueryFnData, + TError, + TPageParam, + TData, + TOutputData + > {} + +export function createFetchInfiniteQuery< + TQueryFnData = unknown, + TError = DefaultError, + TPageParam = unknown, + TData = InfiniteData, + TOutputData = any, +>( + queryClient: AnyQueryClient, + options: + | Omit< + Partial< + CreateFetchInfiniteQueryFnConfig< + TQueryFnData, + TError, + TPageParam, + TData, + TOutputData + > + >, + 'queryClient' + > + | (() => Omit< + Partial< + CreateFetchInfiniteQueryFnConfig< + TQueryFnData, + TError, + TPageParam, + TData, + TOutputData + > + >, + 'queryClient' + >), +): FetchInfiniteQuery; + +export function createFetchInfiniteQuery< + TQueryFnData = unknown, + TError = DefaultError, + TPageParam = unknown, + TData = InfiniteData, + TOutputData = any, +>( + options: PartialKeys< + CreateFetchInfiniteQueryFnConfig< + TQueryFnData, + TError, + TPageParam, + TData, + TOutputData + >, + 'queryClient' + >, +): FetchInfiniteQuery; + +export function createFetchInfiniteQuery(...args: [any, any?]) { + let fetchInfiniteQuery: FetchInfiniteQuery; + + if (args.length === 2) { + fetchInfiniteQuery = new FetchInfiniteQuery( + args[0], + typeof args[1] === 'function' ? args[1] : () => args[1], + ); + } else { + const options = args[0]; + fetchInfiniteQuery = new FetchInfiniteQuery({ + ...options, + queryClient: options.queryClient ?? queryClient, + }); + } + + mountQueryClientOnce(getQueryClient(fetchInfiniteQuery)); + + return fetchInfiniteQuery; +} diff --git a/src/preset/create-fetch-query.test.ts b/src/preset/create-fetch-query.test.ts new file mode 100644 index 0000000..4f0972a --- /dev/null +++ b/src/preset/create-fetch-query.test.ts @@ -0,0 +1,195 @@ +import { FetchQuery, QueryClient } from 'mobx-tanstack-query'; +import { describe, expect, expectTypeOf, it } from 'vitest'; +import { createFetchQuery } from './create-fetch-query.js'; + +describe('createFetchQuery', () => { + it('preserves typings for overloads', () => { + type User = { + id: string; + email: string; + }; + + // options-only overload — queryClient defaults to preset + const queryFromOptions = createFetchQuery({ + params: () => ({ path: '/users/1' }), + }); + + expectTypeOf(queryFromOptions.data).toEqualTypeOf(); + + // options-only overload with explicit generics + const queryFromOptionsWithGenerics = createFetchQuery({ + params: () => ({ path: '/users/1' }), + }); + + expectTypeOf(queryFromOptionsWithGenerics.data).toEqualTypeOf< + User | undefined + >(); + + // options-only overload with select + const queryFromOptionsWithSelect = createFetchQuery({ + params: () => ({ path: '/users/1' }), + select: (user) => user.email, + }); + + expectTypeOf(queryFromOptionsWithSelect.data).toEqualTypeOf< + string | undefined + >(); + + // queryClient + dynamic options overload + const queryFromClientAndOptions = createFetchQuery( + new QueryClient(), + () => ({ + params: () => ({ path: '/users/1' }), + select: (user) => user.email, + }), + ); + + expectTypeOf(queryFromClientAndOptions.data).toEqualTypeOf< + string | undefined + >(); + + // queryClient + static options overload + const queryFromClientAndStaticOptions = createFetchQuery( + new QueryClient(), + { + params: () => ({ path: '/users/1' }), + }, + ); + + expectTypeOf(queryFromClientAndStaticOptions.data).toEqualTypeOf< + User | undefined + >(); + }); + + it('returns FetchQuery instance from options overload', () => { + const query = createFetchQuery({ + params: () => ({ path: '/test' }), + }); + + expect(query).toBeInstanceOf(FetchQuery); + + query.destroy(); + }); + + it('returns FetchQuery instance from queryClient overload', () => { + const client = new QueryClient(); + const query = createFetchQuery(client, { + params: () => ({ path: '/test' }), + }); + + expect(query).toBeInstanceOf(FetchQuery); + + query.destroy(); + }); + + it('uses preset queryClient when not provided', () => { + const query = createFetchQuery({ + params: () => ({ path: '/test' }), + }); + + // The preset queryClient should be used + expect(query).toBeInstanceOf(FetchQuery); + + query.destroy(); + }); + + it('uses explicit queryClient in single options overload', () => { + const customClient = new QueryClient(); + + const query = createFetchQuery({ + queryClient: customClient, + params: () => ({ path: '/test' }), + }); + + expect(query).toBeInstanceOf(FetchQuery); + + query.destroy(); + }); + + it('accepts params as a plain object in options-only overload', () => { + const query = createFetchQuery({ + params: { path: '/users/1' }, + }); + + expect(query).toBeInstanceOf(FetchQuery); + + query.destroy(); + }); + + it('accepts params as a plain object with explicit queryClient', () => { + const customClient = new QueryClient(); + + const query = createFetchQuery({ + queryClient: customClient, + params: { path: '/users/1' }, + }); + + expect(query).toBeInstanceOf(FetchQuery); + + query.destroy(); + }); + + it('accepts params as a plain object in queryClient + options overload', () => { + const client = new QueryClient(); + const query = createFetchQuery(client, { + params: { path: '/users/1' }, + }); + + expect(query).toBeInstanceOf(FetchQuery); + + query.destroy(); + }); + + it('accepts params as a plain object with select in options-only overload', () => { + type User = { id: string; email: string }; + + const query = createFetchQuery({ + params: { path: '/users/1' }, + select: (user) => user.email, + }); + + expect(query).toBeInstanceOf(FetchQuery); + + query.destroy(); + }); + + it('preserves typings when params is a plain object', () => { + type User = { + id: string; + email: string; + }; + + // options-only overload with plain params object + const queryFromOptions = createFetchQuery({ + params: { path: '/users/1' }, + }); + + expectTypeOf(queryFromOptions.data).toEqualTypeOf(); + + // options-only overload with plain params object and select + const queryFromOptionsWithSelect = createFetchQuery({ + params: { path: '/users/1' }, + select: (user) => { + expectTypeOf(user).toEqualTypeOf(); + expectTypeOf(user.email).toEqualTypeOf(); + return user.email; + }, + }); + + expectTypeOf(queryFromOptionsWithSelect.data).toEqualTypeOf< + string | undefined + >(); + + // queryClient + static options overload with plain params object + const queryFromClientAndStaticOptions = createFetchQuery( + new QueryClient(), + { + params: { path: '/users/1' }, + }, + ); + + expectTypeOf(queryFromClientAndStaticOptions.data).toEqualTypeOf< + User | undefined + >(); + }); +}); diff --git a/src/preset/create-fetch-query.ts b/src/preset/create-fetch-query.ts new file mode 100644 index 0000000..d2f17b3 --- /dev/null +++ b/src/preset/create-fetch-query.ts @@ -0,0 +1,52 @@ +import { + type AnyQueryClient, + FetchQuery, + type FetchQueryConfig, + getQueryClient, + mountQueryClientOnce, +} from 'mobx-tanstack-query'; +import type { PartialKeys } from 'yummies/types'; +import { queryClient } from './query-client.js'; + +export type CreateFetchQueryFnConfig< + TData = unknown, + TOutputData = any, +> = FetchQueryConfig; + +export function createFetchQuery( + queryClient: AnyQueryClient, + options: + | Omit>, 'queryClient'> + | (() => Omit< + Partial>, + 'queryClient' + >), +): FetchQuery; + +export function createFetchQuery( + options: PartialKeys< + CreateFetchQueryFnConfig, + 'queryClient' + >, +): FetchQuery; + +export function createFetchQuery(...args: [any, any?]) { + let fetchQuery: FetchQuery; + + if (args.length === 2) { + fetchQuery = new FetchQuery( + args[0], + typeof args[1] === 'function' ? args[1] : () => args[1], + ); + } else { + const options = args[0]; + fetchQuery = new FetchQuery({ + ...options, + queryClient: options.queryClient ?? queryClient, + }); + } + + mountQueryClientOnce(getQueryClient(fetchQuery)); + + return fetchQuery; +} diff --git a/src/preset/index.ts b/src/preset/index.ts index 0c5dd54..b4fa3de 100644 --- a/src/preset/index.ts +++ b/src/preset/index.ts @@ -1,4 +1,6 @@ export * from './configs/index.js'; +export * from './create-fetch-infinite-query.js'; +export * from './create-fetch-query.js'; export * from './create-infinite-query.js'; export * from './create-mutation.js'; export * from './create-query.js'; diff --git a/src/query-client.ts b/src/query-client.ts index 3ed9fd0..dd59c03 100644 --- a/src/query-client.ts +++ b/src/query-client.ts @@ -3,6 +3,7 @@ import { QueryClient as QueryClientCore } from '@tanstack/query-core'; import type { MutationFeatures } from './mutation.types.js'; import type { QueryFeatures } from './query.types.js'; import type { + FetchQueriesOptions, IQueryClientCore, QueryClientConfig, QueryClientHooks, @@ -11,9 +12,12 @@ import type { export class QueryClient extends QueryClientCore implements IQueryClientCore { hooks?: QueryClientHooks; + fetchQueries: FetchQueriesOptions; + constructor(private config: QueryClientConfig = {}) { super(config); this.hooks = config.hooks; + this.fetchQueries = config.fetchQueries ?? {}; } setDefaultOptions( diff --git a/src/query-client.types.ts b/src/query-client.types.ts index f44f143..54508a0 100644 --- a/src/query-client.types.ts +++ b/src/query-client.types.ts @@ -3,8 +3,10 @@ import type { DefaultError, QueryClient as QueryClientCore, QueryClientConfig as QueryClientCoreConfig, + QueryMeta, } from '@tanstack/query-core'; - +import type { Dict } from 'yummies/types'; +import type { TransformResponseFn } from './fetch-query.js'; import type { InfiniteQuery } from './inifinite-query.js'; import type { Mutation } from './mutation.js'; import type { MutationFeatures } from './mutation.types.js'; @@ -34,8 +36,21 @@ export interface QueryClientHooks { onMutationDestroy?: (query: Mutation) => void; } +export interface FetchQueriesOptions { + baseUrl?: string; + headers?: Dict; + customFetch?: typeof globalThis.fetch; + transformResponse?: TransformResponseFn; + throwOnError?: boolean; + timeout?: number; + credentials?: RequestCredentials; + mode?: RequestMode; + meta?: QueryMeta; +} + export interface QueryClientConfig extends Omit { defaultOptions?: DefaultOptions; hooks?: QueryClientHooks; + fetchQueries?: FetchQueriesOptions; } diff --git a/src/utils/make-fetch-request.ts b/src/utils/make-fetch-request.ts new file mode 100644 index 0000000..873f364 --- /dev/null +++ b/src/utils/make-fetch-request.ts @@ -0,0 +1,97 @@ +import type { FetchQueryParams, TransformResponseFn } from '../fetch-query.js'; +import { + buildFetchQueryUrl, + isNativeBodyInit, + serializeFetchQuery, +} from '../fetch-query.js'; +import type { QueryClient } from '../query-client.js'; +import type { AnyQueryClient } from '../query-client.types.js'; + +export interface MakeFetchRequestOptions { + fetchParams: Omit, 'transformResponse'>; + transformResponse?: TransformResponseFn; + queryClient: AnyQueryClient; + signal: AbortSignal; +} + +export async function makeFetchRequest( + options: MakeFetchRequestOptions, +): Promise { + const { fetchParams, queryClient, signal } = options; + const clientFetchQueries = (queryClient as QueryClient).fetchQueries ?? {}; + const throwOnError = + fetchParams.throwOnError ?? clientFetchQueries.throwOnError ?? true; + + const transformResponse = + options.transformResponse ?? clientFetchQueries.transformResponse; + + const baseUrl = fetchParams.baseUrl ?? clientFetchQueries.baseUrl; + + const serializedQuery = serializeFetchQuery(fetchParams.query); + const url = buildFetchQueryUrl( + baseUrl ? `${baseUrl}${fetchParams.path}` : fetchParams.path, + serializedQuery, + ); + + let fetchBody: BodyInit | undefined; + const clientHeaders = clientFetchQueries.headers; + let fetchHeaders: Record | undefined = + clientHeaders || fetchParams.headers + ? { ...clientHeaders, ...fetchParams.headers } + : undefined; + + if (fetchParams.body != null) { + if (isNativeBodyInit(fetchParams.body)) { + fetchBody = fetchParams.body; + } else { + fetchBody = JSON.stringify(fetchParams.body); + fetchHeaders = { + 'Content-Type': 'application/json', + ...fetchHeaders, + }; + } + } + + const customFetch = clientFetchQueries.customFetch; + const fetch = customFetch ?? globalThis.fetch; + + let abortSignal = options.signal; + + if (clientFetchQueries.timeout) { + const abortControllerWithTimeout = new AbortController(); + const abortHandler = () => abortControllerWithTimeout.abort(); + + signal.addEventListener('abort', abortHandler, { once: true }); + + const timeoutSignal = AbortSignal.timeout(clientFetchQueries.timeout); + timeoutSignal.addEventListener('abort', abortHandler, { once: true }); + abortSignal = abortControllerWithTimeout.signal; + } + + const requestInit: RequestInit = { + credentials: clientFetchQueries.credentials, + mode: clientFetchQueries.mode, + ...fetchParams.request, + method: + fetchParams.method ?? (fetchParams.body != null ? 'POST' : undefined), + headers: fetchHeaders, + body: fetchBody, + signal: abortSignal, + }; + + const response = await fetch(url, requestInit); + + if (!response.ok) { + if (fetchParams.notFoundAsNull && response.status === 404) { + return null; + } + + if (throwOnError) { + throw response; + } + } + + return transformResponse + ? await transformResponse(response, fetchParams) + : await response[fetchParams.responseType ?? 'json'](); +} diff --git a/tsconfig.json b/tsconfig.json index 003b840..57a9e67 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -28,5 +28,8 @@ }, "include": [ "src" + ], + "exclude": [ + "src/**/*.test.ts" ] } diff --git a/tsconfig.test.json b/tsconfig.test.json index e8fe33c..1ec87e4 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -2,7 +2,7 @@ "compilerOptions": { "types": ["vitest", "node"], "target": "ESNext", - "rootDir": "src", + "rootDir": ".", "outDir": "dist", "declaration": true, "declarationMap": true, @@ -28,7 +28,8 @@ "jsx": "react-jsx" }, "include": [ - "src/**/*.test.ts" + "src/**/*.test.ts", + "scripts/**/*.ts" ], "exclude": [ "node_modules"