@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.
- package/dist/index.cjs +0 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +109 -27
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +109 -27
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +0 -0
- package/dist/index.mjs.map +1 -1
- package/dist/{root-B6pNq75w.mjs → root-Byq-QYTp.mjs} +1465 -347
- package/dist/root-Byq-QYTp.mjs.map +1 -0
- package/dist/{root-CFgYhBd-.cjs → root-CPSL5kpR.cjs} +1470 -346
- package/dist/root-CPSL5kpR.cjs.map +1 -0
- package/dist/testing.cjs +5 -1
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +3 -2
- package/dist/testing.d.cts.map +1 -1
- package/dist/testing.d.mts +3 -2
- package/dist/testing.d.mts.map +1 -1
- package/dist/testing.mjs +5 -2
- package/dist/testing.mjs.map +1 -1
- package/dist/{types-Bmi04hDI.d.mts → types-Brp-4WaZ.d.mts} +233 -23
- package/dist/types-Brp-4WaZ.d.mts.map +1 -0
- package/dist/{types-BsZMPPOE.d.cts → types-DzDIypPo.d.cts} +233 -23
- package/dist/types-DzDIypPo.d.cts.map +1 -0
- package/package.json +4 -1
- package/src/controller/instance.ts +345 -94
- package/src/controller/root.ts +52 -30
- package/src/controller/types.ts +55 -4
- package/src/emitter.ts +8 -2
- package/src/errors.ts +39 -3
- package/src/forms/field.ts +219 -25
- package/src/forms/form-types.ts +16 -3
- package/src/forms/form.ts +360 -38
- package/src/forms/index.ts +3 -1
- package/src/forms/types.ts +27 -1
- package/src/forms/validators.ts +45 -11
- package/src/index.ts +13 -7
- package/src/query/client.ts +237 -58
- package/src/query/define.ts +0 -0
- package/src/query/entry.ts +259 -15
- package/src/query/focus-online.ts +53 -11
- package/src/query/infinite.ts +351 -47
- package/src/query/keys.ts +33 -2
- package/src/query/local.ts +3 -0
- package/src/query/mutation.ts +27 -6
- package/src/query/plugin.ts +43 -4
- package/src/query/types.ts +76 -6
- package/src/query/use.ts +53 -10
- package/src/selection.ts +49 -12
- package/src/signals/readonly.ts +3 -0
- package/src/signals/runtime.ts +27 -0
- package/src/signals/types.ts +10 -0
- package/src/testing.ts +9 -0
- package/src/timing/debounced.ts +106 -23
- package/src/timing/index.ts +1 -1
- package/src/timing/throttled.ts +85 -28
- package/src/utils.ts +15 -2
- package/dist/root-B6pNq75w.mjs.map +0 -1
- package/dist/root-CFgYhBd-.cjs.map +0 -1
- package/dist/types-Bmi04hDI.d.mts.map +0 -1
- package/dist/types-BsZMPPOE.d.cts.map +0 -1
package/src/query/entry.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
262
|
-
const record: SnapshotRecord<T>
|
|
263
|
-
|
|
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.
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
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',
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|