@kontsedal/olas-core 0.0.1-rc.0 → 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 +28 -2
- 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/controller/root.ts
CHANGED
|
@@ -48,7 +48,15 @@ export function createRootWithProps<Props, Api, TDeps extends Record<string, unk
|
|
|
48
48
|
)
|
|
49
49
|
|
|
50
50
|
// Bootstrap failure throws straight out of createRoot. Spec §12.1.5.
|
|
51
|
-
|
|
51
|
+
// Tear down the QueryClient and any plugins it spawned (window/storage
|
|
52
|
+
// listeners, transports) before re-throwing so the failure doesn't leak.
|
|
53
|
+
let api: Api
|
|
54
|
+
try {
|
|
55
|
+
api = instance.construct(getFactory(def), props)
|
|
56
|
+
} catch (err) {
|
|
57
|
+
queryClient.dispose()
|
|
58
|
+
throw err
|
|
59
|
+
}
|
|
52
60
|
|
|
53
61
|
if (typeof api !== 'object' || api === null) {
|
|
54
62
|
// Allow primitive APIs in principle but root controls must live somewhere.
|
package/src/controller/types.ts
CHANGED
|
@@ -127,18 +127,28 @@ export type Ctx<TDeps = AmbientDeps> = {
|
|
|
127
127
|
): Api
|
|
128
128
|
|
|
129
129
|
/**
|
|
130
|
-
* Like `child(...)` but additionally returns a
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
130
|
+
* Like `child(...)` but additionally returns a handle that lets the parent
|
|
131
|
+
* control the attached sub-tree's lifecycle independently — `dispose()`
|
|
132
|
+
* tears it down early, and `suspend()` / `resume()` freeze and thaw it.
|
|
133
|
+
* The child is still disposed automatically when the parent disposes;
|
|
134
|
+
* `dispose()` / `suspend()` / `resume()` are idempotent.
|
|
135
|
+
*
|
|
136
|
+
* `<KeepAlive controller={…}>` in `@kontsedal/olas-react` consumes the
|
|
137
|
+
* returned `{ suspend, resume }` directly — no hand-rolled `isPaused`
|
|
138
|
+
* signal needed on the child's `Api`. Useful for "openable" sub-
|
|
139
|
+
* controllers driven by a user gesture (modal, side panel, wizard).
|
|
140
|
+
*
|
|
141
|
+
* `suspend()` cascades through the attached controller's lifecycle
|
|
142
|
+
* entries: cache subscriptions pause `refetchInterval` and release the
|
|
143
|
+
* entry, effects are torn down, `onSuspend(...)` handlers fire. `resume()`
|
|
144
|
+
* re-runs effects, re-acquires cache entries (a stale entry refetches),
|
|
145
|
+
* and fires `onResume(...)`. Spec §4.1, §16.5.
|
|
136
146
|
*/
|
|
137
147
|
attach<Props, Api>(
|
|
138
148
|
def: ControllerDef<Props, Api>,
|
|
139
149
|
props: Props,
|
|
140
150
|
options?: { deps?: Partial<TDeps> },
|
|
141
|
-
): { api: Api; dispose: () => void }
|
|
151
|
+
): { api: Api; dispose: () => void; suspend: () => void; resume: () => void }
|
|
142
152
|
|
|
143
153
|
effect(fn: () => void | (() => void)): void
|
|
144
154
|
|
package/src/forms/field.ts
CHANGED
|
@@ -23,6 +23,17 @@ export type FieldDevtoolsOwner = {
|
|
|
23
23
|
emitter: DevtoolsEmitter
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Optional reporter for synchronous validator throws — wired in by `ctx.field`
|
|
28
|
+
* (and `createForm` for leaf fields inside a form) so a thrown validator
|
|
29
|
+
* doesn't escape the signal effect silently. Without this, a buggy validator
|
|
30
|
+
* just stops contributing to `errors` and the field reads as "valid" while
|
|
31
|
+
* silently broken. With it, the throw is routed through `root.onError` as
|
|
32
|
+
* `kind: 'effect'` AND the throw's message lands in the field's `errors`
|
|
33
|
+
* array so the UI surfaces the problem.
|
|
34
|
+
*/
|
|
35
|
+
export type ValidatorErrorReporter = (err: unknown) => void
|
|
36
|
+
|
|
26
37
|
class FieldImpl<T> implements Field<T> {
|
|
27
38
|
private readonly value$: Signal<T>
|
|
28
39
|
private readonly errors$: Signal<string[]>
|
|
@@ -41,10 +52,20 @@ class FieldImpl<T> implements Field<T> {
|
|
|
41
52
|
private runId = 0
|
|
42
53
|
private disposed = false
|
|
43
54
|
private devtoolsOwner: FieldDevtoolsOwner | null = null
|
|
55
|
+
private onValidatorError: ValidatorErrorReporter | null = null
|
|
44
56
|
|
|
45
|
-
constructor(
|
|
57
|
+
constructor(
|
|
58
|
+
initial: T,
|
|
59
|
+
validators: ReadonlyArray<Validator<T>> = [],
|
|
60
|
+
options?: { onValidatorError?: ValidatorErrorReporter },
|
|
61
|
+
) {
|
|
46
62
|
this.initial = initial
|
|
47
63
|
this.validators = validators
|
|
64
|
+
// Capture the reporter BEFORE the validator effect kicks off so a sync
|
|
65
|
+
// throw on the very first pass routes through `onError` instead of
|
|
66
|
+
// disappearing into the effect (`bindValidatorErrorReporter` is a
|
|
67
|
+
// post-construct hook so it can't catch the first run).
|
|
68
|
+
this.onValidatorError = options?.onValidatorError ?? null
|
|
48
69
|
this.value$ = signal(initial)
|
|
49
70
|
this.errors$ = signal<string[]>([])
|
|
50
71
|
this.touched$ = signal(false)
|
|
@@ -60,6 +81,14 @@ class FieldImpl<T> implements Field<T> {
|
|
|
60
81
|
}
|
|
61
82
|
}
|
|
62
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Internal hook for `ctx.field` / `createForm` to route synchronous
|
|
86
|
+
* validator throws through `root.onError`. See `ValidatorErrorReporter`.
|
|
87
|
+
*/
|
|
88
|
+
bindValidatorErrorReporter(reporter: ValidatorErrorReporter | null): void {
|
|
89
|
+
this.onValidatorError = reporter
|
|
90
|
+
}
|
|
91
|
+
|
|
63
92
|
// --- ReadSignal<T> ---
|
|
64
93
|
get value(): T {
|
|
65
94
|
return this.value$.value
|
|
@@ -207,11 +236,27 @@ class FieldImpl<T> implements Field<T> {
|
|
|
207
236
|
const asyncPromises: Promise<string | null>[] = []
|
|
208
237
|
|
|
209
238
|
for (const validator of this.validators) {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
239
|
+
try {
|
|
240
|
+
const result = validator(value, abort.signal)
|
|
241
|
+
if (result instanceof Promise) {
|
|
242
|
+
// Defend against the validator promise rejecting *synchronously*
|
|
243
|
+
// with a thrown error (rare but legal) — the catch-handler in
|
|
244
|
+
// `Promise.allSettled` covers true async rejection.
|
|
245
|
+
asyncPromises.push(result)
|
|
246
|
+
} else if (result != null) {
|
|
247
|
+
syncErrors.push(result)
|
|
248
|
+
}
|
|
249
|
+
} catch (err) {
|
|
250
|
+
// A buggy validator that throws synchronously: surface it twice.
|
|
251
|
+
// (1) Route through `onError` so the user knows something is wrong.
|
|
252
|
+
// (2) Convert to a validation error string so the field reads invalid
|
|
253
|
+
// until the bug is fixed (don't pretend everything's OK).
|
|
254
|
+
try {
|
|
255
|
+
this.onValidatorError?.(err)
|
|
256
|
+
} catch {
|
|
257
|
+
// The reporter must not propagate.
|
|
258
|
+
}
|
|
259
|
+
syncErrors.push(err instanceof Error ? err.message : String(err))
|
|
215
260
|
}
|
|
216
261
|
}
|
|
217
262
|
|
|
@@ -270,8 +315,28 @@ export function bindFieldDevtoolsOwner<T>(field: Field<T>, owner: FieldDevtoolsO
|
|
|
270
315
|
}
|
|
271
316
|
}
|
|
272
317
|
|
|
273
|
-
|
|
274
|
-
|
|
318
|
+
/**
|
|
319
|
+
* Internal — install a synchronous-validator-throw reporter on a `Field`
|
|
320
|
+
* (matched structurally to keep the public `Field<T>` surface stable).
|
|
321
|
+
* Called by `ctx.field` and `bindTreeToDevtools` so leaves inside a form/
|
|
322
|
+
* field-array tree get the same reporting as a standalone field.
|
|
323
|
+
*/
|
|
324
|
+
export function bindFieldValidatorErrorReporter<T>(
|
|
325
|
+
field: Field<T>,
|
|
326
|
+
reporter: ValidatorErrorReporter | null,
|
|
327
|
+
): void {
|
|
328
|
+
const impl = field as { bindValidatorErrorReporter?: (r: ValidatorErrorReporter | null) => void }
|
|
329
|
+
if (typeof impl.bindValidatorErrorReporter === 'function') {
|
|
330
|
+
impl.bindValidatorErrorReporter(reporter)
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export function createField<T>(
|
|
335
|
+
initial: T,
|
|
336
|
+
validators?: ReadonlyArray<Validator<T>>,
|
|
337
|
+
options?: { onValidatorError?: ValidatorErrorReporter },
|
|
338
|
+
): Field<T> {
|
|
339
|
+
return new FieldImpl(initial, validators, options)
|
|
275
340
|
}
|
|
276
341
|
|
|
277
342
|
/**
|
package/src/forms/form-types.ts
CHANGED
|
@@ -45,8 +45,24 @@ export type FormValidator<S extends FormSchema> = Validator<FormValue<S>>
|
|
|
45
45
|
export type FieldArrayValidator<I> = Validator<FieldArrayValue<I>>
|
|
46
46
|
|
|
47
47
|
export type FormOptions<S extends FormSchema> = {
|
|
48
|
+
/**
|
|
49
|
+
* Initial values for the form. A function form is **tracked** — if the
|
|
50
|
+
* function reads reactive signals (e.g. a query's `data`), the form re-seats
|
|
51
|
+
* itself when those signals change, but only while the form is not dirty
|
|
52
|
+
* (so a user mid-edit isn't clobbered by a background refetch). See
|
|
53
|
+
* `resetOnInitialChange` for opt-out. Spec §8.4.
|
|
54
|
+
*/
|
|
48
55
|
initial?: (() => DeepPartial<FormValue<S>> | undefined) | DeepPartial<FormValue<S>>
|
|
49
56
|
validators?: FormValidator<S>[]
|
|
57
|
+
/**
|
|
58
|
+
* When `initial` is a function and one of its tracked deps changes:
|
|
59
|
+
* - `'when-clean'` (default) — re-seat only if the form is not dirty.
|
|
60
|
+
* - `'never'` — never re-seat; `initial()` runs once at construction.
|
|
61
|
+
* - `'always'` — re-seat unconditionally (dirty state is discarded).
|
|
62
|
+
*
|
|
63
|
+
* Spec §20.7.
|
|
64
|
+
*/
|
|
65
|
+
resetOnInitialChange?: 'when-clean' | 'never' | 'always'
|
|
50
66
|
}
|
|
51
67
|
|
|
52
68
|
export type FieldArrayOptions<I> = {
|
package/src/forms/form.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import type { Field } from '../controller/types'
|
|
2
2
|
import { batch, computed, effect, type Signal, signal, untracked } from '../signals'
|
|
3
3
|
import type { ReadSignal } from '../signals/types'
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
bindFieldDevtoolsOwner,
|
|
6
|
+
bindFieldValidatorErrorReporter,
|
|
7
|
+
createField,
|
|
8
|
+
type ValidatorErrorReporter,
|
|
9
|
+
} from './field'
|
|
5
10
|
import type {
|
|
6
11
|
DeepPartial,
|
|
7
12
|
FieldArray,
|
|
@@ -49,21 +54,59 @@ class FormImpl<S extends FormSchema> implements Form<S> {
|
|
|
49
54
|
private readonly validators: ReadonlyArray<FormValidator<S>>
|
|
50
55
|
private readonly options: FormOptions<S> | undefined
|
|
51
56
|
private validatorDispose: (() => void) | null = null
|
|
57
|
+
private initialDispose: (() => void) | null = null
|
|
52
58
|
private currentValidatorRun = 0
|
|
53
59
|
private currentValidatorAbort: AbortController | null = null
|
|
54
60
|
private disposed = false
|
|
61
|
+
private onValidatorError: ((err: unknown) => void) | null = null
|
|
55
62
|
|
|
56
|
-
|
|
63
|
+
/** Internal — wire a sync-throw reporter for the top-level validators. */
|
|
64
|
+
bindValidatorErrorReporter(reporter: ((err: unknown) => void) | null): void {
|
|
65
|
+
this.onValidatorError = reporter
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
constructor(
|
|
69
|
+
schema: S,
|
|
70
|
+
options?: FormOptions<S>,
|
|
71
|
+
internalOptions?: { onValidatorError?: (err: unknown) => void },
|
|
72
|
+
) {
|
|
57
73
|
this.fields = schema
|
|
58
74
|
this.options = options
|
|
59
75
|
this.validators = options?.validators ?? []
|
|
60
|
-
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
76
|
+
// Capture reporter BEFORE the top-level validator effect kicks off in
|
|
77
|
+
// this constructor — mirrors the FieldImpl fix.
|
|
78
|
+
this.onValidatorError = internalOptions?.onValidatorError ?? null
|
|
79
|
+
|
|
80
|
+
// Initial values — supports both the static shape and the tracked-function
|
|
81
|
+
// shape from spec §8.4. For the function form, wrap in an effect so a
|
|
82
|
+
// change to any tracked signal re-seats the form (subject to the dirty
|
|
83
|
+
// guard from `resetOnInitialChange`).
|
|
64
84
|
if (options?.initial !== undefined) {
|
|
65
|
-
|
|
66
|
-
|
|
85
|
+
if (typeof options.initial === 'function') {
|
|
86
|
+
const initialFn = options.initial
|
|
87
|
+
const mode = options.resetOnInitialChange ?? 'when-clean'
|
|
88
|
+
let firstRun = true
|
|
89
|
+
this.initialDispose = effect(() => {
|
|
90
|
+
// Track signals read by `initialFn`. The dirty-guard MUST run
|
|
91
|
+
// untracked — otherwise `isDirty` would become a dep and re-seating
|
|
92
|
+
// on user input would cascade.
|
|
93
|
+
const ini = initialFn()
|
|
94
|
+
if (ini === undefined) return
|
|
95
|
+
untracked(() => {
|
|
96
|
+
if (this.disposed) return
|
|
97
|
+
if (firstRun) {
|
|
98
|
+
firstRun = false
|
|
99
|
+
this.applyPartial(ini as DeepPartial<FormValue<S>>, true)
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
if (mode === 'never') return
|
|
103
|
+
if (mode === 'when-clean' && this.isDirty.peek()) return
|
|
104
|
+
this.applyPartial(ini as DeepPartial<FormValue<S>>, true)
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
} else {
|
|
108
|
+
this.applyPartial(options.initial as DeepPartial<FormValue<S>>, true)
|
|
109
|
+
}
|
|
67
110
|
}
|
|
68
111
|
|
|
69
112
|
this.value = computed(() => this.computeValue())
|
|
@@ -145,6 +188,10 @@ class FormImpl<S extends FormSchema> implements Form<S> {
|
|
|
145
188
|
for (const [k, val] of Object.entries(partial)) {
|
|
146
189
|
const child = (this.fields as Record<string, unknown>)[k]
|
|
147
190
|
if (!child) continue
|
|
191
|
+
// `partial.someNestedForm === undefined` means "leave this subtree
|
|
192
|
+
// alone", not "reset it with undefined" — which would crash on
|
|
193
|
+
// `Object.entries(undefined)`.
|
|
194
|
+
if (val === undefined) continue
|
|
148
195
|
if (isForm(child)) {
|
|
149
196
|
// Nested form: recurse via its own `set` (user) or rebuild via reset
|
|
150
197
|
// through the same `applyPartial`-with-`asInitial` flag (initial).
|
|
@@ -214,6 +261,12 @@ class FormImpl<S extends FormSchema> implements Form<S> {
|
|
|
214
261
|
}
|
|
215
262
|
}
|
|
216
263
|
await Promise.all(tasks)
|
|
264
|
+
// Kick a fresh top-level run so the surface matches "re-run every
|
|
265
|
+
// validator" — without this, `validate()` would skip top-level if it
|
|
266
|
+
// settled before the call and the value hasn't tracked-changed since.
|
|
267
|
+
if (this.validators.length > 0) {
|
|
268
|
+
this.runTopLevelValidators()
|
|
269
|
+
}
|
|
217
270
|
// Wait for top-level validators to finish.
|
|
218
271
|
if (this.topLevelValidating$.peek()) {
|
|
219
272
|
await new Promise<void>((resolve) => {
|
|
@@ -232,6 +285,7 @@ class FormImpl<S extends FormSchema> implements Form<S> {
|
|
|
232
285
|
if (this.disposed) return
|
|
233
286
|
this.disposed = true
|
|
234
287
|
this.validatorDispose?.()
|
|
288
|
+
this.initialDispose?.()
|
|
235
289
|
this.currentValidatorAbort?.abort()
|
|
236
290
|
for (const child of Object.values(this.fields)) {
|
|
237
291
|
;(child as { dispose?: () => void }).dispose?.()
|
|
@@ -249,9 +303,18 @@ class FormImpl<S extends FormSchema> implements Form<S> {
|
|
|
249
303
|
const syncErrors: string[] = []
|
|
250
304
|
const asyncPromises: Promise<string | null>[] = []
|
|
251
305
|
for (const v of this.validators) {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
306
|
+
try {
|
|
307
|
+
const r = v(value, abort.signal)
|
|
308
|
+
if (r instanceof Promise) asyncPromises.push(r)
|
|
309
|
+
else if (r != null) syncErrors.push(r)
|
|
310
|
+
} catch (err) {
|
|
311
|
+
try {
|
|
312
|
+
this.onValidatorError?.(err)
|
|
313
|
+
} catch {
|
|
314
|
+
// The reporter must not propagate.
|
|
315
|
+
}
|
|
316
|
+
syncErrors.push(err instanceof Error ? err.message : String(err))
|
|
317
|
+
}
|
|
255
318
|
}
|
|
256
319
|
|
|
257
320
|
if (syncErrors.length > 0) {
|
|
@@ -346,10 +409,21 @@ class FieldArrayImpl<I extends Field<any> | Form<any>> implements FieldArray<I>
|
|
|
346
409
|
private currentValidatorAbort: AbortController | null = null
|
|
347
410
|
private validatorDispose: (() => void) | null = null
|
|
348
411
|
private disposed = false
|
|
412
|
+
private onValidatorError: ((err: unknown) => void) | null = null
|
|
349
413
|
|
|
350
|
-
|
|
414
|
+
/** Internal — see `FormImpl.bindValidatorErrorReporter`. */
|
|
415
|
+
bindValidatorErrorReporter(reporter: ((err: unknown) => void) | null): void {
|
|
416
|
+
this.onValidatorError = reporter
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
constructor(
|
|
420
|
+
itemFactory: (initial?: ItemInitial<I>) => I,
|
|
421
|
+
options?: FieldArrayOptions<I>,
|
|
422
|
+
internalOptions?: { onValidatorError?: (err: unknown) => void },
|
|
423
|
+
) {
|
|
351
424
|
this.itemFactory = itemFactory
|
|
352
425
|
this.validators = options?.validators ?? []
|
|
426
|
+
this.onValidatorError = internalOptions?.onValidatorError ?? null
|
|
353
427
|
this.items$ = signal<I[]>([])
|
|
354
428
|
if (options?.initial) {
|
|
355
429
|
this.initialItems = options.initial
|
|
@@ -480,6 +554,10 @@ class FieldArrayImpl<I extends Field<any> | Form<any>> implements FieldArray<I>
|
|
|
480
554
|
else tasks.push((item as Field<unknown>).revalidate())
|
|
481
555
|
}
|
|
482
556
|
await Promise.all(tasks)
|
|
557
|
+
// Fresh top-level run — see `FormImpl.validate` for the rationale.
|
|
558
|
+
if (this.validators.length > 0) {
|
|
559
|
+
this.runTopLevelValidators()
|
|
560
|
+
}
|
|
483
561
|
if (this.topLevelValidating$.peek()) {
|
|
484
562
|
await new Promise<void>((resolve) => {
|
|
485
563
|
const unsub = this.topLevelValidating$.subscribe((v) => {
|
|
@@ -514,9 +592,18 @@ class FieldArrayImpl<I extends Field<any> | Form<any>> implements FieldArray<I>
|
|
|
514
592
|
const syncErrors: string[] = []
|
|
515
593
|
const asyncPromises: Promise<string | null>[] = []
|
|
516
594
|
for (const v of this.validators) {
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
595
|
+
try {
|
|
596
|
+
const r = v(value, abort.signal)
|
|
597
|
+
if (r instanceof Promise) asyncPromises.push(r)
|
|
598
|
+
else if (r != null) syncErrors.push(r)
|
|
599
|
+
} catch (err) {
|
|
600
|
+
try {
|
|
601
|
+
this.onValidatorError?.(err)
|
|
602
|
+
} catch {
|
|
603
|
+
// The reporter must not propagate.
|
|
604
|
+
}
|
|
605
|
+
syncErrors.push(err instanceof Error ? err.message : String(err))
|
|
606
|
+
}
|
|
520
607
|
}
|
|
521
608
|
|
|
522
609
|
if (syncErrors.length > 0) {
|
|
@@ -554,15 +641,20 @@ class FieldArrayImpl<I extends Field<any> | Form<any>> implements FieldArray<I>
|
|
|
554
641
|
}
|
|
555
642
|
}
|
|
556
643
|
|
|
557
|
-
export function createForm<S extends FormSchema>(
|
|
558
|
-
|
|
644
|
+
export function createForm<S extends FormSchema>(
|
|
645
|
+
schema: S,
|
|
646
|
+
options?: FormOptions<S>,
|
|
647
|
+
internalOptions?: { onValidatorError?: (err: unknown) => void },
|
|
648
|
+
): Form<S> {
|
|
649
|
+
return new FormImpl(schema, options, internalOptions)
|
|
559
650
|
}
|
|
560
651
|
|
|
561
652
|
export function createFieldArray<I extends Field<any> | Form<any>>(
|
|
562
653
|
itemFactory: (initial?: ItemInitial<I>) => I,
|
|
563
654
|
options?: FieldArrayOptions<I>,
|
|
655
|
+
internalOptions?: { onValidatorError?: (err: unknown) => void },
|
|
564
656
|
): FieldArray<I> {
|
|
565
|
-
return new FieldArrayImpl<I>(itemFactory, options)
|
|
657
|
+
return new FieldArrayImpl<I>(itemFactory, options, internalOptions)
|
|
566
658
|
}
|
|
567
659
|
|
|
568
660
|
/**
|
|
@@ -614,16 +706,40 @@ function bindTreeToDevtoolsInto(
|
|
|
614
706
|
}
|
|
615
707
|
if (isFieldArray(node)) {
|
|
616
708
|
// Re-bind on every items change so dynamically-added entries get tracked.
|
|
617
|
-
//
|
|
618
|
-
//
|
|
709
|
+
// Each re-bind has its own disposer set scoped to that pass; on the next
|
|
710
|
+
// items change we flush the previous pass's disposers BEFORE creating the
|
|
711
|
+
// new effects, so a churning array doesn't accumulate reactive work.
|
|
712
|
+
// (Pre-fix, every items mutation appended fresh effects to the outer
|
|
713
|
+
// `disposers` array and never released the old ones.)
|
|
619
714
|
const arr = node as FieldArray<Field<unknown> | Form<FormSchema>>
|
|
715
|
+
let perPass: Array<() => void> = []
|
|
620
716
|
const stop = effect(() => {
|
|
621
717
|
const items = arr.items.value
|
|
718
|
+
// Flush previous pass before rebinding the new item set.
|
|
719
|
+
for (const d of perPass) {
|
|
720
|
+
try {
|
|
721
|
+
d()
|
|
722
|
+
} catch {
|
|
723
|
+
// Disposer failures must not break sibling cleanup.
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
perPass = []
|
|
622
727
|
items.forEach((item, idx) => {
|
|
623
|
-
bindTreeToDevtoolsInto(item, `${prefix}[${idx}]`, controllerPath, emitter,
|
|
728
|
+
bindTreeToDevtoolsInto(item, `${prefix}[${idx}]`, controllerPath, emitter, perPass)
|
|
624
729
|
})
|
|
625
730
|
})
|
|
626
731
|
disposers.push(stop)
|
|
732
|
+
// On final dispose, drain the per-pass disposers too.
|
|
733
|
+
disposers.push(() => {
|
|
734
|
+
for (const d of perPass) {
|
|
735
|
+
try {
|
|
736
|
+
d()
|
|
737
|
+
} catch {
|
|
738
|
+
// Ignore.
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
perPass = []
|
|
742
|
+
})
|
|
627
743
|
return
|
|
628
744
|
}
|
|
629
745
|
// Leaf Field.
|
|
@@ -634,6 +750,40 @@ function bindTreeToDevtoolsInto(
|
|
|
634
750
|
})
|
|
635
751
|
}
|
|
636
752
|
|
|
753
|
+
/**
|
|
754
|
+
* Walk a Form/FieldArray subtree and install `reporter` on every level —
|
|
755
|
+
* leaf fields, nested forms' top-level validators, and field-arrays' top-level
|
|
756
|
+
* validators. Called by `ctx.form` / `ctx.fieldArray` so synchronous validator
|
|
757
|
+
* throws anywhere in the tree route through `root.onError`. See
|
|
758
|
+
* `ValidatorErrorReporter` in `./field.ts`.
|
|
759
|
+
*/
|
|
760
|
+
export function bindTreeValidatorErrorReporter(
|
|
761
|
+
node: Field<unknown> | Form<FormSchema> | FieldArray<Field<unknown> | Form<FormSchema>>,
|
|
762
|
+
reporter: ValidatorErrorReporter | null,
|
|
763
|
+
): void {
|
|
764
|
+
if (isForm(node)) {
|
|
765
|
+
const impl = node as { bindValidatorErrorReporter?: (r: ValidatorErrorReporter | null) => void }
|
|
766
|
+
impl.bindValidatorErrorReporter?.(reporter)
|
|
767
|
+
for (const child of Object.values(node.fields)) {
|
|
768
|
+
bindTreeValidatorErrorReporter(child, reporter)
|
|
769
|
+
}
|
|
770
|
+
return
|
|
771
|
+
}
|
|
772
|
+
if (isFieldArray(node)) {
|
|
773
|
+
const impl = node as { bindValidatorErrorReporter?: (r: ValidatorErrorReporter | null) => void }
|
|
774
|
+
impl.bindValidatorErrorReporter?.(reporter)
|
|
775
|
+
// Items currently in the array. (Items added later won't get the reporter
|
|
776
|
+
// unless `ctx.fieldArray` is wrapped to rebind — but the leaf items in the
|
|
777
|
+
// typical pattern come from a user factory that constructs through
|
|
778
|
+
// `createField` and is bound here by the parent traversal.)
|
|
779
|
+
for (const item of node.items.value) {
|
|
780
|
+
bindTreeValidatorErrorReporter(item, reporter)
|
|
781
|
+
}
|
|
782
|
+
return
|
|
783
|
+
}
|
|
784
|
+
bindFieldValidatorErrorReporter(node as Field<unknown>, reporter)
|
|
785
|
+
}
|
|
786
|
+
|
|
637
787
|
// Quiet unused-import linter without exporting these symbols publicly.
|
|
638
788
|
void createField
|
|
639
789
|
void untracked
|
package/src/index.ts
CHANGED
|
@@ -44,6 +44,11 @@ export type {
|
|
|
44
44
|
InfiniteQuerySpec,
|
|
45
45
|
InfiniteQuerySubscription,
|
|
46
46
|
} from './query/infinite'
|
|
47
|
+
// Key hashing — exported so plugins (entities, etc.) that need a stable
|
|
48
|
+
// per-`keyArgs` index key reuse the canonical implementation instead of
|
|
49
|
+
// rolling their own ad-hoc JSON.stringify (which mishandles Date, key
|
|
50
|
+
// ordering, and `undefined`).
|
|
51
|
+
export { stableHash } from './query/keys'
|
|
47
52
|
export type {
|
|
48
53
|
Mutation,
|
|
49
54
|
MutationConcurrency,
|