@r0hitsharma/http-client-react 0.12.0-rohit-fork-ci.1
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/DESIGN.md +378 -0
- package/README.md +427 -0
- package/dist/errors.d.ts +56 -0
- package/dist/errors.js +51 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +29 -0
- package/dist/middleware.d.ts +45 -0
- package/dist/middleware.js +25 -0
- package/dist/query-api.d.ts +114 -0
- package/dist/query-api.js +128 -0
- package/dist/query-client.d.ts +84 -0
- package/dist/query-client.js +152 -0
- package/dist/query-key.d.ts +50 -0
- package/dist/query-key.js +70 -0
- package/dist/tags.d.ts +28 -0
- package/dist/tags.js +53 -0
- package/dist/zod-response.d.ts +61 -0
- package/dist/zod-response.js +91 -0
- package/oxfmt.config.ts +5 -0
- package/oxlint.config.ts +5 -0
- package/package.json +49 -0
- package/src/deprecated.test.ts +27 -0
- package/src/errors.ts +74 -0
- package/src/index.tsx +100 -0
- package/src/middleware.test.ts +88 -0
- package/src/middleware.ts +84 -0
- package/src/query-api.test.ts +644 -0
- package/src/query-api.ts +512 -0
- package/src/query-api.types.test.ts +225 -0
- package/src/query-client.test.ts +315 -0
- package/src/query-client.ts +173 -0
- package/src/query-key.test.ts +121 -0
- package/src/query-key.ts +113 -0
- package/src/tags.ts +100 -0
- package/src/test-fixtures.ts +222 -0
- package/src/zod-response.ts +150 -0
- package/tsconfig.build.json +21 -0
- package/tsconfig.json +7 -0
- package/vitest.config.ts +11 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { type ApiClient, createApiClient } from '@r0hitsharma/http-client-core';
|
|
2
|
+
import { useQuery } from '@tanstack/react-query';
|
|
3
|
+
import type { DataTag, QueryClient } from '@tanstack/react-query';
|
|
4
|
+
import { describe, expect, expectTypeOf, it } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import { type HttpRequestError, isHttpRequestError } from './errors.js';
|
|
7
|
+
import { createQueryApi } from './query-api.js';
|
|
8
|
+
import type { QueryApi, QueryApiPaths } from './query-api.js';
|
|
9
|
+
import type { ApiFault, TestPaths, User } from './test-fixtures.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Type-level coverage of the inference the whole design rests on: methods,
|
|
13
|
+
* paths, params, request bodies, and response types all come off the generated
|
|
14
|
+
* `TPaths` type, and the single cast inside `createQueryApi` still lines up with
|
|
15
|
+
* the generic surface it claims.
|
|
16
|
+
*
|
|
17
|
+
* `expectTypeOf` and `@ts-expect-error` are checked by `npm run type:check`, so
|
|
18
|
+
* the assertions live in functions that are never called — several of them would
|
|
19
|
+
* throw or issue requests if they ran, and one calls `useQuery` outside a React
|
|
20
|
+
* render purely to borrow its overload resolution.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const client = createApiClient<TestPaths>('https://api.test');
|
|
24
|
+
const api = createQueryApi(client, { tags: ['users', 'user'] });
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Stands in for `useQuery`, reading the data and error types back out of the
|
|
28
|
+
* branded query key so both can be asserted without rendering a hook.
|
|
29
|
+
*/
|
|
30
|
+
declare function readTags<TData, TError>(options: {
|
|
31
|
+
queryKey: DataTag<readonly unknown[], TData, TError>;
|
|
32
|
+
}): { data: TData; error: TError };
|
|
33
|
+
|
|
34
|
+
type ResolvedQueryFn<
|
|
35
|
+
TOptions extends { queryFn: (...args: never[]) => unknown },
|
|
36
|
+
> = Awaited<ReturnType<TOptions['queryFn']>>;
|
|
37
|
+
|
|
38
|
+
describe('createQueryApi — inference', () => {
|
|
39
|
+
// A real assertion rather than a placeholder: the file's whole premise is
|
|
40
|
+
// that this api instance was constructed, and everything asserted below is
|
|
41
|
+
// about the options it builds.
|
|
42
|
+
it('builds options whose key is the operation', () => {
|
|
43
|
+
expect(api.queryOptions('get', '/users').queryKey).toEqual([
|
|
44
|
+
'get',
|
|
45
|
+
'/users',
|
|
46
|
+
{},
|
|
47
|
+
]);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export function typeAssertions(): void {
|
|
52
|
+
// The tag vocabulary narrows `TTag` — no explicit type argument needed.
|
|
53
|
+
expectTypeOf(api.tagFilter).parameter(0).toEqualTypeOf<'users' | 'user'>();
|
|
54
|
+
expectTypeOf(api.taggedEndpoints)
|
|
55
|
+
.parameter(0)
|
|
56
|
+
.toEqualTypeOf<'users' | 'user'>();
|
|
57
|
+
|
|
58
|
+
// Response data comes from the operation's 2xx response.
|
|
59
|
+
const listOptions = api.queryOptions('get', '/users', {
|
|
60
|
+
params: { query: { limit: 10 } },
|
|
61
|
+
});
|
|
62
|
+
expectTypeOf<ResolvedQueryFn<typeof listOptions>>().toEqualTypeOf<User[]>();
|
|
63
|
+
|
|
64
|
+
const listResult = readTags(listOptions);
|
|
65
|
+
expectTypeOf(listResult.data).toEqualTypeOf<User[]>();
|
|
66
|
+
expectTypeOf(listResult.error).toEqualTypeOf<
|
|
67
|
+
HttpRequestError<ApiFault> | Error
|
|
68
|
+
>();
|
|
69
|
+
|
|
70
|
+
// The derived key is `[method, path, sanitized(init)]`.
|
|
71
|
+
expectTypeOf(listOptions.queryKey).toExtend<
|
|
72
|
+
readonly ['get', '/users', { query?: Record<string, unknown> }]
|
|
73
|
+
>();
|
|
74
|
+
|
|
75
|
+
const detailOptions = api.queryOptions('get', '/users/{id}', {
|
|
76
|
+
params: { path: { id: 'u1' } },
|
|
77
|
+
});
|
|
78
|
+
expectTypeOf<ResolvedQueryFn<typeof detailOptions>>().toEqualTypeOf<User>();
|
|
79
|
+
expectTypeOf(readTags(detailOptions).error).toEqualTypeOf<
|
|
80
|
+
HttpRequestError<ApiFault> | Error
|
|
81
|
+
>();
|
|
82
|
+
|
|
83
|
+
// Narrowing needs no body type parameter: the guard filters the declared
|
|
84
|
+
// union, so the operation's own error body survives it — and stays optional,
|
|
85
|
+
// because a failure need not carry a body at all.
|
|
86
|
+
const failure = readTags(detailOptions).error;
|
|
87
|
+
if (isHttpRequestError(failure)) {
|
|
88
|
+
expectTypeOf(failure.body).toEqualTypeOf<ApiFault | undefined>();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// A `catch` binding declares nothing, so the body stays `unknown` — the guard
|
|
92
|
+
// has no type parameter with which to claim otherwise.
|
|
93
|
+
const caught = failure as unknown;
|
|
94
|
+
if (isHttpRequestError(caught)) {
|
|
95
|
+
expectTypeOf(caught.status).toEqualTypeOf<number>();
|
|
96
|
+
expectTypeOf(caught.body).toEqualTypeOf<unknown>();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// `select` retypes what a component reads without losing the fetched type.
|
|
100
|
+
const selectedOptions = api.queryOptions('get', '/users', undefined, {
|
|
101
|
+
select: (users) => users.length,
|
|
102
|
+
});
|
|
103
|
+
expectTypeOf(selectedOptions.select).parameter(0).toEqualTypeOf<User[]>();
|
|
104
|
+
|
|
105
|
+
// The assertion that matters: the options' `TData` is the *selected* type, so
|
|
106
|
+
// this is what a component reads. Asserted through the real `useQuery` rather
|
|
107
|
+
// than a stand-in, since matching its overload resolution is the point.
|
|
108
|
+
// This function never runs, so these two are type-level calls only — they
|
|
109
|
+
// borrow `useQuery`'s overload resolution rather than invoking a hook.
|
|
110
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
111
|
+
expectTypeOf(useQuery(selectedOptions).data).toEqualTypeOf<
|
|
112
|
+
number | undefined
|
|
113
|
+
>();
|
|
114
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
115
|
+
expectTypeOf(useQuery(listOptions).data).toEqualTypeOf<User[] | undefined>();
|
|
116
|
+
|
|
117
|
+
// And the key's brand still carries the *fetched* type, not the selected one:
|
|
118
|
+
// `getQueryData` on this key hands back what the endpoint returned.
|
|
119
|
+
expectTypeOf(readTags(selectedOptions).data).toEqualTypeOf<User[]>();
|
|
120
|
+
|
|
121
|
+
// HEAD is in the query method union, so a declared HEAD operation has to be
|
|
122
|
+
// reachable through it — and only on the path that declares one.
|
|
123
|
+
const headOptions = api.queryOptions('head', '/users', {
|
|
124
|
+
params: { query: { search: 'ada' } },
|
|
125
|
+
});
|
|
126
|
+
expectTypeOf(headOptions.queryKey).toExtend<
|
|
127
|
+
readonly ['head', '/users', { query?: Record<string, unknown> }]
|
|
128
|
+
>();
|
|
129
|
+
|
|
130
|
+
// @ts-expect-error — /users/{id} declares no HEAD operation
|
|
131
|
+
api.queryOptions('head', '/users/{id}', { params: { path: { id: 'u1' } } });
|
|
132
|
+
|
|
133
|
+
// @ts-expect-error — `params.path.id` is required by the operation
|
|
134
|
+
api.queryOptions('get', '/users/{id}');
|
|
135
|
+
|
|
136
|
+
// @ts-expect-error — `id` must be a string
|
|
137
|
+
api.queryOptions('get', '/users/{id}', { params: { path: { id: 7 } } });
|
|
138
|
+
|
|
139
|
+
// @ts-expect-error — the path is not in TestPaths
|
|
140
|
+
api.queryOptions('get', '/unknown');
|
|
141
|
+
|
|
142
|
+
// @ts-expect-error — /users has no DELETE operation
|
|
143
|
+
api.queryOptions('delete', '/users');
|
|
144
|
+
|
|
145
|
+
// @ts-expect-error — mutating methods do not belong on queryOptions
|
|
146
|
+
api.queryOptions('post', '/users');
|
|
147
|
+
|
|
148
|
+
// @ts-expect-error — 'orders' is not in the declared tag vocabulary
|
|
149
|
+
api.queryOptions('get', '/users', undefined, { tags: ['orders'] });
|
|
150
|
+
|
|
151
|
+
// Mutations: variables are the operation's init, data is the 2xx body.
|
|
152
|
+
const createOptions = api.mutationOptions('post', '/users', {
|
|
153
|
+
invalidates: ['users'],
|
|
154
|
+
});
|
|
155
|
+
expectTypeOf(createOptions.mutationFn)
|
|
156
|
+
.parameter(0)
|
|
157
|
+
.toExtend<{ body: { name: string } }>();
|
|
158
|
+
expectTypeOf<
|
|
159
|
+
Awaited<ReturnType<typeof createOptions.mutationFn>>
|
|
160
|
+
>().toEqualTypeOf<User>();
|
|
161
|
+
expectTypeOf(createOptions.mutationKey).toEqualTypeOf<
|
|
162
|
+
readonly ['post', '/users']
|
|
163
|
+
>();
|
|
164
|
+
|
|
165
|
+
// A tag-producing callback sees the mutation's own result.
|
|
166
|
+
api.mutationOptions('post', '/users', {
|
|
167
|
+
invalidates: [
|
|
168
|
+
(created) => {
|
|
169
|
+
expectTypeOf(created).toEqualTypeOf<User>();
|
|
170
|
+
return ['user', 'users'];
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// @ts-expect-error — 'orders' is not in the declared tag vocabulary
|
|
176
|
+
api.mutationOptions('post', '/users', { invalidates: ['orders'] });
|
|
177
|
+
|
|
178
|
+
// @ts-expect-error — read methods do not belong on mutationOptions
|
|
179
|
+
api.mutationOptions('get', '/users');
|
|
180
|
+
|
|
181
|
+
const deleteOptions = api.mutationOptions('delete', '/users/{id}');
|
|
182
|
+
expectTypeOf(deleteOptions.mutationFn)
|
|
183
|
+
.parameter(0)
|
|
184
|
+
.toExtend<{ params: { path: { id: string } } }>();
|
|
185
|
+
|
|
186
|
+
// The query key helper is branded with the same data and error types.
|
|
187
|
+
expectTypeOf(
|
|
188
|
+
readTags({ queryKey: api.queryKey('get', '/users') }).data,
|
|
189
|
+
).toEqualTypeOf<User[]>();
|
|
190
|
+
|
|
191
|
+
// `QueryApiPaths` constrains `createQueryApi` itself, not just the option
|
|
192
|
+
// types it returns, so a paths type that is not a map of path → operations is
|
|
193
|
+
// rejected at the api's own signature.
|
|
194
|
+
const notPathsClient = {} as ApiClient<{ '/users': string }>;
|
|
195
|
+
// @ts-expect-error — `string` is not a map of HTTP method to operation
|
|
196
|
+
createQueryApi<{ '/users': string }>(notPathsClient);
|
|
197
|
+
|
|
198
|
+
// Inferring the same bad paths type off the client is the case the constraint
|
|
199
|
+
// cannot reject: inference finds no candidate and falls back to the
|
|
200
|
+
// constraint, so the api is typed `QueryApi<QueryApiPaths>` and it is the
|
|
201
|
+
// calls on it that fail, not the construction.
|
|
202
|
+
expectTypeOf(createQueryApi(notPathsClient)).toEqualTypeOf<
|
|
203
|
+
QueryApi<QueryApiPaths>
|
|
204
|
+
>();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* An operation whose init is entirely optional must be callable with two
|
|
209
|
+
* arguments — including in a contextually typed position, which is where the
|
|
210
|
+
* `NoInfer` wrappers on the return types earn their keep.
|
|
211
|
+
*/
|
|
212
|
+
export function optionalInitArity(queryClient: QueryClient): void {
|
|
213
|
+
api.queryKey('get', '/users');
|
|
214
|
+
api.queryOptions('get', '/users');
|
|
215
|
+
api.mutationOptions('post', '/users');
|
|
216
|
+
|
|
217
|
+
const key: readonly unknown[] = api.queryKey('get', '/users');
|
|
218
|
+
void key;
|
|
219
|
+
|
|
220
|
+
void queryClient.invalidateQueries({
|
|
221
|
+
queryKey: api.queryKey('get', '/users'),
|
|
222
|
+
});
|
|
223
|
+
void queryClient.getQueryState(api.queryKey('get', '/users'));
|
|
224
|
+
void queryClient.fetchQuery(api.queryOptions('get', '/users'));
|
|
225
|
+
}
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import {
|
|
2
|
+
environmentManager,
|
|
3
|
+
QueryCache,
|
|
4
|
+
QueryClient,
|
|
5
|
+
type QueryClientConfig,
|
|
6
|
+
QueryObserver,
|
|
7
|
+
} from '@tanstack/react-query';
|
|
8
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
9
|
+
|
|
10
|
+
import { HttpRequestError } from './errors.js';
|
|
11
|
+
import {
|
|
12
|
+
createQueryClient,
|
|
13
|
+
isRetryableError,
|
|
14
|
+
isRetryableHttpStatus,
|
|
15
|
+
shouldRetryRequest,
|
|
16
|
+
} from './query-client.js';
|
|
17
|
+
import { ZodResponseValidationError } from './zod-response.js';
|
|
18
|
+
|
|
19
|
+
/** An `HttpRequestError` as `createQueryApi` would build it for `status`. */
|
|
20
|
+
function httpError(status: number): HttpRequestError {
|
|
21
|
+
return new HttpRequestError({
|
|
22
|
+
method: 'get',
|
|
23
|
+
path: '/users',
|
|
24
|
+
body: undefined,
|
|
25
|
+
response: new Response(null, { status }),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A `ZodResponseValidationError` as the zod middleware would build it. */
|
|
30
|
+
function validationError(): ZodResponseValidationError {
|
|
31
|
+
return new ZodResponseValidationError({
|
|
32
|
+
method: 'get',
|
|
33
|
+
path: '/users/{id}',
|
|
34
|
+
schemaName: 'User',
|
|
35
|
+
issues: [{ path: 'name', message: 'expected string, received undefined' }],
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('isRetryableHttpStatus', () => {
|
|
40
|
+
// The four names below are the whole policy; the table specs under
|
|
41
|
+
// `shouldRetryRequest` exercise it through the exported predicate.
|
|
42
|
+
it.for([408, 425, 429])('retries the transient 4xx %i', (status) => {
|
|
43
|
+
expect(isRetryableHttpStatus(status)).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it.for([400, 401, 403, 404, 409, 410, 418, 422, 451, 499])(
|
|
47
|
+
'does not retry the client fault %i',
|
|
48
|
+
(status) => {
|
|
49
|
+
expect(isRetryableHttpStatus(status)).toBe(false);
|
|
50
|
+
},
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
it.for([500, 502, 503, 504, 599])('retries the server fault %i', (status) => {
|
|
54
|
+
expect(isRetryableHttpStatus(status)).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it.for([200, 204, 301, 304])(
|
|
58
|
+
'does not retry the sub-400 status %i',
|
|
59
|
+
(status) => {
|
|
60
|
+
expect(isRetryableHttpStatus(status)).toBe(false);
|
|
61
|
+
},
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('isRetryableError', () => {
|
|
66
|
+
it('reads the status off an HttpRequestError', () => {
|
|
67
|
+
expect(isRetryableError(httpError(429))).toBe(true);
|
|
68
|
+
expect(isRetryableError(httpError(422))).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('retries a rejection that never reached a status', () => {
|
|
72
|
+
// A dropped connection, a CORS refusal, or a middleware that threw: the
|
|
73
|
+
// only evidence is that the request did not complete.
|
|
74
|
+
expect(isRetryableError(new TypeError('Failed to fetch'))).toBe(true);
|
|
75
|
+
expect(isRetryableError(new Error('response validation failed'))).toBe(
|
|
76
|
+
true,
|
|
77
|
+
);
|
|
78
|
+
expect(isRetryableError('not an error at all')).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('narrows on `name`, not `instanceof`', () => {
|
|
82
|
+
// Two copies of this package in one module graph produce an error that is
|
|
83
|
+
// not `instanceof` the class this predicate closed over.
|
|
84
|
+
const foreign = Object.assign(new Error('HTTP 404'), {
|
|
85
|
+
name: 'HttpRequestError',
|
|
86
|
+
status: 404,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
expect(isRetryableError(foreign)).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('does not retry a body that failed response validation', () => {
|
|
93
|
+
// The one rejection without a status that still means the request
|
|
94
|
+
// completed: the middleware rejects after a 2xx arrived and parsed.
|
|
95
|
+
expect(isRetryableError(validationError())).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('narrows the validation error on `name` too', () => {
|
|
99
|
+
const foreign = Object.assign(new Error('User validation failed'), {
|
|
100
|
+
name: 'ZodResponseValidationError',
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
expect(isRetryableError(foreign)).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('shouldRetryRequest', () => {
|
|
108
|
+
it('allows two retries, then stops', () => {
|
|
109
|
+
// `failureCount` is the number of failures *before* this attempt, so the
|
|
110
|
+
// first rejection arrives as 0. Two `true`s means three attempts total.
|
|
111
|
+
expect(shouldRetryRequest(0, httpError(503))).toBe(true);
|
|
112
|
+
expect(shouldRetryRequest(1, httpError(503))).toBe(true);
|
|
113
|
+
expect(shouldRetryRequest(2, httpError(503))).toBe(false);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('stops on a client fault at the first failure', () => {
|
|
117
|
+
expect(shouldRetryRequest(0, httpError(404))).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('createQueryClient', () => {
|
|
122
|
+
it('installs the dashboard defaults', () => {
|
|
123
|
+
const queries = createQueryClient().getDefaultOptions().queries;
|
|
124
|
+
|
|
125
|
+
expect(queries?.refetchOnWindowFocus).toBe(false);
|
|
126
|
+
expect(queries?.retry).toBe(shouldRetryRequest);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('leaves mutations un-retried', () => {
|
|
130
|
+
expect(
|
|
131
|
+
createQueryClient().getDefaultOptions().mutations?.retry,
|
|
132
|
+
).toBeUndefined();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('replaces one default without dropping the others', () => {
|
|
136
|
+
const queries = createQueryClient({
|
|
137
|
+
defaultOptions: { queries: { retry: 5 } },
|
|
138
|
+
}).getDefaultOptions().queries;
|
|
139
|
+
|
|
140
|
+
expect(queries?.retry).toBe(5);
|
|
141
|
+
expect(queries?.refetchOnWindowFocus).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('does not read a present-but-undefined key as an override', () => {
|
|
145
|
+
// How conditional config is ordinarily written. A raw spread would copy
|
|
146
|
+
// `retry: undefined` over the predicate, and react-query resolves that as
|
|
147
|
+
// `retry ?? 3` — the retry-everything default this module exists to
|
|
148
|
+
// replace, reinstated by a branch that meant to change nothing.
|
|
149
|
+
const alwaysFail = false as boolean;
|
|
150
|
+
|
|
151
|
+
const queries = createQueryClient({
|
|
152
|
+
defaultOptions: {
|
|
153
|
+
queries: {
|
|
154
|
+
retry: alwaysFail ? false : undefined,
|
|
155
|
+
refetchOnWindowFocus: undefined,
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
}).getDefaultOptions().queries;
|
|
159
|
+
|
|
160
|
+
expect(queries?.retry).toBe(shouldRetryRequest);
|
|
161
|
+
expect(queries?.refetchOnWindowFocus).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('keeps the defined keys of a partly-undefined override', () => {
|
|
165
|
+
const queries = createQueryClient({
|
|
166
|
+
defaultOptions: { queries: { retry: undefined, staleTime: 30_000 } },
|
|
167
|
+
}).getDefaultOptions().queries;
|
|
168
|
+
|
|
169
|
+
expect(queries?.retry).toBe(shouldRetryRequest);
|
|
170
|
+
expect(queries?.staleTime).toBe(30_000);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('passes the rest of QueryClientConfig through untouched', () => {
|
|
174
|
+
const queryCache = new QueryCache();
|
|
175
|
+
|
|
176
|
+
const client = createQueryClient({
|
|
177
|
+
queryCache,
|
|
178
|
+
defaultOptions: { mutations: { retry: 1 }, dehydrate: {} },
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
expect(client.getQueryCache()).toBe(queryCache);
|
|
182
|
+
expect(client.getDefaultOptions().mutations?.retry).toBe(1);
|
|
183
|
+
expect(client.getDefaultOptions().dehydrate).toEqual({});
|
|
184
|
+
expect(client.getDefaultOptions().queries?.refetchOnWindowFocus).toBe(
|
|
185
|
+
false,
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('returns a distinct client per call', () => {
|
|
190
|
+
expect(createQueryClient()).not.toBe(createQueryClient());
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('is a real QueryClient', () => {
|
|
194
|
+
expect(createQueryClient()).toBeInstanceOf(QueryClient);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe('the retry policy a real client applies', () => {
|
|
199
|
+
/**
|
|
200
|
+
* How many times a query rejecting with `error` is attempted under the
|
|
201
|
+
* package defaults. Only `retryDelay` is overridden — `retry` is the default
|
|
202
|
+
* under test — so the exponential backoff does not make the spec wait out
|
|
203
|
+
* the three seconds a real screen would.
|
|
204
|
+
*/
|
|
205
|
+
async function countAttempts(error: unknown): Promise<number> {
|
|
206
|
+
let attempts = 0;
|
|
207
|
+
|
|
208
|
+
await createQueryClient({ defaultOptions: { queries: { retryDelay: 0 } } })
|
|
209
|
+
.fetchQuery({
|
|
210
|
+
queryKey: ['attempts'],
|
|
211
|
+
queryFn: () => {
|
|
212
|
+
attempts += 1;
|
|
213
|
+
return Promise.reject(error);
|
|
214
|
+
},
|
|
215
|
+
})
|
|
216
|
+
.catch(() => undefined);
|
|
217
|
+
|
|
218
|
+
return attempts;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
it('asks once when the response body drifted', async () => {
|
|
222
|
+
// Before the validation carve-out this was 3, and with the default
|
|
223
|
+
// backoff the error reached the screen three seconds late.
|
|
224
|
+
await expect(countAttempts(validationError())).resolves.toBe(1);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('asks once on a client fault', async () => {
|
|
228
|
+
await expect(countAttempts(httpError(422))).resolves.toBe(1);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// The rest of the statusless branch is unchanged: no status and no verdict
|
|
232
|
+
// from the server means the only reading available is that the request did
|
|
233
|
+
// not complete.
|
|
234
|
+
it.for([
|
|
235
|
+
['a dropped connection', new TypeError('Failed to fetch')],
|
|
236
|
+
['an abort', new DOMException('The operation was aborted.', 'AbortError')],
|
|
237
|
+
[
|
|
238
|
+
'a middleware that threw',
|
|
239
|
+
new Error('http-client-react: middleware called next() more than once'),
|
|
240
|
+
],
|
|
241
|
+
] as const)('still retries %s twice', async ([, error]) => {
|
|
242
|
+
await expect(countAttempts(error)).resolves.toBe(3);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('retries a server fault twice', async () => {
|
|
246
|
+
await expect(countAttempts(httpError(503))).resolves.toBe(3);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe('the retry policy a mounted query applies', () => {
|
|
251
|
+
// `fetchQuery` rewrites an `undefined` retry to `false`, so `countAttempts`
|
|
252
|
+
// above reads 1 whether the predicate is installed or missing — it cannot
|
|
253
|
+
// see a retry default that went away. A subscribed observer is the
|
|
254
|
+
// `useQuery` path, where react-query falls back to `retry ?? 3` instead, and
|
|
255
|
+
// the only place a lost predicate costs visible round trips.
|
|
256
|
+
const serverByDefault = environmentManager.isServer();
|
|
257
|
+
|
|
258
|
+
// That fallback is `?? 0` on the server, and these specs run under `node`.
|
|
259
|
+
beforeAll(() => environmentManager.setIsServer(() => false));
|
|
260
|
+
afterAll(() => environmentManager.setIsServer(() => serverByDefault));
|
|
261
|
+
|
|
262
|
+
/** How many times a *mounted* query rejecting with `error` is attempted. */
|
|
263
|
+
async function countMountedAttempts(
|
|
264
|
+
config: QueryClientConfig,
|
|
265
|
+
error: unknown,
|
|
266
|
+
): Promise<number> {
|
|
267
|
+
let attempts = 0;
|
|
268
|
+
|
|
269
|
+
const observer = new QueryObserver(createQueryClient(config), {
|
|
270
|
+
queryKey: ['mounted-attempts'],
|
|
271
|
+
queryFn: () => {
|
|
272
|
+
attempts += 1;
|
|
273
|
+
return Promise.reject(error);
|
|
274
|
+
},
|
|
275
|
+
retryDelay: 0,
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
await new Promise<void>((resolve) => {
|
|
279
|
+
const unsubscribe = observer.subscribe((result) => {
|
|
280
|
+
if (result.status === 'error') {
|
|
281
|
+
unsubscribe();
|
|
282
|
+
resolve();
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
return attempts;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
it('asks once on a client fault', async () => {
|
|
291
|
+
await expect(countMountedAttempts({}, httpError(422))).resolves.toBe(1);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('asks once when an override names `retry` as undefined', async () => {
|
|
295
|
+
// The shape a conditional override is ordinarily written in. Before the
|
|
296
|
+
// merge dropped undefined-valued keys this was 4 — react-query's own
|
|
297
|
+
// `retry ?? 3`, reinstated by a branch that meant to change nothing.
|
|
298
|
+
const alwaysFail = false as boolean;
|
|
299
|
+
|
|
300
|
+
await expect(
|
|
301
|
+
countMountedAttempts(
|
|
302
|
+
{
|
|
303
|
+
defaultOptions: {
|
|
304
|
+
queries: { retry: alwaysFail ? false : undefined },
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
httpError(422),
|
|
308
|
+
),
|
|
309
|
+
).resolves.toBe(1);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('retries a server fault twice', async () => {
|
|
313
|
+
await expect(countMountedAttempts({}, httpError(503))).resolves.toBe(3);
|
|
314
|
+
});
|
|
315
|
+
});
|