openapi-typesafe-react-query 1.0.0-rc.4 → 1.0.0-rc.6

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,6 +1,7 @@
1
1
  import {
2
2
  type DataTag,
3
3
  type DefaultError,
4
+ type DefinedUseQueryResult,
4
5
  type InfiniteData,
5
6
  type QueryClient,
6
7
  type QueryFunctionContext,
@@ -35,6 +36,8 @@ import type {
35
36
  PathsWithMethod,
36
37
  RequiredKeysOf,
37
38
  } from "openapi-typesafe-fetch/helpers";
39
+ import { queryKeyInit } from "./query-key.js";
40
+ import { withQuerySignal } from "./query-signal.js";
38
41
 
39
42
  // Helper type to dynamically infer the type from the `select` property
40
43
  type InferSelectReturnType<TData, TSelect> = TSelect extends (data: TData) => infer R ? R : TData;
@@ -100,6 +103,12 @@ type InitArguments<Init, Rest extends unknown[]> =
100
103
  ? [InitWithUnknowns<Init>?, ...Rest]
101
104
  : [InitWithUnknowns<Init>, ...Rest];
102
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
+ */
103
112
  export type QueryKey<
104
113
  Paths extends {},
105
114
  Method extends HttpMethod,
@@ -121,6 +130,36 @@ export type InfiniteQueryKey<
121
130
  Path extends PathsWithMethod<Paths, Method>,
122
131
  > = readonly [...QueryKey<Paths, Method, Path>, typeof INFINITE_QUERY_KEY_MARKER];
123
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
+
124
163
  export type QueryOptionsFunction<Paths extends {}, Media extends MediaType> = <
125
164
  Method extends HttpMethod,
126
165
  Path extends PathsWithMethod<Paths, Method>,
@@ -154,7 +193,8 @@ export type QueryOptionsFunction<Paths extends {}, Media extends MediaType> = <
154
193
  QueryKey<Paths, Method, Path>,
155
194
  QueryData<Response["data"]>,
156
195
  QueryError<Response["error"]>
157
- > & {
196
+ > &
197
+ InitialDataOf<Options, QueryData<Response["data"]>> & {
158
198
  queryFn: Exclude<
159
199
  UseQueryOptions<
160
200
  QueryData<Response["data"]>,
@@ -186,9 +226,10 @@ export type UseQueryMethod<Paths extends {}, Media extends MediaType> = <
186
226
  method: Method,
187
227
  url: Path,
188
228
  ...[init, options, queryClient]: InitArguments<Init, [Options?, QueryClient?]>
189
- ) => UseQueryResult<
229
+ ) => QueryResult<
190
230
  InferSelectReturnType<QueryData<Response["data"]>, Options["select"]>,
191
- QueryError<Response["error"]>
231
+ QueryError<Response["error"]>,
232
+ Options
192
233
  >;
193
234
 
194
235
  /**
@@ -408,7 +449,6 @@ const DEFAULT_PAGE_PARAM_NAME = "cursor";
408
449
  interface PageRequest {
409
450
  readonly pageParamName: string;
410
451
  readonly pageParam: unknown;
411
- readonly signal: AbortSignal;
412
452
  }
413
453
 
414
454
  /**
@@ -416,10 +456,21 @@ interface PageRequest {
416
456
  * `initialPageParam: undefined`) is left out instead of being sent as `0` (upstream #2715, #2340,
417
457
  * ADR-022), so a cursor API gets no cursor on the first page.
418
458
  */
419
- function pageInit(init: unknown, { pageParamName, pageParam, signal }: PageRequest) {
459
+ function pageInit(init: unknown, { pageParamName, pageParam }: PageRequest) {
420
460
  const { params, ...rest } = (init ?? {}) as { params?: { query?: DefaultParamsOption } };
421
461
  const page = pageParam === undefined ? {} : { [pageParamName]: pageParam };
422
- 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;
423
474
  }
424
475
 
425
476
  /**
@@ -429,49 +480,70 @@ function pageInit(init: unknown, { pageParamName, pageParam, signal }: PageReque
429
480
  // biome-ignore lint/complexity/noExcessiveLinesPerFunction: inherited, kept until 1.1 (ADR-036)
430
481
  export default function createClient<Paths extends {}, Media extends MediaType = MediaType>(
431
482
  client: FetchClient<Paths, Media>,
483
+ { keyScope }: CreateClientOptions = {},
432
484
  ): OpenapiQueryClient<Paths, Media> {
433
- const queryFn = async <Method extends HttpMethod, Path extends PathsWithMethod<Paths, Method>>({
434
- queryKey,
435
- signal,
436
- }: QueryFunctionContext<QueryKey<Paths, Method, Path>>) => {
437
- // While `Path` is generic, TypeScript cannot work out that the key is a tuple: the init is a
438
- // conditional type per operation (`MaybeOptionalInit`), and the key's type waits for it.
439
- 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
+ ) => {
440
497
  const mth = method.toUpperCase() as Uppercase<typeof method>;
441
498
  const fn = client[mth] as ClientMethod<Paths, typeof method, Media>;
442
- // The init is `unknown` here. An `any` init lets the call infer the generic response type
443
- // that `queryOptions` declares; a typed init makes TypeScript compare two unresolved
444
- // conditional types, which it cannot do.
445
- // biome-ignore lint/suspicious/noExplicitAny: see the comment above
446
- const result = await fn(path, { signal, ...(init as any) });
447
- throwIfFailed(result);
448
- // TanStack Query rejects `undefined` query data. The fetch client gives `undefined` for every
449
- // success without a body: a 204, `Content-Length: 0`, an empty chunked body, a HEAD request
450
- // (upstream #2234). A parsed body is never `undefined`, so this changes nothing else.
451
- 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
+ }
452
511
  };
453
512
 
454
- const queryOptions: QueryOptionsFunction<Paths, Media> = (method, path, ...[init, options]) => ({
455
- queryKey: (init === undefined
456
- ? ([method, path] as const)
457
- : ([method, path, init] as const)) as DataTag<
458
- QueryKey<Paths, typeof method, typeof path>,
459
- // biome-ignore lint/suspicious/noExplicitAny: the declared return type sets the data type
460
- any,
461
- // biome-ignore lint/suspicious/noExplicitAny: the declared return type sets the error type
462
- any
463
- >,
464
- queryFn,
465
- ...options,
466
- });
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
+ };
467
536
 
468
537
  return {
469
538
  queryOptions,
470
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.
471
543
  useQuery(
472
544
  queryOptions(method, path, init as InitWithUnknowns<typeof init>, options),
473
545
  queryClient,
474
- ),
546
+ ) as never,
475
547
  useSuspenseQuery: (method, path, ...[init, options, queryClient]) =>
476
548
  useSuspenseQuery(
477
549
  queryOptions(method, path, init as InitWithUnknowns<typeof init>, options),
@@ -481,23 +553,13 @@ export default function createClient<Paths extends {}, Media extends MediaType =
481
553
  useInfiniteQuery: (method, path, init, options, queryClient) => {
482
554
  const { pageParamName = DEFAULT_PAGE_PARAM_NAME, placeholderData, ...restOptions } = options;
483
555
  const { queryKey } = queryOptions(method, path, init);
484
- const mth = method.toUpperCase() as Uppercase<typeof method>;
485
- const fn = client[mth] as ClientMethod<Paths, typeof method, Media>;
486
556
  return useInfiniteQuery(
487
557
  {
488
558
  queryKey: [...queryKey, INFINITE_QUERY_KEY_MARKER] as const,
489
- // The request comes from the arguments; the key only identifies the cache entry.
490
- queryFn: async ({ pageParam, signal }) => {
491
- const result = await fn(
492
- path,
493
- // biome-ignore lint/suspicious/noExplicitAny: a generic init; see queryFn
494
- pageInit(init, { pageParamName, pageParam, signal }) as any,
495
- );
496
- throwIfFailed(result);
497
- // An empty page stays `undefined`, as in upstream: TanStack Query rejects only
498
- // `undefined` query data, and the infinite query data is the `{ pages }` object.
499
- return result.data;
500
- },
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),
501
563
  ...restOptions,
502
564
  // Typed with an `unknown` page param for the caller (`InfinitePlaceholderData`). TanStack
503
565
  // passes the previous data of this query, with its own page params, and takes it back.
@@ -0,0 +1,154 @@
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 of a File. Its bytes cannot be read while
61
+ * the component renders, so two Blobs of the same type and size share an entry. The date of a File
62
+ * is left out: `FormData.append` and `new File` set it to the current time, so a body built while
63
+ * rendering got a new key, and a new request, on every render.
64
+ */
65
+ function blobKey(blob: Blob): object {
66
+ const { name } = blob as Partial<File>;
67
+ return { type: blob.type, size: blob.size, name };
68
+ }
69
+
70
+ /** The fields of a FormData in a key, in order, with a file as `blobKey`. */
71
+ function formDataKey(form: FormData): unknown[] {
72
+ // React Native's FormData has no `entries`, so its fields cannot be read.
73
+ if (typeof form.entries !== "function") {
74
+ return [];
75
+ }
76
+ return Array.from(form.entries(), ([name, value]) => [
77
+ name,
78
+ typeof value === "string" ? value : blobKey(value),
79
+ ]);
80
+ }
81
+
82
+ /** The bytes of an ArrayBuffer or a typed array, or null for another value. */
83
+ function bodyBytes(body: object): Uint8Array | null {
84
+ if (ArrayBuffer.isView(body)) {
85
+ return new Uint8Array(body.buffer, body.byteOffset, body.byteLength);
86
+ }
87
+ return isClass(body, "ArrayBuffer") ? new Uint8Array(body as ArrayBuffer) : null;
88
+ }
89
+
90
+ /** A body that JSON cannot hold, as a value TanStack Query hashes, and the name of its kind. */
91
+ interface RawBodyKey {
92
+ readonly body: unknown;
93
+ readonly bodyType: "URLSearchParams" | "FormData" | "Blob" | "bytes";
94
+ }
95
+
96
+ /**
97
+ * A body that JSON cannot hold by its content: a URLSearchParams as its text, a FormData as its
98
+ * fields, a Blob as `blobKey` and bytes as their numbers. Undefined for any other body.
99
+ */
100
+ function rawBodyKey(body: unknown): RawBodyKey | undefined {
101
+ if (typeof body !== "object" || body === null) {
102
+ return undefined;
103
+ }
104
+ if (isClass(body, "URLSearchParams")) {
105
+ return { body: String(body), bodyType: "URLSearchParams" };
106
+ }
107
+ if (isClass(body, "FormData")) {
108
+ return { body: formDataKey(body as FormData), bodyType: "FormData" };
109
+ }
110
+ if (isClass(body, "Blob") || isClass(body, "File")) {
111
+ return { body: blobKey(body as Blob), bodyType: "Blob" };
112
+ }
113
+ const bytes = bodyBytes(body);
114
+ return bytes === null ? undefined : { body: Array.from(bytes), bodyType: "bytes" };
115
+ }
116
+
117
+ /**
118
+ * The init fields that are not `undefined`. A field set to `undefined` (`headers: token ? … :
119
+ * undefined`) sends the same request as none, so it must not make the init count as not empty.
120
+ */
121
+ function definedFields(fields: object): Record<string, unknown> {
122
+ return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined));
123
+ }
124
+
125
+ /**
126
+ * The init of a query key: the request init with its headers and body as plain values, and the
127
+ * client's key scope, or its base URL when the init has none. The `signal` is left out: TanStack
128
+ * Query gives each request its own. A body that JSON cannot hold also names its kind in
129
+ * `bodyType`, a field no init has, so it never shares a key with a JSON body of the same content.
130
+ * Undefined when nothing is left, so a call without an init and one with an empty init, or one
131
+ * of only `undefined` fields, have the key `[method, path]`.
132
+ */
133
+ export function queryKeyInit(init: unknown, client: KeyClient): object | undefined {
134
+ const { signal: _signal, ...rest } = (init ?? {}) as {
135
+ signal?: unknown;
136
+ headers?: unknown;
137
+ baseUrl?: unknown;
138
+ body?: unknown;
139
+ };
140
+ const keyInit = definedFields(rest);
141
+ if (typeof rest.headers === "object" && rest.headers !== null) {
142
+ keyInit.headers = plainHeaders(rest.headers);
143
+ }
144
+ const rawBody = rawBodyKey(rest.body);
145
+ if (rawBody !== undefined) {
146
+ Object.assign(keyInit, rawBody);
147
+ }
148
+ if (client.keyScope !== undefined) {
149
+ keyInit.keyScope = client.keyScope;
150
+ } else if (!rest.baseUrl && client.baseUrl) {
151
+ keyInit.baseUrl = client.baseUrl;
152
+ }
153
+ return Object.keys(keyInit).length === 0 ? undefined : keyInit;
154
+ }
@@ -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
+ }