@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.
- package/dist/index.cjs +72 -10
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +72 -12
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +72 -12
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +71 -11
- package/dist/index.mjs.map +1 -1
- package/dist/{root-BCZDC5Fv.mjs → root-Cnkb3I--.mjs} +556 -28
- package/dist/root-Cnkb3I--.mjs.map +1 -0
- package/dist/{root-DXV1gVbQ.cjs → root-D_xAdcom.cjs} +556 -28
- package/dist/root-D_xAdcom.cjs.map +1 -0
- package/dist/testing.cjs +2 -1
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +2 -1
- package/dist/testing.d.cts.map +1 -1
- package/dist/testing.d.mts +2 -1
- package/dist/testing.d.mts.map +1 -1
- package/dist/testing.mjs +2 -1
- package/dist/testing.mjs.map +1 -1
- package/dist/{types-CffZ1QXt.d.cts → types-CRn4UoLn.d.mts} +196 -8
- package/dist/types-CRn4UoLn.d.mts.map +1 -0
- package/dist/{types-DSlDowpE.d.mts → types-r_TVaRkD.d.cts} +196 -8
- package/dist/types-r_TVaRkD.d.cts.map +1 -0
- package/package.json +1 -1
- package/src/controller/index.ts +6 -0
- package/src/controller/instance.ts +317 -3
- package/src/controller/types.ts +151 -0
- package/src/emitter.ts +34 -3
- package/src/forms/field.ts +42 -9
- package/src/forms/form-types.ts +37 -0
- package/src/forms/form.ts +165 -5
- package/src/forms/index.ts +12 -1
- package/src/forms/standard-schema.ts +37 -0
- package/src/forms/validators.ts +31 -0
- package/src/index.ts +20 -4
- package/src/query/entry.ts +10 -3
- package/src/query/infinite.ts +8 -1
- package/src/query/structural-share.ts +114 -0
- package/src/query/types.ts +15 -2
- package/src/query/use.ts +47 -13
- package/src/signals/readonly.ts +3 -3
- package/src/testing.ts +2 -0
- package/src/timing/debounced.ts +24 -4
- package/src/timing/throttled.ts +22 -3
- package/src/utils.ts +8 -4
- package/dist/root-BCZDC5Fv.mjs.map +0 -1
- package/dist/root-DXV1gVbQ.cjs.map +0 -1
- package/dist/types-CffZ1QXt.d.cts.map +0 -1
- package/dist/types-DSlDowpE.d.mts.map +0 -1
package/src/forms/validators.ts
CHANGED
|
@@ -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 {
|
|
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,
|
package/src/query/entry.ts
CHANGED
|
@@ -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(
|
|
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?.(
|
|
199
|
-
return
|
|
205
|
+
this.onSuccessData?.(shared)
|
|
206
|
+
return shared
|
|
200
207
|
}
|
|
201
208
|
|
|
202
209
|
private applyFailure(err: unknown): never {
|
package/src/query/infinite.ts
CHANGED
|
@@ -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([
|
|
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
|
+
}
|
package/src/query/types.ts
CHANGED
|
@@ -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 —
|
|
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 {
|
|
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<
|
|
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<
|
|
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(
|
|
25
|
-
|
|
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<
|
|
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<
|
|
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) |
|
|
118
|
+
keyOrOptions?: (() => Args) | UseInternalOptions<Args, T, U>,
|
|
87
119
|
): {
|
|
88
|
-
subscription: QuerySubscription<
|
|
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
|
|
package/src/signals/readonly.ts
CHANGED
|
@@ -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
|
package/src/timing/debounced.ts
CHANGED
|
@@ -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
|
-
*
|
|
9
|
-
*
|
|
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>(
|
|
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
|
}
|
package/src/timing/throttled.ts
CHANGED
|
@@ -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
|
-
*
|
|
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>(
|
|
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`
|
|
3
|
-
*
|
|
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
|
|
6
|
-
*
|
|
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
|
|