@krymskyimaksym/react-api-client 1.0.0 → 2.0.0-beta.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/dist/index.js CHANGED
@@ -21,6 +21,68 @@ function isConfigured() {
21
21
  return globalConfig !== null;
22
22
  }
23
23
 
24
+ // src/errors.ts
25
+ var ApiError = class _ApiError extends Error {
26
+ constructor(init) {
27
+ super(init.message);
28
+ this.name = "ApiError";
29
+ this.status = init.status;
30
+ this.code = init.code;
31
+ this.errors = init.errors;
32
+ this.isNetworkError = init.isNetworkError ?? false;
33
+ this.isUnauthorized = init.isUnauthorized ?? init.status === 401;
34
+ this.isValidationError = init.isValidationError ?? (init.status === 422 || init.errors !== void 0 && init.errors !== null);
35
+ this.raw = init.raw;
36
+ Object.setPrototypeOf(this, _ApiError.prototype);
37
+ }
38
+ };
39
+ var isObject = (v) => typeof v === "object" && v !== null;
40
+ function toApiError(thrown) {
41
+ if (thrown instanceof ApiError) return thrown;
42
+ if (isObject(thrown) && "response" in thrown && isObject(thrown.response)) {
43
+ const r = thrown.response;
44
+ const status = typeof r.status === "number" ? r.status : 0;
45
+ const data = isObject(r.data) ? r.data : void 0;
46
+ const message = (data && typeof data.message === "string" ? data.message : void 0) ?? (thrown instanceof Error ? thrown.message : void 0) ?? `HTTP ${status}`;
47
+ return new ApiError({
48
+ message,
49
+ status,
50
+ code: data && typeof data.code === "string" ? data.code : void 0,
51
+ errors: data?.errors,
52
+ isNetworkError: status === 0,
53
+ isUnauthorized: status === 401,
54
+ isValidationError: status === 422 || data?.errors !== void 0,
55
+ raw: r.data ?? thrown
56
+ });
57
+ }
58
+ if (thrown instanceof Error) {
59
+ return new ApiError({
60
+ message: thrown.message,
61
+ status: 0,
62
+ isNetworkError: true,
63
+ raw: thrown
64
+ });
65
+ }
66
+ return new ApiError({
67
+ message: "Unknown error",
68
+ status: 0,
69
+ raw: thrown
70
+ });
71
+ }
72
+ function businessErrorToApiError(response) {
73
+ if (!isObject(response)) {
74
+ return new ApiError({ message: "Request failed", status: 200, raw: response });
75
+ }
76
+ return new ApiError({
77
+ message: typeof response.message === "string" ? response.message : "Request failed",
78
+ status: 200,
79
+ code: typeof response.code === "string" ? response.code : void 0,
80
+ errors: response.errors,
81
+ isValidationError: response.errors !== void 0,
82
+ raw: response
83
+ });
84
+ }
85
+
24
86
  // src/utils.ts
25
87
  function buildEndpoint(endpoint, params) {
26
88
  if (typeof endpoint === "function" && typeof params !== "undefined") {
@@ -28,7 +90,7 @@ function buildEndpoint(endpoint, params) {
28
90
  }
29
91
  return endpoint;
30
92
  }
31
- async function executeRequest(endpoint, fetchConfig, params) {
93
+ async function executeRequest(endpoint, fetchConfig, params, signal) {
32
94
  const url = buildEndpoint(endpoint, params);
33
95
  const config = getConfig();
34
96
  try {
@@ -39,7 +101,8 @@ async function executeRequest(endpoint, fetchConfig, params) {
39
101
  params: {
40
102
  ...requestConfig.requestParams,
41
103
  ...params
42
- }
104
+ },
105
+ signal
43
106
  });
44
107
  } else {
45
108
  response = await config.httpClient.request(url, {
@@ -47,15 +110,30 @@ async function executeRequest(endpoint, fetchConfig, params) {
47
110
  data: {
48
111
  ...requestConfig.requestParams,
49
112
  ...params
50
- }
113
+ },
114
+ signal
51
115
  });
52
116
  }
117
+ const hasExplicitStatus = response && typeof response === "object" && "status" in response && typeof response.status === "boolean";
118
+ if (config.throwOnError && hasExplicitStatus && response.status === false) {
119
+ throw businessErrorToApiError(response);
120
+ }
53
121
  return { ...response, status: true };
54
122
  } catch (e) {
123
+ if (e instanceof ApiError) {
124
+ if (e.status === 401 && config.onUnauthorized) {
125
+ await config.onUnauthorized();
126
+ }
127
+ throw e;
128
+ }
55
129
  const error = e;
56
- if (error.response?.status === 401 && config.onUnauthorized) {
130
+ const httpStatus = error.response?.status;
131
+ if (httpStatus === 401 && config.onUnauthorized) {
57
132
  await config.onUnauthorized();
58
133
  }
134
+ if (config.throwOnError) {
135
+ throw toApiError(e);
136
+ }
59
137
  if (error.response?.data) {
60
138
  return {
61
139
  ...error.response.data,
@@ -64,7 +142,7 @@ async function executeRequest(endpoint, fetchConfig, params) {
64
142
  }
65
143
  return {
66
144
  status: false,
67
- message: error instanceof Error ? error.message : "Request error"
145
+ message: e instanceof Error ? e.message : "Request error"
68
146
  };
69
147
  }
70
148
  }
@@ -76,78 +154,562 @@ function handleResponse(result, onSuccess, onError) {
76
154
  onError(err);
77
155
  }
78
156
  }
157
+
158
+ // src/query/focus-manager.ts
159
+ var FocusManager = class {
160
+ constructor() {
161
+ this.focused = true;
162
+ this.listeners = /* @__PURE__ */ new Set();
163
+ this.cleanup = null;
164
+ }
165
+ subscribe(listener) {
166
+ this.listeners.add(listener);
167
+ this.setupBrowserListeners();
168
+ return () => {
169
+ this.listeners.delete(listener);
170
+ if (this.listeners.size === 0) this.teardownBrowserListeners();
171
+ };
172
+ }
173
+ isFocused() {
174
+ return this.focused;
175
+ }
176
+ setFocused(focused) {
177
+ if (this.focused === focused) return;
178
+ this.focused = focused;
179
+ for (const l of this.listeners) l(focused);
180
+ }
181
+ setupBrowserListeners() {
182
+ if (this.cleanup) return;
183
+ if (typeof window === "undefined" || typeof window.addEventListener !== "function") {
184
+ return;
185
+ }
186
+ const onFocus = () => this.setFocused(true);
187
+ const onVisibility = () => {
188
+ if (typeof document !== "undefined") {
189
+ this.setFocused(document.visibilityState !== "hidden");
190
+ }
191
+ };
192
+ window.addEventListener("focus", onFocus);
193
+ if (typeof document !== "undefined") {
194
+ document.addEventListener("visibilitychange", onVisibility);
195
+ }
196
+ this.cleanup = () => {
197
+ window.removeEventListener("focus", onFocus);
198
+ if (typeof document !== "undefined") {
199
+ document.removeEventListener("visibilitychange", onVisibility);
200
+ }
201
+ };
202
+ }
203
+ teardownBrowserListeners() {
204
+ this.cleanup?.();
205
+ this.cleanup = null;
206
+ }
207
+ };
208
+ var focusManager = new FocusManager();
209
+
210
+ // src/query/online-manager.ts
211
+ var OnlineManager = class {
212
+ constructor() {
213
+ this.online = true;
214
+ this.listeners = /* @__PURE__ */ new Set();
215
+ this.cleanup = null;
216
+ }
217
+ subscribe(listener) {
218
+ this.listeners.add(listener);
219
+ this.setupBrowserListeners();
220
+ return () => {
221
+ this.listeners.delete(listener);
222
+ if (this.listeners.size === 0) this.teardownBrowserListeners();
223
+ };
224
+ }
225
+ isOnline() {
226
+ return this.online;
227
+ }
228
+ setOnline(online) {
229
+ if (this.online === online) return;
230
+ this.online = online;
231
+ for (const l of this.listeners) l(online);
232
+ }
233
+ setupBrowserListeners() {
234
+ if (this.cleanup) return;
235
+ if (typeof window === "undefined" || typeof window.addEventListener !== "function") {
236
+ return;
237
+ }
238
+ if (typeof navigator !== "undefined" && "onLine" in navigator) {
239
+ this.online = navigator.onLine;
240
+ }
241
+ const onOnline = () => this.setOnline(true);
242
+ const onOffline = () => this.setOnline(false);
243
+ window.addEventListener("online", onOnline);
244
+ window.addEventListener("offline", onOffline);
245
+ this.cleanup = () => {
246
+ window.removeEventListener("online", onOnline);
247
+ window.removeEventListener("offline", onOffline);
248
+ };
249
+ }
250
+ teardownBrowserListeners() {
251
+ this.cleanup?.();
252
+ this.cleanup = null;
253
+ }
254
+ };
255
+ var onlineManager = new OnlineManager();
256
+
257
+ // src/query/key.ts
258
+ function hashQueryKey(key) {
259
+ return JSON.stringify(key, (_, value) => {
260
+ if (typeof value === "function") {
261
+ throw new Error(
262
+ "react-api-client: \u0444\u0443\u043D\u043A\u0446\u0438\u0438 \u0437\u0430\u043F\u0440\u0435\u0449\u0435\u043D\u044B \u0432 queryKey \u2014 \u043A\u043B\u044E\u0447 \u0434\u043E\u043B\u0436\u0435\u043D \u0431\u044B\u0442\u044C \u0441\u0435\u0440\u0438\u0430\u043B\u0438\u0437\u0443\u0435\u043C"
263
+ );
264
+ }
265
+ if (typeof value === "symbol") {
266
+ throw new Error(
267
+ "react-api-client: symbol \u0437\u0430\u043F\u0440\u0435\u0449\u0435\u043D\u044B \u0432 queryKey \u2014 \u043A\u043B\u044E\u0447 \u0434\u043E\u043B\u0436\u0435\u043D \u0431\u044B\u0442\u044C \u0441\u0435\u0440\u0438\u0430\u043B\u0438\u0437\u0443\u0435\u043C"
268
+ );
269
+ }
270
+ if (value && typeof value === "object" && !Array.isArray(value)) {
271
+ const sortedKeys = Object.keys(value).sort();
272
+ const result = {};
273
+ for (const k of sortedKeys) {
274
+ const v = value[k];
275
+ if (v !== void 0) result[k] = v;
276
+ }
277
+ return result;
278
+ }
279
+ return value;
280
+ });
281
+ }
282
+ function matchQueryKey(prefix, key) {
283
+ if (prefix.length > key.length) return false;
284
+ for (let i = 0; i < prefix.length; i++) {
285
+ if (hashQueryKey([prefix[i]]) !== hashQueryKey([key[i]])) return false;
286
+ }
287
+ return true;
288
+ }
289
+
290
+ // src/query/cache.ts
291
+ var DEFAULT_GC_TIME = 5 * 60 * 1e3;
292
+ var DEFAULT_STALE_TIME = 0;
293
+ var QueryCache = class {
294
+ constructor() {
295
+ this.entries = /* @__PURE__ */ new Map();
296
+ this.globalListeners = /* @__PURE__ */ new Set();
297
+ }
298
+ /**
299
+ * Подписка на любое изменение кэша: setData, invalidate, remove,
300
+ * успешный/ошибочный fetch. Используется persistQueryClient и
301
+ * devtools-подобными адаптерами. Не дублирует `subscribe(key, ...)`.
302
+ */
303
+ subscribeAll(listener) {
304
+ this.globalListeners.add(listener);
305
+ return () => {
306
+ this.globalListeners.delete(listener);
307
+ };
308
+ }
309
+ notifyGlobal() {
310
+ for (const listener of this.globalListeners) listener();
311
+ }
312
+ ensureEntry(key, staleTime, gcTime) {
313
+ const hash = hashQueryKey(key);
314
+ let entry = this.entries.get(hash);
315
+ if (!entry) {
316
+ entry = {
317
+ key,
318
+ state: {
319
+ data: void 0,
320
+ error: null,
321
+ status: "idle",
322
+ updatedAt: 0,
323
+ isStale: true
324
+ },
325
+ subscribers: /* @__PURE__ */ new Set(),
326
+ inflight: null,
327
+ inflightController: null,
328
+ gcTimer: null,
329
+ staleTime: staleTime ?? DEFAULT_STALE_TIME,
330
+ gcTime: gcTime ?? DEFAULT_GC_TIME
331
+ };
332
+ this.entries.set(hash, entry);
333
+ } else {
334
+ if (staleTime !== void 0) entry.staleTime = staleTime;
335
+ if (gcTime !== void 0) entry.gcTime = gcTime;
336
+ }
337
+ return entry;
338
+ }
339
+ getState(key) {
340
+ const entry = this.entries.get(hashQueryKey(key));
341
+ return entry?.state;
342
+ }
343
+ getData(key) {
344
+ return this.getState(key)?.data;
345
+ }
346
+ setData(key, updater) {
347
+ const entry = this.ensureEntry(key);
348
+ const next = typeof updater === "function" ? updater(entry.state.data) : updater;
349
+ entry.state = {
350
+ data: next,
351
+ error: null,
352
+ status: "success",
353
+ updatedAt: Date.now(),
354
+ isStale: false
355
+ };
356
+ this.notify(entry);
357
+ }
358
+ /**
359
+ * Подписка на изменения ключа. Возвращает unsubscribe.
360
+ * Подписка останавливает GC таймер; отписка — запускает его обратно.
361
+ */
362
+ subscribe(key, listener) {
363
+ const entry = this.ensureEntry(key);
364
+ entry.subscribers.add(listener);
365
+ if (entry.gcTimer) {
366
+ clearTimeout(entry.gcTimer);
367
+ entry.gcTimer = null;
368
+ }
369
+ return () => {
370
+ entry.subscribers.delete(listener);
371
+ if (entry.subscribers.size === 0) this.scheduleGc(entry);
372
+ };
373
+ }
374
+ notify(entry) {
375
+ for (const listener of entry.subscribers) listener();
376
+ this.notifyGlobal();
377
+ }
378
+ scheduleGc(entry) {
379
+ if (entry.gcTimer) clearTimeout(entry.gcTimer);
380
+ if (entry.gcTime <= 0) {
381
+ this.entries.delete(hashQueryKey(entry.key));
382
+ return;
383
+ }
384
+ entry.gcTimer = setTimeout(() => {
385
+ if (entry.subscribers.size === 0 && !entry.inflight) {
386
+ this.entries.delete(hashQueryKey(entry.key));
387
+ }
388
+ }, entry.gcTime);
389
+ }
390
+ /**
391
+ * Запускает или присоединяется к inflight-запросу.
392
+ * Если данные свежие (не stale) и не force — отдаёт кэш без запроса.
393
+ */
394
+ async fetch(key, queryFn, options = {}) {
395
+ const staleTime = options.staleTime ?? DEFAULT_STALE_TIME;
396
+ const gcTime = options.gcTime ?? DEFAULT_GC_TIME;
397
+ const entry = this.ensureEntry(key, staleTime, gcTime);
398
+ const isFresh = entry.state.status === "success" && !entry.state.isStale && Date.now() - entry.state.updatedAt < staleTime;
399
+ if (!options.force && isFresh && entry.state.data !== void 0) {
400
+ return entry.state.data;
401
+ }
402
+ if (entry.inflight) return entry.inflight;
403
+ entry.state = { ...entry.state, status: "loading", error: null };
404
+ this.notify(entry);
405
+ const controller = new AbortController();
406
+ const token = Symbol("inflight");
407
+ entry.inflightToken = token;
408
+ entry.inflightController = controller;
409
+ const isCurrent = () => entry.inflightToken === token;
410
+ const myPromise = (async () => {
411
+ try {
412
+ const data = await queryFn({ signal: controller.signal });
413
+ if (!isCurrent()) return data;
414
+ entry.state = {
415
+ data,
416
+ error: null,
417
+ status: "success",
418
+ updatedAt: Date.now(),
419
+ isStale: false
420
+ };
421
+ this.notify(entry);
422
+ return data;
423
+ } catch (err) {
424
+ if (!isCurrent()) throw err;
425
+ entry.state = {
426
+ ...entry.state,
427
+ status: "error",
428
+ error: err
429
+ };
430
+ this.notify(entry);
431
+ throw err;
432
+ } finally {
433
+ if (isCurrent()) {
434
+ entry.inflight = null;
435
+ entry.inflightController = null;
436
+ }
437
+ }
438
+ })();
439
+ entry.inflight = myPromise;
440
+ return myPromise;
441
+ }
442
+ /**
443
+ * Помечает запись как stale. Сами по себе данные не удаляются.
444
+ * Если есть подписчики — они получат уведомление, чтобы инициировать refetch.
445
+ */
446
+ invalidate(predicate) {
447
+ const invalidated = [];
448
+ const match = typeof predicate === "function" ? predicate : (k) => matchQueryKey(predicate, k);
449
+ for (const [hash, entry] of this.entries) {
450
+ if (match(entry.key)) {
451
+ entry.state = { ...entry.state, isStale: true };
452
+ this.notify(entry);
453
+ invalidated.push(hash);
454
+ }
455
+ }
456
+ return invalidated;
457
+ }
458
+ /**
459
+ * Отменяет «привязку» inflight-промиса к ключу. Сам HTTP-запрос
460
+ * продолжит исполняться (executeRequest не использует AbortSignal),
461
+ * но его результат больше не попадёт в кэш и не уведомит подписчиков.
462
+ * Полезно при размонтировании / при переключении страниц.
463
+ */
464
+ cancelQueries(predicate) {
465
+ const match = typeof predicate === "function" ? predicate : (k) => matchQueryKey(predicate, k);
466
+ for (const entry of this.entries.values()) {
467
+ if (match(entry.key) && entry.inflight) {
468
+ entry.inflightController?.abort();
469
+ entry.inflightController = null;
470
+ entry.inflight = null;
471
+ entry.inflightToken = void 0;
472
+ if (entry.state.status === "loading") {
473
+ entry.state = { ...entry.state, status: "idle" };
474
+ this.notify(entry);
475
+ }
476
+ }
477
+ }
478
+ }
479
+ /**
480
+ * Полностью удаляет записи (даже с активными подписчиками).
481
+ * Используется редко — обычно достаточно invalidate.
482
+ */
483
+ remove(predicate) {
484
+ const match = typeof predicate === "function" ? predicate : (k) => matchQueryKey(predicate, k);
485
+ let removed = false;
486
+ for (const [hash, entry] of [...this.entries]) {
487
+ if (match(entry.key)) {
488
+ if (entry.gcTimer) clearTimeout(entry.gcTimer);
489
+ this.entries.delete(hash);
490
+ removed = true;
491
+ }
492
+ }
493
+ if (removed) this.notifyGlobal();
494
+ }
495
+ /** Только для тестов / DevTools. */
496
+ _debugEntries() {
497
+ return this.entries;
498
+ }
499
+ /**
500
+ * Сериализует записи со статусом success — для persistence.
501
+ * inflight / loading / error не сохраняются, чтобы не гидратировать
502
+ * приложение в полу-загруженном состоянии.
503
+ */
504
+ dehydrate(filter) {
505
+ const queries = [];
506
+ for (const entry of this.entries.values()) {
507
+ if (entry.state.status !== "success") continue;
508
+ if (entry.state.data === void 0) continue;
509
+ if (filter && !filter(entry.key)) continue;
510
+ queries.push({
511
+ key: entry.key,
512
+ data: entry.state.data,
513
+ updatedAt: entry.state.updatedAt
514
+ });
515
+ }
516
+ return { queries };
517
+ }
518
+ hydrate(state) {
519
+ for (const q of state.queries) {
520
+ const entry = this.ensureEntry(q.key);
521
+ if (entry.state.updatedAt >= q.updatedAt && entry.state.data !== void 0) {
522
+ continue;
523
+ }
524
+ entry.state = {
525
+ data: q.data,
526
+ error: null,
527
+ status: "success",
528
+ updatedAt: q.updatedAt,
529
+ isStale: true
530
+ // гидратированные данные сразу stale → фоновый refetch
531
+ };
532
+ this.notify(entry);
533
+ }
534
+ }
535
+ };
536
+
537
+ // src/query/client.ts
538
+ var QueryClient = class {
539
+ constructor(cache) {
540
+ this.cache = cache ?? new QueryCache();
541
+ }
542
+ getQueryData(key) {
543
+ return this.cache.getData(key);
544
+ }
545
+ setQueryData(key, updater) {
546
+ this.cache.setData(key, updater);
547
+ }
548
+ fetchQuery(key, queryFn, options) {
549
+ return this.cache.fetch(key, queryFn, options);
550
+ }
551
+ invalidateQueries(predicate) {
552
+ this.cache.invalidate(predicate);
553
+ }
554
+ removeQueries(predicate) {
555
+ this.cache.remove(predicate);
556
+ }
557
+ cancelQueries(predicate) {
558
+ this.cache.cancelQueries(predicate);
559
+ }
560
+ };
561
+ var globalClient = null;
562
+ function getQueryClient() {
563
+ if (!globalClient) globalClient = new QueryClient();
564
+ return globalClient;
565
+ }
566
+ function setQueryClient(client) {
567
+ globalClient = client;
568
+ }
569
+
570
+ // src/hooks/use-fetch.ts
79
571
  function createUseFetch(endpoint, fetchConfig) {
80
572
  return (params, options = {}) => {
81
573
  const {
82
574
  enabled = true,
83
575
  refetchOnMount = true,
576
+ refetchOnFocus = false,
577
+ refetchOnAppActive = false,
578
+ refetchOnReconnect = false,
579
+ staleTime = 0,
580
+ gcTime,
581
+ pollingInterval,
582
+ queryKey: customKey,
583
+ select,
84
584
  onSuccess,
85
585
  onError
86
586
  } = options;
87
- const [data, setData] = react.useState(null);
88
- const [isLoading, setIsLoading] = react.useState(enabled && refetchOnMount);
89
- const [isRefetching, setIsRefetching] = react.useState(false);
90
- const [error, setError] = react.useState(null);
91
- const isMountedRef = react.useRef(true);
92
587
  const serializedParams = react.useMemo(
93
- () => params ? JSON.stringify(params) : null,
588
+ () => params === void 0 ? null : JSON.stringify(params),
94
589
  [params]
95
590
  );
96
- const fetchData = react.useCallback(
97
- async (isRefetch = false) => {
98
- if (!enabled) return;
591
+ const queryKey = react.useMemo(() => {
592
+ if (customKey) return customKey;
593
+ const endpointId = typeof endpoint === "function" ? buildEndpoint(endpoint, params) : endpoint;
594
+ return ["__endpoint__", endpointId, params ?? null];
595
+ }, [customKey ? hashQueryKey(customKey) : null, serializedParams]);
596
+ const client = getQueryClient();
597
+ const cache = client.cache;
598
+ const queryFn = react.useCallback(
599
+ ({ signal }) => {
600
+ const parsedParams = serializedParams ? JSON.parse(serializedParams) : void 0;
601
+ return executeRequest(endpoint, fetchConfig, parsedParams, signal);
602
+ },
603
+ [serializedParams]
604
+ );
605
+ const initialState = cache.getState(queryKey);
606
+ const [, forceRender] = react.useState(0);
607
+ const rerender = react.useCallback(() => forceRender((v) => v + 1), []);
608
+ react.useEffect(() => {
609
+ const unsub = cache.subscribe(queryKey, rerender);
610
+ return () => {
611
+ unsub();
612
+ const state2 = cache._debugEntries().get(hashQueryKey(queryKey));
613
+ if (state2 && state2.subscribers.size === 0 && state2.inflight) {
614
+ cache.cancelQueries(queryKey);
615
+ }
616
+ };
617
+ }, [cache, hashQueryKey(queryKey), rerender]);
618
+ const lastNotifiedRef = react.useRef({});
619
+ const runFetch = react.useCallback(
620
+ async (force) => {
99
621
  try {
100
- if (isRefetch) {
101
- setIsRefetching(true);
102
- } else {
103
- setIsLoading(true);
104
- }
105
- setError(null);
106
- const parsedParams = serializedParams ? JSON.parse(serializedParams) : void 0;
107
- const result = await executeRequest(endpoint, fetchConfig, parsedParams);
108
- if (isMountedRef.current) {
109
- setData(result);
622
+ const data = await cache.fetch(queryKey, queryFn, {
623
+ staleTime,
624
+ gcTime,
625
+ force
626
+ });
627
+ if (lastNotifiedRef.current.success !== data) {
628
+ lastNotifiedRef.current.success = data;
110
629
  handleResponse(
111
- result,
630
+ data,
112
631
  onSuccess,
113
632
  onError
114
633
  );
115
634
  }
116
635
  } catch (err) {
117
- const error2 = err;
118
- if (isMountedRef.current) {
119
- setError(error2);
120
- if (onError) {
121
- onError(error2);
122
- }
123
- }
124
- } finally {
125
- if (isMountedRef.current) {
126
- if (isRefetch) {
127
- setIsRefetching(false);
128
- } else {
129
- setIsLoading(false);
130
- }
636
+ const e = err;
637
+ const hash = `${e.name}:${e.message}`;
638
+ if (lastNotifiedRef.current.errorHash !== hash) {
639
+ lastNotifiedRef.current.errorHash = hash;
640
+ onError?.(e);
131
641
  }
132
642
  }
133
643
  },
134
- [enabled, serializedParams, onSuccess, onError]
644
+ [cache, hashQueryKey(queryKey), queryFn, staleTime, gcTime, onSuccess, onError]
135
645
  );
136
- const refetch = react.useCallback(async () => {
137
- await fetchData(true);
138
- }, [fetchData]);
139
646
  react.useEffect(() => {
140
- isMountedRef.current = true;
141
- if (enabled && refetchOnMount) {
142
- void fetchData(false);
143
- }
647
+ if (!enabled || !refetchOnMount) return;
648
+ void runFetch(false);
649
+ }, [enabled, refetchOnMount, runFetch]);
650
+ const stateForEffect = cache.getState(queryKey);
651
+ const isStale = stateForEffect?.isStale ?? false;
652
+ const hasFetchedData = stateForEffect?.data !== void 0;
653
+ react.useEffect(() => {
654
+ if (!enabled) return;
655
+ if (isStale && hasFetchedData) void runFetch(true);
656
+ }, [enabled, isStale, hasFetchedData, runFetch]);
657
+ react.useEffect(() => {
658
+ if (!enabled) return;
659
+ if (!refetchOnFocus && !refetchOnAppActive) return;
660
+ const unsub = focusManager.subscribe((focused) => {
661
+ if (focused) void runFetch(false);
662
+ });
663
+ return unsub;
664
+ }, [enabled, refetchOnFocus, refetchOnAppActive, runFetch]);
665
+ react.useEffect(() => {
666
+ if (!enabled || !refetchOnReconnect) return;
667
+ const unsub = onlineManager.subscribe((online) => {
668
+ if (online) void runFetch(false);
669
+ });
670
+ return unsub;
671
+ }, [enabled, refetchOnReconnect, runFetch]);
672
+ react.useEffect(() => {
673
+ if (!enabled || !pollingInterval || pollingInterval <= 0) return;
674
+ let timer = null;
675
+ const start = () => {
676
+ if (timer) return;
677
+ timer = setInterval(() => {
678
+ if (focusManager.isFocused()) void runFetch(true);
679
+ }, pollingInterval);
680
+ };
681
+ const stop = () => {
682
+ if (timer) clearInterval(timer);
683
+ timer = null;
684
+ };
685
+ start();
686
+ const unsub = focusManager.subscribe((focused) => {
687
+ if (focused) start();
688
+ else stop();
689
+ });
144
690
  return () => {
145
- isMountedRef.current = false;
691
+ stop();
692
+ unsub();
146
693
  };
147
- }, [enabled, refetchOnMount, fetchData]);
694
+ }, [enabled, pollingInterval, runFetch]);
695
+ const state = cache.getState(queryKey) ?? initialState;
696
+ const rawData = state?.data ?? null;
697
+ const selectedData = react.useMemo(() => {
698
+ if (rawData === null) return null;
699
+ if (!select) return rawData;
700
+ return select(rawData);
701
+ }, [rawData, select]);
702
+ const status = state?.status ?? "idle";
703
+ const hasData = rawData !== null;
704
+ const isLoading = status === "loading" && !hasData;
705
+ const isRefetching = status === "loading" && hasData;
706
+ const error = state?.error ?? null;
707
+ const refetch = react.useCallback(async () => {
708
+ await runFetch(true);
709
+ }, [runFetch]);
148
710
  return {
149
- data,
150
- isLoading,
711
+ data: selectedData,
712
+ isLoading: enabled ? isLoading : false,
151
713
  isRefetching,
152
714
  error,
153
715
  refetch
@@ -156,7 +718,14 @@ function createUseFetch(endpoint, fetchConfig) {
156
718
  }
157
719
  function createUseMutation(endpoint, fetchConfig) {
158
720
  return (options = {}) => {
159
- const { onMutate, onSuccess, onError, onSettled } = options;
721
+ const {
722
+ onMutate,
723
+ onSuccess,
724
+ onError,
725
+ onSettled,
726
+ invalidateKeys,
727
+ setQueryData
728
+ } = options;
160
729
  const [data, setData] = react.useState(null);
161
730
  const [error, setError] = react.useState(null);
162
731
  const [isLoading, setIsLoading] = react.useState(false);
@@ -169,33 +738,51 @@ function createUseMutation(endpoint, fetchConfig) {
169
738
  setIsSuccess(false);
170
739
  setIsError(false);
171
740
  }, []);
741
+ const applyInvalidate = react.useCallback(
742
+ (vars, result) => {
743
+ if (!invalidateKeys) return;
744
+ const client = getQueryClient();
745
+ const keys = typeof invalidateKeys === "function" ? invalidateKeys(vars, result) : invalidateKeys;
746
+ if (keys.length === 0) return;
747
+ client.invalidateQueries(
748
+ (k) => keys.some((prefix) => matchQueryKey(prefix, k))
749
+ );
750
+ },
751
+ [invalidateKeys]
752
+ );
172
753
  const mutateAsync = react.useCallback(
173
754
  async (variables) => {
174
755
  setIsLoading(true);
175
756
  setIsSuccess(false);
176
757
  setIsError(false);
177
758
  setError(null);
759
+ let context;
178
760
  try {
179
761
  if (onMutate) {
180
- await onMutate(variables);
762
+ const ctx = await onMutate(variables);
763
+ context = ctx;
181
764
  }
182
765
  const result = await executeRequest(endpoint, fetchConfig, variables);
183
766
  setData(result);
184
767
  if (result.status) {
185
768
  setIsSuccess(true);
769
+ if (setQueryData) {
770
+ setQueryData(getQueryClient(), variables, result);
771
+ }
772
+ applyInvalidate(variables, result);
186
773
  if (onSuccess) {
187
- await onSuccess(result, variables);
774
+ await onSuccess(result, variables, context);
188
775
  }
189
776
  } else {
190
777
  const err = new Error(result.message ?? "Mutation failed");
191
778
  setIsError(true);
192
779
  setError(err);
193
780
  if (onError) {
194
- await onError(err, variables);
781
+ await onError(err, variables, context);
195
782
  }
196
783
  }
197
784
  if (onSettled) {
198
- await onSettled(result, null, variables);
785
+ await onSettled(result, null, variables, context);
199
786
  }
200
787
  return result;
201
788
  } catch (err) {
@@ -204,17 +791,24 @@ function createUseMutation(endpoint, fetchConfig) {
204
791
  setIsError(true);
205
792
  setIsSuccess(false);
206
793
  if (onError) {
207
- await onError(error2, variables);
794
+ await onError(error2, variables, context);
208
795
  }
209
796
  if (onSettled) {
210
- await onSettled(null, error2, variables);
797
+ await onSettled(null, error2, variables, context);
211
798
  }
212
799
  throw error2;
213
800
  } finally {
214
801
  setIsLoading(false);
215
802
  }
216
803
  },
217
- [onMutate, onSuccess, onError, onSettled]
804
+ [
805
+ onMutate,
806
+ onSuccess,
807
+ onError,
808
+ onSettled,
809
+ setQueryData,
810
+ applyInvalidate
811
+ ]
218
812
  );
219
813
  const mutateSync = react.useCallback(
220
814
  (variables) => {
@@ -240,127 +834,153 @@ function createUsePaginate(endpoint, fetchConfig, options) {
240
834
  enabled = true,
241
835
  initialPage = 1,
242
836
  initialLimit = 20,
837
+ staleTime = 0,
838
+ gcTime,
839
+ keepPreviousData = false,
840
+ queryKey: customKey,
243
841
  onSuccess,
244
842
  onError
245
843
  } = hookOptions;
246
- const [data, setData] = react.useState([]);
844
+ const client = getQueryClient();
845
+ const cache = client.cache;
846
+ const limit = initialLimit;
247
847
  const [currentPage, setCurrentPage] = react.useState(initialPage);
248
- const [totalPages, setTotalPages] = react.useState(null);
249
- const [total, setTotal] = react.useState(null);
250
- const [isLoading, setIsLoading] = react.useState(enabled);
251
848
  const [isFetchingNextPage, setIsFetchingNextPage] = react.useState(false);
252
849
  const [error, setError] = react.useState(null);
253
- const isMountedRef = react.useRef(true);
254
- const limit = initialLimit;
255
850
  const dataExtractor = react.useMemo(
256
851
  () => options?.dataExtractor || ((response) => response.data),
852
+ // eslint-disable-next-line react-hooks/exhaustive-deps
257
853
  []
258
854
  );
259
855
  const totalExtractor = react.useMemo(
260
856
  () => options?.totalExtractor || ((response) => response.total ?? 0),
857
+ // eslint-disable-next-line react-hooks/exhaustive-deps
261
858
  []
262
859
  );
263
860
  const serializedParams = react.useMemo(
264
- () => params ? JSON.stringify(params) : null,
861
+ () => params === void 0 ? null : JSON.stringify(params),
265
862
  [params]
266
863
  );
267
- const fetchPage = react.useCallback(
268
- async (page, append = false) => {
269
- if (!enabled) return;
864
+ const keyPrefix = react.useMemo(() => {
865
+ if (customKey) return customKey;
866
+ const endpointId = typeof endpoint === "function" ? buildEndpoint(
867
+ endpoint,
868
+ params
869
+ ) : endpoint;
870
+ return ["__paginate__", endpointId, params ?? null];
871
+ }, [customKey ? hashQueryKey(customKey) : null, serializedParams]);
872
+ const pageKey = react.useCallback(
873
+ (page) => [...keyPrefix, { page, limit }],
874
+ [keyPrefix, limit]
875
+ );
876
+ const pageQueryFn = react.useCallback(
877
+ (page) => ({ signal }) => {
878
+ const parsedParams = serializedParams ? JSON.parse(serializedParams) : {};
879
+ const requestParams = {
880
+ ...parsedParams,
881
+ page,
882
+ limit
883
+ };
884
+ return executeRequest(endpoint, fetchConfig, requestParams, signal);
885
+ },
886
+ [serializedParams, limit]
887
+ );
888
+ const [, forceRender] = react.useState(0);
889
+ const rerender = react.useCallback(() => forceRender((v) => v + 1), []);
890
+ react.useEffect(() => {
891
+ if (!enabled) return;
892
+ const unsub = cache.subscribe(pageKey(currentPage), rerender);
893
+ return unsub;
894
+ }, [cache, enabled, hashQueryKey(pageKey(currentPage)), rerender]);
895
+ const previousPageKeyRef = react.useRef(null);
896
+ const runFetchPage = react.useCallback(
897
+ async (page, isNextPage) => {
270
898
  try {
271
- if (append) {
272
- setIsFetchingNextPage(true);
273
- } else {
274
- setIsLoading(true);
275
- }
899
+ if (isNextPage) setIsFetchingNextPage(true);
276
900
  setError(null);
277
- const parsedParams = serializedParams ? JSON.parse(serializedParams) : {};
278
- const requestParams = {
279
- ...parsedParams,
280
- page,
281
- limit
282
- };
283
- const result = await executeRequest(endpoint, fetchConfig, requestParams);
284
- if (isMountedRef.current) {
285
- handleResponse(
286
- result,
287
- onSuccess,
288
- onError
289
- );
290
- if (!result.status) {
291
- const err = new Error(result.message ?? "Request failed");
292
- setError(err);
293
- return;
294
- }
295
- const newData = dataExtractor(result);
296
- const totalCount = totalExtractor(result);
297
- if (append) {
298
- setData((prevData) => [...prevData, ...newData]);
299
- } else {
300
- setData(newData);
301
- }
302
- setCurrentPage(page);
303
- setTotal(totalCount);
304
- setTotalPages(Math.ceil(totalCount / limit));
901
+ const result = await cache.fetch(pageKey(page), pageQueryFn(page), {
902
+ staleTime,
903
+ gcTime
904
+ });
905
+ handleResponse(
906
+ result,
907
+ onSuccess,
908
+ onError
909
+ );
910
+ if (!result.status) {
911
+ setError(new Error(result.message ?? "Request failed"));
912
+ return;
305
913
  }
914
+ previousPageKeyRef.current = pageKey(page);
915
+ setCurrentPage(page);
306
916
  } catch (err) {
307
- const error2 = err;
308
- if (isMountedRef.current) {
309
- setError(error2);
310
- if (onError) {
311
- onError(error2);
312
- }
313
- }
917
+ const e = err;
918
+ setError(e);
919
+ onError?.(e);
314
920
  } finally {
315
- if (isMountedRef.current) {
316
- if (append) {
317
- setIsFetchingNextPage(false);
318
- } else {
319
- setIsLoading(false);
320
- }
321
- }
921
+ if (isNextPage) setIsFetchingNextPage(false);
322
922
  }
323
923
  },
324
- [
325
- enabled,
326
- serializedParams,
327
- limit,
328
- onSuccess,
329
- onError,
330
- dataExtractor,
331
- totalExtractor
332
- ]
924
+ [cache, pageKey, pageQueryFn, staleTime, gcTime, onSuccess, onError]
333
925
  );
926
+ react.useEffect(() => {
927
+ if (!enabled) return;
928
+ void runFetchPage(initialPage, false);
929
+ setCurrentPage(initialPage);
930
+ }, [enabled, serializedParams, initialPage]);
931
+ const currentState = cache.getState(pageKey(currentPage));
932
+ const isStale = currentState?.isStale ?? false;
933
+ const hasFetchedData = currentState?.data !== void 0;
934
+ react.useEffect(() => {
935
+ if (!enabled) return;
936
+ if (isStale && hasFetchedData) void runFetchPage(currentPage, false);
937
+ }, [enabled, isStale, hasFetchedData, currentPage, runFetchPage]);
938
+ const currentResult = currentState?.data;
939
+ const usingPlaceholder = keepPreviousData && !currentResult && previousPageKeyRef.current !== null;
940
+ const effectiveResult = usingPlaceholder ? cache.getData(previousPageKeyRef.current) : currentResult;
941
+ const data = effectiveResult && effectiveResult.status ? dataExtractor(effectiveResult) : [];
942
+ const totalCount = effectiveResult && effectiveResult.status ? totalExtractor(effectiveResult) : null;
943
+ const total = totalCount;
944
+ const totalPages = totalCount !== null ? Math.ceil(totalCount / limit) : null;
334
945
  const hasNextPage = totalPages !== null && currentPage < totalPages;
335
946
  const hasPreviousPage = currentPage > 1;
947
+ const status = currentState?.status ?? "idle";
948
+ const isLoading = status === "loading" && !hasFetchedData && !usingPlaceholder;
336
949
  const fetchNextPage = react.useCallback(async () => {
337
950
  if (!hasNextPage) return;
338
- await fetchPage(currentPage + 1, true);
339
- }, [hasNextPage, currentPage, fetchPage]);
951
+ await runFetchPage(currentPage + 1, true);
952
+ }, [hasNextPage, currentPage, runFetchPage]);
340
953
  const fetchPreviousPage = react.useCallback(async () => {
341
954
  if (!hasPreviousPage) return;
342
- await fetchPage(currentPage - 1, false);
343
- }, [hasPreviousPage, currentPage, fetchPage]);
955
+ await runFetchPage(currentPage - 1, false);
956
+ }, [hasPreviousPage, currentPage, runFetchPage]);
957
+ const prefetchNextPage = react.useCallback(async () => {
958
+ if (!hasNextPage) return;
959
+ await cache.fetch(
960
+ pageKey(currentPage + 1),
961
+ pageQueryFn(currentPage + 1),
962
+ { staleTime, gcTime }
963
+ );
964
+ }, [hasNextPage, currentPage, cache, pageKey, pageQueryFn, staleTime, gcTime]);
344
965
  const refetch = react.useCallback(async () => {
345
- await fetchPage(currentPage, false);
346
- }, [currentPage, fetchPage]);
966
+ await cache.fetch(pageKey(currentPage), pageQueryFn(currentPage), {
967
+ staleTime: 0,
968
+ gcTime,
969
+ force: true
970
+ });
971
+ }, [cache, pageKey, pageQueryFn, currentPage, gcTime]);
347
972
  const reset = react.useCallback(() => {
348
- setData([]);
973
+ cache.remove((k) => {
974
+ if (k.length < keyPrefix.length) return false;
975
+ for (let i = 0; i < keyPrefix.length; i++) {
976
+ if (hashQueryKey([k[i]]) !== hashQueryKey([keyPrefix[i]])) return false;
977
+ }
978
+ return true;
979
+ });
980
+ previousPageKeyRef.current = null;
349
981
  setCurrentPage(initialPage);
350
- setTotalPages(null);
351
- setTotal(null);
352
- setError(null);
353
- void fetchPage(initialPage, false);
354
- }, [initialPage, fetchPage]);
355
- react.useEffect(() => {
356
- isMountedRef.current = true;
357
- if (enabled) {
358
- void fetchPage(initialPage, false);
359
- }
360
- return () => {
361
- isMountedRef.current = false;
362
- };
363
- }, [enabled, serializedParams, fetchPage, initialPage]);
982
+ void runFetchPage(initialPage, false);
983
+ }, [cache, keyPrefix, initialPage, runFetchPage]);
364
984
  return {
365
985
  data,
366
986
  currentPage,
@@ -368,17 +988,150 @@ function createUsePaginate(endpoint, fetchConfig, options) {
368
988
  total,
369
989
  hasNextPage,
370
990
  hasPreviousPage,
371
- isLoading,
991
+ isLoading: enabled ? isLoading : false,
372
992
  isFetchingNextPage,
993
+ isPlaceholderData: usingPlaceholder,
373
994
  error,
374
995
  fetchNextPage,
375
996
  fetchPreviousPage,
997
+ prefetchNextPage,
376
998
  refetch,
377
999
  reset
378
1000
  };
379
1001
  };
380
1002
  }
381
1003
 
1004
+ // src/query/persist.ts
1005
+ function persistQueryClient(options) {
1006
+ const {
1007
+ client,
1008
+ storage,
1009
+ storageKey = "react-api-client:cache",
1010
+ throttleMs = 1e3,
1011
+ allowList,
1012
+ maxAge,
1013
+ version
1014
+ } = options;
1015
+ let pendingTimer = null;
1016
+ let lastSerialized = null;
1017
+ let unsubscribed = false;
1018
+ const persist = async () => {
1019
+ const state = client.cache.dehydrate(allowList);
1020
+ if (state.queries.length === 0) return;
1021
+ const snap = { version, savedAt: Date.now(), state };
1022
+ const serialized = JSON.stringify(snap);
1023
+ if (serialized === lastSerialized) return;
1024
+ lastSerialized = serialized;
1025
+ await storage.setItem(storageKey, serialized);
1026
+ };
1027
+ const scheduleWrite = () => {
1028
+ if (unsubscribed) return;
1029
+ if (pendingTimer) return;
1030
+ pendingTimer = setTimeout(() => {
1031
+ pendingTimer = null;
1032
+ void persist();
1033
+ }, throttleMs);
1034
+ };
1035
+ const restore = async () => {
1036
+ const raw = await storage.getItem(storageKey);
1037
+ if (!raw) return;
1038
+ try {
1039
+ const snap = JSON.parse(raw);
1040
+ if (version !== void 0 && snap.version !== version) {
1041
+ await storage.removeItem(storageKey);
1042
+ return;
1043
+ }
1044
+ if (maxAge && Date.now() - snap.savedAt > maxAge) {
1045
+ await storage.removeItem(storageKey);
1046
+ return;
1047
+ }
1048
+ client.cache.hydrate(snap.state);
1049
+ } catch {
1050
+ await storage.removeItem(storageKey);
1051
+ }
1052
+ };
1053
+ const unsubscribeFromCache = client.cache.subscribeAll(scheduleWrite);
1054
+ return {
1055
+ restore,
1056
+ persist,
1057
+ unsubscribe: () => {
1058
+ unsubscribed = true;
1059
+ unsubscribeFromCache();
1060
+ if (pendingTimer) {
1061
+ clearTimeout(pendingTimer);
1062
+ pendingTimer = null;
1063
+ }
1064
+ }
1065
+ };
1066
+ }
1067
+
1068
+ // src/query/devtools.ts
1069
+ function inspectCache(cache) {
1070
+ const entries = cache._debugEntries();
1071
+ const snapshot = [];
1072
+ for (const [hash, entry] of entries) {
1073
+ snapshot.push({
1074
+ key: entry.key,
1075
+ hash,
1076
+ status: entry.state.status,
1077
+ isStale: entry.state.isStale,
1078
+ updatedAt: entry.state.updatedAt,
1079
+ hasData: entry.state.data !== void 0,
1080
+ subscribers: entry.subscribers.size,
1081
+ hasInflight: entry.inflight !== null,
1082
+ errorMessage: entry.state.error?.message ?? null
1083
+ });
1084
+ }
1085
+ return snapshot;
1086
+ }
1087
+ function invalidateAll(client) {
1088
+ client.invalidateQueries(() => true);
1089
+ }
1090
+ function summarizeCache(cache) {
1091
+ const byStatus = {
1092
+ idle: 0,
1093
+ loading: 0,
1094
+ success: 0,
1095
+ error: 0
1096
+ };
1097
+ let withSubscribers = 0;
1098
+ let inflight = 0;
1099
+ let stale = 0;
1100
+ const entries = cache._debugEntries();
1101
+ for (const entry of entries.values()) {
1102
+ byStatus[entry.state.status]++;
1103
+ if (entry.subscribers.size > 0) withSubscribers++;
1104
+ if (entry.inflight) inflight++;
1105
+ if (entry.state.isStale) stale++;
1106
+ }
1107
+ return { total: entries.size, withSubscribers, inflight, stale, byStatus };
1108
+ }
1109
+ var QueryClientContext = react.createContext(null);
1110
+ function ApiClientProvider({
1111
+ client,
1112
+ children
1113
+ }) {
1114
+ const value = react.useMemo(() => {
1115
+ const instance = client ?? new QueryClient();
1116
+ setQueryClient(instance);
1117
+ return instance;
1118
+ }, [client]);
1119
+ return react.createElement(
1120
+ QueryClientContext.Provider,
1121
+ { value },
1122
+ children
1123
+ );
1124
+ }
1125
+ function useQueryClient() {
1126
+ const ctx = react.useContext(QueryClientContext);
1127
+ if (!ctx) {
1128
+ throw new Error(
1129
+ "useQueryClient: \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D ApiClientProvider. \u041E\u0431\u0435\u0440\u043D\u0438 \u043A\u043E\u0440\u0435\u043D\u044C \u043F\u0440\u0438\u043B\u043E\u0436\u0435\u043D\u0438\u044F \u0432 <ApiClientProvider>."
1130
+ );
1131
+ }
1132
+ return ctx;
1133
+ }
1134
+
382
1135
  // src/index.ts
383
1136
  function apiClient(endpoint, fetchConfig = {}) {
384
1137
  const fetch = async (params) => {
@@ -408,11 +1161,28 @@ function apiPaginate(endpoint, fetchConfig = {}, options) {
408
1161
  }
409
1162
  var index_default = apiClient;
410
1163
 
1164
+ exports.ApiClientProvider = ApiClientProvider;
1165
+ exports.ApiError = ApiError;
1166
+ exports.QueryCache = QueryCache;
1167
+ exports.QueryClient = QueryClient;
411
1168
  exports.apiMutation = apiMutation;
412
1169
  exports.apiPaginate = apiPaginate;
1170
+ exports.businessErrorToApiError = businessErrorToApiError;
413
1171
  exports.configureApiClient = configureApiClient;
414
1172
  exports.default = index_default;
1173
+ exports.focusManager = focusManager;
415
1174
  exports.getConfig = getConfig;
1175
+ exports.getQueryClient = getQueryClient;
1176
+ exports.hashQueryKey = hashQueryKey;
1177
+ exports.inspectCache = inspectCache;
1178
+ exports.invalidateAll = invalidateAll;
416
1179
  exports.isConfigured = isConfigured;
1180
+ exports.matchQueryKey = matchQueryKey;
1181
+ exports.onlineManager = onlineManager;
1182
+ exports.persistQueryClient = persistQueryClient;
1183
+ exports.setQueryClient = setQueryClient;
1184
+ exports.summarizeCache = summarizeCache;
1185
+ exports.toApiError = toApiError;
1186
+ exports.useQueryClient = useQueryClient;
417
1187
  //# sourceMappingURL=index.js.map
418
1188
  //# sourceMappingURL=index.js.map