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