@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bunbase-ae/react-sdk",
3
- "version": "1.0.1-next.2.7e6534b",
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
- if (e.status === "error") return false;
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 retryableError = isRetryableError(err);
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: 0,
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 stale.
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 or errored entries. Called on window focus. */
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
- const shouldRetryError = entry.status === "error" && entry.retryableError;
427
- const isStale = entry.status === "success" && Date.now() >= entry.staleAt;
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
- if (enabledRef.current && cache.isStale(cacheKey)) {
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
- if (cacheKey && cache.isStale(cacheKey)) {
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();