@bunbase-ae/react-sdk 1.0.1-next.2.7e6534b → 1.0.1-next.3.ee6c18d
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/package.json +1 -1
- package/src/cache.ts +54 -9
- package/src/useList.ts +4 -1
- package/src/useRecord.ts +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bunbase-ae/react-sdk",
|
|
3
|
-
"version": "1.0.1-next.
|
|
3
|
+
"version": "1.0.1-next.3.ee6c18d",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "React hooks for BunBase — caching, mutations, auth, realtime",
|
|
6
6
|
"homepage": "https://docs-bunbase.palmcode.ae/sdk/react",
|
package/src/cache.ts
CHANGED
|
@@ -25,6 +25,17 @@ export interface CacheEntry {
|
|
|
25
25
|
fetcher: (() => Promise<unknown>) | null;
|
|
26
26
|
staleTime: number;
|
|
27
27
|
retryableError: boolean;
|
|
28
|
+
/** Number of consecutive fetch failures — used to compute exponential backoff. */
|
|
29
|
+
consecutiveErrors: number;
|
|
30
|
+
/** Epoch ms before which fetch() is a no-op (exponential backoff window). */
|
|
31
|
+
backoffUntil: number;
|
|
32
|
+
/**
|
|
33
|
+
* True when this entry was explicitly invalidated (via `invalidate()`) and has
|
|
34
|
+
* not yet been re-fetched. Subscription callbacks use this flag to distinguish
|
|
35
|
+
* "app-triggered invalidation → fetch" from "settled fetch → re-render only",
|
|
36
|
+
* which prevents error/success notifications from spawning a new fetch cycle.
|
|
37
|
+
*/
|
|
38
|
+
invalidated: boolean;
|
|
28
39
|
}
|
|
29
40
|
|
|
30
41
|
// ─── Storage adapter ─────────────────────────────────────────────────────────
|
|
@@ -161,6 +172,9 @@ export class QueryCache {
|
|
|
161
172
|
fetcher: null,
|
|
162
173
|
staleTime: stored.staleTime,
|
|
163
174
|
retryableError: false,
|
|
175
|
+
consecutiveErrors: 0,
|
|
176
|
+
backoffUntil: 0,
|
|
177
|
+
invalidated: false,
|
|
164
178
|
});
|
|
165
179
|
} catch {
|
|
166
180
|
// Corrupt entry — skip silently.
|
|
@@ -215,11 +229,17 @@ export class QueryCache {
|
|
|
215
229
|
isStale(key: string): boolean {
|
|
216
230
|
const e = this.entries.get(key);
|
|
217
231
|
if (!e) return true;
|
|
218
|
-
|
|
232
|
+
// Errored entries have staleAt = backoffUntil; they are stale once the backoff expires.
|
|
233
|
+
if (e.status === "error") return Date.now() >= e.staleAt;
|
|
219
234
|
if (e.status !== "success") return true;
|
|
220
235
|
return Date.now() >= e.staleAt;
|
|
221
236
|
}
|
|
222
237
|
|
|
238
|
+
/** True only when this entry was explicitly invalidated and hasn't been re-fetched yet. */
|
|
239
|
+
isInvalidated(key: string): boolean {
|
|
240
|
+
return this.entries.get(key)?.invalidated === true;
|
|
241
|
+
}
|
|
242
|
+
|
|
223
243
|
// ─── Fetch ──────────────────────────────────────────────────────────────────
|
|
224
244
|
|
|
225
245
|
/**
|
|
@@ -239,6 +259,9 @@ export class QueryCache {
|
|
|
239
259
|
// Data is fresh — nothing to do.
|
|
240
260
|
if (existing?.status === "success" && Date.now() < existing.staleAt) return;
|
|
241
261
|
|
|
262
|
+
// Respect exponential backoff after consecutive errors.
|
|
263
|
+
if (existing?.backoffUntil && Date.now() < existing.backoffUntil) return;
|
|
264
|
+
|
|
242
265
|
// Keep existing data visible while refetching (SWR).
|
|
243
266
|
const entry: CacheEntry = {
|
|
244
267
|
data: existing?.data,
|
|
@@ -249,6 +272,9 @@ export class QueryCache {
|
|
|
249
272
|
fetcher,
|
|
250
273
|
staleTime,
|
|
251
274
|
retryableError: false,
|
|
275
|
+
consecutiveErrors: existing?.consecutiveErrors ?? 0,
|
|
276
|
+
backoffUntil: existing?.backoffUntil ?? 0,
|
|
277
|
+
invalidated: false,
|
|
252
278
|
};
|
|
253
279
|
|
|
254
280
|
const promise: Promise<void> = fetcher().then(
|
|
@@ -265,6 +291,9 @@ export class QueryCache {
|
|
|
265
291
|
fetcher,
|
|
266
292
|
staleTime,
|
|
267
293
|
retryableError: false,
|
|
294
|
+
consecutiveErrors: 0,
|
|
295
|
+
backoffUntil: 0,
|
|
296
|
+
invalidated: false,
|
|
268
297
|
};
|
|
269
298
|
this.entries.set(key, next);
|
|
270
299
|
this.persist(key, next);
|
|
@@ -274,16 +303,22 @@ export class QueryCache {
|
|
|
274
303
|
(err: unknown) => {
|
|
275
304
|
const current = this.entries.get(key);
|
|
276
305
|
if (current?.promise !== promise) return;
|
|
277
|
-
const
|
|
306
|
+
const consecutiveErrors = (current.consecutiveErrors ?? 0) + 1;
|
|
307
|
+
// Exponential backoff: 1s → 2s → 4s → 8s → 16s → 30s cap.
|
|
308
|
+
const backoffMs = Math.min(1_000 * 2 ** (consecutiveErrors - 1), 30_000);
|
|
309
|
+
const backoffUntil = Date.now() + backoffMs;
|
|
278
310
|
this.entries.set(key, {
|
|
279
311
|
data: current?.data,
|
|
280
312
|
error: err instanceof Error ? err : new Error(String(err)),
|
|
281
313
|
status: "error",
|
|
282
|
-
staleAt:
|
|
314
|
+
staleAt: backoffUntil,
|
|
283
315
|
promise: null,
|
|
284
316
|
fetcher,
|
|
285
317
|
staleTime,
|
|
286
|
-
retryableError,
|
|
318
|
+
retryableError: isRetryableError(err),
|
|
319
|
+
consecutiveErrors,
|
|
320
|
+
backoffUntil,
|
|
321
|
+
invalidated: false,
|
|
287
322
|
});
|
|
288
323
|
this.notify(key);
|
|
289
324
|
},
|
|
@@ -300,6 +335,7 @@ export class QueryCache {
|
|
|
300
335
|
refetch(key: string): void {
|
|
301
336
|
const entry = this.entries.get(key);
|
|
302
337
|
if (!entry?.fetcher) return;
|
|
338
|
+
// Clear backoff and reset error state so the forced refetch always proceeds.
|
|
303
339
|
this.entries.set(key, {
|
|
304
340
|
...entry,
|
|
305
341
|
error: null,
|
|
@@ -307,6 +343,8 @@ export class QueryCache {
|
|
|
307
343
|
staleAt: 0,
|
|
308
344
|
promise: null,
|
|
309
345
|
retryableError: false,
|
|
346
|
+
backoffUntil: 0,
|
|
347
|
+
invalidated: true,
|
|
310
348
|
});
|
|
311
349
|
this.fetch(key, entry.fetcher, entry.staleTime);
|
|
312
350
|
}
|
|
@@ -318,7 +356,7 @@ export class QueryCache {
|
|
|
318
356
|
*
|
|
319
357
|
* Data is KEPT so components remain in a loaded state (not loading) while the
|
|
320
358
|
* background re-fetch runs. Subscribers should call `cache.fetch()` when they
|
|
321
|
-
* receive a notification and find the entry is
|
|
359
|
+
* receive a notification and find the entry is invalidated.
|
|
322
360
|
*/
|
|
323
361
|
invalidate(prefix: string): void {
|
|
324
362
|
const toNotify: string[] = [];
|
|
@@ -336,6 +374,8 @@ export class QueryCache {
|
|
|
336
374
|
staleAt: 0,
|
|
337
375
|
promise: null,
|
|
338
376
|
retryableError: false,
|
|
377
|
+
backoffUntil: 0,
|
|
378
|
+
invalidated: true,
|
|
339
379
|
});
|
|
340
380
|
toNotify.push(key);
|
|
341
381
|
}
|
|
@@ -359,6 +399,9 @@ export class QueryCache {
|
|
|
359
399
|
fetcher: null,
|
|
360
400
|
staleTime,
|
|
361
401
|
retryableError: false,
|
|
402
|
+
consecutiveErrors: 0,
|
|
403
|
+
backoffUntil: 0,
|
|
404
|
+
invalidated: false,
|
|
362
405
|
};
|
|
363
406
|
this.entries.set(key, entry);
|
|
364
407
|
this.persist(key, entry);
|
|
@@ -383,6 +426,9 @@ export class QueryCache {
|
|
|
383
426
|
fetcher: existing?.fetcher ?? null,
|
|
384
427
|
staleTime,
|
|
385
428
|
retryableError: false,
|
|
429
|
+
consecutiveErrors: 0,
|
|
430
|
+
backoffUntil: 0,
|
|
431
|
+
invalidated: false,
|
|
386
432
|
};
|
|
387
433
|
this.entries.set(key, entry);
|
|
388
434
|
this.persist(key, entry);
|
|
@@ -419,13 +465,12 @@ export class QueryCache {
|
|
|
419
465
|
}
|
|
420
466
|
}
|
|
421
467
|
|
|
422
|
-
/** Re-fetch all stale
|
|
468
|
+
/** Re-fetch all stale entries. Called on window focus. */
|
|
423
469
|
refetchStale(): void {
|
|
424
470
|
for (const [key, entry] of this.entries) {
|
|
425
471
|
if (!entry.fetcher) continue;
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
if ((shouldRetryError || isStale) && !entry.promise) {
|
|
472
|
+
// Errored entries have staleAt = backoffUntil, so the same check handles them.
|
|
473
|
+
if (!entry.promise && Date.now() >= entry.staleAt) {
|
|
429
474
|
this.fetch(key, entry.fetcher, entry.staleTime);
|
|
430
475
|
}
|
|
431
476
|
}
|
package/src/useList.ts
CHANGED
|
@@ -74,7 +74,10 @@ export function useList<
|
|
|
74
74
|
useEffect(
|
|
75
75
|
() =>
|
|
76
76
|
cache.subscribe(cacheKey, () => {
|
|
77
|
-
|
|
77
|
+
// Only re-fetch when the entry was explicitly invalidated (invalidate() /
|
|
78
|
+
// refetch() / interval). Do NOT re-fetch on error or success notifications —
|
|
79
|
+
// that would create a tight retry loop bypassing refetchInterval and backoff.
|
|
80
|
+
if (enabledRef.current && cache.isInvalidated(cacheKey)) {
|
|
78
81
|
cache.fetch(cacheKey, () => fetcherRef.current(), staleTimeRef.current);
|
|
79
82
|
}
|
|
80
83
|
rerender();
|
package/src/useRecord.ts
CHANGED
|
@@ -47,7 +47,8 @@ export function useRecord<
|
|
|
47
47
|
useEffect(() => {
|
|
48
48
|
if (!cacheKey) return;
|
|
49
49
|
return cache.subscribe(cacheKey, () => {
|
|
50
|
-
|
|
50
|
+
// Only re-fetch on explicit invalidation — not on error/success notifications.
|
|
51
|
+
if (cacheKey && cache.isInvalidated(cacheKey)) {
|
|
51
52
|
cache.fetch(cacheKey, () => fetcherRef.current(), staleTimeRef.current);
|
|
52
53
|
}
|
|
53
54
|
rerender();
|