@kontsedal/olas-core 0.0.5 → 0.1.0

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 (61) hide show
  1. package/dist/index.cjs +0 -0
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +109 -27
  4. package/dist/index.d.cts.map +1 -1
  5. package/dist/index.d.mts +109 -27
  6. package/dist/index.d.mts.map +1 -1
  7. package/dist/index.mjs +0 -0
  8. package/dist/index.mjs.map +1 -1
  9. package/dist/{root-DqWolle_.mjs → root-Byq-QYTp.mjs} +1478 -347
  10. package/dist/root-Byq-QYTp.mjs.map +1 -0
  11. package/dist/{root-lBp7qziQ.cjs → root-CPSL5kpR.cjs} +1483 -346
  12. package/dist/root-CPSL5kpR.cjs.map +1 -0
  13. package/dist/testing.cjs +5 -1
  14. package/dist/testing.cjs.map +1 -1
  15. package/dist/testing.d.cts +3 -2
  16. package/dist/testing.d.cts.map +1 -1
  17. package/dist/testing.d.mts +3 -2
  18. package/dist/testing.d.mts.map +1 -1
  19. package/dist/testing.mjs +5 -2
  20. package/dist/testing.mjs.map +1 -1
  21. package/dist/{types-BH1o6nYa.d.mts → types-Brp-4WaZ.d.mts} +244 -23
  22. package/dist/types-Brp-4WaZ.d.mts.map +1 -0
  23. package/dist/{types-C4Vtkxbn.d.cts → types-DzDIypPo.d.cts} +244 -23
  24. package/dist/types-DzDIypPo.d.cts.map +1 -0
  25. package/package.json +4 -1
  26. package/src/controller/instance.ts +360 -94
  27. package/src/controller/root.ts +58 -30
  28. package/src/controller/types.ts +66 -4
  29. package/src/emitter.ts +8 -2
  30. package/src/errors.ts +39 -3
  31. package/src/forms/field.ts +219 -25
  32. package/src/forms/form-types.ts +16 -3
  33. package/src/forms/form.ts +360 -38
  34. package/src/forms/index.ts +3 -1
  35. package/src/forms/types.ts +27 -1
  36. package/src/forms/validators.ts +45 -11
  37. package/src/index.ts +13 -7
  38. package/src/query/client.ts +237 -58
  39. package/src/query/define.ts +0 -0
  40. package/src/query/entry.ts +259 -15
  41. package/src/query/focus-online.ts +53 -11
  42. package/src/query/infinite.ts +351 -47
  43. package/src/query/keys.ts +33 -2
  44. package/src/query/local.ts +3 -0
  45. package/src/query/mutation.ts +27 -6
  46. package/src/query/plugin.ts +43 -4
  47. package/src/query/types.ts +76 -6
  48. package/src/query/use.ts +53 -10
  49. package/src/selection.ts +49 -12
  50. package/src/signals/readonly.ts +3 -0
  51. package/src/signals/runtime.ts +27 -0
  52. package/src/signals/types.ts +10 -0
  53. package/src/testing.ts +9 -0
  54. package/src/timing/debounced.ts +106 -23
  55. package/src/timing/index.ts +1 -1
  56. package/src/timing/throttled.ts +85 -28
  57. package/src/utils.ts +15 -2
  58. package/dist/root-DqWolle_.mjs.map +0 -1
  59. package/dist/root-lBp7qziQ.cjs.map +0 -1
  60. package/dist/types-BH1o6nYa.d.mts.map +0 -1
  61. package/dist/types-C4Vtkxbn.d.cts.map +0 -1
@@ -10,7 +10,55 @@ import {
10
10
  signal,
11
11
  } from '../signals'
12
12
  import { isAbortError } from '../utils'
13
- import type { Validator } from './types'
13
+ import type { Validator, ValidatorResult } from './types'
14
+
15
+ /**
16
+ * Flatten a single validator result to plain message strings for a leaf field.
17
+ * A leaf has no descendants, so a `FormIssue[]`'s paths don't route anywhere —
18
+ * every issue's `message` applies to this field. `null` → no messages.
19
+ */
20
+ function messagesFromResult(result: ValidatorResult): string[] {
21
+ if (result == null) return []
22
+ if (typeof result === 'string') return [result]
23
+ return result.map((issue) => issue.message)
24
+ }
25
+
26
+ /**
27
+ * Structural equality used by `Field.set` to decide whether a write returns
28
+ * the field to its initial value (clearing `isDirty`). Cheap path for
29
+ * primitives + `Object.is`; deep walk for arrays and plain objects. Class
30
+ * instances, Map, Set, Date fall back to reference identity — same trade-off
31
+ * `structural-share.ts` makes for cache data.
32
+ */
33
+ function isStructurallyEqual(a: unknown, b: unknown): boolean {
34
+ if (Object.is(a, b)) return true
35
+ if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') return false
36
+ if (Array.isArray(a)) {
37
+ if (!Array.isArray(b) || a.length !== b.length) return false
38
+ for (let i = 0; i < a.length; i++) {
39
+ if (!isStructurallyEqual(a[i], b[i])) return false
40
+ }
41
+ return true
42
+ }
43
+ if (Array.isArray(b)) return false
44
+ const protoA = Object.getPrototypeOf(a)
45
+ const protoB = Object.getPrototypeOf(b)
46
+ // Plain-object guard only — class instances aren't safe to walk by keys.
47
+ if (protoA !== Object.prototype && protoA !== null) return false
48
+ if (protoB !== Object.prototype && protoB !== null) return false
49
+ const keysA = Object.keys(a as Record<string, unknown>)
50
+ const keysB = Object.keys(b as Record<string, unknown>)
51
+ if (keysA.length !== keysB.length) return false
52
+ for (const k of keysA) {
53
+ if (!Object.hasOwn(b as object, k)) return false
54
+ if (
55
+ !isStructurallyEqual((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k])
56
+ ) {
57
+ return false
58
+ }
59
+ }
60
+ return true
61
+ }
14
62
 
15
63
  /**
16
64
  * Hook attached by `ctx.form` (or `createForm`) so a Field can publish
@@ -34,12 +82,34 @@ export type FieldDevtoolsOwner = {
34
82
  */
35
83
  export type ValidatorErrorReporter = (err: unknown) => void
36
84
 
85
+ /**
86
+ * When a field's validators are first allowed to run.
87
+ *
88
+ * - `'change'` (default) — validators run on every `set()`. Matches the
89
+ * current behavior, ideal for "type and see errors live."
90
+ * - `'blur'` — first run is gated on `markTouched()`. After that, subsequent
91
+ * value changes do trigger re-validation. UI binding should call
92
+ * `markTouched()` on `onBlur`.
93
+ * - `'submit'` — first run is gated on `revalidate()` / `Form.submit()`.
94
+ * After that, subsequent value changes re-validate. Use when you want
95
+ * "show errors only after the user explicitly tried to submit."
96
+ *
97
+ * `revalidate()` always unlocks the field regardless of mode.
98
+ */
99
+ export type ValidateOn = 'change' | 'blur' | 'submit'
100
+
101
+ export type FieldOptions = {
102
+ onValidatorError?: ValidatorErrorReporter
103
+ validateOn?: ValidateOn
104
+ }
105
+
37
106
  class FieldImpl<T> implements Field<T> {
38
107
  private readonly value$: Signal<T>
39
108
  /**
40
109
  * Validator-produced errors. The public `errors` getter merges this with
41
- * `serverErrors$` so consumers see a single flat array. Kept separate so a
42
- * re-run of validators (after a new value) doesn't clobber server errors.
110
+ * `serverErrors$` and `formErrors$` so consumers see a single flat array.
111
+ * Kept separate so a re-run of validators (after a new value) doesn't clobber
112
+ * the other two channels.
43
113
  */
44
114
  private readonly validatorErrors$: Signal<string[]>
45
115
  /**
@@ -47,11 +117,29 @@ class FieldImpl<T> implements Field<T> {
47
117
  * `set()`, on `reset()`, or via an explicit `setErrors([])`.
48
118
  */
49
119
  private readonly serverErrors$: Signal<string[]>
120
+ /**
121
+ * Errors routed here by a parent (or ancestor) form-level validator that
122
+ * targeted this field via a `FormIssue` path — the third error channel
123
+ * beside validator + server errors (T5.2). Owned by the routing form: cleared
124
+ * and re-applied on every form-level validation run, so it does NOT clear on
125
+ * the field's own `set()` (a stale cross-field error survives until the next
126
+ * form-level run recomputes it). Cleared by `reset()` / `setAsInitial()`.
127
+ */
128
+ private readonly formErrors$: Signal<string[]>
50
129
  private readonly errors$: Computed<string[]>
51
130
  private readonly touched$: Signal<boolean>
52
131
  private readonly dirty$: Signal<boolean>
53
132
  private readonly validating$: Signal<boolean>
54
133
  private readonly isValid$: Computed<boolean>
134
+ /**
135
+ * Validity as of the last *settled* validation pass. While a pass is in
136
+ * flight (`validating$`), `isValid` reads this instead of the live `errors`
137
+ * — otherwise a `debouncedValidator` would flip `isValid` to `false` on every
138
+ * keystroke (the async-start clears `validatorErrors$`), strobing a submit
139
+ * button. Updated only when a pass completes (T5.3). Defaults `true` (an
140
+ * untouched field with a pending first run reads valid, not invalid).
141
+ */
142
+ private readonly lastValid$: Signal<boolean>
55
143
  private readonly revalidateTrigger$: Signal<number>
56
144
 
57
145
  private readonly validators: ReadonlyArray<Validator<T>>
@@ -64,12 +152,15 @@ class FieldImpl<T> implements Field<T> {
64
152
  private disposed = false
65
153
  private devtoolsOwner: FieldDevtoolsOwner | null = null
66
154
  private onValidatorError: ValidatorErrorReporter | null = null
67
-
68
- constructor(
69
- initial: T,
70
- validators: ReadonlyArray<Validator<T>> = [],
71
- options?: { onValidatorError?: ValidatorErrorReporter },
72
- ) {
155
+ private readonly validateOn: ValidateOn
156
+ /** Reactive gate — when false, the validator effect skips its run. Flipped
157
+ * on by the relevant trigger for the field's `validateOn` mode. Once on,
158
+ * stays on for the lifetime of the field (matches RHF's `mode + reValidateMode`
159
+ * default semantics: after the first activation, subsequent changes
160
+ * re-validate). `reset()` flips it back to false. */
161
+ private readonly validateUnlocked$: Signal<boolean>
162
+
163
+ constructor(initial: T, validators: ReadonlyArray<Validator<T>> = [], options?: FieldOptions) {
73
164
  this.initial = initial
74
165
  this.validators = validators
75
166
  // Capture the reporter BEFORE the validator effect kicks off so a sync
@@ -77,21 +168,33 @@ class FieldImpl<T> implements Field<T> {
77
168
  // disappearing into the effect (`bindValidatorErrorReporter` is a
78
169
  // post-construct hook so it can't catch the first run).
79
170
  this.onValidatorError = options?.onValidatorError ?? null
171
+ this.validateOn = options?.validateOn ?? 'change'
80
172
  this.value$ = signal(initial)
81
173
  this.validatorErrors$ = signal<string[]>([])
82
174
  this.serverErrors$ = signal<string[]>([])
175
+ this.formErrors$ = signal<string[]>([])
83
176
  this.touched$ = signal(false)
84
177
  this.dirty$ = signal(false)
85
178
  this.validating$ = signal(false)
179
+ this.lastValid$ = signal(true)
86
180
  this.revalidateTrigger$ = signal(0)
181
+ // 'change' mode is unlocked from construction; 'blur' / 'submit' wait
182
+ // for their trigger so initial typing doesn't surface errors.
183
+ this.validateUnlocked$ = signal(this.validateOn === 'change')
87
184
  this.errors$ = computed(() => {
88
185
  const v = this.validatorErrors$.value
89
186
  const s = this.serverErrors$.value
90
- if (s.length === 0) return v
91
- if (v.length === 0) return s
92
- return [...v, ...s]
187
+ const f = this.formErrors$.value
188
+ if (s.length === 0 && f.length === 0) return v
189
+ if (v.length === 0 && f.length === 0) return s
190
+ if (v.length === 0 && s.length === 0) return f
191
+ return [...v, ...s, ...f]
93
192
  })
94
- this.isValid$ = computed(() => this.errors$.value.length === 0 && !this.validating$.value)
193
+ this.isValid$ = computed(() =>
194
+ // While a validation pass is in flight, hold the last settled validity so
195
+ // the field doesn't strobe invalid mid-check (T5.3). Otherwise it's live.
196
+ this.validating$.value ? this.lastValid$.value : this.errors$.value.length === 0,
197
+ )
95
198
 
96
199
  if (validators.length > 0) {
97
200
  this.validatorDispose = effect(() => {
@@ -121,6 +224,10 @@ class FieldImpl<T> implements Field<T> {
121
224
  return this.value$.subscribe(handler)
122
225
  }
123
226
 
227
+ subscribeChanges(handler: (value: T) => void): () => void {
228
+ return this.value$.subscribeChanges(handler)
229
+ }
230
+
124
231
  // --- Field-only signals ---
125
232
  get errors(): ReadSignal<string[]> {
126
233
  return this.errors$
@@ -147,7 +254,11 @@ class FieldImpl<T> implements Field<T> {
147
254
  if (this.disposed) return
148
255
  batch(() => {
149
256
  this.value$.set(value)
150
- this.dirty$.set(true)
257
+ // Equality-aware dirty: setting back to initial clears dirty, so
258
+ // "Disable Save when unchanged" UIs work without consumer code. Uses
259
+ // a structural comparison for primitive / shallow-object / array
260
+ // payloads; falls back to reference identity for class instances.
261
+ this.dirty$.set(!isStructurallyEqual(value, this.initial))
151
262
  // Server errors are pinned externally and survive validator re-runs,
152
263
  // but they MUST clear when the user edits the field — otherwise a
153
264
  // server error like "username taken" would persist after the user
@@ -162,6 +273,19 @@ class FieldImpl<T> implements Field<T> {
162
273
  this.serverErrors$.set(next)
163
274
  }
164
275
 
276
+ /**
277
+ * Internal — set the parent-form-validator error channel (`formErrors$`).
278
+ * Called by the owning form's issue router when a `FormIssue` path resolves
279
+ * to this field. Guards against churning the signal when nothing changes so
280
+ * a whole-tree clear pass doesn't wake unrelated subscribers. See T5.2 /
281
+ * `routeFormIssues` in `form.ts`.
282
+ */
283
+ setFormErrors(errors: ReadonlyArray<string>): void {
284
+ if (this.disposed) return
285
+ if (this.formErrors$.peek().length === 0 && errors.length === 0) return
286
+ this.formErrors$.set(errors.length === 0 ? [] : [...errors])
287
+ }
288
+
165
289
  /**
166
290
  * Reseat the field as if this value had been its constructor `initial`.
167
291
  * Sets the value, re-anchors `reset()`'s target, and does NOT mark dirty.
@@ -175,6 +299,13 @@ class FieldImpl<T> implements Field<T> {
175
299
  batch(() => {
176
300
  this.value$.set(value)
177
301
  this.dirty$.set(false)
302
+ // Re-seating from a fresh server payload means the previous server
303
+ // response is no longer relevant. Without clearing, errors like
304
+ // "username taken" persist across a successful re-hydrate.
305
+ if (this.serverErrors$.peek().length > 0) this.serverErrors$.set([])
306
+ // A stale cross-field error from the old value is likewise irrelevant;
307
+ // the next form-level run recomputes it against the fresh value.
308
+ if (this.formErrors$.peek().length > 0) this.formErrors$.set([])
178
309
  })
179
310
  }
180
311
 
@@ -188,17 +319,30 @@ class FieldImpl<T> implements Field<T> {
188
319
  this.touched$.set(false)
189
320
  this.validatorErrors$.set([])
190
321
  this.serverErrors$.set([])
322
+ this.formErrors$.set([])
191
323
  this.validating$.set(false)
324
+ // Re-lock validation if the field was in blur/submit mode — a reset
325
+ // means we're back to a clean slate, so the user shouldn't immediately
326
+ // see errors again until they re-trigger.
327
+ if (this.validateOn !== 'change') this.validateUnlocked$.set(false)
192
328
  })
193
329
  }
194
330
 
195
331
  markTouched(): void {
196
332
  if (this.disposed) return
197
333
  this.touched$.set(true)
334
+ // 'blur' mode unlocks validation on first blur. Subsequent set() calls
335
+ // then re-validate live (matches RHF `reValidateMode: onChange` default).
336
+ if (this.validateOn === 'blur' && !this.validateUnlocked$.peek()) {
337
+ this.validateUnlocked$.set(true)
338
+ }
198
339
  }
199
340
 
200
341
  async revalidate(): Promise<boolean> {
201
342
  if (this.disposed) return this.isValid$.peek()
343
+ // `revalidate()` always unlocks the field — same trigger as a successful
344
+ // submit attempt. 'submit' mode uses this as its first activation.
345
+ if (!this.validateUnlocked$.peek()) this.validateUnlocked$.set(true)
202
346
  // Bump the trigger to force re-run.
203
347
  this.revalidateTrigger$.update((n) => n + 1)
204
348
  await this.waitUntilSettled()
@@ -258,6 +402,17 @@ class FieldImpl<T> implements Field<T> {
258
402
  // Track value and revalidate trigger.
259
403
  const value = this.value$.value
260
404
  void this.revalidateTrigger$.value
405
+ // Track the gate so the effect re-runs when the field becomes unlocked.
406
+ // While locked, skip the pass entirely — errors stay empty, the field
407
+ // reads as valid, no async work starts.
408
+ if (!this.validateUnlocked$.value) {
409
+ batch(() => {
410
+ if (this.validatorErrors$.peek().length > 0) this.validatorErrors$.set([])
411
+ if (this.validating$.peek()) this.validating$.set(false)
412
+ this.lastValid$.set(this.errors$.peek().length === 0)
413
+ })
414
+ return
415
+ }
261
416
 
262
417
  // Abort previous in-flight run.
263
418
  this.currentAbort?.abort()
@@ -266,7 +421,7 @@ class FieldImpl<T> implements Field<T> {
266
421
  const myId = ++this.runId
267
422
 
268
423
  const syncErrors: string[] = []
269
- const asyncPromises: Promise<string | null>[] = []
424
+ const asyncPromises: Promise<ValidatorResult>[] = []
270
425
 
271
426
  for (const validator of this.validators) {
272
427
  try {
@@ -276,20 +431,28 @@ class FieldImpl<T> implements Field<T> {
276
431
  // with a thrown error (rare but legal) — the catch-handler in
277
432
  // `Promise.allSettled` covers true async rejection.
278
433
  asyncPromises.push(result)
279
- } else if (result != null) {
280
- syncErrors.push(result)
434
+ } else {
435
+ // A Standard-Schema validator returns `FormIssue[]`; a stdlib one
436
+ // returns `string | null`. Flatten both to messages (a leaf field
437
+ // has no descendants to route issue paths to).
438
+ const msgs = messagesFromResult(result)
439
+ if (msgs.length > 0) syncErrors.push(...msgs)
281
440
  }
282
441
  } catch (err) {
283
442
  // A buggy validator that throws synchronously: surface it twice.
284
- // (1) Route through `onError` so the user knows something is wrong.
285
- // (2) Convert to a validation error string so the field reads invalid
286
- // until the bug is fixed (don't pretend everything's OK).
443
+ // (1) Route through `onError` so the developer knows something is wrong.
444
+ // (2) Mark the field invalid until the bug is fixed (don't pretend OK).
445
+ // In prod the user-visible message is generic leaking an internal
446
+ // error's text into form errors is a footgun; the real error still
447
+ // reaches `onValidatorError` (T5.3). Dev keeps the message for DX.
287
448
  try {
288
449
  this.onValidatorError?.(err)
289
450
  } catch {
290
451
  // The reporter must not propagate.
291
452
  }
292
- syncErrors.push(err instanceof Error ? err.message : String(err))
453
+ syncErrors.push(
454
+ __DEV__ ? (err instanceof Error ? err.message : String(err)) : 'Validation failed',
455
+ )
293
456
  }
294
457
  }
295
458
 
@@ -297,6 +460,7 @@ class FieldImpl<T> implements Field<T> {
297
460
  batch(() => {
298
461
  this.validatorErrors$.set(syncErrors)
299
462
  this.validating$.set(false)
463
+ this.lastValid$.set(this.errors$.peek().length === 0)
300
464
  })
301
465
  this.emitValidated(false, syncErrors)
302
466
  return
@@ -306,6 +470,7 @@ class FieldImpl<T> implements Field<T> {
306
470
  batch(() => {
307
471
  this.validatorErrors$.set([])
308
472
  this.validating$.set(false)
473
+ this.lastValid$.set(this.errors$.peek().length === 0)
309
474
  })
310
475
  this.emitValidated(true, [])
311
476
  return
@@ -321,7 +486,7 @@ class FieldImpl<T> implements Field<T> {
321
486
  const asyncErrors: string[] = []
322
487
  for (const r of results) {
323
488
  if (r.status === 'fulfilled') {
324
- if (r.value != null) asyncErrors.push(r.value)
489
+ asyncErrors.push(...messagesFromResult(r.value))
325
490
  } else if (!isAbortError(r.reason)) {
326
491
  const msg = r.reason instanceof Error ? r.reason.message : String(r.reason)
327
492
  asyncErrors.push(msg)
@@ -330,6 +495,7 @@ class FieldImpl<T> implements Field<T> {
330
495
  batch(() => {
331
496
  this.validatorErrors$.set(asyncErrors)
332
497
  this.validating$.set(false)
498
+ this.lastValid$.set(this.errors$.peek().length === 0)
333
499
  })
334
500
  this.emitValidated(asyncErrors.length === 0, asyncErrors)
335
501
  })
@@ -367,20 +533,48 @@ export function bindFieldValidatorErrorReporter<T>(
367
533
  export function createField<T>(
368
534
  initial: T,
369
535
  validators?: ReadonlyArray<Validator<T>>,
370
- options?: { onValidatorError?: ValidatorErrorReporter },
536
+ options?: FieldOptions,
371
537
  ): Field<T> {
372
538
  return new FieldImpl(initial, validators, options)
373
539
  }
374
540
 
541
+ /**
542
+ * A bidirectional `T ↔ string` transform, suitable for HTML input bindings
543
+ * where DOM values are always strings.
544
+ *
545
+ * `parse(raw)` converts the input's string value into the field's type;
546
+ * `format(value)` converts the field's typed value back into a string for
547
+ * the input. Both must be pure — `useFieldInput` calls them on every
548
+ * render and every input event respectively.
549
+ *
550
+ * ```ts
551
+ * const numberTransform: FieldTransform<number> = {
552
+ * parse: (raw) => Number(raw),
553
+ * format: (v) => String(v),
554
+ * }
555
+ * ```
556
+ */
557
+ export type FieldTransform<T> = {
558
+ parse: (raw: string) => T
559
+ format: (value: T) => string
560
+ }
561
+
375
562
  /**
376
563
  * Wrap an async validator with a debounce. The debounce timer resets on every
377
564
  * value change. While debouncing or the request is in flight, the field's
378
- * `isValidating` is true and `isValid` is false (treat-as-invalid-until-proven-valid).
565
+ * `isValidating` is true and `isValid` HOLDS its last settled value (T5.3) — so
566
+ * editing an already-valid field doesn't strobe a submit button to disabled on
567
+ * every keystroke. A field with no prior settled validation defaults to valid.
379
568
  */
380
569
  export function debouncedValidator<T>(
381
570
  fn: (value: T, signal: AbortSignal) => Promise<string | null>,
382
571
  ms: number,
383
- ): Validator<T> {
572
+ ): (value: T, signal: AbortSignal) => Promise<string | null> {
573
+ // Precise return (not the wide `Validator<T>`): the wrapped `fn` only ever
574
+ // yields `string | null`, so callers that invoke the debounced validator
575
+ // directly — e.g. surfacing its result in a signal — keep that narrow type
576
+ // after `Validator<T>` was widened to allow `FormIssue[]` (T5.2). Still
577
+ // assignable wherever a `Validator<T>` is expected.
384
578
  return (value, signal) =>
385
579
  new Promise<string | null>((resolve, reject) => {
386
580
  if (signal.aborted) {
@@ -89,6 +89,12 @@ export type Form<S extends FormSchema> = {
89
89
  readonly isDirty: ReadSignal<boolean>
90
90
  readonly touched: ReadSignal<boolean>
91
91
  readonly isValidating: ReadSignal<boolean>
92
+ /**
93
+ * Dotted paths of every leaf whose `isDirty` is true. Useful for PATCH
94
+ * payloads and "highlight changed inputs" UIs. Field paths use dot
95
+ * notation; array items use bracket notation (`items[0].title`).
96
+ */
97
+ readonly dirtyFields: ReadSignal<string[]>
92
98
 
93
99
  /**
94
100
  * `true` while a `submit(...)` is in flight. Clears when the handler
@@ -117,6 +123,13 @@ export type Form<S extends FormSchema> = {
117
123
  resetWithInitial(partial: DeepPartial<FormValue<S>>): void
118
124
  /** Reset every leaf to its initial value. */
119
125
  reset(): void
126
+ /**
127
+ * Reset a named subtree to its initial. Path uses the same dotted /
128
+ * bracket notation as `setErrors` / `flatErrors`. Unlike `Form.set({foo:
129
+ * undefined})` (which is "leave alone"), this is the explicit
130
+ * "clear this subtree" gesture. Pass `''` to reset the whole form.
131
+ */
132
+ clearSubtree(path: string): void
120
133
  /** Mark every leaf as touched (so error messages appear). */
121
134
  markAllTouched(): void
122
135
  /** Re-run every leaf's validators. Resolves with true if all leaves are valid. */
@@ -127,14 +140,14 @@ export type Form<S extends FormSchema> = {
127
140
  * `submitError`. Returns `{ ok, data?, error? }` — see `FormImpl.submit`
128
141
  * for the full contract.
129
142
  */
130
- submit(
131
- handler: (value: FormValue<S>) => unknown | Promise<unknown>,
143
+ submit<R = unknown>(
144
+ handler: (value: FormValue<S>) => R | Promise<R>,
132
145
  options?: {
133
146
  validateBeforeSubmit?: boolean
134
147
  resetOnSuccess?: boolean
135
148
  onError?: 'rethrow' | 'capture'
136
149
  },
137
- ): Promise<{ ok: boolean; data?: unknown; error?: unknown }>
150
+ ): Promise<{ ok: boolean; data?: Awaited<R>; error?: unknown }>
138
151
  /**
139
152
  * Pin externally-sourced errors on specific fields. Keys are dot-separated
140
153
  * paths through nested forms / field arrays (numeric segments are array