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