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 +112 -0
- package/dist/{createForm-jlzK2lCG.d.cts → createForm-D0xLZH-Q.d.cts} +15 -5
- package/dist/{createForm-jlzK2lCG.d.ts → createForm-D0xLZH-Q.d.ts} +15 -5
- package/dist/devtools/index.d.cts +1 -1
- package/dist/devtools/index.d.ts +1 -1
- package/dist/index.cjs +124 -34
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +43 -22
- package/dist/index.d.ts +43 -22
- package/dist/index.js +124 -36
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
|
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 :
|
|
41
|
-
[K in keyof T &
|
|
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<
|
|
172
|
-
validateFieldAsync: (path: ErrorPath<z.input<TSchema>, D>) => Promise<
|
|
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
|
|
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 :
|
|
41
|
-
[K in keyof T &
|
|
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<
|
|
172
|
-
validateFieldAsync: (path: ErrorPath<z.input<TSchema>, D>) => Promise<
|
|
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-
|
|
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";
|
package/dist/devtools/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as react from 'react';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import { F as Form } from '../createForm-
|
|
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
|
-
|
|
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;
|