formstand 0.16.0 → 0.17.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,117 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.17.0 — 2026-09-17
4
+
5
+ ### Added
6
+
7
+ - **`form.addErrors(errors)`.** The merge-many sibling of `setError`:
8
+ takes a server response's own runtime-keyed error map as-is and merges
9
+ it into the server channel — no per-key `FieldPath` cast, no
10
+ `setErrors` wholesale replacement. An empty array removes its key,
11
+ like `setError`. The docs' own examples now use it.
12
+ - **`SubmitOptions.onError` and a dev-mode swallowed-failure warning.**
13
+ A throwing submit handler resolves `{kind: "error"}` writing no state
14
+ anywhere, so the fire-and-forget `handleSubmit` form could swallow a
15
+ save failure completely — button re-enables, zero feedback. `onError`
16
+ is the save-failure hook mirroring `onInvalid`, and dev builds warn
17
+ when an unobserved handler failure resolves without one.
18
+ - **`useFieldArray` parity with its siblings.** The return gains `path`,
19
+ `firstError`, and the array-level server channel (`setError` /
20
+ `clearError`) — a "too many rows" verdict from the API is set and
21
+ cleared from the hook that owns the array. Custom `FieldArrayFormApi`
22
+ implementations opt in via the optional `setError`/`clearErrors`
23
+ members (same contract as the optional `validateField`).
24
+ - **`useIsValidating(form, path?)`.** The boolean-only in-flight flag,
25
+ form-level (whole-form pass or any field) or scoped to a subtree —
26
+ previously reachable only through a selector, asymmetric with
27
+ `useIsSubmitting`.
28
+ - **`usePersistForm(form, options)`.** The React lifecycle wrapper
29
+ `persistForm` was missing: owns the mount/dispose effect (StrictMode
30
+ safe), returns a reference-stable handle safe to close over in submit
31
+ handlers, and reads options when the subscription starts (the
32
+ `useForm` locked-options rule).
33
+ - **`createFormHooks` binds the newer hooks.** `use{Name}Values`,
34
+ `use{Name}Fields` (position-typed like the plain `useFields`), and
35
+ `use{Name}IsValidating` join the factory.
36
+
37
+ ### Fixed
38
+
39
+ - **`numberInputProps` keeps the value on a partial entry.** The
40
+ `type="number"` builder wrote `emptyValue` for BOTH empty and invalid
41
+ text, so a transient "-" or "1e" nulled the field mid-keystroke
42
+ (error flash, dirty flip) — diverging from `useNumberInput`'s
43
+ deliberate keep-the-value semantics. Invalid now keeps the stored
44
+ value; empty still clears. (Browsers report `""` for badInput on
45
+ number inputs, so `useNumberInput` remains the binding that preserves
46
+ every keystroke.)
47
+ - **Tuples resolve positionally in the typed paths.** Tuples fell into
48
+ the generic-array arms of `FieldPath` and `UnionValueAt`, which union
49
+ every element — so nested paths under heterogeneous tuples were
50
+ rejected (`"hetero.0.a"` unbindable), out-of-range indices
51
+ (`"coord.5"`) accepted with a lying element type, and a discriminated
52
+ union below a tuple position unbindable through `useVariantField`.
53
+ Both types gained a tuple arm keyed on the literal indices; `"coord.0"`
54
+ spellings the CLI generates are unchanged.
55
+ - **Platform containers are path leaves.** `File`/`Blob` (matched
56
+ structurally, so no DOM lib requirement), `Map`, `Set`, `WeakMap`,
57
+ `WeakSet`, `Promise`, and functions no longer descend as records:
58
+ bogus sub-paths like `"file.name"` or `"meta.size"` used to
59
+ type-check, and a `setValue` through one would have spread the
60
+ instance into a plain object.
61
+ - **Literal dotted keys are no longer offered as paths.** A key named
62
+ `"a.b"` appeared in `FieldPath` but resolved to `never` (the runtime
63
+ walk splits on `"."`), so the offer was the lie; it is now excluded
64
+ where it is offered rather than where it resolves.
65
+ - **`validateField` no longer crashes on prototype-key paths.** The
66
+ schema walk read the ZodObject shape with a bare index, so a
67
+ runtime-built path like `"user.constructor.x"` (server error fields
68
+ mapped onto `validateField`) resolved Object's constructor as a
69
+ "schema" and threw — and the subschema cache kept the poisoned entry.
70
+ Both walks now do own-key reads, like every other walk in the library.
71
+ - **Persist discards version-downgrade drafts instead of adopting the
72
+ wrapper.** A draft written with `version: n` is a `{__v, values}`
73
+ wrapper; if the app later shipped without the `version` option, the
74
+ wrapper's keys evaded the shape guard and the wrapper OBJECT became
75
+ the form's values, then re-persisted. A versioned draft under a
76
+ version-less config is now discarded like any other draft the config
77
+ cannot vouch for.
78
+ - **Debounced validation no longer survives `reset()`.** A pending
79
+ debounce timer fired after `reset()`/`adoptValues()` and re-committed
80
+ errors onto the pristine form. The fire now bails when the form is
81
+ pristine (values is the initialValues reference) or when the path's
82
+ value changed since scheduling; an unrelated field's edit inside the
83
+ window still lets the pending validation run.
84
+ - **Array ops accept a null-valued nullable array.** `arrayPush` on a
85
+ `.nullable()` array field holding `null` warned and silently no-op'd,
86
+ while `undefined` created the array — even though the op item types
87
+ are built on `NonNullable<...>` precisely so nullable arrays accept
88
+ ops. Both empties now start the op from `[]`.
89
+
90
+ ### Changed
91
+
92
+ - **Awaited async validation is typed settled.** `validateAsync` and
93
+ `validateFieldAsync` (and `useField`'s `validateAsync`) resolved a
94
+ union carrying a `"pending"` arm that can never occur — the promise IS
95
+ the pending state — forcing every exhaustive `switch` to carry a dead
96
+ case. All three now resolve the `Settled*` result types
97
+ `validateFieldsAsync` always had. A `switch` that handled `"pending"`
98
+ on an awaited result will now fail to compile; delete the dead arm.
99
+
100
+ ## formstand-cli 0.16.0 — 2026-09-16
101
+
102
+ ### Added
103
+
104
+ - **Union array rows generate.** A discriminated union that is an
105
+ array's row item — the shape the airfield-maps trial hand-wrote — now
106
+ compiles to a `{Stem}Row` component per row instead of a TODO, in
107
+ both layouts: the discriminant and common fields bind with the field
108
+ hook, variant-only fields with the variant hook, all on the
109
+ row-indexed path (`methods.${index}`), with the same conditional
110
+ variant blocks, fragment elision, and hoisted number-props hooks a
111
+ static union gets. Needs formstand 0.16 for the row-indexed variant
112
+ path types. A union or tuple nested as a FIELD inside a row object,
113
+ and a tuple row item, keep their TODOs.
114
+
3
115
  ## 0.16.0 — 2026-09-16
4
116
 
5
117
  ### Added
@@ -1,9 +1,15 @@
1
1
  import { z } from 'zod';
2
2
  import { StoreApi } from 'zustand/vanilla';
3
3
 
4
- type LeafType = string | number | boolean | bigint | symbol | null | undefined | Date | RegExp;
4
+ type BlobLike = Readonly<{
5
+ size: number;
6
+ type: string;
7
+ arrayBuffer: () => Promise<unknown>;
8
+ }>;
9
+ type LeafType = string | number | boolean | bigint | symbol | null | undefined | Date | RegExp | ReadonlyMap<unknown, unknown> | ReadonlySet<unknown> | WeakMap<object, unknown> | WeakSet<object> | Promise<unknown> | ((...args: never[]) => unknown) | BlobLike;
5
10
  type IsRecord<T> = T extends LeafType ? false : T extends readonly unknown[] ? false : T extends object ? true : false;
6
11
  type IsArray<T> = T extends readonly unknown[] ? true : false;
12
+ type IsTuple<T> = T extends readonly unknown[] ? number extends T["length"] ? false : true : false;
7
13
  type Prev = [
8
14
  never,
9
15
  0,
@@ -37,8 +43,10 @@ type PathDepth = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14
37
43
  type IsUnion<T, U = T> = [T] extends [never] ? false : T extends unknown ? [U] extends [T] ? false : true : never;
38
44
  type NormalizeDepth<D extends number> = number extends D ? DefaultPathDepth : [D] extends [PathDepth] ? IsUnion<D> extends true ? DefaultPathDepth : D : DefaultPathDepth;
39
45
  type FieldPath<T, D extends number = DefaultPathDepth> = FieldPathRec<T, NormalizeDepth<D>>;
40
- type FieldPathRec<T, D extends number> = [D] extends [0] ? never : IsArray<T> extends true ? T extends readonly (infer U)[] ? `${number}` | (IsRecord<NonNullable<U>> extends true ? `${number}.${FieldPathRec<NonNullable<U>, Prev[D]>}` : IsArray<NonNullable<U>> extends true ? `${number}.${FieldPathRec<NonNullable<U>, Prev[D]>}` : never) : never : IsRecord<T> extends true ? {
41
- [K in keyof T & string]: K | (IsRecord<NonNullable<T[K]>> extends true ? `${K}.${FieldPathRec<NonNullable<T[K]>, Prev[D]>}` : IsArray<NonNullable<T[K]>> extends true ? `${K}.${FieldPathRec<NonNullable<T[K]>, Prev[D]>}` : never);
46
+ type FieldPathRec<T, D extends number> = [D] extends [0] ? never : IsTuple<T> extends true ? {
47
+ [K in keyof T & `${number}`]: K | (IsRecord<NonNullable<T[K]>> extends true ? `${K}.${FieldPathRec<NonNullable<T[K]>, Prev[D]>}` : IsArray<NonNullable<T[K]>> extends true ? `${K}.${FieldPathRec<NonNullable<T[K]>, Prev[D]>}` : never);
48
+ }[keyof T & `${number}`] : IsArray<T> extends true ? T extends readonly (infer U)[] ? `${number}` | (IsRecord<NonNullable<U>> extends true ? `${number}.${FieldPathRec<NonNullable<U>, Prev[D]>}` : IsArray<NonNullable<U>> extends true ? `${number}.${FieldPathRec<NonNullable<U>, Prev[D]>}` : never) : never : IsRecord<T> extends true ? {
49
+ [K in keyof T & string]: K extends `${string}.${string}` ? never : K | (IsRecord<NonNullable<T[K]>> extends true ? `${K}.${FieldPathRec<NonNullable<T[K]>, Prev[D]>}` : IsArray<NonNullable<T[K]>> extends true ? `${K}.${FieldPathRec<NonNullable<T[K]>, Prev[D]>}` : never);
42
50
  }[keyof T & string] : never;
43
51
  type StepValue<T, K extends string> = T extends readonly (infer U)[] ? K extends keyof T ? T[K] : K extends `${number}` ? U : never : T extends object ? K extends keyof T ? T[K] : never : never;
44
52
  type FieldValue<T, P extends string> = [T] extends [NonNullable<T>] ? P extends `${infer Head}.${infer Tail}` ? FieldValue<StepValue<T, Head>, Tail> : StepValue<T, P> : FieldValue<NonNullable<T>, P> | undefined;
@@ -114,6 +122,7 @@ type SubmitHandler<TSchema extends z.ZodType> = (data: z.output<TSchema>) => voi
114
122
  type InvalidSubmitHandler = (errors: ErrorMap) => void;
115
123
  type SubmitOptions = Readonly<{
116
124
  force?: boolean;
125
+ onError?: (error: unknown) => void;
117
126
  }>;
118
127
  type SubmitResult<TOutput> = Readonly<{
119
128
  kind: "valid";
@@ -159,6 +168,7 @@ type Form<TSchema extends z.ZodType, D extends PathDepth = DefaultPathDepth> = R
159
168
  setReValidateMode: (mode: ValidationMode) => void;
160
169
  setError: (path: ErrorPath<z.input<TSchema>, D>, errors: string | readonly string[]) => void;
161
170
  setErrors: (errors: ErrorMap) => void;
171
+ addErrors: (errors: ErrorMap) => void;
162
172
  clearErrors: (path?: ErrorPath<z.input<TSchema>, D>) => void;
163
173
  updateState: (updater: (state: FormState<z.input<TSchema>>) => Partial<Omit<FormState<z.input<TSchema>>, "errors">>) => void;
164
174
  reset: (nextInitial?: Partial<z.input<TSchema>>, options?: ResetOptions) => void;
@@ -168,8 +178,8 @@ type Form<TSchema extends z.ZodType, D extends PathDepth = DefaultPathDepth> = R
168
178
  validate: () => ValidationResult<z.output<TSchema>>;
169
179
  validateField: (path: ErrorPath<z.input<TSchema>, D>) => FieldValidationResult;
170
180
  validateFields: (paths: readonly FieldPath<z.input<TSchema>, D>[]) => FieldsValidationResult;
171
- validateAsync: () => Promise<ValidationResult<z.output<TSchema>>>;
172
- validateFieldAsync: (path: ErrorPath<z.input<TSchema>, D>) => Promise<FieldValidationResult>;
181
+ validateAsync: () => Promise<SettledValidationResult<z.output<TSchema>>>;
182
+ validateFieldAsync: (path: ErrorPath<z.input<TSchema>, D>) => Promise<SettledFieldValidationResult>;
173
183
  validateFieldsAsync: (paths: readonly FieldPath<z.input<TSchema>, D>[]) => Promise<SettledFieldsValidationResult>;
174
184
  submit: (onValid: SubmitHandler<TSchema>, onInvalid?: InvalidSubmitHandler, options?: SubmitOptions) => Promise<SubmitResult<z.output<TSchema>>>;
175
185
  arrayPush: <P extends FieldPath<z.input<TSchema>, D>>(path: P, item: ArrayItemOf<NonNullable<FieldValue<z.input<TSchema>, P>>>) => void;
@@ -1,9 +1,15 @@
1
1
  import { z } from 'zod';
2
2
  import { StoreApi } from 'zustand/vanilla';
3
3
 
4
- type LeafType = string | number | boolean | bigint | symbol | null | undefined | Date | RegExp;
4
+ type BlobLike = Readonly<{
5
+ size: number;
6
+ type: string;
7
+ arrayBuffer: () => Promise<unknown>;
8
+ }>;
9
+ type LeafType = string | number | boolean | bigint | symbol | null | undefined | Date | RegExp | ReadonlyMap<unknown, unknown> | ReadonlySet<unknown> | WeakMap<object, unknown> | WeakSet<object> | Promise<unknown> | ((...args: never[]) => unknown) | BlobLike;
5
10
  type IsRecord<T> = T extends LeafType ? false : T extends readonly unknown[] ? false : T extends object ? true : false;
6
11
  type IsArray<T> = T extends readonly unknown[] ? true : false;
12
+ type IsTuple<T> = T extends readonly unknown[] ? number extends T["length"] ? false : true : false;
7
13
  type Prev = [
8
14
  never,
9
15
  0,
@@ -37,8 +43,10 @@ type PathDepth = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14
37
43
  type IsUnion<T, U = T> = [T] extends [never] ? false : T extends unknown ? [U] extends [T] ? false : true : never;
38
44
  type NormalizeDepth<D extends number> = number extends D ? DefaultPathDepth : [D] extends [PathDepth] ? IsUnion<D> extends true ? DefaultPathDepth : D : DefaultPathDepth;
39
45
  type FieldPath<T, D extends number = DefaultPathDepth> = FieldPathRec<T, NormalizeDepth<D>>;
40
- type FieldPathRec<T, D extends number> = [D] extends [0] ? never : IsArray<T> extends true ? T extends readonly (infer U)[] ? `${number}` | (IsRecord<NonNullable<U>> extends true ? `${number}.${FieldPathRec<NonNullable<U>, Prev[D]>}` : IsArray<NonNullable<U>> extends true ? `${number}.${FieldPathRec<NonNullable<U>, Prev[D]>}` : never) : never : IsRecord<T> extends true ? {
41
- [K in keyof T & string]: K | (IsRecord<NonNullable<T[K]>> extends true ? `${K}.${FieldPathRec<NonNullable<T[K]>, Prev[D]>}` : IsArray<NonNullable<T[K]>> extends true ? `${K}.${FieldPathRec<NonNullable<T[K]>, Prev[D]>}` : never);
46
+ type FieldPathRec<T, D extends number> = [D] extends [0] ? never : IsTuple<T> extends true ? {
47
+ [K in keyof T & `${number}`]: K | (IsRecord<NonNullable<T[K]>> extends true ? `${K}.${FieldPathRec<NonNullable<T[K]>, Prev[D]>}` : IsArray<NonNullable<T[K]>> extends true ? `${K}.${FieldPathRec<NonNullable<T[K]>, Prev[D]>}` : never);
48
+ }[keyof T & `${number}`] : IsArray<T> extends true ? T extends readonly (infer U)[] ? `${number}` | (IsRecord<NonNullable<U>> extends true ? `${number}.${FieldPathRec<NonNullable<U>, Prev[D]>}` : IsArray<NonNullable<U>> extends true ? `${number}.${FieldPathRec<NonNullable<U>, Prev[D]>}` : never) : never : IsRecord<T> extends true ? {
49
+ [K in keyof T & string]: K extends `${string}.${string}` ? never : K | (IsRecord<NonNullable<T[K]>> extends true ? `${K}.${FieldPathRec<NonNullable<T[K]>, Prev[D]>}` : IsArray<NonNullable<T[K]>> extends true ? `${K}.${FieldPathRec<NonNullable<T[K]>, Prev[D]>}` : never);
42
50
  }[keyof T & string] : never;
43
51
  type StepValue<T, K extends string> = T extends readonly (infer U)[] ? K extends keyof T ? T[K] : K extends `${number}` ? U : never : T extends object ? K extends keyof T ? T[K] : never : never;
44
52
  type FieldValue<T, P extends string> = [T] extends [NonNullable<T>] ? P extends `${infer Head}.${infer Tail}` ? FieldValue<StepValue<T, Head>, Tail> : StepValue<T, P> : FieldValue<NonNullable<T>, P> | undefined;
@@ -114,6 +122,7 @@ type SubmitHandler<TSchema extends z.ZodType> = (data: z.output<TSchema>) => voi
114
122
  type InvalidSubmitHandler = (errors: ErrorMap) => void;
115
123
  type SubmitOptions = Readonly<{
116
124
  force?: boolean;
125
+ onError?: (error: unknown) => void;
117
126
  }>;
118
127
  type SubmitResult<TOutput> = Readonly<{
119
128
  kind: "valid";
@@ -159,6 +168,7 @@ type Form<TSchema extends z.ZodType, D extends PathDepth = DefaultPathDepth> = R
159
168
  setReValidateMode: (mode: ValidationMode) => void;
160
169
  setError: (path: ErrorPath<z.input<TSchema>, D>, errors: string | readonly string[]) => void;
161
170
  setErrors: (errors: ErrorMap) => void;
171
+ addErrors: (errors: ErrorMap) => void;
162
172
  clearErrors: (path?: ErrorPath<z.input<TSchema>, D>) => void;
163
173
  updateState: (updater: (state: FormState<z.input<TSchema>>) => Partial<Omit<FormState<z.input<TSchema>>, "errors">>) => void;
164
174
  reset: (nextInitial?: Partial<z.input<TSchema>>, options?: ResetOptions) => void;
@@ -168,8 +178,8 @@ type Form<TSchema extends z.ZodType, D extends PathDepth = DefaultPathDepth> = R
168
178
  validate: () => ValidationResult<z.output<TSchema>>;
169
179
  validateField: (path: ErrorPath<z.input<TSchema>, D>) => FieldValidationResult;
170
180
  validateFields: (paths: readonly FieldPath<z.input<TSchema>, D>[]) => FieldsValidationResult;
171
- validateAsync: () => Promise<ValidationResult<z.output<TSchema>>>;
172
- validateFieldAsync: (path: ErrorPath<z.input<TSchema>, D>) => Promise<FieldValidationResult>;
181
+ validateAsync: () => Promise<SettledValidationResult<z.output<TSchema>>>;
182
+ validateFieldAsync: (path: ErrorPath<z.input<TSchema>, D>) => Promise<SettledFieldValidationResult>;
173
183
  validateFieldsAsync: (paths: readonly FieldPath<z.input<TSchema>, D>[]) => Promise<SettledFieldsValidationResult>;
174
184
  submit: (onValid: SubmitHandler<TSchema>, onInvalid?: InvalidSubmitHandler, options?: SubmitOptions) => Promise<SubmitResult<z.output<TSchema>>>;
175
185
  arrayPush: <P extends FieldPath<z.input<TSchema>, D>>(path: P, item: ArrayItemOf<NonNullable<FieldValue<z.input<TSchema>, P>>>) => void;
@@ -1,6 +1,6 @@
1
1
  import * as react from 'react';
2
2
  import { z } from 'zod';
3
- import { F as Form } from '../createForm-jlzK2lCG.cjs';
3
+ import { F as Form } from '../createForm-D0xLZH-Q.cjs';
4
4
  import 'zustand/vanilla';
5
5
 
6
6
  type DevtoolsPosition = "bottom-right" | "bottom-left" | "top-right" | "top-left";
@@ -1,6 +1,6 @@
1
1
  import * as react from 'react';
2
2
  import { z } from 'zod';
3
- import { F as Form } from '../createForm-jlzK2lCG.js';
3
+ import { F as Form } from '../createForm-D0xLZH-Q.js';
4
4
  import 'zustand/vanilla';
5
5
 
6
6
  type DevtoolsPosition = "bottom-right" | "bottom-left" | "top-right" | "top-left";
package/dist/index.cjs CHANGED
@@ -297,7 +297,7 @@ var schemaHasPath = (schema, path) => {
297
297
  if (head === void 0) return true;
298
298
  if (s instanceof zod.z.ZodObject) {
299
299
  const shape = s.shape;
300
- const child = shape[String(head)];
300
+ const child = Object.hasOwn(shape, String(head)) ? shape[String(head)] : void 0;
301
301
  if (child !== void 0) return walk(child, rest, budget - 1);
302
302
  const catchall = s.def.catchall;
303
303
  return catchall !== void 0 && !(catchall instanceof zod.z.ZodNever);
@@ -317,7 +317,7 @@ var fieldSchemaAtPath = (schema, path) => parsePath(path).reduce((current, segme
317
317
  if (current === null || hasChecks(current)) return null;
318
318
  if (current instanceof zod.z.ZodObject && typeof segment === "string") {
319
319
  const shape = current.shape;
320
- return shape[segment] ?? null;
320
+ return Object.hasOwn(shape, segment) ? shape[segment] ?? null : null;
321
321
  }
322
322
  if (current instanceof zod.z.ZodArray && typeof segment === "number") {
323
323
  return current.element;
@@ -722,7 +722,7 @@ var createForm = (schema, options) => {
722
722
  };
723
723
  const applyArrayOp = (path, nextArray, mapper, indexInBounds) => {
724
724
  const current = getAtPath(store.getState().values, path);
725
- if (current !== void 0 && !Array.isArray(current)) {
725
+ if (current !== void 0 && current !== null && !Array.isArray(current)) {
726
726
  console.warn(
727
727
  `[formstand] array op on "${path}" but the value at that path is not an array (got ${typeof current}). Operation skipped.`
728
728
  );
@@ -831,6 +831,7 @@ var createForm = (schema, options) => {
831
831
  try {
832
832
  await onValid(result.data);
833
833
  } catch (error) {
834
+ submitOptions?.onError?.(error);
834
835
  return { kind: "error", error };
835
836
  }
836
837
  return { kind: "valid", data: result.data };
@@ -938,6 +939,17 @@ var createForm = (schema, options) => {
938
939
  ...state,
939
940
  ...errorChannels(state, { serverErrors: errors })
940
941
  })),
942
+ addErrors: (errors) => store.setState((state) => ({
943
+ ...state,
944
+ ...errorChannels(state, {
945
+ serverErrors: Object.entries(errors).reduce(
946
+ // Computed keys throughout, so a server field named
947
+ // "__proto__" lands as an own property like everywhere else.
948
+ (acc, [key, list]) => list === void 0 || list.length === 0 ? omitKey(acc, key) : { ...acc, [key]: list },
949
+ state.serverErrors
950
+ )
951
+ })
952
+ })),
941
953
  clearErrors: (path) => store.setState((state) => {
942
954
  const scrub = (map) => path === void 0 ? emptyErrors : omitScope(map, [path]);
943
955
  return {
@@ -1077,7 +1089,14 @@ var createForm = (schema, options) => {
1077
1089
  ),
1078
1090
  handleSubmit: (onValid, onInvalid, opts) => async (event) => {
1079
1091
  event?.preventDefault();
1080
- return submit(onValid, onInvalid, opts);
1092
+ const result = await submit(onValid, onInvalid, opts);
1093
+ if (result.kind === "error" && opts?.onError === void 0 && process.env.NODE_ENV !== "production") {
1094
+ console.warn(
1095
+ "[formstand] submit handler threw and nothing observed it \u2014 the form shows no feedback. Pass { onError } to handleSubmit (or setError inside your handler):",
1096
+ result.error
1097
+ );
1098
+ }
1099
+ return result;
1081
1100
  },
1082
1101
  diff: () => {
1083
1102
  const state = store.getState();
@@ -1185,6 +1204,9 @@ var persistForm = (form, options) => {
1185
1204
  const parsed = JSON.parse(raw);
1186
1205
  const versioned = options.version !== void 0 && typeof parsed === "object" && parsed !== null && "__v" in parsed;
1187
1206
  if (options.version !== void 0 && !versioned) return false;
1207
+ if (options.version === void 0 && typeof parsed === "object" && parsed !== null && Object.hasOwn(parsed, "__v") && Object.hasOwn(parsed, "values")) {
1208
+ return false;
1209
+ }
1188
1210
  const wrapper = parsed;
1189
1211
  if (versioned && wrapper.__v !== options.version) return false;
1190
1212
  if (versioned && wrapper.values === void 0) return false;
@@ -1248,6 +1270,33 @@ var persistForm = (form, options) => {
1248
1270
  }
1249
1271
  });
1250
1272
  };
1273
+ var usePersistForm = (form, options) => {
1274
+ const inner = react.useRef(null);
1275
+ const latestOptions = react.useRef(options);
1276
+ react.useEffect(() => {
1277
+ latestOptions.current = options;
1278
+ });
1279
+ const key = options.key;
1280
+ react.useEffect(() => {
1281
+ const handle = persistForm(form, latestOptions.current);
1282
+ inner.current = handle;
1283
+ return () => {
1284
+ inner.current = null;
1285
+ handle.dispose();
1286
+ };
1287
+ }, [form, key]);
1288
+ return react.useMemo(
1289
+ () => ({
1290
+ restore: () => inner.current?.restore() ?? false,
1291
+ clear: () => inner.current?.clear(),
1292
+ // dispose stops THIS component's watching early; the effect cleanup
1293
+ // still runs its own dispose harmlessly (persistForm's dispose is
1294
+ // idempotent by construction — it only cancels and unsubscribes).
1295
+ dispose: () => inner.current?.dispose()
1296
+ }),
1297
+ []
1298
+ );
1299
+ };
1251
1300
  var useForm = (schema, options) => {
1252
1301
  const formRef = react.useRef(null);
1253
1302
  if (formRef.current === null) {
@@ -1324,9 +1373,13 @@ function useField(form, pathArg, options) {
1324
1373
  if (debounceTimerRef.current !== null) {
1325
1374
  clearTimeout(debounceTimerRef.current);
1326
1375
  }
1376
+ const scheduled = getAtPath(form.store.getState().values, path);
1327
1377
  debounceTimerRef.current = setTimeout(() => {
1328
- void form.validateFieldAsync(path);
1329
1378
  debounceTimerRef.current = null;
1379
+ const state = form.store.getState();
1380
+ if (state.values === state.initialValues) return;
1381
+ if (!Object.is(getAtPath(state.values, path), scheduled)) return;
1382
+ void form.validateFieldAsync(path);
1330
1383
  }, debounceMs);
1331
1384
  return;
1332
1385
  }
@@ -1587,25 +1640,64 @@ function useFieldArray(form, pathArg) {
1587
1640
  })),
1588
1641
  [items, ids]
1589
1642
  );
1643
+ const setError = react.useCallback(
1644
+ // Same string-shorthand normalization as useField.setError, so custom
1645
+ // implementations typed for readonly string[] never receive a bare
1646
+ // string.
1647
+ (errors) => form.setError?.(path, typeof errors === "string" ? [errors] : errors),
1648
+ [form, path]
1649
+ );
1650
+ const clearError = react.useCallback(() => form.clearErrors?.(path), [form, path]);
1590
1651
  return react.useMemo(
1591
1652
  () => ({
1653
+ path,
1592
1654
  fields,
1593
1655
  items,
1594
1656
  length: items.length,
1595
1657
  error,
1658
+ firstError: error?.[0],
1659
+ setError,
1660
+ clearError,
1596
1661
  push,
1597
1662
  remove,
1598
1663
  insert,
1599
1664
  move,
1600
1665
  swap
1601
1666
  }),
1602
- [fields, items, error, push, remove, insert, move, swap]
1667
+ [path, fields, items, error, setError, clearError, push, remove, insert, move, swap]
1603
1668
  );
1604
1669
  }
1605
1670
  var useFormError = (form) => react$1.useStore(
1606
1671
  form.store,
1607
1672
  shallow.useShallow((state) => state.errors[""])
1608
1673
  );
1674
+ function useFormValues(form) {
1675
+ return react$1.useStore(form.store, (state) => state.values);
1676
+ }
1677
+
1678
+ // src/react/useFields.ts
1679
+ function useFields(form, paths) {
1680
+ const fields = paths.map(
1681
+ (path) => (
1682
+ // eslint-disable-next-line react-hooks/rules-of-hooks -- a fixed hook count by the stable-arity contract above; each element must be a REAL UseFieldReturn so it spreads into the prop builders
1683
+ useField(form, path)
1684
+ )
1685
+ );
1686
+ const merged = [...new Set(fields.flatMap((field) => field.error ?? []))];
1687
+ return {
1688
+ fields,
1689
+ error: merged.length > 0 ? merged : void 0,
1690
+ firstError: merged[0],
1691
+ touched: fields.some((field) => field.touched),
1692
+ dirty: fields.some((field) => field.dirty),
1693
+ isValidating: fields.some((field) => field.isValidating),
1694
+ onBlur: () => {
1695
+ fields.forEach((field) => {
1696
+ field.onBlur();
1697
+ });
1698
+ }
1699
+ };
1700
+ }
1609
1701
  function useIsDirty(form, path) {
1610
1702
  return react$1.useStore(
1611
1703
  form.store,
@@ -1626,6 +1718,19 @@ function useIsValid(form, path) {
1626
1718
  return true;
1627
1719
  });
1628
1720
  }
1721
+ function useIsValidating(form, path) {
1722
+ return react$1.useStore(form.store, (state) => {
1723
+ if (path === void 0 || path === "") {
1724
+ if (state.isValidatingForm) return true;
1725
+ }
1726
+ for (const k in state.isValidating) {
1727
+ if (state.isValidating[k] === true && (path === void 0 || path === "" || isPathOrChild(k, path))) {
1728
+ return true;
1729
+ }
1730
+ }
1731
+ return false;
1732
+ });
1733
+ }
1629
1734
  var useIsSubmitting = (form) => react$1.useStore(form.store, (state) => state.isSubmitting);
1630
1735
  var useSubmitCount = (form) => react$1.useStore(form.store, (state) => state.submitCount);
1631
1736
  function useFormSelector(form, selector) {
@@ -1651,7 +1756,13 @@ var createFormHooks = (form, name) => {
1651
1756
  const useBoundError = () => useFormError(structural);
1652
1757
  const useBoundIsDirty = (path) => useIsDirty(structural, path);
1653
1758
  const useBoundIsValid = (path) => useIsValid(structural, path);
1759
+ const useBoundIsValidating = (path) => useIsValidating(structural, path);
1654
1760
  const useBoundIsSubmitting = () => useIsSubmitting(structural);
1761
+ const useBoundValues = () => useFormValues(structural);
1762
+ const useBoundFields = (paths) => useFields(
1763
+ structural,
1764
+ paths
1765
+ );
1655
1766
  const useBoundSubmitCount = () => useSubmitCount(structural);
1656
1767
  const prefix = capitalize(name ?? "");
1657
1768
  return {
@@ -1663,37 +1774,13 @@ var createFormHooks = (form, name) => {
1663
1774
  [`use${prefix}Error`]: useBoundError,
1664
1775
  [`use${prefix}IsDirty`]: useBoundIsDirty,
1665
1776
  [`use${prefix}IsValid`]: useBoundIsValid,
1777
+ [`use${prefix}IsValidating`]: useBoundIsValidating,
1666
1778
  [`use${prefix}IsSubmitting`]: useBoundIsSubmitting,
1667
- [`use${prefix}SubmitCount`]: useBoundSubmitCount
1779
+ [`use${prefix}SubmitCount`]: useBoundSubmitCount,
1780
+ [`use${prefix}Values`]: useBoundValues,
1781
+ [`use${prefix}Fields`]: useBoundFields
1668
1782
  };
1669
1783
  };
1670
- function useFormValues(form) {
1671
- return react$1.useStore(form.store, (state) => state.values);
1672
- }
1673
-
1674
- // src/react/useFields.ts
1675
- function useFields(form, paths) {
1676
- const fields = paths.map(
1677
- (path) => (
1678
- // eslint-disable-next-line react-hooks/rules-of-hooks -- a fixed hook count by the stable-arity contract above; each element must be a REAL UseFieldReturn so it spreads into the prop builders
1679
- useField(form, path)
1680
- )
1681
- );
1682
- const merged = [...new Set(fields.flatMap((field) => field.error ?? []))];
1683
- return {
1684
- fields,
1685
- error: merged.length > 0 ? merged : void 0,
1686
- firstError: merged[0],
1687
- touched: fields.some((field) => field.touched),
1688
- dirty: fields.some((field) => field.dirty),
1689
- isValidating: fields.some((field) => field.isValidating),
1690
- onBlur: () => {
1691
- fields.forEach((field) => {
1692
- field.onBlur();
1693
- });
1694
- }
1695
- };
1696
- }
1697
1784
 
1698
1785
  // src/react/inputProps.ts
1699
1786
  var hasFieldError = (error) => error !== void 0 && error.length > 0;
@@ -1723,6 +1810,7 @@ var numberInputProps = (field) => ({
1723
1810
  "aria-invalid": ariaInvalid(field),
1724
1811
  onChange: (e) => {
1725
1812
  const parsed = parseNumberText(e.target.value);
1813
+ if (parsed.kind === "invalid") return;
1726
1814
  field.setValue(
1727
1815
  parsed.kind === "number" ? parsed.value : field.emptyValue
1728
1816
  );
@@ -2122,7 +2210,9 @@ exports.useFormValues = useFormValues;
2122
2210
  exports.useIsDirty = useIsDirty;
2123
2211
  exports.useIsSubmitting = useIsSubmitting;
2124
2212
  exports.useIsValid = useIsValid;
2213
+ exports.useIsValidating = useIsValidating;
2125
2214
  exports.useNumberInput = useNumberInput;
2215
+ exports.usePersistForm = usePersistForm;
2126
2216
  exports.useSubmitCount = useSubmitCount;
2127
2217
  exports.useVariantField = useVariantField;
2128
2218
  exports.validateAsync = validateAsync;