@kontsedal/olas-core 0.0.6 → 0.1.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.
Files changed (61) hide show
  1. package/dist/index.cjs +0 -0
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +109 -27
  4. package/dist/index.d.cts.map +1 -1
  5. package/dist/index.d.mts +109 -27
  6. package/dist/index.d.mts.map +1 -1
  7. package/dist/index.mjs +0 -0
  8. package/dist/index.mjs.map +1 -1
  9. package/dist/{root-B6pNq75w.mjs → root-Byq-QYTp.mjs} +1465 -347
  10. package/dist/root-Byq-QYTp.mjs.map +1 -0
  11. package/dist/{root-CFgYhBd-.cjs → root-CPSL5kpR.cjs} +1470 -346
  12. package/dist/root-CPSL5kpR.cjs.map +1 -0
  13. package/dist/testing.cjs +5 -1
  14. package/dist/testing.cjs.map +1 -1
  15. package/dist/testing.d.cts +3 -2
  16. package/dist/testing.d.cts.map +1 -1
  17. package/dist/testing.d.mts +3 -2
  18. package/dist/testing.d.mts.map +1 -1
  19. package/dist/testing.mjs +5 -2
  20. package/dist/testing.mjs.map +1 -1
  21. package/dist/{types-Bmi04hDI.d.mts → types-Brp-4WaZ.d.mts} +233 -23
  22. package/dist/types-Brp-4WaZ.d.mts.map +1 -0
  23. package/dist/{types-BsZMPPOE.d.cts → types-DzDIypPo.d.cts} +233 -23
  24. package/dist/types-DzDIypPo.d.cts.map +1 -0
  25. package/package.json +4 -1
  26. package/src/controller/instance.ts +345 -94
  27. package/src/controller/root.ts +52 -30
  28. package/src/controller/types.ts +55 -4
  29. package/src/emitter.ts +8 -2
  30. package/src/errors.ts +39 -3
  31. package/src/forms/field.ts +219 -25
  32. package/src/forms/form-types.ts +16 -3
  33. package/src/forms/form.ts +360 -38
  34. package/src/forms/index.ts +3 -1
  35. package/src/forms/types.ts +27 -1
  36. package/src/forms/validators.ts +45 -11
  37. package/src/index.ts +13 -7
  38. package/src/query/client.ts +237 -58
  39. package/src/query/define.ts +0 -0
  40. package/src/query/entry.ts +259 -15
  41. package/src/query/focus-online.ts +53 -11
  42. package/src/query/infinite.ts +351 -47
  43. package/src/query/keys.ts +33 -2
  44. package/src/query/local.ts +3 -0
  45. package/src/query/mutation.ts +27 -6
  46. package/src/query/plugin.ts +43 -4
  47. package/src/query/types.ts +76 -6
  48. package/src/query/use.ts +53 -10
  49. package/src/selection.ts +49 -12
  50. package/src/signals/readonly.ts +3 -0
  51. package/src/signals/runtime.ts +27 -0
  52. package/src/signals/types.ts +10 -0
  53. package/src/testing.ts +9 -0
  54. package/src/timing/debounced.ts +106 -23
  55. package/src/timing/index.ts +1 -1
  56. package/src/timing/throttled.ts +85 -28
  57. package/src/utils.ts +15 -2
  58. package/dist/root-B6pNq75w.mjs.map +0 -1
  59. package/dist/root-CFgYhBd-.cjs.map +0 -1
  60. package/dist/types-Bmi04hDI.d.mts.map +0 -1
  61. package/dist/types-BsZMPPOE.d.cts.map +0 -1
@@ -1,7 +1,8 @@
1
1
  import { batch, type Signal, signal } from '../signals'
2
2
  import { abortableSleep, isAbortError } from '../utils'
3
+ import { subscribeReconnect } from './focus-online'
3
4
  import { structuralShare } from './structural-share'
4
- import type { AsyncStatus, RetryDelay, RetryPolicy, Snapshot } from './types'
5
+ import type { AsyncStatus, NetworkMode, RetryDelay, RetryPolicy, Snapshot } from './types'
5
6
 
6
7
  export type EntryEvents = {
7
8
  onFetchStart?: () => void
@@ -16,6 +17,8 @@ export type EntryOptions<T> = {
16
17
  initialUpdatedAt?: number | undefined
17
18
  retry?: RetryPolicy
18
19
  retryDelay?: RetryDelay
20
+ networkMode?: NetworkMode
21
+ structuralShare?: boolean
19
22
  events?: EntryEvents
20
23
  /**
21
24
  * Fired after a successful fetch result is written to `data`. Used by the
@@ -55,17 +58,37 @@ export class Entry<T> {
55
58
  readonly lastUpdatedAt: Signal<number | undefined>
56
59
  readonly hasPendingMutations: Signal<boolean> = signal(false)
57
60
  readonly isStale: Signal<boolean> = signal(true)
61
+ /** True while a fetch is parked waiting for reconnect (online-mode defer or
62
+ * offlineFirst network-error park). See spec §5.5, T3.5. */
63
+ readonly isPaused: Signal<boolean> = signal(false)
58
64
 
59
65
  fetcherProvider: () => (signal: AbortSignal) => Promise<T>
60
66
  private staleTime: number
61
67
  private retry: RetryPolicy
62
- private retryDelay: RetryDelay
68
+ private retryDelay: RetryDelay | undefined
69
+ private networkMode: NetworkMode
70
+ private structuralShareEnabled: boolean
63
71
  private currentFetchId = 0
64
72
  private currentAbort: AbortController | null = null
65
73
  private staleTimer: ReturnType<typeof setTimeout> | null = null
74
+ /** Set by `markStale()` (invalidate without fetch); forces `isStaleNow()`
75
+ * true until the next successful fetch clears it. Spec §5.7, T3.9. */
76
+ private forcedStale = false
66
77
  private snapshots: Array<SnapshotRecord<T>> = []
67
78
  private nextSnapshotId = 0
68
79
  private disposed = false
80
+ /** Subscribers to reconnect — installed lazily when a deferred fetch lands. */
81
+ private reconnectUnsub: (() => void) | null = null
82
+ /**
83
+ * Set of deferred-fetch resolvers waiting for reconnect (online mode).
84
+ * Stored at `unknown` to keep `Entry<T>` covariant in `T` — same trick as
85
+ * `onSuccessData`. Each resolver is fed the same value from
86
+ * `applySuccess`, which is `T`, then cast at the fan-out call site.
87
+ */
88
+ private deferredResolvers: Array<{
89
+ resolve: (value: unknown) => void
90
+ reject: (reason?: unknown) => void
91
+ }> = []
69
92
  private readonly events: EntryEvents
70
93
  // Stored at `unknown` (not `T`) to keep `Entry<T>` covariant in `T`. The
71
94
  // callback only forwards the value through; Entry never inspects it.
@@ -82,7 +105,11 @@ export class Entry<T> {
82
105
  this.fetcherProvider = options.fetcher
83
106
  this.staleTime = options.staleTime ?? 0
84
107
  this.retry = options.retry ?? 0
85
- this.retryDelay = options.retryDelay ?? 1000
108
+ // Left undefined when not given so `computeDelay` can pick the exponential
109
+ // default only when retries are actually enabled (T3.9).
110
+ this.retryDelay = options.retryDelay
111
+ this.networkMode = options.networkMode ?? 'online'
112
+ this.structuralShareEnabled = options.structuralShare ?? true
86
113
  this.events = options.events ?? {}
87
114
  this.onSuccessData = options.onSuccessData as ((data: unknown) => void) | undefined
88
115
  this.data = signal<T | undefined>(options.initialData)
@@ -119,6 +146,13 @@ export class Entry<T> {
119
146
  if (this.disposed) {
120
147
  return Promise.reject(new Error('Entry disposed'))
121
148
  }
149
+ // `online` mode: defer until reconnect when the browser thinks we're
150
+ // offline. Don't touch status — the UI keeps showing last-known data.
151
+ // `always` / `offlineFirst` proceed to the fetcher; `offlineFirst` will
152
+ // re-handle a network rejection inside the catch path.
153
+ if (this.networkMode === 'online' && this.isOffline()) {
154
+ return this.scheduleDeferredFetch()
155
+ }
122
156
  const myId = ++this.currentFetchId
123
157
  this.currentAbort?.abort()
124
158
  const abort = new AbortController()
@@ -129,6 +163,7 @@ export class Entry<T> {
129
163
  this.status.set('pending')
130
164
  this.isFetching.set(true)
131
165
  this.isLoading.set(!previouslyHadData)
166
+ this.isPaused.set(false)
132
167
  })
133
168
 
134
169
  this.fetchStartTime = Date.now()
@@ -141,6 +176,49 @@ export class Entry<T> {
141
176
  return this.runWithRetry(myId, abort)
142
177
  }
143
178
 
179
+ private isOffline(): boolean {
180
+ return typeof navigator !== 'undefined' && navigator.onLine === false
181
+ }
182
+
183
+ private scheduleDeferredFetch(): Promise<T> {
184
+ // Parked waiting for reconnect — surface it so the UI can show "waiting
185
+ // for network" instead of a silent `idle` (T3.5). Cleared when the fetch
186
+ // actually starts (`startFetch` batch) or settles.
187
+ this.isPaused.set(true)
188
+ // Lazy-install one reconnect listener for the entry. Cleared on dispose
189
+ // and on the first successful drain. Each call appends a fresh resolver.
190
+ if (this.reconnectUnsub === null) {
191
+ this.reconnectUnsub = subscribeReconnect(() => this.drainDeferred())
192
+ }
193
+ return new Promise<T>((resolve, reject) => {
194
+ this.deferredResolvers.push({
195
+ resolve: resolve as (value: unknown) => void,
196
+ reject,
197
+ })
198
+ })
199
+ }
200
+
201
+ private drainDeferred(): void {
202
+ if (this.deferredResolvers.length === 0) return
203
+ if (this.disposed) return
204
+ const pending = this.deferredResolvers
205
+ this.deferredResolvers = []
206
+ // One real fetch fans out to every pending resolver. Tearing down the
207
+ // reconnect listener avoids accumulating listeners across many deferrals.
208
+ if (this.reconnectUnsub !== null) {
209
+ this.reconnectUnsub()
210
+ this.reconnectUnsub = null
211
+ }
212
+ this.startFetch().then(
213
+ (value) => {
214
+ for (const p of pending) p.resolve(value)
215
+ },
216
+ (err) => {
217
+ for (const p of pending) p.reject(err)
218
+ },
219
+ )
220
+ }
221
+
144
222
  private async runWithRetry(myId: number, abort: AbortController): Promise<T> {
145
223
  let attempt = 0
146
224
  while (true) {
@@ -158,6 +236,18 @@ export class Entry<T> {
158
236
  if (myId !== this.currentFetchId || this.disposed || isAbortError(err)) {
159
237
  throw err
160
238
  }
239
+ // offlineFirst: a network-shaped failure while offline parks the entry
240
+ // (wait for reconnect, then retry) instead of surfacing the error. A
241
+ // `fetch()` network failure surfaces as a `TypeError`; `AbortError` is
242
+ // already handled above. Spec §5.5, T3.5.
243
+ if (this.networkMode === 'offlineFirst' && this.isOffline() && err instanceof TypeError) {
244
+ batch(() => {
245
+ this.isFetching.set(false)
246
+ this.isLoading.set(false)
247
+ this.status.set(this.data.peek() !== undefined ? 'success' : 'idle')
248
+ })
249
+ return this.scheduleDeferredFetch()
250
+ }
161
251
  if (!this.shouldRetry(attempt, err)) {
162
252
  return this.applyFailure(err)
163
253
  }
@@ -177,6 +267,10 @@ export class Entry<T> {
177
267
 
178
268
  private computeDelay(attempt: number): number {
179
269
  const d = this.retryDelay
270
+ // Default to exponential backoff (capped at 30s) when `retry > 0` but no
271
+ // `retryDelay` was given — a constant 1s default hammered a flapping
272
+ // backend on every attempt (T3.9). Explicit number/function still wins.
273
+ if (d === undefined) return Math.min(1000 * 2 ** attempt, 30_000)
180
274
  return typeof d === 'function' ? d(attempt) : d
181
275
  }
182
276
 
@@ -184,9 +278,20 @@ export class Entry<T> {
184
278
  // Structurally share with the previous value so unchanged sub-trees
185
279
  // keep their `===` identity. Downstream `computed`s and React snapshots
186
280
  // stop thrashing on no-op refetches. Bails on Maps/Sets/class instances
187
- // — see `structural-share.ts`.
281
+ // — see `structural-share.ts`. Disabled per-query via `structuralShare:
282
+ // false` for large payloads where the O(payload) walk costs more than
283
+ // the re-render savings.
188
284
  const prev = this.data.peek() as T | undefined
189
- const shared = prev === undefined ? result : structuralShare(prev, result)
285
+ const shared =
286
+ prev === undefined || !this.structuralShareEnabled ? result : structuralShare(prev, result)
287
+ // Rebase live optimistic snapshots onto the fresh server truth: a
288
+ // subsequent rollback should restore to THIS value, not the pre-fetch
289
+ // baseline the snapshot captured before the fetch resolved. Without this,
290
+ // a fetch landing during an optimistic mutation, then that mutation
291
+ // failing, resurrects pre-fetch data (spec §6.4, T3.4).
292
+ if (this.snapshots.length > 0) {
293
+ for (const s of this.snapshots) s.prev = shared
294
+ }
190
295
  batch(() => {
191
296
  this.data.set(shared)
192
297
  this.error.set(undefined)
@@ -196,6 +301,7 @@ export class Entry<T> {
196
301
  this.lastUpdatedAt.set(Date.now())
197
302
  this.isStale.set(this.staleTime === 0)
198
303
  })
304
+ this.forcedStale = false // fresh data clears a prior markStale() (T3.9)
199
305
  if (this.staleTime > 0) this.scheduleStaleness()
200
306
  try {
201
307
  this.events.onFetchSuccess?.(Date.now() - this.fetchStartTime)
@@ -235,12 +341,75 @@ export class Entry<T> {
235
341
  return this.startFetch()
236
342
  }
237
343
 
238
- invalidate(): Promise<T> {
344
+ /**
345
+ * Apply a server-supplied data + timestamp without going through the
346
+ * fetcher path. Used by streaming SSR hydration: each `<Suspense>` boundary
347
+ * that resolves on the server pushes its entry's data to the client, and
348
+ * the client routes it through here so the entry transitions to `success`
349
+ * without burning the user's fetcher.
350
+ *
351
+ * Distinct from `setData`: no Snapshot returned (no rollback semantics —
352
+ * this is canonical data, not an optimistic patch), no
353
+ * `hasPendingMutations` flip, and `lastUpdatedAt` honors the supplied
354
+ * server timestamp instead of `Date.now()`. Also bumps `currentFetchId`
355
+ * so any in-flight fetch supersedes itself rather than overwriting the
356
+ * fresher hydrated value.
357
+ */
358
+ applyHydration(data: T, lastUpdatedAt: number): void {
359
+ if (this.disposed) return
360
+ // Bump fetch id: an inflight fetcher will now lose the supersede check
361
+ // in `runWithRetry` and won't write its (likely-stale) result.
362
+ this.currentFetchId += 1
363
+ this.currentAbort?.abort()
364
+ this.currentAbort = null
365
+ if (this.staleTimer !== null) {
366
+ clearTimeout(this.staleTimer)
367
+ this.staleTimer = null
368
+ }
369
+ const alreadyStale = this.staleTime === 0 || Date.now() - lastUpdatedAt >= this.staleTime
370
+ batch(() => {
371
+ this.data.set(data)
372
+ this.error.set(undefined)
373
+ this.status.set('success')
374
+ this.isLoading.set(false)
375
+ this.isFetching.set(false)
376
+ this.lastUpdatedAt.set(lastUpdatedAt)
377
+ this.isStale.set(alreadyStale)
378
+ })
379
+ if (!alreadyStale && this.staleTime > 0) {
380
+ const remaining = this.staleTime - (Date.now() - lastUpdatedAt)
381
+ this.staleTimer = setTimeout(() => {
382
+ this.staleTimer = null
383
+ if (!this.disposed) this.isStale.set(true)
384
+ }, remaining)
385
+ }
386
+ this.onSuccessData?.(data)
387
+ // Resolve any awaiters parked in firstValue / promise.
388
+ if (this.pendingFirstValueRejects.length > 0) {
389
+ // First-value awaiters are subscribed to `this.status`; the batched
390
+ // `status.set('success')` above wakes them through the normal
391
+ // subscribe path. We don't need to do anything else here.
392
+ }
393
+ }
394
+
395
+ /**
396
+ * Force this entry stale WITHOUT fetching. `isStaleNow()` returns true until
397
+ * the next successful fetch clears the flag, so the next subscriber refetches.
398
+ * Used by `client.invalidate` for subscriber-less entries — spec §5.7 says
399
+ * invalidate refetches only IF subscribed (T3.9).
400
+ */
401
+ markStale(): void {
402
+ if (this.disposed) return
239
403
  if (this.staleTimer != null) {
240
404
  clearTimeout(this.staleTimer)
241
405
  this.staleTimer = null
242
406
  }
407
+ this.forcedStale = true
243
408
  this.isStale.set(true)
409
+ }
410
+
411
+ invalidate(): Promise<T> {
412
+ this.markStale()
244
413
  return this.startFetch()
245
414
  }
246
415
 
@@ -252,15 +421,53 @@ export class Entry<T> {
252
421
  })
253
422
  }
254
423
 
255
- setData(updater: (prev: T | undefined) => T): Snapshot {
424
+ /**
425
+ * Cancel an in-flight fetch without touching `data`. Aborts the current
426
+ * request and supersedes it (bumps `currentFetchId` so its result can never
427
+ * land), then restores a settled status: `'success'` if data exists, else
428
+ * `'idle'`. No-op when nothing is fetching. This is the primitive behind
429
+ * `query.cancel(...)` / `subscription.cancel()`: the canonical optimistic
430
+ * recipe cancels outgoing refetches before an optimistic `setData` so a
431
+ * stale response can't clobber the optimistic value (spec §5, §6.4). T3.4.
432
+ */
433
+ cancel(): void {
434
+ if (this.disposed || !this.isFetching.peek()) return
435
+ this.currentFetchId += 1
436
+ this.currentAbort?.abort()
437
+ this.currentAbort = null
438
+ batch(() => {
439
+ this.isFetching.set(false)
440
+ this.isLoading.set(false)
441
+ this.status.set(this.data.peek() !== undefined ? 'success' : 'idle')
442
+ })
443
+ }
444
+
445
+ /**
446
+ * Write data into the entry.
447
+ *
448
+ * `track` (default `true`) is the optimistic-update path: it pushes a
449
+ * snapshot record and flips `hasPendingMutations`, so the returned
450
+ * `Snapshot.rollback()` can restore the pre-write baseline and
451
+ * `finalize()` commits it. This is what `query.setData(...)` inside a
452
+ * mutation's `onMutate` uses (spec §6.4).
453
+ *
454
+ * `track: false` is a canonical cache write — cross-tab receive, entities
455
+ * backprop, realtime patches (spec §13.2). It updates the data signal but
456
+ * pushes NO snapshot and does NOT flip `hasPendingMutations`, so a
457
+ * fire-and-forget plugin write can't wedge the pending flag at `true`
458
+ * forever. Returns a no-op `Snapshot`.
459
+ */
460
+ setData(updater: (prev: T | undefined) => T, opts?: { track?: boolean }): Snapshot {
256
461
  if (this.disposed) {
257
462
  return { rollback: () => {}, finalize: () => {} }
258
463
  }
259
464
  const prev = this.data.peek()
260
465
  const next = updater(prev)
261
- const id = this.nextSnapshotId++
262
- const record: SnapshotRecord<T> = { id, prev, live: true }
263
- this.snapshots.push(record)
466
+ const track = opts?.track ?? true
467
+ const record: SnapshotRecord<T> | null = track
468
+ ? { id: this.nextSnapshotId++, prev, live: true }
469
+ : null
470
+ if (record) this.snapshots.push(record)
264
471
 
265
472
  batch(() => {
266
473
  this.data.set(next)
@@ -268,18 +475,37 @@ export class Entry<T> {
268
475
  this.status.set('success')
269
476
  }
270
477
  this.lastUpdatedAt.set(Date.now())
271
- this.hasPendingMutations.set(true)
478
+ if (record) this.hasPendingMutations.set(true)
272
479
  })
273
480
 
481
+ if (!record) {
482
+ return { rollback: () => {}, finalize: () => {} }
483
+ }
484
+ const id = record.id
274
485
  return {
275
486
  rollback: () => {
276
487
  if (!record.live || this.disposed) return
277
488
  record.live = false
278
489
  batch(() => {
279
- this.data.set(record.prev as T)
280
- this.snapshots = this.snapshots.filter((s) => s.id !== id)
281
- const anyLive = this.snapshots.some((s) => s.live)
282
- this.hasPendingMutations.set(anyLive)
490
+ const i = this.snapshots.indexOf(record)
491
+ if (i !== -1) {
492
+ if (i === this.snapshots.length - 1) {
493
+ // Top of the stack: restore this layer's captured baseline as
494
+ // the current data.
495
+ this.data.set(record.prev as T)
496
+ } else {
497
+ // Not the top: leave the currently-displayed value alone and
498
+ // thread this layer's baseline down onto the next layer, so a
499
+ // later top-rollback lands on the correct pre-everything value
500
+ // instead of resurrecting this layer's delta (chain-splice —
501
+ // T3.1, spec §6.4). Out-of-order rollback of all layers now
502
+ // returns to the original pre-mutation value.
503
+ const below = this.snapshots[i + 1] as SnapshotRecord<T>
504
+ below.prev = record.prev
505
+ }
506
+ this.snapshots.splice(i, 1)
507
+ }
508
+ this.hasPendingMutations.set(this.snapshots.some((s) => s.live))
283
509
  })
284
510
  },
285
511
  finalize: () => {
@@ -327,6 +553,7 @@ export class Entry<T> {
327
553
  * Used by the query client to decide whether to refetch on subscribe.
328
554
  */
329
555
  isStaleNow(): boolean {
556
+ if (this.forcedStale) return true
330
557
  const last = this.lastUpdatedAt.peek()
331
558
  if (last === undefined) return true
332
559
  return Date.now() - last >= this.staleTime
@@ -341,6 +568,23 @@ export class Entry<T> {
341
568
  }
342
569
  this.currentAbort?.abort()
343
570
  this.currentAbort = null
571
+ // A disposed entry is idle. Reset the in-flight flags so `waitForIdle`
572
+ // (which waits on `isFetching`) can't hang when a fetch races dispose — the
573
+ // aborted fetcher's applySuccess/applyFailure never runs to clear them (T3.9).
574
+ batch(() => {
575
+ this.isFetching.set(false)
576
+ this.isLoading.set(false)
577
+ })
578
+ if (this.reconnectUnsub !== null) {
579
+ this.reconnectUnsub()
580
+ this.reconnectUnsub = null
581
+ }
582
+ if (this.deferredResolvers.length > 0) {
583
+ const disposed = new DOMException('Entry disposed', 'AbortError')
584
+ const pending = this.deferredResolvers
585
+ this.deferredResolvers = []
586
+ for (const p of pending) p.reject(disposed)
587
+ }
344
588
  if (this.pendingFirstValueRejects.length > 0) {
345
589
  const disposed = new DOMException('Entry disposed', 'AbortError')
346
590
  const rejects = this.pendingFirstValueRejects
@@ -14,9 +14,6 @@ type Sub = () => void
14
14
  const focusSubs = new Set<Sub>()
15
15
  const onlineSubs = new Set<Sub>()
16
16
 
17
- let focusInstalled = false
18
- let onlineInstalled = false
19
-
20
17
  function fireFocus(): void {
21
18
  for (const fn of focusSubs) {
22
19
  try {
@@ -37,37 +34,82 @@ function fireOnline(): void {
37
34
  }
38
35
  }
39
36
 
40
- function ensureFocusInstalled(): void {
37
+ // A tab-return commonly fires BOTH `focus` and `visibilitychange` in the same
38
+ // tick. Coalesce them into a single `fireFocus` via a microtask flag so
39
+ // subscribers refetch once, not twice (T3.9).
40
+ let focusScheduled = false
41
+ function scheduleFocus(): void {
42
+ if (focusScheduled) return
43
+ focusScheduled = true
44
+ queueMicrotask(() => {
45
+ focusScheduled = false
46
+ fireFocus()
47
+ })
48
+ }
49
+
50
+ function onVisibilityChange(): void {
51
+ if (document.visibilityState === 'visible') scheduleFocus()
52
+ }
53
+
54
+ let focusInstalled = false
55
+ let onlineInstalled = false
56
+
57
+ function installFocus(): void {
41
58
  if (focusInstalled) return
42
59
  if (typeof window === 'undefined') return
43
- window.addEventListener('focus', fireFocus)
60
+ window.addEventListener('focus', scheduleFocus)
44
61
  if (typeof document !== 'undefined') {
45
- document.addEventListener('visibilitychange', () => {
46
- if (document.visibilityState === 'visible') fireFocus()
47
- })
62
+ document.addEventListener('visibilitychange', onVisibilityChange)
48
63
  }
49
64
  focusInstalled = true
50
65
  }
51
66
 
52
- function ensureOnlineInstalled(): void {
67
+ function uninstallFocus(): void {
68
+ if (!focusInstalled) return
69
+ if (typeof window === 'undefined') return
70
+ window.removeEventListener('focus', scheduleFocus)
71
+ if (typeof document !== 'undefined') {
72
+ document.removeEventListener('visibilitychange', onVisibilityChange)
73
+ }
74
+ focusInstalled = false
75
+ }
76
+
77
+ function installOnline(): void {
53
78
  if (onlineInstalled) return
54
79
  if (typeof window === 'undefined') return
55
80
  window.addEventListener('online', fireOnline)
56
81
  onlineInstalled = true
57
82
  }
58
83
 
84
+ function uninstallOnline(): void {
85
+ if (!onlineInstalled) return
86
+ if (typeof window === 'undefined') return
87
+ window.removeEventListener('online', fireOnline)
88
+ onlineInstalled = false
89
+ }
90
+
59
91
  export function subscribeWindowFocus(fn: Sub): () => void {
60
- ensureFocusInstalled()
92
+ installFocus()
61
93
  focusSubs.add(fn)
62
94
  return () => {
63
95
  focusSubs.delete(fn)
96
+ if (focusSubs.size === 0) uninstallFocus()
64
97
  }
65
98
  }
66
99
 
67
100
  export function subscribeReconnect(fn: Sub): () => void {
68
- ensureOnlineInstalled()
101
+ installOnline()
69
102
  onlineSubs.add(fn)
70
103
  return () => {
71
104
  onlineSubs.delete(fn)
105
+ if (onlineSubs.size === 0) uninstallOnline()
72
106
  }
73
107
  }
108
+
109
+ /** Test-only — force-detach global listeners regardless of subscriber state. */
110
+ export function __resetFocusOnlineForTests(): void {
111
+ focusSubs.clear()
112
+ onlineSubs.clear()
113
+ uninstallFocus()
114
+ uninstallOnline()
115
+ }