@krymskyimaksym/react-api-client 1.0.0 → 2.0.0-beta.0

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