@kontsedal/olas-core 0.0.1-rc.1 → 0.0.2

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 (45) hide show
  1. package/dist/index.cjs +40 -10
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +32 -11
  4. package/dist/index.d.cts.map +1 -1
  5. package/dist/index.d.mts +32 -11
  6. package/dist/index.d.mts.map +1 -1
  7. package/dist/index.mjs +40 -11
  8. package/dist/index.mjs.map +1 -1
  9. package/dist/{root-BImHnGj1.mjs → root-De-6KWIZ.mjs} +750 -149
  10. package/dist/root-De-6KWIZ.mjs.map +1 -0
  11. package/dist/{root-Bazp5_Ik.cjs → root-XKEsSmcd.cjs} +755 -148
  12. package/dist/root-XKEsSmcd.cjs.map +1 -0
  13. package/dist/testing.cjs +1 -1
  14. package/dist/testing.d.cts +1 -1
  15. package/dist/testing.d.mts +1 -1
  16. package/dist/testing.mjs +1 -1
  17. package/dist/{types-CAMgqCMz.d.mts → types-C-zV1JZA.d.mts} +215 -13
  18. package/dist/types-C-zV1JZA.d.mts.map +1 -0
  19. package/dist/{types-emq_lZd7.d.cts → types-DKfpkm17.d.cts} +215 -13
  20. package/dist/types-DKfpkm17.d.cts.map +1 -0
  21. package/package.json +1 -1
  22. package/src/controller/index.ts +6 -0
  23. package/src/controller/instance.ts +432 -18
  24. package/src/controller/root.ts +9 -1
  25. package/src/controller/types.ts +148 -7
  26. package/src/emitter.ts +34 -3
  27. package/src/forms/field.ts +73 -8
  28. package/src/forms/form-types.ts +16 -0
  29. package/src/forms/form.ts +218 -26
  30. package/src/index.ts +12 -1
  31. package/src/query/client.ts +161 -6
  32. package/src/query/define.ts +14 -0
  33. package/src/query/entry.ts +64 -42
  34. package/src/query/infinite.ts +77 -55
  35. package/src/query/mutation.ts +11 -21
  36. package/src/query/plugin.ts +50 -0
  37. package/src/query/use.ts +80 -3
  38. package/src/signals/readonly.ts +3 -3
  39. package/src/timing/debounced.ts +24 -4
  40. package/src/timing/throttled.ts +22 -3
  41. package/src/utils.ts +32 -4
  42. package/dist/root-BImHnGj1.mjs.map +0 -1
  43. package/dist/root-Bazp5_Ik.cjs.map +0 -1
  44. package/dist/types-CAMgqCMz.d.mts.map +0 -1
  45. package/dist/types-emq_lZd7.d.cts.map +0 -1
@@ -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
- while (true) {
301
- if (myId !== this.currentFetchId || this.disposed) {
302
- throw new DOMException('Superseded', 'AbortError')
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
- onSuccess(page, pageParam)
310
- return page
311
- } catch (err) {
312
- if (myId !== this.currentFetchId || this.disposed || isAbortError(err)) {
313
- throw err
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
- const shouldRetry =
316
- typeof this.retry === 'number' ? attempt < this.retry : this.retry(attempt, err)
317
- if (!shouldRetry) {
318
- batch(() => {
319
- this.error.set(err)
320
- this.status.set('error')
321
- this.isLoading.set(false)
322
- this.isFetching.set(false)
323
- if (direction === 'next') this.isFetchingNextPage.set(false)
324
- if (direction === 'prev') this.isFetchingPreviousPage.set(false)
325
- })
326
- throw err
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
- reject(this.error.peek())
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
- function abortableSleep(ms: number, signal: AbortSignal): Promise<void> {
446
- return new Promise((resolve, reject) => {
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
- const timer = setTimeout(() => {
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
  }
@@ -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
- this.serialQueue.length = 0
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
- }
@@ -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
- ): { subscription: QuerySubscription<T>; dispose: () => void } {
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
- return { subscription: sub, dispose }
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
- return { subscription: sub, dispose }
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
  }
@@ -8,15 +8,15 @@ import type { ReadSignal } from './types'
8
8
  * Internal helper — not exported from the package's public surface.
9
9
  */
10
10
  export function readOnly<T>(source: ReadSignal<T>): ReadSignal<T> {
11
- return {
11
+ return Object.freeze({
12
12
  get value() {
13
13
  return source.value
14
14
  },
15
15
  peek() {
16
16
  return source.peek()
17
17
  },
18
- subscribe(handler) {
18
+ subscribe(handler: (value: T) => void) {
19
19
  return source.subscribe(handler)
20
20
  },
21
- }
21
+ })
22
22
  }
@@ -5,15 +5,22 @@ import type { ReadSignal } from '../signals/types'
5
5
  * Lag a signal by `ms`. The returned signal updates only after the source has
6
6
  * been unchanged for `ms`. Each new write resets the timer.
7
7
  *
8
- * No lifecycle the internal effect runs for the lifetime of the program.
9
- * Use inside a controller closure so it dies with the closure.
8
+ * Pass `options.signal` (an `AbortSignal`) to tie the internal effect to a
9
+ * lifecycle when the signal aborts the effect disposes, the pending timer
10
+ * clears, and the subscriber chain on `source` drops. Without `signal`, the
11
+ * effect lives as long as `source` does; pass a signal whenever the source
12
+ * outlives the consumer.
10
13
  */
11
- export function debounced<T>(source: ReadSignal<T>, ms: number): ReadSignal<T> {
14
+ export function debounced<T>(
15
+ source: ReadSignal<T>,
16
+ ms: number,
17
+ options?: { signal?: AbortSignal },
18
+ ): ReadSignal<T> {
12
19
  const out = signal<T>(source.peek())
13
20
  let timer: ReturnType<typeof setTimeout> | null = null
14
21
  let initial = true
15
22
 
16
- effect(() => {
23
+ const dispose = effect(() => {
17
24
  const value = source.value
18
25
  if (initial) {
19
26
  // The first effect run reads the source for tracking; we already
@@ -28,5 +35,18 @@ export function debounced<T>(source: ReadSignal<T>, ms: number): ReadSignal<T> {
28
35
  }, ms)
29
36
  })
30
37
 
38
+ const sig = options?.signal
39
+ if (sig) {
40
+ const stop = () => {
41
+ if (timer != null) {
42
+ clearTimeout(timer)
43
+ timer = null
44
+ }
45
+ dispose()
46
+ }
47
+ if (sig.aborted) stop()
48
+ else sig.addEventListener('abort', stop, { once: true })
49
+ }
50
+
31
51
  return out
32
52
  }
@@ -6,16 +6,22 @@ import type { ReadSignal } from '../signals/types'
6
6
  * The first change passes through immediately. Subsequent changes within the
7
7
  * window are coalesced; the latest value is emitted when the window expires.
8
8
  *
9
- * No lifecycle — see debounced() note.
9
+ * Pass `options.signal` to tie the internal effect to a lifecycle — when the
10
+ * signal aborts the effect disposes and any pending trailing timer clears.
11
+ * Without `signal`, the effect lives as long as `source` does.
10
12
  */
11
- export function throttled<T>(source: ReadSignal<T>, ms: number): ReadSignal<T> {
13
+ export function throttled<T>(
14
+ source: ReadSignal<T>,
15
+ ms: number,
16
+ options?: { signal?: AbortSignal },
17
+ ): ReadSignal<T> {
12
18
  const out = signal<T>(source.peek())
13
19
  let lastEmit = Number.NEGATIVE_INFINITY
14
20
  let trailingTimer: ReturnType<typeof setTimeout> | null = null
15
21
  let trailingValue: T = source.peek()
16
22
  let initial = true
17
23
 
18
- effect(() => {
24
+ const dispose = effect(() => {
19
25
  const value = source.value
20
26
  if (initial) {
21
27
  initial = false
@@ -42,5 +48,18 @@ export function throttled<T>(source: ReadSignal<T>, ms: number): ReadSignal<T> {
42
48
  }
43
49
  })
44
50
 
51
+ const sig = options?.signal
52
+ if (sig) {
53
+ const stop = () => {
54
+ if (trailingTimer != null) {
55
+ clearTimeout(trailingTimer)
56
+ trailingTimer = null
57
+ }
58
+ dispose()
59
+ }
60
+ if (sig.aborted) stop()
61
+ else sig.addEventListener('abort', stop, { once: true })
62
+ }
63
+
45
64
  return out
46
65
  }
package/src/utils.ts CHANGED
@@ -1,13 +1,41 @@
1
1
  /**
2
- * True iff `err` is an AbortError. Used to filter superseded latest-wins
3
- * mutations and aborted fetches from genuine failures.
2
+ * True iff `err` looks like an AbortError. Matches the standard `DOMException`
3
+ * shape thrown by `AbortController` AND any object whose `name === 'AbortError'`
4
+ * — that covers axios / msw / user-thrown plain Errors that signal abort.
4
5
  *
5
- * Spec: §20.12 checks `err instanceof DOMException && err.name === 'AbortError'`.
6
- * Node 17+ exposes a global DOMException, so this works server-side too.
6
+ * Spec: §20.12. Node 17+ exposes a global DOMException, so the instanceof
7
+ * branch works server-side; the name-based branch is the portable fallback.
7
8
  */
8
9
  export function isAbortError(err: unknown): boolean {
9
10
  if (typeof DOMException !== 'undefined' && err instanceof DOMException) {
10
11
  return err.name === 'AbortError'
11
12
  }
13
+ if (err != null && typeof err === 'object' && 'name' in err) {
14
+ return (err as { name: unknown }).name === 'AbortError'
15
+ }
12
16
  return false
13
17
  }
18
+
19
+ /**
20
+ * `setTimeout` wrapped in a promise that rejects with `AbortError` if the
21
+ * passed signal fires. Internal — used by the retry loops in `Entry`,
22
+ * `InfiniteEntry`, and `Mutation` so a slow backoff never blocks a supersede.
23
+ */
24
+ export function abortableSleep(ms: number, signal: AbortSignal): Promise<void> {
25
+ return new Promise((resolve, reject) => {
26
+ if (signal.aborted) {
27
+ reject(new DOMException('Aborted', 'AbortError'))
28
+ return
29
+ }
30
+ const timer = setTimeout(() => {
31
+ signal.removeEventListener('abort', onAbort)
32
+ resolve()
33
+ }, ms)
34
+ const onAbort = () => {
35
+ clearTimeout(timer)
36
+ signal.removeEventListener('abort', onAbort)
37
+ reject(new DOMException('Aborted', 'AbortError'))
38
+ }
39
+ signal.addEventListener('abort', onAbort, { once: true })
40
+ })
41
+ }