openapi-typesafe-react-query 1.0.0-rc.3 → 1.0.0-rc.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +416 -0
- package/README.md +11 -1
- package/dist/index.cjs +163 -33
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +90 -15
- package/dist/index.d.mts +91 -16
- package/dist/index.d.ts +91 -16
- package/dist/index.mjs +159 -33
- package/dist/index.mjs.map +1 -1
- package/package.json +7 -9
- package/src/index.ts +217 -79
- package/src/query-key.ts +136 -0
- package/src/query-signal.ts +47 -0
package/src/index.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type DataTag,
|
|
3
|
+
type DefaultError,
|
|
4
|
+
type DefinedUseQueryResult,
|
|
3
5
|
type InfiniteData,
|
|
4
6
|
type QueryClient,
|
|
5
7
|
type QueryFunctionContext,
|
|
@@ -25,12 +27,17 @@ import type {
|
|
|
25
27
|
MaybeOptionalInit,
|
|
26
28
|
PathOperation,
|
|
27
29
|
} from "openapi-typesafe-fetch";
|
|
30
|
+
// The helper types come from the client, which declares them: types inferred from the hooks then
|
|
31
|
+
// print the client's `helpers` entry, of a peer every user has, and not `openapi-typesafe-helpers`,
|
|
32
|
+
// which Yarn PnP does not let a library reach (ADR-034).
|
|
28
33
|
import type {
|
|
29
34
|
HttpMethod,
|
|
30
35
|
MediaType,
|
|
31
36
|
PathsWithMethod,
|
|
32
37
|
RequiredKeysOf,
|
|
33
|
-
} from "openapi-typesafe-helpers";
|
|
38
|
+
} from "openapi-typesafe-fetch/helpers";
|
|
39
|
+
import { queryKeyInit } from "./query-key.js";
|
|
40
|
+
import { withQuerySignal } from "./query-signal.js";
|
|
34
41
|
|
|
35
42
|
// Helper type to dynamically infer the type from the `select` property
|
|
36
43
|
type InferSelectReturnType<TData, TSelect> = TSelect extends (data: TData) => infer R ? R : TData;
|
|
@@ -41,6 +48,46 @@ type InferSelectReturnType<TData, TSelect> = TSelect extends (data: TData) => in
|
|
|
41
48
|
*/
|
|
42
49
|
type QueryData<Data> = undefined extends Data ? Exclude<Data, undefined> | null : Data;
|
|
43
50
|
|
|
51
|
+
/**
|
|
52
|
+
* What the hooks throw when a request fails and the response has no error body: a non-OK status
|
|
53
|
+
* with an empty body, a JSON `null` body, or one whose operation declares no content for it. The
|
|
54
|
+
* fetch client gives `error: undefined` (or `null`) then; throwing that left `error` empty in
|
|
55
|
+
* TanStack Query while its type said there was an error body. Tell it apart with
|
|
56
|
+
* `isResponseError(error)`: `error instanceof Error` also matches a network or parse error. The
|
|
57
|
+
* message names the status, not the URL, which can carry secrets.
|
|
58
|
+
*/
|
|
59
|
+
export interface ResponseError extends Error {
|
|
60
|
+
readonly name: "ResponseError";
|
|
61
|
+
/** The response that failed. */
|
|
62
|
+
readonly response: Response;
|
|
63
|
+
/** Its HTTP status, as `response.status`. */
|
|
64
|
+
readonly status: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The error of a hook: the error body of a failed response, a `ResponseError` when it has none, or
|
|
69
|
+
* what the request threw before there was a response to read: a network error (`TypeError`), an
|
|
70
|
+
* abort, a body that is not JSON (`SyntaxError`) or a middleware error. TanStack Query types that
|
|
71
|
+
* last part as `DefaultError` (`Error` unless `Register` says otherwise; a registered `unknown`
|
|
72
|
+
* makes the whole type `unknown`, as for TanStack's own hooks). `undefined` and `null` are left
|
|
73
|
+
* out: a failed response without an error body throws a `ResponseError`.
|
|
74
|
+
*/
|
|
75
|
+
export type QueryError<ErrorBody> =
|
|
76
|
+
| Exclude<ErrorBody, undefined | null>
|
|
77
|
+
| ResponseError
|
|
78
|
+
| DefaultError;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* An object whose `queryKey` is tagged with the query's data and error types: the type that
|
|
82
|
+
* TanStack Query 5.102 exports as `QueryKeyWithDataTag`, for the whole peer range. A named object
|
|
83
|
+
* type, so the declarations of an exported `queryOptions` result print it by name. Spelled out,
|
|
84
|
+
* the tag prints TanStack's `dataTagSymbol` keys, which a declaration file cannot import, and
|
|
85
|
+
* the library's users get TS2304, or `any` cached data with `skipLibCheck`.
|
|
86
|
+
*/
|
|
87
|
+
export type QueryKeyWithDataTag<Key, Data, Error> = {
|
|
88
|
+
queryKey: DataTag<Key, Data, Error>;
|
|
89
|
+
};
|
|
90
|
+
|
|
44
91
|
type InitWithUnknowns<Init> = Init & { [key: string]: unknown };
|
|
45
92
|
|
|
46
93
|
/**
|
|
@@ -56,6 +103,12 @@ type InitArguments<Init, Rest extends unknown[]> =
|
|
|
56
103
|
? [InitWithUnknowns<Init>?, ...Rest]
|
|
57
104
|
: [InitWithUnknowns<Init>, ...Rest];
|
|
58
105
|
|
|
106
|
+
/**
|
|
107
|
+
* The key of a query: the method, the path and the request. Its init is the init of the call with
|
|
108
|
+
* the headers and the body as plain values, and the client's `keyScope`, or its `baseUrl` when the
|
|
109
|
+
* init has none, so two requests never share a key; it is left out when nothing is left. Read a
|
|
110
|
+
* query's key from `queryOptions(...).queryKey` rather than writing it out.
|
|
111
|
+
*/
|
|
59
112
|
export type QueryKey<
|
|
60
113
|
Paths extends {},
|
|
61
114
|
Method extends HttpMethod,
|
|
@@ -66,8 +119,8 @@ export type QueryKey<
|
|
|
66
119
|
/**
|
|
67
120
|
* The last element of an infinite query's key. `useQuery` and `useInfiniteQuery` cache different
|
|
68
121
|
* data (`TData` and `InfiniteData<TData>`), so the same request must not share one key (upstream
|
|
69
|
-
* #2184, ADR-022). A prefix such as `[method, path]` still matches both. Not exported:
|
|
70
|
-
*
|
|
122
|
+
* #2184, ADR-022). A prefix such as `[method, path]` still matches both. Not exported: it is not
|
|
123
|
+
* needed outside the package, and a named export is public API; `InfiniteQueryKey` types the key.
|
|
71
124
|
*/
|
|
72
125
|
const INFINITE_QUERY_KEY_MARKER = "infinite";
|
|
73
126
|
|
|
@@ -77,6 +130,36 @@ export type InfiniteQueryKey<
|
|
|
77
130
|
Path extends PathsWithMethod<Paths, Method>,
|
|
78
131
|
> = readonly [...QueryKey<Paths, Method, Path>, typeof INFINITE_QUERY_KEY_MARKER];
|
|
79
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Whether the options give an `initialData` that is never `undefined`: a value, or a function that
|
|
135
|
+
* never returns `undefined`. Then `data` is never `undefined` either, as for TanStack Query's own
|
|
136
|
+
* `useQuery` with `DefinedInitialDataOptions`.
|
|
137
|
+
*/
|
|
138
|
+
type HasDefinedInitialData<Options> = Options extends { initialData: infer Initial }
|
|
139
|
+
? Initial extends (...args: never) => infer Returned
|
|
140
|
+
? undefined extends Returned
|
|
141
|
+
? false
|
|
142
|
+
: true
|
|
143
|
+
: undefined extends Initial
|
|
144
|
+
? false
|
|
145
|
+
: true
|
|
146
|
+
: false;
|
|
147
|
+
|
|
148
|
+
/** The result of `useQuery`: its `data` is defined when the options give defined initial data. */
|
|
149
|
+
type QueryResult<Data, Error, Options> =
|
|
150
|
+
HasDefinedInitialData<Options> extends true
|
|
151
|
+
? DefinedUseQueryResult<Data, Error>
|
|
152
|
+
: UseQueryResult<Data, Error>;
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* A defined `initialData` of the options, in the type `queryOptions` returns, so TanStack Query's
|
|
156
|
+
* `useQuery` takes the result as options with defined initial data. It is typed as the query data,
|
|
157
|
+
* as TanStack's `DefinedInitialDataOptions` types it: the type of a literal such as `[{ id: 1 }]`
|
|
158
|
+
* lacks the optional properties of the data, and TanStack's overload rejected it.
|
|
159
|
+
*/
|
|
160
|
+
type InitialDataOf<Options, Data> =
|
|
161
|
+
HasDefinedInitialData<Options> extends true ? { initialData: Data | (() => Data) } : unknown;
|
|
162
|
+
|
|
80
163
|
export type QueryOptionsFunction<Paths extends {}, Media extends MediaType> = <
|
|
81
164
|
Method extends HttpMethod,
|
|
82
165
|
Path extends PathsWithMethod<Paths, Method>,
|
|
@@ -86,7 +169,7 @@ export type QueryOptionsFunction<Paths extends {}, Media extends MediaType> = <
|
|
|
86
169
|
Options extends Omit<
|
|
87
170
|
UseQueryOptions<
|
|
88
171
|
QueryData<Response["data"]>,
|
|
89
|
-
Response["error"]
|
|
172
|
+
QueryError<Response["error"]>,
|
|
90
173
|
InferSelectReturnType<QueryData<Response["data"]>, Options["select"]>,
|
|
91
174
|
QueryKey<Paths, Method, Path>
|
|
92
175
|
>,
|
|
@@ -100,27 +183,28 @@ export type QueryOptionsFunction<Paths extends {}, Media extends MediaType> = <
|
|
|
100
183
|
Omit<
|
|
101
184
|
UseQueryOptions<
|
|
102
185
|
QueryData<Response["data"]>,
|
|
103
|
-
Response["error"]
|
|
186
|
+
QueryError<Response["error"]>,
|
|
104
187
|
InferSelectReturnType<QueryData<Response["data"]>, Options["select"]>,
|
|
105
188
|
QueryKey<Paths, Method, Path>
|
|
106
189
|
>,
|
|
107
190
|
"queryFn" | "queryKey"
|
|
108
|
-
> &
|
|
109
|
-
|
|
191
|
+
> &
|
|
192
|
+
QueryKeyWithDataTag<
|
|
110
193
|
QueryKey<Paths, Method, Path>,
|
|
111
194
|
QueryData<Response["data"]>,
|
|
112
|
-
Response["error"]
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
195
|
+
QueryError<Response["error"]>
|
|
196
|
+
> &
|
|
197
|
+
InitialDataOf<Options, QueryData<Response["data"]>> & {
|
|
198
|
+
queryFn: Exclude<
|
|
199
|
+
UseQueryOptions<
|
|
200
|
+
QueryData<Response["data"]>,
|
|
201
|
+
QueryError<Response["error"]>,
|
|
202
|
+
InferSelectReturnType<QueryData<Response["data"]>, Options["select"]>,
|
|
203
|
+
QueryKey<Paths, Method, Path>
|
|
204
|
+
>["queryFn"],
|
|
205
|
+
SkipToken | undefined
|
|
206
|
+
>;
|
|
207
|
+
}
|
|
124
208
|
>;
|
|
125
209
|
|
|
126
210
|
export type UseQueryMethod<Paths extends {}, Media extends MediaType> = <
|
|
@@ -132,7 +216,7 @@ export type UseQueryMethod<Paths extends {}, Media extends MediaType> = <
|
|
|
132
216
|
Options extends Omit<
|
|
133
217
|
UseQueryOptions<
|
|
134
218
|
QueryData<Response["data"]>,
|
|
135
|
-
Response["error"]
|
|
219
|
+
QueryError<Response["error"]>,
|
|
136
220
|
InferSelectReturnType<QueryData<Response["data"]>, Options["select"]>,
|
|
137
221
|
QueryKey<Paths, Method, Path>
|
|
138
222
|
>,
|
|
@@ -142,9 +226,10 @@ export type UseQueryMethod<Paths extends {}, Media extends MediaType> = <
|
|
|
142
226
|
method: Method,
|
|
143
227
|
url: Path,
|
|
144
228
|
...[init, options, queryClient]: InitArguments<Init, [Options?, QueryClient?]>
|
|
145
|
-
) =>
|
|
229
|
+
) => QueryResult<
|
|
146
230
|
InferSelectReturnType<QueryData<Response["data"]>, Options["select"]>,
|
|
147
|
-
Response["error"]
|
|
231
|
+
QueryError<Response["error"]>,
|
|
232
|
+
Options
|
|
148
233
|
>;
|
|
149
234
|
|
|
150
235
|
/**
|
|
@@ -212,7 +297,7 @@ export type UseInfiniteQueryMethod<Paths extends {}, Media extends MediaType> =
|
|
|
212
297
|
Options extends Omit<
|
|
213
298
|
UseInfiniteQueryOptions<
|
|
214
299
|
Response["data"],
|
|
215
|
-
Response["error"]
|
|
300
|
+
QueryError<Response["error"]>,
|
|
216
301
|
InferSelectReturnType<
|
|
217
302
|
InfiniteData<Response["data"], PageParam<Initial, Next, Previous>>,
|
|
218
303
|
Options["select"]
|
|
@@ -224,7 +309,7 @@ export type UseInfiniteQueryMethod<Paths extends {}, Media extends MediaType> =
|
|
|
224
309
|
> & {
|
|
225
310
|
placeholderData?: InfinitePlaceholderData<
|
|
226
311
|
Response["data"],
|
|
227
|
-
Response["error"]
|
|
312
|
+
QueryError<Response["error"]>,
|
|
228
313
|
Paths,
|
|
229
314
|
Method,
|
|
230
315
|
Path
|
|
@@ -246,7 +331,7 @@ export type UseInfiniteQueryMethod<Paths extends {}, Media extends MediaType> =
|
|
|
246
331
|
InfiniteData<Response["data"], PageParam<Initial, Next, Previous>>,
|
|
247
332
|
Options["select"]
|
|
248
333
|
>,
|
|
249
|
-
Response["error"]
|
|
334
|
+
QueryError<Response["error"]>
|
|
250
335
|
>;
|
|
251
336
|
|
|
252
337
|
export type UseSuspenseQueryMethod<Paths extends {}, Media extends MediaType> = <
|
|
@@ -258,7 +343,7 @@ export type UseSuspenseQueryMethod<Paths extends {}, Media extends MediaType> =
|
|
|
258
343
|
Options extends Omit<
|
|
259
344
|
UseSuspenseQueryOptions<
|
|
260
345
|
QueryData<Response["data"]>,
|
|
261
|
-
Response["error"]
|
|
346
|
+
QueryError<Response["error"]>,
|
|
262
347
|
InferSelectReturnType<QueryData<Response["data"]>, Options["select"]>,
|
|
263
348
|
QueryKey<Paths, Method, Path>
|
|
264
349
|
>,
|
|
@@ -270,7 +355,7 @@ export type UseSuspenseQueryMethod<Paths extends {}, Media extends MediaType> =
|
|
|
270
355
|
...[init, options, queryClient]: InitArguments<Init, [Options?, QueryClient?]>
|
|
271
356
|
) => UseSuspenseQueryResult<
|
|
272
357
|
InferSelectReturnType<QueryData<Response["data"]>, Options["select"]>,
|
|
273
|
-
Response["error"]
|
|
358
|
+
QueryError<Response["error"]>
|
|
274
359
|
>;
|
|
275
360
|
|
|
276
361
|
export type UseMutationMethod<Paths extends {}, Media extends MediaType> = <
|
|
@@ -284,11 +369,11 @@ export type UseMutationMethod<Paths extends {}, Media extends MediaType> = <
|
|
|
284
369
|
method: Method,
|
|
285
370
|
url: Path,
|
|
286
371
|
options?: Omit<
|
|
287
|
-
UseMutationOptions<Response["data"], Response["error"]
|
|
372
|
+
UseMutationOptions<Response["data"], QueryError<Response["error"]>, Init, TOnMutateResult>,
|
|
288
373
|
"mutationKey" | "mutationFn"
|
|
289
374
|
>,
|
|
290
375
|
queryClient?: QueryClient,
|
|
291
|
-
) => UseMutationResult<Response["data"], Response["error"]
|
|
376
|
+
) => UseMutationResult<Response["data"], QueryError<Response["error"]>, Init, TOnMutateResult>;
|
|
292
377
|
|
|
293
378
|
export interface OpenapiQueryClient<Paths extends {}, Media extends MediaType = MediaType> {
|
|
294
379
|
queryOptions: QueryOptionsFunction<Paths, Media>;
|
|
@@ -315,15 +400,47 @@ export type MethodResponse<
|
|
|
315
400
|
? NonNullable<FetchResponse<Paths[Path][Method], Options, Media>["data"]>
|
|
316
401
|
: never;
|
|
317
402
|
|
|
403
|
+
/** The `name` of every `ResponseError`. */
|
|
404
|
+
const RESPONSE_ERROR_NAME = "ResponseError";
|
|
405
|
+
|
|
406
|
+
/** The `ResponseError` of a failed response without an error body. */
|
|
407
|
+
class ResponseWithoutErrorBody extends Error implements ResponseError {
|
|
408
|
+
override readonly name = RESPONSE_ERROR_NAME;
|
|
409
|
+
readonly response: Response;
|
|
410
|
+
readonly status: number;
|
|
411
|
+
|
|
412
|
+
constructor(response: Response) {
|
|
413
|
+
const statusText = response.statusText ? ` ${response.statusText}` : "";
|
|
414
|
+
super(`Request failed with status ${response.status}${statusText} and no error body`);
|
|
415
|
+
this.response = response;
|
|
416
|
+
this.status = response.status;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Whether `error` is the `ResponseError` of a failed response without an error body. It narrows a
|
|
422
|
+
* hook's `error`, where `error instanceof Error` also matches network and parse errors. It checks
|
|
423
|
+
* the `name`, not the class, so a `ResponseError` of another copy of this package (its CommonJS and
|
|
424
|
+
* ES module builds loaded side by side) is one too. An error body from JSON is never an `Error`.
|
|
425
|
+
*/
|
|
426
|
+
export function isResponseError(error: unknown): error is ResponseError {
|
|
427
|
+
return error instanceof Error && error.name === RESPONSE_ERROR_NAME;
|
|
428
|
+
}
|
|
429
|
+
|
|
318
430
|
/**
|
|
319
431
|
* Throw the error of a failed fetch result. `if (error)` missed two failures: a non-OK response
|
|
320
432
|
* with an empty body (`error` is `undefined`, upstream #2070, #2829) and a falsy error body such as
|
|
321
|
-
* `0`.
|
|
433
|
+
* `0`. Without an error body a `ResponseError` is thrown, so the hooks' `error` is never
|
|
434
|
+
* `undefined` (ADR-013, decision 3). A JSON `null` body is no error body either: thrown as is, it
|
|
435
|
+
* left `error` `null` while the query's status was `"error"`.
|
|
322
436
|
*/
|
|
323
437
|
function throwIfFailed(result: { error?: unknown; response: Response }): void {
|
|
324
|
-
if (result.error !== undefined
|
|
438
|
+
if (result.error !== undefined && result.error !== null) {
|
|
325
439
|
throw result.error;
|
|
326
440
|
}
|
|
441
|
+
if (!result.response.ok) {
|
|
442
|
+
throw new ResponseWithoutErrorBody(result.response);
|
|
443
|
+
}
|
|
327
444
|
}
|
|
328
445
|
|
|
329
446
|
/** The query parameter that carries the page param unless `pageParamName` names another. */
|
|
@@ -332,7 +449,6 @@ const DEFAULT_PAGE_PARAM_NAME = "cursor";
|
|
|
332
449
|
interface PageRequest {
|
|
333
450
|
readonly pageParamName: string;
|
|
334
451
|
readonly pageParam: unknown;
|
|
335
|
-
readonly signal: AbortSignal;
|
|
336
452
|
}
|
|
337
453
|
|
|
338
454
|
/**
|
|
@@ -340,10 +456,21 @@ interface PageRequest {
|
|
|
340
456
|
* `initialPageParam: undefined`) is left out instead of being sent as `0` (upstream #2715, #2340,
|
|
341
457
|
* ADR-022), so a cursor API gets no cursor on the first page.
|
|
342
458
|
*/
|
|
343
|
-
function pageInit(init: unknown, { pageParamName, pageParam
|
|
459
|
+
function pageInit(init: unknown, { pageParamName, pageParam }: PageRequest) {
|
|
344
460
|
const { params, ...rest } = (init ?? {}) as { params?: { query?: DefaultParamsOption } };
|
|
345
461
|
const page = pageParam === undefined ? {} : { [pageParamName]: pageParam };
|
|
346
|
-
return { ...rest,
|
|
462
|
+
return { ...rest, params: { ...params, query: { ...params?.query, ...page } } };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Options of `createClient`. */
|
|
466
|
+
export interface CreateClientOptions {
|
|
467
|
+
/**
|
|
468
|
+
* The name of the API in every query key, in place of the fetch client's `baseUrl`. Give the
|
|
469
|
+
* same name where the server and the browser reach one API by different URLs, so the browser
|
|
470
|
+
* reads the data the server rendered with; give different names to clients of one base URL that
|
|
471
|
+
* fetch different data, such as one per tenant header.
|
|
472
|
+
*/
|
|
473
|
+
keyScope?: string;
|
|
347
474
|
}
|
|
348
475
|
|
|
349
476
|
/**
|
|
@@ -353,49 +480,70 @@ function pageInit(init: unknown, { pageParamName, pageParam, signal }: PageReque
|
|
|
353
480
|
// biome-ignore lint/complexity/noExcessiveLinesPerFunction: inherited, kept until 1.1 (ADR-036)
|
|
354
481
|
export default function createClient<Paths extends {}, Media extends MediaType = MediaType>(
|
|
355
482
|
client: FetchClient<Paths, Media>,
|
|
483
|
+
{ keyScope }: CreateClientOptions = {},
|
|
356
484
|
): OpenapiQueryClient<Paths, Media> {
|
|
357
|
-
const
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
485
|
+
const keyClient = { baseUrl: client.baseUrl, keyScope };
|
|
486
|
+
/**
|
|
487
|
+
* Send one request of a query: the call's own init, with a signal that TanStack Query can abort.
|
|
488
|
+
* The key only names the cache entry, as for `useInfiniteQuery`: a function in the init (a
|
|
489
|
+
* `fetch`, a `middleware`) cannot be part of a key, so the request is read from the call.
|
|
490
|
+
*/
|
|
491
|
+
const fetchPage = async <Method extends HttpMethod, Path extends PathsWithMethod<Paths, Method>>(
|
|
492
|
+
method: Method,
|
|
493
|
+
path: Path,
|
|
494
|
+
init: unknown,
|
|
495
|
+
signal: AbortSignal,
|
|
496
|
+
) => {
|
|
364
497
|
const mth = method.toUpperCase() as Uppercase<typeof method>;
|
|
365
498
|
const fn = client[mth] as ClientMethod<Paths, typeof method, Media>;
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
499
|
+
const request = withQuerySignal(init, signal);
|
|
500
|
+
try {
|
|
501
|
+
// The init is `unknown` here. An `any` init lets the call infer the generic response type
|
|
502
|
+
// that `queryOptions` declares; a typed init makes TypeScript compare two unresolved
|
|
503
|
+
// conditional types, which it cannot do.
|
|
504
|
+
// biome-ignore lint/suspicious/noExplicitAny: see the comment above
|
|
505
|
+
const result = await fn(path, request.init as any);
|
|
506
|
+
throwIfFailed(result);
|
|
507
|
+
return result.data;
|
|
508
|
+
} finally {
|
|
509
|
+
request.release();
|
|
510
|
+
}
|
|
376
511
|
};
|
|
377
512
|
|
|
378
|
-
const queryOptions: QueryOptionsFunction<Paths, Media> = (method, path, ...[init, options]) =>
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
: (
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
513
|
+
const queryOptions: QueryOptionsFunction<Paths, Media> = (method, path, ...[init, options]) => {
|
|
514
|
+
const keyInit = queryKeyInit(init, keyClient);
|
|
515
|
+
return {
|
|
516
|
+
queryKey: (keyInit === undefined
|
|
517
|
+
? ([method, path] as const)
|
|
518
|
+
: ([method, path, keyInit] as const)) as DataTag<
|
|
519
|
+
QueryKey<Paths, typeof method, typeof path>,
|
|
520
|
+
// biome-ignore lint/suspicious/noExplicitAny: the declared return type sets the data type
|
|
521
|
+
any,
|
|
522
|
+
// biome-ignore lint/suspicious/noExplicitAny: the declared return type sets the error type
|
|
523
|
+
any
|
|
524
|
+
>,
|
|
525
|
+
// TanStack Query rejects `undefined` query data. The fetch client gives `undefined` for
|
|
526
|
+
// every success without a body: a 204, `Content-Length: 0`, an empty chunked body, a HEAD
|
|
527
|
+
// request (upstream #2234). A parsed body is never `undefined`, so this changes nothing else.
|
|
528
|
+
queryFn: async ({ signal }: QueryFunctionContext) =>
|
|
529
|
+
(await fetchPage(method, path, init, signal)) ?? null,
|
|
530
|
+
// Without options, `Options` is its constraint, which has no `initialData` to keep.
|
|
531
|
+
...(options as typeof options & {}),
|
|
532
|
+
// The declared type types a defined `initialData` as the query data, a conditional type of
|
|
533
|
+
// the generic options that TypeScript cannot relate to the object; it is the same object.
|
|
534
|
+
} as never;
|
|
535
|
+
};
|
|
391
536
|
|
|
392
537
|
return {
|
|
393
538
|
queryOptions,
|
|
394
539
|
useQuery: (method, path, ...[init, options, queryClient]) =>
|
|
540
|
+
// TanStack Query's `useQuery` returns `DefinedUseQueryResult` for options with defined
|
|
541
|
+
// `initialData`; while `Options` is generic it cannot pick that overload, and the declared
|
|
542
|
+
// `QueryResult` gives the caller the same answer. Both are the same object at runtime.
|
|
395
543
|
useQuery(
|
|
396
544
|
queryOptions(method, path, init as InitWithUnknowns<typeof init>, options),
|
|
397
545
|
queryClient,
|
|
398
|
-
),
|
|
546
|
+
) as never,
|
|
399
547
|
useSuspenseQuery: (method, path, ...[init, options, queryClient]) =>
|
|
400
548
|
useSuspenseQuery(
|
|
401
549
|
queryOptions(method, path, init as InitWithUnknowns<typeof init>, options),
|
|
@@ -405,23 +553,13 @@ export default function createClient<Paths extends {}, Media extends MediaType =
|
|
|
405
553
|
useInfiniteQuery: (method, path, init, options, queryClient) => {
|
|
406
554
|
const { pageParamName = DEFAULT_PAGE_PARAM_NAME, placeholderData, ...restOptions } = options;
|
|
407
555
|
const { queryKey } = queryOptions(method, path, init);
|
|
408
|
-
const mth = method.toUpperCase() as Uppercase<typeof method>;
|
|
409
|
-
const fn = client[mth] as ClientMethod<Paths, typeof method, Media>;
|
|
410
556
|
return useInfiniteQuery(
|
|
411
557
|
{
|
|
412
558
|
queryKey: [...queryKey, INFINITE_QUERY_KEY_MARKER] as const,
|
|
413
|
-
//
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
// biome-ignore lint/suspicious/noExplicitAny: a generic init; see queryFn
|
|
418
|
-
pageInit(init, { pageParamName, pageParam, signal }) as any,
|
|
419
|
-
);
|
|
420
|
-
throwIfFailed(result);
|
|
421
|
-
// An empty page stays `undefined`, as in upstream: TanStack Query rejects only
|
|
422
|
-
// `undefined` query data, and the infinite query data is the `{ pages }` object.
|
|
423
|
-
return result.data;
|
|
424
|
-
},
|
|
559
|
+
// An empty page stays `undefined`, as in upstream: TanStack Query rejects only
|
|
560
|
+
// `undefined` query data, and the infinite query data is the `{ pages }` object.
|
|
561
|
+
queryFn: ({ pageParam, signal }) =>
|
|
562
|
+
fetchPage(method, path, pageInit(init, { pageParamName, pageParam }), signal),
|
|
425
563
|
...restOptions,
|
|
426
564
|
// Typed with an `unknown` page param for the caller (`InfinitePlaceholderData`). TanStack
|
|
427
565
|
// passes the previous data of this query, with its own page params, and takes it back.
|
package/src/query-key.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The request part of a query key (ADR-046). TanStack Query hashes a key as JSON: a `Headers`
|
|
3
|
+
* object or a list of header pairs hashed as `{}` or by its order, and so did a FormData, a Blob,
|
|
4
|
+
* an ArrayBuffer or a URLSearchParams body. Two requests that differed in them shared one cache
|
|
5
|
+
* entry, and the `queryFn` of that entry sent the request of the first. Two clients with
|
|
6
|
+
* different base URLs shared entries too. The key's init holds the headers and such a body as
|
|
7
|
+
* plain values, and the base URL the request goes to or the client's key scope, so the key names
|
|
8
|
+
* what was fetched.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Headers as a plain object, which TanStack Query hashes by its names and values. */
|
|
12
|
+
type HeadersRecord = Readonly<Record<string, unknown>>;
|
|
13
|
+
|
|
14
|
+
/** What the key takes from the fetch client and the options of `createClient`. */
|
|
15
|
+
export interface KeyClient {
|
|
16
|
+
/** The fetch client's base URL, `""` without one. */
|
|
17
|
+
readonly baseUrl: string;
|
|
18
|
+
/** The name of the API in every key, in place of the base URL. */
|
|
19
|
+
readonly keyScope?: string | undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** `[object Name]`: the type tag is between these indexes of `Object.prototype.toString`. */
|
|
23
|
+
const TYPE_TAG_START = "[object ".length;
|
|
24
|
+
const TYPE_TAG_END = -1;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Whether a value is of the class `name`: an instance of this realm's global class of that name
|
|
28
|
+
* (React Native's classes have no type tag), or a value with its type tag (a class of another
|
|
29
|
+
* realm or of the `undici` package), as the fetch client tells bodies (ADR-030).
|
|
30
|
+
*/
|
|
31
|
+
function isClass(value: object, name: string): boolean {
|
|
32
|
+
const type = (globalThis as Record<string, unknown>)[name];
|
|
33
|
+
return (
|
|
34
|
+
(typeof type === "function" && value instanceof type) ||
|
|
35
|
+
Object.prototype.toString.call(value).slice(TYPE_TAG_START, TYPE_TAG_END) === name
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The headers of an init as a plain object. A `Headers` object and a list of pairs, which are both
|
|
41
|
+
* iterable, become the names and values the request sends: names in lowercase, a repeated one
|
|
42
|
+
* joined with `, `, as `Headers` writes them. They are read without `Headers`, which threw for an
|
|
43
|
+
* invalid name or value while the component rendered; the request reports it as a query error. A
|
|
44
|
+
* plain object stays as it is, since its `null` and array values mean more than a string.
|
|
45
|
+
*/
|
|
46
|
+
function plainHeaders(headers: object): HeadersRecord {
|
|
47
|
+
if (!(Symbol.iterator in headers)) {
|
|
48
|
+
return headers as HeadersRecord;
|
|
49
|
+
}
|
|
50
|
+
const record: Record<string, string> = {};
|
|
51
|
+
for (const pair of headers as Iterable<unknown>) {
|
|
52
|
+
const [name, value] = Array.from(pair as ArrayLike<unknown>);
|
|
53
|
+
const key = String(name).toLowerCase();
|
|
54
|
+
record[key] = key in record ? `${record[key]}, ${String(value)}` : String(value);
|
|
55
|
+
}
|
|
56
|
+
return record;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A Blob or a File in a key: its type, size, and the name and date of a File. Its bytes cannot be
|
|
61
|
+
* read while the component renders, so two Blobs of the same type and size share an entry.
|
|
62
|
+
*/
|
|
63
|
+
function blobKey(blob: Blob): object {
|
|
64
|
+
const { name, lastModified } = blob as Partial<File>;
|
|
65
|
+
return { type: blob.type, size: blob.size, name, lastModified };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The fields of a FormData in a key, in order, with a file as `blobKey`. */
|
|
69
|
+
function formDataKey(form: FormData): unknown[] {
|
|
70
|
+
// React Native's FormData has no `entries`, so its fields cannot be read.
|
|
71
|
+
if (typeof form.entries !== "function") {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
return Array.from(form.entries(), ([name, value]) => [
|
|
75
|
+
name,
|
|
76
|
+
typeof value === "string" ? value : blobKey(value),
|
|
77
|
+
]);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The bytes of an ArrayBuffer or a typed array, or null for another value. */
|
|
81
|
+
function bodyBytes(body: object): Uint8Array | null {
|
|
82
|
+
if (ArrayBuffer.isView(body)) {
|
|
83
|
+
return new Uint8Array(body.buffer, body.byteOffset, body.byteLength);
|
|
84
|
+
}
|
|
85
|
+
return isClass(body, "ArrayBuffer") ? new Uint8Array(body as ArrayBuffer) : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A request body as a value TanStack Query hashes by its content. A URLSearchParams is its text,
|
|
90
|
+
* a FormData its fields, a Blob `blobKey` and bytes their numbers, each under its kind, so it
|
|
91
|
+
* differs from a JSON body with the same content. Other values stay as they are.
|
|
92
|
+
*/
|
|
93
|
+
function bodyKey(body: unknown): unknown {
|
|
94
|
+
if (typeof body !== "object" || body === null) {
|
|
95
|
+
return body;
|
|
96
|
+
}
|
|
97
|
+
if (isClass(body, "URLSearchParams")) {
|
|
98
|
+
return { URLSearchParams: String(body) };
|
|
99
|
+
}
|
|
100
|
+
if (isClass(body, "FormData")) {
|
|
101
|
+
return { FormData: formDataKey(body as FormData) };
|
|
102
|
+
}
|
|
103
|
+
if (isClass(body, "Blob") || isClass(body, "File")) {
|
|
104
|
+
return { Blob: blobKey(body as Blob) };
|
|
105
|
+
}
|
|
106
|
+
const bytes = bodyBytes(body);
|
|
107
|
+
return bytes === null ? body : { bytes: Array.from(bytes) };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The init of a query key: the request init with its headers and body as plain values, and the
|
|
112
|
+
* client's key scope, or its base URL when the init has none. The `signal` is left out: TanStack
|
|
113
|
+
* Query gives each request its own. Undefined when nothing is left, so a call without an init and
|
|
114
|
+
* one with an empty init have the key `[method, path]`.
|
|
115
|
+
*/
|
|
116
|
+
export function queryKeyInit(init: unknown, client: KeyClient): object | undefined {
|
|
117
|
+
const { signal: _signal, ...rest } = (init ?? {}) as {
|
|
118
|
+
signal?: unknown;
|
|
119
|
+
headers?: unknown;
|
|
120
|
+
baseUrl?: unknown;
|
|
121
|
+
body?: unknown;
|
|
122
|
+
};
|
|
123
|
+
const keyInit: Record<string, unknown> = { ...rest };
|
|
124
|
+
if (typeof rest.headers === "object" && rest.headers !== null) {
|
|
125
|
+
keyInit.headers = plainHeaders(rest.headers);
|
|
126
|
+
}
|
|
127
|
+
if ("body" in rest) {
|
|
128
|
+
keyInit.body = bodyKey(rest.body);
|
|
129
|
+
}
|
|
130
|
+
if (client.keyScope !== undefined) {
|
|
131
|
+
keyInit.keyScope = client.keyScope;
|
|
132
|
+
} else if (!rest.baseUrl && client.baseUrl) {
|
|
133
|
+
keyInit.baseUrl = client.baseUrl;
|
|
134
|
+
}
|
|
135
|
+
return Object.keys(keyInit).length === 0 ? undefined : keyInit;
|
|
136
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The signal of a query's request. TanStack Query gives every request a signal, which it aborts
|
|
3
|
+
* when the query is cancelled or no longer used; the init of the call may bring its own. The hooks
|
|
4
|
+
* sent one of them: `useQuery` the call's, so a cancelled query kept its request, and
|
|
5
|
+
* `useInfiniteQuery` TanStack's, so the call's signal did nothing. The request now stops when
|
|
6
|
+
* either is aborted.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** The init to send, and how to stop listening to the signals once the request has settled. */
|
|
10
|
+
export interface QueryRequest {
|
|
11
|
+
readonly init: Record<string, unknown>;
|
|
12
|
+
readonly release: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const ABORT_EVENT = "abort";
|
|
16
|
+
|
|
17
|
+
function withoutRelease(init: Record<string, unknown>): QueryRequest {
|
|
18
|
+
return { init, release: () => undefined };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The init with a signal that aborts when the init's own signal or the query's does. */
|
|
22
|
+
export function withQuerySignal(init: unknown, querySignal: AbortSignal): QueryRequest {
|
|
23
|
+
const { signal: ownSignal, ...rest } = (init ?? {}) as { signal?: AbortSignal | null };
|
|
24
|
+
if (!ownSignal) {
|
|
25
|
+
return withoutRelease({ ...rest, signal: querySignal });
|
|
26
|
+
}
|
|
27
|
+
const controller = new AbortController();
|
|
28
|
+
const sources = [ownSignal, querySignal];
|
|
29
|
+
const aborted = sources.find((source) => source.aborted);
|
|
30
|
+
if (aborted !== undefined) {
|
|
31
|
+
controller.abort(aborted.reason);
|
|
32
|
+
return withoutRelease({ ...rest, signal: controller.signal });
|
|
33
|
+
}
|
|
34
|
+
const listeners = sources.map((source) => {
|
|
35
|
+
const listener = () => controller.abort(source.reason);
|
|
36
|
+
source.addEventListener(ABORT_EVENT, listener, { once: true });
|
|
37
|
+
return () => source.removeEventListener(ABORT_EVENT, listener);
|
|
38
|
+
});
|
|
39
|
+
return {
|
|
40
|
+
init: { ...rest, signal: controller.signal },
|
|
41
|
+
release: () => {
|
|
42
|
+
for (const remove of listeners) {
|
|
43
|
+
remove();
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|