@kontsedal/olas-core 0.0.1 → 0.0.3

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 (50) hide show
  1. package/dist/index.cjs +72 -10
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +72 -12
  4. package/dist/index.d.cts.map +1 -1
  5. package/dist/index.d.mts +72 -12
  6. package/dist/index.d.mts.map +1 -1
  7. package/dist/index.mjs +71 -11
  8. package/dist/index.mjs.map +1 -1
  9. package/dist/{root-BCZDC5Fv.mjs → root-Cnkb3I--.mjs} +556 -28
  10. package/dist/root-Cnkb3I--.mjs.map +1 -0
  11. package/dist/{root-DXV1gVbQ.cjs → root-D_xAdcom.cjs} +556 -28
  12. package/dist/root-D_xAdcom.cjs.map +1 -0
  13. package/dist/testing.cjs +2 -1
  14. package/dist/testing.cjs.map +1 -1
  15. package/dist/testing.d.cts +2 -1
  16. package/dist/testing.d.cts.map +1 -1
  17. package/dist/testing.d.mts +2 -1
  18. package/dist/testing.d.mts.map +1 -1
  19. package/dist/testing.mjs +2 -1
  20. package/dist/testing.mjs.map +1 -1
  21. package/dist/{types-CffZ1QXt.d.cts → types-CRn4UoLn.d.mts} +196 -8
  22. package/dist/types-CRn4UoLn.d.mts.map +1 -0
  23. package/dist/{types-DSlDowpE.d.mts → types-r_TVaRkD.d.cts} +196 -8
  24. package/dist/types-r_TVaRkD.d.cts.map +1 -0
  25. package/package.json +1 -1
  26. package/src/controller/index.ts +6 -0
  27. package/src/controller/instance.ts +317 -3
  28. package/src/controller/types.ts +151 -0
  29. package/src/emitter.ts +34 -3
  30. package/src/forms/field.ts +42 -9
  31. package/src/forms/form-types.ts +37 -0
  32. package/src/forms/form.ts +165 -5
  33. package/src/forms/index.ts +12 -1
  34. package/src/forms/standard-schema.ts +37 -0
  35. package/src/forms/validators.ts +31 -0
  36. package/src/index.ts +20 -4
  37. package/src/query/entry.ts +10 -3
  38. package/src/query/infinite.ts +8 -1
  39. package/src/query/structural-share.ts +114 -0
  40. package/src/query/types.ts +15 -2
  41. package/src/query/use.ts +47 -13
  42. package/src/signals/readonly.ts +3 -3
  43. package/src/testing.ts +2 -0
  44. package/src/timing/debounced.ts +24 -4
  45. package/src/timing/throttled.ts +22 -3
  46. package/src/utils.ts +8 -4
  47. package/dist/root-BCZDC5Fv.mjs.map +0 -1
  48. package/dist/root-DXV1gVbQ.cjs.map +0 -1
  49. package/dist/types-CffZ1QXt.d.cts.map +0 -1
  50. package/dist/types-DSlDowpE.d.mts.map +0 -1
@@ -1,5 +1,36 @@
1
+ import { isStandardSchema, type StandardSchemaV1 } from './standard-schema'
1
2
  import type { Validator } from './types'
2
3
 
4
+ /**
5
+ * Wrap any Standard-Schema-compatible schema (Zod 4, Valibot 1, ArkType 2,
6
+ * …) as an Olas validator. The validator returns the first issue's message
7
+ * on failure (or `'Invalid'` if no issues are produced), `null` on success.
8
+ *
9
+ * Standard Schema validators may be sync or async; this wrapper threads
10
+ * through whichever the schema returns — `Promise<string|null>` only when
11
+ * the underlying validate call is itself async.
12
+ *
13
+ * `signal` is accepted to match the `Validator<T>` shape but isn't forwarded
14
+ * — Standard Schema v1 has no cancellation surface.
15
+ */
16
+ export function validator<I, O>(schema: StandardSchemaV1<I, O>): Validator<I> {
17
+ return (value, signal) => {
18
+ void signal
19
+ const result = schema['~standard'].validate(value)
20
+ if (result instanceof Promise) {
21
+ return result.then(messageFromResult)
22
+ }
23
+ return messageFromResult(result)
24
+ }
25
+ }
26
+
27
+ function messageFromResult(result: { issues?: ReadonlyArray<{ message: string }> }): string | null {
28
+ if (result.issues === undefined || result.issues.length === 0) return null
29
+ return result.issues[0]?.message ?? 'Invalid'
30
+ }
31
+
32
+ export { isStandardSchema, type StandardSchemaV1 } from './standard-schema'
33
+
3
34
  const isEmpty = (value: unknown): boolean => {
4
35
  if (value === undefined || value === null) return true
5
36
  if (typeof value === 'string') return value.length === 0
package/src/index.ts CHANGED
@@ -3,11 +3,17 @@
3
3
  // Controller container
4
4
  export type {
5
5
  AmbientDeps,
6
+ Collection,
7
+ CollectionFactoryApi,
8
+ CollectionFactoryOptions,
9
+ CollectionFactoryResult,
10
+ CollectionHomogeneousOptions,
6
11
  ControllerDef,
7
12
  CtrlApi,
8
13
  CtrlProps,
9
14
  Ctx,
10
15
  Field,
16
+ LazyChild,
11
17
  Root,
12
18
  RootOptions,
13
19
  } from './controller'
@@ -16,12 +22,22 @@ export { createRoot, defineController } from './controller'
16
22
  export type { DebugBus, DebugCacheEntry, DebugEvent } from './devtools'
17
23
 
18
24
  // Emitter
19
- export type { Emitter } from './emitter'
25
+ export type { Emitter, EmitterErrorReporter } from './emitter'
20
26
  export { createEmitter } from './emitter'
21
27
  export type { ErrorContext } from './errors'
22
- // Forms — stdlib validators + debouncedValidator
23
- export type { Validator } from './forms'
24
- export { email, max, maxLength, min, minLength, pattern, required } from './forms'
28
+ // Forms — stdlib validators + Standard Schema adapter + debouncedValidator
29
+ export type { StandardSchemaV1, Validator } from './forms'
30
+ export {
31
+ email,
32
+ isStandardSchema,
33
+ max,
34
+ maxLength,
35
+ min,
36
+ minLength,
37
+ pattern,
38
+ required,
39
+ validator,
40
+ } from './forms'
25
41
  export { debouncedValidator } from './forms/field'
26
42
  export type {
27
43
  DeepPartial,
@@ -1,5 +1,6 @@
1
1
  import { batch, type Signal, signal } from '../signals'
2
2
  import { abortableSleep, isAbortError } from '../utils'
3
+ import { structuralShare } from './structural-share'
3
4
  import type { AsyncStatus, RetryDelay, RetryPolicy, Snapshot } from './types'
4
5
 
5
6
  export type EntryEvents = {
@@ -180,8 +181,14 @@ export class Entry<T> {
180
181
  }
181
182
 
182
183
  private applySuccess(result: T): T {
184
+ // Structurally share with the previous value so unchanged sub-trees
185
+ // keep their `===` identity. Downstream `computed`s and React snapshots
186
+ // stop thrashing on no-op refetches. Bails on Maps/Sets/class instances
187
+ // — see `structural-share.ts`.
188
+ const prev = this.data.peek() as T | undefined
189
+ const shared = prev === undefined ? result : structuralShare(prev, result)
183
190
  batch(() => {
184
- this.data.set(result)
191
+ this.data.set(shared)
185
192
  this.error.set(undefined)
186
193
  this.status.set('success')
187
194
  this.isLoading.set(false)
@@ -195,8 +202,8 @@ export class Entry<T> {
195
202
  } catch {
196
203
  // devtools handlers must not break the program.
197
204
  }
198
- this.onSuccessData?.(result)
199
- return result
205
+ this.onSuccessData?.(shared)
206
+ return shared
200
207
  }
201
208
 
202
209
  private applyFailure(err: unknown): never {
@@ -1,6 +1,7 @@
1
1
  import { batch, computed, type Signal, signal } from '../signals'
2
2
  import type { ReadSignal } from '../signals/types'
3
3
  import { abortableSleep, isAbortError } from '../utils'
4
+ import { structuralShare } from './structural-share'
4
5
  import type { AsyncState, AsyncStatus, RetryDelay, RetryPolicy, Snapshot } from './types'
5
6
 
6
7
  /**
@@ -205,8 +206,14 @@ export class InfiniteEntry<TPage, TItem, PageParam> {
205
206
  this.initialPageParam,
206
207
  (page, param) => {
207
208
  if (myId !== this.currentFetchId || this.disposed) return
209
+ // Structurally share with the previous first-page on refresh, so
210
+ // unchanged pages keep their refs. We only share the head page —
211
+ // initial fetch wipes the rest of the array by definition.
212
+ const prevPages = this.pages.peek()
213
+ const sharedPage =
214
+ prevPages.length > 0 ? structuralShare(prevPages[0] as TPage, page) : page
208
215
  batch(() => {
209
- this.pages.set([page])
216
+ this.pages.set([sharedPage])
210
217
  this.pageParams.set([param])
211
218
  this.error.set(undefined)
212
219
  this.status.set('success')
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Walk `prev` and `next` in parallel. Wherever a sub-tree in `next` is
3
+ * structurally equal to the corresponding sub-tree in `prev`, return `prev`'s
4
+ * reference for that sub-tree. Otherwise return `next`'s.
5
+ *
6
+ * Result: a value that is `===` to `prev` on every refetch where the payload
7
+ * didn't actually change, and shares maximum ref-identity on partial changes.
8
+ * Downstream `computed`s and React `useSyncExternalStore` snapshots stop
9
+ * thrashing because reference equality holds where content equality holds.
10
+ *
11
+ * Bails (returns the `next` ref unchanged, no recursion) on:
12
+ * - Mismatched constructors / different `typeof` between `prev` and `next`
13
+ * - `Map`, `Set`, `Date`, `RegExp`, class instances (anything where the
14
+ * plain-object / array fast path isn't safe)
15
+ * - Functions, symbols, promises
16
+ *
17
+ * Handles cycles via a `WeakSet` of in-progress objects — a self-referential
18
+ * payload that compares structurally identical against itself won't loop.
19
+ */
20
+ export function structuralShare<T>(prev: T, next: T): T {
21
+ // Identity short-circuit — both branches see the exact same allocation.
22
+ if (Object.is(prev, next)) return prev
23
+ return walk(prev, next, new WeakSet<object>()) as T
24
+ }
25
+
26
+ function walk(prev: unknown, next: unknown, seen: WeakSet<object>): unknown {
27
+ if (Object.is(prev, next)) return prev
28
+ if (prev === null || next === null) return next
29
+ if (typeof prev !== 'object' || typeof next !== 'object') return next
30
+
31
+ // Cycle guard. If either side is already on the in-flight stack, we can't
32
+ // safely recurse — fall back to `next`'s ref. Real cyclic payloads are
33
+ // exceedingly rare in HTTP responses; defensive bail.
34
+ if (seen.has(prev as object) || seen.has(next as object)) return next
35
+
36
+ // Arrays — only matched against arrays.
37
+ if (Array.isArray(prev) && Array.isArray(next)) {
38
+ return walkArray(prev, next, seen)
39
+ }
40
+ if (Array.isArray(prev) !== Array.isArray(next)) return next
41
+
42
+ // Constructor / prototype check. Plain objects have `Object.prototype`
43
+ // (and a Map/Set/Date/RegExp/class instance does not). We require an exact
44
+ // prototype match on both sides AND `Object.prototype` so we never deep-
45
+ // walk into class instances whose identity might encode hidden state.
46
+ const prevProto = Object.getPrototypeOf(prev)
47
+ if (prevProto !== Object.getPrototypeOf(next)) return next
48
+ if (prevProto !== Object.prototype && prevProto !== null) return next
49
+
50
+ return walkPlainObject(prev as Record<string, unknown>, next as Record<string, unknown>, seen)
51
+ }
52
+
53
+ function walkArray(
54
+ prev: ReadonlyArray<unknown>,
55
+ next: ReadonlyArray<unknown>,
56
+ seen: WeakSet<object>,
57
+ ): ReadonlyArray<unknown> {
58
+ if (prev.length !== next.length) {
59
+ // Length changed — we can still preserve refs for matching prefixes via
60
+ // index-aligned walking. That's the right trade-off for tables / lists:
61
+ // appending an item keeps the head's refs stable, prepending invalidates
62
+ // everything (which it does anyway — items shifted).
63
+ }
64
+ seen.add(prev)
65
+ seen.add(next)
66
+ try {
67
+ const out: unknown[] = new Array(next.length)
68
+ let changed = next.length !== prev.length
69
+ for (let i = 0; i < next.length; i++) {
70
+ const prevItem = i < prev.length ? prev[i] : undefined
71
+ const shared = walk(prevItem, next[i], seen)
72
+ out[i] = shared
73
+ if (shared !== prev[i]) changed = true
74
+ }
75
+ if (!changed) return prev
76
+ return out
77
+ } finally {
78
+ seen.delete(prev)
79
+ seen.delete(next)
80
+ }
81
+ }
82
+
83
+ function walkPlainObject(
84
+ prev: Record<string, unknown>,
85
+ next: Record<string, unknown>,
86
+ seen: WeakSet<object>,
87
+ ): Record<string, unknown> {
88
+ const prevKeys = Object.keys(prev)
89
+ const nextKeys = Object.keys(next)
90
+ let changed = prevKeys.length !== nextKeys.length
91
+
92
+ seen.add(prev)
93
+ seen.add(next)
94
+ try {
95
+ const out: Record<string, unknown> = {}
96
+ // Iterate `next`'s keys in order so the output preserves payload's
97
+ // key ordering (matters for downstream `JSON.stringify` callers and
98
+ // for predictable React reconciliation when an object is rendered).
99
+ for (const key of nextKeys) {
100
+ const shared = walk(prev[key], next[key], seen)
101
+ out[key] = shared
102
+ if (shared !== prev[key]) changed = true
103
+ else if (!(key in prev)) changed = true
104
+ }
105
+ // Keys present in `prev` but not in `next` are dropped — that's already
106
+ // expressed by `next.keys`. But the length-mismatch flag above catches
107
+ // the changed shape.
108
+ if (!changed) return prev
109
+ return out
110
+ } finally {
111
+ seen.delete(prev)
112
+ seen.delete(next)
113
+ }
114
+ }
@@ -159,10 +159,23 @@ export type QuerySubscription<T> = AsyncState<T>
159
159
 
160
160
  /**
161
161
  * Options passed to `ctx.use(query, opts)` to control the subscription
162
- * (reactive key, enabled-gating). The `key` thunk reads signals — re-evaluating
163
- * when they change re-keys the subscription.
162
+ * (reactive key, enabled-gating). The `key` thunk reads signals —
163
+ * re-evaluating when they change re-keys the subscription.
164
+ *
165
+ * A `select` projection that maps the underlying data shape to a view
166
+ * shape is accepted via a dedicated overload on `Ctx.use` rather than this
167
+ * options bag — the overload threads `T → U` types through cleanly.
164
168
  */
165
169
  export type UseOptions<Args extends readonly unknown[]> = {
166
170
  key?: () => Args
167
171
  enabled?: () => boolean
168
172
  }
173
+
174
+ /**
175
+ * Internal shape — what `createUse` accepts. Includes the optional `select`
176
+ * field used by the `select` overload on `Ctx.use`. Not exported on the
177
+ * public surface; consumers use the typed overload.
178
+ */
179
+ export type UseInternalOptions<Args extends readonly unknown[], T, U> = UseOptions<Args> & {
180
+ select?: (data: T) => U
181
+ }
package/src/query/use.ts CHANGED
@@ -2,17 +2,24 @@ import { computed, effect, type Signal, signal, untracked } from '../signals'
2
2
  import type { ReadSignal } from '../signals/types'
3
3
  import type { ClientEntry, InfiniteClientEntry, QueryClient } from './client'
4
4
  import type { InfiniteQuery, InfiniteQuerySpec, InfiniteQuerySubscription } from './infinite'
5
- import type { AsyncStatus, Query, QuerySpec, QuerySubscription, UseOptions } from './types'
5
+ import type {
6
+ AsyncStatus,
7
+ Query,
8
+ QuerySpec,
9
+ QuerySubscription,
10
+ UseInternalOptions,
11
+ UseOptions,
12
+ } from './types'
6
13
 
7
14
  type QueryInternal<Args extends unknown[], T> = Query<Args, T> & {
8
15
  readonly __spec: QuerySpec<Args, T>
9
16
  }
10
17
 
11
- class SubscriptionImpl<T> implements QuerySubscription<T> {
18
+ class SubscriptionImpl<T, U = T> implements QuerySubscription<U> {
12
19
  private readonly current$: Signal<ClientEntry<T> | null> = signal(null)
13
20
  private readonly previousData$: Signal<T | undefined> = signal(undefined)
14
21
 
15
- readonly data: ReadSignal<T | undefined>
22
+ readonly data: ReadSignal<U | undefined>
16
23
  readonly error: ReadSignal<unknown | undefined>
17
24
  readonly status: ReadSignal<AsyncStatus>
18
25
  readonly isLoading: ReadSignal<boolean>
@@ -21,14 +28,30 @@ class SubscriptionImpl<T> implements QuerySubscription<T> {
21
28
  readonly lastUpdatedAt: ReadSignal<number | undefined>
22
29
  readonly hasPendingMutations: ReadSignal<boolean>
23
30
 
24
- constructor(private readonly keepPreviousData: boolean) {
25
- this.data = computed(() => {
31
+ constructor(
32
+ private readonly keepPreviousData: boolean,
33
+ private readonly select?: (data: T) => U,
34
+ ) {
35
+ // The underlying entry stores `T`. The subscription's `data` is `U`
36
+ // (or `T` when no projection). We compute the raw `T` once, then layer
37
+ // `select` in a second computed so the projection's `Object.is` dedup
38
+ // applies BEFORE downstream subscribers run — combined with structural
39
+ // sharing on the entry, an unchanged payload + a stable `select`
40
+ // outputs the same `U` reference and doesn't churn the React tree.
41
+ const rawData = computed(() => {
26
42
  const cur = this.current$.value
27
43
  const curData = cur?.entry.data.value
28
44
  if (curData !== undefined) return curData
29
45
  if (keepPreviousData) return this.previousData$.value
30
46
  return undefined
31
47
  })
48
+ this.data =
49
+ select === undefined
50
+ ? (rawData as unknown as ReadSignal<U | undefined>)
51
+ : computed<U | undefined>(() => {
52
+ const raw = rawData.value
53
+ return raw === undefined ? undefined : select(raw)
54
+ })
32
55
  this.error = computed(() => this.current$.value?.entry.error.value)
33
56
  this.status = computed<AsyncStatus>(() => this.current$.value?.entry.status.value ?? 'idle')
34
57
  this.isLoading = computed(() => {
@@ -59,33 +82,42 @@ class SubscriptionImpl<T> implements QuerySubscription<T> {
59
82
  this.current$.set(null)
60
83
  }
61
84
 
62
- refetch = (): Promise<T> => {
85
+ refetch = (): Promise<U> => {
63
86
  const cur = this.current$.peek()
64
87
  if (!cur) return Promise.reject(new Error('[olas] no active subscription'))
65
- return cur.entry.refetch()
88
+ return cur.entry.refetch().then((v) => this.project(v))
66
89
  }
67
90
 
68
91
  reset = (): void => {
69
92
  this.current$.peek()?.entry.reset()
70
93
  }
71
94
 
72
- firstValue = (): Promise<T> => {
95
+ firstValue = (): Promise<U> => {
73
96
  const cur = this.current$.peek()
74
97
  if (!cur) return Promise.reject(new Error('[olas] no active subscription'))
75
- return cur.entry.firstValue()
98
+ return cur.entry.firstValue().then((v) => this.project(v))
99
+ }
100
+
101
+ private project(v: T): U {
102
+ return this.select === undefined ? (v as unknown as U) : this.select(v)
76
103
  }
77
104
  }
78
105
 
79
106
  /**
80
107
  * Build a subscription + the effect that keeps it bound to the right entry.
81
108
  * The controller container wires the disposer into the lifecycle.
109
+ *
110
+ * `keyOrOptions` may carry an optional `select` projection that maps the
111
+ * underlying `T` to a view `U`; the returned subscription's data shape
112
+ * widens accordingly. Without `select`, `U = T` and the projection
113
+ * computed is skipped.
82
114
  */
83
- export function createUse<Args extends unknown[], T>(
115
+ export function createUse<Args extends unknown[], T, U = T>(
84
116
  client: QueryClient,
85
117
  query: Query<Args, T>,
86
- keyOrOptions?: (() => Args) | UseOptions<Args>,
118
+ keyOrOptions?: (() => Args) | UseInternalOptions<Args, T, U>,
87
119
  ): {
88
- subscription: QuerySubscription<T>
120
+ subscription: QuerySubscription<U>
89
121
  dispose: () => void
90
122
  /** Suspend the subscription — release the entry (its refetchInterval +
91
123
  * focus/online listeners pause) without disposing it. Spec §4.1. */
@@ -100,8 +132,10 @@ export function createUse<Args extends unknown[], T>(
100
132
  const keyFn = typeof keyOrOptions === 'function' ? keyOrOptions : keyOrOptions?.key
101
133
  const enabledFn =
102
134
  typeof keyOrOptions === 'object' && keyOrOptions !== null ? keyOrOptions.enabled : undefined
135
+ const select =
136
+ typeof keyOrOptions === 'object' && keyOrOptions !== null ? keyOrOptions.select : undefined
103
137
 
104
- const sub = new SubscriptionImpl<T>(keepPreviousData)
138
+ const sub = new SubscriptionImpl<T, U>(keepPreviousData, select)
105
139
  let currentEntry: ClientEntry<T> | null = null
106
140
  let suspended = false
107
141
 
@@ -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
  }
package/src/testing.ts CHANGED
@@ -47,6 +47,7 @@ export function fakeField<T>(
47
47
  reset: () => void
48
48
  markTouched: () => void
49
49
  revalidate: () => Promise<boolean>
50
+ setErrors: (errors: ReadonlyArray<string>) => void
50
51
  dispose: () => void
51
52
  }>,
52
53
  ): Field<T> {
@@ -85,6 +86,7 @@ export function fakeField<T>(
85
86
  reset: overrides?.reset ?? (() => value$.set(currentInitial)),
86
87
  markTouched: overrides?.markTouched ?? (() => touched$.set(true)),
87
88
  revalidate: overrides?.revalidate ?? (async () => errors$.peek().length === 0),
89
+ setErrors: overrides?.setErrors ?? ((errs) => errors$.set([...errs])),
88
90
  dispose: overrides?.dispose ?? (() => {}),
89
91
  }
90
92
  return fake
@@ -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,14 +1,18 @@
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
  }
14
18