@kontsedal/olas-core 0.0.1-rc.1 → 0.0.1
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 +2 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +13 -2
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +13 -2
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -2
- package/dist/index.mjs.map +1 -1
- package/dist/{root-BImHnGj1.mjs → root-BCZDC5Fv.mjs} +442 -139
- package/dist/root-BCZDC5Fv.mjs.map +1 -0
- package/dist/{root-Bazp5_Ik.cjs → root-DXV1gVbQ.cjs} +447 -138
- package/dist/root-DXV1gVbQ.cjs.map +1 -0
- package/dist/testing.cjs +1 -1
- package/dist/testing.d.cts +1 -1
- package/dist/testing.d.mts +1 -1
- package/dist/testing.mjs +1 -1
- package/dist/{types-CAMgqCMz.d.mts → types-CffZ1QXt.d.cts} +82 -10
- package/dist/types-CffZ1QXt.d.cts.map +1 -0
- package/dist/{types-emq_lZd7.d.cts → types-DSlDowpE.d.mts} +82 -10
- package/dist/types-DSlDowpE.d.mts.map +1 -0
- package/package.json +1 -1
- package/src/controller/instance.ts +115 -15
- package/src/controller/root.ts +9 -1
- package/src/controller/types.ts +17 -7
- package/src/forms/field.ts +73 -8
- package/src/forms/form-types.ts +16 -0
- package/src/forms/form.ts +171 -21
- package/src/index.ts +5 -0
- package/src/query/client.ts +161 -6
- package/src/query/define.ts +14 -0
- package/src/query/entry.ts +64 -42
- package/src/query/infinite.ts +77 -55
- package/src/query/mutation.ts +11 -21
- package/src/query/plugin.ts +50 -0
- package/src/query/use.ts +80 -3
- package/src/utils.ts +24 -0
- package/dist/root-BImHnGj1.mjs.map +0 -1
- package/dist/root-Bazp5_Ik.cjs.map +0 -1
- package/dist/types-CAMgqCMz.d.mts.map +0 -1
- package/dist/types-emq_lZd7.d.cts.map +0 -1
package/src/query/infinite.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { batch, computed, type Signal, signal } from '../signals'
|
|
2
2
|
import type { ReadSignal } from '../signals/types'
|
|
3
|
-
import { isAbortError } from '../utils'
|
|
4
|
-
import type { AsyncState, AsyncStatus, RetryDelay, RetryPolicy } from './types'
|
|
3
|
+
import { abortableSleep, isAbortError } from '../utils'
|
|
4
|
+
import type { AsyncState, AsyncStatus, RetryDelay, RetryPolicy, Snapshot } from './types'
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Configuration for `defineInfiniteQuery({ ... })`. Spec §5.7, §20.4.
|
|
@@ -58,9 +58,7 @@ export type InfiniteQuery<Args extends unknown[], TPage, _TItem> = {
|
|
|
58
58
|
readonly __olas: 'infiniteQuery'
|
|
59
59
|
invalidate(...args: Args): void
|
|
60
60
|
invalidateAll(): void
|
|
61
|
-
setData(...args: [...Args, updater: (prev: TPage[] | undefined) => TPage[]]):
|
|
62
|
-
rollback: () => void
|
|
63
|
-
}
|
|
61
|
+
setData(...args: [...Args, updater: (prev: TPage[] | undefined) => TPage[]]): Snapshot
|
|
64
62
|
prefetch(...args: Args): Promise<TPage>
|
|
65
63
|
}
|
|
66
64
|
|
|
@@ -83,8 +81,6 @@ export type InfiniteQuerySubscription<TPage, TItem> = AsyncState<TPage[]> & {
|
|
|
83
81
|
fetchPreviousPage: () => Promise<void>
|
|
84
82
|
}
|
|
85
83
|
|
|
86
|
-
import type { Snapshot } from './types'
|
|
87
|
-
|
|
88
84
|
/**
|
|
89
85
|
* Holds an array of pages plus their pageParams. Supports fetchNextPage /
|
|
90
86
|
* fetchPreviousPage / invalidate (drops all pages). Race-protected.
|
|
@@ -116,6 +112,8 @@ export class InfiniteEntry<TPage, TItem, PageParam> {
|
|
|
116
112
|
private snapshots: Array<{ id: number; prev: TPage[]; live: boolean }> = []
|
|
117
113
|
private nextSnapshotId = 0
|
|
118
114
|
private disposed = false
|
|
115
|
+
/** Mirrors `Entry.pendingFirstValueRejects` — see that field for context. */
|
|
116
|
+
private pendingFirstValueRejects: Array<(err: unknown) => void> = []
|
|
119
117
|
|
|
120
118
|
private readonly fetcher: (pageCtx: {
|
|
121
119
|
pageParam: PageParam
|
|
@@ -130,6 +128,13 @@ export class InfiniteEntry<TPage, TItem, PageParam> {
|
|
|
130
128
|
private readonly retry: RetryPolicy
|
|
131
129
|
private readonly retryDelay: RetryDelay
|
|
132
130
|
private readonly itemsOf?: (page: TPage) => TItem[]
|
|
131
|
+
/**
|
|
132
|
+
* Mirrors `Entry.onSuccessData`. Fires from `applyFetchSuccess`-equivalent
|
|
133
|
+
* branches AFTER `pages.set(...)` settles. Used by `InfiniteClientEntry`
|
|
134
|
+
* to emit `SetDataEvent { kind: 'infinite', source: 'fetch' }` for
|
|
135
|
+
* `QueryClientPlugin`s (e.g. entity normalization).
|
|
136
|
+
*/
|
|
137
|
+
private readonly onSuccessData?: (pages: TPage[]) => void
|
|
133
138
|
|
|
134
139
|
constructor(opts: {
|
|
135
140
|
fetcher: (pageCtx: { pageParam: PageParam; signal: AbortSignal }) => Promise<TPage>
|
|
@@ -140,6 +145,7 @@ export class InfiniteEntry<TPage, TItem, PageParam> {
|
|
|
140
145
|
staleTime?: number
|
|
141
146
|
retry?: RetryPolicy
|
|
142
147
|
retryDelay?: RetryDelay
|
|
148
|
+
onSuccessData?: (pages: TPage[]) => void
|
|
143
149
|
}) {
|
|
144
150
|
this.fetcher = opts.fetcher
|
|
145
151
|
this.initialPageParam = opts.initialPageParam
|
|
@@ -149,6 +155,7 @@ export class InfiniteEntry<TPage, TItem, PageParam> {
|
|
|
149
155
|
this.staleTime = opts.staleTime ?? 0
|
|
150
156
|
this.retry = opts.retry ?? 0
|
|
151
157
|
this.retryDelay = opts.retryDelay ?? 1000
|
|
158
|
+
this.onSuccessData = opts.onSuccessData
|
|
152
159
|
this.pageParams = signal<PageParam[]>([])
|
|
153
160
|
this.data = computed(() => {
|
|
154
161
|
const ps = this.pages.value
|
|
@@ -209,6 +216,7 @@ export class InfiniteEntry<TPage, TItem, PageParam> {
|
|
|
209
216
|
this.isStale.set(this.staleTime === 0)
|
|
210
217
|
})
|
|
211
218
|
if (this.staleTime > 0) this.scheduleStaleness()
|
|
219
|
+
this.onSuccessData?.(this.pages.peek())
|
|
212
220
|
},
|
|
213
221
|
'initial',
|
|
214
222
|
)
|
|
@@ -246,6 +254,7 @@ export class InfiniteEntry<TPage, TItem, PageParam> {
|
|
|
246
254
|
this.isFetching.set(false)
|
|
247
255
|
this.lastUpdatedAt.set(Date.now())
|
|
248
256
|
})
|
|
257
|
+
this.onSuccessData?.(this.pages.peek())
|
|
249
258
|
},
|
|
250
259
|
'next',
|
|
251
260
|
).then(() => {})
|
|
@@ -284,6 +293,7 @@ export class InfiniteEntry<TPage, TItem, PageParam> {
|
|
|
284
293
|
this.isFetching.set(false)
|
|
285
294
|
this.lastUpdatedAt.set(Date.now())
|
|
286
295
|
})
|
|
296
|
+
this.onSuccessData?.(this.pages.peek())
|
|
287
297
|
},
|
|
288
298
|
'prev',
|
|
289
299
|
).then(() => {})
|
|
@@ -297,38 +307,54 @@ export class InfiniteEntry<TPage, TItem, PageParam> {
|
|
|
297
307
|
direction: 'initial' | 'next' | 'prev',
|
|
298
308
|
): Promise<TPage> {
|
|
299
309
|
let attempt = 0
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
}
|
|
304
|
-
try {
|
|
305
|
-
const page = await this.fetcher({ pageParam, signal })
|
|
310
|
+
let succeeded = false
|
|
311
|
+
try {
|
|
312
|
+
while (true) {
|
|
306
313
|
if (myId !== this.currentFetchId || this.disposed) {
|
|
307
314
|
throw new DOMException('Superseded', 'AbortError')
|
|
308
315
|
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
316
|
+
try {
|
|
317
|
+
const page = await this.fetcher({ pageParam, signal })
|
|
318
|
+
if (myId !== this.currentFetchId || this.disposed) {
|
|
319
|
+
throw new DOMException('Superseded', 'AbortError')
|
|
320
|
+
}
|
|
321
|
+
onSuccess(page, pageParam)
|
|
322
|
+
succeeded = true
|
|
323
|
+
return page
|
|
324
|
+
} catch (err) {
|
|
325
|
+
if (myId !== this.currentFetchId || this.disposed || isAbortError(err)) {
|
|
326
|
+
throw err
|
|
327
|
+
}
|
|
328
|
+
const shouldRetry =
|
|
329
|
+
typeof this.retry === 'number' ? attempt < this.retry : this.retry(attempt, err)
|
|
330
|
+
if (!shouldRetry) {
|
|
331
|
+
batch(() => {
|
|
332
|
+
this.error.set(err)
|
|
333
|
+
this.status.set('error')
|
|
334
|
+
this.isLoading.set(false)
|
|
335
|
+
this.isFetching.set(false)
|
|
336
|
+
if (direction === 'next') this.isFetchingNextPage.set(false)
|
|
337
|
+
if (direction === 'prev') this.isFetchingPreviousPage.set(false)
|
|
338
|
+
})
|
|
339
|
+
throw err
|
|
340
|
+
}
|
|
341
|
+
const delay =
|
|
342
|
+
typeof this.retryDelay === 'function' ? this.retryDelay(attempt) : this.retryDelay
|
|
343
|
+
await abortableSleep(delay, signal)
|
|
344
|
+
attempt += 1
|
|
314
345
|
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
}
|
|
328
|
-
const delay =
|
|
329
|
-
typeof this.retryDelay === 'function' ? this.retryDelay(attempt) : this.retryDelay
|
|
330
|
-
await abortableSleep(delay, signal)
|
|
331
|
-
attempt += 1
|
|
346
|
+
}
|
|
347
|
+
} finally {
|
|
348
|
+
// Catch-all reset for the supersede/abort path. The success and explicit
|
|
349
|
+
// failure paths already reset these via `onSuccess` and the
|
|
350
|
+
// `applyFailure`-equivalent branch above; this guarantees that an
|
|
351
|
+
// aborted-mid-flight `fetchNextPage` (e.g., user calls `invalidate()`
|
|
352
|
+
// while paging) doesn't wedge the spinner.
|
|
353
|
+
if (!succeeded) {
|
|
354
|
+
batch(() => {
|
|
355
|
+
if (direction === 'next') this.isFetchingNextPage.set(false)
|
|
356
|
+
if (direction === 'prev') this.isFetchingPreviousPage.set(false)
|
|
357
|
+
})
|
|
332
358
|
}
|
|
333
359
|
}
|
|
334
360
|
}
|
|
@@ -395,6 +421,9 @@ export class InfiniteEntry<TPage, TItem, PageParam> {
|
|
|
395
421
|
}
|
|
396
422
|
|
|
397
423
|
firstValue(): Promise<TPage[]> {
|
|
424
|
+
if (this.disposed) {
|
|
425
|
+
return Promise.reject(new DOMException('Entry disposed', 'AbortError'))
|
|
426
|
+
}
|
|
398
427
|
if (this.status.peek() === 'success') {
|
|
399
428
|
return Promise.resolve(this.pages.peek())
|
|
400
429
|
}
|
|
@@ -402,13 +431,19 @@ export class InfiniteEntry<TPage, TItem, PageParam> {
|
|
|
402
431
|
return Promise.reject(this.error.peek())
|
|
403
432
|
}
|
|
404
433
|
return new Promise<TPage[]>((resolve, reject) => {
|
|
434
|
+
const tracked = (err: unknown): void => {
|
|
435
|
+
this.pendingFirstValueRejects = this.pendingFirstValueRejects.filter((f) => f !== tracked)
|
|
436
|
+
reject(err)
|
|
437
|
+
}
|
|
438
|
+
this.pendingFirstValueRejects.push(tracked)
|
|
405
439
|
const unsub = this.status.subscribe((s) => {
|
|
406
440
|
if (s === 'success') {
|
|
407
441
|
unsub()
|
|
442
|
+
this.pendingFirstValueRejects = this.pendingFirstValueRejects.filter((f) => f !== tracked)
|
|
408
443
|
resolve(this.pages.peek())
|
|
409
444
|
} else if (s === 'error') {
|
|
410
445
|
unsub()
|
|
411
|
-
|
|
446
|
+
tracked(this.error.peek())
|
|
412
447
|
}
|
|
413
448
|
})
|
|
414
449
|
})
|
|
@@ -439,24 +474,11 @@ export class InfiniteEntry<TPage, TItem, PageParam> {
|
|
|
439
474
|
}
|
|
440
475
|
this.currentAbort?.abort()
|
|
441
476
|
this.currentAbort = null
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
if (signal.aborted) {
|
|
448
|
-
reject(new DOMException('Aborted', 'AbortError'))
|
|
449
|
-
return
|
|
477
|
+
if (this.pendingFirstValueRejects.length > 0) {
|
|
478
|
+
const disposed = new DOMException('Entry disposed', 'AbortError')
|
|
479
|
+
const rejects = this.pendingFirstValueRejects
|
|
480
|
+
this.pendingFirstValueRejects = []
|
|
481
|
+
for (const fn of rejects) fn(disposed)
|
|
450
482
|
}
|
|
451
|
-
|
|
452
|
-
signal.removeEventListener('abort', onAbort)
|
|
453
|
-
resolve()
|
|
454
|
-
}, ms)
|
|
455
|
-
const onAbort = () => {
|
|
456
|
-
clearTimeout(timer)
|
|
457
|
-
signal.removeEventListener('abort', onAbort)
|
|
458
|
-
reject(new DOMException('Aborted', 'AbortError'))
|
|
459
|
-
}
|
|
460
|
-
signal.addEventListener('abort', onAbort, { once: true })
|
|
461
|
-
})
|
|
483
|
+
}
|
|
462
484
|
}
|
package/src/query/mutation.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { DevtoolsEmitter } from '../devtools'
|
|
|
2
2
|
import { dispatchError, type ErrorHandler } from '../errors'
|
|
3
3
|
import { batch, type Signal, signal } from '../signals'
|
|
4
4
|
import type { ReadSignal } from '../signals/types'
|
|
5
|
-
import { isAbortError } from '../utils'
|
|
5
|
+
import { abortableSleep, isAbortError } from '../utils'
|
|
6
6
|
import type { RetryDelay, RetryPolicy, Snapshot } from './types'
|
|
7
7
|
|
|
8
8
|
/**
|
|
@@ -299,7 +299,16 @@ class MutationImpl<V, R> implements Mutation<V, R> {
|
|
|
299
299
|
reset(): void {
|
|
300
300
|
if (this.disposed) return
|
|
301
301
|
for (const handle of this.inflight) handle.abort.abort()
|
|
302
|
-
|
|
302
|
+
// Reject queued serial runs so their awaiters don't hang — symmetric with
|
|
303
|
+
// `dispose()`. Without this, callers of `mutation.run(...)` on a serial
|
|
304
|
+
// mutation that get reset mid-queue wait forever.
|
|
305
|
+
if (this.serialQueue.length > 0) {
|
|
306
|
+
const aborted = new DOMException('Aborted', 'AbortError')
|
|
307
|
+
const queue = this.serialQueue
|
|
308
|
+
this.serialQueue = []
|
|
309
|
+
for (const queued of queue) queued.reject(aborted)
|
|
310
|
+
}
|
|
311
|
+
this.serialActive = false
|
|
303
312
|
batch(() => {
|
|
304
313
|
this.data.set(undefined)
|
|
305
314
|
this.error.set(undefined)
|
|
@@ -363,22 +372,3 @@ function raceAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
|
363
372
|
)
|
|
364
373
|
})
|
|
365
374
|
}
|
|
366
|
-
|
|
367
|
-
function abortableSleep(ms: number, signal: AbortSignal): Promise<void> {
|
|
368
|
-
return new Promise((resolve, reject) => {
|
|
369
|
-
if (signal.aborted) {
|
|
370
|
-
reject(new DOMException('Aborted', 'AbortError'))
|
|
371
|
-
return
|
|
372
|
-
}
|
|
373
|
-
const timer = setTimeout(() => {
|
|
374
|
-
signal.removeEventListener('abort', onAbort)
|
|
375
|
-
resolve()
|
|
376
|
-
}, ms)
|
|
377
|
-
const onAbort = () => {
|
|
378
|
-
clearTimeout(timer)
|
|
379
|
-
signal.removeEventListener('abort', onAbort)
|
|
380
|
-
reject(new DOMException('Aborted', 'AbortError'))
|
|
381
|
-
}
|
|
382
|
-
signal.addEventListener('abort', onAbort, { once: true })
|
|
383
|
-
})
|
|
384
|
-
}
|
package/src/query/plugin.ts
CHANGED
|
@@ -30,10 +30,48 @@ export type QueryClientPluginApi = {
|
|
|
30
30
|
*/
|
|
31
31
|
applyRemoteSetData(queryId: string, keyArgs: readonly unknown[], data: unknown): void
|
|
32
32
|
applyRemoteInvalidate(queryId: string, keyArgs: readonly unknown[]): void
|
|
33
|
+
/**
|
|
34
|
+
* Apply a local-originated `setData` to the entry identified by
|
|
35
|
+
* `(queryId, keyArgs)`. The resulting plugin events fire with
|
|
36
|
+
* `isRemote: false` and `source: 'set'` — cross-tab plugins WILL
|
|
37
|
+
* rebroadcast (the write is treated as if a controller called
|
|
38
|
+
* `client.setData(...)` directly).
|
|
39
|
+
*
|
|
40
|
+
* Drops silently when the queryId is unknown, the registered query is
|
|
41
|
+
* infinite, or no local entry exists for that key. The `updater`
|
|
42
|
+
* receives the previous data (typed as `unknown` because plugins are
|
|
43
|
+
* type-erased) and returns the next.
|
|
44
|
+
*
|
|
45
|
+
* Use case: entity-normalization plugins that want to backpropagate an
|
|
46
|
+
* `entity.update(...)` patch into every query holding that entity.
|
|
47
|
+
* Mutations / optimistic updates already go through the public
|
|
48
|
+
* `client.setData` and don't need this API.
|
|
49
|
+
*/
|
|
50
|
+
setEntryData(
|
|
51
|
+
queryId: string,
|
|
52
|
+
keyArgs: readonly unknown[],
|
|
53
|
+
updater: (prev: unknown) => unknown,
|
|
54
|
+
): void
|
|
33
55
|
/**
|
|
34
56
|
* Snapshot of currently bound entry keys for a query (by `queryId`). Empty
|
|
35
57
|
* array when the query isn't registered, has no client entries, or the
|
|
36
58
|
* `queryId` doesn't match any registered query.
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* ```ts
|
|
62
|
+
* // Plugin sees an incoming invalidate; only echo it outward if any local
|
|
63
|
+
* // controller is actually subscribed to that key — otherwise the message
|
|
64
|
+
* // is unilateral noise.
|
|
65
|
+
* const plugin: QueryClientPlugin = {
|
|
66
|
+
* init(api) { this.api = api },
|
|
67
|
+
* onInvalidate(ev) {
|
|
68
|
+
* if (ev.isRemote) return
|
|
69
|
+
* const subscribed = this.api.subscribedKeys(ev.queryId)
|
|
70
|
+
* if (subscribed.length === 0) return // no local subscribers → don't send
|
|
71
|
+
* transport.send({ type: 'invalidate', queryId: ev.queryId, keyArgs: ev.keyArgs })
|
|
72
|
+
* },
|
|
73
|
+
* }
|
|
74
|
+
* ```
|
|
37
75
|
*/
|
|
38
76
|
subscribedKeys(queryId: string): readonly (readonly unknown[])[]
|
|
39
77
|
}
|
|
@@ -53,6 +91,18 @@ export type SetDataEvent = {
|
|
|
53
91
|
* skip rebroadcast in that case — otherwise the message would echo back.
|
|
54
92
|
*/
|
|
55
93
|
isRemote: boolean
|
|
94
|
+
/**
|
|
95
|
+
* Origin of the write. `'set'` covers explicit `client.setData` (mutations,
|
|
96
|
+
* optimistic updates, plugin-initiated patches). `'fetch'` fires when the
|
|
97
|
+
* query fetcher resolved successfully and wrote the result into the entry
|
|
98
|
+
* — emitted after the data signal is settled. `'remote'` is the
|
|
99
|
+
* `applyRemoteSetData` path (cross-tab / server-push); equivalent to
|
|
100
|
+
* `isRemote === true`.
|
|
101
|
+
*
|
|
102
|
+
* Layered plugins use this to decide whether to react: cross-tab broadcasts
|
|
103
|
+
* only on `'set'`, an entity-normalization plugin observes all sources.
|
|
104
|
+
*/
|
|
105
|
+
source: 'set' | 'fetch' | 'remote'
|
|
56
106
|
}
|
|
57
107
|
|
|
58
108
|
export type InvalidateEvent = {
|
package/src/query/use.ts
CHANGED
|
@@ -84,7 +84,15 @@ export function createUse<Args extends unknown[], T>(
|
|
|
84
84
|
client: QueryClient,
|
|
85
85
|
query: Query<Args, T>,
|
|
86
86
|
keyOrOptions?: (() => Args) | UseOptions<Args>,
|
|
87
|
-
): {
|
|
87
|
+
): {
|
|
88
|
+
subscription: QuerySubscription<T>
|
|
89
|
+
dispose: () => void
|
|
90
|
+
/** Suspend the subscription — release the entry (its refetchInterval +
|
|
91
|
+
* focus/online listeners pause) without disposing it. Spec §4.1. */
|
|
92
|
+
suspend: () => void
|
|
93
|
+
/** Resume after `suspend`. Re-acquires the entry and refetches if stale. */
|
|
94
|
+
resume: () => void
|
|
95
|
+
} {
|
|
88
96
|
const internal = query as unknown as QueryInternal<Args, T>
|
|
89
97
|
const spec = internal.__spec
|
|
90
98
|
const keepPreviousData = spec.keepPreviousData ?? false
|
|
@@ -95,8 +103,10 @@ export function createUse<Args extends unknown[], T>(
|
|
|
95
103
|
|
|
96
104
|
const sub = new SubscriptionImpl<T>(keepPreviousData)
|
|
97
105
|
let currentEntry: ClientEntry<T> | null = null
|
|
106
|
+
let suspended = false
|
|
98
107
|
|
|
99
108
|
const effectDispose = effect(() => {
|
|
109
|
+
if (suspended) return
|
|
100
110
|
const isEnabled = enabledFn ? enabledFn() : true
|
|
101
111
|
if (!isEnabled) {
|
|
102
112
|
untracked(() => {
|
|
@@ -138,7 +148,43 @@ export function createUse<Args extends unknown[], T>(
|
|
|
138
148
|
sub.detach()
|
|
139
149
|
}
|
|
140
150
|
|
|
141
|
-
|
|
151
|
+
const suspend = (): void => {
|
|
152
|
+
if (suspended) return
|
|
153
|
+
suspended = true
|
|
154
|
+
if (currentEntry) {
|
|
155
|
+
currentEntry.release()
|
|
156
|
+
currentEntry = null
|
|
157
|
+
}
|
|
158
|
+
// Keep subscription detached so reads return the last committed values
|
|
159
|
+
// via the entry's signals if still alive (the entry may be gc'd after
|
|
160
|
+
// its gcTime; that's fine — resume re-binds).
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const resume = (): void => {
|
|
164
|
+
if (!suspended) return
|
|
165
|
+
suspended = false
|
|
166
|
+
// Re-evaluate the keyFn + enabled flag and rebind. The effect's deps
|
|
167
|
+
// didn't change while suspended, so toggling `suspended` here doesn't
|
|
168
|
+
// re-fire the effect on its own — force a sync rebind through the same
|
|
169
|
+
// code path.
|
|
170
|
+
const isEnabled = enabledFn ? enabledFn() : true
|
|
171
|
+
if (!isEnabled) return
|
|
172
|
+
const args = (keyFn ? keyFn() : ([] as unknown as Args)) as Args
|
|
173
|
+
const entry = client.bindEntry<Args, T>(query, args)
|
|
174
|
+
entry.acquire()
|
|
175
|
+
currentEntry = entry
|
|
176
|
+
sub.attach(entry)
|
|
177
|
+
// On resume, refetch if stale (matches the spec §4.1 "stale-on-resume"
|
|
178
|
+
// requirement). Non-stale data stays as-is.
|
|
179
|
+
const status = entry.entry.status.peek()
|
|
180
|
+
if (status === 'idle' || entry.entry.isStaleNow() || status === 'error') {
|
|
181
|
+
entry.entry.startFetch().catch(() => {
|
|
182
|
+
/* error captured on entry */
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return { subscription: sub, dispose, suspend, resume }
|
|
142
188
|
}
|
|
143
189
|
|
|
144
190
|
type InfiniteQueryInternal<Args extends unknown[], TPage, TItem> = InfiniteQuery<
|
|
@@ -265,6 +311,8 @@ export function createInfiniteUse<Args extends unknown[], TPage, TItem>(
|
|
|
265
311
|
): {
|
|
266
312
|
subscription: InfiniteQuerySubscription<TPage, TItem>
|
|
267
313
|
dispose: () => void
|
|
314
|
+
suspend: () => void
|
|
315
|
+
resume: () => void
|
|
268
316
|
} {
|
|
269
317
|
const spec = (query as unknown as InfiniteQueryInternal<Args, TPage, TItem>).__spec
|
|
270
318
|
const keepPreviousData = spec.keepPreviousData ?? false
|
|
@@ -274,8 +322,10 @@ export function createInfiniteUse<Args extends unknown[], TPage, TItem>(
|
|
|
274
322
|
|
|
275
323
|
const sub = new InfiniteSubscriptionImpl<TPage, TItem>(keepPreviousData)
|
|
276
324
|
let currentEntry: InfiniteClientEntry<TPage, TItem, unknown> | null = null
|
|
325
|
+
let suspended = false
|
|
277
326
|
|
|
278
327
|
const effectDispose = effect(() => {
|
|
328
|
+
if (suspended) return
|
|
279
329
|
const isEnabled = enabledFn ? enabledFn() : true
|
|
280
330
|
if (!isEnabled) {
|
|
281
331
|
untracked(() => {
|
|
@@ -317,5 +367,32 @@ export function createInfiniteUse<Args extends unknown[], TPage, TItem>(
|
|
|
317
367
|
sub.detach()
|
|
318
368
|
}
|
|
319
369
|
|
|
320
|
-
|
|
370
|
+
const suspend = (): void => {
|
|
371
|
+
if (suspended) return
|
|
372
|
+
suspended = true
|
|
373
|
+
if (currentEntry) {
|
|
374
|
+
currentEntry.release()
|
|
375
|
+
currentEntry = null
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const resume = (): void => {
|
|
380
|
+
if (!suspended) return
|
|
381
|
+
suspended = false
|
|
382
|
+
const isEnabled = enabledFn ? enabledFn() : true
|
|
383
|
+
if (!isEnabled) return
|
|
384
|
+
const args = (keyFn ? keyFn() : ([] as unknown as Args)) as Args
|
|
385
|
+
const entry = client.bindInfiniteEntry<Args, TPage, TItem>(query, args)
|
|
386
|
+
entry.acquire()
|
|
387
|
+
currentEntry = entry
|
|
388
|
+
sub.attach(entry)
|
|
389
|
+
const status = entry.entry.status.peek()
|
|
390
|
+
if (status === 'idle' || entry.entry.isStaleNow() || status === 'error') {
|
|
391
|
+
entry.entry.startFetch().catch(() => {
|
|
392
|
+
/* error captured on entry */
|
|
393
|
+
})
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return { subscription: sub, dispose, suspend, resume }
|
|
321
398
|
}
|
package/src/utils.ts
CHANGED
|
@@ -11,3 +11,27 @@ export function isAbortError(err: unknown): boolean {
|
|
|
11
11
|
}
|
|
12
12
|
return false
|
|
13
13
|
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `setTimeout` wrapped in a promise that rejects with `AbortError` if the
|
|
17
|
+
* passed signal fires. Internal — used by the retry loops in `Entry`,
|
|
18
|
+
* `InfiniteEntry`, and `Mutation` so a slow backoff never blocks a supersede.
|
|
19
|
+
*/
|
|
20
|
+
export function abortableSleep(ms: number, signal: AbortSignal): Promise<void> {
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
if (signal.aborted) {
|
|
23
|
+
reject(new DOMException('Aborted', 'AbortError'))
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
const timer = setTimeout(() => {
|
|
27
|
+
signal.removeEventListener('abort', onAbort)
|
|
28
|
+
resolve()
|
|
29
|
+
}, ms)
|
|
30
|
+
const onAbort = () => {
|
|
31
|
+
clearTimeout(timer)
|
|
32
|
+
signal.removeEventListener('abort', onAbort)
|
|
33
|
+
reject(new DOMException('Aborted', 'AbortError'))
|
|
34
|
+
}
|
|
35
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
36
|
+
})
|
|
37
|
+
}
|