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/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: a runtime
70
- * named export next to the default export would change what `require()` returns.
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
- queryKey: DataTag<
191
+ > &
192
+ QueryKeyWithDataTag<
110
193
  QueryKey<Paths, Method, Path>,
111
194
  QueryData<Response["data"]>,
112
- Response["error"]
113
- >;
114
- queryFn: Exclude<
115
- UseQueryOptions<
116
- QueryData<Response["data"]>,
117
- Response["error"],
118
- InferSelectReturnType<QueryData<Response["data"]>, Options["select"]>,
119
- QueryKey<Paths, Method, Path>
120
- >["queryFn"],
121
- SkipToken | undefined
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
- ) => UseQueryResult<
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"], Init, TOnMutateResult>,
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"], Init, TOnMutateResult>;
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 || !result.response.ok) {
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, signal }: PageRequest) {
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, signal, params: { ...params, query: { ...params?.query, ...page } } };
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 queryFn = async <Method extends HttpMethod, Path extends PathsWithMethod<Paths, Method>>({
358
- queryKey,
359
- signal,
360
- }: QueryFunctionContext<QueryKey<Paths, Method, Path>>) => {
361
- // While `Path` is generic, TypeScript cannot work out that the key is a tuple: the init is a
362
- // conditional type per operation (`MaybeOptionalInit`), and the key's type waits for it.
363
- const [method, path, init] = queryKey as readonly [Method, Path, unknown?];
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
- // The init is `unknown` here. An `any` init lets the call infer the generic response type
367
- // that `queryOptions` declares; a typed init makes TypeScript compare two unresolved
368
- // conditional types, which it cannot do.
369
- // biome-ignore lint/suspicious/noExplicitAny: see the comment above
370
- const result = await fn(path, { signal, ...(init as any) });
371
- throwIfFailed(result);
372
- // TanStack Query rejects `undefined` query data. The fetch client gives `undefined` for every
373
- // success without a body: a 204, `Content-Length: 0`, an empty chunked body, a HEAD request
374
- // (upstream #2234). A parsed body is never `undefined`, so this changes nothing else.
375
- return result.data ?? null;
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
- queryKey: (init === undefined
380
- ? ([method, path] as const)
381
- : ([method, path, init] as const)) as DataTag<
382
- QueryKey<Paths, typeof method, typeof path>,
383
- // biome-ignore lint/suspicious/noExplicitAny: the declared return type sets the data type
384
- any,
385
- // biome-ignore lint/suspicious/noExplicitAny: the declared return type sets the error type
386
- any
387
- >,
388
- queryFn,
389
- ...options,
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
- // The request comes from the arguments; the key only identifies the cache entry.
414
- queryFn: async ({ pageParam, signal }) => {
415
- const result = await fn(
416
- path,
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.
@@ -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
+ }